diff --git a/.agent/workflows/update_clawdbot.md b/.agent/workflows/update_clawdbot.md deleted file mode 100644 index 04a079aab41..00000000000 --- a/.agent/workflows/update_clawdbot.md +++ /dev/null @@ -1,380 +0,0 @@ ---- -description: Update Clawdbot from upstream when branch has diverged (ahead/behind) ---- - -# Clawdbot Upstream Sync Workflow - -Use this workflow when your fork has diverged from upstream (e.g., "18 commits ahead, 29 commits behind"). - -## Quick Reference - -```bash -# Check divergence status -git fetch upstream && git rev-list --left-right --count main...upstream/main - -# Full sync (rebase preferred) -git fetch upstream && git rebase upstream/main && pnpm install && pnpm build && ./scripts/restart-mac.sh - -# Check for Swift 6.2 issues after sync -grep -r "FileManager\.default\|Thread\.isMainThread" src/ apps/ --include="*.swift" -``` - ---- - -## Step 1: Assess Divergence - -```bash -git fetch upstream -git log --oneline --left-right main...upstream/main | head -20 -``` - -This shows: - -- `<` = your local commits (ahead) -- `>` = upstream commits you're missing (behind) - -**Decision point:** - -- Few local commits, many upstream → **Rebase** (cleaner history) -- Many local commits or shared branch → **Merge** (preserves history) - ---- - -## Step 2A: Rebase Strategy (Preferred) - -Replays your commits on top of upstream. Results in linear history. - -```bash -# Ensure working tree is clean -git status - -# Rebase onto upstream -git rebase upstream/main -``` - -### Handling Rebase Conflicts - -```bash -# When conflicts occur: -# 1. Fix conflicts in the listed files -# 2. Stage resolved files -git add - -# 3. Continue rebase -git rebase --continue - -# If a commit is no longer needed (already in upstream): -git rebase --skip - -# To abort and return to original state: -git rebase --abort -``` - -### Common Conflict Patterns - -| File | Resolution | -| ---------------- | ------------------------------------------------ | -| `package.json` | Take upstream deps, keep local scripts if needed | -| `pnpm-lock.yaml` | Accept upstream, regenerate with `pnpm install` | -| `*.patch` files | Usually take upstream version | -| Source files | Merge logic carefully, prefer upstream structure | - ---- - -## Step 2B: Merge Strategy (Alternative) - -Preserves all history with a merge commit. - -```bash -git merge upstream/main --no-edit -``` - -Resolve conflicts same as rebase, then: - -```bash -git add -git commit -``` - ---- - -## Step 3: Rebuild Everything - -After sync completes: - -```bash -# Install dependencies (regenerates lock if needed) -pnpm install - -# Build TypeScript -pnpm build - -# Build UI assets -pnpm ui:build - -# Run diagnostics -pnpm clawdbot doctor -``` - ---- - -## Step 4: Rebuild macOS App - -```bash -# Full rebuild, sign, and launch -./scripts/restart-mac.sh - -# Or just package without restart -pnpm mac:package -``` - -### Install to /Applications - -```bash -# Kill running app -pkill -x "Clawdbot" || true - -# Move old version -mv /Applications/Clawdbot.app /tmp/Clawdbot-backup.app - -# Install new build -cp -R dist/Clawdbot.app /Applications/ - -# Launch -open /Applications/Clawdbot.app -``` - ---- - -## Step 4A: Verify macOS App & Agent - -After rebuilding the macOS app, always verify it works correctly: - -```bash -# Check gateway health -pnpm clawdbot health - -# Verify no zombie processes -ps aux | grep -E "(clawdbot|gateway)" | grep -v grep - -# Test agent functionality by sending a verification message -pnpm clawdbot agent --message "Verification: macOS app rebuild successful - agent is responding." --session-id YOUR_TELEGRAM_SESSION_ID - -# Confirm the message was received on Telegram -# (Check your Telegram chat with the bot) -``` - -**Important:** Always wait for the Telegram verification message before proceeding. If the agent doesn't respond, troubleshoot the gateway or model configuration before pushing. - ---- - -## Step 5: Handle Swift/macOS Build Issues (Common After Upstream Sync) - -Upstream updates may introduce Swift 6.2 / macOS 26 SDK incompatibilities. Use analyze-mode for systematic debugging: - -### Analyze-Mode Investigation - -```bash -# Gather context with parallel agents -morph-mcp_warpgrep_codebase_search search_string="Find deprecated FileManager.default and Thread.isMainThread usages in Swift files" repo_path="/Volumes/Main SSD/Developer/clawdis" -morph-mcp_warpgrep_codebase_search search_string="Locate Peekaboo submodule and macOS app Swift files with concurrency issues" repo_path="/Volumes/Main SSD/Developer/clawdis" -``` - -### Common Swift 6.2 Fixes - -**FileManager.default Deprecation:** - -```bash -# Search for deprecated usage -grep -r "FileManager\.default" src/ apps/ --include="*.swift" - -# Replace with proper initialization -# OLD: FileManager.default -# NEW: FileManager() -``` - -**Thread.isMainThread Deprecation:** - -```bash -# Search for deprecated usage -grep -r "Thread\.isMainThread" src/ apps/ --include="*.swift" - -# Replace with modern concurrency check -# OLD: Thread.isMainThread -# NEW: await MainActor.run { ... } or DispatchQueue.main.sync { ... } -``` - -### Peekaboo Submodule Fixes - -```bash -# Check Peekaboo for concurrency issues -cd src/canvas-host/a2ui -grep -r "Thread\.isMainThread\|FileManager\.default" . --include="*.swift" - -# Fix and rebuild submodule -cd /Volumes/Main SSD/Developer/clawdis -pnpm canvas:a2ui:bundle -``` - -### macOS App Concurrency Fixes - -```bash -# Check macOS app for issues -grep -r "Thread\.isMainThread\|FileManager\.default" apps/macos/ --include="*.swift" - -# Clean and rebuild after fixes -cd apps/macos && rm -rf .build .swiftpm -./scripts/restart-mac.sh -``` - -### Model Configuration Updates - -If upstream introduced new model configurations: - -```bash -# Check for OpenRouter API key requirements -grep -r "openrouter\|OPENROUTER" src/ --include="*.ts" --include="*.js" - -# Update clawdbot.json with fallback chains -# Add model fallback configurations as needed -``` - ---- - -## Step 6: Verify & Push - -```bash -# Verify everything works -pnpm clawdbot health -pnpm test - -# Push (force required after rebase) -git push origin main --force-with-lease - -# Or regular push after merge -git push origin main -``` - ---- - -## Troubleshooting - -### Build Fails After Sync - -```bash -# Clean and rebuild -rm -rf node_modules dist -pnpm install -pnpm build -``` - -### Type Errors (Bun/Node Incompatibility) - -Common issue: `fetch.preconnect` type mismatch. Fix by using `FetchLike` type instead of `typeof fetch`. - -### macOS App Crashes on Launch - -Usually resource bundle mismatch. Full rebuild required: - -```bash -cd apps/macos && rm -rf .build .swiftpm -./scripts/restart-mac.sh -``` - -### Patch Failures - -```bash -# Check patch status -pnpm install 2>&1 | grep -i patch - -# If patches fail, they may need updating for new dep versions -# Check patches/ directory against package.json patchedDependencies -``` - -### Swift 6.2 / macOS 26 SDK Build Failures - -**Symptoms:** Build fails with deprecation warnings about `FileManager.default` or `Thread.isMainThread` - -**Search-Mode Investigation:** - -```bash -# Exhaustive search for deprecated APIs -morph-mcp_warpgrep_codebase_search search_string="Find all Swift files using deprecated FileManager.default or Thread.isMainThread" repo_path="/Volumes/Main SSD/Developer/clawdis" -``` - -**Quick Fix Commands:** - -```bash -# Find all affected files -find . -name "*.swift" -exec grep -l "FileManager\.default\|Thread\.isMainThread" {} \; - -# Replace FileManager.default with FileManager() -find . -name "*.swift" -exec sed -i '' 's/FileManager\.default/FileManager()/g' {} \; - -# For Thread.isMainThread, need manual review of each usage -grep -rn "Thread\.isMainThread" --include="*.swift" . -``` - -**Rebuild After Fixes:** - -```bash -# Clean all build artifacts -rm -rf apps/macos/.build apps/macos/.swiftpm -rm -rf src/canvas-host/a2ui/.build - -# Rebuild Peekaboo bundle -pnpm canvas:a2ui:bundle - -# Full macOS rebuild -./scripts/restart-mac.sh -``` - ---- - -## Automation Script - -Save as `scripts/sync-upstream.sh`: - -```bash -#!/usr/bin/env bash -set -euo pipefail - -echo "==> Fetching upstream..." -git fetch upstream - -echo "==> Current divergence:" -git rev-list --left-right --count main...upstream/main - -echo "==> Rebasing onto upstream/main..." -git rebase upstream/main - -echo "==> Installing dependencies..." -pnpm install - -echo "==> Building..." -pnpm build -pnpm ui:build - -echo "==> Running doctor..." -pnpm clawdbot doctor - -echo "==> Rebuilding macOS app..." -./scripts/restart-mac.sh - -echo "==> Verifying gateway health..." -pnpm clawdbot health - -echo "==> Checking for Swift 6.2 compatibility issues..." -if grep -r "FileManager\.default\|Thread\.isMainThread" src/ apps/ --include="*.swift" --quiet; then - echo "⚠️ Found potential Swift 6.2 deprecated API usage" - echo " Run manual fixes or use analyze-mode investigation" -else - echo "✅ No obvious Swift deprecation issues found" -fi - -echo "==> Testing agent functionality..." -# Note: Update YOUR_TELEGRAM_SESSION_ID with actual session ID -pnpm clawdbot agent --message "Verification: Upstream sync and macOS rebuild completed successfully." --session-id YOUR_TELEGRAM_SESSION_ID || echo "Warning: Agent test failed - check Telegram for verification message" - -echo "==> Done! Check Telegram for verification message, then run 'git push --force-with-lease' when ready." -``` diff --git a/.agents/maintainers.md b/.agents/maintainers.md deleted file mode 100644 index 2bbb9c6203e..00000000000 --- a/.agents/maintainers.md +++ /dev/null @@ -1 +0,0 @@ -Maintainer skills now live in [`openclaw/maintainers`](https://github.com/openclaw/maintainers/). diff --git a/.detect-secrets.cfg b/.detect-secrets.cfg deleted file mode 100644 index 38912567c9b..00000000000 --- a/.detect-secrets.cfg +++ /dev/null @@ -1,30 +0,0 @@ -# detect-secrets exclusion patterns (regex) -# -# Note: detect-secrets does not read this file by default. If you want these -# applied, wire them into your scan command (e.g. translate to --exclude-files -# / --exclude-lines) or into a baseline's filters_used. - -[exclude-files] -# pnpm lockfiles contain lots of high-entropy package integrity blobs. -pattern = (^|/)pnpm-lock\.yaml$ -# Generated output and vendored assets. -pattern = (^|/)(dist|vendor)/ -# Local config file with allowlist patterns. -pattern = (^|/)\.detect-secrets\.cfg$ - -[exclude-lines] -# Fastlane checks for private key marker; not a real key. -pattern = key_content\.include\?\("BEGIN PRIVATE KEY"\) -# UI label string for Anthropic auth mode. -pattern = case \.apiKeyEnv: "API key \(env var\)" -# CodingKeys mapping uses apiKey literal. -pattern = case apikey = "apiKey" -# Schema labels referencing password fields (not actual secrets). -pattern = "gateway\.remote\.password" -pattern = "gateway\.auth\.password" -# Schema label for talk API key (label text only). -pattern = "talk\.apiKey" -# checking for typeof is not something we care about. -pattern = === "string" -# specific optional-chaining password check that didn't match the line above. -pattern = typeof remote\?\.password === "string" diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 73d00fff147..00000000000 --- a/.dockerignore +++ /dev/null @@ -1,60 +0,0 @@ -.git -.worktrees -.bun-cache -.bun -.tmp -**/.tmp -.DS_Store -**/.DS_Store -*.png -*.jpg -*.jpeg -*.webp -*.gif -*.mp4 -*.mov -*.wav -*.mp3 -node_modules -**/node_modules -.pnpm-store -**/.pnpm-store -.turbo -**/.turbo -.cache -**/.cache -.next -**/.next -coverage -**/coverage -*.log -tmp -**/tmp - -# build artifacts -dist -**/dist -apps/macos/.build -apps/ios/build -**/*.trace - -# large app trees not needed for CLI build -apps/ -assets/ -Peekaboo/ -Swabble/ -Core/ -Users/ -vendor/ - -# Needed for building the Canvas A2UI bundle during Docker image builds. -# Keep the rest of apps/ and vendor/ excluded to avoid a large build context. -!apps/shared/ -!apps/shared/OpenClawKit/ -!apps/shared/OpenClawKit/Tools/ -!apps/shared/OpenClawKit/Tools/CanvasA2UI/ -!apps/shared/OpenClawKit/Tools/CanvasA2UI/** -!vendor/a2ui/ -!vendor/a2ui/renderers/ -!vendor/a2ui/renderers/lit/ -!vendor/a2ui/renderers/lit/** diff --git a/.env.example b/.env.example deleted file mode 100644 index 41df435b8f9..00000000000 --- a/.env.example +++ /dev/null @@ -1,80 +0,0 @@ -# OpenClaw .env example -# -# Quick start: -# 1) Copy this file to `.env` (for local runs from this repo), OR to `~/.openclaw/.env` (for launchd/systemd daemons). -# 2) Fill only the values you use. -# 3) Keep real secrets out of git. -# -# Env-source precedence for environment variables (highest -> lowest): -# process env, ./.env, ~/.openclaw/.env, then openclaw.json `env` block. -# Existing non-empty process env vars are not overridden by dotenv/config env loading. -# Note: direct config keys (for example `gateway.auth.token` or channel tokens in openclaw.json) -# are resolved separately from env loading and often take precedence over env fallbacks. - -# ----------------------------------------------------------------------------- -# Gateway auth + paths -# ----------------------------------------------------------------------------- -# Recommended if the gateway binds beyond loopback. -OPENCLAW_GATEWAY_TOKEN=change-me-to-a-long-random-token -# Example generator: openssl rand -hex 32 - -# Optional alternative auth mode (use token OR password). -# OPENCLAW_GATEWAY_PASSWORD=change-me-to-a-strong-password - -# Optional path overrides (defaults shown for reference). -# OPENCLAW_STATE_DIR=~/.openclaw -# OPENCLAW_CONFIG_PATH=~/.openclaw/openclaw.json -# OPENCLAW_HOME=~ - -# Optional: import missing keys from your login shell profile. -# OPENCLAW_LOAD_SHELL_ENV=1 -# OPENCLAW_SHELL_ENV_TIMEOUT_MS=15000 - -# ----------------------------------------------------------------------------- -# Model provider API keys (set at least one) -# ----------------------------------------------------------------------------- -# OPENAI_API_KEY=sk-... -# ANTHROPIC_API_KEY=sk-ant-... -# GEMINI_API_KEY=... -# OPENROUTER_API_KEY=sk-or-... -# OPENCLAW_LIVE_OPENAI_KEY=sk-... -# OPENCLAW_LIVE_ANTHROPIC_KEY=sk-ant-... -# OPENCLAW_LIVE_GEMINI_KEY=... -# OPENAI_API_KEY_1=... -# ANTHROPIC_API_KEY_1=... -# GEMINI_API_KEY_1=... -# GOOGLE_API_KEY=... -# OPENAI_API_KEYS=sk-1,sk-2 -# ANTHROPIC_API_KEYS=sk-ant-1,sk-ant-2 -# GEMINI_API_KEYS=key-1,key-2 - -# Optional additional providers -# ZAI_API_KEY=... -# AI_GATEWAY_API_KEY=... -# MINIMAX_API_KEY=... -# SYNTHETIC_API_KEY=... - -# ----------------------------------------------------------------------------- -# Channels (only set what you enable) -# ----------------------------------------------------------------------------- -# TELEGRAM_BOT_TOKEN=123456:ABCDEF... -# DISCORD_BOT_TOKEN=... -# SLACK_BOT_TOKEN=xoxb-... -# SLACK_APP_TOKEN=xapp-... - -# Optional channel env fallbacks -# MATTERMOST_BOT_TOKEN=... -# MATTERMOST_URL=https://chat.example.com -# ZALO_BOT_TOKEN=... -# OPENCLAW_TWITCH_ACCESS_TOKEN=oauth:... - -# ----------------------------------------------------------------------------- -# Tools + voice/media (optional) -# ----------------------------------------------------------------------------- -# BRAVE_API_KEY=... -# PERPLEXITY_API_KEY=pplx-... -# FIRECRAWL_API_KEY=... - -# ELEVENLABS_API_KEY=... -# XI_API_KEY=... # alias for ElevenLabs -# DEEPGRAM_API_KEY=... diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 6313b56c578..00000000000 --- a/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -* text=auto eol=lf diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index 082086ea079..00000000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1 +0,0 @@ -custom: ["https://github.com/sponsors/steipete"] diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml deleted file mode 100644 index 927aa7079cf..00000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ /dev/null @@ -1,95 +0,0 @@ -name: Bug report -description: Report a defect or unexpected behavior in OpenClaw. -title: "[Bug]: " -labels: - - bug -body: - - type: markdown - attributes: - value: | - Thanks for filing this report. Keep it concise, reproducible, and evidence-based. - - type: textarea - id: summary - attributes: - label: Summary - description: One-sentence statement of what is broken. - placeholder: After upgrading to , behavior regressed from . - validations: - required: true - - type: textarea - id: repro - attributes: - label: Steps to reproduce - description: Provide the shortest deterministic repro path. - placeholder: | - 1. Configure channel X. - 2. Send message Y. - 3. Run command Z. - validations: - required: true - - type: textarea - id: expected - attributes: - label: Expected behavior - description: What should happen if the bug does not exist. - placeholder: Agent posts a reply in the same thread. - validations: - required: true - - type: textarea - id: actual - attributes: - label: Actual behavior - description: What happened instead, including user-visible errors. - placeholder: No reply is posted; gateway logs "reply target not found". - validations: - required: true - - type: input - id: version - attributes: - label: OpenClaw version - description: Exact version/build tested. - placeholder: - validations: - required: true - - type: input - id: os - attributes: - label: Operating system - description: OS and version where this occurs. - placeholder: macOS 15.4 / Ubuntu 24.04 / Windows 11 - validations: - required: true - - type: input - id: install_method - attributes: - label: Install method - description: How OpenClaw was installed or launched. - placeholder: npm global / pnpm dev / docker / mac app - - type: textarea - id: logs - attributes: - label: Logs, screenshots, and evidence - description: Include redacted logs/screenshots/recordings that prove the behavior. - render: shell - - type: textarea - id: impact - attributes: - label: Impact and severity - description: | - Explain who is affected, how severe it is, how often it happens, and the practical consequence. - Include: - - Affected users/systems/channels - - Severity (annoying, blocks workflow, data risk, etc.) - - Frequency (always/intermittent/edge case) - - Consequence (missed messages, failed onboarding, extra cost, etc.) - placeholder: | - Affected: Telegram group users on - Severity: High (blocks replies) - Frequency: 100% repro - Consequence: Agents cannot respond in threads - - type: textarea - id: additional_information - attributes: - label: Additional information - description: Add any context that helps triage but does not fit above. - placeholder: Regression started after upgrade from ; temporary workaround is ... diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml deleted file mode 100644 index 4c1b9775597..00000000000 --- a/.github/ISSUE_TEMPLATE/config.yml +++ /dev/null @@ -1,8 +0,0 @@ -blank_issues_enabled: false -contact_links: - - name: Onboarding - url: https://discord.gg/clawd - about: "New to OpenClaw? Join Discord for setup guidance in #help." - - name: Support - url: https://discord.gg/clawd - about: "Get help from the OpenClaw community on Discord in #help." diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml deleted file mode 100644 index a08b456786e..00000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ /dev/null @@ -1,70 +0,0 @@ -name: Feature request -description: Propose a new capability or product improvement. -title: "[Feature]: " -labels: - - enhancement -body: - - type: markdown - attributes: - value: | - Help us evaluate this request with concrete use cases and tradeoffs. - - type: textarea - id: summary - attributes: - label: Summary - description: One-line statement of the requested capability. - placeholder: Add per-channel default response prefix. - validations: - required: true - - type: textarea - id: problem - attributes: - label: Problem to solve - description: What user pain this solves and why current behavior is insufficient. - placeholder: Agents cannot distinguish persona context in mixed channels, causing misrouted follow-ups. - validations: - required: true - - type: textarea - id: proposed_solution - attributes: - label: Proposed solution - description: Desired behavior/API/UX with as much specificity as possible. - placeholder: Support channels..responsePrefix with default fallback and account-level override. - validations: - required: true - - type: textarea - id: alternatives - attributes: - label: Alternatives considered - description: Other approaches considered and why they are weaker. - placeholder: Manual prefixing in prompts is inconsistent and hard to enforce. - - type: textarea - id: impact - attributes: - label: Impact - description: | - Explain who is affected, severity/urgency, how often this pain occurs, and practical consequences. - Include: - - Affected users/systems/channels - - Severity (annoying, blocks workflow, etc.) - - Frequency (always/intermittent/edge case) - - Consequence (delays, errors, extra manual work, etc.) - placeholder: | - Affected: Multi-team shared channels - Severity: Medium - Frequency: Daily - Consequence: +20 minutes/day/operator and delayed alerts - validations: - required: true - - type: textarea - id: evidence - attributes: - label: Evidence/examples - description: Prior art, links, screenshots, logs, or metrics. - placeholder: Comparable behavior in X, sample config, and screenshot of current limitation. - - type: textarea - id: additional_information - attributes: - label: Additional information - description: Extra context, constraints, or references not covered above. - placeholder: Must remain backward-compatible with existing config keys. diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml deleted file mode 100644 index f02fbddb3e8..00000000000 --- a/.github/actionlint.yaml +++ /dev/null @@ -1,22 +0,0 @@ -# actionlint configuration -# https://github.com/rhysd/actionlint/blob/main/docs/config.md - -self-hosted-runner: - labels: - # Blacksmith CI runners - - blacksmith-8vcpu-ubuntu-2404 - - blacksmith-8vcpu-windows-2025 - - blacksmith-16vcpu-ubuntu-2404 - - blacksmith-16vcpu-windows-2025 - - blacksmith-16vcpu-ubuntu-2404-arm - -# Ignore patterns for known issues -paths: - .github/workflows/**/*.yml: - ignore: - # Ignore shellcheck warnings (we run shellcheck separately) - - "shellcheck reported issue.+" - # Ignore intentional if: false for disabled jobs - - 'constant expression "false" in condition' - # actionlint's built-in runner label allowlist lags Blacksmith additions. - - 'label "blacksmith-16vcpu-[^"]+" is unknown\.' diff --git a/.github/actions/detect-docs-changes/action.yml b/.github/actions/detect-docs-changes/action.yml deleted file mode 100644 index 853442a7783..00000000000 --- a/.github/actions/detect-docs-changes/action.yml +++ /dev/null @@ -1,53 +0,0 @@ -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 }} - docs_changed: - description: "'true' if any changed file is under docs/ or is markdown" - value: ${{ steps.check.outputs.docs_changed }} - -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/ 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" - echo "docs_changed=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # Check if any changed file is a doc - DOCS=$(echo "$CHANGED" | grep -E '^docs/|\.md$|\.mdx$' || true) - if [ -n "$DOCS" ]; then - echo "docs_changed=true" >> "$GITHUB_OUTPUT" - else - echo "docs_changed=false" >> "$GITHUB_OUTPUT" - fi - - # 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 diff --git a/.github/actions/setup-node-env/action.yml b/.github/actions/setup-node-env/action.yml deleted file mode 100644 index 334cd3c24fb..00000000000 --- a/.github/actions/setup-node-env/action.yml +++ /dev/null @@ -1,98 +0,0 @@ -name: Setup Node environment -description: > - Initialize submodules with retry, install Node 22, pnpm, optionally Bun, - and run pnpm install. Requires actions/checkout to run first. -inputs: - node-version: - description: Node.js version to install. - required: false - default: "22.x" - pnpm-version: - description: pnpm version for corepack. - required: false - default: "10.23.0" - install-bun: - description: Whether to install Bun alongside Node. - required: false - default: "true" - frozen-lockfile: - description: Whether to use --frozen-lockfile for install. - required: false - default: "true" -runs: - using: composite - steps: - - name: Checkout submodules (retry) - shell: bash - run: | - set -euo pipefail - git submodule sync --recursive - for attempt in 1 2 3 4 5; do - if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then - exit 0 - fi - echo "Submodule update failed (attempt $attempt/5). Retrying…" - sleep $((attempt * 10)) - done - exit 1 - - - name: Setup Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version: ${{ inputs.node-version }} - check-latest: true - - - name: Setup pnpm + cache store - uses: ./.github/actions/setup-pnpm-store-cache - with: - pnpm-version: ${{ inputs.pnpm-version }} - cache-key-suffix: "node22" - - - name: Setup Bun - if: inputs.install-bun == 'true' - uses: oven-sh/setup-bun@v2 - with: - bun-version: "1.3.9+cf6cdbbba" - - - name: Runtime versions - shell: bash - run: | - node -v - npm -v - pnpm -v - if command -v bun &>/dev/null; then bun -v; fi - - - name: Capture node path - shell: bash - run: echo "NODE_BIN=$(dirname "$(node -p "process.execPath")")" >> "$GITHUB_ENV" - - - name: Install dependencies - shell: bash - env: - CI: "true" - FROZEN_LOCKFILE: ${{ inputs.frozen-lockfile }} - run: | - set -euo pipefail - export PATH="$NODE_BIN:$PATH" - which node - node -v - pnpm -v - case "$FROZEN_LOCKFILE" in - true) LOCKFILE_FLAG="--frozen-lockfile" ;; - false) LOCKFILE_FLAG="" ;; - *) - echo "::error::Invalid frozen-lockfile input: '$FROZEN_LOCKFILE' (expected true or false)" - exit 2 - ;; - esac - - install_args=( - install - --ignore-scripts=false - --config.engine-strict=false - --config.enable-pre-post-scripts=true - ) - if [ -n "$LOCKFILE_FLAG" ]; then - install_args+=("$LOCKFILE_FLAG") - fi - pnpm "${install_args[@]}" || pnpm "${install_args[@]}" diff --git a/.github/actions/setup-pnpm-store-cache/action.yml b/.github/actions/setup-pnpm-store-cache/action.yml deleted file mode 100644 index 8e25492ac92..00000000000 --- a/.github/actions/setup-pnpm-store-cache/action.yml +++ /dev/null @@ -1,47 +0,0 @@ -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 - env: - PNPM_VERSION: ${{ inputs.pnpm-version }} - run: | - set -euo pipefail - if [[ ! "$PNPM_VERSION" =~ ^[0-9]+(\.[0-9]+){1,2}([.-][0-9A-Za-z.-]+)?$ ]]; then - echo "::error::Invalid pnpm-version input: '$PNPM_VERSION'" - exit 2 - fi - corepack enable - for attempt in 1 2 3; do - if corepack prepare "pnpm@$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 }}- diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index bfdac1a9c3f..00000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,65 +0,0 @@ -# Dependabot configuration -# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file - -version: 2 - -registries: - npm-npmjs: - type: npm-registry - url: https://registry.npmjs.org - replaces-base: true - -updates: - # npm dependencies (root) - - package-ecosystem: npm - directory: / - schedule: - interval: weekly - cooldown: - default-days: 7 - groups: - production: - dependency-type: production - update-types: - - minor - - patch - development: - dependency-type: development - update-types: - - minor - - patch - open-pull-requests-limit: 10 - registries: - - npm-npmjs - - # GitHub Actions - - package-ecosystem: github-actions - directory: / - schedule: - interval: weekly - cooldown: - default-days: 7 - groups: - actions: - patterns: - - "*" - update-types: - - minor - - patch - open-pull-requests-limit: 5 - - # Docker base images (root Dockerfiles) - - package-ecosystem: docker - directory: / - schedule: - interval: weekly - cooldown: - default-days: 7 - groups: - docker-images: - patterns: - - "*" - update-types: - - minor - - patch - open-pull-requests-limit: 5 diff --git a/.github/instructions/copilot.instructions.md b/.github/instructions/copilot.instructions.md deleted file mode 100644 index 8686521cfc7..00000000000 --- a/.github/instructions/copilot.instructions.md +++ /dev/null @@ -1,64 +0,0 @@ -# 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. diff --git a/.github/labeler.yml b/.github/labeler.yml deleted file mode 100644 index 329e34e4f14..00000000000 --- a/.github/labeler.yml +++ /dev/null @@ -1,258 +0,0 @@ -"channel: bluebubbles": - - changed-files: - - any-glob-to-any-file: - - "extensions/bluebubbles/**" - - "docs/channels/bluebubbles.md" -"channel: discord": - - changed-files: - - any-glob-to-any-file: - - "src/discord/**" - - "extensions/discord/**" - - "docs/channels/discord.md" -"channel: irc": - - changed-files: - - any-glob-to-any-file: - - "extensions/irc/**" - - "docs/channels/irc.md" -"channel: feishu": - - changed-files: - - any-glob-to-any-file: - - "src/feishu/**" - - "extensions/feishu/**" - - "docs/channels/feishu.md" -"channel: googlechat": - - changed-files: - - any-glob-to-any-file: - - "extensions/googlechat/**" - - "docs/channels/googlechat.md" -"channel: imessage": - - changed-files: - - any-glob-to-any-file: - - "src/imessage/**" - - "extensions/imessage/**" - - "docs/channels/imessage.md" -"channel: line": - - changed-files: - - any-glob-to-any-file: - - "extensions/line/**" - - "docs/channels/line.md" -"channel: matrix": - - changed-files: - - any-glob-to-any-file: - - "extensions/matrix/**" - - "docs/channels/matrix.md" -"channel: mattermost": - - changed-files: - - any-glob-to-any-file: - - "extensions/mattermost/**" - - "docs/channels/mattermost.md" -"channel: msteams": - - changed-files: - - any-glob-to-any-file: - - "extensions/msteams/**" - - "docs/channels/msteams.md" -"channel: nextcloud-talk": - - changed-files: - - any-glob-to-any-file: - - "extensions/nextcloud-talk/**" - - "docs/channels/nextcloud-talk.md" -"channel: nostr": - - changed-files: - - any-glob-to-any-file: - - "extensions/nostr/**" - - "docs/channels/nostr.md" -"channel: signal": - - changed-files: - - any-glob-to-any-file: - - "src/signal/**" - - "extensions/signal/**" - - "docs/channels/signal.md" -"channel: slack": - - changed-files: - - any-glob-to-any-file: - - "src/slack/**" - - "extensions/slack/**" - - "docs/channels/slack.md" -"channel: telegram": - - changed-files: - - any-glob-to-any-file: - - "src/telegram/**" - - "extensions/telegram/**" - - "docs/channels/telegram.md" -"channel: tlon": - - changed-files: - - any-glob-to-any-file: - - "extensions/tlon/**" - - "docs/channels/tlon.md" -"channel: twitch": - - changed-files: - - any-glob-to-any-file: - - "extensions/twitch/**" - - "docs/channels/twitch.md" -"channel: voice-call": - - changed-files: - - any-glob-to-any-file: - - "extensions/voice-call/**" -"channel: whatsapp-web": - - changed-files: - - any-glob-to-any-file: - - "src/web/**" - - "extensions/whatsapp/**" - - "docs/channels/whatsapp.md" -"channel: zalo": - - changed-files: - - any-glob-to-any-file: - - "extensions/zalo/**" - - "docs/channels/zalo.md" -"channel: zalouser": - - changed-files: - - any-glob-to-any-file: - - "extensions/zalouser/**" - - "docs/channels/zalouser.md" - -"app: android": - - changed-files: - - any-glob-to-any-file: - - "apps/android/**" - - "docs/platforms/android.md" -"app: ios": - - changed-files: - - any-glob-to-any-file: - - "apps/ios/**" - - "docs/platforms/ios.md" -"app: macos": - - changed-files: - - any-glob-to-any-file: - - "apps/macos/**" - - "docs/platforms/macos.md" - - "docs/platforms/mac/**" -"app: web-ui": - - changed-files: - - any-glob-to-any-file: - - "ui/**" - - "src/gateway/control-ui.ts" - - "src/gateway/control-ui-shared.ts" - - "src/gateway/protocol/**" - - "src/gateway/server-methods/chat.ts" - - "src/infra/control-ui-assets.ts" - -"gateway": - - changed-files: - - any-glob-to-any-file: - - "src/gateway/**" - - "src/daemon/**" - - "docs/gateway/**" - -"docs": - - changed-files: - - any-glob-to-any-file: - - "docs/**" - - "docs.acp.md" - -"cli": - - changed-files: - - any-glob-to-any-file: - - "src/cli/**" - -"commands": - - changed-files: - - any-glob-to-any-file: - - "src/commands/**" - -"scripts": - - changed-files: - - any-glob-to-any-file: - - "scripts/**" - -"docker": - - changed-files: - - any-glob-to-any-file: - - "Dockerfile" - - "Dockerfile.*" - - "docker-compose.yml" - - "docker-setup.sh" - - ".dockerignore" - - "scripts/**/*docker*" - - "scripts/**/Dockerfile*" - - "scripts/sandbox-*.sh" - - "src/agents/sandbox*.ts" - - "src/commands/sandbox*.ts" - - "src/cli/sandbox-cli.ts" - - "src/docker-setup.test.ts" - - "src/config/**/*sandbox*" - - "docs/cli/sandbox.md" - - "docs/gateway/sandbox*.md" - - "docs/install/docker.md" - - "docs/multi-agent-sandbox-tools.md" - -"agents": - - changed-files: - - any-glob-to-any-file: - - "src/agents/**" - -"security": - - changed-files: - - any-glob-to-any-file: - - "docs/cli/security.md" - - "docs/gateway/security.md" - -"extensions: copilot-proxy": - - changed-files: - - any-glob-to-any-file: - - "extensions/copilot-proxy/**" -"extensions: diagnostics-otel": - - changed-files: - - any-glob-to-any-file: - - "extensions/diagnostics-otel/**" -"extensions: google-antigravity-auth": - - changed-files: - - any-glob-to-any-file: - - "extensions/google-antigravity-auth/**" -"extensions: google-gemini-cli-auth": - - changed-files: - - any-glob-to-any-file: - - "extensions/google-gemini-cli-auth/**" -"extensions: denchclaw-auth": - - changed-files: - - any-glob-to-any-file: - - "extensions/denchclaw-auth/**" -"extensions: llm-task": - - changed-files: - - any-glob-to-any-file: - - "extensions/llm-task/**" -"extensions: lobster": - - changed-files: - - any-glob-to-any-file: - - "extensions/lobster/**" -"extensions: memory-core": - - changed-files: - - any-glob-to-any-file: - - "extensions/memory-core/**" -"extensions: memory-lancedb": - - changed-files: - - any-glob-to-any-file: - - "extensions/memory-lancedb/**" -"extensions: open-prose": - - changed-files: - - any-glob-to-any-file: - - "extensions/open-prose/**" -"extensions: qwen-portal-auth": - - changed-files: - - any-glob-to-any-file: - - "extensions/qwen-portal-auth/**" -"extensions: device-pair": - - changed-files: - - any-glob-to-any-file: - - "extensions/device-pair/**" -"extensions: minimax-portal-auth": - - changed-files: - - any-glob-to-any-file: - - "extensions/minimax-portal-auth/**" -"extensions: phone-control": - - changed-files: - - any-glob-to-any-file: - - "extensions/phone-control/**" -"extensions: talk-voice": - - changed-files: - - any-glob-to-any-file: - - "extensions/talk-voice/**" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md deleted file mode 100644 index 9b0e7f8dc4b..00000000000 --- a/.github/pull_request_template.md +++ /dev/null @@ -1,108 +0,0 @@ -## Summary - -Describe the problem and fix in 2–5 bullets: - -- Problem: -- Why it matters: -- What changed: -- What did NOT change (scope boundary): - -## Change Type (select all) - -- [ ] Bug fix -- [ ] Feature -- [ ] Refactor -- [ ] Docs -- [ ] Security hardening -- [ ] Chore/infra - -## Scope (select all touched areas) - -- [ ] Gateway / orchestration -- [ ] Skills / tool execution -- [ ] Auth / tokens -- [ ] Memory / storage -- [ ] Integrations -- [ ] API / contracts -- [ ] UI / DX -- [ ] CI/CD / infra - -## Linked Issue/PR - -- Closes # -- Related # - -## User-visible / Behavior Changes - -List user-visible changes (including defaults/config). -If none, write `None`. - -## Security Impact (required) - -- New permissions/capabilities? (`Yes/No`) -- Secrets/tokens handling changed? (`Yes/No`) -- New/changed network calls? (`Yes/No`) -- Command/tool execution surface changed? (`Yes/No`) -- Data access scope changed? (`Yes/No`) -- If any `Yes`, explain risk + mitigation: - -## Repro + Verification - -### Environment - -- OS: -- Runtime/container: -- Model/provider: -- Integration/channel (if any): -- Relevant config (redacted): - -### Steps - -1. -2. -3. - -### Expected - -- - -### Actual - -- - -## Evidence - -Attach at least one: - -- [ ] Failing test/log before + passing after -- [ ] Trace/log snippets -- [ ] Screenshot/recording -- [ ] Perf numbers (if relevant) - -## Human Verification (required) - -What you personally verified (not just CI), and how: - -- Verified scenarios: -- Edge cases checked: -- What you did **not** verify: - -## Compatibility / Migration - -- Backward compatible? (`Yes/No`) -- Config/env changes? (`Yes/No`) -- Migration needed? (`Yes/No`) -- If yes, exact upgrade steps: - -## Failure Recovery (if this breaks) - -- How to disable/revert this change quickly: -- Files/config to restore: -- Known bad symptoms reviewers should watch for: - -## Risks and Mitigations - -List only real risks for this PR. Add/remove entries as needed. If none, write `None`. - -- Risk: - - Mitigation: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 4f20ec2242a..00000000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,65 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - -concurrency: - group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - -jobs: - # check: - # name: "check" - # runs-on: ubuntu-latest - # steps: - # - name: Checkout - # uses: actions/checkout@v4 - # with: - # submodules: false - # - # - name: Setup Node environment - # uses: ./.github/actions/setup-node-env - # - # - name: Check types and lint and oxfmt - # run: pnpm check - - test: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - runtime: node - command: pnpm canvas:a2ui:bundle && pnpm test - - runtime: bootstrap - command: pnpm vitest run --config src/cli/vitest.config.ts src/cli/profile.test.ts src/cli/bootstrap-external.test.ts src/cli/bootstrap-external.bootstrap-command.test.ts - - runtime: bun - command: pnpm canvas:a2ui:bundle && bunx vitest run --config vitest.unit.config.ts - steps: - - name: Skip bun lane on push - if: github.event_name == 'push' && matrix.runtime == 'bun' - run: echo "Skipping bun test lane on push events." - - - name: Checkout - if: github.event_name != 'push' || matrix.runtime != 'bun' - uses: actions/checkout@v4 - with: - submodules: false - - - name: Setup Node environment - if: matrix.runtime != 'bun' || github.event_name != 'push' - uses: ./.github/actions/setup-node-env - with: - install-bun: "${{ matrix.runtime == 'bun' }}" - - - name: Configure Node test resources - if: matrix.runtime == 'node' - run: | - echo "OPENCLAW_TEST_WORKERS=2" >> "$GITHUB_ENV" - echo "OPENCLAW_TEST_MAX_OLD_SPACE_SIZE_MB=8192" >> "$GITHUB_ENV" - - - name: Run tests (${{ matrix.runtime }}) - if: github.event_name != 'push' || matrix.runtime != 'bun' - run: ${{ matrix.command }} diff --git a/.github/workflows/workflow-sanity.yml b/.github/workflows/workflow-sanity.yml deleted file mode 100644 index 19668e697ad..00000000000 --- a/.github/workflows/workflow-sanity.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: Workflow Sanity - -on: - pull_request: - push: - branches: [main] - -concurrency: - group: workflow-sanity-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - -jobs: - no-tabs: - runs-on: blacksmith-16vcpu-ubuntu-2404 - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Fail on tabs in workflow files - run: | - python - <<'PY' - from __future__ import annotations - - import pathlib - import sys - - root = pathlib.Path(".github/workflows") - bad: list[str] = [] - for path in sorted(root.rglob("*.yml")): - if b"\t" in path.read_bytes(): - bad.append(str(path)) - - for path in sorted(root.rglob("*.yaml")): - if b"\t" in path.read_bytes(): - bad.append(str(path)) - - if bad: - print("Tabs found in workflow file(s):") - for path in bad: - print(f"- {path}") - sys.exit(1) - PY - - actionlint: - runs-on: blacksmith-16vcpu-ubuntu-2404 - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install actionlint - shell: bash - run: | - set -euo pipefail - ACTIONLINT_VERSION="1.7.11" - archive="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" - base_url="https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}" - curl -sSfL -o "${archive}" "${base_url}/${archive}" - curl -sSfL -o checksums.txt "${base_url}/actionlint_${ACTIONLINT_VERSION}_checksums.txt" - grep " ${archive}\$" checksums.txt | sha256sum -c - - tar -xzf "${archive}" actionlint - sudo install -m 0755 actionlint /usr/local/bin/actionlint - - - name: Lint workflows - run: actionlint - - - name: Disallow direct inputs interpolation in composite run blocks - run: python3 scripts/check-composite-action-input-interpolation.py diff --git a/.gitignore b/.gitignore index 3323db84fe6..9c23dc593b2 100644 --- a/.gitignore +++ b/.gitignore @@ -103,7 +103,10 @@ package-lock.json # Local iOS signing overrides apps/ios/LocalSigning.xcconfig + # Generated protocol schema (produced via pnpm protocol:gen) dist/protocol.schema.json .cursor/skills/ + +.npmrc.deploy diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc deleted file mode 100644 index 94035711053..00000000000 --- a/.markdownlint-cli2.jsonc +++ /dev/null @@ -1,52 +0,0 @@ -{ - "globs": ["docs/**/*.md", "docs/**/*.mdx", "README.md"], - "ignores": ["docs/zh-CN/**", "docs/.i18n/**", "docs/reference/templates/**", "**/.local/**"], - "config": { - "default": true, - - "MD013": false, - "MD025": false, - "MD029": false, - - "MD033": { - "allowed_elements": [ - "Note", - "Info", - "Tip", - "Warning", - "Card", - "CardGroup", - "Columns", - "Steps", - "Step", - "Tabs", - "Tab", - "Accordion", - "AccordionGroup", - "CodeGroup", - "Frame", - "Callout", - "ParamField", - "ResponseField", - "RequestExample", - "ResponseExample", - "img", - "a", - "br", - "details", - "summary", - "p", - "strong", - "picture", - "source", - "Tooltip", - "Check", - ], - }, - - "MD036": false, - "MD040": false, - "MD041": false, - "MD046": false, - }, -} diff --git a/.pi/extensions/diff.ts b/.pi/extensions/diff.ts deleted file mode 100644 index 037fa240afb..00000000000 --- a/.pi/extensions/diff.ts +++ /dev/null @@ -1,195 +0,0 @@ -/** - * Diff Extension - * - * /diff command shows modified/deleted/new files from git status and opens - * the selected file in VS Code's diff view. - */ - -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { DynamicBorder } from "@mariozechner/pi-coding-agent"; -import { - Container, - Key, - matchesKey, - type SelectItem, - SelectList, - Text, -} from "@mariozechner/pi-tui"; - -interface FileInfo { - status: string; - statusLabel: string; - file: string; -} - -export default function (pi: ExtensionAPI) { - pi.registerCommand("diff", { - description: "Show git changes and open in VS Code diff view", - handler: async (_args, ctx) => { - if (!ctx.hasUI) { - ctx.ui.notify("No UI available", "error"); - return; - } - - // Get changed files from git status - const result = await pi.exec("git", ["status", "--porcelain"], { cwd: ctx.cwd }); - - if (result.code !== 0) { - ctx.ui.notify(`git status failed: ${result.stderr}`, "error"); - return; - } - - if (!result.stdout || !result.stdout.trim()) { - ctx.ui.notify("No changes in working tree", "info"); - return; - } - - // Parse git status output - // Format: XY filename (where XY is two-letter status, then space, then filename) - const lines = result.stdout.split("\n"); - const files: FileInfo[] = []; - - for (const line of lines) { - if (line.length < 4) { - continue; - } // Need at least "XY f" - - const status = line.slice(0, 2); - const file = line.slice(2).trimStart(); - - // Translate status codes to short labels - let statusLabel: string; - if (status.includes("M")) { - statusLabel = "M"; - } else if (status.includes("A")) { - statusLabel = "A"; - } else if (status.includes("D")) { - statusLabel = "D"; - } else if (status.includes("?")) { - statusLabel = "?"; - } else if (status.includes("R")) { - statusLabel = "R"; - } else if (status.includes("C")) { - statusLabel = "C"; - } else { - statusLabel = status.trim() || "~"; - } - - files.push({ status: statusLabel, statusLabel, file }); - } - - if (files.length === 0) { - ctx.ui.notify("No changes found", "info"); - return; - } - - const openSelected = async (fileInfo: FileInfo): Promise => { - try { - // Open in VS Code diff view. - // For untracked files, git difftool won't work, so fall back to just opening the file. - if (fileInfo.status === "?") { - await pi.exec("code", ["-g", fileInfo.file], { cwd: ctx.cwd }); - return; - } - - const diffResult = await pi.exec( - "git", - ["difftool", "-y", "--tool=vscode", fileInfo.file], - { - cwd: ctx.cwd, - }, - ); - if (diffResult.code !== 0) { - await pi.exec("code", ["-g", fileInfo.file], { cwd: ctx.cwd }); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - ctx.ui.notify(`Failed to open ${fileInfo.file}: ${message}`, "error"); - } - }; - - // Show file picker with SelectList - await ctx.ui.custom((tui, theme, _kb, done) => { - const container = new Container(); - - // Top border - container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); - - // Title - container.addChild(new Text(theme.fg("accent", theme.bold(" Select file to diff")), 0, 0)); - - // Build select items with colored status - const items: SelectItem[] = files.map((f) => { - let statusColor: string; - switch (f.status) { - case "M": - statusColor = theme.fg("warning", f.status); - break; - case "A": - statusColor = theme.fg("success", f.status); - break; - case "D": - statusColor = theme.fg("error", f.status); - break; - case "?": - statusColor = theme.fg("muted", f.status); - break; - default: - statusColor = theme.fg("dim", f.status); - } - return { - value: f, - label: `${statusColor} ${f.file}`, - }; - }); - - const visibleRows = Math.min(files.length, 15); - let currentIndex = 0; - - const selectList = new SelectList(items, visibleRows, { - selectedPrefix: (t) => theme.fg("accent", t), - selectedText: (t) => t, // Keep existing colors - description: (t) => theme.fg("muted", t), - scrollInfo: (t) => theme.fg("dim", t), - noMatch: (t) => theme.fg("warning", t), - }); - selectList.onSelect = (item) => { - void openSelected(item.value as FileInfo); - }; - selectList.onCancel = () => done(); - selectList.onSelectionChange = (item) => { - currentIndex = items.indexOf(item); - }; - container.addChild(selectList); - - // Help text - container.addChild( - new Text(theme.fg("dim", " ↑↓ navigate • ←→ page • enter open • esc close"), 0, 0), - ); - - // Bottom border - container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); - - return { - render: (w) => container.render(w), - invalidate: () => container.invalidate(), - handleInput: (data) => { - // Add paging with left/right - if (matchesKey(data, Key.left)) { - // Page up - clamp to 0 - currentIndex = Math.max(0, currentIndex - visibleRows); - selectList.setSelectedIndex(currentIndex); - } else if (matchesKey(data, Key.right)) { - // Page down - clamp to last - currentIndex = Math.min(items.length - 1, currentIndex + visibleRows); - selectList.setSelectedIndex(currentIndex); - } else { - selectList.handleInput(data); - } - tui.requestRender(); - }, - }; - }); - }, - }); -} diff --git a/.pi/extensions/files.ts b/.pi/extensions/files.ts deleted file mode 100644 index bba2760d032..00000000000 --- a/.pi/extensions/files.ts +++ /dev/null @@ -1,194 +0,0 @@ -/** - * Files Extension - * - * /files command lists all files the model has read/written/edited in the active session branch, - * coalesced by path and sorted newest first. Selecting a file opens it in VS Code. - */ - -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { DynamicBorder } from "@mariozechner/pi-coding-agent"; -import { - Container, - Key, - matchesKey, - type SelectItem, - SelectList, - Text, -} from "@mariozechner/pi-tui"; - -interface FileEntry { - path: string; - operations: Set<"read" | "write" | "edit">; - lastTimestamp: number; -} - -type FileToolName = "read" | "write" | "edit"; - -export default function (pi: ExtensionAPI) { - pi.registerCommand("files", { - description: "Show files read/written/edited in this session", - handler: async (_args, ctx) => { - if (!ctx.hasUI) { - ctx.ui.notify("No UI available", "error"); - return; - } - - // Get the current branch (path from leaf to root) - const branch = ctx.sessionManager.getBranch(); - - // First pass: collect tool calls (id -> {path, name}) from assistant messages - const toolCalls = new Map(); - - for (const entry of branch) { - if (entry.type !== "message") { - continue; - } - const msg = entry.message; - - if (msg.role === "assistant" && Array.isArray(msg.content)) { - for (const block of msg.content) { - if (block.type === "toolCall") { - const name = block.name; - if (name === "read" || name === "write" || name === "edit") { - const path = block.arguments?.path; - if (path && typeof path === "string") { - toolCalls.set(block.id, { path, name, timestamp: msg.timestamp }); - } - } - } - } - } - } - - // Second pass: match tool results to get the actual execution timestamp - const fileMap = new Map(); - - for (const entry of branch) { - if (entry.type !== "message") { - continue; - } - const msg = entry.message; - - if (msg.role === "toolResult") { - const toolCall = toolCalls.get(msg.toolCallId); - if (!toolCall) { - continue; - } - - const { path, name } = toolCall; - const timestamp = msg.timestamp; - - const existing = fileMap.get(path); - if (existing) { - existing.operations.add(name); - if (timestamp > existing.lastTimestamp) { - existing.lastTimestamp = timestamp; - } - } else { - fileMap.set(path, { - path, - operations: new Set([name]), - lastTimestamp: timestamp, - }); - } - } - } - - if (fileMap.size === 0) { - ctx.ui.notify("No files read/written/edited in this session", "info"); - return; - } - - // Sort by most recent first - const files = Array.from(fileMap.values()).toSorted( - (a, b) => b.lastTimestamp - a.lastTimestamp, - ); - - const openSelected = async (file: FileEntry): Promise => { - try { - await pi.exec("code", ["-g", file.path], { cwd: ctx.cwd }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - ctx.ui.notify(`Failed to open ${file.path}: ${message}`, "error"); - } - }; - - // Show file picker with SelectList - await ctx.ui.custom((tui, theme, _kb, done) => { - const container = new Container(); - - // Top border - container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); - - // Title - container.addChild(new Text(theme.fg("accent", theme.bold(" Select file to open")), 0, 0)); - - // Build select items with colored operations - const items: SelectItem[] = files.map((f) => { - const ops: string[] = []; - if (f.operations.has("read")) { - ops.push(theme.fg("muted", "R")); - } - if (f.operations.has("write")) { - ops.push(theme.fg("success", "W")); - } - if (f.operations.has("edit")) { - ops.push(theme.fg("warning", "E")); - } - const opsLabel = ops.join(""); - return { - value: f, - label: `${opsLabel} ${f.path}`, - }; - }); - - const visibleRows = Math.min(files.length, 15); - let currentIndex = 0; - - const selectList = new SelectList(items, visibleRows, { - selectedPrefix: (t) => theme.fg("accent", t), - selectedText: (t) => t, // Keep existing colors - description: (t) => theme.fg("muted", t), - scrollInfo: (t) => theme.fg("dim", t), - noMatch: (t) => theme.fg("warning", t), - }); - selectList.onSelect = (item) => { - void openSelected(item.value as FileEntry); - }; - selectList.onCancel = () => done(); - selectList.onSelectionChange = (item) => { - currentIndex = items.indexOf(item); - }; - container.addChild(selectList); - - // Help text - container.addChild( - new Text(theme.fg("dim", " ↑↓ navigate • ←→ page • enter open • esc close"), 0, 0), - ); - - // Bottom border - container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); - - return { - render: (w) => container.render(w), - invalidate: () => container.invalidate(), - handleInput: (data) => { - // Add paging with left/right - if (matchesKey(data, Key.left)) { - // Page up - clamp to 0 - currentIndex = Math.max(0, currentIndex - visibleRows); - selectList.setSelectedIndex(currentIndex); - } else if (matchesKey(data, Key.right)) { - // Page down - clamp to last - currentIndex = Math.min(items.length - 1, currentIndex + visibleRows); - selectList.setSelectedIndex(currentIndex); - } else { - selectList.handleInput(data); - } - tui.requestRender(); - }, - }; - }); - }, - }); -} diff --git a/.pi/extensions/prompt-url-widget.ts b/.pi/extensions/prompt-url-widget.ts deleted file mode 100644 index 2bb56b104ea..00000000000 --- a/.pi/extensions/prompt-url-widget.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { - DynamicBorder, - type ExtensionAPI, - type ExtensionContext, -} from "@mariozechner/pi-coding-agent"; -import { Container, Text } from "@mariozechner/pi-tui"; - -const PR_PROMPT_PATTERN = /^\s*You are given one or more GitHub PR URLs:\s*(\S+)/im; -const ISSUE_PROMPT_PATTERN = /^\s*Analyze GitHub issue\(s\):\s*(\S+)/im; - -type PromptMatch = { - kind: "pr" | "issue"; - url: string; -}; - -type GhMetadata = { - title?: string; - author?: { - login?: string; - name?: string | null; - }; -}; - -function extractPromptMatch(prompt: string): PromptMatch | undefined { - const prMatch = prompt.match(PR_PROMPT_PATTERN); - if (prMatch?.[1]) { - return { kind: "pr", url: prMatch[1].trim() }; - } - - const issueMatch = prompt.match(ISSUE_PROMPT_PATTERN); - if (issueMatch?.[1]) { - return { kind: "issue", url: issueMatch[1].trim() }; - } - - return undefined; -} - -async function fetchGhMetadata( - pi: ExtensionAPI, - kind: PromptMatch["kind"], - url: string, -): Promise { - const args = - kind === "pr" - ? ["pr", "view", url, "--json", "title,author"] - : ["issue", "view", url, "--json", "title,author"]; - - try { - const result = await pi.exec("gh", args); - if (result.code !== 0 || !result.stdout) { - return undefined; - } - return JSON.parse(result.stdout) as GhMetadata; - } catch { - return undefined; - } -} - -function formatAuthor(author?: GhMetadata["author"]): string | undefined { - if (!author) { - return undefined; - } - const name = author.name?.trim(); - const login = author.login?.trim(); - if (name && login) { - return `${name} (@${login})`; - } - if (login) { - return `@${login}`; - } - if (name) { - return name; - } - return undefined; -} - -export default function promptUrlWidgetExtension(pi: ExtensionAPI) { - const setWidget = ( - ctx: ExtensionContext, - match: PromptMatch, - title?: string, - authorText?: string, - ) => { - ctx.ui.setWidget("prompt-url", (_tui, thm) => { - const titleText = title ? thm.fg("accent", title) : thm.fg("accent", match.url); - const authorLine = authorText ? thm.fg("muted", authorText) : undefined; - const urlLine = thm.fg("dim", match.url); - - const lines = [titleText]; - if (authorLine) { - lines.push(authorLine); - } - lines.push(urlLine); - - const container = new Container(); - container.addChild(new DynamicBorder((s: string) => thm.fg("muted", s))); - container.addChild(new Text(lines.join("\n"), 1, 0)); - return container; - }); - }; - - const applySessionName = (ctx: ExtensionContext, match: PromptMatch, title?: string) => { - const label = match.kind === "pr" ? "PR" : "Issue"; - const trimmedTitle = title?.trim(); - const fallbackName = `${label}: ${match.url}`; - const desiredName = trimmedTitle ? `${label}: ${trimmedTitle} (${match.url})` : fallbackName; - const currentName = pi.getSessionName()?.trim(); - if (!currentName) { - pi.setSessionName(desiredName); - return; - } - if (currentName === match.url || currentName === fallbackName) { - pi.setSessionName(desiredName); - } - }; - - pi.on("before_agent_start", async (event, ctx) => { - if (!ctx.hasUI) { - return; - } - const match = extractPromptMatch(event.prompt); - if (!match) { - return; - } - - setWidget(ctx, match); - applySessionName(ctx, match); - void fetchGhMetadata(pi, match.kind, match.url).then((meta) => { - const title = meta?.title?.trim(); - const authorText = formatAuthor(meta?.author); - setWidget(ctx, match, title, authorText); - applySessionName(ctx, match, title); - }); - }); - - pi.on("session_switch", async (_event, ctx) => { - rebuildFromSession(ctx); - }); - - const getUserText = (content: string | { type: string; text?: string }[] | undefined): string => { - if (!content) { - return ""; - } - if (typeof content === "string") { - return content; - } - return ( - content - .filter((block): block is { type: "text"; text: string } => block.type === "text") - .map((block) => block.text) - .join("\n") ?? "" - ); - }; - - const rebuildFromSession = (ctx: ExtensionContext) => { - if (!ctx.hasUI) { - return; - } - - const entries = ctx.sessionManager.getEntries(); - const lastMatch = [...entries].toReversed().find((entry) => { - if (entry.type !== "message" || entry.message.role !== "user") { - return false; - } - const text = getUserText(entry.message.content); - return !!extractPromptMatch(text); - }); - - const content = - lastMatch?.type === "message" && lastMatch.message.role === "user" - ? lastMatch.message.content - : undefined; - const text = getUserText(content); - const match = text ? extractPromptMatch(text) : undefined; - if (!match) { - ctx.ui.setWidget("prompt-url", undefined); - return; - } - - setWidget(ctx, match); - applySessionName(ctx, match); - void fetchGhMetadata(pi, match.kind, match.url).then((meta) => { - const title = meta?.title?.trim(); - const authorText = formatAuthor(meta?.author); - setWidget(ctx, match, title, authorText); - applySessionName(ctx, match, title); - }); - }; - - pi.on("session_start", async (_event, ctx) => { - rebuildFromSession(ctx); - }); -} diff --git a/.pi/extensions/redraws.ts b/.pi/extensions/redraws.ts deleted file mode 100644 index 6331f5eaba6..00000000000 --- a/.pi/extensions/redraws.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Redraws Extension - * - * Exposes /tui to show TUI redraw stats. - */ - -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { Text } from "@mariozechner/pi-tui"; - -export default function (pi: ExtensionAPI) { - pi.registerCommand("tui", { - description: "Show TUI stats", - handler: async (_args, ctx) => { - if (!ctx.hasUI) { - return; - } - let redraws = 0; - await ctx.ui.custom((tui, _theme, _keybindings, done) => { - redraws = tui.fullRedraws; - done(undefined); - return new Text("", 0, 0); - }); - ctx.ui.notify(`TUI full redraws: ${redraws}`, "info"); - }, - }); -} diff --git a/.pi/git/.gitignore b/.pi/git/.gitignore deleted file mode 100644 index d6b7ef32c84..00000000000 --- a/.pi/git/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/.pi/prompts/cl.md b/.pi/prompts/cl.md deleted file mode 100644 index 6d79ecda66e..00000000000 --- a/.pi/prompts/cl.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -description: Audit changelog entries before release ---- - -Audit changelog entries for all commits since the last release. - -## Process - -1. **Find the last release tag:** - - ```bash - git tag --sort=-version:refname | head -1 - ``` - -2. **List all commits since that tag:** - - ```bash - git log ..HEAD --oneline - ``` - -3. **Read each package's [Unreleased] section:** - - packages/ai/CHANGELOG.md - - packages/tui/CHANGELOG.md - - packages/coding-agent/CHANGELOG.md - -4. **For each commit, check:** - - Skip: changelog updates, doc-only changes, release housekeeping - - Determine which package(s) the commit affects (use `git show --stat`) - - Verify a changelog entry exists in the affected package(s) - - For external contributions (PRs), verify format: `Description ([#N](url) by [@user](url))` - -5. **Cross-package duplication rule:** - Changes in `ai`, `agent` or `tui` that affect end users should be duplicated to `coding-agent` changelog, since coding-agent is the user-facing package that depends on them. - -6. **Add New Features section after changelog fixes:** - - Insert a `### New Features` section at the start of `## [Unreleased]` in `packages/coding-agent/CHANGELOG.md`. - - Propose the top new features to the user for confirmation before writing them. - - Link to relevant docs and sections whenever possible. - -7. **Report:** - - List commits with missing entries - - List entries that need cross-package duplication - - Add any missing entries directly - -## Changelog Format Reference - -Sections (in order): - -- `### Breaking Changes` - API changes requiring migration -- `### Added` - New features -- `### Changed` - Changes to existing functionality -- `### Fixed` - Bug fixes -- `### Removed` - Removed features - -Attribution: - -- Internal: `Fixed foo ([#123](https://github.com/badlogic/pi-mono/issues/123))` -- External: `Added bar ([#456](https://github.com/badlogic/pi-mono/pull/456) by [@user](https://github.com/user))` diff --git a/.pi/prompts/is.md b/.pi/prompts/is.md deleted file mode 100644 index cc8f603adc0..00000000000 --- a/.pi/prompts/is.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -description: Analyze GitHub issues (bugs or feature requests) ---- - -Analyze GitHub issue(s): $ARGUMENTS - -For each issue: - -1. Read the issue in full, including all comments and linked issues/PRs. - -2. **For bugs**: - - Ignore any root cause analysis in the issue (likely wrong) - - Read all related code files in full (no truncation) - - Trace the code path and identify the actual root cause - - Propose a fix - -3. **For feature requests**: - - Read all related code files in full (no truncation) - - Propose the most concise implementation approach - - List affected files and changes needed - -Do NOT implement unless explicitly asked. Analyze and propose only. diff --git a/.pi/prompts/landpr.md b/.pi/prompts/landpr.md deleted file mode 100644 index 95e4692f3e5..00000000000 --- a/.pi/prompts/landpr.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -description: Land a PR (merge with proper workflow) ---- - -Input - -- PR: $1 - - If missing: use the most recent PR mentioned in the conversation. - - If ambiguous: ask. - -Do (end-to-end) -Goal: PR must end in GitHub state = MERGED (never CLOSED). Use `gh pr merge` with `--rebase` or `--squash`. - -1. Assign PR to self: - - `gh pr edit --add-assignee @me` -2. Repo clean: `git status`. -3. Identify PR meta (author + head branch): - - ```sh - gh pr view --json number,title,author,headRefName,baseRefName,headRepository --jq '{number,title,author:.author.login,head:.headRefName,base:.baseRefName,headRepo:.headRepository.nameWithOwner}' - contrib=$(gh pr view --json author --jq .author.login) - head=$(gh pr view --json headRefName --jq .headRefName) - head_repo_url=$(gh pr view --json headRepository --jq .headRepository.url) - ``` - -4. Fast-forward base: - - `git checkout main` - - `git pull --ff-only` -5. Create temp base branch from main: - - `git checkout -b temp/landpr-` -6. Check out PR branch locally: - - `gh pr checkout ` -7. Rebase PR branch onto temp base: - - `git rebase temp/landpr-` - - Fix conflicts; keep history tidy. -8. Fix + tests + changelog: - - Implement fixes + add/adjust tests - - Update `CHANGELOG.md` and mention `#` + `@$contrib` -9. Decide merge strategy: - - Rebase if we want to preserve commit history - - Squash if we want a single clean commit - - If unclear, ask -10. Full gate (BEFORE commit): - - `pnpm lint && pnpm build && pnpm test` -11. Commit via committer (final merge commit only includes PR # + thanks): - - For the final merge-ready commit: `committer "fix: (#) (thanks @$contrib)" CHANGELOG.md ` - - If you need intermediate fix commits before the final merge commit, keep those messages concise and **omit** PR number/thanks. - - `land_sha=$(git rev-parse HEAD)` -12. Push updated PR branch (rebase => usually needs force): - - ```sh - git remote add prhead "$head_repo_url.git" 2>/dev/null || git remote set-url prhead "$head_repo_url.git" - git push --force-with-lease prhead HEAD:$head - ``` - -13. Merge PR (must show MERGED on GitHub): - - Rebase: `gh pr merge --rebase` - - Squash: `gh pr merge --squash` - - Never `gh pr close` (closing is wrong) -14. Sync main: - - `git checkout main` - - `git pull --ff-only` -15. Comment on PR with what we did + SHAs + thanks: - - ```sh - merge_sha=$(gh pr view --json mergeCommit --jq '.mergeCommit.oid') - gh pr comment --body "Landed via temp rebase onto main.\n\n- Gate: pnpm lint && pnpm build && pnpm test\n- Land commit: $land_sha\n- Merge commit: $merge_sha\n\nThanks @$contrib!" - ``` - -16. Verify PR state == MERGED: - - `gh pr view --json state --jq .state` -17. Delete temp branch: - - `git branch -D temp/landpr-` diff --git a/.pi/prompts/reviewpr.md b/.pi/prompts/reviewpr.md deleted file mode 100644 index 835be806dd5..00000000000 --- a/.pi/prompts/reviewpr.md +++ /dev/null @@ -1,105 +0,0 @@ ---- -description: Review a PR thoroughly without merging ---- - -Input - -- PR: $1 - - If missing: use the most recent PR mentioned in the conversation. - - If ambiguous: ask. - -Do (review-only) -Goal: produce a thorough review and a clear recommendation (READY for /landpr vs NEEDS WORK). Do NOT merge, do NOT push, do NOT make changes in the repo as part of this command. - -1. Identify PR meta + context - - ```sh - gh pr view --json number,title,state,isDraft,author,baseRefName,headRefName,headRepository,url,body,labels,assignees,reviewRequests,files,additions,deletions --jq '{number,title,url,state,isDraft,author:.author.login,base:.baseRefName,head:.headRefName,headRepo:.headRepository.nameWithOwner,additions,deletions,files:.files|length}' - ``` - -2. Read the PR description carefully - - Summarize the stated goal, scope, and any "why now?" rationale. - - Call out any missing context: motivation, alternatives considered, rollout/compat notes, risk. - -3. Read the diff thoroughly (prefer full diff) - - ```sh - gh pr diff - # If you need more surrounding context for files: - gh pr checkout # optional; still review-only - git show --stat - ``` - -4. Validate the change is needed / valuable - - What user/customer/dev pain does this solve? - - Is this change the smallest reasonable fix? - - Are we introducing complexity for marginal benefit? - - Are we changing behavior/contract in a way that needs docs or a release note? - -5. Evaluate implementation quality + optimality - - Correctness: edge cases, error handling, null/undefined, concurrency, ordering. - - Design: is the abstraction/architecture appropriate or over/under-engineered? - - Performance: hot paths, allocations, queries, network, N+1s, caching. - - Security/privacy: authz/authn, input validation, secrets, logging PII. - - Backwards compatibility: public APIs, config, migrations. - - Style consistency: formatting, naming, patterns used elsewhere. - -6. Tests & verification - - Identify what's covered by tests (unit/integration/e2e). - - Are there regression tests for the bug fixed / scenario added? - - Missing tests? Call out exact cases that should be added. - - If tests are present, do they actually assert the important behavior (not just snapshots / happy path)? - -7. Follow-up refactors / cleanup suggestions - - Any code that should be simplified before merge? - - Any TODOs that should be tickets vs addressed now? - - Any deprecations, docs, types, or lint rules we should adjust? - -8. Key questions to answer explicitly - - Can we fix everything ourselves in a follow-up, or does the contributor need to update this PR? - - Any blocking concerns (must-fix before merge)? - - Is this PR ready to land, or does it need work? - -9. Output (structured) - Produce a review with these sections: - -A) TL;DR recommendation - -- One of: READY FOR /landpr | NEEDS WORK | NEEDS DISCUSSION -- 1–3 sentence rationale. - -B) What changed - -- Brief bullet summary of the diff/behavioral changes. - -C) What's good - -- Bullets: correctness, simplicity, tests, docs, ergonomics, etc. - -D) Concerns / questions (actionable) - -- Numbered list. -- Mark each item as: - - BLOCKER (must fix before merge) - - IMPORTANT (should fix before merge) - - NIT (optional) -- For each: point to the file/area and propose a concrete fix or alternative. - -E) Tests - -- What exists. -- What's missing (specific scenarios). - -F) Follow-ups (optional) - -- Non-blocking refactors/tickets to open later. - -G) Suggested PR comment (optional) - -- Offer: "Want me to draft a PR comment to the author?" -- If yes, provide a ready-to-paste comment summarizing the above, with clear asks. - -Rules / Guardrails - -- Review only: do not merge (`gh pr merge`), do not push branches, do not edit code. -- If you need clarification, ask questions rather than guessing. diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index e946d18c112..00000000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,105 +0,0 @@ -# Pre-commit hooks for openclaw -# Install: prek install -# Run manually: prek run --all-files -# -# See https://pre-commit.com for more information - -repos: - # Basic file hygiene - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v6.0.0 - hooks: - - id: trailing-whitespace - exclude: '^(docs/|dist/|vendor/|.*\.snap$)' - - id: end-of-file-fixer - exclude: '^(docs/|dist/|vendor/|.*\.snap$)' - - id: check-yaml - args: [--allow-multiple-documents] - - id: check-added-large-files - args: [--maxkb=500] - - id: check-merge-conflict - - # Secret detection (same as CI) - - repo: https://github.com/Yelp/detect-secrets - rev: v1.5.0 - hooks: - - id: detect-secrets - args: - - --baseline - - .secrets.baseline - - --exclude-files - - '(^|/)(dist/|vendor/|pnpm-lock\.yaml$|\.detect-secrets\.cfg$)' - - --exclude-lines - - 'key_content\.include\?\("BEGIN PRIVATE KEY"\)' - - --exclude-lines - - 'case \.apiKeyEnv: "API key \(env var\)"' - - --exclude-lines - - 'case apikey = "apiKey"' - - --exclude-lines - - '"gateway\.remote\.password"' - - --exclude-lines - - '"gateway\.auth\.password"' - - --exclude-lines - - '"talk\.apiKey"' - - --exclude-lines - - '=== "string"' - - --exclude-lines - - 'typeof remote\?\.password === "string"' - - # Shell script linting - - repo: https://github.com/koalaman/shellcheck-precommit - rev: v0.11.0 - hooks: - - id: shellcheck - args: [--severity=error] # Only fail on errors, not warnings/info - # Exclude vendor and scripts with embedded code or known issues - exclude: "^(vendor/|scripts/e2e/)" - - # GitHub Actions linting - - repo: https://github.com/rhysd/actionlint - rev: v1.7.10 - hooks: - - id: actionlint - - # GitHub Actions security audit - - repo: https://github.com/zizmorcore/zizmor-pre-commit - rev: v1.22.0 - hooks: - - id: zizmor - args: [--persona=regular, --min-severity=medium, --min-confidence=medium] - exclude: "^(vendor/|Swabble/)" - - # Project checks (same commands as CI) - - repo: local - hooks: - # oxlint --type-aware src test - - id: oxlint - name: oxlint - entry: scripts/pre-commit/run-node-tool.sh oxlint --type-aware src test - language: system - pass_filenames: false - types_or: [javascript, jsx, ts, tsx] - - # oxfmt --check src test - - id: oxfmt - name: oxfmt - entry: scripts/pre-commit/run-node-tool.sh oxfmt --check src test - language: system - pass_filenames: false - types_or: [javascript, jsx, ts, tsx] - - # swiftlint (same as CI) - - id: swiftlint - name: swiftlint - entry: swiftlint --config .swiftlint.yml - language: system - pass_filenames: false - types: [swift] - - # swiftformat --lint (same as CI) - - id: swiftformat - name: swiftformat - entry: swiftformat --lint apps/macos/Sources --config .swiftformat - language: system - pass_filenames: false - types: [swift] diff --git a/.secrets.baseline b/.secrets.baseline deleted file mode 100644 index 089515fe250..00000000000 --- a/.secrets.baseline +++ /dev/null @@ -1,13104 +0,0 @@ -{ - "version": "1.5.0", - "plugins_used": [ - { - "name": "ArtifactoryDetector" - }, - { - "name": "AWSKeyDetector" - }, - { - "name": "AzureStorageKeyDetector" - }, - { - "name": "Base64HighEntropyString", - "limit": 4.5 - }, - { - "name": "BasicAuthDetector" - }, - { - "name": "CloudantDetector" - }, - { - "name": "DiscordBotTokenDetector" - }, - { - "name": "GitHubTokenDetector" - }, - { - "name": "GitLabTokenDetector" - }, - { - "name": "HexHighEntropyString", - "limit": 3.0 - }, - { - "name": "IbmCloudIamDetector" - }, - { - "name": "IbmCosHmacDetector" - }, - { - "name": "IPPublicDetector" - }, - { - "name": "JwtTokenDetector" - }, - { - "name": "KeywordDetector", - "keyword_exclude": "" - }, - { - "name": "MailchimpDetector" - }, - { - "name": "NpmDetector" - }, - { - "name": "OpenAIDetector" - }, - { - "name": "PrivateKeyDetector" - }, - { - "name": "PypiTokenDetector" - }, - { - "name": "SendGridDetector" - }, - { - "name": "SlackDetector" - }, - { - "name": "SoftlayerDetector" - }, - { - "name": "SquareOAuthDetector" - }, - { - "name": "StripeDetector" - }, - { - "name": "TelegramBotTokenDetector" - }, - { - "name": "TwilioKeyDetector" - } - ], - "filters_used": [ - { - "path": "detect_secrets.filters.allowlist.is_line_allowlisted" - }, - { - "path": "detect_secrets.filters.common.is_baseline_file", - "filename": ".secrets.baseline" - }, - { - "path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies", - "min_level": 2 - }, - { - "path": "detect_secrets.filters.heuristic.is_indirect_reference" - }, - { - "path": "detect_secrets.filters.heuristic.is_likely_id_string" - }, - { - "path": "detect_secrets.filters.heuristic.is_lock_file" - }, - { - "path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string" - }, - { - "path": "detect_secrets.filters.heuristic.is_potential_uuid" - }, - { - "path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign" - }, - { - "path": "detect_secrets.filters.heuristic.is_sequential_string" - }, - { - "path": "detect_secrets.filters.heuristic.is_swagger_file" - }, - { - "path": "detect_secrets.filters.heuristic.is_templated_secret" - }, - { - "path": "detect_secrets.filters.regex.should_exclude_file", - "pattern": [ - "(^|/)pnpm-lock\\.yaml$" - ] - }, - { - "path": "detect_secrets.filters.regex.should_exclude_line", - "pattern": [ - "key_content\\.include\\?\\(\"BEGIN PRIVATE KEY\"\\)", - "case \\.apiKeyEnv: \"API key \\(env var\\)\"", - "case apikey = \"apiKey\"", - "\"gateway\\.remote\\.password\"", - "\"gateway\\.auth\\.password\"", - "\"talk\\.apiKey\"", - "=== \"string\"", - "typeof remote\\?\\.password === \"string\"" - ] - } - ], - "results": { - ".detect-secrets.cfg": [ - { - "type": "Private Key", - "filename": ".detect-secrets.cfg", - "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", - "is_verified": false, - "line_number": 17 - }, - { - "type": "Secret Keyword", - "filename": ".detect-secrets.cfg", - "hashed_secret": "fe88fceb47e040ba1bfafa4ac639366188df2f6d", - "is_verified": false, - "line_number": 19 - } - ], - "appcast.xml": [ - { - "type": "Base64 High Entropy String", - "filename": "appcast.xml", - "hashed_secret": "2bc43713edb8f775582c6314953b7c020d691aba", - "is_verified": false, - "line_number": 141 - }, - { - "type": "Base64 High Entropy String", - "filename": "appcast.xml", - "hashed_secret": "2fcd83b35235522978c19dbbab2884a09aa64f35", - "is_verified": false, - "line_number": 209 - }, - { - "type": "Base64 High Entropy String", - "filename": "appcast.xml", - "hashed_secret": "78b65f0952ed8a557e0f67b2364ff67cb6863bc8", - "is_verified": false, - "line_number": 310 - } - ], - "apps/android/app/src/test/java/ai/openclaw/android/node/AppUpdateHandlerTest.kt": [ - { - "type": "Hex High Entropy String", - "filename": "apps/android/app/src/test/java/ai/openclaw/android/node/AppUpdateHandlerTest.kt", - "hashed_secret": "ee662f2bc691daa48d074542722d8e1b0587673c", - "is_verified": false, - "line_number": 58 - } - ], - "apps/ios/Sources/Gateway/GatewaySettingsStore.swift": [ - { - "type": "Secret Keyword", - "filename": "apps/ios/Sources/Gateway/GatewaySettingsStore.swift", - "hashed_secret": "5f7c0c35e552780b67fe1c0ee186764354793be3", - "is_verified": false, - "line_number": 28 - } - ], - "apps/ios/Tests/DeepLinkParserTests.swift": [ - { - "type": "Secret Keyword", - "filename": "apps/ios/Tests/DeepLinkParserTests.swift", - "hashed_secret": "1a91d62f7ca67399625a4368a6ab5d4a3baa6073", - "is_verified": false, - "line_number": 89 - } - ], - "apps/macos/Sources/OpenClawProtocol/GatewayModels.swift": [ - { - "type": "Secret Keyword", - "filename": "apps/macos/Sources/OpenClawProtocol/GatewayModels.swift", - "hashed_secret": "7990585255d25249fb1e6eac3d2bd6c37429b2cd", - "is_verified": false, - "line_number": 1492 - } - ], - "apps/macos/Tests/OpenClawIPCTests/AnthropicAuthResolverTests.swift": [ - { - "type": "Secret Keyword", - "filename": "apps/macos/Tests/OpenClawIPCTests/AnthropicAuthResolverTests.swift", - "hashed_secret": "e761624445731fcb8b15da94343c6b92e507d190", - "is_verified": false, - "line_number": 26 - }, - { - "type": "Secret Keyword", - "filename": "apps/macos/Tests/OpenClawIPCTests/AnthropicAuthResolverTests.swift", - "hashed_secret": "a23c8630c8a5fbaa21f095e0269c135c20d21689", - "is_verified": false, - "line_number": 42 - } - ], - "apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift": [ - { - "type": "Secret Keyword", - "filename": "apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift", - "hashed_secret": "19dad5cecb110281417d1db56b60e1b006d55bb4", - "is_verified": false, - "line_number": 61 - } - ], - "apps/macos/Tests/OpenClawIPCTests/GatewayLaunchAgentManagerTests.swift": [ - { - "type": "Secret Keyword", - "filename": "apps/macos/Tests/OpenClawIPCTests/GatewayLaunchAgentManagerTests.swift", - "hashed_secret": "1a91d62f7ca67399625a4368a6ab5d4a3baa6073", - "is_verified": false, - "line_number": 13 - } - ], - "apps/macos/Tests/OpenClawIPCTests/TailscaleIntegrationSectionTests.swift": [ - { - "type": "Secret Keyword", - "filename": "apps/macos/Tests/OpenClawIPCTests/TailscaleIntegrationSectionTests.swift", - "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", - "is_verified": false, - "line_number": 27 - } - ], - "apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift": [ - { - "type": "Secret Keyword", - "filename": "apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift", - "hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8", - "is_verified": false, - "line_number": 106 - } - ], - "apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift": [ - { - "type": "Secret Keyword", - "filename": "apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift", - "hashed_secret": "7990585255d25249fb1e6eac3d2bd6c37429b2cd", - "is_verified": false, - "line_number": 1492 - } - ], - "docs/.i18n/zh-CN.tm.jsonl": [ - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "6ba7bb7047f44b28279fbb11350e1a7bf4e7de59", - "is_verified": false, - "line_number": 1 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e83ec66165edcee8f2b408b5e6bafe4844071f8f", - "is_verified": false, - "line_number": 2 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8793597fb80169cbcefe08a1b0151138b7ab78bd", - "is_verified": false, - "line_number": 3 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "af6b2a2ef841b637288e2eb2726e20ed9c3974c0", - "is_verified": false, - "line_number": 4 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "db1f9e54942e872f3a7b29aa174c70a3167d76f2", - "is_verified": false, - "line_number": 5 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f66de1a7ae418bd55115d4fac319824deb0d88cb", - "is_verified": false, - "line_number": 6 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "98510d5b8050a30514bc7fa147af6f66e5e34804", - "is_verified": false, - "line_number": 7 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b03e1a8bbe1b422cb64d7aea071d94088b6c1768", - "is_verified": false, - "line_number": 8 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "6f72b03efde2d701a7e882dcaed1e935484a8e67", - "is_verified": false, - "line_number": 9 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "57d35c7411cff6f679c4a437d3251c0532fbe3cb", - "is_verified": false, - "line_number": 10 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "fbffe72a354d73fad191eec6605543d3e8e5f549", - "is_verified": false, - "line_number": 11 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ceb3b4e53c22f7e28ab7006c9e1931bd31d534e1", - "is_verified": false, - "line_number": 12 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3eb65eb5d24ab5bd58a57bcd1a1894c1d05ad7f6", - "is_verified": false, - "line_number": 13 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "88e065467489c885d4d80d8f582707f3ca6284e6", - "is_verified": false, - "line_number": 14 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "fd9e2dd936c475429f6d461056c5d97d1635de2e", - "is_verified": false, - "line_number": 15 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b7a629ae866eda49b01fe2eccbf842b52594442a", - "is_verified": false, - "line_number": 16 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "67c615ed823ff022c807fcb65d52bd454a52bc1f", - "is_verified": false, - "line_number": 17 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "121e6974c091fafcc6e493892b7e7ffe3c81e7eb", - "is_verified": false, - "line_number": 18 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2be720cb8d166c422e71de2c43dbb5832c952df5", - "is_verified": false, - "line_number": 19 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e44ba9d2b09e8923191b76eb9f58127ad9980cae", - "is_verified": false, - "line_number": 20 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ff53d507245282f09d082321e8ef511a3e2af5ff", - "is_verified": false, - "line_number": 21 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7ecbf8a10b1e8bc096b49c27d3b70812778205eb", - "is_verified": false, - "line_number": 22 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "5628e70d1f7717c328418619beb0ae164fb5075c", - "is_verified": false, - "line_number": 23 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b0b8efbb45c2854a57241d51c2b556838eaebc00", - "is_verified": false, - "line_number": 24 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "686c14971a01fa1737cc2c00790933213b688e52", - "is_verified": false, - "line_number": 25 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "6311a112d1ef120acc3247c79a07721b9dc52f5b", - "is_verified": false, - "line_number": 26 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "0765cbc88514c95526bffd2e5b5144e050969aae", - "is_verified": false, - "line_number": 27 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8d4d995d95dae479362773b1fe5ff943f735dd97", - "is_verified": false, - "line_number": 28 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "6da60e76ffee6f074c22f89fbfe1969b9b5bbbe2", - "is_verified": false, - "line_number": 29 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "40efc129489cfc37e7f114be79db3843adfd6549", - "is_verified": false, - "line_number": 30 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "976e548e417838885ab177817cf2b04f9c390571", - "is_verified": false, - "line_number": 31 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "26ad87428b833b4d5d569c10ec5bd7cc32019a0a", - "is_verified": false, - "line_number": 32 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "45f8de688074faa92a647dcf9f67b670de68a2b0", - "is_verified": false, - "line_number": 33 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "24d6fb4ef117d39c5f9c45a205faf1c85f356fa0", - "is_verified": false, - "line_number": 34 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "172a6875ed57d321409cb9c27d425b0b41eacb29", - "is_verified": false, - "line_number": 35 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "bf13e4219d558c0deff114eb6b6098dd12d30e90", - "is_verified": false, - "line_number": 36 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "1c91d3756008237ba0540b5831e88763e45a4fa9", - "is_verified": false, - "line_number": 37 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "63f55dcafa051c764eebfc72939788ec777fa3b5", - "is_verified": false, - "line_number": 38 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2fec58745fb43cefe32e523ca60285baa33825c3", - "is_verified": false, - "line_number": 39 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7dc4fc41a5c1ba307be067570a0e458f3b139696", - "is_verified": false, - "line_number": 40 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "26e2d413623e29e208ee2e71dd8aa02db3f0daa5", - "is_verified": false, - "line_number": 41 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "816184e85b856e06b4d70967ce713e72b22292e5", - "is_verified": false, - "line_number": 42 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "874b4362c636af8f5b4aebe013ae321ab0b83fd9", - "is_verified": false, - "line_number": 43 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8e89a4e4945335d905762eb2dc5e8510abc9716d", - "is_verified": false, - "line_number": 44 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7d4eb519b7fa3bce189b20609de596db82b56fae", - "is_verified": false, - "line_number": 45 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "22f878f965c38ebecdfd6ba0229e118cbfc80b00", - "is_verified": false, - "line_number": 46 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2b2b5ced0fb09d74ab6fba9f058139ef47ad6bda", - "is_verified": false, - "line_number": 47 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ff5c4ac7b55661c8bb699005b3ba9e0299b66ec9", - "is_verified": false, - "line_number": 48 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "541344e343f0f02cb1548729b073161d0b44c373", - "is_verified": false, - "line_number": 49 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "886979ee264082f1daebc1a2c95e9376281869fa", - "is_verified": false, - "line_number": 50 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d1c7b012097938e3b75365359d49aa134768f64f", - "is_verified": false, - "line_number": 51 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9c6a58787264a4fb0a823f9e20fd2c9abf82b96d", - "is_verified": false, - "line_number": 52 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "79e2c2821ed6a8b47486b4ddea90be8c7d4ad5b8", - "is_verified": false, - "line_number": 53 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ae8e49c80ed43d16eef9f633c28879b3166318ab", - "is_verified": false, - "line_number": 54 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f96db0197e1d67eab1197a03c107b07a71cd0ce7", - "is_verified": false, - "line_number": 55 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "cf799fdab5d19a32f25735f5b6a1265b6e30c33d", - "is_verified": false, - "line_number": 56 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9d2165cc2b208ca555fb00ddaa1768455c89c4d0", - "is_verified": false, - "line_number": 57 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9139a8402a3454c747b23df0d7c8e957312dd6d2", - "is_verified": false, - "line_number": 58 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "00bb66a6c79ba6cfebbf1018a83af7129a29a479", - "is_verified": false, - "line_number": 59 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "5b43b45627cffb5959d10386ec63025d28dbeec4", - "is_verified": false, - "line_number": 60 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c99e2f9d7726da2ea48cb07e71a33a757cb12118", - "is_verified": false, - "line_number": 61 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "1880416d744d0693237d330f6ca744b59e7e12b4", - "is_verified": false, - "line_number": 62 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2ed0dc836758d77d6a96c6b96d054697a59d64f0", - "is_verified": false, - "line_number": 63 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8f34c522fe85146a367d92efe27488718791707e", - "is_verified": false, - "line_number": 64 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "5bc1ce83e698af25ed3427553c8a3fcf8aaefdc9", - "is_verified": false, - "line_number": 65 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "05e16bf4e66e22a4a83defe89f6e746becf049b8", - "is_verified": false, - "line_number": 66 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "97b2b3d469cde6e5e88ac0089433c772d2d86b0d", - "is_verified": false, - "line_number": 67 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "149e7eb26c3598e6fa620c61de9e7562d7995e01", - "is_verified": false, - "line_number": 68 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "5ec42634100091a94f71a2fd14820cb535df481e", - "is_verified": false, - "line_number": 69 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8d6ef196daa5e81bda9ac982bcb40a6f07d4f50c", - "is_verified": false, - "line_number": 70 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2d5c79b7d58642498f734dbe2c1245159a277a1e", - "is_verified": false, - "line_number": 71 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7efd41240b058195c11e1ea621060bc8c82df8fc", - "is_verified": false, - "line_number": 72 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "47f6371bd5fe1746bcade2fea59cb8d93ff5c4e0", - "is_verified": false, - "line_number": 73 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c67ce872a65c537d8748b302f45479714a04c420", - "is_verified": false, - "line_number": 74 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "fc32724374d238112dd530743e85af73f1c8eb8e", - "is_verified": false, - "line_number": 75 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a01d187f1b0f38159c62f32405796de21548be31", - "is_verified": false, - "line_number": 76 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a39ae2ab785dc2d4aab7856b0a7c6e4e5875b215", - "is_verified": false, - "line_number": 77 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "4ad4b170f1617e562f07cba453b69c8bc53cb5cd", - "is_verified": false, - "line_number": 78 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b0e551f8b6fbe0147169202fbc141c1a0478dfb2", - "is_verified": false, - "line_number": 79 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "02593ce120c7398316c65894a5fa4be694ea3cee", - "is_verified": false, - "line_number": 80 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "789bc546ba1936b86999373fca6d6a6a4899a787", - "is_verified": false, - "line_number": 81 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ee29461a81f3e898f4376d270ac84b8567f9b68c", - "is_verified": false, - "line_number": 82 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "235f549d4c65ec31307e0887204c428441d6229f", - "is_verified": false, - "line_number": 83 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "87b2376e9f5457bad56b7fb363c6a5f86d8f119a", - "is_verified": false, - "line_number": 84 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c3b3424f5845769977ccb309a3c2b70117989e3c", - "is_verified": false, - "line_number": 85 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "88ddc980ca5f609c2806df08e2e1b9b206153817", - "is_verified": false, - "line_number": 86 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "af48a18326858bfcef8e5f3a850fba0f9d462549", - "is_verified": false, - "line_number": 87 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c22217254346f8d551183caac2f73ec8284953b3", - "is_verified": false, - "line_number": 88 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2de7388be37ebdde032f5e169940da7c9d38ac8b", - "is_verified": false, - "line_number": 89 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "98facee0b1bf74672bacb855a27972851929dd78", - "is_verified": false, - "line_number": 90 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "0a5cae7f96ade77892c5caa993b6d19cd41232fb", - "is_verified": false, - "line_number": 91 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "fe0da76f124e112f6702f2e9c62514238398ba8d", - "is_verified": false, - "line_number": 92 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d5ce761d7b87445aa65b1734ad36c5d3d1d71c2a", - "is_verified": false, - "line_number": 93 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f5b70c708f3034bd837835329603a499207c4fb5", - "is_verified": false, - "line_number": 94 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "50d6381367811dd8a0ad61bf1dd2c3619ece8a44", - "is_verified": false, - "line_number": 95 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "fe061e35aafc5841544633d917f55357813c0906", - "is_verified": false, - "line_number": 96 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "dc8722d30a33248ccc5dd9012fba71eefd3a44ac", - "is_verified": false, - "line_number": 97 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2fb43da561bbb79d7cf89e5d6c5102c1436f6f49", - "is_verified": false, - "line_number": 98 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "cf61d12e9d98f6ba507bf40285d05f37fe158a01", - "is_verified": false, - "line_number": 99 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "dfeb7563bafd2d89888b8b440dee49d089daeb78", - "is_verified": false, - "line_number": 100 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "fea45d453b5b8650cda0b2b9db6b85b60c503d6c", - "is_verified": false, - "line_number": 101 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "bb7538d46b4fde60dc88be303de19d35fe89019d", - "is_verified": false, - "line_number": 102 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "08e0674faf444c6dc671036d900e3decce98d1eb", - "is_verified": false, - "line_number": 103 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e261897f1d1a99aafec462606b65228331e30583", - "is_verified": false, - "line_number": 104 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ffe19721c941dfb929b30707c8513e2f0c8c4dc7", - "is_verified": false, - "line_number": 105 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "fe1fc5b0e4ca6aa0189f77a9d78b852201366b81", - "is_verified": false, - "line_number": 106 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "590787fa67e0d75346ed1a3850f98741b6a49506", - "is_verified": false, - "line_number": 107 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "eccb56a947e4d36b8e9d51d0e071caf1a978c6f2", - "is_verified": false, - "line_number": 108 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c301ee23c9e41d15d5c58c7cd5939e41e7d1eb99", - "is_verified": false, - "line_number": 109 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9f8607273e42be64e9779e59455706923081cd80", - "is_verified": false, - "line_number": 110 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "72d31fe5a3e5b6e818f5fd3ec97a9ac0042acec7", - "is_verified": false, - "line_number": 111 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "bb9158c9b6e8a0a1007b93b92ec531bdd9ffd32e", - "is_verified": false, - "line_number": 112 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c2ca44d18bd79c0f1b663d8bc3dfcfb02a7e02df", - "is_verified": false, - "line_number": 113 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "eac2c4cc6263495036a0ef8d8aaf2d8075167249", - "is_verified": false, - "line_number": 114 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f55341301796552621f367fff6ea9a2bd076df29", - "is_verified": false, - "line_number": 115 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "21967ac89d793aa883840d7a71308514e9e1dc4e", - "is_verified": false, - "line_number": 116 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "679dc9deb86fd7375692381ae784de604a552ae3", - "is_verified": false, - "line_number": 117 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "dd90f8337c050490f6e9b191fb603c9ad402d8c0", - "is_verified": false, - "line_number": 118 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3c8bfe5a9f458f3884e67768465ac1c17ff80e0f", - "is_verified": false, - "line_number": 119 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3f01eb8d14a37b6e087592d109baf01e603417eb", - "is_verified": false, - "line_number": 120 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "021709695261ffbc463f12b726d9dd6c27abb6f0", - "is_verified": false, - "line_number": 121 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a09a21e3684c15de00769686d906f72dd664f663", - "is_verified": false, - "line_number": 122 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "15a62195ff8e8694bfd7045af4391df383b990ed", - "is_verified": false, - "line_number": 123 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "010fa027e45282a3941133bf3403ab98cacc9edd", - "is_verified": false, - "line_number": 124 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e19fd3f99a05ccf60d1083f5601dea6817b1ac03", - "is_verified": false, - "line_number": 125 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d17a8e92d9f18e17c7477d375dcac30af8c34ff5", - "is_verified": false, - "line_number": 126 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c33ae1092a63f763487a4e0d84720b06a2523880", - "is_verified": false, - "line_number": 127 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9486a607ef0dcb94ce9ac75a85f0a76230defd1d", - "is_verified": false, - "line_number": 128 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "1d850e2d57c74a691b52e3e2526c2767865fb798", - "is_verified": false, - "line_number": 129 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "60a0c030c7e8a5beddd199d1061825b5684ab4ae", - "is_verified": false, - "line_number": 130 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2986a818d44589ee322b0d05a751b9184b74ebac", - "is_verified": false, - "line_number": 131 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "440aad6aaad76b0dab4c53eb8a9c511d38f5ee1c", - "is_verified": false, - "line_number": 132 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "372c99f2afefff2b07dd4611b07c6830ec1014f3", - "is_verified": false, - "line_number": 133 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "99678a4cbb8d20741f35f04235ee808686a5ee52", - "is_verified": false, - "line_number": 134 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3486b5c6f177ac543d846a9195d3291a0d3bd724", - "is_verified": false, - "line_number": 135 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2902179aba6cb39f2c7b774649301a368a39b969", - "is_verified": false, - "line_number": 136 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "4108ee51d5c321b98393b68a262b74d6377cec76", - "is_verified": false, - "line_number": 137 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8abe8434123396924dc964759bc7823d59b31283", - "is_verified": false, - "line_number": 138 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a2a8363585b5988aeff2a2c8c878c15445322a52", - "is_verified": false, - "line_number": 139 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "bbbcc1630c23a709000e6da74ca22fe18b78b919", - "is_verified": false, - "line_number": 140 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "be582fadd937879b93b46e404049076080faed08", - "is_verified": false, - "line_number": 141 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "15320eb2e8d97720f682f8dc5105cb86a539a452", - "is_verified": false, - "line_number": 142 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "611278690506b584ecc5d4c88b334dbe7e9b8c54", - "is_verified": false, - "line_number": 143 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8a08069ce7a3702f245f8c50ac49a529092384be", - "is_verified": false, - "line_number": 144 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8cf1444399ca01a1bf569233106065b30c103cd2", - "is_verified": false, - "line_number": 145 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "4a5a11832d16a4c2c6914d05397ce3e6f457572f", - "is_verified": false, - "line_number": 146 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "80490973b1980ad3740d42426c7c0f2986cbe462", - "is_verified": false, - "line_number": 147 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "495d2b2d95ba56eded4e4d738b229dd5caaeea67", - "is_verified": false, - "line_number": 148 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2264d1d1a69546223eb2754465a1b40ce20ab936", - "is_verified": false, - "line_number": 149 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "6e9e9f0b269aacbf7358498c088c226a9296de14", - "is_verified": false, - "line_number": 150 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "1cb9e17cefe3759cb8fd0de893e8a12531c4375b", - "is_verified": false, - "line_number": 151 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ddc15a0e8c7caca06cf93d15768533595b8ba232", - "is_verified": false, - "line_number": 152 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7dbafb9953c44da0cc46c003d3dacd14a32a4438", - "is_verified": false, - "line_number": 153 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "be61d29ac11ba55400fcaf405a1b404e269e528e", - "is_verified": false, - "line_number": 154 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2e65dec5c2802e2bb8102d3cd8d0a7e031a6b130", - "is_verified": false, - "line_number": 155 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c43e69c82865cf66a55df2d00a9e842df3525669", - "is_verified": false, - "line_number": 156 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "084448bff84b39813fc1efe3ff5840807d7da8f9", - "is_verified": false, - "line_number": 157 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e175aaf2f1a6929f95138b56d92ae7b84b831ffe", - "is_verified": false, - "line_number": 158 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9d6deadf9c4eb8ea0240ecca10258afb9b39e0a2", - "is_verified": false, - "line_number": 159 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "4bf318f05592507a55a872cdb1a5739ad4477293", - "is_verified": false, - "line_number": 160 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b71cc2bafb860b166886bb522c191f45d405cc76", - "is_verified": false, - "line_number": 161 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a723b7af4e7b4ede705855c03e4d3ac8b17a17a0", - "is_verified": false, - "line_number": 162 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "595c5493c18960b81043b1aaa0ada4a86a493f2b", - "is_verified": false, - "line_number": 163 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "dee9b3f8262451274b6451ead384675a75700188", - "is_verified": false, - "line_number": 164 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b300397e68cfcee9898e8e00f7395a27f8280070", - "is_verified": false, - "line_number": 165 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "44973e389b0e5b25d51439d6a9b6c9d43fdd6ee0", - "is_verified": false, - "line_number": 166 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "93ebcb14fec5ae9ae41b0bdce7d6aa2971298e47", - "is_verified": false, - "line_number": 167 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c5b1332b11dd3ba639ce2fdaaa025bad034207e9", - "is_verified": false, - "line_number": 168 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "4927a4f45fa60e6d8deb3d42ca896410d791f3db", - "is_verified": false, - "line_number": 169 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "081e263d2c8f882eb19692648f71ac03a8731c09", - "is_verified": false, - "line_number": 170 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ef5eba4fd8203b259dd839628ddc0d9a3ed6f97f", - "is_verified": false, - "line_number": 171 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c90d7323630daddb2824cd0d9e637521237e2454", - "is_verified": false, - "line_number": 172 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "99e13b6a3b2c3c60603df94711c67938be98e776", - "is_verified": false, - "line_number": 173 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2c55757167c8ecf90790ad052900e790f269619e", - "is_verified": false, - "line_number": 174 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f3e5c54b01b6e69be585cd9142ed7abe5d4056e5", - "is_verified": false, - "line_number": 175 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b0dd1c28e143d597218a174dbe0274598c59b9c8", - "is_verified": false, - "line_number": 176 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9a1fe8341b21243d6116f6b3375877b7fa9b34d7", - "is_verified": false, - "line_number": 177 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e6b9bc000db030828a117a2d31a0598a84120186", - "is_verified": false, - "line_number": 178 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8e40eebcfe379882ecbfb761bb470c208826ebf8", - "is_verified": false, - "line_number": 179 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "afd7a7532b580be96e7cc3c0e368a89f31ef621c", - "is_verified": false, - "line_number": 180 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "bfd20c7315b569fab2449be3018de404ed0d6fc3", - "is_verified": false, - "line_number": 181 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ccba0997cbb3cea20186ca1d3d3b170044e78f27", - "is_verified": false, - "line_number": 182 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "43cd2dcd4adf33ef138634454d93153671a58357", - "is_verified": false, - "line_number": 183 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7244b34d4c1c0014497a432c580eeea0498b7996", - "is_verified": false, - "line_number": 184 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ec96512c56ade3837920de713f54fa81e6463a5b", - "is_verified": false, - "line_number": 185 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f9ab8ac96faef103a825c131a9f6aa18aaf5c496", - "is_verified": false, - "line_number": 186 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "988b02f25fa7b8124ad9d5e3127ec7690bd7f568", - "is_verified": false, - "line_number": 187 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "71d4e0487a5ed7f3f82b2256bed1efb3797c99e2", - "is_verified": false, - "line_number": 188 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "4dad8db6d2449abd1800ac11f64dd362f579a823", - "is_verified": false, - "line_number": 189 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d079b5fbe50b0b84ad69a0d061b4307a3a0a6688", - "is_verified": false, - "line_number": 190 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c2672b9214bb9991530f943c1a5a0d05977c0f0a", - "is_verified": false, - "line_number": 191 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f3a8f4566cd7f256979933da8536f6dafb05d447", - "is_verified": false, - "line_number": 192 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e3b44891d5e5ec135f1e977ec5fd79c74ca11d9c", - "is_verified": false, - "line_number": 193 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8542da23c2d0a4b0bcab3939f096b31e3131d85f", - "is_verified": false, - "line_number": 194 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "fb281df2d7a6793a43236092a3fcc1b038db56c9", - "is_verified": false, - "line_number": 195 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "727686c68fa10c5edecbf37cdfec2d44f3a5f669", - "is_verified": false, - "line_number": 196 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e7957179705dafeab8797bb8f90fcaf5ad0a61ee", - "is_verified": false, - "line_number": 197 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7424aea64d7c75511030d719e479517e8bef9d25", - "is_verified": false, - "line_number": 198 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3ad22266e9a3214addc49722b44d9559eb7cbedc", - "is_verified": false, - "line_number": 199 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8b00c700bf0f6c74820e1ad93d812f961989d69e", - "is_verified": false, - "line_number": 200 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2eef664e5193da7dde51adccd6d726a988701aaf", - "is_verified": false, - "line_number": 201 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9186e0986b4b7967aa03cfe311149d508d22e6aa", - "is_verified": false, - "line_number": 202 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "1a639bb9895dc305d6db698183635c1f8b173c5c", - "is_verified": false, - "line_number": 203 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b5fbec5f1451e2d940c70945a01323eda82984bd", - "is_verified": false, - "line_number": 204 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ebb046a7ba8464ce615d215edb8b1fd82a1357b6", - "is_verified": false, - "line_number": 205 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "719e3976a5a00a7473cd38f81f712ca8c6e522e1", - "is_verified": false, - "line_number": 206 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "12cde4d54e7136273e8aa76d161b6f143469ef6d", - "is_verified": false, - "line_number": 207 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e04ec69eef9a4325231986801ebd42d3159ccca7", - "is_verified": false, - "line_number": 208 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "07c8e9accb3cfcc748b91d0369629fa1ee90576f", - "is_verified": false, - "line_number": 209 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3b00038548a6119fba962ca93f6bd24035d5571e", - "is_verified": false, - "line_number": 210 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2914f579938a910fb510898044063bec779e5ad5", - "is_verified": false, - "line_number": 211 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "868cf20bb88168a03fa29c7261762c97430ea0fc", - "is_verified": false, - "line_number": 212 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "0475a43ad50f08c4a7012c4a87f15eeee3762ff9", - "is_verified": false, - "line_number": 213 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "5ebe715bd56f0448d0374adae8568a6d86856442", - "is_verified": false, - "line_number": 214 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9c6dff479fd398382a289dc8f60cabf06fa60a26", - "is_verified": false, - "line_number": 215 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "0102959abc9fee55edba97642bb1bcc546ce07dc", - "is_verified": false, - "line_number": 216 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "45459296596dbed9d7fbf7eab7a9645eb4fa107a", - "is_verified": false, - "line_number": 217 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "5a5a491d064e789e785a8b080d38d9d1cc7d207f", - "is_verified": false, - "line_number": 218 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f3005c052e76c7e804c10403bdfcd9265a9de2ea", - "is_verified": false, - "line_number": 219 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "73aaaaf5bcab49cc1b1f47b45eae9b31db783a66", - "is_verified": false, - "line_number": 220 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "13aae30474af34fdede678dc5e8c00c075612707", - "is_verified": false, - "line_number": 221 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "336edbc017f4dadc0bf047e0f6d1889679fc3b48", - "is_verified": false, - "line_number": 222 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7bff3213c39d3873551698ec233998613e6b69dc", - "is_verified": false, - "line_number": 223 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9f1a6484627a58c233e1ec3f0aeffe4ff2d8a440", - "is_verified": false, - "line_number": 224 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d7c80e31311e912fb766bb2348b02785c28d878b", - "is_verified": false, - "line_number": 225 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2c75cc7344d810bb26cb768be82e843af623001a", - "is_verified": false, - "line_number": 226 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "607df6be12ab20f70a64076c372b178d6c10bc00", - "is_verified": false, - "line_number": 227 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9b7fed64d1f0682953011eb4702467dee8cd1174", - "is_verified": false, - "line_number": 228 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e982d9359554bc4a5c58d9d8d4387843e6e5cbb4", - "is_verified": false, - "line_number": 229 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c2f3985aed2da033a083cb330fb006239b2a1c8e", - "is_verified": false, - "line_number": 230 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "23d658cf19e1e76efbfa3498d2c2ed091c60b1f4", - "is_verified": false, - "line_number": 231 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a58be87cd80825e211c567b3c5397e122f702019", - "is_verified": false, - "line_number": 232 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f96f43b99c2f249a03a2e57e097c236561a1162c", - "is_verified": false, - "line_number": 233 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2fc8f0d1c9fadfb9cc384af21c8d3716c99a40f6", - "is_verified": false, - "line_number": 234 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f229dfc403d5b25f3362e73c4a7dc05233ecd4b6", - "is_verified": false, - "line_number": 235 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "cf79e1dd8ff4c91b3346f5153780ba52438830be", - "is_verified": false, - "line_number": 236 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "20a1e643e857f0f63923b810289ab4b6c848252e", - "is_verified": false, - "line_number": 237 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9754246ca2c82802cc557d5958175d94ae5c760b", - "is_verified": false, - "line_number": 238 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ca0abe4a600e610c1bbbb25de89390251811ed1c", - "is_verified": false, - "line_number": 239 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b9c7402f138d31bea12092e7243ac7050a693146", - "is_verified": false, - "line_number": 240 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "07e9e0d4ea04d51535c0ec78454f32830dcfe8da", - "is_verified": false, - "line_number": 241 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9872435a00467574f08579e551e3900c65f2b36e", - "is_verified": false, - "line_number": 242 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "eec328050797cfffad3dc2dd6dd16d8ec33675f6", - "is_verified": false, - "line_number": 243 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b3b084478fcaec50b9f7e39dfef8bda422d48d91", - "is_verified": false, - "line_number": 244 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2093470fb2ffad170981ec4b030b0292929f3022", - "is_verified": false, - "line_number": 245 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b920a9ef2ec94e4e4edac20163e006425a391da4", - "is_verified": false, - "line_number": 246 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "06455554c00ce5845d49ebef199c0021b208d5df", - "is_verified": false, - "line_number": 247 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a077b13877b651822b80de2903f4b6acdbac3433", - "is_verified": false, - "line_number": 248 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "78fd658f1b01b01b25be00348caeced0e3ad0b29", - "is_verified": false, - "line_number": 249 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "79f7d6f792cc4e4ba79e3bf7cd3538fb65e4399a", - "is_verified": false, - "line_number": 250 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8280b950e62db218766e1087ec5771ec93de3b36", - "is_verified": false, - "line_number": 251 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "11fffafcae5d1e1aacf6f3c3a0235bbed17cacb2", - "is_verified": false, - "line_number": 252 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f0aebb371b0356a2e803f625a1274299544e0472", - "is_verified": false, - "line_number": 253 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "bce9139737d07f1759822ac6e458eff6c06c1dae", - "is_verified": false, - "line_number": 254 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a61bed5d464a3dd53f1814dc44da919124e2c72b", - "is_verified": false, - "line_number": 255 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9c553b7e8c46273c6e1841f82032a11f697cafe1", - "is_verified": false, - "line_number": 256 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "24535adb56bd8d682e42561ded0eaab8a1a18475", - "is_verified": false, - "line_number": 257 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7f16429d5dba0340ae2ec02921abbe054ad4d9fd", - "is_verified": false, - "line_number": 258 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "61bac3ad8d011d3db96793f70a9fdaf5def37244", - "is_verified": false, - "line_number": 259 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "413654967fff8eae5dd1fece27756c957721d131", - "is_verified": false, - "line_number": 260 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c42fd06a8e9c5ad8b9b3624c1732347dd992f665", - "is_verified": false, - "line_number": 261 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "53fbf2125f17fd346dba810d394774c191c05241", - "is_verified": false, - "line_number": 262 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "312ebc5348c48d940a08737cc70b257c7ba67358", - "is_verified": false, - "line_number": 263 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3c072673c95b839b4c75a59ffcb4e7de11df227c", - "is_verified": false, - "line_number": 264 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "67dcac03bb680bd7400daff1125821df29119a57", - "is_verified": false, - "line_number": 265 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "74ceb07916759595af8144a74de06f4622295fab", - "is_verified": false, - "line_number": 266 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "becd47f7a933263c4029eb3298bdf67e64166b72", - "is_verified": false, - "line_number": 267 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "62cbb7af58e6841cb33ae8aa20b188904e88400b", - "is_verified": false, - "line_number": 268 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "1240f6fbe789e15d2488a1f63a38913ace848063", - "is_verified": false, - "line_number": 269 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b313e2c9b9b7a229486000525bd2bfd909c739c3", - "is_verified": false, - "line_number": 270 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9ccd84180f08a811fc82fc6c2baa43b92b0c6d4c", - "is_verified": false, - "line_number": 271 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "fec498a62202037efd0ff28ff270b1d65600ee21", - "is_verified": false, - "line_number": 272 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "5e5991defd9bf4c9cd7ad44bfc3499b021f9b306", - "is_verified": false, - "line_number": 273 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3ac80ba9980be6af93aa361f71cc0b24ebb9a80d", - "is_verified": false, - "line_number": 274 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3e58a970f8a2580b7929b87623a05bcfd18ff5d0", - "is_verified": false, - "line_number": 275 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "4e95912a938c4a5d793d6147f17b1a4f4564f521", - "is_verified": false, - "line_number": 276 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b9c19621f11904336bb1c83271b6e66392139adf", - "is_verified": false, - "line_number": 277 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ea26c6b69a1fbd9d19136131f1a4904190cdc910", - "is_verified": false, - "line_number": 278 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "88806d10d6a88e386d7bffe5ed9d13a01aa30188", - "is_verified": false, - "line_number": 279 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "92c4052a065855d439918461deb8ab1d85b8dec4", - "is_verified": false, - "line_number": 280 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "5a801127b30267b3143bcd1879b09ce966f4e4db", - "is_verified": false, - "line_number": 281 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "03c0a54929a02a84158ffbab6a79ba8a31bbea5e", - "is_verified": false, - "line_number": 282 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9adc71007b98c2f47eb094b8c771d0a2c81e8584", - "is_verified": false, - "line_number": 283 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "19cc3f05c05fc6ff92f9a56656d3903fb6e05af1", - "is_verified": false, - "line_number": 284 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "901c70145ec0a76f9705743bc180ac505301db81", - "is_verified": false, - "line_number": 285 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e264698710238eada7824909e03b11a1d5b94d01", - "is_verified": false, - "line_number": 286 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e74cd3a559f33f9541ef286068dee5338b7c2f5d", - "is_verified": false, - "line_number": 287 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a0b7170416566ab964d395d0cf138ecd3c65fe2c", - "is_verified": false, - "line_number": 288 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c9c183b3a85dec6b215a6a18a1f0ce82381c12a6", - "is_verified": false, - "line_number": 289 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "06b739bfeff8deb1f44a03424e08ab08f1280851", - "is_verified": false, - "line_number": 290 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "25dc7c4a6b8bfdcb8bc41e815d05dac7fa905711", - "is_verified": false, - "line_number": 291 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "1b298510f55fd15ee6110b2a9250263dbc9f4fc9", - "is_verified": false, - "line_number": 292 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "6403b53b45d57554b17c4388178cd5250aa7587a", - "is_verified": false, - "line_number": 293 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f944cf9178e33e14fddf0ac6149cbb69e993d05c", - "is_verified": false, - "line_number": 294 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "61b4fee247e19961be2d760ed745da4e39d8bf4e", - "is_verified": false, - "line_number": 295 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d25d1f3178dd3a9485d590ce68bd38b3029d0806", - "is_verified": false, - "line_number": 296 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9fdfeae6046b80e2ae85322799cdc6da4842f991", - "is_verified": false, - "line_number": 297 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f7143b0c85044b4b76ef20cd58177815daf7407e", - "is_verified": false, - "line_number": 298 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "5e605f0950f7c24e192224fa469889b9c83c80ac", - "is_verified": false, - "line_number": 299 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "329c29edf1fb8e3427b1d79a30e77a700c01ff5c", - "is_verified": false, - "line_number": 300 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "74a03233311d2f477a3dd7ffa81c7343586b1f8e", - "is_verified": false, - "line_number": 301 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3b1df47dbd920bfaf1de8a7b957d21d552d78a76", - "is_verified": false, - "line_number": 302 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "929a23cdbe2b28de6dac28454d1e7478a4a14fea", - "is_verified": false, - "line_number": 303 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a6436a4a36cd90e5d03b33f562213dfc3d038455", - "is_verified": false, - "line_number": 304 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a010833ccd24af9e70339bac73664fb47b6ac727", - "is_verified": false, - "line_number": 305 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "53be5a9c1c894e77c4fcdfbbb3b003405252ed79", - "is_verified": false, - "line_number": 306 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "61b289fe5c2eb0d8b8bc5b1cc5e9855472daabd9", - "is_verified": false, - "line_number": 307 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "773307c58ca81fd42a4734bbc4b3c7eb8bcfd774", - "is_verified": false, - "line_number": 308 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "35f607d2769173d1672e30f60b9276d01b8250d7", - "is_verified": false, - "line_number": 309 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e602d5d9691c09f57a628600014aaae749d38489", - "is_verified": false, - "line_number": 310 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "625238f7e6c9febfca3878a385daa7b8646a2439", - "is_verified": false, - "line_number": 311 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e6ba52cd1f2f9a30963834fd94aafc869bf05b82", - "is_verified": false, - "line_number": 312 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d629b569233f71690b6e6eaed9001e44b88c50bf", - "is_verified": false, - "line_number": 313 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a001d4059055a1c86b9ec62774d044b54ddb3376", - "is_verified": false, - "line_number": 314 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "bce06d4b0177a2d06399e21e0b26bc99e44d6e9b", - "is_verified": false, - "line_number": 315 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "cb6af31518d65e6dcb92fb01b9f31556c3a70c5e", - "is_verified": false, - "line_number": 316 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c2a95352f382fdbe53bd8b729a718c38eacfbf73", - "is_verified": false, - "line_number": 317 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f9b16dccab1e453362789df2fc682f2ba2c9ee2a", - "is_verified": false, - "line_number": 318 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "1bb4e4fd05b7c33cfab0dad062c54a16278d3423", - "is_verified": false, - "line_number": 319 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9dcc6dc6f20a71fd6880951ceb63262d34de8334", - "is_verified": false, - "line_number": 320 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "666382b579258537d6cf5e7094dbaa0684b78707", - "is_verified": false, - "line_number": 321 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "072c49f046dfdce12c1553a67756e2f5ee4d7e49", - "is_verified": false, - "line_number": 322 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "47b792bdebbbf305d87092f12c0afcd8810e054d", - "is_verified": false, - "line_number": 323 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "41d3b22a387fa43c1491d62310faf50c4ab7956a", - "is_verified": false, - "line_number": 324 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "bcdc3859e08c518f75cfe65b69f3adb9f489400b", - "is_verified": false, - "line_number": 325 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "fc2b22e2d43816acf209af822877aff7e82fa4d0", - "is_verified": false, - "line_number": 326 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f63542bc2eb9de2caa3bfaeafd53d7bf65485889", - "is_verified": false, - "line_number": 327 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7ab01f0f438a3d21b529df89fbde67234aa49d89", - "is_verified": false, - "line_number": 328 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "fed608fe9221f0e45c84b68a80a0c065a9a2b7f1", - "is_verified": false, - "line_number": 329 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7a6394c70b925009c3e708ec195a17ee40cae8f4", - "is_verified": false, - "line_number": 330 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "5d615bd2adf567fe7403c51814ff76c694b1c8d3", - "is_verified": false, - "line_number": 331 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "77f3c695d15ee63db41dabcecce126a246b266e6", - "is_verified": false, - "line_number": 332 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "78138e46003e12617c75a8011fddbe2868ff5650", - "is_verified": false, - "line_number": 333 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "89c905852505ac6168e4132b5ee29241a64b2654", - "is_verified": false, - "line_number": 334 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3d55f361c5d2bf2c1ec7d2c2551d7bec67b3cc35", - "is_verified": false, - "line_number": 335 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "89f1aec19abc18d22541dc01270e0fee325a878b", - "is_verified": false, - "line_number": 336 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "23ed3413498b5fe9fe2d6d3ae4040a0e2571c9df", - "is_verified": false, - "line_number": 337 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e7f990c94d57f6880b1e2cf856ab0646636bc46a", - "is_verified": false, - "line_number": 338 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "87dccf8b7123c723b5c35c45533d7471a19c9c22", - "is_verified": false, - "line_number": 339 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "14a222dcf6b592c1178fae0babbb73d809102462", - "is_verified": false, - "line_number": 340 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "161b87029fb1fe5f37573770659140c254b6f26d", - "is_verified": false, - "line_number": 341 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e01ccf01c8ae560637e1fba1396ec9d27a48943e", - "is_verified": false, - "line_number": 342 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "0d45bd0e0858d416488ca24b5e277430fdbc29a2", - "is_verified": false, - "line_number": 343 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "bd6b3d87fee3f95d7bbe77782404507c7d6d23ba", - "is_verified": false, - "line_number": 344 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "297eface47da40362e6c34af977185a96ecd4503", - "is_verified": false, - "line_number": 345 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "1d908d54bd47e7b762cf149a00428daf8ab41535", - "is_verified": false, - "line_number": 346 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e0404cb2e3feaba3e7bdc52c798b9bce57f546d3", - "is_verified": false, - "line_number": 347 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8dc5b0bbc5b3c3f93405daac036e950013ae6e83", - "is_verified": false, - "line_number": 348 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c914f94ead99fe6e6b262f63f419aba9f1f65cc9", - "is_verified": false, - "line_number": 349 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "5d2559e8fbde4bdf604babb1a00a92f547e9c305", - "is_verified": false, - "line_number": 350 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b28706495d2c7f4e44a064279570ec409025bce8", - "is_verified": false, - "line_number": 351 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ce77aa4f51f5ee1a1f56ba0999a3873e07bdec29", - "is_verified": false, - "line_number": 352 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c828435ec3655b9b44974c212f94811121d3183c", - "is_verified": false, - "line_number": 353 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "0361b85a6a04d362a8704e834cd633a76d7c8531", - "is_verified": false, - "line_number": 354 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e8b43fe4aa4ece98317775e13e359f784187c9ea", - "is_verified": false, - "line_number": 355 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ec00a6364212bbc187bc15f3a22ec56eb7d5d201", - "is_verified": false, - "line_number": 356 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "5599c260b57d92c0f8bd7613fa1233ad9f599db3", - "is_verified": false, - "line_number": 357 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d11065d4dd0b6fd8e29dd99b53bfbe17e1447ab3", - "is_verified": false, - "line_number": 358 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c8c47349a7991ac9cb1df02c20e18dde2ec48b9c", - "is_verified": false, - "line_number": 359 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e5302dc80bfbd04a37e52099a936c74b38d022ec", - "is_verified": false, - "line_number": 360 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "4a4e17621d292bddf3604bcc712ed17fdd28aca2", - "is_verified": false, - "line_number": 361 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a43a1929d714363194cc42b3477dfe9b4c679036", - "is_verified": false, - "line_number": 362 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "645e56a2836118de395a78586b710ac24c6d1b9d", - "is_verified": false, - "line_number": 363 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c0f20d875c6d2d8e99539de46a245a5a30e757d0", - "is_verified": false, - "line_number": 364 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "fb552bf2f6ea4da1a8d0203ac4c6b4ecb1bbea56", - "is_verified": false, - "line_number": 365 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "53c6b8e08eeb37812e6e40071ac16916c372b60f", - "is_verified": false, - "line_number": 366 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c64cf6bc4ec02fa8b2bf2f5de1c04f0a0c8ec77d", - "is_verified": false, - "line_number": 367 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e7dc30b59854ec80d81edc89378c880df83697c4", - "is_verified": false, - "line_number": 368 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e60404864ae5ddda3612f7ece72537ab2a97abf7", - "is_verified": false, - "line_number": 369 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a84bea5c674feff72b4542a20373b69d25a47b89", - "is_verified": false, - "line_number": 370 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "47cbc18c75b60b6e0ed4d8b6a56b705a918e814b", - "is_verified": false, - "line_number": 371 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "cd8bc0fe19677ebb0187995618c3fa78d994bbb2", - "is_verified": false, - "line_number": 372 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "887786ac035ae25cc86bd2205542f8a1936e04d2", - "is_verified": false, - "line_number": 373 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3ef2e1c199d211d5f1805b7116cb0314d7180a5c", - "is_verified": false, - "line_number": 374 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f89746f236eab3882d16c8ff8668ed874692cde3", - "is_verified": false, - "line_number": 375 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2b3db4dc1799edfee973978b339357881c73d3ab", - "is_verified": false, - "line_number": 376 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b7254fda5baf4f83d6081229d10c2734763d58b4", - "is_verified": false, - "line_number": 377 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9af3e435c37c257b5e652e38a2dfd776ab01726e", - "is_verified": false, - "line_number": 378 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "833be77b754d40e1f889b7eda5c192ae9e3a63fe", - "is_verified": false, - "line_number": 379 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a153d9446771953d3e571c86725da1572899c284", - "is_verified": false, - "line_number": 380 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "68d2128a64a2b421d62bc4a5afeeb20649efe317", - "is_verified": false, - "line_number": 381 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "92490f06bfafdb12118f5494f08821c771abafff", - "is_verified": false, - "line_number": 382 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "84a479485dd167e8dc97cce221767e68cbe14793", - "is_verified": false, - "line_number": 383 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ca9c140d7b9b6dbf874d9124b3de861939eb834e", - "is_verified": false, - "line_number": 384 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d293b3b1e9c7e4b8adde8f2a8d68159c72582f71", - "is_verified": false, - "line_number": 385 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "120db881813bc074d8abb7a52909f1ffc4acf08b", - "is_verified": false, - "line_number": 386 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "6be68465c1bce11d46731c083c86cc39b4ca4b26", - "is_verified": false, - "line_number": 387 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ec613f94f9c8e0a7c9a412e1405a0d1862888d44", - "is_verified": false, - "line_number": 388 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "53300289cf9589a5e08bfa702e1f3a09d2d088b1", - "is_verified": false, - "line_number": 389 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "aac8dac3f68993b049bcc04acbb83ee491921fa8", - "is_verified": false, - "line_number": 390 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b309b1a5cda603c764ed884401105a00c1a1b760", - "is_verified": false, - "line_number": 391 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c1d9acf0ca3757e6861a2c8eab08f6bf39f8f1a3", - "is_verified": false, - "line_number": 392 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "39860c432a27f5bcbcd30b58cdd4b2f8e6daf65f", - "is_verified": false, - "line_number": 393 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f28f8289110a85b1b99cd2089e9dfa14901a6bbe", - "is_verified": false, - "line_number": 394 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7c51dd968d2ae5ffad1bc290812c0d6d3f79b28a", - "is_verified": false, - "line_number": 395 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "19e03888ea02a1788b3e7aacdb982a5f29c67816", - "is_verified": false, - "line_number": 396 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "936e0dfc9fa79e90eabe1640e4808232112d6def", - "is_verified": false, - "line_number": 397 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "66b03fc6f79763108c0e0ebced61830ce609d769", - "is_verified": false, - "line_number": 398 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b4615dacf79e97a732e205acd45e29c655a422cb", - "is_verified": false, - "line_number": 399 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "4e9cab1ac24cee599dc609b69273255207fb9703", - "is_verified": false, - "line_number": 400 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7c2d628057af1a5f9cdc10e1a94d61fa2f43671c", - "is_verified": false, - "line_number": 401 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "1f76628414c76162638c6cdd002f50d35c0030df", - "is_verified": false, - "line_number": 402 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "656cd81676438907b67dc35f1dcbc7f65fb44eae", - "is_verified": false, - "line_number": 403 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2b7c94fe6035b5e6d98a65122fd66d9fbc0710f6", - "is_verified": false, - "line_number": 404 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d55f6f2d0aff7554ed2c85a4f534c421ba83601a", - "is_verified": false, - "line_number": 405 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "742a9e62c813d9b6326e2540f1f9f97dfca8542c", - "is_verified": false, - "line_number": 406 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8b446fd2f0b22dc0fdfee36b5b370643b669bd2d", - "is_verified": false, - "line_number": 407 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ce38475ba93df187a8dd9972a02437ffef9e849c", - "is_verified": false, - "line_number": 408 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e5581573b5114490af9bdc16bad95dca6177f4ba", - "is_verified": false, - "line_number": 409 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2f005879125b38683f71c8a64bd232cd11591e08", - "is_verified": false, - "line_number": 410 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7e1581a6326b6fb0d8f18d69631ee8ee2a2b3d50", - "is_verified": false, - "line_number": 411 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e5814a47cd07ed2435b048b8b97f41be6cd2c9eb", - "is_verified": false, - "line_number": 412 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "72a7b76523b4eda36ffdd63ac1bcd4f52063e387", - "is_verified": false, - "line_number": 413 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3d2aeb7f6499d336ff54871823348b2bf58e7c89", - "is_verified": false, - "line_number": 414 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ca1473b861759dfa5fb912c2a7c49316897cafa5", - "is_verified": false, - "line_number": 415 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "5bc665714e4b5b73c47d7e066567db6fde6ff539", - "is_verified": false, - "line_number": 416 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8f2f91164826d44904bc522f6680822bfd758342", - "is_verified": false, - "line_number": 417 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c9c956b3f172ca5ed76808abd98502a3499268f1", - "is_verified": false, - "line_number": 418 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b0c287a3b80addbf5fe7eb56f10dd251368ba491", - "is_verified": false, - "line_number": 419 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "5da8ed9d858656f49131055a4b632defccffd4dd", - "is_verified": false, - "line_number": 420 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "23dd6031c249baabd4b92e8596f896bbc407eb7e", - "is_verified": false, - "line_number": 421 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c58b01cfd3befe531fdad283418fa7ac558cea5f", - "is_verified": false, - "line_number": 422 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "32a9671da53c8e3572ffd9303171adf6ae95a919", - "is_verified": false, - "line_number": 423 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "60789728174b9ee630b33b2af057e0c6a0180947", - "is_verified": false, - "line_number": 424 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "073252599d795b92b38cbad3ed849f1c5fd5368b", - "is_verified": false, - "line_number": 425 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "761bcb628d3c585abebaa8a64b04ab193f5a559e", - "is_verified": false, - "line_number": 426 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "dd230524f2606a207b426444142d01d518781aef", - "is_verified": false, - "line_number": 427 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3b459c62a8c9fe3401808103493996348ef70870", - "is_verified": false, - "line_number": 428 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "70dbcfd2a8a038e265a0d3d6379284b679226101", - "is_verified": false, - "line_number": 429 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "29398aafd66a1c4f181e540ec90a2b76dcdfe2cc", - "is_verified": false, - "line_number": 430 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "4698c1c5c6daf3f88ec2768de0693d543e81c8b5", - "is_verified": false, - "line_number": 431 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "cd333285b1ef33582b502f72b4a153a16a4678a9", - "is_verified": false, - "line_number": 432 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b2c2475773928e727fd3ba3969aaae40ab2b99b2", - "is_verified": false, - "line_number": 433 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c28676c2076efac73f3d01195ed463c6d7a6f442", - "is_verified": false, - "line_number": 434 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c520370cf0e7b1bcc405af46775963a7df856b9d", - "is_verified": false, - "line_number": 435 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "fcd376b4fd7ecf2299b1ad018e66732a5e74ee08", - "is_verified": false, - "line_number": 436 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f9a69a2290885d929addfd83a6c1570dc7c76646", - "is_verified": false, - "line_number": 437 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "5fdb5ce747a93d7048f4fd3a428653520b3efb50", - "is_verified": false, - "line_number": 438 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "4ca9129303ac0d5e4e1b810e7abf90ea11a16833", - "is_verified": false, - "line_number": 439 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f83fb00877111e23db5ceb8b74255963d17c84e9", - "is_verified": false, - "line_number": 440 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "17e35c47564c0e6fefa2946f24d71618053bcfb7", - "is_verified": false, - "line_number": 441 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "fab7d05454c71ae59bade022116124571421e4c4", - "is_verified": false, - "line_number": 442 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7820b9feb8912aee44c524eedf37df78b8d90200", - "is_verified": false, - "line_number": 443 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ea2a0f7323961fd704b1bad39ae54e02c9345d2a", - "is_verified": false, - "line_number": 444 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "353fcf93df94d7081d2bd21eab903cf8e492f614", - "is_verified": false, - "line_number": 445 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7149d4db2de10af66a4390042173958d5fa1cbde", - "is_verified": false, - "line_number": 446 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "85b4428454e38494e03e227d224ae58a586ab768", - "is_verified": false, - "line_number": 447 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "df83530e6fb8ccd7f380c5dc82bc8c314b82436a", - "is_verified": false, - "line_number": 448 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "106157744da44adeb38c39220b1db267c26deb77", - "is_verified": false, - "line_number": 449 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c5e67d1eed731314ac68f5e67cb7b7dba68225f5", - "is_verified": false, - "line_number": 450 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d9737cec69cbdedea1a2d9a70d7961ff76592696", - "is_verified": false, - "line_number": 451 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7aab6c9118166720f0f0e3a9db46fd59e3ed647d", - "is_verified": false, - "line_number": 452 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "500a58b74d63b4c10c8c098743d63e51a477c9cd", - "is_verified": false, - "line_number": 453 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "69a150ffbef689cc7a14cfc019e9c808b19afd4a", - "is_verified": false, - "line_number": 454 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "49d3801a82b82e48cbcc596af60be9d4b72bbd76", - "is_verified": false, - "line_number": 455 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "5f3e17df79af2812cc6b5dbc211224595f8299a8", - "is_verified": false, - "line_number": 456 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "5f21f46cef784459cbac4d4dc83015d760f37bcf", - "is_verified": false, - "line_number": 457 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "4a91f36506d85a30ddc1a32f9ed41545eeb1320f", - "is_verified": false, - "line_number": 458 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b99666bc5cc4bf48a44f4f7265633ebc8af6d4b7", - "is_verified": false, - "line_number": 459 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c061353e73ac0a46b366b0de2325b728e3d75c5b", - "is_verified": false, - "line_number": 460 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d17d588edde018a01f319f5f235e2d3bcbbe8879", - "is_verified": false, - "line_number": 461 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "63567656706221b839b2545375a8ba06cd8d99ae", - "is_verified": false, - "line_number": 462 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "976e5ce3af12f576a37ce83ccf034fd223616033", - "is_verified": false, - "line_number": 463 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "626b3f10041c9e9a173ca99252424b49e3377345", - "is_verified": false, - "line_number": 464 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f8ba93d3a155b11bb1f2ef51b2e3c48c2723ef8e", - "is_verified": false, - "line_number": 465 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8b4879aed0c0368438de972c19849b7835adb762", - "is_verified": false, - "line_number": 466 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d35dbaf2ea5ec4fc587bed878582bba8599f31c0", - "is_verified": false, - "line_number": 467 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c09d7037f9b01473f6d2980d71c2f9a1a666411c", - "is_verified": false, - "line_number": 468 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d53d7f86659a0602cd1eb8068a5ad80a85e16234", - "is_verified": false, - "line_number": 469 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "aa9442f71f2747b5bb2a190454e511a7c62263d8", - "is_verified": false, - "line_number": 470 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f800b1fed08ed55a8e2a9223fc3939c96f3e11e5", - "is_verified": false, - "line_number": 471 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e46a4855198ba0f803471fb44a70ae5fbd2dd58f", - "is_verified": false, - "line_number": 472 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f47b48b6b7c2847fbe206253667d1eda00880758", - "is_verified": false, - "line_number": 473 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a9d98ab785981fe0f13a721e7fe2094a6e644b5d", - "is_verified": false, - "line_number": 474 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "fe151aabb001edb57e3fed654d3a96e00bc58c81", - "is_verified": false, - "line_number": 475 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "77c40b5a173e170886069d57178c0074dfe71514", - "is_verified": false, - "line_number": 476 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "04e04736dcf54eb8a8ef78638b0b0412cab69e96", - "is_verified": false, - "line_number": 477 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b13a34e3be842da54436ed8ab8f2a9758b2cc38e", - "is_verified": false, - "line_number": 478 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3971f1dcb845e4eaedcb04a6505fd69e27b60982", - "is_verified": false, - "line_number": 479 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "1b8ae7b1c309866e28fe66e07927675ce0e24514", - "is_verified": false, - "line_number": 480 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "4c3f6543b234d2db27b1a347b3768028dd60bc77", - "is_verified": false, - "line_number": 481 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ca4ac68931f7c54308050c1b6ac9657c4ff0d399", - "is_verified": false, - "line_number": 482 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "02cca5fc17dc903feb5088abec3d2262f604402e", - "is_verified": false, - "line_number": 483 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d864c37f23cab8cff54e9977a41676319c040928", - "is_verified": false, - "line_number": 484 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e67a5309737b99b0ac9ba746ca33d6682975cea1", - "is_verified": false, - "line_number": 485 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "aef65112b27cc0ecbcfbd3ae95847e9e0fbee0b7", - "is_verified": false, - "line_number": 486 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "40d73861d177d9e22d977dd62b8a111bbf8ee0b7", - "is_verified": false, - "line_number": 487 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "71e44d4a353467958cd9be3a7e6942385e883568", - "is_verified": false, - "line_number": 488 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e1f00f9205b689ba1d025f88e948f03a4ac77a59", - "is_verified": false, - "line_number": 489 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "6a9f1470e772a7f4176e8c24b7ab0e307847b92b", - "is_verified": false, - "line_number": 490 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "5959a3a8554f9ce7987b60e5e915b9e357af0d99", - "is_verified": false, - "line_number": 491 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b0a791edf8675bd6a65fc9de9ba5bcb8336d1fc0", - "is_verified": false, - "line_number": 492 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "557bcf89f60a98f72b336e21f56521a4c30a2f0c", - "is_verified": false, - "line_number": 493 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "80e8a78fd29c2ac00817f37e03d9208f8fd59441", - "is_verified": false, - "line_number": 494 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "351dded8c590b80cc8dc498021fccadc972c1d00", - "is_verified": false, - "line_number": 495 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "4f55ad2c0e5a697defde047e6a388c14b3423cda", - "is_verified": false, - "line_number": 496 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "20412c530d4b4c38510d9924cbfb259126c2568c", - "is_verified": false, - "line_number": 497 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "05e66772d14918a72d1b6f45872428a35c424347", - "is_verified": false, - "line_number": 498 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c61a40f7ae13f5e26ea16a6266491d58e78f6f1f", - "is_verified": false, - "line_number": 499 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b4d93dd6c2e36056d55ce3844610991eec962277", - "is_verified": false, - "line_number": 500 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c7088e4ff6e5a3bc44ca3fdf1b06847711f3e95c", - "is_verified": false, - "line_number": 501 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "5e5168774b473fb9fcc31c8f5c1518eb0f9771c1", - "is_verified": false, - "line_number": 502 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a1f86c50a6626bcab082286bec7f5474e7c8b293", - "is_verified": false, - "line_number": 503 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a9fac6e3490672c5dccd35d5e6fc1cb7b1b5931b", - "is_verified": false, - "line_number": 504 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b48c69b346d712e3df1728014956ac0397c659ea", - "is_verified": false, - "line_number": 505 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8367e351d57fa775f22fc1132dd170c458799542", - "is_verified": false, - "line_number": 506 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "972953c33baa3303c488360576bdd3bae95e79a3", - "is_verified": false, - "line_number": 507 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2ef2d21dde1d6ef435fbf1b6a049f7e94a2d5588", - "is_verified": false, - "line_number": 508 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "76bf193e8f7b54ab5f0007ee41b768ee1e3ce24d", - "is_verified": false, - "line_number": 509 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e8e93efe226e4bf62b880c14bdef1507dc67c4fe", - "is_verified": false, - "line_number": 510 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "71cd9e3eb02ec34d305a55df09540b95549f8342", - "is_verified": false, - "line_number": 511 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "34c2c4351cc369f306886089967adc3fd23202b5", - "is_verified": false, - "line_number": 512 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "95a9e6645670ef390609e97a9a94ab1af8ecb5e5", - "is_verified": false, - "line_number": 513 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7a773ead4f5cbee039dd9c90bcbd2157ff9dfe98", - "is_verified": false, - "line_number": 514 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c8974d5459c5318a865674227914120b61ee7ca8", - "is_verified": false, - "line_number": 515 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9aa53dd7b54460ca4058dc1b993c61c85016c3a5", - "is_verified": false, - "line_number": 516 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "5cf42e6632ac13c10b1709348bda0d36d4cc8fe2", - "is_verified": false, - "line_number": 517 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "22368f64933f9d4b20751ed12db25bdb937f4288", - "is_verified": false, - "line_number": 518 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "558145b7f5778e24056c8de59bd9d54190950f14", - "is_verified": false, - "line_number": 519 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2068d5b68ddc59653056d96e1283951282b22267", - "is_verified": false, - "line_number": 520 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "4d807498a9a96f89bb538a8308d6056a2a303a0d", - "is_verified": false, - "line_number": 521 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3457741ed34d5ad7b9d04fa9cc677a72e8c47b4d", - "is_verified": false, - "line_number": 522 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "59556e4aa33301c95feb9c58d99d10a080179646", - "is_verified": false, - "line_number": 523 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2d49954101a3bd1dd5da50b8a1847f00bf4ec16b", - "is_verified": false, - "line_number": 524 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c2f14cff186baad8445fb7997c3dc863eff10ef6", - "is_verified": false, - "line_number": 525 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "dd317a7973e49de529850041e8c1ce51b0d378df", - "is_verified": false, - "line_number": 526 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9cbaaf4ff0453e81aaac598e05d8c973991c77b3", - "is_verified": false, - "line_number": 527 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "576dd6a98701c267f16a5e568f8b6a748665713d", - "is_verified": false, - "line_number": 528 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c5ce7f45e2ddbd43d244e473e165b1400ba86dd9", - "is_verified": false, - "line_number": 529 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "04a10a70b498263467ef1968fabfb90e012fd101", - "is_verified": false, - "line_number": 530 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "482928d9b3b49339bc5f96e54f970e98f84970b7", - "is_verified": false, - "line_number": 531 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "24d25f3a906f38241bd1d3dfa750631cd4b2f91f", - "is_verified": false, - "line_number": 532 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8cc46e3c020e63d10457e32b2e5d28b5c7ce0960", - "is_verified": false, - "line_number": 533 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "da272306205373082db86bc6bc2577ab85ed9e31", - "is_verified": false, - "line_number": 534 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b03284305e4d5012e7c3cf243b2942a6dab309cc", - "is_verified": false, - "line_number": 535 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f7c91578b688a0054f2c1e18082541d6ecc6b865", - "is_verified": false, - "line_number": 536 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "1f009c80b8504a856a276e8d2c66210b59e8bf2e", - "is_verified": false, - "line_number": 537 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "54490e77b2c296149b58ae26c414fea75c6b34ec", - "is_verified": false, - "line_number": 538 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d5bd68de7769dde988f99eab3781025297a7212d", - "is_verified": false, - "line_number": 539 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b6161808b7485264957a2f88c822f0929047f39a", - "is_verified": false, - "line_number": 540 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "1ff88fb1bf83bca472ab129466e257c9cc412821", - "is_verified": false, - "line_number": 541 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "002e1405d3a8ea0f2241832ea5480b0bf374c4c6", - "is_verified": false, - "line_number": 542 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "1058c455a959a189a2d87806d15edeff48e32077", - "is_verified": false, - "line_number": 543 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "cbcf1915e42c132c29771ceea1ba465602f4907c", - "is_verified": false, - "line_number": 544 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "23738e07a26a79ab81f4d2f72dc46d89f411e234", - "is_verified": false, - "line_number": 545 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "270492f5701f4895695b3491000112ddc2c1427d", - "is_verified": false, - "line_number": 546 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "88aec41eb1eedc51148e0e36361361a6d2ecc84f", - "is_verified": false, - "line_number": 547 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7b7d73969b405098122cd3d32d75689cd37ee505", - "is_verified": false, - "line_number": 548 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "79b731de4a4426370b701ad4274d52a3dc1fc6c1", - "is_verified": false, - "line_number": 549 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "5b328e2a87876ae0b6b37b90ef8637e04822a81b", - "is_verified": false, - "line_number": 550 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8638f4b78c1059177cbfccd236d764224c3cad5c", - "is_verified": false, - "line_number": 551 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ef285f61357b53010f004c1d4435b6bb9eeaff09", - "is_verified": false, - "line_number": 552 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ddd64557778a6d44ac631e92ed64691335cf80df", - "is_verified": false, - "line_number": 553 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "de486a7abd16c23dfdf2da477534329520c0c5ec", - "is_verified": false, - "line_number": 554 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "0618c0886736acb309b0ad209de20783b224caa6", - "is_verified": false, - "line_number": 555 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "521ee58b56f589a8f3b116e6ef2e0d31efd4da1d", - "is_verified": false, - "line_number": 556 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "5b916ff5502800f5113b33ba3a8d88671346e3b3", - "is_verified": false, - "line_number": 557 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7582e85dc9e4a416aa1e2a4ce9e38854f02e8a56", - "is_verified": false, - "line_number": 558 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b24c1e8ac697a8ff152decc54d028e08dd482e4f", - "is_verified": false, - "line_number": 559 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "923eb19912270d9a7c2614d35594711272bc33c0", - "is_verified": false, - "line_number": 560 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e0331901bcbebd698248f7ba932083b13144da42", - "is_verified": false, - "line_number": 561 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f49cc7570d7e3331425d2c1cca13e437c6eb0c86", - "is_verified": false, - "line_number": 562 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "6adbf5db8ff386502f09c1dbb9fa2b37600491a6", - "is_verified": false, - "line_number": 563 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "03060c922cbe09ed17fe632cbf93ed32eb018577", - "is_verified": false, - "line_number": 564 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "71cfee01fe9f254c01da3a00f2b752cf39cbe95d", - "is_verified": false, - "line_number": 565 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "542ef00d5b90d5b9935d54e3c2ebd84c59b7e7ba", - "is_verified": false, - "line_number": 566 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "4073dc551871d96e2b647f18924989272ea88177", - "is_verified": false, - "line_number": 567 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "0a4afe0870fdff9777720cab41c253d7a2a1b318", - "is_verified": false, - "line_number": 568 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ef7992a75c33f682c8382997f7f93d370996ee7d", - "is_verified": false, - "line_number": 569 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a265ebf662a7b28aeacc7f61bdb9ba819782fc24", - "is_verified": false, - "line_number": 570 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2bc27f59373f1a1091eef59a7d9d23c720506614", - "is_verified": false, - "line_number": 571 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e17be476c0805f05b4445d528ae5b03fa7a13366", - "is_verified": false, - "line_number": 572 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "6b8281ade6ee972b53eb2e5e173068a482250005", - "is_verified": false, - "line_number": 573 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "931c912c0da827ad7895c4e6d901dc2924ef23e4", - "is_verified": false, - "line_number": 574 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ecf0566d6b6ce6c44f7f8fb56af4a8608e72f5e4", - "is_verified": false, - "line_number": 575 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "947323679dbee5d60736f14258621626565ea1c6", - "is_verified": false, - "line_number": 576 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "05d0d9d4a4e53fa7d7f3f7f8317bec618b1bfe15", - "is_verified": false, - "line_number": 577 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "6b7871d101c02971f1b9f6f95f5a969c36a8483c", - "is_verified": false, - "line_number": 578 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "05441b75c971d39d04a13b168a1b0f2c4aeb2114", - "is_verified": false, - "line_number": 579 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c9d8088c151b2a7c09676ed3fd9de0fddc490b30", - "is_verified": false, - "line_number": 580 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "07eb4a0a546de02a324550e1e1b66e306bd3f706", - "is_verified": false, - "line_number": 581 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "baa791026849604561c1dd00787a9caa598abae1", - "is_verified": false, - "line_number": 582 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8d49f6f1c3e27bdfe580816e609cab2c9ca00cc6", - "is_verified": false, - "line_number": 583 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "926d8707e359f80554585f4eca9f90b6021d3327", - "is_verified": false, - "line_number": 584 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "68982f7b9ff005fdd9d27fdf5ef5d37c9c611f58", - "is_verified": false, - "line_number": 585 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "cc95ebd65aeae6dd8e774a1e90798079211554f3", - "is_verified": false, - "line_number": 586 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a76b151ddad3198ad11b962ff59170a761baf0c6", - "is_verified": false, - "line_number": 587 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8a59e160326a76b11b5fc26cfa592cfdf158fd49", - "is_verified": false, - "line_number": 588 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "784d839853e3c0966a262a542b36e259aa00e8df", - "is_verified": false, - "line_number": 589 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "fbba9f2d7a916915d9535d71c785ba4491a3b733", - "is_verified": false, - "line_number": 590 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f290b3c4f8aacf898285d68358fcdffe6baf1e2e", - "is_verified": false, - "line_number": 591 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "14f10baeacada2cc41047108f58b200c6026bca3", - "is_verified": false, - "line_number": 592 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e583513a87e1f5b242e81fe86427da78faa63ede", - "is_verified": false, - "line_number": 593 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "391f7646f98c7bf123453c90b372ac45f4ea35fc", - "is_verified": false, - "line_number": 594 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "da2e4b9e552f03c36dcf672072f1d6cda917672d", - "is_verified": false, - "line_number": 595 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9c4a1dc6277cda2374666e447dceb663ac39c62a", - "is_verified": false, - "line_number": 596 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "469b9dfc4d3851edbd0c27f80b4b36c04ec52f5e", - "is_verified": false, - "line_number": 597 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c09b72b36f9e813bdfcf32f58e070a4fe98f4092", - "is_verified": false, - "line_number": 598 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "6ee9dd6fd0333921cb607f274d3bfc04187bfac5", - "is_verified": false, - "line_number": 599 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9ccd2b0b5ae426a9c581621270630389e40d08e0", - "is_verified": false, - "line_number": 600 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "881f2e047f571e1ea937638ea2598581e92e4900", - "is_verified": false, - "line_number": 601 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "1e5acdb5b4e970fd7be282ae31e3195d24aa98b9", - "is_verified": false, - "line_number": 602 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8b1564bd262285220c1f4cc7ba034b14836d3496", - "is_verified": false, - "line_number": 603 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2f79127d99b576c55a920ce8195d9c871296dd79", - "is_verified": false, - "line_number": 604 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "0aa38b942875102db24b7ce22856fbce4dd8bca5", - "is_verified": false, - "line_number": 605 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "62f537c1449b850f2f3b66c200a85fff4e4ce6c3", - "is_verified": false, - "line_number": 606 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2f83b93fddaa24f65acbea08be3fc0b2456f3ea5", - "is_verified": false, - "line_number": 607 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "0d3a416a9b47316629342cf32e4535bd5de367bd", - "is_verified": false, - "line_number": 608 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9d018c03a51c7405ca8de9dafde5fb12bf198544", - "is_verified": false, - "line_number": 609 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "0e20193d744f60ef0bcd425ce45d19c73f5ff504", - "is_verified": false, - "line_number": 610 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a2ad69c925092acbbffb97ea70f2c87985fccc8e", - "is_verified": false, - "line_number": 611 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "997ad02ee3779b7ffcd11b8e19df0afe052b66f6", - "is_verified": false, - "line_number": 612 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "46bc2f629e8b64d43d23cc3429346583a7319bae", - "is_verified": false, - "line_number": 613 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "10e4c7043154dc91c0a002d88fe23f356370b80b", - "is_verified": false, - "line_number": 614 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b002194b0535528d6a24fa7502e7f76b935afc8d", - "is_verified": false, - "line_number": 615 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "43728be0f14a9413b4bebd1d22562002cbd07c2d", - "is_verified": false, - "line_number": 616 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "172cb154f89a4168cbbcc48186b6f5a2b113e893", - "is_verified": false, - "line_number": 617 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "1df3a86d99563dd6124a197f28a21f1412fd438b", - "is_verified": false, - "line_number": 618 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d44276da69dfa1c411354e75dcda7d75ea6d605a", - "is_verified": false, - "line_number": 619 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "39c326b627e45a8ae4192ac750d38cda7fa55d79", - "is_verified": false, - "line_number": 620 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3c24ec7ee3be457039f1e46a4b437065ba4c4130", - "is_verified": false, - "line_number": 621 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "98b18d68b753e89b1b0c8b4ce575011326b0d2c6", - "is_verified": false, - "line_number": 622 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "95dc0c323f31332cea1b74ce77fe4af9fd0d5c5c", - "is_verified": false, - "line_number": 623 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "cb0763f8b448f29101b230bf3ace6a9fc200be9b", - "is_verified": false, - "line_number": 624 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f746e396467de57bda19eb1fe555bc43b8773bf2", - "is_verified": false, - "line_number": 625 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d0878fed2da5ef58888639234936d2df27aa1380", - "is_verified": false, - "line_number": 626 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3010d3905af38cd8156a527f4d531f34c46c39a7", - "is_verified": false, - "line_number": 627 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "4da40200c07f4e433a8fafc73d0567d024606752", - "is_verified": false, - "line_number": 628 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "5415afc22a2c5f94eabfdadbccbe688b42341335", - "is_verified": false, - "line_number": 629 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "86f3350f28fa5af153e0021bd0f95610f50f0aa6", - "is_verified": false, - "line_number": 630 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "84541393133a5662b9b265024ec3edc3545c3802", - "is_verified": false, - "line_number": 631 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "05830a12efa0b065e55a209e1de1b7721546f2a1", - "is_verified": false, - "line_number": 632 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9e7dabf3cda36b3ab3b57fefca047d5271cb674e", - "is_verified": false, - "line_number": 633 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ef05a15dcbe9f43b719bec0f2dc74d6870cab938", - "is_verified": false, - "line_number": 634 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "35c2e8c0d488a1e0e7f4a721cb9fc5af4f91423b", - "is_verified": false, - "line_number": 635 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e4ad4eb707a0dd2b2ef876c8001f966f51f524d9", - "is_verified": false, - "line_number": 636 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f99b3161abeffa11c6be076150cccd8221fcd703", - "is_verified": false, - "line_number": 637 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "4b1647cf6264941baa9ba28fb792cd82e06217cd", - "is_verified": false, - "line_number": 638 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a62b12a0505128c7094f73376a7b32b6896a8602", - "is_verified": false, - "line_number": 639 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8ac29efbb3b877bfdebdcba31d3528f2cd0809ea", - "is_verified": false, - "line_number": 640 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "1aa7fb76951a195b27333fc8580b44a57e98fa9e", - "is_verified": false, - "line_number": 641 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3a29474a5fbc845f27b5bafd16ddbb4d7defa2d8", - "is_verified": false, - "line_number": 642 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b1c3e50ce69aa2cc899da1df5a55338242567ab4", - "is_verified": false, - "line_number": 643 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "841f3550b43d66f5f3138d26990ffbb161a3b827", - "is_verified": false, - "line_number": 644 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "80cfd7fb194ed700b9c0e4970bf4e47cc75257a9", - "is_verified": false, - "line_number": 645 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "bc4508d089cc2186f7bc5bb14ccddeb772a04244", - "is_verified": false, - "line_number": 646 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "01b35bc3e5deb295f2dd6c43f2abae453ed7a20f", - "is_verified": false, - "line_number": 647 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "fa3e9c6424f3bc18eb13d341ed64c132b4f8c929", - "is_verified": false, - "line_number": 648 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b13663ab4e5621994f9bb7909a69c769c343e542", - "is_verified": false, - "line_number": 649 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c06f704f3a0cefec9a28623bda60f64f8c038bdd", - "is_verified": false, - "line_number": 650 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a2eadafda305962f6b553a99abf919d450cc4df2", - "is_verified": false, - "line_number": 651 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "43c8cab46cbb8319ee64234130771cb99a47e034", - "is_verified": false, - "line_number": 652 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "1cc137a3c9d41ba4b30464890ae6a6f08c7ba92d", - "is_verified": false, - "line_number": 653 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b43d13f2dcc835cd55d4a40733b22d07fd882167", - "is_verified": false, - "line_number": 654 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "78d7945d58ea7aaaf4861131b57b5fd4c308437f", - "is_verified": false, - "line_number": 655 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "6b2f6f1c7b573efc39d8bd013cef20e89e011276", - "is_verified": false, - "line_number": 656 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d92bdf2e2be4bfe8acb991a3cf2b0f23da624825", - "is_verified": false, - "line_number": 657 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e8b7c1a13d23facf8589088b2de85f851ad53a82", - "is_verified": false, - "line_number": 658 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "6d3e58158529f32b5ead6e3b94c7ca491ef27ed3", - "is_verified": false, - "line_number": 659 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "800ea2592a27f8b38f0a18253dd49f97b65a3aad", - "is_verified": false, - "line_number": 660 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "0b13798c29f5879b119c807ab7490d35a0342cef", - "is_verified": false, - "line_number": 661 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "0a9a21ca4e9aa08b2b5fbe769bf6afb1deb8da91", - "is_verified": false, - "line_number": 662 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "183877effc366e532c7937f2f62f7f67f299bd36", - "is_verified": false, - "line_number": 663 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e245782b2f99805ed35dab1350ac78781ae882eb", - "is_verified": false, - "line_number": 664 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9b619bf6db9561f29c4cc75e26244017cc97d305", - "is_verified": false, - "line_number": 665 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "377469b721f5e247f1ad0fee41cca960c49a1fe9", - "is_verified": false, - "line_number": 666 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f2cb896b3defe96fd6a885f608e528704b40728c", - "is_verified": false, - "line_number": 667 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7643925d0ad2652497482352b404604985b0f41e", - "is_verified": false, - "line_number": 668 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ce5594ef11357e35de0d439687defce446dd0f66", - "is_verified": false, - "line_number": 669 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "65dde318bca6689643335f831444daf0156cc4e5", - "is_verified": false, - "line_number": 670 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "143c3d69803143aa5d40372c0863df82b176b41c", - "is_verified": false, - "line_number": 671 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c32dcbc4225f3183d5f5a5df78ec5ae9afb38968", - "is_verified": false, - "line_number": 672 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "cfa29e11ebef38d8e08fb599491372f6404e6b6f", - "is_verified": false, - "line_number": 673 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3d91d5f1054fc768cf87c6b19d005e6d3ccbc2f3", - "is_verified": false, - "line_number": 674 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2d6bffd0f0c9cc4790eebc50b6a56155c3789663", - "is_verified": false, - "line_number": 675 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "64110bdd2bf084ec47040ce8b25fc13add2318e7", - "is_verified": false, - "line_number": 676 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7f6bf6522a85f71bf4b93350ec369683759735f9", - "is_verified": false, - "line_number": 677 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3d53588bd3f314ef6e7bf9806e69872aa2ce1aff", - "is_verified": false, - "line_number": 678 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d5efc1772557e4bff709c55a59904928b70ffe1c", - "is_verified": false, - "line_number": 679 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b8e46dd05b23c4127cca0009514527e49b6c400f", - "is_verified": false, - "line_number": 680 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "58d30b123d121316480c37ae6222d755dc9144ca", - "is_verified": false, - "line_number": 681 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "66a2abf99d8a4a38e6d64192d347850840a580bf", - "is_verified": false, - "line_number": 682 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d434fa5b419700a92dc830da1c3d135e8ad0b3e2", - "is_verified": false, - "line_number": 683 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ee251356a77d3ec7b7134156818fac73a2972077", - "is_verified": false, - "line_number": 684 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "239cb830c56b6d22115d2905399f8518bd1a5657", - "is_verified": false, - "line_number": 685 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2e6143570c020503a4e1455ec190038b82bedc19", - "is_verified": false, - "line_number": 686 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9107d00af85969940a45efb9eccad5e87f8a87f2", - "is_verified": false, - "line_number": 687 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "5a5d1ac75eb4c31c7e9650ac70bdc363a9b612c5", - "is_verified": false, - "line_number": 688 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "05a99938fdc58951b4a6a756c8317050e3f5d665", - "is_verified": false, - "line_number": 689 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "67ccbdebe626ab7af430920c1d0d6ec524bdc4f9", - "is_verified": false, - "line_number": 690 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "71fd81160a50c9d47b12b4522c5c60f2fca72b6a", - "is_verified": false, - "line_number": 691 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f150f2f043f66a564ed3b3fb2f29c0636fd2921a", - "is_verified": false, - "line_number": 692 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a1140dfe90f9a5da45451945b56877c45cb36881", - "is_verified": false, - "line_number": 693 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7533bea169a68e900d67a401cac35a7aade18d92", - "is_verified": false, - "line_number": 694 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f0dd83a2a8d653ad8b30fefcde5603b98bf1ca66", - "is_verified": false, - "line_number": 695 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "21334df57a3a5c6629c12f451eeb819a2b37b42c", - "is_verified": false, - "line_number": 696 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "99f04da5b8530b3eb79e3740fece370654d3c271", - "is_verified": false, - "line_number": 697 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c2dfd7c77cafb9193a0e77a45d14ccc1498816fb", - "is_verified": false, - "line_number": 698 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "5351e6405ba12ea193b349e8b2273201bb568404", - "is_verified": false, - "line_number": 699 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "cc215cb1a47a674d2b0c1fb09df87db836ce8505", - "is_verified": false, - "line_number": 700 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3078af7fa82e149420b97ff56fff9f824387b35b", - "is_verified": false, - "line_number": 701 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ac0e1537926b5bbd543ad3e731959a0bad451c73", - "is_verified": false, - "line_number": 702 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a6da4e82d314f4ca0bf7262a78875b0b6edc30aa", - "is_verified": false, - "line_number": 703 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e08c74c3fbf412c2d4f330b0414f1275679cb818", - "is_verified": false, - "line_number": 704 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7bf9ae1b766cb0b9a5aa335a0103518d7be00daf", - "is_verified": false, - "line_number": 705 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ec844560c5f208fa8723c1700f6e86b8e7ffed04", - "is_verified": false, - "line_number": 706 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "6c133b025f53327eb652d2a1ca576dfe58eef1b4", - "is_verified": false, - "line_number": 707 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3dc21b9f6f63b73a241d900e379a3c7094341f8b", - "is_verified": false, - "line_number": 708 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "1a012b2bf61ee9874d5af73df474051c0d235ecf", - "is_verified": false, - "line_number": 709 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b0ebf0b521ec6e6e696f9be2fe4e1845876d57ab", - "is_verified": false, - "line_number": 710 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f0a5d3ac0705186e25effb02649df87361b8c67e", - "is_verified": false, - "line_number": 711 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "385ecb845a1d5d43766d568b466d1dd237a81980", - "is_verified": false, - "line_number": 712 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "18d0416b8ea44ce305b214380de978cef27e8603", - "is_verified": false, - "line_number": 713 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "89dca45aa9146b8a31236fd77001c02769dceb60", - "is_verified": false, - "line_number": 714 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "30acd4c1f4a878883c654846b8f3c5a6ab807285", - "is_verified": false, - "line_number": 715 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7d3229ff5e754c72a8b2072d3d7a5e00749ece9b", - "is_verified": false, - "line_number": 716 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e6da9d65dc0cfb42b86ae8f9b7c1d5fe79b4a763", - "is_verified": false, - "line_number": 717 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9c85908a1bfd5f2a7337f812c68f2ce8dfbfd65e", - "is_verified": false, - "line_number": 718 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "4000341e5c04854eeca9fe7537dfddfdbb7c785a", - "is_verified": false, - "line_number": 719 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ef23e2969a46edf410fab2c69d1b29b2a65f57f9", - "is_verified": false, - "line_number": 720 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "4902863163e24fa9f172e61808385de2b9ee3099", - "is_verified": false, - "line_number": 721 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "31efc8d3bba9c8f66b3f54bc146443732ac15c2c", - "is_verified": false, - "line_number": 722 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "263deaf83b359554fc9dafca8e6622ece44cf75d", - "is_verified": false, - "line_number": 723 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ead7409fe5b86813e3609f7fe6e13b8fc4b0b9d6", - "is_verified": false, - "line_number": 724 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7b0d884d6cdc64a613cf3e887395d875ff738c3e", - "is_verified": false, - "line_number": 725 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "fa0a0a999cb067eee81673f3d2de8bfd96a0d14c", - "is_verified": false, - "line_number": 726 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "0db684d862dfc8427e8f66adb62f33fcdc9f3de8", - "is_verified": false, - "line_number": 727 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8794a8121832fd31b1871d2c5d4b00af07779b0c", - "is_verified": false, - "line_number": 728 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d6070805e7a6c25dbe13a540cbc0f16a89055e7e", - "is_verified": false, - "line_number": 729 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "56b3e8e6d14b9b459bf055900784e8aa31c306c2", - "is_verified": false, - "line_number": 730 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a4d6976637c19991da48707bf35b3cf2ded4c2fb", - "is_verified": false, - "line_number": 731 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f714e448a86a46baf2128d81014e554874f0d4f6", - "is_verified": false, - "line_number": 732 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2b03a5eb51085de41df415881ef1d425f20f9e05", - "is_verified": false, - "line_number": 733 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "99fa7285e15d91ac3047b95ddb475d339c7afc7b", - "is_verified": false, - "line_number": 734 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "4a9880aa478dba526c2d311ae17578711d0f9426", - "is_verified": false, - "line_number": 735 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "0cd512ccf176189c7bf36765b520d8ec2ddeade0", - "is_verified": false, - "line_number": 736 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2eb8822459b9db479752d12f62dec094ab68fc55", - "is_verified": false, - "line_number": 737 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "1aab694ebb334a12ccd22baa0044a3b058db67f9", - "is_verified": false, - "line_number": 738 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ce29f8616e1c62e54a8f0b39b829d9bd7df5721c", - "is_verified": false, - "line_number": 739 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c099a1c5f639e647bda5961d9c51cc158790ff3e", - "is_verified": false, - "line_number": 740 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "78dc2b71e3614e4e802c4f578a66132ea1ae0be8", - "is_verified": false, - "line_number": 741 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "0befb6d3255080ce4d051a531fc1fedb33801389", - "is_verified": false, - "line_number": 742 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "087447f269677e0947da157a5bc0bb535c6c7759", - "is_verified": false, - "line_number": 743 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8911e3aef563e1481305a379a083f7616d57cd08", - "is_verified": false, - "line_number": 744 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2846a4bb4af2826a787fb0d8a0e7342c404a1cd1", - "is_verified": false, - "line_number": 745 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3364317b783250007fcee5bcddf07b2006752ad3", - "is_verified": false, - "line_number": 746 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e1a4444540434bc0ba51a8b5e6540e82d4b17f4f", - "is_verified": false, - "line_number": 747 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f453d1221dfbe308b5c71029f5cc2fba020f2c6a", - "is_verified": false, - "line_number": 748 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3e4231678403aa61b0f4f6719081016d579fa3e4", - "is_verified": false, - "line_number": 749 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a64b90a0dd1a214d6c65a4078437eab4ada65a32", - "is_verified": false, - "line_number": 750 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "0433fe0f97f7a354a3ed06d6a8a77c2f1983f947", - "is_verified": false, - "line_number": 751 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a21195a2dde808b7cff35695396ecf7699125a53", - "is_verified": false, - "line_number": 752 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "6547a05519f26198981f500b703d36443958ad14", - "is_verified": false, - "line_number": 753 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "fbb8441f5e8e9b911cc42a025c856470784d89d1", - "is_verified": false, - "line_number": 754 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "6378293ead806f554612c82fddf04ea8fb1ab2cc", - "is_verified": false, - "line_number": 755 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3272309f5c986a45cd892d943c5bd5af5165ad70", - "is_verified": false, - "line_number": 756 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "1c79d15ecac42472241726cbae8d19bb820f478b", - "is_verified": false, - "line_number": 757 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a868da324435f3b1f32bc12bbd3171e9d62fcdca", - "is_verified": false, - "line_number": 758 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c56de5d2c763355c7a508dec8c7318e0c985dfec", - "is_verified": false, - "line_number": 759 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "258e19436174463d0e1b8066eb8adfbf79f78b32", - "is_verified": false, - "line_number": 760 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "112d96e04bf661b672adc373f32126696e9c06fe", - "is_verified": false, - "line_number": 761 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "bdeaea4ca3484db9e8b0769382e1ba65b62362b3", - "is_verified": false, - "line_number": 762 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "fff367064d95bace4262a1b712aa5b6fb2a821d6", - "is_verified": false, - "line_number": 763 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e16dcae490d17a842f5acd262ca51eae385fb6af", - "is_verified": false, - "line_number": 764 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "bad941c81722b152629cebce1794a7fd01b85ebc", - "is_verified": false, - "line_number": 765 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "65e6aaaad1727c35328c05dd79fb718d5b1f01ce", - "is_verified": false, - "line_number": 766 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b7ea9b9d7d8c84eeeb12423e69f8d4f228e37add", - "is_verified": false, - "line_number": 767 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "42bea72c021eedb1af58f249bdae3a2e948c03fa", - "is_verified": false, - "line_number": 768 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "1ddcb2cad21af53ad5dd2483478f91f3c884cea0", - "is_verified": false, - "line_number": 769 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e72ad6e31d1a19d6b69a1a316486290cb2c61eab", - "is_verified": false, - "line_number": 770 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8ca884c8fb24ecd61300231b81d1d575611cda07", - "is_verified": false, - "line_number": 771 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "5754688edbb69be88b9c0ea821cc97eada724c14", - "is_verified": false, - "line_number": 772 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a267e65960056589647f075496fd3a6067618928", - "is_verified": false, - "line_number": 773 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ad3424f420bf25442aa9df96533852d29eac12a9", - "is_verified": false, - "line_number": 774 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8a5a26db2b7bda6268a9250808256e08d2a62262", - "is_verified": false, - "line_number": 775 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ff90aa934268bd629b33708b7db9a10b5f0bf822", - "is_verified": false, - "line_number": 776 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9294697fb9b36decacc26c3c33c3d186fc128f82", - "is_verified": false, - "line_number": 777 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8dfc552d4f52ed53ccb13c958117ceba6c8038d8", - "is_verified": false, - "line_number": 778 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "49c6467fa09d3052faaa1a369ebd226234db892d", - "is_verified": false, - "line_number": 779 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f2a450ffba5b1fdb7f016e4add7035ef6ba2df77", - "is_verified": false, - "line_number": 780 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "79a4f5a8804b9a94b5c4801700f08a2cdef54662", - "is_verified": false, - "line_number": 781 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "1baf161ffff392357bbfb8e38d95c8c2f79ef6a2", - "is_verified": false, - "line_number": 782 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "840365ccbf5f23b939e8ee15571bdb838a862cb3", - "is_verified": false, - "line_number": 783 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "0e50db71a57f0d0016b2abeaf299294c3bb4fedb", - "is_verified": false, - "line_number": 784 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b108976e96b8ce856b59b4f73cc6caa2555310cf", - "is_verified": false, - "line_number": 785 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "474f1a83c946ec223093d46f5010ff081f433765", - "is_verified": false, - "line_number": 786 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3740691aa3a788e71b7b74806dbcae3009b4f7fb", - "is_verified": false, - "line_number": 787 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c11bddda98ea121b857aabafbcdf75307a18bc45", - "is_verified": false, - "line_number": 788 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3445e70b7f8f3d381c21f6ed88c28c0db545662e", - "is_verified": false, - "line_number": 789 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c368482da3144e79d4f4f8063bdcfc85b1318ca1", - "is_verified": false, - "line_number": 790 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "470e734260c3e67dd19fca5ef32dbc6ce863dcbc", - "is_verified": false, - "line_number": 791 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "0dc9bbedd1b90674d2d0c81563b1b59e82f901b6", - "is_verified": false, - "line_number": 792 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "49bbe143a0a5d2d81eaa04b0ae5f02b89b2e60ce", - "is_verified": false, - "line_number": 793 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9e009fcc53e8ae16ac2cd1c31945812a8b3cb1f8", - "is_verified": false, - "line_number": 794 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "fda8ab7b8d8d0e3d995648f21cb97fb6a4371008", - "is_verified": false, - "line_number": 795 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "15ca6383ad968b3f606e5600e0ee5765cc61a223", - "is_verified": false, - "line_number": 796 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c901600adaae1fae9b24fe869cc11364e07651c1", - "is_verified": false, - "line_number": 797 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2a6968448cc0520a44b0fc8eac395ef9047a0ba9", - "is_verified": false, - "line_number": 798 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e58e1397cdedc8cedfc10472af62b0e24b7d90bd", - "is_verified": false, - "line_number": 799 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3f1a00fc8f814e6e5bfbb1b38a44318af25c0149", - "is_verified": false, - "line_number": 800 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "23887318ac83e9f3953825ada42ec746364c362a", - "is_verified": false, - "line_number": 801 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c5ebf6b1cd6af76112bb20fb2ef8482bd95088fe", - "is_verified": false, - "line_number": 802 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7f2b7465a347061ef449ed6410a3fccb7805775a", - "is_verified": false, - "line_number": 803 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "35c7486eb3aab3d324e34c9f2e4149c0833e7368", - "is_verified": false, - "line_number": 804 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "6bafab58fdb0248c4e31eb58b8b99d326a5fec77", - "is_verified": false, - "line_number": 805 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b5b8f84bebc143026521dd3dec400fc319c8f07f", - "is_verified": false, - "line_number": 806 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "dc663ea73f635724beef79b22fe7c40bf812907f", - "is_verified": false, - "line_number": 807 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a5f5ebcab108b702af3122c9dec85e4aed492ba1", - "is_verified": false, - "line_number": 808 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "24826ebb519bed6f61af4c6dc3008fea3ca87c62", - "is_verified": false, - "line_number": 809 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f5e2d1ee2fc9d16703269c4942a406effa9208ae", - "is_verified": false, - "line_number": 810 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f28e36af3d92643a5ca738f66b0f9b0f0906a02a", - "is_verified": false, - "line_number": 811 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "19c8b107d6fdc4b807d831334b433ba0f051ee3d", - "is_verified": false, - "line_number": 812 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "fd640c778ecdae75e71f490588436bad8551dc0c", - "is_verified": false, - "line_number": 813 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b93f3e5a8f7937290e368015ec63b9faa148a091", - "is_verified": false, - "line_number": 814 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b665cd0e94b8b690e5edb8446039bc20bd4edf8f", - "is_verified": false, - "line_number": 815 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e3482306ec339930b1f4d60e13c4006b9ac9949d", - "is_verified": false, - "line_number": 816 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a2c8590320283074b40e9c0f05af26ac1671580f", - "is_verified": false, - "line_number": 817 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e30ee01ef2baf677c7592e2a339d1d4c5f3b3053", - "is_verified": false, - "line_number": 818 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b8495b9cd806dbee2e7679dc94c9ca6b675107af", - "is_verified": false, - "line_number": 819 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b175eb842c0cb4c4d2b816c80b2cfea2b81eca04", - "is_verified": false, - "line_number": 820 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7cca142d68498553dd9cd10129b64f8f6b1d130d", - "is_verified": false, - "line_number": 821 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "62709b572d8c7952674f5ca8c807aa12346d8219", - "is_verified": false, - "line_number": 822 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "260d9d5da81fc235a36890dc1df9b0b93e620051", - "is_verified": false, - "line_number": 823 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f45c83b63c8fb4ee062a5649950ed25963f72269", - "is_verified": false, - "line_number": 824 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "94ab5caccdc141879f89dff48b17d633cce7c6ae", - "is_verified": false, - "line_number": 825 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8a67f56357e2ab075ec362aa17de81e09829dd1e", - "is_verified": false, - "line_number": 826 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e47ea7fc498253e920531b2f9440df22b65b4bfb", - "is_verified": false, - "line_number": 827 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "608bda7f1c9bbb04cbcd94fbef60907b34e5107c", - "is_verified": false, - "line_number": 828 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "0ef4f672781b0c8008104b4833da99758a37c2d5", - "is_verified": false, - "line_number": 829 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b84c442c7f733ee0416ab3e451b3acd4fe708d11", - "is_verified": false, - "line_number": 830 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "af40c42cfab503d271744c98fa2d912f75fe1192", - "is_verified": false, - "line_number": 831 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "088fb0ba102fd16911bc92ecad1e96d6b9d7c6e1", - "is_verified": false, - "line_number": 832 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "0205ce524bdf9689abb764ade3daff0a75a9680b", - "is_verified": false, - "line_number": 833 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ffb06eac178944f7cd519dffee1bce92b7b39de0", - "is_verified": false, - "line_number": 834 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "1f4fec8780ce70e3b189b9ef478d52cb508ab225", - "is_verified": false, - "line_number": 835 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2084a2c1c5c015caab2036e77747bc1bc8da1b5b", - "is_verified": false, - "line_number": 836 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "6d61e0dc6e9e3786a038ce41b2645ffa55ad34dd", - "is_verified": false, - "line_number": 837 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c2eedfdfb494f1da2837db4fe02a349f6b83e34b", - "is_verified": false, - "line_number": 838 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "cb90f645f60eb596ccd816c2c9cad6df1da2f7af", - "is_verified": false, - "line_number": 839 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3714fb2f7dd6cc5392456fa413a7a6ba3cceca16", - "is_verified": false, - "line_number": 840 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a2b9353093261900009e92216ad07fb712d3aeef", - "is_verified": false, - "line_number": 841 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "38abeae07fcc9d78f57c915f7ec1ef448928c8d7", - "is_verified": false, - "line_number": 842 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "4aab4807666815ca001aecb2c98150fa4e998a4e", - "is_verified": false, - "line_number": 843 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a3c2b5f078ce6bd677972296a39a9b6f476ad8fb", - "is_verified": false, - "line_number": 844 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "76cb76a7b46fbebf5a3d38b4f7507f5f6f966bbb", - "is_verified": false, - "line_number": 845 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "6216237ea7f4271573ad9257b04f29624b32d067", - "is_verified": false, - "line_number": 846 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c46a24ae59ed9570cd0eaaf744cbdac682131822", - "is_verified": false, - "line_number": 847 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c7f4bfd365cfeda78938b48c174e84c476e0b121", - "is_verified": false, - "line_number": 848 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "95306491cf2bf602d32f153877fa3668188e89e5", - "is_verified": false, - "line_number": 849 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "0a86977039aca715fef41f075a006d08913e2f9e", - "is_verified": false, - "line_number": 850 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "98ab4de33fb607da8c4bd3e6dcde7fc48be461cb", - "is_verified": false, - "line_number": 851 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c8a681b8468ceb7be04c81c9531fc1b76a73a979", - "is_verified": false, - "line_number": 852 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c1f2b4dc85c69f47bab7f0c95934abeb21241dfe", - "is_verified": false, - "line_number": 853 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d2c65d95022c1689e545f27bdb9125abfa65014a", - "is_verified": false, - "line_number": 854 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "5334888b103ace2ac1628b453dfba0374aa21563", - "is_verified": false, - "line_number": 855 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "db870d53e2dbee8610b39a18017bf2e95d9b6a1d", - "is_verified": false, - "line_number": 856 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a874dd47f5e9d721212644df27395f9d0455bc7b", - "is_verified": false, - "line_number": 857 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "24304e79b441e1689f7db990cf1380e8ea172237", - "is_verified": false, - "line_number": 858 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ed52cda8715ae3d4b24fdea5e451cf0610003eb6", - "is_verified": false, - "line_number": 859 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8b5757852d0c36e7217daf8504004e6c85212d7a", - "is_verified": false, - "line_number": 860 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "85d089a4858f5681d1828bc1d67eb3f19bbeba6f", - "is_verified": false, - "line_number": 861 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "80dbb757c0b7fb948816886168d397b09b317e0b", - "is_verified": false, - "line_number": 862 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a45b519f89630194e67ed91782425b2095083fcb", - "is_verified": false, - "line_number": 863 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "297a0f9e38f85884d7d6beb518b33f8f35349004", - "is_verified": false, - "line_number": 864 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2200c973aaaaa2f1201604176787152091904d25", - "is_verified": false, - "line_number": 865 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "07d4fef177f006578f4d37289137d90727a5fa86", - "is_verified": false, - "line_number": 866 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d68f0a891f53a354bff2a9002ce0e3c60236d0fa", - "is_verified": false, - "line_number": 867 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d101c2cdae39ce8adcf30a777effd4be14b07713", - "is_verified": false, - "line_number": 868 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7e5670956a5ca012cbfe2ec89841595ada7ffc4a", - "is_verified": false, - "line_number": 869 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d58782068176eeb0987b1850ec9b1e54764c5947", - "is_verified": false, - "line_number": 870 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d779f72f04dbb76344f4c264d19bba7242e25e90", - "is_verified": false, - "line_number": 871 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "99c57a64facfebfb9e41dfae591af95633715986", - "is_verified": false, - "line_number": 872 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a7a97bb3f0508c2ed46ad81ed8cc53ff7469edc5", - "is_verified": false, - "line_number": 873 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c8b289fb0554107bbd07c43f462a87e7b929a529", - "is_verified": false, - "line_number": 874 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3c092d1639246d4ce9167319e729dc39d1bb3793", - "is_verified": false, - "line_number": 875 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c34cc18e2fb77269d8f33529c23d4ae2a55b873e", - "is_verified": false, - "line_number": 876 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "57562f3034b2895272567bccdb4476ff4ffb387f", - "is_verified": false, - "line_number": 877 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e75aa06fcf9eb16ce4f765009f73bff5998b4d82", - "is_verified": false, - "line_number": 878 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "561dd2c1798724b1f7730df97cf07b16f27db369", - "is_verified": false, - "line_number": 879 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "548d01127e6414ebc307a1da07e1814eb28d9c43", - "is_verified": false, - "line_number": 880 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d356fdfdeab6a77435a395a60e99e988f3c7e85e", - "is_verified": false, - "line_number": 881 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7d850865aadf5851746b420805c2d1a859af11fe", - "is_verified": false, - "line_number": 882 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a2221c705b602dee5ab23632133b47700d5a1dd2", - "is_verified": false, - "line_number": 883 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "0d4e54941ee10299f1064634fffb86e4b7bfd005", - "is_verified": false, - "line_number": 884 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "589f88e962e41fc2e6691090dc335a20c7520348", - "is_verified": false, - "line_number": 885 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "0d9ea7340e4afb03c7564f911883428d4d0e5e01", - "is_verified": false, - "line_number": 886 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "86525dece15cc1ed811c029ebae7ce496af598aa", - "is_verified": false, - "line_number": 887 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f3add200e410ee751ec2e65f4c00d5fe546a2b46", - "is_verified": false, - "line_number": 888 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "89588ee266a0fee04980b989461d344c91f917cf", - "is_verified": false, - "line_number": 889 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c02f12006740778cceb3e14d10eef033650f0905", - "is_verified": false, - "line_number": 890 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "16d1c52b661852a0a2d801d14e5153cd2245854a", - "is_verified": false, - "line_number": 891 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "bd48b759e75395bd491df6811d82ada954b1a8f8", - "is_verified": false, - "line_number": 892 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f9d8d2bcc1f978b39c12409b8bd5c35e1fd3caef", - "is_verified": false, - "line_number": 893 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "bd7006183d8fc08da5a29edc7dce2833b7d67c29", - "is_verified": false, - "line_number": 894 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b4f7d597cf8d0e4a8bdd47b462ffaf7f753906f6", - "is_verified": false, - "line_number": 895 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "10d3f4cb2e16143374e3db5c6828184d97cef711", - "is_verified": false, - "line_number": 896 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "6045891b6aed86c8d19a6aecd12b2df1a32e3921", - "is_verified": false, - "line_number": 897 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f09ecd7a19945614bd73b5be04331b691d2bc030", - "is_verified": false, - "line_number": 898 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f0cf1445d72e773713d17ed9ecbf6f805206cc80", - "is_verified": false, - "line_number": 899 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "34cba93b5c522de558e25672a78a5d75028a02fc", - "is_verified": false, - "line_number": 900 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b08833d65be532022a038652bffe2445f840479f", - "is_verified": false, - "line_number": 901 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ed24a43ca6ed9df8d933b25418889701bdf1492d", - "is_verified": false, - "line_number": 902 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f081d33d1093e834b3fe9e678720c07c7dfbaef7", - "is_verified": false, - "line_number": 903 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "fbd0b56627efce28202a4ebc927ed09fb338cf24", - "is_verified": false, - "line_number": 904 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8f79ecdca6ff2d1240ab55db0395f3babd8e0cd7", - "is_verified": false, - "line_number": 905 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "0d42925b4018649775d5543b6e5ccd1096eea954", - "is_verified": false, - "line_number": 906 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "5564f26e8a7f58c2e525d04261557b54ccb3eeae", - "is_verified": false, - "line_number": 907 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7e61f7e6fbbccc54b49c5932dfee56e4d05d8bb6", - "is_verified": false, - "line_number": 908 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d28c82f5235be5773d7b556004493d197863e47e", - "is_verified": false, - "line_number": 909 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ead7a2d8ba1098da1203103338f6077d384ec789", - "is_verified": false, - "line_number": 910 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "57b73b00541a671b1c0f9b49b1a5b9b6d43e386f", - "is_verified": false, - "line_number": 911 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "00d3ba478bd4e0005ba325c0fa3bbb80969a4072", - "is_verified": false, - "line_number": 912 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "63497e9fab38614d05946c0b9dd1338983132696", - "is_verified": false, - "line_number": 913 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "bf7915a186cac89cbf27b479b4318af45d334f3e", - "is_verified": false, - "line_number": 914 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9e5791210452015df2676f6a7706415ad7c8149e", - "is_verified": false, - "line_number": 915 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "149a819c93748d871763fdd157fbf2c93fcff33d", - "is_verified": false, - "line_number": 916 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "5c0e33a6cdc2bcfa911e665929ae524093e8d4a8", - "is_verified": false, - "line_number": 917 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "0a04734c82ec76181682c537a590934fbe46fe44", - "is_verified": false, - "line_number": 918 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "fb96412139d649dc332fc596841dc2d7543a09d3", - "is_verified": false, - "line_number": 919 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c48b721469472686b78de0db8d34ccfbe5113804", - "is_verified": false, - "line_number": 920 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7c832e5288c3cd8f714e3b57d31c7fe05ad0b98b", - "is_verified": false, - "line_number": 921 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "58383e090cd1cdfdbd494f46d533d7be96c3d16f", - "is_verified": false, - "line_number": 922 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "964063ef09c1114c0b89c4a8bdc6fb9a5238b75b", - "is_verified": false, - "line_number": 923 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "0f70be8ee00fb5491a86ff2b185e193bed8147d2", - "is_verified": false, - "line_number": 924 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "eade9c861e70446d1a4057306ea14bcbb105515a", - "is_verified": false, - "line_number": 925 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "645a4a4787c20dbf7d23af52b6b66e963a79701d", - "is_verified": false, - "line_number": 926 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "952b79bc3f47f661ffd882f2cac342d761c7ee89", - "is_verified": false, - "line_number": 927 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "325ae8750d58cb76ba5b471c776b575c6dd8f7de", - "is_verified": false, - "line_number": 928 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c848e0ebbd67aadd99f498bf457fe74377e2dee9", - "is_verified": false, - "line_number": 929 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "938a394aacb5f28860f2a21dc11c2143dfda6609", - "is_verified": false, - "line_number": 930 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "6f7cc320c863e5e4d854df9f1d9343408b316152", - "is_verified": false, - "line_number": 931 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "bca601976f824d572c9829820d04ef78f0aa89f2", - "is_verified": false, - "line_number": 932 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8f436a87f64990bcc5bba342e4614ba240cb4001", - "is_verified": false, - "line_number": 933 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3c41d19e585a5d6932fbedfe9a9970b2be5be662", - "is_verified": false, - "line_number": 934 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "11c444922d1367a8d844b4f265dd34234145b4e1", - "is_verified": false, - "line_number": 935 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "4b5b8766a87bdfe9e72b205635cf3202579c294e", - "is_verified": false, - "line_number": 936 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a8c32045952ca987aa668c54161b8313d4e27d06", - "is_verified": false, - "line_number": 937 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7280d2d3abaeaa0b8c09b30184cfa8e9d96f16f9", - "is_verified": false, - "line_number": 938 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d353aeb68a062440b13bc25906bc19450808c33f", - "is_verified": false, - "line_number": 939 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c06ff020b6c003435cd543d7c094df946d5cee8a", - "is_verified": false, - "line_number": 940 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "6c846e552b2bae1eb5fb1ee603bd35dbcf43f8e1", - "is_verified": false, - "line_number": 941 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9526db9835d636a82d4c7843dcb4b1a97f0cd41a", - "is_verified": false, - "line_number": 942 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c0d1d341758862cd2d243425d7e0e638ccde2be9", - "is_verified": false, - "line_number": 943 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "168f03ae12ec1b265302c9be39275b3ff886f0ba", - "is_verified": false, - "line_number": 944 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d4431e65831239ecb46c60b109b3cdf3d90413e4", - "is_verified": false, - "line_number": 945 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "6065a318efbc35fa8bfa8179ea00d139aa8ac5f8", - "is_verified": false, - "line_number": 946 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ca8eb4ab2a13fd9c8009f64e9a57a9698da2af08", - "is_verified": false, - "line_number": 947 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "076d36e09e412d1baffcfe20e235b32e766d9d37", - "is_verified": false, - "line_number": 948 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8a96b1bb17e8fc8048721963a8944f194e0d6383", - "is_verified": false, - "line_number": 949 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "036334bc532f791df9f17a922a6b282468e3a32d", - "is_verified": false, - "line_number": 950 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2e9e4798ee11ce742834d80c2103c846b8a7daa8", - "is_verified": false, - "line_number": 951 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b34309d4e552ffa204cbf7632dd06376f7cfe925", - "is_verified": false, - "line_number": 952 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "eb323c2dabc2fe8fe9d73e355e24554f45a097ef", - "is_verified": false, - "line_number": 953 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "eeb750c5480e76e5b075a1cc415007182d5a84a5", - "is_verified": false, - "line_number": 954 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "baa82df8fe62f21e4a9bd056515d279b5f4bf296", - "is_verified": false, - "line_number": 955 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7ed197e47d75c92a2bb9fa469ce2584338ae7978", - "is_verified": false, - "line_number": 956 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "eacb84eb412e97afee8329c534ea5822025d2f34", - "is_verified": false, - "line_number": 957 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "1a7e7d49835c298874d24cf9434a7c249f71811c", - "is_verified": false, - "line_number": 958 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "71124a16113f0bfca8f71090445ea96115e92c3b", - "is_verified": false, - "line_number": 959 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "eb6fed65dc17090a731ba790be1c1e913ed43696", - "is_verified": false, - "line_number": 960 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ff488edfba52bda0a9d4ef548f4e848e1bc407c1", - "is_verified": false, - "line_number": 961 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d58ebcc9017888fd12d9eee6a1dbb7a1e5d8bf72", - "is_verified": false, - "line_number": 962 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "4db9b98c3dc42567e08ac91e4658c7774eacfddd", - "is_verified": false, - "line_number": 963 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e91ea43a53d83fb4b47e5769b7db51e4f1c0a333", - "is_verified": false, - "line_number": 964 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b8768444a059004aa7d50c73da0c7665e774c8b7", - "is_verified": false, - "line_number": 965 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "52af7be744b7e8e3c9d75db11b3de31693313573", - "is_verified": false, - "line_number": 966 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "169a53ab3aa86b11c6a4fb5064b2cab7b64d260d", - "is_verified": false, - "line_number": 967 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "6c29925cd018548844c1b174a4fad45f39ca4d3b", - "is_verified": false, - "line_number": 968 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "793d9bb0e0d7f5e031e367587ecb877881cdd56b", - "is_verified": false, - "line_number": 969 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "709969f024af92b318a5dc3a0315a66c2a024820", - "is_verified": false, - "line_number": 970 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "6c66657d4bd785b7c16df241260cd51f8d7e7702", - "is_verified": false, - "line_number": 971 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "54330bf419e7174ab210ac03a0b26bdbb50832e3", - "is_verified": false, - "line_number": 972 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "02bbbfc42d316c59297fe15109e17447512bc76c", - "is_verified": false, - "line_number": 973 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "446f08aead8d20df9ee177b4ee290303cbbfc348", - "is_verified": false, - "line_number": 974 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9b47bd9a70c30307c89348cf7044e66b8eeb604b", - "is_verified": false, - "line_number": 975 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "16799c910c44755b0c3ffa38c27e420439938bb8", - "is_verified": false, - "line_number": 976 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "cfba338d2d1c6c8ee47fd7297eae9e346ef33d2c", - "is_verified": false, - "line_number": 977 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "42f730799ccc5f4e3f522abf901ce4a7872f4353", - "is_verified": false, - "line_number": 978 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "5669611e63657e7b6d5f10aee1fe08837577dc99", - "is_verified": false, - "line_number": 979 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8b8a1180371e560308a4b3bcbf7d135e4fdce66e", - "is_verified": false, - "line_number": 980 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b5b25fad7a60d76bb8612fe1fe7f4114134b7fe1", - "is_verified": false, - "line_number": 981 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7268358632fc15cc97395c23ac937631427a06da", - "is_verified": false, - "line_number": 982 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "77b14302acab126de73e1960951b4d8862f8996b", - "is_verified": false, - "line_number": 983 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a9f98d55aa73cddda74d878887f9cf7c91ed9622", - "is_verified": false, - "line_number": 984 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7c0abf324bb40af2772baa72ec9eb002674b972d", - "is_verified": false, - "line_number": 985 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ecd7751d16ed66ffbccbc3bc0cdc6767e85c9737", - "is_verified": false, - "line_number": 986 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "1829e0ea8aa97dd1c07f83877af61079a0420f0a", - "is_verified": false, - "line_number": 987 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "246e88cdb42b377333a3fb259ca89b8f2927c9f6", - "is_verified": false, - "line_number": 988 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "70c184cc1ba36cc336edff03d3180e16a7b6a8c8", - "is_verified": false, - "line_number": 989 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f3e0f3c62ed74ee4c701d70dbfbf5825e9b153e3", - "is_verified": false, - "line_number": 990 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "fceabb5893c16c83a2f75e44a2c969cb6bff4c70", - "is_verified": false, - "line_number": 991 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "dd14309feb249e827dba5ced8ac68b654e7db8cf", - "is_verified": false, - "line_number": 992 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9f675a535ed79052f233c3b6f844eb96368d2d4f", - "is_verified": false, - "line_number": 993 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "0e0d26feae012efa3585e895b6fa672005c3434e", - "is_verified": false, - "line_number": 994 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "42a18905f6b1ba2fa6cda2c3b08b43059503926d", - "is_verified": false, - "line_number": 995 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "960330eaa639a3374f20fb3bb1d33c3cb926f9cc", - "is_verified": false, - "line_number": 996 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c676ae0d67843480085f4544a475ccec95b1c942", - "is_verified": false, - "line_number": 997 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "05a62b604c1187eb336526d03642a7c46e6727c3", - "is_verified": false, - "line_number": 998 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "cde1211319f593ead3f23c0fac4f0ab48866f5da", - "is_verified": false, - "line_number": 999 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7d12d1e4865212b188c6aefd69096d4f6df8d113", - "is_verified": false, - "line_number": 1000 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "58c2087994575f810e6fb07f476718ac01436189", - "is_verified": false, - "line_number": 1001 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b9b320c5cd52c63f2c7d8df9f7eb8d7ae97ea0c9", - "is_verified": false, - "line_number": 1002 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "94ade2ea50c865df9827f975b66b0ed87f6196b3", - "is_verified": false, - "line_number": 1003 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "399c06fffa9278491e56e25312b94398408888b6", - "is_verified": false, - "line_number": 1004 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f20cde564b4b5821671912b7c6a87f2955fa42e8", - "is_verified": false, - "line_number": 1005 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "6f320defd3068726e899c9764628473dfd3552bf", - "is_verified": false, - "line_number": 1006 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2e1374c55dbeb0c445b7cebbcf13b2258776c08b", - "is_verified": false, - "line_number": 1007 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "60d220a965d81b4d93238d90e5f9f6a8cfe4ee1a", - "is_verified": false, - "line_number": 1008 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b6b4a1a8971608d6c5f4612efb7b811612fab847", - "is_verified": false, - "line_number": 1009 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "54d103be76f6e12ddfb2d277d367ce2e78d41c5b", - "is_verified": false, - "line_number": 1010 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "65de6ec76c0fb7685c47bc8c136b9f8e35187a14", - "is_verified": false, - "line_number": 1011 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3e507308114a34a5709c1796bc43132539ecc410", - "is_verified": false, - "line_number": 1012 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "6b2d7139a0eb9228a3ee9cce0808e1f8a8790e82", - "is_verified": false, - "line_number": 1013 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7a6e781d3ddf14c6314ee3329b8fec94fb15c29c", - "is_verified": false, - "line_number": 1014 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "fee4d49183e2b79df72990acf34d147d86b65df3", - "is_verified": false, - "line_number": 1015 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "6f0633cbd3640e2b979a8a1516c9bd394da76fe5", - "is_verified": false, - "line_number": 1016 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "711980892808cca786860a2790796417f526d762", - "is_verified": false, - "line_number": 1017 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "25756983273f8f4a48bb032b07c85104e4fc98cd", - "is_verified": false, - "line_number": 1018 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "5726a0328e5579f407bbf03fc3caa06062205ca8", - "is_verified": false, - "line_number": 1019 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e8c6a788cf042a2a2ea8989b33826f1d6423eb29", - "is_verified": false, - "line_number": 1020 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "755577452cdccb63d3e7f1d3176316fe5ef084c8", - "is_verified": false, - "line_number": 1021 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "0ec16170fcd97d28c0f5fa919e3c635358935c04", - "is_verified": false, - "line_number": 1022 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "0f91ef272eab7567d0f2db99dffc6dbaae2cc084", - "is_verified": false, - "line_number": 1023 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "35e6dad6c44367b5bb860ff5afeb54c8c92cef58", - "is_verified": false, - "line_number": 1024 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "73dcdb9d800fe9776667edb8cde8312a0a768ada", - "is_verified": false, - "line_number": 1025 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b56ea4486eded8635f63a8622a012fb3ee81a3bb", - "is_verified": false, - "line_number": 1026 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b0f4a8c4f6255ea5f66fdb118eba5eeb0829307d", - "is_verified": false, - "line_number": 1027 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "88d9c65e3ce55ba286c8faf8cb105ea6ac39a19b", - "is_verified": false, - "line_number": 1028 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "adc51f3f9a4c42b861f0da4fcc29392bafe2d98e", - "is_verified": false, - "line_number": 1029 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "96b4ea6fc588c3413700405f4d169504240aa637", - "is_verified": false, - "line_number": 1030 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f119079e796b8f2b9d29804daa90877f525cee3a", - "is_verified": false, - "line_number": 1031 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "fbf43f6ca18c68df0a478acd09bb465453c9358b", - "is_verified": false, - "line_number": 1032 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d437b203233fd78ffc8630e42a0655f58d2e9f4e", - "is_verified": false, - "line_number": 1033 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "6b7f8512ed9b6046476383c6515fc080c63ca508", - "is_verified": false, - "line_number": 1034 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d9f3006796ec72e11dba105176761e360fcf2a3d", - "is_verified": false, - "line_number": 1035 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ad59895b47e8ab566d17c2ef7121c98d469e0559", - "is_verified": false, - "line_number": 1036 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "132f531444b23991fdf797454d8f949e5426ff45", - "is_verified": false, - "line_number": 1037 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "406f3373f38a62e52e8caa4458dfaa68eca20780", - "is_verified": false, - "line_number": 1038 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ce605737729ff998492c8760553bd54393097aac", - "is_verified": false, - "line_number": 1039 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "fc42bf79fd0d8179e9f4f9f0190faad588388004", - "is_verified": false, - "line_number": 1040 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "efc0f56dded17fa0c00b58a820fbe74a1e368b63", - "is_verified": false, - "line_number": 1041 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9d450e49c3cbcffcfb559a51d6ab4531f2a645bf", - "is_verified": false, - "line_number": 1042 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8437e864bc114188554fd79b98cfd43f4c588df7", - "is_verified": false, - "line_number": 1043 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "de462d8851d3dc92579a62f39fadecf6b9d6bc22", - "is_verified": false, - "line_number": 1044 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "508fdca9918030fb0b8a8739ba791f611b793112", - "is_verified": false, - "line_number": 1045 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "4933bc7d4edeb7116d71e7f1947e5d6ed29760ec", - "is_verified": false, - "line_number": 1046 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "4a8bfde12d39966ecc92cc667695767bbdf7366b", - "is_verified": false, - "line_number": 1047 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3dbc1c47b263483e20fa69941a4274cc19f85bc2", - "is_verified": false, - "line_number": 1048 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d1287d92f048a817c6bb27b0993a87aa9560996b", - "is_verified": false, - "line_number": 1049 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "10cb9bc401ea5975fd15188a2b9cc592e513647a", - "is_verified": false, - "line_number": 1050 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f18de35aa597b41bb9d73890f35c8f7704c72ea1", - "is_verified": false, - "line_number": 1051 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "dfe7e4f70a85c9d4d9e5e43b38e6c4afb6af9858", - "is_verified": false, - "line_number": 1052 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d39edd8dd598dfb8918b748d29c25259509675dd", - "is_verified": false, - "line_number": 1053 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "5d2721a37cabecbb784a5e45ff9d869e7c90d7f5", - "is_verified": false, - "line_number": 1054 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "60d52adbbee54411db221581b7d93960b772f691", - "is_verified": false, - "line_number": 1055 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "af1320e386741990cf1c7201101f2ae194fc72ca", - "is_verified": false, - "line_number": 1056 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "4bbc199707b0d38feb6244d4069391cf4af4b8bb", - "is_verified": false, - "line_number": 1057 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "22023f99a0e352116a61bf566f8af2ab60b5d9c1", - "is_verified": false, - "line_number": 1058 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3f664164c66bb49689d9931436c3d4f57f316eb6", - "is_verified": false, - "line_number": 1059 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9a4a988167abb6a3816d472d4be97cd105a69baf", - "is_verified": false, - "line_number": 1060 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7edf4402503eaf501e23c31ef1306392d5ecacd0", - "is_verified": false, - "line_number": 1061 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "508b4ed03f5a2f09fb22e2641580065ee4c8a372", - "is_verified": false, - "line_number": 1062 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b02f44c26e7091096fa6fcafb832b62869af42a2", - "is_verified": false, - "line_number": 1063 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "0f9174e85538561b056727e432773bb69e128278", - "is_verified": false, - "line_number": 1064 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "cabc1f10dc737ef7e110172b814966cdad11b159", - "is_verified": false, - "line_number": 1065 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ee5288a3e32b3b55b342ef18051c78ffff012231", - "is_verified": false, - "line_number": 1066 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "0a25e259c157bcc1a99d7e001e52b35d0a4ae2b8", - "is_verified": false, - "line_number": 1067 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3c7bdd0b20d6f7c299da33dbb32d99105489f1c4", - "is_verified": false, - "line_number": 1068 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "19b40ca81ef322c1c0028ad1a005654faa9cfe93", - "is_verified": false, - "line_number": 1069 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "fc4ff73da4fb03231a38728acf285f405b1b3ce5", - "is_verified": false, - "line_number": 1070 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c4e603285dc95917f8836283bebce03ff4bc11ba", - "is_verified": false, - "line_number": 1071 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e9e498abd308db923d58b1c35ad83467e58a60b3", - "is_verified": false, - "line_number": 1072 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "954161d814c5c2ccf3ce8c3609ebb4157c08b6f7", - "is_verified": false, - "line_number": 1073 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9bcf9c2a4de2db297ac881231955ad39f19a9df1", - "is_verified": false, - "line_number": 1074 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8eafb590298e1d35ed72d88625bd344a427ccc8b", - "is_verified": false, - "line_number": 1075 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "32a3705a4ce42eecec3c45b0bb0a2c36142b6d08", - "is_verified": false, - "line_number": 1076 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "5e8a991485e2080c429eab8a5049b3c3bf7c0ba8", - "is_verified": false, - "line_number": 1077 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d9fbae4d79a44395e6eca487062df13d46954053", - "is_verified": false, - "line_number": 1078 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f62a4f64d930b746fbefdad6c48b0d2a2dc07130", - "is_verified": false, - "line_number": 1079 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f7af30387bf7c4ac2cc0b48eef09f350ec43dae8", - "is_verified": false, - "line_number": 1080 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "afb00100d9ca02672c09acc78c7e13b56b049f63", - "is_verified": false, - "line_number": 1081 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "428e0f17cb680f5fc2b3cdc648ef8739b0fc1d87", - "is_verified": false, - "line_number": 1082 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a7846f258d908bca9bdf9120db6b9b370a4143bd", - "is_verified": false, - "line_number": 1083 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "38c581282a5c2d07745c008443cdc545acbf5aca", - "is_verified": false, - "line_number": 1084 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "63f97716fc1f282d6718710c230006611b86be04", - "is_verified": false, - "line_number": 1085 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "57600ce03478249d79dd13c009f7f64b7ae6211c", - "is_verified": false, - "line_number": 1086 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8e96ee931397b82b3f2c330bcfb3cfea3093d5a7", - "is_verified": false, - "line_number": 1087 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c85653058313f125a2438e1cf446cb90bbedd8ed", - "is_verified": false, - "line_number": 1088 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "1a54794f5e3a4dd2036cfd120e294e6401f6d227", - "is_verified": false, - "line_number": 1089 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "60f2b36dcf992c96fe61ea001441417f314064ff", - "is_verified": false, - "line_number": 1090 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "939ca981ece9656aebd5b02d02ed33deadb8923b", - "is_verified": false, - "line_number": 1091 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c28c0ae6268f5e6e813f9fe3b119e211473071e6", - "is_verified": false, - "line_number": 1092 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "fa66a89cdd91b75a640282d832886514fe6456a1", - "is_verified": false, - "line_number": 1093 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e464c2a1ba37ae51b0f7ff8b3fba06a8ed7108dc", - "is_verified": false, - "line_number": 1094 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8fb023d4933c56bfeb403311ffc3752d2fbc975e", - "is_verified": false, - "line_number": 1095 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8f066fc1693da2a9cfa30bc540bb35f884c62a30", - "is_verified": false, - "line_number": 1096 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "63a7db4c42e5b728324ad5d2c92e6514ab23364a", - "is_verified": false, - "line_number": 1097 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d4b9ba68b048c4c52c65e192dd281c1c203463c0", - "is_verified": false, - "line_number": 1098 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "33e4d896c6a8b4d14cb836f616f03eaafa43018b", - "is_verified": false, - "line_number": 1099 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "1a5b72368ecddce420d879781be813c19475c1be", - "is_verified": false, - "line_number": 1100 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "0106004ab89b24991e5e01849276a2ed348d1194", - "is_verified": false, - "line_number": 1101 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "54ede800e24d999c54ce14b80d8c56f834d1a570", - "is_verified": false, - "line_number": 1102 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ff58b7f59920c5d3484985e53a686b91d7b183cd", - "is_verified": false, - "line_number": 1103 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "255ac9b7f9fa6a2376b2fc2219ff38f80dc8c655", - "is_verified": false, - "line_number": 1104 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b0b7694dff36d2e9337b1012073d9ab41aec18c6", - "is_verified": false, - "line_number": 1105 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3d675b3354c15f5088cf1581fc9fa052360c8ecf", - "is_verified": false, - "line_number": 1106 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "6e11485ed9e411128ab20a54b6d52e4e879e289f", - "is_verified": false, - "line_number": 1107 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "200a78aa828ba2d7cca00e420a85bef9dde6c841", - "is_verified": false, - "line_number": 1108 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "936a30deb66f624c112527914bbe2f09fb1c2ea2", - "is_verified": false, - "line_number": 1109 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "430e0786d83a62119d1ed6bdc8b87efbf7afbc9d", - "is_verified": false, - "line_number": 1110 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f3fd7614d07e21dc15fa385fc2042847610f8259", - "is_verified": false, - "line_number": 1111 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "dddf43eddf77d768ace4901fc5d506ae2c85ec2d", - "is_verified": false, - "line_number": 1112 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ae367707142233fce304a364467337f943952845", - "is_verified": false, - "line_number": 1113 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "6b16b9ea707df813fc90c54d7a531cf0f6b754d0", - "is_verified": false, - "line_number": 1114 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "cd1dc83b5bd180fb9f5e72361ff34526b2227197", - "is_verified": false, - "line_number": 1115 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2f4400f3ba736cab5d0bf75f249c030724c8d0b7", - "is_verified": false, - "line_number": 1116 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "43d51f653e0a59b1f5988c8b6732b71dc2492bde", - "is_verified": false, - "line_number": 1117 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "32336fe7d0a6638edadafcef1f7355ff5a5043d1", - "is_verified": false, - "line_number": 1118 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "4915df89c72bb9de93ba1cf88de251db9ebb05ec", - "is_verified": false, - "line_number": 1119 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3f1343a17f1e3d24a58df03d29a1330994239874", - "is_verified": false, - "line_number": 1120 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a240e2ccfb08d02d3d54ce913d120af2b4a68a19", - "is_verified": false, - "line_number": 1121 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ac1f2ad12e871b6e5818be4e7f23f90f0b655c65", - "is_verified": false, - "line_number": 1122 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3b792af94a90899b8cfb1cc44605d4de5c0eab7a", - "is_verified": false, - "line_number": 1123 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d6d3294546ce3a4df35269a80497b35d3d97851c", - "is_verified": false, - "line_number": 1124 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "04992ccff77891f14f3dca8bb59cc30534ae31f3", - "is_verified": false, - "line_number": 1125 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "bbb54a9a3169f76822f3c8de4c5c33c12138a8ed", - "is_verified": false, - "line_number": 1126 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "64419f894e06d7b0ab1236d60034a5410006f422", - "is_verified": false, - "line_number": 1127 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f58a6063b0ce4ccf2630215d7ab442eb3a6cc154", - "is_verified": false, - "line_number": 1128 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "80fa5cbedc3d970f28652338cbd1da179a4b24f5", - "is_verified": false, - "line_number": 1129 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "904d8f8daa11159afe547828d6da112ec785fc9e", - "is_verified": false, - "line_number": 1130 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "62e23442e30718968242cf6397ceaf835e2b6758", - "is_verified": false, - "line_number": 1131 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8ce675cce57b21a3cf664029ff539107da67583b", - "is_verified": false, - "line_number": 1132 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "64098f0a9449c43a8f071d2052c6066940e75ee8", - "is_verified": false, - "line_number": 1133 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "876250d35eaa0e8f788304e6f47bfb9ecf4aa1f4", - "is_verified": false, - "line_number": 1134 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7aac80369e7b76f53ae0de0d94dfbaa21a130d32", - "is_verified": false, - "line_number": 1135 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "65df2537b97ebdb84c0dc6afa37f140811294e57", - "is_verified": false, - "line_number": 1136 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f6ed524b021390fe734f26cac66fcf1e6a6c455e", - "is_verified": false, - "line_number": 1137 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8fdc365a4e50f09aa482d72bba1974df3b6c9859", - "is_verified": false, - "line_number": 1138 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "36890040b0afedd15fdd9eb87459a4165fcbe2a3", - "is_verified": false, - "line_number": 1139 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9df5cbdfba97fabe10d94f771bcd7ca889c87b2d", - "is_verified": false, - "line_number": 1140 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "de65594f00e0098e7ab3312414faf191bbc3e3c1", - "is_verified": false, - "line_number": 1141 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "37247ab05766ecc1ac7fae19a77b31f7116cce38", - "is_verified": false, - "line_number": 1142 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "13d8923244df4b3025c5d2dd405a22a757628f8d", - "is_verified": false, - "line_number": 1143 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9eef15e4a145e31f7c74235731b69dba5207b237", - "is_verified": false, - "line_number": 1144 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "746b63eabaddeed7ab5dbe3b1fe4e41f89e9f21e", - "is_verified": false, - "line_number": 1145 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f9512226d4044bb241d77988dac046b05effb4f3", - "is_verified": false, - "line_number": 1146 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "de168aa5d99ff80498b7552c850db5d42cb425f9", - "is_verified": false, - "line_number": 1147 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2367ab77f144da2b2349cdbfdc4500d429754353", - "is_verified": false, - "line_number": 1148 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d6a619ebb4b2766bce83fa5bfb6118a9d8ba3212", - "is_verified": false, - "line_number": 1149 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "35fe8489533c677b657cfee61474bab7f268a495", - "is_verified": false, - "line_number": 1150 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e58be566894c228cb922e434d34416a473f0dc28", - "is_verified": false, - "line_number": 1151 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "18f33c6db138875913acb6ad887ed80ca3dc317f", - "is_verified": false, - "line_number": 1152 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "1e8a66cfa6671b1771e5874f29bfd96e47b4ad76", - "is_verified": false, - "line_number": 1153 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "284301d7ef66a6721a4b76a02c274419de91a437", - "is_verified": false, - "line_number": 1154 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "6694d586f66b50c0162e1cff4b1f133e2c8a9423", - "is_verified": false, - "line_number": 1155 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c712802905f08891cac2e68e6d8f5f6d85e4cf60", - "is_verified": false, - "line_number": 1156 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "cd5f0c85968b392a77596cb5143de81f6f109bcd", - "is_verified": false, - "line_number": 1157 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e158eb64d577c9904690ff67584f2b0090792139", - "is_verified": false, - "line_number": 1158 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "62cef2983d23c372ffd1175683e2cf0489a0a93c", - "is_verified": false, - "line_number": 1159 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "0039a393f63d3b522516a90354354b6477765b06", - "is_verified": false, - "line_number": 1160 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "5c91012c71d492f7e5bc5607f71e1d3337562f9b", - "is_verified": false, - "line_number": 1161 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "83fd266255474e467fcc3f1ca61b0371bf6933eb", - "is_verified": false, - "line_number": 1162 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "44dc9bc4f3a32681036d3328bf2e2c298c94c5b3", - "is_verified": false, - "line_number": 1163 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c077db4aab559fcc23cecde6c8dce6f58a86c7ba", - "is_verified": false, - "line_number": 1164 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f2e728ed22184e3a7bf3b34308c53815d811687d", - "is_verified": false, - "line_number": 1165 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9d653c4cd2f63ba627e1f7eb557b793e7eb50f3a", - "is_verified": false, - "line_number": 1166 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "33e0029ea6c1f2989bf2b5b86f6c4acc03fd7b10", - "is_verified": false, - "line_number": 1167 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "139c8a653e6827e2b29b75c31d27eba181977579", - "is_verified": false, - "line_number": 1168 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e34424070b48aeaee9eeeb88a1a928d2ce1f5517", - "is_verified": false, - "line_number": 1169 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c4db39ccd7c06e68ada50b294aa53f947559a99a", - "is_verified": false, - "line_number": 1170 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "0636d970e79e781a5159068c6fe7f0411698b596", - "is_verified": false, - "line_number": 1171 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "0bc38af13c57dafb7f18b33b86e5bcbe1292bc2e", - "is_verified": false, - "line_number": 1172 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "02d9eabf8b61d1e62425eac9c7b39385e602ddad", - "is_verified": false, - "line_number": 1173 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3ba33420b436dd34da6f45fdbdbb26a87c99e811", - "is_verified": false, - "line_number": 1174 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2965a6a5b73c3edfdc11d9a979bb085546d63d1f", - "is_verified": false, - "line_number": 1175 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8b15da0afbed8313d1daec67d4bca7958949484d", - "is_verified": false, - "line_number": 1176 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "4bf0c8b08ddcb81f5ac2457580003197ff4782dd", - "is_verified": false, - "line_number": 1177 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9e3822884cf25511703c4fbfce1ddacc0d19d021", - "is_verified": false, - "line_number": 1178 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "26fd6e63721168b064c7825415fda7da4c17cd36", - "is_verified": false, - "line_number": 1179 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "82db110822969249eff39d4b7e6830ee919c4b8e", - "is_verified": false, - "line_number": 1180 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e81523785f6e5efeb372a665059ab959c7911c37", - "is_verified": false, - "line_number": 1181 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "4c8056fa1e16e63e4da13f329a0f0ba8c3d875eb", - "is_verified": false, - "line_number": 1182 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "63a9faac8e9440b425905da27052de51aa69b937", - "is_verified": false, - "line_number": 1183 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e0ad9315e82b5f80b7b02ce12ba3e686c9a637a5", - "is_verified": false, - "line_number": 1184 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "176ca3d77737c23c86a524235e4281df3a64a573", - "is_verified": false, - "line_number": 1185 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e8b4a7abb0c1178809eb5f5703ed43d558083a2d", - "is_verified": false, - "line_number": 1186 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7e0ad9ba810350bcd8da9180615fd964827c14ef", - "is_verified": false, - "line_number": 1187 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "39c3357766171faf88e70eea0dccb00239f273c5", - "is_verified": false, - "line_number": 1188 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d17aa49aceeaf925527404fa57a4e17668de8596", - "is_verified": false, - "line_number": 1189 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2a6b75b5576df53c3219112e7daff1dc142702d1", - "is_verified": false, - "line_number": 1190 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b75fa52e7d8ecfb8e7e9ff3dc2c37b73abcf7e2c", - "is_verified": false, - "line_number": 1191 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c551bfc4af7eb1fd5daa4f05fd58a2d4d65b85fe", - "is_verified": false, - "line_number": 1192 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a8d858cd02dcd5038dc3e76ac76b2da91f8dbccd", - "is_verified": false, - "line_number": 1193 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "1bf631baf29fc48072c20ebfdd321964066f9f08", - "is_verified": false, - "line_number": 1194 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c6eb53905cd7e0253f4e69f34295cb6a50f58e08", - "is_verified": false, - "line_number": 1195 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7bbb8b2539588d170a6c26e9f61ae0800f9d8f2d", - "is_verified": false, - "line_number": 1196 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "26caefb3dca46d7afafdcf0010c67b9e9fccc92b", - "is_verified": false, - "line_number": 1197 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2cb19ac1427a96db3d380729bf039e5349ef63be", - "is_verified": false, - "line_number": 1198 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9e2aa480ce341383cbca0c207198d483e20322bd", - "is_verified": false, - "line_number": 1199 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "be742ba9f651b96a51823045433f3a1948d7eced", - "is_verified": false, - "line_number": 1200 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "317bd6bc5bcc732a1db7e57d0371aa9257f8df00", - "is_verified": false, - "line_number": 1201 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7c80c0ebf44179e49cf0e5a3d0408cc76aee83de", - "is_verified": false, - "line_number": 1202 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7858b77e2046951eadc43758c07104d777668eb7", - "is_verified": false, - "line_number": 1203 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "85a09b9fd03c47f1b036cf44c4909bc73ddd6cad", - "is_verified": false, - "line_number": 1204 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "1718e46e064b47cec903bad3b0e9d6ef1da2f11b", - "is_verified": false, - "line_number": 1205 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "0c1ee8a96d538ba8b4fa8b05db03563fd7ef8973", - "is_verified": false, - "line_number": 1206 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2017b3f2be44d213be17940140c168a5fba7561d", - "is_verified": false, - "line_number": 1207 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b083a5002d8fe4f2a66696aa0814e03ffa6d1837", - "is_verified": false, - "line_number": 1208 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ff42555f72300b656e47db4ed191f5df0ac07560", - "is_verified": false, - "line_number": 1209 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2ef2cf7195a65a890efa0632dd212ef8220aa1c6", - "is_verified": false, - "line_number": 1210 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "69cb36505922753131885b4a08c707f81ac66a47", - "is_verified": false, - "line_number": 1211 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "069b86c3a9114bd673eef998e22656df1fcaddd8", - "is_verified": false, - "line_number": 1212 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "70c8686a1be4b67a602a59a873ddbede2cd4da7e", - "is_verified": false, - "line_number": 1213 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "523d5a3e6d4fbf64c23594663c7e4687ae9c2be3", - "is_verified": false, - "line_number": 1214 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "16e86f176fd3cd4f7a58f0ffb8dc5791f3f95a86", - "is_verified": false, - "line_number": 1215 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ed84afa53dc05329a7991f5bf5cd2cae1fd77ffc", - "is_verified": false, - "line_number": 1216 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f1289b7119566377ed28ab9dd62af0fd09ed9fe2", - "is_verified": false, - "line_number": 1217 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a4f904b0556d1681ef00ea1813f2f94e28b797eb", - "is_verified": false, - "line_number": 1218 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "0949c112813b58b0da6912740cf8bcbb85226c34", - "is_verified": false, - "line_number": 1219 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "1bbf17622cda5702d35e14ba66df075a7bb57913", - "is_verified": false, - "line_number": 1220 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8e3a03cec08874a64bccc6d6d425f0afe79533a1", - "is_verified": false, - "line_number": 1221 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "1aafc9018c54c7198cf74db22feb0319707898b6", - "is_verified": false, - "line_number": 1222 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e7b49f254a6e2de711e659bd28ad158691e30fce", - "is_verified": false, - "line_number": 1223 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "fbc11861a047faba2041e2b6c715d8ca60803c8e", - "is_verified": false, - "line_number": 1224 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "44c990c1ce572f1e8f1ab851427e3a42ce71242a", - "is_verified": false, - "line_number": 1225 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "4d3640532de6af408ed943d63ed3e3c2689e9c5f", - "is_verified": false, - "line_number": 1226 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a523fffc0ede19e1deeda09652de2b7a018cf8b4", - "is_verified": false, - "line_number": 1227 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "4a995d1758da7e7154ba4acbec5b5b403742b7e1", - "is_verified": false, - "line_number": 1228 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "de4be8856b30e21fc713dc10f8988539feea7023", - "is_verified": false, - "line_number": 1229 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "fb1c0866f73c66412d08391f3ce4878af73aa639", - "is_verified": false, - "line_number": 1230 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a702fefff9cdbe1f95ab8827ddec5ba8efc30892", - "is_verified": false, - "line_number": 1231 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "724b47ffa7a9db1bbaf712b3d9d2b76898db0ea5", - "is_verified": false, - "line_number": 1232 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e0f16906358b6b058b6d986929a05521b6901f68", - "is_verified": false, - "line_number": 1233 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "4332f528fff4a967c90c89db64aa58e23393bfed", - "is_verified": false, - "line_number": 1234 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "451a10712041218c61b0cc3787311943dab42dc6", - "is_verified": false, - "line_number": 1235 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "6a1be9deb76862f934fd8a9197069f4609ef70b5", - "is_verified": false, - "line_number": 1236 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2b1256a86a2fb02c20dc58e47774d30baed60f62", - "is_verified": false, - "line_number": 1237 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "74d000f3ede09a41df362d509537a2ac5f1fa07b", - "is_verified": false, - "line_number": 1238 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "43f8293d7eda52b663063cd56e5a3e394f193642", - "is_verified": false, - "line_number": 1239 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "51352b84bafc3573024540c543cc95922a764ef0", - "is_verified": false, - "line_number": 1240 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "0ece3e42bfed9840f907fa700d5d29f0087985db", - "is_verified": false, - "line_number": 1241 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3b91d6d99ae8c482392adc042654bd076573cd8a", - "is_verified": false, - "line_number": 1242 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ab529305822e1642ed7c7d3acd9ba80dabc55108", - "is_verified": false, - "line_number": 1243 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3cf4744d88fd85b0fcb0fbf0425c5b50eae93b3e", - "is_verified": false, - "line_number": 1244 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "228fe53a555785f979a20a0159c96ef7d8d057c7", - "is_verified": false, - "line_number": 1245 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8d21215aa0a8f29d068ff316fc09ea6ae9e766c7", - "is_verified": false, - "line_number": 1246 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d63d3d63396c5e88f1fd8cdab9116331080cd2e2", - "is_verified": false, - "line_number": 1247 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d4fe6d5f06c2860ed38ebb02079bb2ebfcbfb093", - "is_verified": false, - "line_number": 1248 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "5e1d352485a30350ac108f66da7ac3ce62b1ea4f", - "is_verified": false, - "line_number": 1249 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c682e7af6638379e4edf52c36995c3454ea1b149", - "is_verified": false, - "line_number": 1250 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "bb193ef1c9bcbc39ed64689f474af29719df489e", - "is_verified": false, - "line_number": 1251 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "01c34073e2e61552f4fd0ba64139be0ccabcdb8a", - "is_verified": false, - "line_number": 1252 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "cc47b8620102a6216f098eb7f9ea841c3c2a5f22", - "is_verified": false, - "line_number": 1253 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8f070c859fe84c5502e45b84a274308bbc0a7744", - "is_verified": false, - "line_number": 1254 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "5f3061dc64135be12c1eaef23ab8e02f1826f24d", - "is_verified": false, - "line_number": 1255 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "9238be5963618c3501e919ebd4c13992a4bea3b4", - "is_verified": false, - "line_number": 1256 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "68c1365f209fa103e65c4da375b42d5656575940", - "is_verified": false, - "line_number": 1257 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "384be6402a8d31d62cb35fefaec77b06c8211f59", - "is_verified": false, - "line_number": 1258 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "360329c0a8cb6053168e61758688b85104fc86ff", - "is_verified": false, - "line_number": 1259 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7cd87f59db950306302a74b81e8f926df1577397", - "is_verified": false, - "line_number": 1260 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "553b2380d863621a9e4ab7c7a97fdec425ebab25", - "is_verified": false, - "line_number": 1261 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "43562265e7cf90c28221c2b7dbfcafa8f62843dc", - "is_verified": false, - "line_number": 1262 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ed7e495370ef7882b13866c332dff00ef7c361a6", - "is_verified": false, - "line_number": 1263 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7123453c9f62fc6c33951aa2595f1714b23d583a", - "is_verified": false, - "line_number": 1264 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e941c0eb1694570c999ca3fe548f76f6daaca83c", - "is_verified": false, - "line_number": 1265 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "85018e48b287ca7323192ff38ebe9411e61b38e2", - "is_verified": false, - "line_number": 1266 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "814d7edca30e0262ab0b07c6baf47d20738c823b", - "is_verified": false, - "line_number": 1267 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "5dca59fe14f949e763116aef3968af2662926895", - "is_verified": false, - "line_number": 1268 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "ee86abd29ecfab79519c1efc033546d2c477477f", - "is_verified": false, - "line_number": 1269 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "5878ed0ebded462f8d2461fe18061aa18d1000fd", - "is_verified": false, - "line_number": 1270 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "4dd683cc3993e43d00b1b5f9e4e57895bb56e8e5", - "is_verified": false, - "line_number": 1271 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "a8a20da925fd5126d24df7d8baf68ac1fa23a184", - "is_verified": false, - "line_number": 1272 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "137f68b2d3f03ddd81ed8602ff19218c71df55fb", - "is_verified": false, - "line_number": 1273 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b32f2f31a868ddf0e3f013465c72527f62057e44", - "is_verified": false, - "line_number": 1274 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f5425542a9e9183a33dd16d559c92182f35f44a8", - "is_verified": false, - "line_number": 1275 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "77e8234c8ff852ec820384cd8f9284cde00e34a9", - "is_verified": false, - "line_number": 1276 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "be6e0ac8ab7d8ac8d7f7a4fc86b123392c09374e", - "is_verified": false, - "line_number": 1277 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3063130919857912b6373c6182853095d60ca18b", - "is_verified": false, - "line_number": 1278 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "607c9f8efafb2de11157fefd103f9f1cda4f347b", - "is_verified": false, - "line_number": 1279 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "2c301b0126a15e8150d92a84d8a49ab1eb9b4282", - "is_verified": false, - "line_number": 1280 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "84737ddb75ed5806c645ba66e122402be971389a", - "is_verified": false, - "line_number": 1281 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "5a9adaee2ecb6e99992aa263eda966061c9acac0", - "is_verified": false, - "line_number": 1282 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "0c09b49e14a5a35d3f26420994f8b786035166e6", - "is_verified": false, - "line_number": 1283 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "0ef06e9fe84d92197ae053067b3f3d5051070690", - "is_verified": false, - "line_number": 1284 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b249743c201079e983e03d0afeb3c140342fc9d0", - "is_verified": false, - "line_number": 1285 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "82d624e2d36bf5346e60dd14806ff782bb2a4334", - "is_verified": false, - "line_number": 1286 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "88850db69d81a7ece67fb1d9b286c2d951b70819", - "is_verified": false, - "line_number": 1287 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "e49afb46bf458312000f8f9660ae81ff47bdc199", - "is_verified": false, - "line_number": 1288 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "1cbdad16e84903fc3b9b6388a089a067dea2a3d2", - "is_verified": false, - "line_number": 1289 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "82feda736f248ac86d376891de516d9d1824a27c", - "is_verified": false, - "line_number": 1290 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "4a71f468c1364aff801b9120b1f5d529078048e9", - "is_verified": false, - "line_number": 1291 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f091998ff0fee46909f88aa7fd4f3cc73a3d3c9a", - "is_verified": false, - "line_number": 1292 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "29eaffa6f6f8a37758a5f7b32907b3dc5b691896", - "is_verified": false, - "line_number": 1293 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "44f681b1a58ce0c6df53676cc0808013e97ea9f4", - "is_verified": false, - "line_number": 1294 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "962dfd74b7253ac6cd612a6e748f2e95efb79f51", - "is_verified": false, - "line_number": 1295 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c86ef7132a2306cf87224e55cb204e6d2e8e7828", - "is_verified": false, - "line_number": 1296 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c4eb42c72ecfdf7810202a43d54548f7d2bff62d", - "is_verified": false, - "line_number": 1297 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "19383a628b845b1cbb1c0444832b0afbe8ab5064", - "is_verified": false, - "line_number": 1298 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b34bf28a1f7465a72772787a147d434d923c8d1b", - "is_verified": false, - "line_number": 1299 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "288ba78781c2ed007a423cb65cb1bf2306c3fd95", - "is_verified": false, - "line_number": 1300 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f3ceb3cc25a1228a6c53b4e215d7568d36e757a6", - "is_verified": false, - "line_number": 1301 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "87996bb1e32b4a0ecc22ac1d13cea8e0190b350b", - "is_verified": false, - "line_number": 1302 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "790704b8f93fe5aca8ac2ecfcb68f1584dad2647", - "is_verified": false, - "line_number": 1303 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "86223a1c42e86aae0a1ed4fa7d40eb2d059c4dd5", - "is_verified": false, - "line_number": 1304 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "1673e79621b9dddf3b29a9b1ddf8d2ec0aad4bdc", - "is_verified": false, - "line_number": 1305 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "35b29b6e62d70ae4822318a19d0a46658eddd34f", - "is_verified": false, - "line_number": 1306 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b3cb65216294e3c0b3981e2db721954bafc3b23a", - "is_verified": false, - "line_number": 1307 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "5dbca02e62ce0d208d12a1da12ba317344d8c6cc", - "is_verified": false, - "line_number": 1308 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "83acef9b2863c05447dea16c378025f007bc8c34", - "is_verified": false, - "line_number": 1309 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "524ef34b587ca7240673b9607b4314f3f37cd2a8", - "is_verified": false, - "line_number": 1310 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c76948814be7ef0455d6d9ff65aeae688b7bec24", - "is_verified": false, - "line_number": 1311 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "5604fd630dabf095466a6c854750348059dbb1aa", - "is_verified": false, - "line_number": 1312 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "0b5772a512bb087fa1d6e34a062c7eec75f6e744", - "is_verified": false, - "line_number": 1313 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "7d3fa248843c7c76c909ee18b0dd773bbb5741e7", - "is_verified": false, - "line_number": 1314 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d7d16ac0dbd0bb5e98c6cb1d8508ff0132bbcbb0", - "is_verified": false, - "line_number": 1315 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d33b30cdcf982839a7cb6ae4e04b74deb2bd8f28", - "is_verified": false, - "line_number": 1316 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "281ca8a981dae1cebcb05b90cde4c895f3c59525", - "is_verified": false, - "line_number": 1317 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "712b5a91ad8f25eaaae3afccd7b41c6215102f70", - "is_verified": false, - "line_number": 1318 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "5fbc83376379b2201ae51f28039f87cb1ca14649", - "is_verified": false, - "line_number": 1319 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "d7497697fc350ef28cc0682526233a7846bfbf7f", - "is_verified": false, - "line_number": 1320 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "3f3d0b8308dfa23ce4c75abcfdd3840cab33de8b", - "is_verified": false, - "line_number": 1321 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "71a4936bbf172bf22c55b532a505a2c33f04ef2a", - "is_verified": false, - "line_number": 1322 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "f76a3c0087070143222761d33c9496d10ec5645a", - "is_verified": false, - "line_number": 1323 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "b8e837e18bc28489da6d38ac38370bd4a7757770", - "is_verified": false, - "line_number": 1324 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "af90bf5453dacd36dd205811a40eda42d5496cb5", - "is_verified": false, - "line_number": 1325 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "1fd5a47605b1192ee40beb9203beaafe8e53e13c", - "is_verified": false, - "line_number": 1326 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "c286938c2542589cd0fbed6acb6326d3c9efeb77", - "is_verified": false, - "line_number": 1327 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "73cfd5a17466838726c63386a3e5cccdf722a9d8", - "is_verified": false, - "line_number": 1328 - }, - { - "type": "Hex High Entropy String", - "filename": "docs/.i18n/zh-CN.tm.jsonl", - "hashed_secret": "8bb0680522ae015a5b71c1e7d24ec4641960c322", - "is_verified": false, - "line_number": 1329 - } - ], - "docs/brave-search.md": [ - { - "type": "Secret Keyword", - "filename": "docs/brave-search.md", - "hashed_secret": "491d458f895b9213facb2ee9375b1b044eaea3ac", - "is_verified": false, - "line_number": 27 - } - ], - "docs/channels/bluebubbles.md": [ - { - "type": "Secret Keyword", - "filename": "docs/channels/bluebubbles.md", - "hashed_secret": "555da20df20d4172e00f1b73d7c3943802055270", - "is_verified": false, - "line_number": 37 - } - ], - "docs/channels/feishu.md": [ - { - "type": "Secret Keyword", - "filename": "docs/channels/feishu.md", - "hashed_secret": "b60d121b438a380c343d5ec3c2037564b82ffef3", - "is_verified": false, - "line_number": 187 - }, - { - "type": "Secret Keyword", - "filename": "docs/channels/feishu.md", - "hashed_secret": "186154712b2d5f6791d85b9a0987b98fa231779c", - "is_verified": false, - "line_number": 435 - } - ], - "docs/channels/irc.md": [ - { - "type": "Secret Keyword", - "filename": "docs/channels/irc.md", - "hashed_secret": "d54831b8e4b461d85e32ea82156d2fb5ce5cb624", - "is_verified": false, - "line_number": 191 - } - ], - "docs/channels/line.md": [ - { - "type": "Secret Keyword", - "filename": "docs/channels/line.md", - "hashed_secret": "83661b43df128631f891767fbfc5b049af3dce86", - "is_verified": false, - "line_number": 61 - } - ], - "docs/channels/matrix.md": [ - { - "type": "Secret Keyword", - "filename": "docs/channels/matrix.md", - "hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6", - "is_verified": false, - "line_number": 60 - } - ], - "docs/channels/nextcloud-talk.md": [ - { - "type": "Secret Keyword", - "filename": "docs/channels/nextcloud-talk.md", - "hashed_secret": "76ed0a056aa77060de25754586440cff390791d0", - "is_verified": false, - "line_number": 56 - } - ], - "docs/channels/nostr.md": [ - { - "type": "Secret Keyword", - "filename": "docs/channels/nostr.md", - "hashed_secret": "edeb23e25a619c434d22bb7f1c3ca4841166b4e8", - "is_verified": false, - "line_number": 67 - } - ], - "docs/channels/slack.md": [ - { - "type": "Secret Keyword", - "filename": "docs/channels/slack.md", - "hashed_secret": "3f4800fb7c1fb79a9a48bfd562d90bc6b2e2b718", - "is_verified": false, - "line_number": 104 - } - ], - "docs/channels/twitch.md": [ - { - "type": "Secret Keyword", - "filename": "docs/channels/twitch.md", - "hashed_secret": "0d1ba0da3e84e54f29846c93c43182eede365858", - "is_verified": false, - "line_number": 138 - }, - { - "type": "Secret Keyword", - "filename": "docs/channels/twitch.md", - "hashed_secret": "7cb4c5b8b81e266d08d4f106799af98d748bceb9", - "is_verified": false, - "line_number": 324 - } - ], - "docs/concepts/memory.md": [ - { - "type": "Secret Keyword", - "filename": "docs/concepts/memory.md", - "hashed_secret": "39d711243bfcee9fec8299b204e1aa9c3430fa12", - "is_verified": false, - "line_number": 281 - }, - { - "type": "Secret Keyword", - "filename": "docs/concepts/memory.md", - "hashed_secret": "1a8abbf465c52363ab4c9c6ad945b8e857cbea55", - "is_verified": false, - "line_number": 305 - }, - { - "type": "Secret Keyword", - "filename": "docs/concepts/memory.md", - "hashed_secret": "b9f640d6095b9f6b5a65983f7b76dbbb254e0044", - "is_verified": false, - "line_number": 706 - } - ], - "docs/concepts/model-providers.md": [ - { - "type": "Secret Keyword", - "filename": "docs/concepts/model-providers.md", - "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", - "is_verified": false, - "line_number": 178 - }, - { - "type": "Secret Keyword", - "filename": "docs/concepts/model-providers.md", - "hashed_secret": "6a4a6c8f2406f4f0843a0a1aae6a320f92f9d6ae", - "is_verified": false, - "line_number": 274 - }, - { - "type": "Secret Keyword", - "filename": "docs/concepts/model-providers.md", - "hashed_secret": "ef83ad68b9b66e008727b7c417c6a8f618b5177e", - "is_verified": false, - "line_number": 305 - } - ], - "docs/gateway/configuration-examples.md": [ - { - "type": "Secret Keyword", - "filename": "docs/gateway/configuration-examples.md", - "hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281", - "is_verified": false, - "line_number": 57 - }, - { - "type": "Secret Keyword", - "filename": "docs/gateway/configuration-examples.md", - "hashed_secret": "b6f56e5e92078ed7c078c46fbfeedcbe5719bc25", - "is_verified": false, - "line_number": 59 - }, - { - "type": "Secret Keyword", - "filename": "docs/gateway/configuration-examples.md", - "hashed_secret": "22af290a1a3d5e941193a41a3d3a9e4ca8da5e27", - "is_verified": false, - "line_number": 332 - }, - { - "type": "Secret Keyword", - "filename": "docs/gateway/configuration-examples.md", - "hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd", - "is_verified": false, - "line_number": 431 - }, - { - "type": "Secret Keyword", - "filename": "docs/gateway/configuration-examples.md", - "hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209", - "is_verified": false, - "line_number": 596 - } - ], - "docs/gateway/configuration-reference.md": [ - { - "type": "Secret Keyword", - "filename": "docs/gateway/configuration-reference.md", - "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", - "is_verified": false, - "line_number": 149 - }, - { - "type": "Secret Keyword", - "filename": "docs/gateway/configuration-reference.md", - "hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e", - "is_verified": false, - "line_number": 1267 - }, - { - "type": "Secret Keyword", - "filename": "docs/gateway/configuration-reference.md", - "hashed_secret": "bde4db9b4c3be4049adc3b9a69851d7c35119770", - "is_verified": false, - "line_number": 1283 - }, - { - "type": "Secret Keyword", - "filename": "docs/gateway/configuration-reference.md", - "hashed_secret": "7f8aaf142ce0552c260f2e546dda43ddd7c9aef3", - "is_verified": false, - "line_number": 1461 - }, - { - "type": "Secret Keyword", - "filename": "docs/gateway/configuration-reference.md", - "hashed_secret": "22af290a1a3d5e941193a41a3d3a9e4ca8da5e27", - "is_verified": false, - "line_number": 1603 - }, - { - "type": "Secret Keyword", - "filename": "docs/gateway/configuration-reference.md", - "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", - "is_verified": false, - "line_number": 1631 - }, - { - "type": "Secret Keyword", - "filename": "docs/gateway/configuration-reference.md", - "hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd", - "is_verified": false, - "line_number": 1862 - }, - { - "type": "Secret Keyword", - "filename": "docs/gateway/configuration-reference.md", - "hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6", - "is_verified": false, - "line_number": 1966 - }, - { - "type": "Secret Keyword", - "filename": "docs/gateway/configuration-reference.md", - "hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281", - "is_verified": false, - "line_number": 2202 - }, - { - "type": "Secret Keyword", - "filename": "docs/gateway/configuration-reference.md", - "hashed_secret": "b6f56e5e92078ed7c078c46fbfeedcbe5719bc25", - "is_verified": false, - "line_number": 2204 - } - ], - "docs/gateway/configuration.md": [ - { - "type": "Secret Keyword", - "filename": "docs/gateway/configuration.md", - "hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281", - "is_verified": false, - "line_number": 434 - }, - { - "type": "Secret Keyword", - "filename": "docs/gateway/configuration.md", - "hashed_secret": "b6f56e5e92078ed7c078c46fbfeedcbe5719bc25", - "is_verified": false, - "line_number": 435 - } - ], - "docs/gateway/local-models.md": [ - { - "type": "Secret Keyword", - "filename": "docs/gateway/local-models.md", - "hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209", - "is_verified": false, - "line_number": 34 - }, - { - "type": "Secret Keyword", - "filename": "docs/gateway/local-models.md", - "hashed_secret": "49fd535e63175a827aab3eff9ac58a9e82460ac9", - "is_verified": false, - "line_number": 124 - } - ], - "docs/gateway/tailscale.md": [ - { - "type": "Secret Keyword", - "filename": "docs/gateway/tailscale.md", - "hashed_secret": "9cb0dc5383312aa15b9dc6745645bde18ff5ade9", - "is_verified": false, - "line_number": 81 - } - ], - "docs/help/environment.md": [ - { - "type": "Secret Keyword", - "filename": "docs/help/environment.md", - "hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281", - "is_verified": false, - "line_number": 31 - }, - { - "type": "Secret Keyword", - "filename": "docs/help/environment.md", - "hashed_secret": "b6f56e5e92078ed7c078c46fbfeedcbe5719bc25", - "is_verified": false, - "line_number": 33 - } - ], - "docs/help/faq.md": [ - { - "type": "Secret Keyword", - "filename": "docs/help/faq.md", - "hashed_secret": "491d458f895b9213facb2ee9375b1b044eaea3ac", - "is_verified": false, - "line_number": 1412 - }, - { - "type": "Secret Keyword", - "filename": "docs/help/faq.md", - "hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281", - "is_verified": false, - "line_number": 1689 - }, - { - "type": "Secret Keyword", - "filename": "docs/help/faq.md", - "hashed_secret": "b6f56e5e92078ed7c078c46fbfeedcbe5719bc25", - "is_verified": false, - "line_number": 1690 - }, - { - "type": "Secret Keyword", - "filename": "docs/help/faq.md", - "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", - "is_verified": false, - "line_number": 2118 - }, - { - "type": "Secret Keyword", - "filename": "docs/help/faq.md", - "hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6", - "is_verified": false, - "line_number": 2398 - } - ], - "docs/install/macos-vm.md": [ - { - "type": "Secret Keyword", - "filename": "docs/install/macos-vm.md", - "hashed_secret": "8dd3bcd07c9ee927e6921c98b4dc6e94e2cc10a9", - "is_verified": false, - "line_number": 217 - } - ], - "docs/nodes/talk.md": [ - { - "type": "Secret Keyword", - "filename": "docs/nodes/talk.md", - "hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e", - "is_verified": false, - "line_number": 58 - } - ], - "docs/perplexity.md": [ - { - "type": "Secret Keyword", - "filename": "docs/perplexity.md", - "hashed_secret": "6b26c117c66a0c030e239eef595c1e18865132a8", - "is_verified": false, - "line_number": 36 - } - ], - "docs/plugins/voice-call.md": [ - { - "type": "Secret Keyword", - "filename": "docs/plugins/voice-call.md", - "hashed_secret": "cb46980ce5532f18440dff4bbbe097896a8c08c8", - "is_verified": false, - "line_number": 239 - } - ], - "docs/providers/anthropic.md": [ - { - "type": "Secret Keyword", - "filename": "docs/providers/anthropic.md", - "hashed_secret": "c7a8c334eef5d1749fface7d42c66f9ae5e8cf36", - "is_verified": false, - "line_number": 33 - } - ], - "docs/providers/claude-max-api-proxy.md": [ - { - "type": "Secret Keyword", - "filename": "docs/providers/claude-max-api-proxy.md", - "hashed_secret": "b5c2827eb65bf13b87130e7e3c424ba9ff07cd67", - "is_verified": false, - "line_number": 80 - } - ], - "docs/providers/glm.md": [ - { - "type": "Secret Keyword", - "filename": "docs/providers/glm.md", - "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", - "is_verified": false, - "line_number": 24 - } - ], - "docs/providers/litellm.md": [ - { - "type": "Secret Keyword", - "filename": "docs/providers/litellm.md", - "hashed_secret": "b907cadbe5a060ca6c6b78fee4c1953f34c64c32", - "is_verified": false, - "line_number": 40 - }, - { - "type": "Secret Keyword", - "filename": "docs/providers/litellm.md", - "hashed_secret": "651702a4fa521c0c493a3171cfba79c3c49eeaec", - "is_verified": false, - "line_number": 52 - } - ], - "docs/providers/minimax.md": [ - { - "type": "Secret Keyword", - "filename": "docs/providers/minimax.md", - "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", - "is_verified": false, - "line_number": 71 - }, - { - "type": "Secret Keyword", - "filename": "docs/providers/minimax.md", - "hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209", - "is_verified": false, - "line_number": 140 - } - ], - "docs/providers/moonshot.md": [ - { - "type": "Secret Keyword", - "filename": "docs/providers/moonshot.md", - "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", - "is_verified": false, - "line_number": 43 - } - ], - "docs/providers/nvidia.md": [ - { - "type": "Secret Keyword", - "filename": "docs/providers/nvidia.md", - "hashed_secret": "2083c49ad8d63838a4d18f1de0c419f06eb464db", - "is_verified": false, - "line_number": 18 - } - ], - "docs/providers/ollama.md": [ - { - "type": "Secret Keyword", - "filename": "docs/providers/ollama.md", - "hashed_secret": "e774aaeac31c6272107ba89080295e277050fa7c", - "is_verified": false, - "line_number": 33 - } - ], - "docs/providers/openai.md": [ - { - "type": "Secret Keyword", - "filename": "docs/providers/openai.md", - "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", - "is_verified": false, - "line_number": 31 - } - ], - "docs/providers/opencode.md": [ - { - "type": "Secret Keyword", - "filename": "docs/providers/opencode.md", - "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", - "is_verified": false, - "line_number": 27 - } - ], - "docs/providers/openrouter.md": [ - { - "type": "Secret Keyword", - "filename": "docs/providers/openrouter.md", - "hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281", - "is_verified": false, - "line_number": 24 - } - ], - "docs/providers/synthetic.md": [ - { - "type": "Secret Keyword", - "filename": "docs/providers/synthetic.md", - "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", - "is_verified": false, - "line_number": 33 - } - ], - "docs/providers/venice.md": [ - { - "type": "Secret Keyword", - "filename": "docs/providers/venice.md", - "hashed_secret": "0b1b9301d9cd541620de4e3865d4a8f54f42fa89", - "is_verified": false, - "line_number": 55 - }, - { - "type": "Secret Keyword", - "filename": "docs/providers/venice.md", - "hashed_secret": "c179fe46776696372a90218532dc0d67267f2f04", - "is_verified": false, - "line_number": 236 - } - ], - "docs/providers/vllm.md": [ - { - "type": "Secret Keyword", - "filename": "docs/providers/vllm.md", - "hashed_secret": "6a4a6c8f2406f4f0843a0a1aae6a320f92f9d6ae", - "is_verified": false, - "line_number": 26 - } - ], - "docs/providers/xiaomi.md": [ - { - "type": "Secret Keyword", - "filename": "docs/providers/xiaomi.md", - "hashed_secret": "6d9c68c603e465077bdd49c62347fe54717f83a3", - "is_verified": false, - "line_number": 34 - }, - { - "type": "Secret Keyword", - "filename": "docs/providers/xiaomi.md", - "hashed_secret": "2369ac9988d706e53899168280d126c81c33bcd2", - "is_verified": false, - "line_number": 42 - } - ], - "docs/providers/zai.md": [ - { - "type": "Secret Keyword", - "filename": "docs/providers/zai.md", - "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", - "is_verified": false, - "line_number": 27 - } - ], - "docs/tools/browser.md": [ - { - "type": "Basic Auth Credentials", - "filename": "docs/tools/browser.md", - "hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684", - "is_verified": false, - "line_number": 140 - } - ], - "docs/tools/firecrawl.md": [ - { - "type": "Secret Keyword", - "filename": "docs/tools/firecrawl.md", - "hashed_secret": "674397e2c0c2faaa85961c708d2a96a7cc7af217", - "is_verified": false, - "line_number": 29 - } - ], - "docs/tools/skills-config.md": [ - { - "type": "Secret Keyword", - "filename": "docs/tools/skills-config.md", - "hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd", - "is_verified": false, - "line_number": 29 - } - ], - "docs/tools/skills.md": [ - { - "type": "Secret Keyword", - "filename": "docs/tools/skills.md", - "hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd", - "is_verified": false, - "line_number": 198 - } - ], - "docs/tools/web.md": [ - { - "type": "Secret Keyword", - "filename": "docs/tools/web.md", - "hashed_secret": "6b26c117c66a0c030e239eef595c1e18865132a8", - "is_verified": false, - "line_number": 62 - }, - { - "type": "Secret Keyword", - "filename": "docs/tools/web.md", - "hashed_secret": "96c682c88ed551f22fe76d206c2dfb7df9221ad9", - "is_verified": false, - "line_number": 113 - }, - { - "type": "Secret Keyword", - "filename": "docs/tools/web.md", - "hashed_secret": "491d458f895b9213facb2ee9375b1b044eaea3ac", - "is_verified": false, - "line_number": 161 - }, - { - "type": "Secret Keyword", - "filename": "docs/tools/web.md", - "hashed_secret": "674397e2c0c2faaa85961c708d2a96a7cc7af217", - "is_verified": false, - "line_number": 235 - } - ], - "docs/tts.md": [ - { - "type": "Secret Keyword", - "filename": "docs/tts.md", - "hashed_secret": "bde4db9b4c3be4049adc3b9a69851d7c35119770", - "is_verified": false, - "line_number": 95 - }, - { - "type": "Secret Keyword", - "filename": "docs/tts.md", - "hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e", - "is_verified": false, - "line_number": 100 - } - ], - "docs/zh-CN/brave-search.md": [ - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/brave-search.md", - "hashed_secret": "491d458f895b9213facb2ee9375b1b044eaea3ac", - "is_verified": false, - "line_number": 34 - } - ], - "docs/zh-CN/channels/bluebubbles.md": [ - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/channels/bluebubbles.md", - "hashed_secret": "555da20df20d4172e00f1b73d7c3943802055270", - "is_verified": false, - "line_number": 43 - } - ], - "docs/zh-CN/channels/feishu.md": [ - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/channels/feishu.md", - "hashed_secret": "b60d121b438a380c343d5ec3c2037564b82ffef3", - "is_verified": false, - "line_number": 195 - }, - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/channels/feishu.md", - "hashed_secret": "186154712b2d5f6791d85b9a0987b98fa231779c", - "is_verified": false, - "line_number": 445 - } - ], - "docs/zh-CN/channels/line.md": [ - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/channels/line.md", - "hashed_secret": "83661b43df128631f891767fbfc5b049af3dce86", - "is_verified": false, - "line_number": 62 - } - ], - "docs/zh-CN/channels/matrix.md": [ - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/channels/matrix.md", - "hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6", - "is_verified": false, - "line_number": 62 - } - ], - "docs/zh-CN/channels/nextcloud-talk.md": [ - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/channels/nextcloud-talk.md", - "hashed_secret": "76ed0a056aa77060de25754586440cff390791d0", - "is_verified": false, - "line_number": 61 - } - ], - "docs/zh-CN/channels/nostr.md": [ - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/channels/nostr.md", - "hashed_secret": "edeb23e25a619c434d22bb7f1c3ca4841166b4e8", - "is_verified": false, - "line_number": 74 - } - ], - "docs/zh-CN/channels/slack.md": [ - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/channels/slack.md", - "hashed_secret": "3f4800fb7c1fb79a9a48bfd562d90bc6b2e2b718", - "is_verified": false, - "line_number": 153 - } - ], - "docs/zh-CN/channels/twitch.md": [ - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/channels/twitch.md", - "hashed_secret": "0d1ba0da3e84e54f29846c93c43182eede365858", - "is_verified": false, - "line_number": 145 - }, - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/channels/twitch.md", - "hashed_secret": "7cb4c5b8b81e266d08d4f106799af98d748bceb9", - "is_verified": false, - "line_number": 330 - } - ], - "docs/zh-CN/concepts/memory.md": [ - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/concepts/memory.md", - "hashed_secret": "39d711243bfcee9fec8299b204e1aa9c3430fa12", - "is_verified": false, - "line_number": 127 - }, - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/concepts/memory.md", - "hashed_secret": "1a8abbf465c52363ab4c9c6ad945b8e857cbea55", - "is_verified": false, - "line_number": 150 - }, - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/concepts/memory.md", - "hashed_secret": "b9f640d6095b9f6b5a65983f7b76dbbb254e0044", - "is_verified": false, - "line_number": 398 - } - ], - "docs/zh-CN/concepts/model-providers.md": [ - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/concepts/model-providers.md", - "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", - "is_verified": false, - "line_number": 181 - }, - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/concepts/model-providers.md", - "hashed_secret": "ef83ad68b9b66e008727b7c417c6a8f618b5177e", - "is_verified": false, - "line_number": 282 - } - ], - "docs/zh-CN/gateway/configuration-examples.md": [ - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/gateway/configuration-examples.md", - "hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281", - "is_verified": false, - "line_number": 64 - }, - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/gateway/configuration-examples.md", - "hashed_secret": "b6f56e5e92078ed7c078c46fbfeedcbe5719bc25", - "is_verified": false, - "line_number": 66 - }, - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/gateway/configuration-examples.md", - "hashed_secret": "22af290a1a3d5e941193a41a3d3a9e4ca8da5e27", - "is_verified": false, - "line_number": 329 - }, - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/gateway/configuration-examples.md", - "hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd", - "is_verified": false, - "line_number": 424 - }, - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/gateway/configuration-examples.md", - "hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209", - "is_verified": false, - "line_number": 563 - } - ], - "docs/zh-CN/gateway/configuration.md": [ - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/gateway/configuration.md", - "hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281", - "is_verified": false, - "line_number": 289 - }, - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/gateway/configuration.md", - "hashed_secret": "b6f56e5e92078ed7c078c46fbfeedcbe5719bc25", - "is_verified": false, - "line_number": 291 - }, - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/gateway/configuration.md", - "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", - "is_verified": false, - "line_number": 1092 - }, - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/gateway/configuration.md", - "hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e", - "is_verified": false, - "line_number": 1570 - }, - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/gateway/configuration.md", - "hashed_secret": "bde4db9b4c3be4049adc3b9a69851d7c35119770", - "is_verified": false, - "line_number": 1586 - }, - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/gateway/configuration.md", - "hashed_secret": "22af290a1a3d5e941193a41a3d3a9e4ca8da5e27", - "is_verified": false, - "line_number": 2398 - }, - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/gateway/configuration.md", - "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", - "is_verified": false, - "line_number": 2476 - }, - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/gateway/configuration.md", - "hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd", - "is_verified": false, - "line_number": 2768 - }, - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/gateway/configuration.md", - "hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6", - "is_verified": false, - "line_number": 2967 - } - ], - "docs/zh-CN/gateway/local-models.md": [ - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/gateway/local-models.md", - "hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209", - "is_verified": false, - "line_number": 41 - }, - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/gateway/local-models.md", - "hashed_secret": "49fd535e63175a827aab3eff9ac58a9e82460ac9", - "is_verified": false, - "line_number": 131 - } - ], - "docs/zh-CN/gateway/tailscale.md": [ - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/gateway/tailscale.md", - "hashed_secret": "9cb0dc5383312aa15b9dc6745645bde18ff5ade9", - "is_verified": false, - "line_number": 80 - } - ], - "docs/zh-CN/help/environment.md": [ - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/help/environment.md", - "hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281", - "is_verified": false, - "line_number": 38 - }, - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/help/environment.md", - "hashed_secret": "b6f56e5e92078ed7c078c46fbfeedcbe5719bc25", - "is_verified": false, - "line_number": 40 - } - ], - "docs/zh-CN/help/faq.md": [ - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/help/faq.md", - "hashed_secret": "491d458f895b9213facb2ee9375b1b044eaea3ac", - "is_verified": false, - "line_number": 1277 - }, - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/help/faq.md", - "hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281", - "is_verified": false, - "line_number": 1524 - }, - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/help/faq.md", - "hashed_secret": "b6f56e5e92078ed7c078c46fbfeedcbe5719bc25", - "is_verified": false, - "line_number": 1525 - }, - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/help/faq.md", - "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", - "is_verified": false, - "line_number": 1916 - }, - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/help/faq.md", - "hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6", - "is_verified": false, - "line_number": 2191 - } - ], - "docs/zh-CN/install/macos-vm.md": [ - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/install/macos-vm.md", - "hashed_secret": "8dd3bcd07c9ee927e6921c98b4dc6e94e2cc10a9", - "is_verified": false, - "line_number": 224 - } - ], - "docs/zh-CN/nodes/talk.md": [ - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/nodes/talk.md", - "hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e", - "is_verified": false, - "line_number": 65 - } - ], - "docs/zh-CN/perplexity.md": [ - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/perplexity.md", - "hashed_secret": "6b26c117c66a0c030e239eef595c1e18865132a8", - "is_verified": false, - "line_number": 42 - } - ], - "docs/zh-CN/plugins/voice-call.md": [ - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/plugins/voice-call.md", - "hashed_secret": "cb46980ce5532f18440dff4bbbe097896a8c08c8", - "is_verified": false, - "line_number": 167 - } - ], - "docs/zh-CN/providers/anthropic.md": [ - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/providers/anthropic.md", - "hashed_secret": "c7a8c334eef5d1749fface7d42c66f9ae5e8cf36", - "is_verified": false, - "line_number": 40 - } - ], - "docs/zh-CN/providers/claude-max-api-proxy.md": [ - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/providers/claude-max-api-proxy.md", - "hashed_secret": "b5c2827eb65bf13b87130e7e3c424ba9ff07cd67", - "is_verified": false, - "line_number": 87 - } - ], - "docs/zh-CN/providers/glm.md": [ - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/providers/glm.md", - "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", - "is_verified": false, - "line_number": 30 - } - ], - "docs/zh-CN/providers/minimax.md": [ - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/providers/minimax.md", - "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", - "is_verified": false, - "line_number": 72 - }, - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/providers/minimax.md", - "hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209", - "is_verified": false, - "line_number": 140 - } - ], - "docs/zh-CN/providers/moonshot.md": [ - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/providers/moonshot.md", - "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", - "is_verified": false, - "line_number": 47 - } - ], - "docs/zh-CN/providers/ollama.md": [ - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/providers/ollama.md", - "hashed_secret": "e774aaeac31c6272107ba89080295e277050fa7c", - "is_verified": false, - "line_number": 38 - } - ], - "docs/zh-CN/providers/openai.md": [ - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/providers/openai.md", - "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", - "is_verified": false, - "line_number": 37 - } - ], - "docs/zh-CN/providers/opencode.md": [ - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/providers/opencode.md", - "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", - "is_verified": false, - "line_number": 32 - } - ], - "docs/zh-CN/providers/openrouter.md": [ - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/providers/openrouter.md", - "hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281", - "is_verified": false, - "line_number": 30 - } - ], - "docs/zh-CN/providers/synthetic.md": [ - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/providers/synthetic.md", - "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", - "is_verified": false, - "line_number": 39 - } - ], - "docs/zh-CN/providers/venice.md": [ - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/providers/venice.md", - "hashed_secret": "0b1b9301d9cd541620de4e3865d4a8f54f42fa89", - "is_verified": false, - "line_number": 62 - }, - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/providers/venice.md", - "hashed_secret": "c179fe46776696372a90218532dc0d67267f2f04", - "is_verified": false, - "line_number": 243 - } - ], - "docs/zh-CN/providers/xiaomi.md": [ - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/providers/xiaomi.md", - "hashed_secret": "6d9c68c603e465077bdd49c62347fe54717f83a3", - "is_verified": false, - "line_number": 38 - }, - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/providers/xiaomi.md", - "hashed_secret": "2369ac9988d706e53899168280d126c81c33bcd2", - "is_verified": false, - "line_number": 46 - } - ], - "docs/zh-CN/providers/zai.md": [ - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/providers/zai.md", - "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", - "is_verified": false, - "line_number": 32 - } - ], - "docs/zh-CN/tools/browser.md": [ - { - "type": "Basic Auth Credentials", - "filename": "docs/zh-CN/tools/browser.md", - "hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684", - "is_verified": false, - "line_number": 137 - } - ], - "docs/zh-CN/tools/firecrawl.md": [ - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/tools/firecrawl.md", - "hashed_secret": "674397e2c0c2faaa85961c708d2a96a7cc7af217", - "is_verified": false, - "line_number": 36 - } - ], - "docs/zh-CN/tools/skills-config.md": [ - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/tools/skills-config.md", - "hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd", - "is_verified": false, - "line_number": 36 - } - ], - "docs/zh-CN/tools/skills.md": [ - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/tools/skills.md", - "hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd", - "is_verified": false, - "line_number": 183 - } - ], - "docs/zh-CN/tools/web.md": [ - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/tools/web.md", - "hashed_secret": "6b26c117c66a0c030e239eef595c1e18865132a8", - "is_verified": false, - "line_number": 67 - }, - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/tools/web.md", - "hashed_secret": "96c682c88ed551f22fe76d206c2dfb7df9221ad9", - "is_verified": false, - "line_number": 112 - }, - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/tools/web.md", - "hashed_secret": "491d458f895b9213facb2ee9375b1b044eaea3ac", - "is_verified": false, - "line_number": 159 - }, - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/tools/web.md", - "hashed_secret": "674397e2c0c2faaa85961c708d2a96a7cc7af217", - "is_verified": false, - "line_number": 229 - } - ], - "docs/zh-CN/tts.md": [ - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/tts.md", - "hashed_secret": "bde4db9b4c3be4049adc3b9a69851d7c35119770", - "is_verified": false, - "line_number": 89 - }, - { - "type": "Secret Keyword", - "filename": "docs/zh-CN/tts.md", - "hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e", - "is_verified": false, - "line_number": 94 - } - ], - "extensions/bluebubbles/src/actions.test.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/bluebubbles/src/actions.test.ts", - "hashed_secret": "789cbe0407840b1c2041cb33452ff60f19bf58cc", - "is_verified": false, - "line_number": 86 - } - ], - "extensions/bluebubbles/src/attachments.test.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/bluebubbles/src/attachments.test.ts", - "hashed_secret": "789cbe0407840b1c2041cb33452ff60f19bf58cc", - "is_verified": false, - "line_number": 21 - }, - { - "type": "Secret Keyword", - "filename": "extensions/bluebubbles/src/attachments.test.ts", - "hashed_secret": "db1530e1ea43af094d3d75b8dbaf19a4a182a318", - "is_verified": false, - "line_number": 85 - }, - { - "type": "Secret Keyword", - "filename": "extensions/bluebubbles/src/attachments.test.ts", - "hashed_secret": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", - "is_verified": false, - "line_number": 103 - }, - { - "type": "Secret Keyword", - "filename": "extensions/bluebubbles/src/attachments.test.ts", - "hashed_secret": "052f076c732648ab32d2fcde9fe255319bfa0c7b", - "is_verified": false, - "line_number": 215 - } - ], - "extensions/bluebubbles/src/chat.test.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/bluebubbles/src/chat.test.ts", - "hashed_secret": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", - "is_verified": false, - "line_number": 19 - }, - { - "type": "Secret Keyword", - "filename": "extensions/bluebubbles/src/chat.test.ts", - "hashed_secret": "789cbe0407840b1c2041cb33452ff60f19bf58cc", - "is_verified": false, - "line_number": 54 - }, - { - "type": "Secret Keyword", - "filename": "extensions/bluebubbles/src/chat.test.ts", - "hashed_secret": "5c5a15a8b0b3e154d77746945e563ba40100681b", - "is_verified": false, - "line_number": 82 - }, - { - "type": "Secret Keyword", - "filename": "extensions/bluebubbles/src/chat.test.ts", - "hashed_secret": "faacad0ce4ea1c19b46e128fd79679d37d3d331d", - "is_verified": false, - "line_number": 131 - }, - { - "type": "Secret Keyword", - "filename": "extensions/bluebubbles/src/chat.test.ts", - "hashed_secret": "4dcc26a1d99532846fedf1265df4f40f4e0005b8", - "is_verified": false, - "line_number": 227 - }, - { - "type": "Secret Keyword", - "filename": "extensions/bluebubbles/src/chat.test.ts", - "hashed_secret": "fd2a721f7be1ee3d691a011affcdb11d0ca365a8", - "is_verified": false, - "line_number": 290 - } - ], - "extensions/bluebubbles/src/monitor.test.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/bluebubbles/src/monitor.test.ts", - "hashed_secret": "789cbe0407840b1c2041cb33452ff60f19bf58cc", - "is_verified": false, - "line_number": 278 - }, - { - "type": "Secret Keyword", - "filename": "extensions/bluebubbles/src/monitor.test.ts", - "hashed_secret": "1ae0af3fe72b3ba394f9fa95a6cffc090d726c23", - "is_verified": false, - "line_number": 552 - } - ], - "extensions/bluebubbles/src/reactions.test.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/bluebubbles/src/reactions.test.ts", - "hashed_secret": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", - "is_verified": false, - "line_number": 37 - }, - { - "type": "Secret Keyword", - "filename": "extensions/bluebubbles/src/reactions.test.ts", - "hashed_secret": "789cbe0407840b1c2041cb33452ff60f19bf58cc", - "is_verified": false, - "line_number": 178 - }, - { - "type": "Secret Keyword", - "filename": "extensions/bluebubbles/src/reactions.test.ts", - "hashed_secret": "a4a05c9a6449eb9d6cdac81dd7edc49230e327e6", - "is_verified": false, - "line_number": 209 - }, - { - "type": "Secret Keyword", - "filename": "extensions/bluebubbles/src/reactions.test.ts", - "hashed_secret": "a2833da9f0a16f09994754d0a31749cecf8c8c77", - "is_verified": false, - "line_number": 315 - } - ], - "extensions/bluebubbles/src/send.test.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/bluebubbles/src/send.test.ts", - "hashed_secret": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", - "is_verified": false, - "line_number": 55 - }, - { - "type": "Secret Keyword", - "filename": "extensions/bluebubbles/src/send.test.ts", - "hashed_secret": "faacad0ce4ea1c19b46e128fd79679d37d3d331d", - "is_verified": false, - "line_number": 692 - } - ], - "extensions/bluebubbles/src/targets.test.ts": [ - { - "type": "Hex High Entropy String", - "filename": "extensions/bluebubbles/src/targets.test.ts", - "hashed_secret": "a3af2fb0c1e2a30bb038049e1e4b401593af6225", - "is_verified": false, - "line_number": 61 - } - ], - "extensions/bluebubbles/src/targets.ts": [ - { - "type": "Hex High Entropy String", - "filename": "extensions/bluebubbles/src/targets.ts", - "hashed_secret": "a3af2fb0c1e2a30bb038049e1e4b401593af6225", - "is_verified": false, - "line_number": 265 - } - ], - "extensions/copilot-proxy/index.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/copilot-proxy/index.ts", - "hashed_secret": "50f013532a9770a2c2cfdc38b7581dd01df69b70", - "is_verified": false, - "line_number": 9 - } - ], - "extensions/feishu/skills/feishu-doc/SKILL.md": [ - { - "type": "Hex High Entropy String", - "filename": "extensions/feishu/skills/feishu-doc/SKILL.md", - "hashed_secret": "8a2256bca273bb01a4e09ae6555b1e6652d9ff8c", - "is_verified": false, - "line_number": 20 - } - ], - "extensions/feishu/skills/feishu-wiki/SKILL.md": [ - { - "type": "Hex High Entropy String", - "filename": "extensions/feishu/skills/feishu-wiki/SKILL.md", - "hashed_secret": "8a2256bca273bb01a4e09ae6555b1e6652d9ff8c", - "is_verified": false, - "line_number": 40 - } - ], - "extensions/feishu/src/channel.test.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/feishu/src/channel.test.ts", - "hashed_secret": "8437d84cae482d10a2b9fd3f555d45006979e4be", - "is_verified": false, - "line_number": 21 - } - ], - "extensions/feishu/src/docx.test.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/feishu/src/docx.test.ts", - "hashed_secret": "f49922d511d666848f250663c4fca84074b856a8", - "is_verified": false, - "line_number": 97 - } - ], - "extensions/feishu/src/media.test.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/feishu/src/media.test.ts", - "hashed_secret": "f49922d511d666848f250663c4fca84074b856a8", - "is_verified": false, - "line_number": 45 - } - ], - "extensions/feishu/src/reply-dispatcher.test.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/feishu/src/reply-dispatcher.test.ts", - "hashed_secret": "f49922d511d666848f250663c4fca84074b856a8", - "is_verified": false, - "line_number": 48 - } - ], - "extensions/google-antigravity-auth/index.ts": [ - { - "type": "Base64 High Entropy String", - "filename": "extensions/google-antigravity-auth/index.ts", - "hashed_secret": "709d0f232b6ac4f8d24dec3e4fabfdb14257174f", - "is_verified": false, - "line_number": 14 - } - ], - "extensions/google-gemini-cli-auth/oauth.test.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/google-gemini-cli-auth/oauth.test.ts", - "hashed_secret": "021343c1f561d7bcbc3b513df45cc3a6baf67b43", - "is_verified": false, - "line_number": 30 - } - ], - "extensions/irc/src/accounts.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/irc/src/accounts.ts", - "hashed_secret": "920f8f5815b381ea692e9e7c2f7119f2b1aa620a", - "is_verified": false, - "line_number": 19 - } - ], - "extensions/irc/src/client.test.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/irc/src/client.test.ts", - "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", - "is_verified": false, - "line_number": 8 - }, - { - "type": "Secret Keyword", - "filename": "extensions/irc/src/client.test.ts", - "hashed_secret": "b1cc3814a07fc3d7094f4cc181df7b57b51d165b", - "is_verified": false, - "line_number": 39 - } - ], - "extensions/line/src/channel.startup.test.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/line/src/channel.startup.test.ts", - "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", - "is_verified": false, - "line_number": 103 - } - ], - "extensions/matrix/src/matrix/accounts.test.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/matrix/src/matrix/accounts.test.ts", - "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", - "is_verified": false, - "line_number": 74 - } - ], - "extensions/matrix/src/matrix/client.test.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/matrix/src/matrix/client.test.ts", - "hashed_secret": "fe7fcdaea49ece14677acd32374d2f1225819d5c", - "is_verified": false, - "line_number": 13 - }, - { - "type": "Secret Keyword", - "filename": "extensions/matrix/src/matrix/client.test.ts", - "hashed_secret": "3dc927d80543dc0f643940b70d066bd4b4c4b78e", - "is_verified": false, - "line_number": 23 - } - ], - "extensions/matrix/src/matrix/client/storage.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/matrix/src/matrix/client/storage.ts", - "hashed_secret": "7505d64a54e061b7acd54ccd58b49dc43500b635", - "is_verified": false, - "line_number": 8 - } - ], - "extensions/memory-lancedb/config.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/memory-lancedb/config.ts", - "hashed_secret": "ecb252044b5ea0f679ee78ec1a12904739e2904d", - "is_verified": false, - "line_number": 101 - } - ], - "extensions/memory-lancedb/index.test.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/memory-lancedb/index.test.ts", - "hashed_secret": "ed65c049bb2f78ee4f703b2158ba9cc6ea31fb7e", - "is_verified": false, - "line_number": 71 - } - ], - "extensions/msteams/src/probe.test.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/msteams/src/probe.test.ts", - "hashed_secret": "1a91d62f7ca67399625a4368a6ab5d4a3baa6073", - "is_verified": false, - "line_number": 35 - } - ], - "extensions/nextcloud-talk/src/accounts.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/nextcloud-talk/src/accounts.ts", - "hashed_secret": "920f8f5815b381ea692e9e7c2f7119f2b1aa620a", - "is_verified": false, - "line_number": 22 - }, - { - "type": "Secret Keyword", - "filename": "extensions/nextcloud-talk/src/accounts.ts", - "hashed_secret": "71f8e7976e4cbc4561c9d62fb283e7f788202acb", - "is_verified": false, - "line_number": 151 - } - ], - "extensions/nextcloud-talk/src/channel.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/nextcloud-talk/src/channel.ts", - "hashed_secret": "71f8e7976e4cbc4561c9d62fb283e7f788202acb", - "is_verified": false, - "line_number": 396 - } - ], - "extensions/nostr/README.md": [ - { - "type": "Secret Keyword", - "filename": "extensions/nostr/README.md", - "hashed_secret": "edeb23e25a619c434d22bb7f1c3ca4841166b4e8", - "is_verified": false, - "line_number": 46 - } - ], - "extensions/nostr/src/channel.test.ts": [ - { - "type": "Hex High Entropy String", - "filename": "extensions/nostr/src/channel.test.ts", - "hashed_secret": "ce4303f6b22257d9c9cf314ef1dee4707c6e1c13", - "is_verified": false, - "line_number": 48 - }, - { - "type": "Secret Keyword", - "filename": "extensions/nostr/src/channel.test.ts", - "hashed_secret": "ce4303f6b22257d9c9cf314ef1dee4707c6e1c13", - "is_verified": false, - "line_number": 48 - } - ], - "extensions/nostr/src/nostr-bus.fuzz.test.ts": [ - { - "type": "Hex High Entropy String", - "filename": "extensions/nostr/src/nostr-bus.fuzz.test.ts", - "hashed_secret": "2b4489606a23fb31fcdc849fa7e577ba90f6d39a", - "is_verified": false, - "line_number": 193 - }, - { - "type": "Hex High Entropy String", - "filename": "extensions/nostr/src/nostr-bus.fuzz.test.ts", - "hashed_secret": "ce4303f6b22257d9c9cf314ef1dee4707c6e1c13", - "is_verified": false, - "line_number": 194 - }, - { - "type": "Hex High Entropy String", - "filename": "extensions/nostr/src/nostr-bus.fuzz.test.ts", - "hashed_secret": "b84cb0c3925d34496e6c8b0e55b8c1664a438035", - "is_verified": false, - "line_number": 199 - } - ], - "extensions/nostr/src/nostr-bus.test.ts": [ - { - "type": "Hex High Entropy String", - "filename": "extensions/nostr/src/nostr-bus.test.ts", - "hashed_secret": "ce4303f6b22257d9c9cf314ef1dee4707c6e1c13", - "is_verified": false, - "line_number": 11 - }, - { - "type": "Hex High Entropy String", - "filename": "extensions/nostr/src/nostr-bus.test.ts", - "hashed_secret": "7258e28563f03fb4c5994e8402e6f610d1f0f110", - "is_verified": false, - "line_number": 33 - }, - { - "type": "Hex High Entropy String", - "filename": "extensions/nostr/src/nostr-bus.test.ts", - "hashed_secret": "2b4489606a23fb31fcdc849fa7e577ba90f6d39a", - "is_verified": false, - "line_number": 101 - }, - { - "type": "Hex High Entropy String", - "filename": "extensions/nostr/src/nostr-bus.test.ts", - "hashed_secret": "ef717286343f6da3f4e6f68c6de02a5148a801c4", - "is_verified": false, - "line_number": 106 - }, - { - "type": "Hex High Entropy String", - "filename": "extensions/nostr/src/nostr-bus.test.ts", - "hashed_secret": "98b35fe4c45011220f509ebb5546d3889b55a891", - "is_verified": false, - "line_number": 111 - } - ], - "extensions/nostr/src/nostr-profile.fuzz.test.ts": [ - { - "type": "Hex High Entropy String", - "filename": "extensions/nostr/src/nostr-profile.fuzz.test.ts", - "hashed_secret": "ce4303f6b22257d9c9cf314ef1dee4707c6e1c13", - "is_verified": false, - "line_number": 11 - } - ], - "extensions/nostr/src/nostr-profile.test.ts": [ - { - "type": "Hex High Entropy String", - "filename": "extensions/nostr/src/nostr-profile.test.ts", - "hashed_secret": "ce4303f6b22257d9c9cf314ef1dee4707c6e1c13", - "is_verified": false, - "line_number": 14 - } - ], - "extensions/nostr/src/types.test.ts": [ - { - "type": "Hex High Entropy String", - "filename": "extensions/nostr/src/types.test.ts", - "hashed_secret": "ce4303f6b22257d9c9cf314ef1dee4707c6e1c13", - "is_verified": false, - "line_number": 4 - }, - { - "type": "Secret Keyword", - "filename": "extensions/nostr/src/types.test.ts", - "hashed_secret": "ce4303f6b22257d9c9cf314ef1dee4707c6e1c13", - "is_verified": false, - "line_number": 4 - }, - { - "type": "Secret Keyword", - "filename": "extensions/nostr/src/types.test.ts", - "hashed_secret": "3bee216ebc256d692260fc3adc765050508fef5e", - "is_verified": false, - "line_number": 123 - } - ], - "extensions/open-prose/skills/prose/SKILL.md": [ - { - "type": "Basic Auth Credentials", - "filename": "extensions/open-prose/skills/prose/SKILL.md", - "hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684", - "is_verified": false, - "line_number": 204 - } - ], - "extensions/open-prose/skills/prose/state/postgres.md": [ - { - "type": "Secret Keyword", - "filename": "extensions/open-prose/skills/prose/state/postgres.md", - "hashed_secret": "fa9beb99e4029ad5a6615399e7bbae21356086b3", - "is_verified": false, - "line_number": 77 - }, - { - "type": "Basic Auth Credentials", - "filename": "extensions/open-prose/skills/prose/state/postgres.md", - "hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684", - "is_verified": false, - "line_number": 200 - } - ], - "extensions/twitch/src/onboarding.test.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/twitch/src/onboarding.test.ts", - "hashed_secret": "f2b14f68eb995facb3a1c35287b778d5bd785511", - "is_verified": false, - "line_number": 239 - }, - { - "type": "Secret Keyword", - "filename": "extensions/twitch/src/onboarding.test.ts", - "hashed_secret": "c8d8f8140951794fa875ea2c2d010c4382f36566", - "is_verified": false, - "line_number": 249 - } - ], - "extensions/twitch/src/status.test.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/twitch/src/status.test.ts", - "hashed_secret": "f2b14f68eb995facb3a1c35287b778d5bd785511", - "is_verified": false, - "line_number": 122 - } - ], - "extensions/voice-call/README.md": [ - { - "type": "Secret Keyword", - "filename": "extensions/voice-call/README.md", - "hashed_secret": "48004f85d79e636cfd408c3baddcb1f0bbdd611a", - "is_verified": false, - "line_number": 49 - } - ], - "extensions/voice-call/src/config.test.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/voice-call/src/config.test.ts", - "hashed_secret": "62207a469ec2fdcfc7d66b04c2980ac1501acbf0", - "is_verified": false, - "line_number": 129 - } - ], - "extensions/voice-call/src/providers/telnyx.test.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/voice-call/src/providers/telnyx.test.ts", - "hashed_secret": "62207a469ec2fdcfc7d66b04c2980ac1501acbf0", - "is_verified": false, - "line_number": 30 - } - ], - "extensions/zalo/README.md": [ - { - "type": "Secret Keyword", - "filename": "extensions/zalo/README.md", - "hashed_secret": "f51aaee16a4a756d287f126b99c081b73cba7f15", - "is_verified": false, - "line_number": 41 - } - ], - "extensions/zalo/src/monitor.webhook.test.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/zalo/src/monitor.webhook.test.ts", - "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", - "is_verified": false, - "line_number": 40 - } - ], - "skills/1password/references/cli-examples.md": [ - { - "type": "Secret Keyword", - "filename": "skills/1password/references/cli-examples.md", - "hashed_secret": "9dda0987cc3054773a2df97e352d4f64d233ef10", - "is_verified": false, - "line_number": 17 - } - ], - "skills/openai-whisper-api/SKILL.md": [ - { - "type": "Secret Keyword", - "filename": "skills/openai-whisper-api/SKILL.md", - "hashed_secret": "1077361f94d70e1ddcc7c6dc581a489532a81d03", - "is_verified": false, - "line_number": 48 - } - ], - "skills/trello/SKILL.md": [ - { - "type": "Secret Keyword", - "filename": "skills/trello/SKILL.md", - "hashed_secret": "11fa7c37d697f30e6aee828b4426a10f83ab2380", - "is_verified": false, - "line_number": 22 - } - ], - "src/agents/compaction.tool-result-details.e2e.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/compaction.tool-result-details.e2e.test.ts", - "hashed_secret": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", - "is_verified": false, - "line_number": 50 - } - ], - "src/agents/memory-search.e2e.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/memory-search.e2e.test.ts", - "hashed_secret": "a1b49d68a91fdf9c9217773f3fac988d77fa0f50", - "is_verified": false, - "line_number": 189 - } - ], - "src/agents/minimax-vlm.normalizes-api-key.e2e.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/minimax-vlm.normalizes-api-key.e2e.test.ts", - "hashed_secret": "8a8461b67e3fe515f248ac2610fd7b1f4fc3b412", - "is_verified": false, - "line_number": 28 - } - ], - "src/agents/model-auth.e2e.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/model-auth.e2e.test.ts", - "hashed_secret": "07a6b9cec637c806195e8aa7e5c0851ab03dc35e", - "is_verified": false, - "line_number": 228 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/model-auth.e2e.test.ts", - "hashed_secret": "21f296583ccd80c5ab9b3330a8b0d47e4a409fb9", - "is_verified": false, - "line_number": 254 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/model-auth.e2e.test.ts", - "hashed_secret": "b65888424ecafcc98bfd803b24817e4dadf821f8", - "is_verified": false, - "line_number": 275 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/model-auth.e2e.test.ts", - "hashed_secret": "77e991e9f56e6fa4ed1a908208048421f1214c07", - "is_verified": false, - "line_number": 296 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/model-auth.e2e.test.ts", - "hashed_secret": "dff6d4ff5dc357cf451d1855ab9cbda562645c9f", - "is_verified": false, - "line_number": 319 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/model-auth.e2e.test.ts", - "hashed_secret": "b43be360db55d89ec6afd74d6ed8f82002fe4982", - "is_verified": false, - "line_number": 374 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/model-auth.e2e.test.ts", - "hashed_secret": "5b850e9dc678446137ff6d905ebd78634d687fdd", - "is_verified": false, - "line_number": 395 - } - ], - "src/agents/model-auth.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/model-auth.ts", - "hashed_secret": "8956265d216d474a080edaa97880d37fc1386f33", - "is_verified": false, - "line_number": 25 - } - ], - "src/agents/models-config.e2e-harness.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/models-config.e2e-harness.ts", - "hashed_secret": "7cf31e8b6cda49f70c31f1f25af05d46f924142d", - "is_verified": false, - "line_number": 110 - } - ], - "src/agents/models-config.fills-missing-provider-apikey-from-env-var.e2e.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/models-config.fills-missing-provider-apikey-from-env-var.e2e.test.ts", - "hashed_secret": "fcdd655b11f33ba4327695084a347b2ba192976c", - "is_verified": false, - "line_number": 19 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/models-config.fills-missing-provider-apikey-from-env-var.e2e.test.ts", - "hashed_secret": "3a81eb091f80c845232225be5663d270e90dacb7", - "is_verified": false, - "line_number": 73 - } - ], - "src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.e2e.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.e2e.test.ts", - "hashed_secret": "980d02eb9335ae7c9e9984f6c8ad432352a0d2ac", - "is_verified": false, - "line_number": 20 - } - ], - "src/agents/models-config.providers.nvidia.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/models-config.providers.nvidia.test.ts", - "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", - "is_verified": false, - "line_number": 13 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/models-config.providers.nvidia.test.ts", - "hashed_secret": "be1a7be9d4d5af417882b267f4db6dddc08507bd", - "is_verified": false, - "line_number": 27 - } - ], - "src/agents/models-config.providers.ollama.e2e.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/models-config.providers.ollama.e2e.test.ts", - "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", - "is_verified": false, - "line_number": 37 - } - ], - "src/agents/models-config.providers.qianfan.e2e.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/models-config.providers.qianfan.e2e.test.ts", - "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", - "is_verified": false, - "line_number": 12 - } - ], - "src/agents/models-config.skips-writing-models-json-no-env-token.e2e.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/models-config.skips-writing-models-json-no-env-token.e2e.test.ts", - "hashed_secret": "4c7bac93427c83bcc3beeceebfa54f16f801b78f", - "is_verified": false, - "line_number": 100 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/models-config.skips-writing-models-json-no-env-token.e2e.test.ts", - "hashed_secret": "4f2b3ddc953da005a97d825652080fe6eff21520", - "is_verified": false, - "line_number": 113 - } - ], - "src/agents/openai-responses.reasoning-replay.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/openai-responses.reasoning-replay.test.ts", - "hashed_secret": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", - "is_verified": false, - "line_number": 55 - } - ], - "src/agents/pi-embedded-runner.e2e.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/pi-embedded-runner.e2e.test.ts", - "hashed_secret": "e9a5f12a8ecbb3eb46eca5096b5c52aa5e7c9fdd", - "is_verified": false, - "line_number": 127 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/pi-embedded-runner.e2e.test.ts", - "hashed_secret": "fcdd655b11f33ba4327695084a347b2ba192976c", - "is_verified": false, - "line_number": 238 - } - ], - "src/agents/pi-embedded-runner/model.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/pi-embedded-runner/model.ts", - "hashed_secret": "e774aaeac31c6272107ba89080295e277050fa7c", - "is_verified": false, - "line_number": 118 - } - ], - "src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts", - "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", - "is_verified": false, - "line_number": 86 - } - ], - "src/agents/pi-tools.safe-bins.e2e.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/pi-tools.safe-bins.e2e.test.ts", - "hashed_secret": "3ea88a727641fd5571b5e126ce87032377be1e7f", - "is_verified": false, - "line_number": 126 - } - ], - "src/agents/sanitize-for-prompt.test.ts": [ - { - "type": "Base64 High Entropy String", - "filename": "src/agents/sanitize-for-prompt.test.ts", - "hashed_secret": "9c62d3aa77c19e170c44b18129f967e2041fda41", - "is_verified": false, - "line_number": 28 - } - ], - "src/agents/skills.build-workspace-skills-prompt.prefers-workspace-skills-managed-skills.e2e.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/skills.build-workspace-skills-prompt.prefers-workspace-skills-managed-skills.e2e.test.ts", - "hashed_secret": "7a85f4764bbd6daf1c3545efbbf0f279a6dc0beb", - "is_verified": false, - "line_number": 103 - } - ], - "src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.e2e.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.e2e.test.ts", - "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", - "is_verified": false, - "line_number": 147 - } - ], - "src/agents/skills.e2e.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/skills.e2e.test.ts", - "hashed_secret": "5df3a673d724e8a1eb673a8baf623e183940804d", - "is_verified": false, - "line_number": 250 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/skills.e2e.test.ts", - "hashed_secret": "8921daaa546693e52bc1f9c40bdcf15e816e0448", - "is_verified": false, - "line_number": 277 - } - ], - "src/agents/tools/web-fetch.firecrawl-api-key-normalization.e2e.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/tools/web-fetch.firecrawl-api-key-normalization.e2e.test.ts", - "hashed_secret": "9da08ab1e27fe0ae2ba6101aea30edcec02d21a4", - "is_verified": false, - "line_number": 45 - } - ], - "src/agents/tools/web-fetch.ssrf.e2e.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/tools/web-fetch.ssrf.e2e.test.ts", - "hashed_secret": "5ce8e9d54c77266fff990194d2219a708c59b76c", - "is_verified": false, - "line_number": 73 - } - ], - "src/agents/tools/web-search.e2e.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/tools/web-search.e2e.test.ts", - "hashed_secret": "c8d313eac6d38274ccfc0fa7935c68bd61d5bc2f", - "is_verified": false, - "line_number": 129 - } - ], - "src/agents/tools/web-search.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/tools/web-search.ts", - "hashed_secret": "dfba7aade0868074c2861c98e2a9a92f3178a51b", - "is_verified": false, - "line_number": 97 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/tools/web-search.ts", - "hashed_secret": "71f8e7976e4cbc4561c9d62fb283e7f788202acb", - "is_verified": false, - "line_number": 285 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/tools/web-search.ts", - "hashed_secret": "c4865ff9250aca23b0d98eb079dad70ebec1cced", - "is_verified": false, - "line_number": 295 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/tools/web-search.ts", - "hashed_secret": "527ee41f36386e85fa932ef09471ca017f3c95c8", - "is_verified": false, - "line_number": 298 - } - ], - "src/agents/tools/web-tools.enabled-defaults.e2e.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/tools/web-tools.enabled-defaults.e2e.test.ts", - "hashed_secret": "47b249a75ca78fdb578d0f28c33685e27ea82684", - "is_verified": false, - "line_number": 181 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/tools/web-tools.enabled-defaults.e2e.test.ts", - "hashed_secret": "d0ffd81d6d7ad1bc3c365660fe8882480c9a986e", - "is_verified": false, - "line_number": 187 - } - ], - "src/agents/tools/web-tools.fetch.e2e.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/tools/web-tools.fetch.e2e.test.ts", - "hashed_secret": "5ce8e9d54c77266fff990194d2219a708c59b76c", - "is_verified": false, - "line_number": 246 - } - ], - "src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.e2e.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.e2e.test.ts", - "hashed_secret": "e9a5f12a8ecbb3eb46eca5096b5c52aa5e7c9fdd", - "is_verified": false, - "line_number": 56 - }, - { - "type": "Secret Keyword", - "filename": "src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.e2e.test.ts", - "hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209", - "is_verified": false, - "line_number": 62 - } - ], - "src/auto-reply/reply.directive.directive-behavior.supports-fuzzy-model-matches-model-directive.e2e.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/auto-reply/reply.directive.directive-behavior.supports-fuzzy-model-matches-model-directive.e2e.test.ts", - "hashed_secret": "e9a5f12a8ecbb3eb46eca5096b5c52aa5e7c9fdd", - "is_verified": false, - "line_number": 42 - }, - { - "type": "Secret Keyword", - "filename": "src/auto-reply/reply.directive.directive-behavior.supports-fuzzy-model-matches-model-directive.e2e.test.ts", - "hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209", - "is_verified": false, - "line_number": 149 - } - ], - "src/auto-reply/status.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/auto-reply/status.test.ts", - "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", - "is_verified": false, - "line_number": 36 - } - ], - "src/browser/bridge-server.auth.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/browser/bridge-server.auth.test.ts", - "hashed_secret": "6af3c121ed4a752936c297cddfb7b00394eabf10", - "is_verified": false, - "line_number": 66 - } - ], - "src/browser/browser-utils.test.ts": [ - { - "type": "Hex High Entropy String", - "filename": "src/browser/browser-utils.test.ts", - "hashed_secret": "4e126c049580d66ca1549fa534d95a7263f27f46", - "is_verified": false, - "line_number": 38 - }, - { - "type": "Basic Auth Credentials", - "filename": "src/browser/browser-utils.test.ts", - "hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684", - "is_verified": false, - "line_number": 159 - } - ], - "src/browser/cdp.test.ts": [ - { - "type": "Basic Auth Credentials", - "filename": "src/browser/cdp.test.ts", - "hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684", - "is_verified": false, - "line_number": 186 - } - ], - "src/channels/plugins/plugins-channel.test.ts": [ - { - "type": "Hex High Entropy String", - "filename": "src/channels/plugins/plugins-channel.test.ts", - "hashed_secret": "99c962e8c62296bdc9a17f5caf91ce9bb4c7e0e6", - "is_verified": false, - "line_number": 46 - } - ], - "src/cli/program.smoke.e2e.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/cli/program.smoke.e2e.test.ts", - "hashed_secret": "8689a958b58e4a6f7da6211e666da8e17651697c", - "is_verified": false, - "line_number": 215 - } - ], - "src/cli/update-cli.test.ts": [ - { - "type": "Hex High Entropy String", - "filename": "src/cli/update-cli.test.ts", - "hashed_secret": "e4f91dd323bac5bfc4f60a6e433787671dc2421d", - "is_verified": false, - "line_number": 239 - } - ], - "src/commands/auth-choice.e2e.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/commands/auth-choice.e2e.test.ts", - "hashed_secret": "2480500ff391183070fe22ba8665a8be19350833", - "is_verified": false, - "line_number": 454 - }, - { - "type": "Secret Keyword", - "filename": "src/commands/auth-choice.e2e.test.ts", - "hashed_secret": "844ae5308654406d80db6f2b3d0beb07d616f9e1", - "is_verified": false, - "line_number": 487 - }, - { - "type": "Secret Keyword", - "filename": "src/commands/auth-choice.e2e.test.ts", - "hashed_secret": "77e991e9f56e6fa4ed1a908208048421f1214c07", - "is_verified": false, - "line_number": 549 - }, - { - "type": "Secret Keyword", - "filename": "src/commands/auth-choice.e2e.test.ts", - "hashed_secret": "266e955b27b5fc2c2f532e446f2e71c3667a4cd9", - "is_verified": false, - "line_number": 584 - }, - { - "type": "Secret Keyword", - "filename": "src/commands/auth-choice.e2e.test.ts", - "hashed_secret": "1b4d8423b11d32dd0c466428ac81de84a4a9442b", - "is_verified": false, - "line_number": 726 - }, - { - "type": "Secret Keyword", - "filename": "src/commands/auth-choice.e2e.test.ts", - "hashed_secret": "c24e00b94c972ed497d5961212ac96f0dffb4f7a", - "is_verified": false, - "line_number": 798 - } - ], - "src/commands/auth-choice.preferred-provider.ts": [ - { - "type": "Secret Keyword", - "filename": "src/commands/auth-choice.preferred-provider.ts", - "hashed_secret": "c03a8d10174dd7eb2b3288b570a5a74fdd9ae05d", - "is_verified": false, - "line_number": 8 - } - ], - "src/commands/configure.gateway-auth.e2e.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/commands/configure.gateway-auth.e2e.test.ts", - "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", - "is_verified": false, - "line_number": 21 - }, - { - "type": "Secret Keyword", - "filename": "src/commands/configure.gateway-auth.e2e.test.ts", - "hashed_secret": "d5d4cd07616a542891b7ec2d0257b3a24b69856e", - "is_verified": false, - "line_number": 62 - } - ], - "src/commands/daemon-install-helpers.e2e.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/commands/daemon-install-helpers.e2e.test.ts", - "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", - "is_verified": false, - "line_number": 128 - } - ], - "src/commands/doctor-memory-search.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/commands/doctor-memory-search.test.ts", - "hashed_secret": "2e07956ffc9bc4fd624064c40b7495c85d5f1467", - "is_verified": false, - "line_number": 38 - } - ], - "src/commands/model-picker.e2e.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/commands/model-picker.e2e.test.ts", - "hashed_secret": "5b924ca5330ede58702a5b0e414207b90fb1aef3", - "is_verified": false, - "line_number": 127 - } - ], - "src/commands/models/list.status.e2e.test.ts": [ - { - "type": "Base64 High Entropy String", - "filename": "src/commands/models/list.status.e2e.test.ts", - "hashed_secret": "d6ae2508a78a232d5378ef24b85ce40cbb4d7ff0", - "is_verified": false, - "line_number": 12 - }, - { - "type": "Base64 High Entropy String", - "filename": "src/commands/models/list.status.e2e.test.ts", - "hashed_secret": "2d8012102440ea97852b3152239218f00579bafa", - "is_verified": false, - "line_number": 19 - }, - { - "type": "Base64 High Entropy String", - "filename": "src/commands/models/list.status.e2e.test.ts", - "hashed_secret": "51848e2be4b461a549218d3167f19c01be6b98b8", - "is_verified": false, - "line_number": 51 - }, - { - "type": "Secret Keyword", - "filename": "src/commands/models/list.status.e2e.test.ts", - "hashed_secret": "51848e2be4b461a549218d3167f19c01be6b98b8", - "is_verified": false, - "line_number": 51 - }, - { - "type": "Secret Keyword", - "filename": "src/commands/models/list.status.e2e.test.ts", - "hashed_secret": "1c1e381bfb72d3b7bfca9437053d9875356680f0", - "is_verified": false, - "line_number": 57 - } - ], - "src/commands/onboard-auth.config-minimax.ts": [ - { - "type": "Secret Keyword", - "filename": "src/commands/onboard-auth.config-minimax.ts", - "hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209", - "is_verified": false, - "line_number": 36 - }, - { - "type": "Secret Keyword", - "filename": "src/commands/onboard-auth.config-minimax.ts", - "hashed_secret": "ddcb713196b974770575a9bea5a4e7d46361f8e9", - "is_verified": false, - "line_number": 78 - } - ], - "src/commands/onboard-auth.e2e.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/commands/onboard-auth.e2e.test.ts", - "hashed_secret": "e184b402822abc549b37689c84e8e0e33c39a1f1", - "is_verified": false, - "line_number": 272 - } - ], - "src/commands/onboard-custom.e2e.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/commands/onboard-custom.e2e.test.ts", - "hashed_secret": "62e6748c6bb4c4a0f785a28cdd7d41ef212c0091", - "is_verified": false, - "line_number": 238 - } - ], - "src/commands/onboard-non-interactive.provider-auth.e2e.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/commands/onboard-non-interactive.provider-auth.e2e.test.ts", - "hashed_secret": "fcdd655b11f33ba4327695084a347b2ba192976c", - "is_verified": false, - "line_number": 153 - }, - { - "type": "Secret Keyword", - "filename": "src/commands/onboard-non-interactive.provider-auth.e2e.test.ts", - "hashed_secret": "07a6b9cec637c806195e8aa7e5c0851ab03dc35e", - "is_verified": false, - "line_number": 191 - }, - { - "type": "Secret Keyword", - "filename": "src/commands/onboard-non-interactive.provider-auth.e2e.test.ts", - "hashed_secret": "77e991e9f56e6fa4ed1a908208048421f1214c07", - "is_verified": false, - "line_number": 234 - }, - { - "type": "Secret Keyword", - "filename": "src/commands/onboard-non-interactive.provider-auth.e2e.test.ts", - "hashed_secret": "65547299f940eca3dc839f3eac85e8a78a6deb05", - "is_verified": false, - "line_number": 282 - }, - { - "type": "Secret Keyword", - "filename": "src/commands/onboard-non-interactive.provider-auth.e2e.test.ts", - "hashed_secret": "2833d098c110602e4c8d577fbfdb423a9ffd58e9", - "is_verified": false, - "line_number": 304 - }, - { - "type": "Secret Keyword", - "filename": "src/commands/onboard-non-interactive.provider-auth.e2e.test.ts", - "hashed_secret": "266e955b27b5fc2c2f532e446f2e71c3667a4cd9", - "is_verified": false, - "line_number": 338 - }, - { - "type": "Secret Keyword", - "filename": "src/commands/onboard-non-interactive.provider-auth.e2e.test.ts", - "hashed_secret": "995b80728ee01edb90ddfed07870bbab405df19f", - "is_verified": false, - "line_number": 366 - }, - { - "type": "Secret Keyword", - "filename": "src/commands/onboard-non-interactive.provider-auth.e2e.test.ts", - "hashed_secret": "b65888424ecafcc98bfd803b24817e4dadf821f8", - "is_verified": false, - "line_number": 383 - }, - { - "type": "Secret Keyword", - "filename": "src/commands/onboard-non-interactive.provider-auth.e2e.test.ts", - "hashed_secret": "62e6748c6bb4c4a0f785a28cdd7d41ef212c0091", - "is_verified": false, - "line_number": 402 - }, - { - "type": "Secret Keyword", - "filename": "src/commands/onboard-non-interactive.provider-auth.e2e.test.ts", - "hashed_secret": "8818d3b7c102fd6775af9e1390e5ed3a128473fb", - "is_verified": false, - "line_number": 447 - } - ], - "src/commands/onboard-non-interactive/api-keys.ts": [ - { - "type": "Secret Keyword", - "filename": "src/commands/onboard-non-interactive/api-keys.ts", - "hashed_secret": "112f3a99b283a4e1788dedd8e0e5d35375c33747", - "is_verified": false, - "line_number": 11 - } - ], - "src/commands/status.update.test.ts": [ - { - "type": "Hex High Entropy String", - "filename": "src/commands/status.update.test.ts", - "hashed_secret": "33c76f70af66754ca47d19b17da8dc232e125253", - "is_verified": false, - "line_number": 74 - } - ], - "src/commands/vllm-setup.ts": [ - { - "type": "Secret Keyword", - "filename": "src/commands/vllm-setup.ts", - "hashed_secret": "5b924ca5330ede58702a5b0e414207b90fb1aef3", - "is_verified": false, - "line_number": 60 - } - ], - "src/commands/zai-endpoint-detect.e2e.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/commands/zai-endpoint-detect.e2e.test.ts", - "hashed_secret": "e9a5f12a8ecbb3eb46eca5096b5c52aa5e7c9fdd", - "is_verified": false, - "line_number": 24 - } - ], - "src/config/config-misc.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/config/config-misc.test.ts", - "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", - "is_verified": false, - "line_number": 62 - } - ], - "src/config/config.env-vars.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/config/config.env-vars.test.ts", - "hashed_secret": "a24ef9c1a27cac44823571ceef2e8262718eee36", - "is_verified": false, - "line_number": 13 - }, - { - "type": "Secret Keyword", - "filename": "src/config/config.env-vars.test.ts", - "hashed_secret": "29d5f92e9ee44d4854d6dfaeefc3dc27d779fdf3", - "is_verified": false, - "line_number": 19 - }, - { - "type": "Secret Keyword", - "filename": "src/config/config.env-vars.test.ts", - "hashed_secret": "1672b6a1e7956c6a70f45d699aa42a351b1f8b80", - "is_verified": false, - "line_number": 27 - } - ], - "src/config/config.irc.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/config/config.irc.test.ts", - "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", - "is_verified": false, - "line_number": 92 - } - ], - "src/config/config.talk-api-key-fallback.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/config/config.talk-api-key-fallback.test.ts", - "hashed_secret": "bea2f7b64fab8d1d414d0449530b1e088d36d5b1", - "is_verified": false, - "line_number": 33 - } - ], - "src/config/env-preserve-io.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/config/env-preserve-io.test.ts", - "hashed_secret": "85639f0560fd9bf8704f52e01c5e764c9ed5a6aa", - "is_verified": false, - "line_number": 59 - }, - { - "type": "Secret Keyword", - "filename": "src/config/env-preserve-io.test.ts", - "hashed_secret": "996650087ab48bdb1ca80f0842c97d4fbb6f1c71", - "is_verified": false, - "line_number": 86 - } - ], - "src/config/env-preserve.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/config/env-preserve.test.ts", - "hashed_secret": "f6067ac4599b1cd5176f34897bb556a1a1eaf049", - "is_verified": false, - "line_number": 6 - }, - { - "type": "Secret Keyword", - "filename": "src/config/env-preserve.test.ts", - "hashed_secret": "5a41c5061e7279cec0566b3ef52cbe042e831192", - "is_verified": false, - "line_number": 7 - }, - { - "type": "Secret Keyword", - "filename": "src/config/env-preserve.test.ts", - "hashed_secret": "53d407242b91f07138abcf30ee0e6b71f304b87f", - "is_verified": false, - "line_number": 19 - }, - { - "type": "Secret Keyword", - "filename": "src/config/env-preserve.test.ts", - "hashed_secret": "c1b24294f00e281605f9dd6a298612e3060062b4", - "is_verified": false, - "line_number": 82 - } - ], - "src/config/env-substitution.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/config/env-substitution.test.ts", - "hashed_secret": "f2b14f68eb995facb3a1c35287b778d5bd785511", - "is_verified": false, - "line_number": 37 - }, - { - "type": "Secret Keyword", - "filename": "src/config/env-substitution.test.ts", - "hashed_secret": "ec417f567082612f8fd6afafe1abcab831fca840", - "is_verified": false, - "line_number": 68 - }, - { - "type": "Secret Keyword", - "filename": "src/config/env-substitution.test.ts", - "hashed_secret": "520bd69c3eb1646d9a78181ecb4c90c51fdf428d", - "is_verified": false, - "line_number": 69 - }, - { - "type": "Secret Keyword", - "filename": "src/config/env-substitution.test.ts", - "hashed_secret": "f136444bf9b3d01a9f9b772b80ac6bf7b6a43ef0", - "is_verified": false, - "line_number": 227 - } - ], - "src/config/io.write-config.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/config/io.write-config.test.ts", - "hashed_secret": "13951588fd3325e25ed1e3b116d7009fb221c85e", - "is_verified": false, - "line_number": 65 - } - ], - "src/config/model-alias-defaults.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/config/model-alias-defaults.test.ts", - "hashed_secret": "e9a5f12a8ecbb3eb46eca5096b5c52aa5e7c9fdd", - "is_verified": false, - "line_number": 66 - } - ], - "src/config/redact-snapshot.test.ts": [ - { - "type": "Base64 High Entropy String", - "filename": "src/config/redact-snapshot.test.ts", - "hashed_secret": "3732e17b2d11ed6c64fef02c341958007af154e7", - "is_verified": false, - "line_number": 77 - }, - { - "type": "Secret Keyword", - "filename": "src/config/redact-snapshot.test.ts", - "hashed_secret": "3732e17b2d11ed6c64fef02c341958007af154e7", - "is_verified": false, - "line_number": 77 - }, - { - "type": "Secret Keyword", - "filename": "src/config/redact-snapshot.test.ts", - "hashed_secret": "7f413afd37447cd321d79286be0f58d7a9875d9b", - "is_verified": false, - "line_number": 89 - }, - { - "type": "Secret Keyword", - "filename": "src/config/redact-snapshot.test.ts", - "hashed_secret": "c21afa950dee2a70f3e0f6ffdfbc87f8edb90262", - "is_verified": false, - "line_number": 99 - }, - { - "type": "Secret Keyword", - "filename": "src/config/redact-snapshot.test.ts", - "hashed_secret": "83a9937c6de261ffda22304834f30fe6c8f97926", - "is_verified": false, - "line_number": 110 - }, - { - "type": "Secret Keyword", - "filename": "src/config/redact-snapshot.test.ts", - "hashed_secret": "87ac76dfc9cba93bead43c191e31bd099a97cc11", - "is_verified": false, - "line_number": 198 - }, - { - "type": "Secret Keyword", - "filename": "src/config/redact-snapshot.test.ts", - "hashed_secret": "abb1aabcd0e49019c2873944a40671a80ccd64c7", - "is_verified": false, - "line_number": 309 - }, - { - "type": "Base64 High Entropy String", - "filename": "src/config/redact-snapshot.test.ts", - "hashed_secret": "8e22880b4e96bab354e1da6c91d2f58dabde3555", - "is_verified": false, - "line_number": 321 - }, - { - "type": "Secret Keyword", - "filename": "src/config/redact-snapshot.test.ts", - "hashed_secret": "8e22880b4e96bab354e1da6c91d2f58dabde3555", - "is_verified": false, - "line_number": 321 - }, - { - "type": "Secret Keyword", - "filename": "src/config/redact-snapshot.test.ts", - "hashed_secret": "a9c732e05044a08c760cce7f6d142cd0d35a19e5", - "is_verified": false, - "line_number": 375 - }, - { - "type": "Secret Keyword", - "filename": "src/config/redact-snapshot.test.ts", - "hashed_secret": "50843dd5651cfafbe7c5611c1eed195c63e6e3fd", - "is_verified": false, - "line_number": 691 - }, - { - "type": "Secret Keyword", - "filename": "src/config/redact-snapshot.test.ts", - "hashed_secret": "927e7cdedcb8f71af399a49fb90a381df8b8df28", - "is_verified": false, - "line_number": 808 - }, - { - "type": "Secret Keyword", - "filename": "src/config/redact-snapshot.test.ts", - "hashed_secret": "1996cc327bd39dad69cd8feb24250dafd51e7c08", - "is_verified": false, - "line_number": 814 - }, - { - "type": "Secret Keyword", - "filename": "src/config/redact-snapshot.test.ts", - "hashed_secret": "a5c0a65a4fa8874a486aa5072671927ceba82a90", - "is_verified": false, - "line_number": 838 - } - ], - "src/config/schema.help.ts": [ - { - "type": "Secret Keyword", - "filename": "src/config/schema.help.ts", - "hashed_secret": "9f4cda226d3868676ac7f86f59e4190eb94bd208", - "is_verified": false, - "line_number": 109 - }, - { - "type": "Secret Keyword", - "filename": "src/config/schema.help.ts", - "hashed_secret": "01822c8bbf6a8b136944b14182cb885100ec2eae", - "is_verified": false, - "line_number": 130 - }, - { - "type": "Secret Keyword", - "filename": "src/config/schema.help.ts", - "hashed_secret": "bb7dfd9746e660e4a4374951ec5938ef0e343255", - "is_verified": false, - "line_number": 187 - } - ], - "src/config/schema.irc.ts": [ - { - "type": "Secret Keyword", - "filename": "src/config/schema.irc.ts", - "hashed_secret": "de18cf01737148de8ff7cb33fd38dd4d3e226384", - "is_verified": false, - "line_number": 6 - }, - { - "type": "Secret Keyword", - "filename": "src/config/schema.irc.ts", - "hashed_secret": "b362522192a2259c5d10ecb89fe728a66d6015e9", - "is_verified": false, - "line_number": 7 - }, - { - "type": "Secret Keyword", - "filename": "src/config/schema.irc.ts", - "hashed_secret": "383088054f9b38c21ec29db239e3fccb7eb0a485", - "is_verified": false, - "line_number": 20 - }, - { - "type": "Secret Keyword", - "filename": "src/config/schema.irc.ts", - "hashed_secret": "a3484eea8ccb96dd79f50edc14b8fbf2867a9180", - "is_verified": false, - "line_number": 21 - } - ], - "src/config/schema.labels.ts": [ - { - "type": "Secret Keyword", - "filename": "src/config/schema.labels.ts", - "hashed_secret": "e73c9fcad85cd4eecc74181ec4bdb31064d68439", - "is_verified": false, - "line_number": 104 - }, - { - "type": "Secret Keyword", - "filename": "src/config/schema.labels.ts", - "hashed_secret": "2eda7cd978f39eebec3bf03e4410a40e14167fff", - "is_verified": false, - "line_number": 145 - } - ], - "src/config/slack-http-config.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/config/slack-http-config.test.ts", - "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", - "is_verified": false, - "line_number": 10 - } - ], - "src/config/telegram-webhook-secret.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/config/telegram-webhook-secret.test.ts", - "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", - "is_verified": false, - "line_number": 10 - } - ], - "src/docker-setup.test.ts": [ - { - "type": "Base64 High Entropy String", - "filename": "src/docker-setup.test.ts", - "hashed_secret": "32ac33b537769e97787f70ef85576cc243fab934", - "is_verified": false, - "line_number": 131 - } - ], - "src/gateway/auth-rate-limit.ts": [ - { - "type": "Secret Keyword", - "filename": "src/gateway/auth-rate-limit.ts", - "hashed_secret": "76ed0a056aa77060de25754586440cff390791d0", - "is_verified": false, - "line_number": 37 - } - ], - "src/gateway/auth.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/gateway/auth.test.ts", - "hashed_secret": "db5543cd7440bbdc4c5aaf8aa363715c31dd5a27", - "is_verified": false, - "line_number": 32 - }, - { - "type": "Secret Keyword", - "filename": "src/gateway/auth.test.ts", - "hashed_secret": "d51f846285cbc6d1dd76677a0fd588c8df44e506", - "is_verified": false, - "line_number": 48 - }, - { - "type": "Secret Keyword", - "filename": "src/gateway/auth.test.ts", - "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", - "is_verified": false, - "line_number": 95 - }, - { - "type": "Secret Keyword", - "filename": "src/gateway/auth.test.ts", - "hashed_secret": "a4b48a81cdab1e1a5dd37907d6c85ca1c61ddc7c", - "is_verified": false, - "line_number": 103 - } - ], - "src/gateway/call.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/gateway/call.test.ts", - "hashed_secret": "db5543cd7440bbdc4c5aaf8aa363715c31dd5a27", - "is_verified": false, - "line_number": 357 - }, - { - "type": "Secret Keyword", - "filename": "src/gateway/call.test.ts", - "hashed_secret": "de1c41e8ece73f5d5c259bb37eccb59a542b91dc", - "is_verified": false, - "line_number": 361 - }, - { - "type": "Secret Keyword", - "filename": "src/gateway/call.test.ts", - "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", - "is_verified": false, - "line_number": 398 - }, - { - "type": "Secret Keyword", - "filename": "src/gateway/call.test.ts", - "hashed_secret": "e493f561d90c6638c1f51c5a8a069c3b129b79ed", - "is_verified": false, - "line_number": 408 - }, - { - "type": "Secret Keyword", - "filename": "src/gateway/call.test.ts", - "hashed_secret": "2e07956ffc9bc4fd624064c40b7495c85d5f1467", - "is_verified": false, - "line_number": 413 - }, - { - "type": "Secret Keyword", - "filename": "src/gateway/call.test.ts", - "hashed_secret": "bddc29032de580fb53b3a9a0357dd409086db800", - "is_verified": false, - "line_number": 426 - }, - { - "type": "Secret Keyword", - "filename": "src/gateway/call.test.ts", - "hashed_secret": "6255675480f681df08c1704b7b3cd2c49917f0e2", - "is_verified": false, - "line_number": 463 - } - ], - "src/gateway/client.e2e.test.ts": [ - { - "type": "Private Key", - "filename": "src/gateway/client.e2e.test.ts", - "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", - "is_verified": false, - "line_number": 85 - } - ], - "src/gateway/gateway-cli-backend.live.test.ts": [ - { - "type": "Hex High Entropy String", - "filename": "src/gateway/gateway-cli-backend.live.test.ts", - "hashed_secret": "3e2fd4a90d5afbd27974730c4d6a9592fe300825", - "is_verified": false, - "line_number": 38 - } - ], - "src/gateway/gateway-models.profiles.live.test.ts": [ - { - "type": "Hex High Entropy String", - "filename": "src/gateway/gateway-models.profiles.live.test.ts", - "hashed_secret": "3e2fd4a90d5afbd27974730c4d6a9592fe300825", - "is_verified": false, - "line_number": 242 - } - ], - "src/gateway/server-methods/skills.update.normalizes-api-key.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/gateway/server-methods/skills.update.normalizes-api-key.test.ts", - "hashed_secret": "c17b6f497b392e2efc655e8b646b3455f4b28e58", - "is_verified": false, - "line_number": 29 - } - ], - "src/gateway/server-methods/talk.ts": [ - { - "type": "Secret Keyword", - "filename": "src/gateway/server-methods/talk.ts", - "hashed_secret": "e478a5eeba4907d2f12a68761996b9de745d826d", - "is_verified": false, - "line_number": 13 - } - ], - "src/gateway/server.auth.e2e.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/gateway/server.auth.e2e.test.ts", - "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", - "is_verified": false, - "line_number": 460 - }, - { - "type": "Secret Keyword", - "filename": "src/gateway/server.auth.e2e.test.ts", - "hashed_secret": "a4b48a81cdab1e1a5dd37907d6c85ca1c61ddc7c", - "is_verified": false, - "line_number": 478 - } - ], - "src/gateway/server.skills-status.e2e.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/gateway/server.skills-status.e2e.test.ts", - "hashed_secret": "1cc6bff0f84efb2d3ff4fa1347f3b2bc173aaff0", - "is_verified": false, - "line_number": 13 - } - ], - "src/gateway/server.talk-config.e2e.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/gateway/server.talk-config.e2e.test.ts", - "hashed_secret": "3c310634864babb081f0b617c14bc34823d7e369", - "is_verified": false, - "line_number": 13 - } - ], - "src/gateway/session-utils.test.ts": [ - { - "type": "Base64 High Entropy String", - "filename": "src/gateway/session-utils.test.ts", - "hashed_secret": "bb9a5d9483409d2c60b28268a0efcb93324d4cda", - "is_verified": false, - "line_number": 280 - } - ], - "src/gateway/test-openai-responses-model.ts": [ - { - "type": "Secret Keyword", - "filename": "src/gateway/test-openai-responses-model.ts", - "hashed_secret": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", - "is_verified": false, - "line_number": 17 - } - ], - "src/gateway/ws-log.test.ts": [ - { - "type": "Base64 High Entropy String", - "filename": "src/gateway/ws-log.test.ts", - "hashed_secret": "edd2e7ac4f61d0c606e80a0919d727540842a307", - "is_verified": false, - "line_number": 22 - } - ], - "src/infra/env.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/infra/env.test.ts", - "hashed_secret": "df98a117ddabf85991b9fe0e268214dc0e1254dc", - "is_verified": false, - "line_number": 9 - }, - { - "type": "Secret Keyword", - "filename": "src/infra/env.test.ts", - "hashed_secret": "6d811dc1f59a55ca1a3d38b5042a062b9f79e8ec", - "is_verified": false, - "line_number": 30 - } - ], - "src/infra/outbound/message-action-runner.test.ts": [ - { - "type": "Hex High Entropy String", - "filename": "src/infra/outbound/message-action-runner.test.ts", - "hashed_secret": "804ec071803318791b835cffd6e509c8d32239db", - "is_verified": false, - "line_number": 129 - }, - { - "type": "Secret Keyword", - "filename": "src/infra/outbound/message-action-runner.test.ts", - "hashed_secret": "789cbe0407840b1c2041cb33452ff60f19bf58cc", - "is_verified": false, - "line_number": 435 - } - ], - "src/infra/outbound/outbound.test.ts": [ - { - "type": "Hex High Entropy String", - "filename": "src/infra/outbound/outbound.test.ts", - "hashed_secret": "804ec071803318791b835cffd6e509c8d32239db", - "is_verified": false, - "line_number": 631 - } - ], - "src/infra/provider-usage.auth.normalizes-keys.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/infra/provider-usage.auth.normalizes-keys.test.ts", - "hashed_secret": "45c7365e3b542cdb4fae6ec10c2ff149224d7656", - "is_verified": false, - "line_number": 80 - }, - { - "type": "Secret Keyword", - "filename": "src/infra/provider-usage.auth.normalizes-keys.test.ts", - "hashed_secret": "b67074884ab7ef7c7a8cd6a3da9565d96c792248", - "is_verified": false, - "line_number": 81 - }, - { - "type": "Secret Keyword", - "filename": "src/infra/provider-usage.auth.normalizes-keys.test.ts", - "hashed_secret": "d4d8027e64f9cf4180d3aecfe31ea409368022ee", - "is_verified": false, - "line_number": 82 - } - ], - "src/infra/shell-env.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/infra/shell-env.test.ts", - "hashed_secret": "65c10dc3549fe07424148a8a4790a3341ecbc253", - "is_verified": false, - "line_number": 26 - }, - { - "type": "Secret Keyword", - "filename": "src/infra/shell-env.test.ts", - "hashed_secret": "e013ffda590d2178607c16d11b1ea42f75ceb0e7", - "is_verified": false, - "line_number": 58 - }, - { - "type": "Base64 High Entropy String", - "filename": "src/infra/shell-env.test.ts", - "hashed_secret": "be6ee9a6bf9f2dad84a5a67d6c0576a5bacc391e", - "is_verified": false, - "line_number": 60 - } - ], - "src/line/accounts.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/line/accounts.test.ts", - "hashed_secret": "fe1bae27cb7c1fb823f496f286e78f1d2ae87734", - "is_verified": false, - "line_number": 30 - }, - { - "type": "Secret Keyword", - "filename": "src/line/accounts.test.ts", - "hashed_secret": "8a8281cec699f5e51330e21dd7fab3531af6ef0c", - "is_verified": false, - "line_number": 48 - }, - { - "type": "Secret Keyword", - "filename": "src/line/accounts.test.ts", - "hashed_secret": "b4924d9834a1126714643ac231fb6623c14c3449", - "is_verified": false, - "line_number": 74 - } - ], - "src/line/bot-handlers.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/line/bot-handlers.test.ts", - "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", - "is_verified": false, - "line_number": 106 - } - ], - "src/line/bot-message-context.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/line/bot-message-context.test.ts", - "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", - "is_verified": false, - "line_number": 18 - } - ], - "src/line/monitor.fail-closed.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/line/monitor.fail-closed.test.ts", - "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", - "is_verified": false, - "line_number": 22 - } - ], - "src/line/webhook-node.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/line/webhook-node.test.ts", - "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", - "is_verified": false, - "line_number": 28 - } - ], - "src/line/webhook.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/line/webhook.test.ts", - "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", - "is_verified": false, - "line_number": 23 - } - ], - "src/logging/redact.test.ts": [ - { - "type": "Base64 High Entropy String", - "filename": "src/logging/redact.test.ts", - "hashed_secret": "dd7754662b89333191ff45e8257a3e6d3fcd3990", - "is_verified": false, - "line_number": 8 - }, - { - "type": "Private Key", - "filename": "src/logging/redact.test.ts", - "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", - "is_verified": false, - "line_number": 73 - }, - { - "type": "Hex High Entropy String", - "filename": "src/logging/redact.test.ts", - "hashed_secret": "7992945213f7d76889fa83ff0f2be352409c837e", - "is_verified": false, - "line_number": 74 - }, - { - "type": "Base64 High Entropy String", - "filename": "src/logging/redact.test.ts", - "hashed_secret": "063995ecb4fa5afe2460397d322925cd867b7d74", - "is_verified": false, - "line_number": 88 - } - ], - "src/media-understanding/apply.e2e.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/media-understanding/apply.e2e.test.ts", - "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", - "is_verified": false, - "line_number": 12 - } - ], - "src/media-understanding/providers/deepgram/audio.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/media-understanding/providers/deepgram/audio.test.ts", - "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", - "is_verified": false, - "line_number": 27 - } - ], - "src/media-understanding/providers/google/video.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/media-understanding/providers/google/video.test.ts", - "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", - "is_verified": false, - "line_number": 64 - } - ], - "src/media-understanding/providers/openai/audio.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/media-understanding/providers/openai/audio.test.ts", - "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", - "is_verified": false, - "line_number": 22 - } - ], - "src/media-understanding/runner.auto-audio.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/media-understanding/runner.auto-audio.test.ts", - "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", - "is_verified": false, - "line_number": 40 - } - ], - "src/media-understanding/runner.deepgram.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/media-understanding/runner.deepgram.test.ts", - "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", - "is_verified": false, - "line_number": 44 - } - ], - "src/memory/embeddings-voyage.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/memory/embeddings-voyage.test.ts", - "hashed_secret": "7c2020578bbe5e2e3f78d7f954eb2ad8ab5b0403", - "is_verified": false, - "line_number": 33 - }, - { - "type": "Secret Keyword", - "filename": "src/memory/embeddings-voyage.test.ts", - "hashed_secret": "8afdb3da9b79c8957ae35978ea8f33fbc3bfdf60", - "is_verified": false, - "line_number": 77 - } - ], - "src/memory/embeddings.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/memory/embeddings.test.ts", - "hashed_secret": "a47110e348a3063541fb1f1f640d635d457181a0", - "is_verified": false, - "line_number": 45 - }, - { - "type": "Secret Keyword", - "filename": "src/memory/embeddings.test.ts", - "hashed_secret": "c734e47630dda71619c696d88381f06f7511bd78", - "is_verified": false, - "line_number": 160 - }, - { - "type": "Secret Keyword", - "filename": "src/memory/embeddings.test.ts", - "hashed_secret": "56e1d57b8db262b08bc73c60ed08d8c92e59503f", - "is_verified": false, - "line_number": 189 - } - ], - "src/pairing/pairing-store.ts": [ - { - "type": "Base64 High Entropy String", - "filename": "src/pairing/pairing-store.ts", - "hashed_secret": "f8c6f1ff98c5ee78c27d34a3ca68f35ad79847af", - "is_verified": false, - "line_number": 13 - } - ], - "src/pairing/setup-code.test.ts": [ - { - "type": "Base64 High Entropy String", - "filename": "src/pairing/setup-code.test.ts", - "hashed_secret": "4914c103484773b5a8e18448b11919bb349cbff8", - "is_verified": false, - "line_number": 22 - }, - { - "type": "Secret Keyword", - "filename": "src/pairing/setup-code.test.ts", - "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", - "is_verified": false, - "line_number": 96 - } - ], - "src/security/audit.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/security/audit.test.ts", - "hashed_secret": "21f688ab56f76a99e5c6ed342291422f4e57e47f", - "is_verified": false, - "line_number": 2063 - }, - { - "type": "Secret Keyword", - "filename": "src/security/audit.test.ts", - "hashed_secret": "3dc927d80543dc0f643940b70d066bd4b4c4b78e", - "is_verified": false, - "line_number": 2094 - } - ], - "src/telegram/monitor.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/telegram/monitor.test.ts", - "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", - "is_verified": false, - "line_number": 205 - }, - { - "type": "Secret Keyword", - "filename": "src/telegram/monitor.test.ts", - "hashed_secret": "5934c4d4a4fa5d66ddb3d3fc0bba84996c17a5b7", - "is_verified": false, - "line_number": 233 - } - ], - "src/telegram/webhook.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/telegram/webhook.test.ts", - "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", - "is_verified": false, - "line_number": 42 - } - ], - "src/tts/tts.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/tts/tts.test.ts", - "hashed_secret": "2e7a7ee14caebf378fc32d6cf6f557f347c96773", - "is_verified": false, - "line_number": 36 - }, - { - "type": "Hex High Entropy String", - "filename": "src/tts/tts.test.ts", - "hashed_secret": "b214f706bb602c1cc2adc5c6165e73622305f4bb", - "is_verified": false, - "line_number": 98 - }, - { - "type": "Secret Keyword", - "filename": "src/tts/tts.test.ts", - "hashed_secret": "75ddfb45216fe09680dfe70eda4f559a910c832c", - "is_verified": false, - "line_number": 397 - }, - { - "type": "Secret Keyword", - "filename": "src/tts/tts.test.ts", - "hashed_secret": "e29af93630aa18cc3457cb5b13937b7ab7c99c9b", - "is_verified": false, - "line_number": 413 - }, - { - "type": "Secret Keyword", - "filename": "src/tts/tts.test.ts", - "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", - "is_verified": false, - "line_number": 447 - } - ], - "src/tui/gateway-chat.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/tui/gateway-chat.test.ts", - "hashed_secret": "6255675480f681df08c1704b7b3cd2c49917f0e2", - "is_verified": false, - "line_number": 85 - } - ], - "src/web/login.test.ts": [ - { - "type": "Hex High Entropy String", - "filename": "src/web/login.test.ts", - "hashed_secret": "564666dc1ca6e7318b2d5feeb1ce7b5bf717411e", - "is_verified": false, - "line_number": 60 - } - ], - "ui/src/i18n/locales/en.ts": [ - { - "type": "Secret Keyword", - "filename": "ui/src/i18n/locales/en.ts", - "hashed_secret": "de0ff6b974d6910aca8d6b830e1b761f076d8fe6", - "is_verified": false, - "line_number": 60 - } - ], - "ui/src/i18n/locales/pt-BR.ts": [ - { - "type": "Secret Keyword", - "filename": "ui/src/i18n/locales/pt-BR.ts", - "hashed_secret": "ef7b6f95faca2d7d3a5aa5a6434c89530c6dd243", - "is_verified": false, - "line_number": 60 - } - ], - "vendor/a2ui/README.md": [ - { - "type": "Secret Keyword", - "filename": "vendor/a2ui/README.md", - "hashed_secret": "2619a5397a5d054dab3fe24e6a8da1fbd76ec3a6", - "is_verified": false, - "line_number": 123 - } - ] - }, - "generated_at": "2026-02-17T13:34:38Z" -} diff --git a/.shellcheckrc b/.shellcheckrc deleted file mode 100644 index 515f25a5f1e..00000000000 --- a/.shellcheckrc +++ /dev/null @@ -1,25 +0,0 @@ -# ShellCheck configuration -# https://www.shellcheck.net/wiki/ - -# Disable common false positives and style suggestions - -# SC2034: Variable appears unused (often exported or used indirectly) -disable=SC2034 - -# SC2155: Declare and assign separately (common idiom, rarely causes issues) -disable=SC2155 - -# SC2295: Expansions inside ${..} need quoting (info-level, rarely causes issues) -disable=SC2295 - -# SC1012: \r is literal (tr -d '\r' works as intended on most systems) -disable=SC1012 - -# SC2026: Word outside quotes (info-level, often intentional) -disable=SC2026 - -# SC2016: Expressions don't expand in single quotes (often intentional in sed/awk) -disable=SC2016 - -# SC2129: Consider using { cmd1; cmd2; } >> file (style preference) -disable=SC2129 diff --git a/.swiftformat b/.swiftformat deleted file mode 100644 index fd8c0e6315c..00000000000 --- a/.swiftformat +++ /dev/null @@ -1,51 +0,0 @@ -# SwiftFormat configuration adapted from Peekaboo defaults (Swift 6 friendly) - ---swiftversion 6.2 - -# Self handling ---self insert ---selfrequired - -# Imports / extensions ---importgrouping testable-bottom ---extensionacl on-declarations - -# Indentation ---indent 4 ---indentcase false ---ifdef no-indent ---xcodeindentation enabled - -# Line breaks ---linebreaks lf ---maxwidth 120 - -# Whitespace ---trimwhitespace always ---emptybraces no-space ---nospaceoperators ...,..< ---ranges no-space ---someAny true ---voidtype void - -# Wrapping ---wraparguments before-first ---wrapparameters before-first ---wrapcollections before-first ---closingparen same-line - -# Organization ---organizetypes class,struct,enum,extension ---extensionmark "MARK: - %t + %p" ---marktypes always ---markextensions always ---structthreshold 0 ---enumthreshold 0 - -# Other ---stripunusedargs closure-only ---header ignore ---allman false - -# Exclusions ---exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,Peekaboo,Swabble,apps/android,apps/ios,apps/shared,apps/macos/Sources/MoltbotProtocol diff --git a/.swiftlint.yml b/.swiftlint.yml deleted file mode 100644 index b5622880111..00000000000 --- a/.swiftlint.yml +++ /dev/null @@ -1,148 +0,0 @@ -# SwiftLint configuration adapted from Peekaboo defaults (Swift 6 friendly) - -included: - - apps/macos/Sources - -excluded: - - .build - - DerivedData - - "**/.build" - - "**/.swiftpm" - - "**/DerivedData" - - "**/Generated" - - "**/Resources" - - "**/Package.swift" - - "**/Tests/Resources" - - node_modules - - dist - - coverage - - "*.playground" - # Generated (protocol-gen-swift.ts) - - apps/macos/Sources/MoltbotProtocol/GatewayModels.swift - -analyzer_rules: - - unused_declaration - - unused_import - -opt_in_rules: - - array_init - - closure_spacing - - contains_over_first_not_nil - - empty_count - - empty_string - - explicit_init - - fallthrough - - fatal_error_message - - first_where - - joined_default_parameter - - last_where - - literal_expression_end_indentation - - multiline_arguments - - multiline_parameters - - operator_usage_whitespace - - overridden_super_call - - pattern_matching_keywords - - private_outlet - - prohibited_super_call - - redundant_nil_coalescing - - sorted_first_last - - switch_case_alignment - - unneeded_parentheses_in_closure_argument - - vertical_parameter_alignment_on_call - -disabled_rules: - # SwiftFormat handles these - - trailing_whitespace - - trailing_newline - - trailing_comma - - vertical_whitespace - - indentation_width - - # Style exclusions - - explicit_self - - identifier_name - - file_header - - explicit_top_level_acl - - explicit_acl - - explicit_type_interface - - missing_docs - - required_deinit - - prefer_nimble - - quick_discouraged_call - - quick_discouraged_focused_test - - quick_discouraged_pending_test - - anonymous_argument_in_multiline_closure - - no_extension_access_modifier - - no_grouping_extension - - switch_case_on_newline - - strict_fileprivate - - extension_access_modifier - - convenience_type - - no_magic_numbers - - one_declaration_per_file - - vertical_whitespace_between_cases - - vertical_whitespace_closing_braces - - superfluous_else - - number_separator - - prefixed_toplevel_constant - - opening_brace - - trailing_closure - - contrasted_opening_brace - - sorted_imports - - redundant_type_annotation - - shorthand_optional_binding - - untyped_error_in_catch - - file_name - - todo - -force_cast: warning -force_try: warning - -type_name: - min_length: - warning: 2 - error: 1 - max_length: - warning: 60 - error: 80 - -function_body_length: - warning: 150 - error: 300 - -function_parameter_count: - warning: 7 - error: 10 - -file_length: - warning: 1500 - error: 2500 - ignore_comment_only_lines: true - -type_body_length: - warning: 800 - error: 1200 - -cyclomatic_complexity: - warning: 20 - error: 120 - -large_tuple: - warning: 4 - error: 5 - -nesting: - type_level: - warning: 4 - error: 6 - function_level: - warning: 5 - error: 7 - -line_length: - warning: 120 - error: 250 - ignores_comments: true - ignores_urls: true - -reporter: "xcode" diff --git a/.vscode/extensions.json b/.vscode/extensions.json deleted file mode 100644 index 99e2f7ddf76..00000000000 --- a/.vscode/extensions.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "recommendations": ["oxc.oxc-vscode"] -} diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index e291954cfc3..00000000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "editor.formatOnSave": true, - "files.insertFinalNewline": true, - "files.trimFinalNewlines": true, - "[javascript]": { - "editor.defaultFormatter": "oxc.oxc-vscode" - }, - "[typescriptreact]": { - "editor.defaultFormatter": "oxc.oxc-vscode" - }, - "[typescript]": { - "editor.defaultFormatter": "oxc.oxc-vscode" - }, - "[json]": { - "editor.defaultFormatter": "oxc.oxc-vscode" - }, - "typescript.preferences.importModuleSpecifierEnding": "js", - "typescript.reportStyleChecksAsWarnings": false, - "typescript.updateImportsOnFileMove.enabled": "always", - "typescript.tsdk": "node_modules/typescript/lib", - "typescript.experimental.useTsgo": true -} diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 3555ef17936..00000000000 --- a/AGENTS.md +++ /dev/null @@ -1,240 +0,0 @@ -# Repository Guidelines - -- Repo: https://github.com/openclaw/openclaw -- GitHub issues/comments/PR comments: use literal multiline strings or `-F - <<'EOF'` (or $'...') for real newlines; never embed "\\n". - -## Project Structure & Module Organization - -- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`). -- Tests: colocated `*.test.ts`. -- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`. -- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them. -- Plugins: install runs `npm install --omit=dev` in plugin dir; runtime deps must live in `dependencies`. Avoid `workspace:*` in `dependencies` (npm install breaks); put `openclaw` in `devDependencies` or `peerDependencies` instead (runtime resolves `openclaw/plugin-sdk` via jiti alias). -- Installers served from `https://openclaw.ai/*`: live in the sibling repo `../openclaw.ai` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`). -- Messaging channels: always consider **all** built-in + extension channels when refactoring shared logic (routing, allowlists, pairing, command gating, onboarding, docs). - - Core channel docs: `docs/channels/` - - Core channel code: `src/telegram`, `src/discord`, `src/slack`, `src/signal`, `src/imessage`, `src/web` (WhatsApp web), `src/channels`, `src/routing` - - Extensions (channel plugins): `extensions/*` (e.g. `extensions/msteams`, `extensions/matrix`, `extensions/zalo`, `extensions/zalouser`, `extensions/voice-call`) -- When adding channels/extensions/apps/docs, update `.github/labeler.yml` and create matching GitHub labels (use existing channel/extension label colors). - -## Docs Linking (Mintlify) - -- Docs are hosted on Mintlify (docs.openclaw.ai). -- Internal doc links in `docs/**/*.md`: root-relative, no `.md`/`.mdx` (example: `[Config](/configuration)`). -- When working with documentation, read the mintlify skill. -- Section cross-references: use anchors on root-relative paths (example: `[Hooks](/configuration#hooks)`). -- Doc headings and anchors: avoid em dashes and apostrophes in headings because they break Mintlify anchor links. -- When Peter asks for links, reply with full `https://docs.openclaw.ai/...` URLs (not root-relative). -- When you touch docs, end the reply with the `https://docs.openclaw.ai/...` URLs you referenced. -- README (GitHub): keep absolute docs URLs (`https://docs.openclaw.ai/...`) so links work on GitHub. -- Docs content must be generic: no personal device names/hostnames/paths; use placeholders like `user@gateway-host` and “gateway host”. - -## Docs i18n (zh-CN) - -- `docs/zh-CN/**` is generated; do not edit unless the user explicitly asks. -- Pipeline: update English docs → adjust glossary (`docs/.i18n/glossary.zh-CN.json`) → run `scripts/docs-i18n` → apply targeted fixes only if instructed. -- Translation memory: `docs/.i18n/zh-CN.tm.jsonl` (generated). -- See `docs/.i18n/README.md`. -- The pipeline can be slow/inefficient; if it’s dragging, ping @jospalmbier on Discord instead of hacking around it. - -## exe.dev VM ops (general) - -- Access: stable path is `ssh exe.dev` then `ssh vm-name` (assume SSH key already set). -- SSH flaky: use exe.dev web terminal or Shelley (web agent); keep a tmux session for long ops. -- Update: `sudo npm i -g openclaw@latest` (global install needs root on `/usr/lib/node_modules`). -- Config: use `openclaw config set ...`; ensure `gateway.mode=local` is set. -- Discord: store raw token only (no `DISCORD_BOT_TOKEN=` prefix). -- Restart: stop old gateway and run: - `pkill -9 -f openclaw-gateway || true; nohup openclaw gateway run --bind loopback --port 18789 --force > /tmp/openclaw-gateway.log 2>&1 &` -- Verify: `openclaw channels status --probe`, `ss -ltnp | rg 18789`, `tail -n 120 /tmp/openclaw-gateway.log`. - -## Build, Test, and Development Commands - -- Runtime baseline: Node **22+** (keep Node + Bun paths working). -- Install deps: `pnpm install` -- If deps are missing (for example `node_modules` missing, `vitest not found`, or `command not found`), run the repo’s package-manager install command (prefer lockfile/README-defined PM), then rerun the exact requested command once. Apply this to test/build/lint/typecheck/dev commands; if retry still fails, report the command and first actionable error. -- Pre-commit hooks: `prek install` (runs same checks as CI) -- Also supported: `bun install` (keep `pnpm-lock.yaml` + Bun patching in sync when touching deps/patches). -- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun ` / `bunx `. -- Run CLI in dev: `pnpm openclaw ...` (bun) or `pnpm dev`. -- Node remains supported for running built output (`dist/*`) and production installs. -- Mac packaging (dev): `scripts/package-mac-app.sh` defaults to current arch. Release checklist: `docs/platforms/mac/release.md`. -- Type-check/build: `pnpm build` -- TypeScript checks: `pnpm tsgo` -- Lint/format: `pnpm check` -- Format check: `pnpm format` (oxfmt --check) -- Format fix: `pnpm format:fix` (oxfmt --write) -- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage` - -## Coding Style & Naming Conventions - -- Language: TypeScript (ESM). Prefer strict typing; avoid `any`. -- Formatting/linting via Oxlint and Oxfmt; run `pnpm check` before commits. -- Never add `@ts-nocheck` and do not disable `no-explicit-any`; fix root causes and update Oxlint/Oxfmt config only when required. -- Never share class behavior via prototype mutation (`applyPrototypeMixins`, `Object.defineProperty` on `.prototype`, or exporting `Class.prototype` for merges). Use explicit inheritance/composition (`A extends B extends C`) or helper composition so TypeScript can typecheck. -- If this pattern is needed, stop and get explicit approval before shipping; default behavior is to split/refactor into an explicit class hierarchy and keep members strongly typed. -- In tests, prefer per-instance stubs over prototype mutation (`SomeClass.prototype.method = ...`) unless a test explicitly documents why prototype-level patching is required. -- Add brief code comments for tricky or non-obvious logic. -- Keep files concise; extract helpers instead of “V2” copies. Use existing patterns for CLI options and dependency injection via `createDefaultDeps`. -- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability. -- Naming: use **OpenClaw** for product/app/docs headings; use `openclaw` for CLI command, package/binary, paths, and config keys. - -## Release Channels (Naming) - -- stable: tagged releases only (e.g. `vYYYY.M.D`), npm dist-tag `latest`. -- beta: prerelease tags `vYYYY.M.D-beta.N`, npm dist-tag `beta` (may ship without macOS app). -- dev: moving head on `main` (no tag; git checkout main). - -## Testing Guidelines - -- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements). -- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`. -- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic. -- Do not set test workers above 16; tried already. -- Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (OpenClaw-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`. -- Full kit + what’s covered: `docs/testing.md`. -- Changelog: user-facing changes only; no internal/meta notes (version alignment, appcast reminders, release process). -- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one. -- Mobile: before using a simulator, check for connected real devices (iOS + Android) and prefer them when available. - -## Commit & Pull Request Guidelines - -**Full maintainer PR workflow (optional):** If you want the repo's end-to-end maintainer workflow (triage order, quality bar, rebase rules, commit/changelog conventions, co-contributor policy, and the `review-pr` > `prepare-pr` > `merge-pr` pipeline), see `.agents/skills/PR_WORKFLOW.md`. Maintainers may use other workflows; when a maintainer specifies a workflow, follow that. If no workflow is specified, default to PR_WORKFLOW. - -- Create commits with `scripts/committer "" `; avoid manual `git add`/`git commit` so staging stays scoped. -- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`). -- Group related changes; avoid bundling unrelated refactors. -- PR submission template (canonical): `.github/pull_request_template.md` -- Issue submission templates (canonical): `.github/ISSUE_TEMPLATE/` - -## Shorthand Commands - -- `sync`: if working tree is dirty, commit all changes (pick a sensible Conventional Commit message), then `git pull --rebase`; if rebase conflicts and cannot resolve, stop; otherwise `git push`. - -## Git Notes - -- If `git branch -d/-D ` is policy-blocked, delete the local ref directly: `git update-ref -d refs/heads/`. -- Bulk PR close/reopen safety: if a close action would affect more than 5 PRs, first ask for explicit user confirmation with the exact PR count and target scope/query. - -## Security & Configuration Tips - -- Web provider stores creds at `~/.openclaw/credentials/`; rerun `openclaw login` if logged out. -- Pi sessions live under `~/.openclaw/sessions/` by default; the base directory is not configurable. -- Environment variables: see `~/.profile`. -- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples. -- Release flow: always read `docs/reference/RELEASING.md` and `docs/platforms/mac/release.md` before any release work; do not ask routine questions once those docs answer them. - -## GHSA (Repo Advisory) Patch/Publish - -- Before reviewing security advisories, read `SECURITY.md`. -- Fetch: `gh api /repos/openclaw/openclaw/security-advisories/` -- Latest npm: `npm view openclaw version --userconfig "$(mktemp)"` -- Private fork PRs must be closed: - `fork=$(gh api /repos/openclaw/openclaw/security-advisories/ | jq -r .private_fork.full_name)` - `gh pr list -R "$fork" --state open` (must be empty) -- Description newline footgun: write Markdown via heredoc to `/tmp/ghsa.desc.md` (no `"\\n"` strings) -- Build patch JSON via jq: `jq -n --rawfile desc /tmp/ghsa.desc.md '{summary,severity,description:$desc,vulnerabilities:[...]}' > /tmp/ghsa.patch.json` -- GHSA API footgun: cannot set `severity` and `cvss_vector_string` in the same PATCH; do separate calls. -- Patch + publish: `gh api -X PATCH /repos/openclaw/openclaw/security-advisories/ --input /tmp/ghsa.patch.json` (publish = include `"state":"published"`; no `/publish` endpoint) -- If publish fails (HTTP 422): missing `severity`/`description`/`vulnerabilities[]`, or private fork has open PRs -- Verify: re-fetch; ensure `state=published`, `published_at` set; `jq -r .description | rg '\\\\n'` returns nothing - -## Troubleshooting - -- Rebrand/migration issues or legacy config/service warnings: run `openclaw doctor` (see `docs/gateway/doctor.md`). - -## Agent-Specific Notes - -- Vocabulary: "makeup" = "mac app". -- Never edit `node_modules` (global/Homebrew/npm/git installs too). Updates overwrite. Skill notes go in `tools.md` or `AGENTS.md`. -- When adding a new `AGENTS.md` anywhere in the repo, also add a `CLAUDE.md` symlink pointing to it (example: `ln -s AGENTS.md CLAUDE.md`). -- Signal: "update fly" => `fly ssh console -a flawd-bot -C "bash -lc 'cd /data/clawd/openclaw && git pull --rebase origin main'"` then `fly machines restart e825232f34d058 -a flawd-bot`. -- When working on a GitHub Issue or PR, print the full URL at the end of the task. -- When answering questions, respond with high-confidence answers only: verify in code; do not guess. -- Never update the Carbon dependency. -- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`). -- Patching dependencies (pnpm patches, overrides, or vendored changes) requires explicit approval; do not do this by default. -- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars. -- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes. -- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the OpenClaw Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep openclaw` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.** -- macOS logs: use `./scripts/clawlog.sh` to query unified logs for the OpenClaw subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`. -- If shared guardrails are available locally, review them; otherwise follow this repo's guidance. -- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code. -- Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync. -- Version locations: `package.json` (CLI), `apps/android/app/build.gradle.kts` (versionName/versionCode), `apps/ios/Sources/Info.plist` + `apps/ios/Tests/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `apps/macos/Sources/OpenClaw/Resources/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `docs/install/updating.md` (pinned npm version), `docs/platforms/mac/release.md` (APP_VERSION/APP_BUILD examples), Peekaboo Xcode projects/Info.plists (MARKETING_VERSION/CURRENT_PROJECT_VERSION). -- "Bump version everywhere" means all version locations above **except** `appcast.xml` (only touch appcast when cutting a new macOS Sparkle release). -- **Restart apps:** “restart iOS/Android apps” means rebuild (recompile/install) and relaunch, not just kill/launch. -- **Device checks:** before testing, verify connected real devices (iOS/Android) before reaching for simulators/emulators. -- iOS Team ID lookup: `security find-identity -p codesigning -v` → use Apple Development (…) TEAMID. Fallback: `defaults read com.apple.dt.Xcode IDEProvisioningTeamIdentifiers`. -- A2UI bundle hash: `src/canvas-host/a2ui/.bundle.hash` is auto-generated; ignore unexpected changes, and only regenerate via `pnpm canvas:a2ui:bundle` (or `scripts/bundle-a2ui.sh`) when needed. Commit the hash as a separate commit. -- Release signing/notary keys are managed outside the repo; follow internal release docs. -- Notary auth env vars (`APP_STORE_CONNECT_ISSUER_ID`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_API_KEY_P8`) are expected in your environment (per internal release docs). -- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless explicitly requested (this includes `git pull --rebase --autostash`). Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes. -- **Multi-agent safety:** when the user says "push", you may `git pull --rebase` to integrate latest changes (never discard other agents' work). When the user says "commit", scope to your changes only. When the user says "commit all", commit everything in grouped chunks. -- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless explicitly requested. -- **Multi-agent safety:** do **not** switch branches / check out a different branch unless explicitly requested. -- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session. -- **Multi-agent safety:** when you see unrecognized files, keep going; focus on your changes and commit only those. -- Lint/format churn: - - If staged+unstaged diffs are formatting-only, auto-resolve without asking. - - If commit/push already requested, auto-stage and include formatting-only follow-ups in the same commit (or a tiny follow-up commit if needed), no extra confirmation. - - Only ask when changes are semantic (logic/data/behavior). -- Lobster seam: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed. -- **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant. -- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause. -- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed). -- Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`. -- Tool schema guardrails: avoid raw `format` property names in tool schemas; some validators treat `format` as a reserved keyword and reject the schema. -- When asked to open a “session” file, open the Pi session logs under `~/.openclaw/agents//sessions/*.jsonl` (use the `agent=` value in the Runtime line of the system prompt; newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there. -- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac. -- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel. -- Voice wake forwarding tips: - - Command template should stay `openclaw-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes. - - launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`openclaw` binaries resolve when invoked via `openclaw-mac`. -- For manual `openclaw message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping. -- Release guardrails: do not change version numbers without operator’s explicit consent; always ask permission before running any npm publish/release step. - -## NPM + 1Password (publish/verify) - -- Use the 1password skill; all `op` commands must run inside a fresh tmux session. -- Sign in: `eval "$(op signin --account my.1password.com)"` (app unlocked + integration on). -- OTP: `op read 'op://Private/Npmjs/one-time password?attribute=otp'`. -- Publish: `npm publish --access public --otp=""` (run from the package dir). -- Verify without local npmrc side effects: `npm view version --userconfig "$(mktemp)"`. -- Kill the tmux session after publish. - -## Plugin Release Fast Path (no core `openclaw` publish) - -- Release only already-on-npm plugins. Source list is in `docs/reference/RELEASING.md` under "Current npm plugin list". -- Run all CLI `op` calls and `npm publish` inside tmux to avoid hangs/interruption: - - `tmux new -d -s release-plugins-$(date +%Y%m%d-%H%M%S)` - - `eval "$(op signin --account my.1password.com)"` -- 1Password helpers: - - password used by `npm login`: - `op item get Npmjs --format=json | jq -r '.fields[] | select(.id=="password").value'` - - OTP: - `op read 'op://Private/Npmjs/one-time password?attribute=otp'` -- Fast publish loop (local helper script in `/tmp` is fine; keep repo clean): - - compare local plugin `version` to `npm view version` - - only run `npm publish --access public --otp=""` when versions differ - - skip if package is missing on npm or version already matches. -- Keep `openclaw` untouched: never run publish from repo root unless explicitly requested. -- Post-check for each release: - - per-plugin: `npm view @openclaw/ version --userconfig "$(mktemp)"` should be `2026.2.17` - - core guard: `npm view openclaw version --userconfig "$(mktemp)"` should stay at previous version unless explicitly requested. - -## Changelog Release Notes - -- When cutting a mac release with beta GitHub prerelease: - - Tag `vYYYY.M.D-beta.N` from the release commit (example: `v2026.2.15-beta.1`). - - Create prerelease with title `openclaw YYYY.M.D-beta.N`. - - Use release notes from `CHANGELOG.md` version section (`Changes` + `Fixes`, no title duplicate). - - Attach at least `OpenClaw-YYYY.M.D.zip` and `OpenClaw-YYYY.M.D.dSYM.zip`; include `.dmg` if available. - -- Keep top version entries in `CHANGELOG.md` sorted by impact: - - `### Changes` first. - - `### Fixes` deduped and ranked with user-facing fixes first. -- Before tagging/publishing, run: - - `node --import tsx scripts/release-check.ts` - - `pnpm release:check` - - `pnpm test:install:smoke` or `OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke` for non-root smoke path. diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index c29a34c9bd4..00000000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,2585 +0,0 @@ -# Changelog - -Docs: https://docs.openclaw.ai - -## 2026.2.22 (Unreleased) - -### Changes - -- Channels/Config: unify channel preview streaming config handling with a shared resolver and canonical migration path. -- Discord/Allowlist: canonicalize resolved Discord allowlist names to IDs and split resolution flow for clearer fail-closed behavior. -- Memory/FTS: add Korean stop-word filtering and particle-aware keyword extraction (including mixed Korean/English stems) for query expansion in FTS-only search mode. (#18899) Thanks @ruypang. -- iOS/Talk: prefetch TTS segments and suppress expected speech-cancellation errors for smoother talk playback. (#22833) Thanks @ngutman. - -### Breaking - -- **BREAKING:** unify channel preview-streaming config to `channels..streaming` with enum values `off | partial | block | progress`, and move Slack native stream toggle to `channels.slack.nativeStreaming`. Legacy keys (`streamMode`, Slack boolean `streaming`) are still read and migrated by `openclaw doctor --fix`, but canonical saved config/docs now use the unified names. - -### Fixes - -- Telegram/WSL2: disable `autoSelectFamily` by default on WSL2 and memoize WSL2 detection in Telegram network decision logic to avoid repeated sync `/proc/version` probes on fetch/send paths. (#21916) Thanks @MizukiMachine. -- Telegram/Streaming: preserve archived draft preview mapping after flush and clean superseded reasoning preview bubbles so multi-message preview finals no longer cross-edit or orphan stale messages under send/rotation races. (#23202) Thanks @obviyus. -- Slack/Slash commands: preserve the Bolt app receiver when registering external select options handlers so monitor startup does not crash on runtimes that require bound `app.options` calls. (#23209) Thanks @0xgaia. -- Slack/Telegram slash sessions: await session metadata persistence before dispatch so first-turn native slash runs do not race session-origin metadata updates. (#23065) thanks @hydro13. -- Agents/Ollama: preserve unsafe integer tool-call arguments as exact strings during NDJSON parsing, preventing large numeric IDs from being rounded before tool execution. (#23170) Thanks @BestJoester. -- Cron/Gateway: keep `cron.list` and `cron.status` responsive during startup catch-up by avoiding a long-held cron lock while missed jobs execute. (#23106) Thanks @jayleekr. -- Gateway/Config reload: compare array-valued config paths structurally during diffing so unchanged `memory.qmd.paths` and `memory.qmd.scope.rules` no longer trigger false restart-required reloads. (#23185) Thanks @rex05ai. -- Cron/Scheduling: validate runtime cron expressions before schedule/stagger evaluation so malformed persisted jobs report a clear `invalid cron schedule: expr is required` error instead of crashing with `undefined.trim` failures and auto-disable churn. (#23223) Thanks @asimons81. -- Memory/QMD: migrate legacy unscoped collection bindings (for example `memory-root`) to per-agent scoped names (for example `memory-root-main`) during startup when safe, so QMD-backed `memory_search` no longer fails with `Collection not found` after upgrades. (#23228, #20727) Thanks @JLDynamics and @AaronFaby. -- TUI/Input: enable multiline-paste burst coalescing on macOS Terminal.app and iTerm so pasted blocks no longer submit line-by-line as separate messages. (#18809) Thanks @fwends. -- TUI/Status: request immediate renders after setting `sending`/`waiting` activity states so in-flight runs always show visible progress indicators instead of appearing idle until completion. (#21549) Thanks @13Guinness. -- Agents/Fallbacks: treat JSON payloads with `type: "api_error"` + `"Internal server error"` as transient failover errors so Anthropic 500-style failures trigger model fallback. (#23193) Thanks @jarvis-lane. -- Agents/Diagnostics: include resolved lifecycle error text in `embedded run agent end` warnings so UI/TUI “Connection error” runs expose actionable provider failure reasons in gateway logs. (#23054) Thanks @Raize. -- Gateway/Pairing: treat operator.admin pairing tokens as satisfying operator.write requests so legacy devices stop looping through scope-upgrade prompts introduced in 2026.2.19. (#23125, #23006) Thanks @vignesh07. -- Gateway/Pairing: treat `operator.admin` as satisfying other `operator.*` scope checks during device-auth verification so local CLI/TUI sessions stop entering pairing-required loops for pairing/approval-scoped commands. (#22062, #22193, #21191) Thanks @Botaccess, @jhartshorn, and @ctbritt. -- Gateway/Pairing: preserve existing approved token scopes when processing repair pairings that omit `scopes`, preventing empty-scope token regressions on reconnecting clients. (#21906) Thanks @paki81. -- Plugins/CLI: make `openclaw plugins enable` and plugin install/link flows update allowlists via shared plugin-enable policy so enabled plugins are not left disabled by allowlist mismatch. (#23190) Thanks @downwind7clawd-ctrl. -- Memory/QMD: add optional `memory.qmd.mcporter` search routing so QMD `query/search/vsearch` can run through mcporter keep-alive flows (including multi-collection paths) to reduce cold starts, while keeping searches on agent-scoped QMD state for consistent recall. (#19617) Thanks @nicole-luxe and @vignesh07. -- Chat/UI: strip inline reply/audio directive tags (`[[reply_to_current]]`, `[[reply_to:]]`, `[[audio_as_voice]]`) from displayed chat history, live chat event output, and session preview snippets so control tags no longer leak into user-visible surfaces. -- BlueBubbles/DM history: restore DM backfill context with account-scoped rolling history, bounded backfill retries, and safer history payload limits. (#20302) Thanks @Ryan-Haines. -- Security/Config: block prototype-key traversal during config merge patch and legacy migration merge helpers (`__proto__`, `constructor`, `prototype`) to prevent prototype pollution during config mutation flows. (#22968) Thanks @Clawborn. -- Security/Shell env: validate login-shell executable paths for shell-env fallback (`/etc/shells` + trusted prefixes) and block `SHELL` in dangerous env override policy paths so untrusted shell-path injection falls back safely to `/bin/sh`. Thanks @athuljayaram for reporting. -- Security/Config: make parsed chat allowlist checks fail closed when `allowFrom` is empty, restoring expected DM/pairing gating. -- Security/Exec: in non-default setups that manually add `sort` to `tools.exec.safeBins`, block `sort --compress-program` so allowlist-mode safe-bin checks cannot bypass approval. Thanks @tdjackey for reporting. -- Security/Exec approvals: when users choose `allow-always` for shell-wrapper commands (for example `/bin/zsh -lc ...`), persist allowlist patterns for the inner executable(s) instead of the wrapper shell binary, preventing accidental broad shell allowlisting in moderate mode. (#23276) Thanks @xrom2863. -- Security/macOS app beta: enforce path-only `system.run` allowlist matching (drop basename matches like `echo`), migrate legacy basename entries to last resolved paths when available, and harden shell-chain handling to fail closed on unsafe parse/control syntax (including quoted command substitution/backticks). This is an optional allowlist-mode feature; default installs remain deny-by-default. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/SSRF: expand IPv4 fetch guard blocking to include RFC special-use/non-global ranges (including benchmarking, TEST-NET, multicast, and reserved/broadcast blocks), and centralize range checks into a single CIDR policy table to reduce classifier drift. -- Security/Archive: block zip symlink escapes during archive extraction. -- Security/Media sandbox: keep tmp media allowance for absolute tmp paths only and enforce symlink-escape checks before sandbox-validated reads, preventing tmp symlink exfiltration and relative `../` sandbox escapes when sandboxes live under tmp. (#17892) Thanks @dashed. -- Security/Discord: add `openclaw security audit` warnings for name/tag-based Discord allowlist entries (DM allowlists, guild/channel `users`, and pairing-store entries), highlighting slug-collision risk while keeping name-based matching supported, and canonicalize resolved Discord allowlist names to IDs at runtime without rewriting config files. Thanks @tdjackey for reporting. -- Security/Gateway: block node-role connections when device identity metadata is missing. -- Security/Media: enforce inbound media byte limits during download/read across Discord, Telegram, Zalo, Microsoft Teams, and BlueBubbles to prevent oversized payload memory spikes before rejection. This ships in the next npm release. Thanks @tdjackey for reporting. -- Media/Understanding: preserve `application/pdf` MIME classification during text-like file heuristics so PDF uploads use PDF extraction paths instead of being inlined as raw text. (#23191) Thanks @claudeplay2026-byte. -- Security/Control UI: block symlink-based out-of-root static file reads by enforcing realpath containment and file-identity checks when serving Control UI assets and SPA fallback `index.html`. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/MSTeams media: enforce allowlist checks for SharePoint reference attachment URLs and redirect targets during Graph-backed media fetches so redirect chains cannot escape configured media host boundaries. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/macOS discovery: fail closed for unresolved discovery endpoints by clearing stale remote selection values, use resolved service host only for SSH target derivation, and keep remote URL config aligned with resolved endpoint availability. (#21618) Thanks @bmendonca3. -- Chat/Usage/TUI: strip synthetic inbound metadata blocks (including `Conversation info` and trailing `Untrusted context` channel metadata wrappers) from displayed conversation history so internal prompt context no longer leaks into user-visible logs. -- CI/Tests: fix TypeScript case-table typing and lint assertion regressions so `pnpm check` passes again after Synology Chat landing. (#23012) Thanks @druide67. -- Security/Browser relay: harden extension relay auth token handling for `/extension` and `/cdp` pathways. -- Cron: persist `delivered` state in cron job records so delivery failures remain visible in status and logs. (#19174) Thanks @simonemacario. -- Config/Doctor: only repair the OAuth credentials directory when affected channels are configured, avoiding fresh-install noise. -- Usage/Pricing: correct MiniMax M2.5 pricing defaults to fix inflated cost reporting. (#22755) Thanks @miloudbelarebia. -- Gateway/Daemon: verify gateway health after daemon restart. -- Agents/UI text: stop rewriting normal assistant billing/payment language outside explicit error contexts. (#17834) Thanks @niceysam. - -## 2026.2.21 - -### Changes - -- Models/Google: add Gemini 3.1 support (`google/gemini-3.1-pro-preview`). -- Providers/Onboarding: add Volcano Engine (Doubao) and BytePlus providers/models (including coding variants), wire onboarding auth choices for interactive + non-interactive flows, and align docs to `volcengine-api-key`. (#7967) Thanks @funmore123. -- Channels/CLI: add per-account/channel `defaultTo` outbound routing fallback so `openclaw agent --deliver` can send without explicit `--reply-to` when a default target is configured. (#16985) Thanks @KirillShchetinin. -- Channels: allow per-channel model overrides via `channels.modelByChannel` and note them in /status. Thanks @thewilloftheshadow. -- Telegram/Streaming: simplify preview streaming config to `channels.telegram.streaming` (boolean), auto-map legacy `streamMode` values, and remove block-vs-partial preview branching. (#22012) thanks @obviyus. -- Discord/Streaming: add stream preview mode for live draft replies with partial/block options and configurable chunking. Thanks @thewilloftheshadow. Inspiration @neoagentic-ship-it. -- Discord/Telegram: add configurable lifecycle status reactions for queued/thinking/tool/done/error phases with a shared controller and emoji/timing overrides. Thanks @wolly-tundracube and @thewilloftheshadow. -- Discord/Voice: add voice channel join/leave/status via `/vc`, plus auto-join configuration for realtime voice conversations. Thanks @thewilloftheshadow. -- Discord: add configurable ephemeral defaults for slash-command responses. (#16563) Thanks @wei. -- Discord: support updating forum `available_tags` via channel edit actions for forum tag management. (#12070) Thanks @xiaoyaner0201. -- Discord: include channel topics in trusted inbound metadata on new sessions. Thanks @thewilloftheshadow. -- Discord/Subagents: add thread-bound subagent sessions on Discord with per-thread focus/list controls and thread-bound continuation routing for spawned helper agents. (#21805) Thanks @onutc. -- iOS/Chat: clean chat UI noise by stripping inbound untrusted metadata/timestamp prefixes, formatting tool outputs into concise summaries/errors, compacting the composer while typing, and supporting tap-to-dismiss keyboard in chat view. (#22122) thanks @mbelinky. -- iOS/Watch: bridge mirrored watch prompt notification actions into iOS quick-reply handling, including queued action handoff until app model initialization. (#22123) thanks @mbelinky. -- iOS/Gateway: stabilize background wake and reconnect behavior with background reconnect suppression/lease windows, BGAppRefresh wake fallback, location wake hook throttling, and APNs wake retry+nudge instrumentation. (#21226) thanks @mbelinky. -- Auto-reply/UI: add model fallback lifecycle visibility in verbose logs, /status active-model context with fallback reason, and cohesive WebUI fallback indicators. (#20704) Thanks @joshavant. -- MSTeams: dedupe sent-message cache storage by removing duplicate per-message Set storage and using timestamps Map keys as the single membership source. (#22514) Thanks @TaKO8Ki. -- Agents/Subagents: default subagent spawn depth now uses shared `maxSpawnDepth=2`, enabling depth-1 orchestrator spawning by default while keeping depth policy checks consistent across spawn and prompt paths. (#22223) Thanks @tyler6204. -- Security/Agents: make owner-ID obfuscation use a dedicated HMAC secret from configuration (`ownerDisplaySecret`) and update hashing behavior so obfuscation is decoupled from gateway token handling for improved control. (#7343) Thanks @vincentkoc. -- Security/Infra: switch gateway lock and tool-call synthetic IDs from SHA-1 to SHA-256 with unchanged truncation length to strengthen hash basis while keeping deterministic behavior and lock key format. (#7343) Thanks @vincentkoc. -- Dependencies/Tooling: add non-blocking dead-code scans in CI via Knip/ts-prune/ts-unused-exports to surface unused dependencies and exports earlier. (#22468) Thanks @vincentkoc. -- Dependencies/Unused Dependencies: remove or scope unused root and extension deps (`@larksuiteoapi/node-sdk`, `signal-utils`, `ollama`, `lit`, `@lit/context`, `@lit-labs/signals`, `@microsoft/agents-hosting-express`, `@microsoft/agents-hosting-extensions-teams`, and plugin-local `openclaw` devDeps in `extensions/open-prose`, `extensions/lobster`, and `extensions/llm-task`). (#22471, #22495) Thanks @vincentkoc. -- Dependencies/A2UI: harden dependency resolution after root cleanup (resolve `lit`, `@lit/context`, `@lit-labs/signals`, and `signal-utils` from workspace/root) and simplify bundling fallback behavior, including `pnpm dlx rolldown` compatibility. (#22481, #22507) Thanks @vincentkoc. - -### Fixes - -- Security/Agents: cap embedded Pi runner outer retry loop with a higher profile-aware dynamic limit (32-160 attempts) and return an explicit `retry_limit` error payload when retries never converge, preventing unbounded internal retry cycles (`GHSA-76m6-pj3w-v7mf`). -- Telegram: detect duplicate bot-token ownership across Telegram accounts at startup/status time, mark secondary accounts as not configured with an explicit fix message, and block duplicate account startup before polling to avoid endless `getUpdates` conflict loops. -- Agents/Tool images: include source filenames in `agents/tool-images` resize logs so compression events can be traced back to specific files. -- Providers/OAuth: harden Qwen and Chutes refresh handling by validating refresh response expiry values and preserving prior refresh tokens when providers return empty refresh token fields, with regression coverage for empty-token responses. -- Models/Kimi-Coding: add missing implicit provider template for `kimi-coding` with correct `anthropic-messages` API type and base URL, fixing 403 errors when using Kimi for Coding. (#22409) -- Auto-reply/Tools: forward `senderIsOwner` through embedded queued/followup runner params so owner-only tools remain available for authorized senders. (#22296) thanks @hcoj. -- Discord: restore model picker back navigation when a provider is missing and document the Discord picker flow. (#21458) Thanks @pejmanjohn and @thewilloftheshadow. -- Memory/QMD: respect per-agent `memorySearch.enabled=false` during gateway QMD startup initialization, split multi-collection QMD searches into per-collection queries (`search`/`vsearch`/`query`) to avoid sparse-term drops, prefer collection-hinted doc resolution to avoid stale-hash collisions, retry boot updates on transient lock/timeout failures, skip `qmd embed` in BM25-only `search` mode (including `memory index --force`), and serialize embed runs globally with failure backoff to prevent CPU storms on multi-agent hosts. (#20581, #21590, #20513, #20001, #21266, #21583, #20346, #19493) Thanks @danielrevivo, @zanderkrause, @sunyan034-cmd, @tilleulenspiegel, @dae-oss, @adamlongcreativellc, @jonathanadams96, and @kiliansitel. -- Memory/Builtin: prevent automatic sync races with manager shutdown by skipping post-close sync starts and waiting for in-flight sync before closing SQLite, so `onSearch`/`onSessionStart` no longer fail with `database is not open` in ephemeral CLI flows. (#20556, #7464) Thanks @FuzzyTG and @henrybottter. -- Providers/Copilot: drop persisted assistant `thinking` blocks for Claude models (while preserving turn structure/tool blocks) so follow-up requests no longer fail on invalid `thinkingSignature` payloads. (#19459) Thanks @jackheuberger. -- Providers/Copilot: add `claude-sonnet-4.6` and `claude-sonnet-4.5` to the default GitHub Copilot model catalog and add coverage for model-list/definition helpers. (#20270, fixes #20091) Thanks @Clawborn. -- Auto-reply/WebChat: avoid defaulting inbound runtime channel labels to unrelated providers (for example `whatsapp`) for webchat sessions so channel-specific formatting guidance stays accurate. (#21534) Thanks @lbo728. -- Status: include persisted `cacheRead`/`cacheWrite` in session summaries so compact `/status` output consistently shows cache hit percentages from real session data. -- Heartbeat/Cron: restore interval heartbeat behavior so missing `HEARTBEAT.md` no longer suppresses runs (only effectively empty files skip), preserving prompt-driven and tagged-cron execution paths. -- WhatsApp/Cron/Heartbeat: enforce allowlisted routing for implicit scheduled/system delivery by merging pairing-store + configured `allowFrom` recipients, selecting authorized recipients when last-route context points to a non-allowlisted chat, and preventing heartbeat fan-out to recent unauthorized chats. -- Heartbeat/Active hours: constrain active-hours `24` sentinel parsing to `24:00` in time validation so invalid values like `24:30` are rejected early. (#21410) thanks @adhitShet. -- Heartbeat: treat `activeHours` windows with identical `start`/`end` times as zero-width (always outside the window) instead of always-active. (#21408) thanks @adhitShet. -- CLI/Pairing: default `pairing list` and `pairing approve` to the sole available pairing channel when omitted, so TUI-only setups can recover from `pairing required` without guessing channel arguments. (#21527) Thanks @losts1. -- TUI/Pairing: show explicit pairing-required recovery guidance after gateway disconnects that return `pairing required`, including approval steps to unblock quickstart TUI hatching on fresh installs. (#21841) Thanks @nicolinux. -- TUI/Input: suppress duplicate backspace events arriving in the same input burst window so SSH sessions no longer delete two characters per backspace press in the composer. (#19318) Thanks @eheimer. -- TUI/Models: scope `models.list` to the configured model allowlist (`agents.defaults.models`) so `/model` picker no longer floods with unrelated catalog entries by default. (#18816) Thanks @fwends. -- TUI/Heartbeat: suppress heartbeat ACK/prompt noise in chat streaming when `showOk` is disabled, while still preserving non-ACK heartbeat alerts in final output. (#20228) Thanks @bhalliburton. -- TUI/History: cap chat-log component growth and prune stale render nodes/references so large default history loads no longer overflow render recursion with `RangeError: Maximum call stack size exceeded`. (#18068) Thanks @JaniJegoroff. -- Memory/QMD: diversify mixed-source search ranking when both session and memory collections are present so session transcript hits no longer crowd out durable memory-file matches in top results. (#19913) Thanks @alextempr. -- Memory/Tools: return explicit `unavailable` warnings/actions from `memory_search` when embedding/provider failures occur (including quota exhaustion), so disabled memory does not look like an empty recall result. (#21894) Thanks @XBS9. -- Session/Startup: require the `/new` and `/reset` greeting path to run Session Startup file-reading instructions before responding, so daily memory startup context is not skipped on fresh-session greetings. (#22338) Thanks @armstrong-pv. -- Auth/Onboarding: align OAuth profile-id config mapping with stored credential IDs for OpenAI Codex and Chutes flows, preventing `provider:default` mismatches when OAuth returns email-scoped credentials. (#12692) thanks @mudrii. -- Provider/HTTP: treat HTTP 503 as failover-eligible for LLM provider errors. (#21086) Thanks @Protocol-zero-0. -- Slack: pass `recipient_team_id` / `recipient_user_id` through Slack native streaming calls so `chat.startStream`/`appendStream`/`stopStream` work reliably across DMs and Slack Connect setups, and disable block streaming when native streaming is active. (#20988) Thanks @Dithilli. Earlier recipient-ID groundwork was contributed in #20377 by @AsserAl1012. -- CLI/Config: add canonical `--strict-json` parsing for `config set` and keep `--json` as a legacy alias to reduce help/behavior drift. (#21332) thanks @adhitShet. -- CLI/Config: preserve explicitly unset config paths in persisted JSON after writes so `openclaw config unset ` no longer re-introduces defaulted keys (for example `commands.ownerDisplay`) through schema normalization. (#22984) Thanks @aronchick. -- CLI: keep `openclaw -v` as a root-only version alias so subcommand `-v, --verbose` flags (for example ACP/hooks/skills) are no longer intercepted globally. (#21303) thanks @adhitShet. -- Memory: return empty snippets when `memory_get`/QMD read files that have not been created yet, and harden memory indexing/session helpers against ENOENT races so missing Markdown no longer crashes tools. (#20680) Thanks @pahdo. -- Telegram/Streaming: always clean up draft previews even when dispatch throws before fallback handling, preventing orphaned preview messages during failed runs. (#19041) thanks @mudrii. -- Telegram/Streaming: split reasoning and answer draft preview lanes to prevent cross-lane overwrites, and ignore literal `` tags inside inline/fenced code snippets so sample markup is not misrouted as reasoning. (#20774) Thanks @obviyus. -- Telegram/Streaming: restore 30-char first-preview debounce and scope `NO_REPLY` prefix suppression to partial sentinel fragments so normal `No...` text is not filtered. (#22613) thanks @obviyus. -- Telegram/Status reactions: refresh stall timers on repeated phase updates and honor ack-reaction scope when lifecycle reactions are enabled, preventing false stall emojis and unwanted group reactions. Thanks @wolly-tundracube and @thewilloftheshadow. -- Telegram/Status reactions: keep lifecycle reactions active when available-reactions lookup fails by falling back to unrestricted variant selection instead of suppressing reaction updates. (#22380) thanks @obviyus. -- Discord/Events: await `DiscordMessageListener` message handlers so regular `MESSAGE_CREATE` traffic is processed through queue ordering/timeout flow instead of fire-and-forget drops. (#22396) Thanks @sIlENtbuffER. -- Discord/Streaming: apply `replyToMode: first` only to the first Discord chunk so block-streamed replies do not spam mention pings. (#20726) Thanks @thewilloftheshadow for the report. -- Discord/Components: map DM channel targets back to user-scoped component sessions so button/select interactions stay in the main DM session. Thanks @thewilloftheshadow. -- Discord/Allowlist: lazy-load guild lists when resolving Discord user allowlists so ID-only entries resolve even if guild fetch fails. (#20208) Thanks @zhangjunmengyang. -- Discord/Gateway: handle close code 4014 (missing privileged gateway intents) without crashing the gateway. Thanks @thewilloftheshadow. -- Discord: ingest inbound stickers as media so sticker-only messages and forwarded stickers are visible to agents. Thanks @thewilloftheshadow. -- Auto-reply/Runner: emit `onAgentRunStart` only after agent lifecycle or tool activity begins (and only once per run), so fallback preflight errors no longer mark runs as started. (#21165) Thanks @shakkernerd. -- Auto-reply/Tool results: serialize tool-result delivery and keep the delivery chain progressing after individual failures so concurrent tool outputs preserve user-visible ordering. (#21231) thanks @ahdernasr. -- Auto-reply/Prompt caching: restore prefix-cache stability by keeping inbound system metadata session-stable and moving per-message IDs (`message_id`, `message_id_full`, `reply_to_id`, `sender_id`) into untrusted conversation context. (#20597) Thanks @anisoptera. -- iOS/Watch: add actionable watch approval/reject controls and quick-reply actions so watch-originated approvals and responses can be sent directly from notification flows. (#21996) Thanks @mbelinky. -- iOS/Watch: refresh iOS and watch app icon assets with the lobster icon set to keep phone/watch branding aligned. (#21997) Thanks @mbelinky. -- CLI/Onboarding: fix Anthropic-compatible custom provider verification by normalizing base URLs to avoid duplicate `/v1` paths during setup checks. (#21336) Thanks @17jmumford. -- iOS/Gateway/Tools: prefer uniquely connected node matches when duplicate display names exist, surface actionable `nodes invoke` pairing-required guidance with request IDs, and refresh active iOS gateway registration after location-capability setting changes so capability updates apply immediately. (#22120) thanks @mbelinky. -- Gateway/Auth: require `gateway.trustedProxies` to include a loopback proxy address when `auth.mode="trusted-proxy"` and `bind="loopback"`, preventing same-host proxy misconfiguration from silently blocking auth. (#22082, follow-up to #20097) thanks @mbelinky. -- Gateway/Auth: allow trusted-proxy mode with loopback bind for same-host reverse-proxy deployments, while still requiring configured `gateway.trustedProxies`. (#20097) thanks @xinhuagu. -- Gateway/Auth: allow authenticated clients across roles/scopes to call `health` while preserving role and scope enforcement for non-health methods. (#19699) thanks @Nachx639. -- Gateway/Hooks: include transform export name in hook-transform cache keys so distinct exports from the same module do not reuse the wrong cached transform function. (#13855) thanks @mcaxtr. -- Gateway/Control UI: return 404 for missing static-asset paths instead of serving SPA fallback HTML, while preserving client-route fallback behavior for extensionless and non-asset dotted paths. (#12060) thanks @mcaxtr. -- Gateway/Pairing: prevent device-token rotate scope escalation by enforcing an approved-scope baseline, preserving approved scopes across metadata updates, and rejecting rotate requests that exceed approved role scope implications. (#20703) thanks @coygeek. -- Gateway/Pairing: clear persisted paired-device state when the gateway client closes with `device token mismatch` (`1008`) so reconnect flows can cleanly re-enter pairing. (#22071) Thanks @mbelinky. -- Gateway/Config: allow `gateway.customBindHost` in strict config validation when `gateway.bind="custom"` so valid custom bind-host configurations no longer fail startup. (#20318, fixes #20289) Thanks @MisterGuy420. -- Gateway/Pairing: tolerate legacy paired devices missing `roles`/`scopes` metadata in websocket upgrade checks and backfill metadata on reconnect. (#21447, fixes #21236) Thanks @joshavant. -- Gateway/Pairing/CLI: align read-scope compatibility in pairing/device-token checks and add local `openclaw devices` fallback recovery for loopback `pairing required` deadlocks, with explicit fallback notice to unblock approval bootstrap flows. (#21616) Thanks @shakkernerd. -- Cron: honor `cron.maxConcurrentRuns` in the timer loop so due jobs can execute up to the configured parallelism instead of always running serially. (#11595) Thanks @Takhoffman. -- Agents/Compaction: restore embedded compaction safeguard/context-pruning extension loading in production by wiring bundled extension factories into the resource loader instead of runtime file-path resolution. (#22349) Thanks @Glucksberg. -- Agents/Subagents: restore announce-chain delivery to agent injection, defer nested announce output until descendant follow-up content is ready, and prevent descendant deferrals from consuming announce retry budget so deep chains do not drop final completions. (#22223) Thanks @tyler6204. -- Agents/System Prompt: label allowlisted senders as authorized senders to avoid implying ownership. Thanks @thewilloftheshadow. -- Agents/Tool display: fix exec cwd suffix inference so `pushd ... && popd ... && ` does not keep stale `(in )` context in summaries. (#21925) Thanks @Lukavyi. -- Agents/Google: flatten residual nested `anyOf`/`oneOf` unions in Gemini tool-schema cleanup so Cloud Code Assist no longer rejects unsupported union keywords that survive earlier simplification. (#22825) Thanks @Oceanswave. -- Tools/web_search: handle xAI Responses API payloads that emit top-level `output_text` blocks (without a `message` wrapper) so Grok web_search no longer returns `No response` for those results. (#20508) Thanks @echoVic. -- Agents/Failover: treat non-default override runs as direct fallback-to-configured-primary (skip configured fallback chain), normalize default-model detection for provider casing/whitespace, and add regression coverage for override/auth error paths. (#18820) Thanks @Glucksberg. -- Docker/Build: include `ownerDisplay` in `CommandsSchema` object-level defaults so Docker `pnpm build` no longer fails with `TS2769` during plugin SDK d.ts generation. (#22558) Thanks @obviyus. -- Docker/Browser: install Playwright Chromium into `/home/node/.cache/ms-playwright` and set `node:node` ownership so browser binaries are available to the runtime user in browser-enabled images. (#22585) thanks @obviyus. -- Hooks/Session memory: trigger bundled `session-memory` persistence on both `/new` and `/reset` so reset flows no longer skip markdown transcript capture before archival. (#21382) Thanks @mofesolapaul. -- Dependencies/Agents: bump embedded Pi SDK packages (`@mariozechner/pi-agent-core`, `@mariozechner/pi-ai`, `@mariozechner/pi-coding-agent`, `@mariozechner/pi-tui`) to `0.54.0`. (#21578) Thanks @Takhoffman. -- Config/Agents: expose Pi compaction tuning values `agents.defaults.compaction.reserveTokens` and `agents.defaults.compaction.keepRecentTokens` in config schema/types and apply them in embedded Pi runner settings overrides with floor enforcement via `reserveTokensFloor`. (#21568) Thanks @Takhoffman. -- Docker: pin base images to SHA256 digests in Docker builds to prevent mutable tag drift. (#7734) Thanks @coygeek. -- Docker: run build steps as the `node` user and use `COPY --chown` to avoid recursive ownership changes, trimming image size and layer churn. Thanks @huntharo. -- Config/Memory: restore schema help/label metadata for hybrid `mmr` and `temporalDecay` settings so configuration surfaces show correct names and guidance. (#18786) Thanks @rodrigouroz. -- Skills/SonosCLI: add troubleshooting guidance for `sonos discover` failures on macOS direct mode (`sendto: no route to host`) and sandbox network restrictions (`bind: operation not permitted`). (#21316) Thanks @huntharo. -- macOS/Build: default release packaging to `BUNDLE_ID=ai.openclaw.mac` in `scripts/package-mac-dist.sh`, so Sparkle feed URL is retained and auto-update no longer fails with an empty appcast feed. (#19750) thanks @loganprit. -- Signal/Outbound: preserve case for Base64 group IDs during outbound target normalization so cross-context routing and policy checks no longer break when group IDs include uppercase characters. (#5578) Thanks @heyhudson. -- Anthropic/Agents: preserve required pi-ai default OAuth beta headers when `context1m` injects `anthropic-beta`, preventing 401 auth failures for `sk-ant-oat-*` tokens. (#19789, fixes #19769) Thanks @minupla. -- Security/Exec: block unquoted heredoc body expansion tokens in shell allowlist analysis, reject unterminated heredocs, and require explicit approval for allowlisted heredoc execution on gateway hosts to prevent heredoc substitution allowlist bypass. Thanks @torturado for reporting. -- macOS/Security: evaluate `system.run` allowlists per shell segment in macOS node runtime and companion exec host (including chained shell operators), fail closed on shell/process substitution parsing, and require explicit approval on unsafe parse cases to prevent allowlist bypass via `rawCommand` chaining. Thanks @tdjackey for reporting. -- WhatsApp/Security: enforce allowlist JID authorization for reaction actions so authenticated callers cannot target non-allowlisted chats by forging `chatJid` + valid `messageId` pairs. Thanks @aether-ai-agent for reporting. -- ACP/Security: escape control and delimiter characters in ACP `resource_link` title/URI metadata before prompt interpolation to prevent metadata-driven prompt injection through resource links. Thanks @aether-ai-agent for reporting. -- TTS/Security: make model-driven provider switching opt-in by default (`messages.tts.modelOverrides.allowProvider=false` unless explicitly enabled), while keeping voice/style overrides available, to reduce prompt-injection-driven provider hops and unexpected TTS cost escalation. Thanks @aether-ai-agent for reporting. -- Security/Agents: keep overflow compaction retry budgeting global across tool-result truncation recovery so successful truncation cannot reset the overflow retry counter and amplify retry/cost cycles. Thanks @aether-ai-agent for reporting. -- BlueBubbles/Security: require webhook token authentication for all BlueBubbles webhook requests (including loopback/proxied setups), removing passwordless webhook fallback behavior. Thanks @zpbrent. -- iOS/Security: force `https://` for non-loopback manual gateway hosts during iOS onboarding to block insecure remote transport URLs. (#21969) Thanks @mbelinky. -- Gateway/Security: remove shared-IP fallback for canvas endpoints and require token or session capability for canvas access. Thanks @thewilloftheshadow. -- Gateway/Security: require secure context and paired-device checks for Control UI auth even when `gateway.controlUi.allowInsecureAuth` is set, and align audit messaging with the hardened behavior. (#20684) Thanks @coygeek and @Vasco0x4 for reporting. -- Gateway/Security: scope tokenless Tailscale forwarded-header auth to Control UI websocket auth only, so HTTP gateway routes still require token/password even on trusted hosts. Thanks @zpbrent for reporting. -- Docker/Security: run E2E and install-sh test images as non-root by adding appuser directives. Thanks @thewilloftheshadow. -- Skills/Security: sanitize skill env overrides to block unsafe runtime injection variables and only allow sensitive keys when declared in skill metadata, with warnings for suspicious values. Thanks @thewilloftheshadow. -- Security/Commands: block prototype-key injection in runtime `/debug` overrides and require own-property checks for gated command flags (`bash`, `config`, `debug`) so inherited prototype values cannot enable privileged commands. Thanks @tdjackey for reporting. -- Security/Browser: block non-network browser navigation protocols (including `file:`, `data:`, and `javascript:`) while preserving `about:blank`, preventing local file reads via browser tool navigation. Thanks @q1uf3ng for reporting. -- Security/Exec: block shell startup-file env injection (`BASH_ENV`, `ENV`, `BASH_FUNC_*`, `LD_*`, `DYLD_*`) across config env ingestion, node-host inherited environment sanitization, and macOS exec host runtime to prevent pre-command execution from attacker-controlled environment variables. Thanks @tdjackey. -- Security/Exec (Windows): canonicalize `cmd.exe /c` command text across validation, approval binding, and audit/event rendering to prevent trailing-argument approval mismatches in `system.run`. Thanks @tdjackey for reporting. -- Security/Gateway/Hooks: block `__proto__`, `constructor`, and `prototype` traversal in webhook template path resolution to prevent prototype-chain payload data leakage in `messageTemplate` rendering. (#22213) Thanks @SleuthCo. -- Security/OpenClawKit/UI: prevent injected inbound user context metadata blocks from leaking into chat history in TUI, webchat, and macOS surfaces by stripping all untrusted metadata prefixes at display boundaries. (#22142) Thanks @Mellowambience, @vincentkoc. -- Security/OpenClawKit/UI: strip inbound metadata blocks from user messages in TUI rendering while preserving user-authored content. (#22345) Thanks @kansodata, @vincentkoc. -- Security/OpenClawKit/UI: prevent inbound metadata leaks and reply-tag streaming artifacts in TUI rendering by stripping untrusted metadata prefixes at display boundaries. (#22346) Thanks @akramcodez, @vincentkoc. -- Security/Agents: restrict local MEDIA tool attachments to core tools and the OpenClaw temp root to prevent untrusted MCP tool file exfiltration. Thanks @NucleiAv and @thewilloftheshadow. -- Security/Net: strip sensitive headers (`Authorization`, `Proxy-Authorization`, `Cookie`, `Cookie2`) on cross-origin redirects in `fetchWithSsrFGuard` to prevent credential forwarding across origin boundaries. (#20313) Thanks @afurm. -- Security/Systemd: reject CR/LF in systemd unit environment values and fix argument escaping so generated units cannot be injected with extra directives. Thanks @thewilloftheshadow. -- Security/Tools: add per-wrapper random IDs to untrusted-content markers from `wrapExternalContent`/`wrapWebContent`, preventing marker spoofing from escaping content boundaries. (#19009) Thanks @Whoaa512. -- Shared/Security: reject insecure deep links that use `ws://` non-loopback gateway URLs to prevent plaintext remote websocket configuration. (#21970) Thanks @mbelinky. -- macOS/Security: reject non-loopback `ws://` remote gateway URLs in macOS remote config to block insecure plaintext websocket endpoints. (#21971) Thanks @mbelinky. -- Browser/Security: block upload path symlink escapes so browser upload sources cannot traverse outside the allowed workspace via symlinked paths. (#21972) Thanks @mbelinky. -- Security/Dependencies: bump transitive `hono` usage to `4.11.10` to incorporate timing-safe authentication comparison hardening for `basicAuth`/`bearerAuth` (`GHSA-gq3j-xvxp-8hrf`). Thanks @vincentkoc. -- Security/Gateway: parse `X-Forwarded-For` with trust-preserving semantics when requests come from configured trusted proxies, preventing proxy-chain spoofing from influencing client IP classification and rate-limit identity. Thanks @AnthonyDiSanti and @vincentkoc. -- Security/Sandbox: remove default `--no-sandbox` for the browser container entrypoint, add explicit opt-in via `OPENCLAW_BROWSER_NO_SANDBOX` / `CLAWDBOT_BROWSER_NO_SANDBOX`, and add security-audit checks for stale/missing sandbox browser Docker hash labels. Thanks @TerminalsandCoffee and @vincentkoc. -- Security/Sandbox Browser: require VNC password auth for noVNC observer sessions in the sandbox browser entrypoint, plumb per-container noVNC passwords from runtime, and emit short-lived noVNC observer token URLs while keeping loopback-only host port publishing. Thanks @TerminalsandCoffee for reporting. -- Security/Sandbox Browser: default browser sandbox containers to a dedicated Docker network (`openclaw-sandbox-browser`), add optional CDP ingress source-range restrictions, auto-create missing dedicated networks, and warn in `openclaw security --audit` when browser sandboxing runs on bridge without source-range limits. Thanks @TerminalsandCoffee for reporting. - -## 2026.2.19 - -### Changes - -- iOS/Watch: add an Apple Watch companion MVP with watch inbox UI, watch notification relay handling, and gateway command surfaces for watch status/send flows. (#20054) Thanks @mbelinky. -- iOS/Gateway: wake disconnected iOS nodes via APNs before `nodes.invoke` and auto-reconnect gateway sessions on silent push wake to reduce invoke failures while the app is backgrounded. (#20332) Thanks @mbelinky. -- Gateway/CLI: add paired-device hygiene flows with `device.pair.remove`, plus `openclaw devices remove` and guarded `openclaw devices clear --yes [--pending]` commands for removing paired entries and optionally rejecting pending requests. (#20057) Thanks @mbelinky. -- iOS/APNs: add push registration and notification-signing configuration for node delivery. (#20308) Thanks @mbelinky. -- Gateway/APNs: add a push-test pipeline for APNs delivery validation in gateway flows. (#20307) Thanks @mbelinky. -- Security/Audit: add `gateway.http.no_auth` findings when `gateway.auth.mode="none"` leaves Gateway HTTP APIs reachable, with loopback warning and remote-exposure critical severity, plus regression coverage and docs updates. -- Skills: harden coding-agent skill guidance by removing shell-command examples that interpolate untrusted issue text directly into command strings. -- Dev tooling: align `oxfmt` local/CI formatting behavior. (#12579) Thanks @vincentkoc. - -### Fixes - -- Agents/Streaming: keep assistant partial streaming active during reasoning streams, handle native `thinking_*` stream events consistently, dedupe mixed reasoning-end signals, and clear stale mutating tool errors after same-target retry success. (#20635) Thanks @obviyus. -- iOS/Chat: use a dedicated iOS chat session key for ChatSheet routing to avoid cross-client session collisions with main-session traffic. (#21139) thanks @mbelinky. -- iOS/Chat: auto-resync chat history after reconnect sequence gaps, clear stale pending runs, and avoid dead-end manual refresh errors after transient disconnects. (#21135) thanks @mbelinky. -- UI/Usage: reload usage data immediately when timezone changes so Local/UTC toggles apply the selected date range without requiring a manual refresh. (#17774) -- iOS/Screen: move `WKWebView` lifecycle ownership into `ScreenWebView` coordinator and explicit attach/detach flow to reduce gesture/lifecycle crash risk (`__NSArrayM insertObject:atIndex:` paths) during screen tab updates. (#20366) Thanks @ngutman. -- iOS/Onboarding: prevent pairing-status flicker during auto-resume by keeping resumed state transitions stable. (#20310) Thanks @mbelinky. -- iOS/Onboarding: stabilize pairing and reconnect behavior by resetting stale pairing request state on manual retry, disconnecting both operator and node gateways on operator failure, and avoiding duplicate pairing loops from operator transport identity attachment. (#20056) Thanks @mbelinky. -- iOS/Signing: restore local auto-selected signing-team overrides during iOS project generation by wiring `.local-signing.xcconfig` into the active signing config and emitting `OPENCLAW_DEVELOPMENT_TEAM` in local signing setup. (#19993) Thanks @ngutman. -- Telegram: unify message-like inbound handling so `message` and `channel_post` share the same dedupe/access/media pipeline and remain behaviorally consistent. (#20591) Thanks @obviyus. -- Telegram/Agents: gate exec/bash tool-failure warnings behind verbose mode so default Telegram replies stay clean while verbose sessions still surface diagnostics. (#20560) Thanks @obviyus. -- Telegram/Cron/Heartbeat: honor explicit Telegram topic targets in cron and heartbeat delivery (`:topic:`) so scheduled sends land in the configured topic instead of the last active thread. (#19367) Thanks @Lukavyi. -- Gateway/Daemon: forward `TMPDIR` into installed service environments so macOS LaunchAgent gateway runs can open SQLite temp/journal files reliably instead of failing with `SQLITE_CANTOPEN`. (#20512) Thanks @Clawborn. -- Agents/Billing: include the active model that produced a billing error in user-facing billing messages (for example, `OpenAI (gpt-5.3)`) across payload, failover, and lifecycle error paths, so users can identify exactly which key needs credits. (#20510) Thanks @echoVic. -- Gateway/TUI: honor `agents.defaults.blockStreamingDefault` for `chat.send` by removing the hardcoded block-streaming disable override, so replies can use configured block-mode delivery. (#19693) Thanks @neipor. -- UI/Sessions: accept the canonical main session-key alias in Chat UI flows so main-session routing stays consistent. (#20311) Thanks @mbelinky. -- OpenClawKit/Protocol: preserve JSON boolean literals (`true`/`false`) when bridging through `AnyCodable` so Apple client RPC params no longer re-encode booleans as `1`/`0`. Thanks @mbelinky. -- Commands/Doctor: skip embedding-provider warnings when `memory.backend` is `qmd`, because QMD manages embeddings internally and does not require `memorySearch` providers. (#17263) Thanks @miloudbelarebia. -- Canvas/A2UI: improve bundled-asset resolution and empty-state handling so UI fallbacks render reliably. (#20312) Thanks @mbelinky. -- Commands/Doctor: avoid rewriting invalid configs with new `gateway.auth.token` defaults during repair and only write when real config changes are detected, preventing accidental token duplication and backup churn. -- Gateway/Auth: default unresolved gateway auth to token mode with startup auto-generation/persistence of `gateway.auth.token`, while allowing explicit `gateway.auth.mode: "none"` for intentional open loopback setups. (#20686) thanks @gumadeiras. -- Channels/Matrix: fix mention detection for `formatted_body` Matrix-to links by handling matrix.to mention formats consistently. (#16941) Thanks @zerone0x. -- Heartbeat/Cron: skip interval heartbeats when `HEARTBEAT.md` is missing or empty and no tagged cron events are queued, while preserving cron-event fallback for queued tagged reminders. (#20461) thanks @vikpos. -- Browser/Relay: reuse an already-running extension relay when the relay port is occupied by another OpenClaw process, while still failing on non-relay port collisions to avoid masking unrelated listeners. (#20035) Thanks @mbelinky. -- Scripts: update clawdock helper command support to include `docker-compose.extra.yml` where available. (#17094) Thanks @zerone0x. -- Lobster/Config: remove Lobster executable-path overrides (`lobsterPath`), require PATH-based execution, and add focused Windows wrapper-resolution tests to keep shell-free behavior stable. -- Gateway/WebChat: block `sessions.patch` and `sessions.delete` for WebChat clients so session-store mutations stay restricted to non-WebChat operator flows. Thanks @allsmog for reporting. -- Gateway: clarify launchctl GUI domain bootstrap failure on macOS. (#13795) Thanks @vincentkoc. -- Lobster/CI: fix flaky test Windows cmd shim script resolution. (#20833) Thanks @vincentkoc. -- Browser/Relay: require gateway-token auth on both `/extension` and `/cdp`, and align Chrome extension setup to use a single `gateway.auth.token` input for relay authentication. Thanks @tdjackey for reporting. -- Gateway/Hooks: run BOOT.md startup checks per configured agent scope, including per-agent session-key resolution, startup-hook regression coverage, and non-success boot outcome logging for diagnosability. (#20569) thanks @mcaxtr. -- Protocol/Apple: regenerate Swift gateway models for `push.test` so `pnpm protocol:check` stays green on main. Thanks @mbelinky. -- Sandbox/Registry: serialize container and browser registry writes with shared file locks and atomic replacement to prevent lost updates and delete rollback races from desyncing `sandbox list`, `prune`, and `recreate --all`. Thanks @kexinoh. -- OTEL/diagnostics-otel: complete OpenTelemetry v2 API migration. (#12897) Thanks @vincentkoc. -- Cron/Webhooks: protect cron webhook POST delivery with SSRF-guarded outbound fetch (`fetchWithSsrFGuard`) to block private/metadata destinations before request dispatch. Thanks @Adam55A-code. -- Security/Voice Call: harden `voice-call` telephony TTS override merging by blocking unsafe deep-merge keys (`__proto__`, `prototype`, `constructor`) and add regression coverage for top-level and nested prototype-pollution payloads. -- Security/Windows Daemon: harden Scheduled Task `gateway.cmd` generation by quoting cmd metacharacter arguments, escaping `%`/`!` expansions, and rejecting CR/LF in arguments, descriptions, and environment assignments (`set "KEY=VALUE"`), preventing command injection in Windows daemon startup scripts. Thanks @tdjackey for reporting. -- Security/Gateway/Canvas: replace shared-IP fallback auth with node-scoped session capability URLs for `/__openclaw__/canvas/*` and `/__openclaw__/a2ui/*`, fail closed when trusted-proxy requests omit forwarded client headers, and add IPv6/proxy-header regression coverage. Thanks @aether-ai-agent for reporting. -- Security/Net: enforce strict dotted-decimal IPv4 literals in SSRF checks and fail closed on unsupported legacy forms (octal/hex/short/packed, for example `0177.0.0.1`, `127.1`, `2130706433`) before DNS lookup. -- Security/Discord: enforce trusted-sender guild permission checks for moderation actions (`timeout`, `kick`, `ban`) and ignore untrusted `senderUserId` params to prevent privilege escalation in tool-driven flows. Thanks @aether-ai-agent for reporting. -- Security/ACP+Exec: add `openclaw acp --token-file/--password-file` secret-file support (with inline secret flag warnings), redact ACP working-directory prefixes to `~` home-relative paths, constrain exec script preflight file inspection to the effective `workdir` boundary, and add security-audit warnings when `tools.exec.host="sandbox"` is configured while sandbox mode is off. -- Security/Plugins/Hooks: enforce runtime/package path containment with realpath checks so `openclaw.extensions`, `openclaw.hooks`, and hook handler modules cannot escape their trusted roots via traversal or symlinks. -- Security/Discord: centralize trusted sender checks for moderation actions in message-action dispatch, share moderation command parsing across handlers, and clarify permission helpers with explicit any/all semantics. -- Security/ACP: harden ACP bridge session management with duplicate-session refresh, idle-session reaping, oldest-idle soft-cap eviction, and burst rate limiting on session creation to reduce local DoS risk without disrupting normal IDE usage. -- Security/ACP: bound ACP prompt text payloads to 2 MiB before gateway forwarding, account for join separator bytes during pre-concatenation size checks, and avoid stale active-run session state when oversized prompts are rejected. Thanks @aether-ai-agent for reporting. -- Security/Plugins/Hooks: add optional `--pin` for npm plugin/hook installs, persist resolved npm metadata (`name`, `version`, `spec`, integrity, shasum, timestamp), warn/confirm on integrity drift during updates, and extend `openclaw security audit` to flag unpinned specs, missing integrity metadata, and install-record version drift. -- Security/Plugins: harden plugin discovery by blocking unsafe candidates (root escapes, world-writable paths, suspicious ownership), add startup warnings when `plugins.allow` is empty with discoverable non-bundled plugins, and warn on loaded plugins without install/load-path provenance. -- Security/Gateway: rate-limit control-plane write RPCs (`config.apply`, `config.patch`, `update.run`) to 3 requests per minute per `deviceId+clientIp`, add restart single-flight coalescing plus a 30-second restart cooldown, and log actor/device/ip with changed-path audit details for config/update-triggered restarts. -- Security/Webhooks: harden Feishu and Zalo webhook ingress with webhook-mode token preconditions, loopback-default Feishu bind host, JSON content-type enforcement, per-path rate limiting, replay dedupe for Zalo events, constant-time Zalo secret comparison, and anomaly status counters. -- Security/Plugins: for the next npm release, clarify plugin trust boundary and keep `runtime.system.runCommandWithTimeout` available by default for trusted in-process plugins. Thanks @markmusson for reporting. -- Security/Skills: for the next npm release, reject symlinks during skill packaging to prevent external file inclusion in distributed `.skill` archives. Thanks @aether-ai-agent for reporting. -- Security/Gateway: fail startup when `hooks.token` matches `gateway.auth.token` so hooks and gateway token reuse is rejected at boot. (#20813) Thanks @coygeek. -- Security/Network: block plaintext `ws://` connections to non-loopback hosts and require secure websocket transport elsewhere. (#20803) Thanks @jscaldwell55. -- Security/Config: parse frontmatter YAML using the YAML 1.2 core schema to avoid implicit coercion of `on`/`off`-style values. (#20857) Thanks @davidrudduck. -- Security/Discord: escape backticks in exec-approval embed content to prevent markdown formatting injection via command text. (#20854) Thanks @davidrudduck. -- Security/Agents: replace shell-based `execSync` usage with `execFileSync` in command lookup helpers to eliminate shell argument interpolation risk. (#20655) Thanks @mahanandhi. -- Security/Media: use `crypto.randomBytes()` for temp file names and set owner-only permissions for TTS temp files. (#20654) Thanks @mahanandhi. -- Security/Gateway: set baseline security headers (`X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`) on gateway HTTP responses. (#10526) Thanks @abdelsfane. -- Security/iMessage: harden remote attachment SSH/SCP handling by requiring strict host-key verification, validating `channels.imessage.remoteHost` as `host`/`user@host`, and rejecting unsafe host tokens from config or auto-detection. Thanks @allsmog for reporting. -- Security/Feishu: prevent path traversal in Feishu inbound media temp-file writes by replacing key-derived temp filenames with UUID-based names. Thanks @allsmog for reporting. -- Security/Feishu: escape mention regex metacharacters in `stripBotMention` so crafted mention metadata cannot trigger regex injection or ReDoS during inbound message parsing. (#20916) Thanks @orlyjamie for the fix and @allsmog for reporting. -- LINE/Security: harden inbound media temp-file naming by using UUID-based temp paths for downloaded media instead of external message IDs. (#20792) Thanks @mbelinky. -- Security/Media: harden local media ingestion against TOCTOU/symlink swap attacks by pinning reads to a single file descriptor with symlink rejection and inode/device verification in `saveMediaSource`. Thanks @dorjoos for reporting. -- Security/Lobster (Windows): for the next npm release, remove shell-based fallback when launching Lobster wrappers (`.cmd`/`.bat`) and switch to explicit argv execution with wrapper entrypoint resolution, preventing command injection while preserving Windows wrapper compatibility. Thanks @allsmog for reporting. -- Security/Exec: require `tools.exec.safeBins` binaries to resolve from trusted bin directories (system defaults plus gateway startup `PATH`) so PATH-hijacked trojan binaries cannot bypass allowlist checks. Thanks @jackhax for reporting. -- Security/Exec: remove file-existence oracle behavior from `tools.exec.safeBins` by using deterministic argv-only stdin-safe validation and blocking file-oriented flags (for example `sort -o`, `jq -f`, `grep -f`) so allow/deny results no longer disclose host file presence. Thanks @nedlir for reporting. -- Security/Browser: route browser URL navigation through one SSRF-guarded validation path for tab-open/CDP-target/Playwright navigation flows and block private/metadata destinations by default (configurable via `browser.ssrfPolicy`). Thanks @dorjoos for reporting. -- Security/Exec: for the next npm release, harden safe-bin stdin-only enforcement by blocking output/recursive flags (`sort -o/--output`, grep recursion) and tightening default safe bins to remove `sort`/`grep`, preventing safe-bin allowlist bypass for file writes/recursive reads. Thanks @nedlir for reporting. -- Security/Exec: block grep safe-bin positional operand bypass by setting grep positional budget to zero, so `-e/--regexp` cannot smuggle bare filename reads (for example `.env`) via ambiguous positionals; safe-bin grep patterns must come from `-e/--regexp`. Thanks @athuljayaram for reporting. -- Security/Gateway/Agents: remove implicit admin scopes from agent tool gateway calls by classifying methods to least-privilege operator scopes, and enforce owner-only tooling (`cron`, `gateway`, `whatsapp_login`) through centralized tool-policy wrappers plus tool metadata to prevent non-owner DM privilege escalation. Ships in the next npm release. Thanks @Adam55A-code for reporting. -- Security/Gateway: centralize gateway method-scope authorization and default non-CLI gateway callers to least-privilege method scopes, with explicit CLI scope handling, full core-handler scope classification coverage, and regression guards to prevent scope drift. -- Security/Net: block SSRF bypass via NAT64 (`64:ff9b::/96`, `64:ff9b:1::/48`), 6to4 (`2002::/16`), and Teredo (`2001:0000::/32`) IPv6 transition addresses, and fail closed on IPv6 parse errors. Thanks @jackhax. -- Security/OTEL: sanitize OTLP endpoint URL resolution. (#13791) Thanks @vincentkoc. -- Security: patch Dependabot security issues in pnpm lock. (#20832) Thanks @vincentkoc. -- Security: migrate request dependencies to `@cypress/request`. (#20836) Thanks @vincentkoc. - -## 2026.2.17 - -### Changes - -- Agents/Anthropic: add opt-in 1M context beta header support for Opus/Sonnet via model `params.context1m: true` (maps to `anthropic-beta: context-1m-2025-08-07`). -- Agents/Models: support Anthropic Sonnet 4.6 (`anthropic/claude-sonnet-4-6`) across aliases/defaults with forward-compat fallback when upstream catalogs still only expose Sonnet 4.5. -- Commands/Subagents: add `/subagents spawn` for deterministic subagent activation from chat commands. (#18218) Thanks @JoshuaLelon. -- Agents/Subagents: add an accepted response note for `sessions_spawn` explaining polling subagents are disabled for one-off calls. Thanks @tyler6204. -- Agents/Subagents: prefix spawned subagent task messages with context to preserve source information in downstream handling. Thanks @tyler6204. -- iOS/Share: add an iOS share extension that forwards shared URL/text/image content directly to gateway `agent.request`, with delivery-route fallback and optional receipt acknowledgements. (#19424) Thanks @mbelinky. -- iOS/Talk: add a `Background Listening` toggle that keeps Talk Mode active while the app is backgrounded (off by default for battery safety). Thanks @zeulewan. -- iOS/Talk: add a `Voice Directive Hint` toggle for Talk Mode prompts so users can disable ElevenLabs voice-switching instructions to save tokens when not needed. (#18250) Thanks @zeulewan. -- iOS/Talk: harden barge-in behavior by disabling interrupt-on-speech when output route is built-in speaker/receiver, reducing false interruptions from local TTS bleed-through. Thanks @zeulewan. -- Slack: add native single-message text streaming with Slack `chat.startStream`/`appendStream`/`stopStream`; keep reply threading aligned with `replyToMode`, default streaming to enabled, and fall back to normal delivery when streaming fails. (#9972) Thanks @natedenh. -- Slack: add configurable streaming modes for draft previews. (#18555) Thanks @Solvely-Colin. -- Telegram/Agents: add inline button `style` support (`primary|success|danger`) across message tool schema, Telegram action parsing, send pipeline, and runtime prompt guidance. (#18241) Thanks @obviyus. -- Telegram: surface user message reactions as system events, with configurable `channels.telegram.reactionNotifications` scope. (#10075) Thanks @Glucksberg. -- iMessage: support `replyToId` on outbound text/media sends and normalize leading `[[reply_to:]]` tags so replies target the intended iMessage. Thanks @tyler6204. -- Tool Display/Web UI: add intent-first tool detail views and exec summaries. (#18592) Thanks @xdLawless2. -- Discord: expose native `/exec` command options (host/security/ask/node) so Discord slash commands get autocomplete and structured inputs. Thanks @thewilloftheshadow. -- Discord: allow reusable interactive components with `components.reusable=true` so buttons, selects, and forms can be used multiple times before expiring. Thanks @thewilloftheshadow. -- Discord: add per-button `allowedUsers` allowlist for interactive components to restrict who can click buttons. Thanks @thewilloftheshadow. -- Cron/Gateway: separate per-job webhook delivery (`delivery.mode = "webhook"`) from announce delivery, enforce valid HTTP(S) webhook URLs, and keep a temporary legacy `notify + cron.webhook` fallback for stored jobs. (#17901) Thanks @advaitpaliwal. -- Cron/CLI: add deterministic default stagger for recurring top-of-hour cron schedules (including 6-field seconds cron), auto-migrate existing jobs to persisted `schedule.staggerMs`, and add `openclaw cron add/edit --stagger ` plus `--exact` overrides for per-job timing control. -- Cron: log per-run model/provider usage telemetry in cron run logs/webhooks and add a local usage report script for aggregating token usage by job. (#18172) Thanks @HankAndTheCrew. -- Tools/Web: add URL allowlists for `web_search` and `web_fetch`. (#18584) Thanks @smartprogrammer93. -- Browser: add `extraArgs` config for custom Chrome launch arguments. (#18443) Thanks @JayMishra-source. -- Voice Call: pre-cache inbound greeting TTS for faster first playback. (#18447) Thanks @JayMishra-source. -- Skills: compact skill file `` paths in the system prompt by replacing home-directory prefixes with `~`, and add targeted compaction tests for prompt serialization behavior. (#14776) Thanks @bitfish3. -- Skills: refine skill-description routing boundaries with explicit "Use when"/"NOT for" guidance for coding-agent/github/weather, and clarify PTY/browser fallback wording. (#14577) Thanks @DylanWoodAkers. -- Auto-reply/Prompts: include trusted inbound `message_id` in conversation metadata payloads for downstream targeting workflows. Thanks @tyler6204. -- Auto-reply: include `sender_id` in trusted inbound metadata so moderation workflows can target the sender without relying on untrusted text. (#18303) Thanks @crimeacs. -- UI/Sessions: avoid duplicating typed session prefixes in display names (for example `Subagent Subagent ...`). Thanks @tyler6204. -- Agents/Z.AI: enable `tool_stream` by default for real-time tool call streaming, with opt-out via `params.tool_stream: false`. (#18173) Thanks @tianxiao1430-jpg. -- Plugins: add `before_agent_start` model/provider overrides before resolution. (#18568) Thanks @natefikru. -- Mattermost: add emoji reaction actions plus reaction event notifications, including an explicit boolean `remove` flag to avoid accidental removals. (#18608) Thanks @echo931. -- Memory/Search: add FTS fallback plus query expansion for memory search. (#18304) Thanks @irchelper. -- Agents/Models: support per-model `thinkingDefault` overrides in model config. (#18152) Thanks @wu-tian807. -- Agents: enable `llms.txt` discovery in default behavior. (#18158) Thanks @yolo-maxi. -- Extensions/Auth: add OpenAI Codex CLI auth provider integration. (#18009) Thanks @jiteshdhamaniya. -- Feishu: add Bitable create-app/create-field tools for automation workflows. (#17963) Thanks @gaowanqi08141999. -- Docker: add optional `OPENCLAW_INSTALL_BROWSER` build arg to preinstall Chromium + Xvfb in the Docker image, avoiding runtime Playwright installs. (#18449) - -### Fixes - -- Agents/Antigravity: preserve unsigned Claude thinking blocks as plain text instead of dropping them during transcript sanitization, preventing reasoning context loss while avoiding `thinking.signature` request rejections. -- Agents/Google: clean tool JSON Schemas for `google-antigravity` the same as `google-gemini-cli` before Cloud Code Assist requests, preventing Claude tool calls from failing with `patternProperties` 400 errors. (#19860) -- Tests/Telegram: add regression coverage for command-menu sync that asserts all `setMyCommands` entries are Telegram-safe and hyphen-normalized across native/custom/plugin command sources. (#19703) Thanks @obviyus. -- Agents/Image: collapse resize diagnostics to one line per image and include visible pixel/byte size details in the log message for faster triage. -- Auth/Cooldowns: clear all usage stats fields (`disabledUntil`, `disabledReason`, `failureCounts`) in `clearAuthProfileCooldown` so manual cooldown resets fully recover billing-disabled profiles without requiring direct file edits. (#19211) Thanks @nabbilkhan. -- Agents/Subagents: preemptively guard accumulated tool-result context before model calls by truncating oversized outputs and compacting oldest tool-result messages to avoid context-window overflow crashes. Thanks @tyler6204. -- Agents/Subagents/CLI: fail `sessions_spawn` when subagent model patching is rejected, allow subagent model patch defaults from `subagents.model`, and keep `sessions list`/`status` model reporting aligned to runtime model resolution. (#18660) Thanks @robbyczgw-cla. -- Agents/Subagents: add explicit subagent guidance to recover from `[compacted: tool output removed to free context]` / `[truncated: output exceeded context limit]` markers by re-reading with smaller chunks instead of full-file `cat`. Thanks @tyler6204. -- Agents/Tools: make `read` auto-page across chunks (when no explicit `limit` is provided) and scale its per-call output budget from model `contextWindow`, so larger contexts can read more before context guards kick in. Thanks @tyler6204. -- Agents/Tools: strip duplicated `read` truncation payloads from tool-result `details` and make pre-call context guarding account for heavy tool-result metadata, so repeated `read` calls no longer bypass compaction and overflow model context windows. Thanks @tyler6204. -- Reply threading: keep reply context sticky across streamed/split chunks and preserve `replyToId` on all chunk sends across shared and channel-specific delivery paths (including iMessage, BlueBubbles, Telegram, Discord, and Matrix), so follow-up bubbles stay attached to the same referenced message. Thanks @tyler6204. -- Gateway/Agent: defer transient lifecycle `error` snapshots with a short grace window so `agent.wait` does not resolve early during retry/failover. Thanks @tyler6204. -- Gateway/Presence: centralize presence snapshot broadcasts and unify runtime version precedence (`OPENCLAW_VERSION` > `OPENCLAW_SERVICE_VERSION` > `npm_package_version`) so self-presence and websocket `hello-ok` report consistent versions. -- Hooks/Automation: bridge outbound/inbound message lifecycle into internal hook events (`message:received`, `message:sent`) with session-key correlation guards, while keeping per-payload success/error reporting accurate for chunked and best-effort deliveries. (PR #9387) -- Media understanding: honor `agents.defaults.imageModel` during auto-discovery so implicit image analysis uses configured primary/fallback image models. (PR #7607) -- iOS/Onboarding: stop auth Step 3 retry-loop churn by pausing reconnect attempts on unauthorized/missing-token gateway errors and keeping auth/pairing issue state sticky during manual retry. (#19153) Thanks @mbelinky. -- Voice-call: auto-end calls when media streams disconnect to prevent stuck active calls. (#18435) Thanks @JayMishra-source. -- Voice call/Gateway: prevent overlapping closed-loop turn races with per-call turn locking, route transcript dedupe via source-aware fingerprints with strict cache eviction bounds, and harden `voicecall latency` stats for large logs without spread-operator stack overflow. (#19140) Thanks @mbelinky. -- iOS/Chat: route ChatSheet RPCs through the operator session instead of the node session to avoid node-role authorization failures for `chat.history`, `chat.send`, and `sessions.list`. (#19320) Thanks @mbelinky. -- macOS/Update: correct the Sparkle appcast version for 2026.2.15 so updates are offered again. (#18201) -- Gateway/Auth: clear stale device-auth tokens after device token mismatch errors so re-paired clients can re-auth. (#18201) -- Telegram: enable DM voice-note transcription with CLI fallback handling. (#18564) Thanks @thhuang. -- Telegram/Polls: restore Telegram poll action wiring in channel handlers. (#18122) Thanks @akyourowngames. -- WebChat: strip reply/audio directive tags from rendered chat output. (#18093) Thanks @aldoeliacim. -- Discord: honor configured HTTP proxy for app-id and allowlist REST resolution. (#17958) Thanks @k2009. -- BlueBubbles: add fallback path to recover outbound `message_id` from `fromMe` webhooks when platform message IDs are missing. Thanks @tyler6204. -- BlueBubbles: match outbound message-id fallback recovery by chat identifier as well as account context. Thanks @tyler6204. -- BlueBubbles: include sender identifier in untrusted conversation metadata for conversation info payloads. Thanks @tyler6204. -- Security/Exec: fix the OC-09 credential-theft path via environment-variable injection. (#18048) Thanks @aether-ai-agent. -- Security/Config: confine `$include` resolution to the top-level config directory, harden traversal/symlink checks with cross-platform-safe path containment, and add doctor hints for invalid escaped include paths. (#18652) Thanks @aether-ai-agent. -- Security/Net: block SSRF bypass via ISATAP embedded IPv4 transition addresses and centralize hostname/IP blocking checks across URL safety validators. Thanks @zpbrent for reporting. -- Providers: improve error messaging for unconfigured local `ollama`/`vllm` providers. (#18183) Thanks @arosstale. -- TTS: surface all provider errors instead of only the last error in aggregated failures. (#17964) Thanks @ikari-pl. -- CLI/Doctor/Configure: skip gateway auth checks for loopback-only setups. (#18407) Thanks @sggolakiya. -- CLI/Doctor: reconcile gateway service-token drift after re-pair flows. (#18525) Thanks @norunners. -- Process/Windows: disable detached spawn in exec runs to prevent empty command output. (#18067) Thanks @arosstale. -- Process: gracefully terminate process trees with SIGTERM before SIGKILL. (#18626) Thanks @sauerdaniel. -- Sessions/Windows: use atomic session-store writes to prevent context loss on Windows. (#18347) Thanks @twcwinston. -- Agents/Image: validate base64 image payloads before provider submission. (#18263) Thanks @sriram369. -- Models CLI: validate catalog entries in `openclaw models set`. (#18129) Thanks @carrotRakko. -- Usage: isolate last-turn totals in token usage reporting to avoid mixed-turn totals. (#18052) Thanks @arosstale. -- Cron: resolve `accountId` from agent bindings in isolated sessions. (#17996) Thanks @simonemacario. -- Gateway/HTTP: preserve unbracketed IPv6 `Host` headers when normalizing requests. (#18061) Thanks @Clawborn. -- Sandbox: fix workspace-directory orphaning during SHA-1 -> SHA-256 slug migration. (#18523) Thanks @yinghaosang. -- Ollama/Qwen: handle Qwen 3 reasoning field format in Ollama responses. (#18631) Thanks @mr-sk. -- OpenAI/Transcripts: always drop orphaned reasoning blocks from transcript repair. (#18632) Thanks @TySabs. -- Fix types in all tests. Typecheck the whole repository. -- Gateway/Channels: wire `gateway.channelHealthCheckMinutes` into strict config validation, treat implicit account status as managed for health checks, and harden channel auto-restart flow (preserve restart-attempt caps across crash loops, propagate enabled/configured runtime flags, and stop pending restart backoff after manual stop). Thanks @steipete. -- Gateway/WebChat: hard-cap `chat.history` oversized payloads by truncating high-cost fields and replacing over-budget entries with placeholders, so history fetches stay within configured byte limits and avoid chat UI freezes. (#18505) -- UI/Usage: replace lingering undefined `var(--text-muted)` usage with `var(--muted)` in usage date-range and chart styles to keep muted text visible across themes. (#17975) Thanks @jogelin. -- UI/Usage: preserve selected-range totals when timeline data is downsampled by bucket-aggregating timeseries points (instead of dropping intermediate points), so filtered tokens/cost stay accurate. (#17959) Thanks @jogelin. -- UI/Sessions: refresh the sessions table only after successful deletes and preserve delete errors on cancel/failure paths, so deleted sessions disappear automatically without masking delete failures. (#18507) -- Scripts/UI/Windows: fix `pnpm ui:*` spawn `EINVAL` failures by restoring shell-backed launch for `.cmd`/`.bat` runners, narrowing shell usage to launcher types that require it, and rejecting unsafe forwarded shell metacharacters in UI script args. (#18594) -- Hooks/Session-memory: recover `/new` conversation summaries when session pointers are reset-path or missing `sessionFile`, and consistently prefer the newest `.jsonl.reset.*` transcript candidate for fallback extraction. (#18088) -- Auto-reply/Sessions: prevent stale thread ID leakage into non-thread sessions so replies stay in the main DM after topic interactions. (#18528) Thanks @j2h4u. -- Slack: restrict forwarded-attachment ingestion to explicit shared-message attachments and skip non-Slack forwarded `image_url` fetches, preventing non-forward attachment unfurls from polluting inbound agent context while preserving forwarded message handling. -- Feishu: detect bot mentions in post messages with embedded docs when `message.mentions` is empty. (#18074) Thanks @popomore. -- Agents/Sessions: align session lock watchdog hold windows with run and compaction timeout budgets (plus grace), preventing valid long-running turns from being force-unlocked mid-run while still recovering hung lock owners. (#18060) -- Cron: preserve default model fallbacks for cron agent runs when only `model.primary` is overridden, so failover still follows configured fallbacks unless explicitly cleared with `fallbacks: []`. (#18210) Thanks @mahsumaktas. -- Cron: route text-only announce output through the main session announce flow via runSubagentAnnounceFlow so cron text-only output remains visible to the initiating session. Thanks @tyler6204. -- Cron: treat `timeoutSeconds: 0` as no-timeout (not clamped to 1), ensuring long-running cron runs are not prematurely terminated. Thanks @tyler6204. -- Cron announce injection now targets the session determined by delivery config (`to` + channel) instead of defaulting to the current session. Thanks @tyler6204. -- Cron/Heartbeat: canonicalize session-scoped reminder `sessionKey` routing and preserve explicit flat `sessionKey` cron tool inputs, preventing enqueue/wake namespace drift for session-targeted reminders. (#18637) Thanks @vignesh07. -- Cron/Webhooks: reuse existing session IDs for webhook/cron runs when the session key is stable and still fresh, preserving conversation history. (#18031) Thanks @Operative-001. -- Cron: prevent spin loops when cron jobs complete within the scheduled second by advancing the next run and enforcing a minimum refire gap. (#18073) Thanks @widingmarcus-cyber. -- OpenClawKit/iOS ChatUI: accept canonical session-key completion events for local pending runs and preserve message IDs across history refreshes, preventing stuck "thinking" state and message flicker after gateway replies. (#18165) Thanks @mbelinky. -- iOS/Onboarding: add QR-first onboarding wizard with setup-code deep link support, pairing/auth issue guidance, and device-pair QR generation improvements for Telegram/Web/TUI fallback flows. (#18162) Thanks @mbelinky and @Marvae. -- iOS/Gateway: stabilize connect/discovery state handling, add onboarding reset recovery in Settings, and fix iOS gateway-controller coverage for command-surface and last-connection persistence behavior. (#18164) Thanks @mbelinky. -- iOS/Talk: harden mobile talk config handling by ignoring redacted/env-placeholder API keys, support secure local keychain override, improve accessibility motion/contrast behavior in status UI, and tighten ATS to local-network allowance. (#18163) Thanks @mbelinky. -- iOS/Location: restore the significant location monitor implementation (service hooks + protocol surface + ATS key alignment) after merge drift so iOS builds compile again. (#18260) Thanks @ngutman. -- iOS/Signing: auto-select local Apple Development team during iOS project generation/build, prefer the canonical OpenClaw team when available, and support local per-machine signing overrides without committing team IDs. (#18421) Thanks @ngutman. -- Discord/Telegram: make per-account message action gates effective for both action listing and execution, and preserve top-level gate restrictions when account overrides only specify a subset of `actions` keys (account key -> base key -> default fallback). (#18494) -- Telegram: keep DM-topic replies and draft previews in the originating private-chat topic by preserving positive `message_thread_id` values for DM threads. (#18586) Thanks @sebslight. -- Telegram: preserve private-chat topic `message_thread_id` on outbound sends (message/sticker/poll), keep thread-not-found retry fallback, and avoid masking `chat not found` routing errors. (#18993) Thanks @obviyus. -- Discord: prevent duplicate media delivery when the model uses the `message send` tool with media, by skipping media extraction from messaging tool results since the tool already sent the message directly. (#18270) -- Discord: route `audioAsVoice` auto-replies through the voice message API so opt-in audio renders as voice messages. (#18041) Thanks @zerone0x. -- Discord: skip auto-thread creation in forum/media/voice/stage channels and keep group session last-route metadata fresh to avoid invalid thread API errors and lost follow-up sends. (#18098) Thanks @Clawborn. -- Discord/Commands: normalize `commands.allowFrom` entries with `user:`/`discord:`/`pk:` prefixes and `<@id>` mentions so command authorization matches Discord allowlist behavior. (#18042) -- Telegram: keep draft-stream preview replies attached to the user message for `replyToMode: "all"` in groups and DMs, preserving threaded reply context from preview through finalization. (#17880) Thanks @yinghaosang. -- Telegram: prevent streaming final replies from being overwritten by later final/error payloads, and suppress fallback tool-error warnings when a recovered assistant answer already exists after tool calls. (#17883) Thanks @Marvae and @obviyus. -- Telegram: debounce the first draft-stream preview update (30-char threshold) and finalize short responses by editing the stop-time preview message, improving first push notifications and avoiding duplicate final sends. (#18148) Thanks @Marvae. -- Telegram: disable block streaming when `channels.telegram.streamMode` is `off`, preventing newline/content-block replies from splitting into multiple messages. (#17679) Thanks @saivarunk. -- Telegram: keep `streamMode: "partial"` draft previews in a single message across assistant-message/reasoning boundaries, preventing duplicate preview bubbles during partial-mode tool-call turns. (#18956) Thanks @obviyus. -- Telegram: normalize native command names for Telegram menu registration (`-` -> `_`) to avoid `BOT_COMMAND_INVALID` command-menu wipeouts, and log failed command syncs instead of silently swallowing them. (#19257) Thanks @akramcodez. -- Telegram: route non-abort slash commands on the normal chat/topic sequential lane while keeping true abort requests (`/stop`, `stop`) on the control lane, preventing command/reply race conditions from control-lane bypass. (#17899) Thanks @obviyus. -- Telegram: ignore `` placeholder lines when extracting `MEDIA:` tool-result paths, preventing false local-file reads and dropped replies. (#18510) Thanks @yinghaosang. -- Telegram: skip retries when inbound media `getFile` fails with Telegram's 20MB limit and continue processing message text, avoiding dropped messages for oversized attachments. (#18531) Thanks @brandonwise. -- Telegram: clear stored polling offsets when bot tokens change or accounts are deleted, preventing stale offsets after token rotations. (#18233) -- Telegram: enable `autoSelectFamily` by default on Node.js 22+ so IPv4 fallback works on broken IPv6 networks. (#18272) Thanks @nacho9900. -- Auto-reply/TTS: keep tool-result media delivery enabled in group chats and native command sessions (while still suppressing tool summary text) so `NO_REPLY` follow-ups do not drop successful TTS audio. (#17991) Thanks @zerone0x. -- Agents/Tools: deliver tool-result media even when verbose tool output is off so media attachments are not dropped. (#16679) -- Discord: optimize reaction notification handling to skip unnecessary message fetches in `off`/`all`/`allowlist` modes, streamline reaction routing, and improve reaction emoji formatting. (#18248) Thanks @thewilloftheshadow and @victorGPT. -- CLI/Pairing: make `openclaw qr --remote` prefer `gateway.remote.url` over tailscale/public URL resolution and register the `openclaw clawbot qr` legacy alias path. (#18091) -- CLI/QR: restore fail-fast validation for `openclaw qr --remote` when neither `gateway.remote.url` nor tailscale `serve`/`funnel` is configured, preventing unusable remote pairing QR flows. (#18166) Thanks @mbelinky. -- CLI: fix parent/subcommand option collisions across gateway, daemon, update, ACP, and browser command flows, while preserving legacy `browser set headers --json ` compatibility. -- CLI/Doctor: ensure `openclaw doctor --fix --non-interactive --yes` exits promptly after completion so one-shot automation no longer hangs. (#18502) -- CLI/Doctor: auto-repair `dmPolicy="open"` configs missing wildcard allowlists and write channel-correct repair paths (including `channels.googlechat.dm.allowFrom`) so `openclaw doctor --fix` no longer leaves Google Chat configs invalid after attempted repair. (#18544) -- CLI/Doctor: detect gateway service token drift when the gateway token is only provided via environment variables, keeping service repairs aligned after token rotation. -- Gateway/Update: prevent restart crash loops after failed self-updates by restarting only on successful updates, stopping early on failed install/build steps, and running `openclaw doctor --fix` during updates to sanitize config. (#18131) Thanks @RamiNoodle733. -- Gateway/Update: preserve update.run restart delivery context so post-update status replies route back to the initiating channel/thread. (#18267) Thanks @yinghaosang. -- CLI/Update: run a standalone restart helper after updates, honoring service-name overrides and reporting restart initiation separately from confirmed restarts. (#18050) -- CLI/Daemon: warn when a gateway restart sees a stale service token so users can reinstall with `openclaw gateway install --force`, and skip drift warnings for non-gateway service restarts. (#18018) -- CLI/Daemon: prefer the active version-manager Node when installing daemons and include macOS version-manager bin directories in the service PATH so launchd services resolve user-managed runtimes. -- CLI/Status: fix `openclaw status --all` token summaries for bot-token-only channels so Mattermost/Zalo no longer show a bot+app warning. (#18527) Thanks @echo931. -- CLI/Configure: make the `/model picker` allowlist prompt searchable with tokenized matching in `openclaw configure` so users can filter huge model lists by typing terms like `gpt-5.2 openai/`. (#19010) Thanks @bjesuiter. -- CLI/Message: preserve `--components` JSON payloads in `openclaw message send` so Discord component payloads are no longer dropped. (#18222) Thanks @saurabhchopade. -- Voice Call: add an optional stale call reaper (`staleCallReaperSeconds`) to end stuck calls when enabled. (#18437) -- Auto-reply/Subagents: propagate group context (`groupId`, `groupChannel`, `space`) when spawning via `/subagents spawn`, matching tool-triggered subagent spawn behavior. -- Subagents: route nested announce results back to the parent session after the parent run ends, falling back only when the parent session is deleted. (#18043) Thanks @tyler6204. -- Subagents: cap announce retry loops with max attempts and expiry to prevent infinite retry spam after deferred announces. (#18444) -- Agents/Tools/exec: add a preflight guard that detects likely shell env var injection (e.g. `$DM_JSON`, `$TMPDIR`) in Python/Node scripts before execution, preventing recurring cron failures and wasted tokens when models emit mixed shell+language source. (#12836) -- Agents/Tools/exec: treat normal non-zero exit codes as completed and append the exit code to tool output to avoid false tool-failure warnings. (#18425) -- Agents/Tools: make loop detection progress-aware and phased by hard-blocking known `process(action=poll|log)` no-progress loops, warning on generic identical-call repeats, warning + no-progress-blocking ping-pong alternation loops (10/20), coalescing repeated warning spam into threshold buckets (including canonical ping-pong pairs), adding a global circuit breaker at 30 no-progress repeats, and emitting structured diagnostic `tool.loop` warning/error events for loop actions. (#16808) Thanks @akramcodez and @beca-oc. -- Agents/Hooks: preserve the `before_tool_call` wrapped-marker across abort-signal tool wrapping so the hook runs once per tool call in normal agent sessions. (#16852) Thanks @sreuter. -- Agents/Tests: add `before_message_write` persistence regression coverage for block/mutate behavior (including synthetic tool-result flushes) and thrown-hook fallback persistence. (#18197) Thanks @shakkernerd -- Agents/Tools: scope the `message` tool schema to the active channel so Telegram uses `buttons` and Discord uses `components`. (#18215) Thanks @obviyus. -- Agents/Image tool: replace Anthropic-incompatible union schema with explicit `image` (single) and `images` (multi) parameters, keeping tool schemas `anyOf`/`oneOf`/`allOf`-free while preserving multi-image analysis support. (#18551, #18566) Thanks @aldoeliacim. -- Agents/Models: probe the primary model when its auth-profile cooldown is near expiry (with per-provider throttling), so runs recover from temporary rate limits without staying on fallback models until restart. (#17478) Thanks @PlayerGhost. -- Agents/Failover: classify provider abort stop-reason errors (`Unhandled stop reason: abort`, `stop reason: abort`, `reason: abort`) as timeout-class failures so configured model fallback chains trigger instead of surfacing raw abort failures. (#18618) Thanks @sauerdaniel. -- Models/CLI: sync auth-profiles credentials into agent `auth.json` before registry availability checks so `openclaw models list --all` reports auth correctly for API-key/token providers, normalize provider-id aliases when bridging credentials, and skip expired token mirrors. (#18610, #18615) -- Agents/Context: raise default total bootstrap prompt cap from `24000` to `150000` chars (keeping `bootstrapMaxChars` at `20000`), include total-cap visibility in `/context`, and mark truncation from injected-vs-raw sizes so total-cap clipping is reflected accurately. -- Memory/QMD: scope managed collection names per agent and precreate glob-backed collection directories before registration, preventing cross-agent collection clobbering and startup ENOENT failures in fresh workspaces. (#17194) Thanks @jonathanadams96. -- Cron: preserve per-job schedule-error isolation in post-run maintenance recompute so malformed sibling jobs no longer abort persistence of successful runs. (#17852) Thanks @pierreeurope. -- Gateway/Config: prevent `config.patch` object-array merges from falling back to full-array replacement when some patch entries lack `id`, so partial `agents.list` updates no longer drop unrelated agents. (#17989) Thanks @stakeswky. -- Gateway/Auth: trim whitespace around trusted proxy entries before matching so configured proxies with stray spaces still authorize. (#18084) Thanks @Clawborn. -- Config/Discord: require string IDs in Discord allowlists, keep onboarding inputs string-only, and add doctor repair for numeric entries. (#18220) Thanks @thewilloftheshadow. -- Security/Sessions: create new session transcript JSONL files with user-only (`0o600`) permissions and extend `openclaw security audit --fix` to remediate existing transcript file permissions. -- Sessions/Maintenance: archive transcripts when pruning stale sessions, clean expired media in subdirectories, and purge `.deleted` transcript archives after the prune window to prevent disk leaks. (#18538) -- Infra/Fetch: ensure foreign abort-signal listener cleanup never masks original fetch successes/failures, while still preventing detached-finally unhandled rejection noise in `wrapFetchWithAbortSignal`. Thanks @Jackten. -- Heartbeat: allow suppressing tool error warning payloads during heartbeat runs via a new heartbeat config flag. (#18497) Thanks @thewilloftheshadow. -- Heartbeat: include sender metadata (From/To/Provider) in heartbeat prompts so model context matches the delivery target. (#18532) Thanks @dinakars777. -- Heartbeat/Telegram: strip configured `responsePrefix` before heartbeat ack detection (with boundary-safe matching) so prefixed `HEARTBEAT_OK` replies are correctly suppressed instead of leaking into DMs. (#18602) - -## 2026.2.15 - -### Changes - -- Discord: unlock rich interactive agent prompts with Components v2 (buttons, selects, modals, and attachment-backed file blocks) so for native interaction through Discord. Thanks @thewilloftheshadow. -- Discord: components v2 UI + embeds passthrough + exec approval UX refinements (CV2 containers, button layout, Discord-forwarding skip). Thanks @thewilloftheshadow. -- Plugins: expose `llm_input` and `llm_output` hook payloads so extensions can observe prompt/input context and model output usage details. (#16724) Thanks @SecondThread. -- Subagents: nested sub-agents (sub-sub-agents) with configurable depth. Set `agents.defaults.subagents.maxSpawnDepth: 2` to allow sub-agents to spawn their own children. Includes `maxChildrenPerAgent` limit (default 5), depth-aware tool policy, and proper announce chain routing. (#14447) Thanks @tyler6204. -- Slack/Discord/Telegram: add per-channel ack reaction overrides (account/channel-level) to support platform-specific emoji formats. (#17092) Thanks @zerone0x. -- Telegram: add `channel_post` inbound support for channel-based bot-to-bot wake/trigger flows, with channel allowlist gating and message/media batching parity. -- Cron/Gateway: add finished-run webhook delivery toggle (`notify`) and dedicated webhook auth token support (`cron.webhookToken`) for outbound cron webhook posts. (#14535) Thanks @advaitpaliwal. -- Channels: deduplicate probe/token resolution base types across core + extensions while preserving per-channel error typing. (#16986) Thanks @iyoda and @thewilloftheshadow. -- Memory: add MMR (Maximal Marginal Relevance) re-ranking for hybrid search diversity. Configurable via `memorySearch.query.hybrid.mmr`. Thanks @rodrigouroz. -- Memory: add opt-in temporal decay for hybrid search scoring, with configurable half-life via `memorySearch.query.hybrid.temporalDecay`. Thanks @rodrigouroz. - -### Fixes - -- Discord: send initial content when creating non-forum threads so `thread-create` content is delivered. (#18117) Thanks @zerone0x. -- Security: replace deprecated SHA-1 sandbox configuration hashing with SHA-256 for deterministic sandbox cache identity and recreation checks. Thanks @kexinoh. -- Security/Logging: redact Telegram bot tokens from error messages and uncaught stack traces to prevent accidental secret leakage into logs. Thanks @aether-ai-agent. -- Sandbox/Security: block dangerous sandbox Docker config (bind mounts, host networking, unconfined seccomp/apparmor) to prevent container escape via config injection. Thanks @aether-ai-agent. -- Sandbox: preserve array order in config hashing so order-sensitive Docker/browser settings trigger container recreation correctly. Thanks @kexinoh. -- Gateway/Security: redact sensitive session/path details from `status` responses for non-admin clients; full details remain available to `operator.admin`. (#8590) Thanks @fr33d3m0n. -- Gateway/Control UI: preserve requested operator scopes for Control UI bypass modes (`allowInsecureAuth` / `dangerouslyDisableDeviceAuth`) when device identity is unavailable, preventing false `missing scope` failures on authenticated LAN/HTTP operator sessions. (#17682) Thanks @leafbird. -- LINE/Security: fail closed on webhook startup when channel token or channel secret is missing, and treat LINE accounts as configured only when both are present. (#17587) Thanks @davidahmann. -- Skills/Security: restrict `download` installer `targetDir` to the per-skill tools directory to prevent arbitrary file writes. Thanks @Adam55A-code. -- Skills/Linux: harden go installer fallback on apt-based systems by handling root/no-sudo environments safely, doing best-effort apt index refresh, and returning actionable errors instead of failing with spawn errors. (#17687) Thanks @mcrolly. -- Web Fetch/Security: cap downloaded response body size before HTML parsing to prevent memory exhaustion from oversized or deeply nested pages. Thanks @xuemian168. -- Config/Gateway: make sensitive-key whitelist suffix matching case-insensitive while preserving `passwordFile` path exemptions, preventing accidental redaction of non-secret config values like `maxTokens` and IRC password-file paths. (#16042) Thanks @akramcodez. -- Dev tooling: harden git `pre-commit` hook against option injection from malicious filenames (for example `--force`), preventing accidental staging of ignored files. Thanks @mrthankyou. -- Gateway/Agent: reject malformed `agent:`-prefixed session keys (for example, `agent:main`) in `agent` and `agent.identity.get` instead of silently resolving them to the default agent, preventing accidental cross-session routing. (#15707) Thanks @rodrigouroz. -- Gateway/Chat: harden `chat.send` inbound message handling by rejecting null bytes, stripping unsafe control characters, and normalizing Unicode to NFC before dispatch. (#8593) Thanks @fr33d3m0n. -- Gateway/Send: return an actionable error when `send` targets internal-only `webchat`, guiding callers to use `chat.send` or a deliverable channel. (#15703) Thanks @rodrigouroz. -- Gateway/Commands: keep webchat command authorization on the internal `webchat` context instead of inferring another provider from channel allowlists, fixing dropped `/new`/`/status` commands in Control UI when channel allowlists are configured. (#7189) Thanks @karlisbergmanis-lv. -- Control UI: prevent stored XSS via assistant name/avatar by removing inline script injection, serving bootstrap config as JSON, and enforcing `script-src 'self'`. Thanks @Adam55A-code. -- Agents/Security: sanitize workspace paths before embedding into LLM prompts (strip Unicode control/format chars) to prevent instruction injection via malicious directory names. Thanks @aether-ai-agent. -- Agents/Sandbox: clarify system prompt path guidance so sandbox `bash/exec` uses container paths (for example `/workspace`) while file tools keep host-bridge mapping, avoiding first-attempt path misses from host-only absolute paths in sandbox command execution. (#17693) Thanks @app/juniordevbot. -- Agents/Context: apply configured model `contextWindow` overrides after provider discovery so `lookupContextTokens()` honors operator config values (including discovery-failure paths). (#17404) Thanks @michaelbship and @vignesh07. -- Agents/Context: derive `lookupContextTokens()` from auth-available model metadata and keep the smallest discovered context window for duplicate model ids, preventing cross-provider cache collisions from overestimating session context limits. (#17586) Thanks @githabideri and @vignesh07. -- Agents/OpenAI: force `store=true` for direct OpenAI Responses/Codex runs to preserve multi-turn server-side conversation state, while leaving proxy/non-OpenAI endpoints unchanged. (#16803) Thanks @mark9232 and @vignesh07. -- Memory/FTS: make `buildFtsQuery` Unicode-aware so non-ASCII queries (including CJK) produce keyword tokens instead of falling back to vector-only search. (#17672) Thanks @KinGP5471. -- Auto-reply/Compaction: resolve `memory/YYYY-MM-DD.md` placeholders with timezone-aware runtime dates and append a `Current time:` line to memory-flush turns, preventing wrong-year memory filenames without making the system prompt time-variant. (#17603, #17633) Thanks @nicholaspapadam-wq and @vignesh07. -- Auth/Cooldowns: auto-expire stale auth profile cooldowns when `cooldownUntil` or `disabledUntil` timestamps have passed, and reset `errorCount` so the next transient failure does not immediately escalate to a disproportionately long cooldown. Handles `cooldownUntil` and `disabledUntil` independently. (#3604) Thanks @nabbilkhan. -- Agents: return an explicit timeout error reply when an embedded run times out before producing any payloads, preventing silent dropped turns during slow cache-refresh transitions. (#16659) Thanks @liaosvcaf and @vignesh07. -- Group chats: always inject group chat context (name, participants, reply guidance) into the system prompt on every turn, not just the first. Prevents the model from losing awareness of which group it's in and incorrectly using the message tool to send to the same group. (#14447) Thanks @tyler6204. -- Browser/Agents: when browser control service is unavailable, return explicit non-retry guidance (instead of "try again") so models do not loop on repeated browser tool calls until timeout. (#17673) Thanks @austenstone. -- Subagents: use child-run-based deterministic announce idempotency keys across direct and queued delivery paths (with legacy queued-item fallback) to prevent duplicate announce retries without collapsing distinct same-millisecond announces. (#17150) Thanks @widingmarcus-cyber. -- Subagents/Models: preserve `agents.defaults.model.fallbacks` when subagent sessions carry a model override, so subagent runs fail over to configured fallback models instead of retrying only the overridden primary model. -- Agents/Tools: scope the `message` tool schema to the active channel so Telegram uses `buttons` and Discord uses `components`. (#18215) Thanks @obviyus. -- Telegram: omit `message_thread_id` for DM sends/draft previews and keep forum-topic handling (`id=1` general omitted, non-general kept), preventing DM failures with `400 Bad Request: message thread not found`. (#10942) Thanks @garnetlyx. -- Telegram: replace inbound `` placeholder with successful preflight voice transcript in message body context, preventing placeholder-only prompt bodies for mention-gated voice messages. (#16789) Thanks @Limitless2023. -- Telegram: retry inbound media `getFile` calls (3 attempts with backoff) and gracefully fall back to placeholder-only processing when retries fail, preventing dropped voice/media messages on transient Telegram network errors. (#16154) Thanks @yinghaosang. -- Telegram: finalize streaming preview replies in place instead of sending a second final message, preventing duplicate Telegram assistant outputs at stream completion. (#17218) Thanks @obviyus. -- Discord: preserve channel session continuity when runtime payloads omit `message.channelId` by falling back to event/raw `channel_id` values for routing/session keys, so same-channel messages keep history across turns/restarts. Also align diagnostics so active Discord runs no longer appear as `sessionKey=unknown`. (#17622) Thanks @shakkernerd. -- Discord: dedupe native skill commands by skill name in multi-agent setups to prevent duplicated slash commands with `_2` suffixes. (#17365) Thanks @seewhyme. -- Discord: ensure role allowlist matching uses raw role IDs for message routing authorization. Thanks @xinhuagu. -- Discord: skip text-based exec approval forwarding in favor of Discord's component-based approval UI. Thanks @thewilloftheshadow. -- Web UI/Agents: hide `BOOTSTRAP.md` in the Agents Files list after onboarding is completed, avoiding confusing missing-file warnings for completed workspaces. (#17491) Thanks @gumadeiras. -- Memory/QMD: scope managed collection names per agent and precreate glob-backed collection directories before registration, preventing cross-agent collection clobbering and startup ENOENT failures in fresh workspaces. (#17194) Thanks @jonathanadams96. -- Gateway/Memory: initialize QMD startup sync for every configured agent (not just the default agent), so `memory.qmd.update.onBoot` is effective across multi-agent setups. (#17663) Thanks @HenryLoenwind. -- Auto-reply/WhatsApp/TUI/Web: when a final assistant message is `NO_REPLY` and a messaging tool send succeeded, mirror the delivered messaging-tool text into session-visible assistant output so TUI/Web no longer show `NO_REPLY` placeholders. (#7010) Thanks @Morrowind-Xie. -- Cron: infer `payload.kind="agentTurn"` for model-only `cron.update` payload patches, so partial agent-turn updates do not fail validation when `kind` is omitted. (#15664) Thanks @rodrigouroz. -- TUI: make searchable-select filtering and highlight rendering ANSI-aware so queries ignore hidden escape codes and no longer corrupt ANSI styling sequences during match highlighting. (#4519) Thanks @bee4come. -- TUI/Windows: coalesce rapid single-line submit bursts in Git Bash into one multiline message as a fallback when bracketed paste is unavailable, preventing pasted multiline text from being split into multiple sends. (#4986) Thanks @adamkane. -- TUI: suppress false `(no output)` placeholders for non-local empty final events during concurrent runs, preventing external-channel replies from showing empty assistant bubbles while a local run is still streaming. (#5782) Thanks @LagWizard and @vignesh07. -- TUI: preserve copy-sensitive long tokens (URLs/paths/file-like identifiers) during wrapping and overflow sanitization so wrapped output no longer inserts spaces that corrupt copy/paste values. (#17515, #17466, #17505) Thanks @abe238, @trevorpan, and @JasonCry. -- CLI/Build: make legacy daemon CLI compatibility shim generation tolerant of minimal tsdown daemon export sets, while preserving restart/register compatibility aliases and surfacing explicit errors for unavailable legacy daemon commands. Thanks @vignesh07. - -## 2026.2.14 - -### Changes - -- Telegram: add poll sending via `openclaw message poll` (duration seconds, silent delivery, anonymity controls). (#16209) Thanks @robbyczgw-cla. -- Slack/Discord: add `dmPolicy` + `allowFrom` config aliases for DM access control; legacy `dm.policy` + `dm.allowFrom` keys remain supported and `openclaw doctor --fix` can migrate them. -- Discord: allow exec approval prompts to target channels or both DM+channel via `channels.discord.execApprovals.target`. (#16051) Thanks @leonnardo. -- Sandbox: add `sandbox.browser.binds` to configure browser-container bind mounts separately from exec containers. (#16230) Thanks @seheepeak. -- Discord: add debug logging for message routing decisions to improve `--debug` tracing. (#16202) Thanks @jayleekr. -- Agents: add optional `messages.suppressToolErrors` config to hide non-mutating tool-failure warnings from user-facing chat while still surfacing mutating failures. (#16620) Thanks @vai-oro. - -### Fixes - -- CLI/Installation: fix Docker installation hangs on macOS. (#12972) Thanks @vincentkoc. -- Models: fix antigravity opus 4.6 availability follow-up. (#12845) Thanks @vincentkoc. -- Security/Sessions/Telegram: restrict session tool targeting by default to the current session tree (`tools.sessions.visibility`, default `tree`) with sandbox clamping, and pass configured per-account Telegram webhook secrets in webhook mode when no explicit override is provided. Thanks @aether-ai-agent. -- CLI/Plugins: ensure `openclaw message send` exits after successful delivery across plugin-backed channels so one-shot sends do not hang. (#16491) Thanks @yinghaosang. -- CLI/Plugins: run registered plugin `gateway_stop` hooks before `openclaw message` exits (success and failure paths), so plugin-backed channels can clean up one-shot CLI resources. (#16580) Thanks @gumadeiras. -- WhatsApp: honor per-account `dmPolicy` overrides (account-level settings now take precedence over channel defaults for inbound DMs). (#10082) Thanks @mcaxtr. -- Telegram: when `channels.telegram.commands.native` is `false`, exclude plugin commands from `setMyCommands` menu registration while keeping plugin slash handlers callable. (#15132) Thanks @Glucksberg. -- LINE: return 200 OK for Developers Console "Verify" requests (`{"events":[]}`) without `X-Line-Signature`, while still requiring signatures for real deliveries. (#16582) Thanks @arosstale. -- Cron: deliver text-only output directly when `delivery.to` is set so cron recipients get full output instead of summaries. (#16360) Thanks @thewilloftheshadow. -- Cron/Slack: preserve agent identity (name and icon) when cron jobs deliver outbound messages. (#16242) Thanks @robbyczgw-cla. -- Media: accept `MEDIA:`-prefixed paths (lenient whitespace) when loading outbound media to prevent `ENOENT` for tool-returned local media paths. (#13107) Thanks @mcaxtr. -- Media understanding: treat binary `application/vnd.*`/zip/octet-stream attachments as non-text (while keeping vendor `+json`/`+xml` text-eligible) so Office/ZIP files are not inlined into prompt body text. (#16513) Thanks @rmramsey32. -- Agents: deliver tool result media (screenshots, images, audio) to channels regardless of verbose level. (#11735) Thanks @strelov1. -- Auto-reply/Block streaming: strip leading whitespace from streamed block replies so messages starting with blank lines no longer deliver visible leading empty lines. (#16422) Thanks @mcinteerj. -- Auto-reply/Queue: keep queued followups and overflow summaries when drain attempts fail, then retry delivery instead of dropping messages on transient errors. (#16771) Thanks @mmhzlrj. -- Agents/Image tool: allow workspace-local image paths by including the active workspace directory in local media allowlists, and trust sandbox-validated paths in image loaders to prevent false "not under an allowed directory" rejections. (#15541) -- Agents/Image tool: propagate the effective workspace root into tool wiring so workspace-local image paths are accepted by default when running without an explicit `workspaceDir`. (#16722) -- BlueBubbles: include sender identity in group chat envelopes and pass clean message text to the agent prompt, aligning with iMessage/Signal formatting. (#16210) Thanks @zerone0x. -- CLI: fix lazy core command registration so top-level maintenance commands (`doctor`, `dashboard`, `reset`, `uninstall`) resolve correctly instead of exposing a non-functional `maintenance` placeholder command. -- CLI/Dashboard: when `gateway.bind=lan`, generate localhost dashboard URLs to satisfy browser secure-context requirements while preserving non-LAN bind behavior. (#16434) Thanks @BinHPdev. -- TUI/Gateway: resolve local gateway target URL from `gateway.bind` mode (tailnet/lan) instead of hardcoded localhost so `openclaw tui` connects when gateway is non-loopback. (#16299) Thanks @cortexuvula. -- TUI: honor explicit `--session ` in `openclaw tui` even when `session.scope` is `global`, so named sessions no longer collapse into shared global history. (#16575) Thanks @cinqu. -- TUI: use available terminal width for session name display in searchable select lists. (#16238) Thanks @robbyczgw-cla. -- TUI: preserve in-flight streaming replies when a different run finalizes concurrently (avoid clearing active run or reloading history mid-stream). (#10704) Thanks @axschr73. -- TUI: keep pre-tool streamed text visible when later tool-boundary deltas temporarily omit earlier text blocks. (#6958) Thanks @KrisKind75. -- TUI: sanitize ANSI/control-heavy history text, redact binary-like lines, and split pathological long unbroken tokens before rendering to prevent startup crashes on binary attachment history. (#13007) Thanks @wilkinspoe. -- TUI: harden render-time sanitizer for narrow terminals by chunking moderately long unbroken tokens and adding fast-path sanitization guards to reduce overhead on normal text. (#5355) Thanks @tingxueren. -- TUI: render assistant body text in terminal default foreground (instead of fixed light ANSI color) so contrast remains readable on light themes such as Solarized Light. (#16750) Thanks @paymog. -- TUI/Hooks: pass explicit reset reason (`new` vs `reset`) through `sessions.reset` and emit internal command hooks for gateway-triggered resets so `/new` hook workflows fire in TUI/webchat. -- Gateway/Agent: route bare `/new` and `/reset` through `sessions.reset` before running the fresh-session greeting prompt, so reset commands clear the current session in-place instead of falling through to normal agent runs. (#16732) Thanks @kdotndot and @vignesh07. -- Cron: prevent `cron list`/`cron status` from silently skipping past-due recurring jobs by using maintenance recompute semantics. (#16156) Thanks @zerone0x. -- Cron: repair missing/corrupt `nextRunAtMs` for the updated job without globally recomputing unrelated due jobs during `cron update`. (#15750) -- Cron: treat persisted jobs with missing `enabled` as enabled by default across update/list/timer due-path checks, and add regression coverage for missing-`enabled` store records. (#15433) Thanks @eternauta1337. -- Cron: skip missed-job replay on startup for jobs interrupted mid-run (stale `runningAtMs` markers), preventing restart loops for self-restarting jobs such as update tasks. (#16694) Thanks @sbmilburn. -- Heartbeat/Cron: treat cron-tagged queued system events as cron reminders even on interval wakes, so isolated cron announce summaries no longer run under the default heartbeat prompt. (#14947) Thanks @archedark-ada and @vignesh07. -- Discord: prefer gateway guild id when logging inbound messages so cached-miss guilds do not appear as `guild=dm`. Thanks @thewilloftheshadow. -- Discord: treat empty per-guild `channels: {}` config maps as no channel allowlist (not deny-all), so `groupPolicy: "open"` guilds without explicit channel entries continue to receive messages. (#16714) Thanks @xqliu. -- Models/CLI: guard `models status` string trimming paths to prevent crashes from malformed non-string config values. (#16395) Thanks @BinHPdev. -- Gateway/Subagents: preserve queued announce items and summary state on delivery errors, retry failed announce drains, and avoid dropping unsent announcements on timeout/failure. (#16729) Thanks @Clawdette-Workspace. -- Gateway/Config: make `config.patch` merge object arrays by `id` (for example `agents.list`) instead of replacing the whole array, so partial agent updates do not silently delete unrelated agents. (#6766) Thanks @lightclient. -- Webchat/Prompts: stop injecting direct-chat `conversation_label` into inbound untrusted metadata context blocks, preventing internal label noise from leaking into visible chat replies. (#16556) Thanks @nberardi. -- Auto-reply/Prompts: include trusted inbound `message_id`, `chat_id`, `reply_to_id`, and optional `message_id_full` metadata fields so action tools (for example reactions) can target the triggering message without relying on user text. (#17662) Thanks @MaikiMolto. -- Gateway/Sessions: abort active embedded runs and clear queued session work before `sessions.reset`, returning unavailable if the run does not stop in time. (#16576) Thanks @Grynn. -- Sessions/Agents: harden transcript path resolution for mismatched agent context by preserving explicit store roots and adding safe absolute-path fallback to the correct agent sessions directory. (#16288) Thanks @robbyczgw-cla. -- Agents: add a safety timeout around embedded `session.compact()` to ensure stalled compaction runs settle and release blocked session lanes. (#16331) Thanks @BinHPdev. -- Agents/Tools: make required-parameter validation errors list missing fields and instruct: "Supply correct parameters before retrying," reducing repeated invalid tool-call loops (for example `read({})`). (#14729) -- Agents: keep unresolved mutating tool failures visible until the same action retry succeeds, scope mutation-error surfacing to mutating calls (including `session_status` model changes), and dedupe duplicate failure warnings in outbound replies. (#16131) Thanks @Swader. -- Agents/Process/Bootstrap: preserve unbounded `process log` offset-only pagination (default tail applies only when both `offset` and `limit` are omitted) and enforce strict `bootstrapTotalMaxChars` budgeting across injected bootstrap content (including markers), skipping additional injection when remaining budget is too small. (#16539) Thanks @CharlieGreenman. -- Agents/Workspace: persist bootstrap onboarding state so partially initialized workspaces recover missing `BOOTSTRAP.md` once, while completed onboarding keeps BOOTSTRAP deleted even if runtime files are later recreated. Thanks @gumadeiras. -- Agents/Workspace: create `BOOTSTRAP.md` when core workspace files are seeded in partially initialized workspaces, while keeping BOOTSTRAP one-shot after onboarding deletion. (#16457) Thanks @robbyczgw-cla. -- Agents: classify external timeout aborts during compaction the same as internal timeouts, preventing unnecessary auth-profile rotation and preserving compaction-timeout snapshot fallback behavior. (#9855) Thanks @mverrilli. -- Agents: treat empty-stream provider failures (`request ended without sending any chunks`) as timeout-class failover signals, enabling auth-profile rotation/fallback and showing a friendly timeout message instead of raw provider errors. (#10210) Thanks @zenchantlive. -- Agents: treat `read` tool `file_path` arguments as valid in tool-start diagnostics to avoid false “read tool called without path” warnings when alias parameters are used. (#16717) Thanks @Stache73. -- Agents/Transcript: drop malformed tool-call blocks with blank required fields (`id`/`name` or missing `input`/`arguments`) during session transcript repair to prevent persistent tool-call corruption on future turns. (#15485) Thanks @mike-zachariades. -- Tools/Write/Edit: normalize structured text-block arguments for `content`/`oldText`/`newText` before filesystem edits, preventing JSON-like file corruption and false “exact text not found” misses from block-form params. (#16778) Thanks @danielpipernz. -- Ollama/Agents: avoid forcing `` tag enforcement for Ollama models, which could suppress all output as `(no output)`. (#16191) Thanks @Glucksberg. -- Plugins: suppress false duplicate plugin id warnings when the same extension is discovered via multiple paths (config/workspace/global vs bundled), while still warning on genuine duplicates. (#16222) Thanks @shadril238. -- Agents/Process: supervise PTY/child process lifecycles with explicit ownership, cancellation, timeouts, and deterministic cleanup, preventing Codex/Pi PTY sessions from dying or stalling on resume. (#14257) Thanks @onutc. -- Skills: watch `SKILL.md` only when refreshing skills snapshot to avoid file-descriptor exhaustion in large data trees. (#11325) Thanks @household-bard. -- Memory/QMD: make `memory status` read-only by skipping QMD boot update/embed side effects for status-only manager checks. -- Memory/QMD: keep original QMD failures when builtin fallback initialization fails (for example missing embedding API keys), instead of replacing them with fallback init errors. -- Memory/Builtin: keep `memory status` dirty reporting stable across invocations by deriving status-only manager dirty state from persisted index metadata instead of process-start defaults. (#10863) Thanks @BarryYangi. -- Memory/QMD: cap QMD command output buffering to prevent memory exhaustion from pathological `qmd` command output. -- Memory/QMD: parse qmd scope keys once per request to avoid repeated parsing in scope checks. -- Memory/QMD: query QMD index using exact docid matches before falling back to prefix lookup for better recall correctness and index efficiency. -- Memory/QMD: pass result limits to `search`/`vsearch` commands so QMD can cap results earlier. -- Memory/QMD: avoid reading full markdown files when a `from/lines` window is requested in QMD reads. -- Memory/QMD: skip rewriting unchanged session export markdown files during sync to reduce disk churn. -- Memory/QMD: make QMD result JSON parsing resilient to noisy command output by extracting the first JSON array from noisy `stdout`. -- Memory/QMD: treat prefixed `no results found` marker output as an empty result set in qmd JSON parsing. (#11302) Thanks @blazerui. -- Memory/QMD: avoid multi-collection `query` ranking corruption by running one `qmd query -c ` per managed collection and merging by best score (also used for `search`/`vsearch` fallback-to-query). (#16740) Thanks @volarian-vai. -- Memory/QMD: rebind managed collections when existing collection metadata drifts (including sessions name-only listings), preventing non-default agents from reusing another agent's `sessions` collection path. (#17194) Thanks @jonathanadams96. -- Memory/QMD: make `openclaw memory index` verify and print the active QMD index file path/size, and fail when QMD leaves a missing or zero-byte index artifact after an update. (#16775) Thanks @Shunamxiao. -- Memory/QMD: detect null-byte `ENOTDIR` update failures, rebuild managed collections once, and retry update to self-heal corrupted collection metadata. (#12919) Thanks @jorgejhms. -- Memory/QMD/Security: add `rawKeyPrefix` support for QMD scope rules and preserve legacy `keyPrefix: "agent:..."` matching, preventing scoped deny bypass when operators match agent-prefixed session keys. -- Memory/Builtin: narrow memory watcher targets to markdown globs and ignore dependency/venv directories to reduce file-descriptor pressure during memory sync startup. (#11721) Thanks @rex05ai. -- Security/Memory-LanceDB: treat recalled memories as untrusted context (escape injected memory text + explicit non-instruction framing), skip likely prompt-injection payloads during auto-capture, and restrict auto-capture to user messages to reduce memory-poisoning risk. (#12524) Thanks @davidschmid24. -- Security/Memory-LanceDB: require explicit `autoCapture: true` opt-in (default is now disabled) to prevent automatic PII capture unless operators intentionally enable it. (#12552) Thanks @fr33d3m0n. -- Diagnostics/Memory: prune stale diagnostic session state entries and cap tracked session states to prevent unbounded in-memory growth on long-running gateways. (#5136) Thanks @coygeek and @vignesh07. -- Gateway/Memory: clean up `agentRunSeq` tracking on run completion/abort and enforce maintenance-time cap pruning to prevent unbounded sequence-map growth over long uptimes. (#6036) Thanks @coygeek and @vignesh07. -- Auto-reply/Memory: bound `ABORT_MEMORY` growth by evicting oldest entries and deleting reset (`false`) flags so abort state tracking cannot grow unbounded over long uptimes. (#6629) Thanks @coygeek and @vignesh07. -- Slack/Memory: bound thread-starter cache growth with TTL + max-size pruning to prevent long-running Slack gateways from accumulating unbounded thread cache state. (#5258) Thanks @coygeek and @vignesh07. -- Outbound/Memory: bound directory cache growth with max-size eviction and proactive TTL pruning to prevent long-running gateways from accumulating unbounded directory entries. (#5140) Thanks @coygeek and @vignesh07. -- Skills/Memory: remove disconnected nodes from remote-skills cache to prevent stale node metadata from accumulating over long uptimes. (#6760) Thanks @coygeek. -- Sandbox/Tools: make sandbox file tools bind-mount aware (including absolute container paths) and enforce read-only bind semantics for writes. (#16379) Thanks @tasaankaeris. -- Sandbox/Prompts: show the sandbox container workdir as the prompt working directory and clarify host-path usage for file tools, preventing host-path `exec` failures in sandbox sessions. (#16790) Thanks @carrotRakko. -- Media/Security: allow local media reads from OpenClaw state `workspace/` and `sandboxes/` roots by default so generated workspace media can be delivered without unsafe global path bypasses. (#15541) Thanks @lanceji. -- Media/Security: harden local media allowlist bypasses by requiring an explicit `readFile` override when callers mark paths as validated, and reject filesystem-root `localRoots` entries. (#16739) -- Media/Security: allow outbound local media reads from the active agent workspace (including `workspace-`) via agent-scoped local roots, avoiding broad global allowlisting of all per-agent workspaces. (#17136) Thanks @MisterGuy420. -- Outbound/Media: thread explicit `agentId` through core `sendMessage` direct-delivery path so agent-scoped local media roots apply even when mirror metadata is absent. (#17268) Thanks @gumadeiras. -- Discord/Security: harden voice message media loading (SSRF + allowed-local-root checks) so tool-supplied paths/URLs cannot be used to probe internal URLs or read arbitrary local files. -- Security/BlueBubbles: require explicit `mediaLocalRoots` allowlists for local outbound media path reads to prevent local file disclosure. (#16322) Thanks @mbelinky. -- Security/BlueBubbles: reject ambiguous shared-path webhook routing when multiple webhook targets match the same guid/password. -- Security/BlueBubbles: harden BlueBubbles webhook auth behind reverse proxies by only accepting passwordless webhooks for direct localhost loopback requests (forwarded/proxied requests now require a password). Thanks @simecek. -- Feishu/Security: harden media URL fetching against SSRF and local file disclosure. (#16285) Thanks @mbelinky. -- Security/Zalo: reject ambiguous shared-path webhook routing when multiple webhook targets match the same secret. -- Security/Nostr: require loopback source and block cross-origin profile mutation/import attempts. Thanks @vincentkoc. -- Security/Signal: harden signal-cli archive extraction during install to prevent path traversal outside the install root. -- Security/Hooks: restrict hook transform modules to `~/.openclaw/hooks/transforms` (prevents path traversal/escape module loads via config). Config note: `hooks.transformsDir` must now be within that directory. Thanks @akhmittra. -- Security/Hooks: ignore hook package manifest entries that point outside the package directory (prevents out-of-tree handler loads during hook discovery). -- Security/Archive: enforce archive extraction entry/size limits to prevent resource exhaustion from high-expansion ZIP/TAR archives. Thanks @vincentkoc. -- Security/Media: reject oversized base64-backed input media before decoding to avoid large allocations. Thanks @vincentkoc. -- Security/Media: stream and bound URL-backed input media fetches to prevent memory exhaustion from oversized responses. Thanks @vincentkoc. -- Security/Skills: harden archive extraction for download-installed skills to prevent path traversal outside the target directory. Thanks @markmusson. -- Security/Slack: compute command authorization for DM slash commands even when `dmPolicy=open`, preventing unauthorized users from running privileged commands via DM. Thanks @christos-eth. -- Security/Pairing: scope pairing allowlist writes/reads to channel accounts (for example `telegram:yy`), and propagate account-aware pairing approvals so multi-account channels do not share a single per-channel pairing allowFrom store. (#17631) Thanks @crazytan. -- Security/iMessage: keep DM pairing-store identities out of group allowlist authorization (prevents cross-context command authorization). Thanks @vincentkoc. -- Security/Google Chat: deprecate `users/` allowlists (treat `users/...` as immutable user id only); keep raw email allowlists for usability. Thanks @vincentkoc. -- Security/Google Chat: reject ambiguous shared-path webhook routing when multiple webhook targets verify successfully (prevents cross-account policy-context misrouting). Thanks @vincentkoc. -- Telegram/Security: require numeric Telegram sender IDs for allowlist authorization (reject `@username` principals), auto-resolve `@username` to IDs in `openclaw doctor --fix` (when possible), and warn in `openclaw security audit` when legacy configs contain usernames. Thanks @vincentkoc. -- Telegram/Security: reject Telegram webhook startup when `webhookSecret` is missing or empty (prevents unauthenticated webhook request forgery). Thanks @yueyueL. -- Security/Windows: avoid shell invocation when spawning child processes to prevent cmd.exe metacharacter injection via untrusted CLI arguments (e.g. agent prompt text). -- Telegram: set webhook callback timeout handling to `onTimeout: "return"` (10s) so long-running update processing no longer emits webhook 500s and retry storms. (#16763) Thanks @chansearrington. -- Signal: preserve case-sensitive `group:` target IDs during normalization so mixed-case group IDs no longer fail with `Group not found`. (#16748) Thanks @repfigit. -- Security/Agents: scope CLI process cleanup to owned child PIDs to avoid killing unrelated processes on shared hosts. Thanks @aether-ai-agent. -- Security/Agents: enforce workspace-root path bounds for `apply_patch` in non-sandbox mode to block traversal and symlink escape writes. Thanks @p80n-sec. -- Security/Agents: enforce symlink-escape checks for `apply_patch` delete hunks under `workspaceOnly`, while still allowing deleting the symlink itself. Thanks @p80n-sec. -- Security/Agents (macOS): prevent shell injection when writing Claude CLI keychain credentials. (#15924) Thanks @aether-ai-agent. -- macOS: hard-limit unkeyed `openclaw://agent` deep links and ignore `deliver` / `to` / `channel` unless a valid unattended key is provided. Thanks @Cillian-Collins. -- Scripts/Security: validate GitHub logins and avoid shell invocation in `scripts/update-clawtributors.ts` to prevent command injection via malicious commit records. Thanks @scanleale. -- Security: fix Chutes manual OAuth login state validation by requiring the full redirect URL (reject code-only pastes) (thanks @aether-ai-agent). -- Security/Gateway: harden tool-supplied `gatewayUrl` overrides by restricting them to loopback or the configured `gateway.remote.url`. Thanks @p80n-sec. -- Security/Gateway: block `system.execApprovals.*` via `node.invoke` (use `exec.approvals.node.*` instead). Thanks @christos-eth. -- Security/Gateway: reject oversized base64 chat attachments before decoding to avoid large allocations. Thanks @vincentkoc. -- Security/Gateway: stop returning raw resolved config values in `skills.status` requirement checks (prevents operator.read clients from reading secrets). Thanks @simecek. -- Security/Net: fix SSRF guard bypass via full-form IPv4-mapped IPv6 literals (blocks loopback/private/metadata access). Thanks @yueyueL. -- Security/Browser: harden browser control file upload + download helpers to prevent path traversal / local file disclosure. Thanks @1seal. -- Security/Browser: block cross-origin mutating requests to loopback browser control routes (CSRF hardening). Thanks @vincentkoc. -- Security/Node Host: enforce `system.run` rawCommand/argv consistency to prevent allowlist/approval bypass. Thanks @christos-eth. -- Security/Exec approvals: prevent safeBins allowlist bypass via shell expansion (host exec allowlist mode only; not enabled by default). Thanks @christos-eth. -- Security/Exec: harden PATH handling by disabling project-local `node_modules/.bin` bootstrapping by default, disallowing node-host `PATH` overrides, and spawning ACP servers via the current executable by default. Thanks @akhmittra. -- Security/Tlon: harden Urbit URL fetching against SSRF by blocking private/internal hosts by default (opt-in: `channels.tlon.allowPrivateNetwork`). Thanks @p80n-sec. -- Security/Voice Call (Telnyx): require webhook signature verification when receiving inbound events; configs without `telnyx.publicKey` are now rejected unless `skipSignatureVerification` is enabled. Thanks @p80n-sec. -- Security/Voice Call: require valid Twilio webhook signatures even when ngrok free tier loopback compatibility mode is enabled. Thanks @p80n-sec. -- Security/Discovery: stop treating Bonjour TXT records as authoritative routing (prefer resolved service endpoints) and prevent discovery from overriding stored TLS pins; autoconnect now requires a previously trusted gateway. Thanks @simecek. - -## 2026.2.13 - -### Changes - -- Install: add optional Podman-based setup: `setup-podman.sh` for one-time host setup (openclaw user, image, launch script, systemd quadlet), `run-openclaw-podman.sh launch` / `launch setup`; systemd Quadlet unit for openclaw user service; docs for rootless container, openclaw user (subuid/subgid), and quadlet (troubleshooting). (#16273) Thanks @DarwinsBuddy. -- Discord: send voice messages with waveform previews from local audio files (including silent delivery). (#7253) Thanks @nyanjou. -- Discord: add configurable presence status/activity/type/url (custom status defaults to activity text). (#10855) Thanks @h0tp-ftw. -- Slack/Plugins: add thread-ownership outbound gating via `message_sending` hooks, including @-mention bypass tracking and Slack outbound hook wiring for cancel/modify behavior. (#15775) Thanks @DarlingtonDeveloper. -- Agents: add synthetic catalog support for `hf:zai-org/GLM-5`. (#15867) Thanks @battman21. -- Skills: remove duplicate `local-places` Google Places skill/proxy and keep `goplaces` as the single supported Google Places path. -- Agents: add pre-prompt context diagnostics (`messages`, `systemPromptChars`, `promptChars`, provider/model, session file) before embedded runner prompt calls to improve overflow debugging. (#8930) Thanks @Glucksberg. -- Onboarding/Providers: add first-class Hugging Face Inference provider support (provider wiring, onboarding auth choice/API key flow, and default-model selection), and preserve Hugging Face auth intent in auth-choice remapping (`tokenProvider=huggingface` with `authChoice=apiKey`) while skipping env-override prompts when an explicit token is provided. (#13472) Thanks @Josephrp. -- Onboarding/Providers: add `minimax-api-key-cn` auth choice for the MiniMax China API endpoint. (#15191) Thanks @liuy. - -### Breaking - -- Config/State: removed legacy `.moltbot` auto-detection/migration and `moltbot.json` config candidates. If you still have state/config under `~/.moltbot`, move it to `~/.openclaw` (recommended) or set `OPENCLAW_STATE_DIR` / `OPENCLAW_CONFIG_PATH` explicitly. - -### Fixes - -- Gateway/Auth: add trusted-proxy mode hardening follow-ups by keeping `OPENCLAW_GATEWAY_*` env compatibility, auto-normalizing invalid setup combinations in interactive `gateway configure` (trusted-proxy forces `bind=lan` and disables Tailscale serve/funnel), and suppressing shared-secret/rate-limit audit findings that do not apply to trusted-proxy deployments. (#15940) Thanks @nickytonline. -- Docs/Hooks: update hooks documentation URLs to the new `/automation/hooks` location. (#16165) Thanks @nicholascyh. -- Security/Audit: warn when `gateway.tools.allow` re-enables default-denied tools over HTTP `POST /tools/invoke`, since this can increase RCE blast radius if the gateway is reachable. -- Security/Plugins/Hooks: harden npm-based installs by restricting specs to registry packages only, passing `--ignore-scripts` to `npm pack`, and cleaning up temp install directories. -- Security/Sessions: preserve inter-session input provenance for routed prompts so delegated/internal sessions are not treated as direct external user instructions. Thanks @anbecker. -- Feishu: stop persistent Typing reaction on NO_REPLY/suppressed runs by wiring reply-dispatcher cleanup to remove typing indicators. (#15464) Thanks @arosstale. -- Agents: strip leading empty lines from `sanitizeUserFacingText` output and normalize whitespace-only outputs to empty text. (#16158) Thanks @mcinteerj. -- BlueBubbles: gracefully degrade when Private API is disabled by filtering private-only actions, skipping private-only reactions/reply effects, and avoiding private reply markers so non-private flows remain usable. (#16002) Thanks @L-U-C-K-Y. -- Outbound: add a write-ahead delivery queue with crash-recovery retries to prevent lost outbound messages after gateway restarts. (#15636) Thanks @nabbilkhan, @thewilloftheshadow. -- Auto-reply/Threading: auto-inject implicit reply threading so `replyToMode` works without requiring model-emitted `[[reply_to_current]]`, while preserving `replyToMode: "off"` behavior for implicit Slack replies and keeping block-streaming chunk coalescing stable under `replyToMode: "first"`. (#14976) Thanks @Diaspar4u. -- Auto-reply/Threading: honor explicit `[[reply_to_*]]` tags even when `replyToMode` is `off`. (#16174) Thanks @aldoeliacim. -- Plugins/Threading: rename `allowTagsWhenOff` to `allowExplicitReplyTagsWhenOff` and keep the old key as a deprecated alias for compatibility. (#16189) -- Outbound/Threading: pass `replyTo` and `threadId` from `message send` tool actions through the core outbound send path to channel adapters, preserving thread/reply routing. (#14948) Thanks @mcaxtr. -- Auto-reply/Media: allow image-only inbound messages (no caption) to reach the agent instead of short-circuiting as empty text, and preserve thread context in queued/followup prompt bodies for media-only runs. (#11916) Thanks @arosstale. -- Discord: route autoThread replies to existing threads instead of the root channel. (#8302) Thanks @gavinbmoore, @thewilloftheshadow. -- Web UI: add `img` to DOMPurify allowed tags and `src`/`alt` to allowed attributes so markdown images render in webchat instead of being stripped. (#15437) Thanks @lailoo. -- Telegram/Matrix: treat MP3 and M4A (including `audio/mp4`) as voice-compatible for `asVoice` routing, and keep WAV/AAC falling back to regular audio sends. (#15438) Thanks @azade-c. -- WhatsApp: preserve outbound document filenames for web-session document sends instead of always sending `"file"`. (#15594) Thanks @TsekaLuk. -- Telegram: cap bot menu registration to Telegram's 100-command limit with an overflow warning while keeping typed hidden commands available. (#15844) Thanks @battman21. -- Telegram: scope skill commands to the resolved agent for default accounts so `setMyCommands` no longer triggers `BOT_COMMANDS_TOO_MUCH` when multiple agents are configured. (#15599) -- Discord: avoid misrouting numeric guild allowlist entries to `/channels/` by prefixing guild-only inputs with `guild:` during resolution. (#12326) Thanks @headswim. -- Memory/QMD: default `memory.qmd.searchMode` to `search` for faster CPU-only recall and always scope `search`/`vsearch` requests to managed collections (auto-falling back to `query` when required). (#16047) Thanks @togotago. -- Memory/LanceDB: add configurable `captureMaxChars` for auto-capture while keeping the legacy 500-char default. (#16641) Thanks @ciberponk. -- MS Teams: preserve parsed mention entities/text when appending OneDrive fallback file links, and accept broader real-world Teams mention ID formats (`29:...`, `8:orgid:...`) while still rejecting placeholder patterns. (#15436) Thanks @hyojin. -- Media: classify `text/*` MIME types as documents in media-kind routing so text attachments are no longer treated as unknown. (#12237) Thanks @arosstale. -- Inbound/Web UI: preserve literal `\n` sequences when normalizing inbound text so Windows paths like `C:\\Work\\nxxx\\README.md` are not corrupted. (#11547) Thanks @mcaxtr. -- TUI/Streaming: preserve richer streamed assistant text when final payload drops pre-tool-call text blocks, while keeping non-empty final payload authoritative for plain-text updates. (#15452) Thanks @TsekaLuk. -- Providers/MiniMax: switch implicit MiniMax API-key provider from `openai-completions` to `anthropic-messages` with the correct Anthropic-compatible base URL, fixing `invalid role: developer (2013)` errors on MiniMax M2.5. (#15275) Thanks @lailoo. -- Ollama/Agents: use resolved model/provider base URLs for native `/api/chat` streaming (including aliased providers), normalize `/v1` endpoints, and forward abort + `maxTokens` stream options for reliable cancellation and token caps. (#11853) Thanks @BrokenFinger98. -- OpenAI Codex/Spark: implement end-to-end `gpt-5.3-codex-spark` support across fallback/thinking/model resolution and `models list` forward-compat visibility. (#14990, #15174) Thanks @L-U-C-K-Y, @loiie45e. -- Agents/Codex: allow `gpt-5.3-codex-spark` in forward-compat fallback, live model filtering, and thinking presets, and fix model-picker recognition for spark. (#14990) Thanks @L-U-C-K-Y. -- Models/Codex: resolve configured `openai-codex/gpt-5.3-codex-spark` through forward-compat fallback during `models list`, so it is not incorrectly tagged as missing when runtime resolution succeeds. (#15174) Thanks @loiie45e. -- OpenAI Codex/Auth: bridge OpenClaw OAuth profiles into `pi` `auth.json` so model discovery and models-list registry resolution can use Codex OAuth credentials. (#15184) Thanks @loiie45e. -- Auth/OpenAI Codex: share OAuth login handling across onboarding and `models auth login --provider openai-codex`, keep onboarding alive when OAuth fails, and surface a direct OAuth help note instead of terminating the wizard. (#15406, follow-up to #14552) Thanks @zhiluo20. -- Onboarding/Providers: add vLLM as an onboarding provider with model discovery, auth profile wiring, and non-interactive auth-choice validation. (#12577) Thanks @gejifeng. -- Onboarding/CLI: restore terminal state without resuming paused `stdin`, so onboarding exits cleanly (including Docker TTY installs that would otherwise hang). (#12972) Thanks @vincentkoc. -- Signal/Install: auto-install `signal-cli` via Homebrew on non-x64 Linux architectures, avoiding x86_64 native binary `Exec format error` failures on arm64/arm hosts. (#15443) Thanks @jogvan-k. -- macOS Voice Wake: fix a crash in trigger trimming for CJK/Unicode transcripts by matching and slicing on original-string ranges instead of transformed-string indices. (#11052) Thanks @Flash-LHR. -- Mattermost (plugin): retry websocket monitor connections with exponential backoff and abort-aware teardown so transient connect failures no longer permanently stop monitoring. (#14962) Thanks @mcaxtr. -- Discord/Agents: apply channel/group `historyLimit` during embedded-runner history compaction to prevent long-running channel sessions from bypassing truncation and overflowing context windows. (#11224) Thanks @shadril238. -- Outbound targets: fail closed for WhatsApp/Twitch/Google Chat fallback paths so invalid or missing targets are dropped instead of rerouted, and align resolver hints with strict target requirements. (#13578) Thanks @mcaxtr. -- Gateway/Restart: clear stale command-queue and heartbeat wake runtime state after SIGUSR1 in-process restarts to prevent zombie gateway behavior where queued work stops draining. (#15195) Thanks @joeykrug. -- Heartbeat: prevent scheduler silent-death races during runner reloads, preserve retry cooldown backoff under wake bursts, and prioritize user/action wake causes over interval/retry reasons when coalescing. (#15108) Thanks @joeykrug. -- Heartbeat: allow explicit wake (`wake`) and hook wake (`hook:*`) reasons to run even when `HEARTBEAT.md` is effectively empty so queued system events are processed. (#14527) Thanks @arosstale. -- Auto-reply/Heartbeat: strip sentence-ending `HEARTBEAT_OK` tokens even when followed by up to 4 punctuation characters, while preserving surrounding sentence punctuation. (#15847) Thanks @Spacefish. -- Sessions/Agents: pass `agentId` when resolving existing transcript paths in reply runs so non-default agents and heartbeat/chat handlers no longer fail with `Session file path must be within sessions directory`. (#15141) Thanks @Goldenmonstew. -- Sessions/Agents: pass `agentId` through status and usage transcript-resolution paths (auto-reply, gateway usage APIs, and session cost/log loaders) so non-default agents can resolve absolute session files without path-validation failures. (#15103) Thanks @jalehman. -- Sessions: archive previous transcript files on `/new` and `/reset` session resets (including gateway `sessions.reset`) so stale transcripts do not accumulate on disk. (#14869) Thanks @mcaxtr. -- Status/Sessions: stop clamping derived `totalTokens` to context-window size, keep prompt-token snapshots wired through session accounting, and surface context usage as unknown when fresh snapshot data is missing to avoid false 100% reports. (#15114) Thanks @echoVic. -- Gateway/Routing: speed up hot paths for session listing (derived titles + previews), WS broadcast, and binding resolution. -- Gateway/Sessions: cache derived title + last-message transcript reads to speed up repeated sessions list refreshes. -- CLI/Completion: route plugin-load logs to stderr and write generated completion scripts directly to stdout to avoid `source <(openclaw completion ...)` corruption. (#15481) Thanks @arosstale. -- CLI: lazily load outbound provider dependencies and remove forced success-path exits so commands terminate naturally without killing intentional long-running foreground actions. (#12906) Thanks @DrCrinkle. -- CLI: speed up startup by lazily registering core commands (keeps rich `--help` while reducing cold-start overhead). -- Security/Gateway + ACP: block high-risk tools (`sessions_spawn`, `sessions_send`, `gateway`, `whatsapp_login`) from HTTP `/tools/invoke` by default with `gateway.tools.{allow,deny}` overrides, and harden ACP permission selection to fail closed when tool identity/options are ambiguous while supporting `allow_always`/`reject_always`. (#15390) Thanks @aether-ai-agent. -- Security/ACP: prompt for non-read/search permission requests in ACP clients (reduces silent tool approval risk). Thanks @aether-ai-agent. -- Security/Gateway: breaking default-behavior change - canvas IP-based auth fallback now only accepts machine-scoped addresses (RFC1918, link-local, ULA IPv6, CGNAT); public-source IP matches now require bearer token auth. (#14661) Thanks @sumleo. -- Security/Link understanding: block loopback/internal host patterns and private/mapped IPv6 addresses in extracted URL handling to close SSRF bypasses in link CLI flows. (#15604) Thanks @AI-Reviewer-QS. -- Security/Browser: constrain `POST /trace/stop`, `POST /wait/download`, and `POST /download` output paths to OpenClaw temp roots and reject traversal/escape paths. -- Security/Browser: sanitize download `suggestedFilename` to keep implicit `wait/download` paths within the downloads root. Thanks @1seal. -- Security/Browser: confine `POST /hooks/file-chooser` upload paths to an OpenClaw temp uploads root and reject traversal/escape paths. Thanks @1seal. -- Security/Browser: require auth for the sandbox browser bridge server (protects `/profiles`, `/tabs`, CDP URLs, and other control endpoints). Thanks @jackhax. -- Security: bind local helper servers to loopback and fail closed on non-loopback OAuth callback hosts (reduces localhost/LAN attack surface). -- Security/Canvas: serve A2UI assets via the shared safe-open path (`openFileWithinRoot`) to close traversal/TOCTOU gaps, with traversal and symlink regression coverage. (#10525) Thanks @abdelsfane. -- Security/WhatsApp: enforce `0o600` on `creds.json` and `creds.json.bak` on save/backup/restore paths to reduce credential file exposure. (#10529) Thanks @abdelsfane. -- Security/Gateway: sanitize and truncate untrusted WebSocket header values in pre-handshake close logs to reduce log-poisoning risk. Thanks @thewilloftheshadow. -- Security/Audit: add misconfiguration checks for sandbox Docker config with sandbox mode off, ineffective `gateway.nodes.denyCommands` entries, global minimal tool-profile overrides by agent profiles, and permissive extension-plugin tool reachability. -- Security/Audit: distinguish external webhooks (`hooks.enabled`) from internal hooks (`hooks.internal.enabled`) in attack-surface summaries to avoid false exposure signals when only internal hooks are enabled. (#13474) Thanks @mcaxtr. -- Security/Onboarding: clarify multi-user DM isolation remediation with explicit `openclaw config set session.dmScope ...` commands in security audit, doctor security, and channel onboarding guidance. (#13129) Thanks @VintLin. -- Security/Gateway: bind node `system.run` approval overrides to gateway exec-approval records (runId-bound), preventing approval-bypass via `node.invoke` param injection. Thanks @222n5. -- Agents/Nodes: harden node exec approval decision handling in the `nodes` tool run path by failing closed on unexpected approval decisions, and add regression coverage for approval-required retry/deny/timeout flows. (#4726) Thanks @rmorse. -- Android/Nodes: harden `app.update` by requiring HTTPS and gateway-host URL matching plus SHA-256 verification, stream URL camera downloads to disk with size guards to avoid memory spikes, and stop signing release builds with debug keys. (#13541) Thanks @smartprogrammer93. -- Routing: enforce strict binding-scope matching across peer/guild/team/roles so peer-scoped Discord/Slack bindings no longer match unrelated guild/team contexts or fallback tiers. (#15274) Thanks @lailoo. -- Exec/Allowlist: allow multiline heredoc bodies (`<<`, `<<-`) while keeping multiline non-heredoc shell commands blocked, so exec approval parsing permits heredoc input safely without allowing general newline command chaining. (#13811) Thanks @mcaxtr. -- Config: preserve `${VAR}` env references when writing config files so `openclaw config set/apply/patch` does not persist secrets to disk. Thanks @thewilloftheshadow. -- Config: remove a cross-request env-snapshot race in config writes by carrying read-time env context into write calls per request, preserving `${VAR}` refs safely under concurrent gateway config mutations. (#11560) Thanks @akoscz. -- Config: log overwrite audit entries (path, backup target, and hash transition) whenever an existing config file is replaced, improving traceability for unexpected config clobbers. -- Config: keep legacy audio transcription migration strict by rejecting non-string/unsafe command tokens while still migrating valid custom script executables. (#5042) Thanks @shayan919293. -- Config: accept `$schema` key in config file so JSON Schema editor tooling works without validation errors. (#14998) -- Gateway/Tools Invoke: sanitize `/tools/invoke` execution failures while preserving `400` for tool input errors and returning `500` for unexpected runtime failures, with regression coverage and docs updates. (#13185) Thanks @davidrudduck. -- Gateway/Hooks: preserve `408` for hook request-body timeout responses while keeping bounded auth-failure cache eviction behavior, with timeout-status regression coverage. (#15848) Thanks @AI-Reviewer-QS. -- Plugins/Hooks: fire `before_tool_call` hook exactly once per tool invocation in embedded runs by removing duplicate dispatch paths while preserving parameter mutation semantics. (#15635) Thanks @lailoo. -- Agents/Transcript policy: sanitize OpenAI/Codex tool-call ids during transcript policy normalization to prevent invalid tool-call identifiers from propagating into session history. (#15279) Thanks @divisonofficer. -- Agents/Image tool: cap image-analysis completion `maxTokens` by model capability (`min(4096, model.maxTokens)`) to avoid over-limit provider failures while still preventing truncation. (#11770) Thanks @detecti1. -- Agents/Compaction: centralize exec default resolution in the shared tool factory so per-agent `tools.exec` overrides (host/security/ask/node and related defaults) persist across compaction retries. (#15833) Thanks @napetrov. -- Gateway/Agents: stop injecting a phantom `main` agent into gateway agent listings when `agents.list` explicitly excludes it. (#11450) Thanks @arosstale. -- Process/Exec: avoid shell execution for `.exe` commands on Windows so env overrides work reliably in `runCommandWithTimeout`. Thanks @thewilloftheshadow. -- Daemon/Windows: preserve literal backslashes in `gateway.cmd` command parsing so drive and UNC paths are not corrupted in runtime checks and doctor entrypoint comparisons. (#15642) Thanks @arosstale. -- Sandbox: pass configured `sandbox.docker.env` variables to sandbox containers at `docker create` time. (#15138) Thanks @stevebot-alive. -- Voice Call: route webhook runtime event handling through shared manager event logic so rejected inbound hangups are idempotent in production, with regression tests for duplicate reject events and provider-call-ID remapping parity. (#15892) Thanks @dcantu96. -- Cron: add regression coverage for announce-mode isolated jobs so runs that already report `delivered: true` do not enqueue duplicate main-session relays, including delivery configs where `mode` is omitted and defaults to announce. (#15737) Thanks @brandonwise. -- Cron: honor `deleteAfterRun` in isolated announce delivery by mapping it to subagent announce cleanup mode, so cron run sessions configured for deletion are removed after completion. (#15368) Thanks @arosstale. -- Web tools/web_fetch: prefer `text/markdown` responses for Cloudflare Markdown for Agents, add `cf-markdown` extraction for markdown bodies, and redact fetched URLs in `x-markdown-tokens` debug logs to avoid leaking raw paths/query params. (#15376) Thanks @Yaxuan42. -- Tools/web_search: support `freshness` for the Perplexity provider by mapping `pd`/`pw`/`pm`/`py` to Perplexity `search_recency_filter` values and including freshness in the Perplexity cache key. (#15343) Thanks @echoVic. -- Clawdock: avoid Zsh readonly variable collisions in helper scripts. (#15501) Thanks @nkelner. -- Memory: switch default local embedding model to the QAT `embeddinggemma-300m-qat-Q8_0` variant for better quality at the same footprint. (#15429) Thanks @azade-c. -- Docs/Discord: expand quick setup and clarify guild workspace guidance. (#20088) Thanks @pejmanjohn, @thewilloftheshadow. -- Docs/Mermaid: remove hardcoded Mermaid init theme blocks from four docs diagrams so dark mode inherits readable theme defaults. (#15157) Thanks @heytulsiprasad. -- Security/Pairing: generate 256-bit base64url device and node pairing tokens and use byte-safe constant-time verification to avoid token-compare edge-case failures. (#16535) Thanks @FaizanKolega, @gumadeiras. - -## 2026.2.12 - -### Changes - -- CLI/Plugins: add `openclaw plugins uninstall ` with `--dry-run`, `--force`, and `--keep-files` options, including safe uninstall path handling and plugin uninstall docs. (#5985) Thanks @JustasMonkev. -- CLI: add `openclaw logs --local-time` to display log timestamps in local timezone. (#13818) Thanks @xialonglee. -- Telegram: render blockquotes as native `
` tags instead of stripping them. (#14608) -- Telegram: expose `/compact` in the native command menu. (#10352) Thanks @akramcodez. -- Discord: add role-based allowlists and role-based agent routing. (#10650) Thanks @Minidoracat. -- Config: avoid redacting `maxTokens`-like fields during config snapshot redaction, preventing round-trip validation failures in `/config`. (#14006) Thanks @constansino. - -### Breaking - -- Hooks: `POST /hooks/agent` now rejects payload `sessionKey` overrides by default. To keep fixed hook context, set `hooks.defaultSessionKey` (recommended with `hooks.allowedSessionKeyPrefixes: ["hook:"]`). If you need legacy behavior, explicitly set `hooks.allowRequestSessionKey: true`. Thanks @alpernae for reporting. - -### Fixes - -- Gateway/OpenResponses: harden URL-based `input_file`/`input_image` handling with explicit SSRF deny policy, hostname allowlists (`files.urlAllowlist` / `images.urlAllowlist`), per-request URL input caps (`maxUrlParts`), blocked-fetch audit logging, and regression coverage/docs updates. -- Sessions: guard `withSessionStoreLock` against undefined `storePath` to prevent `path.dirname` crash. (#14717) -- Security: fix unauthenticated Nostr profile API remote config tampering. (#13719) Thanks @coygeek. -- Security: remove bundled soul-evil hook. (#14757) Thanks @Imccccc. -- Security/Audit: add hook session-routing hardening checks (`hooks.defaultSessionKey`, `hooks.allowRequestSessionKey`, and prefix allowlists), and warn when HTTP API endpoints allow explicit session-key routing. -- Security/Sandbox: confine mirrored skill sync destinations to the sandbox `skills/` root and stop using frontmatter-controlled skill names as filesystem destination paths. Thanks @1seal. -- Security/Web tools: treat browser/web content as untrusted by default (wrapped outputs for browser snapshot/tabs/console and structured external-content metadata for web tools), and strip `toolResult.details` from model-facing transcript/compaction inputs to reduce prompt-injection replay risk. -- Security/Hooks: harden webhook and device token verification with shared constant-time secret comparison, and add per-client auth-failure throttling for hook endpoints (`429` + `Retry-After`). Thanks @akhmittra. -- Security/Browser: require auth for loopback browser control HTTP routes, auto-generate `gateway.auth.token` when browser control starts without auth, and add a security-audit check for unauthenticated browser control. Thanks @tcusolle. -- Sessions/Gateway: harden transcript path resolution and reject unsafe session IDs/file paths so session operations stay within agent sessions directories. Thanks @akhmittra. -- Sessions: preserve `verboseLevel`, `thinkingLevel`/`reasoningLevel`, and `ttsAuto` overrides across `/new` and `/reset` session resets. (#10787) Thanks @mcaxtr. -- Gateway: raise WS payload/buffer limits so 5,000,000-byte image attachments work reliably. (#14486) Thanks @0xRaini. -- Logging/CLI: use local timezone timestamps for console prefixing, and include `±HH:MM` offsets when using `openclaw logs --local-time` to avoid ambiguity. (#14771) Thanks @0xRaini. -- Gateway: drain active turns before restart to prevent message loss. (#13931) Thanks @0xRaini. -- Gateway: auto-generate auth token during install to prevent launchd restart loops. (#13813) Thanks @cathrynlavery. -- Gateway: prevent `undefined`/missing token in auth config. (#13809) Thanks @asklee-klawd. -- Configure/Gateway: reject literal `"undefined"`/`"null"` token input and validate gateway password prompt values to avoid invalid password-mode configs. (#13767) Thanks @omair445. -- Gateway: handle async `EPIPE` on stdout/stderr during shutdown. (#13414) Thanks @keshav55. -- Gateway/Control UI: resolve missing dashboard assets when `openclaw` is installed globally via symlink-based Node managers (nvm/fnm/n/Homebrew). (#14919) Thanks @aynorica. -- Gateway/Control UI: keep partial assistant output visible when runs are aborted, and persist aborted partials to session transcripts for follow-up context. -- Cron: use requested `agentId` for isolated job auth resolution. (#13983) Thanks @0xRaini. -- Cron: prevent cron jobs from skipping execution when `nextRunAtMs` advances. (#14068) Thanks @WalterSumbon. -- Cron: pass `agentId` to `runHeartbeatOnce` for main-session jobs. (#14140) Thanks @ishikawa-pro. -- Cron: re-arm timers when `onTimer` fires while a job is still executing. (#14233) Thanks @tomron87. -- Cron: prevent duplicate fires when multiple jobs trigger simultaneously. (#14256) Thanks @xinhuagu. -- Cron: prevent duplicate announce-mode isolated cron deliveries, and keep main-session fallback active when best-effort structured delivery attempts fail to send any message. (#15739) Thanks @widingmarcus-cyber. -- Cron: isolate scheduler errors so one bad job does not break all jobs. (#14385) Thanks @MarvinDontPanic. -- Cron: prevent one-shot `at` jobs from re-firing on restart after skipped/errored runs. (#13878) Thanks @lailoo. -- Heartbeat: prevent scheduler stalls on unexpected run errors and avoid immediate rerun loops after `requests-in-flight` skips. (#14901) Thanks @joeykrug. -- Cron: honor stored session model overrides for isolated-agent runs while preserving `hooks.gmail.model` precedence for Gmail hook sessions. (#14983) Thanks @shtse8. -- Logging/Browser: fall back to `os.tmpdir()/openclaw` for default log, browser trace, and browser download temp paths when `/tmp/openclaw` is unavailable. -- WhatsApp: convert Markdown bold/strikethrough to WhatsApp formatting. (#14285) Thanks @Raikan10. -- WhatsApp: allow media-only sends and normalize leading blank payloads. (#14408) Thanks @karimnaguib. -- WhatsApp: default MIME type for voice messages when Baileys omits it. (#14444) Thanks @mcaxtr. -- Telegram: handle no-text message in model picker editMessageText. (#14397) Thanks @0xRaini. -- Telegram: surface REACTION_INVALID as non-fatal warning. (#14340) Thanks @0xRaini. -- BlueBubbles: fix webhook auth bypass via loopback proxy trust. (#13787) Thanks @coygeek. -- Slack: change default replyToMode from "off" to "all". (#14364) Thanks @nm-de. -- Slack: honor `limit` for `emoji-list` actions across core and extension adapters, with capped emoji-list responses in the Slack action handler. (#4293) Thanks @mcaxtr. -- Slack: detect control commands when channel messages start with bot mention prefixes (for example, `@Bot /new`). (#14142) Thanks @beefiker. -- Slack: include thread reply metadata in inbound message footer context (`thread_ts`, `parent_user_id`) while keeping top-level `thread_ts == ts` events unthreaded. (#14625) Thanks @bennewton999. -- Signal: enforce E.164 validation for the Signal bot account prompt so mistyped numbers are caught early. (#15063) Thanks @Duartemartins. -- Discord: process DM reactions instead of silently dropping them. (#10418) Thanks @mcaxtr. -- Discord: treat Administrator as full permissions in channel permission checks. Thanks @thewilloftheshadow. -- Discord: respect replyToMode in threads. (#11062) Thanks @cordx56. -- Discord: add optional gateway proxy support for WebSocket connections via `channels.discord.proxy`. (#10400) Thanks @winter-loo, @thewilloftheshadow. -- Browser: add Chrome launch flag `--disable-blink-features=AutomationControlled` to reduce `navigator.webdriver` automation detection issues on reCAPTCHA-protected sites. (#10735) Thanks @Milofax. -- Heartbeat: filter noise-only system events so scheduled reminder notifications do not fire when cron runs carry only heartbeat markers. (#13317) Thanks @pvtclawn. -- Signal: render mention placeholders as `@uuid`/`@phone` so mention gating and Clawdbot targeting work. (#2013) Thanks @alexgleason. -- Agents/Reminders: guard reminder promises by appending a note when no `cron.add` succeeded in the turn, so users know nothing was scheduled. (#18588) Thanks @vignesh07. -- Discord: omit empty content fields for media-only messages while preserving caption whitespace. (#9507) Thanks @leszekszpunar. -- Onboarding/Providers: add Z.AI endpoint-specific auth choices (`zai-coding-global`, `zai-coding-cn`, `zai-global`, `zai-cn`) and expand default Z.AI model wiring. (#13456) Thanks @tomsun28. -- Onboarding/Providers: update MiniMax API default/recommended models from M2.1 to M2.5, add M2.5/M2.5-Lightning model entries, and include `minimax-m2.5` in modern model filtering. (#14865) Thanks @adao-max. -- Ollama: use configured `models.providers.ollama.baseUrl` for model discovery and normalize `/v1` endpoints to the native Ollama API root. (#14131) Thanks @shtse8. -- Voice Call: pass Twilio stream auth token via `` instead of query string. (#14029) Thanks @mcwigglesmcgee. -- Config/Models: allow full `models.providers.*.models[*].compat` keys used by `openai-completions` (`thinkingFormat`, `supportsStrictMode`, and streaming/tool-result compatibility flags) so valid provider overrides no longer fail strict config validation. (#11063) Thanks @ikari-pl. -- Feishu: pass `Buffer` directly to the Feishu SDK upload APIs instead of `Readable.from(...)` to avoid form-data upload failures. (#10345) Thanks @youngerstyle. -- Feishu: trigger mention-gated group handling only when the bot itself is mentioned (not just any mention). (#11088) Thanks @openperf. -- Feishu: probe status uses the resolved account context for multi-account credential checks. (#11233) Thanks @onevcat. -- Feishu: add streaming card replies via Card Kit API and preserve `renderMode=auto` fallback behavior for plain-text responses. (#10379) Thanks @xzq-xu. -- Feishu DocX: preserve top-level converted block order using `firstLevelBlockIds` when writing/appending documents. (#13994) Thanks @Cynosure159. -- Feishu plugin packaging: remove `workspace:*` `openclaw` dependency from `extensions/feishu` and sync lockfile for install compatibility. (#14423) Thanks @jackcooper2015. -- CLI/Wizard: exit with code 1 when `configure`, `agents add`, or interactive `onboard` wizards are canceled, so `set -e` automation stops correctly. (#14156) Thanks @0xRaini. -- Media: strip `MEDIA:` lines with local paths instead of leaking as visible text. (#14399) Thanks @0xRaini. -- Config/Cron: exclude `maxTokens` from config redaction and honor `deleteAfterRun` on skipped cron jobs. (#13342) Thanks @niceysam. -- Config: ignore `meta` field changes in config file watcher. (#13460) Thanks @brandonwise. -- Daemon: suppress `EPIPE` error when restarting LaunchAgent. (#14343) Thanks @0xRaini. -- Antigravity: add opus 4.6 forward-compat model and bypass thinking signature sanitization. (#14218) Thanks @jg-noncelogic. -- Agents: prevent file descriptor leaks in child process cleanup. (#13565) Thanks @KyleChen26. -- Agents: prevent double compaction caused by cache TTL bypassing guard. (#13514) Thanks @taw0002. -- Agents: use last API call's cache tokens for context display instead of accumulated sum. (#13805) Thanks @akari-musubi. -- Agents: keep followup-runner session `totalTokens` aligned with post-compaction context by using last-call usage and shared token-accounting logic. (#14979) Thanks @shtse8. -- Hooks/Plugins: wire 9 previously unwired plugin lifecycle hooks into core runtime paths (session, compaction, gateway, and outbound message hooks). (#14882) Thanks @shtse8. -- Hooks/Tools: dispatch `before_tool_call` and `after_tool_call` hooks from both tool execution paths with rebased conflict fixes. (#15012) Thanks @Patrick-Barletta, @Takhoffman. -- Hooks: replace loader `console.*` output with subsystem logger messages so hook loading errors/warnings route through standard logging. (#11029) Thanks @shadril238. -- Discord: allow channel-edit to archive/lock threads and set auto-archive duration. (#5542) Thanks @stumct. -- Discord tests: use a partial @buape/carbon mock in slash command coverage. (#13262) Thanks @arosstale. -- Tests: update thread ID handling in Slack message collection tests. (#14108) Thanks @swizzmagik. -- Update/Daemon: fix post-update restart compatibility by generating `dist/cli/daemon-cli.js` with alias-aware exports from hashed daemon bundles, preventing `registerDaemonCli` import failures during `openclaw update`. - -## 2026.2.9 - -### Added - -- Commands: add `commands.allowFrom` config for separate command authorization, allowing operators to restrict slash commands to specific users while keeping chat open to others. (#12430) Thanks @thewilloftheshadow. -- Docker: add ClawDock shell helpers for Docker workflows. (#12817) Thanks @Olshansk. -- Gateway: periodic channel health monitor auto-restarts stuck, crashed, or silently-stopped channels. Configurable via `gateway.channelHealthCheckMinutes` (default: 5, set to 0 to disable). (#7053, #4302) -- iOS: alpha node app + setup-code onboarding. (#11756) Thanks @mbelinky. -- Channels: comprehensive BlueBubbles and channel cleanup. (#11093) Thanks @tyler6204. -- Channels: IRC first-class channel support. (#11482) Thanks @vignesh07. -- 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. -- Gateway: stream thinking events to WS clients and broadcast tool events independent of verbose level. (#10568) Thanks @nk1tz. -- Web UI: show a Compaction divider in chat history. (#11341) Thanks @Takhoffman. -- Agents: include runtime shell in agent envelopes. (#1835) Thanks @Takhoffman. -- Agents: auto-select `zai/glm-4.6v` for image understanding when ZAI is primary provider. (#10267) Thanks @liuy. -- Paths: add `OPENCLAW_HOME` for overriding the home directory used by internal path resolution. (#12091) Thanks @sebslight. -- Onboarding: add Custom Provider flow for OpenAI and Anthropic-compatible endpoints. (#11106) Thanks @MackDing. -- Hooks: route webhook agent runs to specific `agentId`s, add `hooks.allowedAgentIds` controls, and fall back to default agent when unknown IDs are provided. (#13672) Thanks @BillChirico. - -### Fixes - -- Cron: prevent one-shot `at` jobs from re-firing on gateway restart when previously skipped or errored. (#13845) -- Discord: add exec approval cleanup option to delete DMs after approval/denial/timeout. (#13205) Thanks @thewilloftheshadow. -- Sessions: prune stale entries, cap session store size, rotate large stores, accept duration/size thresholds, default to warn-only maintenance, and prune cron run sessions after retention windows. (#13083) Thanks @skyfallsin, @Glucksberg, @gumadeiras. -- CI: Implement pipeline and workflow order. Thanks @quotentiroler. -- WhatsApp: preserve original filenames for inbound documents. (#12691) Thanks @akramcodez. -- Telegram: harden quote parsing; preserve quote context; avoid QUOTE_TEXT_INVALID; avoid nested reply quote misclassification. (#12156) Thanks @rybnikov. -- Security/Telegram: breaking default-behavior change — standalone canvas host + Telegram webhook listeners now bind loopback (`127.0.0.1`) instead of `0.0.0.0`; set `channels.telegram.webhookHost` when external ingress is required. (#13184) Thanks @davidrudduck. -- Telegram: recover proactive sends when stale topic thread IDs are used by retrying without `message_thread_id`. (#11620) -- Discord: auto-create forum/media thread posts on send, with chunked follow-up replies and media handling for forum sends. (#12380) Thanks @magendary, @thewilloftheshadow. -- Discord: cap gateway reconnect attempts to avoid infinite retry loops. (#12230) Thanks @Yida-Dev. -- Telegram: render markdown spoilers with `` 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. -- Pairing/Telegram: include the actual pairing code in approve commands, route Telegram pairing replies through the shared pairing message builder, and add regression checks to prevent `` placeholder drift. -- Onboarding: QuickStart now auto-installs shell completion (prompt only in Manual). -- Onboarding/Providers: add LiteLLM provider onboarding and preserve custom LiteLLM proxy base URLs while enforcing API-key auth mode. (#12823) Thanks @ryan-crabbe. -- Docker: make `docker-setup.sh` compatible with macOS Bash 3.2 and empty extra mounts. (#9441) Thanks @mateusz-michalik. -- Auth: strip embedded line breaks from pasted API keys and tokens before storing/resolving credentials. -- Agents: strip reasoning tags and downgraded tool markers from messaging tool and streaming output to prevent leakage. (#11053, #13453) Thanks @liebertar, @meaadore1221-afk, @gumadeiras. -- Browser: prevent stuck `act:evaluate` from wedging the browser tool, and make cancellation stop waiting promptly. (#13498) Thanks @onutc. -- Security/Gateway: default-deny missing connect `scopes` (no implicit `operator.admin`). -- Web UI: make chat refresh smoothly scroll to the latest messages and suppress new-messages badge flash during manual refresh. -- Web UI: coerce Form Editor values to schema types before `config.set` and `config.apply`, preventing numeric and boolean fields from being serialized as strings. (#13468) Thanks @mcaxtr. -- Tools/web_search: include provider-specific settings in the web search cache key, and pass `inlineCitations` for Grok. (#12419) Thanks @tmchow. -- Tools/web_search: fix Grok response parsing for xAI Responses API output blocks. (#13049) Thanks @ereid7. -- 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. -- Errors: avoid rewriting/swallowing normal assistant replies that mention error keywords by scoping `sanitizeUserFacingText` rewrites to error-context. (#12988) Thanks @Takhoffman. -- Config: re-hydrate state-dir `.env` during runtime config loads so `${VAR}` substitutions remain resolvable. (#12748) Thanks @rodrigouroz. -- Gateway: no more post-compaction amnesia; injected transcript writes now preserve Pi session `parentId` chain so agents can remember again. (#12283) Thanks @Takhoffman. -- Gateway: fix multi-agent sessions.usage discovery. (#11523) Thanks @Takhoffman. -- Agents: recover from context overflow caused by oversized tool results (pre-emptive capping + fallback truncation). (#11579) Thanks @tyler6204. -- Subagents/compaction: stabilize announce timing and preserve compaction metrics across retries. (#11664) Thanks @tyler6204. -- Subagents: report timeout-aborted runs as timed out instead of completed successfully in parent-session announcements. (#13996) Thanks @dario-github. -- Cron: share isolated announce flow and harden scheduling/delivery reliability. (#11641) Thanks @tyler6204. -- Cron tool: recover flat params when LLM omits the `job` wrapper for add requests. (#12124) Thanks @tyler6204. -- Gateway/CLI: when `gateway.bind=lan`, use a LAN IP for probe URLs and Control UI links. (#11448) Thanks @AnonO6. -- CLI: make `openclaw plugins list` output scannable by hoisting source roots and shortening bundled/global/workspace plugin paths. -- Hooks: fix bundled hooks broken since 2026.2.2 (tsdown migration). (#9295) Thanks @patrickshao. -- Security/Plugins: install plugin and hook dependencies with `--ignore-scripts` to prevent lifecycle script execution. -- Routing: refresh bindings per message by loading config at route resolution so binding changes apply without restart. (#11372) Thanks @juanpablodlc. -- Exec approvals: render forwarded commands in monospace for safer approval scanning. (#11937) Thanks @sebslight. -- Config: clamp `maxTokens` to `contextWindow` to prevent invalid model configs. (#5516) Thanks @lailoo. -- Thinking: allow xhigh for `github-copilot/gpt-5.2-codex` and `github-copilot/gpt-5.2`. (#11646) Thanks @LatencyTDH. -- Thinking: honor `/think off` for reasoning-capable models. (#9564) Thanks @liuy. -- Discord: support forum/media thread-create starter messages, wire `message thread create --message`, and harden routing. (#10062) Thanks @jarvis89757. -- Discord: download attachments from forwarded messages. (#17049) Thanks @pip-nomel, @thewilloftheshadow. -- 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: disable async batch embeddings by default for memory indexing (opt-in via `agents.defaults.memorySearch.remote.batch.enabled`). (#13069) Thanks @mcinteerj. -- Memory/QMD: reuse default model cache across agents instead of re-downloading per agent. (#12114) Thanks @tyler6204. -- Memory/QMD: run boot refresh in background by default, add configurable QMD maintenance timeouts, retry QMD after fallback failures, and scope QMD queries to OpenClaw-managed collections. (#9690, #9705, #10042) Thanks @vignesh07. -- Memory/QMD: initialize QMD backend on gateway startup so background update timers restart after process reloads. (#10797) Thanks @vignesh07. -- Config/Memory: auto-migrate legacy top-level `memorySearch` settings into `agents.defaults.memorySearch`. (#11278, #9143) Thanks @vignesh07. -- Memory/QMD: treat plain-text `No results found` output from QMD as an empty result instead of throwing invalid JSON errors. (#9824) -- Memory/QMD: add `memory.qmd.searchMode` to choose `query`, `search`, or `vsearch` recall mode. (#9967, #10084) -- 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. -- Doctor/State dir: suppress repeated legacy migration warnings only for valid symlink mirrors, while keeping warnings for empty or invalid legacy trees. (#11709) Thanks @gumadeiras. -- Tests: harden flaky hotspots by removing timer sleeps, consolidating onboarding provider-auth coverage, and improving memory test realism. (#11598) Thanks @gumadeiras. -- macOS: honor Nix-managed defaults suite (`ai.openclaw.mac`) for nixMode to prevent onboarding from reappearing after bundle-id churn. (#12205) Thanks @joshp123. -- Matrix: add multi-account support via `channels.matrix.accounts`; use per-account config for dm policy, allowFrom, groups, and other settings; serialize account startup to avoid race condition. (#7286, #3165, #3085) Thanks @emonty. - -## 2026.2.6 - -### Changes - -- Cron: default `wakeMode` is now `"now"` for new jobs (was `"next-heartbeat"`). (#10776) Thanks @tyler6204. -- Cron: `cron run` defaults to force execution; use `--due` to restrict to due-only. (#10776) Thanks @tyler6204. -- Models: support Anthropic Opus 4.6 and OpenAI Codex gpt-5.3-codex (forward-compat fallbacks). (#9853, #10720, #9995) Thanks @TinyTb, @calvin-hpnet, @tyler6204. -- Providers: add xAI (Grok) support. (#9885) Thanks @grp06. -- Providers: add Baidu Qianfan support. (#8868) Thanks @ide-rea. -- Web UI: add token usage dashboard. (#10072) Thanks @Takhoffman. -- Web UI: add RTL auto-direction support for Hebrew/Arabic text in chat composer and rendered messages. (#11498) Thanks @dirbalak. -- Memory: native Voyage AI support. (#7078) Thanks @mcinteerj. -- Sessions: cap sessions_history payloads to reduce context overflow. (#10000) Thanks @gut-puncture. -- CLI: sort commands alphabetically in help output. (#8068) Thanks @deepsoumya617. -- CI: optimize pipeline throughput (macOS consolidation, Windows perf, workflow concurrency). (#10784) Thanks @mcaxtr. -- Agents: bump pi-mono to 0.52.7; add embedded forward-compat fallback for Opus 4.6 model ids. - -### Added - -- Cron: run history deep-links to session chat from the dashboard. (#10776) Thanks @tyler6204. -- Cron: per-run session keys in run log entries and default labels for cron sessions. (#10776) Thanks @tyler6204. -- Cron: legacy payload field compatibility (`deliver`, `channel`, `to`, `bestEffortDeliver`) in schema. (#10776) Thanks @tyler6204. - -### Fixes - -- TTS: add missing OpenAI voices (ballad, cedar, juniper, marin, verse) to the allowlist so they are recognized instead of silently falling back to Edge TTS. (#2393) -- Cron: scheduler reliability (timer drift, restart catch-up, lock contention, stale running markers). (#10776) Thanks @tyler6204. -- Cron: store migration hardening (legacy field migration, parse error handling, explicit delivery mode persistence). (#10776) Thanks @tyler6204. -- Memory: set Voyage embeddings `input_type` for improved retrieval. (#10818) Thanks @mcinteerj. -- Memory/QMD: run boot refresh in background by default, add configurable QMD maintenance timeouts, retry QMD after fallback failures, and scope QMD queries to OpenClaw-managed collections. (#9690, #9705, #10042) Thanks @vignesh07. -- Media understanding: recognize `.caf` audio attachments for transcription. (#10982) Thanks @succ985. -- Telegram: auto-inject DM topic threadId in message tool + subagent announce. (#7235) Thanks @Lukavyi. -- Security: require auth for Gateway canvas host and A2UI assets. (#9518) Thanks @coygeek. -- Cron: fix scheduling and reminder delivery regressions; harden next-run recompute + timer re-arming + legacy schedule fields. (#9733, #9823, #9948, #9932) Thanks @tyler6204, @pycckuu, @j2h4u, @fujiwara-tofu-shop. -- Update: harden Control UI asset handling in update flow. (#10146) Thanks @gumadeiras. -- Security: add skill/plugin code safety scanner; redact credentials from config.get gateway responses. (#9806, #9858) Thanks @abdelsfane. -- Exec approvals: coerce bare string allowlist entries to objects. (#9903) Thanks @mcaxtr. -- Slack: add mention stripPatterns for /new and /reset. (#9971) Thanks @ironbyte-rgb. -- Chrome extension: fix bundled path resolution. (#8914) Thanks @kelvinCB. -- Compaction/errors: allow multiple compaction retries on context overflow; show clear billing errors. (#8928, #8391) Thanks @Glucksberg. - -## 2026.2.3 - -### Changes - -- Telegram: remove last `@ts-nocheck` from `bot-handlers.ts`, use Grammy types directly, deduplicate `StickerMetadata`. Zero `@ts-nocheck` remaining in `src/telegram/`. (#9206) -- Telegram: remove `@ts-nocheck` from `bot-message.ts`, type deps via `Omit`, widen `allMedia` to `TelegramMediaRef[]`. (#9180) -- Telegram: remove `@ts-nocheck` from `bot.ts`, fix duplicate `bot.catch` error handler (Grammy overrides), remove dead reaction `message_thread_id` routing, harden sticker cache guard. (#9077) -- Onboarding: add Cloudflare AI Gateway provider setup and docs. (#7914) Thanks @roerohan. -- Onboarding: add Moonshot (.cn) auth choice and keep the China base URL when preserving defaults. (#7180) Thanks @waynelwz. -- Docs: clarify tmux send-keys for TUI by splitting text and Enter. (#7737) Thanks @Wangnov. -- Docs: mirror the landing page revamp for zh-CN (features, quickstart, docs directory, network model, credits). (#8994) Thanks @joshp123. -- Messages: add per-channel and per-account responsePrefix overrides across channels. (#9001) Thanks @mudrii. -- Cron: add announce delivery mode for isolated jobs (CLI + Control UI) and delivery mode config. -- Cron: default isolated jobs to announce delivery; accept ISO 8601 `schedule.at` in tool inputs. -- Cron: hard-migrate isolated jobs to announce/none delivery; drop legacy post-to-main/payload delivery fields and `atMs` inputs. -- Cron: delete one-shot jobs after success by default; add `--keep-after-run` for CLI. -- Cron: suppress messaging tools during announce delivery so summaries post consistently. -- Cron: avoid duplicate deliveries when isolated runs send messages directly. - -### Fixes - -- Control UI: add hardened fallback for asset resolution in global npm installs. (#4855) Thanks @anapivirtua. -- Update: remove dead restore control-ui step that failed on gitignored dist/ output. -- Update: avoid wiping prebuilt Control UI assets during dev auto-builds (`tsdown --no-clean`), run update doctor via `openclaw.mjs`, and auto-restore missing UI assets after doctor. (#10146) Thanks @gumadeiras. -- Models: add forward-compat fallback for `openai-codex/gpt-5.3-codex` when model registry hasn't discovered it yet. (#9989) Thanks @w1kke. -- Auto-reply/Docs: normalize `extra-high` (and spaced variants) to `xhigh` for Codex thinking levels, and align Codex 5.3 FAQ examples. (#9976) Thanks @slonce70. -- Compaction: remove orphaned `tool_result` messages during history pruning to prevent session corruption from aborted tool calls. (#9868, fixes #9769, #9724, #9672) -- Telegram: pass `parentPeer` for forum topic binding inheritance so group-level bindings apply to all topics within the group. (#9789, fixes #9545, #9351) -- CLI: pass `--disable-warning=ExperimentalWarning` as a Node CLI option when respawning (avoid disallowed `NODE_OPTIONS` usage; fixes npm pack). (#9691) Thanks @18-RAJAT. -- CLI: resolve bundled Chrome extension assets by walking up to the nearest assets directory; add resolver and clipboard tests. (#8914) Thanks @kelvinCB. -- Tests: stabilize Windows ACL coverage with deterministic os.userInfo mocking. (#9335) Thanks @M00N7682. -- Exec approvals: coerce bare string allowlist entries to objects to prevent allowlist corruption. (#9903, fixes #9790) Thanks @mcaxtr. -- Exec approvals: ensure two-phase approval registration/decision flow works reliably by validating `twoPhase` requests and exposing `waitDecision` as an approvals-scoped gateway method. (#3357, fixes #2402) Thanks @ramin-shirali. -- Heartbeat: allow explicit accountId routing for multi-account channels. (#8702) Thanks @lsh411. -- TUI/Gateway: handle non-streaming finals, refresh history for non-local chat runs, and avoid event gap warnings for targeted tool streams. (#8432) Thanks @gumadeiras. -- Shell completion: auto-detect and migrate slow dynamic patterns to cached files for faster terminal startup; add completion health checks to doctor/update/onboard. -- Telegram: honor session model overrides in inline model selection. (#8193) Thanks @gildo. -- Web UI: fix agent model selection saves for default/non-default agents and wrap long workspace paths. Thanks @Takhoffman. -- Web UI: resolve header logo path when `gateway.controlUi.basePath` is set. (#7178) Thanks @Yeom-JinHo. -- Web UI: apply button styling to the new-messages indicator. -- Onboarding: infer auth choice from non-interactive API key flags. (#8484) Thanks @f-trycua. -- Security: keep untrusted channel metadata out of system prompts (Slack/Discord). Thanks @KonstantinMirin. -- Security: enforce sandboxed media paths for message tool attachments. (#9182) Thanks @victormier. -- Security: require explicit credentials for gateway URL overrides to prevent credential leakage. (#8113) Thanks @victormier. -- Security: gate `whatsapp_login` tool to owner senders and default-deny non-owner contexts. (#8768) Thanks @victormier. -- Voice call: harden webhook verification with host allowlists/proxy trust and keep ngrok loopback bypass. -- Voice call: add regression coverage for anonymous inbound caller IDs with allowlist policy. (#8104) Thanks @victormier. -- Cron: accept epoch timestamps and 0ms durations in CLI `--at` parsing. -- Cron: reload store data when the store file is recreated or mtime changes. -- Cron: deliver announce runs directly, honor delivery mode, and respect wakeMode for summaries. (#8540) Thanks @tyler6204. -- Telegram: include forward_from_chat metadata in forwarded messages and harden cron delivery target checks. (#8392) Thanks @Glucksberg. -- macOS: fix cron payload summary rendering and ISO 8601 formatter concurrency safety. -- Discord: enforce DM allowlists for agent components (buttons/select menus), honoring pairing store approvals and tag matches. (#11254) Thanks @thedudeabidesai. - -## 2026.2.2-3 - -### Fixes - -- Update: ship legacy daemon-cli shim for pre-tsdown update imports (fixes daemon restart after npm update). - -## 2026.2.2-2 - -### Changes - -- Docs: promote BlueBubbles as the recommended iMessage integration; mark imsg channel as legacy. (#8415) Thanks @tyler6204. - -### Fixes - -- CLI status: resolve build-info from bundled dist output (fixes "unknown" commit in npm builds). - -## 2026.2.2-1 - -### Fixes - -- CLI status: fall back to build-info for version detection (fixes "unknown" in beta builds). Thanks @gumadeira. - -## 2026.2.2 - -### Changes - -- Feishu: add Feishu/Lark plugin support + docs. (#7313) Thanks @jiulingyun (openclaw-cn). -- Web UI: add Agents dashboard for managing agent files, tools, skills, models, channels, and cron jobs. -- Subagents: discourage direct messaging tool use unless a specific external recipient is requested. -- Memory: implement the opt-in QMD backend for workspace memory. (#3160) Thanks @vignesh07. -- Security: add healthcheck skill and bootstrap audit guidance. (#7641) Thanks @Takhoffman. -- Config: allow setting a default subagent thinking level via `agents.defaults.subagents.thinking` (and per-agent `agents.list[].subagents.thinking`). (#7372) Thanks @tyler6204. -- Docs: zh-CN translations seed + polish, pipeline guidance, nav/landing updates, and typo fixes. (#8202, #6995, #6619, #7242, #7303, #7415) Thanks @AaronWander, @taiyi747, @Explorer1092, @rendaoyuan, @joshp123, @lailoo. -- Docs: add zh-CN i18n guardrails to avoid editing generated translations. (#8416) Thanks @joshp123. - -### Fixes - -- Docs: finish renaming the QMD memory docs to reference the OpenClaw state dir. -- Onboarding: keep TUI flow exclusive (skip completion prompt + background Web UI seed). -- Onboarding: drop completion prompt now handled by install/update. -- TUI: block onboarding output while TUI is active and restore terminal state on exit. -- CLI: cache shell completion scripts in state dir and source cached files in profiles. -- Zsh completion: escape option descriptions to avoid invalid option errors. -- Agents: repair malformed tool calls and session transcripts. (#7473) Thanks @justinhuangcode. -- fix(agents): validate AbortSignal instances before calling AbortSignal.any() (#7277) (thanks @Elarwei001) -- fix(webchat): respect user scroll position during streaming and refresh (#7226) (thanks @marcomarandiz) -- Telegram: recover from grammY long-poll timed out errors. (#7466) Thanks @macmimi23. -- Media understanding: skip binary media from file text extraction. (#7475) Thanks @AlexZhangji. -- Security: enforce access-group gating for Slack slash commands when channel type lookup fails. -- Security: require validated shared-secret auth before skipping device identity on gateway connect. Thanks @simecek. -- Security: guard skill installer downloads with SSRF checks (block private/localhost URLs). -- Security/Gateway: require `operator.approvals` for in-chat `/approve` when invoked from gateway clients. Thanks @yueyueL. -- Security: harden Windows exec allowlist; block cmd.exe bypass via single &. Thanks @simecek. -- Discord: route autoThread replies to existing threads instead of the root channel. (#8302) Thanks @gavinbmoore, @thewilloftheshadow. -- Media understanding: apply SSRF guardrails to provider fetches; allow private baseUrl overrides explicitly. -- fix(voice-call): harden inbound allowlist; reject anonymous callers; require Telnyx publicKey for allowlist; token-gate Twilio media streams; cap webhook body size (thanks @simecek) -- Onboarding: keep TUI flow exclusive (skip completion prompt + background Web UI seed); completion prompt now handled by install/update. -- CLI/Zsh completion: cache scripts in state dir and escape option descriptions to avoid invalid option errors. -- fix(ui): resolve Control UI asset path correctly. -- fix(ui): refresh agent files after external edits. -- Tests: stub SSRF DNS pinning in web auto-reply + Gemini video coverage. (#6619) Thanks @joshp123. - -## 2026.2.1 - -### Changes - -- Docs: onboarding/install/i18n/exec-approvals/Control UI/exe.dev/cacheRetention updates + misc nav/typos. (#3050, #3461, #4064, #4675, #4729, #4763, #5003, #5402, #5446, #5474, #5663, #5689, #5694, #5967, #6270, #6300, #6311, #6416, #6487, #6550, #6789) -- Telegram: use shared pairing store. (#6127) Thanks @obviyus. -- Agents: add OpenRouter app attribution headers. Thanks @alexanderatallah. -- Agents: add system prompt safety guardrails. (#5445) Thanks @joshp123. -- Agents: update pi-ai to 0.50.9 and rename cacheControlTtl -> cacheRetention (with back-compat mapping). -- Agents: extend CreateAgentSessionOptions with systemPrompt/skills/contextFiles. -- Agents: add tool policy conformance snapshot (no runtime behavior change). (#6011) -- Auth: update MiniMax OAuth hint + portal auth note copy. -- Discord: inherit thread parent bindings for routing. (#3892) Thanks @aerolalit. -- Gateway: inject timestamps into agent and chat.send messages. (#3705) Thanks @conroywhitney, @CashWilliams. -- Gateway: require TLS 1.3 minimum for TLS listeners. (#5970) Thanks @loganaden. -- Web UI: refine chat layout + extend session active duration. -- CI: add formal conformance + alias consistency checks. (#5723, #5807) - -### Fixes - -- Security: guard remote media fetches with SSRF protections (block private/localhost, DNS pinning). -- Updates: clean stale global install rename dirs and extend gateway update timeouts to avoid npm ENOTEMPTY failures. -- Security/Plugins/Hooks: validate install paths and reject traversal-like names (prevents path traversal outside the state dir). Thanks @logicx24. -- Telegram: add download timeouts for file fetches. (#6914) Thanks @hclsys. -- Telegram: enforce thread specs for DM vs forum sends. (#6833) Thanks @obviyus. -- Streaming: flush block streaming on paragraph boundaries for newline chunking. (#7014) -- Streaming: stabilize partial streaming filters. -- Auto-reply: avoid referencing workspace files in /new greeting prompt. (#5706) Thanks @bravostation. -- Tools: align tool execute adapters/signatures (legacy + parameter order + arg normalization). -- Tools: treat "\*" tool allowlist entries as valid to avoid spurious unknown-entry warnings. -- Skills: update session-logs paths from .clawdbot to .openclaw. (#4502) -- Slack: harden media fetch limits and Slack file URL validation. (#6639) Thanks @davidiach. -- Lint: satisfy curly rule after import sorting. (#6310) -- Process: resolve Windows `spawn()` failures for npm-family CLIs by appending `.cmd` when needed. (#5815) Thanks @thejhinvirtuoso. -- Discord: resolve PluralKit proxied senders for allowlists and labels. (#5838) Thanks @thewilloftheshadow. -- Tlon: add timeout to SSE client fetch calls (CWE-400). (#5926) -- Memory search: L2-normalize local embedding vectors to fix semantic search. (#5332) -- Agents: align embedded runner + typings with pi-coding-agent API updates (pi 0.51.0). -- Agents: ensure OpenRouter attribution headers apply in the embedded runner. -- Agents: cap context window resolution for compaction safeguard. (#6187) Thanks @iamEvanYT. -- System prompt: resolve overrides and hint using session_status for current date/time. (#1897, #1928, #2108, #3677) -- Agents: fix Pi prompt template argument syntax. (#6543) -- Subagents: fix announce failover race (always emit lifecycle end; timeout=0 means no-timeout). (#6621) -- Teams: gate media auth retries. -- Telegram: restore draft streaming partials. (#5543) Thanks @obviyus. -- Onboarding: friendlier Windows onboarding message. (#6242) Thanks @shanselman. -- TUI: prevent crash when searching with digits in the model selector. -- Agents: wire before_tool_call plugin hook into tool execution. (#6570, #6660) Thanks @ryancnelson. -- Browser: secure Chrome extension relay CDP sessions. -- Docker: use container port for gateway command instead of host port. (#5110) Thanks @mise42. -- Docker: start gateway CMD by default for container deployments. (#6635) Thanks @kaizen403. -- fix(lobster): block arbitrary exec via lobsterPath/cwd injection (GHSA-4mhr-g7xj-cg8j). (#5335) Thanks @vignesh07. -- Security: sanitize WhatsApp accountId to prevent path traversal. (#4610) -- Security: restrict MEDIA path extraction to prevent LFI. (#4930) -- Security: validate message-tool filePath/path against sandbox root. (#6398) -- Security: block LD*/DYLD* env overrides for host exec. (#4896) Thanks @HassanFleyah. -- Security: harden web tool content wrapping + file parsing safeguards. (#4058) Thanks @VACInc. -- Security: enforce Twitch `allowFrom` allowlist gating (deny non-allowlisted senders). Thanks @MegaManSec. - -## 2026.1.31 - -### Changes - -- Docs: onboarding/install/i18n/exec-approvals/Control UI/exe.dev/cacheRetention updates + misc nav/typos. (#3050, #3461, #4064, #4675, #4729, #4763, #5003, #5402, #5446, #5474, #5663, #5689, #5694, #5967, #6270, #6300, #6311, #6416, #6487, #6550, #6789) -- Telegram: use shared pairing store. (#6127) Thanks @obviyus. -- Agents: add OpenRouter app attribution headers. Thanks @alexanderatallah. -- Agents: add system prompt safety guardrails. (#5445) Thanks @joshp123. -- Agents: update pi-ai to 0.50.9 and rename cacheControlTtl -> cacheRetention (with back-compat mapping). -- Agents: extend CreateAgentSessionOptions with systemPrompt/skills/contextFiles. -- Agents: add tool policy conformance snapshot (no runtime behavior change). (#6011) -- Auth: update MiniMax OAuth hint + portal auth note copy. -- Discord: inherit thread parent bindings for routing. (#3892) Thanks @aerolalit. -- Gateway: inject timestamps into agent and chat.send messages. (#3705) Thanks @conroywhitney, @CashWilliams. -- Gateway: require TLS 1.3 minimum for TLS listeners. (#5970) Thanks @loganaden. -- Web UI: refine chat layout + extend session active duration. -- CI: add formal conformance + alias consistency checks. (#5723, #5807) - -### Fixes - -- Security: guard remote media fetches with SSRF protections (block private/localhost, DNS pinning). -- Updates: clean stale global install rename dirs and extend gateway update timeouts to avoid npm ENOTEMPTY failures. -- Plugins: validate plugin/hook install paths and reject traversal-like names. -- Telegram: add download timeouts for file fetches. (#6914) Thanks @hclsys. -- Telegram: enforce thread specs for DM vs forum sends. (#6833) Thanks @obviyus. -- Streaming: flush block streaming on paragraph boundaries for newline chunking. (#7014) -- Streaming: stabilize partial streaming filters. -- Auto-reply: avoid referencing workspace files in /new greeting prompt. (#5706) Thanks @bravostation. -- Tools: align tool execute adapters/signatures (legacy + parameter order + arg normalization). -- Tools: treat `"*"` tool allowlist entries as valid to avoid spurious unknown-entry warnings. -- Skills: update session-logs paths from .clawdbot to .openclaw. (#4502) -- Slack: harden media fetch limits and Slack file URL validation. (#6639) Thanks @davidiach. -- Lint: satisfy curly rule after import sorting. (#6310) -- Process: resolve Windows `spawn()` failures for npm-family CLIs by appending `.cmd` when needed. (#5815) Thanks @thejhinvirtuoso. -- Discord: resolve PluralKit proxied senders for allowlists and labels. (#5838) Thanks @thewilloftheshadow. -- Tlon: add timeout to SSE client fetch calls (CWE-400). (#5926) -- Memory search: L2-normalize local embedding vectors to fix semantic search. (#5332) -- Agents: align embedded runner + typings with pi-coding-agent API updates (pi 0.51.0). -- Agents: ensure OpenRouter attribution headers apply in the embedded runner. -- Agents: cap context window resolution for compaction safeguard. (#6187) Thanks @iamEvanYT. -- System prompt: resolve overrides and hint using session_status for current date/time. (#1897, #1928, #2108, #3677) -- Agents: fix Pi prompt template argument syntax. (#6543) -- Subagents: fix announce failover race (always emit lifecycle end; timeout=0 means no-timeout). (#6621) -- Teams: gate media auth retries. -- Telegram: restore draft streaming partials. (#5543) Thanks @obviyus. -- Onboarding: friendlier Windows onboarding message. (#6242) Thanks @shanselman. -- TUI: prevent crash when searching with digits in the model selector. -- Agents: wire before_tool_call plugin hook into tool execution. (#6570, #6660) Thanks @ryancnelson. -- Browser: secure Chrome extension relay CDP sessions. -- Docker: use container port for gateway command instead of host port. (#5110) Thanks @mise42. -- Docker: start gateway CMD by default for container deployments. (#6635) Thanks @kaizen403. -- fix(lobster): block arbitrary exec via lobsterPath/cwd injection (GHSA-4mhr-g7xj-cg8j). (#5335) Thanks @vignesh07. -- Security: sanitize WhatsApp accountId to prevent path traversal. (#4610) -- Security: restrict MEDIA path extraction to prevent LFI. (#4930) -- Security: validate message-tool filePath/path against sandbox root. (#6398) -- Security: block LD*/DYLD* env overrides for host exec. (#4896) Thanks @HassanFleyah. -- Security: harden web tool content wrapping + file parsing safeguards. (#4058) Thanks @VACInc. -- Security: enforce Twitch `allowFrom` allowlist gating (deny non-allowlisted senders). Thanks @MegaManSec. - -## 2026.1.30 - -### Changes - -- CLI: add `completion` command (Zsh/Bash/PowerShell/Fish) and auto-setup during postinstall/onboarding. -- CLI: add per-agent `models status` (`--agent` filter). (#4780) Thanks @jlowin. -- Agents: add Kimi K2.5 to the synthetic model catalog. (#4407) Thanks @manikv12. -- Auth: switch Kimi Coding to built-in provider; normalize OAuth profile email. -- Auth: add MiniMax OAuth plugin + onboarding option. (#4521) Thanks @Maosghoul. -- Agents: update pi SDK/API usage and dependencies. -- Web UI: refresh sessions after chat commands and improve session display names. -- Build: move TypeScript builds to `tsdown` + `tsgo` (faster builds, CI typechecks), update tsconfig target, and clean up lint rules. -- Build: align npm tar override and bin metadata so the `openclaw` CLI entrypoint is preserved in npm publishes. -- Docs: add pi/pi-dev docs and update OpenClaw branding + install links. -- Docker E2E: stabilize gateway readiness, plugin installs/manifests, and cleanup/doctor switch entrypoint checks. - -### Fixes - -- Security: restrict local path extraction in media parser to prevent LFI. (#4880) -- Gateway: prevent token defaults from becoming the literal "undefined". (#4873) Thanks @Hisleren. -- Control UI: fix assets resolution for npm global installs. (#4909) Thanks @YuriNachos. -- macOS: avoid stderr pipe backpressure in gateway discovery. (#3304) Thanks @abhijeet117. -- Telegram: normalize account token lookup for non-normalized IDs. (#5055) Thanks @jasonsschin. -- Telegram: preserve delivery thread fallback and fix threadId handling in delivery context. -- Telegram: fix HTML nesting for overlapping styles/links. (#4578) Thanks @ThanhNguyxn. -- Telegram: accept numeric messageId/chatId in react actions. (#4533) Thanks @Ayush10. -- Telegram: honor per-account proxy dispatcher via undici fetch. (#4456) Thanks @spiceoogway. -- Telegram: scope skill commands to bound agent per bot. (#4360) Thanks @robhparker. -- BlueBubbles: debounce by messageId to preserve attachments in text+image messages. (#4984) -- Routing: prefer requesterOrigin over stale session entries for sub-agent announce delivery. (#4957) -- Extensions: restore embedded extension discovery typings. -- CLI: fix `tui:dev` port resolution. -- LINE: fix status command TypeError. (#4651) -- OAuth: skip expired-token warnings when refresh tokens are still valid. (#4593) -- Build: skip redundant UI install step in Dockerfile. (#4584) Thanks @obviyus. - -## 2026.1.29 - -### Changes - -- Rebrand: rename the npm package/CLI to `openclaw`, add a `openclaw` compatibility shim, and move extensions to the `@openclaw/*` scope. -- Onboarding: strengthen security warning copy for beta + access control expectations. -- Onboarding: add Venice API key to non-interactive flow. (#1893) Thanks @jonisjongithub. -- Config: auto-migrate legacy state/config paths and keep config resolution consistent across legacy filenames. -- Gateway: warn on hook tokens via query params; document header auth preference. (#2200) Thanks @YuriNachos. -- Gateway: add dangerous Control UI device auth bypass flag + audit warnings. (#2248) -- Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz. -- Web UI: keep sub-agent announce replies visible in WebChat. (#1977) Thanks @andrescardonas7. -- Browser: route browser control via gateway/node; remove standalone browser control command and control URL config. -- Browser: route `browser.request` via node proxies when available; honor proxy timeouts; derive browser ports from `gateway.port`. -- Browser: fall back to URL matching for extension relay target resolution. (#1999) Thanks @jonit-dev. -- Telegram: allow caption param for media sends. (#1888) Thanks @mguellsegarra. -- Telegram: support plugin sendPayload channelData (media/buttons) and validate plugin commands. (#1917) Thanks @JoshuaLelon. -- Telegram: avoid block replies when streaming is disabled. (#1885) Thanks @ivancasco. -- Telegram: add optional silent send flag (disable notifications). (#2382) Thanks @Suksham-sharma. -- Telegram: support editing sent messages via message(action="edit"). (#2394) Thanks @marcelomar21. -- Telegram: support quote replies for message tool and inbound context. (#2900) Thanks @aduk059. -- Telegram: add sticker receive/send with vision caching. (#2629) Thanks @longjos. -- Telegram: send sticker pixels to vision models. (#2650) -- Telegram: keep topic IDs in restart sentinel notifications. (#1807) Thanks @hsrvc. -- Discord: add configurable privileged gateway intents for presences/members. (#2266) Thanks @kentaro. -- Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999. -- Matrix: switch plugin SDK to @vector-im/matrix-bot-sdk. -- Tlon: format thread reply IDs as @ud. (#1837) Thanks @wca4a. -- Tools: add per-sender group tool policies and fix precedence. (#1757) Thanks @adam91holt. -- Agents: summarize dropped messages during compaction safeguard pruning. (#2509) Thanks @jogi47. -- Agents: expand cron tool description with full schema docs. (#1988) Thanks @tomascupr. -- Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281) -- Memory Search: allow extra paths for memory indexing (ignores symlinks). (#3600) Thanks @kira-ariaki. -- Skills: add multi-image input support to Nano Banana Pro skill. (#1958) Thanks @tyler6204. -- Skills: add missing dependency metadata for GitHub, Notion, Slack, Discord. (#1995) Thanks @jackheuberger. -- Commands: group /help and /commands output with Telegram paging. (#2504) Thanks @hougangdev. -- Routing: add per-account DM session scope and document multi-account isolation. (#3095) Thanks @jarvis-sam. -- Routing: precompile session key regexes. (#1697) Thanks @Ray0907. -- CLI: use Node's module compile cache for faster startup. (#2808) Thanks @pi0. -- Auth: show copyable Google auth URL after ASCII prompt. (#1787) Thanks @robbyczgw-cla. -- TUI: avoid width overflow when rendering selection lists. (#1686) Thanks @mossein. -- macOS: finish OpenClaw app rename for macOS sources, bundle identifiers, and shared kit paths. (#2844) Thanks @fal3. -- Branding: update launchd labels, mobile bundle IDs, and logging subsystems to bot.molt (legacy bundle ID migrations). Thanks @thewilloftheshadow. -- macOS: limit project-local `node_modules/.bin` PATH preference to debug builds (reduce PATH hijacking risk). -- macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal. -- macOS: avoid crash when rendering code blocks by bumping Textual to 0.3.1. (#2033) Thanks @garricn. -- Update: ignore dist/control-ui for dirty checks and restore after ui builds. (#1976) Thanks @Glucksberg. -- Build: bundle A2UI assets during build and stop tracking generated bundles. (#2455) Thanks @0oAstro. -- CI: increase Node heap size for macOS checks. (#1890) Thanks @realZachi. -- Config: apply config.env before ${VAR} substitution. (#1813) Thanks @spanishflu-est1918. -- Gateway: prefer newest session metadata when combining stores. (#1823) Thanks @emanuelst. -- Docs: tighten Fly private deployment steps. (#2289) Thanks @dguido. -- Docs: add migration guide for moving to a new machine. (#2381) -- Docs: add Northflank one-click deployment guide. (#2167) Thanks @AdeboyeDN. -- Docs: add Vercel AI Gateway to providers sidebar. (#1901) Thanks @jerilynzheng. -- Docs: add Render deployment guide. (#1975) Thanks @anurag. -- Docs: add Claude Max API Proxy guide. (#1875) Thanks @atalovesyou. -- Docs: add DigitalOcean deployment guide. (#1870) Thanks @0xJonHoldsCrypto. -- Docs: add Oracle Cloud (OCI) platform guide + cross-links. (#2333) Thanks @hirefrank. -- Docs: add Raspberry Pi install guide. (#1871) Thanks @0xJonHoldsCrypto. -- Docs: add GCP Compute Engine deployment guide. (#1848) Thanks @hougangdev. -- Docs: add LINE channel guide. Thanks @thewilloftheshadow. -- Docs: credit both contributors for Control UI refresh. (#1852) Thanks @EnzeD. -- Docs: keep docs header sticky so navbar stays visible while scrolling. (#2445) Thanks @chenyuan99. -- Docs: update exe.dev install instructions. (#https://github.com/openclaw/openclaw/pull/3047) Thanks @zackerthescar. - -### Breaking - -- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed). - -### Fixes - -- Skills: update session-logs paths to use ~/.openclaw. (#4502) Thanks @bonald. -- Telegram: avoid silent empty replies by tracking normalization skips before fallback. (#3796) -- Mentions: honor mentionPatterns even when explicit mentions are present. (#3303) Thanks @HirokiKobayashi-R. -- Discord: restore username directory lookup in target resolution. (#3131) Thanks @bonald. -- Agents: align MiniMax base URL test expectation with default provider config. (#3131) Thanks @bonald. -- Agents: prevent retries on oversized image errors and surface size limits. (#2871) Thanks @Suksham-sharma. -- Agents: inherit provider baseUrl/api for inline models. (#2740) Thanks @lploc94. -- Memory Search: keep auto provider model defaults and only include remote when configured. (#2576) Thanks @papago2355. -- Telegram: include AccountId in native command context for multi-agent routing. (#2942) Thanks @Chloe-VP. -- Telegram: handle video note attachments in media extraction. (#2905) Thanks @mylukin. -- TTS: read OPENAI_TTS_BASE_URL at runtime instead of module load to honor config.env. (#3341) Thanks @hclsys. -- macOS: auto-scroll to bottom when sending a new message while scrolled up. (#2471) Thanks @kennyklee. -- Web UI: auto-expand the chat compose textarea while typing (with sensible max height). (#2950) Thanks @shivamraut101. -- Gateway: prevent crashes on transient network errors (fetch failures, timeouts, DNS). Added fatal error detection to only exit on truly critical errors. Fixes #2895, #2879, #2873. (#2980) Thanks @elliotsecops. -- Agents: guard channel tool listActions to avoid plugin crashes. (#2859) Thanks @mbelinky. -- Discord: stop resolveDiscordTarget from passing directory params into messaging target parsers. Fixes #3167. Thanks @thewilloftheshadow. -- Discord: avoid resolving bare channel names to user DMs when a username matches. Thanks @thewilloftheshadow. -- Discord: fix directory config type import for target resolution. Thanks @thewilloftheshadow. -- Providers: update MiniMax API endpoint and compatibility mode. (#3064) Thanks @hlbbbbbbb. -- Telegram: treat more network errors as recoverable in polling. (#3013) Thanks @ryancontent. -- Discord: resolve usernames to user IDs for outbound messages. (#2649) Thanks @nonggialiang. -- Providers: update Moonshot Kimi model references to kimi-k2.5. (#2762) Thanks @MarvinCui. -- Gateway: suppress AbortError and transient network errors in unhandled rejections. (#2451) Thanks @Glucksberg. -- TTS: keep /tts status replies on text-only commands and avoid duplicate block-stream audio. (#2451) Thanks @Glucksberg. -- Security: pin npm overrides to keep tar@7.5.4 for install toolchains. -- Security: properly test Windows ACL audit for config includes. (#2403) Thanks @dominicnunez. -- CLI: recognize versioned Node executables when parsing argv. (#2490) Thanks @David-Marsh-Photo. -- CLI: avoid prompting for gateway runtime under the spinner. (#2874) -- BlueBubbles: coalesce inbound URL link preview messages. (#1981) Thanks @tyler6204. -- Cron: allow payloads containing "heartbeat" in event filter. (#2219) Thanks @dwfinkelstein. -- CLI: avoid loading config for global help/version while registering plugin commands. (#2212) Thanks @dial481. -- Agents: include memory.md when bootstrapping memory context. (#2318) Thanks @czekaj. -- Agents: release session locks on process termination and cover more signals. (#2483) Thanks @janeexai. -- Agents: skip cooldowned providers during model failover. (#2143) Thanks @YiWang24. -- Telegram: harden polling + retry behavior for transient network errors and Node 22 transport issues. (#2420) Thanks @techboss. -- Telegram: ignore non-forum group message_thread_id while preserving DM thread sessions. (#2731) Thanks @dylanneve1. -- Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos. -- Telegram: centralize API error logging for delivery and bot calls. (#2492) Thanks @altryne. -- Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default. -- Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers. -- Media: fix text attachment MIME misclassification with CSV/TSV inference and UTF-16 detection; add XML attribute escaping for file output. (#3628) Thanks @frankekn. -- Build: align memory-core peer dependency with lockfile. -- Security: add mDNS discovery mode with minimal default to reduce information disclosure. (#1882) Thanks @orlyjamie. -- Security: harden URL fetches with DNS pinning to reduce rebinding risk. Thanks Chris Zheng. -- Web UI: improve WebChat image paste previews and allow image-only sends. (#1925) Thanks @smartprogrammer93. -- Security: wrap external hook content by default with a per-hook opt-out. (#1827) Thanks @mertcicekci0. -- Gateway: default auth now fail-closed (token/password required; Tailscale Serve identity remains allowed). -- Gateway: treat loopback + non-local Host connections as remote unless trusted proxy headers are present. -- Onboarding: remove unsupported gateway auth "off" choice from onboarding/configure flows and CLI flags. - -## 2026.1.24-3 - -### Fixes - -- Slack: fix image downloads failing due to missing Authorization header on cross-origin redirects. (#1936) Thanks @sanderhelgesen. -- Gateway: harden reverse proxy handling for local-client detection and unauthenticated proxied connects. (#1795) Thanks @orlyjamie. -- Security audit: flag loopback Control UI with auth disabled as critical. (#1795) Thanks @orlyjamie. -- CLI: resume claude-cli sessions and stream CLI replies to TUI clients. (#1921) Thanks @rmorse. - -## 2026.1.24-2 - -### Fixes - -- Packaging: include dist/link-understanding output in npm tarball (fixes missing apply.js import on install). - -## 2026.1.24-1 - -### Fixes - -- Packaging: include dist/shared output in npm tarball (fixes missing reasoning-tags import on install). - -## 2026.1.24 - -### Highlights - -- Providers: Ollama discovery + docs; Venice guide upgrades + cross-links. (#1606) Thanks @abhaymundhara. https://docs.openclaw.ai/providers/ollama https://docs.openclaw.ai/providers/venice -- Channels: LINE plugin (Messaging API) with rich replies + quick replies. (#1630) Thanks @plum-dawg. -- TTS: Edge fallback (keyless) + `/tts` auto modes. (#1668, #1667) Thanks @steipete, @sebslight. https://docs.openclaw.ai/tts -- Exec approvals: approve in-chat via `/approve` across all channels (including plugins). (#1621) Thanks @czekaj. https://docs.openclaw.ai/tools/exec-approvals https://docs.openclaw.ai/tools/slash-commands -- Telegram: DM topics as separate sessions + outbound link preview toggle. (#1597, #1700) Thanks @rohannagpal, @zerone0x. https://docs.openclaw.ai/channels/telegram - -### Changes - -- Channels: add LINE plugin (Messaging API) with rich replies, quick replies, and plugin HTTP registry. (#1630) Thanks @plum-dawg. -- TTS: add Edge TTS provider fallback, defaulting to keyless Edge with MP3 retry on format failures. (#1668) Thanks @steipete. https://docs.openclaw.ai/tts -- TTS: add auto mode enum (off/always/inbound/tagged) with per-session `/tts` override. (#1667) Thanks @sebslight. https://docs.openclaw.ai/tts -- Telegram: treat DM topics as separate sessions and keep DM history limits stable with thread suffixes. (#1597) Thanks @rohannagpal. -- Telegram: add `channels.telegram.linkPreview` to toggle outbound link previews. (#1700) Thanks @zerone0x. https://docs.openclaw.ai/channels/telegram -- Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.openclaw.ai/tools/web -- UI: refresh Control UI dashboard design system (colors, icons, typography). (#1745, #1786) Thanks @EnzeD, @mousberg. -- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.openclaw.ai/tools/exec-approvals https://docs.openclaw.ai/tools/slash-commands -- Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg. -- Diagnostics: add diagnostic flags for targeted debug logs (config + env override). https://docs.openclaw.ai/diagnostics/flags -- Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround). -- Docs: add verbose installer troubleshooting guidance. -- Docs: add macOS VM guide with local/hosted options + VPS/nodes guidance. (#1693) Thanks @f-trycua. -- Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.openclaw.ai/bedrock -- Docs: update Fly.io guide notes. -- Dev: add prek pre-commit hooks + dependabot config for weekly updates. (#1720) Thanks @dguido. - -### Fixes - -- Web UI: fix config/debug layout overflow, scrolling, and code block sizing. (#1715) Thanks @saipreetham589. -- Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent. -- Web UI: clear stale disconnect banners on reconnect; allow form saves with unsupported schema paths but block missing schema. (#1707) Thanks @Glucksberg. -- Web UI: hide internal `message_id` hints in chat bubbles. -- Gateway: allow Control UI token-only auth to skip device pairing even when device identity is present (`gateway.controlUi.allowInsecureAuth`). (#1679) Thanks @steipete. -- Matrix: decrypt E2EE media attachments with preflight size guard. (#1744) Thanks @araa47. -- BlueBubbles: route phone-number targets to DMs, avoid leaking routing IDs, and auto-create missing DMs (Private API required). (#1751) Thanks @tyler6204. https://docs.openclaw.ai/channels/bluebubbles -- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing. -- iMessage: normalize chat_id/chat_guid/chat_identifier prefixes case-insensitively and keep service-prefixed handles stable. (#1708) Thanks @aaronn. -- Signal: repair reaction sends (group/UUID targets + CLI author flags). (#1651) Thanks @vilkasdev. -- Signal: add configurable signal-cli startup timeout + external daemon mode docs. (#1677) https://docs.openclaw.ai/channels/signal -- Telegram: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338. -- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639) -- Telegram: honor per-account proxy for outbound API calls. (#1774) Thanks @radek-paclt. -- Telegram: fall back to text when voice notes are blocked by privacy settings. (#1725) Thanks @foeken. -- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634) -- Voice Call: serialize Twilio TTS playback and cancel on barge-in to prevent overlap. (#1713) Thanks @dguido. -- Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy. -- Google Chat: normalize space targets without double `spaces/` prefix. -- Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz. -- Agents: use the active auth profile for auto-compaction recovery. -- Media understanding: skip image understanding when the primary model already supports vision. (#1747) Thanks @tyler6204. -- Models: default missing custom provider fields so minimal configs are accepted. -- Messaging: keep newline chunking safe for fenced markdown blocks across channels. -- Messaging: treat newline chunking as paragraph-aware (blank-line splits) to keep lists and headings together. (#1726) Thanks @tyler6204. -- TUI: reload history after gateway reconnect to restore session state. (#1663) -- Heartbeat: normalize target identifiers for consistent routing. -- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco. -- Exec: treat Windows platform labels as Windows for node shell selection. (#1760) Thanks @ymat19. -- Gateway: include inline config env vars in service install environments. (#1735) Thanks @Seredeep. -- Gateway: skip Tailscale DNS probing when tailscale.mode is off. (#1671) -- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b. -- Gateway: clarify Control UI/WebChat auth error hints for missing tokens. (#1690) -- Gateway: listen on IPv6 loopback when bound to 127.0.0.1 so localhost webhooks work. -- Gateway: store lock files in the temp directory to avoid stale locks on persistent volumes. (#1676) -- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman. -- Tests: cap Vitest workers on CI macOS to reduce timeouts. (#1597) Thanks @rohannagpal. -- Tests: avoid fake-timer dependency in embedded runner stream mock to reduce CI flakes. (#1597) Thanks @rohannagpal. -- Tests: increase embedded runner ordering test timeout to reduce CI flakes. (#1597) Thanks @rohannagpal. - -## 2026.1.23-1 - -### Fixes - -- Packaging: include dist/tts output in npm tarball (fixes missing dist/tts/tts.js). - -## 2026.1.23 - -### Highlights - -- TTS: move Telegram TTS into core + enable model-driven TTS tags by default for expressive audio replies. (#1559) Thanks @Glucksberg. https://docs.openclaw.ai/tts -- Gateway: add `/tools/invoke` HTTP endpoint for direct tool calls (auth + tool policy enforced). (#1575) Thanks @vignesh07. https://docs.openclaw.ai/gateway/tools-invoke-http-api -- Heartbeat: per-channel visibility controls (OK/alerts/indicator). (#1452) Thanks @dlauer. https://docs.openclaw.ai/gateway/heartbeat -- Deploy: add Fly.io deployment support + guide. (#1570) https://docs.openclaw.ai/platforms/fly -- Channels: add Tlon/Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a. https://docs.openclaw.ai/channels/tlon - -### Changes - -- Channels: allow per-group tool allow/deny policies across built-in + plugin channels. (#1546) Thanks @adam91holt. https://docs.openclaw.ai/multi-agent-sandbox-tools -- Agents: add Bedrock auto-discovery defaults + config overrides. (#1553) Thanks @fal3. https://docs.openclaw.ai/bedrock -- CLI: add `openclaw system` for system events + heartbeat controls; remove standalone `wake`. (commit 71203829d) https://docs.openclaw.ai/cli/system -- CLI: add live auth probes to `openclaw models status` for per-profile verification. (commit 40181afde) https://docs.openclaw.ai/cli/models -- CLI: restart the gateway by default after `openclaw update`; add `--no-restart` to skip it. (commit 2c85b1b40) -- Browser: add node-host proxy auto-routing for remote gateways (configurable per gateway/node). (commit c3cb26f7c) -- Plugins: add optional `llm-task` JSON-only tool for workflows. (#1498) Thanks @vignesh07. https://docs.openclaw.ai/tools/llm-task -- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0. -- Agents: keep system prompt time zone-only and move current time to `session_status` for better cache hits. (commit 66eec295b) -- Agents: remove redundant bash tool alias from tool registration/display. (#1571) Thanks @Takhoffman. -- Docs: add cron vs heartbeat decision guide (with Lobster workflow notes). (#1533) Thanks @JustYannicc. https://docs.openclaw.ai/automation/cron-vs-heartbeat -- Docs: clarify HEARTBEAT.md empty file skips heartbeats, missing file still runs. (#1535) Thanks @JustYannicc. https://docs.openclaw.ai/gateway/heartbeat - -### Fixes - -- Sessions: accept non-UUID sessionIds for history/send/status while preserving agent scoping. (#1518) -- Heartbeat: accept plugin channel ids for heartbeat target validation + UI hints. -- Messaging/Sessions: mirror outbound sends into target session keys (threads + dmScope), create session entries on send, and normalize session key casing. (#1520, commit 4b6cdd1d3) -- Sessions: reject array-backed session stores to prevent silent wipes. (#1469) -- Gateway: compare Linux process start time to avoid PID recycling lock loops; keep locks unless stale. (#1572) Thanks @steipete. -- Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo. -- Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman. -- Exec: honor tools.exec ask/security defaults for elevated approvals (avoid unwanted prompts). (commit 5662a9cdf) -- Daemon: use platform PATH delimiters when building minimal service paths. (commit a4e57d3ac) -- Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla. -- Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies. -- Docker: update gateway command in docker-compose and Hetzner guide. (#1514) -- Agents: show tool error fallback when the last assistant turn only invoked tools (prevents silent stops). (commit 8ea8801d0) -- Agents: ignore IDENTITY.md template placeholders when parsing identity. (#1556) -- Agents: drop orphaned OpenAI Responses reasoning blocks on model switches. (#1562) Thanks @roshanasingh4. -- Agents: add CLI log hint to "agent failed before reply" messages. (#1550) Thanks @sweepies. -- Agents: warn and ignore tool allowlists that only reference unknown or unloaded plugin tools. (#1566) -- Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467) -- Agents: honor enqueue overrides for embedded runs to avoid queue deadlocks in tests. (commit 084002998) -- Slack: honor open groupPolicy for unlisted channels in message + slash gating. (#1563) Thanks @itsjaydesu. -- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo. -- Discord: retry rate-limited allowlist resolution + command deploy to avoid gateway crashes. (commit f70ac0c7c) -- Mentions: ignore mentionPattern matches when another explicit mention is present in group chats (Slack/Discord/Telegram/WhatsApp). (commit d905ca0e0) -- Telegram: render markdown in media captions. (#1478) -- MS Teams: remove `.default` suffix from Graph scopes and Bot Framework probe scopes. (#1507, #1574) Thanks @Evizero. -- Browser: keep extension relay tabs controllable when the extension reuses a session id after switching tabs. (#1160) -- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS. (commit 69f645c66) -- UI: keep the Control UI sidebar visible while scrolling long pages. (#1515) Thanks @pookNast. -- UI: cache Control UI markdown rendering + memoize chat text extraction to reduce Safari typing jank. (commit d57cb2e1a) -- TUI: forward unknown slash commands, include Gateway commands in autocomplete, and render slash replies as system output. (commit 1af227b61, commit 8195497ce, commit 6fba598ea) -- CLI: auth probe output polish (table output, inline errors, reduced noise, and wrap fixes in `openclaw models status`). (commit da3f2b489, commit 00ae21bed, commit 31e59cd58, commit f7dc27f2d, commit 438e782f8, commit 886752217, commit aabe0bed3, commit 81535d512, commit c63144ab1) -- Media: only parse `MEDIA:` tags when they start the line to avoid stripping prose mentions. (#1206) -- Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla. -- Skills: gate bird Homebrew install to macOS. (#1569) Thanks @bradleypriest. - -## 2026.1.22 - -### Changes - -- Highlight: Compaction safeguard now uses adaptive chunking, progressive fallback, and UI status + retries. (#1466) Thanks @dlauer. -- Providers: add Antigravity usage tracking to status output. (#1490) Thanks @patelhiren. -- Slack: add chat-type reply threading overrides via `replyToModeByChatType`. (#1442) Thanks @stefangalescu. -- BlueBubbles: add `asVoice` support for MP3/CAF voice memos in sendAttachment. (#1477, #1482) Thanks @Nicell. -- Onboarding: add hatch choice (TUI/Web/Later), token explainer, background dashboard seed on macOS, and showcase link. - -### Fixes - -- BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell. -- Message tool: keep path/filePath as-is for send; hydrate buffers only for sendAttachment. (#1444) Thanks @hopyky. -- Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla. -- Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer. -- Agents: sanitize assistant history text to strip tool-call markers. (#1456) Thanks @zerone0x. -- Discord: clarify Message Content Intent onboarding hint. (#1487) Thanks @kyleok. -- Gateway: stop the service before uninstalling and fail if it remains loaded. -- Agents: surface concrete API error details instead of generic AI service errors. -- Exec: fall back to non-PTY when PTY spawn fails (EBADF). (#1484) -- Exec approvals: allow per-segment allowlists for chained shell commands on gateway + node hosts. (#1458) Thanks @czekaj. -- Agents: make OpenAI sessions image-sanitize-only; gate tool-id/repair sanitization by provider. -- Doctor: honor CLAWDBOT_GATEWAY_TOKEN for auth checks and security audit token reuse. (#1448) Thanks @azade-c. -- Agents: make tool summaries more readable and only show optional params when set. -- Agents: honor SOUL.md guidance even when the file is nested or path-qualified. (#1434) Thanks @neooriginal. -- Matrix (plugin): persist m.direct for resolved DMs and harden room fallback. (#1436, #1486) Thanks @sibbl. -- CLI: prefer `~` for home paths in output. -- Mattermost (plugin): enforce pairing/allowlist gating, keep @username targets, and clarify plugin-only docs. (#1428) Thanks @damoahdominic. -- Agents: centralize transcript sanitization in the runner; keep tags and error turns intact. -- Auth: skip auth profiles in cooldown during initial selection and rotation. (#1316) Thanks @odrobnik. -- Agents/TUI: honor user-pinned auth profiles during cooldown and preserve search picker ranking. (#1432) Thanks @tobiasbischoff. -- Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x. -- Slack: reduce WebClient retries to avoid duplicate sends. (#1481) -- Slack: read thread replies for message reads when threadId is provided (replies-only). (#1450) Thanks @rodrigouroz. -- Discord: honor accountId across message actions and cron deliveries. (#1492) Thanks @svkozak. -- macOS: prefer linked channels in gateway summary to avoid false “not linked” status. -- macOS/tests: fix gateway summary lookup after guard unwrap; prevent browser opens during tests. (ECID-1483) - -## 2026.1.21-2 - -### Fixes - -- Control UI: ignore bootstrap identity placeholder text for avatar values and fall back to the default avatar. https://docs.openclaw.ai/cli/agents https://docs.openclaw.ai/web/control-ui -- Slack: remove deprecated `filetype` field from `files.uploadV2` to eliminate API warnings. (#1447) - -## 2026.1.21 - -### Changes - -- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.openclaw.ai/tools/lobster -- Lobster: allow workflow file args via `argsJson` in the plugin tool. https://docs.openclaw.ai/tools/lobster -- Heartbeat: allow running heartbeats in an explicit session key. (#1256) Thanks @zknicker. -- CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output. -- CLI: exec approvals mutations render tables instead of raw JSON. -- Exec approvals: support wildcard agent allowlists (`*`) across all agents. -- Exec approvals: allowlist matches resolved binary paths only, add safe stdin-only bins, and tighten allowlist shell parsing. -- Nodes: expose node PATH in status/describe and bootstrap PATH for node-host execution. -- CLI: flatten node service commands under `openclaw node` and remove `service node` docs. -- CLI: move gateway service commands under `openclaw gateway` and add `gateway probe` for reachability. -- Sessions: add per-channel reset overrides via `session.resetByChannel`. (#1353) Thanks @cash-echo-bot. -- Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer. -- UI: show per-session assistant identity in the Control UI. (#1420) Thanks @robbyczgw-cla. -- CLI: add `openclaw update wizard` for interactive channel selection and restart prompts. https://docs.openclaw.ai/cli/update -- Signal: add typing indicators and DM read receipts via signal-cli. -- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero. -- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead). -- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.openclaw.ai/gateway/troubleshooting -- Docs: add /model allowlist troubleshooting note. (#1405) -- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky. - -### Breaking - -- **BREAKING:** Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set `gateway.controlUi.allowInsecureAuth: true` to allow token-only auth. https://docs.openclaw.ai/web/control-ui#insecure-http -- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert. - -### Fixes - -- Nodes/macOS: prompt on allowlist miss for node exec approvals, persist allowlist decisions, and flatten node invoke errors. (#1394) Thanks @ngutman. -- Gateway: keep auto bind loopback-first and add explicit tailnet binding to avoid Tailscale taking over local UI. (#1380) -- Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early. -- Agents: enforce 9-char alphanumeric tool call ids for Mistral providers. (#1372) Thanks @zerone0x. -- Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell. -- Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging. -- macOS: exec approvals now respect wildcard agent allowlists (`*`). -- macOS: allow SSH agent auth when no identity file is set. (#1384) Thanks @ameno-. -- Gateway: prevent multiple gateways from sharing the same config/state at once (singleton lock). -- UI: remove the chat stop button and keep the composer aligned to the bottom edge. -- Typing: start instant typing indicators at run start so DMs and mentions show immediately. -- Configure: restrict the model allowlist picker to OAuth-compatible Anthropic models and preselect Opus 4.5. -- Configure: seed model fallbacks from the allowlist selection when multiple models are chosen. -- Model picker: list the full catalog when no model allowlist is configured. -- Discord: honor wildcard channel configs via shared match helpers. (#1334) Thanks @pvoo. -- BlueBubbles: resolve short message IDs safely and expose full IDs in templates. (#1387) Thanks @tyler6204. -- Infra: preserve fetch helper methods when wrapping abort signals. (#1387) -- macOS: default distribution packaging to universal binaries. (#1396) Thanks @JustYannicc. - -## 2026.1.20 - -### Changes - -- Control UI: add copy-as-markdown with error feedback. (#1345) https://docs.openclaw.ai/web/control-ui -- Control UI: drop the legacy list view. (#1345) https://docs.openclaw.ai/web/control-ui -- TUI: add syntax highlighting for code blocks. (#1200) https://docs.openclaw.ai/tui -- TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview. (#1271) https://docs.openclaw.ai/tui -- TUI: add a searchable model picker for quicker model selection. (#1198) https://docs.openclaw.ai/tui -- TUI: add input history (up/down) for submitted messages. (#1348) https://docs.openclaw.ai/tui -- ACP: add `openclaw acp` for IDE integrations. https://docs.openclaw.ai/cli/acp -- ACP: add `openclaw acp client` interactive harness for debugging. https://docs.openclaw.ai/cli/acp -- Skills: add download installs with OS-filtered options. https://docs.openclaw.ai/tools/skills -- Skills: add the local sherpa-onnx-tts skill. https://docs.openclaw.ai/tools/skills -- Memory: add hybrid BM25 + vector search (FTS5) with weighted merging and fallback. https://docs.openclaw.ai/concepts/memory -- Memory: add SQLite embedding cache to speed up reindexing and frequent updates. https://docs.openclaw.ai/concepts/memory -- Memory: add OpenAI batch indexing for embeddings when configured. https://docs.openclaw.ai/concepts/memory -- Memory: enable OpenAI batch indexing by default for OpenAI embeddings. https://docs.openclaw.ai/concepts/memory -- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2). https://docs.openclaw.ai/concepts/memory -- Memory: render progress immediately, color batch statuses in verbose logs, and poll OpenAI batch status every 2s by default. https://docs.openclaw.ai/concepts/memory -- Memory: add `--verbose` logging for memory status + batch indexing details. https://docs.openclaw.ai/concepts/memory -- Memory: add native Gemini embeddings provider for memory search. (#1151) https://docs.openclaw.ai/concepts/memory -- Browser: allow config defaults for efficient snapshots in the tool/CLI. (#1336) https://docs.openclaw.ai/tools/browser -- Nostr: add the Nostr channel plugin with profile management + onboarding defaults. (#1323) https://docs.openclaw.ai/channels/nostr -- Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) https://docs.openclaw.ai/channels/matrix -- Slack: add HTTP webhook mode via Bolt HTTP receiver. (#1143) https://docs.openclaw.ai/channels/slack -- Telegram: enrich forwarded-message context with normalized origin details + legacy fallback. (#1090) https://docs.openclaw.ai/channels/telegram -- Discord: fall back to `/skill` when native command limits are exceeded. (#1287) -- Discord: expose `/skill` globally. (#1287) -- Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) https://docs.openclaw.ai/plugins/zalouser -- Plugins: require manifest-embedded config schemas with preflight validation warnings. (#1272) https://docs.openclaw.ai/plugins/manifest -- Plugins: move channel catalog metadata into plugin manifests. (#1290) https://docs.openclaw.ai/plugins/manifest -- Plugins: align Nextcloud Talk policy helpers with core patterns. (#1290) https://docs.openclaw.ai/plugins/manifest -- Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) https://docs.openclaw.ai/web/control-ui -- Agents/UI: add agent avatar support in identity config, IDENTITY.md, and the Control UI. (#1329) https://docs.openclaw.ai/gateway/configuration -- Plugins: add plugin slots with a dedicated memory slot selector. https://docs.openclaw.ai/plugins/agent-tools -- Plugins: ship the bundled BlueBubbles channel plugin (disabled by default). https://docs.openclaw.ai/channels/bluebubbles -- Plugins: migrate bundled messaging extensions to the plugin SDK and resolve plugin-sdk imports in the loader. -- Plugins: migrate the Zalo plugin to the shared plugin SDK runtime. https://docs.openclaw.ai/channels/zalo -- Plugins: migrate the Zalo Personal plugin to the shared plugin SDK runtime. https://docs.openclaw.ai/plugins/zalouser -- Plugins: allow optional agent tools with explicit allowlists and add the plugin tool authoring guide. https://docs.openclaw.ai/plugins/agent-tools -- Plugins: auto-enable bundled channel/provider plugins when configuration is present. -- Plugins: sync plugin sources on channel switches and update npm-installed plugins during `openclaw update`. -- Plugins: share npm plugin update logic between `openclaw update` and `openclaw plugins update`. - -- Gateway/API: add `/v1/responses` (OpenResponses) with item-based input + semantic streaming events. (#1229) -- Gateway/API: expand `/v1/responses` to support file/image inputs, tool_choice, usage, and output limits. (#1229) -- Usage: add `/usage cost` summaries and macOS menu cost charts. https://docs.openclaw.ai/reference/api-usage-costs -- Security: warn when <=300B models run without sandboxing while web tools are enabled. https://docs.openclaw.ai/cli/security -- Exec: add host/security/ask routing for gateway + node exec. https://docs.openclaw.ai/tools/exec -- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node). https://docs.openclaw.ai/tools/exec -- Exec approvals: migrate approvals to `~/.openclaw/exec-approvals.json` with per-agent allowlists + skill auto-allow toggle, and add approvals UI + node exec lifecycle events. https://docs.openclaw.ai/tools/exec-approvals -- Nodes: add headless node host (`openclaw node start`) for `system.run`/`system.which`. https://docs.openclaw.ai/cli/node -- Nodes: add node daemon service install/status/start/stop/restart. https://docs.openclaw.ai/cli/node -- Bridge: add `skills.bins` RPC to support node host auto-allow skill bins. -- Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) https://docs.openclaw.ai/concepts/session -- Sessions: allow `sessions_spawn` to override thinking level for sub-agent runs. https://docs.openclaw.ai/tools/subagents -- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers. https://docs.openclaw.ai/concepts/groups -- Models: add Qwen Portal OAuth provider support. (#1120) https://docs.openclaw.ai/providers/qwen -- Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels. https://docs.openclaw.ai/start/onboarding -- Docs: clarify allowlist input types and onboarding behavior for messaging channels. https://docs.openclaw.ai/start/onboarding -- Docs: refresh Android node discovery docs for the Gateway WS service type. https://docs.openclaw.ai/platforms/android -- Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) https://docs.openclaw.ai/bedrock -- Docs: clarify WhatsApp voice notes. https://docs.openclaw.ai/channels/whatsapp -- Docs: clarify Windows WSL portproxy LAN access notes. https://docs.openclaw.ai/platforms/windows -- Docs: refresh bird skill install metadata and usage notes. (#1302) https://docs.openclaw.ai/tools/browser-login -- Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt. -- Agents: clarify node_modules read-only guidance in agent instructions. -- Config: stamp last-touched metadata on write and warn if the config is newer than the running build. -- macOS: hide usage section when usage is unavailable instead of showing provider errors. -- Android: migrate node transport to the Gateway WebSocket protocol with TLS pinning support + gateway discovery naming. -- Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects. -- Android: remove legacy bridge transport code now that nodes use the gateway protocol. -- Android: bump okhttp + dnsjava to satisfy lint dependency checks. -- Build: update workspace + core/plugin deps. -- Build: use tsgo for dev/watch builds by default (opt out with `OPENCLAW_TS_COMPILER=tsc`). -- Repo: remove the Peekaboo git submodule now that the SPM release is used. -- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release. -- macOS: stop syncing Peekaboo in postinstall. -- Swabble: use the tagged Commander Swift package release. - -### Breaking - -- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety. Run `openclaw doctor --fix` to repair, then update plugins (`openclaw plugins update`) if you use any. - -### Fixes - -- Discovery: shorten Bonjour DNS-SD service type to `_moltbot-gw._tcp` and update discovery clients/docs. -- Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry. -- Diagnostics: emit message-flow diagnostics across channels via shared dispatch. (#1244) -- Diagnostics: gate heartbeat/webhook logging. (#1244) -- Gateway: strip inbound envelope headers from chat history messages to keep clients clean. -- Gateway: clarify unauthorized handshake responses with token/password mismatch guidance. -- Gateway: allow mobile node client ids for iOS + Android handshake validation. (#1354) -- Gateway: clarify connect/validation errors for gateway params. (#1347) -- Gateway: preserve restart wake routing + thread replies across restarts. (#1337) -- Gateway: reschedule per-agent heartbeats on config hot reload without restarting the runner. -- Gateway: require authorized restarts for SIGUSR1 (restart/apply/update) so config gating can't be bypassed. -- Cron: auto-deliver isolated agent output to explicit targets without tool calls. (#1285) -- Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241) -- Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058) -- Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs. (#1137) -- Agents: sanitize oversized image payloads before send and surface image-dimension errors. -- Sessions: fall back to session labels when listing display names. (#1124) -- Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084) -- Config: log invalid config issues once per run and keep invalid-config errors stackless. -- Config: allow Perplexity as a web_search provider in config validation. (#1230) -- Config: allow custom fields under `skills.entries..config` for skill credentials/config. (#1226) -- Doctor: clarify plugin auto-enable hint text in the startup banner. -- Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169) -- Docs: make docs:list fail fast with a clear error if the docs directory is missing. -- Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297) -- Plugins: surface plugin load/register/config errors in gateway logs with plugin/source context. -- CLI: preserve cron delivery settings when editing message payloads. (#1322) -- CLI: keep `openclaw logs` output resilient to broken pipes while preserving progress output. -- CLI: avoid duplicating --profile/--dev flags when formatting commands. -- CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207) -- CLI: keep banners on routed commands, restore config guarding outside fast-path routing, and tighten fast-path flag parsing while skipping console capture for extra speed. (#1195) -- CLI: skip runner rebuilds when dist is fresh. (#1231) -- CLI: add WSL2/systemd unavailable hints in daemon status/doctor output. -- Status: route native `/status` to the active agent so model selection reflects the correct profile. (#1301) -- Status: show both usage windows with reset hints when usage data is available. (#1101) -- UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315) -- UI: preserve ordered list numbering in chat markdown. (#1341) -- UI: allow Control UI to read gatewayUrl from URL params for remote WebSocket targets. (#1342) -- UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283) -- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) -- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) -- TUI: align custom editor initialization with the latest pi-tui API. (#1298) -- TUI: show generic empty-state text for searchable pickers. (#1201) -- TUI: highlight model search matches and stabilize search ordering. -- Configure: hide OpenRouter auto routing model from the model picker. (#1182) -- Memory: show total file counts + scan issues in `openclaw memory status`. -- Memory: fall back to non-batch embeddings after repeated batch failures. -- Memory: apply OpenAI batch defaults even without explicit remote config. -- Memory: index atomically so failed reindex preserves the previous memory database. (#1151) -- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151) -- Memory: retry transient 5xx errors (Cloudflare) during embedding indexing. -- Memory: parallelize embedding indexing with rate-limit retries. -- Memory: split overly long lines to keep embeddings under token limits. -- Memory: skip empty chunks to avoid invalid embedding inputs. -- Memory: split embedding batches to avoid OpenAI token limits during indexing. -- Memory: probe sqlite-vec availability in `openclaw memory status`. -- Exec approvals: enforce allowlist when ask is off. -- Exec approvals: prefer raw command for node approvals/events. -- Tools: show exec elevated flag before the command and keep it outside markdown in tool summaries. -- Tools: return a companion-app-required message when node exec is requested with no paired node. -- Tools: return a companion-app-required message when `system.run` is requested without a supporting node. -- Exec: default gateway/node exec security to allowlist when unset (sandbox stays deny). -- Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297) -- Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304) -- Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147) -- Discord: make resolve warnings avoid raw JSON payloads on rate limits. -- Discord: process message handlers in parallel across sessions to avoid event queue blocking. (#1295) -- Discord: stop reconnecting the gateway after aborts to prevent duplicate listeners. -- Discord: only emit slow listener warnings after 30s. -- Discord: inherit parent channel allowlists for thread slash commands and reactions. (#1123) -- Telegram: honor pairing allowlists for native slash commands. -- Telegram: preserve hidden text_link URLs by expanding entities in inbound text. (#1118) -- Slack: resolve Bolt import interop for Bun + Node. (#1191) -- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter). -- Web fetch: harden SSRF protection with shared hostname checks and redirect limits. (#1346) -- Browser: register AI snapshot refs for act commands. (#1282) -- Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864) -- Anthropic: default API prompt caching to 1h with configurable TTL override. -- Anthropic: ignore TTL for OAuth. -- Auth profiles: keep auto-pinned preference while allowing rotation on failover. (#1138) -- Auth profiles: user pins stay locked. (#1138) -- Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332) -- Tests: stabilize Windows gateway/CLI tests by skipping sidecars, normalizing argv, and extending timeouts. -- Tests: stabilize plugin SDK resolution and embedded agent timeouts. -- Windows: install gateway scheduled task as the current user. -- Windows: show friendly guidance instead of failing on access denied. -- macOS: load menu session previews asynchronously so items populate while the menu is open. -- macOS: use label colors for session preview text so previews render in menu subviews. -- macOS: suppress usage error text in the menubar cost view. -- macOS: Doctor repairs LaunchAgent bootstrap issues for Gateway + Node when listed but not loaded. (#1166) -- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105) -- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006) -- Daemon: include HOME in service environments to avoid missing HOME errors. (#1214) - -Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @NicholaiVogel, @RyanLisse, @ThePickle31, @VACInc, @Whoaa512, @YuriNachos, @aaronveklabs, @abdaraxus, @alauppe, @ameno-, @artuskg, @austinm911, @bradleypriest, @cheeeee, @dougvk, @fogboots, @gnarco, @gumadeiras, @jdrhyne, @joelklabo, @longmaba, @mukhtharcm, @odysseus0, @oscargavin, @rhjoh, @sebslight, @sibbl, @sleontenko, @steipete, @suminhthanh, @thewilloftheshadow, @tyler6204, @vignesh07, @visionik, @ysqander, @zerone0x. - -## 2026.1.16-2 - -### Changes - -- CLI: stamp build commit into dist metadata so banners show the commit in npm installs. -- CLI: close memory manager after memory commands to avoid hanging processes. (#1127) — thanks @NicholasSpisak. - -## 2026.1.16-1 - -### Highlights - -- Hooks: add hooks system with bundled hooks, CLI tooling, and docs. (#1028) — thanks @ThomsenDrake. https://docs.openclaw.ai/hooks -- Media: add inbound media understanding (image/audio/video) with provider + CLI fallbacks. https://docs.openclaw.ai/nodes/media-understanding -- Plugins: add Zalo Personal plugin (`@openclaw/zalouser`) and unify channel directory for plugins. (#1032) — thanks @suminhthanh. https://docs.openclaw.ai/plugins/zalouser -- Models: add Vercel AI Gateway auth choice + onboarding updates. (#1016) — thanks @timolins. https://docs.openclaw.ai/providers/vercel-ai-gateway -- Sessions: add `session.identityLinks` for cross-platform DM session li nking. (#1033) — thanks @thewilloftheshadow. https://docs.openclaw.ai/concepts/session -- Web search: add `country`/`language` parameters (schema + Brave API) and docs. (#1046) — thanks @YuriNachos. https://docs.openclaw.ai/tools/web - -### Breaking - -- **BREAKING:** `openclaw message` and message tool now require `target` (dropping `to`/`channelId` for destinations). (#1034) — thanks @tobalsan. -- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow. -- **BREAKING:** Drop legacy `chatType: "room"` support; use `chatType: "channel"`. -- **BREAKING:** remove legacy provider-specific target resolution fallbacks; target resolution is centralized with plugin hints + directory lookups. -- **BREAKING:** `openclaw hooks` is now `openclaw webhooks`; hooks live under `openclaw hooks`. https://docs.openclaw.ai/cli/webhooks -- **BREAKING:** `openclaw plugins install ` now copies into `~/.openclaw/extensions` (use `--link` to keep path-based loading). - -### Changes - -- Plugins: ship bundled plugins disabled by default and allow overrides by installed versions. (#1066) — thanks @ItzR3NO. -- Plugins: add bundled Antigravity + Gemini CLI OAuth + Copilot Proxy provider plugins. (#1066) — thanks @ItzR3NO. -- Tools: improve `web_fetch` extraction using Readability (with fallback). -- Tools: add Firecrawl fallback for `web_fetch` when configured. -- Tools: send Chrome-like headers by default for `web_fetch` to improve extraction on bot-sensitive sites. -- Tools: Firecrawl fallback now uses bot-circumvention + cache by default; remove basic HTML fallback when extraction fails. -- Tools: default `exec` exit notifications and auto-migrate legacy `tools.bash` to `tools.exec`. -- Tools: add `exec` PTY support for interactive sessions. https://docs.openclaw.ai/tools/exec -- Tools: add tmux-style `process send-keys` and bracketed paste helpers for PTY sessions. -- Tools: add `process submit` helper to send CR for PTY sessions. -- Tools: respond to PTY cursor position queries to unblock interactive TUIs. -- Tools: include tool outputs in verbose mode and expand verbose tool feedback. -- Skills: update coding-agent guidance to prefer PTY-enabled exec runs and simplify tmux usage. -- TUI: refresh session token counts after runs complete or fail. (#1079) — thanks @d-ploutarchos. -- Status: trim `/status` to current-provider usage only and drop the OAuth/token block. -- Directory: unify `openclaw directory` across channels and plugin channels. -- UI: allow deleting sessions from the Control UI. -- Memory: add sqlite-vec vector acceleration with CLI status details. -- Memory: add experimental session transcript indexing for memory_search (opt-in via memorySearch.experimental.sessionMemory + sources). -- Skills: add user-invocable skill commands and expanded skill command registration. -- Telegram: default reaction level to minimal and enable reaction notifications by default. -- Telegram: allow reply-chain messages to bypass mention gating in groups. (#1038) — thanks @adityashaw2. -- iMessage: add remote attachment support for VM/SSH deployments. -- Messages: refresh live directory cache results when resolving targets. -- Messages: mirror delivered outbound text/media into session transcripts. (#1031) — thanks @TSavo. -- Messages: avoid redundant sender envelopes for iMessage + Signal group chats. (#1080) — thanks @tyler6204. -- Media: normalize Deepgram audio upload bytes for fetch compatibility. -- Cron: isolated cron jobs now start a fresh session id on every run to prevent context buildup. -- Docs: add `/help` hub, Node/npm PATH guide, and expand directory CLI docs. -- Config: support env var substitution in config values. (#1044) — thanks @sebslight. -- Health: add per-agent session summaries and account-level health details, and allow selective probes. (#1047) — thanks @gumadeiras. -- Hooks: add hook pack installs (npm/path/zip/tar) with `openclaw.hooks` manifests and `openclaw hooks install/update`. -- Plugins: add zip installs and `--link` to avoid copying local paths. - -### Fixes - -- macOS: drain subprocess pipes before waiting to avoid deadlocks. (#1081) — thanks @thesash. -- Verbose: wrap tool summaries/output in markdown only for markdown-capable channels. -- Tools: include provider/session context in elevated exec denial errors. -- Tools: normalize exec tool alias naming in tool error logs. -- Logging: reuse shared ANSI stripping to keep console capture lint-clean. -- Logging: prefix nested agent output with session/run/channel context. -- Telegram: accept tg/group/telegram prefixes + topic targets for inline button validation. (#1072) — thanks @danielz1z. -- Telegram: split long captions into follow-up messages. -- Config: block startup on invalid config, preserve best-effort doctor config, and keep rolling config backups. (#1083) — thanks @mukhtharcm. -- Sub-agents: normalize announce delivery origin + queue bucketing by accountId to keep multi-account routing stable. (#1061, #1058) — thanks @adam91holt. -- Sessions: include deliveryContext in sessions.list and reuse normalized delivery routing for announce/restart fallbacks. (#1058) -- Sessions: propagate deliveryContext into last-route updates to keep account/channel routing stable. (#1058) -- Sessions: preserve overrides on `/new` reset. -- Memory: prevent unhandled rejections when watch/interval sync fails. (#1076) — thanks @roshanasingh4. -- Memory: avoid gateway crash when embeddings return 429/insufficient_quota (disable tool + surface error). (#1004) -- Gateway: honor explicit delivery targets without implicit accountId fallback; preserve lastAccountId for implicit routing. -- Gateway: avoid reusing last-to/accountId when the requested channel differs; sync deliveryContext with last route fields. -- Build: allow `@lydell/node-pty` builds on supported platforms. -- Repo: fix oxlint config filename and move ignore pattern into config. (#1064) — thanks @connorshea. -- Messages: `/stop` now hard-aborts queued followups and sub-agent runs; suppress zero-count stop notes. -- Messages: honor message tool channel when deduping sends. -- Messages: include sender labels for live group messages across channels, matching queued/history formatting. (#1059) -- Sessions: reset `compactionCount` on `/new` and `/reset`, and preserve `sessions.json` file mode (0600). -- Sessions: repair orphaned user turns before embedded prompts. -- Sessions: hard-stop `sessions.delete` cleanup. -- Channels: treat replies to the bot as implicit mentions across supported channels. -- Channels: normalize object-format capabilities in channel capability parsing. -- Security: default-deny slash/control commands unless a channel computed `CommandAuthorized` (fixes accidental “open” behavior), and ensure WhatsApp + Zalo plugin channels gate inline `/…` tokens correctly. https://docs.openclaw.ai/gateway/security -- Security: redact sensitive text in gateway WS logs. -- Tools: cap pending `exec` process output to avoid unbounded buffers. -- CLI: speed up `openclaw sandbox-explain` by avoiding heavy plugin imports when normalizing channel ids. -- Browser: remote profile tab operations prefer persistent Playwright and avoid silent HTTP fallbacks. (#1057) — thanks @mukhtharcm. -- Browser: remote profile tab ops follow-up: shared Playwright loader, Playwright-based focus, and more coverage (incl. opt-in live Browserless test). (follow-up to #1057) — thanks @mukhtharcm. -- Browser: refresh extension relay tab metadata after navigation so `/json/list` stays current. (#1073) — thanks @roshanasingh4. -- WhatsApp: scope self-chat response prefix; inject pending-only group history and clear after any processed message. -- WhatsApp: include `linked` field in `describeAccount`. -- Agents: drop unsigned Gemini tool calls and avoid JSON Schema `format` keyword collisions. -- Agents: hide the image tool when the primary model already supports images. -- Agents: avoid duplicate sends by replying with `NO_REPLY` after `message` tool sends. -- Auth: inherit/merge sub-agent auth profiles from the main agent. -- Gateway: resolve local auth for security probe and validate gateway token/password file modes. (#1011, #1022) — thanks @ivanrvpereira, @kkarimi. -- Signal/iMessage: bound transport readiness waits to 30s with periodic logging. (#1014) — thanks @Szpadel. -- iMessage: avoid RPC restart loops. -- OpenAI image-gen: handle URL + `b64_json` responses and remove deprecated `response_format` (use URL downloads). -- CLI: auto-update global installs when installed via a package manager. -- Routing: migrate legacy `accountID` bindings to `accountId` and remove legacy fallback lookups. (#1047) — thanks @gumadeiras. -- Discord: truncate skill command descriptions to 100 chars for slash command limits. (#1018) — thanks @evalexpr. -- Security: bump `tar` to 7.5.3. -- Models: align ZAI thinking toggles. -- iMessage/Signal: include sender metadata for non-queued group messages. (#1059) -- Discord: preserve whitespace when chunking long lines so message splits keep spacing intact. -- Skills: fix skills watcher ignored list typing (tsc). - -## 2026.1.15 - -### Highlights - -- Plugins: add provider auth registry + `openclaw models auth login` for plugin-driven OAuth/API key flows. -- Browser: improve remote CDP/Browserless support (auth passthrough, `wss` upgrade, timeouts, clearer errors). -- Heartbeat: per-agent configuration + 24h duplicate suppression. (#980) — thanks @voidserf. -- Security: audit warns on weak model tiers; app nodes store auth tokens encrypted (Keychain/SecurePrefs). - -### Breaking - -- **BREAKING:** iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702) -- **BREAKING:** Microsoft Teams is now a plugin; install `@openclaw/msteams` via `openclaw plugins install @openclaw/msteams`. -- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow. - -### Changes - -- UI/Apps: move channel/config settings to schema-driven forms and rename Connections → Channels. (#1040) — thanks @thewilloftheshadow. -- CLI: set process titles to `openclaw-` for clearer process listings. -- CLI/macOS: sync remote SSH target/identity to config and let `gateway status` auto-infer SSH targets (ssh-config aware). -- Telegram: scope inline buttons with allowlist default + callback gating in DMs/groups. -- Telegram: default reaction notifications to own. -- Tools: improve `web_fetch` extraction using Readability (with fallback). -- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf. -- Repo: ignore local identity files to avoid accidental commits. (#1001) — thanks @gerardward2007. -- Sessions/Security: add `session.dmScope` for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee. -- Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker. -- TUI: show provider/model labels for the active session and default model. -- Heartbeat: add per-agent heartbeat configuration and multi-agent docs example. -- UI: show gateway auth guidance + doc link on unauthorized Control UI connections. -- UI: add session deletion action in Control UI sessions list. (#1017) — thanks @Szpadel. -- Security: warn on weak model tiers (Haiku, below GPT-5, below Claude 4.5) in `openclaw security audit`. -- Apps: store node auth tokens encrypted (Keychain/SecurePrefs). -- Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts. -- Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter. -- Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24). -- Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields. -- macOS: add `system.which` for prompt-free remote skill discovery (with gateway fallback to `system.run`). -- Docs: add Date & Time guide and update prompt/timezone configuration docs. -- Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc. -- Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev. -- Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in `/status` and `openclaw models status`, and update docs. -- CLI: add `--json` output for `openclaw daemon` lifecycle/install commands. -- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors. -- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`. -- Browser: `profile="chrome"` now defaults to host control and returns clearer “attach a tab” errors. -- Browser: prefer stable Chrome for auto-detect, with Brave/Edge fallbacks and updated docs. (#983) — thanks @cpojer. -- Browser: increase remote CDP reachability timeouts + add `remoteCdpTimeoutMs`/`remoteCdpHandshakeTimeoutMs`. -- Browser: preserve auth/query tokens for remote CDP endpoints and pass Basic auth for CDP HTTP/WS. (#895) — thanks @mukhtharcm. -- Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi. -- Telegram: allow custom commands in the bot menu (merged with native; conflicts ignored). (#860) — thanks @nachoiacovino. -- Discord: allow allowlisted guilds without channel lists to receive messages when `groupPolicy="allowlist"`. — thanks @thewilloftheshadow. -- Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE. - -### Fixes - -- Messages: make `/stop` clear queued followups and pending session lane work for a hard abort. -- Messages: make `/stop` abort active sub-agent runs spawned from the requester session and report how many were stopped. -- WhatsApp: report linked status consistently in channel status. (#1050) — thanks @YuriNachos. -- Sessions: keep per-session overrides when `/new` resets compaction counters. (#1050) — thanks @YuriNachos. -- Skills: allow OpenAI image-gen helper to handle URL or base64 responses. (#1050) — thanks @YuriNachos. -- WhatsApp: default response prefix only for self-chat, using identity name when set. -- Signal/iMessage: bound transport readiness waits to 30s with periodic logging. (#1014) — thanks @Szpadel. -- iMessage: treat missing `imsg rpc` support as fatal to avoid restart loops. -- Auth: merge main auth profiles into per-agent stores for sub-agents and document inheritance. (#1013) — thanks @marcmarg. -- Agents: avoid JSON Schema `format` collisions in tool params by renaming snapshot format fields. (#1013) — thanks @marcmarg. -- Fix: make `openclaw update` auto-update global installs when installed via a package manager. -- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj. -- Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr. -- Fix: persist `gateway.mode=local` after selecting Local run mode in `openclaw configure`, even if no other sections are chosen. -- Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter. -- Agents: avoid false positives when logging unsupported Google tool schema keywords. -- Agents: skip Gemini history downgrades for google-antigravity to preserve tool calls. (#894) — thanks @mukhtharcm. -- Status: restore usage summary line for current provider when no OAuth profiles exist. -- Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4. -- Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204. -- Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639. -- Fix: support MiniMax coding plan usage responses with `model_remains`/`current_interval_*` payloads. -- Fix: honor message tool channel for duplicate suppression (prefer `NO_REPLY` after `message` tool sends). (#1053) — thanks @sashcatanzarite. -- Fix: suppress WhatsApp pairing replies for historical catch-up DMs on initial link. (#904) -- Browser: extension mode recovers when only one tab is attached (stale targetId fallback). -- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page). -- Browser: upgrade `ws` → `wss` when remote CDP uses `https` (fixes Browserless handshake). -- Telegram: skip `message_thread_id=1` for General topic sends while keeping typing indicators. (#848) — thanks @azade-c. -- Fix: sanitize user-facing error text + strip `` tags across reply pipelines. (#975) — thanks @ThomsenDrake. -- Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba. -- Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash. -- Fix: reset sessions after role-ordering conflicts to recover from consecutive user turns. (#998) - -## 2026.1.14-1 - -### Highlights - -- Web search: `web_search`/`web_fetch` tools (Brave API) + first-time setup in onboarding/configure. -- Browser control: Chrome extension relay takeover mode + remote browser control support. -- Plugins: channel plugins (gateway HTTP hooks) + Zalo plugin + onboarding install flow. (#854) — thanks @longmaba. -- Security: expanded `openclaw security audit` (+ `--fix`), detect-secrets CI scan, and a `SECURITY.md` reporting policy. - -### Changes - -- Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics. -- Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors. -- Agents: add optional auth-profile copy prompt on `agents add` and improve auth error messaging. -- Security: expand `openclaw security audit` checks (model hygiene, config includes, plugin allowlists, exposure matrix) and extend `--fix` to tighten more sensitive state paths. -- Security: add `SECURITY.md` reporting policy. -- Channels: add Matrix plugin (external) with docs + onboarding hooks. -- Plugins: add Zalo channel plugin with gateway HTTP hooks and onboarding install prompt. (#854) — thanks @longmaba. -- Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`. -- Docs: expand gateway security hardening guidance and incident response checklist. -- Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf. -- Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia. -- Tools: add `web_search`/`web_fetch` (Brave API), auto-enable `web_fetch` for sandboxed sessions, and remove the `brave-search` skill. -- CLI/Docs: add a web tools configure section for storing Brave API keys and update onboarding tips. -- Browser: add Chrome extension relay takeover mode (toolbar button), plus `openclaw browser extension install/path` and remote browser control (standalone server + token auth). - -### Fixes - -- Sessions: refactor session store updates to lock + mutate per-entry, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204. -- Browser: add tests for snapshot labels/efficient query params and labeled image responses. -- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors. -- Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06. -- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06. -- Agents: harden Antigravity Claude history/tool-call sanitization. (#968) — thanks @rdev. -- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4. -- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06. -- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight. -- Daemon: clear persisted launchd disabled state before bootstrap (fixes `daemon install` after uninstall). (#849) — thanks @ndraiman. -- Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06. -- Sandbox: restore `docker.binds` config validation for custom bind mounts. (#873) — thanks @akonyer. -- Sandbox: preserve configured PATH for `docker exec` so custom tools remain available. (#873) — thanks @akonyer. -- Slack: respect `channels.slack.requireMention` default when resolving channel mention gating. (#850) — thanks @evalexpr. -- Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”). -- Auto-reply: treat trailing `NO_REPLY` tokens as silent replies. -- Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves). - -## 2026.1.14 - -### Changes - -- Usage: add MiniMax coding plan usage tracking. -- Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR. -- Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915) -- Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko. -- Config: add `channels..configWrites` gating for channel-initiated config writes; migrate Slack channel IDs. - -### Fixes - -- Mac: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor. -- UI: use application-defined WebSocket close code (browser compatibility). (#918) — thanks @rahthakor. -- TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank. -- TUI: add a bright spinner + elapsed time in the status line for send/stream/run states. -- TUI: show LLM error messages (rate limits, auth, etc.) instead of `(no output)`. -- Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.openclaw-dev`). - -#### Agents / Auth / Tools / Sandbox - -- Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams. -- Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994. -- Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli. -- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06. -- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4. -- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight. -- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06. -- Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06. -- Sandbox: restore `docker.binds` config validation and preserve configured PATH for `docker exec`. (#873) — thanks @akonyer. -- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors. - -#### macOS / Apps - -- macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4. -- macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75. -- macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor. -- macOS: reuse launchd gateway auth and skip wizard when gateway config already exists. (#917) -- macOS: prefer the default bridge tunnel port in remote mode for node bridge connectivity; document macOS remote control + bridge tunnels. (#960, fixes #865) — thanks @kkarimi. -- Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare `main` sessions. -- macOS: fix cron preview/testing payload to use `channel` key. (#867) — thanks @wes-davis. -- Telegram: honor `channels.telegram.timeoutSeconds` for grammY API requests. (#863) — thanks @Snaver. -- Telegram: split long captions into media + follow-up text messages. (#907) - thanks @jalehman. -- Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko. -- Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose. -- Slack: drop Socket Mode events with mismatched `api_app_id`/`team_id`. (#889) — thanks @roshanasingh4. -- Discord: isolate autoThread thread context. (#856) — thanks @davidguttman. -- WhatsApp: fix context isolation using wrong ID (was bot's number, now conversation ID). (#911) — thanks @tristanmanchester. -- WhatsApp: normalize user JIDs with device suffix for allowlist checks in groups. (#838) — thanks @peschee. - -## 2026.1.13 - -### Fixes - -- Postinstall: treat already-applied pnpm patches as no-ops to avoid npm/bun install failures. -- Packaging: pin `@mariozechner/pi-ai` to 0.45.7 and refresh patched dependency to match npm resolution. - -## 2026.1.12-2 - -### Fixes - -- Packaging: include `dist/memory/**` in the npm tarball (fixes `ERR_MODULE_NOT_FOUND` for `dist/memory/index.js`). -- Agents: persist sub-agent registry across gateway restarts and resume announce flow safely. (#831) — thanks @roshanasingh4. -- Agents: strip invalid Gemini thought signatures from OpenRouter history to avoid 400s. (#841, #845) — thanks @MatthieuBizien. - -## 2026.1.12-1 - -### Fixes - -- Packaging: include `dist/channels/**` in the npm tarball (fixes `ERR_MODULE_NOT_FOUND` for `dist/channels/registry.js`). - -## 2026.1.12 - -### Highlights - -- **BREAKING:** rename chat “providers” (Slack/Telegram/WhatsApp/…) to **channels** across CLI/RPC/config; legacy config keys auto-migrate on load (and are written back as `channels.*`). -- Memory: add vector search for agent memories (Markdown-only) with SQLite index, chunking, lazy sync + file watch, and per-agent enablement/fallback. -- Plugins: restore full voice-call plugin parity (Telnyx/Twilio, streaming, inbound policies, tools/CLI). -- Models: add Synthetic provider plus Moonshot Kimi K2 0905 + turbo/thinking variants (with docs). (#811) — thanks @siraht; (#818) — thanks @mickahouan. -- Cron: one-shot schedules accept ISO timestamps (UTC) with optional delete-after-run; cron jobs can target a specific agent (CLI + macOS/Control UI). -- Agents: add compaction mode config with optional safeguard summarization and per-agent model fallbacks. (#700) — thanks @thewilloftheshadow; (#583) — thanks @mitschabaude-bot. - -### New & Improved - -- Memory: add custom OpenAI-compatible embedding endpoints; support OpenAI/local `node-llama-cpp` embeddings with per-agent overrides and provider metadata in tools/CLI. (#819) — thanks @mukhtharcm. -- Memory: new `openclaw memory` CLI plus `memory_search`/`memory_get` tools with snippets + line ranges; index stored under `~/.openclaw/memory/{agentId}.sqlite` with watch-on-by-default. -- Agents: strengthen memory recall guidance; make workspace bootstrap truncation configurable (default 20k) with warnings; add default sub-agent model config. -- Tools/Sandbox: add tool profiles + group shorthands; support tool-policy groups in `tools.sandbox.tools`; drop legacy `memory` shorthand; allow Docker bind mounts via `docker.binds`. (#790) — thanks @akonyer. -- Tools: add provider/model-specific tool policy overrides (`tools.byProvider`) to trim tool exposure per provider. -- Tools: add browser `scrollintoview` action; allow Claude/Gemini tool param aliases; allow thinking `xhigh` for GPT-5.2/Codex with safe downgrades. (#793) — thanks @hsrvc; (#444) — thanks @grp06. -- Gateway/CLI: add Tailscale binary discovery, custom bind mode, and probe auth retry; add `openclaw dashboard` auto-open flow; default native slash commands to `"auto"` with per-provider overrides. (#740) — thanks @jeffersonwarrior. -- Auth/Onboarding: add Chutes OAuth (PKCE + refresh + onboarding choice); normalize API key inputs; default TUI onboarding to `deliver: false`. (#726) — thanks @FrieSei; (#791) — thanks @roshanasingh4. -- Providers: add `discord.allowBots`; trim legacy MiniMax M2 from default catalogs; route MiniMax vision to the Coding Plan VLM endpoint (also accepts `@/path/to/file.png` inputs). (#802) — thanks @zknicker. -- Gateway: allow Tailscale Serve identity headers to satisfy token auth; rebuild Control UI assets when protocol schema is newer. (#823) — thanks @roshanasingh4; (#786) — thanks @meaningfool. -- Heartbeat: default `ackMaxChars` to 300 so short `HEARTBEAT_OK` replies stay internal. - -### Installer - -- Install: run `openclaw doctor --non-interactive` after git installs/updates and nudge daemon restarts when detected. - -### Fixes - -- Doctor: warn on pnpm workspace mismatches, missing Control UI assets, and missing tsx binaries; offer UI rebuilds. -- Tools: apply global tool allow/deny even when agent-specific tool policy is set. -- Models/Providers: treat credential validation failures as auth errors to trigger fallback; normalize `${ENV_VAR}` apiKey values and auto-fill missing provider keys; preserve explicit GitHub Copilot provider config + agent-dir auth profiles. (#822) — thanks @sebslight; (#705) — thanks @TAGOOZ. -- Auth: drop invalid auth profiles from ordering so environment keys can still be used for providers like MiniMax. -- Gemini: normalize Gemini 3 ids to preview variants; strip Gemini CLI tool call/response ids; downgrade missing `thought_signature`; strip Claude `msg_*` thought_signature fields to avoid base64 decode errors. (#795) — thanks @thewilloftheshadow; (#783) — thanks @ananth-vardhan-cn; (#793) — thanks @hsrvc; (#805) — thanks @marcmarg. -- Agents: auto-recover from compaction context overflow by resetting the session and retrying; propagate overflow details from embedded runs so callers can recover. -- MiniMax: strip malformed tool invocation XML; include `MiniMax-VL-01` in implicit provider for image pairing. (#809) — thanks @latitudeki5223. -- Onboarding/Auth: honor `CLAWDBOT_AGENT_DIR` / `PI_CODING_AGENT_DIR` when writing auth profiles (MiniMax). (#829) — thanks @roshanasingh4. -- Anthropic: handle `overloaded_error` with a friendly message and failover classification. (#832) — thanks @danielz1z. -- Anthropic: merge consecutive user turns (preserve newest metadata) before validation to avoid incorrect role errors. (#804) — thanks @ThomsenDrake. -- Messaging: enforce context isolation for message tool sends; keep typing indicators alive during tool execution. (#793) — thanks @hsrvc; (#450, #447) — thanks @thewilloftheshadow. -- Auto-reply: `/status` allowlist behavior, reasoning-tag enforcement on fallback, and system-event enqueueing for elevated/reasoning toggles. (#810) — thanks @mcinteerj. -- System events: include local timestamps when events are injected into prompts. (#245) — thanks @thewilloftheshadow. -- Auto-reply: resolve ambiguous `/model` matches; fix streaming block reply media handling; keep >300 char heartbeat replies instead of dropping. -- Discord/Slack: centralize reply-thread planning; fix autoThread routing + add per-channel autoThread; avoid duplicate listeners; keep reasoning italics intact; allow clearing channel parents via message tool. (#800, #807) — thanks @davidguttman; (#744) — thanks @thewilloftheshadow. -- Telegram: preserve forum topic thread ids, persist polling offsets, respect account bindings in webhook mode, and show typing indicator in General topics. (#727, #739) — thanks @thewilloftheshadow; (#821) — thanks @gumadeiras; (#779) — thanks @azade-c. -- Slack: accept slash commands with or without leading `/` for custom command configs. (#798) — thanks @thewilloftheshadow. -- Cron: persist disabled jobs correctly; accept `jobId` aliases for update/run/remove params. (#205, #252) — thanks @thewilloftheshadow. -- Gateway/CLI: honor `CLAWDBOT_LAUNCHD_LABEL` / `CLAWDBOT_SYSTEMD_UNIT` overrides; `agents.list` respects explicit config; reduce noisy loopback WS logs during tests; run `openclaw doctor --non-interactive` during updates. (#781) — thanks @ronyrus. -- Onboarding/Control UI: refuse invalid configs (run doctor first); quote Windows browser URLs for OAuth; keep chat scroll position unless the user is near the bottom. (#764) — thanks @mukhtharcm; (#794) — thanks @roshanasingh4; (#217) — thanks @thewilloftheshadow. -- Tools/UI: harden tool input schemas for strict providers; drop null-only union variants for Gemini schema cleanup; treat `maxChars: 0` as unlimited; keep TUI last streamed response instead of "(no output)". (#782) — thanks @AbhisekBasu1; (#796) — thanks @gabriel-trigo; (#747) — thanks @thewilloftheshadow. -- Connections UI: polish multi-account account cards. (#816) — thanks @steipete. - -### Maintenance - -- Dependencies: bump Pi packages to 0.45.3 and refresh patched pi-ai. -- Testing: update Vitest + browser-playwright to 4.0.17. -- Docs: add Amazon Bedrock provider notes and link from models/FAQ. - -## 2026.1.11 - -### Highlights - -- Plugins are now first-class: loader + CLI management, plus the new Voice Call plugin. -- Config: modular `$include` support for split config files. (#731) — thanks @pasogott. -- Agents/Pi: reserve compaction headroom so pre-compaction memory writes can run before auto-compaction. -- Agents: automatic pre-compaction memory flush turn to store durable memories before compaction. - -### Changes - -- CLI/Onboarding: simplify MiniMax auth choice to a single M2.1 option. -- CLI: configure section selection now loops until Continue. -- Docs: explain MiniMax vs MiniMax Lightning (speed vs cost) and restore LM Studio example. -- Docs: add Cerebras GLM 4.6/4.7 config example (OpenAI-compatible endpoint). -- Onboarding/CLI: group model/auth choice by provider and label Z.AI as GLM 4.7. -- Onboarding/Docs: add Moonshot AI (Kimi K2) auth choice + config example. -- CLI/Onboarding: prompt to reuse detected API keys for Moonshot/MiniMax/Z.AI/Gemini/Anthropic/OpenCode. -- Auto-reply: add compact `/model` picker (models + available providers) and show provider endpoints in `/model status`. -- Control UI: add Config tab model presets (MiniMax M2.1, GLM 4.7, Kimi) for one-click setup. -- Plugins: add extension loader (tools/RPC/CLI/services), discovery paths, and config schema + Control UI labels (uiHints). -- Plugins: add `openclaw plugins install` (path/tgz/npm), plus `list|info|enable|disable|doctor` UX. -- Plugins: voice-call plugin now real (Twilio/log), adds start/status RPC/CLI/tool + tests. -- Docs: add plugins doc + cross-links from tools/skills/gateway config. -- Docs: add beginner-friendly plugin quick start + expand Voice Call plugin docs. -- Tests: add Docker plugin loader + tgz-install smoke test. -- Tests: extend Docker plugin E2E to cover installing from local folders (`plugins.load.paths`) and `file:` npm specs. -- Tests: add coverage for pre-compaction memory flush settings. -- Tests: modernize live model smoke selection for current releases and enforce tools/images/thinking-high coverage. (#769) — thanks @steipete. -- Agents/Tools: add `apply_patch` tool for multi-file edits (experimental; gated by tools.exec.applyPatch; OpenAI-only). -- Agents/Tools: rename the bash tool to exec (config alias maintained). (#748) — thanks @myfunc. -- Agents: add pre-compaction memory flush config (`agents.defaults.compaction.*`) with a soft threshold + system prompt. -- Config: add `$include` directive for modular config files. (#731) — thanks @pasogott. -- Build: set pnpm minimum release age to 2880 minutes (2 days). (#718) — thanks @dan-dr. -- macOS: prompt to install the global `openclaw` CLI when missing in local mode; install via `openclaw.ai/install-cli.sh` (no onboarding) and use external launchd/CLI instead of the embedded gateway runtime. -- Docs: add gog calendar event color IDs from `gog calendar colors`. (#715) — thanks @mjrussell. -- Cron/CLI: add `--model` flag to cron add/edit commands. (#711) — thanks @mjrussell. -- Cron/CLI: trim model overrides on cron edits and document main-session guidance. (#711) — thanks @mjrussell. -- Skills: bundle `skill-creator` to guide creating and packaging skills. -- Providers: add per-DM history limit overrides (`dmHistoryLimit`) with provider-level config. (#728) — thanks @pkrmf. -- Discord: expose channel/category management actions in the message tool. (#730) — thanks @NicholasSpisak. -- Docs: rename README “macOS app” section to “Apps”. (#733) — thanks @AbhisekBasu1. -- Gateway: require `client.id` in WebSocket connect params; use `client.instanceId` for presence de-dupe; update docs/tests. -- macOS: remove the attach-only gateway setting; local mode now always manages launchd while still attaching to an existing gateway if present. - -### Installer - -- Postinstall: replace `git apply` with builtin JS patcher (works npm/pnpm/bun; no git dependency) plus regression tests. -- Postinstall: skip pnpm patch fallback when the new patcher is active. -- Installer tests: add root+non-root docker smokes, CI workflow to fetch openclaw.ai scripts and run install sh/cli with onboarding skipped. -- Installer UX: support `CLAWDBOT_NO_ONBOARD=1` for non-interactive installs; fix npm prefix on Linux and auto-install git. -- Installer UX: add `install.sh --help` with flags/env and git install hint. -- Installer UX: add `--install-method git|npm` and auto-detect source checkouts (prompt to update git checkout vs migrate to npm). - -### Fixes - -- Models/Onboarding: configure MiniMax (minimax.io) via Anthropic-compatible `/anthropic` endpoint by default (keep `minimax-api` as a legacy alias). -- Models: normalize Gemini 3 Pro/Flash IDs to preview names for live model lookups. (#769) — thanks @steipete. -- CLI: fix guardCancel typing for configure prompts. (#769) — thanks @steipete. -- Gateway/WebChat: include handshake validation details in the WebSocket close reason for easier debugging; preserve close codes. -- Gateway/Auth: send invalid connect responses before closing the handshake; stabilize invalid-connect auth test. -- Gateway: tighten gateway listener detection. -- Control UI: hide onboarding chat when configured and guard the mobile chat sidebar overlay. -- Auth: read Codex keychain credentials and make the lookup platform-aware. -- macOS/Release: avoid bundling dist artifacts in relay builds and generate appcasts from zip-only sources. -- Doctor: surface plugin diagnostics in the report. -- Plugins: treat `plugins.load.paths` directory entries as package roots when they contain `package.json` + `openclaw.extensions`; load plugin packages from config dirs; extract archives without system tar. -- Config: expand `~` in `CLAWDBOT_CONFIG_PATH` and common path-like config fields (including `plugins.load.paths`); guard invalid `$include` paths. (#731) — thanks @pasogott. -- Agents: stop pre-creating session transcripts so first user messages persist in JSONL history. -- Agents: skip pre-compaction memory flush when the session workspace is read-only. -- Auto-reply: ignore inline `/status` directives unless the message is directive-only. -- Auto-reply: align `/think` default display with model reasoning defaults. (#751) — thanks @gabriel-trigo. -- Auto-reply: flush block reply buffers on tool boundaries. (#750) — thanks @sebslight. -- Auto-reply: allow sender fallback for command authorization when `SenderId` is empty (WhatsApp self-chat). (#755) — thanks @juanpablodlc. -- Auto-reply: treat whitespace-only sender ids as missing for command authorization (WhatsApp self-chat). (#766) — thanks @steipete. -- Heartbeat: refresh prompt text for updated defaults. -- Agents/Tools: use PowerShell on Windows to capture system utility output. (#748) — thanks @myfunc. -- Docker: tolerate unset optional env vars in docker-setup.sh under strict mode. (#725) — thanks @petradonka. -- CLI/Update: preserve base environment when passing overrides to update subprocesses. (#713) — thanks @danielz1z. -- Agents: treat message tool errors as failures so fallback replies still send; require `to` + `message` for `action=send`. (#717) — thanks @theglove44. -- Agents: preserve reasoning items on tool-only turns. -- Agents/Subagents: wait for completion before announcing, align wait timeout with run timeout, and make announce prompts more emphatic. -- Agents: route subagent transcripts to the target agent sessions directory and add regression coverage. (#708) — thanks @xMikeMickelson. -- Agents/Tools: preserve action enums when flattening tool schemas. (#708) — thanks @xMikeMickelson. -- Gateway/Agents: canonicalize main session aliases for store writes and add regression coverage. (#709) — thanks @xMikeMickelson. -- Agents: reset sessions and retry when auto-compaction overflows instead of crashing the gateway. -- Providers/Telegram: normalize command mentions for consistent parsing. (#729) — thanks @obviyus. -- Providers: skip DM history limit handling for non-DM sessions. (#728) — thanks @pkrmf. -- Sandbox: fix non-main mode incorrectly sandboxing the main DM session and align `/status` runtime reporting with effective sandbox state. -- Sandbox/Gateway: treat `agent::main` as a main-session alias when `session.mainKey` is customized (backwards compatible). -- Auto-reply: fast-path allowlisted slash commands (inline `/help`/`/commands`/`/status`/`/whoami` stripped before model). - -## 2026.1.10 - -### Highlights - -- CLI: `openclaw status` now table-based + shows OS/update/gateway/daemon/agents/sessions; `status --all` adds a full read-only debug report (tables, log tails, Tailscale summary, and scan progress via OSC-9 + spinner). -- CLI Backends: add Codex CLI fallback with resume support (text output) and JSONL parsing for new runs, plus a live CLI resume probe. -- CLI: add `openclaw update` (safe-ish git checkout update) + `--update` shorthand. (#673) — thanks @fm1randa. -- Gateway: add OpenAI-compatible `/v1/chat/completions` HTTP endpoint (auth, SSE streaming, per-agent routing). (#680). - -### Changes - -- Onboarding/Models: add first-class Z.AI (GLM) auth choice (`zai-api-key`) + `--zai-api-key` flag. -- CLI/Onboarding: add OpenRouter API key auth option in configure/onboard. (#703) — thanks @mteam88. -- Agents: add human-delay pacing between block replies (modes: off/natural/custom, per-agent configurable). (#446) — thanks @tony-freedomology. -- Agents/Browser: add `browser.target` (sandbox/host/custom) with sandbox host-control gating via `agents.defaults.sandbox.browser.allowHostControl`, allowlists for custom control URLs/hosts/ports, and expand browser tool docs (remote control, profiles, internals). -- Onboarding/Models: add catalog-backed default model picker to onboarding + configure. (#611) — thanks @jonasjancarik. -- Agents/OpenCode Zen: update fallback models + defaults, keep legacy alias mappings. (#669) — thanks @magimetal. -- CLI: add `openclaw reset` and `openclaw uninstall` flows (interactive + non-interactive) plus docker cleanup smoke test. -- Providers: move provider wiring to a plugin architecture. (#661). -- Providers: unify group history context wrappers across providers with per-provider/per-account `historyLimit` overrides (fallback to `messages.groupChat.historyLimit`). Set `0` to disable. (#672). -- Gateway/Heartbeat: optionally deliver heartbeat `Reasoning:` output (`agents.defaults.heartbeat.includeReasoning`). (#690) -- Docker: allow optional home volume + extra bind mounts in `docker-setup.sh`. (#679) — thanks @gabriel-trigo. - -### Fixes - -- Auto-reply: suppress draft/typing streaming for `NO_REPLY` (silent system ops) so it doesn’t leak partial output. -- CLI/Status: expand tables to full terminal width; clarify provider setup vs runtime warnings; richer per-provider detail; token previews in `status` while keeping `status --all` redacted; add troubleshooting link footer; keep log tails pasteable; show gateway auth used when reachable; surface provider runtime errors (Signal/iMessage/Slack); harden `tailscale status --json` parsing; make `status --all` scan progress determinate; and replace the footer with a 3-line “Next steps” recommendation (share/debug/probe). -- CLI/Gateway: clarify that `openclaw gateway status` reports RPC health (connect + RPC) and shows RPC failures separately from connect failures. -- CLI/Update: gate progress spinner on stdout TTY and align clean-check step label. (#701) — thanks @bjesuiter. -- Telegram: add `/whoami` + `/id` commands to reveal sender id for allowlists; allow `@username` and prefixed ids in `allowFrom` prompts (with stability warning). -- Heartbeat: strip markup-wrapped `HEARTBEAT_OK` so acks don’t leak to external providers (e.g., Telegram). -- Control UI: stop auto-writing `telegram.groups["*"]` and warn/confirm before enabling wildcard groups. -- WhatsApp: send ack reactions only for handled messages and ignore legacy `messages.ackReaction` (doctor copies to `whatsapp.ackReaction`). (#629) — thanks @pasogott. -- Sandbox/Skills: mirror skills into sandbox workspaces for read-only mounts so SKILL.md stays accessible. -- Terminal/Table: ANSI-safe wrapping to prevent table clipping/color loss; add regression coverage. -- Docker: allow optional apt packages during image build and document the build arg. (#697) — thanks @gabriel-trigo. -- Gateway/Heartbeat: deliver reasoning even when the main heartbeat reply is `HEARTBEAT_OK`. (#694) — thanks @antons. -- Agents/Pi: inject config `temperature`/`maxTokens` into streaming without replacing the session streamFn; cover with live maxTokens probe. (#732) — thanks @peschee. -- macOS: clear unsigned launchd overrides on signed restarts and warn via doctor when attach-only/disable markers are set. (#695) — thanks @jeffersonwarrior. -- Agents: enforce single-writer session locks and drop orphan tool results to prevent tool-call ID failures (MiniMax/Anthropic-compatible APIs). -- Docs: make `openclaw status` the first diagnostic step, clarify `status --deep` behavior, and document `/whoami` + `/id`. -- Docs/Testing: clarify live tool+image probes and how to list your testable `provider/model` ids. -- Tests/Live: make gateway bash+read probes resilient to provider formatting while still validating real tool calls. -- WhatsApp: detect @lid mentions in groups using authDir reverse mapping + resolve self JID E.164 for mention gating. (#692) — thanks @peschee. -- Gateway/Auth: default to token auth on loopback during onboarding, add doctor token generation flow, and tighten audio transcription config to Whisper-only. -- Providers: dedupe inbound messages across providers to avoid duplicate LLM runs on redeliveries/reconnects. (#689) — thanks @adam91holt. -- Agents: strip ``/`` tags from hidden reasoning output and cover tag variants in tests. (#688) — thanks @theglove44. -- macOS: save model picker selections as normalized provider/model IDs and keep manual entries aligned. (#683) — thanks @benithors. -- Agents: recognize "usage limit" errors as rate limits for failover. (#687) — thanks @evalexpr. -- CLI: avoid success message when daemon restart is skipped. (#685) — thanks @carlulsoe. -- Commands: disable `/config` + `/debug` by default; gate via `commands.config`/`commands.debug` and hide from native registration/help output. -- Agents/System: clarify that sub-agents remain sandboxed and cannot use elevated host access. -- Gateway: disable the OpenAI-compatible `/v1/chat/completions` endpoint by default; enable via `gateway.http.endpoints.chatCompletions.enabled=true`. -- macOS: stabilize bridge tunnels, guard invoke senders on disconnect, and drain stdout/stderr to avoid deadlocks. (#676) — thanks @ngutman. -- Agents/System: clarify sandboxed runtime in system prompt and surface elevated availability when sandboxed. -- Auto-reply: prefer `RawBody` for command/directive parsing (WhatsApp + Discord) and prevent fallback runs from clobbering concurrent session updates. (#643) — thanks @mcinteerj. -- WhatsApp: fix group reactions by preserving message IDs and sender JIDs in history; normalize participant phone numbers to JIDs in outbound reactions. (#640) — thanks @mcinteerj. -- WhatsApp: expose group participant IDs to the model so reactions can target the right sender. -- Cron: `wakeMode: "now"` waits for heartbeat completion (and retries when the main lane is busy). (#666) — thanks @roshanasingh4. -- Agents/OpenAI: fix Responses tool-only → follow-up turn handling (avoid standalone `reasoning` items that trigger 400 “required following item”) and replay reasoning items in Responses/Codex Responses history for tool-call-only turns. -- Sandbox: add `openclaw sandbox explain` (effective policy inspector + fix-it keys); improve “sandbox jail” tool-policy/elevated errors with actionable config key paths; link to docs. -- Hooks/Gmail: keep Tailscale serve path at `/` while preserving the public path. (#668) — thanks @antons. -- Hooks/Gmail: allow Tailscale target URLs to preserve internal serve paths. -- Auth: update Claude Code keychain credentials in-place during refresh sync; share JSON file helpers; add CLI fallback coverage. -- Auth: throttle external CLI credential syncs (Claude/Codex), reduce Keychain reads, and skip sync when cached credentials are still fresh. -- CLI: respect `CLAWDBOT_STATE_DIR` for node pairing + voice wake settings storage. (#664) — thanks @azade-c. -- Onboarding/Gateway: persist non-interactive gateway token auth in config; add WS wizard + gateway tool-calling regression coverage. -- Gateway/Control UI: make `chat.send` non-blocking, wire Stop to `chat.abort`, and treat `/stop` as an out-of-band abort. (#653) -- Gateway/Control UI: allow `chat.abort` without `runId` (abort active runs), suppress post-abort chat streaming, and prune stuck chat runs. (#653) -- Gateway/Control UI: sniff image attachments for chat.send, drop non-images, and log mismatches. (#670) — thanks @cristip73. -- macOS: force `restart-mac.sh --sign` to require identities and keep bundled Node signed for relay verification. (#580) — thanks @jeffersonwarrior. -- Gateway/Agent: accept image attachments on `agent` (multimodal message) and add live gateway image probe (`CLAWDBOT_LIVE_GATEWAY_IMAGE_PROBE=1`). -- CLI: `openclaw sessions` now includes `elev:*` + `usage:*` flags in the table output. -- CLI/Pairing: accept positional provider for `pairing list|approve` (npm-run compatible); update docs/bot hints. -- Branding: normalize legacy casing/branding to “OpenClaw” (CLI, status, docs). -- Auto-reply: fix native `/model` not updating the actual chat session (Telegram/Slack/Discord). (#646) -- Doctor: offer to run `openclaw update` first on git installs (keeps doctor output aligned with latest). -- Doctor: avoid false legacy workspace warning when install dir is `~/openclaw`. (#660) -- iMessage: fix reasoning persistence across DMs; avoid partial/duplicate replies when reasoning is enabled. (#655) — thanks @antons. -- Models/Auth: allow MiniMax API configs without `models.providers.minimax.apiKey` (auth profiles / `MINIMAX_API_KEY`). (#656) — thanks @mneves75. -- Agents: avoid duplicate replies when the message tool sends. (#659) — thanks @mickahouan. -- Agents: harden Cloud Code Assist tool ID sanitization (toolUse/toolCall/toolResult) and scrub extra JSON Schema constraints. (#665) — thanks @sebslight. -- Agents: sanitize tool results + Cloud Code Assist tool IDs at context-build time (prevents mid-run strict-provider request rejects). -- Agents/Tools: resolve workspace-relative Read/Write/Edit paths; align bash default cwd. (#642) — thanks @mukhtharcm. -- Discord: include forwarded message snapshots in agent session context. (#667) — thanks @rubyrunsstuff. -- Telegram: add `telegram.draftChunk` to tune draft streaming chunking for `streamMode: "block"`. (#667) — thanks @rubyrunsstuff. -- Tests/Agents: add regression coverage for workspace tool path resolution and bash cwd defaults. -- iOS/Android: enable stricter concurrency/lint checks; fix Swift 6 strict concurrency issues + Android lint errors (ExifInterface, obsolete SDK check). (#662) — thanks @KristijanJovanovski. -- Auth: read Codex CLI keychain tokens on macOS before falling back to `~/.codex/auth.json`, preventing stale refresh tokens from breaking gateway live tests. -- Security/Exec approvals: reject shell command substitution (`$()` and backticks) inside double quotes to prevent exec allowlist bypass when exec allowlist mode is explicitly enabled (the default configuration does not use this mode). Thanks @simecek. -- iOS/macOS: share `AsyncTimeout`, require explicit `bridgeStableID` on connect, and harden tool display defaults (avoids missing-resource label fallbacks). -- Telegram: serialize media-group processing to avoid missed albums under load. -- Signal: handle `dataMessage.reaction` events (signal-cli SSE) to avoid broken attachment errors. (#637) — thanks @neist. -- Docs: showcase entries for ParentPay, R2 Upload, iOS TestFlight, and Oura Health. (#650) — thanks @henrino3. -- Agents: repair session transcripts by dropping duplicate tool results across the whole history (unblocks Anthropic-compatible APIs after retries). -- Tests/Live: reset the gateway session between model runs to avoid cross-provider transcript incompatibilities (notably OpenAI Responses reasoning replay rules). - -## 2026.1.9 - -### Highlights - -- Microsoft Teams provider: polling, attachments, outbound CLI send, per-channel policy. -- Models/Auth expansion: OpenCode Zen + MiniMax API onboarding; token auth profiles + auth order; OAuth health in doctor/status. -- CLI/Gateway UX: message subcommands, gateway discover/status/SSH, /config + /debug, sandbox CLI. -- Provider reliability sweep: WhatsApp contact cards/targets, Telegram audio-as-voice + streaming, Signal reactions, Slack threading, Discord stability. -- Auto-reply + status: block-streaming controls, reasoning handling, usage/cost reporting. -- Control UI/TUI: queued messages, session links, reasoning view, mobile polish, logs UX. - -### Breaking - -- CLI: `openclaw message` now subcommands (`message send|poll|...`) and requires `--provider` unless only one provider configured. -- Commands/Tools: `/restart` and gateway restart tool disabled by default; enable with `commands.restart=true`. - -### New Features and Changes - -- Models/Auth: OpenCode Zen onboarding (#623) — thanks @magimetal; MiniMax Anthropic-compatible API + hosted onboarding (#590, #495) — thanks @mneves75, @tobiasbischoff. -- Models/Auth: setup-token + token auth profiles; `openclaw models auth order {get,set,clear}`; per-agent auth candidates in `/model status`; OAuth expiry checks in doctor/status. -- Agent/System: claude-cli runner; `session_status` tool (and sandbox allow); adaptive context pruning default; system prompt messaging guidance + no auto self-update; eligible skills list injection; sub-agent context trimmed. -- Commands: `/commands` list; `/models` alias; `/usage` alias; `/debug` runtime overrides + effective config view; `/config` chat updates + `/config get`; `config --section`. -- CLI/Gateway: unified message tool + message subcommands; gateway discover (local + wide-area DNS-SD) with JSON/timeout; gateway status human-readable + JSON + SSH loopback; wide-area records include gatewayPort/sshPort/cliPath + tailnet DNS fallback. -- CLI UX: logs output modes (pretty/plain/JSONL) + colorized health/daemon output; global `--no-color`; lobster palette in onboarding/config. -- Dev ergonomics: gateway `--dev/--reset` + dev profile auto-config; C-3PO dev templates; dev gateway/TUI helper scripts. -- Sandbox/Workspace: sandbox list/recreate commands; sync skills into sandbox workspace; sandbox browser auto-start. -- Config/Onboarding: inline env vars; OpenAI API key flow to shared `~/.openclaw/.env`; Opus 4.5 default prompt for Anthropic auth; QuickStart auto-install gateway (Node-only) + provider picker tweaks + skip-systemd flags; TUI bootstrap prompt (`tui --message`); remove Bun runtime choice. -- Providers: Microsoft Teams provider (polling, attachments, outbound sends, requireMention, config reload/DM policy). (#404) — thanks @onutc -- Providers: WhatsApp broadcast groups for multi-agent replies (#547) — thanks @pasogott; inbound media size cap configurable (#505) — thanks @koala73; identity-based message prefixes (#578) — thanks @p6l-richard. -- Providers: Telegram inline keyboard buttons + callback payload routing (#491) — thanks @azade-c; cron topic delivery targets (#474/#478) — thanks @mitschabaude-bot, @nachoiacovino; `[[audio_as_voice]]` tag support (#490) — thanks @jarvis-medmatic. -- Providers: Signal reactions + notifications with allowlist support. -- Status/Usage: /status cost reporting + `/cost` lines; auth profile snippet; provider usage windows. -- Control UI: mobile responsiveness (#558) — thanks @carlulsoe; queued messages + Enter-to-send (#527) — thanks @YuriNachos; session links (#471) — thanks @HazAT; reasoning view; skill install feedback (#445) — thanks @pkrmf; chat layout refresh (#475) — thanks @rahthakor; docs link + new session button; drop explicit `ui:install`. -- TUI: agent picker + agents list RPC; improved status line. -- Doctor/Daemon: audit/repair flows, permissions checks, supervisor config audits; provider status probes + warnings for Discord intents and Telegram privacy; last activity timestamps; gateway restart guidance. -- Docs: Hetzner Docker VPS guide + cross-links (#556/#592) — thanks @Iamadig; Ansible guide (#545) — thanks @pasogott; provider troubleshooting index; hook parameter expansion (#532) — thanks @mcinteerj; model allowlist notes; OAuth deep dive; showcase refresh. -- Apps/Branding: refreshed iOS/Android/macOS icons (#521) — thanks @fishfisher. - -### Fixes - -- Packaging: include MS Teams send module in npm tarball. -- Sandbox/Browser: auto-start CDP endpoint; proxy CDP out of container for attachOnly; relax Bun fetch typing; align sandbox list output with config images. -- Agents/Runtime: gate heartbeat prompt to default sessions; /stop aborts between tool calls; require explicit system-event session keys; guard small context windows; fix model fallback stringification; sessions_spawn inherits provider; failover on billing/credits; respect auth cooldown ordering; restore Anthropic OAuth tool dispatch + tool-name bypass; avoid OpenAI invalid reasoning replay; harden Gmail hook model defaults. -- Agent history/schema: strip/skip empty assistant/error blocks to prevent session corruption/Claude 400s; scrub unsupported JSON Schema keywords + sanitize tool call IDs for Cloud Code Assist; simplify Gemini-compatible tool/session schemas; require raw for config.apply. -- Auto-reply/Streaming: default audioAsVoice false; preserve audio_as_voice propagation + buffer audio blocks + guard voice notes; block reply ordering (timeout) + forced-block fence-safe; avoid chunk splits inside parentheses + fence-close breaks + invalid UTF-16 truncation; preserve inline directive spacing + allow whitespace in reply tags; filter NO_REPLY prefixes + normalize routed replies; suppress leakage with separate Reasoning; block streaming defaults (off by default, minChars/idle tuning) + coalesced blocks; dedupe followup queue; restore explicit responsePrefix default. -- Status/Commands: provider prefix in /status model display; usage filtering + provider mapping; auth label + usage snapshots (claude-cli fallback + optional claude.ai); show Verbose/Elevated only when enabled; compact usage/cost line + restore emoji-rich status; /status in directive-only + multi-directive handling; mention-bypass elevated handling; surface provider usage errors; wire /usage to /status; restore hidden gateway-daemon alias; fallback /model list when catalog unavailable. -- WhatsApp: vCard/contact cards (prefer FN, include numbers, show all contacts, keep summary counts, better empty summaries); preserve group JIDs + normalize targets; resolve @lid mappings/JIDs (Baileys/auth-dir) + inbound mapping; route queued replies to sender; improve web listener errors + remove provider name from errors; record outbound activity account id; fix web media fetch errors; broadcast group history consistency. -- Telegram: keep streamMode draft-only; long-poll conflict retries + update dedupe; grammY fetch mismatch fixes + restrict native fetch to Bun; suppress getUpdates stack traces; include user id in pairing; audio_as_voice handling fixes. -- Discord/Slack: thread context helpers + forum thread starters; avoid category parent overrides; gateway reconnect logs + HELLO timeout + stop provider after reconnect exhaustion; DM recipient parsing for numeric IDs; remove incorrect limited warning; reply threading + mrkdwn edge cases; remove ack reactions after reply; gateway debug event visibility. -- Signal: reaction handling safety; own-reaction matching (uuid+phone); UUID-only senders accepted; ignore reaction-only messages. -- MS Teams: download image attachments reliably; fix top-level replies; stop on shutdown + honor chunk limits; normalize poll providers/deps; pairing label fixes. -- iMessage: isolate group-ish threads by chat_id. -- Gateway/Daemon/Doctor: atomic config writes; repair gateway service entrypoint + install switches; non-interactive legacy migrations; systemd unit alignment + KillMode=process; node bridge keepalive/pings; Launch at Login persistence; bundle MoltbotKit resources + Swift 6.2 compat dylib; relay version check + remove smoke test; regen Swift GatewayModels + keep agent provider string; cron jobId alias + channel alias migration + main session key normalization; heartbeat Telegram accountId resolution; avoid WhatsApp fallback for internal runs; gateway listener error wording; serveBaseUrl param; honor gateway --dev; fix wide-area discovery updates; align agents.defaults schema; provider account metadata in daemon status; refresh Carbon patch for gateway fixes; restore doctor prompter initialValue handling. -- Control UI/TUI: persist per-session verbose off + hide tool cards; logs tab opens at bottom; relative asset paths + landing cleanup; session labels lookup/persistence; stop pinning main session in recents; start logs at bottom; TUI status bar refresh + timeout handling + hide reasoning label when off. -- Onboarding/Configure: QuickStart single-select provider picker; avoid Codex CLI false-expiry warnings; clarify WhatsApp owner prompt; fix Minimax hosted onboarding (agents.defaults + msteams heartbeat target); remove configure Control UI prompt; honor gateway --dev flag. - -### Maintenance - -- Dependencies: bump pi-\* stack to 0.42.2. -- Dependencies: Pi 0.40.0 bump (#543) — thanks @mcinteerj. -- Build: Docker build cache layer (#605) — thanks @zknicker. - -- Auth: enable OAuth token refresh for Claude Code CLI credentials (`anthropic:claude-cli`) with bidirectional sync back to Claude Code storage (file on Linux/Windows, Keychain on macOS). This allows long-running agents to operate autonomously without manual re-authentication (#654 — thanks @radek-paclt). - -## 2026.1.8 - -### Highlights - -- Security: DMs locked down by default across providers; pairing-first + allowlist guidance. -- Sandbox: per-agent scope defaults + workspace access controls; tool/session isolation tuned. -- Agent loop: compaction, pruning, streaming, and error handling hardened. -- Providers: Telegram/WhatsApp/Discord/Slack reliability, threading, reactions, media, and retries improved. -- Control UI: logs tab, streaming stability, focus mode, and large-output rendering fixes. -- CLI/Gateway/Doctor: daemon/logs/status, auth migration, and diagnostics significantly expanded. - -### Breaking - -- **SECURITY (update ASAP):** inbound DMs are now **locked down by default** on Telegram/WhatsApp/Signal/iMessage/Discord/Slack. - - Previously, if you didn’t configure an allowlist, your bot could be **open to anyone** (especially discoverable Telegram bots). - - New default: DM pairing (`dmPolicy="pairing"` / `discord.dm.policy="pairing"` / `slack.dm.policy="pairing"`). - - To keep old “open to everyone” behavior: set `dmPolicy="open"` and include `"*"` in the relevant `allowFrom` (Discord/Slack: `discord.dm.allowFrom` / `slack.dm.allowFrom`). - - Approve requests via `openclaw pairing list ` + `openclaw pairing approve `. -- Sandbox: default `agent.sandbox.scope` to `"agent"` (one container/workspace per agent). Use `"session"` for per-session isolation; `"shared"` disables cross-session isolation. -- Timestamps in agent envelopes are now UTC (compact `YYYY-MM-DDTHH:mmZ`); removed `messages.timestampPrefix`. Add `agent.userTimezone` to tell the model the user’s local time (system prompt only). -- Model config schema changes (auth profiles + model lists); doctor auto-migrates and the gateway rewrites legacy configs on startup. -- Commands: gate all slash commands to authorized senders; add `/compact` to manually compact session context. -- Groups: `whatsapp.groups`, `telegram.groups`, and `imessage.groups` now act as allowlists when set. Add `"*"` to keep allow-all behavior. -- Auto-reply: removed `autoReply` from Discord/Slack/Telegram channel configs; use `requireMention` instead (Telegram topics now support `requireMention` overrides). -- CLI: remove `update`, `gateway-daemon`, `gateway {install|uninstall|start|stop|restart|daemon status|wake|send|agent}`, and `telegram` commands; move `login/logout` to `providers login/logout` (top-level aliases hidden); use `daemon` for service control, `send`/`agent`/`wake` for RPC, and `nodes canvas` for canvas ops. - -### Fixes - -- **CLI/Gateway/Doctor:** daemon runtime selection + improved logs/status/health/errors; auth/password handling for local CLI; richer close/timeout details; auto-migrate legacy config/sessions/state; integrity checks + repair prompts; `--yes`/`--non-interactive`; `--deep` gateway scans; better restart/service hints. -- **Agent loop + compaction:** compaction/pruning tuning, overflow handling, safer bootstrap context, and per-provider threading/confirmations; opt-in tool-result pruning + compact tracking. -- **Sandbox + tools:** per-agent sandbox overrides, workspaceAccess controls, session tool visibility, tool policy overrides, process isolation, and tool schema/timeout/reaction unification. -- **Providers (Telegram/WhatsApp/Discord/Slack/Signal/iMessage):** retry/backoff, threading, reactions, media groups/attachments, mention gating, typing behavior, and error/log stability; long polling + forum topic isolation for Telegram. -- **Gateway/CLI UX:** `openclaw logs`, cron list colors/aliases, docs search, agents list/add/delete flows, status usage snapshots, runtime/auth source display, and `/status`/commands auth unification. -- **Control UI/Web:** logs tab, focus mode polish, config form resilience, streaming stability, tool output caps, windowed chat history, and reconnect/password URL auth. -- **macOS/Android/TUI/Build:** macOS gateway races, QR bundling, JSON5 config safety, Voice Wake hardening; Android EXIF rotation + APK naming/versioning; TUI key handling; tooling/bundling fixes. -- **Packaging/compat:** npm dist folder coverage, Node 25 qrcode-terminal import fixes, Bun/Playwright/WebSocket patches, and Docker Bun install. -- **Docs:** new FAQ/ClawHub/config examples/showcase entries and clarified auth, sandbox, and systemd docs. - -### Maintenance - -- Skills additions (Himalaya email, CodexBar, 1Password). -- Dependency refreshes (pi-\* stack, Slack SDK, discord-api-types, file-type, zod, Biome, Vite). - -## 2026.1.5 - -### Highlights - -- Models: add image-specific model config (`agent.imageModel` + fallbacks) and scan support. -- Agent tools: new `image` tool routed to the image model (when configured). -- Config: default model shorthands (`opus`, `sonnet`, `gpt`, `gpt-mini`, `gemini`, `gemini-flash`). -- Docs: document built-in model shorthands + precedence (user config wins). -- Bun: optional local install/build workflow without maintaining a Bun lockfile (see `docs/bun.md`). - -### Fixes - -- Control UI: render Markdown in tool result cards. -- Control UI: prevent overlapping action buttons in Discord guild rules on narrow layouts. -- Android: tapping the foreground service notification brings the app to the front. (#179) — thanks @Syhids -- Cron tool uses `id` for update/remove/run/runs (aligns with gateway params). (#180) — thanks @adamgall -- Control UI: chat view uses page scroll with sticky header/sidebar and fixed composer (no inner scroll frame). -- macOS: treat location permission as always-only to avoid iOS-only enums. (#165) — thanks @Nachx639 -- macOS: make generated gateway protocol models `Sendable` for Swift 6 strict concurrency. (#195) — thanks @andranik-sahakyan -- macOS: bundle QR code renderer modules so DMG gateway boot doesn't crash on missing qrcode-terminal vendor files. -- macOS: parse JSON5 config safely to avoid wiping user settings when comments are present. -- WhatsApp: suppress typing indicator during heartbeat background tasks. (#190) — thanks @mcinteerj -- WhatsApp: mark offline history sync messages as read without auto-reply. (#193) — thanks @mcinteerj -- Discord: avoid duplicate replies when a provider emits late streaming `text_end` events (OpenAI/GPT). -- CLI: use tailnet IP for local gateway calls when bind is tailnet/auto (fixes #176). -- Env: load global `$OPENCLAW_STATE_DIR/.env` (`~/.openclaw/.env`) as a fallback after CWD `.env`. -- Env: optional login-shell env fallback (opt-in; imports expected keys without overriding existing env). -- Agent tools: OpenAI-compatible tool JSON Schemas (fix `browser`, normalize union schemas). -- Onboarding: when running from source, auto-build missing Control UI assets (`bun run ui:build`). -- Discord/Slack: route reaction + system notifications to the correct session (no main-session bleed). -- Agent tools: honor `agent.tools` allow/deny policy even when sandbox is off. -- Discord: avoid duplicate replies when OpenAI emits repeated `message_end` events. -- Commands: unify /status (inline) and command auth across providers; group bypass for authorized control commands; remove Discord /clawd slash handler. -- CLI: run `openclaw agent` via the Gateway by default; use `--local` to force embedded mode. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 120000 index c3170642553..00000000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 2beaeeba290..00000000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,147 +0,0 @@ -# Contributing to OpenClaw - -Welcome to the lobster tank! 🦞 - -## Quick Links - -- **GitHub:** https://github.com/openclaw/openclaw -- **Vision:** [`VISION.md`](VISION.md) -- **Discord:** https://discord.gg/qkhbAGHRBT -- **X/Twitter:** [@steipete](https://x.com/steipete) / [@openclaw](https://x.com/openclaw) - -## Maintainers - -- **Peter Steinberger** - Benevolent Dictator - - GitHub: [@steipete](https://github.com/steipete) · X: [@steipete](https://x.com/steipete) - -- **Shadow** - Discord subsystem, Discord admin, Clawhub, all community moderation - - GitHub: [@thewilloftheshadow](https://github.com/thewilloftheshadow) · X: [@4shad0wed](https://x.com/4shad0wed) - -- **Vignesh** - Memory (QMD), formal modeling, TUI, IRC, and Lobster - - GitHub: [@vignesh07](https://github.com/vignesh07) · X: [@\_vgnsh](https://x.com/_vgnsh) - -- **Jos** - Telegram, API, Nix mode - - GitHub: [@joshp123](https://github.com/joshp123) · X: [@jjpcodes](https://x.com/jjpcodes) - -- **Ayaan Zaidi** - Telegram subsystem, iOS app - - GitHub: [@obviyus](https://github.com/obviyus) · X: [@0bviyus](https://x.com/0bviyus) - -- **Tyler Yust** - Agents/subagents, cron, BlueBubbles, macOS app - - GitHub: [@tyler6204](https://github.com/tyler6204) · X: [@tyleryust](https://x.com/tyleryust) - -- **Mariano Belinky** - iOS app, Security - - GitHub: [@mbelinky](https://github.com/mbelinky) · X: [@belimad](https://x.com/belimad) - -- **Vincent Koc** - Agents, Telemetry, Hooks, Security - - GitHub: [@vincentkoc](https://github.com/vincentkoc) · X: [@vincent_koc](https://x.com/vincent_koc) - -- **Seb Slight** - Docs, Agent Reliability, Runtime Hardening - - GitHub: [@sebslight](https://github.com/sebslight) · X: [@sebslig](https://x.com/sebslig) - -- **Christoph Nakazawa** - JS Infra - - GitHub: [@cpojer](https://github.com/cpojer) · X: [@cnakazawa](https://x.com/cnakazawa) - -- **Gustavo Madeira Santana** - Multi-agents, CLI, web UI - - GitHub: [@gumadeiras](https://github.com/gumadeiras) · X: [@gumadeiras](https://x.com/gumadeiras) - -- **Onur Solmaz** - Agents, dev workflows, ACP integrations, MS Teams - - GitHub: [@onutc](https://github.com/onutc), [@osolmaz](https://github.com/osolmaz) · X: [@onusoz](https://x.com/onusoz) - -## How to Contribute - -1. **Bugs & small fixes** → Open a PR! -2. **New features / architecture** → Start a [GitHub Discussion](https://github.com/openclaw/openclaw/discussions) or ask in Discord first -3. **Questions** → Discord #setup-help - -## Before You PR - -- Test locally with your OpenClaw instance -- Run tests: `pnpm build && pnpm check && pnpm test` -- Ensure CI checks pass -- Keep PRs focused (one thing per PR; do not mix unrelated concerns) -- Describe what & why - -## Control UI Decorators - -The Control UI uses Lit with **legacy** decorators (current Rollup parsing does not support -`accessor` fields required for standard decorators). When adding reactive fields, keep the -legacy style: - -```ts -@state() foo = "bar"; -@property({ type: Number }) count = 0; -``` - -The root `tsconfig.json` is configured for legacy decorators (`experimentalDecorators: true`) -with `useDefineForClassFields: false`. Avoid flipping these unless you are also updating the UI -build tooling to support standard decorators. - -## AI/Vibe-Coded PRs Welcome! 🤖 - -Built with Codex, Claude, or other AI tools? **Awesome - just mark it!** - -Please include in your PR: - -- [ ] Mark as AI-assisted in the PR title or description -- [ ] Note the degree of testing (untested / lightly tested / fully tested) -- [ ] Include prompts or session logs if possible (super helpful!) -- [ ] Confirm you understand what the code does - -AI PRs are first-class citizens here. We just want transparency so reviewers know what to look for. - -## Current Focus & Roadmap 🗺 - -We are currently prioritizing: - -- **Stability**: Fixing edge cases in channel connections (WhatsApp/Telegram). -- **UX**: Improving the onboarding wizard and error messages. -- **Skills**: For skill contributions, head to [ClawHub](https://clawhub.ai/) — the community hub for OpenClaw skills. -- **Performance**: Optimizing token usage and compaction logic. - -Check the [GitHub Issues](https://github.com/openclaw/openclaw/issues) for "good first issue" labels! - -## Maintainers - -We're selectively expanding the maintainer team. -If you're an experienced contributor who wants to help shape OpenClaw's direction — whether through code, docs, or community — we'd like to hear from you. - -Being a maintainer is a responsibility, not an honorary title. We expect active, consistent involvement — triaging issues, reviewing PRs, and helping move the project forward. - -Still interested? Email contributing@openclaw.ai with: - -- Links to your PRs on OpenClaw (if you don't have any, start there first) -- Links to open source projects you maintain or actively contribute to -- Your GitHub, Discord, and X/Twitter handles -- A brief intro: background, experience, and areas of interest -- Languages you speak and where you're based -- How much time you can realistically commit - -We welcome people across all skill sets — engineering, documentation, community management, and more. -We review every human-only-written application carefully and add maintainers slowly and deliberately. -Please allow a few weeks for a response. - -## Report a Vulnerability - -We take security reports seriously. Report vulnerabilities directly to the repository where the issue lives: - -- **Core CLI and gateway** — [openclaw/openclaw](https://github.com/openclaw/openclaw) -- **macOS desktop app** — [openclaw/openclaw](https://github.com/openclaw/openclaw) (apps/macos) -- **iOS app** — [openclaw/openclaw](https://github.com/openclaw/openclaw) (apps/ios) -- **Android app** — [openclaw/openclaw](https://github.com/openclaw/openclaw) (apps/android) -- **ClawHub** — [openclaw/clawhub](https://github.com/openclaw/clawhub) -- **Trust and threat model** — [openclaw/trust](https://github.com/openclaw/trust) - -For issues that don't fit a specific repo, or if you're unsure, email **security@openclaw.ai** and we'll route it. - -### Required in Reports - -1. **Title** -2. **Severity Assessment** -3. **Impact** -4. **Affected Component** -5. **Technical Reproduction** -6. **Demonstrated Impact** -7. **Environment** -8. **Remediation Advice** - -Reports without reproduction steps, demonstrated impact, and remediation advice will be deprioritized. Given the volume of AI-generated scanner findings, we must ensure we're receiving vetted reports from researchers who understand the issues. diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 255340cb02b..00000000000 --- a/Dockerfile +++ /dev/null @@ -1,65 +0,0 @@ -FROM node:22-bookworm@sha256:cd7bcd2e7a1e6f72052feb023c7f6b722205d3fcab7bbcbd2d1bfdab10b1e935 - -# Install Bun (required for build scripts) -RUN curl -fsSL https://bun.sh/install | bash -ENV PATH="/root/.bun/bin:${PATH}" - -RUN corepack enable - -WORKDIR /app -RUN chown node:node /app - -ARG OPENCLAW_DOCKER_APT_PACKAGES="" -RUN if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \ - apt-get update && \ - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $OPENCLAW_DOCKER_APT_PACKAGES && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \ - fi - -COPY --chown=node:node package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ -COPY --chown=node:node ui/package.json ./ui/package.json -COPY --chown=node:node patches ./patches -COPY --chown=node:node scripts ./scripts - -USER node -RUN pnpm install --frozen-lockfile - -# Optionally install Chromium and Xvfb for browser automation. -# Build with: docker build --build-arg OPENCLAW_INSTALL_BROWSER=1 ... -# Adds ~300MB but eliminates the 60-90s Playwright install on every container start. -# Must run after pnpm install so playwright-core is available in node_modules. -USER root -ARG OPENCLAW_INSTALL_BROWSER="" -RUN if [ -n "$OPENCLAW_INSTALL_BROWSER" ]; then \ - apt-get update && \ - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends xvfb && \ - mkdir -p /home/node/.cache/ms-playwright && \ - PLAYWRIGHT_BROWSERS_PATH=/home/node/.cache/ms-playwright \ - node /app/node_modules/playwright-core/cli.js install --with-deps chromium && \ - chown -R node:node /home/node/.cache/ms-playwright && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \ - fi - -USER node -COPY --chown=node:node . . -RUN pnpm build -# Force pnpm for UI build (Bun may fail on ARM/Synology architectures) -ENV OPENCLAW_PREFER_PNPM=1 -RUN pnpm ui:build - -ENV NODE_ENV=production - -# Security hardening: Run as non-root user -# The node:22-bookworm image includes a 'node' user (uid 1000) -# This reduces the attack surface by preventing container escape via root privileges -USER node - -# Start gateway server with default config. -# Binds to loopback (127.0.0.1) by default for security. -# -# For container platforms requiring external health checks: -# 1. Set OPENCLAW_GATEWAY_TOKEN or OPENCLAW_GATEWAY_PASSWORD env var -# 2. Override CMD: ["node","openclaw.mjs","gateway","--allow-unconfigured","--bind","lan"] -CMD ["node", "openclaw.mjs", "gateway", "--allow-unconfigured"] diff --git a/Dockerfile.sandbox b/Dockerfile.sandbox deleted file mode 100644 index a463d4a1020..00000000000 --- a/Dockerfile.sandbox +++ /dev/null @@ -1,20 +0,0 @@ -FROM debian:bookworm-slim@sha256:98f4b71de414932439ac6ac690d7060df1f27161073c5036a7553723881bffbe - -ENV DEBIAN_FRONTEND=noninteractive - -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - bash \ - ca-certificates \ - curl \ - git \ - jq \ - python3 \ - ripgrep \ - && rm -rf /var/lib/apt/lists/* - -RUN useradd --create-home --shell /bin/bash sandbox -USER sandbox -WORKDIR /home/sandbox - -CMD ["sleep", "infinity"] diff --git a/Dockerfile.sandbox-browser b/Dockerfile.sandbox-browser deleted file mode 100644 index ec9faf71113..00000000000 --- a/Dockerfile.sandbox-browser +++ /dev/null @@ -1,32 +0,0 @@ -FROM debian:bookworm-slim@sha256:98f4b71de414932439ac6ac690d7060df1f27161073c5036a7553723881bffbe - -ENV DEBIAN_FRONTEND=noninteractive - -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - bash \ - ca-certificates \ - chromium \ - curl \ - fonts-liberation \ - fonts-noto-color-emoji \ - git \ - jq \ - novnc \ - python3 \ - socat \ - websockify \ - x11vnc \ - xvfb \ - && rm -rf /var/lib/apt/lists/* - -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"] diff --git a/Dockerfile.sandbox-common b/Dockerfile.sandbox-common deleted file mode 100644 index 71f80070adf..00000000000 --- a/Dockerfile.sandbox-common +++ /dev/null @@ -1,45 +0,0 @@ -ARG BASE_IMAGE=openclaw-sandbox:bookworm-slim -FROM ${BASE_IMAGE} - -USER root - -ENV DEBIAN_FRONTEND=noninteractive - -ARG PACKAGES="curl wget jq coreutils grep nodejs npm python3 git ca-certificates golang-go rustc cargo unzip pkg-config libasound2-dev build-essential file" -ARG INSTALL_PNPM=1 -ARG INSTALL_BUN=1 -ARG BUN_INSTALL_DIR=/opt/bun -ARG INSTALL_BREW=1 -ARG BREW_INSTALL_DIR=/home/linuxbrew/.linuxbrew -ARG FINAL_USER=sandbox - -ENV BUN_INSTALL=${BUN_INSTALL_DIR} -ENV HOMEBREW_PREFIX=${BREW_INSTALL_DIR} -ENV HOMEBREW_CELLAR=${BREW_INSTALL_DIR}/Cellar -ENV HOMEBREW_REPOSITORY=${BREW_INSTALL_DIR}/Homebrew -ENV PATH=${BUN_INSTALL_DIR}/bin:${BREW_INSTALL_DIR}/bin:${BREW_INSTALL_DIR}/sbin:${PATH} - -RUN apt-get update \ - && apt-get install -y --no-install-recommends ${PACKAGES} \ - && rm -rf /var/lib/apt/lists/* - -RUN if [ "${INSTALL_PNPM}" = "1" ]; then npm install -g pnpm; fi - -RUN if [ "${INSTALL_BUN}" = "1" ]; then \ - curl -fsSL https://bun.sh/install | bash; \ - ln -sf "${BUN_INSTALL_DIR}/bin/bun" /usr/local/bin/bun; \ -fi - -RUN if [ "${INSTALL_BREW}" = "1" ]; then \ - if ! id -u linuxbrew >/dev/null 2>&1; then useradd -m -s /bin/bash linuxbrew; fi; \ - mkdir -p "${BREW_INSTALL_DIR}"; \ - chown -R linuxbrew:linuxbrew "$(dirname "${BREW_INSTALL_DIR}")"; \ - su - linuxbrew -c "NONINTERACTIVE=1 CI=1 /bin/bash -c '$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)'"; \ - if [ ! -e "${BREW_INSTALL_DIR}/Library" ]; then ln -s "${BREW_INSTALL_DIR}/Homebrew/Library" "${BREW_INSTALL_DIR}/Library"; fi; \ - if [ ! -x "${BREW_INSTALL_DIR}/bin/brew" ]; then echo \"brew install failed\"; exit 1; fi; \ - ln -sf "${BREW_INSTALL_DIR}/bin/brew" /usr/local/bin/brew; \ -fi - -# Default is sandbox, but allow BASE_IMAGE overrides to select another final user. -USER ${FINAL_USER} - diff --git a/README.md b/README.md index b1419f42fa4..86a83591633 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@

- Website · Docs · OpenClaw Framework · Discord · Skills Store + Website · Docs · OpenClaw Framework · Discord · Skills Store

--- diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index 4b51daeaa73..00000000000 --- a/SECURITY.md +++ /dev/null @@ -1,137 +0,0 @@ -# Security Policy - -If you believe you've found a security issue in OpenClaw, please report it privately. - -## Reporting - -Report vulnerabilities directly to the repository where the issue lives: - -- **Core CLI and gateway** — [openclaw/openclaw](https://github.com/openclaw/openclaw) -- **macOS desktop app** — [openclaw/openclaw](https://github.com/openclaw/openclaw) (apps/macos) -- **iOS app** — [openclaw/openclaw](https://github.com/openclaw/openclaw) (apps/ios) -- **Android app** — [openclaw/openclaw](https://github.com/openclaw/openclaw) (apps/android) -- **ClawHub** — [openclaw/clawhub](https://github.com/openclaw/clawhub) -- **Trust and threat model** — [openclaw/trust](https://github.com/openclaw/trust) - -For issues that don't fit a specific repo, or if you're unsure, email **security@openclaw.ai** and we'll route it. - -For full reporting instructions see our [Trust page](https://trust.openclaw.ai). - -### Required in Reports - -1. **Title** -2. **Severity Assessment** -3. **Impact** -4. **Affected Component** -5. **Technical Reproduction** -6. **Demonstrated Impact** -7. **Environment** -8. **Remediation Advice** - -Reports without reproduction steps, demonstrated impact, and remediation advice will be deprioritized. Given the volume of AI-generated scanner findings, we must ensure we're receiving vetted reports from researchers who understand the issues. - -## 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 - -OpenClaw is a labor of love. There is no bug bounty program and no budget for paid reports. Please still disclose responsibly so we can fix issues quickly. -The best way to help the project right now is by sending PRs. - -## Maintainers: GHSA Updates via CLI - -When patching a GHSA via `gh api`, include `X-GitHub-Api-Version: 2022-11-28` (or newer). Without it, some fields (notably CVSS) may not persist even if the request returns 200. - -## Out of Scope - -- Public Internet Exposure -- Using OpenClaw in ways that the docs recommend not to -- Deployments where mutually untrusted/adversarial operators share one gateway host and config -- Prompt injection attacks - -## Deployment Assumptions - -OpenClaw security guidance assumes: - -- The host where OpenClaw runs is within a trusted OS/admin boundary. -- Anyone who can modify `~/.openclaw` state/config (including `openclaw.json`) is effectively a trusted operator. -- A single Gateway shared by mutually untrusted people is **not a recommended setup**. Use separate gateways (or at minimum separate OS users/hosts) per trust boundary. - -## Plugin Trust Boundary - -Plugins/extensions are loaded **in-process** with the Gateway and are treated as trusted code. - -- Plugins can execute with the same OS privileges as the OpenClaw process. -- Runtime helpers (for example `runtime.system.runCommandWithTimeout`) are convenience APIs, not a sandbox boundary. -- Only install plugins you trust, and prefer `plugins.allow` to pin explicit trusted plugin ids. - -## Operational Guidance - -For threat model + hardening guidance (including `openclaw security audit --deep` and `--fix`), see: - -- `https://docs.openclaw.ai/gateway/security` - -### Tool filesystem hardening - -- `tools.exec.applyPatch.workspaceOnly: true` (recommended): keeps `apply_patch` writes/deletes within the configured workspace directory. -- `tools.fs.workspaceOnly: true` (optional): restricts `read`/`write`/`edit`/`apply_patch` paths to the workspace directory. -- Avoid setting `tools.exec.applyPatch.workspaceOnly: false` unless you fully trust who can trigger tool execution. - -### Web Interface Safety - -OpenClaw's web interface (Gateway Control UI + HTTP endpoints) is intended for **local use only**. - -- Recommended: keep the Gateway **loopback-only** (`127.0.0.1` / `::1`). - - Config: `gateway.bind="loopback"` (default). - - CLI: `openclaw gateway run --bind loopback`. -- Canvas host note: network-visible canvas is **intentional** for trusted node scenarios (LAN/tailnet). - - Expected setup: non-loopback bind + Gateway auth (token/password/trusted-proxy) + firewall/tailnet controls. - - Expected routes: `/__openclaw__/canvas/`, `/__openclaw__/a2ui/`. - - This deployment model alone is not a security vulnerability. -- Do **not** expose it to the public internet (no direct bind to `0.0.0.0`, no public reverse proxy). It is not hardened for public exposure. -- If you need remote access, prefer an SSH tunnel or Tailscale serve/funnel (so the Gateway still binds to loopback), plus strong Gateway auth. -- The Gateway HTTP surface includes the canvas host (`/__openclaw__/canvas/`, `/__openclaw__/a2ui/`). Treat canvas content as sensitive/untrusted and avoid exposing it beyond loopback unless you understand the risk. - -## Runtime Requirements - -### Node.js Version - -OpenClaw requires **Node.js 22.12.0 or later** (LTS). This version includes important security patches: - -- CVE-2025-59466: async_hooks DoS vulnerability -- CVE-2026-21636: Permission model bypass vulnerability - -Verify your Node.js version: - -```bash -node --version # Should be v22.12.0 or later -``` - -### Docker Security - -When running OpenClaw in Docker: - -1. The official image runs as a non-root user (`node`) for reduced attack surface -2. Use `--read-only` flag when possible for additional filesystem protection -3. Limit container capabilities with `--cap-drop=ALL` - -Example secure Docker run: - -```bash -docker run --read-only --cap-drop=ALL \ - -v openclaw-data:/app/data \ - openclaw/openclaw:latest -``` - -## Security Scanning - -This project uses `detect-secrets` for automated secret detection in CI/CD. -See `.detect-secrets.cfg` for configuration and `.secrets.baseline` for the baseline. - -Run locally: - -```bash -pip install detect-secrets==1.5.0 -detect-secrets scan --baseline .secrets.baseline -``` diff --git a/Swabble/.github/workflows/ci.yml b/Swabble/.github/workflows/ci.yml deleted file mode 100644 index aff600f6df0..00000000000 --- a/Swabble/.github/workflows/ci.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - -jobs: - build-and-test: - runs-on: macos-latest - defaults: - run: - shell: bash - working-directory: swabble - steps: - - name: Checkout swabble - uses: actions/checkout@v4 - with: - path: swabble - - - name: Select Xcode 26.1 (prefer 26.1.1) - run: | - set -euo pipefail - # pick the newest installed 26.1.x, fallback to newest 26.x - CANDIDATE="$(ls -d /Applications/Xcode_26.1*.app 2>/dev/null | sort -V | tail -1 || true)" - if [[ -z "$CANDIDATE" ]]; then - CANDIDATE="$(ls -d /Applications/Xcode_26*.app 2>/dev/null | sort -V | tail -1 || true)" - fi - if [[ -z "$CANDIDATE" ]]; then - echo "No Xcode 26.x found on runner" >&2 - exit 1 - fi - echo "Selecting $CANDIDATE" - sudo xcode-select -s "$CANDIDATE" - xcodebuild -version - - - name: Show Swift version - run: swift --version - - - name: Install tooling - run: | - brew update - brew install swiftlint swiftformat - - - name: Format check - run: | - ./scripts/format.sh - git diff --exit-code - - - name: Lint - run: ./scripts/lint.sh - - - name: Test - run: swift test --parallel diff --git a/Swabble/.gitignore b/Swabble/.gitignore deleted file mode 100644 index e988a5b232b..00000000000 --- a/Swabble/.gitignore +++ /dev/null @@ -1,33 +0,0 @@ -# macOS -.DS_Store - -# SwiftPM / Build -/.build -/.swiftpm -/DerivedData -xcuserdata/ -*.xcuserstate - -# Editors -/.vscode -.idea/ - -# Xcode artifacts -*.hmap -*.ipa -*.dSYM.zip -*.dSYM - -# Playgrounds -*.xcplayground -playground.xcworkspace -timeline.xctimeline - -# Carthage -Carthage/Build/ - -# fastlane -fastlane/report.xml -fastlane/Preview.html -fastlane/screenshots/**/*.png -fastlane/test_output diff --git a/Swabble/.swiftformat b/Swabble/.swiftformat deleted file mode 100644 index 2686269a272..00000000000 --- a/Swabble/.swiftformat +++ /dev/null @@ -1,8 +0,0 @@ ---swiftversion 6.2 ---indent 4 ---maxwidth 120 ---wraparguments before-first ---wrapcollections before-first ---stripunusedargs closure-only ---self remove ---header "" diff --git a/Swabble/.swiftlint.yml b/Swabble/.swiftlint.yml deleted file mode 100644 index f63ff5dbb18..00000000000 --- a/Swabble/.swiftlint.yml +++ /dev/null @@ -1,43 +0,0 @@ -# SwiftLint for swabble -included: - - Sources -excluded: - - .build - - DerivedData - - "**/.swiftpm" - - "**/.build" - - "**/DerivedData" - - "**/.DS_Store" -opt_in_rules: - - array_init - - closure_spacing - - explicit_init - - fatal_error_message - - first_where - - joined_default_parameter - - last_where - - literal_expression_end_indentation - - multiline_arguments - - multiline_parameters - - operator_usage_whitespace - - redundant_nil_coalescing - - sorted_first_last - - switch_case_alignment - - vertical_parameter_alignment_on_call - - vertical_whitespace_opening_braces - - vertical_whitespace_closing_braces - -disabled_rules: - - trailing_whitespace - - trailing_newline - - indentation_width - - identifier_name - - explicit_self - - file_header - - todo - -line_length: - warning: 140 - error: 180 - -reporter: "xcode" diff --git a/Swabble/CHANGELOG.md b/Swabble/CHANGELOG.md deleted file mode 100644 index e8f2ad60d85..00000000000 --- a/Swabble/CHANGELOG.md +++ /dev/null @@ -1,11 +0,0 @@ -# Changelog - -## 0.2.0 — 2025-12-23 - -### Highlights -- Added `SwabbleKit` (multi-platform wake-word gate utilities with segment-aware gap detection). -- Swabble package now supports iOS + macOS consumers; CLI remains macOS 26-only. - -### Changes -- CLI wake-word matching/stripping routed through `SwabbleKit` helpers. -- Speech pipeline types now explicitly gated to macOS 26 / iOS 26 availability. diff --git a/Swabble/LICENSE b/Swabble/LICENSE deleted file mode 100644 index f7b526698bb..00000000000 --- a/Swabble/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 Peter Steinberger - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/Swabble/Package.resolved b/Swabble/Package.resolved deleted file mode 100644 index f52a51fbe53..00000000000 --- a/Swabble/Package.resolved +++ /dev/null @@ -1,69 +0,0 @@ -{ - "originHash" : "24a723309d7a0039d3df3051106f77ac1ed7068a02508e3a6804e41d757e6c72", - "pins" : [ - { - "identity" : "commander", - "kind" : "remoteSourceControl", - "location" : "https://github.com/steipete/Commander.git", - "state" : { - "revision" : "9e349575c8e3c6745e81fe19e5bb5efa01b078ce", - "version" : "0.2.1" - } - }, - { - "identity" : "elevenlabskit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/steipete/ElevenLabsKit", - "state" : { - "revision" : "7e3c948d8340abe3977014f3de020edf221e9269", - "version" : "0.1.0" - } - }, - { - "identity" : "swift-concurrency-extras", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-concurrency-extras", - "state" : { - "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", - "version" : "1.3.2" - } - }, - { - "identity" : "swift-syntax", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-syntax.git", - "state" : { - "revision" : "0687f71944021d616d34d922343dcef086855920", - "version" : "600.0.1" - } - }, - { - "identity" : "swift-testing", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-testing", - "state" : { - "revision" : "399f76dcd91e4c688ca2301fa24a8cc6d9927211", - "version" : "0.99.0" - } - }, - { - "identity" : "swiftui-math", - "kind" : "remoteSourceControl", - "location" : "https://github.com/gonzalezreal/swiftui-math", - "state" : { - "revision" : "0b5c2cfaaec8d6193db206f675048eeb5ce95f71", - "version" : "0.1.0" - } - }, - { - "identity" : "textual", - "kind" : "remoteSourceControl", - "location" : "https://github.com/gonzalezreal/textual", - "state" : { - "revision" : "5b06b811c0f5313b6b84bbef98c635a630638c38", - "version" : "0.3.1" - } - } - ], - "version" : 3 -} diff --git a/Swabble/Package.swift b/Swabble/Package.swift deleted file mode 100644 index 9f5a0003619..00000000000 --- a/Swabble/Package.swift +++ /dev/null @@ -1,55 +0,0 @@ -// swift-tools-version: 6.2 -import PackageDescription - -let package = Package( - name: "swabble", - platforms: [ - .macOS(.v15), - .iOS(.v17), - ], - products: [ - .library(name: "Swabble", targets: ["Swabble"]), - .library(name: "SwabbleKit", targets: ["SwabbleKit"]), - .executable(name: "swabble", targets: ["SwabbleCLI"]), - ], - dependencies: [ - .package(url: "https://github.com/steipete/Commander.git", exact: "0.2.1"), - .package(url: "https://github.com/apple/swift-testing", from: "0.99.0"), - ], - targets: [ - .target( - name: "Swabble", - path: "Sources/SwabbleCore", - swiftSettings: []), - .target( - name: "SwabbleKit", - path: "Sources/SwabbleKit", - swiftSettings: [ - .enableUpcomingFeature("StrictConcurrency"), - ]), - .executableTarget( - name: "SwabbleCLI", - dependencies: [ - "Swabble", - "SwabbleKit", - .product(name: "Commander", package: "Commander"), - ], - path: "Sources/swabble"), - .testTarget( - name: "SwabbleKitTests", - dependencies: [ - "SwabbleKit", - .product(name: "Testing", package: "swift-testing"), - ], - swiftSettings: [ - .enableUpcomingFeature("StrictConcurrency"), - .enableExperimentalFeature("SwiftTesting"), - ]), - .testTarget( - name: "swabbleTests", - dependencies: [ - "Swabble", - .product(name: "Testing", package: "swift-testing"), - ]), - ], - swiftLanguageModes: [.v6]) diff --git a/Swabble/README.md b/Swabble/README.md deleted file mode 100644 index bf6dc3dc8bd..00000000000 --- a/Swabble/README.md +++ /dev/null @@ -1,111 +0,0 @@ -# 🎙️ swabble — Speech.framework wake-word hook daemon (macOS 26) - -swabble is a Swift 6.2 wake-word hook daemon. The CLI targets macOS 26 (SpeechAnalyzer + SpeechTranscriber). The shared `SwabbleKit` target is multi-platform and exposes wake-word gating utilities for iOS/macOS apps. - -- **Local-only**: Speech.framework on-device models; zero network usage. -- **Wake word**: Default `clawd` (aliases `claude`), optional `--no-wake` bypass. -- **SwabbleKit**: Shared wake gate utilities (gap-based gating when you provide speech segments). -- **Hooks**: Run any command with prefix/env, cooldown, min_chars, timeout. -- **Services**: launchd helper stubs for start/stop/install. -- **File transcribe**: TXT or SRT with time ranges (using AttributedString splits). - -## Quick start -```bash -# Install deps -brew install swiftformat swiftlint - -# Build -swift build - -# Write default config (~/.config/swabble/config.json) -swift run swabble setup - -# Run foreground daemon -swift run swabble serve - -# Test your hook -swift run swabble test-hook "hello world" - -# Transcribe a file to SRT -swift run swabble transcribe /path/to/audio.m4a --format srt --output out.srt -``` - -## Use as a library -Add swabble as a SwiftPM dependency and import the `Swabble` or `SwabbleKit` product: - -```swift -// Package.swift -dependencies: [ - .package(url: "https://github.com/steipete/swabble.git", branch: "main"), -], -targets: [ - .target(name: "MyApp", dependencies: [ - .product(name: "Swabble", package: "swabble"), // Speech pipeline (macOS 26+ / iOS 26+) - .product(name: "SwabbleKit", package: "swabble"), // Wake-word gate utilities (iOS 17+ / macOS 15+) - ]), -] -``` - -## CLI -- `serve` — foreground loop (mic → wake → hook) -- `transcribe ` — offline transcription (txt|srt) -- `test-hook "text"` — invoke configured hook -- `mic list|set ` — enumerate/select input device -- `setup` — write default config JSON -- `doctor` — check Speech auth & device availability -- `health` — prints `ok` -- `tail-log` — last 10 transcripts -- `status` — show wake state + recent transcripts -- `service install|uninstall|status` — user launchd plist (stub: prints launchctl commands) -- `start|stop|restart` — placeholders until full launchd wiring - -All commands accept Commander runtime flags (`-v/--verbose`, `--json-output`, `--log-level`), plus `--config` where applicable. - -## Config -`~/.config/swabble/config.json` (auto-created by `setup`): -```json -{ - "audio": {"deviceName": "", "deviceIndex": -1, "sampleRate": 16000, "channels": 1}, - "wake": {"enabled": true, "word": "clawd", "aliases": ["claude"]}, - "hook": { - "command": "", - "args": [], - "prefix": "Voice swabble from ${hostname}: ", - "cooldownSeconds": 1, - "minCharacters": 24, - "timeoutSeconds": 5, - "env": {} - }, - "logging": {"level": "info", "format": "text"}, - "transcripts": {"enabled": true, "maxEntries": 50}, - "speech": {"localeIdentifier": "en_US", "etiquetteReplacements": false} -} -``` - -- Config path override: `--config /path/to/config.json` on relevant commands. -- Transcripts persist to `~/Library/Application Support/swabble/transcripts.log`. - -## Hook protocol -When a wake-gated transcript passes min_chars & cooldown, swabble runs: -``` - "" -``` -Environment variables: -- `SWABBLE_TEXT` — stripped transcript (wake word removed) -- `SWABBLE_PREFIX` — rendered prefix (hostname substituted) -- plus any `hook.env` key/values - -## Speech pipeline -- `AVAudioEngine` tap → `BufferConverter` → `AnalyzerInput` → `SpeechAnalyzer` with a `SpeechTranscriber` module. -- Requests volatile + final results; the CLI uses text-only wake gating today. -- Authorization requested at first start; requires macOS 26 + new Speech.framework APIs. - -## Development -- Format: `./scripts/format.sh` (uses local `.swiftformat`) -- Lint: `./scripts/lint.sh` (uses local `.swiftlint.yml`) -- Tests: `swift test` (uses swift-testing package) - -## Roadmap -- launchd control (load/bootout, PID + status socket) -- JSON logging + PII redaction toggle -- Stronger wake-word detection and control socket status/health diff --git a/Swabble/Sources/SwabbleCore/Config/Config.swift b/Swabble/Sources/SwabbleCore/Config/Config.swift deleted file mode 100644 index 4dc9d4668c0..00000000000 --- a/Swabble/Sources/SwabbleCore/Config/Config.swift +++ /dev/null @@ -1,77 +0,0 @@ -import Foundation - -public struct SwabbleConfig: Codable, Sendable { - public struct Audio: Codable, Sendable { - public var deviceName: String = "" - public var deviceIndex: Int = -1 - public var sampleRate: Double = 16000 - public var channels: Int = 1 - } - - public struct Wake: Codable, Sendable { - public var enabled: Bool = true - public var word: String = "clawd" - public var aliases: [String] = ["claude"] - } - - public struct Hook: Codable, Sendable { - public var command: String = "" - public var args: [String] = [] - public var prefix: String = "Voice swabble from ${hostname}: " - public var cooldownSeconds: Double = 1 - public var minCharacters: Int = 24 - public var timeoutSeconds: Double = 5 - public var env: [String: String] = [:] - } - - public struct Logging: Codable, Sendable { - public var level: String = "info" - public var format: String = "text" // text|json placeholder - } - - public struct Transcripts: Codable, Sendable { - public var enabled: Bool = true - public var maxEntries: Int = 50 - } - - public struct Speech: Codable, Sendable { - public var localeIdentifier: String = Locale.current.identifier - public var etiquetteReplacements: Bool = false - } - - public var audio = Audio() - public var wake = Wake() - public var hook = Hook() - public var logging = Logging() - public var transcripts = Transcripts() - public var speech = Speech() - - public static let defaultPath = FileManager.default - .homeDirectoryForCurrentUser - .appendingPathComponent(".config/swabble/config.json") - - public init() {} -} - -public enum ConfigError: Error { - case missingConfig -} - -public enum ConfigLoader { - public static func load(at path: URL?) throws -> SwabbleConfig { - let url = path ?? SwabbleConfig.defaultPath - if !FileManager.default.fileExists(atPath: url.path) { - throw ConfigError.missingConfig - } - let data = try Data(contentsOf: url) - return try JSONDecoder().decode(SwabbleConfig.self, from: data) - } - - public static func save(_ config: SwabbleConfig, at path: URL?) throws { - let url = path ?? SwabbleConfig.defaultPath - let dir = url.deletingLastPathComponent() - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - let data = try JSONEncoder().encode(config) - try data.write(to: url) - } -} diff --git a/Swabble/Sources/SwabbleCore/Hooks/HookExecutor.swift b/Swabble/Sources/SwabbleCore/Hooks/HookExecutor.swift deleted file mode 100644 index dd59c43bb58..00000000000 --- a/Swabble/Sources/SwabbleCore/Hooks/HookExecutor.swift +++ /dev/null @@ -1,75 +0,0 @@ -import Foundation - -public struct HookJob: Sendable { - public let text: String - public let timestamp: Date - - public init(text: String, timestamp: Date) { - self.text = text - self.timestamp = timestamp - } -} - -public actor HookExecutor { - private let config: SwabbleConfig - private var lastRun: Date? - private let hostname: String - - public init(config: SwabbleConfig) { - self.config = config - hostname = Host.current().localizedName ?? "host" - } - - public func shouldRun() -> Bool { - guard config.hook.cooldownSeconds > 0 else { return true } - if let lastRun, Date().timeIntervalSince(lastRun) < config.hook.cooldownSeconds { - return false - } - return true - } - - public func run(job: HookJob) async throws { - guard shouldRun() else { return } - guard !config.hook.command.isEmpty else { throw NSError( - domain: "Hook", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "hook command not set"]) } - - let prefix = config.hook.prefix.replacingOccurrences(of: "${hostname}", with: hostname) - let payload = prefix + job.text - - let process = Process() - process.executableURL = URL(fileURLWithPath: config.hook.command) - process.arguments = config.hook.args + [payload] - - var env = ProcessInfo.processInfo.environment - env["SWABBLE_TEXT"] = job.text - env["SWABBLE_PREFIX"] = prefix - for (k, v) in config.hook.env { - env[k] = v - } - process.environment = env - - let pipe = Pipe() - process.standardOutput = pipe - process.standardError = pipe - - try process.run() - - let timeoutNanos = UInt64(max(config.hook.timeoutSeconds, 0.1) * 1_000_000_000) - try await withThrowingTaskGroup(of: Void.self) { group in - group.addTask { - process.waitUntilExit() - } - group.addTask { - try await Task.sleep(nanoseconds: timeoutNanos) - if process.isRunning { - process.terminate() - } - } - try await group.next() - group.cancelAll() - } - lastRun = Date() - } -} diff --git a/Swabble/Sources/SwabbleCore/Speech/BufferConverter.swift b/Swabble/Sources/SwabbleCore/Speech/BufferConverter.swift deleted file mode 100644 index e6d7dc993ba..00000000000 --- a/Swabble/Sources/SwabbleCore/Speech/BufferConverter.swift +++ /dev/null @@ -1,50 +0,0 @@ -@preconcurrency import AVFoundation -import Foundation - -final class BufferConverter { - private final class Box: @unchecked Sendable { var value: T; init(_ value: T) { self.value = value } } - enum ConverterError: Swift.Error { - case failedToCreateConverter - case failedToCreateConversionBuffer - case conversionFailed(NSError?) - } - - private var converter: AVAudioConverter? - - func convert(_ buffer: AVAudioPCMBuffer, to format: AVAudioFormat) throws -> AVAudioPCMBuffer { - let inputFormat = buffer.format - if inputFormat == format { - return buffer - } - if converter == nil || converter?.outputFormat != format { - converter = AVAudioConverter(from: inputFormat, to: format) - converter?.primeMethod = .none - } - guard let converter else { throw ConverterError.failedToCreateConverter } - - let sampleRateRatio = converter.outputFormat.sampleRate / converter.inputFormat.sampleRate - let scaledInputFrameLength = Double(buffer.frameLength) * sampleRateRatio - let frameCapacity = AVAudioFrameCount(scaledInputFrameLength.rounded(.up)) - guard let conversionBuffer = AVAudioPCMBuffer(pcmFormat: converter.outputFormat, frameCapacity: frameCapacity) - else { - throw ConverterError.failedToCreateConversionBuffer - } - - var nsError: NSError? - let consumed = Box(false) - let inputBuffer = buffer - let status = converter.convert(to: conversionBuffer, error: &nsError) { _, statusPtr in - if consumed.value { - statusPtr.pointee = .noDataNow - return nil - } - consumed.value = true - statusPtr.pointee = .haveData - return inputBuffer - } - if status == .error { - throw ConverterError.conversionFailed(nsError) - } - return conversionBuffer - } -} diff --git a/Swabble/Sources/SwabbleCore/Speech/SpeechPipeline.swift b/Swabble/Sources/SwabbleCore/Speech/SpeechPipeline.swift deleted file mode 100644 index 014b174da7b..00000000000 --- a/Swabble/Sources/SwabbleCore/Speech/SpeechPipeline.swift +++ /dev/null @@ -1,114 +0,0 @@ -import AVFoundation -import Foundation -import Speech - -@available(macOS 26.0, iOS 26.0, *) -public struct SpeechSegment: Sendable { - public let text: String - public let isFinal: Bool -} - -@available(macOS 26.0, iOS 26.0, *) -public enum SpeechPipelineError: Error { - case authorizationDenied - case analyzerFormatUnavailable - case transcriberUnavailable -} - -/// Live microphone → SpeechAnalyzer → SpeechTranscriber pipeline. -@available(macOS 26.0, iOS 26.0, *) -public actor SpeechPipeline { - private struct UnsafeBuffer: @unchecked Sendable { let buffer: AVAudioPCMBuffer } - - private var engine = AVAudioEngine() - private var transcriber: SpeechTranscriber? - private var analyzer: SpeechAnalyzer? - private var inputContinuation: AsyncStream.Continuation? - private var resultTask: Task? - private let converter = BufferConverter() - - public init() {} - - public func start(localeIdentifier: String, etiquette: Bool) async throws -> AsyncStream { - let auth = await requestAuthorizationIfNeeded() - guard auth == .authorized else { throw SpeechPipelineError.authorizationDenied } - - let transcriberModule = SpeechTranscriber( - locale: Locale(identifier: localeIdentifier), - transcriptionOptions: etiquette ? [.etiquetteReplacements] : [], - reportingOptions: [.volatileResults], - attributeOptions: []) - transcriber = transcriberModule - - guard let analyzerFormat = await SpeechAnalyzer.bestAvailableAudioFormat(compatibleWith: [transcriberModule]) - else { - throw SpeechPipelineError.analyzerFormatUnavailable - } - - analyzer = SpeechAnalyzer(modules: [transcriberModule]) - let (stream, continuation) = AsyncStream.makeStream() - inputContinuation = continuation - - let inputNode = engine.inputNode - let inputFormat = inputNode.outputFormat(forBus: 0) - inputNode.removeTap(onBus: 0) - inputNode.installTap(onBus: 0, bufferSize: 2048, format: inputFormat) { [weak self] buffer, _ in - guard let self else { return } - let boxed = UnsafeBuffer(buffer: buffer) - Task { await self.handleBuffer(boxed.buffer, targetFormat: analyzerFormat) } - } - - engine.prepare() - try engine.start() - try await analyzer?.start(inputSequence: stream) - - guard let transcriberForStream = transcriber else { - throw SpeechPipelineError.transcriberUnavailable - } - - return AsyncStream { continuation in - self.resultTask = Task { - do { - for try await result in transcriberForStream.results { - let seg = SpeechSegment(text: String(result.text.characters), isFinal: result.isFinal) - continuation.yield(seg) - } - } catch { - // swallow errors and finish - } - continuation.finish() - } - continuation.onTermination = { _ in - Task { await self.stop() } - } - } - } - - public func stop() async { - resultTask?.cancel() - inputContinuation?.finish() - engine.inputNode.removeTap(onBus: 0) - engine.stop() - try? await analyzer?.finalizeAndFinishThroughEndOfInput() - } - - private func handleBuffer(_ buffer: AVAudioPCMBuffer, targetFormat: AVAudioFormat) async { - do { - let converted = try converter.convert(buffer, to: targetFormat) - let input = AnalyzerInput(buffer: converted) - inputContinuation?.yield(input) - } catch { - // drop on conversion failure - } - } - - private func requestAuthorizationIfNeeded() async -> SFSpeechRecognizerAuthorizationStatus { - let current = SFSpeechRecognizer.authorizationStatus() - guard current == .notDetermined else { return current } - return await withCheckedContinuation { continuation in - SFSpeechRecognizer.requestAuthorization { status in - continuation.resume(returning: status) - } - } - } -} diff --git a/Swabble/Sources/SwabbleCore/Support/AttributedString+Sentences.swift b/Swabble/Sources/SwabbleCore/Support/AttributedString+Sentences.swift deleted file mode 100644 index e2de6fdfce5..00000000000 --- a/Swabble/Sources/SwabbleCore/Support/AttributedString+Sentences.swift +++ /dev/null @@ -1,62 +0,0 @@ -import CoreMedia -import Foundation -import NaturalLanguage - -extension AttributedString { - public func sentences(maxLength: Int? = nil) -> [AttributedString] { - let tokenizer = NLTokenizer(unit: .sentence) - let string = String(characters) - tokenizer.string = string - let sentenceRanges = tokenizer.tokens(for: string.startIndex.. maxLength else { - return [sentenceRange] - } - - let wordTokenizer = NLTokenizer(unit: .word) - wordTokenizer.string = string - var wordRanges = wordTokenizer.tokens(for: sentenceStringRange).map { - AttributedString.Index($0.lowerBound, within: self)! - ..< - AttributedString.Index($0.upperBound, within: self)! - } - guard !wordRanges.isEmpty else { return [sentenceRange] } - wordRanges[0] = sentenceRange.lowerBound..] = [] - for wordRange in wordRanges { - if let lastRange = ranges.last, - self[lastRange].characters.count + self[wordRange].characters.count <= maxLength { - ranges[ranges.count - 1] = lastRange.lowerBound.. Bool { lhs.rank < rhs.rank } -} - -public struct Logger: Sendable { - public let level: LogLevel - - public init(level: LogLevel) { self.level = level } - - public func log(_ level: LogLevel, _ message: String) { - guard level >= self.level else { return } - let ts = ISO8601DateFormatter().string(from: Date()) - print("[\(level.rawValue.uppercased())] \(ts) | \(message)") - } - - public func trace(_ msg: String) { log(.trace, msg) } - public func debug(_ msg: String) { log(.debug, msg) } - public func info(_ msg: String) { log(.info, msg) } - public func warn(_ msg: String) { log(.warn, msg) } - public func error(_ msg: String) { log(.error, msg) } -} - -extension LogLevel { - public init?(configValue: String) { - self.init(rawValue: configValue.lowercased()) - } -} diff --git a/Swabble/Sources/SwabbleCore/Support/OutputFormat.swift b/Swabble/Sources/SwabbleCore/Support/OutputFormat.swift deleted file mode 100644 index 84047c7284b..00000000000 --- a/Swabble/Sources/SwabbleCore/Support/OutputFormat.swift +++ /dev/null @@ -1,45 +0,0 @@ -import CoreMedia -import Foundation - -public enum OutputFormat: String { - case txt - case srt - - public var needsAudioTimeRange: Bool { - switch self { - case .srt: true - default: false - } - } - - public func text(for transcript: AttributedString, maxLength: Int) -> String { - switch self { - case .txt: - return String(transcript.characters) - case .srt: - func format(_ timeInterval: TimeInterval) -> String { - let ms = Int(timeInterval.truncatingRemainder(dividingBy: 1) * 1000) - let s = Int(timeInterval) % 60 - let m = (Int(timeInterval) / 60) % 60 - let h = Int(timeInterval) / 60 / 60 - return String(format: "%0.2d:%0.2d:%0.2d,%0.3d", h, m, s, ms) - } - - return transcript.sentences(maxLength: maxLength).compactMap { (sentence: AttributedString) -> ( - CMTimeRange, - String)? in - guard let timeRange = sentence.audioTimeRange else { return nil } - return (timeRange, String(sentence.characters)) - }.enumerated().map { index, run in - let (timeRange, text) = run - return """ - - \(index + 1) - \(format(timeRange.start.seconds)) --> \(format(timeRange.end.seconds)) - \(text.trimmingCharacters(in: .whitespacesAndNewlines)) - - """ - }.joined().trimmingCharacters(in: .whitespacesAndNewlines) - } - } -} diff --git a/Swabble/Sources/SwabbleCore/Support/TranscriptsStore.swift b/Swabble/Sources/SwabbleCore/Support/TranscriptsStore.swift deleted file mode 100644 index 4f91d052e6a..00000000000 --- a/Swabble/Sources/SwabbleCore/Support/TranscriptsStore.swift +++ /dev/null @@ -1,45 +0,0 @@ -import Foundation - -public actor TranscriptsStore { - public static let shared = TranscriptsStore() - - private var entries: [String] = [] - private let limit = 100 - private let fileURL: URL - - public init() { - let dir = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Library/Application Support/swabble", isDirectory: true) - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - fileURL = dir.appendingPathComponent("transcripts.log") - if let data = try? Data(contentsOf: fileURL), - let text = String(data: data, encoding: .utf8) { - entries = text.split(separator: "\n").map(String.init).suffix(limit) - } - } - - public func append(text: String) { - entries.append(text) - if entries.count > limit { - entries.removeFirst(entries.count - limit) - } - let body = entries.joined(separator: "\n") - try? body.write(to: fileURL, atomically: false, encoding: .utf8) - } - - public func latest() -> [String] { entries } -} - -extension String { - private func appendLine(to url: URL) throws { - let data = (self + "\n").data(using: .utf8) ?? Data() - if FileManager.default.fileExists(atPath: url.path) { - let handle = try FileHandle(forWritingTo: url) - try handle.seekToEnd() - try handle.write(contentsOf: data) - try handle.close() - } else { - try data.write(to: url) - } - } -} diff --git a/Swabble/Sources/SwabbleKit/WakeWordGate.swift b/Swabble/Sources/SwabbleKit/WakeWordGate.swift deleted file mode 100644 index 27c952a8d1b..00000000000 --- a/Swabble/Sources/SwabbleKit/WakeWordGate.swift +++ /dev/null @@ -1,197 +0,0 @@ -import Foundation - -public struct WakeWordSegment: Sendable, Equatable { - public let text: String - public let start: TimeInterval - public let duration: TimeInterval - public let range: Range? - - public init(text: String, start: TimeInterval, duration: TimeInterval, range: Range? = nil) { - self.text = text - self.start = start - self.duration = duration - self.range = range - } - - public var end: TimeInterval { start + duration } -} - -public struct WakeWordGateConfig: Sendable, Equatable { - public var triggers: [String] - public var minPostTriggerGap: TimeInterval - public var minCommandLength: Int - - public init( - triggers: [String], - minPostTriggerGap: TimeInterval = 0.45, - minCommandLength: Int = 1) { - self.triggers = triggers - self.minPostTriggerGap = minPostTriggerGap - self.minCommandLength = minCommandLength - } -} - -public struct WakeWordGateMatch: Sendable, Equatable { - public let triggerEndTime: TimeInterval - public let postGap: TimeInterval - public let command: String - - public init(triggerEndTime: TimeInterval, postGap: TimeInterval, command: String) { - self.triggerEndTime = triggerEndTime - self.postGap = postGap - self.command = command - } -} - -public enum WakeWordGate { - private struct Token { - let normalized: String - let start: TimeInterval - let end: TimeInterval - let range: Range? - let text: String - } - - private struct TriggerTokens { - let tokens: [String] - } - - private struct MatchCandidate { - let index: Int - let triggerEnd: TimeInterval - let gap: TimeInterval - } - - public static func match( - transcript: String, - segments: [WakeWordSegment], - config: WakeWordGateConfig) - -> WakeWordGateMatch? { - let triggerTokens = normalizeTriggers(config.triggers) - guard !triggerTokens.isEmpty else { return nil } - - let tokens = normalizeSegments(segments) - guard !tokens.isEmpty else { return nil } - - var best: MatchCandidate? - - for trigger in triggerTokens { - let count = trigger.tokens.count - guard count > 0, tokens.count > count else { continue } - for i in 0...(tokens.count - count - 1) { - let matched = (0..= config.minCommandLength else { return nil } - return WakeWordGateMatch(triggerEndTime: best.triggerEnd, postGap: best.gap, command: command) - } - - public static func commandText( - transcript: String, - segments: [WakeWordSegment], - triggerEndTime: TimeInterval) - -> String { - let threshold = triggerEndTime + 0.001 - for segment in segments where segment.start >= threshold { - if normalizeToken(segment.text).isEmpty { continue } - if let range = segment.range { - let slice = transcript[range.lowerBound...] - return String(slice).trimmingCharacters(in: Self.whitespaceAndPunctuation) - } - break - } - - let text = segments - .filter { $0.start >= threshold && !normalizeToken($0.text).isEmpty } - .map(\.text) - .joined(separator: " ") - return text.trimmingCharacters(in: Self.whitespaceAndPunctuation) - } - - public static func matchesTextOnly(text: String, triggers: [String]) -> Bool { - guard !text.isEmpty else { return false } - let normalized = text.lowercased() - for trigger in triggers { - let token = trigger.trimmingCharacters(in: whitespaceAndPunctuation).lowercased() - if token.isEmpty { continue } - if normalized.contains(token) { return true } - } - return false - } - - public static func stripWake(text: String, triggers: [String]) -> String { - var out = text - for trigger in triggers { - let token = trigger.trimmingCharacters(in: whitespaceAndPunctuation) - guard !token.isEmpty else { continue } - out = out.replacingOccurrences(of: token, with: "", options: [.caseInsensitive]) - } - return out.trimmingCharacters(in: whitespaceAndPunctuation) - } - - private static func normalizeTriggers(_ triggers: [String]) -> [TriggerTokens] { - var output: [TriggerTokens] = [] - for trigger in triggers { - let tokens = trigger - .split(whereSeparator: { $0.isWhitespace }) - .map { normalizeToken(String($0)) } - .filter { !$0.isEmpty } - if tokens.isEmpty { continue } - output.append(TriggerTokens(tokens: tokens)) - } - return output - } - - private static func normalizeSegments(_ segments: [WakeWordSegment]) -> [Token] { - segments.compactMap { segment in - let normalized = normalizeToken(segment.text) - guard !normalized.isEmpty else { return nil } - return Token( - normalized: normalized, - start: segment.start, - end: segment.end, - range: segment.range, - text: segment.text) - } - } - - private static func normalizeToken(_ token: String) -> String { - token - .trimmingCharacters(in: whitespaceAndPunctuation) - .lowercased() - } - - private static let whitespaceAndPunctuation = CharacterSet.whitespacesAndNewlines - .union(.punctuationCharacters) -} - -#if canImport(Speech) -import Speech - -public enum WakeWordSpeechSegments { - public static func from(transcription: SFTranscription, transcript: String) -> [WakeWordSegment] { - transcription.segments.map { segment in - let range = Range(segment.substringRange, in: transcript) - return WakeWordSegment( - text: segment.substring, - start: segment.timestamp, - duration: segment.duration, - range: range) - } - } -} -#endif diff --git a/Swabble/Sources/swabble/CLI/CLIRegistry.swift b/Swabble/Sources/swabble/CLI/CLIRegistry.swift deleted file mode 100644 index c47a9864f9a..00000000000 --- a/Swabble/Sources/swabble/CLI/CLIRegistry.swift +++ /dev/null @@ -1,71 +0,0 @@ -import Commander -import Foundation - -@available(macOS 26.0, *) -@MainActor -enum CLIRegistry { - static var descriptors: [CommandDescriptor] { - let serveDesc = descriptor(for: ServeCommand.self) - let transcribeDesc = descriptor(for: TranscribeCommand.self) - let testHookDesc = descriptor(for: TestHookCommand.self) - let micList = descriptor(for: MicList.self) - let micSet = descriptor(for: MicSet.self) - let micRoot = CommandDescriptor( - name: "mic", - abstract: "Microphone management", - discussion: nil, - signature: CommandSignature(), - subcommands: [micList, micSet]) - let serviceRoot = CommandDescriptor( - name: "service", - abstract: "launchd helper", - discussion: nil, - signature: CommandSignature(), - subcommands: [ - descriptor(for: ServiceInstall.self), - descriptor(for: ServiceUninstall.self), - descriptor(for: ServiceStatus.self) - ]) - let doctorDesc = descriptor(for: DoctorCommand.self) - let setupDesc = descriptor(for: SetupCommand.self) - let healthDesc = descriptor(for: HealthCommand.self) - let tailLogDesc = descriptor(for: TailLogCommand.self) - let startDesc = descriptor(for: StartCommand.self) - let stopDesc = descriptor(for: StopCommand.self) - let restartDesc = descriptor(for: RestartCommand.self) - let statusDesc = descriptor(for: StatusCommand.self) - - let rootSignature = CommandSignature().withStandardRuntimeFlags() - let root = CommandDescriptor( - name: "swabble", - abstract: "Speech hook daemon", - discussion: "Local wake-word → SpeechTranscriber → hook", - signature: rootSignature, - subcommands: [ - serveDesc, - transcribeDesc, - testHookDesc, - micRoot, - serviceRoot, - doctorDesc, - setupDesc, - healthDesc, - tailLogDesc, - startDesc, - stopDesc, - restartDesc, - statusDesc - ]) - return [root] - } - - private static func descriptor(for type: any ParsableCommand.Type) -> CommandDescriptor { - let sig = CommandSignature.describe(type.init()).withStandardRuntimeFlags() - return CommandDescriptor( - name: type.commandDescription.commandName ?? "", - abstract: type.commandDescription.abstract, - discussion: type.commandDescription.discussion, - signature: sig, - subcommands: []) - } -} diff --git a/Swabble/Sources/swabble/Commands/DoctorCommand.swift b/Swabble/Sources/swabble/Commands/DoctorCommand.swift deleted file mode 100644 index ec6c84ad44a..00000000000 --- a/Swabble/Sources/swabble/Commands/DoctorCommand.swift +++ /dev/null @@ -1,37 +0,0 @@ -import Commander -import Foundation -import Speech -import Swabble - -@MainActor -struct DoctorCommand: ParsableCommand { - static var commandDescription: CommandDescription { - CommandDescription(commandName: "doctor", abstract: "Check Speech permission and config") - } - - @Option(name: .long("config"), help: "Path to config JSON") var configPath: String? - - init() {} - init(parsed: ParsedValues) { - self.init() - if let cfg = parsed.options["config"]?.last { configPath = cfg } - } - - mutating func run() async throws { - let auth = await SFSpeechRecognizer.authorizationStatus() - print("Speech auth: \(auth)") - do { - _ = try ConfigLoader.load(at: configURL) - print("Config: OK") - } catch { - print("Config missing or invalid; run setup") - } - let session = AVCaptureDevice.DiscoverySession( - deviceTypes: [.microphone, .external], - mediaType: .audio, - position: .unspecified) - print("Mics found: \(session.devices.count)") - } - - private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } } -} diff --git a/Swabble/Sources/swabble/Commands/HealthCommand.swift b/Swabble/Sources/swabble/Commands/HealthCommand.swift deleted file mode 100644 index b3db452868d..00000000000 --- a/Swabble/Sources/swabble/Commands/HealthCommand.swift +++ /dev/null @@ -1,16 +0,0 @@ -import Commander -import Foundation - -@MainActor -struct HealthCommand: ParsableCommand { - static var commandDescription: CommandDescription { - CommandDescription(commandName: "health", abstract: "Health probe") - } - - init() {} - init(parsed: ParsedValues) {} - - mutating func run() async throws { - print("ok") - } -} diff --git a/Swabble/Sources/swabble/Commands/MicCommands.swift b/Swabble/Sources/swabble/Commands/MicCommands.swift deleted file mode 100644 index 6430c86d529..00000000000 --- a/Swabble/Sources/swabble/Commands/MicCommands.swift +++ /dev/null @@ -1,62 +0,0 @@ -import AVFoundation -import Commander -import Foundation -import Swabble - -@MainActor -struct MicCommand: ParsableCommand { - static var commandDescription: CommandDescription { - CommandDescription( - commandName: "mic", - abstract: "Microphone management", - subcommands: [MicList.self, MicSet.self]) - } -} - -@MainActor -struct MicList: ParsableCommand { - static var commandDescription: CommandDescription { - CommandDescription(commandName: "list", abstract: "List input devices") - } - - init() {} - init(parsed: ParsedValues) {} - - mutating func run() async throws { - let session = AVCaptureDevice.DiscoverySession( - deviceTypes: [.microphone, .external], - mediaType: .audio, - position: .unspecified) - let devices = session.devices - if devices.isEmpty { print("no audio inputs found"); return } - for (idx, device) in devices.enumerated() { - print("[\(idx)] \(device.localizedName)") - } - } -} - -@MainActor -struct MicSet: ParsableCommand { - @Argument(help: "Device index from list") var index: Int = 0 - @Option(name: .long("config"), help: "Path to config JSON") var configPath: String? - - static var commandDescription: CommandDescription { - CommandDescription(commandName: "set", abstract: "Set default input device index") - } - - init() {} - init(parsed: ParsedValues) { - self.init() - if let value = parsed.positional.first, let intVal = Int(value) { index = intVal } - if let cfg = parsed.options["config"]?.last { configPath = cfg } - } - - mutating func run() async throws { - var cfg = try ConfigLoader.load(at: configURL) - cfg.audio.deviceIndex = index - try ConfigLoader.save(cfg, at: configURL) - print("saved device index \(index)") - } - - private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } } -} diff --git a/Swabble/Sources/swabble/Commands/ServeCommand.swift b/Swabble/Sources/swabble/Commands/ServeCommand.swift deleted file mode 100644 index 705ecf41a65..00000000000 --- a/Swabble/Sources/swabble/Commands/ServeCommand.swift +++ /dev/null @@ -1,81 +0,0 @@ -import Commander -import Foundation -import Swabble -import SwabbleKit - -@available(macOS 26.0, *) -@MainActor -struct ServeCommand: ParsableCommand { - @Option(name: .long("config"), help: "Path to config JSON") var configPath: String? - @Flag(name: .long("no-wake"), help: "Disable wake word") var noWake: Bool = false - - static var commandDescription: CommandDescription { - CommandDescription( - commandName: "serve", - abstract: "Run swabble in the foreground") - } - - init() {} - - init(parsed: ParsedValues) { - self.init() - if parsed.flags.contains("noWake") { noWake = true } - if let cfg = parsed.options["config"]?.last { configPath = cfg } - } - - mutating func run() async throws { - var cfg: SwabbleConfig - do { - cfg = try ConfigLoader.load(at: configURL) - } catch { - cfg = SwabbleConfig() - try ConfigLoader.save(cfg, at: configURL) - } - if noWake { - cfg.wake.enabled = false - } - - let logger = Logger(level: LogLevel(configValue: cfg.logging.level) ?? .info) - logger.info("swabble serve starting (wake: \(cfg.wake.enabled ? cfg.wake.word : "disabled"))") - let pipeline = SpeechPipeline() - do { - let stream = try await pipeline.start( - localeIdentifier: cfg.speech.localeIdentifier, - etiquette: cfg.speech.etiquetteReplacements) - for await seg in stream { - if cfg.wake.enabled { - guard Self.matchesWake(text: seg.text, cfg: cfg) else { continue } - } - let stripped = Self.stripWake(text: seg.text, cfg: cfg) - let job = HookJob(text: stripped, timestamp: Date()) - let executor = HookExecutor(config: cfg) - try await executor.run(job: job) - if cfg.transcripts.enabled { - await TranscriptsStore.shared.append(text: stripped) - } - if seg.isFinal { - logger.info("final: \(stripped)") - } else { - logger.debug("partial: \(stripped)") - } - } - } catch { - logger.error("serve error: \(error)") - throw error - } - } - - private var configURL: URL? { - configPath.map { URL(fileURLWithPath: $0) } - } - - private static func matchesWake(text: String, cfg: SwabbleConfig) -> Bool { - let triggers = [cfg.wake.word] + cfg.wake.aliases - return WakeWordGate.matchesTextOnly(text: text, triggers: triggers) - } - - private static func stripWake(text: String, cfg: SwabbleConfig) -> String { - let triggers = [cfg.wake.word] + cfg.wake.aliases - return WakeWordGate.stripWake(text: text, triggers: triggers) - } -} diff --git a/Swabble/Sources/swabble/Commands/ServiceCommands.swift b/Swabble/Sources/swabble/Commands/ServiceCommands.swift deleted file mode 100644 index 8690e95628d..00000000000 --- a/Swabble/Sources/swabble/Commands/ServiceCommands.swift +++ /dev/null @@ -1,77 +0,0 @@ -import Commander -import Foundation - -@MainActor -struct ServiceRootCommand: ParsableCommand { - static var commandDescription: CommandDescription { - CommandDescription( - commandName: "service", - abstract: "Manage launchd agent", - subcommands: [ServiceInstall.self, ServiceUninstall.self, ServiceStatus.self]) - } -} - -private enum LaunchdHelper { - static let label = "com.swabble.agent" - - static var plistURL: URL { - FileManager.default - .homeDirectoryForCurrentUser - .appendingPathComponent("Library/LaunchAgents/\(label).plist") - } - - static func writePlist(executable: String) throws { - let plist: [String: Any] = [ - "Label": label, - "ProgramArguments": [executable, "serve"], - "RunAtLoad": true, - "KeepAlive": true - ] - let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) - try data.write(to: plistURL) - } - - static func removePlist() throws { - try? FileManager.default.removeItem(at: plistURL) - } -} - -@MainActor -struct ServiceInstall: ParsableCommand { - static var commandDescription: CommandDescription { - CommandDescription(commandName: "install", abstract: "Install user launch agent") - } - - mutating func run() async throws { - let exe = CommandLine.arguments.first ?? "/usr/local/bin/swabble" - try LaunchdHelper.writePlist(executable: exe) - print("launchctl load -w \(LaunchdHelper.plistURL.path)") - } -} - -@MainActor -struct ServiceUninstall: ParsableCommand { - static var commandDescription: CommandDescription { - CommandDescription(commandName: "uninstall", abstract: "Remove launch agent") - } - - mutating func run() async throws { - try LaunchdHelper.removePlist() - print("launchctl bootout gui/$(id -u)/\(LaunchdHelper.label)") - } -} - -@MainActor -struct ServiceStatus: ParsableCommand { - static var commandDescription: CommandDescription { - CommandDescription(commandName: "status", abstract: "Show launch agent status") - } - - mutating func run() async throws { - if FileManager.default.fileExists(atPath: LaunchdHelper.plistURL.path) { - print("plist present at \(LaunchdHelper.plistURL.path)") - } else { - print("launchd plist not installed") - } - } -} diff --git a/Swabble/Sources/swabble/Commands/SetupCommand.swift b/Swabble/Sources/swabble/Commands/SetupCommand.swift deleted file mode 100644 index 469de233d11..00000000000 --- a/Swabble/Sources/swabble/Commands/SetupCommand.swift +++ /dev/null @@ -1,26 +0,0 @@ -import Commander -import Foundation -import Swabble - -@MainActor -struct SetupCommand: ParsableCommand { - static var commandDescription: CommandDescription { - CommandDescription(commandName: "setup", abstract: "Write default config") - } - - @Option(name: .long("config"), help: "Path to config JSON") var configPath: String? - - init() {} - init(parsed: ParsedValues) { - self.init() - if let cfg = parsed.options["config"]?.last { configPath = cfg } - } - - mutating func run() async throws { - let cfg = SwabbleConfig() - try ConfigLoader.save(cfg, at: configURL) - print("wrote config to \(configURL?.path ?? SwabbleConfig.defaultPath.path)") - } - - private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } } -} diff --git a/Swabble/Sources/swabble/Commands/StartStopCommands.swift b/Swabble/Sources/swabble/Commands/StartStopCommands.swift deleted file mode 100644 index 641cd923a0d..00000000000 --- a/Swabble/Sources/swabble/Commands/StartStopCommands.swift +++ /dev/null @@ -1,35 +0,0 @@ -import Commander -import Foundation - -@MainActor -struct StartCommand: ParsableCommand { - static var commandDescription: CommandDescription { - CommandDescription(commandName: "start", abstract: "Start swabble (foreground placeholder)") - } - - mutating func run() async throws { - print("start: launchd helper not implemented; run 'swabble serve' instead") - } -} - -@MainActor -struct StopCommand: ParsableCommand { - static var commandDescription: CommandDescription { - CommandDescription(commandName: "stop", abstract: "Stop swabble (placeholder)") - } - - mutating func run() async throws { - print("stop: launchd helper not implemented yet") - } -} - -@MainActor -struct RestartCommand: ParsableCommand { - static var commandDescription: CommandDescription { - CommandDescription(commandName: "restart", abstract: "Restart swabble (placeholder)") - } - - mutating func run() async throws { - print("restart: launchd helper not implemented yet") - } -} diff --git a/Swabble/Sources/swabble/Commands/StatusCommand.swift b/Swabble/Sources/swabble/Commands/StatusCommand.swift deleted file mode 100644 index 19db16117ab..00000000000 --- a/Swabble/Sources/swabble/Commands/StatusCommand.swift +++ /dev/null @@ -1,34 +0,0 @@ -import Commander -import Foundation -import Swabble - -@MainActor -struct StatusCommand: ParsableCommand { - static var commandDescription: CommandDescription { - CommandDescription(commandName: "status", abstract: "Show daemon state") - } - - @Option(name: .long("config"), help: "Path to config JSON") var configPath: String? - - init() {} - init(parsed: ParsedValues) { - self.init() - if let cfg = parsed.options["config"]?.last { configPath = cfg } - } - - mutating func run() async throws { - let cfg = try? ConfigLoader.load(at: configURL) - let wake = cfg?.wake.word ?? "clawd" - let wakeEnabled = cfg?.wake.enabled ?? false - let latest = await TranscriptsStore.shared.latest().suffix(3) - print("wake: \(wakeEnabled ? wake : "disabled")") - if latest.isEmpty { - print("transcripts: (none yet)") - } else { - print("last transcripts:") - latest.forEach { print("- \($0)") } - } - } - - private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } } -} diff --git a/Swabble/Sources/swabble/Commands/TailLogCommand.swift b/Swabble/Sources/swabble/Commands/TailLogCommand.swift deleted file mode 100644 index 451ed37de41..00000000000 --- a/Swabble/Sources/swabble/Commands/TailLogCommand.swift +++ /dev/null @@ -1,20 +0,0 @@ -import Commander -import Foundation -import Swabble - -@MainActor -struct TailLogCommand: ParsableCommand { - static var commandDescription: CommandDescription { - CommandDescription(commandName: "tail-log", abstract: "Tail recent transcripts") - } - - init() {} - init(parsed: ParsedValues) {} - - mutating func run() async throws { - let latest = await TranscriptsStore.shared.latest() - for line in latest.suffix(10) { - print(line) - } - } -} diff --git a/Swabble/Sources/swabble/Commands/TestHookCommand.swift b/Swabble/Sources/swabble/Commands/TestHookCommand.swift deleted file mode 100644 index 226776ceb89..00000000000 --- a/Swabble/Sources/swabble/Commands/TestHookCommand.swift +++ /dev/null @@ -1,30 +0,0 @@ -import Commander -import Foundation -import Swabble - -@MainActor -struct TestHookCommand: ParsableCommand { - @Argument(help: "Text to send to hook") var text: String - @Option(name: .long("config"), help: "Path to config JSON") var configPath: String? - - static var commandDescription: CommandDescription { - CommandDescription(commandName: "test-hook", abstract: "Invoke the configured hook with text") - } - - init() {} - - init(parsed: ParsedValues) { - self.init() - if let positional = parsed.positional.first { text = positional } - if let cfg = parsed.options["config"]?.last { configPath = cfg } - } - - mutating func run() async throws { - let cfg = try ConfigLoader.load(at: configURL) - let executor = HookExecutor(config: cfg) - try await executor.run(job: HookJob(text: text, timestamp: Date())) - print("hook invoked") - } - - private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } } -} diff --git a/Swabble/Sources/swabble/Commands/TranscribeCommand.swift b/Swabble/Sources/swabble/Commands/TranscribeCommand.swift deleted file mode 100644 index 1bedca3fc0a..00000000000 --- a/Swabble/Sources/swabble/Commands/TranscribeCommand.swift +++ /dev/null @@ -1,61 +0,0 @@ -import AVFoundation -import Commander -import Foundation -import Speech -import Swabble - -@MainActor -struct TranscribeCommand: ParsableCommand { - @Argument(help: "Path to audio/video file") var inputFile: String = "" - @Option(name: .long("locale"), help: "Locale identifier", parsing: .singleValue) var locale: String = Locale.current - .identifier - @Flag(help: "Censor etiquette-sensitive content") var censor: Bool = false - @Option(name: .long("output"), help: "Output file path") var outputFile: String? - @Option(name: .long("format"), help: "Output format txt|srt") var format: String = "txt" - @Option(name: .long("max-length"), help: "Max sentence length for srt") var maxLength: Int = 40 - - static var commandDescription: CommandDescription { - CommandDescription( - commandName: "transcribe", - abstract: "Transcribe a media file locally") - } - - init() {} - - init(parsed: ParsedValues) { - self.init() - if let positional = parsed.positional.first { inputFile = positional } - if let loc = parsed.options["locale"]?.last { locale = loc } - if parsed.flags.contains("censor") { censor = true } - if let out = parsed.options["output"]?.last { outputFile = out } - if let fmt = parsed.options["format"]?.last { format = fmt } - if let len = parsed.options["maxLength"]?.last, let intVal = Int(len) { maxLength = intVal } - } - - mutating func run() async throws { - let fileURL = URL(fileURLWithPath: inputFile) - let audioFile = try AVAudioFile(forReading: fileURL) - - let outputFormat = OutputFormat(rawValue: format) ?? .txt - - let transcriber = SpeechTranscriber( - locale: Locale(identifier: locale), - transcriptionOptions: censor ? [.etiquetteReplacements] : [], - reportingOptions: [], - attributeOptions: outputFormat.needsAudioTimeRange ? [.audioTimeRange] : []) - let analyzer = SpeechAnalyzer(modules: [transcriber]) - try await analyzer.start(inputAudioFile: audioFile, finishAfterFile: true) - - var transcript: AttributedString = "" - for try await result in transcriber.results { - transcript += result.text - } - - let output = outputFormat.text(for: transcript, maxLength: maxLength) - if let path = outputFile { - try output.write(to: URL(fileURLWithPath: path), atomically: false, encoding: .utf8) - } else { - print(output) - } - } -} diff --git a/Swabble/Sources/swabble/main.swift b/Swabble/Sources/swabble/main.swift deleted file mode 100644 index a534c68d969..00000000000 --- a/Swabble/Sources/swabble/main.swift +++ /dev/null @@ -1,151 +0,0 @@ -import Commander -import Foundation - -@available(macOS 26.0, *) -@MainActor -private func runCLI() async -> Int32 { - do { - let descriptors = CLIRegistry.descriptors - let program = Program(descriptors: descriptors) - let invocation = try program.resolve(argv: CommandLine.arguments) - try await dispatch(invocation: invocation) - return 0 - } catch { - fputs("error: \(error)\n", stderr) - return 1 - } -} - -@available(macOS 26.0, *) -@MainActor -private func dispatch(invocation: CommandInvocation) async throws { - let parsed = invocation.parsedValues - let path = invocation.path - guard let first = path.first else { throw CommanderProgramError.missingCommand } - - switch first { - case "swabble": - try await dispatchSwabble(parsed: parsed, path: path) - default: - throw CommanderProgramError.unknownCommand(first) - } -} - -@available(macOS 26.0, *) -@MainActor -private func dispatchSwabble(parsed: ParsedValues, path: [String]) async throws { - let sub = try subcommand(path, index: 1, command: "swabble") - switch sub { - case "mic": - try await dispatchMic(parsed: parsed, path: path) - case "service": - try await dispatchService(path: path) - default: - let handlers = swabbleHandlers(parsed: parsed) - guard let handler = handlers[sub] else { - throw CommanderProgramError.unknownSubcommand(command: "swabble", name: sub) - } - try await handler() - } -} - -@available(macOS 26.0, *) -@MainActor -private func swabbleHandlers(parsed: ParsedValues) -> [String: () async throws -> Void] { - [ - "serve": { - var cmd = ServeCommand(parsed: parsed) - try await cmd.run() - }, - "transcribe": { - var cmd = TranscribeCommand(parsed: parsed) - try await cmd.run() - }, - "test-hook": { - var cmd = TestHookCommand(parsed: parsed) - try await cmd.run() - }, - "doctor": { - var cmd = DoctorCommand(parsed: parsed) - try await cmd.run() - }, - "setup": { - var cmd = SetupCommand(parsed: parsed) - try await cmd.run() - }, - "health": { - var cmd = HealthCommand(parsed: parsed) - try await cmd.run() - }, - "tail-log": { - var cmd = TailLogCommand(parsed: parsed) - try await cmd.run() - }, - "start": { - var cmd = StartCommand() - try await cmd.run() - }, - "stop": { - var cmd = StopCommand() - try await cmd.run() - }, - "restart": { - var cmd = RestartCommand() - try await cmd.run() - }, - "status": { - var cmd = StatusCommand() - try await cmd.run() - } - ] -} - -@available(macOS 26.0, *) -@MainActor -private func dispatchMic(parsed: ParsedValues, path: [String]) async throws { - let micSub = try subcommand(path, index: 2, command: "mic") - switch micSub { - case "list": - var cmd = MicList(parsed: parsed) - try await cmd.run() - case "set": - var cmd = MicSet(parsed: parsed) - try await cmd.run() - default: - throw CommanderProgramError.unknownSubcommand(command: "mic", name: micSub) - } -} - -@available(macOS 26.0, *) -@MainActor -private func dispatchService(path: [String]) async throws { - let svcSub = try subcommand(path, index: 2, command: "service") - switch svcSub { - case "install": - var cmd = ServiceInstall() - try await cmd.run() - case "uninstall": - var cmd = ServiceUninstall() - try await cmd.run() - case "status": - var cmd = ServiceStatus() - try await cmd.run() - default: - throw CommanderProgramError.unknownSubcommand(command: "service", name: svcSub) - } -} - -private func subcommand(_ path: [String], index: Int, command: String) throws -> String { - guard path.count > index else { - throw CommanderProgramError.missingSubcommand(command: command) - } - return path[index] -} - -if #available(macOS 26.0, *) { - let exitCode = await runCLI() - exit(exitCode) -} else { - fputs("error: swabble requires macOS 26 or newer\n", stderr) - exit(1) -} diff --git a/Swabble/Tests/SwabbleKitTests/WakeWordGateTests.swift b/Swabble/Tests/SwabbleKitTests/WakeWordGateTests.swift deleted file mode 100644 index 5cc283c35ae..00000000000 --- a/Swabble/Tests/SwabbleKitTests/WakeWordGateTests.swift +++ /dev/null @@ -1,63 +0,0 @@ -import Foundation -import SwabbleKit -import Testing - -@Suite struct WakeWordGateTests { - @Test func matchRequiresGapAfterTrigger() { - let transcript = "hey clawd do thing" - let segments = makeSegments( - transcript: transcript, - words: [ - ("hey", 0.0, 0.1), - ("clawd", 0.2, 0.1), - ("do", 0.35, 0.1), - ("thing", 0.5, 0.1), - ]) - let config = WakeWordGateConfig(triggers: ["clawd"], minPostTriggerGap: 0.3) - #expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config) == nil) - } - - @Test func matchAllowsGapAndExtractsCommand() { - let transcript = "hey clawd do thing" - let segments = makeSegments( - transcript: transcript, - words: [ - ("hey", 0.0, 0.1), - ("clawd", 0.2, 0.1), - ("do", 0.9, 0.1), - ("thing", 1.1, 0.1), - ]) - let config = WakeWordGateConfig(triggers: ["clawd"], minPostTriggerGap: 0.3) - let match = WakeWordGate.match(transcript: transcript, segments: segments, config: config) - #expect(match?.command == "do thing") - } - - @Test func matchHandlesMultiWordTriggers() { - let transcript = "hey clawd do it" - let segments = makeSegments( - transcript: transcript, - words: [ - ("hey", 0.0, 0.1), - ("clawd", 0.2, 0.1), - ("do", 0.8, 0.1), - ("it", 1.0, 0.1), - ]) - let config = WakeWordGateConfig(triggers: ["hey clawd"], minPostTriggerGap: 0.3) - let match = WakeWordGate.match(transcript: transcript, segments: segments, config: config) - #expect(match?.command == "do it") - } -} - -private func makeSegments( - transcript: String, - words: [(String, TimeInterval, TimeInterval)]) --> [WakeWordSegment] { - var searchStart = transcript.startIndex - var output: [WakeWordSegment] = [] - for (word, start, duration) in words { - let range = transcript.range(of: word, range: searchStart../dev/null; then - echo "swiftlint not installed" >&2 - exit 1 -fi -swiftlint --config "$CONFIG" diff --git a/VISION.md b/VISION.md deleted file mode 100644 index 4ff70189ab8..00000000000 --- a/VISION.md +++ /dev/null @@ -1,110 +0,0 @@ -## OpenClaw Vision - -OpenClaw is the AI that actually does things. -It runs on your devices, in your channels, with your rules. - -This document explains the current state and direction of the project. -We are still early, so iteration is fast. -Project overview and developer docs: [`README.md`](README.md) -Contribution guide: [`CONTRIBUTING.md`](CONTRIBUTING.md) - -OpenClaw started as a personal playground to learn AI and build something genuinely useful: -an assistant that can run real tasks on a real computer. -It evolved through several names and shells: Warelay -> Clawdbot -> Moltbot -> OpenClaw. - -The goal: a personal assistant that is easy to use, supports a wide range of platforms, and respects privacy and security. - -The current focus is: - -Priority: - -- Security and safe defaults -- Bug fixes and stability -- Setup reliability and first-run UX - -Next priorities: - -- Supporting all major model providers -- Improving support for major messaging channels (and adding a few high-demand ones) -- Performance and test infrastructure -- Better computer-use and agent harness capabilities -- Ergonomics across CLI and web frontend -- Companion apps on macOS, iOS, Android, Windows, and Linux - -Contribution rules: - -- One PR = one issue/topic. Do not bundle multiple unrelated fixes/features. -- PRs over ~5,000 changed lines are reviewed only in exceptional circumstances. -- Do not open large batches of tiny PRs at once; each PR has review cost. -- For very small related fixes, grouping into one focused PR is encouraged. - -## Security - -Security in OpenClaw is a deliberate tradeoff: strong defaults without killing capability. -The goal is to stay powerful for real work while making risky paths explicit and operator-controlled. - -Canonical security policy and reporting: - -- [`SECURITY.md`](SECURITY.md) - -We prioritize secure defaults, but also expose clear knobs for trusted high-power workflows. - -## Plugins & Memory - -OpenClaw has an extensive plugin API. -Core stays lean; optional capability should usually ship as plugins. - -Preferred plugin path is npm package distribution plus local extension loading for development. -If you build a plugin, host and maintain it in your own repository. -The bar for adding optional plugins to core is intentionally high. -Plugin docs: [`docs/tools/plugin.md`](docs/tools/plugin.md) -Community plugin listing + PR bar: https://docs.openclaw.ai/plugins/community - -Memory is a special plugin slot where only one memory plugin can be active at a time. -Today we ship multiple memory options; over time we plan to converge on one recommended default path. - -### Skills - -We still ship some bundled skills for baseline UX. -New skills should be published to ClawHub first (`clawhub.ai`), not added to core by default. -Core skill additions should be rare and require a strong product or security reason. - -### MCP Support - -OpenClaw supports MCP through `mcporter`: https://github.com/steipete/mcporter - -This keeps MCP integration flexible and decoupled from core runtime: - -- add or change MCP servers without restarting the gateway -- keep core tool/context surface lean -- reduce MCP churn impact on core stability and security - -For now, we prefer this bridge model over building first-class MCP runtime into core. -If there is an MCP server or feature `mcporter` does not support yet, please open an issue there. - -### Setup - -OpenClaw is currently terminal-first by design. -This keeps setup explicit: users see docs, auth, permissions, and security posture up front. - -Long term, we want easier onboarding flows as hardening matures. -We do not want convenience wrappers that hide critical security decisions from users. - -### Why TypeScript? - -OpenClaw is primarily an orchestration system: prompts, tools, protocols, and integrations. -TypeScript was chosen to keep OpenClaw hackable by default. -It is widely known, fast to iterate in, and easy to read, modify, and extend. - -## What We Will Not Merge (For Now) - -- New core skills when they can live on ClawHub -- Full-doc translation sets for all docs (deferred; we plan AI-generated translations later) -- Commercial service integrations that do not clearly fit the model-provider category -- Wrapper channels around already supported channels without a clear capability or security gap -- First-class MCP runtime in core when `mcporter` already provides the integration path -- Agent-hierarchy frameworks (manager-of-managers / nested planner trees) as a default architecture -- Heavy orchestration layers that duplicate existing agent and tool infrastructure - -This list is a roadmap guardrail, not a law of physics. -Strong user demand and strong technical rationale can change it. diff --git a/appcast.xml b/appcast.xml deleted file mode 100644 index ac9369da007..00000000000 --- a/appcast.xml +++ /dev/null @@ -1,363 +0,0 @@ - - - - OpenClaw - - 2026.2.14 - Sun, 15 Feb 2026 04:24:34 +0100 - https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml - 202602140 - 2026.2.14 - 15.0 - OpenClaw 2026.2.14 -

Changes

-
    -
  • Telegram: add poll sending via openclaw message poll (duration seconds, silent delivery, anonymity controls). (#16209) Thanks @robbyczgw-cla.
  • -
  • Slack/Discord: add dmPolicy + allowFrom config aliases for DM access control; legacy dm.policy + dm.allowFrom keys remain supported and openclaw doctor --fix can migrate them.
  • -
  • Discord: allow exec approval prompts to target channels or both DM+channel via channels.discord.execApprovals.target. (#16051) Thanks @leonnardo.
  • -
  • Sandbox: add sandbox.browser.binds to configure browser-container bind mounts separately from exec containers. (#16230) Thanks @seheepeak.
  • -
  • Discord: add debug logging for message routing decisions to improve --debug tracing. (#16202) Thanks @jayleekr.
  • -
-

Fixes

-
    -
  • CLI/Plugins: ensure openclaw message send exits after successful delivery across plugin-backed channels so one-shot sends do not hang. (#16491) Thanks @yinghaosang.
  • -
  • CLI/Plugins: run registered plugin gateway_stop hooks before openclaw message exits (success and failure paths), so plugin-backed channels can clean up one-shot CLI resources. (#16580) Thanks @gumadeiras.
  • -
  • WhatsApp: honor per-account dmPolicy overrides (account-level settings now take precedence over channel defaults for inbound DMs). (#10082) Thanks @mcaxtr.
  • -
  • Telegram: when channels.telegram.commands.native is false, exclude plugin commands from setMyCommands menu registration while keeping plugin slash handlers callable. (#15132) Thanks @Glucksberg.
  • -
  • LINE: return 200 OK for Developers Console "Verify" requests ({"events":[]}) without X-Line-Signature, while still requiring signatures for real deliveries. (#16582) Thanks @arosstale.
  • -
  • Cron: deliver text-only output directly when delivery.to is set so cron recipients get full output instead of summaries. (#16360) Thanks @thewilloftheshadow.
  • -
  • Cron/Slack: preserve agent identity (name and icon) when cron jobs deliver outbound messages. (#16242) Thanks @robbyczgw-cla.
  • -
  • Media: accept MEDIA:-prefixed paths (lenient whitespace) when loading outbound media to prevent ENOENT for tool-returned local media paths. (#13107) Thanks @mcaxtr.
  • -
  • Agents: deliver tool result media (screenshots, images, audio) to channels regardless of verbose level. (#11735) Thanks @strelov1.
  • -
  • Agents/Image tool: allow workspace-local image paths by including the active workspace directory in local media allowlists, and trust sandbox-validated paths in image loaders to prevent false "not under an allowed directory" rejections. (#15541)
  • -
  • Agents/Image tool: propagate the effective workspace root into tool wiring so workspace-local image paths are accepted by default when running without an explicit workspaceDir. (#16722)
  • -
  • BlueBubbles: include sender identity in group chat envelopes and pass clean message text to the agent prompt, aligning with iMessage/Signal formatting. (#16210) Thanks @zerone0x.
  • -
  • CLI: fix lazy core command registration so top-level maintenance commands (doctor, dashboard, reset, uninstall) resolve correctly instead of exposing a non-functional maintenance placeholder command.
  • -
  • CLI/Dashboard: when gateway.bind=lan, generate localhost dashboard URLs to satisfy browser secure-context requirements while preserving non-LAN bind behavior. (#16434) Thanks @BinHPdev.
  • -
  • TUI/Gateway: resolve local gateway target URL from gateway.bind mode (tailnet/lan) instead of hardcoded localhost so openclaw tui connects when gateway is non-loopback. (#16299) Thanks @cortexuvula.
  • -
  • TUI: honor explicit --session in openclaw tui even when session.scope is global, so named sessions no longer collapse into shared global history. (#16575) Thanks @cinqu.
  • -
  • TUI: use available terminal width for session name display in searchable select lists. (#16238) Thanks @robbyczgw-cla.
  • -
  • TUI: refactor searchable select list description layout and add regression coverage for ANSI-highlight width bounds.
  • -
  • TUI: preserve in-flight streaming replies when a different run finalizes concurrently (avoid clearing active run or reloading history mid-stream). (#10704) Thanks @axschr73.
  • -
  • TUI: keep pre-tool streamed text visible when later tool-boundary deltas temporarily omit earlier text blocks. (#6958) Thanks @KrisKind75.
  • -
  • TUI: sanitize ANSI/control-heavy history text, redact binary-like lines, and split pathological long unbroken tokens before rendering to prevent startup crashes on binary attachment history. (#13007) Thanks @wilkinspoe.
  • -
  • TUI: harden render-time sanitizer for narrow terminals by chunking moderately long unbroken tokens and adding fast-path sanitization guards to reduce overhead on normal text. (#5355) Thanks @tingxueren.
  • -
  • TUI: render assistant body text in terminal default foreground (instead of fixed light ANSI color) so contrast remains readable on light themes such as Solarized Light. (#16750) Thanks @paymog.
  • -
  • TUI/Hooks: pass explicit reset reason (new vs reset) through sessions.reset and emit internal command hooks for gateway-triggered resets so /new hook workflows fire in TUI/webchat.
  • -
  • Cron: prevent cron list/cron status from silently skipping past-due recurring jobs by using maintenance recompute semantics. (#16156) Thanks @zerone0x.
  • -
  • Cron: repair missing/corrupt nextRunAtMs for the updated job without globally recomputing unrelated due jobs during cron update. (#15750)
  • -
  • Cron: skip missed-job replay on startup for jobs interrupted mid-run (stale runningAtMs markers), preventing restart loops for self-restarting jobs such as update tasks. (#16694) Thanks @sbmilburn.
  • -
  • Discord: prefer gateway guild id when logging inbound messages so cached-miss guilds do not appear as guild=dm. Thanks @thewilloftheshadow.
  • -
  • Discord: treat empty per-guild channels: {} config maps as no channel allowlist (not deny-all), so groupPolicy: "open" guilds without explicit channel entries continue to receive messages. (#16714) Thanks @xqliu.
  • -
  • Models/CLI: guard models status string trimming paths to prevent crashes from malformed non-string config values. (#16395) Thanks @BinHPdev.
  • -
  • Gateway/Subagents: preserve queued announce items and summary state on delivery errors, retry failed announce drains, and avoid dropping unsent announcements on timeout/failure. (#16729) Thanks @Clawdette-Workspace.
  • -
  • Gateway/Sessions: abort active embedded runs and clear queued session work before sessions.reset, returning unavailable if the run does not stop in time. (#16576) Thanks @Grynn.
  • -
  • Sessions/Agents: harden transcript path resolution for mismatched agent context by preserving explicit store roots and adding safe absolute-path fallback to the correct agent sessions directory. (#16288) Thanks @robbyczgw-cla.
  • -
  • Agents: add a safety timeout around embedded session.compact() to ensure stalled compaction runs settle and release blocked session lanes. (#16331) Thanks @BinHPdev.
  • -
  • Agents: keep unresolved mutating tool failures visible until the same action retry succeeds, scope mutation-error surfacing to mutating calls (including session_status model changes), and dedupe duplicate failure warnings in outbound replies. (#16131) Thanks @Swader.
  • -
  • Agents/Process/Bootstrap: preserve unbounded process log offset-only pagination (default tail applies only when both offset and limit are omitted) and enforce strict bootstrapTotalMaxChars budgeting across injected bootstrap content (including markers), skipping additional injection when remaining budget is too small. (#16539) Thanks @CharlieGreenman.
  • -
  • Agents/Workspace: persist bootstrap onboarding state so partially initialized workspaces recover missing BOOTSTRAP.md once, while completed onboarding keeps BOOTSTRAP deleted even if runtime files are later recreated. Thanks @gumadeiras.
  • -
  • Agents/Workspace: create BOOTSTRAP.md when core workspace files are seeded in partially initialized workspaces, while keeping BOOTSTRAP one-shot after onboarding deletion. (#16457) Thanks @robbyczgw-cla.
  • -
  • Agents: classify external timeout aborts during compaction the same as internal timeouts, preventing unnecessary auth-profile rotation and preserving compaction-timeout snapshot fallback behavior. (#9855) Thanks @mverrilli.
  • -
  • Agents: treat empty-stream provider failures (request ended without sending any chunks) as timeout-class failover signals, enabling auth-profile rotation/fallback and showing a friendly timeout message instead of raw provider errors. (#10210) Thanks @zenchantlive.
  • -
  • Agents: treat read tool file_path arguments as valid in tool-start diagnostics to avoid false “read tool called without path” warnings when alias parameters are used. (#16717) Thanks @Stache73.
  • -
  • Ollama/Agents: avoid forcing tag enforcement for Ollama models, which could suppress all output as (no output). (#16191) Thanks @Glucksberg.
  • -
  • Plugins: suppress false duplicate plugin id warnings when the same extension is discovered via multiple paths (config/workspace/global vs bundled), while still warning on genuine duplicates. (#16222) Thanks @shadril238.
  • -
  • Skills: watch SKILL.md only when refreshing skills snapshot to avoid file-descriptor exhaustion in large data trees. (#11325) Thanks @household-bard.
  • -
  • Memory/QMD: make memory status read-only by skipping QMD boot update/embed side effects for status-only manager checks.
  • -
  • Memory/QMD: keep original QMD failures when builtin fallback initialization fails (for example missing embedding API keys), instead of replacing them with fallback init errors.
  • -
  • Memory/Builtin: keep memory status dirty reporting stable across invocations by deriving status-only manager dirty state from persisted index metadata instead of process-start defaults. (#10863) Thanks @BarryYangi.
  • -
  • Memory/QMD: cap QMD command output buffering to prevent memory exhaustion from pathological qmd command output.
  • -
  • Memory/QMD: parse qmd scope keys once per request to avoid repeated parsing in scope checks.
  • -
  • Memory/QMD: query QMD index using exact docid matches before falling back to prefix lookup for better recall correctness and index efficiency.
  • -
  • Memory/QMD: pass result limits to search/vsearch commands so QMD can cap results earlier.
  • -
  • Memory/QMD: avoid reading full markdown files when a from/lines window is requested in QMD reads.
  • -
  • Memory/QMD: skip rewriting unchanged session export markdown files during sync to reduce disk churn.
  • -
  • Memory/QMD: make QMD result JSON parsing resilient to noisy command output by extracting the first JSON array from noisy stdout.
  • -
  • Memory/QMD: treat prefixed no results found marker output as an empty result set in qmd JSON parsing. (#11302) Thanks @blazerui.
  • -
  • Memory/QMD: avoid multi-collection query ranking corruption by running one qmd query -c per managed collection and merging by best score (also used for search/vsearch fallback-to-query). (#16740) Thanks @volarian-vai.
  • -
  • Memory/QMD: detect null-byte ENOTDIR update failures, rebuild managed collections once, and retry update to self-heal corrupted collection metadata. (#12919) Thanks @jorgejhms.
  • -
  • Memory/QMD/Security: add rawKeyPrefix support for QMD scope rules and preserve legacy keyPrefix: "agent:..." matching, preventing scoped deny bypass when operators match agent-prefixed session keys.
  • -
  • Memory/Builtin: narrow memory watcher targets to markdown globs and ignore dependency/venv directories to reduce file-descriptor pressure during memory sync startup. (#11721) Thanks @rex05ai.
  • -
  • Security/Memory-LanceDB: treat recalled memories as untrusted context (escape injected memory text + explicit non-instruction framing), skip likely prompt-injection payloads during auto-capture, and restrict auto-capture to user messages to reduce memory-poisoning risk. (#12524) Thanks @davidschmid24.
  • -
  • Security/Memory-LanceDB: require explicit autoCapture: true opt-in (default is now disabled) to prevent automatic PII capture unless operators intentionally enable it. (#12552) Thanks @fr33d3m0n.
  • -
  • Diagnostics/Memory: prune stale diagnostic session state entries and cap tracked session states to prevent unbounded in-memory growth on long-running gateways. (#5136) Thanks @coygeek and @vignesh07.
  • -
  • Gateway/Memory: clean up agentRunSeq tracking on run completion/abort and enforce maintenance-time cap pruning to prevent unbounded sequence-map growth over long uptimes. (#6036) Thanks @coygeek and @vignesh07.
  • -
  • Auto-reply/Memory: bound ABORT_MEMORY growth by evicting oldest entries and deleting reset (false) flags so abort state tracking cannot grow unbounded over long uptimes. (#6629) Thanks @coygeek and @vignesh07.
  • -
  • Slack/Memory: bound thread-starter cache growth with TTL + max-size pruning to prevent long-running Slack gateways from accumulating unbounded thread cache state. (#5258) Thanks @coygeek and @vignesh07.
  • -
  • Outbound/Memory: bound directory cache growth with max-size eviction and proactive TTL pruning to prevent long-running gateways from accumulating unbounded directory entries. (#5140) Thanks @coygeek and @vignesh07.
  • -
  • Skills/Memory: remove disconnected nodes from remote-skills cache to prevent stale node metadata from accumulating over long uptimes. (#6760) Thanks @coygeek.
  • -
  • Sandbox/Tools: make sandbox file tools bind-mount aware (including absolute container paths) and enforce read-only bind semantics for writes. (#16379) Thanks @tasaankaeris.
  • -
  • Media/Security: allow local media reads from OpenClaw state workspace/ and sandboxes/ roots by default so generated workspace media can be delivered without unsafe global path bypasses. (#15541) Thanks @lanceji.
  • -
  • Media/Security: harden local media allowlist bypasses by requiring an explicit readFile override when callers mark paths as validated, and reject filesystem-root localRoots entries. (#16739)
  • -
  • Discord/Security: harden voice message media loading (SSRF + allowed-local-root checks) so tool-supplied paths/URLs cannot be used to probe internal URLs or read arbitrary local files.
  • -
  • Security/BlueBubbles: require explicit mediaLocalRoots allowlists for local outbound media path reads to prevent local file disclosure. (#16322) Thanks @mbelinky.
  • -
  • Security/BlueBubbles: reject ambiguous shared-path webhook routing when multiple webhook targets match the same guid/password.
  • -
  • Security/BlueBubbles: harden BlueBubbles webhook auth behind reverse proxies by only accepting passwordless webhooks for direct localhost loopback requests (forwarded/proxied requests now require a password). Thanks @simecek.
  • -
  • Feishu/Security: harden media URL fetching against SSRF and local file disclosure. (#16285) Thanks @mbelinky.
  • -
  • Security/Zalo: reject ambiguous shared-path webhook routing when multiple webhook targets match the same secret.
  • -
  • Security/Nostr: require loopback source and block cross-origin profile mutation/import attempts. Thanks @vincentkoc.
  • -
  • Security/Signal: harden signal-cli archive extraction during install to prevent path traversal outside the install root.
  • -
  • Security/Hooks: restrict hook transform modules to ~/.openclaw/hooks/transforms (prevents path traversal/escape module loads via config). Config note: hooks.transformsDir must now be within that directory. Thanks @akhmittra.
  • -
  • Security/Hooks: ignore hook package manifest entries that point outside the package directory (prevents out-of-tree handler loads during hook discovery).
  • -
  • Security/Archive: enforce archive extraction entry/size limits to prevent resource exhaustion from high-expansion ZIP/TAR archives. Thanks @vincentkoc.
  • -
  • Security/Media: reject oversized base64-backed input media before decoding to avoid large allocations. Thanks @vincentkoc.
  • -
  • Security/Media: stream and bound URL-backed input media fetches to prevent memory exhaustion from oversized responses. Thanks @vincentkoc.
  • -
  • Security/Skills: harden archive extraction for download-installed skills to prevent path traversal outside the target directory. Thanks @markmusson.
  • -
  • Security/Slack: compute command authorization for DM slash commands even when dmPolicy=open, preventing unauthorized users from running privileged commands via DM. Thanks @christos-eth.
  • -
  • Security/iMessage: keep DM pairing-store identities out of group allowlist authorization (prevents cross-context command authorization). Thanks @vincentkoc.
  • -
  • Security/Google Chat: deprecate users/ allowlists (treat users/... as immutable user id only); keep raw email allowlists for usability. Thanks @vincentkoc.
  • -
  • Security/Google Chat: reject ambiguous shared-path webhook routing when multiple webhook targets verify successfully (prevents cross-account policy-context misrouting). Thanks @vincentkoc.
  • -
  • Telegram/Security: require numeric Telegram sender IDs for allowlist authorization (reject @username principals), auto-resolve @username to IDs in openclaw doctor --fix (when possible), and warn in openclaw security audit when legacy configs contain usernames. Thanks @vincentkoc.
  • -
  • Telegram/Security: reject Telegram webhook startup when webhookSecret is missing or empty (prevents unauthenticated webhook request forgery). Thanks @yueyueL.
  • -
  • Security/Windows: avoid shell invocation when spawning child processes to prevent cmd.exe metacharacter injection via untrusted CLI arguments (e.g. agent prompt text).
  • -
  • Telegram: set webhook callback timeout handling to onTimeout: "return" (10s) so long-running update processing no longer emits webhook 500s and retry storms. (#16763) Thanks @chansearrington.
  • -
  • Signal: preserve case-sensitive group: target IDs during normalization so mixed-case group IDs no longer fail with Group not found. (#16748) Thanks @repfigit.
  • -
  • Feishu/Security: harden media URL fetching against SSRF and local file disclosure. (#16285) Thanks @mbelinky.
  • -
  • Security/Agents: scope CLI process cleanup to owned child PIDs to avoid killing unrelated processes on shared hosts. Thanks @aether-ai-agent.
  • -
  • Security/Agents: enforce workspace-root path bounds for apply_patch in non-sandbox mode to block traversal and symlink escape writes. Thanks @p80n-sec.
  • -
  • Security/Agents: enforce symlink-escape checks for apply_patch delete hunks under workspaceOnly, while still allowing deleting the symlink itself. Thanks @p80n-sec.
  • -
  • Security/Agents (macOS): prevent shell injection when writing Claude CLI keychain credentials. (#15924) Thanks @aether-ai-agent.
  • -
  • macOS: hard-limit unkeyed openclaw://agent deep links and ignore deliver / to / channel unless a valid unattended key is provided. Thanks @Cillian-Collins.
  • -
  • Scripts/Security: validate GitHub logins and avoid shell invocation in scripts/update-clawtributors.ts to prevent command injection via malicious commit records. Thanks @scanleale.
  • -
  • Security: fix Chutes manual OAuth login state validation by requiring the full redirect URL (reject code-only pastes) (thanks @aether-ai-agent).
  • -
  • Security/Gateway: harden tool-supplied gatewayUrl overrides by restricting them to loopback or the configured gateway.remote.url. Thanks @p80n-sec.
  • -
  • Security/Gateway: block system.execApprovals.* via node.invoke (use exec.approvals.node.* instead). Thanks @christos-eth.
  • -
  • Security/Gateway: reject oversized base64 chat attachments before decoding to avoid large allocations. Thanks @vincentkoc.
  • -
  • Security/Gateway: stop returning raw resolved config values in skills.status requirement checks (prevents operator.read clients from reading secrets). Thanks @simecek.
  • -
  • Security/Net: fix SSRF guard bypass via full-form IPv4-mapped IPv6 literals (blocks loopback/private/metadata access). Thanks @yueyueL.
  • -
  • Security/Browser: harden browser control file upload + download helpers to prevent path traversal / local file disclosure. Thanks @1seal.
  • -
  • Security/Browser: block cross-origin mutating requests to loopback browser control routes (CSRF hardening). Thanks @vincentkoc.
  • -
  • Security/Node Host: enforce system.run rawCommand/argv consistency to prevent allowlist/approval bypass. Thanks @christos-eth.
  • -
  • Security/Exec approvals: prevent safeBins allowlist bypass via shell expansion (host exec allowlist mode only; not enabled by default). Thanks @christos-eth.
  • -
  • Security/Exec: harden PATH handling by disabling project-local node_modules/.bin bootstrapping by default, disallowing node-host PATH overrides, and spawning ACP servers via the current executable by default. Thanks @akhmittra.
  • -
  • Security/Tlon: harden Urbit URL fetching against SSRF by blocking private/internal hosts by default (opt-in: channels.tlon.allowPrivateNetwork). Thanks @p80n-sec.
  • -
  • Security/Voice Call (Telnyx): require webhook signature verification when receiving inbound events; configs without telnyx.publicKey are now rejected unless skipSignatureVerification is enabled. Thanks @p80n-sec.
  • -
  • Security/Voice Call: require valid Twilio webhook signatures even when ngrok free tier loopback compatibility mode is enabled. Thanks @p80n-sec.
  • -
  • Security/Discovery: stop treating Bonjour TXT records as authoritative routing (prefer resolved service endpoints) and prevent discovery from overriding stored TLS pins; autoconnect now requires a previously trusted gateway. Thanks @simecek.
  • -
-

View full changelog

-]]>
- -
- - 2026.2.15 - Mon, 16 Feb 2026 05:04:34 +0100 - https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml - 202602150 - 2026.2.15 - 15.0 - OpenClaw 2026.2.15 -

Changes

-
    -
  • Discord: unlock rich interactive agent prompts with Components v2 (buttons, selects, modals, and attachment-backed file blocks) so for native interaction through Discord. Thanks @thewilloftheshadow.
  • -
  • Discord: components v2 UI + embeds passthrough + exec approval UX refinements (CV2 containers, button layout, Discord-forwarding skip). Thanks @thewilloftheshadow.
  • -
  • Plugins: expose llm_input and llm_output hook payloads so extensions can observe prompt/input context and model output usage details. (#16724) Thanks @SecondThread.
  • -
  • Subagents: nested sub-agents (sub-sub-agents) with configurable depth. Set agents.defaults.subagents.maxSpawnDepth: 2 to allow sub-agents to spawn their own children. Includes maxChildrenPerAgent limit (default 5), depth-aware tool policy, and proper announce chain routing. (#14447) Thanks @tyler6204.
  • -
  • Slack/Discord/Telegram: add per-channel ack reaction overrides (account/channel-level) to support platform-specific emoji formats. (#17092) Thanks @zerone0x.
  • -
  • Cron/Gateway: add finished-run webhook delivery toggle (notify) and dedicated webhook auth token support (cron.webhookToken) for outbound cron webhook posts. (#14535) Thanks @advaitpaliwal.
  • -
  • Channels: deduplicate probe/token resolution base types across core + extensions while preserving per-channel error typing. (#16986) Thanks @iyoda and @thewilloftheshadow.
  • -
-

Fixes

-
    -
  • Security: replace deprecated SHA-1 sandbox configuration hashing with SHA-256 for deterministic sandbox cache identity and recreation checks. Thanks @kexinoh.
  • -
  • Security/Logging: redact Telegram bot tokens from error messages and uncaught stack traces to prevent accidental secret leakage into logs. Thanks @aether-ai-agent.
  • -
  • Sandbox/Security: block dangerous sandbox Docker config (bind mounts, host networking, unconfined seccomp/apparmor) to prevent container escape via config injection. Thanks @aether-ai-agent.
  • -
  • Sandbox: preserve array order in config hashing so order-sensitive Docker/browser settings trigger container recreation correctly. Thanks @kexinoh.
  • -
  • Gateway/Security: redact sensitive session/path details from status responses for non-admin clients; full details remain available to operator.admin. (#8590) Thanks @fr33d3m0n.
  • -
  • Gateway/Control UI: preserve requested operator scopes for Control UI bypass modes (allowInsecureAuth / dangerouslyDisableDeviceAuth) when device identity is unavailable, preventing false missing scope failures on authenticated LAN/HTTP operator sessions. (#17682) Thanks @leafbird.
  • -
  • LINE/Security: fail closed on webhook startup when channel token or channel secret is missing, and treat LINE accounts as configured only when both are present. (#17587) Thanks @davidahmann.
  • -
  • Skills/Security: restrict download installer targetDir to the per-skill tools directory to prevent arbitrary file writes. Thanks @Adam55A-code.
  • -
  • Skills/Linux: harden go installer fallback on apt-based systems by handling root/no-sudo environments safely, doing best-effort apt index refresh, and returning actionable errors instead of failing with spawn errors. (#17687) Thanks @mcrolly.
  • -
  • Web Fetch/Security: cap downloaded response body size before HTML parsing to prevent memory exhaustion from oversized or deeply nested pages. Thanks @xuemian168.
  • -
  • Config/Gateway: make sensitive-key whitelist suffix matching case-insensitive while preserving passwordFile path exemptions, preventing accidental redaction of non-secret config values like maxTokens and IRC password-file paths. (#16042) Thanks @akramcodez.
  • -
  • Dev tooling: harden git pre-commit hook against option injection from malicious filenames (for example --force), preventing accidental staging of ignored files. Thanks @mrthankyou.
  • -
  • Gateway/Agent: reject malformed agent:-prefixed session keys (for example, agent:main) in agent and agent.identity.get instead of silently resolving them to the default agent, preventing accidental cross-session routing. (#15707) Thanks @rodrigouroz.
  • -
  • Gateway/Chat: harden chat.send inbound message handling by rejecting null bytes, stripping unsafe control characters, and normalizing Unicode to NFC before dispatch. (#8593) Thanks @fr33d3m0n.
  • -
  • Gateway/Send: return an actionable error when send targets internal-only webchat, guiding callers to use chat.send or a deliverable channel. (#15703) Thanks @rodrigouroz.
  • -
  • Control UI: prevent stored XSS via assistant name/avatar by removing inline script injection, serving bootstrap config as JSON, and enforcing script-src 'self'. Thanks @Adam55A-code.
  • -
  • Agents/Security: sanitize workspace paths before embedding into LLM prompts (strip Unicode control/format chars) to prevent instruction injection via malicious directory names. Thanks @aether-ai-agent.
  • -
  • Agents/Sandbox: clarify system prompt path guidance so sandbox bash/exec uses container paths (for example /workspace) while file tools keep host-bridge mapping, avoiding first-attempt path misses from host-only absolute paths in sandbox command execution. (#17693) Thanks @app/juniordevbot.
  • -
  • Agents/Context: apply configured model contextWindow overrides after provider discovery so lookupContextTokens() honors operator config values (including discovery-failure paths). (#17404) Thanks @michaelbship and @vignesh07.
  • -
  • Agents/Context: derive lookupContextTokens() from auth-available model metadata and keep the smallest discovered context window for duplicate model ids, preventing cross-provider cache collisions from overestimating session context limits. (#17586) Thanks @githabideri and @vignesh07.
  • -
  • Agents/OpenAI: force store=true for direct OpenAI Responses/Codex runs to preserve multi-turn server-side conversation state, while leaving proxy/non-OpenAI endpoints unchanged. (#16803) Thanks @mark9232 and @vignesh07.
  • -
  • Memory/FTS: make buildFtsQuery Unicode-aware so non-ASCII queries (including CJK) produce keyword tokens instead of falling back to vector-only search. (#17672) Thanks @KinGP5471.
  • -
  • Auto-reply/Compaction: resolve memory/YYYY-MM-DD.md placeholders with timezone-aware runtime dates and append a Current time: line to memory-flush turns, preventing wrong-year memory filenames without making the system prompt time-variant. (#17603, #17633) Thanks @nicholaspapadam-wq and @vignesh07.
  • -
  • Agents: return an explicit timeout error reply when an embedded run times out before producing any payloads, preventing silent dropped turns during slow cache-refresh transitions. (#16659) Thanks @liaosvcaf and @vignesh07.
  • -
  • Group chats: always inject group chat context (name, participants, reply guidance) into the system prompt on every turn, not just the first. Prevents the model from losing awareness of which group it's in and incorrectly using the message tool to send to the same group. (#14447) Thanks @tyler6204.
  • -
  • Browser/Agents: when browser control service is unavailable, return explicit non-retry guidance (instead of "try again") so models do not loop on repeated browser tool calls until timeout. (#17673) Thanks @austenstone.
  • -
  • Subagents: use child-run-based deterministic announce idempotency keys across direct and queued delivery paths (with legacy queued-item fallback) to prevent duplicate announce retries without collapsing distinct same-millisecond announces. (#17150) Thanks @widingmarcus-cyber.
  • -
  • Subagents/Models: preserve agents.defaults.model.fallbacks when subagent sessions carry a model override, so subagent runs fail over to configured fallback models instead of retrying only the overridden primary model.
  • -
  • Telegram: omit message_thread_id for DM sends/draft previews and keep forum-topic handling (id=1 general omitted, non-general kept), preventing DM failures with 400 Bad Request: message thread not found. (#10942) Thanks @garnetlyx.
  • -
  • Telegram: replace inbound placeholder with successful preflight voice transcript in message body context, preventing placeholder-only prompt bodies for mention-gated voice messages. (#16789) Thanks @Limitless2023.
  • -
  • Telegram: retry inbound media getFile calls (3 attempts with backoff) and gracefully fall back to placeholder-only processing when retries fail, preventing dropped voice/media messages on transient Telegram network errors. (#16154) Thanks @yinghaosang.
  • -
  • Telegram: finalize streaming preview replies in place instead of sending a second final message, preventing duplicate Telegram assistant outputs at stream completion. (#17218) Thanks @obviyus.
  • -
  • Discord: preserve channel session continuity when runtime payloads omit message.channelId by falling back to event/raw channel_id values for routing/session keys, so same-channel messages keep history across turns/restarts. Also align diagnostics so active Discord runs no longer appear as sessionKey=unknown. (#17622) Thanks @shakkernerd.
  • -
  • Discord: dedupe native skill commands by skill name in multi-agent setups to prevent duplicated slash commands with _2 suffixes. (#17365) Thanks @seewhyme.
  • -
  • Discord: ensure role allowlist matching uses raw role IDs for message routing authorization. Thanks @xinhuagu.
  • -
  • Web UI/Agents: hide BOOTSTRAP.md in the Agents Files list after onboarding is completed, avoiding confusing missing-file warnings for completed workspaces. (#17491) Thanks @gumadeiras.
  • -
  • Auto-reply/WhatsApp/TUI/Web: when a final assistant message is NO_REPLY and a messaging tool send succeeded, mirror the delivered messaging-tool text into session-visible assistant output so TUI/Web no longer show NO_REPLY placeholders. (#7010) Thanks @Morrowind-Xie.
  • -
  • Cron: infer payload.kind="agentTurn" for model-only cron.update payload patches, so partial agent-turn updates do not fail validation when kind is omitted. (#15664) Thanks @rodrigouroz.
  • -
  • TUI: make searchable-select filtering and highlight rendering ANSI-aware so queries ignore hidden escape codes and no longer corrupt ANSI styling sequences during match highlighting. (#4519) Thanks @bee4come.
  • -
  • TUI/Windows: coalesce rapid single-line submit bursts in Git Bash into one multiline message as a fallback when bracketed paste is unavailable, preventing pasted multiline text from being split into multiple sends. (#4986) Thanks @adamkane.
  • -
  • TUI: suppress false (no output) placeholders for non-local empty final events during concurrent runs, preventing external-channel replies from showing empty assistant bubbles while a local run is still streaming. (#5782) Thanks @LagWizard and @vignesh07.
  • -
  • TUI: preserve copy-sensitive long tokens (URLs/paths/file-like identifiers) during wrapping and overflow sanitization so wrapped output no longer inserts spaces that corrupt copy/paste values. (#17515, #17466, #17505) Thanks @abe238, @trevorpan, and @JasonCry.
  • -
  • CLI/Build: make legacy daemon CLI compatibility shim generation tolerant of minimal tsdown daemon export sets, while preserving restart/register compatibility aliases and surfacing explicit errors for unavailable legacy daemon commands. Thanks @vignesh07.
  • -
-

View full changelog

-]]>
- -
- - 2026.2.21 - Sat, 21 Feb 2026 17:55:48 +0100 - https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml - 13056 - 2026.2.21 - 15.0 - OpenClaw 2026.2.21 -

Changes

-
    -
  • Models/Google: add Gemini 3.1 support (google/gemini-3.1-pro-preview).
  • -
  • Providers/Onboarding: add Volcano Engine (Doubao) and BytePlus providers/models (including coding variants), wire onboarding auth choices for interactive + non-interactive flows, and align docs to volcengine-api-key. (#7967) Thanks @funmore123.
  • -
  • Channels/CLI: add per-account/channel defaultTo outbound routing fallback so openclaw agent --deliver can send without explicit --reply-to when a default target is configured. (#16985) Thanks @KirillShchetinin.
  • -
  • Channels: allow per-channel model overrides via channels.modelByChannel and note them in /status. Thanks @thewilloftheshadow.
  • -
  • Telegram/Streaming: simplify preview streaming config to channels.telegram.streaming (boolean), auto-map legacy streamMode values, and remove block-vs-partial preview branching. (#22012) thanks @obviyus.
  • -
  • Discord/Streaming: add stream preview mode for live draft replies with partial/block options and configurable chunking. Thanks @thewilloftheshadow. Inspiration @neoagentic-ship-it.
  • -
  • Discord/Telegram: add configurable lifecycle status reactions for queued/thinking/tool/done/error phases with a shared controller and emoji/timing overrides. Thanks @wolly-tundracube and @thewilloftheshadow.
  • -
  • Discord/Voice: add voice channel join/leave/status via /vc, plus auto-join configuration for realtime voice conversations. Thanks @thewilloftheshadow.
  • -
  • Discord: add configurable ephemeral defaults for slash-command responses. (#16563) Thanks @wei.
  • -
  • Discord: support updating forum available_tags via channel edit actions for forum tag management. (#12070) Thanks @xiaoyaner0201.
  • -
  • Discord: include channel topics in trusted inbound metadata on new sessions. Thanks @thewilloftheshadow.
  • -
  • Discord/Subagents: add thread-bound subagent sessions on Discord with per-thread focus/list controls and thread-bound continuation routing for spawned helper agents. (#21805) Thanks @onutc.
  • -
  • iOS/Chat: clean chat UI noise by stripping inbound untrusted metadata/timestamp prefixes, formatting tool outputs into concise summaries/errors, compacting the composer while typing, and supporting tap-to-dismiss keyboard in chat view. (#22122) thanks @mbelinky.
  • -
  • iOS/Watch: bridge mirrored watch prompt notification actions into iOS quick-reply handling, including queued action handoff until app model initialization. (#22123) thanks @mbelinky.
  • -
  • iOS/Gateway: stabilize background wake and reconnect behavior with background reconnect suppression/lease windows, BGAppRefresh wake fallback, location wake hook throttling, and APNs wake retry+nudge instrumentation. (#21226) thanks @mbelinky.
  • -
  • Auto-reply/UI: add model fallback lifecycle visibility in verbose logs, /status active-model context with fallback reason, and cohesive WebUI fallback indicators. (#20704) Thanks @joshavant.
  • -
  • MSTeams: dedupe sent-message cache storage by removing duplicate per-message Set storage and using timestamps Map keys as the single membership source. (#22514) Thanks @TaKO8Ki.
  • -
  • Agents/Subagents: default subagent spawn depth now uses shared maxSpawnDepth=2, enabling depth-1 orchestrator spawning by default while keeping depth policy checks consistent across spawn and prompt paths. (#22223) Thanks @tyler6204.
  • -
  • Security/Agents: make owner-ID obfuscation use a dedicated HMAC secret from configuration (ownerDisplaySecret) and update hashing behavior so obfuscation is decoupled from gateway token handling for improved control. (#7343) Thanks @vincentkoc.
  • -
  • Security/Infra: switch gateway lock and tool-call synthetic IDs from SHA-1 to SHA-256 with unchanged truncation length to strengthen hash basis while keeping deterministic behavior and lock key format. (#7343) Thanks @vincentkoc.
  • -
  • Dependencies/Tooling: add non-blocking dead-code scans in CI via Knip/ts-prune/ts-unused-exports to surface unused dependencies and exports earlier. (#22468) Thanks @vincentkoc.
  • -
  • Dependencies/Unused Dependencies: remove or scope unused root and extension deps (@larksuiteoapi/node-sdk, signal-utils, ollama, lit, @lit/context, @lit-labs/signals, @microsoft/agents-hosting-express, @microsoft/agents-hosting-extensions-teams, and plugin-local openclaw devDeps in extensions/open-prose, extensions/lobster, and extensions/llm-task). (#22471, #22495) Thanks @vincentkoc.
  • -
  • Dependencies/A2UI: harden dependency resolution after root cleanup (resolve lit, @lit/context, @lit-labs/signals, and signal-utils from workspace/root) and simplify bundling fallback behavior, including pnpm dlx rolldown compatibility. (#22481, #22507) Thanks @vincentkoc.
  • -
-

Fixes

-
    -
  • Security/Agents: cap embedded Pi runner outer retry loop with a higher profile-aware dynamic limit (32-160 attempts) and return an explicit retry_limit error payload when retries never converge, preventing unbounded internal retry cycles (GHSA-76m6-pj3w-v7mf).
  • -
  • Telegram: detect duplicate bot-token ownership across Telegram accounts at startup/status time, mark secondary accounts as not configured with an explicit fix message, and block duplicate account startup before polling to avoid endless getUpdates conflict loops.
  • -
  • Agents/Tool images: include source filenames in agents/tool-images resize logs so compression events can be traced back to specific files.
  • -
  • Providers/OAuth: harden Qwen and Chutes refresh handling by validating refresh response expiry values and preserving prior refresh tokens when providers return empty refresh token fields, with regression coverage for empty-token responses.
  • -
  • Models/Kimi-Coding: add missing implicit provider template for kimi-coding with correct anthropic-messages API type and base URL, fixing 403 errors when using Kimi for Coding. (#22409)
  • -
  • Auto-reply/Tools: forward senderIsOwner through embedded queued/followup runner params so owner-only tools remain available for authorized senders. (#22296) thanks @hcoj.
  • -
  • Discord: restore model picker back navigation when a provider is missing and document the Discord picker flow. (#21458) Thanks @pejmanjohn and @thewilloftheshadow.
  • -
  • Memory/QMD: respect per-agent memorySearch.enabled=false during gateway QMD startup initialization, split multi-collection QMD searches into per-collection queries (search/vsearch/query) to avoid sparse-term drops, prefer collection-hinted doc resolution to avoid stale-hash collisions, retry boot updates on transient lock/timeout failures, skip qmd embed in BM25-only search mode (including memory index --force), and serialize embed runs globally with failure backoff to prevent CPU storms on multi-agent hosts. (#20581, #21590, #20513, #20001, #21266, #21583, #20346, #19493) Thanks @danielrevivo, @zanderkrause, @sunyan034-cmd, @tilleulenspiegel, @dae-oss, @adamlongcreativellc, @jonathanadams96, and @kiliansitel.
  • -
  • Memory/Builtin: prevent automatic sync races with manager shutdown by skipping post-close sync starts and waiting for in-flight sync before closing SQLite, so onSearch/onSessionStart no longer fail with database is not open in ephemeral CLI flows. (#20556, #7464) Thanks @FuzzyTG and @henrybottter.
  • -
  • Providers/Copilot: drop persisted assistant thinking blocks for Claude models (while preserving turn structure/tool blocks) so follow-up requests no longer fail on invalid thinkingSignature payloads. (#19459) Thanks @jackheuberger.
  • -
  • Providers/Copilot: add claude-sonnet-4.6 and claude-sonnet-4.5 to the default GitHub Copilot model catalog and add coverage for model-list/definition helpers. (#20270, fixes #20091) Thanks @Clawborn.
  • -
  • Auto-reply/WebChat: avoid defaulting inbound runtime channel labels to unrelated providers (for example whatsapp) for webchat sessions so channel-specific formatting guidance stays accurate. (#21534) Thanks @lbo728.
  • -
  • Status: include persisted cacheRead/cacheWrite in session summaries so compact /status output consistently shows cache hit percentages from real session data.
  • -
  • Heartbeat/Cron: restore interval heartbeat behavior so missing HEARTBEAT.md no longer suppresses runs (only effectively empty files skip), preserving prompt-driven and tagged-cron execution paths.
  • -
  • WhatsApp/Cron/Heartbeat: enforce allowlisted routing for implicit scheduled/system delivery by merging pairing-store + configured allowFrom recipients, selecting authorized recipients when last-route context points to a non-allowlisted chat, and preventing heartbeat fan-out to recent unauthorized chats.
  • -
  • Heartbeat/Active hours: constrain active-hours 24 sentinel parsing to 24:00 in time validation so invalid values like 24:30 are rejected early. (#21410) thanks @adhitShet.
  • -
  • Heartbeat: treat activeHours windows with identical start/end times as zero-width (always outside the window) instead of always-active. (#21408) thanks @adhitShet.
  • -
  • CLI/Pairing: default pairing list and pairing approve to the sole available pairing channel when omitted, so TUI-only setups can recover from pairing required without guessing channel arguments. (#21527) Thanks @losts1.
  • -
  • TUI/Pairing: show explicit pairing-required recovery guidance after gateway disconnects that return pairing required, including approval steps to unblock quickstart TUI hatching on fresh installs. (#21841) Thanks @nicolinux.
  • -
  • TUI/Input: suppress duplicate backspace events arriving in the same input burst window so SSH sessions no longer delete two characters per backspace press in the composer. (#19318) Thanks @eheimer.
  • -
  • TUI/Heartbeat: suppress heartbeat ACK/prompt noise in chat streaming when showOk is disabled, while still preserving non-ACK heartbeat alerts in final output. (#20228) Thanks @bhalliburton.
  • -
  • TUI/History: cap chat-log component growth and prune stale render nodes/references so large default history loads no longer overflow render recursion with RangeError: Maximum call stack size exceeded. (#18068) Thanks @JaniJegoroff.
  • -
  • Memory/QMD: diversify mixed-source search ranking when both session and memory collections are present so session transcript hits no longer crowd out durable memory-file matches in top results. (#19913) Thanks @alextempr.
  • -
  • Memory/Tools: return explicit unavailable warnings/actions from memory_search when embedding/provider failures occur (including quota exhaustion), so disabled memory does not look like an empty recall result. (#21894) Thanks @XBS9.
  • -
  • Session/Startup: require the /new and /reset greeting path to run Session Startup file-reading instructions before responding, so daily memory startup context is not skipped on fresh-session greetings. (#22338) Thanks @armstrong-pv.
  • -
  • Auth/Onboarding: align OAuth profile-id config mapping with stored credential IDs for OpenAI Codex and Chutes flows, preventing provider:default mismatches when OAuth returns email-scoped credentials. (#12692) thanks @mudrii.
  • -
  • Provider/HTTP: treat HTTP 503 as failover-eligible for LLM provider errors. (#21086) Thanks @Protocol-zero-0.
  • -
  • Slack: pass recipient_team_id / recipient_user_id through Slack native streaming calls so chat.startStream/appendStream/stopStream work reliably across DMs and Slack Connect setups, and disable block streaming when native streaming is active. (#20988) Thanks @Dithilli. Earlier recipient-ID groundwork was contributed in #20377 by @AsserAl1012.
  • -
  • CLI/Config: add canonical --strict-json parsing for config set and keep --json as a legacy alias to reduce help/behavior drift. (#21332) thanks @adhitShet.
  • -
  • CLI: keep openclaw -v as a root-only version alias so subcommand -v, --verbose flags (for example ACP/hooks/skills) are no longer intercepted globally. (#21303) thanks @adhitShet.
  • -
  • Memory: return empty snippets when memory_get/QMD read files that have not been created yet, and harden memory indexing/session helpers against ENOENT races so missing Markdown no longer crashes tools. (#20680) Thanks @pahdo.
  • -
  • Telegram/Streaming: always clean up draft previews even when dispatch throws before fallback handling, preventing orphaned preview messages during failed runs. (#19041) thanks @mudrii.
  • -
  • Telegram/Streaming: split reasoning and answer draft preview lanes to prevent cross-lane overwrites, and ignore literal tags inside inline/fenced code snippets so sample markup is not misrouted as reasoning. (#20774) Thanks @obviyus.
  • -
  • Telegram/Streaming: restore 30-char first-preview debounce and scope NO_REPLY prefix suppression to partial sentinel fragments so normal No... text is not filtered. (#22613) thanks @obviyus.
  • -
  • Telegram/Status reactions: refresh stall timers on repeated phase updates and honor ack-reaction scope when lifecycle reactions are enabled, preventing false stall emojis and unwanted group reactions. Thanks @wolly-tundracube and @thewilloftheshadow.
  • -
  • Telegram/Status reactions: keep lifecycle reactions active when available-reactions lookup fails by falling back to unrestricted variant selection instead of suppressing reaction updates. (#22380) thanks @obviyus.
  • -
  • Discord/Streaming: apply replyToMode: first only to the first Discord chunk so block-streamed replies do not spam mention pings. (#20726) Thanks @thewilloftheshadow for the report.
  • -
  • Discord/Components: map DM channel targets back to user-scoped component sessions so button/select interactions stay in the main DM session. Thanks @thewilloftheshadow.
  • -
  • Discord/Allowlist: lazy-load guild lists when resolving Discord user allowlists so ID-only entries resolve even if guild fetch fails. (#20208) Thanks @zhangjunmengyang.
  • -
  • Discord/Gateway: handle close code 4014 (missing privileged gateway intents) without crashing the gateway. Thanks @thewilloftheshadow.
  • -
  • Discord: ingest inbound stickers as media so sticker-only messages and forwarded stickers are visible to agents. Thanks @thewilloftheshadow.
  • -
  • Auto-reply/Runner: emit onAgentRunStart only after agent lifecycle or tool activity begins (and only once per run), so fallback preflight errors no longer mark runs as started. (#21165) Thanks @shakkernerd.
  • -
  • Auto-reply/Tool results: serialize tool-result delivery and keep the delivery chain progressing after individual failures so concurrent tool outputs preserve user-visible ordering. (#21231) thanks @ahdernasr.
  • -
  • Auto-reply/Prompt caching: restore prefix-cache stability by keeping inbound system metadata session-stable and moving per-message IDs (message_id, message_id_full, reply_to_id, sender_id) into untrusted conversation context. (#20597) Thanks @anisoptera.
  • -
  • iOS/Watch: add actionable watch approval/reject controls and quick-reply actions so watch-originated approvals and responses can be sent directly from notification flows. (#21996) Thanks @mbelinky.
  • -
  • iOS/Watch: refresh iOS and watch app icon assets with the lobster icon set to keep phone/watch branding aligned. (#21997) Thanks @mbelinky.
  • -
  • CLI/Onboarding: fix Anthropic-compatible custom provider verification by normalizing base URLs to avoid duplicate /v1 paths during setup checks. (#21336) Thanks @17jmumford.
  • -
  • iOS/Gateway/Tools: prefer uniquely connected node matches when duplicate display names exist, surface actionable nodes invoke pairing-required guidance with request IDs, and refresh active iOS gateway registration after location-capability setting changes so capability updates apply immediately. (#22120) thanks @mbelinky.
  • -
  • Gateway/Auth: require gateway.trustedProxies to include a loopback proxy address when auth.mode="trusted-proxy" and bind="loopback", preventing same-host proxy misconfiguration from silently blocking auth. (#22082, follow-up to #20097) thanks @mbelinky.
  • -
  • Gateway/Auth: allow trusted-proxy mode with loopback bind for same-host reverse-proxy deployments, while still requiring configured gateway.trustedProxies. (#20097) thanks @xinhuagu.
  • -
  • Gateway/Auth: allow authenticated clients across roles/scopes to call health while preserving role and scope enforcement for non-health methods. (#19699) thanks @Nachx639.
  • -
  • Gateway/Hooks: include transform export name in hook-transform cache keys so distinct exports from the same module do not reuse the wrong cached transform function. (#13855) thanks @mcaxtr.
  • -
  • Gateway/Control UI: return 404 for missing static-asset paths instead of serving SPA fallback HTML, while preserving client-route fallback behavior for extensionless and non-asset dotted paths. (#12060) thanks @mcaxtr.
  • -
  • Gateway/Pairing: prevent device-token rotate scope escalation by enforcing an approved-scope baseline, preserving approved scopes across metadata updates, and rejecting rotate requests that exceed approved role scope implications. (#20703) thanks @coygeek.
  • -
  • Gateway/Pairing: clear persisted paired-device state when the gateway client closes with device token mismatch (1008) so reconnect flows can cleanly re-enter pairing. (#22071) Thanks @mbelinky.
  • -
  • Gateway/Config: allow gateway.customBindHost in strict config validation when gateway.bind="custom" so valid custom bind-host configurations no longer fail startup. (#20318, fixes #20289) Thanks @MisterGuy420.
  • -
  • Gateway/Pairing: tolerate legacy paired devices missing roles/scopes metadata in websocket upgrade checks and backfill metadata on reconnect. (#21447, fixes #21236) Thanks @joshavant.
  • -
  • Gateway/Pairing/CLI: align read-scope compatibility in pairing/device-token checks and add local openclaw devices fallback recovery for loopback pairing required deadlocks, with explicit fallback notice to unblock approval bootstrap flows. (#21616) Thanks @shakkernerd.
  • -
  • Cron: honor cron.maxConcurrentRuns in the timer loop so due jobs can execute up to the configured parallelism instead of always running serially. (#11595) Thanks @Takhoffman.
  • -
  • Agents/Compaction: restore embedded compaction safeguard/context-pruning extension loading in production by wiring bundled extension factories into the resource loader instead of runtime file-path resolution. (#22349) Thanks @Glucksberg.
  • -
  • Agents/Subagents: restore announce-chain delivery to agent injection, defer nested announce output until descendant follow-up content is ready, and prevent descendant deferrals from consuming announce retry budget so deep chains do not drop final completions. (#22223) Thanks @tyler6204.
  • -
  • Agents/System Prompt: label allowlisted senders as authorized senders to avoid implying ownership. Thanks @thewilloftheshadow.
  • -
  • Agents/Tool display: fix exec cwd suffix inference so pushd ... && popd ... && does not keep stale (in ) context in summaries. (#21925) Thanks @Lukavyi.
  • -
  • Tools/web_search: handle xAI Responses API payloads that emit top-level output_text blocks (without a message wrapper) so Grok web_search no longer returns No response for those results. (#20508) Thanks @echoVic.
  • -
  • Agents/Failover: treat non-default override runs as direct fallback-to-configured-primary (skip configured fallback chain), normalize default-model detection for provider casing/whitespace, and add regression coverage for override/auth error paths. (#18820) Thanks @Glucksberg.
  • -
  • Docker/Build: include ownerDisplay in CommandsSchema object-level defaults so Docker pnpm build no longer fails with TS2769 during plugin SDK d.ts generation. (#22558) Thanks @obviyus.
  • -
  • Docker/Browser: install Playwright Chromium into /home/node/.cache/ms-playwright and set node:node ownership so browser binaries are available to the runtime user in browser-enabled images. (#22585) thanks @obviyus.
  • -
  • Hooks/Session memory: trigger bundled session-memory persistence on both /new and /reset so reset flows no longer skip markdown transcript capture before archival. (#21382) Thanks @mofesolapaul.
  • -
  • Dependencies/Agents: bump embedded Pi SDK packages (@mariozechner/pi-agent-core, @mariozechner/pi-ai, @mariozechner/pi-coding-agent, @mariozechner/pi-tui) to 0.54.0. (#21578) Thanks @Takhoffman.
  • -
  • Config/Agents: expose Pi compaction tuning values agents.defaults.compaction.reserveTokens and agents.defaults.compaction.keepRecentTokens in config schema/types and apply them in embedded Pi runner settings overrides with floor enforcement via reserveTokensFloor. (#21568) Thanks @Takhoffman.
  • -
  • Docker: pin base images to SHA256 digests in Docker builds to prevent mutable tag drift. (#7734) Thanks @coygeek.
  • -
  • Docker: run build steps as the node user and use COPY --chown to avoid recursive ownership changes, trimming image size and layer churn. Thanks @huntharo.
  • -
  • Config/Memory: restore schema help/label metadata for hybrid mmr and temporalDecay settings so configuration surfaces show correct names and guidance. (#18786) Thanks @rodrigouroz.
  • -
  • Skills/SonosCLI: add troubleshooting guidance for sonos discover failures on macOS direct mode (sendto: no route to host) and sandbox network restrictions (bind: operation not permitted). (#21316) Thanks @huntharo.
  • -
  • macOS/Build: default release packaging to BUNDLE_ID=ai.openclaw.mac in scripts/package-mac-dist.sh, so Sparkle feed URL is retained and auto-update no longer fails with an empty appcast feed. (#19750) thanks @loganprit.
  • -
  • Signal/Outbound: preserve case for Base64 group IDs during outbound target normalization so cross-context routing and policy checks no longer break when group IDs include uppercase characters. (#5578) Thanks @heyhudson.
  • -
  • Anthropic/Agents: preserve required pi-ai default OAuth beta headers when context1m injects anthropic-beta, preventing 401 auth failures for sk-ant-oat-* tokens. (#19789, fixes #19769) Thanks @minupla.
  • -
  • Security/Exec: block unquoted heredoc body expansion tokens in shell allowlist analysis, reject unterminated heredocs, and require explicit approval for allowlisted heredoc execution on gateway hosts to prevent heredoc substitution allowlist bypass. Thanks @torturado for reporting.
  • -
  • macOS/Security: evaluate system.run allowlists per shell segment in macOS node runtime and companion exec host (including chained shell operators), fail closed on shell/process substitution parsing, and require explicit approval on unsafe parse cases to prevent allowlist bypass via rawCommand chaining. Thanks @tdjackey for reporting.
  • -
  • WhatsApp/Security: enforce allowlist JID authorization for reaction actions so authenticated callers cannot target non-allowlisted chats by forging chatJid + valid messageId pairs. Thanks @aether-ai-agent for reporting.
  • -
  • ACP/Security: escape control and delimiter characters in ACP resource_link title/URI metadata before prompt interpolation to prevent metadata-driven prompt injection through resource links. Thanks @aether-ai-agent for reporting.
  • -
  • TTS/Security: make model-driven provider switching opt-in by default (messages.tts.modelOverrides.allowProvider=false unless explicitly enabled), while keeping voice/style overrides available, to reduce prompt-injection-driven provider hops and unexpected TTS cost escalation. Thanks @aether-ai-agent for reporting.
  • -
  • Security/Agents: keep overflow compaction retry budgeting global across tool-result truncation recovery so successful truncation cannot reset the overflow retry counter and amplify retry/cost cycles. Thanks @aether-ai-agent for reporting.
  • -
  • BlueBubbles/Security: require webhook token authentication for all BlueBubbles webhook requests (including loopback/proxied setups), removing passwordless webhook fallback behavior. Thanks @zpbrent.
  • -
  • iOS/Security: force https:// for non-loopback manual gateway hosts during iOS onboarding to block insecure remote transport URLs. (#21969) Thanks @mbelinky.
  • -
  • Gateway/Security: remove shared-IP fallback for canvas endpoints and require token or session capability for canvas access. Thanks @thewilloftheshadow.
  • -
  • Gateway/Security: require secure context and paired-device checks for Control UI auth even when gateway.controlUi.allowInsecureAuth is set, and align audit messaging with the hardened behavior. (#20684) Thanks @coygeek and @Vasco0x4 for reporting.
  • -
  • Gateway/Security: scope tokenless Tailscale forwarded-header auth to Control UI websocket auth only, so HTTP gateway routes still require token/password even on trusted hosts. Thanks @zpbrent for reporting.
  • -
  • Docker/Security: run E2E and install-sh test images as non-root by adding appuser directives. Thanks @thewilloftheshadow.
  • -
  • Skills/Security: sanitize skill env overrides to block unsafe runtime injection variables and only allow sensitive keys when declared in skill metadata, with warnings for suspicious values. Thanks @thewilloftheshadow.
  • -
  • Security/Commands: block prototype-key injection in runtime /debug overrides and require own-property checks for gated command flags (bash, config, debug) so inherited prototype values cannot enable privileged commands. Thanks @tdjackey for reporting.
  • -
  • Security/Browser: block non-network browser navigation protocols (including file:, data:, and javascript:) while preserving about:blank, preventing local file reads via browser tool navigation. Thanks @q1uf3ng for reporting.
  • -
  • Security/Exec: block shell startup-file env injection (BASH_ENV, ENV, BASH_FUNC_*, LD_*, DYLD_*) across config env ingestion, node-host inherited environment sanitization, and macOS exec host runtime to prevent pre-command execution from attacker-controlled environment variables. Thanks @tdjackey.
  • -
  • Security/Exec (Windows): canonicalize cmd.exe /c command text across validation, approval binding, and audit/event rendering to prevent trailing-argument approval mismatches in system.run. Thanks @tdjackey for reporting.
  • -
  • Security/Gateway/Hooks: block __proto__, constructor, and prototype traversal in webhook template path resolution to prevent prototype-chain payload data leakage in messageTemplate rendering. (#22213) Thanks @SleuthCo.
  • -
  • Security/OpenClawKit/UI: prevent injected inbound user context metadata blocks from leaking into chat history in TUI, webchat, and macOS surfaces by stripping all untrusted metadata prefixes at display boundaries. (#22142) Thanks @Mellowambience, @vincentkoc.
  • -
  • Security/OpenClawKit/UI: strip inbound metadata blocks from user messages in TUI rendering while preserving user-authored content. (#22345) Thanks @kansodata, @vincentkoc.
  • -
  • Security/OpenClawKit/UI: prevent inbound metadata leaks and reply-tag streaming artifacts in TUI rendering by stripping untrusted metadata prefixes at display boundaries. (#22346) Thanks @akramcodez, @vincentkoc.
  • -
  • Security/Agents: restrict local MEDIA tool attachments to core tools and the OpenClaw temp root to prevent untrusted MCP tool file exfiltration. Thanks @NucleiAv and @thewilloftheshadow.
  • -
  • Security/Net: strip sensitive headers (Authorization, Proxy-Authorization, Cookie, Cookie2) on cross-origin redirects in fetchWithSsrFGuard to prevent credential forwarding across origin boundaries. (#20313) Thanks @afurm.
  • -
  • Security/Systemd: reject CR/LF in systemd unit environment values and fix argument escaping so generated units cannot be injected with extra directives. Thanks @thewilloftheshadow.
  • -
  • Security/Tools: add per-wrapper random IDs to untrusted-content markers from wrapExternalContent/wrapWebContent, preventing marker spoofing from escaping content boundaries. (#19009) Thanks @Whoaa512.
  • -
  • Shared/Security: reject insecure deep links that use ws:// non-loopback gateway URLs to prevent plaintext remote websocket configuration. (#21970) Thanks @mbelinky.
  • -
  • macOS/Security: reject non-loopback ws:// remote gateway URLs in macOS remote config to block insecure plaintext websocket endpoints. (#21971) Thanks @mbelinky.
  • -
  • Browser/Security: block upload path symlink escapes so browser upload sources cannot traverse outside the allowed workspace via symlinked paths. (#21972) Thanks @mbelinky.
  • -
  • Security/Dependencies: bump transitive hono usage to 4.11.10 to incorporate timing-safe authentication comparison hardening for basicAuth/bearerAuth (GHSA-gq3j-xvxp-8hrf). Thanks @vincentkoc.
  • -
  • Security/Gateway: parse X-Forwarded-For with trust-preserving semantics when requests come from configured trusted proxies, preventing proxy-chain spoofing from influencing client IP classification and rate-limit identity. Thanks @AnthonyDiSanti and @vincentkoc.
  • -
  • Security/Sandbox: remove default --no-sandbox for the browser container entrypoint, add explicit opt-in via OPENCLAW_BROWSER_NO_SANDBOX / CLAWDBOT_BROWSER_NO_SANDBOX, and add security-audit checks for stale/missing sandbox browser Docker hash labels. Thanks @TerminalsandCoffee and @vincentkoc.
  • -
  • Security/Sandbox Browser: require VNC password auth for noVNC observer sessions in the sandbox browser entrypoint, plumb per-container noVNC passwords from runtime, and emit short-lived noVNC observer token URLs while keeping loopback-only host port publishing. Thanks @TerminalsandCoffee for reporting.
  • -
  • Security/Sandbox Browser: default browser sandbox containers to a dedicated Docker network (openclaw-sandbox-browser), add optional CDP ingress source-range restrictions, auto-create missing dedicated networks, and warn in openclaw security --audit when browser sandboxing runs on bridge without source-range limits. Thanks @TerminalsandCoffee for reporting.
  • -
-

View full changelog

-]]>
- -
-
-
\ No newline at end of file diff --git a/apps/android/.gitignore b/apps/android/.gitignore deleted file mode 100644 index 68bfc099e36..00000000000 --- a/apps/android/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -.gradle/ -**/build/ -local.properties -.idea/ -**/*.iml diff --git a/apps/android/README.md b/apps/android/README.md deleted file mode 100644 index c2ae5a2179b..00000000000 --- a/apps/android/README.md +++ /dev/null @@ -1,51 +0,0 @@ -## OpenClaw Node (Android) (internal) - -Modern Android node app: connects to the **Gateway WebSocket** (`_openclaw-gw._tcp`) and exposes **Canvas + Chat + Camera**. - -Notes: -- The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action). -- Chat always uses the shared session key **`main`** (same session across iOS/macOS/WebChat/Android). -- Supports modern Android only (`minSdk 31`, Kotlin + Jetpack Compose). - -## Open in Android Studio -- Open the folder `apps/android`. - -## Build / Run - -```bash -cd apps/android -./gradlew :app:assembleDebug -./gradlew :app:installDebug -./gradlew :app:testDebugUnitTest -``` - -`gradlew` auto-detects the Android SDK at `~/Library/Android/sdk` (macOS default) if `ANDROID_SDK_ROOT` / `ANDROID_HOME` are unset. - -## Connect / Pair - -1) Start the gateway (on your “master” machine): -```bash -pnpm openclaw gateway --port 18789 --verbose -``` - -2) In the Android app: -- Open **Settings** -- Either select a discovered gateway under **Discovered Gateways**, or use **Advanced → Manual Gateway** (host + port). - -3) Approve pairing (on the gateway machine): -```bash -openclaw nodes pending -openclaw nodes approve -``` - -More details: `docs/platforms/android.md`. - -## Permissions - -- Discovery: - - Android 13+ (`API 33+`): `NEARBY_WIFI_DEVICES` - - Android 12 and below: `ACCESS_FINE_LOCATION` (required for NSD scanning) -- Foreground service notification (Android 13+): `POST_NOTIFICATIONS` -- Camera: - - `CAMERA` for `camera.snap` and `camera.clip` - - `RECORD_AUDIO` for `camera.clip` when `includeAudio=true` diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts deleted file mode 100644 index b91b1e21537..00000000000 --- a/apps/android/app/build.gradle.kts +++ /dev/null @@ -1,150 +0,0 @@ -import com.android.build.api.variant.impl.VariantOutputImpl - -plugins { - id("com.android.application") - id("org.jetbrains.kotlin.android") - id("org.jetbrains.kotlin.plugin.compose") - id("org.jetbrains.kotlin.plugin.serialization") -} - -android { - namespace = "ai.openclaw.android" - compileSdk = 36 - - sourceSets { - getByName("main") { - assets.srcDir(file("../../shared/OpenClawKit/Sources/OpenClawKit/Resources")) - } - } - - defaultConfig { - applicationId = "ai.openclaw.android" - minSdk = 31 - targetSdk = 36 - versionCode = 202602210 - versionName = "2026.2.21" - ndk { - // Support all major ABIs — native libs are tiny (~47 KB per ABI) - abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64") - } - } - - buildTypes { - release { - isMinifyEnabled = true - isShrinkResources = true - proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") - } - debug { - isMinifyEnabled = false - } - } - - buildFeatures { - compose = true - buildConfig = true - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - packaging { - resources { - excludes += setOf( - "/META-INF/{AL2.0,LGPL2.1}", - "/META-INF/*.version", - "/META-INF/LICENSE*.txt", - "DebugProbesKt.bin", - "kotlin-tooling-metadata.json", - ) - } - } - - lint { - disable += setOf( - "GradleDependency", - "IconLauncherShape", - "NewerVersionAvailable", - ) - warningsAsErrors = true - } - - testOptions { - unitTests.isIncludeAndroidResources = true - } -} - -androidComponents { - onVariants { variant -> - variant.outputs - .filterIsInstance() - .forEach { output -> - val versionName = output.versionName.orNull ?: "0" - val buildType = variant.buildType - - val outputFileName = "openclaw-${versionName}-${buildType}.apk" - output.outputFileName = outputFileName - } - } -} -kotlin { - compilerOptions { - jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) - allWarningsAsErrors.set(true) - } -} - -dependencies { - val composeBom = platform("androidx.compose:compose-bom:2025.12.00") - implementation(composeBom) - androidTestImplementation(composeBom) - - implementation("androidx.core:core-ktx:1.17.0") - implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0") - implementation("androidx.activity:activity-compose:1.12.2") - implementation("androidx.webkit:webkit:1.15.0") - - implementation("androidx.compose.ui:ui") - implementation("androidx.compose.ui:ui-tooling-preview") - implementation("androidx.compose.material3:material3") - // material-icons-extended pulled in full icon set (~20 MB DEX). Only ~18 icons used. - // R8 will tree-shake unused icons when minify is enabled on release builds. - implementation("androidx.compose.material:material-icons-extended") - implementation("androidx.navigation:navigation-compose:2.9.6") - - debugImplementation("androidx.compose.ui:ui-tooling") - - // Material Components (XML theme + resources) - implementation("com.google.android.material:material:1.13.0") - - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") - - implementation("androidx.security:security-crypto:1.1.0") - implementation("androidx.exifinterface:exifinterface:1.4.2") - implementation("com.squareup.okhttp3:okhttp:5.3.2") - implementation("org.bouncycastle:bcprov-jdk18on:1.83") - - // CameraX (for node.invoke camera.* parity) - implementation("androidx.camera:camera-core:1.5.2") - implementation("androidx.camera:camera-camera2:1.5.2") - implementation("androidx.camera:camera-lifecycle:1.5.2") - implementation("androidx.camera:camera-video:1.5.2") - implementation("androidx.camera:camera-view:1.5.2") - - // Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains. - implementation("dnsjava:dnsjava:3.6.4") - - testImplementation("junit:junit:4.13.2") - testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") - testImplementation("io.kotest:kotest-runner-junit5-jvm:6.0.7") - testImplementation("io.kotest:kotest-assertions-core-jvm:6.0.7") - testImplementation("org.robolectric:robolectric:4.16") - testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.2") -} - -tasks.withType().configureEach { - useJUnitPlatform() -} diff --git a/apps/android/app/proguard-rules.pro b/apps/android/app/proguard-rules.pro deleted file mode 100644 index d73c79711d6..00000000000 --- a/apps/android/app/proguard-rules.pro +++ /dev/null @@ -1,28 +0,0 @@ -# ── App classes ─────────────────────────────────────────────────── --keep class ai.openclaw.android.** { *; } - -# ── Bouncy Castle ───────────────────────────────────────────────── --keep class org.bouncycastle.** { *; } --dontwarn org.bouncycastle.** - -# ── CameraX ─────────────────────────────────────────────────────── --keep class androidx.camera.** { *; } - -# ── kotlinx.serialization ──────────────────────────────────────── --keep class kotlinx.serialization.** { *; } --keepclassmembers class * { - @kotlinx.serialization.Serializable *; -} --keepattributes *Annotation*, InnerClasses - -# ── OkHttp ──────────────────────────────────────────────────────── --dontwarn okhttp3.** --dontwarn okio.** --keep class okhttp3.internal.platform.** { *; } - -# ── Misc suppressions ──────────────────────────────────────────── --dontwarn com.sun.jna.** --dontwarn javax.naming.** --dontwarn lombok.Generated --dontwarn org.slf4j.impl.StaticLoggerBinder --dontwarn sun.net.spi.nameservice.NameServiceDescriptor diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml deleted file mode 100644 index facdbf301b4..00000000000 --- a/apps/android/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,64 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/android/app/src/main/java/ai/openclaw/android/CameraHudState.kt b/apps/android/app/src/main/java/ai/openclaw/android/CameraHudState.kt deleted file mode 100644 index 636c31bdd3c..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/CameraHudState.kt +++ /dev/null @@ -1,14 +0,0 @@ -package ai.openclaw.android - -enum class CameraHudKind { - Photo, - Recording, - Success, - Error, -} - -data class CameraHudState( - val token: Long, - val kind: CameraHudKind, - val message: String, -) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/DeviceNames.kt b/apps/android/app/src/main/java/ai/openclaw/android/DeviceNames.kt deleted file mode 100644 index 3c44a3bb4f7..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/DeviceNames.kt +++ /dev/null @@ -1,26 +0,0 @@ -package ai.openclaw.android - -import android.content.Context -import android.os.Build -import android.provider.Settings - -object DeviceNames { - fun bestDefaultNodeName(context: Context): String { - val deviceName = - runCatching { - Settings.Global.getString(context.contentResolver, "device_name") - } - .getOrNull() - ?.trim() - .orEmpty() - - if (deviceName.isNotEmpty()) return deviceName - - val model = - listOfNotNull(Build.MANUFACTURER?.takeIf { it.isNotBlank() }, Build.MODEL?.takeIf { it.isNotBlank() }) - .joinToString(" ") - .trim() - - return model.ifEmpty { "Android Node" } - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/InstallResultReceiver.kt b/apps/android/app/src/main/java/ai/openclaw/android/InstallResultReceiver.kt deleted file mode 100644 index ffb21258c1c..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/InstallResultReceiver.kt +++ /dev/null @@ -1,33 +0,0 @@ -package ai.openclaw.android - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.pm.PackageInstaller -import android.util.Log - -class InstallResultReceiver : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE) - val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) - - when (status) { - PackageInstaller.STATUS_PENDING_USER_ACTION -> { - // System needs user confirmation — launch the confirmation activity - @Suppress("DEPRECATION") - val confirmIntent = intent.getParcelableExtra(Intent.EXTRA_INTENT) - if (confirmIntent != null) { - confirmIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - context.startActivity(confirmIntent) - Log.w("openclaw", "app.update: user confirmation requested, launching install dialog") - } - } - PackageInstaller.STATUS_SUCCESS -> { - Log.w("openclaw", "app.update: install SUCCESS") - } - else -> { - Log.e("openclaw", "app.update: install FAILED status=$status message=$message") - } - } - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/LocationMode.kt b/apps/android/app/src/main/java/ai/openclaw/android/LocationMode.kt deleted file mode 100644 index eb9c84428e0..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/LocationMode.kt +++ /dev/null @@ -1,15 +0,0 @@ -package ai.openclaw.android - -enum class LocationMode(val rawValue: String) { - Off("off"), - WhileUsing("whileUsing"), - Always("always"), - ; - - companion object { - fun fromRawValue(raw: String?): LocationMode { - val normalized = raw?.trim()?.lowercase() - return entries.firstOrNull { it.rawValue.lowercase() == normalized } ?: Off - } - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/MainActivity.kt b/apps/android/app/src/main/java/ai/openclaw/android/MainActivity.kt deleted file mode 100644 index 2bbfd8712f9..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/MainActivity.kt +++ /dev/null @@ -1,130 +0,0 @@ -package ai.openclaw.android - -import android.Manifest -import android.content.pm.ApplicationInfo -import android.os.Bundle -import android.os.Build -import android.view.WindowManager -import android.webkit.WebView -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.activity.viewModels -import androidx.compose.material3.Surface -import androidx.compose.ui.Modifier -import androidx.core.content.ContextCompat -import androidx.core.view.WindowCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import ai.openclaw.android.ui.RootScreen -import ai.openclaw.android.ui.OpenClawTheme -import kotlinx.coroutines.launch - -class MainActivity : ComponentActivity() { - private val viewModel: MainViewModel by viewModels() - private lateinit var permissionRequester: PermissionRequester - private lateinit var screenCaptureRequester: ScreenCaptureRequester - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val isDebuggable = (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 - WebView.setWebContentsDebuggingEnabled(isDebuggable) - applyImmersiveMode() - requestDiscoveryPermissionsIfNeeded() - requestNotificationPermissionIfNeeded() - NodeForegroundService.start(this) - permissionRequester = PermissionRequester(this) - screenCaptureRequester = ScreenCaptureRequester(this) - viewModel.camera.attachLifecycleOwner(this) - viewModel.camera.attachPermissionRequester(permissionRequester) - viewModel.sms.attachPermissionRequester(permissionRequester) - viewModel.screenRecorder.attachScreenCaptureRequester(screenCaptureRequester) - viewModel.screenRecorder.attachPermissionRequester(permissionRequester) - - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.preventSleep.collect { enabled -> - if (enabled) { - window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } else { - window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } - } - } - } - - setContent { - OpenClawTheme { - Surface(modifier = Modifier) { - RootScreen(viewModel = viewModel) - } - } - } - } - - override fun onResume() { - super.onResume() - applyImmersiveMode() - } - - override fun onWindowFocusChanged(hasFocus: Boolean) { - super.onWindowFocusChanged(hasFocus) - if (hasFocus) { - applyImmersiveMode() - } - } - - override fun onStart() { - super.onStart() - viewModel.setForeground(true) - } - - override fun onStop() { - viewModel.setForeground(false) - super.onStop() - } - - private fun applyImmersiveMode() { - WindowCompat.setDecorFitsSystemWindows(window, false) - val controller = WindowInsetsControllerCompat(window, window.decorView) - controller.systemBarsBehavior = - WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - controller.hide(WindowInsetsCompat.Type.systemBars()) - } - - private fun requestDiscoveryPermissionsIfNeeded() { - if (Build.VERSION.SDK_INT >= 33) { - val ok = - ContextCompat.checkSelfPermission( - this, - Manifest.permission.NEARBY_WIFI_DEVICES, - ) == android.content.pm.PackageManager.PERMISSION_GRANTED - if (!ok) { - requestPermissions(arrayOf(Manifest.permission.NEARBY_WIFI_DEVICES), 100) - } - } else { - val ok = - ContextCompat.checkSelfPermission( - this, - Manifest.permission.ACCESS_FINE_LOCATION, - ) == android.content.pm.PackageManager.PERMISSION_GRANTED - if (!ok) { - requestPermissions(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), 101) - } - } - } - - private fun requestNotificationPermissionIfNeeded() { - if (Build.VERSION.SDK_INT < 33) return - val ok = - ContextCompat.checkSelfPermission( - this, - Manifest.permission.POST_NOTIFICATIONS, - ) == android.content.pm.PackageManager.PERMISSION_GRANTED - if (!ok) { - requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 102) - } - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt deleted file mode 100644 index d9123d10293..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt +++ /dev/null @@ -1,188 +0,0 @@ -package ai.openclaw.android - -import android.app.Application -import androidx.lifecycle.AndroidViewModel -import ai.openclaw.android.gateway.GatewayEndpoint -import ai.openclaw.android.chat.OutgoingAttachment -import ai.openclaw.android.node.CameraCaptureManager -import ai.openclaw.android.node.CanvasController -import ai.openclaw.android.node.ScreenRecordManager -import ai.openclaw.android.node.SmsManager -import kotlinx.coroutines.flow.StateFlow - -class MainViewModel(app: Application) : AndroidViewModel(app) { - private val runtime: NodeRuntime = (app as NodeApp).runtime - - val canvas: CanvasController = runtime.canvas - val camera: CameraCaptureManager = runtime.camera - val screenRecorder: ScreenRecordManager = runtime.screenRecorder - val sms: SmsManager = runtime.sms - - val gateways: StateFlow> = runtime.gateways - val discoveryStatusText: StateFlow = runtime.discoveryStatusText - - val isConnected: StateFlow = runtime.isConnected - val statusText: StateFlow = runtime.statusText - val serverName: StateFlow = runtime.serverName - val remoteAddress: StateFlow = runtime.remoteAddress - val pendingGatewayTrust: StateFlow = runtime.pendingGatewayTrust - val isForeground: StateFlow = runtime.isForeground - val seamColorArgb: StateFlow = runtime.seamColorArgb - val mainSessionKey: StateFlow = runtime.mainSessionKey - - val cameraHud: StateFlow = runtime.cameraHud - val cameraFlashToken: StateFlow = runtime.cameraFlashToken - val screenRecordActive: StateFlow = runtime.screenRecordActive - - val instanceId: StateFlow = runtime.instanceId - val displayName: StateFlow = runtime.displayName - val cameraEnabled: StateFlow = runtime.cameraEnabled - val locationMode: StateFlow = runtime.locationMode - val locationPreciseEnabled: StateFlow = runtime.locationPreciseEnabled - val preventSleep: StateFlow = runtime.preventSleep - val wakeWords: StateFlow> = runtime.wakeWords - val voiceWakeMode: StateFlow = runtime.voiceWakeMode - val voiceWakeStatusText: StateFlow = runtime.voiceWakeStatusText - val voiceWakeIsListening: StateFlow = runtime.voiceWakeIsListening - val talkEnabled: StateFlow = runtime.talkEnabled - val talkStatusText: StateFlow = runtime.talkStatusText - val talkIsListening: StateFlow = runtime.talkIsListening - val talkIsSpeaking: StateFlow = runtime.talkIsSpeaking - val manualEnabled: StateFlow = runtime.manualEnabled - val manualHost: StateFlow = runtime.manualHost - val manualPort: StateFlow = runtime.manualPort - val manualTls: StateFlow = runtime.manualTls - val gatewayToken: StateFlow = runtime.gatewayToken - val canvasDebugStatusEnabled: StateFlow = runtime.canvasDebugStatusEnabled - - val chatSessionKey: StateFlow = runtime.chatSessionKey - val chatSessionId: StateFlow = runtime.chatSessionId - val chatMessages = runtime.chatMessages - val chatError: StateFlow = runtime.chatError - val chatHealthOk: StateFlow = runtime.chatHealthOk - val chatThinkingLevel: StateFlow = runtime.chatThinkingLevel - val chatStreamingAssistantText: StateFlow = runtime.chatStreamingAssistantText - val chatPendingToolCalls = runtime.chatPendingToolCalls - val chatSessions = runtime.chatSessions - val pendingRunCount: StateFlow = runtime.pendingRunCount - - fun setForeground(value: Boolean) { - runtime.setForeground(value) - } - - fun setDisplayName(value: String) { - runtime.setDisplayName(value) - } - - fun setCameraEnabled(value: Boolean) { - runtime.setCameraEnabled(value) - } - - fun setLocationMode(mode: LocationMode) { - runtime.setLocationMode(mode) - } - - fun setLocationPreciseEnabled(value: Boolean) { - runtime.setLocationPreciseEnabled(value) - } - - fun setPreventSleep(value: Boolean) { - runtime.setPreventSleep(value) - } - - fun setManualEnabled(value: Boolean) { - runtime.setManualEnabled(value) - } - - fun setManualHost(value: String) { - runtime.setManualHost(value) - } - - fun setManualPort(value: Int) { - runtime.setManualPort(value) - } - - fun setManualTls(value: Boolean) { - runtime.setManualTls(value) - } - - fun setGatewayToken(value: String) { - runtime.setGatewayToken(value) - } - - fun setCanvasDebugStatusEnabled(value: Boolean) { - runtime.setCanvasDebugStatusEnabled(value) - } - - fun setWakeWords(words: List) { - runtime.setWakeWords(words) - } - - fun resetWakeWordsDefaults() { - runtime.resetWakeWordsDefaults() - } - - fun setVoiceWakeMode(mode: VoiceWakeMode) { - runtime.setVoiceWakeMode(mode) - } - - fun setTalkEnabled(enabled: Boolean) { - runtime.setTalkEnabled(enabled) - } - - fun refreshGatewayConnection() { - runtime.refreshGatewayConnection() - } - - fun connect(endpoint: GatewayEndpoint) { - runtime.connect(endpoint) - } - - fun connectManual() { - runtime.connectManual() - } - - fun disconnect() { - runtime.disconnect() - } - - fun acceptGatewayTrustPrompt() { - runtime.acceptGatewayTrustPrompt() - } - - fun declineGatewayTrustPrompt() { - runtime.declineGatewayTrustPrompt() - } - - fun handleCanvasA2UIActionFromWebView(payloadJson: String) { - runtime.handleCanvasA2UIActionFromWebView(payloadJson) - } - - fun loadChat(sessionKey: String) { - runtime.loadChat(sessionKey) - } - - fun refreshChat() { - runtime.refreshChat() - } - - fun refreshChatSessions(limit: Int? = null) { - runtime.refreshChatSessions(limit = limit) - } - - fun setChatThinkingLevel(level: String) { - runtime.setChatThinkingLevel(level) - } - - fun switchChatSession(sessionKey: String) { - runtime.switchChatSession(sessionKey) - } - - fun abortChat() { - runtime.abortChat() - } - - fun sendChat(message: String, thinking: String, attachments: List) { - runtime.sendChat(message = message, thinking = thinking, attachments = attachments) - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/NodeApp.kt b/apps/android/app/src/main/java/ai/openclaw/android/NodeApp.kt deleted file mode 100644 index 2be9ee71a2c..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/NodeApp.kt +++ /dev/null @@ -1,37 +0,0 @@ -package ai.openclaw.android - -import android.app.Application -import android.os.StrictMode -import android.util.Log -import java.security.Security - -class NodeApp : Application() { - val runtime: NodeRuntime by lazy { NodeRuntime(this) } - - override fun onCreate() { - super.onCreate() - // Register Bouncy Castle as highest-priority provider for Ed25519 support - try { - val bcProvider = Class.forName("org.bouncycastle.jce.provider.BouncyCastleProvider") - .getDeclaredConstructor().newInstance() as java.security.Provider - Security.removeProvider("BC") - Security.insertProviderAt(bcProvider, 1) - } catch (it: Throwable) { - Log.e("NodeApp", "Failed to register Bouncy Castle provider", it) - } - if (BuildConfig.DEBUG) { - StrictMode.setThreadPolicy( - StrictMode.ThreadPolicy.Builder() - .detectAll() - .penaltyLog() - .build(), - ) - StrictMode.setVmPolicy( - StrictMode.VmPolicy.Builder() - .detectAll() - .penaltyLog() - .build(), - ) - } - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/NodeForegroundService.kt b/apps/android/app/src/main/java/ai/openclaw/android/NodeForegroundService.kt deleted file mode 100644 index ee7c8e00674..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/NodeForegroundService.kt +++ /dev/null @@ -1,180 +0,0 @@ -package ai.openclaw.android - -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.Service -import android.app.PendingIntent -import android.Manifest -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.content.pm.ServiceInfo -import androidx.core.app.NotificationCompat -import androidx.core.content.ContextCompat -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.launch - -class NodeForegroundService : Service() { - private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) - private var notificationJob: Job? = null - private var lastRequiresMic = false - private var didStartForeground = false - - override fun onCreate() { - super.onCreate() - ensureChannel() - val initial = buildNotification(title = "OpenClaw Node", text = "Starting…") - startForegroundWithTypes(notification = initial, requiresMic = false) - - val runtime = (application as NodeApp).runtime - notificationJob = - scope.launch { - combine( - runtime.statusText, - runtime.serverName, - runtime.isConnected, - runtime.voiceWakeMode, - runtime.voiceWakeIsListening, - ) { status, server, connected, voiceMode, voiceListening -> - Quint(status, server, connected, voiceMode, voiceListening) - }.collect { (status, server, connected, voiceMode, voiceListening) -> - val title = if (connected) "OpenClaw Node · Connected" else "OpenClaw Node" - val voiceSuffix = - if (voiceMode == VoiceWakeMode.Always) { - if (voiceListening) " · Voice Wake: Listening" else " · Voice Wake: Paused" - } else { - "" - } - val text = (server?.let { "$status · $it" } ?: status) + voiceSuffix - - val requiresMic = - voiceMode == VoiceWakeMode.Always && hasRecordAudioPermission() - startForegroundWithTypes( - notification = buildNotification(title = title, text = text), - requiresMic = requiresMic, - ) - } - } - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - when (intent?.action) { - ACTION_STOP -> { - (application as NodeApp).runtime.disconnect() - stopSelf() - return START_NOT_STICKY - } - } - // Keep running; connection is managed by NodeRuntime (auto-reconnect + manual). - return START_STICKY - } - - override fun onDestroy() { - notificationJob?.cancel() - scope.cancel() - super.onDestroy() - } - - override fun onBind(intent: Intent?) = null - - private fun ensureChannel() { - val mgr = getSystemService(NotificationManager::class.java) - val channel = - NotificationChannel( - CHANNEL_ID, - "Connection", - NotificationManager.IMPORTANCE_LOW, - ).apply { - description = "OpenClaw node connection status" - setShowBadge(false) - } - mgr.createNotificationChannel(channel) - } - - private fun buildNotification(title: String, text: String): Notification { - val launchIntent = Intent(this, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP - } - val launchPending = - PendingIntent.getActivity( - this, - 1, - launchIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ) - - val stopIntent = Intent(this, NodeForegroundService::class.java).setAction(ACTION_STOP) - val stopPending = - PendingIntent.getService( - this, - 2, - stopIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ) - - return NotificationCompat.Builder(this, CHANNEL_ID) - .setSmallIcon(R.mipmap.ic_launcher) - .setContentTitle(title) - .setContentText(text) - .setContentIntent(launchPending) - .setOngoing(true) - .setOnlyAlertOnce(true) - .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) - .addAction(0, "Disconnect", stopPending) - .build() - } - - private fun updateNotification(notification: Notification) { - val mgr = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - mgr.notify(NOTIFICATION_ID, notification) - } - - private fun startForegroundWithTypes(notification: Notification, requiresMic: Boolean) { - if (didStartForeground && requiresMic == lastRequiresMic) { - updateNotification(notification) - return - } - - lastRequiresMic = requiresMic - val types = - if (requiresMic) { - ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE - } else { - ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC - } - startForeground(NOTIFICATION_ID, notification, types) - didStartForeground = true - } - - private fun hasRecordAudioPermission(): Boolean { - return ( - ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == - PackageManager.PERMISSION_GRANTED - ) - } - - companion object { - private const val CHANNEL_ID = "connection" - private const val NOTIFICATION_ID = 1 - - private const val ACTION_STOP = "ai.openclaw.android.action.STOP" - - fun start(context: Context) { - val intent = Intent(context, NodeForegroundService::class.java) - context.startForegroundService(intent) - } - - fun stop(context: Context) { - val intent = Intent(context, NodeForegroundService::class.java).setAction(ACTION_STOP) - context.startService(intent) - } - } -} - -private data class Quint(val first: A, val second: B, val third: C, val fourth: D, val fifth: E) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt deleted file mode 100644 index aec192c25bb..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt +++ /dev/null @@ -1,753 +0,0 @@ -package ai.openclaw.android - -import android.Manifest -import android.content.Context -import android.content.pm.PackageManager -import android.os.SystemClock -import androidx.core.content.ContextCompat -import ai.openclaw.android.chat.ChatController -import ai.openclaw.android.chat.ChatMessage -import ai.openclaw.android.chat.ChatPendingToolCall -import ai.openclaw.android.chat.ChatSessionEntry -import ai.openclaw.android.chat.OutgoingAttachment -import ai.openclaw.android.gateway.DeviceAuthStore -import ai.openclaw.android.gateway.DeviceIdentityStore -import ai.openclaw.android.gateway.GatewayDiscovery -import ai.openclaw.android.gateway.GatewayEndpoint -import ai.openclaw.android.gateway.GatewaySession -import ai.openclaw.android.gateway.probeGatewayTlsFingerprint -import ai.openclaw.android.node.* -import ai.openclaw.android.protocol.OpenClawCanvasA2UIAction -import ai.openclaw.android.voice.TalkModeManager -import ai.openclaw.android.voice.VoiceWakeManager -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.launch -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.buildJsonObject -import java.util.concurrent.atomic.AtomicLong - -class NodeRuntime(context: Context) { - private val appContext = context.applicationContext - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - - val prefs = SecurePrefs(appContext) - private val deviceAuthStore = DeviceAuthStore(prefs) - val canvas = CanvasController() - val camera = CameraCaptureManager(appContext) - val location = LocationCaptureManager(appContext) - val screenRecorder = ScreenRecordManager(appContext) - val sms = SmsManager(appContext) - private val json = Json { ignoreUnknownKeys = true } - - private val externalAudioCaptureActive = MutableStateFlow(false) - - private val voiceWake: VoiceWakeManager by lazy { - VoiceWakeManager( - context = appContext, - scope = scope, - onCommand = { command -> - nodeSession.sendNodeEvent( - event = "agent.request", - payloadJson = - buildJsonObject { - put("message", JsonPrimitive(command)) - put("sessionKey", JsonPrimitive(resolveMainSessionKey())) - put("thinking", JsonPrimitive(chatThinkingLevel.value)) - put("deliver", JsonPrimitive(false)) - }.toString(), - ) - }, - ) - } - - val voiceWakeIsListening: StateFlow - get() = voiceWake.isListening - - val voiceWakeStatusText: StateFlow - get() = voiceWake.statusText - - val talkStatusText: StateFlow - get() = talkMode.statusText - - val talkIsListening: StateFlow - get() = talkMode.isListening - - val talkIsSpeaking: StateFlow - get() = talkMode.isSpeaking - - private val discovery = GatewayDiscovery(appContext, scope = scope) - val gateways: StateFlow> = discovery.gateways - val discoveryStatusText: StateFlow = discovery.statusText - - private val identityStore = DeviceIdentityStore(appContext) - private var connectedEndpoint: GatewayEndpoint? = null - - private val cameraHandler: CameraHandler = CameraHandler( - appContext = appContext, - camera = camera, - prefs = prefs, - connectedEndpoint = { connectedEndpoint }, - externalAudioCaptureActive = externalAudioCaptureActive, - showCameraHud = ::showCameraHud, - triggerCameraFlash = ::triggerCameraFlash, - invokeErrorFromThrowable = { invokeErrorFromThrowable(it) }, - ) - - private val debugHandler: DebugHandler = DebugHandler( - appContext = appContext, - identityStore = identityStore, - ) - - private val appUpdateHandler: AppUpdateHandler = AppUpdateHandler( - appContext = appContext, - connectedEndpoint = { connectedEndpoint }, - ) - - private val locationHandler: LocationHandler = LocationHandler( - appContext = appContext, - location = location, - json = json, - isForeground = { _isForeground.value }, - locationMode = { locationMode.value }, - locationPreciseEnabled = { locationPreciseEnabled.value }, - ) - - private val screenHandler: ScreenHandler = ScreenHandler( - screenRecorder = screenRecorder, - setScreenRecordActive = { _screenRecordActive.value = it }, - invokeErrorFromThrowable = { invokeErrorFromThrowable(it) }, - ) - - private val smsHandlerImpl: SmsHandler = SmsHandler( - sms = sms, - ) - - private val a2uiHandler: A2UIHandler = A2UIHandler( - canvas = canvas, - json = json, - getNodeCanvasHostUrl = { nodeSession.currentCanvasHostUrl() }, - getOperatorCanvasHostUrl = { operatorSession.currentCanvasHostUrl() }, - ) - - private val connectionManager: ConnectionManager = ConnectionManager( - prefs = prefs, - cameraEnabled = { cameraEnabled.value }, - locationMode = { locationMode.value }, - voiceWakeMode = { voiceWakeMode.value }, - smsAvailable = { sms.canSendSms() }, - hasRecordAudioPermission = { hasRecordAudioPermission() }, - manualTls = { manualTls.value }, - ) - - private val invokeDispatcher: InvokeDispatcher = InvokeDispatcher( - canvas = canvas, - cameraHandler = cameraHandler, - locationHandler = locationHandler, - screenHandler = screenHandler, - smsHandler = smsHandlerImpl, - a2uiHandler = a2uiHandler, - debugHandler = debugHandler, - appUpdateHandler = appUpdateHandler, - isForeground = { _isForeground.value }, - cameraEnabled = { cameraEnabled.value }, - locationEnabled = { locationMode.value != LocationMode.Off }, - ) - - private lateinit var gatewayEventHandler: GatewayEventHandler - - data class GatewayTrustPrompt( - val endpoint: GatewayEndpoint, - val fingerprintSha256: String, - ) - - private val _isConnected = MutableStateFlow(false) - val isConnected: StateFlow = _isConnected.asStateFlow() - - private val _statusText = MutableStateFlow("Offline") - val statusText: StateFlow = _statusText.asStateFlow() - - private val _pendingGatewayTrust = MutableStateFlow(null) - val pendingGatewayTrust: StateFlow = _pendingGatewayTrust.asStateFlow() - - private val _mainSessionKey = MutableStateFlow("main") - val mainSessionKey: StateFlow = _mainSessionKey.asStateFlow() - - private val cameraHudSeq = AtomicLong(0) - private val _cameraHud = MutableStateFlow(null) - val cameraHud: StateFlow = _cameraHud.asStateFlow() - - private val _cameraFlashToken = MutableStateFlow(0L) - val cameraFlashToken: StateFlow = _cameraFlashToken.asStateFlow() - - private val _screenRecordActive = MutableStateFlow(false) - val screenRecordActive: StateFlow = _screenRecordActive.asStateFlow() - - private val _serverName = MutableStateFlow(null) - val serverName: StateFlow = _serverName.asStateFlow() - - private val _remoteAddress = MutableStateFlow(null) - val remoteAddress: StateFlow = _remoteAddress.asStateFlow() - - private val _seamColorArgb = MutableStateFlow(DEFAULT_SEAM_COLOR_ARGB) - val seamColorArgb: StateFlow = _seamColorArgb.asStateFlow() - - private val _isForeground = MutableStateFlow(true) - val isForeground: StateFlow = _isForeground.asStateFlow() - - private var lastAutoA2uiUrl: String? = null - private var operatorConnected = false - private var nodeConnected = false - private var operatorStatusText: String = "Offline" - private var nodeStatusText: String = "Offline" - - private val operatorSession = - GatewaySession( - scope = scope, - identityStore = identityStore, - deviceAuthStore = deviceAuthStore, - onConnected = { name, remote, mainSessionKey -> - operatorConnected = true - operatorStatusText = "Connected" - _serverName.value = name - _remoteAddress.value = remote - _seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB - applyMainSessionKey(mainSessionKey) - updateStatus() - scope.launch { refreshBrandingFromGateway() } - scope.launch { gatewayEventHandler.refreshWakeWordsFromGateway() } - }, - onDisconnected = { message -> - operatorConnected = false - operatorStatusText = message - _serverName.value = null - _remoteAddress.value = null - _seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB - if (!isCanonicalMainSessionKey(_mainSessionKey.value)) { - _mainSessionKey.value = "main" - } - val mainKey = resolveMainSessionKey() - talkMode.setMainSessionKey(mainKey) - chat.applyMainSessionKey(mainKey) - chat.onDisconnected(message) - updateStatus() - }, - onEvent = { event, payloadJson -> - handleGatewayEvent(event, payloadJson) - }, - ) - - private val nodeSession = - GatewaySession( - scope = scope, - identityStore = identityStore, - deviceAuthStore = deviceAuthStore, - onConnected = { _, _, _ -> - nodeConnected = true - nodeStatusText = "Connected" - updateStatus() - maybeNavigateToA2uiOnConnect() - }, - onDisconnected = { message -> - nodeConnected = false - nodeStatusText = message - updateStatus() - showLocalCanvasOnDisconnect() - }, - onEvent = { _, _ -> }, - onInvoke = { req -> - invokeDispatcher.handleInvoke(req.command, req.paramsJson) - }, - onTlsFingerprint = { stableId, fingerprint -> - prefs.saveGatewayTlsFingerprint(stableId, fingerprint) - }, - ) - - private val chat: ChatController = - ChatController( - scope = scope, - session = operatorSession, - json = json, - supportsChatSubscribe = false, - ) - private val talkMode: TalkModeManager by lazy { - TalkModeManager( - context = appContext, - scope = scope, - session = operatorSession, - supportsChatSubscribe = false, - isConnected = { operatorConnected }, - ) - } - - private fun applyMainSessionKey(candidate: String?) { - val trimmed = normalizeMainKey(candidate) ?: return - if (isCanonicalMainSessionKey(_mainSessionKey.value)) return - if (_mainSessionKey.value == trimmed) return - _mainSessionKey.value = trimmed - talkMode.setMainSessionKey(trimmed) - chat.applyMainSessionKey(trimmed) - } - - private fun updateStatus() { - _isConnected.value = operatorConnected - _statusText.value = - when { - operatorConnected && nodeConnected -> "Connected" - operatorConnected && !nodeConnected -> "Connected (node offline)" - !operatorConnected && nodeConnected -> "Connected (operator offline)" - operatorStatusText.isNotBlank() && operatorStatusText != "Offline" -> operatorStatusText - else -> nodeStatusText - } - } - - private fun resolveMainSessionKey(): String { - val trimmed = _mainSessionKey.value.trim() - return if (trimmed.isEmpty()) "main" else trimmed - } - - private fun maybeNavigateToA2uiOnConnect() { - val a2uiUrl = a2uiHandler.resolveA2uiHostUrl() ?: return - val current = canvas.currentUrl()?.trim().orEmpty() - if (current.isEmpty() || current == lastAutoA2uiUrl) { - lastAutoA2uiUrl = a2uiUrl - canvas.navigate(a2uiUrl) - } - } - - private fun showLocalCanvasOnDisconnect() { - lastAutoA2uiUrl = null - canvas.navigate("") - } - - val instanceId: StateFlow = prefs.instanceId - val displayName: StateFlow = prefs.displayName - val cameraEnabled: StateFlow = prefs.cameraEnabled - val locationMode: StateFlow = prefs.locationMode - val locationPreciseEnabled: StateFlow = prefs.locationPreciseEnabled - val preventSleep: StateFlow = prefs.preventSleep - val wakeWords: StateFlow> = prefs.wakeWords - val voiceWakeMode: StateFlow = prefs.voiceWakeMode - val talkEnabled: StateFlow = prefs.talkEnabled - val manualEnabled: StateFlow = prefs.manualEnabled - val manualHost: StateFlow = prefs.manualHost - val manualPort: StateFlow = prefs.manualPort - val manualTls: StateFlow = prefs.manualTls - val gatewayToken: StateFlow = prefs.gatewayToken - fun setGatewayToken(value: String) = prefs.setGatewayToken(value) - val lastDiscoveredStableId: StateFlow = prefs.lastDiscoveredStableId - val canvasDebugStatusEnabled: StateFlow = prefs.canvasDebugStatusEnabled - - private var didAutoConnect = false - - val chatSessionKey: StateFlow = chat.sessionKey - val chatSessionId: StateFlow = chat.sessionId - val chatMessages: StateFlow> = chat.messages - val chatError: StateFlow = chat.errorText - val chatHealthOk: StateFlow = chat.healthOk - val chatThinkingLevel: StateFlow = chat.thinkingLevel - val chatStreamingAssistantText: StateFlow = chat.streamingAssistantText - val chatPendingToolCalls: StateFlow> = chat.pendingToolCalls - val chatSessions: StateFlow> = chat.sessions - val pendingRunCount: StateFlow = chat.pendingRunCount - - init { - gatewayEventHandler = GatewayEventHandler( - scope = scope, - prefs = prefs, - json = json, - operatorSession = operatorSession, - isConnected = { _isConnected.value }, - ) - - scope.launch { - combine( - voiceWakeMode, - isForeground, - externalAudioCaptureActive, - wakeWords, - ) { mode, foreground, externalAudio, words -> - Quad(mode, foreground, externalAudio, words) - }.distinctUntilChanged() - .collect { (mode, foreground, externalAudio, words) -> - voiceWake.setTriggerWords(words) - - val shouldListen = - when (mode) { - VoiceWakeMode.Off -> false - VoiceWakeMode.Foreground -> foreground - VoiceWakeMode.Always -> true - } && !externalAudio - - if (!shouldListen) { - voiceWake.stop(statusText = if (mode == VoiceWakeMode.Off) "Off" else "Paused") - return@collect - } - - if (!hasRecordAudioPermission()) { - voiceWake.stop(statusText = "Microphone permission required") - return@collect - } - - voiceWake.start() - } - } - - scope.launch { - talkEnabled.collect { enabled -> - talkMode.setEnabled(enabled) - externalAudioCaptureActive.value = enabled - } - } - - scope.launch(Dispatchers.Default) { - gateways.collect { list -> - if (list.isNotEmpty()) { - // Security: don't let an unauthenticated discovery feed continuously steer autoconnect. - // UX parity with iOS: only set once when unset. - if (lastDiscoveredStableId.value.trim().isEmpty()) { - prefs.setLastDiscoveredStableId(list.first().stableId) - } - } - - if (didAutoConnect) return@collect - if (_isConnected.value) return@collect - - if (manualEnabled.value) { - val host = manualHost.value.trim() - val port = manualPort.value - if (host.isNotEmpty() && port in 1..65535) { - // Security: autoconnect only to previously trusted gateways (stored TLS pin). - if (!manualTls.value) return@collect - val stableId = GatewayEndpoint.manual(host = host, port = port).stableId - val storedFingerprint = prefs.loadGatewayTlsFingerprint(stableId)?.trim().orEmpty() - if (storedFingerprint.isEmpty()) return@collect - - didAutoConnect = true - connect(GatewayEndpoint.manual(host = host, port = port)) - } - return@collect - } - - val targetStableId = lastDiscoveredStableId.value.trim() - if (targetStableId.isEmpty()) return@collect - val target = list.firstOrNull { it.stableId == targetStableId } ?: return@collect - - // Security: autoconnect only to previously trusted gateways (stored TLS pin). - val storedFingerprint = prefs.loadGatewayTlsFingerprint(target.stableId)?.trim().orEmpty() - if (storedFingerprint.isEmpty()) return@collect - - didAutoConnect = true - connect(target) - } - } - - scope.launch { - combine( - canvasDebugStatusEnabled, - statusText, - serverName, - remoteAddress, - ) { debugEnabled, status, server, remote -> - Quad(debugEnabled, status, server, remote) - }.distinctUntilChanged() - .collect { (debugEnabled, status, server, remote) -> - canvas.setDebugStatusEnabled(debugEnabled) - if (!debugEnabled) return@collect - canvas.setDebugStatus(status, server ?: remote) - } - } - } - - fun setForeground(value: Boolean) { - _isForeground.value = value - } - - fun setDisplayName(value: String) { - prefs.setDisplayName(value) - } - - fun setCameraEnabled(value: Boolean) { - prefs.setCameraEnabled(value) - } - - fun setLocationMode(mode: LocationMode) { - prefs.setLocationMode(mode) - } - - fun setLocationPreciseEnabled(value: Boolean) { - prefs.setLocationPreciseEnabled(value) - } - - fun setPreventSleep(value: Boolean) { - prefs.setPreventSleep(value) - } - - fun setManualEnabled(value: Boolean) { - prefs.setManualEnabled(value) - } - - fun setManualHost(value: String) { - prefs.setManualHost(value) - } - - fun setManualPort(value: Int) { - prefs.setManualPort(value) - } - - fun setManualTls(value: Boolean) { - prefs.setManualTls(value) - } - - fun setCanvasDebugStatusEnabled(value: Boolean) { - prefs.setCanvasDebugStatusEnabled(value) - } - - fun setWakeWords(words: List) { - prefs.setWakeWords(words) - gatewayEventHandler.scheduleWakeWordsSyncIfNeeded() - } - - fun resetWakeWordsDefaults() { - setWakeWords(SecurePrefs.defaultWakeWords) - } - - fun setVoiceWakeMode(mode: VoiceWakeMode) { - prefs.setVoiceWakeMode(mode) - } - - fun setTalkEnabled(value: Boolean) { - prefs.setTalkEnabled(value) - } - - fun refreshGatewayConnection() { - val endpoint = connectedEndpoint ?: return - val token = prefs.loadGatewayToken() - val password = prefs.loadGatewayPassword() - val tls = connectionManager.resolveTlsParams(endpoint) - operatorSession.connect(endpoint, token, password, connectionManager.buildOperatorConnectOptions(), tls) - nodeSession.connect(endpoint, token, password, connectionManager.buildNodeConnectOptions(), tls) - operatorSession.reconnect() - nodeSession.reconnect() - } - - fun connect(endpoint: GatewayEndpoint) { - val tls = connectionManager.resolveTlsParams(endpoint) - if (tls?.required == true && tls.expectedFingerprint.isNullOrBlank()) { - // First-time TLS: capture fingerprint, ask user to verify out-of-band, then store and connect. - _statusText.value = "Verify gateway TLS fingerprint…" - scope.launch { - val fp = probeGatewayTlsFingerprint(endpoint.host, endpoint.port) ?: run { - _statusText.value = "Failed: can't read TLS fingerprint" - return@launch - } - _pendingGatewayTrust.value = GatewayTrustPrompt(endpoint = endpoint, fingerprintSha256 = fp) - } - return - } - - connectedEndpoint = endpoint - operatorStatusText = "Connecting…" - nodeStatusText = "Connecting…" - updateStatus() - val token = prefs.loadGatewayToken() - val password = prefs.loadGatewayPassword() - operatorSession.connect(endpoint, token, password, connectionManager.buildOperatorConnectOptions(), tls) - nodeSession.connect(endpoint, token, password, connectionManager.buildNodeConnectOptions(), tls) - } - - fun acceptGatewayTrustPrompt() { - val prompt = _pendingGatewayTrust.value ?: return - _pendingGatewayTrust.value = null - prefs.saveGatewayTlsFingerprint(prompt.endpoint.stableId, prompt.fingerprintSha256) - connect(prompt.endpoint) - } - - fun declineGatewayTrustPrompt() { - _pendingGatewayTrust.value = null - _statusText.value = "Offline" - } - - private fun hasRecordAudioPermission(): Boolean { - return ( - ContextCompat.checkSelfPermission(appContext, Manifest.permission.RECORD_AUDIO) == - PackageManager.PERMISSION_GRANTED - ) - } - - fun connectManual() { - val host = manualHost.value.trim() - val port = manualPort.value - if (host.isEmpty() || port <= 0 || port > 65535) { - _statusText.value = "Failed: invalid manual host/port" - return - } - connect(GatewayEndpoint.manual(host = host, port = port)) - } - - fun disconnect() { - connectedEndpoint = null - _pendingGatewayTrust.value = null - operatorSession.disconnect() - nodeSession.disconnect() - } - - fun handleCanvasA2UIActionFromWebView(payloadJson: String) { - scope.launch { - val trimmed = payloadJson.trim() - if (trimmed.isEmpty()) return@launch - - val root = - try { - json.parseToJsonElement(trimmed).asObjectOrNull() ?: return@launch - } catch (_: Throwable) { - return@launch - } - - val userActionObj = (root["userAction"] as? JsonObject) ?: root - val actionId = (userActionObj["id"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { - java.util.UUID.randomUUID().toString() - } - val name = OpenClawCanvasA2UIAction.extractActionName(userActionObj) ?: return@launch - - val surfaceId = - (userActionObj["surfaceId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "main" } - val sourceComponentId = - (userActionObj["sourceComponentId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "-" } - val contextJson = (userActionObj["context"] as? JsonObject)?.toString() - - val sessionKey = resolveMainSessionKey() - val message = - OpenClawCanvasA2UIAction.formatAgentMessage( - actionName = name, - sessionKey = sessionKey, - surfaceId = surfaceId, - sourceComponentId = sourceComponentId, - host = displayName.value, - instanceId = instanceId.value.lowercase(), - contextJson = contextJson, - ) - - val connected = nodeConnected - var error: String? = null - if (connected) { - try { - nodeSession.sendNodeEvent( - event = "agent.request", - payloadJson = - buildJsonObject { - put("message", JsonPrimitive(message)) - put("sessionKey", JsonPrimitive(sessionKey)) - put("thinking", JsonPrimitive("low")) - put("deliver", JsonPrimitive(false)) - put("key", JsonPrimitive(actionId)) - }.toString(), - ) - } catch (e: Throwable) { - error = e.message ?: "send failed" - } - } else { - error = "gateway not connected" - } - - try { - canvas.eval( - OpenClawCanvasA2UIAction.jsDispatchA2UIActionStatus( - actionId = actionId, - ok = connected && error == null, - error = error, - ), - ) - } catch (_: Throwable) { - // ignore - } - } - } - - fun loadChat(sessionKey: String) { - val key = sessionKey.trim().ifEmpty { resolveMainSessionKey() } - chat.load(key) - } - - fun refreshChat() { - chat.refresh() - } - - fun refreshChatSessions(limit: Int? = null) { - chat.refreshSessions(limit = limit) - } - - fun setChatThinkingLevel(level: String) { - chat.setThinkingLevel(level) - } - - fun switchChatSession(sessionKey: String) { - chat.switchSession(sessionKey) - } - - fun abortChat() { - chat.abort() - } - - fun sendChat(message: String, thinking: String, attachments: List) { - chat.sendMessage(message = message, thinkingLevel = thinking, attachments = attachments) - } - - private fun handleGatewayEvent(event: String, payloadJson: String?) { - if (event == "voicewake.changed") { - gatewayEventHandler.handleVoiceWakeChangedEvent(payloadJson) - return - } - - talkMode.handleGatewayEvent(event, payloadJson) - chat.handleGatewayEvent(event, payloadJson) - } - - private suspend fun refreshBrandingFromGateway() { - if (!_isConnected.value) return - try { - val res = operatorSession.request("config.get", "{}") - val root = json.parseToJsonElement(res).asObjectOrNull() - val config = root?.get("config").asObjectOrNull() - val ui = config?.get("ui").asObjectOrNull() - val raw = ui?.get("seamColor").asStringOrNull()?.trim() - val sessionCfg = config?.get("session").asObjectOrNull() - val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull()) - applyMainSessionKey(mainKey) - - val parsed = parseHexColorArgb(raw) - _seamColorArgb.value = parsed ?: DEFAULT_SEAM_COLOR_ARGB - } catch (_: Throwable) { - // ignore - } - } - - private fun triggerCameraFlash() { - // Token is used as a pulse trigger; value doesn't matter as long as it changes. - _cameraFlashToken.value = SystemClock.elapsedRealtimeNanos() - } - - private fun showCameraHud(message: String, kind: CameraHudKind, autoHideMs: Long? = null) { - val token = cameraHudSeq.incrementAndGet() - _cameraHud.value = CameraHudState(token = token, kind = kind, message = message) - - if (autoHideMs != null && autoHideMs > 0) { - scope.launch { - delay(autoHideMs) - if (_cameraHud.value?.token == token) _cameraHud.value = null - } - } - } - -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/PermissionRequester.kt b/apps/android/app/src/main/java/ai/openclaw/android/PermissionRequester.kt deleted file mode 100644 index 0ee267b5588..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/PermissionRequester.kt +++ /dev/null @@ -1,133 +0,0 @@ -package ai.openclaw.android - -import android.content.pm.PackageManager -import android.content.Intent -import android.Manifest -import android.net.Uri -import android.provider.Settings -import androidx.appcompat.app.AlertDialog -import androidx.activity.ComponentActivity -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.content.ContextCompat -import androidx.core.app.ActivityCompat -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlin.coroutines.resume - -class PermissionRequester(private val activity: ComponentActivity) { - private val mutex = Mutex() - private var pending: CompletableDeferred>? = null - - private val launcher: ActivityResultLauncher> = - activity.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result -> - val p = pending - pending = null - p?.complete(result) - } - - suspend fun requestIfMissing( - permissions: List, - timeoutMs: Long = 20_000, - ): Map = - mutex.withLock { - val missing = - permissions.filter { perm -> - ContextCompat.checkSelfPermission(activity, perm) != PackageManager.PERMISSION_GRANTED - } - if (missing.isEmpty()) { - return permissions.associateWith { true } - } - - val needsRationale = - missing.any { ActivityCompat.shouldShowRequestPermissionRationale(activity, it) } - if (needsRationale) { - val proceed = showRationaleDialog(missing) - if (!proceed) { - return permissions.associateWith { perm -> - ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED - } - } - } - - val deferred = CompletableDeferred>() - pending = deferred - withContext(Dispatchers.Main) { - launcher.launch(missing.toTypedArray()) - } - - val result = - withContext(Dispatchers.Default) { - kotlinx.coroutines.withTimeout(timeoutMs) { deferred.await() } - } - - // Merge: if something was already granted, treat it as granted even if launcher omitted it. - val merged = - permissions.associateWith { perm -> - val nowGranted = - ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED - result[perm] == true || nowGranted - } - - val denied = - merged.filterValues { !it }.keys.filter { - !ActivityCompat.shouldShowRequestPermissionRationale(activity, it) - } - if (denied.isNotEmpty()) { - showSettingsDialog(denied) - } - - return merged - } - - private suspend fun showRationaleDialog(permissions: List): Boolean = - withContext(Dispatchers.Main) { - suspendCancellableCoroutine { cont -> - AlertDialog.Builder(activity) - .setTitle("Permission required") - .setMessage(buildRationaleMessage(permissions)) - .setPositiveButton("Continue") { _, _ -> cont.resume(true) } - .setNegativeButton("Not now") { _, _ -> cont.resume(false) } - .setOnCancelListener { cont.resume(false) } - .show() - } - } - - private fun showSettingsDialog(permissions: List) { - AlertDialog.Builder(activity) - .setTitle("Enable permission in Settings") - .setMessage(buildSettingsMessage(permissions)) - .setPositiveButton("Open Settings") { _, _ -> - val intent = - Intent( - Settings.ACTION_APPLICATION_DETAILS_SETTINGS, - Uri.fromParts("package", activity.packageName, null), - ) - activity.startActivity(intent) - } - .setNegativeButton("Cancel", null) - .show() - } - - private fun buildRationaleMessage(permissions: List): String { - val labels = permissions.map { permissionLabel(it) } - return "OpenClaw needs ${labels.joinToString(", ")} permissions to continue." - } - - private fun buildSettingsMessage(permissions: List): String { - val labels = permissions.map { permissionLabel(it) } - return "Please enable ${labels.joinToString(", ")} in Android Settings to continue." - } - - private fun permissionLabel(permission: String): String = - when (permission) { - Manifest.permission.CAMERA -> "Camera" - Manifest.permission.RECORD_AUDIO -> "Microphone" - Manifest.permission.SEND_SMS -> "SMS" - else -> permission - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ScreenCaptureRequester.kt b/apps/android/app/src/main/java/ai/openclaw/android/ScreenCaptureRequester.kt deleted file mode 100644 index c215103b54d..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/ScreenCaptureRequester.kt +++ /dev/null @@ -1,65 +0,0 @@ -package ai.openclaw.android - -import android.app.Activity -import android.content.Context -import android.content.Intent -import android.media.projection.MediaProjectionManager -import androidx.activity.ComponentActivity -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AlertDialog -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlin.coroutines.resume - -class ScreenCaptureRequester(private val activity: ComponentActivity) { - data class CaptureResult(val resultCode: Int, val data: Intent) - - private val mutex = Mutex() - private var pending: CompletableDeferred? = null - - private val launcher: ActivityResultLauncher = - activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - val p = pending - pending = null - val data = result.data - if (result.resultCode == Activity.RESULT_OK && data != null) { - p?.complete(CaptureResult(result.resultCode, data)) - } else { - p?.complete(null) - } - } - - suspend fun requestCapture(timeoutMs: Long = 20_000): CaptureResult? = - mutex.withLock { - val proceed = showRationaleDialog() - if (!proceed) return null - - val mgr = activity.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager - val intent = mgr.createScreenCaptureIntent() - - val deferred = CompletableDeferred() - pending = deferred - withContext(Dispatchers.Main) { launcher.launch(intent) } - - withContext(Dispatchers.Default) { withTimeout(timeoutMs) { deferred.await() } } - } - - private suspend fun showRationaleDialog(): Boolean = - withContext(Dispatchers.Main) { - suspendCancellableCoroutine { cont -> - AlertDialog.Builder(activity) - .setTitle("Screen recording required") - .setMessage("OpenClaw needs to record the screen for this command.") - .setPositiveButton("Continue") { _, _ -> cont.resume(true) } - .setNegativeButton("Not now") { _, _ -> cont.resume(false) } - .setOnCancelListener { cont.resume(false) } - .show() - } - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt b/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt deleted file mode 100644 index 29ef4a3eaae..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt +++ /dev/null @@ -1,285 +0,0 @@ -@file:Suppress("DEPRECATION") - -package ai.openclaw.android - -import android.content.Context -import android.content.SharedPreferences -import androidx.core.content.edit -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonPrimitive -import java.util.UUID - -class SecurePrefs(context: Context) { - companion object { - val defaultWakeWords: List = listOf("openclaw", "claude") - private const val displayNameKey = "node.displayName" - private const val voiceWakeModeKey = "voiceWake.mode" - } - - private val appContext = context.applicationContext - private val json = Json { ignoreUnknownKeys = true } - - private val masterKey = - MasterKey.Builder(context) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() - - private val prefs: SharedPreferences by lazy { - createPrefs(appContext, "openclaw.node.secure") - } - - private val _instanceId = MutableStateFlow(loadOrCreateInstanceId()) - val instanceId: StateFlow = _instanceId - - private val _displayName = - MutableStateFlow(loadOrMigrateDisplayName(context = context)) - val displayName: StateFlow = _displayName - - private val _cameraEnabled = MutableStateFlow(prefs.getBoolean("camera.enabled", true)) - val cameraEnabled: StateFlow = _cameraEnabled - - private val _locationMode = - MutableStateFlow(LocationMode.fromRawValue(prefs.getString("location.enabledMode", "off"))) - val locationMode: StateFlow = _locationMode - - private val _locationPreciseEnabled = - MutableStateFlow(prefs.getBoolean("location.preciseEnabled", true)) - val locationPreciseEnabled: StateFlow = _locationPreciseEnabled - - private val _preventSleep = MutableStateFlow(prefs.getBoolean("screen.preventSleep", true)) - val preventSleep: StateFlow = _preventSleep - - private val _manualEnabled = - MutableStateFlow(prefs.getBoolean("gateway.manual.enabled", false)) - val manualEnabled: StateFlow = _manualEnabled - - private val _manualHost = - MutableStateFlow(prefs.getString("gateway.manual.host", "") ?: "") - val manualHost: StateFlow = _manualHost - - private val _manualPort = - MutableStateFlow(prefs.getInt("gateway.manual.port", 18789)) - val manualPort: StateFlow = _manualPort - - private val _manualTls = - MutableStateFlow(prefs.getBoolean("gateway.manual.tls", true)) - val manualTls: StateFlow = _manualTls - - private val _gatewayToken = - MutableStateFlow(prefs.getString("gateway.manual.token", "") ?: "") - val gatewayToken: StateFlow = _gatewayToken - - private val _lastDiscoveredStableId = - MutableStateFlow( - prefs.getString("gateway.lastDiscoveredStableID", "") ?: "", - ) - val lastDiscoveredStableId: StateFlow = _lastDiscoveredStableId - - private val _canvasDebugStatusEnabled = - MutableStateFlow(prefs.getBoolean("canvas.debugStatusEnabled", false)) - val canvasDebugStatusEnabled: StateFlow = _canvasDebugStatusEnabled - - private val _wakeWords = MutableStateFlow(loadWakeWords()) - val wakeWords: StateFlow> = _wakeWords - - private val _voiceWakeMode = MutableStateFlow(loadVoiceWakeMode()) - val voiceWakeMode: StateFlow = _voiceWakeMode - - private val _talkEnabled = MutableStateFlow(prefs.getBoolean("talk.enabled", false)) - val talkEnabled: StateFlow = _talkEnabled - - fun setLastDiscoveredStableId(value: String) { - val trimmed = value.trim() - prefs.edit { putString("gateway.lastDiscoveredStableID", trimmed) } - _lastDiscoveredStableId.value = trimmed - } - - fun setDisplayName(value: String) { - val trimmed = value.trim() - prefs.edit { putString(displayNameKey, trimmed) } - _displayName.value = trimmed - } - - fun setCameraEnabled(value: Boolean) { - prefs.edit { putBoolean("camera.enabled", value) } - _cameraEnabled.value = value - } - - fun setLocationMode(mode: LocationMode) { - prefs.edit { putString("location.enabledMode", mode.rawValue) } - _locationMode.value = mode - } - - fun setLocationPreciseEnabled(value: Boolean) { - prefs.edit { putBoolean("location.preciseEnabled", value) } - _locationPreciseEnabled.value = value - } - - fun setPreventSleep(value: Boolean) { - prefs.edit { putBoolean("screen.preventSleep", value) } - _preventSleep.value = value - } - - fun setManualEnabled(value: Boolean) { - prefs.edit { putBoolean("gateway.manual.enabled", value) } - _manualEnabled.value = value - } - - fun setManualHost(value: String) { - val trimmed = value.trim() - prefs.edit { putString("gateway.manual.host", trimmed) } - _manualHost.value = trimmed - } - - fun setManualPort(value: Int) { - prefs.edit { putInt("gateway.manual.port", value) } - _manualPort.value = value - } - - fun setManualTls(value: Boolean) { - prefs.edit { putBoolean("gateway.manual.tls", value) } - _manualTls.value = value - } - - fun setGatewayToken(value: String) { - prefs.edit { putString("gateway.manual.token", value) } - _gatewayToken.value = value - } - - fun setCanvasDebugStatusEnabled(value: Boolean) { - prefs.edit { putBoolean("canvas.debugStatusEnabled", value) } - _canvasDebugStatusEnabled.value = value - } - - fun loadGatewayToken(): String? { - val manual = _gatewayToken.value.trim() - if (manual.isNotEmpty()) return manual - val key = "gateway.token.${_instanceId.value}" - val stored = prefs.getString(key, null)?.trim() - return stored?.takeIf { it.isNotEmpty() } - } - - fun saveGatewayToken(token: String) { - val key = "gateway.token.${_instanceId.value}" - prefs.edit { putString(key, token.trim()) } - } - - fun loadGatewayPassword(): String? { - val key = "gateway.password.${_instanceId.value}" - val stored = prefs.getString(key, null)?.trim() - return stored?.takeIf { it.isNotEmpty() } - } - - fun saveGatewayPassword(password: String) { - val key = "gateway.password.${_instanceId.value}" - prefs.edit { putString(key, password.trim()) } - } - - fun loadGatewayTlsFingerprint(stableId: String): String? { - val key = "gateway.tls.$stableId" - return prefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() } - } - - fun saveGatewayTlsFingerprint(stableId: String, fingerprint: String) { - val key = "gateway.tls.$stableId" - prefs.edit { putString(key, fingerprint.trim()) } - } - - fun getString(key: String): String? { - return prefs.getString(key, null) - } - - fun putString(key: String, value: String) { - prefs.edit { putString(key, value) } - } - - fun remove(key: String) { - prefs.edit { remove(key) } - } - - private fun createPrefs(context: Context, name: String): SharedPreferences { - return EncryptedSharedPreferences.create( - context, - name, - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, - ) - } - - private fun loadOrCreateInstanceId(): String { - val existing = prefs.getString("node.instanceId", null)?.trim() - if (!existing.isNullOrBlank()) return existing - val fresh = UUID.randomUUID().toString() - prefs.edit { putString("node.instanceId", fresh) } - return fresh - } - - private fun loadOrMigrateDisplayName(context: Context): String { - val existing = prefs.getString(displayNameKey, null)?.trim().orEmpty() - if (existing.isNotEmpty() && existing != "Android Node") return existing - - val candidate = DeviceNames.bestDefaultNodeName(context).trim() - val resolved = candidate.ifEmpty { "Android Node" } - - prefs.edit { putString(displayNameKey, resolved) } - return resolved - } - - fun setWakeWords(words: List) { - val sanitized = WakeWords.sanitize(words, defaultWakeWords) - val encoded = - JsonArray(sanitized.map { JsonPrimitive(it) }).toString() - prefs.edit { putString("voiceWake.triggerWords", encoded) } - _wakeWords.value = sanitized - } - - fun setVoiceWakeMode(mode: VoiceWakeMode) { - prefs.edit { putString(voiceWakeModeKey, mode.rawValue) } - _voiceWakeMode.value = mode - } - - fun setTalkEnabled(value: Boolean) { - prefs.edit { putBoolean("talk.enabled", value) } - _talkEnabled.value = value - } - - private fun loadVoiceWakeMode(): VoiceWakeMode { - val raw = prefs.getString(voiceWakeModeKey, null) - val resolved = VoiceWakeMode.fromRawValue(raw) - - // Default ON (foreground) when unset. - if (raw.isNullOrBlank()) { - prefs.edit { putString(voiceWakeModeKey, resolved.rawValue) } - } - - return resolved - } - - private fun loadWakeWords(): List { - val raw = prefs.getString("voiceWake.triggerWords", null)?.trim() - if (raw.isNullOrEmpty()) return defaultWakeWords - return try { - val element = json.parseToJsonElement(raw) - val array = element as? JsonArray ?: return defaultWakeWords - val decoded = - array.mapNotNull { item -> - when (item) { - is JsonNull -> null - is JsonPrimitive -> item.content.trim().takeIf { it.isNotEmpty() } - else -> null - } - } - WakeWords.sanitize(decoded, defaultWakeWords) - } catch (_: Throwable) { - defaultWakeWords - } - } - -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/SessionKey.kt b/apps/android/app/src/main/java/ai/openclaw/android/SessionKey.kt deleted file mode 100644 index 8148a17029e..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/SessionKey.kt +++ /dev/null @@ -1,13 +0,0 @@ -package ai.openclaw.android - -internal fun normalizeMainKey(raw: String?): String { - val trimmed = raw?.trim() - return if (!trimmed.isNullOrEmpty()) trimmed else "main" -} - -internal fun isCanonicalMainSessionKey(raw: String?): Boolean { - val trimmed = raw?.trim().orEmpty() - if (trimmed.isEmpty()) return false - if (trimmed == "global") return true - return trimmed.startsWith("agent:") -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/VoiceWakeMode.kt b/apps/android/app/src/main/java/ai/openclaw/android/VoiceWakeMode.kt deleted file mode 100644 index 75c2fe34468..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/VoiceWakeMode.kt +++ /dev/null @@ -1,14 +0,0 @@ -package ai.openclaw.android - -enum class VoiceWakeMode(val rawValue: String) { - Off("off"), - Foreground("foreground"), - Always("always"), - ; - - companion object { - fun fromRawValue(raw: String?): VoiceWakeMode { - return entries.firstOrNull { it.rawValue == raw?.trim()?.lowercase() } ?: Foreground - } - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/WakeWords.kt b/apps/android/app/src/main/java/ai/openclaw/android/WakeWords.kt deleted file mode 100644 index b64cb1dd749..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/WakeWords.kt +++ /dev/null @@ -1,21 +0,0 @@ -package ai.openclaw.android - -object WakeWords { - const val maxWords: Int = 32 - const val maxWordLength: Int = 64 - - fun parseCommaSeparated(input: String): List { - return input.split(",").map { it.trim() }.filter { it.isNotEmpty() } - } - - fun parseIfChanged(input: String, current: List): List? { - val parsed = parseCommaSeparated(input) - return if (parsed == current) null else parsed - } - - fun sanitize(words: List, defaults: List): List { - val cleaned = - words.map { it.trim() }.filter { it.isNotEmpty() }.take(maxWords).map { it.take(maxWordLength) } - return cleaned.ifEmpty { defaults } - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/chat/ChatController.kt b/apps/android/app/src/main/java/ai/openclaw/android/chat/ChatController.kt deleted file mode 100644 index 3ed69ee5b24..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/chat/ChatController.kt +++ /dev/null @@ -1,524 +0,0 @@ -package ai.openclaw.android.chat - -import ai.openclaw.android.gateway.GatewaySession -import java.util.UUID -import java.util.concurrent.ConcurrentHashMap -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.buildJsonObject - -class ChatController( - private val scope: CoroutineScope, - private val session: GatewaySession, - private val json: Json, - private val supportsChatSubscribe: Boolean, -) { - private val _sessionKey = MutableStateFlow("main") - val sessionKey: StateFlow = _sessionKey.asStateFlow() - - private val _sessionId = MutableStateFlow(null) - val sessionId: StateFlow = _sessionId.asStateFlow() - - private val _messages = MutableStateFlow>(emptyList()) - val messages: StateFlow> = _messages.asStateFlow() - - private val _errorText = MutableStateFlow(null) - val errorText: StateFlow = _errorText.asStateFlow() - - private val _healthOk = MutableStateFlow(false) - val healthOk: StateFlow = _healthOk.asStateFlow() - - private val _thinkingLevel = MutableStateFlow("off") - val thinkingLevel: StateFlow = _thinkingLevel.asStateFlow() - - private val _pendingRunCount = MutableStateFlow(0) - val pendingRunCount: StateFlow = _pendingRunCount.asStateFlow() - - private val _streamingAssistantText = MutableStateFlow(null) - val streamingAssistantText: StateFlow = _streamingAssistantText.asStateFlow() - - private val pendingToolCallsById = ConcurrentHashMap() - private val _pendingToolCalls = MutableStateFlow>(emptyList()) - val pendingToolCalls: StateFlow> = _pendingToolCalls.asStateFlow() - - private val _sessions = MutableStateFlow>(emptyList()) - val sessions: StateFlow> = _sessions.asStateFlow() - - private val pendingRuns = mutableSetOf() - private val pendingRunTimeoutJobs = ConcurrentHashMap() - private val pendingRunTimeoutMs = 120_000L - - private var lastHealthPollAtMs: Long? = null - - fun onDisconnected(message: String) { - _healthOk.value = false - // Not an error; keep connection status in the UI pill. - _errorText.value = null - clearPendingRuns() - pendingToolCallsById.clear() - publishPendingToolCalls() - _streamingAssistantText.value = null - _sessionId.value = null - } - - fun load(sessionKey: String) { - val key = sessionKey.trim().ifEmpty { "main" } - _sessionKey.value = key - scope.launch { bootstrap(forceHealth = true) } - } - - fun applyMainSessionKey(mainSessionKey: String) { - val trimmed = mainSessionKey.trim() - if (trimmed.isEmpty()) return - if (_sessionKey.value == trimmed) return - if (_sessionKey.value != "main") return - _sessionKey.value = trimmed - scope.launch { bootstrap(forceHealth = true) } - } - - fun refresh() { - scope.launch { bootstrap(forceHealth = true) } - } - - fun refreshSessions(limit: Int? = null) { - scope.launch { fetchSessions(limit = limit) } - } - - fun setThinkingLevel(thinkingLevel: String) { - val normalized = normalizeThinking(thinkingLevel) - if (normalized == _thinkingLevel.value) return - _thinkingLevel.value = normalized - } - - fun switchSession(sessionKey: String) { - val key = sessionKey.trim() - if (key.isEmpty()) return - if (key == _sessionKey.value) return - _sessionKey.value = key - scope.launch { bootstrap(forceHealth = true) } - } - - fun sendMessage( - message: String, - thinkingLevel: String, - attachments: List, - ) { - val trimmed = message.trim() - if (trimmed.isEmpty() && attachments.isEmpty()) return - if (!_healthOk.value) { - _errorText.value = "Gateway health not OK; cannot send" - return - } - - val runId = UUID.randomUUID().toString() - val text = if (trimmed.isEmpty() && attachments.isNotEmpty()) "See attached." else trimmed - val sessionKey = _sessionKey.value - val thinking = normalizeThinking(thinkingLevel) - - // Optimistic user message. - val userContent = - buildList { - add(ChatMessageContent(type = "text", text = text)) - for (att in attachments) { - add( - ChatMessageContent( - type = att.type, - mimeType = att.mimeType, - fileName = att.fileName, - base64 = att.base64, - ), - ) - } - } - _messages.value = - _messages.value + - ChatMessage( - id = UUID.randomUUID().toString(), - role = "user", - content = userContent, - timestampMs = System.currentTimeMillis(), - ) - - armPendingRunTimeout(runId) - synchronized(pendingRuns) { - pendingRuns.add(runId) - _pendingRunCount.value = pendingRuns.size - } - - _errorText.value = null - _streamingAssistantText.value = null - pendingToolCallsById.clear() - publishPendingToolCalls() - - scope.launch { - try { - val params = - buildJsonObject { - put("sessionKey", JsonPrimitive(sessionKey)) - put("message", JsonPrimitive(text)) - put("thinking", JsonPrimitive(thinking)) - put("timeoutMs", JsonPrimitive(30_000)) - put("idempotencyKey", JsonPrimitive(runId)) - if (attachments.isNotEmpty()) { - put( - "attachments", - JsonArray( - attachments.map { att -> - buildJsonObject { - put("type", JsonPrimitive(att.type)) - put("mimeType", JsonPrimitive(att.mimeType)) - put("fileName", JsonPrimitive(att.fileName)) - put("content", JsonPrimitive(att.base64)) - } - }, - ), - ) - } - } - val res = session.request("chat.send", params.toString()) - val actualRunId = parseRunId(res) ?: runId - if (actualRunId != runId) { - clearPendingRun(runId) - armPendingRunTimeout(actualRunId) - synchronized(pendingRuns) { - pendingRuns.add(actualRunId) - _pendingRunCount.value = pendingRuns.size - } - } - } catch (err: Throwable) { - clearPendingRun(runId) - _errorText.value = err.message - } - } - } - - fun abort() { - val runIds = - synchronized(pendingRuns) { - pendingRuns.toList() - } - if (runIds.isEmpty()) return - scope.launch { - for (runId in runIds) { - try { - val params = - buildJsonObject { - put("sessionKey", JsonPrimitive(_sessionKey.value)) - put("runId", JsonPrimitive(runId)) - } - session.request("chat.abort", params.toString()) - } catch (_: Throwable) { - // best-effort - } - } - } - } - - fun handleGatewayEvent(event: String, payloadJson: String?) { - when (event) { - "tick" -> { - scope.launch { pollHealthIfNeeded(force = false) } - } - "health" -> { - // If we receive a health snapshot, the gateway is reachable. - _healthOk.value = true - } - "seqGap" -> { - _errorText.value = "Event stream interrupted; try refreshing." - clearPendingRuns() - } - "chat" -> { - if (payloadJson.isNullOrBlank()) return - handleChatEvent(payloadJson) - } - "agent" -> { - if (payloadJson.isNullOrBlank()) return - handleAgentEvent(payloadJson) - } - } - } - - private suspend fun bootstrap(forceHealth: Boolean) { - _errorText.value = null - _healthOk.value = false - clearPendingRuns() - pendingToolCallsById.clear() - publishPendingToolCalls() - _streamingAssistantText.value = null - _sessionId.value = null - - val key = _sessionKey.value - try { - if (supportsChatSubscribe) { - try { - session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""") - } catch (_: Throwable) { - // best-effort - } - } - - val historyJson = session.request("chat.history", """{"sessionKey":"$key"}""") - val history = parseHistory(historyJson, sessionKey = key) - _messages.value = history.messages - _sessionId.value = history.sessionId - history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it } - - pollHealthIfNeeded(force = forceHealth) - fetchSessions(limit = 50) - } catch (err: Throwable) { - _errorText.value = err.message - } - } - - private suspend fun fetchSessions(limit: Int?) { - try { - val params = - buildJsonObject { - put("includeGlobal", JsonPrimitive(true)) - put("includeUnknown", JsonPrimitive(false)) - if (limit != null && limit > 0) put("limit", JsonPrimitive(limit)) - } - val res = session.request("sessions.list", params.toString()) - _sessions.value = parseSessions(res) - } catch (_: Throwable) { - // best-effort - } - } - - private suspend fun pollHealthIfNeeded(force: Boolean) { - val now = System.currentTimeMillis() - val last = lastHealthPollAtMs - if (!force && last != null && now - last < 10_000) return - lastHealthPollAtMs = now - try { - session.request("health", null) - _healthOk.value = true - } catch (_: Throwable) { - _healthOk.value = false - } - } - - private fun handleChatEvent(payloadJson: String) { - val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return - val sessionKey = payload["sessionKey"].asStringOrNull()?.trim() - if (!sessionKey.isNullOrEmpty() && sessionKey != _sessionKey.value) return - - val runId = payload["runId"].asStringOrNull() - if (runId != null) { - val isPending = - synchronized(pendingRuns) { - pendingRuns.contains(runId) - } - if (!isPending) return - } - - val state = payload["state"].asStringOrNull() - when (state) { - "final", "aborted", "error" -> { - if (state == "error") { - _errorText.value = payload["errorMessage"].asStringOrNull() ?: "Chat failed" - } - if (runId != null) clearPendingRun(runId) else clearPendingRuns() - pendingToolCallsById.clear() - publishPendingToolCalls() - _streamingAssistantText.value = null - scope.launch { - try { - val historyJson = - session.request("chat.history", """{"sessionKey":"${_sessionKey.value}"}""") - val history = parseHistory(historyJson, sessionKey = _sessionKey.value) - _messages.value = history.messages - _sessionId.value = history.sessionId - history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it } - } catch (_: Throwable) { - // best-effort - } - } - } - } - } - - private fun handleAgentEvent(payloadJson: String) { - val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return - val runId = payload["runId"].asStringOrNull() - val sessionId = _sessionId.value - if (sessionId != null && runId != sessionId) return - - val stream = payload["stream"].asStringOrNull() - val data = payload["data"].asObjectOrNull() - - when (stream) { - "assistant" -> { - val text = data?.get("text")?.asStringOrNull() - if (!text.isNullOrEmpty()) { - _streamingAssistantText.value = text - } - } - "tool" -> { - val phase = data?.get("phase")?.asStringOrNull() - val name = data?.get("name")?.asStringOrNull() - val toolCallId = data?.get("toolCallId")?.asStringOrNull() - if (phase.isNullOrEmpty() || name.isNullOrEmpty() || toolCallId.isNullOrEmpty()) return - - val ts = payload["ts"].asLongOrNull() ?: System.currentTimeMillis() - if (phase == "start") { - val args = data?.get("args").asObjectOrNull() - pendingToolCallsById[toolCallId] = - ChatPendingToolCall( - toolCallId = toolCallId, - name = name, - args = args, - startedAtMs = ts, - isError = null, - ) - publishPendingToolCalls() - } else if (phase == "result") { - pendingToolCallsById.remove(toolCallId) - publishPendingToolCalls() - } - } - "error" -> { - _errorText.value = "Event stream interrupted; try refreshing." - clearPendingRuns() - pendingToolCallsById.clear() - publishPendingToolCalls() - _streamingAssistantText.value = null - } - } - } - - private fun publishPendingToolCalls() { - _pendingToolCalls.value = - pendingToolCallsById.values.sortedBy { it.startedAtMs } - } - - private fun armPendingRunTimeout(runId: String) { - pendingRunTimeoutJobs[runId]?.cancel() - pendingRunTimeoutJobs[runId] = - scope.launch { - delay(pendingRunTimeoutMs) - val stillPending = - synchronized(pendingRuns) { - pendingRuns.contains(runId) - } - if (!stillPending) return@launch - clearPendingRun(runId) - _errorText.value = "Timed out waiting for a reply; try again or refresh." - } - } - - private fun clearPendingRun(runId: String) { - pendingRunTimeoutJobs.remove(runId)?.cancel() - synchronized(pendingRuns) { - pendingRuns.remove(runId) - _pendingRunCount.value = pendingRuns.size - } - } - - private fun clearPendingRuns() { - for ((_, job) in pendingRunTimeoutJobs) { - job.cancel() - } - pendingRunTimeoutJobs.clear() - synchronized(pendingRuns) { - pendingRuns.clear() - _pendingRunCount.value = 0 - } - } - - private fun parseHistory(historyJson: String, sessionKey: String): ChatHistory { - val root = json.parseToJsonElement(historyJson).asObjectOrNull() ?: return ChatHistory(sessionKey, null, null, emptyList()) - val sid = root["sessionId"].asStringOrNull() - val thinkingLevel = root["thinkingLevel"].asStringOrNull() - val array = root["messages"].asArrayOrNull() ?: JsonArray(emptyList()) - - val messages = - array.mapNotNull { item -> - val obj = item.asObjectOrNull() ?: return@mapNotNull null - val role = obj["role"].asStringOrNull() ?: return@mapNotNull null - val content = obj["content"].asArrayOrNull()?.mapNotNull(::parseMessageContent) ?: emptyList() - val ts = obj["timestamp"].asLongOrNull() - ChatMessage( - id = UUID.randomUUID().toString(), - role = role, - content = content, - timestampMs = ts, - ) - } - - return ChatHistory(sessionKey = sessionKey, sessionId = sid, thinkingLevel = thinkingLevel, messages = messages) - } - - private fun parseMessageContent(el: JsonElement): ChatMessageContent? { - val obj = el.asObjectOrNull() ?: return null - val type = obj["type"].asStringOrNull() ?: "text" - return if (type == "text") { - ChatMessageContent(type = "text", text = obj["text"].asStringOrNull()) - } else { - ChatMessageContent( - type = type, - mimeType = obj["mimeType"].asStringOrNull(), - fileName = obj["fileName"].asStringOrNull(), - base64 = obj["content"].asStringOrNull(), - ) - } - } - - private fun parseSessions(jsonString: String): List { - val root = json.parseToJsonElement(jsonString).asObjectOrNull() ?: return emptyList() - val sessions = root["sessions"].asArrayOrNull() ?: return emptyList() - return sessions.mapNotNull { item -> - val obj = item.asObjectOrNull() ?: return@mapNotNull null - val key = obj["key"].asStringOrNull()?.trim().orEmpty() - if (key.isEmpty()) return@mapNotNull null - val updatedAt = obj["updatedAt"].asLongOrNull() - val displayName = obj["displayName"].asStringOrNull()?.trim() - ChatSessionEntry(key = key, updatedAtMs = updatedAt, displayName = displayName) - } - } - - private fun parseRunId(resJson: String): String? { - return try { - json.parseToJsonElement(resJson).asObjectOrNull()?.get("runId").asStringOrNull() - } catch (_: Throwable) { - null - } - } - - private fun normalizeThinking(raw: String): String { - return when (raw.trim().lowercase()) { - "low" -> "low" - "medium" -> "medium" - "high" -> "high" - else -> "off" - } - } -} - -private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject - -private fun JsonElement?.asArrayOrNull(): JsonArray? = this as? JsonArray - -private fun JsonElement?.asStringOrNull(): String? = - when (this) { - is JsonNull -> null - is JsonPrimitive -> content - else -> null - } - -private fun JsonElement?.asLongOrNull(): Long? = - when (this) { - is JsonPrimitive -> content.toLongOrNull() - else -> null - } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/chat/ChatModels.kt b/apps/android/app/src/main/java/ai/openclaw/android/chat/ChatModels.kt deleted file mode 100644 index dd17a8c1ae5..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/chat/ChatModels.kt +++ /dev/null @@ -1,44 +0,0 @@ -package ai.openclaw.android.chat - -data class ChatMessage( - val id: String, - val role: String, - val content: List, - val timestampMs: Long?, -) - -data class ChatMessageContent( - val type: String = "text", - val text: String? = null, - val mimeType: String? = null, - val fileName: String? = null, - val base64: String? = null, -) - -data class ChatPendingToolCall( - val toolCallId: String, - val name: String, - val args: kotlinx.serialization.json.JsonObject? = null, - val startedAtMs: Long, - val isError: Boolean? = null, -) - -data class ChatSessionEntry( - val key: String, - val updatedAtMs: Long?, - val displayName: String? = null, -) - -data class ChatHistory( - val sessionKey: String, - val sessionId: String?, - val thinkingLevel: String?, - val messages: List, -) - -data class OutgoingAttachment( - val type: String, - val mimeType: String, - val fileName: String, - val base64: String, -) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/BonjourEscapes.kt b/apps/android/app/src/main/java/ai/openclaw/android/gateway/BonjourEscapes.kt deleted file mode 100644 index 1606df79ec6..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/gateway/BonjourEscapes.kt +++ /dev/null @@ -1,35 +0,0 @@ -package ai.openclaw.android.gateway - -object BonjourEscapes { - fun decode(input: String): String { - if (input.isEmpty()) return input - - val bytes = mutableListOf() - var i = 0 - while (i < input.length) { - if (input[i] == '\\' && i + 3 < input.length) { - val d0 = input[i + 1] - val d1 = input[i + 2] - val d2 = input[i + 3] - if (d0.isDigit() && d1.isDigit() && d2.isDigit()) { - val value = - ((d0.code - '0'.code) * 100) + ((d1.code - '0'.code) * 10) + (d2.code - '0'.code) - if (value in 0..255) { - bytes.add(value.toByte()) - i += 4 - continue - } - } - } - - val codePoint = Character.codePointAt(input, i) - val charBytes = String(Character.toChars(codePoint)).toByteArray(Charsets.UTF_8) - for (b in charBytes) { - bytes.add(b) - } - i += Character.charCount(codePoint) - } - - return String(bytes.toByteArray(), Charsets.UTF_8) - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceAuthStore.kt b/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceAuthStore.kt deleted file mode 100644 index 810e029fba8..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceAuthStore.kt +++ /dev/null @@ -1,26 +0,0 @@ -package ai.openclaw.android.gateway - -import ai.openclaw.android.SecurePrefs - -class DeviceAuthStore(private val prefs: SecurePrefs) { - fun loadToken(deviceId: String, role: String): String? { - val key = tokenKey(deviceId, role) - return prefs.getString(key)?.trim()?.takeIf { it.isNotEmpty() } - } - - fun saveToken(deviceId: String, role: String, token: String) { - val key = tokenKey(deviceId, role) - prefs.putString(key, token.trim()) - } - - fun clearToken(deviceId: String, role: String) { - val key = tokenKey(deviceId, role) - prefs.remove(key) - } - - private fun tokenKey(deviceId: String, role: String): String { - val normalizedDevice = deviceId.trim().lowercase() - val normalizedRole = role.trim().lowercase() - return "gateway.deviceToken.$normalizedDevice.$normalizedRole" - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceIdentityStore.kt b/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceIdentityStore.kt deleted file mode 100644 index ff651c6c17b..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceIdentityStore.kt +++ /dev/null @@ -1,182 +0,0 @@ -package ai.openclaw.android.gateway - -import android.content.Context -import android.util.Base64 -import java.io.File -import java.security.KeyFactory -import java.security.KeyPairGenerator -import java.security.MessageDigest -import java.security.Signature -import java.security.spec.PKCS8EncodedKeySpec -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json - -@Serializable -data class DeviceIdentity( - val deviceId: String, - val publicKeyRawBase64: String, - val privateKeyPkcs8Base64: String, - val createdAtMs: Long, -) - -class DeviceIdentityStore(context: Context) { - private val json = Json { ignoreUnknownKeys = true } - private val identityFile = File(context.filesDir, "openclaw/identity/device.json") - - @Synchronized - fun loadOrCreate(): DeviceIdentity { - val existing = load() - if (existing != null) { - val derived = deriveDeviceId(existing.publicKeyRawBase64) - if (derived != null && derived != existing.deviceId) { - val updated = existing.copy(deviceId = derived) - save(updated) - return updated - } - return existing - } - val fresh = generate() - save(fresh) - return fresh - } - - fun signPayload(payload: String, identity: DeviceIdentity): String? { - return try { - // Use BC lightweight API directly — JCA provider registration is broken by R8 - val privateKeyBytes = Base64.decode(identity.privateKeyPkcs8Base64, Base64.DEFAULT) - val pkInfo = org.bouncycastle.asn1.pkcs.PrivateKeyInfo.getInstance(privateKeyBytes) - val parsed = pkInfo.parsePrivateKey() - val rawPrivate = org.bouncycastle.asn1.DEROctetString.getInstance(parsed).octets - val privateKey = org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters(rawPrivate, 0) - val signer = org.bouncycastle.crypto.signers.Ed25519Signer() - signer.init(true, privateKey) - val payloadBytes = payload.toByteArray(Charsets.UTF_8) - signer.update(payloadBytes, 0, payloadBytes.size) - base64UrlEncode(signer.generateSignature()) - } catch (e: Throwable) { - android.util.Log.e("DeviceAuth", "signPayload FAILED: ${e.javaClass.simpleName}: ${e.message}", e) - null - } - } - - fun verifySelfSignature(payload: String, signatureBase64Url: String, identity: DeviceIdentity): Boolean { - return try { - val rawPublicKey = Base64.decode(identity.publicKeyRawBase64, Base64.DEFAULT) - val pubKey = org.bouncycastle.crypto.params.Ed25519PublicKeyParameters(rawPublicKey, 0) - val sigBytes = base64UrlDecode(signatureBase64Url) - val verifier = org.bouncycastle.crypto.signers.Ed25519Signer() - verifier.init(false, pubKey) - val payloadBytes = payload.toByteArray(Charsets.UTF_8) - verifier.update(payloadBytes, 0, payloadBytes.size) - verifier.verifySignature(sigBytes) - } catch (e: Throwable) { - android.util.Log.e("DeviceAuth", "self-verify exception: ${e.message}", e) - false - } - } - - private fun base64UrlDecode(input: String): ByteArray { - val normalized = input.replace('-', '+').replace('_', '/') - val padded = normalized + "=".repeat((4 - normalized.length % 4) % 4) - return Base64.decode(padded, Base64.DEFAULT) - } - - fun publicKeyBase64Url(identity: DeviceIdentity): String? { - return try { - val raw = Base64.decode(identity.publicKeyRawBase64, Base64.DEFAULT) - base64UrlEncode(raw) - } catch (_: Throwable) { - null - } - } - - private fun load(): DeviceIdentity? { - return readIdentity(identityFile) - } - - private fun readIdentity(file: File): DeviceIdentity? { - return try { - if (!file.exists()) return null - val raw = file.readText(Charsets.UTF_8) - val decoded = json.decodeFromString(DeviceIdentity.serializer(), raw) - if (decoded.deviceId.isBlank() || - decoded.publicKeyRawBase64.isBlank() || - decoded.privateKeyPkcs8Base64.isBlank() - ) { - null - } else { - decoded - } - } catch (_: Throwable) { - null - } - } - - private fun save(identity: DeviceIdentity) { - try { - identityFile.parentFile?.mkdirs() - val encoded = json.encodeToString(DeviceIdentity.serializer(), identity) - identityFile.writeText(encoded, Charsets.UTF_8) - } catch (_: Throwable) { - // best-effort only - } - } - - private fun generate(): DeviceIdentity { - // Use BC lightweight API directly to avoid JCA provider issues with R8 - val kpGen = org.bouncycastle.crypto.generators.Ed25519KeyPairGenerator() - kpGen.init(org.bouncycastle.crypto.params.Ed25519KeyGenerationParameters(java.security.SecureRandom())) - val kp = kpGen.generateKeyPair() - val pubKey = kp.public as org.bouncycastle.crypto.params.Ed25519PublicKeyParameters - val privKey = kp.private as org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters - val rawPublic = pubKey.encoded // 32 bytes - val deviceId = sha256Hex(rawPublic) - // Encode private key as PKCS8 for storage - val privKeyInfo = org.bouncycastle.crypto.util.PrivateKeyInfoFactory.createPrivateKeyInfo(privKey) - val pkcs8Bytes = privKeyInfo.encoded - return DeviceIdentity( - deviceId = deviceId, - publicKeyRawBase64 = Base64.encodeToString(rawPublic, Base64.NO_WRAP), - privateKeyPkcs8Base64 = Base64.encodeToString(pkcs8Bytes, Base64.NO_WRAP), - createdAtMs = System.currentTimeMillis(), - ) - } - - private fun deriveDeviceId(publicKeyRawBase64: String): String? { - return try { - val raw = Base64.decode(publicKeyRawBase64, Base64.DEFAULT) - sha256Hex(raw) - } catch (_: Throwable) { - null - } - } - - private fun stripSpkiPrefix(spki: ByteArray): ByteArray { - if (spki.size == ED25519_SPKI_PREFIX.size + 32 && - spki.copyOfRange(0, ED25519_SPKI_PREFIX.size).contentEquals(ED25519_SPKI_PREFIX) - ) { - return spki.copyOfRange(ED25519_SPKI_PREFIX.size, spki.size) - } - return spki - } - - private fun sha256Hex(data: ByteArray): String { - val digest = MessageDigest.getInstance("SHA-256").digest(data) - val out = StringBuilder(digest.size * 2) - for (byte in digest) { - out.append(String.format("%02x", byte)) - } - return out.toString() - } - - private fun base64UrlEncode(data: ByteArray): String { - return Base64.encodeToString(data, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) - } - - companion object { - private val ED25519_SPKI_PREFIX = - byteArrayOf( - 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, - ) - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayDiscovery.kt b/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayDiscovery.kt deleted file mode 100644 index 2ad8ec0cb19..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayDiscovery.kt +++ /dev/null @@ -1,521 +0,0 @@ -package ai.openclaw.android.gateway - -import android.content.Context -import android.net.ConnectivityManager -import android.net.DnsResolver -import android.net.NetworkCapabilities -import android.net.nsd.NsdManager -import android.net.nsd.NsdServiceInfo -import android.os.CancellationSignal -import android.util.Log -import java.io.IOException -import java.net.InetSocketAddress -import java.nio.ByteBuffer -import java.nio.charset.CodingErrorAction -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.Executor -import java.util.concurrent.Executors -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine -import org.xbill.DNS.AAAARecord -import org.xbill.DNS.ARecord -import org.xbill.DNS.DClass -import org.xbill.DNS.ExtendedResolver -import org.xbill.DNS.Message -import org.xbill.DNS.Name -import org.xbill.DNS.PTRRecord -import org.xbill.DNS.Record -import org.xbill.DNS.Rcode -import org.xbill.DNS.Resolver -import org.xbill.DNS.SRVRecord -import org.xbill.DNS.Section -import org.xbill.DNS.SimpleResolver -import org.xbill.DNS.TextParseException -import org.xbill.DNS.TXTRecord -import org.xbill.DNS.Type -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException - -@Suppress("DEPRECATION") -class GatewayDiscovery( - context: Context, - private val scope: CoroutineScope, -) { - private val nsd = context.getSystemService(NsdManager::class.java) - private val connectivity = context.getSystemService(ConnectivityManager::class.java) - private val dns = DnsResolver.getInstance() - private val serviceType = "_openclaw-gw._tcp." - private val wideAreaDomain = System.getenv("OPENCLAW_WIDE_AREA_DOMAIN") - private val logTag = "OpenClaw/GatewayDiscovery" - - private val localById = ConcurrentHashMap() - private val unicastById = ConcurrentHashMap() - private val _gateways = MutableStateFlow>(emptyList()) - val gateways: StateFlow> = _gateways.asStateFlow() - - private val _statusText = MutableStateFlow("Searching…") - val statusText: StateFlow = _statusText.asStateFlow() - - private var unicastJob: Job? = null - private val dnsExecutor: Executor = Executors.newCachedThreadPool() - - @Volatile private var lastWideAreaRcode: Int? = null - @Volatile private var lastWideAreaCount: Int = 0 - - private val discoveryListener = - object : NsdManager.DiscoveryListener { - override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {} - override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {} - override fun onDiscoveryStarted(serviceType: String) {} - override fun onDiscoveryStopped(serviceType: String) {} - - override fun onServiceFound(serviceInfo: NsdServiceInfo) { - if (serviceInfo.serviceType != this@GatewayDiscovery.serviceType) return - resolve(serviceInfo) - } - - override fun onServiceLost(serviceInfo: NsdServiceInfo) { - val serviceName = BonjourEscapes.decode(serviceInfo.serviceName) - val id = stableId(serviceName, "local.") - localById.remove(id) - publish() - } - } - - init { - startLocalDiscovery() - if (!wideAreaDomain.isNullOrBlank()) { - startUnicastDiscovery(wideAreaDomain) - } - } - - private fun startLocalDiscovery() { - try { - nsd.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, discoveryListener) - } catch (_: Throwable) { - // ignore (best-effort) - } - } - - private fun stopLocalDiscovery() { - try { - nsd.stopServiceDiscovery(discoveryListener) - } catch (_: Throwable) { - // ignore (best-effort) - } - } - - private fun startUnicastDiscovery(domain: String) { - unicastJob = - scope.launch(Dispatchers.IO) { - while (true) { - try { - refreshUnicast(domain) - } catch (_: Throwable) { - // ignore (best-effort) - } - delay(5000) - } - } - } - - private fun resolve(serviceInfo: NsdServiceInfo) { - nsd.resolveService( - serviceInfo, - object : NsdManager.ResolveListener { - override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {} - - override fun onServiceResolved(resolved: NsdServiceInfo) { - val host = resolved.host?.hostAddress ?: return - val port = resolved.port - if (port <= 0) return - - val rawServiceName = resolved.serviceName - val serviceName = BonjourEscapes.decode(rawServiceName) - val displayName = BonjourEscapes.decode(txt(resolved, "displayName") ?: serviceName) - val lanHost = txt(resolved, "lanHost") - val tailnetDns = txt(resolved, "tailnetDns") - val gatewayPort = txtInt(resolved, "gatewayPort") - val canvasPort = txtInt(resolved, "canvasPort") - val tlsEnabled = txtBool(resolved, "gatewayTls") - val tlsFingerprint = txt(resolved, "gatewayTlsSha256") - val id = stableId(serviceName, "local.") - localById[id] = - GatewayEndpoint( - stableId = id, - name = displayName, - host = host, - port = port, - lanHost = lanHost, - tailnetDns = tailnetDns, - gatewayPort = gatewayPort, - canvasPort = canvasPort, - tlsEnabled = tlsEnabled, - tlsFingerprintSha256 = tlsFingerprint, - ) - publish() - } - }, - ) - } - - private fun publish() { - _gateways.value = - (localById.values + unicastById.values).sortedBy { it.name.lowercase() } - _statusText.value = buildStatusText() - } - - private fun buildStatusText(): String { - val localCount = localById.size - val wideRcode = lastWideAreaRcode - val wideCount = lastWideAreaCount - - val wide = - when (wideRcode) { - null -> "Wide: ?" - Rcode.NOERROR -> "Wide: $wideCount" - Rcode.NXDOMAIN -> "Wide: NXDOMAIN" - else -> "Wide: ${Rcode.string(wideRcode)}" - } - - return when { - localCount == 0 && wideRcode == null -> "Searching for gateways…" - localCount == 0 -> "$wide" - else -> "Local: $localCount • $wide" - } - } - - private fun stableId(serviceName: String, domain: String): String { - return "${serviceType}|${domain}|${normalizeName(serviceName)}" - } - - private fun normalizeName(raw: String): String { - return raw.trim().split(Regex("\\s+")).joinToString(" ") - } - - private fun txt(info: NsdServiceInfo, key: String): String? { - val bytes = info.attributes[key] ?: return null - return try { - String(bytes, Charsets.UTF_8).trim().ifEmpty { null } - } catch (_: Throwable) { - null - } - } - - private fun txtInt(info: NsdServiceInfo, key: String): Int? { - return txt(info, key)?.toIntOrNull() - } - - private fun txtBool(info: NsdServiceInfo, key: String): Boolean { - val raw = txt(info, key)?.trim()?.lowercase() ?: return false - return raw == "1" || raw == "true" || raw == "yes" - } - - private suspend fun refreshUnicast(domain: String) { - val ptrName = "${serviceType}${domain}" - val ptrMsg = lookupUnicastMessage(ptrName, Type.PTR) ?: return - val ptrRecords = records(ptrMsg, Section.ANSWER).mapNotNull { it as? PTRRecord } - - val next = LinkedHashMap() - for (ptr in ptrRecords) { - val instanceFqdn = ptr.target.toString() - val srv = - recordByName(ptrMsg, instanceFqdn, Type.SRV) as? SRVRecord - ?: run { - val msg = lookupUnicastMessage(instanceFqdn, Type.SRV) ?: return@run null - recordByName(msg, instanceFqdn, Type.SRV) as? SRVRecord - } - ?: continue - val port = srv.port - if (port <= 0) continue - - val targetFqdn = srv.target.toString() - val host = - resolveHostFromMessage(ptrMsg, targetFqdn) - ?: resolveHostFromMessage(lookupUnicastMessage(instanceFqdn, Type.SRV), targetFqdn) - ?: resolveHostUnicast(targetFqdn) - ?: continue - - val txtFromPtr = - recordsByName(ptrMsg, Section.ADDITIONAL)[keyName(instanceFqdn)] - .orEmpty() - .mapNotNull { it as? TXTRecord } - val txt = - if (txtFromPtr.isNotEmpty()) { - txtFromPtr - } else { - val msg = lookupUnicastMessage(instanceFqdn, Type.TXT) - records(msg, Section.ANSWER).mapNotNull { it as? TXTRecord } - } - val instanceName = BonjourEscapes.decode(decodeInstanceName(instanceFqdn, domain)) - val displayName = BonjourEscapes.decode(txtValue(txt, "displayName") ?: instanceName) - val lanHost = txtValue(txt, "lanHost") - val tailnetDns = txtValue(txt, "tailnetDns") - val gatewayPort = txtIntValue(txt, "gatewayPort") - val canvasPort = txtIntValue(txt, "canvasPort") - val tlsEnabled = txtBoolValue(txt, "gatewayTls") - val tlsFingerprint = txtValue(txt, "gatewayTlsSha256") - val id = stableId(instanceName, domain) - next[id] = - GatewayEndpoint( - stableId = id, - name = displayName, - host = host, - port = port, - lanHost = lanHost, - tailnetDns = tailnetDns, - gatewayPort = gatewayPort, - canvasPort = canvasPort, - tlsEnabled = tlsEnabled, - tlsFingerprintSha256 = tlsFingerprint, - ) - } - - unicastById.clear() - unicastById.putAll(next) - lastWideAreaRcode = ptrMsg.header.rcode - lastWideAreaCount = next.size - publish() - - if (next.isEmpty()) { - Log.d( - logTag, - "wide-area discovery: 0 results for $ptrName (rcode=${Rcode.string(ptrMsg.header.rcode)})", - ) - } - } - - private fun decodeInstanceName(instanceFqdn: String, domain: String): String { - val suffix = "${serviceType}${domain}" - val withoutSuffix = - if (instanceFqdn.endsWith(suffix)) { - instanceFqdn.removeSuffix(suffix) - } else { - instanceFqdn.substringBefore(serviceType) - } - return normalizeName(stripTrailingDot(withoutSuffix)) - } - - private fun stripTrailingDot(raw: String): String { - return raw.removeSuffix(".") - } - - private suspend fun lookupUnicastMessage(name: String, type: Int): Message? { - val query = - try { - Message.newQuery( - org.xbill.DNS.Record.newRecord( - Name.fromString(name), - type, - DClass.IN, - ), - ) - } catch (_: TextParseException) { - return null - } - - val system = queryViaSystemDns(query) - if (records(system, Section.ANSWER).any { it.type == type }) return system - - val direct = createDirectResolver() ?: return system - return try { - val msg = direct.send(query) - if (records(msg, Section.ANSWER).any { it.type == type }) msg else system - } catch (_: Throwable) { - system - } - } - - private suspend fun queryViaSystemDns(query: Message): Message? { - val network = preferredDnsNetwork() - val bytes = - try { - rawQuery(network, query.toWire()) - } catch (_: Throwable) { - return null - } - - return try { - Message(bytes) - } catch (_: IOException) { - null - } - } - - private fun records(msg: Message?, section: Int): List { - return msg?.getSectionArray(section)?.toList() ?: emptyList() - } - - private fun keyName(raw: String): String { - return raw.trim().lowercase() - } - - private fun recordsByName(msg: Message, section: Int): Map> { - val next = LinkedHashMap>() - for (r in records(msg, section)) { - val name = r.name?.toString() ?: continue - next.getOrPut(keyName(name)) { mutableListOf() }.add(r) - } - return next - } - - private fun recordByName(msg: Message, fqdn: String, type: Int): Record? { - val key = keyName(fqdn) - val byNameAnswer = recordsByName(msg, Section.ANSWER) - val fromAnswer = byNameAnswer[key].orEmpty().firstOrNull { it.type == type } - if (fromAnswer != null) return fromAnswer - - val byNameAdditional = recordsByName(msg, Section.ADDITIONAL) - return byNameAdditional[key].orEmpty().firstOrNull { it.type == type } - } - - private fun resolveHostFromMessage(msg: Message?, hostname: String): String? { - val m = msg ?: return null - val key = keyName(hostname) - val additional = recordsByName(m, Section.ADDITIONAL)[key].orEmpty() - val a = additional.mapNotNull { it as? ARecord }.mapNotNull { it.address?.hostAddress } - val aaaa = additional.mapNotNull { it as? AAAARecord }.mapNotNull { it.address?.hostAddress } - return a.firstOrNull() ?: aaaa.firstOrNull() - } - - private fun preferredDnsNetwork(): android.net.Network? { - val cm = connectivity ?: return null - - // Prefer VPN (Tailscale) when present; otherwise use the active network. - cm.allNetworks.firstOrNull { n -> - val caps = cm.getNetworkCapabilities(n) ?: return@firstOrNull false - caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) - }?.let { return it } - - return cm.activeNetwork - } - - private fun createDirectResolver(): Resolver? { - val cm = connectivity ?: return null - - val candidateNetworks = - buildList { - cm.allNetworks - .firstOrNull { n -> - val caps = cm.getNetworkCapabilities(n) ?: return@firstOrNull false - caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) - }?.let(::add) - cm.activeNetwork?.let(::add) - }.distinct() - - val servers = - candidateNetworks - .asSequence() - .flatMap { n -> - cm.getLinkProperties(n)?.dnsServers?.asSequence() ?: emptySequence() - } - .distinctBy { it.hostAddress ?: it.toString() } - .toList() - if (servers.isEmpty()) return null - - return try { - val resolvers = - servers.mapNotNull { addr -> - try { - SimpleResolver().apply { - setAddress(InetSocketAddress(addr, 53)) - setTimeout(3) - } - } catch (_: Throwable) { - null - } - } - if (resolvers.isEmpty()) return null - ExtendedResolver(resolvers.toTypedArray()).apply { setTimeout(3) } - } catch (_: Throwable) { - null - } - } - - private suspend fun rawQuery(network: android.net.Network?, wireQuery: ByteArray): ByteArray = - suspendCancellableCoroutine { cont -> - val signal = CancellationSignal() - cont.invokeOnCancellation { signal.cancel() } - - dns.rawQuery( - network, - wireQuery, - DnsResolver.FLAG_EMPTY, - dnsExecutor, - signal, - object : DnsResolver.Callback { - override fun onAnswer(answer: ByteArray, rcode: Int) { - cont.resume(answer) - } - - override fun onError(error: DnsResolver.DnsException) { - cont.resumeWithException(error) - } - }, - ) - } - - private fun txtValue(records: List, key: String): String? { - val prefix = "$key=" - for (r in records) { - val strings: List = - try { - r.strings.mapNotNull { it as? String } - } catch (_: Throwable) { - emptyList() - } - for (s in strings) { - val trimmed = decodeDnsTxtString(s).trim() - if (trimmed.startsWith(prefix)) { - return trimmed.removePrefix(prefix).trim().ifEmpty { null } - } - } - } - return null - } - - private fun txtIntValue(records: List, key: String): Int? { - return txtValue(records, key)?.toIntOrNull() - } - - private fun txtBoolValue(records: List, key: String): Boolean { - val raw = txtValue(records, key)?.trim()?.lowercase() ?: return false - return raw == "1" || raw == "true" || raw == "yes" - } - - private fun decodeDnsTxtString(raw: String): String { - // dnsjava treats TXT as opaque bytes and decodes as ISO-8859-1 to preserve bytes. - // Our TXT payload is UTF-8 (written by the gateway), so re-decode when possible. - val bytes = raw.toByteArray(Charsets.ISO_8859_1) - val decoder = - Charsets.UTF_8 - .newDecoder() - .onMalformedInput(CodingErrorAction.REPORT) - .onUnmappableCharacter(CodingErrorAction.REPORT) - return try { - decoder.decode(ByteBuffer.wrap(bytes)).toString() - } catch (_: Throwable) { - raw - } - } - - private suspend fun resolveHostUnicast(hostname: String): String? { - val a = - records(lookupUnicastMessage(hostname, Type.A), Section.ANSWER) - .mapNotNull { it as? ARecord } - .mapNotNull { it.address?.hostAddress } - val aaaa = - records(lookupUnicastMessage(hostname, Type.AAAA), Section.ANSWER) - .mapNotNull { it as? AAAARecord } - .mapNotNull { it.address?.hostAddress } - - return a.firstOrNull() ?: aaaa.firstOrNull() - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayEndpoint.kt b/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayEndpoint.kt deleted file mode 100644 index 9a301060282..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayEndpoint.kt +++ /dev/null @@ -1,26 +0,0 @@ -package ai.openclaw.android.gateway - -data class GatewayEndpoint( - val stableId: String, - val name: String, - val host: String, - val port: Int, - val lanHost: String? = null, - val tailnetDns: String? = null, - val gatewayPort: Int? = null, - val canvasPort: Int? = null, - val tlsEnabled: Boolean = false, - val tlsFingerprintSha256: String? = null, -) { - companion object { - fun manual(host: String, port: Int): GatewayEndpoint = - GatewayEndpoint( - stableId = "manual|${host.lowercase()}|$port", - name = "$host:$port", - host = host, - port = port, - tlsEnabled = false, - tlsFingerprintSha256 = null, - ) - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayProtocol.kt b/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayProtocol.kt deleted file mode 100644 index da8fa4c6933..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayProtocol.kt +++ /dev/null @@ -1,3 +0,0 @@ -package ai.openclaw.android.gateway - -const val GATEWAY_PROTOCOL_VERSION = 3 diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt b/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt deleted file mode 100644 index 091e735530d..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt +++ /dev/null @@ -1,704 +0,0 @@ -package ai.openclaw.android.gateway - -import android.util.Log -import java.util.Locale -import java.util.UUID -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.atomic.AtomicBoolean -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.buildJsonObject -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response -import okhttp3.WebSocket -import okhttp3.WebSocketListener - -data class GatewayClientInfo( - val id: String, - val displayName: String?, - val version: String, - val platform: String, - val mode: String, - val instanceId: String?, - val deviceFamily: String?, - val modelIdentifier: String?, -) - -data class GatewayConnectOptions( - val role: String, - val scopes: List, - val caps: List, - val commands: List, - val permissions: Map, - val client: GatewayClientInfo, - val userAgent: String? = null, -) - -class GatewaySession( - private val scope: CoroutineScope, - private val identityStore: DeviceIdentityStore, - private val deviceAuthStore: DeviceAuthStore, - private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit, - private val onDisconnected: (message: String) -> Unit, - private val onEvent: (event: String, payloadJson: String?) -> Unit, - private val onInvoke: (suspend (InvokeRequest) -> InvokeResult)? = null, - private val onTlsFingerprint: ((stableId: String, fingerprint: String) -> Unit)? = null, -) { - data class InvokeRequest( - val id: String, - val nodeId: String, - val command: String, - val paramsJson: String?, - val timeoutMs: Long?, - ) - - data class InvokeResult(val ok: Boolean, val payloadJson: String?, val error: ErrorShape?) { - companion object { - fun ok(payloadJson: String?) = InvokeResult(ok = true, payloadJson = payloadJson, error = null) - fun error(code: String, message: String) = - InvokeResult(ok = false, payloadJson = null, error = ErrorShape(code = code, message = message)) - } - } - - data class ErrorShape(val code: String, val message: String) - - private val json = Json { ignoreUnknownKeys = true } - private val writeLock = Mutex() - private val pending = ConcurrentHashMap>() - - @Volatile private var canvasHostUrl: String? = null - @Volatile private var mainSessionKey: String? = null - - private data class DesiredConnection( - val endpoint: GatewayEndpoint, - val token: String?, - val password: String?, - val options: GatewayConnectOptions, - val tls: GatewayTlsParams?, - ) - - private var desired: DesiredConnection? = null - private var job: Job? = null - @Volatile private var currentConnection: Connection? = null - - fun connect( - endpoint: GatewayEndpoint, - token: String?, - password: String?, - options: GatewayConnectOptions, - tls: GatewayTlsParams? = null, - ) { - desired = DesiredConnection(endpoint, token, password, options, tls) - if (job == null) { - job = scope.launch(Dispatchers.IO) { runLoop() } - } - } - - fun disconnect() { - desired = null - currentConnection?.closeQuietly() - scope.launch(Dispatchers.IO) { - job?.cancelAndJoin() - job = null - canvasHostUrl = null - mainSessionKey = null - onDisconnected("Offline") - } - } - - fun reconnect() { - currentConnection?.closeQuietly() - } - - fun currentCanvasHostUrl(): String? = canvasHostUrl - fun currentMainSessionKey(): String? = mainSessionKey - - suspend fun sendNodeEvent(event: String, payloadJson: String?) { - val conn = currentConnection ?: return - val parsedPayload = payloadJson?.let { parseJsonOrNull(it) } - val params = - buildJsonObject { - put("event", JsonPrimitive(event)) - if (parsedPayload != null) { - put("payload", parsedPayload) - } else if (payloadJson != null) { - put("payloadJSON", JsonPrimitive(payloadJson)) - } else { - put("payloadJSON", JsonNull) - } - } - try { - conn.request("node.event", params, timeoutMs = 8_000) - } catch (err: Throwable) { - Log.w("OpenClawGateway", "node.event failed: ${err.message ?: err::class.java.simpleName}") - } - } - - suspend fun request(method: String, paramsJson: String?, timeoutMs: Long = 15_000): String { - val conn = currentConnection ?: throw IllegalStateException("not connected") - val params = - if (paramsJson.isNullOrBlank()) { - null - } else { - json.parseToJsonElement(paramsJson) - } - val res = conn.request(method, params, timeoutMs) - if (res.ok) return res.payloadJson ?: "" - val err = res.error - throw IllegalStateException("${err?.code ?: "UNAVAILABLE"}: ${err?.message ?: "request failed"}") - } - - private data class RpcResponse(val id: String, val ok: Boolean, val payloadJson: String?, val error: ErrorShape?) - - private inner class Connection( - private val endpoint: GatewayEndpoint, - private val token: String?, - private val password: String?, - private val options: GatewayConnectOptions, - private val tls: GatewayTlsParams?, - ) { - private val connectDeferred = CompletableDeferred() - private val closedDeferred = CompletableDeferred() - private val isClosed = AtomicBoolean(false) - private val connectNonceDeferred = CompletableDeferred() - private val client: OkHttpClient = buildClient() - private var socket: WebSocket? = null - private val loggerTag = "OpenClawGateway" - - val remoteAddress: String = - if (endpoint.host.contains(":")) { - "[${endpoint.host}]:${endpoint.port}" - } else { - "${endpoint.host}:${endpoint.port}" - } - - suspend fun connect() { - val scheme = if (tls != null) "wss" else "ws" - val url = "$scheme://${endpoint.host}:${endpoint.port}" - val httpScheme = if (tls != null) "https" else "http" - val origin = "$httpScheme://${endpoint.host}:${endpoint.port}" - val request = Request.Builder().url(url).header("Origin", origin).build() - socket = client.newWebSocket(request, Listener()) - try { - connectDeferred.await() - } catch (err: Throwable) { - throw err - } - } - - suspend fun request(method: String, params: JsonElement?, timeoutMs: Long): RpcResponse { - val id = UUID.randomUUID().toString() - val deferred = CompletableDeferred() - pending[id] = deferred - val frame = - buildJsonObject { - put("type", JsonPrimitive("req")) - put("id", JsonPrimitive(id)) - put("method", JsonPrimitive(method)) - if (params != null) put("params", params) - } - sendJson(frame) - return try { - withTimeout(timeoutMs) { deferred.await() } - } catch (err: TimeoutCancellationException) { - pending.remove(id) - throw IllegalStateException("request timeout") - } - } - - suspend fun sendJson(obj: JsonObject) { - val jsonString = obj.toString() - writeLock.withLock { - socket?.send(jsonString) - } - } - - suspend fun awaitClose() = closedDeferred.await() - - fun closeQuietly() { - if (isClosed.compareAndSet(false, true)) { - socket?.close(1000, "bye") - socket = null - closedDeferred.complete(Unit) - } - } - - private fun buildClient(): OkHttpClient { - val builder = OkHttpClient.Builder() - .writeTimeout(60, java.util.concurrent.TimeUnit.SECONDS) - .readTimeout(0, java.util.concurrent.TimeUnit.SECONDS) - .pingInterval(30, java.util.concurrent.TimeUnit.SECONDS) - val tlsConfig = buildGatewayTlsConfig(tls) { fingerprint -> - onTlsFingerprint?.invoke(tls?.stableId ?: endpoint.stableId, fingerprint) - } - if (tlsConfig != null) { - builder.sslSocketFactory(tlsConfig.sslSocketFactory, tlsConfig.trustManager) - builder.hostnameVerifier(tlsConfig.hostnameVerifier) - } - return builder.build() - } - - private inner class Listener : WebSocketListener() { - override fun onOpen(webSocket: WebSocket, response: Response) { - scope.launch { - try { - val nonce = awaitConnectNonce() - sendConnect(nonce) - } catch (err: Throwable) { - connectDeferred.completeExceptionally(err) - closeQuietly() - } - } - } - - override fun onMessage(webSocket: WebSocket, text: String) { - scope.launch { handleMessage(text) } - } - - override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { - if (!connectDeferred.isCompleted) { - connectDeferred.completeExceptionally(t) - } - if (isClosed.compareAndSet(false, true)) { - failPending() - closedDeferred.complete(Unit) - onDisconnected("Gateway error: ${t.message ?: t::class.java.simpleName}") - } - } - - override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { - if (!connectDeferred.isCompleted) { - connectDeferred.completeExceptionally(IllegalStateException("Gateway closed: $reason")) - } - if (isClosed.compareAndSet(false, true)) { - failPending() - closedDeferred.complete(Unit) - onDisconnected("Gateway closed: $reason") - } - } - } - - private suspend fun sendConnect(connectNonce: String?) { - val identity = identityStore.loadOrCreate() - val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role) - val trimmedToken = token?.trim().orEmpty() - val authToken = if (storedToken.isNullOrBlank()) trimmedToken else storedToken - val canFallbackToShared = !storedToken.isNullOrBlank() && trimmedToken.isNotBlank() - val payload = buildConnectParams(identity, connectNonce, authToken, password?.trim()) - val res = request("connect", payload, timeoutMs = 8_000) - if (!res.ok) { - val msg = res.error?.message ?: "connect failed" - if (canFallbackToShared) { - deviceAuthStore.clearToken(identity.deviceId, options.role) - } - throw IllegalStateException(msg) - } - val payloadJson = res.payloadJson ?: throw IllegalStateException("connect failed: missing payload") - val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: throw IllegalStateException("connect failed") - val serverName = obj["server"].asObjectOrNull()?.get("host").asStringOrNull() - val authObj = obj["auth"].asObjectOrNull() - val deviceToken = authObj?.get("deviceToken").asStringOrNull() - val authRole = authObj?.get("role").asStringOrNull() ?: options.role - if (!deviceToken.isNullOrBlank()) { - deviceAuthStore.saveToken(identity.deviceId, authRole, deviceToken) - } - val rawCanvas = obj["canvasHostUrl"].asStringOrNull() - canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint) - val sessionDefaults = - obj["snapshot"].asObjectOrNull() - ?.get("sessionDefaults").asObjectOrNull() - mainSessionKey = sessionDefaults?.get("mainSessionKey").asStringOrNull() - onConnected(serverName, remoteAddress, mainSessionKey) - connectDeferred.complete(Unit) - } - - private fun buildConnectParams( - identity: DeviceIdentity, - connectNonce: String?, - authToken: String, - authPassword: String?, - ): JsonObject { - val client = options.client - val locale = Locale.getDefault().toLanguageTag() - val clientObj = - buildJsonObject { - put("id", JsonPrimitive(client.id)) - client.displayName?.let { put("displayName", JsonPrimitive(it)) } - put("version", JsonPrimitive(client.version)) - put("platform", JsonPrimitive(client.platform)) - put("mode", JsonPrimitive(client.mode)) - client.instanceId?.let { put("instanceId", JsonPrimitive(it)) } - client.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) } - client.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) } - } - - val password = authPassword?.trim().orEmpty() - val authJson = - when { - authToken.isNotEmpty() -> - buildJsonObject { - put("token", JsonPrimitive(authToken)) - } - password.isNotEmpty() -> - buildJsonObject { - put("password", JsonPrimitive(password)) - } - else -> null - } - - val signedAtMs = System.currentTimeMillis() - val payload = - buildDeviceAuthPayload( - deviceId = identity.deviceId, - clientId = client.id, - clientMode = client.mode, - role = options.role, - scopes = options.scopes, - signedAtMs = signedAtMs, - token = if (authToken.isNotEmpty()) authToken else null, - nonce = connectNonce, - ) - val signature = identityStore.signPayload(payload, identity) - val publicKey = identityStore.publicKeyBase64Url(identity) - val deviceJson = - if (!signature.isNullOrBlank() && !publicKey.isNullOrBlank()) { - buildJsonObject { - put("id", JsonPrimitive(identity.deviceId)) - put("publicKey", JsonPrimitive(publicKey)) - put("signature", JsonPrimitive(signature)) - put("signedAt", JsonPrimitive(signedAtMs)) - if (!connectNonce.isNullOrBlank()) { - put("nonce", JsonPrimitive(connectNonce)) - } - } - } else { - null - } - - return buildJsonObject { - put("minProtocol", JsonPrimitive(GATEWAY_PROTOCOL_VERSION)) - put("maxProtocol", JsonPrimitive(GATEWAY_PROTOCOL_VERSION)) - put("client", clientObj) - if (options.caps.isNotEmpty()) put("caps", JsonArray(options.caps.map(::JsonPrimitive))) - if (options.commands.isNotEmpty()) put("commands", JsonArray(options.commands.map(::JsonPrimitive))) - if (options.permissions.isNotEmpty()) { - put( - "permissions", - buildJsonObject { - options.permissions.forEach { (key, value) -> - put(key, JsonPrimitive(value)) - } - }, - ) - } - put("role", JsonPrimitive(options.role)) - if (options.scopes.isNotEmpty()) put("scopes", JsonArray(options.scopes.map(::JsonPrimitive))) - authJson?.let { put("auth", it) } - deviceJson?.let { put("device", it) } - put("locale", JsonPrimitive(locale)) - options.userAgent?.trim()?.takeIf { it.isNotEmpty() }?.let { - put("userAgent", JsonPrimitive(it)) - } - } - } - - private suspend fun handleMessage(text: String) { - val frame = json.parseToJsonElement(text).asObjectOrNull() ?: return - when (frame["type"].asStringOrNull()) { - "res" -> handleResponse(frame) - "event" -> handleEvent(frame) - } - } - - private fun handleResponse(frame: JsonObject) { - val id = frame["id"].asStringOrNull() ?: return - val ok = frame["ok"].asBooleanOrNull() ?: false - val payloadJson = frame["payload"]?.let { payload -> payload.toString() } - val error = - frame["error"]?.asObjectOrNull()?.let { obj -> - val code = obj["code"].asStringOrNull() ?: "UNAVAILABLE" - val msg = obj["message"].asStringOrNull() ?: "request failed" - ErrorShape(code, msg) - } - pending.remove(id)?.complete(RpcResponse(id, ok, payloadJson, error)) - } - - private fun handleEvent(frame: JsonObject) { - val event = frame["event"].asStringOrNull() ?: return - val payloadJson = - frame["payload"]?.let { it.toString() } ?: frame["payloadJSON"].asStringOrNull() - if (event == "connect.challenge") { - val nonce = extractConnectNonce(payloadJson) - if (!connectNonceDeferred.isCompleted) { - connectNonceDeferred.complete(nonce) - } - return - } - if (event == "node.invoke.request" && payloadJson != null && onInvoke != null) { - handleInvokeEvent(payloadJson) - return - } - onEvent(event, payloadJson) - } - - private suspend fun awaitConnectNonce(): String? { - if (isLoopbackHost(endpoint.host)) return null - return try { - withTimeout(2_000) { connectNonceDeferred.await() } - } catch (_: Throwable) { - null - } - } - - private fun extractConnectNonce(payloadJson: String?): String? { - if (payloadJson.isNullOrBlank()) return null - val obj = parseJsonOrNull(payloadJson)?.asObjectOrNull() ?: return null - return obj["nonce"].asStringOrNull() - } - - private fun handleInvokeEvent(payloadJson: String) { - val payload = - try { - json.parseToJsonElement(payloadJson).asObjectOrNull() - } catch (_: Throwable) { - null - } ?: return - val id = payload["id"].asStringOrNull() ?: return - val nodeId = payload["nodeId"].asStringOrNull() ?: return - val command = payload["command"].asStringOrNull() ?: return - val params = - payload["paramsJSON"].asStringOrNull() - ?: payload["params"]?.let { value -> if (value is JsonNull) null else value.toString() } - val timeoutMs = payload["timeoutMs"].asLongOrNull() - scope.launch { - val result = - try { - onInvoke?.invoke(InvokeRequest(id, nodeId, command, params, timeoutMs)) - ?: InvokeResult.error("UNAVAILABLE", "invoke handler missing") - } catch (err: Throwable) { - invokeErrorFromThrowable(err) - } - sendInvokeResult(id, nodeId, result) - } - } - - private suspend fun sendInvokeResult(id: String, nodeId: String, result: InvokeResult) { - val parsedPayload = result.payloadJson?.let { parseJsonOrNull(it) } - val params = - buildJsonObject { - put("id", JsonPrimitive(id)) - put("nodeId", JsonPrimitive(nodeId)) - put("ok", JsonPrimitive(result.ok)) - if (parsedPayload != null) { - put("payload", parsedPayload) - } else if (result.payloadJson != null) { - put("payloadJSON", JsonPrimitive(result.payloadJson)) - } - result.error?.let { err -> - put( - "error", - buildJsonObject { - put("code", JsonPrimitive(err.code)) - put("message", JsonPrimitive(err.message)) - }, - ) - } - } - try { - request("node.invoke.result", params, timeoutMs = 15_000) - } catch (err: Throwable) { - Log.w(loggerTag, "node.invoke.result failed: ${err.message ?: err::class.java.simpleName}") - } - } - - private fun invokeErrorFromThrowable(err: Throwable): InvokeResult { - val msg = err.message?.trim().takeIf { !it.isNullOrEmpty() } ?: err::class.java.simpleName - val parts = msg.split(":", limit = 2) - if (parts.size == 2) { - val code = parts[0].trim() - val rest = parts[1].trim() - if (code.isNotEmpty() && code.all { it.isUpperCase() || it == '_' }) { - return InvokeResult.error(code = code, message = rest.ifEmpty { msg }) - } - } - return InvokeResult.error(code = "UNAVAILABLE", message = msg) - } - - private fun failPending() { - for ((_, waiter) in pending) { - waiter.cancel() - } - pending.clear() - } - } - - private suspend fun runLoop() { - var attempt = 0 - while (scope.isActive) { - val target = desired - if (target == null) { - currentConnection?.closeQuietly() - currentConnection = null - delay(250) - continue - } - - try { - onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…") - connectOnce(target) - attempt = 0 - } catch (err: Throwable) { - attempt += 1 - onDisconnected("Gateway error: ${err.message ?: err::class.java.simpleName}") - val sleepMs = minOf(8_000L, (350.0 * Math.pow(1.7, attempt.toDouble())).toLong()) - delay(sleepMs) - } - } - } - - private suspend fun connectOnce(target: DesiredConnection) = withContext(Dispatchers.IO) { - val conn = Connection(target.endpoint, target.token, target.password, target.options, target.tls) - currentConnection = conn - try { - conn.connect() - conn.awaitClose() - } finally { - currentConnection = null - canvasHostUrl = null - mainSessionKey = null - } - } - - private fun buildDeviceAuthPayload( - deviceId: String, - clientId: String, - clientMode: String, - role: String, - scopes: List, - signedAtMs: Long, - token: String?, - nonce: String?, - ): String { - val scopeString = scopes.joinToString(",") - val authToken = token.orEmpty() - val version = if (nonce.isNullOrBlank()) "v1" else "v2" - val parts = - mutableListOf( - version, - deviceId, - clientId, - clientMode, - role, - scopeString, - signedAtMs.toString(), - authToken, - ) - if (!nonce.isNullOrBlank()) { - parts.add(nonce) - } - return parts.joinToString("|") - } - - private fun normalizeCanvasHostUrl(raw: String?, endpoint: GatewayEndpoint): String? { - val trimmed = raw?.trim().orEmpty() - val parsed = trimmed.takeIf { it.isNotBlank() }?.let { runCatching { java.net.URI(it) }.getOrNull() } - val host = parsed?.host?.trim().orEmpty() - val port = parsed?.port ?: -1 - val scheme = parsed?.scheme?.trim().orEmpty().ifBlank { "http" } - - // Detect TLS reverse proxy: endpoint on port 443, or domain-based host - val tls = endpoint.port == 443 || endpoint.host.contains(".") - - // If raw URL is a non-loopback address AND we're behind TLS reverse proxy, - // fix the port (gateway sends its internal port like 18789, but we need 443 via Caddy) - if (trimmed.isNotBlank() && !isLoopbackHost(host)) { - if (tls && port > 0 && port != 443) { - // Rewrite the URL to use the reverse proxy port instead of the raw gateway port - val fixedScheme = "https" - val formattedHost = if (host.contains(":")) "[${host}]" else host - return "$fixedScheme://$formattedHost" - } - return trimmed - } - - val fallbackHost = - endpoint.tailnetDns?.trim().takeIf { !it.isNullOrEmpty() } - ?: endpoint.lanHost?.trim().takeIf { !it.isNullOrEmpty() } - ?: endpoint.host.trim() - if (fallbackHost.isEmpty()) return trimmed.ifBlank { null } - - // When connecting through a reverse proxy (TLS on standard port), use the - // connection endpoint's scheme and port instead of the raw canvas port. - val fallbackScheme = if (tls) "https" else scheme - // Behind reverse proxy, always use the proxy port (443), not the raw canvas port - val fallbackPort = if (tls) endpoint.port else (endpoint.canvasPort ?: endpoint.port) - val formattedHost = if (fallbackHost.contains(":")) "[${fallbackHost}]" else fallbackHost - val portSuffix = if ((fallbackScheme == "https" && fallbackPort == 443) || (fallbackScheme == "http" && fallbackPort == 80)) "" else ":$fallbackPort" - return "$fallbackScheme://$formattedHost$portSuffix" - } - - private fun isLoopbackHost(raw: String?): Boolean { - val host = raw?.trim()?.lowercase().orEmpty() - if (host.isEmpty()) return false - if (host == "localhost") return true - if (host == "::1") return true - if (host == "0.0.0.0" || host == "::") return true - return host.startsWith("127.") - } -} - -private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject - -private fun JsonElement?.asStringOrNull(): String? = - when (this) { - is JsonNull -> null - is JsonPrimitive -> content - else -> null - } - -private fun JsonElement?.asBooleanOrNull(): Boolean? = - when (this) { - is JsonPrimitive -> { - val c = content.trim() - when { - c.equals("true", ignoreCase = true) -> true - c.equals("false", ignoreCase = true) -> false - else -> null - } - } - else -> null - } - -private fun JsonElement?.asLongOrNull(): Long? = - when (this) { - is JsonPrimitive -> content.toLongOrNull() - else -> null - } - -private fun parseJsonOrNull(payload: String): JsonElement? { - val trimmed = payload.trim() - if (trimmed.isEmpty()) return null - return try { - Json.parseToJsonElement(trimmed) - } catch (_: Throwable) { - null - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayTls.kt b/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayTls.kt deleted file mode 100644 index 0726c94fc97..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayTls.kt +++ /dev/null @@ -1,159 +0,0 @@ -package ai.openclaw.android.gateway - -import android.annotation.SuppressLint -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.net.InetSocketAddress -import java.security.MessageDigest -import java.security.SecureRandom -import java.security.cert.CertificateException -import java.security.cert.X509Certificate -import java.util.Locale -import javax.net.ssl.HttpsURLConnection -import javax.net.ssl.HostnameVerifier -import javax.net.ssl.SSLContext -import javax.net.ssl.SSLParameters -import javax.net.ssl.SSLSocketFactory -import javax.net.ssl.SNIHostName -import javax.net.ssl.SSLSocket -import javax.net.ssl.TrustManagerFactory -import javax.net.ssl.X509TrustManager - -data class GatewayTlsParams( - val required: Boolean, - val expectedFingerprint: String?, - val allowTOFU: Boolean, - val stableId: String, -) - -data class GatewayTlsConfig( - val sslSocketFactory: SSLSocketFactory, - val trustManager: X509TrustManager, - val hostnameVerifier: HostnameVerifier, -) - -fun buildGatewayTlsConfig( - params: GatewayTlsParams?, - onStore: ((String) -> Unit)? = null, -): GatewayTlsConfig? { - if (params == null) return null - val expected = params.expectedFingerprint?.let(::normalizeFingerprint) - val defaultTrust = defaultTrustManager() - @SuppressLint("CustomX509TrustManager") - val trustManager = - object : X509TrustManager { - override fun checkClientTrusted(chain: Array, authType: String) { - defaultTrust.checkClientTrusted(chain, authType) - } - - override fun checkServerTrusted(chain: Array, authType: String) { - if (chain.isEmpty()) throw CertificateException("empty certificate chain") - val fingerprint = sha256Hex(chain[0].encoded) - if (expected != null) { - if (fingerprint != expected) { - throw CertificateException("gateway TLS fingerprint mismatch") - } - return - } - if (params.allowTOFU) { - onStore?.invoke(fingerprint) - return - } - defaultTrust.checkServerTrusted(chain, authType) - } - - override fun getAcceptedIssuers(): Array = defaultTrust.acceptedIssuers - } - - val context = SSLContext.getInstance("TLS") - context.init(null, arrayOf(trustManager), SecureRandom()) - val verifier = - if (expected != null || params.allowTOFU) { - // When pinning, we intentionally ignore hostname mismatch (service discovery often yields IPs). - HostnameVerifier { _, _ -> true } - } else { - HttpsURLConnection.getDefaultHostnameVerifier() - } - return GatewayTlsConfig( - sslSocketFactory = context.socketFactory, - trustManager = trustManager, - hostnameVerifier = verifier, - ) -} - -suspend fun probeGatewayTlsFingerprint( - host: String, - port: Int, - timeoutMs: Int = 3_000, -): String? { - val trimmedHost = host.trim() - if (trimmedHost.isEmpty()) return null - if (port !in 1..65535) return null - - return withContext(Dispatchers.IO) { - val trustAll = - @SuppressLint("CustomX509TrustManager", "TrustAllX509TrustManager") - object : X509TrustManager { - @SuppressLint("TrustAllX509TrustManager") - override fun checkClientTrusted(chain: Array, authType: String) {} - @SuppressLint("TrustAllX509TrustManager") - override fun checkServerTrusted(chain: Array, authType: String) {} - override fun getAcceptedIssuers(): Array = emptyArray() - } - - val context = SSLContext.getInstance("TLS") - context.init(null, arrayOf(trustAll), SecureRandom()) - - val socket = (context.socketFactory.createSocket() as SSLSocket) - try { - socket.soTimeout = timeoutMs - socket.connect(InetSocketAddress(trimmedHost, port), timeoutMs) - - // Best-effort SNI for hostnames (avoid crashing on IP literals). - try { - if (trimmedHost.any { it.isLetter() }) { - val params = SSLParameters() - params.serverNames = listOf(SNIHostName(trimmedHost)) - socket.sslParameters = params - } - } catch (_: Throwable) { - // ignore - } - - socket.startHandshake() - val cert = socket.session.peerCertificates.firstOrNull() as? X509Certificate ?: return@withContext null - sha256Hex(cert.encoded) - } catch (_: Throwable) { - null - } finally { - try { - socket.close() - } catch (_: Throwable) { - // ignore - } - } - } -} - -private fun defaultTrustManager(): X509TrustManager { - val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) - factory.init(null as java.security.KeyStore?) - val trust = - factory.trustManagers.firstOrNull { it is X509TrustManager } as? X509TrustManager - return trust ?: throw IllegalStateException("No default X509TrustManager found") -} - -private fun sha256Hex(data: ByteArray): String { - val digest = MessageDigest.getInstance("SHA-256").digest(data) - val out = StringBuilder(digest.size * 2) - for (byte in digest) { - out.append(String.format(Locale.US, "%02x", byte)) - } - return out.toString() -} - -private fun normalizeFingerprint(raw: String): String { - val stripped = raw.trim() - .replace(Regex("^sha-?256\\s*:?\\s*", RegexOption.IGNORE_CASE), "") - return stripped.lowercase(Locale.US).filter { it in '0'..'9' || it in 'a'..'f' } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/A2UIHandler.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/A2UIHandler.kt deleted file mode 100644 index 4e7ee32b996..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/A2UIHandler.kt +++ /dev/null @@ -1,146 +0,0 @@ -package ai.openclaw.android.node - -import ai.openclaw.android.gateway.GatewaySession -import kotlinx.coroutines.delay -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive - -class A2UIHandler( - private val canvas: CanvasController, - private val json: Json, - private val getNodeCanvasHostUrl: () -> String?, - private val getOperatorCanvasHostUrl: () -> String?, -) { - fun resolveA2uiHostUrl(): String? { - val nodeRaw = getNodeCanvasHostUrl()?.trim().orEmpty() - val operatorRaw = getOperatorCanvasHostUrl()?.trim().orEmpty() - val raw = if (nodeRaw.isNotBlank()) nodeRaw else operatorRaw - if (raw.isBlank()) return null - val base = raw.trimEnd('/') - return "${base}/__openclaw__/a2ui/?platform=android" - } - - suspend fun ensureA2uiReady(a2uiUrl: String): Boolean { - try { - val already = canvas.eval(a2uiReadyCheckJS) - if (already == "true") return true - } catch (_: Throwable) { - // ignore - } - - canvas.navigate(a2uiUrl) - repeat(50) { - try { - val ready = canvas.eval(a2uiReadyCheckJS) - if (ready == "true") return true - } catch (_: Throwable) { - // ignore - } - delay(120) - } - return false - } - - fun decodeA2uiMessages(command: String, paramsJson: String?): String { - val raw = paramsJson?.trim().orEmpty() - if (raw.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: paramsJSON required") - - val obj = - json.parseToJsonElement(raw) as? JsonObject - ?: throw IllegalArgumentException("INVALID_REQUEST: expected object params") - - val jsonlField = (obj["jsonl"] as? JsonPrimitive)?.content?.trim().orEmpty() - val hasMessagesArray = obj["messages"] is JsonArray - - if (command == "canvas.a2ui.pushJSONL" || (!hasMessagesArray && jsonlField.isNotBlank())) { - val jsonl = jsonlField - if (jsonl.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: jsonl required") - val messages = - jsonl - .lineSequence() - .map { it.trim() } - .filter { it.isNotBlank() } - .mapIndexed { idx, line -> - val el = json.parseToJsonElement(line) - val msg = - el as? JsonObject - ?: throw IllegalArgumentException("A2UI JSONL line ${idx + 1}: expected a JSON object") - validateA2uiV0_8(msg, idx + 1) - msg - } - .toList() - return JsonArray(messages).toString() - } - - val arr = obj["messages"] as? JsonArray ?: throw IllegalArgumentException("INVALID_REQUEST: messages[] required") - val out = - arr.mapIndexed { idx, el -> - val msg = - el as? JsonObject - ?: throw IllegalArgumentException("A2UI messages[${idx}]: expected a JSON object") - validateA2uiV0_8(msg, idx + 1) - msg - } - return JsonArray(out).toString() - } - - private fun validateA2uiV0_8(msg: JsonObject, lineNumber: Int) { - if (msg.containsKey("createSurface")) { - throw IllegalArgumentException( - "A2UI JSONL line $lineNumber: looks like A2UI v0.9 (`createSurface`). Canvas supports v0.8 messages only.", - ) - } - val allowed = setOf("beginRendering", "surfaceUpdate", "dataModelUpdate", "deleteSurface") - val matched = msg.keys.filter { allowed.contains(it) } - if (matched.size != 1) { - val found = msg.keys.sorted().joinToString(", ") - throw IllegalArgumentException( - "A2UI JSONL line $lineNumber: expected exactly one of ${allowed.sorted().joinToString(", ")}; found: $found", - ) - } - } - - companion object { - const val a2uiReadyCheckJS: String = - """ - (() => { - try { - const host = globalThis.openclawA2UI; - return !!host && typeof host.applyMessages === 'function'; - } catch (_) { - return false; - } - })() - """ - - const val a2uiResetJS: String = - """ - (() => { - try { - const host = globalThis.openclawA2UI; - if (!host) return { ok: false, error: "missing openclawA2UI" }; - return host.reset(); - } catch (e) { - return { ok: false, error: String(e?.message ?? e) }; - } - })() - """ - - fun a2uiApplyMessagesJS(messagesJson: String): String { - return """ - (() => { - try { - const host = globalThis.openclawA2UI; - if (!host) return { ok: false, error: "missing openclawA2UI" }; - const messages = $messagesJson; - return host.applyMessages(messages); - } catch (e) { - return { ok: false, error: String(e?.message ?? e) }; - } - })() - """.trimIndent() - } - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/AppUpdateHandler.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/AppUpdateHandler.kt deleted file mode 100644 index e54c846c0fb..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/AppUpdateHandler.kt +++ /dev/null @@ -1,295 +0,0 @@ -package ai.openclaw.android.node - -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import ai.openclaw.android.InstallResultReceiver -import ai.openclaw.android.MainActivity -import ai.openclaw.android.gateway.GatewayEndpoint -import ai.openclaw.android.gateway.GatewaySession -import java.io.File -import java.net.URI -import java.security.MessageDigest -import java.util.Locale -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.put - -private val SHA256_HEX = Regex("^[a-fA-F0-9]{64}$") - -internal data class AppUpdateRequest( - val url: String, - val expectedSha256: String, -) - -internal fun parseAppUpdateRequest(paramsJson: String?, connectedHost: String?): AppUpdateRequest { - val params = - try { - paramsJson?.let { Json.parseToJsonElement(it).jsonObject } - } catch (_: Throwable) { - throw IllegalArgumentException("params must be valid JSON") - } ?: throw IllegalArgumentException("missing 'url' parameter") - - val urlRaw = - params["url"]?.jsonPrimitive?.content?.trim().orEmpty() - .ifEmpty { throw IllegalArgumentException("missing 'url' parameter") } - val sha256Raw = - params["sha256"]?.jsonPrimitive?.content?.trim().orEmpty() - .ifEmpty { throw IllegalArgumentException("missing 'sha256' parameter") } - if (!SHA256_HEX.matches(sha256Raw)) { - throw IllegalArgumentException("invalid 'sha256' parameter (expected 64 hex chars)") - } - - val uri = - try { - URI(urlRaw) - } catch (_: Throwable) { - throw IllegalArgumentException("invalid 'url' parameter") - } - val scheme = uri.scheme?.lowercase(Locale.US).orEmpty() - if (scheme != "https") { - throw IllegalArgumentException("url must use https") - } - if (!uri.userInfo.isNullOrBlank()) { - throw IllegalArgumentException("url must not include credentials") - } - val host = uri.host?.lowercase(Locale.US) ?: throw IllegalArgumentException("url host required") - val connectedHostNormalized = connectedHost?.trim()?.lowercase(Locale.US).orEmpty() - if (connectedHostNormalized.isNotEmpty() && host != connectedHostNormalized) { - throw IllegalArgumentException("url host must match connected gateway host") - } - - return AppUpdateRequest( - url = uri.toASCIIString(), - expectedSha256 = sha256Raw.lowercase(Locale.US), - ) -} - -internal fun sha256Hex(file: File): String { - val digest = MessageDigest.getInstance("SHA-256") - file.inputStream().use { input -> - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - while (true) { - val read = input.read(buffer) - if (read < 0) break - if (read == 0) continue - digest.update(buffer, 0, read) - } - } - val out = StringBuilder(64) - for (byte in digest.digest()) { - out.append(String.format(Locale.US, "%02x", byte)) - } - return out.toString() -} - -class AppUpdateHandler( - private val appContext: Context, - private val connectedEndpoint: () -> GatewayEndpoint?, -) { - - fun handleUpdate(paramsJson: String?): GatewaySession.InvokeResult { - try { - val updateRequest = - try { - parseAppUpdateRequest(paramsJson, connectedEndpoint()?.host) - } catch (err: IllegalArgumentException) { - return GatewaySession.InvokeResult.error( - code = "INVALID_REQUEST", - message = "INVALID_REQUEST: ${err.message ?: "invalid app.update params"}", - ) - } - val url = updateRequest.url - val expectedSha256 = updateRequest.expectedSha256 - - android.util.Log.w("openclaw", "app.update: downloading from $url") - - val notifId = 9001 - val channelId = "app_update" - val notifManager = appContext.getSystemService(android.content.Context.NOTIFICATION_SERVICE) as android.app.NotificationManager - - // Create notification channel (required for Android 8+) - val channel = android.app.NotificationChannel(channelId, "App Updates", android.app.NotificationManager.IMPORTANCE_LOW) - notifManager.createNotificationChannel(channel) - - // PendingIntent to open the app when notification is tapped - val launchIntent = Intent(appContext, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP - } - val launchPi = PendingIntent.getActivity(appContext, 0, launchIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - - // Launch download async so the invoke returns immediately - CoroutineScope(Dispatchers.IO).launch { - try { - val cacheDir = java.io.File(appContext.cacheDir, "updates") - cacheDir.mkdirs() - val file = java.io.File(cacheDir, "update.apk") - if (file.exists()) file.delete() - - // Show initial progress notification - fun buildProgressNotif(progress: Int, max: Int, text: String): android.app.Notification { - return android.app.Notification.Builder(appContext, channelId) - .setSmallIcon(android.R.drawable.stat_sys_download) - .setContentTitle("OpenClaw Update") - .setContentText(text) - .setProgress(max, progress, max == 0) - - .setContentIntent(launchPi) - .setOngoing(true) - .build() - } - notifManager.notify(notifId, buildProgressNotif(0, 0, "Connecting...")) - - val client = okhttp3.OkHttpClient.Builder() - .connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS) - .readTimeout(300, java.util.concurrent.TimeUnit.SECONDS) - .build() - val request = okhttp3.Request.Builder().url(url).build() - val response = client.newCall(request).execute() - if (!response.isSuccessful) { - notifManager.cancel(notifId) - notifManager.notify(notifId, android.app.Notification.Builder(appContext, channelId) - .setSmallIcon(android.R.drawable.stat_notify_error) - .setContentTitle("Update Failed") - - .setContentIntent(launchPi) - .setContentText("HTTP ${response.code}") - .build()) - return@launch - } - - val contentLength = response.body?.contentLength() ?: -1L - val body = response.body ?: run { - notifManager.cancel(notifId) - return@launch - } - - // Download with progress tracking - var totalBytes = 0L - var lastNotifUpdate = 0L - body.byteStream().use { input -> - file.outputStream().use { output -> - val buffer = ByteArray(8192) - while (true) { - val bytesRead = input.read(buffer) - if (bytesRead == -1) break - output.write(buffer, 0, bytesRead) - totalBytes += bytesRead - - // Update notification at most every 500ms - val now = System.currentTimeMillis() - if (now - lastNotifUpdate > 500) { - lastNotifUpdate = now - if (contentLength > 0) { - val pct = ((totalBytes * 100) / contentLength).toInt() - val mb = String.format(Locale.US, "%.1f", totalBytes / 1048576.0) - val totalMb = String.format(Locale.US, "%.1f", contentLength / 1048576.0) - notifManager.notify(notifId, buildProgressNotif(pct, 100, "$mb / $totalMb MB ($pct%)")) - } else { - val mb = String.format(Locale.US, "%.1f", totalBytes / 1048576.0) - notifManager.notify(notifId, buildProgressNotif(0, 0, "${mb} MB downloaded")) - } - } - } - } - } - - android.util.Log.w("openclaw", "app.update: downloaded ${file.length()} bytes") - val actualSha256 = sha256Hex(file) - if (actualSha256 != expectedSha256) { - android.util.Log.e( - "openclaw", - "app.update: sha256 mismatch expected=$expectedSha256 actual=$actualSha256", - ) - file.delete() - notifManager.cancel(notifId) - notifManager.notify( - notifId, - android.app.Notification.Builder(appContext, channelId) - .setSmallIcon(android.R.drawable.stat_notify_error) - .setContentTitle("Update Failed") - .setContentIntent(launchPi) - .setContentText("SHA-256 mismatch") - .build(), - ) - return@launch - } - - // Verify file is a valid APK (basic check: ZIP magic bytes) - val magic = file.inputStream().use { it.read().toByte() to it.read().toByte() } - if (magic.first != 0x50.toByte() || magic.second != 0x4B.toByte()) { - android.util.Log.e("openclaw", "app.update: invalid APK (bad magic: ${magic.first}, ${magic.second})") - file.delete() - notifManager.cancel(notifId) - notifManager.notify(notifId, android.app.Notification.Builder(appContext, channelId) - .setSmallIcon(android.R.drawable.stat_notify_error) - .setContentTitle("Update Failed") - - .setContentIntent(launchPi) - .setContentText("Downloaded file is not a valid APK") - .build()) - return@launch - } - - // Use PackageInstaller session API — works from background on API 34+ - // The system handles showing the install confirmation dialog - notifManager.cancel(notifId) - notifManager.notify( - notifId, - android.app.Notification.Builder(appContext, channelId) - .setSmallIcon(android.R.drawable.stat_sys_download_done) - .setContentTitle("Installing Update...") - .setContentIntent(launchPi) - .setContentText("${String.format(Locale.US, "%.1f", totalBytes / 1048576.0)} MB downloaded") - .build(), - ) - - val installer = appContext.packageManager.packageInstaller - val params = android.content.pm.PackageInstaller.SessionParams( - android.content.pm.PackageInstaller.SessionParams.MODE_FULL_INSTALL - ) - params.setSize(file.length()) - val sessionId = installer.createSession(params) - val session = installer.openSession(sessionId) - session.openWrite("openclaw-update.apk", 0, file.length()).use { out -> - file.inputStream().use { inp -> inp.copyTo(out) } - session.fsync(out) - } - // Commit with FLAG_MUTABLE PendingIntent — system requires mutable for PackageInstaller status - val callbackIntent = android.content.Intent(appContext, InstallResultReceiver::class.java) - val pi = android.app.PendingIntent.getBroadcast( - appContext, sessionId, callbackIntent, - android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_MUTABLE - ) - session.commit(pi.intentSender) - android.util.Log.w("openclaw", "app.update: PackageInstaller session committed, waiting for user confirmation") - } catch (err: Throwable) { - android.util.Log.e("openclaw", "app.update: async error", err) - notifManager.cancel(notifId) - notifManager.notify(notifId, android.app.Notification.Builder(appContext, channelId) - .setSmallIcon(android.R.drawable.stat_notify_error) - .setContentTitle("Update Failed") - - .setContentIntent(launchPi) - .setContentText(err.message ?: "Unknown error") - .build()) - } - } - - // Return immediately — download happens in background - return GatewaySession.InvokeResult.ok(buildJsonObject { - put("status", "downloading") - put("url", url) - put("sha256", expectedSha256) - }.toString()) - } catch (err: Throwable) { - android.util.Log.e("openclaw", "app.update: error", err) - return GatewaySession.InvokeResult.error(code = "UNAVAILABLE", message = err.message ?: "update failed") - } - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/CameraCaptureManager.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/CameraCaptureManager.kt deleted file mode 100644 index 65bac915eff..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/CameraCaptureManager.kt +++ /dev/null @@ -1,364 +0,0 @@ -package ai.openclaw.android.node - -import android.Manifest -import android.content.Context -import android.annotation.SuppressLint -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.Matrix -import android.util.Base64 -import android.content.pm.PackageManager -import androidx.exifinterface.media.ExifInterface -import androidx.lifecycle.LifecycleOwner -import androidx.camera.core.CameraSelector -import androidx.camera.core.ImageCapture -import androidx.camera.core.ImageCaptureException -import androidx.camera.lifecycle.ProcessCameraProvider -import androidx.camera.video.FileOutputOptions -import androidx.camera.video.FallbackStrategy -import androidx.camera.video.Quality -import androidx.camera.video.QualitySelector -import androidx.camera.video.Recorder -import androidx.camera.video.Recording -import androidx.camera.video.VideoCapture -import androidx.camera.video.VideoRecordEvent -import androidx.core.content.ContextCompat -import androidx.core.content.ContextCompat.checkSelfPermission -import androidx.core.graphics.scale -import ai.openclaw.android.PermissionRequester -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withTimeout -import kotlinx.coroutines.withContext -import java.io.ByteArrayOutputStream -import java.io.File -import java.util.concurrent.Executor -import kotlin.math.roundToInt -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException - -class CameraCaptureManager(private val context: Context) { - data class Payload(val payloadJson: String) - data class FilePayload(val file: File, val durationMs: Long, val hasAudio: Boolean) - - @Volatile private var lifecycleOwner: LifecycleOwner? = null - @Volatile private var permissionRequester: PermissionRequester? = null - - fun attachLifecycleOwner(owner: LifecycleOwner) { - lifecycleOwner = owner - } - - fun attachPermissionRequester(requester: PermissionRequester) { - permissionRequester = requester - } - - private suspend fun ensureCameraPermission() { - val granted = checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED - if (granted) return - - val requester = permissionRequester - ?: throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission") - val results = requester.requestIfMissing(listOf(Manifest.permission.CAMERA)) - if (results[Manifest.permission.CAMERA] != true) { - throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission") - } - } - - private suspend fun ensureMicPermission() { - val granted = checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED - if (granted) return - - val requester = permissionRequester - ?: throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission") - val results = requester.requestIfMissing(listOf(Manifest.permission.RECORD_AUDIO)) - if (results[Manifest.permission.RECORD_AUDIO] != true) { - throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission") - } - } - - suspend fun snap(paramsJson: String?): Payload = - withContext(Dispatchers.Main) { - ensureCameraPermission() - val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready") - val facing = parseFacing(paramsJson) ?: "front" - val quality = (parseQuality(paramsJson) ?: 0.5).coerceIn(0.1, 1.0) - val maxWidth = parseMaxWidth(paramsJson) ?: 800 - - val provider = context.cameraProvider() - val capture = ImageCapture.Builder().build() - val selector = - if (facing == "front") CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA - - provider.unbindAll() - provider.bindToLifecycle(owner, selector, capture) - - val (bytes, orientation) = capture.takeJpegWithExif(context.mainExecutor()) - val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) - ?: throw IllegalStateException("UNAVAILABLE: failed to decode captured image") - val rotated = rotateBitmapByExif(decoded, orientation) - val scaled = - if (maxWidth > 0 && rotated.width > maxWidth) { - val h = - (rotated.height.toDouble() * (maxWidth.toDouble() / rotated.width.toDouble())) - .toInt() - .coerceAtLeast(1) - rotated.scale(maxWidth, h) - } else { - rotated - } - - val maxPayloadBytes = 5 * 1024 * 1024 - // Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under 5MB (API limit). - val maxEncodedBytes = (maxPayloadBytes / 4) * 3 - val result = - JpegSizeLimiter.compressToLimit( - initialWidth = scaled.width, - initialHeight = scaled.height, - startQuality = (quality * 100.0).roundToInt().coerceIn(10, 100), - maxBytes = maxEncodedBytes, - encode = { width, height, q -> - val bitmap = - if (width == scaled.width && height == scaled.height) { - scaled - } else { - scaled.scale(width, height) - } - val out = ByteArrayOutputStream() - if (!bitmap.compress(Bitmap.CompressFormat.JPEG, q, out)) { - if (bitmap !== scaled) bitmap.recycle() - throw IllegalStateException("UNAVAILABLE: failed to encode JPEG") - } - if (bitmap !== scaled) { - bitmap.recycle() - } - out.toByteArray() - }, - ) - val base64 = Base64.encodeToString(result.bytes, Base64.NO_WRAP) - Payload( - """{"format":"jpg","base64":"$base64","width":${result.width},"height":${result.height}}""", - ) - } - - @SuppressLint("MissingPermission") - suspend fun clip(paramsJson: String?): FilePayload = - withContext(Dispatchers.Main) { - ensureCameraPermission() - val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready") - val facing = parseFacing(paramsJson) ?: "front" - val durationMs = (parseDurationMs(paramsJson) ?: 3_000).coerceIn(200, 60_000) - val includeAudio = parseIncludeAudio(paramsJson) ?: true - if (includeAudio) ensureMicPermission() - - android.util.Log.w("CameraCaptureManager", "clip: start facing=$facing duration=$durationMs audio=$includeAudio") - - val provider = context.cameraProvider() - android.util.Log.w("CameraCaptureManager", "clip: got camera provider") - - // Use LOWEST quality for smallest files over WebSocket - val recorder = Recorder.Builder() - .setQualitySelector( - QualitySelector.from(Quality.LOWEST, FallbackStrategy.lowerQualityOrHigherThan(Quality.LOWEST)) - ) - .build() - val videoCapture = VideoCapture.withOutput(recorder) - val selector = - if (facing == "front") CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA - - // CameraX requires a Preview use case for the camera to start producing frames; - // without it, the encoder may get no data (ERROR_NO_VALID_DATA). - val preview = androidx.camera.core.Preview.Builder().build() - // Provide a dummy SurfaceTexture so the preview pipeline activates - val surfaceTexture = android.graphics.SurfaceTexture(0) - surfaceTexture.setDefaultBufferSize(640, 480) - preview.setSurfaceProvider { request -> - val surface = android.view.Surface(surfaceTexture) - request.provideSurface(surface, context.mainExecutor()) { result -> - surface.release() - surfaceTexture.release() - } - } - - provider.unbindAll() - android.util.Log.w("CameraCaptureManager", "clip: binding preview + videoCapture to lifecycle") - val camera = provider.bindToLifecycle(owner, selector, preview, videoCapture) - android.util.Log.w("CameraCaptureManager", "clip: bound, cameraInfo=${camera.cameraInfo}") - - // Give camera pipeline time to initialize before recording - android.util.Log.w("CameraCaptureManager", "clip: warming up camera 1.5s...") - kotlinx.coroutines.delay(1_500) - - val file = File.createTempFile("openclaw-clip-", ".mp4") - val outputOptions = FileOutputOptions.Builder(file).build() - - val finalized = kotlinx.coroutines.CompletableDeferred() - android.util.Log.w("CameraCaptureManager", "clip: starting recording to ${file.absolutePath}") - val recording: Recording = - videoCapture.output - .prepareRecording(context, outputOptions) - .apply { - if (includeAudio) withAudioEnabled() - } - .start(context.mainExecutor()) { event -> - android.util.Log.w("CameraCaptureManager", "clip: event ${event.javaClass.simpleName}") - if (event is VideoRecordEvent.Status) { - android.util.Log.w("CameraCaptureManager", "clip: recording status update") - } - if (event is VideoRecordEvent.Finalize) { - android.util.Log.w("CameraCaptureManager", "clip: finalize hasError=${event.hasError()} error=${event.error} cause=${event.cause}") - finalized.complete(event) - } - } - - android.util.Log.w("CameraCaptureManager", "clip: recording started, delaying ${durationMs}ms") - try { - kotlinx.coroutines.delay(durationMs.toLong()) - } finally { - android.util.Log.w("CameraCaptureManager", "clip: stopping recording") - recording.stop() - } - - val finalizeEvent = - try { - withTimeout(15_000) { finalized.await() } - } catch (err: Throwable) { - android.util.Log.e("CameraCaptureManager", "clip: finalize timed out", err) - withContext(Dispatchers.IO) { file.delete() } - provider.unbindAll() - throw IllegalStateException("UNAVAILABLE: camera clip finalize timed out") - } - if (finalizeEvent.hasError()) { - android.util.Log.e("CameraCaptureManager", "clip: FAILED error=${finalizeEvent.error}, cause=${finalizeEvent.cause}", finalizeEvent.cause) - // Check file size for debugging - val fileSize = withContext(Dispatchers.IO) { if (file.exists()) file.length() else -1 } - android.util.Log.e("CameraCaptureManager", "clip: file exists=${file.exists()} size=$fileSize") - withContext(Dispatchers.IO) { file.delete() } - provider.unbindAll() - throw IllegalStateException("UNAVAILABLE: camera clip failed (error=${finalizeEvent.error})") - } - - val fileSize = withContext(Dispatchers.IO) { file.length() } - android.util.Log.w("CameraCaptureManager", "clip: SUCCESS file size=$fileSize") - - provider.unbindAll() - - FilePayload(file = file, durationMs = durationMs.toLong(), hasAudio = includeAudio) - } - - private fun rotateBitmapByExif(bitmap: Bitmap, orientation: Int): Bitmap { - val matrix = Matrix() - when (orientation) { - ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f) - ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f) - ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f) - ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f) - ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f) - ExifInterface.ORIENTATION_TRANSPOSE -> { - matrix.postRotate(90f) - matrix.postScale(-1f, 1f) - } - ExifInterface.ORIENTATION_TRANSVERSE -> { - matrix.postRotate(-90f) - matrix.postScale(-1f, 1f) - } - else -> return bitmap - } - val rotated = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) - if (rotated !== bitmap) { - bitmap.recycle() - } - return rotated - } - - private fun parseFacing(paramsJson: String?): String? = - when { - paramsJson?.contains("\"front\"") == true -> "front" - paramsJson?.contains("\"back\"") == true -> "back" - else -> null - } - - private fun parseQuality(paramsJson: String?): Double? = - parseNumber(paramsJson, key = "quality")?.toDoubleOrNull() - - private fun parseMaxWidth(paramsJson: String?): Int? = - parseNumber(paramsJson, key = "maxWidth")?.toIntOrNull() - - private fun parseDurationMs(paramsJson: String?): Int? = - parseNumber(paramsJson, key = "durationMs")?.toIntOrNull() - - private fun parseIncludeAudio(paramsJson: String?): Boolean? { - val raw = paramsJson ?: return null - val key = "\"includeAudio\"" - val idx = raw.indexOf(key) - if (idx < 0) return null - val colon = raw.indexOf(':', idx + key.length) - if (colon < 0) return null - val tail = raw.substring(colon + 1).trimStart() - return when { - tail.startsWith("true") -> true - tail.startsWith("false") -> false - else -> null - } - } - - private fun parseNumber(paramsJson: String?, key: String): String? { - val raw = paramsJson ?: return null - val needle = "\"$key\"" - val idx = raw.indexOf(needle) - if (idx < 0) return null - val colon = raw.indexOf(':', idx + needle.length) - if (colon < 0) return null - val tail = raw.substring(colon + 1).trimStart() - return tail.takeWhile { it.isDigit() || it == '.' } - } - - private fun Context.mainExecutor(): Executor = ContextCompat.getMainExecutor(this) -} - -private suspend fun Context.cameraProvider(): ProcessCameraProvider = - suspendCancellableCoroutine { cont -> - val future = ProcessCameraProvider.getInstance(this) - future.addListener( - { - try { - cont.resume(future.get()) - } catch (e: Exception) { - cont.resumeWithException(e) - } - }, - ContextCompat.getMainExecutor(this), - ) - } - -/** Returns (jpegBytes, exifOrientation) so caller can rotate the decoded bitmap. */ -private suspend fun ImageCapture.takeJpegWithExif(executor: Executor): Pair = - suspendCancellableCoroutine { cont -> - val file = File.createTempFile("openclaw-snap-", ".jpg") - val options = ImageCapture.OutputFileOptions.Builder(file).build() - takePicture( - options, - executor, - object : ImageCapture.OnImageSavedCallback { - override fun onError(exception: ImageCaptureException) { - file.delete() - cont.resumeWithException(exception) - } - - override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) { - try { - val exif = ExifInterface(file.absolutePath) - val orientation = exif.getAttributeInt( - ExifInterface.TAG_ORIENTATION, - ExifInterface.ORIENTATION_NORMAL, - ) - val bytes = file.readBytes() - cont.resume(Pair(bytes, orientation)) - } catch (e: Exception) { - cont.resumeWithException(e) - } finally { - file.delete() - } - } - }, - ) - } diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/CameraHandler.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/CameraHandler.kt deleted file mode 100644 index 658c117ff31..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/CameraHandler.kt +++ /dev/null @@ -1,157 +0,0 @@ -package ai.openclaw.android.node - -import android.content.Context -import ai.openclaw.android.CameraHudKind -import ai.openclaw.android.BuildConfig -import ai.openclaw.android.SecurePrefs -import ai.openclaw.android.gateway.GatewayEndpoint -import ai.openclaw.android.gateway.GatewaySession -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.withContext -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.RequestBody.Companion.asRequestBody - -class CameraHandler( - private val appContext: Context, - private val camera: CameraCaptureManager, - private val prefs: SecurePrefs, - private val connectedEndpoint: () -> GatewayEndpoint?, - private val externalAudioCaptureActive: MutableStateFlow, - private val showCameraHud: (message: String, kind: CameraHudKind, autoHideMs: Long?) -> Unit, - private val triggerCameraFlash: () -> Unit, - private val invokeErrorFromThrowable: (err: Throwable) -> Pair, -) { - - suspend fun handleSnap(paramsJson: String?): GatewaySession.InvokeResult { - val logFile = if (BuildConfig.DEBUG) java.io.File(appContext.cacheDir, "camera_debug.log") else null - fun camLog(msg: String) { - if (!BuildConfig.DEBUG) return - val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.US).format(java.util.Date()) - logFile?.appendText("[$ts] $msg\n") - android.util.Log.w("openclaw", "camera.snap: $msg") - } - try { - logFile?.writeText("") // clear - camLog("starting, params=$paramsJson") - camLog("calling showCameraHud") - showCameraHud("Taking photo…", CameraHudKind.Photo, null) - camLog("calling triggerCameraFlash") - triggerCameraFlash() - val res = - try { - camLog("calling camera.snap()") - val r = camera.snap(paramsJson) - camLog("success, payload size=${r.payloadJson.length}") - r - } catch (err: Throwable) { - camLog("inner error: ${err::class.java.simpleName}: ${err.message}") - camLog("stack: ${err.stackTraceToString().take(2000)}") - val (code, message) = invokeErrorFromThrowable(err) - showCameraHud(message, CameraHudKind.Error, 2200) - return GatewaySession.InvokeResult.error(code = code, message = message) - } - camLog("returning result") - showCameraHud("Photo captured", CameraHudKind.Success, 1600) - return GatewaySession.InvokeResult.ok(res.payloadJson) - } catch (err: Throwable) { - camLog("outer error: ${err::class.java.simpleName}: ${err.message}") - camLog("stack: ${err.stackTraceToString().take(2000)}") - return GatewaySession.InvokeResult.error(code = "UNAVAILABLE", message = err.message ?: "camera snap failed") - } - } - - suspend fun handleClip(paramsJson: String?): GatewaySession.InvokeResult { - val clipLogFile = if (BuildConfig.DEBUG) java.io.File(appContext.cacheDir, "camera_debug.log") else null - fun clipLog(msg: String) { - if (!BuildConfig.DEBUG) return - val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.US).format(java.util.Date()) - clipLogFile?.appendText("[CLIP $ts] $msg\n") - android.util.Log.w("openclaw", "camera.clip: $msg") - } - val includeAudio = paramsJson?.contains("\"includeAudio\":true") != false - if (includeAudio) externalAudioCaptureActive.value = true - try { - clipLogFile?.writeText("") // clear - clipLog("starting, params=$paramsJson includeAudio=$includeAudio") - clipLog("calling showCameraHud") - showCameraHud("Recording…", CameraHudKind.Recording, null) - val filePayload = - try { - clipLog("calling camera.clip()") - val r = camera.clip(paramsJson) - clipLog("success, file size=${r.file.length()}") - r - } catch (err: Throwable) { - clipLog("inner error: ${err::class.java.simpleName}: ${err.message}") - clipLog("stack: ${err.stackTraceToString().take(2000)}") - val (code, message) = invokeErrorFromThrowable(err) - showCameraHud(message, CameraHudKind.Error, 2400) - return GatewaySession.InvokeResult.error(code = code, message = message) - } - // Upload file via HTTP instead of base64 through WebSocket - clipLog("uploading via HTTP...") - val uploadUrl = try { - withContext(Dispatchers.IO) { - val ep = connectedEndpoint() - val gatewayHost = if (ep != null) { - val isHttps = ep.tlsEnabled || ep.port == 443 - if (!isHttps) { - clipLog("refusing to upload over plain HTTP — bearer token would be exposed; falling back to base64") - throw Exception("HTTPS required for upload (bearer token protection)") - } - if (ep.port == 443) "https://${ep.host}" else "https://${ep.host}:${ep.port}" - } else { - clipLog("error: no gateway endpoint connected, cannot upload") - throw Exception("no gateway endpoint connected") - } - val token = prefs.loadGatewayToken() ?: "" - val client = okhttp3.OkHttpClient.Builder() - .connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS) - .writeTimeout(120, java.util.concurrent.TimeUnit.SECONDS) - .readTimeout(30, java.util.concurrent.TimeUnit.SECONDS) - .build() - val body = filePayload.file.asRequestBody("video/mp4".toMediaType()) - val req = okhttp3.Request.Builder() - .url("$gatewayHost/upload/clip.mp4") - .put(body) - .header("Authorization", "Bearer $token") - .build() - clipLog("uploading ${filePayload.file.length()} bytes to $gatewayHost/upload/clip.mp4") - val resp = client.newCall(req).execute() - val respBody = resp.body?.string() ?: "" - clipLog("upload response: ${resp.code} $respBody") - filePayload.file.delete() - if (!resp.isSuccessful) throw Exception("upload failed: HTTP ${resp.code}") - // Parse URL from response - val urlMatch = Regex("\"url\":\"([^\"]+)\"").find(respBody) - urlMatch?.groupValues?.get(1) ?: throw Exception("no url in response: $respBody") - } - } catch (err: Throwable) { - clipLog("upload failed: ${err.message}, falling back to base64") - // Fallback to base64 if upload fails - val bytes = withContext(Dispatchers.IO) { - val b = filePayload.file.readBytes() - filePayload.file.delete() - b - } - val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP) - showCameraHud("Clip captured", CameraHudKind.Success, 1800) - return GatewaySession.InvokeResult.ok( - """{"format":"mp4","base64":"$base64","durationMs":${filePayload.durationMs},"hasAudio":${filePayload.hasAudio}}""" - ) - } - clipLog("returning URL result: $uploadUrl") - showCameraHud("Clip captured", CameraHudKind.Success, 1800) - return GatewaySession.InvokeResult.ok( - """{"format":"mp4","url":"$uploadUrl","durationMs":${filePayload.durationMs},"hasAudio":${filePayload.hasAudio}}""" - ) - } catch (err: Throwable) { - clipLog("outer error: ${err::class.java.simpleName}: ${err.message}") - clipLog("stack: ${err.stackTraceToString().take(2000)}") - return GatewaySession.InvokeResult.error(code = "UNAVAILABLE", message = err.message ?: "camera clip failed") - } finally { - if (includeAudio) externalAudioCaptureActive.value = false - } - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/CanvasController.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/CanvasController.kt deleted file mode 100644 index c46770a6367..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/CanvasController.kt +++ /dev/null @@ -1,264 +0,0 @@ -package ai.openclaw.android.node - -import android.graphics.Bitmap -import android.graphics.Canvas -import android.os.Looper -import android.util.Log -import android.webkit.WebView -import androidx.core.graphics.createBitmap -import androidx.core.graphics.scale -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withContext -import java.io.ByteArrayOutputStream -import android.util.Base64 -import org.json.JSONObject -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import ai.openclaw.android.BuildConfig -import kotlin.coroutines.resume - -class CanvasController { - enum class SnapshotFormat(val rawValue: String) { - Png("png"), - Jpeg("jpeg"), - } - - @Volatile private var webView: WebView? = null - @Volatile private var url: String? = null - @Volatile private var debugStatusEnabled: Boolean = false - @Volatile private var debugStatusTitle: String? = null - @Volatile private var debugStatusSubtitle: String? = null - - private val scaffoldAssetUrl = "file:///android_asset/CanvasScaffold/scaffold.html" - - private fun clampJpegQuality(quality: Double?): Int { - val q = (quality ?: 0.82).coerceIn(0.1, 1.0) - return (q * 100.0).toInt().coerceIn(1, 100) - } - - fun attach(webView: WebView) { - this.webView = webView - reload() - applyDebugStatus() - } - - fun navigate(url: String) { - val trimmed = url.trim() - this.url = if (trimmed.isBlank() || trimmed == "/") null else trimmed - reload() - } - - fun currentUrl(): String? = url - - fun isDefaultCanvas(): Boolean = url == null - - fun setDebugStatusEnabled(enabled: Boolean) { - debugStatusEnabled = enabled - applyDebugStatus() - } - - fun setDebugStatus(title: String?, subtitle: String?) { - debugStatusTitle = title - debugStatusSubtitle = subtitle - applyDebugStatus() - } - - fun onPageFinished() { - applyDebugStatus() - } - - private inline fun withWebViewOnMain(crossinline block: (WebView) -> Unit) { - val wv = webView ?: return - if (Looper.myLooper() == Looper.getMainLooper()) { - block(wv) - } else { - wv.post { block(wv) } - } - } - - private fun reload() { - val currentUrl = url - withWebViewOnMain { wv -> - if (currentUrl == null) { - if (BuildConfig.DEBUG) { - Log.d("OpenClawCanvas", "load scaffold: $scaffoldAssetUrl") - } - wv.loadUrl(scaffoldAssetUrl) - } else { - if (BuildConfig.DEBUG) { - Log.d("OpenClawCanvas", "load url: $currentUrl") - } - wv.loadUrl(currentUrl) - } - } - } - - private fun applyDebugStatus() { - val enabled = debugStatusEnabled - val title = debugStatusTitle - val subtitle = debugStatusSubtitle - withWebViewOnMain { wv -> - val titleJs = title?.let { JSONObject.quote(it) } ?: "null" - val subtitleJs = subtitle?.let { JSONObject.quote(it) } ?: "null" - val js = """ - (() => { - try { - const api = globalThis.__openclaw; - if (!api) return; - if (typeof api.setDebugStatusEnabled === 'function') { - api.setDebugStatusEnabled(${if (enabled) "true" else "false"}); - } - if (!${if (enabled) "true" else "false"}) return; - if (typeof api.setStatus === 'function') { - api.setStatus($titleJs, $subtitleJs); - } - } catch (_) {} - })(); - """.trimIndent() - wv.evaluateJavascript(js, null) - } - } - - suspend fun eval(javaScript: String): String = - withContext(Dispatchers.Main) { - val wv = webView ?: throw IllegalStateException("no webview") - suspendCancellableCoroutine { cont -> - wv.evaluateJavascript(javaScript) { result -> - cont.resume(result ?: "") - } - } - } - - suspend fun snapshotPngBase64(maxWidth: Int?): String = - withContext(Dispatchers.Main) { - val wv = webView ?: throw IllegalStateException("no webview") - val bmp = wv.captureBitmap() - val scaled = - if (maxWidth != null && maxWidth > 0 && bmp.width > maxWidth) { - val h = (bmp.height.toDouble() * (maxWidth.toDouble() / bmp.width.toDouble())).toInt().coerceAtLeast(1) - bmp.scale(maxWidth, h) - } else { - bmp - } - - val out = ByteArrayOutputStream() - scaled.compress(Bitmap.CompressFormat.PNG, 100, out) - Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP) - } - - suspend fun snapshotBase64(format: SnapshotFormat, quality: Double?, maxWidth: Int?): String = - withContext(Dispatchers.Main) { - val wv = webView ?: throw IllegalStateException("no webview") - val bmp = wv.captureBitmap() - val scaled = - if (maxWidth != null && maxWidth > 0 && bmp.width > maxWidth) { - val h = (bmp.height.toDouble() * (maxWidth.toDouble() / bmp.width.toDouble())).toInt().coerceAtLeast(1) - bmp.scale(maxWidth, h) - } else { - bmp - } - - val out = ByteArrayOutputStream() - val (compressFormat, compressQuality) = - when (format) { - SnapshotFormat.Png -> Bitmap.CompressFormat.PNG to 100 - SnapshotFormat.Jpeg -> Bitmap.CompressFormat.JPEG to clampJpegQuality(quality) - } - scaled.compress(compressFormat, compressQuality, out) - Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP) - } - - private suspend fun WebView.captureBitmap(): Bitmap = - suspendCancellableCoroutine { cont -> - val width = width.coerceAtLeast(1) - val height = height.coerceAtLeast(1) - val bitmap = createBitmap(width, height, Bitmap.Config.ARGB_8888) - - // WebView isn't supported by PixelCopy.request(...) directly; draw() is the most reliable - // cross-version snapshot for this lightweight "canvas" use-case. - draw(Canvas(bitmap)) - cont.resume(bitmap) - } - - companion object { - data class SnapshotParams(val format: SnapshotFormat, val quality: Double?, val maxWidth: Int?) - - fun parseNavigateUrl(paramsJson: String?): String { - val obj = parseParamsObject(paramsJson) ?: return "" - return obj.string("url").trim() - } - - fun parseEvalJs(paramsJson: String?): String? { - val obj = parseParamsObject(paramsJson) ?: return null - val js = obj.string("javaScript").trim() - return js.takeIf { it.isNotBlank() } - } - - fun parseSnapshotMaxWidth(paramsJson: String?): Int? { - val obj = parseParamsObject(paramsJson) ?: return null - if (!obj.containsKey("maxWidth")) return null - val width = obj.int("maxWidth") ?: 0 - return width.takeIf { it > 0 } - } - - fun parseSnapshotFormat(paramsJson: String?): SnapshotFormat { - val obj = parseParamsObject(paramsJson) ?: return SnapshotFormat.Jpeg - val raw = obj.string("format").trim().lowercase() - return when (raw) { - "png" -> SnapshotFormat.Png - "jpeg", "jpg" -> SnapshotFormat.Jpeg - "" -> SnapshotFormat.Jpeg - else -> SnapshotFormat.Jpeg - } - } - - fun parseSnapshotQuality(paramsJson: String?): Double? { - val obj = parseParamsObject(paramsJson) ?: return null - if (!obj.containsKey("quality")) return null - val q = obj.double("quality") ?: Double.NaN - if (!q.isFinite()) return null - return q.coerceIn(0.1, 1.0) - } - - fun parseSnapshotParams(paramsJson: String?): SnapshotParams { - return SnapshotParams( - format = parseSnapshotFormat(paramsJson), - quality = parseSnapshotQuality(paramsJson), - maxWidth = parseSnapshotMaxWidth(paramsJson), - ) - } - - private val json = Json { ignoreUnknownKeys = true } - - private fun parseParamsObject(paramsJson: String?): JsonObject? { - val raw = paramsJson?.trim().orEmpty() - if (raw.isEmpty()) return null - return try { - json.parseToJsonElement(raw).asObjectOrNull() - } catch (_: Throwable) { - null - } - } - - private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject - - private fun JsonObject.string(key: String): String { - val prim = this[key] as? JsonPrimitive ?: return "" - val raw = prim.content - return raw.takeIf { it != "null" }.orEmpty() - } - - private fun JsonObject.int(key: String): Int? { - val prim = this[key] as? JsonPrimitive ?: return null - return prim.content.toIntOrNull() - } - - private fun JsonObject.double(key: String): Double? { - val prim = this[key] as? JsonPrimitive ?: return null - return prim.content.toDoubleOrNull() - } - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/ConnectionManager.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/ConnectionManager.kt deleted file mode 100644 index d15d928e0a4..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/ConnectionManager.kt +++ /dev/null @@ -1,188 +0,0 @@ -package ai.openclaw.android.node - -import android.os.Build -import ai.openclaw.android.BuildConfig -import ai.openclaw.android.SecurePrefs -import ai.openclaw.android.gateway.GatewayClientInfo -import ai.openclaw.android.gateway.GatewayConnectOptions -import ai.openclaw.android.gateway.GatewayEndpoint -import ai.openclaw.android.gateway.GatewayTlsParams -import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand -import ai.openclaw.android.protocol.OpenClawCanvasCommand -import ai.openclaw.android.protocol.OpenClawCameraCommand -import ai.openclaw.android.protocol.OpenClawLocationCommand -import ai.openclaw.android.protocol.OpenClawScreenCommand -import ai.openclaw.android.protocol.OpenClawSmsCommand -import ai.openclaw.android.protocol.OpenClawCapability -import ai.openclaw.android.LocationMode -import ai.openclaw.android.VoiceWakeMode - -class ConnectionManager( - private val prefs: SecurePrefs, - private val cameraEnabled: () -> Boolean, - private val locationMode: () -> LocationMode, - private val voiceWakeMode: () -> VoiceWakeMode, - private val smsAvailable: () -> Boolean, - private val hasRecordAudioPermission: () -> Boolean, - private val manualTls: () -> Boolean, -) { - companion object { - internal fun resolveTlsParamsForEndpoint( - endpoint: GatewayEndpoint, - storedFingerprint: String?, - manualTlsEnabled: Boolean, - ): GatewayTlsParams? { - val stableId = endpoint.stableId - val stored = storedFingerprint?.trim().takeIf { !it.isNullOrEmpty() } - val isManual = stableId.startsWith("manual|") - - if (isManual) { - if (!manualTlsEnabled) return null - if (!stored.isNullOrBlank()) { - return GatewayTlsParams( - required = true, - expectedFingerprint = stored, - allowTOFU = false, - stableId = stableId, - ) - } - return GatewayTlsParams( - required = true, - expectedFingerprint = null, - allowTOFU = false, - stableId = stableId, - ) - } - - // Prefer stored pins. Never let discovery-provided TXT override a stored fingerprint. - if (!stored.isNullOrBlank()) { - return GatewayTlsParams( - required = true, - expectedFingerprint = stored, - allowTOFU = false, - stableId = stableId, - ) - } - - val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank() - if (hinted) { - // TXT is unauthenticated. Do not treat the advertised fingerprint as authoritative. - return GatewayTlsParams( - required = true, - expectedFingerprint = null, - allowTOFU = false, - stableId = stableId, - ) - } - - return null - } - } - - fun buildInvokeCommands(): List = - buildList { - add(OpenClawCanvasCommand.Present.rawValue) - add(OpenClawCanvasCommand.Hide.rawValue) - add(OpenClawCanvasCommand.Navigate.rawValue) - add(OpenClawCanvasCommand.Eval.rawValue) - add(OpenClawCanvasCommand.Snapshot.rawValue) - add(OpenClawCanvasA2UICommand.Push.rawValue) - add(OpenClawCanvasA2UICommand.PushJSONL.rawValue) - add(OpenClawCanvasA2UICommand.Reset.rawValue) - add(OpenClawScreenCommand.Record.rawValue) - if (cameraEnabled()) { - add(OpenClawCameraCommand.Snap.rawValue) - add(OpenClawCameraCommand.Clip.rawValue) - } - if (locationMode() != LocationMode.Off) { - add(OpenClawLocationCommand.Get.rawValue) - } - if (smsAvailable()) { - add(OpenClawSmsCommand.Send.rawValue) - } - if (BuildConfig.DEBUG) { - add("debug.logs") - add("debug.ed25519") - } - add("app.update") - } - - fun buildCapabilities(): List = - buildList { - add(OpenClawCapability.Canvas.rawValue) - add(OpenClawCapability.Screen.rawValue) - if (cameraEnabled()) add(OpenClawCapability.Camera.rawValue) - if (smsAvailable()) add(OpenClawCapability.Sms.rawValue) - if (voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission()) { - add(OpenClawCapability.VoiceWake.rawValue) - } - if (locationMode() != LocationMode.Off) { - add(OpenClawCapability.Location.rawValue) - } - } - - fun resolvedVersionName(): String { - val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" } - return if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) { - "$versionName-dev" - } else { - versionName - } - } - - fun resolveModelIdentifier(): String? { - return listOfNotNull(Build.MANUFACTURER, Build.MODEL) - .joinToString(" ") - .trim() - .ifEmpty { null } - } - - fun buildUserAgent(): String { - val version = resolvedVersionName() - val release = Build.VERSION.RELEASE?.trim().orEmpty() - val releaseLabel = if (release.isEmpty()) "unknown" else release - return "OpenClawAndroid/$version (Android $releaseLabel; SDK ${Build.VERSION.SDK_INT})" - } - - fun buildClientInfo(clientId: String, clientMode: String): GatewayClientInfo { - return GatewayClientInfo( - id = clientId, - displayName = prefs.displayName.value, - version = resolvedVersionName(), - platform = "android", - mode = clientMode, - instanceId = prefs.instanceId.value, - deviceFamily = "Android", - modelIdentifier = resolveModelIdentifier(), - ) - } - - fun buildNodeConnectOptions(): GatewayConnectOptions { - return GatewayConnectOptions( - role = "node", - scopes = emptyList(), - caps = buildCapabilities(), - commands = buildInvokeCommands(), - permissions = emptyMap(), - client = buildClientInfo(clientId = "openclaw-android", clientMode = "node"), - userAgent = buildUserAgent(), - ) - } - - fun buildOperatorConnectOptions(): GatewayConnectOptions { - return GatewayConnectOptions( - role = "operator", - scopes = listOf("operator.read", "operator.write", "operator.talk.secrets"), - caps = emptyList(), - commands = emptyList(), - permissions = emptyMap(), - client = buildClientInfo(clientId = "openclaw-control-ui", clientMode = "ui"), - userAgent = buildUserAgent(), - ) - } - - fun resolveTlsParams(endpoint: GatewayEndpoint): GatewayTlsParams? { - val stored = prefs.loadGatewayTlsFingerprint(endpoint.stableId) - return resolveTlsParamsForEndpoint(endpoint, storedFingerprint = stored, manualTlsEnabled = manualTls()) - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/DebugHandler.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/DebugHandler.kt deleted file mode 100644 index 49502bd3631..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/DebugHandler.kt +++ /dev/null @@ -1,117 +0,0 @@ -package ai.openclaw.android.node - -import android.content.Context -import ai.openclaw.android.BuildConfig -import ai.openclaw.android.gateway.DeviceIdentityStore -import ai.openclaw.android.gateway.GatewaySession -import kotlinx.serialization.json.JsonPrimitive - -class DebugHandler( - private val appContext: Context, - private val identityStore: DeviceIdentityStore, -) { - - fun handleEd25519(): GatewaySession.InvokeResult { - if (!BuildConfig.DEBUG) { - return GatewaySession.InvokeResult.error(code = "UNAVAILABLE", message = "debug commands are disabled in release builds") - } - // Self-test Ed25519 signing and return diagnostic info - try { - val identity = identityStore.loadOrCreate() - val testPayload = "test|${identity.deviceId}|${System.currentTimeMillis()}" - val results = mutableListOf() - results.add("deviceId: ${identity.deviceId}") - results.add("publicKeyRawBase64: ${identity.publicKeyRawBase64.take(20)}...") - results.add("privateKeyPkcs8Base64: ${identity.privateKeyPkcs8Base64.take(20)}...") - - // Test publicKeyBase64Url - val pubKeyUrl = identityStore.publicKeyBase64Url(identity) - results.add("publicKeyBase64Url: ${pubKeyUrl ?: "NULL (FAILED)"}") - - // Test signing - val signature = identityStore.signPayload(testPayload, identity) - results.add("signPayload: ${if (signature != null) "${signature.take(20)}... (OK)" else "NULL (FAILED)"}") - - // Test self-verify - if (signature != null) { - val verifyOk = identityStore.verifySelfSignature(testPayload, signature, identity) - results.add("verifySelfSignature: $verifyOk") - } - - // Check available providers - val providers = java.security.Security.getProviders() - val ed25519Providers = providers.filter { p -> - p.services.any { s -> s.algorithm.contains("Ed25519", ignoreCase = true) } - } - results.add("Ed25519 providers: ${ed25519Providers.map { "${it.name} v${it.version}" }}") - results.add("Provider order: ${providers.take(5).map { it.name }}") - - // Test KeyFactory directly - try { - val kf = java.security.KeyFactory.getInstance("Ed25519") - results.add("KeyFactory.Ed25519: ${kf.provider.name} (OK)") - } catch (e: Throwable) { - results.add("KeyFactory.Ed25519: FAILED - ${e.javaClass.simpleName}: ${e.message}") - } - - // Test Signature directly - try { - val sig = java.security.Signature.getInstance("Ed25519") - results.add("Signature.Ed25519: ${sig.provider.name} (OK)") - } catch (e: Throwable) { - results.add("Signature.Ed25519: FAILED - ${e.javaClass.simpleName}: ${e.message}") - } - - return GatewaySession.InvokeResult.ok("""{"diagnostics":"${results.joinToString("\\n").replace("\"", "\\\"")}"}"""") - } catch (e: Throwable) { - return GatewaySession.InvokeResult.error(code = "ED25519_TEST_FAILED", message = "${e.javaClass.simpleName}: ${e.message}\n${e.stackTraceToString().take(500)}") - } - } - - fun handleLogs(): GatewaySession.InvokeResult { - if (!BuildConfig.DEBUG) { - return GatewaySession.InvokeResult.error(code = "UNAVAILABLE", message = "debug commands are disabled in release builds") - } - val pid = android.os.Process.myPid() - val rt = Runtime.getRuntime() - val info = "v6 pid=$pid thread=${Thread.currentThread().name} free=${rt.freeMemory()/1024}K total=${rt.totalMemory()/1024}K max=${rt.maxMemory()/1024}K uptime=${android.os.SystemClock.elapsedRealtime()/1000}s sdk=${android.os.Build.VERSION.SDK_INT} device=${android.os.Build.MODEL}\n" - // Run logcat on current dispatcher thread (no withContext) with file redirect - val logResult = try { - val tmpFile = java.io.File(appContext.cacheDir, "debug_logs.txt") - if (tmpFile.exists()) tmpFile.delete() - val pb = ProcessBuilder("logcat", "-d", "-t", "200", "--pid=$pid") - pb.redirectOutput(tmpFile) - pb.redirectErrorStream(true) - val proc = pb.start() - val finished = proc.waitFor(4, java.util.concurrent.TimeUnit.SECONDS) - if (!finished) proc.destroyForcibly() - val raw = if (tmpFile.exists() && tmpFile.length() > 0) { - tmpFile.readText().take(128000) - } else { - "(no output, finished=$finished, exists=${tmpFile.exists()})" - } - tmpFile.delete() - val spamPatterns = listOf("setRequestedFrameRate", "I View :", "BLASTBufferQueue", "VRI[Pop-Up", - "InsetsController:", "VRI[MainActivity", "InsetsSource:", "handleResized", "ProfileInstaller", - "I VRI[", "onStateChanged: host=", "D StrictMode:", "E StrictMode:", "ImeFocusController", - "InputTransport", "IncorrectContextUseViolation") - val sb = StringBuilder() - for (line in raw.lineSequence()) { - if (line.isBlank()) continue - if (spamPatterns.any { line.contains(it) }) continue - if (sb.length + line.length > 16000) { sb.append("\n(truncated)"); break } - if (sb.isNotEmpty()) sb.append('\n') - sb.append(line) - } - sb.toString().ifEmpty { "(all ${raw.lines().size} lines filtered as spam)" } - } catch (e: Throwable) { - "(logcat error: ${e::class.java.simpleName}: ${e.message})" - } - // Also include camera debug log if it exists - val camLogFile = java.io.File(appContext.cacheDir, "camera_debug.log") - val camLog = if (camLogFile.exists() && camLogFile.length() > 0) { - "\n--- camera_debug.log ---\n" + camLogFile.readText().take(4000) - } else "" - return GatewaySession.InvokeResult.ok("""{"logs":${JsonPrimitive(info + logResult + camLog)}}""") - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/GatewayEventHandler.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/GatewayEventHandler.kt deleted file mode 100644 index 9c0514d8635..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/GatewayEventHandler.kt +++ /dev/null @@ -1,71 +0,0 @@ -package ai.openclaw.android.node - -import ai.openclaw.android.SecurePrefs -import ai.openclaw.android.gateway.GatewaySession -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray - -class GatewayEventHandler( - private val scope: CoroutineScope, - private val prefs: SecurePrefs, - private val json: Json, - private val operatorSession: GatewaySession, - private val isConnected: () -> Boolean, -) { - private var suppressWakeWordsSync = false - private var wakeWordsSyncJob: Job? = null - - fun applyWakeWordsFromGateway(words: List) { - suppressWakeWordsSync = true - prefs.setWakeWords(words) - suppressWakeWordsSync = false - } - - fun scheduleWakeWordsSyncIfNeeded() { - if (suppressWakeWordsSync) return - if (!isConnected()) return - - val snapshot = prefs.wakeWords.value - wakeWordsSyncJob?.cancel() - wakeWordsSyncJob = - scope.launch { - delay(650) - val jsonList = snapshot.joinToString(separator = ",") { it.toJsonString() } - val params = """{"triggers":[$jsonList]}""" - try { - operatorSession.request("voicewake.set", params) - } catch (_: Throwable) { - // ignore - } - } - } - - suspend fun refreshWakeWordsFromGateway() { - if (!isConnected()) return - try { - val res = operatorSession.request("voicewake.get", "{}") - val payload = json.parseToJsonElement(res).asObjectOrNull() ?: return - val array = payload["triggers"] as? JsonArray ?: return - val triggers = array.mapNotNull { it.asStringOrNull() } - applyWakeWordsFromGateway(triggers) - } catch (_: Throwable) { - // ignore - } - } - - fun handleVoiceWakeChangedEvent(payloadJson: String?) { - if (payloadJson.isNullOrBlank()) return - try { - val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return - val array = payload["triggers"] as? JsonArray ?: return - val triggers = array.mapNotNull { it.asStringOrNull() } - applyWakeWordsFromGateway(triggers) - } catch (_: Throwable) { - // ignore - } - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt deleted file mode 100644 index e44896db0fa..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt +++ /dev/null @@ -1,176 +0,0 @@ -package ai.openclaw.android.node - -import ai.openclaw.android.gateway.GatewaySession -import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand -import ai.openclaw.android.protocol.OpenClawCanvasCommand -import ai.openclaw.android.protocol.OpenClawCameraCommand -import ai.openclaw.android.protocol.OpenClawLocationCommand -import ai.openclaw.android.protocol.OpenClawScreenCommand -import ai.openclaw.android.protocol.OpenClawSmsCommand - -class InvokeDispatcher( - private val canvas: CanvasController, - private val cameraHandler: CameraHandler, - private val locationHandler: LocationHandler, - private val screenHandler: ScreenHandler, - private val smsHandler: SmsHandler, - private val a2uiHandler: A2UIHandler, - private val debugHandler: DebugHandler, - private val appUpdateHandler: AppUpdateHandler, - private val isForeground: () -> Boolean, - private val cameraEnabled: () -> Boolean, - private val locationEnabled: () -> Boolean, -) { - suspend fun handleInvoke(command: String, paramsJson: String?): GatewaySession.InvokeResult { - // Check foreground requirement for canvas/camera/screen commands - if ( - command.startsWith(OpenClawCanvasCommand.NamespacePrefix) || - command.startsWith(OpenClawCanvasA2UICommand.NamespacePrefix) || - command.startsWith(OpenClawCameraCommand.NamespacePrefix) || - command.startsWith(OpenClawScreenCommand.NamespacePrefix) - ) { - if (!isForeground()) { - return GatewaySession.InvokeResult.error( - code = "NODE_BACKGROUND_UNAVAILABLE", - message = "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground", - ) - } - } - - // Check camera enabled - if (command.startsWith(OpenClawCameraCommand.NamespacePrefix) && !cameraEnabled()) { - return GatewaySession.InvokeResult.error( - code = "CAMERA_DISABLED", - message = "CAMERA_DISABLED: enable Camera in Settings", - ) - } - - // Check location enabled - if (command.startsWith(OpenClawLocationCommand.NamespacePrefix) && !locationEnabled()) { - return GatewaySession.InvokeResult.error( - code = "LOCATION_DISABLED", - message = "LOCATION_DISABLED: enable Location in Settings", - ) - } - - return when (command) { - // Canvas commands - OpenClawCanvasCommand.Present.rawValue -> { - val url = CanvasController.parseNavigateUrl(paramsJson) - canvas.navigate(url) - GatewaySession.InvokeResult.ok(null) - } - OpenClawCanvasCommand.Hide.rawValue -> GatewaySession.InvokeResult.ok(null) - OpenClawCanvasCommand.Navigate.rawValue -> { - val url = CanvasController.parseNavigateUrl(paramsJson) - canvas.navigate(url) - GatewaySession.InvokeResult.ok(null) - } - OpenClawCanvasCommand.Eval.rawValue -> { - val js = - CanvasController.parseEvalJs(paramsJson) - ?: return GatewaySession.InvokeResult.error( - code = "INVALID_REQUEST", - message = "INVALID_REQUEST: javaScript required", - ) - val result = - try { - canvas.eval(js) - } catch (err: Throwable) { - return GatewaySession.InvokeResult.error( - code = "NODE_BACKGROUND_UNAVAILABLE", - message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable", - ) - } - GatewaySession.InvokeResult.ok("""{"result":${result.toJsonString()}}""") - } - OpenClawCanvasCommand.Snapshot.rawValue -> { - val snapshotParams = CanvasController.parseSnapshotParams(paramsJson) - val base64 = - try { - canvas.snapshotBase64( - format = snapshotParams.format, - quality = snapshotParams.quality, - maxWidth = snapshotParams.maxWidth, - ) - } catch (err: Throwable) { - return GatewaySession.InvokeResult.error( - code = "NODE_BACKGROUND_UNAVAILABLE", - message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable", - ) - } - GatewaySession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""") - } - - // A2UI commands - OpenClawCanvasA2UICommand.Reset.rawValue -> { - val a2uiUrl = a2uiHandler.resolveA2uiHostUrl() - ?: return GatewaySession.InvokeResult.error( - code = "A2UI_HOST_NOT_CONFIGURED", - message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host", - ) - val ready = a2uiHandler.ensureA2uiReady(a2uiUrl) - if (!ready) { - return GatewaySession.InvokeResult.error( - code = "A2UI_HOST_UNAVAILABLE", - message = "A2UI host not reachable", - ) - } - val res = canvas.eval(A2UIHandler.a2uiResetJS) - GatewaySession.InvokeResult.ok(res) - } - OpenClawCanvasA2UICommand.Push.rawValue, OpenClawCanvasA2UICommand.PushJSONL.rawValue -> { - val messages = - try { - a2uiHandler.decodeA2uiMessages(command, paramsJson) - } catch (err: Throwable) { - return GatewaySession.InvokeResult.error( - code = "INVALID_REQUEST", - message = err.message ?: "invalid A2UI payload" - ) - } - val a2uiUrl = a2uiHandler.resolveA2uiHostUrl() - ?: return GatewaySession.InvokeResult.error( - code = "A2UI_HOST_NOT_CONFIGURED", - message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host", - ) - val ready = a2uiHandler.ensureA2uiReady(a2uiUrl) - if (!ready) { - return GatewaySession.InvokeResult.error( - code = "A2UI_HOST_UNAVAILABLE", - message = "A2UI host not reachable", - ) - } - val js = A2UIHandler.a2uiApplyMessagesJS(messages) - val res = canvas.eval(js) - GatewaySession.InvokeResult.ok(res) - } - - // Camera commands - OpenClawCameraCommand.Snap.rawValue -> cameraHandler.handleSnap(paramsJson) - OpenClawCameraCommand.Clip.rawValue -> cameraHandler.handleClip(paramsJson) - - // Location command - OpenClawLocationCommand.Get.rawValue -> locationHandler.handleLocationGet(paramsJson) - - // Screen command - OpenClawScreenCommand.Record.rawValue -> screenHandler.handleScreenRecord(paramsJson) - - // SMS command - OpenClawSmsCommand.Send.rawValue -> smsHandler.handleSmsSend(paramsJson) - - // Debug commands - "debug.ed25519" -> debugHandler.handleEd25519() - "debug.logs" -> debugHandler.handleLogs() - - // App update - "app.update" -> appUpdateHandler.handleUpdate(paramsJson) - - else -> - GatewaySession.InvokeResult.error( - code = "INVALID_REQUEST", - message = "INVALID_REQUEST: unknown command", - ) - } - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/JpegSizeLimiter.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/JpegSizeLimiter.kt deleted file mode 100644 index d6018467e66..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/JpegSizeLimiter.kt +++ /dev/null @@ -1,61 +0,0 @@ -package ai.openclaw.android.node - -import kotlin.math.max -import kotlin.math.min -import kotlin.math.roundToInt - -internal data class JpegSizeLimiterResult( - val bytes: ByteArray, - val width: Int, - val height: Int, - val quality: Int, -) - -internal object JpegSizeLimiter { - fun compressToLimit( - initialWidth: Int, - initialHeight: Int, - startQuality: Int, - maxBytes: Int, - minQuality: Int = 20, - minSize: Int = 256, - scaleStep: Double = 0.85, - maxScaleAttempts: Int = 6, - maxQualityAttempts: Int = 6, - encode: (width: Int, height: Int, quality: Int) -> ByteArray, - ): JpegSizeLimiterResult { - require(initialWidth > 0 && initialHeight > 0) { "Invalid image size" } - require(maxBytes > 0) { "Invalid maxBytes" } - - var width = initialWidth - var height = initialHeight - val clampedStartQuality = startQuality.coerceIn(minQuality, 100) - var best = JpegSizeLimiterResult(bytes = encode(width, height, clampedStartQuality), width = width, height = height, quality = clampedStartQuality) - if (best.bytes.size <= maxBytes) return best - - repeat(maxScaleAttempts) { - var quality = clampedStartQuality - repeat(maxQualityAttempts) { - val bytes = encode(width, height, quality) - best = JpegSizeLimiterResult(bytes = bytes, width = width, height = height, quality = quality) - if (bytes.size <= maxBytes) return best - if (quality <= minQuality) return@repeat - quality = max(minQuality, (quality * 0.75).roundToInt()) - } - - val minScale = (minSize.toDouble() / min(width, height).toDouble()).coerceAtMost(1.0) - val nextScale = max(scaleStep, minScale) - val nextWidth = max(minSize, (width * nextScale).roundToInt()) - val nextHeight = max(minSize, (height * nextScale).roundToInt()) - if (nextWidth == width && nextHeight == height) return@repeat - width = min(nextWidth, width) - height = min(nextHeight, height) - } - - if (best.bytes.size > maxBytes) { - throw IllegalStateException("CAMERA_TOO_LARGE: ${best.bytes.size} bytes > $maxBytes bytes") - } - - return best - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/LocationCaptureManager.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/LocationCaptureManager.kt deleted file mode 100644 index 87762e87fa9..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/LocationCaptureManager.kt +++ /dev/null @@ -1,117 +0,0 @@ -package ai.openclaw.android.node - -import android.Manifest -import android.content.Context -import android.content.pm.PackageManager -import android.location.Location -import android.location.LocationManager -import android.os.CancellationSignal -import androidx.core.content.ContextCompat -import java.time.Instant -import java.time.format.DateTimeFormatter -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException -import kotlinx.coroutines.suspendCancellableCoroutine - -class LocationCaptureManager(private val context: Context) { - data class Payload(val payloadJson: String) - - suspend fun getLocation( - desiredProviders: List, - maxAgeMs: Long?, - timeoutMs: Long, - isPrecise: Boolean, - ): Payload = - withContext(Dispatchers.Main) { - val manager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager - if (!manager.isProviderEnabled(LocationManager.GPS_PROVIDER) && - !manager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) - ) { - throw IllegalStateException("LOCATION_UNAVAILABLE: no location providers enabled") - } - - val cached = bestLastKnown(manager, desiredProviders, maxAgeMs) - val location = - cached ?: requestCurrent(manager, desiredProviders, timeoutMs) - - val timestamp = DateTimeFormatter.ISO_INSTANT.format(Instant.ofEpochMilli(location.time)) - val source = location.provider - val altitudeMeters = if (location.hasAltitude()) location.altitude else null - val speedMps = if (location.hasSpeed()) location.speed.toDouble() else null - val headingDeg = if (location.hasBearing()) location.bearing.toDouble() else null - Payload( - buildString { - append("{\"lat\":") - append(location.latitude) - append(",\"lon\":") - append(location.longitude) - append(",\"accuracyMeters\":") - append(location.accuracy.toDouble()) - if (altitudeMeters != null) append(",\"altitudeMeters\":").append(altitudeMeters) - if (speedMps != null) append(",\"speedMps\":").append(speedMps) - if (headingDeg != null) append(",\"headingDeg\":").append(headingDeg) - append(",\"timestamp\":\"").append(timestamp).append('"') - append(",\"isPrecise\":").append(isPrecise) - append(",\"source\":\"").append(source).append('"') - append('}') - }, - ) - } - - private fun bestLastKnown( - manager: LocationManager, - providers: List, - maxAgeMs: Long?, - ): Location? { - val fineOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == - PackageManager.PERMISSION_GRANTED - val coarseOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == - PackageManager.PERMISSION_GRANTED - if (!fineOk && !coarseOk) { - throw IllegalStateException("LOCATION_PERMISSION_REQUIRED: grant Location permission") - } - val now = System.currentTimeMillis() - val candidates = - providers.mapNotNull { provider -> manager.getLastKnownLocation(provider) } - val freshest = candidates.maxByOrNull { it.time } ?: return null - if (maxAgeMs != null && now - freshest.time > maxAgeMs) return null - return freshest - } - - private suspend fun requestCurrent( - manager: LocationManager, - providers: List, - timeoutMs: Long, - ): Location { - val fineOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == - PackageManager.PERMISSION_GRANTED - val coarseOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == - PackageManager.PERMISSION_GRANTED - if (!fineOk && !coarseOk) { - throw IllegalStateException("LOCATION_PERMISSION_REQUIRED: grant Location permission") - } - val resolved = - providers.firstOrNull { manager.isProviderEnabled(it) } - ?: throw IllegalStateException("LOCATION_UNAVAILABLE: no providers available") - return withTimeout(timeoutMs.coerceAtLeast(1)) { - suspendCancellableCoroutine { cont -> - val signal = CancellationSignal() - cont.invokeOnCancellation { signal.cancel() } - manager.getCurrentLocation(resolved, signal, context.mainExecutor) { location -> - if (location != null) { - cont.resume(location) - } else { - cont.resumeWithException(IllegalStateException("LOCATION_UNAVAILABLE: no fix")) - } - } - } - } - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/LocationHandler.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/LocationHandler.kt deleted file mode 100644 index c3f292f97a5..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/LocationHandler.kt +++ /dev/null @@ -1,116 +0,0 @@ -package ai.openclaw.android.node - -import android.Manifest -import android.content.Context -import android.content.pm.PackageManager -import android.location.LocationManager -import androidx.core.content.ContextCompat -import ai.openclaw.android.LocationMode -import ai.openclaw.android.gateway.GatewaySession -import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive - -class LocationHandler( - private val appContext: Context, - private val location: LocationCaptureManager, - private val json: Json, - private val isForeground: () -> Boolean, - private val locationMode: () -> LocationMode, - private val locationPreciseEnabled: () -> Boolean, -) { - fun hasFineLocationPermission(): Boolean { - return ( - ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_FINE_LOCATION) == - PackageManager.PERMISSION_GRANTED - ) - } - - fun hasCoarseLocationPermission(): Boolean { - return ( - ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_COARSE_LOCATION) == - PackageManager.PERMISSION_GRANTED - ) - } - - fun hasBackgroundLocationPermission(): Boolean { - return ( - ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_BACKGROUND_LOCATION) == - PackageManager.PERMISSION_GRANTED - ) - } - - suspend fun handleLocationGet(paramsJson: String?): GatewaySession.InvokeResult { - val mode = locationMode() - if (!isForeground() && mode != LocationMode.Always) { - return GatewaySession.InvokeResult.error( - code = "LOCATION_BACKGROUND_UNAVAILABLE", - message = "LOCATION_BACKGROUND_UNAVAILABLE: background location requires Always", - ) - } - if (!hasFineLocationPermission() && !hasCoarseLocationPermission()) { - return GatewaySession.InvokeResult.error( - code = "LOCATION_PERMISSION_REQUIRED", - message = "LOCATION_PERMISSION_REQUIRED: grant Location permission", - ) - } - if (!isForeground() && mode == LocationMode.Always && !hasBackgroundLocationPermission()) { - return GatewaySession.InvokeResult.error( - code = "LOCATION_PERMISSION_REQUIRED", - message = "LOCATION_PERMISSION_REQUIRED: enable Always in system Settings", - ) - } - val (maxAgeMs, timeoutMs, desiredAccuracy) = parseLocationParams(paramsJson) - val preciseEnabled = locationPreciseEnabled() - val accuracy = - when (desiredAccuracy) { - "precise" -> if (preciseEnabled && hasFineLocationPermission()) "precise" else "balanced" - "coarse" -> "coarse" - else -> if (preciseEnabled && hasFineLocationPermission()) "precise" else "balanced" - } - val providers = - when (accuracy) { - "precise" -> listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER) - "coarse" -> listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER) - else -> listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER) - } - try { - val payload = - location.getLocation( - desiredProviders = providers, - maxAgeMs = maxAgeMs, - timeoutMs = timeoutMs, - isPrecise = accuracy == "precise", - ) - return GatewaySession.InvokeResult.ok(payload.payloadJson) - } catch (err: TimeoutCancellationException) { - return GatewaySession.InvokeResult.error( - code = "LOCATION_TIMEOUT", - message = "LOCATION_TIMEOUT: no fix in time", - ) - } catch (err: Throwable) { - val message = err.message ?: "LOCATION_UNAVAILABLE: no fix" - return GatewaySession.InvokeResult.error(code = "LOCATION_UNAVAILABLE", message = message) - } - } - - private fun parseLocationParams(paramsJson: String?): Triple { - if (paramsJson.isNullOrBlank()) { - return Triple(null, 10_000L, null) - } - val root = - try { - json.parseToJsonElement(paramsJson).asObjectOrNull() - } catch (_: Throwable) { - null - } - val maxAgeMs = (root?.get("maxAgeMs") as? JsonPrimitive)?.content?.toLongOrNull() - val timeoutMs = - (root?.get("timeoutMs") as? JsonPrimitive)?.content?.toLongOrNull()?.coerceIn(1_000L, 60_000L) - ?: 10_000L - val desiredAccuracy = - (root?.get("desiredAccuracy") as? JsonPrimitive)?.content?.trim()?.lowercase() - return Triple(maxAgeMs, timeoutMs, desiredAccuracy) - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/NodeUtils.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/NodeUtils.kt deleted file mode 100644 index 8ba5ad276d5..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/NodeUtils.kt +++ /dev/null @@ -1,57 +0,0 @@ -package ai.openclaw.android.node - -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive - -const val DEFAULT_SEAM_COLOR_ARGB: Long = 0xFF4F7A9A - -data class Quad(val first: A, val second: B, val third: C, val fourth: D) - -fun String.toJsonString(): String { - val escaped = - this.replace("\\", "\\\\") - .replace("\"", "\\\"") - .replace("\n", "\\n") - .replace("\r", "\\r") - return "\"$escaped\"" -} - -fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject - -fun JsonElement?.asStringOrNull(): String? = - when (this) { - is JsonNull -> null - is JsonPrimitive -> content - else -> null - } - -fun parseHexColorArgb(raw: String?): Long? { - val trimmed = raw?.trim().orEmpty() - if (trimmed.isEmpty()) return null - val hex = if (trimmed.startsWith("#")) trimmed.drop(1) else trimmed - if (hex.length != 6) return null - val rgb = hex.toLongOrNull(16) ?: return null - return 0xFF000000L or rgb -} - -fun invokeErrorFromThrowable(err: Throwable): Pair { - val raw = (err.message ?: "").trim() - if (raw.isEmpty()) return "UNAVAILABLE" to "UNAVAILABLE: error" - - val idx = raw.indexOf(':') - if (idx <= 0) return "UNAVAILABLE" to raw - val code = raw.substring(0, idx).trim().ifEmpty { "UNAVAILABLE" } - val message = raw.substring(idx + 1).trim().ifEmpty { raw } - return code to "$code: $message" -} - -fun normalizeMainKey(raw: String?): String? { - val trimmed = raw?.trim().orEmpty() - return if (trimmed.isEmpty()) null else trimmed -} - -fun isCanonicalMainSessionKey(key: String): Boolean { - return key == "main" -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/ScreenHandler.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/ScreenHandler.kt deleted file mode 100644 index c63d73f5e52..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/ScreenHandler.kt +++ /dev/null @@ -1,25 +0,0 @@ -package ai.openclaw.android.node - -import ai.openclaw.android.gateway.GatewaySession - -class ScreenHandler( - private val screenRecorder: ScreenRecordManager, - private val setScreenRecordActive: (Boolean) -> Unit, - private val invokeErrorFromThrowable: (Throwable) -> Pair, -) { - suspend fun handleScreenRecord(paramsJson: String?): GatewaySession.InvokeResult { - setScreenRecordActive(true) - try { - val res = - try { - screenRecorder.record(paramsJson) - } catch (err: Throwable) { - val (code, message) = invokeErrorFromThrowable(err) - return GatewaySession.InvokeResult.error(code = code, message = message) - } - return GatewaySession.InvokeResult.ok(res.payloadJson) - } finally { - setScreenRecordActive(false) - } - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/ScreenRecordManager.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/ScreenRecordManager.kt deleted file mode 100644 index 337a953866a..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/ScreenRecordManager.kt +++ /dev/null @@ -1,199 +0,0 @@ -package ai.openclaw.android.node - -import android.content.Context -import android.hardware.display.DisplayManager -import android.media.MediaRecorder -import android.media.projection.MediaProjectionManager -import android.os.Build -import android.util.Base64 -import ai.openclaw.android.ScreenCaptureRequester -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.withContext -import java.io.File -import kotlin.math.roundToInt - -class ScreenRecordManager(private val context: Context) { - data class Payload(val payloadJson: String) - - @Volatile private var screenCaptureRequester: ScreenCaptureRequester? = null - @Volatile private var permissionRequester: ai.openclaw.android.PermissionRequester? = null - - fun attachScreenCaptureRequester(requester: ScreenCaptureRequester) { - screenCaptureRequester = requester - } - - fun attachPermissionRequester(requester: ai.openclaw.android.PermissionRequester) { - permissionRequester = requester - } - - suspend fun record(paramsJson: String?): Payload = - withContext(Dispatchers.Default) { - val requester = - screenCaptureRequester - ?: throw IllegalStateException( - "SCREEN_PERMISSION_REQUIRED: grant Screen Recording permission", - ) - - val durationMs = (parseDurationMs(paramsJson) ?: 10_000).coerceIn(250, 60_000) - val fps = (parseFps(paramsJson) ?: 10.0).coerceIn(1.0, 60.0) - val fpsInt = fps.roundToInt().coerceIn(1, 60) - val screenIndex = parseScreenIndex(paramsJson) - val includeAudio = parseIncludeAudio(paramsJson) ?: true - val format = parseString(paramsJson, key = "format") - if (format != null && format.lowercase() != "mp4") { - throw IllegalArgumentException("INVALID_REQUEST: screen format must be mp4") - } - if (screenIndex != null && screenIndex != 0) { - throw IllegalArgumentException("INVALID_REQUEST: screenIndex must be 0 on Android") - } - - val capture = requester.requestCapture() - ?: throw IllegalStateException( - "SCREEN_PERMISSION_REQUIRED: grant Screen Recording permission", - ) - - val mgr = - context.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager - val projection = mgr.getMediaProjection(capture.resultCode, capture.data) - ?: throw IllegalStateException("UNAVAILABLE: screen capture unavailable") - - val metrics = context.resources.displayMetrics - val width = metrics.widthPixels - val height = metrics.heightPixels - val densityDpi = metrics.densityDpi - - val file = File.createTempFile("openclaw-screen-", ".mp4") - if (includeAudio) ensureMicPermission() - - val recorder = createMediaRecorder() - var virtualDisplay: android.hardware.display.VirtualDisplay? = null - try { - if (includeAudio) { - recorder.setAudioSource(MediaRecorder.AudioSource.MIC) - } - recorder.setVideoSource(MediaRecorder.VideoSource.SURFACE) - recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) - recorder.setVideoEncoder(MediaRecorder.VideoEncoder.H264) - if (includeAudio) { - recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC) - recorder.setAudioChannels(1) - recorder.setAudioSamplingRate(44_100) - recorder.setAudioEncodingBitRate(96_000) - } - recorder.setVideoSize(width, height) - recorder.setVideoFrameRate(fpsInt) - recorder.setVideoEncodingBitRate(estimateBitrate(width, height, fpsInt)) - recorder.setOutputFile(file.absolutePath) - recorder.prepare() - - val surface = recorder.surface - virtualDisplay = - projection.createVirtualDisplay( - "openclaw-screen", - width, - height, - densityDpi, - DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, - surface, - null, - null, - ) - - recorder.start() - delay(durationMs.toLong()) - } finally { - try { - recorder.stop() - } catch (_: Throwable) { - // ignore - } - recorder.reset() - recorder.release() - virtualDisplay?.release() - projection.stop() - } - - val bytes = withContext(Dispatchers.IO) { file.readBytes() } - file.delete() - val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP) - Payload( - """{"format":"mp4","base64":"$base64","durationMs":$durationMs,"fps":$fpsInt,"screenIndex":0,"hasAudio":$includeAudio}""", - ) - } - - private fun createMediaRecorder(): MediaRecorder = MediaRecorder(context) - - private suspend fun ensureMicPermission() { - val granted = - androidx.core.content.ContextCompat.checkSelfPermission( - context, - android.Manifest.permission.RECORD_AUDIO, - ) == android.content.pm.PackageManager.PERMISSION_GRANTED - if (granted) return - - val requester = - permissionRequester - ?: throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission") - val results = requester.requestIfMissing(listOf(android.Manifest.permission.RECORD_AUDIO)) - if (results[android.Manifest.permission.RECORD_AUDIO] != true) { - throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission") - } - } - - private fun parseDurationMs(paramsJson: String?): Int? = - parseNumber(paramsJson, key = "durationMs")?.toIntOrNull() - - private fun parseFps(paramsJson: String?): Double? = - parseNumber(paramsJson, key = "fps")?.toDoubleOrNull() - - private fun parseScreenIndex(paramsJson: String?): Int? = - parseNumber(paramsJson, key = "screenIndex")?.toIntOrNull() - - private fun parseIncludeAudio(paramsJson: String?): Boolean? { - val raw = paramsJson ?: return null - val key = "\"includeAudio\"" - val idx = raw.indexOf(key) - if (idx < 0) return null - val colon = raw.indexOf(':', idx + key.length) - if (colon < 0) return null - val tail = raw.substring(colon + 1).trimStart() - return when { - tail.startsWith("true") -> true - tail.startsWith("false") -> false - else -> null - } - } - - private fun parseNumber(paramsJson: String?, key: String): String? { - val raw = paramsJson ?: return null - val needle = "\"$key\"" - val idx = raw.indexOf(needle) - if (idx < 0) return null - val colon = raw.indexOf(':', idx + needle.length) - if (colon < 0) return null - val tail = raw.substring(colon + 1).trimStart() - return tail.takeWhile { it.isDigit() || it == '.' || it == '-' } - } - - private fun parseString(paramsJson: String?, key: String): String? { - val raw = paramsJson ?: return null - val needle = "\"$key\"" - val idx = raw.indexOf(needle) - if (idx < 0) return null - val colon = raw.indexOf(':', idx + needle.length) - if (colon < 0) return null - val tail = raw.substring(colon + 1).trimStart() - if (!tail.startsWith('\"')) return null - val rest = tail.drop(1) - val end = rest.indexOf('\"') - if (end < 0) return null - return rest.substring(0, end) - } - - private fun estimateBitrate(width: Int, height: Int, fps: Int): Int { - val pixels = width.toLong() * height.toLong() - val raw = (pixels * fps.toLong() * 2L).toInt() - return raw.coerceIn(1_000_000, 12_000_000) - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/SmsHandler.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/SmsHandler.kt deleted file mode 100644 index 30b7781009d..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/SmsHandler.kt +++ /dev/null @@ -1,19 +0,0 @@ -package ai.openclaw.android.node - -import ai.openclaw.android.gateway.GatewaySession - -class SmsHandler( - private val sms: SmsManager, -) { - suspend fun handleSmsSend(paramsJson: String?): GatewaySession.InvokeResult { - val res = sms.send(paramsJson) - if (res.ok) { - return GatewaySession.InvokeResult.ok(res.payloadJson) - } else { - val error = res.error ?: "SMS_SEND_FAILED" - val idx = error.indexOf(':') - val code = if (idx > 0) error.substring(0, idx).trim() else "SMS_SEND_FAILED" - return GatewaySession.InvokeResult.error(code = code, message = error) - } - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/SmsManager.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/SmsManager.kt deleted file mode 100644 index d727bfd2763..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/SmsManager.kt +++ /dev/null @@ -1,230 +0,0 @@ -package ai.openclaw.android.node - -import android.Manifest -import android.content.Context -import android.content.pm.PackageManager -import android.telephony.SmsManager as AndroidSmsManager -import androidx.core.content.ContextCompat -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.encodeToString -import ai.openclaw.android.PermissionRequester - -/** - * Sends SMS messages via the Android SMS API. - * Requires SEND_SMS permission to be granted. - */ -class SmsManager(private val context: Context) { - - private val json = JsonConfig - @Volatile private var permissionRequester: PermissionRequester? = null - - data class SendResult( - val ok: Boolean, - val to: String, - val message: String?, - val error: String? = null, - val payloadJson: String, - ) - - internal data class ParsedParams( - val to: String, - val message: String, - ) - - internal sealed class ParseResult { - data class Ok(val params: ParsedParams) : ParseResult() - data class Error( - val error: String, - val to: String = "", - val message: String? = null, - ) : ParseResult() - } - - internal data class SendPlan( - val parts: List, - val useMultipart: Boolean, - ) - - companion object { - internal val JsonConfig = Json { ignoreUnknownKeys = true } - - internal fun parseParams(paramsJson: String?, json: Json = JsonConfig): ParseResult { - val params = paramsJson?.trim().orEmpty() - if (params.isEmpty()) { - return ParseResult.Error(error = "INVALID_REQUEST: paramsJSON required") - } - - val obj = try { - json.parseToJsonElement(params).jsonObject - } catch (_: Throwable) { - null - } - - if (obj == null) { - return ParseResult.Error(error = "INVALID_REQUEST: expected JSON object") - } - - val to = (obj["to"] as? JsonPrimitive)?.content?.trim().orEmpty() - val message = (obj["message"] as? JsonPrimitive)?.content.orEmpty() - - if (to.isEmpty()) { - return ParseResult.Error( - error = "INVALID_REQUEST: 'to' phone number required", - message = message, - ) - } - - if (message.isEmpty()) { - return ParseResult.Error( - error = "INVALID_REQUEST: 'message' text required", - to = to, - ) - } - - return ParseResult.Ok(ParsedParams(to = to, message = message)) - } - - internal fun buildSendPlan( - message: String, - divider: (String) -> List, - ): SendPlan { - val parts = divider(message).ifEmpty { listOf(message) } - return SendPlan(parts = parts, useMultipart = parts.size > 1) - } - - internal fun buildPayloadJson( - json: Json = JsonConfig, - ok: Boolean, - to: String, - error: String?, - ): String { - val payload = - mutableMapOf( - "ok" to JsonPrimitive(ok), - "to" to JsonPrimitive(to), - ) - if (!ok) { - payload["error"] = JsonPrimitive(error ?: "SMS_SEND_FAILED") - } - return json.encodeToString(JsonObject.serializer(), JsonObject(payload)) - } - } - - fun hasSmsPermission(): Boolean { - return ContextCompat.checkSelfPermission( - context, - Manifest.permission.SEND_SMS - ) == PackageManager.PERMISSION_GRANTED - } - - fun canSendSms(): Boolean { - return hasSmsPermission() && hasTelephonyFeature() - } - - fun hasTelephonyFeature(): Boolean { - return context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true - } - - fun attachPermissionRequester(requester: PermissionRequester) { - permissionRequester = requester - } - - /** - * Send an SMS message. - * - * @param paramsJson JSON with "to" (phone number) and "message" (text) fields - * @return SendResult indicating success or failure - */ - suspend fun send(paramsJson: String?): SendResult { - if (!hasTelephonyFeature()) { - return errorResult( - error = "SMS_UNAVAILABLE: telephony not available", - ) - } - - if (!ensureSmsPermission()) { - return errorResult( - error = "SMS_PERMISSION_REQUIRED: grant SMS permission", - ) - } - - val parseResult = parseParams(paramsJson, json) - if (parseResult is ParseResult.Error) { - return errorResult( - error = parseResult.error, - to = parseResult.to, - message = parseResult.message, - ) - } - val params = (parseResult as ParseResult.Ok).params - - return try { - val smsManager = context.getSystemService(AndroidSmsManager::class.java) - ?: throw IllegalStateException("SMS_UNAVAILABLE: SmsManager not available") - - val plan = buildSendPlan(params.message) { smsManager.divideMessage(it) } - if (plan.useMultipart) { - smsManager.sendMultipartTextMessage( - params.to, // destination - null, // service center (null = default) - ArrayList(plan.parts), // message parts - null, // sent intents - null, // delivery intents - ) - } else { - smsManager.sendTextMessage( - params.to, // destination - null, // service center (null = default) - params.message,// message - null, // sent intent - null, // delivery intent - ) - } - - okResult(to = params.to, message = params.message) - } catch (e: SecurityException) { - errorResult( - error = "SMS_PERMISSION_REQUIRED: ${e.message}", - to = params.to, - message = params.message, - ) - } catch (e: Throwable) { - errorResult( - error = "SMS_SEND_FAILED: ${e.message ?: "unknown error"}", - to = params.to, - message = params.message, - ) - } - } - - private suspend fun ensureSmsPermission(): Boolean { - if (hasSmsPermission()) return true - val requester = permissionRequester ?: return false - val results = requester.requestIfMissing(listOf(Manifest.permission.SEND_SMS)) - return results[Manifest.permission.SEND_SMS] == true - } - - private fun okResult(to: String, message: String): SendResult { - return SendResult( - ok = true, - to = to, - message = message, - error = null, - payloadJson = buildPayloadJson(json = json, ok = true, to = to, error = null), - ) - } - - private fun errorResult(error: String, to: String = "", message: String? = null): SendResult { - return SendResult( - ok = false, - to = to, - message = message, - error = error, - payloadJson = buildPayloadJson(json = json, ok = false, to = to, error = error), - ) - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/protocol/OpenClawCanvasA2UIAction.kt b/apps/android/app/src/main/java/ai/openclaw/android/protocol/OpenClawCanvasA2UIAction.kt deleted file mode 100644 index 7e1a5bf127e..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/protocol/OpenClawCanvasA2UIAction.kt +++ /dev/null @@ -1,66 +0,0 @@ -package ai.openclaw.android.protocol - -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive - -object OpenClawCanvasA2UIAction { - fun extractActionName(userAction: JsonObject): String? { - val name = - (userAction["name"] as? JsonPrimitive) - ?.content - ?.trim() - .orEmpty() - if (name.isNotEmpty()) return name - val action = - (userAction["action"] as? JsonPrimitive) - ?.content - ?.trim() - .orEmpty() - return action.ifEmpty { null } - } - - fun sanitizeTagValue(value: String): String { - val trimmed = value.trim().ifEmpty { "-" } - val normalized = trimmed.replace(" ", "_") - val out = StringBuilder(normalized.length) - for (c in normalized) { - val ok = - c.isLetterOrDigit() || - c == '_' || - c == '-' || - c == '.' || - c == ':' - out.append(if (ok) c else '_') - } - return out.toString() - } - - fun formatAgentMessage( - actionName: String, - sessionKey: String, - surfaceId: String, - sourceComponentId: String, - host: String, - instanceId: String, - contextJson: String?, - ): String { - val ctxSuffix = contextJson?.takeIf { it.isNotBlank() }?.let { " ctx=$it" }.orEmpty() - return listOf( - "CANVAS_A2UI", - "action=${sanitizeTagValue(actionName)}", - "session=${sanitizeTagValue(sessionKey)}", - "surface=${sanitizeTagValue(surfaceId)}", - "component=${sanitizeTagValue(sourceComponentId)}", - "host=${sanitizeTagValue(host)}", - "instance=${sanitizeTagValue(instanceId)}$ctxSuffix", - "default=update_canvas", - ).joinToString(separator = " ") - } - - fun jsDispatchA2UIActionStatus(actionId: String, ok: Boolean, error: String?): String { - val err = (error ?: "").replace("\\", "\\\\").replace("\"", "\\\"") - val okLiteral = if (ok) "true" else "false" - val idEscaped = actionId.replace("\\", "\\\\").replace("\"", "\\\"") - return "window.dispatchEvent(new CustomEvent('openclaw:a2ui-action-status', { detail: { id: \"${idEscaped}\", ok: ${okLiteral}, error: \"${err}\" } }));" - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/protocol/OpenClawProtocolConstants.kt b/apps/android/app/src/main/java/ai/openclaw/android/protocol/OpenClawProtocolConstants.kt deleted file mode 100644 index ccca40c4c35..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/protocol/OpenClawProtocolConstants.kt +++ /dev/null @@ -1,71 +0,0 @@ -package ai.openclaw.android.protocol - -enum class OpenClawCapability(val rawValue: String) { - Canvas("canvas"), - Camera("camera"), - Screen("screen"), - Sms("sms"), - VoiceWake("voiceWake"), - Location("location"), -} - -enum class OpenClawCanvasCommand(val rawValue: String) { - Present("canvas.present"), - Hide("canvas.hide"), - Navigate("canvas.navigate"), - Eval("canvas.eval"), - Snapshot("canvas.snapshot"), - ; - - companion object { - const val NamespacePrefix: String = "canvas." - } -} - -enum class OpenClawCanvasA2UICommand(val rawValue: String) { - Push("canvas.a2ui.push"), - PushJSONL("canvas.a2ui.pushJSONL"), - Reset("canvas.a2ui.reset"), - ; - - companion object { - const val NamespacePrefix: String = "canvas.a2ui." - } -} - -enum class OpenClawCameraCommand(val rawValue: String) { - Snap("camera.snap"), - Clip("camera.clip"), - ; - - companion object { - const val NamespacePrefix: String = "camera." - } -} - -enum class OpenClawScreenCommand(val rawValue: String) { - Record("screen.record"), - ; - - companion object { - const val NamespacePrefix: String = "screen." - } -} - -enum class OpenClawSmsCommand(val rawValue: String) { - Send("sms.send"), - ; - - companion object { - const val NamespacePrefix: String = "sms." - } -} - -enum class OpenClawLocationCommand(val rawValue: String) { - Get("location.get"), - ; - - companion object { - const val NamespacePrefix: String = "location." - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/tools/ToolDisplay.kt b/apps/android/app/src/main/java/ai/openclaw/android/tools/ToolDisplay.kt deleted file mode 100644 index 1c5561767e6..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/tools/ToolDisplay.kt +++ /dev/null @@ -1,222 +0,0 @@ -package ai.openclaw.android.tools - -import android.content.Context -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.contentOrNull - -@Serializable -private data class ToolDisplayActionSpec( - val label: String? = null, - val detailKeys: List? = null, -) - -@Serializable -private data class ToolDisplaySpec( - val emoji: String? = null, - val title: String? = null, - val label: String? = null, - val detailKeys: List? = null, - val actions: Map? = null, -) - -@Serializable -private data class ToolDisplayConfig( - val version: Int? = null, - val fallback: ToolDisplaySpec? = null, - val tools: Map? = null, -) - -data class ToolDisplaySummary( - val name: String, - val emoji: String, - val title: String, - val label: String, - val verb: String?, - val detail: String?, -) { - val detailLine: String? - get() { - val parts = mutableListOf() - if (!verb.isNullOrBlank()) parts.add(verb) - if (!detail.isNullOrBlank()) parts.add(detail) - return if (parts.isEmpty()) null else parts.joinToString(" · ") - } - - val summaryLine: String - get() = if (detailLine != null) "${emoji} ${label}: ${detailLine}" else "${emoji} ${label}" -} - -object ToolDisplayRegistry { - private const val CONFIG_ASSET = "tool-display.json" - - private val json = Json { ignoreUnknownKeys = true } - @Volatile private var cachedConfig: ToolDisplayConfig? = null - - fun resolve( - context: Context, - name: String?, - args: JsonObject?, - meta: String? = null, - ): ToolDisplaySummary { - val trimmedName = name?.trim().orEmpty().ifEmpty { "tool" } - val key = trimmedName.lowercase() - val config = loadConfig(context) - val spec = config.tools?.get(key) - val fallback = config.fallback - - val emoji = spec?.emoji ?: fallback?.emoji ?: "🧩" - val title = spec?.title ?: titleFromName(trimmedName) - val label = spec?.label ?: trimmedName - - val actionRaw = args?.get("action")?.asStringOrNull()?.trim() - val action = actionRaw?.takeIf { it.isNotEmpty() } - val actionSpec = action?.let { spec?.actions?.get(it) } - val verb = normalizeVerb(actionSpec?.label ?: action) - - var detail: String? = null - if (key == "read") { - detail = readDetail(args) - } else if (key == "write" || key == "edit" || key == "attach") { - detail = pathDetail(args) - } - - val detailKeys = actionSpec?.detailKeys ?: spec?.detailKeys ?: fallback?.detailKeys ?: emptyList() - if (detail == null) { - detail = firstValue(args, detailKeys) - } - - if (detail == null) { - detail = meta - } - - if (detail != null) { - detail = shortenHomeInString(detail) - } - - return ToolDisplaySummary( - name = trimmedName, - emoji = emoji, - title = title, - label = label, - verb = verb, - detail = detail, - ) - } - - private fun loadConfig(context: Context): ToolDisplayConfig { - val existing = cachedConfig - if (existing != null) return existing - return try { - val jsonString = context.assets.open(CONFIG_ASSET).bufferedReader().use { it.readText() } - val decoded = json.decodeFromString(ToolDisplayConfig.serializer(), jsonString) - cachedConfig = decoded - decoded - } catch (_: Throwable) { - val fallback = ToolDisplayConfig() - cachedConfig = fallback - fallback - } - } - - private fun titleFromName(name: String): String { - val cleaned = name.replace("_", " ").trim() - if (cleaned.isEmpty()) return "Tool" - return cleaned - .split(Regex("\\s+")) - .joinToString(" ") { part -> - val upper = part.uppercase() - if (part.length <= 2 && part == upper) part - else upper.firstOrNull()?.toString().orEmpty() + part.lowercase().drop(1) - } - } - - private fun normalizeVerb(value: String?): String? { - val trimmed = value?.trim().orEmpty() - if (trimmed.isEmpty()) return null - return trimmed.replace("_", " ") - } - - private fun readDetail(args: JsonObject?): String? { - val path = args?.get("path")?.asStringOrNull() ?: return null - val offset = args["offset"].asNumberOrNull() - val limit = args["limit"].asNumberOrNull() - return if (offset != null && limit != null) { - val end = offset + limit - "${path}:${offset.toInt()}-${end.toInt()}" - } else { - path - } - } - - private fun pathDetail(args: JsonObject?): String? { - return args?.get("path")?.asStringOrNull() - } - - private fun firstValue(args: JsonObject?, keys: List): String? { - for (key in keys) { - val value = valueForPath(args, key) - val rendered = renderValue(value) - if (!rendered.isNullOrBlank()) return rendered - } - return null - } - - private fun valueForPath(args: JsonObject?, path: String): JsonElement? { - var current: JsonElement? = args - for (segment in path.split(".")) { - if (segment.isBlank()) return null - val obj = current as? JsonObject ?: return null - current = obj[segment] - } - return current - } - - private fun renderValue(value: JsonElement?): String? { - if (value == null) return null - if (value is JsonPrimitive) { - if (value.isString) { - val trimmed = value.contentOrNull?.trim().orEmpty() - if (trimmed.isEmpty()) return null - val firstLine = trimmed.lineSequence().firstOrNull()?.trim().orEmpty() - if (firstLine.isEmpty()) return null - return if (firstLine.length > 160) "${firstLine.take(157)}…" else firstLine - } - val raw = value.contentOrNull?.trim().orEmpty() - raw.toBooleanStrictOrNull()?.let { return it.toString() } - raw.toLongOrNull()?.let { return it.toString() } - raw.toDoubleOrNull()?.let { return it.toString() } - } - if (value is JsonArray) { - val items = value.mapNotNull { renderValue(it) } - if (items.isEmpty()) return null - val preview = items.take(3).joinToString(", ") - return if (items.size > 3) "${preview}…" else preview - } - return null - } - - private fun shortenHomeInString(value: String): String { - val home = System.getProperty("user.home")?.takeIf { it.isNotBlank() } - ?: System.getenv("HOME")?.takeIf { it.isNotBlank() } - if (home.isNullOrEmpty()) return value - return value.replace(home, "~") - .replace(Regex("/Users/[^/]+"), "~") - .replace(Regex("/home/[^/]+"), "~") - } - - private fun JsonElement?.asStringOrNull(): String? { - val primitive = this as? JsonPrimitive ?: return null - return if (primitive.isString) primitive.contentOrNull else primitive.toString() - } - - private fun JsonElement?.asNumberOrNull(): Double? { - val primitive = this as? JsonPrimitive ?: return null - val raw = primitive.contentOrNull ?: return null - return raw.toDoubleOrNull() - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/CameraHudOverlay.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/CameraHudOverlay.kt deleted file mode 100644 index 21043d739b0..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/CameraHudOverlay.kt +++ /dev/null @@ -1,44 +0,0 @@ -package ai.openclaw.android.ui - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.graphics.Color -import kotlinx.coroutines.delay - -@Composable -fun CameraFlashOverlay( - token: Long, - modifier: Modifier = Modifier, -) { - Box(modifier = modifier.fillMaxSize()) { - CameraFlash(token = token) - } -} - -@Composable -private fun CameraFlash(token: Long) { - var alpha by remember { mutableFloatStateOf(0f) } - LaunchedEffect(token) { - if (token == 0L) return@LaunchedEffect - alpha = 0.85f - delay(110) - alpha = 0f - } - - Box( - modifier = - Modifier - .fillMaxSize() - .alpha(alpha) - .background(Color.White), - ) -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/ChatSheet.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/ChatSheet.kt deleted file mode 100644 index 85f20364c61..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/ChatSheet.kt +++ /dev/null @@ -1,10 +0,0 @@ -package ai.openclaw.android.ui - -import androidx.compose.runtime.Composable -import ai.openclaw.android.MainViewModel -import ai.openclaw.android.ui.chat.ChatSheetContent - -@Composable -fun ChatSheet(viewModel: MainViewModel) { - ChatSheetContent(viewModel = viewModel) -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/OpenClawTheme.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/OpenClawTheme.kt deleted file mode 100644 index aad743a6d7d..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/OpenClawTheme.kt +++ /dev/null @@ -1,32 +0,0 @@ -package ai.openclaw.android.ui - -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext - -@Composable -fun OpenClawTheme(content: @Composable () -> Unit) { - val context = LocalContext.current - val isDark = isSystemInDarkTheme() - val colorScheme = if (isDark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - - MaterialTheme(colorScheme = colorScheme, content = content) -} - -@Composable -fun overlayContainerColor(): Color { - val scheme = MaterialTheme.colorScheme - val isDark = isSystemInDarkTheme() - val base = if (isDark) scheme.surfaceContainerLow else scheme.surfaceContainerHigh - // Light mode: background stays dark (canvas), so clamp overlays away from pure-white glare. - return if (isDark) base else base.copy(alpha = 0.88f) -} - -@Composable -fun overlayIconColor(): Color { - return MaterialTheme.colorScheme.onSurfaceVariant -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/RootScreen.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/RootScreen.kt deleted file mode 100644 index af0cfe628ac..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/RootScreen.kt +++ /dev/null @@ -1,429 +0,0 @@ -package ai.openclaw.android.ui - -import android.annotation.SuppressLint -import android.Manifest -import android.content.pm.PackageManager -import android.graphics.Color -import android.util.Log -import android.view.View -import android.webkit.JavascriptInterface -import android.webkit.ConsoleMessage -import android.webkit.WebChromeClient -import android.webkit.WebView -import android.webkit.WebSettings -import android.webkit.WebResourceError -import android.webkit.WebResourceRequest -import android.webkit.WebResourceResponse -import android.webkit.WebViewClient -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.webkit.WebSettingsCompat -import androidx.webkit.WebViewFeature -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilledTonalIconButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ScreenShare -import androidx.compose.material.icons.filled.ChatBubble -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.Error -import androidx.compose.material.icons.filled.FiberManualRecord -import androidx.compose.material.icons.filled.PhotoCamera -import androidx.compose.material.icons.filled.RecordVoiceOver -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.Report -import androidx.compose.material.icons.filled.Settings -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color as ComposeColor -import androidx.compose.ui.graphics.lerp -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupProperties -import androidx.core.content.ContextCompat -import ai.openclaw.android.CameraHudKind -import ai.openclaw.android.MainViewModel - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun RootScreen(viewModel: MainViewModel) { - var sheet by remember { mutableStateOf(null) } - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val safeOverlayInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) - val context = LocalContext.current - val serverName by viewModel.serverName.collectAsState() - val statusText by viewModel.statusText.collectAsState() - val cameraHud by viewModel.cameraHud.collectAsState() - val cameraFlashToken by viewModel.cameraFlashToken.collectAsState() - val screenRecordActive by viewModel.screenRecordActive.collectAsState() - val isForeground by viewModel.isForeground.collectAsState() - val voiceWakeStatusText by viewModel.voiceWakeStatusText.collectAsState() - val talkEnabled by viewModel.talkEnabled.collectAsState() - val talkStatusText by viewModel.talkStatusText.collectAsState() - val talkIsListening by viewModel.talkIsListening.collectAsState() - val talkIsSpeaking by viewModel.talkIsSpeaking.collectAsState() - val seamColorArgb by viewModel.seamColorArgb.collectAsState() - val seamColor = remember(seamColorArgb) { ComposeColor(seamColorArgb) } - val audioPermissionLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> - if (granted) viewModel.setTalkEnabled(true) - } - val activity = - remember(cameraHud, screenRecordActive, isForeground, statusText, voiceWakeStatusText) { - // Status pill owns transient activity state so it doesn't overlap the connection indicator. - if (!isForeground) { - return@remember StatusActivity( - title = "Foreground required", - icon = Icons.Default.Report, - contentDescription = "Foreground required", - ) - } - - val lowerStatus = statusText.lowercase() - if (lowerStatus.contains("repair")) { - return@remember StatusActivity( - title = "Repairing…", - icon = Icons.Default.Refresh, - contentDescription = "Repairing", - ) - } - if (lowerStatus.contains("pairing") || lowerStatus.contains("approval")) { - return@remember StatusActivity( - title = "Approval pending", - icon = Icons.Default.RecordVoiceOver, - contentDescription = "Approval pending", - ) - } - // Avoid duplicating the primary gateway status ("Connecting…") in the activity slot. - - if (screenRecordActive) { - return@remember StatusActivity( - title = "Recording screen…", - icon = Icons.AutoMirrored.Filled.ScreenShare, - contentDescription = "Recording screen", - tint = androidx.compose.ui.graphics.Color.Red, - ) - } - - cameraHud?.let { hud -> - return@remember when (hud.kind) { - CameraHudKind.Photo -> - StatusActivity( - title = hud.message, - icon = Icons.Default.PhotoCamera, - contentDescription = "Taking photo", - ) - CameraHudKind.Recording -> - StatusActivity( - title = hud.message, - icon = Icons.Default.FiberManualRecord, - contentDescription = "Recording", - tint = androidx.compose.ui.graphics.Color.Red, - ) - CameraHudKind.Success -> - StatusActivity( - title = hud.message, - icon = Icons.Default.CheckCircle, - contentDescription = "Capture finished", - ) - CameraHudKind.Error -> - StatusActivity( - title = hud.message, - icon = Icons.Default.Error, - contentDescription = "Capture failed", - tint = androidx.compose.ui.graphics.Color.Red, - ) - } - } - - if (voiceWakeStatusText.contains("Microphone permission", ignoreCase = true)) { - return@remember StatusActivity( - title = "Mic permission", - icon = Icons.Default.Error, - contentDescription = "Mic permission required", - ) - } - if (voiceWakeStatusText == "Paused") { - val suffix = if (!isForeground) " (background)" else "" - return@remember StatusActivity( - title = "Voice Wake paused$suffix", - icon = Icons.Default.RecordVoiceOver, - contentDescription = "Voice Wake paused", - ) - } - - null - } - - val gatewayState = - remember(serverName, statusText) { - when { - serverName != null -> GatewayState.Connected - statusText.contains("connecting", ignoreCase = true) || - statusText.contains("reconnecting", ignoreCase = true) -> GatewayState.Connecting - statusText.contains("error", ignoreCase = true) -> GatewayState.Error - else -> GatewayState.Disconnected - } - } - - val voiceEnabled = - ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == - PackageManager.PERMISSION_GRANTED - - Box(modifier = Modifier.fillMaxSize()) { - CanvasView(viewModel = viewModel, modifier = Modifier.fillMaxSize()) - } - - // Camera flash must be in a Popup to render above the WebView. - Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) { - CameraFlashOverlay(token = cameraFlashToken, modifier = Modifier.fillMaxSize()) - } - - // Keep the overlay buttons above the WebView canvas (AndroidView), otherwise they may not receive touches. - Popup(alignment = Alignment.TopStart, properties = PopupProperties(focusable = false)) { - StatusPill( - gateway = gatewayState, - voiceEnabled = voiceEnabled, - activity = activity, - onClick = { sheet = Sheet.Settings }, - modifier = Modifier.windowInsetsPadding(safeOverlayInsets).padding(start = 12.dp, top = 12.dp), - ) - } - - Popup(alignment = Alignment.TopEnd, properties = PopupProperties(focusable = false)) { - Column( - modifier = Modifier.windowInsetsPadding(safeOverlayInsets).padding(end = 12.dp, top = 12.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - horizontalAlignment = Alignment.End, - ) { - OverlayIconButton( - onClick = { sheet = Sheet.Chat }, - icon = { Icon(Icons.Default.ChatBubble, contentDescription = "Chat") }, - ) - - // Talk mode gets a dedicated side bubble instead of burying it in settings. - val baseOverlay = overlayContainerColor() - val talkContainer = - lerp( - baseOverlay, - seamColor.copy(alpha = baseOverlay.alpha), - if (talkEnabled) 0.35f else 0.22f, - ) - val talkContent = if (talkEnabled) seamColor else overlayIconColor() - OverlayIconButton( - onClick = { - val next = !talkEnabled - if (next) { - val micOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == - PackageManager.PERMISSION_GRANTED - if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) - viewModel.setTalkEnabled(true) - } else { - viewModel.setTalkEnabled(false) - } - }, - containerColor = talkContainer, - contentColor = talkContent, - icon = { - Icon( - Icons.Default.RecordVoiceOver, - contentDescription = "Talk Mode", - ) - }, - ) - - OverlayIconButton( - onClick = { sheet = Sheet.Settings }, - icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") }, - ) - } - } - - if (talkEnabled) { - Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) { - TalkOrbOverlay( - seamColor = seamColor, - statusText = talkStatusText, - isListening = talkIsListening, - isSpeaking = talkIsSpeaking, - ) - } - } - - val currentSheet = sheet - if (currentSheet != null) { - ModalBottomSheet( - onDismissRequest = { sheet = null }, - sheetState = sheetState, - ) { - when (currentSheet) { - Sheet.Chat -> ChatSheet(viewModel = viewModel) - Sheet.Settings -> SettingsSheet(viewModel = viewModel) - } - } - } -} - -private enum class Sheet { - Chat, - Settings, -} - -@Composable -private fun OverlayIconButton( - onClick: () -> Unit, - icon: @Composable () -> Unit, - containerColor: ComposeColor? = null, - contentColor: ComposeColor? = null, -) { - FilledTonalIconButton( - onClick = onClick, - modifier = Modifier.size(44.dp), - colors = - IconButtonDefaults.filledTonalIconButtonColors( - containerColor = containerColor ?: overlayContainerColor(), - contentColor = contentColor ?: overlayIconColor(), - ), - ) { - icon() - } -} - -@SuppressLint("SetJavaScriptEnabled") -@Composable -private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier) { - val context = LocalContext.current - val isDebuggable = (context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0 - AndroidView( - modifier = modifier, - factory = { - WebView(context).apply { - settings.javaScriptEnabled = true - // Some embedded web UIs (incl. the "background website") use localStorage/sessionStorage. - settings.domStorageEnabled = true - settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE - if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) { - WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, false) - } else { - disableForceDarkIfSupported(settings) - } - if (isDebuggable) { - Log.d("OpenClawWebView", "userAgent: ${settings.userAgentString}") - } - isScrollContainer = true - overScrollMode = View.OVER_SCROLL_IF_CONTENT_SCROLLS - isVerticalScrollBarEnabled = true - isHorizontalScrollBarEnabled = true - webViewClient = - object : WebViewClient() { - override fun onReceivedError( - view: WebView, - request: WebResourceRequest, - error: WebResourceError, - ) { - if (!isDebuggable) return - if (!request.isForMainFrame) return - Log.e("OpenClawWebView", "onReceivedError: ${error.errorCode} ${error.description} ${request.url}") - } - - override fun onReceivedHttpError( - view: WebView, - request: WebResourceRequest, - errorResponse: WebResourceResponse, - ) { - if (!isDebuggable) return - if (!request.isForMainFrame) return - Log.e( - "OpenClawWebView", - "onReceivedHttpError: ${errorResponse.statusCode} ${errorResponse.reasonPhrase} ${request.url}", - ) - } - - override fun onPageFinished(view: WebView, url: String?) { - if (isDebuggable) { - Log.d("OpenClawWebView", "onPageFinished: $url") - } - viewModel.canvas.onPageFinished() - } - - override fun onRenderProcessGone( - view: WebView, - detail: android.webkit.RenderProcessGoneDetail, - ): Boolean { - if (isDebuggable) { - Log.e( - "OpenClawWebView", - "onRenderProcessGone didCrash=${detail.didCrash()} priorityAtExit=${detail.rendererPriorityAtExit()}", - ) - } - return true - } - } - webChromeClient = - object : WebChromeClient() { - override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { - if (!isDebuggable) return false - val msg = consoleMessage ?: return false - Log.d( - "OpenClawWebView", - "console ${msg.messageLevel()} @ ${msg.sourceId()}:${msg.lineNumber()} ${msg.message()}", - ) - return false - } - } - // Use default layer/background; avoid forcing a black fill over WebView content. - - val a2uiBridge = - CanvasA2UIActionBridge { payload -> - viewModel.handleCanvasA2UIActionFromWebView(payload) - } - addJavascriptInterface(a2uiBridge, CanvasA2UIActionBridge.interfaceName) - viewModel.canvas.attach(this) - } - }, - ) -} - -private fun disableForceDarkIfSupported(settings: WebSettings) { - if (!WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) return - @Suppress("DEPRECATION") - WebSettingsCompat.setForceDark(settings, WebSettingsCompat.FORCE_DARK_OFF) -} - -private class CanvasA2UIActionBridge(private val onMessage: (String) -> Unit) { - @JavascriptInterface - fun postMessage(payload: String?) { - val msg = payload?.trim().orEmpty() - if (msg.isEmpty()) return - onMessage(msg) - } - - companion object { - const val interfaceName: String = "openclawCanvasA2UIAction" - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt deleted file mode 100644 index bb04c30108c..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt +++ /dev/null @@ -1,723 +0,0 @@ -package ai.openclaw.android.ui - -import android.Manifest -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.net.Uri -import android.os.Build -import android.provider.Settings -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ExpandLess -import androidx.compose.material.icons.filled.ExpandMore -import androidx.compose.material3.Button -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.ListItem -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.RadioButton -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.core.content.ContextCompat -import ai.openclaw.android.BuildConfig -import ai.openclaw.android.LocationMode -import ai.openclaw.android.MainViewModel -import ai.openclaw.android.NodeForegroundService -import ai.openclaw.android.VoiceWakeMode -import ai.openclaw.android.WakeWords - -@Composable -fun SettingsSheet(viewModel: MainViewModel) { - val context = LocalContext.current - val instanceId by viewModel.instanceId.collectAsState() - val displayName by viewModel.displayName.collectAsState() - val cameraEnabled by viewModel.cameraEnabled.collectAsState() - val locationMode by viewModel.locationMode.collectAsState() - val locationPreciseEnabled by viewModel.locationPreciseEnabled.collectAsState() - val preventSleep by viewModel.preventSleep.collectAsState() - val wakeWords by viewModel.wakeWords.collectAsState() - val voiceWakeMode by viewModel.voiceWakeMode.collectAsState() - val voiceWakeStatusText by viewModel.voiceWakeStatusText.collectAsState() - val isConnected by viewModel.isConnected.collectAsState() - val manualEnabled by viewModel.manualEnabled.collectAsState() - val manualHost by viewModel.manualHost.collectAsState() - val manualPort by viewModel.manualPort.collectAsState() - val manualTls by viewModel.manualTls.collectAsState() - val gatewayToken by viewModel.gatewayToken.collectAsState() - val canvasDebugStatusEnabled by viewModel.canvasDebugStatusEnabled.collectAsState() - val statusText by viewModel.statusText.collectAsState() - val serverName by viewModel.serverName.collectAsState() - val remoteAddress by viewModel.remoteAddress.collectAsState() - val gateways by viewModel.gateways.collectAsState() - val discoveryStatusText by viewModel.discoveryStatusText.collectAsState() - val pendingTrust by viewModel.pendingGatewayTrust.collectAsState() - - val listState = rememberLazyListState() - val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") } - val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) } - val focusManager = LocalFocusManager.current - var wakeWordsHadFocus by remember { mutableStateOf(false) } - val deviceModel = - remember { - listOfNotNull(Build.MANUFACTURER, Build.MODEL) - .joinToString(" ") - .trim() - .ifEmpty { "Android" } - } - val appVersion = - remember { - val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" } - if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) { - "$versionName-dev" - } else { - versionName - } - } - - if (pendingTrust != null) { - val prompt = pendingTrust!! - AlertDialog( - onDismissRequest = { viewModel.declineGatewayTrustPrompt() }, - title = { Text("Trust this gateway?") }, - text = { - Text( - "First-time TLS connection.\n\n" + - "Verify this SHA-256 fingerprint out-of-band before trusting:\n" + - prompt.fingerprintSha256, - ) - }, - confirmButton = { - TextButton(onClick = { viewModel.acceptGatewayTrustPrompt() }) { - Text("Trust and connect") - } - }, - dismissButton = { - TextButton(onClick = { viewModel.declineGatewayTrustPrompt() }) { - Text("Cancel") - } - }, - ) - } - - LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) } - val commitWakeWords = { - val parsed = WakeWords.parseIfChanged(wakeWordsText, wakeWords) - if (parsed != null) { - viewModel.setWakeWords(parsed) - } - } - - val permissionLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms -> - val cameraOk = perms[Manifest.permission.CAMERA] == true - viewModel.setCameraEnabled(cameraOk) - } - - var pendingLocationMode by remember { mutableStateOf(null) } - var pendingPreciseToggle by remember { mutableStateOf(false) } - - val locationPermissionLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms -> - val fineOk = perms[Manifest.permission.ACCESS_FINE_LOCATION] == true - val coarseOk = perms[Manifest.permission.ACCESS_COARSE_LOCATION] == true - val granted = fineOk || coarseOk - val requestedMode = pendingLocationMode - pendingLocationMode = null - - if (pendingPreciseToggle) { - pendingPreciseToggle = false - viewModel.setLocationPreciseEnabled(fineOk) - return@rememberLauncherForActivityResult - } - - if (!granted) { - viewModel.setLocationMode(LocationMode.Off) - return@rememberLauncherForActivityResult - } - - if (requestedMode != null) { - viewModel.setLocationMode(requestedMode) - if (requestedMode == LocationMode.Always) { - val backgroundOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) == - PackageManager.PERMISSION_GRANTED - if (!backgroundOk) { - openAppSettings(context) - } - } - } - } - - val audioPermissionLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { _ -> - // Status text is handled by NodeRuntime. - } - - val smsPermissionAvailable = - remember { - context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true - } - var smsPermissionGranted by - remember { - mutableStateOf( - ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) == - PackageManager.PERMISSION_GRANTED, - ) - } - val smsPermissionLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> - smsPermissionGranted = granted - viewModel.refreshGatewayConnection() - } - - fun setCameraEnabledChecked(checked: Boolean) { - if (!checked) { - viewModel.setCameraEnabled(false) - return - } - - val cameraOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == - PackageManager.PERMISSION_GRANTED - if (cameraOk) { - viewModel.setCameraEnabled(true) - } else { - permissionLauncher.launch(arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO)) - } - } - - fun requestLocationPermissions(targetMode: LocationMode) { - val fineOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == - PackageManager.PERMISSION_GRANTED - val coarseOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == - PackageManager.PERMISSION_GRANTED - if (fineOk || coarseOk) { - viewModel.setLocationMode(targetMode) - if (targetMode == LocationMode.Always) { - val backgroundOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) == - PackageManager.PERMISSION_GRANTED - if (!backgroundOk) { - openAppSettings(context) - } - } - } else { - pendingLocationMode = targetMode - locationPermissionLauncher.launch( - arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION), - ) - } - } - - fun setPreciseLocationChecked(checked: Boolean) { - if (!checked) { - viewModel.setLocationPreciseEnabled(false) - return - } - val fineOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == - PackageManager.PERMISSION_GRANTED - if (fineOk) { - viewModel.setLocationPreciseEnabled(true) - } else { - pendingPreciseToggle = true - locationPermissionLauncher.launch(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)) - } - } - - val visibleGateways = - if (isConnected && remoteAddress != null) { - gateways.filterNot { "${it.host}:${it.port}" == remoteAddress } - } else { - gateways - } - - val gatewayDiscoveryFooterText = - if (visibleGateways.isEmpty()) { - discoveryStatusText - } else if (isConnected) { - "Discovery active • ${visibleGateways.size} other gateway${if (visibleGateways.size == 1) "" else "s"} found" - } else { - "Discovery active • ${visibleGateways.size} gateway${if (visibleGateways.size == 1) "" else "s"} found" - } - - LazyColumn( - state = listState, - modifier = - Modifier - .fillMaxWidth() - .fillMaxHeight() - .imePadding() - .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - // Order parity: Node → Gateway → Voice → Camera → Messaging → Location → Screen. - item { Text("Node", style = MaterialTheme.typography.titleSmall) } - item { - OutlinedTextField( - value = displayName, - onValueChange = viewModel::setDisplayName, - label = { Text("Name") }, - modifier = Modifier.fillMaxWidth(), - ) - } - item { Text("Instance ID: $instanceId", color = MaterialTheme.colorScheme.onSurfaceVariant) } - item { Text("Device: $deviceModel", color = MaterialTheme.colorScheme.onSurfaceVariant) } - item { Text("Version: $appVersion", color = MaterialTheme.colorScheme.onSurfaceVariant) } - - item { HorizontalDivider() } - - // Gateway - item { Text("Gateway", style = MaterialTheme.typography.titleSmall) } - item { ListItem(headlineContent = { Text("Status") }, supportingContent = { Text(statusText) }) } - if (serverName != null) { - item { ListItem(headlineContent = { Text("Server") }, supportingContent = { Text(serverName!!) }) } - } - if (remoteAddress != null) { - item { ListItem(headlineContent = { Text("Address") }, supportingContent = { Text(remoteAddress!!) }) } - } - item { - // UI sanity: "Disconnect" only when we have an active remote. - if (isConnected && remoteAddress != null) { - Button( - onClick = { - viewModel.disconnect() - NodeForegroundService.stop(context) - }, - ) { - Text("Disconnect") - } - } - } - - item { HorizontalDivider() } - - if (!isConnected || visibleGateways.isNotEmpty()) { - item { - Text( - if (isConnected) "Other Gateways" else "Discovered Gateways", - style = MaterialTheme.typography.titleSmall, - ) - } - if (!isConnected && visibleGateways.isEmpty()) { - item { Text("No gateways found yet.", color = MaterialTheme.colorScheme.onSurfaceVariant) } - } else { - items(items = visibleGateways, key = { it.stableId }) { gateway -> - val detailLines = - buildList { - add("IP: ${gateway.host}:${gateway.port}") - gateway.lanHost?.let { add("LAN: $it") } - gateway.tailnetDns?.let { add("Tailnet: $it") } - if (gateway.gatewayPort != null || gateway.canvasPort != null) { - val gw = (gateway.gatewayPort ?: gateway.port).toString() - val canvas = gateway.canvasPort?.toString() ?: "—" - add("Ports: gw $gw · canvas $canvas") - } - } - ListItem( - headlineContent = { Text(gateway.name) }, - supportingContent = { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - detailLines.forEach { line -> - Text(line, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - }, - trailingContent = { - Button( - onClick = { - NodeForegroundService.start(context) - viewModel.connect(gateway) - }, - ) { - Text("Connect") - } - }, - ) - } - } - item { - Text( - gatewayDiscoveryFooterText, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - item { HorizontalDivider() } - - item { - ListItem( - headlineContent = { Text("Advanced") }, - supportingContent = { Text("Manual gateway connection") }, - trailingContent = { - Icon( - imageVector = if (advancedExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, - contentDescription = if (advancedExpanded) "Collapse" else "Expand", - ) - }, - modifier = - Modifier.clickable { - setAdvancedExpanded(!advancedExpanded) - }, - ) - } - item { - AnimatedVisibility(visible = advancedExpanded) { - Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { - ListItem( - headlineContent = { Text("Use Manual Gateway") }, - supportingContent = { Text("Use this when discovery is blocked.") }, - trailingContent = { Switch(checked = manualEnabled, onCheckedChange = viewModel::setManualEnabled) }, - ) - - OutlinedTextField( - value = manualHost, - onValueChange = viewModel::setManualHost, - label = { Text("Host") }, - modifier = Modifier.fillMaxWidth(), - enabled = manualEnabled, - ) - OutlinedTextField( - value = manualPort.toString(), - onValueChange = { v -> viewModel.setManualPort(v.toIntOrNull() ?: 0) }, - label = { Text("Port") }, - modifier = Modifier.fillMaxWidth(), - enabled = manualEnabled, - ) - OutlinedTextField( - value = gatewayToken, - onValueChange = viewModel::setGatewayToken, - label = { Text("Gateway Token") }, - modifier = Modifier.fillMaxWidth(), - enabled = manualEnabled, - singleLine = true, - ) - ListItem( - headlineContent = { Text("Require TLS") }, - supportingContent = { Text("Pin the gateway certificate on first connect.") }, - trailingContent = { Switch(checked = manualTls, onCheckedChange = viewModel::setManualTls, enabled = manualEnabled) }, - modifier = Modifier.alpha(if (manualEnabled) 1f else 0.5f), - ) - - val hostOk = manualHost.trim().isNotEmpty() - val portOk = manualPort in 1..65535 - Button( - onClick = { - NodeForegroundService.start(context) - viewModel.connectManual() - }, - enabled = manualEnabled && hostOk && portOk, - ) { - Text("Connect (Manual)") - } - } - } - } - - item { HorizontalDivider() } - - // Voice - item { Text("Voice", style = MaterialTheme.typography.titleSmall) } - item { - val enabled = voiceWakeMode != VoiceWakeMode.Off - ListItem( - headlineContent = { Text("Voice Wake") }, - supportingContent = { Text(voiceWakeStatusText) }, - trailingContent = { - Switch( - checked = enabled, - onCheckedChange = { on -> - if (on) { - val micOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == - PackageManager.PERMISSION_GRANTED - if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) - viewModel.setVoiceWakeMode(VoiceWakeMode.Foreground) - } else { - viewModel.setVoiceWakeMode(VoiceWakeMode.Off) - } - }, - ) - }, - ) - } - item { - AnimatedVisibility(visible = voiceWakeMode != VoiceWakeMode.Off) { - Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) { - ListItem( - headlineContent = { Text("Foreground Only") }, - supportingContent = { Text("Listens only while OpenClaw is open.") }, - trailingContent = { - RadioButton( - selected = voiceWakeMode == VoiceWakeMode.Foreground, - onClick = { - val micOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == - PackageManager.PERMISSION_GRANTED - if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) - viewModel.setVoiceWakeMode(VoiceWakeMode.Foreground) - }, - ) - }, - ) - ListItem( - headlineContent = { Text("Always") }, - supportingContent = { Text("Keeps listening in the background (shows a persistent notification).") }, - trailingContent = { - RadioButton( - selected = voiceWakeMode == VoiceWakeMode.Always, - onClick = { - val micOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == - PackageManager.PERMISSION_GRANTED - if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) - viewModel.setVoiceWakeMode(VoiceWakeMode.Always) - }, - ) - }, - ) - } - } - } - item { - OutlinedTextField( - value = wakeWordsText, - onValueChange = setWakeWordsText, - label = { Text("Wake Words (comma-separated)") }, - modifier = - Modifier.fillMaxWidth().onFocusChanged { focusState -> - if (focusState.isFocused) { - wakeWordsHadFocus = true - } else if (wakeWordsHadFocus) { - wakeWordsHadFocus = false - commitWakeWords() - } - }, - singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = - KeyboardActions( - onDone = { - commitWakeWords() - focusManager.clearFocus() - }, - ), - ) - } - item { Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") } } - item { - Text( - if (isConnected) { - "Any node can edit wake words. Changes sync via the gateway." - } else { - "Connect to a gateway to sync wake words globally." - }, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - item { HorizontalDivider() } - - // Camera - item { Text("Camera", style = MaterialTheme.typography.titleSmall) } - item { - ListItem( - headlineContent = { Text("Allow Camera") }, - supportingContent = { Text("Allows the gateway to request photos or short video clips (foreground only).") }, - trailingContent = { Switch(checked = cameraEnabled, onCheckedChange = ::setCameraEnabledChecked) }, - ) - } - item { - Text( - "Tip: grant Microphone permission for video clips with audio.", - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - item { HorizontalDivider() } - - // Messaging - item { Text("Messaging", style = MaterialTheme.typography.titleSmall) } - item { - val buttonLabel = - when { - !smsPermissionAvailable -> "Unavailable" - smsPermissionGranted -> "Manage" - else -> "Grant" - } - ListItem( - headlineContent = { Text("SMS Permission") }, - supportingContent = { - Text( - if (smsPermissionAvailable) { - "Allow the gateway to send SMS from this device." - } else { - "SMS requires a device with telephony hardware." - }, - ) - }, - trailingContent = { - Button( - onClick = { - if (!smsPermissionAvailable) return@Button - if (smsPermissionGranted) { - openAppSettings(context) - } else { - smsPermissionLauncher.launch(Manifest.permission.SEND_SMS) - } - }, - enabled = smsPermissionAvailable, - ) { - Text(buttonLabel) - } - }, - ) - } - - item { HorizontalDivider() } - - // Location - item { Text("Location", style = MaterialTheme.typography.titleSmall) } - item { - Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) { - ListItem( - headlineContent = { Text("Off") }, - supportingContent = { Text("Disable location sharing.") }, - trailingContent = { - RadioButton( - selected = locationMode == LocationMode.Off, - onClick = { viewModel.setLocationMode(LocationMode.Off) }, - ) - }, - ) - ListItem( - headlineContent = { Text("While Using") }, - supportingContent = { Text("Only while OpenClaw is open.") }, - trailingContent = { - RadioButton( - selected = locationMode == LocationMode.WhileUsing, - onClick = { requestLocationPermissions(LocationMode.WhileUsing) }, - ) - }, - ) - ListItem( - headlineContent = { Text("Always") }, - supportingContent = { Text("Allow background location (requires system permission).") }, - trailingContent = { - RadioButton( - selected = locationMode == LocationMode.Always, - onClick = { requestLocationPermissions(LocationMode.Always) }, - ) - }, - ) - } - } - item { - ListItem( - headlineContent = { Text("Precise Location") }, - supportingContent = { Text("Use precise GPS when available.") }, - trailingContent = { - Switch( - checked = locationPreciseEnabled, - onCheckedChange = ::setPreciseLocationChecked, - enabled = locationMode != LocationMode.Off, - ) - }, - ) - } - item { - Text( - "Always may require Android Settings to allow background location.", - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - item { HorizontalDivider() } - - // Screen - item { Text("Screen", style = MaterialTheme.typography.titleSmall) } - item { - ListItem( - headlineContent = { Text("Prevent Sleep") }, - supportingContent = { Text("Keeps the screen awake while OpenClaw is open.") }, - trailingContent = { Switch(checked = preventSleep, onCheckedChange = viewModel::setPreventSleep) }, - ) - } - - item { HorizontalDivider() } - - // Debug - item { Text("Debug", style = MaterialTheme.typography.titleSmall) } - item { - ListItem( - headlineContent = { Text("Debug Canvas Status") }, - supportingContent = { Text("Show status text in the canvas when debug is enabled.") }, - trailingContent = { - Switch( - checked = canvasDebugStatusEnabled, - onCheckedChange = viewModel::setCanvasDebugStatusEnabled, - ) - }, - ) - } - - item { Spacer(modifier = Modifier.height(20.dp)) } - } -} - -private fun openAppSettings(context: Context) { - val intent = - Intent( - Settings.ACTION_APPLICATION_DETAILS_SETTINGS, - Uri.fromParts("package", context.packageName, null), - ) - context.startActivity(intent) -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/StatusPill.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/StatusPill.kt deleted file mode 100644 index d608fc38a7b..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/StatusPill.kt +++ /dev/null @@ -1,114 +0,0 @@ -package ai.openclaw.android.ui - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Mic -import androidx.compose.material.icons.filled.MicOff -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.VerticalDivider -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp - -@Composable -fun StatusPill( - gateway: GatewayState, - voiceEnabled: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, - activity: StatusActivity? = null, -) { - Surface( - onClick = onClick, - modifier = modifier, - shape = RoundedCornerShape(14.dp), - color = overlayContainerColor(), - tonalElevation = 3.dp, - shadowElevation = 0.dp, - ) { - Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - Surface( - modifier = Modifier.size(9.dp), - shape = CircleShape, - color = gateway.color, - ) {} - - Text( - text = gateway.title, - style = MaterialTheme.typography.labelLarge, - ) - } - - VerticalDivider( - modifier = Modifier.height(14.dp).alpha(0.35f), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - if (activity != null) { - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = activity.icon, - contentDescription = activity.contentDescription, - tint = activity.tint ?: overlayIconColor(), - modifier = Modifier.size(18.dp), - ) - Text( - text = activity.title, - style = MaterialTheme.typography.labelLarge, - maxLines = 1, - ) - } - } else { - Icon( - imageVector = if (voiceEnabled) Icons.Default.Mic else Icons.Default.MicOff, - contentDescription = if (voiceEnabled) "Voice enabled" else "Voice disabled", - tint = - if (voiceEnabled) { - overlayIconColor() - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - modifier = Modifier.size(18.dp), - ) - } - - Spacer(modifier = Modifier.width(2.dp)) - } - } -} - -data class StatusActivity( - val title: String, - val icon: androidx.compose.ui.graphics.vector.ImageVector, - val contentDescription: String, - val tint: Color? = null, -) - -enum class GatewayState(val title: String, val color: Color) { - Connected("Connected", Color(0xFF2ECC71)), - Connecting("Connecting…", Color(0xFFF1C40F)), - Error("Error", Color(0xFFE74C3C)), - Disconnected("Offline", Color(0xFF9E9E9E)), -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/TalkOrbOverlay.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/TalkOrbOverlay.kt deleted file mode 100644 index f89b298d1f7..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/TalkOrbOverlay.kt +++ /dev/null @@ -1,134 +0,0 @@ -package ai.openclaw.android.ui - -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp - -@Composable -fun TalkOrbOverlay( - seamColor: Color, - statusText: String, - isListening: Boolean, - isSpeaking: Boolean, - modifier: Modifier = Modifier, -) { - val transition = rememberInfiniteTransition(label = "talk-orb") - val t by - transition.animateFloat( - initialValue = 0f, - targetValue = 1f, - animationSpec = - infiniteRepeatable( - animation = tween(durationMillis = 1500, easing = LinearEasing), - repeatMode = RepeatMode.Restart, - ), - label = "pulse", - ) - - val trimmed = statusText.trim() - val showStatus = trimmed.isNotEmpty() && trimmed != "Off" - val phase = - when { - isSpeaking -> "Speaking" - isListening -> "Listening" - else -> "Thinking" - } - - Column( - modifier = modifier.padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Box(contentAlignment = Alignment.Center) { - Canvas(modifier = Modifier.size(360.dp)) { - val center = this.center - val baseRadius = size.minDimension * 0.30f - - val ring1 = 1.05f + (t * 0.25f) - val ring2 = 1.20f + (t * 0.55f) - val ringAlpha1 = (1f - t) * 0.34f - val ringAlpha2 = (1f - t) * 0.22f - - drawCircle( - color = seamColor.copy(alpha = ringAlpha1), - radius = baseRadius * ring1, - center = center, - style = Stroke(width = 3.dp.toPx()), - ) - drawCircle( - color = seamColor.copy(alpha = ringAlpha2), - radius = baseRadius * ring2, - center = center, - style = Stroke(width = 3.dp.toPx()), - ) - - drawCircle( - brush = - Brush.radialGradient( - colors = - listOf( - seamColor.copy(alpha = 0.92f), - seamColor.copy(alpha = 0.40f), - Color.Black.copy(alpha = 0.56f), - ), - center = center, - radius = baseRadius * 1.35f, - ), - radius = baseRadius, - center = center, - ) - - drawCircle( - color = seamColor.copy(alpha = 0.34f), - radius = baseRadius, - center = center, - style = Stroke(width = 1.dp.toPx()), - ) - } - } - - if (showStatus) { - Surface( - color = Color.Black.copy(alpha = 0.40f), - shape = CircleShape, - ) { - Text( - text = trimmed, - modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), - color = Color.White.copy(alpha = 0.92f), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - ) - } - } else { - Text( - text = phase, - color = Color.White.copy(alpha = 0.80f), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - ) - } - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatComposer.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatComposer.kt deleted file mode 100644 index 07ba769697d..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatComposer.kt +++ /dev/null @@ -1,285 +0,0 @@ -package ai.openclaw.android.ui.chat - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.horizontalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowUpward -import androidx.compose.material.icons.filled.AttachFile -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.Stop -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.FilledTonalIconButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import ai.openclaw.android.chat.ChatSessionEntry - -@Composable -fun ChatComposer( - sessionKey: String, - sessions: List, - mainSessionKey: String, - healthOk: Boolean, - thinkingLevel: String, - pendingRunCount: Int, - errorText: String?, - attachments: List, - onPickImages: () -> Unit, - onRemoveAttachment: (id: String) -> Unit, - onSetThinkingLevel: (level: String) -> Unit, - onSelectSession: (sessionKey: String) -> Unit, - onRefresh: () -> Unit, - onAbort: () -> Unit, - onSend: (text: String) -> Unit, -) { - var input by rememberSaveable { mutableStateOf("") } - var showThinkingMenu by remember { mutableStateOf(false) } - var showSessionMenu by remember { mutableStateOf(false) } - - val sessionOptions = resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey) - val currentSessionLabel = friendlySessionName( - sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey - ) - - val canSend = pendingRunCount == 0 && (input.trim().isNotEmpty() || attachments.isNotEmpty()) && healthOk - - Surface( - shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surfaceContainer, - tonalElevation = 0.dp, - shadowElevation = 0.dp, - ) { - Column(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row( - modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box { - FilledTonalButton( - onClick = { showSessionMenu = true }, - contentPadding = ButtonDefaults.ContentPadding, - ) { - Text(currentSessionLabel, maxLines = 1, overflow = TextOverflow.Ellipsis) - } - - DropdownMenu(expanded = showSessionMenu, onDismissRequest = { showSessionMenu = false }) { - for (entry in sessionOptions) { - DropdownMenuItem( - text = { Text(friendlySessionName(entry.displayName ?: entry.key)) }, - onClick = { - onSelectSession(entry.key) - showSessionMenu = false - }, - trailingIcon = { - if (entry.key == sessionKey) { - Text("✓") - } else { - Spacer(modifier = Modifier.width(10.dp)) - } - }, - ) - } - } - } - - Box { - FilledTonalButton( - onClick = { showThinkingMenu = true }, - contentPadding = ButtonDefaults.ContentPadding, - ) { - Text("🧠 ${thinkingLabel(thinkingLevel)}", maxLines = 1) - } - - DropdownMenu(expanded = showThinkingMenu, onDismissRequest = { showThinkingMenu = false }) { - ThinkingMenuItem("off", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } - ThinkingMenuItem("low", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } - ThinkingMenuItem("medium", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } - ThinkingMenuItem("high", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } - } - } - - FilledTonalIconButton(onClick = onRefresh, modifier = Modifier.size(42.dp)) { - Icon(Icons.Default.Refresh, contentDescription = "Refresh") - } - - FilledTonalIconButton(onClick = onPickImages, modifier = Modifier.size(42.dp)) { - Icon(Icons.Default.AttachFile, contentDescription = "Add image") - } - } - - if (attachments.isNotEmpty()) { - AttachmentsStrip(attachments = attachments, onRemoveAttachment = onRemoveAttachment) - } - - OutlinedTextField( - value = input, - onValueChange = { input = it }, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text("Message OpenClaw…") }, - minLines = 2, - maxLines = 6, - ) - - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - ConnectionPill(sessionLabel = currentSessionLabel, healthOk = healthOk) - Spacer(modifier = Modifier.weight(1f)) - - if (pendingRunCount > 0) { - FilledTonalIconButton( - onClick = onAbort, - colors = - IconButtonDefaults.filledTonalIconButtonColors( - containerColor = Color(0x33E74C3C), - contentColor = Color(0xFFE74C3C), - ), - ) { - Icon(Icons.Default.Stop, contentDescription = "Abort") - } - } else { - FilledTonalIconButton(onClick = { - val text = input - input = "" - onSend(text) - }, enabled = canSend) { - Icon(Icons.Default.ArrowUpward, contentDescription = "Send") - } - } - } - - if (!errorText.isNullOrBlank()) { - Text( - text = errorText, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - maxLines = 2, - ) - } - } - } -} - -@Composable -private fun ConnectionPill(sessionLabel: String, healthOk: Boolean) { - Surface( - shape = RoundedCornerShape(999.dp), - color = MaterialTheme.colorScheme.surfaceContainerHighest, - ) { - Row( - modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Surface( - modifier = Modifier.size(7.dp), - shape = androidx.compose.foundation.shape.CircleShape, - color = if (healthOk) Color(0xFF2ECC71) else Color(0xFFF39C12), - ) {} - Text(sessionLabel, style = MaterialTheme.typography.labelSmall) - Text( - if (healthOk) "Connected" else "Connecting…", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} - -@Composable -private fun ThinkingMenuItem( - value: String, - current: String, - onSet: (String) -> Unit, - onDismiss: () -> Unit, -) { - DropdownMenuItem( - text = { Text(thinkingLabel(value)) }, - onClick = { - onSet(value) - onDismiss() - }, - trailingIcon = { - if (value == current.trim().lowercase()) { - Text("✓") - } else { - Spacer(modifier = Modifier.width(10.dp)) - } - }, - ) -} - -private fun thinkingLabel(raw: String): String { - return when (raw.trim().lowercase()) { - "low" -> "Low" - "medium" -> "Medium" - "high" -> "High" - else -> "Off" - } -} - -@Composable -private fun AttachmentsStrip( - attachments: List, - onRemoveAttachment: (id: String) -> Unit, -) { - Row( - modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - for (att in attachments) { - AttachmentChip( - fileName = att.fileName, - onRemove = { onRemoveAttachment(att.id) }, - ) - } - } -} - -@Composable -private fun AttachmentChip(fileName: String, onRemove: () -> Unit) { - Surface( - shape = RoundedCornerShape(999.dp), - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.10f), - ) { - Row( - modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text(text = fileName, style = MaterialTheme.typography.bodySmall, maxLines = 1) - FilledTonalIconButton( - onClick = onRemove, - modifier = Modifier.size(30.dp), - ) { - Text("×") - } - } - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMarkdown.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMarkdown.kt deleted file mode 100644 index 77dba2275a4..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMarkdown.kt +++ /dev/null @@ -1,215 +0,0 @@ -package ai.openclaw.android.ui.chat - -import android.graphics.BitmapFactory -import android.util.Base64 -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -@Composable -fun ChatMarkdown(text: String, textColor: Color) { - val blocks = remember(text) { splitMarkdown(text) } - val inlineCodeBg = MaterialTheme.colorScheme.surfaceContainerLow - - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - for (b in blocks) { - when (b) { - is ChatMarkdownBlock.Text -> { - val trimmed = b.text.trimEnd() - if (trimmed.isEmpty()) continue - Text( - text = parseInlineMarkdown(trimmed, inlineCodeBg = inlineCodeBg), - style = MaterialTheme.typography.bodyMedium, - color = textColor, - ) - } - is ChatMarkdownBlock.Code -> { - SelectionContainer(modifier = Modifier.fillMaxWidth()) { - ChatCodeBlock(code = b.code, language = b.language) - } - } - is ChatMarkdownBlock.InlineImage -> { - InlineBase64Image(base64 = b.base64, mimeType = b.mimeType) - } - } - } - } -} - -private sealed interface ChatMarkdownBlock { - data class Text(val text: String) : ChatMarkdownBlock - data class Code(val code: String, val language: String?) : ChatMarkdownBlock - data class InlineImage(val mimeType: String?, val base64: String) : ChatMarkdownBlock -} - -private fun splitMarkdown(raw: String): List { - if (raw.isEmpty()) return emptyList() - - val out = ArrayList() - var idx = 0 - while (idx < raw.length) { - val fenceStart = raw.indexOf("```", startIndex = idx) - if (fenceStart < 0) { - out.addAll(splitInlineImages(raw.substring(idx))) - break - } - - if (fenceStart > idx) { - out.addAll(splitInlineImages(raw.substring(idx, fenceStart))) - } - - val langLineStart = fenceStart + 3 - val langLineEnd = raw.indexOf('\n', startIndex = langLineStart).let { if (it < 0) raw.length else it } - val language = raw.substring(langLineStart, langLineEnd).trim().ifEmpty { null } - - val codeStart = if (langLineEnd < raw.length && raw[langLineEnd] == '\n') langLineEnd + 1 else langLineEnd - val fenceEnd = raw.indexOf("```", startIndex = codeStart) - if (fenceEnd < 0) { - out.addAll(splitInlineImages(raw.substring(fenceStart))) - break - } - val code = raw.substring(codeStart, fenceEnd) - out.add(ChatMarkdownBlock.Code(code = code, language = language)) - - idx = fenceEnd + 3 - } - - return out -} - -private fun splitInlineImages(text: String): List { - if (text.isEmpty()) return emptyList() - val regex = Regex("data:image/([a-zA-Z0-9+.-]+);base64,([A-Za-z0-9+/=\\n\\r]+)") - val out = ArrayList() - - var idx = 0 - while (idx < text.length) { - val m = regex.find(text, startIndex = idx) ?: break - val start = m.range.first - val end = m.range.last + 1 - if (start > idx) out.add(ChatMarkdownBlock.Text(text.substring(idx, start))) - - val mime = "image/" + (m.groupValues.getOrNull(1)?.trim()?.ifEmpty { "png" } ?: "png") - val b64 = m.groupValues.getOrNull(2)?.replace("\n", "")?.replace("\r", "")?.trim().orEmpty() - if (b64.isNotEmpty()) { - out.add(ChatMarkdownBlock.InlineImage(mimeType = mime, base64 = b64)) - } - idx = end - } - - if (idx < text.length) out.add(ChatMarkdownBlock.Text(text.substring(idx))) - return out -} - -private fun parseInlineMarkdown(text: String, inlineCodeBg: androidx.compose.ui.graphics.Color): AnnotatedString { - if (text.isEmpty()) return AnnotatedString("") - - val out = buildAnnotatedString { - var i = 0 - while (i < text.length) { - if (text.startsWith("**", startIndex = i)) { - val end = text.indexOf("**", startIndex = i + 2) - if (end > i + 2) { - withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) { - append(text.substring(i + 2, end)) - } - i = end + 2 - continue - } - } - - if (text[i] == '`') { - val end = text.indexOf('`', startIndex = i + 1) - if (end > i + 1) { - withStyle( - SpanStyle( - fontFamily = FontFamily.Monospace, - background = inlineCodeBg, - ), - ) { - append(text.substring(i + 1, end)) - } - i = end + 1 - continue - } - } - - if (text[i] == '*' && (i + 1 < text.length && text[i + 1] != '*')) { - val end = text.indexOf('*', startIndex = i + 1) - if (end > i + 1) { - withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { - append(text.substring(i + 1, end)) - } - i = end + 1 - continue - } - } - - append(text[i]) - i += 1 - } - } - return out -} - -@Composable -private fun InlineBase64Image(base64: String, mimeType: String?) { - var image by remember(base64) { mutableStateOf(null) } - var failed by remember(base64) { mutableStateOf(false) } - - LaunchedEffect(base64) { - failed = false - image = - withContext(Dispatchers.Default) { - try { - val bytes = Base64.decode(base64, Base64.DEFAULT) - val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null - bitmap.asImageBitmap() - } catch (_: Throwable) { - null - } - } - if (image == null) failed = true - } - - if (image != null) { - Image( - bitmap = image!!, - contentDescription = mimeType ?: "image", - contentScale = ContentScale.Fit, - modifier = Modifier.fillMaxWidth(), - ) - } else if (failed) { - Text( - text = "Image unavailable", - modifier = Modifier.padding(vertical = 2.dp), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageListCard.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageListCard.kt deleted file mode 100644 index bcec19a5fa2..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageListCard.kt +++ /dev/null @@ -1,110 +0,0 @@ -package ai.openclaw.android.ui.chat - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowCircleDown -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.unit.dp -import ai.openclaw.android.chat.ChatMessage -import ai.openclaw.android.chat.ChatPendingToolCall - -@Composable -fun ChatMessageListCard( - messages: List, - pendingRunCount: Int, - pendingToolCalls: List, - streamingAssistantText: String?, - modifier: Modifier = Modifier, -) { - val listState = rememberLazyListState() - - // With reverseLayout the newest item is at index 0 (bottom of screen). - LaunchedEffect(messages.size, pendingRunCount, pendingToolCalls.size, streamingAssistantText) { - listState.animateScrollToItem(index = 0) - } - - Card( - modifier = modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.large, - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), - ) { - Box(modifier = Modifier.fillMaxSize()) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - state = listState, - reverseLayout = true, - verticalArrangement = Arrangement.spacedBy(14.dp), - contentPadding = androidx.compose.foundation.layout.PaddingValues(top = 12.dp, bottom = 12.dp, start = 12.dp, end = 12.dp), - ) { - // With reverseLayout = true, index 0 renders at the BOTTOM. - // So we emit newest items first: streaming → tools → typing → messages (newest→oldest). - - val stream = streamingAssistantText?.trim() - if (!stream.isNullOrEmpty()) { - item(key = "stream") { - ChatStreamingAssistantBubble(text = stream) - } - } - - if (pendingToolCalls.isNotEmpty()) { - item(key = "tools") { - ChatPendingToolsBubble(toolCalls = pendingToolCalls) - } - } - - if (pendingRunCount > 0) { - item(key = "typing") { - ChatTypingIndicatorBubble() - } - } - - items(count = messages.size, key = { idx -> messages[messages.size - 1 - idx].id }) { idx -> - ChatMessageBubble(message = messages[messages.size - 1 - idx]) - } - } - - if (messages.isEmpty() && pendingRunCount == 0 && pendingToolCalls.isEmpty() && streamingAssistantText.isNullOrBlank()) { - EmptyChatHint(modifier = Modifier.align(Alignment.Center)) - } - } - } -} - -@Composable -private fun EmptyChatHint(modifier: Modifier = Modifier) { - Row( - modifier = modifier.alpha(0.7f), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon( - imageVector = Icons.Default.ArrowCircleDown, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = "Message OpenClaw…", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageViews.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageViews.kt deleted file mode 100644 index bf294327551..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageViews.kt +++ /dev/null @@ -1,263 +0,0 @@ -package ai.openclaw.android.ui.chat - -import android.graphics.BitmapFactory -import android.util.Base64 -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.unit.dp -import androidx.compose.foundation.Image -import ai.openclaw.android.chat.ChatMessage -import ai.openclaw.android.chat.ChatMessageContent -import ai.openclaw.android.chat.ChatPendingToolCall -import ai.openclaw.android.tools.ToolDisplayRegistry -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import androidx.compose.ui.platform.LocalContext - -@Composable -fun ChatMessageBubble(message: ChatMessage) { - val isUser = message.role.lowercase() == "user" - - // Filter to only displayable content parts (text with content, or base64 images) - val displayableContent = message.content.filter { part -> - when (part.type) { - "text" -> !part.text.isNullOrBlank() - else -> part.base64 != null - } - } - - // Skip rendering entirely if no displayable content - if (displayableContent.isEmpty()) return - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start, - ) { - Surface( - shape = RoundedCornerShape(16.dp), - tonalElevation = 0.dp, - shadowElevation = 0.dp, - color = Color.Transparent, - modifier = Modifier.fillMaxWidth(0.92f), - ) { - Box( - modifier = - Modifier - .background(bubbleBackground(isUser)) - .padding(horizontal = 12.dp, vertical = 10.dp), - ) { - val textColor = textColorOverBubble(isUser) - ChatMessageBody(content = displayableContent, textColor = textColor) - } - } - } -} - -@Composable -private fun ChatMessageBody(content: List, textColor: Color) { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - for (part in content) { - when (part.type) { - "text" -> { - val text = part.text ?: continue - ChatMarkdown(text = text, textColor = textColor) - } - else -> { - val b64 = part.base64 ?: continue - ChatBase64Image(base64 = b64, mimeType = part.mimeType) - } - } - } - } -} - -@Composable -fun ChatTypingIndicatorBubble() { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) { - Surface( - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surfaceContainer, - ) { - Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - DotPulse() - Text("Thinking…", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - } -} - -@Composable -fun ChatPendingToolsBubble(toolCalls: List) { - val context = LocalContext.current - val displays = - remember(toolCalls, context) { - toolCalls.map { ToolDisplayRegistry.resolve(context, it.name, it.args) } - } - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) { - Surface( - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surfaceContainer, - ) { - Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text("Running tools…", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface) - for (display in displays.take(6)) { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text( - "${display.emoji} ${display.label}", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontFamily = FontFamily.Monospace, - ) - display.detailLine?.let { detail -> - Text( - detail, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontFamily = FontFamily.Monospace, - ) - } - } - } - if (toolCalls.size > 6) { - Text( - "… +${toolCalls.size - 6} more", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } -} - -@Composable -fun ChatStreamingAssistantBubble(text: String) { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) { - Surface( - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surfaceContainer, - ) { - Box(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) { - ChatMarkdown(text = text, textColor = MaterialTheme.colorScheme.onSurface) - } - } - } -} - -@Composable -private fun bubbleBackground(isUser: Boolean): Brush { - return if (isUser) { - Brush.linearGradient( - colors = listOf(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.primary.copy(alpha = 0.78f)), - ) - } else { - Brush.linearGradient( - colors = listOf(MaterialTheme.colorScheme.surfaceContainer, MaterialTheme.colorScheme.surfaceContainerHigh), - ) - } -} - -@Composable -private fun textColorOverBubble(isUser: Boolean): Color { - return if (isUser) { - MaterialTheme.colorScheme.onPrimary - } else { - MaterialTheme.colorScheme.onSurface - } -} - -@Composable -private fun ChatBase64Image(base64: String, mimeType: String?) { - var image by remember(base64) { mutableStateOf(null) } - var failed by remember(base64) { mutableStateOf(false) } - - LaunchedEffect(base64) { - failed = false - image = - withContext(Dispatchers.Default) { - try { - val bytes = Base64.decode(base64, Base64.DEFAULT) - val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null - bitmap.asImageBitmap() - } catch (_: Throwable) { - null - } - } - if (image == null) failed = true - } - - if (image != null) { - Image( - bitmap = image!!, - contentDescription = mimeType ?: "attachment", - contentScale = ContentScale.Fit, - modifier = Modifier.fillMaxWidth(), - ) - } else if (failed) { - Text("Unsupported attachment", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } -} - -@Composable -private fun DotPulse() { - Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically) { - PulseDot(alpha = 0.38f) - PulseDot(alpha = 0.62f) - PulseDot(alpha = 0.90f) - } -} - -@Composable -private fun PulseDot(alpha: Float) { - Surface( - modifier = Modifier.size(6.dp).alpha(alpha), - shape = CircleShape, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) {} -} - -@Composable -fun ChatCodeBlock(code: String, language: String?) { - Surface( - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceContainerLowest, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = code.trimEnd(), - modifier = Modifier.padding(10.dp), - fontFamily = FontFamily.Monospace, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - ) - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatSessionsDialog.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatSessionsDialog.kt deleted file mode 100644 index 56b5cfb1faf..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatSessionsDialog.kt +++ /dev/null @@ -1,92 +0,0 @@ -package ai.openclaw.android.ui.chat - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.FilledTonalIconButton -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import ai.openclaw.android.chat.ChatSessionEntry - -@Composable -fun ChatSessionsDialog( - currentSessionKey: String, - sessions: List, - onDismiss: () -> Unit, - onRefresh: () -> Unit, - onSelect: (sessionKey: String) -> Unit, -) { - AlertDialog( - onDismissRequest = onDismiss, - confirmButton = {}, - title = { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - Text("Sessions", style = MaterialTheme.typography.titleMedium) - Spacer(modifier = Modifier.weight(1f)) - FilledTonalIconButton(onClick = onRefresh) { - Icon(Icons.Default.Refresh, contentDescription = "Refresh") - } - } - }, - text = { - if (sessions.isEmpty()) { - Text("No sessions", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - } else { - LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { - items(sessions, key = { it.key }) { entry -> - SessionRow( - entry = entry, - isCurrent = entry.key == currentSessionKey, - onClick = { onSelect(entry.key) }, - ) - } - } - } - }, - ) -} - -@Composable -private fun SessionRow( - entry: ChatSessionEntry, - isCurrent: Boolean, - onClick: () -> Unit, -) { - Surface( - onClick = onClick, - shape = MaterialTheme.shapes.medium, - color = - if (isCurrent) { - MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) - } else { - MaterialTheme.colorScheme.surfaceContainer - }, - modifier = Modifier.fillMaxWidth(), - ) { - Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Text(entry.displayName ?: entry.key, style = MaterialTheme.typography.bodyMedium) - Spacer(modifier = Modifier.weight(1f)) - if (isCurrent) { - Text("Current", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatSheetContent.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatSheetContent.kt deleted file mode 100644 index effee6708e0..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatSheetContent.kt +++ /dev/null @@ -1,147 +0,0 @@ -package ai.openclaw.android.ui.chat - -import android.content.ContentResolver -import android.net.Uri -import android.util.Base64 -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import ai.openclaw.android.MainViewModel -import ai.openclaw.android.chat.OutgoingAttachment -import java.io.ByteArrayOutputStream -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -@Composable -fun ChatSheetContent(viewModel: MainViewModel) { - val messages by viewModel.chatMessages.collectAsState() - val errorText by viewModel.chatError.collectAsState() - val pendingRunCount by viewModel.pendingRunCount.collectAsState() - val healthOk by viewModel.chatHealthOk.collectAsState() - val sessionKey by viewModel.chatSessionKey.collectAsState() - val mainSessionKey by viewModel.mainSessionKey.collectAsState() - val thinkingLevel by viewModel.chatThinkingLevel.collectAsState() - val streamingAssistantText by viewModel.chatStreamingAssistantText.collectAsState() - val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState() - val sessions by viewModel.chatSessions.collectAsState() - - LaunchedEffect(mainSessionKey) { - viewModel.loadChat(mainSessionKey) - viewModel.refreshChatSessions(limit = 200) - } - - val context = LocalContext.current - val resolver = context.contentResolver - val scope = rememberCoroutineScope() - - val attachments = remember { mutableStateListOf() } - - val pickImages = - rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris -> - if (uris.isNullOrEmpty()) return@rememberLauncherForActivityResult - scope.launch(Dispatchers.IO) { - val next = - uris.take(8).mapNotNull { uri -> - try { - loadImageAttachment(resolver, uri) - } catch (_: Throwable) { - null - } - } - withContext(Dispatchers.Main) { - attachments.addAll(next) - } - } - } - - Column( - modifier = - Modifier - .fillMaxSize() - .padding(horizontal = 12.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - ChatMessageListCard( - messages = messages, - pendingRunCount = pendingRunCount, - pendingToolCalls = pendingToolCalls, - streamingAssistantText = streamingAssistantText, - modifier = Modifier.weight(1f, fill = true), - ) - - ChatComposer( - sessionKey = sessionKey, - sessions = sessions, - mainSessionKey = mainSessionKey, - healthOk = healthOk, - thinkingLevel = thinkingLevel, - pendingRunCount = pendingRunCount, - errorText = errorText, - attachments = attachments, - onPickImages = { pickImages.launch("image/*") }, - onRemoveAttachment = { id -> attachments.removeAll { it.id == id } }, - onSetThinkingLevel = { level -> viewModel.setChatThinkingLevel(level) }, - onSelectSession = { key -> viewModel.switchChatSession(key) }, - onRefresh = { - viewModel.refreshChat() - viewModel.refreshChatSessions(limit = 200) - }, - onAbort = { viewModel.abortChat() }, - onSend = { text -> - val outgoing = - attachments.map { att -> - OutgoingAttachment( - type = "image", - mimeType = att.mimeType, - fileName = att.fileName, - base64 = att.base64, - ) - } - viewModel.sendChat(message = text, thinking = thinkingLevel, attachments = outgoing) - attachments.clear() - }, - ) - } -} - -data class PendingImageAttachment( - val id: String, - val fileName: String, - val mimeType: String, - val base64: String, -) - -private suspend fun loadImageAttachment(resolver: ContentResolver, uri: Uri): PendingImageAttachment { - val mimeType = resolver.getType(uri) ?: "image/*" - val fileName = (uri.lastPathSegment ?: "image").substringAfterLast('/') - val bytes = - withContext(Dispatchers.IO) { - resolver.openInputStream(uri)?.use { input -> - val out = ByteArrayOutputStream() - input.copyTo(out) - out.toByteArray() - } ?: ByteArray(0) - } - if (bytes.isEmpty()) throw IllegalStateException("empty attachment") - val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP) - return PendingImageAttachment( - id = uri.toString() + "#" + System.currentTimeMillis().toString(), - fileName = fileName, - mimeType = mimeType, - base64 = base64, - ) -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/SessionFilters.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/SessionFilters.kt deleted file mode 100644 index 68f3f409960..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/SessionFilters.kt +++ /dev/null @@ -1,73 +0,0 @@ -package ai.openclaw.android.ui.chat - -import ai.openclaw.android.chat.ChatSessionEntry - -private const val RECENT_WINDOW_MS = 24 * 60 * 60 * 1000L - -/** - * Derive a human-friendly label from a raw session key. - * Examples: - * "telegram:g-agent-main-main" -> "Main" - * "agent:main:main" -> "Main" - * "discord:g-server-channel" -> "Server Channel" - * "my-custom-session" -> "My Custom Session" - */ -fun friendlySessionName(key: String): String { - // Strip common prefixes like "telegram:", "agent:", "discord:" etc. - val stripped = key.substringAfterLast(":") - - // Remove leading "g-" prefix (gateway artifact) - val cleaned = if (stripped.startsWith("g-")) stripped.removePrefix("g-") else stripped - - // Split on hyphens/underscores, title-case each word, collapse "main main" -> "Main" - val words = cleaned.split('-', '_').filter { it.isNotBlank() }.map { word -> - word.replaceFirstChar { it.uppercaseChar() } - }.distinct() - - val result = words.joinToString(" ") - return result.ifBlank { key } -} - -fun resolveSessionChoices( - currentSessionKey: String, - sessions: List, - mainSessionKey: String, - nowMs: Long = System.currentTimeMillis(), -): List { - val mainKey = mainSessionKey.trim().ifEmpty { "main" } - val current = currentSessionKey.trim().let { if (it == "main" && mainKey != "main") mainKey else it } - val aliasKey = if (mainKey == "main") null else "main" - val cutoff = nowMs - RECENT_WINDOW_MS - val sorted = sessions.sortedByDescending { it.updatedAtMs ?: 0L } - val recent = mutableListOf() - val seen = mutableSetOf() - for (entry in sorted) { - if (aliasKey != null && entry.key == aliasKey) continue - if (!seen.add(entry.key)) continue - if ((entry.updatedAtMs ?: 0L) < cutoff) continue - recent.add(entry) - } - - val result = mutableListOf() - val included = mutableSetOf() - val mainEntry = sorted.firstOrNull { it.key == mainKey } - if (mainEntry != null) { - result.add(mainEntry) - included.add(mainKey) - } else if (current == mainKey) { - result.add(ChatSessionEntry(key = mainKey, updatedAtMs = null)) - included.add(mainKey) - } - - for (entry in recent) { - if (included.add(entry.key)) { - result.add(entry) - } - } - - if (current.isNotEmpty() && !included.contains(current)) { - result.add(ChatSessionEntry(key = current, updatedAtMs = null)) - } - - return result -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/voice/StreamingMediaDataSource.kt b/apps/android/app/src/main/java/ai/openclaw/android/voice/StreamingMediaDataSource.kt deleted file mode 100644 index 329707ad56a..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/voice/StreamingMediaDataSource.kt +++ /dev/null @@ -1,98 +0,0 @@ -package ai.openclaw.android.voice - -import android.media.MediaDataSource -import kotlin.math.min - -internal class StreamingMediaDataSource : MediaDataSource() { - private data class Chunk(val start: Long, val data: ByteArray) - - private val lock = Object() - private val chunks = ArrayList() - private var totalSize: Long = 0 - private var closed = false - private var finished = false - private var lastReadIndex = 0 - - fun append(data: ByteArray) { - if (data.isEmpty()) return - synchronized(lock) { - if (closed || finished) return - val chunk = Chunk(totalSize, data) - chunks.add(chunk) - totalSize += data.size.toLong() - lock.notifyAll() - } - } - - fun finish() { - synchronized(lock) { - if (closed) return - finished = true - lock.notifyAll() - } - } - - fun fail() { - synchronized(lock) { - closed = true - lock.notifyAll() - } - } - - override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int { - if (position < 0) return -1 - synchronized(lock) { - while (!closed && !finished && position >= totalSize) { - lock.wait() - } - if (closed) return -1 - if (position >= totalSize && finished) return -1 - - val available = (totalSize - position).toInt() - val toRead = min(size, available) - var remaining = toRead - var destOffset = offset - var pos = position - - var index = findChunkIndex(pos) - while (remaining > 0 && index < chunks.size) { - val chunk = chunks[index] - val inChunkOffset = (pos - chunk.start).toInt() - if (inChunkOffset >= chunk.data.size) { - index++ - continue - } - val copyLen = min(remaining, chunk.data.size - inChunkOffset) - System.arraycopy(chunk.data, inChunkOffset, buffer, destOffset, copyLen) - remaining -= copyLen - destOffset += copyLen - pos += copyLen - if (inChunkOffset + copyLen >= chunk.data.size) { - index++ - } - } - - return toRead - remaining - } - } - - override fun getSize(): Long = -1 - - override fun close() { - synchronized(lock) { - closed = true - lock.notifyAll() - } - } - - private fun findChunkIndex(position: Long): Int { - var index = lastReadIndex - while (index < chunks.size) { - val chunk = chunks[index] - if (position < chunk.start + chunk.data.size) break - index++ - } - lastReadIndex = index - return index - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/voice/TalkDirectiveParser.kt b/apps/android/app/src/main/java/ai/openclaw/android/voice/TalkDirectiveParser.kt deleted file mode 100644 index 5c80cc1f4f1..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/voice/TalkDirectiveParser.kt +++ /dev/null @@ -1,191 +0,0 @@ -package ai.openclaw.android.voice - -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive - -private val directiveJson = Json { ignoreUnknownKeys = true } - -data class TalkDirective( - val voiceId: String? = null, - val modelId: String? = null, - val speed: Double? = null, - val rateWpm: Int? = null, - val stability: Double? = null, - val similarity: Double? = null, - val style: Double? = null, - val speakerBoost: Boolean? = null, - val seed: Long? = null, - val normalize: String? = null, - val language: String? = null, - val outputFormat: String? = null, - val latencyTier: Int? = null, - val once: Boolean? = null, -) - -data class TalkDirectiveParseResult( - val directive: TalkDirective?, - val stripped: String, - val unknownKeys: List, -) - -object TalkDirectiveParser { - fun parse(text: String): TalkDirectiveParseResult { - val normalized = text.replace("\r\n", "\n") - val lines = normalized.split("\n").toMutableList() - if (lines.isEmpty()) return TalkDirectiveParseResult(null, text, emptyList()) - - val firstNonEmpty = lines.indexOfFirst { it.trim().isNotEmpty() } - if (firstNonEmpty == -1) return TalkDirectiveParseResult(null, text, emptyList()) - - val head = lines[firstNonEmpty].trim() - if (!head.startsWith("{") || !head.endsWith("}")) { - return TalkDirectiveParseResult(null, text, emptyList()) - } - - val obj = parseJsonObject(head) ?: return TalkDirectiveParseResult(null, text, emptyList()) - - val speakerBoost = - boolValue(obj, listOf("speaker_boost", "speakerBoost")) - ?: boolValue(obj, listOf("no_speaker_boost", "noSpeakerBoost"))?.not() - - val directive = TalkDirective( - voiceId = stringValue(obj, listOf("voice", "voice_id", "voiceId")), - modelId = stringValue(obj, listOf("model", "model_id", "modelId")), - speed = doubleValue(obj, listOf("speed")), - rateWpm = intValue(obj, listOf("rate", "wpm")), - stability = doubleValue(obj, listOf("stability")), - similarity = doubleValue(obj, listOf("similarity", "similarity_boost", "similarityBoost")), - style = doubleValue(obj, listOf("style")), - speakerBoost = speakerBoost, - seed = longValue(obj, listOf("seed")), - normalize = stringValue(obj, listOf("normalize", "apply_text_normalization")), - language = stringValue(obj, listOf("lang", "language_code", "language")), - outputFormat = stringValue(obj, listOf("output_format", "format")), - latencyTier = intValue(obj, listOf("latency", "latency_tier", "latencyTier")), - once = boolValue(obj, listOf("once")), - ) - - val hasDirective = listOf( - directive.voiceId, - directive.modelId, - directive.speed, - directive.rateWpm, - directive.stability, - directive.similarity, - directive.style, - directive.speakerBoost, - directive.seed, - directive.normalize, - directive.language, - directive.outputFormat, - directive.latencyTier, - directive.once, - ).any { it != null } - - if (!hasDirective) return TalkDirectiveParseResult(null, text, emptyList()) - - val knownKeys = setOf( - "voice", "voice_id", "voiceid", - "model", "model_id", "modelid", - "speed", "rate", "wpm", - "stability", "similarity", "similarity_boost", "similarityboost", - "style", - "speaker_boost", "speakerboost", - "no_speaker_boost", "nospeakerboost", - "seed", - "normalize", "apply_text_normalization", - "lang", "language_code", "language", - "output_format", "format", - "latency", "latency_tier", "latencytier", - "once", - ) - val unknownKeys = obj.keys.filter { !knownKeys.contains(it.lowercase()) }.sorted() - - lines.removeAt(firstNonEmpty) - if (firstNonEmpty < lines.size) { - if (lines[firstNonEmpty].trim().isEmpty()) { - lines.removeAt(firstNonEmpty) - } - } - - return TalkDirectiveParseResult(directive, lines.joinToString("\n"), unknownKeys) - } - - private fun parseJsonObject(line: String): JsonObject? { - return try { - directiveJson.parseToJsonElement(line) as? JsonObject - } catch (_: Throwable) { - null - } - } - - private fun stringValue(obj: JsonObject, keys: List): String? { - for (key in keys) { - val value = obj[key].asStringOrNull()?.trim() - if (!value.isNullOrEmpty()) return value - } - return null - } - - private fun doubleValue(obj: JsonObject, keys: List): Double? { - for (key in keys) { - val value = obj[key].asDoubleOrNull() - if (value != null) return value - } - return null - } - - private fun intValue(obj: JsonObject, keys: List): Int? { - for (key in keys) { - val value = obj[key].asIntOrNull() - if (value != null) return value - } - return null - } - - private fun longValue(obj: JsonObject, keys: List): Long? { - for (key in keys) { - val value = obj[key].asLongOrNull() - if (value != null) return value - } - return null - } - - private fun boolValue(obj: JsonObject, keys: List): Boolean? { - for (key in keys) { - val value = obj[key].asBooleanOrNull() - if (value != null) return value - } - return null - } -} - -private fun JsonElement?.asStringOrNull(): String? = - (this as? JsonPrimitive)?.takeIf { it.isString }?.content - -private fun JsonElement?.asDoubleOrNull(): Double? { - val primitive = this as? JsonPrimitive ?: return null - return primitive.content.toDoubleOrNull() -} - -private fun JsonElement?.asIntOrNull(): Int? { - val primitive = this as? JsonPrimitive ?: return null - return primitive.content.toIntOrNull() -} - -private fun JsonElement?.asLongOrNull(): Long? { - val primitive = this as? JsonPrimitive ?: return null - return primitive.content.toLongOrNull() -} - -private fun JsonElement?.asBooleanOrNull(): Boolean? { - val primitive = this as? JsonPrimitive ?: return null - val content = primitive.content.trim().lowercase() - return when (content) { - "true", "yes", "1" -> true - "false", "no", "0" -> false - else -> null - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/voice/TalkModeManager.kt b/apps/android/app/src/main/java/ai/openclaw/android/voice/TalkModeManager.kt deleted file mode 100644 index 04d18b62260..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/voice/TalkModeManager.kt +++ /dev/null @@ -1,1257 +0,0 @@ -package ai.openclaw.android.voice - -import android.Manifest -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.media.AudioAttributes -import android.media.AudioFormat -import android.media.AudioManager -import android.media.AudioTrack -import android.media.MediaPlayer -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.os.SystemClock -import android.speech.RecognitionListener -import android.speech.RecognizerIntent -import android.speech.SpeechRecognizer -import android.speech.tts.TextToSpeech -import android.speech.tts.UtteranceProgressListener -import android.util.Log -import androidx.core.content.ContextCompat -import ai.openclaw.android.gateway.GatewaySession -import ai.openclaw.android.isCanonicalMainSessionKey -import ai.openclaw.android.normalizeMainKey -import java.net.HttpURLConnection -import java.net.URL -import java.util.UUID -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.buildJsonObject -import kotlin.math.max - -class TalkModeManager( - private val context: Context, - private val scope: CoroutineScope, - private val session: GatewaySession, - private val supportsChatSubscribe: Boolean, - private val isConnected: () -> Boolean, -) { - companion object { - private const val tag = "TalkMode" - private const val defaultModelIdFallback = "eleven_v3" - private const val defaultOutputFormatFallback = "pcm_24000" - } - - private val mainHandler = Handler(Looper.getMainLooper()) - private val json = Json { ignoreUnknownKeys = true } - - private val _isEnabled = MutableStateFlow(false) - val isEnabled: StateFlow = _isEnabled - - private val _isListening = MutableStateFlow(false) - val isListening: StateFlow = _isListening - - private val _isSpeaking = MutableStateFlow(false) - val isSpeaking: StateFlow = _isSpeaking - - private val _statusText = MutableStateFlow("Off") - val statusText: StateFlow = _statusText - - private val _lastAssistantText = MutableStateFlow(null) - val lastAssistantText: StateFlow = _lastAssistantText - - private val _usingFallbackTts = MutableStateFlow(false) - val usingFallbackTts: StateFlow = _usingFallbackTts - - private var recognizer: SpeechRecognizer? = null - private var restartJob: Job? = null - private var stopRequested = false - private var listeningMode = false - - private var silenceJob: Job? = null - private val silenceWindowMs = 700L - private var lastTranscript: String = "" - private var lastHeardAtMs: Long? = null - private var lastSpokenText: String? = null - private var lastInterruptedAtSeconds: Double? = null - - private var defaultVoiceId: String? = null - private var currentVoiceId: String? = null - private var fallbackVoiceId: String? = null - private var defaultModelId: String? = null - private var currentModelId: String? = null - private var defaultOutputFormat: String? = null - private var apiKey: String? = null - private var voiceAliases: Map = emptyMap() - private var interruptOnSpeech: Boolean = true - private var voiceOverrideActive = false - private var modelOverrideActive = false - private var mainSessionKey: String = "main" - - private var pendingRunId: String? = null - private var pendingFinal: CompletableDeferred? = null - private var chatSubscribedSessionKey: String? = null - - private var player: MediaPlayer? = null - private var streamingSource: StreamingMediaDataSource? = null - private var pcmTrack: AudioTrack? = null - @Volatile private var pcmStopRequested = false - private var systemTts: TextToSpeech? = null - private var systemTtsPending: CompletableDeferred? = null - private var systemTtsPendingId: String? = null - - fun setMainSessionKey(sessionKey: String?) { - val trimmed = sessionKey?.trim().orEmpty() - if (trimmed.isEmpty()) return - if (isCanonicalMainSessionKey(mainSessionKey)) return - mainSessionKey = trimmed - } - - fun setEnabled(enabled: Boolean) { - if (_isEnabled.value == enabled) return - _isEnabled.value = enabled - if (enabled) { - Log.d(tag, "enabled") - start() - } else { - Log.d(tag, "disabled") - stop() - } - } - - fun handleGatewayEvent(event: String, payloadJson: String?) { - if (event != "chat") return - if (payloadJson.isNullOrBlank()) return - val pending = pendingRunId ?: return - val obj = - try { - json.parseToJsonElement(payloadJson).asObjectOrNull() - } catch (_: Throwable) { - null - } ?: return - val runId = obj["runId"].asStringOrNull() ?: return - if (runId != pending) return - val state = obj["state"].asStringOrNull() ?: return - if (state == "final") { - pendingFinal?.complete(true) - pendingFinal = null - pendingRunId = null - } - } - - private fun start() { - mainHandler.post { - if (_isListening.value) return@post - stopRequested = false - listeningMode = true - Log.d(tag, "start") - - if (!SpeechRecognizer.isRecognitionAvailable(context)) { - _statusText.value = "Speech recognizer unavailable" - Log.w(tag, "speech recognizer unavailable") - return@post - } - - val micOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == - PackageManager.PERMISSION_GRANTED - if (!micOk) { - _statusText.value = "Microphone permission required" - Log.w(tag, "microphone permission required") - return@post - } - - try { - recognizer?.destroy() - recognizer = SpeechRecognizer.createSpeechRecognizer(context).also { it.setRecognitionListener(listener) } - startListeningInternal(markListening = true) - startSilenceMonitor() - Log.d(tag, "listening") - } catch (err: Throwable) { - _statusText.value = "Start failed: ${err.message ?: err::class.simpleName}" - Log.w(tag, "start failed: ${err.message ?: err::class.simpleName}") - } - } - } - - private fun stop() { - stopRequested = true - listeningMode = false - restartJob?.cancel() - restartJob = null - silenceJob?.cancel() - silenceJob = null - lastTranscript = "" - lastHeardAtMs = null - _isListening.value = false - _statusText.value = "Off" - stopSpeaking() - _usingFallbackTts.value = false - chatSubscribedSessionKey = null - - mainHandler.post { - recognizer?.cancel() - recognizer?.destroy() - recognizer = null - } - systemTts?.stop() - systemTtsPending?.cancel() - systemTtsPending = null - systemTtsPendingId = null - } - - private fun startListeningInternal(markListening: Boolean) { - val r = recognizer ?: return - val intent = - Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { - putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) - putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true) - putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 3) - putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, context.packageName) - } - - if (markListening) { - _statusText.value = "Listening" - _isListening.value = true - } - r.startListening(intent) - } - - private fun scheduleRestart(delayMs: Long = 350) { - if (stopRequested) return - restartJob?.cancel() - restartJob = - scope.launch { - delay(delayMs) - mainHandler.post { - if (stopRequested) return@post - try { - recognizer?.cancel() - val shouldListen = listeningMode - val shouldInterrupt = _isSpeaking.value && interruptOnSpeech - if (!shouldListen && !shouldInterrupt) return@post - startListeningInternal(markListening = shouldListen) - } catch (_: Throwable) { - // handled by onError - } - } - } - } - - private fun handleTranscript(text: String, isFinal: Boolean) { - val trimmed = text.trim() - if (_isSpeaking.value && interruptOnSpeech) { - if (shouldInterrupt(trimmed)) { - stopSpeaking() - } - return - } - - if (!_isListening.value) return - - if (trimmed.isNotEmpty()) { - lastTranscript = trimmed - lastHeardAtMs = SystemClock.elapsedRealtime() - } - - if (isFinal) { - lastTranscript = trimmed - } - } - - private fun startSilenceMonitor() { - silenceJob?.cancel() - silenceJob = - scope.launch { - while (_isEnabled.value) { - delay(200) - checkSilence() - } - } - } - - private fun checkSilence() { - if (!_isListening.value) return - val transcript = lastTranscript.trim() - if (transcript.isEmpty()) return - val lastHeard = lastHeardAtMs ?: return - val elapsed = SystemClock.elapsedRealtime() - lastHeard - if (elapsed < silenceWindowMs) return - scope.launch { finalizeTranscript(transcript) } - } - - private suspend fun finalizeTranscript(transcript: String) { - listeningMode = false - _isListening.value = false - _statusText.value = "Thinking…" - lastTranscript = "" - lastHeardAtMs = null - - reloadConfig() - val prompt = buildPrompt(transcript) - if (!isConnected()) { - _statusText.value = "Gateway not connected" - Log.w(tag, "finalize: gateway not connected") - start() - return - } - - try { - val startedAt = System.currentTimeMillis().toDouble() / 1000.0 - subscribeChatIfNeeded(session = session, sessionKey = mainSessionKey) - Log.d(tag, "chat.send start sessionKey=${mainSessionKey.ifBlank { "main" }} chars=${prompt.length}") - val runId = sendChat(prompt, session) - Log.d(tag, "chat.send ok runId=$runId") - val ok = waitForChatFinal(runId) - if (!ok) { - Log.w(tag, "chat final timeout runId=$runId; attempting history fallback") - } - val assistant = waitForAssistantText(session, startedAt, if (ok) 12_000 else 25_000) - if (assistant.isNullOrBlank()) { - _statusText.value = "No reply" - Log.w(tag, "assistant text timeout runId=$runId") - start() - return - } - Log.d(tag, "assistant text ok chars=${assistant.length}") - playAssistant(assistant) - } catch (err: Throwable) { - _statusText.value = "Talk failed: ${err.message ?: err::class.simpleName}" - Log.w(tag, "finalize failed: ${err.message ?: err::class.simpleName}") - } - - if (_isEnabled.value) { - start() - } - } - - private suspend fun subscribeChatIfNeeded(session: GatewaySession, sessionKey: String) { - if (!supportsChatSubscribe) return - val key = sessionKey.trim() - if (key.isEmpty()) return - if (chatSubscribedSessionKey == key) return - try { - session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""") - chatSubscribedSessionKey = key - Log.d(tag, "chat.subscribe ok sessionKey=$key") - } catch (err: Throwable) { - Log.w(tag, "chat.subscribe failed sessionKey=$key err=${err.message ?: err::class.java.simpleName}") - } - } - - private fun buildPrompt(transcript: String): String { - val lines = mutableListOf( - "Talk Mode active. Reply in a concise, spoken tone.", - "You may optionally prefix the response with JSON (first line) to set ElevenLabs voice (id or alias), e.g. {\"voice\":\"\",\"once\":true}.", - ) - lastInterruptedAtSeconds?.let { - lines.add("Assistant speech interrupted at ${"%.1f".format(it)}s.") - lastInterruptedAtSeconds = null - } - lines.add("") - lines.add(transcript) - return lines.joinToString("\n") - } - - private suspend fun sendChat(message: String, session: GatewaySession): String { - val runId = UUID.randomUUID().toString() - val params = - buildJsonObject { - put("sessionKey", JsonPrimitive(mainSessionKey.ifBlank { "main" })) - put("message", JsonPrimitive(message)) - put("thinking", JsonPrimitive("low")) - put("timeoutMs", JsonPrimitive(30_000)) - put("idempotencyKey", JsonPrimitive(runId)) - } - val res = session.request("chat.send", params.toString()) - val parsed = parseRunId(res) ?: runId - if (parsed != runId) { - pendingRunId = parsed - } - return parsed - } - - private suspend fun waitForChatFinal(runId: String): Boolean { - pendingFinal?.cancel() - val deferred = CompletableDeferred() - pendingRunId = runId - pendingFinal = deferred - - val result = - withContext(Dispatchers.IO) { - try { - kotlinx.coroutines.withTimeout(120_000) { deferred.await() } - } catch (_: Throwable) { - false - } - } - - if (!result) { - pendingFinal = null - pendingRunId = null - } - return result - } - - private suspend fun waitForAssistantText( - session: GatewaySession, - sinceSeconds: Double, - timeoutMs: Long, - ): String? { - val deadline = SystemClock.elapsedRealtime() + timeoutMs - while (SystemClock.elapsedRealtime() < deadline) { - val text = fetchLatestAssistantText(session, sinceSeconds) - if (!text.isNullOrBlank()) return text - delay(300) - } - return null - } - - private suspend fun fetchLatestAssistantText( - session: GatewaySession, - sinceSeconds: Double? = null, - ): String? { - val key = mainSessionKey.ifBlank { "main" } - val res = session.request("chat.history", "{\"sessionKey\":\"$key\"}") - val root = json.parseToJsonElement(res).asObjectOrNull() ?: return null - val messages = root["messages"] as? JsonArray ?: return null - for (item in messages.reversed()) { - val obj = item.asObjectOrNull() ?: continue - if (obj["role"].asStringOrNull() != "assistant") continue - if (sinceSeconds != null) { - val timestamp = obj["timestamp"].asDoubleOrNull() - if (timestamp != null && !TalkModeRuntime.isMessageTimestampAfter(timestamp, sinceSeconds)) continue - } - val content = obj["content"] as? JsonArray ?: continue - val text = - content.mapNotNull { entry -> - entry.asObjectOrNull()?.get("text")?.asStringOrNull()?.trim() - }.filter { it.isNotEmpty() } - if (text.isNotEmpty()) return text.joinToString("\n") - } - return null - } - - private suspend fun playAssistant(text: String) { - val parsed = TalkDirectiveParser.parse(text) - if (parsed.unknownKeys.isNotEmpty()) { - Log.w(tag, "Unknown talk directive keys: ${parsed.unknownKeys}") - } - val directive = parsed.directive - val cleaned = parsed.stripped.trim() - if (cleaned.isEmpty()) return - _lastAssistantText.value = cleaned - - val requestedVoice = directive?.voiceId?.trim()?.takeIf { it.isNotEmpty() } - val resolvedVoice = resolveVoiceAlias(requestedVoice) - if (requestedVoice != null && resolvedVoice == null) { - Log.w(tag, "unknown voice alias: $requestedVoice") - } - - if (directive?.voiceId != null) { - if (directive.once != true) { - currentVoiceId = resolvedVoice - voiceOverrideActive = true - } - } - if (directive?.modelId != null) { - if (directive.once != true) { - currentModelId = directive.modelId - modelOverrideActive = true - } - } - - val apiKey = - apiKey?.trim()?.takeIf { it.isNotEmpty() } - ?: System.getenv("ELEVENLABS_API_KEY")?.trim() - val preferredVoice = resolvedVoice ?: currentVoiceId ?: defaultVoiceId - val voiceId = - if (!apiKey.isNullOrEmpty()) { - resolveVoiceId(preferredVoice, apiKey) - } else { - null - } - - _statusText.value = "Speaking…" - _isSpeaking.value = true - lastSpokenText = cleaned - ensureInterruptListener() - - try { - val canUseElevenLabs = !voiceId.isNullOrBlank() && !apiKey.isNullOrEmpty() - if (!canUseElevenLabs) { - if (voiceId.isNullOrBlank()) { - Log.w(tag, "missing voiceId; falling back to system voice") - } - if (apiKey.isNullOrEmpty()) { - Log.w(tag, "missing ELEVENLABS_API_KEY; falling back to system voice") - } - _usingFallbackTts.value = true - _statusText.value = "Speaking (System)…" - speakWithSystemTts(cleaned) - } else { - _usingFallbackTts.value = false - val ttsStarted = SystemClock.elapsedRealtime() - val modelId = directive?.modelId ?: currentModelId ?: defaultModelId - val request = - ElevenLabsRequest( - text = cleaned, - modelId = modelId, - outputFormat = - TalkModeRuntime.validatedOutputFormat(directive?.outputFormat ?: defaultOutputFormat), - speed = TalkModeRuntime.resolveSpeed(directive?.speed, directive?.rateWpm), - stability = TalkModeRuntime.validatedStability(directive?.stability, modelId), - similarity = TalkModeRuntime.validatedUnit(directive?.similarity), - style = TalkModeRuntime.validatedUnit(directive?.style), - speakerBoost = directive?.speakerBoost, - seed = TalkModeRuntime.validatedSeed(directive?.seed), - normalize = TalkModeRuntime.validatedNormalize(directive?.normalize), - language = TalkModeRuntime.validatedLanguage(directive?.language), - latencyTier = TalkModeRuntime.validatedLatencyTier(directive?.latencyTier), - ) - streamAndPlay(voiceId = voiceId!!, apiKey = apiKey!!, request = request) - Log.d(tag, "elevenlabs stream ok durMs=${SystemClock.elapsedRealtime() - ttsStarted}") - } - } catch (err: Throwable) { - Log.w(tag, "speak failed: ${err.message ?: err::class.simpleName}; falling back to system voice") - try { - _usingFallbackTts.value = true - _statusText.value = "Speaking (System)…" - speakWithSystemTts(cleaned) - } catch (fallbackErr: Throwable) { - _statusText.value = "Speak failed: ${fallbackErr.message ?: fallbackErr::class.simpleName}" - Log.w(tag, "system voice failed: ${fallbackErr.message ?: fallbackErr::class.simpleName}") - } - } - - _isSpeaking.value = false - } - - private suspend fun streamAndPlay(voiceId: String, apiKey: String, request: ElevenLabsRequest) { - stopSpeaking(resetInterrupt = false) - - pcmStopRequested = false - val pcmSampleRate = TalkModeRuntime.parsePcmSampleRate(request.outputFormat) - if (pcmSampleRate != null) { - try { - streamAndPlayPcm(voiceId = voiceId, apiKey = apiKey, request = request, sampleRate = pcmSampleRate) - return - } catch (err: Throwable) { - if (pcmStopRequested) return - Log.w(tag, "pcm playback failed; falling back to mp3: ${err.message ?: err::class.simpleName}") - } - } - - streamAndPlayMp3(voiceId = voiceId, apiKey = apiKey, request = request) - } - - private suspend fun streamAndPlayMp3(voiceId: String, apiKey: String, request: ElevenLabsRequest) { - val dataSource = StreamingMediaDataSource() - streamingSource = dataSource - - val player = MediaPlayer() - this.player = player - - val prepared = CompletableDeferred() - val finished = CompletableDeferred() - - player.setAudioAttributes( - AudioAttributes.Builder() - .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) - .setUsage(AudioAttributes.USAGE_ASSISTANT) - .build(), - ) - player.setOnPreparedListener { - it.start() - prepared.complete(Unit) - } - player.setOnCompletionListener { - finished.complete(Unit) - } - player.setOnErrorListener { _, _, _ -> - finished.completeExceptionally(IllegalStateException("MediaPlayer error")) - true - } - - player.setDataSource(dataSource) - withContext(Dispatchers.Main) { - player.prepareAsync() - } - - val fetchError = CompletableDeferred() - val fetchJob = - scope.launch(Dispatchers.IO) { - try { - streamTts(voiceId = voiceId, apiKey = apiKey, request = request, sink = dataSource) - fetchError.complete(null) - } catch (err: Throwable) { - dataSource.fail() - fetchError.complete(err) - } - } - - Log.d(tag, "play start") - try { - prepared.await() - finished.await() - fetchError.await()?.let { throw it } - } finally { - fetchJob.cancel() - cleanupPlayer() - } - Log.d(tag, "play done") - } - - private suspend fun streamAndPlayPcm( - voiceId: String, - apiKey: String, - request: ElevenLabsRequest, - sampleRate: Int, - ) { - val minBuffer = - AudioTrack.getMinBufferSize( - sampleRate, - AudioFormat.CHANNEL_OUT_MONO, - AudioFormat.ENCODING_PCM_16BIT, - ) - if (minBuffer <= 0) { - throw IllegalStateException("AudioTrack buffer size invalid: $minBuffer") - } - - val bufferSize = max(minBuffer * 2, 8 * 1024) - val track = - AudioTrack( - AudioAttributes.Builder() - .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) - .setUsage(AudioAttributes.USAGE_ASSISTANT) - .build(), - AudioFormat.Builder() - .setSampleRate(sampleRate) - .setChannelMask(AudioFormat.CHANNEL_OUT_MONO) - .setEncoding(AudioFormat.ENCODING_PCM_16BIT) - .build(), - bufferSize, - AudioTrack.MODE_STREAM, - AudioManager.AUDIO_SESSION_ID_GENERATE, - ) - if (track.state != AudioTrack.STATE_INITIALIZED) { - track.release() - throw IllegalStateException("AudioTrack init failed") - } - pcmTrack = track - track.play() - - Log.d(tag, "pcm play start sampleRate=$sampleRate bufferSize=$bufferSize") - try { - streamPcm(voiceId = voiceId, apiKey = apiKey, request = request, track = track) - } finally { - cleanupPcmTrack() - } - Log.d(tag, "pcm play done") - } - - private suspend fun speakWithSystemTts(text: String) { - val trimmed = text.trim() - if (trimmed.isEmpty()) return - val ok = ensureSystemTts() - if (!ok) { - throw IllegalStateException("system TTS unavailable") - } - - val tts = systemTts ?: throw IllegalStateException("system TTS unavailable") - val utteranceId = "talk-${UUID.randomUUID()}" - val deferred = CompletableDeferred() - systemTtsPending?.cancel() - systemTtsPending = deferred - systemTtsPendingId = utteranceId - - withContext(Dispatchers.Main) { - val params = Bundle() - tts.speak(trimmed, TextToSpeech.QUEUE_FLUSH, params, utteranceId) - } - - withContext(Dispatchers.IO) { - try { - kotlinx.coroutines.withTimeout(180_000) { deferred.await() } - } catch (err: Throwable) { - throw err - } - } - } - - private suspend fun ensureSystemTts(): Boolean { - if (systemTts != null) return true - return withContext(Dispatchers.Main) { - val deferred = CompletableDeferred() - val tts = - try { - TextToSpeech(context) { status -> - deferred.complete(status == TextToSpeech.SUCCESS) - } - } catch (_: Throwable) { - deferred.complete(false) - null - } - if (tts == null) return@withContext false - - tts.setOnUtteranceProgressListener( - object : UtteranceProgressListener() { - override fun onStart(utteranceId: String?) {} - - override fun onDone(utteranceId: String?) { - if (utteranceId == null) return - if (utteranceId != systemTtsPendingId) return - systemTtsPending?.complete(Unit) - systemTtsPending = null - systemTtsPendingId = null - } - - @Suppress("OVERRIDE_DEPRECATION") - @Deprecated("Deprecated in Java") - override fun onError(utteranceId: String?) { - if (utteranceId == null) return - if (utteranceId != systemTtsPendingId) return - systemTtsPending?.completeExceptionally(IllegalStateException("system TTS error")) - systemTtsPending = null - systemTtsPendingId = null - } - - override fun onError(utteranceId: String?, errorCode: Int) { - if (utteranceId == null) return - if (utteranceId != systemTtsPendingId) return - systemTtsPending?.completeExceptionally(IllegalStateException("system TTS error $errorCode")) - systemTtsPending = null - systemTtsPendingId = null - } - }, - ) - - val ok = - try { - deferred.await() - } catch (_: Throwable) { - false - } - if (ok) { - systemTts = tts - } else { - tts.shutdown() - } - ok - } - } - - private fun stopSpeaking(resetInterrupt: Boolean = true) { - pcmStopRequested = true - if (!_isSpeaking.value) { - cleanupPlayer() - cleanupPcmTrack() - systemTts?.stop() - systemTtsPending?.cancel() - systemTtsPending = null - systemTtsPendingId = null - return - } - if (resetInterrupt) { - val currentMs = player?.currentPosition?.toDouble() ?: 0.0 - lastInterruptedAtSeconds = currentMs / 1000.0 - } - cleanupPlayer() - cleanupPcmTrack() - systemTts?.stop() - systemTtsPending?.cancel() - systemTtsPending = null - systemTtsPendingId = null - _isSpeaking.value = false - } - - private fun cleanupPlayer() { - player?.stop() - player?.release() - player = null - streamingSource?.close() - streamingSource = null - } - - private fun cleanupPcmTrack() { - val track = pcmTrack ?: return - try { - track.pause() - track.flush() - track.stop() - } catch (_: Throwable) { - // ignore cleanup errors - } finally { - track.release() - } - pcmTrack = null - } - - private fun shouldInterrupt(transcript: String): Boolean { - val trimmed = transcript.trim() - if (trimmed.length < 3) return false - val spoken = lastSpokenText?.lowercase() - if (spoken != null && spoken.contains(trimmed.lowercase())) return false - return true - } - - private suspend fun reloadConfig() { - val envVoice = System.getenv("ELEVENLABS_VOICE_ID")?.trim() - val sagVoice = System.getenv("SAG_VOICE_ID")?.trim() - val envKey = System.getenv("ELEVENLABS_API_KEY")?.trim() - try { - val res = session.request("talk.config", """{"includeSecrets":true}""") - val root = json.parseToJsonElement(res).asObjectOrNull() - val config = root?.get("config").asObjectOrNull() - val talk = config?.get("talk").asObjectOrNull() - val sessionCfg = config?.get("session").asObjectOrNull() - val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull()) - val voice = talk?.get("voiceId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } - val aliases = - talk?.get("voiceAliases").asObjectOrNull()?.entries?.mapNotNull { (key, value) -> - val id = value.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null - normalizeAliasKey(key).takeIf { it.isNotEmpty() }?.let { it to id } - }?.toMap().orEmpty() - val model = talk?.get("modelId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } - val outputFormat = talk?.get("outputFormat")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } - val key = talk?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } - val interrupt = talk?.get("interruptOnSpeech")?.asBooleanOrNull() - - if (!isCanonicalMainSessionKey(mainSessionKey)) { - mainSessionKey = mainKey - } - defaultVoiceId = voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() } - voiceAliases = aliases - if (!voiceOverrideActive) currentVoiceId = defaultVoiceId - defaultModelId = model ?: defaultModelIdFallback - if (!modelOverrideActive) currentModelId = defaultModelId - defaultOutputFormat = outputFormat ?: defaultOutputFormatFallback - apiKey = key ?: envKey?.takeIf { it.isNotEmpty() } - if (interrupt != null) interruptOnSpeech = interrupt - } catch (_: Throwable) { - defaultVoiceId = envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() } - defaultModelId = defaultModelIdFallback - if (!modelOverrideActive) currentModelId = defaultModelId - apiKey = envKey?.takeIf { it.isNotEmpty() } - voiceAliases = emptyMap() - defaultOutputFormat = defaultOutputFormatFallback - } - } - - private fun parseRunId(jsonString: String): String? { - val obj = json.parseToJsonElement(jsonString).asObjectOrNull() ?: return null - return obj["runId"].asStringOrNull() - } - - private suspend fun streamTts( - voiceId: String, - apiKey: String, - request: ElevenLabsRequest, - sink: StreamingMediaDataSource, - ) { - withContext(Dispatchers.IO) { - val conn = openTtsConnection(voiceId = voiceId, apiKey = apiKey, request = request) - try { - val payload = buildRequestPayload(request) - conn.outputStream.use { it.write(payload.toByteArray()) } - - val code = conn.responseCode - if (code >= 400) { - val message = conn.errorStream?.readBytes()?.toString(Charsets.UTF_8) ?: "" - sink.fail() - throw IllegalStateException("ElevenLabs failed: $code $message") - } - - val buffer = ByteArray(8 * 1024) - conn.inputStream.use { input -> - while (true) { - val read = input.read(buffer) - if (read <= 0) break - sink.append(buffer.copyOf(read)) - } - } - sink.finish() - } finally { - conn.disconnect() - } - } - } - - private suspend fun streamPcm( - voiceId: String, - apiKey: String, - request: ElevenLabsRequest, - track: AudioTrack, - ) { - withContext(Dispatchers.IO) { - val conn = openTtsConnection(voiceId = voiceId, apiKey = apiKey, request = request) - try { - val payload = buildRequestPayload(request) - conn.outputStream.use { it.write(payload.toByteArray()) } - - val code = conn.responseCode - if (code >= 400) { - val message = conn.errorStream?.readBytes()?.toString(Charsets.UTF_8) ?: "" - throw IllegalStateException("ElevenLabs failed: $code $message") - } - - val buffer = ByteArray(8 * 1024) - conn.inputStream.use { input -> - while (true) { - if (pcmStopRequested) return@withContext - val read = input.read(buffer) - if (read <= 0) break - var offset = 0 - while (offset < read) { - if (pcmStopRequested) return@withContext - val wrote = - try { - track.write(buffer, offset, read - offset) - } catch (err: Throwable) { - if (pcmStopRequested) return@withContext - throw err - } - if (wrote <= 0) { - if (pcmStopRequested) return@withContext - throw IllegalStateException("AudioTrack write failed: $wrote") - } - offset += wrote - } - } - } - } finally { - conn.disconnect() - } - } - } - - private fun openTtsConnection( - voiceId: String, - apiKey: String, - request: ElevenLabsRequest, - ): HttpURLConnection { - val baseUrl = "https://api.elevenlabs.io/v1/text-to-speech/$voiceId/stream" - val latencyTier = request.latencyTier - val url = - if (latencyTier != null) { - URL("$baseUrl?optimize_streaming_latency=$latencyTier") - } else { - URL(baseUrl) - } - val conn = url.openConnection() as HttpURLConnection - conn.requestMethod = "POST" - conn.connectTimeout = 30_000 - conn.readTimeout = 30_000 - conn.setRequestProperty("Content-Type", "application/json") - conn.setRequestProperty("Accept", resolveAcceptHeader(request.outputFormat)) - conn.setRequestProperty("xi-api-key", apiKey) - conn.doOutput = true - return conn - } - - private fun resolveAcceptHeader(outputFormat: String?): String { - val normalized = outputFormat?.trim()?.lowercase().orEmpty() - return if (normalized.startsWith("pcm_")) "audio/pcm" else "audio/mpeg" - } - - private fun buildRequestPayload(request: ElevenLabsRequest): String { - val voiceSettingsEntries = - buildJsonObject { - request.speed?.let { put("speed", JsonPrimitive(it)) } - request.stability?.let { put("stability", JsonPrimitive(it)) } - request.similarity?.let { put("similarity_boost", JsonPrimitive(it)) } - request.style?.let { put("style", JsonPrimitive(it)) } - request.speakerBoost?.let { put("use_speaker_boost", JsonPrimitive(it)) } - } - - val payload = - buildJsonObject { - put("text", JsonPrimitive(request.text)) - request.modelId?.takeIf { it.isNotEmpty() }?.let { put("model_id", JsonPrimitive(it)) } - request.outputFormat?.takeIf { it.isNotEmpty() }?.let { put("output_format", JsonPrimitive(it)) } - request.seed?.let { put("seed", JsonPrimitive(it)) } - request.normalize?.let { put("apply_text_normalization", JsonPrimitive(it)) } - request.language?.let { put("language_code", JsonPrimitive(it)) } - if (voiceSettingsEntries.isNotEmpty()) { - put("voice_settings", voiceSettingsEntries) - } - } - - return payload.toString() - } - - private data class ElevenLabsRequest( - val text: String, - val modelId: String?, - val outputFormat: String?, - val speed: Double?, - val stability: Double?, - val similarity: Double?, - val style: Double?, - val speakerBoost: Boolean?, - val seed: Long?, - val normalize: String?, - val language: String?, - val latencyTier: Int?, - ) - - private object TalkModeRuntime { - fun resolveSpeed(speed: Double?, rateWpm: Int?): Double? { - if (rateWpm != null && rateWpm > 0) { - val resolved = rateWpm.toDouble() / 175.0 - if (resolved <= 0.5 || resolved >= 2.0) return null - return resolved - } - if (speed != null) { - if (speed <= 0.5 || speed >= 2.0) return null - return speed - } - return null - } - - fun validatedUnit(value: Double?): Double? { - if (value == null) return null - if (value < 0 || value > 1) return null - return value - } - - fun validatedStability(value: Double?, modelId: String?): Double? { - if (value == null) return null - val normalized = modelId?.trim()?.lowercase() - if (normalized == "eleven_v3") { - return if (value == 0.0 || value == 0.5 || value == 1.0) value else null - } - return validatedUnit(value) - } - - fun validatedSeed(value: Long?): Long? { - if (value == null) return null - if (value < 0 || value > 4294967295L) return null - return value - } - - fun validatedNormalize(value: String?): String? { - val normalized = value?.trim()?.lowercase() ?: return null - return if (normalized in listOf("auto", "on", "off")) normalized else null - } - - fun validatedLanguage(value: String?): String? { - val normalized = value?.trim()?.lowercase() ?: return null - if (normalized.length != 2) return null - if (!normalized.all { it in 'a'..'z' }) return null - return normalized - } - - fun validatedOutputFormat(value: String?): String? { - val trimmed = value?.trim()?.lowercase() ?: return null - if (trimmed.isEmpty()) return null - if (trimmed.startsWith("mp3_")) return trimmed - return if (parsePcmSampleRate(trimmed) != null) trimmed else null - } - - fun validatedLatencyTier(value: Int?): Int? { - if (value == null) return null - if (value < 0 || value > 4) return null - return value - } - - fun parsePcmSampleRate(value: String?): Int? { - val trimmed = value?.trim()?.lowercase() ?: return null - if (!trimmed.startsWith("pcm_")) return null - val suffix = trimmed.removePrefix("pcm_") - val digits = suffix.takeWhile { it.isDigit() } - val rate = digits.toIntOrNull() ?: return null - return if (rate in setOf(16000, 22050, 24000, 44100)) rate else null - } - - fun isMessageTimestampAfter(timestamp: Double, sinceSeconds: Double): Boolean { - val sinceMs = sinceSeconds * 1000 - return if (timestamp > 10_000_000_000) { - timestamp >= sinceMs - 500 - } else { - timestamp >= sinceSeconds - 0.5 - } - } - } - - private fun ensureInterruptListener() { - if (!interruptOnSpeech || !_isEnabled.value) return - mainHandler.post { - if (stopRequested) return@post - if (!SpeechRecognizer.isRecognitionAvailable(context)) return@post - try { - if (recognizer == null) { - recognizer = SpeechRecognizer.createSpeechRecognizer(context).also { it.setRecognitionListener(listener) } - } - recognizer?.cancel() - startListeningInternal(markListening = false) - } catch (_: Throwable) { - // ignore - } - } - } - - private fun resolveVoiceAlias(value: String?): String? { - val trimmed = value?.trim().orEmpty() - if (trimmed.isEmpty()) return null - val normalized = normalizeAliasKey(trimmed) - voiceAliases[normalized]?.let { return it } - if (voiceAliases.values.any { it.equals(trimmed, ignoreCase = true) }) return trimmed - return if (isLikelyVoiceId(trimmed)) trimmed else null - } - - private suspend fun resolveVoiceId(preferred: String?, apiKey: String): String? { - val trimmed = preferred?.trim().orEmpty() - if (trimmed.isNotEmpty()) { - val resolved = resolveVoiceAlias(trimmed) - if (resolved != null) return resolved - Log.w(tag, "unknown voice alias $trimmed") - } - fallbackVoiceId?.let { return it } - - return try { - val voices = listVoices(apiKey) - val first = voices.firstOrNull() ?: return null - fallbackVoiceId = first.voiceId - if (defaultVoiceId.isNullOrBlank()) { - defaultVoiceId = first.voiceId - } - if (!voiceOverrideActive) { - currentVoiceId = first.voiceId - } - val name = first.name ?: "unknown" - Log.d(tag, "default voice selected $name (${first.voiceId})") - first.voiceId - } catch (err: Throwable) { - Log.w(tag, "list voices failed: ${err.message ?: err::class.simpleName}") - null - } - } - - private suspend fun listVoices(apiKey: String): List { - return withContext(Dispatchers.IO) { - val url = URL("https://api.elevenlabs.io/v1/voices") - val conn = url.openConnection() as HttpURLConnection - conn.requestMethod = "GET" - conn.connectTimeout = 15_000 - conn.readTimeout = 15_000 - conn.setRequestProperty("xi-api-key", apiKey) - - val code = conn.responseCode - val stream = if (code >= 400) conn.errorStream else conn.inputStream - val data = stream.readBytes() - if (code >= 400) { - val message = data.toString(Charsets.UTF_8) - throw IllegalStateException("ElevenLabs voices failed: $code $message") - } - - val root = json.parseToJsonElement(data.toString(Charsets.UTF_8)).asObjectOrNull() - val voices = (root?.get("voices") as? JsonArray) ?: JsonArray(emptyList()) - voices.mapNotNull { entry -> - val obj = entry.asObjectOrNull() ?: return@mapNotNull null - val voiceId = obj["voice_id"].asStringOrNull() ?: return@mapNotNull null - val name = obj["name"].asStringOrNull() - ElevenLabsVoice(voiceId, name) - } - } - } - - private fun isLikelyVoiceId(value: String): Boolean { - if (value.length < 10) return false - return value.all { it.isLetterOrDigit() || it == '-' || it == '_' } - } - - private fun normalizeAliasKey(value: String): String = - value.trim().lowercase() - - private data class ElevenLabsVoice(val voiceId: String, val name: String?) - - private val listener = - object : RecognitionListener { - override fun onReadyForSpeech(params: Bundle?) { - if (_isEnabled.value) { - _statusText.value = if (_isListening.value) "Listening" else _statusText.value - } - } - - override fun onBeginningOfSpeech() {} - - override fun onRmsChanged(rmsdB: Float) {} - - override fun onBufferReceived(buffer: ByteArray?) {} - - override fun onEndOfSpeech() { - scheduleRestart() - } - - override fun onError(error: Int) { - if (stopRequested) return - _isListening.value = false - if (error == SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS) { - _statusText.value = "Microphone permission required" - return - } - - _statusText.value = - when (error) { - SpeechRecognizer.ERROR_AUDIO -> "Audio error" - SpeechRecognizer.ERROR_CLIENT -> "Client error" - SpeechRecognizer.ERROR_NETWORK -> "Network error" - SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "Network timeout" - SpeechRecognizer.ERROR_NO_MATCH -> "Listening" - SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "Recognizer busy" - SpeechRecognizer.ERROR_SERVER -> "Server error" - SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "Listening" - else -> "Speech error ($error)" - } - scheduleRestart(delayMs = 600) - } - - override fun onResults(results: Bundle?) { - val list = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION).orEmpty() - list.firstOrNull()?.let { handleTranscript(it, isFinal = true) } - scheduleRestart() - } - - override fun onPartialResults(partialResults: Bundle?) { - val list = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION).orEmpty() - list.firstOrNull()?.let { handleTranscript(it, isFinal = false) } - } - - override fun onEvent(eventType: Int, params: Bundle?) {} - } -} - -private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject - -private fun JsonElement?.asStringOrNull(): String? = - (this as? JsonPrimitive)?.takeIf { it.isString }?.content - -private fun JsonElement?.asDoubleOrNull(): Double? { - val primitive = this as? JsonPrimitive ?: return null - return primitive.content.toDoubleOrNull() -} - -private fun JsonElement?.asBooleanOrNull(): Boolean? { - val primitive = this as? JsonPrimitive ?: return null - val content = primitive.content.trim().lowercase() - return when (content) { - "true", "yes", "1" -> true - "false", "no", "0" -> false - else -> null - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/voice/VoiceWakeCommandExtractor.kt b/apps/android/app/src/main/java/ai/openclaw/android/voice/VoiceWakeCommandExtractor.kt deleted file mode 100644 index dccd3950c90..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/voice/VoiceWakeCommandExtractor.kt +++ /dev/null @@ -1,40 +0,0 @@ -package ai.openclaw.android.voice - -object VoiceWakeCommandExtractor { - fun extractCommand(text: String, triggerWords: List): String? { - val raw = text.trim() - if (raw.isEmpty()) return null - - val triggers = - triggerWords - .map { it.trim().lowercase() } - .filter { it.isNotEmpty() } - .distinct() - if (triggers.isEmpty()) return null - - val alternation = triggers.joinToString("|") { Regex.escape(it) } - // Match: " " - val regex = Regex("(?i)(?:^|\\s)($alternation)\\b[\\s\\p{Punct}]*([\\s\\S]+)$") - val match = regex.find(raw) ?: return null - val extracted = match.groupValues.getOrNull(2)?.trim().orEmpty() - if (extracted.isEmpty()) return null - - val cleaned = extracted.trimStart { it.isWhitespace() || it.isPunctuation() }.trim() - if (cleaned.isEmpty()) return null - return cleaned - } -} - -private fun Char.isPunctuation(): Boolean { - return when (Character.getType(this)) { - Character.CONNECTOR_PUNCTUATION.toInt(), - Character.DASH_PUNCTUATION.toInt(), - Character.START_PUNCTUATION.toInt(), - Character.END_PUNCTUATION.toInt(), - Character.INITIAL_QUOTE_PUNCTUATION.toInt(), - Character.FINAL_QUOTE_PUNCTUATION.toInt(), - Character.OTHER_PUNCTUATION.toInt(), - -> true - else -> false - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/voice/VoiceWakeManager.kt b/apps/android/app/src/main/java/ai/openclaw/android/voice/VoiceWakeManager.kt deleted file mode 100644 index 334f985a028..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/android/voice/VoiceWakeManager.kt +++ /dev/null @@ -1,173 +0,0 @@ -package ai.openclaw.android.voice - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.speech.RecognitionListener -import android.speech.RecognizerIntent -import android.speech.SpeechRecognizer -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch - -class VoiceWakeManager( - private val context: Context, - private val scope: CoroutineScope, - private val onCommand: suspend (String) -> Unit, -) { - private val mainHandler = Handler(Looper.getMainLooper()) - - private val _isListening = MutableStateFlow(false) - val isListening: StateFlow = _isListening - - private val _statusText = MutableStateFlow("Off") - val statusText: StateFlow = _statusText - - var triggerWords: List = emptyList() - private set - - private var recognizer: SpeechRecognizer? = null - private var restartJob: Job? = null - private var lastDispatched: String? = null - private var stopRequested = false - - fun setTriggerWords(words: List) { - triggerWords = words - } - - fun start() { - mainHandler.post { - if (_isListening.value) return@post - stopRequested = false - - if (!SpeechRecognizer.isRecognitionAvailable(context)) { - _isListening.value = false - _statusText.value = "Speech recognizer unavailable" - return@post - } - - try { - recognizer?.destroy() - recognizer = SpeechRecognizer.createSpeechRecognizer(context).also { it.setRecognitionListener(listener) } - startListeningInternal() - } catch (err: Throwable) { - _isListening.value = false - _statusText.value = "Start failed: ${err.message ?: err::class.simpleName}" - } - } - } - - fun stop(statusText: String = "Off") { - stopRequested = true - restartJob?.cancel() - restartJob = null - mainHandler.post { - _isListening.value = false - _statusText.value = statusText - recognizer?.cancel() - recognizer?.destroy() - recognizer = null - } - } - - private fun startListeningInternal() { - val r = recognizer ?: return - val intent = - Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { - putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) - putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true) - putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 3) - putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, context.packageName) - } - - _statusText.value = "Listening" - _isListening.value = true - r.startListening(intent) - } - - private fun scheduleRestart(delayMs: Long = 350) { - if (stopRequested) return - restartJob?.cancel() - restartJob = - scope.launch { - delay(delayMs) - mainHandler.post { - if (stopRequested) return@post - try { - recognizer?.cancel() - startListeningInternal() - } catch (_: Throwable) { - // Will be picked up by onError and retry again. - } - } - } - } - - private fun handleTranscription(text: String) { - val command = VoiceWakeCommandExtractor.extractCommand(text, triggerWords) ?: return - if (command == lastDispatched) return - lastDispatched = command - - scope.launch { onCommand(command) } - _statusText.value = "Triggered" - scheduleRestart(delayMs = 650) - } - - private val listener = - object : RecognitionListener { - override fun onReadyForSpeech(params: Bundle?) { - _statusText.value = "Listening" - } - - override fun onBeginningOfSpeech() {} - - override fun onRmsChanged(rmsdB: Float) {} - - override fun onBufferReceived(buffer: ByteArray?) {} - - override fun onEndOfSpeech() { - scheduleRestart() - } - - override fun onError(error: Int) { - if (stopRequested) return - _isListening.value = false - if (error == SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS) { - _statusText.value = "Microphone permission required" - return - } - - _statusText.value = - when (error) { - SpeechRecognizer.ERROR_AUDIO -> "Audio error" - SpeechRecognizer.ERROR_CLIENT -> "Client error" - SpeechRecognizer.ERROR_NETWORK -> "Network error" - SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "Network timeout" - SpeechRecognizer.ERROR_NO_MATCH -> "Listening" - SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "Recognizer busy" - SpeechRecognizer.ERROR_SERVER -> "Server error" - SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "Listening" - else -> "Speech error ($error)" - } - scheduleRestart(delayMs = 600) - } - - override fun onResults(results: Bundle?) { - val list = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION).orEmpty() - list.firstOrNull()?.let(::handleTranscription) - scheduleRestart() - } - - override fun onPartialResults(partialResults: Bundle?) { - val list = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION).orEmpty() - list.firstOrNull()?.let(::handleTranscription) - } - - override fun onEvent(eventType: Int, params: Bundle?) {} - } -} diff --git a/apps/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/apps/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml deleted file mode 100644 index 6f379984a93..00000000000 --- a/apps/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/apps/android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/apps/android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml deleted file mode 100644 index 6f379984a93..00000000000 --- a/apps/android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index 613e2666383..00000000000 Binary files a/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png deleted file mode 100644 index 22442bc1d80..00000000000 Binary files a/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index b1fd747de01..00000000000 Binary files a/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png deleted file mode 100644 index d26c0189852..00000000000 Binary files a/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index 038e3dc7a70..00000000000 Binary files a/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png deleted file mode 100644 index 2f065970225..00000000000 Binary files a/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index a5d995c2ee2..00000000000 Binary files a/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png deleted file mode 100644 index 7c976dc74d9..00000000000 Binary files a/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index ceabff1f562..00000000000 Binary files a/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png deleted file mode 100644 index 240acdf4fec..00000000000 Binary files a/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/apps/android/app/src/main/res/values/colors.xml b/apps/android/app/src/main/res/values/colors.xml deleted file mode 100644 index dfadc94cf03..00000000000 --- a/apps/android/app/src/main/res/values/colors.xml +++ /dev/null @@ -1,3 +0,0 @@ - - #0A0A0A - diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml deleted file mode 100644 index 0098cee20f0..00000000000 --- a/apps/android/app/src/main/res/values/strings.xml +++ /dev/null @@ -1,3 +0,0 @@ - - OpenClaw Node - diff --git a/apps/android/app/src/main/res/values/themes.xml b/apps/android/app/src/main/res/values/themes.xml deleted file mode 100644 index 3ac5d04d831..00000000000 --- a/apps/android/app/src/main/res/values/themes.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/apps/android/app/src/main/res/xml/backup_rules.xml b/apps/android/app/src/main/res/xml/backup_rules.xml deleted file mode 100644 index 21e592ca47a..00000000000 --- a/apps/android/app/src/main/res/xml/backup_rules.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/apps/android/app/src/main/res/xml/data_extraction_rules.xml b/apps/android/app/src/main/res/xml/data_extraction_rules.xml deleted file mode 100644 index 46e58c54eb0..00000000000 --- a/apps/android/app/src/main/res/xml/data_extraction_rules.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/apps/android/app/src/main/res/xml/file_paths.xml b/apps/android/app/src/main/res/xml/file_paths.xml deleted file mode 100644 index 5e0f4f1ef3c..00000000000 --- a/apps/android/app/src/main/res/xml/file_paths.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/apps/android/app/src/main/res/xml/network_security_config.xml b/apps/android/app/src/main/res/xml/network_security_config.xml deleted file mode 100644 index 7ac5f5cdd7b..00000000000 --- a/apps/android/app/src/main/res/xml/network_security_config.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - openclaw.local - - - ts.net - - diff --git a/apps/android/app/src/test/java/ai/openclaw/android/NodeForegroundServiceTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/NodeForegroundServiceTest.kt deleted file mode 100644 index 7a81936ecd2..00000000000 --- a/apps/android/app/src/test/java/ai/openclaw/android/NodeForegroundServiceTest.kt +++ /dev/null @@ -1,43 +0,0 @@ -package ai.openclaw.android - -import android.app.Notification -import android.content.Intent -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.Robolectric -import org.robolectric.RobolectricTestRunner -import org.robolectric.Shadows -import org.robolectric.annotation.Config - -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34]) -class NodeForegroundServiceTest { - @Test - fun buildNotificationSetsLaunchIntent() { - val service = Robolectric.buildService(NodeForegroundService::class.java).get() - val notification = buildNotification(service) - - val pendingIntent = notification.contentIntent - assertNotNull(pendingIntent) - - val savedIntent = Shadows.shadowOf(pendingIntent).savedIntent - assertNotNull(savedIntent) - assertEquals(MainActivity::class.java.name, savedIntent.component?.className) - - val expectedFlags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP - assertEquals(expectedFlags, savedIntent.flags and expectedFlags) - } - - private fun buildNotification(service: NodeForegroundService): Notification { - val method = - NodeForegroundService::class.java.getDeclaredMethod( - "buildNotification", - String::class.java, - String::class.java, - ) - method.isAccessible = true - return method.invoke(service, "Title", "Text") as Notification - } -} diff --git a/apps/android/app/src/test/java/ai/openclaw/android/WakeWordsTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/WakeWordsTest.kt deleted file mode 100644 index 55730e2f5ab..00000000000 --- a/apps/android/app/src/test/java/ai/openclaw/android/WakeWordsTest.kt +++ /dev/null @@ -1,50 +0,0 @@ -package ai.openclaw.android - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Test - -class WakeWordsTest { - @Test - fun parseCommaSeparatedTrimsAndDropsEmpty() { - assertEquals(listOf("openclaw", "claude"), WakeWords.parseCommaSeparated(" openclaw , claude, , ")) - } - - @Test - fun sanitizeTrimsCapsAndFallsBack() { - val defaults = listOf("openclaw", "claude") - val long = "x".repeat(WakeWords.maxWordLength + 10) - val words = listOf(" ", " hello ", long) - - val sanitized = WakeWords.sanitize(words, defaults) - assertEquals(2, sanitized.size) - assertEquals("hello", sanitized[0]) - assertEquals("x".repeat(WakeWords.maxWordLength), sanitized[1]) - - assertEquals(defaults, WakeWords.sanitize(listOf(" ", ""), defaults)) - } - - @Test - fun sanitizeLimitsWordCount() { - val defaults = listOf("openclaw") - val words = (1..(WakeWords.maxWords + 5)).map { "w$it" } - val sanitized = WakeWords.sanitize(words, defaults) - assertEquals(WakeWords.maxWords, sanitized.size) - assertEquals("w1", sanitized.first()) - assertEquals("w${WakeWords.maxWords}", sanitized.last()) - } - - @Test - fun parseIfChangedSkipsWhenUnchanged() { - val current = listOf("openclaw", "claude") - val parsed = WakeWords.parseIfChanged(" openclaw , claude ", current) - assertNull(parsed) - } - - @Test - fun parseIfChangedReturnsUpdatedList() { - val current = listOf("openclaw") - val parsed = WakeWords.parseIfChanged(" openclaw , jarvis ", current) - assertEquals(listOf("openclaw", "jarvis"), parsed) - } -} diff --git a/apps/android/app/src/test/java/ai/openclaw/android/gateway/BonjourEscapesTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/gateway/BonjourEscapesTest.kt deleted file mode 100644 index fe00e50a72d..00000000000 --- a/apps/android/app/src/test/java/ai/openclaw/android/gateway/BonjourEscapesTest.kt +++ /dev/null @@ -1,19 +0,0 @@ -package ai.openclaw.android.gateway - -import org.junit.Assert.assertEquals -import org.junit.Test - -class BonjourEscapesTest { - @Test - fun decodeNoop() { - assertEquals("", BonjourEscapes.decode("")) - assertEquals("hello", BonjourEscapes.decode("hello")) - } - - @Test - fun decodeDecodesDecimalEscapes() { - assertEquals("OpenClaw Gateway", BonjourEscapes.decode("OpenClaw\\032Gateway")) - assertEquals("A B", BonjourEscapes.decode("A\\032B")) - assertEquals("Peter\u2019s Mac", BonjourEscapes.decode("Peter\\226\\128\\153s Mac")) - } -} diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/AppUpdateHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/node/AppUpdateHandlerTest.kt deleted file mode 100644 index 743ed92c6d5..00000000000 --- a/apps/android/app/src/test/java/ai/openclaw/android/node/AppUpdateHandlerTest.kt +++ /dev/null @@ -1,65 +0,0 @@ -package ai.openclaw.android.node - -import java.io.File -import org.junit.Assert.assertEquals -import org.junit.Assert.assertThrows -import org.junit.Test - -class AppUpdateHandlerTest { - @Test - fun parseAppUpdateRequest_acceptsHttpsWithMatchingHost() { - val req = - parseAppUpdateRequest( - paramsJson = - """{"url":"https://gw.example.com/releases/openclaw.apk","sha256":"${"a".repeat(64)}"}""", - connectedHost = "gw.example.com", - ) - - assertEquals("https://gw.example.com/releases/openclaw.apk", req.url) - assertEquals("a".repeat(64), req.expectedSha256) - } - - @Test - fun parseAppUpdateRequest_rejectsNonHttps() { - assertThrows(IllegalArgumentException::class.java) { - parseAppUpdateRequest( - paramsJson = """{"url":"http://gw.example.com/releases/openclaw.apk","sha256":"${"a".repeat(64)}"}""", - connectedHost = "gw.example.com", - ) - } - } - - @Test - fun parseAppUpdateRequest_rejectsHostMismatch() { - assertThrows(IllegalArgumentException::class.java) { - parseAppUpdateRequest( - paramsJson = """{"url":"https://evil.example.com/releases/openclaw.apk","sha256":"${"a".repeat(64)}"}""", - connectedHost = "gw.example.com", - ) - } - } - - @Test - fun parseAppUpdateRequest_rejectsInvalidSha256() { - assertThrows(IllegalArgumentException::class.java) { - parseAppUpdateRequest( - paramsJson = """{"url":"https://gw.example.com/releases/openclaw.apk","sha256":"bad"}""", - connectedHost = "gw.example.com", - ) - } - } - - @Test - fun sha256Hex_computesExpectedDigest() { - val tmp = File.createTempFile("openclaw-update-hash", ".bin") - try { - tmp.writeText("hello", Charsets.UTF_8) - assertEquals( - "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", - sha256Hex(tmp), - ) - } finally { - tmp.delete() - } - } -} diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/CanvasControllerSnapshotParamsTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/node/CanvasControllerSnapshotParamsTest.kt deleted file mode 100644 index dd1b9d5d19a..00000000000 --- a/apps/android/app/src/test/java/ai/openclaw/android/node/CanvasControllerSnapshotParamsTest.kt +++ /dev/null @@ -1,43 +0,0 @@ -package ai.openclaw.android.node - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Test - -class CanvasControllerSnapshotParamsTest { - @Test - fun parseSnapshotParamsDefaultsToJpeg() { - val params = CanvasController.parseSnapshotParams(null) - assertEquals(CanvasController.SnapshotFormat.Jpeg, params.format) - assertNull(params.quality) - assertNull(params.maxWidth) - } - - @Test - fun parseSnapshotParamsParsesPng() { - val params = CanvasController.parseSnapshotParams("""{"format":"png","maxWidth":900}""") - assertEquals(CanvasController.SnapshotFormat.Png, params.format) - assertEquals(900, params.maxWidth) - } - - @Test - fun parseSnapshotParamsParsesJpegAliases() { - assertEquals( - CanvasController.SnapshotFormat.Jpeg, - CanvasController.parseSnapshotParams("""{"format":"jpeg"}""").format, - ) - assertEquals( - CanvasController.SnapshotFormat.Jpeg, - CanvasController.parseSnapshotParams("""{"format":"jpg"}""").format, - ) - } - - @Test - fun parseSnapshotParamsClampsQuality() { - val low = CanvasController.parseSnapshotParams("""{"quality":0.01}""") - assertEquals(0.1, low.quality) - - val high = CanvasController.parseSnapshotParams("""{"quality":5}""") - assertEquals(1.0, high.quality) - } -} diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/ConnectionManagerTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/node/ConnectionManagerTest.kt deleted file mode 100644 index 534b90a2121..00000000000 --- a/apps/android/app/src/test/java/ai/openclaw/android/node/ConnectionManagerTest.kt +++ /dev/null @@ -1,76 +0,0 @@ -package ai.openclaw.android.node - -import ai.openclaw.android.gateway.GatewayEndpoint -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Test - -class ConnectionManagerTest { - @Test - fun resolveTlsParamsForEndpoint_prefersStoredPinOverAdvertisedFingerprint() { - val endpoint = - GatewayEndpoint( - stableId = "_openclaw-gw._tcp.|local.|Test", - name = "Test", - host = "10.0.0.2", - port = 18789, - tlsEnabled = true, - tlsFingerprintSha256 = "attacker", - ) - - val params = - ConnectionManager.resolveTlsParamsForEndpoint( - endpoint, - storedFingerprint = "legit", - manualTlsEnabled = false, - ) - - assertEquals("legit", params?.expectedFingerprint) - assertEquals(false, params?.allowTOFU) - } - - @Test - fun resolveTlsParamsForEndpoint_doesNotTrustAdvertisedFingerprintWhenNoStoredPin() { - val endpoint = - GatewayEndpoint( - stableId = "_openclaw-gw._tcp.|local.|Test", - name = "Test", - host = "10.0.0.2", - port = 18789, - tlsEnabled = true, - tlsFingerprintSha256 = "attacker", - ) - - val params = - ConnectionManager.resolveTlsParamsForEndpoint( - endpoint, - storedFingerprint = null, - manualTlsEnabled = false, - ) - - assertNull(params?.expectedFingerprint) - assertEquals(false, params?.allowTOFU) - } - - @Test - fun resolveTlsParamsForEndpoint_manualRespectsManualTlsToggle() { - val endpoint = GatewayEndpoint.manual(host = "example.com", port = 443) - - val off = - ConnectionManager.resolveTlsParamsForEndpoint( - endpoint, - storedFingerprint = null, - manualTlsEnabled = false, - ) - assertNull(off) - - val on = - ConnectionManager.resolveTlsParamsForEndpoint( - endpoint, - storedFingerprint = null, - manualTlsEnabled = true, - ) - assertNull(on?.expectedFingerprint) - assertEquals(false, on?.allowTOFU) - } -} diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/JpegSizeLimiterTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/node/JpegSizeLimiterTest.kt deleted file mode 100644 index 5de1dd5451a..00000000000 --- a/apps/android/app/src/test/java/ai/openclaw/android/node/JpegSizeLimiterTest.kt +++ /dev/null @@ -1,47 +0,0 @@ -package ai.openclaw.android.node - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test -import kotlin.math.min - -class JpegSizeLimiterTest { - @Test - fun compressesLargePayloadsUnderLimit() { - val maxBytes = 5 * 1024 * 1024 - val result = - JpegSizeLimiter.compressToLimit( - initialWidth = 4000, - initialHeight = 3000, - startQuality = 95, - maxBytes = maxBytes, - encode = { width, height, quality -> - val estimated = (width.toLong() * height.toLong() * quality.toLong()) / 100 - val size = min(maxBytes.toLong() * 2, estimated).toInt() - ByteArray(size) - }, - ) - - assertTrue(result.bytes.size <= maxBytes) - assertTrue(result.width <= 4000) - assertTrue(result.height <= 3000) - assertTrue(result.quality <= 95) - } - - @Test - fun keepsSmallPayloadsAsIs() { - val maxBytes = 5 * 1024 * 1024 - val result = - JpegSizeLimiter.compressToLimit( - initialWidth = 800, - initialHeight = 600, - startQuality = 90, - maxBytes = maxBytes, - encode = { _, _, _ -> ByteArray(120_000) }, - ) - - assertEquals(800, result.width) - assertEquals(600, result.height) - assertEquals(90, result.quality) - } -} diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/SmsManagerTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/node/SmsManagerTest.kt deleted file mode 100644 index a3d61329b4a..00000000000 --- a/apps/android/app/src/test/java/ai/openclaw/android/node/SmsManagerTest.kt +++ /dev/null @@ -1,91 +0,0 @@ -package ai.openclaw.android.node - -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -class SmsManagerTest { - private val json = SmsManager.JsonConfig - - @Test - fun parseParamsRejectsEmptyPayload() { - val result = SmsManager.parseParams("", json) - assertTrue(result is SmsManager.ParseResult.Error) - val error = result as SmsManager.ParseResult.Error - assertEquals("INVALID_REQUEST: paramsJSON required", error.error) - } - - @Test - fun parseParamsRejectsInvalidJson() { - val result = SmsManager.parseParams("not-json", json) - assertTrue(result is SmsManager.ParseResult.Error) - val error = result as SmsManager.ParseResult.Error - assertEquals("INVALID_REQUEST: expected JSON object", error.error) - } - - @Test - fun parseParamsRejectsNonObjectJson() { - val result = SmsManager.parseParams("[]", json) - assertTrue(result is SmsManager.ParseResult.Error) - val error = result as SmsManager.ParseResult.Error - assertEquals("INVALID_REQUEST: expected JSON object", error.error) - } - - @Test - fun parseParamsRejectsMissingTo() { - val result = SmsManager.parseParams("{\"message\":\"Hi\"}", json) - assertTrue(result is SmsManager.ParseResult.Error) - val error = result as SmsManager.ParseResult.Error - assertEquals("INVALID_REQUEST: 'to' phone number required", error.error) - assertEquals("Hi", error.message) - } - - @Test - fun parseParamsRejectsMissingMessage() { - val result = SmsManager.parseParams("{\"to\":\"+1234\"}", json) - assertTrue(result is SmsManager.ParseResult.Error) - val error = result as SmsManager.ParseResult.Error - assertEquals("INVALID_REQUEST: 'message' text required", error.error) - assertEquals("+1234", error.to) - } - - @Test - fun parseParamsTrimsToField() { - val result = SmsManager.parseParams("{\"to\":\" +1555 \",\"message\":\"Hello\"}", json) - assertTrue(result is SmsManager.ParseResult.Ok) - val ok = result as SmsManager.ParseResult.Ok - assertEquals("+1555", ok.params.to) - assertEquals("Hello", ok.params.message) - } - - @Test - fun buildPayloadJsonEscapesFields() { - val payload = SmsManager.buildPayloadJson( - json = json, - ok = false, - to = "+1\"23", - error = "SMS_SEND_FAILED: \"nope\"", - ) - val parsed = json.parseToJsonElement(payload).jsonObject - assertEquals("false", parsed["ok"]?.jsonPrimitive?.content) - assertEquals("+1\"23", parsed["to"]?.jsonPrimitive?.content) - assertEquals("SMS_SEND_FAILED: \"nope\"", parsed["error"]?.jsonPrimitive?.content) - } - - @Test - fun buildSendPlanUsesMultipartWhenMultipleParts() { - val plan = SmsManager.buildSendPlan("hello") { listOf("a", "b") } - assertTrue(plan.useMultipart) - assertEquals(listOf("a", "b"), plan.parts) - } - - @Test - fun buildSendPlanFallsBackToSinglePartWhenDividerEmpty() { - val plan = SmsManager.buildSendPlan("hello") { emptyList() } - assertFalse(plan.useMultipart) - assertEquals(listOf("hello"), plan.parts) - } -} diff --git a/apps/android/app/src/test/java/ai/openclaw/android/protocol/OpenClawCanvasA2UIActionTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/protocol/OpenClawCanvasA2UIActionTest.kt deleted file mode 100644 index c767d2eb910..00000000000 --- a/apps/android/app/src/test/java/ai/openclaw/android/protocol/OpenClawCanvasA2UIActionTest.kt +++ /dev/null @@ -1,49 +0,0 @@ -package ai.openclaw.android.protocol - -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.jsonObject -import org.junit.Assert.assertEquals -import org.junit.Test - -class OpenClawCanvasA2UIActionTest { - @Test - fun extractActionNameAcceptsNameOrAction() { - val nameObj = Json.parseToJsonElement("{\"name\":\"Hello\"}").jsonObject - assertEquals("Hello", OpenClawCanvasA2UIAction.extractActionName(nameObj)) - - val actionObj = Json.parseToJsonElement("{\"action\":\"Wave\"}").jsonObject - assertEquals("Wave", OpenClawCanvasA2UIAction.extractActionName(actionObj)) - - val fallbackObj = - Json.parseToJsonElement("{\"name\":\" \",\"action\":\"Fallback\"}").jsonObject - assertEquals("Fallback", OpenClawCanvasA2UIAction.extractActionName(fallbackObj)) - } - - @Test - fun formatAgentMessageMatchesSharedSpec() { - val msg = - OpenClawCanvasA2UIAction.formatAgentMessage( - actionName = "Get Weather", - sessionKey = "main", - surfaceId = "main", - sourceComponentId = "btnWeather", - host = "Peter’s iPad", - instanceId = "ipad16,6", - contextJson = "{\"city\":\"Vienna\"}", - ) - - assertEquals( - "CANVAS_A2UI action=Get_Weather session=main surface=main component=btnWeather host=Peter_s_iPad instance=ipad16_6 ctx={\"city\":\"Vienna\"} default=update_canvas", - msg, - ) - } - - @Test - fun jsDispatchA2uiStatusIsStable() { - val js = OpenClawCanvasA2UIAction.jsDispatchA2UIActionStatus(actionId = "a1", ok = true, error = null) - assertEquals( - "window.dispatchEvent(new CustomEvent('openclaw:a2ui-action-status', { detail: { id: \"a1\", ok: true, error: \"\" } }));", - js, - ) - } -} diff --git a/apps/android/app/src/test/java/ai/openclaw/android/protocol/OpenClawProtocolConstantsTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/protocol/OpenClawProtocolConstantsTest.kt deleted file mode 100644 index 10ab733ae53..00000000000 --- a/apps/android/app/src/test/java/ai/openclaw/android/protocol/OpenClawProtocolConstantsTest.kt +++ /dev/null @@ -1,35 +0,0 @@ -package ai.openclaw.android.protocol - -import org.junit.Assert.assertEquals -import org.junit.Test - -class OpenClawProtocolConstantsTest { - @Test - fun canvasCommandsUseStableStrings() { - assertEquals("canvas.present", OpenClawCanvasCommand.Present.rawValue) - assertEquals("canvas.hide", OpenClawCanvasCommand.Hide.rawValue) - assertEquals("canvas.navigate", OpenClawCanvasCommand.Navigate.rawValue) - assertEquals("canvas.eval", OpenClawCanvasCommand.Eval.rawValue) - assertEquals("canvas.snapshot", OpenClawCanvasCommand.Snapshot.rawValue) - } - - @Test - fun a2uiCommandsUseStableStrings() { - assertEquals("canvas.a2ui.push", OpenClawCanvasA2UICommand.Push.rawValue) - assertEquals("canvas.a2ui.pushJSONL", OpenClawCanvasA2UICommand.PushJSONL.rawValue) - assertEquals("canvas.a2ui.reset", OpenClawCanvasA2UICommand.Reset.rawValue) - } - - @Test - fun capabilitiesUseStableStrings() { - assertEquals("canvas", OpenClawCapability.Canvas.rawValue) - assertEquals("camera", OpenClawCapability.Camera.rawValue) - assertEquals("screen", OpenClawCapability.Screen.rawValue) - assertEquals("voiceWake", OpenClawCapability.VoiceWake.rawValue) - } - - @Test - fun screenCommandsUseStableStrings() { - assertEquals("screen.record", OpenClawScreenCommand.Record.rawValue) - } -} diff --git a/apps/android/app/src/test/java/ai/openclaw/android/ui/chat/SessionFiltersTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/ui/chat/SessionFiltersTest.kt deleted file mode 100644 index 8e9e5800095..00000000000 --- a/apps/android/app/src/test/java/ai/openclaw/android/ui/chat/SessionFiltersTest.kt +++ /dev/null @@ -1,35 +0,0 @@ -package ai.openclaw.android.ui.chat - -import ai.openclaw.android.chat.ChatSessionEntry -import org.junit.Assert.assertEquals -import org.junit.Test - -class SessionFiltersTest { - @Test - fun sessionChoicesPreferMainAndRecent() { - val now = 1_700_000_000_000L - val recent1 = now - 2 * 60 * 60 * 1000L - val recent2 = now - 5 * 60 * 60 * 1000L - val stale = now - 26 * 60 * 60 * 1000L - val sessions = - listOf( - ChatSessionEntry(key = "recent-1", updatedAtMs = recent1), - ChatSessionEntry(key = "main", updatedAtMs = stale), - ChatSessionEntry(key = "old-1", updatedAtMs = stale), - ChatSessionEntry(key = "recent-2", updatedAtMs = recent2), - ) - - val result = resolveSessionChoices("main", sessions, mainSessionKey = "main", nowMs = now).map { it.key } - assertEquals(listOf("main", "recent-1", "recent-2"), result) - } - - @Test - fun sessionChoicesIncludeCurrentWhenMissing() { - val now = 1_700_000_000_000L - val recent = now - 10 * 60 * 1000L - val sessions = listOf(ChatSessionEntry(key = "main", updatedAtMs = recent)) - - val result = resolveSessionChoices("custom", sessions, mainSessionKey = "main", nowMs = now).map { it.key } - assertEquals(listOf("main", "custom"), result) - } -} diff --git a/apps/android/app/src/test/java/ai/openclaw/android/voice/TalkDirectiveParserTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/voice/TalkDirectiveParserTest.kt deleted file mode 100644 index 77d62849c6c..00000000000 --- a/apps/android/app/src/test/java/ai/openclaw/android/voice/TalkDirectiveParserTest.kt +++ /dev/null @@ -1,55 +0,0 @@ -package ai.openclaw.android.voice - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test - -class TalkDirectiveParserTest { - @Test - fun parsesDirectiveAndStripsHeader() { - val input = """ - {"voice":"voice-123","once":true} - Hello from talk mode. - """.trimIndent() - val result = TalkDirectiveParser.parse(input) - assertEquals("voice-123", result.directive?.voiceId) - assertEquals(true, result.directive?.once) - assertEquals("Hello from talk mode.", result.stripped.trim()) - } - - @Test - fun ignoresUnknownKeysButReportsThem() { - val input = """ - {"voice":"abc","foo":1,"bar":"baz"} - Hi there. - """.trimIndent() - val result = TalkDirectiveParser.parse(input) - assertEquals("abc", result.directive?.voiceId) - assertTrue(result.unknownKeys.containsAll(listOf("bar", "foo"))) - } - - @Test - fun parsesAlternateKeys() { - val input = """ - {"model_id":"eleven_v3","similarity_boost":0.4,"no_speaker_boost":true,"rate":200} - Speak. - """.trimIndent() - val result = TalkDirectiveParser.parse(input) - assertEquals("eleven_v3", result.directive?.modelId) - assertEquals(0.4, result.directive?.similarity) - assertEquals(false, result.directive?.speakerBoost) - assertEquals(200, result.directive?.rateWpm) - } - - @Test - fun returnsNullWhenNoDirectivePresent() { - val input = """ - {} - Hello. - """.trimIndent() - val result = TalkDirectiveParser.parse(input) - assertNull(result.directive) - assertEquals(input, result.stripped) - } -} diff --git a/apps/android/app/src/test/java/ai/openclaw/android/voice/VoiceWakeCommandExtractorTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/voice/VoiceWakeCommandExtractorTest.kt deleted file mode 100644 index 76b50d8abcd..00000000000 --- a/apps/android/app/src/test/java/ai/openclaw/android/voice/VoiceWakeCommandExtractorTest.kt +++ /dev/null @@ -1,25 +0,0 @@ -package ai.openclaw.android.voice - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Test - -class VoiceWakeCommandExtractorTest { - @Test - fun extractsCommandAfterTriggerWord() { - val res = VoiceWakeCommandExtractor.extractCommand("Claude take a photo", listOf("openclaw", "claude")) - assertEquals("take a photo", res) - } - - @Test - fun extractsCommandWithPunctuation() { - val res = VoiceWakeCommandExtractor.extractCommand("hey openclaw, what's the weather?", listOf("openclaw")) - assertEquals("what's the weather?", res) - } - - @Test - fun returnsNullWhenNoCommandProvided() { - assertNull(VoiceWakeCommandExtractor.extractCommand("claude", listOf("claude"))) - assertNull(VoiceWakeCommandExtractor.extractCommand("hey claude!", listOf("claude"))) - } -} diff --git a/apps/android/build.gradle.kts b/apps/android/build.gradle.kts deleted file mode 100644 index f79902d5615..00000000000 --- a/apps/android/build.gradle.kts +++ /dev/null @@ -1,6 +0,0 @@ -plugins { - id("com.android.application") version "8.13.2" apply false - id("org.jetbrains.kotlin.android") version "2.2.21" apply false - id("org.jetbrains.kotlin.plugin.compose") version "2.2.21" apply false - id("org.jetbrains.kotlin.plugin.serialization") version "2.2.21" apply false -} diff --git a/apps/android/gradle.properties b/apps/android/gradle.properties deleted file mode 100644 index 5f84d966ee8..00000000000 --- a/apps/android/gradle.properties +++ /dev/null @@ -1,5 +0,0 @@ -org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8 --enable-native-access=ALL-UNNAMED -org.gradle.warning.mode=none -android.useAndroidX=true -android.nonTransitiveRClass=true -android.enableR8.fullMode=true diff --git a/apps/android/gradle/wrapper/gradle-wrapper.jar b/apps/android/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index e6441136f3d..00000000000 Binary files a/apps/android/gradle/wrapper/gradle-wrapper.jar and /dev/null differ diff --git a/apps/android/gradle/wrapper/gradle-wrapper.properties b/apps/android/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index 23449a2b543..00000000000 --- a/apps/android/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,7 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip -networkTimeout=10000 -validateDistributionUrl=true -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists diff --git a/apps/android/gradlew b/apps/android/gradlew deleted file mode 100755 index 6e5806dcc24..00000000000 --- a/apps/android/gradlew +++ /dev/null @@ -1,249 +0,0 @@ -#!/bin/sh - -# -# Copyright © 2015-2021 the original authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -############################################################################## -# -# Gradle start up script for POSIX generated by Gradle. -# -# Important for running: -# -# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is -# noncompliant, but you have some other compliant shell such as ksh or -# bash, then to run this script, type that shell name before the whole -# command line, like: -# -# ksh Gradle -# -# Busybox and similar reduced shells will NOT work, because this script -# requires all of these POSIX shell features: -# * functions; -# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», -# «${var#prefix}», «${var%suffix}», and «$( cmd )»; -# * compound commands having a testable exit status, especially «case»; -# * various built-in commands including «command», «set», and «ulimit». -# -# Important for patching: -# -# (2) This script targets any POSIX shell, so it avoids extensions provided -# by Bash, Ksh, etc; in particular arrays are avoided. -# -# The "traditional" practice of packing multiple parameters into a -# space-separated string is a well documented source of bugs and security -# problems, so this is (mostly) avoided, by progressively accumulating -# options in "$@", and eventually passing that to Java. -# -# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, -# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; -# see the in-line comments for details. -# -# There are tweaks for specific operating systems such as AIX, CygWin, -# Darwin, MinGW, and NonStop. -# -# (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt -# within the Gradle project. -# -# You can find Gradle at https://github.com/gradle/gradle/. -# -############################################################################## - -# Attempt to set APP_HOME - -# Resolve links: $0 may be a link -app_path=$0 - -# Need this for daisy-chained symlinks. -while - APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path - [ -h "$app_path" ] -do - ls=$( ls -ld "$app_path" ) - link=${ls#*' -> '} - case $link in #( - /*) app_path=$link ;; #( - *) app_path=$APP_HOME$link ;; - esac -done - -# This is normally unused -# shellcheck disable=SC2034 -APP_BASE_NAME=${0##*/} -# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD=maximum - -warn () { - echo "$*" -} >&2 - -die () { - echo - echo "$*" - echo - exit 1 -} >&2 - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "$( uname )" in #( - CYGWIN* ) cygwin=true ;; #( - Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NONSTOP* ) nonstop=true ;; -esac - -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD=$JAVA_HOME/jre/sh/java - else - JAVACMD=$JAVA_HOME/bin/java - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD=java - if ! command -v java >/dev/null 2>&1 - then - die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -fi - -# Increase the maximum file descriptors if we can. -if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in #( - max*) - # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - MAX_FD=$( ulimit -H -n ) || - warn "Could not query maximum file descriptor limit" - esac - case $MAX_FD in #( - '' | soft) :;; #( - *) - # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - ulimit -n "$MAX_FD" || - warn "Could not set maximum file descriptor limit to $MAX_FD" - esac -fi - -# Collect all arguments for the java command, stacking in reverse order: -# * args from the command line -# * the main class name -# * -classpath -# * -D...appname settings -# * --module-path (only if needed) -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. - -# For Cygwin or MSYS, switch paths to Windows format before running java -if "$cygwin" || "$msys" ; then - APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) - - JAVACMD=$( cygpath --unix "$JAVACMD" ) - - # Now convert the arguments - kludge to limit ourselves to /bin/sh - for arg do - if - case $arg in #( - -*) false ;; # don't mess with options #( - /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath - [ -e "$t" ] ;; #( - *) false ;; - esac - then - arg=$( cygpath --path --ignore --mixed "$arg" ) - fi - # Roll the args list around exactly as many times as the number of - # args, so each arg winds up back in the position where it started, but - # possibly modified. - # - # NB: a `for` loop captures its iteration list before it begins, so - # changing the positional parameters here affects neither the number of - # iterations, nor the values presented in `arg`. - shift # remove old arg - set -- "$@" "$arg" # push replacement arg - done -fi - - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m" "--enable-native-access=ALL-UNNAMED"' - -# Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, -# and any embedded shellness will be escaped. -# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be -# treated as '${Hostname}' itself on the command line. - -set -- \ - "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ - "$@" - -# Stop when "xargs" is not available. -if ! command -v xargs >/dev/null 2>&1 -then - die "xargs is not available" -fi - -# Use "xargs" to parse quoted args. -# -# With -n1 it outputs one arg per line, with the quotes and backslashes removed. -# -# In Bash we could simply go: -# -# readarray ARGS < <( xargs -n1 <<<"$var" ) && -# set -- "${ARGS[@]}" "$@" -# -# but POSIX shell has neither arrays nor command substitution, so instead we -# post-process each arg (as a line of input to sed) to backslash-escape any -# character that might be a shell metacharacter, then use eval to reverse -# that process (while maintaining the separation between arguments), and wrap -# the whole thing up as a single "set" statement. -# -# This will of course break if any of these variables contains a newline or -# an unmatched quote. -# - -eval "set -- $( - printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | - xargs -n1 | - sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | - tr '\n' ' ' - )" '"$@"' - -exec "$JAVACMD" "$@" diff --git a/apps/android/gradlew.bat b/apps/android/gradlew.bat deleted file mode 100644 index 6f8e9066584..00000000000 --- a/apps/android/gradlew.bat +++ /dev/null @@ -1,92 +0,0 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" "--enable-native-access=ALL-UNNAMED" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega diff --git a/apps/android/settings.gradle.kts b/apps/android/settings.gradle.kts deleted file mode 100644 index b3b43a44550..00000000000 --- a/apps/android/settings.gradle.kts +++ /dev/null @@ -1,18 +0,0 @@ -pluginManagement { - repositories { - google() - mavenCentral() - gradlePluginPortal() - } -} - -dependencyResolutionManagement { - repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) - repositories { - google() - mavenCentral() - } -} - -rootProject.name = "OpenClawNodeAndroid" -include(":app") diff --git a/apps/ios/.swiftlint.yml b/apps/ios/.swiftlint.yml deleted file mode 100644 index 23db4515968..00000000000 --- a/apps/ios/.swiftlint.yml +++ /dev/null @@ -1,9 +0,0 @@ -parent_config: ../../.swiftlint.yml - -included: - - Sources - - ../shared/ClawdisNodeKit/Sources - -type_body_length: - warning: 900 - error: 1300 diff --git a/apps/ios/Config/Signing.xcconfig b/apps/ios/Config/Signing.xcconfig deleted file mode 100644 index e0afd46aa7e..00000000000 --- a/apps/ios/Config/Signing.xcconfig +++ /dev/null @@ -1,18 +0,0 @@ -// Shared iOS signing defaults for local development + CI. -OPENCLAW_IOS_DEFAULT_TEAM = Y5PE65HELJ -OPENCLAW_IOS_SELECTED_TEAM = $(OPENCLAW_IOS_DEFAULT_TEAM) -OPENCLAW_APP_BUNDLE_ID = ai.openclaw.ios -OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.ios.watchkitapp -OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.ios.watchkitapp.extension - -// Local contributors can override this by running scripts/ios-configure-signing.sh. -// Keep include after defaults: xcconfig is evaluated top-to-bottom. -#include? "../.local-signing.xcconfig" -#include? "../LocalSigning.xcconfig" - -CODE_SIGN_STYLE = Automatic -CODE_SIGN_IDENTITY = Apple Development -DEVELOPMENT_TEAM = $(OPENCLAW_IOS_SELECTED_TEAM) - -// Let Xcode manage provisioning for the selected local team. -PROVISIONING_PROFILE_SPECIFIER = diff --git a/apps/ios/LocalSigning.xcconfig.example b/apps/ios/LocalSigning.xcconfig.example deleted file mode 100644 index bfa610fb350..00000000000 --- a/apps/ios/LocalSigning.xcconfig.example +++ /dev/null @@ -1,14 +0,0 @@ -// Copy to LocalSigning.xcconfig for personal local signing overrides. -// This file is only an example and should stay committed. - -OPENCLAW_CODE_SIGN_STYLE = Automatic -OPENCLAW_DEVELOPMENT_TEAM = P5Z8X89DJL - -OPENCLAW_APP_BUNDLE_ID = ai.openclaw.ios.test.mariano -OPENCLAW_SHARE_BUNDLE_ID = ai.openclaw.ios.test.mariano.share -OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.ios.test.mariano.watchkitapp -OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.ios.test.mariano.watchkitapp.extension - -// Leave empty with automatic signing. -OPENCLAW_APP_PROFILE = -OPENCLAW_SHARE_PROFILE = diff --git a/apps/ios/README.md b/apps/ios/README.md deleted file mode 100644 index c7c501fcbff..00000000000 --- a/apps/ios/README.md +++ /dev/null @@ -1,141 +0,0 @@ -# OpenClaw iOS (Super Alpha) - -NO TEST FLIGHT AVAILABLE AT THIS POINT - -This iPhone app is super-alpha and internal-use only. It connects to an OpenClaw Gateway as a `role: node`. - -## Distribution Status - -NO TEST FLIGHT AVAILABLE AT THIS POINT - -- Current distribution: local/manual deploy from source via Xcode. -- App Store flow is not part of the current internal development path. - -## Super-Alpha Disclaimer - -- Breaking changes are expected. -- UI and onboarding flows can change without migration guarantees. -- Foreground use is the only reliable mode right now. -- Treat this build as sensitive while permissions and background behavior are still being hardened. - -## Exact Xcode Manual Deploy Flow - -1. Prereqs: - - Xcode 16+ - - `pnpm` - - `xcodegen` - - Apple Development signing set up in Xcode -2. From repo root: - -```bash -pnpm install -./scripts/ios-configure-signing.sh -cd apps/ios -xcodegen generate -open OpenClaw.xcodeproj -``` - -3. In Xcode: - - Scheme: `OpenClaw` - - Destination: connected iPhone (recommended for real behavior) - - Build configuration: `Debug` - - Run (`Product` -> `Run`) -4. If signing fails on a personal team: - - Use unique local bundle IDs via `apps/ios/LocalSigning.xcconfig`. - - Start from `apps/ios/LocalSigning.xcconfig.example`. - -Shortcut command (same flow + open project): - -```bash -pnpm ios:open -``` - -## APNs Expectations For Local/Manual Builds - -- The app calls `registerForRemoteNotifications()` at launch. -- `apps/ios/Sources/OpenClaw.entitlements` sets `aps-environment` to `development`. -- APNs token registration to gateway happens only after gateway connection (`push.apns.register`). -- Your selected team/profile must support Push Notifications for the app bundle ID you are signing. -- If push capability or provisioning is wrong, APNs registration fails at runtime (check Xcode logs for `APNs registration failed`). -- Debug builds register as APNs sandbox; Release builds use production. - -## What Works Now (Concrete) - -- Pairing via setup code flow (`/pair` then `/pair approve` in Telegram). -- Gateway connection via discovery or manual host/port with TLS fingerprint trust prompt. -- Chat + Talk surfaces through the operator gateway session. -- iPhone node commands in foreground: camera snap/clip, canvas present/navigate/eval/snapshot, screen record, location, contacts, calendar, reminders, photos, motion, local notifications. -- Share extension deep-link forwarding into the connected gateway session. - -## Location Automation Use Case (Testing) - -Use this for automation signals ("I moved", "I arrived", "I left"), not as a keep-awake mechanism. - -- Product intent: - - movement-aware automations driven by iOS location events - - example: arrival/exit geofence, significant movement, visit detection -- Non-goal: - - continuous GPS polling just to keep the app alive - -Test path to include in QA runs: - -1. Enable location permission in app: - - set `Always` permission - - verify background location capability is enabled in the build profile -2. Background the app and trigger movement: - - walk/drive enough for a significant location update, or cross a configured geofence -3. Validate gateway side effects: - - node reconnect/wake if needed - - expected location/movement event arrives at gateway - - automation trigger executes once (no duplicate storm) -4. Validate resource impact: - - no sustained high thermal state - - no excessive background battery drain over a short observation window - -Pass criteria: - -- movement events are delivered reliably enough for automation UX -- no location-driven reconnect spam loops -- app remains stable after repeated background/foreground transitions - -## Known Issues / Limitations / Problems - -- Foreground-first: iOS can suspend sockets in background; reconnect recovery is still being tuned. -- Background command limits are strict: `canvas.*`, `camera.*`, `screen.*`, and `talk.*` are blocked when backgrounded. -- Background location requires `Always` location permission. -- Pairing/auth errors intentionally pause reconnect loops until a human fixes auth/pairing state. -- Voice Wake and Talk contend for the same microphone; Talk suppresses wake capture while active. -- APNs reliability depends on local signing/provisioning/topic alignment. -- Expect rough UX edges and occasional reconnect churn during active development. - -## Current In-Progress Workstream - -Automatic wake/reconnect hardening: - -- improve wake/resume behavior across scene transitions -- reduce dead-socket states after background -> foreground -- tighten node/operator session reconnect coordination -- reduce manual recovery steps after transient network failures - -## Debugging Checklist - -1. Confirm build/signing baseline: - - regenerate project (`xcodegen generate`) - - verify selected team + bundle IDs -2. In app `Settings -> Gateway`: - - confirm status text, server, and remote address - - verify whether status shows pairing/auth gating -3. If pairing is required: - - run `/pair approve` from Telegram, then reconnect -4. If discovery is flaky: - - enable `Discovery Debug Logs` - - inspect `Settings -> Gateway -> Discovery Logs` -5. If network path is unclear: - - switch to manual host/port + TLS in Gateway Advanced settings -6. In Xcode console, filter for subsystem/category signals: - - `ai.openclaw.ios` - - `GatewayDiag` - - `APNs registration failed` -7. Validate background expectations: - - repro in foreground first - - then test background transitions and confirm reconnect on return diff --git a/apps/ios/ShareExtension/Info.plist b/apps/ios/ShareExtension/Info.plist deleted file mode 100644 index 0656afbf2d7..00000000000 --- a/apps/ios/ShareExtension/Info.plist +++ /dev/null @@ -1,45 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - OpenClaw Share - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - XPC! - CFBundleShortVersionString - 2026.2.21 - CFBundleVersion - 20260220 - NSExtension - - NSExtensionAttributes - - NSExtensionActivationRule - - NSExtensionActivationSupportsImageWithMaxCount - 10 - NSExtensionActivationSupportsMovieWithMaxCount - 1 - NSExtensionActivationSupportsText - - NSExtensionActivationSupportsWebURLWithMaxCount - 1 - - - NSExtensionPointIdentifier - com.apple.share-services - NSExtensionPrincipalClass - $(PRODUCT_MODULE_NAME).ShareViewController - - - diff --git a/apps/ios/ShareExtension/ShareViewController.swift b/apps/ios/ShareExtension/ShareViewController.swift deleted file mode 100644 index 1181641e330..00000000000 --- a/apps/ios/ShareExtension/ShareViewController.swift +++ /dev/null @@ -1,548 +0,0 @@ -import Foundation -import OpenClawKit -import os -import UIKit -import UniformTypeIdentifiers - -final class ShareViewController: UIViewController { - private struct ShareAttachment: Codable { - var type: String - var mimeType: String - var fileName: String - var content: String - } - - private struct ExtractedShareContent { - var payload: SharedContentPayload - var attachments: [ShareAttachment] - } - - private let logger = Logger(subsystem: "ai.openclaw.ios", category: "ShareExtension") - private var statusLabel: UILabel? - private let draftTextView = UITextView() - private let sendButton = UIButton(type: .system) - private let cancelButton = UIButton(type: .system) - private var didPrepareDraft = false - private var isSending = false - private var pendingAttachments: [ShareAttachment] = [] - - override func viewDidLoad() { - super.viewDidLoad() - self.preferredContentSize = CGSize(width: UIScreen.main.bounds.width, height: 420) - self.setupUI() - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - guard !self.didPrepareDraft else { return } - self.didPrepareDraft = true - Task { await self.prepareDraft() } - } - - private func setupUI() { - self.view.backgroundColor = .systemBackground - - self.draftTextView.translatesAutoresizingMaskIntoConstraints = false - self.draftTextView.font = .preferredFont(forTextStyle: .body) - self.draftTextView.backgroundColor = UIColor.secondarySystemBackground - self.draftTextView.layer.cornerRadius = 10 - self.draftTextView.textContainerInset = UIEdgeInsets(top: 12, left: 10, bottom: 12, right: 10) - - self.sendButton.translatesAutoresizingMaskIntoConstraints = false - self.sendButton.setTitle("Send to OpenClaw", for: .normal) - self.sendButton.titleLabel?.font = .preferredFont(forTextStyle: .headline) - self.sendButton.addTarget(self, action: #selector(self.handleSendTap), for: .touchUpInside) - self.sendButton.isEnabled = false - - self.cancelButton.translatesAutoresizingMaskIntoConstraints = false - self.cancelButton.setTitle("Cancel", for: .normal) - self.cancelButton.addTarget(self, action: #selector(self.handleCancelTap), for: .touchUpInside) - - let buttons = UIStackView(arrangedSubviews: [self.cancelButton, self.sendButton]) - buttons.translatesAutoresizingMaskIntoConstraints = false - buttons.axis = .horizontal - buttons.alignment = .fill - buttons.distribution = .fillEqually - buttons.spacing = 12 - - self.view.addSubview(self.draftTextView) - self.view.addSubview(buttons) - - NSLayoutConstraint.activate([ - self.draftTextView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 14), - self.draftTextView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 14), - self.draftTextView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -14), - self.draftTextView.bottomAnchor.constraint(equalTo: buttons.topAnchor, constant: -12), - - buttons.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 14), - buttons.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -14), - buttons.bottomAnchor.constraint(equalTo: self.view.keyboardLayoutGuide.topAnchor, constant: -8), - buttons.heightAnchor.constraint(equalToConstant: 44), - ]) - } - - private func prepareDraft() async { - let traceId = UUID().uuidString - ShareGatewayRelaySettings.saveLastEvent("Share opened.") - self.showStatus("Preparing share…") - self.logger.info("share begin trace=\(traceId, privacy: .public)") - let extracted = await self.extractSharedContent() - let payload = extracted.payload - self.pendingAttachments = extracted.attachments - self.logger.info( - "share payload trace=\(traceId, privacy: .public) titleChars=\(payload.title?.count ?? 0) textChars=\(payload.text?.count ?? 0) hasURL=\(payload.url != nil) imageAttachments=\(self.pendingAttachments.count)" - ) - let message = self.composeDraft(from: payload) - await MainActor.run { - self.draftTextView.text = message - self.sendButton.isEnabled = true - self.draftTextView.becomeFirstResponder() - } - if message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - ShareGatewayRelaySettings.saveLastEvent("Share ready: waiting for message input.") - self.showStatus("Add a message, then tap Send.") - } else { - ShareGatewayRelaySettings.saveLastEvent("Share ready: draft prepared.") - self.showStatus("Edit text, then tap Send.") - } - } - - @objc - private func handleSendTap() { - guard !self.isSending else { return } - Task { await self.sendCurrentDraft() } - } - - @objc - private func handleCancelTap() { - self.extensionContext?.completeRequest(returningItems: nil) - } - - private func sendCurrentDraft() async { - let message = await MainActor.run { self.draftTextView.text ?? "" } - let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - ShareGatewayRelaySettings.saveLastEvent("Share blocked: message is empty.") - self.showStatus("Message is empty.") - return - } - - await MainActor.run { - self.isSending = true - self.sendButton.isEnabled = false - self.cancelButton.isEnabled = false - } - self.showStatus("Sending to OpenClaw gateway…") - ShareGatewayRelaySettings.saveLastEvent("Sending to gateway…") - do { - try await self.sendMessageToGateway(trimmed, attachments: self.pendingAttachments) - ShareGatewayRelaySettings.saveLastEvent( - "Sent to gateway (\(trimmed.count) chars, \(self.pendingAttachments.count) attachment(s)).") - self.showStatus("Sent to OpenClaw.") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { - self.extensionContext?.completeRequest(returningItems: nil) - } - } catch { - self.logger.error("share send failed reason=\(error.localizedDescription, privacy: .public)") - ShareGatewayRelaySettings.saveLastEvent("Send failed: \(error.localizedDescription)") - self.showStatus("Send failed: \(error.localizedDescription)") - await MainActor.run { - self.isSending = false - self.sendButton.isEnabled = true - self.cancelButton.isEnabled = true - } - } - } - - private func sendMessageToGateway(_ message: String, attachments: [ShareAttachment]) async throws { - guard let config = ShareGatewayRelaySettings.loadConfig() else { - throw NSError( - domain: "OpenClawShare", - code: 10, - userInfo: [NSLocalizedDescriptionKey: "OpenClaw is not connected to a gateway yet."]) - } - guard let url = URL(string: config.gatewayURLString) else { - throw NSError( - domain: "OpenClawShare", - code: 11, - userInfo: [NSLocalizedDescriptionKey: "Invalid saved gateway URL."]) - } - - let gateway = GatewayNodeSession() - defer { - Task { await gateway.disconnect() } - } - let makeOptions: (String) -> GatewayConnectOptions = { clientId in - GatewayConnectOptions( - role: "node", - scopes: [], - caps: [], - commands: [], - permissions: [:], - clientId: clientId, - clientMode: "node", - clientDisplayName: "OpenClaw Share", - includeDeviceIdentity: false) - } - - do { - try await gateway.connect( - url: url, - token: config.token, - password: config.password, - connectOptions: makeOptions("openclaw-ios"), - sessionBox: nil, - onConnected: {}, - onDisconnected: { _ in }, - onInvoke: { req in - BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError( - code: .invalidRequest, - message: "share extension does not support node invoke")) - }) - } catch { - let expectsLegacyClientId = self.shouldRetryWithLegacyClientId(error) - guard expectsLegacyClientId else { throw error } - try await gateway.connect( - url: url, - token: config.token, - password: config.password, - connectOptions: makeOptions("moltbot-ios"), - sessionBox: nil, - onConnected: {}, - onDisconnected: { _ in }, - onInvoke: { req in - BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError( - code: .invalidRequest, - message: "share extension does not support node invoke")) - }) - } - - struct AgentRequestPayload: Codable { - var message: String - var sessionKey: String? - var thinking: String - var deliver: Bool - var attachments: [ShareAttachment]? - var receipt: Bool - var receiptText: String? - var to: String? - var channel: String? - var timeoutSeconds: Int? - var key: String? - } - - let deliveryChannel = config.deliveryChannel?.trimmingCharacters(in: .whitespacesAndNewlines) - let deliveryTo = config.deliveryTo?.trimmingCharacters(in: .whitespacesAndNewlines) - let canDeliverToRoute = (deliveryChannel?.isEmpty == false) && (deliveryTo?.isEmpty == false) - - let params = AgentRequestPayload( - message: message, - sessionKey: config.sessionKey, - thinking: "low", - deliver: canDeliverToRoute, - attachments: attachments.isEmpty ? nil : attachments, - receipt: canDeliverToRoute, - receiptText: canDeliverToRoute ? "Just received your iOS share + request, working on it." : nil, - to: canDeliverToRoute ? deliveryTo : nil, - channel: canDeliverToRoute ? deliveryChannel : nil, - timeoutSeconds: nil, - key: UUID().uuidString) - let data = try JSONEncoder().encode(params) - guard let json = String(data: data, encoding: .utf8) else { - throw NSError( - domain: "OpenClawShare", - code: 12, - userInfo: [NSLocalizedDescriptionKey: "Failed to encode chat payload."]) - } - struct NodeEventParams: Codable { - var event: String - var payloadJSON: String - } - let eventData = try JSONEncoder().encode(NodeEventParams(event: "agent.request", payloadJSON: json)) - guard let nodeEventParams = String(data: eventData, encoding: .utf8) else { - throw NSError( - domain: "OpenClawShare", - code: 13, - userInfo: [NSLocalizedDescriptionKey: "Failed to encode node event payload."]) - } - _ = try await gateway.request(method: "node.event", paramsJSON: nodeEventParams, timeoutSeconds: 25) - } - - private func shouldRetryWithLegacyClientId(_ error: Error) -> Bool { - if let gatewayError = error as? GatewayResponseError { - let code = gatewayError.code.lowercased() - let message = gatewayError.message.lowercased() - let pathValue = (gatewayError.details["path"]?.value as? String)?.lowercased() ?? "" - let mentionsClientIdPath = - message.contains("/client/id") || message.contains("client id") - || pathValue.contains("/client/id") - let isInvalidConnectParams = - (code.contains("invalid") && code.contains("connect")) - || message.contains("invalid connect params") - if isInvalidConnectParams && mentionsClientIdPath { - return true - } - } - - let text = error.localizedDescription.lowercased() - return text.contains("invalid connect params") - && (text.contains("/client/id") || text.contains("client id")) - } - - private func showStatus(_ text: String) { - DispatchQueue.main.async { - let label: UILabel - if let existing = self.statusLabel { - label = existing - } else { - let newLabel = UILabel() - newLabel.translatesAutoresizingMaskIntoConstraints = false - newLabel.numberOfLines = 0 - newLabel.textAlignment = .center - newLabel.font = .preferredFont(forTextStyle: .body) - newLabel.textColor = .label - newLabel.backgroundColor = UIColor.systemBackground.withAlphaComponent(0.92) - newLabel.layer.cornerRadius = 12 - newLabel.clipsToBounds = true - newLabel.layoutMargins = UIEdgeInsets(top: 12, left: 14, bottom: 12, right: 14) - self.view.addSubview(newLabel) - NSLayoutConstraint.activate([ - newLabel.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 18), - newLabel.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -18), - newLabel.bottomAnchor.constraint(equalTo: self.sendButton.topAnchor, constant: -10), - ]) - self.statusLabel = newLabel - label = newLabel - } - label.text = " \(text) " - } - } - - private func composeDraft(from payload: SharedContentPayload) -> String { - var lines: [String] = [] - let title = self.sanitizeDraftFragment(payload.title) - let text = self.sanitizeDraftFragment(payload.text) - let url = payload.url?.absoluteString.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - - if let title, !title.isEmpty { lines.append(title) } - if let text, !text.isEmpty { lines.append(text) } - if !url.isEmpty { lines.append(url) } - - return lines.joined(separator: "\n\n") - } - - private func sanitizeDraftFragment(_ raw: String?) -> String? { - guard let raw else { return nil } - let banned = [ - "shared from ios.", - "text:", - "shared attachment(s):", - "please help me with this.", - "please help me with this.w", - ] - let cleanedLines = raw - .components(separatedBy: .newlines) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { line in - guard !line.isEmpty else { return false } - let lowered = line.lowercased() - return !banned.contains { lowered == $0 || lowered.hasPrefix($0) } - } - let cleaned = cleanedLines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) - return cleaned.isEmpty ? nil : cleaned - } - - private func extractSharedContent() async -> ExtractedShareContent { - guard let items = self.extensionContext?.inputItems as? [NSExtensionItem] else { - return ExtractedShareContent( - payload: SharedContentPayload(title: nil, url: nil, text: nil), - attachments: []) - } - - var title: String? - var sharedURL: URL? - var sharedText: String? - var imageCount = 0 - var videoCount = 0 - var fileCount = 0 - var unknownCount = 0 - var attachments: [ShareAttachment] = [] - let maxImageAttachments = 3 - - for item in items { - if title == nil { - title = item.attributedTitle?.string ?? item.attributedContentText?.string - } - - for provider in item.attachments ?? [] { - if sharedURL == nil { - sharedURL = await self.loadURL(from: provider) - } - - if sharedText == nil { - sharedText = await self.loadText(from: provider) - } - - if provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) { - imageCount += 1 - if attachments.count < maxImageAttachments, - let attachment = await self.loadImageAttachment(from: provider, index: attachments.count) - { - attachments.append(attachment) - } - } else if provider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) { - videoCount += 1 - } else if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { - fileCount += 1 - } else { - unknownCount += 1 - } - - } - } - - _ = imageCount - _ = videoCount - _ = fileCount - _ = unknownCount - - return ExtractedShareContent( - payload: SharedContentPayload(title: title, url: sharedURL, text: sharedText), - attachments: attachments) - } - - private func loadImageAttachment(from provider: NSItemProvider, index: Int) async -> ShareAttachment? { - let imageUTI = self.preferredImageTypeIdentifier(from: provider) ?? UTType.image.identifier - guard let rawData = await self.loadDataValue(from: provider, typeIdentifier: imageUTI) else { - return nil - } - - let maxBytes = 5_000_000 - guard let image = UIImage(data: rawData), - let data = self.normalizedJPEGData(from: image, maxBytes: maxBytes) - else { - return nil - } - - return ShareAttachment( - type: "image", - mimeType: "image/jpeg", - fileName: "shared-image-\(index + 1).jpg", - content: data.base64EncodedString()) - } - - private func preferredImageTypeIdentifier(from provider: NSItemProvider) -> String? { - for identifier in provider.registeredTypeIdentifiers { - guard let utType = UTType(identifier) else { continue } - if utType.conforms(to: .image) { - return identifier - } - } - return nil - } - - private func normalizedJPEGData(from image: UIImage, maxBytes: Int) -> Data? { - var quality: CGFloat = 0.9 - while quality >= 0.4 { - if let data = image.jpegData(compressionQuality: quality), data.count <= maxBytes { - return data - } - quality -= 0.1 - } - guard let fallback = image.jpegData(compressionQuality: 0.35) else { return nil } - if fallback.count <= maxBytes { return fallback } - return nil - } - - private func loadURL(from provider: NSItemProvider) async -> URL? { - if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) { - if let url = await self.loadURLValue( - from: provider, - typeIdentifier: UTType.url.identifier) - { - return url - } - } - - if provider.hasItemConformingToTypeIdentifier(UTType.text.identifier) { - if let text = await self.loadTextValue(from: provider, typeIdentifier: UTType.text.identifier), - let url = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines)), - url.scheme != nil - { - return url - } - } - - return nil - } - - private func loadText(from provider: NSItemProvider) async -> String? { - if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) { - if let text = await self.loadTextValue(from: provider, typeIdentifier: UTType.plainText.identifier) { - return text - } - } - - if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) { - if let url = await self.loadURLValue(from: provider, typeIdentifier: UTType.url.identifier) { - return url.absoluteString - } - } - - return nil - } - - private func loadURLValue(from provider: NSItemProvider, typeIdentifier: String) async -> URL? { - await withCheckedContinuation { continuation in - provider.loadItem(forTypeIdentifier: typeIdentifier, options: nil) { item, _ in - if let url = item as? URL { - continuation.resume(returning: url) - return - } - if let str = item as? String, let url = URL(string: str) { - continuation.resume(returning: url) - return - } - if let ns = item as? NSString, let url = URL(string: ns as String) { - continuation.resume(returning: url) - return - } - continuation.resume(returning: nil) - } - } - } - - private func loadTextValue(from provider: NSItemProvider, typeIdentifier: String) async -> String? { - await withCheckedContinuation { continuation in - provider.loadItem(forTypeIdentifier: typeIdentifier, options: nil) { item, _ in - if let text = item as? String { - continuation.resume(returning: text) - return - } - if let text = item as? NSString { - continuation.resume(returning: text as String) - return - } - if let text = item as? NSAttributedString { - continuation.resume(returning: text.string) - return - } - continuation.resume(returning: nil) - } - } - } - - private func loadDataValue(from provider: NSItemProvider, typeIdentifier: String) async -> Data? { - await withCheckedContinuation { continuation in - provider.loadDataRepresentation(forTypeIdentifier: typeIdentifier) { data, _ in - continuation.resume(returning: data) - } - } - } -} diff --git a/apps/ios/Signing.xcconfig b/apps/ios/Signing.xcconfig deleted file mode 100644 index f942fc0224f..00000000000 --- a/apps/ios/Signing.xcconfig +++ /dev/null @@ -1,17 +0,0 @@ -// Default signing values for shared/repo builds. -// Auto-selected local team overrides live in .local-signing.xcconfig (git-ignored). -// Manual local overrides can go in LocalSigning.xcconfig (git-ignored). - -OPENCLAW_CODE_SIGN_STYLE = Manual -OPENCLAW_DEVELOPMENT_TEAM = Y5PE65HELJ - -OPENCLAW_APP_BUNDLE_ID = ai.openclaw.ios -OPENCLAW_SHARE_BUNDLE_ID = ai.openclaw.ios.share - -OPENCLAW_APP_PROFILE = ai.openclaw.ios Development -OPENCLAW_SHARE_PROFILE = ai.openclaw.ios.share Development - -// Keep local includes after defaults: xcconfig is evaluated top-to-bottom, -// so later assignments in local files override the defaults above. -#include? ".local-signing.xcconfig" -#include? "LocalSigning.xcconfig" diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/100.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/100.png deleted file mode 100644 index 22a04c9f22a..00000000000 Binary files a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/100.png and /dev/null differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/102.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/102.png deleted file mode 100644 index ff8397de297..00000000000 Binary files a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/102.png and /dev/null differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/1024.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/1024.png deleted file mode 100644 index ecea78807d8..00000000000 Binary files a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/1024.png and /dev/null differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/108.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/108.png deleted file mode 100644 index a6888456dfa..00000000000 Binary files a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/108.png and /dev/null differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/114.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/114.png deleted file mode 100644 index 20e9ea1a557..00000000000 Binary files a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/114.png and /dev/null differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/120.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/120.png deleted file mode 100644 index 154836b43a2..00000000000 Binary files a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/120.png and /dev/null differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/172.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/172.png deleted file mode 100644 index a66c0132393..00000000000 Binary files a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/172.png and /dev/null differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/180.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/180.png deleted file mode 100644 index d01e83d8ccc..00000000000 Binary files a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/180.png and /dev/null differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/196.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/196.png deleted file mode 100644 index b7989e43d84..00000000000 Binary files a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/196.png and /dev/null differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/216.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/216.png deleted file mode 100644 index 4dfb94abefb..00000000000 Binary files a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/216.png and /dev/null differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/234.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/234.png deleted file mode 100644 index c0da9ae922c..00000000000 Binary files a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/234.png and /dev/null differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/258.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/258.png deleted file mode 100644 index dbfb75050bd..00000000000 Binary files a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/258.png and /dev/null differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/29.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/29.png deleted file mode 100644 index f4d57311481..00000000000 Binary files a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/29.png and /dev/null differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/40.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/40.png deleted file mode 100644 index 87a14602e3c..00000000000 Binary files a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/40.png and /dev/null differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/48.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/48.png deleted file mode 100644 index f66c2ded344..00000000000 Binary files a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/48.png and /dev/null differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/55.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/55.png deleted file mode 100644 index 0730736fca0..00000000000 Binary files a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/55.png and /dev/null differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/57.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/57.png deleted file mode 100644 index f8946de39b3..00000000000 Binary files a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/57.png and /dev/null differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/58.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/58.png deleted file mode 100644 index 92ae2f999d9..00000000000 Binary files a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/58.png and /dev/null differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/60.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/60.png deleted file mode 100644 index 03231a71d18..00000000000 Binary files a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/60.png and /dev/null differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/66.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/66.png deleted file mode 100644 index 834c6b0987f..00000000000 Binary files a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/66.png and /dev/null differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/80.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/80.png deleted file mode 100644 index 485a1aae7bd..00000000000 Binary files a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/80.png and /dev/null differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/87.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/87.png deleted file mode 100644 index 61da8b5fd79..00000000000 Binary files a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/87.png and /dev/null differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/88.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/88.png deleted file mode 100644 index f47fb37b5fc..00000000000 Binary files a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/88.png and /dev/null differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/92.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/92.png deleted file mode 100644 index 67a10a48458..00000000000 Binary files a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/92.png and /dev/null differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 922e8c6d731..00000000000 --- a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1 +0,0 @@ -{"images":[{"size":"60x60","expected-size":"180","filename":"180.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"40x40","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"60x60","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"57x57","expected-size":"57","filename":"57.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"57x57","expected-size":"114","filename":"114.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"60","filename":"60.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"1024x1024","filename":"1024.png","expected-size":"1024","idiom":"ios-marketing","folder":"Assets.xcassets/AppIcon.appiconset/","scale":"1x"},{"idiom":"watch","filename":"172.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"86x86","expected-size":"172","role":"quickLook"},{"idiom":"watch","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"40x40","expected-size":"80","role":"appLauncher"},{"idiom":"watch","filename":"88.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"40mm","scale":"2x","size":"44x44","expected-size":"88","role":"appLauncher"},{"idiom":"watch","filename":"102.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"51x51","expected-size":"102","role":"appLauncher"},{"idiom":"watch","filename":"108.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"49mm","scale":"2x","size":"54x54","expected-size":"108","role":"appLauncher"},{"idiom":"watch","filename":"92.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"41mm","scale":"2x","size":"46x46","expected-size":"92","role":"appLauncher"},{"idiom":"watch","filename":"100.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"44mm","scale":"2x","size":"50x50","expected-size":"100","role":"appLauncher"},{"idiom":"watch","filename":"196.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"42mm","scale":"2x","size":"98x98","expected-size":"196","role":"quickLook"},{"idiom":"watch","filename":"216.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"44mm","scale":"2x","size":"108x108","expected-size":"216","role":"quickLook"},{"idiom":"watch","filename":"234.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"117x117","expected-size":"234","role":"quickLook"},{"idiom":"watch","filename":"258.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"49mm","scale":"2x","size":"129x129","expected-size":"258","role":"quickLook"},{"idiom":"watch","filename":"48.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"24x24","expected-size":"48","role":"notificationCenter"},{"idiom":"watch","filename":"55.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"42mm","scale":"2x","size":"27.5x27.5","expected-size":"55","role":"notificationCenter"},{"idiom":"watch","filename":"66.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"33x33","expected-size":"66","role":"notificationCenter"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch","role":"companionSettings","scale":"3x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch","role":"companionSettings","scale":"2x"},{"size":"1024x1024","expected-size":"1024","filename":"1024.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch-marketing","scale":"1x"}]} \ No newline at end of file diff --git a/apps/ios/Sources/Calendar/CalendarService.swift b/apps/ios/Sources/Calendar/CalendarService.swift deleted file mode 100644 index 94b2d9ea3f5..00000000000 --- a/apps/ios/Sources/Calendar/CalendarService.swift +++ /dev/null @@ -1,135 +0,0 @@ -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 = EventKitAuthorization.allowsRead(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 = EventKitAuthorization.allowsWrite(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 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) - } -} diff --git a/apps/ios/Sources/Camera/CameraController.swift b/apps/ios/Sources/Camera/CameraController.swift deleted file mode 100644 index 1e9c10bc44c..00000000000 --- a/apps/ios/Sources/Camera/CameraController.swift +++ /dev/null @@ -1,402 +0,0 @@ -import AVFoundation -import OpenClawKit -import Foundation - -actor CameraController { - struct CameraDeviceInfo: Codable, Sendable { - var id: String - var name: String - var position: String - var deviceType: String - } - - enum CameraError: LocalizedError, Sendable { - case cameraUnavailable - case microphoneUnavailable - case permissionDenied(kind: String) - case invalidParams(String) - case captureFailed(String) - case exportFailed(String) - - var errorDescription: String? { - switch self { - case .cameraUnavailable: - "Camera unavailable" - case .microphoneUnavailable: - "Microphone unavailable" - case let .permissionDenied(kind): - "\(kind) permission denied" - case let .invalidParams(msg): - msg - case let .captureFailed(msg): - msg - case let .exportFailed(msg): - msg - } - } - } - - func snap(params: OpenClawCameraSnapParams) async throws -> ( - format: String, - base64: String, - width: Int, - height: Int) - { - let facing = params.facing ?? .front - let format = params.format ?? .jpg - // Default to a reasonable max width to keep gateway payload sizes manageable. - // If you need the full-res photo, explicitly request a larger maxWidth. - let maxWidth = params.maxWidth.flatMap { $0 > 0 ? $0 : nil } ?? 1600 - let quality = Self.clampQuality(params.quality) - let delayMs = max(0, params.delayMs ?? 0) - - try await self.ensureAccess(for: .video) - - let session = AVCaptureSession() - session.sessionPreset = .photo - - guard let device = Self.pickCamera(facing: facing, deviceId: params.deviceId) else { - throw CameraError.cameraUnavailable - } - - let input = try AVCaptureDeviceInput(device: device) - guard session.canAddInput(input) else { - throw CameraError.captureFailed("Failed to add camera input") - } - session.addInput(input) - - let output = AVCapturePhotoOutput() - guard session.canAddOutput(output) else { - throw CameraError.captureFailed("Failed to add photo output") - } - session.addOutput(output) - output.maxPhotoQualityPrioritization = .quality - - session.startRunning() - defer { session.stopRunning() } - await Self.warmUpCaptureSession() - await Self.sleepDelayMs(delayMs) - - let settings: AVCapturePhotoSettings = { - if output.availablePhotoCodecTypes.contains(.jpeg) { - return AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.jpeg]) - } - return AVCapturePhotoSettings() - }() - settings.photoQualityPrioritization = .quality - - var delegate: PhotoCaptureDelegate? - let rawData: Data = try await withCheckedThrowingContinuation { cont in - let d = PhotoCaptureDelegate(cont) - delegate = d - output.capturePhoto(with: settings, delegate: d) - } - withExtendedLifetime(delegate) {} - - let res = try PhotoCapture.transcodeJPEGForGateway( - rawData: rawData, - maxWidthPx: maxWidth, - quality: quality) - - return ( - format: format.rawValue, - base64: res.data.base64EncodedString(), - width: res.widthPx, - height: res.heightPx) - } - - func clip(params: OpenClawCameraClipParams) async throws -> ( - format: String, - base64: String, - durationMs: Int, - hasAudio: Bool) - { - let facing = params.facing ?? .front - let durationMs = Self.clampDurationMs(params.durationMs) - let includeAudio = params.includeAudio ?? true - let format = params.format ?? .mp4 - - try await self.ensureAccess(for: .video) - if includeAudio { - try await self.ensureAccess(for: .audio) - } - - let session = AVCaptureSession() - session.sessionPreset = .high - - guard let camera = Self.pickCamera(facing: facing, deviceId: params.deviceId) else { - throw CameraError.cameraUnavailable - } - let cameraInput = try AVCaptureDeviceInput(device: camera) - guard session.canAddInput(cameraInput) else { - throw CameraError.captureFailed("Failed to add camera input") - } - session.addInput(cameraInput) - - if includeAudio { - guard let mic = AVCaptureDevice.default(for: .audio) else { - throw CameraError.microphoneUnavailable - } - let micInput = try AVCaptureDeviceInput(device: mic) - if session.canAddInput(micInput) { - session.addInput(micInput) - } else { - throw CameraError.captureFailed("Failed to add microphone input") - } - } - - let output = AVCaptureMovieFileOutput() - guard session.canAddOutput(output) else { - throw CameraError.captureFailed("Failed to add movie output") - } - session.addOutput(output) - output.maxRecordedDuration = CMTime(value: Int64(durationMs), timescale: 1000) - - session.startRunning() - defer { session.stopRunning() } - await Self.warmUpCaptureSession() - - let movURL = FileManager().temporaryDirectory - .appendingPathComponent("openclaw-camera-\(UUID().uuidString).mov") - let mp4URL = FileManager().temporaryDirectory - .appendingPathComponent("openclaw-camera-\(UUID().uuidString).mp4") - - defer { - try? FileManager().removeItem(at: movURL) - try? FileManager().removeItem(at: mp4URL) - } - - var delegate: MovieFileDelegate? - let recordedURL: URL = try await withCheckedThrowingContinuation { cont in - let d = MovieFileDelegate(cont) - delegate = d - output.startRecording(to: movURL, recordingDelegate: d) - } - withExtendedLifetime(delegate) {} - - // Transcode .mov -> .mp4 for easier downstream handling. - try await Self.exportToMP4(inputURL: recordedURL, outputURL: mp4URL) - - let data = try Data(contentsOf: mp4URL) - return ( - format: format.rawValue, - base64: data.base64EncodedString(), - durationMs: durationMs, - hasAudio: includeAudio) - } - - func listDevices() -> [CameraDeviceInfo] { - return Self.discoverVideoDevices().map { device in - CameraDeviceInfo( - id: device.uniqueID, - name: device.localizedName, - position: Self.positionLabel(device.position), - deviceType: device.deviceType.rawValue) - } - } - - private func ensureAccess(for mediaType: AVMediaType) async throws { - let status = AVCaptureDevice.authorizationStatus(for: mediaType) - switch status { - case .authorized: - return - case .notDetermined: - let ok = await withCheckedContinuation(isolation: nil) { cont in - AVCaptureDevice.requestAccess(for: mediaType) { granted in - cont.resume(returning: granted) - } - } - if !ok { - throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone") - } - case .denied, .restricted: - throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone") - @unknown default: - throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone") - } - } - - private nonisolated static func pickCamera( - facing: OpenClawCameraFacing, - deviceId: String?) -> AVCaptureDevice? - { - if let deviceId, !deviceId.isEmpty { - if let match = Self.discoverVideoDevices().first(where: { $0.uniqueID == deviceId }) { - return match - } - } - let position: AVCaptureDevice.Position = (facing == .front) ? .front : .back - if let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position) { - return device - } - // Fall back to any default camera (e.g. simulator / unusual device configurations). - return AVCaptureDevice.default(for: .video) - } - - private nonisolated static func positionLabel(_ position: AVCaptureDevice.Position) -> String { - switch position { - case .front: "front" - case .back: "back" - default: "unspecified" - } - } - - private nonisolated static func discoverVideoDevices() -> [AVCaptureDevice] { - let types: [AVCaptureDevice.DeviceType] = [ - .builtInWideAngleCamera, - .builtInUltraWideCamera, - .builtInTelephotoCamera, - .builtInDualCamera, - .builtInDualWideCamera, - .builtInTripleCamera, - .builtInTrueDepthCamera, - .builtInLiDARDepthCamera, - ] - let session = AVCaptureDevice.DiscoverySession( - deviceTypes: types, - mediaType: .video, - position: .unspecified) - return session.devices - } - - nonisolated static func clampQuality(_ quality: Double?) -> Double { - let q = quality ?? 0.9 - return min(1.0, max(0.05, q)) - } - - nonisolated static func clampDurationMs(_ ms: Int?) -> Int { - let v = ms ?? 3000 - // Keep clips short by default; avoid huge base64 payloads on the gateway. - return min(60000, max(250, v)) - } - - private nonisolated static func exportToMP4(inputURL: URL, outputURL: URL) async throws { - let asset = AVURLAsset(url: inputURL) - guard let exporter = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetMediumQuality) else { - throw CameraError.exportFailed("Failed to create export session") - } - exporter.shouldOptimizeForNetworkUse = true - - if #available(iOS 18.0, tvOS 18.0, visionOS 2.0, *) { - do { - try await exporter.export(to: outputURL, as: .mp4) - return - } catch { - throw CameraError.exportFailed(error.localizedDescription) - } - } else { - exporter.outputURL = outputURL - exporter.outputFileType = .mp4 - - try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation) in - exporter.exportAsynchronously { - cont.resume(returning: ()) - } - } - - switch exporter.status { - case .completed: - return - case .failed: - throw CameraError.exportFailed(exporter.error?.localizedDescription ?? "export failed") - case .cancelled: - throw CameraError.exportFailed("export cancelled") - default: - throw CameraError.exportFailed("export did not complete") - } - } - } - - private nonisolated static func warmUpCaptureSession() async { - // A short delay after `startRunning()` significantly reduces "blank first frame" captures on some devices. - try? await Task.sleep(nanoseconds: 150_000_000) // 150ms - } - - private nonisolated static func sleepDelayMs(_ delayMs: Int) async { - guard delayMs > 0 else { return } - let maxDelayMs = 10 * 1000 - let ns = UInt64(min(delayMs, maxDelayMs)) * UInt64(NSEC_PER_MSEC) - try? await Task.sleep(nanoseconds: ns) - } -} - -private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate { - private let continuation: CheckedContinuation - private var didResume = false - - init(_ continuation: CheckedContinuation) { - self.continuation = continuation - } - - func photoOutput( - _ output: AVCapturePhotoOutput, - didFinishProcessingPhoto photo: AVCapturePhoto, - error: Error? - ) { - guard !self.didResume else { return } - self.didResume = true - - if let error { - self.continuation.resume(throwing: error) - return - } - guard let data = photo.fileDataRepresentation() else { - self.continuation.resume( - throwing: NSError(domain: "Camera", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "photo data missing", - ])) - return - } - if data.isEmpty { - self.continuation.resume( - throwing: NSError(domain: "Camera", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "photo data empty", - ])) - return - } - self.continuation.resume(returning: data) - } - - func photoOutput( - _ output: AVCapturePhotoOutput, - didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings, - error: Error? - ) { - guard let error else { return } - guard !self.didResume else { return } - self.didResume = true - self.continuation.resume(throwing: error) - } -} - -private final class MovieFileDelegate: NSObject, AVCaptureFileOutputRecordingDelegate { - private let continuation: CheckedContinuation - private var didResume = false - - init(_ continuation: CheckedContinuation) { - self.continuation = continuation - } - - func fileOutput( - _ output: AVCaptureFileOutput, - didFinishRecordingTo outputFileURL: URL, - from connections: [AVCaptureConnection], - error: Error?) - { - guard !self.didResume else { return } - self.didResume = true - - if let error { - let ns = error as NSError - if ns.domain == AVFoundationErrorDomain, - ns.code == AVError.maximumDurationReached.rawValue - { - self.continuation.resume(returning: outputFileURL) - return - } - self.continuation.resume(throwing: error) - return - } - self.continuation.resume(returning: outputFileURL) - } -} diff --git a/apps/ios/Sources/Capabilities/NodeCapabilityRouter.swift b/apps/ios/Sources/Capabilities/NodeCapabilityRouter.swift deleted file mode 100644 index 6dbdd51eb8e..00000000000 --- a/apps/ios/Sources/Capabilities/NodeCapabilityRouter.swift +++ /dev/null @@ -1,25 +0,0 @@ -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) - } -} diff --git a/apps/ios/Sources/Chat/ChatSheet.swift b/apps/ios/Sources/Chat/ChatSheet.swift deleted file mode 100644 index bbed501cf70..00000000000 --- a/apps/ios/Sources/Chat/ChatSheet.swift +++ /dev/null @@ -1,47 +0,0 @@ -import OpenClawChatUI -import OpenClawKit -import SwiftUI - -struct ChatSheet: View { - @Environment(\.dismiss) private var dismiss - @State private var viewModel: OpenClawChatViewModel - private let userAccent: Color? - private let agentName: String? - - init(gateway: GatewayNodeSession, sessionKey: String, agentName: String? = nil, userAccent: Color? = nil) { - let transport = IOSGatewayChatTransport(gateway: gateway) - self._viewModel = State( - initialValue: OpenClawChatViewModel( - sessionKey: sessionKey, - transport: transport)) - self.userAccent = userAccent - self.agentName = agentName - } - - var body: some View { - NavigationStack { - OpenClawChatView( - viewModel: self.viewModel, - showsSessionSwitcher: true, - userAccent: self.userAccent) - .navigationTitle(self.chatTitle) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button { - self.dismiss() - } label: { - Image(systemName: "xmark") - } - .accessibilityLabel("Close") - } - } - } - } - - private var chatTitle: String { - let trimmed = (self.agentName ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { return "Chat" } - return "Chat (\(trimmed))" - } -} diff --git a/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift b/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift deleted file mode 100644 index 9571839059d..00000000000 --- a/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift +++ /dev/null @@ -1,137 +0,0 @@ -import OpenClawChatUI -import OpenClawKit -import OpenClawProtocol -import Foundation -import OSLog - -struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable { - private static let logger = Logger(subsystem: "ai.openclaw", category: "ios.chat.transport") - private let gateway: GatewayNodeSession - - init(gateway: GatewayNodeSession) { - self.gateway = gateway - } - - func abortRun(sessionKey: String, runId: String) async throws { - struct Params: Codable { - var sessionKey: String - var runId: String - } - let data = try JSONEncoder().encode(Params(sessionKey: sessionKey, runId: runId)) - let json = String(data: data, encoding: .utf8) - _ = try await self.gateway.request(method: "chat.abort", paramsJSON: json, timeoutSeconds: 10) - } - - func listSessions(limit: Int?) async throws -> OpenClawChatSessionsListResponse { - struct Params: Codable { - var includeGlobal: Bool - var includeUnknown: Bool - var limit: Int? - } - let data = try JSONEncoder().encode(Params(includeGlobal: true, includeUnknown: false, limit: limit)) - let json = String(data: data, encoding: .utf8) - let res = try await self.gateway.request(method: "sessions.list", paramsJSON: json, timeoutSeconds: 15) - return try JSONDecoder().decode(OpenClawChatSessionsListResponse.self, from: res) - } - - func setActiveSessionKey(_ sessionKey: String) async throws { - // Operator clients receive chat events without node-style subscriptions. - // (chat.subscribe is a node event, not an operator RPC method.) - } - - func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload { - struct Params: Codable { var sessionKey: String } - let data = try JSONEncoder().encode(Params(sessionKey: sessionKey)) - let json = String(data: data, encoding: .utf8) - let res = try await self.gateway.request(method: "chat.history", paramsJSON: json, timeoutSeconds: 15) - return try JSONDecoder().decode(OpenClawChatHistoryPayload.self, from: res) - } - - func sendMessage( - sessionKey: String, - message: String, - thinking: String, - idempotencyKey: String, - attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse - { - Self.logger.info("chat.send start sessionKey=\(sessionKey, privacy: .public) len=\(message.count, privacy: .public) attachments=\(attachments.count, privacy: .public)") - struct Params: Codable { - var sessionKey: String - var message: String - var thinking: String - var attachments: [OpenClawChatAttachmentPayload]? - var timeoutMs: Int - var idempotencyKey: String - } - - let params = Params( - sessionKey: sessionKey, - message: message, - thinking: thinking, - attachments: attachments.isEmpty ? nil : attachments, - timeoutMs: 30000, - idempotencyKey: idempotencyKey) - let data = try JSONEncoder().encode(params) - let json = String(data: data, encoding: .utf8) - do { - let res = try await self.gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35) - let decoded = try JSONDecoder().decode(OpenClawChatSendResponse.self, from: res) - Self.logger.info("chat.send ok runId=\(decoded.runId, privacy: .public)") - return decoded - } catch { - Self.logger.error("chat.send failed \(error.localizedDescription, privacy: .public)") - throw error - } - } - - func requestHealth(timeoutMs: Int) async throws -> Bool { - let seconds = max(1, Int(ceil(Double(timeoutMs) / 1000.0))) - let res = try await self.gateway.request(method: "health", paramsJSON: nil, timeoutSeconds: seconds) - return (try? JSONDecoder().decode(OpenClawGatewayHealthOK.self, from: res))?.ok ?? true - } - - func events() -> AsyncStream { - AsyncStream { continuation in - let task = Task { - let stream = await self.gateway.subscribeServerEvents() - for await evt in stream { - if Task.isCancelled { return } - switch evt.event { - case "tick": - continuation.yield(.tick) - case "seqGap": - continuation.yield(.seqGap) - case "health": - guard let payload = evt.payload else { break } - let ok = (try? GatewayPayloadDecoding.decode( - payload, - as: OpenClawGatewayHealthOK.self))?.ok ?? true - continuation.yield(.health(ok: ok)) - case "chat": - guard let payload = evt.payload else { break } - if let chatPayload = try? GatewayPayloadDecoding.decode( - payload, - as: OpenClawChatEventPayload.self) - { - continuation.yield(.chat(chatPayload)) - } - case "agent": - guard let payload = evt.payload else { break } - if let agentPayload = try? GatewayPayloadDecoding.decode( - payload, - as: OpenClawAgentEventPayload.self) - { - continuation.yield(.agent(agentPayload)) - } - default: - break - } - } - } - - continuation.onTermination = { @Sendable _ in - task.cancel() - } - } - } -} diff --git a/apps/ios/Sources/Contacts/ContactsService.swift b/apps/ios/Sources/Contacts/ContactsService.swift deleted file mode 100644 index db203d070f1..00000000000 --- a/apps/ios/Sources/Contacts/ContactsService.swift +++ /dev/null @@ -1,212 +0,0 @@ -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() - - 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 -} diff --git a/apps/ios/Sources/Device/DeviceStatusService.swift b/apps/ios/Sources/Device/DeviceStatusService.swift deleted file mode 100644 index fed2716b5b8..00000000000 --- a/apps/ios/Sources/Device/DeviceStatusService.swift +++ /dev/null @@ -1,87 +0,0 @@ -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 - } -} diff --git a/apps/ios/Sources/Device/NetworkStatusService.swift b/apps/ios/Sources/Device/NetworkStatusService.swift deleted file mode 100644 index 7d92d1cc1ca..00000000000 --- a/apps/ios/Sources/Device/NetworkStatusService.swift +++ /dev/null @@ -1,69 +0,0 @@ -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 - } -} diff --git a/apps/ios/Sources/Device/NodeDisplayName.swift b/apps/ios/Sources/Device/NodeDisplayName.swift deleted file mode 100644 index 9ddf38b24a7..00000000000 --- a/apps/ios/Sources/Device/NodeDisplayName.swift +++ /dev/null @@ -1,48 +0,0 @@ -import Foundation -import UIKit - -enum NodeDisplayName { - private static let genericNames: Set = ["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 - } -} diff --git a/apps/ios/Sources/EventKit/EventKitAuthorization.swift b/apps/ios/Sources/EventKit/EventKitAuthorization.swift deleted file mode 100644 index c27e9a3efde..00000000000 --- a/apps/ios/Sources/EventKit/EventKitAuthorization.swift +++ /dev/null @@ -1,34 +0,0 @@ -import EventKit - -enum EventKitAuthorization { - static func allowsRead(status: EKAuthorizationStatus) -> Bool { - switch status { - case .authorized, .fullAccess: - return true - case .writeOnly: - return false - 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 - } - } - - static func allowsWrite(status: EKAuthorizationStatus) -> 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 - } - } -} - diff --git a/apps/ios/Sources/Gateway/GatewayConnectConfig.swift b/apps/ios/Sources/Gateway/GatewayConnectConfig.swift deleted file mode 100644 index 7f4e93380b0..00000000000 --- a/apps/ios/Sources/Gateway/GatewayConnectConfig.swift +++ /dev/null @@ -1,27 +0,0 @@ -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 - } -} diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController.swift b/apps/ios/Sources/Gateway/GatewayConnectionController.swift deleted file mode 100644 index 2b7f94ba453..00000000000 --- a/apps/ios/Sources/Gateway/GatewayConnectionController.swift +++ /dev/null @@ -1,1093 +0,0 @@ -import AVFoundation -import Contacts -import CoreLocation -import CoreMotion -import CryptoKit -import EventKit -import Foundation -import Darwin -import OpenClawKit -import Network -import Observation -import Photos -import ReplayKit -import Security -import Speech -import SwiftUI -import UIKit - -@MainActor -@Observable -final class GatewayConnectionController { - struct TrustPrompt: Identifiable, Equatable { - let stableID: String - let gatewayName: String - let host: String - let port: Int - let fingerprintSha256: String - let isManual: Bool - - var id: String { self.stableID } - } - - private(set) var gateways: [GatewayDiscoveryModel.DiscoveredGateway] = [] - private(set) var discoveryStatusText: String = "Idle" - private(set) var discoveryDebugLog: [GatewayDiscoveryModel.DebugLogEntry] = [] - private(set) var pendingTrustPrompt: TrustPrompt? - - private let discovery = GatewayDiscoveryModel() - private weak var appModel: NodeAppModel? - private var didAutoConnect = false - private var pendingServiceResolvers: [String: GatewayServiceResolver] = [:] - private var pendingTrustConnect: (url: URL, stableID: String, isManual: Bool)? - - init(appModel: NodeAppModel, startDiscovery: Bool = true) { - self.appModel = appModel - - GatewaySettingsStore.bootstrapPersistence() - let defaults = UserDefaults.standard - self.discovery.setDebugLoggingEnabled(defaults.bool(forKey: "gateway.discovery.debugLogs")) - - self.updateFromDiscovery() - self.observeDiscovery() - - if startDiscovery { - self.discovery.start() - } - } - - func setDiscoveryDebugLoggingEnabled(_ enabled: Bool) { - self.discovery.setDebugLoggingEnabled(enabled) - } - - func setScenePhase(_ phase: ScenePhase) { - switch phase { - case .background: - self.discovery.stop() - case .active, .inactive: - self.discovery.start() - self.attemptAutoReconnectIfNeeded() - @unknown default: - self.discovery.start() - self.attemptAutoReconnectIfNeeded() - } - } - - func allowAutoConnectAgain() { - self.didAutoConnect = false - self.maybeAutoConnect() - } - - func restartDiscovery() { - self.discovery.stop() - self.didAutoConnect = false - self.discovery.start() - self.updateFromDiscovery() - } - - - /// Returns `nil` when a connect attempt was started, otherwise returns a user-facing error. - func connectWithDiagnostics(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async -> String? { - await self.connectDiscoveredGateway(gateway) - } - - private func connectDiscoveredGateway( - _ gateway: GatewayDiscoveryModel.DiscoveredGateway) async -> String? - { - let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if instanceId.isEmpty { - return "Missing instanceId (node.instanceId). Try restarting the app." - } - let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId) - let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId) - - // Resolve the service endpoint (SRV/A/AAAA). TXT is unauthenticated; do not route via TXT. - guard let target = await self.resolveServiceEndpoint(gateway.endpoint) else { - return "Failed to resolve the discovered gateway endpoint." - } - - let stableID = gateway.stableID - // Discovery is a LAN operation; refuse unauthenticated plaintext connects. - let tlsRequired = true - let stored = GatewayTLSStore.loadFingerprint(stableID: stableID) - - guard gateway.tlsEnabled || stored != nil else { - return "Discovered gateway is missing TLS and no trusted fingerprint is stored." - } - - if tlsRequired, stored == nil { - guard let url = self.buildGatewayURL(host: target.host, port: target.port, useTLS: true) - else { return "Failed to build TLS URL for trust verification." } - guard let fp = await self.probeTLSFingerprint(url: url) else { - return "Failed to read TLS fingerprint from discovered gateway." - } - self.pendingTrustConnect = (url: url, stableID: stableID, isManual: false) - self.pendingTrustPrompt = TrustPrompt( - stableID: stableID, - gatewayName: gateway.name, - host: target.host, - port: target.port, - fingerprintSha256: fp, - isManual: false) - self.appModel?.gatewayStatusText = "Verify gateway TLS fingerprint" - return nil - } - - let tlsParams = stored.map { fp in - GatewayTLSParams(required: true, expectedFingerprint: fp, allowTOFU: false, storeKey: stableID) - } - - guard let url = self.buildGatewayURL( - host: target.host, - port: target.port, - useTLS: tlsParams?.required == true) - else { return "Failed to build discovered gateway URL." } - GatewaySettingsStore.saveLastGatewayConnectionDiscovered(stableID: stableID, useTLS: true) - self.didAutoConnect = true - self.startAutoConnect( - url: url, - gatewayStableID: stableID, - tls: tlsParams, - token: token, - password: password) - return nil - } - - func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async { - _ = await self.connectWithDiagnostics(gateway) - } - - func connectManual(host: String, port: Int, useTLS: Bool) async { - 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 = self.resolveManualUseTLS(host: host, useTLS: useTLS) - guard let resolvedPort = self.resolveManualPort(host: host, port: port, useTLS: resolvedUseTLS) - else { return } - let stableID = self.manualStableID(host: host, port: resolvedPort) - let stored = GatewayTLSStore.loadFingerprint(stableID: stableID) - if resolvedUseTLS, stored == nil { - guard let url = self.buildGatewayURL(host: host, port: resolvedPort, useTLS: true) else { return } - guard let fp = await self.probeTLSFingerprint(url: url) else { return } - self.pendingTrustConnect = (url: url, stableID: stableID, isManual: true) - self.pendingTrustPrompt = TrustPrompt( - stableID: stableID, - gatewayName: "\(host):\(resolvedPort)", - host: host, - port: resolvedPort, - fingerprintSha256: fp, - isManual: true) - self.appModel?.gatewayStatusText = "Verify gateway TLS fingerprint" - return - } - - let tlsParams = stored.map { fp in - GatewayTLSParams(required: true, expectedFingerprint: fp, allowTOFU: false, storeKey: stableID) - } - guard let url = self.buildGatewayURL( - host: host, - port: resolvedPort, - useTLS: tlsParams?.required == true) - else { return } - GatewaySettingsStore.saveLastGatewayConnectionManual( - host: host, - port: resolvedPort, - useTLS: resolvedUseTLS && tlsParams != nil, - stableID: stableID) - self.didAutoConnect = true - self.startAutoConnect( - url: url, - gatewayStableID: stableID, - tls: tlsParams, - token: token, - password: password) - } - - func connectLastKnown() async { - guard let last = GatewaySettingsStore.loadLastGatewayConnection() else { return } - switch last { - case let .manual(host, port, useTLS, _): - await self.connectManual(host: host, port: port, useTLS: useTLS) - case let .discovered(stableID, _): - guard let gateway = self.gateways.first(where: { $0.stableID == stableID }) else { return } - await self.connectDiscoveredGateway(gateway) - } - } - - /// Rebuild connect options from current local settings (caps/commands/permissions) - /// and re-apply the active gateway config so capability changes take effect immediately. - func refreshActiveGatewayRegistrationFromSettings() { - guard let appModel else { return } - guard let cfg = appModel.activeGatewayConnectConfig else { return } - guard appModel.gatewayAutoReconnectEnabled else { return } - - let refreshedConfig = GatewayConnectConfig( - url: cfg.url, - stableID: cfg.stableID, - tls: cfg.tls, - token: cfg.token, - password: cfg.password, - nodeOptions: self.makeConnectOptions(stableID: cfg.stableID)) - appModel.applyGatewayConnectConfig(refreshedConfig) - } - - func clearPendingTrustPrompt() { - self.pendingTrustPrompt = nil - self.pendingTrustConnect = nil - } - - func acceptPendingTrustPrompt() async { - guard let pending = self.pendingTrustConnect, - let prompt = self.pendingTrustPrompt, - pending.stableID == prompt.stableID - else { return } - - GatewayTLSStore.saveFingerprint(prompt.fingerprintSha256, stableID: pending.stableID) - self.clearPendingTrustPrompt() - - if pending.isManual { - GatewaySettingsStore.saveLastGatewayConnectionManual( - host: prompt.host, - port: prompt.port, - useTLS: true, - stableID: pending.stableID) - } else { - GatewaySettingsStore.saveLastGatewayConnectionDiscovered(stableID: pending.stableID, useTLS: true) - } - - let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId) - let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId) - let tlsParams = GatewayTLSParams( - required: true, - expectedFingerprint: prompt.fingerprintSha256, - allowTOFU: false, - storeKey: pending.stableID) - - self.didAutoConnect = true - self.startAutoConnect( - url: pending.url, - gatewayStableID: pending.stableID, - tls: tlsParams, - token: token, - password: password) - } - - func declinePendingTrustPrompt() { - self.clearPendingTrustPrompt() - self.appModel?.gatewayStatusText = "Offline" - } - - private func updateFromDiscovery() { - let newGateways = self.discovery.gateways - self.gateways = newGateways - self.discoveryStatusText = self.discovery.statusText - self.discoveryDebugLog = self.discovery.debugLog - self.updateLastDiscoveredGateway(from: newGateways) - self.maybeAutoConnect() - } - - private func observeDiscovery() { - withObservationTracking { - _ = self.discovery.gateways - _ = self.discovery.statusText - _ = self.discovery.debugLog - } onChange: { [weak self] in - Task { @MainActor in - guard let self else { return } - self.updateFromDiscovery() - self.observeDiscovery() - } - } - } - - private func maybeAutoConnect() { - guard !self.didAutoConnect else { return } - guard let appModel = self.appModel else { return } - 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")? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !instanceId.isEmpty else { return } - - let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId) - let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId) - - if manualEnabled { - let manualHost = defaults.string(forKey: "gateway.manual.host")? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !manualHost.isEmpty else { return } - - let manualPort = defaults.integer(forKey: "gateway.manual.port") - let manualTLS = defaults.bool(forKey: "gateway.manual.tls") - let resolvedUseTLS = self.resolveManualUseTLS(host: manualHost, useTLS: manualTLS) - 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: resolvedUseTLS, - allowTOFUReset: self.shouldRequireTLS(host: manualHost)) - - guard let url = self.buildGatewayURL( - host: manualHost, - port: resolvedPort, - useTLS: tlsParams?.required == true) - else { return } - - self.didAutoConnect = true - self.startAutoConnect( - url: url, - gatewayStableID: stableID, - tls: tlsParams, - token: token, - password: password) - return - } - - if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection() { - if case let .manual(host, port, useTLS, stableID) = lastKnown { - let resolvedUseTLS = self.resolveManualUseTLS(host: host, useTLS: useTLS) - let stored = GatewayTLSStore.loadFingerprint(stableID: stableID) - let tlsParams = stored.map { fp in - GatewayTLSParams(required: true, expectedFingerprint: fp, allowTOFU: false, storeKey: stableID) - } - guard let url = self.buildGatewayURL( - host: host, - port: port, - useTLS: resolvedUseTLS && tlsParams != nil) - else { return } - - // Security: autoconnect only to previously trusted gateways (stored TLS pin). - guard tlsParams != nil else { return } - - self.didAutoConnect = true - self.startAutoConnect( - url: url, - gatewayStableID: stableID, - tls: tlsParams, - token: token, - 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 } - if let targetStableID = candidates.first(where: { id in - self.gateways.contains(where: { $0.stableID == id }) - }) { - guard let target = self.gateways.first(where: { $0.stableID == targetStableID }) else { return } - // Security: autoconnect only to previously trusted gateways (stored TLS pin). - guard GatewayTLSStore.loadFingerprint(stableID: target.stableID) != nil else { return } - - self.didAutoConnect = true - Task { [weak self] in - guard let self else { return } - await self.connectDiscoveredGateway(target) - } - return - } - - if self.gateways.count == 1, let gateway = self.gateways.first { - // Security: autoconnect only to previously trusted gateways (stored TLS pin). - guard GatewayTLSStore.loadFingerprint(stableID: gateway.stableID) != nil else { return } - - self.didAutoConnect = true - Task { [weak self] in - guard let self else { return } - await self.connectDiscoveredGateway(gateway) - } - 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]) { - let defaults = UserDefaults.standard - let preferred = defaults.string(forKey: "gateway.preferredStableID")? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let existingLast = defaults.string(forKey: "gateway.lastDiscoveredStableID")? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - - // Avoid overriding user intent (preferred/lastDiscovered are also set on manual Connect). - guard preferred.isEmpty, existingLast.isEmpty else { return } - guard let first = gateways.first else { return } - - defaults.set(first.stableID, forKey: "gateway.lastDiscoveredStableID") - GatewaySettingsStore.saveLastDiscoveredGatewayStableID(first.stableID) - } - - private func startAutoConnect( - url: URL, - gatewayStableID: String, - tls: GatewayTLSParams?, - token: String?, - password: String?) - { - guard let appModel else { return } - let connectOptions = self.makeConnectOptions(stableID: gatewayStableID) - - Task { [weak appModel] in - guard let appModel else { return } - await MainActor.run { - appModel.gatewayStatusText = "Connecting…" - } - let cfg = GatewayConnectConfig( - url: url, - stableID: gatewayStableID, - tls: tls, - token: token, - password: password, - nodeOptions: connectOptions) - appModel.applyGatewayConnectConfig(cfg) - } - } - - private func resolveDiscoveredTLSParams( - gateway: GatewayDiscoveryModel.DiscoveredGateway, - allowTOFU: Bool) -> GatewayTLSParams? - { - let stableID = gateway.stableID - let stored = GatewayTLSStore.loadFingerprint(stableID: stableID) - - // Never let unauthenticated discovery (TXT) override a stored pin. - if let stored { - return GatewayTLSParams( - required: true, - expectedFingerprint: stored, - allowTOFU: false, - storeKey: stableID) - } - - if gateway.tlsEnabled || gateway.tlsFingerprintSha256 != nil { - return GatewayTLSParams( - required: true, - expectedFingerprint: nil, - allowTOFU: false, - storeKey: stableID) - } - - return nil - } - - 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: false, - storeKey: stableID) - } - - return nil - } - - private func probeTLSFingerprint(url: URL) async -> String? { - await withCheckedContinuation { continuation in - let probe = GatewayTLSFingerprintProbe(url: url, timeoutSeconds: 3) { fp in - continuation.resume(returning: fp) - } - probe.start() - } - } - - private func resolveServiceEndpoint(_ endpoint: NWEndpoint) async -> (host: String, port: Int)? { - guard case let .service(name, type, domain, _) = endpoint else { return nil } - let key = "\(domain)|\(type)|\(name)" - return await withCheckedContinuation { continuation in - let resolver = GatewayServiceResolver(name: name, type: type, domain: domain) { [weak self] result in - Task { @MainActor in - self?.pendingServiceResolvers[key] = nil - continuation.resume(returning: result) - } - } - self.pendingServiceResolvers[key] = resolver - resolver.start() - } - } - - private func resolveHostPortFromBonjourEndpoint(_ endpoint: NWEndpoint) async -> (host: String, port: Int)? { - switch endpoint { - case let .hostPort(host, port): - return (host: host.debugDescription, port: Int(port.rawValue)) - case let .service(name, type, domain, _): - return await Self.resolveBonjourServiceToHostPort(name: name, type: type, domain: domain) - default: - return nil - } - } - - private static func resolveBonjourServiceToHostPort( - name: String, - type: String, - domain: String, - timeoutSeconds: TimeInterval = 3.0 - ) async -> (host: String, port: Int)? { - // NetService callbacks are delivered via a run loop. If we resolve from a thread without one, - // we can end up never receiving callbacks, which in turn leaks the continuation and leaves - // the UI stuck "connecting". Keep the whole lifecycle on the main run loop and always - // resume the continuation exactly once (timeout/cancel safe). - @MainActor - final class Resolver: NSObject, @preconcurrency NetServiceDelegate { - private var cont: CheckedContinuation<(host: String, port: Int)?, Never>? - private let service: NetService - private var timeoutTask: Task? - private var finished = false - - init(cont: CheckedContinuation<(host: String, port: Int)?, Never>, service: NetService) { - self.cont = cont - self.service = service - super.init() - } - - func start(timeoutSeconds: TimeInterval) { - self.service.delegate = self - self.service.schedule(in: .main, forMode: .default) - - // NetService has its own timeout, but we keep a manual one as a backstop in case - // callbacks never arrive (e.g. local network permission issues). - self.timeoutTask = Task { @MainActor [weak self] in - guard let self else { return } - let ns = UInt64(max(0.1, timeoutSeconds) * 1_000_000_000) - try? await Task.sleep(nanoseconds: ns) - self.finish(nil) - } - - self.service.resolve(withTimeout: timeoutSeconds) - } - - func netServiceDidResolveAddress(_ sender: NetService) { - self.finish(Self.extractHostPort(sender)) - } - - func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) { - _ = errorDict // currently best-effort; callers surface a generic failure - self.finish(nil) - } - - private func finish(_ result: (host: String, port: Int)?) { - guard !self.finished else { return } - self.finished = true - - self.timeoutTask?.cancel() - self.timeoutTask = nil - - self.service.stop() - self.service.remove(from: .main, forMode: .default) - - let c = self.cont - self.cont = nil - c?.resume(returning: result) - } - - private static func extractHostPort(_ svc: NetService) -> (host: String, port: Int)? { - let port = svc.port - - if let host = svc.hostName?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty { - return (host: host, port: port) - } - - guard let addrs = svc.addresses else { return nil } - for addrData in addrs { - let host = addrData.withUnsafeBytes { ptr -> String? in - guard let base = ptr.baseAddress, !ptr.isEmpty else { return nil } - var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) - - let rc = getnameinfo( - base.assumingMemoryBound(to: sockaddr.self), - socklen_t(ptr.count), - &buffer, - socklen_t(buffer.count), - nil, - 0, - NI_NUMERICHOST) - guard rc == 0 else { return nil } - return String(cString: buffer) - } - - if let host, !host.isEmpty { - return (host: host, port: port) - } - } - - return nil - } - } - - return await withCheckedContinuation { cont in - Task { @MainActor in - let service = NetService(domain: domain, type: type, name: name) - let resolver = Resolver(cont: cont, service: service) - // Keep the resolver alive for the lifetime of the NetService resolve. - objc_setAssociatedObject(service, "resolver", resolver, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - resolver.start(timeoutSeconds: timeoutSeconds) - } - } - } - - private func buildGatewayURL(host: String, port: Int, useTLS: Bool) -> URL? { - let scheme = useTLS ? "wss" : "ws" - var components = URLComponents() - components.scheme = scheme - components.host = host - components.port = port - return components.url - } - - private func resolveManualUseTLS(host: String, useTLS: Bool) -> Bool { - useTLS || self.shouldRequireTLS(host: host) - } - - private func shouldRequireTLS(host: String) -> Bool { - !Self.isLoopbackHost(host) - } - - 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 static func isLoopbackHost(_ rawHost: String) -> Bool { - var host = rawHost.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !host.isEmpty else { return false } - - if host.hasPrefix("[") && host.hasSuffix("]") { - host.removeFirst() - host.removeLast() - } - if host.hasSuffix(".") { - host.removeLast() - } - if let zoneIndex = host.firstIndex(of: "%") { - host = String(host[.. Bool { - var addr = in_addr() - let parsed = host.withCString { inet_pton(AF_INET, $0, &addr) == 1 } - guard parsed else { return false } - let value = UInt32(bigEndian: addr.s_addr) - let firstOctet = UInt8((value >> 24) & 0xFF) - return firstOctet == 127 - } - - private static func isLoopbackIPv6(_ host: String) -> Bool { - var addr = in6_addr() - let parsed = host.withCString { inet_pton(AF_INET6, $0, &addr) == 1 } - guard parsed else { return false } - return withUnsafeBytes(of: &addr) { rawBytes in - let bytes = rawBytes.bindMemory(to: UInt8.self) - let isV6Loopback = bytes[0..<15].allSatisfy { $0 == 0 } && bytes[15] == 1 - if isV6Loopback { return true } - - let isMappedV4 = bytes[0..<10].allSatisfy { $0 == 0 } && bytes[10] == 0xFF && bytes[11] == 0xFF - return isMappedV4 && bytes[12] == 127 - } - } - - private func manualStableID(host: String, port: Int) -> String { - "manual|\(host.lowercased())|\(port)" - } - - 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: 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 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 resolved - } - - private func currentCaps() -> [String] { - var caps = [OpenClawCapability.canvas.rawValue, OpenClawCapability.screen.rawValue] - - // Default-on: if the key doesn't exist yet, treat it as enabled. - let cameraEnabled = - UserDefaults.standard.object(forKey: "camera.enabled") == nil - ? true - : UserDefaults.standard.bool(forKey: "camera.enabled") - if cameraEnabled { caps.append(OpenClawCapability.camera.rawValue) } - - let voiceWakeEnabled = UserDefaults.standard.bool(forKey: VoiceWakePreferences.enabledKey) - if voiceWakeEnabled { caps.append(OpenClawCapability.voiceWake.rawValue) } - - let locationModeRaw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off" - let locationMode = OpenClawLocationMode(rawValue: locationModeRaw) ?? .off - if locationMode != .off { caps.append(OpenClawCapability.location.rawValue) } - - caps.append(OpenClawCapability.device.rawValue) - if WatchMessagingService.isSupportedOnDevice() { - caps.append(OpenClawCapability.watch.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 - } - - private func currentCommands() -> [String] { - var commands: [String] = [ - OpenClawCanvasCommand.present.rawValue, - OpenClawCanvasCommand.hide.rawValue, - OpenClawCanvasCommand.navigate.rawValue, - OpenClawCanvasCommand.evalJS.rawValue, - OpenClawCanvasCommand.snapshot.rawValue, - OpenClawCanvasA2UICommand.push.rawValue, - OpenClawCanvasA2UICommand.pushJSONL.rawValue, - OpenClawCanvasA2UICommand.reset.rawValue, - OpenClawScreenCommand.record.rawValue, - OpenClawSystemCommand.notify.rawValue, - OpenClawChatCommand.push.rawValue, - OpenClawTalkCommand.pttStart.rawValue, - OpenClawTalkCommand.pttStop.rawValue, - OpenClawTalkCommand.pttCancel.rawValue, - OpenClawTalkCommand.pttOnce.rawValue, - ] - - let caps = Set(self.currentCaps()) - if caps.contains(OpenClawCapability.camera.rawValue) { - commands.append(OpenClawCameraCommand.list.rawValue) - commands.append(OpenClawCameraCommand.snap.rawValue) - commands.append(OpenClawCameraCommand.clip.rawValue) - } - 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.watch.rawValue) { - commands.append(OpenClawWatchCommand.status.rawValue) - commands.append(OpenClawWatchCommand.notify.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 - - let watchStatus = WatchMessagingService.currentStatusSnapshot() - permissions["watchSupported"] = watchStatus.supported - permissions["watchPaired"] = watchStatus.paired - permissions["watchAppInstalled"] = watchStatus.appInstalled - permissions["watchReachable"] = watchStatus.reachable - - 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 { - case .pad: - "iPadOS" - case .phone: - "iOS" - default: - "iOS" - } - return "\(name) \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" - } - - private func deviceFamily() -> String { - switch UIDevice.current.userInterfaceIdiom { - case .pad: - "iPad" - case .phone: - "iPhone" - default: - "iOS" - } - } - - private 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 - } - - private func appVersion() -> String { - Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev" - } -} - -#if DEBUG -extension GatewayConnectionController { - func _test_resolvedDisplayName(defaults: UserDefaults) -> String { - self.resolvedDisplayName(defaults: defaults) - } - - func _test_currentCaps() -> [String] { - self.currentCaps() - } - - func _test_currentCommands() -> [String] { - self.currentCommands() - } - - func _test_currentPermissions() -> [String: Bool] { - self.currentPermissions() - } - - func _test_platformString() -> String { - self.platformString() - } - - func _test_deviceFamily() -> String { - self.deviceFamily() - } - - func _test_modelIdentifier() -> String { - self.modelIdentifier() - } - - func _test_appVersion() -> String { - self.appVersion() - } - - func _test_setGateways(_ gateways: [GatewayDiscoveryModel.DiscoveredGateway]) { - self.gateways = gateways - } - - func _test_triggerAutoConnect() { - self.maybeAutoConnect() - } - - func _test_didAutoConnect() -> Bool { - self.didAutoConnect - } - - func _test_resolveDiscoveredTLSParams( - gateway: GatewayDiscoveryModel.DiscoveredGateway, - allowTOFU: Bool) -> GatewayTLSParams? - { - self.resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: allowTOFU) - } - - func _test_resolveManualUseTLS(host: String, useTLS: Bool) -> Bool { - self.resolveManualUseTLS(host: host, useTLS: useTLS) - } - - func _test_resolveManualPort(host: String, port: Int, useTLS: Bool) -> Int? { - self.resolveManualPort(host: host, port: port, useTLS: useTLS) - } -} -#endif - -private final class GatewayTLSFingerprintProbe: NSObject, URLSessionDelegate { - private let url: URL - private let timeoutSeconds: Double - private let onComplete: (String?) -> Void - private var didFinish = false - private var session: URLSession? - private var task: URLSessionWebSocketTask? - - init(url: URL, timeoutSeconds: Double, onComplete: @escaping (String?) -> Void) { - self.url = url - self.timeoutSeconds = timeoutSeconds - self.onComplete = onComplete - } - - func start() { - let config = URLSessionConfiguration.ephemeral - config.timeoutIntervalForRequest = self.timeoutSeconds - config.timeoutIntervalForResource = self.timeoutSeconds - let session = URLSession(configuration: config, delegate: self, delegateQueue: nil) - self.session = session - let task = session.webSocketTask(with: self.url) - self.task = task - task.resume() - - DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + self.timeoutSeconds) { [weak self] in - self?.finish(nil) - } - } - - func urlSession( - _ session: URLSession, - didReceive challenge: URLAuthenticationChallenge, - completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void - ) { - guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, - let trust = challenge.protectionSpace.serverTrust - else { - completionHandler(.performDefaultHandling, nil) - return - } - - let fp = GatewayTLSFingerprintProbe.certificateFingerprint(trust) - completionHandler(.cancelAuthenticationChallenge, nil) - self.finish(fp) - } - - private func finish(_ fingerprint: String?) { - objc_sync_enter(self) - defer { objc_sync_exit(self) } - guard !self.didFinish else { return } - self.didFinish = true - self.task?.cancel(with: .goingAway, reason: nil) - self.session?.invalidateAndCancel() - self.onComplete(fingerprint) - } - - private static func certificateFingerprint(_ trust: SecTrust) -> String? { - guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate], - let cert = chain.first - else { - return nil - } - let data = SecCertificateCopyData(cert) as Data - let digest = SHA256.hash(data: data) - return digest.map { String(format: "%02x", $0) }.joined() - } -} diff --git a/apps/ios/Sources/Gateway/GatewayConnectionIssue.swift b/apps/ios/Sources/Gateway/GatewayConnectionIssue.swift deleted file mode 100644 index 56d490e226b..00000000000 --- a/apps/ios/Sources/Gateway/GatewayConnectionIssue.swift +++ /dev/null @@ -1,71 +0,0 @@ -import Foundation - -enum GatewayConnectionIssue: Equatable { - case none - case tokenMissing - case unauthorized - case pairingRequired(requestId: String?) - case network - case unknown(String) - - var requestId: String? { - if case let .pairingRequired(requestId) = self { - return requestId - } - return nil - } - - var needsAuthToken: Bool { - switch self { - case .tokenMissing, .unauthorized: - return true - default: - return false - } - } - - var needsPairing: Bool { - if case .pairingRequired = self { return true } - return false - } - - static func detect(from statusText: String) -> Self { - let trimmed = statusText.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return .none } - let lower = trimmed.lowercased() - - if lower.contains("pairing required") || lower.contains("not_paired") || lower.contains("not paired") { - return .pairingRequired(requestId: self.extractRequestId(from: trimmed)) - } - if lower.contains("gateway token missing") { - return .tokenMissing - } - if lower.contains("unauthorized") { - return .unauthorized - } - if lower.contains("connection refused") || - lower.contains("timed out") || - lower.contains("network is unreachable") || - lower.contains("cannot find host") || - lower.contains("could not connect") - { - return .network - } - if lower.hasPrefix("gateway error:") { - return .unknown(trimmed) - } - return .none - } - - private static func extractRequestId(from statusText: String) -> String? { - let marker = "requestId:" - guard let range = statusText.range(of: marker) else { return nil } - let suffix = statusText[range.upperBound...] - let trimmed = suffix.trimmingCharacters(in: .whitespacesAndNewlines) - let end = trimmed.firstIndex(where: { ch in - ch == ")" || ch.isWhitespace || ch == "," || ch == ";" - }) ?? trimmed.endIndex - let id = String(trimmed[.. String { - self.gatewayController.discoveryDebugLog - .map { "\(Self.formatISO($0.ts)) \($0.message)" } - .joined(separator: "\n") - } - - private static let timeFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "HH:mm:ss" - return formatter - }() - - private static let isoFormatter: ISO8601DateFormatter = { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter - }() - - private static func formatTime(_ date: Date) -> String { - self.timeFormatter.string(from: date) - } - - private static func formatISO(_ date: Date) -> String { - self.isoFormatter.string(from: date) - } -} diff --git a/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift b/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift deleted file mode 100644 index ce1ba4bf2cb..00000000000 --- a/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift +++ /dev/null @@ -1,190 +0,0 @@ -import OpenClawKit -import Foundation -import Network -import Observation - -@MainActor -@Observable -final class GatewayDiscoveryModel { - struct DebugLogEntry: Identifiable, Equatable { - var id = UUID() - var ts: Date - var message: String - } - - struct DiscoveredGateway: Identifiable, Equatable { - var id: String { self.stableID } - var name: String - var endpoint: NWEndpoint - var stableID: String - var debugID: String - var lanHost: String? - var tailnetDns: String? - var gatewayPort: Int? - var canvasPort: Int? - var tlsEnabled: Bool - var tlsFingerprintSha256: String? - var cliPath: String? - } - - var gateways: [DiscoveredGateway] = [] - var statusText: String = "Idle" - private(set) var debugLog: [DebugLogEntry] = [] - - private var browsers: [String: NWBrowser] = [:] - private var gatewaysByDomain: [String: [DiscoveredGateway]] = [:] - private var statesByDomain: [String: NWBrowser.State] = [:] - private var debugLoggingEnabled = false - private var lastStableIDs = Set() - - func setDebugLoggingEnabled(_ enabled: Bool) { - let wasEnabled = self.debugLoggingEnabled - self.debugLoggingEnabled = enabled - if !enabled { - self.debugLog = [] - } else if !wasEnabled { - self.appendDebugLog("debug logging enabled") - self.appendDebugLog("snapshot: status=\(self.statusText) gateways=\(self.gateways.count)") - } - } - - func start() { - if !self.browsers.isEmpty { return } - self.appendDebugLog("start()") - - for domain in OpenClawBonjour.gatewayServiceDomains { - let params = NWParameters.tcp - params.includePeerToPeer = true - let browser = NWBrowser( - for: .bonjour(type: OpenClawBonjour.gatewayServiceType, domain: domain), - using: params) - - browser.stateUpdateHandler = { [weak self] state in - Task { @MainActor in - guard let self else { return } - self.statesByDomain[domain] = state - self.updateStatusText() - self.appendDebugLog("state[\(domain)]: \(Self.prettyState(state))") - } - } - - browser.browseResultsChangedHandler = { [weak self] results, _ in - Task { @MainActor in - guard let self else { return } - self.gatewaysByDomain[domain] = results.compactMap { result -> DiscoveredGateway? in - switch result.endpoint { - case let .service(name, _, _, _): - let decodedName = BonjourEscapes.decode(name) - let txt = result.endpoint.txtRecord?.dictionary ?? [:] - let advertisedName = txt["displayName"] - let prettyAdvertised = advertisedName - .map(Self.prettifyInstanceName) - .flatMap { $0.isEmpty ? nil : $0 } - let prettyName = prettyAdvertised ?? Self.prettifyInstanceName(decodedName) - return DiscoveredGateway( - name: prettyName, - endpoint: result.endpoint, - stableID: GatewayEndpointID.stableID(result.endpoint), - debugID: GatewayEndpointID.prettyDescription(result.endpoint), - lanHost: Self.txtValue(txt, key: "lanHost"), - tailnetDns: Self.txtValue(txt, key: "tailnetDns"), - gatewayPort: Self.txtIntValue(txt, key: "gatewayPort"), - canvasPort: Self.txtIntValue(txt, key: "canvasPort"), - tlsEnabled: Self.txtBoolValue(txt, key: "gatewayTls"), - tlsFingerprintSha256: Self.txtValue(txt, key: "gatewayTlsSha256"), - cliPath: Self.txtValue(txt, key: "cliPath")) - default: - return nil - } - } - .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } - - self.recomputeGateways() - } - } - - self.browsers[domain] = browser - browser.start(queue: DispatchQueue(label: "bot.molt.ios.gateway-discovery.\(domain)")) - } - } - - func stop() { - self.appendDebugLog("stop()") - for browser in self.browsers.values { - browser.cancel() - } - self.browsers = [:] - self.gatewaysByDomain = [:] - self.statesByDomain = [:] - self.gateways = [] - self.statusText = "Stopped" - } - - private func recomputeGateways() { - let next = self.gatewaysByDomain.values - .flatMap(\.self) - .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } - - let nextIDs = Set(next.map(\.stableID)) - let added = nextIDs.subtracting(self.lastStableIDs) - let removed = self.lastStableIDs.subtracting(nextIDs) - if !added.isEmpty || !removed.isEmpty { - self.appendDebugLog("results: total=\(next.count) added=\(added.count) removed=\(removed.count)") - } - self.lastStableIDs = nextIDs - self.gateways = next - } - - private func updateStatusText() { - self.statusText = GatewayDiscoveryStatusText.make( - states: Array(self.statesByDomain.values), - hasBrowsers: !self.browsers.isEmpty) - } - - private static func prettyState(_ state: NWBrowser.State) -> String { - switch state { - case .setup: - "setup" - case .ready: - "ready" - case let .failed(err): - "failed (\(err))" - case .cancelled: - "cancelled" - case let .waiting(err): - "waiting (\(err))" - @unknown default: - "unknown" - } - } - - private func appendDebugLog(_ message: String) { - guard self.debugLoggingEnabled else { return } - self.debugLog.append(DebugLogEntry(ts: Date(), message: message)) - if self.debugLog.count > 200 { - self.debugLog.removeFirst(self.debugLog.count - 200) - } - } - - private static func prettifyInstanceName(_ decodedName: String) -> String { - let normalized = decodedName.split(whereSeparator: \.isWhitespace).joined(separator: " ") - let stripped = normalized.replacingOccurrences(of: " (OpenClaw)", with: "") - .replacingOccurrences(of: #"\s+\(\d+\)$"#, with: "", options: .regularExpression) - return stripped.trimmingCharacters(in: .whitespacesAndNewlines) - } - - private static func txtValue(_ dict: [String: String], key: String) -> String? { - let raw = dict[key]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return raw.isEmpty ? nil : raw - } - - private static func txtIntValue(_ dict: [String: String], key: String) -> Int? { - guard let raw = self.txtValue(dict, key: key) else { return nil } - return Int(raw) - } - - private static func txtBoolValue(_ dict: [String: String], key: String) -> Bool { - guard let raw = self.txtValue(dict, key: key)?.lowercased() else { return false } - return raw == "1" || raw == "true" || raw == "yes" - } -} diff --git a/apps/ios/Sources/Gateway/GatewayHealthMonitor.swift b/apps/ios/Sources/Gateway/GatewayHealthMonitor.swift deleted file mode 100644 index 182df942c9d..00000000000 --- a/apps/ios/Sources/Gateway/GatewayHealthMonitor.swift +++ /dev/null @@ -1,85 +0,0 @@ -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? - - 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 - } - } -} diff --git a/apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift b/apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift deleted file mode 100644 index eac92df71e8..00000000000 --- a/apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift +++ /dev/null @@ -1,113 +0,0 @@ -import SwiftUI - -struct GatewayQuickSetupSheet: View { - @Environment(NodeAppModel.self) private var appModel - @Environment(GatewayConnectionController.self) private var gatewayController - @Environment(\.dismiss) private var dismiss - - @AppStorage("onboarding.quickSetupDismissed") private var quickSetupDismissed: Bool = false - @State private var connecting: Bool = false - @State private var connectError: String? - - var body: some View { - NavigationStack { - VStack(alignment: .leading, spacing: 16) { - Text("Connect to a Gateway?") - .font(.title2.bold()) - - if let candidate = self.bestCandidate { - VStack(alignment: .leading, spacing: 6) { - Text(verbatim: candidate.name) - .font(.headline) - Text(verbatim: candidate.debugID) - .font(.footnote) - .foregroundStyle(.secondary) - - VStack(alignment: .leading, spacing: 2) { - // Use verbatim strings so Bonjour-provided values can't be interpreted as - // localized format strings (which can crash with Objective-C exceptions). - Text(verbatim: "Discovery: \(self.gatewayController.discoveryStatusText)") - Text(verbatim: "Status: \(self.appModel.gatewayStatusText)") - Text(verbatim: "Node: \(self.appModel.nodeStatusText)") - Text(verbatim: "Operator: \(self.appModel.operatorStatusText)") - } - .font(.footnote) - .foregroundStyle(.secondary) - } - .padding(12) - .background(.thinMaterial) - .clipShape(RoundedRectangle(cornerRadius: 14)) - - Button { - self.connectError = nil - self.connecting = true - Task { - let err = await self.gatewayController.connectWithDiagnostics(candidate) - await MainActor.run { - self.connecting = false - self.connectError = err - // If we kicked off a connect, leave the sheet up so the user can see status evolve. - } - } - } label: { - Group { - if self.connecting { - HStack(spacing: 8) { - ProgressView().progressViewStyle(.circular) - Text("Connecting…") - } - } else { - Text("Connect") - } - } - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .disabled(self.connecting) - - if let connectError { - Text(connectError) - .font(.footnote) - .foregroundStyle(.secondary) - .textSelection(.enabled) - } - - Button { - self.dismiss() - } label: { - Text("Not now") - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - .disabled(self.connecting) - - Toggle("Don’t show this again", isOn: self.$quickSetupDismissed) - .padding(.top, 4) - } else { - Text("No gateways found yet. Make sure your gateway is running and Bonjour discovery is enabled.") - .foregroundStyle(.secondary) - } - - Spacer() - } - .padding() - .navigationTitle("Quick Setup") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button { - self.quickSetupDismissed = true - self.dismiss() - } label: { - Text("Close") - } - } - } - } - } - - private var bestCandidate: GatewayDiscoveryModel.DiscoveredGateway? { - // Prefer whatever discovery says is first; the list is already name-sorted. - self.gatewayController.gateways.first - } -} diff --git a/apps/ios/Sources/Gateway/GatewayServiceResolver.swift b/apps/ios/Sources/Gateway/GatewayServiceResolver.swift deleted file mode 100644 index 882a4e7d05a..00000000000 --- a/apps/ios/Sources/Gateway/GatewayServiceResolver.swift +++ /dev/null @@ -1,55 +0,0 @@ -import Foundation - -// NetService-based resolver for Bonjour services. -// Used to resolve the service endpoint (SRV + A/AAAA) without trusting TXT for routing. -final class GatewayServiceResolver: NSObject, NetServiceDelegate { - private let service: NetService - private let completion: ((host: String, port: Int)?) -> Void - private var didFinish = false - - init( - name: String, - type: String, - domain: String, - completion: @escaping ((host: String, port: Int)?) -> Void) - { - self.service = NetService(domain: domain, type: type, name: name) - self.completion = completion - super.init() - self.service.delegate = self - } - - func start(timeout: TimeInterval = 2.0) { - self.service.schedule(in: .main, forMode: .common) - self.service.resolve(withTimeout: timeout) - } - - func netServiceDidResolveAddress(_ sender: NetService) { - let host = Self.normalizeHost(sender.hostName) - let port = sender.port - guard let host, !host.isEmpty, port > 0 else { - self.finish(result: nil) - return - } - self.finish(result: (host: host, port: port)) - } - - func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) { - self.finish(result: nil) - } - - private func finish(result: ((host: String, port: Int))?) { - guard !self.didFinish else { return } - self.didFinish = true - self.service.stop() - self.service.remove(from: .main, forMode: .common) - self.completion(result) - } - - private static func normalizeHost(_ raw: String?) -> String? { - let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if trimmed.isEmpty { return nil } - return trimmed.hasSuffix(".") ? String(trimmed.dropLast()) : trimmed - } -} - diff --git a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift deleted file mode 100644 index 3ff57ad2e67..00000000000 --- a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift +++ /dev/null @@ -1,438 +0,0 @@ -import Foundation -import os - -enum GatewaySettingsStore { - private static let gatewayService = "ai.openclaw.gateway" - private static let nodeService = "ai.openclaw.node" - private static let talkService = "ai.openclaw.talk" - - private static let instanceIdDefaultsKey = "node.instanceId" - private static let preferredGatewayStableIDDefaultsKey = "gateway.preferredStableID" - private static let lastDiscoveredGatewayStableIDDefaultsKey = "gateway.lastDiscoveredStableID" - private static let manualEnabledDefaultsKey = "gateway.manual.enabled" - private static let manualHostDefaultsKey = "gateway.manual.host" - private static let manualPortDefaultsKey = "gateway.manual.port" - private static let manualTlsDefaultsKey = "gateway.manual.tls" - private static let discoveryDebugLogsDefaultsKey = "gateway.discovery.debugLogs" - private static let lastGatewayKindDefaultsKey = "gateway.last.kind" - 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" - private static let lastDiscoveredGatewayStableIDAccount = "lastDiscoveredStableID" - private static let talkElevenLabsApiKeyAccount = "elevenlabs.apiKey" - - static func bootstrapPersistence() { - self.ensureStableInstanceID() - self.ensurePreferredGatewayStableID() - self.ensureLastDiscoveredGatewayStableID() - } - - static func loadStableInstanceID() -> String? { - if let value = KeychainStore.loadString(service: self.nodeService, account: self.instanceIdAccount)? - .trimmingCharacters(in: .whitespacesAndNewlines), - !value.isEmpty - { - return value - } - - return nil - } - - static func saveStableInstanceID(_ instanceId: String) { - _ = KeychainStore.saveString(instanceId, service: self.nodeService, account: self.instanceIdAccount) - } - - static func loadPreferredGatewayStableID() -> String? { - if let value = KeychainStore.loadString( - service: self.gatewayService, - account: self.preferredGatewayStableIDAccount - )?.trimmingCharacters(in: .whitespacesAndNewlines), - !value.isEmpty - { - return value - } - - return nil - } - - static func savePreferredGatewayStableID(_ stableID: String) { - _ = KeychainStore.saveString( - stableID, - service: self.gatewayService, - account: self.preferredGatewayStableIDAccount) - } - - static func loadLastDiscoveredGatewayStableID() -> String? { - if let value = KeychainStore.loadString( - service: self.gatewayService, - account: self.lastDiscoveredGatewayStableIDAccount - )?.trimmingCharacters(in: .whitespacesAndNewlines), - !value.isEmpty - { - return value - } - - return nil - } - - static func saveLastDiscoveredGatewayStableID(_ stableID: String) { - _ = KeychainStore.saveString( - stableID, - service: self.gatewayService, - account: self.lastDiscoveredGatewayStableIDAccount) - } - - static func loadGatewayToken(instanceId: String) -> String? { - let account = self.gatewayTokenAccount(instanceId: instanceId) - let token = KeychainStore.loadString(service: self.gatewayService, account: account)? - .trimmingCharacters(in: .whitespacesAndNewlines) - if token?.isEmpty == false { return token } - return nil - } - - static func saveGatewayToken(_ token: String, instanceId: String) { - _ = KeychainStore.saveString( - token, - service: self.gatewayService, - account: self.gatewayTokenAccount(instanceId: instanceId)) - } - - static func loadGatewayPassword(instanceId: String) -> String? { - KeychainStore.loadString( - service: self.gatewayService, - account: self.gatewayPasswordAccount(instanceId: instanceId))? - .trimmingCharacters(in: .whitespacesAndNewlines) - } - - static func saveGatewayPassword(_ password: String, instanceId: String) { - _ = KeychainStore.saveString( - password, - service: self.gatewayService, - account: self.gatewayPasswordAccount(instanceId: instanceId)) - } - - enum LastGatewayConnection: Equatable { - case manual(host: String, port: Int, useTLS: Bool, stableID: String) - case discovered(stableID: String, useTLS: Bool) - - var stableID: String { - switch self { - case let .manual(_, _, _, stableID): - return stableID - case let .discovered(stableID, _): - return stableID - } - } - - var useTLS: Bool { - switch self { - case let .manual(_, _, useTLS, _): - return useTLS - case let .discovered(_, useTLS): - return useTLS - } - } - } - - private enum LastGatewayKind: String { - case manual - case discovered - } - - static func loadTalkElevenLabsApiKey() -> String? { - let value = KeychainStore.loadString( - service: self.talkService, - account: self.talkElevenLabsApiKeyAccount)? - .trimmingCharacters(in: .whitespacesAndNewlines) - if value?.isEmpty == false { return value } - return nil - } - - static func saveTalkElevenLabsApiKey(_ apiKey: String?) { - let trimmed = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if trimmed.isEmpty { - _ = KeychainStore.delete(service: self.talkService, account: self.talkElevenLabsApiKeyAccount) - return - } - _ = KeychainStore.saveString( - trimmed, - service: self.talkService, - account: self.talkElevenLabsApiKeyAccount) - } - - static func saveLastGatewayConnectionManual(host: String, port: Int, useTLS: Bool, stableID: String) { - let defaults = UserDefaults.standard - defaults.set(LastGatewayKind.manual.rawValue, forKey: self.lastGatewayKindDefaultsKey) - 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 saveLastGatewayConnectionDiscovered(stableID: String, useTLS: Bool) { - let defaults = UserDefaults.standard - defaults.set(LastGatewayKind.discovered.rawValue, forKey: self.lastGatewayKindDefaultsKey) - defaults.removeObject(forKey: self.lastGatewayHostDefaultsKey) - defaults.removeObject(forKey: self.lastGatewayPortDefaultsKey) - defaults.set(useTLS, forKey: self.lastGatewayTlsDefaultsKey) - defaults.set(stableID, forKey: self.lastGatewayStableIDDefaultsKey) - } - - static func loadLastGatewayConnection() -> LastGatewayConnection? { - let defaults = UserDefaults.standard - let stableID = defaults.string(forKey: self.lastGatewayStableIDDefaultsKey)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !stableID.isEmpty else { return nil } - let useTLS = defaults.bool(forKey: self.lastGatewayTlsDefaultsKey) - let kindRaw = defaults.string(forKey: self.lastGatewayKindDefaultsKey)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let kind = LastGatewayKind(rawValue: kindRaw) ?? .manual - - if kind == .discovered { - return .discovered(stableID: stableID, useTLS: useTLS) - } - - let host = defaults.string(forKey: self.lastGatewayHostDefaultsKey)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let port = defaults.integer(forKey: self.lastGatewayPortDefaultsKey) - - // Back-compat: older builds persisted manual-style host/port without a kind marker. - guard !host.isEmpty, port > 0, port <= 65535 else { return nil } - return .manual(host: host, port: port, useTLS: useTLS, stableID: stableID) - } - - static func clearLastGatewayConnection(defaults: UserDefaults = .standard) { - defaults.removeObject(forKey: self.lastGatewayKindDefaultsKey) - defaults.removeObject(forKey: self.lastGatewayHostDefaultsKey) - defaults.removeObject(forKey: self.lastGatewayPortDefaultsKey) - defaults.removeObject(forKey: self.lastGatewayTlsDefaultsKey) - defaults.removeObject(forKey: self.lastGatewayStableIDDefaultsKey) - } - - static func deleteGatewayCredentials(instanceId: String) { - let trimmed = instanceId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - _ = KeychainStore.delete( - service: self.gatewayService, - account: self.gatewayTokenAccount(instanceId: trimmed)) - _ = KeychainStore.delete( - service: self.gatewayService, - account: self.gatewayPasswordAccount(instanceId: trimmed)) - } - - 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)" - } - - private static func gatewayPasswordAccount(instanceId: String) -> String { - "gateway-password.\(instanceId)" - } - - private static func ensureStableInstanceID() { - let defaults = UserDefaults.standard - - if let existing = defaults.string(forKey: self.instanceIdDefaultsKey)? - .trimmingCharacters(in: .whitespacesAndNewlines), - !existing.isEmpty - { - if self.loadStableInstanceID() == nil { - self.saveStableInstanceID(existing) - } - return - } - - if let stored = self.loadStableInstanceID(), !stored.isEmpty { - defaults.set(stored, forKey: self.instanceIdDefaultsKey) - return - } - - let fresh = UUID().uuidString - self.saveStableInstanceID(fresh) - defaults.set(fresh, forKey: self.instanceIdDefaultsKey) - } - - private static func ensurePreferredGatewayStableID() { - let defaults = UserDefaults.standard - - if let existing = defaults.string(forKey: self.preferredGatewayStableIDDefaultsKey)? - .trimmingCharacters(in: .whitespacesAndNewlines), - !existing.isEmpty - { - if self.loadPreferredGatewayStableID() == nil { - self.savePreferredGatewayStableID(existing) - } - return - } - - if let stored = self.loadPreferredGatewayStableID(), !stored.isEmpty { - defaults.set(stored, forKey: self.preferredGatewayStableIDDefaultsKey) - } - } - - private static func ensureLastDiscoveredGatewayStableID() { - let defaults = UserDefaults.standard - - if let existing = defaults.string(forKey: self.lastDiscoveredGatewayStableIDDefaultsKey)? - .trimmingCharacters(in: .whitespacesAndNewlines), - !existing.isEmpty - { - if self.loadLastDiscoveredGatewayStableID() == nil { - self.saveLastDiscoveredGatewayStableID(existing) - } - return - } - - if let stored = self.loadLastDiscoveredGatewayStableID(), !stored.isEmpty { - defaults.set(stored, forKey: self.lastDiscoveredGatewayStableIDDefaultsKey) - } - } - -} - -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) - } - } -} diff --git a/apps/ios/Sources/Gateway/GatewaySetupCode.swift b/apps/ios/Sources/Gateway/GatewaySetupCode.swift deleted file mode 100644 index 8ccbab42da7..00000000000 --- a/apps/ios/Sources/Gateway/GatewaySetupCode.swift +++ /dev/null @@ -1,42 +0,0 @@ -import Foundation - -struct GatewaySetupPayload: Codable { - var url: String? - var host: String? - var port: Int? - var tls: Bool? - var token: String? - var password: String? -} - -enum GatewaySetupCode { - static func decode(raw: String) -> GatewaySetupPayload? { - if let payload = decodeFromJSON(raw) { - return payload - } - if let decoded = decodeBase64Payload(raw), - let payload = decodeFromJSON(decoded) - { - return payload - } - return nil - } - - private static func decodeFromJSON(_ json: String) -> GatewaySetupPayload? { - guard let data = json.data(using: .utf8) else { return nil } - return try? JSONDecoder().decode(GatewaySetupPayload.self, from: data) - } - - private static 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) - } -} - diff --git a/apps/ios/Sources/Gateway/GatewayTrustPromptAlert.swift b/apps/ios/Sources/Gateway/GatewayTrustPromptAlert.swift deleted file mode 100644 index eff6b71bad5..00000000000 --- a/apps/ios/Sources/Gateway/GatewayTrustPromptAlert.swift +++ /dev/null @@ -1,41 +0,0 @@ -import SwiftUI - -struct GatewayTrustPromptAlert: ViewModifier { - @Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController - - private var promptBinding: Binding { - Binding( - get: { self.gatewayController.pendingTrustPrompt }, - set: { _ in - // Keep pending trust state until explicit user action. - // `alert(item:)` may set the binding to nil during dismissal, which can race with - // the button handler and cause accept to no-op. - }) - } - - func body(content: Content) -> some View { - content.alert(item: self.promptBinding) { prompt in - Alert( - title: Text("Trust this gateway?"), - message: Text( - """ - First-time TLS connection. - - Verify this SHA-256 fingerprint out-of-band before trusting: - \(prompt.fingerprintSha256) - """), - primaryButton: .cancel(Text("Cancel")) { - self.gatewayController.declinePendingTrustPrompt() - }, - secondaryButton: .default(Text("Trust and connect")) { - Task { await self.gatewayController.acceptPendingTrustPrompt() } - }) - } - } -} - -extension View { - func gatewayTrustPromptAlert() -> some View { - self.modifier(GatewayTrustPromptAlert()) - } -} diff --git a/apps/ios/Sources/Gateway/KeychainStore.swift b/apps/ios/Sources/Gateway/KeychainStore.swift deleted file mode 100644 index 1377d8517ef..00000000000 --- a/apps/ios/Sources/Gateway/KeychainStore.swift +++ /dev/null @@ -1,48 +0,0 @@ -import Foundation -import Security - -enum KeychainStore { - static func loadString(service: String, account: String) -> String? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne, - ] - - var item: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &item) - guard status == errSecSuccess, let data = item as? Data else { return nil } - return String(data: data, encoding: .utf8) - } - - static func saveString(_ value: String, service: String, account: String) -> Bool { - let data = Data(value.utf8) - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - ] - - let update: [String: Any] = [kSecValueData as String: data] - let status = SecItemUpdate(query as CFDictionary, update as CFDictionary) - if status == errSecSuccess { return true } - if status != errSecItemNotFound { return false } - - var insert = query - insert[kSecValueData as String] = data - insert[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - return SecItemAdd(insert as CFDictionary, nil) == errSecSuccess - } - - static func delete(service: String, account: String) -> Bool { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - ] - let status = SecItemDelete(query as CFDictionary) - return status == errSecSuccess || status == errSecItemNotFound - } -} diff --git a/apps/ios/Sources/Gateway/TCPProbe.swift b/apps/ios/Sources/Gateway/TCPProbe.swift deleted file mode 100644 index e22da96298f..00000000000 --- a/apps/ios/Sources/Gateway/TCPProbe.swift +++ /dev/null @@ -1,43 +0,0 @@ -import Foundation -import Network -import os - -enum TCPProbe { - static func probe(host: String, port: Int, timeoutSeconds: Double, queueLabel: String) async -> Bool { - guard port >= 1, port <= 65535 else { return false } - 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: queueLabel) - 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) } - } - } -} - diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist deleted file mode 100644 index c3b469e7092..00000000000 --- a/apps/ios/Sources/Info.plist +++ /dev/null @@ -1,88 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - OpenClaw - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIconName - AppIcon - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - APPL - CFBundleShortVersionString - 2026.2.21 - CFBundleURLTypes - - - CFBundleURLName - ai.openclaw.ios - CFBundleURLSchemes - - openclaw - - - - CFBundleVersion - 20260220 - NSAppTransportSecurity - - NSAllowsArbitraryLoadsInWebContent - - - NSBonjourServices - - _openclaw-gw._tcp - - NSCameraUsageDescription - OpenClaw can capture photos or short video clips when requested via the gateway. - NSLocalNetworkUsageDescription - OpenClaw discovers and connects to your OpenClaw gateway on the local network. - NSLocationAlwaysAndWhenInUseUsageDescription - OpenClaw can share your location in the background when you enable Always. - NSLocationWhenInUseUsageDescription - OpenClaw uses your location when you allow location sharing. - NSMicrophoneUsageDescription - OpenClaw needs microphone access for voice wake. - NSSpeechRecognitionUsageDescription - OpenClaw uses on-device speech recognition for voice wake. - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - - UIBackgroundModes - - audio - remote-notification - - BGTaskSchedulerPermittedIdentifiers - - ai.openclaw.ios.bgrefresh - - UILaunchScreen - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - - diff --git a/apps/ios/Sources/Location/LocationService.swift b/apps/ios/Sources/Location/LocationService.swift deleted file mode 100644 index f1f0f69ed7f..00000000000 --- a/apps/ios/Sources/Location/LocationService.swift +++ /dev/null @@ -1,202 +0,0 @@ -import OpenClawKit -import CoreLocation -import Foundation - -@MainActor -final class LocationService: NSObject, CLLocationManagerDelegate { - enum Error: Swift.Error { - case timeout - case unavailable - } - - private let manager = CLLocationManager() - private var authContinuation: CheckedContinuation? - private var locationContinuation: CheckedContinuation? - private var updatesContinuation: AsyncStream.Continuation? - private var isStreaming = false - private var significantLocationCallback: (@Sendable (CLLocation) -> Void)? - private var isMonitoringSignificantChanges = false - - override init() { - super.init() - self.manager.delegate = self - self.manager.desiredAccuracy = kCLLocationAccuracyBest - } - - func authorizationStatus() -> CLAuthorizationStatus { - self.manager.authorizationStatus - } - - func accuracyAuthorization() -> CLAccuracyAuthorization { - if #available(iOS 14.0, *) { - return self.manager.accuracyAuthorization - } - return .fullAccuracy - } - - func ensureAuthorization(mode: OpenClawLocationMode) async -> CLAuthorizationStatus { - guard CLLocationManager.locationServicesEnabled() else { return .denied } - - let status = self.manager.authorizationStatus - if status == .notDetermined { - self.manager.requestWhenInUseAuthorization() - let updated = await self.awaitAuthorizationChange() - if mode != .always { return updated } - } - - if mode == .always { - let current = self.manager.authorizationStatus - if current == .authorizedWhenInUse { - self.manager.requestAlwaysAuthorization() - return await self.awaitAuthorizationChange() - } - return current - } - - return self.manager.authorizationStatus - } - - func currentLocation( - params: OpenClawLocationGetParams, - desiredAccuracy: OpenClawLocationAccuracy, - maxAgeMs: Int?, - timeoutMs: Int?) async throws -> CLLocation - { - let now = Date() - if let maxAgeMs, - let cached = self.manager.location, - now.timeIntervalSince(cached.timestamp) * 1000 <= Double(maxAgeMs) - { - return cached - } - - self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy) - let timeout = max(0, timeoutMs ?? 10000) - return try await self.withTimeout(timeoutMs: timeout) { - try await self.requestLocation() - } - } - - private func requestLocation() async throws -> CLLocation { - try await withCheckedThrowingContinuation { cont in - self.locationContinuation = cont - self.manager.requestLocation() - } - } - - private func awaitAuthorizationChange() async -> CLAuthorizationStatus { - await withCheckedContinuation { cont in - self.authContinuation = cont - } - } - - private func withTimeout( - timeoutMs: Int, - operation: @escaping @Sendable () async throws -> T) async throws -> T - { - try await AsyncTimeout.withTimeoutMs(timeoutMs: timeoutMs, onTimeout: { Error.timeout }, operation: operation) - } - - private static func accuracyValue(_ accuracy: OpenClawLocationAccuracy) -> CLLocationAccuracy { - switch accuracy { - case .coarse: - kCLLocationAccuracyKilometer - case .balanced: - kCLLocationAccuracyHundredMeters - case .precise: - kCLLocationAccuracyBest - } - } - - func startLocationUpdates( - desiredAccuracy: OpenClawLocationAccuracy, - significantChangesOnly: Bool) -> AsyncStream - { - self.stopLocationUpdates() - - self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy) - self.manager.pausesLocationUpdatesAutomatically = true - self.manager.allowsBackgroundLocationUpdates = true - - self.isStreaming = true - if significantChangesOnly { - self.manager.startMonitoringSignificantLocationChanges() - } else { - self.manager.startUpdatingLocation() - } - - return AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in - self.updatesContinuation = continuation - continuation.onTermination = { @Sendable _ in - Task { @MainActor in - self.stopLocationUpdates() - } - } - } - } - - func stopLocationUpdates() { - guard self.isStreaming else { return } - self.isStreaming = false - self.manager.stopUpdatingLocation() - self.manager.stopMonitoringSignificantLocationChanges() - self.updatesContinuation?.finish() - self.updatesContinuation = nil - } - - func startMonitoringSignificantLocationChanges(onUpdate: @escaping @Sendable (CLLocation) -> Void) { - self.significantLocationCallback = onUpdate - guard !self.isMonitoringSignificantChanges else { return } - self.isMonitoringSignificantChanges = true - self.manager.startMonitoringSignificantLocationChanges() - } - - func stopMonitoringSignificantLocationChanges() { - guard self.isMonitoringSignificantChanges else { return } - self.isMonitoringSignificantChanges = false - self.significantLocationCallback = nil - self.manager.stopMonitoringSignificantLocationChanges() - } - - nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { - let status = manager.authorizationStatus - Task { @MainActor in - if let cont = self.authContinuation { - self.authContinuation = nil - cont.resume(returning: status) - } - } - } - - nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { - let locs = locations - Task { @MainActor in - // Resolve the one-shot continuation first (if any). - if let cont = self.locationContinuation { - self.locationContinuation = nil - if let latest = locs.last { - cont.resume(returning: latest) - } else { - cont.resume(throwing: Error.unavailable) - } - // Don't return — also forward to significant-change callback below - // so both consumers receive updates when both are active. - } - if let callback = self.significantLocationCallback, let latest = locs.last { - callback(latest) - } - if let latest = locs.last, let updates = self.updatesContinuation { - updates.yield(latest) - } - } - } - - nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Swift.Error) { - let err = error - Task { @MainActor in - guard let cont = self.locationContinuation else { return } - self.locationContinuation = nil - cont.resume(throwing: err) - } - } -} diff --git a/apps/ios/Sources/Location/SignificantLocationMonitor.swift b/apps/ios/Sources/Location/SignificantLocationMonitor.swift deleted file mode 100644 index 1b8d5ca2a0d..00000000000 --- a/apps/ios/Sources/Location/SignificantLocationMonitor.swift +++ /dev/null @@ -1,42 +0,0 @@ -import CoreLocation -import Foundation -import OpenClawKit - -/// Monitors significant location changes and pushes `location.update` -/// events to the gateway so the severance hook can determine whether -/// the user is at their configured work location. -@MainActor -enum SignificantLocationMonitor { - static func startIfNeeded( - locationService: any LocationServicing, - locationMode: OpenClawLocationMode, - gateway: GatewayNodeSession, - beforeSend: (@MainActor @Sendable () async -> Void)? = nil - ) { - guard locationMode == .always else { return } - let status = locationService.authorizationStatus() - guard status == .authorizedAlways else { return } - locationService.startMonitoringSignificantLocationChanges { location in - struct Payload: Codable { - var lat: Double - var lon: Double - var accuracyMeters: Double - var source: String? - } - let payload = Payload( - lat: location.coordinate.latitude, - lon: location.coordinate.longitude, - accuracyMeters: location.horizontalAccuracy, - source: "ios-significant-location") - guard let data = try? JSONEncoder().encode(payload), - let json = String(data: data, encoding: .utf8) - else { return } - Task { @MainActor in - if let beforeSend { - await beforeSend() - } - await gateway.sendEvent(event: "location.update", payloadJSON: json) - } - } - } -} diff --git a/apps/ios/Sources/Media/PhotoLibraryService.swift b/apps/ios/Sources/Media/PhotoLibraryService.swift deleted file mode 100644 index f66beb3e707..00000000000 --- a/apps/ios/Sources/Media/PhotoLibraryService.swift +++ /dev/null @@ -1,164 +0,0 @@ -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)) - } - } -} diff --git a/apps/ios/Sources/Model/NodeAppModel+Canvas.swift b/apps/ios/Sources/Model/NodeAppModel+Canvas.swift deleted file mode 100644 index e8dce2cd30c..00000000000 --- a/apps/ios/Sources/Model/NodeAppModel+Canvas.swift +++ /dev/null @@ -1,70 +0,0 @@ -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) - return await TCPProbe.probe( - host: host, - port: portInt, - timeoutSeconds: timeoutSeconds, - queueLabel: "a2ui.preflight") - } -} diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift deleted file mode 100644 index 5bd98e6f492..00000000000 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ /dev/null @@ -1,2611 +0,0 @@ -import OpenClawChatUI -import OpenClawKit -import OpenClawProtocol -import Observation -import os -import SwiftUI -import UIKit -import UserNotifications - -// Wrap errors without pulling non-Sendable types into async notification paths. -private struct NotificationCallError: Error, Sendable { - let message: String -} -// Ensures notification requests return promptly even if the system prompt blocks. -private final class NotificationInvokeLatch: @unchecked Sendable { - private let lock = NSLock() - private var continuation: CheckedContinuation, Never>? - private var resumed = false - - func setContinuation(_ continuation: CheckedContinuation, Never>) { - self.lock.lock() - defer { self.lock.unlock() } - self.continuation = continuation - } - - func resume(_ response: Result) { - let cont: CheckedContinuation, Never>? - self.lock.lock() - if self.resumed { - self.lock.unlock() - return - } - self.resumed = true - cont = self.continuation - self.continuation = nil - self.lock.unlock() - cont?.resume(returning: response) - } -} -@MainActor -@Observable -final class NodeAppModel { - private let deepLinkLogger = Logger(subsystem: "ai.openclaw.ios", category: "DeepLink") - private let pushWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "PushWake") - private let locationWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "LocationWake") - private let watchReplyLogger = Logger(subsystem: "ai.openclaw.ios", category: "WatchReply") - enum CameraHUDKind { - case photo - case recording - case success - case error - } - - var isBackgrounded: Bool = false - let screen: ScreenController - private let camera: any CameraServicing - private let screenRecorder: any ScreenRecordingServicing - var gatewayStatusText: String = "Offline" - var nodeStatusText: String = "Offline" - var operatorStatusText: String = "Offline" - var gatewayServerName: String? - var gatewayRemoteAddress: String? - var connectedGatewayID: String? - var gatewayAutoReconnectEnabled: Bool = true - // When the gateway requires pairing approval, we pause reconnect churn and show a stable UX. - // Reconnect loops (both our own and the underlying WebSocket watchdog) can otherwise generate - // multiple pending requests and cause the onboarding UI to "flip-flop". - var gatewayPairingPaused: Bool = false - var gatewayPairingRequestId: String? - var seamColorHex: String? - private var mainSessionBaseKey: String = "main" - var selectedAgentId: String? - var gatewayDefaultAgentId: String? - var gatewayAgents: [AgentSummary] = [] - var lastShareEventText: String = "No share events yet." - var openChatRequestID: Int = 0 - - // Primary "node" connection: used for device capabilities and node.invoke requests. - private let nodeGateway = GatewayNodeSession() - // Secondary "operator" connection: used for chat/talk/config/voicewake requests. - private let operatorGateway = GatewayNodeSession() - private var nodeGatewayTask: Task? - private var operatorGatewayTask: Task? - private var voiceWakeSyncTask: Task? - @ObservationIgnored private var cameraHUDDismissTask: Task? - @ObservationIgnored private lazy var capabilityRouter: NodeCapabilityRouter = self.buildCapabilityRouter() - private let gatewayHealthMonitor = GatewayHealthMonitor() - private var gatewayHealthMonitorDisabled = false - private let notificationCenter: NotificationCentering - let voiceWake = VoiceWakeManager() - let talkMode: TalkModeManager - private let locationService: any LocationServicing - private let deviceStatusService: any DeviceStatusServicing - private let photosService: any PhotosServicing - private let contactsService: any ContactsServicing - private let calendarService: any CalendarServicing - private let remindersService: any RemindersServicing - private let motionService: any MotionServicing - private let watchMessagingService: any WatchMessagingServicing - var lastAutoA2uiURL: String? - private var pttVoiceWakeSuspended = false - private var talkVoiceWakeSuspended = false - private var backgroundVoiceWakeSuspended = false - private var backgroundTalkSuspended = false - private var backgroundTalkKeptActive = false - private var backgroundedAt: Date? - private var reconnectAfterBackgroundArmed = false - private var backgroundGraceTaskID: UIBackgroundTaskIdentifier = .invalid - @ObservationIgnored private var backgroundGraceTaskTimer: Task? - private var backgroundReconnectSuppressed = false - private var backgroundReconnectLeaseUntil: Date? - private var lastSignificantLocationWakeAt: Date? - private var queuedWatchReplies: [WatchQuickReplyEvent] = [] - private var seenWatchReplyIds = Set() - - private var gatewayConnected = false - private var operatorConnected = false - private var shareDeliveryChannel: String? - private var shareDeliveryTo: String? - private var apnsDeviceTokenHex: String? - private var apnsLastRegisteredTokenHex: String? - var gatewaySession: GatewayNodeSession { self.nodeGateway } - var operatorSession: GatewayNodeSession { self.operatorGateway } - private(set) var activeGatewayConnectConfig: GatewayConnectConfig? - - var cameraHUDText: String? - var cameraHUDKind: CameraHUDKind? - var cameraFlashNonce: Int = 0 - var screenRecordActive: Bool = false - - init( - screen: ScreenController = ScreenController(), - camera: any CameraServicing = CameraController(), - screenRecorder: any ScreenRecordingServicing = ScreenRecordService(), - locationService: any LocationServicing = LocationService(), - notificationCenter: NotificationCentering = LiveNotificationCenter(), - deviceStatusService: any DeviceStatusServicing = DeviceStatusService(), - photosService: any PhotosServicing = PhotoLibraryService(), - contactsService: any ContactsServicing = ContactsService(), - calendarService: any CalendarServicing = CalendarService(), - remindersService: any RemindersServicing = RemindersService(), - motionService: any MotionServicing = MotionService(), - watchMessagingService: any WatchMessagingServicing = WatchMessagingService(), - talkMode: TalkModeManager = TalkModeManager()) - { - self.screen = screen - self.camera = camera - self.screenRecorder = screenRecorder - self.locationService = locationService - self.notificationCenter = notificationCenter - self.deviceStatusService = deviceStatusService - self.photosService = photosService - self.contactsService = contactsService - self.calendarService = calendarService - self.remindersService = remindersService - self.motionService = motionService - self.watchMessagingService = watchMessagingService - self.talkMode = talkMode - self.apnsDeviceTokenHex = UserDefaults.standard.string(forKey: Self.apnsDeviceTokenUserDefaultsKey) - GatewayDiagnostics.bootstrap() - self.watchMessagingService.setReplyHandler { [weak self] event in - Task { @MainActor in - await self?.handleWatchQuickReply(event) - } - } - - self.voiceWake.configure { [weak self] cmd in - guard let self else { return } - let sessionKey = await MainActor.run { self.mainSessionKey } - do { - try await self.sendVoiceTranscript(text: cmd, sessionKey: sessionKey) - } catch { - // Best-effort only. - } - } - - let enabled = UserDefaults.standard.bool(forKey: "voiceWake.enabled") - self.voiceWake.setEnabled(enabled) - self.talkMode.attachGateway(self.operatorGateway) - self.refreshLastShareEventFromRelay() - let talkEnabled = UserDefaults.standard.bool(forKey: "talk.enabled") - // Route through the coordinator so VoiceWake and Talk don't fight over the microphone. - self.setTalkEnabled(talkEnabled) - - // Wire up deep links from canvas taps - self.screen.onDeepLink = { [weak self] url in - guard let self else { return } - Task { @MainActor in - await self.handleDeepLink(url: url) - } - } - - // Wire up A2UI action clicks (buttons, etc.) - self.screen.onA2UIAction = { [weak self] body in - guard let self else { return } - Task { @MainActor in - await self.handleCanvasA2UIAction(body: body) - } - } - } - - private func handleCanvasA2UIAction(body: [String: Any]) async { - let userActionAny = body["userAction"] ?? body - let userAction: [String: Any] = { - if let dict = userActionAny as? [String: Any] { return dict } - if let dict = userActionAny as? [AnyHashable: Any] { - return dict.reduce(into: [String: Any]()) { acc, pair in - guard let key = pair.key as? String else { return } - acc[key] = pair.value - } - } - return [:] - }() - guard !userAction.isEmpty else { return } - - guard let name = OpenClawCanvasA2UIAction.extractActionName(userAction) else { return } - let actionId: String = { - let id = (userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return id.isEmpty ? UUID().uuidString : id - }() - - let surfaceId: String = { - let raw = (userAction["surfaceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return raw.isEmpty ? "main" : raw - }() - let sourceComponentId: String = { - let raw = (userAction[ - "sourceComponentId", - ] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return raw.isEmpty ? "-" : raw - }() - - let host = NodeDisplayName.resolve( - existing: UserDefaults.standard.string(forKey: "node.displayName"), - deviceName: UIDevice.current.name, - interfaceIdiom: UIDevice.current.userInterfaceIdiom) - let instanceId = (UserDefaults.standard.string(forKey: "node.instanceId") ?? "ios-node").lowercased() - let contextJSON = OpenClawCanvasA2UIAction.compactJSON(userAction["context"]) - let sessionKey = self.mainSessionKey - - let messageContext = OpenClawCanvasA2UIAction.AgentMessageContext( - actionName: name, - session: .init(key: sessionKey, surfaceId: surfaceId), - component: .init(id: sourceComponentId, host: host, instanceId: instanceId), - contextJSON: contextJSON) - let message = OpenClawCanvasA2UIAction.formatAgentMessage(messageContext) - - let ok: Bool - var errorText: String? - if await !self.isGatewayConnected() { - ok = false - errorText = "gateway not connected" - } else { - do { - try await self.sendAgentRequest(link: AgentDeepLink( - message: message, - sessionKey: sessionKey, - thinking: "low", - deliver: false, - to: nil, - channel: nil, - timeoutSeconds: nil, - key: actionId)) - ok = true - } catch { - ok = false - errorText = error.localizedDescription - } - } - - let js = OpenClawCanvasA2UIAction.jsDispatchA2UIActionStatus(actionId: actionId, ok: ok, error: errorText) - do { - _ = try await self.screen.eval(javaScript: js) - } catch { - // ignore - } - } - - - func setScenePhase(_ phase: ScenePhase) { - let keepTalkActive = UserDefaults.standard.bool(forKey: "talk.background.enabled") - switch phase { - case .background: - self.isBackgrounded = true - self.stopGatewayHealthMonitor() - self.backgroundedAt = Date() - self.reconnectAfterBackgroundArmed = true - self.beginBackgroundConnectionGracePeriod() - // Release voice wake mic in background. - self.backgroundVoiceWakeSuspended = self.voiceWake.suspendForExternalAudioCapture() - let shouldKeepTalkActive = keepTalkActive && self.talkMode.isEnabled - self.backgroundTalkKeptActive = shouldKeepTalkActive - self.backgroundTalkSuspended = self.talkMode.suspendForBackground(keepActive: shouldKeepTalkActive) - case .active, .inactive: - self.isBackgrounded = false - self.endBackgroundConnectionGracePeriod(reason: "scene_foreground") - self.clearBackgroundReconnectSuppression(reason: "scene_foreground") - if self.operatorConnected { - self.startGatewayHealthMonitor() - } - if phase == .active { - self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: self.backgroundVoiceWakeSuspended) - self.backgroundVoiceWakeSuspended = false - Task { [weak self] in - guard let self else { return } - let suspended = await MainActor.run { self.backgroundTalkSuspended } - let keptActive = await MainActor.run { self.backgroundTalkKeptActive } - await MainActor.run { - self.backgroundTalkSuspended = false - self.backgroundTalkKeptActive = false - } - await self.talkMode.resumeAfterBackground(wasSuspended: suspended, wasKeptActive: keptActive) - } - } - if phase == .active, self.reconnectAfterBackgroundArmed { - self.reconnectAfterBackgroundArmed = false - let backgroundedFor = self.backgroundedAt.map { Date().timeIntervalSince($0) } ?? 0 - self.backgroundedAt = nil - // iOS may suspend network sockets in background without a clean close. - // On foreground, force a fresh handshake to avoid "connected but dead" states. - if backgroundedFor >= 3.0 { - Task { [weak self] in - guard let self else { return } - let operatorWasConnected = await MainActor.run { self.operatorConnected } - if operatorWasConnected { - // Prefer keeping the connection if it's healthy; reconnect only when needed. - let healthy = (try? await self.operatorGateway.request( - method: "health", - paramsJSON: nil, - timeoutSeconds: 2)) != nil - if healthy { - await MainActor.run { self.startGatewayHealthMonitor() } - return - } - } - - await self.operatorGateway.disconnect() - await self.nodeGateway.disconnect() - await MainActor.run { - self.operatorConnected = false - self.gatewayConnected = false - self.talkMode.updateGatewayConnected(false) - } - } - } - } - @unknown default: - self.isBackgrounded = false - self.endBackgroundConnectionGracePeriod(reason: "scene_unknown") - self.clearBackgroundReconnectSuppression(reason: "scene_unknown") - } - } - - private func beginBackgroundConnectionGracePeriod(seconds: TimeInterval = 25) { - self.grantBackgroundReconnectLease(seconds: seconds, reason: "scene_background_grace") - self.endBackgroundConnectionGracePeriod(reason: "restart") - let taskID = UIApplication.shared.beginBackgroundTask(withName: "gateway-background-grace") { [weak self] in - Task { @MainActor in - self?.suppressBackgroundReconnect( - reason: "background_grace_expired", - disconnectIfNeeded: true) - self?.endBackgroundConnectionGracePeriod(reason: "expired") - } - } - guard taskID != .invalid else { - self.pushWakeLogger.info("Background grace unavailable: beginBackgroundTask returned invalid") - return - } - self.backgroundGraceTaskID = taskID - self.pushWakeLogger.info("Background grace started seconds=\(seconds, privacy: .public)") - self.backgroundGraceTaskTimer = Task { [weak self] in - guard let self else { return } - try? await Task.sleep(nanoseconds: UInt64(max(1, seconds) * 1_000_000_000)) - await MainActor.run { - self.suppressBackgroundReconnect(reason: "background_grace_timer", disconnectIfNeeded: true) - self.endBackgroundConnectionGracePeriod(reason: "timer") - } - } - } - - private func endBackgroundConnectionGracePeriod(reason: String) { - self.backgroundGraceTaskTimer?.cancel() - self.backgroundGraceTaskTimer = nil - guard self.backgroundGraceTaskID != .invalid else { return } - UIApplication.shared.endBackgroundTask(self.backgroundGraceTaskID) - self.backgroundGraceTaskID = .invalid - self.pushWakeLogger.info("Background grace ended reason=\(reason, privacy: .public)") - } - - private func grantBackgroundReconnectLease(seconds: TimeInterval, reason: String) { - guard self.isBackgrounded else { return } - let leaseSeconds = max(5, seconds) - let leaseUntil = Date().addingTimeInterval(leaseSeconds) - if let existing = self.backgroundReconnectLeaseUntil, existing > leaseUntil { - // Keep the longer lease if one is already active. - } else { - self.backgroundReconnectLeaseUntil = leaseUntil - } - let wasSuppressed = self.backgroundReconnectSuppressed - self.backgroundReconnectSuppressed = false - self.pushWakeLogger.info( - "Background reconnect lease reason=\(reason, privacy: .public) seconds=\(leaseSeconds, privacy: .public) wasSuppressed=\(wasSuppressed, privacy: .public)") - } - - private func suppressBackgroundReconnect(reason: String, disconnectIfNeeded: Bool) { - guard self.isBackgrounded else { return } - let hadLease = self.backgroundReconnectLeaseUntil != nil - let changed = hadLease || !self.backgroundReconnectSuppressed - self.backgroundReconnectLeaseUntil = nil - self.backgroundReconnectSuppressed = true - guard changed else { return } - self.pushWakeLogger.info( - "Background reconnect suppressed reason=\(reason, privacy: .public) disconnect=\(disconnectIfNeeded, privacy: .public)") - guard disconnectIfNeeded else { return } - Task { [weak self] in - guard let self else { return } - await self.operatorGateway.disconnect() - await self.nodeGateway.disconnect() - await MainActor.run { - self.operatorConnected = false - self.gatewayConnected = false - self.talkMode.updateGatewayConnected(false) - if self.isBackgrounded { - self.gatewayStatusText = "Background idle" - self.gatewayServerName = nil - self.gatewayRemoteAddress = nil - self.showLocalCanvasOnDisconnect() - } - } - } - } - - private func clearBackgroundReconnectSuppression(reason: String) { - let changed = self.backgroundReconnectSuppressed || self.backgroundReconnectLeaseUntil != nil - self.backgroundReconnectSuppressed = false - self.backgroundReconnectLeaseUntil = nil - guard changed else { return } - self.pushWakeLogger.info("Background reconnect cleared reason=\(reason, privacy: .public)") - } - - func setVoiceWakeEnabled(_ enabled: Bool) { - self.voiceWake.setEnabled(enabled) - if enabled { - // If talk is enabled, voice wake should not grab the mic. - if self.talkMode.isEnabled { - self.voiceWake.setSuppressedByTalk(true) - self.talkVoiceWakeSuspended = self.voiceWake.suspendForExternalAudioCapture() - } - } else { - self.voiceWake.setSuppressedByTalk(false) - self.talkVoiceWakeSuspended = false - } - } - - func setTalkEnabled(_ enabled: Bool) { - UserDefaults.standard.set(enabled, forKey: "talk.enabled") - if enabled { - // Voice wake holds the microphone continuously; talk mode needs exclusive access for STT. - // When talk is enabled from the UI, prioritize talk and pause voice wake. - self.voiceWake.setSuppressedByTalk(true) - self.talkVoiceWakeSuspended = self.voiceWake.suspendForExternalAudioCapture() - } else { - self.voiceWake.setSuppressedByTalk(false) - self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: self.talkVoiceWakeSuspended) - self.talkVoiceWakeSuspended = false - } - self.talkMode.setEnabled(enabled) - Task { [weak self] in - await self?.pushTalkModeToGateway( - enabled: enabled, - phase: enabled ? "enabled" : "disabled") - } - } - - func requestLocationPermissions(mode: OpenClawLocationMode) async -> Bool { - guard mode != .off else { return true } - let status = await self.locationService.ensureAuthorization(mode: mode) - switch status { - case .authorizedAlways: - return true - case .authorizedWhenInUse: - return mode != .always - default: - return false - } - } - - private func applyMainSessionKey(_ key: String?) { - let trimmed = (key ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - let current = self.mainSessionBaseKey.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed == current { return } - self.mainSessionBaseKey = trimmed - self.talkMode.updateMainSessionKey(self.mainSessionKey) - } - - var seamColor: Color { - Self.color(fromHex: self.seamColorHex) ?? Self.defaultSeamColor - } - - private static let defaultSeamColor = Color(red: 79 / 255.0, green: 122 / 255.0, blue: 154 / 255.0) - private static let apnsDeviceTokenUserDefaultsKey = "push.apns.deviceTokenHex" - private static var apnsEnvironment: String { -#if DEBUG - "sandbox" -#else - "production" -#endif - } - - private static func color(fromHex raw: String?) -> Color? { - let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed - guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil } - let r = Double((value >> 16) & 0xFF) / 255.0 - let g = Double((value >> 8) & 0xFF) / 255.0 - let b = Double(value & 0xFF) / 255.0 - return Color(red: r, green: g, blue: b) - } - - private func refreshBrandingFromGateway() async { - do { - let res = try await self.operatorGateway.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8) - guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return } - guard let config = json["config"] as? [String: Any] else { return } - let ui = config["ui"] as? [String: Any] - let raw = (ui?["seamColor"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let session = config["session"] as? [String: Any] - let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String) - await MainActor.run { - self.seamColorHex = raw.isEmpty ? nil : raw - self.mainSessionBaseKey = mainKey - self.talkMode.updateMainSessionKey(self.mainSessionKey) - } - } catch { - if let gatewayError = error as? GatewayResponseError { - let lower = gatewayError.message.lowercased() - if lower.contains("unauthorized role") { - return - } - } - // ignore - } - } - - private func refreshAgentsFromGateway() async { - do { - let res = try await self.operatorGateway.request(method: "agents.list", paramsJSON: "{}", timeoutSeconds: 8) - let decoded = try JSONDecoder().decode(AgentsListResult.self, from: res) - await MainActor.run { - self.gatewayDefaultAgentId = decoded.defaultid - self.gatewayAgents = decoded.agents - self.applyMainSessionKey(decoded.mainkey) - - let selected = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - if !selected.isEmpty && !decoded.agents.contains(where: { $0.id == selected }) { - self.selectedAgentId = nil - } - self.talkMode.updateMainSessionKey(self.mainSessionKey) - } - } catch { - // Best-effort only. - } - } - - func setSelectedAgentId(_ agentId: String?) { - let trimmed = (agentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - let stableID = (self.connectedGatewayID ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - if stableID.isEmpty { - self.selectedAgentId = trimmed.isEmpty ? nil : trimmed - } else { - self.selectedAgentId = trimmed.isEmpty ? nil : trimmed - GatewaySettingsStore.saveGatewaySelectedAgentId(stableID: stableID, agentId: self.selectedAgentId) - } - self.talkMode.updateMainSessionKey(self.mainSessionKey) - if let relay = ShareGatewayRelaySettings.loadConfig() { - ShareGatewayRelaySettings.saveConfig( - ShareGatewayRelayConfig( - gatewayURLString: relay.gatewayURLString, - token: relay.token, - password: relay.password, - sessionKey: self.mainSessionKey, - deliveryChannel: self.shareDeliveryChannel, - deliveryTo: self.shareDeliveryTo)) - } - } - - func setGlobalWakeWords(_ words: [String]) async { - let sanitized = VoiceWakePreferences.sanitizeTriggerWords(words) - - struct Payload: Codable { - var triggers: [String] - } - let payload = Payload(triggers: sanitized) - guard let data = try? JSONEncoder().encode(payload), - let json = String(data: data, encoding: .utf8) - else { return } - - do { - _ = try await self.operatorGateway.request(method: "voicewake.set", paramsJSON: json, timeoutSeconds: 12) - } catch { - // Best-effort only. - } - } - - private func startVoiceWakeSync() async { - self.voiceWakeSyncTask?.cancel() - self.voiceWakeSyncTask = Task { [weak self] in - guard let self else { return } - - if !(await self.isGatewayHealthMonitorDisabled()) { - await self.refreshWakeWordsFromGateway() - } - - let stream = await self.operatorGateway.subscribeServerEvents(bufferingNewest: 200) - for await evt in stream { - if Task.isCancelled { return } - guard let payload = evt.payload else { continue } - switch evt.event { - case "voicewake.changed": - struct Payload: Decodable { var triggers: [String] } - guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { continue } - let triggers = VoiceWakePreferences.sanitizeTriggerWords(decoded.triggers) - VoiceWakePreferences.saveTriggerWords(triggers) - case "talk.mode": - struct Payload: Decodable { - var enabled: Bool - var phase: String? - } - guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { continue } - self.applyTalkModeSync(enabled: decoded.enabled, phase: decoded.phase) - default: - continue - } - } - } - } - - private func applyTalkModeSync(enabled: Bool, phase: String?) { - _ = phase - guard self.talkMode.isEnabled != enabled else { return } - self.setTalkEnabled(enabled) - } - - private func pushTalkModeToGateway(enabled: Bool, phase: String?) async { - guard await self.isOperatorConnected() else { return } - struct TalkModePayload: Encodable { - var enabled: Bool - var phase: String? - } - let payload = TalkModePayload(enabled: enabled, phase: phase) - guard let data = try? JSONEncoder().encode(payload), - let json = String(data: data, encoding: .utf8) - else { return } - _ = try? await self.operatorGateway.request( - method: "talk.mode", - paramsJSON: json, - timeoutSeconds: 8) - } - - private func startGatewayHealthMonitor() { - self.gatewayHealthMonitorDisabled = false - self.gatewayHealthMonitor.start( - check: { [weak self] in - guard let self else { return false } - if await self.isGatewayHealthMonitorDisabled() { return true } - do { - let data = try await self.operatorGateway.request(method: "health", paramsJSON: nil, timeoutSeconds: 6) - guard let decoded = try? JSONDecoder().decode(OpenClawGatewayHealthOK.self, from: data) else { - return false - } - return decoded.ok ?? false - } catch { - if let gatewayError = error as? GatewayResponseError { - let lower = gatewayError.message.lowercased() - if lower.contains("unauthorized role") || lower.contains("missing scope") { - await self.setGatewayHealthMonitorDisabled(true) - return true - } - } - return false - } - }, - onFailure: { [weak self] _ in - guard let self else { return } - await self.operatorGateway.disconnect() - await self.nodeGateway.disconnect() - await MainActor.run { - self.operatorConnected = false - self.gatewayConnected = false - self.gatewayStatusText = "Reconnecting…" - self.talkMode.updateGatewayConnected(false) - } - }) - } - - private func stopGatewayHealthMonitor() { - self.gatewayHealthMonitor.stop() - } - - private func refreshWakeWordsFromGateway() async { - do { - let data = try await self.operatorGateway.request(method: "voicewake.get", paramsJSON: "{}", timeoutSeconds: 8) - guard let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: data) else { return } - VoiceWakePreferences.saveTriggerWords(triggers) - } catch { - if let gatewayError = error as? GatewayResponseError { - let lower = gatewayError.message.lowercased() - if lower.contains("unauthorized role") || lower.contains("missing scope") { - await self.setGatewayHealthMonitorDisabled(true) - return - } - } - // Best-effort only. - } - } - - private func isGatewayHealthMonitorDisabled() -> Bool { - self.gatewayHealthMonitorDisabled - } - - private func setGatewayHealthMonitorDisabled(_ disabled: Bool) { - self.gatewayHealthMonitorDisabled = disabled - } - - func sendVoiceTranscript(text: String, sessionKey: String?) async throws { - if await !self.isGatewayConnected() { - throw NSError(domain: "Gateway", code: 10, userInfo: [ - NSLocalizedDescriptionKey: "Gateway not connected", - ]) - } - struct Payload: Codable { - var text: String - var sessionKey: String? - } - let payload = Payload(text: text, sessionKey: sessionKey) - let data = try JSONEncoder().encode(payload) - guard let json = String(bytes: data, encoding: .utf8) else { - throw NSError(domain: "NodeAppModel", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Failed to encode voice transcript payload as UTF-8", - ]) - } - await self.nodeGateway.sendEvent(event: "voice.transcript", payloadJSON: json) - } - - func handleDeepLink(url: URL) async { - guard let route = DeepLinkParser.parse(url) else { return } - - switch route { - case let .agent(link): - await self.handleAgentDeepLink(link, originalURL: url) - case .gateway: - break - } - } - - private func handleAgentDeepLink(_ link: AgentDeepLink, originalURL: URL) async { - let message = link.message.trimmingCharacters(in: .whitespacesAndNewlines) - guard !message.isEmpty else { return } - self.deepLinkLogger.info( - "agent deep link received messageChars=\(message.count) url=\(originalURL.absoluteString, privacy: .public)" - ) - - if message.count > 20000 { - self.screen.errorText = "Deep link too large (message exceeds 20,000 characters)." - self.recordShareEvent("Rejected: message too large (\(message.count) chars).") - return - } - - guard await self.isGatewayConnected() else { - self.screen.errorText = "Gateway not connected (cannot forward deep link)." - self.recordShareEvent("Failed: gateway not connected.") - self.deepLinkLogger.error("agent deep link rejected: gateway not connected") - return - } - - do { - try await self.sendAgentRequest(link: link) - self.screen.errorText = nil - self.recordShareEvent("Sent to gateway (\(message.count) chars).") - self.deepLinkLogger.info("agent deep link forwarded to gateway") - self.openChatRequestID &+= 1 - } catch { - self.screen.errorText = "Agent request failed: \(error.localizedDescription)" - self.recordShareEvent("Failed: \(error.localizedDescription)") - self.deepLinkLogger.error("agent deep link send failed: \(error.localizedDescription, privacy: .public)") - } - } - - private func sendAgentRequest(link: AgentDeepLink) async throws { - if link.message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - throw NSError(domain: "DeepLink", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "invalid agent message", - ]) - } - - // iOS gateway forwards to the gateway; no local auth prompts here. - // (Key-based unattended auth is handled on macOS for openclaw:// links.) - let data = try JSONEncoder().encode(link) - guard let json = String(bytes: data, encoding: .utf8) else { - throw NSError(domain: "NodeAppModel", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "Failed to encode agent request payload as UTF-8", - ]) - } - await self.nodeGateway.sendEvent(event: "agent.request", payloadJSON: json) - } - - private func isGatewayConnected() async -> Bool { - self.gatewayConnected - } - - private func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse { - let command = req.command - - if self.isBackgrounded, self.isBackgroundRestricted(command) { - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError( - code: .backgroundUnavailable, - message: "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground")) - } - - if command.hasPrefix("camera."), !self.isCameraEnabled() { - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError( - code: .unavailable, - message: "CAMERA_DISABLED: enable Camera in iOS Settings → Camera → Allow Camera")) - } - - do { - return try await self.capabilityRouter.handle(req) - } catch let error as NodeCapabilityRouter.RouterError { - switch error { - case .unknownCommand: - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command")) - case .handlerUnavailable: - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError(code: .unavailable, message: "node handler unavailable")) - } - } catch { - if command.hasPrefix("camera.") { - let text = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription - self.showCameraHUD(text: text, kind: .error, autoHideSeconds: 2.2) - } - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError(code: .unavailable, message: error.localizedDescription)) - } - } - - private func isBackgroundRestricted(_ command: String) -> Bool { - command.hasPrefix("canvas.") || command.hasPrefix("camera.") || command.hasPrefix("screen.") || - command.hasPrefix("talk.") - } - - private func handleLocationInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { - let mode = self.locationMode() - guard mode != .off else { - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError( - code: .unavailable, - message: "LOCATION_DISABLED: enable Location in Settings")) - } - if self.isBackgrounded, mode != .always { - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError( - code: .backgroundUnavailable, - message: "LOCATION_BACKGROUND_UNAVAILABLE: background location requires Always")) - } - let params = (try? Self.decodeParams(OpenClawLocationGetParams.self, from: req.paramsJSON)) ?? - OpenClawLocationGetParams() - let desired = params.desiredAccuracy ?? - (self.isLocationPreciseEnabled() ? .precise : .balanced) - let status = self.locationService.authorizationStatus() - if status != .authorizedAlways, status != .authorizedWhenInUse { - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError( - code: .unavailable, - message: "LOCATION_PERMISSION_REQUIRED: grant Location permission")) - } - if self.isBackgrounded, status != .authorizedAlways { - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError( - code: .unavailable, - message: "LOCATION_PERMISSION_REQUIRED: enable Always for background access")) - } - let location = try await self.locationService.currentLocation( - params: params, - desiredAccuracy: desired, - maxAgeMs: params.maxAgeMs, - timeoutMs: params.timeoutMs) - let isPrecise = self.locationService.accuracyAuthorization() == .fullAccuracy - let payload = OpenClawLocationPayload( - lat: location.coordinate.latitude, - lon: location.coordinate.longitude, - accuracyMeters: location.horizontalAccuracy, - altitudeMeters: location.verticalAccuracy >= 0 ? location.altitude : nil, - speedMps: location.speed >= 0 ? location.speed : nil, - headingDeg: location.course >= 0 ? location.course : nil, - timestamp: ISO8601DateFormatter().string(from: location.timestamp), - isPrecise: isPrecise, - source: nil) - let json = try Self.encodePayload(payload) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) - } - - private func handleCanvasInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { - switch req.command { - case OpenClawCanvasCommand.present.rawValue: - // iOS ignores placement hints; canvas always fills the screen. - let params = (try? Self.decodeParams(OpenClawCanvasPresentParams.self, from: req.paramsJSON)) ?? - OpenClawCanvasPresentParams() - let url = params.url?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if url.isEmpty { - self.screen.showDefaultCanvas() - } else { - self.screen.navigate(to: url) - } - return BridgeInvokeResponse(id: req.id, ok: true) - case OpenClawCanvasCommand.hide.rawValue: - self.screen.showDefaultCanvas() - return BridgeInvokeResponse(id: req.id, ok: true) - case OpenClawCanvasCommand.navigate.rawValue: - let params = try Self.decodeParams(OpenClawCanvasNavigateParams.self, from: req.paramsJSON) - self.screen.navigate(to: params.url) - return BridgeInvokeResponse(id: req.id, ok: true) - case OpenClawCanvasCommand.evalJS.rawValue: - let params = try Self.decodeParams(OpenClawCanvasEvalParams.self, from: req.paramsJSON) - let result = try await self.screen.eval(javaScript: params.javaScript) - let payload = try Self.encodePayload(["result": result]) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) - case OpenClawCanvasCommand.snapshot.rawValue: - let params = try? Self.decodeParams(OpenClawCanvasSnapshotParams.self, from: req.paramsJSON) - let format = params?.format ?? .jpeg - let maxWidth: CGFloat? = { - if let raw = params?.maxWidth, raw > 0 { return CGFloat(raw) } - // Keep default snapshots comfortably below the gateway client's maxPayload. - // For full-res, clients should explicitly request a larger maxWidth. - return switch format { - case .png: 900 - case .jpeg: 1600 - } - }() - let base64 = try await self.screen.snapshotBase64( - maxWidth: maxWidth, - format: format, - quality: params?.quality) - let payload = try Self.encodePayload([ - "format": format == .jpeg ? "jpeg" : "png", - "base64": base64, - ]) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) - default: - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command")) - } - } - - private func handleCanvasA2UIInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { - let command = req.command - switch command { - case OpenClawCanvasA2UICommand.reset.rawValue: - guard let a2uiUrl = await self.resolveA2UIHostURL() else { - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError( - code: .unavailable, - message: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host")) - } - self.screen.navigate(to: a2uiUrl) - if await !self.screen.waitForA2UIReady(timeoutMs: 5000) { - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError( - code: .unavailable, - message: "A2UI_HOST_UNAVAILABLE: A2UI host not reachable")) - } - - let json = try await self.screen.eval(javaScript: """ - (() => { - const host = globalThis.openclawA2UI; - if (!host) return JSON.stringify({ ok: false, error: "missing openclawA2UI" }); - return JSON.stringify(host.reset()); - })() - """) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) - case OpenClawCanvasA2UICommand.push.rawValue, OpenClawCanvasA2UICommand.pushJSONL.rawValue: - let messages: [OpenClawKit.AnyCodable] - if command == OpenClawCanvasA2UICommand.pushJSONL.rawValue { - let params = try Self.decodeParams(OpenClawCanvasA2UIPushJSONLParams.self, from: req.paramsJSON) - messages = try OpenClawCanvasA2UIJSONL.decodeMessagesFromJSONL(params.jsonl) - } else { - do { - let params = try Self.decodeParams(OpenClawCanvasA2UIPushParams.self, from: req.paramsJSON) - messages = params.messages - } catch { - // Be forgiving: some clients still send JSONL payloads to `canvas.a2ui.push`. - let params = try Self.decodeParams(OpenClawCanvasA2UIPushJSONLParams.self, from: req.paramsJSON) - messages = try OpenClawCanvasA2UIJSONL.decodeMessagesFromJSONL(params.jsonl) - } - } - - guard let a2uiUrl = await self.resolveA2UIHostURL() else { - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError( - code: .unavailable, - message: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host")) - } - self.screen.navigate(to: a2uiUrl) - if await !self.screen.waitForA2UIReady(timeoutMs: 5000) { - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError( - code: .unavailable, - message: "A2UI_HOST_UNAVAILABLE: A2UI host not reachable")) - } - - let messagesJSON = try OpenClawCanvasA2UIJSONL.encodeMessagesJSONArray(messages) - let js = """ - (() => { - try { - const host = globalThis.openclawA2UI; - if (!host) return JSON.stringify({ ok: false, error: "missing openclawA2UI" }); - const messages = \(messagesJSON); - return JSON.stringify(host.applyMessages(messages)); - } catch (e) { - return JSON.stringify({ ok: false, error: String(e?.message ?? e) }); - } - })() - """ - let resultJSON = try await self.screen.eval(javaScript: js) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: resultJSON) - default: - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command")) - } - } - - private func handleCameraInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { - switch req.command { - case OpenClawCameraCommand.list.rawValue: - let devices = await self.camera.listDevices() - struct Payload: Codable { - var devices: [CameraController.CameraDeviceInfo] - } - let payload = try Self.encodePayload(Payload(devices: devices)) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) - case OpenClawCameraCommand.snap.rawValue: - self.showCameraHUD(text: "Taking photo…", kind: .photo) - self.triggerCameraFlash() - let params = (try? Self.decodeParams(OpenClawCameraSnapParams.self, from: req.paramsJSON)) ?? - OpenClawCameraSnapParams() - let res = try await self.camera.snap(params: params) - - struct Payload: Codable { - var format: String - var base64: String - var width: Int - var height: Int - } - let payload = try Self.encodePayload(Payload( - format: res.format, - base64: res.base64, - width: res.width, - height: res.height)) - self.showCameraHUD(text: "Photo captured", kind: .success, autoHideSeconds: 1.6) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) - case OpenClawCameraCommand.clip.rawValue: - let params = (try? Self.decodeParams(OpenClawCameraClipParams.self, from: req.paramsJSON)) ?? - OpenClawCameraClipParams() - - let suspended = (params.includeAudio ?? true) ? self.voiceWake.suspendForExternalAudioCapture() : false - defer { self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: suspended) } - - self.showCameraHUD(text: "Recording…", kind: .recording) - let res = try await self.camera.clip(params: params) - - struct Payload: Codable { - var format: String - var base64: String - var durationMs: Int - var hasAudio: Bool - } - let payload = try Self.encodePayload(Payload( - format: res.format, - base64: res.base64, - durationMs: res.durationMs, - hasAudio: res.hasAudio)) - self.showCameraHUD(text: "Clip captured", kind: .success, autoHideSeconds: 1.8) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) - default: - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command")) - } - } - - private func handleScreenRecordInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { - let params = (try? Self.decodeParams(OpenClawScreenRecordParams.self, from: req.paramsJSON)) ?? - OpenClawScreenRecordParams() - if let format = params.format, format.lowercased() != "mp4" { - throw NSError(domain: "Screen", code: 30, userInfo: [ - NSLocalizedDescriptionKey: "INVALID_REQUEST: screen format must be mp4", - ]) - } - // Status pill mirrors screen recording state so it stays visible without overlay stacking. - self.screenRecordActive = true - defer { self.screenRecordActive = false } - let path = try await self.screenRecorder.record( - screenIndex: params.screenIndex, - durationMs: params.durationMs, - fps: params.fps, - includeAudio: params.includeAudio, - outPath: nil) - defer { try? FileManager().removeItem(atPath: path) } - let data = try Data(contentsOf: URL(fileURLWithPath: path)) - struct Payload: Codable { - var format: String - var base64: String - var durationMs: Int? - var fps: Double? - var screenIndex: Int? - var hasAudio: Bool - } - let payload = try Self.encodePayload(Payload( - format: "mp4", - base64: data.base64EncodedString(), - durationMs: params.durationMs, - fps: params.fps, - screenIndex: params.screenIndex, - hasAudio: params.includeAudio ?? true)) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) - } - - private func handleSystemNotify(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { - let params = try Self.decodeParams(OpenClawSystemNotifyParams.self, from: req.paramsJSON) - let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines) - let body = params.body.trimmingCharacters(in: .whitespacesAndNewlines) - if title.isEmpty, body.isEmpty { - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: empty notification")) - } - - let finalStatus = await self.requestNotificationAuthorizationIfNeeded() - guard finalStatus == .authorized || finalStatus == .provisional || finalStatus == .ephemeral else { - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError(code: .unavailable, message: "NOT_AUTHORIZED: notifications")) - } - - let addResult = await self.runNotificationCall(timeoutSeconds: 2.0) { [notificationCenter] in - let content = UNMutableNotificationContent() - content.title = title - content.body = body - if #available(iOS 15.0, *) { - switch params.priority ?? .active { - case .passive: - content.interruptionLevel = .passive - case .timeSensitive: - content.interruptionLevel = .timeSensitive - case .active: - content.interruptionLevel = .active - } - } - let soundValue = params.sound?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if let soundValue, ["none", "silent", "off", "false", "0"].contains(soundValue) { - content.sound = nil - } else { - content.sound = .default - } - let request = UNNotificationRequest( - identifier: UUID().uuidString, - content: content, - trigger: nil) - try await notificationCenter.add(request) - } - if case let .failure(error) = addResult { - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError(code: .unavailable, message: "NOTIFICATION_FAILED: \(error.message)")) - } - return BridgeInvokeResponse(id: req.id, ok: true) - } - - private func handleChatPushInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { - let params = try Self.decodeParams(OpenClawChatPushParams.self, from: req.paramsJSON) - let text = params.text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty else { - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: empty chat.push text")) - } - - let finalStatus = await self.requestNotificationAuthorizationIfNeeded() - let messageId = UUID().uuidString - if finalStatus == .authorized || finalStatus == .provisional || finalStatus == .ephemeral { - let addResult = await self.runNotificationCall(timeoutSeconds: 2.0) { [notificationCenter] in - let content = UNMutableNotificationContent() - content.title = "OpenClaw" - content.body = text - content.sound = .default - content.userInfo = ["messageId": messageId] - let request = UNNotificationRequest( - identifier: messageId, - content: content, - trigger: nil) - try await notificationCenter.add(request) - } - if case let .failure(error) = addResult { - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError(code: .unavailable, message: "NOTIFICATION_FAILED: \(error.message)")) - } - } - - if params.speak ?? true { - let toSpeak = text - Task { @MainActor in - try? await TalkSystemSpeechSynthesizer.shared.speak(text: toSpeak) - } - } - - let payload = OpenClawChatPushPayload(messageId: messageId) - let json = try Self.encodePayload(payload) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) - } - - private func requestNotificationAuthorizationIfNeeded() async -> NotificationAuthorizationStatus { - let status = await self.notificationAuthorizationStatus() - guard status == .notDetermined else { return status } - - // Avoid hanging invoke requests if the permission prompt is never answered. - _ = await self.runNotificationCall(timeoutSeconds: 2.0) { [notificationCenter] in - _ = try await notificationCenter.requestAuthorization(options: [.alert, .sound, .badge]) - } - - return await self.notificationAuthorizationStatus() - } - - private func notificationAuthorizationStatus() async -> NotificationAuthorizationStatus { - let result = await self.runNotificationCall(timeoutSeconds: 1.5) { [notificationCenter] in - await notificationCenter.authorizationStatus() - } - switch result { - case let .success(status): - return status - case .failure: - return .denied - } - } - - private func runNotificationCall( - timeoutSeconds: Double, - operation: @escaping @Sendable () async throws -> T - ) async -> Result { - let latch = NotificationInvokeLatch() - var opTask: Task? - var timeoutTask: Task? - defer { - opTask?.cancel() - timeoutTask?.cancel() - } - let clamped = max(0.0, timeoutSeconds) - return await withCheckedContinuation { (cont: CheckedContinuation, Never>) in - latch.setContinuation(cont) - opTask = Task { @MainActor in - do { - let value = try await operation() - latch.resume(.success(value)) - } catch { - latch.resume(.failure(NotificationCallError(message: error.localizedDescription))) - } - } - timeoutTask = Task.detached { - if clamped > 0 { - try? await Task.sleep(nanoseconds: UInt64(clamped * 1_000_000_000)) - } - latch.resume(.failure(NotificationCallError(message: "notification request timed out"))) - } - } - } - - private func handleDeviceInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { - switch req.command { - case OpenClawDeviceCommand.status.rawValue: - let payload = try await self.deviceStatusService.status() - let json = try Self.encodePayload(payload) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) - case OpenClawDeviceCommand.info.rawValue: - let payload = self.deviceStatusService.info() - let json = try Self.encodePayload(payload) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) - default: - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command")) - } - } - - private func handlePhotosInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { - let params = (try? Self.decodeParams(OpenClawPhotosLatestParams.self, from: req.paramsJSON)) ?? - OpenClawPhotosLatestParams() - let payload = try await self.photosService.latest(params: params) - let json = try Self.encodePayload(payload) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) - } - - private func handleContactsInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { - switch req.command { - case OpenClawContactsCommand.search.rawValue: - let params = (try? Self.decodeParams(OpenClawContactsSearchParams.self, from: req.paramsJSON)) ?? - OpenClawContactsSearchParams() - let payload = try await self.contactsService.search(params: params) - let json = try Self.encodePayload(payload) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) - case OpenClawContactsCommand.add.rawValue: - let params = try Self.decodeParams(OpenClawContactsAddParams.self, from: req.paramsJSON) - let payload = try await self.contactsService.add(params: params) - let json = try Self.encodePayload(payload) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) - default: - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command")) - } - } - - private func handleCalendarInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { - switch req.command { - case OpenClawCalendarCommand.events.rawValue: - let params = (try? Self.decodeParams(OpenClawCalendarEventsParams.self, from: req.paramsJSON)) ?? - OpenClawCalendarEventsParams() - let payload = try await self.calendarService.events(params: params) - let json = try Self.encodePayload(payload) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) - case OpenClawCalendarCommand.add.rawValue: - let params = try Self.decodeParams(OpenClawCalendarAddParams.self, from: req.paramsJSON) - let payload = try await self.calendarService.add(params: params) - let json = try Self.encodePayload(payload) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) - default: - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command")) - } - } - - private func handleRemindersInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { - switch req.command { - case OpenClawRemindersCommand.list.rawValue: - let params = (try? Self.decodeParams(OpenClawRemindersListParams.self, from: req.paramsJSON)) ?? - OpenClawRemindersListParams() - let payload = try await self.remindersService.list(params: params) - let json = try Self.encodePayload(payload) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) - case OpenClawRemindersCommand.add.rawValue: - let params = try Self.decodeParams(OpenClawRemindersAddParams.self, from: req.paramsJSON) - let payload = try await self.remindersService.add(params: params) - let json = try Self.encodePayload(payload) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) - default: - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command")) - } - } - - private func handleMotionInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { - switch req.command { - case OpenClawMotionCommand.activity.rawValue: - let params = (try? Self.decodeParams(OpenClawMotionActivityParams.self, from: req.paramsJSON)) ?? - OpenClawMotionActivityParams() - let payload = try await self.motionService.activities(params: params) - let json = try Self.encodePayload(payload) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) - case OpenClawMotionCommand.pedometer.rawValue: - let params = (try? Self.decodeParams(OpenClawPedometerParams.self, from: req.paramsJSON)) ?? - OpenClawPedometerParams() - let payload = try await self.motionService.pedometer(params: params) - let json = try Self.encodePayload(payload) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) - default: - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command")) - } - } - - private func handleTalkInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { - switch req.command { - case OpenClawTalkCommand.pttStart.rawValue: - self.pttVoiceWakeSuspended = self.voiceWake.suspendForExternalAudioCapture() - let payload = try await self.talkMode.beginPushToTalk() - let json = try Self.encodePayload(payload) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) - case OpenClawTalkCommand.pttStop.rawValue: - let payload = await self.talkMode.endPushToTalk() - self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: self.pttVoiceWakeSuspended) - self.pttVoiceWakeSuspended = false - let json = try Self.encodePayload(payload) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) - case OpenClawTalkCommand.pttCancel.rawValue: - let payload = await self.talkMode.cancelPushToTalk() - self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: self.pttVoiceWakeSuspended) - self.pttVoiceWakeSuspended = false - let json = try Self.encodePayload(payload) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) - case OpenClawTalkCommand.pttOnce.rawValue: - self.pttVoiceWakeSuspended = self.voiceWake.suspendForExternalAudioCapture() - defer { - self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: self.pttVoiceWakeSuspended) - self.pttVoiceWakeSuspended = false - } - let payload = try await self.talkMode.runPushToTalkOnce() - let json = try Self.encodePayload(payload) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) - default: - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command")) - } - } - -} - -private extension NodeAppModel { - // Central registry for node invoke routing to keep commands in one place. - func buildCapabilityRouter() -> NodeCapabilityRouter { - var handlers: [String: NodeCapabilityRouter.Handler] = [:] - - func register(_ commands: [String], handler: @escaping NodeCapabilityRouter.Handler) { - for command in commands { - handlers[command] = handler - } - } - - register([OpenClawLocationCommand.get.rawValue]) { [weak self] req in - guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } - return try await self.handleLocationInvoke(req) - } - - register([ - OpenClawCanvasCommand.present.rawValue, - OpenClawCanvasCommand.hide.rawValue, - OpenClawCanvasCommand.navigate.rawValue, - OpenClawCanvasCommand.evalJS.rawValue, - OpenClawCanvasCommand.snapshot.rawValue, - ]) { [weak self] req in - guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } - return try await self.handleCanvasInvoke(req) - } - - register([ - OpenClawCanvasA2UICommand.reset.rawValue, - OpenClawCanvasA2UICommand.push.rawValue, - OpenClawCanvasA2UICommand.pushJSONL.rawValue, - ]) { [weak self] req in - guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } - return try await self.handleCanvasA2UIInvoke(req) - } - - register([ - OpenClawCameraCommand.list.rawValue, - OpenClawCameraCommand.snap.rawValue, - OpenClawCameraCommand.clip.rawValue, - ]) { [weak self] req in - guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } - return try await self.handleCameraInvoke(req) - } - - register([OpenClawScreenCommand.record.rawValue]) { [weak self] req in - guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } - return try await self.handleScreenRecordInvoke(req) - } - - register([OpenClawSystemCommand.notify.rawValue]) { [weak self] req in - guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } - return try await self.handleSystemNotify(req) - } - - register([OpenClawChatCommand.push.rawValue]) { [weak self] req in - guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } - return try await self.handleChatPushInvoke(req) - } - - register([ - OpenClawDeviceCommand.status.rawValue, - OpenClawDeviceCommand.info.rawValue, - ]) { [weak self] req in - guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } - return try await self.handleDeviceInvoke(req) - } - - register([ - OpenClawWatchCommand.status.rawValue, - OpenClawWatchCommand.notify.rawValue, - ]) { [weak self] req in - guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } - return try await self.handleWatchInvoke(req) - } - - register([OpenClawPhotosCommand.latest.rawValue]) { [weak self] req in - guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } - return try await self.handlePhotosInvoke(req) - } - - register([ - OpenClawContactsCommand.search.rawValue, - OpenClawContactsCommand.add.rawValue, - ]) { [weak self] req in - guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } - return try await self.handleContactsInvoke(req) - } - - register([ - OpenClawCalendarCommand.events.rawValue, - OpenClawCalendarCommand.add.rawValue, - ]) { [weak self] req in - guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } - return try await self.handleCalendarInvoke(req) - } - - register([ - OpenClawRemindersCommand.list.rawValue, - OpenClawRemindersCommand.add.rawValue, - ]) { [weak self] req in - guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } - return try await self.handleRemindersInvoke(req) - } - - register([ - OpenClawMotionCommand.activity.rawValue, - OpenClawMotionCommand.pedometer.rawValue, - ]) { [weak self] req in - guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } - return try await self.handleMotionInvoke(req) - } - - register([ - OpenClawTalkCommand.pttStart.rawValue, - OpenClawTalkCommand.pttStop.rawValue, - OpenClawTalkCommand.pttCancel.rawValue, - OpenClawTalkCommand.pttOnce.rawValue, - ]) { [weak self] req in - guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } - return try await self.handleTalkInvoke(req) - } - - return NodeCapabilityRouter(handlers: handlers) - } - - func handleWatchInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { - switch req.command { - case OpenClawWatchCommand.status.rawValue: - let status = await self.watchMessagingService.status() - let payload = OpenClawWatchStatusPayload( - supported: status.supported, - paired: status.paired, - appInstalled: status.appInstalled, - reachable: status.reachable, - activationState: status.activationState) - let json = try Self.encodePayload(payload) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) - case OpenClawWatchCommand.notify.rawValue: - let params = try Self.decodeParams(OpenClawWatchNotifyParams.self, from: req.paramsJSON) - let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines) - let body = params.body.trimmingCharacters(in: .whitespacesAndNewlines) - if title.isEmpty && body.isEmpty { - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError( - code: .invalidRequest, - message: "INVALID_REQUEST: empty watch notification")) - } - do { - let result = try await self.watchMessagingService.sendNotification( - id: req.id, - params: params) - if result.queuedForDelivery || !result.deliveredImmediately { - let invokeID = req.id - Task { @MainActor in - await WatchPromptNotificationBridge.scheduleMirroredWatchPromptNotificationIfNeeded( - invokeID: invokeID, - params: params, - sendResult: result) - } - } - let payload = OpenClawWatchNotifyPayload( - deliveredImmediately: result.deliveredImmediately, - queuedForDelivery: result.queuedForDelivery, - transport: result.transport) - let json = try Self.encodePayload(payload) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) - } catch { - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError( - code: .unavailable, - message: error.localizedDescription)) - } - default: - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command")) - } - } - - func locationMode() -> OpenClawLocationMode { - let raw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off" - return OpenClawLocationMode(rawValue: raw) ?? .off - } - - func isLocationPreciseEnabled() -> Bool { - // iOS settings now expose a single location mode control. - // Default location tool precision stays high unless a command explicitly requests balanced. - true - } - - static func decodeParams(_ type: T.Type, from json: String?) throws -> T { - guard let json, let data = json.data(using: .utf8) else { - throw NSError(domain: "Gateway", code: 20, userInfo: [ - NSLocalizedDescriptionKey: "INVALID_REQUEST: paramsJSON required", - ]) - } - return try JSONDecoder().decode(type, from: data) - } - - static func encodePayload(_ obj: some Encodable) throws -> String { - let data = try JSONEncoder().encode(obj) - guard let json = String(bytes: data, encoding: .utf8) else { - throw NSError(domain: "NodeAppModel", code: 21, userInfo: [ - NSLocalizedDescriptionKey: "Failed to encode payload as UTF-8", - ]) - } - return json - } - - func isCameraEnabled() -> Bool { - // Default-on: if the key doesn't exist yet, treat it as enabled. - if UserDefaults.standard.object(forKey: "camera.enabled") == nil { return true } - return UserDefaults.standard.bool(forKey: "camera.enabled") - } - - func triggerCameraFlash() { - self.cameraFlashNonce &+= 1 - } - - func showCameraHUD(text: String, kind: CameraHUDKind, autoHideSeconds: Double? = nil) { - self.cameraHUDDismissTask?.cancel() - - withAnimation(.spring(response: 0.25, dampingFraction: 0.85)) { - self.cameraHUDText = text - self.cameraHUDKind = kind - } - - guard let autoHideSeconds else { return } - self.cameraHUDDismissTask = Task { @MainActor in - try? await Task.sleep(nanoseconds: UInt64(autoHideSeconds * 1_000_000_000)) - withAnimation(.easeOut(duration: 0.25)) { - self.cameraHUDText = nil - self.cameraHUDKind = nil - } - } - } -} - -extension NodeAppModel { - var mainSessionKey: String { - let base = SessionKey.normalizeMainKey(self.mainSessionBaseKey) - let agentId = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - let defaultId = (self.gatewayDefaultAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - if agentId.isEmpty || (!defaultId.isEmpty && agentId == defaultId) { return base } - return SessionKey.makeAgentSessionKey(agentId: agentId, baseKey: base) - } - - var chatSessionKey: String { - let base = "ios" - let agentId = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - let defaultId = (self.gatewayDefaultAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - if agentId.isEmpty || (!defaultId.isEmpty && agentId == defaultId) { return base } - return SessionKey.makeAgentSessionKey(agentId: agentId, baseKey: base) - } - - var activeAgentName: String { - let agentId = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - let defaultId = (self.gatewayDefaultAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - let resolvedId = agentId.isEmpty ? defaultId : agentId - if resolvedId.isEmpty { return "Main" } - if let match = self.gatewayAgents.first(where: { $0.id == resolvedId }) { - let name = (match.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - return name.isEmpty ? match.id : name - } - return resolvedId - } - - func connectToGateway( - url: URL, - gatewayStableID: String, - tls: GatewayTLSParams?, - token: String?, - password: String?, - connectOptions: GatewayConnectOptions) - { - let stableID = gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines) - let effectiveStableID = stableID.isEmpty ? url.absoluteString : stableID - let sessionBox = tls.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) } - - self.activeGatewayConnectConfig = GatewayConnectConfig( - url: url, - stableID: stableID, - tls: tls, - token: token, - password: password, - nodeOptions: connectOptions) - self.prepareForGatewayConnect(url: url, stableID: effectiveStableID) - self.startOperatorGatewayLoop( - url: url, - stableID: effectiveStableID, - token: token, - password: password, - nodeOptions: connectOptions, - sessionBox: sessionBox) - self.startNodeGatewayLoop( - url: url, - stableID: effectiveStableID, - token: token, - password: password, - nodeOptions: connectOptions, - sessionBox: sessionBox) - } - - /// Preferred entry-point: apply a single config object and start both sessions. - func applyGatewayConnectConfig(_ cfg: GatewayConnectConfig) { - self.activeGatewayConnectConfig = cfg - self.connectToGateway( - url: cfg.url, - // Preserve the caller-provided stableID (may be empty) and let connectToGateway - // derive the effective stable id consistently for persistence keys. - gatewayStableID: cfg.stableID, - tls: cfg.tls, - token: cfg.token, - password: cfg.password, - connectOptions: cfg.nodeOptions) - } - - func disconnectGateway() { - self.gatewayAutoReconnectEnabled = false - self.gatewayPairingPaused = false - self.gatewayPairingRequestId = nil - self.nodeGatewayTask?.cancel() - self.nodeGatewayTask = nil - self.operatorGatewayTask?.cancel() - self.operatorGatewayTask = nil - self.voiceWakeSyncTask?.cancel() - self.voiceWakeSyncTask = nil - self.gatewayHealthMonitor.stop() - Task { - await self.operatorGateway.disconnect() - await self.nodeGateway.disconnect() - } - self.gatewayStatusText = "Offline" - self.gatewayServerName = nil - self.gatewayRemoteAddress = nil - self.connectedGatewayID = nil - self.activeGatewayConnectConfig = nil - self.gatewayConnected = false - self.operatorConnected = false - self.talkMode.updateGatewayConnected(false) - self.seamColorHex = nil - self.mainSessionBaseKey = "main" - self.talkMode.updateMainSessionKey(self.mainSessionKey) - ShareGatewayRelaySettings.clearConfig() - self.showLocalCanvasOnDisconnect() - } -} - -private extension NodeAppModel { - func prepareForGatewayConnect(url: URL, stableID: String) { - self.gatewayAutoReconnectEnabled = true - self.gatewayPairingPaused = false - self.gatewayPairingRequestId = nil - self.nodeGatewayTask?.cancel() - self.operatorGatewayTask?.cancel() - self.gatewayHealthMonitor.stop() - self.gatewayServerName = nil - self.gatewayRemoteAddress = nil - self.connectedGatewayID = stableID - self.gatewayConnected = false - self.operatorConnected = false - self.voiceWakeSyncTask?.cancel() - self.voiceWakeSyncTask = nil - self.gatewayDefaultAgentId = nil - self.gatewayAgents = [] - self.selectedAgentId = GatewaySettingsStore.loadGatewaySelectedAgentId(stableID: stableID) - self.apnsLastRegisteredTokenHex = nil - } - - func refreshBackgroundReconnectSuppressionIfNeeded(source: String) { - guard self.isBackgrounded else { return } - guard !self.backgroundReconnectSuppressed else { return } - guard let leaseUntil = self.backgroundReconnectLeaseUntil else { - self.suppressBackgroundReconnect(reason: "\(source):no_lease", disconnectIfNeeded: true) - return - } - if Date() >= leaseUntil { - self.suppressBackgroundReconnect(reason: "\(source):lease_expired", disconnectIfNeeded: true) - } - } - - func shouldPauseReconnectLoopInBackground(source: String) -> Bool { - self.refreshBackgroundReconnectSuppressionIfNeeded(source: source) - return self.isBackgrounded && self.backgroundReconnectSuppressed - } - - func startOperatorGatewayLoop( - url: URL, - stableID: String, - token: String?, - password: String?, - nodeOptions: GatewayConnectOptions, - sessionBox: WebSocketSessionBox?) - { - // Operator session reconnects independently (chat/talk/config/voicewake), but we tie its - // lifecycle to the current gateway config so it doesn't keep running across Disconnect. - self.operatorGatewayTask = Task { [weak self] in - guard let self else { return } - var attempt = 0 - while !Task.isCancelled { - if self.gatewayPairingPaused { - try? await Task.sleep(nanoseconds: 1_000_000_000) - continue - } - if !self.gatewayAutoReconnectEnabled { - try? await Task.sleep(nanoseconds: 1_000_000_000) - continue - } - if self.shouldPauseReconnectLoopInBackground(source: "operator_loop") { try? await Task.sleep(nanoseconds: 2_000_000_000); continue } - if await self.isOperatorConnected() { - try? await Task.sleep(nanoseconds: 1_000_000_000) - continue - } - - let effectiveClientId = - GatewaySettingsStore.loadGatewayClientIdOverride(stableID: stableID) ?? nodeOptions.clientId - let operatorOptions = self.makeOperatorConnectOptions( - clientId: effectiveClientId, - displayName: nodeOptions.clientDisplayName) - - do { - try await self.operatorGateway.connect( - url: url, - token: token, - password: password, - connectOptions: operatorOptions, - sessionBox: sessionBox, - onConnected: { [weak self] in - guard let self else { return } - await MainActor.run { - self.operatorConnected = true - self.talkMode.updateGatewayConnected(true) - } - GatewayDiagnostics.log( - "operator gateway connected host=\(url.host ?? "?") scheme=\(url.scheme ?? "?")") - await self.talkMode.reloadConfig() - await self.refreshBrandingFromGateway() - await self.refreshAgentsFromGateway() - await self.refreshShareRouteFromGateway() - await self.startVoiceWakeSync() - await MainActor.run { self.startGatewayHealthMonitor() } - }, - onDisconnected: { [weak self] reason in - guard let self else { return } - await MainActor.run { - self.operatorConnected = false - self.talkMode.updateGatewayConnected(false) - } - GatewayDiagnostics.log("operator gateway disconnected reason=\(reason)") - await MainActor.run { self.stopGatewayHealthMonitor() } - }, - onInvoke: { req in - // Operator session should not handle node.invoke requests. - BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError( - code: .invalidRequest, - message: "INVALID_REQUEST: operator session cannot invoke node commands")) - }) - - attempt = 0 - try? await Task.sleep(nanoseconds: 1_000_000_000) - } catch { - attempt += 1 - GatewayDiagnostics.log("operator gateway connect error: \(error.localizedDescription)") - let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(attempt))) - try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000)) - } - } - } - } - - func startNodeGatewayLoop( - url: URL, - stableID: String, - token: String?, - password: String?, - nodeOptions: GatewayConnectOptions, - sessionBox: WebSocketSessionBox?) - { - self.nodeGatewayTask = Task { [weak self] in - guard let self else { return } - var attempt = 0 - var currentOptions = nodeOptions - var didFallbackClientId = false - var pausedForPairingApproval = false - - while !Task.isCancelled { - if self.gatewayPairingPaused { - try? await Task.sleep(nanoseconds: 1_000_000_000) - continue - } - if !self.gatewayAutoReconnectEnabled { - try? await Task.sleep(nanoseconds: 1_000_000_000) - continue - } - if self.shouldPauseReconnectLoopInBackground(source: "node_loop") { try? await Task.sleep(nanoseconds: 2_000_000_000); continue } - if await self.isGatewayConnected() { - try? await Task.sleep(nanoseconds: 1_000_000_000) - continue - } - await MainActor.run { - self.gatewayStatusText = (attempt == 0) ? "Connecting…" : "Reconnecting…" - self.gatewayServerName = nil - self.gatewayRemoteAddress = nil - } - - do { - let epochMs = Int(Date().timeIntervalSince1970 * 1000) - GatewayDiagnostics.log("connect attempt epochMs=\(epochMs) url=\(url.absoluteString)") - try await self.nodeGateway.connect( - url: url, - token: token, - password: password, - connectOptions: currentOptions, - sessionBox: sessionBox, - onConnected: { [weak self] in - guard let self else { return } - await MainActor.run { - self.gatewayStatusText = "Connected" - self.gatewayServerName = url.host ?? "gateway" - self.gatewayConnected = true - self.screen.errorText = nil - UserDefaults.standard.set(true, forKey: "gateway.autoconnect") - } - let relayData = await MainActor.run { - ( - sessionKey: self.mainSessionKey, - deliveryChannel: self.shareDeliveryChannel, - deliveryTo: self.shareDeliveryTo - ) - } - ShareGatewayRelaySettings.saveConfig( - ShareGatewayRelayConfig( - gatewayURLString: url.absoluteString, - token: token, - password: password, - sessionKey: relayData.sessionKey, - deliveryChannel: relayData.deliveryChannel, - deliveryTo: relayData.deliveryTo)) - GatewayDiagnostics.log("gateway connected host=\(url.host ?? "?") scheme=\(url.scheme ?? "?")") - if let addr = await self.nodeGateway.currentRemoteAddress() { - await MainActor.run { self.gatewayRemoteAddress = addr } - } - await self.showA2UIOnConnectIfNeeded() - await self.onNodeGatewayConnected() - await MainActor.run { - SignificantLocationMonitor.startIfNeeded( - locationService: self.locationService, - locationMode: self.locationMode(), - gateway: self.nodeGateway, - beforeSend: { [weak self] in - await self?.handleSignificantLocationWakeIfNeeded() - }) - } - }, - onDisconnected: { [weak self] reason in - guard let self else { return } - await MainActor.run { - self.gatewayStatusText = "Disconnected: \(reason)" - self.gatewayServerName = nil - self.gatewayRemoteAddress = nil - self.gatewayConnected = false - self.showLocalCanvasOnDisconnect() - } - GatewayDiagnostics.log("gateway disconnected reason: \(reason)") - }, - onInvoke: { [weak self] req in - guard let self else { - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError( - code: .unavailable, - message: "UNAVAILABLE: node not ready")) - } - return await self.handleInvoke(req) - }) - - attempt = 0 - try? await Task.sleep(nanoseconds: 1_000_000_000) - } catch { - if Task.isCancelled { break } - if !didFallbackClientId, - let fallbackClientId = self.legacyClientIdFallback( - currentClientId: currentOptions.clientId, - error: error) - { - didFallbackClientId = true - currentOptions.clientId = fallbackClientId - GatewaySettingsStore.saveGatewayClientIdOverride( - stableID: stableID, - clientId: fallbackClientId) - await MainActor.run { self.gatewayStatusText = "Gateway rejected client id. Retrying…" } - continue - } - - attempt += 1 - await MainActor.run { - self.gatewayStatusText = "Gateway error: \(error.localizedDescription)" - self.gatewayServerName = nil - self.gatewayRemoteAddress = nil - self.gatewayConnected = false - self.showLocalCanvasOnDisconnect() - } - GatewayDiagnostics.log("gateway connect error: \(error.localizedDescription)") - - // If auth is missing/rejected, pause reconnect churn until the user intervenes. - // Reconnect loops only spam the same failing handshake and make onboarding noisy. - let lower = error.localizedDescription.lowercased() - if lower.contains("unauthorized") || lower.contains("gateway token missing") { - await MainActor.run { - self.gatewayAutoReconnectEnabled = false - } - } - - // If pairing is required, stop reconnect churn. The user must approve the request - // on the gateway before another connect attempt will succeed, and retry loops can - // generate multiple pending requests. - if lower.contains("not_paired") || lower.contains("pairing required") { - let requestId: String? = { - // GatewayResponseError for connect decorates the message with `(requestId: ...)`. - // Keep this resilient since other layers may wrap the text. - let text = error.localizedDescription - guard let start = text.range(of: "(requestId: ")?.upperBound else { return nil } - guard let end = text[start...].firstIndex(of: ")") else { return nil } - let raw = String(text[start.. GatewayConnectOptions { - GatewayConnectOptions( - role: "operator", - scopes: ["operator.read", "operator.write", "operator.talk.secrets"], - caps: [], - commands: [], - permissions: [:], - clientId: clientId, - clientMode: "ui", - clientDisplayName: displayName, - includeDeviceIdentity: true) - } - - func legacyClientIdFallback(currentClientId: String, error: Error) -> String? { - let normalizedClientId = currentClientId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard normalizedClientId == "openclaw-ios" else { return nil } - let message = error.localizedDescription.lowercased() - guard message.contains("invalid connect params"), message.contains("/client/id") else { - return nil - } - return "moltbot-ios" - } - - func isOperatorConnected() async -> Bool { - self.operatorConnected - } -} - -extension NodeAppModel { - private func refreshShareRouteFromGateway() async { - struct Params: Codable { - var includeGlobal: Bool - var includeUnknown: Bool - var limit: Int - } - struct SessionRow: Decodable { - var key: String - var updatedAt: Double? - var lastChannel: String? - var lastTo: String? - } - struct SessionsListResult: Decodable { - var sessions: [SessionRow] - } - - let normalize: (String?) -> String? = { raw in - let value = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - return value.isEmpty ? nil : value - } - - do { - let data = try JSONEncoder().encode( - Params(includeGlobal: true, includeUnknown: false, limit: 80)) - guard let json = String(data: data, encoding: .utf8) else { return } - let response = try await self.operatorGateway.request( - method: "sessions.list", - paramsJSON: json, - timeoutSeconds: 10) - let decoded = try JSONDecoder().decode(SessionsListResult.self, from: response) - let currentKey = self.mainSessionKey - let sorted = decoded.sessions.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) } - let exactMatch = sorted.first { row in - row.key == currentKey && normalize(row.lastChannel) != nil && normalize(row.lastTo) != nil - } - let selected = exactMatch - let channel = normalize(selected?.lastChannel) - let to = normalize(selected?.lastTo) - - await MainActor.run { - self.shareDeliveryChannel = channel - self.shareDeliveryTo = to - if let relay = ShareGatewayRelaySettings.loadConfig() { - ShareGatewayRelaySettings.saveConfig( - ShareGatewayRelayConfig( - gatewayURLString: relay.gatewayURLString, - token: relay.token, - password: relay.password, - sessionKey: self.mainSessionKey, - deliveryChannel: channel, - deliveryTo: to)) - } - } - } catch { - // Best-effort only. - } - } - - func runSharePipelineSelfTest() async { - self.recordShareEvent("Share self-test running…") - - let payload = SharedContentPayload( - title: "OpenClaw Share Self-Test", - url: URL(string: "https://openclaw.ai/share-self-test"), - text: "Validate iOS share->deep-link->gateway forwarding.") - guard let deepLink = ShareToAgentDeepLink.buildURL( - from: payload, - instruction: "Reply with: SHARE SELF-TEST OK") - else { - self.recordShareEvent("Self-test failed: could not build deep link.") - return - } - - await self.handleDeepLink(url: deepLink) - } - - func refreshLastShareEventFromRelay() { - if let event = ShareGatewayRelaySettings.loadLastEvent() { - self.lastShareEventText = event - } - } - - func recordShareEvent(_ text: String) { - ShareGatewayRelaySettings.saveLastEvent(text) - self.refreshLastShareEventFromRelay() - } - - func reloadTalkConfig() { - Task { [weak self] in - await self?.talkMode.reloadConfig() - } - } - - /// Back-compat hook retained for older gateway-connect flows. - func onNodeGatewayConnected() async { - await self.registerAPNsTokenIfNeeded() - await self.flushQueuedWatchRepliesIfConnected() - } - - private func handleWatchQuickReply(_ event: WatchQuickReplyEvent) async { - let replyId = event.replyId.trimmingCharacters(in: .whitespacesAndNewlines) - let actionId = event.actionId.trimmingCharacters(in: .whitespacesAndNewlines) - if replyId.isEmpty || actionId.isEmpty { - self.watchReplyLogger.info("watch reply dropped: missing replyId/actionId") - return - } - - if self.seenWatchReplyIds.contains(replyId) { - self.watchReplyLogger.debug( - "watch reply deduped replyId=\(replyId, privacy: .public)") - return - } - self.seenWatchReplyIds.insert(replyId) - - if await !self.isGatewayConnected() { - self.queuedWatchReplies.append(event) - self.watchReplyLogger.info( - "watch reply queued replyId=\(replyId, privacy: .public) action=\(actionId, privacy: .public)") - return - } - - await self.forwardWatchReplyToAgent(event) - } - - private func flushQueuedWatchRepliesIfConnected() async { - guard await self.isGatewayConnected() else { return } - guard !self.queuedWatchReplies.isEmpty else { return } - - let pending = self.queuedWatchReplies - self.queuedWatchReplies.removeAll() - for event in pending { - await self.forwardWatchReplyToAgent(event) - } - } - - private func forwardWatchReplyToAgent(_ event: WatchQuickReplyEvent) async { - let sessionKey = event.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines) - let effectiveSessionKey = (sessionKey?.isEmpty == false) ? sessionKey : self.mainSessionKey - let message = Self.makeWatchReplyAgentMessage(event) - let link = AgentDeepLink( - message: message, - sessionKey: effectiveSessionKey, - thinking: "low", - deliver: false, - to: nil, - channel: nil, - timeoutSeconds: nil, - key: event.replyId) - do { - try await self.sendAgentRequest(link: link) - self.watchReplyLogger.info( - "watch reply forwarded replyId=\(event.replyId, privacy: .public) action=\(event.actionId, privacy: .public)") - self.openChatRequestID &+= 1 - } catch { - self.watchReplyLogger.error( - "watch reply forwarding failed replyId=\(event.replyId, privacy: .public) error=\(error.localizedDescription, privacy: .public)") - self.queuedWatchReplies.insert(event, at: 0) - } - } - - private static func makeWatchReplyAgentMessage(_ event: WatchQuickReplyEvent) -> String { - let actionLabel = event.actionLabel?.trimmingCharacters(in: .whitespacesAndNewlines) - let promptId = event.promptId.trimmingCharacters(in: .whitespacesAndNewlines) - let transport = event.transport.trimmingCharacters(in: .whitespacesAndNewlines) - let summary = actionLabel?.isEmpty == false ? actionLabel! : event.actionId - var lines: [String] = [] - lines.append("Watch reply: \(summary)") - lines.append("promptId=\(promptId.isEmpty ? "unknown" : promptId)") - lines.append("actionId=\(event.actionId)") - lines.append("replyId=\(event.replyId)") - if !transport.isEmpty { - lines.append("transport=\(transport)") - } - if let sentAtMs = event.sentAtMs { - lines.append("sentAtMs=\(sentAtMs)") - } - if let note = event.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty { - lines.append("note=\(note)") - } - return lines.joined(separator: "\n") - } - - func handleSilentPushWake(_ userInfo: [AnyHashable: Any]) async -> Bool { - let wakeId = Self.makePushWakeAttemptID() - guard Self.isSilentPushPayload(userInfo) else { - self.pushWakeLogger.info("Ignored APNs payload wakeId=\(wakeId, privacy: .public): not silent push") - return false - } - let pushKind = Self.openclawPushKind(userInfo) - self.pushWakeLogger.info( - "Silent push received wakeId=\(wakeId, privacy: .public) kind=\(pushKind, privacy: .public) backgrounded=\(self.isBackgrounded, privacy: .public) autoReconnect=\(self.gatewayAutoReconnectEnabled, privacy: .public)") - let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId) - self.pushWakeLogger.info( - "Silent push outcome wakeId=\(wakeId, privacy: .public) applied=\(result.applied, privacy: .public) reason=\(result.reason, privacy: .public) durationMs=\(result.durationMs, privacy: .public)") - return result.applied - } - - func handleBackgroundRefreshWake(trigger: String = "bg_app_refresh") async -> Bool { - let wakeId = Self.makePushWakeAttemptID() - self.pushWakeLogger.info( - "Background refresh wake received wakeId=\(wakeId, privacy: .public) trigger=\(trigger, privacy: .public) backgrounded=\(self.isBackgrounded, privacy: .public) autoReconnect=\(self.gatewayAutoReconnectEnabled, privacy: .public)") - let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId) - self.pushWakeLogger.info( - "Background refresh wake outcome wakeId=\(wakeId, privacy: .public) applied=\(result.applied, privacy: .public) reason=\(result.reason, privacy: .public) durationMs=\(result.durationMs, privacy: .public)") - return result.applied - } - - func handleSignificantLocationWakeIfNeeded() async { - let wakeId = Self.makePushWakeAttemptID() - let now = Date() - let throttleWindowSeconds: TimeInterval = 180 - - if await self.isGatewayConnected() { - self.locationWakeLogger.info( - "Location wake no-op wakeId=\(wakeId, privacy: .public): already connected") - return - } - if let last = self.lastSignificantLocationWakeAt, - now.timeIntervalSince(last) < throttleWindowSeconds - { - self.locationWakeLogger.info( - "Location wake throttled wakeId=\(wakeId, privacy: .public) elapsedSec=\(now.timeIntervalSince(last), privacy: .public)") - return - } - self.lastSignificantLocationWakeAt = now - - self.locationWakeLogger.info( - "Location wake begin wakeId=\(wakeId, privacy: .public) backgrounded=\(self.isBackgrounded, privacy: .public) autoReconnect=\(self.gatewayAutoReconnectEnabled, privacy: .public)") - let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId) - self.locationWakeLogger.info( - "Location wake trigger wakeId=\(wakeId, privacy: .public) applied=\(result.applied, privacy: .public) reason=\(result.reason, privacy: .public) durationMs=\(result.durationMs, privacy: .public)") - - guard result.applied else { return } - let connected = await self.waitForGatewayConnection(timeoutMs: 5000, pollMs: 250) - self.locationWakeLogger.info( - "Location wake post-check wakeId=\(wakeId, privacy: .public) connected=\(connected, privacy: .public)") - } - - func updateAPNsDeviceToken(_ tokenData: Data) { - let tokenHex = tokenData.map { String(format: "%02x", $0) }.joined() - let trimmed = tokenHex.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - self.apnsDeviceTokenHex = trimmed - UserDefaults.standard.set(trimmed, forKey: Self.apnsDeviceTokenUserDefaultsKey) - Task { [weak self] in - await self?.registerAPNsTokenIfNeeded() - } - } - - private func registerAPNsTokenIfNeeded() async { - guard self.gatewayConnected else { return } - guard let token = self.apnsDeviceTokenHex?.trimmingCharacters(in: .whitespacesAndNewlines), - !token.isEmpty - else { - return - } - if token == self.apnsLastRegisteredTokenHex { - return - } - guard let topic = Bundle.main.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines), - !topic.isEmpty - else { - return - } - - struct PushRegistrationPayload: Codable { - var token: String - var topic: String - var environment: String - } - - let payload = PushRegistrationPayload( - token: token, - topic: topic, - environment: Self.apnsEnvironment) - do { - let json = try Self.encodePayload(payload) - await self.nodeGateway.sendEvent(event: "push.apns.register", payloadJSON: json) - self.apnsLastRegisteredTokenHex = token - } catch { - // Best-effort only. - } - } - - private static func isSilentPushPayload(_ userInfo: [AnyHashable: Any]) -> Bool { - guard let apsAny = userInfo["aps"] else { return false } - if let aps = apsAny as? [AnyHashable: Any] { - return Self.hasContentAvailable(aps["content-available"]) - } - if let aps = apsAny as? [String: Any] { - return Self.hasContentAvailable(aps["content-available"]) - } - return false - } - - private static func hasContentAvailable(_ value: Any?) -> Bool { - if let number = value as? NSNumber { - return number.intValue == 1 - } - if let text = value as? String { - return text.trimmingCharacters(in: .whitespacesAndNewlines) == "1" - } - return false - } - - private static func makePushWakeAttemptID() -> String { - let raw = UUID().uuidString.replacingOccurrences(of: "-", with: "") - return String(raw.prefix(8)) - } - - private static func openclawPushKind(_ userInfo: [AnyHashable: Any]) -> String { - if let payload = userInfo["openclaw"] as? [String: Any], - let kind = payload["kind"] as? String - { - let trimmed = kind.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { return trimmed } - } - if let payload = userInfo["openclaw"] as? [AnyHashable: Any], - let kind = payload["kind"] as? String - { - let trimmed = kind.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { return trimmed } - } - return "unknown" - } - - private struct SilentPushWakeAttemptResult { - var applied: Bool - var reason: String - var durationMs: Int - } - - private func waitForGatewayConnection(timeoutMs: Int, pollMs: Int) async -> Bool { - let clampedTimeoutMs = max(0, timeoutMs) - let pollIntervalNs = UInt64(max(50, pollMs)) * 1_000_000 - let deadline = Date().addingTimeInterval(Double(clampedTimeoutMs) / 1000.0) - while Date() < deadline { - if await self.isGatewayConnected() { - return true - } - try? await Task.sleep(nanoseconds: pollIntervalNs) - } - return await self.isGatewayConnected() - } - - private func reconnectGatewaySessionsForSilentPushIfNeeded( - wakeId: String - ) async -> SilentPushWakeAttemptResult { - let startedAt = Date() - let makeResult: (Bool, String) -> SilentPushWakeAttemptResult = { applied, reason in - let durationMs = Int(Date().timeIntervalSince(startedAt) * 1000) - return SilentPushWakeAttemptResult( - applied: applied, - reason: reason, - durationMs: max(0, durationMs)) - } - - guard self.isBackgrounded else { - self.pushWakeLogger.info("Wake no-op wakeId=\(wakeId, privacy: .public): app not backgrounded") - return makeResult(false, "not_backgrounded") - } - guard self.gatewayAutoReconnectEnabled else { - self.pushWakeLogger.info("Wake no-op wakeId=\(wakeId, privacy: .public): auto reconnect disabled") - return makeResult(false, "auto_reconnect_disabled") - } - guard let cfg = self.activeGatewayConnectConfig else { - self.pushWakeLogger.info("Wake no-op wakeId=\(wakeId, privacy: .public): no active gateway config") - return makeResult(false, "no_active_gateway_config") - } - - self.pushWakeLogger.info( - "Wake reconnect begin wakeId=\(wakeId, privacy: .public) stableID=\(cfg.stableID, privacy: .public)") - self.grantBackgroundReconnectLease(seconds: 30, reason: "wake_\(wakeId)") - await self.operatorGateway.disconnect() - await self.nodeGateway.disconnect() - self.operatorConnected = false - self.gatewayConnected = false - self.gatewayStatusText = "Reconnecting…" - self.talkMode.updateGatewayConnected(false) - self.applyGatewayConnectConfig(cfg) - self.pushWakeLogger.info("Wake reconnect trigger applied wakeId=\(wakeId, privacy: .public)") - return makeResult(true, "reconnect_triggered") - } -} - -extension NodeAppModel { - func _bridgeConsumeMirroredWatchReply(_ event: WatchQuickReplyEvent) async { - await self.handleWatchQuickReply(event) - } -} - -#if DEBUG -extension NodeAppModel { - func _test_handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse { - await self.handleInvoke(req) - } - - static func _test_decodeParams(_ type: T.Type, from json: String?) throws -> T { - try self.decodeParams(type, from: json) - } - - static func _test_encodePayload(_ obj: some Encodable) throws -> String { - try self.encodePayload(obj) - } - - func _test_isCameraEnabled() -> Bool { - self.isCameraEnabled() - } - - func _test_triggerCameraFlash() { - self.triggerCameraFlash() - } - - func _test_showCameraHUD(text: String, kind: CameraHUDKind, autoHideSeconds: Double? = nil) { - self.showCameraHUD(text: text, kind: kind, autoHideSeconds: autoHideSeconds) - } - - func _test_handleCanvasA2UIAction(body: [String: Any]) async { - await self.handleCanvasA2UIAction(body: body) - } - - func _test_showLocalCanvasOnDisconnect() { - self.showLocalCanvasOnDisconnect() - } - - func _test_applyTalkModeSync(enabled: Bool, phase: String? = nil) { - self.applyTalkModeSync(enabled: enabled, phase: phase) - } - - func _test_queuedWatchReplyCount() -> Int { - self.queuedWatchReplies.count - } -} -#endif diff --git a/apps/ios/Sources/Motion/MotionService.swift b/apps/ios/Sources/Motion/MotionService.swift deleted file mode 100644 index f108e0b560b..00000000000 --- a/apps/ios/Sources/Motion/MotionService.swift +++ /dev/null @@ -1,100 +0,0 @@ -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) 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" - } - } -} diff --git a/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift b/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift deleted file mode 100644 index bf6c0ba2d18..00000000000 --- a/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift +++ /dev/null @@ -1,354 +0,0 @@ -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") - } - .gatewayTrustPromptAlert() - } -} - -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 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 = GatewaySetupCode.decode(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 - } - } - - // (GatewaySetupCode) decode raw setup codes. -} - -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 - } -} diff --git a/apps/ios/Sources/Onboarding/OnboardingStateStore.swift b/apps/ios/Sources/Onboarding/OnboardingStateStore.swift deleted file mode 100644 index 9822ac1706f..00000000000 --- a/apps/ios/Sources/Onboarding/OnboardingStateStore.swift +++ /dev/null @@ -1,52 +0,0 @@ -import Foundation - -enum OnboardingConnectionMode: String, CaseIterable { - case homeNetwork = "home_network" - case remoteDomain = "remote_domain" - case developerLocal = "developer_local" - - var title: String { - switch self { - case .homeNetwork: - "Home Network" - case .remoteDomain: - "Remote Domain" - case .developerLocal: - "Same Machine (Dev)" - } - } -} - -enum OnboardingStateStore { - private static let completedDefaultsKey = "onboarding.completed" - private static let lastModeDefaultsKey = "onboarding.last_mode" - private static let lastSuccessTimeDefaultsKey = "onboarding.last_success_time" - - @MainActor - static func shouldPresentOnLaunch(appModel: NodeAppModel, defaults: UserDefaults = .standard) -> Bool { - if defaults.bool(forKey: Self.completedDefaultsKey) { return false } - // If we have a last-known connection config, don't force onboarding on launch. Auto-connect - // should handle reconnecting, and users can always open onboarding manually if needed. - if GatewaySettingsStore.loadLastGatewayConnection() != nil { return false } - return appModel.gatewayServerName == nil - } - - static func markCompleted(mode: OnboardingConnectionMode? = nil, defaults: UserDefaults = .standard) { - defaults.set(true, forKey: Self.completedDefaultsKey) - if let mode { - defaults.set(mode.rawValue, forKey: Self.lastModeDefaultsKey) - } - defaults.set(Int(Date().timeIntervalSince1970), forKey: Self.lastSuccessTimeDefaultsKey) - } - - static func markIncomplete(defaults: UserDefaults = .standard) { - defaults.set(false, forKey: Self.completedDefaultsKey) - } - - static func lastMode(defaults: UserDefaults = .standard) -> OnboardingConnectionMode? { - let raw = defaults.string(forKey: Self.lastModeDefaultsKey)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !raw.isEmpty else { return nil } - return OnboardingConnectionMode(rawValue: raw) - } -} diff --git a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift deleted file mode 100644 index c0e872b2ceb..00000000000 --- a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift +++ /dev/null @@ -1,890 +0,0 @@ -import CoreImage -import Combine -import OpenClawKit -import PhotosUI -import SwiftUI -import UIKit - -private enum OnboardingStep: Int, CaseIterable { - case welcome - case mode - case connect - case auth - case success - - var previous: Self? { - Self(rawValue: self.rawValue - 1) - } - - var next: Self? { - Self(rawValue: self.rawValue + 1) - } - - /// Progress label for the manual setup flow (mode → connect → auth → success). - var manualProgressTitle: String { - let manualSteps: [OnboardingStep] = [.mode, .connect, .auth, .success] - guard let idx = manualSteps.firstIndex(of: self) else { return "" } - return "Step \(idx + 1) of \(manualSteps.count)" - } - - var title: String { - switch self { - case .welcome: "Welcome" - case .mode: "Connection Mode" - case .connect: "Connect" - case .auth: "Authentication" - case .success: "Connected" - } - } - - var canGoBack: Bool { - self != .welcome && self != .success - } -} - -struct OnboardingWizardView: View { - @Environment(NodeAppModel.self) private var appModel: NodeAppModel - @Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController - @Environment(\.scenePhase) private var scenePhase - @AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString - @AppStorage("gateway.discovery.domain") private var discoveryDomain: String = "" - @AppStorage("onboarding.developerMode") private var developerModeEnabled: Bool = false - @State private var step: OnboardingStep = .welcome - @State private var selectedMode: OnboardingConnectionMode? - @State private var manualHost: String = "" - @State private var manualPort: Int = 18789 - @State private var manualPortText: String = "18789" - @State private var manualTLS: Bool = true - @State private var gatewayToken: String = "" - @State private var gatewayPassword: String = "" - @State private var connectMessage: String? - @State private var statusLine: String = "Scan the QR code from your gateway to connect." - @State private var connectingGatewayID: String? - @State private var issue: GatewayConnectionIssue = .none - @State private var didMarkCompleted = false - @State private var didAutoPresentQR = false - @State private var pairingRequestId: String? - @State private var discoveryRestartTask: Task? - @State private var showQRScanner: Bool = false - @State private var scannerError: String? - @State private var selectedPhoto: PhotosPickerItem? - @State private var lastPairingAutoResumeAttemptAt: Date? - private static let pairingAutoResumeTicker = Timer.publish(every: 2.0, on: .main, in: .common).autoconnect() - - let allowSkip: Bool - let onClose: () -> Void - - private var isFullScreenStep: Bool { - self.step == .welcome || self.step == .success - } - - var body: some View { - NavigationStack { - Group { - switch self.step { - case .welcome: - self.welcomeStep - case .success: - self.successStep - default: - Form { - switch self.step { - case .mode: - self.modeStep - case .connect: - self.connectStep - case .auth: - self.authStep - default: - EmptyView() - } - } - .scrollDismissesKeyboard(.interactively) - } - } - .navigationTitle(self.isFullScreenStep ? "" : self.step.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - if !self.isFullScreenStep { - ToolbarItem(placement: .principal) { - VStack(spacing: 2) { - Text(self.step.title) - .font(.headline) - Text(self.step.manualProgressTitle) - .font(.caption2) - .foregroundStyle(.secondary) - } - } - } - ToolbarItem(placement: .topBarLeading) { - if self.step.canGoBack { - Button { - self.navigateBack() - } label: { - Label("Back", systemImage: "chevron.left") - } - } else if self.allowSkip { - Button("Close") { - self.onClose() - } - } - } - ToolbarItemGroup(placement: .keyboard) { - Spacer() - Button("Done") { - UIApplication.shared.sendAction( - #selector(UIResponder.resignFirstResponder), - to: nil, from: nil, for: nil) - } - } - } - } - .gatewayTrustPromptAlert() - .alert("QR Scanner Unavailable", isPresented: Binding( - get: { self.scannerError != nil }, - set: { if !$0 { self.scannerError = nil } } - )) { - Button("OK", role: .cancel) {} - } message: { - Text(self.scannerError ?? "") - } - .sheet(isPresented: self.$showQRScanner) { - NavigationStack { - QRScannerView( - onGatewayLink: { link in - self.handleScannedLink(link) - }, - onError: { error in - self.showQRScanner = false - self.statusLine = "Scanner error: \(error)" - self.scannerError = error - }, - onDismiss: { - self.showQRScanner = false - }) - .ignoresSafeArea() - .navigationTitle("Scan QR Code") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button("Cancel") { self.showQRScanner = false } - } - ToolbarItem(placement: .topBarTrailing) { - PhotosPicker(selection: self.$selectedPhoto, matching: .images) { - Label("Photos", systemImage: "photo") - } - } - } - } - .onChange(of: self.selectedPhoto) { _, newValue in - guard let item = newValue else { return } - self.selectedPhoto = nil - Task { - guard let data = try? await item.loadTransferable(type: Data.self) else { - self.showQRScanner = false - self.scannerError = "Could not load the selected image." - return - } - if let message = self.detectQRCode(from: data) { - if let link = GatewayConnectDeepLink.fromSetupCode(message) { - self.handleScannedLink(link) - return - } - if let url = URL(string: message), - let route = DeepLinkParser.parse(url), - case let .gateway(link) = route - { - self.handleScannedLink(link) - return - } - } - self.showQRScanner = false - self.scannerError = "No valid QR code found in the selected image." - } - } - } - .onAppear { - self.initializeState() - } - .onDisappear { - self.discoveryRestartTask?.cancel() - self.discoveryRestartTask = nil - } - .onChange(of: self.discoveryDomain) { _, _ in - self.scheduleDiscoveryRestart() - } - .onChange(of: self.manualPortText) { _, newValue in - let digits = newValue.filter(\.isNumber) - if digits != newValue { - self.manualPortText = digits - return - } - guard let parsed = Int(digits), parsed > 0 else { - self.manualPort = 0 - return - } - self.manualPort = min(parsed, 65535) - } - .onChange(of: self.manualPort) { _, newValue in - let normalized = newValue > 0 ? String(newValue) : "" - if self.manualPortText != normalized { - self.manualPortText = normalized - } - } - .onChange(of: self.gatewayToken) { _, newValue in - self.saveGatewayCredentials(token: newValue, password: self.gatewayPassword) - } - .onChange(of: self.gatewayPassword) { _, newValue in - self.saveGatewayCredentials(token: self.gatewayToken, password: newValue) - } - .onChange(of: self.appModel.gatewayStatusText) { _, newValue in - let next = GatewayConnectionIssue.detect(from: newValue) - // Avoid "flip-flopping" the UI by clearing actionable issues when the underlying connection - // transitions through intermediate statuses (e.g. Offline/Connecting while reconnect churns). - if self.issue.needsPairing, next.needsPairing { - // Keep the requestId sticky even if the status line omits it after we pause. - let mergedRequestId = next.requestId ?? self.issue.requestId ?? self.pairingRequestId - self.issue = .pairingRequired(requestId: mergedRequestId) - } else if self.issue.needsPairing, !next.needsPairing { - // Ignore non-pairing statuses until the user explicitly retries/scans again, or we connect. - } else if self.issue.needsAuthToken, !next.needsAuthToken, !next.needsPairing { - // Same idea for auth: once we learn credentials are missing/rejected, keep that sticky until - // the user retries/scans again or we successfully connect. - } else { - self.issue = next - } - - if let requestId = next.requestId, !requestId.isEmpty { - self.pairingRequestId = requestId - } - - // If the gateway tells us auth is missing/rejected, stop reconnect churn until the user intervenes. - if next.needsAuthToken { - self.appModel.gatewayAutoReconnectEnabled = false - } - - if self.issue.needsAuthToken || self.issue.needsPairing { - self.step = .auth - } - if !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - self.connectMessage = newValue - self.statusLine = newValue - } - } - .onChange(of: self.appModel.gatewayServerName) { _, newValue in - guard newValue != nil else { return } - self.showQRScanner = false - self.statusLine = "Connected." - if !self.didMarkCompleted, let selectedMode { - OnboardingStateStore.markCompleted(mode: selectedMode) - self.didMarkCompleted = true - } - self.onClose() - } - .onChange(of: self.scenePhase) { _, newValue in - guard newValue == .active else { return } - self.attemptAutomaticPairingResumeIfNeeded() - } - .onReceive(Self.pairingAutoResumeTicker) { _ in - self.attemptAutomaticPairingResumeIfNeeded() - } - } - - @ViewBuilder - private var welcomeStep: some View { - VStack(spacing: 0) { - Spacer() - - Image(systemName: "qrcode.viewfinder") - .font(.system(size: 64)) - .foregroundStyle(.tint) - .padding(.bottom, 20) - - Text("Welcome") - .font(.largeTitle.weight(.bold)) - .padding(.bottom, 8) - - Text("Connect to your OpenClaw gateway") - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 32) - - Spacer() - - VStack(spacing: 12) { - Button { - self.statusLine = "Opening QR scanner…" - self.showQRScanner = true - } label: { - Label("Scan QR Code", systemImage: "qrcode") - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - - Button { - self.step = .mode - } label: { - Text("Set Up Manually") - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - .controlSize(.large) - } - .padding(.bottom, 12) - - Text(self.statusLine) - .font(.footnote) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 24) - .padding(.horizontal, 24) - .padding(.bottom, 48) - } - } - - @ViewBuilder - private var modeStep: some View { - Section("Connection Mode") { - OnboardingModeRow( - title: OnboardingConnectionMode.homeNetwork.title, - subtitle: "LAN or Tailscale host", - selected: self.selectedMode == .homeNetwork) - { - self.selectMode(.homeNetwork) - } - - OnboardingModeRow( - title: OnboardingConnectionMode.remoteDomain.title, - subtitle: "VPS with domain", - selected: self.selectedMode == .remoteDomain) - { - self.selectMode(.remoteDomain) - } - - Toggle( - "Developer mode", - isOn: Binding( - get: { self.developerModeEnabled }, - set: { newValue in - self.developerModeEnabled = newValue - if !newValue, self.selectedMode == .developerLocal { - self.selectedMode = nil - } - })) - - if self.developerModeEnabled { - OnboardingModeRow( - title: OnboardingConnectionMode.developerLocal.title, - subtitle: "For local iOS app development", - selected: self.selectedMode == .developerLocal) - { - self.selectMode(.developerLocal) - } - } - } - - Section { - Button("Continue") { - self.step = .connect - } - .disabled(self.selectedMode == nil) - } - } - - @ViewBuilder - private var connectStep: some View { - if let selectedMode { - Section { - LabeledContent("Mode", value: selectedMode.title) - LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText) - LabeledContent("Status", value: self.appModel.gatewayStatusText) - LabeledContent("Progress", value: self.statusLine) - } header: { - Text("Status") - } footer: { - if let connectMessage { - Text(connectMessage) - } - } - - switch selectedMode { - case .homeNetwork: - self.homeNetworkConnectSection - case .remoteDomain: - self.remoteDomainConnectSection - case .developerLocal: - self.developerConnectSection - } - } else { - Section { - Text("Choose a mode first.") - Button("Back to Mode Selection") { - self.step = .mode - } - } - } - } - - private var homeNetworkConnectSection: some View { - Group { - Section("Discovered Gateways") { - if self.gatewayController.gateways.isEmpty { - Text("No gateways found yet.") - .foregroundStyle(.secondary) - } else { - ForEach(self.gatewayController.gateways) { gateway in - let hasHost = self.gatewayHasResolvableHost(gateway) - - HStack { - VStack(alignment: .leading, spacing: 4) { - Text(gateway.name) - if let host = gateway.lanHost ?? gateway.tailnetDns { - Text(host) - .font(.footnote) - .foregroundStyle(.secondary) - } - } - Spacer() - Button { - Task { await self.connectDiscoveredGateway(gateway) } - } label: { - if self.connectingGatewayID == gateway.id { - ProgressView() - .progressViewStyle(.circular) - } else if !hasHost { - Text("Resolving…") - } else { - Text("Connect") - } - } - .disabled(self.connectingGatewayID != nil || !hasHost) - } - } - } - - Button("Restart Discovery") { - self.gatewayController.restartDiscovery() - } - .disabled(self.connectingGatewayID != nil) - } - - self.manualConnectionFieldsSection(title: "Manual Fallback") - } - } - - private var remoteDomainConnectSection: some View { - self.manualConnectionFieldsSection(title: "Domain Settings") - } - - private var developerConnectSection: some View { - Section { - TextField("Host", text: self.$manualHost) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - TextField("Port", text: self.$manualPortText) - .keyboardType(.numberPad) - Toggle("Use TLS", isOn: self.$manualTLS) - - Button { - Task { await self.connectManual() } - } label: { - if self.connectingGatewayID == "manual" { - HStack(spacing: 8) { - ProgressView() - .progressViewStyle(.circular) - Text("Connecting…") - } - } else { - Text("Connect") - } - } - .disabled(!self.canConnectManual || self.connectingGatewayID != nil) - } header: { - Text("Developer Local") - } footer: { - Text("Default host is localhost. Use your Mac LAN IP if simulator networking requires it.") - } - } - - private var authStep: some View { - Group { - Section("Authentication") { - TextField("Gateway Auth Token", text: self.$gatewayToken) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - SecureField("Gateway Password", text: self.$gatewayPassword) - - if self.issue.needsAuthToken { - Text("Gateway rejected credentials. Scan a fresh QR code or update token/password.") - .font(.footnote) - .foregroundStyle(.secondary) - } else { - Text("Auth token looks valid.") - .font(.footnote) - .foregroundStyle(.secondary) - } - } - - if self.issue.needsPairing { - Section { - Button { - self.resumeAfterPairingApproval() - } label: { - Label("Resume After Approval", systemImage: "arrow.clockwise") - } - .disabled(self.connectingGatewayID != nil) - } header: { - Text("Pairing Approval") - } footer: { - let requestLine: String = { - if let id = self.issue.requestId, !id.isEmpty { - return "Request ID: \(id)" - } - return "Request ID: check `openclaw devices list`." - }() - Text( - "Approve this device on the gateway.\n" - + "1) `openclaw devices approve` (or `openclaw devices approve `)\n" - + "2) `/pair approve` in Telegram\n" - + "\(requestLine)\n" - + "OpenClaw will also retry automatically when you return to this app.") - } - } - - Section { - Button { - self.openQRScannerFromOnboarding() - } label: { - Label("Scan QR Code Again", systemImage: "qrcode.viewfinder") - } - .disabled(self.connectingGatewayID != nil) - - Button { - Task { await self.retryLastAttempt() } - } label: { - if self.connectingGatewayID == "retry" { - ProgressView() - .progressViewStyle(.circular) - } else { - Text("Retry Connection") - } - } - .disabled(self.connectingGatewayID != nil) - } - } - } - - private var successStep: some View { - VStack(spacing: 0) { - Spacer() - - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 64)) - .foregroundStyle(.green) - .padding(.bottom, 20) - - Text("Connected") - .font(.largeTitle.weight(.bold)) - .padding(.bottom, 8) - - let server = self.appModel.gatewayServerName ?? "gateway" - Text(server) - .font(.subheadline) - .foregroundStyle(.secondary) - .padding(.bottom, 4) - - if let addr = self.appModel.gatewayRemoteAddress { - Text(addr) - .font(.subheadline) - .foregroundStyle(.secondary) - } - - Spacer() - - Button { - self.onClose() - } label: { - Text("Open OpenClaw") - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .padding(.horizontal, 24) - .padding(.bottom, 48) - } - } - - @ViewBuilder - private func manualConnectionFieldsSection(title: String) -> some View { - Section(title) { - TextField("Host", text: self.$manualHost) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - TextField("Port", text: self.$manualPortText) - .keyboardType(.numberPad) - Toggle("Use TLS", isOn: self.$manualTLS) - TextField("Discovery Domain (optional)", text: self.$discoveryDomain) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - - Button { - Task { await self.connectManual() } - } label: { - if self.connectingGatewayID == "manual" { - HStack(spacing: 8) { - ProgressView() - .progressViewStyle(.circular) - Text("Connecting…") - } - } else { - Text("Connect") - } - } - .disabled(!self.canConnectManual || self.connectingGatewayID != nil) - } - } - - private func handleScannedLink(_ link: GatewayConnectDeepLink) { - self.manualHost = link.host - self.manualPort = link.port - self.manualTLS = link.tls - if let token = link.token { - self.gatewayToken = token - } - if let password = link.password { - self.gatewayPassword = password - } - self.saveGatewayCredentials(token: self.gatewayToken, password: self.gatewayPassword) - self.showQRScanner = false - self.connectMessage = "Connecting via QR code…" - self.statusLine = "QR loaded. Connecting to \(link.host):\(link.port)…" - if self.selectedMode == nil { - self.selectedMode = link.tls ? .remoteDomain : .homeNetwork - } - Task { await self.connectManual() } - } - - private func openQRScannerFromOnboarding() { - // Stop active reconnect loops before scanning new credentials. - self.appModel.disconnectGateway() - self.connectingGatewayID = nil - self.connectMessage = nil - self.issue = .none - self.pairingRequestId = nil - self.statusLine = "Opening QR scanner…" - self.showQRScanner = true - } - - private func resumeAfterPairingApproval() { - // We intentionally stop reconnect churn while unpaired to avoid generating multiple pending requests. - self.appModel.gatewayAutoReconnectEnabled = true - self.appModel.gatewayPairingPaused = false - self.appModel.gatewayPairingRequestId = nil - // Pairing state is sticky to prevent UI flip-flop during reconnect churn. - // Once the user explicitly resumes after approving, clear the sticky issue - // so new status/auth errors can surface instead of being masked as pairing. - self.issue = .none - self.connectMessage = "Retrying after approval…" - self.statusLine = "Retrying after approval…" - Task { await self.retryLastAttempt() } - } - - private func resumeAfterPairingApprovalInBackground() { - // Keep the pairing issue sticky to avoid visual flicker while we probe for approval. - self.appModel.gatewayAutoReconnectEnabled = true - self.appModel.gatewayPairingPaused = false - self.appModel.gatewayPairingRequestId = nil - Task { await self.retryLastAttempt(silent: true) } - } - - private func attemptAutomaticPairingResumeIfNeeded() { - guard self.scenePhase == .active else { return } - guard self.step == .auth else { return } - guard self.issue.needsPairing else { return } - guard self.connectingGatewayID == nil else { return } - - let now = Date() - if let last = self.lastPairingAutoResumeAttemptAt, now.timeIntervalSince(last) < 6 { - return - } - self.lastPairingAutoResumeAttemptAt = now - self.resumeAfterPairingApprovalInBackground() - } - - private func detectQRCode(from data: Data) -> String? { - guard let ciImage = CIImage(data: data) else { return nil } - let detector = CIDetector( - ofType: CIDetectorTypeQRCode, context: nil, - options: [CIDetectorAccuracy: CIDetectorAccuracyHigh]) - let features = detector?.features(in: ciImage) ?? [] - for feature in features { - if let qr = feature as? CIQRCodeFeature, let message = qr.messageString { - return message - } - } - return nil - } - - private func navigateBack() { - guard let target = self.step.previous else { return } - self.connectingGatewayID = nil - self.connectMessage = nil - self.step = target - } - private var canConnectManual: Bool { - let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines) - return !host.isEmpty && self.manualPort > 0 && self.manualPort <= 65535 - } - - private func initializeState() { - if self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - if let last = GatewaySettingsStore.loadLastGatewayConnection() { - switch last { - case let .manual(host, port, useTLS, _): - self.manualHost = host - self.manualPort = port - self.manualTLS = useTLS - case .discovered: - self.manualHost = "openclaw.local" - self.manualPort = 18789 - self.manualTLS = true - } - } else { - self.manualHost = "openclaw.local" - self.manualPort = 18789 - self.manualTLS = true - } - } - self.manualPortText = self.manualPort > 0 ? String(self.manualPort) : "" - if self.selectedMode == nil { - self.selectedMode = OnboardingStateStore.lastMode() - } - if self.selectedMode == .developerLocal && self.manualHost == "openclaw.local" { - self.manualHost = "localhost" - self.manualTLS = false - } - - let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmedInstanceId.isEmpty { - self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? "" - self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? "" - } - - let hasSavedGateway = GatewaySettingsStore.loadLastGatewayConnection() != nil - let hasToken = !self.gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - let hasPassword = !self.gatewayPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - if !self.didAutoPresentQR, !hasSavedGateway, !hasToken, !hasPassword { - self.didAutoPresentQR = true - self.statusLine = "No saved pairing found. Scan QR code to connect." - self.showQRScanner = true - } - } - - private func scheduleDiscoveryRestart() { - self.discoveryRestartTask?.cancel() - self.discoveryRestartTask = Task { @MainActor in - try? await Task.sleep(nanoseconds: 350_000_000) - guard !Task.isCancelled else { return } - self.gatewayController.restartDiscovery() - } - } - - private func saveGatewayCredentials(token: String, password: String) { - let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedInstanceId.isEmpty else { return } - let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines) - GatewaySettingsStore.saveGatewayToken(trimmedToken, instanceId: trimmedInstanceId) - let trimmedPassword = password.trimmingCharacters(in: .whitespacesAndNewlines) - GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: trimmedInstanceId) - } - - private func connectDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async { - self.connectingGatewayID = gateway.id - self.issue = .none - self.connectMessage = "Connecting to \(gateway.name)…" - self.statusLine = "Connecting to \(gateway.name)…" - defer { self.connectingGatewayID = nil } - await self.gatewayController.connect(gateway) - } - - private func selectMode(_ mode: OnboardingConnectionMode) { - self.selectedMode = mode - self.applyModeDefaults(mode) - } - - private func applyModeDefaults(_ mode: OnboardingConnectionMode) { - let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let hostIsDefaultLike = host.isEmpty || host == "openclaw.local" || host == "localhost" - - switch mode { - case .homeNetwork: - if hostIsDefaultLike { self.manualHost = "openclaw.local" } - self.manualTLS = true - if self.manualPort <= 0 || self.manualPort > 65535 { self.manualPort = 18789 } - case .remoteDomain: - if host == "openclaw.local" || host == "localhost" { self.manualHost = "" } - self.manualTLS = true - if self.manualPort <= 0 || self.manualPort > 65535 { self.manualPort = 18789 } - case .developerLocal: - if hostIsDefaultLike { self.manualHost = "localhost" } - self.manualTLS = false - if self.manualPort <= 0 || self.manualPort > 65535 { self.manualPort = 18789 } - } - } - - private func gatewayHasResolvableHost(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> Bool { - let lanHost = gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !lanHost.isEmpty { return true } - let tailnetDns = gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return !tailnetDns.isEmpty - } - - private func connectManual() async { - let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines) - guard !host.isEmpty, self.manualPort > 0, self.manualPort <= 65535 else { return } - self.connectingGatewayID = "manual" - self.issue = .none - self.connectMessage = "Connecting to \(host)…" - self.statusLine = "Connecting to \(host):\(self.manualPort)…" - defer { self.connectingGatewayID = nil } - await self.gatewayController.connectManual(host: host, port: self.manualPort, useTLS: self.manualTLS) - } - - private func retryLastAttempt(silent: Bool = false) async { - self.connectingGatewayID = silent ? "retry-auto" : "retry" - // Keep current auth/pairing issue sticky while retrying to avoid Step 3 UI flip-flop. - if !silent { - self.connectMessage = "Retrying…" - self.statusLine = "Retrying last connection…" - } - defer { self.connectingGatewayID = nil } - await self.gatewayController.connectLastKnown() - } -} - -private struct OnboardingModeRow: View { - let title: String - let subtitle: String - let selected: Bool - let action: () -> Void - - var body: some View { - Button(action: self.action) { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(self.title) - .font(.body.weight(.semibold)) - Text(self.subtitle) - .font(.footnote) - .foregroundStyle(.secondary) - } - Spacer() - Image(systemName: self.selected ? "checkmark.circle.fill" : "circle") - .foregroundStyle(self.selected ? Color.accentColor : Color.secondary) - } - } - .buttonStyle(.plain) - } -} diff --git a/apps/ios/Sources/Onboarding/QRScannerView.swift b/apps/ios/Sources/Onboarding/QRScannerView.swift deleted file mode 100644 index d326c09c42b..00000000000 --- a/apps/ios/Sources/Onboarding/QRScannerView.swift +++ /dev/null @@ -1,96 +0,0 @@ -import OpenClawKit -import SwiftUI -import VisionKit - -struct QRScannerView: UIViewControllerRepresentable { - let onGatewayLink: (GatewayConnectDeepLink) -> Void - let onError: (String) -> Void - let onDismiss: () -> Void - - func makeUIViewController(context: Context) -> UIViewController { - guard DataScannerViewController.isSupported else { - context.coordinator.reportError("QR scanning is not supported on this device.") - return UIViewController() - } - guard DataScannerViewController.isAvailable else { - context.coordinator.reportError("Camera scanning is currently unavailable.") - return UIViewController() - } - let scanner = DataScannerViewController( - recognizedDataTypes: [.barcode(symbologies: [.qr])], - isHighlightingEnabled: true) - scanner.delegate = context.coordinator - do { - try scanner.startScanning() - } catch { - context.coordinator.reportError("Could not start QR scanner.") - } - return scanner - } - - func updateUIViewController(_: UIViewController, context _: Context) {} - - static func dismantleUIViewController(_ uiViewController: UIViewController, coordinator: Coordinator) { - if let scanner = uiViewController as? DataScannerViewController { - scanner.stopScanning() - } - coordinator.parent.onDismiss() - } - - func makeCoordinator() -> Coordinator { - Coordinator(parent: self) - } - - final class Coordinator: NSObject, DataScannerViewControllerDelegate { - let parent: QRScannerView - private var handled = false - private var reportedError = false - - init(parent: QRScannerView) { - self.parent = parent - } - - func reportError(_ message: String) { - guard !self.reportedError else { return } - self.reportedError = true - Task { @MainActor in - self.parent.onError(message) - } - } - - func dataScanner(_: DataScannerViewController, didAdd items: [RecognizedItem], allItems _: [RecognizedItem]) { - guard !self.handled else { return } - for item in items { - guard case let .barcode(barcode) = item, - let payload = barcode.payloadStringValue - else { continue } - - // Try setup code format first (base64url JSON from /pair qr). - if let link = GatewayConnectDeepLink.fromSetupCode(payload) { - self.handled = true - self.parent.onGatewayLink(link) - return - } - - // Fall back to deep link URL format (openclaw://gateway?...). - if let url = URL(string: payload), - let route = DeepLinkParser.parse(url), - case let .gateway(link) = route - { - self.handled = true - self.parent.onGatewayLink(link) - return - } - } - } - - func dataScanner(_: DataScannerViewController, didRemove _: [RecognizedItem], allItems _: [RecognizedItem]) {} - - func dataScanner( - _: DataScannerViewController, - becameUnavailableWithError _: DataScannerViewController.ScanningUnavailable) - { - self.reportError("Camera is not available on this device.") - } - } -} diff --git a/apps/ios/Sources/OpenClaw.entitlements b/apps/ios/Sources/OpenClaw.entitlements deleted file mode 100644 index a2663ce930b..00000000000 --- a/apps/ios/Sources/OpenClaw.entitlements +++ /dev/null @@ -1,9 +0,0 @@ - - - - - aps-environment - development - - - diff --git a/apps/ios/Sources/OpenClawApp.swift b/apps/ios/Sources/OpenClawApp.swift deleted file mode 100644 index 335e09fd986..00000000000 --- a/apps/ios/Sources/OpenClawApp.swift +++ /dev/null @@ -1,499 +0,0 @@ -import SwiftUI -import Foundation -import OpenClawKit -import os -import UIKit -import BackgroundTasks -import UserNotifications - -private struct PendingWatchPromptAction { - var promptId: String? - var actionId: String - var actionLabel: String? - var sessionKey: String? -} - -@MainActor -final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUserNotificationCenterDelegate { - private let logger = Logger(subsystem: "ai.openclaw.ios", category: "Push") - private let backgroundWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "BackgroundWake") - private static let wakeRefreshTaskIdentifier = "ai.openclaw.ios.bgrefresh" - private var backgroundWakeTask: Task? - private var pendingAPNsDeviceToken: Data? - private var pendingWatchPromptActions: [PendingWatchPromptAction] = [] - - weak var appModel: NodeAppModel? { - didSet { - guard let model = self.appModel else { return } - if let token = self.pendingAPNsDeviceToken { - self.pendingAPNsDeviceToken = nil - Task { @MainActor in - model.updateAPNsDeviceToken(token) - } - } - if !self.pendingWatchPromptActions.isEmpty { - let pending = self.pendingWatchPromptActions - self.pendingWatchPromptActions.removeAll() - Task { @MainActor in - for action in pending { - await model.handleMirroredWatchPromptAction( - promptId: action.promptId, - actionId: action.actionId, - actionLabel: action.actionLabel, - sessionKey: action.sessionKey) - } - } - } - } - } - - func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil - ) -> Bool - { - self.registerBackgroundWakeRefreshTask() - UNUserNotificationCenter.current().delegate = self - application.registerForRemoteNotifications() - return true - } - - func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { - if let appModel = self.appModel { - Task { @MainActor in - appModel.updateAPNsDeviceToken(deviceToken) - } - return - } - - self.pendingAPNsDeviceToken = deviceToken - } - - func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: any Error) { - self.logger.error("APNs registration failed: \(error.localizedDescription, privacy: .public)") - } - - func application( - _ application: UIApplication, - didReceiveRemoteNotification userInfo: [AnyHashable: Any], - fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) - { - self.logger.info("APNs remote notification received keys=\(userInfo.keys.count, privacy: .public)") - Task { @MainActor in - guard let appModel = self.appModel else { - self.logger.info("APNs wake skipped: appModel unavailable") - self.scheduleBackgroundWakeRefresh(afterSeconds: 90, reason: "silent_push_no_model") - completionHandler(.noData) - return - } - let handled = await appModel.handleSilentPushWake(userInfo) - self.logger.info("APNs wake handled=\(handled, privacy: .public)") - if !handled { - self.scheduleBackgroundWakeRefresh(afterSeconds: 90, reason: "silent_push_not_applied") - } - completionHandler(handled ? .newData : .noData) - } - } - - func scenePhaseChanged(_ phase: ScenePhase) { - if phase == .background { - self.scheduleBackgroundWakeRefresh(afterSeconds: 120, reason: "scene_background") - } - } - - private func registerBackgroundWakeRefreshTask() { - BGTaskScheduler.shared.register( - forTaskWithIdentifier: Self.wakeRefreshTaskIdentifier, - using: nil - ) { [weak self] task in - guard let refreshTask = task as? BGAppRefreshTask else { - task.setTaskCompleted(success: false) - return - } - self?.handleBackgroundWakeRefresh(task: refreshTask) - } - } - - private func scheduleBackgroundWakeRefresh(afterSeconds delay: TimeInterval, reason: String) { - let request = BGAppRefreshTaskRequest(identifier: Self.wakeRefreshTaskIdentifier) - request.earliestBeginDate = Date().addingTimeInterval(max(60, delay)) - do { - try BGTaskScheduler.shared.submit(request) - self.backgroundWakeLogger.info( - "Scheduled background wake refresh reason=\(reason, privacy: .public) delaySeconds=\(max(60, delay), privacy: .public)") - } catch { - self.backgroundWakeLogger.error( - "Failed scheduling background wake refresh reason=\(reason, privacy: .public) error=\(error.localizedDescription, privacy: .public)") - } - } - - private func handleBackgroundWakeRefresh(task: BGAppRefreshTask) { - self.scheduleBackgroundWakeRefresh(afterSeconds: 15 * 60, reason: "reschedule") - self.backgroundWakeTask?.cancel() - - let wakeTask = Task { @MainActor [weak self] in - guard let self, let appModel = self.appModel else { return false } - return await appModel.handleBackgroundRefreshWake(trigger: "bg_app_refresh") - } - self.backgroundWakeTask = wakeTask - task.expirationHandler = { - wakeTask.cancel() - } - Task { - let applied = await wakeTask.value - task.setTaskCompleted(success: applied) - self.backgroundWakeLogger.info( - "Background wake refresh finished applied=\(applied, privacy: .public)") - } - } - - private static func isWatchPromptNotification(_ userInfo: [AnyHashable: Any]) -> Bool { - (userInfo[WatchPromptNotificationBridge.typeKey] as? String) == WatchPromptNotificationBridge.typeValue - } - - private static func parseWatchPromptAction( - from response: UNNotificationResponse) -> PendingWatchPromptAction? - { - let userInfo = response.notification.request.content.userInfo - guard Self.isWatchPromptNotification(userInfo) else { return nil } - - let promptId = userInfo[WatchPromptNotificationBridge.promptIDKey] as? String - let sessionKey = userInfo[WatchPromptNotificationBridge.sessionKeyKey] as? String - - switch response.actionIdentifier { - case WatchPromptNotificationBridge.actionPrimaryIdentifier: - let actionId = (userInfo[WatchPromptNotificationBridge.actionPrimaryIDKey] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !actionId.isEmpty else { return nil } - let actionLabel = userInfo[WatchPromptNotificationBridge.actionPrimaryLabelKey] as? String - return PendingWatchPromptAction( - promptId: promptId, - actionId: actionId, - actionLabel: actionLabel, - sessionKey: sessionKey) - case WatchPromptNotificationBridge.actionSecondaryIdentifier: - let actionId = (userInfo[WatchPromptNotificationBridge.actionSecondaryIDKey] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !actionId.isEmpty else { return nil } - let actionLabel = userInfo[WatchPromptNotificationBridge.actionSecondaryLabelKey] as? String - return PendingWatchPromptAction( - promptId: promptId, - actionId: actionId, - actionLabel: actionLabel, - sessionKey: sessionKey) - default: - return nil - } - } - - private func routeWatchPromptAction(_ action: PendingWatchPromptAction) async { - guard let appModel = self.appModel else { - self.pendingWatchPromptActions.append(action) - return - } - await appModel.handleMirroredWatchPromptAction( - promptId: action.promptId, - actionId: action.actionId, - actionLabel: action.actionLabel, - sessionKey: action.sessionKey) - _ = await appModel.handleBackgroundRefreshWake(trigger: "watch_prompt_action") - } - - func userNotificationCenter( - _ center: UNUserNotificationCenter, - willPresent notification: UNNotification, - withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) - { - let userInfo = notification.request.content.userInfo - if Self.isWatchPromptNotification(userInfo) { - completionHandler([.banner, .list, .sound]) - return - } - completionHandler([]) - } - - func userNotificationCenter( - _ center: UNUserNotificationCenter, - didReceive response: UNNotificationResponse, - withCompletionHandler completionHandler: @escaping () -> Void) - { - guard let action = Self.parseWatchPromptAction(from: response) else { - completionHandler() - return - } - Task { @MainActor [weak self] in - guard let self else { - completionHandler() - return - } - await self.routeWatchPromptAction(action) - completionHandler() - } - } -} - -enum WatchPromptNotificationBridge { - static let typeKey = "openclaw.type" - static let typeValue = "watch.prompt" - static let promptIDKey = "openclaw.watch.promptId" - static let sessionKeyKey = "openclaw.watch.sessionKey" - static let actionPrimaryIDKey = "openclaw.watch.action.primary.id" - static let actionPrimaryLabelKey = "openclaw.watch.action.primary.label" - static let actionSecondaryIDKey = "openclaw.watch.action.secondary.id" - static let actionSecondaryLabelKey = "openclaw.watch.action.secondary.label" - static let actionPrimaryIdentifier = "openclaw.watch.action.primary" - static let actionSecondaryIdentifier = "openclaw.watch.action.secondary" - static let categoryPrefix = "openclaw.watch.prompt.category." - - @MainActor - static func scheduleMirroredWatchPromptNotificationIfNeeded( - invokeID: String, - params: OpenClawWatchNotifyParams, - sendResult: WatchNotificationSendResult) async - { - guard sendResult.queuedForDelivery || !sendResult.deliveredImmediately else { return } - - let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines) - let body = params.body.trimmingCharacters(in: .whitespacesAndNewlines) - guard !title.isEmpty || !body.isEmpty else { return } - guard await self.requestNotificationAuthorizationIfNeeded() else { return } - - let normalizedActions = (params.actions ?? []).compactMap { action -> OpenClawWatchAction? in - let id = action.id.trimmingCharacters(in: .whitespacesAndNewlines) - let label = action.label.trimmingCharacters(in: .whitespacesAndNewlines) - guard !id.isEmpty, !label.isEmpty else { return nil } - return OpenClawWatchAction(id: id, label: label, style: action.style) - } - let primaryAction = normalizedActions.first - let secondaryAction = normalizedActions.dropFirst().first - - let center = UNUserNotificationCenter.current() - var categoryIdentifier = "" - if let primaryAction { - let categoryID = "\(self.categoryPrefix)\(invokeID)" - let category = UNNotificationCategory( - identifier: categoryID, - actions: self.categoryActions(primaryAction: primaryAction, secondaryAction: secondaryAction), - intentIdentifiers: [], - options: []) - await self.upsertNotificationCategory(category, center: center) - categoryIdentifier = categoryID - } - - var userInfo: [AnyHashable: Any] = [ - self.typeKey: self.typeValue, - ] - if let promptId = params.promptId?.trimmingCharacters(in: .whitespacesAndNewlines), !promptId.isEmpty { - userInfo[self.promptIDKey] = promptId - } - if let sessionKey = params.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines), !sessionKey.isEmpty { - userInfo[self.sessionKeyKey] = sessionKey - } - if let primaryAction { - userInfo[self.actionPrimaryIDKey] = primaryAction.id - userInfo[self.actionPrimaryLabelKey] = primaryAction.label - } - if let secondaryAction { - userInfo[self.actionSecondaryIDKey] = secondaryAction.id - userInfo[self.actionSecondaryLabelKey] = secondaryAction.label - } - - let content = UNMutableNotificationContent() - content.title = title.isEmpty ? "OpenClaw" : title - content.body = body - content.sound = .default - content.userInfo = userInfo - if !categoryIdentifier.isEmpty { - content.categoryIdentifier = categoryIdentifier - } - if #available(iOS 15.0, *) { - switch params.priority ?? .active { - case .passive: - content.interruptionLevel = .passive - case .timeSensitive: - content.interruptionLevel = .timeSensitive - case .active: - content.interruptionLevel = .active - } - } - - let request = UNNotificationRequest( - identifier: "watch.prompt.\(invokeID)", - content: content, - trigger: nil) - try? await self.addNotificationRequest(request, center: center) - } - - private static func categoryActions( - primaryAction: OpenClawWatchAction, - secondaryAction: OpenClawWatchAction?) -> [UNNotificationAction] - { - var actions: [UNNotificationAction] = [ - UNNotificationAction( - identifier: self.actionPrimaryIdentifier, - title: primaryAction.label, - options: self.notificationActionOptions(style: primaryAction.style)) - ] - if let secondaryAction { - actions.append( - UNNotificationAction( - identifier: self.actionSecondaryIdentifier, - title: secondaryAction.label, - options: self.notificationActionOptions(style: secondaryAction.style))) - } - return actions - } - - private static func notificationActionOptions(style: String?) -> UNNotificationActionOptions { - switch style?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { - case "destructive": - return [.destructive] - case "foreground": - // For mirrored watch actions, keep handling in background when possible. - return [] - default: - return [] - } - } - - private static func requestNotificationAuthorizationIfNeeded() async -> Bool { - let center = UNUserNotificationCenter.current() - let status = await self.notificationAuthorizationStatus(center: center) - switch status { - case .authorized, .provisional, .ephemeral: - return true - case .notDetermined: - let granted = (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false - if !granted { return false } - let updatedStatus = await self.notificationAuthorizationStatus(center: center) - return self.isAuthorizationStatusAllowed(updatedStatus) - case .denied: - return false - @unknown default: - return false - } - } - - private static func isAuthorizationStatusAllowed(_ status: UNAuthorizationStatus) -> Bool { - switch status { - case .authorized, .provisional, .ephemeral: - return true - case .denied, .notDetermined: - return false - @unknown default: - return false - } - } - - private static func notificationAuthorizationStatus(center: UNUserNotificationCenter) async -> UNAuthorizationStatus { - await withCheckedContinuation { continuation in - center.getNotificationSettings { settings in - continuation.resume(returning: settings.authorizationStatus) - } - } - } - - private static func upsertNotificationCategory( - _ category: UNNotificationCategory, - center: UNUserNotificationCenter) async - { - await withCheckedContinuation { continuation in - center.getNotificationCategories { categories in - var updated = categories - updated.update(with: category) - center.setNotificationCategories(updated) - continuation.resume() - } - } - } - - private static func addNotificationRequest(_ request: UNNotificationRequest, center: UNUserNotificationCenter) async throws { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - center.add(request) { error in - if let error { - continuation.resume(throwing: error) - } else { - continuation.resume(returning: ()) - } - } - } - } -} - -extension NodeAppModel { - func handleMirroredWatchPromptAction( - promptId: String?, - actionId: String, - actionLabel: String?, - sessionKey: String?) async - { - let normalizedActionID = actionId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalizedActionID.isEmpty else { return } - - let normalizedPromptID = promptId?.trimmingCharacters(in: .whitespacesAndNewlines) - let normalizedSessionKey = sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines) - let normalizedActionLabel = actionLabel?.trimmingCharacters(in: .whitespacesAndNewlines) - - let event = WatchQuickReplyEvent( - replyId: UUID().uuidString, - promptId: (normalizedPromptID?.isEmpty == false) ? normalizedPromptID! : "unknown", - actionId: normalizedActionID, - actionLabel: (normalizedActionLabel?.isEmpty == false) ? normalizedActionLabel : nil, - sessionKey: (normalizedSessionKey?.isEmpty == false) ? normalizedSessionKey : nil, - note: "source=ios.notification", - sentAtMs: Int(Date().timeIntervalSince1970 * 1000), - transport: "ios.notification") - await self._bridgeConsumeMirroredWatchReply(event) - } -} - -@main -struct OpenClawApp: App { - @State private var appModel: NodeAppModel - @State private var gatewayController: GatewayConnectionController - @UIApplicationDelegateAdaptor(OpenClawAppDelegate.self) private var appDelegate - @Environment(\.scenePhase) private var scenePhase - - init() { - Self.installUncaughtExceptionLogger() - GatewaySettingsStore.bootstrapPersistence() - let appModel = NodeAppModel() - _appModel = State(initialValue: appModel) - _gatewayController = State(initialValue: GatewayConnectionController(appModel: appModel)) - } - - var body: some Scene { - WindowGroup { - RootCanvas() - .environment(self.appModel) - .environment(self.appModel.voiceWake) - .environment(self.gatewayController) - .task { - self.appDelegate.appModel = self.appModel - } - .onOpenURL { url in - Task { await self.appModel.handleDeepLink(url: url) } - } - .onChange(of: self.scenePhase) { _, newValue in - self.appModel.setScenePhase(newValue) - self.gatewayController.setScenePhase(newValue) - self.appDelegate.scenePhaseChanged(newValue) - } - } - } -} - -extension OpenClawApp { - private static func installUncaughtExceptionLogger() { - NSLog("OpenClaw: installing uncaught exception handler") - NSSetUncaughtExceptionHandler { exception in - // Useful when the app hits NSExceptions from SwiftUI/WebKit internals; these do not - // produce a normal Swift error backtrace. - let reason = exception.reason ?? "(no reason)" - NSLog("UNCAUGHT EXCEPTION: %@ %@", exception.name.rawValue, reason) - for line in exception.callStackSymbols { - NSLog(" %@", line) - } - } - } -} diff --git a/apps/ios/Sources/Reminders/RemindersService.swift b/apps/ios/Sources/Reminders/RemindersService.swift deleted file mode 100644 index 249f439fb17..00000000000 --- a/apps/ios/Sources/Reminders/RemindersService.swift +++ /dev/null @@ -1,133 +0,0 @@ -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 = EventKitAuthorization.allowsRead(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 = EventKitAuthorization.allowsWrite(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 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", - ]) - } -} diff --git a/apps/ios/Sources/RootCanvas.swift b/apps/ios/Sources/RootCanvas.swift deleted file mode 100644 index da893d3c943..00000000000 --- a/apps/ios/Sources/RootCanvas.swift +++ /dev/null @@ -1,505 +0,0 @@ -import SwiftUI -import UIKit - -struct RootCanvas: View { - @Environment(NodeAppModel.self) private var appModel - @Environment(GatewayConnectionController.self) private var gatewayController - @Environment(VoiceWakeManager.self) private var voiceWake - @Environment(\.colorScheme) private var systemColorScheme - @Environment(\.scenePhase) private var scenePhase - @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("onboarding.requestID") private var onboardingRequestID: Int = 0 - @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 = "" - @AppStorage("onboarding.quickSetupDismissed") private var quickSetupDismissed: Bool = false - @State private var presentedSheet: PresentedSheet? - @State private var voiceWakeToastText: String? - @State private var toastDismissTask: Task? - @State private var showOnboarding: Bool = false - @State private var onboardingAllowSkip: Bool = true - @State private var didEvaluateOnboarding: Bool = false - @State private var didAutoOpenSettings: Bool = false - - private enum PresentedSheet: Identifiable { - case settings - case chat - case quickSetup - - var id: Int { - switch self { - case .settings: 0 - case .chat: 1 - case .quickSetup: 2 - } - } - } - - enum StartupPresentationRoute: Equatable { - case none - case onboarding - case settings - } - - static func startupPresentationRoute( - gatewayConnected: Bool, - hasConnectedOnce: Bool, - onboardingComplete: Bool, - hasExistingGatewayConfig: Bool, - shouldPresentOnLaunch: Bool) -> StartupPresentationRoute - { - if gatewayConnected { - return .none - } - // On first run or explicit launch onboarding state, onboarding always wins. - if shouldPresentOnLaunch || !hasConnectedOnce || !onboardingComplete { - return .onboarding - } - // Settings auto-open is a recovery path for previously-connected installs only. - if !hasExistingGatewayConfig { - return .settings - } - return .none - } - - var body: some View { - ZStack { - CanvasContent( - systemColorScheme: self.systemColorScheme, - gatewayStatus: self.gatewayStatus, - voiceWakeEnabled: self.voiceWakeEnabled, - voiceWakeToastText: self.voiceWakeToastText, - cameraHUDText: self.appModel.cameraHUDText, - cameraHUDKind: self.appModel.cameraHUDKind, - openChat: { - self.presentedSheet = .chat - }, - openSettings: { - self.presentedSheet = .settings - }) - .preferredColorScheme(.dark) - - if self.appModel.cameraFlashNonce != 0 { - CameraFlashOverlay(nonce: self.appModel.cameraFlashNonce) - } - } - .gatewayTrustPromptAlert() - .sheet(item: self.$presentedSheet) { sheet in - switch sheet { - case .settings: - SettingsTab() - .environment(self.appModel) - .environment(self.appModel.voiceWake) - .environment(self.gatewayController) - case .chat: - ChatSheet( - // Chat RPCs run on the operator session (read/write scopes). - gateway: self.appModel.operatorSession, - sessionKey: self.appModel.chatSessionKey, - agentName: self.appModel.activeAgentName, - userAccent: self.appModel.seamColor) - case .quickSetup: - GatewayQuickSetupSheet() - .environment(self.appModel) - .environment(self.gatewayController) - } - } - .fullScreenCover(isPresented: self.$showOnboarding) { - OnboardingWizardView( - allowSkip: self.onboardingAllowSkip, - onClose: { - self.showOnboarding = false - }) - .environment(self.appModel) - .environment(self.appModel.voiceWake) - .environment(self.gatewayController) - } - .onAppear { self.updateIdleTimer() } - .onAppear { self.evaluateOnboardingPresentation(force: false) } - .onAppear { self.maybeAutoOpenSettings() } - .onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() } - .onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() } - .onAppear { self.maybeShowQuickSetup() } - .onChange(of: self.gatewayController.gateways.count) { _, _ in self.maybeShowQuickSetup() } - .onAppear { self.updateCanvasDebugStatus() } - .onChange(of: self.canvasDebugStatusEnabled) { _, _ in self.updateCanvasDebugStatus() } - .onChange(of: self.appModel.gatewayStatusText) { _, _ in self.updateCanvasDebugStatus() } - .onChange(of: self.appModel.gatewayServerName) { _, _ in self.updateCanvasDebugStatus() } - .onChange(of: self.appModel.gatewayServerName) { _, newValue in - if newValue != nil { - self.showOnboarding = false - } - } - .onChange(of: self.onboardingRequestID) { _, _ in - self.evaluateOnboardingPresentation(force: true) - } - .onChange(of: self.appModel.gatewayRemoteAddress) { _, _ in self.updateCanvasDebugStatus() } - .onChange(of: self.appModel.gatewayServerName) { _, newValue in - if newValue != nil { - self.onboardingComplete = true - self.hasConnectedOnce = true - OnboardingStateStore.markCompleted(mode: nil) - } - self.maybeAutoOpenSettings() - } - .onChange(of: self.appModel.openChatRequestID) { _, _ in - self.presentedSheet = .chat - } - .onChange(of: self.voiceWake.lastTriggeredCommand) { _, newValue in - guard let newValue else { return } - let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - - self.toastDismissTask?.cancel() - withAnimation(.spring(response: 0.25, dampingFraction: 0.85)) { - self.voiceWakeToastText = trimmed - } - - self.toastDismissTask = Task { - try? await Task.sleep(nanoseconds: 2_300_000_000) - await MainActor.run { - withAnimation(.easeOut(duration: 0.25)) { - self.voiceWakeToastText = nil - } - } - } - } - .onDisappear { - UIApplication.shared.isIdleTimerDisabled = false - self.toastDismissTask?.cancel() - self.toastDismissTask = nil - } - } - - private var gatewayStatus: StatusPill.GatewayState { - if self.appModel.gatewayServerName != nil { return .connected } - - let text = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) - if text.localizedCaseInsensitiveContains("connecting") || - text.localizedCaseInsensitiveContains("reconnecting") - { - return .connecting - } - - if text.localizedCaseInsensitiveContains("error") { - return .error - } - - return .disconnected - } - - private func updateIdleTimer() { - UIApplication.shared.isIdleTimerDisabled = (self.scenePhase == .active && self.preventSleep) - } - - private func updateCanvasDebugStatus() { - self.appModel.screen.setDebugStatusEnabled(self.canvasDebugStatusEnabled) - guard self.canvasDebugStatusEnabled else { return } - let title = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) - let subtitle = self.appModel.gatewayServerName ?? self.appModel.gatewayRemoteAddress - self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle) - } - - private func evaluateOnboardingPresentation(force: Bool) { - if force { - self.onboardingAllowSkip = true - self.showOnboarding = true - return - } - - guard !self.didEvaluateOnboarding else { return } - self.didEvaluateOnboarding = true - let route = Self.startupPresentationRoute( - gatewayConnected: self.appModel.gatewayServerName != nil, - hasConnectedOnce: self.hasConnectedOnce, - onboardingComplete: self.onboardingComplete, - hasExistingGatewayConfig: self.hasExistingGatewayConfig(), - shouldPresentOnLaunch: OnboardingStateStore.shouldPresentOnLaunch(appModel: self.appModel)) - switch route { - case .none: - break - case .onboarding: - self.onboardingAllowSkip = true - self.showOnboarding = true - case .settings: - self.didAutoOpenSettings = true - self.presentedSheet = .settings - } - } - - 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.showOnboarding else { return } - let route = Self.startupPresentationRoute( - gatewayConnected: self.appModel.gatewayServerName != nil, - hasConnectedOnce: self.hasConnectedOnce, - onboardingComplete: self.onboardingComplete, - hasExistingGatewayConfig: self.hasExistingGatewayConfig(), - shouldPresentOnLaunch: false) - guard route == .settings else { return } - self.didAutoOpenSettings = true - self.presentedSheet = .settings - } - - private func maybeShowQuickSetup() { - guard !self.quickSetupDismissed else { return } - guard !self.showOnboarding else { return } - guard self.presentedSheet == nil else { return } - guard self.appModel.gatewayServerName == nil else { return } - guard !self.gatewayController.gateways.isEmpty else { return } - self.presentedSheet = .quickSetup - } -} - -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 - var voiceWakeToastText: String? - var cameraHUDText: String? - var cameraHUDKind: NodeAppModel.CameraHUDKind? - var openChat: () -> Void - var openSettings: () -> Void - - private var brightenButtons: Bool { self.systemColorScheme == .light } - - var body: some View { - ZStack(alignment: .topTrailing) { - ScreenTab() - - VStack(spacing: 10) { - OverlayButton(systemImage: "text.bubble.fill", brighten: self.brightenButtons) { - self.openChat() - } - .accessibilityLabel("Chat") - - if self.talkButtonEnabled { - // Talk mode lives on a side bubble so it doesn't get buried in settings. - OverlayButton( - systemImage: self.appModel.talkMode.isEnabled ? "waveform.circle.fill" : "waveform.circle", - brighten: self.brightenButtons, - tint: self.appModel.seamColor, - isActive: self.appModel.talkMode.isEnabled) - { - let next = !self.appModel.talkMode.isEnabled - self.talkEnabled = next - self.appModel.setTalkEnabled(next) - } - .accessibilityLabel("Talk Mode") - } - - OverlayButton(systemImage: "gearshape.fill", brighten: self.brightenButtons) { - self.openSettings() - } - .accessibilityLabel("Settings") - } - .padding(.top, 10) - .padding(.trailing, 10) - } - .overlay(alignment: .center) { - if self.appModel.talkMode.isEnabled { - TalkOrbOverlay() - .transition(.opacity) - } - } - .overlay(alignment: .topLeading) { - StatusPill( - gateway: self.gatewayStatus, - voiceWakeEnabled: self.voiceWakeEnabled, - activity: self.statusActivity, - brighten: self.brightenButtons, - onTap: { - if self.gatewayStatus == .connected { - self.showGatewayActions = true - } else { - self.openSettings() - } - }) - .padding(.leading, 10) - .safeAreaPadding(.top, 10) - } - .overlay(alignment: .topLeading) { - if let voiceWakeToastText, !voiceWakeToastText.isEmpty { - VoiceWakeToast( - command: voiceWakeToastText, - brighten: self.brightenButtons) - .padding(.leading, 10) - .safeAreaPadding(.top, 58) - .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? { - // Status pill owns transient activity state so it doesn't overlap the connection indicator. - if self.appModel.isBackgrounded { - return StatusPill.Activity( - title: "Foreground required", - systemImage: "exclamationmark.triangle.fill", - tint: .orange) - } - - let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) - let gatewayLower = gatewayStatus.lowercased() - if gatewayLower.contains("repair") { - return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange) - } - if gatewayLower.contains("approval") || gatewayLower.contains("pairing") { - return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock") - } - // Avoid duplicating the primary gateway status ("Connecting…") in the activity slot. - - if self.appModel.screenRecordActive { - return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red) - } - - if let cameraHUDText, !cameraHUDText.isEmpty, let cameraHUDKind { - let systemImage: String - let tint: Color? - switch cameraHUDKind { - case .photo: - systemImage = "camera.fill" - tint = nil - case .recording: - systemImage = "video.fill" - tint = .red - case .success: - systemImage = "checkmark.circle.fill" - tint = .green - case .error: - systemImage = "exclamationmark.triangle.fill" - tint = .red - } - return StatusPill.Activity(title: cameraHUDText, systemImage: systemImage, tint: tint) - } - - if self.voiceWakeEnabled { - let voiceStatus = self.appModel.voiceWake.statusText - if voiceStatus.localizedCaseInsensitiveContains("microphone permission") { - 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") - } - } - - return nil - } -} - -private struct OverlayButton: View { - let systemImage: String - let brighten: Bool - var tint: Color? - var isActive: Bool = false - let action: () -> Void - - var body: some View { - Button(action: self.action) { - Image(systemName: self.systemImage) - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(self.isActive ? (self.tint ?? .primary) : .primary) - .padding(10) - .background { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(.ultraThinMaterial) - .overlay { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill( - LinearGradient( - colors: [ - .white.opacity(self.brighten ? 0.26 : 0.18), - .white.opacity(self.brighten ? 0.08 : 0.04), - .clear, - ], - startPoint: .topLeading, - endPoint: .bottomTrailing)) - .blendMode(.overlay) - } - .overlay { - if let tint { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill( - LinearGradient( - colors: [ - tint.opacity(self.isActive ? 0.22 : 0.14), - tint.opacity(self.isActive ? 0.10 : 0.06), - .clear, - ], - startPoint: .topLeading, - endPoint: .bottomTrailing)) - .blendMode(.overlay) - } - } - .overlay { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder( - (self.tint ?? .white).opacity(self.isActive ? 0.34 : (self.brighten ? 0.24 : 0.18)), - lineWidth: self.isActive ? 0.7 : 0.5) - } - .shadow(color: .black.opacity(0.35), radius: 12, y: 6) - } - } - .buttonStyle(.plain) - } -} - -private struct CameraFlashOverlay: View { - var nonce: Int - - @State private var opacity: CGFloat = 0 - @State private var task: Task? - - var body: some View { - Color.white - .opacity(self.opacity) - .ignoresSafeArea() - .allowsHitTesting(false) - .onChange(of: self.nonce) { _, _ in - self.task?.cancel() - self.task = Task { @MainActor in - withAnimation(.easeOut(duration: 0.08)) { - self.opacity = 0.85 - } - try? await Task.sleep(nanoseconds: 110_000_000) - withAnimation(.easeOut(duration: 0.32)) { - self.opacity = 0 - } - } - } - } -} diff --git a/apps/ios/Sources/RootTabs.swift b/apps/ios/Sources/RootTabs.swift deleted file mode 100644 index 4733a4a30fc..00000000000 --- a/apps/ios/Sources/RootTabs.swift +++ /dev/null @@ -1,114 +0,0 @@ -import SwiftUI - -struct RootTabs: View { - @Environment(NodeAppModel.self) private var appModel - @Environment(VoiceWakeManager.self) private var voiceWake - @Environment(\.accessibilityReduceMotion) private var reduceMotion - @AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false - @State private var selectedTab: Int = 0 - @State private var voiceWakeToastText: String? - @State private var toastDismissTask: Task? - @State private var showGatewayActions: Bool = false - - var body: some View { - TabView(selection: self.$selectedTab) { - ScreenTab() - .tabItem { Label("Screen", systemImage: "rectangle.and.hand.point.up.left") } - .tag(0) - - VoiceTab() - .tabItem { Label("Voice", systemImage: "mic") } - .tag(1) - - SettingsTab() - .tabItem { Label("Settings", systemImage: "gearshape") } - .tag(2) - } - .overlay(alignment: .topLeading) { - StatusPill( - gateway: self.gatewayStatus, - voiceWakeEnabled: self.voiceWakeEnabled, - activity: self.statusActivity, - onTap: { - if self.gatewayStatus == .connected { - self.showGatewayActions = true - } else { - self.selectedTab = 2 - } - }) - .padding(.leading, 10) - .safeAreaPadding(.top, 10) - } - .overlay(alignment: .topLeading) { - if let voiceWakeToastText, !voiceWakeToastText.isEmpty { - VoiceWakeToast(command: voiceWakeToastText) - .padding(.leading, 10) - .safeAreaPadding(.top, 58) - .transition(.move(edge: .top).combined(with: .opacity)) - } - } - .onChange(of: self.voiceWake.lastTriggeredCommand) { _, newValue in - guard let newValue else { return } - let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - - self.toastDismissTask?.cancel() - withAnimation(self.reduceMotion ? .none : .spring(response: 0.25, dampingFraction: 0.85)) { - self.voiceWakeToastText = trimmed - } - - self.toastDismissTask = Task { - try? await Task.sleep(nanoseconds: 2_300_000_000) - await MainActor.run { - withAnimation(self.reduceMotion ? .none : .easeOut(duration: 0.25)) { - self.voiceWakeToastText = nil - } - } - } - } - .onDisappear { - 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 { - if self.appModel.gatewayServerName != nil { return .connected } - - let text = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) - if text.localizedCaseInsensitiveContains("connecting") || - text.localizedCaseInsensitiveContains("reconnecting") - { - return .connecting - } - - if text.localizedCaseInsensitiveContains("error") { - return .error - } - - return .disconnected - } - - private var statusActivity: StatusPill.Activity? { - StatusActivityBuilder.build( - appModel: self.appModel, - voiceWakeEnabled: self.voiceWakeEnabled, - cameraHUDText: self.appModel.cameraHUDText, - cameraHUDKind: self.appModel.cameraHUDKind) - } -} diff --git a/apps/ios/Sources/RootView.swift b/apps/ios/Sources/RootView.swift deleted file mode 100644 index b0281865334..00000000000 --- a/apps/ios/Sources/RootView.swift +++ /dev/null @@ -1,7 +0,0 @@ -import SwiftUI - -struct RootView: View { - var body: some View { - RootCanvas() - } -} diff --git a/apps/ios/Sources/Screen/ScreenController.swift b/apps/ios/Sources/Screen/ScreenController.swift deleted file mode 100644 index 0045232362b..00000000000 --- a/apps/ios/Sources/Screen/ScreenController.swift +++ /dev/null @@ -1,373 +0,0 @@ -import OpenClawKit -import Observation -import UIKit -import WebKit - -@MainActor -@Observable -final class ScreenController { - private weak var activeWebView: WKWebView? - - var urlString: String = "" - var errorText: String? - - /// Callback invoked when an openclaw:// deep link is tapped in the canvas - var onDeepLink: ((URL) -> Void)? - - /// Callback invoked when the user clicks an A2UI action (e.g. button) inside the canvas web UI. - var onA2UIAction: (([String: Any]) -> Void)? - - private var debugStatusEnabled: Bool = false - private var debugStatusTitle: String? - private var debugStatusSubtitle: String? - - init() { - self.reload() - } - - 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() - } - - func reload() { - self.applyScrollBehavior() - guard let webView = self.activeWebView else { return } - - let trimmed = self.urlString.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { - guard let url = Self.canvasScaffoldURL else { return } - self.errorText = nil - webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent()) - return - } - - guard let url = URL(string: trimmed) else { - self.errorText = "Invalid URL: \(trimmed)" - return - } - self.errorText = nil - if url.isFileURL { - webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent()) - } else { - webView.load(URLRequest(url: url)) - } - } - - func showDefaultCanvas() { - self.urlString = "" - self.reload() - } - - func setDebugStatusEnabled(_ enabled: Bool) { - self.debugStatusEnabled = enabled - self.applyDebugStatusIfNeeded() - } - - func updateDebugStatus(title: String?, subtitle: String?) { - self.debugStatusTitle = title - self.debugStatusSubtitle = subtitle - self.applyDebugStatusIfNeeded() - } - - func applyDebugStatusIfNeeded() { - guard let webView = self.activeWebView else { return } - let enabled = self.debugStatusEnabled - let title = self.debugStatusTitle - let subtitle = self.debugStatusSubtitle - let js = """ - (() => { - try { - const api = globalThis.__openclaw; - if (!api) return; - if (typeof api.setDebugStatusEnabled === 'function') { - api.setDebugStatusEnabled(\(enabled ? "true" : "false")); - } - if (!\(enabled ? "true" : "false")) return; - if (typeof api.setStatus === 'function') { - api.setStatus(\(Self.jsValue(title)), \(Self.jsValue(subtitle))); - } - } catch (_) {} - })() - """ - webView.evaluateJavaScript(js) { _, _ in } - } - - func waitForA2UIReady(timeoutMs: Int) async -> Bool { - let clock = ContinuousClock() - let deadline = clock.now.advanced(by: .milliseconds(timeoutMs)) - while clock.now < deadline { - do { - let res = try await self.eval(javaScript: """ - (() => { - try { - const host = globalThis.openclawA2UI; - return !!host && typeof host.applyMessages === 'function'; - } catch (_) { return false; } - })() - """) - let trimmed = res.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if trimmed == "true" || trimmed == "1" { return true } - } catch { - // ignore; page likely still loading - } - try? await Task.sleep(nanoseconds: 120_000_000) - } - return false - } - - func eval(javaScript: String) async throws -> String { - guard let webView = self.activeWebView else { - throw NSError(domain: "Screen", code: 3, userInfo: [ - NSLocalizedDescriptionKey: "web view unavailable", - ]) - } - return try await withCheckedThrowingContinuation { cont in - webView.evaluateJavaScript(javaScript) { result, error in - if let error { - cont.resume(throwing: error) - return - } - if let result { - cont.resume(returning: String(describing: result)) - } else { - cont.resume(returning: "") - } - } - } - } - - func snapshotPNGBase64(maxWidth: CGFloat? = nil) async throws -> String { - let config = WKSnapshotConfiguration() - if let maxWidth { - config.snapshotWidth = NSNumber(value: Double(maxWidth)) - } - guard let webView = self.activeWebView else { - throw NSError(domain: "Screen", code: 3, userInfo: [ - NSLocalizedDescriptionKey: "web view unavailable", - ]) - } - let image: UIImage = try await withCheckedThrowingContinuation { cont in - webView.takeSnapshot(with: config) { image, error in - if let error { - cont.resume(throwing: error) - return - } - guard let image else { - cont.resume(throwing: NSError(domain: "Screen", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "snapshot failed", - ])) - return - } - cont.resume(returning: image) - } - } - guard let data = image.pngData() else { - throw NSError(domain: "Screen", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "snapshot encode failed", - ]) - } - return data.base64EncodedString() - } - - func snapshotBase64( - maxWidth: CGFloat? = nil, - format: OpenClawCanvasSnapshotFormat, - quality: Double? = nil) async throws -> String - { - let config = WKSnapshotConfiguration() - if let maxWidth { - config.snapshotWidth = NSNumber(value: Double(maxWidth)) - } - guard let webView = self.activeWebView else { - throw NSError(domain: "Screen", code: 3, userInfo: [ - NSLocalizedDescriptionKey: "web view unavailable", - ]) - } - let image: UIImage = try await withCheckedThrowingContinuation { cont in - webView.takeSnapshot(with: config) { image, error in - if let error { - cont.resume(throwing: error) - return - } - guard let image else { - cont.resume(throwing: NSError(domain: "Screen", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "snapshot failed", - ])) - return - } - cont.resume(returning: image) - } - } - - let data: Data? - switch format { - case .png: - data = image.pngData() - case .jpeg: - let q = (quality ?? 0.82).clamped(to: 0.1...1.0) - data = image.jpegData(compressionQuality: q) - } - guard let data else { - throw NSError(domain: "Screen", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "snapshot encode failed", - ]) - } - return data.base64EncodedString() - } - - func attachWebView(_ webView: WKWebView) { - self.activeWebView = webView - self.reload() - self.applyDebugStatusIfNeeded() - } - - func detachWebView(_ webView: WKWebView) { - guard self.activeWebView === webView else { return } - self.activeWebView = nil - } - - private static func bundledResourceURL( - name: String, - ext: String, - subdirectory: String) - -> URL? - { - let bundle = OpenClawKitResources.bundle - return bundle.url(forResource: name, withExtension: ext, subdirectory: subdirectory) - ?? bundle.url(forResource: name, withExtension: ext) - } - - private static let canvasScaffoldURL: URL? = ScreenController.bundledResourceURL( - 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 - if let expected = Self.canvasScaffoldURL, - std == expected.standardizedFileURL - { - return true - } - return false - } - - private func applyScrollBehavior() { - guard let webView = self.activeWebView else { return } - let trimmed = self.urlString.trimmingCharacters(in: .whitespacesAndNewlines) - let allowScroll = !trimmed.isEmpty - let scrollView = webView.scrollView - // Default canvas needs raw touch events; external pages should scroll. - scrollView.isScrollEnabled = allowScroll - scrollView.bounces = allowScroll - } - - private static func jsValue(_ value: String?) -> String { - guard let value else { return "null" } - if let data = try? JSONSerialization.data(withJSONObject: [value]), - let encoded = String(data: data, encoding: .utf8), - encoded.count >= 2 - { - return String(encoded.dropFirst().dropLast()) - } - return "null" - } - - func isLocalNetworkCanvasURL(_ url: URL) -> Bool { - guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { - return false - } - guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty else { - return false - } - if host == "localhost" { return true } - if host.hasSuffix(".local") { return true } - if host.hasSuffix(".ts.net") { return true } - if host.hasSuffix(".tailscale.net") { return true } - // Allow MagicDNS / LAN hostnames like "peters-mac-studio-1". - if !host.contains("."), !host.contains(":") { return true } - if let ipv4 = Self.parseIPv4(host) { - return Self.isLocalNetworkIPv4(ipv4) - } - return false - } - - private static func parseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? { - let parts = host.split(separator: ".", omittingEmptySubsequences: false) - guard parts.count == 4 else { return nil } - let bytes: [UInt8] = parts.compactMap { UInt8($0) } - guard bytes.count == 4 else { return nil } - return (bytes[0], bytes[1], bytes[2], bytes[3]) - } - - private static func isLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool { - let (a, b, _, _) = ip - // 10.0.0.0/8 - if a == 10 { return true } - // 172.16.0.0/12 - if a == 172, (16...31).contains(Int(b)) { return true } - // 192.168.0.0/16 - if a == 192, b == 168 { return true } - // 127.0.0.0/8 - if a == 127 { return true } - // 169.254.0.0/16 (link-local) - if a == 169, b == 254 { return true } - // Tailscale: 100.64.0.0/10 - if a == 100, (64...127).contains(Int(b)) { return true } - return false - } - - nonisolated static func parseA2UIActionBody(_ body: Any) -> [String: Any]? { - if let dict = body as? [String: Any] { return dict.isEmpty ? nil : dict } - if let str = body as? String, - let data = str.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] - { - return json.isEmpty ? nil : json - } - if let dict = body as? [AnyHashable: Any] { - let mapped = dict.reduce(into: [String: Any]()) { acc, pair in - guard let key = pair.key as? String else { return } - acc[key] = pair.value - } - return mapped.isEmpty ? nil : mapped - } - return nil - } -} - -extension Double { - fileprivate func clamped(to range: ClosedRange) -> Double { - if self < range.lowerBound { return range.lowerBound } - if self > range.upperBound { return range.upperBound } - return self - } -} diff --git a/apps/ios/Sources/Screen/ScreenRecordService.swift b/apps/ios/Sources/Screen/ScreenRecordService.swift deleted file mode 100644 index 11052f23543..00000000000 --- a/apps/ios/Sources/Screen/ScreenRecordService.swift +++ /dev/null @@ -1,360 +0,0 @@ -import AVFoundation -import ReplayKit - -final class ScreenRecordService: @unchecked Sendable { - private struct UncheckedSendableBox: @unchecked Sendable { - let value: T - } - - private final class CaptureState: @unchecked Sendable { - private let lock = NSLock() - var writer: AVAssetWriter? - var videoInput: AVAssetWriterInput? - var audioInput: AVAssetWriterInput? - var started = false - var sawVideo = false - var lastVideoTime: CMTime? - var handlerError: Error? - - func withLock(_ body: (CaptureState) -> T) -> T { - self.lock.lock() - defer { lock.unlock() } - return body(self) - } - } - - enum ScreenRecordError: LocalizedError { - case invalidScreenIndex(Int) - case captureFailed(String) - case writeFailed(String) - - var errorDescription: String? { - switch self { - case let .invalidScreenIndex(idx): - "Invalid screen index \(idx)" - case let .captureFailed(msg): - msg - case let .writeFailed(msg): - msg - } - } - } - - func record( - screenIndex: Int?, - durationMs: Int?, - fps: Double?, - includeAudio: Bool?, - outPath: String?) async throws -> String - { - let config = try self.makeRecordConfig( - screenIndex: screenIndex, - durationMs: durationMs, - fps: fps, - includeAudio: includeAudio, - outPath: outPath) - - let state = CaptureState() - let recordQueue = DispatchQueue(label: "bot.molt.screenrecord") - - try await self.startCapture(state: state, config: config, recordQueue: recordQueue) - try await Task.sleep(nanoseconds: UInt64(config.durationMs) * 1_000_000) - try await self.stopCapture() - try self.finalizeCapture(state: state) - try await self.finishWriting(state: state) - - return config.outURL.path - } - - private struct RecordConfig { - let durationMs: Int - let fpsValue: Double - let includeAudio: Bool - let outURL: URL - } - - private func makeRecordConfig( - screenIndex: Int?, - durationMs: Int?, - fps: Double?, - includeAudio: Bool?, - outPath: String?) throws -> RecordConfig - { - if let idx = screenIndex, idx != 0 { - throw ScreenRecordError.invalidScreenIndex(idx) - } - - let durationMs = Self.clampDurationMs(durationMs) - let fps = Self.clampFps(fps) - let fpsInt = Int32(fps.rounded()) - let fpsValue = Double(fpsInt) - let includeAudio = includeAudio ?? true - - let outURL = self.makeOutputURL(outPath: outPath) - try? FileManager().removeItem(at: outURL) - - return RecordConfig( - durationMs: durationMs, - fpsValue: fpsValue, - includeAudio: includeAudio, - outURL: outURL) - } - - private func makeOutputURL(outPath: String?) -> URL { - if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return URL(fileURLWithPath: outPath) - } - return FileManager().temporaryDirectory - .appendingPathComponent("openclaw-screen-record-\(UUID().uuidString).mp4") - } - - private func startCapture( - state: CaptureState, - config: RecordConfig, - recordQueue: DispatchQueue) async throws - { - try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - let handler = self.makeCaptureHandler( - state: state, - config: config, - recordQueue: recordQueue) - let completion: @Sendable (Error?) -> Void = { error in - if let error { cont.resume(throwing: error) } else { cont.resume() } - } - - Task { @MainActor in - startReplayKitCapture( - includeAudio: config.includeAudio, - handler: handler, - completion: completion) - } - } - } - - private func makeCaptureHandler( - state: CaptureState, - config: RecordConfig, - recordQueue: DispatchQueue) -> @Sendable (CMSampleBuffer, RPSampleBufferType, Error?) -> Void - { - { sample, type, error in - let sampleBox = UncheckedSendableBox(value: sample) - // ReplayKit can call the capture handler on a background queue. - // Serialize writes to avoid queue asserts. - recordQueue.async { - let sample = sampleBox.value - if let error { - state.withLock { state in - if state.handlerError == nil { state.handlerError = error } - } - return - } - guard CMSampleBufferDataIsReady(sample) else { return } - - switch type { - case .video: - self.handleVideoSample(sample, state: state, config: config) - case .audioApp, .audioMic: - self.handleAudioSample(sample, state: state, includeAudio: config.includeAudio) - @unknown default: - break - } - } - } - } - - private func handleVideoSample( - _ sample: CMSampleBuffer, - state: CaptureState, - config: RecordConfig) - { - let pts = CMSampleBufferGetPresentationTimeStamp(sample) - let shouldSkip = state.withLock { state in - if let lastVideoTime = state.lastVideoTime { - let delta = CMTimeSubtract(pts, lastVideoTime) - return delta.seconds < (1.0 / config.fpsValue) - } - return false - } - if shouldSkip { return } - - if state.withLock({ $0.writer == nil }) { - self.prepareWriter(sample: sample, state: state, config: config, pts: pts) - } - - let vInput = state.withLock { $0.videoInput } - let isStarted = state.withLock { $0.started } - guard let vInput, isStarted else { return } - if vInput.isReadyForMoreMediaData { - if vInput.append(sample) { - state.withLock { state in - state.sawVideo = true - state.lastVideoTime = pts - } - } else { - let err = state.withLock { $0.writer?.error } - if let err { - state.withLock { state in - if state.handlerError == nil { - state.handlerError = ScreenRecordError.writeFailed(err.localizedDescription) - } - } - } - } - } - } - - private func prepareWriter( - sample: CMSampleBuffer, - state: CaptureState, - config: RecordConfig, - pts: CMTime) - { - guard let imageBuffer = CMSampleBufferGetImageBuffer(sample) else { - state.withLock { state in - if state.handlerError == nil { - state.handlerError = ScreenRecordError.captureFailed("Missing image buffer") - } - } - return - } - let width = CVPixelBufferGetWidth(imageBuffer) - let height = CVPixelBufferGetHeight(imageBuffer) - do { - let writer = try AVAssetWriter(outputURL: config.outURL, fileType: .mp4) - let settings: [String: Any] = [ - AVVideoCodecKey: AVVideoCodecType.h264, - AVVideoWidthKey: width, - AVVideoHeightKey: height, - ] - let vInput = AVAssetWriterInput(mediaType: .video, outputSettings: settings) - vInput.expectsMediaDataInRealTime = true - guard writer.canAdd(vInput) else { - throw ScreenRecordError.writeFailed("Cannot add video input") - } - writer.add(vInput) - - if config.includeAudio { - let aInput = AVAssetWriterInput(mediaType: .audio, outputSettings: nil) - aInput.expectsMediaDataInRealTime = true - if writer.canAdd(aInput) { - writer.add(aInput) - state.withLock { state in - state.audioInput = aInput - } - } - } - - guard writer.startWriting() else { - throw ScreenRecordError.writeFailed( - writer.error?.localizedDescription ?? "Failed to start writer") - } - writer.startSession(atSourceTime: pts) - state.withLock { state in - state.writer = writer - state.videoInput = vInput - state.started = true - } - } catch { - state.withLock { state in - if state.handlerError == nil { state.handlerError = error } - } - } - } - - private func handleAudioSample( - _ sample: CMSampleBuffer, - state: CaptureState, - includeAudio: Bool) - { - let aInput = state.withLock { $0.audioInput } - let isStarted = state.withLock { $0.started } - guard includeAudio, let aInput, isStarted else { return } - if aInput.isReadyForMoreMediaData { - _ = aInput.append(sample) - } - } - - private func stopCapture() async throws { - let stopError = await withCheckedContinuation { cont in - Task { @MainActor in - stopReplayKitCapture { error in cont.resume(returning: error) } - } - } - if let stopError { throw stopError } - } - - private func finalizeCapture(state: CaptureState) throws { - if let handlerErrorSnapshot = state.withLock({ $0.handlerError }) { - throw handlerErrorSnapshot - } - let writerSnapshot = state.withLock { $0.writer } - let videoInputSnapshot = state.withLock { $0.videoInput } - let audioInputSnapshot = state.withLock { $0.audioInput } - let sawVideoSnapshot = state.withLock { $0.sawVideo } - guard let writerSnapshot, let videoInputSnapshot, sawVideoSnapshot else { - throw ScreenRecordError.captureFailed("No frames captured") - } - - videoInputSnapshot.markAsFinished() - audioInputSnapshot?.markAsFinished() - _ = writerSnapshot - } - - private func finishWriting(state: CaptureState) async throws { - guard let writerSnapshot = state.withLock({ $0.writer }) else { - throw ScreenRecordError.captureFailed("Missing writer") - } - let writerBox = UncheckedSendableBox(value: writerSnapshot) - try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - writerBox.value.finishWriting { - let writer = writerBox.value - if let err = writer.error { - cont.resume(throwing: ScreenRecordError.writeFailed(err.localizedDescription)) - } else if writer.status != .completed { - cont.resume(throwing: ScreenRecordError.writeFailed("Failed to finalize video")) - } else { - cont.resume() - } - } - } - } - - private nonisolated static func clampDurationMs(_ ms: Int?) -> Int { - let v = ms ?? 10000 - return min(60000, max(250, v)) - } - - private nonisolated static func clampFps(_ fps: Double?) -> Double { - let v = fps ?? 10 - if !v.isFinite { return 10 } - return min(30, max(1, v)) - } -} - -@MainActor -private func startReplayKitCapture( - includeAudio: Bool, - handler: @escaping @Sendable (CMSampleBuffer, RPSampleBufferType, Error?) -> Void, - completion: @escaping @Sendable (Error?) -> Void) -{ - let recorder = RPScreenRecorder.shared() - recorder.isMicrophoneEnabled = includeAudio - recorder.startCapture(handler: handler, completionHandler: completion) -} - -@MainActor -private func stopReplayKitCapture(_ completion: @escaping @Sendable (Error?) -> Void) { - RPScreenRecorder.shared().stopCapture { error in completion(error) } -} - -#if DEBUG -extension ScreenRecordService { - nonisolated static func _test_clampDurationMs(_ ms: Int?) -> Int { - self.clampDurationMs(ms) - } - - nonisolated static func _test_clampFps(_ fps: Double?) -> Double { - self.clampFps(fps) - } -} -#endif diff --git a/apps/ios/Sources/Screen/ScreenTab.swift b/apps/ios/Sources/Screen/ScreenTab.swift deleted file mode 100644 index 16b5f857496..00000000000 --- a/apps/ios/Sources/Screen/ScreenTab.swift +++ /dev/null @@ -1,27 +0,0 @@ -import OpenClawKit -import SwiftUI - -struct ScreenTab: View { - @Environment(NodeAppModel.self) private var appModel - - var body: some View { - ZStack(alignment: .top) { - ScreenWebView(controller: self.appModel.screen) - .ignoresSafeArea() - .overlay(alignment: .top) { - if let errorText = self.appModel.screen.errorText, - self.appModel.gatewayServerName == nil - { - Text(errorText) - .font(.footnote) - .padding(10) - .background(.thinMaterial) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - .padding() - } - } - } - } - - // Navigation is agent-driven; no local URL bar here. -} diff --git a/apps/ios/Sources/Screen/ScreenWebView.swift b/apps/ios/Sources/Screen/ScreenWebView.swift deleted file mode 100644 index a30d78cbd00..00000000000 --- a/apps/ios/Sources/Screen/ScreenWebView.swift +++ /dev/null @@ -1,193 +0,0 @@ -import OpenClawKit -import SwiftUI -import WebKit - -struct ScreenWebView: UIViewRepresentable { - var controller: ScreenController - - func makeCoordinator() -> ScreenWebViewCoordinator { - ScreenWebViewCoordinator(controller: self.controller) - } - - func makeUIView(context: Context) -> UIView { - context.coordinator.makeContainerView() - } - - func updateUIView(_: UIView, context: Context) { - context.coordinator.updateController(self.controller) - } - - static func dismantleUIView(_: UIView, coordinator: ScreenWebViewCoordinator) { - coordinator.teardown() - } -} - -@MainActor -final class ScreenWebViewCoordinator: NSObject { - private weak var controller: ScreenController? - private let navigationDelegate = ScreenNavigationDelegate() - private let a2uiActionHandler = CanvasA2UIActionMessageHandler() - private let userContentController = WKUserContentController() - - private(set) var managedWebView: WKWebView? - private weak var containerView: UIView? - - init(controller: ScreenController) { - self.controller = controller - super.init() - self.navigationDelegate.controller = controller - self.a2uiActionHandler.controller = controller - } - - func makeContainerView() -> UIView { - if let containerView { - return containerView - } - - let container = UIView(frame: .zero) - container.backgroundColor = .black - - let webView = Self.makeWebView(userContentController: self.userContentController) - webView.navigationDelegate = self.navigationDelegate - self.installA2UIHandlers() - - webView.translatesAutoresizingMaskIntoConstraints = false - container.addSubview(webView) - NSLayoutConstraint.activate([ - webView.leadingAnchor.constraint(equalTo: container.leadingAnchor), - webView.trailingAnchor.constraint(equalTo: container.trailingAnchor), - webView.topAnchor.constraint(equalTo: container.topAnchor), - webView.bottomAnchor.constraint(equalTo: container.bottomAnchor), - ]) - - self.managedWebView = webView - self.containerView = container - self.controller?.attachWebView(webView) - return container - } - - func updateController(_ controller: ScreenController) { - let previousController = self.controller - let controllerChanged = self.controller !== controller - self.controller = controller - self.navigationDelegate.controller = controller - self.a2uiActionHandler.controller = controller - if controllerChanged, let managedWebView { - previousController?.detachWebView(managedWebView) - controller.attachWebView(managedWebView) - } - } - - func teardown() { - if let managedWebView { - self.controller?.detachWebView(managedWebView) - managedWebView.navigationDelegate = nil - } - self.removeA2UIHandlers() - self.navigationDelegate.controller = nil - self.a2uiActionHandler.controller = nil - self.managedWebView = nil - self.containerView = nil - } - - private static func makeWebView(userContentController: WKUserContentController) -> WKWebView { - let config = WKWebViewConfiguration() - config.websiteDataStore = .nonPersistent() - config.userContentController = userContentController - - let webView = WKWebView(frame: .zero, configuration: config) - // Canvas scaffold is a fully self-contained HTML page; avoid relying on transparency underlays. - webView.isOpaque = true - webView.backgroundColor = .black - - let scrollView = webView.scrollView - scrollView.backgroundColor = .black - scrollView.contentInsetAdjustmentBehavior = .never - scrollView.contentInset = .zero - scrollView.scrollIndicatorInsets = .zero - scrollView.automaticallyAdjustsScrollIndicatorInsets = false - - return webView - } - - private func installA2UIHandlers() { - for name in CanvasA2UIActionMessageHandler.handlerNames { - self.userContentController.add(self.a2uiActionHandler, name: name) - } - } - - private func removeA2UIHandlers() { - for name in CanvasA2UIActionMessageHandler.handlerNames { - self.userContentController.removeScriptMessageHandler(forName: name) - } - } -} - -// MARK: - Navigation Delegate - -/// Handles navigation policy to intercept openclaw:// deep links from canvas -@MainActor -private final class ScreenNavigationDelegate: NSObject, WKNavigationDelegate { - weak var controller: ScreenController? - - func webView( - _: WKWebView, - decidePolicyFor navigationAction: WKNavigationAction, - decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void) - { - guard let url = navigationAction.request.url else { - decisionHandler(.allow) - return - } - - // Intercept openclaw:// deep links. - if url.scheme?.lowercased() == "openclaw" { - decisionHandler(.cancel) - self.controller?.onDeepLink?(url) - return - } - - decisionHandler(.allow) - } - - func webView( - _: WKWebView, - didFailProvisionalNavigation _: WKNavigation?, - withError error: any Error) - { - self.controller?.errorText = error.localizedDescription - } - - func webView(_: WKWebView, didFinish _: WKNavigation?) { - self.controller?.errorText = nil - self.controller?.applyDebugStatusIfNeeded() - } - - func webView(_: WKWebView, didFail _: WKNavigation?, withError error: any Error) { - self.controller?.errorText = error.localizedDescription - } -} - -private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler { - static let messageName = "openclawCanvasA2UIAction" - static let handlerNames = [messageName] - - weak var controller: ScreenController? - - func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) { - guard Self.handlerNames.contains(message.name) else { return } - guard let controller else { return } - - guard let url = message.webView?.url else { return } - if url.isFileURL { - guard controller.isTrustedCanvasUIURL(url) else { return } - } else { - // For security, only accept actions from local-network pages (e.g. the canvas host). - guard controller.isLocalNetworkCanvasURL(url) else { return } - } - - guard let body = ScreenController.parseA2UIActionBody(message.body) else { return } - - controller.onA2UIAction?(body) - } -} diff --git a/apps/ios/Sources/Services/NodeServiceProtocols.swift b/apps/ios/Sources/Services/NodeServiceProtocols.swift deleted file mode 100644 index 27ee7cc2776..00000000000 --- a/apps/ios/Sources/Services/NodeServiceProtocols.swift +++ /dev/null @@ -1,103 +0,0 @@ -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 - func startLocationUpdates( - desiredAccuracy: OpenClawLocationAccuracy, - significantChangesOnly: Bool) -> AsyncStream - func stopLocationUpdates() - func startMonitoringSignificantLocationChanges(onUpdate: @escaping @Sendable (CLLocation) -> Void) - func stopMonitoringSignificantLocationChanges() -} - -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 -} - -struct WatchMessagingStatus: Sendable, Equatable { - var supported: Bool - var paired: Bool - var appInstalled: Bool - var reachable: Bool - var activationState: String -} - -struct WatchQuickReplyEvent: Sendable, Equatable { - var replyId: String - var promptId: String - var actionId: String - var actionLabel: String? - var sessionKey: String? - var note: String? - var sentAtMs: Int? - var transport: String -} - -struct WatchNotificationSendResult: Sendable, Equatable { - var deliveredImmediately: Bool - var queuedForDelivery: Bool - var transport: String -} - -protocol WatchMessagingServicing: AnyObject, Sendable { - func status() async -> WatchMessagingStatus - func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?) - func sendNotification( - id: String, - params: OpenClawWatchNotifyParams) async throws -> WatchNotificationSendResult -} - -extension CameraController: CameraServicing {} -extension ScreenRecordService: ScreenRecordingServicing {} -extension LocationService: LocationServicing {} diff --git a/apps/ios/Sources/Services/NotificationService.swift b/apps/ios/Sources/Services/NotificationService.swift deleted file mode 100644 index 348e93edc61..00000000000 --- a/apps/ios/Sources/Services/NotificationService.swift +++ /dev/null @@ -1,58 +0,0 @@ -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) in - self.center.add(request) { error in - if let error { - cont.resume(throwing: error) - } else { - cont.resume(returning: ()) - } - } - } - } -} diff --git a/apps/ios/Sources/Services/WatchMessagingService.swift b/apps/ios/Sources/Services/WatchMessagingService.swift deleted file mode 100644 index 3511a06c2db..00000000000 --- a/apps/ios/Sources/Services/WatchMessagingService.swift +++ /dev/null @@ -1,280 +0,0 @@ -import Foundation -import OpenClawKit -import OSLog -@preconcurrency import WatchConnectivity - -enum WatchMessagingError: LocalizedError { - case unsupported - case notPaired - case watchAppNotInstalled - - var errorDescription: String? { - switch self { - case .unsupported: - "WATCH_UNAVAILABLE: WatchConnectivity is not supported on this device" - case .notPaired: - "WATCH_UNAVAILABLE: no paired Apple Watch" - case .watchAppNotInstalled: - "WATCH_UNAVAILABLE: OpenClaw watch companion app is not installed" - } - } -} - -final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked Sendable { - private static let logger = Logger(subsystem: "ai.openclaw", category: "watch.messaging") - private let session: WCSession? - private let replyHandlerLock = NSLock() - private var replyHandler: (@Sendable (WatchQuickReplyEvent) -> Void)? - - override init() { - if WCSession.isSupported() { - self.session = WCSession.default - } else { - self.session = nil - } - super.init() - if let session = self.session { - session.delegate = self - session.activate() - } - } - - static func isSupportedOnDevice() -> Bool { - WCSession.isSupported() - } - - static func currentStatusSnapshot() -> WatchMessagingStatus { - guard WCSession.isSupported() else { - return WatchMessagingStatus( - supported: false, - paired: false, - appInstalled: false, - reachable: false, - activationState: "unsupported") - } - let session = WCSession.default - return status(for: session) - } - - func status() async -> WatchMessagingStatus { - await self.ensureActivated() - guard let session = self.session else { - return WatchMessagingStatus( - supported: false, - paired: false, - appInstalled: false, - reachable: false, - activationState: "unsupported") - } - return Self.status(for: session) - } - - func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?) { - self.replyHandlerLock.lock() - self.replyHandler = handler - self.replyHandlerLock.unlock() - } - - func sendNotification( - id: String, - params: OpenClawWatchNotifyParams) async throws -> WatchNotificationSendResult - { - await self.ensureActivated() - guard let session = self.session else { - throw WatchMessagingError.unsupported - } - - let snapshot = Self.status(for: session) - guard snapshot.paired else { throw WatchMessagingError.notPaired } - guard snapshot.appInstalled else { throw WatchMessagingError.watchAppNotInstalled } - - var payload: [String: Any] = [ - "type": "watch.notify", - "id": id, - "title": params.title, - "body": params.body, - "priority": params.priority?.rawValue ?? OpenClawNotificationPriority.active.rawValue, - "sentAtMs": Int(Date().timeIntervalSince1970 * 1000), - ] - if let promptId = Self.nonEmpty(params.promptId) { - payload["promptId"] = promptId - } - if let sessionKey = Self.nonEmpty(params.sessionKey) { - payload["sessionKey"] = sessionKey - } - if let kind = Self.nonEmpty(params.kind) { - payload["kind"] = kind - } - if let details = Self.nonEmpty(params.details) { - payload["details"] = details - } - if let expiresAtMs = params.expiresAtMs { - payload["expiresAtMs"] = expiresAtMs - } - if let risk = params.risk { - payload["risk"] = risk.rawValue - } - if let actions = params.actions, !actions.isEmpty { - payload["actions"] = actions.map { action in - var encoded: [String: Any] = [ - "id": action.id, - "label": action.label, - ] - if let style = Self.nonEmpty(action.style) { - encoded["style"] = style - } - return encoded - } - } - - if snapshot.reachable { - do { - try await self.sendReachableMessage(payload, with: session) - return WatchNotificationSendResult( - deliveredImmediately: true, - queuedForDelivery: false, - transport: "sendMessage") - } catch { - Self.logger.error("watch sendMessage failed: \(error.localizedDescription, privacy: .public)") - } - } - - _ = session.transferUserInfo(payload) - return WatchNotificationSendResult( - deliveredImmediately: false, - queuedForDelivery: true, - transport: "transferUserInfo") - } - - private func sendReachableMessage(_ payload: [String: Any], with session: WCSession) async throws { - try await withCheckedThrowingContinuation { continuation in - session.sendMessage(payload, replyHandler: { _ in - continuation.resume() - }, errorHandler: { error in - continuation.resume(throwing: error) - }) - } - } - - private func emitReply(_ event: WatchQuickReplyEvent) { - let handler: ((WatchQuickReplyEvent) -> Void)? - self.replyHandlerLock.lock() - handler = self.replyHandler - self.replyHandlerLock.unlock() - handler?(event) - } - - private static func nonEmpty(_ value: String?) -> String? { - let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return trimmed.isEmpty ? nil : trimmed - } - - private static func parseQuickReplyPayload( - _ payload: [String: Any], - transport: String) -> WatchQuickReplyEvent? - { - guard (payload["type"] as? String) == "watch.reply" else { - return nil - } - guard let actionId = nonEmpty(payload["actionId"] as? String) else { - return nil - } - let promptId = nonEmpty(payload["promptId"] as? String) ?? "unknown" - let replyId = nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString - let actionLabel = nonEmpty(payload["actionLabel"] as? String) - let sessionKey = nonEmpty(payload["sessionKey"] as? String) - let note = nonEmpty(payload["note"] as? String) - let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue - - return WatchQuickReplyEvent( - replyId: replyId, - promptId: promptId, - actionId: actionId, - actionLabel: actionLabel, - sessionKey: sessionKey, - note: note, - sentAtMs: sentAtMs, - transport: transport) - } - - private func ensureActivated() async { - guard let session = self.session else { return } - if session.activationState == .activated { return } - session.activate() - for _ in 0..<8 { - if session.activationState == .activated { return } - try? await Task.sleep(nanoseconds: 100_000_000) - } - } - - private static func status(for session: WCSession) -> WatchMessagingStatus { - WatchMessagingStatus( - supported: true, - paired: session.isPaired, - appInstalled: session.isWatchAppInstalled, - reachable: session.isReachable, - activationState: activationStateLabel(session.activationState)) - } - - private static func activationStateLabel(_ state: WCSessionActivationState) -> String { - switch state { - case .notActivated: - "notActivated" - case .inactive: - "inactive" - case .activated: - "activated" - @unknown default: - "unknown" - } - } -} - -extension WatchMessagingService: WCSessionDelegate { - func session( - _ session: WCSession, - activationDidCompleteWith activationState: WCSessionActivationState, - error: (any Error)?) - { - if let error { - Self.logger.error("watch activation failed: \(error.localizedDescription, privacy: .public)") - return - } - Self.logger.debug("watch activation state=\(Self.activationStateLabel(activationState), privacy: .public)") - } - - func sessionDidBecomeInactive(_ session: WCSession) {} - - func sessionDidDeactivate(_ session: WCSession) { - session.activate() - } - - func session(_: WCSession, didReceiveMessage message: [String: Any]) { - guard let event = Self.parseQuickReplyPayload(message, transport: "sendMessage") else { - return - } - self.emitReply(event) - } - - func session( - _: WCSession, - didReceiveMessage message: [String: Any], - replyHandler: @escaping ([String: Any]) -> Void) - { - guard let event = Self.parseQuickReplyPayload(message, transport: "sendMessage") else { - replyHandler(["ok": false, "error": "unsupported_payload"]) - return - } - replyHandler(["ok": true]) - self.emitReply(event) - } - - func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) { - guard let event = Self.parseQuickReplyPayload(userInfo, transport: "transferUserInfo") else { - return - } - self.emitReply(event) - } - - func sessionReachabilityDidChange(_ session: WCSession) {} -} diff --git a/apps/ios/Sources/SessionKey.swift b/apps/ios/Sources/SessionKey.swift deleted file mode 100644 index 89798b6a293..00000000000 --- a/apps/ios/Sources/SessionKey.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation - -enum SessionKey { - static func normalizeMainKey(_ raw: String?) -> String { - let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - 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 } - if trimmed == "global" { return true } - return trimmed.hasPrefix("agent:") - } -} diff --git a/apps/ios/Sources/Settings/SettingsNetworkingHelpers.swift b/apps/ios/Sources/Settings/SettingsNetworkingHelpers.swift deleted file mode 100644 index f061ff9a204..00000000000 --- a/apps/ios/Sources/Settings/SettingsNetworkingHelpers.swift +++ /dev/null @@ -1,40 +0,0 @@ -import Foundation - -struct SettingsHostPort: Equatable { - var host: String - var port: Int -} - -enum SettingsNetworkingHelpers { - static func parseHostPort(from address: String) -> SettingsHostPort? { - let trimmed = address.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - - if trimmed.hasPrefix("["), - let close = trimmed.firstIndex(of: "]"), - close < trimmed.endIndex - { - let host = String(trimmed[trimmed.index(after: trimmed.startIndex).. String { - if let host, let port { - let needsBrackets = host.contains(":") && !host.hasPrefix("[") && !host.hasSuffix("]") - let hostPart = needsBrackets ? "[\(host)]" : host - return "http://\(hostPart):\(port)" - } - return "http://\(fallback)" - } -} diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift deleted file mode 100644 index 024a4cbf42b..00000000000 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ /dev/null @@ -1,1042 +0,0 @@ -import OpenClawKit -import Network -import Observation -import os -import SwiftUI -import UIKit - -struct SettingsTab: View { - private struct FeatureHelp: Identifiable { - let id = UUID() - let title: String - let message: String - } - - @Environment(NodeAppModel.self) private var appModel: NodeAppModel - @Environment(VoiceWakeManager.self) private var voiceWake: VoiceWakeManager - @Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController - @Environment(\.dismiss) private var dismiss - @AppStorage("node.displayName") private var displayName: String = "iOS Node" - @AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString - @AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false - @AppStorage("talk.enabled") private var talkEnabled: Bool = false - @AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true - @AppStorage("talk.background.enabled") private var talkBackgroundEnabled: Bool = false - @AppStorage("talk.voiceDirectiveHint.enabled") private var talkVoiceDirectiveHintEnabled: Bool = true - @AppStorage("camera.enabled") private var cameraEnabled: Bool = true - @AppStorage("location.enabledMode") private var locationEnabledModeRaw: String = OpenClawLocationMode.off.rawValue - @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 - - // Onboarding control (RootCanvas listens to onboarding.requestID and force-opens the wizard). - @AppStorage("onboarding.requestID") private var onboardingRequestID: Int = 0 - @AppStorage("gateway.onboardingComplete") private var onboardingComplete: Bool = false - @AppStorage("gateway.hasConnectedOnce") private var hasConnectedOnce: Bool = false - - @State private var connectingGatewayID: String? - @State private var lastLocationModeRaw: String = OpenClawLocationMode.off.rawValue - @State private var gatewayToken: String = "" - @State private var gatewayPassword: String = "" - @State private var defaultShareInstruction: 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 = "" - - @State private var showResetOnboardingAlert: Bool = false - @State private var activeFeatureHelp: FeatureHelp? - @State private var suppressCredentialPersist: Bool = false - - private let gatewayLogger = Logger(subsystem: "ai.openclaw.ios", category: "GatewaySettings") - - var body: some View { - NavigationStack { - Form { - 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) - } - } - - 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 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 = urlString - } label: { - Label("Copy URL", 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) - } - - DisclosureGroup("Advanced") { - Toggle("Use Manual Gateway", isOn: self.$manualGatewayEnabled) - - TextField("Host", text: self.$manualGatewayHost) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - - TextField("Port (optional)", text: self.manualPortBinding) - .keyboardType(.numberPad) - - Toggle("Use TLS", isOn: self.$manualGatewayTLS) - - Button { - Task { await self.connectManual() } - } label: { - if self.connectingGatewayID == "manual" { - HStack(spacing: 8) { - ProgressView() - .progressViewStyle(.circular) - Text("Connecting…") - } - } else { - Text("Connect (Manual)") - } - } - .disabled(self.connectingGatewayID != nil || self.manualGatewayHost - .trimmingCharacters(in: .whitespacesAndNewlines) - .isEmpty || !self.manualPortIsValid) - - Text( - "Use this when mDNS/Bonjour discovery is blocked. " - + "Leave port empty for 443 on tailnet DNS (TLS) or 18789 otherwise.") - .font(.footnote) - .foregroundStyle(.secondary) - - Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled) - .onChange(of: self.discoveryDebugLogsEnabled) { _, newValue in - self.gatewayController.setDiscoveryDebugLoggingEnabled(newValue) - } - - NavigationLink("Discovery Logs") { - GatewayDiscoveryDebugLogView() - } - - Toggle("Debug Canvas Status", isOn: self.$canvasDebugStatusEnabled) - - TextField("Gateway Auth Token", text: self.$gatewayToken) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - - SecureField("Gateway Password", text: self.$gatewayPassword) - - Button("Reset Onboarding", role: .destructive) { - self.showResetOnboardingAlert = true - } - - 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)) - } - } - } label: { - 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("Device") { - DisclosureGroup("Features") { - self.featureToggle( - "Voice Wake", - isOn: self.$voiceWakeEnabled, - help: "Enables wake-word activation to start a hands-free session.") { newValue in - self.appModel.setVoiceWakeEnabled(newValue) - } - self.featureToggle( - "Talk Mode", - isOn: self.$talkEnabled, - help: "Enables voice conversation mode with your connected OpenClaw agent.") { newValue in - self.appModel.setTalkEnabled(newValue) - } - self.featureToggle( - "Background Listening", - isOn: self.$talkBackgroundEnabled, - help: "Keeps listening while the app is backgrounded. Uses more battery.") - - NavigationLink { - VoiceWakeWordsSettingsView() - } label: { - LabeledContent( - "Wake Words", - value: VoiceWakePreferences.displayString(for: self.voiceWake.triggerWords)) - } - - self.featureToggle( - "Allow Camera", - isOn: self.$cameraEnabled, - help: "Allows the gateway to request photos or short video clips while OpenClaw is foregrounded.") - - HStack(spacing: 8) { - Text("Location Access") - Spacer() - Button { - self.activeFeatureHelp = FeatureHelp( - title: "Location Access", - message: "Controls location permissions for OpenClaw. Off disables location tools, While Using enables foreground location, and Always enables background location.") - } label: { - Image(systemName: "info.circle") - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - .accessibilityLabel("Location Access info") - } - 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) - } - .labelsHidden() - .pickerStyle(.segmented) - - self.featureToggle( - "Prevent Sleep", - isOn: self.$preventSleep, - help: "Keeps the screen awake while OpenClaw is open.") - - DisclosureGroup("Advanced") { - VStack(alignment: .leading, spacing: 8) { - Text("Talk Voice (Gateway)") - .font(.footnote.weight(.semibold)) - .foregroundStyle(.secondary) - LabeledContent("Provider", value: "ElevenLabs") - LabeledContent( - "API Key", - value: self.appModel.talkMode.gatewayTalkConfigLoaded - ? (self.appModel.talkMode.gatewayTalkApiKeyConfigured ? "Configured" : "Not configured") - : "Not loaded") - LabeledContent( - "Default Model", - value: self.appModel.talkMode.gatewayTalkDefaultModelId ?? "eleven_v3 (fallback)") - LabeledContent( - "Default Voice", - value: self.appModel.talkMode.gatewayTalkDefaultVoiceId ?? "auto (first available)") - Text("Configured on gateway via talk.apiKey, talk.modelId, and talk.voiceId.") - .font(.footnote) - .foregroundStyle(.secondary) - } - self.featureToggle( - "Voice Directive Hint", - isOn: self.$talkVoiceDirectiveHintEnabled, - help: "Adds voice-switching instructions to Talk prompts. Disable to reduce prompt size.") - self.featureToggle( - "Show Talk Button", - isOn: self.$talkButtonEnabled, - help: "Shows the floating Talk button in the main interface.") - TextField("Default Share Instruction", text: self.$defaultShareInstruction, axis: .vertical) - .lineLimit(2 ... 6) - .textInputAutocapitalization(.sentences) - HStack(spacing: 8) { - Text("Default Share Instruction") - .font(.footnote) - .foregroundStyle(.secondary) - Spacer() - Button { - self.activeFeatureHelp = FeatureHelp( - title: "Default Share Instruction", - message: "Appends this instruction when sharing content into OpenClaw from iOS.") - } label: { - Image(systemName: "info.circle") - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - .accessibilityLabel("Default Share Instruction info") - } - - VStack(alignment: .leading, spacing: 8) { - Button { - Task { await self.appModel.runSharePipelineSelfTest() } - } label: { - Label("Run Share Self-Test", systemImage: "checkmark.seal") - } - Text(self.appModel.lastShareEventText) - .font(.footnote) - .foregroundStyle(.secondary) - } - } - } - - DisclosureGroup("Device Info") { - TextField("Name", text: self.$displayName) - Text(self.instanceId) - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - LabeledContent("Device", value: self.deviceFamily()) - LabeledContent("Platform", value: self.platformString()) - LabeledContent("OpenClaw", value: self.openClawVersionString()) - } - } - } - .navigationTitle("Settings") - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button { - self.dismiss() - } label: { - Image(systemName: "xmark") - } - .accessibilityLabel("Close") - } - } - .alert("Reset Onboarding?", isPresented: self.$showResetOnboardingAlert) { - Button("Reset", role: .destructive) { - self.resetOnboarding() - } - Button("Cancel", role: .cancel) {} - } message: { - Text( - "This will disconnect, clear saved gateway connection + credentials, and reopen the onboarding wizard.") - } - .alert(item: self.$activeFeatureHelp) { help in - Alert( - title: Text(help.title), - message: Text(help.message), - dismissButton: .default(Text("OK"))) - } - .onAppear { - 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) ?? "" - } - self.defaultShareInstruction = ShareToAgentSettings.loadDefaultInstruction() - self.appModel.refreshLastShareEventFromRelay() - // Keep setup front-and-center when disconnected; keep things compact once connected. - self.gatewayExpanded = !self.isGatewayConnected - self.selectedAgentPickerId = self.appModel.selectedAgentId ?? "" - if self.isGatewayConnected { - self.appModel.reloadTalkConfig() - } - } - .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) - guard !trimmed.isEmpty else { return } - GatewaySettingsStore.savePreferredGatewayStableID(trimmed) - } - .onChange(of: self.gatewayToken) { _, newValue in - guard !self.suppressCredentialPersist else { return } - let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) - let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !instanceId.isEmpty else { return } - GatewaySettingsStore.saveGatewayToken(trimmed, instanceId: instanceId) - } - .onChange(of: self.gatewayPassword) { _, newValue in - guard !self.suppressCredentialPersist else { return } - let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) - let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !instanceId.isEmpty else { return } - GatewaySettingsStore.saveGatewayPassword(trimmed, instanceId: instanceId) - } - .onChange(of: self.defaultShareInstruction) { _, newValue in - ShareToAgentSettings.saveDefaultInstruction(newValue) - } - .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 - self.lastLocationModeRaw = newValue - guard let mode = OpenClawLocationMode(rawValue: newValue) else { return } - Task { - let granted = await self.appModel.requestLocationPermissions(mode: mode) - if !granted { - await MainActor.run { - self.locationEnabledModeRaw = previous - self.lastLocationModeRaw = previous - } - return - } - await MainActor.run { - self.gatewayController.refreshActiveGatewayRegistrationFromSettings() - } - } - } - } - .gatewayTrustPromptAlert() - } - - @ViewBuilder - private func gatewayList(showing: GatewayListMode) -> some View { - if self.gatewayController.gateways.isEmpty { - 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(), - case let .manual(host, port, _, _) = lastKnown - { - Button { - Task { await self.connectLastKnown() } - } label: { - self.lastKnownButtonLabel(host: host, port: 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 - let isConnected = gateway.stableID == connectedID - switch showing { - case .all: - return true - case .availableOnly: - return !isConnected - } - } - - if rows.isEmpty, showing == .availableOnly { - Text("No other gateways found.") - .foregroundStyle(.secondary) - } else { - ForEach(rows) { gateway in - HStack { - VStack(alignment: .leading, spacing: 2) { - // Avoid localized-string formatting edge cases from Bonjour-advertised names. - Text(verbatim: gateway.name) - let detailLines = self.gatewayDetailLines(gateway) - ForEach(detailLines, id: \.self) { line in - Text(verbatim: line) - .font(.footnote) - .foregroundStyle(.secondary) - } - } - Spacer() - - Button { - Task { await self.connect(gateway) } - } label: { - if self.connectingGatewayID == gateway.id { - ProgressView() - .progressViewStyle(.circular) - } else { - Text("Connect") - } - } - .disabled(self.connectingGatewayID != nil) - } - } - } - } - } - - private enum GatewayListMode: Equatable { - case all - 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)" - } - - private func deviceFamily() -> String { - switch UIDevice.current.userInterfaceIdiom { - case .pad: - "iPad" - case .phone: - "iPhone" - default: - "iOS" - } - } - - private func openClawVersionString() -> String { - let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev" - let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "" - let trimmedBuild = build.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmedBuild.isEmpty || trimmedBuild == version { - return version - } - return "\(version) (\(trimmedBuild))" - } - - private func featureToggle( - _ title: String, - isOn: Binding, - help: String, - onChange: ((Bool) -> Void)? = nil - ) -> some View { - HStack(spacing: 8) { - Toggle(title, isOn: isOn) - Button { - self.activeFeatureHelp = FeatureHelp(title: title, message: help) - } label: { - Image(systemName: "info.circle") - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - .accessibilityLabel("\(title) info") - } - .onChange(of: isOn.wrappedValue) { _, newValue in - onChange?(newValue) - } - } - - private func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async { - self.connectingGatewayID = gateway.id - self.manualGatewayEnabled = false - self.preferredGatewayStableID = gateway.stableID - GatewaySettingsStore.savePreferredGatewayStableID(gateway.stableID) - self.lastDiscoveredGatewayStableID = gateway.stableID - GatewaySettingsStore.saveLastDiscoveredGatewayStableID(gateway.stableID) - defer { self.connectingGatewayID = nil } - - let err = await self.gatewayController.connectWithDiagnostics(gateway) - if let err { - self.setupStatusText = err - } - } - - 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 { - 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 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 = GatewaySetupCode.decode(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 { - await TCPProbe.probe( - host: host, - port: port, - timeoutSeconds: timeoutSeconds, - queueLabel: "gateway.preflight") - } - - // (GatewaySetupCode) decode raw setup codes. - - private func connectManual() async { - let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines) - guard !host.isEmpty else { - self.setupStatusText = "Failed: host required" - return - } - guard self.manualPortIsValid else { - self.setupStatusText = "Failed: invalid port" - return - } - - self.connectingGatewayID = "manual" - self.manualGatewayEnabled = true - defer { self.connectingGatewayID = nil } - - 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 hasTailnetIPv4() -> Bool { - var addrList: UnsafeMutablePointer? - 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) - } - - private static func httpURLString(host: String?, port: Int?, fallback: String) -> String { - SettingsNetworkingHelpers.httpURLString(host: host, port: port, fallback: fallback) - } - - private func resetOnboarding() { - // Disconnect first so RootCanvas doesn't instantly mark onboarding complete again. - self.appModel.disconnectGateway() - self.connectingGatewayID = nil - self.setupStatusText = nil - self.setupCode = "" - self.gatewayAutoConnect = false - - self.suppressCredentialPersist = true - defer { self.suppressCredentialPersist = false } - - self.gatewayToken = "" - self.gatewayPassword = "" - - let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmedInstanceId.isEmpty { - GatewaySettingsStore.deleteGatewayCredentials(instanceId: trimmedInstanceId) - } - - // Reset onboarding state + clear saved gateway connection (the two things RootCanvas checks). - GatewaySettingsStore.clearLastGatewayConnection() - - // RootCanvas also short-circuits onboarding when these are true. - self.onboardingComplete = false - self.hasConnectedOnce = false - - // Clear manual override so it doesn't count as an existing gateway config. - self.manualGatewayEnabled = false - self.manualGatewayHost = "" - - // Force re-present even without app restart. - self.onboardingRequestID += 1 - - // The onboarding wizard is presented from RootCanvas; dismiss Settings so it can show. - self.dismiss() - } - - private func gatewayDetailLines(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> [String] { - var lines: [String] = [] - if let lanHost = gateway.lanHost { lines.append("LAN: \(lanHost)") } - if let tailnet = gateway.tailnetDns { lines.append("Tailnet: \(tailnet)") } - - let gatewayPort = gateway.gatewayPort - let canvasPort = gateway.canvasPort - if gatewayPort != nil || canvasPort != nil { - let gw = gatewayPort.map(String.init) ?? "—" - let canvas = canvasPort.map(String.init) ?? "—" - lines.append("Ports: gateway \(gw) · canvas \(canvas)") - } - - if lines.isEmpty { - lines.append(gateway.debugID) - } - - return lines - } -} diff --git a/apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift b/apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift deleted file mode 100644 index e00e87e55d6..00000000000 --- a/apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift +++ /dev/null @@ -1,98 +0,0 @@ -import SwiftUI -import Combine - -struct VoiceWakeWordsSettingsView: View { - @Environment(NodeAppModel.self) private var appModel - @State private var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords() - @FocusState private var focusedTriggerIndex: Int? - @State private var syncTask: Task? - - var body: some View { - Form { - Section { - ForEach(self.triggerWords.indices, id: \.self) { index in - TextField("Wake word", text: self.binding(for: index)) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .focused(self.$focusedTriggerIndex, equals: index) - .onSubmit { - self.commitTriggerWords() - } - } - .onDelete(perform: self.removeWords) - - Button { - self.addWord() - } label: { - Label("Add word", systemImage: "plus") - } - .disabled(self.triggerWords - .contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })) - - Button("Reset defaults") { - self.triggerWords = VoiceWakePreferences.defaultTriggerWords - } - } header: { - Text("Wake Words") - } footer: { - Text( - "OpenClaw reacts when any trigger appears in a transcription. " - + "Keep them short to avoid false positives.") - } - } - .navigationTitle("Wake Words") - .toolbar { EditButton() } - .onAppear { - if self.triggerWords.isEmpty { - self.triggerWords = VoiceWakePreferences.defaultTriggerWords - self.commitTriggerWords() - } - } - .onChange(of: self.focusedTriggerIndex) { oldValue, newValue in - guard oldValue != nil, oldValue != newValue else { return } - self.commitTriggerWords() - } - .onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in - guard self.focusedTriggerIndex == nil else { return } - let updated = VoiceWakePreferences.loadTriggerWords() - if updated != self.triggerWords { - self.triggerWords = updated - } - } - } - - private func addWord() { - self.triggerWords.append("") - } - - private func removeWords(at offsets: IndexSet) { - self.triggerWords.remove(atOffsets: offsets) - if self.triggerWords.isEmpty { - self.triggerWords = VoiceWakePreferences.defaultTriggerWords - } - self.commitTriggerWords() - } - - private func binding(for index: Int) -> Binding { - Binding( - get: { - guard self.triggerWords.indices.contains(index) else { return "" } - return self.triggerWords[index] - }, - set: { newValue in - guard self.triggerWords.indices.contains(index) else { return } - self.triggerWords[index] = newValue - }) - } - - private func commitTriggerWords() { - VoiceWakePreferences.saveTriggerWords(self.triggerWords) - - let snapshot = VoiceWakePreferences.sanitizeTriggerWords(self.triggerWords) - self.syncTask?.cancel() - self.syncTask = Task { [snapshot, weak appModel = self.appModel] in - try? await Task.sleep(nanoseconds: 650_000_000) - await appModel?.setGlobalWakeWords(snapshot) - } - } -} diff --git a/apps/ios/Sources/Status/StatusActivityBuilder.swift b/apps/ios/Sources/Status/StatusActivityBuilder.swift deleted file mode 100644 index 381b3d2b9e8..00000000000 --- a/apps/ios/Sources/Status/StatusActivityBuilder.swift +++ /dev/null @@ -1,71 +0,0 @@ -import SwiftUI - -enum StatusActivityBuilder { - @MainActor - static func build( - appModel: NodeAppModel, - voiceWakeEnabled: Bool, - cameraHUDText: String?, - cameraHUDKind: NodeAppModel.CameraHUDKind? - ) -> StatusPill.Activity? { - // Keep the top pill consistent across tabs (camera + voice wake + pairing states). - if appModel.isBackgrounded { - return StatusPill.Activity( - title: "Foreground required", - systemImage: "exclamationmark.triangle.fill", - tint: .orange) - } - - let gatewayStatus = appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) - let gatewayLower = gatewayStatus.lowercased() - if gatewayLower.contains("repair") { - return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange) - } - if gatewayLower.contains("approval") || gatewayLower.contains("pairing") { - return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock") - } - // Avoid duplicating the primary gateway status ("Connecting…") in the activity slot. - - if appModel.screenRecordActive { - return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red) - } - - if let cameraHUDText, !cameraHUDText.isEmpty, let cameraHUDKind { - let systemImage: String - let tint: Color? - switch cameraHUDKind { - case .photo: - systemImage = "camera.fill" - tint = nil - case .recording: - systemImage = "video.fill" - tint = .red - case .success: - systemImage = "checkmark.circle.fill" - tint = .green - case .error: - systemImage = "exclamationmark.triangle.fill" - tint = .red - } - return StatusPill.Activity(title: cameraHUDText, systemImage: systemImage, tint: tint) - } - - if voiceWakeEnabled { - let voiceStatus = appModel.voiceWake.statusText - if voiceStatus.localizedCaseInsensitiveContains("microphone permission") { - 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 appModel.talkMode.isEnabled { - return nil - } - let suffix = appModel.isBackgrounded ? " (background)" : "" - return StatusPill.Activity(title: "Voice Wake paused\(suffix)", systemImage: "pause.circle.fill") - } - } - - return nil - } -} - diff --git a/apps/ios/Sources/Status/StatusPill.swift b/apps/ios/Sources/Status/StatusPill.swift deleted file mode 100644 index ea5e425c49d..00000000000 --- a/apps/ios/Sources/Status/StatusPill.swift +++ /dev/null @@ -1,136 +0,0 @@ -import SwiftUI - -struct StatusPill: View { - @Environment(\.scenePhase) private var scenePhase - @Environment(\.accessibilityReduceMotion) private var reduceMotion - @Environment(\.colorSchemeContrast) private var contrast - - enum GatewayState: Equatable { - case connected - case connecting - case error - case disconnected - - var title: String { - switch self { - case .connected: "Connected" - case .connecting: "Connecting…" - case .error: "Error" - case .disconnected: "Offline" - } - } - - var color: Color { - switch self { - case .connected: .green - case .connecting: .yellow - case .error: .red - case .disconnected: .gray - } - } - } - - struct Activity: Equatable { - var title: String - var systemImage: String - var tint: Color? - } - - var gateway: GatewayState - var voiceWakeEnabled: Bool - var activity: Activity? - var brighten: Bool = false - var onTap: () -> Void - - @State private var pulse: Bool = false - - var body: some View { - Button(action: self.onTap) { - HStack(spacing: 10) { - HStack(spacing: 8) { - Circle() - .fill(self.gateway.color) - .frame(width: 9, height: 9) - .scaleEffect(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.15 : 0.85) : 1.0) - .opacity(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.0 : 0.6) : 1.0) - - Text(self.gateway.title) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.primary) - } - - Divider() - .frame(height: 14) - .opacity(0.35) - - if let activity { - HStack(spacing: 6) { - Image(systemName: activity.systemImage) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(activity.tint ?? .primary) - Text(activity.title) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.primary) - .lineLimit(1) - } - .transition(.opacity.combined(with: .move(edge: .top))) - } else { - Image(systemName: self.voiceWakeEnabled ? "mic.fill" : "mic.slash") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(self.voiceWakeEnabled ? .primary : .secondary) - .accessibilityLabel(self.voiceWakeEnabled ? "Voice Wake enabled" : "Voice Wake disabled") - .transition(.opacity.combined(with: .move(edge: .top))) - } - } - .padding(.vertical, 8) - .padding(.horizontal, 12) - .background { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(.ultraThinMaterial) - .overlay { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .strokeBorder( - .white.opacity(self.contrast == .increased ? 0.5 : (self.brighten ? 0.24 : 0.18)), - lineWidth: self.contrast == .increased ? 1.0 : 0.5 - ) - } - .shadow(color: .black.opacity(0.25), radius: 12, y: 6) - } - } - .buttonStyle(.plain) - .accessibilityLabel("Connection Status") - .accessibilityValue(self.accessibilityValue) - .accessibilityHint("Double tap to open settings") - .onAppear { self.updatePulse(for: self.gateway, scenePhase: self.scenePhase, reduceMotion: self.reduceMotion) } - .onDisappear { self.pulse = false } - .onChange(of: self.gateway) { _, newValue in - self.updatePulse(for: newValue, scenePhase: self.scenePhase, reduceMotion: self.reduceMotion) - } - .onChange(of: self.scenePhase) { _, newValue in - self.updatePulse(for: self.gateway, scenePhase: newValue, reduceMotion: self.reduceMotion) - } - .onChange(of: self.reduceMotion) { _, newValue in - self.updatePulse(for: self.gateway, scenePhase: self.scenePhase, reduceMotion: newValue) - } - .animation(.easeInOut(duration: 0.18), value: self.activity?.title) - } - - private var accessibilityValue: String { - if let activity { - return "\(self.gateway.title), \(activity.title)" - } - return "\(self.gateway.title), Voice Wake \(self.voiceWakeEnabled ? "enabled" : "disabled")" - } - - private func updatePulse(for gateway: GatewayState, scenePhase: ScenePhase, reduceMotion: Bool) { - guard gateway == .connecting, scenePhase == .active, !reduceMotion else { - withAnimation(reduceMotion ? .none : .easeOut(duration: 0.2)) { self.pulse = false } - return - } - - guard !self.pulse else { return } - withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) { - self.pulse = true - } - } -} diff --git a/apps/ios/Sources/Status/VoiceWakeToast.swift b/apps/ios/Sources/Status/VoiceWakeToast.swift deleted file mode 100644 index ef6fc1295a7..00000000000 --- a/apps/ios/Sources/Status/VoiceWakeToast.swift +++ /dev/null @@ -1,38 +0,0 @@ -import SwiftUI - -struct VoiceWakeToast: View { - @Environment(\.colorSchemeContrast) private var contrast - - var command: String - var brighten: Bool = false - - var body: some View { - HStack(spacing: 10) { - Image(systemName: "mic.fill") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.primary) - - Text(self.command) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.primary) - .lineLimit(1) - .truncationMode(.tail) - } - .padding(.vertical, 10) - .padding(.horizontal, 12) - .background { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(.ultraThinMaterial) - .overlay { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .strokeBorder( - .white.opacity(self.contrast == .increased ? 0.5 : (self.brighten ? 0.24 : 0.18)), - lineWidth: self.contrast == .increased ? 1.0 : 0.5 - ) - } - .shadow(color: .black.opacity(0.25), radius: 12, y: 6) - } - .accessibilityLabel("Voice Wake triggered") - .accessibilityValue("Command: \(self.command)") - } -} diff --git a/apps/ios/Sources/Voice/TalkModeManager.swift b/apps/ios/Sources/Voice/TalkModeManager.swift deleted file mode 100644 index 8f208c66d50..00000000000 --- a/apps/ios/Sources/Voice/TalkModeManager.swift +++ /dev/null @@ -1,2089 +0,0 @@ -import AVFAudio -import OpenClawChatUI -import OpenClawKit -import OpenClawProtocol -import Foundation -import Observation -import OSLog -import Speech - -// This file intentionally centralizes talk mode state + behavior. -// It's large, and splitting would force `private` -> `fileprivate` across many members. -// We'll refactor into smaller files when the surface stabilizes. -// swiftlint:disable type_body_length -@MainActor -@Observable -final class TalkModeManager: NSObject { - private typealias SpeechRequest = SFSpeechAudioBufferRecognitionRequest - private static let defaultModelIdFallback = "eleven_v3" - private static let redactedConfigSentinel = "__OPENCLAW_REDACTED__" - var isEnabled: Bool = false - var isListening: Bool = false - var isSpeaking: Bool = false - var isPushToTalkActive: Bool = false - var statusText: String = "Off" - /// 0..1-ish (not calibrated). Intended for UI feedback only. - var micLevel: Double = 0 - var gatewayTalkConfigLoaded: Bool = false - var gatewayTalkApiKeyConfigured: Bool = false - var gatewayTalkDefaultModelId: String? - var gatewayTalkDefaultVoiceId: String? - - private enum CaptureMode { - case idle - case continuous - case pushToTalk - } - - private var captureMode: CaptureMode = .idle - private var resumeContinuousAfterPTT: Bool = false - private var activePTTCaptureId: String? - private var pttAutoStopEnabled: Bool = false - private var pttCompletion: CheckedContinuation? - private var pttTimeoutTask: Task? - - private let allowSimulatorCapture: Bool - - private let audioEngine = AVAudioEngine() - private var inputTapInstalled = false - private var audioTapDiagnostics: AudioTapDiagnostics? - private var speechRecognizer: SFSpeechRecognizer? - private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? - private var recognitionTask: SFSpeechRecognitionTask? - private var silenceTask: Task? - - private var lastHeard: Date? - private var lastTranscript: String = "" - private var loggedPartialThisCycle: Bool = false - private var lastSpokenText: String? - private var lastInterruptedAtSeconds: Double? - - private var defaultVoiceId: String? - private var currentVoiceId: String? - private var defaultModelId: String? - private var currentModelId: String? - private var voiceOverrideActive = false - private var modelOverrideActive = false - private var defaultOutputFormat: String? - private var apiKey: String? - private var voiceAliases: [String: String] = [:] - private var interruptOnSpeech: Bool = true - private var mainSessionKey: String = "main" - private var fallbackVoiceId: String? - private var lastPlaybackWasPCM: Bool = false - var pcmPlayer: PCMStreamingAudioPlaying = PCMStreamingAudioPlayer.shared - var mp3Player: StreamingAudioPlaying = StreamingAudioPlayer.shared - - private var gateway: GatewayNodeSession? - private var gatewayConnected = false - private let silenceWindow: TimeInterval = 0.9 - private var lastAudioActivity: Date? - private var noiseFloorSamples: [Double] = [] - private var noiseFloor: Double? - private var noiseFloorReady: Bool = false - - private var chatSubscribedSessionKeys = Set() - private var incrementalSpeechQueue: [String] = [] - private var incrementalSpeechTask: Task? - private var incrementalSpeechActive = false - private var incrementalSpeechUsed = false - private var incrementalSpeechLanguage: String? - private var incrementalSpeechBuffer = IncrementalSpeechBuffer() - private var incrementalSpeechContext: IncrementalSpeechContext? - private var incrementalSpeechDirective: TalkDirective? - private var incrementalSpeechPrefetch: IncrementalSpeechPrefetchState? - private var incrementalSpeechPrefetchMonitorTask: Task? - - private let logger = Logger(subsystem: "bot.molt", category: "TalkMode") - - init(allowSimulatorCapture: Bool = false) { - self.allowSimulatorCapture = allowSimulatorCapture - super.init() - } - - func attachGateway(_ gateway: GatewayNodeSession) { - self.gateway = gateway - } - - func updateGatewayConnected(_ connected: Bool) { - self.gatewayConnected = connected - if connected { - // If talk mode is enabled before the gateway connects (common on cold start), - // kick recognition once we're online so the UI doesn’t stay “Offline”. - if self.isEnabled, !self.isListening, self.captureMode != .pushToTalk { - Task { await self.start() } - } - } else { - if self.isEnabled, !self.isSpeaking { - self.statusText = "Offline" - } - } - } - - func updateMainSessionKey(_ sessionKey: String?) { - let trimmed = (sessionKey ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - if trimmed == self.mainSessionKey { return } - self.mainSessionKey = trimmed - if self.gatewayConnected, self.isEnabled { - Task { await self.subscribeChatIfNeeded(sessionKey: trimmed) } - } - } - - func setEnabled(_ enabled: Bool) { - self.isEnabled = enabled - if enabled { - self.logger.info("enabled") - Task { await self.start() } - } else { - self.logger.info("disabled") - self.stop() - } - } - - func start() async { - guard self.isEnabled else { return } - guard self.captureMode != .pushToTalk else { return } - if self.isListening { return } - guard self.gatewayConnected else { - self.statusText = "Offline" - return - } - - self.logger.info("start") - self.statusText = "Requesting permissions…" - let micOk = await Self.requestMicrophonePermission() - guard micOk else { - self.logger.warning("start blocked: microphone permission denied") - self.statusText = Self.permissionMessage( - kind: "Microphone", - status: AVAudioSession.sharedInstance().recordPermission) - return - } - let speechOk = await Self.requestSpeechPermission() - guard speechOk else { - self.logger.warning("start blocked: speech permission denied") - self.statusText = Self.permissionMessage( - kind: "Speech recognition", - status: SFSpeechRecognizer.authorizationStatus()) - return - } - - await self.reloadConfig() - do { - try Self.configureAudioSession() - // Set this before starting recognition so any early speech errors are classified correctly. - self.captureMode = .continuous - try self.startRecognition() - self.isListening = true - self.statusText = "Listening" - self.startSilenceMonitor() - await self.subscribeChatIfNeeded(sessionKey: self.mainSessionKey) - self.logger.info("listening") - } catch { - self.isListening = false - self.statusText = "Start failed: \(error.localizedDescription)" - self.logger.error("start failed: \(error.localizedDescription, privacy: .public)") - } - } - - func stop() { - self.isEnabled = false - self.isListening = false - self.isPushToTalkActive = false - self.captureMode = .idle - self.statusText = "Off" - self.lastTranscript = "" - self.lastHeard = nil - self.silenceTask?.cancel() - self.silenceTask = nil - self.stopRecognition() - self.stopSpeaking() - self.lastInterruptedAtSeconds = nil - let pendingPTT = self.pttCompletion != nil - let pendingCaptureId = self.activePTTCaptureId ?? UUID().uuidString - self.pttTimeoutTask?.cancel() - self.pttTimeoutTask = nil - self.pttAutoStopEnabled = false - if pendingPTT { - let payload = OpenClawTalkPTTStopPayload( - captureId: pendingCaptureId, - transcript: nil, - status: "cancelled") - self.finishPTTOnce(payload) - } - self.resumeContinuousAfterPTT = false - self.activePTTCaptureId = nil - TalkSystemSpeechSynthesizer.shared.stop() - do { - try AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation]) - } catch { - self.logger.warning("audio session deactivate failed: \(error.localizedDescription, privacy: .public)") - } - Task { await self.unsubscribeAllChats() } - } - - /// Suspends microphone usage without disabling Talk Mode. - /// Used when the app backgrounds (or when we need to temporarily release the mic). - func suspendForBackground(keepActive: Bool = false) -> Bool { - guard self.isEnabled else { return false } - if keepActive { - self.statusText = self.isListening ? "Listening" : self.statusText - return false - } - let wasActive = self.isListening || self.isSpeaking || self.isPushToTalkActive - - self.isListening = false - self.isPushToTalkActive = false - self.captureMode = .idle - self.statusText = "Paused" - self.lastTranscript = "" - self.lastHeard = nil - self.silenceTask?.cancel() - self.silenceTask = nil - - self.stopRecognition() - self.stopSpeaking() - self.lastInterruptedAtSeconds = nil - TalkSystemSpeechSynthesizer.shared.stop() - - do { - try AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation]) - } catch { - self.logger.warning("audio session deactivate failed: \(error.localizedDescription, privacy: .public)") - } - - Task { await self.unsubscribeAllChats() } - return wasActive - } - - func resumeAfterBackground(wasSuspended: Bool, wasKeptActive: Bool = false) async { - if wasKeptActive { return } - guard wasSuspended else { return } - guard self.isEnabled else { return } - await self.start() - } - - func userTappedOrb() { - self.stopSpeaking() - } - - func beginPushToTalk() async throws -> OpenClawTalkPTTStartPayload { - guard self.gatewayConnected else { - self.statusText = "Offline" - throw NSError(domain: "TalkMode", code: 7, userInfo: [ - NSLocalizedDescriptionKey: "Gateway not connected", - ]) - } - if self.isPushToTalkActive, let captureId = self.activePTTCaptureId { - return OpenClawTalkPTTStartPayload(captureId: captureId) - } - - self.stopSpeaking(storeInterruption: false) - self.pttTimeoutTask?.cancel() - self.pttTimeoutTask = nil - self.pttAutoStopEnabled = false - - self.resumeContinuousAfterPTT = self.isEnabled && self.captureMode == .continuous - self.silenceTask?.cancel() - self.silenceTask = nil - self.stopRecognition() - self.isListening = false - - let captureId = UUID().uuidString - self.activePTTCaptureId = captureId - self.lastTranscript = "" - self.lastHeard = nil - - self.statusText = "Requesting permissions…" - if !self.allowSimulatorCapture { - let micOk = await Self.requestMicrophonePermission() - guard micOk else { - self.statusText = Self.permissionMessage( - kind: "Microphone", - status: AVAudioSession.sharedInstance().recordPermission) - throw NSError(domain: "TalkMode", code: 4, userInfo: [ - NSLocalizedDescriptionKey: "Microphone permission denied", - ]) - } - let speechOk = await Self.requestSpeechPermission() - guard speechOk else { - self.statusText = Self.permissionMessage( - kind: "Speech recognition", - status: SFSpeechRecognizer.authorizationStatus()) - throw NSError(domain: "TalkMode", code: 5, userInfo: [ - NSLocalizedDescriptionKey: "Speech recognition permission denied", - ]) - } - } - - do { - try Self.configureAudioSession() - self.captureMode = .pushToTalk - try self.startRecognition() - self.isListening = true - self.isPushToTalkActive = true - self.statusText = "Listening (PTT)" - } catch { - self.isListening = false - self.isPushToTalkActive = false - self.captureMode = .idle - self.statusText = "Start failed: \(error.localizedDescription)" - throw error - } - - return OpenClawTalkPTTStartPayload(captureId: captureId) - } - - func endPushToTalk() async -> OpenClawTalkPTTStopPayload { - let captureId = self.activePTTCaptureId ?? UUID().uuidString - guard self.isPushToTalkActive else { - let payload = OpenClawTalkPTTStopPayload( - captureId: captureId, - transcript: nil, - status: "idle") - self.finishPTTOnce(payload) - return payload - } - - self.isPushToTalkActive = false - self.isListening = false - self.captureMode = .idle - self.stopRecognition() - self.pttTimeoutTask?.cancel() - self.pttTimeoutTask = nil - self.pttAutoStopEnabled = false - - let transcript = self.lastTranscript.trimmingCharacters(in: .whitespacesAndNewlines) - self.lastTranscript = "" - self.lastHeard = nil - - guard !transcript.isEmpty else { - self.statusText = "Ready" - if self.resumeContinuousAfterPTT { - await self.start() - } - self.resumeContinuousAfterPTT = false - self.activePTTCaptureId = nil - let payload = OpenClawTalkPTTStopPayload( - captureId: captureId, - transcript: nil, - status: "empty") - self.finishPTTOnce(payload) - return payload - } - - guard self.gatewayConnected else { - self.statusText = "Gateway not connected" - if self.resumeContinuousAfterPTT { - await self.start() - } - self.resumeContinuousAfterPTT = false - self.activePTTCaptureId = nil - let payload = OpenClawTalkPTTStopPayload( - captureId: captureId, - transcript: transcript, - status: "offline") - self.finishPTTOnce(payload) - return payload - } - - self.statusText = "Thinking…" - Task { @MainActor in - await self.processTranscript(transcript, restartAfter: self.resumeContinuousAfterPTT) - } - self.resumeContinuousAfterPTT = false - self.activePTTCaptureId = nil - let payload = OpenClawTalkPTTStopPayload( - captureId: captureId, - transcript: transcript, - status: "queued") - self.finishPTTOnce(payload) - return payload - } - - func runPushToTalkOnce(maxDurationSeconds: TimeInterval = 12) async throws -> OpenClawTalkPTTStopPayload { - if self.pttCompletion != nil { - _ = await self.cancelPushToTalk() - } - - if self.isPushToTalkActive { - let captureId = self.activePTTCaptureId ?? UUID().uuidString - return OpenClawTalkPTTStopPayload( - captureId: captureId, - transcript: nil, - status: "busy") - } - - _ = try await self.beginPushToTalk() - - return await withCheckedContinuation { cont in - self.pttCompletion = cont - self.pttAutoStopEnabled = true - self.startSilenceMonitor() - self.schedulePTTTimeout(seconds: maxDurationSeconds) - } - } - - func cancelPushToTalk() async -> OpenClawTalkPTTStopPayload { - let captureId = self.activePTTCaptureId ?? UUID().uuidString - guard self.isPushToTalkActive else { - let payload = OpenClawTalkPTTStopPayload( - captureId: captureId, - transcript: nil, - status: "idle") - self.finishPTTOnce(payload) - self.pttAutoStopEnabled = false - self.pttTimeoutTask?.cancel() - self.pttTimeoutTask = nil - self.resumeContinuousAfterPTT = false - self.activePTTCaptureId = nil - return payload - } - - let shouldResume = self.resumeContinuousAfterPTT - self.isPushToTalkActive = false - self.isListening = false - self.captureMode = .idle - self.stopRecognition() - self.lastTranscript = "" - self.lastHeard = nil - self.pttAutoStopEnabled = false - self.pttTimeoutTask?.cancel() - self.pttTimeoutTask = nil - self.resumeContinuousAfterPTT = false - self.activePTTCaptureId = nil - self.statusText = "Ready" - - let payload = OpenClawTalkPTTStopPayload( - captureId: captureId, - transcript: nil, - status: "cancelled") - self.finishPTTOnce(payload) - - if shouldResume { - await self.start() - } - return payload - } - - private func startRecognition() throws { - #if targetEnvironment(simulator) - if !self.allowSimulatorCapture { - throw NSError(domain: "TalkMode", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "Talk mode is not supported on the iOS simulator", - ]) - } else { - self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest() - self.recognitionRequest?.shouldReportPartialResults = true - return - } - #endif - - self.stopRecognition() - self.speechRecognizer = SFSpeechRecognizer() - guard let recognizer = self.speechRecognizer else { - throw NSError(domain: "TalkMode", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Speech recognizer unavailable", - ]) - } - - self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest() - self.recognitionRequest?.shouldReportPartialResults = true - self.recognitionRequest?.taskHint = .dictation - guard let request = self.recognitionRequest else { return } - - GatewayDiagnostics.log("talk audio: session \(Self.describeAudioSession())") - - let input = self.audioEngine.inputNode - let format = input.inputFormat(forBus: 0) - guard format.sampleRate > 0, format.channelCount > 0 else { - throw NSError(domain: "TalkMode", code: 3, userInfo: [ - NSLocalizedDescriptionKey: "Invalid audio input format", - ]) - } - input.removeTap(onBus: 0) - let tapDiagnostics = AudioTapDiagnostics(label: "talk") { [weak self] level in - guard let self else { return } - Task { @MainActor in - // Smooth + clamp for UI, and keep it cheap. - let raw = max(0, min(Double(level) * 10.0, 1.0)) - let next = (self.micLevel * 0.80) + (raw * 0.20) - self.micLevel = next - - // Dynamic thresholding so background noise doesn’t prevent endpointing. - if self.isListening, !self.isSpeaking, !self.noiseFloorReady { - self.noiseFloorSamples.append(raw) - if self.noiseFloorSamples.count >= 22 { - let sorted = self.noiseFloorSamples.sorted() - let take = max(6, sorted.count / 2) - let slice = sorted.prefix(take) - let avg = slice.reduce(0.0, +) / Double(slice.count) - self.noiseFloor = avg - self.noiseFloorReady = true - self.noiseFloorSamples.removeAll(keepingCapacity: true) - let threshold = min(0.35, max(0.12, avg + 0.10)) - GatewayDiagnostics.log( - "talk audio: noiseFloor=\(String(format: "%.3f", avg)) threshold=\(String(format: "%.3f", threshold))") - } - } - - let threshold: Double = if let floor = self.noiseFloor, self.noiseFloorReady { - min(0.35, max(0.12, floor + 0.10)) - } else { - 0.18 - } - if raw >= threshold { - self.lastAudioActivity = Date() - } - } - } - self.audioTapDiagnostics = tapDiagnostics - let tapBlock = Self.makeAudioTapAppendCallback(request: request, diagnostics: tapDiagnostics) - input.installTap(onBus: 0, bufferSize: 2048, format: format, block: tapBlock) - self.inputTapInstalled = true - - self.audioEngine.prepare() - try self.audioEngine.start() - self.loggedPartialThisCycle = false - - GatewayDiagnostics.log( - "talk speech: recognition started mode=\(String(describing: self.captureMode)) engineRunning=\(self.audioEngine.isRunning)") - self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in - guard let self else { return } - if let error { - let msg = error.localizedDescription - let lowered = msg.lowercased() - let isCancellation = lowered.contains("cancelled") || lowered.contains("canceled") - if isCancellation { - GatewayDiagnostics.log("talk speech: cancelled") - if self.captureMode == .continuous, self.isEnabled, !self.isSpeaking { - self.statusText = "Listening" - } - self.logger.debug("speech recognition cancelled") - return - } - GatewayDiagnostics.log("talk speech: error=\(msg)") - if !self.isSpeaking { - if msg.localizedCaseInsensitiveContains("no speech detected") { - // Treat as transient silence. Don't scare users with an error banner. - self.statusText = self.isEnabled ? "Listening" : "Speech error: \(msg)" - } else { - self.statusText = "Speech error: \(msg)" - } - } - self.logger.debug("speech recognition error: \(msg, privacy: .public)") - // Speech recognition can terminate on transient errors (e.g. no speech detected). - // If talk mode is enabled and we're in continuous capture, try to restart. - if self.captureMode == .continuous, self.isEnabled, !self.isSpeaking { - // Treat the task as terminal on error so we don't get stuck with a dead recognizer. - self.stopRecognition() - Task { @MainActor [weak self] in - await self?.restartRecognitionAfterError() - } - } - } - guard let result else { return } - let transcript = result.bestTranscription.formattedString - if !result.isFinal, !self.loggedPartialThisCycle { - let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { - self.loggedPartialThisCycle = true - GatewayDiagnostics.log("talk speech: partial chars=\(trimmed.count)") - } - } - Task { @MainActor in - await self.handleTranscript(transcript: transcript, isFinal: result.isFinal) - } - } - } - - private func restartRecognitionAfterError() async { - guard self.isEnabled, self.captureMode == .continuous else { return } - // Avoid thrashing the audio engine if it’s already running. - if self.recognitionTask != nil, self.audioEngine.isRunning { return } - try? await Task.sleep(nanoseconds: 250_000_000) - guard self.isEnabled, self.captureMode == .continuous else { return } - do { - try Self.configureAudioSession() - try self.startRecognition() - self.isListening = true - if self.statusText.localizedCaseInsensitiveContains("speech error") { - self.statusText = "Listening" - } - GatewayDiagnostics.log("talk speech: recognition restarted") - } catch { - let msg = error.localizedDescription - GatewayDiagnostics.log("talk speech: restart failed error=\(msg)") - } - } - - private func stopRecognition() { - self.recognitionTask?.cancel() - self.recognitionTask = nil - self.recognitionRequest?.endAudio() - self.recognitionRequest = nil - self.micLevel = 0 - self.lastAudioActivity = nil - self.noiseFloorSamples.removeAll(keepingCapacity: true) - self.noiseFloor = nil - self.noiseFloorReady = false - self.audioTapDiagnostics = nil - if self.inputTapInstalled { - self.audioEngine.inputNode.removeTap(onBus: 0) - self.inputTapInstalled = false - } - self.audioEngine.stop() - self.speechRecognizer = nil - } - - private nonisolated static func makeAudioTapAppendCallback( - request: SpeechRequest, - diagnostics: AudioTapDiagnostics) -> AVAudioNodeTapBlock - { - { buffer, _ in - request.append(buffer) - diagnostics.onBuffer(buffer) - } - } - - private func handleTranscript(transcript: String, isFinal: Bool) async { - let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines) - let ttsActive = self.isSpeechOutputActive - if ttsActive, self.interruptOnSpeech { - if self.shouldInterrupt(with: trimmed) { - self.stopSpeaking() - } - return - } - - guard self.isListening else { return } - if !trimmed.isEmpty { - self.lastTranscript = trimmed - self.lastHeard = Date() - } - if isFinal { - self.lastTranscript = trimmed - guard !trimmed.isEmpty else { return } - GatewayDiagnostics.log("talk speech: final transcript chars=\(trimmed.count)") - self.loggedPartialThisCycle = false - if self.captureMode == .pushToTalk, self.pttAutoStopEnabled, self.isPushToTalkActive { - _ = await self.endPushToTalk() - return - } - if self.captureMode == .continuous, !self.isSpeechOutputActive { - await self.processTranscript(trimmed, restartAfter: true) - } - } - } - - private func startSilenceMonitor() { - self.silenceTask?.cancel() - self.silenceTask = Task { [weak self] in - guard let self else { return } - while self.isEnabled || (self.isPushToTalkActive && self.pttAutoStopEnabled) { - try? await Task.sleep(nanoseconds: 200_000_000) - await self.checkSilence() - } - } - } - - private func checkSilence() async { - if self.captureMode == .continuous { - guard self.isListening, !self.isSpeechOutputActive else { return } - let transcript = self.lastTranscript.trimmingCharacters(in: .whitespacesAndNewlines) - guard !transcript.isEmpty else { return } - let lastActivity = [self.lastHeard, self.lastAudioActivity].compactMap { $0 }.max() - guard let lastActivity else { return } - if Date().timeIntervalSince(lastActivity) < self.silenceWindow { return } - await self.processTranscript(transcript, restartAfter: true) - return - } - - guard self.captureMode == .pushToTalk, self.pttAutoStopEnabled else { return } - guard self.isListening, !self.isSpeaking, self.isPushToTalkActive else { return } - let transcript = self.lastTranscript.trimmingCharacters(in: .whitespacesAndNewlines) - guard !transcript.isEmpty else { return } - let lastActivity = [self.lastHeard, self.lastAudioActivity].compactMap { $0 }.max() - guard let lastActivity else { return } - if Date().timeIntervalSince(lastActivity) < self.silenceWindow { return } - _ = await self.endPushToTalk() - } - - // Guardrail for PTT once so we don't stay open indefinitely. - private func schedulePTTTimeout(seconds: TimeInterval) { - guard seconds > 0 else { return } - let nanos = UInt64(seconds * 1_000_000_000) - self.pttTimeoutTask?.cancel() - self.pttTimeoutTask = Task { [weak self] in - try? await Task.sleep(nanoseconds: nanos) - await self?.handlePTTTimeout() - } - } - - private func handlePTTTimeout() async { - guard self.pttAutoStopEnabled, self.isPushToTalkActive else { return } - _ = await self.endPushToTalk() - } - - private func finishPTTOnce(_ payload: OpenClawTalkPTTStopPayload) { - guard let continuation = self.pttCompletion else { return } - self.pttCompletion = nil - continuation.resume(returning: payload) - } - - private func processTranscript(_ transcript: String, restartAfter: Bool) async { - self.isListening = false - self.captureMode = .idle - self.statusText = "Thinking…" - self.lastTranscript = "" - self.lastHeard = nil - self.stopRecognition() - - GatewayDiagnostics.log("talk: process transcript chars=\(transcript.count) restartAfter=\(restartAfter)") - await self.reloadConfig() - let prompt = self.buildPrompt(transcript: transcript) - guard self.gatewayConnected, let gateway else { - self.statusText = "Gateway not connected" - self.logger.warning("finalize: gateway not connected") - GatewayDiagnostics.log("talk: abort gateway not connected") - if restartAfter { - await self.start() - } - return - } - - do { - let startedAt = Date().timeIntervalSince1970 - let sessionKey = self.mainSessionKey - await self.subscribeChatIfNeeded(sessionKey: sessionKey) - self.logger.info( - "chat.send start sessionKey=\(sessionKey, privacy: .public) chars=\(prompt.count, privacy: .public)") - GatewayDiagnostics.log("talk: chat.send start sessionKey=\(sessionKey) chars=\(prompt.count)") - let runId = try await self.sendChat(prompt, gateway: gateway) - self.logger.info("chat.send ok runId=\(runId, privacy: .public)") - GatewayDiagnostics.log("talk: chat.send ok runId=\(runId)") - let shouldIncremental = self.shouldUseIncrementalTTS() - var streamingTask: Task? - if shouldIncremental { - self.resetIncrementalSpeech() - streamingTask = Task { @MainActor [weak self] in - guard let self else { return } - await self.streamAssistant(runId: runId, gateway: gateway) - } - } - let completion = await self.waitForChatCompletion(runId: runId, gateway: gateway, timeoutSeconds: 120) - if completion == .timeout { - self.logger.warning( - "chat completion timeout runId=\(runId, privacy: .public); attempting history fallback") - GatewayDiagnostics.log("talk: chat completion timeout runId=\(runId)") - } else if completion == .aborted { - self.statusText = "Aborted" - self.logger.warning("chat completion aborted runId=\(runId, privacy: .public)") - GatewayDiagnostics.log("talk: chat completion aborted runId=\(runId)") - streamingTask?.cancel() - await self.finishIncrementalSpeech() - await self.start() - return - } else if completion == .error { - self.statusText = "Chat error" - self.logger.warning("chat completion error runId=\(runId, privacy: .public)") - GatewayDiagnostics.log("talk: chat completion error runId=\(runId)") - streamingTask?.cancel() - await self.finishIncrementalSpeech() - await self.start() - return - } - - var assistantText = try await self.waitForAssistantText( - gateway: gateway, - since: startedAt, - timeoutSeconds: completion == .final ? 12 : 25) - if assistantText == nil, shouldIncremental { - let fallback = self.incrementalSpeechBuffer.latestText - if !fallback.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - assistantText = fallback - } - } - guard let assistantText else { - self.statusText = "No reply" - self.logger.warning("assistant text timeout runId=\(runId, privacy: .public)") - GatewayDiagnostics.log("talk: assistant text timeout runId=\(runId)") - streamingTask?.cancel() - await self.finishIncrementalSpeech() - await self.start() - return - } - self.logger.info("assistant text ok chars=\(assistantText.count, privacy: .public)") - GatewayDiagnostics.log("talk: assistant text ok chars=\(assistantText.count)") - streamingTask?.cancel() - if shouldIncremental { - await self.handleIncrementalAssistantFinal(text: assistantText) - } else { - await self.playAssistant(text: assistantText) - } - } catch { - self.statusText = "Talk failed: \(error.localizedDescription)" - self.logger.error("finalize failed: \(error.localizedDescription, privacy: .public)") - GatewayDiagnostics.log("talk: failed error=\(error.localizedDescription)") - } - - if restartAfter { - await self.start() - } - } - - private func subscribeChatIfNeeded(sessionKey: String) async { - let key = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) - guard !key.isEmpty else { return } - guard !self.chatSubscribedSessionKeys.contains(key) else { return } - - // Operator clients receive chat events without node-style subscriptions. - self.chatSubscribedSessionKeys.insert(key) - } - - private func unsubscribeAllChats() async { - self.chatSubscribedSessionKeys.removeAll() - } - - private func buildPrompt(transcript: String) -> String { - let interrupted = self.lastInterruptedAtSeconds - self.lastInterruptedAtSeconds = nil - let includeVoiceDirectiveHint = (UserDefaults.standard.object(forKey: "talk.voiceDirectiveHint.enabled") as? Bool) ?? true - return TalkPromptBuilder.build( - transcript: transcript, - interruptedAtSeconds: interrupted, - includeVoiceDirectiveHint: includeVoiceDirectiveHint) - } - - private enum ChatCompletionState: CustomStringConvertible { - case final - case aborted - case error - case timeout - - var description: String { - switch self { - case .final: "final" - case .aborted: "aborted" - case .error: "error" - case .timeout: "timeout" - } - } - } - - private func sendChat(_ message: String, gateway: GatewayNodeSession) async throws -> String { - struct SendResponse: Decodable { let runId: String } - let payload: [String: Any] = [ - "sessionKey": self.mainSessionKey, - "message": message, - "thinking": "low", - "timeoutMs": 30000, - "idempotencyKey": UUID().uuidString, - ] - let data = try JSONSerialization.data(withJSONObject: payload) - guard let json = String(bytes: data, encoding: .utf8) else { - throw NSError( - domain: "TalkModeManager", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Failed to encode chat payload"]) - } - let res = try await gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 30) - let decoded = try JSONDecoder().decode(SendResponse.self, from: res) - return decoded.runId - } - - private func waitForChatCompletion( - runId: String, - gateway: GatewayNodeSession, - timeoutSeconds: Int = 120) async -> ChatCompletionState - { - let stream = await gateway.subscribeServerEvents(bufferingNewest: 200) - return await withTaskGroup(of: ChatCompletionState.self) { group in - group.addTask { [runId] in - for await evt in stream { - if Task.isCancelled { return .timeout } - guard evt.event == "chat", let payload = evt.payload else { continue } - guard let chatEvent = try? GatewayPayloadDecoding.decode(payload, as: ChatEvent.self) else { - continue - } - guard chatEvent.runid == runId else { continue } - if let state = chatEvent.state.value as? String { - switch state { - case "final": return .final - case "aborted": return .aborted - case "error": return .error - default: break - } - } - } - return .timeout - } - group.addTask { - try? await Task.sleep(nanoseconds: UInt64(timeoutSeconds) * 1_000_000_000) - return .timeout - } - let result = await group.next() ?? .timeout - group.cancelAll() - return result - } - } - - private func waitForAssistantText( - gateway: GatewayNodeSession, - since: Double, - timeoutSeconds: Int) async throws -> String? - { - let deadline = Date().addingTimeInterval(TimeInterval(timeoutSeconds)) - while Date() < deadline { - if let text = try await self.fetchLatestAssistantText(gateway: gateway, since: since) { - return text - } - try? await Task.sleep(nanoseconds: 300_000_000) - } - return nil - } - - private func fetchLatestAssistantText(gateway: GatewayNodeSession, since: Double? = nil) async throws -> String? { - let res = try await gateway.request( - method: "chat.history", - paramsJSON: "{\"sessionKey\":\"\(self.mainSessionKey)\"}", - timeoutSeconds: 15) - guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return nil } - guard let messages = json["messages"] as? [[String: Any]] else { return nil } - for msg in messages.reversed() { - guard (msg["role"] as? String) == "assistant" else { continue } - if let since, let timestamp = msg["timestamp"] as? Double, - TalkHistoryTimestamp.isAfter(timestamp, sinceSeconds: since) == false - { - continue - } - guard let content = msg["content"] as? [[String: Any]] else { continue } - let text = content.compactMap { $0["text"] as? String }.joined(separator: "\n") - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { return trimmed } - } - return nil - } - - private func playAssistant(text: String) async { - let parsed = TalkDirectiveParser.parse(text) - let directive = parsed.directive - let cleaned = parsed.stripped.trimmingCharacters(in: .whitespacesAndNewlines) - guard !cleaned.isEmpty else { return } - self.applyDirective(directive) - - self.statusText = "Generating voice…" - self.isSpeaking = true - self.lastSpokenText = cleaned - - do { - let started = Date() - let language = ElevenLabsTTSClient.validatedLanguage(directive?.language) - let requestedVoice = directive?.voiceId?.trimmingCharacters(in: .whitespacesAndNewlines) - let resolvedVoice = self.resolveVoiceAlias(requestedVoice) - if requestedVoice?.isEmpty == false, resolvedVoice == nil { - self.logger.warning("unknown voice alias \(requestedVoice ?? "?", privacy: .public)") - } - - let resolvedKey = - (self.apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? self.apiKey : nil) ?? - ProcessInfo.processInfo.environment["ELEVENLABS_API_KEY"] - let apiKey = resolvedKey?.trimmingCharacters(in: .whitespacesAndNewlines) - let preferredVoice = resolvedVoice ?? self.currentVoiceId ?? self.defaultVoiceId - let voiceId: String? = if let apiKey, !apiKey.isEmpty { - await self.resolveVoiceId(preferred: preferredVoice, apiKey: apiKey) - } else { - nil - } - let canUseElevenLabs = (voiceId?.isEmpty == false) && (apiKey?.isEmpty == false) - - if canUseElevenLabs, let voiceId, let apiKey { - GatewayDiagnostics.log("talk tts: provider=elevenlabs voiceId=\(voiceId)") - let desiredOutputFormat = (directive?.outputFormat ?? self.defaultOutputFormat)? - .trimmingCharacters(in: .whitespacesAndNewlines) - let requestedOutputFormat = (desiredOutputFormat?.isEmpty == false) ? desiredOutputFormat : nil - let outputFormat = ElevenLabsTTSClient.validatedOutputFormat(requestedOutputFormat ?? "pcm_44100") - if outputFormat == nil, let requestedOutputFormat { - self.logger.warning( - "talk output_format unsupported for local playback: \(requestedOutputFormat, privacy: .public)") - } - - let modelId = directive?.modelId ?? self.currentModelId ?? self.defaultModelId - if let modelId { - GatewayDiagnostics.log("talk tts: modelId=\(modelId)") - } - func makeRequest(outputFormat: String?) -> ElevenLabsTTSRequest { - ElevenLabsTTSRequest( - text: cleaned, - modelId: modelId, - outputFormat: outputFormat, - speed: TalkTTSValidation.resolveSpeed(speed: directive?.speed, rateWPM: directive?.rateWPM), - stability: TalkTTSValidation.validatedStability(directive?.stability, modelId: modelId), - similarity: TalkTTSValidation.validatedUnit(directive?.similarity), - style: TalkTTSValidation.validatedUnit(directive?.style), - speakerBoost: directive?.speakerBoost, - seed: TalkTTSValidation.validatedSeed(directive?.seed), - normalize: ElevenLabsTTSClient.validatedNormalize(directive?.normalize), - language: language, - latencyTier: TalkTTSValidation.validatedLatencyTier(directive?.latencyTier)) - } - - let request = makeRequest(outputFormat: outputFormat) - - let client = ElevenLabsTTSClient(apiKey: apiKey) - let stream = client.streamSynthesize(voiceId: voiceId, request: request) - - if self.interruptOnSpeech { - do { - try self.startRecognition() - } catch { - self.logger.warning( - "startRecognition during speak failed: \(error.localizedDescription, privacy: .public)") - } - } - - self.statusText = "Speaking…" - let sampleRate = TalkTTSValidation.pcmSampleRate(from: outputFormat) - let result: StreamingPlaybackResult - if let sampleRate { - self.lastPlaybackWasPCM = true - var playback = await self.pcmPlayer.play(stream: stream, sampleRate: sampleRate) - if !playback.finished, playback.interruptedAt == nil { - let mp3Format = ElevenLabsTTSClient.validatedOutputFormat("mp3_44100") - self.logger.warning("pcm playback failed; retrying mp3") - self.lastPlaybackWasPCM = false - let mp3Stream = client.streamSynthesize( - voiceId: voiceId, - request: makeRequest(outputFormat: mp3Format)) - playback = await self.mp3Player.play(stream: mp3Stream) - } - result = playback - } else { - self.lastPlaybackWasPCM = false - result = await self.mp3Player.play(stream: stream) - } - let duration = Date().timeIntervalSince(started) - self.logger.info("elevenlabs stream finished=\(result.finished, privacy: .public) dur=\(duration, privacy: .public)s") - if !result.finished, let interruptedAt = result.interruptedAt { - self.lastInterruptedAtSeconds = interruptedAt - } - } else { - self.logger.warning("tts unavailable; falling back to system voice (missing key or voiceId)") - GatewayDiagnostics.log("talk tts: provider=system (missing key or voiceId)") - if self.interruptOnSpeech { - do { - try self.startRecognition() - } catch { - self.logger.warning( - "startRecognition during speak failed: \(error.localizedDescription, privacy: .public)") - } - } - self.statusText = "Speaking (System)…" - try await TalkSystemSpeechSynthesizer.shared.speak(text: cleaned, language: language) - } - } catch { - self.logger.error( - "tts failed: \(error.localizedDescription, privacy: .public); falling back to system voice") - GatewayDiagnostics.log("talk tts: provider=system (error) msg=\(error.localizedDescription)") - do { - if self.interruptOnSpeech { - do { - try self.startRecognition() - } catch { - self.logger.warning( - "startRecognition during speak failed: \(error.localizedDescription, privacy: .public)") - } - } - self.statusText = "Speaking (System)…" - let language = ElevenLabsTTSClient.validatedLanguage(directive?.language) - try await TalkSystemSpeechSynthesizer.shared.speak(text: cleaned, language: language) - } catch { - self.statusText = "Speak failed: \(error.localizedDescription)" - self.logger.error("system voice failed: \(error.localizedDescription, privacy: .public)") - } - } - - self.stopRecognition() - self.isSpeaking = false - } - - private func stopSpeaking(storeInterruption: Bool = true) { - let hasIncremental = self.incrementalSpeechActive || - self.incrementalSpeechTask != nil || - !self.incrementalSpeechQueue.isEmpty - if self.isSpeaking { - let interruptedAt = self.lastPlaybackWasPCM - ? self.pcmPlayer.stop() - : self.mp3Player.stop() - if storeInterruption { - self.lastInterruptedAtSeconds = interruptedAt - } - _ = self.lastPlaybackWasPCM - ? self.mp3Player.stop() - : self.pcmPlayer.stop() - } else if !hasIncremental { - return - } - TalkSystemSpeechSynthesizer.shared.stop() - self.cancelIncrementalSpeech() - self.isSpeaking = false - } - - private func shouldInterrupt(with transcript: String) -> Bool { - guard self.shouldAllowSpeechInterruptForCurrentRoute() else { return false } - let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.count >= 3 else { return false } - if let spoken = self.lastSpokenText?.lowercased(), spoken.contains(trimmed.lowercased()) { - return false - } - return true - } - - private func shouldAllowSpeechInterruptForCurrentRoute() -> Bool { - let route = AVAudioSession.sharedInstance().currentRoute - // Built-in speaker/receiver often feeds TTS back into STT, causing false interrupts. - // Allow barge-in for isolated outputs (headphones/Bluetooth/USB/CarPlay/AirPlay). - return !route.outputs.contains { output in - switch output.portType { - case .builtInSpeaker, .builtInReceiver: - return true - default: - return false - } - } - } - - private func shouldUseIncrementalTTS() -> Bool { - true - } - - private var isSpeechOutputActive: Bool { - self.isSpeaking || - self.incrementalSpeechActive || - self.incrementalSpeechTask != nil || - !self.incrementalSpeechQueue.isEmpty - } - - private func applyDirective(_ directive: TalkDirective?) { - let requestedVoice = directive?.voiceId?.trimmingCharacters(in: .whitespacesAndNewlines) - let resolvedVoice = self.resolveVoiceAlias(requestedVoice) - if requestedVoice?.isEmpty == false, resolvedVoice == nil { - self.logger.warning("unknown voice alias \(requestedVoice ?? "?", privacy: .public)") - } - if let voice = resolvedVoice { - if directive?.once != true { - self.currentVoiceId = voice - self.voiceOverrideActive = true - } - } - if let model = directive?.modelId { - if directive?.once != true { - self.currentModelId = model - self.modelOverrideActive = true - } - } - } - - private func resetIncrementalSpeech() { - self.incrementalSpeechQueue.removeAll() - self.incrementalSpeechTask?.cancel() - self.incrementalSpeechTask = nil - self.cancelIncrementalPrefetch() - self.incrementalSpeechActive = true - self.incrementalSpeechUsed = false - self.incrementalSpeechLanguage = nil - self.incrementalSpeechBuffer = IncrementalSpeechBuffer() - self.incrementalSpeechContext = nil - self.incrementalSpeechDirective = nil - } - - private func cancelIncrementalSpeech() { - self.incrementalSpeechQueue.removeAll() - self.incrementalSpeechTask?.cancel() - self.incrementalSpeechTask = nil - self.cancelIncrementalPrefetch() - self.incrementalSpeechActive = false - self.incrementalSpeechContext = nil - self.incrementalSpeechDirective = nil - } - - private func enqueueIncrementalSpeech(_ text: String) { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - self.incrementalSpeechQueue.append(trimmed) - self.incrementalSpeechUsed = true - if self.incrementalSpeechTask == nil { - self.startIncrementalSpeechTask() - } - } - - private func startIncrementalSpeechTask() { - if self.interruptOnSpeech { - do { - try self.startRecognition() - } catch { - self.logger.warning( - "startRecognition during incremental speak failed: \(error.localizedDescription, privacy: .public)") - } - } - - self.incrementalSpeechTask = Task { @MainActor [weak self] in - guard let self else { return } - defer { - self.cancelIncrementalPrefetch() - self.isSpeaking = false - self.stopRecognition() - self.incrementalSpeechTask = nil - } - while !Task.isCancelled { - guard !self.incrementalSpeechQueue.isEmpty else { break } - let segment = self.incrementalSpeechQueue.removeFirst() - self.statusText = "Speaking…" - self.isSpeaking = true - self.lastSpokenText = segment - await self.updateIncrementalContextIfNeeded() - let context = self.incrementalSpeechContext - let prefetchedAudio = await self.consumeIncrementalPrefetchedAudioIfAvailable( - for: segment, - context: context) - if let context { - self.startIncrementalPrefetchMonitor(context: context) - } - await self.speakIncrementalSegment( - segment, - context: context, - prefetchedAudio: prefetchedAudio) - self.cancelIncrementalPrefetchMonitor() - } - } - } - - private func cancelIncrementalPrefetch() { - self.cancelIncrementalPrefetchMonitor() - self.incrementalSpeechPrefetch?.task.cancel() - self.incrementalSpeechPrefetch = nil - } - - private func cancelIncrementalPrefetchMonitor() { - self.incrementalSpeechPrefetchMonitorTask?.cancel() - self.incrementalSpeechPrefetchMonitorTask = nil - } - - private func startIncrementalPrefetchMonitor(context: IncrementalSpeechContext) { - self.cancelIncrementalPrefetchMonitor() - self.incrementalSpeechPrefetchMonitorTask = Task { @MainActor [weak self] in - guard let self else { return } - while !Task.isCancelled { - if self.ensureIncrementalPrefetchForUpcomingSegment(context: context) { - return - } - try? await Task.sleep(nanoseconds: 40_000_000) - } - } - } - - private func ensureIncrementalPrefetchForUpcomingSegment(context: IncrementalSpeechContext) -> Bool { - guard context.canUseElevenLabs else { - self.cancelIncrementalPrefetch() - return false - } - guard let nextSegment = self.incrementalSpeechQueue.first else { return false } - if let existing = self.incrementalSpeechPrefetch { - if existing.segment == nextSegment, existing.context == context { - return true - } - existing.task.cancel() - self.incrementalSpeechPrefetch = nil - } - self.startIncrementalPrefetch(segment: nextSegment, context: context) - return self.incrementalSpeechPrefetch != nil - } - - private func startIncrementalPrefetch(segment: String, context: IncrementalSpeechContext) { - guard context.canUseElevenLabs, let apiKey = context.apiKey, let voiceId = context.voiceId else { return } - let prefetchOutputFormat = self.resolveIncrementalPrefetchOutputFormat(context: context) - let request = self.makeIncrementalTTSRequest( - text: segment, - context: context, - outputFormat: prefetchOutputFormat) - let id = UUID() - let task = Task { [weak self] in - let stream = ElevenLabsTTSClient(apiKey: apiKey).streamSynthesize(voiceId: voiceId, request: request) - var chunks: [Data] = [] - do { - for try await chunk in stream { - try Task.checkCancellation() - chunks.append(chunk) - } - await self?.completeIncrementalPrefetch(id: id, chunks: chunks) - } catch is CancellationError { - await self?.clearIncrementalPrefetch(id: id) - } catch { - await self?.failIncrementalPrefetch(id: id, error: error) - } - } - self.incrementalSpeechPrefetch = IncrementalSpeechPrefetchState( - id: id, - segment: segment, - context: context, - outputFormat: prefetchOutputFormat, - chunks: nil, - task: task) - } - - private func completeIncrementalPrefetch(id: UUID, chunks: [Data]) { - guard var prefetch = self.incrementalSpeechPrefetch, prefetch.id == id else { return } - prefetch.chunks = chunks - self.incrementalSpeechPrefetch = prefetch - } - - private func clearIncrementalPrefetch(id: UUID) { - guard let prefetch = self.incrementalSpeechPrefetch, prefetch.id == id else { return } - prefetch.task.cancel() - self.incrementalSpeechPrefetch = nil - } - - private func failIncrementalPrefetch(id: UUID, error: any Error) { - guard let prefetch = self.incrementalSpeechPrefetch, prefetch.id == id else { return } - self.logger.debug("incremental prefetch failed: \(error.localizedDescription, privacy: .public)") - prefetch.task.cancel() - self.incrementalSpeechPrefetch = nil - } - - private func consumeIncrementalPrefetchedAudioIfAvailable( - for segment: String, - context: IncrementalSpeechContext? - ) async -> IncrementalPrefetchedAudio? - { - guard let context else { - self.cancelIncrementalPrefetch() - return nil - } - guard let prefetch = self.incrementalSpeechPrefetch else { - return nil - } - guard prefetch.context == context else { - prefetch.task.cancel() - self.incrementalSpeechPrefetch = nil - return nil - } - guard prefetch.segment == segment else { - return nil - } - if let chunks = prefetch.chunks, !chunks.isEmpty { - let prefetched = IncrementalPrefetchedAudio(chunks: chunks, outputFormat: prefetch.outputFormat) - self.incrementalSpeechPrefetch = nil - return prefetched - } - await prefetch.task.value - guard let completed = self.incrementalSpeechPrefetch else { return nil } - guard completed.context == context, completed.segment == segment else { return nil } - guard let chunks = completed.chunks, !chunks.isEmpty else { return nil } - let prefetched = IncrementalPrefetchedAudio(chunks: chunks, outputFormat: completed.outputFormat) - self.incrementalSpeechPrefetch = nil - return prefetched - } - - private func resolveIncrementalPrefetchOutputFormat(context: IncrementalSpeechContext) -> String? { - if TalkTTSValidation.pcmSampleRate(from: context.outputFormat) != nil { - return ElevenLabsTTSClient.validatedOutputFormat("mp3_44100") - } - return context.outputFormat - } - - private func finishIncrementalSpeech() async { - guard self.incrementalSpeechActive else { return } - let leftover = self.incrementalSpeechBuffer.flush() - if let leftover { - self.enqueueIncrementalSpeech(leftover) - } - if let task = self.incrementalSpeechTask { - _ = await task.result - } - self.incrementalSpeechActive = false - } - - private func handleIncrementalAssistantFinal(text: String) async { - let parsed = TalkDirectiveParser.parse(text) - self.applyDirective(parsed.directive) - if let lang = parsed.directive?.language { - self.incrementalSpeechLanguage = ElevenLabsTTSClient.validatedLanguage(lang) - } - await self.updateIncrementalContextIfNeeded() - let segments = self.incrementalSpeechBuffer.ingest(text: text, isFinal: true) - for segment in segments { - self.enqueueIncrementalSpeech(segment) - } - await self.finishIncrementalSpeech() - if !self.incrementalSpeechUsed { - await self.playAssistant(text: text) - } - } - - private func streamAssistant(runId: String, gateway: GatewayNodeSession) async { - let stream = await gateway.subscribeServerEvents(bufferingNewest: 200) - for await evt in stream { - if Task.isCancelled { return } - guard evt.event == "agent", let payload = evt.payload else { continue } - guard let agentEvent = try? GatewayPayloadDecoding.decode(payload, as: OpenClawAgentEventPayload.self) else { - continue - } - guard agentEvent.runId == runId, agentEvent.stream == "assistant" else { continue } - guard let text = agentEvent.data["text"]?.value as? String else { continue } - let segments = self.incrementalSpeechBuffer.ingest(text: text, isFinal: false) - if let lang = self.incrementalSpeechBuffer.directive?.language { - self.incrementalSpeechLanguage = ElevenLabsTTSClient.validatedLanguage(lang) - } - await self.updateIncrementalContextIfNeeded() - for segment in segments { - self.enqueueIncrementalSpeech(segment) - } - } - } - - private func updateIncrementalContextIfNeeded() async { - let directive = self.incrementalSpeechBuffer.directive - if let existing = self.incrementalSpeechContext, directive == self.incrementalSpeechDirective { - if existing.language != self.incrementalSpeechLanguage { - self.incrementalSpeechContext = IncrementalSpeechContext( - apiKey: existing.apiKey, - voiceId: existing.voiceId, - modelId: existing.modelId, - outputFormat: existing.outputFormat, - language: self.incrementalSpeechLanguage, - directive: existing.directive, - canUseElevenLabs: existing.canUseElevenLabs) - } - return - } - let context = await self.buildIncrementalSpeechContext(directive: directive) - self.incrementalSpeechContext = context - self.incrementalSpeechDirective = directive - } - - private func buildIncrementalSpeechContext(directive: TalkDirective?) async -> IncrementalSpeechContext { - let requestedVoice = directive?.voiceId?.trimmingCharacters(in: .whitespacesAndNewlines) - let resolvedVoice = self.resolveVoiceAlias(requestedVoice) - if requestedVoice?.isEmpty == false, resolvedVoice == nil { - self.logger.warning("unknown voice alias \(requestedVoice ?? "?", privacy: .public)") - } - let preferredVoice = resolvedVoice ?? self.currentVoiceId ?? self.defaultVoiceId - let modelId = directive?.modelId ?? self.currentModelId ?? self.defaultModelId - let desiredOutputFormat = (directive?.outputFormat ?? self.defaultOutputFormat)? - .trimmingCharacters(in: .whitespacesAndNewlines) - let requestedOutputFormat = (desiredOutputFormat?.isEmpty == false) ? desiredOutputFormat : nil - let outputFormat = ElevenLabsTTSClient.validatedOutputFormat(requestedOutputFormat ?? "pcm_44100") - if outputFormat == nil, let requestedOutputFormat { - self.logger.warning( - "talk output_format unsupported for local playback: \(requestedOutputFormat, privacy: .public)") - } - - let resolvedKey = - (self.apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? self.apiKey : nil) ?? - ProcessInfo.processInfo.environment["ELEVENLABS_API_KEY"] - let apiKey = resolvedKey?.trimmingCharacters(in: .whitespacesAndNewlines) - let voiceId: String? = if let apiKey, !apiKey.isEmpty { - await self.resolveVoiceId(preferred: preferredVoice, apiKey: apiKey) - } else { - nil - } - let canUseElevenLabs = (voiceId?.isEmpty == false) && (apiKey?.isEmpty == false) - return IncrementalSpeechContext( - apiKey: apiKey, - voiceId: voiceId, - modelId: modelId, - outputFormat: outputFormat, - language: self.incrementalSpeechLanguage, - directive: directive, - canUseElevenLabs: canUseElevenLabs) - } - - private func makeIncrementalTTSRequest( - text: String, - context: IncrementalSpeechContext, - outputFormat: String? - ) -> ElevenLabsTTSRequest - { - ElevenLabsTTSRequest( - text: text, - modelId: context.modelId, - outputFormat: outputFormat, - speed: TalkTTSValidation.resolveSpeed( - speed: context.directive?.speed, - rateWPM: context.directive?.rateWPM), - stability: TalkTTSValidation.validatedStability( - context.directive?.stability, - modelId: context.modelId), - similarity: TalkTTSValidation.validatedUnit(context.directive?.similarity), - style: TalkTTSValidation.validatedUnit(context.directive?.style), - speakerBoost: context.directive?.speakerBoost, - seed: TalkTTSValidation.validatedSeed(context.directive?.seed), - normalize: ElevenLabsTTSClient.validatedNormalize(context.directive?.normalize), - language: context.language, - latencyTier: TalkTTSValidation.validatedLatencyTier(context.directive?.latencyTier)) - } - - private static func makeBufferedAudioStream(chunks: [Data]) -> AsyncThrowingStream { - AsyncThrowingStream { continuation in - for chunk in chunks { - continuation.yield(chunk) - } - continuation.finish() - } - } - - private func speakIncrementalSegment( - _ text: String, - context preferredContext: IncrementalSpeechContext? = nil, - prefetchedAudio: IncrementalPrefetchedAudio? = nil - ) async - { - let context: IncrementalSpeechContext - if let preferredContext { - context = preferredContext - } else { - await self.updateIncrementalContextIfNeeded() - guard let resolvedContext = self.incrementalSpeechContext else { - try? await TalkSystemSpeechSynthesizer.shared.speak( - text: text, - language: self.incrementalSpeechLanguage) - return - } - context = resolvedContext - } - - guard context.canUseElevenLabs, let apiKey = context.apiKey, let voiceId = context.voiceId else { - try? await TalkSystemSpeechSynthesizer.shared.speak( - text: text, - language: self.incrementalSpeechLanguage) - return - } - - let client = ElevenLabsTTSClient(apiKey: apiKey) - let request = self.makeIncrementalTTSRequest( - text: text, - context: context, - outputFormat: context.outputFormat) - let stream: AsyncThrowingStream - if let prefetchedAudio, !prefetchedAudio.chunks.isEmpty { - stream = Self.makeBufferedAudioStream(chunks: prefetchedAudio.chunks) - } else { - stream = client.streamSynthesize(voiceId: voiceId, request: request) - } - let playbackFormat = prefetchedAudio?.outputFormat ?? context.outputFormat - let sampleRate = TalkTTSValidation.pcmSampleRate(from: playbackFormat) - let result: StreamingPlaybackResult - if let sampleRate { - self.lastPlaybackWasPCM = true - var playback = await self.pcmPlayer.play(stream: stream, sampleRate: sampleRate) - if !playback.finished, playback.interruptedAt == nil { - self.logger.warning("pcm playback failed; retrying mp3") - self.lastPlaybackWasPCM = false - let mp3Format = ElevenLabsTTSClient.validatedOutputFormat("mp3_44100") - let mp3Stream = client.streamSynthesize( - voiceId: voiceId, - request: self.makeIncrementalTTSRequest( - text: text, - context: context, - outputFormat: mp3Format)) - playback = await self.mp3Player.play(stream: mp3Stream) - } - result = playback - } else { - self.lastPlaybackWasPCM = false - result = await self.mp3Player.play(stream: stream) - } - if !result.finished, let interruptedAt = result.interruptedAt { - self.lastInterruptedAtSeconds = interruptedAt - } - } - -} - -private struct IncrementalSpeechBuffer { - private(set) var latestText: String = "" - private(set) var directive: TalkDirective? - private var spokenOffset: Int = 0 - private var inCodeBlock = false - private var directiveParsed = false - - mutating func ingest(text: String, isFinal: Bool) -> [String] { - let normalized = text.replacingOccurrences(of: "\r\n", with: "\n") - guard let usable = self.stripDirectiveIfReady(from: normalized) else { return [] } - self.updateText(usable) - return self.extractSegments(isFinal: isFinal) - } - - mutating func flush() -> String? { - guard !self.latestText.isEmpty else { return nil } - let segments = self.extractSegments(isFinal: true) - return segments.first - } - - private mutating func stripDirectiveIfReady(from text: String) -> String? { - guard !self.directiveParsed else { return text } - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - if trimmed.hasPrefix("{") { - guard let newlineRange = text.range(of: "\n") else { return nil } - let firstLine = text[.. commonPrefix { - self.spokenOffset = commonPrefix - } - } - if self.spokenOffset > self.latestText.count { - self.spokenOffset = self.latestText.count - } - } - - private static func commonPrefixCount(_ lhs: String, _ rhs: String) -> Int { - let left = Array(lhs) - let right = Array(rhs) - let limit = min(left.count, right.count) - var idx = 0 - while idx < limit, left[idx] == right[idx] { - idx += 1 - } - return idx - } - - private mutating func extractSegments(isFinal: Bool) -> [String] { - let chars = Array(self.latestText) - guard self.spokenOffset < chars.count else { return [] } - var idx = self.spokenOffset - var lastBoundary: Int? - var inCodeBlock = self.inCodeBlock - var buffer = "" - var bufferAtBoundary = "" - var inCodeBlockAtBoundary = inCodeBlock - - while idx < chars.count { - if idx + 2 < chars.count, - chars[idx] == "`", - chars[idx + 1] == "`", - chars[idx + 2] == "`" - { - inCodeBlock.toggle() - idx += 3 - continue - } - - if !inCodeBlock { - buffer.append(chars[idx]) - if Self.isBoundary(chars[idx]) { - lastBoundary = idx + 1 - bufferAtBoundary = buffer - inCodeBlockAtBoundary = inCodeBlock - } - } - - idx += 1 - } - - if let boundary = lastBoundary { - self.spokenOffset = boundary - self.inCodeBlock = inCodeBlockAtBoundary - let trimmed = bufferAtBoundary.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? [] : [trimmed] - } - - guard isFinal else { return [] } - self.spokenOffset = chars.count - self.inCodeBlock = inCodeBlock - let trimmed = buffer.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? [] : [trimmed] - } - - private static func isBoundary(_ ch: Character) -> Bool { - ch == "." || ch == "!" || ch == "?" || ch == "\n" - } -} - -extension TalkModeManager { - nonisolated static func requestMicrophonePermission() async -> Bool { - 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) - } - } - } - - nonisolated static func requestSpeechPermission() async -> Bool { - 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: "TalkMode", 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 - } - } - - 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" - } - } - - 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" - } - } -} - -extension TalkModeManager { - func resolveVoiceAlias(_ value: String?) -> String? { - let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - let normalized = trimmed.lowercased() - if let mapped = self.voiceAliases[normalized] { return mapped } - if self.voiceAliases.values.contains(where: { $0.caseInsensitiveCompare(trimmed) == .orderedSame }) { - return trimmed - } - return Self.isLikelyVoiceId(trimmed) ? trimmed : nil - } - - func resolveVoiceId(preferred: String?, apiKey: String) async -> String? { - let trimmed = preferred?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !trimmed.isEmpty { - // Config / directives can provide a raw ElevenLabs voiceId (not an alias). - // Accept it directly to avoid unnecessary listVoices calls (and accidental fallback selection). - if Self.isLikelyVoiceId(trimmed) { - return trimmed - } - if let resolved = self.resolveVoiceAlias(trimmed) { return resolved } - self.logger.warning("unknown voice alias \(trimmed, privacy: .public)") - } - if let fallbackVoiceId { return fallbackVoiceId } - - do { - let voices = try await ElevenLabsTTSClient(apiKey: apiKey).listVoices() - guard let first = voices.first else { - self.logger.warning("elevenlabs voices list empty") - return nil - } - self.fallbackVoiceId = first.voiceId - if self.defaultVoiceId == nil { - self.defaultVoiceId = first.voiceId - } - if !self.voiceOverrideActive { - self.currentVoiceId = first.voiceId - } - let name = first.name ?? "unknown" - self.logger - .info("default voice selected \(name, privacy: .public) (\(first.voiceId, privacy: .public))") - return first.voiceId - } catch { - self.logger.error("elevenlabs list voices failed: \(error.localizedDescription, privacy: .public)") - return nil - } - } - - static func isLikelyVoiceId(_ value: String) -> Bool { - guard value.count >= 10 else { return false } - return value.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" } - } - - private static func normalizedTalkApiKey(_ raw: String?) -> String? { - let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - guard trimmed != Self.redactedConfigSentinel else { return nil } - // Config values may be env placeholders (for example `${ELEVENLABS_API_KEY}`). - if trimmed.hasPrefix("${"), trimmed.hasSuffix("}") { return nil } - return trimmed - } - - func reloadConfig() async { - guard let gateway else { return } - do { - let res = try await gateway.request(method: "talk.config", paramsJSON: "{\"includeSecrets\":true}", timeoutSeconds: 8) - guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return } - guard let config = json["config"] as? [String: Any] else { return } - let talk = config["talk"] as? [String: Any] - self.defaultVoiceId = (talk?["voiceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - if let aliases = talk?["voiceAliases"] as? [String: Any] { - var resolved: [String: String] = [:] - for (key, value) in aliases { - guard let id = value as? String else { continue } - let normalizedKey = key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let trimmedId = id.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalizedKey.isEmpty, !trimmedId.isEmpty else { continue } - resolved[normalizedKey] = trimmedId - } - self.voiceAliases = resolved - } else { - self.voiceAliases = [:] - } - if !self.voiceOverrideActive { - self.currentVoiceId = self.defaultVoiceId - } - let model = (talk?["modelId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - self.defaultModelId = (model?.isEmpty == false) ? model : Self.defaultModelIdFallback - if !self.modelOverrideActive { - self.currentModelId = self.defaultModelId - } - self.defaultOutputFormat = (talk?["outputFormat"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines) - let rawConfigApiKey = (talk?["apiKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - let configApiKey = Self.normalizedTalkApiKey(rawConfigApiKey) - let localApiKey = Self.normalizedTalkApiKey(GatewaySettingsStore.loadTalkElevenLabsApiKey()) - if rawConfigApiKey == Self.redactedConfigSentinel { - self.apiKey = (localApiKey?.isEmpty == false) ? localApiKey : nil - GatewayDiagnostics.log("talk config apiKey redacted; using local override if present") - } else { - self.apiKey = (localApiKey?.isEmpty == false) ? localApiKey : configApiKey - } - self.gatewayTalkDefaultVoiceId = self.defaultVoiceId - self.gatewayTalkDefaultModelId = self.defaultModelId - self.gatewayTalkApiKeyConfigured = (self.apiKey?.isEmpty == false) - self.gatewayTalkConfigLoaded = true - if let interrupt = talk?["interruptOnSpeech"] as? Bool { - self.interruptOnSpeech = interrupt - } - } catch { - self.defaultModelId = Self.defaultModelIdFallback - if !self.modelOverrideActive { - self.currentModelId = self.defaultModelId - } - self.gatewayTalkDefaultVoiceId = nil - self.gatewayTalkDefaultModelId = nil - self.gatewayTalkApiKeyConfigured = false - self.gatewayTalkConfigLoaded = false - } - } - - static func configureAudioSession() throws { - let session = AVAudioSession.sharedInstance() - // Prefer `.spokenAudio` for STT; it tends to preserve speech energy better than `.voiceChat`. - try session.setCategory(.playAndRecord, mode: .spokenAudio, options: [ - .allowBluetoothHFP, - .defaultToSpeaker, - ]) - try? session.setPreferredSampleRate(48_000) - try? session.setPreferredIOBufferDuration(0.02) - try session.setActive(true, options: []) - } - - private static func describeAudioSession() -> String { - let session = AVAudioSession.sharedInstance() - let inputs = session.currentRoute.inputs.map { "\($0.portType.rawValue):\($0.portName)" }.joined(separator: ",") - let outputs = session.currentRoute.outputs.map { "\($0.portType.rawValue):\($0.portName)" }.joined(separator: ",") - let available = session.availableInputs?.map { "\($0.portType.rawValue):\($0.portName)" }.joined(separator: ",") ?? "" - return "category=\(session.category.rawValue) mode=\(session.mode.rawValue) opts=\(session.categoryOptions.rawValue) inputAvail=\(session.isInputAvailable) routeIn=[\(inputs)] routeOut=[\(outputs)] availIn=[\(available)]" - } -} - -private final class AudioTapDiagnostics: @unchecked Sendable { - private let label: String - private let onLevel: (@Sendable (Float) -> Void)? - private let lock = NSLock() - private var bufferCount: Int = 0 - private var lastLoggedAt = Date.distantPast - private var lastLevelEmitAt = Date.distantPast - private var maxRmsWindow: Float = 0 - private var lastRms: Float = 0 - - init(label: String, onLevel: (@Sendable (Float) -> Void)? = nil) { - self.label = label - self.onLevel = onLevel - } - - func onBuffer(_ buffer: AVAudioPCMBuffer) { - var shouldLog = false - var shouldEmitLevel = false - var count = 0 - lock.lock() - bufferCount += 1 - count = bufferCount - let now = Date() - if now.timeIntervalSince(lastLoggedAt) >= 1.0 { - lastLoggedAt = now - shouldLog = true - } - if now.timeIntervalSince(lastLevelEmitAt) >= 0.12 { - lastLevelEmitAt = now - shouldEmitLevel = true - } - lock.unlock() - - let rate = buffer.format.sampleRate - let ch = buffer.format.channelCount - let frames = buffer.frameLength - - var rms: Float? - if let data = buffer.floatChannelData?.pointee { - let n = Int(frames) - if n > 0 { - var sum: Float = 0 - for i in 0.. maxRmsWindow { maxRmsWindow = resolvedRms } - let maxRms = maxRmsWindow - if shouldLog { maxRmsWindow = 0 } - lock.unlock() - - if shouldEmitLevel, let onLevel { - onLevel(resolvedRms) - } - - guard shouldLog else { return } - GatewayDiagnostics.log( - "\(label) mic: buffers=\(count) frames=\(frames) rate=\(Int(rate))Hz ch=\(ch) rms=\(String(format: "%.4f", resolvedRms)) max=\(String(format: "%.4f", maxRms))") - } -} - -#if DEBUG -extension TalkModeManager { - func _test_seedTranscript(_ transcript: String) { - self.lastTranscript = transcript - self.lastHeard = Date() - } - - func _test_handleTranscript(_ transcript: String, isFinal: Bool) async { - await self.handleTranscript(transcript: transcript, isFinal: isFinal) - } - - func _test_backdateLastHeard(seconds: TimeInterval) { - self.lastHeard = Date().addingTimeInterval(-seconds) - } - - func _test_runSilenceCheck() async { - await self.checkSilence() - } - - func _test_incrementalReset() { - self.incrementalSpeechBuffer = IncrementalSpeechBuffer() - } - - func _test_incrementalIngest(_ text: String, isFinal: Bool) -> [String] { - self.incrementalSpeechBuffer.ingest(text: text, isFinal: isFinal) - } -} -#endif - -private struct IncrementalSpeechContext: Equatable { - let apiKey: String? - let voiceId: String? - let modelId: String? - let outputFormat: String? - let language: String? - let directive: TalkDirective? - let canUseElevenLabs: Bool -} - -private struct IncrementalSpeechPrefetchState { - let id: UUID - let segment: String - let context: IncrementalSpeechContext - let outputFormat: String? - var chunks: [Data]? - let task: Task -} - -private struct IncrementalPrefetchedAudio { - let chunks: [Data] - let outputFormat: String? -} - -// swiftlint:enable type_body_length diff --git a/apps/ios/Sources/Voice/TalkOrbOverlay.swift b/apps/ios/Sources/Voice/TalkOrbOverlay.swift deleted file mode 100644 index f24cab5aedb..00000000000 --- a/apps/ios/Sources/Voice/TalkOrbOverlay.swift +++ /dev/null @@ -1,87 +0,0 @@ -import SwiftUI - -struct TalkOrbOverlay: View { - @Environment(NodeAppModel.self) private var appModel - @State private var pulse: Bool = false - - 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 { - Circle() - .stroke(seam.opacity(0.26), lineWidth: 2) - .frame(width: 320, height: 320) - .scaleEffect(self.pulse ? 1.15 : 0.96) - .opacity(self.pulse ? 0.0 : 1.0) - .animation(.easeOut(duration: 1.3).repeatForever(autoreverses: false), value: self.pulse) - - Circle() - .stroke(seam.opacity(0.18), lineWidth: 2) - .frame(width: 320, height: 320) - .scaleEffect(self.pulse ? 1.45 : 1.02) - .opacity(self.pulse ? 0.0 : 0.9) - .animation(.easeOut(duration: 1.9).repeatForever(autoreverses: false).delay(0.2), value: self.pulse) - - Circle() - .fill( - RadialGradient( - colors: [ - seam.opacity(0.75 + (0.20 * mic)), - seam.opacity(0.40), - Color.black.opacity(0.55), - ], - center: .center, - startRadius: 1, - endRadius: 112)) - .frame(width: 190, height: 190) - .scaleEffect(1.0 + (0.12 * mic)) - .overlay( - Circle() - .stroke(seam.opacity(0.35), lineWidth: 1)) - .shadow(color: seam.opacity(0.32), radius: 26, x: 0, y: 0) - .shadow(color: Color.black.opacity(0.50), radius: 22, x: 0, y: 10) - } - .contentShape(Circle()) - .onTapGesture { - 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)) - .foregroundStyle(Color.white.opacity(0.92)) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background( - Capsule() - .fill(Color.black.opacity(0.40)) - .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 { - self.pulse = true - } - .accessibilityElement(children: .combine) - .accessibilityLabel("Talk Mode \(status)") - } -} diff --git a/apps/ios/Sources/Voice/VoiceTab.swift b/apps/ios/Sources/Voice/VoiceTab.swift deleted file mode 100644 index 4fedd0ce9aa..00000000000 --- a/apps/ios/Sources/Voice/VoiceTab.swift +++ /dev/null @@ -1,46 +0,0 @@ -import SwiftUI - -struct VoiceTab: View { - @Environment(NodeAppModel.self) private var appModel - @Environment(VoiceWakeManager.self) private var voiceWake - @AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false - @AppStorage("talk.enabled") private var talkEnabled: Bool = false - - var body: some View { - NavigationStack { - List { - Section("Status") { - LabeledContent("Voice Wake", value: self.voiceWakeEnabled ? "Enabled" : "Disabled") - LabeledContent("Listener", value: self.voiceWake.isListening ? "Listening" : "Idle") - Text(self.voiceWake.statusText) - .font(.footnote) - .foregroundStyle(.secondary) - LabeledContent("Talk Mode", value: self.talkEnabled ? "Enabled" : "Disabled") - } - - Section("Notes") { - let triggers = self.voiceWake.activeTriggerWords - Group { - if triggers.isEmpty { - Text("Add wake words in Settings.") - } else if triggers.count == 1 { - Text("Say “\(triggers[0]) …” to trigger.") - } else if triggers.count == 2 { - Text("Say “\(triggers[0]) …” or “\(triggers[1]) …” to trigger.") - } else { - Text("Say “\(triggers.joined(separator: " …”, “")) …” to trigger.") - } - } - .foregroundStyle(.secondary) - } - } - .navigationTitle("Voice") - .onChange(of: self.voiceWakeEnabled) { _, newValue in - self.appModel.setVoiceWakeEnabled(newValue) - } - .onChange(of: self.talkEnabled) { _, newValue in - self.appModel.setTalkEnabled(newValue) - } - } - } -} diff --git a/apps/ios/Sources/Voice/VoiceWakeManager.swift b/apps/ios/Sources/Voice/VoiceWakeManager.swift deleted file mode 100644 index 15a993feaa0..00000000000 --- a/apps/ios/Sources/Voice/VoiceWakeManager.swift +++ /dev/null @@ -1,495 +0,0 @@ -import AVFAudio -import Foundation -import Observation -import OpenClawKit -import Speech -import SwabbleKit - -private func makeAudioTapEnqueueCallback(queue: AudioBufferQueue) -> @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void { - { buffer, _ in - // This callback is invoked on a realtime audio thread/queue. Keep it tiny and nonisolated. - queue.enqueueCopy(of: buffer) - } -} - -private final class AudioBufferQueue: @unchecked Sendable { - private let lock = NSLock() - private var buffers: [AVAudioPCMBuffer] = [] - - func enqueueCopy(of buffer: AVAudioPCMBuffer) { - guard let copy = buffer.deepCopy() else { return } - self.lock.lock() - self.buffers.append(copy) - self.lock.unlock() - } - - func drain() -> [AVAudioPCMBuffer] { - self.lock.lock() - let drained = self.buffers - self.buffers.removeAll(keepingCapacity: true) - self.lock.unlock() - return drained - } - - func clear() { - self.lock.lock() - self.buffers.removeAll(keepingCapacity: false) - self.lock.unlock() - } -} - -extension AVAudioPCMBuffer { - fileprivate func deepCopy() -> AVAudioPCMBuffer? { - let format = self.format - let frameLength = self.frameLength - guard let copy = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameLength) else { - return nil - } - copy.frameLength = frameLength - - if let src = self.floatChannelData, let dst = copy.floatChannelData { - let channels = Int(format.channelCount) - let frames = Int(frameLength) - for ch in 0..? - - private var lastDispatched: String? - private var onCommand: (@Sendable (String) async -> Void)? - private var userDefaultsObserver: NSObjectProtocol? - private var suppressedByTalk: Bool = false - - override init() { - super.init() - self.triggerWords = VoiceWakePreferences.loadTriggerWords() - self.userDefaultsObserver = NotificationCenter.default.addObserver( - forName: UserDefaults.didChangeNotification, - object: UserDefaults.standard, - queue: .main, - using: { [weak self] _ in - Task { @MainActor in - self?.handleUserDefaultsDidChange() - } - }) - } - - @MainActor deinit { - if let userDefaultsObserver = self.userDefaultsObserver { - NotificationCenter.default.removeObserver(userDefaultsObserver) - } - } - - var activeTriggerWords: [String] { - VoiceWakePreferences.sanitizeTriggerWords(self.triggerWords) - } - - private func handleUserDefaultsDidChange() { - let updated = VoiceWakePreferences.loadTriggerWords() - if updated != self.triggerWords { - self.triggerWords = updated - } - } - - func configure(onCommand: @escaping @Sendable (String) async -> Void) { - self.onCommand = onCommand - } - - func setEnabled(_ enabled: Bool) { - self.isEnabled = enabled - if enabled { - Task { await self.start() } - } else { - self.stop() - } - } - - 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 - { - // The iOS Simulator’s audio stack is unreliable for long-running microphone capture. - // (We’ve observed CoreAudio deadlocks after TCC permission prompts.) - self.isListening = false - self.statusText = "Voice Wake isn’t supported on Simulator" - return - } - - self.statusText = "Requesting permissions…" - - let micOk = await Self.requestMicrophonePermission() - guard micOk else { - self.statusText = Self.permissionMessage( - kind: "Microphone", - status: AVAudioSession.sharedInstance().recordPermission) - self.isListening = false - return - } - - let speechOk = await Self.requestSpeechPermission() - guard speechOk else { - self.statusText = Self.permissionMessage( - kind: "Speech recognition", - status: SFSpeechRecognizer.authorizationStatus()) - self.isListening = false - return - } - - self.speechRecognizer = SFSpeechRecognizer() - guard self.speechRecognizer != nil else { - self.statusText = "Speech recognizer unavailable" - self.isListening = false - return - } - - do { - try Self.configureAudioSession() - try self.startRecognition() - self.isListening = true - self.statusText = "Listening" - } catch { - self.isListening = false - self.statusText = "Start failed: \(error.localizedDescription)" - } - } - - func stop() { - self.isEnabled = false - self.isListening = false - self.statusText = "Off" - - self.tapDrainTask?.cancel() - self.tapDrainTask = nil - self.tapQueue?.clear() - self.tapQueue = nil - - self.recognitionTask?.cancel() - self.recognitionTask = nil - self.recognitionRequest = nil - - if self.audioEngine.isRunning { - self.audioEngine.stop() - self.audioEngine.inputNode.removeTap(onBus: 0) - } - - try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) - } - - /// Temporarily releases the microphone so other subsystems (e.g. camera video capture) can record audio. - /// Returns `true` when listening was active and was suspended. - func suspendForExternalAudioCapture() -> Bool { - guard self.isEnabled, self.isListening else { return false } - - self.isListening = false - self.statusText = "Paused" - - self.tapDrainTask?.cancel() - self.tapDrainTask = nil - self.tapQueue?.clear() - self.tapQueue = nil - - self.recognitionTask?.cancel() - self.recognitionTask = nil - self.recognitionRequest = nil - - if self.audioEngine.isRunning { - self.audioEngine.stop() - self.audioEngine.inputNode.removeTap(onBus: 0) - } - - try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) - return true - } - - func resumeAfterExternalAudioCapture(wasSuspended: Bool) { - guard wasSuspended else { return } - Task { await self.start() } - } - - private func startRecognition() throws { - self.recognitionTask?.cancel() - self.recognitionTask = nil - self.tapDrainTask?.cancel() - self.tapDrainTask = nil - self.tapQueue?.clear() - self.tapQueue = nil - - let request = SFSpeechAudioBufferRecognitionRequest() - request.shouldReportPartialResults = true - self.recognitionRequest = request - - let inputNode = self.audioEngine.inputNode - inputNode.removeTap(onBus: 0) - - let recordingFormat = inputNode.outputFormat(forBus: 0) - - let queue = AudioBufferQueue() - self.tapQueue = queue - let tapBlock: @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void = makeAudioTapEnqueueCallback(queue: queue) - inputNode.installTap( - onBus: 0, - bufferSize: 1024, - format: recordingFormat, - block: tapBlock) - - self.audioEngine.prepare() - try self.audioEngine.start() - - let handler = self.makeRecognitionResultHandler() - self.recognitionTask = self.speechRecognizer?.recognitionTask(with: request, resultHandler: handler) - - self.tapDrainTask = Task { [weak self] in - guard let self, let queue = self.tapQueue else { return } - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: 40_000_000) - let drained = queue.drain() - if drained.isEmpty { continue } - for buf in drained { - request.append(buf) - } - } - } - } - - private nonisolated func makeRecognitionResultHandler() -> @Sendable (SFSpeechRecognitionResult?, Error?) -> Void { - { [weak self] result, error in - let transcript = result?.bestTranscription.formattedString - let segments = result.flatMap { result in - transcript.map { WakeWordSpeechSegments.from(transcription: result.bestTranscription, transcript: $0) } - } ?? [] - let errorText = error?.localizedDescription - - Task { @MainActor in - self?.handleRecognitionCallback(transcript: transcript, segments: segments, errorText: errorText) - } - } - } - - private func handleRecognitionCallback(transcript: String?, segments: [WakeWordSegment], errorText: String?) { - if let errorText { - self.statusText = "Recognizer error: \(errorText)" - self.isListening = false - - let shouldRestart = self.isEnabled - if shouldRestart { - Task { - try? await Task.sleep(nanoseconds: 700_000_000) - await self.start() - } - } - return - } - - guard let transcript else { return } - guard let cmd = self.extractCommand(from: transcript, segments: segments) else { return } - - if cmd == self.lastDispatched { return } - self.lastDispatched = cmd - self.lastTriggeredCommand = cmd - self.statusText = "Triggered" - - Task { [weak self] in - guard let self else { return } - await self.onCommand?(cmd) - await self.startIfEnabled() - } - } - - private func startIfEnabled() async { - let shouldRestart = self.isEnabled - if shouldRestart { - await self.start() - } - } - - private func extractCommand(from transcript: String, segments: [WakeWordSegment]) -> String? { - Self.extractCommand(from: transcript, segments: segments, triggers: self.activeTriggerWords) - } - - nonisolated static func extractCommand( - from transcript: String, - segments: [WakeWordSegment], - triggers: [String], - minPostTriggerGap: TimeInterval = 0.45) -> String? - { - let config = WakeWordGateConfig(triggers: triggers, minPostTriggerGap: minPostTriggerGap) - return WakeWordGate.match(transcript: transcript, segments: segments, config: config)?.command - } - - private static func configureAudioSession() throws { - let session = AVAudioSession.sharedInstance() - try session.setCategory(.playAndRecord, mode: .measurement, options: [ - .duckOthers, - .mixWithOthers, - .allowBluetoothHFP, - .defaultToSpeaker, - ]) - try session.setActive(true, options: []) - } - - private nonisolated static func requestMicrophonePermission() async -> Bool { - 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 { - 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 -extension VoiceWakeManager { - func _test_handleRecognitionCallback(transcript: String?, segments: [WakeWordSegment], errorText: String?) { - self.handleRecognitionCallback(transcript: transcript, segments: segments, errorText: errorText) - } -} -#endif diff --git a/apps/ios/Sources/Voice/VoiceWakePreferences.swift b/apps/ios/Sources/Voice/VoiceWakePreferences.swift deleted file mode 100644 index 56762b515e2..00000000000 --- a/apps/ios/Sources/Voice/VoiceWakePreferences.swift +++ /dev/null @@ -1,44 +0,0 @@ -import Foundation - -enum VoiceWakePreferences { - static let enabledKey = "voiceWake.enabled" - static let triggerWordsKey = "voiceWake.triggerWords" - - // Keep defaults aligned with the mac app. - static let defaultTriggerWords: [String] = ["openclaw", "claude"] - static let maxWords = 32 - static let maxWordLength = 64 - - static func decodeGatewayTriggers(from payloadJSON: String) -> [String]? { - guard let data = payloadJSON.data(using: .utf8) else { return nil } - return self.decodeGatewayTriggers(from: data) - } - - static func decodeGatewayTriggers(from data: Data) -> [String]? { - struct Payload: Decodable { var triggers: [String] } - guard let decoded = try? JSONDecoder().decode(Payload.self, from: data) else { return nil } - return self.sanitizeTriggerWords(decoded.triggers) - } - - static func loadTriggerWords(defaults: UserDefaults = .standard) -> [String] { - defaults.stringArray(forKey: self.triggerWordsKey) ?? self.defaultTriggerWords - } - - static func saveTriggerWords(_ words: [String], defaults: UserDefaults = .standard) { - defaults.set(words, forKey: self.triggerWordsKey) - } - - static func sanitizeTriggerWords(_ words: [String]) -> [String] { - let cleaned = words - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - .prefix(Self.maxWords) - .map { String($0.prefix(Self.maxWordLength)) } - return cleaned.isEmpty ? Self.defaultTriggerWords : cleaned - } - - static func displayString(for words: [String]) -> String { - let sanitized = self.sanitizeTriggerWords(words) - return sanitized.joined(separator: ", ") - } -} diff --git a/apps/ios/SwiftSources.input.xcfilelist b/apps/ios/SwiftSources.input.xcfilelist deleted file mode 100644 index 5b1ba7d70e6..00000000000 --- a/apps/ios/SwiftSources.input.xcfilelist +++ /dev/null @@ -1,61 +0,0 @@ -Sources/Gateway/GatewayConnectionController.swift -Sources/Gateway/GatewayDiscoveryDebugLogView.swift -Sources/Gateway/GatewayDiscoveryModel.swift -Sources/Gateway/GatewaySettingsStore.swift -Sources/Gateway/KeychainStore.swift -Sources/Camera/CameraController.swift -Sources/Chat/ChatSheet.swift -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 -Sources/Screen/ScreenRecordService.swift -Sources/Screen/ScreenTab.swift -Sources/Screen/ScreenWebView.swift -Sources/SessionKey.swift -Sources/Settings/SettingsNetworkingHelpers.swift -Sources/Settings/SettingsTab.swift -Sources/Settings/VoiceWakeWordsSettingsView.swift -Sources/Status/StatusPill.swift -Sources/Status/VoiceWakeToast.swift -Sources/Voice/VoiceTab.swift -Sources/Voice/VoiceWakeManager.swift -Sources/Voice/VoiceWakePreferences.swift -../shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift -../shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift -../shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownPreprocessor.swift -../shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift -../shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift -../shared/OpenClawKit/Sources/OpenClawChatUI/ChatPayloadDecoding.swift -../shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessions.swift -../shared/OpenClawKit/Sources/OpenClawChatUI/ChatSheets.swift -../shared/OpenClawKit/Sources/OpenClawChatUI/ChatTheme.swift -../shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift -../shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift -../shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift -../shared/OpenClawKit/Sources/OpenClawKit/AnyCodable.swift -../shared/OpenClawKit/Sources/OpenClawKit/BonjourEscapes.swift -../shared/OpenClawKit/Sources/OpenClawKit/BonjourTypes.swift -../shared/OpenClawKit/Sources/OpenClawKit/BridgeFrames.swift -../shared/OpenClawKit/Sources/OpenClawKit/CameraCommands.swift -../shared/OpenClawKit/Sources/OpenClawKit/CanvasA2UIAction.swift -../shared/OpenClawKit/Sources/OpenClawKit/CanvasA2UICommands.swift -../shared/OpenClawKit/Sources/OpenClawKit/CanvasA2UIJSONL.swift -../shared/OpenClawKit/Sources/OpenClawKit/CanvasCommandParams.swift -../shared/OpenClawKit/Sources/OpenClawKit/CanvasCommands.swift -../shared/OpenClawKit/Sources/OpenClawKit/Capabilities.swift -../shared/OpenClawKit/Sources/OpenClawKit/OpenClawKitResources.swift -../shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift -../shared/OpenClawKit/Sources/OpenClawKit/JPEGTranscoder.swift -../shared/OpenClawKit/Sources/OpenClawKit/NodeError.swift -../shared/OpenClawKit/Sources/OpenClawKit/ScreenCommands.swift -../shared/OpenClawKit/Sources/OpenClawKit/StoragePaths.swift -../shared/OpenClawKit/Sources/OpenClawKit/SystemCommands.swift -../shared/OpenClawKit/Sources/OpenClawKit/TalkDirective.swift -../../Swabble/Sources/SwabbleKit/WakeWordGate.swift -Sources/Voice/TalkModeManager.swift -Sources/Voice/TalkOrbOverlay.swift diff --git a/apps/ios/Tests/AppCoverageTests.swift b/apps/ios/Tests/AppCoverageTests.swift deleted file mode 100644 index 33c71cccd05..00000000000 --- a/apps/ios/Tests/AppCoverageTests.swift +++ /dev/null @@ -1,31 +0,0 @@ -import SwiftUI -import Testing -@testable import OpenClaw - -@Suite struct AppCoverageTests { - @Test @MainActor func nodeAppModelUpdatesBackgroundedState() { - let appModel = NodeAppModel() - - appModel.setScenePhase(.background) - #expect(appModel.isBackgrounded == true) - - appModel.setScenePhase(.inactive) - #expect(appModel.isBackgrounded == false) - - appModel.setScenePhase(.active) - #expect(appModel.isBackgrounded == false) - } - - @Test @MainActor func voiceWakeStartReportsUnsupportedOnSimulator() async { - let voiceWake = VoiceWakeManager() - voiceWake.isEnabled = true - - await voiceWake.start() - - #expect(voiceWake.isListening == false) - #expect(voiceWake.statusText.contains("Simulator")) - - voiceWake.stop() - #expect(voiceWake.statusText == "Off") - } -} diff --git a/apps/ios/Tests/CameraControllerClampTests.swift b/apps/ios/Tests/CameraControllerClampTests.swift deleted file mode 100644 index 791010d11b0..00000000000 --- a/apps/ios/Tests/CameraControllerClampTests.swift +++ /dev/null @@ -1,24 +0,0 @@ -import Testing -@testable import OpenClaw - -@Suite struct CameraControllerClampTests { - @Test func clampQualityDefaultsAndBounds() { - #expect(CameraController.clampQuality(nil) == 0.9) - #expect(CameraController.clampQuality(0.0) == 0.05) - #expect(CameraController.clampQuality(0.049) == 0.05) - #expect(CameraController.clampQuality(0.05) == 0.05) - #expect(CameraController.clampQuality(0.5) == 0.5) - #expect(CameraController.clampQuality(1.0) == 1.0) - #expect(CameraController.clampQuality(1.1) == 1.0) - } - - @Test func clampDurationDefaultsAndBounds() { - #expect(CameraController.clampDurationMs(nil) == 3000) - #expect(CameraController.clampDurationMs(0) == 250) - #expect(CameraController.clampDurationMs(249) == 250) - #expect(CameraController.clampDurationMs(250) == 250) - #expect(CameraController.clampDurationMs(1000) == 1000) - #expect(CameraController.clampDurationMs(60000) == 60000) - #expect(CameraController.clampDurationMs(60001) == 60000) - } -} diff --git a/apps/ios/Tests/CameraControllerErrorTests.swift b/apps/ios/Tests/CameraControllerErrorTests.swift deleted file mode 100644 index 26cac6177da..00000000000 --- a/apps/ios/Tests/CameraControllerErrorTests.swift +++ /dev/null @@ -1,14 +0,0 @@ -import Testing -@testable import OpenClaw - -@Suite struct CameraControllerErrorTests { - @Test func errorDescriptionsAreStable() { - #expect(CameraController.CameraError.cameraUnavailable.errorDescription == "Camera unavailable") - #expect(CameraController.CameraError.microphoneUnavailable.errorDescription == "Microphone unavailable") - #expect(CameraController.CameraError.permissionDenied(kind: "Camera") - .errorDescription == "Camera permission denied") - #expect(CameraController.CameraError.invalidParams("bad").errorDescription == "bad") - #expect(CameraController.CameraError.captureFailed("nope").errorDescription == "nope") - #expect(CameraController.CameraError.exportFailed("export").errorDescription == "export") - } -} diff --git a/apps/ios/Tests/DeepLinkParserTests.swift b/apps/ios/Tests/DeepLinkParserTests.swift deleted file mode 100644 index 51ef9547a10..00000000000 --- a/apps/ios/Tests/DeepLinkParserTests.swift +++ /dev/null @@ -1,181 +0,0 @@ -import OpenClawKit -import Foundation -import Testing - -@Suite struct DeepLinkParserTests { - @Test func parseRejectsUnknownHost() { - let url = URL(string: "openclaw://nope?message=hi")! - #expect(DeepLinkParser.parse(url) == nil) - } - - @Test func parseHostIsCaseInsensitive() { - let url = URL(string: "openclaw://AGENT?message=Hello")! - #expect(DeepLinkParser.parse(url) == .agent(.init( - message: "Hello", - sessionKey: nil, - thinking: nil, - deliver: false, - to: nil, - channel: nil, - timeoutSeconds: nil, - key: nil))) - } - - @Test func parseRejectsNonOpenClawScheme() { - let url = URL(string: "https://example.com/agent?message=hi")! - #expect(DeepLinkParser.parse(url) == nil) - } - - @Test func parseRejectsEmptyMessage() { - let url = URL(string: "openclaw://agent?message=%20%20%0A")! - #expect(DeepLinkParser.parse(url) == nil) - } - - @Test func parseAgentLinkParsesCommonFields() { - let url = - URL(string: "openclaw://agent?message=Hello&deliver=1&sessionKey=node-test&thinking=low&timeoutSeconds=30")! - #expect( - DeepLinkParser.parse(url) == .agent( - .init( - message: "Hello", - sessionKey: "node-test", - thinking: "low", - deliver: true, - to: nil, - channel: nil, - timeoutSeconds: 30, - key: nil))) - } - - @Test func parseAgentLinkParsesTargetRoutingFields() { - let url = - URL( - string: "openclaw://agent?message=Hello%20World&deliver=1&to=%2B15551234567&channel=whatsapp&key=secret")! - #expect( - DeepLinkParser.parse(url) == .agent( - .init( - message: "Hello World", - sessionKey: nil, - thinking: nil, - deliver: true, - to: "+15551234567", - channel: "whatsapp", - timeoutSeconds: nil, - key: "secret"))) - } - - @Test func parseRejectsNegativeTimeoutSeconds() { - let url = URL(string: "openclaw://agent?message=Hello&timeoutSeconds=-1")! - #expect(DeepLinkParser.parse(url) == .agent(.init( - message: "Hello", - sessionKey: nil, - thinking: nil, - deliver: false, - to: nil, - channel: nil, - timeoutSeconds: nil, - key: nil))) - } - - @Test func parseGatewayLinkParsesCommonFields() { - let url = URL( - string: "openclaw://gateway?host=openclaw.local&port=18789&tls=1&token=abc&password=def")! - #expect( - DeepLinkParser.parse(url) == .gateway( - .init(host: "openclaw.local", port: 18789, tls: true, token: "abc", password: "def"))) - } - - @Test func parseGatewayLinkRejectsInsecureNonLoopbackWs() { - let url = URL( - string: "openclaw://gateway?host=attacker.example&port=18789&tls=0&token=abc")! - #expect(DeepLinkParser.parse(url) == nil) - } - - @Test func parseGatewayLinkRejectsInsecurePrefixBypassHost() { - let url = URL( - string: "openclaw://gateway?host=127.attacker.example&port=18789&tls=0&token=abc")! - #expect(DeepLinkParser.parse(url) == nil) - } - - @Test func parseGatewaySetupCodeParsesBase64UrlPayload() { - let payload = #"{"url":"wss://gateway.example.com:443","token":"tok","password":"pw"}"# - let encoded = Data(payload.utf8) - .base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - - let link = GatewayConnectDeepLink.fromSetupCode(encoded) - - #expect(link == .init( - host: "gateway.example.com", - port: 443, - tls: true, - token: "tok", - password: "pw")) - } - - @Test func parseGatewaySetupCodeRejectsInvalidInput() { - #expect(GatewayConnectDeepLink.fromSetupCode("not-a-valid-setup-code") == nil) - } - - @Test func parseGatewaySetupCodeDefaultsTo443ForWssWithoutPort() { - let payload = #"{"url":"wss://gateway.example.com","token":"tok"}"# - let encoded = Data(payload.utf8) - .base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - - let link = GatewayConnectDeepLink.fromSetupCode(encoded) - - #expect(link == .init( - host: "gateway.example.com", - port: 443, - tls: true, - token: "tok", - password: nil)) - } - - @Test func parseGatewaySetupCodeRejectsInsecureNonLoopbackWs() { - let payload = #"{"url":"ws://attacker.example:18789","token":"tok"}"# - let encoded = Data(payload.utf8) - .base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - - let link = GatewayConnectDeepLink.fromSetupCode(encoded) - #expect(link == nil) - } - - @Test func parseGatewaySetupCodeRejectsInsecurePrefixBypassHost() { - let payload = #"{"url":"ws://127.attacker.example:18789","token":"tok"}"# - let encoded = Data(payload.utf8) - .base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - - let link = GatewayConnectDeepLink.fromSetupCode(encoded) - #expect(link == nil) - } - - @Test func parseGatewaySetupCodeAllowsLoopbackWs() { - let payload = #"{"url":"ws://127.0.0.1:18789","token":"tok"}"# - let encoded = Data(payload.utf8) - .base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - - let link = GatewayConnectDeepLink.fromSetupCode(encoded) - - #expect(link == .init( - host: "127.0.0.1", - port: 18789, - tls: false, - token: "tok", - password: nil)) - } -} diff --git a/apps/ios/Tests/GatewayConnectionControllerTests.swift b/apps/ios/Tests/GatewayConnectionControllerTests.swift deleted file mode 100644 index 27e7aed7aea..00000000000 --- a/apps/ios/Tests/GatewayConnectionControllerTests.swift +++ /dev/null @@ -1,122 +0,0 @@ -import OpenClawKit -import Foundation -import Testing -import UIKit -@testable import OpenClaw - -private func withUserDefaults(_ updates: [String: Any?], _ body: () throws -> T) rethrows -> T { - let defaults = UserDefaults.standard - var snapshot: [String: Any?] = [:] - for key in updates.keys { - snapshot[key] = defaults.object(forKey: key) - } - for (key, value) in updates { - if let value { - defaults.set(value, forKey: key) - } else { - defaults.removeObject(forKey: key) - } - } - defer { - for (key, value) in snapshot { - if let value { - defaults.set(value, forKey: key) - } else { - defaults.removeObject(forKey: key) - } - } - } - return try body() -} - -@Suite(.serialized) struct GatewayConnectionControllerTests { - @Test @MainActor func resolvedDisplayNameSetsDefaultWhenMissing() { - let defaults = UserDefaults.standard - let displayKey = "node.displayName" - - withUserDefaults([displayKey: nil, "node.instanceId": "ios-test"]) { - let appModel = NodeAppModel() - let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) - - let resolved = controller._test_resolvedDisplayName(defaults: defaults) - #expect(!resolved.isEmpty) - #expect(defaults.string(forKey: displayKey) == resolved) - } - } - - @Test @MainActor func currentCapsReflectToggles() { - withUserDefaults([ - "node.instanceId": "ios-test", - "node.displayName": "Test Node", - "camera.enabled": true, - "location.enabledMode": OpenClawLocationMode.always.rawValue, - VoiceWakePreferences.enabledKey: true, - ]) { - let appModel = NodeAppModel() - let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) - let caps = Set(controller._test_currentCaps()) - - #expect(caps.contains(OpenClawCapability.canvas.rawValue)) - #expect(caps.contains(OpenClawCapability.screen.rawValue)) - #expect(caps.contains(OpenClawCapability.camera.rawValue)) - #expect(caps.contains(OpenClawCapability.location.rawValue)) - #expect(caps.contains(OpenClawCapability.voiceWake.rawValue)) - } - } - - @Test @MainActor func currentCommandsIncludeLocationWhenEnabled() { - withUserDefaults([ - "node.instanceId": "ios-test", - "location.enabledMode": OpenClawLocationMode.whileUsing.rawValue, - ]) { - let appModel = NodeAppModel() - let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) - let commands = Set(controller._test_currentCommands()) - - #expect(commands.contains(OpenClawLocationCommand.get.rawValue)) - } - } - @Test @MainActor func currentCommandsExcludeDangerousSystemExecCommands() { - withUserDefaults([ - "node.instanceId": "ios-test", - "camera.enabled": true, - "location.enabledMode": OpenClawLocationMode.whileUsing.rawValue, - ]) { - let appModel = NodeAppModel() - let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) - let commands = Set(controller._test_currentCommands()) - - // iOS should expose notify, but not host shell/exec-approval commands. - #expect(commands.contains(OpenClawSystemCommand.notify.rawValue)) - #expect(!commands.contains(OpenClawSystemCommand.run.rawValue)) - #expect(!commands.contains(OpenClawSystemCommand.which.rawValue)) - #expect(!commands.contains(OpenClawSystemCommand.execApprovalsGet.rawValue)) - #expect(!commands.contains(OpenClawSystemCommand.execApprovalsSet.rawValue)) - } - } - - @Test @MainActor func loadLastConnectionReadsSavedValues() { - withUserDefaults([:]) { - GatewaySettingsStore.saveLastGatewayConnectionManual( - host: "gateway.example.com", - port: 443, - useTLS: true, - stableID: "manual|gateway.example.com|443") - let loaded = GatewaySettingsStore.loadLastGatewayConnection() - #expect(loaded == .manual(host: "gateway.example.com", port: 443, useTLS: true, stableID: "manual|gateway.example.com|443")) - } - } - - @Test @MainActor func loadLastConnectionReturnsNilForInvalidData() { - withUserDefaults([ - "gateway.last.kind": "manual", - "gateway.last.host": "", - "gateway.last.port": 0, - "gateway.last.tls": false, - "gateway.last.stableID": "manual|invalid|0", - ]) { - let loaded = GatewaySettingsStore.loadLastGatewayConnection() - #expect(loaded == nil) - } - } -} diff --git a/apps/ios/Tests/GatewayConnectionIssueTests.swift b/apps/ios/Tests/GatewayConnectionIssueTests.swift deleted file mode 100644 index 8eb63f268ba..00000000000 --- a/apps/ios/Tests/GatewayConnectionIssueTests.swift +++ /dev/null @@ -1,33 +0,0 @@ -import Testing -@testable import OpenClaw - -@Suite(.serialized) struct GatewayConnectionIssueTests { - @Test func detectsTokenMissing() { - let issue = GatewayConnectionIssue.detect(from: "unauthorized: gateway token missing") - #expect(issue == .tokenMissing) - #expect(issue.needsAuthToken) - } - - @Test func detectsUnauthorized() { - let issue = GatewayConnectionIssue.detect(from: "Gateway error: unauthorized role") - #expect(issue == .unauthorized) - #expect(issue.needsAuthToken) - } - - @Test func detectsPairingWithRequestId() { - let issue = GatewayConnectionIssue.detect(from: "pairing required (requestId: abc123)") - #expect(issue == .pairingRequired(requestId: "abc123")) - #expect(issue.needsPairing) - #expect(issue.requestId == "abc123") - } - - @Test func detectsNetworkError() { - let issue = GatewayConnectionIssue.detect(from: "Gateway error: Connection refused") - #expect(issue == .network) - } - - @Test func returnsNoneForBenignStatus() { - let issue = GatewayConnectionIssue.detect(from: "Connected") - #expect(issue == .none) - } -} diff --git a/apps/ios/Tests/GatewayConnectionSecurityTests.swift b/apps/ios/Tests/GatewayConnectionSecurityTests.swift deleted file mode 100644 index b82ae716168..00000000000 --- a/apps/ios/Tests/GatewayConnectionSecurityTests.swift +++ /dev/null @@ -1,131 +0,0 @@ -import Foundation -import Network -import Testing -@testable import OpenClaw - -@Suite(.serialized) struct GatewayConnectionSecurityTests { - private func clearTLSFingerprint(stableID: String) { - let suite = UserDefaults(suiteName: "ai.openclaw.shared") ?? .standard - suite.removeObject(forKey: "gateway.tls.\(stableID)") - } - - @Test @MainActor func discoveredTLSParams_prefersStoredPinOverAdvertisedTXT() async { - let stableID = "test|\(UUID().uuidString)" - defer { clearTLSFingerprint(stableID: stableID) } - clearTLSFingerprint(stableID: stableID) - - GatewayTLSStore.saveFingerprint("11", stableID: stableID) - - let endpoint: NWEndpoint = .service(name: "Test", type: "_openclaw-gw._tcp", domain: "local.", interface: nil) - let gateway = GatewayDiscoveryModel.DiscoveredGateway( - name: "Test", - endpoint: endpoint, - stableID: stableID, - debugID: "debug", - lanHost: "evil.example.com", - tailnetDns: "evil.example.com", - gatewayPort: 12345, - canvasPort: nil, - tlsEnabled: true, - tlsFingerprintSha256: "22", - cliPath: nil) - - let appModel = NodeAppModel() - let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) - - let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: true) - #expect(params?.expectedFingerprint == "11") - #expect(params?.allowTOFU == false) - } - - @Test @MainActor func discoveredTLSParams_doesNotTrustAdvertisedFingerprint() async { - let stableID = "test|\(UUID().uuidString)" - defer { clearTLSFingerprint(stableID: stableID) } - clearTLSFingerprint(stableID: stableID) - - let endpoint: NWEndpoint = .service(name: "Test", type: "_openclaw-gw._tcp", domain: "local.", interface: nil) - let gateway = GatewayDiscoveryModel.DiscoveredGateway( - name: "Test", - endpoint: endpoint, - stableID: stableID, - debugID: "debug", - lanHost: nil, - tailnetDns: nil, - gatewayPort: nil, - canvasPort: nil, - tlsEnabled: true, - tlsFingerprintSha256: "22", - cliPath: nil) - - let appModel = NodeAppModel() - let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) - - let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: true) - #expect(params?.expectedFingerprint == nil) - #expect(params?.allowTOFU == false) - } - - @Test @MainActor func autoconnectRequiresStoredPinForDiscoveredGateways() async { - let stableID = "test|\(UUID().uuidString)" - defer { clearTLSFingerprint(stableID: stableID) } - clearTLSFingerprint(stableID: stableID) - - let defaults = UserDefaults.standard - defaults.set(true, forKey: "gateway.autoconnect") - defaults.set(false, forKey: "gateway.manual.enabled") - defaults.removeObject(forKey: "gateway.last.host") - defaults.removeObject(forKey: "gateway.last.port") - defaults.removeObject(forKey: "gateway.last.tls") - defaults.removeObject(forKey: "gateway.last.stableID") - defaults.removeObject(forKey: "gateway.last.kind") - defaults.removeObject(forKey: "gateway.preferredStableID") - defaults.set(stableID, forKey: "gateway.lastDiscoveredStableID") - - let endpoint: NWEndpoint = .service(name: "Test", type: "_openclaw-gw._tcp", domain: "local.", interface: nil) - let gateway = GatewayDiscoveryModel.DiscoveredGateway( - name: "Test", - endpoint: endpoint, - stableID: stableID, - debugID: "debug", - lanHost: "test.local", - tailnetDns: nil, - gatewayPort: 18789, - canvasPort: nil, - tlsEnabled: true, - tlsFingerprintSha256: nil, - cliPath: nil) - - let appModel = NodeAppModel() - let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) - controller._test_setGateways([gateway]) - controller._test_triggerAutoConnect() - - #expect(controller._test_didAutoConnect() == false) - } - - @Test @MainActor func manualConnectionsForceTLSForNonLoopbackHosts() async { - let appModel = NodeAppModel() - let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) - - #expect(controller._test_resolveManualUseTLS(host: "gateway.example.com", useTLS: false) == true) - #expect(controller._test_resolveManualUseTLS(host: "openclaw.local", useTLS: false) == true) - #expect(controller._test_resolveManualUseTLS(host: "127.attacker.example", useTLS: false) == true) - - #expect(controller._test_resolveManualUseTLS(host: "localhost", useTLS: false) == false) - #expect(controller._test_resolveManualUseTLS(host: "127.0.0.1", useTLS: false) == false) - #expect(controller._test_resolveManualUseTLS(host: "::1", useTLS: false) == false) - #expect(controller._test_resolveManualUseTLS(host: "[::1]", useTLS: false) == false) - #expect(controller._test_resolveManualUseTLS(host: "::ffff:127.0.0.1", useTLS: false) == false) - #expect(controller._test_resolveManualUseTLS(host: "0.0.0.0", useTLS: false) == false) - } - - @Test @MainActor func manualDefaultPortUses443OnlyForTailnetTLSHosts() async { - let appModel = NodeAppModel() - let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) - - #expect(controller._test_resolveManualPort(host: "gateway.example.com", port: 0, useTLS: true) == 18789) - #expect(controller._test_resolveManualPort(host: "device.sample.ts.net", port: 0, useTLS: true) == 443) - #expect(controller._test_resolveManualPort(host: "device.sample.ts.net.", port: 0, useTLS: true) == 443) - #expect(controller._test_resolveManualPort(host: "device.sample.ts.net", port: 18789, useTLS: true) == 18789) - } -} diff --git a/apps/ios/Tests/GatewayDiscoveryModelTests.swift b/apps/ios/Tests/GatewayDiscoveryModelTests.swift deleted file mode 100644 index 2f98948c962..00000000000 --- a/apps/ios/Tests/GatewayDiscoveryModelTests.swift +++ /dev/null @@ -1,22 +0,0 @@ -import Testing -@testable import OpenClaw - -@Suite(.serialized) struct GatewayDiscoveryModelTests { - @Test @MainActor func debugLoggingCapturesLifecycleAndResets() { - let model = GatewayDiscoveryModel() - - #expect(model.debugLog.isEmpty) - #expect(model.statusText == "Idle") - - model.setDebugLoggingEnabled(true) - #expect(model.debugLog.count >= 2) - - model.stop() - #expect(model.statusText == "Stopped") - #expect(model.gateways.isEmpty) - #expect(model.debugLog.count >= 3) - - model.setDebugLoggingEnabled(false) - #expect(model.debugLog.isEmpty) - } -} diff --git a/apps/ios/Tests/GatewayEndpointIDTests.swift b/apps/ios/Tests/GatewayEndpointIDTests.swift deleted file mode 100644 index e6edf2df237..00000000000 --- a/apps/ios/Tests/GatewayEndpointIDTests.swift +++ /dev/null @@ -1,33 +0,0 @@ -import OpenClawKit -import Network -import Testing -@testable import OpenClaw - -@Suite struct GatewayEndpointIDTests { - @Test func stableIDForServiceDecodesAndNormalizesName() { - let endpoint = NWEndpoint.service( - name: "OpenClaw\\032Gateway \\032 Node\n", - type: "_openclaw-gw._tcp", - domain: "local.", - interface: nil) - - #expect(GatewayEndpointID.stableID(endpoint) == "_openclaw-gw._tcp|local.|OpenClaw Gateway Node") - } - - @Test func stableIDForNonServiceUsesEndpointDescription() { - let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host("127.0.0.1"), port: 4242) - #expect(GatewayEndpointID.stableID(endpoint) == String(describing: endpoint)) - } - - @Test func prettyDescriptionDecodesBonjourEscapes() { - let endpoint = NWEndpoint.service( - name: "OpenClaw\\032Gateway", - type: "_openclaw-gw._tcp", - domain: "local.", - interface: nil) - - let pretty = GatewayEndpointID.prettyDescription(endpoint) - #expect(pretty == BonjourEscapes.decode(String(describing: endpoint))) - #expect(!pretty.localizedCaseInsensitiveContains("\\032")) - } -} diff --git a/apps/ios/Tests/GatewaySettingsStoreTests.swift b/apps/ios/Tests/GatewaySettingsStoreTests.swift deleted file mode 100644 index 7e67ab84a97..00000000000 --- a/apps/ios/Tests/GatewaySettingsStoreTests.swift +++ /dev/null @@ -1,199 +0,0 @@ -import Foundation -import Testing -@testable import OpenClaw - -private struct KeychainEntry: Hashable { - let service: String - let account: String -} - -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") - -private func snapshotDefaults(_ keys: [String]) -> [String: Any?] { - let defaults = UserDefaults.standard - var snapshot: [String: Any?] = [:] - for key in keys { - snapshot[key] = defaults.object(forKey: key) - } - return snapshot -} - -private func applyDefaults(_ values: [String: Any?]) { - let defaults = UserDefaults.standard - for (key, value) in values { - if let value { - defaults.set(value, forKey: key) - } else { - defaults.removeObject(forKey: key) - } - } -} - -private func restoreDefaults(_ snapshot: [String: Any?]) { - applyDefaults(snapshot) -} - -private func snapshotKeychain(_ entries: [KeychainEntry]) -> [KeychainEntry: String?] { - var snapshot: [KeychainEntry: String?] = [:] - for entry in entries { - snapshot[entry] = KeychainStore.loadString(service: entry.service, account: entry.account) - } - return snapshot -} - -private func applyKeychain(_ values: [KeychainEntry: String?]) { - for (entry, value) in values { - if let value { - _ = KeychainStore.saveString(value, service: entry.service, account: entry.account) - } else { - _ = KeychainStore.delete(service: entry.service, account: entry.account) - } - } -} - -private func restoreKeychain(_ snapshot: [KeychainEntry: String?]) { - applyKeychain(snapshot) -} - -@Suite(.serialized) struct GatewaySettingsStoreTests { - @Test func bootstrapCopiesDefaultsToKeychainWhenMissing() { - let defaultsKeys = [ - "node.instanceId", - "gateway.preferredStableID", - "gateway.lastDiscoveredStableID", - ] - let entries = [instanceIdEntry, preferredGatewayEntry, lastGatewayEntry] - let defaultsSnapshot = snapshotDefaults(defaultsKeys) - let keychainSnapshot = snapshotKeychain(entries) - defer { - restoreDefaults(defaultsSnapshot) - restoreKeychain(keychainSnapshot) - } - - applyDefaults([ - "node.instanceId": "node-test", - "gateway.preferredStableID": "preferred-test", - "gateway.lastDiscoveredStableID": "last-test", - ]) - applyKeychain([ - instanceIdEntry: nil, - preferredGatewayEntry: nil, - lastGatewayEntry: nil, - ]) - - GatewaySettingsStore.bootstrapPersistence() - - #expect(KeychainStore.loadString(service: nodeService, account: "instanceId") == "node-test") - #expect(KeychainStore.loadString(service: gatewayService, account: "preferredStableID") == "preferred-test") - #expect(KeychainStore.loadString(service: gatewayService, account: "lastDiscoveredStableID") == "last-test") - } - - @Test func bootstrapCopiesKeychainToDefaultsWhenMissing() { - let defaultsKeys = [ - "node.instanceId", - "gateway.preferredStableID", - "gateway.lastDiscoveredStableID", - ] - let entries = [instanceIdEntry, preferredGatewayEntry, lastGatewayEntry] - let defaultsSnapshot = snapshotDefaults(defaultsKeys) - let keychainSnapshot = snapshotKeychain(entries) - defer { - restoreDefaults(defaultsSnapshot) - restoreKeychain(keychainSnapshot) - } - - applyDefaults([ - "node.instanceId": nil, - "gateway.preferredStableID": nil, - "gateway.lastDiscoveredStableID": nil, - ]) - applyKeychain([ - instanceIdEntry: "node-from-keychain", - preferredGatewayEntry: "preferred-from-keychain", - lastGatewayEntry: "last-from-keychain", - ]) - - GatewaySettingsStore.bootstrapPersistence() - - let defaults = UserDefaults.standard - #expect(defaults.string(forKey: "node.instanceId") == "node-from-keychain") - #expect(defaults.string(forKey: "gateway.preferredStableID") == "preferred-from-keychain") - #expect(defaults.string(forKey: "gateway.lastDiscoveredStableID") == "last-from-keychain") - } - - @Test func lastGateway_manualRoundTrip() { - let keys = [ - "gateway.last.kind", - "gateway.last.host", - "gateway.last.port", - "gateway.last.tls", - "gateway.last.stableID", - ] - let snapshot = snapshotDefaults(keys) - defer { restoreDefaults(snapshot) } - - GatewaySettingsStore.saveLastGatewayConnectionManual( - host: "example.com", - port: 443, - useTLS: true, - stableID: "manual|example.com|443") - - let loaded = GatewaySettingsStore.loadLastGatewayConnection() - #expect(loaded == .manual(host: "example.com", port: 443, useTLS: true, stableID: "manual|example.com|443")) - } - - @Test func lastGateway_discoveredDoesNotPersistResolvedHostPort() { - let keys = [ - "gateway.last.kind", - "gateway.last.host", - "gateway.last.port", - "gateway.last.tls", - "gateway.last.stableID", - ] - let snapshot = snapshotDefaults(keys) - defer { restoreDefaults(snapshot) } - - // Simulate a prior manual record that included host/port. - applyDefaults([ - "gateway.last.host": "10.0.0.99", - "gateway.last.port": 18789, - "gateway.last.tls": true, - "gateway.last.stableID": "manual|10.0.0.99|18789", - "gateway.last.kind": "manual", - ]) - - GatewaySettingsStore.saveLastGatewayConnectionDiscovered(stableID: "gw|abc", useTLS: true) - - let defaults = UserDefaults.standard - #expect(defaults.object(forKey: "gateway.last.host") == nil) - #expect(defaults.object(forKey: "gateway.last.port") == nil) - #expect(GatewaySettingsStore.loadLastGatewayConnection() == .discovered(stableID: "gw|abc", useTLS: true)) - } - - @Test func lastGateway_backCompat_manualLoadsWhenKindMissing() { - let keys = [ - "gateway.last.kind", - "gateway.last.host", - "gateway.last.port", - "gateway.last.tls", - "gateway.last.stableID", - ] - let snapshot = snapshotDefaults(keys) - defer { restoreDefaults(snapshot) } - - applyDefaults([ - "gateway.last.kind": nil, - "gateway.last.host": "example.org", - "gateway.last.port": 18789, - "gateway.last.tls": false, - "gateway.last.stableID": "manual|example.org|18789", - ]) - - let loaded = GatewaySettingsStore.loadLastGatewayConnection() - #expect(loaded == .manual(host: "example.org", port: 18789, useTLS: false, stableID: "manual|example.org|18789")) - } -} diff --git a/apps/ios/Tests/IOSGatewayChatTransportTests.swift b/apps/ios/Tests/IOSGatewayChatTransportTests.swift deleted file mode 100644 index f49f242ff24..00000000000 --- a/apps/ios/Tests/IOSGatewayChatTransportTests.swift +++ /dev/null @@ -1,30 +0,0 @@ -import OpenClawKit -import Testing -@testable import OpenClaw - -@Suite struct IOSGatewayChatTransportTests { - @Test func requestsFailFastWhenGatewayNotConnected() async { - let gateway = GatewayNodeSession() - let transport = IOSGatewayChatTransport(gateway: gateway) - - do { - _ = try await transport.requestHistory(sessionKey: "node-test") - Issue.record("Expected requestHistory to throw when gateway not connected") - } catch {} - - do { - _ = try await transport.sendMessage( - sessionKey: "node-test", - message: "hello", - thinking: "low", - idempotencyKey: "idempotency", - attachments: []) - Issue.record("Expected sendMessage to throw when gateway not connected") - } catch {} - - do { - _ = try await transport.requestHealth(timeoutMs: 250) - Issue.record("Expected requestHealth to throw when gateway not connected") - } catch {} - } -} diff --git a/apps/ios/Tests/Info.plist b/apps/ios/Tests/Info.plist deleted file mode 100644 index 7fc8d827044..00000000000 --- a/apps/ios/Tests/Info.plist +++ /dev/null @@ -1,24 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - OpenClawTests - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - BNDL - CFBundleShortVersionString - 2026.2.21 - CFBundleVersion - 20260220 - - diff --git a/apps/ios/Tests/KeychainStoreTests.swift b/apps/ios/Tests/KeychainStoreTests.swift deleted file mode 100644 index 827be250ed7..00000000000 --- a/apps/ios/Tests/KeychainStoreTests.swift +++ /dev/null @@ -1,22 +0,0 @@ -import Foundation -import Testing -@testable import OpenClaw - -@Suite struct KeychainStoreTests { - @Test func saveLoadUpdateDeleteRoundTrip() { - let service = "bot.molt.tests.\(UUID().uuidString)" - let account = "value" - - #expect(KeychainStore.delete(service: service, account: account)) - #expect(KeychainStore.loadString(service: service, account: account) == nil) - - #expect(KeychainStore.saveString("first", service: service, account: account)) - #expect(KeychainStore.loadString(service: service, account: account) == "first") - - #expect(KeychainStore.saveString("second", service: service, account: account)) - #expect(KeychainStore.loadString(service: service, account: account) == "second") - - #expect(KeychainStore.delete(service: service, account: account)) - #expect(KeychainStore.loadString(service: service, account: account) == nil) - } -} diff --git a/apps/ios/Tests/NodeAppModelInvokeTests.swift b/apps/ios/Tests/NodeAppModelInvokeTests.swift deleted file mode 100644 index 3d015afae84..00000000000 --- a/apps/ios/Tests/NodeAppModelInvokeTests.swift +++ /dev/null @@ -1,351 +0,0 @@ -import OpenClawKit -import Foundation -import Testing -import UIKit -@testable import OpenClaw - -private func withUserDefaults(_ updates: [String: Any?], _ body: () throws -> T) rethrows -> T { - let defaults = UserDefaults.standard - var snapshot: [String: Any?] = [:] - for key in updates.keys { - snapshot[key] = defaults.object(forKey: key) - } - for (key, value) in updates { - if let value { - defaults.set(value, forKey: key) - } else { - defaults.removeObject(forKey: key) - } - } - defer { - for (key, value) in snapshot { - if let value { - defaults.set(value, forKey: key) - } else { - defaults.removeObject(forKey: key) - } - } - } - return try body() -} - -@MainActor -private final class MockWatchMessagingService: WatchMessagingServicing, @unchecked Sendable { - var currentStatus = WatchMessagingStatus( - supported: true, - paired: true, - appInstalled: true, - reachable: true, - activationState: "activated") - var nextSendResult = WatchNotificationSendResult( - deliveredImmediately: true, - queuedForDelivery: false, - transport: "sendMessage") - var sendError: Error? - var lastSent: (id: String, params: OpenClawWatchNotifyParams)? - private var replyHandler: (@Sendable (WatchQuickReplyEvent) -> Void)? - - func status() async -> WatchMessagingStatus { - self.currentStatus - } - - func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?) { - self.replyHandler = handler - } - - func sendNotification(id: String, params: OpenClawWatchNotifyParams) async throws -> WatchNotificationSendResult { - self.lastSent = (id: id, params: params) - if let sendError = self.sendError { - throw sendError - } - return self.nextSendResult - } - - func emitReply(_ event: WatchQuickReplyEvent) { - self.replyHandler?(event) - } -} - -@Suite(.serialized) struct NodeAppModelInvokeTests { - @Test @MainActor func decodeParamsFailsWithoutJSON() { - #expect(throws: Error.self) { - _ = try NodeAppModel._test_decodeParams(OpenClawCanvasNavigateParams.self, from: nil) - } - } - - @Test @MainActor func encodePayloadEmitsJSON() throws { - struct Payload: Codable, Equatable { - var value: String - } - let json = try NodeAppModel._test_encodePayload(Payload(value: "ok")) - #expect(json.contains("\"value\"")) - } - - @Test @MainActor func chatSessionKeyDefaultsToIOSBase() { - let appModel = NodeAppModel() - #expect(appModel.chatSessionKey == "ios") - } - - @Test @MainActor func chatSessionKeyUsesAgentScopedKeyForNonDefaultAgent() { - let appModel = NodeAppModel() - appModel.gatewayDefaultAgentId = "main" - appModel.setSelectedAgentId("agent-123") - #expect(appModel.chatSessionKey == SessionKey.makeAgentSessionKey(agentId: "agent-123", baseKey: "ios")) - #expect(appModel.mainSessionKey == "agent:agent-123:main") - } - - @Test @MainActor func handleInvokeRejectsBackgroundCommands() async { - let appModel = NodeAppModel() - appModel.setScenePhase(.background) - - let req = BridgeInvokeRequest(id: "bg", command: OpenClawCanvasCommand.present.rawValue) - let res = await appModel._test_handleInvoke(req) - #expect(res.ok == false) - #expect(res.error?.code == .backgroundUnavailable) - } - - @Test @MainActor func handleInvokeRejectsCameraWhenDisabled() async { - let appModel = NodeAppModel() - let req = BridgeInvokeRequest(id: "cam", command: OpenClawCameraCommand.snap.rawValue) - - let defaults = UserDefaults.standard - let key = "camera.enabled" - let previous = defaults.object(forKey: key) - defaults.set(false, forKey: key) - defer { - if let previous { - defaults.set(previous, forKey: key) - } else { - defaults.removeObject(forKey: key) - } - } - - let res = await appModel._test_handleInvoke(req) - #expect(res.ok == false) - #expect(res.error?.code == .unavailable) - #expect(res.error?.message.contains("CAMERA_DISABLED") == true) - } - - @Test @MainActor func handleInvokeRejectsInvalidScreenFormat() async { - let appModel = NodeAppModel() - let params = OpenClawScreenRecordParams(format: "gif") - let data = try? JSONEncoder().encode(params) - let json = data.flatMap { String(data: $0, encoding: .utf8) } - - let req = BridgeInvokeRequest( - id: "screen", - command: OpenClawScreenCommand.record.rawValue, - paramsJSON: json) - - let res = await appModel._test_handleInvoke(req) - #expect(res.ok == false) - #expect(res.error?.message.contains("screen format must be mp4") == true) - } - - @Test @MainActor func handleInvokeCanvasCommandsUpdateScreen() async throws { - let appModel = NodeAppModel() - appModel.screen.navigate(to: "http://example.com") - - let present = BridgeInvokeRequest(id: "present", command: OpenClawCanvasCommand.present.rawValue) - let presentRes = await appModel._test_handleInvoke(present) - #expect(presentRes.ok == true) - #expect(appModel.screen.urlString.isEmpty) - - // 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( - id: "nav", - command: OpenClawCanvasCommand.navigate.rawValue, - paramsJSON: navJSON) - let navRes = await appModel._test_handleInvoke(navigate) - #expect(navRes.ok == true) - #expect(appModel.screen.urlString == "http://example.com/") - - let evalParams = OpenClawCanvasEvalParams(javaScript: "1+1") - let evalData = try JSONEncoder().encode(evalParams) - let evalJSON = String(decoding: evalData, as: UTF8.self) - let eval = BridgeInvokeRequest( - id: "eval", - command: OpenClawCanvasCommand.evalJS.rawValue, - paramsJSON: evalJSON) - let evalRes = await appModel._test_handleInvoke(eval) - #expect(evalRes.ok == true) - let payloadData = try #require(evalRes.payloadJSON?.data(using: .utf8)) - let payload = try JSONSerialization.jsonObject(with: payloadData) as? [String: Any] - #expect(payload?["result"] as? String == "2") - } - - @Test @MainActor func handleInvokeA2UICommandsFailWhenHostMissing() async throws { - let appModel = NodeAppModel() - - let reset = BridgeInvokeRequest(id: "reset", command: OpenClawCanvasA2UICommand.reset.rawValue) - let resetRes = await appModel._test_handleInvoke(reset) - #expect(resetRes.ok == false) - #expect(resetRes.error?.message.contains("A2UI_HOST_NOT_CONFIGURED") == true) - - let jsonl = "{\"beginRendering\":{}}" - let pushParams = OpenClawCanvasA2UIPushJSONLParams(jsonl: jsonl) - let pushData = try JSONEncoder().encode(pushParams) - let pushJSON = String(decoding: pushData, as: UTF8.self) - let push = BridgeInvokeRequest( - id: "push", - command: OpenClawCanvasA2UICommand.pushJSONL.rawValue, - paramsJSON: pushJSON) - let pushRes = await appModel._test_handleInvoke(push) - #expect(pushRes.ok == false) - #expect(pushRes.error?.message.contains("A2UI_HOST_NOT_CONFIGURED") == true) - } - - @Test @MainActor func handleInvokeUnknownCommandReturnsInvalidRequest() async { - let appModel = NodeAppModel() - let req = BridgeInvokeRequest(id: "unknown", command: "nope") - let res = await appModel._test_handleInvoke(req) - #expect(res.ok == false) - #expect(res.error?.code == .invalidRequest) - } - - @Test @MainActor func handleInvokeWatchStatusReturnsServiceSnapshot() async throws { - let watchService = MockWatchMessagingService() - watchService.currentStatus = WatchMessagingStatus( - supported: true, - paired: true, - appInstalled: true, - reachable: false, - activationState: "inactive") - let appModel = NodeAppModel(watchMessagingService: watchService) - let req = BridgeInvokeRequest(id: "watch-status", command: OpenClawWatchCommand.status.rawValue) - - let res = await appModel._test_handleInvoke(req) - #expect(res.ok == true) - - let payloadData = try #require(res.payloadJSON?.data(using: .utf8)) - let payload = try JSONDecoder().decode(OpenClawWatchStatusPayload.self, from: payloadData) - #expect(payload.supported == true) - #expect(payload.reachable == false) - #expect(payload.activationState == "inactive") - } - - @Test @MainActor func handleInvokeWatchNotifyRoutesToWatchService() async throws { - let watchService = MockWatchMessagingService() - watchService.nextSendResult = WatchNotificationSendResult( - deliveredImmediately: false, - queuedForDelivery: true, - transport: "transferUserInfo") - let appModel = NodeAppModel(watchMessagingService: watchService) - let params = OpenClawWatchNotifyParams( - title: "OpenClaw", - body: "Meeting with Peter is at 4pm", - priority: .timeSensitive) - let paramsData = try JSONEncoder().encode(params) - let paramsJSON = String(decoding: paramsData, as: UTF8.self) - let req = BridgeInvokeRequest( - id: "watch-notify", - command: OpenClawWatchCommand.notify.rawValue, - paramsJSON: paramsJSON) - - let res = await appModel._test_handleInvoke(req) - #expect(res.ok == true) - #expect(watchService.lastSent?.params.title == "OpenClaw") - #expect(watchService.lastSent?.params.body == "Meeting with Peter is at 4pm") - #expect(watchService.lastSent?.params.priority == .timeSensitive) - - let payloadData = try #require(res.payloadJSON?.data(using: .utf8)) - let payload = try JSONDecoder().decode(OpenClawWatchNotifyPayload.self, from: payloadData) - #expect(payload.deliveredImmediately == false) - #expect(payload.queuedForDelivery == true) - #expect(payload.transport == "transferUserInfo") - } - - @Test @MainActor func handleInvokeWatchNotifyRejectsEmptyMessage() async throws { - let watchService = MockWatchMessagingService() - let appModel = NodeAppModel(watchMessagingService: watchService) - let params = OpenClawWatchNotifyParams(title: " ", body: "\n") - let paramsData = try JSONEncoder().encode(params) - let paramsJSON = String(decoding: paramsData, as: UTF8.self) - let req = BridgeInvokeRequest( - id: "watch-notify-empty", - command: OpenClawWatchCommand.notify.rawValue, - paramsJSON: paramsJSON) - - let res = await appModel._test_handleInvoke(req) - #expect(res.ok == false) - #expect(res.error?.code == .invalidRequest) - #expect(watchService.lastSent == nil) - } - - @Test @MainActor func handleInvokeWatchNotifyReturnsUnavailableOnDeliveryFailure() async throws { - let watchService = MockWatchMessagingService() - watchService.sendError = NSError( - domain: "watch", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "WATCH_UNAVAILABLE: no paired Apple Watch"]) - let appModel = NodeAppModel(watchMessagingService: watchService) - let params = OpenClawWatchNotifyParams(title: "OpenClaw", body: "Delivery check") - let paramsData = try JSONEncoder().encode(params) - let paramsJSON = String(decoding: paramsData, as: UTF8.self) - let req = BridgeInvokeRequest( - id: "watch-notify-fail", - command: OpenClawWatchCommand.notify.rawValue, - paramsJSON: paramsJSON) - - let res = await appModel._test_handleInvoke(req) - #expect(res.ok == false) - #expect(res.error?.code == .unavailable) - #expect(res.error?.message.contains("WATCH_UNAVAILABLE") == true) - } - - @Test @MainActor func watchReplyQueuesWhenGatewayOffline() async { - let watchService = MockWatchMessagingService() - let appModel = NodeAppModel(watchMessagingService: watchService) - watchService.emitReply( - WatchQuickReplyEvent( - replyId: "reply-offline-1", - promptId: "prompt-1", - actionId: "approve", - actionLabel: "Approve", - sessionKey: "ios", - note: nil, - sentAtMs: 1234, - transport: "transferUserInfo")) - #expect(appModel._test_queuedWatchReplyCount() == 1) - } - - @Test @MainActor func handleDeepLinkSetsErrorWhenNotConnected() async { - let appModel = NodeAppModel() - let url = URL(string: "openclaw://agent?message=hello")! - await appModel.handleDeepLink(url: url) - #expect(appModel.screen.errorText?.contains("Gateway not connected") == true) - } - - @Test @MainActor func handleDeepLinkRejectsOversizedMessage() async { - let appModel = NodeAppModel() - let msg = String(repeating: "a", count: 20001) - let url = URL(string: "openclaw://agent?message=\(msg)")! - await appModel.handleDeepLink(url: url) - #expect(appModel.screen.errorText?.contains("Deep link too large") == true) - } - - @Test @MainActor func sendVoiceTranscriptThrowsWhenGatewayOffline() async { - let appModel = NodeAppModel() - await #expect(throws: Error.self) { - try await appModel.sendVoiceTranscript(text: "hello", sessionKey: "main") - } - } - - @Test @MainActor func canvasA2UIActionDispatchesStatus() async { - let appModel = NodeAppModel() - let body: [String: Any] = [ - "userAction": [ - "name": "tap", - "id": "action-1", - "surfaceId": "main", - "sourceComponentId": "button-1", - "context": ["value": "ok"], - ], - ] - await appModel._test_handleCanvasA2UIAction(body: body) - #expect(appModel.screen.urlString.isEmpty) - } -} diff --git a/apps/ios/Tests/OnboardingStateStoreTests.swift b/apps/ios/Tests/OnboardingStateStoreTests.swift deleted file mode 100644 index 30c014647b6..00000000000 --- a/apps/ios/Tests/OnboardingStateStoreTests.swift +++ /dev/null @@ -1,57 +0,0 @@ -import Foundation -import Testing -@testable import OpenClaw - -@Suite(.serialized) struct OnboardingStateStoreTests { - @Test @MainActor func shouldPresentWhenFreshAndDisconnected() { - let testDefaults = self.makeDefaults() - let defaults = testDefaults.defaults - defer { self.reset(testDefaults) } - - let appModel = NodeAppModel() - appModel.gatewayServerName = nil - #expect(OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults)) - } - - @Test @MainActor func doesNotPresentWhenConnected() { - let testDefaults = self.makeDefaults() - let defaults = testDefaults.defaults - defer { self.reset(testDefaults) } - - let appModel = NodeAppModel() - appModel.gatewayServerName = "gateway" - #expect(!OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults)) - } - - @Test @MainActor func markCompletedPersistsMode() { - let testDefaults = self.makeDefaults() - let defaults = testDefaults.defaults - defer { self.reset(testDefaults) } - - let appModel = NodeAppModel() - appModel.gatewayServerName = nil - - OnboardingStateStore.markCompleted(mode: .remoteDomain, defaults: defaults) - #expect(OnboardingStateStore.lastMode(defaults: defaults) == .remoteDomain) - #expect(!OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults)) - - OnboardingStateStore.markIncomplete(defaults: defaults) - #expect(OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults)) - } - - private struct TestDefaults { - var suiteName: String - var defaults: UserDefaults - } - - private func makeDefaults() -> TestDefaults { - let suiteName = "OnboardingStateStoreTests.\(UUID().uuidString)" - return TestDefaults( - suiteName: suiteName, - defaults: UserDefaults(suiteName: suiteName) ?? .standard) - } - - private func reset(_ defaults: TestDefaults) { - defaults.defaults.removePersistentDomain(forName: defaults.suiteName) - } -} diff --git a/apps/ios/Tests/ScreenControllerTests.swift b/apps/ios/Tests/ScreenControllerTests.swift deleted file mode 100644 index d0e47c84fb3..00000000000 --- a/apps/ios/Tests/ScreenControllerTests.swift +++ /dev/null @@ -1,87 +0,0 @@ -import Testing -import WebKit -@testable import OpenClaw - -@MainActor -private func mountScreen(_ screen: ScreenController) throws -> (ScreenWebViewCoordinator, WKWebView) { - let coordinator = ScreenWebViewCoordinator(controller: screen) - _ = coordinator.makeContainerView() - let webView = try #require(coordinator.managedWebView) - return (coordinator, webView) -} - -@Suite struct ScreenControllerTests { - @Test @MainActor func canvasModeConfiguresWebViewForTouch() throws { - let screen = ScreenController() - let (coordinator, webView) = try mountScreen(screen) - defer { coordinator.teardown() } - - #expect(webView.isOpaque == true) - #expect(webView.backgroundColor == .black) - - let scrollView = webView.scrollView - #expect(scrollView.backgroundColor == .black) - #expect(scrollView.contentInsetAdjustmentBehavior == .never) - #expect(scrollView.isScrollEnabled == false) - #expect(scrollView.bounces == false) - } - - @Test @MainActor func navigateEnablesScrollForWebPages() throws { - let screen = ScreenController() - let (coordinator, webView) = try mountScreen(screen) - defer { coordinator.teardown() } - - screen.navigate(to: "https://example.com") - - let scrollView = webView.scrollView - #expect(scrollView.isScrollEnabled == true) - #expect(scrollView.bounces == true) - } - - @Test @MainActor func navigateSlashShowsDefaultCanvas() { - let screen = ScreenController() - screen.navigate(to: "/") - - #expect(screen.urlString.isEmpty) - } - - @Test @MainActor func evalExecutesJavaScript() async throws { - let screen = ScreenController() - let (coordinator, _) = try mountScreen(screen) - defer { coordinator.teardown() } - - let deadline = ContinuousClock().now.advanced(by: .seconds(3)) - - while true { - do { - let result = try await screen.eval(javaScript: "1+1") - #expect(result == "2") - return - } catch { - if ContinuousClock().now >= deadline { - throw error - } - try? await Task.sleep(nanoseconds: 100_000_000) - } - } - } - - @Test @MainActor func localNetworkCanvasURLsAreAllowed() { - let screen = ScreenController() - #expect(screen.isLocalNetworkCanvasURL(URL(string: "http://localhost:18789/")!) == true) - #expect(screen.isLocalNetworkCanvasURL(URL(string: "http://openclaw.local:18789/")!) == true) - #expect(screen.isLocalNetworkCanvasURL(URL(string: "http://peters-mac-studio-1:18789/")!) == true) - #expect(screen.isLocalNetworkCanvasURL(URL(string: "https://peters-mac-studio-1.ts.net:18789/")!) == true) - #expect(screen.isLocalNetworkCanvasURL(URL(string: "http://192.168.0.10:18789/")!) == true) - #expect(screen.isLocalNetworkCanvasURL(URL(string: "http://10.0.0.10:18789/")!) == true) - #expect(screen.isLocalNetworkCanvasURL(URL(string: "http://100.123.224.76:18789/")!) == true) // Tailscale CGNAT - #expect(screen.isLocalNetworkCanvasURL(URL(string: "https://example.com/")!) == false) - #expect(screen.isLocalNetworkCanvasURL(URL(string: "http://8.8.8.8/")!) == false) - } - - @Test func parseA2UIActionBodyAcceptsJSONString() throws { - let body = ScreenController.parseA2UIActionBody("{\"userAction\":{\"name\":\"hello\"}}") - let userAction = try #require(body?["userAction"] as? [String: Any]) - #expect(userAction["name"] as? String == "hello") - } -} diff --git a/apps/ios/Tests/ScreenRecordServiceTests.swift b/apps/ios/Tests/ScreenRecordServiceTests.swift deleted file mode 100644 index 6ae8f1ca30f..00000000000 --- a/apps/ios/Tests/ScreenRecordServiceTests.swift +++ /dev/null @@ -1,32 +0,0 @@ -import Testing -@testable import OpenClaw - -@Suite(.serialized) struct ScreenRecordServiceTests { - @Test func clampDefaultsAndBounds() { - #expect(ScreenRecordService._test_clampDurationMs(nil) == 10000) - #expect(ScreenRecordService._test_clampDurationMs(0) == 250) - #expect(ScreenRecordService._test_clampDurationMs(60001) == 60000) - - #expect(ScreenRecordService._test_clampFps(nil) == 10) - #expect(ScreenRecordService._test_clampFps(0) == 1) - #expect(ScreenRecordService._test_clampFps(120) == 30) - #expect(ScreenRecordService._test_clampFps(.infinity) == 10) - } - - @Test @MainActor func recordRejectsInvalidScreenIndex() async { - let recorder = ScreenRecordService() - do { - _ = try await recorder.record( - screenIndex: 1, - durationMs: 250, - fps: 5, - includeAudio: false, - outPath: nil) - Issue.record("Expected invalid screen index to throw") - } catch let error as ScreenRecordService.ScreenRecordError { - #expect(error.localizedDescription.contains("Invalid screen index") == true) - } catch { - Issue.record("Unexpected error type: \(error)") - } - } -} diff --git a/apps/ios/Tests/SettingsNetworkingHelpersTests.swift b/apps/ios/Tests/SettingsNetworkingHelpersTests.swift deleted file mode 100644 index f1a649613b5..00000000000 --- a/apps/ios/Tests/SettingsNetworkingHelpersTests.swift +++ /dev/null @@ -1,50 +0,0 @@ -import Testing -@testable import OpenClaw - -@Suite struct SettingsNetworkingHelpersTests { - @Test func parseHostPortParsesIPv4() { - #expect(SettingsNetworkingHelpers.parseHostPort(from: "127.0.0.1:8080") == .init(host: "127.0.0.1", port: 8080)) - } - - @Test func parseHostPortParsesHostnameAndTrims() { - #expect(SettingsNetworkingHelpers.parseHostPort(from: " example.com:80 \n") == .init( - host: "example.com", - port: 80)) - } - - @Test func parseHostPortParsesBracketedIPv6() { - #expect( - SettingsNetworkingHelpers.parseHostPort(from: "[2001:db8::1]:443") == - .init(host: "2001:db8::1", port: 443)) - } - - @Test func parseHostPortRejectsMissingPort() { - #expect(SettingsNetworkingHelpers.parseHostPort(from: "example.com") == nil) - #expect(SettingsNetworkingHelpers.parseHostPort(from: "[2001:db8::1]") == nil) - } - - @Test func parseHostPortRejectsInvalidPort() { - #expect(SettingsNetworkingHelpers.parseHostPort(from: "example.com:lol") == nil) - #expect(SettingsNetworkingHelpers.parseHostPort(from: "[2001:db8::1]:lol") == nil) - } - - @Test func httpURLStringFormatsIPv4AndPort() { - #expect(SettingsNetworkingHelpers - .httpURLString(host: "127.0.0.1", port: 8080, fallback: "fallback") == "http://127.0.0.1:8080") - } - - @Test func httpURLStringBracketsIPv6() { - #expect(SettingsNetworkingHelpers - .httpURLString(host: "2001:db8::1", port: 8080, fallback: "fallback") == "http://[2001:db8::1]:8080") - } - - @Test func httpURLStringLeavesAlreadyBracketedIPv6() { - #expect(SettingsNetworkingHelpers - .httpURLString(host: "[2001:db8::1]", port: 8080, fallback: "fallback") == "http://[2001:db8::1]:8080") - } - - @Test func httpURLStringFallsBackWhenMissingHostOrPort() { - #expect(SettingsNetworkingHelpers.httpURLString(host: nil, port: 80, fallback: "x") == "http://x") - #expect(SettingsNetworkingHelpers.httpURLString(host: "example.com", port: nil, fallback: "y") == "http://y") - } -} diff --git a/apps/ios/Tests/ShareToAgentDeepLinkTests.swift b/apps/ios/Tests/ShareToAgentDeepLinkTests.swift deleted file mode 100644 index 4ea178ecfa2..00000000000 --- a/apps/ios/Tests/ShareToAgentDeepLinkTests.swift +++ /dev/null @@ -1,51 +0,0 @@ -import OpenClawKit -import Foundation -import Testing - -@Suite struct ShareToAgentDeepLinkTests { - @Test func buildMessageIncludesSharedFields() { - let payload = SharedContentPayload( - title: "Article", - url: URL(string: "https://example.com/post")!, - text: "Read this") - - let message = ShareToAgentDeepLink.buildMessage( - from: payload, - instruction: "Summarize and give next steps.") - #expect(message.contains("Shared from iOS.")) - #expect(message.contains("Title: Article")) - #expect(message.contains("URL: https://example.com/post")) - #expect(message.contains("Text:\nRead this")) - #expect(message.contains("Summarize and give next steps.")) - } - - @Test func buildURLEncodesAgentRoute() { - let payload = SharedContentPayload( - title: "", - url: URL(string: "https://example.com")!, - text: nil) - - let url = ShareToAgentDeepLink.buildURL(from: payload) - let parsed = url.flatMap { DeepLinkParser.parse($0) } - guard case let .agent(agent)? = parsed else { - Issue.record("Expected openclaw://agent deep link") - return - } - - #expect(agent.thinking == "low") - #expect(agent.message.contains("https://example.com")) - } - - @Test func buildURLReturnsNilWhenPayloadEmpty() { - let payload = SharedContentPayload(title: nil, url: nil, text: nil) - #expect(ShareToAgentDeepLink.buildURL(from: payload) == nil) - } - - @Test func shareInstructionSettingsRoundTrip() { - let value = "Focus on booking constraints and alternatives." - ShareToAgentSettings.saveDefaultInstruction(value) - defer { ShareToAgentSettings.saveDefaultInstruction(nil) } - - #expect(ShareToAgentSettings.loadDefaultInstruction() == value) - } -} diff --git a/apps/ios/Tests/SwiftUIRenderSmokeTests.swift b/apps/ios/Tests/SwiftUIRenderSmokeTests.swift deleted file mode 100644 index 4e13b3f4cd1..00000000000 --- a/apps/ios/Tests/SwiftUIRenderSmokeTests.swift +++ /dev/null @@ -1,81 +0,0 @@ -import OpenClawKit -import SwiftUI -import Testing -import UIKit -@testable import OpenClaw - -@Suite struct SwiftUIRenderSmokeTests { - @MainActor private static func host(_ view: some View) -> UIWindow { - let window = UIWindow(frame: UIScreen.main.bounds) - window.rootViewController = UIHostingController(rootView: view) - window.makeKeyAndVisible() - window.rootViewController?.view.setNeedsLayout() - window.rootViewController?.view.layoutIfNeeded() - return window - } - - @Test @MainActor func statusPillConnectingBuildsAViewHierarchy() { - let root = StatusPill(gateway: .connecting, voiceWakeEnabled: true, brighten: true) {} - _ = Self.host(root) - } - - @Test @MainActor func statusPillDisconnectedBuildsAViewHierarchy() { - let root = StatusPill(gateway: .disconnected, voiceWakeEnabled: false) {} - _ = Self.host(root) - } - - @Test @MainActor func settingsTabBuildsAViewHierarchy() { - let appModel = NodeAppModel() - let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false) - - let root = SettingsTab() - .environment(appModel) - .environment(appModel.voiceWake) - .environment(gatewayController) - - _ = Self.host(root) - } - - @Test @MainActor func rootTabsBuildAViewHierarchy() { - let appModel = NodeAppModel() - let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false) - - let root = RootTabs() - .environment(appModel) - .environment(appModel.voiceWake) - .environment(gatewayController) - - _ = Self.host(root) - } - - @Test @MainActor func voiceTabBuildsAViewHierarchy() { - let appModel = NodeAppModel() - - let root = VoiceTab() - .environment(appModel) - .environment(appModel.voiceWake) - - _ = Self.host(root) - } - - @Test @MainActor func voiceWakeWordsViewBuildsAViewHierarchy() { - let appModel = NodeAppModel() - let root = NavigationStack { VoiceWakeWordsSettingsView() } - .environment(appModel) - _ = Self.host(root) - } - - @Test @MainActor func chatSheetBuildsAViewHierarchy() { - let appModel = NodeAppModel() - let gateway = GatewayNodeSession() - let root = ChatSheet(gateway: gateway, sessionKey: "test") - .environment(appModel) - .environment(appModel.voiceWake) - _ = Self.host(root) - } - - @Test @MainActor func voiceWakeToastBuildsAViewHierarchy() { - let root = VoiceWakeToast(command: "openclaw: do something") - _ = Self.host(root) - } -} diff --git a/apps/ios/Tests/VoiceWakeGatewaySyncTests.swift b/apps/ios/Tests/VoiceWakeGatewaySyncTests.swift deleted file mode 100644 index fa4a070da28..00000000000 --- a/apps/ios/Tests/VoiceWakeGatewaySyncTests.swift +++ /dev/null @@ -1,22 +0,0 @@ -import Foundation -import Testing -@testable import OpenClaw - -@Suite struct VoiceWakeGatewaySyncTests { - @Test func decodeGatewayTriggersFromJSONSanitizes() { - let payload = #"{"triggers":[" openclaw ","", "computer"]}"# - let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: payload) - #expect(triggers == ["openclaw", "computer"]) - } - - @Test func decodeGatewayTriggersFromJSONFallsBackWhenEmpty() { - let payload = #"{"triggers":[" ",""]}"# - let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: payload) - #expect(triggers == VoiceWakePreferences.defaultTriggerWords) - } - - @Test func decodeGatewayTriggersFromInvalidJSONReturnsNil() { - let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: "not json") - #expect(triggers == nil) - } -} diff --git a/apps/ios/Tests/VoiceWakeManagerExtractCommandTests.swift b/apps/ios/Tests/VoiceWakeManagerExtractCommandTests.swift deleted file mode 100644 index f6b0378cd6b..00000000000 --- a/apps/ios/Tests/VoiceWakeManagerExtractCommandTests.swift +++ /dev/null @@ -1,90 +0,0 @@ -import Foundation -import SwabbleKit -import Testing -@testable import OpenClaw - -@Suite struct VoiceWakeManagerExtractCommandTests { - @Test func extractCommandReturnsNilWhenNoTriggerFound() { - let transcript = "hello world" - let segments = makeSegments( - transcript: transcript, - words: [("hello", 0.0, 0.1), ("world", 0.2, 0.1)]) - #expect(VoiceWakeManager.extractCommand(from: transcript, segments: segments, triggers: ["openclaw"]) == nil) - } - - @Test func extractCommandTrimsTokensAndResult() { - let transcript = "hey openclaw do thing" - let segments = makeSegments( - transcript: transcript, - words: [ - ("hey", 0.0, 0.1), - ("openclaw", 0.2, 0.1), - ("do", 0.9, 0.1), - ("thing", 1.1, 0.1), - ]) - let cmd = VoiceWakeManager.extractCommand( - from: transcript, - segments: segments, - triggers: [" openclaw "], - minPostTriggerGap: 0.3) - #expect(cmd == "do thing") - } - - @Test func extractCommandReturnsNilWhenGapTooShort() { - let transcript = "hey openclaw do thing" - let segments = makeSegments( - transcript: transcript, - words: [ - ("hey", 0.0, 0.1), - ("openclaw", 0.2, 0.1), - ("do", 0.35, 0.1), - ("thing", 0.5, 0.1), - ]) - let cmd = VoiceWakeManager.extractCommand( - from: transcript, - segments: segments, - triggers: ["openclaw"], - minPostTriggerGap: 0.3) - #expect(cmd == nil) - } - - @Test func extractCommandReturnsNilWhenNothingAfterTrigger() { - let transcript = "hey openclaw" - let segments = makeSegments( - transcript: transcript, - words: [("hey", 0.0, 0.1), ("openclaw", 0.2, 0.1)]) - #expect(VoiceWakeManager.extractCommand(from: transcript, segments: segments, triggers: ["openclaw"]) == nil) - } - - @Test func extractCommandIgnoresEmptyTriggers() { - let transcript = "hey openclaw do thing" - let segments = makeSegments( - transcript: transcript, - words: [ - ("hey", 0.0, 0.1), - ("openclaw", 0.2, 0.1), - ("do", 0.9, 0.1), - ("thing", 1.1, 0.1), - ]) - let cmd = VoiceWakeManager.extractCommand( - from: transcript, - segments: segments, - triggers: ["", " ", "openclaw"], - minPostTriggerGap: 0.3) - #expect(cmd == "do thing") - } -} - -private func makeSegments( - transcript: String, - words: [(String, TimeInterval, TimeInterval)]) --> [WakeWordSegment] { - var searchStart = transcript.startIndex - var output: [WakeWordSegment] = [] - for (word, start, duration) in words { - let range = transcript.range(of: word, range: searchStart.. - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - OpenClaw - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - APPL - CFBundleShortVersionString - 2026.2.21 - CFBundleVersion - 20260220 - WKCompanionAppBundleIdentifier - $(OPENCLAW_APP_BUNDLE_ID) - WKWatchKitApp - - - diff --git a/apps/ios/WatchExtension/Info.plist b/apps/ios/WatchExtension/Info.plist deleted file mode 100644 index 2d6b7baa7b8..00000000000 --- a/apps/ios/WatchExtension/Info.plist +++ /dev/null @@ -1,32 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - OpenClaw - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundleShortVersionString - 2026.2.21 - CFBundleVersion - 20260220 - NSExtension - - NSExtensionAttributes - - WKAppBundleIdentifier - $(OPENCLAW_WATCH_APP_BUNDLE_ID) - - NSExtensionPointIdentifier - com.apple.watchkit - - - diff --git a/apps/ios/WatchExtension/Sources/OpenClawWatchApp.swift b/apps/ios/WatchExtension/Sources/OpenClawWatchApp.swift deleted file mode 100644 index 4c123c49f16..00000000000 --- a/apps/ios/WatchExtension/Sources/OpenClawWatchApp.swift +++ /dev/null @@ -1,28 +0,0 @@ -import SwiftUI - -@main -struct OpenClawWatchApp: App { - @State private var inboxStore = WatchInboxStore() - @State private var receiver: WatchConnectivityReceiver? - - var body: some Scene { - WindowGroup { - WatchInboxView(store: self.inboxStore) { action in - guard let receiver = self.receiver else { return } - let draft = self.inboxStore.makeReplyDraft(action: action) - self.inboxStore.markReplySending(actionLabel: action.label) - Task { @MainActor in - let result = await receiver.sendReply(draft) - self.inboxStore.markReplyResult(result, actionLabel: action.label) - } - } - .task { - if self.receiver == nil { - let receiver = WatchConnectivityReceiver(store: self.inboxStore) - receiver.activate() - self.receiver = receiver - } - } - } - } -} diff --git a/apps/ios/WatchExtension/Sources/WatchConnectivityReceiver.swift b/apps/ios/WatchExtension/Sources/WatchConnectivityReceiver.swift deleted file mode 100644 index da1c3c379a3..00000000000 --- a/apps/ios/WatchExtension/Sources/WatchConnectivityReceiver.swift +++ /dev/null @@ -1,236 +0,0 @@ -import Foundation -import WatchConnectivity - -struct WatchReplyDraft: Sendable { - var replyId: String - var promptId: String - var actionId: String - var actionLabel: String? - var sessionKey: String? - var note: String? - var sentAtMs: Int -} - -struct WatchReplySendResult: Sendable, Equatable { - var deliveredImmediately: Bool - var queuedForDelivery: Bool - var transport: String - var errorMessage: String? -} - -final class WatchConnectivityReceiver: NSObject, @unchecked Sendable { - private let store: WatchInboxStore - private let session: WCSession? - - init(store: WatchInboxStore) { - self.store = store - if WCSession.isSupported() { - self.session = WCSession.default - } else { - self.session = nil - } - super.init() - } - - func activate() { - guard let session = self.session else { return } - session.delegate = self - session.activate() - } - - private func ensureActivated() async { - guard let session = self.session else { return } - if session.activationState == .activated { - return - } - session.activate() - for _ in 0..<8 { - if session.activationState == .activated { - return - } - try? await Task.sleep(nanoseconds: 100_000_000) - } - } - - func sendReply(_ draft: WatchReplyDraft) async -> WatchReplySendResult { - await self.ensureActivated() - guard let session = self.session else { - return WatchReplySendResult( - deliveredImmediately: false, - queuedForDelivery: false, - transport: "none", - errorMessage: "watch session unavailable") - } - - var payload: [String: Any] = [ - "type": "watch.reply", - "replyId": draft.replyId, - "promptId": draft.promptId, - "actionId": draft.actionId, - "sentAtMs": draft.sentAtMs, - ] - if let actionLabel = draft.actionLabel?.trimmingCharacters(in: .whitespacesAndNewlines), - !actionLabel.isEmpty - { - payload["actionLabel"] = actionLabel - } - if let sessionKey = draft.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines), - !sessionKey.isEmpty - { - payload["sessionKey"] = sessionKey - } - if let note = draft.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty { - payload["note"] = note - } - - if session.isReachable { - do { - try await withCheckedThrowingContinuation { continuation in - session.sendMessage(payload, replyHandler: { _ in - continuation.resume() - }, errorHandler: { error in - continuation.resume(throwing: error) - }) - } - return WatchReplySendResult( - deliveredImmediately: true, - queuedForDelivery: false, - transport: "sendMessage", - errorMessage: nil) - } catch { - // Fall through to queued delivery below. - } - } - - _ = session.transferUserInfo(payload) - return WatchReplySendResult( - deliveredImmediately: false, - queuedForDelivery: true, - transport: "transferUserInfo", - errorMessage: nil) - } - - private static func normalizeObject(_ value: Any) -> [String: Any]? { - if let object = value as? [String: Any] { - return object - } - if let object = value as? [AnyHashable: Any] { - var normalized: [String: Any] = [:] - normalized.reserveCapacity(object.count) - for (key, item) in object { - guard let stringKey = key as? String else { - continue - } - normalized[stringKey] = item - } - return normalized - } - return nil - } - - private static func parseActions(_ value: Any?) -> [WatchPromptAction] { - guard let raw = value as? [Any] else { - return [] - } - return raw.compactMap { item in - guard let obj = Self.normalizeObject(item) else { - return nil - } - let id = (obj["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let label = (obj["label"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !id.isEmpty, !label.isEmpty else { - return nil - } - let style = (obj["style"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - return WatchPromptAction(id: id, label: label, style: style) - } - } - - private static func parseNotificationPayload(_ payload: [String: Any]) -> WatchNotifyMessage? { - guard let type = payload["type"] as? String, type == "watch.notify" else { - return nil - } - - let title = (payload["title"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let body = (payload["body"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - - guard title.isEmpty == false || body.isEmpty == false else { - return nil - } - - let id = (payload["id"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines) - let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue - let promptId = (payload["promptId"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines) - let sessionKey = (payload["sessionKey"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines) - let kind = (payload["kind"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines) - let details = (payload["details"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines) - let expiresAtMs = (payload["expiresAtMs"] as? Int) ?? (payload["expiresAtMs"] as? NSNumber)?.intValue - let risk = (payload["risk"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines) - let actions = Self.parseActions(payload["actions"]) - - return WatchNotifyMessage( - id: id, - title: title, - body: body, - sentAtMs: sentAtMs, - promptId: promptId, - sessionKey: sessionKey, - kind: kind, - details: details, - expiresAtMs: expiresAtMs, - risk: risk, - actions: actions) - } -} - -extension WatchConnectivityReceiver: WCSessionDelegate { - func session( - _: WCSession, - activationDidCompleteWith _: WCSessionActivationState, - error _: (any Error)?) - {} - - func session(_: WCSession, didReceiveMessage message: [String: Any]) { - guard let incoming = Self.parseNotificationPayload(message) else { return } - Task { @MainActor in - self.store.consume(message: incoming, transport: "sendMessage") - } - } - - func session( - _: WCSession, - didReceiveMessage message: [String: Any], - replyHandler: @escaping ([String: Any]) -> Void) - { - guard let incoming = Self.parseNotificationPayload(message) else { - replyHandler(["ok": false]) - return - } - replyHandler(["ok": true]) - Task { @MainActor in - self.store.consume(message: incoming, transport: "sendMessage") - } - } - - func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) { - guard let incoming = Self.parseNotificationPayload(userInfo) else { return } - Task { @MainActor in - self.store.consume(message: incoming, transport: "transferUserInfo") - } - } - - func session(_: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { - guard let incoming = Self.parseNotificationPayload(applicationContext) else { return } - Task { @MainActor in - self.store.consume(message: incoming, transport: "applicationContext") - } - } -} diff --git a/apps/ios/WatchExtension/Sources/WatchInboxStore.swift b/apps/ios/WatchExtension/Sources/WatchInboxStore.swift deleted file mode 100644 index 2ac1d75d6e1..00000000000 --- a/apps/ios/WatchExtension/Sources/WatchInboxStore.swift +++ /dev/null @@ -1,230 +0,0 @@ -import Foundation -import Observation -import UserNotifications -import WatchKit - -struct WatchPromptAction: Codable, Sendable, Equatable, Identifiable { - var id: String - var label: String - var style: String? -} - -struct WatchNotifyMessage: Sendable { - var id: String? - var title: String - var body: String - var sentAtMs: Int? - var promptId: String? - var sessionKey: String? - var kind: String? - var details: String? - var expiresAtMs: Int? - var risk: String? - var actions: [WatchPromptAction] -} - -@MainActor @Observable final class WatchInboxStore { - private struct PersistedState: Codable { - var title: String - var body: String - var transport: String - var updatedAt: Date - var lastDeliveryKey: String? - var promptId: String? - var sessionKey: String? - var kind: String? - var details: String? - var expiresAtMs: Int? - var risk: String? - var actions: [WatchPromptAction]? - var replyStatusText: String? - var replyStatusAt: Date? - } - - private static let persistedStateKey = "watch.inbox.state.v1" - private let defaults: UserDefaults - - var title = "OpenClaw" - var body = "Waiting for messages from your iPhone." - var transport = "none" - var updatedAt: Date? - var promptId: String? - var sessionKey: String? - var kind: String? - var details: String? - var expiresAtMs: Int? - var risk: String? - var actions: [WatchPromptAction] = [] - var replyStatusText: String? - var replyStatusAt: Date? - var isReplySending = false - private var lastDeliveryKey: String? - - init(defaults: UserDefaults = .standard) { - self.defaults = defaults - self.restorePersistedState() - Task { - await self.ensureNotificationAuthorization() - } - } - - func consume(message: WatchNotifyMessage, transport: String) { - let messageID = message.id? - .trimmingCharacters(in: .whitespacesAndNewlines) - let deliveryKey = self.deliveryKey( - messageID: messageID, - title: message.title, - body: message.body, - sentAtMs: message.sentAtMs) - guard deliveryKey != self.lastDeliveryKey else { return } - - let normalizedTitle = message.title.isEmpty ? "OpenClaw" : message.title - self.title = normalizedTitle - self.body = message.body - self.transport = transport - self.updatedAt = Date() - self.promptId = message.promptId - self.sessionKey = message.sessionKey - self.kind = message.kind - self.details = message.details - self.expiresAtMs = message.expiresAtMs - self.risk = message.risk - self.actions = message.actions - self.lastDeliveryKey = deliveryKey - self.replyStatusText = nil - self.replyStatusAt = nil - self.isReplySending = false - self.persistState() - - Task { - await self.postLocalNotification( - identifier: deliveryKey, - title: normalizedTitle, - body: message.body, - risk: message.risk) - } - } - - private func restorePersistedState() { - guard let data = self.defaults.data(forKey: Self.persistedStateKey), - let state = try? JSONDecoder().decode(PersistedState.self, from: data) - else { - return - } - - self.title = state.title - self.body = state.body - self.transport = state.transport - self.updatedAt = state.updatedAt - self.lastDeliveryKey = state.lastDeliveryKey - self.promptId = state.promptId - self.sessionKey = state.sessionKey - self.kind = state.kind - self.details = state.details - self.expiresAtMs = state.expiresAtMs - self.risk = state.risk - self.actions = state.actions ?? [] - self.replyStatusText = state.replyStatusText - self.replyStatusAt = state.replyStatusAt - } - - private func persistState() { - guard let updatedAt = self.updatedAt else { return } - let state = PersistedState( - title: self.title, - body: self.body, - transport: self.transport, - updatedAt: updatedAt, - lastDeliveryKey: self.lastDeliveryKey, - promptId: self.promptId, - sessionKey: self.sessionKey, - kind: self.kind, - details: self.details, - expiresAtMs: self.expiresAtMs, - risk: self.risk, - actions: self.actions, - replyStatusText: self.replyStatusText, - replyStatusAt: self.replyStatusAt) - guard let data = try? JSONEncoder().encode(state) else { return } - self.defaults.set(data, forKey: Self.persistedStateKey) - } - - private func deliveryKey(messageID: String?, title: String, body: String, sentAtMs: Int?) -> String { - if let messageID, messageID.isEmpty == false { - return "id:\(messageID)" - } - return "content:\(title)|\(body)|\(sentAtMs ?? 0)" - } - - private func ensureNotificationAuthorization() async { - let center = UNUserNotificationCenter.current() - let settings = await center.notificationSettings() - switch settings.authorizationStatus { - case .notDetermined: - _ = try? await center.requestAuthorization(options: [.alert, .sound]) - default: - break - } - } - - private func mapHapticRisk(_ risk: String?) -> WKHapticType { - switch risk?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { - case "high": - return .failure - case "medium": - return .notification - default: - return .click - } - } - - func makeReplyDraft(action: WatchPromptAction) -> WatchReplyDraft { - let prompt = self.promptId?.trimmingCharacters(in: .whitespacesAndNewlines) - return WatchReplyDraft( - replyId: UUID().uuidString, - promptId: (prompt?.isEmpty == false) ? prompt! : "unknown", - actionId: action.id, - actionLabel: action.label, - sessionKey: self.sessionKey, - note: nil, - sentAtMs: Int(Date().timeIntervalSince1970 * 1000)) - } - - func markReplySending(actionLabel: String) { - self.isReplySending = true - self.replyStatusText = "Sending \(actionLabel)…" - self.replyStatusAt = Date() - self.persistState() - } - - func markReplyResult(_ result: WatchReplySendResult, actionLabel: String) { - self.isReplySending = false - if let errorMessage = result.errorMessage, !errorMessage.isEmpty { - self.replyStatusText = "Failed: \(errorMessage)" - } else if result.deliveredImmediately { - self.replyStatusText = "\(actionLabel): sent" - } else if result.queuedForDelivery { - self.replyStatusText = "\(actionLabel): queued" - } else { - self.replyStatusText = "\(actionLabel): sent" - } - self.replyStatusAt = Date() - self.persistState() - } - - private func postLocalNotification(identifier: String, title: String, body: String, risk: String?) async { - let content = UNMutableNotificationContent() - content.title = title - content.body = body - content.sound = .default - content.threadIdentifier = "openclaw-watch" - - let request = UNNotificationRequest( - identifier: identifier, - content: content, - trigger: UNTimeIntervalNotificationTrigger(timeInterval: 0.2, repeats: false)) - - _ = try? await UNUserNotificationCenter.current().add(request) - WKInterfaceDevice.current().play(self.mapHapticRisk(risk)) - } -} diff --git a/apps/ios/WatchExtension/Sources/WatchInboxView.swift b/apps/ios/WatchExtension/Sources/WatchInboxView.swift deleted file mode 100644 index c6f944a949e..00000000000 --- a/apps/ios/WatchExtension/Sources/WatchInboxView.swift +++ /dev/null @@ -1,64 +0,0 @@ -import SwiftUI - -struct WatchInboxView: View { - @Bindable var store: WatchInboxStore - var onAction: ((WatchPromptAction) -> Void)? - - private func role(for action: WatchPromptAction) -> ButtonRole? { - switch action.style?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { - case "destructive": - return .destructive - case "cancel": - return .cancel - default: - return nil - } - } - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 8) { - Text(store.title) - .font(.headline) - .lineLimit(2) - - Text(store.body) - .font(.body) - .fixedSize(horizontal: false, vertical: true) - - if let details = store.details, !details.isEmpty { - Text(details) - .font(.footnote) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - if !store.actions.isEmpty { - ForEach(store.actions) { action in - Button(role: self.role(for: action)) { - self.onAction?(action) - } label: { - Text(action.label) - .frame(maxWidth: .infinity) - } - .disabled(store.isReplySending) - } - } - - if let replyStatusText = store.replyStatusText, !replyStatusText.isEmpty { - Text(replyStatusText) - .font(.footnote) - .foregroundStyle(.secondary) - } - - if let updatedAt = store.updatedAt { - Text("Updated \(updatedAt.formatted(date: .omitted, time: .shortened))") - .font(.footnote) - .foregroundStyle(.secondary) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding() - } - } -} diff --git a/apps/ios/fastlane/.env.example b/apps/ios/fastlane/.env.example deleted file mode 100644 index 7f2c61333ab..00000000000 --- a/apps/ios/fastlane/.env.example +++ /dev/null @@ -1,21 +0,0 @@ -# App Store Connect API key (pick one approach) -# -# Recommended (use the downloaded .p8 directly): -# ASC_KEY_ID=XXXXXXXXXX -# ASC_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx -# ASC_KEY_PATH=/absolute/path/to/AuthKey_XXXXXXXXXX.p8 -# -# Or (JSON key file): -# APP_STORE_CONNECT_API_KEY_PATH=/absolute/path/to/AuthKey_XXXXXX.json -# -# Or: -# ASC_KEY_ID=XXXXXXXXXX -# ASC_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx -# ASC_KEY_CONTENT=BASE64_P8_CONTENT - -# Code signing -# IOS_DEVELOPMENT_TEAM=XXXXXXXXXX - -# Deliver toggles (off by default) -# DELIVER_METADATA=1 -# DELIVER_SCREENSHOTS=1 diff --git a/apps/ios/fastlane/Appfile b/apps/ios/fastlane/Appfile deleted file mode 100644 index adaa3fc29fb..00000000000 --- a/apps/ios/fastlane/Appfile +++ /dev/null @@ -1,7 +0,0 @@ -app_identifier("bot.molt.ios") - -# Auth is expected via App Store Connect API key. -# Provide either: -# - APP_STORE_CONNECT_API_KEY_PATH=/path/to/AuthKey_XXXXXX.p8.json (recommended) -# or: -# - ASC_KEY_ID, ASC_ISSUER_ID, and ASC_KEY_CONTENT (base64 or raw p8 content) diff --git a/apps/ios/fastlane/Fastfile b/apps/ios/fastlane/Fastfile deleted file mode 100644 index f1dbf6df18c..00000000000 --- a/apps/ios/fastlane/Fastfile +++ /dev/null @@ -1,104 +0,0 @@ -require "shellwords" - -default_platform(:ios) - -def load_env_file(path) - return unless File.exist?(path) - - File.foreach(path) do |line| - stripped = line.strip - next if stripped.empty? || stripped.start_with?("#") - - key, value = stripped.split("=", 2) - next if key.nil? || key.empty? || value.nil? - - ENV[key] = value if ENV[key].nil? || ENV[key].strip.empty? - end -end - -platform :ios do - private_lane :asc_api_key do - load_env_file(File.join(__dir__, ".env")) - - api_key = nil - - key_path = ENV["APP_STORE_CONNECT_API_KEY_PATH"] - if key_path && !key_path.strip.empty? - api_key = app_store_connect_api_key(path: key_path) - else - p8_path = ENV["ASC_KEY_PATH"] - if p8_path && !p8_path.strip.empty? - key_id = ENV["ASC_KEY_ID"] - issuer_id = ENV["ASC_ISSUER_ID"] - UI.user_error!("Missing ASC_KEY_ID or ASC_ISSUER_ID for ASC_KEY_PATH auth.") if [key_id, issuer_id].any? { |v| v.nil? || v.strip.empty? } - - api_key = app_store_connect_api_key( - key_id: key_id, - issuer_id: issuer_id, - key_filepath: p8_path - ) - else - key_id = ENV["ASC_KEY_ID"] - issuer_id = ENV["ASC_ISSUER_ID"] - key_content = ENV["ASC_KEY_CONTENT"] - - UI.user_error!("Missing App Store Connect API key. Set APP_STORE_CONNECT_API_KEY_PATH (json) or ASC_KEY_PATH (p8) or ASC_KEY_ID/ASC_ISSUER_ID/ASC_KEY_CONTENT.") if [key_id, issuer_id, key_content].any? { |v| v.nil? || v.strip.empty? } - - is_base64 = key_content.include?("BEGIN PRIVATE KEY") ? false : true - - api_key = app_store_connect_api_key( - key_id: key_id, - issuer_id: issuer_id, - key_content: key_content, - is_key_content_base64: is_base64 - ) - end - end - - api_key - end - - desc "Build + upload to TestFlight" - lane :beta do - api_key = asc_api_key - - team_id = ENV["IOS_DEVELOPMENT_TEAM"] - if team_id.nil? || team_id.strip.empty? - helper_path = File.expand_path("../../scripts/ios-team-id.sh", __dir__) - if File.exist?(helper_path) - # Keep CI/local compatibility where teams are present in keychain but not Xcode account metadata. - team_id = sh("IOS_ALLOW_KEYCHAIN_TEAM_FALLBACK=1 bash #{helper_path.shellescape}").strip - end - end - UI.user_error!("Missing IOS_DEVELOPMENT_TEAM (Apple Team ID). Add it to fastlane/.env or export it in your shell.") if team_id.nil? || team_id.strip.empty? - - build_app( - project: "OpenClaw.xcodeproj", - scheme: "OpenClaw", - export_method: "app-store", - clean: true, - xcargs: "DEVELOPMENT_TEAM=#{team_id} -allowProvisioningUpdates", - export_xcargs: "-allowProvisioningUpdates", - export_options: { - signingStyle: "automatic" - } - ) - - upload_to_testflight( - api_key: api_key, - skip_waiting_for_build_processing: true - ) - end - - desc "Upload App Store metadata (and optionally screenshots)" - lane :metadata do - api_key = asc_api_key - - deliver( - api_key: api_key, - force: true, - skip_screenshots: ENV["DELIVER_SCREENSHOTS"] != "1", - skip_metadata: ENV["DELIVER_METADATA"] != "1" - ) - end -end diff --git a/apps/ios/fastlane/SETUP.md b/apps/ios/fastlane/SETUP.md deleted file mode 100644 index 930258fcc79..00000000000 --- a/apps/ios/fastlane/SETUP.md +++ /dev/null @@ -1,32 +0,0 @@ -# fastlane setup (OpenClaw iOS) - -Install: - -```bash -brew install fastlane -``` - -Create an App Store Connect API key: - -- App Store Connect → Users and Access → Keys → App Store Connect API → Generate API Key -- Download the `.p8`, note the **Issuer ID** and **Key ID** - -Create `apps/ios/fastlane/.env` (gitignored): - -```bash -ASC_KEY_ID=YOUR_KEY_ID -ASC_ISSUER_ID=YOUR_ISSUER_ID -ASC_KEY_PATH=/absolute/path/to/AuthKey_XXXXXXXXXX.p8 - -# Code signing (Apple Team ID / App ID Prefix) -IOS_DEVELOPMENT_TEAM=YOUR_TEAM_ID -``` - -Tip: run `scripts/ios-team-id.sh` from the repo root to print a Team ID to paste into `.env`. The helper prefers the canonical OpenClaw team (`Y5PE65HELJ`) when present locally; otherwise it prefers the first non-personal team from your Xcode account (then personal team if needed). Fastlane uses this helper automatically if `IOS_DEVELOPMENT_TEAM` is missing. - -Run: - -```bash -cd apps/ios -fastlane beta -``` diff --git a/apps/ios/project.yml b/apps/ios/project.yml deleted file mode 100644 index 613322f3e8e..00000000000 --- a/apps/ios/project.yml +++ /dev/null @@ -1,232 +0,0 @@ -name: OpenClaw -options: - bundleIdPrefix: ai.openclaw - deploymentTarget: - iOS: "18.0" - xcodeVersion: "16.0" - -settings: - base: - SWIFT_VERSION: "6.0" - -packages: - OpenClawKit: - path: ../shared/OpenClawKit - Swabble: - path: ../../Swabble - -schemes: - OpenClaw: - shared: true - build: - targets: - OpenClaw: all - test: - targets: - - OpenClawTests - -targets: - OpenClaw: - type: application - platform: iOS - configFiles: - Debug: Signing.xcconfig - Release: Signing.xcconfig - sources: - - path: Sources - dependencies: - - target: OpenClawShareExtension - embed: true - - target: OpenClawWatchApp - - package: OpenClawKit - - package: OpenClawKit - product: OpenClawChatUI - - package: OpenClawKit - product: OpenClawProtocol - - package: Swabble - product: SwabbleKit - - sdk: AppIntents.framework - preBuildScripts: - - name: SwiftFormat (lint) - basedOnDependencyAnalysis: false - inputFileLists: - - $(SRCROOT)/SwiftSources.input.xcfilelist - script: | - set -euo pipefail - export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH" - if ! command -v swiftformat >/dev/null 2>&1; then - echo "error: swiftformat not found (brew install swiftformat)" >&2 - exit 1 - fi - swiftformat --lint --config "$SRCROOT/../../.swiftformat" \ - --filelist "$SRCROOT/SwiftSources.input.xcfilelist" - - name: SwiftLint - basedOnDependencyAnalysis: false - inputFileLists: - - $(SRCROOT)/SwiftSources.input.xcfilelist - script: | - set -euo pipefail - export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH" - if ! command -v swiftlint >/dev/null 2>&1; then - echo "error: swiftlint not found (brew install swiftlint)" >&2 - exit 1 - fi - swiftlint lint --config "$SRCROOT/.swiftlint.yml" --use-script-input-file-lists - settings: - base: - CODE_SIGN_IDENTITY: "Apple Development" - CODE_SIGN_ENTITLEMENTS: Sources/OpenClaw.entitlements - CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)" - DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)" - PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_APP_BUNDLE_ID)" - PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_APP_PROFILE)" - SWIFT_VERSION: "6.0" - SWIFT_STRICT_CONCURRENCY: complete - ENABLE_APPINTENTS_METADATA: NO - info: - path: Sources/Info.plist - properties: - CFBundleDisplayName: OpenClaw - CFBundleIconName: AppIcon - CFBundleURLTypes: - - CFBundleURLName: ai.openclaw.ios - CFBundleURLSchemes: - - openclaw - CFBundleShortVersionString: "2026.2.21" - CFBundleVersion: "20260220" - UILaunchScreen: {} - UIApplicationSceneManifest: - UIApplicationSupportsMultipleScenes: false - UIBackgroundModes: - - audio - - remote-notification - BGTaskSchedulerPermittedIdentifiers: - - ai.openclaw.ios.bgrefresh - NSLocalNetworkUsageDescription: OpenClaw discovers and connects to your OpenClaw gateway on the local network. - NSAppTransportSecurity: - NSAllowsArbitraryLoadsInWebContent: true - NSBonjourServices: - - _openclaw-gw._tcp - NSCameraUsageDescription: OpenClaw can capture photos or short video clips when requested via the gateway. - NSLocationWhenInUseUsageDescription: OpenClaw uses your location when you allow location sharing. - NSLocationAlwaysAndWhenInUseUsageDescription: OpenClaw can share your location in the background when you enable Always. - NSMicrophoneUsageDescription: OpenClaw needs microphone access for voice wake. - NSSpeechRecognitionUsageDescription: OpenClaw uses on-device speech recognition for voice wake. - UISupportedInterfaceOrientations: - - UIInterfaceOrientationPortrait - - UIInterfaceOrientationPortraitUpsideDown - - UIInterfaceOrientationLandscapeLeft - - UIInterfaceOrientationLandscapeRight - UISupportedInterfaceOrientations~ipad: - - UIInterfaceOrientationPortrait - - UIInterfaceOrientationPortraitUpsideDown - - UIInterfaceOrientationLandscapeLeft - - UIInterfaceOrientationLandscapeRight - - OpenClawShareExtension: - type: app-extension - platform: iOS - configFiles: - Debug: Signing.xcconfig - Release: Signing.xcconfig - sources: - - path: ShareExtension - dependencies: - - package: OpenClawKit - settings: - base: - CODE_SIGN_IDENTITY: "Apple Development" - CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)" - DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)" - PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_SHARE_BUNDLE_ID)" - PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_SHARE_PROFILE)" - SWIFT_VERSION: "6.0" - SWIFT_STRICT_CONCURRENCY: complete - info: - path: ShareExtension/Info.plist - properties: - CFBundleDisplayName: OpenClaw Share - CFBundleShortVersionString: "2026.2.21" - CFBundleVersion: "20260220" - NSExtension: - NSExtensionPointIdentifier: com.apple.share-services - NSExtensionPrincipalClass: "$(PRODUCT_MODULE_NAME).ShareViewController" - NSExtensionAttributes: - NSExtensionActivationRule: - NSExtensionActivationSupportsText: true - NSExtensionActivationSupportsWebURLWithMaxCount: 1 - NSExtensionActivationSupportsImageWithMaxCount: 10 - NSExtensionActivationSupportsMovieWithMaxCount: 1 - - OpenClawWatchApp: - type: application.watchapp2 - platform: watchOS - deploymentTarget: "11.0" - sources: - - path: WatchApp - dependencies: - - target: OpenClawWatchExtension - configFiles: - Debug: Config/Signing.xcconfig - Release: Config/Signing.xcconfig - settings: - base: - PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)" - info: - path: WatchApp/Info.plist - properties: - CFBundleDisplayName: OpenClaw - CFBundleShortVersionString: "2026.2.21" - CFBundleVersion: "20260220" - WKCompanionAppBundleIdentifier: "$(OPENCLAW_APP_BUNDLE_ID)" - WKWatchKitApp: true - - OpenClawWatchExtension: - type: watchkit2-extension - platform: watchOS - deploymentTarget: "11.0" - sources: - - path: WatchExtension/Sources - dependencies: - - sdk: WatchConnectivity.framework - - sdk: UserNotifications.framework - configFiles: - Debug: Config/Signing.xcconfig - Release: Config/Signing.xcconfig - settings: - base: - PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_WATCH_EXTENSION_BUNDLE_ID)" - info: - path: WatchExtension/Info.plist - properties: - CFBundleDisplayName: OpenClaw - CFBundleShortVersionString: "2026.2.21" - CFBundleVersion: "20260220" - NSExtension: - NSExtensionAttributes: - WKAppBundleIdentifier: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)" - NSExtensionPointIdentifier: com.apple.watchkit - - OpenClawTests: - type: bundle.unit-test - platform: iOS - sources: - - path: Tests - dependencies: - - target: OpenClaw - - package: Swabble - product: SwabbleKit - - sdk: AppIntents.framework - settings: - base: - PRODUCT_BUNDLE_IDENTIFIER: ai.openclaw.ios.tests - SWIFT_VERSION: "6.0" - SWIFT_STRICT_CONCURRENCY: complete - TEST_HOST: "$(BUILT_PRODUCTS_DIR)/OpenClaw.app/OpenClaw" - BUNDLE_LOADER: "$(TEST_HOST)" - info: - path: Tests/Info.plist - properties: - CFBundleDisplayName: OpenClawTests - CFBundleShortVersionString: "2026.2.21" - CFBundleVersion: "20260220" diff --git a/apps/macos/Icon.icon/Assets/openclaw-mac.png b/apps/macos/Icon.icon/Assets/openclaw-mac.png deleted file mode 100644 index 1ebd257d93f..00000000000 Binary files a/apps/macos/Icon.icon/Assets/openclaw-mac.png and /dev/null differ diff --git a/apps/macos/Icon.icon/icon.json b/apps/macos/Icon.icon/icon.json deleted file mode 100644 index 6172a47ef23..00000000000 --- a/apps/macos/Icon.icon/icon.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "fill" : { - "automatic-gradient" : "extended-srgb:0.00000,0.53333,1.00000,1.00000" - }, - "groups" : [ - { - "layers" : [ - { - "image-name" : "openclaw-mac.png", - "name" : "openclaw-mac", - "position" : { - "scale" : 1.07, - "translation-in-points" : [ - -2, - 0 - ] - } - } - ], - "shadow" : { - "kind" : "neutral", - "opacity" : 0.5 - }, - "translucency" : { - "enabled" : true, - "value" : 0.5 - } - } - ], - "supported-platforms" : { - "circles" : [ - "watchOS" - ], - "squares" : "shared" - } -} diff --git a/apps/macos/Package.resolved b/apps/macos/Package.resolved deleted file mode 100644 index 0281713738b..00000000000 --- a/apps/macos/Package.resolved +++ /dev/null @@ -1,132 +0,0 @@ -{ - "originHash" : "1c9c9d251b760ed3234ecff741a88eb4bf42315ad6f50ac7392b187cf226c16c", - "pins" : [ - { - "identity" : "axorcist", - "kind" : "remoteSourceControl", - "location" : "https://github.com/steipete/AXorcist.git", - "state" : { - "revision" : "c75d06f7f93e264a9786edc2b78c04973061cb2f", - "version" : "0.1.0" - } - }, - { - "identity" : "commander", - "kind" : "remoteSourceControl", - "location" : "https://github.com/steipete/Commander.git", - "state" : { - "revision" : "9e349575c8e3c6745e81fe19e5bb5efa01b078ce", - "version" : "0.2.1" - } - }, - { - "identity" : "elevenlabskit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/steipete/ElevenLabsKit", - "state" : { - "revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d", - "version" : "0.1.0" - } - }, - { - "identity" : "menubarextraaccess", - "kind" : "remoteSourceControl", - "location" : "https://github.com/orchetect/MenuBarExtraAccess", - "state" : { - "revision" : "707dff6f55217b3ef5b6be84ced3e83511d4df5c", - "version" : "1.2.2" - } - }, - { - "identity" : "peekaboo", - "kind" : "remoteSourceControl", - "location" : "https://github.com/steipete/Peekaboo.git", - "state" : { - "branch" : "main", - "revision" : "bace59f90bb276f1c6fb613acfda3935ec4a7a90" - } - }, - { - "identity" : "sparkle", - "kind" : "remoteSourceControl", - "location" : "https://github.com/sparkle-project/Sparkle", - "state" : { - "revision" : "5581748cef2bae787496fe6d61139aebe0a451f6", - "version" : "2.8.1" - } - }, - { - "identity" : "swift-algorithms", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-algorithms", - "state" : { - "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", - "version" : "1.2.1" - } - }, - { - "identity" : "swift-concurrency-extras", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-concurrency-extras", - "state" : { - "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", - "version" : "1.3.2" - } - }, - { - "identity" : "swift-log", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-log.git", - "state" : { - "revision" : "2778fd4e5a12a8aaa30a3ee8285f4ce54c5f3181", - "version" : "1.9.1" - } - }, - { - "identity" : "swift-numerics", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-numerics.git", - "state" : { - "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", - "version" : "1.1.1" - } - }, - { - "identity" : "swift-subprocess", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-subprocess.git", - "state" : { - "revision" : "ba5888ad7758cbcbe7abebac37860b1652af2d9c", - "version" : "0.3.0" - } - }, - { - "identity" : "swift-system", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-system", - "state" : { - "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", - "version" : "1.6.4" - } - }, - { - "identity" : "swiftui-math", - "kind" : "remoteSourceControl", - "location" : "https://github.com/gonzalezreal/swiftui-math", - "state" : { - "revision" : "0b5c2cfaaec8d6193db206f675048eeb5ce95f71", - "version" : "0.1.0" - } - }, - { - "identity" : "textual", - "kind" : "remoteSourceControl", - "location" : "https://github.com/gonzalezreal/textual", - "state" : { - "revision" : "5b06b811c0f5313b6b84bbef98c635a630638c38", - "version" : "0.3.1" - } - } - ], - "version" : 3 -} diff --git a/apps/macos/Package.swift b/apps/macos/Package.swift deleted file mode 100644 index 10ab47b8514..00000000000 --- a/apps/macos/Package.swift +++ /dev/null @@ -1,92 +0,0 @@ -// swift-tools-version: 6.2 -// Package manifest for the OpenClaw macOS companion (menu bar app + IPC library). - -import PackageDescription - -let package = Package( - name: "OpenClaw", - platforms: [ - .macOS(.v15), - ], - products: [ - .library(name: "OpenClawIPC", targets: ["OpenClawIPC"]), - .library(name: "OpenClawDiscovery", targets: ["OpenClawDiscovery"]), - .executable(name: "OpenClaw", targets: ["OpenClaw"]), - .executable(name: "openclaw-mac", targets: ["OpenClawMacCLI"]), - ], - dependencies: [ - .package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"), - .package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.1.0"), - .package(url: "https://github.com/apple/swift-log.git", from: "1.8.0"), - .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.8.1"), - .package(url: "https://github.com/steipete/Peekaboo.git", branch: "main"), - .package(path: "../shared/OpenClawKit"), - .package(path: "../../Swabble"), - ], - targets: [ - .target( - name: "OpenClawIPC", - dependencies: [], - swiftSettings: [ - .enableUpcomingFeature("StrictConcurrency"), - ]), - .target( - name: "OpenClawDiscovery", - dependencies: [ - .product(name: "OpenClawKit", package: "OpenClawKit"), - ], - path: "Sources/OpenClawDiscovery", - swiftSettings: [ - .enableUpcomingFeature("StrictConcurrency"), - ]), - .executableTarget( - name: "OpenClaw", - dependencies: [ - "OpenClawIPC", - "OpenClawDiscovery", - .product(name: "OpenClawKit", package: "OpenClawKit"), - .product(name: "OpenClawChatUI", package: "OpenClawKit"), - .product(name: "OpenClawProtocol", package: "OpenClawKit"), - .product(name: "SwabbleKit", package: "swabble"), - .product(name: "MenuBarExtraAccess", package: "MenuBarExtraAccess"), - .product(name: "Subprocess", package: "swift-subprocess"), - .product(name: "Logging", package: "swift-log"), - .product(name: "Sparkle", package: "Sparkle"), - .product(name: "PeekabooBridge", package: "Peekaboo"), - .product(name: "PeekabooAutomationKit", package: "Peekaboo"), - ], - exclude: [ - "Resources/Info.plist", - ], - resources: [ - .copy("Resources/OpenClaw.icns"), - .copy("Resources/DeviceModels"), - ], - swiftSettings: [ - .enableUpcomingFeature("StrictConcurrency"), - ]), - .executableTarget( - name: "OpenClawMacCLI", - dependencies: [ - "OpenClawDiscovery", - .product(name: "OpenClawKit", package: "OpenClawKit"), - .product(name: "OpenClawProtocol", package: "OpenClawKit"), - ], - path: "Sources/OpenClawMacCLI", - swiftSettings: [ - .enableUpcomingFeature("StrictConcurrency"), - ]), - .testTarget( - name: "OpenClawIPCTests", - dependencies: [ - "OpenClawIPC", - "OpenClaw", - "OpenClawDiscovery", - .product(name: "OpenClawProtocol", package: "OpenClawKit"), - .product(name: "SwabbleKit", package: "swabble"), - ], - swiftSettings: [ - .enableUpcomingFeature("StrictConcurrency"), - .enableExperimentalFeature("SwiftTesting"), - ]), - ]) diff --git a/apps/macos/README.md b/apps/macos/README.md deleted file mode 100644 index 05743dc6e2f..00000000000 --- a/apps/macos/README.md +++ /dev/null @@ -1,64 +0,0 @@ -# OpenClaw macOS app (dev + signing) - -## Quick dev run - -```bash -# from repo root -scripts/restart-mac.sh -``` - -Options: - -```bash -scripts/restart-mac.sh --no-sign # fastest dev; ad-hoc signing (TCC permissions do not stick) -scripts/restart-mac.sh --sign # force code signing (requires cert) -``` - -## Packaging flow - -```bash -scripts/package-mac-app.sh -``` - -Creates `dist/OpenClaw.app` and signs it via `scripts/codesign-mac-app.sh`. - -## Signing behavior - -Auto-selects identity (first match): -1) Developer ID Application -2) Apple Distribution -3) Apple Development -4) first available identity - -If none found: -- errors by default -- set `ALLOW_ADHOC_SIGNING=1` or `SIGN_IDENTITY="-"` to ad-hoc sign - -## Team ID audit (Sparkle mismatch guard) - -After signing, we read the app bundle Team ID and compare every Mach-O inside the app. -If any embedded binary has a different Team ID, signing fails. - -Skip the audit: -```bash -SKIP_TEAM_ID_CHECK=1 scripts/package-mac-app.sh -``` - -## Library validation workaround (dev only) - -If Sparkle Team ID mismatch blocks loading (common with Apple Development certs), opt in: - -```bash -DISABLE_LIBRARY_VALIDATION=1 scripts/package-mac-app.sh -``` - -This adds `com.apple.security.cs.disable-library-validation` to app entitlements. -Use for local dev only; keep off for release builds. - -## Useful env flags - -- `SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"` -- `ALLOW_ADHOC_SIGNING=1` (ad-hoc, TCC permissions do not persist) -- `CODESIGN_TIMESTAMP=off` (offline debug) -- `DISABLE_LIBRARY_VALIDATION=1` (dev-only Sparkle workaround) -- `SKIP_TEAM_ID_CHECK=1` (bypass audit) diff --git a/apps/macos/Sources/OpenClaw/AboutSettings.swift b/apps/macos/Sources/OpenClaw/AboutSettings.swift deleted file mode 100644 index b61cfee89a5..00000000000 --- a/apps/macos/Sources/OpenClaw/AboutSettings.swift +++ /dev/null @@ -1,199 +0,0 @@ -import SwiftUI - -struct AboutSettings: View { - weak var updater: UpdaterProviding? - @State private var iconHover = false - @AppStorage("autoUpdateEnabled") private var autoCheckEnabled = true - @State private var didLoadUpdaterState = false - - var body: some View { - VStack(spacing: 8) { - let appIcon = NSApplication.shared.applicationIconImage ?? CritterIconRenderer.makeIcon(blink: 0) - Button { - if let url = URL(string: "https://github.com/openclaw/openclaw") { - NSWorkspace.shared.open(url) - } - } label: { - Image(nsImage: appIcon) - .resizable() - .frame(width: 160, height: 160) - .cornerRadius(24) - .shadow(color: self.iconHover ? .accentColor.opacity(0.25) : .clear, radius: 10) - .scaleEffect(self.iconHover ? 1.05 : 1.0) - } - .buttonStyle(.plain) - .focusable(false) - .pointingHandCursor() - .onHover { hover in - withAnimation(.spring(response: 0.3, dampingFraction: 0.72)) { self.iconHover = hover } - } - - VStack(spacing: 3) { - Text("OpenClaw") - .font(.title3.bold()) - Text("Version \(self.versionString)") - .foregroundStyle(.secondary) - if let buildTimestamp { - Text("Built \(buildTimestamp)\(self.buildSuffix)") - .font(.footnote) - .foregroundStyle(.secondary) - } - Text("Menu bar companion for notifications, screenshots, and privileged agent actions.") - .font(.footnote) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 18) - } - - VStack(alignment: .center, spacing: 6) { - AboutLinkRow( - icon: "chevron.left.slash.chevron.right", - title: "GitHub", - url: "https://github.com/openclaw/openclaw") - AboutLinkRow(icon: "globe", title: "Website", url: "https://openclaw.ai") - AboutLinkRow(icon: "bird", title: "Twitter", url: "https://twitter.com/steipete") - AboutLinkRow(icon: "envelope", title: "Email", url: "mailto:peter@steipete.me") - } - .frame(maxWidth: .infinity) - .multilineTextAlignment(.center) - .padding(.vertical, 10) - - if let updater { - Divider() - .padding(.vertical, 8) - - if updater.isAvailable { - VStack(spacing: 10) { - Toggle("Check for updates automatically", isOn: self.$autoCheckEnabled) - .toggleStyle(.checkbox) - .frame(maxWidth: .infinity, alignment: .center) - - Button("Check for Updates…") { updater.checkForUpdates(nil) } - } - } else { - Text("Updates unavailable in this build.") - .foregroundStyle(.secondary) - .padding(.top, 4) - } - } - - Text("© 2025 Peter Steinberger — MIT License.") - .font(.footnote) - .foregroundStyle(.secondary) - .padding(.top, 4) - - Spacer() - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(.top, 4) - .padding(.horizontal, 24) - .padding(.bottom, 24) - .onAppear { - guard let updater, !self.didLoadUpdaterState else { return } - // Keep Sparkle’s auto-check setting in sync with the persisted toggle. - updater.automaticallyChecksForUpdates = self.autoCheckEnabled - updater.automaticallyDownloadsUpdates = self.autoCheckEnabled - self.didLoadUpdaterState = true - } - .onChange(of: self.autoCheckEnabled) { _, newValue in - self.updater?.automaticallyChecksForUpdates = newValue - self.updater?.automaticallyDownloadsUpdates = newValue - } - } - - private var versionString: String { - let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "dev" - let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String - return build.map { "\(version) (\($0))" } ?? version - } - - private var buildTimestamp: String? { - guard - let raw = - (Bundle.main.object(forInfoDictionaryKey: "OpenClawBuildTimestamp") as? String) ?? - (Bundle.main.object(forInfoDictionaryKey: "OpenClawBuildTimestamp") as? String) - else { return nil } - let parser = ISO8601DateFormatter() - parser.formatOptions = [.withInternetDateTime] - guard let date = parser.date(from: raw) else { return raw } - - let formatter = DateFormatter() - formatter.dateStyle = .medium - formatter.timeStyle = .short - formatter.locale = .current - return formatter.string(from: date) - } - - private var gitCommit: String { - (Bundle.main.object(forInfoDictionaryKey: "OpenClawGitCommit") as? String) ?? - (Bundle.main.object(forInfoDictionaryKey: "OpenClawGitCommit") as? String) ?? - "unknown" - } - - private var bundleID: String { - Bundle.main.bundleIdentifier ?? "unknown" - } - - private var buildSuffix: String { - let git = self.gitCommit - guard !git.isEmpty, git != "unknown" else { return "" } - - var suffix = " (\(git)" - #if DEBUG - suffix += " DEBUG" - #endif - suffix += ")" - return suffix - } -} - -@MainActor -private struct AboutLinkRow: View { - let icon: String - let title: String - let url: String - - @State private var hovering = false - - var body: some View { - Button { - if let url = URL(string: url) { NSWorkspace.shared.open(url) } - } label: { - HStack(spacing: 6) { - Image(systemName: self.icon) - Text(self.title) - .underline(self.hovering, color: .accentColor) - } - .foregroundColor(.accentColor) - } - .buttonStyle(.plain) - .onHover { self.hovering = $0 } - .pointingHandCursor() - } -} - -private struct AboutMetaRow: View { - let label: String - let value: String - - var body: some View { - HStack { - Text(self.label) - .foregroundStyle(.secondary) - Spacer() - Text(self.value) - .font(.caption.monospaced()) - .foregroundStyle(.primary) - } - } -} - -#if DEBUG -struct AboutSettings_Previews: PreviewProvider { - private static let updater = DisabledUpdaterController() - static var previews: some View { - AboutSettings(updater: updater) - .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) - } -} -#endif diff --git a/apps/macos/Sources/OpenClaw/AgeFormatting.swift b/apps/macos/Sources/OpenClaw/AgeFormatting.swift deleted file mode 100644 index 5bb46bf459d..00000000000 --- a/apps/macos/Sources/OpenClaw/AgeFormatting.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Foundation - -/// Human-friendly age string (e.g., "2m ago"). -func age(from date: Date, now: Date = .init()) -> String { - let seconds = max(0, Int(now.timeIntervalSince(date))) - let minutes = seconds / 60 - let hours = minutes / 60 - let days = hours / 24 - - if seconds < 60 { return "just now" } - if minutes == 1 { return "1 minute ago" } - if minutes < 60 { return "\(minutes)m ago" } - if hours == 1 { return "1 hour ago" } - if hours < 24 { return "\(hours)h ago" } - if days == 1 { return "yesterday" } - return "\(days)d ago" -} diff --git a/apps/macos/Sources/OpenClaw/AgentEventStore.swift b/apps/macos/Sources/OpenClaw/AgentEventStore.swift deleted file mode 100644 index 780867a32f4..00000000000 --- a/apps/macos/Sources/OpenClaw/AgentEventStore.swift +++ /dev/null @@ -1,22 +0,0 @@ -import Foundation -import Observation - -@MainActor -@Observable -final class AgentEventStore { - static let shared = AgentEventStore() - - private(set) var events: [ControlAgentEvent] = [] - private let maxEvents = 400 - - func append(_ event: ControlAgentEvent) { - self.events.append(event) - if self.events.count > self.maxEvents { - self.events.removeFirst(self.events.count - self.maxEvents) - } - } - - func clear() { - self.events.removeAll() - } -} diff --git a/apps/macos/Sources/OpenClaw/AgentEventsWindow.swift b/apps/macos/Sources/OpenClaw/AgentEventsWindow.swift deleted file mode 100644 index 673588cc379..00000000000 --- a/apps/macos/Sources/OpenClaw/AgentEventsWindow.swift +++ /dev/null @@ -1,109 +0,0 @@ -import OpenClawProtocol -import SwiftUI - -@MainActor -struct AgentEventsWindow: View { - private let store = AgentEventStore.shared - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - HStack { - Text("Agent Events") - .font(.title3.weight(.semibold)) - Spacer() - Button("Clear") { self.store.clear() } - .buttonStyle(.bordered) - } - .padding(.bottom, 4) - - ScrollView { - LazyVStack(alignment: .leading, spacing: 8) { - ForEach(self.store.events.reversed(), id: \.seq) { evt in - EventRow(event: evt) - } - } - } - } - .padding(12) - .frame(minWidth: 520, minHeight: 360) - } -} - -private struct EventRow: View { - let event: ControlAgentEvent - - var body: some View { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - Text(self.event.stream.uppercased()) - .font(.caption2.weight(.bold)) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(self.tint) - .foregroundStyle(Color.white) - .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) - Text("run " + self.event.runId) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - Spacer() - Text(self.formattedTs) - .font(.caption2) - .foregroundStyle(.secondary) - } - if let json = self.prettyJSON(event.data) { - Text(json) - .font(.caption.monospaced()) - .foregroundStyle(.primary) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 2) - } - } - .padding(8) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(Color.primary.opacity(0.04))) - } - - private var tint: Color { - switch self.event.stream { - case "job": .blue - case "tool": .orange - case "assistant": .green - default: .gray - } - } - - private var formattedTs: String { - let date = Date(timeIntervalSince1970: event.ts / 1000) - let f = DateFormatter() - f.dateFormat = "HH:mm:ss.SSS" - return f.string(from: date) - } - - private func prettyJSON(_ dict: [String: OpenClawProtocol.AnyCodable]) -> String? { - let normalized = dict.mapValues { $0.value } - guard JSONSerialization.isValidJSONObject(normalized), - let data = try? JSONSerialization.data(withJSONObject: normalized, options: [.prettyPrinted]), - let str = String(data: data, encoding: .utf8) - else { return nil } - return str - } -} - -struct AgentEventsWindow_Previews: PreviewProvider { - static var previews: some View { - let sample = ControlAgentEvent( - runId: "abc", - seq: 1, - stream: "tool", - ts: Date().timeIntervalSince1970 * 1000, - data: [ - "phase": OpenClawProtocol.AnyCodable("start"), - "name": OpenClawProtocol.AnyCodable("bash"), - ], - summary: nil) - AgentEventStore.shared.append(sample) - return AgentEventsWindow() - } -} diff --git a/apps/macos/Sources/OpenClaw/AgentWorkspace.swift b/apps/macos/Sources/OpenClaw/AgentWorkspace.swift deleted file mode 100644 index 57164ebb892..00000000000 --- a/apps/macos/Sources/OpenClaw/AgentWorkspace.swift +++ /dev/null @@ -1,340 +0,0 @@ -import Foundation -import OSLog - -enum AgentWorkspace { - private static let logger = Logger(subsystem: "ai.openclaw", category: "workspace") - static let agentsFilename = "AGENTS.md" - static let soulFilename = "SOUL.md" - static let identityFilename = "IDENTITY.md" - static let userFilename = "USER.md" - static let bootstrapFilename = "BOOTSTRAP.md" - private static let templateDirname = "templates" - private static let ignoredEntries: Set = [".DS_Store", ".git", ".gitignore"] - private static let templateEntries: Set = [ - AgentWorkspace.agentsFilename, - AgentWorkspace.soulFilename, - AgentWorkspace.identityFilename, - AgentWorkspace.userFilename, - AgentWorkspace.bootstrapFilename, - ] - enum BootstrapSafety: Equatable { - case safe - case unsafe (reason: String) - } - - static func displayPath(for url: URL) -> String { - let home = FileManager().homeDirectoryForCurrentUser.path - let path = url.path - if path == home { return "~" } - if path.hasPrefix(home + "/") { - return "~/" + String(path.dropFirst(home.count + 1)) - } - return path - } - - static func resolveWorkspaceURL(from userInput: String?) -> URL { - let trimmed = userInput?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if trimmed.isEmpty { return OpenClawConfigFile.defaultWorkspaceURL() } - let expanded = (trimmed as NSString).expandingTildeInPath - return URL(fileURLWithPath: expanded, isDirectory: true) - } - - static func agentsURL(workspaceURL: URL) -> URL { - workspaceURL.appendingPathComponent(self.agentsFilename) - } - - static func workspaceEntries(workspaceURL: URL) throws -> [String] { - let contents = try FileManager().contentsOfDirectory(atPath: workspaceURL.path) - return contents.filter { !self.ignoredEntries.contains($0) } - } - - static func isWorkspaceEmpty(workspaceURL: URL) -> Bool { - let fm = FileManager() - var isDir: ObjCBool = false - if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) { - return true - } - guard isDir.boolValue else { return false } - guard let entries = try? self.workspaceEntries(workspaceURL: workspaceURL) else { return false } - return entries.isEmpty - } - - static func isTemplateOnlyWorkspace(workspaceURL: URL) -> Bool { - guard let entries = try? self.workspaceEntries(workspaceURL: workspaceURL) else { return false } - guard !entries.isEmpty else { return true } - return Set(entries).isSubset(of: self.templateEntries) - } - - static func bootstrapSafety(for workspaceURL: URL) -> BootstrapSafety { - let fm = FileManager() - var isDir: ObjCBool = false - if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) { - return .safe - } - if !isDir.boolValue { - return .unsafe (reason: "Workspace path points to a file.") - } - let agentsURL = self.agentsURL(workspaceURL: workspaceURL) - if fm.fileExists(atPath: agentsURL.path) { - return .safe - } - do { - let entries = try self.workspaceEntries(workspaceURL: workspaceURL) - return entries.isEmpty - ? .safe - : .unsafe (reason: "Folder isn't empty. Choose a new folder or add AGENTS.md first.") - } catch { - return .unsafe (reason: "Couldn't inspect the workspace folder.") - } - } - - static func bootstrap(workspaceURL: URL) throws -> URL { - let shouldSeedBootstrap = self.isWorkspaceEmpty(workspaceURL: workspaceURL) - try FileManager().createDirectory(at: workspaceURL, withIntermediateDirectories: true) - let agentsURL = self.agentsURL(workspaceURL: workspaceURL) - if !FileManager().fileExists(atPath: agentsURL.path) { - try self.defaultTemplate().write(to: agentsURL, atomically: true, encoding: .utf8) - self.logger.info("Created AGENTS.md at \(agentsURL.path, privacy: .public)") - } - let soulURL = workspaceURL.appendingPathComponent(self.soulFilename) - if !FileManager().fileExists(atPath: soulURL.path) { - try self.defaultSoulTemplate().write(to: soulURL, atomically: true, encoding: .utf8) - self.logger.info("Created SOUL.md at \(soulURL.path, privacy: .public)") - } - let identityURL = workspaceURL.appendingPathComponent(self.identityFilename) - if !FileManager().fileExists(atPath: identityURL.path) { - try self.defaultIdentityTemplate().write(to: identityURL, atomically: true, encoding: .utf8) - self.logger.info("Created IDENTITY.md at \(identityURL.path, privacy: .public)") - } - let userURL = workspaceURL.appendingPathComponent(self.userFilename) - if !FileManager().fileExists(atPath: userURL.path) { - try self.defaultUserTemplate().write(to: userURL, atomically: true, encoding: .utf8) - self.logger.info("Created USER.md at \(userURL.path, privacy: .public)") - } - let bootstrapURL = workspaceURL.appendingPathComponent(self.bootstrapFilename) - if shouldSeedBootstrap, !FileManager().fileExists(atPath: bootstrapURL.path) { - try self.defaultBootstrapTemplate().write(to: bootstrapURL, atomically: true, encoding: .utf8) - self.logger.info("Created BOOTSTRAP.md at \(bootstrapURL.path, privacy: .public)") - } - return agentsURL - } - - static func needsBootstrap(workspaceURL: URL) -> Bool { - let fm = FileManager() - var isDir: ObjCBool = false - if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) { - return true - } - guard isDir.boolValue else { return true } - if self.hasIdentity(workspaceURL: workspaceURL) { - return false - } - let bootstrapURL = workspaceURL.appendingPathComponent(self.bootstrapFilename) - guard fm.fileExists(atPath: bootstrapURL.path) else { return false } - return self.isTemplateOnlyWorkspace(workspaceURL: workspaceURL) - } - - static func hasIdentity(workspaceURL: URL) -> Bool { - let identityURL = workspaceURL.appendingPathComponent(self.identityFilename) - guard let contents = try? String(contentsOf: identityURL, encoding: .utf8) else { return false } - return self.identityLinesHaveValues(contents) - } - - private static func identityLinesHaveValues(_ content: String) -> Bool { - for line in content.split(separator: "\n") { - let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.hasPrefix("-"), let colon = trimmed.firstIndex(of: ":") else { continue } - let value = trimmed[trimmed.index(after: colon)...].trimmingCharacters(in: .whitespacesAndNewlines) - if !value.isEmpty { - return true - } - } - return false - } - - static func defaultTemplate() -> String { - let fallback = """ - # AGENTS.md - OpenClaw Workspace - - This folder is the assistant's working directory. - - ## First run (one-time) - - If BOOTSTRAP.md exists, follow its ritual and delete it once complete. - - Your agent identity lives in IDENTITY.md. - - Your profile lives in USER.md. - - ## Backup tip (recommended) - If you treat this workspace as the agent's "memory", make it a git repo (ideally private) so identity - and notes are backed up. - - ```bash - git init - git add AGENTS.md - git commit -m "Add agent workspace" - ``` - - ## Safety defaults - - Don't exfiltrate secrets or private data. - - Don't run destructive commands unless explicitly asked. - - Be concise in chat; write longer output to files in this workspace. - - ## Daily memory (recommended) - - Keep a short daily log at memory/YYYY-MM-DD.md (create memory/ if needed). - - On session start, read today + yesterday if present. - - Capture durable facts, preferences, and decisions; avoid secrets. - - ## Customize - - Add your preferred style, rules, and "memory" here. - """ - return self.loadTemplate(named: self.agentsFilename, fallback: fallback) - } - - static func defaultSoulTemplate() -> String { - let fallback = """ - # SOUL.md - Persona & Boundaries - - Describe who the assistant is, tone, and boundaries. - - - Keep replies concise and direct. - - Ask clarifying questions when needed. - - Never send streaming/partial replies to external messaging surfaces. - """ - return self.loadTemplate(named: self.soulFilename, fallback: fallback) - } - - static func defaultIdentityTemplate() -> String { - let fallback = """ - # IDENTITY.md - Agent Identity - - - Name: - - Creature: - - Vibe: - - Emoji: - """ - return self.loadTemplate(named: self.identityFilename, fallback: fallback) - } - - static func defaultUserTemplate() -> String { - let fallback = """ - # USER.md - User Profile - - - Name: - - Preferred address: - - Pronouns (optional): - - Timezone (optional): - - Notes: - """ - return self.loadTemplate(named: self.userFilename, fallback: fallback) - } - - static func defaultBootstrapTemplate() -> String { - let fallback = """ - # BOOTSTRAP.md - First Run Ritual (delete after) - - Hello. I was just born. - - ## Your mission - Start a short, playful conversation and learn: - - Who am I? - - What am I? - - Who are you? - - How should I call you? - - ## How to ask (cute + helpful) - Say: - "Hello! I was just born. Who am I? What am I? Who are you? How should I call you?" - - Then offer suggestions: - - 3-5 name ideas. - - 3-5 creature/vibe combos. - - 5 emoji ideas. - - ## Write these files - After the user chooses, update: - - 1) IDENTITY.md - - Name - - Creature - - Vibe - - Emoji - - 2) USER.md - - Name - - Preferred address - - Pronouns (optional) - - Timezone (optional) - - Notes - - 3) ~/.openclaw/openclaw.json - Set identity.name, identity.theme, identity.emoji to match IDENTITY.md. - - ## Cleanup - Delete BOOTSTRAP.md once this is complete. - """ - return self.loadTemplate(named: self.bootstrapFilename, fallback: fallback) - } - - private static func loadTemplate(named: String, fallback: String) -> String { - for url in self.templateURLs(named: named) { - if let content = try? String(contentsOf: url, encoding: .utf8) { - let stripped = self.stripFrontMatter(content) - if !stripped.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return stripped - } - } - } - return fallback - } - - private static func templateURLs(named: String) -> [URL] { - var urls: [URL] = [] - if let resource = Bundle.main.url( - forResource: named.replacingOccurrences(of: ".md", with: ""), - withExtension: "md", - subdirectory: self.templateDirname) - { - urls.append(resource) - } - if let resource = Bundle.main.url( - forResource: named, - withExtension: nil, - subdirectory: self.templateDirname) - { - urls.append(resource) - } - if let dev = self.devTemplateURL(named: named) { - urls.append(dev) - } - let cwd = URL(fileURLWithPath: FileManager().currentDirectoryPath) - urls.append(cwd.appendingPathComponent("docs") - .appendingPathComponent(self.templateDirname) - .appendingPathComponent(named)) - return urls - } - - private static func devTemplateURL(named: String) -> URL? { - let sourceURL = URL(fileURLWithPath: #filePath) - let repoRoot = sourceURL - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - return repoRoot.appendingPathComponent("docs") - .appendingPathComponent(self.templateDirname) - .appendingPathComponent(named) - } - - private static func stripFrontMatter(_ content: String) -> String { - guard content.hasPrefix("---") else { return content } - let start = content.index(content.startIndex, offsetBy: 3) - guard let range = content.range(of: "\n---", range: start.. = { - if ProcessInfo.processInfo.isRunningTests { - return Empty(completeImmediately: false).eraseToAnyPublisher() - } - return Timer.publish(every: 0.4, on: .main, in: .common) - .autoconnect() - .eraseToAnyPublisher() - }() - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - if self.connectionMode != .local { - Text("Gateway isn’t running locally; OAuth must be created on the gateway host.") - .font(.footnote) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - HStack(spacing: 10) { - Circle() - .fill(self.oauthStatus.isConnected ? Color.green : Color.orange) - .frame(width: 8, height: 8) - Text(self.oauthStatus.shortDescription) - .font(.footnote.weight(.semibold)) - .foregroundStyle(.secondary) - Spacer() - Button("Reveal") { - NSWorkspace.shared.activateFileViewerSelecting([OpenClawOAuthStore.oauthURL()]) - } - .buttonStyle(.bordered) - .disabled(!FileManager().fileExists(atPath: OpenClawOAuthStore.oauthURL().path)) - - Button("Refresh") { - self.refresh() - } - .buttonStyle(.bordered) - } - - Text(OpenClawOAuthStore.oauthURL().path) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - .textSelection(.enabled) - - HStack(spacing: 12) { - Button { - self.startOAuth() - } label: { - if self.busy { - ProgressView().controlSize(.small) - } else { - Text(self.oauthStatus.isConnected ? "Re-auth (OAuth)" : "Open sign-in (OAuth)") - } - } - .buttonStyle(.borderedProminent) - .disabled(self.connectionMode != .local || self.busy) - - if self.pkce != nil { - Button("Cancel") { - self.pkce = nil - self.code = "" - self.statusText = nil - } - .buttonStyle(.bordered) - .disabled(self.busy) - } - } - - if self.pkce != nil { - VStack(alignment: .leading, spacing: 8) { - Text("Paste `code#state`") - .font(.footnote.weight(.semibold)) - .foregroundStyle(.secondary) - - TextField("code#state", text: self.$code) - .textFieldStyle(.roundedBorder) - .disabled(self.busy) - - Toggle("Auto-detect from clipboard", isOn: self.$autoDetectClipboard) - .font(.footnote) - .foregroundStyle(.secondary) - .disabled(self.busy) - - Toggle("Auto-connect when detected", isOn: self.$autoConnectClipboard) - .font(.footnote) - .foregroundStyle(.secondary) - .disabled(self.busy) - - Button("Connect") { - Task { await self.finishOAuth() } - } - .buttonStyle(.bordered) - .disabled(self.busy || self.connectionMode != .local || self.code - .trimmingCharacters(in: .whitespacesAndNewlines) - .isEmpty) - } - } - - if let statusText, !statusText.isEmpty { - Text(statusText) - .font(.footnote) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - .onAppear { - self.refresh() - } - .onReceive(Self.clipboardPoll) { _ in - self.pollClipboardIfNeeded() - } - } - - private func refresh() { - let imported = OpenClawOAuthStore.importLegacyAnthropicOAuthIfNeeded() - self.oauthStatus = OpenClawOAuthStore.anthropicOAuthStatus() - if imported != nil { - self.statusText = "Imported existing OAuth credentials." - } - } - - private func startOAuth() { - guard self.connectionMode == .local else { return } - guard !self.busy else { return } - self.busy = true - defer { self.busy = false } - - do { - let pkce = try AnthropicOAuth.generatePKCE() - self.pkce = pkce - let url = AnthropicOAuth.buildAuthorizeURL(pkce: pkce) - NSWorkspace.shared.open(url) - self.statusText = "Browser opened. After approving, paste the `code#state` value here." - } catch { - self.statusText = "Failed to start OAuth: \(error.localizedDescription)" - } - } - - @MainActor - private func finishOAuth() async { - guard self.connectionMode == .local else { return } - guard !self.busy else { return } - guard let pkce = self.pkce else { return } - self.busy = true - defer { self.busy = false } - - guard let parsed = AnthropicOAuthCodeState.parse(from: self.code) else { - self.statusText = "OAuth failed: missing or invalid code/state." - return - } - - do { - let creds = try await AnthropicOAuth.exchangeCode( - code: parsed.code, - state: parsed.state, - verifier: pkce.verifier) - try OpenClawOAuthStore.saveAnthropicOAuth(creds) - self.refresh() - self.pkce = nil - self.code = "" - self.statusText = "Connected. OpenClaw can now use Claude via OAuth." - } catch { - self.statusText = "OAuth failed: \(error.localizedDescription)" - } - } - - private func pollClipboardIfNeeded() { - guard self.connectionMode == .local else { return } - guard self.pkce != nil else { return } - guard !self.busy else { return } - guard self.autoDetectClipboard else { return } - - let pb = NSPasteboard.general - let changeCount = pb.changeCount - guard changeCount != self.lastPasteboardChangeCount else { return } - self.lastPasteboardChangeCount = changeCount - - guard let raw = pb.string(forType: .string), !raw.isEmpty else { return } - guard let parsed = AnthropicOAuthCodeState.parse(from: raw) else { return } - guard let pkce = self.pkce, parsed.state == pkce.verifier else { return } - - let next = "\(parsed.code)#\(parsed.state)" - if self.code != next { - self.code = next - self.statusText = "Detected `code#state` from clipboard." - } - - guard self.autoConnectClipboard else { return } - Task { await self.finishOAuth() } - } -} - -#if DEBUG -extension AnthropicAuthControls { - init( - connectionMode: AppState.ConnectionMode, - oauthStatus: OpenClawOAuthStore.AnthropicOAuthStatus, - pkce: AnthropicOAuth.PKCE? = nil, - code: String = "", - busy: Bool = false, - statusText: String? = nil, - autoDetectClipboard: Bool = true, - autoConnectClipboard: Bool = true) - { - self.connectionMode = connectionMode - self._oauthStatus = State(initialValue: oauthStatus) - self._pkce = State(initialValue: pkce) - self._code = State(initialValue: code) - self._busy = State(initialValue: busy) - self._statusText = State(initialValue: statusText) - self._autoDetectClipboard = State(initialValue: autoDetectClipboard) - self._autoConnectClipboard = State(initialValue: autoConnectClipboard) - self._lastPasteboardChangeCount = State(initialValue: NSPasteboard.general.changeCount) - } -} -#endif diff --git a/apps/macos/Sources/OpenClaw/AnthropicOAuth.swift b/apps/macos/Sources/OpenClaw/AnthropicOAuth.swift deleted file mode 100644 index f594cc04c31..00000000000 --- a/apps/macos/Sources/OpenClaw/AnthropicOAuth.swift +++ /dev/null @@ -1,383 +0,0 @@ -import CryptoKit -import Foundation -import OSLog -import Security - -struct AnthropicOAuthCredentials: Codable { - let type: String - let refresh: String - let access: String - let expires: Int64 -} - -enum AnthropicAuthMode: Equatable { - case oauthFile - case oauthEnv - case apiKeyEnv - case missing - - var shortLabel: String { - switch self { - case .oauthFile: "OAuth (OpenClaw token file)" - case .oauthEnv: "OAuth (env var)" - case .apiKeyEnv: "API key (env var)" - case .missing: "Missing credentials" - } - } - - var isConfigured: Bool { - switch self { - case .missing: false - case .oauthFile, .oauthEnv, .apiKeyEnv: true - } - } -} - -enum AnthropicAuthResolver { - static func resolve( - environment: [String: String] = ProcessInfo.processInfo.environment, - oauthStatus: OpenClawOAuthStore.AnthropicOAuthStatus = OpenClawOAuthStore - .anthropicOAuthStatus()) -> AnthropicAuthMode - { - if oauthStatus.isConnected { return .oauthFile } - - if let token = environment["ANTHROPIC_OAUTH_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines), - !token.isEmpty - { - return .oauthEnv - } - - if let key = environment["ANTHROPIC_API_KEY"]?.trimmingCharacters(in: .whitespacesAndNewlines), - !key.isEmpty - { - return .apiKeyEnv - } - - return .missing - } -} - -enum AnthropicOAuth { - private static let logger = Logger(subsystem: "ai.openclaw", category: "anthropic-oauth") - - private static let clientId = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" - private static let authorizeURL = URL(string: "https://claude.ai/oauth/authorize")! - private static let tokenURL = URL(string: "https://console.anthropic.com/v1/oauth/token")! - private static let redirectURI = "https://console.anthropic.com/oauth/code/callback" - private static let scopes = "org:create_api_key user:profile user:inference" - - struct PKCE { - let verifier: String - let challenge: String - } - - static func generatePKCE() throws -> PKCE { - var bytes = [UInt8](repeating: 0, count: 32) - let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) - guard status == errSecSuccess else { - throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) - } - let verifier = Data(bytes).base64URLEncodedString() - let hash = SHA256.hash(data: Data(verifier.utf8)) - let challenge = Data(hash).base64URLEncodedString() - return PKCE(verifier: verifier, challenge: challenge) - } - - static func buildAuthorizeURL(pkce: PKCE) -> URL { - var components = URLComponents(url: self.authorizeURL, resolvingAgainstBaseURL: false)! - components.queryItems = [ - URLQueryItem(name: "code", value: "true"), - URLQueryItem(name: "client_id", value: self.clientId), - URLQueryItem(name: "response_type", value: "code"), - URLQueryItem(name: "redirect_uri", value: self.redirectURI), - URLQueryItem(name: "scope", value: self.scopes), - URLQueryItem(name: "code_challenge", value: pkce.challenge), - URLQueryItem(name: "code_challenge_method", value: "S256"), - // Match legacy flow: state is the verifier. - URLQueryItem(name: "state", value: pkce.verifier), - ] - return components.url! - } - - static func exchangeCode( - code: String, - state: String, - verifier: String) async throws -> AnthropicOAuthCredentials - { - let payload: [String: Any] = [ - "grant_type": "authorization_code", - "client_id": self.clientId, - "code": code, - "state": state, - "redirect_uri": self.redirectURI, - "code_verifier": verifier, - ] - let body = try JSONSerialization.data(withJSONObject: payload, options: []) - - var request = URLRequest(url: self.tokenURL) - request.httpMethod = "POST" - request.httpBody = body - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - let (data, response) = try await URLSession.shared.data(for: request) - guard let http = response as? HTTPURLResponse else { - throw URLError(.badServerResponse) - } - guard (200..<300).contains(http.statusCode) else { - let text = String(data: data, encoding: .utf8) ?? "" - throw NSError( - domain: "AnthropicOAuth", - code: http.statusCode, - userInfo: [NSLocalizedDescriptionKey: "Token exchange failed: \(text)"]) - } - - let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any] - let access = decoded?["access_token"] as? String - let refresh = decoded?["refresh_token"] as? String - let expiresIn = decoded?["expires_in"] as? Double - guard let access, let refresh, let expiresIn else { - throw NSError(domain: "AnthropicOAuth", code: 0, userInfo: [ - NSLocalizedDescriptionKey: "Unexpected token response.", - ]) - } - - // Match legacy flow: expiresAt = now + expires_in - 5 minutes. - let expiresAtMs = Int64(Date().timeIntervalSince1970 * 1000) - + Int64(expiresIn * 1000) - - Int64(5 * 60 * 1000) - - self.logger.info("Anthropic OAuth exchange ok; expiresAtMs=\(expiresAtMs, privacy: .public)") - return AnthropicOAuthCredentials(type: "oauth", refresh: refresh, access: access, expires: expiresAtMs) - } - - static func refresh(refreshToken: String) async throws -> AnthropicOAuthCredentials { - let payload: [String: Any] = [ - "grant_type": "refresh_token", - "client_id": self.clientId, - "refresh_token": refreshToken, - ] - let body = try JSONSerialization.data(withJSONObject: payload, options: []) - - var request = URLRequest(url: self.tokenURL) - request.httpMethod = "POST" - request.httpBody = body - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - let (data, response) = try await URLSession.shared.data(for: request) - guard let http = response as? HTTPURLResponse else { - throw URLError(.badServerResponse) - } - guard (200..<300).contains(http.statusCode) else { - let text = String(data: data, encoding: .utf8) ?? "" - throw NSError( - domain: "AnthropicOAuth", - code: http.statusCode, - userInfo: [NSLocalizedDescriptionKey: "Token refresh failed: \(text)"]) - } - - let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any] - let access = decoded?["access_token"] as? String - let refresh = (decoded?["refresh_token"] as? String) ?? refreshToken - let expiresIn = decoded?["expires_in"] as? Double - guard let access, let expiresIn else { - throw NSError(domain: "AnthropicOAuth", code: 0, userInfo: [ - NSLocalizedDescriptionKey: "Unexpected token response.", - ]) - } - - let expiresAtMs = Int64(Date().timeIntervalSince1970 * 1000) - + Int64(expiresIn * 1000) - - Int64(5 * 60 * 1000) - - self.logger.info("Anthropic OAuth refresh ok; expiresAtMs=\(expiresAtMs, privacy: .public)") - return AnthropicOAuthCredentials(type: "oauth", refresh: refresh, access: access, expires: expiresAtMs) - } -} - -enum OpenClawOAuthStore { - static let oauthFilename = "oauth.json" - private static let providerKey = "anthropic" - private static let openclawOAuthDirEnv = "OPENCLAW_OAUTH_DIR" - private static let legacyPiDirEnv = "PI_CODING_AGENT_DIR" - - enum AnthropicOAuthStatus: Equatable { - case missingFile - case unreadableFile - case invalidJSON - case missingProviderEntry - case missingTokens - case connected(expiresAtMs: Int64?) - - var isConnected: Bool { - if case .connected = self { return true } - return false - } - - var shortDescription: String { - switch self { - case .missingFile: "OpenClaw OAuth token file not found" - case .unreadableFile: "OpenClaw OAuth token file not readable" - case .invalidJSON: "OpenClaw OAuth token file invalid" - case .missingProviderEntry: "No Anthropic entry in OpenClaw OAuth token file" - case .missingTokens: "Anthropic entry missing tokens" - case .connected: "OpenClaw OAuth credentials found" - } - } - } - - static func oauthDir() -> URL { - if let override = ProcessInfo.processInfo.environment[self.openclawOAuthDirEnv]? - .trimmingCharacters(in: .whitespacesAndNewlines), - !override.isEmpty - { - let expanded = NSString(string: override).expandingTildeInPath - return URL(fileURLWithPath: expanded, isDirectory: true) - } - let home = FileManager().homeDirectoryForCurrentUser - return home.appendingPathComponent(".openclaw", isDirectory: true) - .appendingPathComponent("credentials", isDirectory: true) - } - - static func oauthURL() -> URL { - self.oauthDir().appendingPathComponent(self.oauthFilename) - } - - static func legacyOAuthURLs() -> [URL] { - var urls: [URL] = [] - let env = ProcessInfo.processInfo.environment - if let override = env[self.legacyPiDirEnv]?.trimmingCharacters(in: .whitespacesAndNewlines), - !override.isEmpty - { - let expanded = NSString(string: override).expandingTildeInPath - urls.append(URL(fileURLWithPath: expanded, isDirectory: true).appendingPathComponent(self.oauthFilename)) - } - - let home = FileManager().homeDirectoryForCurrentUser - urls.append(home.appendingPathComponent(".pi/agent/\(self.oauthFilename)")) - urls.append(home.appendingPathComponent(".claude/\(self.oauthFilename)")) - urls.append(home.appendingPathComponent(".config/claude/\(self.oauthFilename)")) - urls.append(home.appendingPathComponent(".config/anthropic/\(self.oauthFilename)")) - - var seen = Set() - return urls.filter { url in - let path = url.standardizedFileURL.path - if seen.contains(path) { return false } - seen.insert(path) - return true - } - } - - static func importLegacyAnthropicOAuthIfNeeded() -> URL? { - let dest = self.oauthURL() - guard !FileManager().fileExists(atPath: dest.path) else { return nil } - - for url in self.legacyOAuthURLs() { - guard FileManager().fileExists(atPath: url.path) else { continue } - guard self.anthropicOAuthStatus(at: url).isConnected else { continue } - guard let storage = self.loadStorage(at: url) else { continue } - do { - try self.saveStorage(storage) - return url - } catch { - continue - } - } - - return nil - } - - static func anthropicOAuthStatus() -> AnthropicOAuthStatus { - self.anthropicOAuthStatus(at: self.oauthURL()) - } - - static func hasAnthropicOAuth() -> Bool { - self.anthropicOAuthStatus().isConnected - } - - static func anthropicOAuthStatus(at url: URL) -> AnthropicOAuthStatus { - guard FileManager().fileExists(atPath: url.path) else { return .missingFile } - - guard let data = try? Data(contentsOf: url) else { return .unreadableFile } - guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return .invalidJSON } - guard let storage = json as? [String: Any] else { return .invalidJSON } - guard let rawEntry = storage[self.providerKey] else { return .missingProviderEntry } - guard let entry = rawEntry as? [String: Any] else { return .invalidJSON } - - let refresh = self.firstString(in: entry, keys: ["refresh", "refresh_token", "refreshToken"]) - let access = self.firstString(in: entry, keys: ["access", "access_token", "accessToken"]) - guard refresh?.isEmpty == false, access?.isEmpty == false else { return .missingTokens } - - let expiresAny = entry["expires"] ?? entry["expires_at"] ?? entry["expiresAt"] - let expiresAtMs: Int64? = if let ms = expiresAny as? Int64 { - ms - } else if let number = expiresAny as? NSNumber { - number.int64Value - } else if let ms = expiresAny as? Double { - Int64(ms) - } else { - nil - } - - return .connected(expiresAtMs: expiresAtMs) - } - - static func loadAnthropicOAuthRefreshToken() -> String? { - let url = self.oauthURL() - guard let storage = self.loadStorage(at: url) else { return nil } - guard let rawEntry = storage[self.providerKey] as? [String: Any] else { return nil } - let refresh = self.firstString(in: rawEntry, keys: ["refresh", "refresh_token", "refreshToken"]) - return refresh?.trimmingCharacters(in: .whitespacesAndNewlines) - } - - private static func firstString(in dict: [String: Any], keys: [String]) -> String? { - for key in keys { - if let value = dict[key] as? String { return value } - } - return nil - } - - private static func loadStorage(at url: URL) -> [String: Any]? { - guard let data = try? Data(contentsOf: url) else { return nil } - guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return nil } - return json as? [String: Any] - } - - static func saveAnthropicOAuth(_ creds: AnthropicOAuthCredentials) throws { - let url = self.oauthURL() - let existing: [String: Any] = self.loadStorage(at: url) ?? [:] - - var updated = existing - updated[self.providerKey] = [ - "type": creds.type, - "refresh": creds.refresh, - "access": creds.access, - "expires": creds.expires, - ] - - try self.saveStorage(updated) - } - - private static func saveStorage(_ storage: [String: Any]) throws { - let dir = self.oauthDir() - try FileManager().createDirectory( - at: dir, - withIntermediateDirectories: true, - attributes: [.posixPermissions: 0o700]) - - let url = self.oauthURL() - let data = try JSONSerialization.data( - withJSONObject: storage, - options: [.prettyPrinted, .sortedKeys]) - try data.write(to: url, options: [.atomic]) - try FileManager().setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) - } -} - -extension Data { - fileprivate func base64URLEncodedString() -> String { - self.base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } -} diff --git a/apps/macos/Sources/OpenClaw/AnthropicOAuthCodeState.swift b/apps/macos/Sources/OpenClaw/AnthropicOAuthCodeState.swift deleted file mode 100644 index 2a88898c34d..00000000000 --- a/apps/macos/Sources/OpenClaw/AnthropicOAuthCodeState.swift +++ /dev/null @@ -1,59 +0,0 @@ -import Foundation - -enum AnthropicOAuthCodeState { - struct Parsed: Equatable { - let code: String - let state: String - } - - /// Extracts a `code#state` payload from arbitrary text. - /// - /// Supports: - /// - raw `code#state` - /// - OAuth callback URLs containing `code=` and `state=` query params - /// - surrounding text/backticks from instructions pages - static func extract(from raw: String) -> String? { - let text = raw.trimmingCharacters(in: .whitespacesAndNewlines) - .trimmingCharacters(in: CharacterSet(charactersIn: "`")) - if text.isEmpty { return nil } - - if let fromURL = self.extractFromURL(text) { return fromURL } - if let fromToken = self.extractFromToken(text) { return fromToken } - return nil - } - - static func parse(from raw: String) -> Parsed? { - guard let extracted = self.extract(from: raw) else { return nil } - let parts = extracted.split(separator: "#", maxSplits: 1).map(String.init) - let code = parts.first ?? "" - let state = parts.count > 1 ? parts[1] : "" - guard !code.isEmpty, !state.isEmpty else { return nil } - return Parsed(code: code, state: state) - } - - private static func extractFromURL(_ text: String) -> String? { - // Users might copy the callback URL from the browser address bar. - guard let components = URLComponents(string: text), - let items = components.queryItems, - let code = items.first(where: { $0.name == "code" })?.value, - let state = items.first(where: { $0.name == "state" })?.value, - !code.isEmpty, !state.isEmpty - else { return nil } - - return "\(code)#\(state)" - } - - private static func extractFromToken(_ text: String) -> String? { - // Base64url-ish tokens; keep this fairly strict to avoid false positives. - let pattern = #"([A-Za-z0-9._~-]{8,})#([A-Za-z0-9._~-]{8,})"# - guard let re = try? NSRegularExpression(pattern: pattern) else { return nil } - - let range = NSRange(text.startIndex..? - - private func ifNotPreview(_ action: () -> Void) { - guard !self.isPreview else { return } - action() - } - - enum ConnectionMode: String { - case unconfigured - case local - case remote - } - - enum RemoteTransport: String { - case ssh - case direct - } - - var isPaused: Bool { - didSet { self.ifNotPreview { UserDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } } - } - - var launchAtLogin: Bool { - didSet { - guard !self.isInitializing else { return } - self.ifNotPreview { Task { AppStateStore.updateLaunchAtLogin(enabled: self.launchAtLogin) } } - } - } - - var onboardingSeen: Bool { - didSet { self.ifNotPreview { UserDefaults.standard.set(self.onboardingSeen, forKey: onboardingSeenKey) } - } - } - - var debugPaneEnabled: Bool { - didSet { - self.ifNotPreview { UserDefaults.standard.set(self.debugPaneEnabled, forKey: debugPaneEnabledKey) } - CanvasManager.shared.refreshDebugStatus() - } - } - - var swabbleEnabled: Bool { - didSet { - self.ifNotPreview { - UserDefaults.standard.set(self.swabbleEnabled, forKey: swabbleEnabledKey) - Task { await VoiceWakeRuntime.shared.refresh(state: self) } - } - } - } - - var swabbleTriggerWords: [String] { - didSet { - // Preserve the raw editing state; sanitization happens when we actually use the triggers. - self.ifNotPreview { - UserDefaults.standard.set(self.swabbleTriggerWords, forKey: swabbleTriggersKey) - if self.swabbleEnabled { - Task { await VoiceWakeRuntime.shared.refresh(state: self) } - } - self.scheduleVoiceWakeGlobalSyncIfNeeded() - } - } - } - - var voiceWakeTriggerChime: VoiceWakeChime { - didSet { self.ifNotPreview { self.storeChime(self.voiceWakeTriggerChime, key: voiceWakeTriggerChimeKey) } } - } - - var voiceWakeSendChime: VoiceWakeChime { - didSet { self.ifNotPreview { self.storeChime(self.voiceWakeSendChime, key: voiceWakeSendChimeKey) } } - } - - var iconAnimationsEnabled: Bool { - didSet { self.ifNotPreview { UserDefaults.standard.set( - self.iconAnimationsEnabled, - forKey: iconAnimationsEnabledKey) } } - } - - var showDockIcon: Bool { - didSet { - self.ifNotPreview { - UserDefaults.standard.set(self.showDockIcon, forKey: showDockIconKey) - AppActivationPolicy.apply(showDockIcon: self.showDockIcon) - } - } - } - - var voiceWakeMicID: String { - didSet { - self.ifNotPreview { - UserDefaults.standard.set(self.voiceWakeMicID, forKey: voiceWakeMicKey) - if self.swabbleEnabled { - Task { await VoiceWakeRuntime.shared.refresh(state: self) } - } - } - } - } - - var voiceWakeMicName: String { - didSet { self.ifNotPreview { UserDefaults.standard.set(self.voiceWakeMicName, forKey: voiceWakeMicNameKey) } } - } - - var voiceWakeLocaleID: String { - didSet { - self.ifNotPreview { - UserDefaults.standard.set(self.voiceWakeLocaleID, forKey: voiceWakeLocaleKey) - if self.swabbleEnabled { - Task { await VoiceWakeRuntime.shared.refresh(state: self) } - } - } - } - } - - var voiceWakeAdditionalLocaleIDs: [String] { - didSet { self.ifNotPreview { UserDefaults.standard.set( - self.voiceWakeAdditionalLocaleIDs, - forKey: voiceWakeAdditionalLocalesKey) } } - } - - var voicePushToTalkEnabled: Bool { - didSet { self.ifNotPreview { UserDefaults.standard.set( - self.voicePushToTalkEnabled, - forKey: voicePushToTalkEnabledKey) } } - } - - var talkEnabled: Bool { - didSet { - self.ifNotPreview { - UserDefaults.standard.set(self.talkEnabled, forKey: talkEnabledKey) - Task { await TalkModeController.shared.setEnabled(self.talkEnabled) } - } - } - } - - /// Gateway-provided UI accent color (hex). Optional; clients provide a default. - var seamColorHex: String? - - var iconOverride: IconOverrideSelection { - didSet { self.ifNotPreview { UserDefaults.standard.set(self.iconOverride.rawValue, forKey: iconOverrideKey) } } - } - - var isWorking: Bool = false - var earBoostActive: Bool = false - var blinkTick: Int = 0 - var sendCelebrationTick: Int = 0 - var heartbeatsEnabled: Bool { - didSet { - self.ifNotPreview { - UserDefaults.standard.set(self.heartbeatsEnabled, forKey: heartbeatsEnabledKey) - Task { _ = await GatewayConnection.shared.setHeartbeatsEnabled(self.heartbeatsEnabled) } - } - } - } - - var connectionMode: ConnectionMode { - didSet { - self.ifNotPreview { UserDefaults.standard.set(self.connectionMode.rawValue, forKey: connectionModeKey) } - self.syncGatewayConfigIfNeeded() - } - } - - var remoteTransport: RemoteTransport { - didSet { self.syncGatewayConfigIfNeeded() } - } - - var canvasEnabled: Bool { - didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } } - } - - var execApprovalMode: ExecApprovalQuickMode { - didSet { - self.ifNotPreview { - ExecApprovalsStore.updateDefaults { defaults in - defaults.security = self.execApprovalMode.security - defaults.ask = self.execApprovalMode.ask - } - } - } - } - - /// Tracks whether the Canvas panel is currently visible (not persisted). - var canvasPanelVisible: Bool = false - - var peekabooBridgeEnabled: Bool { - didSet { - self.ifNotPreview { - UserDefaults.standard.set(self.peekabooBridgeEnabled, forKey: peekabooBridgeEnabledKey) - Task { await PeekabooBridgeHostCoordinator.shared.setEnabled(self.peekabooBridgeEnabled) } - } - } - } - - var remoteTarget: String { - didSet { - self.ifNotPreview { UserDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) } - self.syncGatewayConfigIfNeeded() - } - } - - var remoteUrl: String { - didSet { self.syncGatewayConfigIfNeeded() } - } - - var remoteIdentity: String { - didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } } - } - - var remoteProjectRoot: String { - didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteProjectRoot, forKey: remoteProjectRootKey) } } - } - - var remoteCliPath: String { - didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteCliPath, forKey: remoteCliPathKey) } } - } - - private var earBoostTask: Task? - - init(preview: Bool = false) { - let isPreview = preview || ProcessInfo.processInfo.isRunningTests - self.isPreview = isPreview - if !isPreview { - migrateLegacyDefaults() - } - let onboardingSeen = UserDefaults.standard.bool(forKey: onboardingSeenKey) - self.isPaused = UserDefaults.standard.bool(forKey: pauseDefaultsKey) - self.launchAtLogin = false - self.onboardingSeen = onboardingSeen - self.debugPaneEnabled = UserDefaults.standard.bool(forKey: debugPaneEnabledKey) - let savedVoiceWake = UserDefaults.standard.bool(forKey: swabbleEnabledKey) - self.swabbleEnabled = voiceWakeSupported ? savedVoiceWake : false - self.swabbleTriggerWords = UserDefaults.standard - .stringArray(forKey: swabbleTriggersKey) ?? defaultVoiceWakeTriggers - self.voiceWakeTriggerChime = Self.loadChime( - key: voiceWakeTriggerChimeKey, - fallback: .system(name: "Glass")) - self.voiceWakeSendChime = Self.loadChime( - key: voiceWakeSendChimeKey, - fallback: .system(name: "Glass")) - if let storedIconAnimations = UserDefaults.standard.object(forKey: iconAnimationsEnabledKey) as? Bool { - self.iconAnimationsEnabled = storedIconAnimations - } else { - self.iconAnimationsEnabled = true - UserDefaults.standard.set(true, forKey: iconAnimationsEnabledKey) - } - self.showDockIcon = UserDefaults.standard.bool(forKey: showDockIconKey) - self.voiceWakeMicID = UserDefaults.standard.string(forKey: voiceWakeMicKey) ?? "" - self.voiceWakeMicName = UserDefaults.standard.string(forKey: voiceWakeMicNameKey) ?? "" - self.voiceWakeLocaleID = UserDefaults.standard.string(forKey: voiceWakeLocaleKey) ?? Locale.current.identifier - self.voiceWakeAdditionalLocaleIDs = UserDefaults.standard - .stringArray(forKey: voiceWakeAdditionalLocalesKey) ?? [] - self.voicePushToTalkEnabled = UserDefaults.standard - .object(forKey: voicePushToTalkEnabledKey) as? Bool ?? false - self.talkEnabled = UserDefaults.standard.bool(forKey: talkEnabledKey) - self.seamColorHex = nil - if let storedHeartbeats = UserDefaults.standard.object(forKey: heartbeatsEnabledKey) as? Bool { - self.heartbeatsEnabled = storedHeartbeats - } else { - self.heartbeatsEnabled = true - UserDefaults.standard.set(true, forKey: heartbeatsEnabledKey) - } - if let storedOverride = UserDefaults.standard.string(forKey: iconOverrideKey), - let selection = IconOverrideSelection(rawValue: storedOverride) - { - self.iconOverride = selection - } else { - self.iconOverride = .system - UserDefaults.standard.set(IconOverrideSelection.system.rawValue, forKey: iconOverrideKey) - } - - let configRoot = OpenClawConfigFile.loadDict() - let configRemoteUrl = GatewayRemoteConfig.resolveUrlString(root: configRoot) - let configRemoteTransport = GatewayRemoteConfig.resolveTransport(root: configRoot) - let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode - self.remoteTransport = configRemoteTransport - self.connectionMode = resolvedConnectionMode - - let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? "" - if resolvedConnectionMode == .remote, - configRemoteTransport != .direct, - storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - let host = AppState.remoteHost(from: configRemoteUrl) - { - self.remoteTarget = "\(NSUserName())@\(host)" - } else { - self.remoteTarget = storedRemoteTarget - } - self.remoteUrl = configRemoteUrl ?? "" - self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? "" - self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? "" - self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? "" - self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true - let execDefaults = ExecApprovalsStore.resolveDefaults() - self.execApprovalMode = ExecApprovalQuickMode.from(security: execDefaults.security, ask: execDefaults.ask) - self.peekabooBridgeEnabled = UserDefaults.standard - .object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true - if !self.isPreview { - Task.detached(priority: .utility) { [weak self] in - let current = await LaunchAgentManager.status() - await MainActor.run { [weak self] in self?.launchAtLogin = current } - } - } - - if self.swabbleEnabled, !PermissionManager.voiceWakePermissionsGranted() { - self.swabbleEnabled = false - } - if self.talkEnabled, !PermissionManager.voiceWakePermissionsGranted() { - self.talkEnabled = false - } - - if !self.isPreview { - Task { await VoiceWakeRuntime.shared.refresh(state: self) } - Task { await TalkModeController.shared.setEnabled(self.talkEnabled) } - } - - self.isInitializing = false - if !self.isPreview { - self.startConfigWatcher() - } - } - - @MainActor - deinit { - self.configWatcher?.stop() - } - - private static func remoteHost(from urlString: String?) -> String? { - guard let raw = urlString?.trimmingCharacters(in: .whitespacesAndNewlines), - !raw.isEmpty, - let url = URL(string: raw), - let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), - !host.isEmpty - else { - return nil - } - return host - } - - private static func sanitizeSSHTarget(_ value: String) -> String { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.hasPrefix("ssh ") { - return trimmed.replacingOccurrences(of: "ssh ", with: "") - .trimmingCharacters(in: .whitespacesAndNewlines) - } - return trimmed - } - - private func startConfigWatcher() { - let configUrl = OpenClawConfigFile.url() - self.configWatcher = ConfigFileWatcher(url: configUrl) { [weak self] in - Task { @MainActor in - self?.applyConfigFromDisk() - } - } - self.configWatcher?.start() - } - - private func applyConfigFromDisk() { - let root = OpenClawConfigFile.loadDict() - self.applyConfigOverrides(root) - } - - private func applyConfigOverrides(_ root: [String: Any]) { - let gateway = root["gateway"] as? [String: Any] - let modeRaw = (gateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - let remoteUrl = GatewayRemoteConfig.resolveUrlString(root: root) - let hasRemoteUrl = !(remoteUrl? - .trimmingCharacters(in: .whitespacesAndNewlines) - .isEmpty ?? true) - let remoteTransport = GatewayRemoteConfig.resolveTransport(root: root) - - let desiredMode: ConnectionMode? = switch modeRaw { - case "local": - .local - case "remote": - .remote - case "unconfigured": - .unconfigured - default: - nil - } - - if let desiredMode { - if desiredMode != self.connectionMode { - self.connectionMode = desiredMode - } - } else if hasRemoteUrl, self.connectionMode != .remote { - self.connectionMode = .remote - } - - if remoteTransport != self.remoteTransport { - self.remoteTransport = remoteTransport - } - let remoteUrlText = remoteUrl ?? "" - if remoteUrlText != self.remoteUrl { - self.remoteUrl = remoteUrlText - } - - let targetMode = desiredMode ?? self.connectionMode - if targetMode == .remote, - remoteTransport != .direct, - let host = AppState.remoteHost(from: remoteUrl) - { - self.updateRemoteTarget(host: host) - } - } - - private func updateRemoteTarget(host: String) { - let trimmed = self.remoteTarget.trimmingCharacters(in: .whitespacesAndNewlines) - guard let parsed = CommandResolver.parseSSHTarget(trimmed) else { return } - let trimmedUser = parsed.user?.trimmingCharacters(in: .whitespacesAndNewlines) - let user = (trimmedUser?.isEmpty ?? true) ? nil : trimmedUser - let port = parsed.port - let assembled: String = if let user { - port == 22 ? "\(user)@\(host)" : "\(user)@\(host):\(port)" - } else { - port == 22 ? host : "\(host):\(port)" - } - if assembled != self.remoteTarget { - self.remoteTarget = assembled - } - } - - private func syncGatewayConfigIfNeeded() { - guard !self.isPreview, !self.isInitializing else { return } - - let connectionMode = self.connectionMode - let remoteTarget = self.remoteTarget - let remoteIdentity = self.remoteIdentity - let remoteTransport = self.remoteTransport - let remoteUrl = self.remoteUrl - let desiredMode: String? = switch connectionMode { - case .local: - "local" - case .remote: - "remote" - case .unconfigured: - nil - } - let remoteHost = connectionMode == .remote - ? CommandResolver.parseSSHTarget(remoteTarget)?.host - : nil - - Task { @MainActor in - // Keep app-only connection settings local to avoid overwriting remote gateway config. - var root = OpenClawConfigFile.loadDict() - var gateway = root["gateway"] as? [String: Any] ?? [:] - var changed = false - - let currentMode = (gateway["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - if let desiredMode { - if currentMode != desiredMode { - gateway["mode"] = desiredMode - changed = true - } - } else if currentMode != nil { - gateway.removeValue(forKey: "mode") - changed = true - } - - if connectionMode == .remote { - var remote = gateway["remote"] as? [String: Any] ?? [:] - var remoteChanged = false - - if remoteTransport == .direct { - let trimmedUrl = remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmedUrl.isEmpty { - if remote["url"] != nil { - remote.removeValue(forKey: "url") - remoteChanged = true - } - } else if let normalizedUrl = GatewayRemoteConfig.normalizeGatewayUrlString(trimmedUrl) { - if (remote["url"] as? String) != normalizedUrl { - remote["url"] = normalizedUrl - remoteChanged = true - } - } - if (remote["transport"] as? String) != RemoteTransport.direct.rawValue { - remote["transport"] = RemoteTransport.direct.rawValue - remoteChanged = true - } - } else { - if remote["transport"] != nil { - remote.removeValue(forKey: "transport") - remoteChanged = true - } - if let host = remoteHost { - let existingUrl = (remote["url"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl) - let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws" - let port = parsedExisting?.port ?? 18789 - let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)" - if existingUrl != desiredUrl { - remote["url"] = desiredUrl - remoteChanged = true - } - } - - let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget) - if !sanitizedTarget.isEmpty { - if (remote["sshTarget"] as? String) != sanitizedTarget { - remote["sshTarget"] = sanitizedTarget - remoteChanged = true - } - } else if remote["sshTarget"] != nil { - remote.removeValue(forKey: "sshTarget") - remoteChanged = true - } - - let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmedIdentity.isEmpty { - if (remote["sshIdentity"] as? String) != trimmedIdentity { - remote["sshIdentity"] = trimmedIdentity - remoteChanged = true - } - } else if remote["sshIdentity"] != nil { - remote.removeValue(forKey: "sshIdentity") - remoteChanged = true - } - } - - if remoteChanged { - gateway["remote"] = remote - changed = true - } - } - - guard changed else { return } - if gateway.isEmpty { - root.removeValue(forKey: "gateway") - } else { - root["gateway"] = gateway - } - OpenClawConfigFile.saveDict(root) - } - } - - func triggerVoiceEars(ttl: TimeInterval? = 5) { - self.earBoostTask?.cancel() - self.earBoostActive = true - - guard let ttl else { return } - - self.earBoostTask = Task { [weak self] in - try? await Task.sleep(nanoseconds: UInt64(ttl * 1_000_000_000)) - await MainActor.run { [weak self] in self?.earBoostActive = false } - } - } - - func stopVoiceEars() { - self.earBoostTask?.cancel() - self.earBoostTask = nil - self.earBoostActive = false - } - - func blinkOnce() { - self.blinkTick &+= 1 - } - - func celebrateSend() { - self.sendCelebrationTick &+= 1 - } - - func setVoiceWakeEnabled(_ enabled: Bool) async { - guard voiceWakeSupported else { - self.swabbleEnabled = false - return - } - - self.swabbleEnabled = enabled - guard !self.isPreview else { return } - - if !enabled { - Task { await VoiceWakeRuntime.shared.refresh(state: self) } - return - } - - if PermissionManager.voiceWakePermissionsGranted() { - Task { await VoiceWakeRuntime.shared.refresh(state: self) } - return - } - - let granted = await PermissionManager.ensureVoiceWakePermissions(interactive: true) - self.swabbleEnabled = granted - Task { await VoiceWakeRuntime.shared.refresh(state: self) } - } - - func setTalkEnabled(_ enabled: Bool) async { - guard voiceWakeSupported else { - self.talkEnabled = false - await GatewayConnection.shared.talkMode(enabled: false, phase: "disabled") - return - } - - self.talkEnabled = enabled - guard !self.isPreview else { return } - - if !enabled { - await GatewayConnection.shared.talkMode(enabled: false, phase: "disabled") - return - } - - if PermissionManager.voiceWakePermissionsGranted() { - await GatewayConnection.shared.talkMode(enabled: true, phase: "enabled") - return - } - - let granted = await PermissionManager.ensureVoiceWakePermissions(interactive: true) - self.talkEnabled = granted - await GatewayConnection.shared.talkMode(enabled: granted, phase: granted ? "enabled" : "denied") - } - - // MARK: - Global wake words sync (Gateway-owned) - - func applyGlobalVoiceWakeTriggers(_ triggers: [String]) { - self.suppressVoiceWakeGlobalSync = true - self.swabbleTriggerWords = triggers - self.suppressVoiceWakeGlobalSync = false - } - - private func scheduleVoiceWakeGlobalSyncIfNeeded() { - guard !self.suppressVoiceWakeGlobalSync else { return } - let sanitized = sanitizeVoiceWakeTriggers(self.swabbleTriggerWords) - self.voiceWakeGlobalSyncTask?.cancel() - self.voiceWakeGlobalSyncTask = Task { [sanitized] in - try? await Task.sleep(nanoseconds: 650_000_000) - await GatewayConnection.shared.voiceWakeSetTriggers(sanitized) - } - } - - func setWorking(_ working: Bool) { - self.isWorking = working - } - - // MARK: - Chime persistence - - private static func loadChime(key: String, fallback: VoiceWakeChime) -> VoiceWakeChime { - guard let data = UserDefaults.standard.data(forKey: key) else { return fallback } - if let decoded = try? JSONDecoder().decode(VoiceWakeChime.self, from: data) { - return decoded - } - return fallback - } - - private func storeChime(_ chime: VoiceWakeChime, key: String) { - guard let data = try? JSONEncoder().encode(chime) else { return } - UserDefaults.standard.set(data, forKey: key) - } -} - -extension AppState { - static var preview: AppState { - let state = AppState(preview: true) - state.isPaused = false - state.launchAtLogin = true - state.onboardingSeen = true - state.debugPaneEnabled = true - state.swabbleEnabled = true - state.swabbleTriggerWords = ["Claude", "Computer", "Jarvis"] - state.voiceWakeTriggerChime = .system(name: "Glass") - state.voiceWakeSendChime = .system(name: "Ping") - state.iconAnimationsEnabled = true - state.showDockIcon = true - state.voiceWakeMicID = "BuiltInMic" - state.voiceWakeMicName = "Built-in Microphone" - state.voiceWakeLocaleID = Locale.current.identifier - state.voiceWakeAdditionalLocaleIDs = ["en-US", "de-DE"] - state.voicePushToTalkEnabled = false - state.talkEnabled = false - state.iconOverride = .system - state.heartbeatsEnabled = true - state.connectionMode = .local - state.remoteTransport = .ssh - state.canvasEnabled = true - state.remoteTarget = "user@example.com" - state.remoteUrl = "wss://gateway.example.ts.net" - state.remoteIdentity = "~/.ssh/id_ed25519" - state.remoteProjectRoot = "~/Projects/openclaw" - state.remoteCliPath = "" - return state - } -} - -@MainActor -enum AppStateStore { - static let shared = AppState() - static var isPausedFlag: Bool { - UserDefaults.standard.bool(forKey: pauseDefaultsKey) - } - - static func updateLaunchAtLogin(enabled: Bool) { - Task.detached(priority: .utility) { - await LaunchAgentManager.set(enabled: enabled, bundlePath: Bundle.main.bundlePath) - } - } - - static var canvasEnabled: Bool { - UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true - } -} - -@MainActor -enum AppActivationPolicy { - static func apply(showDockIcon: Bool) { - _ = showDockIcon - DockIconManager.shared.updateDockVisibility() - } -} diff --git a/apps/macos/Sources/OpenClaw/AudioInputDeviceObserver.swift b/apps/macos/Sources/OpenClaw/AudioInputDeviceObserver.swift deleted file mode 100644 index abbddb24588..00000000000 --- a/apps/macos/Sources/OpenClaw/AudioInputDeviceObserver.swift +++ /dev/null @@ -1,216 +0,0 @@ -import CoreAudio -import Foundation -import OSLog - -final class AudioInputDeviceObserver { - private let logger = Logger(subsystem: "ai.openclaw", category: "audio.devices") - private var isActive = false - private var devicesListener: AudioObjectPropertyListenerBlock? - private var defaultInputListener: AudioObjectPropertyListenerBlock? - - static func defaultInputDeviceUID() -> String? { - let systemObject = AudioObjectID(kAudioObjectSystemObject) - var address = AudioObjectPropertyAddress( - mSelector: kAudioHardwarePropertyDefaultInputDevice, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain) - var deviceID = AudioObjectID(0) - var size = UInt32(MemoryLayout.size) - let status = AudioObjectGetPropertyData( - systemObject, - &address, - 0, - nil, - &size, - &deviceID) - guard status == noErr, deviceID != 0 else { return nil } - return self.deviceUID(for: deviceID) - } - - static func aliveInputDeviceUIDs() -> Set { - let systemObject = AudioObjectID(kAudioObjectSystemObject) - var address = AudioObjectPropertyAddress( - mSelector: kAudioHardwarePropertyDevices, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain) - var size: UInt32 = 0 - var status = AudioObjectGetPropertyDataSize(systemObject, &address, 0, nil, &size) - guard status == noErr, size > 0 else { return [] } - - let count = Int(size) / MemoryLayout.size - var deviceIDs = [AudioObjectID](repeating: 0, count: count) - status = AudioObjectGetPropertyData(systemObject, &address, 0, nil, &size, &deviceIDs) - guard status == noErr else { return [] } - - var output = Set() - for deviceID in deviceIDs { - guard self.deviceIsAlive(deviceID) else { continue } - guard self.deviceHasInput(deviceID) else { continue } - if let uid = self.deviceUID(for: deviceID) { - output.insert(uid) - } - } - return output - } - - static func defaultInputDeviceSummary() -> String { - let systemObject = AudioObjectID(kAudioObjectSystemObject) - var address = AudioObjectPropertyAddress( - mSelector: kAudioHardwarePropertyDefaultInputDevice, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain) - var deviceID = AudioObjectID(0) - var size = UInt32(MemoryLayout.size) - let status = AudioObjectGetPropertyData( - systemObject, - &address, - 0, - nil, - &size, - &deviceID) - guard status == noErr, deviceID != 0 else { - return "defaultInput=unknown" - } - let uid = self.deviceUID(for: deviceID) ?? "unknown" - let name = self.deviceName(for: deviceID) ?? "unknown" - return "defaultInput=\(name) (\(uid))" - } - - func start(onChange: @escaping @Sendable () -> Void) { - guard !self.isActive else { return } - self.isActive = true - - let systemObject = AudioObjectID(kAudioObjectSystemObject) - let queue = DispatchQueue.main - - var devicesAddress = AudioObjectPropertyAddress( - mSelector: kAudioHardwarePropertyDevices, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain) - let devicesListener: AudioObjectPropertyListenerBlock = { _, _ in - self.logDefaultInputChange(reason: "devices") - onChange() - } - let devicesStatus = AudioObjectAddPropertyListenerBlock( - systemObject, - &devicesAddress, - queue, - devicesListener) - - var defaultInputAddress = AudioObjectPropertyAddress( - mSelector: kAudioHardwarePropertyDefaultInputDevice, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain) - let defaultInputListener: AudioObjectPropertyListenerBlock = { _, _ in - self.logDefaultInputChange(reason: "default") - onChange() - } - let defaultStatus = AudioObjectAddPropertyListenerBlock( - systemObject, - &defaultInputAddress, - queue, - defaultInputListener) - - if devicesStatus != noErr || defaultStatus != noErr { - self.logger.error("audio device observer install failed devices=\(devicesStatus) default=\(defaultStatus)") - } - - self.logger.info("audio device observer started (\(Self.defaultInputDeviceSummary(), privacy: .public))") - - self.devicesListener = devicesListener - self.defaultInputListener = defaultInputListener - } - - func stop() { - guard self.isActive else { return } - self.isActive = false - let systemObject = AudioObjectID(kAudioObjectSystemObject) - - if let devicesListener { - var devicesAddress = AudioObjectPropertyAddress( - mSelector: kAudioHardwarePropertyDevices, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain) - _ = AudioObjectRemovePropertyListenerBlock( - systemObject, - &devicesAddress, - DispatchQueue.main, - devicesListener) - } - - if let defaultInputListener { - var defaultInputAddress = AudioObjectPropertyAddress( - mSelector: kAudioHardwarePropertyDefaultInputDevice, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain) - _ = AudioObjectRemovePropertyListenerBlock( - systemObject, - &defaultInputAddress, - DispatchQueue.main, - defaultInputListener) - } - - self.devicesListener = nil - self.defaultInputListener = nil - } - - private static func deviceUID(for deviceID: AudioObjectID) -> String? { - var address = AudioObjectPropertyAddress( - mSelector: kAudioDevicePropertyDeviceUID, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain) - var uid: Unmanaged? - var size = UInt32(MemoryLayout?>.size) - let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &uid) - guard status == noErr, let uid else { return nil } - return uid.takeUnretainedValue() as String - } - - private static func deviceName(for deviceID: AudioObjectID) -> String? { - var address = AudioObjectPropertyAddress( - mSelector: kAudioObjectPropertyName, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain) - var name: Unmanaged? - var size = UInt32(MemoryLayout?>.size) - let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &name) - guard status == noErr, let name else { return nil } - return name.takeUnretainedValue() as String - } - - private static func deviceIsAlive(_ deviceID: AudioObjectID) -> Bool { - var address = AudioObjectPropertyAddress( - mSelector: kAudioDevicePropertyDeviceIsAlive, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain) - var alive: UInt32 = 0 - var size = UInt32(MemoryLayout.size) - let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &alive) - return status == noErr && alive != 0 - } - - private static func deviceHasInput(_ deviceID: AudioObjectID) -> Bool { - var address = AudioObjectPropertyAddress( - mSelector: kAudioDevicePropertyStreamConfiguration, - mScope: kAudioDevicePropertyScopeInput, - mElement: kAudioObjectPropertyElementMain) - var size: UInt32 = 0 - var status = AudioObjectGetPropertyDataSize(deviceID, &address, 0, nil, &size) - guard status == noErr, size > 0 else { return false } - - let raw = UnsafeMutableRawPointer.allocate( - byteCount: Int(size), - alignment: MemoryLayout.alignment) - defer { raw.deallocate() } - let bufferList = raw.bindMemory(to: AudioBufferList.self, capacity: 1) - status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, bufferList) - guard status == noErr else { return false } - - let buffers = UnsafeMutableAudioBufferListPointer(bufferList) - return buffers.contains(where: { $0.mNumberChannels > 0 }) - } - - private func logDefaultInputChange(reason: StaticString) { - self.logger.info("audio input changed (\(reason)) (\(Self.defaultInputDeviceSummary(), privacy: .public))") - } -} diff --git a/apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift b/apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift deleted file mode 100644 index 482f36fd6d0..00000000000 --- a/apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift +++ /dev/null @@ -1,84 +0,0 @@ -import AppKit -import Foundation -import OSLog - -@MainActor -final class CLIInstallPrompter { - static let shared = CLIInstallPrompter() - private let logger = Logger(subsystem: "ai.openclaw", category: "cli.prompt") - private var isPrompting = false - - func checkAndPromptIfNeeded(reason: String) { - guard self.shouldPrompt() else { return } - guard let version = Self.appVersion() else { return } - self.isPrompting = true - UserDefaults.standard.set(version, forKey: cliInstallPromptedVersionKey) - - let alert = NSAlert() - alert.messageText = "Install OpenClaw CLI?" - alert.informativeText = "Local mode needs the CLI so launchd can run the gateway." - alert.addButton(withTitle: "Install CLI") - alert.addButton(withTitle: "Not now") - alert.addButton(withTitle: "Open Settings") - let response = alert.runModal() - - switch response { - case .alertFirstButtonReturn: - Task { await self.installCLI() } - case .alertThirdButtonReturn: - self.openSettings(tab: .general) - default: - break - } - - self.logger.debug("cli install prompt handled reason=\(reason, privacy: .public)") - self.isPrompting = false - } - - private func shouldPrompt() -> Bool { - guard !self.isPrompting else { return false } - guard AppStateStore.shared.onboardingSeen else { return false } - guard AppStateStore.shared.connectionMode == .local else { return false } - guard CLIInstaller.installedLocation() == nil else { return false } - guard let version = Self.appVersion() else { return false } - let lastPrompt = UserDefaults.standard.string(forKey: cliInstallPromptedVersionKey) - return lastPrompt != version - } - - private func installCLI() async { - let status = StatusBox() - await CLIInstaller.install { message in - await status.set(message) - } - if let message = await status.get() { - let alert = NSAlert() - alert.messageText = "CLI install finished" - alert.informativeText = message - alert.runModal() - } - } - - private func openSettings(tab: SettingsTab) { - SettingsTabRouter.request(tab) - SettingsWindowOpener.shared.open() - DispatchQueue.main.async { - NotificationCenter.default.post(name: .openclawSelectSettingsTab, object: tab) - } - } - - private static func appVersion() -> String? { - Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String - } -} - -private actor StatusBox { - private var value: String? - - func set(_ value: String) { - self.value = value - } - - func get() -> String? { - self.value - } -} diff --git a/apps/macos/Sources/OpenClaw/CLIInstaller.swift b/apps/macos/Sources/OpenClaw/CLIInstaller.swift deleted file mode 100644 index ce6d25202ae..00000000000 --- a/apps/macos/Sources/OpenClaw/CLIInstaller.swift +++ /dev/null @@ -1,103 +0,0 @@ -import Foundation - -@MainActor -enum CLIInstaller { - static func installedLocation() -> String? { - self.installedLocation( - searchPaths: CommandResolver.preferredPaths(), - fileManager: .default) - } - - static func installedLocation( - searchPaths: [String], - fileManager: FileManager) -> String? - { - for basePath in searchPaths { - let candidate = URL(fileURLWithPath: basePath).appendingPathComponent("openclaw").path - var isDirectory: ObjCBool = false - - guard fileManager.fileExists(atPath: candidate, isDirectory: &isDirectory), - !isDirectory.boolValue - else { - continue - } - - guard fileManager.isExecutableFile(atPath: candidate) else { continue } - - return candidate - } - - return nil - } - - static func isInstalled() -> Bool { - self.installedLocation() != nil - } - - static func install(statusHandler: @escaping @MainActor @Sendable (String) async -> Void) async { - let expected = GatewayEnvironment.expectedGatewayVersionString() ?? "latest" - let prefix = Self.installPrefix() - await statusHandler("Installing openclaw CLI…") - let cmd = self.installScriptCommand(version: expected, prefix: prefix) - let response = await ShellExecutor.runDetailed(command: cmd, cwd: nil, env: nil, timeout: 900) - - if response.success { - let parsed = self.parseInstallEvents(response.stdout) - let installedVersion = parsed.last { $0.event == "done" }?.version - let summary = installedVersion.map { "Installed openclaw \($0)." } ?? "Installed openclaw." - await statusHandler(summary) - return - } - - let parsed = self.parseInstallEvents(response.stdout) - if let error = parsed.last(where: { $0.event == "error" })?.message { - await statusHandler("Install failed: \(error)") - return - } - - let detail = response.stderr.trimmingCharacters(in: .whitespacesAndNewlines) - let fallback = response.errorMessage ?? "install failed" - await statusHandler("Install failed: \(detail.isEmpty ? fallback : detail)") - } - - private static func installPrefix() -> String { - FileManager().homeDirectoryForCurrentUser - .appendingPathComponent(".openclaw") - .path - } - - private static func installScriptCommand(version: String, prefix: String) -> [String] { - let escapedVersion = self.shellEscape(version) - let escapedPrefix = self.shellEscape(prefix) - let script = """ - curl -fsSL https://openclaw.bot/install-cli.sh | \ - bash -s -- --json --no-onboard --prefix \(escapedPrefix) --version \(escapedVersion) - """ - return ["/bin/bash", "-lc", script] - } - - private static func parseInstallEvents(_ output: String) -> [InstallEvent] { - let decoder = JSONDecoder() - let lines = output - .split(whereSeparator: \.isNewline) - .map { String($0) } - var events: [InstallEvent] = [] - for line in lines { - guard let data = line.data(using: .utf8) else { continue } - if let event = try? decoder.decode(InstallEvent.self, from: data) { - events.append(event) - } - } - return events - } - - private static func shellEscape(_ raw: String) -> String { - "'" + raw.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" - } -} - -private struct InstallEvent: Decodable { - let event: String - let version: String? - let message: String? -} diff --git a/apps/macos/Sources/OpenClaw/CameraCaptureService.swift b/apps/macos/Sources/OpenClaw/CameraCaptureService.swift deleted file mode 100644 index 4e3749d6a68..00000000000 --- a/apps/macos/Sources/OpenClaw/CameraCaptureService.swift +++ /dev/null @@ -1,427 +0,0 @@ -import AVFoundation -import CoreGraphics -import Foundation -import OpenClawIPC -import OpenClawKit -import OSLog - -actor CameraCaptureService { - struct CameraDeviceInfo: Encodable, Sendable { - let id: String - let name: String - let position: String - let deviceType: String - } - - enum CameraError: LocalizedError, Sendable { - case cameraUnavailable - case microphoneUnavailable - case permissionDenied(kind: String) - case captureFailed(String) - case exportFailed(String) - - var errorDescription: String? { - switch self { - case .cameraUnavailable: - "Camera unavailable" - case .microphoneUnavailable: - "Microphone unavailable" - case let .permissionDenied(kind): - "\(kind) permission denied" - case let .captureFailed(msg): - msg - case let .exportFailed(msg): - msg - } - } - } - - private let logger = Logger(subsystem: "ai.openclaw", category: "camera") - - func listDevices() -> [CameraDeviceInfo] { - Self.availableCameras().map { device in - CameraDeviceInfo( - id: device.uniqueID, - name: device.localizedName, - position: Self.positionLabel(device.position), - deviceType: device.deviceType.rawValue) - } - } - - func snap( - facing: CameraFacing?, - maxWidth: Int?, - quality: Double?, - deviceId: String?, - delayMs: Int) async throws -> (data: Data, size: CGSize) - { - let facing = facing ?? .front - let normalized = Self.normalizeSnap(maxWidth: maxWidth, quality: quality) - let maxWidth = normalized.maxWidth - let quality = normalized.quality - let delayMs = max(0, delayMs) - let deviceId = deviceId?.trimmingCharacters(in: .whitespacesAndNewlines) - - try await self.ensureAccess(for: .video) - - let session = AVCaptureSession() - session.sessionPreset = .photo - - guard let device = Self.pickCamera(facing: facing, deviceId: deviceId) else { - throw CameraError.cameraUnavailable - } - - let input = try AVCaptureDeviceInput(device: device) - guard session.canAddInput(input) else { - throw CameraError.captureFailed("Failed to add camera input") - } - session.addInput(input) - - let output = AVCapturePhotoOutput() - guard session.canAddOutput(output) else { - throw CameraError.captureFailed("Failed to add photo output") - } - session.addOutput(output) - output.maxPhotoQualityPrioritization = .quality - - session.startRunning() - defer { session.stopRunning() } - await Self.warmUpCaptureSession() - await self.waitForExposureAndWhiteBalance(device: device) - await self.sleepDelayMs(delayMs) - - let settings: AVCapturePhotoSettings = { - if output.availablePhotoCodecTypes.contains(.jpeg) { - return AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.jpeg]) - } - return AVCapturePhotoSettings() - }() - settings.photoQualityPrioritization = .quality - - var delegate: PhotoCaptureDelegate? - let rawData: Data = try await withCheckedThrowingContinuation { cont in - let d = PhotoCaptureDelegate(cont) - delegate = d - output.capturePhoto(with: settings, delegate: d) - } - withExtendedLifetime(delegate) {} - - let res: (data: Data, widthPx: Int, heightPx: Int) - do { - res = try PhotoCapture.transcodeJPEGForGateway( - rawData: rawData, - maxWidthPx: maxWidth, - quality: quality) - } catch { - throw CameraError.captureFailed(error.localizedDescription) - } - - return (data: res.data, size: CGSize(width: res.widthPx, height: res.heightPx)) - } - - func clip( - facing: CameraFacing?, - durationMs: Int?, - includeAudio: Bool, - deviceId: String?, - outPath: String?) async throws -> (path: String, durationMs: Int, hasAudio: Bool) - { - let facing = facing ?? .front - let durationMs = Self.clampDurationMs(durationMs) - let deviceId = deviceId?.trimmingCharacters(in: .whitespacesAndNewlines) - - try await self.ensureAccess(for: .video) - if includeAudio { - try await self.ensureAccess(for: .audio) - } - - let session = AVCaptureSession() - session.sessionPreset = .high - - guard let camera = Self.pickCamera(facing: facing, deviceId: deviceId) else { - throw CameraError.cameraUnavailable - } - let cameraInput = try AVCaptureDeviceInput(device: camera) - guard session.canAddInput(cameraInput) else { - throw CameraError.captureFailed("Failed to add camera input") - } - session.addInput(cameraInput) - - if includeAudio { - guard let mic = AVCaptureDevice.default(for: .audio) else { - throw CameraError.microphoneUnavailable - } - let micInput = try AVCaptureDeviceInput(device: mic) - guard session.canAddInput(micInput) else { - throw CameraError.captureFailed("Failed to add microphone input") - } - session.addInput(micInput) - } - - let output = AVCaptureMovieFileOutput() - guard session.canAddOutput(output) else { - throw CameraError.captureFailed("Failed to add movie output") - } - session.addOutput(output) - output.maxRecordedDuration = CMTime(value: Int64(durationMs), timescale: 1000) - - session.startRunning() - defer { session.stopRunning() } - await Self.warmUpCaptureSession() - - let tmpMovURL = FileManager().temporaryDirectory - .appendingPathComponent("openclaw-camera-\(UUID().uuidString).mov") - defer { try? FileManager().removeItem(at: tmpMovURL) } - - let outputURL: URL = { - if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return URL(fileURLWithPath: outPath) - } - return FileManager().temporaryDirectory - .appendingPathComponent("openclaw-camera-\(UUID().uuidString).mp4") - }() - - // Ensure we don't fail exporting due to an existing file. - try? FileManager().removeItem(at: outputURL) - - let logger = self.logger - var delegate: MovieFileDelegate? - let recordedURL: URL = try await withCheckedThrowingContinuation { cont in - let d = MovieFileDelegate(cont, logger: logger) - delegate = d - output.startRecording(to: tmpMovURL, recordingDelegate: d) - } - withExtendedLifetime(delegate) {} - - try await Self.exportToMP4(inputURL: recordedURL, outputURL: outputURL) - return (path: outputURL.path, durationMs: durationMs, hasAudio: includeAudio) - } - - private func ensureAccess(for mediaType: AVMediaType) async throws { - let status = AVCaptureDevice.authorizationStatus(for: mediaType) - switch status { - case .authorized: - return - case .notDetermined: - let ok = await withCheckedContinuation(isolation: nil) { cont in - AVCaptureDevice.requestAccess(for: mediaType) { granted in - cont.resume(returning: granted) - } - } - if !ok { - throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone") - } - case .denied, .restricted: - throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone") - @unknown default: - throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone") - } - } - - private nonisolated static func availableCameras() -> [AVCaptureDevice] { - var types: [AVCaptureDevice.DeviceType] = [ - .builtInWideAngleCamera, - .continuityCamera, - ] - if let external = externalDeviceType() { - types.append(external) - } - let session = AVCaptureDevice.DiscoverySession( - deviceTypes: types, - mediaType: .video, - position: .unspecified) - return session.devices - } - - private nonisolated static func externalDeviceType() -> AVCaptureDevice.DeviceType? { - if #available(macOS 14.0, *) { - return .external - } - // Use raw value to avoid deprecated symbol in the SDK. - return AVCaptureDevice.DeviceType(rawValue: "AVCaptureDeviceTypeExternalUnknown") - } - - private nonisolated static func pickCamera( - facing: CameraFacing, - deviceId: String?) -> AVCaptureDevice? - { - if let deviceId, !deviceId.isEmpty { - if let match = availableCameras().first(where: { $0.uniqueID == deviceId }) { - return match - } - } - let position: AVCaptureDevice.Position = (facing == .front) ? .front : .back - - if let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position) { - return device - } - - // Many macOS cameras report `unspecified` position; fall back to any default. - return AVCaptureDevice.default(for: .video) - } - - private nonisolated static func clampQuality(_ quality: Double?) -> Double { - let q = quality ?? 0.9 - return min(1.0, max(0.05, q)) - } - - nonisolated static func normalizeSnap(maxWidth: Int?, quality: Double?) -> (maxWidth: Int, quality: Double) { - // Default to a reasonable max width to keep downstream payload sizes manageable. - // If you need full-res, explicitly request a larger maxWidth. - let maxWidth = maxWidth.flatMap { $0 > 0 ? $0 : nil } ?? 1600 - let quality = Self.clampQuality(quality) - return (maxWidth: maxWidth, quality: quality) - } - - private nonisolated static func clampDurationMs(_ ms: Int?) -> Int { - let v = ms ?? 3000 - return min(60000, max(250, v)) - } - - private nonisolated static func exportToMP4(inputURL: URL, outputURL: URL) async throws { - let asset = AVURLAsset(url: inputURL) - guard let export = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetMediumQuality) else { - throw CameraError.exportFailed("Failed to create export session") - } - export.shouldOptimizeForNetworkUse = true - - if #available(macOS 15.0, *) { - do { - try await export.export(to: outputURL, as: .mp4) - return - } catch { - throw CameraError.exportFailed(error.localizedDescription) - } - } else { - export.outputURL = outputURL - export.outputFileType = .mp4 - - try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation) in - export.exportAsynchronously { - cont.resume(returning: ()) - } - } - - switch export.status { - case .completed: - return - case .failed: - throw CameraError.exportFailed(export.error?.localizedDescription ?? "export failed") - case .cancelled: - throw CameraError.exportFailed("export cancelled") - default: - throw CameraError.exportFailed("export did not complete (\(export.status.rawValue))") - } - } - } - - private nonisolated static func warmUpCaptureSession() async { - // A short delay after `startRunning()` significantly reduces "blank first frame" captures on some devices. - try? await Task.sleep(nanoseconds: 150_000_000) // 150ms - } - - private func waitForExposureAndWhiteBalance(device: AVCaptureDevice) async { - let stepNs: UInt64 = 50_000_000 - let maxSteps = 30 // ~1.5s - for _ in 0.. 0 else { return } - let ns = UInt64(min(delayMs, 10000)) * 1_000_000 - try? await Task.sleep(nanoseconds: ns) - } - - private nonisolated static func positionLabel(_ position: AVCaptureDevice.Position) -> String { - switch position { - case .front: "front" - case .back: "back" - default: "unspecified" - } - } -} - -private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate { - private var cont: CheckedContinuation? - private var didResume = false - - init(_ cont: CheckedContinuation) { - self.cont = cont - } - - func photoOutput( - _ output: AVCapturePhotoOutput, - didFinishProcessingPhoto photo: AVCapturePhoto, - error: Error?) - { - guard !self.didResume, let cont else { return } - self.didResume = true - self.cont = nil - if let error { - cont.resume(throwing: error) - return - } - guard let data = photo.fileDataRepresentation() else { - cont.resume(throwing: CameraCaptureService.CameraError.captureFailed("No photo data")) - return - } - if data.isEmpty { - cont.resume(throwing: CameraCaptureService.CameraError.captureFailed("Photo data empty")) - return - } - cont.resume(returning: data) - } - - func photoOutput( - _ output: AVCapturePhotoOutput, - didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings, - error: Error?) - { - guard let error else { return } - guard !self.didResume, let cont else { return } - self.didResume = true - self.cont = nil - cont.resume(throwing: error) - } -} - -private final class MovieFileDelegate: NSObject, AVCaptureFileOutputRecordingDelegate { - private var cont: CheckedContinuation? - private let logger: Logger - - init(_ cont: CheckedContinuation, logger: Logger) { - self.cont = cont - self.logger = logger - } - - func fileOutput( - _ output: AVCaptureFileOutput, - didFinishRecordingTo outputFileURL: URL, - from connections: [AVCaptureConnection], - error: Error?) - { - guard let cont else { return } - self.cont = nil - - if let error { - let ns = error as NSError - if ns.domain == AVFoundationErrorDomain, - ns.code == AVError.maximumDurationReached.rawValue - { - cont.resume(returning: outputFileURL) - return - } - - self.logger.error("camera record failed: \(error.localizedDescription, privacy: .public)") - cont.resume(throwing: error) - return - } - - cont.resume(returning: outputFileURL) - } -} diff --git a/apps/macos/Sources/OpenClaw/CanvasA2UIActionMessageHandler.swift b/apps/macos/Sources/OpenClaw/CanvasA2UIActionMessageHandler.swift deleted file mode 100644 index 40f443c5c8b..00000000000 --- a/apps/macos/Sources/OpenClaw/CanvasA2UIActionMessageHandler.swift +++ /dev/null @@ -1,149 +0,0 @@ -import AppKit -import Foundation -import OpenClawIPC -import OpenClawKit -import WebKit - -final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler { - static let messageName = "openclawCanvasA2UIAction" - static let allMessageNames = [messageName] - - private let sessionKey: String - - init(sessionKey: String) { - self.sessionKey = sessionKey - super.init() - } - - func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) { - guard Self.allMessageNames.contains(message.name) else { return } - - // Only accept actions from local Canvas content (not arbitrary web pages). - guard let webView = message.webView, let url = webView.url else { return } - if let scheme = url.scheme, CanvasScheme.allSchemes.contains(scheme) { - // ok - } else if Self.isLocalNetworkCanvasURL(url) { - // ok - } else { - return - } - - let body: [String: Any] = { - if let dict = message.body as? [String: Any] { return dict } - if let dict = message.body as? [AnyHashable: Any] { - return dict.reduce(into: [String: Any]()) { acc, pair in - guard let key = pair.key as? String else { return } - acc[key] = pair.value - } - } - return [:] - }() - guard !body.isEmpty else { return } - - let userActionAny = body["userAction"] ?? body - let userAction: [String: Any] = { - if let dict = userActionAny as? [String: Any] { return dict } - if let dict = userActionAny as? [AnyHashable: Any] { - return dict.reduce(into: [String: Any]()) { acc, pair in - guard let key = pair.key as? String else { return } - acc[key] = pair.value - } - } - return [:] - }() - guard !userAction.isEmpty else { return } - - guard let name = OpenClawCanvasA2UIAction.extractActionName(userAction) else { return } - let actionId = - (userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty - ?? UUID().uuidString - - canvasWindowLogger.info("A2UI action \(name, privacy: .public) session=\(self.sessionKey, privacy: .public)") - - let surfaceId = (userAction["surfaceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - .nonEmpty ?? "main" - let sourceComponentId = (userAction["sourceComponentId"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "-" - let instanceId = InstanceIdentity.instanceId.lowercased() - let contextJSON = OpenClawCanvasA2UIAction.compactJSON(userAction["context"]) - - // Token-efficient and unambiguous. The agent should treat this as a UI event and (by default) update Canvas. - let messageContext = OpenClawCanvasA2UIAction.AgentMessageContext( - actionName: name, - session: .init(key: self.sessionKey, surfaceId: surfaceId), - component: .init(id: sourceComponentId, host: InstanceIdentity.displayName, instanceId: instanceId), - contextJSON: contextJSON) - let text = OpenClawCanvasA2UIAction.formatAgentMessage(messageContext) - - Task { [weak webView] in - if AppStateStore.shared.connectionMode == .local { - GatewayProcessManager.shared.setActive(true) - } - - let result = await GatewayConnection.shared.sendAgent( - GatewayAgentInvocation( - message: text, - sessionKey: self.sessionKey, - thinking: "low", - deliver: false, - to: nil, - channel: .last, - idempotencyKey: actionId)) - - await MainActor.run { - guard let webView else { return } - let js = OpenClawCanvasA2UIAction.jsDispatchA2UIActionStatus( - actionId: actionId, - ok: result.ok, - error: result.error) - webView.evaluateJavaScript(js) { _, _ in } - } - if !result.ok { - canvasWindowLogger.error( - """ - A2UI action send failed name=\(name, privacy: .public) \ - error=\(result.error ?? "unknown", privacy: .public) - """) - } - } - } - - static func isLocalNetworkCanvasURL(_ url: URL) -> Bool { - guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { - return false - } - guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty else { - return false - } - if host == "localhost" { return true } - if host.hasSuffix(".local") { return true } - if host.hasSuffix(".ts.net") { return true } - if host.hasSuffix(".tailscale.net") { return true } - if !host.contains("."), !host.contains(":") { return true } - if let ipv4 = Self.parseIPv4(host) { - return Self.isLocalNetworkIPv4(ipv4) - } - return false - } - - static func parseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? { - let parts = host.split(separator: ".", omittingEmptySubsequences: false) - guard parts.count == 4 else { return nil } - let bytes: [UInt8] = parts.compactMap { UInt8($0) } - guard bytes.count == 4 else { return nil } - return (bytes[0], bytes[1], bytes[2], bytes[3]) - } - - static func isLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool { - let (a, b, _, _) = ip - if a == 10 { return true } - if a == 172, (16...31).contains(Int(b)) { return true } - if a == 192, b == 168 { return true } - if a == 127 { return true } - if a == 169, b == 254 { return true } - if a == 100, (64...127).contains(Int(b)) { return true } - return false - } - - // Formatting helpers live in OpenClawKit (`OpenClawCanvasA2UIAction`). -} diff --git a/apps/macos/Sources/OpenClaw/CanvasChromeContainerView.swift b/apps/macos/Sources/OpenClaw/CanvasChromeContainerView.swift deleted file mode 100644 index b4158167dcf..00000000000 --- a/apps/macos/Sources/OpenClaw/CanvasChromeContainerView.swift +++ /dev/null @@ -1,235 +0,0 @@ -import AppKit -import QuartzCore - -final class HoverChromeContainerView: NSView { - private let content: NSView - private let chrome: CanvasChromeOverlayView - private var tracking: NSTrackingArea? - var onClose: (() -> Void)? - - init(containing content: NSView) { - self.content = content - self.chrome = CanvasChromeOverlayView(frame: .zero) - super.init(frame: .zero) - - self.wantsLayer = true - self.layer?.cornerRadius = 12 - self.layer?.masksToBounds = true - self.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor - - self.content.translatesAutoresizingMaskIntoConstraints = false - self.addSubview(self.content) - - self.chrome.translatesAutoresizingMaskIntoConstraints = false - self.chrome.alphaValue = 0 - self.chrome.onClose = { [weak self] in self?.onClose?() } - self.addSubview(self.chrome) - - NSLayoutConstraint.activate([ - self.content.leadingAnchor.constraint(equalTo: self.leadingAnchor), - self.content.trailingAnchor.constraint(equalTo: self.trailingAnchor), - self.content.topAnchor.constraint(equalTo: self.topAnchor), - self.content.bottomAnchor.constraint(equalTo: self.bottomAnchor), - - self.chrome.leadingAnchor.constraint(equalTo: self.leadingAnchor), - self.chrome.trailingAnchor.constraint(equalTo: self.trailingAnchor), - self.chrome.topAnchor.constraint(equalTo: self.topAnchor), - self.chrome.bottomAnchor.constraint(equalTo: self.bottomAnchor), - ]) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) is not supported") - } - - override func updateTrackingAreas() { - super.updateTrackingAreas() - if let tracking { - self.removeTrackingArea(tracking) - } - let area = NSTrackingArea( - rect: self.bounds, - options: [.activeAlways, .mouseEnteredAndExited, .inVisibleRect], - owner: self, - userInfo: nil) - self.addTrackingArea(area) - self.tracking = area - } - - private final class CanvasDragHandleView: NSView { - override func mouseDown(with event: NSEvent) { - self.window?.performDrag(with: event) - } - - override func acceptsFirstMouse(for _: NSEvent?) -> Bool { - true - } - } - - private final class CanvasResizeHandleView: NSView { - private var startPoint: NSPoint = .zero - private var startFrame: NSRect = .zero - - override func acceptsFirstMouse(for _: NSEvent?) -> Bool { - true - } - - override func mouseDown(with event: NSEvent) { - guard let window else { return } - _ = window.makeFirstResponder(self) - self.startPoint = NSEvent.mouseLocation - self.startFrame = window.frame - super.mouseDown(with: event) - } - - override func mouseDragged(with _: NSEvent) { - guard let window else { return } - let current = NSEvent.mouseLocation - let dx = current.x - self.startPoint.x - let dy = current.y - self.startPoint.y - - var frame = self.startFrame - frame.size.width = max(CanvasLayout.minPanelSize.width, frame.size.width + dx) - frame.origin.y += dy - frame.size.height = max(CanvasLayout.minPanelSize.height, frame.size.height - dy) - - if let screen = window.screen { - frame = CanvasWindowController.constrainFrame(frame, toVisibleFrame: screen.visibleFrame) - } - window.setFrame(frame, display: true) - } - } - - private final class CanvasChromeOverlayView: NSView { - var onClose: (() -> Void)? - - private let dragHandle = CanvasDragHandleView(frame: .zero) - private let resizeHandle = CanvasResizeHandleView(frame: .zero) - - private final class PassthroughVisualEffectView: NSVisualEffectView { - override func hitTest(_: NSPoint) -> NSView? { - nil - } - } - - private let closeBackground: NSVisualEffectView = { - let v = PassthroughVisualEffectView(frame: .zero) - v.material = .hudWindow - v.blendingMode = .withinWindow - v.state = .active - v.appearance = NSAppearance(named: .vibrantDark) - v.wantsLayer = true - v.layer?.cornerRadius = 10 - v.layer?.masksToBounds = true - v.layer?.borderWidth = 1 - v.layer?.borderColor = NSColor.white.withAlphaComponent(0.22).cgColor - v.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.28).cgColor - v.layer?.shadowColor = NSColor.black.withAlphaComponent(0.35).cgColor - v.layer?.shadowOpacity = 0.35 - v.layer?.shadowRadius = 8 - v.layer?.shadowOffset = .zero - return v - }() - - private let closeButton: NSButton = { - let cfg = NSImage.SymbolConfiguration(pointSize: 8, weight: .semibold) - let img = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close")? - .withSymbolConfiguration(cfg) - ?? NSImage(size: NSSize(width: 18, height: 18)) - let btn = NSButton(image: img, target: nil, action: nil) - btn.isBordered = false - btn.bezelStyle = .regularSquare - btn.imageScaling = .scaleProportionallyDown - btn.contentTintColor = NSColor.white.withAlphaComponent(0.92) - btn.toolTip = "Close" - return btn - }() - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - - self.wantsLayer = true - self.layer?.cornerRadius = 12 - self.layer?.masksToBounds = true - self.layer?.borderWidth = 1 - self.layer?.borderColor = NSColor.black.withAlphaComponent(0.18).cgColor - self.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.02).cgColor - - self.dragHandle.translatesAutoresizingMaskIntoConstraints = false - self.dragHandle.wantsLayer = true - self.dragHandle.layer?.backgroundColor = NSColor.clear.cgColor - self.addSubview(self.dragHandle) - - self.resizeHandle.translatesAutoresizingMaskIntoConstraints = false - self.resizeHandle.wantsLayer = true - self.resizeHandle.layer?.backgroundColor = NSColor.clear.cgColor - self.addSubview(self.resizeHandle) - - self.closeBackground.translatesAutoresizingMaskIntoConstraints = false - self.addSubview(self.closeBackground) - - self.closeButton.translatesAutoresizingMaskIntoConstraints = false - self.closeButton.target = self - self.closeButton.action = #selector(self.handleClose) - self.addSubview(self.closeButton) - - NSLayoutConstraint.activate([ - self.dragHandle.leadingAnchor.constraint(equalTo: self.leadingAnchor), - self.dragHandle.trailingAnchor.constraint(equalTo: self.trailingAnchor), - self.dragHandle.topAnchor.constraint(equalTo: self.topAnchor), - self.dragHandle.heightAnchor.constraint(equalToConstant: 30), - - self.closeBackground.centerXAnchor.constraint(equalTo: self.closeButton.centerXAnchor), - self.closeBackground.centerYAnchor.constraint(equalTo: self.closeButton.centerYAnchor), - self.closeBackground.widthAnchor.constraint(equalToConstant: 20), - self.closeBackground.heightAnchor.constraint(equalToConstant: 20), - - self.closeButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -8), - self.closeButton.topAnchor.constraint(equalTo: self.topAnchor, constant: 8), - self.closeButton.widthAnchor.constraint(equalToConstant: 16), - self.closeButton.heightAnchor.constraint(equalToConstant: 16), - - self.resizeHandle.trailingAnchor.constraint(equalTo: self.trailingAnchor), - self.resizeHandle.bottomAnchor.constraint(equalTo: self.bottomAnchor), - self.resizeHandle.widthAnchor.constraint(equalToConstant: 18), - self.resizeHandle.heightAnchor.constraint(equalToConstant: 18), - ]) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) is not supported") - } - - override func hitTest(_ point: NSPoint) -> NSView? { - // When the chrome is hidden, do not intercept any mouse events (let the WKWebView receive them). - guard self.alphaValue > 0.02 else { return nil } - - if self.closeButton.frame.contains(point) { return self.closeButton } - if self.dragHandle.frame.contains(point) { return self.dragHandle } - if self.resizeHandle.frame.contains(point) { return self.resizeHandle } - return nil - } - - @objc private func handleClose() { - self.onClose?() - } - } - - override func mouseEntered(with _: NSEvent) { - NSAnimationContext.runAnimationGroup { ctx in - ctx.duration = 0.12 - ctx.timingFunction = CAMediaTimingFunction(name: .easeOut) - self.chrome.animator().alphaValue = 1 - } - } - - override func mouseExited(with _: NSEvent) { - NSAnimationContext.runAnimationGroup { ctx in - ctx.duration = 0.16 - ctx.timingFunction = CAMediaTimingFunction(name: .easeOut) - self.chrome.animator().alphaValue = 0 - } - } -} diff --git a/apps/macos/Sources/OpenClaw/CanvasFileWatcher.swift b/apps/macos/Sources/OpenClaw/CanvasFileWatcher.swift deleted file mode 100644 index 3ed0d67ffbc..00000000000 --- a/apps/macos/Sources/OpenClaw/CanvasFileWatcher.swift +++ /dev/null @@ -1,24 +0,0 @@ -import Foundation - -final class CanvasFileWatcher: @unchecked Sendable { - private let watcher: CoalescingFSEventsWatcher - - init(url: URL, onChange: @escaping () -> Void) { - self.watcher = CoalescingFSEventsWatcher( - paths: [url.path], - queueLabel: "ai.openclaw.canvaswatcher", - onChange: onChange) - } - - deinit { - self.stop() - } - - func start() { - self.watcher.start() - } - - func stop() { - self.watcher.stop() - } -} diff --git a/apps/macos/Sources/OpenClaw/CanvasManager.swift b/apps/macos/Sources/OpenClaw/CanvasManager.swift deleted file mode 100644 index 843f78842bd..00000000000 --- a/apps/macos/Sources/OpenClaw/CanvasManager.swift +++ /dev/null @@ -1,342 +0,0 @@ -import AppKit -import Foundation -import OpenClawIPC -import OpenClawKit -import OSLog - -@MainActor -final class CanvasManager { - static let shared = CanvasManager() - - private static let logger = Logger(subsystem: "ai.openclaw", category: "CanvasManager") - - private var panelController: CanvasWindowController? - private var panelSessionKey: String? - private var lastAutoA2UIUrl: String? - private var gatewayWatchTask: Task? - - private init() { - self.startGatewayObserver() - } - - var onPanelVisibilityChanged: ((Bool) -> Void)? - - /// Optional anchor provider (e.g. menu bar status item). If nil, Canvas anchors to the mouse cursor. - var defaultAnchorProvider: (() -> NSRect?)? - - private nonisolated static let canvasRoot: URL = { - let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first! - return base.appendingPathComponent("OpenClaw/canvas", isDirectory: true) - }() - - func show(sessionKey: String, path: String? = nil, placement: CanvasPlacement? = nil) throws -> String { - try self.showDetailed(sessionKey: sessionKey, target: path, placement: placement).directory - } - - func showDetailed( - sessionKey: String, - target: String? = nil, - placement: CanvasPlacement? = nil) throws -> CanvasShowResult - { - Self.logger.debug( - """ - showDetailed start session=\(sessionKey, privacy: .public) \ - target=\(target ?? "", privacy: .public) \ - placement=\(placement != nil) - """) - let anchorProvider = self.defaultAnchorProvider ?? Self.mouseAnchorProvider - let session = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) - let normalizedTarget = target? - .trimmingCharacters(in: .whitespacesAndNewlines) - .nonEmpty - - if let controller = self.panelController, self.panelSessionKey == session { - Self.logger.debug("showDetailed reuse existing session=\(session, privacy: .public)") - controller.onVisibilityChanged = { [weak self] visible in - self?.onPanelVisibilityChanged?(visible) - } - controller.presentAnchoredPanel(anchorProvider: anchorProvider) - controller.applyPreferredPlacement(placement) - self.refreshDebugStatus() - - // Existing session: only navigate when an explicit target was provided. - if let normalizedTarget { - controller.load(target: normalizedTarget) - return self.makeShowResult( - directory: controller.directoryPath, - target: target, - effectiveTarget: normalizedTarget) - } - - self.maybeAutoNavigateToA2UIAsync(controller: controller) - return CanvasShowResult( - directory: controller.directoryPath, - target: target, - effectiveTarget: nil, - status: .shown, - url: nil) - } - - Self.logger.debug("showDetailed creating new session=\(session, privacy: .public)") - self.panelController?.close() - self.panelController = nil - self.panelSessionKey = nil - - Self.logger.debug("showDetailed ensure canvas root dir") - try FileManager().createDirectory(at: Self.canvasRoot, withIntermediateDirectories: true) - Self.logger.debug("showDetailed init CanvasWindowController") - let controller = try CanvasWindowController( - sessionKey: session, - root: Self.canvasRoot, - presentation: .panel(anchorProvider: anchorProvider)) - Self.logger.debug("showDetailed CanvasWindowController init done") - controller.onVisibilityChanged = { [weak self] visible in - self?.onPanelVisibilityChanged?(visible) - } - self.panelController = controller - self.panelSessionKey = session - controller.applyPreferredPlacement(placement) - - // New session: default to "/" so the user sees either the welcome page or `index.html`. - let effectiveTarget = normalizedTarget ?? "/" - Self.logger.debug("showDetailed showCanvas effectiveTarget=\(effectiveTarget, privacy: .public)") - controller.showCanvas(path: effectiveTarget) - Self.logger.debug("showDetailed showCanvas done") - if normalizedTarget == nil { - self.maybeAutoNavigateToA2UIAsync(controller: controller) - } - self.refreshDebugStatus() - - return self.makeShowResult( - directory: controller.directoryPath, - target: target, - effectiveTarget: effectiveTarget) - } - - func hide(sessionKey: String) { - let session = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) - guard self.panelSessionKey == session else { return } - self.panelController?.hideCanvas() - } - - func hideAll() { - self.panelController?.hideCanvas() - } - - func eval(sessionKey: String, javaScript: String) async throws -> String { - _ = try self.show(sessionKey: sessionKey, path: nil) - guard let controller = self.panelController else { return "" } - return try await controller.eval(javaScript: javaScript) - } - - func snapshot(sessionKey: String, outPath: String?) async throws -> String { - _ = try self.show(sessionKey: sessionKey, path: nil) - guard let controller = self.panelController else { - throw NSError(domain: "Canvas", code: 21, userInfo: [NSLocalizedDescriptionKey: "canvas not available"]) - } - return try await controller.snapshot(to: outPath) - } - - // MARK: - Gateway A2UI auto-nav - - private func startGatewayObserver() { - self.gatewayWatchTask?.cancel() - self.gatewayWatchTask = Task { [weak self] in - guard let self else { return } - let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 1) - for await push in stream { - self.handleGatewayPush(push) - } - } - } - - private func handleGatewayPush(_ push: GatewayPush) { - guard case let .snapshot(snapshot) = push else { return } - let raw = snapshot.canvashosturl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if raw.isEmpty { - Self.logger.debug("canvas host url missing in gateway snapshot") - } else { - Self.logger.debug("canvas host url snapshot=\(raw, privacy: .public)") - } - let a2uiUrl = Self.resolveA2UIHostUrl(from: raw) - if a2uiUrl == nil, !raw.isEmpty { - Self.logger.debug("canvas host url invalid; cannot resolve A2UI") - } - guard let controller = self.panelController else { - if a2uiUrl != nil { - Self.logger.debug("canvas panel not visible; skipping auto-nav") - } - return - } - self.maybeAutoNavigateToA2UI(controller: controller, a2uiUrl: a2uiUrl) - } - - private func maybeAutoNavigateToA2UIAsync(controller: CanvasWindowController) { - Task { [weak self] in - guard let self else { return } - let a2uiUrl = await self.resolveA2UIHostUrl() - await MainActor.run { - guard self.panelController === controller else { return } - self.maybeAutoNavigateToA2UI(controller: controller, a2uiUrl: a2uiUrl) - } - } - } - - private func maybeAutoNavigateToA2UI(controller: CanvasWindowController, a2uiUrl: String?) { - guard let a2uiUrl else { return } - let shouldNavigate = controller.shouldAutoNavigateToA2UI(lastAutoTarget: self.lastAutoA2UIUrl) - guard shouldNavigate else { - Self.logger.debug("canvas auto-nav skipped; target unchanged") - return - } - Self.logger.debug("canvas auto-nav -> \(a2uiUrl, privacy: .public)") - controller.load(target: a2uiUrl) - self.lastAutoA2UIUrl = a2uiUrl - } - - private func resolveA2UIHostUrl() async -> String? { - let raw = await GatewayConnection.shared.canvasHostUrl() - return Self.resolveA2UIHostUrl(from: raw) - } - - func refreshDebugStatus() { - guard let controller = self.panelController else { return } - let enabled = AppStateStore.shared.debugPaneEnabled - let mode = AppStateStore.shared.connectionMode - let title: String? - let subtitle: String? - switch mode { - case .remote: - title = "Remote control" - switch ControlChannel.shared.state { - case .connected: - subtitle = "Connected" - case .connecting: - subtitle = "Connecting…" - case .disconnected: - subtitle = "Disconnected" - case let .degraded(message): - subtitle = message.isEmpty ? "Degraded" : message - } - case .local: - title = GatewayProcessManager.shared.status.label - subtitle = mode.rawValue - case .unconfigured: - title = "Unconfigured" - subtitle = mode.rawValue - } - controller.updateDebugStatus(enabled: enabled, title: title, subtitle: subtitle) - } - - private static func resolveA2UIHostUrl(from raw: String?) -> String? { - let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil } - return base.appendingPathComponent("__openclaw__/a2ui/").absoluteString + "?platform=macos" - } - - // MARK: - Anchoring - - private static func mouseAnchorProvider() -> NSRect? { - let pt = NSEvent.mouseLocation - return NSRect(x: pt.x, y: pt.y, width: 1, height: 1) - } - - // placement interpretation is handled by the window controller. - - // MARK: - Helpers - - private static func directURL(for target: String?) -> URL? { - guard let target else { return nil } - let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - - if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased() { - if scheme == "https" || scheme == "http" || scheme == "file" { return url } - } - - // Convenience: existing absolute *file* paths resolve as local files. - // (Avoid treating Canvas routes like "/" as filesystem paths.) - if trimmed.hasPrefix("/") { - var isDir: ObjCBool = false - if FileManager().fileExists(atPath: trimmed, isDirectory: &isDir), !isDir.boolValue { - return URL(fileURLWithPath: trimmed) - } - } - - return nil - } - - private func makeShowResult( - directory: String, - target: String?, - effectiveTarget: String) -> CanvasShowResult - { - if let url = Self.directURL(for: effectiveTarget) { - return CanvasShowResult( - directory: directory, - target: target, - effectiveTarget: effectiveTarget, - status: .web, - url: url.absoluteString) - } - - let sessionDir = URL(fileURLWithPath: directory) - let status = Self.localStatus(sessionDir: sessionDir, target: effectiveTarget) - let host = sessionDir.lastPathComponent - let canvasURL = CanvasScheme.makeURL(session: host, path: effectiveTarget)?.absoluteString - return CanvasShowResult( - directory: directory, - target: target, - effectiveTarget: effectiveTarget, - status: status, - url: canvasURL) - } - - private static func localStatus(sessionDir: URL, target: String) -> CanvasShowStatus { - let fm = FileManager() - let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines) - let withoutQuery = trimmed.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false).first - .map(String.init) ?? trimmed - var path = withoutQuery - if path.hasPrefix("/") { path.removeFirst() } - path = path.removingPercentEncoding ?? path - - // Root special-case: built-in scaffold page when no index exists. - if path.isEmpty { - let a = sessionDir.appendingPathComponent("index.html", isDirectory: false) - let b = sessionDir.appendingPathComponent("index.htm", isDirectory: false) - if fm.fileExists(atPath: a.path) || fm.fileExists(atPath: b.path) { return .ok } - return .welcome - } - - // Direct file or directory. - var candidate = sessionDir.appendingPathComponent(path, isDirectory: false) - var isDir: ObjCBool = false - if fm.fileExists(atPath: candidate.path, isDirectory: &isDir) { - if isDir.boolValue { - return Self.indexExists(in: candidate) ? .ok : .notFound - } - return .ok - } - - // Directory index behavior ("/yolo" -> "yolo/index.html") if directory exists. - if !path.isEmpty, !path.hasSuffix("/") { - candidate = sessionDir.appendingPathComponent(path, isDirectory: true) - if fm.fileExists(atPath: candidate.path, isDirectory: &isDir), isDir.boolValue { - return Self.indexExists(in: candidate) ? .ok : .notFound - } - } - - return .notFound - } - - private static func indexExists(in dir: URL) -> Bool { - let fm = FileManager() - let a = dir.appendingPathComponent("index.html", isDirectory: false) - if fm.fileExists(atPath: a.path) { return true } - let b = dir.appendingPathComponent("index.htm", isDirectory: false) - return fm.fileExists(atPath: b.path) - } - - // no bundled A2UI shell; scaffold fallback is purely visual -} diff --git a/apps/macos/Sources/OpenClaw/CanvasScheme.swift b/apps/macos/Sources/OpenClaw/CanvasScheme.swift deleted file mode 100644 index 4f08da2d7b3..00000000000 --- a/apps/macos/Sources/OpenClaw/CanvasScheme.swift +++ /dev/null @@ -1,42 +0,0 @@ -import Foundation - -enum CanvasScheme { - static let scheme = "openclaw-canvas" - static let allSchemes = [scheme] - - static func makeURL(session: String, path: String? = nil) -> URL? { - var comps = URLComponents() - comps.scheme = Self.scheme - comps.host = session - let p = (path ?? "/").trimmingCharacters(in: .whitespacesAndNewlines) - if p.isEmpty || p == "/" { - comps.path = "/" - } else if p.hasPrefix("/") { - comps.path = p - } else { - comps.path = "/" + p - } - return comps.url - } - - static func mimeType(forExtension ext: String) -> String { - switch ext.lowercased() { - // Note: WKURLSchemeHandler uses URLResponse(mimeType:), which expects a bare MIME type - // (no `; charset=...`). Encoding is provided via URLResponse(textEncodingName:). - case "html", "htm": "text/html" - case "js", "mjs": "application/javascript" - case "css": "text/css" - case "json", "map": "application/json" - case "svg": "image/svg+xml" - case "png": "image/png" - case "jpg", "jpeg": "image/jpeg" - case "gif": "image/gif" - case "ico": "image/x-icon" - case "woff2": "font/woff2" - case "woff": "font/woff" - case "ttf": "font/ttf" - case "wasm": "application/wasm" - default: "application/octet-stream" - } - } -} diff --git a/apps/macos/Sources/OpenClaw/CanvasSchemeHandler.swift b/apps/macos/Sources/OpenClaw/CanvasSchemeHandler.swift deleted file mode 100644 index 6905af50014..00000000000 --- a/apps/macos/Sources/OpenClaw/CanvasSchemeHandler.swift +++ /dev/null @@ -1,259 +0,0 @@ -import Foundation -import OpenClawKit -import OSLog -import WebKit - -private let canvasLogger = Logger(subsystem: "ai.openclaw", category: "Canvas") - -final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler { - private let root: URL - - init(root: URL) { - self.root = root - } - - func webView(_: WKWebView, start urlSchemeTask: WKURLSchemeTask) { - guard let url = urlSchemeTask.request.url else { - urlSchemeTask.didFailWithError(NSError(domain: "Canvas", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "missing url", - ])) - return - } - - let response = self.response(for: url) - let mime = response.mime - let data = response.data - let encoding = self.textEncodingName(forMimeType: mime) - - let urlResponse = URLResponse( - url: url, - mimeType: mime, - expectedContentLength: data.count, - textEncodingName: encoding) - urlSchemeTask.didReceive(urlResponse) - urlSchemeTask.didReceive(data) - urlSchemeTask.didFinish() - } - - func webView(_: WKWebView, stop _: WKURLSchemeTask) { - // no-op - } - - private struct CanvasResponse { - let mime: String - let data: Data - } - - private func response(for url: URL) -> CanvasResponse { - guard let scheme = url.scheme, CanvasScheme.allSchemes.contains(scheme) else { - return self.html("Invalid scheme.") - } - guard let session = url.host, !session.isEmpty else { - return self.html("Missing session.") - } - - // Keep session component safe; don't allow slashes or traversal. - if session.contains("/") || session.contains("..") { - return self.html("Invalid session.") - } - - let sessionRoot = self.root.appendingPathComponent(session, isDirectory: true) - - // Path mapping: request path maps directly into the session dir. - var path = url.path - if let qIdx = path.firstIndex(of: "?") { path = String(path[.. \(servedPath, privacy: .public)") - return CanvasResponse(mime: mime, data: data) - } catch { - let failedPath = standardizedFile.path - let errorText = error.localizedDescription - canvasLogger - .error( - "failed reading \(failedPath, privacy: .public): \(errorText, privacy: .public)") - return self.html("Failed to read file.", title: "Canvas error") - } - } - - private func resolveFileURL(sessionRoot: URL, requestPath: String) -> URL? { - let fm = FileManager() - var candidate = sessionRoot.appendingPathComponent(requestPath, isDirectory: false) - - var isDir: ObjCBool = false - if fm.fileExists(atPath: candidate.path, isDirectory: &isDir) { - if isDir.boolValue { - if let idx = self.resolveIndex(in: candidate) { return idx } - return nil - } - return candidate - } - - // Directory index behavior: - // - "/yolo" serves "/index.html" if that directory exists. - if !requestPath.isEmpty, !requestPath.hasSuffix("/") { - candidate = sessionRoot.appendingPathComponent(requestPath, isDirectory: true) - if fm.fileExists(atPath: candidate.path, isDirectory: &isDir), isDir.boolValue { - if let idx = self.resolveIndex(in: candidate) { return idx } - } - } - - // Root fallback: - // - "/" serves "/index.html" if present. - if requestPath.isEmpty { - return self.resolveIndex(in: sessionRoot) - } - - return nil - } - - private func resolveIndex(in dir: URL) -> URL? { - let fm = FileManager() - let a = dir.appendingPathComponent("index.html", isDirectory: false) - if fm.fileExists(atPath: a.path) { return a } - let b = dir.appendingPathComponent("index.htm", isDirectory: false) - if fm.fileExists(atPath: b.path) { return b } - return nil - } - - private func html(_ body: String, title: String = "Canvas") -> CanvasResponse { - let html = """ - - - - - - \(title) - - - -
-
\(body)
-
- - - """ - return CanvasResponse(mime: "text/html", data: Data(html.utf8)) - } - - private func welcomePage(sessionRoot: URL) -> CanvasResponse { - let escaped = sessionRoot.path - .replacingOccurrences(of: "&", with: "&") - .replacingOccurrences(of: "<", with: "<") - .replacingOccurrences(of: ">", with: ">") - let body = """ -
Canvas is ready.
-
Create index.html in:
-
\(escaped)
- """ - return self.html(body, title: "Canvas") - } - - private func scaffoldPage(sessionRoot: URL) -> CanvasResponse { - // Default Canvas UX: when no index exists, show the built-in scaffold page. - if let data = self.loadBundledResourceData(relativePath: "CanvasScaffold/scaffold.html") { - return CanvasResponse(mime: "text/html", data: data) - } - - // Fallback for dev misconfiguration: show the classic welcome page. - return self.welcomePage(sessionRoot: sessionRoot) - } - - private func loadBundledResourceData(relativePath: String) -> Data? { - let trimmed = relativePath.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - if trimmed.contains("..") || trimmed.contains("\\") { return nil } - - let parts = trimmed.split(separator: "/") - guard let filename = parts.last else { return nil } - let subdirectory = - parts.count > 1 ? parts.dropLast().joined(separator: "/") : nil - let fileURL = URL(fileURLWithPath: String(filename)) - let ext = fileURL.pathExtension - let name = fileURL.deletingPathExtension().lastPathComponent - guard !name.isEmpty, !ext.isEmpty else { return nil } - - let bundle = OpenClawKitResources.bundle - let resourceURL = - bundle.url(forResource: name, withExtension: ext, subdirectory: subdirectory) - ?? bundle.url(forResource: name, withExtension: ext) - guard let resourceURL else { return nil } - return try? Data(contentsOf: resourceURL) - } - - private func textEncodingName(forMimeType mimeType: String) -> String? { - if mimeType.hasPrefix("text/") { return "utf-8" } - switch mimeType { - case "application/javascript", "application/json", "image/svg+xml": - return "utf-8" - default: - return nil - } - } -} - -#if DEBUG -extension CanvasSchemeHandler { - func _testResponse(for url: URL) -> (mime: String, data: Data) { - let response = self.response(for: url) - return (response.mime, response.data) - } - - func _testResolveFileURL(sessionRoot: URL, requestPath: String) -> URL? { - self.resolveFileURL(sessionRoot: sessionRoot, requestPath: requestPath) - } - - func _testTextEncodingName(for mimeType: String) -> String? { - self.textEncodingName(forMimeType: mimeType) - } -} -#endif diff --git a/apps/macos/Sources/OpenClaw/CanvasWindow.swift b/apps/macos/Sources/OpenClaw/CanvasWindow.swift deleted file mode 100644 index a87f3256170..00000000000 --- a/apps/macos/Sources/OpenClaw/CanvasWindow.swift +++ /dev/null @@ -1,31 +0,0 @@ -import AppKit - -let canvasWindowLogger = Logger(subsystem: "ai.openclaw", category: "Canvas") - -enum CanvasLayout { - static let panelSize = NSSize(width: 520, height: 680) - static let windowSize = NSSize(width: 1120, height: 840) - static let anchorPadding: CGFloat = 8 - static let defaultPadding: CGFloat = 10 - static let minPanelSize = NSSize(width: 360, height: 360) -} - -final class CanvasPanel: NSPanel { - override var canBecomeKey: Bool { - true - } - - override var canBecomeMain: Bool { - true - } -} - -enum CanvasPresentation { - case window - case panel(anchorProvider: () -> NSRect?) - - var isPanel: Bool { - if case .panel = self { return true } - return false - } -} diff --git a/apps/macos/Sources/OpenClaw/CanvasWindowController+Helpers.swift b/apps/macos/Sources/OpenClaw/CanvasWindowController+Helpers.swift deleted file mode 100644 index a7d10f95b56..00000000000 --- a/apps/macos/Sources/OpenClaw/CanvasWindowController+Helpers.swift +++ /dev/null @@ -1,43 +0,0 @@ -import AppKit -import Foundation - -extension CanvasWindowController { - // MARK: - Helpers - - static func sanitizeSessionKey(_ key: String) -> String { - let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { return "main" } - let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-+") - let scalars = trimmed.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" } - return String(scalars) - } - - static func jsStringLiteral(_ value: String) -> String { - let data = try? JSONEncoder().encode(value) - return data.flatMap { String(data: $0, encoding: .utf8) } ?? "\"\"" - } - - static func jsOptionalStringLiteral(_ value: String?) -> String { - guard let value else { return "null" } - return Self.jsStringLiteral(value) - } - - static func storedFrameDefaultsKey(sessionKey: String) -> String { - "openclaw.canvas.frame.\(self.sanitizeSessionKey(sessionKey))" - } - - static func loadRestoredFrame(sessionKey: String) -> NSRect? { - let key = self.storedFrameDefaultsKey(sessionKey: sessionKey) - guard let arr = UserDefaults.standard.array(forKey: key) as? [Double], arr.count == 4 else { return nil } - let rect = NSRect(x: arr[0], y: arr[1], width: arr[2], height: arr[3]) - if rect.width < CanvasLayout.minPanelSize.width || rect.height < CanvasLayout.minPanelSize.height { return nil } - return rect - } - - static func storeRestoredFrame(_ frame: NSRect, sessionKey: String) { - let key = self.storedFrameDefaultsKey(sessionKey: sessionKey) - UserDefaults.standard.set( - [Double(frame.origin.x), Double(frame.origin.y), Double(frame.size.width), Double(frame.size.height)], - forKey: key) - } -} diff --git a/apps/macos/Sources/OpenClaw/CanvasWindowController+Navigation.swift b/apps/macos/Sources/OpenClaw/CanvasWindowController+Navigation.swift deleted file mode 100644 index 16e0b01d294..00000000000 --- a/apps/macos/Sources/OpenClaw/CanvasWindowController+Navigation.swift +++ /dev/null @@ -1,64 +0,0 @@ -import AppKit -import WebKit - -extension CanvasWindowController { - // MARK: - WKNavigationDelegate - - @MainActor - func webView( - _: WKWebView, - decidePolicyFor navigationAction: WKNavigationAction, - decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void) - { - guard let url = navigationAction.request.url else { - decisionHandler(.cancel) - return - } - let scheme = url.scheme?.lowercased() - - // Deep links: allow local Canvas content to invoke the agent without bouncing through NSWorkspace. - if scheme == "openclaw" { - if let currentScheme = self.webView.url?.scheme, - CanvasScheme.allSchemes.contains(currentScheme) - { - Task { await DeepLinkHandler.shared.handle(url: url) } - } else { - canvasWindowLogger - .debug("ignoring deep link from non-canvas page \(url.absoluteString, privacy: .public)") - } - decisionHandler(.cancel) - return - } - - // Keep web content inside the panel when reasonable. - // `about:blank` and friends are common internal navigations for WKWebView; never send them to NSWorkspace. - if CanvasScheme.allSchemes.contains(scheme ?? "") - || scheme == "https" - || scheme == "http" - || scheme == "about" - || scheme == "blob" - || scheme == "data" - || scheme == "javascript" - { - decisionHandler(.allow) - return - } - - // Only open external URLs when there is a registered handler, otherwise macOS will show a confusing - // "There is no application set to open the URL ..." alert (e.g. for about:blank). - if let appURL = NSWorkspace.shared.urlForApplication(toOpen: url) { - NSWorkspace.shared.open( - [url], - withApplicationAt: appURL, - configuration: NSWorkspace.OpenConfiguration(), - completionHandler: nil) - } else { - canvasWindowLogger.debug("no application to open url \(url.absoluteString, privacy: .public)") - } - decisionHandler(.cancel) - } - - func webView(_: WKWebView, didFinish _: WKNavigation?) { - self.applyDebugStatusIfNeeded() - } -} diff --git a/apps/macos/Sources/OpenClaw/CanvasWindowController+Testing.swift b/apps/macos/Sources/OpenClaw/CanvasWindowController+Testing.swift deleted file mode 100644 index 6c53fbc9971..00000000000 --- a/apps/macos/Sources/OpenClaw/CanvasWindowController+Testing.swift +++ /dev/null @@ -1,39 +0,0 @@ -#if DEBUG -import AppKit -import Foundation - -extension CanvasWindowController { - static func _testSanitizeSessionKey(_ key: String) -> String { - self.sanitizeSessionKey(key) - } - - static func _testJSStringLiteral(_ value: String) -> String { - self.jsStringLiteral(value) - } - - static func _testJSOptionalStringLiteral(_ value: String?) -> String { - self.jsOptionalStringLiteral(value) - } - - static func _testStoredFrameKey(sessionKey: String) -> String { - self.storedFrameDefaultsKey(sessionKey: sessionKey) - } - - static func _testStoreAndLoadFrame(sessionKey: String, frame: NSRect) -> NSRect? { - self.storeRestoredFrame(frame, sessionKey: sessionKey) - return self.loadRestoredFrame(sessionKey: sessionKey) - } - - static func _testParseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? { - CanvasA2UIActionMessageHandler.parseIPv4(host) - } - - static func _testIsLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool { - CanvasA2UIActionMessageHandler.isLocalNetworkIPv4(ip) - } - - static func _testIsLocalNetworkCanvasURL(_ url: URL) -> Bool { - CanvasA2UIActionMessageHandler.isLocalNetworkCanvasURL(url) - } -} -#endif diff --git a/apps/macos/Sources/OpenClaw/CanvasWindowController+Window.swift b/apps/macos/Sources/OpenClaw/CanvasWindowController+Window.swift deleted file mode 100644 index 042ee00ba97..00000000000 --- a/apps/macos/Sources/OpenClaw/CanvasWindowController+Window.swift +++ /dev/null @@ -1,166 +0,0 @@ -import AppKit -import OpenClawIPC - -extension CanvasWindowController { - // MARK: - Window - - static func makeWindow(for presentation: CanvasPresentation, contentView: NSView) -> NSWindow { - switch presentation { - case .window: - let window = NSWindow( - contentRect: NSRect(origin: .zero, size: CanvasLayout.windowSize), - styleMask: [.titled, .closable, .resizable, .miniaturizable], - backing: .buffered, - defer: false) - window.title = "OpenClaw Canvas" - window.isReleasedWhenClosed = false - window.contentView = contentView - window.center() - window.minSize = NSSize(width: 880, height: 680) - return window - - case .panel: - let panel = CanvasPanel( - contentRect: NSRect(origin: .zero, size: CanvasLayout.panelSize), - styleMask: [.borderless, .resizable], - backing: .buffered, - defer: false) - // Keep Canvas below the Voice Wake overlay panel. - panel.level = NSWindow.Level(rawValue: NSWindow.Level.statusBar.rawValue - 1) - panel.hasShadow = true - panel.isMovable = false - panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] - panel.titleVisibility = .hidden - panel.titlebarAppearsTransparent = true - panel.backgroundColor = .clear - panel.isOpaque = false - panel.contentView = contentView - panel.becomesKeyOnlyIfNeeded = true - panel.hidesOnDeactivate = false - panel.minSize = CanvasLayout.minPanelSize - return panel - } - } - - func presentAnchoredPanel(anchorProvider: @escaping () -> NSRect?) { - guard case .panel = self.presentation, let window else { return } - self.repositionPanel(using: anchorProvider) - window.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - window.makeFirstResponder(self.webView) - VoiceWakeOverlayController.shared.bringToFrontIfVisible() - self.onVisibilityChanged?(true) - } - - func repositionPanel(using anchorProvider: () -> NSRect?) { - guard let panel = self.window else { return } - let anchor = anchorProvider() - let targetScreen = Self.screen(forAnchor: anchor) - ?? Self.screenContainingMouseCursor() - ?? panel.screen - ?? NSScreen.main - ?? NSScreen.screens.first - - let restored = Self.loadRestoredFrame(sessionKey: self.sessionKey) - let restoredIsValid = if let restored, let targetScreen { - Self.isFrameMeaningfullyVisible(restored, on: targetScreen) - } else { - restored != nil - } - - var frame = if let restored, restoredIsValid { - restored - } else { - Self.defaultTopRightFrame(panel: panel, screen: targetScreen) - } - - // Apply agent placement as partial overrides: - // - If agent provides x/y, override origin. - // - If agent provides width/height, override size. - // - If agent provides only size, keep the remembered origin. - if let placement = self.preferredPlacement { - if let x = placement.x { frame.origin.x = x } - if let y = placement.y { frame.origin.y = y } - if let w = placement.width { frame.size.width = max(CanvasLayout.minPanelSize.width, CGFloat(w)) } - if let h = placement.height { frame.size.height = max(CanvasLayout.minPanelSize.height, CGFloat(h)) } - } - - self.setPanelFrame(frame, on: targetScreen) - } - - static func defaultTopRightFrame(panel: NSWindow, screen: NSScreen?) -> NSRect { - let w = max(CanvasLayout.minPanelSize.width, panel.frame.width) - let h = max(CanvasLayout.minPanelSize.height, panel.frame.height) - return WindowPlacement.topRightFrame( - size: NSSize(width: w, height: h), - padding: CanvasLayout.defaultPadding, - on: screen) - } - - func setPanelFrame(_ frame: NSRect, on screen: NSScreen?) { - guard let panel = self.window else { return } - guard let s = screen ?? panel.screen ?? NSScreen.main ?? NSScreen.screens.first else { - panel.setFrame(frame, display: false) - self.persistFrameIfPanel() - return - } - - let constrained = Self.constrainFrame(frame, toVisibleFrame: s.visibleFrame) - panel.setFrame(constrained, display: false) - self.persistFrameIfPanel() - } - - static func screen(forAnchor anchor: NSRect?) -> NSScreen? { - guard let anchor else { return nil } - let center = NSPoint(x: anchor.midX, y: anchor.midY) - return NSScreen.screens.first { screen in - screen.frame.contains(anchor.origin) || screen.frame.contains(center) - } - } - - static func screenContainingMouseCursor() -> NSScreen? { - let point = NSEvent.mouseLocation - return NSScreen.screens.first { $0.frame.contains(point) } - } - - static func isFrameMeaningfullyVisible(_ frame: NSRect, on screen: NSScreen) -> Bool { - frame.intersects(screen.visibleFrame.insetBy(dx: 12, dy: 12)) - } - - static func constrainFrame(_ frame: NSRect, toVisibleFrame bounds: NSRect) -> NSRect { - if bounds == .zero { return frame } - - var next = frame - next.size.width = min(max(CanvasLayout.minPanelSize.width, next.size.width), bounds.width) - next.size.height = min(max(CanvasLayout.minPanelSize.height, next.size.height), bounds.height) - - let maxX = bounds.maxX - next.size.width - let maxY = bounds.maxY - next.size.height - - next.origin.x = maxX >= bounds.minX ? min(max(next.origin.x, bounds.minX), maxX) : bounds.minX - next.origin.y = maxY >= bounds.minY ? min(max(next.origin.y, bounds.minY), maxY) : bounds.minY - - next.origin.x = round(next.origin.x) - next.origin.y = round(next.origin.y) - return next - } - - // MARK: - NSWindowDelegate - - func windowWillClose(_: Notification) { - self.onVisibilityChanged?(false) - } - - func windowDidMove(_: Notification) { - self.persistFrameIfPanel() - } - - func windowDidEndLiveResize(_: Notification) { - self.persistFrameIfPanel() - } - - func persistFrameIfPanel() { - guard case .panel = self.presentation, let window else { return } - Self.storeRestoredFrame(window.frame, sessionKey: self.sessionKey) - } -} diff --git a/apps/macos/Sources/OpenClaw/CanvasWindowController.swift b/apps/macos/Sources/OpenClaw/CanvasWindowController.swift deleted file mode 100644 index d30f54186ae..00000000000 --- a/apps/macos/Sources/OpenClaw/CanvasWindowController.swift +++ /dev/null @@ -1,373 +0,0 @@ -import AppKit -import Foundation -import OpenClawIPC -import OpenClawKit -import WebKit - -@MainActor -final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NSWindowDelegate { - let sessionKey: String - private let root: URL - private let sessionDir: URL - private let schemeHandler: CanvasSchemeHandler - let webView: WKWebView - private var a2uiActionMessageHandler: CanvasA2UIActionMessageHandler? - private let watcher: CanvasFileWatcher - private let container: HoverChromeContainerView - let presentation: CanvasPresentation - var preferredPlacement: CanvasPlacement? - private(set) var currentTarget: String? - private var debugStatusEnabled = false - private var debugStatusTitle: String? - private var debugStatusSubtitle: String? - - var onVisibilityChanged: ((Bool) -> Void)? - - init(sessionKey: String, root: URL, presentation: CanvasPresentation) throws { - self.sessionKey = sessionKey - self.root = root - self.presentation = presentation - - canvasWindowLogger.debug("CanvasWindowController init start session=\(sessionKey, privacy: .public)") - let safeSessionKey = CanvasWindowController.sanitizeSessionKey(sessionKey) - canvasWindowLogger.debug("CanvasWindowController init sanitized session=\(safeSessionKey, privacy: .public)") - self.sessionDir = root.appendingPathComponent(safeSessionKey, isDirectory: true) - try FileManager().createDirectory(at: self.sessionDir, withIntermediateDirectories: true) - canvasWindowLogger.debug("CanvasWindowController init session dir ready") - - self.schemeHandler = CanvasSchemeHandler(root: root) - canvasWindowLogger.debug("CanvasWindowController init scheme handler ready") - - let config = WKWebViewConfiguration() - config.userContentController = WKUserContentController() - config.preferences.isElementFullscreenEnabled = true - config.preferences.setValue(true, forKey: "developerExtrasEnabled") - canvasWindowLogger.debug("CanvasWindowController init config ready") - for scheme in CanvasScheme.allSchemes { - config.setURLSchemeHandler(self.schemeHandler, forURLScheme: scheme) - } - canvasWindowLogger.debug("CanvasWindowController init scheme handler installed") - - // Bridge A2UI "a2uiaction" DOM events back into the native agent loop. - // - // Prefer WKScriptMessageHandler when WebKit exposes it, otherwise fall back to an unattended deep link - // (includes the app-generated key so it won't prompt). - canvasWindowLogger.debug("CanvasWindowController init building A2UI bridge script") - let deepLinkKey = DeepLinkHandler.currentCanvasKey() - let injectedSessionKey = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "main" - let bridgeScript = """ - (() => { - try { - const allowedSchemes = \(String(describing: CanvasScheme.allSchemes)); - const protocol = location.protocol.replace(':', ''); - if (!allowedSchemes.includes(protocol)) return; - if (globalThis.__openclawA2UIBridgeInstalled) return; - globalThis.__openclawA2UIBridgeInstalled = true; - - const deepLinkKey = \(Self.jsStringLiteral(deepLinkKey)); - const sessionKey = \(Self.jsStringLiteral(injectedSessionKey)); - const machineName = \(Self.jsStringLiteral(InstanceIdentity.displayName)); - const instanceId = \(Self.jsStringLiteral(InstanceIdentity.instanceId)); - - globalThis.addEventListener('a2uiaction', (evt) => { - try { - const payload = evt?.detail ?? evt?.payload ?? null; - if (!payload || payload.eventType !== 'a2ui.action') return; - - const action = payload.action ?? null; - const name = action?.name ?? ''; - if (!name) return; - - const context = Array.isArray(action?.context) ? action.context : []; - const userAction = { - id: (globalThis.crypto?.randomUUID?.() ?? String(Date.now())), - name, - surfaceId: payload.surfaceId ?? 'main', - sourceComponentId: payload.sourceComponentId ?? '', - dataContextPath: payload.dataContextPath ?? '', - timestamp: new Date().toISOString(), - ...(context.length ? { context } : {}), - }; - - const handler = globalThis.webkit?.messageHandlers?.openclawCanvasA2UIAction; - - // If the bundled A2UI shell is present, let it forward actions so we keep its richer - // context resolution (data model path lookups, surface detection, etc.). - const hasBundledA2UIHost = - !!globalThis.openclawA2UI || - !!document.querySelector('openclaw-a2ui-host'); - if (hasBundledA2UIHost && handler?.postMessage) return; - - // Otherwise, forward directly when possible. - if (!hasBundledA2UIHost && handler?.postMessage) { - handler.postMessage({ userAction }); - return; - } - - const ctx = userAction.context ? (' ctx=' + JSON.stringify(userAction.context)) : ''; - const message = - 'CANVAS_A2UI action=' + userAction.name + - ' session=' + sessionKey + - ' surface=' + userAction.surfaceId + - ' component=' + (userAction.sourceComponentId || '-') + - ' host=' + machineName.replace(/\\s+/g, '_') + - ' instance=' + instanceId + - ctx + - ' default=update_canvas'; - const params = new URLSearchParams(); - params.set('message', message); - params.set('sessionKey', sessionKey); - params.set('thinking', 'low'); - params.set('deliver', 'false'); - params.set('channel', 'last'); - params.set('key', deepLinkKey); - location.href = 'openclaw://agent?' + params.toString(); - } catch {} - }, true); - } catch {} - })(); - """ - config.userContentController.addUserScript( - WKUserScript(source: bridgeScript, injectionTime: .atDocumentStart, forMainFrameOnly: true)) - canvasWindowLogger.debug("CanvasWindowController init A2UI bridge installed") - - canvasWindowLogger.debug("CanvasWindowController init creating WKWebView") - self.webView = WKWebView(frame: .zero, configuration: config) - // Canvas scaffold is a fully self-contained HTML page; avoid relying on transparency underlays. - self.webView.setValue(true, forKey: "drawsBackground") - - let sessionDir = self.sessionDir - let webView = self.webView - self.watcher = CanvasFileWatcher(url: sessionDir) { [weak webView] in - Task { @MainActor in - guard let webView else { return } - - // Only auto-reload when we are showing local canvas content. - guard let scheme = webView.url?.scheme, - CanvasScheme.allSchemes.contains(scheme) else { return } - - let path = webView.url?.path ?? "" - if path == "/" || path.isEmpty { - let indexA = sessionDir.appendingPathComponent("index.html", isDirectory: false) - let indexB = sessionDir.appendingPathComponent("index.htm", isDirectory: false) - if !FileManager().fileExists(atPath: indexA.path), - !FileManager().fileExists(atPath: indexB.path) - { - return - } - } - - webView.reload() - } - } - - self.container = HoverChromeContainerView(containing: self.webView) - let window = Self.makeWindow(for: presentation, contentView: self.container) - canvasWindowLogger.debug("CanvasWindowController init makeWindow done") - super.init(window: window) - - let handler = CanvasA2UIActionMessageHandler(sessionKey: sessionKey) - self.a2uiActionMessageHandler = handler - for name in CanvasA2UIActionMessageHandler.allMessageNames { - self.webView.configuration.userContentController.add(handler, name: name) - } - - self.webView.navigationDelegate = self - self.window?.delegate = self - self.container.onClose = { [weak self] in - self?.hideCanvas() - } - - self.watcher.start() - canvasWindowLogger.debug("CanvasWindowController init done") - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) is not supported") - } - - @MainActor deinit { - for name in CanvasA2UIActionMessageHandler.allMessageNames { - self.webView.configuration.userContentController.removeScriptMessageHandler(forName: name) - } - self.watcher.stop() - } - - func applyPreferredPlacement(_ placement: CanvasPlacement?) { - self.preferredPlacement = placement - } - - func showCanvas(path: String? = nil) { - if case let .panel(anchorProvider) = self.presentation { - self.presentAnchoredPanel(anchorProvider: anchorProvider) - if let path { - self.load(target: path) - } - return - } - - self.showWindow(nil) - self.window?.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - if let path { - self.load(target: path) - } - self.onVisibilityChanged?(true) - } - - func hideCanvas() { - if case .panel = self.presentation { - self.persistFrameIfPanel() - } - self.window?.orderOut(nil) - self.onVisibilityChanged?(false) - } - - func load(target: String) { - let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines) - self.currentTarget = trimmed - - if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased() { - if scheme == "https" || scheme == "http" { - canvasWindowLogger.debug("canvas load url \(url.absoluteString, privacy: .public)") - self.webView.load(URLRequest(url: url)) - return - } - if scheme == "file" { - canvasWindowLogger.debug("canvas load file \(url.absoluteString, privacy: .public)") - self.loadFile(url) - return - } - } - - // Convenience: absolute file paths resolve as local files when they exist. - // (Avoid treating Canvas routes like "/" as filesystem paths.) - if trimmed.hasPrefix("/") { - var isDir: ObjCBool = false - if FileManager().fileExists(atPath: trimmed, isDirectory: &isDir), !isDir.boolValue { - let url = URL(fileURLWithPath: trimmed) - canvasWindowLogger.debug("canvas load file \(url.absoluteString, privacy: .public)") - self.loadFile(url) - return - } - } - - guard let url = CanvasScheme.makeURL( - session: CanvasWindowController.sanitizeSessionKey(self.sessionKey), - path: trimmed) - else { - canvasWindowLogger - .error( - "invalid canvas url session=\(self.sessionKey, privacy: .public) path=\(trimmed, privacy: .public)") - return - } - canvasWindowLogger.debug("canvas load canvas \(url.absoluteString, privacy: .public)") - self.webView.load(URLRequest(url: url)) - } - - func updateDebugStatus(enabled: Bool, title: String?, subtitle: String?) { - self.debugStatusEnabled = enabled - self.debugStatusTitle = title - self.debugStatusSubtitle = subtitle - self.applyDebugStatusIfNeeded() - } - - func applyDebugStatusIfNeeded() { - let enabled = self.debugStatusEnabled - let title = Self.jsOptionalStringLiteral(self.debugStatusTitle) - let subtitle = Self.jsOptionalStringLiteral(self.debugStatusSubtitle) - let js = """ - (() => { - try { - const api = globalThis.__openclaw; - if (!api) return; - if (typeof api.setDebugStatusEnabled === 'function') { - api.setDebugStatusEnabled(\(enabled ? "true" : "false")); - } - if (!\(enabled ? "true" : "false")) return; - if (typeof api.setStatus === 'function') { - api.setStatus(\(title), \(subtitle)); - } - } catch (_) {} - })(); - """ - self.webView.evaluateJavaScript(js) { _, _ in } - } - - private func loadFile(_ url: URL) { - let fileURL = url.isFileURL ? url : URL(fileURLWithPath: url.path) - let accessDir = fileURL.deletingLastPathComponent() - self.webView.loadFileURL(fileURL, allowingReadAccessTo: accessDir) - } - - func eval(javaScript: String) async throws -> String { - try await withCheckedThrowingContinuation { cont in - self.webView.evaluateJavaScript(javaScript) { result, error in - if let error { - cont.resume(throwing: error) - return - } - if let result { - cont.resume(returning: String(describing: result)) - } else { - cont.resume(returning: "") - } - } - } - } - - func snapshot(to outPath: String?) async throws -> String { - let image: NSImage = try await withCheckedThrowingContinuation { cont in - self.webView.takeSnapshot(with: nil) { image, error in - if let error { - cont.resume(throwing: error) - return - } - guard let image else { - cont.resume(throwing: NSError(domain: "Canvas", code: 11, userInfo: [ - NSLocalizedDescriptionKey: "snapshot returned nil image", - ])) - return - } - cont.resume(returning: image) - } - } - - guard let tiff = image.tiffRepresentation, - let rep = NSBitmapImageRep(data: tiff), - let png = rep.representation(using: .png, properties: [:]) - else { - throw NSError(domain: "Canvas", code: 12, userInfo: [ - NSLocalizedDescriptionKey: "failed to encode png", - ]) - } - - let path: String - if let outPath, !outPath.isEmpty { - path = outPath - } else { - let ts = Int(Date().timeIntervalSince1970) - path = "/tmp/openclaw-canvas-\(CanvasWindowController.sanitizeSessionKey(self.sessionKey))-\(ts).png" - } - - try png.write(to: URL(fileURLWithPath: path), options: [.atomic]) - return path - } - - var directoryPath: String { - self.sessionDir.path - } - - func shouldAutoNavigateToA2UI(lastAutoTarget: String?) -> Bool { - let trimmed = (self.currentTarget ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty || trimmed == "/" { return true } - if let lastAuto = lastAutoTarget?.trimmingCharacters(in: .whitespacesAndNewlines), - !lastAuto.isEmpty, - trimmed == lastAuto - { - return true - } - return false - } -} diff --git a/apps/macos/Sources/OpenClaw/ChannelConfigForm.swift b/apps/macos/Sources/OpenClaw/ChannelConfigForm.swift deleted file mode 100644 index d00725be768..00000000000 --- a/apps/macos/Sources/OpenClaw/ChannelConfigForm.swift +++ /dev/null @@ -1,363 +0,0 @@ -import SwiftUI - -struct ConfigSchemaForm: View { - @Bindable var store: ChannelsStore - let schema: ConfigSchemaNode - let path: ConfigPath - - var body: some View { - self.renderNode(self.schema, path: self.path) - } - - private func renderNode(_ schema: ConfigSchemaNode, path: ConfigPath) -> AnyView { - let storedValue = self.store.configValue(at: path) - let value = storedValue ?? schema.explicitDefault - let label = hintForPath(path, hints: store.configUiHints)?.label ?? schema.title - let help = hintForPath(path, hints: store.configUiHints)?.help ?? schema.description - let variants = schema.anyOf.isEmpty ? schema.oneOf : schema.anyOf - - if !variants.isEmpty { - let nonNull = variants.filter { !$0.isNullSchema } - if nonNull.count == 1, let only = nonNull.first { - return self.renderNode(only, path: path) - } - let literals = nonNull.compactMap(\.literalValue) - if !literals.isEmpty, literals.count == nonNull.count { - return AnyView( - VStack(alignment: .leading, spacing: 6) { - if let label { Text(label).font(.callout.weight(.semibold)) } - if let help { - Text(help) - .font(.caption) - .foregroundStyle(.secondary) - } - Picker( - "", - selection: self.enumBinding( - path, - options: literals, - defaultValue: schema.explicitDefault)) - { - Text("Select…").tag(-1) - ForEach(literals.indices, id: \ .self) { index in - Text(String(describing: literals[index])).tag(index) - } - } - .pickerStyle(.menu) - }) - } - } - - switch schema.schemaType { - case "object": - return AnyView( - VStack(alignment: .leading, spacing: 12) { - if let label { - Text(label) - .font(.callout.weight(.semibold)) - } - if let help { - Text(help) - .font(.caption) - .foregroundStyle(.secondary) - } - let properties = schema.properties - let sortedKeys = properties.keys.sorted { lhs, rhs in - let orderA = hintForPath(path + [.key(lhs)], hints: store.configUiHints)?.order ?? 0 - let orderB = hintForPath(path + [.key(rhs)], hints: store.configUiHints)?.order ?? 0 - if orderA != orderB { return orderA < orderB } - return lhs < rhs - } - ForEach(sortedKeys, id: \ .self) { key in - if let child = properties[key] { - self.renderNode(child, path: path + [.key(key)]) - } - } - if schema.allowsAdditionalProperties { - self.renderAdditionalProperties(schema, path: path, value: value) - } - }) - case "array": - return AnyView(self.renderArray(schema, path: path, value: value, label: label, help: help)) - case "boolean": - return AnyView( - Toggle(isOn: self.boolBinding(path, defaultValue: schema.explicitDefault as? Bool)) { - if let label { Text(label) } else { Text("Enabled") } - } - .help(help ?? "")) - case "number", "integer": - return AnyView(self.renderNumberField(schema, path: path, label: label, help: help)) - case "string": - return AnyView(self.renderStringField(schema, path: path, label: label, help: help)) - default: - return AnyView( - VStack(alignment: .leading, spacing: 6) { - if let label { Text(label).font(.callout.weight(.semibold)) } - Text("Unsupported field type.") - .font(.caption) - .foregroundStyle(.secondary) - }) - } - } - - @ViewBuilder - private func renderStringField( - _ schema: ConfigSchemaNode, - path: ConfigPath, - label: String?, - help: String?) -> some View - { - let hint = hintForPath(path, hints: store.configUiHints) - let placeholder = hint?.placeholder ?? "" - let sensitive = hint?.sensitive ?? isSensitivePath(path) - let defaultValue = schema.explicitDefault as? String - VStack(alignment: .leading, spacing: 6) { - if let label { Text(label).font(.callout.weight(.semibold)) } - if let help { - Text(help) - .font(.caption) - .foregroundStyle(.secondary) - } - if let options = schema.enumValues { - Picker("", selection: self.enumBinding(path, options: options, defaultValue: schema.explicitDefault)) { - Text("Select…").tag(-1) - ForEach(options.indices, id: \ .self) { index in - Text(String(describing: options[index])).tag(index) - } - } - .pickerStyle(.menu) - } else if sensitive { - SecureField(placeholder, text: self.stringBinding(path, defaultValue: defaultValue)) - .textFieldStyle(.roundedBorder) - } else { - TextField(placeholder, text: self.stringBinding(path, defaultValue: defaultValue)) - .textFieldStyle(.roundedBorder) - } - } - } - - @ViewBuilder - private func renderNumberField( - _ schema: ConfigSchemaNode, - path: ConfigPath, - label: String?, - help: String?) -> some View - { - let defaultValue = (schema.explicitDefault as? Double) - ?? (schema.explicitDefault as? Int).map(Double.init) - VStack(alignment: .leading, spacing: 6) { - if let label { Text(label).font(.callout.weight(.semibold)) } - if let help { - Text(help) - .font(.caption) - .foregroundStyle(.secondary) - } - TextField( - "", - text: self.numberBinding( - path, - isInteger: schema.schemaType == "integer", - defaultValue: defaultValue)) - .textFieldStyle(.roundedBorder) - } - } - - @ViewBuilder - private func renderArray( - _ schema: ConfigSchemaNode, - path: ConfigPath, - value: Any?, - label: String?, - help: String?) -> some View - { - let items = value as? [Any] ?? [] - let itemSchema = schema.items - VStack(alignment: .leading, spacing: 10) { - if let label { Text(label).font(.callout.weight(.semibold)) } - if let help { - Text(help) - .font(.caption) - .foregroundStyle(.secondary) - } - ForEach(items.indices, id: \ .self) { index in - HStack(alignment: .top, spacing: 8) { - if let itemSchema { - self.renderNode(itemSchema, path: path + [.index(index)]) - } else { - Text(String(describing: items[index])) - } - Button("Remove") { - var next = items - next.remove(at: index) - self.store.updateConfigValue(path: path, value: next) - } - .buttonStyle(.bordered) - .controlSize(.small) - } - } - Button("Add") { - var next = items - if let itemSchema { - next.append(itemSchema.defaultValue) - } else { - next.append("") - } - self.store.updateConfigValue(path: path, value: next) - } - .buttonStyle(.bordered) - .controlSize(.small) - } - } - - @ViewBuilder - private func renderAdditionalProperties( - _ schema: ConfigSchemaNode, - path: ConfigPath, - value: Any?) -> some View - { - if let additionalSchema = schema.additionalProperties { - let dict = value as? [String: Any] ?? [:] - let reserved = Set(schema.properties.keys) - let extras = dict.keys.filter { !reserved.contains($0) }.sorted() - - VStack(alignment: .leading, spacing: 8) { - Text("Extra entries") - .font(.callout.weight(.semibold)) - if extras.isEmpty { - Text("No extra entries yet.") - .font(.caption) - .foregroundStyle(.secondary) - } else { - ForEach(extras, id: \ .self) { key in - let itemPath: ConfigPath = path + [.key(key)] - HStack(alignment: .top, spacing: 8) { - TextField("Key", text: self.mapKeyBinding(path: path, key: key)) - .textFieldStyle(.roundedBorder) - .frame(width: 160) - self.renderNode(additionalSchema, path: itemPath) - Button("Remove") { - var next = dict - next.removeValue(forKey: key) - self.store.updateConfigValue(path: path, value: next) - } - .buttonStyle(.bordered) - .controlSize(.small) - } - } - } - Button("Add") { - var next = dict - var index = 1 - var key = "new-\(index)" - while next[key] != nil { - index += 1 - key = "new-\(index)" - } - next[key] = additionalSchema.defaultValue - self.store.updateConfigValue(path: path, value: next) - } - .buttonStyle(.bordered) - .controlSize(.small) - } - } - } - - private func stringBinding(_ path: ConfigPath, defaultValue: String?) -> Binding { - Binding( - get: { - if let value = store.configValue(at: path) as? String { return value } - return defaultValue ?? "" - }, - set: { newValue in - let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) - self.store.updateConfigValue(path: path, value: trimmed.isEmpty ? nil : trimmed) - }) - } - - private func boolBinding(_ path: ConfigPath, defaultValue: Bool?) -> Binding { - Binding( - get: { - if let value = store.configValue(at: path) as? Bool { return value } - return defaultValue ?? false - }, - set: { newValue in - self.store.updateConfigValue(path: path, value: newValue) - }) - } - - private func numberBinding( - _ path: ConfigPath, - isInteger: Bool, - defaultValue: Double?) -> Binding - { - Binding( - get: { - if let value = store.configValue(at: path) { return String(describing: value) } - guard let defaultValue else { return "" } - return isInteger ? String(Int(defaultValue)) : String(defaultValue) - }, - set: { newValue in - let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { - self.store.updateConfigValue(path: path, value: nil) - } else if let value = Double(trimmed) { - self.store.updateConfigValue(path: path, value: isInteger ? Int(value) : value) - } - }) - } - - private func enumBinding( - _ path: ConfigPath, - options: [Any], - defaultValue: Any?) -> Binding - { - Binding( - get: { - let value = self.store.configValue(at: path) ?? defaultValue - guard let value else { return -1 } - return options.firstIndex { option in - String(describing: option) == String(describing: value) - } ?? -1 - }, - set: { index in - guard index >= 0, index < options.count else { - self.store.updateConfigValue(path: path, value: nil) - return - } - self.store.updateConfigValue(path: path, value: options[index]) - }) - } - - private func mapKeyBinding(path: ConfigPath, key: String) -> Binding { - Binding( - get: { key }, - set: { newValue in - let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - guard trimmed != key else { return } - let current = self.store.configValue(at: path) as? [String: Any] ?? [:] - guard current[trimmed] == nil else { return } - var next = current - next[trimmed] = current[key] - next.removeValue(forKey: key) - self.store.updateConfigValue(path: path, value: next) - }) - } -} - -struct ChannelConfigForm: View { - @Bindable var store: ChannelsStore - let channelId: String - - var body: some View { - if self.store.configSchemaLoading { - ProgressView().controlSize(.small) - } else if let schema = store.channelConfigSchema(for: channelId) { - ConfigSchemaForm(store: self.store, schema: schema, path: [.key("channels"), .key(self.channelId)]) - } else { - Text("Schema unavailable for this channel.") - .font(.caption) - .foregroundStyle(.secondary) - } - } -} diff --git a/apps/macos/Sources/OpenClaw/ChannelsSettings+ChannelSections.swift b/apps/macos/Sources/OpenClaw/ChannelsSettings+ChannelSections.swift deleted file mode 100644 index 2bef47f2dea..00000000000 --- a/apps/macos/Sources/OpenClaw/ChannelsSettings+ChannelSections.swift +++ /dev/null @@ -1,137 +0,0 @@ -import SwiftUI - -extension ChannelsSettings { - func formSection(_ title: String, @ViewBuilder content: () -> some View) -> some View { - GroupBox(title) { - VStack(alignment: .leading, spacing: 10) { - content() - } - .frame(maxWidth: .infinity, alignment: .leading) - } - } - - func channelHeaderActions(_ channel: ChannelItem) -> some View { - HStack(spacing: 8) { - if channel.id == "whatsapp" { - Button("Logout") { - Task { await self.store.logoutWhatsApp() } - } - .buttonStyle(.bordered) - .disabled(self.store.whatsappBusy) - } - - if channel.id == "telegram" { - Button("Logout") { - Task { await self.store.logoutTelegram() } - } - .buttonStyle(.bordered) - .disabled(self.store.telegramBusy) - } - - Button { - Task { await self.store.refresh(probe: true) } - } label: { - if self.store.isRefreshing { - ProgressView().controlSize(.small) - } else { - Text("Refresh") - } - } - .buttonStyle(.bordered) - .disabled(self.store.isRefreshing) - } - .controlSize(.small) - } - - var whatsAppSection: some View { - VStack(alignment: .leading, spacing: 16) { - self.formSection("Linking") { - if let message = self.store.whatsappLoginMessage { - Text(message) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - if let qr = self.store.whatsappLoginQrDataUrl, let image = self.qrImage(from: qr) { - Image(nsImage: image) - .resizable() - .interpolation(.none) - .frame(width: 180, height: 180) - .cornerRadius(8) - } - - HStack(spacing: 12) { - Button { - Task { await self.store.startWhatsAppLogin(force: false) } - } label: { - if self.store.whatsappBusy { - ProgressView().controlSize(.small) - } else { - Text("Show QR") - } - } - .buttonStyle(.borderedProminent) - .disabled(self.store.whatsappBusy) - - Button("Relink") { - Task { await self.store.startWhatsAppLogin(force: true) } - } - .buttonStyle(.bordered) - .disabled(self.store.whatsappBusy) - } - .font(.caption) - } - - self.configEditorSection(channelId: "whatsapp") - } - } - - func genericChannelSection(_ channel: ChannelItem) -> some View { - VStack(alignment: .leading, spacing: 16) { - self.configEditorSection(channelId: channel.id) - } - } - - @ViewBuilder - private func configEditorSection(channelId: String) -> some View { - self.formSection("Configuration") { - ChannelConfigForm(store: self.store, channelId: channelId) - } - - self.configStatusMessage - - HStack(spacing: 12) { - Button { - Task { await self.store.saveConfigDraft() } - } label: { - if self.store.isSavingConfig { - ProgressView().controlSize(.small) - } else { - Text("Save") - } - } - .buttonStyle(.borderedProminent) - .disabled(self.store.isSavingConfig || !self.store.configDirty) - - Button("Reload") { - Task { await self.store.reloadConfigDraft() } - } - .buttonStyle(.bordered) - .disabled(self.store.isSavingConfig) - - Spacer() - } - .font(.caption) - } - - @ViewBuilder - var configStatusMessage: some View { - if let status = self.store.configStatus { - Text(status) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } -} diff --git a/apps/macos/Sources/OpenClaw/ChannelsSettings+ChannelState.swift b/apps/macos/Sources/OpenClaw/ChannelsSettings+ChannelState.swift deleted file mode 100644 index 5be5818425b..00000000000 --- a/apps/macos/Sources/OpenClaw/ChannelsSettings+ChannelState.swift +++ /dev/null @@ -1,508 +0,0 @@ -import OpenClawProtocol -import SwiftUI - -extension ChannelsSettings { - private func channelStatus( - _ id: String, - as type: T.Type) -> T? - { - self.store.snapshot?.decodeChannel(id, as: type) - } - - var whatsAppTint: Color { - guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self) - else { return .secondary } - if !status.configured { return .secondary } - if !status.linked { return .red } - if status.lastError != nil { return .orange } - if status.connected { return .green } - if status.running { return .orange } - return .orange - } - - var telegramTint: Color { - guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self) - else { return .secondary } - if !status.configured { return .secondary } - if status.lastError != nil { return .orange } - if status.probe?.ok == false { return .orange } - if status.running { return .green } - return .orange - } - - var discordTint: Color { - guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self) - else { return .secondary } - if !status.configured { return .secondary } - if status.lastError != nil { return .orange } - if status.probe?.ok == false { return .orange } - if status.running { return .green } - return .orange - } - - var googlechatTint: Color { - guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self) - else { return .secondary } - if !status.configured { return .secondary } - if status.lastError != nil { return .orange } - if status.probe?.ok == false { return .orange } - if status.running { return .green } - return .orange - } - - var signalTint: Color { - guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self) - else { return .secondary } - if !status.configured { return .secondary } - if status.lastError != nil { return .orange } - if status.probe?.ok == false { return .orange } - if status.running { return .green } - return .orange - } - - var imessageTint: Color { - guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self) - else { return .secondary } - if !status.configured { return .secondary } - if status.lastError != nil { return .orange } - if status.probe?.ok == false { return .orange } - if status.running { return .green } - return .orange - } - - var whatsAppSummary: String { - guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self) - else { return "Checking…" } - if !status.linked { return "Not linked" } - if status.connected { return "Connected" } - if status.running { return "Running" } - return "Linked" - } - - var telegramSummary: String { - guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self) - else { return "Checking…" } - if !status.configured { return "Not configured" } - if status.running { return "Running" } - return "Configured" - } - - var discordSummary: String { - guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self) - else { return "Checking…" } - if !status.configured { return "Not configured" } - if status.running { return "Running" } - return "Configured" - } - - var googlechatSummary: String { - guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self) - else { return "Checking…" } - if !status.configured { return "Not configured" } - if status.running { return "Running" } - return "Configured" - } - - var signalSummary: String { - guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self) - else { return "Checking…" } - if !status.configured { return "Not configured" } - if status.running { return "Running" } - return "Configured" - } - - var imessageSummary: String { - guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self) - else { return "Checking…" } - if !status.configured { return "Not configured" } - if status.running { return "Running" } - return "Configured" - } - - var whatsAppDetails: String? { - guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self) - else { return nil } - var lines: [String] = [] - if let e164 = status.`self`?.e164 ?? status.`self`?.jid { - lines.append("Linked as \(e164)") - } - if let age = status.authAgeMs { - lines.append("Auth age \(msToAge(age))") - } - if let last = self.date(fromMs: status.lastConnectedAt) { - lines.append("Last connect \(relativeAge(from: last))") - } - if let disconnect = status.lastDisconnect { - let when = self.date(fromMs: disconnect.at).map { relativeAge(from: $0) } ?? "unknown" - let code = disconnect.status.map { "status \($0)" } ?? "status unknown" - let err = disconnect.error ?? "disconnect" - lines.append("Last disconnect \(code) · \(err) · \(when)") - } - if status.reconnectAttempts > 0 { - lines.append("Reconnect attempts \(status.reconnectAttempts)") - } - if let msgAt = self.date(fromMs: status.lastMessageAt) { - lines.append("Last message \(relativeAge(from: msgAt))") - } - if let err = status.lastError, !err.isEmpty { - lines.append("Error: \(err)") - } - return lines.isEmpty ? nil : lines.joined(separator: " · ") - } - - var telegramDetails: String? { - guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self) - else { return nil } - var lines: [String] = [] - if let source = status.tokenSource { - lines.append("Token source: \(source)") - } - if let mode = status.mode { - lines.append("Mode: \(mode)") - } - if let probe = status.probe { - if probe.ok { - if let name = probe.bot?.username { - lines.append("Bot: @\(name)") - } - if let url = probe.webhook?.url, !url.isEmpty { - lines.append("Webhook: \(url)") - } - } else { - let code = probe.status.map { String($0) } ?? "unknown" - lines.append("Probe failed (\(code))") - } - } - if let last = self.date(fromMs: status.lastProbeAt) { - lines.append("Last probe \(relativeAge(from: last))") - } - if let err = status.lastError, !err.isEmpty { - lines.append("Error: \(err)") - } - return lines.isEmpty ? nil : lines.joined(separator: " · ") - } - - var discordDetails: String? { - guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self) - else { return nil } - var lines: [String] = [] - if let source = status.tokenSource { - lines.append("Token source: \(source)") - } - if let probe = status.probe { - if probe.ok { - if let name = probe.bot?.username { - lines.append("Bot: @\(name)") - } - if let elapsed = probe.elapsedMs { - lines.append("Probe \(Int(elapsed))ms") - } - } else { - let code = probe.status.map { String($0) } ?? "unknown" - lines.append("Probe failed (\(code))") - } - } - if let last = self.date(fromMs: status.lastProbeAt) { - lines.append("Last probe \(relativeAge(from: last))") - } - if let err = status.lastError, !err.isEmpty { - lines.append("Error: \(err)") - } - return lines.isEmpty ? nil : lines.joined(separator: " · ") - } - - var googlechatDetails: String? { - guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self) - else { return nil } - var lines: [String] = [] - if let source = status.credentialSource { - lines.append("Credential: \(source)") - } - if let audienceType = status.audienceType { - let audience = status.audience ?? "" - let label = audience.isEmpty ? audienceType : "\(audienceType) \(audience)" - lines.append("Audience: \(label)") - } - if let probe = status.probe { - if probe.ok { - if let elapsed = probe.elapsedMs { - lines.append("Probe \(Int(elapsed))ms") - } - } else { - let code = probe.status.map { String($0) } ?? "unknown" - lines.append("Probe failed (\(code))") - } - } - if let last = self.date(fromMs: status.lastProbeAt) { - lines.append("Last probe \(relativeAge(from: last))") - } - if let err = status.lastError, !err.isEmpty { - lines.append("Error: \(err)") - } - return lines.isEmpty ? nil : lines.joined(separator: " · ") - } - - var signalDetails: String? { - guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self) - else { return nil } - var lines: [String] = [] - lines.append("Base URL: \(status.baseUrl)") - if let probe = status.probe { - if probe.ok { - if let version = probe.version, !version.isEmpty { - lines.append("Version \(version)") - } - if let elapsed = probe.elapsedMs { - lines.append("Probe \(Int(elapsed))ms") - } - } else { - let code = probe.status.map { String($0) } ?? "unknown" - lines.append("Probe failed (\(code))") - } - } - if let last = self.date(fromMs: status.lastProbeAt) { - lines.append("Last probe \(relativeAge(from: last))") - } - if let err = status.lastError, !err.isEmpty { - lines.append("Error: \(err)") - } - return lines.isEmpty ? nil : lines.joined(separator: " · ") - } - - var imessageDetails: String? { - guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self) - else { return nil } - var lines: [String] = [] - if let cliPath = status.cliPath, !cliPath.isEmpty { - lines.append("CLI: \(cliPath)") - } - if let dbPath = status.dbPath, !dbPath.isEmpty { - lines.append("DB: \(dbPath)") - } - if let probe = status.probe, !probe.ok { - let err = probe.error ?? "probe failed" - lines.append("Probe error: \(err)") - } - if let last = self.date(fromMs: status.lastProbeAt) { - lines.append("Last probe \(relativeAge(from: last))") - } - if let err = status.lastError, !err.isEmpty { - lines.append("Error: \(err)") - } - return lines.isEmpty ? nil : lines.joined(separator: " · ") - } - - var orderedChannels: [ChannelItem] { - let fallback = ["whatsapp", "telegram", "discord", "googlechat", "slack", "signal", "imessage"] - let order = self.store.snapshot?.channelOrder ?? fallback - let channels = order.enumerated().map { index, id in - ChannelItem( - id: id, - title: self.resolveChannelTitle(id), - detailTitle: self.resolveChannelDetailTitle(id), - systemImage: self.resolveChannelSystemImage(id), - sortOrder: index) - } - return channels.sorted { lhs, rhs in - let lhsEnabled = self.channelEnabled(lhs) - let rhsEnabled = self.channelEnabled(rhs) - if lhsEnabled != rhsEnabled { return lhsEnabled && !rhsEnabled } - return lhs.sortOrder < rhs.sortOrder - } - } - - var enabledChannels: [ChannelItem] { - self.orderedChannels.filter { self.channelEnabled($0) } - } - - var availableChannels: [ChannelItem] { - self.orderedChannels.filter { !self.channelEnabled($0) } - } - - func ensureSelection() { - guard let selected = self.selectedChannel else { - self.selectedChannel = self.orderedChannels.first - return - } - if !self.orderedChannels.contains(selected) { - self.selectedChannel = self.orderedChannels.first - } - } - - func channelEnabled(_ channel: ChannelItem) -> Bool { - let status = self.channelStatusDictionary(channel.id) - let configured = status?["configured"]?.boolValue ?? false - let running = status?["running"]?.boolValue ?? false - let connected = status?["connected"]?.boolValue ?? false - let accountActive = self.store.snapshot?.channelAccounts[channel.id]?.contains( - where: { $0.configured == true || $0.running == true || $0.connected == true }) ?? false - return configured || running || connected || accountActive - } - - @ViewBuilder - func channelSection(_ channel: ChannelItem) -> some View { - if channel.id == "whatsapp" { - self.whatsAppSection - } else { - self.genericChannelSection(channel) - } - } - - func channelTint(_ channel: ChannelItem) -> Color { - switch channel.id { - case "whatsapp": - return self.whatsAppTint - case "telegram": - return self.telegramTint - case "discord": - return self.discordTint - case "googlechat": - return self.googlechatTint - case "signal": - return self.signalTint - case "imessage": - return self.imessageTint - default: - if self.channelHasError(channel) { return .orange } - if self.channelEnabled(channel) { return .green } - return .secondary - } - } - - func channelSummary(_ channel: ChannelItem) -> String { - switch channel.id { - case "whatsapp": - return self.whatsAppSummary - case "telegram": - return self.telegramSummary - case "discord": - return self.discordSummary - case "googlechat": - return self.googlechatSummary - case "signal": - return self.signalSummary - case "imessage": - return self.imessageSummary - default: - if self.channelHasError(channel) { return "Error" } - if self.channelEnabled(channel) { return "Active" } - return "Not configured" - } - } - - func channelDetails(_ channel: ChannelItem) -> String? { - switch channel.id { - case "whatsapp": - return self.whatsAppDetails - case "telegram": - return self.telegramDetails - case "discord": - return self.discordDetails - case "googlechat": - return self.googlechatDetails - case "signal": - return self.signalDetails - case "imessage": - return self.imessageDetails - default: - let status = self.channelStatusDictionary(channel.id) - if let err = status?["lastError"]?.stringValue, !err.isEmpty { - return "Error: \(err)" - } - return nil - } - } - - func channelLastCheckText(_ channel: ChannelItem) -> String { - guard let date = self.channelLastCheck(channel) else { return "never" } - return relativeAge(from: date) - } - - func channelLastCheck(_ channel: ChannelItem) -> Date? { - switch channel.id { - case "whatsapp": - guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self) - else { return nil } - return self.date(fromMs: status.lastEventAt ?? status.lastMessageAt ?? status.lastConnectedAt) - case "telegram": - return self - .date(fromMs: self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)? - .lastProbeAt) - case "discord": - return self - .date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)? - .lastProbeAt) - case "googlechat": - return self - .date(fromMs: self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)? - .lastProbeAt) - case "signal": - return self - .date(fromMs: self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)?.lastProbeAt) - case "imessage": - return self - .date(fromMs: self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)? - .lastProbeAt) - default: - let status = self.channelStatusDictionary(channel.id) - if let probeAt = status?["lastProbeAt"]?.doubleValue { - return self.date(fromMs: probeAt) - } - if let accounts = self.store.snapshot?.channelAccounts[channel.id] { - let last = accounts.compactMap { $0.lastInboundAt ?? $0.lastOutboundAt }.max() - return self.date(fromMs: last) - } - return nil - } - } - - func channelHasError(_ channel: ChannelItem) -> Bool { - switch channel.id { - case "whatsapp": - guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self) - else { return false } - return status.lastError?.isEmpty == false || status.lastDisconnect?.loggedOut == true - case "telegram": - guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self) - else { return false } - return status.lastError?.isEmpty == false || status.probe?.ok == false - case "discord": - guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self) - else { return false } - return status.lastError?.isEmpty == false || status.probe?.ok == false - case "googlechat": - guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self) - else { return false } - return status.lastError?.isEmpty == false || status.probe?.ok == false - case "signal": - guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self) - else { return false } - return status.lastError?.isEmpty == false || status.probe?.ok == false - case "imessage": - guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self) - else { return false } - return status.lastError?.isEmpty == false || status.probe?.ok == false - default: - let status = self.channelStatusDictionary(channel.id) - return status?["lastError"]?.stringValue?.isEmpty == false - } - } - - private func resolveChannelTitle(_ id: String) -> String { - let label = self.store.resolveChannelLabel(id) - if label != id { return label } - return id.prefix(1).uppercased() + id.dropFirst() - } - - private func resolveChannelDetailTitle(_ id: String) -> String { - self.store.resolveChannelDetailLabel(id) - } - - private func resolveChannelSystemImage(_ id: String) -> String { - self.store.resolveChannelSystemImage(id) - } - - private func channelStatusDictionary(_ id: String) -> [String: AnyCodable]? { - self.store.snapshot?.channels[id]?.dictionaryValue - } -} diff --git a/apps/macos/Sources/OpenClaw/ChannelsSettings+Helpers.swift b/apps/macos/Sources/OpenClaw/ChannelsSettings+Helpers.swift deleted file mode 100644 index 05b79ca0492..00000000000 --- a/apps/macos/Sources/OpenClaw/ChannelsSettings+Helpers.swift +++ /dev/null @@ -1,17 +0,0 @@ -import AppKit - -extension ChannelsSettings { - func date(fromMs ms: Double?) -> Date? { - guard let ms else { return nil } - return Date(timeIntervalSince1970: ms / 1000) - } - - func qrImage(from dataUrl: String) -> NSImage? { - guard let comma = dataUrl.firstIndex(of: ",") else { return nil } - let header = dataUrl[.. some View { - ScrollView(.vertical) { - VStack(alignment: .leading, spacing: 16) { - self.detailHeader(for: channel) - Divider() - self.channelSection(channel) - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 24) - .padding(.vertical, 18) - } - } - - private func sidebarRow(_ channel: ChannelItem) -> some View { - let isSelected = self.selectedChannel == channel - return Button { - self.selectedChannel = channel - } label: { - HStack(spacing: 8) { - Circle() - .fill(self.channelTint(channel)) - .frame(width: 8, height: 8) - VStack(alignment: .leading, spacing: 2) { - Text(channel.title) - Text(self.channelSummary(channel)) - .font(.caption) - .foregroundStyle(.secondary) - } - } - .padding(.vertical, 4) - .padding(.horizontal, 6) - .frame(maxWidth: .infinity, alignment: .leading) - .background(isSelected ? Color.accentColor.opacity(0.18) : Color.clear) - .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) - .background(Color.clear) // ensure full-width hit test area - .contentShape(Rectangle()) - } - .frame(maxWidth: .infinity, alignment: .leading) - .buttonStyle(.plain) - .contentShape(Rectangle()) - } - - private func sidebarSectionHeader(_ title: String) -> some View { - Text(title) - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - .textCase(.uppercase) - .padding(.horizontal, 4) - .padding(.top, 2) - } - - private func detailHeader(for channel: ChannelItem) -> some View { - VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .firstTextBaseline, spacing: 10) { - Label(channel.detailTitle, systemImage: channel.systemImage) - .font(.title3.weight(.semibold)) - self.statusBadge( - self.channelSummary(channel), - color: self.channelTint(channel)) - Spacer() - self.channelHeaderActions(channel) - } - - HStack(spacing: 10) { - Text("Last check \(self.channelLastCheckText(channel))") - .font(.caption) - .foregroundStyle(.secondary) - if self.channelHasError(channel) { - Text("Error") - .font(.caption2.weight(.semibold)) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(Color.red.opacity(0.15)) - .foregroundStyle(.red) - .clipShape(Capsule()) - } - } - - if let details = self.channelDetails(channel) { - Text(details) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - } - - private func statusBadge(_ text: String, color: Color) -> some View { - Text(text) - .font(.caption2.weight(.semibold)) - .padding(.horizontal, 8) - .padding(.vertical, 3) - .background(color.opacity(0.16)) - .foregroundStyle(color) - .clipShape(Capsule()) - } -} diff --git a/apps/macos/Sources/OpenClaw/ChannelsSettings.swift b/apps/macos/Sources/OpenClaw/ChannelsSettings.swift deleted file mode 100644 index b1177f0033b..00000000000 --- a/apps/macos/Sources/OpenClaw/ChannelsSettings.swift +++ /dev/null @@ -1,19 +0,0 @@ -import AppKit -import SwiftUI - -struct ChannelsSettings: View { - struct ChannelItem: Identifiable, Hashable { - let id: String - let title: String - let detailTitle: String - let systemImage: String - let sortOrder: Int - } - - @Bindable var store: ChannelsStore - @State var selectedChannel: ChannelItem? - - init(store: ChannelsStore = .shared) { - self.store = store - } -} diff --git a/apps/macos/Sources/OpenClaw/ChannelsStore+Config.swift b/apps/macos/Sources/OpenClaw/ChannelsStore+Config.swift deleted file mode 100644 index 703c7efed63..00000000000 --- a/apps/macos/Sources/OpenClaw/ChannelsStore+Config.swift +++ /dev/null @@ -1,154 +0,0 @@ -import Foundation -import OpenClawProtocol - -extension ChannelsStore { - func loadConfigSchema() async { - guard !self.configSchemaLoading else { return } - self.configSchemaLoading = true - defer { self.configSchemaLoading = false } - - do { - let res: ConfigSchemaResponse = try await GatewayConnection.shared.requestDecoded( - method: .configSchema, - params: nil, - timeoutMs: 8000) - let schemaValue = res.schema.foundationValue - self.configSchema = ConfigSchemaNode(raw: schemaValue) - let hintValues = res.uihints.mapValues { $0.foundationValue } - self.configUiHints = decodeUiHints(hintValues) - } catch { - self.configStatus = error.localizedDescription - } - } - - func loadConfig() async { - do { - let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded( - method: .configGet, - params: nil, - timeoutMs: 10000) - self.configStatus = snap.valid == false - ? "Config invalid; fix it in ~/.openclaw/openclaw.json." - : nil - self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:] - self.configDraft = cloneConfigValue(self.configRoot) as? [String: Any] ?? self.configRoot - self.configDirty = false - self.configLoaded = true - - self.applyUIConfig(snap) - } catch { - self.configStatus = error.localizedDescription - } - } - - private func applyUIConfig(_ snap: ConfigSnapshot) { - let ui = snap.config?["ui"]?.dictionaryValue - let rawSeam = ui?["seamColor"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam - } - - func channelConfigSchema(for channelId: String) -> ConfigSchemaNode? { - guard let root = self.configSchema else { return nil } - return root.node(at: [.key("channels"), .key(channelId)]) - } - - func configValue(at path: ConfigPath) -> Any? { - if let value = valueAtPath(self.configDraft, path: path) { - return value - } - guard path.count >= 2 else { return nil } - if case .key("channels") = path[0], case .key = path[1] { - let fallbackPath = Array(path.dropFirst()) - return valueAtPath(self.configDraft, path: fallbackPath) - } - return nil - } - - func updateConfigValue(path: ConfigPath, value: Any?) { - var root: Any = self.configDraft - setValue(&root, path: path, value: value) - self.configDraft = root as? [String: Any] ?? self.configDraft - self.configDirty = true - } - - func saveConfigDraft() async { - guard !self.isSavingConfig else { return } - self.isSavingConfig = true - defer { self.isSavingConfig = false } - - do { - try await ConfigStore.save(self.configDraft) - await self.loadConfig() - } catch { - self.configStatus = error.localizedDescription - } - } - - func reloadConfigDraft() async { - await self.loadConfig() - } -} - -private func valueAtPath(_ root: Any, path: ConfigPath) -> Any? { - var current: Any? = root - for segment in path { - switch segment { - case let .key(key): - guard let dict = current as? [String: Any] else { return nil } - current = dict[key] - case let .index(index): - guard let array = current as? [Any], array.indices.contains(index) else { return nil } - current = array[index] - } - } - return current -} - -private func setValue(_ root: inout Any, path: ConfigPath, value: Any?) { - guard let segment = path.first else { return } - switch segment { - case let .key(key): - var dict = root as? [String: Any] ?? [:] - if path.count == 1 { - if let value { - dict[key] = value - } else { - dict.removeValue(forKey: key) - } - root = dict - return - } - var child = dict[key] ?? [:] - setValue(&child, path: Array(path.dropFirst()), value: value) - dict[key] = child - root = dict - case let .index(index): - var array = root as? [Any] ?? [] - if index >= array.count { - array.append(contentsOf: repeatElement(NSNull() as Any, count: index - array.count + 1)) - } - if path.count == 1 { - if let value { - array[index] = value - } else if array.indices.contains(index) { - array.remove(at: index) - } - root = array - return - } - var child = array[index] - setValue(&child, path: Array(path.dropFirst()), value: value) - array[index] = child - root = array - } -} - -private func cloneConfigValue(_ value: Any) -> Any { - guard JSONSerialization.isValidJSONObject(value) else { return value } - do { - let data = try JSONSerialization.data(withJSONObject: value, options: []) - return try JSONSerialization.jsonObject(with: data, options: []) - } catch { - return value - } -} diff --git a/apps/macos/Sources/OpenClaw/ChannelsStore+Lifecycle.swift b/apps/macos/Sources/OpenClaw/ChannelsStore+Lifecycle.swift deleted file mode 100644 index fd516480f96..00000000000 --- a/apps/macos/Sources/OpenClaw/ChannelsStore+Lifecycle.swift +++ /dev/null @@ -1,163 +0,0 @@ -import Foundation -import OpenClawProtocol - -extension ChannelsStore { - func start() { - guard !self.isPreview else { return } - guard self.pollTask == nil else { return } - self.pollTask = Task.detached { [weak self] in - guard let self else { return } - await self.refresh(probe: true) - await self.loadConfigSchema() - await self.loadConfig() - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000)) - await self.refresh(probe: false) - } - } - } - - func stop() { - self.pollTask?.cancel() - self.pollTask = nil - } - - func refresh(probe: Bool) async { - guard !self.isRefreshing else { return } - self.isRefreshing = true - defer { self.isRefreshing = false } - - do { - let params: [String: AnyCodable] = [ - "probe": AnyCodable(probe), - "timeoutMs": AnyCodable(8000), - ] - let snap: ChannelsStatusSnapshot = try await GatewayConnection.shared.requestDecoded( - method: .channelsStatus, - params: params, - timeoutMs: 12000) - self.snapshot = snap - self.lastSuccess = Date() - self.lastError = nil - } catch { - self.lastError = error.localizedDescription - } - } - - func startWhatsAppLogin(force: Bool, autoWait: Bool = true) async { - guard !self.whatsappBusy else { return } - self.whatsappBusy = true - defer { self.whatsappBusy = false } - var shouldAutoWait = false - do { - let params: [String: AnyCodable] = [ - "force": AnyCodable(force), - "timeoutMs": AnyCodable(30000), - ] - let result: WhatsAppLoginStartResult = try await GatewayConnection.shared.requestDecoded( - method: .webLoginStart, - params: params, - timeoutMs: 35000) - self.whatsappLoginMessage = result.message - self.whatsappLoginQrDataUrl = result.qrDataUrl - self.whatsappLoginConnected = nil - shouldAutoWait = autoWait && result.qrDataUrl != nil - } catch { - self.whatsappLoginMessage = error.localizedDescription - self.whatsappLoginQrDataUrl = nil - self.whatsappLoginConnected = nil - } - await self.refresh(probe: true) - if shouldAutoWait { - Task { await self.waitWhatsAppLogin() } - } - } - - func waitWhatsAppLogin(timeoutMs: Int = 120_000) async { - guard !self.whatsappBusy else { return } - self.whatsappBusy = true - defer { self.whatsappBusy = false } - do { - let params: [String: AnyCodable] = [ - "timeoutMs": AnyCodable(timeoutMs), - ] - let result: WhatsAppLoginWaitResult = try await GatewayConnection.shared.requestDecoded( - method: .webLoginWait, - params: params, - timeoutMs: Double(timeoutMs) + 5000) - self.whatsappLoginMessage = result.message - self.whatsappLoginConnected = result.connected - if result.connected { - self.whatsappLoginQrDataUrl = nil - } - } catch { - self.whatsappLoginMessage = error.localizedDescription - } - await self.refresh(probe: true) - } - - func logoutWhatsApp() async { - guard !self.whatsappBusy else { return } - self.whatsappBusy = true - defer { self.whatsappBusy = false } - do { - let params: [String: AnyCodable] = [ - "channel": AnyCodable("whatsapp"), - ] - let result: ChannelLogoutResult = try await GatewayConnection.shared.requestDecoded( - method: .channelsLogout, - params: params, - timeoutMs: 15000) - self.whatsappLoginMessage = result.cleared - ? "Logged out and cleared credentials." - : "No WhatsApp session found." - self.whatsappLoginQrDataUrl = nil - } catch { - self.whatsappLoginMessage = error.localizedDescription - } - await self.refresh(probe: true) - } - - func logoutTelegram() async { - guard !self.telegramBusy else { return } - self.telegramBusy = true - defer { self.telegramBusy = false } - do { - let params: [String: AnyCodable] = [ - "channel": AnyCodable("telegram"), - ] - let result: ChannelLogoutResult = try await GatewayConnection.shared.requestDecoded( - method: .channelsLogout, - params: params, - timeoutMs: 15000) - if result.envToken == true { - self.configStatus = "Telegram token still set via env; config cleared." - } else { - self.configStatus = result.cleared - ? "Telegram token cleared." - : "No Telegram token configured." - } - await self.loadConfig() - } catch { - self.configStatus = error.localizedDescription - } - await self.refresh(probe: true) - } -} - -private struct WhatsAppLoginStartResult: Codable { - let qrDataUrl: String? - let message: String -} - -private struct WhatsAppLoginWaitResult: Codable { - let connected: Bool - let message: String -} - -private struct ChannelLogoutResult: Codable { - let channel: String? - let accountId: String? - let cleared: Bool - let envToken: Bool? -} diff --git a/apps/macos/Sources/OpenClaw/ChannelsStore.swift b/apps/macos/Sources/OpenClaw/ChannelsStore.swift deleted file mode 100644 index 09b9b75a532..00000000000 --- a/apps/macos/Sources/OpenClaw/ChannelsStore.swift +++ /dev/null @@ -1,296 +0,0 @@ -import Foundation -import Observation -import OpenClawProtocol - -struct ChannelsStatusSnapshot: Codable { - struct WhatsAppSelf: Codable { - let e164: String? - let jid: String? - } - - struct WhatsAppDisconnect: Codable { - let at: Double - let status: Int? - let error: String? - let loggedOut: Bool? - } - - struct WhatsAppStatus: Codable { - let configured: Bool - let linked: Bool - let authAgeMs: Double? - let `self`: WhatsAppSelf? - let running: Bool - let connected: Bool - let lastConnectedAt: Double? - let lastDisconnect: WhatsAppDisconnect? - let reconnectAttempts: Int - let lastMessageAt: Double? - let lastEventAt: Double? - let lastError: String? - } - - struct TelegramBot: Codable { - let id: Int? - let username: String? - } - - struct TelegramWebhook: Codable { - let url: String? - let hasCustomCert: Bool? - } - - struct TelegramProbe: Codable { - let ok: Bool - let status: Int? - let error: String? - let elapsedMs: Double? - let bot: TelegramBot? - let webhook: TelegramWebhook? - } - - struct TelegramStatus: Codable { - let configured: Bool - let tokenSource: String? - let running: Bool - let mode: String? - let lastStartAt: Double? - let lastStopAt: Double? - let lastError: String? - let probe: TelegramProbe? - let lastProbeAt: Double? - } - - struct DiscordBot: Codable { - let id: String? - let username: String? - } - - struct DiscordProbe: Codable { - let ok: Bool - let status: Int? - let error: String? - let elapsedMs: Double? - let bot: DiscordBot? - } - - struct DiscordStatus: Codable { - let configured: Bool - let tokenSource: String? - let running: Bool - let lastStartAt: Double? - let lastStopAt: Double? - let lastError: String? - let probe: DiscordProbe? - let lastProbeAt: Double? - } - - struct GoogleChatProbe: Codable { - let ok: Bool - let status: Int? - let error: String? - let elapsedMs: Double? - } - - struct GoogleChatStatus: Codable { - let configured: Bool - let credentialSource: String? - let audienceType: String? - let audience: String? - let webhookPath: String? - let webhookUrl: String? - let running: Bool - let lastStartAt: Double? - let lastStopAt: Double? - let lastError: String? - let probe: GoogleChatProbe? - let lastProbeAt: Double? - } - - struct SignalProbe: Codable { - let ok: Bool - let status: Int? - let error: String? - let elapsedMs: Double? - let version: String? - } - - struct SignalStatus: Codable { - let configured: Bool - let baseUrl: String - let running: Bool - let lastStartAt: Double? - let lastStopAt: Double? - let lastError: String? - let probe: SignalProbe? - let lastProbeAt: Double? - } - - struct IMessageProbe: Codable { - let ok: Bool - let error: String? - } - - struct IMessageStatus: Codable { - let configured: Bool - let running: Bool - let lastStartAt: Double? - let lastStopAt: Double? - let lastError: String? - let cliPath: String? - let dbPath: String? - let probe: IMessageProbe? - let lastProbeAt: Double? - } - - struct ChannelAccountSnapshot: Codable { - let accountId: String - let name: String? - let enabled: Bool? - let configured: Bool? - let linked: Bool? - let running: Bool? - let connected: Bool? - let reconnectAttempts: Int? - let lastConnectedAt: Double? - let lastError: String? - let lastStartAt: Double? - let lastStopAt: Double? - let lastInboundAt: Double? - let lastOutboundAt: Double? - let lastProbeAt: Double? - let mode: String? - let dmPolicy: String? - let allowFrom: [String]? - let tokenSource: String? - let botTokenSource: String? - let appTokenSource: String? - let baseUrl: String? - let allowUnmentionedGroups: Bool? - let cliPath: String? - let dbPath: String? - let port: Int? - let probe: AnyCodable? - let audit: AnyCodable? - let application: AnyCodable? - } - - struct ChannelUiMetaEntry: Codable { - let id: String - let label: String - let detailLabel: String - let systemImage: String? - } - - let ts: Double - let channelOrder: [String] - let channelLabels: [String: String] - let channelDetailLabels: [String: String]? - let channelSystemImages: [String: String]? - let channelMeta: [ChannelUiMetaEntry]? - let channels: [String: AnyCodable] - let channelAccounts: [String: [ChannelAccountSnapshot]] - let channelDefaultAccountId: [String: String] - - func decodeChannel(_ id: String, as type: T.Type) -> T? { - guard let value = self.channels[id] else { return nil } - do { - let data = try JSONEncoder().encode(value) - return try JSONDecoder().decode(type, from: data) - } catch { - return nil - } - } -} - -struct ConfigSnapshot: Codable { - struct Issue: Codable { - let path: String - let message: String - } - - let path: String? - let exists: Bool? - let raw: String? - let hash: String? - let parsed: AnyCodable? - let valid: Bool? - let config: [String: AnyCodable]? - let issues: [Issue]? -} - -@MainActor -@Observable -final class ChannelsStore { - static let shared = ChannelsStore() - - var snapshot: ChannelsStatusSnapshot? - var lastError: String? - var lastSuccess: Date? - var isRefreshing = false - - var whatsappLoginMessage: String? - var whatsappLoginQrDataUrl: String? - var whatsappLoginConnected: Bool? - var whatsappBusy = false - var telegramBusy = false - - var configStatus: String? - var isSavingConfig = false - var configSchemaLoading = false - var configSchema: ConfigSchemaNode? - var configUiHints: [String: ConfigUiHint] = [:] - var configDraft: [String: Any] = [:] - var configDirty = false - - let interval: TimeInterval = 45 - let isPreview: Bool - var pollTask: Task? - var configRoot: [String: Any] = [:] - var configLoaded = false - - func channelMetaEntry(_ id: String) -> ChannelsStatusSnapshot.ChannelUiMetaEntry? { - self.snapshot?.channelMeta?.first(where: { $0.id == id }) - } - - func resolveChannelLabel(_ id: String) -> String { - if let meta = self.channelMetaEntry(id), !meta.label.isEmpty { - return meta.label - } - if let label = self.snapshot?.channelLabels[id], !label.isEmpty { - return label - } - return id - } - - func resolveChannelDetailLabel(_ id: String) -> String { - if let meta = self.channelMetaEntry(id), !meta.detailLabel.isEmpty { - return meta.detailLabel - } - if let detail = self.snapshot?.channelDetailLabels?[id], !detail.isEmpty { - return detail - } - return self.resolveChannelLabel(id) - } - - func resolveChannelSystemImage(_ id: String) -> String { - if let meta = self.channelMetaEntry(id), let symbol = meta.systemImage, !symbol.isEmpty { - return symbol - } - if let symbol = self.snapshot?.channelSystemImages?[id], !symbol.isEmpty { - return symbol - } - return "message" - } - - func orderedChannelIds() -> [String] { - if let meta = self.snapshot?.channelMeta, !meta.isEmpty { - return meta.map(\.id) - } - return self.snapshot?.channelOrder ?? [] - } - - init(isPreview: Bool = ProcessInfo.processInfo.isPreview) { - self.isPreview = isPreview - } -} diff --git a/apps/macos/Sources/OpenClaw/CoalescingFSEventsWatcher.swift b/apps/macos/Sources/OpenClaw/CoalescingFSEventsWatcher.swift deleted file mode 100644 index f9e38d81170..00000000000 --- a/apps/macos/Sources/OpenClaw/CoalescingFSEventsWatcher.swift +++ /dev/null @@ -1,110 +0,0 @@ -import CoreServices -import Foundation - -final class CoalescingFSEventsWatcher: @unchecked Sendable { - private let queue: DispatchQueue - private var stream: FSEventStreamRef? - private var pending = false - - private let paths: [String] - private let shouldNotify: (Int, UnsafeMutableRawPointer?) -> Bool - private let onChange: () -> Void - private let coalesceDelay: TimeInterval - - init( - paths: [String], - queueLabel: String, - coalesceDelay: TimeInterval = 0.12, - shouldNotify: @escaping (Int, UnsafeMutableRawPointer?) -> Bool = { _, _ in true }, - onChange: @escaping () -> Void) - { - self.paths = paths - self.queue = DispatchQueue(label: queueLabel) - self.coalesceDelay = coalesceDelay - self.shouldNotify = shouldNotify - self.onChange = onChange - } - - deinit { - self.stop() - } - - func start() { - guard self.stream == nil else { return } - - let retainedSelf = Unmanaged.passRetained(self) - var context = FSEventStreamContext( - version: 0, - info: retainedSelf.toOpaque(), - retain: nil, - release: { pointer in - guard let pointer else { return } - Unmanaged.fromOpaque(pointer).release() - }, - copyDescription: nil) - - let paths = self.paths as CFArray - let flags = FSEventStreamCreateFlags( - kFSEventStreamCreateFlagFileEvents | - kFSEventStreamCreateFlagUseCFTypes | - kFSEventStreamCreateFlagNoDefer) - - guard let stream = FSEventStreamCreate( - kCFAllocatorDefault, - Self.callback, - &context, - paths, - FSEventStreamEventId(kFSEventStreamEventIdSinceNow), - 0.05, - flags) - else { - retainedSelf.release() - return - } - - self.stream = stream - FSEventStreamSetDispatchQueue(stream, self.queue) - if FSEventStreamStart(stream) == false { - self.stream = nil - FSEventStreamSetDispatchQueue(stream, nil) - FSEventStreamInvalidate(stream) - FSEventStreamRelease(stream) - } - } - - func stop() { - guard let stream = self.stream else { return } - self.stream = nil - FSEventStreamStop(stream) - FSEventStreamSetDispatchQueue(stream, nil) - FSEventStreamInvalidate(stream) - FSEventStreamRelease(stream) - } -} - -extension CoalescingFSEventsWatcher { - private static let callback: FSEventStreamCallback = { _, info, numEvents, eventPaths, eventFlags, _ in - guard let info else { return } - let watcher = Unmanaged.fromOpaque(info).takeUnretainedValue() - watcher.handleEvents(numEvents: numEvents, eventPaths: eventPaths, eventFlags: eventFlags) - } - - private func handleEvents( - numEvents: Int, - eventPaths: UnsafeMutableRawPointer?, - eventFlags: UnsafePointer?) - { - guard numEvents > 0 else { return } - guard eventFlags != nil else { return } - guard self.shouldNotify(numEvents, eventPaths) else { return } - - // Coalesce rapid changes (common during builds/atomic saves). - if self.pending { return } - self.pending = true - self.queue.asyncAfter(deadline: .now() + self.coalesceDelay) { [weak self] in - guard let self else { return } - self.pending = false - self.onChange() - } - } -} diff --git a/apps/macos/Sources/OpenClaw/CommandResolver.swift b/apps/macos/Sources/OpenClaw/CommandResolver.swift deleted file mode 100644 index c17f64e30e7..00000000000 --- a/apps/macos/Sources/OpenClaw/CommandResolver.swift +++ /dev/null @@ -1,574 +0,0 @@ -import Foundation - -enum CommandResolver { - private static let projectRootDefaultsKey = "openclaw.gatewayProjectRootPath" - private static let helperName = "openclaw" - - static func gatewayEntrypoint(in root: URL) -> String? { - let distEntry = root.appendingPathComponent("dist/index.js").path - if FileManager().isReadableFile(atPath: distEntry) { return distEntry } - let openclawEntry = root.appendingPathComponent("openclaw.mjs").path - if FileManager().isReadableFile(atPath: openclawEntry) { return openclawEntry } - let binEntry = root.appendingPathComponent("bin/openclaw.js").path - if FileManager().isReadableFile(atPath: binEntry) { return binEntry } - return nil - } - - static func runtimeResolution() -> Result { - RuntimeLocator.resolve(searchPaths: self.preferredPaths()) - } - - static func runtimeResolution(searchPaths: [String]?) -> Result { - RuntimeLocator.resolve(searchPaths: searchPaths ?? self.preferredPaths()) - } - - static func makeRuntimeCommand( - runtime: RuntimeResolution, - entrypoint: String, - subcommand: String, - extraArgs: [String]) -> [String] - { - [runtime.path, entrypoint, subcommand] + extraArgs - } - - static func runtimeErrorCommand(_ error: RuntimeResolutionError) -> [String] { - let message = RuntimeLocator.describeFailure(error) - return self.errorCommand(with: message) - } - - static func errorCommand(with message: String) -> [String] { - let script = """ - cat <<'__OPENCLAW_ERR__' >&2 - \(message) - __OPENCLAW_ERR__ - exit 1 - """ - return ["/bin/sh", "-c", script] - } - - static func projectRoot() -> URL { - if let stored = UserDefaults.standard.string(forKey: self.projectRootDefaultsKey), - let url = self.expandPath(stored), - FileManager().fileExists(atPath: url.path) - { - return url - } - let fallback = FileManager().homeDirectoryForCurrentUser - .appendingPathComponent("Projects/openclaw") - if FileManager().fileExists(atPath: fallback.path) { - return fallback - } - return FileManager().homeDirectoryForCurrentUser - } - - static func setProjectRoot(_ path: String) { - UserDefaults.standard.set(path, forKey: self.projectRootDefaultsKey) - } - - static func projectRootPath() -> String { - self.projectRoot().path - } - - static func preferredPaths() -> [String] { - let current = ProcessInfo.processInfo.environment["PATH"]? - .split(separator: ":").map(String.init) ?? [] - let home = FileManager().homeDirectoryForCurrentUser - let projectRoot = self.projectRoot() - return self.preferredPaths(home: home, current: current, projectRoot: projectRoot) - } - - static func preferredPaths(home: URL, current: [String], projectRoot: URL) -> [String] { - var extras = [ - home.appendingPathComponent("Library/pnpm").path, - "/opt/homebrew/bin", - "/usr/local/bin", - "/usr/bin", - "/bin", - ] - #if DEBUG - // Dev-only convenience. Avoid project-local PATH hijacking in release builds. - extras.insert(projectRoot.appendingPathComponent("node_modules/.bin").path, at: 0) - #endif - let openclawPaths = self.openclawManagedPaths(home: home) - if !openclawPaths.isEmpty { - extras.insert(contentsOf: openclawPaths, at: 1) - } - extras.insert(contentsOf: self.nodeManagerBinPaths(home: home), at: 1 + openclawPaths.count) - var seen = Set() - // Preserve order while stripping duplicates so PATH lookups remain deterministic. - return (extras + current).filter { seen.insert($0).inserted } - } - - private static func openclawManagedPaths(home: URL) -> [String] { - let bases = [ - home.appendingPathComponent(".openclaw"), - ] - var paths: [String] = [] - for base in bases { - let bin = base.appendingPathComponent("bin") - let nodeBin = base.appendingPathComponent("tools/node/bin") - if FileManager().fileExists(atPath: bin.path) { - paths.append(bin.path) - } - if FileManager().fileExists(atPath: nodeBin.path) { - paths.append(nodeBin.path) - } - } - return paths - } - - private static func nodeManagerBinPaths(home: URL) -> [String] { - var bins: [String] = [] - - // Volta - let volta = home.appendingPathComponent(".volta/bin") - if FileManager().fileExists(atPath: volta.path) { - bins.append(volta.path) - } - - // asdf - let asdf = home.appendingPathComponent(".asdf/shims") - if FileManager().fileExists(atPath: asdf.path) { - bins.append(asdf.path) - } - - // fnm - bins.append(contentsOf: self.versionedNodeBinPaths( - base: home.appendingPathComponent(".local/share/fnm/node-versions"), - suffix: "installation/bin")) - - // nvm - bins.append(contentsOf: self.versionedNodeBinPaths( - base: home.appendingPathComponent(".nvm/versions/node"), - suffix: "bin")) - - return bins - } - - private static func versionedNodeBinPaths(base: URL, suffix: String) -> [String] { - guard FileManager().fileExists(atPath: base.path) else { return [] } - let entries: [String] - do { - entries = try FileManager().contentsOfDirectory(atPath: base.path) - } catch { - return [] - } - - func parseVersion(_ name: String) -> [Int] { - let trimmed = name.hasPrefix("v") ? String(name.dropFirst()) : name - return trimmed.split(separator: ".").compactMap { Int($0) } - } - - let sorted = entries.sorted { a, b in - let va = parseVersion(a) - let vb = parseVersion(b) - let maxCount = max(va.count, vb.count) - for i in 0.. bi } - } - // If identical numerically, keep stable ordering. - return a > b - } - - var paths: [String] = [] - for entry in sorted { - let binDir = base.appendingPathComponent(entry).appendingPathComponent(suffix) - let node = binDir.appendingPathComponent("node") - if FileManager().isExecutableFile(atPath: node.path) { - paths.append(binDir.path) - } - } - return paths - } - - static func findExecutable(named name: String, searchPaths: [String]? = nil) -> String? { - for dir in searchPaths ?? self.preferredPaths() { - let candidate = (dir as NSString).appendingPathComponent(name) - if FileManager().isExecutableFile(atPath: candidate) { - return candidate - } - } - return nil - } - - static func openclawExecutable(searchPaths: [String]? = nil) -> String? { - self.findExecutable(named: self.helperName, searchPaths: searchPaths) - } - - static func projectOpenClawExecutable(projectRoot: URL? = nil) -> String? { - #if DEBUG - let root = projectRoot ?? self.projectRoot() - let candidate = root.appendingPathComponent("node_modules/.bin").appendingPathComponent(self.helperName).path - return FileManager().isExecutableFile(atPath: candidate) ? candidate : nil - #else - return nil - #endif - } - - static func nodeCliPath() -> String? { - let root = self.projectRoot() - let candidates = [ - root.appendingPathComponent("openclaw.mjs").path, - root.appendingPathComponent("bin/openclaw.js").path, - ] - for candidate in candidates where FileManager().isReadableFile(atPath: candidate) { - return candidate - } - return nil - } - - static func hasAnyOpenClawInvoker(searchPaths: [String]? = nil) -> Bool { - if self.openclawExecutable(searchPaths: searchPaths) != nil { return true } - if self.findExecutable(named: "pnpm", searchPaths: searchPaths) != nil { return true } - if self.findExecutable(named: "node", searchPaths: searchPaths) != nil, - self.nodeCliPath() != nil - { - return true - } - return false - } - - static func openclawNodeCommand( - subcommand: String, - extraArgs: [String] = [], - defaults: UserDefaults = .standard, - configRoot: [String: Any]? = nil, - searchPaths: [String]? = nil) -> [String] - { - let settings = self.connectionSettings(defaults: defaults, configRoot: configRoot) - if settings.mode == .remote, let ssh = self.sshNodeCommand( - subcommand: subcommand, - extraArgs: extraArgs, - settings: settings) - { - return ssh - } - - let runtimeResult = self.runtimeResolution(searchPaths: searchPaths) - - switch runtimeResult { - case let .success(runtime): - let root = self.projectRoot() - if let openclawPath = self.projectOpenClawExecutable(projectRoot: root) { - return [openclawPath, subcommand] + extraArgs - } - - if let entry = self.gatewayEntrypoint(in: root) { - return self.makeRuntimeCommand( - runtime: runtime, - entrypoint: entry, - subcommand: subcommand, - extraArgs: extraArgs) - } - if let pnpm = self.findExecutable(named: "pnpm", searchPaths: searchPaths) { - // Use --silent to avoid pnpm lifecycle banners that would corrupt JSON outputs. - return [pnpm, "--silent", "openclaw", subcommand] + extraArgs - } - if let openclawPath = self.openclawExecutable(searchPaths: searchPaths) { - return [openclawPath, subcommand] + extraArgs - } - - let missingEntry = """ - openclaw entrypoint missing (looked for dist/index.js or openclaw.mjs); run pnpm build. - """ - return self.errorCommand(with: missingEntry) - - case let .failure(error): - return self.runtimeErrorCommand(error) - } - } - - static func openclawCommand( - subcommand: String, - extraArgs: [String] = [], - defaults: UserDefaults = .standard, - configRoot: [String: Any]? = nil, - searchPaths: [String]? = nil) -> [String] - { - self.openclawNodeCommand( - subcommand: subcommand, - extraArgs: extraArgs, - defaults: defaults, - configRoot: configRoot, - searchPaths: searchPaths) - } - - // MARK: - SSH helpers - - private static func sshNodeCommand(subcommand: String, extraArgs: [String], settings: RemoteSettings) -> [String]? { - guard !settings.target.isEmpty else { return nil } - guard let parsed = self.parseSSHTarget(settings.target) else { return nil } - - // Run the real openclaw CLI on the remote host. - let exportedPath = [ - "/opt/homebrew/bin", - "/usr/local/bin", - "/usr/bin", - "/bin", - "/usr/sbin", - "/sbin", - "$HOME/Library/pnpm", - "$PATH", - ].joined(separator: ":") - let quotedArgs = ([subcommand] + extraArgs).map(self.shellQuote).joined(separator: " ") - let userPRJ = settings.projectRoot.trimmingCharacters(in: .whitespacesAndNewlines) - let userCLI = settings.cliPath.trimmingCharacters(in: .whitespacesAndNewlines) - - let projectSection = if userPRJ.isEmpty { - """ - DEFAULT_PRJ="$HOME/Projects/openclaw" - if [ -d "$DEFAULT_PRJ" ]; then - PRJ="$DEFAULT_PRJ" - cd "$PRJ" || { echo "Project root not found: $PRJ"; exit 127; } - fi - """ - } else { - """ - PRJ=\(self.shellQuote(userPRJ)) - cd "$PRJ" || { echo "Project root not found: $PRJ"; exit 127; } - """ - } - - let cliSection = if userCLI.isEmpty { - "" - } else { - """ - CLI_HINT=\(self.shellQuote(userCLI)) - if [ -n "$CLI_HINT" ]; then - if [ -x "$CLI_HINT" ]; then - CLI="$CLI_HINT" - "$CLI_HINT" \(quotedArgs); - exit $?; - elif [ -f "$CLI_HINT" ]; then - if command -v node >/dev/null 2>&1; then - CLI="node $CLI_HINT" - node "$CLI_HINT" \(quotedArgs); - exit $?; - fi - fi - fi - """ - } - - let scriptBody = """ - PATH=\(exportedPath); - CLI=""; - \(cliSection) - \(projectSection) - if command -v openclaw >/dev/null 2>&1; then - CLI="$(command -v openclaw)" - openclaw \(quotedArgs); - elif [ -n "${PRJ:-}" ] && [ -f "$PRJ/dist/index.js" ]; then - if command -v node >/dev/null 2>&1; then - CLI="node $PRJ/dist/index.js" - node "$PRJ/dist/index.js" \(quotedArgs); - else - echo "Node >=22 required on remote host"; exit 127; - fi - elif [ -n "${PRJ:-}" ] && [ -f "$PRJ/openclaw.mjs" ]; then - if command -v node >/dev/null 2>&1; then - CLI="node $PRJ/openclaw.mjs" - node "$PRJ/openclaw.mjs" \(quotedArgs); - else - echo "Node >=22 required on remote host"; exit 127; - fi - elif [ -n "${PRJ:-}" ] && [ -f "$PRJ/bin/openclaw.js" ]; then - if command -v node >/dev/null 2>&1; then - CLI="node $PRJ/bin/openclaw.js" - node "$PRJ/bin/openclaw.js" \(quotedArgs); - else - echo "Node >=22 required on remote host"; exit 127; - fi - elif command -v pnpm >/dev/null 2>&1; then - CLI="pnpm --silent openclaw" - pnpm --silent openclaw \(quotedArgs); - else - echo "openclaw CLI missing on remote host"; exit 127; - fi - """ - let options: [String] = [ - "-o", "BatchMode=yes", - "-o", "StrictHostKeyChecking=accept-new", - "-o", "UpdateHostKeys=yes", - ] - let args = self.sshArguments( - target: parsed, - identity: settings.identity, - options: options, - remoteCommand: ["/bin/sh", "-c", scriptBody]) - return ["/usr/bin/ssh"] + args - } - - struct RemoteSettings { - let mode: AppState.ConnectionMode - let target: String - let identity: String - let projectRoot: String - let cliPath: String - } - - static func connectionSettings( - defaults: UserDefaults = .standard, - configRoot: [String: Any]? = nil) -> RemoteSettings - { - let root = configRoot ?? OpenClawConfigFile.loadDict() - let mode = ConnectionModeResolver.resolve(root: root, defaults: defaults).mode - let target = defaults.string(forKey: remoteTargetKey) ?? "" - let identity = defaults.string(forKey: remoteIdentityKey) ?? "" - let projectRoot = defaults.string(forKey: remoteProjectRootKey) ?? "" - let cliPath = defaults.string(forKey: remoteCliPathKey) ?? "" - return RemoteSettings( - mode: mode, - target: self.sanitizedTarget(target), - identity: identity, - projectRoot: projectRoot, - cliPath: cliPath) - } - - static func connectionModeIsRemote(defaults: UserDefaults = .standard) -> Bool { - self.connectionSettings(defaults: defaults).mode == .remote - } - - private static func sanitizedTarget(_ raw: String) -> String { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.hasPrefix("ssh ") { - return trimmed.replacingOccurrences(of: "ssh ", with: "").trimmingCharacters(in: .whitespacesAndNewlines) - } - return trimmed - } - - struct SSHParsedTarget { - let user: String? - let host: String - let port: Int - } - - static func parseSSHTarget(_ target: String) -> SSHParsedTarget? { - let trimmed = self.normalizeSSHTargetInput(target) - guard !trimmed.isEmpty else { return nil } - if trimmed.rangeOfCharacter(from: CharacterSet.whitespacesAndNewlines.union(.controlCharacters)) != nil { - return nil - } - let userHostPort: String - let user: String? - if let atRange = trimmed.range(of: "@") { - user = String(trimmed[.. 0, parsedPort <= 65535 else { - return nil - } - port = parsedPort - } else { - host = userHostPort - port = 22 - } - - return self.makeSSHTarget(user: user, host: host, port: port) - } - - static func sshTargetValidationMessage(_ target: String) -> String? { - let trimmed = self.normalizeSSHTargetInput(target) - guard !trimmed.isEmpty else { return nil } - if trimmed.hasPrefix("-") { - return "SSH target cannot start with '-'" - } - if trimmed.rangeOfCharacter(from: CharacterSet.whitespacesAndNewlines.union(.controlCharacters)) != nil { - return "SSH target cannot contain spaces" - } - if self.parseSSHTarget(trimmed) == nil { - return "SSH target must look like user@host[:port]" - } - return nil - } - - private static func shellQuote(_ text: String) -> String { - if text.isEmpty { return "''" } - let escaped = text.replacingOccurrences(of: "'", with: "'\\''") - return "'\(escaped)'" - } - - private static func expandPath(_ path: String) -> URL? { - var expanded = path - if expanded.hasPrefix("~") { - let home = FileManager().homeDirectoryForCurrentUser.path - expanded.replaceSubrange(expanded.startIndex...expanded.startIndex, with: home) - } - return URL(fileURLWithPath: expanded) - } - - private static func normalizeSSHTargetInput(_ target: String) -> String { - var trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.hasPrefix("ssh ") { - trimmed = trimmed.replacingOccurrences(of: "ssh ", with: "") - .trimmingCharacters(in: .whitespacesAndNewlines) - } - return trimmed - } - - private static func isValidSSHComponent(_ value: String, allowLeadingDash: Bool = false) -> Bool { - if value.isEmpty { return false } - if !allowLeadingDash, value.hasPrefix("-") { return false } - let invalid = CharacterSet.whitespacesAndNewlines.union(.controlCharacters) - return value.rangeOfCharacter(from: invalid) == nil - } - - static func makeSSHTarget(user: String?, host: String, port: Int) -> SSHParsedTarget? { - let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines) - guard self.isValidSSHComponent(trimmedHost) else { return nil } - let trimmedUser = user?.trimmingCharacters(in: .whitespacesAndNewlines) - let normalizedUser: String? - if let trimmedUser { - guard self.isValidSSHComponent(trimmedUser) else { return nil } - normalizedUser = trimmedUser.isEmpty ? nil : trimmedUser - } else { - normalizedUser = nil - } - guard port > 0, port <= 65535 else { return nil } - return SSHParsedTarget(user: normalizedUser, host: trimmedHost, port: port) - } - - private static func sshTargetString(_ target: SSHParsedTarget) -> String { - target.user.map { "\($0)@\(target.host)" } ?? target.host - } - - static func sshArguments( - target: SSHParsedTarget, - identity: String, - options: [String], - remoteCommand: [String] = []) -> [String] - { - var args = options - if target.port > 0 { - args.append(contentsOf: ["-p", String(target.port)]) - } - let trimmedIdentity = identity.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmedIdentity.isEmpty { - // Only use IdentitiesOnly when an explicit identity file is provided. - // This allows 1Password SSH agent and other SSH agents to provide keys. - args.append(contentsOf: ["-o", "IdentitiesOnly=yes"]) - args.append(contentsOf: ["-i", trimmedIdentity]) - } - args.append("--") - args.append(self.sshTargetString(target)) - args.append(contentsOf: remoteCommand) - return args - } - - #if SWIFT_PACKAGE - static func _testNodeManagerBinPaths(home: URL) -> [String] { - self.nodeManagerBinPaths(home: home) - } - #endif -} diff --git a/apps/macos/Sources/OpenClaw/ConfigFileWatcher.swift b/apps/macos/Sources/OpenClaw/ConfigFileWatcher.swift deleted file mode 100644 index 4434443497e..00000000000 --- a/apps/macos/Sources/OpenClaw/ConfigFileWatcher.swift +++ /dev/null @@ -1,45 +0,0 @@ -import Foundation - -final class ConfigFileWatcher: @unchecked Sendable { - private let url: URL - private let watchedDir: URL - private let targetPath: String - private let targetName: String - private let watcher: CoalescingFSEventsWatcher - - init(url: URL, onChange: @escaping () -> Void) { - self.url = url - self.watchedDir = url.deletingLastPathComponent() - self.targetPath = url.path - self.targetName = url.lastPathComponent - let watchedDirPath = self.watchedDir.path - let targetPath = self.targetPath - let targetName = self.targetName - self.watcher = CoalescingFSEventsWatcher( - paths: [watchedDirPath], - queueLabel: "ai.openclaw.configwatcher", - shouldNotify: { _, eventPaths in - guard let eventPaths else { return true } - let paths = unsafeBitCast(eventPaths, to: NSArray.self) - for case let path as String in paths { - if path == targetPath { return true } - if path.hasSuffix("/\(targetName)") { return true } - if path == watchedDirPath { return true } - } - return false - }, - onChange: onChange) - } - - deinit { - self.stop() - } - - func start() { - self.watcher.start() - } - - func stop() { - self.watcher.stop() - } -} diff --git a/apps/macos/Sources/OpenClaw/ConfigSchemaSupport.swift b/apps/macos/Sources/OpenClaw/ConfigSchemaSupport.swift deleted file mode 100644 index 406d908d0b7..00000000000 --- a/apps/macos/Sources/OpenClaw/ConfigSchemaSupport.swift +++ /dev/null @@ -1,219 +0,0 @@ -import Foundation - -enum ConfigPathSegment: Hashable { - case key(String) - case index(Int) -} - -typealias ConfigPath = [ConfigPathSegment] - -struct ConfigUiHint { - let label: String? - let help: String? - let order: Double? - let advanced: Bool? - let sensitive: Bool? - let placeholder: String? - - init(raw: [String: Any]) { - self.label = raw["label"] as? String - self.help = raw["help"] as? String - if let order = raw["order"] as? Double { - self.order = order - } else if let orderInt = raw["order"] as? Int { - self.order = Double(orderInt) - } else { - self.order = nil - } - self.advanced = raw["advanced"] as? Bool - self.sensitive = raw["sensitive"] as? Bool - self.placeholder = raw["placeholder"] as? String - } -} - -struct ConfigSchemaNode { - let raw: [String: Any] - - init?(raw: Any) { - guard let dict = raw as? [String: Any] else { return nil } - self.raw = dict - } - - var title: String? { - self.raw["title"] as? String - } - - var description: String? { - self.raw["description"] as? String - } - - var enumValues: [Any]? { - self.raw["enum"] as? [Any] - } - - var constValue: Any? { - self.raw["const"] - } - - var explicitDefault: Any? { - self.raw["default"] - } - - var requiredKeys: Set { - Set((self.raw["required"] as? [String]) ?? []) - } - - var typeList: [String] { - if let type = self.raw["type"] as? String { return [type] } - if let types = self.raw["type"] as? [String] { return types } - return [] - } - - var schemaType: String? { - let filtered = self.typeList.filter { $0 != "null" } - if let first = filtered.first { return first } - return self.typeList.first - } - - var isNullSchema: Bool { - let types = self.typeList - return types.count == 1 && types.first == "null" - } - - var properties: [String: ConfigSchemaNode] { - guard let props = self.raw["properties"] as? [String: Any] else { return [:] } - return props.compactMapValues { ConfigSchemaNode(raw: $0) } - } - - var anyOf: [ConfigSchemaNode] { - guard let raw = self.raw["anyOf"] as? [Any] else { return [] } - return raw.compactMap { ConfigSchemaNode(raw: $0) } - } - - var oneOf: [ConfigSchemaNode] { - guard let raw = self.raw["oneOf"] as? [Any] else { return [] } - return raw.compactMap { ConfigSchemaNode(raw: $0) } - } - - var literalValue: Any? { - if let constValue { return constValue } - if let enumValues, enumValues.count == 1 { return enumValues[0] } - return nil - } - - var items: ConfigSchemaNode? { - if let items = self.raw["items"] as? [Any], let first = items.first { - return ConfigSchemaNode(raw: first) - } - if let items = self.raw["items"] { - return ConfigSchemaNode(raw: items) - } - return nil - } - - var additionalProperties: ConfigSchemaNode? { - if let additional = self.raw["additionalProperties"] as? [String: Any] { - return ConfigSchemaNode(raw: additional) - } - return nil - } - - var allowsAdditionalProperties: Bool { - if let allow = self.raw["additionalProperties"] as? Bool { return allow } - return self.additionalProperties != nil - } - - var defaultValue: Any { - if let value = self.raw["default"] { return value } - switch self.schemaType { - case "object": - return [String: Any]() - case "array": - return [Any]() - case "boolean": - return false - case "integer": - return 0 - case "number": - return 0.0 - case "string": - return "" - default: - return "" - } - } - - func node(at path: ConfigPath) -> ConfigSchemaNode? { - var current: ConfigSchemaNode? = self - for segment in path { - guard let node = current else { return nil } - switch segment { - case let .key(key): - if node.schemaType == "object" { - if let next = node.properties[key] { - current = next - continue - } - if let additional = node.additionalProperties { - current = additional - continue - } - return nil - } - return nil - case .index: - guard node.schemaType == "array" else { return nil } - current = node.items - } - } - return current - } -} - -func decodeUiHints(_ raw: [String: Any]) -> [String: ConfigUiHint] { - raw.reduce(into: [:]) { result, entry in - if let hint = entry.value as? [String: Any] { - result[entry.key] = ConfigUiHint(raw: hint) - } - } -} - -func hintForPath(_ path: ConfigPath, hints: [String: ConfigUiHint]) -> ConfigUiHint? { - let key = pathKey(path) - if let direct = hints[key] { return direct } - let segments = key.split(separator: ".").map(String.init) - for (hintKey, hint) in hints { - guard hintKey.contains("*") else { continue } - let hintSegments = hintKey.split(separator: ".").map(String.init) - guard hintSegments.count == segments.count else { continue } - var match = true - for (index, seg) in segments.enumerated() { - let hintSegment = hintSegments[index] - if hintSegment != "*", hintSegment != seg { - match = false - break - } - } - if match { return hint } - } - return nil -} - -func isSensitivePath(_ path: ConfigPath) -> Bool { - let key = pathKey(path).lowercased() - return key.contains("token") - || key.contains("password") - || key.contains("secret") - || key.contains("apikey") - || key.hasSuffix("key") -} - -func pathKey(_ path: ConfigPath) -> String { - path.compactMap { segment -> String? in - switch segment { - case let .key(key): return key - case .index: return nil - } - } - .joined(separator: ".") -} diff --git a/apps/macos/Sources/OpenClaw/ConfigSettings.swift b/apps/macos/Sources/OpenClaw/ConfigSettings.swift deleted file mode 100644 index 096ae3f7149..00000000000 --- a/apps/macos/Sources/OpenClaw/ConfigSettings.swift +++ /dev/null @@ -1,395 +0,0 @@ -import SwiftUI - -@MainActor -struct ConfigSettings: View { - private let isPreview = ProcessInfo.processInfo.isPreview - private let isNixMode = ProcessInfo.processInfo.isNixMode - @Bindable var store: ChannelsStore - @State private var hasLoaded = false - @State private var activeSectionKey: String? - @State private var activeSubsection: SubsectionSelection? - - init(store: ChannelsStore = .shared) { - self.store = store - } - - var body: some View { - HStack(spacing: 16) { - self.sidebar - self.detail - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .task { - guard !self.hasLoaded else { return } - guard !self.isPreview else { return } - self.hasLoaded = true - await self.store.loadConfigSchema() - await self.store.loadConfig() - } - .onAppear { self.ensureSelection() } - .onChange(of: self.store.configSchemaLoading) { _, loading in - if !loading { self.ensureSelection() } - } - } -} - -extension ConfigSettings { - private enum SubsectionSelection: Hashable { - case all - case key(String) - } - - private struct ConfigSection: Identifiable { - let key: String - let label: String - let help: String? - let node: ConfigSchemaNode - - var id: String { - self.key - } - } - - private struct ConfigSubsection: Identifiable { - let key: String - let label: String - let help: String? - let node: ConfigSchemaNode - let path: ConfigPath - - var id: String { - self.key - } - } - - private var sections: [ConfigSection] { - guard let schema = self.store.configSchema else { return [] } - return self.resolveSections(schema) - } - - private var activeSection: ConfigSection? { - self.sections.first { $0.key == self.activeSectionKey } - } - - private var sidebar: some View { - ScrollView { - LazyVStack(alignment: .leading, spacing: 8) { - if self.sections.isEmpty { - Text("No config sections available.") - .font(.caption) - .foregroundStyle(.secondary) - .padding(.horizontal, 6) - .padding(.vertical, 4) - } else { - ForEach(self.sections) { section in - self.sidebarRow(section) - } - } - } - .padding(.vertical, 10) - .padding(.horizontal, 10) - } - .frame(minWidth: 220, idealWidth: 240, maxWidth: 280, maxHeight: .infinity, alignment: .topLeading) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color(nsColor: .windowBackgroundColor))) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - } - - private var detail: some View { - VStack(alignment: .leading, spacing: 16) { - if self.store.configSchemaLoading { - ProgressView().controlSize(.small) - } else if let section = self.activeSection { - self.sectionDetail(section) - } else if self.store.configSchema != nil { - self.emptyDetail - } else { - Text("Schema unavailable.") - .font(.caption) - .foregroundStyle(.secondary) - } - } - .frame(minWidth: 460, maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - } - - private var emptyDetail: some View { - VStack(alignment: .leading, spacing: 8) { - self.header - Text("Select a config section to view settings.") - .font(.callout) - .foregroundStyle(.secondary) - } - .padding(.horizontal, 24) - .padding(.vertical, 18) - } - - private func sectionDetail(_ section: ConfigSection) -> some View { - ScrollView(.vertical) { - VStack(alignment: .leading, spacing: 16) { - self.header - if let status = self.store.configStatus { - Text(status) - .font(.callout) - .foregroundStyle(.secondary) - } - self.actionRow - self.sectionHeader(section) - self.subsectionNav(section) - self.sectionForm(section) - if self.store.configDirty, !self.isNixMode { - Text("Unsaved changes") - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 24) - .padding(.vertical, 18) - .groupBoxStyle(PlainSettingsGroupBoxStyle()) - } - } - - @ViewBuilder - private var header: some View { - Text("Config") - .font(.title3.weight(.semibold)) - Text(self.isNixMode - ? "This tab is read-only in Nix mode. Edit config via Nix and rebuild." - : "Edit ~/.openclaw/openclaw.json using the schema-driven form.") - .font(.callout) - .foregroundStyle(.secondary) - } - - private func sectionHeader(_ section: ConfigSection) -> some View { - VStack(alignment: .leading, spacing: 6) { - Text(section.label) - .font(.title3.weight(.semibold)) - if let help = section.help { - Text(help) - .font(.callout) - .foregroundStyle(.secondary) - } - } - } - - private var actionRow: some View { - HStack(spacing: 10) { - Button("Reload") { - Task { await self.store.reloadConfigDraft() } - } - .disabled(!self.store.configLoaded) - - Button(self.store.isSavingConfig ? "Saving…" : "Save") { - Task { await self.store.saveConfigDraft() } - } - .disabled(self.isNixMode || self.store.isSavingConfig || !self.store.configDirty) - } - .buttonStyle(.bordered) - } - - private func sidebarRow(_ section: ConfigSection) -> some View { - let isSelected = self.activeSectionKey == section.key - return Button { - self.selectSection(section) - } label: { - VStack(alignment: .leading, spacing: 2) { - Text(section.label) - if let help = section.help { - Text(help) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(2) - } - } - .padding(.vertical, 6) - .padding(.horizontal, 8) - .frame(maxWidth: .infinity, alignment: .leading) - .background(isSelected ? Color.accentColor.opacity(0.18) : Color.clear) - .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) - .background(Color.clear) - .contentShape(Rectangle()) - } - .frame(maxWidth: .infinity, alignment: .leading) - .buttonStyle(.plain) - .contentShape(Rectangle()) - } - - @ViewBuilder - private func subsectionNav(_ section: ConfigSection) -> some View { - let subsections = self.resolveSubsections(for: section) - if subsections.isEmpty { - EmptyView() - } else { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - self.subsectionButton( - title: "All", - isSelected: self.activeSubsection == .all) - { - self.activeSubsection = .all - } - ForEach(subsections) { subsection in - self.subsectionButton( - title: subsection.label, - isSelected: self.activeSubsection == .key(subsection.key)) - { - self.activeSubsection = .key(subsection.key) - } - } - } - .padding(.vertical, 2) - } - } - } - - private func subsectionButton( - title: String, - isSelected: Bool, - action: @escaping () -> Void) -> some View - { - Button(action: action) { - Text(title) - .font(.callout.weight(.semibold)) - .foregroundStyle(isSelected ? Color.accentColor : .primary) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(isSelected ? Color.accentColor.opacity(0.18) : Color(nsColor: .controlBackgroundColor)) - .clipShape(Capsule()) - } - .buttonStyle(.plain) - } - - private func sectionForm(_ section: ConfigSection) -> some View { - let subsection = self.activeSubsection - let defaultPath: ConfigPath = [.key(section.key)] - let subsections = self.resolveSubsections(for: section) - let resolved: (ConfigSchemaNode, ConfigPath) = { - if case let .key(key) = subsection, - let match = subsections.first(where: { $0.key == key }) - { - return (match.node, match.path) - } - return (self.resolvedSchemaNode(section.node), defaultPath) - }() - - return ConfigSchemaForm(store: self.store, schema: resolved.0, path: resolved.1) - .disabled(self.isNixMode) - } - - private func ensureSelection() { - guard let schema = self.store.configSchema else { return } - let sections = self.resolveSections(schema) - guard !sections.isEmpty else { return } - - let active = sections.first { $0.key == self.activeSectionKey } ?? sections[0] - if self.activeSectionKey != active.key { - self.activeSectionKey = active.key - } - self.ensureSubsection(for: active) - } - - private func ensureSubsection(for section: ConfigSection) { - let subsections = self.resolveSubsections(for: section) - guard !subsections.isEmpty else { - self.activeSubsection = nil - return - } - - switch self.activeSubsection { - case .all: - return - case let .key(key): - if subsections.contains(where: { $0.key == key }) { return } - case .none: - break - } - - if let first = subsections.first { - self.activeSubsection = .key(first.key) - } - } - - private func selectSection(_ section: ConfigSection) { - guard self.activeSectionKey != section.key else { return } - self.activeSectionKey = section.key - let subsections = self.resolveSubsections(for: section) - if let first = subsections.first { - self.activeSubsection = .key(first.key) - } else { - self.activeSubsection = nil - } - } - - private func resolveSections(_ root: ConfigSchemaNode) -> [ConfigSection] { - let node = self.resolvedSchemaNode(root) - let hints = self.store.configUiHints - let keys = node.properties.keys.sorted { lhs, rhs in - let orderA = hintForPath([.key(lhs)], hints: hints)?.order ?? 0 - let orderB = hintForPath([.key(rhs)], hints: hints)?.order ?? 0 - if orderA != orderB { return orderA < orderB } - return lhs < rhs - } - - return keys.compactMap { key in - guard let child = node.properties[key] else { return nil } - let path: ConfigPath = [.key(key)] - let hint = hintForPath(path, hints: hints) - let label = hint?.label - ?? child.title - ?? self.humanize(key) - let help = hint?.help ?? child.description - return ConfigSection(key: key, label: label, help: help, node: child) - } - } - - private func resolveSubsections(for section: ConfigSection) -> [ConfigSubsection] { - let node = self.resolvedSchemaNode(section.node) - guard node.schemaType == "object" else { return [] } - let hints = self.store.configUiHints - let keys = node.properties.keys.sorted { lhs, rhs in - let orderA = hintForPath([.key(section.key), .key(lhs)], hints: hints)?.order ?? 0 - let orderB = hintForPath([.key(section.key), .key(rhs)], hints: hints)?.order ?? 0 - if orderA != orderB { return orderA < orderB } - return lhs < rhs - } - - return keys.compactMap { key in - guard let child = node.properties[key] else { return nil } - let path: ConfigPath = [.key(section.key), .key(key)] - let hint = hintForPath(path, hints: hints) - let label = hint?.label - ?? child.title - ?? self.humanize(key) - let help = hint?.help ?? child.description - return ConfigSubsection( - key: key, - label: label, - help: help, - node: child, - path: path) - } - } - - private func resolvedSchemaNode(_ node: ConfigSchemaNode) -> ConfigSchemaNode { - let variants = node.anyOf.isEmpty ? node.oneOf : node.anyOf - if !variants.isEmpty { - let nonNull = variants.filter { !$0.isNullSchema } - if nonNull.count == 1, let only = nonNull.first { return only } - } - return node - } - - private func humanize(_ key: String) -> String { - key.replacingOccurrences(of: "_", with: " ") - .replacingOccurrences(of: "-", with: " ") - .capitalized - } -} - -struct ConfigSettings_Previews: PreviewProvider { - static var previews: some View { - ConfigSettings() - } -} diff --git a/apps/macos/Sources/OpenClaw/ConfigStore.swift b/apps/macos/Sources/OpenClaw/ConfigStore.swift deleted file mode 100644 index 8fd779c6456..00000000000 --- a/apps/macos/Sources/OpenClaw/ConfigStore.swift +++ /dev/null @@ -1,117 +0,0 @@ -import Foundation -import OpenClawProtocol - -enum ConfigStore { - struct Overrides: Sendable { - var isRemoteMode: (@Sendable () async -> Bool)? - var loadLocal: (@MainActor @Sendable () -> [String: Any])? - var saveLocal: (@MainActor @Sendable ([String: Any]) -> Void)? - var loadRemote: (@MainActor @Sendable () async -> [String: Any])? - var saveRemote: (@MainActor @Sendable ([String: Any]) async throws -> Void)? - } - - private actor OverrideStore { - var overrides = Overrides() - - func setOverride(_ overrides: Overrides) { - self.overrides = overrides - } - } - - private static let overrideStore = OverrideStore() - @MainActor private static var lastHash: String? - - private static func isRemoteMode() async -> Bool { - let overrides = await self.overrideStore.overrides - if let override = overrides.isRemoteMode { - return await override() - } - return await MainActor.run { AppStateStore.shared.connectionMode == .remote } - } - - @MainActor - static func load() async -> [String: Any] { - let overrides = await self.overrideStore.overrides - if await self.isRemoteMode() { - if let override = overrides.loadRemote { - return await override() - } - return await self.loadFromGateway() ?? [:] - } - if let override = overrides.loadLocal { - return override() - } - if let gateway = await self.loadFromGateway() { - return gateway - } - return OpenClawConfigFile.loadDict() - } - - @MainActor - static func save(_ root: sending [String: Any]) async throws { - let overrides = await self.overrideStore.overrides - if await self.isRemoteMode() { - if let override = overrides.saveRemote { - try await override(root) - } else { - try await self.saveToGateway(root) - } - } else { - if let override = overrides.saveLocal { - override(root) - } else { - do { - try await self.saveToGateway(root) - } catch { - OpenClawConfigFile.saveDict(root) - } - } - } - } - - @MainActor - private static func loadFromGateway() async -> [String: Any]? { - do { - let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded( - method: .configGet, - params: nil, - timeoutMs: 8000) - self.lastHash = snap.hash - return snap.config?.mapValues { $0.foundationValue } ?? [:] - } catch { - return nil - } - } - - @MainActor - private static func saveToGateway(_ root: [String: Any]) async throws { - if self.lastHash == nil { - _ = await self.loadFromGateway() - } - let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys]) - guard let raw = String(data: data, encoding: .utf8) else { - throw NSError(domain: "ConfigStore", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Failed to encode config.", - ]) - } - var params: [String: AnyCodable] = ["raw": AnyCodable(raw)] - if let baseHash = self.lastHash { - params["baseHash"] = AnyCodable(baseHash) - } - _ = try await GatewayConnection.shared.requestRaw( - method: .configSet, - params: params, - timeoutMs: 10000) - _ = await self.loadFromGateway() - } - - #if DEBUG - static func _testSetOverrides(_ overrides: Overrides) async { - await self.overrideStore.setOverride(overrides) - } - - static func _testClearOverrides() async { - await self.overrideStore.setOverride(.init()) - } - #endif -} diff --git a/apps/macos/Sources/OpenClaw/ConnectionModeCoordinator.swift b/apps/macos/Sources/OpenClaw/ConnectionModeCoordinator.swift deleted file mode 100644 index b1c5eab1dbb..00000000000 --- a/apps/macos/Sources/OpenClaw/ConnectionModeCoordinator.swift +++ /dev/null @@ -1,79 +0,0 @@ -import Foundation -import OSLog - -@MainActor -final class ConnectionModeCoordinator { - static let shared = ConnectionModeCoordinator() - - private let logger = Logger(subsystem: "ai.openclaw", category: "connection") - private var lastMode: AppState.ConnectionMode? - - /// Apply the requested connection mode by starting/stopping local gateway, - /// managing the control-channel SSH tunnel, and cleaning up chat windows/panels. - func apply(mode: AppState.ConnectionMode, paused: Bool) async { - if let lastMode = self.lastMode, lastMode != mode { - GatewayProcessManager.shared.clearLastFailure() - NodesStore.shared.lastError = nil - } - self.lastMode = mode - switch mode { - case .unconfigured: - _ = await NodeServiceManager.stop() - NodesStore.shared.lastError = nil - await RemoteTunnelManager.shared.stopAll() - WebChatManager.shared.resetTunnels() - GatewayProcessManager.shared.stop() - await GatewayConnection.shared.shutdown() - await ControlChannel.shared.disconnect() - Task.detached { await PortGuardian.shared.sweep(mode: .unconfigured) } - - case .local: - _ = await NodeServiceManager.stop() - NodesStore.shared.lastError = nil - await RemoteTunnelManager.shared.stopAll() - WebChatManager.shared.resetTunnels() - let shouldStart = GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: paused) - if shouldStart { - GatewayProcessManager.shared.setActive(true) - if GatewayAutostartPolicy.shouldEnsureLaunchAgent( - mode: .local, - paused: paused) - { - Task { await GatewayProcessManager.shared.ensureLaunchAgentEnabledIfNeeded() } - } - _ = await GatewayProcessManager.shared.waitForGatewayReady() - } else { - GatewayProcessManager.shared.stop() - } - do { - try await ControlChannel.shared.configure(mode: .local) - } catch { - // Control channel will mark itself degraded; nothing else to do here. - self.logger.error( - "control channel local configure failed: \(error.localizedDescription, privacy: .public)") - } - Task.detached { await PortGuardian.shared.sweep(mode: .local) } - - case .remote: - // Never run a local gateway in remote mode. - GatewayProcessManager.shared.stop() - WebChatManager.shared.resetTunnels() - - do { - NodesStore.shared.lastError = nil - if let error = await NodeServiceManager.start() { - NodesStore.shared.lastError = "Node service start failed: \(error)" - } - _ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel() - let settings = CommandResolver.connectionSettings() - try await ControlChannel.shared.configure(mode: .remote( - target: settings.target, - identity: settings.identity)) - } catch { - self.logger.error("remote tunnel/configure failed: \(error.localizedDescription, privacy: .public)") - } - - Task.detached { await PortGuardian.shared.sweep(mode: .remote) } - } - } -} diff --git a/apps/macos/Sources/OpenClaw/ConnectionModeResolver.swift b/apps/macos/Sources/OpenClaw/ConnectionModeResolver.swift deleted file mode 100644 index 60c6fab9d56..00000000000 --- a/apps/macos/Sources/OpenClaw/ConnectionModeResolver.swift +++ /dev/null @@ -1,49 +0,0 @@ -import Foundation - -enum EffectiveConnectionModeSource: Sendable, Equatable { - case configMode - case configRemoteURL - case userDefaults - case onboarding -} - -struct EffectiveConnectionMode: Sendable, Equatable { - let mode: AppState.ConnectionMode - let source: EffectiveConnectionModeSource -} - -enum ConnectionModeResolver { - static func resolve( - root: [String: Any], - defaults: UserDefaults = .standard) -> EffectiveConnectionMode - { - let gateway = root["gateway"] as? [String: Any] - let configModeRaw = (gateway?["mode"] as? String) ?? "" - let configMode = configModeRaw - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - - switch configMode { - case "local": - return EffectiveConnectionMode(mode: .local, source: .configMode) - case "remote": - return EffectiveConnectionMode(mode: .remote, source: .configMode) - default: - break - } - - let remoteURLRaw = ((gateway?["remote"] as? [String: Any])?["url"] as? String) ?? "" - let remoteURL = remoteURLRaw.trimmingCharacters(in: .whitespacesAndNewlines) - if !remoteURL.isEmpty { - return EffectiveConnectionMode(mode: .remote, source: .configRemoteURL) - } - - if let storedModeRaw = defaults.string(forKey: connectionModeKey) { - let storedMode = AppState.ConnectionMode(rawValue: storedModeRaw) ?? .local - return EffectiveConnectionMode(mode: storedMode, source: .userDefaults) - } - - let seen = defaults.bool(forKey: "openclaw.onboardingSeen") - return EffectiveConnectionMode(mode: seen ? .local : .unconfigured, source: .onboarding) - } -} diff --git a/apps/macos/Sources/OpenClaw/Constants.swift b/apps/macos/Sources/OpenClaw/Constants.swift deleted file mode 100644 index 7065702d688..00000000000 --- a/apps/macos/Sources/OpenClaw/Constants.swift +++ /dev/null @@ -1,48 +0,0 @@ -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" -let onboardingSeenKey = "openclaw.onboardingSeen" -let currentOnboardingVersion = 7 -let pauseDefaultsKey = "openclaw.pauseEnabled" -let iconAnimationsEnabledKey = "openclaw.iconAnimationsEnabled" -let swabbleEnabledKey = "openclaw.swabbleEnabled" -let swabbleTriggersKey = "openclaw.swabbleTriggers" -let voiceWakeTriggerChimeKey = "openclaw.voiceWakeTriggerChime" -let voiceWakeSendChimeKey = "openclaw.voiceWakeSendChime" -let showDockIconKey = "openclaw.showDockIcon" -let defaultVoiceWakeTriggers = ["openclaw"] -let voiceWakeMaxWords = 32 -let voiceWakeMaxWordLength = 64 -let voiceWakeMicKey = "openclaw.voiceWakeMicID" -let voiceWakeMicNameKey = "openclaw.voiceWakeMicName" -let voiceWakeLocaleKey = "openclaw.voiceWakeLocaleID" -let voiceWakeAdditionalLocalesKey = "openclaw.voiceWakeAdditionalLocaleIDs" -let voicePushToTalkEnabledKey = "openclaw.voicePushToTalkEnabled" -let talkEnabledKey = "openclaw.talkEnabled" -let iconOverrideKey = "openclaw.iconOverride" -let connectionModeKey = "openclaw.connectionMode" -let remoteTargetKey = "openclaw.remoteTarget" -let remoteIdentityKey = "openclaw.remoteIdentity" -let remoteProjectRootKey = "openclaw.remoteProjectRoot" -let remoteCliPathKey = "openclaw.remoteCliPath" -let canvasEnabledKey = "openclaw.canvasEnabled" -let cameraEnabledKey = "openclaw.cameraEnabled" -let systemRunPolicyKey = "openclaw.systemRunPolicy" -let systemRunAllowlistKey = "openclaw.systemRunAllowlist" -let systemRunEnabledKey = "openclaw.systemRunEnabled" -let locationModeKey = "openclaw.locationMode" -let locationPreciseKey = "openclaw.locationPreciseEnabled" -let peekabooBridgeEnabledKey = "openclaw.peekabooBridgeEnabled" -let deepLinkKeyKey = "openclaw.deepLinkKey" -let modelCatalogPathKey = "openclaw.modelCatalogPath" -let modelCatalogReloadKey = "openclaw.modelCatalogReload" -let cliInstallPromptedVersionKey = "openclaw.cliInstallPromptedVersion" -let heartbeatsEnabledKey = "openclaw.heartbeatsEnabled" -let debugPaneEnabledKey = "openclaw.debugPaneEnabled" -let debugFileLogEnabledKey = "openclaw.debug.fileLogEnabled" -let appLogLevelKey = "openclaw.debug.appLogLevel" -let voiceWakeSupported: Bool = ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26 diff --git a/apps/macos/Sources/OpenClaw/ContextMenuCardView.swift b/apps/macos/Sources/OpenClaw/ContextMenuCardView.swift deleted file mode 100644 index f9a11b9e512..00000000000 --- a/apps/macos/Sources/OpenClaw/ContextMenuCardView.swift +++ /dev/null @@ -1,120 +0,0 @@ -import Foundation -import SwiftUI - -/// Context usage card shown at the top of the menubar menu. -struct ContextMenuCardView: View { - private let rows: [SessionRow] - private let statusText: String? - private let isLoading: Bool - private let paddingTop: CGFloat = 8 - private let paddingBottom: CGFloat = 8 - private let paddingTrailing: CGFloat = 10 - private let paddingLeading: CGFloat = 20 - private let barHeight: CGFloat = 3 - - init( - rows: [SessionRow], - statusText: String? = nil, - isLoading: Bool = false) - { - self.rows = rows - self.statusText = statusText - self.isLoading = isLoading - } - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - HStack(alignment: .firstTextBaseline) { - Text("Context") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - Spacer(minLength: 10) - Text(self.subtitle) - .font(.caption) - .foregroundStyle(.secondary) - } - - if let statusText { - Text(statusText) - .font(.caption) - .foregroundStyle(.secondary) - } else if self.rows.isEmpty, !self.isLoading { - Text("No active sessions") - .font(.caption) - .foregroundStyle(.secondary) - } else { - VStack(alignment: .leading, spacing: 12) { - if self.rows.isEmpty, self.isLoading { - ForEach(0..<2, id: \.self) { _ in - self.placeholderRow - } - } else { - ForEach(self.rows) { row in - self.sessionRow(row) - } - } - } - } - } - .padding(.top, self.paddingTop) - .padding(.bottom, self.paddingBottom) - .padding(.leading, self.paddingLeading) - .padding(.trailing, self.paddingTrailing) - .frame(minWidth: 300, maxWidth: .infinity, alignment: .leading) - .transaction { txn in txn.animation = nil } - } - - private var subtitle: String { - let count = self.rows.count - if count == 1 { return "1 session · 24h" } - return "\(count) sessions · 24h" - } - - private func sessionRow(_ row: SessionRow) -> some View { - VStack(alignment: .leading, spacing: 5) { - ContextUsageBar( - usedTokens: row.tokens.total, - contextTokens: row.tokens.contextTokens, - height: self.barHeight) - - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(row.label) - .font(.caption.weight(row.key == "main" ? .semibold : .regular)) - .lineLimit(1) - .truncationMode(.middle) - .layoutPriority(1) - Spacer(minLength: 8) - Text(row.tokens.contextSummaryShort) - .font(.caption.monospacedDigit()) - .foregroundStyle(.secondary) - .lineLimit(1) - .fixedSize(horizontal: true, vertical: false) - .layoutPriority(2) - } - } - .padding(.vertical, 2) - } - - private var placeholderRow: some View { - VStack(alignment: .leading, spacing: 5) { - ContextUsageBar( - usedTokens: 0, - contextTokens: 200_000, - height: self.barHeight) - - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text("main") - .font(.caption.weight(.semibold)) - .lineLimit(1) - .layoutPriority(1) - Spacer(minLength: 8) - Text("000k/000k") - .font(.caption.monospacedDigit()) - .foregroundStyle(.secondary) - .fixedSize(horizontal: true, vertical: false) - .layoutPriority(2) - } - .redacted(reason: .placeholder) - } - } -} diff --git a/apps/macos/Sources/OpenClaw/ContextUsageBar.swift b/apps/macos/Sources/OpenClaw/ContextUsageBar.swift deleted file mode 100644 index f5bfa0530b0..00000000000 --- a/apps/macos/Sources/OpenClaw/ContextUsageBar.swift +++ /dev/null @@ -1,93 +0,0 @@ -import SwiftUI - -struct ContextUsageBar: View { - let usedTokens: Int - let contextTokens: Int - var width: CGFloat? - var height: CGFloat = 6 - - private static let okGreen: NSColor = .init(name: nil) { appearance in - let base = NSColor.systemGreen - let match = appearance.bestMatch(from: [.aqua, .darkAqua]) - if match == .darkAqua { return base } - return base.blended(withFraction: 0.24, of: .black) ?? base - } - - private static let trackFill: NSColor = .init(name: nil) { appearance in - let match = appearance.bestMatch(from: [.aqua, .darkAqua]) - if match == .darkAqua { return NSColor.white.withAlphaComponent(0.14) } - return NSColor.black.withAlphaComponent(0.12) - } - - private static let trackStroke: NSColor = .init(name: nil) { appearance in - let match = appearance.bestMatch(from: [.aqua, .darkAqua]) - if match == .darkAqua { return NSColor.white.withAlphaComponent(0.22) } - return NSColor.black.withAlphaComponent(0.2) - } - - private var clampedFractionUsed: Double { - guard self.contextTokens > 0 else { return 0 } - return min(1, max(0, Double(self.usedTokens) / Double(self.contextTokens))) - } - - private var percentUsed: Int? { - guard self.contextTokens > 0, self.usedTokens > 0 else { return nil } - return min(100, Int(round(self.clampedFractionUsed * 100))) - } - - private var tint: Color { - guard let pct = self.percentUsed else { return .secondary } - if pct >= 95 { return Color(nsColor: .systemRed) } - if pct >= 80 { return Color(nsColor: .systemOrange) } - if pct >= 60 { return Color(nsColor: .systemYellow) } - return Color(nsColor: Self.okGreen) - } - - var body: some View { - let fraction = self.clampedFractionUsed - Group { - if let width = self.width, width > 0 { - self.barBody(width: width, fraction: fraction) - .frame(width: width, height: self.height) - } else { - GeometryReader { proxy in - self.barBody(width: proxy.size.width, fraction: fraction) - .frame(width: proxy.size.width, height: self.height) - } - .frame(height: self.height) - } - } - .accessibilityLabel("Context usage") - .accessibilityValue(self.accessibilityValue) - } - - private var accessibilityValue: String { - if self.contextTokens <= 0 { return "Unknown context window" } - let pct = Int(round(self.clampedFractionUsed * 100)) - return "\(pct) percent used" - } - - @ViewBuilder - private func barBody(width: CGFloat, fraction: Double) -> some View { - let radius = self.height / 2 - let trackFill = Color(nsColor: Self.trackFill) - let trackStroke = Color(nsColor: Self.trackStroke) - let fillWidth = max(1, floor(width * CGFloat(fraction))) - - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: radius, style: .continuous) - .fill(trackFill) - .overlay { - RoundedRectangle(cornerRadius: radius, style: .continuous) - .strokeBorder(trackStroke, lineWidth: 0.75) - } - - RoundedRectangle(cornerRadius: radius, style: .continuous) - .fill(self.tint) - .frame(width: fillWidth) - .mask { - RoundedRectangle(cornerRadius: radius, style: .continuous) - } - } - } -} diff --git a/apps/macos/Sources/OpenClaw/ControlChannel.swift b/apps/macos/Sources/OpenClaw/ControlChannel.swift deleted file mode 100644 index 16b4d6d3ad4..00000000000 --- a/apps/macos/Sources/OpenClaw/ControlChannel.swift +++ /dev/null @@ -1,428 +0,0 @@ -import Foundation -import Observation -import OpenClawKit -import OpenClawProtocol -import SwiftUI - -struct ControlHeartbeatEvent: Codable { - let ts: Double - let status: String - let to: String? - let preview: String? - let durationMs: Double? - let hasMedia: Bool? - let reason: String? -} - -struct ControlAgentEvent: Codable, Sendable, Identifiable { - var id: String { - "\(self.runId)-\(self.seq)" - } - - let runId: String - let seq: Int - let stream: String - let ts: Double - let data: [String: OpenClawProtocol.AnyCodable] - let summary: String? -} - -enum ControlChannelError: Error, LocalizedError { - case disconnected - case badResponse(String) - - var errorDescription: String? { - switch self { - case .disconnected: "Control channel disconnected" - case let .badResponse(msg): msg - } - } -} - -@MainActor -@Observable -final class ControlChannel { - static let shared = ControlChannel() - - enum Mode { - case local - case remote(target: String, identity: String) - } - - enum ConnectionState: Equatable { - case disconnected - case connecting - case connected - case degraded(String) - } - - private(set) var state: ConnectionState = .disconnected { - didSet { - CanvasManager.shared.refreshDebugStatus() - guard oldValue != self.state else { return } - switch self.state { - case .connected: - self.logger.info("control channel state -> connected") - case .connecting: - self.logger.info("control channel state -> connecting") - case .disconnected: - self.logger.info("control channel state -> disconnected") - self.scheduleRecovery(reason: "disconnected") - case let .degraded(message): - let detail = message.isEmpty ? "degraded" : "degraded: \(message)" - self.logger.info("control channel state -> \(detail, privacy: .public)") - self.scheduleRecovery(reason: message) - } - } - } - - private(set) var lastPingMs: Double? - private(set) var authSourceLabel: String? - - private let logger = Logger(subsystem: "ai.openclaw", category: "control") - - private var eventTask: Task? - private var recoveryTask: Task? - private var lastRecoveryAt: Date? - - private init() { - self.startEventStream() - } - - func configure() async { - self.logger.info("control channel configure mode=local") - await self.refreshEndpoint(reason: "configure") - } - - func configure(mode: Mode = .local) async throws { - switch mode { - case .local: - await self.configure() - case let .remote(target, identity): - do { - _ = (target, identity) - let idSet = !identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - self.logger.info( - "control channel configure mode=remote " + - "target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)") - self.state = .connecting - _ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel() - await self.refreshEndpoint(reason: "configure") - } catch { - self.state = .degraded(error.localizedDescription) - throw error - } - } - } - - func refreshEndpoint(reason: String) async { - self.logger.info("control channel refresh endpoint reason=\(reason, privacy: .public)") - self.state = .connecting - do { - try await self.establishGatewayConnection() - self.state = .connected - PresenceReporter.shared.sendImmediate(reason: "connect") - } catch { - let message = self.friendlyGatewayMessage(error) - self.state = .degraded(message) - } - } - - func disconnect() async { - await GatewayConnection.shared.shutdown() - self.state = .disconnected - self.lastPingMs = nil - self.authSourceLabel = nil - } - - func health(timeout: TimeInterval? = nil) async throws -> Data { - do { - let start = Date() - var params: [String: AnyHashable]? - if let timeout { - params = ["timeout": AnyHashable(Int(timeout * 1000))] - } - let timeoutMs = (timeout ?? 15) * 1000 - let payload = try await self.request(method: "health", params: params, timeoutMs: timeoutMs) - let ms = Date().timeIntervalSince(start) * 1000 - self.lastPingMs = ms - self.state = .connected - return payload - } catch { - let message = self.friendlyGatewayMessage(error) - self.state = .degraded(message) - throw ControlChannelError.badResponse(message) - } - } - - func lastHeartbeat() async throws -> ControlHeartbeatEvent? { - let data = try await self.request(method: "last-heartbeat") - return try JSONDecoder().decode(ControlHeartbeatEvent?.self, from: data) - } - - func request( - method: String, - params: [String: AnyHashable]? = nil, - timeoutMs: Double? = nil) async throws -> Data - { - do { - let rawParams = params?.reduce(into: [String: OpenClawKit.AnyCodable]()) { - $0[$1.key] = OpenClawKit.AnyCodable($1.value.base) - } - let data = try await GatewayConnection.shared.request( - method: method, - params: rawParams, - timeoutMs: timeoutMs) - self.state = .connected - return data - } catch { - let message = self.friendlyGatewayMessage(error) - self.state = .degraded(message) - throw ControlChannelError.badResponse(message) - } - } - - private func friendlyGatewayMessage(_ error: Error) -> String { - // Map URLSession/WS errors into user-facing, actionable text. - if let ctrlErr = error as? ControlChannelError, let desc = ctrlErr.errorDescription { - return desc - } - - // If the gateway explicitly rejects the hello (e.g., auth/token mismatch), surface it. - if let urlErr = error as? URLError, - urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures - { - let reason = urlErr.failureURLString ?? urlErr.localizedDescription - let tokenKey = CommandResolver.connectionModeIsRemote() - ? "gateway.remote.token" - : "gateway.auth.token" - return - "Gateway rejected token; set \(tokenKey) or clear it on the gateway. Reason: \(reason)" - } - - // Common misfire: we connected to the configured localhost port but it is occupied - // by some other process (e.g. a local dev gateway or a stuck SSH forward). - // The gateway handshake returns something we can't parse, which currently - // surfaces as "hello failed (unexpected response)". Give the user a pointer - // to free the port instead of a vague message. - let nsError = error as NSError - if nsError.domain == "Gateway", - nsError.localizedDescription.contains("hello failed (unexpected response)") - { - let port = GatewayEnvironment.gatewayPort() - return """ - Gateway handshake got non-gateway data on localhost:\(port). - Another process is using that port or the SSH forward failed. - Stop the local gateway/port-forward on \(port) and retry Remote mode. - """ - } - - if let urlError = error as? URLError { - let port = GatewayEnvironment.gatewayPort() - switch urlError.code { - case .cancelled: - return "Gateway connection was closed; start the gateway (localhost:\(port)) and retry." - case .cannotFindHost, .cannotConnectToHost: - let isRemote = CommandResolver.connectionModeIsRemote() - if isRemote { - return """ - Cannot reach gateway at localhost:\(port). - Remote mode uses an SSH tunnel—check the SSH target and that the tunnel is running. - """ - } - return "Cannot reach gateway at localhost:\(port); ensure the gateway is running." - case .networkConnectionLost: - return "Gateway connection dropped; gateway likely restarted—retry." - case .timedOut: - return "Gateway request timed out; check gateway on localhost:\(port)." - case .notConnectedToInternet: - return "No network connectivity; cannot reach gateway." - default: - break - } - } - - if nsError.domain == "Gateway", nsError.code == 5 { - let port = GatewayEnvironment.gatewayPort() - return "Gateway request timed out; check the gateway process on localhost:\(port)." - } - - let detail = nsError.localizedDescription.isEmpty ? "unknown gateway error" : nsError.localizedDescription - let trimmed = detail.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.lowercased().hasPrefix("gateway error:") { return trimmed } - return "Gateway error: \(trimmed)" - } - - private func scheduleRecovery(reason: String) { - let now = Date() - if let last = self.lastRecoveryAt, now.timeIntervalSince(last) < 10 { return } - guard self.recoveryTask == nil else { return } - self.lastRecoveryAt = now - - self.recoveryTask = Task { [weak self] in - guard let self else { return } - let mode = await MainActor.run { AppStateStore.shared.connectionMode } - guard mode != .unconfigured else { - self.recoveryTask = nil - return - } - - let trimmedReason = reason.trimmingCharacters(in: .whitespacesAndNewlines) - let reasonText = trimmedReason.isEmpty ? "unknown" : trimmedReason - self.logger.info( - "control channel recovery starting " + - "mode=\(String(describing: mode), privacy: .public) " + - "reason=\(reasonText, privacy: .public)") - if mode == .local { - GatewayProcessManager.shared.setActive(true) - } - if mode == .remote { - do { - let port = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel() - self.logger.info("control channel recovery ensured SSH tunnel port=\(port, privacy: .public)") - } catch { - self.logger.error( - "control channel recovery tunnel failed \(error.localizedDescription, privacy: .public)") - } - } - - await self.refreshEndpoint(reason: "recovery:\(reasonText)") - if case .connected = self.state { - self.logger.info("control channel recovery finished") - } else if case let .degraded(message) = self.state { - self.logger.error("control channel recovery failed \(message, privacy: .public)") - } - - self.recoveryTask = nil - } - } - - private func establishGatewayConnection(timeoutMs: Int = 5000) async throws { - try await GatewayConnection.shared.refresh() - let ok = try await GatewayConnection.shared.healthOK(timeoutMs: timeoutMs) - if ok == false { - throw NSError( - domain: "Gateway", - code: 0, - userInfo: [NSLocalizedDescriptionKey: "gateway health not ok"]) - } - await self.refreshAuthSourceLabel() - } - - private func refreshAuthSourceLabel() async { - let isRemote = CommandResolver.connectionModeIsRemote() - let authSource = await GatewayConnection.shared.authSource() - self.authSourceLabel = Self.formatAuthSource(authSource, isRemote: isRemote) - } - - private static func formatAuthSource(_ source: GatewayAuthSource?, isRemote: Bool) -> String? { - guard let source else { return nil } - switch source { - case .deviceToken: - return "Auth: device token (paired device)" - case .sharedToken: - return "Auth: shared token (\(isRemote ? "gateway.remote.token" : "gateway.auth.token"))" - case .password: - return "Auth: password (\(isRemote ? "gateway.remote.password" : "gateway.auth.password"))" - case .none: - return "Auth: none" - } - } - - func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws { - var merged = params - merged["text"] = AnyHashable(text) - _ = try await self.request(method: "system-event", params: merged) - } - - private func startEventStream() { - self.eventTask?.cancel() - self.eventTask = Task { [weak self] in - guard let self else { return } - let stream = await GatewayConnection.shared.subscribe() - for await push in stream { - if Task.isCancelled { return } - await MainActor.run { [weak self] in - self?.handle(push: push) - } - } - } - } - - private func handle(push: GatewayPush) { - switch push { - case let .event(evt) where evt.event == "agent": - if let payload = evt.payload, - let agent = try? GatewayPayloadDecoding.decode(payload, as: ControlAgentEvent.self) - { - AgentEventStore.shared.append(agent) - self.routeWorkActivity(from: agent) - } - case let .event(evt) where evt.event == "heartbeat": - if let payload = evt.payload, - let heartbeat = try? GatewayPayloadDecoding.decode(payload, as: ControlHeartbeatEvent.self), - let data = try? JSONEncoder().encode(heartbeat) - { - NotificationCenter.default.post(name: .controlHeartbeat, object: data) - } - case let .event(evt) where evt.event == "shutdown": - self.state = .degraded("gateway shutdown") - case .snapshot: - self.state = .connected - default: - break - } - } - - private func routeWorkActivity(from event: ControlAgentEvent) { - // We currently treat VoiceWake as the "main" session for UI purposes. - // In the future, the gateway can include a sessionKey to distinguish runs. - let sessionKey = (event.data["sessionKey"]?.value as? String) ?? "main" - - switch event.stream.lowercased() { - case "job": - if let state = event.data["state"]?.value as? String { - WorkActivityStore.shared.handleJob(sessionKey: sessionKey, state: state) - } - case "tool": - let phase = event.data["phase"]?.value as? String ?? "" - let name = event.data["name"]?.value as? String - let meta = event.data["meta"]?.value as? String - let args = Self.bridgeToProtocolArgs(event.data["args"]) - WorkActivityStore.shared.handleTool( - sessionKey: sessionKey, - phase: phase, - name: name, - meta: meta, - args: args) - default: - break - } - } - - private static func bridgeToProtocolArgs( - _ value: OpenClawProtocol.AnyCodable?) -> [String: OpenClawProtocol.AnyCodable]? - { - guard let value else { return nil } - if let dict = value.value as? [String: OpenClawProtocol.AnyCodable] { - return dict - } - if let dict = value.value as? [String: OpenClawKit.AnyCodable], - let data = try? JSONEncoder().encode(dict), - let decoded = try? JSONDecoder().decode([String: OpenClawProtocol.AnyCodable].self, from: data) - { - return decoded - } - if let data = try? JSONEncoder().encode(value), - let decoded = try? JSONDecoder().decode([String: OpenClawProtocol.AnyCodable].self, from: data) - { - return decoded - } - return nil - } -} - -extension Notification.Name { - static let controlHeartbeat = Notification.Name("openclaw.control.heartbeat") - static let controlAgentEvent = Notification.Name("openclaw.control.agent") -} diff --git a/apps/macos/Sources/OpenClaw/CostUsageMenuView.swift b/apps/macos/Sources/OpenClaw/CostUsageMenuView.swift deleted file mode 100644 index c94a4de3518..00000000000 --- a/apps/macos/Sources/OpenClaw/CostUsageMenuView.swift +++ /dev/null @@ -1,99 +0,0 @@ -import Charts -import SwiftUI - -struct CostUsageHistoryMenuView: View { - let summary: GatewayCostUsageSummary - let width: CGFloat - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - self.header - self.chart - self.footer - } - .padding(.horizontal, 12) - .padding(.vertical, 10) - .frame(width: max(1, self.width), alignment: .leading) - } - - private var header: some View { - let todayKey = CostUsageMenuDateParser.format(Date()) - let todayEntry = self.summary.daily.first { $0.date == todayKey } - let todayCost = CostUsageFormatting.formatUsd(todayEntry?.totalCost) ?? "n/a" - let totalCost = CostUsageFormatting.formatUsd(self.summary.totals.totalCost) ?? "n/a" - - return HStack(alignment: .firstTextBaseline, spacing: 12) { - VStack(alignment: .leading, spacing: 2) { - Text("Today") - .font(.caption2) - .foregroundStyle(.secondary) - Text(todayCost) - .font(.system(size: 14, weight: .semibold)) - } - VStack(alignment: .leading, spacing: 2) { - Text("Last \(self.summary.days)d") - .font(.caption2) - .foregroundStyle(.secondary) - Text(totalCost) - .font(.system(size: 14, weight: .semibold)) - } - Spacer() - } - } - - private var chart: some View { - let entries = self.summary.daily.compactMap { entry -> (Date, Double)? in - guard let date = CostUsageMenuDateParser.parse(entry.date) else { return nil } - return (date, entry.totalCost) - } - - return Chart(entries, id: \.0) { entry in - BarMark( - x: .value("Day", entry.0), - y: .value("Cost", entry.1)) - .foregroundStyle(Color.accentColor) - .cornerRadius(3) - } - .chartXAxis { - AxisMarks(values: .stride(by: .day, count: 7)) { - AxisGridLine().foregroundStyle(.clear) - AxisValueLabel(format: .dateTime.month().day()) - } - } - .chartYAxis { - AxisMarks(position: .leading) { - AxisGridLine() - AxisValueLabel() - } - } - .frame(height: 110) - } - - private var footer: some View { - if self.summary.totals.missingCostEntries == 0 { - return AnyView(EmptyView()) - } - return AnyView( - Text("Partial: \(self.summary.totals.missingCostEntries) entries missing cost") - .font(.caption2) - .foregroundStyle(.secondary)) - } -} - -private enum CostUsageMenuDateParser { - static let formatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd" - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = TimeZone.current - return formatter - }() - - static func parse(_ value: String) -> Date? { - self.formatter.date(from: value) - } - - static func format(_ date: Date) -> String { - self.formatter.string(from: date) - } -} diff --git a/apps/macos/Sources/OpenClaw/CritterIconRenderer.swift b/apps/macos/Sources/OpenClaw/CritterIconRenderer.swift deleted file mode 100644 index 0309461965c..00000000000 --- a/apps/macos/Sources/OpenClaw/CritterIconRenderer.swift +++ /dev/null @@ -1,387 +0,0 @@ -import AppKit - -enum CritterIconRenderer { - private static let size = NSSize(width: 18, height: 18) - - struct Badge { - let symbolName: String - let prominence: IconState.BadgeProminence - } - - private struct Canvas { - let w: CGFloat - let h: CGFloat - let stepX: CGFloat - let stepY: CGFloat - let snapX: (CGFloat) -> CGFloat - let snapY: (CGFloat) -> CGFloat - let context: CGContext - } - - private struct Geometry { - let bodyRect: CGRect - let bodyCorner: CGFloat - let leftEarRect: CGRect - let rightEarRect: CGRect - let earCorner: CGFloat - let earW: CGFloat - let earH: CGFloat - let legW: CGFloat - let legH: CGFloat - let legSpacing: CGFloat - let legStartX: CGFloat - let legYBase: CGFloat - let legLift: CGFloat - let legHeightScale: CGFloat - let eyeW: CGFloat - let eyeY: CGFloat - let eyeOffset: CGFloat - - init(canvas: Canvas, legWiggle: CGFloat, earWiggle: CGFloat, earScale: CGFloat) { - let w = canvas.w - let h = canvas.h - let snapX = canvas.snapX - let snapY = canvas.snapY - - let bodyW = snapX(w * 0.78) - let bodyH = snapY(h * 0.58) - let bodyX = snapX((w - bodyW) / 2) - let bodyY = snapY(h * 0.36) - let bodyCorner = snapX(w * 0.09) - - let earW = snapX(w * 0.22) - let earH = snapY(bodyH * 0.54 * earScale * (1 - 0.08 * abs(earWiggle))) - let earCorner = snapX(earW * 0.24) - let leftEarRect = CGRect( - x: snapX(bodyX - earW * 0.55 + earWiggle), - y: snapY(bodyY + bodyH * 0.08 + earWiggle * 0.4), - width: earW, - height: earH) - let rightEarRect = CGRect( - x: snapX(bodyX + bodyW - earW * 0.45 - earWiggle), - y: snapY(bodyY + bodyH * 0.08 - earWiggle * 0.4), - width: earW, - height: earH) - - let legW = snapX(w * 0.11) - let legH = snapY(h * 0.26) - let legSpacing = snapX(w * 0.085) - let legsWidth = snapX(4 * legW + 3 * legSpacing) - let legStartX = snapX((w - legsWidth) / 2) - let legLift = snapY(legH * 0.35 * legWiggle) - let legYBase = snapY(bodyY - legH + h * 0.05) - let legHeightScale = 1 - 0.12 * legWiggle - - let eyeW = snapX(bodyW * 0.2) - let eyeY = snapY(bodyY + bodyH * 0.56) - let eyeOffset = snapX(bodyW * 0.24) - - self.bodyRect = CGRect(x: bodyX, y: bodyY, width: bodyW, height: bodyH) - self.bodyCorner = bodyCorner - self.leftEarRect = leftEarRect - self.rightEarRect = rightEarRect - self.earCorner = earCorner - self.earW = earW - self.earH = earH - self.legW = legW - self.legH = legH - self.legSpacing = legSpacing - self.legStartX = legStartX - self.legYBase = legYBase - self.legLift = legLift - self.legHeightScale = legHeightScale - self.eyeW = eyeW - self.eyeY = eyeY - self.eyeOffset = eyeOffset - } - } - - private struct FaceOptions { - let blink: CGFloat - let earHoles: Bool - let earScale: CGFloat - let eyesClosedLines: Bool - } - - static func makeIcon( - blink: CGFloat, - legWiggle: CGFloat = 0, - earWiggle: CGFloat = 0, - earScale: CGFloat = 1, - earHoles: Bool = false, - eyesClosedLines: Bool = false, - badge: Badge? = nil) -> NSImage - { - guard let rep = self.makeBitmapRep() else { - return NSImage(size: self.size) - } - rep.size = self.size - - NSGraphicsContext.saveGraphicsState() - defer { NSGraphicsContext.restoreGraphicsState() } - - guard let context = NSGraphicsContext(bitmapImageRep: rep) else { - return NSImage(size: self.size) - } - NSGraphicsContext.current = context - context.imageInterpolation = .none - context.cgContext.setShouldAntialias(false) - - let canvas = self.makeCanvas(for: rep, context: context) - let geometry = Geometry(canvas: canvas, legWiggle: legWiggle, earWiggle: earWiggle, earScale: earScale) - - self.drawBody(in: canvas, geometry: geometry) - let face = FaceOptions( - blink: blink, - earHoles: earHoles, - earScale: earScale, - eyesClosedLines: eyesClosedLines) - self.drawFace(in: canvas, geometry: geometry, options: face) - - if let badge { - self.drawBadge(badge, canvas: canvas) - } - - let image = NSImage(size: size) - image.addRepresentation(rep) - image.isTemplate = true - return image - } - - private static func makeBitmapRep() -> NSBitmapImageRep? { - // Force a 36×36px backing store (2× for the 18pt logical canvas) so the menu bar icon stays crisp on Retina. - let pixelsWide = 36 - let pixelsHigh = 36 - return NSBitmapImageRep( - bitmapDataPlanes: nil, - pixelsWide: pixelsWide, - pixelsHigh: pixelsHigh, - bitsPerSample: 8, - samplesPerPixel: 4, - hasAlpha: true, - isPlanar: false, - colorSpaceName: .deviceRGB, - bitmapFormat: [], - bytesPerRow: 0, - bitsPerPixel: 0) - } - - private static func makeCanvas(for rep: NSBitmapImageRep, context: NSGraphicsContext) -> Canvas { - let stepX = self.size.width / max(CGFloat(rep.pixelsWide), 1) - let stepY = self.size.height / max(CGFloat(rep.pixelsHigh), 1) - let snapX: (CGFloat) -> CGFloat = { ($0 / stepX).rounded() * stepX } - let snapY: (CGFloat) -> CGFloat = { ($0 / stepY).rounded() * stepY } - - let w = snapX(size.width) - let h = snapY(size.height) - - return Canvas( - w: w, - h: h, - stepX: stepX, - stepY: stepY, - snapX: snapX, - snapY: snapY, - context: context.cgContext) - } - - private static func drawBody(in canvas: Canvas, geometry: Geometry) { - canvas.context.setFillColor(NSColor.labelColor.cgColor) - - canvas.context.addPath(CGPath( - roundedRect: geometry.bodyRect, - cornerWidth: geometry.bodyCorner, - cornerHeight: geometry.bodyCorner, - transform: nil)) - canvas.context.addPath(CGPath( - roundedRect: geometry.leftEarRect, - cornerWidth: geometry.earCorner, - cornerHeight: geometry.earCorner, - transform: nil)) - canvas.context.addPath(CGPath( - roundedRect: geometry.rightEarRect, - cornerWidth: geometry.earCorner, - cornerHeight: geometry.earCorner, - transform: nil)) - - for i in 0..<4 { - let x = geometry.legStartX + CGFloat(i) * (geometry.legW + geometry.legSpacing) - let lift = i % 2 == 0 ? geometry.legLift : -geometry.legLift - let rect = CGRect( - x: x, - y: geometry.legYBase + lift, - width: geometry.legW, - height: geometry.legH * geometry.legHeightScale) - canvas.context.addPath(CGPath( - roundedRect: rect, - cornerWidth: geometry.legW * 0.34, - cornerHeight: geometry.legW * 0.34, - transform: nil)) - } - canvas.context.fillPath() - } - - private static func drawFace( - in canvas: Canvas, - geometry: Geometry, - options: FaceOptions) - { - canvas.context.saveGState() - canvas.context.setBlendMode(.clear) - - let leftCenter = CGPoint( - x: canvas.snapX(canvas.w / 2 - geometry.eyeOffset), - y: canvas.snapY(geometry.eyeY)) - let rightCenter = CGPoint( - x: canvas.snapX(canvas.w / 2 + geometry.eyeOffset), - y: canvas.snapY(geometry.eyeY)) - - if options.earHoles || options.earScale > 1.05 { - let holeW = canvas.snapX(geometry.earW * 0.6) - let holeH = canvas.snapY(geometry.earH * 0.46) - let holeCorner = canvas.snapX(holeW * 0.34) - let leftHoleRect = CGRect( - x: canvas.snapX(geometry.leftEarRect.midX - holeW / 2), - y: canvas.snapY(geometry.leftEarRect.midY - holeH / 2 + geometry.earH * 0.04), - width: holeW, - height: holeH) - let rightHoleRect = CGRect( - x: canvas.snapX(geometry.rightEarRect.midX - holeW / 2), - y: canvas.snapY(geometry.rightEarRect.midY - holeH / 2 + geometry.earH * 0.04), - width: holeW, - height: holeH) - - canvas.context.addPath(CGPath( - roundedRect: leftHoleRect, - cornerWidth: holeCorner, - cornerHeight: holeCorner, - transform: nil)) - canvas.context.addPath(CGPath( - roundedRect: rightHoleRect, - cornerWidth: holeCorner, - cornerHeight: holeCorner, - transform: nil)) - } - - if options.eyesClosedLines { - let lineW = canvas.snapX(geometry.eyeW * 0.95) - let lineH = canvas.snapY(max(canvas.stepY * 2, geometry.bodyRect.height * 0.06)) - let corner = canvas.snapX(lineH * 0.6) - let leftRect = CGRect( - x: canvas.snapX(leftCenter.x - lineW / 2), - y: canvas.snapY(leftCenter.y - lineH / 2), - width: lineW, - height: lineH) - let rightRect = CGRect( - x: canvas.snapX(rightCenter.x - lineW / 2), - y: canvas.snapY(rightCenter.y - lineH / 2), - width: lineW, - height: lineH) - canvas.context.addPath(CGPath( - roundedRect: leftRect, - cornerWidth: corner, - cornerHeight: corner, - transform: nil)) - canvas.context.addPath(CGPath( - roundedRect: rightRect, - cornerWidth: corner, - cornerHeight: corner, - transform: nil)) - } else { - let eyeOpen = max(0.05, 1 - options.blink) - let eyeH = canvas.snapY(geometry.bodyRect.height * 0.26 * eyeOpen) - - let left = CGMutablePath() - left.move(to: CGPoint( - x: canvas.snapX(leftCenter.x - geometry.eyeW / 2), - y: canvas.snapY(leftCenter.y - eyeH))) - left.addLine(to: CGPoint( - x: canvas.snapX(leftCenter.x + geometry.eyeW / 2), - y: canvas.snapY(leftCenter.y))) - left.addLine(to: CGPoint( - x: canvas.snapX(leftCenter.x - geometry.eyeW / 2), - y: canvas.snapY(leftCenter.y + eyeH))) - left.closeSubpath() - - let right = CGMutablePath() - right.move(to: CGPoint( - x: canvas.snapX(rightCenter.x + geometry.eyeW / 2), - y: canvas.snapY(rightCenter.y - eyeH))) - right.addLine(to: CGPoint( - x: canvas.snapX(rightCenter.x - geometry.eyeW / 2), - y: canvas.snapY(rightCenter.y))) - right.addLine(to: CGPoint( - x: canvas.snapX(rightCenter.x + geometry.eyeW / 2), - y: canvas.snapY(rightCenter.y + eyeH))) - right.closeSubpath() - - canvas.context.addPath(left) - canvas.context.addPath(right) - } - - canvas.context.fillPath() - canvas.context.restoreGState() - } - - private static func drawBadge(_ badge: Badge, canvas: Canvas) { - let strength: CGFloat = switch badge.prominence { - case .primary: 1.0 - case .secondary: 0.58 - case .overridden: 0.85 - } - - // Bigger, higher-contrast badge: - // - Increase diameter so tool activity is noticeable. - // - Draw a filled "puck", then knock out the symbol shape (transparent hole). - // This reads better in template-rendered menu bar icons than tiny monochrome glyphs. - let diameter = canvas.snapX(canvas.w * 0.52 * (0.92 + 0.08 * strength)) // ~9–10pt on an 18pt canvas - let margin = canvas.snapX(max(0.45, canvas.w * 0.03)) - let rect = CGRect( - x: canvas.snapX(canvas.w - diameter - margin), - y: canvas.snapY(margin), - width: diameter, - height: diameter) - - canvas.context.saveGState() - canvas.context.setShouldAntialias(true) - - // Clear the underlying pixels so the badge stays readable over the critter. - canvas.context.saveGState() - canvas.context.setBlendMode(.clear) - canvas.context.addEllipse(in: rect.insetBy(dx: -1.0, dy: -1.0)) - canvas.context.fillPath() - canvas.context.restoreGState() - - let fillAlpha: CGFloat = min(1.0, 0.36 + 0.24 * strength) - let strokeAlpha: CGFloat = min(1.0, 0.78 + 0.22 * strength) - - canvas.context.setFillColor(NSColor.labelColor.withAlphaComponent(fillAlpha).cgColor) - canvas.context.addEllipse(in: rect) - canvas.context.fillPath() - - canvas.context.setStrokeColor(NSColor.labelColor.withAlphaComponent(strokeAlpha).cgColor) - canvas.context.setLineWidth(max(1.25, canvas.snapX(canvas.w * 0.075))) - canvas.context.strokeEllipse(in: rect.insetBy(dx: 0.45, dy: 0.45)) - - if let base = NSImage(systemSymbolName: badge.symbolName, accessibilityDescription: nil) { - let pointSize = max(7.0, diameter * 0.82) - let config = NSImage.SymbolConfiguration(pointSize: pointSize, weight: .black) - let symbol = base.withSymbolConfiguration(config) ?? base - symbol.isTemplate = true - - let symbolRect = rect.insetBy(dx: diameter * 0.17, dy: diameter * 0.17) - canvas.context.saveGState() - canvas.context.setBlendMode(.clear) - symbol.draw( - in: symbolRect, - from: .zero, - operation: .sourceOver, - fraction: 1, - respectFlipped: true, - hints: nil) - canvas.context.restoreGState() - } - - canvas.context.restoreGState() - } -} diff --git a/apps/macos/Sources/OpenClaw/CritterStatusLabel+Behavior.swift b/apps/macos/Sources/OpenClaw/CritterStatusLabel+Behavior.swift deleted file mode 100644 index e1145c4e393..00000000000 --- a/apps/macos/Sources/OpenClaw/CritterStatusLabel+Behavior.swift +++ /dev/null @@ -1,305 +0,0 @@ -import AppKit -import SwiftUI - -extension CritterStatusLabel { - private var isWorkingNow: Bool { - self.iconState.isWorking || self.isWorking - } - - private var effectiveAnimationsEnabled: Bool { - self.animationsEnabled && !self.isSleeping - } - - var body: some View { - ZStack(alignment: .topTrailing) { - self.iconImage - .frame(width: 18, height: 18) - .rotationEffect(.degrees(self.wiggleAngle), anchor: .center) - .offset(x: self.wiggleOffset) - // Avoid Combine's TimerPublisher here: on macOS 26.2 we've seen crashes inside executor checks - // triggered by its callbacks. Drive periodic updates via a Swift-concurrency task instead. - .task(id: self.tickTaskID) { - guard self.effectiveAnimationsEnabled, !self.earBoostActive else { - await MainActor.run { self.resetMotion() } - return - } - - while !Task.isCancelled { - let now = Date() - await MainActor.run { self.tick(now) } - try? await Task.sleep(nanoseconds: 350_000_000) - } - } - .onChange(of: self.isPaused) { _, _ in self.resetMotion() } - .onChange(of: self.blinkTick) { _, _ in - guard self.effectiveAnimationsEnabled, !self.earBoostActive else { return } - self.blink() - } - .onChange(of: self.sendCelebrationTick) { _, _ in - guard self.effectiveAnimationsEnabled, !self.earBoostActive else { return } - self.wiggleLegs() - } - .onChange(of: self.animationsEnabled) { _, enabled in - if enabled, !self.isSleeping { - self.scheduleRandomTimers(from: Date()) - } else { - self.resetMotion() - } - } - .onChange(of: self.isSleeping) { _, _ in - self.resetMotion() - } - .onChange(of: self.earBoostActive) { _, active in - if active { - self.resetMotion() - } else if self.effectiveAnimationsEnabled { - self.scheduleRandomTimers(from: Date()) - } - } - - if self.gatewayNeedsAttention { - Circle() - .fill(self.gatewayBadgeColor) - .frame(width: 6, height: 6) - .padding(1) - } - } - .frame(width: 18, height: 18) - } - - private var tickTaskID: Int { - // Ensure SwiftUI restarts (and cancels) the task when these change. - (self.effectiveAnimationsEnabled ? 1 : 0) | (self.earBoostActive ? 2 : 0) - } - - private func tick(_ now: Date) { - guard self.effectiveAnimationsEnabled, !self.earBoostActive else { - self.resetMotion() - return - } - - if now >= self.nextBlink { - self.blink() - self.nextBlink = now.addingTimeInterval(Double.random(in: 3.5...8.5)) - } - - if now >= self.nextWiggle { - self.wiggle() - self.nextWiggle = now.addingTimeInterval(Double.random(in: 6.5...14)) - } - - if now >= self.nextLegWiggle { - self.wiggleLegs() - self.nextLegWiggle = now.addingTimeInterval(Double.random(in: 5.0...11.0)) - } - - if now >= self.nextEarWiggle { - self.wiggleEars() - self.nextEarWiggle = now.addingTimeInterval(Double.random(in: 7.0...14.0)) - } - - if self.isWorkingNow { - self.scurry() - } - } - - private var iconImage: Image { - let badge: CritterIconRenderer.Badge? = if let prominence = self.iconState.badgeProminence, !self.isPaused { - CritterIconRenderer.Badge( - symbolName: self.iconState.badgeSymbolName, - prominence: prominence) - } else { - nil - } - - if self.isPaused { - return Image(nsImage: CritterIconRenderer.makeIcon(blink: 0, badge: nil)) - } - - if self.isSleeping { - return Image(nsImage: CritterIconRenderer.makeIcon(blink: 1, eyesClosedLines: true, badge: nil)) - } - - return Image(nsImage: CritterIconRenderer.makeIcon( - blink: self.blinkAmount, - legWiggle: max(self.legWiggle, self.isWorkingNow ? 0.6 : 0), - earWiggle: self.earWiggle, - earScale: self.earBoostActive ? 1.9 : 1.0, - earHoles: self.earBoostActive, - badge: badge)) - } - - private func resetMotion() { - self.blinkAmount = 0 - self.wiggleAngle = 0 - self.wiggleOffset = 0 - self.legWiggle = 0 - self.earWiggle = 0 - } - - private func blink() { - withAnimation(.easeInOut(duration: 0.08)) { self.blinkAmount = 1 } - Task { @MainActor in - try? await Task.sleep(nanoseconds: 160_000_000) - withAnimation(.easeOut(duration: 0.12)) { self.blinkAmount = 0 } - } - } - - private func wiggle() { - let targetAngle = Double.random(in: -4.5...4.5) - let targetOffset = CGFloat.random(in: -0.5...0.5) - withAnimation(.interpolatingSpring(stiffness: 220, damping: 18)) { - self.wiggleAngle = targetAngle - self.wiggleOffset = targetOffset - } - Task { @MainActor in - try? await Task.sleep(nanoseconds: 360_000_000) - withAnimation(.interpolatingSpring(stiffness: 220, damping: 18)) { - self.wiggleAngle = 0 - self.wiggleOffset = 0 - } - } - } - - private func wiggleLegs() { - let target = CGFloat.random(in: 0.35...0.9) - withAnimation(.easeInOut(duration: 0.14)) { - self.legWiggle = target - } - Task { @MainActor in - try? await Task.sleep(nanoseconds: 220_000_000) - withAnimation(.easeOut(duration: 0.18)) { self.legWiggle = 0 } - } - } - - private func scurry() { - let target = CGFloat.random(in: 0.7...1.0) - withAnimation(.easeInOut(duration: 0.12)) { - self.legWiggle = target - self.wiggleOffset = CGFloat.random(in: -0.6...0.6) - } - Task { @MainActor in - try? await Task.sleep(nanoseconds: 180_000_000) - withAnimation(.easeOut(duration: 0.16)) { - self.legWiggle = 0.25 - self.wiggleOffset = 0 - } - } - } - - private func wiggleEars() { - let target = CGFloat.random(in: -1.2...1.2) - withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) { - self.earWiggle = target - } - Task { @MainActor in - try? await Task.sleep(nanoseconds: 320_000_000) - withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) { - self.earWiggle = 0 - } - } - } - - private func scheduleRandomTimers(from date: Date) { - self.nextBlink = date.addingTimeInterval(Double.random(in: 3.5...8.5)) - self.nextWiggle = date.addingTimeInterval(Double.random(in: 6.5...14)) - self.nextLegWiggle = date.addingTimeInterval(Double.random(in: 5.0...11.0)) - self.nextEarWiggle = date.addingTimeInterval(Double.random(in: 7.0...14.0)) - } - - private var gatewayNeedsAttention: Bool { - if self.isSleeping { return false } - switch self.gatewayStatus { - case .failed, .stopped: - return !self.isPaused - case .starting, .running, .attachedExisting: - return false - } - } - - private var gatewayBadgeColor: Color { - switch self.gatewayStatus { - case .failed: .red - case .stopped: .orange - default: .clear - } - } -} - -#if DEBUG -@MainActor -extension CritterStatusLabel { - static func exerciseForTesting() async { - var label = CritterStatusLabel( - isPaused: false, - isSleeping: false, - isWorking: true, - earBoostActive: false, - blinkTick: 1, - sendCelebrationTick: 1, - gatewayStatus: .running(details: nil), - animationsEnabled: true, - iconState: .workingMain(.tool(.bash))) - - _ = label.body - _ = label.iconImage - _ = label.tickTaskID - label.tick(Date()) - label.resetMotion() - label.blink() - label.wiggle() - label.wiggleLegs() - label.wiggleEars() - label.scurry() - label.scheduleRandomTimers(from: Date()) - _ = label.gatewayNeedsAttention - _ = label.gatewayBadgeColor - - label.isPaused = true - _ = label.iconImage - - label.isPaused = false - label.isSleeping = true - _ = label.iconImage - - label.isSleeping = false - label.iconState = .idle - _ = label.iconImage - - let failed = CritterStatusLabel( - isPaused: false, - isSleeping: false, - isWorking: false, - earBoostActive: false, - blinkTick: 0, - sendCelebrationTick: 0, - gatewayStatus: .failed("boom"), - animationsEnabled: false, - iconState: .idle) - _ = failed.gatewayNeedsAttention - _ = failed.gatewayBadgeColor - - let stopped = CritterStatusLabel( - isPaused: false, - isSleeping: false, - isWorking: false, - earBoostActive: false, - blinkTick: 0, - sendCelebrationTick: 0, - gatewayStatus: .stopped, - animationsEnabled: false, - iconState: .idle) - _ = stopped.gatewayNeedsAttention - _ = stopped.gatewayBadgeColor - - _ = CritterIconRenderer.makeIcon( - blink: 0.6, - legWiggle: 0.8, - earWiggle: 0.4, - earScale: 1.4, - earHoles: true, - eyesClosedLines: true, - badge: .init(symbolName: "gearshape.fill", prominence: .secondary)) - } -} -#endif diff --git a/apps/macos/Sources/OpenClaw/CritterStatusLabel.swift b/apps/macos/Sources/OpenClaw/CritterStatusLabel.swift deleted file mode 100644 index beeffdf8dd7..00000000000 --- a/apps/macos/Sources/OpenClaw/CritterStatusLabel.swift +++ /dev/null @@ -1,23 +0,0 @@ -import SwiftUI - -struct CritterStatusLabel: View { - var isPaused: Bool - var isSleeping: Bool - var isWorking: Bool - var earBoostActive: Bool - var blinkTick: Int - var sendCelebrationTick: Int - var gatewayStatus: GatewayProcessManager.Status - var animationsEnabled: Bool - var iconState: IconState - - @State var blinkAmount: CGFloat = 0 - @State var nextBlink = Date().addingTimeInterval(Double.random(in: 3.5...8.5)) - @State var wiggleAngle: Double = 0 - @State var wiggleOffset: CGFloat = 0 - @State var nextWiggle = Date().addingTimeInterval(Double.random(in: 6.5...14)) - @State var legWiggle: CGFloat = 0 - @State var nextLegWiggle = Date().addingTimeInterval(Double.random(in: 5.0...11.0)) - @State var earWiggle: CGFloat = 0 - @State var nextEarWiggle = Date().addingTimeInterval(Double.random(in: 7.0...14.0)) -} diff --git a/apps/macos/Sources/OpenClaw/CronJobEditor+Helpers.swift b/apps/macos/Sources/OpenClaw/CronJobEditor+Helpers.swift deleted file mode 100644 index 6b3fc85a7c0..00000000000 --- a/apps/macos/Sources/OpenClaw/CronJobEditor+Helpers.swift +++ /dev/null @@ -1,271 +0,0 @@ -import Foundation -import OpenClawProtocol -import SwiftUI - -extension CronJobEditor { - func gridLabel(_ text: String) -> some View { - Text(text) - .foregroundStyle(.secondary) - .frame(width: self.labelColumnWidth, alignment: .leading) - } - - func hydrateFromJob() { - guard let job else { return } - self.name = job.name - self.description = job.description ?? "" - self.agentId = job.agentId ?? "" - self.enabled = job.enabled - self.deleteAfterRun = job.deleteAfterRun ?? false - self.sessionTarget = job.sessionTarget - self.wakeMode = job.wakeMode - - switch job.schedule { - case let .at(at): - self.scheduleKind = .at - if let date = CronSchedule.parseAtDate(at) { - self.atDate = date - } - case let .every(everyMs, _): - self.scheduleKind = .every - self.everyText = self.formatDuration(ms: everyMs) - case let .cron(expr, tz): - self.scheduleKind = .cron - self.cronExpr = expr - self.cronTz = tz ?? "" - } - - switch job.payload { - case let .systemEvent(text): - self.payloadKind = .systemEvent - self.systemEventText = text - case let .agentTurn(message, thinking, timeoutSeconds, _, _, _, _): - self.payloadKind = .agentTurn - self.agentMessage = message - self.thinking = thinking ?? "" - self.timeoutSeconds = timeoutSeconds.map(String.init) ?? "" - } - - if let delivery = job.delivery { - self.deliveryMode = delivery.mode == .announce ? .announce : .none - let trimmed = (delivery.channel ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - self.channel = trimmed.isEmpty ? "last" : trimmed - self.to = delivery.to ?? "" - self.bestEffortDeliver = delivery.bestEffort ?? false - } else if self.sessionTarget == .isolated { - self.deliveryMode = .announce - } - } - - func save() { - do { - self.error = nil - let payload = try self.buildPayload() - self.onSave(payload) - } catch { - self.error = error.localizedDescription - } - } - - func buildPayload() throws -> [String: AnyCodable] { - let name = try self.requireName() - let description = self.trimmed(self.description) - let agentId = self.trimmed(self.agentId) - let schedule = try self.buildSchedule() - let payload = try self.buildSelectedPayload() - - try self.validateSessionTarget(payload) - try self.validatePayloadRequiredFields(payload) - - var root: [String: Any] = [ - "name": name, - "enabled": self.enabled, - "schedule": schedule, - "sessionTarget": self.sessionTarget.rawValue, - "wakeMode": self.wakeMode.rawValue, - "payload": payload, - ] - self.applyDeleteAfterRun(to: &root) - if !description.isEmpty { root["description"] = description } - if !agentId.isEmpty { - root["agentId"] = agentId - } else if self.job?.agentId != nil { - root["agentId"] = NSNull() - } - - if self.sessionTarget == .isolated { - root["delivery"] = self.buildDelivery() - } - - return root.mapValues { AnyCodable($0) } - } - - func buildDelivery() -> [String: Any] { - let mode = self.deliveryMode == .announce ? "announce" : "none" - var delivery: [String: Any] = ["mode": mode] - if self.deliveryMode == .announce { - let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines) - delivery["channel"] = trimmed.isEmpty ? "last" : trimmed - let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines) - if !to.isEmpty { delivery["to"] = to } - if self.bestEffortDeliver { - delivery["bestEffort"] = true - } else if self.job?.delivery?.bestEffort == true { - delivery["bestEffort"] = false - } - } - return delivery - } - - func trimmed(_ value: String) -> String { - value.trimmingCharacters(in: .whitespacesAndNewlines) - } - - func requireName() throws -> String { - let name = self.trimmed(self.name) - if name.isEmpty { - throw NSError( - domain: "Cron", - code: 0, - userInfo: [NSLocalizedDescriptionKey: "Name is required."]) - } - return name - } - - func buildSchedule() throws -> [String: Any] { - switch self.scheduleKind { - case .at: - return ["kind": "at", "at": CronSchedule.formatIsoDate(self.atDate)] - case .every: - guard let ms = Self.parseDurationMs(self.everyText) else { - throw NSError( - domain: "Cron", - code: 0, - userInfo: [NSLocalizedDescriptionKey: "Invalid every duration (use 10m, 1h, 1d)."]) - } - return ["kind": "every", "everyMs": ms] - case .cron: - let expr = self.trimmed(self.cronExpr) - if expr.isEmpty { - throw NSError( - domain: "Cron", - code: 0, - userInfo: [NSLocalizedDescriptionKey: "Cron expression is required."]) - } - let tz = self.trimmed(self.cronTz) - if tz.isEmpty { - return ["kind": "cron", "expr": expr] - } - return ["kind": "cron", "expr": expr, "tz": tz] - } - } - - func buildSelectedPayload() throws -> [String: Any] { - if self.sessionTarget == .isolated { return self.buildAgentTurnPayload() } - switch self.payloadKind { - case .systemEvent: - let text = self.trimmed(self.systemEventText) - return ["kind": "systemEvent", "text": text] - case .agentTurn: - return self.buildAgentTurnPayload() - } - } - - func validateSessionTarget(_ payload: [String: Any]) throws { - if self.sessionTarget == .main, payload["kind"] as? String == "agentTurn" { - throw NSError( - domain: "Cron", - code: 0, - userInfo: [ - NSLocalizedDescriptionKey: - "Main session jobs require systemEvent payloads (switch Session target to isolated).", - ]) - } - - if self.sessionTarget == .isolated, payload["kind"] as? String == "systemEvent" { - throw NSError( - domain: "Cron", - code: 0, - userInfo: [NSLocalizedDescriptionKey: "Isolated jobs require agentTurn payloads."]) - } - } - - func validatePayloadRequiredFields(_ payload: [String: Any]) throws { - if payload["kind"] as? String == "systemEvent" { - if (payload["text"] as? String ?? "").isEmpty { - throw NSError( - domain: "Cron", - code: 0, - userInfo: [NSLocalizedDescriptionKey: "System event text is required."]) - } - } - if payload["kind"] as? String == "agentTurn" { - if (payload["message"] as? String ?? "").isEmpty { - throw NSError( - domain: "Cron", - code: 0, - userInfo: [NSLocalizedDescriptionKey: "Agent message is required."]) - } - } - } - - func applyDeleteAfterRun( - to root: inout [String: Any], - scheduleKind: ScheduleKind? = nil, - deleteAfterRun: Bool? = nil) - { - let resolvedSchedule = scheduleKind ?? self.scheduleKind - let resolvedDelete = deleteAfterRun ?? self.deleteAfterRun - if resolvedSchedule == .at { - root["deleteAfterRun"] = resolvedDelete - } else if self.job?.deleteAfterRun != nil { - root["deleteAfterRun"] = false - } - } - - func buildAgentTurnPayload() -> [String: Any] { - let msg = self.agentMessage.trimmingCharacters(in: .whitespacesAndNewlines) - var payload: [String: Any] = ["kind": "agentTurn", "message": msg] - let thinking = self.thinking.trimmingCharacters(in: .whitespacesAndNewlines) - if !thinking.isEmpty { payload["thinking"] = thinking } - if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n } - return payload - } - - static func parseDurationMs(_ input: String) -> Int? { - let raw = input.trimmingCharacters(in: .whitespacesAndNewlines) - if raw.isEmpty { return nil } - - let rx = try? NSRegularExpression(pattern: "^(\\d+(?:\\.\\d+)?)(ms|s|m|h|d)$", options: [.caseInsensitive]) - guard let match = rx?.firstMatch(in: raw, range: NSRange(location: 0, length: raw.utf16.count)) else { - return nil - } - func group(_ idx: Int) -> String { - let range = match.range(at: idx) - guard let r = Range(range, in: raw) else { return "" } - return String(raw[r]) - } - let n = Double(group(1)) ?? 0 - if !n.isFinite || n <= 0 { return nil } - let unit = group(2).lowercased() - let factor: Double = switch unit { - case "ms": 1 - case "s": 1000 - case "m": 60000 - case "h": 3_600_000 - default: 86_400_000 - } - return Int(floor(n * factor)) - } - - func formatDuration(ms: Int) -> String { - if ms < 1000 { return "\(ms)ms" } - let s = Double(ms) / 1000.0 - if s < 60 { return "\(Int(round(s)))s" } - let m = s / 60.0 - if m < 60 { return "\(Int(round(m)))m" } - let h = m / 60.0 - if h < 48 { return "\(Int(round(h)))h" } - let d = h / 24.0 - return "\(Int(round(d)))d" - } -} diff --git a/apps/macos/Sources/OpenClaw/CronJobEditor+Testing.swift b/apps/macos/Sources/OpenClaw/CronJobEditor+Testing.swift deleted file mode 100644 index 83b5923e6fd..00000000000 --- a/apps/macos/Sources/OpenClaw/CronJobEditor+Testing.swift +++ /dev/null @@ -1,28 +0,0 @@ -#if DEBUG -extension CronJobEditor { - mutating func exerciseForTesting() { - self.name = "Test job" - self.description = "Test description" - self.agentId = "ops" - self.enabled = true - self.sessionTarget = .isolated - self.wakeMode = .now - - self.scheduleKind = .every - self.everyText = "15m" - - self.payloadKind = .agentTurn - self.agentMessage = "Run diagnostic" - self.deliveryMode = .announce - self.channel = "last" - self.to = "+15551230000" - self.thinking = "low" - self.timeoutSeconds = "90" - self.bestEffortDeliver = true - - _ = self.buildAgentTurnPayload() - _ = try? self.buildPayload() - _ = self.formatDuration(ms: 45000) - } -} -#endif diff --git a/apps/macos/Sources/OpenClaw/CronJobEditor.swift b/apps/macos/Sources/OpenClaw/CronJobEditor.swift deleted file mode 100644 index a7d88a4f2fb..00000000000 --- a/apps/macos/Sources/OpenClaw/CronJobEditor.swift +++ /dev/null @@ -1,362 +0,0 @@ -import Observation -import OpenClawProtocol -import SwiftUI - -struct CronJobEditor: View { - let job: CronJob? - @Binding var isSaving: Bool - @Binding var error: String? - @Bindable var channelsStore: ChannelsStore - let onCancel: () -> Void - let onSave: ([String: AnyCodable]) -> Void - - let labelColumnWidth: CGFloat = 160 - static let introText = - "Create a schedule that wakes OpenClaw via the Gateway. " - + "Use an isolated session for agent turns so your main chat stays clean." - static let sessionTargetNote = - "Main jobs post a system event into the current main session. " - + "Isolated jobs run OpenClaw in a dedicated session and can announce results to a channel." - static let scheduleKindNote = - "“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression." - static let isolatedPayloadNote = - "Isolated jobs always run an agent turn. Announce sends a short summary to a channel." - static let mainPayloadNote = - "System events are injected into the current main session. Agent turns require an isolated session target." - - @State var name: String = "" - @State var description: String = "" - @State var agentId: String = "" - @State var enabled: Bool = true - @State var sessionTarget: CronSessionTarget = .main - @State var wakeMode: CronWakeMode = .now - @State var deleteAfterRun: Bool = false - - enum ScheduleKind: String, CaseIterable, Identifiable { case at, every, cron; var id: String { - rawValue - } } - @State var scheduleKind: ScheduleKind = .every - @State var atDate: Date = .init().addingTimeInterval(60 * 5) - @State var everyText: String = "1h" - @State var cronExpr: String = "0 9 * * 3" - @State var cronTz: String = "" - - enum PayloadKind: String, CaseIterable, Identifiable { case systemEvent, agentTurn; var id: String { - rawValue - } } - @State var payloadKind: PayloadKind = .systemEvent - @State var systemEventText: String = "" - @State var agentMessage: String = "" - enum DeliveryChoice: String, CaseIterable, Identifiable { case announce, none; var id: String { - rawValue - } } - @State var deliveryMode: DeliveryChoice = .announce - @State var channel: String = "last" - @State var to: String = "" - @State var thinking: String = "" - @State var timeoutSeconds: String = "" - @State var bestEffortDeliver: Bool = false - - var channelOptions: [String] { - let ordered = self.channelsStore.orderedChannelIds() - var options = ["last"] + ordered - let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty, !options.contains(trimmed) { - options.append(trimmed) - } - var seen = Set() - return options.filter { seen.insert($0).inserted } - } - - func channelLabel(for id: String) -> String { - if id == "last" { return "last" } - return self.channelsStore.resolveChannelLabel(id) - } - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - VStack(alignment: .leading, spacing: 6) { - Text(self.job == nil ? "New cron job" : "Edit cron job") - .font(.title3.weight(.semibold)) - Text(Self.introText) - .font(.callout) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - ScrollView(.vertical) { - VStack(alignment: .leading, spacing: 14) { - GroupBox("Basics") { - Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { - GridRow { - self.gridLabel("Name") - TextField("Required (e.g. “Daily summary”)", text: self.$name) - .textFieldStyle(.roundedBorder) - .frame(maxWidth: .infinity) - } - GridRow { - self.gridLabel("Description") - TextField("Optional notes", text: self.$description) - .textFieldStyle(.roundedBorder) - .frame(maxWidth: .infinity) - } - GridRow { - self.gridLabel("Agent ID") - TextField("Optional (default agent)", text: self.$agentId) - .textFieldStyle(.roundedBorder) - .frame(maxWidth: .infinity) - } - GridRow { - self.gridLabel("Enabled") - Toggle("", isOn: self.$enabled) - .labelsHidden() - .toggleStyle(.switch) - } - GridRow { - self.gridLabel("Session target") - Picker("", selection: self.$sessionTarget) { - Text("main").tag(CronSessionTarget.main) - Text("isolated").tag(CronSessionTarget.isolated) - } - .labelsHidden() - .pickerStyle(.segmented) - .frame(maxWidth: .infinity, alignment: .leading) - } - GridRow { - self.gridLabel("Wake mode") - Picker("", selection: self.$wakeMode) { - Text("now").tag(CronWakeMode.now) - Text("next-heartbeat").tag(CronWakeMode.nextHeartbeat) - } - .labelsHidden() - .pickerStyle(.segmented) - .frame(maxWidth: .infinity, alignment: .leading) - } - GridRow { - Color.clear - .frame(width: self.labelColumnWidth, height: 1) - Text( - Self.sessionTargetNote) - .font(.footnote) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - } - - GroupBox("Schedule") { - Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { - GridRow { - self.gridLabel("Kind") - Picker("", selection: self.$scheduleKind) { - Text("at").tag(ScheduleKind.at) - Text("every").tag(ScheduleKind.every) - Text("cron").tag(ScheduleKind.cron) - } - .labelsHidden() - .pickerStyle(.segmented) - .frame(maxWidth: .infinity) - } - GridRow { - Color.clear - .frame(width: self.labelColumnWidth, height: 1) - Text( - Self.scheduleKindNote) - .font(.footnote) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - } - - switch self.scheduleKind { - case .at: - GridRow { - self.gridLabel("At") - DatePicker( - "", - selection: self.$atDate, - displayedComponents: [.date, .hourAndMinute]) - .labelsHidden() - .frame(maxWidth: .infinity, alignment: .leading) - } - GridRow { - self.gridLabel("Auto-delete") - Toggle("Delete after successful run", isOn: self.$deleteAfterRun) - .toggleStyle(.switch) - } - case .every: - GridRow { - self.gridLabel("Every") - TextField("10m, 1h, 1d", text: self.$everyText) - .textFieldStyle(.roundedBorder) - .frame(maxWidth: .infinity) - } - case .cron: - GridRow { - self.gridLabel("Expression") - TextField("e.g. 0 9 * * 3", text: self.$cronExpr) - .textFieldStyle(.roundedBorder) - .frame(maxWidth: .infinity) - } - GridRow { - self.gridLabel("Timezone") - TextField("Optional (e.g. America/Los_Angeles)", text: self.$cronTz) - .textFieldStyle(.roundedBorder) - .frame(maxWidth: .infinity) - } - } - } - } - - GroupBox("Payload") { - VStack(alignment: .leading, spacing: 10) { - if self.sessionTarget == .isolated { - Text(Self.isolatedPayloadNote) - .font(.footnote) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - self.agentTurnEditor - } else { - Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { - GridRow { - self.gridLabel("Kind") - Picker("", selection: self.$payloadKind) { - Text("systemEvent").tag(PayloadKind.systemEvent) - Text("agentTurn").tag(PayloadKind.agentTurn) - } - .labelsHidden() - .pickerStyle(.segmented) - .frame(maxWidth: .infinity) - } - GridRow { - Color.clear - .frame(width: self.labelColumnWidth, height: 1) - Text( - Self.mainPayloadNote) - .font(.footnote) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - - switch self.payloadKind { - case .systemEvent: - TextField("System event text", text: self.$systemEventText, axis: .vertical) - .textFieldStyle(.roundedBorder) - .lineLimit(3...7) - .frame(maxWidth: .infinity) - case .agentTurn: - self.agentTurnEditor - } - } - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical, 2) - } - - if let error, !error.isEmpty { - Text(error) - .font(.footnote) - .foregroundStyle(.red) - .fixedSize(horizontal: false, vertical: true) - } - - HStack { - Button("Cancel") { self.onCancel() } - .keyboardShortcut(.cancelAction) - .buttonStyle(.bordered) - Spacer() - Button { - self.save() - } label: { - if self.isSaving { - ProgressView().controlSize(.small) - } else { - Text("Save") - } - } - .keyboardShortcut(.defaultAction) - .buttonStyle(.borderedProminent) - .disabled(self.isSaving) - } - } - .padding(24) - .frame(minWidth: 720, minHeight: 640) - .onAppear { self.hydrateFromJob() } - .onChange(of: self.payloadKind) { _, newValue in - if newValue == .agentTurn, self.sessionTarget == .main { - self.sessionTarget = .isolated - } - } - .onChange(of: self.sessionTarget) { _, newValue in - if newValue == .isolated { - self.payloadKind = .agentTurn - } else if newValue == .main, self.payloadKind == .agentTurn { - self.payloadKind = .systemEvent - } - } - } - - var agentTurnEditor: some View { - VStack(alignment: .leading, spacing: 10) { - Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { - GridRow { - self.gridLabel("Message") - TextField("What should OpenClaw do?", text: self.$agentMessage, axis: .vertical) - .textFieldStyle(.roundedBorder) - .lineLimit(3...7) - .frame(maxWidth: .infinity) - } - GridRow { - self.gridLabel("Thinking") - TextField("Optional (e.g. low)", text: self.$thinking) - .textFieldStyle(.roundedBorder) - .frame(maxWidth: .infinity) - } - GridRow { - self.gridLabel("Timeout") - TextField("Seconds (optional)", text: self.$timeoutSeconds) - .textFieldStyle(.roundedBorder) - .frame(width: 180, alignment: .leading) - } - GridRow { - self.gridLabel("Delivery") - Picker("", selection: self.$deliveryMode) { - Text("Announce summary").tag(DeliveryChoice.announce) - Text("None").tag(DeliveryChoice.none) - } - .labelsHidden() - .pickerStyle(.segmented) - } - } - - if self.deliveryMode == .announce { - Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { - GridRow { - self.gridLabel("Channel") - Picker("", selection: self.$channel) { - ForEach(self.channelOptions, id: \.self) { channel in - Text(self.channelLabel(for: channel)).tag(channel) - } - } - .labelsHidden() - .pickerStyle(.segmented) - .frame(maxWidth: .infinity, alignment: .leading) - } - GridRow { - self.gridLabel("To") - TextField("Optional override (phone number / chat id / Discord channel)", text: self.$to) - .textFieldStyle(.roundedBorder) - .frame(maxWidth: .infinity) - } - GridRow { - self.gridLabel("Best-effort") - Toggle("Do not fail the job if announce fails", isOn: self.$bestEffortDeliver) - .toggleStyle(.switch) - } - } - } - } - } -} diff --git a/apps/macos/Sources/OpenClaw/CronJobsStore.swift b/apps/macos/Sources/OpenClaw/CronJobsStore.swift deleted file mode 100644 index 21c70ded584..00000000000 --- a/apps/macos/Sources/OpenClaw/CronJobsStore.swift +++ /dev/null @@ -1,200 +0,0 @@ -import Foundation -import Observation -import OpenClawKit -import OpenClawProtocol -import OSLog - -@MainActor -@Observable -final class CronJobsStore { - static let shared = CronJobsStore() - - var jobs: [CronJob] = [] - var selectedJobId: String? - var runEntries: [CronRunLogEntry] = [] - - var schedulerEnabled: Bool? - var schedulerStorePath: String? - var schedulerNextWakeAtMs: Int? - - var isLoadingJobs = false - var isLoadingRuns = false - var lastError: String? - var statusMessage: String? - - private let logger = Logger(subsystem: "ai.openclaw", category: "cron.ui") - private var refreshTask: Task? - private var runsTask: Task? - private var eventTask: Task? - private var pollTask: Task? - - private let interval: TimeInterval = 30 - private let isPreview: Bool - - init(isPreview: Bool = ProcessInfo.processInfo.isPreview) { - self.isPreview = isPreview - } - - func start() { - guard !self.isPreview else { return } - guard self.eventTask == nil else { return } - self.startGatewaySubscription() - self.pollTask = Task.detached { [weak self] in - guard let self else { return } - await self.refreshJobs() - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000)) - await self.refreshJobs() - } - } - } - - func stop() { - self.refreshTask?.cancel() - self.refreshTask = nil - self.runsTask?.cancel() - self.runsTask = nil - self.eventTask?.cancel() - self.eventTask = nil - self.pollTask?.cancel() - self.pollTask = nil - } - - func refreshJobs() async { - guard !self.isLoadingJobs else { return } - self.isLoadingJobs = true - self.lastError = nil - self.statusMessage = nil - defer { self.isLoadingJobs = false } - - do { - if let status = try? await GatewayConnection.shared.cronStatus() { - self.schedulerEnabled = status.enabled - self.schedulerStorePath = status.storePath - self.schedulerNextWakeAtMs = status.nextWakeAtMs - } - self.jobs = try await GatewayConnection.shared.cronList(includeDisabled: true) - if self.jobs.isEmpty { - self.statusMessage = "No cron jobs yet." - } - } catch { - self.logger.error("cron.list failed \(error.localizedDescription, privacy: .public)") - self.lastError = error.localizedDescription - } - } - - func refreshRuns(jobId: String, limit: Int = 200) async { - guard !self.isLoadingRuns else { return } - self.isLoadingRuns = true - defer { self.isLoadingRuns = false } - - do { - self.runEntries = try await GatewayConnection.shared.cronRuns(jobId: jobId, limit: limit) - } catch { - self.logger.error("cron.runs failed \(error.localizedDescription, privacy: .public)") - self.lastError = error.localizedDescription - } - } - - func runJob(id: String, force: Bool = true) async { - do { - try await GatewayConnection.shared.cronRun(jobId: id, force: force) - } catch { - self.lastError = error.localizedDescription - } - } - - func removeJob(id: String) async { - do { - try await GatewayConnection.shared.cronRemove(jobId: id) - await self.refreshJobs() - if self.selectedJobId == id { - self.selectedJobId = nil - self.runEntries = [] - } - } catch { - self.lastError = error.localizedDescription - } - } - - func setJobEnabled(id: String, enabled: Bool) async { - do { - try await GatewayConnection.shared.cronUpdate( - jobId: id, - patch: ["enabled": AnyCodable(enabled)]) - await self.refreshJobs() - } catch { - self.lastError = error.localizedDescription - } - } - - func upsertJob( - id: String?, - payload: [String: AnyCodable]) async throws - { - if let id { - try await GatewayConnection.shared.cronUpdate(jobId: id, patch: payload) - } else { - try await GatewayConnection.shared.cronAdd(payload: payload) - } - await self.refreshJobs() - } - - // MARK: - Gateway events - - private func startGatewaySubscription() { - self.eventTask?.cancel() - self.eventTask = Task { [weak self] in - guard let self else { return } - let stream = await GatewayConnection.shared.subscribe() - for await push in stream { - if Task.isCancelled { return } - await MainActor.run { [weak self] in - self?.handle(push: push) - } - } - } - } - - private func handle(push: GatewayPush) { - switch push { - case let .event(evt) where evt.event == "cron": - guard let payload = evt.payload else { return } - if let cronEvt = try? GatewayPayloadDecoding.decode(payload, as: CronEvent.self) { - self.handle(cronEvent: cronEvt) - } - case .seqGap: - self.scheduleRefresh() - default: - break - } - } - - private func handle(cronEvent evt: CronEvent) { - // Keep UI in sync with the gateway scheduler. - self.scheduleRefresh(delayMs: 250) - if evt.action == "finished", let selected = self.selectedJobId, selected == evt.jobId { - self.scheduleRunsRefresh(jobId: selected, delayMs: 200) - } - } - - private func scheduleRefresh(delayMs: Int = 250) { - self.refreshTask?.cancel() - self.refreshTask = Task { [weak self] in - guard let self else { return } - try? await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000) - await self.refreshJobs() - } - } - - private func scheduleRunsRefresh(jobId: String, delayMs: Int = 200) { - self.runsTask?.cancel() - self.runsTask = Task { [weak self] in - guard let self else { return } - try? await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000) - await self.refreshRuns(jobId: jobId) - } - } - - // MARK: - (no additional RPC helpers) -} diff --git a/apps/macos/Sources/OpenClaw/CronModels.swift b/apps/macos/Sources/OpenClaw/CronModels.swift deleted file mode 100644 index cbfbc061d6a..00000000000 --- a/apps/macos/Sources/OpenClaw/CronModels.swift +++ /dev/null @@ -1,271 +0,0 @@ -import Foundation - -enum CronSessionTarget: String, CaseIterable, Identifiable, Codable { - case main - case isolated - - var id: String { - self.rawValue - } -} - -enum CronWakeMode: String, CaseIterable, Identifiable, Codable { - case now - case nextHeartbeat = "next-heartbeat" - - var id: String { - self.rawValue - } -} - -enum CronDeliveryMode: String, CaseIterable, Identifiable, Codable { - case none - case announce - case webhook - - var id: String { - self.rawValue - } -} - -struct CronDelivery: Codable, Equatable { - var mode: CronDeliveryMode - var channel: String? - var to: String? - var bestEffort: Bool? -} - -enum CronSchedule: Codable, Equatable { - case at(at: String) - case every(everyMs: Int, anchorMs: Int?) - case cron(expr: String, tz: String?) - - enum CodingKeys: String, CodingKey { case kind, at, atMs, everyMs, anchorMs, expr, tz } - - var kind: String { - switch self { - case .at: "at" - case .every: "every" - case .cron: "cron" - } - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let kind = try container.decode(String.self, forKey: .kind) - switch kind { - case "at": - if let at = try container.decodeIfPresent(String.self, forKey: .at), - !at.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - self = .at(at: at) - return - } - if let atMs = try container.decodeIfPresent(Int.self, forKey: .atMs) { - let date = Date(timeIntervalSince1970: TimeInterval(atMs) / 1000) - self = .at(at: Self.formatIsoDate(date)) - return - } - throw DecodingError.dataCorruptedError( - forKey: .at, - in: container, - debugDescription: "Missing schedule.at") - case "every": - self = try .every( - everyMs: container.decode(Int.self, forKey: .everyMs), - anchorMs: container.decodeIfPresent(Int.self, forKey: .anchorMs)) - case "cron": - self = try .cron( - expr: container.decode(String.self, forKey: .expr), - tz: container.decodeIfPresent(String.self, forKey: .tz)) - default: - throw DecodingError.dataCorruptedError( - forKey: .kind, - in: container, - debugDescription: "Unknown schedule kind: \(kind)") - } - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(self.kind, forKey: .kind) - switch self { - case let .at(at): - try container.encode(at, forKey: .at) - case let .every(everyMs, anchorMs): - try container.encode(everyMs, forKey: .everyMs) - try container.encodeIfPresent(anchorMs, forKey: .anchorMs) - case let .cron(expr, tz): - try container.encode(expr, forKey: .expr) - try container.encodeIfPresent(tz, forKey: .tz) - } - } - - static func parseAtDate(_ value: String) -> Date? { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { return nil } - if let date = makeIsoFormatter(withFractional: true).date(from: trimmed) { return date } - return self.makeIsoFormatter(withFractional: false).date(from: trimmed) - } - - static func formatIsoDate(_ date: Date) -> String { - self.makeIsoFormatter(withFractional: false).string(from: date) - } - - private static func makeIsoFormatter(withFractional: Bool) -> ISO8601DateFormatter { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = withFractional - ? [.withInternetDateTime, .withFractionalSeconds] - : [.withInternetDateTime] - return formatter - } -} - -enum CronPayload: Codable, Equatable { - case systemEvent(text: String) - case agentTurn( - message: String, - thinking: String?, - timeoutSeconds: Int?, - deliver: Bool?, - channel: String?, - to: String?, - bestEffortDeliver: Bool?) - - enum CodingKeys: String, CodingKey { - case kind, text, message, thinking, timeoutSeconds, deliver, channel, provider, to, bestEffortDeliver - } - - var kind: String { - switch self { - case .systemEvent: "systemEvent" - case .agentTurn: "agentTurn" - } - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let kind = try container.decode(String.self, forKey: .kind) - switch kind { - case "systemEvent": - self = try .systemEvent(text: container.decode(String.self, forKey: .text)) - case "agentTurn": - self = try .agentTurn( - message: container.decode(String.self, forKey: .message), - thinking: container.decodeIfPresent(String.self, forKey: .thinking), - timeoutSeconds: container.decodeIfPresent(Int.self, forKey: .timeoutSeconds), - deliver: container.decodeIfPresent(Bool.self, forKey: .deliver), - channel: container.decodeIfPresent(String.self, forKey: .channel) - ?? container.decodeIfPresent(String.self, forKey: .provider), - to: container.decodeIfPresent(String.self, forKey: .to), - bestEffortDeliver: container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver)) - default: - throw DecodingError.dataCorruptedError( - forKey: .kind, - in: container, - debugDescription: "Unknown payload kind: \(kind)") - } - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(self.kind, forKey: .kind) - switch self { - case let .systemEvent(text): - try container.encode(text, forKey: .text) - case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver): - try container.encode(message, forKey: .message) - try container.encodeIfPresent(thinking, forKey: .thinking) - try container.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds) - try container.encodeIfPresent(deliver, forKey: .deliver) - try container.encodeIfPresent(channel, forKey: .channel) - try container.encodeIfPresent(to, forKey: .to) - try container.encodeIfPresent(bestEffortDeliver, forKey: .bestEffortDeliver) - } - } -} - -struct CronJobState: Codable, Equatable { - var nextRunAtMs: Int? - var runningAtMs: Int? - var lastRunAtMs: Int? - var lastStatus: String? - var lastError: String? - var lastDurationMs: Int? -} - -struct CronJob: Identifiable, Codable, Equatable { - let id: String - let agentId: String? - var name: String - var description: String? - var enabled: Bool - var deleteAfterRun: Bool? - let createdAtMs: Int - let updatedAtMs: Int - let schedule: CronSchedule - let sessionTarget: CronSessionTarget - let wakeMode: CronWakeMode - let payload: CronPayload - let delivery: CronDelivery? - let state: CronJobState - - var displayName: String { - let trimmed = self.name.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? "Untitled job" : trimmed - } - - var nextRunDate: Date? { - guard let ms = self.state.nextRunAtMs else { return nil } - return Date(timeIntervalSince1970: TimeInterval(ms) / 1000) - } - - var lastRunDate: Date? { - guard let ms = self.state.lastRunAtMs else { return nil } - return Date(timeIntervalSince1970: TimeInterval(ms) / 1000) - } -} - -struct CronEvent: Codable, Sendable { - let jobId: String - let action: String - let runAtMs: Int? - let durationMs: Int? - let status: String? - let error: String? - let summary: String? - let nextRunAtMs: Int? -} - -struct CronRunLogEntry: Codable, Identifiable, Sendable { - var id: String { - "\(self.jobId)-\(self.ts)" - } - - let ts: Int - let jobId: String - let action: String - let status: String? - let error: String? - let summary: String? - let runAtMs: Int? - let durationMs: Int? - let nextRunAtMs: Int? - - var date: Date { - Date(timeIntervalSince1970: TimeInterval(self.ts) / 1000) - } - - var runDate: Date? { - guard let runAtMs else { return nil } - return Date(timeIntervalSince1970: TimeInterval(runAtMs) / 1000) - } -} - -struct CronListResponse: Codable { - let jobs: [CronJob] -} - -struct CronRunsResponse: Codable { - let entries: [CronRunLogEntry] -} diff --git a/apps/macos/Sources/OpenClaw/CronSettings+Actions.swift b/apps/macos/Sources/OpenClaw/CronSettings+Actions.swift deleted file mode 100644 index 3fffaf90fd5..00000000000 --- a/apps/macos/Sources/OpenClaw/CronSettings+Actions.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation -import OpenClawProtocol - -extension CronSettings { - func save(payload: [String: AnyCodable]) async { - guard !self.isSaving else { return } - self.isSaving = true - self.editorError = nil - do { - try await self.store.upsertJob(id: self.editingJob?.id, payload: payload) - await MainActor.run { - self.isSaving = false - self.showEditor = false - self.editingJob = nil - } - } catch { - await MainActor.run { - self.isSaving = false - self.editorError = error.localizedDescription - } - } - } -} diff --git a/apps/macos/Sources/OpenClaw/CronSettings+Helpers.swift b/apps/macos/Sources/OpenClaw/CronSettings+Helpers.swift deleted file mode 100644 index c638e4c87b1..00000000000 --- a/apps/macos/Sources/OpenClaw/CronSettings+Helpers.swift +++ /dev/null @@ -1,56 +0,0 @@ -import SwiftUI - -extension CronSettings { - var selectedJob: CronJob? { - guard let id = self.store.selectedJobId else { return nil } - return self.store.jobs.first(where: { $0.id == id }) - } - - func statusTint(_ status: String?) -> Color { - switch (status ?? "").lowercased() { - case "ok": .green - case "error": .red - case "skipped": .orange - default: .secondary - } - } - - func scheduleSummary(_ schedule: CronSchedule) -> String { - switch schedule { - case let .at(at): - if let date = CronSchedule.parseAtDate(at) { - return "at \(date.formatted(date: .abbreviated, time: .standard))" - } - return "at \(at)" - case let .every(everyMs, _): - return "every \(self.formatDuration(ms: everyMs))" - case let .cron(expr, tz): - if let tz, !tz.isEmpty { return "cron \(expr) (\(tz))" } - return "cron \(expr)" - } - } - - func formatDuration(ms: Int) -> String { - if ms < 1000 { return "\(ms)ms" } - let s = Double(ms) / 1000.0 - if s < 60 { return "\(Int(round(s)))s" } - let m = s / 60.0 - if m < 60 { return "\(Int(round(m)))m" } - let h = m / 60.0 - if h < 48 { return "\(Int(round(h)))h" } - let d = h / 24.0 - return "\(Int(round(d)))d" - } - - func nextRunLabel(_ date: Date, now: Date = .init()) -> String { - let delta = date.timeIntervalSince(now) - if delta <= 0 { return "due" } - if delta < 60 { return "in <1m" } - let minutes = Int(round(delta / 60)) - if minutes < 60 { return "in \(minutes)m" } - let hours = Int(round(Double(minutes) / 60)) - if hours < 48 { return "in \(hours)h" } - let days = Int(round(Double(hours) / 24)) - return "in \(days)d" - } -} diff --git a/apps/macos/Sources/OpenClaw/CronSettings+Layout.swift b/apps/macos/Sources/OpenClaw/CronSettings+Layout.swift deleted file mode 100644 index 11c7c0a0e5b..00000000000 --- a/apps/macos/Sources/OpenClaw/CronSettings+Layout.swift +++ /dev/null @@ -1,179 +0,0 @@ -import SwiftUI - -extension CronSettings { - var body: some View { - VStack(alignment: .leading, spacing: 12) { - self.header - self.schedulerBanner - self.content - Spacer(minLength: 0) - } - .onAppear { - self.store.start() - self.channelsStore.start() - } - .onDisappear { - self.store.stop() - self.channelsStore.stop() - } - .sheet(isPresented: self.$showEditor) { - CronJobEditor( - job: self.editingJob, - isSaving: self.$isSaving, - error: self.$editorError, - channelsStore: self.channelsStore, - onCancel: { - self.showEditor = false - self.editingJob = nil - }, - onSave: { payload in - Task { - await self.save(payload: payload) - } - }) - } - .alert("Delete cron job?", isPresented: Binding( - get: { self.confirmDelete != nil }, - set: { if !$0 { self.confirmDelete = nil } })) - { - Button("Cancel", role: .cancel) { self.confirmDelete = nil } - Button("Delete", role: .destructive) { - if let job = self.confirmDelete { - Task { await self.store.removeJob(id: job.id) } - } - self.confirmDelete = nil - } - } message: { - if let job = self.confirmDelete { - Text(job.displayName) - } - } - .onChange(of: self.store.selectedJobId) { _, newValue in - guard let newValue else { return } - Task { await self.store.refreshRuns(jobId: newValue) } - } - } - - var schedulerBanner: some View { - Group { - if self.store.schedulerEnabled == false { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 8) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) - Text("Cron scheduler is disabled") - .font(.headline) - Spacer() - } - Text( - "Jobs are saved, but they will not run automatically until `cron.enabled` is set to `true` " + - "and the Gateway restarts.") - .font(.footnote) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - if let storePath = self.store.schedulerStorePath, !storePath.isEmpty { - Text(storePath) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .textSelection(.enabled) - .lineLimit(1) - .truncationMode(.middle) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(10) - .background(Color.orange.opacity(0.10)) - .cornerRadius(8) - } - } - } - - var header: some View { - HStack(alignment: .top) { - VStack(alignment: .leading, spacing: 4) { - Text("Cron") - .font(.headline) - Text("Manage Gateway cron jobs (main session vs isolated runs) and inspect run history.") - .font(.footnote) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - Spacer() - HStack(spacing: 8) { - Button { - Task { await self.store.refreshJobs() } - } label: { - Label("Refresh", systemImage: "arrow.clockwise") - } - .buttonStyle(.bordered) - .disabled(self.store.isLoadingJobs) - - Button { - self.editorError = nil - self.editingJob = nil - self.showEditor = true - } label: { - Label("New Job", systemImage: "plus") - } - .buttonStyle(.borderedProminent) - } - } - } - - var content: some View { - HStack(spacing: 12) { - VStack(alignment: .leading, spacing: 8) { - if let err = self.store.lastError { - Text("Error: \(err)") - .font(.footnote) - .foregroundStyle(.red) - } else if let msg = self.store.statusMessage { - Text(msg) - .font(.footnote) - .foregroundStyle(.secondary) - } - - List(selection: self.$store.selectedJobId) { - ForEach(self.store.jobs) { job in - self.jobRow(job) - .tag(job.id) - .contextMenu { self.jobContextMenu(job) } - } - } - .listStyle(.inset) - } - .frame(width: 250) - - Divider() - - self.detail - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - } - } - - @ViewBuilder - var detail: some View { - if let selected = self.selectedJob { - ScrollView(.vertical) { - VStack(alignment: .leading, spacing: 12) { - self.detailHeader(selected) - self.detailCard(selected) - self.runHistoryCard(selected) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 2) - } - } else { - VStack(alignment: .leading, spacing: 8) { - Text("Select a job to inspect details and run history.") - .font(.callout) - .foregroundStyle(.secondary) - Text("Tip: use ‘New Job’ to add one, or enable cron in your gateway config.") - .font(.caption) - .foregroundStyle(.tertiary) - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .padding(.top, 8) - } - } -} diff --git a/apps/macos/Sources/OpenClaw/CronSettings+Rows.swift b/apps/macos/Sources/OpenClaw/CronSettings+Rows.swift deleted file mode 100644 index 69655bdc302..00000000000 --- a/apps/macos/Sources/OpenClaw/CronSettings+Rows.swift +++ /dev/null @@ -1,246 +0,0 @@ -import SwiftUI - -extension CronSettings { - func jobRow(_ job: CronJob) -> some View { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 8) { - Text(job.displayName) - .font(.subheadline.weight(.semibold)) - .lineLimit(1) - .truncationMode(.middle) - Spacer() - if !job.enabled { - StatusPill(text: "disabled", tint: .secondary) - } else if let next = job.nextRunDate { - StatusPill(text: self.nextRunLabel(next), tint: .secondary) - } else { - StatusPill(text: "no next run", tint: .secondary) - } - } - HStack(spacing: 6) { - StatusPill(text: job.sessionTarget.rawValue, tint: .secondary) - StatusPill(text: job.wakeMode.rawValue, tint: .secondary) - if let agentId = job.agentId, !agentId.isEmpty { - StatusPill(text: "agent \(agentId)", tint: .secondary) - } - if let status = job.state.lastStatus { - StatusPill(text: status, tint: status == "ok" ? .green : .orange) - } - } - } - .padding(.vertical, 6) - } - - @ViewBuilder - func jobContextMenu(_ job: CronJob) -> some View { - Button("Run now") { Task { await self.store.runJob(id: job.id, force: true) } } - if job.sessionTarget == .isolated { - Button("Open transcript") { - WebChatManager.shared.show(sessionKey: "cron:\(job.id)") - } - } - Divider() - Button(job.enabled ? "Disable" : "Enable") { - Task { await self.store.setJobEnabled(id: job.id, enabled: !job.enabled) } - } - Button("Edit…") { - self.editingJob = job - self.editorError = nil - self.showEditor = true - } - Divider() - Button("Delete…", role: .destructive) { - self.confirmDelete = job - } - } - - func detailHeader(_ job: CronJob) -> some View { - HStack(alignment: .center) { - VStack(alignment: .leading, spacing: 4) { - Text(job.displayName) - .font(.title3.weight(.semibold)) - Text(job.id) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .textSelection(.enabled) - .lineLimit(1) - .truncationMode(.middle) - } - Spacer() - HStack(spacing: 8) { - Toggle("Enabled", isOn: Binding( - get: { job.enabled }, - set: { enabled in Task { await self.store.setJobEnabled(id: job.id, enabled: enabled) } })) - .toggleStyle(.switch) - .labelsHidden() - Button("Run") { Task { await self.store.runJob(id: job.id, force: true) } } - .buttonStyle(.borderedProminent) - if job.sessionTarget == .isolated { - Button("Transcript") { - WebChatManager.shared.show(sessionKey: "cron:\(job.id)") - } - .buttonStyle(.bordered) - } - Button("Edit") { - self.editingJob = job - self.editorError = nil - self.showEditor = true - } - .buttonStyle(.bordered) - } - } - } - - func detailCard(_ job: CronJob) -> some View { - VStack(alignment: .leading, spacing: 10) { - LabeledContent("Schedule") { Text(self.scheduleSummary(job.schedule)).font(.callout) } - if case .at = job.schedule, job.deleteAfterRun == true { - LabeledContent("Auto-delete") { Text("after success") } - } - if let desc = job.description, !desc.isEmpty { - LabeledContent("Description") { Text(desc).font(.callout) } - } - if let agentId = job.agentId, !agentId.isEmpty { - LabeledContent("Agent") { Text(agentId) } - } - LabeledContent("Session") { Text(job.sessionTarget.rawValue) } - LabeledContent("Wake") { Text(job.wakeMode.rawValue) } - LabeledContent("Next run") { - if let date = job.nextRunDate { - Text(date.formatted(date: .abbreviated, time: .standard)) - } else { - Text("—").foregroundStyle(.secondary) - } - } - LabeledContent("Last run") { - if let date = job.lastRunDate { - Text("\(date.formatted(date: .abbreviated, time: .standard)) · \(relativeAge(from: date))") - } else { - Text("—").foregroundStyle(.secondary) - } - } - if let status = job.state.lastStatus { - LabeledContent("Last status") { Text(status) } - } - if let err = job.state.lastError, !err.isEmpty { - Text(err) - .font(.footnote) - .foregroundStyle(.orange) - .textSelection(.enabled) - } - self.payloadSummary(job) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(10) - .background(Color.secondary.opacity(0.06)) - .cornerRadius(8) - } - - func runHistoryCard(_ job: CronJob) -> some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("Run history") - .font(.headline) - Spacer() - Button { - Task { await self.store.refreshRuns(jobId: job.id) } - } label: { - Label("Refresh", systemImage: "arrow.clockwise") - } - .buttonStyle(.bordered) - .disabled(self.store.isLoadingRuns) - } - - if self.store.isLoadingRuns { - ProgressView().controlSize(.small) - } - - if self.store.runEntries.isEmpty { - Text("No run log entries yet.") - .font(.footnote) - .foregroundStyle(.secondary) - } else { - VStack(alignment: .leading, spacing: 6) { - ForEach(self.store.runEntries) { entry in - self.runRow(entry) - } - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(10) - .background(Color.secondary.opacity(0.06)) - .cornerRadius(8) - } - - func runRow(_ entry: CronRunLogEntry) -> some View { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 8) { - StatusPill(text: entry.status ?? "unknown", tint: self.statusTint(entry.status)) - Text(entry.date.formatted(date: .abbreviated, time: .standard)) - .font(.caption) - .foregroundStyle(.secondary) - Spacer() - if let ms = entry.durationMs { - Text("\(ms)ms") - .font(.caption2.monospacedDigit()) - .foregroundStyle(.secondary) - } - } - if let summary = entry.summary, !summary.isEmpty { - Text(summary) - .font(.caption) - .foregroundStyle(.secondary) - .textSelection(.enabled) - .lineLimit(2) - } - if let error = entry.error, !error.isEmpty { - Text(error) - .font(.caption) - .foregroundStyle(.orange) - .textSelection(.enabled) - .lineLimit(2) - } - } - .padding(.vertical, 4) - } - - func payloadSummary(_ job: CronJob) -> some View { - let payload = job.payload - return VStack(alignment: .leading, spacing: 6) { - Text("Payload") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - switch payload { - case let .systemEvent(text): - Text(text) - .font(.callout) - .textSelection(.enabled) - case let .agentTurn(message, thinking, timeoutSeconds, _, _, _, _): - VStack(alignment: .leading, spacing: 4) { - Text(message) - .font(.callout) - .textSelection(.enabled) - HStack(spacing: 8) { - if let thinking, !thinking.isEmpty { StatusPill(text: "think \(thinking)", tint: .secondary) } - if let timeoutSeconds { StatusPill(text: "\(timeoutSeconds)s", tint: .secondary) } - if job.sessionTarget == .isolated { - let delivery = job.delivery - if let delivery { - if delivery.mode == .announce { - StatusPill(text: "announce", tint: .secondary) - if let channel = delivery.channel, !channel.isEmpty { - StatusPill(text: channel, tint: .secondary) - } - if let to = delivery.to, !to.isEmpty { StatusPill(text: to, tint: .secondary) } - } else { - StatusPill(text: "no delivery", tint: .secondary) - } - } - } - } - } - } - } - } -} diff --git a/apps/macos/Sources/OpenClaw/CronSettings+Testing.swift b/apps/macos/Sources/OpenClaw/CronSettings+Testing.swift deleted file mode 100644 index 4b51a4a9e9c..00000000000 --- a/apps/macos/Sources/OpenClaw/CronSettings+Testing.swift +++ /dev/null @@ -1,121 +0,0 @@ -import SwiftUI - -#if DEBUG -struct CronSettings_Previews: PreviewProvider { - static var previews: some View { - let store = CronJobsStore(isPreview: true) - store.jobs = [ - CronJob( - id: "job-1", - agentId: "ops", - name: "Daily summary", - description: nil, - enabled: true, - deleteAfterRun: nil, - createdAtMs: 0, - updatedAtMs: 0, - schedule: .every(everyMs: 86_400_000, anchorMs: nil), - sessionTarget: .isolated, - wakeMode: .now, - payload: .agentTurn( - message: "Summarize inbox", - thinking: "low", - timeoutSeconds: 600, - deliver: nil, - channel: nil, - to: nil, - bestEffortDeliver: nil), - delivery: CronDelivery(mode: .announce, channel: "last", to: nil, bestEffort: true), - state: CronJobState( - nextRunAtMs: Int(Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000), - runningAtMs: nil, - lastRunAtMs: nil, - lastStatus: nil, - lastError: nil, - lastDurationMs: nil)), - ] - store.selectedJobId = "job-1" - store.runEntries = [ - CronRunLogEntry( - ts: Int(Date().timeIntervalSince1970 * 1000), - jobId: "job-1", - action: "finished", - status: "ok", - error: nil, - summary: "All good.", - runAtMs: nil, - durationMs: 1234, - nextRunAtMs: nil), - ] - return CronSettings(store: store, channelsStore: ChannelsStore(isPreview: true)) - .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) - } -} - -@MainActor -extension CronSettings { - static func exerciseForTesting() { - let store = CronJobsStore(isPreview: true) - store.schedulerEnabled = false - store.schedulerStorePath = "/tmp/openclaw-cron-store.json" - - let job = CronJob( - id: "job-1", - agentId: "ops", - name: "Daily summary", - description: "Summary job", - enabled: true, - deleteAfterRun: nil, - createdAtMs: 1_700_000_000_000, - updatedAtMs: 1_700_000_100_000, - schedule: .cron(expr: "0 8 * * *", tz: "UTC"), - sessionTarget: .isolated, - wakeMode: .nextHeartbeat, - payload: .agentTurn( - message: "Summarize", - thinking: "low", - timeoutSeconds: 120, - deliver: nil, - channel: nil, - to: nil, - bestEffortDeliver: nil), - delivery: CronDelivery(mode: .announce, channel: "whatsapp", to: "+15551234567", bestEffort: true), - state: CronJobState( - nextRunAtMs: 1_700_000_200_000, - runningAtMs: nil, - lastRunAtMs: 1_700_000_050_000, - lastStatus: "ok", - lastError: nil, - lastDurationMs: 1200)) - - let run = CronRunLogEntry( - ts: 1_700_000_050_000, - jobId: job.id, - action: "finished", - status: "ok", - error: nil, - summary: "done", - runAtMs: 1_700_000_050_000, - durationMs: 1200, - nextRunAtMs: 1_700_000_200_000) - - store.jobs = [job] - store.selectedJobId = job.id - store.runEntries = [run] - - let view = CronSettings(store: store, channelsStore: ChannelsStore(isPreview: true)) - _ = view.body - _ = view.jobRow(job) - _ = view.jobContextMenu(job) - _ = view.detailHeader(job) - _ = view.detailCard(job) - _ = view.runHistoryCard(job) - _ = view.runRow(run) - _ = view.payloadSummary(job) - _ = view.scheduleSummary(job.schedule) - _ = view.statusTint(job.state.lastStatus) - _ = view.nextRunLabel(Date()) - _ = view.formatDuration(ms: 1234) - } -} -#endif diff --git a/apps/macos/Sources/OpenClaw/CronSettings.swift b/apps/macos/Sources/OpenClaw/CronSettings.swift deleted file mode 100644 index 999712a595d..00000000000 --- a/apps/macos/Sources/OpenClaw/CronSettings.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Observation -import SwiftUI - -struct CronSettings: View { - @Bindable var store: CronJobsStore - @Bindable var channelsStore: ChannelsStore - @State var showEditor = false - @State var editingJob: CronJob? - @State var editorError: String? - @State var isSaving = false - @State var confirmDelete: CronJob? - - init(store: CronJobsStore = .shared, channelsStore: ChannelsStore = .shared) { - self.store = store - self.channelsStore = channelsStore - } -} diff --git a/apps/macos/Sources/OpenClaw/DebugActions.swift b/apps/macos/Sources/OpenClaw/DebugActions.swift deleted file mode 100644 index 706d9cc2ca2..00000000000 --- a/apps/macos/Sources/OpenClaw/DebugActions.swift +++ /dev/null @@ -1,265 +0,0 @@ -import AppKit -import Foundation -import SwiftUI - -enum DebugActions { - private static let verboseDefaultsKey = "openclaw.debug.verboseMain" - private static let sessionMenuLimit = 12 - private static let onboardingSeenKey = "openclaw.onboardingSeen" - - @MainActor - static func openAgentEventsWindow() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 620, height: 420), - styleMask: [.titled, .closable, .miniaturizable, .resizable], - backing: .buffered, - defer: false) - window.title = "Agent Events" - window.isReleasedWhenClosed = false - window.contentView = NSHostingView(rootView: AgentEventsWindow()) - window.center() - window.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - } - - @MainActor - static func openLog() { - let path = self.pinoLogPath() - let url = URL(fileURLWithPath: path) - guard FileManager().fileExists(atPath: path) else { - let alert = NSAlert() - alert.messageText = "Log file not found" - alert.informativeText = path - alert.runModal() - return - } - NSWorkspace.shared.activateFileViewerSelecting([url]) - } - - @MainActor - static func openConfigFolder() { - let url = OpenClawPaths.stateDirURL - NSWorkspace.shared.activateFileViewerSelecting([url]) - } - - @MainActor - static func openSessionStore() { - if AppStateStore.shared.connectionMode == .remote { - let alert = NSAlert() - alert.messageText = "Remote mode" - alert.informativeText = "Session store lives on the gateway host in remote mode." - alert.runModal() - return - } - let path = self.resolveSessionStorePath() - let url = URL(fileURLWithPath: path) - if FileManager().fileExists(atPath: path) { - NSWorkspace.shared.activateFileViewerSelecting([url]) - } else { - NSWorkspace.shared.open(url.deletingLastPathComponent()) - } - } - - static func sendTestNotification() async { - _ = await NotificationManager().send(title: "OpenClaw", body: "Test notification", sound: nil) - } - - static func sendDebugVoice() async -> Result { - let message = """ - This is a debug test from the Mac app. Reply with "Debug test works (and a funny pun)" \ - if you received that. - """ - let result = await VoiceWakeForwarder.forward(transcript: message) - switch result { - case .success: - return .success("Sent. Await reply.") - case let .failure(error): - let detail = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) - return .failure(.message("Send failed: \(detail)")) - } - } - - static func restartGateway() { - Task { @MainActor in - switch AppStateStore.shared.connectionMode { - case .local: - GatewayProcessManager.shared.stop() - // Kick the control channel + health check so the UI recovers immediately. - await GatewayConnection.shared.shutdown() - try? await Task.sleep(nanoseconds: 300_000_000) - GatewayProcessManager.shared.setActive(true) - Task { try? await ControlChannel.shared.configure(mode: .local) } - Task { await HealthStore.shared.refresh(onDemand: true) } - - case .remote: - // In remote mode, there is no local gateway to restart. "Restart Gateway" should - // reset the SSH control tunnel + reconnect so the menu recovers. - await RemoteTunnelManager.shared.stopAll() - await GatewayConnection.shared.shutdown() - do { - _ = try await RemoteTunnelManager.shared.ensureControlTunnel() - let settings = CommandResolver.connectionSettings() - try await ControlChannel.shared.configure(mode: .remote( - target: settings.target, - identity: settings.identity)) - } catch { - // ControlChannel will surface a degraded state; also refresh health to update the menu text. - Task { await HealthStore.shared.refresh(onDemand: true) } - } - - case .unconfigured: - await GatewayConnection.shared.shutdown() - await ControlChannel.shared.disconnect() - } - } - } - - static func resetGatewayTunnel() async -> Result { - let mode = CommandResolver.connectionSettings().mode - guard mode == .remote else { - return .failure(.message("Remote mode is not enabled.")) - } - await RemoteTunnelManager.shared.stopAll() - await GatewayConnection.shared.shutdown() - do { - _ = try await RemoteTunnelManager.shared.ensureControlTunnel() - let settings = CommandResolver.connectionSettings() - try await ControlChannel.shared.configure(mode: .remote( - target: settings.target, - identity: settings.identity)) - await HealthStore.shared.refresh(onDemand: true) - return .success("SSH tunnel reset.") - } catch { - Task { await HealthStore.shared.refresh(onDemand: true) } - return .failure(.message(error.localizedDescription)) - } - } - - static func pinoLogPath() -> String { - LogLocator.bestLogFile()?.path ?? LogLocator.launchdLogPath - } - - @MainActor - static func runHealthCheckNow() async { - await HealthStore.shared.refresh(onDemand: true) - } - - static func sendTestHeartbeat() async -> Result { - do { - _ = await GatewayConnection.shared.setHeartbeatsEnabled(true) - await ControlChannel.shared.configure() - let data = try await ControlChannel.shared.request(method: "last-heartbeat") - if let evt = try? JSONDecoder().decode(ControlHeartbeatEvent.self, from: data) { - return .success(evt) - } - return .success(nil) - } catch { - return .failure(error) - } - } - - static var verboseLoggingEnabledMain: Bool { - UserDefaults.standard.bool(forKey: self.verboseDefaultsKey) - } - - static func toggleVerboseLoggingMain() async -> Bool { - let newValue = !self.verboseLoggingEnabledMain - UserDefaults.standard.set(newValue, forKey: self.verboseDefaultsKey) - _ = try? await ControlChannel.shared.request( - method: "system-event", - params: ["text": AnyHashable("verbose-main:\(newValue ? "on" : "off")")]) - return newValue - } - - @MainActor - static func restartApp() { - let url = Bundle.main.bundleURL - let task = Process() - // Relaunch shortly after this instance exits so we get a true restart even in debug. - task.launchPath = "/bin/sh" - task.arguments = ["-c", "sleep 0.2; open -n \"$1\"", "_", url.path] - try? task.run() - NSApp.terminate(nil) - } - - @MainActor - static func restartOnboarding() { - UserDefaults.standard.set(false, forKey: self.onboardingSeenKey) - UserDefaults.standard.set(0, forKey: onboardingVersionKey) - AppStateStore.shared.onboardingSeen = false - OnboardingController.shared.restart() - } - - @MainActor - private static func resolveSessionStorePath() -> String { - let defaultPath = SessionLoader.defaultStorePath - let configURL = OpenClawPaths.configURL - guard - let data = try? Data(contentsOf: configURL), - let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let session = parsed["session"] as? [String: Any], - let path = session["store"] as? String, - !path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - else { - return defaultPath - } - return path - } - - // MARK: - Sessions (thinking / verbose) - - static func recentSessions(limit: Int = sessionMenuLimit) async -> [SessionRow] { - guard let snapshot = try? await SessionLoader.loadSnapshot(limit: limit) else { return [] } - return Array(snapshot.rows.prefix(limit)) - } - - static func updateSession( - key: String, - thinking: String?, - verbose: String?) async throws - { - var params: [String: AnyHashable] = ["key": AnyHashable(key)] - params["thinkingLevel"] = thinking.map(AnyHashable.init) ?? AnyHashable(NSNull()) - params["verboseLevel"] = verbose.map(AnyHashable.init) ?? AnyHashable(NSNull()) - _ = try await ControlChannel.shared.request(method: "sessions.patch", params: params) - } - - // MARK: - Port diagnostics - - typealias PortListener = PortGuardian.ReportListener - typealias PortReport = PortGuardian.PortReport - - static func checkGatewayPorts() async -> [PortReport] { - let mode = CommandResolver.connectionSettings().mode - return await PortGuardian.shared.diagnose(mode: mode) - } - - static func killProcess(_ pid: Int) async -> Result { - let primary = await ShellExecutor.run(command: ["kill", "-TERM", "\(pid)"], cwd: nil, env: nil, timeout: 2) - if primary.ok { return .success(()) } - let force = await ShellExecutor.run(command: ["kill", "-KILL", "\(pid)"], cwd: nil, env: nil, timeout: 2) - if force.ok { return .success(()) } - let detail = force.message ?? primary.message ?? "kill failed" - return .failure(.message(detail)) - } - - @MainActor - static func openSessionStoreInCode() { - let path = SessionLoader.defaultStorePath - let proc = Process() - proc.launchPath = "/usr/bin/env" - proc.arguments = ["code", path] - try? proc.run() - } -} - -enum DebugActionError: LocalizedError { - case message(String) - - var errorDescription: String? { - switch self { - case let .message(text): - text - } - } -} diff --git a/apps/macos/Sources/OpenClaw/DebugSettings.swift b/apps/macos/Sources/OpenClaw/DebugSettings.swift deleted file mode 100644 index 678ffc9e3ff..00000000000 --- a/apps/macos/Sources/OpenClaw/DebugSettings.swift +++ /dev/null @@ -1,1026 +0,0 @@ -import AppKit -import Observation -import SwiftUI -import UniformTypeIdentifiers - -struct DebugSettings: View { - @Bindable var state: AppState - private let isPreview = ProcessInfo.processInfo.isPreview - private let labelColumnWidth: CGFloat = 140 - @AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath - @AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0 - @AppStorage(iconOverrideKey) private var iconOverrideRaw: String = IconOverrideSelection.system.rawValue - @AppStorage(canvasEnabledKey) private var canvasEnabled: Bool = true - @State private var modelsCount: Int? - @State private var modelsLoading = false - @State private var modelsError: String? - private let gatewayManager = GatewayProcessManager.shared - private let healthStore = HealthStore.shared - @State private var launchAgentWriteDisabled = GatewayLaunchAgentManager.isLaunchAgentWriteDisabled() - @State private var launchAgentWriteError: String? - @State private var gatewayRootInput: String = GatewayProcessManager.shared.projectRootPath() - @State private var sessionStorePath: String = SessionLoader.defaultStorePath - @State private var sessionStoreSaveError: String? - @State private var debugSendInFlight = false - @State private var debugSendStatus: String? - @State private var debugSendError: String? - @State private var portCheckInFlight = false - @State private var portReports: [DebugActions.PortReport] = [] - @State private var portKillStatus: String? - @State private var tunnelResetInFlight = false - @State private var tunnelResetStatus: String? - @State private var pendingKill: DebugActions.PortListener? - @AppStorage(debugFileLogEnabledKey) private var diagnosticsFileLogEnabled: Bool = false - @AppStorage(appLogLevelKey) private var appLogLevelRaw: String = AppLogLevel.default.rawValue - - @State private var canvasSessionKey: String = "main" - @State private var canvasStatus: String? - @State private var canvasError: String? - @State private var canvasEvalJS: String = "document.title" - @State private var canvasEvalResult: String? - @State private var canvasSnapshotPath: String? - - init(state: AppState = AppStateStore.shared) { - self.state = state - } - - var body: some View { - ScrollView(.vertical) { - VStack(alignment: .leading, spacing: 14) { - self.header - - self.launchdSection - self.appInfoSection - self.gatewaySection - self.logsSection - self.portsSection - self.pathsSection - self.quickActionsSection - self.canvasSection - self.experimentsSection - - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 24) - .padding(.vertical, 18) - .groupBoxStyle(PlainSettingsGroupBoxStyle()) - } - .task { - guard !self.isPreview else { return } - await self.reloadModels() - self.loadSessionStorePath() - } - .alert(item: self.$pendingKill) { listener in - Alert( - title: Text("Kill \(listener.command) (\(listener.pid))?"), - message: Text("This process looks expected for the current mode. Kill anyway?"), - primaryButton: .destructive(Text("Kill")) { - Task { await self.killConfirmed(listener.pid) } - }, - secondaryButton: .cancel()) - } - } - - private var launchdSection: some View { - GroupBox("Gateway startup") { - VStack(alignment: .leading, spacing: 8) { - Toggle("Attach only (skip launchd install)", isOn: self.$launchAgentWriteDisabled) - .onChange(of: self.launchAgentWriteDisabled) { _, newValue in - self.launchAgentWriteError = GatewayLaunchAgentManager.setLaunchAgentWriteDisabled(newValue) - if self.launchAgentWriteError != nil { - self.launchAgentWriteDisabled = GatewayLaunchAgentManager.isLaunchAgentWriteDisabled() - return - } - if newValue { - Task { - _ = await GatewayLaunchAgentManager.set( - enabled: false, - bundlePath: Bundle.main.bundlePath, - port: GatewayEnvironment.gatewayPort()) - } - } - } - - Text( - "When enabled, OpenClaw won't install or manage \(gatewayLaunchdLabel). " + - "It will only attach to an existing Gateway.") - .font(.caption) - .foregroundStyle(.secondary) - - if let launchAgentWriteError { - Text(launchAgentWriteError) - .font(.caption) - .foregroundStyle(.red) - } - } - } - } - - private var header: some View { - VStack(alignment: .leading, spacing: 6) { - Text("Debug") - .font(.title3.weight(.semibold)) - Text("Tools for diagnosing local issues (Gateway, ports, logs, Canvas).") - .font(.callout) - .foregroundStyle(.secondary) - } - } - - private func gridLabel(_ text: String) -> some View { - Text(text) - .foregroundStyle(.secondary) - .frame(width: self.labelColumnWidth, alignment: .leading) - } - - private var appInfoSection: some View { - GroupBox("App") { - Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { - GridRow { - self.gridLabel("Health") - HStack(spacing: 8) { - Circle().fill(self.healthStore.state.tint).frame(width: 10, height: 10) - Text(self.healthStore.summaryLine) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - GridRow { - self.gridLabel("CLI") - let loc = CLIInstaller.installedLocation() - Text(loc ?? "missing") - .font(.caption.monospaced()) - .foregroundStyle(loc == nil ? Color.red : Color.secondary) - .textSelection(.enabled) - .lineLimit(1) - .truncationMode(.middle) - } - GridRow { - self.gridLabel("PID") - Text("\(ProcessInfo.processInfo.processIdentifier)") - } - GridRow { - self.gridLabel("Binary path") - Text(Bundle.main.bundlePath) - .font(.caption2.monospaced()) - .foregroundStyle(.secondary) - .textSelection(.enabled) - .lineLimit(1) - .truncationMode(.middle) - } - } - } - } - - private var gatewaySection: some View { - GroupBox("Gateway") { - VStack(alignment: .leading, spacing: 10) { - Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { - GridRow { - self.gridLabel("Status") - HStack(spacing: 8) { - Text(self.gatewayManager.status.label) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - } - - let key = DeepLinkHandler.currentKey() - HStack(spacing: 8) { - Text("Key") - .foregroundStyle(.secondary) - .frame(width: self.labelColumnWidth, alignment: .leading) - Text(key) - .font(.caption2.monospaced()) - .foregroundStyle(.secondary) - .textSelection(.enabled) - .lineLimit(1) - .truncationMode(.middle) - Button("Copy") { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(key, forType: .string) - } - .buttonStyle(.bordered) - Button("Copy sample URL") { - let msg = "Hello from deep link" - let encoded = msg.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? msg - let url = "openclaw://agent?message=\(encoded)&key=\(key)" - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(url, forType: .string) - } - .buttonStyle(.bordered) - Spacer(minLength: 0) - } - - Text("Deep links (openclaw://…) are always enabled; the key controls unattended runs.") - .font(.caption2) - .foregroundStyle(.secondary) - - VStack(alignment: .leading, spacing: 6) { - Text("Stdout / stderr") - .font(.caption.weight(.semibold)) - ScrollView { - Text(self.gatewayManager.log.isEmpty ? "—" : self.gatewayManager.log) - .font(.caption.monospaced()) - .frame(maxWidth: .infinity, alignment: .leading) - .textSelection(.enabled) - } - .frame(height: 180) - .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.2))) - - HStack(spacing: 8) { - if self.canRestartGateway { - Button("Restart Gateway") { DebugActions.restartGateway() } - } - Button("Clear log") { GatewayProcessManager.shared.clearLog() } - Spacer(minLength: 0) - } - .buttonStyle(.bordered) - } - } - } - } - - private var logsSection: some View { - GroupBox("Logs") { - Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { - GridRow { - self.gridLabel("Pino log") - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 8) { - Button("Open") { DebugActions.openLog() } - .buttonStyle(.bordered) - Text(DebugActions.pinoLogPath()) - .font(.caption2.monospaced()) - .foregroundStyle(.secondary) - .textSelection(.enabled) - .lineLimit(1) - .truncationMode(.middle) - } - } - } - - GridRow { - self.gridLabel("App logging") - VStack(alignment: .leading, spacing: 8) { - Picker("Verbosity", selection: self.$appLogLevelRaw) { - ForEach(AppLogLevel.allCases) { level in - Text(level.title).tag(level.rawValue) - } - } - .pickerStyle(.menu) - .labelsHidden() - .help("Controls the macOS app log verbosity.") - - Toggle("Write rolling diagnostics log (JSONL)", isOn: self.$diagnosticsFileLogEnabled) - .toggleStyle(.checkbox) - .help( - "Writes a rotating, local-only log under ~/Library/Logs/OpenClaw/. " + - "Enable only while actively debugging.") - - HStack(spacing: 8) { - Button("Open folder") { - NSWorkspace.shared.open(DiagnosticsFileLog.logDirectoryURL()) - } - .buttonStyle(.bordered) - Button("Clear") { - Task { try? await DiagnosticsFileLog.shared.clear() } - } - .buttonStyle(.bordered) - } - Text(DiagnosticsFileLog.logFileURL().path) - .font(.caption2.monospaced()) - .foregroundStyle(.secondary) - .textSelection(.enabled) - .lineLimit(1) - .truncationMode(.middle) - } - } - } - } - } - - private var portsSection: some View { - GroupBox("Ports") { - VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 8) { - Text("Port diagnostics") - .font(.caption.weight(.semibold)) - if self.portCheckInFlight { ProgressView().controlSize(.small) } - Spacer() - Button("Check gateway ports") { - Task { await self.runPortCheck() } - } - .buttonStyle(.borderedProminent) - .disabled(self.portCheckInFlight) - Button("Reset SSH tunnel") { - Task { await self.resetGatewayTunnel() } - } - .buttonStyle(.bordered) - .disabled(self.tunnelResetInFlight || !self.isRemoteMode) - } - - if let portKillStatus { - Text(portKillStatus) - .font(.caption2) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - if let tunnelResetStatus { - Text(tunnelResetStatus) - .font(.caption2) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - if self.portReports.isEmpty, !self.portCheckInFlight { - Text("Check which process owns \(GatewayEnvironment.gatewayPort()) and suggest fixes.") - .font(.caption2) - .foregroundStyle(.secondary) - } else { - ForEach(self.portReports) { report in - VStack(alignment: .leading, spacing: 4) { - Text("Port \(report.port)") - .font(.footnote.weight(.semibold)) - Text(report.summary) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - ForEach(report.listeners) { listener in - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 8) { - Text("\(listener.command) (\(listener.pid))") - .font(.caption.monospaced()) - .foregroundStyle(listener.expected ? .secondary : Color.red) - .lineLimit(1) - Spacer() - Button("Kill") { - self.requestKill(listener) - } - .buttonStyle(.bordered) - } - Text(listener.fullCommand) - .font(.caption2.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(2) - .truncationMode(.middle) - } - .padding(6) - .background(Color.secondary.opacity(0.05)) - .cornerRadius(4) - } - } - .padding(8) - .background(Color.secondary.opacity(0.08)) - .cornerRadius(6) - } - } - } - } - } - - private var pathsSection: some View { - GroupBox("Paths") { - VStack(alignment: .leading, spacing: 12) { - VStack(alignment: .leading, spacing: 6) { - Text("OpenClaw project root") - .font(.caption.weight(.semibold)) - HStack(spacing: 8) { - TextField("Path to openclaw repo", text: self.$gatewayRootInput) - .textFieldStyle(.roundedBorder) - .font(.caption.monospaced()) - .onSubmit { self.saveRelayRoot() } - Button("Save") { self.saveRelayRoot() } - .buttonStyle(.borderedProminent) - Button("Reset") { - let def = FileManager().homeDirectoryForCurrentUser - .appendingPathComponent("Projects/openclaw").path - self.gatewayRootInput = def - self.saveRelayRoot() - } - .buttonStyle(.bordered) - } - Text("Used for pnpm/node fallback and PATH population when launching the gateway.") - .font(.caption2) - .foregroundStyle(.secondary) - } - - Divider() - - Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { - GridRow { - self.gridLabel("Session store") - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 8) { - TextField("Path", text: self.$sessionStorePath) - .textFieldStyle(.roundedBorder) - .font(.caption.monospaced()) - .frame(width: 360) - Button("Save") { self.saveSessionStorePath() } - .buttonStyle(.borderedProminent) - } - if let sessionStoreSaveError { - Text(sessionStoreSaveError) - .font(.footnote) - .foregroundStyle(.secondary) - } else { - Text("Used by the CLI session loader; stored in ~/.openclaw/openclaw.json.") - .font(.footnote) - .foregroundStyle(.secondary) - } - } - } - GridRow { - self.gridLabel("Model catalog") - VStack(alignment: .leading, spacing: 6) { - Text(self.modelCatalogPath) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(2) - HStack(spacing: 8) { - Button { - self.chooseCatalogFile() - } label: { - Label("Choose models.generated.ts…", systemImage: "folder") - } - .buttonStyle(.bordered) - - Button { - Task { await self.reloadModels() } - } label: { - Label( - self.modelsLoading ? "Reloading…" : "Reload models", - systemImage: "arrow.clockwise") - } - .buttonStyle(.bordered) - .disabled(self.modelsLoading) - } - if let modelsError { - Text(modelsError) - .font(.footnote) - .foregroundStyle(.secondary) - } else if let modelsCount { - Text("Loaded \(modelsCount) models") - .font(.footnote) - .foregroundStyle(.secondary) - } - Text("Local fallback for model picker when gateway models.list is unavailable.") - .font(.footnote) - .foregroundStyle(.tertiary) - } - } - } - } - } - } - - private var quickActionsSection: some View { - GroupBox("Quick actions") { - VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 8) { - Button("Send Test Notification") { - Task { await DebugActions.sendTestNotification() } - } - .buttonStyle(.bordered) - - Button("Open Agent Events") { - DebugActions.openAgentEventsWindow() - } - .buttonStyle(.borderedProminent) - - Spacer(minLength: 0) - } - - VStack(alignment: .leading, spacing: 6) { - Button { - Task { await self.sendVoiceDebug() } - } label: { - Label( - self.debugSendInFlight ? "Sending debug voice…" : "Send debug voice", - systemImage: self.debugSendInFlight ? "bolt.horizontal.circle" : "waveform") - } - .buttonStyle(.borderedProminent) - .disabled(self.debugSendInFlight) - - if !self.debugSendInFlight { - if let debugSendStatus { - Text(debugSendStatus) - .font(.caption) - .foregroundStyle(.secondary) - } else if let debugSendError { - Text(debugSendError) - .font(.caption) - .foregroundStyle(.red) - } else { - Text( - """ - Uses the Voice Wake path: forwards over SSH when configured, - otherwise runs locally via rpc. - """) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - - VStack(alignment: .leading, spacing: 6) { - Text( - "Note: macOS may require restarting OpenClaw after enabling Accessibility or Screen Recording.") - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - - Button { - LaunchdManager.startOpenClaw() - } label: { - Label("Restart OpenClaw", systemImage: "arrow.counterclockwise") - } - .buttonStyle(.bordered) - .controlSize(.small) - } - - HStack(spacing: 8) { - Button("Restart app") { DebugActions.restartApp() } - Button("Restart onboarding") { DebugActions.restartOnboarding() } - Button("Reveal app in Finder") { self.revealApp() } - Spacer(minLength: 0) - } - .buttonStyle(.bordered) - } - } - } - - private var canvasSection: some View { - GroupBox("Canvas") { - VStack(alignment: .leading, spacing: 10) { - Text("Enable/disable Canvas in General settings.") - .font(.caption) - .foregroundStyle(.secondary) - - HStack(spacing: 8) { - TextField("Session", text: self.$canvasSessionKey) - .textFieldStyle(.roundedBorder) - .font(.caption.monospaced()) - .frame(width: 160) - Button("Show panel") { - Task { await self.canvasPresent() } - } - .buttonStyle(.borderedProminent) - Button("Hide panel") { - CanvasManager.shared.hideAll() - self.canvasStatus = "hidden" - self.canvasError = nil - } - .buttonStyle(.bordered) - Button("Write sample page") { - Task { await self.canvasWriteSamplePage() } - } - .buttonStyle(.bordered) - Spacer(minLength: 0) - } - - HStack(spacing: 8) { - TextField("Eval JS", text: self.$canvasEvalJS) - .textFieldStyle(.roundedBorder) - .font(.caption.monospaced()) - .frame(maxWidth: 520) - Button("Eval") { - Task { await self.canvasEval() } - } - .buttonStyle(.bordered) - Button("Snapshot") { - Task { await self.canvasSnapshot() } - } - .buttonStyle(.bordered) - Spacer(minLength: 0) - } - - if let canvasStatus { - Text(canvasStatus) - .font(.caption2.monospaced()) - .foregroundStyle(.secondary) - .textSelection(.enabled) - } - if let canvasEvalResult { - Text("eval → \(canvasEvalResult)") - .font(.caption2.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(2) - .truncationMode(.middle) - .textSelection(.enabled) - } - if let canvasSnapshotPath { - HStack(spacing: 8) { - Text("snapshot → \(canvasSnapshotPath)") - .font(.caption2.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - .textSelection(.enabled) - Button("Reveal") { - NSWorkspace.shared - .activateFileViewerSelecting([URL(fileURLWithPath: canvasSnapshotPath)]) - } - .buttonStyle(.bordered) - Spacer(minLength: 0) - } - } - if let canvasError { - Text(canvasError) - .font(.caption2) - .foregroundStyle(.red) - } else { - Text("Tip: the session directory is returned by “Show panel”.") - .font(.caption2) - .foregroundStyle(.tertiary) - } - } - } - } - - private var experimentsSection: some View { - GroupBox("Experiments") { - Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { - GridRow { - self.gridLabel("Icon override") - Picker("", selection: self.bindingOverride) { - ForEach(IconOverrideSelection.allCases) { option in - Text(option.label).tag(option.rawValue) - } - } - .labelsHidden() - .frame(maxWidth: 280, alignment: .leading) - } - GridRow { - self.gridLabel("Chat") - Text("Native SwiftUI") - .font(.callout) - .foregroundStyle(.secondary) - } - } - } - } - - @MainActor - private func runPortCheck() async { - self.portCheckInFlight = true - self.portKillStatus = nil - let reports = await DebugActions.checkGatewayPorts() - self.portReports = reports - self.portCheckInFlight = false - } - - @MainActor - private func resetGatewayTunnel() async { - self.tunnelResetInFlight = true - self.tunnelResetStatus = nil - let result = await DebugActions.resetGatewayTunnel() - switch result { - case let .success(message): - self.tunnelResetStatus = message - case let .failure(err): - self.tunnelResetStatus = err.localizedDescription - } - await self.runPortCheck() - self.tunnelResetInFlight = false - } - - @MainActor - private func requestKill(_ listener: DebugActions.PortListener) { - if listener.expected { - self.pendingKill = listener - } else { - Task { await self.killConfirmed(listener.pid) } - } - } - - @MainActor - private func killConfirmed(_ pid: Int32) async { - let result = await DebugActions.killProcess(Int(pid)) - switch result { - case .success: - self.portKillStatus = "Sent kill to \(pid)." - await self.runPortCheck() - case let .failure(err): - self.portKillStatus = "Kill \(pid) failed: \(err.localizedDescription)" - } - } - - private func chooseCatalogFile() { - let panel = NSOpenPanel() - panel.title = "Select models.generated.ts" - let tsType = UTType(filenameExtension: "ts") - ?? UTType(tag: "ts", tagClass: .filenameExtension, conformingTo: .sourceCode) - ?? .item - panel.allowedContentTypes = [tsType] - panel.allowsMultipleSelection = false - panel.directoryURL = URL(fileURLWithPath: self.modelCatalogPath).deletingLastPathComponent() - if panel.runModal() == .OK, let url = panel.url { - self.modelCatalogPath = url.path - self.modelCatalogReloadBump += 1 - Task { await self.reloadModels() } - } - } - - private func reloadModels() async { - guard !self.modelsLoading else { return } - self.modelsLoading = true - self.modelsError = nil - self.modelCatalogReloadBump += 1 - defer { self.modelsLoading = false } - do { - let loaded = try await ModelCatalogLoader.load(from: self.modelCatalogPath) - self.modelsCount = loaded.count - } catch { - self.modelsCount = nil - self.modelsError = error.localizedDescription - } - } - - private func sendVoiceDebug() async { - await MainActor.run { - self.debugSendInFlight = true - self.debugSendError = nil - self.debugSendStatus = nil - } - - let result = await DebugActions.sendDebugVoice() - - await MainActor.run { - self.debugSendInFlight = false - switch result { - case let .success(message): - self.debugSendStatus = message - self.debugSendError = nil - case let .failure(error): - self.debugSendStatus = nil - self.debugSendError = error.localizedDescription - } - } - } - - private func revealApp() { - let url = Bundle.main.bundleURL - NSWorkspace.shared.activateFileViewerSelecting([url]) - } - - private func saveRelayRoot() { - GatewayProcessManager.shared.setProjectRoot(path: self.gatewayRootInput) - } - - private func loadSessionStorePath() { - let url = self.configURL() - guard - let data = try? Data(contentsOf: url), - let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let session = parsed["session"] as? [String: Any], - let path = session["store"] as? String - else { - self.sessionStorePath = SessionLoader.defaultStorePath - return - } - self.sessionStorePath = path - } - - private func saveSessionStorePath() { - let trimmed = self.sessionStorePath.trimmingCharacters(in: .whitespacesAndNewlines) - var root: [String: Any] = [:] - let url = self.configURL() - if let data = try? Data(contentsOf: url), - let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] - { - root = parsed - } - - var session = root["session"] as? [String: Any] ?? [:] - session["store"] = trimmed.isEmpty ? SessionLoader.defaultStorePath : trimmed - root["session"] = session - - do { - let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys]) - try FileManager().createDirectory( - at: url.deletingLastPathComponent(), - withIntermediateDirectories: true) - try data.write(to: url, options: [.atomic]) - self.sessionStoreSaveError = nil - } catch { - self.sessionStoreSaveError = error.localizedDescription - } - } - - private var bindingOverride: Binding { - Binding { - self.iconOverrideRaw - } set: { newValue in - self.iconOverrideRaw = newValue - if let selection = IconOverrideSelection(rawValue: newValue) { - Task { @MainActor in - AppStateStore.shared.iconOverride = selection - WorkActivityStore.shared.resolveIconState(override: selection) - } - } - } - } - - private var isRemoteMode: Bool { - CommandResolver.connectionSettings().mode == .remote - } - - private var canRestartGateway: Bool { - self.state.connectionMode == .local - } - - private func configURL() -> URL { - OpenClawPaths.configURL - } -} - -extension DebugSettings { - // MARK: - Canvas debug actions - - @MainActor - private func canvasPresent() async { - self.canvasError = nil - let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines) - do { - let dir = try CanvasManager.shared.show(sessionKey: session.isEmpty ? "main" : session, path: "/") - self.canvasStatus = "dir: \(dir)" - } catch { - self.canvasError = error.localizedDescription - } - } - - @MainActor - private func canvasWriteSamplePage() async { - self.canvasError = nil - let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines) - do { - let dir = try CanvasManager.shared.show(sessionKey: session.isEmpty ? "main" : session, path: "/") - let url = URL(fileURLWithPath: dir).appendingPathComponent("index.html", isDirectory: false) - let now = ISO8601DateFormatter().string(from: Date()) - let html = """ - - - - - - Canvas Debug - - - -
-
-
Canvas Debug
-
generated: \(now)
-
userAgent:
- -
count: 0
-
-
-
This is a local file served by the WKURLSchemeHandler.
-
-
-
-
-
-
- - - - """ - try html.write(to: url, atomically: true, encoding: .utf8) - self.canvasStatus = "wrote: \(url.path)" - _ = try CanvasManager.shared.show(sessionKey: session.isEmpty ? "main" : session, path: "/") - } catch { - self.canvasError = error.localizedDescription - } - } - - @MainActor - private func canvasEval() async { - self.canvasError = nil - self.canvasEvalResult = nil - do { - let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines) - let result = try await CanvasManager.shared.eval( - sessionKey: session.isEmpty ? "main" : session, - javaScript: self.canvasEvalJS) - self.canvasEvalResult = result - } catch { - self.canvasError = error.localizedDescription - } - } - - @MainActor - private func canvasSnapshot() async { - self.canvasError = nil - self.canvasSnapshotPath = nil - do { - let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines) - let path = try await CanvasManager.shared.snapshot( - sessionKey: session.isEmpty ? "main" : session, - outPath: nil) - self.canvasSnapshotPath = path - } catch { - self.canvasError = error.localizedDescription - } - } -} - -struct PlainSettingsGroupBoxStyle: GroupBoxStyle { - func makeBody(configuration: Configuration) -> some View { - VStack(alignment: .leading, spacing: 10) { - configuration.label - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - configuration.content - } - .frame(maxWidth: .infinity, alignment: .leading) - } -} - -#if DEBUG -struct DebugSettings_Previews: PreviewProvider { - static var previews: some View { - DebugSettings(state: .preview) - .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) - } -} - -@MainActor -extension DebugSettings { - static func exerciseForTesting() async { - let view = DebugSettings(state: .preview) - view.modelsCount = 3 - view.modelsLoading = false - view.modelsError = "Failed to load models" - view.gatewayRootInput = "/tmp/openclaw" - view.sessionStorePath = "/tmp/sessions.json" - view.sessionStoreSaveError = "Save failed" - view.debugSendInFlight = true - view.debugSendStatus = "Sent" - view.debugSendError = "Failed" - view.portCheckInFlight = true - view.portReports = [ - DebugActions.PortReport( - port: GatewayEnvironment.gatewayPort(), - expected: "Gateway websocket (node/tsx)", - status: .missing("Missing"), - listeners: []), - ] - view.portKillStatus = "Killed" - view.pendingKill = DebugActions.PortListener( - pid: 1, - command: "node", - fullCommand: "node", - user: nil, - expected: true) - view.canvasSessionKey = "main" - view.canvasStatus = "Canvas ok" - view.canvasError = "Canvas error" - view.canvasEvalJS = "document.title" - view.canvasEvalResult = "Canvas" - view.canvasSnapshotPath = "/tmp/snapshot.png" - - _ = view.body - _ = view.header - _ = view.appInfoSection - _ = view.gatewaySection - _ = view.logsSection - _ = view.portsSection - _ = view.pathsSection - _ = view.quickActionsSection - _ = view.canvasSection - _ = view.experimentsSection - _ = view.gridLabel("Test") - - view.loadSessionStorePath() - await view.reloadModels() - } -} -#endif diff --git a/apps/macos/Sources/OpenClaw/DeepLinks.swift b/apps/macos/Sources/OpenClaw/DeepLinks.swift deleted file mode 100644 index d11d4d524c3..00000000000 --- a/apps/macos/Sources/OpenClaw/DeepLinks.swift +++ /dev/null @@ -1,199 +0,0 @@ -import AppKit -import Foundation -import OpenClawKit -import OSLog -import Security - -private let deepLinkLogger = Logger(subsystem: "ai.openclaw", category: "DeepLink") - -enum DeepLinkAgentPolicy { - static let maxMessageChars = 20000 - static let maxUnkeyedConfirmChars = 240 - - enum ValidationError: Error, Equatable, LocalizedError { - case messageTooLongForConfirmation(max: Int, actual: Int) - - var errorDescription: String? { - switch self { - case let .messageTooLongForConfirmation(max, actual): - "Message is too long to confirm safely (\(actual) chars; max \(max) without key)." - } - } - } - - static func validateMessageForHandle(message: String, allowUnattended: Bool) -> Result { - if !allowUnattended, message.count > self.maxUnkeyedConfirmChars { - return .failure(.messageTooLongForConfirmation(max: self.maxUnkeyedConfirmChars, actual: message.count)) - } - return .success(()) - } - - static func effectiveDelivery( - link: AgentDeepLink, - allowUnattended: Bool) -> (deliver: Bool, to: String?, channel: GatewayAgentChannel) - { - if !allowUnattended { - // Without the unattended key, ignore delivery/routing knobs to reduce exfiltration risk. - return (deliver: false, to: nil, channel: .last) - } - let channel = GatewayAgentChannel(raw: link.channel) - let deliver = channel.shouldDeliver(link.deliver) - let to = link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty - return (deliver: deliver, to: to, channel: channel) - } -} - -@MainActor -final class DeepLinkHandler { - static let shared = DeepLinkHandler() - - private var lastPromptAt: Date = .distantPast - - /// Ephemeral, in-memory key used for unattended deep links originating from the in-app Canvas. - /// This avoids blocking Canvas init on UserDefaults and doesn't weaken the external deep-link prompt: - /// outside callers can't know this randomly generated key. - private nonisolated static let canvasUnattendedKey: String = DeepLinkHandler.generateRandomKey() - - func handle(url: URL) async { - guard let route = DeepLinkParser.parse(url) else { - deepLinkLogger.debug("ignored url \(url.absoluteString, privacy: .public)") - return - } - guard !AppStateStore.shared.isPaused else { - self.presentAlert(title: "OpenClaw is paused", message: "Unpause OpenClaw to run agent actions.") - return - } - - switch route { - case let .agent(link): - await self.handleAgent(link: link, originalURL: url) - case .gateway: - break - } - } - - private func handleAgent(link: AgentDeepLink, originalURL: URL) async { - let messagePreview = link.message.trimmingCharacters(in: .whitespacesAndNewlines) - if messagePreview.count > DeepLinkAgentPolicy.maxMessageChars { - self.presentAlert(title: "Deep link too large", message: "Message exceeds 20,000 characters.") - return - } - - let allowUnattended = link.key == Self.canvasUnattendedKey || link.key == Self.expectedKey() - if !allowUnattended { - if Date().timeIntervalSince(self.lastPromptAt) < 1.0 { - deepLinkLogger.debug("throttling deep link prompt") - return - } - self.lastPromptAt = Date() - - if case let .failure(error) = DeepLinkAgentPolicy.validateMessageForHandle( - message: messagePreview, - allowUnattended: allowUnattended) - { - self.presentAlert(title: "Deep link blocked", message: error.localizedDescription) - return - } - - let urlText = originalURL.absoluteString - let urlPreview = urlText.count > 500 ? "\(urlText.prefix(500))…" : urlText - let body = - "Run the agent with this message?\n\n\(messagePreview)\n\nURL:\n\(urlPreview)" - guard self.confirm(title: "Run OpenClaw agent?", message: body) else { return } - } - - if AppStateStore.shared.connectionMode == .local { - GatewayProcessManager.shared.setActive(true) - } - - do { - let effectiveDelivery = DeepLinkAgentPolicy.effectiveDelivery(link: link, allowUnattended: allowUnattended) - let explicitSessionKey = link.sessionKey? - .trimmingCharacters(in: .whitespacesAndNewlines) - .nonEmpty - let resolvedSessionKey: String = if let explicitSessionKey { - explicitSessionKey - } else { - await GatewayConnection.shared.mainSessionKey() - } - let invocation = GatewayAgentInvocation( - message: messagePreview, - sessionKey: resolvedSessionKey, - thinking: link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty, - deliver: effectiveDelivery.deliver, - to: effectiveDelivery.to, - channel: effectiveDelivery.channel, - timeoutSeconds: link.timeoutSeconds, - idempotencyKey: UUID().uuidString) - - let res = await GatewayConnection.shared.sendAgent(invocation) - if !res.ok { - throw NSError( - domain: "DeepLink", - code: 1, - userInfo: [NSLocalizedDescriptionKey: res.error ?? "agent request failed"]) - } - } catch { - self.presentAlert(title: "Agent request failed", message: error.localizedDescription) - } - } - - // MARK: - Auth - - static func currentKey() -> String { - self.expectedKey() - } - - static func currentCanvasKey() -> String { - self.canvasUnattendedKey - } - - private static func expectedKey() -> String { - let defaults = UserDefaults.standard - if let key = defaults.string(forKey: deepLinkKeyKey), !key.isEmpty { - return key - } - var bytes = [UInt8](repeating: 0, count: 32) - _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) - let data = Data(bytes) - let key = data - .base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - defaults.set(key, forKey: deepLinkKeyKey) - return key - } - - private nonisolated static func generateRandomKey() -> String { - var bytes = [UInt8](repeating: 0, count: 32) - _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) - let data = Data(bytes) - return data - .base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } - - // MARK: - UI - - private func confirm(title: String, message: String) -> Bool { - let alert = NSAlert() - alert.messageText = title - alert.informativeText = message - alert.addButton(withTitle: "Run") - alert.addButton(withTitle: "Cancel") - alert.alertStyle = .warning - return alert.runModal() == .alertFirstButtonReturn - } - - private func presentAlert(title: String, message: String) { - let alert = NSAlert() - alert.messageText = title - alert.informativeText = message - alert.addButton(withTitle: "OK") - alert.alertStyle = .informational - alert.runModal() - } -} diff --git a/apps/macos/Sources/OpenClaw/DeviceModelCatalog.swift b/apps/macos/Sources/OpenClaw/DeviceModelCatalog.swift deleted file mode 100644 index ce6dd10c931..00000000000 --- a/apps/macos/Sources/OpenClaw/DeviceModelCatalog.swift +++ /dev/null @@ -1,188 +0,0 @@ -import Foundation - -struct DevicePresentation: Sendable { - let title: String - let symbol: String? -} - -enum DeviceModelCatalog { - private static let modelIdentifierToName: [String: String] = loadModelIdentifierToName() - private static let resourceBundle: Bundle? = locateResourceBundle() - private static let resourceSubdirectory = "DeviceModels" - - static func presentation(deviceFamily: String?, modelIdentifier: String?) -> DevicePresentation? { - let family = (deviceFamily ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - let model = (modelIdentifier ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - - let friendlyName = model.isEmpty ? nil : self.modelIdentifierToName[model] - let symbol = self.symbol(deviceFamily: family, modelIdentifier: model, friendlyName: friendlyName) - - let title = if let friendlyName, !friendlyName.isEmpty { - friendlyName - } else if !family.isEmpty, !model.isEmpty { - "\(family) (\(model))" - } else if !family.isEmpty { - family - } else if !model.isEmpty { - model - } else { - "" - } - - if title.isEmpty { return nil } - return DevicePresentation(title: title, symbol: symbol) - } - - static func symbol( - deviceFamily familyRaw: String, - modelIdentifier modelIdentifierRaw: String, - friendlyName: String?) -> String? - { - let family = familyRaw.trimmingCharacters(in: .whitespacesAndNewlines) - let modelIdentifier = modelIdentifierRaw.trimmingCharacters(in: .whitespacesAndNewlines) - - return self.symbolFor(modelIdentifier: modelIdentifier, friendlyName: friendlyName) - ?? self.fallbackSymbol(for: family, modelIdentifier: modelIdentifier) - } - - private static func symbolFor(modelIdentifier rawModelIdentifier: String, friendlyName: String?) -> String? { - let modelIdentifier = rawModelIdentifier.trimmingCharacters(in: .whitespacesAndNewlines) - guard !modelIdentifier.isEmpty else { return nil } - - let lower = modelIdentifier.lowercased() - if lower.hasPrefix("ipad") { return "ipad" } - if lower.hasPrefix("iphone") { return "iphone" } - if lower.hasPrefix("ipod") { return "iphone" } - if lower.hasPrefix("watch") { return "applewatch" } - if lower.hasPrefix("appletv") { return "appletv" } - if lower.hasPrefix("audio") || lower.hasPrefix("homepod") { return "speaker" } - - if lower.hasPrefix("macbook") || lower.hasPrefix("macbookpro") || lower.hasPrefix("macbookair") { - return "laptopcomputer" - } - if lower.hasPrefix("macstudio") { return "macstudio" } - if lower.hasPrefix("macmini") { return "macmini" } - if lower.hasPrefix("imac") || lower.hasPrefix("macpro") { return "desktopcomputer" } - - if lower.hasPrefix("mac"), let friendlyNameLower = friendlyName?.lowercased() { - if friendlyNameLower.contains("macbook") { return "laptopcomputer" } - if friendlyNameLower.contains("imac") { return "desktopcomputer" } - if friendlyNameLower.contains("mac mini") { return "macmini" } - if friendlyNameLower.contains("mac studio") { return "macstudio" } - if friendlyNameLower.contains("mac pro") { return "desktopcomputer" } - } - - return nil - } - - private static func fallbackSymbol(for familyRaw: String, modelIdentifier: String) -> String? { - let family = familyRaw.trimmingCharacters(in: .whitespacesAndNewlines) - if family.isEmpty { return nil } - switch family.lowercased() { - case "ipad": - return "ipad" - case "iphone": - return "iphone" - case "mac": - return "laptopcomputer" - case "android": - return "android" - case "linux": - return "cpu" - default: - return "cpu" - } - } - - private static func loadModelIdentifierToName() -> [String: String] { - var combined: [String: String] = [:] - combined.merge( - self.loadMapping(resourceName: "ios-device-identifiers"), - uniquingKeysWith: { current, _ in current }) - combined.merge( - self.loadMapping(resourceName: "mac-device-identifiers"), - uniquingKeysWith: { current, _ in current }) - return combined - } - - private static func loadMapping(resourceName: String) -> [String: String] { - guard let url = self.resourceBundle?.url( - forResource: resourceName, - withExtension: "json", - subdirectory: self.resourceSubdirectory) - else { return [:] } - - do { - let data = try Data(contentsOf: url) - let decoded = try JSONDecoder().decode([String: NameValue].self, from: data) - return decoded.compactMapValues { $0.normalizedName } - } catch { - return [:] - } - } - - private static func locateResourceBundle() -> Bundle? { - // Prefer main bundle (packaged app), then module bundle (SwiftPM/tests). - // Accessing Bundle.module in the packaged app can crash if the bundle isn't where SwiftPM expects it. - if let bundle = self.bundleIfContainsDeviceModels(Bundle.main) { - return bundle - } - - if let bundle = self.bundleIfContainsDeviceModels(Bundle.module) { - return bundle - } - return nil - } - - private static func bundleIfContainsDeviceModels(_ bundle: Bundle) -> Bundle? { - if bundle.url( - forResource: "ios-device-identifiers", - withExtension: "json", - subdirectory: self.resourceSubdirectory) != nil - { - return bundle - } - if bundle.url( - forResource: "mac-device-identifiers", - withExtension: "json", - subdirectory: self.resourceSubdirectory) != nil - { - return bundle - } - return nil - } - - private enum NameValue: Decodable { - case string(String) - case stringArray([String]) - - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if let s = try? container.decode(String.self) { - self = .string(s) - return - } - if let arr = try? container.decode([String].self) { - self = .stringArray(arr) - return - } - throw DecodingError.typeMismatch( - String.self, - .init(codingPath: decoder.codingPath, debugDescription: "Expected string or string array")) - } - - var normalizedName: String? { - switch self { - case let .string(s): - let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - case let .stringArray(arr): - let values = arr - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - guard !values.isEmpty else { return nil } - return values.joined(separator: " / ") - } - } - } -} diff --git a/apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift b/apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift deleted file mode 100644 index f85e8d1a5df..00000000000 --- a/apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift +++ /dev/null @@ -1,307 +0,0 @@ -import AppKit -import Foundation -import Observation -import OpenClawKit -import OpenClawProtocol -import OSLog - -@MainActor -@Observable -final class DevicePairingApprovalPrompter { - static let shared = DevicePairingApprovalPrompter() - - private let logger = Logger(subsystem: "ai.openclaw", category: "device-pairing") - private var task: Task? - private var isStopping = false - private var isPresenting = false - private var queue: [PendingRequest] = [] - var pendingCount: Int = 0 - var pendingRepairCount: Int = 0 - private var activeAlert: NSAlert? - private var activeRequestId: String? - private var alertHostWindow: NSWindow? - private var resolvedByRequestId: Set = [] - - private struct PairingList: Codable { - let pending: [PendingRequest] - let paired: [PairedDevice]? - } - - private struct PairedDevice: Codable, Equatable { - let deviceId: String - let approvedAtMs: Double? - let displayName: String? - let platform: String? - let remoteIp: String? - } - - private struct PendingRequest: Codable, Equatable, Identifiable { - let requestId: String - let deviceId: String - let publicKey: String - let displayName: String? - let platform: String? - let clientId: String? - let clientMode: String? - let role: String? - let scopes: [String]? - let remoteIp: String? - let silent: Bool? - let isRepair: Bool? - let ts: Double - - var id: String { - self.requestId - } - } - - private struct PairingResolvedEvent: Codable { - let requestId: String - let deviceId: String - let decision: String - let ts: Double - } - - private enum PairingResolution: String { - case approved - case rejected - } - - func start() { - guard self.task == nil else { return } - self.isStopping = false - self.task = Task { [weak self] in - guard let self else { return } - _ = try? await GatewayConnection.shared.refresh() - await self.loadPendingRequestsFromGateway() - let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200) - for await push in stream { - if Task.isCancelled { return } - await MainActor.run { [weak self] in self?.handle(push: push) } - } - } - } - - func stop() { - self.isStopping = true - self.endActiveAlert() - self.task?.cancel() - self.task = nil - self.queue.removeAll(keepingCapacity: false) - self.updatePendingCounts() - self.isPresenting = false - self.activeRequestId = nil - self.alertHostWindow?.orderOut(nil) - self.alertHostWindow?.close() - self.alertHostWindow = nil - self.resolvedByRequestId.removeAll(keepingCapacity: false) - } - - private func loadPendingRequestsFromGateway() async { - do { - let list: PairingList = try await GatewayConnection.shared.requestDecoded(method: .devicePairList) - await self.apply(list: list) - } catch { - self.logger.error("failed to load device pairing requests: \(error.localizedDescription, privacy: .public)") - } - } - - private func apply(list: PairingList) async { - self.queue = list.pending.sorted(by: { $0.ts > $1.ts }) - self.updatePendingCounts() - self.presentNextIfNeeded() - } - - private func updatePendingCounts() { - self.pendingCount = self.queue.count - self.pendingRepairCount = self.queue.count(where: { $0.isRepair == true }) - } - - private func presentNextIfNeeded() { - guard !self.isStopping else { return } - guard !self.isPresenting else { return } - guard let next = self.queue.first else { return } - self.isPresenting = true - self.presentAlert(for: next) - } - - private func presentAlert(for req: PendingRequest) { - self.logger.info("presenting device pairing alert requestId=\(req.requestId, privacy: .public)") - NSApp.activate(ignoringOtherApps: true) - - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = "Allow device to connect?" - alert.informativeText = Self.describe(req) - alert.addButton(withTitle: "Later") - alert.addButton(withTitle: "Approve") - alert.addButton(withTitle: "Reject") - if #available(macOS 11.0, *), alert.buttons.indices.contains(2) { - alert.buttons[2].hasDestructiveAction = true - } - - self.activeAlert = alert - self.activeRequestId = req.requestId - let hostWindow = self.requireAlertHostWindow() - - let sheetSize = alert.window.frame.size - if let screen = hostWindow.screen ?? NSScreen.main { - let bounds = screen.visibleFrame - let x = bounds.midX - (sheetSize.width / 2) - let sheetOriginY = bounds.midY - (sheetSize.height / 2) - let hostY = sheetOriginY + sheetSize.height - hostWindow.frame.height - hostWindow.setFrameOrigin(NSPoint(x: x, y: hostY)) - } else { - hostWindow.center() - } - - hostWindow.makeKeyAndOrderFront(nil) - alert.beginSheetModal(for: hostWindow) { [weak self] response in - Task { @MainActor [weak self] in - guard let self else { return } - self.activeRequestId = nil - self.activeAlert = nil - await self.handleAlertResponse(response, request: req) - hostWindow.orderOut(nil) - } - } - } - - private func handleAlertResponse(_ response: NSApplication.ModalResponse, request: PendingRequest) async { - var shouldRemove = response != .alertFirstButtonReturn - defer { - if shouldRemove { - if self.queue.first == request { - self.queue.removeFirst() - } else { - self.queue.removeAll { $0 == request } - } - } - self.updatePendingCounts() - self.isPresenting = false - self.presentNextIfNeeded() - } - - guard !self.isStopping else { return } - - if self.resolvedByRequestId.remove(request.requestId) != nil { - return - } - - switch response { - case .alertFirstButtonReturn: - shouldRemove = false - if let idx = self.queue.firstIndex(of: request) { - self.queue.remove(at: idx) - } - self.queue.append(request) - return - case .alertSecondButtonReturn: - _ = await self.approve(requestId: request.requestId) - case .alertThirdButtonReturn: - await self.reject(requestId: request.requestId) - default: - return - } - } - - private func approve(requestId: String) async -> Bool { - do { - try await GatewayConnection.shared.devicePairApprove(requestId: requestId) - self.logger.info("approved device pairing requestId=\(requestId, privacy: .public)") - return true - } catch { - self.logger.error("approve failed requestId=\(requestId, privacy: .public)") - self.logger.error("approve failed: \(error.localizedDescription, privacy: .public)") - return false - } - } - - private func reject(requestId: String) async { - do { - try await GatewayConnection.shared.devicePairReject(requestId: requestId) - self.logger.info("rejected device pairing requestId=\(requestId, privacy: .public)") - } catch { - self.logger.error("reject failed requestId=\(requestId, privacy: .public)") - self.logger.error("reject failed: \(error.localizedDescription, privacy: .public)") - } - } - - private func endActiveAlert() { - PairingAlertSupport.endActiveAlert(activeAlert: &self.activeAlert, activeRequestId: &self.activeRequestId) - } - - private func requireAlertHostWindow() -> NSWindow { - PairingAlertSupport.requireAlertHostWindow(alertHostWindow: &self.alertHostWindow) - } - - private func handle(push: GatewayPush) { - switch push { - case let .event(evt) where evt.event == "device.pair.requested": - guard let payload = evt.payload else { return } - do { - let req = try GatewayPayloadDecoding.decode(payload, as: PendingRequest.self) - self.enqueue(req) - } catch { - self.logger - .error("failed to decode device pairing request: \(error.localizedDescription, privacy: .public)") - } - case let .event(evt) where evt.event == "device.pair.resolved": - guard let payload = evt.payload else { return } - do { - let resolved = try GatewayPayloadDecoding.decode(payload, as: PairingResolvedEvent.self) - self.handleResolved(resolved) - } catch { - self.logger - .error( - "failed to decode device pairing resolution: \(error.localizedDescription, privacy: .public)") - } - default: - break - } - } - - private func enqueue(_ req: PendingRequest) { - guard !self.queue.contains(req) else { return } - self.queue.append(req) - self.updatePendingCounts() - self.presentNextIfNeeded() - } - - private func handleResolved(_ resolved: PairingResolvedEvent) { - let resolution = resolved.decision == PairingResolution.approved.rawValue ? PairingResolution - .approved : .rejected - if let activeRequestId, activeRequestId == resolved.requestId { - self.resolvedByRequestId.insert(resolved.requestId) - self.endActiveAlert() - let decision = resolution.rawValue - self.logger.info( - "device pairing resolved while active requestId=\(resolved.requestId, privacy: .public) " + - "decision=\(decision, privacy: .public)") - return - } - self.queue.removeAll { $0.requestId == resolved.requestId } - self.updatePendingCounts() - } - - private static func describe(_ req: PendingRequest) -> String { - var lines: [String] = [] - lines.append("Device: \(req.displayName ?? req.deviceId)") - if let platform = req.platform { - lines.append("Platform: \(platform)") - } - if let role = req.role { - lines.append("Role: \(role)") - } - if let scopes = req.scopes, !scopes.isEmpty { - lines.append("Scopes: \(scopes.joined(separator: ", "))") - } - if let remoteIp = req.remoteIp { - lines.append("IP: \(remoteIp)") - } - if req.isRepair == true { - lines.append("Repair: yes") - } - return lines.joined(separator: "\n") - } -} diff --git a/apps/macos/Sources/OpenClaw/DiagnosticsFileLog.swift b/apps/macos/Sources/OpenClaw/DiagnosticsFileLog.swift deleted file mode 100644 index 44baa738bdc..00000000000 --- a/apps/macos/Sources/OpenClaw/DiagnosticsFileLog.swift +++ /dev/null @@ -1,133 +0,0 @@ -import Foundation - -actor DiagnosticsFileLog { - static let shared = DiagnosticsFileLog() - - private let fileName = "diagnostics.jsonl" - private let maxBytes: Int64 = 5 * 1024 * 1024 - private let maxBackups = 5 - - struct Record: Codable, Sendable { - let ts: String - let pid: Int32 - let category: String - let event: String - let fields: [String: String]? - } - - nonisolated static func isEnabled() -> Bool { - UserDefaults.standard.bool(forKey: debugFileLogEnabledKey) - } - - nonisolated static func logDirectoryURL() -> URL { - let library = FileManager().urls(for: .libraryDirectory, in: .userDomainMask).first - ?? FileManager().homeDirectoryForCurrentUser.appendingPathComponent("Library", isDirectory: true) - return library - .appendingPathComponent("Logs", isDirectory: true) - .appendingPathComponent("OpenClaw", isDirectory: true) - } - - nonisolated static func logFileURL() -> URL { - self.logDirectoryURL().appendingPathComponent("diagnostics.jsonl", isDirectory: false) - } - - nonisolated func log(category: String, event: String, fields: [String: String]? = nil) { - guard Self.isEnabled() else { return } - let record = Record( - ts: ISO8601DateFormatter().string(from: Date()), - pid: ProcessInfo.processInfo.processIdentifier, - category: category, - event: event, - fields: fields) - Task { await self.write(record: record) } - } - - func clear() throws { - let fm = FileManager() - let base = Self.logFileURL() - if fm.fileExists(atPath: base.path) { - try fm.removeItem(at: base) - } - for idx in 1...self.maxBackups { - let url = self.rotatedURL(index: idx) - if fm.fileExists(atPath: url.path) { - try fm.removeItem(at: url) - } - } - } - - private func write(record: Record) { - do { - try self.ensureDirectory() - try self.rotateIfNeeded() - try self.append(record: record) - } catch { - // Best-effort only: never crash or block the app on logging. - } - } - - private func ensureDirectory() throws { - try FileManager().createDirectory( - at: Self.logDirectoryURL(), - withIntermediateDirectories: true) - } - - private func append(record: Record) throws { - let url = Self.logFileURL() - let data = try JSONEncoder().encode(record) - var line = Data() - line.append(data) - line.append(0x0A) // newline - - let fm = FileManager() - if !fm.fileExists(atPath: url.path) { - fm.createFile(atPath: url.path, contents: nil) - } - - let handle = try FileHandle(forWritingTo: url) - defer { try? handle.close() } - try handle.seekToEnd() - try handle.write(contentsOf: line) - } - - private func rotateIfNeeded() throws { - let url = Self.logFileURL() - guard let attrs = try? FileManager().attributesOfItem(atPath: url.path), - let size = attrs[.size] as? NSNumber - else { return } - - if size.int64Value < self.maxBytes { return } - - let fm = FileManager() - - let oldest = self.rotatedURL(index: self.maxBackups) - if fm.fileExists(atPath: oldest.path) { - try fm.removeItem(at: oldest) - } - - if self.maxBackups > 1 { - for idx in stride(from: self.maxBackups - 1, through: 1, by: -1) { - let src = self.rotatedURL(index: idx) - let dst = self.rotatedURL(index: idx + 1) - if fm.fileExists(atPath: src.path) { - if fm.fileExists(atPath: dst.path) { - try fm.removeItem(at: dst) - } - try fm.moveItem(at: src, to: dst) - } - } - } - - let first = self.rotatedURL(index: 1) - if fm.fileExists(atPath: first.path) { - try fm.removeItem(at: first) - } - if fm.fileExists(atPath: url.path) { - try fm.moveItem(at: url, to: first) - } - } - - private func rotatedURL(index: Int) -> URL { - Self.logDirectoryURL().appendingPathComponent("\(self.fileName).\(index)", isDirectory: false) - } -} diff --git a/apps/macos/Sources/OpenClaw/DockIconManager.swift b/apps/macos/Sources/OpenClaw/DockIconManager.swift deleted file mode 100644 index 98201393b75..00000000000 --- a/apps/macos/Sources/OpenClaw/DockIconManager.swift +++ /dev/null @@ -1,116 +0,0 @@ -import AppKit - -/// Central manager for Dock icon visibility. -/// Shows the Dock icon while any windows are visible, regardless of user preference. -final class DockIconManager: NSObject, @unchecked Sendable { - static let shared = DockIconManager() - - private var windowsObservation: NSKeyValueObservation? - private let logger = Logger(subsystem: "ai.openclaw", category: "DockIconManager") - - override private init() { - super.init() - self.setupObservers() - Task { @MainActor in - self.updateDockVisibility() - } - } - - deinit { - self.windowsObservation?.invalidate() - NotificationCenter.default.removeObserver(self) - } - - func updateDockVisibility() { - Task { @MainActor in - guard NSApp != nil else { - self.logger.warning("NSApp not ready, skipping Dock visibility update") - return - } - - let userWantsDockHidden = !UserDefaults.standard.bool(forKey: showDockIconKey) - let visibleWindows = NSApp?.windows.filter { window in - window.isVisible && - window.frame.width > 1 && - window.frame.height > 1 && - !window.isKind(of: NSPanel.self) && - "\(type(of: window))" != "NSPopupMenuWindow" && - window.contentViewController != nil - } ?? [] - - let hasVisibleWindows = !visibleWindows.isEmpty - if !userWantsDockHidden || hasVisibleWindows { - NSApp?.setActivationPolicy(.regular) - } else { - NSApp?.setActivationPolicy(.accessory) - } - } - } - - func temporarilyShowDock() { - Task { @MainActor in - guard NSApp != nil else { - self.logger.warning("NSApp not ready, cannot show Dock icon") - return - } - NSApp.setActivationPolicy(.regular) - } - } - - private func setupObservers() { - Task { @MainActor in - guard let app = NSApp else { - self.logger.warning("NSApp not ready, delaying Dock observers") - try? await Task.sleep(for: .milliseconds(200)) - self.setupObservers() - return - } - - self.windowsObservation = app.observe(\.windows, options: [.new]) { [weak self] _, _ in - Task { @MainActor in - try? await Task.sleep(for: .milliseconds(50)) - self?.updateDockVisibility() - } - } - - NotificationCenter.default.addObserver( - self, - selector: #selector(self.windowVisibilityChanged), - name: NSWindow.didBecomeKeyNotification, - object: nil) - NotificationCenter.default.addObserver( - self, - selector: #selector(self.windowVisibilityChanged), - name: NSWindow.didResignKeyNotification, - object: nil) - NotificationCenter.default.addObserver( - self, - selector: #selector(self.windowVisibilityChanged), - name: NSWindow.willCloseNotification, - object: nil) - NotificationCenter.default.addObserver( - self, - selector: #selector(self.dockPreferenceChanged), - name: UserDefaults.didChangeNotification, - object: nil) - } - } - - @objc - private func windowVisibilityChanged(_: Notification) { - Task { @MainActor in - self.updateDockVisibility() - } - } - - @objc - private func dockPreferenceChanged(_ notification: Notification) { - guard let userDefaults = notification.object as? UserDefaults, - userDefaults == UserDefaults.standard - else { return } - - Task { @MainActor in - self.updateDockVisibility() - } - } -} diff --git a/apps/macos/Sources/OpenClaw/ExecAllowlistMatcher.swift b/apps/macos/Sources/OpenClaw/ExecAllowlistMatcher.swift deleted file mode 100644 index 2dd720741bb..00000000000 --- a/apps/macos/Sources/OpenClaw/ExecAllowlistMatcher.swift +++ /dev/null @@ -1,79 +0,0 @@ -import Foundation - -enum ExecAllowlistMatcher { - static func match(entries: [ExecAllowlistEntry], resolution: ExecCommandResolution?) -> ExecAllowlistEntry? { - guard let resolution, !entries.isEmpty else { return nil } - let rawExecutable = resolution.rawExecutable - let resolvedPath = resolution.resolvedPath - - for entry in entries { - switch ExecApprovalHelpers.validateAllowlistPattern(entry.pattern) { - case .valid(let pattern): - let target = resolvedPath ?? rawExecutable - if self.matches(pattern: pattern, target: target) { return entry } - case .invalid: - continue - } - } - return nil - } - - static func matchAll( - entries: [ExecAllowlistEntry], - resolutions: [ExecCommandResolution]) -> [ExecAllowlistEntry] - { - guard !entries.isEmpty, !resolutions.isEmpty else { return [] } - var matches: [ExecAllowlistEntry] = [] - matches.reserveCapacity(resolutions.count) - for resolution in resolutions { - guard let match = self.match(entries: entries, resolution: resolution) else { - return [] - } - matches.append(match) - } - return matches - } - - private static func matches(pattern: String, target: String) -> Bool { - let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return false } - let expanded = trimmed.hasPrefix("~") ? (trimmed as NSString).expandingTildeInPath : trimmed - let normalizedPattern = self.normalizeMatchTarget(expanded) - let normalizedTarget = self.normalizeMatchTarget(target) - guard let regex = self.regex(for: normalizedPattern) else { return false } - let range = NSRange(location: 0, length: normalizedTarget.utf16.count) - return regex.firstMatch(in: normalizedTarget, options: [], range: range) != nil - } - - private static func normalizeMatchTarget(_ value: String) -> String { - value.replacingOccurrences(of: "\\\\", with: "/").lowercased() - } - - private static func regex(for pattern: String) -> NSRegularExpression? { - var regex = "^" - var idx = pattern.startIndex - while idx < pattern.endIndex { - let ch = pattern[idx] - if ch == "*" { - let next = pattern.index(after: idx) - if next < pattern.endIndex, pattern[next] == "*" { - regex += ".*" - idx = pattern.index(after: next) - } else { - regex += "[^/]*" - idx = next - } - continue - } - if ch == "?" { - regex += "." - idx = pattern.index(after: idx) - continue - } - regex += NSRegularExpression.escapedPattern(for: String(ch)) - idx = pattern.index(after: idx) - } - regex += "$" - return try? NSRegularExpression(pattern: regex, options: [.caseInsensitive]) - } -} diff --git a/apps/macos/Sources/OpenClaw/ExecApprovalEvaluation.swift b/apps/macos/Sources/OpenClaw/ExecApprovalEvaluation.swift deleted file mode 100644 index 7bb05aff0c9..00000000000 --- a/apps/macos/Sources/OpenClaw/ExecApprovalEvaluation.swift +++ /dev/null @@ -1,67 +0,0 @@ -import Foundation - -struct ExecApprovalEvaluation { - let command: [String] - let displayCommand: String - let agentId: String? - let security: ExecSecurity - let ask: ExecAsk - let env: [String: String] - let resolution: ExecCommandResolution? - let allowlistResolutions: [ExecCommandResolution] - let allowlistMatches: [ExecAllowlistEntry] - let allowlistSatisfied: Bool - let allowlistMatch: ExecAllowlistEntry? - let skillAllow: Bool -} - -enum ExecApprovalEvaluator { - static func evaluate( - command: [String], - rawCommand: String?, - cwd: String?, - envOverrides: [String: String]?, - agentId: String?) async -> ExecApprovalEvaluation - { - let trimmedAgent = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) - let normalizedAgentId = (trimmedAgent?.isEmpty == false) ? trimmedAgent : nil - let approvals = ExecApprovalsStore.resolve(agentId: normalizedAgentId) - let security = approvals.agent.security - let ask = approvals.agent.ask - let env = HostEnvSanitizer.sanitize(overrides: envOverrides) - let displayCommand = ExecCommandFormatter.displayString(for: command, rawCommand: rawCommand) - let allowlistResolutions = ExecCommandResolution.resolveForAllowlist( - command: command, - rawCommand: rawCommand, - cwd: cwd, - env: env) - let allowlistMatches = security == .allowlist - ? ExecAllowlistMatcher.matchAll(entries: approvals.allowlist, resolutions: allowlistResolutions) - : [] - let allowlistSatisfied = security == .allowlist && - !allowlistResolutions.isEmpty && - allowlistMatches.count == allowlistResolutions.count - - let skillAllow: Bool - if approvals.agent.autoAllowSkills, !allowlistResolutions.isEmpty { - let bins = await SkillBinsCache.shared.currentBins() - skillAllow = allowlistResolutions.allSatisfy { bins.contains($0.executableName) } - } else { - skillAllow = false - } - - return ExecApprovalEvaluation( - command: command, - displayCommand: displayCommand, - agentId: normalizedAgentId, - security: security, - ask: ask, - env: env, - resolution: allowlistResolutions.first, - allowlistResolutions: allowlistResolutions, - allowlistMatches: allowlistMatches, - allowlistSatisfied: allowlistSatisfied, - allowlistMatch: allowlistSatisfied ? allowlistMatches.first : nil, - skillAllow: skillAllow) - } -} diff --git a/apps/macos/Sources/OpenClaw/ExecApprovals.swift b/apps/macos/Sources/OpenClaw/ExecApprovals.swift deleted file mode 100644 index 08567cd0b09..00000000000 --- a/apps/macos/Sources/OpenClaw/ExecApprovals.swift +++ /dev/null @@ -1,794 +0,0 @@ -import CryptoKit -import Foundation -import OSLog -import Security - -enum ExecSecurity: String, CaseIterable, Codable, Identifiable { - case deny - case allowlist - case full - - var id: String { - self.rawValue - } - - var title: String { - switch self { - case .deny: "Deny" - case .allowlist: "Allowlist" - case .full: "Always Allow" - } - } -} - -enum ExecApprovalQuickMode: String, CaseIterable, Identifiable { - case deny - case ask - case allow - - var id: String { - self.rawValue - } - - var title: String { - switch self { - case .deny: "Deny" - case .ask: "Always Ask" - case .allow: "Always Allow" - } - } - - var security: ExecSecurity { - switch self { - case .deny: .deny - case .ask: .allowlist - case .allow: .full - } - } - - var ask: ExecAsk { - switch self { - case .deny: .off - case .ask: .onMiss - case .allow: .off - } - } - - static func from(security: ExecSecurity, ask: ExecAsk) -> ExecApprovalQuickMode { - switch security { - case .deny: - .deny - case .full: - .allow - case .allowlist: - .ask - } - } -} - -enum ExecAsk: String, CaseIterable, Codable, Identifiable { - case off - case onMiss = "on-miss" - case always - - var id: String { - self.rawValue - } - - var title: String { - switch self { - case .off: "Never Ask" - case .onMiss: "Ask on Allowlist Miss" - case .always: "Always Ask" - } - } -} - -enum ExecApprovalDecision: String, Codable, Sendable { - case allowOnce = "allow-once" - case allowAlways = "allow-always" - case deny -} - -enum ExecAllowlistPatternValidationReason: String, Codable, Sendable, Equatable { - case empty - case missingPathComponent - - var message: String { - switch self { - case .empty: - "Pattern cannot be empty." - case .missingPathComponent: - "Path patterns only. Include '/', '~', or '\\\\'." - } - } -} - -enum ExecAllowlistPatternValidation: Sendable, Equatable { - case valid(String) - case invalid(ExecAllowlistPatternValidationReason) -} - -struct ExecAllowlistRejectedEntry: Sendable, Equatable { - let id: UUID - let pattern: String - let reason: ExecAllowlistPatternValidationReason -} - -struct ExecAllowlistEntry: Codable, Hashable, Identifiable { - var id: UUID - var pattern: String - var lastUsedAt: Double? - var lastUsedCommand: String? - var lastResolvedPath: String? - - init( - id: UUID = UUID(), - pattern: String, - lastUsedAt: Double? = nil, - lastUsedCommand: String? = nil, - lastResolvedPath: String? = nil) - { - self.id = id - self.pattern = pattern - self.lastUsedAt = lastUsedAt - self.lastUsedCommand = lastUsedCommand - self.lastResolvedPath = lastResolvedPath - } - - private enum CodingKeys: String, CodingKey { - case id - case pattern - case lastUsedAt - case lastUsedCommand - case lastResolvedPath - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() - self.pattern = try container.decode(String.self, forKey: .pattern) - self.lastUsedAt = try container.decodeIfPresent(Double.self, forKey: .lastUsedAt) - self.lastUsedCommand = try container.decodeIfPresent(String.self, forKey: .lastUsedCommand) - self.lastResolvedPath = try container.decodeIfPresent(String.self, forKey: .lastResolvedPath) - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(self.id, forKey: .id) - try container.encode(self.pattern, forKey: .pattern) - try container.encodeIfPresent(self.lastUsedAt, forKey: .lastUsedAt) - try container.encodeIfPresent(self.lastUsedCommand, forKey: .lastUsedCommand) - try container.encodeIfPresent(self.lastResolvedPath, forKey: .lastResolvedPath) - } -} - -struct ExecApprovalsDefaults: Codable { - var security: ExecSecurity? - var ask: ExecAsk? - var askFallback: ExecSecurity? - var autoAllowSkills: Bool? -} - -struct ExecApprovalsAgent: Codable { - var security: ExecSecurity? - var ask: ExecAsk? - var askFallback: ExecSecurity? - var autoAllowSkills: Bool? - var allowlist: [ExecAllowlistEntry]? - - var isEmpty: Bool { - self.security == nil && self.ask == nil && self.askFallback == nil && self - .autoAllowSkills == nil && (self.allowlist?.isEmpty ?? true) - } -} - -struct ExecApprovalsSocketConfig: Codable { - var path: String? - var token: String? -} - -struct ExecApprovalsFile: Codable { - var version: Int - var socket: ExecApprovalsSocketConfig? - var defaults: ExecApprovalsDefaults? - var agents: [String: ExecApprovalsAgent]? -} - -struct ExecApprovalsSnapshot: Codable { - var path: String - var exists: Bool - var hash: String - var file: ExecApprovalsFile -} - -struct ExecApprovalsResolved { - let url: URL - let socketPath: String - let token: String - let defaults: ExecApprovalsResolvedDefaults - let agent: ExecApprovalsResolvedDefaults - let allowlist: [ExecAllowlistEntry] - var file: ExecApprovalsFile -} - -struct ExecApprovalsResolvedDefaults { - var security: ExecSecurity - var ask: ExecAsk - var askFallback: ExecSecurity - var autoAllowSkills: Bool -} - -enum ExecApprovalsStore { - private static let logger = Logger(subsystem: "ai.openclaw", category: "exec-approvals") - private static let defaultAgentId = "main" - private static let defaultSecurity: ExecSecurity = .deny - private static let defaultAsk: ExecAsk = .onMiss - private static let defaultAskFallback: ExecSecurity = .deny - private static let defaultAutoAllowSkills = false - - static func fileURL() -> URL { - OpenClawPaths.stateDirURL.appendingPathComponent("exec-approvals.json") - } - - static func socketPath() -> String { - OpenClawPaths.stateDirURL.appendingPathComponent("exec-approvals.sock").path - } - - static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile { - let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - var agents = file.agents ?? [:] - if let legacyDefault = agents["default"] { - if let main = agents[self.defaultAgentId] { - agents[self.defaultAgentId] = self.mergeAgents(current: main, legacy: legacyDefault) - } else { - agents[self.defaultAgentId] = legacyDefault - } - agents.removeValue(forKey: "default") - } - if !agents.isEmpty { - var normalizedAgents: [String: ExecApprovalsAgent] = [:] - normalizedAgents.reserveCapacity(agents.count) - for (key, var agent) in agents { - if let allowlist = agent.allowlist { - let normalized = self.normalizeAllowlistEntries(allowlist, dropInvalid: false).entries - agent.allowlist = normalized.isEmpty ? nil : normalized - } - normalizedAgents[key] = agent - } - agents = normalizedAgents - } - return ExecApprovalsFile( - version: 1, - socket: ExecApprovalsSocketConfig( - path: socketPath.isEmpty ? nil : socketPath, - token: token.isEmpty ? nil : token), - defaults: file.defaults, - agents: agents.isEmpty ? nil : agents) - } - - static func readSnapshot() -> ExecApprovalsSnapshot { - let url = self.fileURL() - guard FileManager().fileExists(atPath: url.path) else { - return ExecApprovalsSnapshot( - path: url.path, - exists: false, - hash: self.hashRaw(nil), - file: ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])) - } - let raw = try? String(contentsOf: url, encoding: .utf8) - let data = raw.flatMap { $0.data(using: .utf8) } - let decoded: ExecApprovalsFile = { - if let data, let file = try? JSONDecoder().decode(ExecApprovalsFile.self, from: data), file.version == 1 { - return file - } - return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]) - }() - return ExecApprovalsSnapshot( - path: url.path, - exists: true, - hash: self.hashRaw(raw), - file: decoded) - } - - static func redactForSnapshot(_ file: ExecApprovalsFile) -> ExecApprovalsFile { - let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if socketPath.isEmpty { - return ExecApprovalsFile( - version: file.version, - socket: nil, - defaults: file.defaults, - agents: file.agents) - } - return ExecApprovalsFile( - version: file.version, - socket: ExecApprovalsSocketConfig(path: socketPath, token: nil), - defaults: file.defaults, - agents: file.agents) - } - - static func loadFile() -> ExecApprovalsFile { - let url = self.fileURL() - guard FileManager().fileExists(atPath: url.path) else { - return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]) - } - do { - let data = try Data(contentsOf: url) - let decoded = try JSONDecoder().decode(ExecApprovalsFile.self, from: data) - if decoded.version != 1 { - return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]) - } - return decoded - } catch { - self.logger.warning("exec approvals load failed: \(error.localizedDescription, privacy: .public)") - return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]) - } - } - - static func saveFile(_ file: ExecApprovalsFile) { - do { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(file) - let url = self.fileURL() - try FileManager().createDirectory( - at: url.deletingLastPathComponent(), - withIntermediateDirectories: true) - try data.write(to: url, options: [.atomic]) - try? FileManager().setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) - } catch { - self.logger.error("exec approvals save failed: \(error.localizedDescription, privacy: .public)") - } - } - - static func ensureFile() -> ExecApprovalsFile { - let url = self.fileURL() - let existed = FileManager().fileExists(atPath: url.path) - let loaded = self.loadFile() - let loadedHash = self.hashFile(loaded) - - var file = self.normalizeIncoming(loaded) - if file.socket == nil { file.socket = ExecApprovalsSocketConfig(path: nil, token: nil) } - let path = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if path.isEmpty { - file.socket?.path = self.socketPath() - } - let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if token.isEmpty { - file.socket?.token = self.generateToken() - } - if file.agents == nil { file.agents = [:] } - if !existed || loadedHash != self.hashFile(file) { - self.saveFile(file) - } - return file - } - - static func resolve(agentId: String?) -> ExecApprovalsResolved { - let file = self.ensureFile() - let defaults = file.defaults ?? ExecApprovalsDefaults() - let resolvedDefaults = ExecApprovalsResolvedDefaults( - security: defaults.security ?? self.defaultSecurity, - ask: defaults.ask ?? self.defaultAsk, - askFallback: defaults.askFallback ?? self.defaultAskFallback, - autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills) - let key = self.agentKey(agentId) - let agentEntry = file.agents?[key] ?? ExecApprovalsAgent() - let wildcardEntry = file.agents?["*"] ?? ExecApprovalsAgent() - let resolvedAgent = ExecApprovalsResolvedDefaults( - security: agentEntry.security ?? wildcardEntry.security ?? resolvedDefaults.security, - ask: agentEntry.ask ?? wildcardEntry.ask ?? resolvedDefaults.ask, - askFallback: agentEntry.askFallback ?? wildcardEntry.askFallback - ?? resolvedDefaults.askFallback, - autoAllowSkills: agentEntry.autoAllowSkills ?? wildcardEntry.autoAllowSkills - ?? resolvedDefaults.autoAllowSkills) - let allowlist = self.normalizeAllowlistEntries( - (wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? []), - dropInvalid: true).entries - let socketPath = self.expandPath(file.socket?.path ?? self.socketPath()) - let token = file.socket?.token ?? "" - return ExecApprovalsResolved( - url: self.fileURL(), - socketPath: socketPath, - token: token, - defaults: resolvedDefaults, - agent: resolvedAgent, - allowlist: allowlist, - file: file) - } - - static func resolveDefaults() -> ExecApprovalsResolvedDefaults { - let file = self.ensureFile() - let defaults = file.defaults ?? ExecApprovalsDefaults() - return ExecApprovalsResolvedDefaults( - security: defaults.security ?? self.defaultSecurity, - ask: defaults.ask ?? self.defaultAsk, - askFallback: defaults.askFallback ?? self.defaultAskFallback, - autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills) - } - - static func saveDefaults(_ defaults: ExecApprovalsDefaults) { - self.updateFile { file in - file.defaults = defaults - } - } - - static func updateDefaults(_ mutate: (inout ExecApprovalsDefaults) -> Void) { - self.updateFile { file in - var defaults = file.defaults ?? ExecApprovalsDefaults() - mutate(&defaults) - file.defaults = defaults - } - } - - static func saveAgent(_ agent: ExecApprovalsAgent, agentId: String?) { - self.updateFile { file in - var agents = file.agents ?? [:] - let key = self.agentKey(agentId) - if agent.isEmpty { - agents.removeValue(forKey: key) - } else { - agents[key] = agent - } - file.agents = agents.isEmpty ? nil : agents - } - } - - @discardableResult - static func addAllowlistEntry(agentId: String?, pattern: String) -> ExecAllowlistPatternValidationReason? { - let normalizedPattern: String - switch ExecApprovalHelpers.validateAllowlistPattern(pattern) { - case .valid(let validPattern): - normalizedPattern = validPattern - case .invalid(let reason): - return reason - } - - self.updateFile { file in - let key = self.agentKey(agentId) - var agents = file.agents ?? [:] - var entry = agents[key] ?? ExecApprovalsAgent() - var allowlist = entry.allowlist ?? [] - if allowlist.contains(where: { $0.pattern == normalizedPattern }) { return } - allowlist.append(ExecAllowlistEntry( - pattern: normalizedPattern, - lastUsedAt: Date().timeIntervalSince1970 * 1000)) - entry.allowlist = allowlist - agents[key] = entry - file.agents = agents - } - return nil - } - - static func recordAllowlistUse( - agentId: String?, - pattern: String, - command: String, - resolvedPath: String?) - { - self.updateFile { file in - let key = self.agentKey(agentId) - var agents = file.agents ?? [:] - var entry = agents[key] ?? ExecApprovalsAgent() - let allowlist = (entry.allowlist ?? []).map { item -> ExecAllowlistEntry in - guard item.pattern == pattern else { return item } - return ExecAllowlistEntry( - id: item.id, - pattern: item.pattern, - lastUsedAt: Date().timeIntervalSince1970 * 1000, - lastUsedCommand: command, - lastResolvedPath: resolvedPath) - } - entry.allowlist = allowlist - agents[key] = entry - file.agents = agents - } - } - - @discardableResult - static func updateAllowlist(agentId: String?, allowlist: [ExecAllowlistEntry]) -> [ExecAllowlistRejectedEntry] { - var rejected: [ExecAllowlistRejectedEntry] = [] - self.updateFile { file in - let key = self.agentKey(agentId) - var agents = file.agents ?? [:] - var entry = agents[key] ?? ExecApprovalsAgent() - let normalized = self.normalizeAllowlistEntries(allowlist, dropInvalid: true) - rejected = normalized.rejected - let cleaned = normalized.entries - entry.allowlist = cleaned - agents[key] = entry - file.agents = agents - } - return rejected - } - - static func updateAgentSettings(agentId: String?, mutate: (inout ExecApprovalsAgent) -> Void) { - self.updateFile { file in - let key = self.agentKey(agentId) - var agents = file.agents ?? [:] - var entry = agents[key] ?? ExecApprovalsAgent() - mutate(&entry) - if entry.isEmpty { - agents.removeValue(forKey: key) - } else { - agents[key] = entry - } - file.agents = agents.isEmpty ? nil : agents - } - } - - private static func updateFile(_ mutate: (inout ExecApprovalsFile) -> Void) { - var file = self.ensureFile() - mutate(&file) - self.saveFile(file) - } - - private static func generateToken() -> String { - var bytes = [UInt8](repeating: 0, count: 24) - let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) - if status == errSecSuccess { - return Data(bytes) - .base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } - return UUID().uuidString - } - - private static func hashRaw(_ raw: String?) -> String { - let data = Data((raw ?? "").utf8) - let digest = SHA256.hash(data: data) - return digest.map { String(format: "%02x", $0) }.joined() - } - - private static func hashFile(_ file: ExecApprovalsFile) -> String { - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - let data = (try? encoder.encode(file)) ?? Data() - let digest = SHA256.hash(data: data) - return digest.map { String(format: "%02x", $0) }.joined() - } - - private static func expandPath(_ raw: String) -> String { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed == "~" { - return FileManager().homeDirectoryForCurrentUser.path - } - if trimmed.hasPrefix("~/") { - let suffix = trimmed.dropFirst(2) - return FileManager().homeDirectoryForCurrentUser - .appendingPathComponent(String(suffix)).path - } - return trimmed - } - - private static func agentKey(_ agentId: String?) -> String { - let trimmed = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return trimmed.isEmpty ? self.defaultAgentId : trimmed - } - - private static func normalizedPattern(_ pattern: String?) -> String? { - switch ExecApprovalHelpers.validateAllowlistPattern(pattern) { - case .valid(let normalized): - return normalized.lowercased() - case .invalid(.empty): - return nil - case .invalid: - let trimmed = pattern?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return trimmed.isEmpty ? nil : trimmed.lowercased() - } - } - - private static func migrateLegacyPattern(_ entry: ExecAllowlistEntry) -> ExecAllowlistEntry { - let trimmedPattern = entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines) - let trimmedResolved = entry.lastResolvedPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let normalizedResolved = trimmedResolved.isEmpty ? nil : trimmedResolved - - switch ExecApprovalHelpers.validateAllowlistPattern(trimmedPattern) { - case .valid(let pattern): - return ExecAllowlistEntry( - id: entry.id, - pattern: pattern, - lastUsedAt: entry.lastUsedAt, - lastUsedCommand: entry.lastUsedCommand, - lastResolvedPath: normalizedResolved) - case .invalid: - switch ExecApprovalHelpers.validateAllowlistPattern(trimmedResolved) { - case .valid(let migratedPattern): - return ExecAllowlistEntry( - id: entry.id, - pattern: migratedPattern, - lastUsedAt: entry.lastUsedAt, - lastUsedCommand: entry.lastUsedCommand, - lastResolvedPath: normalizedResolved) - case .invalid: - return ExecAllowlistEntry( - id: entry.id, - pattern: trimmedPattern, - lastUsedAt: entry.lastUsedAt, - lastUsedCommand: entry.lastUsedCommand, - lastResolvedPath: normalizedResolved) - } - } - } - - private static func normalizeAllowlistEntries( - _ entries: [ExecAllowlistEntry], - dropInvalid: Bool) -> (entries: [ExecAllowlistEntry], rejected: [ExecAllowlistRejectedEntry]) - { - var normalized: [ExecAllowlistEntry] = [] - normalized.reserveCapacity(entries.count) - var rejected: [ExecAllowlistRejectedEntry] = [] - - for entry in entries { - let migrated = self.migrateLegacyPattern(entry) - let trimmedPattern = migrated.pattern.trimmingCharacters(in: .whitespacesAndNewlines) - let trimmedResolvedPath = migrated.lastResolvedPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let normalizedResolvedPath = trimmedResolvedPath.isEmpty ? nil : trimmedResolvedPath - - switch ExecApprovalHelpers.validateAllowlistPattern(trimmedPattern) { - case .valid(let pattern): - normalized.append( - ExecAllowlistEntry( - id: migrated.id, - pattern: pattern, - lastUsedAt: migrated.lastUsedAt, - lastUsedCommand: migrated.lastUsedCommand, - lastResolvedPath: normalizedResolvedPath)) - case .invalid(let reason): - if dropInvalid { - rejected.append( - ExecAllowlistRejectedEntry( - id: migrated.id, - pattern: trimmedPattern, - reason: reason)) - } else if reason != .empty { - normalized.append( - ExecAllowlistEntry( - id: migrated.id, - pattern: trimmedPattern, - lastUsedAt: migrated.lastUsedAt, - lastUsedCommand: migrated.lastUsedCommand, - lastResolvedPath: normalizedResolvedPath)) - } - } - } - - return (normalized, rejected) - } - - private static func mergeAgents( - current: ExecApprovalsAgent, - legacy: ExecApprovalsAgent) -> ExecApprovalsAgent - { - let currentAllowlist = self.normalizeAllowlistEntries(current.allowlist ?? [], dropInvalid: false).entries - let legacyAllowlist = self.normalizeAllowlistEntries(legacy.allowlist ?? [], dropInvalid: false).entries - var seen = Set() - var allowlist: [ExecAllowlistEntry] = [] - func append(_ entry: ExecAllowlistEntry) { - guard let key = self.normalizedPattern(entry.pattern), !seen.contains(key) else { - return - } - seen.insert(key) - allowlist.append(entry) - } - for entry in currentAllowlist { - append(entry) - } - for entry in legacyAllowlist { - append(entry) - } - - return ExecApprovalsAgent( - security: current.security ?? legacy.security, - ask: current.ask ?? legacy.ask, - askFallback: current.askFallback ?? legacy.askFallback, - autoAllowSkills: current.autoAllowSkills ?? legacy.autoAllowSkills, - allowlist: allowlist.isEmpty ? nil : allowlist) - } -} - -enum ExecApprovalHelpers { - static func validateAllowlistPattern(_ pattern: String?) -> ExecAllowlistPatternValidation { - let trimmed = pattern?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !trimmed.isEmpty else { return .invalid(.empty) } - guard self.containsPathComponent(trimmed) else { return .invalid(.missingPathComponent) } - return .valid(trimmed) - } - - static func isPathPattern(_ pattern: String?) -> Bool { - switch self.validateAllowlistPattern(pattern) { - case .valid: - true - case .invalid: - false - } - } - - static func parseDecision(_ raw: String?) -> ExecApprovalDecision? { - let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !trimmed.isEmpty else { return nil } - return ExecApprovalDecision(rawValue: trimmed) - } - - static func requiresAsk( - ask: ExecAsk, - security: ExecSecurity, - allowlistMatch: ExecAllowlistEntry?, - skillAllow: Bool) -> Bool - { - if ask == .always { return true } - if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true } - return false - } - - static func allowlistPattern(command: [String], resolution: ExecCommandResolution?) -> String? { - let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? "" - return pattern.isEmpty ? nil : pattern - } - - private static func containsPathComponent(_ pattern: String) -> Bool { - pattern.contains("/") || pattern.contains("~") || pattern.contains("\\") - } -} - -struct ExecEventPayload: Codable, Sendable { - var sessionKey: String - var runId: String - var host: String - var command: String? - var exitCode: Int? - var timedOut: Bool? - var success: Bool? - var output: String? - var reason: String? - - static func truncateOutput(_ raw: String, maxChars: Int = 20000) -> String? { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - if trimmed.count <= maxChars { return trimmed } - let suffix = trimmed.suffix(maxChars) - return "... (truncated) \(suffix)" - } -} - -actor SkillBinsCache { - static let shared = SkillBinsCache() - - private var bins: Set = [] - private var lastRefresh: Date? - private let refreshInterval: TimeInterval = 90 - - func currentBins(force: Bool = false) async -> Set { - if force || self.isStale() { - await self.refresh() - } - return self.bins - } - - func refresh() async { - do { - let report = try await GatewayConnection.shared.skillsStatus() - var next = Set() - for skill in report.skills { - for bin in skill.requirements.bins { - let trimmed = bin.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { next.insert(trimmed) } - } - } - self.bins = next - self.lastRefresh = Date() - } catch { - if self.lastRefresh == nil { - self.bins = [] - } - } - } - - private func isStale() -> Bool { - guard let lastRefresh else { return true } - return Date().timeIntervalSince(lastRefresh) > self.refreshInterval - } -} diff --git a/apps/macos/Sources/OpenClaw/ExecApprovalsGatewayPrompter.swift b/apps/macos/Sources/OpenClaw/ExecApprovalsGatewayPrompter.swift deleted file mode 100644 index 670fa891c5b..00000000000 --- a/apps/macos/Sources/OpenClaw/ExecApprovalsGatewayPrompter.swift +++ /dev/null @@ -1,123 +0,0 @@ -import CoreGraphics -import Foundation -import OpenClawKit -import OpenClawProtocol -import OSLog - -@MainActor -final class ExecApprovalsGatewayPrompter { - static let shared = ExecApprovalsGatewayPrompter() - - private let logger = Logger(subsystem: "ai.openclaw", category: "exec-approvals.gateway") - private var task: Task? - - struct GatewayApprovalRequest: Codable, Sendable { - var id: String - var request: ExecApprovalPromptRequest - var createdAtMs: Int - var expiresAtMs: Int - } - - func start() { - guard self.task == nil else { return } - self.task = Task { [weak self] in - await self?.run() - } - } - - func stop() { - self.task?.cancel() - self.task = nil - } - - private func run() async { - let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200) - for await push in stream { - if Task.isCancelled { return } - await self.handle(push: push) - } - } - - private func handle(push: GatewayPush) async { - guard case let .event(evt) = push else { return } - guard evt.event == "exec.approval.requested" else { return } - guard let payload = evt.payload else { return } - do { - let data = try JSONEncoder().encode(payload) - let request = try JSONDecoder().decode(GatewayApprovalRequest.self, from: data) - guard self.shouldPresent(request: request) else { return } - let decision = ExecApprovalsPromptPresenter.prompt(request.request) - try await GatewayConnection.shared.requestVoid( - method: .execApprovalResolve, - params: [ - "id": AnyCodable(request.id), - "decision": AnyCodable(decision.rawValue), - ], - timeoutMs: 10000) - } catch { - self.logger.error("exec approval handling failed \(error.localizedDescription, privacy: .public)") - } - } - - private func shouldPresent(request: GatewayApprovalRequest) -> Bool { - let mode = AppStateStore.shared.connectionMode - let activeSession = WebChatManager.shared.activeSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines) - let requestSession = request.request.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines) - return Self.shouldPresent( - mode: mode, - activeSession: activeSession, - requestSession: requestSession, - lastInputSeconds: Self.lastInputSeconds(), - thresholdSeconds: 120) - } - - private static func shouldPresent( - mode: AppState.ConnectionMode, - activeSession: String?, - requestSession: String?, - lastInputSeconds: Int?, - thresholdSeconds: Int) -> Bool - { - let active = activeSession?.trimmingCharacters(in: .whitespacesAndNewlines) - let requested = requestSession?.trimmingCharacters(in: .whitespacesAndNewlines) - let recentlyActive = lastInputSeconds.map { $0 <= thresholdSeconds } ?? (mode == .local) - - if let session = requested, !session.isEmpty { - if let active, !active.isEmpty { - return active == session - } - return recentlyActive - } - - if let active, !active.isEmpty { - return true - } - return mode == .local - } - - private static func lastInputSeconds() -> Int? { - let anyEvent = CGEventType(rawValue: UInt32.max) ?? .null - let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyEvent) - if seconds.isNaN || seconds.isInfinite || seconds < 0 { return nil } - return Int(seconds.rounded()) - } -} - -#if DEBUG -extension ExecApprovalsGatewayPrompter { - static func _testShouldPresent( - mode: AppState.ConnectionMode, - activeSession: String?, - requestSession: String?, - lastInputSeconds: Int?, - thresholdSeconds: Int = 120) -> Bool - { - self.shouldPresent( - mode: mode, - activeSession: activeSession, - requestSession: requestSession, - lastInputSeconds: lastInputSeconds, - thresholdSeconds: thresholdSeconds) - } -} -#endif diff --git a/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift b/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift deleted file mode 100644 index 362a7da01d8..00000000000 --- a/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift +++ /dev/null @@ -1,780 +0,0 @@ -import AppKit -import CryptoKit -import Darwin -import Foundation -import OpenClawKit -import OSLog - -struct ExecApprovalPromptRequest: Codable, Sendable { - var command: String - var cwd: String? - var host: String? - var security: String? - var ask: String? - var agentId: String? - var resolvedPath: String? - var sessionKey: String? -} - -private struct ExecApprovalSocketRequest: Codable { - var type: String - var token: String - var id: String - var request: ExecApprovalPromptRequest -} - -private struct ExecApprovalSocketDecision: Codable { - var type: String - var id: String - var decision: ExecApprovalDecision -} - -private struct ExecHostSocketRequest: Codable { - var type: String - var id: String - var nonce: String - var ts: Int - var hmac: String - var requestJson: String -} - -private struct ExecHostRequest: Codable { - var command: [String] - var rawCommand: String? - var cwd: String? - var env: [String: String]? - var timeoutMs: Int? - var needsScreenRecording: Bool? - var agentId: String? - var sessionKey: String? - var approvalDecision: ExecApprovalDecision? -} - -private struct ExecHostRunResult: Codable { - var exitCode: Int? - var timedOut: Bool - var success: Bool - var stdout: String - var stderr: String - var error: String? -} - -private struct ExecHostError: Codable { - var code: String - var message: String - var reason: String? -} - -private struct ExecHostResponse: Codable { - var type: String - var id: String - var ok: Bool - var payload: ExecHostRunResult? - var error: ExecHostError? -} - -enum ExecApprovalsSocketClient { - private struct TimeoutError: LocalizedError { - var message: String - var errorDescription: String? { - self.message - } - } - - static func requestDecision( - socketPath: String, - token: String, - request: ExecApprovalPromptRequest, - timeoutMs: Int = 15000) async -> ExecApprovalDecision? - { - let trimmedPath = socketPath.trimmingCharacters(in: .whitespacesAndNewlines) - let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedPath.isEmpty, !trimmedToken.isEmpty else { return nil } - do { - return try await AsyncTimeout.withTimeoutMs( - timeoutMs: timeoutMs, - onTimeout: { - TimeoutError(message: "exec approvals socket timeout") - }, - operation: { - try await Task.detached { - try self.requestDecisionSync( - socketPath: trimmedPath, - token: trimmedToken, - request: request) - }.value - }) - } catch { - return nil - } - } - - private static func requestDecisionSync( - socketPath: String, - token: String, - request: ExecApprovalPromptRequest) throws -> ExecApprovalDecision? - { - let fd = socket(AF_UNIX, SOCK_STREAM, 0) - guard fd >= 0 else { - throw NSError(domain: "ExecApprovals", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "socket create failed", - ]) - } - - var addr = sockaddr_un() - addr.sun_family = sa_family_t(AF_UNIX) - let maxLen = MemoryLayout.size(ofValue: addr.sun_path) - if socketPath.utf8.count >= maxLen { - throw NSError(domain: "ExecApprovals", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "socket path too long", - ]) - } - socketPath.withCString { cstr in - withUnsafeMutablePointer(to: &addr.sun_path) { ptr in - let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: Int8.self) - strncpy(raw, cstr, maxLen - 1) - } - } - let size = socklen_t(MemoryLayout.size(ofValue: addr)) - let result = withUnsafePointer(to: &addr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in - connect(fd, rebound, size) - } - } - if result != 0 { - throw NSError(domain: "ExecApprovals", code: 3, userInfo: [ - NSLocalizedDescriptionKey: "socket connect failed", - ]) - } - - let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true) - - let message = ExecApprovalSocketRequest( - type: "request", - token: token, - id: UUID().uuidString, - request: request) - let data = try JSONEncoder().encode(message) - var payload = data - payload.append(0x0A) - try handle.write(contentsOf: payload) - - guard let line = try self.readLine(from: handle, maxBytes: 256_000), - let lineData = line.data(using: .utf8) - else { return nil } - let response = try JSONDecoder().decode(ExecApprovalSocketDecision.self, from: lineData) - return response.decision - } - - private static func readLine(from handle: FileHandle, maxBytes: Int) throws -> String? { - var buffer = Data() - while buffer.count < maxBytes { - let chunk = try handle.read(upToCount: 4096) ?? Data() - if chunk.isEmpty { break } - buffer.append(chunk) - if buffer.contains(0x0A) { break } - } - guard let newlineIndex = buffer.firstIndex(of: 0x0A) else { - guard !buffer.isEmpty else { return nil } - return String(data: buffer, encoding: .utf8) - } - let lineData = buffer.subdata(in: 0.. ExecApprovalDecision { - NSApp.activate(ignoringOtherApps: true) - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = "Allow this command?" - alert.informativeText = "Review the command details before allowing." - alert.accessoryView = self.buildAccessoryView(request) - - alert.addButton(withTitle: "Allow Once") - alert.addButton(withTitle: "Always Allow") - alert.addButton(withTitle: "Don't Allow") - if #available(macOS 11.0, *), alert.buttons.indices.contains(2) { - alert.buttons[2].hasDestructiveAction = true - } - - switch alert.runModal() { - case .alertFirstButtonReturn: - return .allowOnce - case .alertSecondButtonReturn: - return .allowAlways - default: - return .deny - } - } - - @MainActor - private static func buildAccessoryView(_ request: ExecApprovalPromptRequest) -> NSView { - let stack = NSStackView() - stack.orientation = .vertical - stack.spacing = 8 - stack.alignment = .leading - stack.translatesAutoresizingMaskIntoConstraints = false - stack.widthAnchor.constraint(greaterThanOrEqualToConstant: 380).isActive = true - - let commandTitle = NSTextField(labelWithString: "Command") - commandTitle.font = NSFont.boldSystemFont(ofSize: NSFont.systemFontSize) - stack.addArrangedSubview(commandTitle) - - let commandText = NSTextView() - commandText.isEditable = false - commandText.isSelectable = true - commandText.drawsBackground = true - commandText.backgroundColor = NSColor.textBackgroundColor - commandText.font = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular) - commandText.string = request.command - commandText.textContainerInset = NSSize(width: 6, height: 6) - commandText.textContainer?.lineFragmentPadding = 0 - commandText.textContainer?.widthTracksTextView = true - commandText.isHorizontallyResizable = false - commandText.isVerticallyResizable = true - - let commandScroll = NSScrollView() - commandScroll.borderType = .lineBorder - commandScroll.hasVerticalScroller = true - commandScroll.hasHorizontalScroller = false - commandScroll.autohidesScrollers = true - commandScroll.documentView = commandText - commandScroll.translatesAutoresizingMaskIntoConstraints = false - commandScroll.widthAnchor.constraint(greaterThanOrEqualToConstant: 380).isActive = true - commandScroll.widthAnchor.constraint(lessThanOrEqualToConstant: 440).isActive = true - commandScroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 56).isActive = true - commandScroll.heightAnchor.constraint(lessThanOrEqualToConstant: 120).isActive = true - stack.addArrangedSubview(commandScroll) - - let contextTitle = NSTextField(labelWithString: "Context") - contextTitle.font = NSFont.boldSystemFont(ofSize: NSFont.systemFontSize) - stack.addArrangedSubview(contextTitle) - - let contextStack = NSStackView() - contextStack.orientation = .vertical - contextStack.spacing = 4 - contextStack.alignment = .leading - - let trimmedCwd = request.cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !trimmedCwd.isEmpty { - self.addDetailRow(title: "Working directory", value: trimmedCwd, to: contextStack) - } - let trimmedAgent = request.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !trimmedAgent.isEmpty { - self.addDetailRow(title: "Agent", value: trimmedAgent, to: contextStack) - } - let trimmedPath = request.resolvedPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !trimmedPath.isEmpty { - self.addDetailRow(title: "Executable", value: trimmedPath, to: contextStack) - } - let trimmedHost = request.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !trimmedHost.isEmpty { - self.addDetailRow(title: "Host", value: trimmedHost, to: contextStack) - } - if let security = request.security?.trimmingCharacters(in: .whitespacesAndNewlines), !security.isEmpty { - self.addDetailRow(title: "Security", value: security, to: contextStack) - } - if let ask = request.ask?.trimmingCharacters(in: .whitespacesAndNewlines), !ask.isEmpty { - self.addDetailRow(title: "Ask mode", value: ask, to: contextStack) - } - - if contextStack.arrangedSubviews.isEmpty { - let empty = NSTextField(labelWithString: "No additional context provided.") - empty.textColor = NSColor.secondaryLabelColor - empty.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize) - contextStack.addArrangedSubview(empty) - } - - stack.addArrangedSubview(contextStack) - - let footer = NSTextField(labelWithString: "This runs on this machine.") - footer.textColor = NSColor.secondaryLabelColor - footer.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize) - stack.addArrangedSubview(footer) - - return stack - } - - @MainActor - private static func addDetailRow(title: String, value: String, to stack: NSStackView) { - let row = NSStackView() - row.orientation = .horizontal - row.spacing = 6 - row.alignment = .firstBaseline - - let titleLabel = NSTextField(labelWithString: "\(title):") - titleLabel.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize, weight: .semibold) - titleLabel.textColor = NSColor.secondaryLabelColor - - let valueLabel = NSTextField(labelWithString: value) - valueLabel.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize) - valueLabel.lineBreakMode = .byTruncatingMiddle - valueLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - - row.addArrangedSubview(titleLabel) - row.addArrangedSubview(valueLabel) - stack.addArrangedSubview(row) - } -} - -@MainActor -private enum ExecHostExecutor { - private typealias ExecApprovalContext = ExecApprovalEvaluation - - static func handle(_ request: ExecHostRequest) async -> ExecHostResponse { - let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - guard !command.isEmpty else { - return self.errorResponse( - code: "INVALID_REQUEST", - message: "command required", - reason: "invalid") - } - - let context = await self.buildContext(request: request, command: command) - if context.security == .deny { - return self.errorResponse( - code: "UNAVAILABLE", - message: "SYSTEM_RUN_DISABLED: security=deny", - reason: "security=deny") - } - - let approvalDecision = request.approvalDecision - if approvalDecision == .deny { - return self.errorResponse( - code: "UNAVAILABLE", - message: "SYSTEM_RUN_DENIED: user denied", - reason: "user-denied") - } - - var approvedByAsk = approvalDecision != nil - if ExecApprovalHelpers.requiresAsk( - ask: context.ask, - security: context.security, - allowlistMatch: context.allowlistMatch, - skillAllow: context.skillAllow), - approvalDecision == nil - { - let decision = ExecApprovalsPromptPresenter.prompt( - ExecApprovalPromptRequest( - command: context.displayCommand, - cwd: request.cwd, - host: "node", - security: context.security.rawValue, - ask: context.ask.rawValue, - agentId: context.agentId, - resolvedPath: context.resolution?.resolvedPath, - sessionKey: request.sessionKey)) - - switch decision { - case .deny: - return self.errorResponse( - code: "UNAVAILABLE", - message: "SYSTEM_RUN_DENIED: user denied", - reason: "user-denied") - case .allowAlways: - approvedByAsk = true - self.persistAllowlistEntry(decision: decision, context: context) - case .allowOnce: - approvedByAsk = true - } - } - - self.persistAllowlistEntry(decision: approvalDecision, context: context) - - if context.security == .allowlist, - !context.allowlistSatisfied, - !context.skillAllow, - !approvedByAsk - { - return self.errorResponse( - code: "UNAVAILABLE", - message: "SYSTEM_RUN_DENIED: allowlist miss", - reason: "allowlist-miss") - } - - if context.allowlistSatisfied { - var seenPatterns = Set() - for (idx, match) in context.allowlistMatches.enumerated() { - if !seenPatterns.insert(match.pattern).inserted { - continue - } - let resolvedPath = idx < context.allowlistResolutions.count - ? context.allowlistResolutions[idx].resolvedPath - : nil - ExecApprovalsStore.recordAllowlistUse( - agentId: context.agentId, - pattern: match.pattern, - command: context.displayCommand, - resolvedPath: resolvedPath) - } - } - - if let errorResponse = await self.ensureScreenRecordingAccess(request.needsScreenRecording) { - return errorResponse - } - - return await self.runCommand( - command: command, - cwd: request.cwd, - env: context.env, - timeoutMs: request.timeoutMs) - } - - private static func buildContext(request: ExecHostRequest, command: [String]) async -> ExecApprovalContext { - await ExecApprovalEvaluator.evaluate( - command: command, - rawCommand: request.rawCommand, - cwd: request.cwd, - envOverrides: request.env, - agentId: request.agentId) - } - - private static func persistAllowlistEntry( - decision: ExecApprovalDecision?, - context: ExecApprovalContext) - { - guard decision == .allowAlways, context.security == .allowlist else { return } - var seenPatterns = Set() - for candidate in context.allowlistResolutions { - guard let pattern = ExecApprovalHelpers.allowlistPattern( - command: context.command, - resolution: candidate) - else { - continue - } - if seenPatterns.insert(pattern).inserted { - ExecApprovalsStore.addAllowlistEntry(agentId: context.agentId, pattern: pattern) - } - } - } - - private static func ensureScreenRecordingAccess(_ needsScreenRecording: Bool?) async -> ExecHostResponse? { - guard needsScreenRecording == true else { return nil } - let authorized = await PermissionManager - .status([.screenRecording])[.screenRecording] ?? false - if authorized { return nil } - return self.errorResponse( - code: "UNAVAILABLE", - message: "PERMISSION_MISSING: screenRecording", - reason: "permission:screenRecording") - } - - private static func runCommand( - command: [String], - cwd: String?, - env: [String: String]?, - timeoutMs: Int?) async -> ExecHostResponse - { - let timeoutSec = timeoutMs.flatMap { Double($0) / 1000.0 } - let result = await Task.detached { () -> ShellExecutor.ShellResult in - await ShellExecutor.runDetailed( - command: command, - cwd: cwd, - env: env, - timeout: timeoutSec) - }.value - let payload = ExecHostRunResult( - exitCode: result.exitCode, - timedOut: result.timedOut, - success: result.success, - stdout: result.stdout, - stderr: result.stderr, - error: result.errorMessage) - return self.successResponse(payload) - } - - private static func errorResponse( - code: String, - message: String, - reason: String?) -> ExecHostResponse - { - ExecHostResponse( - type: "exec-res", - id: UUID().uuidString, - ok: false, - payload: nil, - error: ExecHostError(code: code, message: message, reason: reason)) - } - - private static func successResponse(_ payload: ExecHostRunResult) -> ExecHostResponse { - ExecHostResponse( - type: "exec-res", - id: UUID().uuidString, - ok: true, - payload: payload, - error: nil) - } -} - -private final class ExecApprovalsSocketServer: @unchecked Sendable { - private let logger = Logger(subsystem: "ai.openclaw", category: "exec-approvals.socket") - private let socketPath: String - private let token: String - private let onPrompt: @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision - private let onExec: @Sendable (ExecHostRequest) async -> ExecHostResponse - private var socketFD: Int32 = -1 - private var acceptTask: Task? - private var isRunning = false - - init( - socketPath: String, - token: String, - onPrompt: @escaping @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision, - onExec: @escaping @Sendable (ExecHostRequest) async -> ExecHostResponse) - { - self.socketPath = socketPath - self.token = token - self.onPrompt = onPrompt - self.onExec = onExec - } - - func start() { - guard !self.isRunning else { return } - self.isRunning = true - self.acceptTask = Task.detached { [weak self] in - await self?.runAcceptLoop() - } - } - - func stop() { - self.isRunning = false - self.acceptTask?.cancel() - self.acceptTask = nil - if self.socketFD >= 0 { - close(self.socketFD) - self.socketFD = -1 - } - if !self.socketPath.isEmpty { - unlink(self.socketPath) - } - } - - private func runAcceptLoop() async { - let fd = self.openSocket() - guard fd >= 0 else { - self.isRunning = false - return - } - self.socketFD = fd - while self.isRunning { - var addr = sockaddr_un() - var len = socklen_t(MemoryLayout.size(ofValue: addr)) - let client = withUnsafeMutablePointer(to: &addr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in - accept(fd, rebound, &len) - } - } - if client < 0 { - if errno == EINTR { continue } - break - } - Task.detached { [weak self] in - await self?.handleClient(fd: client) - } - } - } - - private func openSocket() -> Int32 { - let fd = socket(AF_UNIX, SOCK_STREAM, 0) - guard fd >= 0 else { - self.logger.error("exec approvals socket create failed") - return -1 - } - unlink(self.socketPath) - var addr = sockaddr_un() - addr.sun_family = sa_family_t(AF_UNIX) - let maxLen = MemoryLayout.size(ofValue: addr.sun_path) - if self.socketPath.utf8.count >= maxLen { - self.logger.error("exec approvals socket path too long") - close(fd) - return -1 - } - self.socketPath.withCString { cstr in - withUnsafeMutablePointer(to: &addr.sun_path) { ptr in - let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: Int8.self) - memset(raw, 0, maxLen) - strncpy(raw, cstr, maxLen - 1) - } - } - let size = socklen_t(MemoryLayout.size(ofValue: addr)) - let result = withUnsafePointer(to: &addr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in - bind(fd, rebound, size) - } - } - if result != 0 { - self.logger.error("exec approvals socket bind failed") - close(fd) - return -1 - } - if listen(fd, 16) != 0 { - self.logger.error("exec approvals socket listen failed") - close(fd) - return -1 - } - chmod(self.socketPath, 0o600) - self.logger.info("exec approvals socket listening at \(self.socketPath, privacy: .public)") - return fd - } - - private func handleClient(fd: Int32) async { - let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true) - do { - guard self.isAllowedPeer(fd: fd) else { - try self.sendApprovalResponse(handle: handle, id: UUID().uuidString, decision: .deny) - return - } - guard let line = try self.readLine(from: handle, maxBytes: 256_000), - let data = line.data(using: .utf8) - else { - return - } - guard - let envelope = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let type = envelope["type"] as? String - else { - return - } - - if type == "request" { - let request = try JSONDecoder().decode(ExecApprovalSocketRequest.self, from: data) - guard request.token == self.token else { - try self.sendApprovalResponse(handle: handle, id: request.id, decision: .deny) - return - } - let decision = await self.onPrompt(request.request) - try self.sendApprovalResponse(handle: handle, id: request.id, decision: decision) - return - } - - if type == "exec" { - let request = try JSONDecoder().decode(ExecHostSocketRequest.self, from: data) - let response = await self.handleExecRequest(request) - try self.sendExecResponse(handle: handle, response: response) - return - } - } catch { - self.logger.error("exec approvals socket handling failed: \(error.localizedDescription, privacy: .public)") - } - } - - private func readLine(from handle: FileHandle, maxBytes: Int) throws -> String? { - var buffer = Data() - while buffer.count < maxBytes { - let chunk = try handle.read(upToCount: 4096) ?? Data() - if chunk.isEmpty { break } - buffer.append(chunk) - if buffer.contains(0x0A) { break } - } - guard let newlineIndex = buffer.firstIndex(of: 0x0A) else { - guard !buffer.isEmpty else { return nil } - return String(data: buffer, encoding: .utf8) - } - let lineData = buffer.subdata(in: 0.. Bool { - var uid = uid_t(0) - var gid = gid_t(0) - if getpeereid(fd, &uid, &gid) != 0 { - return false - } - return uid == geteuid() - } - - private func handleExecRequest(_ request: ExecHostSocketRequest) async -> ExecHostResponse { - let nowMs = Int(Date().timeIntervalSince1970 * 1000) - if abs(nowMs - request.ts) > 10000 { - return ExecHostResponse( - type: "exec-res", - id: request.id, - ok: false, - payload: nil, - error: ExecHostError(code: "INVALID_REQUEST", message: "expired request", reason: "ttl")) - } - let expected = self.hmacHex(nonce: request.nonce, ts: request.ts, requestJson: request.requestJson) - if expected != request.hmac { - return ExecHostResponse( - type: "exec-res", - id: request.id, - ok: false, - payload: nil, - error: ExecHostError(code: "INVALID_REQUEST", message: "invalid auth", reason: "hmac")) - } - guard let requestData = request.requestJson.data(using: .utf8), - let payload = try? JSONDecoder().decode(ExecHostRequest.self, from: requestData) - else { - return ExecHostResponse( - type: "exec-res", - id: request.id, - ok: false, - payload: nil, - error: ExecHostError(code: "INVALID_REQUEST", message: "invalid payload", reason: "json")) - } - let response = await self.onExec(payload) - return ExecHostResponse( - type: "exec-res", - id: request.id, - ok: response.ok, - payload: response.payload, - error: response.error) - } - - private func hmacHex(nonce: String, ts: Int, requestJson: String) -> String { - let key = SymmetricKey(data: Data(self.token.utf8)) - let message = "\(nonce):\(ts):\(requestJson)" - let mac = HMAC.authenticationCode(for: Data(message.utf8), using: key) - return mac.map { String(format: "%02x", $0) }.joined() - } -} diff --git a/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift b/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift deleted file mode 100644 index 8910163456f..00000000000 --- a/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift +++ /dev/null @@ -1,305 +0,0 @@ -import Foundation - -struct ExecCommandResolution: Sendable { - let rawExecutable: String - let resolvedPath: String? - let executableName: String - let cwd: String? - - static func resolve( - command: [String], - rawCommand: String?, - cwd: String?, - env: [String: String]?) -> ExecCommandResolution? - { - let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !trimmedRaw.isEmpty, let token = self.parseFirstToken(trimmedRaw) { - return self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env) - } - return self.resolve(command: command, cwd: cwd, env: env) - } - - static func resolveForAllowlist( - command: [String], - rawCommand: String?, - cwd: String?, - env: [String: String]?) -> [ExecCommandResolution] - { - let shell = self.extractShellCommandFromArgv(command: command, rawCommand: rawCommand) - if shell.isWrapper { - guard let shellCommand = shell.command, - let segments = self.splitShellCommandChain(shellCommand) - else { - // Fail closed: if we cannot safely parse a shell wrapper payload, - // treat this as an allowlist miss and require approval. - return [] - } - var resolutions: [ExecCommandResolution] = [] - resolutions.reserveCapacity(segments.count) - for segment in segments { - guard let token = self.parseFirstToken(segment), - let resolution = self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env) - else { - return [] - } - resolutions.append(resolution) - } - return resolutions - } - - guard let resolution = self.resolve(command: command, rawCommand: rawCommand, cwd: cwd, env: env) else { - return [] - } - return [resolution] - } - - static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? { - guard let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { - return nil - } - return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env) - } - - private static func resolveExecutable( - rawExecutable: String, - cwd: String?, - env: [String: String]?) -> ExecCommandResolution? - { - let expanded = rawExecutable.hasPrefix("~") ? (rawExecutable as NSString).expandingTildeInPath : rawExecutable - let hasPathSeparator = expanded.contains("/") || expanded.contains("\\") - let resolvedPath: String? = { - if hasPathSeparator { - if expanded.hasPrefix("/") { - return expanded - } - let base = cwd?.trimmingCharacters(in: .whitespacesAndNewlines) - let root = (base?.isEmpty == false) ? base! : FileManager().currentDirectoryPath - return URL(fileURLWithPath: root).appendingPathComponent(expanded).path - } - let searchPaths = self.searchPaths(from: env) - return CommandResolver.findExecutable(named: expanded, searchPaths: searchPaths) - }() - let name = resolvedPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? expanded - return ExecCommandResolution( - rawExecutable: expanded, - resolvedPath: resolvedPath, - executableName: name, - cwd: cwd) - } - - private static func parseFirstToken(_ command: String) -> String? { - let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - guard let first = trimmed.first else { return nil } - if first == "\"" || first == "'" { - let rest = trimmed.dropFirst() - if let end = rest.firstIndex(of: first) { - return String(rest[.. String { - let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return "" } - let normalized = trimmed.replacingOccurrences(of: "\\", with: "/") - return normalized.split(separator: "/").last.map { String($0).lowercased() } ?? normalized.lowercased() - } - - private static func extractShellCommandFromArgv( - command: [String], - rawCommand: String?) -> (isWrapper: Bool, command: String?) - { - guard let token0 = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token0.isEmpty else { - return (false, nil) - } - let base0 = self.basenameLower(token0) - let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let preferredRaw = trimmedRaw.isEmpty ? nil : trimmedRaw - - if ["sh", "bash", "zsh", "dash", "ksh"].contains(base0) { - let flag = command.count > 1 ? command[1].trimmingCharacters(in: .whitespacesAndNewlines) : "" - guard flag == "-lc" || flag == "-c" else { return (false, nil) } - let payload = command.count > 2 ? command[2].trimmingCharacters(in: .whitespacesAndNewlines) : "" - let normalized = preferredRaw ?? (payload.isEmpty ? nil : payload) - return (true, normalized) - } - - if base0 == "cmd.exe" || base0 == "cmd" { - guard let idx = command - .firstIndex(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "/c" }) - else { - return (false, nil) - } - let tail = command.suffix(from: command.index(after: idx)).joined(separator: " ") - let payload = tail.trimmingCharacters(in: .whitespacesAndNewlines) - let normalized = preferredRaw ?? (payload.isEmpty ? nil : payload) - return (true, normalized) - } - - return (false, nil) - } - - private enum ShellTokenContext { - case unquoted - case doubleQuoted - } - - private struct ShellFailClosedRule { - let token: Character - let next: Character? - } - - private static let shellFailClosedRules: [ShellTokenContext: [ShellFailClosedRule]] = [ - .unquoted: [ - ShellFailClosedRule(token: "`", next: nil), - ShellFailClosedRule(token: "$", next: "("), - ShellFailClosedRule(token: "<", next: "("), - ShellFailClosedRule(token: ">", next: "("), - ], - .doubleQuoted: [ - ShellFailClosedRule(token: "`", next: nil), - ShellFailClosedRule(token: "$", next: "("), - ], - ] - - private static func splitShellCommandChain(_ command: String) -> [String]? { - let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - - var segments: [String] = [] - var current = "" - var inSingle = false - var inDouble = false - var escaped = false - let chars = Array(trimmed) - var idx = 0 - - func appendCurrent() -> Bool { - let segment = current.trimmingCharacters(in: .whitespacesAndNewlines) - guard !segment.isEmpty else { return false } - segments.append(segment) - current.removeAll(keepingCapacity: true) - return true - } - - while idx < chars.count { - let ch = chars[idx] - let next: Character? = idx + 1 < chars.count ? chars[idx + 1] : nil - - if escaped { - current.append(ch) - escaped = false - idx += 1 - continue - } - - if ch == "\\", !inSingle { - current.append(ch) - escaped = true - idx += 1 - continue - } - - if ch == "'", !inDouble { - inSingle.toggle() - current.append(ch) - idx += 1 - continue - } - - if ch == "\"", !inSingle { - inDouble.toggle() - current.append(ch) - idx += 1 - continue - } - - if !inSingle, self.shouldFailClosedForShell(ch: ch, next: next, inDouble: inDouble) { - // Fail closed on command/process substitution in allowlist mode, - // including command substitution inside double-quoted shell strings. - return nil - } - - if !inSingle, !inDouble { - let prev: Character? = idx > 0 ? chars[idx - 1] : nil - if let delimiterStep = self.chainDelimiterStep(ch: ch, prev: prev, next: next) { - guard appendCurrent() else { return nil } - idx += delimiterStep - continue - } - } - - current.append(ch) - idx += 1 - } - - if escaped || inSingle || inDouble { return nil } - guard appendCurrent() else { return nil } - return segments - } - - private static func shouldFailClosedForShell(ch: Character, next: Character?, inDouble: Bool) -> Bool { - let context: ShellTokenContext = inDouble ? .doubleQuoted : .unquoted - guard let rules = self.shellFailClosedRules[context] else { - return false - } - for rule in rules { - if ch == rule.token, rule.next == nil || next == rule.next { - return true - } - } - return false - } - - private static func chainDelimiterStep(ch: Character, prev: Character?, next: Character?) -> Int? { - if ch == ";" || ch == "\n" { - return 1 - } - if ch == "&" { - if next == "&" { - return 2 - } - // Keep fd redirections like 2>&1 or &>file intact. - let prevIsRedirect = prev == ">" - let nextIsRedirect = next == ">" - return (!prevIsRedirect && !nextIsRedirect) ? 1 : nil - } - if ch == "|" { - if next == "|" || next == "&" { - return 2 - } - return 1 - } - return nil - } - - private static func searchPaths(from env: [String: String]?) -> [String] { - let raw = env?["PATH"] - if let raw, !raw.isEmpty { - return raw.split(separator: ":").map(String.init) - } - return CommandResolver.preferredPaths() - } -} - -enum ExecCommandFormatter { - static func displayString(for argv: [String]) -> String { - argv.map { arg in - let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return "\"\"" } - let needsQuotes = trimmed.contains { $0.isWhitespace || $0 == "\"" } - if !needsQuotes { return trimmed } - let escaped = trimmed.replacingOccurrences(of: "\"", with: "\\\"") - return "\"\(escaped)\"" - }.joined(separator: " ") - } - - static func displayString(for argv: [String], rawCommand: String?) -> String { - let trimmed = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !trimmed.isEmpty { return trimmed } - return self.displayString(for: argv) - } -} diff --git a/apps/macos/Sources/OpenClaw/FileHandle+SafeRead.swift b/apps/macos/Sources/OpenClaw/FileHandle+SafeRead.swift deleted file mode 100644 index 7cd16096938..00000000000 --- a/apps/macos/Sources/OpenClaw/FileHandle+SafeRead.swift +++ /dev/null @@ -1,28 +0,0 @@ -import Foundation - -extension FileHandle { - /// Reads until EOF using the throwing FileHandle API and returns empty `Data` on failure. - /// - /// Important: Avoid legacy, non-throwing FileHandle read APIs (e.g. `readDataToEndOfFile()` and - /// `availableData`). They can raise Objective-C exceptions when the handle is closed/invalid, which - /// will abort the process. - func readToEndSafely() -> Data { - do { - return try self.readToEnd() ?? Data() - } catch { - return Data() - } - } - - /// Reads up to `count` bytes using the throwing FileHandle API and returns empty `Data` on failure/EOF. - /// - /// Important: Use this instead of `availableData` in callbacks like `readabilityHandler` to avoid - /// Objective-C exceptions terminating the process. - func readSafely(upToCount count: Int) -> Data { - do { - return try self.read(upToCount: count) ?? Data() - } catch { - return Data() - } - } -} diff --git a/apps/macos/Sources/OpenClaw/GatewayAutostartPolicy.swift b/apps/macos/Sources/OpenClaw/GatewayAutostartPolicy.swift deleted file mode 100644 index 27f60abadb6..00000000000 --- a/apps/macos/Sources/OpenClaw/GatewayAutostartPolicy.swift +++ /dev/null @@ -1,14 +0,0 @@ -import Foundation - -enum GatewayAutostartPolicy { - static func shouldStartGateway(mode: AppState.ConnectionMode, paused: Bool) -> Bool { - mode == .local && !paused - } - - static func shouldEnsureLaunchAgent( - mode: AppState.ConnectionMode, - paused: Bool) -> Bool - { - self.shouldStartGateway(mode: mode, paused: paused) - } -} diff --git a/apps/macos/Sources/OpenClaw/GatewayConnection.swift b/apps/macos/Sources/OpenClaw/GatewayConnection.swift deleted file mode 100644 index 0d7d582dd33..00000000000 --- a/apps/macos/Sources/OpenClaw/GatewayConnection.swift +++ /dev/null @@ -1,742 +0,0 @@ -import Foundation -import OpenClawChatUI -import OpenClawKit -import OpenClawProtocol -import OSLog - -private let gatewayConnectionLogger = Logger(subsystem: "ai.openclaw", category: "gateway.connection") - -enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable { - case last - case whatsapp - case telegram - case discord - case googlechat - case slack - case signal - case imessage - case msteams - case bluebubbles - case webchat - - init(raw: String?) { - let normalized = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - self = GatewayAgentChannel(rawValue: normalized) ?? .last - } - - var isDeliverable: Bool { - self != .webchat - } - - func shouldDeliver(_ deliver: Bool) -> Bool { - deliver && self.isDeliverable - } -} - -struct GatewayAgentInvocation: Sendable { - var message: String - var sessionKey: String = "main" - var thinking: String? - var deliver: Bool = false - var to: String? - var channel: GatewayAgentChannel = .last - var timeoutSeconds: Int? - var idempotencyKey: String = UUID().uuidString -} - -/// Single, shared Gateway websocket connection for the whole app. -/// -/// This owns exactly one `GatewayChannelActor` and reuses it across all callers -/// (ControlChannel, debug actions, SwiftUI WebChat, etc.). -actor GatewayConnection { - static let shared = GatewayConnection() - - typealias Config = (url: URL, token: String?, password: String?) - - enum Method: String, Sendable { - case agent - case status - case setHeartbeats = "set-heartbeats" - case systemEvent = "system-event" - case health - case channelsStatus = "channels.status" - case configGet = "config.get" - case configSet = "config.set" - case configPatch = "config.patch" - case configSchema = "config.schema" - case wizardStart = "wizard.start" - case wizardNext = "wizard.next" - case wizardCancel = "wizard.cancel" - case wizardStatus = "wizard.status" - case talkConfig = "talk.config" - case talkMode = "talk.mode" - case webLoginStart = "web.login.start" - case webLoginWait = "web.login.wait" - case channelsLogout = "channels.logout" - case modelsList = "models.list" - case chatHistory = "chat.history" - case sessionsPreview = "sessions.preview" - case chatSend = "chat.send" - case chatAbort = "chat.abort" - case skillsStatus = "skills.status" - case skillsInstall = "skills.install" - case skillsUpdate = "skills.update" - case voicewakeGet = "voicewake.get" - case voicewakeSet = "voicewake.set" - case nodePairApprove = "node.pair.approve" - case nodePairReject = "node.pair.reject" - case devicePairList = "device.pair.list" - case devicePairApprove = "device.pair.approve" - case devicePairReject = "device.pair.reject" - case execApprovalResolve = "exec.approval.resolve" - case cronList = "cron.list" - case cronRuns = "cron.runs" - case cronRun = "cron.run" - case cronRemove = "cron.remove" - case cronUpdate = "cron.update" - case cronAdd = "cron.add" - case cronStatus = "cron.status" - } - - private let configProvider: @Sendable () async throws -> Config - private let sessionBox: WebSocketSessionBox? - private let decoder = JSONDecoder() - - private var client: GatewayChannelActor? - private var configuredURL: URL? - private var configuredToken: String? - private var configuredPassword: String? - - private var subscribers: [UUID: AsyncStream.Continuation] = [:] - private var lastSnapshot: HelloOk? - - init( - configProvider: @escaping @Sendable () async throws -> Config = GatewayConnection.defaultConfigProvider, - sessionBox: WebSocketSessionBox? = nil) - { - self.configProvider = configProvider - self.sessionBox = sessionBox - } - - // MARK: - Low-level request - - func request( - method: String, - params: [String: AnyCodable]?, - timeoutMs: Double? = nil) async throws -> Data - { - let cfg = try await self.configProvider() - await self.configure(url: cfg.url, token: cfg.token, password: cfg.password) - guard let client else { - throw NSError(domain: "Gateway", code: 0, userInfo: [NSLocalizedDescriptionKey: "gateway not configured"]) - } - - do { - return try await client.request(method: method, params: params, timeoutMs: timeoutMs) - } catch { - if error is GatewayResponseError || error is GatewayDecodingError { - throw error - } - - // Auto-recover in local mode by spawning/attaching a gateway and retrying a few times. - // Canvas interactions should "just work" even if the local gateway isn't running yet. - let mode = await MainActor.run { AppStateStore.shared.connectionMode } - switch mode { - case .local: - await MainActor.run { GatewayProcessManager.shared.setActive(true) } - - var lastError: Error = error - for delayMs in [150, 400, 900] { - try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000) - do { - return try await client.request(method: method, params: params, timeoutMs: timeoutMs) - } catch { - lastError = error - } - } - - let nsError = lastError as NSError - if nsError.domain == URLError.errorDomain, - let fallback = await GatewayEndpointStore.shared.maybeFallbackToTailnet(from: cfg.url) - { - await self.configure(url: fallback.url, token: fallback.token, password: fallback.password) - for delayMs in [150, 400, 900] { - try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000) - do { - guard let client = self.client else { - throw NSError( - domain: "Gateway", - code: 0, - userInfo: [NSLocalizedDescriptionKey: "gateway not configured"]) - } - return try await client.request(method: method, params: params, timeoutMs: timeoutMs) - } catch { - lastError = error - } - } - } - - throw lastError - case .remote: - let nsError = error as NSError - guard nsError.domain == URLError.errorDomain else { throw error } - - var lastError: Error = error - await RemoteTunnelManager.shared.stopAll() - do { - _ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel() - } catch { - lastError = error - } - - for delayMs in [150, 400, 900] { - try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000) - do { - let cfg = try await self.configProvider() - await self.configure(url: cfg.url, token: cfg.token, password: cfg.password) - guard let client = self.client else { - throw NSError( - domain: "Gateway", - code: 0, - userInfo: [NSLocalizedDescriptionKey: "gateway not configured"]) - } - return try await client.request(method: method, params: params, timeoutMs: timeoutMs) - } catch { - lastError = error - } - } - - throw lastError - case .unconfigured: - throw error - } - } - } - - func requestRaw( - method: Method, - params: [String: AnyCodable]? = nil, - timeoutMs: Double? = nil) async throws -> Data - { - try await self.request(method: method.rawValue, params: params, timeoutMs: timeoutMs) - } - - func requestRaw( - method: String, - params: [String: AnyCodable]? = nil, - timeoutMs: Double? = nil) async throws -> Data - { - try await self.request(method: method, params: params, timeoutMs: timeoutMs) - } - - func requestDecoded( - method: Method, - params: [String: AnyCodable]? = nil, - timeoutMs: Double? = nil) async throws -> T - { - let data = try await self.requestRaw(method: method, params: params, timeoutMs: timeoutMs) - do { - return try self.decoder.decode(T.self, from: data) - } catch { - throw GatewayDecodingError(method: method.rawValue, message: error.localizedDescription) - } - } - - func requestVoid( - method: Method, - params: [String: AnyCodable]? = nil, - timeoutMs: Double? = nil) async throws - { - _ = try await self.requestRaw(method: method, params: params, timeoutMs: timeoutMs) - } - - /// Ensure the underlying socket is configured (and replaced if config changed). - func refresh() async throws { - let cfg = try await self.configProvider() - await self.configure(url: cfg.url, token: cfg.token, password: cfg.password) - } - - func authSource() async -> GatewayAuthSource? { - guard let client else { return nil } - return await client.authSource() - } - - func shutdown() async { - if let client { - await client.shutdown() - } - self.client = nil - self.configuredURL = nil - self.configuredToken = nil - self.lastSnapshot = nil - } - - func canvasHostUrl() async -> String? { - guard let snapshot = self.lastSnapshot else { return nil } - let trimmed = snapshot.canvashosturl?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) ?? "" - return trimmed.isEmpty ? nil : trimmed - } - - private func sessionDefaultString(_ defaults: [String: OpenClawProtocol.AnyCodable]?, key: String) -> String { - let raw = defaults?[key]?.value as? String - return (raw ?? "").trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) - } - - func cachedMainSessionKey() -> String? { - guard let snapshot = self.lastSnapshot else { return nil } - let trimmed = self.sessionDefaultString(snapshot.snapshot.sessiondefaults, key: "mainSessionKey") - return trimmed.isEmpty ? nil : trimmed - } - - func cachedGatewayVersion() -> String? { - guard let snapshot = self.lastSnapshot else { return nil } - let raw = snapshot.server["version"]?.value as? String - let trimmed = raw?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) ?? "" - return trimmed.isEmpty ? nil : trimmed - } - - func snapshotPaths() -> (configPath: String?, stateDir: String?) { - guard let snapshot = self.lastSnapshot else { return (nil, nil) } - let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines) - let stateDir = snapshot.snapshot.statedir?.trimmingCharacters(in: .whitespacesAndNewlines) - return ( - configPath?.isEmpty == false ? configPath : nil, - stateDir?.isEmpty == false ? stateDir : nil) - } - - func subscribe(bufferingNewest: Int = 100) -> AsyncStream { - let id = UUID() - let snapshot = self.lastSnapshot - let connection = self - return AsyncStream(bufferingPolicy: .bufferingNewest(bufferingNewest)) { continuation in - if let snapshot { - continuation.yield(.snapshot(snapshot)) - } - self.subscribers[id] = continuation - continuation.onTermination = { @Sendable _ in - Task { await connection.removeSubscriber(id) } - } - } - } - - private func removeSubscriber(_ id: UUID) { - self.subscribers[id] = nil - } - - private func broadcast(_ push: GatewayPush) { - if case let .snapshot(snapshot) = push { - self.lastSnapshot = snapshot - if let mainSessionKey = self.cachedMainSessionKey() { - Task { @MainActor in - WorkActivityStore.shared.setMainSessionKey(mainSessionKey) - } - } - } - for (_, continuation) in self.subscribers { - continuation.yield(push) - } - } - - private func canonicalizeSessionKey(_ raw: String) -> String { - let trimmed = raw.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) - guard !trimmed.isEmpty else { return trimmed } - guard let defaults = self.lastSnapshot?.snapshot.sessiondefaults else { return trimmed } - let mainSessionKey = self.sessionDefaultString(defaults, key: "mainSessionKey") - guard !mainSessionKey.isEmpty else { return trimmed } - let mainKey = self.sessionDefaultString(defaults, key: "mainKey") - let defaultAgentId = self.sessionDefaultString(defaults, key: "defaultAgentId") - let isMainAlias = - trimmed == "main" || - (!mainKey.isEmpty && trimmed == mainKey) || - trimmed == mainSessionKey || - (!defaultAgentId.isEmpty && - (trimmed == "agent:\(defaultAgentId):main" || - (mainKey.isEmpty == false && trimmed == "agent:\(defaultAgentId):\(mainKey)"))) - return isMainAlias ? mainSessionKey : trimmed - } - - private func configure(url: URL, token: String?, password: String?) async { - if self.client != nil, self.configuredURL == url, self.configuredToken == token, - self.configuredPassword == password - { - return - } - if let client { - await client.shutdown() - } - self.lastSnapshot = nil - self.client = GatewayChannelActor( - url: url, - token: token, - password: password, - session: self.sessionBox, - pushHandler: { [weak self] push in - await self?.handle(push: push) - }) - self.configuredURL = url - self.configuredToken = token - self.configuredPassword = password - } - - private func handle(push: GatewayPush) { - self.broadcast(push) - } - - private static func defaultConfigProvider() async throws -> Config { - try await GatewayEndpointStore.shared.requireConfig() - } -} - -// MARK: - Typed gateway API - -extension GatewayConnection { - struct ConfigGetSnapshot: Decodable, Sendable { - struct SnapshotConfig: Decodable, Sendable { - struct Session: Decodable, Sendable { - let mainKey: String? - let scope: String? - } - - let session: Session? - } - - let config: SnapshotConfig? - } - - static func mainSessionKey(fromConfigGetData data: Data) throws -> String { - let snapshot = try JSONDecoder().decode(ConfigGetSnapshot.self, from: data) - let scope = snapshot.config?.session?.scope?.trimmingCharacters(in: .whitespacesAndNewlines) - if scope == "global" { - return "global" - } - return "main" - } - - func mainSessionKey(timeoutMs: Double = 15000) async -> String { - if let cached = self.cachedMainSessionKey() { - return cached - } - do { - let data = try await self.requestRaw(method: "config.get", params: nil, timeoutMs: timeoutMs) - return try Self.mainSessionKey(fromConfigGetData: data) - } catch { - return "main" - } - } - - func status() async -> (ok: Bool, error: String?) { - do { - _ = try await self.requestRaw(method: .status) - return (true, nil) - } catch { - return (false, error.localizedDescription) - } - } - - func setHeartbeatsEnabled(_ enabled: Bool) async -> Bool { - do { - try await self.requestVoid(method: .setHeartbeats, params: ["enabled": AnyCodable(enabled)]) - return true - } catch { - gatewayConnectionLogger.error("setHeartbeatsEnabled failed \(error.localizedDescription, privacy: .public)") - return false - } - } - - func sendAgent(_ invocation: GatewayAgentInvocation) async -> (ok: Bool, error: String?) { - let trimmed = invocation.message.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return (false, "message empty") } - let sessionKey = self.canonicalizeSessionKey(invocation.sessionKey) - - var params: [String: AnyCodable] = [ - "message": AnyCodable(trimmed), - "sessionKey": AnyCodable(sessionKey), - "thinking": AnyCodable(invocation.thinking ?? "default"), - "deliver": AnyCodable(invocation.deliver), - "to": AnyCodable(invocation.to ?? ""), - "channel": AnyCodable(invocation.channel.rawValue), - "idempotencyKey": AnyCodable(invocation.idempotencyKey), - ] - if let timeout = invocation.timeoutSeconds { - params["timeout"] = AnyCodable(timeout) - } - - do { - try await self.requestVoid(method: .agent, params: params) - return (true, nil) - } catch { - return (false, error.localizedDescription) - } - } - - func sendAgent( - message: String, - thinking: String?, - sessionKey: String, - deliver: Bool, - to: String?, - channel: GatewayAgentChannel = .last, - timeoutSeconds: Int? = nil, - idempotencyKey: String = UUID().uuidString) async -> (ok: Bool, error: String?) - { - await self.sendAgent(GatewayAgentInvocation( - message: message, - sessionKey: sessionKey, - thinking: thinking, - deliver: deliver, - to: to, - channel: channel, - timeoutSeconds: timeoutSeconds, - idempotencyKey: idempotencyKey)) - } - - func sendSystemEvent(_ params: [String: AnyCodable]) async { - do { - try await self.requestVoid(method: .systemEvent, params: params) - } catch { - // Best-effort only. - } - } - - // MARK: - Health - - func healthSnapshot(timeoutMs: Double? = nil) async throws -> HealthSnapshot { - let data = try await self.requestRaw(method: .health, timeoutMs: timeoutMs) - if let snap = decodeHealthSnapshot(from: data) { return snap } - throw GatewayDecodingError(method: Method.health.rawValue, message: "failed to decode health snapshot") - } - - func healthOK(timeoutMs: Int = 8000) async throws -> Bool { - let data = try await self.requestRaw(method: .health, timeoutMs: Double(timeoutMs)) - return (try? self.decoder.decode(OpenClawGatewayHealthOK.self, from: data))?.ok ?? true - } - - // MARK: - Skills - - func skillsStatus() async throws -> SkillsStatusReport { - try await self.requestDecoded(method: .skillsStatus) - } - - func skillsInstall( - name: String, - installId: String, - timeoutMs: Int? = nil) async throws -> SkillInstallResult - { - var params: [String: AnyCodable] = [ - "name": AnyCodable(name), - "installId": AnyCodable(installId), - ] - if let timeoutMs { - params["timeoutMs"] = AnyCodable(timeoutMs) - } - return try await self.requestDecoded(method: .skillsInstall, params: params) - } - - func skillsUpdate( - skillKey: String, - enabled: Bool? = nil, - apiKey: String? = nil, - env: [String: String]? = nil) async throws -> SkillUpdateResult - { - var params: [String: AnyCodable] = [ - "skillKey": AnyCodable(skillKey), - ] - if let enabled { params["enabled"] = AnyCodable(enabled) } - if let apiKey { params["apiKey"] = AnyCodable(apiKey) } - if let env, !env.isEmpty { params["env"] = AnyCodable(env) } - return try await self.requestDecoded(method: .skillsUpdate, params: params) - } - - // MARK: - Sessions - - func sessionsPreview( - keys: [String], - limit: Int? = nil, - maxChars: Int? = nil, - timeoutMs: Int? = nil) async throws -> OpenClawSessionsPreviewPayload - { - let resolvedKeys = keys - .map { self.canonicalizeSessionKey($0) } - .filter { !$0.isEmpty } - if resolvedKeys.isEmpty { - return OpenClawSessionsPreviewPayload(ts: 0, previews: []) - } - var params: [String: AnyCodable] = ["keys": AnyCodable(resolvedKeys)] - if let limit { params["limit"] = AnyCodable(limit) } - if let maxChars { params["maxChars"] = AnyCodable(maxChars) } - let timeout = timeoutMs.map { Double($0) } - return try await self.requestDecoded( - method: .sessionsPreview, - params: params, - timeoutMs: timeout) - } - - // MARK: - Chat - - func chatHistory( - sessionKey: String, - limit: Int? = nil, - timeoutMs: Int? = nil) async throws -> OpenClawChatHistoryPayload - { - let resolvedKey = self.canonicalizeSessionKey(sessionKey) - var params: [String: AnyCodable] = ["sessionKey": AnyCodable(resolvedKey)] - if let limit { params["limit"] = AnyCodable(limit) } - let timeout = timeoutMs.map { Double($0) } - return try await self.requestDecoded( - method: .chatHistory, - params: params, - timeoutMs: timeout) - } - - func chatSend( - sessionKey: String, - message: String, - thinking: String, - idempotencyKey: String, - attachments: [OpenClawChatAttachmentPayload], - timeoutMs: Int = 30000) async throws -> OpenClawChatSendResponse - { - let resolvedKey = self.canonicalizeSessionKey(sessionKey) - var params: [String: AnyCodable] = [ - "sessionKey": AnyCodable(resolvedKey), - "message": AnyCodable(message), - "thinking": AnyCodable(thinking), - "idempotencyKey": AnyCodable(idempotencyKey), - "timeoutMs": AnyCodable(timeoutMs), - ] - - if !attachments.isEmpty { - let encoded = attachments.map { att in - [ - "type": att.type, - "mimeType": att.mimeType, - "fileName": att.fileName, - "content": att.content, - ] - } - params["attachments"] = AnyCodable(encoded) - } - - return try await self.requestDecoded( - method: .chatSend, - params: params, - timeoutMs: Double(timeoutMs)) - } - - func chatAbort(sessionKey: String, runId: String) async throws -> Bool { - let resolvedKey = self.canonicalizeSessionKey(sessionKey) - struct AbortResponse: Decodable { let ok: Bool?; let aborted: Bool? } - let res: AbortResponse = try await self.requestDecoded( - method: .chatAbort, - params: ["sessionKey": AnyCodable(resolvedKey), "runId": AnyCodable(runId)]) - return res.aborted ?? false - } - - func talkMode(enabled: Bool, phase: String? = nil) async { - var params: [String: AnyCodable] = ["enabled": AnyCodable(enabled)] - if let phase { params["phase"] = AnyCodable(phase) } - try? await self.requestVoid(method: .talkMode, params: params) - } - - // MARK: - VoiceWake - - func voiceWakeGetTriggers() async throws -> [String] { - struct VoiceWakePayload: Decodable { let triggers: [String] } - let payload: VoiceWakePayload = try await self.requestDecoded(method: .voicewakeGet) - return payload.triggers - } - - func voiceWakeSetTriggers(_ triggers: [String]) async { - do { - try await self.requestVoid( - method: .voicewakeSet, - params: ["triggers": AnyCodable(triggers)], - timeoutMs: 10000) - } catch { - // Best-effort only. - } - } - - // MARK: - Node pairing - - func nodePairApprove(requestId: String) async throws { - try await self.requestVoid( - method: .nodePairApprove, - params: ["requestId": AnyCodable(requestId)], - timeoutMs: 10000) - } - - func nodePairReject(requestId: String) async throws { - try await self.requestVoid( - method: .nodePairReject, - params: ["requestId": AnyCodable(requestId)], - timeoutMs: 10000) - } - - // MARK: - Device pairing - - func devicePairApprove(requestId: String) async throws { - try await self.requestVoid( - method: .devicePairApprove, - params: ["requestId": AnyCodable(requestId)], - timeoutMs: 10000) - } - - func devicePairReject(requestId: String) async throws { - try await self.requestVoid( - method: .devicePairReject, - params: ["requestId": AnyCodable(requestId)], - timeoutMs: 10000) - } - - // MARK: - Cron - - struct CronSchedulerStatus: Decodable, Sendable { - let enabled: Bool - let storePath: String - let jobs: Int - let nextWakeAtMs: Int? - } - - func cronStatus() async throws -> CronSchedulerStatus { - try await self.requestDecoded(method: .cronStatus) - } - - func cronList(includeDisabled: Bool = true) async throws -> [CronJob] { - let res: CronListResponse = try await self.requestDecoded( - method: .cronList, - params: ["includeDisabled": AnyCodable(includeDisabled)]) - return res.jobs - } - - func cronRuns(jobId: String, limit: Int = 200) async throws -> [CronRunLogEntry] { - let res: CronRunsResponse = try await self.requestDecoded( - method: .cronRuns, - params: ["id": AnyCodable(jobId), "limit": AnyCodable(limit)]) - return res.entries - } - - func cronRun(jobId: String, force: Bool = true) async throws { - try await self.requestVoid( - method: .cronRun, - params: [ - "id": AnyCodable(jobId), - "mode": AnyCodable(force ? "force" : "due"), - ], - timeoutMs: 20000) - } - - func cronRemove(jobId: String) async throws { - try await self.requestVoid(method: .cronRemove, params: ["id": AnyCodable(jobId)]) - } - - func cronUpdate(jobId: String, patch: [String: AnyCodable]) async throws { - try await self.requestVoid( - method: .cronUpdate, - params: ["id": AnyCodable(jobId), "patch": AnyCodable(patch)]) - } - - func cronAdd(payload: [String: AnyCodable]) async throws { - try await self.requestVoid(method: .cronAdd, params: payload) - } -} diff --git a/apps/macos/Sources/OpenClaw/GatewayConnectivityCoordinator.swift b/apps/macos/Sources/OpenClaw/GatewayConnectivityCoordinator.swift deleted file mode 100644 index aeb1ebb9af0..00000000000 --- a/apps/macos/Sources/OpenClaw/GatewayConnectivityCoordinator.swift +++ /dev/null @@ -1,63 +0,0 @@ -import Foundation -import Observation -import OSLog - -@MainActor -@Observable -final class GatewayConnectivityCoordinator { - static let shared = GatewayConnectivityCoordinator() - - private let logger = Logger(subsystem: "ai.openclaw", category: "gateway.connectivity") - private var endpointTask: Task? - private var lastResolvedURL: URL? - - private(set) var endpointState: GatewayEndpointState? - private(set) var resolvedURL: URL? - private(set) var resolvedMode: AppState.ConnectionMode? - private(set) var resolvedHostLabel: String? - - private init() { - self.start() - } - - func start() { - guard self.endpointTask == nil else { return } - self.endpointTask = Task { [weak self] in - guard let self else { return } - let stream = await GatewayEndpointStore.shared.subscribe() - for await state in stream { - await MainActor.run { self.handleEndpointState(state) } - } - } - } - - var localEndpointHostLabel: String? { - guard self.resolvedMode == .local, let url = self.resolvedURL else { return nil } - return Self.hostLabel(for: url) - } - - private func handleEndpointState(_ state: GatewayEndpointState) { - self.endpointState = state - switch state { - case let .ready(mode, url, _, _): - self.resolvedMode = mode - self.resolvedURL = url - self.resolvedHostLabel = Self.hostLabel(for: url) - let urlChanged = self.lastResolvedURL?.absoluteString != url.absoluteString - if urlChanged { - self.lastResolvedURL = url - Task { await ControlChannel.shared.refreshEndpoint(reason: "endpoint changed") } - } - case let .connecting(mode, _): - self.resolvedMode = mode - case let .unavailable(mode, _): - self.resolvedMode = mode - } - } - - private static func hostLabel(for url: URL) -> String { - let host = url.host ?? url.absoluteString - if let port = url.port { return "\(host):\(port)" } - return host - } -} diff --git a/apps/macos/Sources/OpenClaw/GatewayDiscoveryHelpers.swift b/apps/macos/Sources/OpenClaw/GatewayDiscoveryHelpers.swift deleted file mode 100644 index 81383efa21a..00000000000 --- a/apps/macos/Sources/OpenClaw/GatewayDiscoveryHelpers.swift +++ /dev/null @@ -1,77 +0,0 @@ -import Foundation -import OpenClawDiscovery - -enum GatewayDiscoveryHelpers { - static func resolvedServiceHost( - for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? - { - self.resolvedServiceHost(gateway.serviceHost) - } - - static func resolvedServiceHost(_ host: String?) -> String? { - guard let host = self.trimmed(host), !host.isEmpty else { return nil } - return host - } - - static func serviceEndpoint( - for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> (host: String, port: Int)? - { - self.serviceEndpoint(serviceHost: gateway.serviceHost, servicePort: gateway.servicePort) - } - - static func serviceEndpoint( - serviceHost: String?, - servicePort: Int?) -> (host: String, port: Int)? - { - guard let host = self.resolvedServiceHost(serviceHost) else { return nil } - guard let port = servicePort, port > 0, port <= 65535 else { return nil } - return (host, port) - } - - static func sshTarget(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? { - guard let host = self.resolvedServiceHost(for: gateway) else { return nil } - let user = NSUserName() - var target = "\(user)@\(host)" - if gateway.sshPort != 22 { - target += ":\(gateway.sshPort)" - } - return target - } - - static func directUrl(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? { - self.directGatewayUrl( - serviceHost: gateway.serviceHost, - servicePort: gateway.servicePort) - } - - static func directGatewayUrl( - serviceHost: String?, - servicePort: Int?) -> String? - { - // Security: do not route using unauthenticated TXT hints (tailnetDns/lanHost/gatewayPort). - // Prefer the resolved service endpoint (SRV + A/AAAA). - guard let endpoint = self.serviceEndpoint(serviceHost: serviceHost, servicePort: servicePort) else { - return nil - } - // Security: for non-loopback hosts, force TLS to avoid plaintext credential/session leakage. - let scheme = self.isLoopbackHost(endpoint.host) ? "ws" : "wss" - let portSuffix = endpoint.port == 443 ? "" : ":\(endpoint.port)" - return "\(scheme)://\(endpoint.host)\(portSuffix)" - } - - private static func trimmed(_ value: String?) -> String? { - value?.trimmingCharacters(in: .whitespacesAndNewlines) - } - - private static func isLoopbackHost(_ rawHost: String) -> Bool { - let host = rawHost.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !host.isEmpty else { return false } - if host == "localhost" || host == "::1" || host == "0:0:0:0:0:0:0:1" { - return true - } - if host.hasPrefix("::ffff:127.") { - return true - } - return host.hasPrefix("127.") - } -} diff --git a/apps/macos/Sources/OpenClaw/GatewayDiscoveryMenu.swift b/apps/macos/Sources/OpenClaw/GatewayDiscoveryMenu.swift deleted file mode 100644 index babab5866fd..00000000000 --- a/apps/macos/Sources/OpenClaw/GatewayDiscoveryMenu.swift +++ /dev/null @@ -1,139 +0,0 @@ -import OpenClawDiscovery -import SwiftUI - -struct GatewayDiscoveryInlineList: View { - var discovery: GatewayDiscoveryModel - var currentTarget: String? - var currentUrl: String? - var transport: AppState.RemoteTransport - var onSelect: (GatewayDiscoveryModel.DiscoveredGateway) -> Void - @State private var hoveredGatewayID: GatewayDiscoveryModel.DiscoveredGateway.ID? - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - HStack(alignment: .firstTextBaseline, spacing: 6) { - Image(systemName: "dot.radiowaves.left.and.right") - .font(.caption) - .foregroundStyle(.secondary) - Text(self.discovery.statusText) - .font(.caption) - .foregroundStyle(.secondary) - } - - if self.discovery.gateways.isEmpty { - Text("No gateways found yet.") - .font(.caption) - .foregroundStyle(.secondary) - } else { - VStack(alignment: .leading, spacing: 6) { - ForEach(self.discovery.gateways.prefix(6)) { gateway in - let display = self.displayInfo(for: gateway) - let selected = display.selected - - Button { - withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) { - self.onSelect(gateway) - } - } label: { - HStack(alignment: .center, spacing: 10) { - VStack(alignment: .leading, spacing: 2) { - Text(gateway.displayName) - .font(.callout.weight(.semibold)) - .lineLimit(1) - .truncationMode(.tail) - Text(display.label) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - } - Spacer(minLength: 0) - if selected { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(Color.accentColor) - } else { - Image(systemName: "arrow.right.circle") - .foregroundStyle(.secondary) - } - } - .padding(.horizontal, 10) - .padding(.vertical, 8) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(self.rowBackground( - selected: selected, - hovered: self.hoveredGatewayID == gateway.id))) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .strokeBorder( - selected ? Color.accentColor.opacity(0.45) : Color.clear, - lineWidth: 1)) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .onHover { hovering in - self.hoveredGatewayID = hovering ? gateway - .id : (self.hoveredGatewayID == gateway.id ? nil : self.hoveredGatewayID) - } - } - } - .padding(10) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(Color(NSColor.controlBackgroundColor))) - } - } - .help(self.transport == .direct - ? "Click a discovered gateway to fill the gateway URL." - : "Click a discovered gateway to fill the SSH target.") - } - - private func displayInfo( - for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> (label: String, selected: Bool) - { - switch self.transport { - case .direct: - let url = GatewayDiscoveryHelpers.directUrl(for: gateway) - let label = url ?? "Gateway pairing only" - let selected = url != nil && self.trimmed(self.currentUrl) == url - return (label, selected) - case .ssh: - let target = GatewayDiscoveryHelpers.sshTarget(for: gateway) - let label = target ?? "Gateway pairing only" - let selected = target != nil && self.trimmed(self.currentTarget) == target - return (label, selected) - } - } - - private func rowBackground(selected: Bool, hovered: Bool) -> Color { - if selected { return Color.accentColor.opacity(0.12) } - if hovered { return Color.secondary.opacity(0.08) } - return Color.clear - } - - private func trimmed(_ value: String?) -> String { - value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - } -} - -struct GatewayDiscoveryMenu: View { - var discovery: GatewayDiscoveryModel - var onSelect: (GatewayDiscoveryModel.DiscoveredGateway) -> Void - - var body: some View { - Menu { - if self.discovery.gateways.isEmpty { - Button(self.discovery.statusText) {} - .disabled(true) - } else { - ForEach(self.discovery.gateways) { gateway in - Button(gateway.displayName) { self.onSelect(gateway) } - } - } - } label: { - Image(systemName: "dot.radiowaves.left.and.right") - } - .help("Discover OpenClaw gateways on your LAN") - } -} diff --git a/apps/macos/Sources/OpenClaw/GatewayDiscoveryPreferences.swift b/apps/macos/Sources/OpenClaw/GatewayDiscoveryPreferences.swift deleted file mode 100644 index d725fdba587..00000000000 --- a/apps/macos/Sources/OpenClaw/GatewayDiscoveryPreferences.swift +++ /dev/null @@ -1,25 +0,0 @@ -import Foundation - -enum GatewayDiscoveryPreferences { - private static let preferredStableIDKey = "gateway.preferredStableID" - private static let legacyPreferredStableIDKey = "bridge.preferredStableID" - - static func preferredStableID() -> String? { - let defaults = UserDefaults.standard - let raw = defaults.string(forKey: self.preferredStableIDKey) - ?? defaults.string(forKey: self.legacyPreferredStableIDKey) - let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed?.isEmpty == false ? trimmed : nil - } - - static func setPreferredStableID(_ stableID: String?) { - let trimmed = stableID?.trimmingCharacters(in: .whitespacesAndNewlines) - if let trimmed, !trimmed.isEmpty { - UserDefaults.standard.set(trimmed, forKey: self.preferredStableIDKey) - UserDefaults.standard.removeObject(forKey: self.legacyPreferredStableIDKey) - } else { - UserDefaults.standard.removeObject(forKey: self.preferredStableIDKey) - UserDefaults.standard.removeObject(forKey: self.legacyPreferredStableIDKey) - } - } -} diff --git a/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift b/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift deleted file mode 100644 index 0edb2e65122..00000000000 --- a/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift +++ /dev/null @@ -1,728 +0,0 @@ -import ConcurrencyExtras -import Foundation -import OSLog - -enum GatewayEndpointState: Sendable, Equatable { - case ready(mode: AppState.ConnectionMode, url: URL, token: String?, password: String?) - case connecting(mode: AppState.ConnectionMode, detail: String) - case unavailable(mode: AppState.ConnectionMode, reason: String) -} - -/// Single place to resolve (and publish) the effective gateway control endpoint. -/// -/// This is intentionally separate from `GatewayConnection`: -/// - `GatewayConnection` consumes the resolved endpoint (no tunnel side-effects). -/// - The endpoint store owns observation + explicit "ensure tunnel" actions. -actor GatewayEndpointStore { - static let shared = GatewayEndpointStore() - private static let supportedBindModes: Set = [ - "loopback", - "tailnet", - "lan", - "auto", - "custom", - ] - private static let remoteConnectingDetail = "Connecting to remote gateway…" - private static let staticLogger = Logger(subsystem: "ai.openclaw", category: "gateway-endpoint") - private enum EnvOverrideWarningKind: Sendable { - case token - case password - } - - private static let envOverrideWarnings = LockIsolated((token: false, password: false)) - - struct Deps: Sendable { - let mode: @Sendable () async -> AppState.ConnectionMode - let token: @Sendable () -> String? - let password: @Sendable () -> String? - let localPort: @Sendable () -> Int - let localHost: @Sendable () async -> String - let remotePortIfRunning: @Sendable () async -> UInt16? - let ensureRemoteTunnel: @Sendable () async throws -> UInt16 - - static let live = Deps( - mode: { await MainActor.run { AppStateStore.shared.connectionMode } }, - token: { - let root = OpenClawConfigFile.loadDict() - let isRemote = ConnectionModeResolver.resolve(root: root).mode == .remote - return GatewayEndpointStore.resolveGatewayToken( - isRemote: isRemote, - root: root, - env: ProcessInfo.processInfo.environment, - launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot()) - }, - password: { - let root = OpenClawConfigFile.loadDict() - let isRemote = ConnectionModeResolver.resolve(root: root).mode == .remote - return GatewayEndpointStore.resolveGatewayPassword( - isRemote: isRemote, - root: root, - env: ProcessInfo.processInfo.environment, - launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot()) - }, - localPort: { GatewayEnvironment.gatewayPort() }, - localHost: { - let root = OpenClawConfigFile.loadDict() - let bind = GatewayEndpointStore.resolveGatewayBindMode( - root: root, - env: ProcessInfo.processInfo.environment) - let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: root) - let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP } - ?? TailscaleService.fallbackTailnetIPv4() - return GatewayEndpointStore.resolveLocalGatewayHost( - bindMode: bind, - customBindHost: customBindHost, - tailscaleIP: tailscaleIP) - }, - remotePortIfRunning: { await RemoteTunnelManager.shared.controlTunnelPortIfRunning() }, - ensureRemoteTunnel: { try await RemoteTunnelManager.shared.ensureControlTunnel() }) - } - - private static func resolveGatewayPassword( - isRemote: Bool, - root: [String: Any], - env: [String: String], - launchdSnapshot: LaunchAgentPlistSnapshot?) -> String? - { - let raw = env["OPENCLAW_GATEWAY_PASSWORD"] ?? "" - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { - if let configPassword = self.resolveConfigPassword(isRemote: isRemote, root: root), - !configPassword.isEmpty - { - self.warnEnvOverrideOnce( - kind: .password, - envVar: "OPENCLAW_GATEWAY_PASSWORD", - configKey: isRemote ? "gateway.remote.password" : "gateway.auth.password") - } - return trimmed - } - if isRemote { - if let gateway = root["gateway"] as? [String: Any], - let remote = gateway["remote"] as? [String: Any], - let password = remote["password"] as? String - { - let pw = password.trimmingCharacters(in: .whitespacesAndNewlines) - if !pw.isEmpty { - return pw - } - } - return nil - } - if let gateway = root["gateway"] as? [String: Any], - let auth = gateway["auth"] as? [String: Any], - let password = auth["password"] as? String - { - let pw = password.trimmingCharacters(in: .whitespacesAndNewlines) - if !pw.isEmpty { - return pw - } - } - if let password = launchdSnapshot?.password?.trimmingCharacters(in: .whitespacesAndNewlines), - !password.isEmpty - { - return password - } - return nil - } - - private static func resolveConfigPassword(isRemote: Bool, root: [String: Any]) -> String? { - if isRemote { - if let gateway = root["gateway"] as? [String: Any], - let remote = gateway["remote"] as? [String: Any], - let password = remote["password"] as? String - { - return password.trimmingCharacters(in: .whitespacesAndNewlines) - } - return nil - } - - if let gateway = root["gateway"] as? [String: Any], - let auth = gateway["auth"] as? [String: Any], - let password = auth["password"] as? String - { - return password.trimmingCharacters(in: .whitespacesAndNewlines) - } - return nil - } - - private static func resolveGatewayToken( - isRemote: Bool, - root: [String: Any], - env: [String: String], - launchdSnapshot: LaunchAgentPlistSnapshot?) -> String? - { - let raw = env["OPENCLAW_GATEWAY_TOKEN"] ?? "" - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { - if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root), - !configToken.isEmpty, - configToken != trimmed - { - self.warnEnvOverrideOnce( - kind: .token, - envVar: "OPENCLAW_GATEWAY_TOKEN", - configKey: isRemote ? "gateway.remote.token" : "gateway.auth.token") - } - return trimmed - } - - if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root), - !configToken.isEmpty - { - return configToken - } - - if isRemote { - return nil - } - - if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines), - !token.isEmpty - { - return token - } - - return nil - } - - private static func resolveConfigToken(isRemote: Bool, root: [String: Any]) -> String? { - if isRemote { - if let gateway = root["gateway"] as? [String: Any], - let remote = gateway["remote"] as? [String: Any], - let token = remote["token"] as? String - { - return token.trimmingCharacters(in: .whitespacesAndNewlines) - } - return nil - } - - if let gateway = root["gateway"] as? [String: Any], - let auth = gateway["auth"] as? [String: Any], - let token = auth["token"] as? String - { - return token.trimmingCharacters(in: .whitespacesAndNewlines) - } - return nil - } - - private static func warnEnvOverrideOnce( - kind: EnvOverrideWarningKind, - envVar: String, - configKey: String) - { - let shouldWarn = Self.envOverrideWarnings.withValue { state in - switch kind { - case .token: - guard !state.token else { return false } - state.token = true - return true - case .password: - guard !state.password else { return false } - state.password = true - return true - } - } - guard shouldWarn else { return } - Self.staticLogger.warning( - "\(envVar, privacy: .public) is set and overrides \(configKey, privacy: .public). " + - "If this is unintentional, clear it with: launchctl unsetenv \(envVar, privacy: .public)") - } - - private let deps: Deps - private let logger = Logger(subsystem: "ai.openclaw", category: "gateway-endpoint") - - private var state: GatewayEndpointState - private var subscribers: [UUID: AsyncStream.Continuation] = [:] - private var remoteEnsure: (token: UUID, task: Task)? - - init(deps: Deps = .live) { - self.deps = deps - let modeRaw = UserDefaults.standard.string(forKey: connectionModeKey) - let initialMode: AppState.ConnectionMode - if let modeRaw { - initialMode = AppState.ConnectionMode(rawValue: modeRaw) ?? .local - } else { - let seen = UserDefaults.standard.bool(forKey: "openclaw.onboardingSeen") - initialMode = seen ? .local : .unconfigured - } - - let port = deps.localPort() - let bind = GatewayEndpointStore.resolveGatewayBindMode( - root: OpenClawConfigFile.loadDict(), - env: ProcessInfo.processInfo.environment) - let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: OpenClawConfigFile.loadDict()) - let scheme = GatewayEndpointStore.resolveGatewayScheme( - root: OpenClawConfigFile.loadDict(), - env: ProcessInfo.processInfo.environment) - let host = GatewayEndpointStore.resolveLocalGatewayHost( - bindMode: bind, - customBindHost: customBindHost, - tailscaleIP: nil) - let token = deps.token() - let password = deps.password() - switch initialMode { - case .local: - self.state = .ready( - mode: .local, - url: URL(string: "\(scheme)://\(host):\(port)")!, - token: token, - password: password) - case .remote: - self.state = .connecting(mode: .remote, detail: Self.remoteConnectingDetail) - Task { await self.setMode(.remote) } - case .unconfigured: - self.state = .unavailable(mode: .unconfigured, reason: "Gateway not configured") - } - } - - func subscribe(bufferingNewest: Int = 1) -> AsyncStream { - let id = UUID() - let initial = self.state - let store = self - return AsyncStream(bufferingPolicy: .bufferingNewest(bufferingNewest)) { continuation in - continuation.yield(initial) - self.subscribers[id] = continuation - continuation.onTermination = { @Sendable _ in - Task { await store.removeSubscriber(id) } - } - } - } - - func refresh() async { - let mode = await self.deps.mode() - await self.setMode(mode) - } - - func setMode(_ mode: AppState.ConnectionMode) async { - let token = self.deps.token() - let password = self.deps.password() - switch mode { - case .local: - self.cancelRemoteEnsure() - let port = self.deps.localPort() - let host = await self.deps.localHost() - let scheme = GatewayEndpointStore.resolveGatewayScheme( - root: OpenClawConfigFile.loadDict(), - env: ProcessInfo.processInfo.environment) - self.setState(.ready( - mode: .local, - url: URL(string: "\(scheme)://\(host):\(port)")!, - token: token, - password: password)) - case .remote: - let root = OpenClawConfigFile.loadDict() - if GatewayRemoteConfig.resolveTransport(root: root) == .direct { - guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else { - self.cancelRemoteEnsure() - self.setState(.unavailable( - mode: .remote, - reason: "gateway.remote.url missing or invalid for direct transport")) - return - } - self.cancelRemoteEnsure() - self.setState(.ready(mode: .remote, url: url, token: token, password: password)) - return - } - let port = await self.deps.remotePortIfRunning() - guard let port else { - self.setState(.connecting(mode: .remote, detail: Self.remoteConnectingDetail)) - self.kickRemoteEnsureIfNeeded(detail: Self.remoteConnectingDetail) - return - } - self.cancelRemoteEnsure() - let scheme = GatewayEndpointStore.resolveGatewayScheme( - root: OpenClawConfigFile.loadDict(), - env: ProcessInfo.processInfo.environment) - self.setState(.ready( - mode: .remote, - url: URL(string: "\(scheme)://127.0.0.1:\(Int(port))")!, - token: token, - password: password)) - case .unconfigured: - self.cancelRemoteEnsure() - self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured")) - } - } - - /// Explicit action: ensure the remote control tunnel is established and publish the resolved endpoint. - func ensureRemoteControlTunnel() async throws -> UInt16 { - let mode = await self.deps.mode() - guard mode == .remote else { - throw NSError( - domain: "RemoteTunnel", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"]) - } - let root = OpenClawConfigFile.loadDict() - if GatewayRemoteConfig.resolveTransport(root: root) == .direct { - guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else { - throw NSError( - domain: "GatewayEndpoint", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"]) - } - guard let port = GatewayRemoteConfig.defaultPort(for: url), - let portInt = UInt16(exactly: port) - else { - throw NSError( - domain: "GatewayEndpoint", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Invalid gateway.remote.url port"]) - } - self.logger.info("remote transport direct; skipping SSH tunnel") - return portInt - } - let config = try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail) - guard let portInt = config.0.port, let port = UInt16(exactly: portInt) else { - throw NSError( - domain: "GatewayEndpoint", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Missing tunnel port"]) - } - return port - } - - func requireConfig() async throws -> GatewayConnection.Config { - await self.refresh() - switch self.state { - case let .ready(_, url, token, password): - return (url, token, password) - case let .connecting(mode, _): - guard mode == .remote else { - throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"]) - } - return try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail) - case let .unavailable(mode, reason): - guard mode == .remote else { - throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: reason]) - } - - // Auto-recover for remote mode: if the SSH control tunnel died (or hasn't been created yet), - // recreate it on demand so callers can recover without a manual reconnect. - self.logger.info( - "endpoint unavailable; ensuring remote control tunnel reason=\(reason, privacy: .public)") - return try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail) - } - } - - private func cancelRemoteEnsure() { - self.remoteEnsure?.task.cancel() - self.remoteEnsure = nil - } - - private func kickRemoteEnsureIfNeeded(detail: String) { - if self.remoteEnsure != nil { - self.setState(.connecting(mode: .remote, detail: detail)) - return - } - - let deps = self.deps - let token = UUID() - let task = Task.detached(priority: .utility) { try await deps.ensureRemoteTunnel() } - self.remoteEnsure = (token: token, task: task) - self.setState(.connecting(mode: .remote, detail: detail)) - } - - private func ensureRemoteConfig(detail: String) async throws -> GatewayConnection.Config { - let mode = await self.deps.mode() - guard mode == .remote else { - throw NSError( - domain: "RemoteTunnel", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"]) - } - - let root = OpenClawConfigFile.loadDict() - if GatewayRemoteConfig.resolveTransport(root: root) == .direct { - guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else { - throw NSError( - domain: "GatewayEndpoint", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"]) - } - let token = self.deps.token() - let password = self.deps.password() - self.cancelRemoteEnsure() - self.setState(.ready(mode: .remote, url: url, token: token, password: password)) - return (url, token, password) - } - - self.kickRemoteEnsureIfNeeded(detail: detail) - guard let ensure = self.remoteEnsure else { - throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"]) - } - - do { - let forwarded = try await ensure.task.value - let stillRemote = await self.deps.mode() == .remote - guard stillRemote else { - throw NSError( - domain: "RemoteTunnel", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"]) - } - - if self.remoteEnsure?.token == ensure.token { - self.remoteEnsure = nil - } - - let token = self.deps.token() - let password = self.deps.password() - let scheme = GatewayEndpointStore.resolveGatewayScheme( - root: OpenClawConfigFile.loadDict(), - env: ProcessInfo.processInfo.environment) - let url = URL(string: "\(scheme)://127.0.0.1:\(Int(forwarded))")! - self.setState(.ready(mode: .remote, url: url, token: token, password: password)) - return (url, token, password) - } catch let err as CancellationError { - if self.remoteEnsure?.token == ensure.token { - self.remoteEnsure = nil - } - throw err - } catch { - if self.remoteEnsure?.token == ensure.token { - self.remoteEnsure = nil - } - let msg = "Remote control tunnel failed (\(error.localizedDescription))" - self.setState(.unavailable(mode: .remote, reason: msg)) - self.logger.error("remote control tunnel ensure failed \(msg, privacy: .public)") - throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: msg]) - } - } - - private func removeSubscriber(_ id: UUID) { - self.subscribers[id] = nil - } - - private func setState(_ next: GatewayEndpointState) { - guard next != self.state else { return } - self.state = next - for (_, continuation) in self.subscribers { - continuation.yield(next) - } - switch next { - case let .ready(mode, url, _, _): - let modeDesc = String(describing: mode) - let urlDesc = url.absoluteString - self.logger - .debug( - "resolved endpoint mode=\(modeDesc, privacy: .public) url=\(urlDesc, privacy: .public)") - case let .connecting(mode, detail): - let modeDesc = String(describing: mode) - self.logger - .debug( - "endpoint connecting mode=\(modeDesc, privacy: .public) detail=\(detail, privacy: .public)") - case let .unavailable(mode, reason): - let modeDesc = String(describing: mode) - self.logger - .debug( - "endpoint unavailable mode=\(modeDesc, privacy: .public) reason=\(reason, privacy: .public)") - } - } - - func maybeFallbackToTailnet(from currentURL: URL) async -> GatewayConnection.Config? { - let mode = await self.deps.mode() - guard mode == .local else { return nil } - - let root = OpenClawConfigFile.loadDict() - let bind = GatewayEndpointStore.resolveGatewayBindMode( - root: root, - env: ProcessInfo.processInfo.environment) - guard bind == "tailnet" else { return nil } - - let currentHost = currentURL.host?.lowercased() ?? "" - guard currentHost == "127.0.0.1" || currentHost == "localhost" else { return nil } - - let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP } - ?? TailscaleService.fallbackTailnetIPv4() - guard let tailscaleIP, !tailscaleIP.isEmpty else { return nil } - - let scheme = GatewayEndpointStore.resolveGatewayScheme( - root: root, - env: ProcessInfo.processInfo.environment) - let port = self.deps.localPort() - let token = self.deps.token() - let password = self.deps.password() - let url = URL(string: "\(scheme)://\(tailscaleIP):\(port)")! - - self.logger.info("auto bind fallback to tailnet host=\(tailscaleIP, privacy: .public)") - self.setState(.ready(mode: .local, url: url, token: token, password: password)) - return (url, token, password) - } - - private static func resolveGatewayBindMode( - root: [String: Any], - env: [String: String]) -> String? - { - if let envBind = env["OPENCLAW_GATEWAY_BIND"] { - let trimmed = envBind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if self.supportedBindModes.contains(trimmed) { - return trimmed - } - } - if let gateway = root["gateway"] as? [String: Any], - let bind = gateway["bind"] as? String - { - let trimmed = bind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if self.supportedBindModes.contains(trimmed) { - return trimmed - } - } - return nil - } - - private static func resolveGatewayCustomBindHost(root: [String: Any]) -> String? { - if let gateway = root["gateway"] as? [String: Any], - let customBindHost = gateway["customBindHost"] as? String - { - let trimmed = customBindHost.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } - return nil - } - - private static func resolveGatewayScheme( - root: [String: Any], - env: [String: String]) -> String - { - if let envValue = env["OPENCLAW_GATEWAY_TLS"]?.trimmingCharacters(in: .whitespacesAndNewlines), - !envValue.isEmpty - { - return (envValue == "1" || envValue.lowercased() == "true") ? "wss" : "ws" - } - if let gateway = root["gateway"] as? [String: Any], - let tls = gateway["tls"] as? [String: Any], - let enabled = tls["enabled"] as? Bool - { - return enabled ? "wss" : "ws" - } - return "ws" - } - - private static func resolveLocalGatewayHost( - bindMode: String?, - customBindHost: String?, - tailscaleIP: String?) -> String - { - switch bindMode { - case "tailnet": - tailscaleIP ?? "127.0.0.1" - case "auto": - "127.0.0.1" - case "custom": - customBindHost ?? "127.0.0.1" - default: - "127.0.0.1" - } - } -} - -extension GatewayEndpointStore { - private static func normalizeDashboardPath(_ rawPath: String?) -> String { - let trimmed = (rawPath ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return "/" } - let withLeadingSlash = trimmed.hasPrefix("/") ? trimmed : "/" + trimmed - guard withLeadingSlash != "/" else { return "/" } - return withLeadingSlash.hasSuffix("/") ? withLeadingSlash : withLeadingSlash + "/" - } - - private static func localControlUiBasePath() -> String { - let root = OpenClawConfigFile.loadDict() - guard let gateway = root["gateway"] as? [String: Any], - let controlUi = gateway["controlUi"] as? [String: Any] - else { - return "/" - } - return self.normalizeDashboardPath(controlUi["basePath"] as? String) - } - - static func dashboardURL( - for config: GatewayConnection.Config, - mode: AppState.ConnectionMode, - localBasePath: String? = nil) throws -> URL - { - guard var components = URLComponents(url: config.url, resolvingAgainstBaseURL: false) else { - throw NSError(domain: "Dashboard", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Invalid gateway URL", - ]) - } - switch components.scheme?.lowercased() { - case "ws": - components.scheme = "http" - case "wss": - components.scheme = "https" - default: - components.scheme = "http" - } - - let urlPath = self.normalizeDashboardPath(components.path) - if urlPath != "/" { - components.path = urlPath - } else if mode == .local { - let fallbackPath = localBasePath ?? self.localControlUiBasePath() - components.path = self.normalizeDashboardPath(fallbackPath) - } else { - components.path = "/" - } - - var queryItems: [URLQueryItem] = [] - if let token = config.token?.trimmingCharacters(in: .whitespacesAndNewlines), - !token.isEmpty - { - queryItems.append(URLQueryItem(name: "token", value: token)) - } - if let password = config.password?.trimmingCharacters(in: .whitespacesAndNewlines), - !password.isEmpty - { - queryItems.append(URLQueryItem(name: "password", value: password)) - } - components.queryItems = queryItems.isEmpty ? nil : queryItems - guard let url = components.url else { - throw NSError(domain: "Dashboard", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "Failed to build dashboard URL", - ]) - } - return url - } -} - -#if DEBUG -extension GatewayEndpointStore { - static func _testResolveGatewayPassword( - isRemote: Bool, - root: [String: Any], - env: [String: String], - launchdSnapshot: LaunchAgentPlistSnapshot? = nil) -> String? - { - self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env, launchdSnapshot: launchdSnapshot) - } - - static func _testResolveGatewayToken( - isRemote: Bool, - root: [String: Any], - env: [String: String], - launchdSnapshot: LaunchAgentPlistSnapshot? = nil) -> String? - { - self.resolveGatewayToken(isRemote: isRemote, root: root, env: env, launchdSnapshot: launchdSnapshot) - } - - static func _testResolveGatewayBindMode( - root: [String: Any], - env: [String: String]) -> String? - { - self.resolveGatewayBindMode(root: root, env: env) - } - - static func _testResolveLocalGatewayHost( - bindMode: String?, - tailscaleIP: String?, - customBindHost: String? = nil) -> String - { - self.resolveLocalGatewayHost( - bindMode: bindMode, - customBindHost: customBindHost, - tailscaleIP: tailscaleIP) - } -} -#endif diff --git a/apps/macos/Sources/OpenClaw/GatewayEnvironment.swift b/apps/macos/Sources/OpenClaw/GatewayEnvironment.swift deleted file mode 100644 index 059eb4da6e0..00000000000 --- a/apps/macos/Sources/OpenClaw/GatewayEnvironment.swift +++ /dev/null @@ -1,344 +0,0 @@ -import Foundation -import OpenClawIPC -import OSLog - -/// Lightweight SemVer helper (major.minor.patch only) for gateway compatibility checks. -struct Semver: Comparable, CustomStringConvertible, Sendable { - let major: Int - let minor: Int - let patch: Int - - var description: String { - "\(self.major).\(self.minor).\(self.patch)" - } - - static func < (lhs: Semver, rhs: Semver) -> Bool { - if lhs.major != rhs.major { return lhs.major < rhs.major } - if lhs.minor != rhs.minor { return lhs.minor < rhs.minor } - return lhs.patch < rhs.patch - } - - static func parse(_ raw: String?) -> Semver? { - guard let raw, !raw.isEmpty else { return nil } - let cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines) - .replacingOccurrences(of: "^v", with: "", options: .regularExpression) - let parts = cleaned.split(separator: ".") - guard parts.count >= 3, - let major = Int(parts[0]), - let minor = Int(parts[1]) - else { return nil } - // Strip prerelease suffix (e.g., "11-4" → "11", "5-beta.1" → "5") - let patchRaw = String(parts[2]) - guard let patchToken = patchRaw.split(whereSeparator: { $0 == "-" || $0 == "+" }).first, - let patchNumeric = Int(patchToken) - else { - return nil - } - return Semver(major: major, minor: minor, patch: patchNumeric) - } - - func compatible(with required: Semver) -> Bool { - // Same major and not older than required. - self.major == required.major && self >= required - } -} - -enum GatewayEnvironmentKind: Equatable { - case checking - case ok - case missingNode - case missingGateway - case incompatible(found: String, required: String) - case error(String) -} - -struct GatewayEnvironmentStatus: Equatable { - let kind: GatewayEnvironmentKind - let nodeVersion: String? - let gatewayVersion: String? - let requiredGateway: String? - let message: String - - static var checking: Self { - .init(kind: .checking, nodeVersion: nil, gatewayVersion: nil, requiredGateway: nil, message: "Checking…") - } -} - -struct GatewayCommandResolution { - let status: GatewayEnvironmentStatus - let command: [String]? -} - -enum GatewayEnvironment { - private static let logger = Logger(subsystem: "ai.openclaw", category: "gateway.env") - private static let supportedBindModes: Set = ["loopback", "tailnet", "lan", "auto"] - - static func gatewayPort() -> Int { - if let raw = ProcessInfo.processInfo.environment["OPENCLAW_GATEWAY_PORT"] { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - if let parsed = Int(trimmed), parsed > 0 { return parsed } - } - if let configPort = OpenClawConfigFile.gatewayPort(), configPort > 0 { - return configPort - } - let stored = UserDefaults.standard.integer(forKey: "gatewayPort") - return stored > 0 ? stored : 18789 - } - - static func expectedGatewayVersion() -> Semver? { - Semver.parse(self.expectedGatewayVersionString()) - } - - static func expectedGatewayVersionString() -> String? { - let bundleVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String - let trimmed = bundleVersion?.trimmingCharacters(in: .whitespacesAndNewlines) - return (trimmed?.isEmpty == false) ? trimmed : nil - } - - /// Exposed for tests so we can inject fake version checks without rewriting bundle metadata. - static func expectedGatewayVersion(from versionString: String?) -> Semver? { - Semver.parse(versionString) - } - - static func check() -> GatewayEnvironmentStatus { - let start = Date() - defer { - let elapsedMs = Int(Date().timeIntervalSince(start) * 1000) - if elapsedMs > 500 { - self.logger.warning("gateway env check slow (\(elapsedMs, privacy: .public)ms)") - } else { - self.logger.debug("gateway env check ok (\(elapsedMs, privacy: .public)ms)") - } - } - let expected = self.expectedGatewayVersion() - let expectedString = self.expectedGatewayVersionString() - - let projectRoot = CommandResolver.projectRoot() - let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot) - - switch RuntimeLocator.resolve(searchPaths: CommandResolver.preferredPaths()) { - case let .failure(err): - return GatewayEnvironmentStatus( - kind: .missingNode, - nodeVersion: nil, - gatewayVersion: nil, - requiredGateway: expectedString, - message: RuntimeLocator.describeFailure(err)) - case let .success(runtime): - let gatewayBin = CommandResolver.openclawExecutable() - - if gatewayBin == nil, projectEntrypoint == nil { - return GatewayEnvironmentStatus( - kind: .missingGateway, - nodeVersion: runtime.version.description, - gatewayVersion: nil, - requiredGateway: expectedString, - message: "openclaw CLI not found in PATH; install the CLI.") - } - - let installed = gatewayBin.flatMap { self.readGatewayVersion(binary: $0) } - ?? self.readLocalGatewayVersion(projectRoot: projectRoot) - - if let expected, let installed, !installed.compatible(with: expected) { - let expectedText = expectedString ?? expected.description - return GatewayEnvironmentStatus( - kind: .incompatible(found: installed.description, required: expectedText), - nodeVersion: runtime.version.description, - gatewayVersion: installed.description, - requiredGateway: expectedText, - message: """ - Gateway version \(installed.description) is incompatible with app \(expectedText); - install or update the global package. - """) - } - - let gatewayLabel = gatewayBin != nil ? "global" : "local" - let gatewayVersionText = installed?.description ?? "unknown" - // Avoid repeating "(local)" twice; if using the local entrypoint, show the path once. - let localPathHint = gatewayBin == nil && projectEntrypoint != nil - ? " (local: \(projectEntrypoint ?? "unknown"))" - : "" - let gatewayLabelText = gatewayBin != nil - ? "(\(gatewayLabel))" - : localPathHint.isEmpty ? "(\(gatewayLabel))" : localPathHint - return GatewayEnvironmentStatus( - kind: .ok, - nodeVersion: runtime.version.description, - gatewayVersion: gatewayVersionText, - requiredGateway: expectedString, - message: "Node \(runtime.version.description); gateway \(gatewayVersionText) \(gatewayLabelText)") - } - } - - static func resolveGatewayCommand() -> GatewayCommandResolution { - let start = Date() - defer { - let elapsedMs = Int(Date().timeIntervalSince(start) * 1000) - if elapsedMs > 500 { - self.logger.warning("gateway command resolve slow (\(elapsedMs, privacy: .public)ms)") - } else { - self.logger.debug("gateway command resolve ok (\(elapsedMs, privacy: .public)ms)") - } - } - let projectRoot = CommandResolver.projectRoot() - let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot) - let status = self.check() - let gatewayBin = CommandResolver.openclawExecutable() - let runtime = RuntimeLocator.resolve(searchPaths: CommandResolver.preferredPaths()) - - guard case .ok = status.kind else { - return GatewayCommandResolution(status: status, command: nil) - } - - let port = self.gatewayPort() - if let gatewayBin { - let bind = self.preferredGatewayBind() ?? "loopback" - let cmd = [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind] - return GatewayCommandResolution(status: status, command: cmd) - } - - if let entry = projectEntrypoint, - case let .success(resolvedRuntime) = runtime - { - let bind = self.preferredGatewayBind() ?? "loopback" - let cmd = [resolvedRuntime.path, entry, "gateway-daemon", "--port", "\(port)", "--bind", bind] - return GatewayCommandResolution(status: status, command: cmd) - } - - return GatewayCommandResolution(status: status, command: nil) - } - - private static func preferredGatewayBind() -> String? { - if CommandResolver.connectionModeIsRemote() { - return nil - } - if let env = ProcessInfo.processInfo.environment["OPENCLAW_GATEWAY_BIND"] { - let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if self.supportedBindModes.contains(trimmed) { - return trimmed - } - } - - let root = OpenClawConfigFile.loadDict() - if let gateway = root["gateway"] as? [String: Any], - let bind = gateway["bind"] as? String - { - let trimmed = bind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if self.supportedBindModes.contains(trimmed) { - return trimmed - } - } - - return nil - } - - static func installGlobal(version: Semver?, statusHandler: @escaping @Sendable (String) -> Void) async { - await self.installGlobal(versionString: version?.description, statusHandler: statusHandler) - } - - static func installGlobal(versionString: String?, statusHandler: @escaping @Sendable (String) -> Void) async { - let preferred = CommandResolver.preferredPaths().joined(separator: ":") - let trimmed = versionString?.trimmingCharacters(in: .whitespacesAndNewlines) - let target: String = if let trimmed, !trimmed.isEmpty { - trimmed - } else { - "latest" - } - let npm = CommandResolver.findExecutable(named: "npm") - let pnpm = CommandResolver.findExecutable(named: "pnpm") - let bun = CommandResolver.findExecutable(named: "bun") - let (label, cmd): (String, [String]) = - if let npm { - ("npm", [npm, "install", "-g", "openclaw@\(target)"]) - } else if let pnpm { - ("pnpm", [pnpm, "add", "-g", "openclaw@\(target)"]) - } else if let bun { - ("bun", [bun, "add", "-g", "openclaw@\(target)"]) - } else { - ("npm", ["npm", "install", "-g", "openclaw@\(target)"]) - } - - statusHandler("Installing openclaw@\(target) via \(label)…") - - func summarize(_ text: String) -> String? { - let lines = text - .split(whereSeparator: \.isNewline) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - guard let last = lines.last else { return nil } - let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) - return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized - } - - let response = await ShellExecutor.runDetailed(command: cmd, cwd: nil, env: ["PATH": preferred], timeout: 300) - if response.success { - statusHandler("Installed openclaw@\(target)") - } else { - if response.timedOut { - statusHandler("Install failed: timed out. Check your internet connection and try again.") - return - } - - let exit = response.exitCode.map { "exit \($0)" } ?? (response.errorMessage ?? "failed") - let detail = summarize(response.stderr) ?? summarize(response.stdout) - if let detail { - statusHandler("Install failed (\(exit)): \(detail)") - } else { - statusHandler("Install failed (\(exit))") - } - } - } - - // MARK: - Internals - - private static func readGatewayVersion(binary: String) -> Semver? { - let start = Date() - let process = Process() - process.executableURL = URL(fileURLWithPath: binary) - process.arguments = ["--version"] - process.environment = ["PATH": CommandResolver.preferredPaths().joined(separator: ":")] - - let pipe = Pipe() - process.standardOutput = pipe - process.standardError = pipe - do { - let data = try process.runAndReadToEnd(from: pipe) - let elapsedMs = Int(Date().timeIntervalSince(start) * 1000) - if elapsedMs > 500 { - self.logger.warning( - """ - gateway --version slow (\(elapsedMs, privacy: .public)ms) \ - bin=\(binary, privacy: .public) - """) - } else { - self.logger.debug( - """ - gateway --version ok (\(elapsedMs, privacy: .public)ms) \ - bin=\(binary, privacy: .public) - """) - } - let raw = String(data: data, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines) - return Semver.parse(raw) - } catch { - let elapsedMs = Int(Date().timeIntervalSince(start) * 1000) - self.logger.error( - """ - gateway --version failed (\(elapsedMs, privacy: .public)ms) \ - bin=\(binary, privacy: .public) \ - err=\(error.localizedDescription, privacy: .public) - """) - return nil - } - } - - private static func readLocalGatewayVersion(projectRoot: URL) -> Semver? { - let pkg = projectRoot.appendingPathComponent("package.json") - guard let data = try? Data(contentsOf: pkg) else { return nil } - guard - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let version = json["version"] as? String - else { return nil } - return Semver.parse(version) - } -} diff --git a/apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift b/apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift deleted file mode 100644 index 98743fec8b3..00000000000 --- a/apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift +++ /dev/null @@ -1,204 +0,0 @@ -import Foundation - -enum GatewayLaunchAgentManager { - private static let logger = Logger(subsystem: "ai.openclaw", category: "gateway.launchd") - private static let disableLaunchAgentMarker = ".openclaw/disable-launchagent" - - private static var disableLaunchAgentMarkerURL: URL { - FileManager().homeDirectoryForCurrentUser - .appendingPathComponent(self.disableLaunchAgentMarker) - } - - private static var plistURL: URL { - FileManager().homeDirectoryForCurrentUser - .appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist") - } - - static func isLaunchAgentWriteDisabled() -> Bool { - if FileManager().fileExists(atPath: self.disableLaunchAgentMarkerURL.path) { return true } - return false - } - - static func setLaunchAgentWriteDisabled(_ disabled: Bool) -> String? { - let marker = self.disableLaunchAgentMarkerURL - if disabled { - do { - try FileManager().createDirectory( - at: marker.deletingLastPathComponent(), - withIntermediateDirectories: true) - if !FileManager().fileExists(atPath: marker.path) { - FileManager().createFile(atPath: marker.path, contents: nil) - } - } catch { - return error.localizedDescription - } - return nil - } - - if FileManager().fileExists(atPath: marker.path) { - do { - try FileManager().removeItem(at: marker) - } catch { - return error.localizedDescription - } - } - return nil - } - - static func isLoaded() async -> Bool { - guard let loaded = await self.readDaemonLoaded() else { return false } - return loaded - } - - static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? { - _ = bundlePath - guard !CommandResolver.connectionModeIsRemote() else { - self.logger.info("launchd change skipped (remote mode)") - return nil - } - if enabled, self.isLaunchAgentWriteDisabled() { - self.logger.info("launchd enable skipped (disable marker set)") - return nil - } - - if enabled { - self.logger.info("launchd enable requested via CLI port=\(port)") - return await self.runDaemonCommand([ - "install", - "--force", - "--port", - "\(port)", - "--runtime", - "node", - ]) - } - - self.logger.info("launchd disable requested via CLI") - return await self.runDaemonCommand(["uninstall"]) - } - - static func kickstart() async { - _ = await self.runDaemonCommand(["restart"], timeout: 20) - } - - static func launchdConfigSnapshot() -> LaunchAgentPlistSnapshot? { - LaunchAgentPlist.snapshot(url: self.plistURL) - } - - static func launchdGatewayLogPath() -> String { - let snapshot = self.launchdConfigSnapshot() - if let stdout = snapshot?.stdoutPath?.trimmingCharacters(in: .whitespacesAndNewlines), - !stdout.isEmpty - { - return stdout - } - if let stderr = snapshot?.stderrPath?.trimmingCharacters(in: .whitespacesAndNewlines), - !stderr.isEmpty - { - return stderr - } - return LogLocator.launchdGatewayLogPath - } -} - -extension GatewayLaunchAgentManager { - private static func readDaemonLoaded() async -> Bool? { - let result = await self.runDaemonCommandResult( - ["status", "--json", "--no-probe"], - timeout: 15, - quiet: true) - guard result.success, let payload = result.payload else { return nil } - guard - let json = try? JSONSerialization.jsonObject(with: payload) as? [String: Any], - let service = json["service"] as? [String: Any], - let loaded = service["loaded"] as? Bool - else { - return nil - } - return loaded - } - - private struct CommandResult { - let success: Bool - let payload: Data? - let message: String? - } - - private struct ParsedDaemonJson { - let text: String - let object: [String: Any] - } - - private static func runDaemonCommand( - _ args: [String], - timeout: Double = 15, - quiet: Bool = false) async -> String? - { - let result = await self.runDaemonCommandResult(args, timeout: timeout, quiet: quiet) - if result.success { return nil } - return result.message ?? "Gateway daemon command failed" - } - - private static func runDaemonCommandResult( - _ args: [String], - timeout: Double, - quiet: Bool) async -> CommandResult - { - let command = CommandResolver.openclawCommand( - subcommand: "gateway", - extraArgs: self.withJsonFlag(args), - // Launchd management must always run locally, even if remote mode is configured. - configRoot: ["gateway": ["mode": "local"]]) - var env = ProcessInfo.processInfo.environment - env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":") - let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout) - let parsed = self.parseDaemonJson(from: response.stdout) ?? self.parseDaemonJson(from: response.stderr) - let ok = parsed?.object["ok"] as? Bool - let message = (parsed?.object["error"] as? String) ?? (parsed?.object["message"] as? String) - let payload = parsed?.text.data(using: .utf8) - ?? (response.stdout.isEmpty ? response.stderr : response.stdout).data(using: .utf8) - let success = ok ?? response.success - if success { - return CommandResult(success: true, payload: payload, message: nil) - } - - if quiet { - return CommandResult(success: false, payload: payload, message: message) - } - - let detail = message ?? self.summarize(response.stderr) ?? self.summarize(response.stdout) - let exit = response.exitCode.map { "exit \($0)" } ?? (response.errorMessage ?? "failed") - let fullMessage = detail.map { "Gateway daemon command failed (\(exit)): \($0)" } - ?? "Gateway daemon command failed (\(exit))" - self.logger.error("\(fullMessage, privacy: .public)") - return CommandResult(success: false, payload: payload, message: detail) - } - - private static func withJsonFlag(_ args: [String]) -> [String] { - if args.contains("--json") { return args } - return args + ["--json"] - } - - private static func parseDaemonJson(from raw: String) -> ParsedDaemonJson? { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard let start = trimmed.firstIndex(of: "{"), - let end = trimmed.lastIndex(of: "}") - else { - return nil - } - let jsonText = String(trimmed[start...end]) - guard let data = jsonText.data(using: .utf8) else { return nil } - guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } - return ParsedDaemonJson(text: jsonText, object: object) - } - - private static func summarize(_ text: String) -> String? { - let lines = text - .split(whereSeparator: \.isNewline) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - guard let last = lines.last else { return nil } - let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) - return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized - } -} diff --git a/apps/macos/Sources/OpenClaw/GatewayProcessManager.swift b/apps/macos/Sources/OpenClaw/GatewayProcessManager.swift deleted file mode 100644 index e3d5263e9bc..00000000000 --- a/apps/macos/Sources/OpenClaw/GatewayProcessManager.swift +++ /dev/null @@ -1,432 +0,0 @@ -import Foundation -import Observation - -@MainActor -@Observable -final class GatewayProcessManager { - static let shared = GatewayProcessManager() - - enum Status: Equatable { - case stopped - case starting - case running(details: String?) - case attachedExisting(details: String?) - case failed(String) - - var label: String { - switch self { - case .stopped: return "Stopped" - case .starting: return "Starting…" - case let .running(details): - if let details, !details.isEmpty { return "Running (\(details))" } - return "Running" - case let .attachedExisting(details): - if let details, !details.isEmpty { - return "Using existing gateway (\(details))" - } - return "Using existing gateway" - case let .failed(reason): return "Failed: \(reason)" - } - } - } - - private(set) var status: Status = .stopped { - didSet { CanvasManager.shared.refreshDebugStatus() } - } - - private(set) var log: String = "" - private(set) var environmentStatus: GatewayEnvironmentStatus = .checking - private(set) var existingGatewayDetails: String? - private(set) var lastFailureReason: String? - private var desiredActive = false - private var environmentRefreshTask: Task? - private var lastEnvironmentRefresh: Date? - private var logRefreshTask: Task? - #if DEBUG - private var testingConnection: GatewayConnection? - #endif - private let logger = Logger(subsystem: "ai.openclaw", category: "gateway.process") - - private let logLimit = 20000 // characters to keep in-memory - private let environmentRefreshMinInterval: TimeInterval = 30 - private var connection: GatewayConnection { - #if DEBUG - return self.testingConnection ?? .shared - #else - return .shared - #endif - } - - func setActive(_ active: Bool) { - // Remote mode should never spawn a local gateway; treat as stopped. - if CommandResolver.connectionModeIsRemote() { - self.desiredActive = false - self.stop() - self.status = .stopped - self.appendLog("[gateway] remote mode active; skipping local gateway\n") - self.logger.info("gateway process skipped: remote mode active") - return - } - self.logger.debug("gateway active requested active=\(active)") - self.desiredActive = active - self.refreshEnvironmentStatus() - if active { - self.startIfNeeded() - } else { - self.stop() - } - } - - func ensureLaunchAgentEnabledIfNeeded() async { - guard !CommandResolver.connectionModeIsRemote() else { return } - if GatewayLaunchAgentManager.isLaunchAgentWriteDisabled() { - self.appendLog("[gateway] launchd auto-enable skipped (attach-only)\n") - self.logger.info("gateway launchd auto-enable skipped (disable marker set)") - return - } - let enabled = await GatewayLaunchAgentManager.isLoaded() - guard !enabled else { return } - let bundlePath = Bundle.main.bundleURL.path - let port = GatewayEnvironment.gatewayPort() - self.appendLog("[gateway] auto-enabling launchd job (\(gatewayLaunchdLabel)) on port \(port)\n") - let err = await GatewayLaunchAgentManager.set(enabled: true, bundlePath: bundlePath, port: port) - if let err { - self.appendLog("[gateway] launchd auto-enable failed: \(err)\n") - } - } - - func startIfNeeded() { - guard self.desiredActive else { return } - // Do not spawn in remote mode (the gateway should run on the remote host). - guard !CommandResolver.connectionModeIsRemote() else { - self.status = .stopped - return - } - // Many surfaces can call `setActive(true)` in quick succession (startup, Canvas, health checks). - // Avoid spawning multiple concurrent "start" tasks that can thrash launchd and flap the port. - switch self.status { - case .starting, .running, .attachedExisting: - return - case .stopped, .failed: - break - } - self.status = .starting - self.logger.debug("gateway start requested") - - // First try to latch onto an already-running gateway to avoid spawning a duplicate. - Task { [weak self] in - guard let self else { return } - if await self.attachExistingGatewayIfAvailable() { - return - } - await self.enableLaunchdGateway() - } - } - - func stop() { - self.desiredActive = false - self.existingGatewayDetails = nil - self.lastFailureReason = nil - self.status = .stopped - self.logger.info("gateway stop requested") - if CommandResolver.connectionModeIsRemote() { - return - } - let bundlePath = Bundle.main.bundleURL.path - Task { - _ = await GatewayLaunchAgentManager.set( - enabled: false, - bundlePath: bundlePath, - port: GatewayEnvironment.gatewayPort()) - } - } - - func clearLastFailure() { - self.lastFailureReason = nil - } - - func refreshEnvironmentStatus(force: Bool = false) { - let now = Date() - if !force { - if self.environmentRefreshTask != nil { return } - if let last = self.lastEnvironmentRefresh, - now.timeIntervalSince(last) < self.environmentRefreshMinInterval - { - return - } - } - self.lastEnvironmentRefresh = now - self.environmentRefreshTask = Task { [weak self] in - let status = await Task.detached(priority: .utility) { - GatewayEnvironment.check() - }.value - await MainActor.run { - guard let self else { return } - self.environmentStatus = status - self.environmentRefreshTask = nil - } - } - } - - func refreshLog() { - guard self.logRefreshTask == nil else { return } - let path = GatewayLaunchAgentManager.launchdGatewayLogPath() - let limit = self.logLimit - self.logRefreshTask = Task { [weak self] in - let log = await Task.detached(priority: .utility) { - Self.readGatewayLog(path: path, limit: limit) - }.value - await MainActor.run { - guard let self else { return } - if !log.isEmpty { - self.log = log - } - self.logRefreshTask = nil - } - } - } - - // MARK: - Internals - - /// Attempt to connect to an already-running gateway on the configured port. - /// If successful, mark status as attached and skip spawning a new process. - private func attachExistingGatewayIfAvailable() async -> Bool { - let port = GatewayEnvironment.gatewayPort() - let instance = await PortGuardian.shared.describe(port: port) - let instanceText = instance.map { self.describe(instance: $0) } - let hasListener = instance != nil - - let attemptAttach = { - try await self.connection.requestRaw(method: .health, timeoutMs: 2000) - } - - for attempt in 0..<(hasListener ? 3 : 1) { - do { - let data = try await attemptAttach() - let snap = decodeHealthSnapshot(from: data) - let details = self.describe(details: instanceText, port: port, snap: snap) - self.existingGatewayDetails = details - self.clearLastFailure() - self.status = .attachedExisting(details: details) - self.appendLog("[gateway] using existing instance: \(details)\n") - self.logger.info("gateway using existing instance details=\(details)") - self.refreshControlChannelIfNeeded(reason: "attach existing") - self.refreshLog() - return true - } catch { - if attempt < 2, hasListener { - try? await Task.sleep(nanoseconds: 250_000_000) - continue - } - - if hasListener { - let reason = self.describeAttachFailure(error, port: port, instance: instance) - self.existingGatewayDetails = instanceText - self.status = .failed(reason) - self.lastFailureReason = reason - self.appendLog("[gateway] existing listener on port \(port) but attach failed: \(reason)\n") - self.logger.warning("gateway attach failed reason=\(reason)") - return true - } - - // No reachable gateway (and no listener) — fall through to spawn. - self.existingGatewayDetails = nil - return false - } - } - - self.existingGatewayDetails = nil - return false - } - - private func describe(details instance: String?, port: Int, snap: HealthSnapshot?) -> String { - let instanceText = instance ?? "pid unknown" - if let snap { - let order = snap.channelOrder ?? Array(snap.channels.keys) - let linkId = order.first(where: { snap.channels[$0]?.linked == true }) - ?? order.first(where: { snap.channels[$0]?.linked != nil }) - guard let linkId else { - return "port \(port), health probe succeeded, \(instanceText)" - } - let linked = snap.channels[linkId]?.linked ?? false - let authAge = snap.channels[linkId]?.authAgeMs.flatMap(msToAge) ?? "unknown age" - let label = - snap.channelLabels?[linkId] ?? - linkId.capitalized - let linkText = linked ? "linked" : "not linked" - return "port \(port), \(label) \(linkText), auth \(authAge), \(instanceText)" - } - return "port \(port), health probe succeeded, \(instanceText)" - } - - private func describe(instance: PortGuardian.Descriptor) -> String { - let path = instance.executablePath ?? "path unknown" - return "pid \(instance.pid) \(instance.command) @ \(path)" - } - - private func describeAttachFailure(_ error: Error, port: Int, instance: PortGuardian.Descriptor?) -> String { - let ns = error as NSError - let message = ns.localizedDescription.isEmpty ? "unknown error" : ns.localizedDescription - let lower = message.lowercased() - if self.isGatewayAuthFailure(error) { - return """ - Gateway on port \(port) rejected auth. Set gateway.auth.token to match the running gateway \ - (or clear it on the gateway) and retry. - """ - } - if lower.contains("protocol mismatch") { - return "Gateway on port \(port) is incompatible (protocol mismatch). Update the app/gateway." - } - if lower.contains("unexpected response") || lower.contains("invalid response") { - return "Port \(port) returned non-gateway data; another process is using it." - } - if let instance { - let instanceText = self.describe(instance: instance) - return "Gateway listener found on port \(port) (\(instanceText)) but health check failed: \(message)" - } - return "Gateway listener found on port \(port) but health check failed: \(message)" - } - - private func isGatewayAuthFailure(_ error: Error) -> Bool { - if let urlError = error as? URLError, urlError.code == .dataNotAllowed { - return true - } - let ns = error as NSError - if ns.domain == "Gateway", ns.code == 1008 { return true } - let lower = ns.localizedDescription.lowercased() - return lower.contains("unauthorized") || lower.contains("auth") - } - - private func enableLaunchdGateway() async { - self.existingGatewayDetails = nil - let resolution = await Task.detached(priority: .utility) { - GatewayEnvironment.resolveGatewayCommand() - }.value - await MainActor.run { self.environmentStatus = resolution.status } - guard resolution.command != nil else { - await MainActor.run { - self.status = .failed(resolution.status.message) - } - self.logger.error("gateway command resolve failed: \(resolution.status.message)") - return - } - - if GatewayLaunchAgentManager.isLaunchAgentWriteDisabled() { - let message = "Launchd disabled; start the Gateway manually or disable attach-only." - self.status = .failed(message) - self.lastFailureReason = "launchd disabled" - self.appendLog("[gateway] launchd disabled; skipping auto-start\n") - self.logger.info("gateway launchd enable skipped (disable marker set)") - return - } - - let bundlePath = Bundle.main.bundleURL.path - let port = GatewayEnvironment.gatewayPort() - self.appendLog("[gateway] enabling launchd job (\(gatewayLaunchdLabel)) on port \(port)\n") - self.logger.info("gateway enabling launchd port=\(port)") - let err = await GatewayLaunchAgentManager.set(enabled: true, bundlePath: bundlePath, port: port) - if let err { - self.status = .failed(err) - self.lastFailureReason = err - self.logger.error("gateway launchd enable failed: \(err)") - return - } - - // Best-effort: wait for the gateway to accept connections. - let deadline = Date().addingTimeInterval(6) - while Date() < deadline { - if !self.desiredActive { return } - do { - _ = try await self.connection.requestRaw(method: .health, timeoutMs: 1500) - let instance = await PortGuardian.shared.describe(port: port) - let details = instance.map { "pid \($0.pid)" } - self.clearLastFailure() - self.status = .running(details: details) - self.logger.info("gateway started details=\(details ?? "ok")") - self.refreshControlChannelIfNeeded(reason: "gateway started") - self.refreshLog() - return - } catch { - try? await Task.sleep(nanoseconds: 400_000_000) - } - } - - self.status = .failed("Gateway did not start in time") - self.lastFailureReason = "launchd start timeout" - self.logger.warning("gateway start timed out") - } - - private func appendLog(_ chunk: String) { - self.log.append(chunk) - if self.log.count > self.logLimit { - self.log = String(self.log.suffix(self.logLimit)) - } - } - - private func refreshControlChannelIfNeeded(reason: String) { - switch ControlChannel.shared.state { - case .connected, .connecting: - return - case .disconnected, .degraded: - break - } - self.appendLog("[gateway] refreshing control channel (\(reason))\n") - self.logger.debug("gateway control channel refresh reason=\(reason)") - Task { await ControlChannel.shared.configure() } - } - - func waitForGatewayReady(timeout: TimeInterval = 6) async -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if !self.desiredActive { return false } - do { - _ = try await self.connection.requestRaw(method: .health, timeoutMs: 1500) - self.clearLastFailure() - return true - } catch { - try? await Task.sleep(nanoseconds: 300_000_000) - } - } - self.appendLog("[gateway] readiness wait timed out\n") - self.logger.warning("gateway readiness wait timed out") - return false - } - - func clearLog() { - self.log = "" - try? FileManager().removeItem(atPath: GatewayLaunchAgentManager.launchdGatewayLogPath()) - self.logger.debug("gateway log cleared") - } - - func setProjectRoot(path: String) { - CommandResolver.setProjectRoot(path) - } - - func projectRootPath() -> String { - CommandResolver.projectRootPath() - } - - private nonisolated static func readGatewayLog(path: String, limit: Int) -> String { - guard FileManager().fileExists(atPath: path) else { return "" } - guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return "" } - let text = String(data: data, encoding: .utf8) ?? "" - if text.count <= limit { return text } - return String(text.suffix(limit)) - } -} - -#if DEBUG -extension GatewayProcessManager { - func setTestingConnection(_ connection: GatewayConnection?) { - self.testingConnection = connection - } - - func setTestingDesiredActive(_ active: Bool) { - self.desiredActive = active - } - - func setTestingLastFailureReason(_ reason: String?) { - self.lastFailureReason = reason - } -} -#endif diff --git a/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift b/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift deleted file mode 100644 index 64a6f92db8f..00000000000 --- a/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift +++ /dev/null @@ -1,102 +0,0 @@ -import Foundation -import Network - -enum GatewayRemoteConfig { - private static func isLoopbackHost(_ rawHost: String) -> Bool { - var host = rawHost - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - .trimmingCharacters(in: CharacterSet(charactersIn: "[]")) - if host.hasSuffix(".") { - host.removeLast() - } - if let zoneIndex = host.firstIndex(of: "%") { - host = String(host[.. AppState.RemoteTransport { - guard let gateway = root["gateway"] as? [String: Any], - let remote = gateway["remote"] as? [String: Any], - let raw = remote["transport"] as? String - else { - return .ssh - } - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - return trimmed == AppState.RemoteTransport.direct.rawValue ? .direct : .ssh - } - - static func resolveUrlString(root: [String: Any]) -> String? { - guard let gateway = root["gateway"] as? [String: Any], - let remote = gateway["remote"] as? [String: Any], - let urlRaw = remote["url"] as? String - else { - return nil - } - let trimmed = urlRaw.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } - - static func resolveGatewayUrl(root: [String: Any]) -> URL? { - guard let raw = self.resolveUrlString(root: root) else { return nil } - return self.normalizeGatewayUrl(raw) - } - - static func normalizeGatewayUrlString(_ raw: String) -> String? { - self.normalizeGatewayUrl(raw)?.absoluteString - } - - static func normalizeGatewayUrl(_ raw: String) -> URL? { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, let url = URL(string: trimmed) else { return nil } - let scheme = url.scheme?.lowercased() ?? "" - guard scheme == "ws" || scheme == "wss" else { return nil } - let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !host.isEmpty else { return nil } - if scheme == "ws", !self.isLoopbackHost(host) { - return nil - } - if scheme == "ws", url.port == nil { - guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { - return url - } - components.port = 18789 - return components.url - } - return url - } - - static func defaultPort(for url: URL) -> Int? { - if let port = url.port { return port } - let scheme = url.scheme?.lowercased() ?? "" - switch scheme { - case "wss": - return 443 - case "ws": - return 18789 - default: - return nil - } - } -} diff --git a/apps/macos/Sources/OpenClaw/GeneralSettings.swift b/apps/macos/Sources/OpenClaw/GeneralSettings.swift deleted file mode 100644 index 60cfdfb1d73..00000000000 --- a/apps/macos/Sources/OpenClaw/GeneralSettings.swift +++ /dev/null @@ -1,743 +0,0 @@ -import AppKit -import Observation -import OpenClawDiscovery -import OpenClawIPC -import OpenClawKit -import SwiftUI - -struct GeneralSettings: View { - @Bindable var state: AppState - @AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false - private let healthStore = HealthStore.shared - private let gatewayManager = GatewayProcessManager.shared - @State private var gatewayDiscovery = GatewayDiscoveryModel( - localDisplayName: InstanceIdentity.displayName) - @State private var gatewayStatus: GatewayEnvironmentStatus = .checking - @State private var remoteStatus: RemoteStatus = .idle - @State private var showRemoteAdvanced = false - private let isPreview = ProcessInfo.processInfo.isPreview - private var isNixMode: Bool { - ProcessInfo.processInfo.isNixMode - } - - private var remoteLabelWidth: CGFloat { - 88 - } - - var body: some View { - ScrollView(.vertical) { - VStack(alignment: .leading, spacing: 18) { - VStack(alignment: .leading, spacing: 12) { - SettingsToggleRow( - title: "OpenClaw active", - subtitle: "Pause to stop the OpenClaw gateway; no messages will be processed.", - binding: self.activeBinding) - - self.connectionSection - - Divider() - - SettingsToggleRow( - title: "Launch at login", - subtitle: "Automatically start OpenClaw after you sign in.", - binding: self.$state.launchAtLogin) - - SettingsToggleRow( - title: "Show Dock icon", - subtitle: "Keep OpenClaw visible in the Dock instead of menu-bar-only mode.", - binding: self.$state.showDockIcon) - - SettingsToggleRow( - title: "Play menu bar icon animations", - subtitle: "Enable idle blinks and wiggles on the status icon.", - binding: self.$state.iconAnimationsEnabled) - - SettingsToggleRow( - title: "Allow Canvas", - subtitle: "Allow the agent to show and control the Canvas panel.", - binding: self.$state.canvasEnabled) - - SettingsToggleRow( - title: "Allow Camera", - subtitle: "Allow the agent to capture a photo or short video via the built-in camera.", - binding: self.$cameraEnabled) - - SettingsToggleRow( - title: "Enable Peekaboo Bridge", - subtitle: "Allow signed tools (e.g. `peekaboo`) to drive UI automation via PeekabooBridge.", - binding: self.$state.peekabooBridgeEnabled) - - SettingsToggleRow( - title: "Enable debug tools", - subtitle: "Show the Debug tab with development utilities.", - binding: self.$state.debugPaneEnabled) - } - - Spacer(minLength: 12) - HStack { - Spacer() - Button("Quit OpenClaw") { NSApp.terminate(nil) } - .buttonStyle(.borderedProminent) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 22) - .padding(.bottom, 16) - } - .onAppear { - guard !self.isPreview else { return } - self.refreshGatewayStatus() - } - .onChange(of: self.state.canvasEnabled) { _, enabled in - if !enabled { - CanvasManager.shared.hideAll() - } - } - } - - private var activeBinding: Binding { - Binding( - get: { !self.state.isPaused }, - set: { self.state.isPaused = !$0 }) - } - - private var connectionSection: some View { - VStack(alignment: .leading, spacing: 10) { - Text("OpenClaw runs") - .font(.title3.weight(.semibold)) - .frame(maxWidth: .infinity, alignment: .leading) - - Picker("Mode", selection: self.$state.connectionMode) { - Text("Not configured").tag(AppState.ConnectionMode.unconfigured) - Text("Local (this Mac)").tag(AppState.ConnectionMode.local) - Text("Remote (another host)").tag(AppState.ConnectionMode.remote) - } - .pickerStyle(.menu) - .labelsHidden() - .frame(width: 260, alignment: .leading) - - if self.state.connectionMode == .unconfigured { - Text("Pick Local or Remote to start the Gateway.") - .font(.footnote) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - if self.state.connectionMode == .local { - // In Nix mode, gateway is managed declaratively - no install buttons. - if !self.isNixMode { - self.gatewayInstallerCard - } - TailscaleIntegrationSection( - connectionMode: self.state.connectionMode, - isPaused: self.state.isPaused) - self.healthRow - } - - if self.state.connectionMode == .remote { - self.remoteCard - } - } - } - - private var remoteCard: some View { - VStack(alignment: .leading, spacing: 10) { - self.remoteTransportRow - - if self.state.remoteTransport == .ssh { - self.remoteSshRow - } else { - self.remoteDirectRow - } - - GatewayDiscoveryInlineList( - discovery: self.gatewayDiscovery, - currentTarget: self.state.remoteTarget, - currentUrl: self.state.remoteUrl, - transport: self.state.remoteTransport) - { gateway in - self.applyDiscoveredGateway(gateway) - } - .padding(.leading, self.remoteLabelWidth + 10) - - self.remoteStatusView - .padding(.leading, self.remoteLabelWidth + 10) - - if self.state.remoteTransport == .ssh { - DisclosureGroup(isExpanded: self.$showRemoteAdvanced) { - VStack(alignment: .leading, spacing: 8) { - LabeledContent("Identity file") { - TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity) - .textFieldStyle(.roundedBorder) - .frame(width: 280) - } - LabeledContent("Project root") { - TextField("/home/you/Projects/openclaw", text: self.$state.remoteProjectRoot) - .textFieldStyle(.roundedBorder) - .frame(width: 280) - } - LabeledContent("CLI path") { - TextField("/Applications/OpenClaw.app/.../openclaw", text: self.$state.remoteCliPath) - .textFieldStyle(.roundedBorder) - .frame(width: 280) - } - } - .padding(.top, 4) - } label: { - Text("Advanced") - .font(.callout.weight(.semibold)) - } - } - - // Diagnostics - VStack(alignment: .leading, spacing: 4) { - Text("Control channel") - .font(.caption.weight(.semibold)) - if !self.isControlStatusDuplicate || ControlChannel.shared.lastPingMs != nil { - let status = self.isControlStatusDuplicate ? nil : self.controlStatusLine - let ping = ControlChannel.shared.lastPingMs.map { "Ping \(Int($0)) ms" } - let line = [status, ping].compactMap(\.self).joined(separator: " · ") - if !line.isEmpty { - Text(line) - .font(.caption) - .foregroundStyle(.secondary) - } - } - if let hb = HeartbeatStore.shared.lastEvent { - let ageText = age(from: Date(timeIntervalSince1970: hb.ts / 1000)) - Text("Last heartbeat: \(hb.status) · \(ageText)") - .font(.caption) - .foregroundStyle(.secondary) - } - if let authLabel = ControlChannel.shared.authSourceLabel { - Text(authLabel) - .font(.caption) - .foregroundStyle(.secondary) - } - } - - if self.state.remoteTransport == .ssh { - Text("Tip: enable Tailscale for stable remote access.") - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(1) - } else { - Text("Tip: use Tailscale Serve so the gateway has a valid HTTPS cert.") - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(2) - } - } - .transition(.opacity) - .onAppear { self.gatewayDiscovery.start() } - .onDisappear { self.gatewayDiscovery.stop() } - } - - private var remoteTransportRow: some View { - HStack(alignment: .center, spacing: 10) { - Text("Transport") - .font(.callout.weight(.semibold)) - .frame(width: self.remoteLabelWidth, alignment: .leading) - Picker("Transport", selection: self.$state.remoteTransport) { - Text("SSH tunnel").tag(AppState.RemoteTransport.ssh) - Text("Direct (ws/wss)").tag(AppState.RemoteTransport.direct) - } - .pickerStyle(.segmented) - .frame(maxWidth: 320) - } - } - - private var remoteSshRow: some View { - let trimmedTarget = self.state.remoteTarget.trimmingCharacters(in: .whitespacesAndNewlines) - let validationMessage = CommandResolver.sshTargetValidationMessage(trimmedTarget) - let canTest = !trimmedTarget.isEmpty && validationMessage == nil - - return VStack(alignment: .leading, spacing: 4) { - HStack(alignment: .center, spacing: 10) { - Text("SSH target") - .font(.callout.weight(.semibold)) - .frame(width: self.remoteLabelWidth, alignment: .leading) - TextField("user@host[:22]", text: self.$state.remoteTarget) - .textFieldStyle(.roundedBorder) - .frame(maxWidth: .infinity) - Button { - Task { await self.testRemote() } - } label: { - if self.remoteStatus == .checking { - ProgressView().controlSize(.small) - } else { - Text("Test remote") - } - } - .buttonStyle(.borderedProminent) - .disabled(self.remoteStatus == .checking || !canTest) - } - if let validationMessage { - Text(validationMessage) - .font(.caption) - .foregroundStyle(.red) - .padding(.leading, self.remoteLabelWidth + 10) - } - } - } - - private var remoteDirectRow: some View { - VStack(alignment: .leading, spacing: 6) { - HStack(alignment: .center, spacing: 10) { - Text("Gateway") - .font(.callout.weight(.semibold)) - .frame(width: self.remoteLabelWidth, alignment: .leading) - TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl) - .textFieldStyle(.roundedBorder) - .frame(maxWidth: .infinity) - Button { - Task { await self.testRemote() } - } label: { - if self.remoteStatus == .checking { - ProgressView().controlSize(.small) - } else { - Text("Test remote") - } - } - .buttonStyle(.borderedProminent) - .disabled(self.remoteStatus == .checking || self.state.remoteUrl - .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - Text( - "Direct mode requires wss:// for remote hosts. ws:// is only allowed for localhost/127.0.0.1." - ) - .font(.caption) - .foregroundStyle(.secondary) - .padding(.leading, self.remoteLabelWidth + 10) - } - } - - private var controlStatusLine: String { - switch ControlChannel.shared.state { - case .connected: "Connected" - case .connecting: "Connecting…" - case .disconnected: "Disconnected" - case let .degraded(msg): msg - } - } - - @ViewBuilder - private var remoteStatusView: some View { - switch self.remoteStatus { - case .idle: - EmptyView() - case .checking: - Text("Testing…") - .font(.caption) - .foregroundStyle(.secondary) - case .ok: - Label("Ready", systemImage: "checkmark.circle.fill") - .font(.caption) - .foregroundStyle(.green) - case let .failed(message): - Text(message) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(2) - } - } - - private var isControlStatusDuplicate: Bool { - guard case let .failed(message) = self.remoteStatus else { return false } - return message == self.controlStatusLine - } - - private var gatewayInstallerCard: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 10) { - Circle() - .fill(self.gatewayStatusColor) - .frame(width: 10, height: 10) - Text(self.gatewayStatus.message) - .font(.callout) - .frame(maxWidth: .infinity, alignment: .leading) - } - - if let gatewayVersion = self.gatewayStatus.gatewayVersion, - let required = self.gatewayStatus.requiredGateway, - gatewayVersion != required - { - Text("Installed: \(gatewayVersion) · Required: \(required)") - .font(.caption) - .foregroundStyle(.secondary) - } else if let gatewayVersion = self.gatewayStatus.gatewayVersion { - Text("Gateway \(gatewayVersion) detected") - .font(.caption) - .foregroundStyle(.secondary) - } - - if let node = self.gatewayStatus.nodeVersion { - Text("Node \(node)") - .font(.caption) - .foregroundStyle(.secondary) - } - - if case let .attachedExisting(details) = self.gatewayManager.status { - Text(details ?? "Using existing gateway instance") - .font(.caption) - .foregroundStyle(.secondary) - } - - if let failure = self.gatewayManager.lastFailureReason { - Text("Last failure: \(failure)") - .font(.caption) - .foregroundStyle(.red) - } - - Button("Recheck") { self.refreshGatewayStatus() } - .buttonStyle(.bordered) - - Text("Gateway auto-starts in local mode via launchd (\(gatewayLaunchdLabel)).") - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(2) - } - .padding(12) - .background(Color.gray.opacity(0.08)) - .cornerRadius(10) - } - - private func refreshGatewayStatus() { - Task { - let status = await Task.detached(priority: .utility) { - GatewayEnvironment.check() - }.value - self.gatewayStatus = status - } - } - - private var gatewayStatusColor: Color { - switch self.gatewayStatus.kind { - case .ok: .green - case .checking: .secondary - case .missingNode, .missingGateway, .incompatible, .error: .orange - } - } - - private var healthCard: some View { - let snapshot = self.healthStore.snapshot - return VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 8) { - Circle() - .fill(self.healthStore.state.tint) - .frame(width: 10, height: 10) - Text(self.healthStore.summaryLine) - .font(.callout.weight(.semibold)) - } - - if let snap = snapshot { - let linkId = snap.channelOrder?.first(where: { - if let summary = snap.channels[$0] { return summary.linked != nil } - return false - }) ?? snap.channels.keys.first(where: { - if let summary = snap.channels[$0] { return summary.linked != nil } - return false - }) - let linkLabel = - linkId.flatMap { snap.channelLabels?[$0] } ?? - linkId?.capitalized ?? - "Link channel" - let linkAge = linkId.flatMap { snap.channels[$0]?.authAgeMs } - Text("\(linkLabel) auth age: \(healthAgeString(linkAge))") - .font(.caption) - .foregroundStyle(.secondary) - Text("Session store: \(snap.sessions.path) (\(snap.sessions.count) entries)") - .font(.caption) - .foregroundStyle(.secondary) - if let recent = snap.sessions.recent.first { - let lastActivity = recent.updatedAt != nil - ? relativeAge(from: Date(timeIntervalSince1970: (recent.updatedAt ?? 0) / 1000)) - : "unknown" - Text("Last activity: \(recent.key) \(lastActivity)") - .font(.caption) - .foregroundStyle(.secondary) - } - Text("Last check: \(relativeAge(from: self.healthStore.lastSuccess))") - .font(.caption) - .foregroundStyle(.secondary) - } else if let error = self.healthStore.lastError { - Text(error) - .font(.caption) - .foregroundStyle(.red) - } else { - Text("Health check pending…") - .font(.caption) - .foregroundStyle(.secondary) - } - - HStack(spacing: 12) { - Button { - Task { await self.healthStore.refresh(onDemand: true) } - } label: { - if self.healthStore.isRefreshing { - ProgressView().controlSize(.small) - } else { - Label("Run Health Check", systemImage: "arrow.clockwise") - } - } - .disabled(self.healthStore.isRefreshing) - - Divider().frame(height: 18) - - Button { - self.revealLogs() - } label: { - Label("Reveal Logs", systemImage: "doc.text.magnifyingglass") - } - } - } - .padding(12) - .background(Color.gray.opacity(0.08)) - .cornerRadius(10) - } -} - -private enum RemoteStatus: Equatable { - case idle - case checking - case ok - case failed(String) -} - -extension GeneralSettings { - private var healthRow: some View { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 10) { - Circle() - .fill(self.healthStore.state.tint) - .frame(width: 10, height: 10) - Text(self.healthStore.summaryLine) - .font(.callout) - .frame(maxWidth: .infinity, alignment: .leading) - } - - if let detail = self.healthStore.detailLine { - Text(detail) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - HStack(spacing: 10) { - Button("Retry now") { - Task { await HealthStore.shared.refresh(onDemand: true) } - } - .disabled(self.healthStore.isRefreshing) - - Button("Open logs") { self.revealLogs() } - .buttonStyle(.link) - .foregroundStyle(.secondary) - } - .font(.caption) - } - } - - @MainActor - func testRemote() async { - self.remoteStatus = .checking - let settings = CommandResolver.connectionSettings() - if self.state.remoteTransport == .direct { - let trimmedUrl = self.state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedUrl.isEmpty else { - self.remoteStatus = .failed("Set a gateway URL first") - return - } - guard Self.isValidWsUrl(trimmedUrl) else { - self.remoteStatus = .failed( - "Gateway URL must use wss:// for remote hosts (ws:// only for localhost)" - ) - return - } - } else { - guard !settings.target.isEmpty else { - self.remoteStatus = .failed("Set an SSH target first") - return - } - - // Step 1: basic SSH reachability check - guard let sshCommand = Self.sshCheckCommand( - target: settings.target, - identity: settings.identity) - else { - self.remoteStatus = .failed("SSH target is invalid") - return - } - let sshResult = await ShellExecutor.run( - command: sshCommand, - cwd: nil, - env: nil, - timeout: 8) - - guard sshResult.ok else { - self.remoteStatus = .failed(self.formatSSHFailure(sshResult, target: settings.target)) - return - } - } - - // Step 2: control channel health check - let originalMode = AppStateStore.shared.connectionMode - do { - try await ControlChannel.shared.configure(mode: .remote( - target: settings.target, - identity: settings.identity)) - let data = try await ControlChannel.shared.health(timeout: 10) - if decodeHealthSnapshot(from: data) != nil { - self.remoteStatus = .ok - } else { - self.remoteStatus = .failed("Control channel returned invalid health JSON") - } - } catch { - self.remoteStatus = .failed(error.localizedDescription) - } - - // Restore original mode if we temporarily switched - switch originalMode { - case .remote: - break - case .local: - try? await ControlChannel.shared.configure(mode: .local) - case .unconfigured: - await ControlChannel.shared.disconnect() - } - } - - private static func isValidWsUrl(_ raw: String) -> Bool { - GatewayRemoteConfig.normalizeGatewayUrl(raw) != nil - } - - private static func sshCheckCommand(target: String, identity: String) -> [String]? { - guard let parsed = CommandResolver.parseSSHTarget(target) else { return nil } - let options = [ - "-o", "BatchMode=yes", - "-o", "ConnectTimeout=5", - "-o", "StrictHostKeyChecking=accept-new", - "-o", "UpdateHostKeys=yes", - ] - let args = CommandResolver.sshArguments( - target: parsed, - identity: identity, - options: options, - remoteCommand: ["echo", "ok"]) - return ["/usr/bin/ssh"] + args - } - - private func formatSSHFailure(_ response: Response, target: String) -> String { - let payload = response.payload.flatMap { String(data: $0, encoding: .utf8) } - let trimmed = payload? - .trimmingCharacters(in: .whitespacesAndNewlines) - .split(whereSeparator: \.isNewline) - .joined(separator: " ") - if let trimmed, - trimmed.localizedCaseInsensitiveContains("host key verification failed") - { - let host = CommandResolver.parseSSHTarget(target)?.host ?? target - return "SSH check failed: Host key verification failed. Remove the old key with " + - "`ssh-keygen -R \(host)` and try again." - } - if let trimmed, !trimmed.isEmpty { - if let message = response.message, message.hasPrefix("exit ") { - return "SSH check failed: \(trimmed) (\(message))" - } - return "SSH check failed: \(trimmed)" - } - if let message = response.message { - return "SSH check failed (\(message))" - } - return "SSH check failed" - } - - private func revealLogs() { - let target = LogLocator.bestLogFile() - - if let target { - NSWorkspace.shared.selectFile( - target.path, - inFileViewerRootedAtPath: target.deletingLastPathComponent().path) - return - } - - let alert = NSAlert() - alert.messageText = "Log file not found" - alert.informativeText = """ - Looked for openclaw logs in /tmp/openclaw/. - Run a health check or send a message to generate activity, then try again. - """ - alert.alertStyle = .informational - alert.addButton(withTitle: "OK") - alert.runModal() - } - - private func applyDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) { - MacNodeModeCoordinator.shared.setPreferredGatewayStableID(gateway.stableID) - - if self.state.remoteTransport == .direct { - self.state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? "" - } else { - self.state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? "" - } - if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) { - OpenClawConfigFile.setRemoteGatewayUrl( - host: endpoint.host, - port: endpoint.port) - } else { - OpenClawConfigFile.clearRemoteGatewayUrl() - } - } -} - -private func healthAgeString(_ ms: Double?) -> String { - guard let ms else { return "unknown" } - return msToAge(ms) -} - -#if DEBUG -struct GeneralSettings_Previews: PreviewProvider { - static var previews: some View { - GeneralSettings(state: .preview) - .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) - .environment(TailscaleService.shared) - } -} - -@MainActor -extension GeneralSettings { - static func exerciseForTesting() { - let state = AppState(preview: true) - state.connectionMode = .remote - state.remoteTransport = .ssh - state.remoteTarget = "user@host:2222" - state.remoteUrl = "wss://gateway.example.ts.net" - state.remoteIdentity = "/tmp/id_ed25519" - state.remoteProjectRoot = "/tmp/openclaw" - state.remoteCliPath = "/tmp/openclaw" - - let view = GeneralSettings(state: state) - view.gatewayStatus = GatewayEnvironmentStatus( - kind: .ok, - nodeVersion: "1.0.0", - gatewayVersion: "1.0.0", - requiredGateway: nil, - message: "Gateway ready") - view.remoteStatus = .failed("SSH failed") - view.showRemoteAdvanced = true - _ = view.body - - state.connectionMode = .unconfigured - _ = view.body - - state.connectionMode = .local - view.gatewayStatus = GatewayEnvironmentStatus( - kind: .error("Gateway offline"), - nodeVersion: nil, - gatewayVersion: nil, - requiredGateway: nil, - message: "Gateway offline") - _ = view.body - } -} -#endif diff --git a/apps/macos/Sources/OpenClaw/HealthStore.swift b/apps/macos/Sources/OpenClaw/HealthStore.swift deleted file mode 100644 index 22c1409fca7..00000000000 --- a/apps/macos/Sources/OpenClaw/HealthStore.swift +++ /dev/null @@ -1,301 +0,0 @@ -import Foundation -import Network -import Observation -import SwiftUI - -struct HealthSnapshot: Codable, Sendable { - struct ChannelSummary: Codable, Sendable { - struct Probe: Codable, Sendable { - struct Bot: Codable, Sendable { - let username: String? - } - - struct Webhook: Codable, Sendable { - let url: String? - } - - let ok: Bool? - let status: Int? - let error: String? - let elapsedMs: Double? - let bot: Bot? - let webhook: Webhook? - } - - let configured: Bool? - let linked: Bool? - let authAgeMs: Double? - let probe: Probe? - let lastProbeAt: Double? - } - - struct SessionInfo: Codable, Sendable { - let key: String - let updatedAt: Double? - let age: Double? - } - - struct Sessions: Codable, Sendable { - let path: String - let count: Int - let recent: [SessionInfo] - } - - let ok: Bool? - let ts: Double - let durationMs: Double - let channels: [String: ChannelSummary] - let channelOrder: [String]? - let channelLabels: [String: String]? - let heartbeatSeconds: Int? - let sessions: Sessions -} - -enum HealthState: Equatable { - case unknown - case ok - case linkingNeeded - case degraded(String) - - var tint: Color { - switch self { - case .ok: .green - case .linkingNeeded: .red - case .degraded: .orange - case .unknown: .secondary - } - } -} - -@MainActor -@Observable -final class HealthStore { - static let shared = HealthStore() - - private static let logger = Logger(subsystem: "ai.openclaw", category: "health") - - private(set) var snapshot: HealthSnapshot? - private(set) var lastSuccess: Date? - private(set) var lastError: String? - private(set) var isRefreshing = false - - private var loopTask: Task? - private let refreshInterval: TimeInterval = 60 - - private init() { - // Avoid background health polling in SwiftUI previews and tests. - if !ProcessInfo.processInfo.isPreview, !ProcessInfo.processInfo.isRunningTests { - self.start() - } - } - - /// Test-only escape hatch: the HealthStore is a process-wide singleton but - /// state derivation is pure from `snapshot` + `lastError`. - func __setSnapshotForTest(_ snapshot: HealthSnapshot?, lastError: String? = nil) { - self.snapshot = snapshot - self.lastError = lastError - } - - func start() { - guard self.loopTask == nil else { return } - self.loopTask = Task { [weak self] in - guard let self else { return } - while !Task.isCancelled { - await self.refresh() - try? await Task.sleep(nanoseconds: UInt64(self.refreshInterval * 1_000_000_000)) - } - } - } - - func stop() { - self.loopTask?.cancel() - self.loopTask = nil - } - - func refresh(onDemand: Bool = false) async { - guard !self.isRefreshing else { return } - self.isRefreshing = true - defer { self.isRefreshing = false } - let previousError = self.lastError - - do { - let data = try await ControlChannel.shared.health(timeout: 15) - if let decoded = decodeHealthSnapshot(from: data) { - self.snapshot = decoded - self.lastSuccess = Date() - self.lastError = nil - if previousError != nil { - Self.logger.info("health refresh recovered") - } - } else { - self.lastError = "health output not JSON" - if onDemand { self.snapshot = nil } - if previousError != self.lastError { - Self.logger.warning("health refresh failed: output not JSON") - } - } - } catch { - let desc = error.localizedDescription - self.lastError = desc - if onDemand { self.snapshot = nil } - if previousError != desc { - Self.logger.error("health refresh failed \(desc, privacy: .public)") - } - } - } - - private static func isChannelHealthy(_ summary: HealthSnapshot.ChannelSummary) -> Bool { - guard summary.configured == true else { return false } - // If probe is missing, treat it as "configured but unknown health" (not a hard fail). - return summary.probe?.ok ?? true - } - - private static func describeProbeFailure(_ probe: HealthSnapshot.ChannelSummary.Probe) -> String { - let elapsed = probe.elapsedMs.map { "\(Int($0))ms" } - if let error = probe.error, error.lowercased().contains("timeout") || probe.status == nil { - if let elapsed { return "Health check timed out (\(elapsed))" } - return "Health check timed out" - } - let code = probe.status.map { "status \($0)" } ?? "status unknown" - let reason = probe.error?.isEmpty == false ? probe.error! : "health probe failed" - if let elapsed { return "\(reason) (\(code), \(elapsed))" } - return "\(reason) (\(code))" - } - - private func resolveLinkChannel( - _ snap: HealthSnapshot) -> (id: String, summary: HealthSnapshot.ChannelSummary)? - { - let order = snap.channelOrder ?? Array(snap.channels.keys) - for id in order { - if let summary = snap.channels[id], summary.linked == true { - return (id: id, summary: summary) - } - } - for id in order { - if let summary = snap.channels[id], summary.linked != nil { - return (id: id, summary: summary) - } - } - return nil - } - - private func resolveFallbackChannel( - _ snap: HealthSnapshot, - excluding id: String?) -> (id: String, summary: HealthSnapshot.ChannelSummary)? - { - let order = snap.channelOrder ?? Array(snap.channels.keys) - for channelId in order { - if channelId == id { continue } - guard let summary = snap.channels[channelId] else { continue } - if Self.isChannelHealthy(summary) { - return (id: channelId, summary: summary) - } - } - return nil - } - - var state: HealthState { - if let error = self.lastError, !error.isEmpty { - return .degraded(error) - } - guard let snap = self.snapshot else { return .unknown } - guard let link = self.resolveLinkChannel(snap) else { return .unknown } - if link.summary.linked != true { - // Linking is optional if any other channel is healthy; don't paint the whole app red. - let fallback = self.resolveFallbackChannel(snap, excluding: link.id) - return fallback != nil ? .degraded("Not linked") : .linkingNeeded - } - // A channel can be "linked" but still unhealthy (failed probe / cannot connect). - if let probe = link.summary.probe, probe.ok == false { - return .degraded(Self.describeProbeFailure(probe)) - } - return .ok - } - - var summaryLine: String { - if self.isRefreshing { return "Health check running…" } - if let error = self.lastError { return "Health check failed: \(error)" } - guard let snap = self.snapshot else { return "Health check pending" } - guard let link = self.resolveLinkChannel(snap) else { return "Health check pending" } - if link.summary.linked != true { - if let fallback = self.resolveFallbackChannel(snap, excluding: link.id) { - let fallbackLabel = snap.channelLabels?[fallback.id] ?? fallback.id.capitalized - let fallbackState = (fallback.summary.probe?.ok ?? true) ? "ok" : "degraded" - return "\(fallbackLabel) \(fallbackState) · Not linked — run openclaw login" - } - return "Not linked — run openclaw login" - } - let auth = link.summary.authAgeMs.map { msToAge($0) } ?? "unknown" - if let probe = link.summary.probe, probe.ok == false { - let status = probe.status.map(String.init) ?? "?" - let suffix = probe.status == nil ? "probe degraded" : "probe degraded · status \(status)" - return "linked · auth \(auth) · \(suffix)" - } - return "linked · auth \(auth)" - } - - /// Short, human-friendly detail for the last failure, used in the UI. - var detailLine: String? { - if let error = self.lastError, !error.isEmpty { - let lower = error.lowercased() - if lower.contains("connection refused") { - let port = GatewayEnvironment.gatewayPort() - let host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)" - return "The gateway control port (\(host)) isn’t listening — restart OpenClaw to bring it back." - } - if lower.contains("timeout") { - return "Timed out waiting for the control server; the gateway may be crashed or still starting." - } - return error - } - return nil - } - - func describeFailure(from snap: HealthSnapshot, fallback: String?) -> String { - if let link = self.resolveLinkChannel(snap), link.summary.linked != true { - return "Not linked — run openclaw login" - } - if let link = self.resolveLinkChannel(snap), let probe = link.summary.probe, probe.ok == false { - return Self.describeProbeFailure(probe) - } - if let fallback, !fallback.isEmpty { - return fallback - } - return "health probe failed" - } - - var degradedSummary: String? { - guard case let .degraded(reason) = self.state else { return nil } - if reason == "[object Object]" || reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - let snap = self.snapshot - { - return self.describeFailure(from: snap, fallback: reason) - } - return reason - } -} - -func msToAge(_ ms: Double) -> String { - let minutes = Int(round(ms / 60000)) - if minutes < 1 { return "just now" } - if minutes < 60 { return "\(minutes)m" } - let hours = Int(round(Double(minutes) / 60)) - if hours < 48 { return "\(hours)h" } - let days = Int(round(Double(hours) / 24)) - return "\(days)d" -} - -/// Decode a health snapshot, tolerating stray log lines before/after the JSON blob. -func decodeHealthSnapshot(from data: Data) -> HealthSnapshot? { - let decoder = JSONDecoder() - if let snap = try? decoder.decode(HealthSnapshot.self, from: data) { - return snap - } - guard let text = String(data: data, encoding: .utf8) else { return nil } - guard let firstBrace = text.firstIndex(of: "{"), let lastBrace = text.lastIndex(of: "}") else { - return nil - } - let slice = text[firstBrace...lastBrace] - let cleaned = Data(slice.utf8) - return try? decoder.decode(HealthSnapshot.self, from: cleaned) -} diff --git a/apps/macos/Sources/OpenClaw/HeartbeatStore.swift b/apps/macos/Sources/OpenClaw/HeartbeatStore.swift deleted file mode 100644 index 6bd7bb52529..00000000000 --- a/apps/macos/Sources/OpenClaw/HeartbeatStore.swift +++ /dev/null @@ -1,39 +0,0 @@ -import Foundation -import Observation -import SwiftUI - -@MainActor -@Observable -final class HeartbeatStore { - static let shared = HeartbeatStore() - - private(set) var lastEvent: ControlHeartbeatEvent? - - private var observer: NSObjectProtocol? - - private init() { - self.observer = NotificationCenter.default.addObserver( - forName: .controlHeartbeat, - object: nil, - queue: .main) - { [weak self] note in - guard let data = note.object as? Data else { return } - if let decoded = try? JSONDecoder().decode(ControlHeartbeatEvent.self, from: data) { - Task { @MainActor in self?.lastEvent = decoded } - } - } - - Task { - if self.lastEvent == nil { - if let evt = try? await ControlChannel.shared.lastHeartbeat() { - self.lastEvent = evt - } - } - } - } - - @MainActor - deinit { - if let observer { NotificationCenter.default.removeObserver(observer) } - } -} diff --git a/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift b/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift deleted file mode 100644 index b387c36d3a4..00000000000 --- a/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift +++ /dev/null @@ -1,57 +0,0 @@ -import Foundation - -enum HostEnvSanitizer { - /// Keep in sync with src/infra/host-env-security-policy.json. - /// Parity is validated by src/infra/host-env-security.policy-parity.test.ts. - private static let blockedKeys: Set = [ - "NODE_OPTIONS", - "NODE_PATH", - "PYTHONHOME", - "PYTHONPATH", - "PERL5LIB", - "PERL5OPT", - "RUBYLIB", - "RUBYOPT", - "BASH_ENV", - "ENV", - "SHELL", - "GCONV_PATH", - "IFS", - "SSLKEYLOGFILE", - ] - - private static let blockedPrefixes: [String] = [ - "DYLD_", - "LD_", - "BASH_FUNC_", - ] - - private static func isBlocked(_ upperKey: String) -> Bool { - if self.blockedKeys.contains(upperKey) { return true } - return self.blockedPrefixes.contains(where: { upperKey.hasPrefix($0) }) - } - - static func sanitize(overrides: [String: String]?) -> [String: String] { - var merged: [String: String] = [:] - for (rawKey, value) in ProcessInfo.processInfo.environment { - let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) - guard !key.isEmpty else { continue } - let upper = key.uppercased() - if self.isBlocked(upper) { continue } - merged[key] = value - } - - guard let overrides else { return merged } - for (rawKey, value) in overrides { - let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) - guard !key.isEmpty else { continue } - let upper = key.uppercased() - // PATH is part of the security boundary (command resolution + safe-bin checks). Never - // allow request-scoped PATH overrides from agents/gateways. - if upper == "PATH" { continue } - if self.isBlocked(upper) { continue } - merged[key] = value - } - return merged - } -} diff --git a/apps/macos/Sources/OpenClaw/HoverHUD.swift b/apps/macos/Sources/OpenClaw/HoverHUD.swift deleted file mode 100644 index d3482362a0f..00000000000 --- a/apps/macos/Sources/OpenClaw/HoverHUD.swift +++ /dev/null @@ -1,311 +0,0 @@ -import AppKit -import Observation -import QuartzCore -import SwiftUI - -/// Hover-only HUD anchored to the menu bar item. Click expands into full Web Chat. -@MainActor -@Observable -final class HoverHUDController { - static let shared = HoverHUDController() - - struct Model { - var isVisible: Bool = false - var isSuppressed: Bool = false - var hoveringStatusItem: Bool = false - var hoveringPanel: Bool = false - } - - private(set) var model = Model() - - private var window: NSPanel? - private var hostingView: NSHostingView? - private var dismissMonitor: Any? - private var dismissTask: Task? - private var showTask: Task? - private var anchorProvider: (() -> NSRect?)? - - private let width: CGFloat = 360 - private let height: CGFloat = 74 - private let padding: CGFloat = 8 - private let hoverShowDelay: TimeInterval = 0.18 - - func setSuppressed(_ suppressed: Bool) { - self.model.isSuppressed = suppressed - if suppressed { - self.showTask?.cancel() - self.showTask = nil - self.dismiss(reason: "suppressed") - } - } - - func statusItemHoverChanged(inside: Bool, anchorProvider: @escaping () -> NSRect?) { - self.model.hoveringStatusItem = inside - self.anchorProvider = anchorProvider - - guard !self.model.isSuppressed else { return } - - if inside { - self.dismissTask?.cancel() - self.dismissTask = nil - self.showTask?.cancel() - self.showTask = Task { [weak self] in - guard let self else { return } - try? await Task.sleep(nanoseconds: UInt64(self.hoverShowDelay * 1_000_000_000)) - await MainActor.run { [weak self] in - guard let self else { return } - guard !Task.isCancelled else { return } - guard self.model.hoveringStatusItem else { return } - guard !self.model.isSuppressed else { return } - self.present() - } - } - } else { - self.showTask?.cancel() - self.showTask = nil - self.scheduleDismiss() - } - } - - func panelHoverChanged(inside: Bool) { - self.model.hoveringPanel = inside - if inside { - self.dismissTask?.cancel() - self.dismissTask = nil - } else if !self.model.hoveringStatusItem { - self.scheduleDismiss() - } - } - - func openChat() { - guard let anchorProvider = self.anchorProvider else { return } - self.dismiss(reason: "openChat") - Task { @MainActor in - let sessionKey = await WebChatManager.shared.preferredSessionKey() - WebChatManager.shared.togglePanel(sessionKey: sessionKey, anchorProvider: anchorProvider) - } - } - - func dismiss(reason: String = "explicit") { - self.dismissTask?.cancel() - self.dismissTask = nil - self.removeDismissMonitor() - guard let window else { - self.model.isVisible = false - return - } - - if !self.model.isVisible { - window.orderOut(nil) - return - } - - let target = window.frame.offsetBy(dx: 0, dy: 6) - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.14 - context.timingFunction = CAMediaTimingFunction(name: .easeOut) - window.animator().setFrame(target, display: true) - window.animator().alphaValue = 0 - } completionHandler: { - Task { @MainActor in - window.orderOut(nil) - self.model.isVisible = false - } - } - } - - // MARK: - Private - - private func scheduleDismiss() { - self.dismissTask?.cancel() - self.dismissTask = Task { [weak self] in - try? await Task.sleep(nanoseconds: 250_000_000) - await MainActor.run { - guard let self else { return } - if self.model.hoveringStatusItem || self.model.hoveringPanel { return } - self.dismiss(reason: "hoverExit") - } - } - } - - private func present() { - guard !self.model.isSuppressed else { return } - self.ensureWindow() - self.hostingView?.rootView = HoverHUDView(controller: self) - let target = self.targetFrame() - - guard let window else { return } - self.installDismissMonitor() - - if !self.model.isVisible { - self.model.isVisible = true - let start = target.offsetBy(dx: 0, dy: 8) - window.setFrame(start, display: true) - window.alphaValue = 0 - window.orderFrontRegardless() - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.18 - context.timingFunction = CAMediaTimingFunction(name: .easeOut) - window.animator().setFrame(target, display: true) - window.animator().alphaValue = 1 - } - } else { - window.orderFrontRegardless() - self.updateWindowFrame(animate: true) - } - } - - private func ensureWindow() { - if self.window != nil { return } - let panel = NSPanel( - contentRect: NSRect(x: 0, y: 0, width: self.width, height: self.height), - styleMask: [.nonactivatingPanel, .borderless], - backing: .buffered, - defer: false) - panel.isOpaque = false - panel.backgroundColor = .clear - panel.hasShadow = true - panel.level = .statusBar - panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient] - panel.hidesOnDeactivate = false - panel.isMovable = false - panel.isFloatingPanel = true - panel.becomesKeyOnlyIfNeeded = true - panel.titleVisibility = .hidden - panel.titlebarAppearsTransparent = true - - let host = NSHostingView(rootView: HoverHUDView(controller: self)) - host.translatesAutoresizingMaskIntoConstraints = false - panel.contentView = host - self.hostingView = host - self.window = panel - } - - private func targetFrame() -> NSRect { - guard let anchor = self.anchorProvider?() else { - return WindowPlacement.topRightFrame( - size: NSSize(width: self.width, height: self.height), - padding: self.padding) - } - - let screen = NSScreen.screens.first { screen in - screen.frame.contains(anchor.origin) || screen.frame.contains(NSPoint(x: anchor.midX, y: anchor.midY)) - } ?? NSScreen.main - - let bounds = (screen?.visibleFrame ?? .zero).insetBy(dx: self.padding, dy: self.padding) - return WindowPlacement.anchoredBelowFrame( - size: NSSize(width: self.width, height: self.height), - anchor: anchor, - padding: self.padding, - in: bounds) - } - - private func updateWindowFrame(animate: Bool = false) { - guard let window else { return } - let frame = self.targetFrame() - if animate { - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.12 - context.timingFunction = CAMediaTimingFunction(name: .easeOut) - window.animator().setFrame(frame, display: true) - } - } else { - window.setFrame(frame, display: true) - } - } - - private func installDismissMonitor() { - if ProcessInfo.processInfo.isRunningTests { return } - guard self.dismissMonitor == nil, let window else { return } - self.dismissMonitor = NSEvent.addGlobalMonitorForEvents(matching: [ - .leftMouseDown, - .rightMouseDown, - .otherMouseDown, - ]) { [weak self] _ in - guard let self, self.model.isVisible else { return } - let pt = NSEvent.mouseLocation - if !window.frame.contains(pt) { - Task { @MainActor in self.dismiss(reason: "outsideClick") } - } - } - } - - private func removeDismissMonitor() { - if let monitor = self.dismissMonitor { - NSEvent.removeMonitor(monitor) - self.dismissMonitor = nil - } - } -} - -private struct HoverHUDView: View { - var controller: HoverHUDController - private let activityStore = WorkActivityStore.shared - - private var statusTitle: String { - if self.activityStore.iconState.isWorking { return "Working" } - return "Idle" - } - - private var detail: String { - if let current = self.activityStore.current?.label, !current.isEmpty { return current } - if let last = self.activityStore.lastToolLabel, !last.isEmpty { return last } - return "No recent activity" - } - - private var symbolName: String { - if self.activityStore.iconState.isWorking { - return self.activityStore.iconState.badgeSymbolName - } - return "moon.zzz.fill" - } - - private var dotColor: Color { - if self.activityStore.iconState.isWorking { - return Color(nsColor: NSColor.systemGreen.withAlphaComponent(0.7)) - } - return .secondary - } - - var body: some View { - HStack(alignment: .top, spacing: 10) { - Circle() - .fill(self.dotColor) - .frame(width: 7, height: 7) - .padding(.top, 5) - - VStack(alignment: .leading, spacing: 4) { - Text(self.statusTitle) - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(.primary) - Text(self.detail) - .font(.system(size: 12)) - .foregroundStyle(.secondary) - .lineLimit(2) - .truncationMode(.middle) - .fixedSize(horizontal: false, vertical: true) - } - - Spacer(minLength: 8) - - Image(systemName: self.symbolName) - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(.secondary) - .padding(.top, 1) - } - .padding(12) - .background( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(.regularMaterial)) - .overlay( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .strokeBorder(Color.black.opacity(0.10), lineWidth: 1)) - .contentShape(Rectangle()) - .onHover { inside in - self.controller.panelHoverChanged(inside: inside) - } - .onTapGesture { - self.controller.openChat() - } - } -} diff --git a/apps/macos/Sources/OpenClaw/IconState.swift b/apps/macos/Sources/OpenClaw/IconState.swift deleted file mode 100644 index c2eab0e5010..00000000000 --- a/apps/macos/Sources/OpenClaw/IconState.swift +++ /dev/null @@ -1,113 +0,0 @@ -import Foundation -import SwiftUI - -enum SessionRole { - case main - case other -} - -enum ToolKind: String, Codable { - case bash, read, write, edit, attach, other -} - -enum ActivityKind: Codable, Equatable { - case job - case tool(ToolKind) -} - -enum IconState: Equatable { - case idle - case workingMain(ActivityKind) - case workingOther(ActivityKind) - case overridden(ActivityKind) - - enum BadgeProminence: Equatable { - case primary - case secondary - case overridden - } - - var badgeSymbolName: String { - switch self.activity { - case .tool(.bash): "chevron.left.slash.chevron.right" - case .tool(.read): "doc" - case .tool(.write): "pencil" - case .tool(.edit): "pencil.tip" - case .tool(.attach): "paperclip" - case .tool(.other), .job: "gearshape.fill" - } - } - - var badgeProminence: BadgeProminence? { - switch self { - case .idle: nil - case .workingMain: .primary - case .workingOther: .secondary - case .overridden: .overridden - } - } - - var isWorking: Bool { - switch self { - case .idle: false - default: true - } - } - - private var activity: ActivityKind { - switch self { - case let .workingMain(kind), - let .workingOther(kind), - let .overridden(kind): - kind - case .idle: - .job - } - } -} - -enum IconOverrideSelection: String, CaseIterable, Identifiable { - case system - case idle - case mainBash, mainRead, mainWrite, mainEdit, mainOther - case otherBash, otherRead, otherWrite, otherEdit, otherOther - - var id: String { - self.rawValue - } - - var label: String { - switch self { - case .system: "System (auto)" - case .idle: "Idle" - case .mainBash: "Working main – bash" - case .mainRead: "Working main – read" - case .mainWrite: "Working main – write" - case .mainEdit: "Working main – edit" - case .mainOther: "Working main – other" - case .otherBash: "Working other – bash" - case .otherRead: "Working other – read" - case .otherWrite: "Working other – write" - case .otherEdit: "Working other – edit" - case .otherOther: "Working other – other" - } - } - - func toIconState() -> IconState { - let map: (ToolKind) -> ActivityKind = { .tool($0) } - switch self { - case .system: return .idle - case .idle: return .idle - case .mainBash: return .workingMain(map(.bash)) - case .mainRead: return .workingMain(map(.read)) - case .mainWrite: return .workingMain(map(.write)) - case .mainEdit: return .workingMain(map(.edit)) - case .mainOther: return .workingMain(map(.other)) - case .otherBash: return .workingOther(map(.bash)) - case .otherRead: return .workingOther(map(.read)) - case .otherWrite: return .workingOther(map(.write)) - case .otherEdit: return .workingOther(map(.edit)) - case .otherOther: return .workingOther(map(.other)) - } - } -} diff --git a/apps/macos/Sources/OpenClaw/InstancesSettings.swift b/apps/macos/Sources/OpenClaw/InstancesSettings.swift deleted file mode 100644 index 0c992c6970f..00000000000 --- a/apps/macos/Sources/OpenClaw/InstancesSettings.swift +++ /dev/null @@ -1,479 +0,0 @@ -import AppKit -import SwiftUI - -struct InstancesSettings: View { - var store: InstancesStore - - init(store: InstancesStore = .shared) { - self.store = store - } - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - self.header - if let err = store.lastError { - Text("Error: \(err)") - .foregroundStyle(.red) - } else if let info = store.statusMessage { - Text(info) - .foregroundStyle(.secondary) - } - if self.store.instances.isEmpty { - Text("No instances reported yet.") - .foregroundStyle(.secondary) - } else { - List(self.store.instances) { inst in - self.instanceRow(inst) - } - .listStyle(.inset) - } - Spacer() - } - .onAppear { self.store.start() } - .onDisappear { self.store.stop() } - } - - private var header: some View { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Connected Instances") - .font(.headline) - Text("Latest presence beacons from OpenClaw nodes. Updated periodically.") - .font(.footnote) - .foregroundStyle(.secondary) - } - Spacer() - if self.store.isLoading { - ProgressView() - } else { - Button { - Task { await self.store.refresh() } - } label: { - Label("Refresh", systemImage: "arrow.clockwise") - } - .buttonStyle(.bordered) - .help("Refresh") - } - } - } - - @ViewBuilder - private func instanceRow(_ inst: InstanceInfo) -> some View { - let isGateway = (inst.mode ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "gateway" - let prettyPlatform = inst.platform.flatMap { self.prettyPlatform($0) } - let device = DeviceModelCatalog.presentation( - deviceFamily: inst.deviceFamily, - modelIdentifier: inst.modelIdentifier) - - HStack(alignment: .top, spacing: 12) { - self.leadingDeviceIcon(inst, device: device) - .frame(width: 28, height: 28, alignment: .center) - .padding(.top, 1) - - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 8) { - Text(inst.host ?? "unknown host").font(.subheadline.bold()) - self.presenceIndicator(inst) - if let ip = inst.ip { Text("(") + Text(ip).monospaced() + Text(")") } - } - - HStack(spacing: 8) { - if let version = inst.version { - self.label(icon: "shippingbox", text: version) - } - - if let device { - // Avoid showing generic "Mac"/"iPhone"/etc; prefer the concrete model name. - let family = (inst.deviceFamily ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - let isGeneric = !family.isEmpty && device.title == family - if !isGeneric { - if let prettyPlatform { - self.label(icon: device.symbol, text: "\(device.title) · \(prettyPlatform)") - } else { - self.label(icon: device.symbol, text: device.title) - } - } else if let prettyPlatform, let platform = inst.platform { - self.label(icon: self.platformIcon(platform), text: prettyPlatform) - } - } else if let prettyPlatform, let platform = inst.platform { - self.label(icon: self.platformIcon(platform), text: prettyPlatform) - } - - if let mode = inst.mode { self.label(icon: "network", text: mode) } - } - .layoutPriority(1) - - if !isGateway, self.shouldShowUpdateRow(inst) { - HStack(spacing: 8) { - Spacer(minLength: 0) - - // Last local input is helpful for interactive nodes, but noisy/meaningless for the gateway. - if let secs = inst.lastInputSeconds { - self.label(icon: "clock", text: "\(secs)s ago") - } - - if let update = self.updateSummaryText(inst, isGateway: isGateway) { - self.label(icon: "arrow.clockwise", text: update) - .help(self.presenceUpdateSourceHelp(inst.reason ?? "")) - } - } - .foregroundStyle(.secondary) - } - } - } - .padding(.vertical, 6) - .help(inst.text) - .contextMenu { - Button("Copy Debug Summary") { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(inst.text, forType: .string) - } - } - } - - private func label(icon: String?, text: String) -> some View { - HStack(spacing: 4) { - if let icon { - if icon == Self.androidSymbolToken { - AndroidMark() - .foregroundStyle(.secondary) - .frame(width: 12, height: 12, alignment: .center) - } else if self.isSystemSymbolAvailable(icon) { - Image(systemName: icon).foregroundStyle(.secondary).font(.caption) - } - } - Text(text) - } - .font(.footnote) - } - - private func presenceIndicator(_ inst: InstanceInfo) -> some View { - let status = self.presenceStatus(for: inst) - return HStack(spacing: 4) { - Circle() - .fill(status.color) - .frame(width: 6, height: 6) - .accessibilityHidden(true) - Text(status.label) - .foregroundStyle(.secondary) - } - .font(.caption) - .help("Presence updated \(inst.ageDescription).") - .accessibilityLabel("\(status.label) presence") - } - - private func presenceStatus(for inst: InstanceInfo) -> (label: String, color: Color) { - let nowMs = Date().timeIntervalSince1970 * 1000 - let ageSeconds = max(0, Int((nowMs - inst.ts) / 1000)) - if ageSeconds <= 120 { return ("Active", .green) } - if ageSeconds <= 300 { return ("Idle", .yellow) } - return ("Stale", .gray) - } - - @ViewBuilder - private func leadingDeviceIcon(_ inst: InstanceInfo, device: DevicePresentation?) -> some View { - let symbol = self.leadingDeviceSymbol(inst, device: device) - if symbol == Self.androidSymbolToken { - AndroidMark() - .foregroundStyle(.secondary) - .frame(width: 24, height: 24, alignment: .center) - .accessibilityHidden(true) - } else { - Image(systemName: symbol) - .font(.system(size: 26, weight: .regular)) - .foregroundStyle(.secondary) - .accessibilityHidden(true) - } - } - - private static let androidSymbolToken = "android" - - private func leadingDeviceSymbol(_ inst: InstanceInfo, device: DevicePresentation?) -> String { - let family = (inst.deviceFamily ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if family == "android" { - return Self.androidSymbolToken - } - - if let title = device?.title.lowercased() { - if title.contains("mac studio") { - return self.safeSystemSymbol("macstudio", fallback: "desktopcomputer") - } - if title.contains("macbook") { - return self.safeSystemSymbol("laptopcomputer", fallback: "laptopcomputer") - } - if title.contains("ipad") { - return self.safeSystemSymbol("ipad", fallback: "ipad") - } - if title.contains("iphone") { - return self.safeSystemSymbol("iphone", fallback: "iphone") - } - } - - if let symbol = device?.symbol { - return self.safeSystemSymbol(symbol, fallback: "cpu") - } - - if let platform = inst.platform { - return self.safeSystemSymbol(self.platformIcon(platform), fallback: "cpu") - } - - return "cpu" - } - - private func shouldShowUpdateRow(_ inst: InstanceInfo) -> Bool { - if inst.lastInputSeconds != nil { return true } - if self.updateSummaryText(inst, isGateway: false) != nil { return true } - return false - } - - private func safeSystemSymbol(_ preferred: String, fallback: String) -> String { - if self.isSystemSymbolAvailable(preferred) { return preferred } - return fallback - } - - private func isSystemSymbolAvailable(_ name: String) -> Bool { - NSImage(systemSymbolName: name, accessibilityDescription: nil) != nil - } - - private struct AndroidMark: View { - var body: some View { - GeometryReader { geo in - let w = geo.size.width - let h = geo.size.height - let headHeight = h * 0.68 - let headWidth = w * 0.92 - let headY = h * 0.18 - let corner = headHeight * 0.28 - - ZStack { - RoundedRectangle(cornerRadius: corner, style: .continuous) - .frame(width: headWidth, height: headHeight) - .position(x: w / 2, y: headY + headHeight / 2) - - Circle() - .frame(width: max(1, w * 0.1), height: max(1, w * 0.1)) - .position(x: w * 0.38, y: headY + headHeight * 0.55) - .blendMode(.destinationOut) - - Circle() - .frame(width: max(1, w * 0.1), height: max(1, w * 0.1)) - .position(x: w * 0.62, y: headY + headHeight * 0.55) - .blendMode(.destinationOut) - - Rectangle() - .frame(width: max(1, w * 0.08), height: max(1, h * 0.18)) - .rotationEffect(.degrees(-25)) - .position(x: w * 0.34, y: h * 0.12) - - Rectangle() - .frame(width: max(1, w * 0.08), height: max(1, h * 0.18)) - .rotationEffect(.degrees(25)) - .position(x: w * 0.66, y: h * 0.12) - } - .compositingGroup() - } - } - } - - private func platformIcon(_ raw: String) -> String { - let (prefix, _) = self.parsePlatform(raw) - switch prefix { - case "macos": - return "laptopcomputer" - case "ios": - return "iphone" - case "ipados": - return "ipad" - case "tvos": - return "appletv" - case "watchos": - return "applewatch" - default: - return "cpu" - } - } - - private func prettyPlatform(_ raw: String) -> String? { - let (prefix, version) = self.parsePlatform(raw) - if prefix.isEmpty { return nil } - let name: String = switch prefix { - case "macos": "macOS" - case "ios": "iOS" - case "ipados": "iPadOS" - case "tvos": "tvOS" - case "watchos": "watchOS" - default: prefix.prefix(1).uppercased() + prefix.dropFirst() - } - guard let version, !version.isEmpty else { return name } - let parts = version.split(separator: ".").map(String.init) - if parts.count >= 2 { - return "\(name) \(parts[0]).\(parts[1])" - } - return "\(name) \(version)" - } - - private func parsePlatform(_ raw: String) -> (prefix: String, version: String?) { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { return ("", nil) } - let parts = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init) - let prefix = parts.first?.lowercased() ?? "" - let versionToken = parts.dropFirst().first - return (prefix, versionToken) - } - - private func presenceUpdateSourceShortText(_ reason: String) -> String? { - let trimmed = reason.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - switch trimmed { - case "self": - return "Self" - case "connect": - return "Connect" - case "disconnect": - return "Disconnect" - case "node-connected": - return "Node connect" - case "node-disconnected": - return "Node disconnect" - case "launch": - return "Launch" - case "periodic": - return "Heartbeat" - case "instances-refresh": - return "Instances" - case "seq gap": - return "Resync" - default: - return trimmed - } - } - - private func updateSummaryText(_ inst: InstanceInfo, isGateway: Bool) -> String? { - // For gateway rows, omit the "updated via/by" provenance entirely. - if isGateway { - return nil - } - - let age = inst.ageDescription.trimmingCharacters(in: .whitespacesAndNewlines) - guard !age.isEmpty else { return nil } - - let source = self.presenceUpdateSourceShortText(inst.reason ?? "") - if let source, !source.isEmpty { - return "\(age) · \(source)" - } - return age - } - - private func presenceUpdateSourceHelp(_ reason: String) -> String { - let trimmed = reason.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { - return "Why this presence entry was last updated (debug marker)." - } - return "Why this presence entry was last updated (debug marker). Raw: \(trimmed)" - } -} - -#if DEBUG -extension InstancesSettings { - static func exerciseForTesting() { - let view = InstancesSettings(store: InstancesStore(isPreview: true)) - let mac = InstanceInfo( - id: "mac", - host: "studio", - ip: "10.0.0.2", - version: "1.2.3", - platform: "macOS 14.2", - deviceFamily: "Mac", - modelIdentifier: "Mac14,10", - lastInputSeconds: 12, - mode: "local", - reason: "self", - text: "Mac Studio", - ts: 1_700_000_000_000) - let genericIOS = InstanceInfo( - id: "iphone", - host: "phone", - ip: "10.0.0.3", - version: "2.0.0", - platform: "iOS 18.0", - deviceFamily: "iPhone", - modelIdentifier: nil, - lastInputSeconds: 35, - mode: "node", - reason: "connect", - text: "iPhone node", - ts: 1_700_000_100_000) - let android = InstanceInfo( - id: "android", - host: "pixel", - ip: nil, - version: "3.1.0", - platform: "Android 14", - deviceFamily: "Android", - modelIdentifier: nil, - lastInputSeconds: 90, - mode: "node", - reason: "seq gap", - text: "Android node", - ts: 1_700_000_200_000) - let gateway = InstanceInfo( - id: "gateway", - host: "gateway", - ip: "10.0.0.9", - version: "4.0.0", - platform: "Linux", - deviceFamily: nil, - modelIdentifier: nil, - lastInputSeconds: nil, - mode: "gateway", - reason: "periodic", - text: "Gateway", - ts: 1_700_000_300_000) - - _ = view.instanceRow(mac) - _ = view.instanceRow(genericIOS) - _ = view.instanceRow(android) - _ = view.instanceRow(gateway) - - _ = view.leadingDeviceSymbol( - mac, - device: DevicePresentation(title: "Mac Studio", symbol: "macstudio")) - _ = view.leadingDeviceSymbol( - mac, - device: DevicePresentation(title: "MacBook Pro", symbol: "laptopcomputer")) - _ = view.leadingDeviceSymbol(android, device: nil) - _ = view.platformIcon("tvOS 17.1") - _ = view.platformIcon("watchOS 10") - _ = view.platformIcon("unknown 1.0") - _ = view.prettyPlatform("macOS 14.2") - _ = view.prettyPlatform("iOS 18") - _ = view.prettyPlatform("ipados 17.1") - _ = view.prettyPlatform("linux") - _ = view.prettyPlatform(" ") - _ = view.parsePlatform("macOS 14.1") - _ = view.parsePlatform(" ") - _ = view.presenceUpdateSourceShortText("self") - _ = view.presenceUpdateSourceShortText("instances-refresh") - _ = view.presenceUpdateSourceShortText("seq gap") - _ = view.presenceUpdateSourceShortText("custom") - _ = view.presenceUpdateSourceShortText(" ") - _ = view.updateSummaryText(mac, isGateway: false) - _ = view.updateSummaryText(gateway, isGateway: true) - _ = view.presenceUpdateSourceHelp("") - _ = view.presenceUpdateSourceHelp("connect") - _ = view.safeSystemSymbol("not-a-symbol", fallback: "cpu") - _ = view.isSystemSymbolAvailable("sparkles") - _ = view.label(icon: "android", text: "Android") - _ = view.label(icon: "sparkles", text: "Sparkles") - _ = view.label(icon: nil, text: "Plain") - _ = AndroidMark().body - } -} - -struct InstancesSettings_Previews: PreviewProvider { - static var previews: some View { - InstancesSettings(store: .preview()) - .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) - } -} -#endif diff --git a/apps/macos/Sources/OpenClaw/InstancesStore.swift b/apps/macos/Sources/OpenClaw/InstancesStore.swift deleted file mode 100644 index 566340337db..00000000000 --- a/apps/macos/Sources/OpenClaw/InstancesStore.swift +++ /dev/null @@ -1,349 +0,0 @@ -import Cocoa -import Foundation -import Observation -import OpenClawKit -import OpenClawProtocol -import OSLog - -struct InstanceInfo: Identifiable, Codable { - let id: String - let host: String? - let ip: String? - let version: String? - let platform: String? - let deviceFamily: String? - let modelIdentifier: String? - let lastInputSeconds: Int? - let mode: String? - let reason: String? - let text: String - let ts: Double - - var ageDescription: String { - let date = Date(timeIntervalSince1970: ts / 1000) - return age(from: date) - } - - var lastInputDescription: String { - guard let secs = lastInputSeconds else { return "unknown" } - return "\(secs)s ago" - } -} - -@MainActor -@Observable -final class InstancesStore { - static let shared = InstancesStore() - let isPreview: Bool - - var instances: [InstanceInfo] = [] - var lastError: String? - var statusMessage: String? - var isLoading = false - - private let logger = Logger(subsystem: "ai.openclaw", category: "instances") - private var task: Task? - private let interval: TimeInterval = 30 - private var eventTask: Task? - private var startCount = 0 - private var lastPresenceById: [String: InstanceInfo] = [:] - private var lastLoginNotifiedAtMs: [String: Double] = [:] - - private struct PresenceEventPayload: Codable { - let presence: [PresenceEntry] - } - - init(isPreview: Bool = false) { - self.isPreview = isPreview - } - - func start() { - guard !self.isPreview else { return } - self.startCount += 1 - guard self.startCount == 1 else { return } - guard self.task == nil else { return } - self.startGatewaySubscription() - self.task = Task.detached { [weak self] in - guard let self else { return } - await self.refresh() - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000)) - await self.refresh() - } - } - } - - func stop() { - guard !self.isPreview else { return } - guard self.startCount > 0 else { return } - self.startCount -= 1 - guard self.startCount == 0 else { return } - self.task?.cancel() - self.task = nil - self.eventTask?.cancel() - self.eventTask = nil - } - - private func startGatewaySubscription() { - self.eventTask?.cancel() - self.eventTask = Task { [weak self] in - guard let self else { return } - let stream = await GatewayConnection.shared.subscribe() - for await push in stream { - if Task.isCancelled { return } - await MainActor.run { [weak self] in - self?.handle(push: push) - } - } - } - } - - private func handle(push: GatewayPush) { - switch push { - case let .event(evt) where evt.event == "presence": - if let payload = evt.payload { - self.handlePresenceEventPayload(payload) - } - case .seqGap: - Task { await self.refresh() } - case let .snapshot(hello): - self.applyPresence(hello.snapshot.presence) - default: - break - } - } - - func refresh() async { - if self.isLoading { return } - self.statusMessage = nil - self.isLoading = true - defer { self.isLoading = false } - do { - PresenceReporter.shared.sendImmediate(reason: "instances-refresh") - let data = try await ControlChannel.shared.request(method: "system-presence") - self.lastPayload = data - if data.isEmpty { - self.logger.error("instances fetch returned empty payload") - self.instances = [self.localFallbackInstance(reason: "no presence payload")] - self.lastError = nil - self.statusMessage = "No presence payload from gateway; showing local fallback + health probe." - await self.probeHealthIfNeeded(reason: "no payload") - return - } - let decoded = try JSONDecoder().decode([PresenceEntry].self, from: data) - let withIDs = self.normalizePresence(decoded) - if withIDs.isEmpty { - self.instances = [self.localFallbackInstance(reason: "no presence entries")] - self.lastError = nil - self.statusMessage = "Presence list was empty; showing local fallback + health probe." - await self.probeHealthIfNeeded(reason: "empty list") - } else { - self.instances = withIDs - self.lastError = nil - self.statusMessage = nil - } - } catch { - self.logger.error( - """ - instances fetch failed: \(error.localizedDescription, privacy: .public) \ - len=\(self.lastPayload?.count ?? 0, privacy: .public) \ - utf8=\(self.snippet(self.lastPayload), privacy: .public) - """) - self.instances = [self.localFallbackInstance(reason: "presence decode failed")] - self.lastError = nil - self.statusMessage = "Presence data invalid; showing local fallback + health probe." - await self.probeHealthIfNeeded(reason: "decode failed") - } - } - - private func localFallbackInstance(reason: String) -> InstanceInfo { - let host = Host.current().localizedName ?? "this-mac" - let ip = SystemPresenceInfo.primaryIPv4Address() - let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String - let osVersion = ProcessInfo.processInfo.operatingSystemVersion - let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)" - let text = "Local node: \(host)\(ip.map { " (\($0))" } ?? "") · app \(version ?? "dev")" - let ts = Date().timeIntervalSince1970 * 1000 - return InstanceInfo( - id: "local-\(host)", - host: host, - ip: ip, - version: version, - platform: platform, - deviceFamily: "Mac", - modelIdentifier: InstanceIdentity.modelIdentifier, - lastInputSeconds: SystemPresenceInfo.lastInputSeconds(), - mode: "local", - reason: reason, - text: text, - ts: ts) - } - - // MARK: - Helpers - - /// Keep the last raw payload for logging. - private var lastPayload: Data? - - private func snippet(_ data: Data?, limit: Int = 256) -> String { - guard let data else { return "" } - if data.isEmpty { return "" } - let prefix = data.prefix(limit) - if let asString = String(data: prefix, encoding: .utf8) { - return asString.replacingOccurrences(of: "\n", with: " ") - } - return "<\(data.count) bytes non-utf8>" - } - - private func probeHealthIfNeeded(reason: String? = nil) async { - do { - let data = try await ControlChannel.shared.health(timeout: 8) - guard let snap = decodeHealthSnapshot(from: data) else { return } - let linkId = snap.channelOrder?.first(where: { - if let summary = snap.channels[$0] { return summary.linked != nil } - return false - }) ?? snap.channels.keys.first(where: { - if let summary = snap.channels[$0] { return summary.linked != nil } - return false - }) - let linked = linkId.flatMap { snap.channels[$0]?.linked } ?? false - let linkLabel = - linkId.flatMap { snap.channelLabels?[$0] } ?? - linkId?.capitalized ?? - "channel" - let entry = InstanceInfo( - id: "health-\(snap.ts)", - host: "gateway (health)", - ip: nil, - version: nil, - platform: nil, - deviceFamily: nil, - modelIdentifier: nil, - lastInputSeconds: nil, - mode: "health", - reason: "health probe", - text: "Health ok · \(linkLabel) linked=\(linked)", - ts: snap.ts) - if !self.instances.contains(where: { $0.id == entry.id }) { - self.instances.insert(entry, at: 0) - } - self.lastError = nil - self.statusMessage = - "Presence unavailable (\(reason ?? "refresh")); showing health probe + local fallback." - } catch { - self.logger.error("instances health probe failed: \(error.localizedDescription, privacy: .public)") - if let reason { - self.statusMessage = - "Presence unavailable (\(reason)), health probe failed: \(error.localizedDescription)" - } - } - } - - private func decodeAndApplyPresenceData(_ data: Data) { - do { - let decoded = try JSONDecoder().decode([PresenceEntry].self, from: data) - self.applyPresence(decoded) - } catch { - self.logger.error("presence decode from event failed: \(error.localizedDescription, privacy: .public)") - self.lastError = error.localizedDescription - } - } - - func handlePresenceEventPayload(_ payload: OpenClawProtocol.AnyCodable) { - do { - let wrapper = try GatewayPayloadDecoding.decode(payload, as: PresenceEventPayload.self) - self.applyPresence(wrapper.presence) - } catch { - self.logger.error("presence event decode failed: \(error.localizedDescription, privacy: .public)") - self.lastError = error.localizedDescription - } - } - - private func normalizePresence(_ entries: [PresenceEntry]) -> [InstanceInfo] { - entries.map { entry -> InstanceInfo in - let key = entry.instanceid ?? entry.host ?? entry.ip ?? entry.text ?? "entry-\(entry.ts)" - return InstanceInfo( - id: key, - host: entry.host, - ip: entry.ip, - version: entry.version, - platform: entry.platform, - deviceFamily: entry.devicefamily, - modelIdentifier: entry.modelidentifier, - lastInputSeconds: entry.lastinputseconds, - mode: entry.mode, - reason: entry.reason, - text: entry.text ?? "Unnamed node", - ts: Double(entry.ts)) - } - } - - private func applyPresence(_ entries: [PresenceEntry]) { - let withIDs = self.normalizePresence(entries) - self.notifyOnNodeLogin(withIDs) - self.lastPresenceById = Dictionary(uniqueKeysWithValues: withIDs.map { ($0.id, $0) }) - self.instances = withIDs - self.statusMessage = nil - self.lastError = nil - } - - private func notifyOnNodeLogin(_ instances: [InstanceInfo]) { - for inst in instances { - guard let reason = inst.reason?.trimmingCharacters(in: .whitespacesAndNewlines) else { continue } - guard reason == "node-connected" else { continue } - if let mode = inst.mode?.lowercased(), mode == "local" { continue } - - let previous = self.lastPresenceById[inst.id] - if previous?.reason == "node-connected", previous?.ts == inst.ts { continue } - - let lastNotified = self.lastLoginNotifiedAtMs[inst.id] ?? 0 - if inst.ts <= lastNotified { continue } - self.lastLoginNotifiedAtMs[inst.id] = inst.ts - - let name = inst.host?.trimmingCharacters(in: .whitespacesAndNewlines) - let device = name?.isEmpty == false ? name! : inst.id - Task { @MainActor in - _ = await NotificationManager().send( - title: "Node connected", - body: device, - sound: nil, - priority: .active) - } - } - } -} - -extension InstancesStore { - static func preview(instances: [InstanceInfo] = [ - InstanceInfo( - id: "local", - host: "steipete-mac", - ip: "10.0.0.12", - version: "1.2.3", - platform: "macos 26.2.0", - deviceFamily: "Mac", - modelIdentifier: "Mac16,6", - lastInputSeconds: 12, - mode: "local", - reason: "preview", - text: "Local node: steipete-mac (10.0.0.12) · app 1.2.3", - ts: Date().timeIntervalSince1970 * 1000), - InstanceInfo( - id: "gateway", - host: "gateway", - ip: "100.64.0.2", - version: "1.2.3", - platform: "linux 6.6.0", - deviceFamily: "Linux", - modelIdentifier: "x86_64", - lastInputSeconds: 45, - mode: "remote", - reason: "preview", - text: "Gateway node · tunnel ok", - ts: Date().timeIntervalSince1970 * 1000 - 45000), - ]) -> InstancesStore { - let store = InstancesStore(isPreview: true) - store.instances = instances - store.statusMessage = "Preview data" - return store - } -} diff --git a/apps/macos/Sources/OpenClaw/LaunchAgentManager.swift b/apps/macos/Sources/OpenClaw/LaunchAgentManager.swift deleted file mode 100644 index af318b330d4..00000000000 --- a/apps/macos/Sources/OpenClaw/LaunchAgentManager.swift +++ /dev/null @@ -1,78 +0,0 @@ -import Foundation - -enum LaunchAgentManager { - private static var plistURL: URL { - FileManager().homeDirectoryForCurrentUser - .appendingPathComponent("Library/LaunchAgents/ai.openclaw.mac.plist") - } - - static func status() async -> Bool { - guard FileManager().fileExists(atPath: self.plistURL.path) else { return false } - let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(launchdLabel)"]) - return result == 0 - } - - static func set(enabled: Bool, bundlePath: String) async { - if enabled { - self.writePlist(bundlePath: bundlePath) - _ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"]) - _ = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path]) - _ = await self.runLaunchctl(["kickstart", "-k", "gui/\(getuid())/\(launchdLabel)"]) - } else { - // Disable autostart going forward but leave the current app running. - // bootout would terminate the launchd job immediately (and crash the app if launched via agent). - try? FileManager().removeItem(at: self.plistURL) - } - } - - private static func writePlist(bundlePath: String) { - let plist = """ - - - - - Label - ai.openclaw.mac - ProgramArguments - - \(bundlePath)/Contents/MacOS/OpenClaw - - WorkingDirectory - \(FileManager().homeDirectoryForCurrentUser.path) - RunAtLoad - - KeepAlive - - EnvironmentVariables - - PATH - \(CommandResolver.preferredPaths().joined(separator: ":")) - - StandardOutPath - \(LogLocator.launchdLogPath) - StandardErrorPath - \(LogLocator.launchdLogPath) - - - """ - try? plist.write(to: self.plistURL, atomically: true, encoding: .utf8) - } - - @discardableResult - private static func runLaunchctl(_ args: [String]) async -> Int32 { - await Task.detached(priority: .utility) { () -> Int32 in - let process = Process() - process.launchPath = "/bin/launchctl" - process.arguments = args - let pipe = Pipe() - process.standardOutput = pipe - process.standardError = pipe - do { - _ = try process.runAndReadToEnd(from: pipe) - return process.terminationStatus - } catch { - return -1 - } - }.value - } -} diff --git a/apps/macos/Sources/OpenClaw/Launchctl.swift b/apps/macos/Sources/OpenClaw/Launchctl.swift deleted file mode 100644 index cc50fd48ac7..00000000000 --- a/apps/macos/Sources/OpenClaw/Launchctl.swift +++ /dev/null @@ -1,87 +0,0 @@ -import Foundation - -enum Launchctl { - struct Result: Sendable { - let status: Int32 - let output: String - } - - @discardableResult - static func run(_ args: [String]) async -> Result { - await Task.detached(priority: .utility) { () -> Result in - let process = Process() - process.launchPath = "/bin/launchctl" - process.arguments = args - let pipe = Pipe() - process.standardOutput = pipe - process.standardError = pipe - do { - let data = try process.runAndReadToEnd(from: pipe) - let output = String(data: data, encoding: .utf8) ?? "" - return Result(status: process.terminationStatus, output: output) - } catch { - return Result(status: -1, output: error.localizedDescription) - } - }.value - } -} - -struct LaunchAgentPlistSnapshot: Equatable, Sendable { - let programArguments: [String] - let environment: [String: String] - let stdoutPath: String? - let stderrPath: String? - - let port: Int? - let bind: String? - let token: String? - let password: String? -} - -enum LaunchAgentPlist { - static func snapshot(url: URL) -> LaunchAgentPlistSnapshot? { - guard let data = try? Data(contentsOf: url) else { return nil } - let rootAny: Any - do { - rootAny = try PropertyListSerialization.propertyList( - from: data, - options: [], - format: nil) - } catch { - return nil - } - guard let root = rootAny as? [String: Any] else { return nil } - let programArguments = root["ProgramArguments"] as? [String] ?? [] - let env = root["EnvironmentVariables"] as? [String: String] ?? [:] - let stdoutPath = (root["StandardOutPath"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty - let stderrPath = (root["StandardErrorPath"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty - let port = Self.extractFlagInt(programArguments, flag: "--port") - let bind = Self.extractFlagString(programArguments, flag: "--bind")?.lowercased() - let token = env["OPENCLAW_GATEWAY_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty - let password = env["OPENCLAW_GATEWAY_PASSWORD"]?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty - return LaunchAgentPlistSnapshot( - programArguments: programArguments, - environment: env, - stdoutPath: stdoutPath, - stderrPath: stderrPath, - port: port, - bind: bind, - token: token, - password: password) - } - - private static func extractFlagInt(_ args: [String], flag: String) -> Int? { - guard let raw = self.extractFlagString(args, flag: flag) else { return nil } - return Int(raw) - } - - private static func extractFlagString(_ args: [String], flag: String) -> String? { - guard let idx = args.firstIndex(of: flag) else { return nil } - let valueIdx = args.index(after: idx) - guard valueIdx < args.endIndex else { return nil } - let token = args[valueIdx].trimmingCharacters(in: .whitespacesAndNewlines) - return token.isEmpty ? nil : token - } -} diff --git a/apps/macos/Sources/OpenClaw/LaunchdManager.swift b/apps/macos/Sources/OpenClaw/LaunchdManager.swift deleted file mode 100644 index 961246f194b..00000000000 --- a/apps/macos/Sources/OpenClaw/LaunchdManager.swift +++ /dev/null @@ -1,20 +0,0 @@ -import Foundation - -enum LaunchdManager { - private static func runLaunchctl(_ args: [String]) { - let process = Process() - process.launchPath = "/bin/launchctl" - process.arguments = args - try? process.run() - } - - static func startOpenClaw() { - let userTarget = "gui/\(getuid())/\(launchdLabel)" - self.runLaunchctl(["kickstart", "-k", userTarget]) - } - - static func stopOpenClaw() { - let userTarget = "gui/\(getuid())/\(launchdLabel)" - self.runLaunchctl(["stop", userTarget]) - } -} diff --git a/apps/macos/Sources/OpenClaw/LogLocator.swift b/apps/macos/Sources/OpenClaw/LogLocator.swift deleted file mode 100644 index b504ab02ace..00000000000 --- a/apps/macos/Sources/OpenClaw/LogLocator.swift +++ /dev/null @@ -1,59 +0,0 @@ -import Foundation - -enum LogLocator { - private static var logDir: URL { - if let override = ProcessInfo.processInfo.environment["OPENCLAW_LOG_DIR"], - !override.isEmpty - { - return URL(fileURLWithPath: override) - } - return URL(fileURLWithPath: "/tmp/openclaw") - } - - private static var stdoutLog: URL { - logDir.appendingPathComponent("openclaw-stdout.log") - } - - private static var gatewayLog: URL { - logDir.appendingPathComponent("openclaw-gateway.log") - } - - private static func ensureLogDirExists() { - try? FileManager().createDirectory(at: self.logDir, withIntermediateDirectories: true) - } - - private static func modificationDate(for url: URL) -> Date { - (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast - } - - /// Returns the newest log file under /tmp/openclaw/ (rolling or stdout), or nil if none exist. - static func bestLogFile() -> URL? { - self.ensureLogDirExists() - let fm = FileManager() - let files = (try? fm.contentsOfDirectory( - at: self.logDir, - includingPropertiesForKeys: [.contentModificationDateKey], - options: [.skipsHiddenFiles])) ?? [] - - let prefixes = ["openclaw"] - return files - .filter { file in - prefixes.contains { file.lastPathComponent.hasPrefix($0) } && file.pathExtension == "log" - } - .max { lhs, rhs in - self.modificationDate(for: lhs) < self.modificationDate(for: rhs) - } - } - - /// Path to use for launchd stdout/err. - static var launchdLogPath: String { - self.ensureLogDirExists() - return stdoutLog.path - } - - /// Path to use for the Gateway launchd job stdout/err. - static var launchdGatewayLogPath: String { - self.ensureLogDirExists() - return gatewayLog.path - } -} diff --git a/apps/macos/Sources/OpenClaw/Logging/OpenClawLogging.swift b/apps/macos/Sources/OpenClaw/Logging/OpenClawLogging.swift deleted file mode 100644 index 7692887e6c7..00000000000 --- a/apps/macos/Sources/OpenClaw/Logging/OpenClawLogging.swift +++ /dev/null @@ -1,232 +0,0 @@ -import Foundation -@_exported import Logging -import os -import OSLog - -typealias Logger = Logging.Logger - -enum AppLogSettings { - static let logLevelKey = appLogLevelKey - - static func logLevel() -> Logger.Level { - if let raw = UserDefaults.standard.string(forKey: self.logLevelKey), - let level = Logger.Level(rawValue: raw) - { - return level - } - return .info - } - - static func setLogLevel(_ level: Logger.Level) { - UserDefaults.standard.set(level.rawValue, forKey: self.logLevelKey) - } - - static func fileLoggingEnabled() -> Bool { - UserDefaults.standard.bool(forKey: debugFileLogEnabledKey) - } -} - -enum AppLogLevel: String, CaseIterable, Identifiable { - case trace - case debug - case info - case notice - case warning - case error - case critical - - static let `default`: AppLogLevel = .info - - var id: String { - self.rawValue - } - - var title: String { - switch self { - case .trace: "Trace" - case .debug: "Debug" - case .info: "Info" - case .notice: "Notice" - case .warning: "Warning" - case .error: "Error" - case .critical: "Critical" - } - } -} - -enum OpenClawLogging { - private static let labelSeparator = "::" - - private static let didBootstrap: Void = { - LoggingSystem.bootstrap { label in - let (subsystem, category) = Self.parseLabel(label) - let osHandler = OpenClawOSLogHandler(subsystem: subsystem, category: category) - let fileHandler = OpenClawFileLogHandler(label: label) - return MultiplexLogHandler([osHandler, fileHandler]) - } - }() - - static func bootstrapIfNeeded() { - _ = self.didBootstrap - } - - static func makeLabel(subsystem: String, category: String) -> String { - "\(subsystem)\(self.labelSeparator)\(category)" - } - - static func parseLabel(_ label: String) -> (String, String) { - guard let range = label.range(of: labelSeparator) else { - return ("ai.openclaw", label) - } - let subsystem = String(label[.. Logger.Metadata.Value? { - get { self.metadata[key] } - set { self.metadata[key] = newValue } - } - - func log( - level: Logger.Level, - message: Logger.Message, - metadata: Logger.Metadata?, - source: String, - file: String, - function: String, - line: UInt) - { - let merged = Self.mergeMetadata(self.metadata, metadata) - let rendered = Self.renderMessage(message, metadata: merged) - self.osLogger.log(level: Self.osLogType(for: level), "\(rendered, privacy: .public)") - } - - private static func osLogType(for level: Logger.Level) -> OSLogType { - switch level { - case .trace, .debug: - .debug - case .info, .notice: - .info - case .warning: - .default - case .error: - .error - case .critical: - .fault - } - } - - private static func mergeMetadata( - _ base: Logger.Metadata, - _ extra: Logger.Metadata?) -> Logger.Metadata - { - guard let extra else { return base } - return base.merging(extra, uniquingKeysWith: { _, new in new }) - } - - private static func renderMessage(_ message: Logger.Message, metadata: Logger.Metadata) -> String { - guard !metadata.isEmpty else { return message.description } - let meta = metadata - .sorted(by: { $0.key < $1.key }) - .map { "\($0.key)=\(self.stringify($0.value))" } - .joined(separator: " ") - return "\(message.description) [\(meta)]" - } - - private static func stringify(_ value: Logger.Metadata.Value) -> String { - switch value { - case let .string(text): - text - case let .stringConvertible(value): - String(describing: value) - case let .array(values): - "[" + values.map { self.stringify($0) }.joined(separator: ",") + "]" - case let .dictionary(entries): - "{" + entries.map { "\($0.key)=\(self.stringify($0.value))" }.joined(separator: ",") + "}" - } - } -} - -struct OpenClawFileLogHandler: LogHandler { - let label: String - var metadata: Logger.Metadata = [:] - - var logLevel: Logger.Level { - get { AppLogSettings.logLevel() } - set { AppLogSettings.setLogLevel(newValue) } - } - - subscript(metadataKey key: String) -> Logger.Metadata.Value? { - get { self.metadata[key] } - set { self.metadata[key] = newValue } - } - - func log( - level: Logger.Level, - message: Logger.Message, - metadata: Logger.Metadata?, - source: String, - file: String, - function: String, - line: UInt) - { - guard AppLogSettings.fileLoggingEnabled() else { return } - let (subsystem, category) = OpenClawLogging.parseLabel(self.label) - var fields: [String: String] = [ - "subsystem": subsystem, - "category": category, - "level": level.rawValue, - "source": source, - "file": file, - "function": function, - "line": "\(line)", - ] - let merged = self.metadata.merging(metadata ?? [:], uniquingKeysWith: { _, new in new }) - for (key, value) in merged { - fields["meta.\(key)"] = Self.stringify(value) - } - DiagnosticsFileLog.shared.log(category: category, event: message.description, fields: fields) - } - - private static func stringify(_ value: Logger.Metadata.Value) -> String { - switch value { - case let .string(text): - text - case let .stringConvertible(value): - String(describing: value) - case let .array(values): - "[" + values.map { self.stringify($0) }.joined(separator: ",") + "]" - case let .dictionary(entries): - "{" + entries.map { "\($0.key)=\(self.stringify($0.value))" }.joined(separator: ",") + "}" - } - } -} diff --git a/apps/macos/Sources/OpenClaw/MenuBar.swift b/apps/macos/Sources/OpenClaw/MenuBar.swift deleted file mode 100644 index 00e2a9be0a6..00000000000 --- a/apps/macos/Sources/OpenClaw/MenuBar.swift +++ /dev/null @@ -1,474 +0,0 @@ -import AppKit -import Darwin -import Foundation -import MenuBarExtraAccess -import Observation -import OSLog -import Security -import SwiftUI - -@main -struct OpenClawApp: App { - @NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate - @State private var state: AppState - private static let logger = Logger(subsystem: "ai.openclaw", category: "app") - private let gatewayManager = GatewayProcessManager.shared - private let controlChannel = ControlChannel.shared - private let activityStore = WorkActivityStore.shared - private let connectivityCoordinator = GatewayConnectivityCoordinator.shared - @State private var statusItem: NSStatusItem? - @State private var isMenuPresented = false - @State private var isPanelVisible = false - @State private var tailscaleService = TailscaleService.shared - - @MainActor - private func updateStatusHighlight() { - self.statusItem?.button?.highlight(self.isPanelVisible) - } - - @MainActor - private func updateHoverHUDSuppression() { - HoverHUDController.shared.setSuppressed(self.isMenuPresented || self.isPanelVisible) - } - - init() { - OpenClawLogging.bootstrapIfNeeded() - - Self.applyAttachOnlyOverrideIfNeeded() - _state = State(initialValue: AppStateStore.shared) - } - - var body: some Scene { - MenuBarExtra { MenuContent(state: self.state, updater: self.delegate.updaterController) } label: { - CritterStatusLabel( - isPaused: self.state.isPaused, - isSleeping: self.isGatewaySleeping, - isWorking: self.state.isWorking, - earBoostActive: self.state.earBoostActive, - blinkTick: self.state.blinkTick, - sendCelebrationTick: self.state.sendCelebrationTick, - gatewayStatus: self.gatewayManager.status, - animationsEnabled: self.state.iconAnimationsEnabled && !self.isGatewaySleeping, - iconState: self.effectiveIconState) - } - .menuBarExtraStyle(.menu) - .menuBarExtraAccess(isPresented: self.$isMenuPresented) { item in - self.statusItem = item - MenuSessionsInjector.shared.install(into: item) - self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping) - self.installStatusItemMouseHandler(for: item) - self.updateHoverHUDSuppression() - } - .onChange(of: self.state.isPaused) { _, paused in - self.applyStatusItemAppearance(paused: paused, sleeping: self.isGatewaySleeping) - if self.state.connectionMode == .local { - self.gatewayManager.setActive(!paused) - } else { - self.gatewayManager.stop() - } - } - .onChange(of: self.controlChannel.state) { _, _ in - self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping) - } - .onChange(of: self.gatewayManager.status) { _, _ in - self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping) - } - .onChange(of: self.state.connectionMode) { _, mode in - Task { await ConnectionModeCoordinator.shared.apply(mode: mode, paused: self.state.isPaused) } - CLIInstallPrompter.shared.checkAndPromptIfNeeded(reason: "connection-mode") - } - - Settings { - SettingsRootView(state: self.state, updater: self.delegate.updaterController) - .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight, alignment: .topLeading) - .environment(self.tailscaleService) - } - .defaultSize(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) - .windowResizability(.contentSize) - .onChange(of: self.isMenuPresented) { _, _ in - self.updateStatusHighlight() - self.updateHoverHUDSuppression() - } - } - - private func applyStatusItemAppearance(paused: Bool, sleeping: Bool) { - self.statusItem?.button?.appearsDisabled = paused || sleeping - } - - private static func applyAttachOnlyOverrideIfNeeded() { - let args = CommandLine.arguments - guard args.contains("--attach-only") || args.contains("--no-launchd") else { return } - if let error = GatewayLaunchAgentManager.setLaunchAgentWriteDisabled(true) { - Self.logger.error("attach-only flag failed: \(error, privacy: .public)") - return - } - Task { - _ = await GatewayLaunchAgentManager.set( - enabled: false, - bundlePath: Bundle.main.bundlePath, - port: GatewayEnvironment.gatewayPort()) - } - Self.logger.info("attach-only flag enabled") - } - - private var isGatewaySleeping: Bool { - if self.state.isPaused { return false } - switch self.state.connectionMode { - case .unconfigured: - return true - case .remote: - if case .connected = self.controlChannel.state { return false } - return true - case .local: - switch self.gatewayManager.status { - case .running, .starting, .attachedExisting: - if case .connected = self.controlChannel.state { return false } - return true - case .failed, .stopped: - return true - } - } - } - - @MainActor - private func installStatusItemMouseHandler(for item: NSStatusItem) { - guard let button = item.button else { return } - if button.subviews.contains(where: { $0 is StatusItemMouseHandlerView }) { return } - - WebChatManager.shared.onPanelVisibilityChanged = { [self] visible in - self.isPanelVisible = visible - self.updateStatusHighlight() - self.updateHoverHUDSuppression() - } - CanvasManager.shared.onPanelVisibilityChanged = { [self] visible in - self.state.canvasPanelVisible = visible - } - CanvasManager.shared.defaultAnchorProvider = { [self] in self.statusButtonScreenFrame() } - - let handler = StatusItemMouseHandlerView() - handler.translatesAutoresizingMaskIntoConstraints = false - handler.onLeftClick = { [self] in - HoverHUDController.shared.dismiss(reason: "statusItemClick") - self.toggleWebChatPanel() - } - handler.onRightClick = { [self] in - HoverHUDController.shared.dismiss(reason: "statusItemRightClick") - WebChatManager.shared.closePanel() - self.isMenuPresented = true - self.updateStatusHighlight() - } - handler.onHoverChanged = { [self] inside in - HoverHUDController.shared.statusItemHoverChanged( - inside: inside, - anchorProvider: { [self] in self.statusButtonScreenFrame() }) - } - - button.addSubview(handler) - NSLayoutConstraint.activate([ - handler.leadingAnchor.constraint(equalTo: button.leadingAnchor), - handler.trailingAnchor.constraint(equalTo: button.trailingAnchor), - handler.topAnchor.constraint(equalTo: button.topAnchor), - handler.bottomAnchor.constraint(equalTo: button.bottomAnchor), - ]) - } - - @MainActor - private func toggleWebChatPanel() { - HoverHUDController.shared.setSuppressed(true) - self.isMenuPresented = false - Task { @MainActor in - let sessionKey = await WebChatManager.shared.preferredSessionKey() - WebChatManager.shared.togglePanel( - sessionKey: sessionKey, - anchorProvider: { [self] in self.statusButtonScreenFrame() }) - } - } - - @MainActor - private func statusButtonScreenFrame() -> NSRect? { - guard let button = self.statusItem?.button, let window = button.window else { return nil } - let inWindow = button.convert(button.bounds, to: nil) - return window.convertToScreen(inWindow) - } - - private var effectiveIconState: IconState { - let selection = self.state.iconOverride - if selection == .system { - return self.activityStore.iconState - } - let overrideState = selection.toIconState() - switch overrideState { - case let .workingMain(kind): return .overridden(kind) - case let .workingOther(kind): return .overridden(kind) - case .idle: return .idle - case let .overridden(kind): return .overridden(kind) - } - } -} - -/// Transparent overlay that intercepts clicks without stealing MenuBarExtra ownership. -private final class StatusItemMouseHandlerView: NSView { - var onLeftClick: (() -> Void)? - var onRightClick: (() -> Void)? - var onHoverChanged: ((Bool) -> Void)? - private var tracking: NSTrackingArea? - - override func mouseDown(with event: NSEvent) { - if let onLeftClick { - onLeftClick() - } else { - super.mouseDown(with: event) - } - } - - override func rightMouseDown(with event: NSEvent) { - self.onRightClick?() - // Do not call super; menu will be driven by isMenuPresented binding. - } - - override func updateTrackingAreas() { - super.updateTrackingAreas() - if let tracking { - self.removeTrackingArea(tracking) - } - let options: NSTrackingArea.Options = [ - .mouseEnteredAndExited, - .activeAlways, - .inVisibleRect, - ] - let area = NSTrackingArea(rect: self.bounds, options: options, owner: self, userInfo: nil) - self.addTrackingArea(area) - self.tracking = area - } - - override func mouseEntered(with event: NSEvent) { - self.onHoverChanged?(true) - } - - override func mouseExited(with event: NSEvent) { - self.onHoverChanged?(false) - } -} - -@MainActor -final class AppDelegate: NSObject, NSApplicationDelegate { - private var state: AppState? - private let webChatAutoLogger = Logger(subsystem: "ai.openclaw", category: "Chat") - let updaterController: UpdaterProviding = makeUpdaterController() - - func application(_: NSApplication, open urls: [URL]) { - Task { @MainActor in - for url in urls { - await DeepLinkHandler.shared.handle(url: url) - } - } - } - - @MainActor - func applicationDidFinishLaunching(_ notification: Notification) { - if self.isDuplicateInstance() { - NSApp.terminate(nil) - return - } - self.state = AppStateStore.shared - AppActivationPolicy.apply(showDockIcon: self.state?.showDockIcon ?? false) - if let state { - Task { await ConnectionModeCoordinator.shared.apply(mode: state.connectionMode, paused: state.isPaused) } - } - TerminationSignalWatcher.shared.start() - NodePairingApprovalPrompter.shared.start() - DevicePairingApprovalPrompter.shared.start() - ExecApprovalsPromptServer.shared.start() - ExecApprovalsGatewayPrompter.shared.start() - MacNodeModeCoordinator.shared.start() - VoiceWakeGlobalSettingsSync.shared.start() - Task { PresenceReporter.shared.start() } - Task { await HealthStore.shared.refresh(onDemand: true) } - Task { await PortGuardian.shared.sweep(mode: AppStateStore.shared.connectionMode) } - Task { await PeekabooBridgeHostCoordinator.shared.setEnabled(AppStateStore.shared.peekabooBridgeEnabled) } - self.scheduleFirstRunOnboardingIfNeeded() - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - CLIInstallPrompter.shared.checkAndPromptIfNeeded(reason: "launch") - } - - // Developer/testing helper: auto-open chat when launched with --chat (or legacy --webchat). - if CommandLine.arguments.contains("--chat") || CommandLine.arguments.contains("--webchat") { - self.webChatAutoLogger.debug("Auto-opening chat via CLI flag") - Task { @MainActor in - let sessionKey = await WebChatManager.shared.preferredSessionKey() - WebChatManager.shared.show(sessionKey: sessionKey) - } - } - } - - func applicationWillTerminate(_ notification: Notification) { - PresenceReporter.shared.stop() - NodePairingApprovalPrompter.shared.stop() - DevicePairingApprovalPrompter.shared.stop() - ExecApprovalsPromptServer.shared.stop() - ExecApprovalsGatewayPrompter.shared.stop() - MacNodeModeCoordinator.shared.stop() - TerminationSignalWatcher.shared.stop() - VoiceWakeGlobalSettingsSync.shared.stop() - WebChatManager.shared.close() - WebChatManager.shared.resetTunnels() - Task { await RemoteTunnelManager.shared.stopAll() } - Task { await GatewayConnection.shared.shutdown() } - Task { await PeekabooBridgeHostCoordinator.shared.stop() } - } - - @MainActor - private func scheduleFirstRunOnboardingIfNeeded() { - let seenVersion = UserDefaults.standard.integer(forKey: onboardingVersionKey) - let shouldShow = seenVersion < currentOnboardingVersion || !AppStateStore.shared.onboardingSeen - guard shouldShow else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { - OnboardingController.shared.show() - } - } - - private func isDuplicateInstance() -> Bool { - guard let bundleID = Bundle.main.bundleIdentifier else { return false } - let running = NSWorkspace.shared.runningApplications.filter { $0.bundleIdentifier == bundleID } - return running.count > 1 - } -} - -// MARK: - Sparkle updater (disabled for unsigned/dev builds) - -@MainActor -protocol UpdaterProviding: AnyObject { - var automaticallyChecksForUpdates: Bool { get set } - var automaticallyDownloadsUpdates: Bool { get set } - var isAvailable: Bool { get } - var updateStatus: UpdateStatus { get } - func checkForUpdates(_ sender: Any?) -} - -/// No-op updater used for debug/dev runs to suppress Sparkle dialogs. -final class DisabledUpdaterController: UpdaterProviding { - var automaticallyChecksForUpdates: Bool = false - var automaticallyDownloadsUpdates: Bool = false - let isAvailable: Bool = false - let updateStatus = UpdateStatus() - func checkForUpdates(_: Any?) {} -} - -@MainActor -@Observable -final class UpdateStatus { - static let disabled = UpdateStatus() - var isUpdateReady: Bool - - init(isUpdateReady: Bool = false) { - self.isUpdateReady = isUpdateReady - } -} - -#if canImport(Sparkle) -import Sparkle - -@MainActor -final class SparkleUpdaterController: NSObject, UpdaterProviding { - private lazy var controller = SPUStandardUpdaterController( - startingUpdater: false, - updaterDelegate: self, - userDriverDelegate: nil) - let updateStatus = UpdateStatus() - - init(savedAutoUpdate: Bool) { - super.init() - let updater = self.controller.updater - updater.automaticallyChecksForUpdates = savedAutoUpdate - updater.automaticallyDownloadsUpdates = savedAutoUpdate - self.controller.startUpdater() - } - - var automaticallyChecksForUpdates: Bool { - get { self.controller.updater.automaticallyChecksForUpdates } - set { self.controller.updater.automaticallyChecksForUpdates = newValue } - } - - var automaticallyDownloadsUpdates: Bool { - get { self.controller.updater.automaticallyDownloadsUpdates } - set { self.controller.updater.automaticallyDownloadsUpdates = newValue } - } - - var isAvailable: Bool { - true - } - - func checkForUpdates(_ sender: Any?) { - self.controller.checkForUpdates(sender) - } - - func updater(_ updater: SPUUpdater, didDownloadUpdate item: SUAppcastItem) { - self.updateStatus.isUpdateReady = true - } - - func updater(_ updater: SPUUpdater, failedToDownloadUpdate item: SUAppcastItem, error: Error) { - self.updateStatus.isUpdateReady = false - } - - func userDidCancelDownload(_ updater: SPUUpdater) { - self.updateStatus.isUpdateReady = false - } - - func updater( - _ updater: SPUUpdater, - userDidMakeChoice choice: SPUUserUpdateChoice, - forUpdate updateItem: SUAppcastItem, - state: SPUUserUpdateState) - { - switch choice { - case .install, .skip: - self.updateStatus.isUpdateReady = false - case .dismiss: - self.updateStatus.isUpdateReady = (state.stage == .downloaded) - @unknown default: - self.updateStatus.isUpdateReady = false - } - } -} - -extension SparkleUpdaterController: @preconcurrency SPUUpdaterDelegate {} - -private func isDeveloperIDSigned(bundleURL: URL) -> Bool { - var staticCode: SecStaticCode? - guard SecStaticCodeCreateWithPath(bundleURL as CFURL, SecCSFlags(), &staticCode) == errSecSuccess, - let code = staticCode - else { return false } - - var infoCF: CFDictionary? - guard SecCodeCopySigningInformation(code, SecCSFlags(rawValue: kSecCSSigningInformation), &infoCF) == errSecSuccess, - let info = infoCF as? [String: Any], - let certs = info[kSecCodeInfoCertificates as String] as? [SecCertificate], - let leaf = certs.first - else { - return false - } - - if let summary = SecCertificateCopySubjectSummary(leaf) as String? { - return summary.hasPrefix("Developer ID Application:") - } - return false -} - -@MainActor -private func makeUpdaterController() -> UpdaterProviding { - let bundleURL = Bundle.main.bundleURL - let isBundledApp = bundleURL.pathExtension == "app" - guard isBundledApp, isDeveloperIDSigned(bundleURL: bundleURL) else { return DisabledUpdaterController() } - - let defaults = UserDefaults.standard - let autoUpdateKey = "autoUpdateEnabled" - // Default to true; honor the user's last choice otherwise. - let savedAutoUpdate = (defaults.object(forKey: autoUpdateKey) as? Bool) ?? true - return SparkleUpdaterController(savedAutoUpdate: savedAutoUpdate) -} -#else -@MainActor -private func makeUpdaterController() -> UpdaterProviding { - DisabledUpdaterController() -} -#endif diff --git a/apps/macos/Sources/OpenClaw/MenuContentView.swift b/apps/macos/Sources/OpenClaw/MenuContentView.swift deleted file mode 100644 index 3416d23f812..00000000000 --- a/apps/macos/Sources/OpenClaw/MenuContentView.swift +++ /dev/null @@ -1,596 +0,0 @@ -import AppKit -import AVFoundation -import Foundation -import Observation -import SwiftUI - -/// Menu contents for the OpenClaw menu bar extra. -struct MenuContent: View { - @Bindable var state: AppState - let updater: UpdaterProviding? - @Bindable private var updateStatus: UpdateStatus - private let gatewayManager = GatewayProcessManager.shared - private let healthStore = HealthStore.shared - private let heartbeatStore = HeartbeatStore.shared - private let controlChannel = ControlChannel.shared - private let activityStore = WorkActivityStore.shared - @Bindable private var pairingPrompter = NodePairingApprovalPrompter.shared - @Bindable private var devicePairingPrompter = DevicePairingApprovalPrompter.shared - @Environment(\.openSettings) private var openSettings - @State private var availableMics: [AudioInputDevice] = [] - @State private var loadingMics = false - @State private var micObserver = AudioInputDeviceObserver() - @State private var micRefreshTask: Task? - @State private var browserControlEnabled = true - @AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false - @AppStorage(appLogLevelKey) private var appLogLevelRaw: String = AppLogLevel.default.rawValue - @AppStorage(debugFileLogEnabledKey) private var appFileLoggingEnabled: Bool = false - - init(state: AppState, updater: UpdaterProviding?) { - self._state = Bindable(wrappedValue: state) - self.updater = updater - self._updateStatus = Bindable(wrappedValue: updater?.updateStatus ?? UpdateStatus.disabled) - } - - private var execApprovalModeBinding: Binding { - Binding( - get: { self.state.execApprovalMode }, - set: { self.state.execApprovalMode = $0 }) - } - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - Toggle(isOn: self.activeBinding) { - VStack(alignment: .leading, spacing: 2) { - Text(self.connectionLabel) - self.statusLine(label: self.healthStatus.label, color: self.healthStatus.color) - if self.pairingPrompter.pendingCount > 0 { - let repairCount = self.pairingPrompter.pendingRepairCount - let repairSuffix = repairCount > 0 ? " · \(repairCount) repair" : "" - self.statusLine( - label: "Pairing approval pending (\(self.pairingPrompter.pendingCount))\(repairSuffix)", - color: .orange) - } - if self.devicePairingPrompter.pendingCount > 0 { - let repairCount = self.devicePairingPrompter.pendingRepairCount - let repairSuffix = repairCount > 0 ? " · \(repairCount) repair" : "" - self.statusLine( - label: "Device pairing pending (\(self.devicePairingPrompter.pendingCount))\(repairSuffix)", - color: .orange) - } - } - } - .disabled(self.state.connectionMode == .unconfigured) - - Divider() - Toggle(isOn: self.heartbeatsBinding) { - HStack(spacing: 8) { - Label("Send Heartbeats", systemImage: "waveform.path.ecg") - Spacer(minLength: 0) - self.statusLine(label: self.heartbeatStatus.label, color: self.heartbeatStatus.color) - } - } - Toggle( - isOn: Binding( - get: { self.browserControlEnabled }, - set: { enabled in - self.browserControlEnabled = enabled - Task { await self.saveBrowserControlEnabled(enabled) } - })) { - Label("Browser Control", systemImage: "globe") - } - Toggle(isOn: self.$cameraEnabled) { - Label("Allow Camera", systemImage: "camera") - } - Picker(selection: self.execApprovalModeBinding) { - ForEach(ExecApprovalQuickMode.allCases) { mode in - Text(mode.title).tag(mode) - } - } label: { - Label("Exec Approvals", systemImage: "terminal") - } - Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) { - Label("Allow Canvas", systemImage: "rectangle.and.pencil.and.ellipsis") - } - .onChange(of: self.state.canvasEnabled) { _, enabled in - if !enabled { - CanvasManager.shared.hideAll() - } - } - Toggle(isOn: self.voiceWakeBinding) { - Label("Voice Wake", systemImage: "mic.fill") - } - .disabled(!voiceWakeSupported) - .opacity(voiceWakeSupported ? 1 : 0.5) - if self.showVoiceWakeMicPicker { - self.voiceWakeMicMenu - } - Divider() - Button { - Task { @MainActor in - await self.openDashboard() - } - } label: { - Label("Open Dashboard", systemImage: "gauge") - } - Button { - Task { @MainActor in - let sessionKey = await WebChatManager.shared.preferredSessionKey() - WebChatManager.shared.show(sessionKey: sessionKey) - } - } label: { - Label("Open Chat", systemImage: "bubble.left.and.bubble.right") - } - if self.state.canvasEnabled { - Button { - Task { @MainActor in - if self.state.canvasPanelVisible { - CanvasManager.shared.hideAll() - } else { - let sessionKey = await GatewayConnection.shared.mainSessionKey() - // Don't force a navigation on re-open: preserve the current web view state. - _ = try? CanvasManager.shared.show(sessionKey: sessionKey, path: nil) - } - } - } label: { - Label( - self.state.canvasPanelVisible ? "Close Canvas" : "Open Canvas", - systemImage: "rectangle.inset.filled.on.rectangle") - } - } - Button { - Task { await self.state.setTalkEnabled(!self.state.talkEnabled) } - } label: { - Label(self.state.talkEnabled ? "Stop Talk Mode" : "Talk Mode", systemImage: "waveform.circle.fill") - } - .disabled(!voiceWakeSupported) - .opacity(voiceWakeSupported ? 1 : 0.5) - Divider() - Button("Settings…") { self.open(tab: .general) } - .keyboardShortcut(",", modifiers: [.command]) - self.debugMenu - Button("About OpenClaw") { self.open(tab: .about) } - if let updater, updater.isAvailable, self.updateStatus.isUpdateReady { - Button("Update ready, restart now?") { updater.checkForUpdates(nil) } - } - Button("Quit") { NSApplication.shared.terminate(nil) } - } - .task(id: self.state.swabbleEnabled) { - if self.state.swabbleEnabled { - await self.loadMicrophones(force: true) - } - } - .task { - VoicePushToTalkHotkey.shared.setEnabled(voiceWakeSupported && self.state.voicePushToTalkEnabled) - } - .onChange(of: self.state.voicePushToTalkEnabled) { _, enabled in - VoicePushToTalkHotkey.shared.setEnabled(voiceWakeSupported && enabled) - } - .task(id: self.state.connectionMode) { - await self.loadBrowserControlEnabled() - } - .onAppear { - self.startMicObserver() - } - .onDisappear { - self.micRefreshTask?.cancel() - self.micRefreshTask = nil - self.micObserver.stop() - } - .task { @MainActor in - SettingsWindowOpener.shared.register(openSettings: self.openSettings) - } - } - - private var connectionLabel: String { - switch self.state.connectionMode { - case .unconfigured: - "OpenClaw Not Configured" - case .remote: - "Remote OpenClaw Active" - case .local: - "OpenClaw Active" - } - } - - private func loadBrowserControlEnabled() async { - let root = await ConfigStore.load() - let browser = root["browser"] as? [String: Any] - let enabled = browser?["enabled"] as? Bool ?? true - await MainActor.run { self.browserControlEnabled = enabled } - } - - private func saveBrowserControlEnabled(_ enabled: Bool) async { - let (success, _) = await MenuContent.buildAndSaveBrowserEnabled(enabled) - - if !success { - await self.loadBrowserControlEnabled() - } - } - - @MainActor - private static func buildAndSaveBrowserEnabled(_ enabled: Bool) async -> (Bool, ()) { - var root = await ConfigStore.load() - var browser = root["browser"] as? [String: Any] ?? [:] - browser["enabled"] = enabled - root["browser"] = browser - do { - try await ConfigStore.save(root) - return (true, ()) - } catch { - return (false, ()) - } - } - - @ViewBuilder - private var debugMenu: some View { - if self.state.debugPaneEnabled { - Menu("Debug") { - Button { - DebugActions.openConfigFolder() - } label: { - Label("Open Config Folder", systemImage: "folder") - } - Button { - Task { await DebugActions.runHealthCheckNow() } - } label: { - Label("Run Health Check Now", systemImage: "stethoscope") - } - Button { - Task { _ = await DebugActions.sendTestHeartbeat() } - } label: { - Label("Send Test Heartbeat", systemImage: "waveform.path.ecg") - } - if self.state.connectionMode == .remote { - Button { - Task { @MainActor in - let result = await DebugActions.resetGatewayTunnel() - self.presentDebugResult(result, title: "Remote Tunnel") - } - } label: { - Label("Reset Remote Tunnel", systemImage: "arrow.triangle.2.circlepath") - } - } - Button { - Task { _ = await DebugActions.toggleVerboseLoggingMain() } - } label: { - Label( - DebugActions.verboseLoggingEnabledMain - ? "Verbose Logging (Main): On" - : "Verbose Logging (Main): Off", - systemImage: "text.alignleft") - } - Menu { - Picker("Verbosity", selection: self.$appLogLevelRaw) { - ForEach(AppLogLevel.allCases) { level in - Text(level.title).tag(level.rawValue) - } - } - Toggle(isOn: self.$appFileLoggingEnabled) { - Label( - self.appFileLoggingEnabled - ? "File Logging: On" - : "File Logging: Off", - systemImage: "doc.text.magnifyingglass") - } - } label: { - Label("App Logging", systemImage: "doc.text") - } - Button { - DebugActions.openSessionStore() - } label: { - Label("Open Session Store", systemImage: "externaldrive") - } - Divider() - Button { - DebugActions.openAgentEventsWindow() - } label: { - Label("Open Agent Events…", systemImage: "bolt.horizontal.circle") - } - Button { - DebugActions.openLog() - } label: { - Label("Open Log", systemImage: "doc.text.magnifyingglass") - } - Button { - Task { _ = await DebugActions.sendDebugVoice() } - } label: { - Label("Send Debug Voice Text", systemImage: "waveform.circle") - } - Button { - Task { await DebugActions.sendTestNotification() } - } label: { - Label("Send Test Notification", systemImage: "bell") - } - Divider() - if self.state.connectionMode == .local { - Button { - DebugActions.restartGateway() - } label: { - Label("Restart Gateway", systemImage: "arrow.clockwise") - } - } - Button { - DebugActions.restartOnboarding() - } label: { - Label("Restart Onboarding", systemImage: "arrow.counterclockwise") - } - Button { - DebugActions.restartApp() - } label: { - Label("Restart App", systemImage: "arrow.triangle.2.circlepath") - } - } - } - } - - private func open(tab: SettingsTab) { - SettingsTabRouter.request(tab) - NSApp.activate(ignoringOtherApps: true) - self.openSettings() - DispatchQueue.main.async { - NotificationCenter.default.post(name: .openclawSelectSettingsTab, object: tab) - } - } - - @MainActor - private func openDashboard() async { - do { - let config = try await GatewayEndpointStore.shared.requireConfig() - let url = try GatewayEndpointStore.dashboardURL(for: config, mode: self.state.connectionMode) - NSWorkspace.shared.open(url) - } catch { - let alert = NSAlert() - alert.messageText = "Dashboard unavailable" - alert.informativeText = error.localizedDescription - alert.runModal() - } - } - - private var healthStatus: (label: String, color: Color) { - if let activity = self.activityStore.current { - let color: Color = activity.role == .main ? .accentColor : .gray - let roleLabel = activity.role == .main ? "Main" : "Other" - let text = "\(roleLabel) · \(activity.label)" - return (text, color) - } - - let health = self.healthStore.state - let isRefreshing = self.healthStore.isRefreshing - let lastAge = self.healthStore.lastSuccess.map { age(from: $0) } - - if isRefreshing { - return ("Health check running…", health.tint) - } - - switch health { - case .ok: - let ageText = lastAge.map { " · checked \($0)" } ?? "" - return ("Health ok\(ageText)", .green) - case .linkingNeeded: - return ("Health: login required", .red) - case let .degraded(reason): - let detail = HealthStore.shared.degradedSummary ?? reason - let ageText = lastAge.map { " · checked \($0)" } ?? "" - return ("\(detail)\(ageText)", .orange) - case .unknown: - return ("Health pending", .secondary) - } - } - - private var heartbeatStatus: (label: String, color: Color) { - if case .degraded = self.controlChannel.state { - return ("Control channel disconnected", .red) - } else if let evt = self.heartbeatStore.lastEvent { - let ageText = age(from: Date(timeIntervalSince1970: evt.ts / 1000)) - switch evt.status { - case "sent": - return ("Last heartbeat sent · \(ageText)", .blue) - case "ok-empty", "ok-token": - return ("Heartbeat ok · \(ageText)", .green) - case "skipped": - return ("Heartbeat skipped · \(ageText)", .secondary) - case "failed": - return ("Heartbeat failed · \(ageText)", .red) - default: - return ("Heartbeat · \(ageText)", .secondary) - } - } else { - return ("No heartbeat yet", .secondary) - } - } - - private func statusLine(label: String, color: Color) -> some View { - HStack(spacing: 6) { - Circle() - .fill(color) - .frame(width: 6, height: 6) - Text(label) - .font(.caption) - .foregroundStyle(.secondary) - .multilineTextAlignment(.leading) - .lineLimit(nil) - .fixedSize(horizontal: false, vertical: true) - .layoutPriority(1) - } - .padding(.top, 2) - } - - private var activeBinding: Binding { - Binding(get: { !self.state.isPaused }, set: { self.state.isPaused = !$0 }) - } - - private var heartbeatsBinding: Binding { - Binding(get: { self.state.heartbeatsEnabled }, set: { self.state.heartbeatsEnabled = $0 }) - } - - private var voiceWakeBinding: Binding { - Binding( - get: { self.state.swabbleEnabled }, - set: { newValue in - Task { await self.state.setVoiceWakeEnabled(newValue) } - }) - } - - private var showVoiceWakeMicPicker: Bool { - voiceWakeSupported && self.state.swabbleEnabled - } - - private var voiceWakeMicMenu: some View { - Menu { - self.microphoneMenuItems - - if self.loadingMics { - Divider() - Label("Refreshing microphones…", systemImage: "arrow.triangle.2.circlepath") - .labelStyle(.titleOnly) - .foregroundStyle(.secondary) - .disabled(true) - } - } label: { - HStack { - Text("Microphone") - Spacer() - Text(self.selectedMicLabel) - .foregroundStyle(.secondary) - } - } - .task { await self.loadMicrophones() } - } - - private var selectedMicLabel: String { - if self.state.voiceWakeMicID.isEmpty { return self.defaultMicLabel } - if let match = self.availableMics.first(where: { $0.uid == self.state.voiceWakeMicID }) { - return match.name - } - if !self.state.voiceWakeMicName.isEmpty { return self.state.voiceWakeMicName } - return "Unavailable" - } - - private var microphoneMenuItems: some View { - Group { - if self.isSelectedMicUnavailable { - Label("Disconnected (using System default)", systemImage: "exclamationmark.triangle") - .labelStyle(.titleAndIcon) - .foregroundStyle(.secondary) - .disabled(true) - Divider() - } - Button { - self.state.voiceWakeMicID = "" - self.state.voiceWakeMicName = "" - } label: { - Label(self.defaultMicLabel, systemImage: self.state.voiceWakeMicID.isEmpty ? "checkmark" : "") - .labelStyle(.titleAndIcon) - } - .buttonStyle(.plain) - - ForEach(self.availableMics) { mic in - Button { - self.state.voiceWakeMicID = mic.uid - self.state.voiceWakeMicName = mic.name - } label: { - Label(mic.name, systemImage: self.state.voiceWakeMicID == mic.uid ? "checkmark" : "") - .labelStyle(.titleAndIcon) - } - .buttonStyle(.plain) - } - } - } - - private var isSelectedMicUnavailable: Bool { - let selected = self.state.voiceWakeMicID - guard !selected.isEmpty else { return false } - return !self.availableMics.contains(where: { $0.uid == selected }) - } - - private var defaultMicLabel: String { - if let host = Host.current().localizedName, !host.isEmpty { - return "Auto-detect (\(host))" - } - return "System default" - } - - @MainActor - private func presentDebugResult(_ result: Result, title: String) { - let alert = NSAlert() - alert.messageText = title - switch result { - case let .success(message): - alert.informativeText = message - alert.alertStyle = .informational - case let .failure(error): - alert.informativeText = error.localizedDescription - alert.alertStyle = .warning - } - alert.runModal() - } - - @MainActor - private func loadMicrophones(force: Bool = false) async { - guard self.showVoiceWakeMicPicker else { - self.availableMics = [] - self.loadingMics = false - return - } - if !force, !self.availableMics.isEmpty { return } - self.loadingMics = true - let discovery = AVCaptureDevice.DiscoverySession( - deviceTypes: [.external, .microphone], - mediaType: .audio, - position: .unspecified) - let connectedDevices = discovery.devices.filter(\.isConnected) - self.availableMics = connectedDevices - .sorted { lhs, rhs in - lhs.localizedName.localizedCaseInsensitiveCompare(rhs.localizedName) == .orderedAscending - } - .map { AudioInputDevice(uid: $0.uniqueID, name: $0.localizedName) } - self.availableMics = self.filterAliveInputs(self.availableMics) - self.updateSelectedMicName() - self.loadingMics = false - } - - private func startMicObserver() { - self.micObserver.start { - Task { @MainActor in - self.scheduleMicRefresh() - } - } - } - - @MainActor - private func scheduleMicRefresh() { - self.micRefreshTask?.cancel() - self.micRefreshTask = Task { @MainActor in - try? await Task.sleep(nanoseconds: 300_000_000) - guard !Task.isCancelled else { return } - await self.loadMicrophones(force: true) - } - } - - private func filterAliveInputs(_ inputs: [AudioInputDevice]) -> [AudioInputDevice] { - let aliveUIDs = AudioInputDeviceObserver.aliveInputDeviceUIDs() - guard !aliveUIDs.isEmpty else { return inputs } - return inputs.filter { aliveUIDs.contains($0.uid) } - } - - @MainActor - private func updateSelectedMicName() { - let selected = self.state.voiceWakeMicID - if selected.isEmpty { - self.state.voiceWakeMicName = "" - return - } - if let match = self.availableMics.first(where: { $0.uid == selected }) { - self.state.voiceWakeMicName = match.name - } - } - - private struct AudioInputDevice: Identifiable, Equatable { - let uid: String - let name: String - var id: String { - self.uid - } - } -} diff --git a/apps/macos/Sources/OpenClaw/MenuContextCardInjector.swift b/apps/macos/Sources/OpenClaw/MenuContextCardInjector.swift deleted file mode 100644 index f469ca348dc..00000000000 --- a/apps/macos/Sources/OpenClaw/MenuContextCardInjector.swift +++ /dev/null @@ -1,228 +0,0 @@ -import AppKit -import SwiftUI - -@MainActor -final class MenuContextCardInjector: NSObject, NSMenuDelegate { - static let shared = MenuContextCardInjector() - - private let tag = 9_415_227 - private let fallbackCardWidth: CGFloat = 320 - private var lastKnownMenuWidth: CGFloat? - private weak var originalDelegate: NSMenuDelegate? - private var loadTask: Task? - private var warmTask: Task? - private var cachedRows: [SessionRow] = [] - private var cacheErrorText: String? - private var cacheUpdatedAt: Date? - private let activeWindowSeconds: TimeInterval = 24 * 60 * 60 - private let refreshIntervalSeconds: TimeInterval = 15 - private var isMenuOpen = false - - func install(into statusItem: NSStatusItem) { - // SwiftUI owns the menu, but we can inject a custom NSMenuItem.view right before display. - guard let menu = statusItem.menu else { return } - // Preserve SwiftUI's internal NSMenuDelegate, otherwise it may stop populating menu items. - if menu.delegate !== self { - self.originalDelegate = menu.delegate - menu.delegate = self - } - - if self.warmTask == nil { - self.warmTask = Task { await self.refreshCache(force: true) } - } - } - - func menuWillOpen(_ menu: NSMenu) { - self.originalDelegate?.menuWillOpen?(menu) - self.isMenuOpen = true - - // Remove any previous injected card items. - for item in menu.items where item.tag == self.tag { - menu.removeItem(item) - } - - guard let insertIndex = self.findInsertIndex(in: menu) else { return } - - self.loadTask?.cancel() - - let initialRows = self.cachedRows - let initialIsLoading = initialRows.isEmpty - let initialStatusText = initialIsLoading ? self.cacheErrorText : nil - let initialWidth = self.initialCardWidth(for: menu) - - let initial = AnyView(ContextMenuCardView( - rows: initialRows, - statusText: initialStatusText, - isLoading: initialIsLoading)) - - let hosting = NSHostingView(rootView: initial) - hosting.frame.size.width = max(1, initialWidth) - let size = hosting.fittingSize - hosting.frame = NSRect( - origin: .zero, - size: NSSize(width: initialWidth, height: size.height)) - - let item = NSMenuItem() - item.tag = self.tag - item.view = hosting - item.isEnabled = false - - menu.insertItem(item, at: insertIndex) - - // Capture the menu window width for next open, but do not mutate widths while the menu is visible. - DispatchQueue.main.async { [weak self, weak hosting] in - guard let self, let hosting else { return } - self.captureMenuWidthIfAvailable(for: menu, hosting: hosting) - } - - if initialIsLoading { - self.loadTask = Task { [weak hosting] in - await self.refreshCache(force: true) - guard let hosting else { return } - let view = self.cachedView() - await MainActor.run { - hosting.rootView = view - hosting.invalidateIntrinsicContentSize() - self.captureMenuWidthIfAvailable(for: menu, hosting: hosting) - hosting.frame.size.width = max(1, initialWidth) - let size = hosting.fittingSize - hosting.frame.size.height = size.height - } - } - } else { - // Keep the menu stable while it's open; refresh in the background for next open. - self.loadTask = Task { await self.refreshCache(force: false) } - } - } - - func menuDidClose(_ menu: NSMenu) { - self.originalDelegate?.menuDidClose?(menu) - self.isMenuOpen = false - self.loadTask?.cancel() - } - - func menuNeedsUpdate(_ menu: NSMenu) { - self.originalDelegate?.menuNeedsUpdate?(menu) - } - - func confinementRect(for menu: NSMenu, on screen: NSScreen?) -> NSRect { - if let rect = self.originalDelegate?.confinementRect?(for: menu, on: screen) { - return rect - } - return NSRect.zero - } - - private func refreshCache(force: Bool) async { - if !force, let cacheUpdatedAt, Date().timeIntervalSince(cacheUpdatedAt) < self.refreshIntervalSeconds { - return - } - - do { - let rows = try await self.loadCurrentRows() - self.cachedRows = rows - self.cacheErrorText = nil - self.cacheUpdatedAt = Date() - } catch { - if self.cachedRows.isEmpty { - let raw = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { - self.cacheErrorText = "Could not load sessions" - } else { - // Keep the menu readable: one line, short. - let firstLine = trimmed.split(whereSeparator: \.isNewline).first.map(String.init) ?? trimmed - self.cacheErrorText = firstLine.count > 90 ? "\(firstLine.prefix(87))…" : firstLine - } - } - self.cacheUpdatedAt = Date() - } - } - - private func cachedView() -> AnyView { - let rows = self.cachedRows - let isLoading = rows.isEmpty && self.cacheErrorText == nil - return AnyView(ContextMenuCardView(rows: rows, statusText: self.cacheErrorText, isLoading: isLoading)) - } - - private func loadCurrentRows() async throws -> [SessionRow] { - let snapshot = try await SessionLoader.loadSnapshot() - let loaded = snapshot.rows - let now = Date() - let current = loaded.filter { row in - if row.key == "main" { return true } - guard let updatedAt = row.updatedAt else { return false } - return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds - } - - return current.sorted { lhs, rhs in - if lhs.key == "main" { return true } - if rhs.key == "main" { return false } - return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast) - } - } - - private func findInsertIndex(in menu: NSMenu) -> Int? { - // Prefer inserting before the first separator (so the card sits right below the Active toggle). - if let idx = menu.items.firstIndex(where: { $0.title == "Send Heartbeats" }) { - // SwiftUI menus typically include a separator right after the first toggle; insert before it so the - // separator appears below the context card. - if let sepIdx = menu.items[..= 1 { return 1 } - return menu.items.count - } - - private func initialCardWidth(for menu: NSMenu) -> CGFloat { - let widthCandidates: [CGFloat] = [ - menu.minimumWidth, - self.lastKnownMenuWidth ?? 0, - self.fallbackCardWidth, - ] - let resolved = widthCandidates.max() ?? self.fallbackCardWidth - return max(300, resolved) - } - - private func captureMenuWidthIfAvailable(for menu: NSMenu, hosting: NSHostingView) { - let targetWidth: CGFloat? = { - if let contentWidth = hosting.window?.contentView?.bounds.width, contentWidth > 0 { return contentWidth } - if let superWidth = hosting.superview?.bounds.width, superWidth > 0 { return superWidth } - let minimumWidth = menu.minimumWidth - if minimumWidth > 0 { return minimumWidth } - return nil - }() - - guard let targetWidth else { return } - self.lastKnownMenuWidth = max(300, targetWidth) - } -} - -#if DEBUG -extension MenuContextCardInjector { - func _testSetCache(rows: [SessionRow], errorText: String?, updatedAt: Date?) { - self.cachedRows = rows - self.cacheErrorText = errorText - self.cacheUpdatedAt = updatedAt - } - - func _testFindInsertIndex(in menu: NSMenu) -> Int? { - self.findInsertIndex(in: menu) - } - - func _testInitialCardWidth(for menu: NSMenu) -> CGFloat { - self.initialCardWidth(for: menu) - } - - func _testCachedView() -> AnyView { - self.cachedView() - } -} -#endif diff --git a/apps/macos/Sources/OpenClaw/MenuHighlightedHostView.swift b/apps/macos/Sources/OpenClaw/MenuHighlightedHostView.swift deleted file mode 100644 index 7107946989e..00000000000 --- a/apps/macos/Sources/OpenClaw/MenuHighlightedHostView.swift +++ /dev/null @@ -1,104 +0,0 @@ -import AppKit -import SwiftUI - -final class HighlightedMenuItemHostView: NSView { - private var baseView: AnyView - private let hosting: NSHostingView - private var targetWidth: CGFloat - private var tracking: NSTrackingArea? - private var hovered = false { - didSet { self.updateHighlight() } - } - - init(rootView: AnyView, width: CGFloat) { - self.baseView = rootView - self.hosting = NSHostingView(rootView: AnyView(rootView.environment(\.menuItemHighlighted, false))) - self.targetWidth = max(1, width) - super.init(frame: .zero) - - self.addSubview(self.hosting) - self.hosting.autoresizingMask = [.width, .height] - self.updateSizing() - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override var intrinsicContentSize: NSSize { - let size = self.hosting.fittingSize - return NSSize(width: self.targetWidth, height: size.height) - } - - override func updateTrackingAreas() { - super.updateTrackingAreas() - if let tracking { - self.removeTrackingArea(tracking) - } - let options: NSTrackingArea.Options = [ - .mouseEnteredAndExited, - .activeAlways, - .inVisibleRect, - ] - let area = NSTrackingArea(rect: self.bounds, options: options, owner: self, userInfo: nil) - self.addTrackingArea(area) - self.tracking = area - } - - override func mouseEntered(with event: NSEvent) { - _ = event - self.hovered = true - } - - override func mouseExited(with event: NSEvent) { - _ = event - self.hovered = false - } - - override func layout() { - super.layout() - self.hosting.frame = self.bounds - } - - override func draw(_ dirtyRect: NSRect) { - if self.hovered { - NSColor.selectedContentBackgroundColor.setFill() - self.bounds.fill() - } - super.draw(dirtyRect) - } - - func update(rootView: AnyView, width: CGFloat) { - self.baseView = rootView - self.targetWidth = max(1, width) - self.updateHighlight() - } - - private func updateHighlight() { - self.hosting.rootView = AnyView(self.baseView.environment(\.menuItemHighlighted, self.hovered)) - self.updateSizing() - self.needsDisplay = true - } - - private func updateSizing() { - let width = max(1, self.targetWidth) - self.hosting.frame.size.width = width - let size = self.hosting.fittingSize - self.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) - self.invalidateIntrinsicContentSize() - } -} - -struct MenuHostedHighlightedItem: NSViewRepresentable { - let width: CGFloat - let rootView: AnyView - - func makeNSView(context _: Context) -> HighlightedMenuItemHostView { - HighlightedMenuItemHostView(rootView: self.rootView, width: self.width) - } - - func updateNSView(_ nsView: HighlightedMenuItemHostView, context _: Context) { - nsView.update(rootView: self.rootView, width: self.width) - } -} diff --git a/apps/macos/Sources/OpenClaw/MenuHostedItem.swift b/apps/macos/Sources/OpenClaw/MenuHostedItem.swift deleted file mode 100644 index c5a2b73cd94..00000000000 --- a/apps/macos/Sources/OpenClaw/MenuHostedItem.swift +++ /dev/null @@ -1,29 +0,0 @@ -import AppKit -import SwiftUI - -/// Hosts arbitrary SwiftUI content as an AppKit view so it can be embedded in a native `NSMenuItem.view`. -/// -/// SwiftUI `MenuBarExtraStyle.menu` aggressively simplifies many view hierarchies into a title + image. -/// Wrapping the content in an `NSViewRepresentable` forces AppKit-backed menu item rendering. -struct MenuHostedItem: NSViewRepresentable { - let width: CGFloat - let rootView: AnyView - - func makeNSView(context _: Context) -> NSHostingView { - let hosting = NSHostingView(rootView: self.rootView) - self.applySizing(to: hosting) - return hosting - } - - func updateNSView(_ nsView: NSHostingView, context _: Context) { - nsView.rootView = self.rootView - self.applySizing(to: nsView) - } - - private func applySizing(to hosting: NSHostingView) { - let width = max(1, self.width) - hosting.frame.size.width = width - let fitting = hosting.fittingSize - hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: fitting.height)) - } -} diff --git a/apps/macos/Sources/OpenClaw/MenuSessionsHeaderView.swift b/apps/macos/Sources/OpenClaw/MenuSessionsHeaderView.swift deleted file mode 100644 index e96cea53b84..00000000000 --- a/apps/macos/Sources/OpenClaw/MenuSessionsHeaderView.swift +++ /dev/null @@ -1,44 +0,0 @@ -import SwiftUI - -struct MenuSessionsHeaderView: View { - let count: Int - let statusText: String? - - private let paddingTop: CGFloat = 8 - private let paddingBottom: CGFloat = 6 - private let paddingTrailing: CGFloat = 10 - private let paddingLeading: CGFloat = 20 - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - HStack(alignment: .firstTextBaseline) { - Text("Context") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - Spacer(minLength: 10) - Text(self.subtitle) - .font(.caption) - .foregroundStyle(.secondary) - } - - if let statusText, !statusText.isEmpty { - Text(statusText) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - } - } - .padding(.top, self.paddingTop) - .padding(.bottom, self.paddingBottom) - .padding(.leading, self.paddingLeading) - .padding(.trailing, self.paddingTrailing) - .frame(minWidth: 300, maxWidth: .infinity, alignment: .leading) - .transaction { txn in txn.animation = nil } - } - - private var subtitle: String { - if self.count == 1 { return "1 session · 24h" } - return "\(self.count) sessions · 24h" - } -} diff --git a/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift b/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift deleted file mode 100644 index 37fd6ca2505..00000000000 --- a/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift +++ /dev/null @@ -1,1233 +0,0 @@ -import AppKit -import Foundation -import Observation -import SwiftUI - -@MainActor -final class MenuSessionsInjector: NSObject, NSMenuDelegate { - static let shared = MenuSessionsInjector() - - private let tag = 9_415_557 - private let nodesTag = 9_415_558 - private let fallbackWidth: CGFloat = 320 - private let activeWindowSeconds: TimeInterval = 24 * 60 * 60 - - private weak var originalDelegate: NSMenuDelegate? - private weak var statusItem: NSStatusItem? - private var loadTask: Task? - private var nodesLoadTask: Task? - private var previewTasks: [Task] = [] - private var isMenuOpen = false - private var lastKnownMenuWidth: CGFloat? - private var menuOpenWidth: CGFloat? - private var isObservingControlChannel = false - - private var cachedSnapshot: SessionStoreSnapshot? - private var cachedErrorText: String? - private var cacheUpdatedAt: Date? - private let refreshIntervalSeconds: TimeInterval = 12 - private var cachedUsageSummary: GatewayUsageSummary? - private var cachedUsageErrorText: String? - private var usageCacheUpdatedAt: Date? - private let usageRefreshIntervalSeconds: TimeInterval = 30 - private var cachedCostSummary: GatewayCostUsageSummary? - private var cachedCostErrorText: String? - private var costCacheUpdatedAt: Date? - private let costRefreshIntervalSeconds: TimeInterval = 45 - private let nodesStore = NodesStore.shared - #if DEBUG - private var testControlChannelConnected: Bool? - #endif - - func install(into statusItem: NSStatusItem) { - self.statusItem = statusItem - guard let menu = statusItem.menu else { return } - - // Preserve SwiftUI's internal NSMenuDelegate, otherwise it may stop populating menu items. - if menu.delegate !== self { - self.originalDelegate = menu.delegate - menu.delegate = self - } - - if self.loadTask == nil { - self.loadTask = Task { await self.refreshCache(force: true) } - } - - self.startControlChannelObservation() - self.nodesStore.start() - } - - func menuWillOpen(_ menu: NSMenu) { - self.originalDelegate?.menuWillOpen?(menu) - self.isMenuOpen = true - self.menuOpenWidth = self.currentMenuWidth(for: menu) - - self.inject(into: menu) - self.injectNodes(into: menu) - - // Refresh in background for the next open; keep width stable while open. - self.loadTask?.cancel() - let forceRefresh = self.cachedSnapshot == nil || self.cachedErrorText != nil - self.loadTask = Task { [weak self] in - guard let self else { return } - await self.refreshCache(force: forceRefresh) - await self.refreshUsageCache(force: forceRefresh) - await self.refreshCostUsageCache(force: forceRefresh) - await MainActor.run { - guard self.isMenuOpen else { return } - self.inject(into: menu) - self.injectNodes(into: menu) - } - } - - self.nodesLoadTask?.cancel() - self.nodesLoadTask = Task { [weak self] in - guard let self else { return } - await self.nodesStore.refresh() - await MainActor.run { - guard self.isMenuOpen else { return } - self.injectNodes(into: menu) - } - } - } - - func menuDidClose(_ menu: NSMenu) { - self.originalDelegate?.menuDidClose?(menu) - self.isMenuOpen = false - self.menuOpenWidth = nil - self.loadTask?.cancel() - self.nodesLoadTask?.cancel() - self.cancelPreviewTasks() - } - - private func startControlChannelObservation() { - guard !self.isObservingControlChannel else { return } - self.isObservingControlChannel = true - self.observeControlChannelState() - } - - private func observeControlChannelState() { - withObservationTracking { - _ = ControlChannel.shared.state - } onChange: { [weak self] in - Task { @MainActor [weak self] in - guard let self else { return } - self.handleControlChannelStateChange() - self.observeControlChannelState() - } - } - } - - private func handleControlChannelStateChange() { - guard self.isMenuOpen, let menu = self.statusItem?.menu else { return } - self.loadTask?.cancel() - self.loadTask = Task { [weak self, weak menu] in - guard let self, let menu else { return } - await self.refreshCache(force: true) - await self.refreshUsageCache(force: true) - await self.refreshCostUsageCache(force: true) - await MainActor.run { - guard self.isMenuOpen else { return } - self.inject(into: menu) - self.injectNodes(into: menu) - } - } - - self.nodesLoadTask?.cancel() - self.nodesLoadTask = Task { [weak self, weak menu] in - guard let self, let menu else { return } - await self.nodesStore.refresh() - await MainActor.run { - guard self.isMenuOpen else { return } - self.injectNodes(into: menu) - } - } - } - - func menuNeedsUpdate(_ menu: NSMenu) { - self.originalDelegate?.menuNeedsUpdate?(menu) - } - - func confinementRect(for menu: NSMenu, on screen: NSScreen?) -> NSRect { - if let rect = self.originalDelegate?.confinementRect?(for: menu, on: screen) { - return rect - } - return NSRect.zero - } -} - -extension MenuSessionsInjector { - // MARK: - Injection - - private var mainSessionKey: String { - WorkActivityStore.shared.mainSessionKey - } - - private func inject(into menu: NSMenu) { - self.cancelPreviewTasks() - // Remove any previous injected items. - for item in menu.items where item.tag == self.tag { - menu.removeItem(item) - } - - guard let insertIndex = self.findInsertIndex(in: menu) else { return } - let width = self.initialWidth(for: menu) - let isConnected = self.isControlChannelConnected - let channelState = ControlChannel.shared.state - - var cursor = insertIndex - var headerView: NSView? - - if let snapshot = self.cachedSnapshot { - let now = Date() - let mainKey = self.mainSessionKey - let rows = snapshot.rows.filter { row in - if row.key == "main", mainKey != "main" { return false } - if row.key == mainKey { return true } - guard let updatedAt = row.updatedAt else { return false } - return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds - }.sorted { lhs, rhs in - if lhs.key == mainKey { return true } - if rhs.key == mainKey { return false } - return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast) - } - if !rows.isEmpty { - let previewKeys = rows.prefix(20).map(\.key) - let task = Task { - await SessionMenuPreviewLoader.prewarm(sessionKeys: previewKeys, maxItems: 10) - } - self.previewTasks.append(task) - } - - let headerItem = NSMenuItem() - headerItem.tag = self.tag - headerItem.isEnabled = false - let statusText = self - .cachedErrorText ?? (isConnected ? nil : self.controlChannelStatusText(for: channelState)) - let hosted = self.makeHostedView( - rootView: AnyView(MenuSessionsHeaderView( - count: rows.count, - statusText: statusText)), - width: width, - highlighted: false) - headerItem.view = hosted - headerView = hosted - menu.insertItem(headerItem, at: cursor) - cursor += 1 - - if rows.isEmpty { - menu.insertItem( - self.makeMessageItem(text: "No active sessions", symbolName: "minus", width: width), - at: cursor) - cursor += 1 - } else { - for row in rows { - let item = NSMenuItem() - item.tag = self.tag - item.isEnabled = true - item.submenu = self.buildSubmenu(for: row, storePath: snapshot.storePath) - item.view = self.makeHostedView( - rootView: AnyView(SessionMenuLabelView(row: row, width: width)), - width: width, - highlighted: true) - menu.insertItem(item, at: cursor) - cursor += 1 - } - } - } else { - let headerItem = NSMenuItem() - headerItem.tag = self.tag - headerItem.isEnabled = false - let statusText = isConnected - ? (self.cachedErrorText ?? "Loading sessions…") - : self.controlChannelStatusText(for: channelState) - let hosted = self.makeHostedView( - rootView: AnyView(MenuSessionsHeaderView( - count: 0, - statusText: statusText)), - width: width, - highlighted: false) - headerItem.view = hosted - headerView = hosted - menu.insertItem(headerItem, at: cursor) - cursor += 1 - - if !isConnected { - menu.insertItem( - self.makeMessageItem( - text: "Connect the gateway to see sessions", - symbolName: "bolt.slash", - width: width), - at: cursor) - cursor += 1 - } - } - - cursor = self.insertUsageSection(into: menu, at: cursor, width: width) - cursor = self.insertCostUsageSection(into: menu, at: cursor, width: width) - - DispatchQueue.main.async { [weak self, weak headerView] in - guard let self, let headerView else { return } - self.captureMenuWidthIfAvailable(from: headerView) - } - } - - private func injectNodes(into menu: NSMenu) { - for item in menu.items where item.tag == self.nodesTag { - menu.removeItem(item) - } - - guard let insertIndex = self.findNodesInsertIndex(in: menu) else { return } - let width = self.initialWidth(for: menu) - var cursor = insertIndex - - let entries = self.sortedNodeEntries() - let topSeparator = NSMenuItem.separator() - topSeparator.tag = self.nodesTag - menu.insertItem(topSeparator, at: cursor) - cursor += 1 - - if let gatewayEntry = self.gatewayEntry() { - let gatewayItem = self.makeNodeItem(entry: gatewayEntry, width: width) - menu.insertItem(gatewayItem, at: cursor) - cursor += 1 - } - - if case .connecting = ControlChannel.shared.state { - menu.insertItem( - self.makeMessageItem(text: "Connecting…", symbolName: "circle.dashed", width: width), - at: cursor) - cursor += 1 - return - } - - guard self.isControlChannelConnected else { return } - - if let error = self.nodesStore.lastError?.nonEmpty { - menu.insertItem( - self.makeMessageItem( - text: "Error: \(error)", - symbolName: "exclamationmark.triangle", - width: width), - at: cursor) - cursor += 1 - } else if let status = self.nodesStore.statusMessage?.nonEmpty { - menu.insertItem( - self.makeMessageItem(text: status, symbolName: "info.circle", width: width), - at: cursor) - cursor += 1 - } - - if entries.isEmpty { - let title = self.nodesStore.isLoading ? "Loading devices..." : "No devices yet" - menu.insertItem( - self.makeMessageItem(text: title, symbolName: "circle.dashed", width: width), - at: cursor) - cursor += 1 - } else { - for entry in entries.prefix(8) { - let item = self.makeNodeItem(entry: entry, width: width) - menu.insertItem(item, at: cursor) - cursor += 1 - } - - if entries.count > 8 { - let moreItem = NSMenuItem() - moreItem.tag = self.nodesTag - moreItem.title = "More Devices..." - moreItem.image = NSImage(systemSymbolName: "ellipsis.circle", accessibilityDescription: nil) - let overflow = Array(entries.dropFirst(8)) - moreItem.submenu = self.buildNodesOverflowMenu(entries: overflow, width: width) - menu.insertItem(moreItem, at: cursor) - cursor += 1 - } - } - - _ = cursor - } - - private func insertUsageSection(into menu: NSMenu, at cursor: Int, width: CGFloat) -> Int { - let rows = self.usageRows - if rows.isEmpty { - return cursor - } - - var cursor = cursor - - if cursor > 0, !menu.items[cursor - 1].isSeparatorItem { - let separator = NSMenuItem.separator() - separator.tag = self.tag - menu.insertItem(separator, at: cursor) - cursor += 1 - } - - let headerItem = NSMenuItem() - headerItem.tag = self.tag - headerItem.isEnabled = false - headerItem.view = self.makeHostedView( - rootView: AnyView(MenuUsageHeaderView( - count: rows.count)), - width: width, - highlighted: false) - menu.insertItem(headerItem, at: cursor) - cursor += 1 - - if let selectedProvider = self.selectedUsageProviderId, - let primary = rows.first(where: { $0.providerId.lowercased() == selectedProvider }), - rows.count > 1 - { - let others = rows.filter { $0.providerId.lowercased() != selectedProvider } - - let item = NSMenuItem() - item.tag = self.tag - item.isEnabled = true - if !others.isEmpty { - item.submenu = self.buildUsageOverflowMenu(rows: others, width: width) - } - item.view = self.makeHostedView( - rootView: AnyView(UsageMenuLabelView(row: primary, width: width, showsChevron: !others.isEmpty)), - width: width, - highlighted: true) - menu.insertItem(item, at: cursor) - cursor += 1 - - return cursor - } - - for row in rows { - let item = NSMenuItem() - item.tag = self.tag - item.isEnabled = false - item.view = self.makeHostedView( - rootView: AnyView(UsageMenuLabelView(row: row, width: width)), - width: width, - highlighted: false) - menu.insertItem(item, at: cursor) - cursor += 1 - } - - return cursor - } - - private func insertCostUsageSection(into menu: NSMenu, at cursor: Int, width: CGFloat) -> Int { - guard self.isControlChannelConnected else { return cursor } - guard let submenu = self.buildCostUsageSubmenu(width: width) else { return cursor } - var cursor = cursor - - if cursor > 0, !menu.items[cursor - 1].isSeparatorItem { - let separator = NSMenuItem.separator() - separator.tag = self.tag - menu.insertItem(separator, at: cursor) - cursor += 1 - } - - let item = NSMenuItem(title: "Usage cost (30 days)", action: nil, keyEquivalent: "") - item.tag = self.tag - item.isEnabled = true - item.image = NSImage(systemSymbolName: "chart.bar.xaxis", accessibilityDescription: nil) - item.submenu = submenu - menu.insertItem(item, at: cursor) - cursor += 1 - return cursor - } - - private var selectedUsageProviderId: String? { - guard let model = self.cachedSnapshot?.defaults.model.nonEmpty else { return nil } - let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines) - guard let slash = trimmed.firstIndex(of: "/") else { return nil } - let provider = trimmed[.. NSMenu { - let menu = NSMenu() - for row in rows { - let item = NSMenuItem() - item.tag = self.tag - item.isEnabled = false - item.view = self.makeHostedView( - rootView: AnyView(UsageMenuLabelView(row: row, width: width)), - width: width, - highlighted: false) - menu.addItem(item) - } - return menu - } - - private var isControlChannelConnected: Bool { - #if DEBUG - if let override = self.testControlChannelConnected { return override } - #endif - if case .connected = ControlChannel.shared.state { return true } - return false - } - - private func controlChannelStatusText(for state: ControlChannel.ConnectionState) -> String { - switch state { - case .connected: - "Loading sessions…" - case .connecting: - "Connecting…" - case let .degraded(message): - message.nonEmpty ?? "Gateway disconnected" - case .disconnected: - "Gateway disconnected" - } - } - - private func buildCostUsageSubmenu(width: CGFloat) -> NSMenu? { - if let error = self.cachedCostErrorText, !error.isEmpty, self.cachedCostSummary == nil { - let menu = NSMenu() - let item = NSMenuItem(title: error, action: nil, keyEquivalent: "") - item.isEnabled = false - menu.addItem(item) - return menu - } - - guard let summary = self.cachedCostSummary else { return nil } - guard !summary.daily.isEmpty else { return nil } - - let menu = NSMenu() - menu.delegate = self - - let chartView = CostUsageHistoryMenuView(summary: summary, width: width) - let hosting = NSHostingView(rootView: AnyView(chartView)) - let controller = NSHostingController(rootView: AnyView(chartView)) - let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) - hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) - - let chartItem = NSMenuItem() - chartItem.view = hosting - chartItem.isEnabled = false - chartItem.representedObject = "costUsageChart" - menu.addItem(chartItem) - - return menu - } - - private func gatewayEntry() -> NodeInfo? { - let mode = AppStateStore.shared.connectionMode - let isConnected = self.isControlChannelConnected - let port = GatewayEnvironment.gatewayPort() - var host: String? - var platform: String? - - switch mode { - case .remote: - platform = "remote" - if AppStateStore.shared.remoteTransport == .direct { - let trimmedUrl = AppStateStore.shared.remoteUrl - .trimmingCharacters(in: .whitespacesAndNewlines) - if let url = URL(string: trimmedUrl), let urlHost = url.host, !urlHost.isEmpty { - if let port = url.port { - host = "\(urlHost):\(port)" - } else { - host = urlHost - } - } else { - host = trimmedUrl.nonEmpty - } - } else { - let target = AppStateStore.shared.remoteTarget - if let parsed = CommandResolver.parseSSHTarget(target) { - host = parsed.port == 22 ? parsed.host : "\(parsed.host):\(parsed.port)" - } else { - host = target.nonEmpty - } - } - case .local: - platform = "local" - host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)" - case .unconfigured: - platform = nil - host = nil - } - - return NodeInfo( - nodeId: "gateway", - displayName: "Gateway", - platform: platform, - version: nil, - coreVersion: nil, - uiVersion: nil, - deviceFamily: nil, - modelIdentifier: nil, - remoteIp: host, - caps: nil, - commands: nil, - permissions: nil, - paired: nil, - connected: isConnected) - } - - private func makeNodeItem(entry: NodeInfo, width: CGFloat) -> NSMenuItem { - let item = NSMenuItem() - item.tag = self.nodesTag - item.target = self - item.action = #selector(self.copyNodeSummary(_:)) - item.representedObject = NodeMenuEntryFormatter.summaryText(entry) - item.view = HighlightedMenuItemHostView( - rootView: AnyView(NodeMenuRowView(entry: entry, width: width)), - width: width) - item.submenu = self.buildNodeSubmenu(entry: entry, width: width) - return item - } - - private func makeSessionPreviewItem( - sessionKey: String, - title: String, - width: CGFloat, - maxLines: Int) -> NSMenuItem - { - let item = NSMenuItem() - item.tag = self.tag - item.isEnabled = false - let view = AnyView( - SessionMenuPreviewView( - width: width, - maxLines: maxLines, - title: title, - items: [], - status: .loading) - .environment(\.isEnabled, true)) - let hosted = HighlightedMenuItemHostView(rootView: view, width: width) - item.view = hosted - - let task = Task { [weak hosted, weak item] in - let snapshot = await SessionMenuPreviewLoader.load(sessionKey: sessionKey, maxItems: 10) - guard !Task.isCancelled else { return } - - await MainActor.run { - let nextView = AnyView( - SessionMenuPreviewView( - width: width, - maxLines: maxLines, - title: title, - items: snapshot.items, - status: snapshot.status) - .environment(\.isEnabled, true)) - - if let item { - item.view = HighlightedMenuItemHostView(rootView: nextView, width: width) - return - } - - guard let hosted else { return } - hosted.update(rootView: nextView, width: width) - } - } - self.previewTasks.append(task) - return item - } - - private func cancelPreviewTasks() { - for task in self.previewTasks { - task.cancel() - } - self.previewTasks.removeAll() - } - - private func makeMessageItem(text: String, symbolName: String, width: CGFloat, maxLines: Int? = 2) -> NSMenuItem { - let view = AnyView( - HStack(alignment: .top, spacing: 8) { - Image(systemName: symbolName) - .font(.caption) - .foregroundStyle(.secondary) - .frame(width: 14, alignment: .leading) - .padding(.top, 1) - - Text(text) - .font(.caption) - .foregroundStyle(.secondary) - .multilineTextAlignment(.leading) - .lineLimit(maxLines) - .truncationMode(.tail) - .fixedSize(horizontal: false, vertical: true) - .layoutPriority(1) - .frame(maxWidth: .infinity, alignment: .leading) - - Spacer(minLength: 0) - } - .padding(.leading, 18) - .padding(.trailing, 12) - .padding(.vertical, 6) - .frame(width: max(1, width), alignment: .leading)) - - let item = NSMenuItem() - item.tag = self.tag - item.isEnabled = false - item.view = self.makeHostedView(rootView: view, width: width, highlighted: false) - return item - } -} - -extension MenuSessionsInjector { - // MARK: - Cache - - private func refreshCache(force: Bool) async { - if !force, let updated = self.cacheUpdatedAt, Date().timeIntervalSince(updated) < self.refreshIntervalSeconds { - return - } - - guard self.isControlChannelConnected else { - if self.cachedSnapshot != nil { - self.cachedErrorText = "Gateway disconnected (showing cached)" - } else { - self.cachedErrorText = nil - } - self.cacheUpdatedAt = Date() - return - } - - do { - self.cachedSnapshot = try await SessionLoader.loadSnapshot(limit: 32) - self.cachedErrorText = nil - self.cacheUpdatedAt = Date() - } catch { - self.cachedSnapshot = nil - self.cachedErrorText = self.compactError(error) - self.cacheUpdatedAt = Date() - } - } - - private func refreshUsageCache(force: Bool) async { - if !force, - let updated = self.usageCacheUpdatedAt, - Date().timeIntervalSince(updated) < self.usageRefreshIntervalSeconds - { - return - } - - guard self.isControlChannelConnected else { - self.usageCacheUpdatedAt = Date() - return - } - - do { - self.cachedUsageSummary = try await UsageLoader.loadSummary() - } catch { - self.cachedUsageSummary = nil - self.cachedUsageErrorText = nil - } - self.usageCacheUpdatedAt = Date() - } - - private func refreshCostUsageCache(force: Bool) async { - if !force, - let updated = self.costCacheUpdatedAt, - Date().timeIntervalSince(updated) < self.costRefreshIntervalSeconds - { - return - } - - guard self.isControlChannelConnected else { - self.costCacheUpdatedAt = Date() - return - } - - do { - self.cachedCostSummary = try await CostUsageLoader.loadSummary() - self.cachedCostErrorText = nil - } catch { - self.cachedCostSummary = nil - self.cachedCostErrorText = self.compactUsageError(error) - } - self.costCacheUpdatedAt = Date() - } - - private func compactUsageError(_ error: Error) -> String { - let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) - if message.isEmpty { return "Usage unavailable" } - if message.count > 90 { return "\(message.prefix(87))…" } - return message - } - - private func compactError(_ error: Error) -> String { - if let loadError = error as? SessionLoadError { - switch loadError { - case .gatewayUnavailable: - return "No connection to gateway" - case .decodeFailed: - return "Sessions unavailable" - } - } - return "Sessions unavailable" - } -} - -extension MenuSessionsInjector { - // MARK: - Submenus - - private func buildSubmenu(for row: SessionRow, storePath: String) -> NSMenu { - let menu = NSMenu() - let width = self.submenuWidth() - - menu.addItem(self.makeSessionPreviewItem( - sessionKey: row.key, - title: "Recent messages (last 10)", - width: width, - maxLines: 3)) - - let morePreview = NSMenuItem(title: "More preview…", action: nil, keyEquivalent: "") - morePreview.submenu = self.buildPreviewSubmenu(sessionKey: row.key, width: width) - menu.addItem(morePreview) - - menu.addItem(NSMenuItem.separator()) - - let thinking = NSMenuItem(title: "Thinking", action: nil, keyEquivalent: "") - thinking.submenu = self.buildThinkingMenu(for: row) - menu.addItem(thinking) - - let verbose = NSMenuItem(title: "Verbose", action: nil, keyEquivalent: "") - verbose.submenu = self.buildVerboseMenu(for: row) - menu.addItem(verbose) - - if AppStateStore.shared.debugPaneEnabled, - AppStateStore.shared.connectionMode == .local, - let sessionId = row.sessionId, - !sessionId.isEmpty - { - menu.addItem(NSMenuItem.separator()) - let openLog = NSMenuItem( - title: "Open Session Log", - action: #selector(self.openSessionLog(_:)), - keyEquivalent: "") - openLog.target = self - openLog.representedObject = [ - "sessionId": sessionId, - "storePath": storePath, - ] - menu.addItem(openLog) - } - - menu.addItem(NSMenuItem.separator()) - - let reset = NSMenuItem(title: "Reset Session", action: #selector(self.resetSession(_:)), keyEquivalent: "") - reset.target = self - reset.representedObject = row.key - menu.addItem(reset) - - let compact = NSMenuItem( - title: "Compact Session Log", - action: #selector(self.compactSession(_:)), - keyEquivalent: "") - compact.target = self - compact.representedObject = row.key - menu.addItem(compact) - - if row.key != self.mainSessionKey, row.key != "global" { - let del = NSMenuItem(title: "Delete Session", action: #selector(self.deleteSession(_:)), keyEquivalent: "") - del.target = self - del.representedObject = row.key - del.isAlternate = false - del.keyEquivalentModifierMask = [] - menu.addItem(del) - } - - return menu - } - - private func buildThinkingMenu(for row: SessionRow) -> NSMenu { - let menu = NSMenu() - menu.autoenablesItems = false - menu.showsStateColumn = true - let levels: [String] = ["off", "minimal", "low", "medium", "high"] - let current = levels.contains(row.thinkingLevel ?? "") ? row.thinkingLevel ?? "off" : "off" - for level in levels { - let title = level.capitalized - let item = NSMenuItem(title: title, action: #selector(self.patchThinking(_:)), keyEquivalent: "") - item.target = self - item.representedObject = [ - "key": row.key, - "value": level as Any, - ] - item.state = (current == level) ? .on : .off - menu.addItem(item) - } - return menu - } - - private func buildVerboseMenu(for row: SessionRow) -> NSMenu { - let menu = NSMenu() - menu.autoenablesItems = false - menu.showsStateColumn = true - let levels: [String] = ["on", "off"] - let current = levels.contains(row.verboseLevel ?? "") ? row.verboseLevel ?? "off" : "off" - for level in levels { - let title = level.capitalized - let item = NSMenuItem(title: title, action: #selector(self.patchVerbose(_:)), keyEquivalent: "") - item.target = self - item.representedObject = [ - "key": row.key, - "value": level as Any, - ] - item.state = (current == level) ? .on : .off - menu.addItem(item) - } - return menu - } - - private func buildPreviewSubmenu(sessionKey: String, width: CGFloat) -> NSMenu { - let menu = NSMenu() - menu.addItem(self.makeSessionPreviewItem( - sessionKey: sessionKey, - title: "Recent messages (expanded)", - width: width, - maxLines: 8)) - return menu - } - - private func buildNodesOverflowMenu(entries: [NodeInfo], width: CGFloat) -> NSMenu { - let menu = NSMenu() - for entry in entries { - let item = NSMenuItem() - item.target = self - item.action = #selector(self.copyNodeSummary(_:)) - item.representedObject = NodeMenuEntryFormatter.summaryText(entry) - item.view = HighlightedMenuItemHostView( - rootView: AnyView(NodeMenuRowView(entry: entry, width: width)), - width: width) - item.submenu = self.buildNodeSubmenu(entry: entry, width: width) - menu.addItem(item) - } - return menu - } - - private func buildNodeSubmenu(entry: NodeInfo, width: CGFloat) -> NSMenu { - let menu = NSMenu() - menu.autoenablesItems = false - - menu.addItem(self.makeNodeCopyItem(label: "Node ID", value: entry.nodeId)) - - if let name = entry.displayName?.nonEmpty { - menu.addItem(self.makeNodeCopyItem(label: "Name", value: name)) - } - - if let ip = entry.remoteIp?.nonEmpty { - menu.addItem(self.makeNodeCopyItem(label: "IP", value: ip)) - } - - menu.addItem(self.makeNodeCopyItem(label: "Status", value: NodeMenuEntryFormatter.roleText(entry))) - - if let platform = NodeMenuEntryFormatter.platformText(entry) { - menu.addItem(self.makeNodeCopyItem(label: "Platform", value: platform)) - } - - if let version = NodeMenuEntryFormatter.detailRightVersion(entry)?.nonEmpty { - menu.addItem(self.makeNodeCopyItem(label: "Version", value: version)) - } - - menu.addItem(self.makeNodeDetailItem(label: "Connected", value: entry.isConnected ? "Yes" : "No")) - menu.addItem(self.makeNodeDetailItem(label: "Paired", value: entry.isPaired ? "Yes" : "No")) - - if let caps = entry.caps?.filter({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }), - !caps.isEmpty - { - menu.addItem(self.makeNodeCopyItem(label: "Caps", value: caps.joined(separator: ", "))) - } - - if let commands = entry.commands?.filter({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }), - !commands.isEmpty - { - menu.addItem(self.makeNodeMultilineItem( - label: "Commands", - value: commands.joined(separator: ", "), - width: width)) - } - - return menu - } - - private func makeNodeDetailItem(label: String, value: String) -> NSMenuItem { - let item = NSMenuItem(title: "\(label): \(value)", action: nil, keyEquivalent: "") - item.isEnabled = false - return item - } - - private func makeNodeCopyItem(label: String, value: String) -> NSMenuItem { - let item = NSMenuItem(title: "\(label): \(value)", action: #selector(self.copyNodeValue(_:)), keyEquivalent: "") - item.target = self - item.representedObject = value - return item - } - - private func makeNodeMultilineItem(label: String, value: String, width: CGFloat) -> NSMenuItem { - let item = NSMenuItem() - item.target = self - item.action = #selector(self.copyNodeValue(_:)) - item.representedObject = value - item.view = HighlightedMenuItemHostView( - rootView: AnyView(NodeMenuMultilineView(label: label, value: value, width: width)), - width: width) - return item - } - - private func formatVersionLabel(_ version: String) -> String { - let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return version } - if trimmed.hasPrefix("v") { return trimmed } - if let first = trimmed.unicodeScalars.first, CharacterSet.decimalDigits.contains(first) { - return "v\(trimmed)" - } - return trimmed - } - - @objc - private func patchThinking(_ sender: NSMenuItem) { - guard let dict = sender.representedObject as? [String: Any], - let key = dict["key"] as? String - else { return } - let value = dict["value"] as? String - Task { - do { - try await SessionActions.patchSession(key: key, thinking: .some(value)) - await self.refreshCache(force: true) - } catch { - await MainActor.run { - SessionActions.presentError(title: "Update thinking failed", error: error) - } - } - } - } - - @objc - private func patchVerbose(_ sender: NSMenuItem) { - guard let dict = sender.representedObject as? [String: Any], - let key = dict["key"] as? String - else { return } - let value = dict["value"] as? String - Task { - do { - try await SessionActions.patchSession(key: key, verbose: .some(value)) - await self.refreshCache(force: true) - } catch { - await MainActor.run { - SessionActions.presentError(title: "Update verbose failed", error: error) - } - } - } - } - - @objc - private func openSessionLog(_ sender: NSMenuItem) { - guard let dict = sender.representedObject as? [String: String], - let sessionId = dict["sessionId"], - let storePath = dict["storePath"] - else { return } - SessionActions.openSessionLogInCode(sessionId: sessionId, storePath: storePath) - } - - @objc - private func resetSession(_ sender: NSMenuItem) { - guard let key = sender.representedObject as? String else { return } - Task { @MainActor in - guard SessionActions.confirmDestructiveAction( - title: "Reset session?", - message: "Starts a new session id for “\(key)”.", - action: "Reset") - else { return } - - do { - try await SessionActions.resetSession(key: key) - await self.refreshCache(force: true) - } catch { - SessionActions.presentError(title: "Reset failed", error: error) - } - } - } - - @objc - private func compactSession(_ sender: NSMenuItem) { - guard let key = sender.representedObject as? String else { return } - Task { @MainActor in - guard SessionActions.confirmDestructiveAction( - title: "Compact session log?", - message: "Keeps the last 400 lines; archives the old file.", - action: "Compact") - else { return } - - do { - try await SessionActions.compactSession(key: key, maxLines: 400) - await self.refreshCache(force: true) - } catch { - SessionActions.presentError(title: "Compact failed", error: error) - } - } - } - - @objc - private func deleteSession(_ sender: NSMenuItem) { - guard let key = sender.representedObject as? String else { return } - Task { @MainActor in - guard SessionActions.confirmDestructiveAction( - title: "Delete session?", - message: "Deletes the “\(key)” entry and archives its transcript.", - action: "Delete") - else { return } - - do { - try await SessionActions.deleteSession(key: key) - await self.refreshCache(force: true) - } catch { - SessionActions.presentError(title: "Delete failed", error: error) - } - } - } - - @objc - private func copyNodeSummary(_ sender: NSMenuItem) { - guard let summary = sender.representedObject as? String else { return } - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(summary, forType: .string) - } - - @objc - private func copyNodeValue(_ sender: NSMenuItem) { - guard let value = sender.representedObject as? String else { return } - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(value, forType: .string) - } -} - -extension MenuSessionsInjector { - // MARK: - Width + placement - - private func findInsertIndex(in menu: NSMenu) -> Int? { - // Insert right before the separator above "Send Heartbeats". - if let idx = menu.items.firstIndex(where: { $0.title == "Send Heartbeats" }) { - if let sepIdx = menu.items[..= 1 { return 1 } - return menu.items.count - } - - private func findNodesInsertIndex(in menu: NSMenu) -> Int? { - if let idx = menu.items.firstIndex(where: { $0.title == "Send Heartbeats" }) { - if let sepIdx = menu.items[..= 1 { return 1 } - return menu.items.count - } - - private func initialWidth(for menu: NSMenu) -> CGFloat { - if let openWidth = self.menuOpenWidth { - return max(300, openWidth) - } - return self.currentMenuWidth(for: menu) - } - - private func submenuWidth() -> CGFloat { - if let openWidth = self.menuOpenWidth { - return max(300, openWidth) - } - if let cached = self.lastKnownMenuWidth { - return max(300, cached) - } - return self.fallbackWidth - } - - private func menuWindowWidth(for menu: NSMenu) -> CGFloat? { - var menuWindow: NSWindow? - for item in menu.items { - if let window = item.view?.window { - menuWindow = window - break - } - } - guard let width = menuWindow?.contentView?.bounds.width, width > 0 else { return nil } - return width - } - - private func sortedNodeEntries() -> [NodeInfo] { - let entries = self.nodesStore.nodes.filter(\.isConnected) - return entries.sorted { lhs, rhs in - if lhs.isConnected != rhs.isConnected { return lhs.isConnected } - if lhs.isPaired != rhs.isPaired { return lhs.isPaired } - let lhsName = NodeMenuEntryFormatter.primaryName(lhs).lowercased() - let rhsName = NodeMenuEntryFormatter.primaryName(rhs).lowercased() - if lhsName == rhsName { return lhs.nodeId < rhs.nodeId } - return lhsName < rhsName - } - } -} - -extension MenuSessionsInjector { - // MARK: - Views - - private func makeHostedView(rootView: AnyView, width: CGFloat, highlighted: Bool) -> NSView { - if highlighted { - return HighlightedMenuItemHostView(rootView: rootView, width: width) - } - - let hosting = NSHostingView(rootView: rootView) - hosting.frame.size.width = max(1, width) - let size = hosting.fittingSize - hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) - return hosting - } - - private func captureMenuWidthIfAvailable(from view: NSView) { - guard !self.isMenuOpen else { return } - guard let width = view.window?.contentView?.bounds.width, width > 0 else { return } - self.lastKnownMenuWidth = max(300, width) - } - - private func currentMenuWidth(for menu: NSMenu) -> CGFloat { - if let width = self.menuWindowWidth(for: menu) { - return max(300, width) - } - let candidates: [CGFloat] = [ - menu.size.width, - menu.minimumWidth, - self.lastKnownMenuWidth ?? 0, - self.fallbackWidth, - ] - let resolved = candidates.max() ?? self.fallbackWidth - return max(300, resolved) - } -} - -#if DEBUG -extension MenuSessionsInjector { - func setTestingControlChannelConnected(_ connected: Bool?) { - self.testControlChannelConnected = connected - } - - func setTestingSnapshot(_ snapshot: SessionStoreSnapshot?, errorText: String? = nil) { - self.cachedSnapshot = snapshot - self.cachedErrorText = errorText - self.cacheUpdatedAt = Date() - } - - func setTestingUsageSummary(_ summary: GatewayUsageSummary?, errorText: String? = nil) { - self.cachedUsageSummary = summary - self.cachedUsageErrorText = errorText - self.usageCacheUpdatedAt = Date() - } - - func injectForTesting(into menu: NSMenu) { - self.inject(into: menu) - } -} -#endif diff --git a/apps/macos/Sources/OpenClaw/MenuUsageHeaderView.swift b/apps/macos/Sources/OpenClaw/MenuUsageHeaderView.swift deleted file mode 100644 index dbb717d690a..00000000000 --- a/apps/macos/Sources/OpenClaw/MenuUsageHeaderView.swift +++ /dev/null @@ -1,35 +0,0 @@ -import SwiftUI - -struct MenuUsageHeaderView: View { - let count: Int - - private let paddingTop: CGFloat = 8 - private let paddingBottom: CGFloat = 6 - private let paddingTrailing: CGFloat = 10 - private let paddingLeading: CGFloat = 20 - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - HStack(alignment: .firstTextBaseline) { - Text("Usage") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - Spacer(minLength: 10) - Text(self.subtitle) - .font(.caption) - .foregroundStyle(.secondary) - } - } - .padding(.top, self.paddingTop) - .padding(.bottom, self.paddingBottom) - .padding(.leading, self.paddingLeading) - .padding(.trailing, self.paddingTrailing) - .frame(minWidth: 300, maxWidth: .infinity, alignment: .leading) - .transaction { txn in txn.animation = nil } - } - - private var subtitle: String { - if self.count == 1 { return "1 provider" } - return "\(self.count) providers" - } -} diff --git a/apps/macos/Sources/OpenClaw/MicLevelMonitor.swift b/apps/macos/Sources/OpenClaw/MicLevelMonitor.swift deleted file mode 100644 index e35057d28cf..00000000000 --- a/apps/macos/Sources/OpenClaw/MicLevelMonitor.swift +++ /dev/null @@ -1,96 +0,0 @@ -import AVFoundation -import OSLog -import SwiftUI - -actor MicLevelMonitor { - private let logger = Logger(subsystem: "ai.openclaw", category: "voicewake.meter") - private var engine: AVAudioEngine? - private var update: (@Sendable (Double) -> Void)? - private var running = false - private var smoothedLevel: Double = 0 - - func start(onLevel: @Sendable @escaping (Double) -> Void) async throws { - self.update = onLevel - if self.running { return } - self.logger.info( - "mic level monitor start (\(AudioInputDeviceObserver.defaultInputDeviceSummary(), privacy: .public))") - let engine = AVAudioEngine() - self.engine = engine - let input = engine.inputNode - let format = input.outputFormat(forBus: 0) - guard format.channelCount > 0, format.sampleRate > 0 else { - self.engine = nil - throw NSError( - domain: "MicLevelMonitor", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "No audio input available"]) - } - input.removeTap(onBus: 0) - input.installTap(onBus: 0, bufferSize: 512, format: format) { [weak self] buffer, _ in - guard let self else { return } - let level = Self.normalizedLevel(from: buffer) - Task { await self.push(level: level) } - } - engine.prepare() - try engine.start() - self.running = true - } - - func stop() { - guard self.running else { return } - if let engine { - engine.inputNode.removeTap(onBus: 0) - engine.stop() - } - self.engine = nil - self.running = false - } - - private func push(level: Double) { - self.smoothedLevel = (self.smoothedLevel * 0.45) + (level * 0.55) - guard let update else { return } - let value = self.smoothedLevel - Task { @MainActor in update(value) } - } - - private static func normalizedLevel(from buffer: AVAudioPCMBuffer) -> Double { - guard let channel = buffer.floatChannelData?[0] else { return 0 } - let frameCount = Int(buffer.frameLength) - guard frameCount > 0 else { return 0 } - var sum: Float = 0 - for i in 0.. Double(idx) - RoundedRectangle(cornerRadius: 2) - .fill(fill ? self.segmentColor(for: idx) : Color.gray.opacity(0.35)) - .frame(width: 14, height: 10) - } - } - .padding(4) - .background( - RoundedRectangle(cornerRadius: 6) - .stroke(Color.gray.opacity(0.25), lineWidth: 1)) - } - - private func segmentColor(for idx: Int) -> Color { - let fraction = Double(idx + 1) / Double(self.segments) - if fraction < 0.65 { return .green } - if fraction < 0.85 { return .yellow } - return .red - } -} diff --git a/apps/macos/Sources/OpenClaw/ModelCatalogLoader.swift b/apps/macos/Sources/OpenClaw/ModelCatalogLoader.swift deleted file mode 100644 index b320c84d232..00000000000 --- a/apps/macos/Sources/OpenClaw/ModelCatalogLoader.swift +++ /dev/null @@ -1,159 +0,0 @@ -import Foundation -import JavaScriptCore - -enum ModelCatalogLoader { - static var defaultPath: String { - self.resolveDefaultPath() - } - - private static let logger = Logger(subsystem: "ai.openclaw", category: "models") - private nonisolated static let appSupportDir: URL = { - let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first! - return base.appendingPathComponent("OpenClaw", isDirectory: true) - }() - - private static var cachePath: URL { - self.appSupportDir.appendingPathComponent("model-catalog/models.generated.js", isDirectory: false) - } - - static func load(from path: String) async throws -> [ModelChoice] { - let expanded = (path as NSString).expandingTildeInPath - guard let resolved = self.resolvePath(preferred: expanded) else { - self.logger.error("model catalog load failed: file not found") - throw NSError( - domain: "ModelCatalogLoader", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Model catalog file not found"]) - } - self.logger.debug("model catalog load start file=\(URL(fileURLWithPath: resolved.path).lastPathComponent)") - let source = try String(contentsOfFile: resolved.path, encoding: .utf8) - let sanitized = self.sanitize(source: source) - - let ctx = JSContext() - ctx?.exceptionHandler = { _, exception in - if let exception { - self.logger.warning("model catalog JS exception: \(exception)") - } - } - ctx?.evaluateScript(sanitized) - guard let rawModels = ctx?.objectForKeyedSubscript("MODELS")?.toDictionary() as? [String: Any] else { - self.logger.error("model catalog parse failed: MODELS missing") - throw NSError( - domain: "ModelCatalogLoader", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Failed to parse models.generated.ts"]) - } - - var choices: [ModelChoice] = [] - for (provider, value) in rawModels { - guard let models = value as? [String: Any] else { continue } - for (id, payload) in models { - guard let dict = payload as? [String: Any] else { continue } - let name = dict["name"] as? String ?? id - let ctxWindow = dict["contextWindow"] as? Int - choices.append(ModelChoice(id: id, name: name, provider: provider, contextWindow: ctxWindow)) - } - } - - let sorted = choices.sorted { lhs, rhs in - if lhs.provider == rhs.provider { - return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending - } - return lhs.provider.localizedCaseInsensitiveCompare(rhs.provider) == .orderedAscending - } - self.logger.debug("model catalog loaded providers=\(rawModels.count) models=\(sorted.count)") - if resolved.shouldCache { - self.cacheCatalog(sourcePath: resolved.path) - } - return sorted - } - - private static func resolveDefaultPath() -> String { - let cache = self.cachePath.path - if FileManager().isReadableFile(atPath: cache) { return cache } - if let bundlePath = self.bundleCatalogPath() { return bundlePath } - if let nodePath = self.nodeModulesCatalogPath() { return nodePath } - return cache - } - - private static func resolvePath(preferred: String) -> (path: String, shouldCache: Bool)? { - if FileManager().isReadableFile(atPath: preferred) { - return (preferred, preferred != self.cachePath.path) - } - - if let bundlePath = self.bundleCatalogPath(), bundlePath != preferred { - self.logger.warning("model catalog path missing; falling back to bundled catalog") - return (bundlePath, true) - } - - let cache = self.cachePath.path - if cache != preferred, FileManager().isReadableFile(atPath: cache) { - self.logger.warning("model catalog path missing; falling back to cached catalog") - return (cache, false) - } - - if let nodePath = self.nodeModulesCatalogPath(), nodePath != preferred { - self.logger.warning("model catalog path missing; falling back to node_modules catalog") - return (nodePath, true) - } - - return nil - } - - private static func bundleCatalogPath() -> String? { - guard let url = Bundle.main.url(forResource: "models.generated", withExtension: "js") else { - return nil - } - return url.path - } - - private static func nodeModulesCatalogPath() -> String? { - let roots = [ - URL(fileURLWithPath: CommandResolver.projectRootPath()), - URL(fileURLWithPath: FileManager().currentDirectoryPath), - ] - for root in roots { - let candidate = root - .appendingPathComponent("node_modules/@mariozechner/pi-ai/dist/models.generated.js") - if FileManager().isReadableFile(atPath: candidate.path) { - return candidate.path - } - } - return nil - } - - private static func cacheCatalog(sourcePath: String) { - let destination = self.cachePath - do { - try FileManager().createDirectory( - at: destination.deletingLastPathComponent(), - withIntermediateDirectories: true) - if FileManager().fileExists(atPath: destination.path) { - try FileManager().removeItem(at: destination) - } - try FileManager().copyItem(atPath: sourcePath, toPath: destination.path) - self.logger.debug("model catalog cached file=\(destination.lastPathComponent)") - } catch { - self.logger.warning("model catalog cache failed: \(error.localizedDescription)") - } - } - - private static func sanitize(source: String) -> String { - guard let exportRange = source.range(of: "export const MODELS"), - let firstBrace = source[exportRange.upperBound...].firstIndex(of: "{"), - let lastBrace = source.lastIndex(of: "}") - else { - return "var MODELS = {}" - } - var body = String(source[firstBrace...lastBrace]) - body = body.replacingOccurrences( - of: #"(?m)\bsatisfies\s+[^,}\n]+"#, - with: "", - options: .regularExpression) - body = body.replacingOccurrences( - of: #"(?m)\bas\s+[^;,\n]+"#, - with: "", - options: .regularExpression) - return "var MODELS = \(body);" - } -} diff --git a/apps/macos/Sources/OpenClaw/NSAttributedString+VoiceWake.swift b/apps/macos/Sources/OpenClaw/NSAttributedString+VoiceWake.swift deleted file mode 100644 index cb4be425834..00000000000 --- a/apps/macos/Sources/OpenClaw/NSAttributedString+VoiceWake.swift +++ /dev/null @@ -1,9 +0,0 @@ -import Foundation - -extension NSAttributedString { - func strippingForegroundColor() -> NSAttributedString { - let mutable = NSMutableAttributedString(attributedString: self) - mutable.removeAttribute(.foregroundColor, range: NSRange(location: 0, length: mutable.length)) - return mutable - } -} diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeLocationService.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeLocationService.swift deleted file mode 100644 index bd4df512ca4..00000000000 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeLocationService.swift +++ /dev/null @@ -1,139 +0,0 @@ -import CoreLocation -import Foundation -import OpenClawKit - -@MainActor -final class MacNodeLocationService: NSObject, CLLocationManagerDelegate { - enum Error: Swift.Error { - case timeout - case unavailable - } - - private let manager = CLLocationManager() - private var locationContinuation: CheckedContinuation? - - override init() { - super.init() - self.manager.delegate = self - self.manager.desiredAccuracy = kCLLocationAccuracyBest - } - - func authorizationStatus() -> CLAuthorizationStatus { - self.manager.authorizationStatus - } - - func accuracyAuthorization() -> CLAccuracyAuthorization { - if #available(macOS 11.0, *) { - return self.manager.accuracyAuthorization - } - return .fullAccuracy - } - - func currentLocation( - desiredAccuracy: OpenClawLocationAccuracy, - maxAgeMs: Int?, - timeoutMs: Int?) async throws -> CLLocation - { - guard CLLocationManager.locationServicesEnabled() else { - throw Error.unavailable - } - - let now = Date() - if let maxAgeMs, - let cached = self.manager.location, - now.timeIntervalSince(cached.timestamp) * 1000 <= Double(maxAgeMs) - { - return cached - } - - self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy) - let timeout = max(0, timeoutMs ?? 10000) - return try await self.withTimeout(timeoutMs: timeout) { - try await self.requestLocation() - } - } - - private func requestLocation() async throws -> CLLocation { - try await withCheckedThrowingContinuation { cont in - self.locationContinuation = cont - self.manager.requestLocation() - } - } - - private func withTimeout( - timeoutMs: Int, - operation: @escaping () async throws -> T) async throws -> T - { - if timeoutMs == 0 { - return try await operation() - } - - return try await withCheckedThrowingContinuation { continuation in - var didFinish = false - - func finish(returning value: T) { - guard !didFinish else { return } - didFinish = true - continuation.resume(returning: value) - } - - func finish(throwing error: Swift.Error) { - guard !didFinish else { return } - didFinish = true - continuation.resume(throwing: error) - } - - let timeoutItem = DispatchWorkItem { - finish(throwing: Error.timeout) - } - DispatchQueue.main.asyncAfter( - deadline: .now() + .milliseconds(timeoutMs), - execute: timeoutItem) - - Task { @MainActor in - do { - let value = try await operation() - timeoutItem.cancel() - finish(returning: value) - } catch { - timeoutItem.cancel() - finish(throwing: error) - } - } - } - } - - private static func accuracyValue(_ accuracy: OpenClawLocationAccuracy) -> CLLocationAccuracy { - switch accuracy { - case .coarse: - kCLLocationAccuracyKilometer - case .balanced: - kCLLocationAccuracyHundredMeters - case .precise: - kCLLocationAccuracyBest - } - } - - // MARK: - CLLocationManagerDelegate (nonisolated for Swift 6 compatibility) - - nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { - Task { @MainActor in - guard let cont = self.locationContinuation else { return } - self.locationContinuation = nil - if let latest = locations.last { - cont.resume(returning: latest) - } else { - cont.resume(throwing: Error.unavailable) - } - } - } - - nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Swift.Error) { - let errorCopy = error // Capture error for Sendable compliance - Task { @MainActor in - guard let cont = self.locationContinuation else { return } - self.locationContinuation = nil - cont.resume(throwing: errorCopy) - } - } -} diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift deleted file mode 100644 index af46788c9cc..00000000000 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift +++ /dev/null @@ -1,171 +0,0 @@ -import Foundation -import OpenClawKit -import OSLog - -@MainActor -final class MacNodeModeCoordinator { - static let shared = MacNodeModeCoordinator() - - private let logger = Logger(subsystem: "ai.openclaw", category: "mac-node") - private var task: Task? - private let runtime = MacNodeRuntime() - private let session = GatewayNodeSession() - - func start() { - guard self.task == nil else { return } - self.task = Task { [weak self] in - await self?.run() - } - } - - func stop() { - self.task?.cancel() - self.task = nil - Task { await self.session.disconnect() } - } - - func setPreferredGatewayStableID(_ stableID: String?) { - GatewayDiscoveryPreferences.setPreferredStableID(stableID) - Task { await self.session.disconnect() } - } - - private func run() async { - var retryDelay: UInt64 = 1_000_000_000 - var lastCameraEnabled: Bool? - let defaults = UserDefaults.standard - - while !Task.isCancelled { - if await MainActor.run(body: { AppStateStore.shared.isPaused }) { - try? await Task.sleep(nanoseconds: 1_000_000_000) - continue - } - - let cameraEnabled = defaults.object(forKey: cameraEnabledKey) as? Bool ?? false - if lastCameraEnabled == nil { - lastCameraEnabled = cameraEnabled - } else if lastCameraEnabled != cameraEnabled { - lastCameraEnabled = cameraEnabled - await self.session.disconnect() - try? await Task.sleep(nanoseconds: 200_000_000) - } - - do { - let config = try await GatewayEndpointStore.shared.requireConfig() - let caps = self.currentCaps() - let commands = self.currentCommands(caps: caps) - let permissions = await self.currentPermissions() - let connectOptions = GatewayConnectOptions( - role: "node", - scopes: [], - caps: caps, - commands: commands, - permissions: permissions, - clientId: "openclaw-macos", - clientMode: "node", - clientDisplayName: InstanceIdentity.displayName) - let sessionBox = self.buildSessionBox(url: config.url) - - try await self.session.connect( - url: config.url, - token: config.token, - password: config.password, - connectOptions: connectOptions, - sessionBox: sessionBox, - onConnected: { [weak self] in - guard let self else { return } - self.logger.info("mac node connected to gateway") - let mainSessionKey = await GatewayConnection.shared.mainSessionKey() - await self.runtime.updateMainSessionKey(mainSessionKey) - await self.runtime.setEventSender { [weak self] event, payload in - guard let self else { return } - await self.session.sendEvent(event: event, payloadJSON: payload) - } - }, - onDisconnected: { [weak self] reason in - guard let self else { return } - await self.runtime.setEventSender(nil) - self.logger.error("mac node disconnected: \(reason, privacy: .public)") - }, - onInvoke: { [weak self] req in - guard let self else { - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError(code: .unavailable, message: "UNAVAILABLE: node not ready")) - } - return await self.runtime.handleInvoke(req) - }) - - retryDelay = 1_000_000_000 - try? await Task.sleep(nanoseconds: 1_000_000_000) - } catch { - self.logger.error("mac node gateway connect failed: \(error.localizedDescription, privacy: .public)") - try? await Task.sleep(nanoseconds: min(retryDelay, 10_000_000_000)) - retryDelay = min(retryDelay * 2, 10_000_000_000) - } - } - } - - private func currentCaps() -> [String] { - var caps: [String] = [OpenClawCapability.canvas.rawValue, OpenClawCapability.screen.rawValue] - if UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false { - caps.append(OpenClawCapability.camera.rawValue) - } - let rawLocationMode = UserDefaults.standard.string(forKey: locationModeKey) ?? "off" - if OpenClawLocationMode(rawValue: rawLocationMode) != .off { - caps.append(OpenClawCapability.location.rawValue) - } - return caps - } - - private func currentPermissions() async -> [String: Bool] { - let statuses = await PermissionManager.status() - return Dictionary(uniqueKeysWithValues: statuses.map { ($0.key.rawValue, $0.value) }) - } - - private func currentCommands(caps: [String]) -> [String] { - var commands: [String] = [ - OpenClawCanvasCommand.present.rawValue, - OpenClawCanvasCommand.hide.rawValue, - OpenClawCanvasCommand.navigate.rawValue, - OpenClawCanvasCommand.evalJS.rawValue, - OpenClawCanvasCommand.snapshot.rawValue, - OpenClawCanvasA2UICommand.push.rawValue, - OpenClawCanvasA2UICommand.pushJSONL.rawValue, - OpenClawCanvasA2UICommand.reset.rawValue, - MacNodeScreenCommand.record.rawValue, - OpenClawSystemCommand.notify.rawValue, - OpenClawSystemCommand.which.rawValue, - OpenClawSystemCommand.run.rawValue, - OpenClawSystemCommand.execApprovalsGet.rawValue, - OpenClawSystemCommand.execApprovalsSet.rawValue, - ] - - let capsSet = Set(caps) - if capsSet.contains(OpenClawCapability.camera.rawValue) { - commands.append(OpenClawCameraCommand.list.rawValue) - commands.append(OpenClawCameraCommand.snap.rawValue) - commands.append(OpenClawCameraCommand.clip.rawValue) - } - if capsSet.contains(OpenClawCapability.location.rawValue) { - commands.append(OpenClawLocationCommand.get.rawValue) - } - - return commands - } - - private func buildSessionBox(url: URL) -> WebSocketSessionBox? { - guard url.scheme?.lowercased() == "wss" else { return nil } - let host = url.host ?? "gateway" - let port = url.port ?? 443 - let stableID = "\(host):\(port)" - let stored = GatewayTLSStore.loadFingerprint(stableID: stableID) - let params = GatewayTLSParams( - required: true, - expectedFingerprint: stored, - allowTOFU: stored == nil, - storeKey: stableID) - let session = GatewayTLSPinningSession(params: params) - return WebSocketSessionBox(session: session) - } -} diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift deleted file mode 100644 index cda8ca6057c..00000000000 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift +++ /dev/null @@ -1,1002 +0,0 @@ -import AppKit -import Foundation -import OpenClawIPC -import OpenClawKit - -actor MacNodeRuntime { - private let cameraCapture = CameraCaptureService() - private let makeMainActorServices: () async -> any MacNodeRuntimeMainActorServices - private var cachedMainActorServices: (any MacNodeRuntimeMainActorServices)? - private var mainSessionKey: String = "main" - private var eventSender: (@Sendable (String, String?) async -> Void)? - - init( - makeMainActorServices: @escaping () async -> any MacNodeRuntimeMainActorServices = { - await MainActor.run { LiveMacNodeRuntimeMainActorServices() } - }) - { - self.makeMainActorServices = makeMainActorServices - } - - func updateMainSessionKey(_ sessionKey: String) { - let trimmed = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - self.mainSessionKey = trimmed - } - - func setEventSender(_ sender: (@Sendable (String, String?) async -> Void)?) { - self.eventSender = sender - } - - func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse { - let command = req.command - if self.isCanvasCommand(command), !Self.canvasEnabled() { - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError( - code: .unavailable, - message: "CANVAS_DISABLED: enable Canvas in Settings")) - } - do { - switch command { - case OpenClawCanvasCommand.present.rawValue, - OpenClawCanvasCommand.hide.rawValue, - OpenClawCanvasCommand.navigate.rawValue, - OpenClawCanvasCommand.evalJS.rawValue, - OpenClawCanvasCommand.snapshot.rawValue: - return try await self.handleCanvasInvoke(req) - case OpenClawCanvasA2UICommand.reset.rawValue, - OpenClawCanvasA2UICommand.push.rawValue, - OpenClawCanvasA2UICommand.pushJSONL.rawValue: - return try await self.handleA2UIInvoke(req) - case OpenClawCameraCommand.snap.rawValue, - OpenClawCameraCommand.clip.rawValue, - OpenClawCameraCommand.list.rawValue: - return try await self.handleCameraInvoke(req) - case OpenClawLocationCommand.get.rawValue: - return try await self.handleLocationInvoke(req) - case MacNodeScreenCommand.record.rawValue: - return try await self.handleScreenRecordInvoke(req) - case OpenClawSystemCommand.run.rawValue: - return try await self.handleSystemRun(req) - case OpenClawSystemCommand.which.rawValue: - return try await self.handleSystemWhich(req) - case OpenClawSystemCommand.notify.rawValue: - return try await self.handleSystemNotify(req) - case OpenClawSystemCommand.execApprovalsGet.rawValue: - return try await self.handleSystemExecApprovalsGet(req) - case OpenClawSystemCommand.execApprovalsSet.rawValue: - return try await self.handleSystemExecApprovalsSet(req) - default: - return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: unknown command") - } - } catch { - return Self.errorResponse(req, code: .unavailable, message: error.localizedDescription) - } - } - - private func isCanvasCommand(_ command: String) -> Bool { - command.hasPrefix("canvas.") || command.hasPrefix("canvas.a2ui.") - } - - private func handleCanvasInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { - switch req.command { - case OpenClawCanvasCommand.present.rawValue: - let params = (try? Self.decodeParams(OpenClawCanvasPresentParams.self, from: req.paramsJSON)) ?? - OpenClawCanvasPresentParams() - let urlTrimmed = params.url?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let url = urlTrimmed.isEmpty ? nil : urlTrimmed - let placement = params.placement.map { - CanvasPlacement(x: $0.x, y: $0.y, width: $0.width, height: $0.height) - } - let sessionKey = self.mainSessionKey - try await MainActor.run { - _ = try CanvasManager.shared.showDetailed( - sessionKey: sessionKey, - target: url, - placement: placement) - } - return BridgeInvokeResponse(id: req.id, ok: true) - case OpenClawCanvasCommand.hide.rawValue: - let sessionKey = self.mainSessionKey - await MainActor.run { - CanvasManager.shared.hide(sessionKey: sessionKey) - } - return BridgeInvokeResponse(id: req.id, ok: true) - case OpenClawCanvasCommand.navigate.rawValue: - let params = try Self.decodeParams(OpenClawCanvasNavigateParams.self, from: req.paramsJSON) - let sessionKey = self.mainSessionKey - try await MainActor.run { - _ = try CanvasManager.shared.show(sessionKey: sessionKey, path: params.url) - } - return BridgeInvokeResponse(id: req.id, ok: true) - case OpenClawCanvasCommand.evalJS.rawValue: - let params = try Self.decodeParams(OpenClawCanvasEvalParams.self, from: req.paramsJSON) - let sessionKey = self.mainSessionKey - let result = try await CanvasManager.shared.eval( - sessionKey: sessionKey, - javaScript: params.javaScript) - let payload = try Self.encodePayload(["result": result] as [String: String]) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) - case OpenClawCanvasCommand.snapshot.rawValue: - let params = try? Self.decodeParams(OpenClawCanvasSnapshotParams.self, from: req.paramsJSON) - let format = params?.format ?? .jpeg - let maxWidth: Int? = { - if let raw = params?.maxWidth, raw > 0 { return raw } - return switch format { - case .png: 900 - case .jpeg: 1600 - } - }() - let quality = params?.quality ?? 0.9 - - let sessionKey = self.mainSessionKey - let path = try await CanvasManager.shared.snapshot(sessionKey: sessionKey, outPath: nil) - defer { try? FileManager().removeItem(atPath: path) } - let data = try Data(contentsOf: URL(fileURLWithPath: path)) - guard let image = NSImage(data: data) else { - return Self.errorResponse(req, code: .unavailable, message: "canvas snapshot decode failed") - } - let encoded = try Self.encodeCanvasSnapshot( - image: image, - format: format, - maxWidth: maxWidth, - quality: quality) - let payload = try Self.encodePayload([ - "format": format == .jpeg ? "jpeg" : "png", - "base64": encoded.base64EncodedString(), - ]) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) - default: - return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: unknown command") - } - } - - private func handleA2UIInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { - switch req.command { - case OpenClawCanvasA2UICommand.reset.rawValue: - try await self.handleA2UIReset(req) - case OpenClawCanvasA2UICommand.push.rawValue, - OpenClawCanvasA2UICommand.pushJSONL.rawValue: - try await self.handleA2UIPush(req) - default: - Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: unknown command") - } - } - - private func handleCameraInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { - guard Self.cameraEnabled() else { - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError( - code: .unavailable, - message: "CAMERA_DISABLED: enable Camera in Settings")) - } - switch req.command { - case OpenClawCameraCommand.snap.rawValue: - let params = (try? Self.decodeParams(OpenClawCameraSnapParams.self, from: req.paramsJSON)) ?? - OpenClawCameraSnapParams() - let delayMs = min(10000, max(0, params.delayMs ?? 2000)) - let res = try await self.cameraCapture.snap( - facing: CameraFacing(rawValue: params.facing?.rawValue ?? "") ?? .front, - maxWidth: params.maxWidth, - quality: params.quality, - deviceId: params.deviceId, - delayMs: delayMs) - struct SnapPayload: Encodable { - var format: String - var base64: String - var width: Int - var height: Int - } - let payload = try Self.encodePayload(SnapPayload( - format: (params.format ?? .jpg).rawValue, - base64: res.data.base64EncodedString(), - width: Int(res.size.width), - height: Int(res.size.height))) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) - case OpenClawCameraCommand.clip.rawValue: - let params = (try? Self.decodeParams(OpenClawCameraClipParams.self, from: req.paramsJSON)) ?? - OpenClawCameraClipParams() - let res = try await self.cameraCapture.clip( - facing: CameraFacing(rawValue: params.facing?.rawValue ?? "") ?? .front, - durationMs: params.durationMs, - includeAudio: params.includeAudio ?? true, - deviceId: params.deviceId, - outPath: nil) - defer { try? FileManager().removeItem(atPath: res.path) } - let data = try Data(contentsOf: URL(fileURLWithPath: res.path)) - struct ClipPayload: Encodable { - var format: String - var base64: String - var durationMs: Int - var hasAudio: Bool - } - let payload = try Self.encodePayload(ClipPayload( - format: (params.format ?? .mp4).rawValue, - base64: data.base64EncodedString(), - durationMs: res.durationMs, - hasAudio: res.hasAudio)) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) - case OpenClawCameraCommand.list.rawValue: - let devices = await self.cameraCapture.listDevices() - let payload = try Self.encodePayload(["devices": devices]) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) - default: - return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: unknown command") - } - } - - private func handleLocationInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { - let mode = Self.locationMode() - guard mode != .off else { - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError( - code: .unavailable, - message: "LOCATION_DISABLED: enable Location in Settings")) - } - let params = (try? Self.decodeParams(OpenClawLocationGetParams.self, from: req.paramsJSON)) ?? - OpenClawLocationGetParams() - let desired = params.desiredAccuracy ?? - (Self.locationPreciseEnabled() ? .precise : .balanced) - let services = await self.mainActorServices() - let status = await services.locationAuthorizationStatus() - let hasPermission = switch mode { - case .always: - status == .authorizedAlways - case .whileUsing: - status == .authorizedAlways - case .off: - false - } - if !hasPermission { - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError( - code: .unavailable, - message: "LOCATION_PERMISSION_REQUIRED: grant Location permission")) - } - do { - let location = try await services.currentLocation( - desiredAccuracy: desired, - maxAgeMs: params.maxAgeMs, - timeoutMs: params.timeoutMs) - let isPrecise = await services.locationAccuracyAuthorization() == .fullAccuracy - let payload = OpenClawLocationPayload( - lat: location.coordinate.latitude, - lon: location.coordinate.longitude, - accuracyMeters: location.horizontalAccuracy, - altitudeMeters: location.verticalAccuracy >= 0 ? location.altitude : nil, - speedMps: location.speed >= 0 ? location.speed : nil, - headingDeg: location.course >= 0 ? location.course : nil, - timestamp: ISO8601DateFormatter().string(from: location.timestamp), - isPrecise: isPrecise, - source: nil) - let json = try Self.encodePayload(payload) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) - } catch MacNodeLocationService.Error.timeout { - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError( - code: .unavailable, - message: "LOCATION_TIMEOUT: no fix in time")) - } catch { - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError( - code: .unavailable, - message: "LOCATION_UNAVAILABLE: \(error.localizedDescription)")) - } - } - - private func handleScreenRecordInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { - let params = (try? Self.decodeParams(MacNodeScreenRecordParams.self, from: req.paramsJSON)) ?? - MacNodeScreenRecordParams() - if let format = params.format?.lowercased(), !format.isEmpty, format != "mp4" { - return Self.errorResponse( - req, - code: .invalidRequest, - message: "INVALID_REQUEST: screen format must be mp4") - } - let services = await self.mainActorServices() - let res = try await services.recordScreen( - screenIndex: params.screenIndex, - durationMs: params.durationMs, - fps: params.fps, - includeAudio: params.includeAudio, - outPath: nil) - defer { try? FileManager().removeItem(atPath: res.path) } - let data = try Data(contentsOf: URL(fileURLWithPath: res.path)) - struct ScreenPayload: Encodable { - var format: String - var base64: String - var durationMs: Int? - var fps: Double? - var screenIndex: Int? - var hasAudio: Bool - } - let payload = try Self.encodePayload(ScreenPayload( - format: "mp4", - base64: data.base64EncodedString(), - durationMs: params.durationMs, - fps: params.fps, - screenIndex: params.screenIndex, - hasAudio: res.hasAudio)) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) - } - - private func mainActorServices() async -> any MacNodeRuntimeMainActorServices { - if let cachedMainActorServices { return cachedMainActorServices } - let services = await self.makeMainActorServices() - self.cachedMainActorServices = services - return services - } - - private func handleA2UIReset(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { - try await self.ensureA2UIHost() - - let sessionKey = self.mainSessionKey - let json = try await CanvasManager.shared.eval(sessionKey: sessionKey, javaScript: """ - (() => { - const host = globalThis.openclawA2UI; - if (!host) return JSON.stringify({ ok: false, error: "missing openclawA2UI" }); - return JSON.stringify(host.reset()); - })() - """) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) - } - - private func handleA2UIPush(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { - let command = req.command - let messages: [OpenClawKit.AnyCodable] - if command == OpenClawCanvasA2UICommand.pushJSONL.rawValue { - let params = try Self.decodeParams(OpenClawCanvasA2UIPushJSONLParams.self, from: req.paramsJSON) - messages = try OpenClawCanvasA2UIJSONL.decodeMessagesFromJSONL(params.jsonl) - } else { - do { - let params = try Self.decodeParams(OpenClawCanvasA2UIPushParams.self, from: req.paramsJSON) - messages = params.messages - } catch { - let params = try Self.decodeParams(OpenClawCanvasA2UIPushJSONLParams.self, from: req.paramsJSON) - messages = try OpenClawCanvasA2UIJSONL.decodeMessagesFromJSONL(params.jsonl) - } - } - - try await self.ensureA2UIHost() - - let messagesJSON = try OpenClawCanvasA2UIJSONL.encodeMessagesJSONArray(messages) - let js = """ - (() => { - try { - const host = globalThis.openclawA2UI; - if (!host) return JSON.stringify({ ok: false, error: "missing openclawA2UI" }); - const messages = \(messagesJSON); - return JSON.stringify(host.applyMessages(messages)); - } catch (e) { - return JSON.stringify({ ok: false, error: String(e?.message ?? e) }); - } - })() - """ - let sessionKey = self.mainSessionKey - let resultJSON = try await CanvasManager.shared.eval(sessionKey: sessionKey, javaScript: js) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: resultJSON) - } - - private func ensureA2UIHost() async throws { - if await self.isA2UIReady() { return } - guard let a2uiUrl = await self.resolveA2UIHostUrl() else { - throw NSError(domain: "Canvas", code: 30, userInfo: [ - NSLocalizedDescriptionKey: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host", - ]) - } - let sessionKey = self.mainSessionKey - _ = try await MainActor.run { - try CanvasManager.shared.show(sessionKey: sessionKey, path: a2uiUrl) - } - if await self.isA2UIReady(poll: true) { return } - throw NSError(domain: "Canvas", code: 31, userInfo: [ - NSLocalizedDescriptionKey: "A2UI_HOST_UNAVAILABLE: A2UI host not reachable", - ]) - } - - private func resolveA2UIHostUrl() async -> String? { - guard let raw = await GatewayConnection.shared.canvasHostUrl() else { return nil } - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, let baseUrl = URL(string: trimmed) else { return nil } - return baseUrl.appendingPathComponent("__openclaw__/a2ui/").absoluteString + "?platform=macos" - } - - private func isA2UIReady(poll: Bool = false) async -> Bool { - let deadline = poll ? Date().addingTimeInterval(6.0) : Date() - while true { - do { - let sessionKey = self.mainSessionKey - let ready = try await CanvasManager.shared.eval(sessionKey: sessionKey, javaScript: """ - (() => { - const host = globalThis.openclawA2UI; - return String(Boolean(host)); - })() - """) - let trimmed = ready.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed == "true" { return true } - } catch { - // Ignore transient eval failures while the page is loading. - } - - guard poll, Date() < deadline else { return false } - try? await Task.sleep(nanoseconds: 120_000_000) - } - } - - private func handleSystemRun(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { - let params = try Self.decodeParams(OpenClawSystemRunParams.self, from: req.paramsJSON) - let command = params.command - guard !command.isEmpty else { - return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required") - } - let sessionKey = (params.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) - ? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines) - : self.mainSessionKey - let runId = UUID().uuidString - let evaluation = await ExecApprovalEvaluator.evaluate( - command: command, - rawCommand: params.rawCommand, - cwd: params.cwd, - envOverrides: params.env, - agentId: params.agentId) - - if evaluation.security == .deny { - await self.emitExecEvent( - "exec.denied", - payload: ExecEventPayload( - sessionKey: sessionKey, - runId: runId, - host: "node", - command: evaluation.displayCommand, - reason: "security=deny")) - return Self.errorResponse( - req, - code: .unavailable, - message: "SYSTEM_RUN_DISABLED: security=deny") - } - - let approval = await self.resolveSystemRunApproval( - req: req, - params: params, - context: ExecRunContext( - displayCommand: evaluation.displayCommand, - security: evaluation.security, - ask: evaluation.ask, - agentId: evaluation.agentId, - resolution: evaluation.resolution, - allowlistMatch: evaluation.allowlistMatch, - skillAllow: evaluation.skillAllow, - sessionKey: sessionKey, - runId: runId)) - if let response = approval.response { return response } - let approvedByAsk = approval.approvedByAsk - let persistAllowlist = approval.persistAllowlist - self.persistAllowlistPatterns( - persistAllowlist: persistAllowlist, - security: evaluation.security, - agentId: evaluation.agentId, - command: command, - allowlistResolutions: evaluation.allowlistResolutions) - - if evaluation.security == .allowlist, !evaluation.allowlistSatisfied, !evaluation.skillAllow, !approvedByAsk { - await self.emitExecEvent( - "exec.denied", - payload: ExecEventPayload( - sessionKey: sessionKey, - runId: runId, - host: "node", - command: evaluation.displayCommand, - reason: "allowlist-miss")) - return Self.errorResponse( - req, - code: .unavailable, - message: "SYSTEM_RUN_DENIED: allowlist miss") - } - - self.recordAllowlistMatches( - security: evaluation.security, - allowlistSatisfied: evaluation.allowlistSatisfied, - agentId: evaluation.agentId, - allowlistMatches: evaluation.allowlistMatches, - allowlistResolutions: evaluation.allowlistResolutions, - displayCommand: evaluation.displayCommand) - - if let permissionResponse = await self.validateScreenRecordingIfNeeded( - req: req, - needsScreenRecording: params.needsScreenRecording, - sessionKey: sessionKey, - runId: runId, - displayCommand: evaluation.displayCommand) - { - return permissionResponse - } - - return try await self.executeSystemRun( - req: req, - params: params, - command: command, - env: evaluation.env, - sessionKey: sessionKey, - runId: runId, - displayCommand: evaluation.displayCommand) - } - - private func handleSystemWhich(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { - let params = try Self.decodeParams(OpenClawSystemWhichParams.self, from: req.paramsJSON) - let bins = params.bins - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - guard !bins.isEmpty else { - return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: bins required") - } - - let searchPaths = CommandResolver.preferredPaths() - var matches: [String] = [] - var paths: [String: String] = [:] - for bin in bins { - if let path = CommandResolver.findExecutable(named: bin, searchPaths: searchPaths) { - matches.append(bin) - paths[bin] = path - } - } - - struct WhichPayload: Encodable { - let bins: [String] - let paths: [String: String] - } - let payload = try Self.encodePayload(WhichPayload(bins: matches, paths: paths)) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) - } - - private struct ExecApprovalOutcome { - var approvedByAsk: Bool - var persistAllowlist: Bool - var response: BridgeInvokeResponse? - } - - private struct ExecRunContext { - var displayCommand: String - var security: ExecSecurity - var ask: ExecAsk - var agentId: String? - var resolution: ExecCommandResolution? - var allowlistMatch: ExecAllowlistEntry? - var skillAllow: Bool - var sessionKey: String - var runId: String - } - - private func resolveSystemRunApproval( - req: BridgeInvokeRequest, - params: OpenClawSystemRunParams, - context: ExecRunContext) async -> ExecApprovalOutcome - { - let requiresAsk = ExecApprovalHelpers.requiresAsk( - ask: context.ask, - security: context.security, - allowlistMatch: context.allowlistMatch, - skillAllow: context.skillAllow) - - let decisionFromParams = ExecApprovalHelpers.parseDecision(params.approvalDecision) - var approvedByAsk = params.approved == true || decisionFromParams != nil - var persistAllowlist = decisionFromParams == .allowAlways - if decisionFromParams == .deny { - await self.emitExecEvent( - "exec.denied", - payload: ExecEventPayload( - sessionKey: context.sessionKey, - runId: context.runId, - host: "node", - command: context.displayCommand, - reason: "user-denied")) - return ExecApprovalOutcome( - approvedByAsk: approvedByAsk, - persistAllowlist: persistAllowlist, - response: Self.errorResponse( - req, - code: .unavailable, - message: "SYSTEM_RUN_DENIED: user denied")) - } - - if requiresAsk, !approvedByAsk { - let decision = await MainActor.run { - ExecApprovalsPromptPresenter.prompt( - ExecApprovalPromptRequest( - command: context.displayCommand, - cwd: params.cwd, - host: "node", - security: context.security.rawValue, - ask: context.ask.rawValue, - agentId: context.agentId, - resolvedPath: context.resolution?.resolvedPath, - sessionKey: context.sessionKey)) - } - switch decision { - case .deny: - await self.emitExecEvent( - "exec.denied", - payload: ExecEventPayload( - sessionKey: context.sessionKey, - runId: context.runId, - host: "node", - command: context.displayCommand, - reason: "user-denied")) - return ExecApprovalOutcome( - approvedByAsk: approvedByAsk, - persistAllowlist: persistAllowlist, - response: Self.errorResponse( - req, - code: .unavailable, - message: "SYSTEM_RUN_DENIED: user denied")) - case .allowAlways: - approvedByAsk = true - persistAllowlist = true - case .allowOnce: - approvedByAsk = true - } - } - - return ExecApprovalOutcome( - approvedByAsk: approvedByAsk, - persistAllowlist: persistAllowlist, - response: nil) - } - - private func handleSystemExecApprovalsGet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { - _ = ExecApprovalsStore.ensureFile() - let snapshot = ExecApprovalsStore.readSnapshot() - let redacted = ExecApprovalsSnapshot( - path: snapshot.path, - exists: snapshot.exists, - hash: snapshot.hash, - file: ExecApprovalsStore.redactForSnapshot(snapshot.file)) - let payload = try Self.encodePayload(redacted) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) - } - - private func handleSystemExecApprovalsSet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { - struct SetParams: Decodable { - var file: ExecApprovalsFile - var baseHash: String? - } - - let params = try Self.decodeParams(SetParams.self, from: req.paramsJSON) - let current = ExecApprovalsStore.ensureFile() - let snapshot = ExecApprovalsStore.readSnapshot() - if snapshot.exists { - if snapshot.hash.isEmpty { - return Self.errorResponse( - req, - code: .invalidRequest, - message: "INVALID_REQUEST: exec approvals base hash unavailable; reload and retry") - } - let baseHash = params.baseHash?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if baseHash.isEmpty { - return Self.errorResponse( - req, - code: .invalidRequest, - message: "INVALID_REQUEST: exec approvals base hash required; reload and retry") - } - if baseHash != snapshot.hash { - return Self.errorResponse( - req, - code: .invalidRequest, - message: "INVALID_REQUEST: exec approvals changed; reload and retry") - } - } - - var normalized = ExecApprovalsStore.normalizeIncoming(params.file) - let socketPath = normalized.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) - let token = normalized.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) - let resolvedPath = (socketPath?.isEmpty == false) - ? socketPath! - : current.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? - ExecApprovalsStore.socketPath() - let resolvedToken = (token?.isEmpty == false) - ? token! - : current.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - normalized.socket = ExecApprovalsSocketConfig(path: resolvedPath, token: resolvedToken) - - ExecApprovalsStore.saveFile(normalized) - let nextSnapshot = ExecApprovalsStore.readSnapshot() - let redacted = ExecApprovalsSnapshot( - path: nextSnapshot.path, - exists: nextSnapshot.exists, - hash: nextSnapshot.hash, - file: ExecApprovalsStore.redactForSnapshot(nextSnapshot.file)) - let payload = try Self.encodePayload(redacted) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) - } - - private func emitExecEvent(_ event: String, payload: ExecEventPayload) async { - guard let sender = self.eventSender else { return } - guard let data = try? JSONEncoder().encode(payload), - let json = String(data: data, encoding: .utf8) - else { - return - } - await sender(event, json) - } - - private func handleSystemNotify(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { - let params = try Self.decodeParams(OpenClawSystemNotifyParams.self, from: req.paramsJSON) - let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines) - let body = params.body.trimmingCharacters(in: .whitespacesAndNewlines) - if title.isEmpty, body.isEmpty { - return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: empty notification") - } - - let priority = params.priority.flatMap { NotificationPriority(rawValue: $0.rawValue) } - let delivery = params.delivery.flatMap { NotificationDelivery(rawValue: $0.rawValue) } ?? .system - let manager = NotificationManager() - - switch delivery { - case .system: - let ok = await manager.send( - title: title, - body: body, - sound: params.sound, - priority: priority) - return ok - ? BridgeInvokeResponse(id: req.id, ok: true) - : Self.errorResponse(req, code: .unavailable, message: "NOT_AUTHORIZED: notifications") - case .overlay: - await NotifyOverlayController.shared.present(title: title, body: body) - return BridgeInvokeResponse(id: req.id, ok: true) - case .auto: - let ok = await manager.send( - title: title, - body: body, - sound: params.sound, - priority: priority) - if ok { - return BridgeInvokeResponse(id: req.id, ok: true) - } - await NotifyOverlayController.shared.present(title: title, body: body) - return BridgeInvokeResponse(id: req.id, ok: true) - } - } -} - -extension MacNodeRuntime { - private func persistAllowlistPatterns( - persistAllowlist: Bool, - security: ExecSecurity, - agentId: String?, - command: [String], - allowlistResolutions: [ExecCommandResolution]) - { - guard persistAllowlist, security == .allowlist else { return } - var seenPatterns = Set() - for candidate in allowlistResolutions { - guard let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: candidate) else { - continue - } - if seenPatterns.insert(pattern).inserted { - ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern) - } - } - } - - private func recordAllowlistMatches( - security: ExecSecurity, - allowlistSatisfied: Bool, - agentId: String?, - allowlistMatches: [ExecAllowlistEntry], - allowlistResolutions: [ExecCommandResolution], - displayCommand: String) - { - guard security == .allowlist, allowlistSatisfied else { return } - var seenPatterns = Set() - for (idx, match) in allowlistMatches.enumerated() { - if !seenPatterns.insert(match.pattern).inserted { - continue - } - let resolvedPath = idx < allowlistResolutions.count ? allowlistResolutions[idx].resolvedPath : nil - ExecApprovalsStore.recordAllowlistUse( - agentId: agentId, - pattern: match.pattern, - command: displayCommand, - resolvedPath: resolvedPath) - } - } - - private func validateScreenRecordingIfNeeded( - req: BridgeInvokeRequest, - needsScreenRecording: Bool?, - sessionKey: String, - runId: String, - displayCommand: String) async -> BridgeInvokeResponse? - { - guard needsScreenRecording == true else { return nil } - let authorized = await PermissionManager - .status([.screenRecording])[.screenRecording] ?? false - if authorized { - return nil - } - await self.emitExecEvent( - "exec.denied", - payload: ExecEventPayload( - sessionKey: sessionKey, - runId: runId, - host: "node", - command: displayCommand, - reason: "permission:screenRecording")) - return Self.errorResponse( - req, - code: .unavailable, - message: "PERMISSION_MISSING: screenRecording") - } - - private func executeSystemRun( - req: BridgeInvokeRequest, - params: OpenClawSystemRunParams, - command: [String], - env: [String: String], - sessionKey: String, - runId: String, - displayCommand: String) async throws -> BridgeInvokeResponse - { - let timeoutSec = params.timeoutMs.flatMap { Double($0) / 1000.0 } - await self.emitExecEvent( - "exec.started", - payload: ExecEventPayload( - sessionKey: sessionKey, - runId: runId, - host: "node", - command: displayCommand)) - let result = await ShellExecutor.runDetailed( - command: command, - cwd: params.cwd, - env: env, - timeout: timeoutSec) - let combined = [result.stdout, result.stderr, result.errorMessage] - .compactMap(\.self) - .filter { !$0.isEmpty } - .joined(separator: "\n") - await self.emitExecEvent( - "exec.finished", - payload: ExecEventPayload( - sessionKey: sessionKey, - runId: runId, - host: "node", - command: displayCommand, - exitCode: result.exitCode, - timedOut: result.timedOut, - success: result.success, - output: ExecEventPayload.truncateOutput(combined))) - - struct RunPayload: Encodable { - var exitCode: Int? - var timedOut: Bool - var success: Bool - var stdout: String - var stderr: String - var error: String? - } - let runPayload = RunPayload( - exitCode: result.exitCode, - timedOut: result.timedOut, - success: result.success, - stdout: result.stdout, - stderr: result.stderr, - error: result.errorMessage) - let payload = try Self.encodePayload(runPayload) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) - } - - private static func decodeParams(_ type: T.Type, from json: String?) throws -> T { - guard let json, let data = json.data(using: .utf8) else { - throw NSError(domain: "Gateway", code: 20, userInfo: [ - NSLocalizedDescriptionKey: "INVALID_REQUEST: paramsJSON required", - ]) - } - return try JSONDecoder().decode(type, from: data) - } - - private static func encodePayload(_ obj: some Encodable) throws -> String { - let data = try JSONEncoder().encode(obj) - guard let json = String(bytes: data, encoding: .utf8) else { - throw NSError(domain: "Node", code: 21, userInfo: [ - NSLocalizedDescriptionKey: "Failed to encode payload as UTF-8", - ]) - } - return json - } - - private nonisolated static func canvasEnabled() -> Bool { - UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true - } - - private nonisolated static func cameraEnabled() -> Bool { - UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false - } - - private nonisolated static func locationMode() -> OpenClawLocationMode { - let raw = UserDefaults.standard.string(forKey: locationModeKey) ?? "off" - return OpenClawLocationMode(rawValue: raw) ?? .off - } - - private nonisolated static func locationPreciseEnabled() -> Bool { - if UserDefaults.standard.object(forKey: locationPreciseKey) == nil { return true } - return UserDefaults.standard.bool(forKey: locationPreciseKey) - } - - private static func errorResponse( - _ req: BridgeInvokeRequest, - code: OpenClawNodeErrorCode, - message: String) -> BridgeInvokeResponse - { - BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError(code: code, message: message)) - } - - private static func encodeCanvasSnapshot( - image: NSImage, - format: OpenClawCanvasSnapshotFormat, - maxWidth: Int?, - quality: Double) throws -> Data - { - let source = Self.scaleImage(image, maxWidth: maxWidth) ?? image - guard let tiff = source.tiffRepresentation, - let rep = NSBitmapImageRep(data: tiff) - else { - throw NSError(domain: "Canvas", code: 22, userInfo: [ - NSLocalizedDescriptionKey: "snapshot encode failed", - ]) - } - - switch format { - case .png: - guard let data = rep.representation(using: .png, properties: [:]) else { - throw NSError(domain: "Canvas", code: 23, userInfo: [ - NSLocalizedDescriptionKey: "png encode failed", - ]) - } - return data - case .jpeg: - let clamped = min(1.0, max(0.05, quality)) - guard let data = rep.representation( - using: .jpeg, - properties: [.compressionFactor: clamped]) - else { - throw NSError(domain: "Canvas", code: 24, userInfo: [ - NSLocalizedDescriptionKey: "jpeg encode failed", - ]) - } - return data - } - } - - private static func scaleImage(_ image: NSImage, maxWidth: Int?) -> NSImage? { - guard let maxWidth, maxWidth > 0 else { return image } - let size = image.size - guard size.width > 0, size.width > CGFloat(maxWidth) else { return image } - let scale = CGFloat(maxWidth) / size.width - let target = NSSize(width: CGFloat(maxWidth), height: size.height * scale) - - let out = NSImage(size: target) - out.lockFocus() - image.draw( - in: NSRect(origin: .zero, size: target), - from: NSRect(origin: .zero, size: size), - operation: .copy, - fraction: 1.0) - out.unlockFocus() - return out - } -} diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntimeMainActorServices.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntimeMainActorServices.swift deleted file mode 100644 index 733410b1860..00000000000 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntimeMainActorServices.swift +++ /dev/null @@ -1,60 +0,0 @@ -import CoreLocation -import Foundation -import OpenClawKit - -@MainActor -protocol MacNodeRuntimeMainActorServices: Sendable { - func recordScreen( - screenIndex: Int?, - durationMs: Int?, - fps: Double?, - includeAudio: Bool?, - outPath: String?) async throws -> (path: String, hasAudio: Bool) - - func locationAuthorizationStatus() -> CLAuthorizationStatus - func locationAccuracyAuthorization() -> CLAccuracyAuthorization - func currentLocation( - desiredAccuracy: OpenClawLocationAccuracy, - maxAgeMs: Int?, - timeoutMs: Int?) async throws -> CLLocation -} - -@MainActor -final class LiveMacNodeRuntimeMainActorServices: MacNodeRuntimeMainActorServices, @unchecked Sendable { - private let screenRecorder = ScreenRecordService() - private let locationService = MacNodeLocationService() - - func recordScreen( - screenIndex: Int?, - durationMs: Int?, - fps: Double?, - includeAudio: Bool?, - outPath: String?) async throws -> (path: String, hasAudio: Bool) - { - try await self.screenRecorder.record( - screenIndex: screenIndex, - durationMs: durationMs, - fps: fps, - includeAudio: includeAudio, - outPath: outPath) - } - - func locationAuthorizationStatus() -> CLAuthorizationStatus { - self.locationService.authorizationStatus() - } - - func locationAccuracyAuthorization() -> CLAccuracyAuthorization { - self.locationService.accuracyAuthorization() - } - - func currentLocation( - desiredAccuracy: OpenClawLocationAccuracy, - maxAgeMs: Int?, - timeoutMs: Int?) async throws -> CLLocation - { - try await self.locationService.currentLocation( - desiredAccuracy: desiredAccuracy, - maxAgeMs: maxAgeMs, - timeoutMs: timeoutMs) - } -} diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeScreenCommands.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeScreenCommands.swift deleted file mode 100644 index 6f849fdf03a..00000000000 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeScreenCommands.swift +++ /dev/null @@ -1,13 +0,0 @@ -import Foundation - -enum MacNodeScreenCommand: String, Codable, Sendable { - case record = "screen.record" -} - -struct MacNodeScreenRecordParams: Codable, Sendable, Equatable { - var screenIndex: Int? - var durationMs: Int? - var fps: Double? - var format: String? - var includeAudio: Bool? -} diff --git a/apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift b/apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift deleted file mode 100644 index 10598d7f4be..00000000000 --- a/apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift +++ /dev/null @@ -1,682 +0,0 @@ -import AppKit -import Foundation -import Observation -import OpenClawDiscovery -import OpenClawIPC -import OpenClawKit -import OpenClawProtocol -import OSLog -import UserNotifications - -enum NodePairingReconcilePolicy { - static let activeIntervalMs: UInt64 = 15000 - static let resyncDelayMs: UInt64 = 250 - - static func shouldPoll(pendingCount: Int, isPresenting: Bool) -> Bool { - pendingCount > 0 || isPresenting - } -} - -@MainActor -@Observable -final class NodePairingApprovalPrompter { - static let shared = NodePairingApprovalPrompter() - - private let logger = Logger(subsystem: "ai.openclaw", category: "node-pairing") - private var task: Task? - private var reconcileTask: Task? - private var reconcileOnceTask: Task? - private var reconcileInFlight = false - private var isStopping = false - private var isPresenting = false - private var queue: [PendingRequest] = [] - var pendingCount: Int = 0 - var pendingRepairCount: Int = 0 - private var activeAlert: NSAlert? - private var activeRequestId: String? - private var alertHostWindow: NSWindow? - private var remoteResolutionsByRequestId: [String: PairingResolution] = [:] - private var autoApproveAttempts: Set = [] - - private struct PairingList: Codable { - let pending: [PendingRequest] - let paired: [PairedNode]? - } - - private struct PairedNode: Codable, Equatable { - let nodeId: String - let approvedAtMs: Double? - let displayName: String? - let platform: String? - let version: String? - let remoteIp: String? - } - - private struct PendingRequest: Codable, Equatable, Identifiable { - let requestId: String - let nodeId: String - let displayName: String? - let platform: String? - let version: String? - let remoteIp: String? - let isRepair: Bool? - let silent: Bool? - let ts: Double - - var id: String { - self.requestId - } - } - - private struct PairingResolvedEvent: Codable { - let requestId: String - let nodeId: String - let decision: String - let ts: Double - } - - private enum PairingResolution: String { - case approved - case rejected - } - - func start() { - guard self.task == nil else { return } - self.isStopping = false - self.reconcileTask?.cancel() - self.reconcileTask = nil - self.task = Task { [weak self] in - guard let self else { return } - _ = try? await GatewayConnection.shared.refresh() - await self.loadPendingRequestsFromGateway() - let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200) - for await push in stream { - if Task.isCancelled { return } - await MainActor.run { [weak self] in self?.handle(push: push) } - } - } - } - - func stop() { - self.isStopping = true - self.endActiveAlert() - self.task?.cancel() - self.task = nil - self.reconcileTask?.cancel() - self.reconcileTask = nil - self.reconcileOnceTask?.cancel() - self.reconcileOnceTask = nil - self.queue.removeAll(keepingCapacity: false) - self.updatePendingCounts() - self.isPresenting = false - self.activeRequestId = nil - self.alertHostWindow?.orderOut(nil) - self.alertHostWindow?.close() - self.alertHostWindow = nil - self.remoteResolutionsByRequestId.removeAll(keepingCapacity: false) - self.autoApproveAttempts.removeAll(keepingCapacity: false) - } - - private func loadPendingRequestsFromGateway() async { - // The gateway process may start slightly after the app. Retry a bit so - // pending pairing prompts are still shown on launch. - var delayMs: UInt64 = 200 - for attempt in 1...8 { - if Task.isCancelled { return } - do { - let data = try await GatewayConnection.shared.request( - method: "node.pair.list", - params: nil, - timeoutMs: 6000) - guard !data.isEmpty else { return } - let list = try JSONDecoder().decode(PairingList.self, from: data) - let pendingCount = list.pending.count - guard pendingCount > 0 else { return } - self.logger.info( - "loaded \(pendingCount, privacy: .public) pending node pairing request(s) on startup") - await self.apply(list: list) - return - } catch { - if attempt == 8 { - self.logger - .error( - "failed to load pending pairing requests: \(error.localizedDescription, privacy: .public)") - return - } - try? await Task.sleep(nanoseconds: delayMs * 1_000_000) - delayMs = min(delayMs * 2, 2000) - } - } - } - - private func reconcileLoop() async { - // Reconcile requests periodically so multiple running apps stay in sync - // (e.g. close dialogs + notify if another machine approves/rejects via app or CLI). - while !Task.isCancelled { - if self.isStopping { break } - if !self.shouldPoll { - self.reconcileTask = nil - return - } - await self.reconcileOnce(timeoutMs: 2500) - try? await Task.sleep( - nanoseconds: NodePairingReconcilePolicy.activeIntervalMs * 1_000_000) - } - self.reconcileTask = nil - } - - private func fetchPairingList(timeoutMs: Double) async throws -> PairingList { - let data = try await GatewayConnection.shared.request( - method: "node.pair.list", - params: nil, - timeoutMs: timeoutMs) - return try JSONDecoder().decode(PairingList.self, from: data) - } - - private func apply(list: PairingList) async { - if self.isStopping { return } - - let pendingById = Dictionary( - uniqueKeysWithValues: list.pending.map { ($0.requestId, $0) }) - - // Enqueue any missing requests (covers missed pushes while reconnecting). - for req in list.pending.sorted(by: { $0.ts < $1.ts }) { - self.enqueue(req) - } - - // Detect resolved requests (approved/rejected elsewhere). - let queued = self.queue - for req in queued { - if pendingById[req.requestId] != nil { continue } - let resolution = self.inferResolution(for: req, list: list) - - if self.activeRequestId == req.requestId, self.activeAlert != nil { - self.remoteResolutionsByRequestId[req.requestId] = resolution - self.logger.info( - """ - pairing request resolved elsewhere; closing dialog \ - requestId=\(req.requestId, privacy: .public) \ - resolution=\(resolution.rawValue, privacy: .public) - """) - self.endActiveAlert() - continue - } - - self.logger.info( - """ - pairing request resolved elsewhere requestId=\(req.requestId, privacy: .public) \ - resolution=\(resolution.rawValue, privacy: .public) - """) - self.queue.removeAll { $0 == req } - Task { @MainActor in - await self.notify(resolution: resolution, request: req, via: "remote") - } - } - - if self.queue.isEmpty { - self.isPresenting = false - } - self.presentNextIfNeeded() - self.updateReconcileLoop() - } - - private func inferResolution(for request: PendingRequest, list: PairingList) -> PairingResolution { - let paired = list.paired ?? [] - guard let node = paired.first(where: { $0.nodeId == request.nodeId }) else { - return .rejected - } - if request.isRepair == true, let approvedAtMs = node.approvedAtMs { - return approvedAtMs >= request.ts ? .approved : .rejected - } - return .approved - } - - private func endActiveAlert() { - PairingAlertSupport.endActiveAlert(activeAlert: &self.activeAlert, activeRequestId: &self.activeRequestId) - } - - private func requireAlertHostWindow() -> NSWindow { - PairingAlertSupport.requireAlertHostWindow(alertHostWindow: &self.alertHostWindow) - } - - private func handle(push: GatewayPush) { - switch push { - case let .event(evt) where evt.event == "node.pair.requested": - guard let payload = evt.payload else { return } - do { - let req = try GatewayPayloadDecoding.decode(payload, as: PendingRequest.self) - self.enqueue(req) - } catch { - self.logger - .error("failed to decode pairing request: \(error.localizedDescription, privacy: .public)") - } - case let .event(evt) where evt.event == "node.pair.resolved": - guard let payload = evt.payload else { return } - do { - let resolved = try GatewayPayloadDecoding.decode(payload, as: PairingResolvedEvent.self) - self.handleResolved(resolved) - } catch { - self.logger - .error( - "failed to decode pairing resolution: \(error.localizedDescription, privacy: .public)") - } - case .snapshot: - self.scheduleReconcileOnce(delayMs: 0) - case .seqGap: - self.scheduleReconcileOnce() - default: - return - } - } - - private func enqueue(_ req: PendingRequest) { - if self.queue.contains(req) { return } - self.queue.append(req) - self.updatePendingCounts() - self.presentNextIfNeeded() - self.updateReconcileLoop() - } - - private func presentNextIfNeeded() { - guard !self.isStopping else { return } - guard !self.isPresenting else { return } - guard let next = self.queue.first else { return } - self.isPresenting = true - Task { @MainActor [weak self] in - guard let self else { return } - if await self.trySilentApproveIfPossible(next) { - return - } - self.presentAlert(for: next) - } - } - - private func presentAlert(for req: PendingRequest) { - self.logger.info("presenting node pairing alert requestId=\(req.requestId, privacy: .public)") - NSApp.activate(ignoringOtherApps: true) - - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = "Allow node to connect?" - alert.informativeText = Self.describe(req) - // Fail-safe ordering: if the dialog can't be presented, default to "Later". - alert.addButton(withTitle: "Later") - alert.addButton(withTitle: "Approve") - alert.addButton(withTitle: "Reject") - if #available(macOS 11.0, *), alert.buttons.indices.contains(2) { - alert.buttons[2].hasDestructiveAction = true - } - - self.activeAlert = alert - self.activeRequestId = req.requestId - let hostWindow = self.requireAlertHostWindow() - - // Position the hidden host window so the sheet appears centered on screen. - // (Sheets attach to the top edge of their parent window; if the parent is tiny, it looks "anchored".) - let sheetSize = alert.window.frame.size - if let screen = hostWindow.screen ?? NSScreen.main { - let bounds = screen.visibleFrame - let x = bounds.midX - (sheetSize.width / 2) - let sheetOriginY = bounds.midY - (sheetSize.height / 2) - let hostY = sheetOriginY + sheetSize.height - hostWindow.frame.height - hostWindow.setFrameOrigin(NSPoint(x: x, y: hostY)) - } else { - hostWindow.center() - } - - hostWindow.makeKeyAndOrderFront(nil) - alert.beginSheetModal(for: hostWindow) { [weak self] response in - Task { @MainActor [weak self] in - guard let self else { return } - self.activeRequestId = nil - self.activeAlert = nil - await self.handleAlertResponse(response, request: req) - hostWindow.orderOut(nil) - } - } - } - - private func handleAlertResponse(_ response: NSApplication.ModalResponse, request: PendingRequest) async { - defer { - if self.queue.first == request { - self.queue.removeFirst() - } else { - self.queue.removeAll { $0 == request } - } - self.updatePendingCounts() - self.isPresenting = false - self.presentNextIfNeeded() - self.updateReconcileLoop() - } - - // Never approve/reject while shutting down (alerts can get dismissed during app termination). - guard !self.isStopping else { return } - - if let resolved = self.remoteResolutionsByRequestId.removeValue(forKey: request.requestId) { - await self.notify(resolution: resolved, request: request, via: "remote") - return - } - - switch response { - case .alertFirstButtonReturn: - // Later: leave as pending (CLI can approve/reject). Request will expire on the gateway TTL. - return - case .alertSecondButtonReturn: - _ = await self.approve(requestId: request.requestId) - await self.notify(resolution: .approved, request: request, via: "local") - case .alertThirdButtonReturn: - await self.reject(requestId: request.requestId) - await self.notify(resolution: .rejected, request: request, via: "local") - default: - return - } - } - - private func approve(requestId: String) async -> Bool { - do { - try await GatewayConnection.shared.nodePairApprove(requestId: requestId) - self.logger.info("approved node pairing requestId=\(requestId, privacy: .public)") - return true - } catch { - self.logger.error("approve failed requestId=\(requestId, privacy: .public)") - self.logger.error("approve failed: \(error.localizedDescription, privacy: .public)") - return false - } - } - - private func reject(requestId: String) async { - do { - try await GatewayConnection.shared.nodePairReject(requestId: requestId) - self.logger.info("rejected node pairing requestId=\(requestId, privacy: .public)") - } catch { - self.logger.error("reject failed requestId=\(requestId, privacy: .public)") - self.logger.error("reject failed: \(error.localizedDescription, privacy: .public)") - } - } - - private static func describe(_ req: PendingRequest) -> String { - let name = req.displayName?.trimmingCharacters(in: .whitespacesAndNewlines) - let platform = self.prettyPlatform(req.platform) - let version = req.version?.trimmingCharacters(in: .whitespacesAndNewlines) - let ip = self.prettyIP(req.remoteIp) - - var lines: [String] = [] - lines.append("Name: \(name?.isEmpty == false ? name! : "Unknown")") - lines.append("Node ID: \(req.nodeId)") - if let platform, !platform.isEmpty { lines.append("Platform: \(platform)") } - if let version, !version.isEmpty { lines.append("App: \(version)") } - if let ip, !ip.isEmpty { lines.append("IP: \(ip)") } - if req.isRepair == true { lines.append("Note: Repair request (token will rotate).") } - return lines.joined(separator: "\n") - } - - private static func prettyIP(_ ip: String?) -> String? { - let trimmed = ip?.trimmingCharacters(in: .whitespacesAndNewlines) - guard let trimmed, !trimmed.isEmpty else { return nil } - return trimmed.replacingOccurrences(of: "::ffff:", with: "") - } - - private static func prettyPlatform(_ platform: String?) -> String? { - let raw = platform?.trimmingCharacters(in: .whitespacesAndNewlines) - guard let raw, !raw.isEmpty else { return nil } - if raw.lowercased() == "ios" { return "iOS" } - if raw.lowercased() == "macos" { return "macOS" } - return raw - } - - private func notify(resolution: PairingResolution, request: PendingRequest, via: String) async { - let center = UNUserNotificationCenter.current() - let settings = await center.notificationSettings() - guard settings.authorizationStatus == .authorized || - settings.authorizationStatus == .provisional - else { - return - } - - let title = resolution == .approved ? "Node pairing approved" : "Node pairing rejected" - let name = request.displayName?.trimmingCharacters(in: .whitespacesAndNewlines) - let device = name?.isEmpty == false ? name! : request.nodeId - let body = "\(device)\n(via \(via))" - - _ = await NotificationManager().send( - title: title, - body: body, - sound: nil, - priority: .active) - } - - private struct SSHTarget { - let host: String - let port: Int - } - - private func trySilentApproveIfPossible(_ req: PendingRequest) async -> Bool { - guard req.silent == true else { return false } - if self.autoApproveAttempts.contains(req.requestId) { return false } - self.autoApproveAttempts.insert(req.requestId) - - guard let target = await self.resolveSSHTarget() else { - self.logger.info("silent pairing skipped (no ssh target) requestId=\(req.requestId, privacy: .public)") - return false - } - - let user = NSUserName().trimmingCharacters(in: .whitespacesAndNewlines) - guard !user.isEmpty else { - self.logger.info("silent pairing skipped (missing local user) requestId=\(req.requestId, privacy: .public)") - return false - } - - let ok = await Self.probeSSH(user: user, host: target.host, port: target.port) - if !ok { - self.logger.info("silent pairing probe failed requestId=\(req.requestId, privacy: .public)") - return false - } - - guard await self.approve(requestId: req.requestId) else { - self.logger.info("silent pairing approve failed requestId=\(req.requestId, privacy: .public)") - return false - } - - await self.notify(resolution: .approved, request: req, via: "silent-ssh") - if self.queue.first == req { - self.queue.removeFirst() - } else { - self.queue.removeAll { $0 == req } - } - - self.updatePendingCounts() - self.isPresenting = false - self.presentNextIfNeeded() - self.updateReconcileLoop() - return true - } - - private func resolveSSHTarget() async -> SSHTarget? { - let settings = CommandResolver.connectionSettings() - if !settings.target.isEmpty, let parsed = CommandResolver.parseSSHTarget(settings.target) { - let user = NSUserName().trimmingCharacters(in: .whitespacesAndNewlines) - if let targetUser = parsed.user, - !targetUser.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - targetUser != user - { - self.logger.info("silent pairing skipped (ssh user mismatch)") - return nil - } - let host = parsed.host.trimmingCharacters(in: .whitespacesAndNewlines) - guard !host.isEmpty else { return nil } - let port = parsed.port > 0 ? parsed.port : 22 - return SSHTarget(host: host, port: port) - } - - let model = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName) - model.start() - defer { model.stop() } - - let deadline = Date().addingTimeInterval(5.0) - while model.gateways.isEmpty, Date() < deadline { - try? await Task.sleep(nanoseconds: 200_000_000) - } - - let preferred = GatewayDiscoveryPreferences.preferredStableID() - let gateway = model.gateways.first { $0.stableID == preferred } ?? model.gateways.first - guard let gateway else { return nil } - guard let target = GatewayDiscoveryHelpers.sshTarget(for: gateway), - let parsed = CommandResolver.parseSSHTarget(target) - else { - return nil - } - return SSHTarget(host: parsed.host, port: parsed.port) - } - - private static func probeSSH(user: String, host: String, port: Int) async -> Bool { - await Task.detached(priority: .utility) { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") - - let options = [ - "-o", "BatchMode=yes", - "-o", "ConnectTimeout=5", - "-o", "NumberOfPasswordPrompts=0", - "-o", "PreferredAuthentications=publickey", - "-o", "StrictHostKeyChecking=accept-new", - ] - guard let target = CommandResolver.makeSSHTarget(user: user, host: host, port: port) else { - return false - } - let args = CommandResolver.sshArguments( - target: target, - identity: "", - options: options, - remoteCommand: ["/usr/bin/true"]) - process.arguments = args - let pipe = Pipe() - process.standardOutput = pipe - process.standardError = pipe - - do { - _ = try process.runAndReadToEnd(from: pipe) - } catch { - return false - } - return process.terminationStatus == 0 - }.value - } - - private var shouldPoll: Bool { - NodePairingReconcilePolicy.shouldPoll( - pendingCount: self.queue.count, - isPresenting: self.isPresenting) - } - - private func updateReconcileLoop() { - guard !self.isStopping else { return } - if self.shouldPoll { - if self.reconcileTask == nil { - self.reconcileTask = Task { [weak self] in - await self?.reconcileLoop() - } - } - } else { - self.reconcileTask?.cancel() - self.reconcileTask = nil - } - } - - private func updatePendingCounts() { - // Keep a cheap observable summary for the menu bar status line. - self.pendingCount = self.queue.count - self.pendingRepairCount = self.queue.count(where: { $0.isRepair == true }) - } - - private func reconcileOnce(timeoutMs: Double) async { - if self.isStopping { return } - if self.reconcileInFlight { return } - self.reconcileInFlight = true - defer { self.reconcileInFlight = false } - do { - let list = try await self.fetchPairingList(timeoutMs: timeoutMs) - await self.apply(list: list) - } catch { - // best effort: ignore transient connectivity failures - } - } - - private func scheduleReconcileOnce(delayMs: UInt64 = NodePairingReconcilePolicy.resyncDelayMs) { - self.reconcileOnceTask?.cancel() - self.reconcileOnceTask = Task { [weak self] in - guard let self else { return } - if delayMs > 0 { - try? await Task.sleep(nanoseconds: delayMs * 1_000_000) - } - await self.reconcileOnce(timeoutMs: 2500) - } - } - - private func handleResolved(_ resolved: PairingResolvedEvent) { - let resolution: PairingResolution = - resolved.decision == PairingResolution.approved.rawValue ? .approved : .rejected - - if self.activeRequestId == resolved.requestId, self.activeAlert != nil { - self.remoteResolutionsByRequestId[resolved.requestId] = resolution - self.logger.info( - """ - pairing request resolved elsewhere; closing dialog \ - requestId=\(resolved.requestId, privacy: .public) \ - resolution=\(resolution.rawValue, privacy: .public) - """) - self.endActiveAlert() - return - } - - guard let request = self.queue.first(where: { $0.requestId == resolved.requestId }) else { - return - } - self.queue.removeAll { $0.requestId == resolved.requestId } - self.updatePendingCounts() - Task { @MainActor in - await self.notify(resolution: resolution, request: request, via: "remote") - } - if self.queue.isEmpty { - self.isPresenting = false - } - self.presentNextIfNeeded() - self.updateReconcileLoop() - } -} - -#if DEBUG -@MainActor -extension NodePairingApprovalPrompter { - static func exerciseForTesting() async { - let prompter = NodePairingApprovalPrompter() - let pending = PendingRequest( - requestId: "req-1", - nodeId: "node-1", - displayName: "Node One", - platform: "macos", - version: "1.0.0", - remoteIp: "127.0.0.1", - isRepair: false, - silent: true, - ts: 1_700_000_000_000) - let paired = PairedNode( - nodeId: "node-1", - approvedAtMs: 1_700_000_000_000, - displayName: "Node One", - platform: "macOS", - version: "1.0.0", - remoteIp: "127.0.0.1") - let list = PairingList(pending: [pending], paired: [paired]) - - _ = Self.describe(pending) - _ = Self.prettyIP(pending.remoteIp) - _ = Self.prettyPlatform(pending.platform) - _ = prompter.inferResolution(for: pending, list: list) - - prompter.queue = [pending] - _ = prompter.shouldPoll - _ = await prompter.trySilentApproveIfPossible(pending) - prompter.queue.removeAll() - } -} -#endif diff --git a/apps/macos/Sources/OpenClaw/NodeServiceManager.swift b/apps/macos/Sources/OpenClaw/NodeServiceManager.swift deleted file mode 100644 index 38d0aa30241..00000000000 --- a/apps/macos/Sources/OpenClaw/NodeServiceManager.swift +++ /dev/null @@ -1,150 +0,0 @@ -import Foundation -import OSLog - -enum NodeServiceManager { - private static let logger = Logger(subsystem: "ai.openclaw", category: "node.service") - - static func start() async -> String? { - let result = await self.runServiceCommandResult( - ["node", "start"], - timeout: 20, - quiet: false) - if let error = self.errorMessage(from: result, treatNotLoadedAsError: true) { - self.logger.error("node service start failed: \(error, privacy: .public)") - return error - } - return nil - } - - static func stop() async -> String? { - let result = await self.runServiceCommandResult( - ["node", "stop"], - timeout: 15, - quiet: false) - if let error = self.errorMessage(from: result, treatNotLoadedAsError: false) { - self.logger.error("node service stop failed: \(error, privacy: .public)") - return error - } - return nil - } -} - -extension NodeServiceManager { - private struct CommandResult { - let success: Bool - let payload: Data? - let message: String? - let parsed: ParsedServiceJson? - } - - private struct ParsedServiceJson { - let text: String - let object: [String: Any] - let ok: Bool? - let result: String? - let message: String? - let error: String? - let hints: [String] - } - - private static func runServiceCommandResult( - _ args: [String], - timeout: Double, - quiet: Bool) async -> CommandResult - { - let command = CommandResolver.openclawCommand( - subcommand: "service", - extraArgs: self.withJsonFlag(args), - // Service management must always run locally, even if remote mode is configured. - configRoot: ["gateway": ["mode": "local"]]) - var env = ProcessInfo.processInfo.environment - env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":") - let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout) - let parsed = self.parseServiceJson(from: response.stdout) ?? self.parseServiceJson(from: response.stderr) - let ok = parsed?.ok - let message = parsed?.error ?? parsed?.message - let payload = parsed?.text.data(using: .utf8) - ?? (response.stdout.isEmpty ? response.stderr : response.stdout).data(using: .utf8) - let success = ok ?? response.success - if success { - return CommandResult(success: true, payload: payload, message: nil, parsed: parsed) - } - - if quiet { - return CommandResult(success: false, payload: payload, message: message, parsed: parsed) - } - - let detail = message ?? self.summarize(response.stderr) ?? self.summarize(response.stdout) - let exit = response.exitCode.map { "exit \($0)" } ?? (response.errorMessage ?? "failed") - let fullMessage = detail.map { "Node service command failed (\(exit)): \($0)" } - ?? "Node service command failed (\(exit))" - self.logger.error("\(fullMessage, privacy: .public)") - return CommandResult(success: false, payload: payload, message: detail, parsed: parsed) - } - - private static func errorMessage(from result: CommandResult, treatNotLoadedAsError: Bool) -> String? { - if !result.success { - return result.message ?? "Node service command failed" - } - guard let parsed = result.parsed else { return nil } - if parsed.ok == false { - return self.mergeHints(message: parsed.error ?? parsed.message, hints: parsed.hints) - } - if treatNotLoadedAsError, parsed.result == "not-loaded" { - let base = parsed.message ?? "Node service not loaded." - return self.mergeHints(message: base, hints: parsed.hints) - } - return nil - } - - private static func withJsonFlag(_ args: [String]) -> [String] { - if args.contains("--json") { return args } - return args + ["--json"] - } - - private static func parseServiceJson(from raw: String) -> ParsedServiceJson? { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard let start = trimmed.firstIndex(of: "{"), - let end = trimmed.lastIndex(of: "}") - else { - return nil - } - let jsonText = String(trimmed[start...end]) - guard let data = jsonText.data(using: .utf8) else { return nil } - guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } - let ok = object["ok"] as? Bool - let result = object["result"] as? String - let message = object["message"] as? String - let error = object["error"] as? String - let hints = (object["hints"] as? [String]) ?? [] - return ParsedServiceJson( - text: jsonText, - object: object, - ok: ok, - result: result, - message: message, - error: error, - hints: hints) - } - - private static func mergeHints(message: String?, hints: [String]) -> String? { - let trimmed = message?.trimmingCharacters(in: .whitespacesAndNewlines) - let nonEmpty = trimmed?.isEmpty == false ? trimmed : nil - guard !hints.isEmpty else { return nonEmpty } - let hintText = hints.prefix(2).joined(separator: " · ") - if let nonEmpty { - return "\(nonEmpty) (\(hintText))" - } - return hintText - } - - private static func summarize(_ text: String) -> String? { - let lines = text - .split(whereSeparator: \.isNewline) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - guard let last = lines.last else { return nil } - let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) - return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized - } -} diff --git a/apps/macos/Sources/OpenClaw/NodesMenu.swift b/apps/macos/Sources/OpenClaw/NodesMenu.swift deleted file mode 100644 index f88177d8dd0..00000000000 --- a/apps/macos/Sources/OpenClaw/NodesMenu.swift +++ /dev/null @@ -1,333 +0,0 @@ -import AppKit -import SwiftUI - -struct NodeMenuEntryFormatter { - static func isGateway(_ entry: NodeInfo) -> Bool { - entry.nodeId == "gateway" - } - - static func isConnected(_ entry: NodeInfo) -> Bool { - entry.isConnected - } - - static func primaryName(_ entry: NodeInfo) -> String { - if self.isGateway(entry) { - return entry.displayName?.nonEmpty ?? "Gateway" - } - return entry.displayName?.nonEmpty ?? entry.nodeId - } - - static func summaryText(_ entry: NodeInfo) -> String { - if self.isGateway(entry) { - let role = self.roleText(entry) - let name = self.primaryName(entry) - var parts = ["\(name) · \(role)"] - if let ip = entry.remoteIp?.nonEmpty { parts.append("host \(ip)") } - if let platform = self.platformText(entry) { parts.append(platform) } - return parts.joined(separator: " · ") - } - let name = self.primaryName(entry) - var prefix = "Node: \(name)" - if let ip = entry.remoteIp?.nonEmpty { - prefix += " (\(ip))" - } - var parts = [prefix] - if let platform = self.platformText(entry) { - parts.append("platform \(platform)") - } - let versionLabels = self.versionLabels(entry) - if !versionLabels.isEmpty { - parts.append(versionLabels.joined(separator: " · ")) - } - parts.append("status \(self.roleText(entry))") - return parts.joined(separator: " · ") - } - - static func roleText(_ entry: NodeInfo) -> String { - if entry.isConnected { return "connected" } - if self.isGateway(entry) { return "disconnected" } - if entry.isPaired { return "paired" } - return "unpaired" - } - - static func detailLeft(_ entry: NodeInfo) -> String { - let role = self.roleText(entry) - if let ip = entry.remoteIp?.nonEmpty { return "\(ip) · \(role)" } - return role - } - - static func headlineRight(_ entry: NodeInfo) -> String? { - self.platformText(entry) - } - - static func detailRightVersion(_ entry: NodeInfo) -> String? { - let labels = self.versionLabels(entry, compact: false) - if labels.isEmpty { return nil } - return labels.joined(separator: " · ") - } - - static func platformText(_ entry: NodeInfo) -> String? { - if let raw = entry.platform?.nonEmpty { - return self.prettyPlatform(raw) ?? raw - } - if let family = entry.deviceFamily?.lowercased() { - if family.contains("mac") { return "macOS" } - if family.contains("iphone") { return "iOS" } - if family.contains("ipad") { return "iPadOS" } - if family.contains("android") { return "Android" } - } - return nil - } - - private static func prettyPlatform(_ raw: String) -> String? { - let (prefix, version) = self.parsePlatform(raw) - if prefix.isEmpty { return nil } - let name: String = switch prefix { - case "macos": "macOS" - case "ios": "iOS" - case "ipados": "iPadOS" - case "tvos": "tvOS" - case "watchos": "watchOS" - default: prefix.prefix(1).uppercased() + prefix.dropFirst() - } - guard let version, !version.isEmpty else { return name } - let parts = version.split(separator: ".").map(String.init) - if parts.count >= 2 { - return "\(name) \(parts[0]).\(parts[1])" - } - return "\(name) \(version)" - } - - private static func parsePlatform(_ raw: String) -> (prefix: String, version: String?) { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { return ("", nil) } - let parts = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init) - let prefix = parts.first?.lowercased() ?? "" - let versionToken = parts.dropFirst().first - return (prefix, versionToken) - } - - private static func compactVersion(_ raw: String) -> String { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return trimmed } - if let range = trimmed.range( - of: #"\s*\([^)]*\d[^)]*\)$"#, - options: .regularExpression) - { - return String(trimmed[.. String { - let compact = self.compactVersion(raw) - if compact.isEmpty { return compact } - if compact.lowercased().hasPrefix("v") { return compact } - if let first = compact.unicodeScalars.first, CharacterSet.decimalDigits.contains(first) { - return "v\(compact)" - } - return compact - } - - private static func versionLabels(_ entry: NodeInfo, compact: Bool = true) -> [String] { - let (core, ui) = self.resolveVersions(entry) - var labels: [String] = [] - if let core { - let label = compact ? self.compactVersion(core) : self.shortVersionLabel(core) - labels.append("core \(label)") - } - if let ui { - let label = compact ? self.compactVersion(ui) : self.shortVersionLabel(ui) - labels.append("ui \(label)") - } - return labels - } - - private static func resolveVersions(_ entry: NodeInfo) -> (core: String?, ui: String?) { - let core = entry.coreVersion?.nonEmpty - let ui = entry.uiVersion?.nonEmpty - if core != nil || ui != nil { - return (core, ui) - } - guard let legacy = entry.version?.nonEmpty else { return (nil, nil) } - if self.isHeadlessPlatform(entry) { - return (legacy, nil) - } - return (nil, legacy) - } - - private static func isHeadlessPlatform(_ entry: NodeInfo) -> Bool { - let raw = entry.platform?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" - if raw == "darwin" || raw == "linux" || raw == "win32" || raw == "windows" { return true } - return false - } - - static func leadingSymbol(_ entry: NodeInfo) -> String { - if self.isGateway(entry) { - return self.safeSystemSymbol( - "antenna.radiowaves.left.and.right", - fallback: "dot.radiowaves.left.and.right") - } - if let family = entry.deviceFamily?.lowercased() { - if family.contains("mac") { - return self.safeSystemSymbol("laptopcomputer", fallback: "laptopcomputer") - } - if family.contains("iphone") { return self.safeSystemSymbol("iphone", fallback: "iphone") } - if family.contains("ipad") { return self.safeSystemSymbol("ipad", fallback: "ipad") } - } - if let platform = entry.platform?.lowercased() { - if platform.contains("mac") { return self.safeSystemSymbol("laptopcomputer", fallback: "laptopcomputer") } - if platform.contains("ios") { return self.safeSystemSymbol("iphone", fallback: "iphone") } - if platform.contains("android") { return self.safeSystemSymbol("cpu", fallback: "cpu") } - } - return "cpu" - } - - static func isAndroid(_ entry: NodeInfo) -> Bool { - let family = entry.deviceFamily?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if family == "android" { return true } - let platform = entry.platform?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - return platform?.contains("android") == true - } - - private static func safeSystemSymbol(_ preferred: String, fallback: String) -> String { - if NSImage(systemSymbolName: preferred, accessibilityDescription: nil) != nil { return preferred } - return fallback - } -} - -struct NodeMenuRowView: View { - let entry: NodeInfo - let width: CGFloat - @Environment(\.menuItemHighlighted) private var isHighlighted - - private var primaryColor: Color { - self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary - } - - private var secondaryColor: Color { - self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary - } - - var body: some View { - HStack(alignment: .center, spacing: 10) { - self.leadingIcon - .frame(width: 22, height: 22, alignment: .center) - - VStack(alignment: .leading, spacing: 2) { - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(NodeMenuEntryFormatter.primaryName(self.entry)) - .font(.callout.weight(NodeMenuEntryFormatter.isConnected(self.entry) ? .semibold : .regular)) - .foregroundStyle(self.primaryColor) - .lineLimit(1) - .truncationMode(.middle) - .layoutPriority(1) - - Spacer(minLength: 8) - - HStack(alignment: .firstTextBaseline, spacing: 6) { - if let right = NodeMenuEntryFormatter.headlineRight(self.entry) { - Text(right) - .font(.caption.monospacedDigit()) - .foregroundStyle(self.secondaryColor) - .lineLimit(1) - .truncationMode(.middle) - .layoutPriority(2) - } - - Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) - .foregroundStyle(self.secondaryColor) - .padding(.leading, 2) - } - } - - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(NodeMenuEntryFormatter.detailLeft(self.entry)) - .font(.caption) - .foregroundStyle(self.secondaryColor) - .lineLimit(1) - .truncationMode(.middle) - - Spacer(minLength: 0) - - if let version = NodeMenuEntryFormatter.detailRightVersion(self.entry) { - Text(version) - .font(.caption.monospacedDigit()) - .foregroundStyle(self.secondaryColor) - .lineLimit(1) - .truncationMode(.middle) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding(.vertical, 8) - .padding(.leading, 18) - .padding(.trailing, 12) - .frame(width: max(1, self.width), alignment: .leading) - } - - @ViewBuilder - private var leadingIcon: some View { - if NodeMenuEntryFormatter.isAndroid(self.entry) { - AndroidMark() - .foregroundStyle(self.secondaryColor) - } else { - Image(systemName: NodeMenuEntryFormatter.leadingSymbol(self.entry)) - .font(.system(size: 18, weight: .regular)) - .foregroundStyle(self.secondaryColor) - } - } -} - -struct AndroidMark: View { - var body: some View { - GeometryReader { geo in - let w = geo.size.width - let h = geo.size.height - let headHeight = h * 0.68 - let headWidth = w * 0.92 - let headX = (w - headWidth) * 0.5 - let headY = (h - headHeight) * 0.5 - let corner = min(w, h) * 0.18 - RoundedRectangle(cornerRadius: corner, style: .continuous) - .frame(width: headWidth, height: headHeight) - .position(x: headX + headWidth * 0.5, y: headY + headHeight * 0.5) - } - } -} - -struct NodeMenuMultilineView: View { - let label: String - let value: String - let width: CGFloat - @Environment(\.menuItemHighlighted) private var isHighlighted - - private var primaryColor: Color { - self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary - } - - private var secondaryColor: Color { - self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary - } - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text("\(self.label):") - .font(.caption.weight(.semibold)) - .foregroundStyle(self.secondaryColor) - - Text(self.value) - .font(.caption) - .foregroundStyle(self.primaryColor) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - } - .padding(.vertical, 6) - .padding(.leading, 18) - .padding(.trailing, 12) - .frame(width: max(1, self.width), alignment: .leading) - } -} diff --git a/apps/macos/Sources/OpenClaw/NodesStore.swift b/apps/macos/Sources/OpenClaw/NodesStore.swift deleted file mode 100644 index 5cc94858645..00000000000 --- a/apps/macos/Sources/OpenClaw/NodesStore.swift +++ /dev/null @@ -1,110 +0,0 @@ -import Foundation -import Observation -import OSLog - -struct NodeInfo: Identifiable, Codable { - let nodeId: String - let displayName: String? - let platform: String? - let version: String? - let coreVersion: String? - let uiVersion: String? - let deviceFamily: String? - let modelIdentifier: String? - let remoteIp: String? - let caps: [String]? - let commands: [String]? - let permissions: [String: Bool]? - let paired: Bool? - let connected: Bool? - - var id: String { - self.nodeId - } - - var isConnected: Bool { - self.connected ?? false - } - - var isPaired: Bool { - self.paired ?? false - } -} - -private struct NodeListResponse: Codable { - let ts: Double? - let nodes: [NodeInfo] -} - -@MainActor -@Observable -final class NodesStore { - static let shared = NodesStore() - - var nodes: [NodeInfo] = [] - var lastError: String? - var statusMessage: String? - var isLoading = false - - private let logger = Logger(subsystem: "ai.openclaw", category: "nodes") - private var task: Task? - private let interval: TimeInterval = 30 - private var startCount = 0 - - func start() { - self.startCount += 1 - guard self.startCount == 1 else { return } - guard self.task == nil else { return } - self.task = Task.detached { [weak self] in - guard let self else { return } - await self.refresh() - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000)) - await self.refresh() - } - } - } - - func stop() { - guard self.startCount > 0 else { return } - self.startCount -= 1 - guard self.startCount == 0 else { return } - self.task?.cancel() - self.task = nil - } - - func refresh() async { - if self.isLoading { return } - self.statusMessage = nil - self.isLoading = true - defer { self.isLoading = false } - do { - let data = try await GatewayConnection.shared.requestRaw(method: "node.list", params: nil, timeoutMs: 8000) - let decoded = try JSONDecoder().decode(NodeListResponse.self, from: data) - self.nodes = decoded.nodes - self.lastError = nil - self.statusMessage = nil - } catch { - if Self.isCancelled(error) { - self.logger.debug("node.list cancelled; keeping last nodes") - if self.nodes.isEmpty { - self.statusMessage = "Refreshing devices…" - } - self.lastError = nil - return - } - self.logger.error("node.list failed \(error.localizedDescription, privacy: .public)") - self.nodes = [] - self.lastError = error.localizedDescription - self.statusMessage = nil - } - } - - private static func isCancelled(_ error: Error) -> Bool { - if error is CancellationError { return true } - if let urlError = error as? URLError, urlError.code == .cancelled { return true } - let nsError = error as NSError - if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled { return true } - return false - } -} diff --git a/apps/macos/Sources/OpenClaw/NotificationManager.swift b/apps/macos/Sources/OpenClaw/NotificationManager.swift deleted file mode 100644 index b8e6fcddc8c..00000000000 --- a/apps/macos/Sources/OpenClaw/NotificationManager.swift +++ /dev/null @@ -1,66 +0,0 @@ -import Foundation -import OpenClawIPC -import Security -import UserNotifications - -@MainActor -struct NotificationManager { - private let logger = Logger(subsystem: "ai.openclaw", category: "notifications") - - private static let hasTimeSensitiveEntitlement: Bool = { - guard let task = SecTaskCreateFromSelf(nil) else { return false } - let key = "com.apple.developer.usernotifications.time-sensitive" as CFString - guard let val = SecTaskCopyValueForEntitlement(task, key, nil) else { return false } - return (val as? Bool) == true - }() - - func send(title: String, body: String, sound: String?, priority: NotificationPriority? = nil) async -> Bool { - let center = UNUserNotificationCenter.current() - let status = await center.notificationSettings() - if status.authorizationStatus == .notDetermined { - let granted = try? await center.requestAuthorization(options: [.alert, .sound, .badge]) - if granted != true { - self.logger.warning("notification permission denied (request)") - return false - } - } else if status.authorizationStatus != .authorized { - self.logger.warning("notification permission denied status=\(status.authorizationStatus.rawValue)") - return false - } - - let content = UNMutableNotificationContent() - content.title = title - content.body = body - if let soundName = sound, !soundName.isEmpty { - content.sound = UNNotificationSound(named: UNNotificationSoundName(soundName)) - } - - // Set interruption level based on priority - if let priority { - switch priority { - case .passive: - content.interruptionLevel = .passive - case .active: - content.interruptionLevel = .active - case .timeSensitive: - if Self.hasTimeSensitiveEntitlement { - content.interruptionLevel = .timeSensitive - } else { - self.logger.debug( - "time-sensitive notification requested without entitlement; falling back to active") - content.interruptionLevel = .active - } - } - } - - let req = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) - do { - try await center.add(req) - self.logger.debug("notification queued") - return true - } catch { - self.logger.error("notification send failed: \(error.localizedDescription)") - return false - } - } -} diff --git a/apps/macos/Sources/OpenClaw/NotifyOverlay.swift b/apps/macos/Sources/OpenClaw/NotifyOverlay.swift deleted file mode 100644 index 31157b0d831..00000000000 --- a/apps/macos/Sources/OpenClaw/NotifyOverlay.swift +++ /dev/null @@ -1,192 +0,0 @@ -import AppKit -import Observation -import QuartzCore -import SwiftUI - -/// Lightweight, borderless panel for in-app "toast" notifications (bypasses macOS Notification Center). -@MainActor -@Observable -final class NotifyOverlayController { - static let shared = NotifyOverlayController() - - private(set) var model = Model() - var isVisible: Bool { - self.model.isVisible - } - - struct Model { - var title: String = "" - var body: String = "" - var isVisible: Bool = false - } - - private var window: NSPanel? - private var hostingView: NSHostingView? - private var dismissTask: Task? - - private let width: CGFloat = 360 - private let padding: CGFloat = 12 - private let maxHeight: CGFloat = 220 - private let minHeight: CGFloat = 64 - - func present(title: String, body: String, autoDismissAfter: TimeInterval = 6) { - self.dismissTask?.cancel() - self.model.title = title - self.model.body = body - self.ensureWindow() - self.hostingView?.rootView = NotifyOverlayView(controller: self) - self.presentWindow() - - if autoDismissAfter > 0 { - self.dismissTask = Task { [weak self] in - try? await Task.sleep(nanoseconds: UInt64(autoDismissAfter * 1_000_000_000)) - await MainActor.run { self?.dismiss() } - } - } - } - - func dismiss() { - self.dismissTask?.cancel() - self.dismissTask = nil - guard let window else { return } - - let target = window.frame.offsetBy(dx: 8, dy: 6) - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.16 - context.timingFunction = CAMediaTimingFunction(name: .easeOut) - window.animator().setFrame(target, display: true) - window.animator().alphaValue = 0 - } completionHandler: { - Task { @MainActor in - window.orderOut(nil) - self.model.isVisible = false - } - } - } - - // MARK: - Private - - private func presentWindow() { - self.ensureWindow() - self.hostingView?.rootView = NotifyOverlayView(controller: self) - let target = self.targetFrame() - - guard let window else { return } - if !self.model.isVisible { - self.model.isVisible = true - let start = target.offsetBy(dx: 0, dy: -6) - window.setFrame(start, display: true) - window.alphaValue = 0 - window.orderFrontRegardless() - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.18 - context.timingFunction = CAMediaTimingFunction(name: .easeOut) - window.animator().setFrame(target, display: true) - window.animator().alphaValue = 1 - } - } else { - self.updateWindowFrame(animate: true) - window.orderFrontRegardless() - } - } - - private func ensureWindow() { - if self.window != nil { return } - let panel = NSPanel( - contentRect: NSRect(x: 0, y: 0, width: self.width, height: self.minHeight), - styleMask: [.nonactivatingPanel, .borderless], - backing: .buffered, - defer: false) - panel.isOpaque = false - panel.backgroundColor = .clear - panel.hasShadow = true - panel.level = .statusBar - panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient] - panel.hidesOnDeactivate = false - panel.isMovable = false - panel.isFloatingPanel = true - panel.becomesKeyOnlyIfNeeded = true - panel.titleVisibility = .hidden - panel.titlebarAppearsTransparent = true - - let host = NSHostingView(rootView: NotifyOverlayView(controller: self)) - host.translatesAutoresizingMaskIntoConstraints = false - panel.contentView = host - self.hostingView = host - self.window = panel - } - - private func targetFrame() -> NSRect { - guard let screen = NSScreen.main else { return .zero } - let height = self.measuredHeight() - let size = NSSize(width: self.width, height: height) - let visible = screen.visibleFrame - let origin = CGPoint(x: visible.maxX - size.width - 8, y: visible.maxY - size.height - 8) - return NSRect(origin: origin, size: size) - } - - private func updateWindowFrame(animate: Bool = false) { - guard let window else { return } - let frame = self.targetFrame() - if animate { - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.12 - context.timingFunction = CAMediaTimingFunction(name: .easeOut) - window.animator().setFrame(frame, display: true) - } - } else { - window.setFrame(frame, display: true) - } - } - - private func measuredHeight() -> CGFloat { - let maxWidth = self.width - self.padding * 2 - let titleFont = NSFont.systemFont(ofSize: 13, weight: .semibold) - let bodyFont = NSFont.systemFont(ofSize: 12, weight: .regular) - - let titleRect = (self.model.title as NSString).boundingRect( - with: CGSize(width: maxWidth, height: .greatestFiniteMagnitude), - options: [.usesLineFragmentOrigin, .usesFontLeading], - attributes: [.font: titleFont], - context: nil) - - let bodyRect = (self.model.body as NSString).boundingRect( - with: CGSize(width: maxWidth, height: .greatestFiniteMagnitude), - options: [.usesLineFragmentOrigin, .usesFontLeading], - attributes: [.font: bodyFont], - context: nil) - - let contentHeight = ceil(titleRect.height + 6 + bodyRect.height) - let total = contentHeight + self.padding * 2 - return max(self.minHeight, min(total, self.maxHeight)) - } -} - -private struct NotifyOverlayView: View { - var controller: NotifyOverlayController - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - Text(self.controller.model.title) - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(.primary) - .lineLimit(1) - - Text(self.controller.model.body) - .font(.system(size: 12)) - .foregroundStyle(.secondary) - .lineLimit(4) - .fixedSize(horizontal: false, vertical: true) - } - .padding(12) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(.regularMaterial)) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder(Color.black.opacity(0.08), lineWidth: 1)) - .onTapGesture { - self.controller.dismiss() - } - } -} diff --git a/apps/macos/Sources/OpenClaw/Onboarding.swift b/apps/macos/Sources/OpenClaw/Onboarding.swift deleted file mode 100644 index b8a6377b419..00000000000 --- a/apps/macos/Sources/OpenClaw/Onboarding.swift +++ /dev/null @@ -1,196 +0,0 @@ -import AppKit -import Combine -import Observation -import OpenClawChatUI -import OpenClawDiscovery -import OpenClawIPC -import SwiftUI - -enum UIStrings { - static let welcomeTitle = "Welcome to OpenClaw" -} - -@MainActor -final class OnboardingController { - static let shared = OnboardingController() - private var window: NSWindow? - - func show() { - if ProcessInfo.processInfo.isNixMode { - // Nix mode is fully declarative; onboarding would suggest interactive setup that doesn't apply. - UserDefaults.standard.set(true, forKey: "openclaw.onboardingSeen") - UserDefaults.standard.set(currentOnboardingVersion, forKey: onboardingVersionKey) - AppStateStore.shared.onboardingSeen = true - return - } - if let window { - DockIconManager.shared.temporarilyShowDock() - window.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - return - } - let hosting = NSHostingController(rootView: OnboardingView()) - let window = NSWindow(contentViewController: hosting) - window.title = UIStrings.welcomeTitle - window.setContentSize(NSSize(width: OnboardingView.windowWidth, height: OnboardingView.windowHeight)) - window.styleMask = [.titled, .closable, .fullSizeContentView] - window.titlebarAppearsTransparent = true - window.titleVisibility = .hidden - window.isMovableByWindowBackground = true - window.center() - DockIconManager.shared.temporarilyShowDock() - window.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - self.window = window - } - - func close() { - self.window?.close() - self.window = nil - } - - func restart() { - self.close() - self.show() - } -} - -struct OnboardingView: View { - @Environment(\.openSettings) var openSettings - @State var currentPage = 0 - @State var isRequesting = false - @State var installingCLI = false - @State var cliStatus: String? - @State var copied = false - @State var monitoringPermissions = false - @State var monitoringDiscovery = false - @State var cliInstalled = false - @State var cliInstallLocation: String? - @State var workspacePath: String = "" - @State var workspaceStatus: String? - @State var workspaceApplying = false - @State var anthropicAuthPKCE: AnthropicOAuth.PKCE? - @State var anthropicAuthCode: String = "" - @State var anthropicAuthStatus: String? - @State var anthropicAuthBusy = false - @State var anthropicAuthConnected = false - @State var anthropicAuthVerifying = false - @State var anthropicAuthVerified = false - @State var anthropicAuthVerificationAttempted = false - @State var anthropicAuthVerificationFailed = false - @State var anthropicAuthVerifiedAt: Date? - @State var anthropicAuthDetectedStatus: OpenClawOAuthStore.AnthropicOAuthStatus = .missingFile - @State var anthropicAuthAutoDetectClipboard = true - @State var anthropicAuthAutoConnectClipboard = true - @State var anthropicAuthLastPasteboardChangeCount = NSPasteboard.general.changeCount - @State var monitoringAuth = false - @State var authMonitorTask: Task? - @State var needsBootstrap = false - @State var didAutoKickoff = false - @State var showAdvancedConnection = false - @State var preferredGatewayID: String? - @State var gatewayDiscovery: GatewayDiscoveryModel - @State var onboardingChatModel: OpenClawChatViewModel - @State var onboardingSkillsModel = SkillsSettingsModel() - @State var onboardingWizard = OnboardingWizardModel() - @State var didLoadOnboardingSkills = false - @State var localGatewayProbe: LocalGatewayProbe? - @Bindable var state: AppState - var permissionMonitor: PermissionMonitor - - static let windowWidth: CGFloat = 630 - static let windowHeight: CGFloat = 752 // ~+10% to fit full onboarding content - - let pageWidth: CGFloat = Self.windowWidth - let contentHeight: CGFloat = 460 - let connectionPageIndex = 1 - let anthropicAuthPageIndex = 2 - let wizardPageIndex = 3 - let onboardingChatPageIndex = 8 - - static let clipboardPoll: AnyPublisher = { - if ProcessInfo.processInfo.isRunningTests { - return Empty(completeImmediately: false).eraseToAnyPublisher() - } - return Timer.publish(every: 0.4, on: .main, in: .common) - .autoconnect() - .eraseToAnyPublisher() - }() - - let permissionsPageIndex = 5 - static func pageOrder( - for mode: AppState.ConnectionMode, - showOnboardingChat: Bool) -> [Int] - { - switch mode { - case .remote: - // Remote setup doesn't need local gateway/CLI/workspace setup pages, - // and WhatsApp/Telegram setup is optional. - showOnboardingChat ? [0, 1, 5, 8, 9] : [0, 1, 5, 9] - case .unconfigured: - showOnboardingChat ? [0, 1, 8, 9] : [0, 1, 9] - case .local: - showOnboardingChat ? [0, 1, 3, 5, 8, 9] : [0, 1, 3, 5, 9] - } - } - - var showOnboardingChat: Bool { - self.state.connectionMode == .local && self.needsBootstrap - } - - var pageOrder: [Int] { - Self.pageOrder(for: self.state.connectionMode, showOnboardingChat: self.showOnboardingChat) - } - - var pageCount: Int { - self.pageOrder.count - } - - var activePageIndex: Int { - self.activePageIndex(for: self.currentPage) - } - - var buttonTitle: String { - self.currentPage == self.pageCount - 1 ? "Finish" : "Next" - } - - var wizardPageOrderIndex: Int? { - self.pageOrder.firstIndex(of: self.wizardPageIndex) - } - - var isWizardBlocking: Bool { - self.activePageIndex == self.wizardPageIndex && !self.onboardingWizard.isComplete - } - - var canAdvance: Bool { - !self.isWizardBlocking - } - - var devLinkCommand: String { - let version = GatewayEnvironment.expectedGatewayVersionString() ?? "latest" - return "npm install -g openclaw@\(version)" - } - - struct LocalGatewayProbe: Equatable { - let port: Int - let pid: Int32 - let command: String - let expected: Bool - } - - init( - state: AppState = AppStateStore.shared, - permissionMonitor: PermissionMonitor = .shared, - discoveryModel: GatewayDiscoveryModel = GatewayDiscoveryModel( - localDisplayName: InstanceIdentity.displayName, - filterLocalGateways: false)) - { - self.state = state - self.permissionMonitor = permissionMonitor - self._gatewayDiscovery = State(initialValue: discoveryModel) - self._onboardingChatModel = State( - initialValue: OpenClawChatViewModel( - sessionKey: "onboarding", - transport: MacGatewayChatTransport())) - } -} diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift deleted file mode 100644 index bcd5bd6d44d..00000000000 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift +++ /dev/null @@ -1,147 +0,0 @@ -import AppKit -import Foundation -import OpenClawDiscovery -import OpenClawIPC -import SwiftUI - -extension OnboardingView { - func selectLocalGateway() { - self.state.connectionMode = .local - self.preferredGatewayID = nil - self.showAdvancedConnection = false - GatewayDiscoveryPreferences.setPreferredStableID(nil) - } - - func selectUnconfiguredGateway() { - Task { await self.onboardingWizard.cancelIfRunning() } - self.state.connectionMode = .unconfigured - self.preferredGatewayID = nil - self.showAdvancedConnection = false - GatewayDiscoveryPreferences.setPreferredStableID(nil) - } - - func selectRemoteGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) { - Task { await self.onboardingWizard.cancelIfRunning() } - self.preferredGatewayID = gateway.stableID - GatewayDiscoveryPreferences.setPreferredStableID(gateway.stableID) - - if self.state.remoteTransport == .direct { - self.state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? "" - } else { - self.state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? "" - } - if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) { - OpenClawConfigFile.setRemoteGatewayUrl( - host: endpoint.host, - port: endpoint.port) - } else { - OpenClawConfigFile.clearRemoteGatewayUrl() - } - - self.state.connectionMode = .remote - MacNodeModeCoordinator.shared.setPreferredGatewayStableID(gateway.stableID) - } - - func openSettings(tab: SettingsTab) { - SettingsTabRouter.request(tab) - self.openSettings() - DispatchQueue.main.async { - NotificationCenter.default.post(name: .openclawSelectSettingsTab, object: tab) - } - } - - func handleBack() { - withAnimation { - self.currentPage = max(0, self.currentPage - 1) - } - } - - func handleNext() { - if self.isWizardBlocking { return } - if self.currentPage < self.pageCount - 1 { - withAnimation { self.currentPage += 1 } - } else { - self.finish() - } - } - - func finish() { - UserDefaults.standard.set(true, forKey: "openclaw.onboardingSeen") - UserDefaults.standard.set(currentOnboardingVersion, forKey: onboardingVersionKey) - OnboardingController.shared.close() - } - - func copyToPasteboard(_ text: String) { - let pb = NSPasteboard.general - pb.clearContents() - pb.setString(text, forType: .string) - self.copied = true - DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { self.copied = false } - } - - func startAnthropicOAuth() { - guard !self.anthropicAuthBusy else { return } - self.anthropicAuthBusy = true - defer { self.anthropicAuthBusy = false } - - do { - let pkce = try AnthropicOAuth.generatePKCE() - self.anthropicAuthPKCE = pkce - let url = AnthropicOAuth.buildAuthorizeURL(pkce: pkce) - NSWorkspace.shared.open(url) - self.anthropicAuthStatus = "Browser opened. After approving, paste the `code#state` value here." - } catch { - self.anthropicAuthStatus = "Failed to start OAuth: \(error.localizedDescription)" - } - } - - @MainActor - func finishAnthropicOAuth() async { - guard !self.anthropicAuthBusy else { return } - guard let pkce = self.anthropicAuthPKCE else { return } - self.anthropicAuthBusy = true - defer { self.anthropicAuthBusy = false } - - guard let parsed = AnthropicOAuthCodeState.parse(from: self.anthropicAuthCode) else { - self.anthropicAuthStatus = "OAuth failed: missing or invalid code/state." - return - } - - do { - let creds = try await AnthropicOAuth.exchangeCode( - code: parsed.code, - state: parsed.state, - verifier: pkce.verifier) - try OpenClawOAuthStore.saveAnthropicOAuth(creds) - self.refreshAnthropicOAuthStatus() - self.anthropicAuthStatus = "Connected. OpenClaw can now use Claude." - } catch { - self.anthropicAuthStatus = "OAuth failed: \(error.localizedDescription)" - } - } - - func pollAnthropicClipboardIfNeeded() { - guard self.currentPage == self.anthropicAuthPageIndex else { return } - guard self.anthropicAuthPKCE != nil else { return } - guard !self.anthropicAuthBusy else { return } - guard self.anthropicAuthAutoDetectClipboard else { return } - - let pb = NSPasteboard.general - let changeCount = pb.changeCount - guard changeCount != self.anthropicAuthLastPasteboardChangeCount else { return } - self.anthropicAuthLastPasteboardChangeCount = changeCount - - guard let raw = pb.string(forType: .string), !raw.isEmpty else { return } - guard let parsed = AnthropicOAuthCodeState.parse(from: raw) else { return } - guard let pkce = self.anthropicAuthPKCE, parsed.state == pkce.verifier else { return } - - let next = "\(parsed.code)#\(parsed.state)" - if self.anthropicAuthCode != next { - self.anthropicAuthCode = next - self.anthropicAuthStatus = "Detected `code#state` from clipboard." - } - - guard self.anthropicAuthAutoConnectClipboard else { return } - Task { await self.finishAnthropicOAuth() } - } -} diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Chat.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Chat.swift deleted file mode 100644 index f95da4ffbb5..00000000000 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Chat.swift +++ /dev/null @@ -1,26 +0,0 @@ -import Foundation - -extension OnboardingView { - func maybeKickoffOnboardingChat(for pageIndex: Int) { - guard pageIndex == self.onboardingChatPageIndex else { return } - guard self.showOnboardingChat else { return } - guard !self.didAutoKickoff else { return } - self.didAutoKickoff = true - - Task { @MainActor in - for _ in 0..<20 { - if !self.onboardingChatModel.isLoading { break } - try? await Task.sleep(nanoseconds: 200_000_000) - } - guard self.onboardingChatModel.messages.isEmpty else { return } - let kickoff = - "Hi! I just installed OpenClaw and you’re my brand‑new agent. " + - "Please start the first‑run ritual from BOOTSTRAP.md, ask one question at a time, " + - "and before we talk about WhatsApp/Telegram, visit soul.md with me to craft SOUL.md: " + - "ask what matters to me and how you should be. Then guide me through choosing " + - "how we should talk (web‑only, WhatsApp, or Telegram)." - self.onboardingChatModel.input = kickoff - self.onboardingChatModel.send() - } - } -} diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift deleted file mode 100644 index ce87e211ce4..00000000000 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift +++ /dev/null @@ -1,234 +0,0 @@ -import AppKit -import SwiftUI - -extension OnboardingView { - var body: some View { - VStack(spacing: 0) { - GlowingOpenClawIcon(size: 130, glowIntensity: 0.28) - .offset(y: 10) - .frame(height: 145) - - GeometryReader { _ in - HStack(spacing: 0) { - ForEach(self.pageOrder, id: \.self) { pageIndex in - self.pageView(for: pageIndex) - .frame(width: self.pageWidth) - } - } - .offset(x: CGFloat(-self.currentPage) * self.pageWidth) - .animation( - .interactiveSpring(response: 0.5, dampingFraction: 0.86, blendDuration: 0.25), - value: self.currentPage) - .frame(height: self.contentHeight, alignment: .top) - .clipped() - } - .frame(height: self.contentHeight) - - Spacer(minLength: 0) - self.navigationBar - } - .frame(width: self.pageWidth, height: Self.windowHeight) - .background(Color(NSColor.windowBackgroundColor)) - .onAppear { - self.currentPage = 0 - self.updateMonitoring(for: 0) - } - .onChange(of: self.currentPage) { _, newValue in - self.updateMonitoring(for: self.activePageIndex(for: newValue)) - } - .onChange(of: self.state.connectionMode) { _, _ in - let oldActive = self.activePageIndex - self.reconcilePageForModeChange(previousActivePageIndex: oldActive) - self.updateDiscoveryMonitoring(for: self.activePageIndex) - } - .onChange(of: self.needsBootstrap) { _, _ in - if self.currentPage >= self.pageOrder.count { - self.currentPage = max(0, self.pageOrder.count - 1) - } - } - .onChange(of: self.onboardingWizard.isComplete) { _, newValue in - guard newValue, self.activePageIndex == self.wizardPageIndex else { return } - self.handleNext() - } - .onDisappear { - self.stopPermissionMonitoring() - self.stopDiscovery() - self.stopAuthMonitoring() - Task { await self.onboardingWizard.cancelIfRunning() } - } - .task { - await self.refreshPerms() - self.refreshCLIStatus() - await self.loadWorkspaceDefaults() - await self.ensureDefaultWorkspace() - self.refreshAnthropicOAuthStatus() - self.refreshBootstrapStatus() - self.preferredGatewayID = GatewayDiscoveryPreferences.preferredStableID() - } - } - - func activePageIndex(for pageCursor: Int) -> Int { - guard !self.pageOrder.isEmpty else { return 0 } - let clamped = min(max(0, pageCursor), self.pageOrder.count - 1) - return self.pageOrder[clamped] - } - - func reconcilePageForModeChange(previousActivePageIndex: Int) { - if let exact = self.pageOrder.firstIndex(of: previousActivePageIndex) { - withAnimation { self.currentPage = exact } - return - } - if let next = self.pageOrder.firstIndex(where: { $0 > previousActivePageIndex }) { - withAnimation { self.currentPage = next } - return - } - withAnimation { self.currentPage = max(0, self.pageOrder.count - 1) } - } - - var navigationBar: some View { - let wizardLockIndex = self.wizardPageOrderIndex - return HStack(spacing: 20) { - ZStack(alignment: .leading) { - Button(action: {}, label: { - Label("Back", systemImage: "chevron.left").labelStyle(.iconOnly) - }) - .buttonStyle(.plain) - .opacity(0) - .disabled(true) - - if self.currentPage > 0 { - Button(action: self.handleBack, label: { - Label("Back", systemImage: "chevron.left") - .labelStyle(.iconOnly) - }) - .buttonStyle(.plain) - .foregroundColor(.secondary) - .opacity(0.8) - .transition(.opacity.combined(with: .scale(scale: 0.9))) - } - } - .frame(minWidth: 80, alignment: .leading) - - Spacer() - - HStack(spacing: 8) { - ForEach(0.. (wizardLockIndex ?? 0) - Button { - withAnimation { self.currentPage = index } - } label: { - Circle() - .fill(index == self.currentPage ? Color.accentColor : Color.gray.opacity(0.3)) - .frame(width: 8, height: 8) - } - .buttonStyle(.plain) - .disabled(isLocked) - .opacity(isLocked ? 0.3 : 1) - } - } - - Spacer() - - Button(action: self.handleNext) { - Text(self.buttonTitle) - .frame(minWidth: 88) - } - .keyboardShortcut(.return) - .buttonStyle(.borderedProminent) - .disabled(!self.canAdvance) - } - .padding(.horizontal, 28) - .padding(.bottom, 13) - .frame(minHeight: 60, alignment: .bottom) - } - - func onboardingPage(@ViewBuilder _ content: () -> some View) -> some View { - let scrollIndicatorGutter: CGFloat = 18 - return ScrollView { - VStack(spacing: 16) { - content() - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity, alignment: .top) - .padding(.trailing, scrollIndicatorGutter) - } - .scrollIndicators(.automatic) - .padding(.horizontal, 28) - .frame(width: self.pageWidth, alignment: .top) - } - - func onboardingCard( - spacing: CGFloat = 12, - padding: CGFloat = 16, - @ViewBuilder _ content: () -> some View) -> some View - { - VStack(alignment: .leading, spacing: spacing) { - content() - } - .padding(padding) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color(NSColor.controlBackgroundColor)) - .shadow(color: .black.opacity(0.06), radius: 8, y: 3)) - } - - func onboardingGlassCard( - spacing: CGFloat = 12, - padding: CGFloat = 16, - @ViewBuilder _ content: () -> some View) -> some View - { - let shape = RoundedRectangle(cornerRadius: 16, style: .continuous) - return VStack(alignment: .leading, spacing: spacing) { - content() - } - .padding(padding) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.clear) - .clipShape(shape) - .overlay(shape.strokeBorder(Color.white.opacity(0.10), lineWidth: 1)) - } - - func featureRow(title: String, subtitle: String, systemImage: String) -> some View { - HStack(alignment: .top, spacing: 12) { - Image(systemName: systemImage) - .font(.title3.weight(.semibold)) - .foregroundStyle(Color.accentColor) - .frame(width: 26) - VStack(alignment: .leading, spacing: 4) { - Text(title).font(.headline) - Text(subtitle) - .font(.subheadline) - .foregroundStyle(.secondary) - } - } - .padding(.vertical, 4) - } - - func featureActionRow( - title: String, - subtitle: String, - systemImage: String, - buttonTitle: String, - action: @escaping () -> Void) -> some View - { - HStack(alignment: .top, spacing: 12) { - Image(systemName: systemImage) - .font(.title3.weight(.semibold)) - .foregroundStyle(Color.accentColor) - .frame(width: 26) - VStack(alignment: .leading, spacing: 4) { - Text(title).font(.headline) - Text(subtitle) - .font(.subheadline) - .foregroundStyle(.secondary) - Button(buttonTitle, action: action) - .buttonStyle(.link) - .padding(.top, 2) - } - Spacer(minLength: 0) - } - .padding(.vertical, 4) - } -} diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift deleted file mode 100644 index dfbdf91d44d..00000000000 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift +++ /dev/null @@ -1,178 +0,0 @@ -import Foundation -import OpenClawIPC - -extension OnboardingView { - @MainActor - func refreshPerms() async { - await self.permissionMonitor.refreshNow() - } - - @MainActor - func request(_ cap: Capability) async { - guard !self.isRequesting else { return } - self.isRequesting = true - defer { isRequesting = false } - _ = await PermissionManager.ensure([cap], interactive: true) - await self.refreshPerms() - } - - func updatePermissionMonitoring(for pageIndex: Int) { - let shouldMonitor = pageIndex == self.permissionsPageIndex - if shouldMonitor, !self.monitoringPermissions { - self.monitoringPermissions = true - PermissionMonitor.shared.register() - } else if !shouldMonitor, self.monitoringPermissions { - self.monitoringPermissions = false - PermissionMonitor.shared.unregister() - } - } - - func updateDiscoveryMonitoring(for pageIndex: Int) { - let isConnectionPage = pageIndex == self.connectionPageIndex - let shouldMonitor = isConnectionPage - if shouldMonitor, !self.monitoringDiscovery { - self.monitoringDiscovery = true - Task { @MainActor in - try? await Task.sleep(nanoseconds: 150_000_000) - guard self.monitoringDiscovery else { return } - self.gatewayDiscovery.start() - await self.refreshLocalGatewayProbe() - } - } else if !shouldMonitor, self.monitoringDiscovery { - self.monitoringDiscovery = false - self.gatewayDiscovery.stop() - } - } - - func updateMonitoring(for pageIndex: Int) { - self.updatePermissionMonitoring(for: pageIndex) - self.updateDiscoveryMonitoring(for: pageIndex) - self.updateAuthMonitoring(for: pageIndex) - self.maybeKickoffOnboardingChat(for: pageIndex) - } - - func stopPermissionMonitoring() { - guard self.monitoringPermissions else { return } - self.monitoringPermissions = false - PermissionMonitor.shared.unregister() - } - - func stopDiscovery() { - guard self.monitoringDiscovery else { return } - self.monitoringDiscovery = false - self.gatewayDiscovery.stop() - } - - func updateAuthMonitoring(for pageIndex: Int) { - let shouldMonitor = pageIndex == self.anthropicAuthPageIndex && self.state.connectionMode == .local - if shouldMonitor, !self.monitoringAuth { - self.monitoringAuth = true - self.startAuthMonitoring() - } else if !shouldMonitor, self.monitoringAuth { - self.stopAuthMonitoring() - } - } - - func startAuthMonitoring() { - self.refreshAnthropicOAuthStatus() - self.authMonitorTask?.cancel() - self.authMonitorTask = Task { - while !Task.isCancelled { - await MainActor.run { self.refreshAnthropicOAuthStatus() } - try? await Task.sleep(nanoseconds: 1_000_000_000) - } - } - } - - func stopAuthMonitoring() { - self.monitoringAuth = false - self.authMonitorTask?.cancel() - self.authMonitorTask = nil - } - - func installCLI() async { - guard !self.installingCLI else { return } - self.installingCLI = true - defer { installingCLI = false } - await CLIInstaller.install { message in - self.cliStatus = message - } - self.refreshCLIStatus() - } - - func refreshCLIStatus() { - let installLocation = CLIInstaller.installedLocation() - self.cliInstallLocation = installLocation - self.cliInstalled = installLocation != nil - } - - func refreshLocalGatewayProbe() async { - let port = GatewayEnvironment.gatewayPort() - let desc = await PortGuardian.shared.describe(port: port) - await MainActor.run { - guard let desc else { - self.localGatewayProbe = nil - return - } - let command = desc.command.trimmingCharacters(in: .whitespacesAndNewlines) - let expectedTokens = ["node", "openclaw", "tsx", "pnpm", "bun"] - let lower = command.lowercased() - let expected = expectedTokens.contains { lower.contains($0) } - self.localGatewayProbe = LocalGatewayProbe( - port: port, - pid: desc.pid, - command: command, - expected: expected) - } - } - - func refreshAnthropicOAuthStatus() { - _ = OpenClawOAuthStore.importLegacyAnthropicOAuthIfNeeded() - let previous = self.anthropicAuthDetectedStatus - let status = OpenClawOAuthStore.anthropicOAuthStatus() - self.anthropicAuthDetectedStatus = status - self.anthropicAuthConnected = status.isConnected - - if previous != status { - self.anthropicAuthVerified = false - self.anthropicAuthVerificationAttempted = false - self.anthropicAuthVerificationFailed = false - self.anthropicAuthVerifiedAt = nil - } - } - - @MainActor - func verifyAnthropicOAuthIfNeeded(force: Bool = false) async { - guard self.state.connectionMode == .local else { return } - guard self.anthropicAuthDetectedStatus.isConnected else { return } - if self.anthropicAuthVerified, !force { return } - if self.anthropicAuthVerifying { return } - if self.anthropicAuthVerificationAttempted, !force { return } - - self.anthropicAuthVerificationAttempted = true - self.anthropicAuthVerifying = true - self.anthropicAuthVerificationFailed = false - defer { self.anthropicAuthVerifying = false } - - guard let refresh = OpenClawOAuthStore.loadAnthropicOAuthRefreshToken(), !refresh.isEmpty else { - self.anthropicAuthStatus = "OAuth verification failed: missing refresh token." - self.anthropicAuthVerificationFailed = true - return - } - - do { - let updated = try await AnthropicOAuth.refresh(refreshToken: refresh) - try OpenClawOAuthStore.saveAnthropicOAuth(updated) - self.refreshAnthropicOAuthStatus() - self.anthropicAuthVerified = true - self.anthropicAuthVerifiedAt = Date() - self.anthropicAuthVerificationFailed = false - self.anthropicAuthStatus = "OAuth detected and verified." - } catch { - self.anthropicAuthVerified = false - self.anthropicAuthVerifiedAt = nil - self.anthropicAuthVerificationFailed = true - self.anthropicAuthStatus = "OAuth verification failed: \(error.localizedDescription)" - } - } -} diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift deleted file mode 100644 index 5b05ab164c2..00000000000 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift +++ /dev/null @@ -1,847 +0,0 @@ -import AppKit -import OpenClawChatUI -import OpenClawDiscovery -import OpenClawIPC -import SwiftUI - -extension OnboardingView { - @ViewBuilder - func pageView(for pageIndex: Int) -> some View { - switch pageIndex { - case 0: - self.welcomePage() - case 1: - self.connectionPage() - case 2: - self.anthropicAuthPage() - case 3: - self.wizardPage() - case 5: - self.permissionsPage() - case 6: - self.cliPage() - case 8: - self.onboardingChatPage() - case 9: - self.readyPage() - default: - EmptyView() - } - } - - func welcomePage() -> some View { - self.onboardingPage { - VStack(spacing: 22) { - Text("Welcome to OpenClaw") - .font(.largeTitle.weight(.semibold)) - Text("OpenClaw is a powerful personal AI assistant that can connect to WhatsApp or Telegram.") - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .lineLimit(2) - .frame(maxWidth: 560) - .fixedSize(horizontal: false, vertical: true) - - self.onboardingCard(spacing: 10, padding: 14) { - HStack(alignment: .top, spacing: 12) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.title3.weight(.semibold)) - .foregroundStyle(Color(nsColor: .systemOrange)) - .frame(width: 22) - .padding(.top, 1) - - VStack(alignment: .leading, spacing: 6) { - Text("Security notice") - .font(.headline) - Text( - "The connected AI agent (e.g. Claude) can trigger powerful actions on your Mac, " + - "including running commands, reading/writing files, and capturing screenshots — " + - "depending on the permissions you grant.\n\n" + - "Only enable OpenClaw if you understand the risks and trust the prompts and " + - "integrations you use.") - .font(.subheadline) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - } - .frame(maxWidth: 520) - } - .padding(.top, 16) - } - } - - func connectionPage() -> some View { - self.onboardingPage { - Text("Choose your Gateway") - .font(.largeTitle.weight(.semibold)) - Text( - "OpenClaw uses a single Gateway that stays running. Pick this Mac, " + - "connect to a discovered gateway nearby, or configure later.") - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .lineLimit(2) - .frame(maxWidth: 520) - .fixedSize(horizontal: false, vertical: true) - - self.onboardingCard(spacing: 12, padding: 14) { - VStack(alignment: .leading, spacing: 10) { - let localSubtitle: String = { - guard let probe = self.localGatewayProbe else { - return "Gateway starts automatically on this Mac." - } - let base = probe.expected - ? "Existing gateway detected" - : "Port \(probe.port) already in use" - let command = probe.command.isEmpty ? "" : " (\(probe.command) pid \(probe.pid))" - return "\(base)\(command). Will attach." - }() - self.connectionChoiceButton( - title: "This Mac", - subtitle: localSubtitle, - selected: self.state.connectionMode == .local) - { - self.selectLocalGateway() - } - - Divider().padding(.vertical, 4) - - HStack(spacing: 8) { - Image(systemName: "dot.radiowaves.left.and.right") - .font(.caption) - .foregroundStyle(.secondary) - Text(self.gatewayDiscovery.statusText) - .font(.caption) - .foregroundStyle(.secondary) - if self.gatewayDiscovery.gateways.isEmpty { - ProgressView().controlSize(.small) - Button("Refresh") { - self.gatewayDiscovery.refreshWideAreaFallbackNow(timeoutSeconds: 5.0) - } - .buttonStyle(.link) - .help("Retry Tailscale discovery (DNS-SD).") - } - Spacer(minLength: 0) - } - - if self.gatewayDiscovery.gateways.isEmpty { - Text("Searching for nearby gateways…") - .font(.caption) - .foregroundStyle(.secondary) - .padding(.leading, 4) - } else { - VStack(alignment: .leading, spacing: 6) { - Text("Nearby gateways") - .font(.caption) - .foregroundStyle(.secondary) - .padding(.leading, 4) - ForEach(self.gatewayDiscovery.gateways.prefix(6)) { gateway in - self.connectionChoiceButton( - title: gateway.displayName, - subtitle: self.gatewaySubtitle(for: gateway), - selected: self.isSelectedGateway(gateway)) - { - self.selectRemoteGateway(gateway) - } - } - } - .padding(8) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(Color(NSColor.controlBackgroundColor))) - } - - self.connectionChoiceButton( - title: "Configure later", - subtitle: "Don’t start the Gateway yet.", - selected: self.state.connectionMode == .unconfigured) - { - self.selectUnconfiguredGateway() - } - - Button(self.showAdvancedConnection ? "Hide Advanced" : "Advanced…") { - withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) { - self.showAdvancedConnection.toggle() - } - if self.showAdvancedConnection, self.state.connectionMode != .remote { - self.state.connectionMode = .remote - } - } - .buttonStyle(.link) - - if self.showAdvancedConnection { - let labelWidth: CGFloat = 110 - let fieldWidth: CGFloat = 320 - - VStack(alignment: .leading, spacing: 10) { - Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { - GridRow { - Text("Transport") - .font(.callout.weight(.semibold)) - .frame(width: labelWidth, alignment: .leading) - Picker("Transport", selection: self.$state.remoteTransport) { - Text("SSH tunnel").tag(AppState.RemoteTransport.ssh) - Text("Direct (ws/wss)").tag(AppState.RemoteTransport.direct) - } - .pickerStyle(.segmented) - .frame(width: fieldWidth) - } - if self.state.remoteTransport == .direct { - GridRow { - Text("Gateway URL") - .font(.callout.weight(.semibold)) - .frame(width: labelWidth, alignment: .leading) - TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl) - .textFieldStyle(.roundedBorder) - .frame(width: fieldWidth) - } - } - if self.state.remoteTransport == .ssh { - GridRow { - Text("SSH target") - .font(.callout.weight(.semibold)) - .frame(width: labelWidth, alignment: .leading) - TextField("user@host[:port]", text: self.$state.remoteTarget) - .textFieldStyle(.roundedBorder) - .frame(width: fieldWidth) - } - if let message = CommandResolver - .sshTargetValidationMessage(self.state.remoteTarget) - { - GridRow { - Text("") - .frame(width: labelWidth, alignment: .leading) - Text(message) - .font(.caption) - .foregroundStyle(.red) - .frame(width: fieldWidth, alignment: .leading) - } - } - GridRow { - Text("Identity file") - .font(.callout.weight(.semibold)) - .frame(width: labelWidth, alignment: .leading) - TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity) - .textFieldStyle(.roundedBorder) - .frame(width: fieldWidth) - } - GridRow { - Text("Project root") - .font(.callout.weight(.semibold)) - .frame(width: labelWidth, alignment: .leading) - TextField("/home/you/Projects/openclaw", text: self.$state.remoteProjectRoot) - .textFieldStyle(.roundedBorder) - .frame(width: fieldWidth) - } - GridRow { - Text("CLI path") - .font(.callout.weight(.semibold)) - .frame(width: labelWidth, alignment: .leading) - TextField( - "/Applications/OpenClaw.app/.../openclaw", - text: self.$state.remoteCliPath) - .textFieldStyle(.roundedBorder) - .frame(width: fieldWidth) - } - } - } - - Text(self.state.remoteTransport == .direct - ? "Tip: use Tailscale Serve so the gateway has a valid HTTPS cert." - : "Tip: keep Tailscale enabled so your gateway stays reachable.") - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(1) - } - .transition(.opacity.combined(with: .move(edge: .top))) - } - } - } - } - } - - func gatewaySubtitle(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? { - if self.state.remoteTransport == .direct { - return GatewayDiscoveryHelpers.directUrl(for: gateway) ?? "Gateway pairing only" - } - if let target = GatewayDiscoveryHelpers.sshTarget(for: gateway), - let parsed = CommandResolver.parseSSHTarget(target) - { - let portSuffix = parsed.port != 22 ? " · ssh \(parsed.port)" : "" - return "\(parsed.host)\(portSuffix)" - } - return "Gateway pairing only" - } - - func isSelectedGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> Bool { - guard self.state.connectionMode == .remote else { return false } - let preferred = self.preferredGatewayID ?? GatewayDiscoveryPreferences.preferredStableID() - return preferred == gateway.stableID - } - - func connectionChoiceButton( - title: String, - subtitle: String?, - selected: Bool, - action: @escaping () -> Void) -> some View - { - Button { - withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) { - action() - } - } label: { - HStack(alignment: .center, spacing: 10) { - VStack(alignment: .leading, spacing: 2) { - Text(title) - .font(.callout.weight(.semibold)) - .lineLimit(1) - .truncationMode(.tail) - if let subtitle { - Text(subtitle) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - } - } - Spacer(minLength: 0) - if selected { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(Color.accentColor) - } else { - Image(systemName: "arrow.right.circle") - .foregroundStyle(.secondary) - } - } - .padding(.horizontal, 10) - .padding(.vertical, 8) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(selected ? Color.accentColor.opacity(0.12) : Color.clear)) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .strokeBorder( - selected ? Color.accentColor.opacity(0.45) : Color.clear, - lineWidth: 1)) - } - .buttonStyle(.plain) - } - - func anthropicAuthPage() -> some View { - self.onboardingPage { - Text("Connect Claude") - .font(.largeTitle.weight(.semibold)) - Text("Give your model the token it needs!") - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 540) - .fixedSize(horizontal: false, vertical: true) - Text("OpenClaw supports any model — we strongly recommend Opus 4.6 for the best experience.") - .font(.callout) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 540) - .fixedSize(horizontal: false, vertical: true) - - self.onboardingCard(spacing: 12, padding: 16) { - HStack(alignment: .center, spacing: 10) { - Circle() - .fill(self.anthropicAuthVerified ? Color.green : Color.orange) - .frame(width: 10, height: 10) - Text( - self.anthropicAuthConnected - ? (self.anthropicAuthVerified - ? "Claude connected (OAuth) — verified" - : "Claude connected (OAuth)") - : "Not connected yet") - .font(.headline) - Spacer() - } - - if self.anthropicAuthConnected, self.anthropicAuthVerifying { - Text("Verifying OAuth…") - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } else if !self.anthropicAuthConnected { - Text(self.anthropicAuthDetectedStatus.shortDescription) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } else if self.anthropicAuthVerified, let date = self.anthropicAuthVerifiedAt { - Text("Detected working OAuth (\(date.formatted(date: .abbreviated, time: .shortened))).") - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - Text( - "This lets OpenClaw use Claude immediately. Credentials are stored at " + - "`~/.openclaw/credentials/oauth.json` (owner-only).") - .font(.subheadline) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - - HStack(spacing: 12) { - Text(OpenClawOAuthStore.oauthURL().path) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - - Spacer() - - Button("Reveal") { - NSWorkspace.shared.activateFileViewerSelecting([OpenClawOAuthStore.oauthURL()]) - } - .buttonStyle(.bordered) - - Button("Refresh") { - self.refreshAnthropicOAuthStatus() - } - .buttonStyle(.bordered) - } - - Divider().padding(.vertical, 2) - - HStack(spacing: 12) { - if !self.anthropicAuthVerified { - if self.anthropicAuthConnected { - Button("Verify") { - Task { await self.verifyAnthropicOAuthIfNeeded(force: true) } - } - .buttonStyle(.borderedProminent) - .disabled(self.anthropicAuthBusy || self.anthropicAuthVerifying) - - if self.anthropicAuthVerificationFailed { - Button("Re-auth (OAuth)") { - self.startAnthropicOAuth() - } - .buttonStyle(.bordered) - .disabled(self.anthropicAuthBusy || self.anthropicAuthVerifying) - } - } else { - Button { - self.startAnthropicOAuth() - } label: { - if self.anthropicAuthBusy { - ProgressView() - } else { - Text("Open Claude sign-in (OAuth)") - } - } - .buttonStyle(.borderedProminent) - .disabled(self.anthropicAuthBusy) - } - } - } - - if !self.anthropicAuthVerified, self.anthropicAuthPKCE != nil { - VStack(alignment: .leading, spacing: 8) { - Text("Paste the `code#state` value") - .font(.headline) - TextField("code#state", text: self.$anthropicAuthCode) - .textFieldStyle(.roundedBorder) - - Toggle("Auto-detect from clipboard", isOn: self.$anthropicAuthAutoDetectClipboard) - .font(.caption) - .foregroundStyle(.secondary) - .disabled(self.anthropicAuthBusy) - - Toggle("Auto-connect when detected", isOn: self.$anthropicAuthAutoConnectClipboard) - .font(.caption) - .foregroundStyle(.secondary) - .disabled(self.anthropicAuthBusy) - - Button("Connect") { - Task { await self.finishAnthropicOAuth() } - } - .buttonStyle(.bordered) - .disabled( - self.anthropicAuthBusy || - self.anthropicAuthCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - .onReceive(Self.clipboardPoll) { _ in - self.pollAnthropicClipboardIfNeeded() - } - } - - self.onboardingCard(spacing: 8, padding: 12) { - Text("API key (advanced)") - .font(.headline) - Text( - "You can also use an Anthropic API key, but this UI is instructions-only for now " + - "(GUI apps don’t automatically inherit your shell env vars like `ANTHROPIC_API_KEY`).") - .font(.subheadline) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - .shadow(color: .clear, radius: 0) - .background(Color.clear) - - if let status = self.anthropicAuthStatus { - Text(status) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - } - .task { await self.verifyAnthropicOAuthIfNeeded() } - } - - func permissionsPage() -> some View { - self.onboardingPage { - Text("Grant permissions") - .font(.largeTitle.weight(.semibold)) - Text("These macOS permissions let OpenClaw automate apps and capture context on this Mac.") - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 520) - .fixedSize(horizontal: false, vertical: true) - - self.onboardingCard(spacing: 8, padding: 12) { - ForEach(Capability.allCases, id: \.self) { cap in - PermissionRow( - capability: cap, - status: self.permissionMonitor.status[cap] ?? false, - compact: true) - { - Task { await self.request(cap) } - } - } - - HStack(spacing: 12) { - Button { - Task { await self.refreshPerms() } - } label: { - Label("Refresh", systemImage: "arrow.clockwise") - } - .buttonStyle(.bordered) - .controlSize(.small) - .help("Refresh status") - if self.isRequesting { - ProgressView() - .controlSize(.small) - } - } - .padding(.top, 4) - } - } - } - - func cliPage() -> some View { - self.onboardingPage { - Text("Install the CLI") - .font(.largeTitle.weight(.semibold)) - Text("Required for local mode: installs `openclaw` so launchd can run the gateway.") - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 520) - .fixedSize(horizontal: false, vertical: true) - - self.onboardingCard(spacing: 10) { - HStack(spacing: 12) { - Button { - Task { await self.installCLI() } - } label: { - let title = self.cliInstalled ? "Reinstall CLI" : "Install CLI" - ZStack { - Text(title) - .opacity(self.installingCLI ? 0 : 1) - if self.installingCLI { - ProgressView() - .controlSize(.mini) - } - } - .frame(minWidth: 120) - } - .buttonStyle(.borderedProminent) - .disabled(self.installingCLI) - - Button(self.copied ? "Copied" : "Copy install command") { - self.copyToPasteboard(self.devLinkCommand) - } - .disabled(self.installingCLI) - - if self.cliInstalled, let loc = self.cliInstallLocation { - Label("Installed at \(loc)", systemImage: "checkmark.circle.fill") - .font(.footnote) - .foregroundStyle(.green) - } - } - - if let cliStatus { - Text(cliStatus) - .font(.caption) - .foregroundStyle(.secondary) - } else if !self.cliInstalled, self.cliInstallLocation == nil { - Text( - """ - Installs a user-space Node 22+ runtime and the CLI (no Homebrew). - Rerun anytime to reinstall or update. - """) - .font(.footnote) - .foregroundStyle(.secondary) - } - } - } - } - - func workspacePage() -> some View { - self.onboardingPage { - Text("Agent workspace") - .font(.largeTitle.weight(.semibold)) - Text( - "OpenClaw runs the agent from a dedicated workspace so it can load `AGENTS.md` " + - "and write files there without mixing into your other projects.") - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 560) - .fixedSize(horizontal: false, vertical: true) - - self.onboardingCard(spacing: 10) { - if self.state.connectionMode == .remote { - Text("Remote gateway detected") - .font(.headline) - Text( - "Create the workspace on the remote host (SSH in first). " + - "The macOS app can’t write files on your gateway over SSH yet.") - .font(.subheadline) - .foregroundStyle(.secondary) - - Button(self.copied ? "Copied" : "Copy setup command") { - self.copyToPasteboard(self.workspaceBootstrapCommand) - } - .buttonStyle(.bordered) - } else { - VStack(alignment: .leading, spacing: 8) { - Text("Workspace folder") - .font(.headline) - TextField( - AgentWorkspace.displayPath(for: OpenClawConfigFile.defaultWorkspaceURL()), - text: self.$workspacePath) - .textFieldStyle(.roundedBorder) - - HStack(spacing: 12) { - Button { - Task { await self.applyWorkspace() } - } label: { - if self.workspaceApplying { - ProgressView() - } else { - Text("Create workspace") - } - } - .buttonStyle(.borderedProminent) - .disabled(self.workspaceApplying) - - Button("Open folder") { - let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath) - NSWorkspace.shared.open(url) - } - .buttonStyle(.bordered) - .disabled(self.workspaceApplying) - - Button("Save in config") { - Task { - let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath) - let saved = await self.saveAgentWorkspace(AgentWorkspace.displayPath(for: url)) - if saved { - self.workspaceStatus = - "Saved to ~/.openclaw/openclaw.json (agents.defaults.workspace)" - } - } - } - .buttonStyle(.bordered) - .disabled(self.workspaceApplying) - } - } - - if let workspaceStatus { - Text(workspaceStatus) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(2) - } else { - Text( - "Tip: edit AGENTS.md in this folder to shape the assistant’s behavior. " + - "For backup, make the workspace a private git repo so your agent’s " + - "“memory” is versioned.") - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(2) - } - } - } - } - } - - func onboardingChatPage() -> some View { - VStack(spacing: 16) { - Text("Meet your agent") - .font(.largeTitle.weight(.semibold)) - Text( - "This is a dedicated onboarding chat. Your agent will introduce itself, " + - "learn who you are, and help you connect WhatsApp or Telegram if you want.") - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 520) - .fixedSize(horizontal: false, vertical: true) - - self.onboardingGlassCard(padding: 8) { - OpenClawChatView(viewModel: self.onboardingChatModel, style: .onboarding) - .frame(maxHeight: .infinity) - } - .frame(maxHeight: .infinity) - } - .padding(.horizontal, 28) - .frame(width: self.pageWidth, height: self.contentHeight, alignment: .top) - } - - func readyPage() -> some View { - self.onboardingPage { - Text("All set") - .font(.largeTitle.weight(.semibold)) - self.onboardingCard { - if self.state.connectionMode == .unconfigured { - self.featureRow( - title: "Configure later", - subtitle: "Pick Local or Remote in Settings → General whenever you’re ready.", - systemImage: "gearshape") - Divider() - .padding(.vertical, 6) - } - if self.state.connectionMode == .remote { - self.featureRow( - title: "Remote gateway checklist", - subtitle: """ - On your gateway host: install/update the `openclaw` package and make sure credentials exist - (typically `~/.openclaw/credentials/oauth.json`). Then connect again if needed. - """, - systemImage: "network") - Divider() - .padding(.vertical, 6) - } - self.featureRow( - title: "Open the menu bar panel", - subtitle: "Click the OpenClaw menu bar icon for quick chat and status.", - systemImage: "bubble.left.and.bubble.right") - self.featureActionRow( - title: "Connect WhatsApp or Telegram", - subtitle: "Open Settings → Channels to link channels and monitor status.", - systemImage: "link", - buttonTitle: "Open Settings → Channels") - { - self.openSettings(tab: .channels) - } - self.featureRow( - title: "Try Voice Wake", - subtitle: "Enable Voice Wake in Settings for hands-free commands with a live transcript overlay.", - systemImage: "waveform.circle") - self.featureRow( - title: "Use the panel + Canvas", - subtitle: "Open the menu bar panel for quick chat; the agent can show previews " + - "and richer visuals in Canvas.", - systemImage: "rectangle.inset.filled.and.person.filled") - self.featureActionRow( - title: "Give your agent more powers", - subtitle: "Enable optional skills (Peekaboo, oracle, camsnap, …) from Settings → Skills.", - systemImage: "sparkles", - buttonTitle: "Open Settings → Skills") - { - self.openSettings(tab: .skills) - } - self.skillsOverview - Toggle("Launch at login", isOn: self.$state.launchAtLogin) - .onChange(of: self.state.launchAtLogin) { _, newValue in - AppStateStore.updateLaunchAtLogin(enabled: newValue) - } - } - } - .task { await self.maybeLoadOnboardingSkills() } - } - - private func maybeLoadOnboardingSkills() async { - guard !self.didLoadOnboardingSkills else { return } - self.didLoadOnboardingSkills = true - await self.onboardingSkillsModel.refresh() - } - - private var skillsOverview: some View { - VStack(alignment: .leading, spacing: 8) { - Divider() - .padding(.vertical, 6) - - HStack(spacing: 10) { - Text("Skills included") - .font(.headline) - Spacer(minLength: 0) - if self.onboardingSkillsModel.isLoading { - ProgressView() - .controlSize(.small) - } else { - Button("Refresh") { - Task { await self.onboardingSkillsModel.refresh() } - } - .buttonStyle(.link) - } - } - - if let error = self.onboardingSkillsModel.error { - VStack(alignment: .leading, spacing: 4) { - Text("Couldn’t load skills from the Gateway.") - .font(.footnote.weight(.semibold)) - .foregroundStyle(.orange) - Text( - "Make sure the Gateway is running and connected, " + - "then hit Refresh (or open Settings → Skills).") - .font(.footnote) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - Text("Details: \(error)") - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } else if self.onboardingSkillsModel.skills.isEmpty { - Text("No skills reported yet.") - .font(.footnote) - .foregroundStyle(.secondary) - } else { - ScrollView { - LazyVStack(alignment: .leading, spacing: 10) { - ForEach(self.onboardingSkillsModel.skills) { skill in - HStack(alignment: .top, spacing: 10) { - Text(skill.emoji ?? "✨") - .font(.callout) - .frame(width: 22, alignment: .leading) - VStack(alignment: .leading, spacing: 2) { - Text(skill.name) - .font(.callout.weight(.semibold)) - Text(skill.description) - .font(.footnote) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - Spacer(minLength: 0) - } - } - } - .padding(10) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color(NSColor.windowBackgroundColor))) - } - .frame(maxHeight: 160) - } - } - } -} diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Testing.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Testing.swift deleted file mode 100644 index cf8c3d0c78f..00000000000 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Testing.swift +++ /dev/null @@ -1,87 +0,0 @@ -import OpenClawDiscovery -import SwiftUI - -#if DEBUG -@MainActor -extension OnboardingView { - static func exerciseForTesting() { - let state = AppState(preview: true) - let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName) - discovery.statusText = "Searching..." - let gateway = GatewayDiscoveryModel.DiscoveredGateway( - displayName: "Test Gateway", - lanHost: "gateway.local", - tailnetDns: "gateway.ts.net", - sshPort: 2222, - gatewayPort: 18789, - cliPath: "/usr/local/bin/openclaw", - stableID: "gateway-1", - debugID: "gateway-1", - isLocal: false) - discovery.gateways = [gateway] - - let view = OnboardingView( - state: state, - permissionMonitor: PermissionMonitor.shared, - discoveryModel: discovery) - view.needsBootstrap = true - view.localGatewayProbe = LocalGatewayProbe( - port: GatewayEnvironment.gatewayPort(), - pid: 123, - command: "openclaw-gateway", - expected: true) - view.showAdvancedConnection = true - view.preferredGatewayID = gateway.stableID - view.cliInstalled = true - view.cliInstallLocation = "/usr/local/bin/openclaw" - view.cliStatus = "Installed" - view.workspacePath = "/tmp/openclaw" - view.workspaceStatus = "Saved workspace" - view.anthropicAuthPKCE = AnthropicOAuth.PKCE(verifier: "verifier", challenge: "challenge") - view.anthropicAuthCode = "code#state" - view.anthropicAuthStatus = "Connected" - view.anthropicAuthDetectedStatus = .connected(expiresAtMs: 1_700_000_000_000) - view.anthropicAuthConnected = true - view.anthropicAuthAutoDetectClipboard = false - view.anthropicAuthAutoConnectClipboard = false - - view.state.connectionMode = .local - _ = view.welcomePage() - _ = view.connectionPage() - _ = view.anthropicAuthPage() - _ = view.wizardPage() - _ = view.permissionsPage() - _ = view.cliPage() - _ = view.workspacePage() - _ = view.onboardingChatPage() - _ = view.readyPage() - - view.selectLocalGateway() - view.selectRemoteGateway(gateway) - view.selectUnconfiguredGateway() - - view.state.connectionMode = .remote - _ = view.connectionPage() - _ = view.workspacePage() - - view.state.connectionMode = .unconfigured - _ = view.connectionPage() - - view.currentPage = 0 - view.handleNext() - view.handleBack() - - _ = view.onboardingPage { Text("Test") } - _ = view.onboardingCard { Text("Card") } - _ = view.featureRow(title: "Feature", subtitle: "Subtitle", systemImage: "sparkles") - _ = view.featureActionRow( - title: "Action", - subtitle: "Action subtitle", - systemImage: "gearshape", - buttonTitle: "Action", - action: {}) - _ = view.gatewaySubtitle(for: gateway) - _ = view.isSelectedGateway(gateway) - } -} -#endif diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift deleted file mode 100644 index 0c77f1e327d..00000000000 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift +++ /dev/null @@ -1,94 +0,0 @@ -import Observation -import OpenClawProtocol -import SwiftUI - -extension OnboardingView { - func wizardPage() -> some View { - self.onboardingPage { - VStack(spacing: 16) { - Text("Setup Wizard") - .font(.largeTitle.weight(.semibold)) - Text("Follow the guided setup from the Gateway. This keeps onboarding in sync with the CLI.") - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 520) - - self.onboardingCard(spacing: 14, padding: 16) { - OnboardingWizardCardContent( - wizard: self.onboardingWizard, - mode: self.state.connectionMode, - workspacePath: self.workspacePath) - } - } - .task { - await self.onboardingWizard.startIfNeeded( - mode: self.state.connectionMode, - workspace: self.workspacePath.isEmpty ? nil : self.workspacePath) - } - } - } -} - -private struct OnboardingWizardCardContent: View { - @Bindable var wizard: OnboardingWizardModel - let mode: AppState.ConnectionMode - let workspacePath: String - - private enum CardState { - case error(String) - case starting - case step(WizardStep) - case complete - case waiting - } - - private var state: CardState { - if let error = wizard.errorMessage { return .error(error) } - if self.wizard.isStarting { return .starting } - if let step = wizard.currentStep { return .step(step) } - if self.wizard.isComplete { return .complete } - return .waiting - } - - var body: some View { - switch self.state { - case let .error(error): - Text("Wizard error") - .font(.headline) - Text(error) - .font(.subheadline) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - Button("Retry") { - self.wizard.reset() - Task { - await self.wizard.startIfNeeded( - mode: self.mode, - workspace: self.workspacePath.isEmpty ? nil : self.workspacePath) - } - } - .buttonStyle(.borderedProminent) - case .starting: - HStack(spacing: 8) { - ProgressView() - Text("Starting wizard…") - .foregroundStyle(.secondary) - } - case let .step(step): - OnboardingWizardStepView( - step: step, - isSubmitting: self.wizard.isSubmitting) - { value in - Task { await self.wizard.submit(step: step, value: value) } - } - .id(step.id) - case .complete: - Text("Wizard complete. Continue to the next step.") - .font(.headline) - case .waiting: - Text("Waiting for wizard…") - .foregroundStyle(.secondary) - } - } -} diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Workspace.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Workspace.swift deleted file mode 100644 index 1895b2af94f..00000000000 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Workspace.swift +++ /dev/null @@ -1,116 +0,0 @@ -import Foundation - -extension OnboardingView { - func loadWorkspaceDefaults() async { - guard self.workspacePath.isEmpty else { return } - let configured = await self.loadAgentWorkspace() - let url = AgentWorkspace.resolveWorkspaceURL(from: configured) - self.workspacePath = AgentWorkspace.displayPath(for: url) - self.refreshBootstrapStatus() - } - - func ensureDefaultWorkspace() async { - guard self.state.connectionMode == .local else { return } - let configured = await self.loadAgentWorkspace() - let url = AgentWorkspace.resolveWorkspaceURL(from: configured) - switch AgentWorkspace.bootstrapSafety(for: url) { - case .safe: - do { - _ = try AgentWorkspace.bootstrap(workspaceURL: url) - if (configured ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - await self.saveAgentWorkspace(AgentWorkspace.displayPath(for: url)) - } - } catch { - self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)" - } - case let .unsafe (reason): - self.workspaceStatus = "Workspace not touched: \(reason)" - } - self.refreshBootstrapStatus() - } - - func refreshBootstrapStatus() { - let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath) - self.needsBootstrap = AgentWorkspace.needsBootstrap(workspaceURL: url) - if self.needsBootstrap { - self.didAutoKickoff = false - } - } - - var workspaceBootstrapCommand: String { - let template = AgentWorkspace.defaultTemplate().trimmingCharacters(in: .whitespacesAndNewlines) - return """ - mkdir -p ~/.openclaw/workspace - cat > ~/.openclaw/workspace/AGENTS.md <<'EOF' - \(template) - EOF - """ - } - - func applyWorkspace() async { - guard !self.workspaceApplying else { return } - self.workspaceApplying = true - defer { self.workspaceApplying = false } - - do { - let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath) - if case let .unsafe (reason) = AgentWorkspace.bootstrapSafety(for: url) { - self.workspaceStatus = "Workspace not created: \(reason)" - return - } - _ = try AgentWorkspace.bootstrap(workspaceURL: url) - self.workspacePath = AgentWorkspace.displayPath(for: url) - self.workspaceStatus = "Workspace ready at \(self.workspacePath)" - self.refreshBootstrapStatus() - } catch { - self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)" - } - } - - private func loadAgentWorkspace() async -> String? { - let root = await ConfigStore.load() - let agents = root["agents"] as? [String: Any] - let defaults = agents?["defaults"] as? [String: Any] - return defaults?["workspace"] as? String - } - - @discardableResult - func saveAgentWorkspace(_ workspace: String?) async -> Bool { - let (success, errorMessage) = await OnboardingView.buildAndSaveWorkspace(workspace) - - if let errorMessage { - self.workspaceStatus = errorMessage - } - return success - } - - @MainActor - private static func buildAndSaveWorkspace(_ workspace: String?) async -> (Bool, String?) { - var root = await ConfigStore.load() - var agents = root["agents"] as? [String: Any] ?? [:] - var defaults = agents["defaults"] as? [String: Any] ?? [:] - let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if trimmed.isEmpty { - defaults.removeValue(forKey: "workspace") - } else { - defaults["workspace"] = trimmed - } - if defaults.isEmpty { - agents.removeValue(forKey: "defaults") - } else { - agents["defaults"] = defaults - } - if agents.isEmpty { - root.removeValue(forKey: "agents") - } else { - root["agents"] = agents - } - do { - try await ConfigStore.save(root) - return (true, nil) - } catch { - let errorMessage = "Failed to save config: \(error.localizedDescription)" - return (false, errorMessage) - } - } -} diff --git a/apps/macos/Sources/OpenClaw/OnboardingWidgets.swift b/apps/macos/Sources/OpenClaw/OnboardingWidgets.swift deleted file mode 100644 index 58d09ef66dc..00000000000 --- a/apps/macos/Sources/OpenClaw/OnboardingWidgets.swift +++ /dev/null @@ -1,65 +0,0 @@ -import AppKit -import SwiftUI - -struct GlowingOpenClawIcon: View { - @Environment(\.scenePhase) private var scenePhase - - let size: CGFloat - let glowIntensity: Double - let enableFloating: Bool - - @State private var breathe = false - - init(size: CGFloat = 148, glowIntensity: Double = 0.35, enableFloating: Bool = true) { - self.size = size - self.glowIntensity = glowIntensity - self.enableFloating = enableFloating - } - - var body: some View { - let glowBlurRadius: CGFloat = 18 - let glowCanvasSize: CGFloat = self.size + 56 - ZStack { - Circle() - .fill( - LinearGradient( - colors: [ - Color.accentColor.opacity(self.glowIntensity), - Color.blue.opacity(self.glowIntensity * 0.6), - ], - startPoint: .topLeading, - endPoint: .bottomTrailing)) - .frame(width: glowCanvasSize, height: glowCanvasSize) - .padding(glowBlurRadius) - .blur(radius: glowBlurRadius) - .scaleEffect(self.breathe ? 1.08 : 0.96) - .opacity(0.84) - - Image(nsImage: NSApp.applicationIconImage) - .resizable() - .frame(width: self.size, height: self.size) - .clipShape(RoundedRectangle(cornerRadius: self.size * 0.22, style: .continuous)) - .shadow(color: .black.opacity(0.18), radius: 14, y: 6) - .scaleEffect(self.breathe ? 1.02 : 1.0) - } - .frame( - width: glowCanvasSize + (glowBlurRadius * 2), - height: glowCanvasSize + (glowBlurRadius * 2)) - .onAppear { self.updateBreatheAnimation() } - .onDisappear { self.breathe = false } - .onChange(of: self.scenePhase) { _, _ in - self.updateBreatheAnimation() - } - } - - private func updateBreatheAnimation() { - guard self.enableFloating, self.scenePhase == .active else { - self.breathe = false - return - } - guard !self.breathe else { return } - withAnimation(Animation.easeInOut(duration: 3.6).repeatForever(autoreverses: true)) { - self.breathe = true - } - } -} diff --git a/apps/macos/Sources/OpenClaw/OnboardingWizard.swift b/apps/macos/Sources/OpenClaw/OnboardingWizard.swift deleted file mode 100644 index 75b9522a4d1..00000000000 --- a/apps/macos/Sources/OpenClaw/OnboardingWizard.swift +++ /dev/null @@ -1,419 +0,0 @@ -import Foundation -import Observation -import OpenClawKit -import OpenClawProtocol -import OSLog -import SwiftUI - -private let onboardingWizardLogger = Logger(subsystem: "ai.openclaw", category: "onboarding.wizard") - -// MARK: - Swift 6 AnyCodable Bridging Helpers - -// Bridge between OpenClawProtocol.AnyCodable and the local module to avoid -// Swift 6 strict concurrency type conflicts. - -private typealias ProtocolAnyCodable = OpenClawProtocol.AnyCodable - -private func bridgeToLocal(_ value: ProtocolAnyCodable) -> AnyCodable { - if let data = try? JSONEncoder().encode(value), - let decoded = try? JSONDecoder().decode(AnyCodable.self, from: data) - { - return decoded - } - return AnyCodable(value.value) -} - -private func bridgeToLocal(_ value: ProtocolAnyCodable?) -> AnyCodable? { - value.map(bridgeToLocal) -} - -@MainActor -@Observable -final class OnboardingWizardModel { - private(set) var sessionId: String? - private(set) var currentStep: WizardStep? - private(set) var status: String? - private(set) var errorMessage: String? - var isStarting = false - var isSubmitting = false - private var lastStartMode: AppState.ConnectionMode? - private var lastStartWorkspace: String? - private var restartAttempts = 0 - private let maxRestartAttempts = 1 - - var isComplete: Bool { - self.status == "done" - } - - var isRunning: Bool { - self.status == "running" - } - - func reset() { - self.sessionId = nil - self.currentStep = nil - self.status = nil - self.errorMessage = nil - self.isStarting = false - self.isSubmitting = false - self.restartAttempts = 0 - self.lastStartMode = nil - self.lastStartWorkspace = nil - } - - func startIfNeeded(mode: AppState.ConnectionMode, workspace: String? = nil) async { - guard self.sessionId == nil, !self.isStarting else { return } - guard mode == .local else { return } - if self.shouldSkipWizard() { - self.sessionId = nil - self.currentStep = nil - self.status = "done" - self.errorMessage = nil - return - } - self.isStarting = true - self.errorMessage = nil - self.lastStartMode = mode - self.lastStartWorkspace = workspace - defer { self.isStarting = false } - - do { - GatewayProcessManager.shared.setActive(true) - if await GatewayProcessManager.shared.waitForGatewayReady(timeout: 12) == false { - throw NSError( - domain: "Gateway", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Gateway did not become ready. Check that it is running."]) - } - var params: [String: AnyCodable] = ["mode": AnyCodable("local")] - if let workspace, !workspace.isEmpty { - params["workspace"] = AnyCodable(workspace) - } - let res: WizardStartResult = try await GatewayConnection.shared.requestDecoded( - method: .wizardStart, - params: params) - self.applyStartResult(res) - } catch { - self.status = "error" - self.errorMessage = error.localizedDescription - onboardingWizardLogger.error("start failed: \(error.localizedDescription, privacy: .public)") - } - } - - func submit(step: WizardStep, value: AnyCodable?) async { - guard let sessionId, !self.isSubmitting else { return } - self.isSubmitting = true - self.errorMessage = nil - defer { self.isSubmitting = false } - - do { - var params: [String: AnyCodable] = ["sessionId": AnyCodable(sessionId)] - var answer: [String: AnyCodable] = ["stepId": AnyCodable(step.id)] - if let value { - answer["value"] = value - } - params["answer"] = AnyCodable(answer) - let res: WizardNextResult = try await GatewayConnection.shared.requestDecoded( - method: .wizardNext, - params: params) - self.applyNextResult(res) - } catch { - if self.restartIfSessionLost(error: error) { - return - } - self.status = "error" - self.errorMessage = error.localizedDescription - onboardingWizardLogger.error("submit failed: \(error.localizedDescription, privacy: .public)") - } - } - - func cancelIfRunning() async { - guard let sessionId, self.isRunning else { return } - do { - let res: WizardStatusResult = try await GatewayConnection.shared.requestDecoded( - method: .wizardCancel, - params: ["sessionId": AnyCodable(sessionId)]) - self.applyStatusResult(res) - } catch { - self.status = "error" - self.errorMessage = error.localizedDescription - onboardingWizardLogger.error("cancel failed: \(error.localizedDescription, privacy: .public)") - } - } - - private func applyStartResult(_ res: WizardStartResult) { - self.sessionId = res.sessionid - self.status = wizardStatusString(res.status) ?? (res.done ? "done" : "running") - self.errorMessage = res.error - self.currentStep = decodeWizardStep(res.step) - if self.currentStep == nil, res.step != nil { - onboardingWizardLogger.error("wizard step decode failed") - } - if res.done { self.currentStep = nil } - self.restartAttempts = 0 - } - - private func applyNextResult(_ res: WizardNextResult) { - let status = wizardStatusString(res.status) - self.status = status ?? self.status - self.errorMessage = res.error - self.currentStep = decodeWizardStep(res.step) - if self.currentStep == nil, res.step != nil { - onboardingWizardLogger.error("wizard step decode failed") - } - if res.done { self.currentStep = nil } - if res.done || status == "done" || status == "cancelled" || status == "error" { - self.sessionId = nil - } - } - - private func applyStatusResult(_ res: WizardStatusResult) { - self.status = wizardStatusString(res.status) ?? "unknown" - self.errorMessage = res.error - self.currentStep = nil - self.sessionId = nil - } - - private func restartIfSessionLost(error: Error) -> Bool { - guard let gatewayError = error as? GatewayResponseError else { return false } - guard gatewayError.code == ErrorCode.invalidRequest.rawValue else { return false } - let message = gatewayError.message.lowercased() - guard message.contains("wizard not found") || message.contains("wizard not running") else { return false } - guard let mode = self.lastStartMode, self.restartAttempts < self.maxRestartAttempts else { - return false - } - self.restartAttempts += 1 - self.sessionId = nil - self.currentStep = nil - self.status = nil - self.errorMessage = "Wizard session lost. Restarting…" - Task { await self.startIfNeeded(mode: mode, workspace: self.lastStartWorkspace) } - return true - } - - private func shouldSkipWizard() -> Bool { - let root = OpenClawConfigFile.loadDict() - if let wizard = root["wizard"] as? [String: Any], !wizard.isEmpty { - return true - } - if let gateway = root["gateway"] as? [String: Any], - let auth = gateway["auth"] as? [String: Any] - { - if let mode = auth["mode"] as? String, - !mode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - return true - } - if let token = auth["token"] as? String, - !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - return true - } - if let password = auth["password"] as? String, - !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - return true - } - } - return false - } -} - -struct OnboardingWizardStepView: View { - let step: WizardStep - let isSubmitting: Bool - let onStepSubmit: (AnyCodable?) -> Void - - @State private var textValue: String - @State private var confirmValue: Bool - @State private var selectedIndex: Int - @State private var selectedIndices: Set - - private let optionItems: [WizardOptionItem] - - init(step: WizardStep, isSubmitting: Bool, onSubmit: @escaping (AnyCodable?) -> Void) { - self.step = step - self.isSubmitting = isSubmitting - self.onStepSubmit = onSubmit - let options = parseWizardOptions(step.options).enumerated().map { index, option in - WizardOptionItem(index: index, option: option) - } - self.optionItems = options - let initialText = anyCodableString(step.initialvalue) - let initialConfirm = anyCodableBool(step.initialvalue) - let initialIndex = options.firstIndex(where: { anyCodableEqual($0.option.value, step.initialvalue) }) ?? 0 - let initialMulti = Set( - options.filter { option in - anyCodableArray(step.initialvalue).contains { anyCodableEqual($0, option.option.value) } - }.map(\.index)) - - _textValue = State(initialValue: initialText) - _confirmValue = State(initialValue: initialConfirm) - _selectedIndex = State(initialValue: initialIndex) - _selectedIndices = State(initialValue: initialMulti) - } - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - if let title = step.title, !title.isEmpty { - Text(title) - .font(.title2.weight(.semibold)) - } - if let message = step.message, !message.isEmpty { - Text(message) - .font(.body) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - switch wizardStepType(self.step) { - case "note": - EmptyView() - case "text": - self.textField - case "confirm": - Toggle("", isOn: self.$confirmValue) - .toggleStyle(.switch) - case "select": - self.selectOptions - case "multiselect": - self.multiselectOptions - case "progress": - ProgressView() - .controlSize(.small) - case "action": - EmptyView() - default: - Text("Unsupported step type") - .foregroundStyle(.secondary) - } - - Button(action: self.submit) { - Text(wizardStepType(self.step) == "action" ? "Run" : "Continue") - .frame(minWidth: 120) - } - .buttonStyle(.borderedProminent) - .disabled(self.isSubmitting || self.isBlocked) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - - @ViewBuilder - private var textField: some View { - let isSensitive = self.step.sensitive == true - if isSensitive { - SecureField(self.step.placeholder ?? "", text: self.$textValue) - .textFieldStyle(.roundedBorder) - .frame(maxWidth: 360) - } else { - TextField(self.step.placeholder ?? "", text: self.$textValue) - .textFieldStyle(.roundedBorder) - .frame(maxWidth: 360) - } - } - - private var selectOptions: some View { - VStack(alignment: .leading, spacing: 8) { - ForEach(self.optionItems, id: \.index) { item in - self.selectOptionRow(item) - } - } - } - - private var multiselectOptions: some View { - VStack(alignment: .leading, spacing: 8) { - ForEach(self.optionItems, id: \.index) { item in - self.multiselectOptionRow(item) - } - } - } - - private func selectOptionRow(_ item: WizardOptionItem) -> some View { - Button { - self.selectedIndex = item.index - } label: { - HStack(alignment: .top, spacing: 8) { - Image(systemName: self.selectedIndex == item.index ? "largecircle.fill.circle" : "circle") - .foregroundStyle(Color.accentColor) - VStack(alignment: .leading, spacing: 2) { - Text(item.option.label) - .foregroundStyle(.primary) - if let hint = item.option.hint, !hint.isEmpty { - Text(hint) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - } - .buttonStyle(.plain) - } - - private func multiselectOptionRow(_ item: WizardOptionItem) -> some View { - Toggle(isOn: self.bindingForOption(item)) { - VStack(alignment: .leading, spacing: 2) { - Text(item.option.label) - if let hint = item.option.hint, !hint.isEmpty { - Text(hint) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - } - - private func bindingForOption(_ item: WizardOptionItem) -> Binding { - Binding(get: { - self.selectedIndices.contains(item.index) - }, set: { newValue in - if newValue { - self.selectedIndices.insert(item.index) - } else { - self.selectedIndices.remove(item.index) - } - }) - } - - private var isBlocked: Bool { - let type = wizardStepType(step) - if type == "select" { return self.optionItems.isEmpty } - if type == "multiselect" { return self.optionItems.isEmpty } - return false - } - - private func submit() { - switch wizardStepType(self.step) { - case "note", "progress": - self.onStepSubmit(nil) - case "text": - self.onStepSubmit(AnyCodable(self.textValue)) - case "confirm": - self.onStepSubmit(AnyCodable(self.confirmValue)) - case "select": - guard self.optionItems.indices.contains(self.selectedIndex) else { - self.onStepSubmit(nil) - return - } - let option = self.optionItems[self.selectedIndex].option - self.onStepSubmit(bridgeToLocal(option.value) ?? AnyCodable(option.label)) - case "multiselect": - let values = self.optionItems - .filter { self.selectedIndices.contains($0.index) } - .map { bridgeToLocal($0.option.value) ?? AnyCodable($0.option.label) } - self.onStepSubmit(AnyCodable(values)) - case "action": - self.onStepSubmit(AnyCodable(true)) - default: - self.onStepSubmit(nil) - } - } -} - -private struct WizardOptionItem: Identifiable { - let index: Int - let option: WizardOption - - var id: Int { - self.index - } -} diff --git a/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift b/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift deleted file mode 100644 index 35744baeda5..00000000000 --- a/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift +++ /dev/null @@ -1,373 +0,0 @@ -import Foundation -import OpenClawProtocol - -enum OpenClawConfigFile { - private static let logger = Logger(subsystem: "ai.openclaw", category: "config") - private static let configAuditFileName = "config-audit.jsonl" - - static func url() -> URL { - OpenClawPaths.configURL - } - - static func stateDirURL() -> URL { - OpenClawPaths.stateDirURL - } - - static func defaultWorkspaceURL() -> URL { - OpenClawPaths.workspaceURL - } - - static func loadDict() -> [String: Any] { - let url = self.url() - guard FileManager().fileExists(atPath: url.path) else { return [:] } - do { - let data = try Data(contentsOf: url) - guard let root = self.parseConfigData(data) else { - self.logger.warning("config JSON root invalid") - return [:] - } - return root - } catch { - self.logger.warning("config read failed: \(error.localizedDescription)") - return [:] - } - } - - static func saveDict(_ dict: [String: Any]) { - // Nix mode disables config writes in production, but tests rely on saving temp configs. - if ProcessInfo.processInfo.isNixMode, !ProcessInfo.processInfo.isRunningTests { return } - let url = self.url() - let previousData = try? Data(contentsOf: url) - let previousRoot = previousData.flatMap { self.parseConfigData($0) } - let previousBytes = previousData?.count - let hadMetaBefore = self.hasMeta(previousRoot) - let gatewayModeBefore = self.gatewayMode(previousRoot) - - var output = dict - self.stampMeta(&output) - - do { - let data = try JSONSerialization.data(withJSONObject: output, options: [.prettyPrinted, .sortedKeys]) - try FileManager().createDirectory( - at: url.deletingLastPathComponent(), - withIntermediateDirectories: true) - try data.write(to: url, options: [.atomic]) - let nextBytes = data.count - let gatewayModeAfter = self.gatewayMode(output) - let suspicious = self.configWriteSuspiciousReasons( - existsBefore: previousData != nil, - previousBytes: previousBytes, - nextBytes: nextBytes, - hadMetaBefore: hadMetaBefore, - gatewayModeBefore: gatewayModeBefore, - gatewayModeAfter: gatewayModeAfter) - if !suspicious.isEmpty { - self.logger.warning("config write anomaly (\(suspicious.joined(separator: ", "))) at \(url.path)") - } - self.appendConfigWriteAudit([ - "result": "success", - "configPath": url.path, - "existsBefore": previousData != nil, - "previousBytes": previousBytes ?? NSNull(), - "nextBytes": nextBytes, - "hasMetaBefore": hadMetaBefore, - "hasMetaAfter": self.hasMeta(output), - "gatewayModeBefore": gatewayModeBefore ?? NSNull(), - "gatewayModeAfter": gatewayModeAfter ?? NSNull(), - "suspicious": suspicious, - ]) - } catch { - self.logger.error("config save failed: \(error.localizedDescription)") - self.appendConfigWriteAudit([ - "result": "failed", - "configPath": url.path, - "existsBefore": previousData != nil, - "previousBytes": previousBytes ?? NSNull(), - "nextBytes": NSNull(), - "hasMetaBefore": hadMetaBefore, - "hasMetaAfter": self.hasMeta(output), - "gatewayModeBefore": gatewayModeBefore ?? NSNull(), - "gatewayModeAfter": self.gatewayMode(output) ?? NSNull(), - "suspicious": [], - "error": error.localizedDescription, - ]) - } - } - - static func loadGatewayDict() -> [String: Any] { - let root = self.loadDict() - return root["gateway"] as? [String: Any] ?? [:] - } - - static func updateGatewayDict(_ mutate: (inout [String: Any]) -> Void) { - var root = self.loadDict() - var gateway = root["gateway"] as? [String: Any] ?? [:] - mutate(&gateway) - if gateway.isEmpty { - root.removeValue(forKey: "gateway") - } else { - root["gateway"] = gateway - } - self.saveDict(root) - } - - static func browserControlEnabled(defaultValue: Bool = true) -> Bool { - let root = self.loadDict() - let browser = root["browser"] as? [String: Any] - return browser?["enabled"] as? Bool ?? defaultValue - } - - static func setBrowserControlEnabled(_ enabled: Bool) { - var root = self.loadDict() - var browser = root["browser"] as? [String: Any] ?? [:] - browser["enabled"] = enabled - root["browser"] = browser - self.saveDict(root) - self.logger.debug("browser control updated enabled=\(enabled)") - } - - static func agentWorkspace() -> String? { - let root = self.loadDict() - let agents = root["agents"] as? [String: Any] - let defaults = agents?["defaults"] as? [String: Any] - return defaults?["workspace"] as? String - } - - static func setAgentWorkspace(_ workspace: String?) { - var root = self.loadDict() - var agents = root["agents"] as? [String: Any] ?? [:] - var defaults = agents["defaults"] as? [String: Any] ?? [:] - let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if trimmed.isEmpty { - defaults.removeValue(forKey: "workspace") - } else { - defaults["workspace"] = trimmed - } - if defaults.isEmpty { - agents.removeValue(forKey: "defaults") - } else { - agents["defaults"] = defaults - } - if agents.isEmpty { - root.removeValue(forKey: "agents") - } else { - root["agents"] = agents - } - self.saveDict(root) - self.logger.debug("agents.defaults.workspace updated set=\(!trimmed.isEmpty)") - } - - static func gatewayPassword() -> String? { - let root = self.loadDict() - guard let gateway = root["gateway"] as? [String: Any], - let remote = gateway["remote"] as? [String: Any] - else { - return nil - } - return remote["password"] as? String - } - - static func gatewayPort() -> Int? { - let root = self.loadDict() - guard let gateway = root["gateway"] as? [String: Any] else { return nil } - if let port = gateway["port"] as? Int, port > 0 { return port } - if let number = gateway["port"] as? NSNumber, number.intValue > 0 { - return number.intValue - } - if let raw = gateway["port"] as? String, - let parsed = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines)), - parsed > 0 - { - return parsed - } - return nil - } - - static func remoteGatewayPort() -> Int? { - guard let url = self.remoteGatewayUrl(), - let port = url.port, - port > 0 - else { return nil } - return port - } - - static func remoteGatewayPort(matchingHost sshHost: String) -> Int? { - let trimmedSshHost = sshHost.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedSshHost.isEmpty, - let url = self.remoteGatewayUrl(), - let port = url.port, - port > 0, - let urlHost = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), - !urlHost.isEmpty - else { - return nil - } - - let sshKey = Self.hostKey(trimmedSshHost) - let urlKey = Self.hostKey(urlHost) - guard !sshKey.isEmpty, !urlKey.isEmpty, sshKey == urlKey else { return nil } - return port - } - - static func setRemoteGatewayUrl(host: String, port: Int?) { - guard let port, port > 0 else { return } - let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedHost.isEmpty else { return } - self.updateGatewayDict { gateway in - var remote = gateway["remote"] as? [String: Any] ?? [:] - let existingUrl = (remote["url"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let scheme = URL(string: existingUrl)?.scheme ?? "ws" - remote["url"] = "\(scheme)://\(trimmedHost):\(port)" - gateway["remote"] = remote - } - } - - static func clearRemoteGatewayUrl() { - self.updateGatewayDict { gateway in - guard var remote = gateway["remote"] as? [String: Any] else { return } - guard remote["url"] != nil else { return } - remote.removeValue(forKey: "url") - if remote.isEmpty { - gateway.removeValue(forKey: "remote") - } else { - gateway["remote"] = remote - } - } - } - - private static func remoteGatewayUrl() -> URL? { - let root = self.loadDict() - guard let gateway = root["gateway"] as? [String: Any], - let remote = gateway["remote"] as? [String: Any], - let raw = remote["url"] as? String - else { - return nil - } - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, let url = URL(string: trimmed) else { return nil } - return url - } - - private static func hostKey(_ host: String) -> String { - let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !trimmed.isEmpty else { return "" } - if trimmed.contains(":") { return trimmed } - let digits = CharacterSet(charactersIn: "0123456789.") - if trimmed.rangeOfCharacter(from: digits.inverted) == nil { - return trimmed - } - return trimmed.split(separator: ".").first.map(String.init) ?? trimmed - } - - private static func parseConfigData(_ data: Data) -> [String: Any]? { - if let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - return root - } - let decoder = JSONDecoder() - if #available(macOS 12.0, *) { - decoder.allowsJSON5 = true - } - if let decoded = try? decoder.decode([String: AnyCodable].self, from: data) { - self.logger.notice("config parsed with JSON5 decoder") - return decoded.mapValues { $0.foundationValue } - } - return nil - } - - private static func stampMeta(_ root: inout [String: Any]) { - var meta = root["meta"] as? [String: Any] ?? [:] - let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "macos-app" - meta["lastTouchedVersion"] = version - meta["lastTouchedAt"] = ISO8601DateFormatter().string(from: Date()) - root["meta"] = meta - } - - private static func hasMeta(_ root: [String: Any]?) -> Bool { - guard let root else { return false } - return root["meta"] is [String: Any] - } - - private static func hasMeta(_ root: [String: Any]) -> Bool { - root["meta"] is [String: Any] - } - - private static func gatewayMode(_ root: [String: Any]?) -> String? { - guard let root else { return nil } - return self.gatewayMode(root) - } - - private static func gatewayMode(_ root: [String: Any]) -> String? { - guard let gateway = root["gateway"] as? [String: Any], - let mode = gateway["mode"] as? String - else { return nil } - let trimmed = mode.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } - - private static func configWriteSuspiciousReasons( - existsBefore: Bool, - previousBytes: Int?, - nextBytes: Int, - hadMetaBefore: Bool, - gatewayModeBefore: String?, - gatewayModeAfter: String?) -> [String] - { - var reasons: [String] = [] - if !existsBefore { - return reasons - } - if let previousBytes, previousBytes >= 512, nextBytes < max(1, previousBytes / 2) { - reasons.append("size-drop:\(previousBytes)->\(nextBytes)") - } - if !hadMetaBefore { - reasons.append("missing-meta-before-write") - } - if gatewayModeBefore != nil, gatewayModeAfter == nil { - reasons.append("gateway-mode-removed") - } - return reasons - } - - private static func configAuditLogURL() -> URL { - self.stateDirURL() - .appendingPathComponent("logs", isDirectory: true) - .appendingPathComponent(self.configAuditFileName, isDirectory: false) - } - - private static func appendConfigWriteAudit(_ fields: [String: Any]) { - var record: [String: Any] = [ - "ts": ISO8601DateFormatter().string(from: Date()), - "source": "macos-openclaw-config-file", - "event": "config.write", - "pid": ProcessInfo.processInfo.processIdentifier, - "argv": Array(ProcessInfo.processInfo.arguments.prefix(8)), - ] - for (key, value) in fields { - record[key] = value is NSNull ? NSNull() : value - } - guard JSONSerialization.isValidJSONObject(record), - let data = try? JSONSerialization.data(withJSONObject: record) - else { - return - } - var line = Data() - line.append(data) - line.append(0x0A) - let logURL = self.configAuditLogURL() - do { - try FileManager().createDirectory( - at: logURL.deletingLastPathComponent(), - withIntermediateDirectories: true) - if !FileManager().fileExists(atPath: logURL.path) { - FileManager().createFile(atPath: logURL.path, contents: nil) - } - let handle = try FileHandle(forWritingTo: logURL) - defer { try? handle.close() } - try handle.seekToEnd() - try handle.write(contentsOf: line) - } catch { - // best-effort - } - } -} diff --git a/apps/macos/Sources/OpenClaw/OpenClawPaths.swift b/apps/macos/Sources/OpenClaw/OpenClawPaths.swift deleted file mode 100644 index 206031f9aa1..00000000000 --- a/apps/macos/Sources/OpenClaw/OpenClawPaths.swift +++ /dev/null @@ -1,53 +0,0 @@ -import Foundation - -enum OpenClawEnv { - static func path(_ key: String) -> String? { - // Normalize env overrides once so UI + file IO stay consistent. - guard let raw = getenv(key) else { return nil } - let value = String(cString: raw).trimmingCharacters(in: .whitespacesAndNewlines) - guard !value.isEmpty - else { - return nil - } - return value - } -} - -enum OpenClawPaths { - private static let configPathEnv = ["OPENCLAW_CONFIG_PATH"] - private static let stateDirEnv = ["OPENCLAW_STATE_DIR"] - - static var stateDirURL: URL { - for key in self.stateDirEnv { - if let override = OpenClawEnv.path(key) { - return URL(fileURLWithPath: override, isDirectory: true) - } - } - let home = FileManager().homeDirectoryForCurrentUser - return home.appendingPathComponent(".openclaw", isDirectory: true) - } - - private static func resolveConfigCandidate(in dir: URL) -> URL? { - let candidates = [ - dir.appendingPathComponent("openclaw.json"), - ] - return candidates.first(where: { FileManager().fileExists(atPath: $0.path) }) - } - - static var configURL: URL { - for key in self.configPathEnv { - if let override = OpenClawEnv.path(key) { - return URL(fileURLWithPath: override) - } - } - let stateDir = self.stateDirURL - if let existing = self.resolveConfigCandidate(in: stateDir) { - return existing - } - return stateDir.appendingPathComponent("openclaw.json") - } - - static var workspaceURL: URL { - self.stateDirURL.appendingPathComponent("workspace", isDirectory: true) - } -} diff --git a/apps/macos/Sources/OpenClaw/PairingAlertSupport.swift b/apps/macos/Sources/OpenClaw/PairingAlertSupport.swift deleted file mode 100644 index e8e4428bf3f..00000000000 --- a/apps/macos/Sources/OpenClaw/PairingAlertSupport.swift +++ /dev/null @@ -1,46 +0,0 @@ -import AppKit - -final class PairingAlertHostWindow: NSWindow { - override var canBecomeKey: Bool { - true - } - - override var canBecomeMain: Bool { - true - } -} - -@MainActor -enum PairingAlertSupport { - static func endActiveAlert(activeAlert: inout NSAlert?, activeRequestId: inout String?) { - guard let alert = activeAlert else { return } - if let parent = alert.window.sheetParent { - parent.endSheet(alert.window, returnCode: .abort) - } - activeAlert = nil - activeRequestId = nil - } - - static func requireAlertHostWindow(alertHostWindow: inout NSWindow?) -> NSWindow { - if let alertHostWindow { - return alertHostWindow - } - - let window = PairingAlertHostWindow( - contentRect: NSRect(x: 0, y: 0, width: 520, height: 1), - styleMask: [.borderless], - backing: .buffered, - defer: false) - window.title = "" - window.isReleasedWhenClosed = false - window.level = .floating - window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] - window.isOpaque = false - window.hasShadow = false - window.backgroundColor = .clear - window.ignoresMouseEvents = true - - alertHostWindow = window - return window - } -} diff --git a/apps/macos/Sources/OpenClaw/PeekabooBridgeHostCoordinator.swift b/apps/macos/Sources/OpenClaw/PeekabooBridgeHostCoordinator.swift deleted file mode 100644 index 9f97650b9f2..00000000000 --- a/apps/macos/Sources/OpenClaw/PeekabooBridgeHostCoordinator.swift +++ /dev/null @@ -1,137 +0,0 @@ -import Foundation -import os -import PeekabooAutomationKit -import PeekabooBridge -import PeekabooFoundation -import Security - -@MainActor -final class PeekabooBridgeHostCoordinator { - static let shared = PeekabooBridgeHostCoordinator() - - private let logger = Logger(subsystem: "ai.openclaw", category: "PeekabooBridge") - - private var host: PeekabooBridgeHost? - private var services: OpenClawPeekabooBridgeServices? - private static var openclawSocketPath: String { - let fileManager = FileManager.default - let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first - ?? fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support") - let directory = base.appendingPathComponent("OpenClaw", isDirectory: true) - return directory.appendingPathComponent(PeekabooBridgeConstants.socketName, isDirectory: false).path - } - - func setEnabled(_ enabled: Bool) async { - if enabled { - await self.startIfNeeded() - } else { - await self.stop() - } - } - - func stop() async { - guard let host else { return } - await host.stop() - self.host = nil - self.services = nil - self.logger.info("PeekabooBridge host stopped") - } - - private func startIfNeeded() async { - guard self.host == nil else { return } - - var allowlistedTeamIDs: Set = ["Y5PE65HELJ"] - if let teamID = Self.currentTeamID() { - allowlistedTeamIDs.insert(teamID) - } - let allowlistedBundles: Set = [] - - let services = OpenClawPeekabooBridgeServices() - let server = PeekabooBridgeServer( - services: services, - hostKind: .gui, - allowlistedTeams: allowlistedTeamIDs, - allowlistedBundles: allowlistedBundles) - - let host = PeekabooBridgeHost( - socketPath: Self.openclawSocketPath, - server: server, - allowedTeamIDs: allowlistedTeamIDs, - requestTimeoutSec: 10) - - self.services = services - self.host = host - - await host.start() - self.logger - .info("PeekabooBridge host started at \(Self.openclawSocketPath, privacy: .public)") - } - - private static func currentTeamID() -> String? { - var code: SecCode? - guard SecCodeCopySelf(SecCSFlags(), &code) == errSecSuccess, - let code - else { - return nil - } - - var staticCode: SecStaticCode? - guard SecCodeCopyStaticCode(code, SecCSFlags(), &staticCode) == errSecSuccess, - let staticCode - else { - return nil - } - - var infoCF: CFDictionary? - guard SecCodeCopySigningInformation( - staticCode, - SecCSFlags(rawValue: kSecCSSigningInformation), - &infoCF) == errSecSuccess, - let info = infoCF as? [String: Any] - else { - return nil - } - - return info[kSecCodeInfoTeamIdentifier as String] as? String - } -} - -@MainActor -private final class OpenClawPeekabooBridgeServices: PeekabooBridgeServiceProviding { - let permissions: PermissionsService - let screenCapture: any ScreenCaptureServiceProtocol - let automation: any UIAutomationServiceProtocol - let windows: any WindowManagementServiceProtocol - let applications: any ApplicationServiceProtocol - let menu: any MenuServiceProtocol - let dock: any DockServiceProtocol - let dialogs: any DialogServiceProtocol - let snapshots: any SnapshotManagerProtocol - - init() { - let logging = LoggingService(subsystem: "ai.openclaw.peekaboo") - let feedbackClient: any AutomationFeedbackClient = NoopAutomationFeedbackClient() - - let snapshots = InMemorySnapshotManager(options: .init( - snapshotValidityWindow: 600, - maxSnapshots: 50, - deleteArtifactsOnCleanup: false)) - let applications = ApplicationService(feedbackClient: feedbackClient) - - let screenCapture = ScreenCaptureService(loggingService: logging) - - self.permissions = PermissionsService() - self.snapshots = snapshots - self.applications = applications - self.screenCapture = screenCapture - self.automation = UIAutomationService( - snapshotManager: snapshots, - loggingService: logging, - searchPolicy: .balanced, - feedbackClient: feedbackClient) - self.windows = WindowManagementService(applicationService: applications, feedbackClient: feedbackClient) - self.menu = MenuService(applicationService: applications, feedbackClient: feedbackClient) - self.dock = DockService(feedbackClient: feedbackClient) - self.dialogs = DialogService(feedbackClient: feedbackClient) - } -} diff --git a/apps/macos/Sources/OpenClaw/PermissionManager.swift b/apps/macos/Sources/OpenClaw/PermissionManager.swift deleted file mode 100644 index b5bcd167a46..00000000000 --- a/apps/macos/Sources/OpenClaw/PermissionManager.swift +++ /dev/null @@ -1,506 +0,0 @@ -import AppKit -import ApplicationServices -import AVFoundation -import CoreGraphics -import CoreLocation -import Foundation -import Observation -import OpenClawIPC -import Speech -import UserNotifications - -enum PermissionManager { - static func isLocationAuthorized(status: CLAuthorizationStatus, requireAlways: Bool) -> Bool { - if requireAlways { return status == .authorizedAlways } - switch status { - case .authorizedAlways, .authorizedWhenInUse: - return true - case .authorized: // deprecated, but still shows up on some macOS versions - return true - default: - return false - } - } - - static func ensure(_ caps: [Capability], interactive: Bool) async -> [Capability: Bool] { - var results: [Capability: Bool] = [:] - for cap in caps { - results[cap] = await self.ensureCapability(cap, interactive: interactive) - } - return results - } - - private static func ensureCapability(_ cap: Capability, interactive: Bool) async -> Bool { - switch cap { - case .notifications: - await self.ensureNotifications(interactive: interactive) - case .appleScript: - await self.ensureAppleScript(interactive: interactive) - case .accessibility: - await self.ensureAccessibility(interactive: interactive) - case .screenRecording: - await self.ensureScreenRecording(interactive: interactive) - case .microphone: - await self.ensureMicrophone(interactive: interactive) - case .speechRecognition: - await self.ensureSpeechRecognition(interactive: interactive) - case .camera: - await self.ensureCamera(interactive: interactive) - case .location: - await self.ensureLocation(interactive: interactive) - } - } - - private static func ensureNotifications(interactive: Bool) async -> Bool { - let center = UNUserNotificationCenter.current() - let settings = await center.notificationSettings() - - switch settings.authorizationStatus { - case .authorized, .provisional, .ephemeral: - return true - case .notDetermined: - guard interactive else { return false } - let granted = await (try? center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false - let updated = await center.notificationSettings() - return granted && - (updated.authorizationStatus == .authorized || updated.authorizationStatus == .provisional) - case .denied: - if interactive { - NotificationPermissionHelper.openSettings() - } - return false - @unknown default: - return false - } - } - - private static func ensureAppleScript(interactive: Bool) async -> Bool { - let granted = await MainActor.run { AppleScriptPermission.isAuthorized() } - if interactive, !granted { - await AppleScriptPermission.requestAuthorization() - } - return await MainActor.run { AppleScriptPermission.isAuthorized() } - } - - private static func ensureAccessibility(interactive: Bool) async -> Bool { - let trusted = await MainActor.run { AXIsProcessTrusted() } - if interactive, !trusted { - await MainActor.run { - let opts: NSDictionary = ["AXTrustedCheckOptionPrompt": true] - _ = AXIsProcessTrustedWithOptions(opts) - } - } - return await MainActor.run { AXIsProcessTrusted() } - } - - private static func ensureScreenRecording(interactive: Bool) async -> Bool { - let granted = ScreenRecordingProbe.isAuthorized() - if interactive, !granted { - await ScreenRecordingProbe.requestAuthorization() - } - return ScreenRecordingProbe.isAuthorized() - } - - private static func ensureMicrophone(interactive: Bool) async -> Bool { - let status = AVCaptureDevice.authorizationStatus(for: .audio) - switch status { - case .authorized: - return true - case .notDetermined: - guard interactive else { return false } - return await AVCaptureDevice.requestAccess(for: .audio) - case .denied, .restricted: - if interactive { - MicrophonePermissionHelper.openSettings() - } - return false - @unknown default: - return false - } - } - - private static func ensureSpeechRecognition(interactive: Bool) async -> Bool { - let status = SFSpeechRecognizer.authorizationStatus() - if status == .notDetermined, interactive { - await withUnsafeContinuation { (cont: UnsafeContinuation) in - SFSpeechRecognizer.requestAuthorization { _ in - DispatchQueue.main.async { cont.resume() } - } - } - } - return SFSpeechRecognizer.authorizationStatus() == .authorized - } - - private static func ensureCamera(interactive: Bool) async -> Bool { - let status = AVCaptureDevice.authorizationStatus(for: .video) - switch status { - case .authorized: - return true - case .notDetermined: - guard interactive else { return false } - return await AVCaptureDevice.requestAccess(for: .video) - case .denied, .restricted: - if interactive { - CameraPermissionHelper.openSettings() - } - return false - @unknown default: - return false - } - } - - private static func ensureLocation(interactive: Bool) async -> Bool { - guard CLLocationManager.locationServicesEnabled() else { - if interactive { - await MainActor.run { LocationPermissionHelper.openSettings() } - } - return false - } - let status = CLLocationManager().authorizationStatus - switch status { - case .authorizedAlways, .authorizedWhenInUse, .authorized: - return true - case .notDetermined: - guard interactive else { return false } - let updated = await LocationPermissionRequester.shared.request(always: false) - return self.isLocationAuthorized(status: updated, requireAlways: false) - case .denied, .restricted: - if interactive { - await MainActor.run { LocationPermissionHelper.openSettings() } - } - return false - @unknown default: - return false - } - } - - static func voiceWakePermissionsGranted() -> Bool { - let mic = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized - let speech = SFSpeechRecognizer.authorizationStatus() == .authorized - return mic && speech - } - - static func ensureVoiceWakePermissions(interactive: Bool) async -> Bool { - let results = await self.ensure([.microphone, .speechRecognition], interactive: interactive) - return results[.microphone] == true && results[.speechRecognition] == true - } - - static func status(_ caps: [Capability] = Capability.allCases) async -> [Capability: Bool] { - var results: [Capability: Bool] = [:] - for cap in caps { - switch cap { - case .notifications: - let center = UNUserNotificationCenter.current() - let settings = await center.notificationSettings() - results[cap] = settings.authorizationStatus == .authorized - || settings.authorizationStatus == .provisional - - case .appleScript: - results[cap] = await MainActor.run { AppleScriptPermission.isAuthorized() } - - case .accessibility: - results[cap] = await MainActor.run { AXIsProcessTrusted() } - - case .screenRecording: - if #available(macOS 10.15, *) { - results[cap] = CGPreflightScreenCaptureAccess() - } else { - results[cap] = true - } - - case .microphone: - results[cap] = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized - - case .speechRecognition: - results[cap] = SFSpeechRecognizer.authorizationStatus() == .authorized - - case .camera: - results[cap] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized - - case .location: - let status = CLLocationManager().authorizationStatus - results[cap] = CLLocationManager.locationServicesEnabled() - && self.isLocationAuthorized(status: status, requireAlways: false) - } - } - return results - } -} - -enum NotificationPermissionHelper { - static func openSettings() { - let candidates = [ - "x-apple.systempreferences:com.apple.Notifications-Settings.extension", - "x-apple.systempreferences:com.apple.preference.notifications", - ] - - for candidate in candidates { - if let url = URL(string: candidate), NSWorkspace.shared.open(url) { - return - } - } - } -} - -enum MicrophonePermissionHelper { - static func openSettings() { - let candidates = [ - "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone", - "x-apple.systempreferences:com.apple.preference.security", - ] - - for candidate in candidates { - if let url = URL(string: candidate), NSWorkspace.shared.open(url) { - return - } - } - } -} - -enum CameraPermissionHelper { - static func openSettings() { - let candidates = [ - "x-apple.systempreferences:com.apple.preference.security?Privacy_Camera", - "x-apple.systempreferences:com.apple.preference.security", - ] - - for candidate in candidates { - if let url = URL(string: candidate), NSWorkspace.shared.open(url) { - return - } - } - } -} - -enum LocationPermissionHelper { - static func openSettings() { - let candidates = [ - "x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices", - "x-apple.systempreferences:com.apple.preference.security", - ] - - for candidate in candidates { - if let url = URL(string: candidate), NSWorkspace.shared.open(url) { - return - } - } - } -} - -@MainActor -final class LocationPermissionRequester: NSObject, CLLocationManagerDelegate { - static let shared = LocationPermissionRequester() - private let manager = CLLocationManager() - private var continuation: CheckedContinuation? - private var timeoutTask: Task? - - override init() { - super.init() - self.manager.delegate = self - } - - func request(always: Bool) async -> CLAuthorizationStatus { - let current = self.manager.authorizationStatus - if PermissionManager.isLocationAuthorized(status: current, requireAlways: always) { - return current - } - - return await withCheckedContinuation { cont in - self.continuation = cont - self.timeoutTask?.cancel() - self.timeoutTask = Task { [weak self] in - try? await Task.sleep(nanoseconds: 3_000_000_000) - await MainActor.run { [weak self] in - guard let self else { return } - guard self.continuation != nil else { return } - LocationPermissionHelper.openSettings() - self.finish(status: self.manager.authorizationStatus) - } - } - if always { - self.manager.requestAlwaysAuthorization() - } else { - self.manager.requestWhenInUseAuthorization() - } - - // On macOS, requesting an actual fix makes the prompt more reliable. - self.manager.requestLocation() - } - } - - private func finish(status: CLAuthorizationStatus) { - self.timeoutTask?.cancel() - self.timeoutTask = nil - guard let cont = self.continuation else { return } - self.continuation = nil - cont.resume(returning: status) - } - - /// nonisolated for Swift 6 strict concurrency compatibility - nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { - let status = manager.authorizationStatus - Task { @MainActor in - self.finish(status: status) - } - } - - /// Legacy callback (still used on some macOS versions / configurations). - nonisolated func locationManager( - _ manager: CLLocationManager, - didChangeAuthorization status: CLAuthorizationStatus) - { - Task { @MainActor in - self.finish(status: status) - } - } - - nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { - let status = manager.authorizationStatus - Task { @MainActor in - if status == .denied || status == .restricted { - LocationPermissionHelper.openSettings() - } - self.finish(status: status) - } - } - - nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { - let status = manager.authorizationStatus - Task { @MainActor in - self.finish(status: status) - } - } -} - -enum AppleScriptPermission { - private static let logger = Logger(subsystem: "ai.openclaw", category: "AppleScriptPermission") - - /// Sends a benign AppleScript to Terminal to verify Automation permission. - @MainActor - static func isAuthorized() -> Bool { - let script = """ - tell application "Terminal" - return "openclaw-ok" - end tell - """ - - var error: NSDictionary? - let appleScript = NSAppleScript(source: script) - let result = appleScript?.executeAndReturnError(&error) - - if let error, let code = error["NSAppleScriptErrorNumber"] as? Int { - if code == -1743 { // errAEEventWouldRequireUserConsent - Self.logger.debug("AppleScript permission denied (-1743)") - return false - } - Self.logger.debug("AppleScript check failed with code \(code)") - } - - return result != nil - } - - /// Triggers the TCC prompt and opens System Settings → Privacy & Security → Automation. - @MainActor - static func requestAuthorization() async { - _ = self.isAuthorized() // first attempt triggers the dialog if not granted - - // Open the Automation pane to help the user if the prompt was dismissed. - let urlStrings = [ - "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation", - "x-apple.systempreferences:com.apple.preference.security", - ] - - for candidate in urlStrings { - if let url = URL(string: candidate), NSWorkspace.shared.open(url) { - break - } - } - } -} - -@MainActor -@Observable -final class PermissionMonitor { - static let shared = PermissionMonitor() - - private(set) var status: [Capability: Bool] = [:] - - private var monitorTimer: Timer? - private var isChecking = false - private var registrations = 0 - private var lastCheck: Date? - private let minimumCheckInterval: TimeInterval = 0.5 - - func register() { - self.registrations += 1 - if self.registrations == 1 { - self.startMonitoring() - } - } - - func unregister() { - guard self.registrations > 0 else { return } - self.registrations -= 1 - if self.registrations == 0 { - self.stopMonitoring() - } - } - - func refreshNow() async { - await self.checkStatus(force: true) - } - - private func startMonitoring() { - Task { await self.checkStatus(force: true) } - - if ProcessInfo.processInfo.isRunningTests { - return - } - self.monitorTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in - guard let self else { return } - Task { @MainActor in - await self.checkStatus(force: false) - } - } - } - - private func stopMonitoring() { - self.monitorTimer?.invalidate() - self.monitorTimer = nil - self.lastCheck = nil - } - - private func checkStatus(force: Bool) async { - if self.isChecking { return } - let now = Date() - if !force, let lastCheck, now.timeIntervalSince(lastCheck) < self.minimumCheckInterval { - return - } - - self.isChecking = true - - let latest = await PermissionManager.status() - if latest != self.status { - self.status = latest - } - self.lastCheck = Date() - - self.isChecking = false - } -} - -enum ScreenRecordingProbe { - static func isAuthorized() -> Bool { - if #available(macOS 10.15, *) { - return CGPreflightScreenCaptureAccess() - } - return true - } - - @MainActor - static func requestAuthorization() async { - if #available(macOS 10.15, *) { - _ = CGRequestScreenCaptureAccess() - } - } -} diff --git a/apps/macos/Sources/OpenClaw/PermissionsSettings.swift b/apps/macos/Sources/OpenClaw/PermissionsSettings.swift deleted file mode 100644 index de15e5ebb63..00000000000 --- a/apps/macos/Sources/OpenClaw/PermissionsSettings.swift +++ /dev/null @@ -1,229 +0,0 @@ -import CoreLocation -import OpenClawIPC -import OpenClawKit -import SwiftUI - -struct PermissionsSettings: View { - let status: [Capability: Bool] - let refresh: () async -> Void - let showOnboarding: () -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 14) { - SystemRunSettingsView() - - Text("Allow these so OpenClaw can notify and capture when needed.") - .padding(.top, 4) - - PermissionStatusList(status: self.status, refresh: self.refresh) - .padding(.horizontal, 2) - .padding(.vertical, 6) - - LocationAccessSettings() - - Button("Restart onboarding") { self.showOnboarding() } - .buttonStyle(.bordered) - Spacer() - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 12) - } -} - -private struct LocationAccessSettings: View { - @AppStorage(locationModeKey) private var locationModeRaw: String = OpenClawLocationMode.off.rawValue - @AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true - @State private var lastLocationModeRaw: String = OpenClawLocationMode.off.rawValue - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - Text("Location Access") - .font(.body) - - Picker("", selection: self.$locationModeRaw) { - Text("Off").tag(OpenClawLocationMode.off.rawValue) - Text("While Using").tag(OpenClawLocationMode.whileUsing.rawValue) - Text("Always").tag(OpenClawLocationMode.always.rawValue) - } - .labelsHidden() - .pickerStyle(.menu) - - Toggle("Precise Location", isOn: self.$locationPreciseEnabled) - .disabled(self.locationMode == .off) - - Text("Always may require System Settings to approve background location.") - .font(.footnote) - .foregroundStyle(.tertiary) - .fixedSize(horizontal: false, vertical: true) - } - .onAppear { - self.lastLocationModeRaw = self.locationModeRaw - } - .onChange(of: self.locationModeRaw) { _, newValue in - let previous = self.lastLocationModeRaw - self.lastLocationModeRaw = newValue - guard let mode = OpenClawLocationMode(rawValue: newValue) else { return } - Task { - let granted = await self.requestLocationAuthorization(mode: mode) - if !granted { - await MainActor.run { - self.locationModeRaw = previous - self.lastLocationModeRaw = previous - } - } - } - } - } - - private var locationMode: OpenClawLocationMode { - OpenClawLocationMode(rawValue: self.locationModeRaw) ?? .off - } - - private func requestLocationAuthorization(mode: OpenClawLocationMode) async -> Bool { - guard mode != .off else { return true } - guard CLLocationManager.locationServicesEnabled() else { - await MainActor.run { LocationPermissionHelper.openSettings() } - return false - } - - let status = CLLocationManager().authorizationStatus - let requireAlways = mode == .always - if PermissionManager.isLocationAuthorized(status: status, requireAlways: requireAlways) { - return true - } - let updated = await LocationPermissionRequester.shared.request(always: requireAlways) - return PermissionManager.isLocationAuthorized(status: updated, requireAlways: requireAlways) - } -} - -struct PermissionStatusList: View { - let status: [Capability: Bool] - let refresh: () async -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - ForEach(Capability.allCases, id: \.self) { cap in - PermissionRow(capability: cap, status: self.status[cap] ?? false) { - Task { await self.handle(cap) } - } - } - Button { - Task { await self.refresh() } - } label: { - Label("Refresh", systemImage: "arrow.clockwise") - } - .buttonStyle(.bordered) - .controlSize(.small) - .font(.footnote) - .padding(.top, 2) - .help("Refresh status") - } - } - - @MainActor - private func handle(_ cap: Capability) async { - _ = await PermissionManager.ensure([cap], interactive: true) - await self.refresh() - } -} - -struct PermissionRow: View { - let capability: Capability - let status: Bool - let compact: Bool - let action: () -> Void - - init(capability: Capability, status: Bool, compact: Bool = false, action: @escaping () -> Void) { - self.capability = capability - self.status = status - self.compact = compact - self.action = action - } - - var body: some View { - HStack(spacing: self.compact ? 10 : 12) { - ZStack { - Circle().fill(self.status ? Color.green.opacity(0.2) : Color.gray.opacity(0.15)) - .frame(width: self.iconSize, height: self.iconSize) - Image(systemName: self.icon) - .foregroundStyle(self.status ? Color.green : Color.secondary) - } - VStack(alignment: .leading, spacing: 2) { - Text(self.title).font(.body.weight(.semibold)) - Text(self.subtitle).font(.caption).foregroundStyle(.secondary) - } - Spacer() - if self.status { - Label("Granted", systemImage: "checkmark.circle.fill") - .foregroundStyle(.green) - } else { - Button("Grant") { self.action() } - .buttonStyle(.bordered) - } - } - .padding(.vertical, self.compact ? 4 : 6) - } - - private var iconSize: CGFloat { - self.compact ? 28 : 32 - } - - private var title: String { - switch self.capability { - case .appleScript: "Automation (AppleScript)" - case .notifications: "Notifications" - case .accessibility: "Accessibility" - case .screenRecording: "Screen Recording" - case .microphone: "Microphone" - case .speechRecognition: "Speech Recognition" - case .camera: "Camera" - case .location: "Location" - } - } - - private var subtitle: String { - switch self.capability { - case .appleScript: - "Control other apps (e.g. Terminal) for automation actions" - case .notifications: "Show desktop alerts for agent activity" - case .accessibility: "Control UI elements when an action requires it" - case .screenRecording: "Capture the screen for context or screenshots" - case .microphone: "Allow Voice Wake and audio capture" - case .speechRecognition: "Transcribe Voice Wake trigger phrases on-device" - case .camera: "Capture photos and video from the camera" - case .location: "Share location when requested by the agent" - } - } - - private var icon: String { - switch self.capability { - case .appleScript: "applescript" - case .notifications: "bell" - case .accessibility: "hand.raised" - case .screenRecording: "display" - case .microphone: "mic" - case .speechRecognition: "waveform" - case .camera: "camera" - case .location: "location" - } - } -} - -#if DEBUG -struct PermissionsSettings_Previews: PreviewProvider { - static var previews: some View { - PermissionsSettings( - status: [ - .appleScript: true, - .notifications: true, - .accessibility: false, - .screenRecording: false, - .microphone: true, - .speechRecognition: false, - ], - refresh: {}, - showOnboarding: {}) - .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) - } -} -#endif diff --git a/apps/macos/Sources/OpenClaw/PointingHandCursor.swift b/apps/macos/Sources/OpenClaw/PointingHandCursor.swift deleted file mode 100644 index ceb6fb6f81d..00000000000 --- a/apps/macos/Sources/OpenClaw/PointingHandCursor.swift +++ /dev/null @@ -1,30 +0,0 @@ -import AppKit -import SwiftUI - -private struct PointingHandCursorModifier: ViewModifier { - @State private var isHovering = false - - func body(content: Content) -> some View { - content - .onHover { hovering in - guard hovering != self.isHovering else { return } - self.isHovering = hovering - if hovering { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() - } - } - .onDisappear { - guard self.isHovering else { return } - self.isHovering = false - NSCursor.pop() - } - } -} - -extension View { - func pointingHandCursor() -> some View { - self.modifier(PointingHandCursorModifier()) - } -} diff --git a/apps/macos/Sources/OpenClaw/PortGuardian.swift b/apps/macos/Sources/OpenClaw/PortGuardian.swift deleted file mode 100644 index 7ab7e8def3f..00000000000 --- a/apps/macos/Sources/OpenClaw/PortGuardian.swift +++ /dev/null @@ -1,422 +0,0 @@ -import Foundation -import OSLog -#if canImport(Darwin) -import Darwin -#endif - -actor PortGuardian { - static let shared = PortGuardian() - - struct Record: Codable { - let port: Int - let pid: Int32 - let command: String - let mode: String - let timestamp: TimeInterval - } - - struct Descriptor: Sendable { - let pid: Int32 - let command: String - let executablePath: String? - } - - private var records: [Record] = [] - private let logger = Logger(subsystem: "ai.openclaw", category: "portguard") - private nonisolated static let appSupportDir: URL = { - let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first! - return base.appendingPathComponent("OpenClaw", isDirectory: true) - }() - - private nonisolated static var recordPath: URL { - self.appSupportDir.appendingPathComponent("port-guard.json", isDirectory: false) - } - - init() { - self.records = Self.loadRecords(from: Self.recordPath) - } - - func sweep(mode: AppState.ConnectionMode) async { - self.logger.info("port sweep starting (mode=\(mode.rawValue, privacy: .public))") - guard mode != .unconfigured else { - self.logger.info("port sweep skipped (mode=unconfigured)") - return - } - let ports = [GatewayEnvironment.gatewayPort()] - for port in ports { - let listeners = await self.listeners(on: port) - guard !listeners.isEmpty else { continue } - for listener in listeners { - if self.isExpected(listener, port: port, mode: mode) { - let message = """ - port \(port) already served by expected \(listener.command) - (pid \(listener.pid)) — keeping - """ - self.logger.info("\(message, privacy: .public)") - continue - } - let killed = await self.kill(listener.pid) - if killed { - let message = """ - port \(port) was held by \(listener.command) - (pid \(listener.pid)); terminated - """ - self.logger.error("\(message, privacy: .public)") - } else { - self.logger.error("failed to terminate pid \(listener.pid) on port \(port, privacy: .public)") - } - } - } - self.logger.info("port sweep done") - } - - func record(port: Int, pid: Int32, command: String, mode: AppState.ConnectionMode) async { - try? FileManager().createDirectory(at: Self.appSupportDir, withIntermediateDirectories: true) - self.records.removeAll { $0.pid == pid } - self.records.append( - Record( - port: port, - pid: pid, - command: command, - mode: mode.rawValue, - timestamp: Date().timeIntervalSince1970)) - self.save() - } - - func removeRecord(pid: Int32) { - let before = self.records.count - self.records.removeAll { $0.pid == pid } - if self.records.count != before { - self.save() - } - } - - struct PortReport: Identifiable { - enum Status { - case ok(String) - case missing(String) - case interference(String, offenders: [ReportListener]) - } - - let port: Int - let expected: String - let status: Status - let listeners: [ReportListener] - - var id: Int { - self.port - } - - var offenders: [ReportListener] { - if case let .interference(_, offenders) = self.status { return offenders } - return [] - } - - var summary: String { - switch self.status { - case let .ok(text): text - case let .missing(text): text - case let .interference(text, _): text - } - } - } - - func describe(port: Int) async -> Descriptor? { - guard let listener = await self.listeners(on: port).first else { return nil } - let path = Self.executablePath(for: listener.pid) - return Descriptor(pid: listener.pid, command: listener.command, executablePath: path) - } - - // MARK: - Internals - - private struct Listener { - let pid: Int32 - let command: String - let fullCommand: String - let user: String? - } - - struct ReportListener: Identifiable { - let pid: Int32 - let command: String - let fullCommand: String - let user: String? - let expected: Bool - - var id: Int32 { - self.pid - } - } - - func diagnose(mode: AppState.ConnectionMode) async -> [PortReport] { - if mode == .unconfigured { - return [] - } - let ports = [GatewayEnvironment.gatewayPort()] - var reports: [PortReport] = [] - - for port in ports { - let listeners = await self.listeners(on: port) - let tunnelHealthy = await self.probeGatewayHealthIfNeeded( - port: port, - mode: mode, - listeners: listeners) - reports.append(Self.buildReport( - port: port, - listeners: listeners, - mode: mode, - tunnelHealthy: tunnelHealthy)) - } - - return reports - } - - func probeGatewayHealth(port: Int, timeout: TimeInterval = 2.0) async -> Bool { - let url = URL(string: "http://127.0.0.1:\(port)/")! - let config = URLSessionConfiguration.ephemeral - config.timeoutIntervalForRequest = timeout - config.timeoutIntervalForResource = timeout - let session = URLSession(configuration: config) - var request = URLRequest(url: url) - request.cachePolicy = .reloadIgnoringLocalCacheData - request.timeoutInterval = timeout - do { - let (_, response) = try await session.data(for: request) - return response is HTTPURLResponse - } catch { - return false - } - } - - func isListening(port: Int, pid: Int32? = nil) async -> Bool { - let listeners = await self.listeners(on: port) - if let pid { - return listeners.contains(where: { $0.pid == pid }) - } - return !listeners.isEmpty - } - - private func listeners(on port: Int) async -> [Listener] { - let res = await ShellExecutor.run( - command: ["lsof", "-nP", "-iTCP:\(port)", "-sTCP:LISTEN", "-Fpcn"], - cwd: nil, - env: nil, - timeout: 5) - guard res.ok, let data = res.payload, !data.isEmpty else { return [] } - let text = String(data: data, encoding: .utf8) ?? "" - return Self.parseListeners(from: text) - } - - private static func readFullCommand(pid: Int32) -> String? { - let proc = Process() - proc.executableURL = URL(fileURLWithPath: "/bin/ps") - proc.arguments = ["-p", "\(pid)", "-o", "command="] - let pipe = Pipe() - proc.standardOutput = pipe - proc.standardError = Pipe() - do { - let data = try proc.runAndReadToEnd(from: pipe) - guard !data.isEmpty else { return nil } - return String(data: data, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines) - } catch { - return nil - } - } - - private static func parseListeners(from text: String) -> [Listener] { - var listeners: [Listener] = [] - var currentPid: Int32? - var currentCmd: String? - var currentUser: String? - - func flush() { - if let pid = currentPid, let cmd = currentCmd { - let full = Self.readFullCommand(pid: pid) ?? cmd - listeners.append(Listener(pid: pid, command: cmd, fullCommand: full, user: currentUser)) - } - currentPid = nil - currentCmd = nil - currentUser = nil - } - - for line in text.split(separator: "\n") { - guard let prefix = line.first else { continue } - let value = String(line.dropFirst()) - switch prefix { - case "p": - flush() - currentPid = Int32(value) ?? 0 - case "c": - currentCmd = value - case "u": - currentUser = value - default: - continue - } - } - flush() - return listeners - } - - private static func buildReport( - port: Int, - listeners: [Listener], - mode: AppState.ConnectionMode, - tunnelHealthy: Bool?) -> PortReport - { - let expectedDesc: String - let okPredicate: (Listener) -> Bool - let expectedCommands = ["node", "openclaw", "tsx", "pnpm", "bun"] - - switch mode { - case .remote: - expectedDesc = "SSH tunnel to remote gateway" - okPredicate = { $0.command.lowercased().contains("ssh") } - case .local: - expectedDesc = "Gateway websocket (node/tsx)" - okPredicate = { listener in - let c = listener.command.lowercased() - return expectedCommands.contains { c.contains($0) } - } - case .unconfigured: - expectedDesc = "Gateway not configured" - okPredicate = { _ in false } - } - - if listeners.isEmpty { - let text = "Nothing is listening on \(port) (\(expectedDesc))." - return .init(port: port, expected: expectedDesc, status: .missing(text), listeners: []) - } - - let tunnelUnhealthy = - mode == .remote && port == GatewayEnvironment.gatewayPort() && tunnelHealthy == false - let reportListeners = listeners.map { listener in - var expected = okPredicate(listener) - if tunnelUnhealthy, expected { expected = false } - return ReportListener( - pid: listener.pid, - command: listener.command, - fullCommand: listener.fullCommand, - user: listener.user, - expected: expected) - } - - let offenders = reportListeners.filter { !$0.expected } - if tunnelUnhealthy { - let list = listeners.map { "\($0.command) (\($0.pid))" }.joined(separator: ", ") - let reason = "Port \(port) is served by \(list), but the SSH tunnel is unhealthy." - return .init( - port: port, - expected: expectedDesc, - status: .interference(reason, offenders: offenders), - listeners: reportListeners) - } - if offenders.isEmpty { - let list = listeners.map { "\($0.command) (\($0.pid))" }.joined(separator: ", ") - let okText = "Port \(port) is served by \(list)." - return .init( - port: port, - expected: expectedDesc, - status: .ok(okText), - listeners: reportListeners) - } - - let list = offenders.map { "\($0.command) (\($0.pid))" }.joined(separator: ", ") - let reason = "Port \(port) is held by \(list), expected \(expectedDesc)." - return .init( - port: port, - expected: expectedDesc, - status: .interference(reason, offenders: offenders), - listeners: reportListeners) - } - - private static func executablePath(for pid: Int32) -> String? { - #if canImport(Darwin) - var buffer = [CChar](repeating: 0, count: Int(PATH_MAX)) - let length = proc_pidpath(pid, &buffer, UInt32(buffer.count)) - guard length > 0 else { return nil } - // Drop trailing null and decode as UTF-8. - let trimmed = buffer.prefix { $0 != 0 } - let bytes = trimmed.map { UInt8(bitPattern: $0) } - return String(bytes: bytes, encoding: .utf8) - #else - return nil - #endif - } - - private func kill(_ pid: Int32) async -> Bool { - let term = await ShellExecutor.run(command: ["kill", "-TERM", "\(pid)"], cwd: nil, env: nil, timeout: 2) - if term.ok { return true } - let sigkill = await ShellExecutor.run(command: ["kill", "-KILL", "\(pid)"], cwd: nil, env: nil, timeout: 2) - return sigkill.ok - } - - private func isExpected(_ listener: Listener, port: Int, mode: AppState.ConnectionMode) -> Bool { - let cmd = listener.command.lowercased() - let full = listener.fullCommand.lowercased() - switch mode { - case .remote: - // Remote mode expects an SSH tunnel for the gateway WebSocket port. - if port == GatewayEnvironment.gatewayPort() { return cmd.contains("ssh") } - return false - case .local: - // The gateway daemon may listen as `openclaw` or as its runtime (`node`, `bun`, etc). - if full.contains("gateway-daemon") { return true } - // If args are unavailable, treat a CLI listener as expected. - if cmd.contains("openclaw"), full == cmd { return true } - return false - case .unconfigured: - return false - } - } - - private func probeGatewayHealthIfNeeded( - port: Int, - mode: AppState.ConnectionMode, - listeners: [Listener]) async -> Bool? - { - guard mode == .remote, port == GatewayEnvironment.gatewayPort(), !listeners.isEmpty else { return nil } - let hasSsh = listeners.contains { $0.command.lowercased().contains("ssh") } - guard hasSsh else { return nil } - return await self.probeGatewayHealth(port: port) - } - - private static func loadRecords(from url: URL) -> [Record] { - guard let data = try? Data(contentsOf: url), - let decoded = try? JSONDecoder().decode([Record].self, from: data) - else { return [] } - return decoded - } - - private func save() { - guard let data = try? JSONEncoder().encode(self.records) else { return } - try? data.write(to: Self.recordPath, options: [.atomic]) - } -} - -#if DEBUG -extension PortGuardian { - static func _testParseListeners(_ text: String) -> [( - pid: Int32, - command: String, - fullCommand: String, - user: String?)] - { - self.parseListeners(from: text).map { ($0.pid, $0.command, $0.fullCommand, $0.user) } - } - - static func _testBuildReport( - port: Int, - mode: AppState.ConnectionMode, - listeners: [(pid: Int32, command: String, fullCommand: String, user: String?)]) -> PortReport - { - let mapped = listeners.map { Listener( - pid: $0.pid, - command: $0.command, - fullCommand: $0.fullCommand, - user: $0.user) } - return Self.buildReport(port: port, listeners: mapped, mode: mode, tunnelHealthy: nil) - } -} -#endif diff --git a/apps/macos/Sources/OpenClaw/PresenceReporter.swift b/apps/macos/Sources/OpenClaw/PresenceReporter.swift deleted file mode 100644 index 2e7a1d4c472..00000000000 --- a/apps/macos/Sources/OpenClaw/PresenceReporter.swift +++ /dev/null @@ -1,114 +0,0 @@ -import Cocoa -import Foundation -import OSLog - -@MainActor -final class PresenceReporter { - static let shared = PresenceReporter() - - private let logger = Logger(subsystem: "ai.openclaw", category: "presence") - private var task: Task? - private let interval: TimeInterval = 180 // a few minutes - private let instanceId: String = InstanceIdentity.instanceId - - func start() { - guard self.task == nil else { return } - self.task = Task.detached { [weak self] in - guard let self else { return } - await self.push(reason: "launch") - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000)) - await self.push(reason: "periodic") - } - } - } - - func stop() { - self.task?.cancel() - self.task = nil - } - - @Sendable - private func push(reason: String) async { - let mode = await MainActor.run { AppStateStore.shared.connectionMode.rawValue } - let host = InstanceIdentity.displayName - let ip = SystemPresenceInfo.primaryIPv4Address() ?? "ip-unknown" - let version = Self.appVersionString() - let platform = Self.platformString() - let lastInput = SystemPresenceInfo.lastInputSeconds() - let text = Self.composePresenceSummary(mode: mode, reason: reason) - var params: [String: AnyHashable] = [ - "instanceId": AnyHashable(self.instanceId), - "host": AnyHashable(host), - "ip": AnyHashable(ip), - "mode": AnyHashable(mode), - "version": AnyHashable(version), - "platform": AnyHashable(platform), - "deviceFamily": AnyHashable("Mac"), - "reason": AnyHashable(reason), - ] - if let model = InstanceIdentity.modelIdentifier { params["modelIdentifier"] = AnyHashable(model) } - if let lastInput { params["lastInputSeconds"] = AnyHashable(lastInput) } - do { - try await ControlChannel.shared.sendSystemEvent(text, params: params) - } catch { - self.logger.error("presence send failed: \(error.localizedDescription, privacy: .public)") - } - } - - /// Fire an immediate presence beacon (e.g., right after connecting). - func sendImmediate(reason: String = "connect") { - Task { await self.push(reason: reason) } - } - - private static func composePresenceSummary(mode: String, reason: String) -> String { - let host = InstanceIdentity.displayName - let ip = SystemPresenceInfo.primaryIPv4Address() ?? "ip-unknown" - let version = Self.appVersionString() - let lastInput = SystemPresenceInfo.lastInputSeconds() - let lastLabel = lastInput.map { "last input \($0)s ago" } ?? "last input unknown" - return "Node: \(host) (\(ip)) · app \(version) · \(lastLabel) · mode \(mode) · reason \(reason)" - } - - private static func appVersionString() -> String { - let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "dev" - if let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String { - let trimmed = build.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty, trimmed != version { - return "\(version) (\(trimmed))" - } - } - return version - } - - private static func platformString() -> String { - let v = ProcessInfo.processInfo.operatingSystemVersion - return "macos \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" - } - - // (SystemPresenceInfo) last input + primary IPv4. -} - -#if DEBUG -extension PresenceReporter { - static func _testComposePresenceSummary(mode: String, reason: String) -> String { - self.composePresenceSummary(mode: mode, reason: reason) - } - - static func _testAppVersionString() -> String { - self.appVersionString() - } - - static func _testPlatformString() -> String { - self.platformString() - } - - static func _testLastInputSeconds() -> Int? { - SystemPresenceInfo.lastInputSeconds() - } - - static func _testPrimaryIPv4Address() -> String? { - SystemPresenceInfo.primaryIPv4Address() - } -} -#endif diff --git a/apps/macos/Sources/OpenClaw/Process+PipeRead.swift b/apps/macos/Sources/OpenClaw/Process+PipeRead.swift deleted file mode 100644 index 7c0f7fe0ca3..00000000000 --- a/apps/macos/Sources/OpenClaw/Process+PipeRead.swift +++ /dev/null @@ -1,11 +0,0 @@ -import Foundation - -extension Process { - /// Runs the process and drains the given pipe before waiting to avoid blocking on full buffers. - func runAndReadToEnd(from pipe: Pipe) throws -> Data { - try self.run() - let data = pipe.fileHandleForReading.readToEndSafely() - self.waitUntilExit() - return data - } -} diff --git a/apps/macos/Sources/OpenClaw/ProcessInfo+OpenClaw.swift b/apps/macos/Sources/OpenClaw/ProcessInfo+OpenClaw.swift deleted file mode 100644 index a219f495336..00000000000 --- a/apps/macos/Sources/OpenClaw/ProcessInfo+OpenClaw.swift +++ /dev/null @@ -1,48 +0,0 @@ -import Foundation - -extension ProcessInfo { - var isPreview: Bool { - guard let raw = getenv("XCODE_RUNNING_FOR_PREVIEWS") else { return false } - 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 { - 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 { - // SwiftPM tests load one or more `.xctest` bundles. With Swift Testing, `Bundle.main` is not - // guaranteed to be the `.xctest` bundle, so check all loaded bundles. - if Bundle.allBundles.contains(where: { $0.bundleURL.pathExtension == "xctest" }) { return true } - if Bundle.main.bundleURL.pathExtension == "xctest" { return true } - - // Backwards-compatible fallbacks for runners that still set XCTest env vars. - return self.environment["XCTestConfigurationFilePath"] != nil - || self.environment["XCTestBundlePath"] != nil - || self.environment["XCTestSessionIdentifier"] != nil - } -} diff --git a/apps/macos/Sources/OpenClaw/RemotePortTunnel.swift b/apps/macos/Sources/OpenClaw/RemotePortTunnel.swift deleted file mode 100644 index 6502d2ad916..00000000000 --- a/apps/macos/Sources/OpenClaw/RemotePortTunnel.swift +++ /dev/null @@ -1,317 +0,0 @@ -import Foundation -import Network -import OSLog -#if canImport(Darwin) -import Darwin -#endif - -/// Port forwarding tunnel for remote mode. -/// -/// Uses `ssh -N -L` to forward the remote gateway ports to localhost. -final class RemotePortTunnel { - private static let logger = Logger(subsystem: "ai.openclaw", category: "remote.tunnel") - - let process: Process - let localPort: UInt16? - private let stderrHandle: FileHandle? - - private init(process: Process, localPort: UInt16?, stderrHandle: FileHandle?) { - self.process = process - self.localPort = localPort - self.stderrHandle = stderrHandle - } - - deinit { - Self.cleanupStderr(self.stderrHandle) - let pid = self.process.processIdentifier - self.process.terminate() - Task { await PortGuardian.shared.removeRecord(pid: pid) } - } - - func terminate() { - Self.cleanupStderr(self.stderrHandle) - let pid = self.process.processIdentifier - if self.process.isRunning { - self.process.terminate() - self.process.waitUntilExit() - } - Task { await PortGuardian.shared.removeRecord(pid: pid) } - } - - static func create( - remotePort: Int, - preferredLocalPort: UInt16? = nil, - allowRemoteUrlOverride: Bool = true, - allowRandomLocalPort: Bool = true) async throws -> RemotePortTunnel - { - let settings = CommandResolver.connectionSettings() - guard settings.mode == .remote, let parsed = CommandResolver.parseSSHTarget(settings.target) else { - throw NSError( - domain: "RemotePortTunnel", - code: 3, - userInfo: [NSLocalizedDescriptionKey: "Remote mode is not configured"]) - } - - let localPort = try await Self.findPort( - preferred: preferredLocalPort, - allowRandom: allowRandomLocalPort) - let sshHost = parsed.host.trimmingCharacters(in: .whitespacesAndNewlines) - let remotePortOverride = - allowRemoteUrlOverride && remotePort == GatewayEnvironment.gatewayPort() - ? Self.resolveRemotePortOverride(for: sshHost) - : nil - let resolvedRemotePort = remotePortOverride ?? remotePort - if let override = remotePortOverride { - Self.logger.info( - "ssh tunnel remote port override " + - "host=\(sshHost, privacy: .public) port=\(override, privacy: .public)") - } else { - Self.logger.debug( - "ssh tunnel using default remote port " + - "host=\(sshHost, privacy: .public) port=\(remotePort, privacy: .public)") - } - let options: [String] = [ - "-o", "BatchMode=yes", - "-o", "ExitOnForwardFailure=yes", - "-o", "StrictHostKeyChecking=accept-new", - "-o", "UpdateHostKeys=yes", - "-o", "ServerAliveInterval=15", - "-o", "ServerAliveCountMax=3", - "-o", "TCPKeepAlive=yes", - "-N", - "-L", "\(localPort):127.0.0.1:\(resolvedRemotePort)", - ] - let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines) - let args = CommandResolver.sshArguments( - target: parsed, - identity: identity, - options: options) - - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") - process.arguments = args - - let pipe = Pipe() - process.standardError = pipe - let stderrHandle = pipe.fileHandleForReading - - // Consume stderr so ssh cannot block if it logs. - stderrHandle.readabilityHandler = { handle in - let data = handle.readSafely(upToCount: 64 * 1024) - guard !data.isEmpty else { - // EOF (or read failure): stop monitoring to avoid spinning on a closed pipe. - Self.cleanupStderr(handle) - return - } - guard let line = String(data: data, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines), - !line.isEmpty - else { return } - Self.logger.error("ssh tunnel stderr: \(line, privacy: .public)") - } - process.terminationHandler = { _ in - Self.cleanupStderr(stderrHandle) - } - - try process.run() - - // If ssh exits immediately (e.g. local port already in use), surface stderr and ensure we stop monitoring. - try? await Task.sleep(nanoseconds: 150_000_000) // 150ms - if !process.isRunning { - let stderr = Self.drainStderr(stderrHandle) - let msg = stderr.isEmpty ? "ssh tunnel exited immediately" : "ssh tunnel failed: \(stderr)" - throw NSError(domain: "RemotePortTunnel", code: 4, userInfo: [NSLocalizedDescriptionKey: msg]) - } - - // Track tunnel so we can clean up stale listeners on restart. - Task { - await PortGuardian.shared.record( - port: Int(localPort), - pid: process.processIdentifier, - command: process.executableURL?.path ?? "ssh", - mode: CommandResolver.connectionSettings().mode) - } - - return RemotePortTunnel(process: process, localPort: localPort, stderrHandle: stderrHandle) - } - - private static func resolveRemotePortOverride(for sshHost: String) -> Int? { - let root = OpenClawConfigFile.loadDict() - guard let gateway = root["gateway"] as? [String: Any], - let remote = gateway["remote"] as? [String: Any], - let urlRaw = remote["url"] as? String - else { - return nil - } - let trimmed = urlRaw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, let url = URL(string: trimmed), let port = url.port else { - return nil - } - guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), - !host.isEmpty - else { - return nil - } - let sshKey = Self.hostKey(sshHost) - let urlKey = Self.hostKey(host) - guard !sshKey.isEmpty, !urlKey.isEmpty else { return nil } - guard sshKey == urlKey else { - Self.logger.debug( - "remote url host mismatch sshHost=\(sshHost, privacy: .public) urlHost=\(host, privacy: .public)") - return nil - } - return port - } - - private static func hostKey(_ host: String) -> String { - let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !trimmed.isEmpty else { return "" } - if trimmed.contains(":") { return trimmed } - let digits = CharacterSet(charactersIn: "0123456789.") - if trimmed.rangeOfCharacter(from: digits.inverted) == nil { - return trimmed - } - return trimmed.split(separator: ".").first.map(String.init) ?? trimmed - } - - private static func findPort(preferred: UInt16?, allowRandom: Bool) async throws -> UInt16 { - if let preferred, self.portIsFree(preferred) { return preferred } - if let preferred, !allowRandom { - throw NSError( - domain: "RemotePortTunnel", - code: 5, - userInfo: [ - NSLocalizedDescriptionKey: "Local port \(preferred) is unavailable", - ]) - } - - return try await withCheckedThrowingContinuation { cont in - let queue = DispatchQueue(label: "ai.openclaw.remote.tunnel.port", qos: .utility) - do { - let listener = try NWListener(using: .tcp, on: .any) - listener.newConnectionHandler = { connection in connection.cancel() } - listener.stateUpdateHandler = { state in - switch state { - case .ready: - if let port = listener.port?.rawValue { - listener.stateUpdateHandler = nil - listener.cancel() - cont.resume(returning: port) - } - case let .failed(error): - listener.stateUpdateHandler = nil - listener.cancel() - cont.resume(throwing: error) - default: - break - } - } - listener.start(queue: queue) - } catch { - cont.resume(throwing: error) - } - } - } - - private static func portIsFree(_ port: UInt16) -> Bool { - #if canImport(Darwin) - // NWListener can succeed even when only one address family is held. Mirror what ssh needs by checking - // both 127.0.0.1 and ::1 for availability. - return self.canBindIPv4(port) && self.canBindIPv6(port) - #else - do { - let listener = try NWListener(using: .tcp, on: NWEndpoint.Port(rawValue: port)!) - listener.cancel() - return true - } catch { - return false - } - #endif - } - - #if canImport(Darwin) - private static func canBindIPv4(_ port: UInt16) -> Bool { - let fd = socket(AF_INET, SOCK_STREAM, 0) - guard fd >= 0 else { return false } - defer { _ = Darwin.close(fd) } - - var one: Int32 = 1 - _ = setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, socklen_t(MemoryLayout.size(ofValue: one))) - - var addr = sockaddr_in() - addr.sin_len = UInt8(MemoryLayout.size) - addr.sin_family = sa_family_t(AF_INET) - addr.sin_port = port.bigEndian - addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) - - let result = withUnsafePointer(to: &addr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in - Darwin.bind(fd, sa, socklen_t(MemoryLayout.size)) - } - } - return result == 0 - } - - private static func canBindIPv6(_ port: UInt16) -> Bool { - let fd = socket(AF_INET6, SOCK_STREAM, 0) - guard fd >= 0 else { return false } - defer { _ = Darwin.close(fd) } - - var one: Int32 = 1 - _ = setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, socklen_t(MemoryLayout.size(ofValue: one))) - - var addr = sockaddr_in6() - addr.sin6_len = UInt8(MemoryLayout.size) - addr.sin6_family = sa_family_t(AF_INET6) - addr.sin6_port = port.bigEndian - var loopback = in6_addr() - _ = withUnsafeMutablePointer(to: &loopback) { ptr in - inet_pton(AF_INET6, "::1", ptr) - } - addr.sin6_addr = loopback - - let result = withUnsafePointer(to: &addr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in - Darwin.bind(fd, sa, socklen_t(MemoryLayout.size)) - } - } - return result == 0 - } - #endif - - private static func cleanupStderr(_ handle: FileHandle?) { - guard let handle else { return } - Self.cleanupStderr(handle) - } - - private static func cleanupStderr(_ handle: FileHandle) { - if handle.readabilityHandler != nil { - handle.readabilityHandler = nil - } - try? handle.close() - } - - private static func drainStderr(_ handle: FileHandle) -> String { - handle.readabilityHandler = nil - defer { try? handle.close() } - - do { - let data = try handle.readToEnd() ?? Data() - return String(data: data, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - } catch { - self.logger.debug("Failed to drain ssh stderr: \(error, privacy: .public)") - return "" - } - } - - #if SWIFT_PACKAGE - static func _testPortIsFree(_ port: UInt16) -> Bool { - self.portIsFree(port) - } - - static func _testDrainStderr(_ handle: FileHandle) -> String { - self.drainStderr(handle) - } - #endif -} diff --git a/apps/macos/Sources/OpenClaw/RemoteTunnelManager.swift b/apps/macos/Sources/OpenClaw/RemoteTunnelManager.swift deleted file mode 100644 index e8f0da6f091..00000000000 --- a/apps/macos/Sources/OpenClaw/RemoteTunnelManager.swift +++ /dev/null @@ -1,122 +0,0 @@ -import Foundation -import OSLog - -/// Manages the SSH tunnel that forwards the remote gateway/control port to localhost. -actor RemoteTunnelManager { - static let shared = RemoteTunnelManager() - - private let logger = Logger(subsystem: "ai.openclaw", category: "remote-tunnel") - private var controlTunnel: RemotePortTunnel? - private var restartInFlight = false - private var lastRestartAt: Date? - private let restartBackoffSeconds: TimeInterval = 2.0 - - func controlTunnelPortIfRunning() async -> UInt16? { - if self.restartInFlight { - self.logger.info("control tunnel restart in flight; skipping reuse check") - return nil - } - if let tunnel = self.controlTunnel, - tunnel.process.isRunning, - let local = tunnel.localPort - { - let pid = tunnel.process.processIdentifier - if await PortGuardian.shared.isListening(port: Int(local), pid: pid) { - self.logger.info("reusing active SSH tunnel localPort=\(local, privacy: .public)") - return local - } - self.logger.error( - "active SSH tunnel on port \(local, privacy: .public) is not listening; restarting") - await self.beginRestart() - tunnel.terminate() - self.controlTunnel = nil - } - // If a previous OpenClaw run already has an SSH listener on the expected port (common after restarts), - // reuse it instead of spawning new ssh processes that immediately fail with "Address already in use". - let desiredPort = UInt16(GatewayEnvironment.gatewayPort()) - if let desc = await PortGuardian.shared.describe(port: Int(desiredPort)), - self.isSshProcess(desc) - { - self.logger.info( - "reusing existing SSH tunnel listener " + - "localPort=\(desiredPort, privacy: .public) " + - "pid=\(desc.pid, privacy: .public)") - return desiredPort - } - return nil - } - - /// Ensure an SSH tunnel is running for the gateway control port. - /// Returns the local forwarded port (usually the configured gateway port). - func ensureControlTunnel() async throws -> UInt16 { - let settings = CommandResolver.connectionSettings() - guard settings.mode == .remote else { - throw NSError( - domain: "RemoteTunnel", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"]) - } - - let identitySet = !settings.identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - self.logger.info( - "ensure SSH tunnel target=\(settings.target, privacy: .public) " + - "identitySet=\(identitySet, privacy: .public)") - - if let local = await self.controlTunnelPortIfRunning() { return local } - await self.waitForRestartBackoffIfNeeded() - - let desiredPort = UInt16(GatewayEnvironment.gatewayPort()) - let tunnel = try await RemotePortTunnel.create( - remotePort: GatewayEnvironment.gatewayPort(), - preferredLocalPort: desiredPort, - allowRandomLocalPort: false) - self.controlTunnel = tunnel - self.endRestart() - let resolvedPort = tunnel.localPort ?? desiredPort - self.logger.info("ssh tunnel ready localPort=\(resolvedPort, privacy: .public)") - return tunnel.localPort ?? desiredPort - } - - func stopAll() { - self.controlTunnel?.terminate() - self.controlTunnel = nil - } - - private func isSshProcess(_ desc: PortGuardian.Descriptor) -> Bool { - let cmd = desc.command.lowercased() - if cmd.contains("ssh") { return true } - if let path = desc.executablePath?.lowercased(), path.contains("/ssh") { return true } - return false - } - - private func beginRestart() async { - guard !self.restartInFlight else { return } - self.restartInFlight = true - self.lastRestartAt = Date() - self.logger.info("control tunnel restart started") - Task { [weak self] in - guard let self else { return } - try? await Task.sleep(nanoseconds: UInt64(self.restartBackoffSeconds * 1_000_000_000)) - await self.endRestart() - } - } - - private func endRestart() { - if self.restartInFlight { - self.restartInFlight = false - self.logger.info("control tunnel restart finished") - } - } - - private func waitForRestartBackoffIfNeeded() async { - guard let last = self.lastRestartAt else { return } - let elapsed = Date().timeIntervalSince(last) - let remaining = self.restartBackoffSeconds - elapsed - guard remaining > 0 else { return } - self.logger.info( - "control tunnel restart backoff \(remaining, privacy: .public)s") - try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000)) - } - - // Keep tunnel reuse lightweight; restart only when the listener disappears. -} diff --git a/apps/macos/Sources/OpenClaw/Resources/DeviceModels/LICENSE.apple-device-identifiers.txt b/apps/macos/Sources/OpenClaw/Resources/DeviceModels/LICENSE.apple-device-identifiers.txt deleted file mode 100644 index d1b9e4b3ce5..00000000000 --- a/apps/macos/Sources/OpenClaw/Resources/DeviceModels/LICENSE.apple-device-identifiers.txt +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2021 Kyle Seongwoo Jun - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/apps/macos/Sources/OpenClaw/Resources/DeviceModels/NOTICE.md b/apps/macos/Sources/OpenClaw/Resources/DeviceModels/NOTICE.md deleted file mode 100644 index 664e78d7bc9..00000000000 --- a/apps/macos/Sources/OpenClaw/Resources/DeviceModels/NOTICE.md +++ /dev/null @@ -1,9 +0,0 @@ -# Apple device identifier mappings - -This directory includes model identifier → human-readable name mappings derived from the open-source project: - -- `kyle-seongwoo-jun/apple-device-identifiers` - - iOS mapping pinned to commit `8e7388b29da046183f5d976eb74dbb2f2acda955` - - macOS mapping pinned to commit `98ca75324f7a88c1649eb5edfc266ef47b7b8193` - -See `LICENSE.apple-device-identifiers.txt` for license terms. diff --git a/apps/macos/Sources/OpenClaw/Resources/DeviceModels/ios-device-identifiers.json b/apps/macos/Sources/OpenClaw/Resources/DeviceModels/ios-device-identifiers.json deleted file mode 100644 index 76caa5452ea..00000000000 --- a/apps/macos/Sources/OpenClaw/Resources/DeviceModels/ios-device-identifiers.json +++ /dev/null @@ -1,176 +0,0 @@ -{ - "i386": "iPhone Simulator", - "x86_64": "iPhone Simulator", - "arm64": "iPhone Simulator", - "iPhone1,1": "iPhone", - "iPhone1,2": "iPhone 3G", - "iPhone2,1": "iPhone 3GS", - "iPhone3,1": "iPhone 4", - "iPhone3,2": "iPhone 4", - "iPhone3,3": "iPhone 4", - "iPhone4,1": "iPhone 4s", - "iPhone5,1": "iPhone 5", - "iPhone5,2": "iPhone 5", - "iPhone5,3": "iPhone 5c", - "iPhone5,4": "iPhone 5c", - "iPhone6,1": "iPhone 5s", - "iPhone6,2": "iPhone 5s", - "iPhone7,1": "iPhone 6 Plus", - "iPhone7,2": "iPhone 6", - "iPhone8,1": "iPhone 6s", - "iPhone8,2": "iPhone 6s Plus", - "iPhone8,4": "iPhone SE (1st generation)", - "iPhone9,1": "iPhone 7", - "iPhone9,2": "iPhone 7 Plus", - "iPhone9,3": "iPhone 7", - "iPhone9,4": "iPhone 7 Plus", - "iPhone10,1": "iPhone 8", - "iPhone10,2": "iPhone 8 Plus", - "iPhone10,3": "iPhone X", - "iPhone10,4": "iPhone 8", - "iPhone10,5": "iPhone 8 Plus", - "iPhone10,6": "iPhone X", - "iPhone11,2": "iPhone XS", - "iPhone11,4": "iPhone XS Max", - "iPhone11,6": "iPhone XS Max", - "iPhone11,8": "iPhone XR", - "iPhone12,1": "iPhone 11", - "iPhone12,3": "iPhone 11 Pro", - "iPhone12,5": "iPhone 11 Pro Max", - "iPhone12,8": "iPhone SE (2nd generation)", - "iPhone13,1": "iPhone 12 mini", - "iPhone13,2": "iPhone 12", - "iPhone13,3": "iPhone 12 Pro", - "iPhone13,4": "iPhone 12 Pro Max", - "iPhone14,2": "iPhone 13 Pro", - "iPhone14,3": "iPhone 13 Pro Max", - "iPhone14,4": "iPhone 13 mini", - "iPhone14,5": "iPhone 13", - "iPhone14,6": "iPhone SE (3rd generation)", - "iPhone14,7": "iPhone 14", - "iPhone14,8": "iPhone 14 Plus", - "iPhone15,2": "iPhone 14 Pro", - "iPhone15,3": "iPhone 14 Pro Max", - "iPhone15,4": "iPhone 15", - "iPhone15,5": "iPhone 15 Plus", - "iPhone16,1": "iPhone 15 Pro", - "iPhone16,2": "iPhone 15 Pro Max", - "iPhone17,1": "iPhone 16 Pro", - "iPhone17,2": "iPhone 16 Pro Max", - "iPhone17,3": "iPhone 16", - "iPhone17,4": "iPhone 16 Plus", - "iPhone17,5": "iPhone 16e", - "iPhone18,1": "iPhone 17 Pro", - "iPhone18,2": "iPhone 17 Pro Max", - "iPhone18,3": "iPhone 17", - "iPhone18,4": "iPhone Air", - "iPad1,1": "iPad", - "iPad1,2": "iPad", - "iPad2,1": "iPad 2", - "iPad2,2": "iPad 2", - "iPad2,3": "iPad 2", - "iPad2,4": "iPad 2", - "iPad2,5": "iPad mini", - "iPad2,6": "iPad mini", - "iPad2,7": "iPad mini", - "iPad3,1": "iPad (3rd generation)", - "iPad3,2": "iPad (3rd generation)", - "iPad3,3": "iPad (3rd generation)", - "iPad3,4": "iPad (4th generation)", - "iPad3,5": "iPad (4th generation)", - "iPad3,6": "iPad (4th generation)", - "iPad4,1": "iPad Air", - "iPad4,2": "iPad Air", - "iPad4,3": "iPad Air", - "iPad4,4": "iPad mini 2", - "iPad4,5": "iPad mini 2", - "iPad4,6": "iPad mini 2", - "iPad4,7": "iPad mini 3", - "iPad4,8": "iPad mini 3", - "iPad4,9": "iPad mini 3", - "iPad5,1": "iPad mini 4", - "iPad5,2": "iPad mini 4", - "iPad5,3": "iPad Air 2", - "iPad5,4": "iPad Air 2", - "iPad6,3": "iPad Pro (9.7-inch)", - "iPad6,4": "iPad Pro (9.7-inch)", - "iPad6,7": "iPad Pro (12.9-inch)", - "iPad6,8": "iPad Pro (12.9-inch)", - "iPad6,11": "iPad (5th generation)", - "iPad6,12": "iPad (5th generation)", - "iPad7,1": "iPad Pro (12.9-inch) (2nd generation)", - "iPad7,2": "iPad Pro (12.9-inch) (2nd generation)", - "iPad7,3": "iPad Pro (10.5-inch)", - "iPad7,4": "iPad Pro (10.5-inch)", - "iPad7,5": "iPad (6th generation)", - "iPad7,6": "iPad (6th generation)", - "iPad7,11": "iPad (7th generation)", - "iPad7,12": "iPad (7th generation)", - "iPad8,1": "iPad Pro (11-inch)", - "iPad8,2": "iPad Pro (11-inch)", - "iPad8,3": "iPad Pro (11-inch)", - "iPad8,4": "iPad Pro (11-inch)", - "iPad8,5": "iPad Pro (12.9-inch) (3rd generation)", - "iPad8,6": "iPad Pro (12.9-inch) (3rd generation)", - "iPad8,7": "iPad Pro (12.9-inch) (3rd generation)", - "iPad8,8": "iPad Pro (12.9-inch) (3rd generation)", - "iPad8,9": "iPad Pro (11-inch) (2nd generation)", - "iPad8,10": "iPad Pro (11-inch) (2nd generation)", - "iPad8,11": "iPad Pro (12.9-inch) (4th generation)", - "iPad8,12": "iPad Pro (12.9-inch) (4th generation)", - "iPad11,1": "iPad mini (5th generation)", - "iPad11,2": "iPad mini (5th generation)", - "iPad11,3": "iPad Air (3rd generation)", - "iPad11,4": "iPad Air (3rd generation)", - "iPad11,6": "iPad (8th generation)", - "iPad11,7": "iPad (8th generation)", - "iPad12,1": "iPad (9th generation)", - "iPad12,2": "iPad (9th generation)", - "iPad13,1": "iPad Air (4th generation)", - "iPad13,2": "iPad Air (4th generation)", - "iPad13,4": "iPad Pro (11-inch) (3rd generation)", - "iPad13,5": "iPad Pro (11-inch) (3rd generation)", - "iPad13,6": "iPad Pro (11-inch) (3rd generation)", - "iPad13,7": "iPad Pro (11-inch) (3rd generation)", - "iPad13,8": "iPad Pro (12.9-inch) (5th generation)", - "iPad13,9": "iPad Pro (12.9-inch) (5th generation)", - "iPad13,10": "iPad Pro (12.9-inch) (5th generation)", - "iPad13,11": "iPad Pro (12.9-inch) (5th generation)", - "iPad13,16": "iPad Air (5th generation)", - "iPad13,17": "iPad Air (5th generation)", - "iPad13,18": "iPad (10th generation)", - "iPad13,19": "iPad (10th generation)", - "iPad14,1": "iPad mini (6th generation)", - "iPad14,2": "iPad mini (6th generation)", - "iPad14,3": "iPad Pro (11-inch) (4th generation)", - "iPad14,4": "iPad Pro (11-inch) (4th generation)", - "iPad14,5": "iPad Pro (12.9-inch) (6th generation)", - "iPad14,6": "iPad Pro (12.9-inch) (6th generation)", - "iPad14,8": "iPad Air 11-inch (M2)", - "iPad14,9": "iPad Air 11-inch (M2)", - "iPad14,10": "iPad Air 13-inch (M2)", - "iPad14,11": "iPad Air 13-inch (M2)", - "iPad15,3": "iPad Air 11-inch (M3)", - "iPad15,4": "iPad Air 11-inch (M3)", - "iPad15,5": "iPad Air 13-inch (M3)", - "iPad15,6": "iPad Air 13-inch (M3)", - "iPad15,7": "iPad (A16)", - "iPad15,8": "iPad (A16)", - "iPad16,1": "iPad mini (A17 Pro)", - "iPad16,2": "iPad mini (A17 Pro)", - "iPad16,3": "iPad Pro 11-inch (M4)", - "iPad16,4": "iPad Pro 11-inch (M4)", - "iPad16,5": "iPad Pro 13-inch (M4)", - "iPad16,6": "iPad Pro 13-inch (M4)", - "iPad17,1": "iPad Pro 11-inch (M5)", - "iPad17,2": "iPad Pro 11-inch (M5)", - "iPad17,3": "iPad Pro 13-inch (M5)", - "iPad17,4": "iPad Pro 13-inch (M5)", - "iPod1,1": "iPod touch", - "iPod2,1": "iPod touch (2nd generation)", - "iPod3,1": "iPod touch (3rd generation)", - "iPod4,1": "iPod touch (4th generation)", - "iPod5,1": "iPod touch (5th generation)", - "iPod7,1": "iPod touch (6th generation)", - "iPod9,1": "iPod touch (7th generation)" -} diff --git a/apps/macos/Sources/OpenClaw/Resources/DeviceModels/mac-device-identifiers.json b/apps/macos/Sources/OpenClaw/Resources/DeviceModels/mac-device-identifiers.json deleted file mode 100644 index 03d5a5eccb1..00000000000 --- a/apps/macos/Sources/OpenClaw/Resources/DeviceModels/mac-device-identifiers.json +++ /dev/null @@ -1,214 +0,0 @@ -{ - "iMac9,1": [ - "iMac (20-inch, Early 2009)", - "iMac (24-inch, Early 2009)" - ], - "iMac10,1": [ - "iMac (21.5-inch, Late 2009)", - "iMac (27-inch, Late 2009)" - ], - "iMac11,2": "iMac (21.5-inch, Mid 2010)", - "iMac11,3": "iMac (27-inch, Mid 2010)", - "iMac12,1": "iMac (21.5-inch, Mid 2011)", - "iMac12,2": "iMac (27-inch, Mid 2011)", - "iMac13,1": "iMac (21.5-inch, Late 2012)", - "iMac13,2": "iMac (27-inch, Late 2012)", - "iMac14,1": "iMac (21.5-inch, Late 2013)", - "iMac14,2": "iMac (27-inch, Late 2013)", - "iMac14,4": "iMac (21.5-inch, Mid 2014)", - "iMac15,1": [ - "iMac (Retina 5K, 27-inch, Late 2014)", - "iMac (Retina 5K, 27-inch, Mid 2015)" - ], - "iMac16,1": "iMac (21.5-inch, Late 2015)", - "iMac16,2": "iMac (Retina 4K, 21.5-inch, Late 2015)", - "iMac17,1": "iMac (Retina 5K, 27-inch, Late 2015)", - "iMac18,1": "iMac (21.5-inch, 2017)", - "iMac18,2": "iMac (Retina 4K, 21.5-inch, 2017)", - "iMac18,3": "iMac (Retina 5K, 27-inch, 2017)", - "iMac19,1": "iMac (Retina 5K, 27-inch, 2019)", - "iMac19,2": "iMac (Retina 4K, 21.5-inch, 2019)", - "iMac20,1": "iMac (Retina 5K, 27-inch, 2020)", - "iMac20,2": "iMac (Retina 5K, 27-inch, 2020)", - "iMac21,1": "iMac (24-inch, M1, 2021)", - "iMac21,2": "iMac (24-inch, M1, 2021)", - "iMacPro1,1": "iMac Pro (2017)", - "Mac13,1": "Mac Studio (2022)", - "Mac13,2": "Mac Studio (2022)", - "Mac14,2": "MacBook Air (M2, 2022)", - "Mac14,3": "Mac mini (2023)", - "Mac14,5": "MacBook Pro (14-inch, 2023)", - "Mac14,6": "MacBook Pro (16-inch, 2023)", - "Mac14,7": "MacBook Pro (13-inch, M2, 2022)", - "Mac14,8": [ - "Mac Pro (2023)", - "Mac Pro (Rack, 2023)" - ], - "Mac14,9": "MacBook Pro (14-inch, 2023)", - "Mac14,10": "MacBook Pro (16-inch, 2023)", - "Mac14,12": "Mac mini (2023)", - "Mac14,13": "Mac Studio (2023)", - "Mac14,14": "Mac Studio (2023)", - "Mac14,15": "MacBook Air (15-inch, M2, 2023)", - "Mac15,3": "MacBook Pro (14-inch, Nov 2023)", - "Mac15,4": "iMac (24-inch, 2023, Two ports)", - "Mac15,5": "iMac (24-inch, 2023, Four ports)", - "Mac15,6": "MacBook Pro (14-inch, Nov 2023)", - "Mac15,7": "MacBook Pro (16-inch, Nov 2023)", - "Mac15,8": "MacBook Pro (14-inch, Nov 2023)", - "Mac15,9": "MacBook Pro (16-inch, Nov 2023)", - "Mac15,10": "MacBook Pro (14-inch, Nov 2023)", - "Mac15,11": "MacBook Pro (16-inch, Nov 2023)", - "Mac15,12": "MacBook Air (13-inch, M3, 2024)", - "Mac15,13": "MacBook Air (15-inch, M3, 2024)", - "Mac15,14": "Mac Studio (2025)", - "Mac16,1": "MacBook Pro (14-inch, 2024)", - "Mac16,2": "iMac (24-inch, 2024, Two ports)", - "Mac16,3": "iMac (24-inch, 2024, Four ports)", - "Mac16,5": "MacBook Pro (16-inch, 2024)", - "Mac16,6": "MacBook Pro (14-inch, 2024)", - "Mac16,7": "MacBook Pro (16-inch, 2024)", - "Mac16,8": "MacBook Pro (14-inch, 2024)", - "Mac16,9": "Mac Studio (2025)", - "Mac16,10": "Mac mini (2024)", - "Mac16,11": "Mac mini (2024)", - "Mac16,12": "MacBook Air (13-inch, M4, 2025)", - "Mac16,13": "MacBook Air (15-inch, M4, 2025)", - "Mac17,2": "MacBook Pro (14-inch, M5)", - "MacBook5,2": [ - "MacBook (13-inch, Early 2009)", - "MacBook (13-inch, Mid 2009)" - ], - "MacBook6,1": "MacBook (13-inch, Late 2009)", - "MacBook7,1": "MacBook (13-inch, Mid 2010)", - "MacBook8,1": "MacBook (Retina, 12-inch, Early 2015)", - "MacBook9,1": "MacBook (Retina, 12-inch, Early 2016)", - "MacBook10,1": "MacBook (Retina, 12-inch, 2017)", - "MacBookAir2,1": "MacBook Air (Mid 2009)", - "MacBookAir3,1": "MacBook Air (11-inch, Late 2010)", - "MacBookAir3,2": "MacBook Air (13-inch, Late 2010)", - "MacBookAir4,1": "MacBook Air (11-inch, Mid 2011)", - "MacBookAir4,2": "MacBook Air (13-inch, Mid 2011)", - "MacBookAir5,1": "MacBook Air (11-inch, Mid 2012)", - "MacBookAir5,2": "MacBook Air (13-inch, Mid 2012)", - "MacBookAir6,1": [ - "MacBook Air (11-inch, Early 2014)", - "MacBook Air (11-inch, Mid 2013)" - ], - "MacBookAir6,2": [ - "MacBook Air (13-inch, Early 2014)", - "MacBook Air (13-inch, Mid 2013)" - ], - "MacBookAir7,1": "MacBook Air (11-inch, Early 2015)", - "MacBookAir7,2": [ - "MacBook Air (13-inch, 2017)", - "MacBook Air (13-inch, Early 2015)" - ], - "MacBookAir8,1": "MacBook Air (Retina, 13-inch, 2018)", - "MacBookAir8,2": "MacBook Air (Retina, 13-inch, 2019)", - "MacBookAir9,1": "MacBook Air (Retina, 13-inch, 2020)", - "MacBookAir10,1": "MacBook Air (M1, 2020)", - "MacBookPro4,1": [ - "MacBook Pro (15-inch, Early 2008)", - "MacBook Pro (17-inch, Early 2008)" - ], - "MacBookPro5,1": "MacBook Pro (15-inch, Late 2008)", - "MacBookPro5,2": [ - "MacBook Pro (17-inch, Early 2009)", - "MacBook Pro (17-inch, Mid 2009)" - ], - "MacBookPro5,3": [ - "MacBook Pro (15-inch, 2.53GHz, Mid 2009)", - "MacBook Pro (15-inch, Mid 2009)" - ], - "MacBookPro5,5": "MacBook Pro (13-inch, Mid 2009)", - "MacBookPro6,1": "MacBook Pro (17-inch, Mid 2010)", - "MacBookPro6,2": "MacBook Pro (15-inch, Mid 2010)", - "MacBookPro7,1": "MacBook Pro (13-inch, Mid 2010)", - "MacBookPro8,1": [ - "MacBook Pro (13-inch, Early 2011)", - "MacBook Pro (13-inch, Late 2011)" - ], - "MacBookPro8,2": [ - "MacBook Pro (15-inch, Early 2011)", - "MacBook Pro (15-inch, Late 2011)" - ], - "MacBookPro8,3": [ - "MacBook Pro (17-inch, Early 2011)", - "MacBook Pro (17-inch, Late 2011)" - ], - "MacBookPro9,1": "MacBook Pro (15-inch, Mid 2012)", - "MacBookPro9,2": "MacBook Pro (13-inch, Mid 2012)", - "MacBookPro10,1": [ - "MacBook Pro (Retina, 15-inch, Early 2013)", - "MacBook Pro (Retina, 15-inch, Mid 2012)" - ], - "MacBookPro10,2": [ - "MacBook Pro (Retina, 13-inch, Early 2013)", - "MacBook Pro (Retina, 13-inch, Late 2012)" - ], - "MacBookPro11,1": [ - "MacBook Pro (Retina, 13-inch, Late 2013)", - "MacBook Pro (Retina, 13-inch, Mid 2014)" - ], - "MacBookPro11,2": [ - "MacBook Pro (Retina, 15-inch, Late 2013)", - "MacBook Pro (Retina, 15-inch, Mid 2014)" - ], - "MacBookPro11,3": [ - "MacBook Pro (Retina, 15-inch, Late 2013)", - "MacBook Pro (Retina, 15-inch, Mid 2014)" - ], - "MacBookPro11,4": "MacBook Pro (Retina, 15-inch, Mid 2015)", - "MacBookPro11,5": "MacBook Pro (Retina, 15-inch, Mid 2015)", - "MacBookPro12,1": "MacBook Pro (Retina, 13-inch, Early 2015)", - "MacBookPro13,1": "MacBook Pro (13-inch, 2016, Two Thunderbolt 3 ports)", - "MacBookPro13,2": "MacBook Pro (13-inch, 2016, Four Thunderbolt 3 ports)", - "MacBookPro13,3": "MacBook Pro (15-inch, 2016)", - "MacBookPro14,1": "MacBook Pro (13-inch, 2017, Two Thunderbolt 3 ports)", - "MacBookPro14,2": "MacBook Pro (13-inch, 2017, Four Thunderbolt 3 ports)", - "MacBookPro14,3": "MacBook Pro (15-inch, 2017)", - "MacBookPro15,1": [ - "MacBook Pro (15-inch, 2018)", - "MacBook Pro (15-inch, 2019)" - ], - "MacBookPro15,2": [ - "MacBook Pro (13-inch, 2018, Four Thunderbolt 3 ports)", - "MacBook Pro (13-inch, 2019, Four Thunderbolt 3 ports)" - ], - "MacBookPro15,3": "MacBook Pro (15-inch, 2019)", - "MacBookPro15,4": "MacBook Pro (13-inch, 2019, Two Thunderbolt 3 ports)", - "MacBookPro16,1": "MacBook Pro (16-inch, 2019)", - "MacBookPro16,2": "MacBook Pro (13-inch, 2020, Four Thunderbolt 3 ports)", - "MacBookPro16,3": "MacBook Pro (13-inch, 2020, Two Thunderbolt 3 ports)", - "MacBookPro16,4": "MacBook Pro (16-inch, 2019)", - "MacBookPro17,1": "MacBook Pro (13-inch, M1, 2020)", - "MacBookPro18,1": "MacBook Pro (16-inch, 2021)", - "MacBookPro18,2": "MacBook Pro (16-inch, 2021)", - "MacBookPro18,3": "MacBook Pro (14-inch, 2021)", - "MacBookPro18,4": "MacBook Pro (14-inch, 2021)", - "Macmini3,1": [ - "Mac mini (Early 2009)", - "Mac mini (Late 2009)" - ], - "Macmini4,1": "Mac mini (Mid 2010)", - "Macmini5,1": "Mac mini (Mid 2011)", - "Macmini5,2": "Mac mini (Mid 2011)", - "Macmini6,1": "Mac mini (Late 2012)", - "Macmini6,2": "Mac mini (Late 2012)", - "Macmini7,1": "Mac mini (Late 2014)", - "Macmini8,1": "Mac mini (2018)", - "Macmini9,1": "Mac mini (M1, 2020)", - "MacPro4,1": "Mac Pro (Early 2009)", - "MacPro5,1": [ - "Mac Pro (Mid 2010)", - "Mac Pro (Mid 2012)", - "Mac Pro Server (Mid 2010)", - "Mac Pro Server (Mid 2012)" - ], - "MacPro6,1": "Mac Pro (Late 2013)", - "MacPro7,1": [ - "Mac Pro (2019)", - "Mac Pro (Rack, 2019)" - ] -} diff --git a/apps/macos/Sources/OpenClaw/Resources/Info.plist b/apps/macos/Sources/OpenClaw/Resources/Info.plist deleted file mode 100644 index e7ca1ad5487..00000000000 --- a/apps/macos/Sources/OpenClaw/Resources/Info.plist +++ /dev/null @@ -1,79 +0,0 @@ - - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - OpenClaw - CFBundleIdentifier - ai.openclaw.mac - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - OpenClaw - CFBundlePackageType - APPL - CFBundleShortVersionString - 2026.2.21 - CFBundleVersion - 202602210 - CFBundleIconFile - OpenClaw - CFBundleURLTypes - - - CFBundleURLName - ai.openclaw.mac.deeplink - CFBundleURLSchemes - - openclaw - - - - LSMinimumSystemVersion - 15.0 - LSUIElement - - - OpenClawBuildTimestamp - - OpenClawGitCommit - - - NSUserNotificationUsageDescription - OpenClaw needs notification permission to show alerts for agent actions. - NSScreenCaptureDescription - OpenClaw captures the screen when the agent needs screenshots for context. - NSCameraUsageDescription - OpenClaw can capture photos or short video clips when requested by the agent. - NSLocationUsageDescription - OpenClaw can share your location when requested by the agent. - NSLocationWhenInUseUsageDescription - OpenClaw can share your location when requested by the agent. - NSLocationAlwaysAndWhenInUseUsageDescription - OpenClaw can share your location when requested by the agent. - NSMicrophoneUsageDescription - OpenClaw needs the mic for Voice Wake tests and agent audio capture. - NSSpeechRecognitionUsageDescription - OpenClaw uses speech recognition to detect your Voice Wake trigger phrase. - NSAppleEventsUsageDescription - OpenClaw needs Automation (AppleScript) permission to drive Terminal and other apps for agent actions. - - NSAppTransportSecurity - - NSAllowsArbitraryLoadsInWebContent - - NSExceptionDomains - - 100.100.100.100 - - NSExceptionAllowsInsecureHTTPLoads - - NSIncludesSubdomains - - - - - - diff --git a/apps/macos/Sources/OpenClaw/Resources/OpenClaw.icns b/apps/macos/Sources/OpenClaw/Resources/OpenClaw.icns deleted file mode 100644 index f317728e1c9..00000000000 Binary files a/apps/macos/Sources/OpenClaw/Resources/OpenClaw.icns and /dev/null differ diff --git a/apps/macos/Sources/OpenClaw/RuntimeLocator.swift b/apps/macos/Sources/OpenClaw/RuntimeLocator.swift deleted file mode 100644 index 3112f57879b..00000000000 --- a/apps/macos/Sources/OpenClaw/RuntimeLocator.swift +++ /dev/null @@ -1,171 +0,0 @@ -import Foundation -import OSLog - -enum RuntimeKind: String { - case node -} - -struct RuntimeVersion: Comparable, CustomStringConvertible { - let major: Int - let minor: Int - let patch: Int - - var description: String { - "\(self.major).\(self.minor).\(self.patch)" - } - - static func < (lhs: RuntimeVersion, rhs: RuntimeVersion) -> Bool { - if lhs.major != rhs.major { return lhs.major < rhs.major } - if lhs.minor != rhs.minor { return lhs.minor < rhs.minor } - return lhs.patch < rhs.patch - } - - static func from(string: String) -> RuntimeVersion? { - // Accept optional leading "v" and ignore trailing metadata. - let pattern = #"(\d+)\.(\d+)\.(\d+)"# - guard let match = string.range(of: pattern, options: .regularExpression) else { return nil } - let versionString = String(string[match]) - let parts = versionString.split(separator: ".") - guard parts.count == 3, - let major = Int(parts[0]), - let minor = Int(parts[1]), - let patch = Int(parts[2]) - else { return nil } - return RuntimeVersion(major: major, minor: minor, patch: patch) - } -} - -struct RuntimeResolution { - let kind: RuntimeKind - let path: String - let version: RuntimeVersion -} - -enum RuntimeResolutionError: Error { - case notFound(searchPaths: [String]) - case unsupported( - kind: RuntimeKind, - found: RuntimeVersion, - required: RuntimeVersion, - path: String, - searchPaths: [String]) - case versionParse(kind: RuntimeKind, raw: String, path: String, searchPaths: [String]) -} - -enum RuntimeLocator { - private static let logger = Logger(subsystem: "ai.openclaw", category: "runtime") - private static let minNode = RuntimeVersion(major: 22, minor: 0, patch: 0) - - static func resolve( - searchPaths: [String] = CommandResolver.preferredPaths()) -> Result - { - let pathEnv = searchPaths.joined(separator: ":") - let runtime: RuntimeKind = .node - - guard let binary = findExecutable(named: runtime.binaryName, searchPaths: searchPaths) else { - return .failure(.notFound(searchPaths: searchPaths)) - } - guard let rawVersion = readVersion(of: binary, pathEnv: pathEnv) else { - return .failure(.versionParse( - kind: runtime, - raw: "(unreadable)", - path: binary, - searchPaths: searchPaths)) - } - guard let parsed = RuntimeVersion.from(string: rawVersion) else { - return .failure(.versionParse(kind: runtime, raw: rawVersion, path: binary, searchPaths: searchPaths)) - } - guard parsed >= self.minNode else { - return .failure(.unsupported( - kind: runtime, - found: parsed, - required: self.minNode, - path: binary, - searchPaths: searchPaths)) - } - - return .success(RuntimeResolution(kind: runtime, path: binary, version: parsed)) - } - - static func describeFailure(_ error: RuntimeResolutionError) -> String { - switch error { - case let .notFound(searchPaths): - [ - "openclaw needs Node >=22.0.0 but found no runtime.", - "PATH searched: \(searchPaths.joined(separator: ":"))", - "Install Node: https://nodejs.org/en/download", - ].joined(separator: "\n") - case let .unsupported(kind, found, required, path, searchPaths): - [ - "Found \(kind.rawValue) \(found) at \(path) but need >= \(required).", - "PATH searched: \(searchPaths.joined(separator: ":"))", - "Upgrade Node and rerun openclaw.", - ].joined(separator: "\n") - case let .versionParse(kind, raw, path, searchPaths): - [ - "Could not parse \(kind.rawValue) version output \"\(raw)\" from \(path).", - "PATH searched: \(searchPaths.joined(separator: ":"))", - "Try reinstalling or pinning a supported version (Node >=22.0.0).", - ].joined(separator: "\n") - } - } - - // MARK: - Internals - - private static func findExecutable(named name: String, searchPaths: [String]) -> String? { - let fm = FileManager() - for dir in searchPaths { - let candidate = (dir as NSString).appendingPathComponent(name) - if fm.isExecutableFile(atPath: candidate) { - return candidate - } - } - return nil - } - - private static func readVersion(of binary: String, pathEnv: String) -> String? { - let start = Date() - let process = Process() - process.executableURL = URL(fileURLWithPath: binary) - process.arguments = ["--version"] - process.environment = ["PATH": pathEnv] - - let pipe = Pipe() - process.standardOutput = pipe - process.standardError = pipe - - do { - let data = try process.runAndReadToEnd(from: pipe) - let elapsedMs = Int(Date().timeIntervalSince(start) * 1000) - if elapsedMs > 500 { - self.logger.warning( - """ - runtime --version slow (\(elapsedMs, privacy: .public)ms) \ - bin=\(binary, privacy: .public) - """) - } else { - self.logger.debug( - """ - runtime --version ok (\(elapsedMs, privacy: .public)ms) \ - bin=\(binary, privacy: .public) - """) - } - return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) - } catch { - let elapsedMs = Int(Date().timeIntervalSince(start) * 1000) - self.logger.error( - """ - runtime --version failed (\(elapsedMs, privacy: .public)ms) \ - bin=\(binary, privacy: .public) \ - err=\(error.localizedDescription, privacy: .public) - """) - return nil - } - } -} - -extension RuntimeKind { - fileprivate var binaryName: String { - "node" - } -} diff --git a/apps/macos/Sources/OpenClaw/ScreenRecordService.swift b/apps/macos/Sources/OpenClaw/ScreenRecordService.swift deleted file mode 100644 index 30d854b1147..00000000000 --- a/apps/macos/Sources/OpenClaw/ScreenRecordService.swift +++ /dev/null @@ -1,266 +0,0 @@ -import AVFoundation -import Foundation -import OSLog -@preconcurrency import ScreenCaptureKit - -@MainActor -final class ScreenRecordService { - enum ScreenRecordError: LocalizedError { - case noDisplays - case invalidScreenIndex(Int) - case noFramesCaptured - case writeFailed(String) - - var errorDescription: String? { - switch self { - case .noDisplays: - "No displays available for screen recording" - case let .invalidScreenIndex(idx): - "Invalid screen index \(idx)" - case .noFramesCaptured: - "No frames captured" - case let .writeFailed(msg): - msg - } - } - } - - private let logger = Logger(subsystem: "ai.openclaw", category: "screenRecord") - - func record( - screenIndex: Int?, - durationMs: Int?, - fps: Double?, - includeAudio: Bool?, - outPath: String?) async throws -> (path: String, hasAudio: Bool) - { - let durationMs = Self.clampDurationMs(durationMs) - let fps = Self.clampFps(fps) - let includeAudio = includeAudio ?? false - - let outURL: URL = { - if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return URL(fileURLWithPath: outPath) - } - return FileManager().temporaryDirectory - .appendingPathComponent("openclaw-screen-record-\(UUID().uuidString).mp4") - }() - try? FileManager().removeItem(at: outURL) - - let content = try await SCShareableContent.current - let displays = content.displays.sorted { $0.displayID < $1.displayID } - guard !displays.isEmpty else { throw ScreenRecordError.noDisplays } - - let idx = screenIndex ?? 0 - guard idx >= 0, idx < displays.count else { throw ScreenRecordError.invalidScreenIndex(idx) } - let display = displays[idx] - - let filter = SCContentFilter(display: display, excludingWindows: []) - let config = SCStreamConfiguration() - config.width = display.width - config.height = display.height - config.queueDepth = 8 - config.showsCursor = true - config.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(max(1, Int32(fps.rounded())))) - if includeAudio { - config.capturesAudio = true - } - - let recorder = try StreamRecorder( - outputURL: outURL, - width: display.width, - height: display.height, - includeAudio: includeAudio, - logger: self.logger) - - let stream = SCStream(filter: filter, configuration: config, delegate: recorder) - try stream.addStreamOutput(recorder, type: .screen, sampleHandlerQueue: recorder.queue) - if includeAudio { - try stream.addStreamOutput(recorder, type: .audio, sampleHandlerQueue: recorder.queue) - } - - self.logger.info( - "screen record start idx=\(idx) durationMs=\(durationMs) fps=\(fps) out=\(outURL.path, privacy: .public)") - - var started = false - do { - try await stream.startCapture() - started = true - try await Task.sleep(nanoseconds: UInt64(durationMs) * 1_000_000) - try await stream.stopCapture() - } catch { - if started { try? await stream.stopCapture() } - throw error - } - - try await recorder.finish() - return (path: outURL.path, hasAudio: recorder.hasAudio) - } - - private nonisolated static func clampDurationMs(_ ms: Int?) -> Int { - let v = ms ?? 10000 - return min(60000, max(250, v)) - } - - private nonisolated static func clampFps(_ fps: Double?) -> Double { - let v = fps ?? 10 - if !v.isFinite { return 10 } - return min(60, max(1, v)) - } -} - -private final class StreamRecorder: NSObject, SCStreamOutput, SCStreamDelegate, @unchecked Sendable { - let queue = DispatchQueue(label: "ai.openclaw.screenRecord.writer") - - private let logger: Logger - private let writer: AVAssetWriter - private let input: AVAssetWriterInput - private let audioInput: AVAssetWriterInput? - let hasAudio: Bool - - private var started = false - private var sawFrame = false - private var didFinish = false - private var pendingErrorMessage: String? - - init(outputURL: URL, width: Int, height: Int, includeAudio: Bool, logger: Logger) throws { - self.logger = logger - self.writer = try AVAssetWriter(outputURL: outputURL, fileType: .mp4) - - let settings: [String: Any] = [ - AVVideoCodecKey: AVVideoCodecType.h264, - AVVideoWidthKey: width, - AVVideoHeightKey: height, - ] - self.input = AVAssetWriterInput(mediaType: .video, outputSettings: settings) - self.input.expectsMediaDataInRealTime = true - - guard self.writer.canAdd(self.input) else { - throw ScreenRecordService.ScreenRecordError.writeFailed("Cannot add video input") - } - self.writer.add(self.input) - - if includeAudio { - let audioSettings: [String: Any] = [ - AVFormatIDKey: kAudioFormatMPEG4AAC, - AVNumberOfChannelsKey: 1, - AVSampleRateKey: 44100, - AVEncoderBitRateKey: 96000, - ] - let audioInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings) - audioInput.expectsMediaDataInRealTime = true - if self.writer.canAdd(audioInput) { - self.writer.add(audioInput) - self.audioInput = audioInput - self.hasAudio = true - } else { - self.audioInput = nil - self.hasAudio = false - } - } else { - self.audioInput = nil - self.hasAudio = false - } - super.init() - } - - func stream(_ stream: SCStream, didStopWithError error: any Error) { - self.queue.async { - let msg = String(describing: error) - self.pendingErrorMessage = msg - self.logger.error("screen record stream stopped with error: \(msg, privacy: .public)") - _ = stream - } - } - - func stream( - _ stream: SCStream, - didOutputSampleBuffer sampleBuffer: CMSampleBuffer, - of type: SCStreamOutputType) - { - guard CMSampleBufferDataIsReady(sampleBuffer) else { return } - // Callback runs on `sampleHandlerQueue` (`self.queue`). - switch type { - case .screen: - self.handleVideo(sampleBuffer: sampleBuffer) - case .audio: - self.handleAudio(sampleBuffer: sampleBuffer) - case .microphone: - break - @unknown default: - break - } - _ = stream - } - - private func handleVideo(sampleBuffer: CMSampleBuffer) { - if let msg = self.pendingErrorMessage { - self.logger.error("screen record aborting due to prior error: \(msg, privacy: .public)") - return - } - if self.didFinish { return } - - if !self.started { - guard self.writer.startWriting() else { - self.pendingErrorMessage = self.writer.error?.localizedDescription ?? "Failed to start writer" - return - } - let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) - self.writer.startSession(atSourceTime: pts) - self.started = true - } - - self.sawFrame = true - if self.input.isReadyForMoreMediaData { - _ = self.input.append(sampleBuffer) - } - } - - private func handleAudio(sampleBuffer: CMSampleBuffer) { - guard let audioInput else { return } - if let msg = self.pendingErrorMessage { - self.logger.error("screen record audio aborting due to prior error: \(msg, privacy: .public)") - return - } - if self.didFinish || !self.started { return } - if audioInput.isReadyForMoreMediaData { - _ = audioInput.append(sampleBuffer) - } - } - - func finish() async throws { - try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - self.queue.async { - if let msg = self.pendingErrorMessage { - cont.resume(throwing: ScreenRecordService.ScreenRecordError.writeFailed(msg)) - return - } - guard self.started, self.sawFrame else { - cont.resume(throwing: ScreenRecordService.ScreenRecordError.noFramesCaptured) - return - } - if self.didFinish { - cont.resume() - return - } - self.didFinish = true - - self.input.markAsFinished() - self.audioInput?.markAsFinished() - self.writer.finishWriting { - if let err = self.writer.error { - cont - .resume(throwing: ScreenRecordService.ScreenRecordError - .writeFailed(err.localizedDescription)) - } else if self.writer.status != .completed { - cont - .resume(throwing: ScreenRecordService.ScreenRecordError - .writeFailed("Failed to finalize video")) - } else { - cont.resume() - } - } - } - } - } -} diff --git a/apps/macos/Sources/OpenClaw/ScreenshotSize.swift b/apps/macos/Sources/OpenClaw/ScreenshotSize.swift deleted file mode 100644 index e1ad915f58a..00000000000 --- a/apps/macos/Sources/OpenClaw/ScreenshotSize.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Foundation -import ImageIO - -enum ScreenshotSize { - struct Size { - let width: Int - let height: Int - } - - static func readPNGSize(data: Data) -> Size? { - guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { return nil } - guard let props = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any] else { return nil } - guard let width = props[kCGImagePropertyPixelWidth] as? Int else { return nil } - guard let height = props[kCGImagePropertyPixelHeight] as? Int else { return nil } - return Size(width: width, height: height) - } -} diff --git a/apps/macos/Sources/OpenClaw/SessionActions.swift b/apps/macos/Sources/OpenClaw/SessionActions.swift deleted file mode 100644 index 10a3c7641d4..00000000000 --- a/apps/macos/Sources/OpenClaw/SessionActions.swift +++ /dev/null @@ -1,91 +0,0 @@ -import AppKit -import Foundation - -enum SessionActions { - static func patchSession( - key: String, - thinking: String?? = nil, - verbose: String?? = nil) async throws - { - var params: [String: AnyHashable] = ["key": AnyHashable(key)] - - if let thinking { - params["thinkingLevel"] = thinking.map(AnyHashable.init) ?? AnyHashable(NSNull()) - } - if let verbose { - params["verboseLevel"] = verbose.map(AnyHashable.init) ?? AnyHashable(NSNull()) - } - - _ = try await ControlChannel.shared.request(method: "sessions.patch", params: params) - } - - static func resetSession(key: String) async throws { - _ = try await ControlChannel.shared.request( - method: "sessions.reset", - params: ["key": AnyHashable(key)]) - } - - static func deleteSession(key: String) async throws { - _ = try await ControlChannel.shared.request( - method: "sessions.delete", - params: ["key": AnyHashable(key), "deleteTranscript": AnyHashable(true)]) - } - - static func compactSession(key: String, maxLines: Int = 400) async throws { - _ = try await ControlChannel.shared.request( - method: "sessions.compact", - params: ["key": AnyHashable(key), "maxLines": AnyHashable(maxLines)]) - } - - @MainActor - static func confirmDestructiveAction(title: String, message: String, action: String) -> Bool { - let alert = NSAlert() - alert.messageText = title - alert.informativeText = message - alert.addButton(withTitle: action) - alert.addButton(withTitle: "Cancel") - alert.alertStyle = .warning - return alert.runModal() == .alertFirstButtonReturn - } - - @MainActor - static func presentError(title: String, error: Error) { - let alert = NSAlert() - alert.messageText = title - alert.informativeText = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription - alert.addButton(withTitle: "OK") - alert.alertStyle = .warning - alert.runModal() - } - - @MainActor - static func openSessionLogInCode(sessionId: String, storePath: String?) { - let candidates: [URL] = { - var urls: [URL] = [] - if let storePath, !storePath.isEmpty { - let dir = URL(fileURLWithPath: storePath).deletingLastPathComponent() - urls.append(dir.appendingPathComponent("\(sessionId).jsonl")) - } - urls.append(OpenClawPaths.stateDirURL.appendingPathComponent("sessions/\(sessionId).jsonl")) - return urls - }() - - let existing = candidates.first(where: { FileManager().fileExists(atPath: $0.path) }) - guard let url = existing else { - let alert = NSAlert() - alert.messageText = "Session log not found" - alert.informativeText = sessionId - alert.runModal() - return - } - - let proc = Process() - proc.launchPath = "/usr/bin/env" - proc.arguments = ["code", url.path] - if (try? proc.run()) != nil { - return - } - - NSWorkspace.shared.activateFileViewerSelecting([url]) - } -} diff --git a/apps/macos/Sources/OpenClaw/SessionData.swift b/apps/macos/Sources/OpenClaw/SessionData.swift deleted file mode 100644 index 8234cbdef85..00000000000 --- a/apps/macos/Sources/OpenClaw/SessionData.swift +++ /dev/null @@ -1,346 +0,0 @@ -import Foundation -import SwiftUI - -struct GatewaySessionDefaultsRecord: Codable { - let model: String? - let contextTokens: Int? -} - -struct GatewaySessionEntryRecord: Codable { - let key: String - let displayName: String? - let provider: String? - let subject: String? - let room: String? - let space: String? - let updatedAt: Double? - let sessionId: String? - let systemSent: Bool? - let abortedLastRun: Bool? - let thinkingLevel: String? - let verboseLevel: String? - let inputTokens: Int? - let outputTokens: Int? - let totalTokens: Int? - let model: String? - let contextTokens: Int? -} - -struct GatewaySessionsListResponse: Codable { - let ts: Double? - let path: String - let count: Int - let defaults: GatewaySessionDefaultsRecord? - let sessions: [GatewaySessionEntryRecord] -} - -struct SessionTokenStats { - let input: Int - let output: Int - let total: Int - let contextTokens: Int - - var contextSummaryShort: String { - "\(Self.formatKTokens(self.total))/\(Self.formatKTokens(self.contextTokens))" - } - - var percentUsed: Int? { - guard self.contextTokens > 0, self.total > 0 else { return nil } - return min(100, Int(round((Double(self.total) / Double(self.contextTokens)) * 100))) - } - - var summary: String { - let parts = ["in \(input)", "out \(output)", "total \(total)"] - var text = parts.joined(separator: " | ") - if let percentUsed { - text += " (\(percentUsed)% of \(self.contextTokens))" - } - return text - } - - static func formatKTokens(_ value: Int) -> String { - if value < 1000 { return "\(value)" } - let thousands = Double(value) / 1000 - let decimals = value >= 10000 ? 0 : 1 - return String(format: "%.\(decimals)fk", thousands) - } -} - -struct SessionRow: Identifiable { - let id: String - let key: String - let kind: SessionKind - let displayName: String? - let provider: String? - let subject: String? - let room: String? - let space: String? - let updatedAt: Date? - let sessionId: String? - let thinkingLevel: String? - let verboseLevel: String? - let systemSent: Bool - let abortedLastRun: Bool - let tokens: SessionTokenStats - let model: String? - - var ageText: String { - relativeAge(from: self.updatedAt) - } - - var label: String { - self.displayName ?? self.key - } - - var flagLabels: [String] { - var flags: [String] = [] - if let thinkingLevel { flags.append("think \(thinkingLevel)") } - if let verboseLevel { flags.append("verbose \(verboseLevel)") } - if self.systemSent { flags.append("system sent") } - if self.abortedLastRun { flags.append("aborted") } - return flags - } -} - -enum SessionKind { - case direct, group, global, unknown - - static func from(key: String) -> SessionKind { - if key == "global" { return .global } - if key.hasPrefix("group:") { return .group } - if key.contains(":group:") { return .group } - if key.contains(":channel:") { return .group } - if key == "unknown" { return .unknown } - return .direct - } - - var label: String { - switch self { - case .direct: "Direct" - case .group: "Group" - case .global: "Global" - case .unknown: "Unknown" - } - } - - var tint: Color { - switch self { - case .direct: .accentColor - case .group: .orange - case .global: .purple - case .unknown: .gray - } - } -} - -struct SessionDefaults { - let model: String - let contextTokens: Int -} - -extension SessionRow { - static var previewRows: [SessionRow] { - [ - SessionRow( - id: "direct-1", - key: "user@example.com", - kind: .direct, - displayName: nil, - provider: nil, - subject: nil, - room: nil, - space: nil, - updatedAt: Date().addingTimeInterval(-90), - sessionId: "sess-direct-1234", - thinkingLevel: "low", - verboseLevel: "info", - systemSent: false, - abortedLastRun: false, - tokens: SessionTokenStats(input: 320, output: 680, total: 1000, contextTokens: 200_000), - model: "claude-3.5-sonnet"), - SessionRow( - id: "group-1", - key: "discord:channel:release-squad", - kind: .group, - displayName: "discord:#release-squad", - provider: "discord", - subject: nil, - room: "#release-squad", - space: nil, - updatedAt: Date().addingTimeInterval(-3600), - sessionId: "sess-group-4321", - thinkingLevel: "medium", - verboseLevel: nil, - systemSent: true, - abortedLastRun: true, - tokens: SessionTokenStats(input: 5000, output: 1200, total: 6200, contextTokens: 200_000), - model: "claude-opus-4-6"), - SessionRow( - id: "global", - key: "global", - kind: .global, - displayName: nil, - provider: nil, - subject: nil, - room: nil, - space: nil, - updatedAt: Date().addingTimeInterval(-86400), - sessionId: nil, - thinkingLevel: nil, - verboseLevel: nil, - systemSent: false, - abortedLastRun: false, - tokens: SessionTokenStats(input: 150, output: 220, total: 370, contextTokens: 200_000), - model: "gpt-4.1-mini"), - ] - } -} - -struct ModelChoice: Identifiable, Hashable, Codable { - let id: String - let name: String - let provider: String - let contextWindow: Int? -} - -extension String? { - var isNilOrEmpty: Bool { - switch self { - case .none: true - case let .some(value): value.isEmpty - } - } -} - -extension [String] { - fileprivate func dedupedPreserveOrder() -> [String] { - var seen = Set() - var result: [String] = [] - for item in self where !seen.contains(item) { - seen.insert(item) - result.append(item) - } - return result - } -} - -enum SessionLoadError: LocalizedError { - case gatewayUnavailable(String) - case decodeFailed(String) - - var errorDescription: String? { - switch self { - case let .gatewayUnavailable(reason): - "Could not reach the gateway for sessions: \(reason)" - - case let .decodeFailed(reason): - "Could not decode gateway session payload: \(reason)" - } - } -} - -struct SessionStoreSnapshot { - let storePath: String - let defaults: SessionDefaults - let rows: [SessionRow] -} - -@MainActor -enum SessionLoader { - static let fallbackModel = "claude-opus-4-6" - static let fallbackContextTokens = 200_000 - - static let defaultStorePath = standardize( - OpenClawPaths.stateDirURL - .appendingPathComponent("sessions/sessions.json").path) - - static func loadSnapshot( - activeMinutes: Int? = nil, - limit: Int? = nil, - includeGlobal: Bool = true, - includeUnknown: Bool = true) async throws -> SessionStoreSnapshot - { - var params: [String: AnyHashable] = [ - "includeGlobal": AnyHashable(includeGlobal), - "includeUnknown": AnyHashable(includeUnknown), - ] - if let activeMinutes { params["activeMinutes"] = AnyHashable(activeMinutes) } - if let limit { params["limit"] = AnyHashable(limit) } - - let data: Data - do { - data = try await ControlChannel.shared.request(method: "sessions.list", params: params) - } catch { - let msg = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription - if msg.localizedCaseInsensitiveContains("unknown method: sessions.list") { - throw SessionLoadError.gatewayUnavailable( - "Gateway is too old (missing sessions.list). Restart/update the gateway.") - } - throw SessionLoadError.gatewayUnavailable(msg) - } - - let decoded: GatewaySessionsListResponse - do { - decoded = try JSONDecoder().decode(GatewaySessionsListResponse.self, from: data) - } catch { - throw SessionLoadError.decodeFailed(error.localizedDescription) - } - - let defaults = SessionDefaults( - model: decoded.defaults?.model ?? self.fallbackModel, - contextTokens: decoded.defaults?.contextTokens ?? self.fallbackContextTokens) - - let rows = decoded.sessions.map { entry -> SessionRow in - let updated = entry.updatedAt.map { Date(timeIntervalSince1970: $0 / 1000) } - let input = entry.inputTokens ?? 0 - let output = entry.outputTokens ?? 0 - let total = entry.totalTokens ?? input + output - let context = entry.contextTokens ?? defaults.contextTokens - let model = entry.model ?? defaults.model - - return SessionRow( - id: entry.key, - key: entry.key, - kind: SessionKind.from(key: entry.key), - displayName: entry.displayName, - provider: entry.provider, - subject: entry.subject, - room: entry.room, - space: entry.space, - updatedAt: updated, - sessionId: entry.sessionId, - thinkingLevel: entry.thinkingLevel, - verboseLevel: entry.verboseLevel, - systemSent: entry.systemSent ?? false, - abortedLastRun: entry.abortedLastRun ?? false, - tokens: SessionTokenStats( - input: input, - output: output, - total: total, - contextTokens: context), - model: model) - }.sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) } - - return SessionStoreSnapshot(storePath: decoded.path, defaults: defaults, rows: rows) - } - - static func loadRows() async throws -> [SessionRow] { - try await self.loadSnapshot().rows - } - - private static func standardize(_ path: String) -> String { - (path as NSString).expandingTildeInPath.replacingOccurrences(of: "//", with: "/") - } -} - -func relativeAge(from date: Date?) -> String { - guard let date else { return "unknown" } - let delta = Date().timeIntervalSince(date) - if delta < 60 { return "just now" } - let minutes = Int(round(delta / 60)) - if minutes < 60 { return "\(minutes)m ago" } - let hours = Int(round(Double(minutes) / 60)) - if hours < 48 { return "\(hours)h ago" } - let days = Int(round(Double(hours) / 24)) - return "\(days)d ago" -} diff --git a/apps/macos/Sources/OpenClaw/SessionMenuLabelView.swift b/apps/macos/Sources/OpenClaw/SessionMenuLabelView.swift deleted file mode 100644 index 51646e0a36a..00000000000 --- a/apps/macos/Sources/OpenClaw/SessionMenuLabelView.swift +++ /dev/null @@ -1,58 +0,0 @@ -import SwiftUI - -extension EnvironmentValues { - @Entry var menuItemHighlighted: Bool = false -} - -struct SessionMenuLabelView: View { - let row: SessionRow - let width: CGFloat - @Environment(\.menuItemHighlighted) private var isHighlighted - private let paddingLeading: CGFloat = 22 - private let paddingTrailing: CGFloat = 14 - private let barHeight: CGFloat = 6 - - private var primaryTextColor: Color { - self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary - } - - private var secondaryTextColor: Color { - self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary - } - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - ContextUsageBar( - usedTokens: self.row.tokens.total, - contextTokens: self.row.tokens.contextTokens, - width: max(1, self.width - (self.paddingLeading + self.paddingTrailing)), - height: self.barHeight) - - HStack(alignment: .firstTextBaseline, spacing: 2) { - Text(self.row.label) - .font(.caption.weight(self.row.key == "main" ? .semibold : .regular)) - .foregroundStyle(self.primaryTextColor) - .lineLimit(1) - .truncationMode(.middle) - .layoutPriority(1) - - Spacer(minLength: 4) - - Text("\(self.row.tokens.contextSummaryShort) · \(self.row.ageText)") - .font(.caption.monospacedDigit()) - .foregroundStyle(self.secondaryTextColor) - .lineLimit(1) - .fixedSize(horizontal: true, vertical: false) - .layoutPriority(2) - - Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) - .foregroundStyle(self.secondaryTextColor) - .padding(.leading, 2) - } - } - .padding(.vertical, 10) - .padding(.leading, self.paddingLeading) - .padding(.trailing, self.paddingTrailing) - } -} diff --git a/apps/macos/Sources/OpenClaw/SessionMenuPreviewView.swift b/apps/macos/Sources/OpenClaw/SessionMenuPreviewView.swift deleted file mode 100644 index 8840bce5569..00000000000 --- a/apps/macos/Sources/OpenClaw/SessionMenuPreviewView.swift +++ /dev/null @@ -1,495 +0,0 @@ -import OpenClawChatUI -import OpenClawKit -import OpenClawProtocol -import OSLog -import SwiftUI - -struct SessionPreviewItem: Identifiable, Sendable { - let id: String - let role: PreviewRole - let text: String -} - -enum PreviewRole: String, Sendable { - case user - case assistant - case tool - case system - case other - - var label: String { - switch self { - case .user: "User" - case .assistant: "Agent" - case .tool: "Tool" - case .system: "System" - case .other: "Other" - } - } -} - -actor SessionPreviewCache { - static let shared = SessionPreviewCache() - - private struct CacheEntry { - let snapshot: SessionMenuPreviewSnapshot - let updatedAt: Date - } - - private var entries: [String: CacheEntry] = [:] - - func cachedSnapshot(for sessionKey: String, maxAge: TimeInterval) -> SessionMenuPreviewSnapshot? { - guard let entry = self.entries[sessionKey] else { return nil } - guard Date().timeIntervalSince(entry.updatedAt) < maxAge else { return nil } - return entry.snapshot - } - - func store(snapshot: SessionMenuPreviewSnapshot, for sessionKey: String) { - self.entries[sessionKey] = CacheEntry(snapshot: snapshot, updatedAt: Date()) - } - - func lastSnapshot(for sessionKey: String) -> SessionMenuPreviewSnapshot? { - self.entries[sessionKey]?.snapshot - } -} - -actor SessionPreviewLimiter { - static let shared = SessionPreviewLimiter(maxConcurrent: 2) - - private let maxConcurrent: Int - private var available: Int - private var waitQueue: [UUID] = [] - private var waiters: [UUID: CheckedContinuation] = [:] - - init(maxConcurrent: Int) { - let normalized = max(1, maxConcurrent) - self.maxConcurrent = normalized - self.available = normalized - } - - func withPermit(_ operation: () async throws -> T) async throws -> T { - await self.acquire() - defer { self.release() } - if Task.isCancelled { throw CancellationError() } - return try await operation() - } - - private func acquire() async { - if self.available > 0 { - self.available -= 1 - return - } - let id = UUID() - await withCheckedContinuation { cont in - self.waitQueue.append(id) - self.waiters[id] = cont - } - } - - private func release() { - if let id = self.waitQueue.first { - self.waitQueue.removeFirst() - if let cont = self.waiters.removeValue(forKey: id) { - cont.resume() - } - return - } - self.available = min(self.available + 1, self.maxConcurrent) - } -} - -#if DEBUG -extension SessionPreviewCache { - func _testSet( - snapshot: SessionMenuPreviewSnapshot, - for sessionKey: String, - updatedAt: Date = Date()) - { - self.entries[sessionKey] = CacheEntry(snapshot: snapshot, updatedAt: updatedAt) - } - - func _testReset() { - self.entries = [:] - } -} -#endif - -struct SessionMenuPreviewSnapshot: Sendable { - let items: [SessionPreviewItem] - let status: SessionMenuPreviewView.LoadStatus -} - -struct SessionMenuPreviewView: View { - let width: CGFloat - let maxLines: Int - let title: String - let items: [SessionPreviewItem] - let status: LoadStatus - - @Environment(\.menuItemHighlighted) private var isHighlighted - - enum LoadStatus: Equatable { - case loading - case ready - case empty - case error(String) - } - - private var primaryColor: Color { - if self.isHighlighted { - return Color(nsColor: .selectedMenuItemTextColor) - } - return Color(nsColor: .labelColor) - } - - private var secondaryColor: Color { - if self.isHighlighted { - return Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) - } - return Color(nsColor: .secondaryLabelColor) - } - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .firstTextBaseline, spacing: 4) { - Text(self.title) - .font(.caption.weight(.semibold)) - .foregroundStyle(self.secondaryColor) - Spacer(minLength: 8) - } - - switch self.status { - case .loading: - self.placeholder("Loading preview…") - case .empty: - self.placeholder("No recent messages") - case let .error(message): - self.placeholder(message) - case .ready: - if self.items.isEmpty { - self.placeholder("No recent messages") - } else { - VStack(alignment: .leading, spacing: 6) { - ForEach(self.items) { item in - self.previewRow(item) - } - } - } - } - } - .padding(.vertical, 6) - .padding(.leading, 16) - .padding(.trailing, 11) - .frame(width: max(1, self.width), alignment: .leading) - } - - private func previewRow(_ item: SessionPreviewItem) -> some View { - HStack(alignment: .top, spacing: 4) { - Text(item.role.label) - .font(.caption2.monospacedDigit()) - .foregroundStyle(self.roleColor(item.role)) - .frame(width: 50, alignment: .leading) - - Text(item.text) - .font(.caption) - .foregroundStyle(self.primaryColor) - .multilineTextAlignment(.leading) - .lineLimit(self.maxLines) - .truncationMode(.tail) - .fixedSize(horizontal: false, vertical: true) - } - } - - private func roleColor(_ role: PreviewRole) -> Color { - if self.isHighlighted { return Color(nsColor: .selectedMenuItemTextColor).opacity(0.9) } - switch role { - case .user: return .accentColor - case .assistant: return .secondary - case .tool: return .orange - case .system: return .gray - case .other: return .secondary - } - } - - private func placeholder(_ text: String) -> some View { - Text(text) - .font(.caption) - .foregroundStyle(self.primaryColor) - } -} - -enum SessionMenuPreviewLoader { - private static let logger = Logger(subsystem: "ai.openclaw", category: "SessionPreview") - private static let previewTimeoutSeconds: Double = 4 - private static let cacheMaxAgeSeconds: TimeInterval = 30 - private static let previewMaxChars = 240 - - private struct PreviewTimeoutError: LocalizedError { - var errorDescription: String? { - "preview timeout" - } - } - - static func prewarm(sessionKeys: [String], maxItems: Int) async { - let keys = self.uniqueKeys(sessionKeys) - guard !keys.isEmpty else { return } - do { - let payload = try await self.requestPreview(keys: keys, maxItems: maxItems) - await self.cache(payload: payload, maxItems: maxItems) - } catch { - if self.isUnknownMethodError(error) { return } - let errorDescription = String(describing: error) - Self.logger.debug( - "Session preview prewarm failed count=\(keys.count, privacy: .public) " + - "error=\(errorDescription, privacy: .public)") - } - } - - static func load(sessionKey: String, maxItems: Int) async -> SessionMenuPreviewSnapshot { - if let cached = await SessionPreviewCache.shared.cachedSnapshot( - for: sessionKey, - maxAge: cacheMaxAgeSeconds) - { - return cached - } - - do { - let snapshot = try await self.fetchSnapshot(sessionKey: sessionKey, maxItems: maxItems) - await SessionPreviewCache.shared.store(snapshot: snapshot, for: sessionKey) - return snapshot - } catch is CancellationError { - return SessionMenuPreviewSnapshot(items: [], status: .loading) - } catch { - if let fallback = await SessionPreviewCache.shared.lastSnapshot(for: sessionKey) { - return fallback - } - let errorDescription = String(describing: error) - Self.logger.warning( - "Session preview failed session=\(sessionKey, privacy: .public) " + - "error=\(errorDescription, privacy: .public)") - return SessionMenuPreviewSnapshot(items: [], status: .error("Preview unavailable")) - } - } - - private static func fetchSnapshot(sessionKey: String, maxItems: Int) async throws -> SessionMenuPreviewSnapshot { - do { - let payload = try await self.requestPreview(keys: [sessionKey], maxItems: maxItems) - if let entry = payload.previews.first(where: { $0.key == sessionKey }) ?? payload.previews.first { - return self.snapshot(from: entry, maxItems: maxItems) - } - return SessionMenuPreviewSnapshot(items: [], status: .error("Preview unavailable")) - } catch { - if self.isUnknownMethodError(error) { - return try await self.fetchHistorySnapshot(sessionKey: sessionKey, maxItems: maxItems) - } - throw error - } - } - - private static func requestPreview( - keys: [String], - maxItems: Int) async throws -> OpenClawSessionsPreviewPayload - { - let boundedItems = self.normalizeMaxItems(maxItems) - let timeoutMs = Int(self.previewTimeoutSeconds * 1000) - return try await SessionPreviewLimiter.shared.withPermit { - try await AsyncTimeout.withTimeout( - seconds: self.previewTimeoutSeconds, - onTimeout: { PreviewTimeoutError() }, - operation: { - try await GatewayConnection.shared.sessionsPreview( - keys: keys, - limit: boundedItems, - maxChars: self.previewMaxChars, - timeoutMs: timeoutMs) - }) - } - } - - private static func fetchHistorySnapshot( - sessionKey: String, - maxItems: Int) async throws -> SessionMenuPreviewSnapshot - { - let timeoutMs = Int(self.previewTimeoutSeconds * 1000) - let payload = try await SessionPreviewLimiter.shared.withPermit { - try await AsyncTimeout.withTimeout( - seconds: self.previewTimeoutSeconds, - onTimeout: { PreviewTimeoutError() }, - operation: { - try await GatewayConnection.shared.chatHistory( - sessionKey: sessionKey, - limit: self.previewLimit(for: maxItems), - timeoutMs: timeoutMs) - }) - } - let built = Self.previewItems(from: payload, maxItems: maxItems) - return Self.snapshot(from: built) - } - - private static func snapshot(from items: [SessionPreviewItem]) -> SessionMenuPreviewSnapshot { - SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready) - } - - private static func snapshot( - from entry: OpenClawSessionPreviewEntry, - maxItems: Int) -> SessionMenuPreviewSnapshot - { - let items = self.previewItems(from: entry, maxItems: maxItems) - let normalized = entry.status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - switch normalized { - case "ok": - return SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready) - case "empty": - return SessionMenuPreviewSnapshot(items: items, status: .empty) - case "missing": - return SessionMenuPreviewSnapshot(items: items, status: .error("Session missing")) - default: - return SessionMenuPreviewSnapshot(items: items, status: .error("Preview unavailable")) - } - } - - private static func cache(payload: OpenClawSessionsPreviewPayload, maxItems: Int) async { - for entry in payload.previews { - let snapshot = self.snapshot(from: entry, maxItems: maxItems) - await SessionPreviewCache.shared.store(snapshot: snapshot, for: entry.key) - } - } - - private static func previewLimit(for maxItems: Int) -> Int { - let boundedItems = self.normalizeMaxItems(maxItems) - return min(max(boundedItems * 3, 20), 120) - } - - private static func normalizeMaxItems(_ maxItems: Int) -> Int { - max(1, min(maxItems, 50)) - } - - private static func previewItems( - from entry: OpenClawSessionPreviewEntry, - maxItems: Int) -> [SessionPreviewItem] - { - let boundedItems = self.normalizeMaxItems(maxItems) - let built: [SessionPreviewItem] = entry.items.enumerated().compactMap { index, item in - let text = item.text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty else { return nil } - let role = self.previewRoleFromRaw(item.role) - return SessionPreviewItem(id: "\(entry.key)-\(index)", role: role, text: text) - } - - let trimmed = built.suffix(boundedItems) - return Array(trimmed.reversed()) - } - - private static func previewItems( - from payload: OpenClawChatHistoryPayload, - maxItems: Int) -> [SessionPreviewItem] - { - let boundedItems = self.normalizeMaxItems(maxItems) - let raw: [OpenClawKit.AnyCodable] = payload.messages ?? [] - let messages = self.decodeMessages(raw) - let built = messages.compactMap { message -> SessionPreviewItem? in - guard let text = self.previewText(for: message) else { return nil } - let isTool = self.isToolCall(message) - let role = self.previewRole(message.role, isTool: isTool) - let id = "\(message.timestamp ?? 0)-\(UUID().uuidString)" - return SessionPreviewItem(id: id, role: role, text: text) - } - - let trimmed = built.suffix(boundedItems) - return Array(trimmed.reversed()) - } - - private static func decodeMessages(_ raw: [OpenClawKit.AnyCodable]) -> [OpenClawChatMessage] { - raw.compactMap { item in - guard let data = try? JSONEncoder().encode(item) else { return nil } - return try? JSONDecoder().decode(OpenClawChatMessage.self, from: data) - } - } - - private static func previewRole(_ raw: String, isTool: Bool) -> PreviewRole { - if isTool { return .tool } - return self.previewRoleFromRaw(raw) - } - - private static func previewRoleFromRaw(_ raw: String) -> PreviewRole { - switch raw.lowercased() { - case "user": .user - case "assistant": .assistant - case "system": .system - case "tool": .tool - default: .other - } - } - - private static func previewText(for message: OpenClawChatMessage) -> String? { - let text = message.content.compactMap(\.text).joined(separator: "\n") - .trimmingCharacters(in: .whitespacesAndNewlines) - if !text.isEmpty { return text } - - let toolNames = self.toolNames(for: message) - if !toolNames.isEmpty { - let shown = toolNames.prefix(2) - let overflow = toolNames.count - shown.count - var label = "call \(shown.joined(separator: ", "))" - if overflow > 0 { label += " +\(overflow)" } - return label - } - - if let media = self.mediaSummary(for: message) { - return media - } - - return nil - } - - private static func isToolCall(_ message: OpenClawChatMessage) -> Bool { - if message.toolName?.nonEmpty != nil { return true } - return message.content.contains { $0.name?.nonEmpty != nil || $0.type?.lowercased() == "toolcall" } - } - - private static func toolNames(for message: OpenClawChatMessage) -> [String] { - var names: [String] = [] - for content in message.content { - if let name = content.name?.nonEmpty { - names.append(name) - } - } - if let toolName = message.toolName?.nonEmpty { - names.append(toolName) - } - return Self.dedupePreservingOrder(names) - } - - private static func mediaSummary(for message: OpenClawChatMessage) -> String? { - let types = message.content.compactMap { content -> String? in - let raw = content.type?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard let raw, !raw.isEmpty else { return nil } - if raw == "text" || raw == "toolcall" { return nil } - return raw - } - guard let first = types.first else { return nil } - return "[\(first)]" - } - - private static func dedupePreservingOrder(_ values: [String]) -> [String] { - var seen = Set() - var result: [String] = [] - for value in values where !seen.contains(value) { - seen.insert(value) - result.append(value) - } - return result - } - - private static func uniqueKeys(_ keys: [String]) -> [String] { - let trimmed = keys.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - return self.dedupePreservingOrder(trimmed.filter { !$0.isEmpty }) - } - - private static func isUnknownMethodError(_ error: Error) -> Bool { - guard let response = error as? GatewayResponseError else { return false } - guard response.code == ErrorCode.invalidRequest.rawValue else { return false } - let message = response.message.lowercased() - return message.contains("unknown method") - } -} diff --git a/apps/macos/Sources/OpenClaw/SessionsSettings.swift b/apps/macos/Sources/OpenClaw/SessionsSettings.swift deleted file mode 100644 index 826f1128f54..00000000000 --- a/apps/macos/Sources/OpenClaw/SessionsSettings.swift +++ /dev/null @@ -1,212 +0,0 @@ -import AppKit -import SwiftUI - -@MainActor -struct SessionsSettings: View { - private let isPreview: Bool - @State private var rows: [SessionRow] - @State private var errorMessage: String? - @State private var loading = false - @State private var hasLoaded = false - - init(rows: [SessionRow]? = nil, isPreview: Bool = ProcessInfo.processInfo.isPreview) { - self._rows = State(initialValue: rows ?? []) - self.isPreview = isPreview - if isPreview { - self._hasLoaded = State(initialValue: true) - } - } - - var body: some View { - VStack(alignment: .leading, spacing: 14) { - self.header - self.content - Spacer() - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 12) - .task { - guard !self.hasLoaded else { return } - guard !self.isPreview else { return } - self.hasLoaded = true - await self.refresh() - } - } - - private var header: some View { - HStack(alignment: .top, spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text("Sessions") - .font(.headline) - Text("Peek at the stored conversation buckets the CLI reuses for context and rate limits.") - .font(.footnote) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - Spacer() - if self.loading { - ProgressView() - } else { - Button { - Task { await self.refresh() } - } label: { - Label("Refresh", systemImage: "arrow.clockwise") - } - .buttonStyle(.bordered) - .help("Refresh") - } - } - } - - private var content: some View { - Group { - if self.rows.isEmpty, self.errorMessage == nil { - Text("No sessions yet. They appear after the first inbound message or heartbeat.") - .font(.footnote) - .foregroundStyle(.secondary) - .padding(.top, 6) - } else { - List(self.rows) { row in - self.sessionRow(row) - } - .listStyle(.inset) - .overlay(alignment: .topLeading) { - if let errorMessage { - Text(errorMessage) - .font(.footnote) - .foregroundStyle(.red) - .padding(.leading, 4) - .padding(.top, 4) - } - } - // The view already applies horizontal padding; keep the list aligned with the text above. - .padding(.horizontal, -12) - } - } - } - - private func sessionRow(_ row: SessionRow) -> some View { - VStack(alignment: .leading, spacing: 6) { - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(row.label) - .font(.subheadline.bold()) - .lineLimit(1) - .truncationMode(.middle) - Spacer() - Text(row.ageText) - .font(.caption) - .foregroundStyle(.secondary) - } - - HStack(spacing: 6) { - if row.kind != .direct { - SessionKindBadge(kind: row.kind) - } - if !row.flagLabels.isEmpty { - ForEach(row.flagLabels, id: \.self) { flag in - Badge(text: flag) - } - } - } - - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 8) { - Text("Context") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - Spacer() - Text(row.tokens.contextSummaryShort) - .font(.caption.monospacedDigit()) - .foregroundStyle(.secondary) - } - ContextUsageBar( - usedTokens: row.tokens.total, - contextTokens: row.tokens.contextTokens, - width: nil) - } - - HStack(spacing: 10) { - if let model = row.model, !model.isEmpty { - self.label(icon: "cpu", text: model) - } - self.label(icon: "arrow.down.left", text: "\(row.tokens.input) in") - self.label(icon: "arrow.up.right", text: "\(row.tokens.output) out") - if let sessionId = row.sessionId, !sessionId.isEmpty { - HStack(spacing: 4) { - Image(systemName: "number").foregroundStyle(.secondary).font(.caption) - Text(sessionId) - .font(.footnote.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - } - .help(sessionId) - } - } - } - .padding(.vertical, 6) - } - - private func label(icon: String, text: String) -> some View { - HStack(spacing: 4) { - Image(systemName: icon).foregroundStyle(.secondary).font(.caption) - Text(text) - } - .font(.footnote) - .foregroundStyle(.secondary) - } - - private func refresh() async { - guard !self.loading else { return } - guard !self.isPreview else { return } - self.loading = true - self.errorMessage = nil - - do { - let snapshot = try await SessionLoader.loadSnapshot() - self.rows = snapshot.rows - } catch { - self.rows = [] - self.errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription - } - - self.loading = false - } -} - -private struct SessionKindBadge: View { - let kind: SessionKind - - var body: some View { - Text(self.kind.label) - .font(.caption2.weight(.bold)) - .padding(.horizontal, 7) - .padding(.vertical, 4) - .foregroundStyle(self.kind.tint) - .background(self.kind.tint.opacity(0.15)) - .clipShape(Capsule()) - } -} - -private struct Badge: View { - let text: String - - var body: some View { - Text(self.text) - .font(.caption2.weight(.semibold)) - .padding(.horizontal, 6) - .padding(.vertical, 3) - .foregroundStyle(.secondary) - .background(Color.secondary.opacity(0.12)) - .clipShape(Capsule()) - } -} - -#if DEBUG -struct SessionsSettings_Previews: PreviewProvider { - static var previews: some View { - SessionsSettings(rows: SessionRow.previewRows, isPreview: true) - .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) - } -} -#endif diff --git a/apps/macos/Sources/OpenClaw/SettingsComponents.swift b/apps/macos/Sources/OpenClaw/SettingsComponents.swift deleted file mode 100644 index f826fd4e52c..00000000000 --- a/apps/macos/Sources/OpenClaw/SettingsComponents.swift +++ /dev/null @@ -1,24 +0,0 @@ -import SwiftUI - -struct SettingsToggleRow: View { - let title: String - let subtitle: String? - @Binding var binding: Bool - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - Toggle(isOn: self.$binding) { - Text(self.title) - .font(.body) - } - .toggleStyle(.checkbox) - - if let subtitle, !subtitle.isEmpty { - Text(subtitle) - .font(.footnote) - .foregroundStyle(.tertiary) - .fixedSize(horizontal: false, vertical: true) - } - } - } -} diff --git a/apps/macos/Sources/OpenClaw/SettingsRootView.swift b/apps/macos/Sources/OpenClaw/SettingsRootView.swift deleted file mode 100644 index 016e2f3d1c7..00000000000 --- a/apps/macos/Sources/OpenClaw/SettingsRootView.swift +++ /dev/null @@ -1,243 +0,0 @@ -import Observation -import SwiftUI - -struct SettingsRootView: View { - @Bindable var state: AppState - private let permissionMonitor = PermissionMonitor.shared - @State private var monitoringPermissions = false - @State private var selectedTab: SettingsTab = .general - @State private var snapshotPaths: (configPath: String?, stateDir: String?) = (nil, nil) - let updater: UpdaterProviding? - private let isPreview = ProcessInfo.processInfo.isPreview - private let isNixMode = ProcessInfo.processInfo.isNixMode - - init(state: AppState, updater: UpdaterProviding?, initialTab: SettingsTab? = nil) { - self.state = state - self.updater = updater - self._selectedTab = State(initialValue: initialTab ?? .general) - } - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - if self.isNixMode { - self.nixManagedBanner - } - TabView(selection: self.$selectedTab) { - GeneralSettings(state: self.state) - .tabItem { Label("General", systemImage: "gearshape") } - .tag(SettingsTab.general) - - ChannelsSettings() - .tabItem { Label("Channels", systemImage: "link") } - .tag(SettingsTab.channels) - - VoiceWakeSettings(state: self.state, isActive: self.selectedTab == .voiceWake) - .tabItem { Label("Voice Wake", systemImage: "waveform.circle") } - .tag(SettingsTab.voiceWake) - - ConfigSettings() - .tabItem { Label("Config", systemImage: "slider.horizontal.3") } - .tag(SettingsTab.config) - - InstancesSettings() - .tabItem { Label("Instances", systemImage: "network") } - .tag(SettingsTab.instances) - - SessionsSettings() - .tabItem { Label("Sessions", systemImage: "clock.arrow.circlepath") } - .tag(SettingsTab.sessions) - - CronSettings() - .tabItem { Label("Cron", systemImage: "calendar") } - .tag(SettingsTab.cron) - - SkillsSettings(state: self.state) - .tabItem { Label("Skills", systemImage: "sparkles") } - .tag(SettingsTab.skills) - - PermissionsSettings( - status: self.permissionMonitor.status, - refresh: self.refreshPerms, - showOnboarding: { DebugActions.restartOnboarding() }) - .tabItem { Label("Permissions", systemImage: "lock.shield") } - .tag(SettingsTab.permissions) - - if self.state.debugPaneEnabled { - DebugSettings(state: self.state) - .tabItem { Label("Debug", systemImage: "ant") } - .tag(SettingsTab.debug) - } - - AboutSettings(updater: self.updater) - .tabItem { Label("About", systemImage: "info.circle") } - .tag(SettingsTab.about) - } - } - .padding(.horizontal, 28) - .padding(.vertical, 22) - .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight, alignment: .topLeading) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .onReceive(NotificationCenter.default.publisher(for: .openclawSelectSettingsTab)) { note in - if let tab = note.object as? SettingsTab { - withAnimation(.spring(response: 0.32, dampingFraction: 0.85)) { - self.selectedTab = tab - } - } - } - .onAppear { - if let pending = SettingsTabRouter.consumePending() { - self.selectedTab = self.validTab(for: pending) - } - self.updatePermissionMonitoring(for: self.selectedTab) - } - .onChange(of: self.state.debugPaneEnabled) { _, enabled in - if !enabled, self.selectedTab == .debug { - self.selectedTab = .general - } - } - .onChange(of: self.selectedTab) { _, newValue in - self.updatePermissionMonitoring(for: newValue) - } - .onDisappear { self.stopPermissionMonitoring() } - .task { - guard !self.isPreview else { return } - await self.refreshPerms() - } - .task(id: self.state.connectionMode) { - guard !self.isPreview else { return } - await self.refreshSnapshotPaths() - } - } - - private var nixManagedBanner: some View { - // Prefer gateway-resolved paths; fall back to local env defaults if disconnected. - let configPath = self.snapshotPaths.configPath ?? OpenClawPaths.configURL.path - let stateDir = self.snapshotPaths.stateDir ?? OpenClawPaths.stateDirURL.path - - return VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 8) { - Image(systemName: "gearshape.2.fill") - .foregroundStyle(.secondary) - Text("Managed by Nix") - .font(.callout.weight(.semibold)) - .foregroundStyle(.secondary) - } - - VStack(alignment: .leading, spacing: 2) { - Text("Config: \(configPath)") - Text("State: \(stateDir)") - } - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .textSelection(.enabled) - .lineLimit(1) - .truncationMode(.middle) - } - .padding(.vertical, 8) - .padding(.horizontal, 10) - .background(Color.gray.opacity(0.12)) - .cornerRadius(10) - } - - private func validTab(for requested: SettingsTab) -> SettingsTab { - if requested == .debug, !self.state.debugPaneEnabled { return .general } - return requested - } - - @MainActor - private func refreshSnapshotPaths() async { - let paths = await GatewayConnection.shared.snapshotPaths() - self.snapshotPaths = paths - } - - @MainActor - private func refreshPerms() async { - guard !self.isPreview else { return } - await self.permissionMonitor.refreshNow() - } - - private func updatePermissionMonitoring(for tab: SettingsTab) { - guard !self.isPreview else { return } - let shouldMonitor = tab == .permissions - if shouldMonitor, !self.monitoringPermissions { - self.monitoringPermissions = true - PermissionMonitor.shared.register() - } else if !shouldMonitor, self.monitoringPermissions { - self.monitoringPermissions = false - PermissionMonitor.shared.unregister() - } - } - - private func stopPermissionMonitoring() { - guard self.monitoringPermissions else { return } - self.monitoringPermissions = false - PermissionMonitor.shared.unregister() - } -} - -enum SettingsTab: CaseIterable { - case general, channels, skills, sessions, cron, config, instances, voiceWake, permissions, debug, about - static let windowWidth: CGFloat = 824 // wider - static let windowHeight: CGFloat = 790 // +10% (more room) - var title: String { - switch self { - case .general: "General" - case .channels: "Channels" - case .skills: "Skills" - case .sessions: "Sessions" - case .cron: "Cron" - case .config: "Config" - case .instances: "Instances" - case .voiceWake: "Voice Wake" - case .permissions: "Permissions" - case .debug: "Debug" - case .about: "About" - } - } - - var systemImage: String { - switch self { - case .general: "gearshape" - case .channels: "link" - case .skills: "sparkles" - case .sessions: "clock.arrow.circlepath" - case .cron: "calendar" - case .config: "slider.horizontal.3" - case .instances: "network" - case .voiceWake: "waveform.circle" - case .permissions: "lock.shield" - case .debug: "ant" - case .about: "info.circle" - } - } -} - -@MainActor -enum SettingsTabRouter { - private static var pending: SettingsTab? - - static func request(_ tab: SettingsTab) { - self.pending = tab - } - - static func consumePending() -> SettingsTab? { - defer { self.pending = nil } - return self.pending - } -} - -extension Notification.Name { - static let openclawSelectSettingsTab = Notification.Name("openclawSelectSettingsTab") -} - -#if DEBUG -struct SettingsRootView_Previews: PreviewProvider { - static var previews: some View { - ForEach(SettingsTab.allCases, id: \.self) { tab in - SettingsRootView(state: .preview, updater: DisabledUpdaterController(), initialTab: tab) - .previewDisplayName(tab.title) - .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) - } - } -} -#endif diff --git a/apps/macos/Sources/OpenClaw/SettingsWindowOpener.swift b/apps/macos/Sources/OpenClaw/SettingsWindowOpener.swift deleted file mode 100644 index 9cc1647b6f5..00000000000 --- a/apps/macos/Sources/OpenClaw/SettingsWindowOpener.swift +++ /dev/null @@ -1,36 +0,0 @@ -import AppKit -import SwiftUI - -@objc -private protocol SettingsWindowMenuActions { - @objc(showSettingsWindow:) - optional func showSettingsWindow(_ sender: Any?) - - @objc(showPreferencesWindow:) - optional func showPreferencesWindow(_ sender: Any?) -} - -@MainActor -final class SettingsWindowOpener { - static let shared = SettingsWindowOpener() - - private var openSettingsAction: OpenSettingsAction? - - func register(openSettings: OpenSettingsAction) { - self.openSettingsAction = openSettings - } - - func open() { - NSApp.activate(ignoringOtherApps: true) - if let openSettingsAction { - openSettingsAction() - return - } - - // Fallback path: mimic the built-in Settings menu item action. - let didOpen = NSApp.sendAction(#selector(SettingsWindowMenuActions.showSettingsWindow(_:)), to: nil, from: nil) - if !didOpen { - _ = NSApp.sendAction(#selector(SettingsWindowMenuActions.showPreferencesWindow(_:)), to: nil, from: nil) - } - } -} diff --git a/apps/macos/Sources/OpenClaw/ShellExecutor.swift b/apps/macos/Sources/OpenClaw/ShellExecutor.swift deleted file mode 100644 index ec757441a15..00000000000 --- a/apps/macos/Sources/OpenClaw/ShellExecutor.swift +++ /dev/null @@ -1,101 +0,0 @@ -import Foundation -import OpenClawIPC - -enum ShellExecutor { - struct ShellResult { - var stdout: String - var stderr: String - var exitCode: Int? - var timedOut: Bool - var success: Bool - var errorMessage: String? - } - - static func runDetailed( - command: [String], - cwd: String?, - env: [String: String]?, - timeout: Double?) async -> ShellResult - { - guard !command.isEmpty else { - return ShellResult( - stdout: "", - stderr: "", - exitCode: nil, - timedOut: false, - success: false, - errorMessage: "empty command") - } - - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - process.arguments = command - if let cwd { process.currentDirectoryURL = URL(fileURLWithPath: cwd) } - if let env { process.environment = env } - - let stdoutPipe = Pipe() - let stderrPipe = Pipe() - process.standardOutput = stdoutPipe - process.standardError = stderrPipe - - do { - try process.run() - } catch { - return ShellResult( - stdout: "", - stderr: "", - exitCode: nil, - timedOut: false, - success: false, - errorMessage: "failed to start: \(error.localizedDescription)") - } - - let outTask = Task { stdoutPipe.fileHandleForReading.readToEndSafely() } - let errTask = Task { stderrPipe.fileHandleForReading.readToEndSafely() } - - let waitTask = Task { () -> ShellResult in - process.waitUntilExit() - let out = await outTask.value - let err = await errTask.value - let status = Int(process.terminationStatus) - return ShellResult( - stdout: String(bytes: out, encoding: .utf8) ?? "", - stderr: String(bytes: err, encoding: .utf8) ?? "", - exitCode: status, - timedOut: false, - success: status == 0, - errorMessage: status == 0 ? nil : "exit \(status)") - } - - if let timeout, timeout > 0 { - let nanos = UInt64(timeout * 1_000_000_000) - return await withTaskGroup(of: ShellResult.self) { group in - group.addTask { await waitTask.value } - group.addTask { - try? await Task.sleep(nanoseconds: nanos) - if process.isRunning { process.terminate() } - _ = await waitTask.value // drain pipes after termination - return ShellResult( - stdout: "", - stderr: "", - exitCode: nil, - timedOut: true, - success: false, - errorMessage: "timeout") - } - let first = await group.next()! - group.cancelAll() - return first - } - } - - return await waitTask.value - } - - static func run(command: [String], cwd: String?, env: [String: String]?, timeout: Double?) async -> Response { - let result = await self.runDetailed(command: command, cwd: cwd, env: env, timeout: timeout) - let combined = result.stdout.isEmpty ? result.stderr : result.stdout - let payload = combined.isEmpty ? nil : Data(combined.utf8) - return Response(ok: result.success, message: result.errorMessage, payload: payload) - } -} diff --git a/apps/macos/Sources/OpenClaw/SkillsModels.swift b/apps/macos/Sources/OpenClaw/SkillsModels.swift deleted file mode 100644 index d143484c40f..00000000000 --- a/apps/macos/Sources/OpenClaw/SkillsModels.swift +++ /dev/null @@ -1,74 +0,0 @@ -import Foundation -import OpenClawProtocol - -struct SkillsStatusReport: Codable { - let workspaceDir: String - let managedSkillsDir: String - let skills: [SkillStatus] -} - -struct SkillStatus: Codable, Identifiable { - let name: String - let description: String - let source: String - let filePath: String - let baseDir: String - let skillKey: String - let primaryEnv: String? - let emoji: String? - let homepage: String? - let always: Bool - let disabled: Bool - let eligible: Bool - let requirements: SkillRequirements - let missing: SkillMissing - let configChecks: [SkillStatusConfigCheck] - let install: [SkillInstallOption] - - var id: String { - self.name - } -} - -struct SkillRequirements: Codable { - let bins: [String] - let env: [String] - let config: [String] -} - -struct SkillMissing: Codable { - let bins: [String] - let env: [String] - let config: [String] -} - -struct SkillStatusConfigCheck: Codable, Identifiable { - let path: String - let value: AnyCodable? - let satisfied: Bool - - var id: String { - self.path - } -} - -struct SkillInstallOption: Codable, Identifiable { - let id: String - let kind: String - let label: String - let bins: [String] -} - -struct SkillInstallResult: Codable { - let ok: Bool - let message: String - let stdout: String? - let stderr: String? - let code: Int? -} - -struct SkillUpdateResult: Codable { - let ok: Bool - let skillKey: String - let config: [String: AnyCodable]? -} diff --git a/apps/macos/Sources/OpenClaw/SkillsSettings.swift b/apps/macos/Sources/OpenClaw/SkillsSettings.swift deleted file mode 100644 index 02db8495112..00000000000 --- a/apps/macos/Sources/OpenClaw/SkillsSettings.swift +++ /dev/null @@ -1,621 +0,0 @@ -import Observation -import OpenClawProtocol -import SwiftUI - -struct SkillsSettings: View { - @Bindable var state: AppState - @State private var model = SkillsSettingsModel() - @State private var envEditor: EnvEditorState? - @State private var filter: SkillsFilter = .all - - init(state: AppState = AppStateStore.shared, model: SkillsSettingsModel = SkillsSettingsModel()) { - self.state = state - self._model = State(initialValue: model) - } - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - self.header - self.statusBanner - self.skillsList - Spacer(minLength: 0) - } - .task { await self.model.refresh() } - .sheet(item: self.$envEditor) { editor in - EnvEditorView(editor: editor) { value in - Task { - await self.model.updateEnv( - skillKey: editor.skillKey, - envKey: editor.envKey, - value: value, - isPrimary: editor.isPrimary) - } - } - } - } - - private var header: some View { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Skills") - .font(.headline) - Text("Skills are enabled when requirements are met (binaries, env, config).") - .font(.footnote) - .foregroundStyle(.secondary) - } - Spacer() - if self.model.isLoading { - ProgressView() - } else { - Button { - Task { await self.model.refresh() } - } label: { - Label("Refresh", systemImage: "arrow.clockwise") - } - .buttonStyle(.bordered) - .help("Refresh") - } - self.headerFilter - } - } - - @ViewBuilder - private var statusBanner: some View { - if let error = self.model.error { - Text(error) - .font(.footnote) - .foregroundStyle(.orange) - } else if let message = self.model.statusMessage { - Text(message) - .font(.footnote) - .foregroundStyle(.secondary) - } - } - - @ViewBuilder - private var skillsList: some View { - if self.model.skills.isEmpty { - Text("No skills reported yet.") - .foregroundStyle(.secondary) - } else { - List { - ForEach(self.filteredSkills) { skill in - SkillRow( - skill: skill, - isBusy: self.model.isBusy(skill: skill), - connectionMode: self.state.connectionMode, - onToggleEnabled: { enabled in - Task { await self.model.setEnabled(skillKey: skill.skillKey, enabled: enabled) } - }, - onInstall: { option, target in - Task { await self.model.install(skill: skill, option: option, target: target) } - }, - onSetEnv: { envKey, isPrimary in - self.envEditor = EnvEditorState( - skillKey: skill.skillKey, - skillName: skill.name, - envKey: envKey, - isPrimary: isPrimary) - }) - } - if !self.model.skills.isEmpty, self.filteredSkills.isEmpty { - Text("No skills match this filter.") - .font(.callout) - .foregroundStyle(.secondary) - } - } - .listStyle(.inset) - } - } - - private var headerFilter: some View { - Picker("Filter", selection: self.$filter) { - ForEach(SkillsFilter.allCases) { filter in - Text(filter.title) - .tag(filter) - } - } - .labelsHidden() - .pickerStyle(.menu) - .frame(width: 160, alignment: .trailing) - } - - private var filteredSkills: [SkillStatus] { - self.model.skills.filter { skill in - switch self.filter { - case .all: - true - case .ready: - !skill.disabled && skill.eligible - case .needsSetup: - !skill.disabled && !skill.eligible - case .disabled: - skill.disabled - } - } - } -} - -private enum SkillsFilter: String, CaseIterable, Identifiable { - case all - case ready - case needsSetup - case disabled - - var id: String { - self.rawValue - } - - var title: String { - switch self { - case .all: - "All" - case .ready: - "Ready" - case .needsSetup: - "Needs Setup" - case .disabled: - "Disabled" - } - } -} - -private enum InstallTarget: String, CaseIterable { - case gateway - case local -} - -private struct SkillRow: View { - let skill: SkillStatus - let isBusy: Bool - let connectionMode: AppState.ConnectionMode - let onToggleEnabled: (Bool) -> Void - let onInstall: (SkillInstallOption, InstallTarget) -> Void - let onSetEnv: (String, Bool) -> Void - - private var missingBins: [String] { - self.skill.missing.bins - } - - private var missingEnv: [String] { - self.skill.missing.env - } - - private var missingConfig: [String] { - self.skill.missing.config - } - - var body: some View { - HStack(alignment: .top, spacing: 12) { - Text(self.skill.emoji ?? "✨") - .font(.title2) - - VStack(alignment: .leading, spacing: 6) { - Text(self.skill.name) - .font(.headline) - Text(self.skill.description) - .font(.subheadline) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - self.metaRow - - if self.skill.disabled { - Text("Disabled in config") - .font(.caption) - .foregroundStyle(.secondary) - } else if !self.requirementsMet, self.shouldShowMissingSummary { - self.missingSummary - } - - if !self.skill.configChecks.isEmpty { - self.configChecksView - } - - if !self.missingEnv.isEmpty { - self.envActionRow - } - } - - Spacer(minLength: 0) - - self.trailingActions - } - .padding(.vertical, 6) - } - - private var sourceLabel: String { - switch self.skill.source { - case "openclaw-bundled": - "Bundled" - case "openclaw-managed": - "Managed" - case "openclaw-workspace": - "Workspace" - case "openclaw-extra": - "Extra" - case "openclaw-plugin": - "Plugin" - default: - self.skill.source - } - } - - private var metaRow: some View { - HStack(spacing: 10) { - SkillTag(text: self.sourceLabel) - if let url = self.homepageUrl { - Link(destination: url) { - Label("Website", systemImage: "link") - .font(.caption2.weight(.semibold)) - } - .buttonStyle(.link) - } - Spacer(minLength: 0) - } - } - - private var homepageUrl: URL? { - guard let raw = self.skill.homepage?.trimmingCharacters(in: .whitespacesAndNewlines) else { - return nil - } - guard !raw.isEmpty else { return nil } - return URL(string: raw) - } - - private var enabledBinding: Binding { - Binding( - get: { !self.skill.disabled }, - set: { self.onToggleEnabled($0) }) - } - - private var missingSummary: some View { - VStack(alignment: .leading, spacing: 4) { - if self.shouldShowMissingBins { - Text("Missing binaries: \(self.missingBins.joined(separator: ", "))") - .font(.caption) - .foregroundStyle(.secondary) - } - if !self.missingEnv.isEmpty { - Text("Missing env: \(self.missingEnv.joined(separator: ", "))") - .font(.caption) - .foregroundStyle(.secondary) - } - if !self.missingConfig.isEmpty { - Text("Requires config: \(self.missingConfig.joined(separator: ", "))") - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - - private var configChecksView: some View { - VStack(alignment: .leading, spacing: 4) { - ForEach(self.skill.configChecks) { check in - HStack(spacing: 6) { - Image(systemName: check.satisfied ? "checkmark.circle" : "xmark.circle") - .foregroundStyle(check.satisfied ? .green : .secondary) - Text(check.path) - .font(.caption) - Text(self.formatConfigValue(check.value)) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - } - - private var envActionRow: some View { - HStack(spacing: 8) { - ForEach(self.missingEnv, id: \.self) { envKey in - let isPrimary = envKey == self.skill.primaryEnv - Button(isPrimary ? "Set API Key" : "Set \(envKey)") { - self.onSetEnv(envKey, isPrimary) - } - .buttonStyle(.bordered) - .disabled(self.isBusy) - } - Spacer(minLength: 0) - } - } - - private var trailingActions: some View { - VStack(alignment: .trailing, spacing: 8) { - if !self.installOptions.isEmpty { - ForEach(self.installOptions, id: \.id) { (option: SkillInstallOption) in - HStack(spacing: 6) { - if self.showGatewayInstall { - Button("Install on Gateway") { self.onInstall(option, .gateway) } - .buttonStyle(.borderedProminent) - .disabled(self.isBusy) - } - if self.showGatewayInstall { - Button("Install on This Mac") { self.onInstall(option, .local) } - .buttonStyle(.bordered) - .disabled(self.isBusy) - .help( - self.localInstallNeedsSwitch - ? "Switches to Local mode to install on this Mac." - : "") - } else { - Button("Install on This Mac") { self.onInstall(option, .local) } - .buttonStyle(.borderedProminent) - .disabled(self.isBusy) - .help( - self.localInstallNeedsSwitch - ? "Switches to Local mode to install on this Mac." - : "") - } - } - } - } else { - Toggle("", isOn: self.enabledBinding) - .toggleStyle(.switch) - .labelsHidden() - .disabled(self.isBusy || !self.requirementsMet) - } - - if self.isBusy { - ProgressView() - .controlSize(.small) - } - } - } - - private var installOptions: [SkillInstallOption] { - guard !self.missingBins.isEmpty else { return [] } - let missing = Set(self.missingBins) - return self.skill.install.filter { option in - if option.bins.isEmpty { return true } - return !missing.isDisjoint(with: option.bins) - } - } - - private var requirementsMet: Bool { - self.missingBins.isEmpty && self.missingEnv.isEmpty && self.missingConfig.isEmpty - } - - private var shouldShowMissingBins: Bool { - !self.missingBins.isEmpty && self.installOptions.isEmpty - } - - private var shouldShowMissingSummary: Bool { - self.shouldShowMissingBins || - !self.missingEnv.isEmpty || - !self.missingConfig.isEmpty - } - - private var showGatewayInstall: Bool { - self.connectionMode == .remote - } - - private var localInstallNeedsSwitch: Bool { - self.connectionMode != .local - } - - private func formatConfigValue(_ value: AnyCodable?) -> String { - guard let value else { return "" } - switch value.value { - case let bool as Bool: - return bool ? "true" : "false" - case let int as Int: - return String(int) - case let double as Double: - return String(double) - case let string as String: - return string - default: - return "" - } - } -} - -private struct SkillTag: View { - let text: String - - var body: some View { - Text(self.text) - .font(.caption2.weight(.semibold)) - .foregroundStyle(.secondary) - .padding(.horizontal, 8) - .padding(.vertical, 2) - .background(Color.secondary.opacity(0.12)) - .clipShape(Capsule()) - } -} - -private struct EnvEditorState: Identifiable { - let skillKey: String - let skillName: String - let envKey: String - let isPrimary: Bool - - var id: String { - "\(self.skillKey)::\(self.envKey)" - } -} - -private struct EnvEditorView: View { - let editor: EnvEditorState - let onSave: (String) -> Void - @Environment(\.dismiss) private var dismiss - @State private var value: String = "" - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text(self.title) - .font(.headline) - Text(self.subtitle) - .font(.subheadline) - .foregroundStyle(.secondary) - SecureField(self.editor.envKey, text: self.$value) - .textFieldStyle(.roundedBorder) - HStack { - Button("Cancel") { self.dismiss() } - Spacer() - Button("Save") { - self.onSave(self.value) - self.dismiss() - } - .buttonStyle(.borderedProminent) - .disabled(self.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - } - .padding(20) - .frame(width: 420) - } - - private var title: String { - self.editor.isPrimary ? "Set API Key" : "Set Environment Variable" - } - - private var subtitle: String { - "Skill: \(self.editor.skillName)" - } -} - -@MainActor -@Observable -final class SkillsSettingsModel { - var skills: [SkillStatus] = [] - var isLoading = false - var error: String? - var statusMessage: String? - private var busySkills: Set = [] - - func isBusy(skill: SkillStatus) -> Bool { - self.busySkills.contains(skill.skillKey) - } - - func refresh() async { - guard !self.isLoading else { return } - self.isLoading = true - self.error = nil - do { - let report = try await GatewayConnection.shared.skillsStatus() - self.skills = report.skills.sorted { $0.name < $1.name } - } catch { - self.error = error.localizedDescription - } - self.isLoading = false - } - - fileprivate func install(skill: SkillStatus, option: SkillInstallOption, target: InstallTarget) async { - await self.withBusy(skill.skillKey) { - do { - if target == .local, AppStateStore.shared.connectionMode != .local { - AppStateStore.shared.connectionMode = .local - self.statusMessage = "Switched to Local mode to install on this Mac" - } - let result = try await GatewayConnection.shared.skillsInstall( - name: skill.name, - installId: option.id, - timeoutMs: 300_000) - self.statusMessage = result.message - } catch { - self.statusMessage = error.localizedDescription - } - await self.refresh() - } - } - - func setEnabled(skillKey: String, enabled: Bool) async { - await self.withBusy(skillKey) { - do { - _ = try await GatewayConnection.shared.skillsUpdate( - skillKey: skillKey, - enabled: enabled) - self.statusMessage = enabled ? "Skill enabled" : "Skill disabled" - } catch { - self.statusMessage = error.localizedDescription - } - await self.refresh() - } - } - - func updateEnv(skillKey: String, envKey: String, value: String, isPrimary: Bool) async { - await self.withBusy(skillKey) { - do { - if isPrimary { - _ = try await GatewayConnection.shared.skillsUpdate( - skillKey: skillKey, - apiKey: value) - self.statusMessage = "Saved API key" - } else { - _ = try await GatewayConnection.shared.skillsUpdate( - skillKey: skillKey, - env: [envKey: value]) - self.statusMessage = "Saved \(envKey)" - } - } catch { - self.statusMessage = error.localizedDescription - } - await self.refresh() - } - } - - private func withBusy(_ id: String, _ work: @escaping () async -> Void) async { - self.busySkills.insert(id) - defer { self.busySkills.remove(id) } - await work() - } -} - -#if DEBUG -struct SkillsSettings_Previews: PreviewProvider { - static var previews: some View { - SkillsSettings(state: .preview) - .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) - } -} - -extension SkillsSettings { - static func exerciseForTesting() { - let skill = SkillStatus( - name: "Test Skill", - description: "Test description", - source: "openclaw-bundled", - filePath: "/tmp/skills/test", - baseDir: "/tmp/skills", - skillKey: "test", - primaryEnv: "API_KEY", - emoji: "🧪", - homepage: "https://example.com", - always: false, - disabled: false, - eligible: false, - requirements: SkillRequirements(bins: ["python3"], env: ["API_KEY"], config: ["skills.test"]), - missing: SkillMissing(bins: ["python3"], env: ["API_KEY"], config: ["skills.test"]), - configChecks: [ - SkillStatusConfigCheck(path: "skills.test", value: AnyCodable(false), satisfied: false), - ], - install: [ - SkillInstallOption(id: "brew", kind: "brew", label: "brew install python", bins: ["python3"]), - ]) - - let row = SkillRow( - skill: skill, - isBusy: false, - connectionMode: .remote, - onToggleEnabled: { _ in }, - onInstall: { _, _ in }, - onSetEnv: { _, _ in }) - _ = row.body - - _ = SkillTag(text: "Bundled").body - - let editor = EnvEditorView( - editor: EnvEditorState( - skillKey: "test", - skillName: "Test Skill", - envKey: "API_KEY", - isPrimary: true), - onSave: { _ in }) - _ = editor.body - } - - mutating func setFilterForTesting(_ rawValue: String) { - guard let filter = SkillsFilter(rawValue: rawValue) else { return } - self.filter = filter - } -} -#endif diff --git a/apps/macos/Sources/OpenClaw/SoundEffects.swift b/apps/macos/Sources/OpenClaw/SoundEffects.swift deleted file mode 100644 index 37df8455f8f..00000000000 --- a/apps/macos/Sources/OpenClaw/SoundEffects.swift +++ /dev/null @@ -1,109 +0,0 @@ -import AppKit -import Foundation - -enum SoundEffectCatalog { - /// All discoverable system sound names, with "Glass" pinned first. - static var systemOptions: [String] { - var names = Set(Self.discoveredSoundMap.keys).union(Self.fallbackNames) - names.remove("Glass") - let sorted = names.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } - return ["Glass"] + sorted - } - - static func displayName(for raw: String) -> String { - raw - } - - static func url(for name: String) -> URL? { - self.discoveredSoundMap[name] - } - - // MARK: - Internals - - private static let allowedExtensions: Set = [ - "aif", "aiff", "caf", "wav", "m4a", "mp3", - ] - - private static let fallbackNames: [String] = [ - "Glass", // default - "Ping", - "Pop", - "Frog", - "Submarine", - "Funk", - "Tink", - "Basso", - "Blow", - "Bottle", - "Hero", - "Morse", - "Purr", - "Sosumi", - "Mail Sent", - "New Mail", - "Mail Scheduled", - "Mail Fetch Error", - ] - - private static let searchRoots: [URL] = [ - FileManager().homeDirectoryForCurrentUser.appendingPathComponent("Library/Sounds"), - URL(fileURLWithPath: "/Library/Sounds"), - URL(fileURLWithPath: "/System/Applications/Mail.app/Contents/Resources"), // Mail “swoosh” - URL(fileURLWithPath: "/System/Library/Sounds"), - ] - - private static let discoveredSoundMap: [String: URL] = { - var map: [String: URL] = [:] - for root in Self.searchRoots { - guard let contents = try? FileManager().contentsOfDirectory( - at: root, - includingPropertiesForKeys: nil, - options: [.skipsHiddenFiles]) - else { continue } - - for url in contents where Self.allowedExtensions.contains(url.pathExtension.lowercased()) { - let name = url.deletingPathExtension().lastPathComponent - // Preserve the first match in priority order. - if map[name] == nil { - map[name] = url - } - } - } - return map - }() -} - -@MainActor -enum SoundEffectPlayer { - private static var lastSound: NSSound? - - static func sound(named name: String) -> NSSound? { - if let named = NSSound(named: NSSound.Name(name)) { - return named - } - if let url = SoundEffectCatalog.url(for: name) { - return NSSound(contentsOf: url, byReference: false) - } - return nil - } - - static func sound(from bookmark: Data) -> NSSound? { - var stale = false - guard let url = try? URL( - resolvingBookmarkData: bookmark, - options: [.withoutUI, .withSecurityScope], - bookmarkDataIsStale: &stale) - else { return nil } - - let scoped = url.startAccessingSecurityScopedResource() - defer { if scoped { url.stopAccessingSecurityScopedResource() } } - return NSSound(contentsOf: url, byReference: false) - } - - static func play(_ sound: NSSound?) { - guard let sound else { return } - self.lastSound = sound - sound.stop() - sound.play() - } -} diff --git a/apps/macos/Sources/OpenClaw/StatusPill.swift b/apps/macos/Sources/OpenClaw/StatusPill.swift deleted file mode 100644 index 846ddd419ad..00000000000 --- a/apps/macos/Sources/OpenClaw/StatusPill.swift +++ /dev/null @@ -1,16 +0,0 @@ -import SwiftUI - -struct StatusPill: View { - let text: String - let tint: Color - - var body: some View { - Text(self.text) - .font(.caption2.weight(.semibold)) - .padding(.horizontal, 7) - .padding(.vertical, 3) - .foregroundStyle(self.tint == .secondary ? .secondary : self.tint) - .background((self.tint == .secondary ? Color.secondary : self.tint).opacity(0.12)) - .clipShape(Capsule()) - } -} diff --git a/apps/macos/Sources/OpenClaw/String+NonEmpty.swift b/apps/macos/Sources/OpenClaw/String+NonEmpty.swift deleted file mode 100644 index 402e4c2db5f..00000000000 --- a/apps/macos/Sources/OpenClaw/String+NonEmpty.swift +++ /dev/null @@ -1,8 +0,0 @@ -import Foundation - -extension String { - var nonEmpty: String? { - let trimmed = self.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } -} diff --git a/apps/macos/Sources/OpenClaw/SystemPresenceInfo.swift b/apps/macos/Sources/OpenClaw/SystemPresenceInfo.swift deleted file mode 100644 index 843ed371fb5..00000000000 --- a/apps/macos/Sources/OpenClaw/SystemPresenceInfo.swift +++ /dev/null @@ -1,16 +0,0 @@ -import CoreGraphics -import Foundation -import OpenClawKit - -enum SystemPresenceInfo { - static func lastInputSeconds() -> Int? { - let anyEvent = CGEventType(rawValue: UInt32.max) ?? .null - let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyEvent) - if seconds.isNaN || seconds.isInfinite || seconds < 0 { return nil } - return Int(seconds.rounded()) - } - - static func primaryIPv4Address() -> String? { - NetworkInterfaces.primaryIPv4Address() - } -} diff --git a/apps/macos/Sources/OpenClaw/SystemRunSettingsView.swift b/apps/macos/Sources/OpenClaw/SystemRunSettingsView.swift deleted file mode 100644 index a6d81f50bca..00000000000 --- a/apps/macos/Sources/OpenClaw/SystemRunSettingsView.swift +++ /dev/null @@ -1,449 +0,0 @@ -import Foundation -import Observation -import SwiftUI - -struct SystemRunSettingsView: View { - @State private var model = ExecApprovalsSettingsModel() - @State private var tab: ExecApprovalsSettingsTab = .policy - @State private var newPattern: String = "" - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .center, spacing: 12) { - Text("Exec approvals") - .font(.body) - Spacer(minLength: 0) - Picker("Agent", selection: Binding( - get: { self.model.selectedAgentId }, - set: { self.model.selectAgent($0) })) - { - ForEach(self.model.agentPickerIds, id: \.self) { id in - Text(self.model.label(for: id)).tag(id) - } - } - .pickerStyle(.menu) - .frame(width: 180, alignment: .trailing) - } - - Picker("", selection: self.$tab) { - ForEach(ExecApprovalsSettingsTab.allCases) { tab in - Text(tab.title).tag(tab) - } - } - .pickerStyle(.segmented) - .frame(width: 320) - - if self.tab == .policy { - self.policyView - } else { - self.allowlistView - } - } - .task { await self.model.refresh() } - .onChange(of: self.tab) { _, _ in - Task { await self.model.refreshSkillBins() } - } - } - - private var policyView: some View { - VStack(alignment: .leading, spacing: 8) { - Picker("", selection: Binding( - get: { self.model.security }, - set: { self.model.setSecurity($0) })) - { - ForEach(ExecSecurity.allCases) { security in - Text(security.title).tag(security) - } - } - .labelsHidden() - .pickerStyle(.menu) - - Picker("", selection: Binding( - get: { self.model.ask }, - set: { self.model.setAsk($0) })) - { - ForEach(ExecAsk.allCases) { ask in - Text(ask.title).tag(ask) - } - } - .labelsHidden() - .pickerStyle(.menu) - - Picker("", selection: Binding( - get: { self.model.askFallback }, - set: { self.model.setAskFallback($0) })) - { - ForEach(ExecSecurity.allCases) { mode in - Text("Fallback: \(mode.title)").tag(mode) - } - } - .labelsHidden() - .pickerStyle(.menu) - - Text(self.scopeMessage) - .font(.footnote) - .foregroundStyle(.tertiary) - .fixedSize(horizontal: false, vertical: true) - } - } - - private var allowlistView: some View { - VStack(alignment: .leading, spacing: 10) { - Toggle("Auto-allow skill CLIs", isOn: Binding( - get: { self.model.autoAllowSkills }, - set: { self.model.setAutoAllowSkills($0) })) - - if self.model.autoAllowSkills, !self.model.skillBins.isEmpty { - Text("Skill CLIs: \(self.model.skillBins.joined(separator: ", "))") - .font(.footnote) - .foregroundStyle(.secondary) - } - - if self.model.isDefaultsScope { - Text("Allowlists are per-agent. Select an agent to edit its allowlist.") - .font(.footnote) - .foregroundStyle(.secondary) - } else { - HStack(spacing: 8) { - TextField("Add allowlist path pattern (case-insensitive globs)", text: self.$newPattern) - .textFieldStyle(.roundedBorder) - Button("Add") { - if self.model.addEntry(self.newPattern) == nil { - self.newPattern = "" - } - } - .buttonStyle(.bordered) - .disabled(!self.model.isPathPattern(self.newPattern)) - } - - Text("Path patterns only. Basename entries like \"echo\" are ignored.") - .font(.footnote) - .foregroundStyle(.secondary) - if let validationMessage = self.model.allowlistValidationMessage { - Text(validationMessage) - .font(.footnote) - .foregroundStyle(.orange) - } - - if self.model.entries.isEmpty { - Text("No allowlisted commands yet.") - .font(.footnote) - .foregroundStyle(.secondary) - } else { - VStack(alignment: .leading, spacing: 8) { - ForEach(self.model.entries, id: \.id) { entry in - ExecAllowlistRow( - entry: Binding( - get: { self.model.entry(for: entry.id) ?? entry }, - set: { self.model.updateEntry($0, id: entry.id) }), - onRemove: { self.model.removeEntry(id: entry.id) }) - } - } - } - } - } - } - - private var scopeMessage: String { - if self.model.isDefaultsScope { - return "Defaults apply when an agent has no overrides. " + - "Ask controls prompt behavior; fallback is used when no companion UI is reachable." - } - return "Security controls whether system.run can execute on this Mac when paired as a node. " + - "Ask controls prompt behavior; fallback is used when no companion UI is reachable." - } -} - -private enum ExecApprovalsSettingsTab: String, CaseIterable, Identifiable { - case policy - case allowlist - - var id: String { - self.rawValue - } - - var title: String { - switch self { - case .policy: "Access" - case .allowlist: "Allowlist" - } - } -} - -struct ExecAllowlistRow: View { - @Binding var entry: ExecAllowlistEntry - let onRemove: () -> Void - @State private var draftPattern: String = "" - - private static let relativeFormatter: RelativeDateTimeFormatter = { - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .short - return formatter - }() - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 8) { - TextField("Pattern", text: self.patternBinding) - .textFieldStyle(.roundedBorder) - - Button(role: .destructive) { - self.onRemove() - } label: { - Image(systemName: "trash") - } - .buttonStyle(.borderless) - } - - if let lastUsedAt = self.entry.lastUsedAt { - let date = Date(timeIntervalSince1970: lastUsedAt / 1000.0) - Text("Last used \(Self.relativeFormatter.localizedString(for: date, relativeTo: Date()))") - .font(.caption) - .foregroundStyle(.secondary) - } - - if let lastUsedCommand = self.entry.lastUsedCommand, !lastUsedCommand.isEmpty { - Text("Last command: \(lastUsedCommand)") - .font(.caption) - .foregroundStyle(.secondary) - } - - if let lastResolvedPath = self.entry.lastResolvedPath, !lastResolvedPath.isEmpty { - Text("Resolved path: \(lastResolvedPath)") - .font(.caption) - .foregroundStyle(.secondary) - } - } - .onAppear { - self.draftPattern = self.entry.pattern - } - } - - private var patternBinding: Binding { - Binding( - get: { self.draftPattern.isEmpty ? self.entry.pattern : self.draftPattern }, - set: { newValue in - self.draftPattern = newValue - self.entry.pattern = newValue - }) - } -} - -@MainActor -@Observable -final class ExecApprovalsSettingsModel { - private static let defaultsScopeId = "__defaults__" - var agentIds: [String] = [] - var selectedAgentId: String = "main" - var defaultAgentId: String = "main" - var security: ExecSecurity = .deny - var ask: ExecAsk = .onMiss - var askFallback: ExecSecurity = .deny - var autoAllowSkills = false - var entries: [ExecAllowlistEntry] = [] - var skillBins: [String] = [] - var allowlistValidationMessage: String? - - var agentPickerIds: [String] { - [Self.defaultsScopeId] + self.agentIds - } - - var isDefaultsScope: Bool { - self.selectedAgentId == Self.defaultsScopeId - } - - func label(for id: String) -> String { - if id == Self.defaultsScopeId { return "Defaults" } - return id - } - - func refresh() async { - await self.refreshAgents() - self.loadSettings(for: self.selectedAgentId) - await self.refreshSkillBins() - } - - func refreshAgents() async { - let root = await ConfigStore.load() - let agents = root["agents"] as? [String: Any] - let list = agents?["list"] as? [[String: Any]] ?? [] - var ids: [String] = [] - var seen = Set() - var defaultId: String? - for entry in list { - guard let raw = entry["id"] as? String else { continue } - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { continue } - if !seen.insert(trimmed).inserted { continue } - ids.append(trimmed) - if (entry["default"] as? Bool) == true, defaultId == nil { - defaultId = trimmed - } - } - if ids.isEmpty { - ids = ["main"] - defaultId = "main" - } else if defaultId == nil { - defaultId = ids.first - } - self.agentIds = ids - self.defaultAgentId = defaultId ?? "main" - if self.selectedAgentId == Self.defaultsScopeId { - return - } - if !self.agentIds.contains(self.selectedAgentId) { - self.selectedAgentId = self.defaultAgentId - } - } - - func selectAgent(_ id: String) { - self.selectedAgentId = id - self.allowlistValidationMessage = nil - self.loadSettings(for: id) - Task { await self.refreshSkillBins() } - } - - func loadSettings(for agentId: String) { - if agentId == Self.defaultsScopeId { - let defaults = ExecApprovalsStore.resolveDefaults() - self.security = defaults.security - self.ask = defaults.ask - self.askFallback = defaults.askFallback - self.autoAllowSkills = defaults.autoAllowSkills - self.entries = [] - self.allowlistValidationMessage = nil - return - } - let resolved = ExecApprovalsStore.resolve(agentId: agentId) - self.security = resolved.agent.security - self.ask = resolved.agent.ask - self.askFallback = resolved.agent.askFallback - self.autoAllowSkills = resolved.agent.autoAllowSkills - self.entries = resolved.allowlist - .sorted { $0.pattern.localizedCaseInsensitiveCompare($1.pattern) == .orderedAscending } - self.allowlistValidationMessage = nil - } - - func setSecurity(_ security: ExecSecurity) { - self.security = security - if self.isDefaultsScope { - ExecApprovalsStore.updateDefaults { defaults in - defaults.security = security - } - } else { - ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in - entry.security = security - } - } - self.syncQuickMode() - } - - func setAsk(_ ask: ExecAsk) { - self.ask = ask - if self.isDefaultsScope { - ExecApprovalsStore.updateDefaults { defaults in - defaults.ask = ask - } - } else { - ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in - entry.ask = ask - } - } - self.syncQuickMode() - } - - func setAskFallback(_ mode: ExecSecurity) { - self.askFallback = mode - if self.isDefaultsScope { - ExecApprovalsStore.updateDefaults { defaults in - defaults.askFallback = mode - } - } else { - ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in - entry.askFallback = mode - } - } - } - - func setAutoAllowSkills(_ enabled: Bool) { - self.autoAllowSkills = enabled - if self.isDefaultsScope { - ExecApprovalsStore.updateDefaults { defaults in - defaults.autoAllowSkills = enabled - } - } else { - ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in - entry.autoAllowSkills = enabled - } - } - Task { await self.refreshSkillBins(force: enabled) } - } - - @discardableResult - func addEntry(_ pattern: String) -> ExecAllowlistPatternValidationReason? { - guard !self.isDefaultsScope else { return nil } - switch ExecApprovalHelpers.validateAllowlistPattern(pattern) { - case .valid(let normalizedPattern): - self.entries.append(ExecAllowlistEntry(pattern: normalizedPattern, lastUsedAt: nil)) - let rejected = ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries) - self.allowlistValidationMessage = rejected.first?.reason.message - return rejected.first?.reason - case .invalid(let reason): - self.allowlistValidationMessage = reason.message - return reason - } - } - - @discardableResult - func updateEntry(_ entry: ExecAllowlistEntry, id: UUID) -> ExecAllowlistPatternValidationReason? { - guard !self.isDefaultsScope else { return nil } - guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return nil } - var next = entry - switch ExecApprovalHelpers.validateAllowlistPattern(next.pattern) { - case .valid(let normalizedPattern): - next.pattern = normalizedPattern - case .invalid(let reason): - self.allowlistValidationMessage = reason.message - return reason - } - self.entries[index] = next - let rejected = ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries) - self.allowlistValidationMessage = rejected.first?.reason.message - return rejected.first?.reason - } - - func removeEntry(id: UUID) { - guard !self.isDefaultsScope else { return } - guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return } - self.entries.remove(at: index) - let rejected = ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries) - self.allowlistValidationMessage = rejected.first?.reason.message - } - - func entry(for id: UUID) -> ExecAllowlistEntry? { - self.entries.first(where: { $0.id == id }) - } - - func isPathPattern(_ pattern: String) -> Bool { - ExecApprovalHelpers.isPathPattern(pattern) - } - - func refreshSkillBins(force: Bool = false) async { - guard self.autoAllowSkills else { - self.skillBins = [] - return - } - let bins = await SkillBinsCache.shared.currentBins(force: force) - self.skillBins = bins.sorted() - } - - private func syncQuickMode() { - if self.isDefaultsScope { - AppStateStore.shared.execApprovalMode = ExecApprovalQuickMode.from(security: self.security, ask: self.ask) - return - } - if self.selectedAgentId == self.defaultAgentId || self.agentIds.count <= 1 { - AppStateStore.shared.execApprovalMode = ExecApprovalQuickMode.from(security: self.security, ask: self.ask) - } - } -} diff --git a/apps/macos/Sources/OpenClaw/TailscaleIntegrationSection.swift b/apps/macos/Sources/OpenClaw/TailscaleIntegrationSection.swift deleted file mode 100644 index c9354d38bc2..00000000000 --- a/apps/macos/Sources/OpenClaw/TailscaleIntegrationSection.swift +++ /dev/null @@ -1,401 +0,0 @@ -import SwiftUI - -private enum GatewayTailscaleMode: String, CaseIterable, Identifiable { - case off - case serve - case funnel - - var id: String { - self.rawValue - } - - var label: String { - switch self { - case .off: "Off" - case .serve: "Tailnet (Serve)" - case .funnel: "Public (Funnel)" - } - } - - var description: String { - switch self { - case .off: - "No automatic Tailscale configuration." - case .serve: - "Tailnet-only HTTPS via Tailscale Serve." - case .funnel: - "Public HTTPS via Tailscale Funnel (requires auth)." - } - } -} - -struct TailscaleIntegrationSection: View { - let connectionMode: AppState.ConnectionMode - let isPaused: Bool - - @Environment(TailscaleService.self) private var tailscaleService - #if DEBUG - private var testingService: TailscaleService? - #endif - - @State private var hasLoaded = false - @State private var tailscaleMode: GatewayTailscaleMode = .serve - @State private var requireCredentialsForServe = false - @State private var password: String = "" - @State private var statusMessage: String? - @State private var validationMessage: String? - @State private var statusTimer: Timer? - - init(connectionMode: AppState.ConnectionMode, isPaused: Bool) { - self.connectionMode = connectionMode - self.isPaused = isPaused - #if DEBUG - self.testingService = nil - #endif - } - - private var effectiveService: TailscaleService { - #if DEBUG - return self.testingService ?? self.tailscaleService - #else - return self.tailscaleService - #endif - } - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - Text("Tailscale (dashboard access)") - .font(.callout.weight(.semibold)) - - self.statusRow - - if !self.effectiveService.isInstalled { - self.installButtons - } else { - self.modePicker - if self.tailscaleMode != .off { - self.accessURLRow - } - if self.tailscaleMode == .serve { - self.serveAuthSection - } - if self.tailscaleMode == .funnel { - self.funnelAuthSection - } - } - - if self.connectionMode != .local { - Text("Local mode required. Update settings on the gateway host.") - .font(.caption) - .foregroundStyle(.secondary) - } - - if let validationMessage { - Text(validationMessage) - .font(.caption) - .foregroundStyle(.orange) - } else if let statusMessage { - Text(statusMessage) - .font(.caption) - .foregroundStyle(.secondary) - } - } - .padding(12) - .background(Color.gray.opacity(0.08)) - .cornerRadius(10) - .disabled(self.connectionMode != .local) - .task { - guard !self.hasLoaded else { return } - await self.loadConfig() - self.hasLoaded = true - await self.effectiveService.checkTailscaleStatus() - self.startStatusTimer() - } - .onDisappear { - self.stopStatusTimer() - } - .onChange(of: self.tailscaleMode) { _, _ in - Task { await self.applySettings() } - } - .onChange(of: self.requireCredentialsForServe) { _, _ in - Task { await self.applySettings() } - } - } - - private var statusRow: some View { - HStack(spacing: 8) { - Circle() - .fill(self.statusColor) - .frame(width: 10, height: 10) - Text(self.statusText) - .font(.callout) - Spacer() - Button("Refresh") { - Task { await self.effectiveService.checkTailscaleStatus() } - } - .buttonStyle(.bordered) - .controlSize(.small) - } - } - - private var statusColor: Color { - if !self.effectiveService.isInstalled { return .yellow } - if self.effectiveService.isRunning { return .green } - return .orange - } - - private var statusText: String { - if !self.effectiveService.isInstalled { return "Tailscale is not installed" } - if self.effectiveService.isRunning { return "Tailscale is installed and running" } - return "Tailscale is installed but not running" - } - - private var installButtons: some View { - HStack(spacing: 12) { - Button("App Store") { self.effectiveService.openAppStore() } - .buttonStyle(.link) - Button("Direct Download") { self.effectiveService.openDownloadPage() } - .buttonStyle(.link) - Button("Setup Guide") { self.effectiveService.openSetupGuide() } - .buttonStyle(.link) - } - .controlSize(.small) - } - - private var modePicker: some View { - VStack(alignment: .leading, spacing: 6) { - Text("Exposure mode") - .font(.callout.weight(.semibold)) - Picker("Exposure", selection: self.$tailscaleMode) { - ForEach(GatewayTailscaleMode.allCases) { mode in - Text(mode.label).tag(mode) - } - } - .pickerStyle(.segmented) - Text(self.tailscaleMode.description) - .font(.caption) - .foregroundStyle(.secondary) - } - } - - @ViewBuilder - private var accessURLRow: some View { - if let host = self.effectiveService.tailscaleHostname { - let url = "https://\(host)/ui/" - HStack(spacing: 8) { - Text("Dashboard URL:") - .font(.caption) - .foregroundStyle(.secondary) - if let link = URL(string: url) { - Link(url, destination: link) - .font(.system(.caption, design: .monospaced)) - } else { - Text(url) - .font(.system(.caption, design: .monospaced)) - } - } - } else if !self.effectiveService.isRunning { - Text("Start Tailscale to get your tailnet hostname.") - .font(.caption) - .foregroundStyle(.secondary) - } - - if self.effectiveService.isInstalled, !self.effectiveService.isRunning { - Button("Start Tailscale") { self.effectiveService.openTailscaleApp() } - .buttonStyle(.borderedProminent) - .controlSize(.small) - } - } - - private var serveAuthSection: some View { - VStack(alignment: .leading, spacing: 8) { - Toggle("Require credentials", isOn: self.$requireCredentialsForServe) - .toggleStyle(.checkbox) - if self.requireCredentialsForServe { - self.authFields - } else { - Text("Serve uses Tailscale identity headers; no password required.") - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - - private var funnelAuthSection: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Funnel requires authentication.") - .font(.caption) - .foregroundStyle(.secondary) - self.authFields - } - } - - @ViewBuilder - private var authFields: some View { - SecureField("Password", text: self.$password) - .textFieldStyle(.roundedBorder) - .frame(maxWidth: 240) - .onSubmit { Task { await self.applySettings() } } - Text("Stored in ~/.openclaw/openclaw.json. Prefer OPENCLAW_GATEWAY_PASSWORD for production.") - .font(.caption) - .foregroundStyle(.secondary) - Button("Update password") { Task { await self.applySettings() } } - .buttonStyle(.bordered) - .controlSize(.small) - } - - private func loadConfig() async { - let root = await ConfigStore.load() - let gateway = root["gateway"] as? [String: Any] ?? [:] - let tailscale = gateway["tailscale"] as? [String: Any] ?? [:] - let modeRaw = (tailscale["mode"] as? String) ?? "serve" - self.tailscaleMode = GatewayTailscaleMode(rawValue: modeRaw) ?? .off - - let auth = gateway["auth"] as? [String: Any] ?? [:] - let authModeRaw = auth["mode"] as? String - let allowTailscale = auth["allowTailscale"] as? Bool - - self.password = auth["password"] as? String ?? "" - - if self.tailscaleMode == .serve { - let usesExplicitAuth = authModeRaw == "password" - if let allowTailscale, allowTailscale == false { - self.requireCredentialsForServe = true - } else { - self.requireCredentialsForServe = usesExplicitAuth - } - } else { - self.requireCredentialsForServe = false - } - } - - private func applySettings() async { - guard self.hasLoaded else { return } - self.validationMessage = nil - self.statusMessage = nil - - let trimmedPassword = self.password.trimmingCharacters(in: .whitespacesAndNewlines) - let requiresPassword = self.tailscaleMode == .funnel - || (self.tailscaleMode == .serve && self.requireCredentialsForServe) - if requiresPassword, trimmedPassword.isEmpty { - self.validationMessage = "Password required for this mode." - return - } - - let (success, errorMessage) = await TailscaleIntegrationSection.buildAndSaveTailscaleConfig( - tailscaleMode: self.tailscaleMode, - requireCredentialsForServe: self.requireCredentialsForServe, - password: trimmedPassword, - connectionMode: self.connectionMode, - isPaused: self.isPaused) - - if !success, let errorMessage { - self.statusMessage = errorMessage - return - } - - if self.connectionMode == .local, !self.isPaused { - self.statusMessage = "Saved to ~/.openclaw/openclaw.json. Restarting gateway…" - } else { - self.statusMessage = "Saved to ~/.openclaw/openclaw.json. Restart the gateway to apply." - } - self.restartGatewayIfNeeded() - } - - @MainActor - private static func buildAndSaveTailscaleConfig( - tailscaleMode: GatewayTailscaleMode, - requireCredentialsForServe: Bool, - password: String, - connectionMode: AppState.ConnectionMode, - isPaused: Bool) async -> (Bool, String?) - { - var root = await ConfigStore.load() - var gateway = root["gateway"] as? [String: Any] ?? [:] - var tailscale = gateway["tailscale"] as? [String: Any] ?? [:] - tailscale["mode"] = tailscaleMode.rawValue - gateway["tailscale"] = tailscale - - if tailscaleMode != .off { - gateway["bind"] = "loopback" - } - - if tailscaleMode == .off { - gateway.removeValue(forKey: "auth") - } else { - var auth = gateway["auth"] as? [String: Any] ?? [:] - if tailscaleMode == .serve, !requireCredentialsForServe { - auth["allowTailscale"] = true - auth.removeValue(forKey: "mode") - auth.removeValue(forKey: "password") - } else { - auth["allowTailscale"] = false - auth["mode"] = "password" - auth["password"] = password - } - - if auth.isEmpty { - gateway.removeValue(forKey: "auth") - } else { - gateway["auth"] = auth - } - } - - if gateway.isEmpty { - root.removeValue(forKey: "gateway") - } else { - root["gateway"] = gateway - } - - do { - try await ConfigStore.save(root) - return (true, nil) - } catch { - return (false, error.localizedDescription) - } - } - - private func restartGatewayIfNeeded() { - guard self.connectionMode == .local, !self.isPaused else { return } - Task { await GatewayLaunchAgentManager.kickstart() } - } - - private func startStatusTimer() { - self.stopStatusTimer() - if ProcessInfo.processInfo.isRunningTests { - return - } - self.statusTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { _ in - Task { await self.effectiveService.checkTailscaleStatus() } - } - } - - private func stopStatusTimer() { - self.statusTimer?.invalidate() - self.statusTimer = nil - } -} - -#if DEBUG -extension TailscaleIntegrationSection { - mutating func setTestingState( - mode: String, - requireCredentials: Bool, - password: String = "secret", - statusMessage: String? = nil, - validationMessage: String? = nil) - { - if let mode = GatewayTailscaleMode(rawValue: mode) { - self.tailscaleMode = mode - } - self.requireCredentialsForServe = requireCredentials - self.password = password - self.statusMessage = statusMessage - self.validationMessage = validationMessage - } - - mutating func setTestingService(_ service: TailscaleService?) { - self.testingService = service - } -} -#endif diff --git a/apps/macos/Sources/OpenClaw/TailscaleService.swift b/apps/macos/Sources/OpenClaw/TailscaleService.swift deleted file mode 100644 index 2cefa69d59d..00000000000 --- a/apps/macos/Sources/OpenClaw/TailscaleService.swift +++ /dev/null @@ -1,182 +0,0 @@ -import AppKit -import Foundation -import Observation -import OpenClawDiscovery -import os - -/// Manages Tailscale integration and status checking. -@Observable -@MainActor -final class TailscaleService { - static let shared = TailscaleService() - - /// Tailscale local API endpoint. - private static let tailscaleAPIEndpoint = "http://100.100.100.100/api/data" - - /// API request timeout in seconds. - private static let apiTimeoutInterval: TimeInterval = 5.0 - - private let logger = Logger(subsystem: "ai.openclaw", category: "tailscale") - - /// Indicates if the Tailscale app is installed on the system. - private(set) var isInstalled = false - - /// Indicates if Tailscale is currently running. - private(set) var isRunning = false - - /// The Tailscale hostname for this device (e.g., "my-mac.tailnet.ts.net"). - private(set) var tailscaleHostname: String? - - /// The Tailscale IPv4 address for this device. - private(set) var tailscaleIP: String? - - /// Error message if status check fails. - private(set) var statusError: String? - - private init() { - Task { await self.checkTailscaleStatus() } - } - - #if DEBUG - init( - isInstalled: Bool, - isRunning: Bool, - tailscaleHostname: String? = nil, - tailscaleIP: String? = nil, - statusError: String? = nil) - { - self.isInstalled = isInstalled - self.isRunning = isRunning - self.tailscaleHostname = tailscaleHostname - self.tailscaleIP = tailscaleIP - self.statusError = statusError - } - #endif - - func checkAppInstallation() -> Bool { - let installed = FileManager().fileExists(atPath: "/Applications/Tailscale.app") - self.logger.info("Tailscale app installed: \(installed)") - return installed - } - - private struct TailscaleAPIResponse: Codable { - let status: String - let deviceName: String - let tailnetName: String - let iPv4: String? - - private enum CodingKeys: String, CodingKey { - case status = "Status" - case deviceName = "DeviceName" - case tailnetName = "TailnetName" - case iPv4 = "IPv4" - } - } - - private func fetchTailscaleStatus() async -> TailscaleAPIResponse? { - guard let url = URL(string: Self.tailscaleAPIEndpoint) else { - self.logger.error("Invalid Tailscale API URL") - return nil - } - - do { - let configuration = URLSessionConfiguration.default - configuration.timeoutIntervalForRequest = Self.apiTimeoutInterval - let session = URLSession(configuration: configuration) - - let (data, response) = try await session.data(from: url) - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 - else { - self.logger.warning("Tailscale API returned non-200 status") - return nil - } - - let decoder = JSONDecoder() - return try decoder.decode(TailscaleAPIResponse.self, from: data) - } catch { - self.logger.debug("Failed to fetch Tailscale status: \(String(describing: error))") - return nil - } - } - - func checkTailscaleStatus() async { - let previousIP = self.tailscaleIP - self.isInstalled = self.checkAppInstallation() - if !self.isInstalled { - self.isRunning = false - self.tailscaleHostname = nil - self.tailscaleIP = nil - self.statusError = "Tailscale is not installed" - } else if let apiResponse = await fetchTailscaleStatus() { - self.isRunning = apiResponse.status.lowercased() == "running" - - if self.isRunning { - let deviceName = apiResponse.deviceName - .lowercased() - .replacingOccurrences(of: " ", with: "-") - let tailnetName = apiResponse.tailnetName - .replacingOccurrences(of: ".ts.net", with: "") - .replacingOccurrences(of: ".tailscale.net", with: "") - - self.tailscaleHostname = "\(deviceName).\(tailnetName).ts.net" - self.tailscaleIP = apiResponse.iPv4 - self.statusError = nil - - self.logger.info( - "Tailscale running host=\(self.tailscaleHostname ?? "nil") ip=\(self.tailscaleIP ?? "nil")") - } else { - self.tailscaleHostname = nil - self.tailscaleIP = nil - self.statusError = "Tailscale is not running" - } - } else { - self.isRunning = false - self.tailscaleHostname = nil - self.tailscaleIP = nil - self.statusError = "Please start the Tailscale app" - self.logger.info("Tailscale API not responding; app likely not running") - } - - if self.tailscaleIP == nil, let fallback = TailscaleNetwork.detectTailnetIPv4() { - self.tailscaleIP = fallback - if !self.isRunning { - self.isRunning = true - } - self.statusError = nil - self.logger.info("Tailscale interface IP detected (fallback) ip=\(fallback, privacy: .public)") - } - - if previousIP != self.tailscaleIP { - await GatewayEndpointStore.shared.refresh() - } - } - - func openTailscaleApp() { - if let url = URL(string: "file:///Applications/Tailscale.app") { - NSWorkspace.shared.open(url) - } - } - - func openAppStore() { - if let url = URL(string: "https://apps.apple.com/us/app/tailscale/id1475387142") { - NSWorkspace.shared.open(url) - } - } - - func openDownloadPage() { - if let url = URL(string: "https://tailscale.com/download/macos") { - NSWorkspace.shared.open(url) - } - } - - func openSetupGuide() { - if let url = URL(string: "https://tailscale.com/kb/1017/install/") { - NSWorkspace.shared.open(url) - } - } - - nonisolated static func fallbackTailnetIPv4() -> String? { - TailscaleNetwork.detectTailnetIPv4() - } -} diff --git a/apps/macos/Sources/OpenClaw/TalkAudioPlayer.swift b/apps/macos/Sources/OpenClaw/TalkAudioPlayer.swift deleted file mode 100644 index ae9a0645104..00000000000 --- a/apps/macos/Sources/OpenClaw/TalkAudioPlayer.swift +++ /dev/null @@ -1,158 +0,0 @@ -import AVFoundation -import Foundation -import OSLog - -@MainActor -final class TalkAudioPlayer: NSObject, @preconcurrency AVAudioPlayerDelegate { - static let shared = TalkAudioPlayer() - - private let logger = Logger(subsystem: "ai.openclaw", category: "talk.tts") - private var player: AVAudioPlayer? - private var playback: Playback? - - private final class Playback: @unchecked Sendable { - private let lock = NSLock() - private var finished = false - private var continuation: CheckedContinuation? - private var watchdog: Task? - - func setContinuation(_ continuation: CheckedContinuation) { - self.lock.lock() - defer { self.lock.unlock() } - self.continuation = continuation - } - - func setWatchdog(_ task: Task?) { - self.lock.lock() - let old = self.watchdog - self.watchdog = task - self.lock.unlock() - old?.cancel() - } - - func cancelWatchdog() { - self.setWatchdog(nil) - } - - func finish(_ result: TalkPlaybackResult) { - let continuation: CheckedContinuation? - self.lock.lock() - if self.finished { - continuation = nil - } else { - self.finished = true - continuation = self.continuation - self.continuation = nil - } - self.lock.unlock() - continuation?.resume(returning: result) - } - } - - func play(data: Data) async -> TalkPlaybackResult { - self.stopInternal() - - let playback = Playback() - self.playback = playback - - return await withCheckedContinuation { continuation in - playback.setContinuation(continuation) - do { - let player = try AVAudioPlayer(data: data) - self.player = player - - player.delegate = self - player.prepareToPlay() - - self.armWatchdog(playback: playback) - - let ok = player.play() - if !ok { - self.logger.error("talk audio player refused to play") - self.finish(playback: playback, result: TalkPlaybackResult(finished: false, interruptedAt: nil)) - } - } catch { - self.logger.error("talk audio player failed: \(error.localizedDescription, privacy: .public)") - self.finish(playback: playback, result: TalkPlaybackResult(finished: false, interruptedAt: nil)) - } - } - } - - func stop() -> Double? { - guard let player else { return nil } - let time = player.currentTime - self.stopInternal(interruptedAt: time) - return time - } - - func audioPlayerDidFinishPlaying(_: AVAudioPlayer, successfully flag: Bool) { - self.stopInternal(finished: flag) - } - - private func stopInternal(finished: Bool = false, interruptedAt: Double? = nil) { - guard let playback else { return } - let result = TalkPlaybackResult(finished: finished, interruptedAt: interruptedAt) - self.finish(playback: playback, result: result) - } - - private func finish(playback: Playback, result: TalkPlaybackResult) { - playback.cancelWatchdog() - playback.finish(result) - - guard self.playback === playback else { return } - self.playback = nil - self.player?.stop() - self.player = nil - } - - private func stopInternal() { - if let playback = self.playback { - let interruptedAt = self.player?.currentTime - self.finish( - playback: playback, - result: TalkPlaybackResult(finished: false, interruptedAt: interruptedAt)) - return - } - self.player?.stop() - self.player = nil - } - - private func armWatchdog(playback: Playback) { - playback.setWatchdog(Task { @MainActor [weak self] in - guard let self else { return } - - do { - try await Task.sleep(nanoseconds: 650_000_000) - } catch { - return - } - if Task.isCancelled { return } - - guard self.playback === playback else { return } - if self.player?.isPlaying != true { - self.logger.error("talk audio player did not start playing") - self.finish(playback: playback, result: TalkPlaybackResult(finished: false, interruptedAt: nil)) - return - } - - let duration = self.player?.duration ?? 0 - let timeoutSeconds = min(max(2.0, duration + 2.0), 5 * 60.0) - do { - try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000)) - } catch { - return - } - if Task.isCancelled { return } - - guard self.playback === playback else { return } - guard self.player?.isPlaying == true else { return } - self.logger.error("talk audio player watchdog fired") - self.finish(playback: playback, result: TalkPlaybackResult(finished: false, interruptedAt: nil)) - }) - } -} - -struct TalkPlaybackResult: Sendable { - let finished: Bool - let interruptedAt: Double? -} diff --git a/apps/macos/Sources/OpenClaw/TalkModeController.swift b/apps/macos/Sources/OpenClaw/TalkModeController.swift deleted file mode 100644 index 8454e503b4f..00000000000 --- a/apps/macos/Sources/OpenClaw/TalkModeController.swift +++ /dev/null @@ -1,69 +0,0 @@ -import Observation - -@MainActor -@Observable -final class TalkModeController { - static let shared = TalkModeController() - - private let logger = Logger(subsystem: "ai.openclaw", category: "talk.controller") - - private(set) var phase: TalkModePhase = .idle - private(set) var isPaused: Bool = false - - func setEnabled(_ enabled: Bool) async { - self.logger.info("talk enabled=\(enabled)") - if enabled { - TalkOverlayController.shared.present() - } else { - TalkOverlayController.shared.dismiss() - } - await TalkModeRuntime.shared.setEnabled(enabled) - } - - func updatePhase(_ phase: TalkModePhase) { - self.phase = phase - TalkOverlayController.shared.updatePhase(phase) - let effectivePhase = self.isPaused ? "paused" : phase.rawValue - Task { - await GatewayConnection.shared.talkMode( - enabled: AppStateStore.shared.talkEnabled, - phase: effectivePhase) - } - } - - func updateLevel(_ level: Double) { - TalkOverlayController.shared.updateLevel(level) - } - - func setPaused(_ paused: Bool) { - guard self.isPaused != paused else { return } - self.logger.info("talk paused=\(paused)") - self.isPaused = paused - TalkOverlayController.shared.updatePaused(paused) - let effectivePhase = paused ? "paused" : self.phase.rawValue - Task { - await GatewayConnection.shared.talkMode( - enabled: AppStateStore.shared.talkEnabled, - phase: effectivePhase) - } - Task { await TalkModeRuntime.shared.setPaused(paused) } - } - - func togglePaused() { - self.setPaused(!self.isPaused) - } - - func stopSpeaking(reason: TalkStopReason = .userTap) { - Task { await TalkModeRuntime.shared.stopSpeaking(reason: reason) } - } - - func exitTalkMode() { - Task { await AppStateStore.shared.setTalkEnabled(false) } - } -} - -enum TalkStopReason { - case userTap - case speech - case manual -} diff --git a/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift b/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift deleted file mode 100644 index 47b041a5873..00000000000 --- a/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift +++ /dev/null @@ -1,953 +0,0 @@ -import AVFoundation -import Foundation -import OpenClawChatUI -import OpenClawKit -import OSLog -import Speech - -actor TalkModeRuntime { - static let shared = TalkModeRuntime() - - private let logger = Logger(subsystem: "ai.openclaw", category: "talk.runtime") - private let ttsLogger = Logger(subsystem: "ai.openclaw", category: "talk.tts") - private static let defaultModelIdFallback = "eleven_v3" - - private final class RMSMeter: @unchecked Sendable { - private let lock = NSLock() - private var latestRMS: Double = 0 - - func set(_ rms: Double) { - self.lock.lock() - self.latestRMS = rms - self.lock.unlock() - } - - func get() -> Double { - self.lock.lock() - let value = self.latestRMS - self.lock.unlock() - return value - } - } - - private var recognizer: SFSpeechRecognizer? - private var audioEngine: AVAudioEngine? - private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? - private var recognitionTask: SFSpeechRecognitionTask? - private var recognitionGeneration: Int = 0 - private var rmsTask: Task? - private let rmsMeter = RMSMeter() - - private var captureTask: Task? - private var silenceTask: Task? - private var phase: TalkModePhase = .idle - private var isEnabled = false - private var isPaused = false - private var lifecycleGeneration: Int = 0 - - private var lastHeard: Date? - private var noiseFloorRMS: Double = 1e-4 - private var lastTranscript: String = "" - private var lastSpeechEnergyAt: Date? - - private var defaultVoiceId: String? - private var currentVoiceId: String? - private var defaultModelId: String? - private var currentModelId: String? - private var voiceOverrideActive = false - private var modelOverrideActive = false - private var defaultOutputFormat: String? - private var interruptOnSpeech: Bool = true - private var lastInterruptedAtSeconds: Double? - private var voiceAliases: [String: String] = [:] - private var lastSpokenText: String? - private var apiKey: String? - private var fallbackVoiceId: String? - private var lastPlaybackWasPCM: Bool = false - - private let silenceWindow: TimeInterval = 0.7 - private let minSpeechRMS: Double = 1e-3 - private let speechBoostFactor: Double = 6.0 - - // MARK: - Lifecycle - - func setEnabled(_ enabled: Bool) async { - guard enabled != self.isEnabled else { return } - self.isEnabled = enabled - self.lifecycleGeneration &+= 1 - if enabled { - await self.start() - } else { - await self.stop() - } - } - - func setPaused(_ paused: Bool) async { - guard paused != self.isPaused else { return } - self.isPaused = paused - await MainActor.run { TalkModeController.shared.updateLevel(0) } - - guard self.isEnabled else { return } - - if paused { - self.lastTranscript = "" - self.lastHeard = nil - self.lastSpeechEnergyAt = nil - await self.stopRecognition() - return - } - - if self.phase == .idle || self.phase == .listening { - await self.startRecognition() - self.phase = .listening - await MainActor.run { TalkModeController.shared.updatePhase(.listening) } - self.startSilenceMonitor() - } - } - - private func isCurrent(_ generation: Int) -> Bool { - generation == self.lifecycleGeneration && self.isEnabled - } - - private func start() async { - let gen = self.lifecycleGeneration - guard voiceWakeSupported else { return } - guard PermissionManager.voiceWakePermissionsGranted() else { - self.logger.debug("talk runtime not starting: permissions missing") - return - } - await self.reloadConfig() - guard self.isCurrent(gen) else { return } - if self.isPaused { - self.phase = .idle - await MainActor.run { - TalkModeController.shared.updateLevel(0) - TalkModeController.shared.updatePhase(.idle) - } - return - } - await self.startRecognition() - guard self.isCurrent(gen) else { return } - self.phase = .listening - await MainActor.run { TalkModeController.shared.updatePhase(.listening) } - self.startSilenceMonitor() - } - - private func stop() async { - self.captureTask?.cancel() - self.captureTask = nil - self.silenceTask?.cancel() - self.silenceTask = nil - - // Stop audio before changing phase (stopSpeaking is gated on .speaking). - await self.stopSpeaking(reason: .manual) - - self.lastTranscript = "" - self.lastHeard = nil - self.lastSpeechEnergyAt = nil - self.phase = .idle - await self.stopRecognition() - await MainActor.run { - TalkModeController.shared.updateLevel(0) - TalkModeController.shared.updatePhase(.idle) - } - } - - // MARK: - Speech recognition - - private struct RecognitionUpdate { - let transcript: String? - let hasConfidence: Bool - let isFinal: Bool - let errorDescription: String? - let generation: Int - } - - private func startRecognition() async { - await self.stopRecognition() - self.recognitionGeneration &+= 1 - let generation = self.recognitionGeneration - - let locale = await MainActor.run { AppStateStore.shared.voiceWakeLocaleID } - self.recognizer = SFSpeechRecognizer(locale: Locale(identifier: locale)) - guard let recognizer, recognizer.isAvailable else { - self.logger.error("talk recognizer unavailable") - return - } - - self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest() - self.recognitionRequest?.shouldReportPartialResults = true - guard let request = self.recognitionRequest else { return } - - if self.audioEngine == nil { - self.audioEngine = AVAudioEngine() - } - guard let audioEngine = self.audioEngine else { return } - - let input = audioEngine.inputNode - let format = input.outputFormat(forBus: 0) - input.removeTap(onBus: 0) - let meter = self.rmsMeter - input.installTap(onBus: 0, bufferSize: 2048, format: format) { [weak request, meter] buffer, _ in - request?.append(buffer) - if let rms = Self.rmsLevel(buffer: buffer) { - meter.set(rms) - } - } - - audioEngine.prepare() - do { - try audioEngine.start() - } catch { - self.logger.error("talk audio engine start failed: \(error.localizedDescription, privacy: .public)") - return - } - - self.startRMSTicker(meter: meter) - - self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self, generation] result, error in - guard let self else { return } - let segments = result?.bestTranscription.segments ?? [] - let transcript = result?.bestTranscription.formattedString - let update = RecognitionUpdate( - transcript: transcript, - hasConfidence: segments.contains { $0.confidence > 0.6 }, - isFinal: result?.isFinal ?? false, - errorDescription: error?.localizedDescription, - generation: generation) - Task { await self.handleRecognition(update) } - } - } - - private func stopRecognition() async { - self.recognitionGeneration &+= 1 - self.recognitionTask?.cancel() - self.recognitionTask = nil - self.recognitionRequest?.endAudio() - self.recognitionRequest = nil - self.audioEngine?.inputNode.removeTap(onBus: 0) - self.audioEngine?.stop() - self.audioEngine = nil - self.recognizer = nil - self.rmsTask?.cancel() - self.rmsTask = nil - } - - private func startRMSTicker(meter: RMSMeter) { - self.rmsTask?.cancel() - self.rmsTask = Task { [weak self, meter] in - while let self { - try? await Task.sleep(nanoseconds: 50_000_000) - if Task.isCancelled { return } - await self.noteAudioLevel(rms: meter.get()) - } - } - } - - private func handleRecognition(_ update: RecognitionUpdate) async { - guard update.generation == self.recognitionGeneration else { return } - guard !self.isPaused else { return } - if let errorDescription = update.errorDescription { - self.logger.debug("talk recognition error: \(errorDescription, privacy: .public)") - } - guard let transcript = update.transcript else { return } - - let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines) - if self.phase == .speaking, self.interruptOnSpeech { - if await self.shouldInterrupt(transcript: trimmed, hasConfidence: update.hasConfidence) { - await self.stopSpeaking(reason: .speech) - self.lastTranscript = "" - self.lastHeard = nil - await self.startListening() - } - return - } - - guard self.phase == .listening else { return } - - if !trimmed.isEmpty { - self.lastTranscript = trimmed - self.lastHeard = Date() - } - - if update.isFinal { - self.lastTranscript = trimmed - } - } - - // MARK: - Silence handling - - private func startSilenceMonitor() { - self.silenceTask?.cancel() - self.silenceTask = Task { [weak self] in - await self?.silenceLoop() - } - } - - private func silenceLoop() async { - while self.isEnabled { - try? await Task.sleep(nanoseconds: 200_000_000) - await self.checkSilence() - } - } - - private func checkSilence() async { - guard !self.isPaused else { return } - guard self.phase == .listening else { return } - let transcript = self.lastTranscript.trimmingCharacters(in: .whitespacesAndNewlines) - guard !transcript.isEmpty else { return } - guard let lastHeard else { return } - let elapsed = Date().timeIntervalSince(lastHeard) - guard elapsed >= self.silenceWindow else { return } - await self.finalizeTranscript(transcript) - } - - private func startListening() async { - self.phase = .listening - self.lastTranscript = "" - self.lastHeard = nil - await MainActor.run { - TalkModeController.shared.updatePhase(.listening) - TalkModeController.shared.updateLevel(0) - } - } - - private func finalizeTranscript(_ text: String) async { - self.lastTranscript = "" - self.lastHeard = nil - self.phase = .thinking - await MainActor.run { TalkModeController.shared.updatePhase(.thinking) } - await self.stopRecognition() - await self.sendAndSpeak(text) - } - - // MARK: - Gateway + TTS - - private func sendAndSpeak(_ transcript: String) async { - let gen = self.lifecycleGeneration - await self.reloadConfig() - guard self.isCurrent(gen) else { return } - let prompt = self.buildPrompt(transcript: transcript) - let activeSessionKey = await MainActor.run { WebChatManager.shared.activeSessionKey } - let sessionKey: String = if let activeSessionKey { - activeSessionKey - } else { - await GatewayConnection.shared.mainSessionKey() - } - let runId = UUID().uuidString - let startedAt = Date().timeIntervalSince1970 - self.logger.info( - "talk send start runId=\(runId, privacy: .public) " + - "session=\(sessionKey, privacy: .public) " + - "chars=\(prompt.count, privacy: .public)") - - do { - let response = try await GatewayConnection.shared.chatSend( - sessionKey: sessionKey, - message: prompt, - thinking: "low", - idempotencyKey: runId, - attachments: []) - guard self.isCurrent(gen) else { return } - self.logger.info( - "talk chat.send ok runId=\(response.runId, privacy: .public) " + - "session=\(sessionKey, privacy: .public)") - - guard let assistantText = await self.waitForAssistantText( - sessionKey: sessionKey, - since: startedAt, - timeoutSeconds: 45) - else { - self.logger.warning("talk assistant text missing after timeout") - await self.startListening() - await self.startRecognition() - return - } - guard self.isCurrent(gen) else { return } - - self.logger.info("talk assistant text len=\(assistantText.count, privacy: .public)") - await self.playAssistant(text: assistantText) - guard self.isCurrent(gen) else { return } - await self.resumeListeningIfNeeded() - return - } catch { - self.logger.error("talk chat.send failed: \(error.localizedDescription, privacy: .public)") - await self.resumeListeningIfNeeded() - return - } - } - - private func resumeListeningIfNeeded() async { - if self.isPaused { - self.lastTranscript = "" - self.lastHeard = nil - self.lastSpeechEnergyAt = nil - await MainActor.run { - TalkModeController.shared.updateLevel(0) - } - return - } - await self.startListening() - await self.startRecognition() - } - - private func buildPrompt(transcript: String) -> String { - let interrupted = self.lastInterruptedAtSeconds - self.lastInterruptedAtSeconds = nil - return TalkPromptBuilder.build(transcript: transcript, interruptedAtSeconds: interrupted) - } - - private func waitForAssistantText( - sessionKey: String, - since: Double, - timeoutSeconds: Int) async -> String? - { - let deadline = Date().addingTimeInterval(TimeInterval(timeoutSeconds)) - while Date() < deadline { - if let text = await self.latestAssistantText(sessionKey: sessionKey, since: since) { - return text - } - try? await Task.sleep(nanoseconds: 300_000_000) - } - return nil - } - - private func latestAssistantText(sessionKey: String, since: Double? = nil) async -> String? { - do { - let history = try await GatewayConnection.shared.chatHistory(sessionKey: sessionKey) - let messages = history.messages ?? [] - let decoded: [OpenClawChatMessage] = messages.compactMap { item in - guard let data = try? JSONEncoder().encode(item) else { return nil } - return try? JSONDecoder().decode(OpenClawChatMessage.self, from: data) - } - let assistant = decoded.last { message in - guard message.role == "assistant" else { return false } - guard let since else { return true } - guard let timestamp = message.timestamp else { return false } - return TalkHistoryTimestamp.isAfter(timestamp, sinceSeconds: since) - } - guard let assistant else { return nil } - let text = assistant.content.compactMap(\.text).joined(separator: "\n") - let trimmed = text.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } catch { - self.logger.error("talk history fetch failed: \(error.localizedDescription, privacy: .public)") - return nil - } - } - - private func playAssistant(text: String) async { - guard let input = await self.preparePlaybackInput(text: text) else { return } - do { - if let apiKey = input.apiKey, !apiKey.isEmpty, let voiceId = input.voiceId { - try await self.playElevenLabs(input: input, apiKey: apiKey, voiceId: voiceId) - } else { - try await self.playSystemVoice(input: input) - } - } catch { - self.ttsLogger - .error( - "talk TTS failed: \(error.localizedDescription, privacy: .public); " + - "falling back to system voice") - do { - try await self.playSystemVoice(input: input) - } catch { - self.ttsLogger.error("talk system voice failed: \(error.localizedDescription, privacy: .public)") - } - } - - if self.phase == .speaking { - self.phase = .thinking - await MainActor.run { TalkModeController.shared.updatePhase(.thinking) } - } - } - - private struct TalkPlaybackInput { - let generation: Int - let cleanedText: String - let directive: TalkDirective? - let apiKey: String? - let voiceId: String? - let language: String? - let synthTimeoutSeconds: Double - } - - private func preparePlaybackInput(text: String) async -> TalkPlaybackInput? { - let gen = self.lifecycleGeneration - let parse = TalkDirectiveParser.parse(text) - let directive = parse.directive - let cleaned = parse.stripped.trimmingCharacters(in: .whitespacesAndNewlines) - guard !cleaned.isEmpty else { return nil } - guard self.isCurrent(gen) else { return nil } - - if !parse.unknownKeys.isEmpty { - self.logger - .warning( - "talk directive ignored keys: " + - "\(parse.unknownKeys.joined(separator: ","), privacy: .public)") - } - - let requestedVoice = directive?.voiceId?.trimmingCharacters(in: .whitespacesAndNewlines) - let resolvedVoice = self.resolveVoiceAlias(requestedVoice) - if let requestedVoice, !requestedVoice.isEmpty, resolvedVoice == nil { - self.logger.warning("talk unknown voice alias \(requestedVoice, privacy: .public)") - } - if let voice = resolvedVoice { - if directive?.once == true { - self.logger.info("talk voice override (once) voiceId=\(voice, privacy: .public)") - } else { - self.currentVoiceId = voice - self.voiceOverrideActive = true - self.logger.info("talk voice override voiceId=\(voice, privacy: .public)") - } - } - - if let model = directive?.modelId { - if directive?.once == true { - self.logger.info("talk model override (once) modelId=\(model, privacy: .public)") - } else { - self.currentModelId = model - self.modelOverrideActive = true - } - } - - let apiKey = self.apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) - let preferredVoice = - resolvedVoice ?? - self.currentVoiceId ?? - self.defaultVoiceId - - let language = ElevenLabsTTSClient.validatedLanguage(directive?.language) - - let voiceId: String? = if let apiKey, !apiKey.isEmpty { - await self.resolveVoiceId(preferred: preferredVoice, apiKey: apiKey) - } else { - nil - } - - if apiKey?.isEmpty != false { - self.ttsLogger.warning("talk missing ELEVENLABS_API_KEY; falling back to system voice") - } else if voiceId == nil { - self.ttsLogger.warning("talk missing voiceId; falling back to system voice") - } else if let voiceId { - self.ttsLogger - .info( - "talk TTS request voiceId=\(voiceId, privacy: .public) " + - "chars=\(cleaned.count, privacy: .public)") - } - self.lastSpokenText = cleaned - - let synthTimeoutSeconds = max(20.0, min(90.0, Double(cleaned.count) * 0.12)) - - guard self.isCurrent(gen) else { return nil } - - return TalkPlaybackInput( - generation: gen, - cleanedText: cleaned, - directive: directive, - apiKey: apiKey, - voiceId: voiceId, - language: language, - synthTimeoutSeconds: synthTimeoutSeconds) - } - - private func playElevenLabs(input: TalkPlaybackInput, apiKey: String, voiceId: String) async throws { - let desiredOutputFormat = input.directive?.outputFormat ?? self.defaultOutputFormat ?? "pcm_44100" - let outputFormat = ElevenLabsTTSClient.validatedOutputFormat(desiredOutputFormat) - if outputFormat == nil, !desiredOutputFormat.isEmpty { - self.logger - .warning( - "talk output_format unsupported for local playback: " + - "\(desiredOutputFormat, privacy: .public)") - } - - let modelId = input.directive?.modelId ?? self.currentModelId ?? self.defaultModelId - func makeRequest(outputFormat: String?) -> ElevenLabsTTSRequest { - ElevenLabsTTSRequest( - text: input.cleanedText, - modelId: modelId, - outputFormat: outputFormat, - speed: TalkTTSValidation.resolveSpeed( - speed: input.directive?.speed, - rateWPM: input.directive?.rateWPM), - stability: TalkTTSValidation.validatedStability( - input.directive?.stability, - modelId: modelId), - similarity: TalkTTSValidation.validatedUnit(input.directive?.similarity), - style: TalkTTSValidation.validatedUnit(input.directive?.style), - speakerBoost: input.directive?.speakerBoost, - seed: TalkTTSValidation.validatedSeed(input.directive?.seed), - normalize: ElevenLabsTTSClient.validatedNormalize(input.directive?.normalize), - language: input.language, - latencyTier: TalkTTSValidation.validatedLatencyTier(input.directive?.latencyTier)) - } - - let request = makeRequest(outputFormat: outputFormat) - self.ttsLogger.info("talk TTS synth timeout=\(input.synthTimeoutSeconds, privacy: .public)s") - let client = ElevenLabsTTSClient(apiKey: apiKey) - let stream = client.streamSynthesize(voiceId: voiceId, request: request) - guard self.isCurrent(input.generation) else { return } - - if self.interruptOnSpeech { - guard await self.prepareForPlayback(generation: input.generation) else { return } - } - - await MainActor.run { TalkModeController.shared.updatePhase(.speaking) } - self.phase = .speaking - - let result = await self.playRemoteStream( - client: client, - voiceId: voiceId, - outputFormat: outputFormat, - makeRequest: makeRequest, - stream: stream) - self.ttsLogger - .info( - "talk audio result finished=\(result.finished, privacy: .public) " + - "interruptedAt=\(String(describing: result.interruptedAt), privacy: .public)") - if !result.finished, result.interruptedAt == nil { - throw NSError(domain: "StreamingAudioPlayer", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "audio playback failed", - ]) - } - if !result.finished, let interruptedAt = result.interruptedAt, self.phase == .speaking { - if self.interruptOnSpeech { - self.lastInterruptedAtSeconds = interruptedAt - } - } - } - - private func playRemoteStream( - client: ElevenLabsTTSClient, - voiceId: String, - outputFormat: String?, - makeRequest: (String?) -> ElevenLabsTTSRequest, - stream: AsyncThrowingStream) async -> StreamingPlaybackResult - { - let sampleRate = TalkTTSValidation.pcmSampleRate(from: outputFormat) - if let sampleRate { - self.lastPlaybackWasPCM = true - let result = await self.playPCM(stream: stream, sampleRate: sampleRate) - if result.finished || result.interruptedAt != nil { - return result - } - let mp3Format = ElevenLabsTTSClient.validatedOutputFormat("mp3_44100") - self.ttsLogger.warning("talk pcm playback failed; retrying mp3") - self.lastPlaybackWasPCM = false - let mp3Stream = client.streamSynthesize( - voiceId: voiceId, - request: makeRequest(mp3Format)) - return await self.playMP3(stream: mp3Stream) - } - self.lastPlaybackWasPCM = false - return await self.playMP3(stream: stream) - } - - private func playSystemVoice(input: TalkPlaybackInput) async throws { - self.ttsLogger.info("talk system voice start chars=\(input.cleanedText.count, privacy: .public)") - if self.interruptOnSpeech { - guard await self.prepareForPlayback(generation: input.generation) else { return } - } - await MainActor.run { TalkModeController.shared.updatePhase(.speaking) } - self.phase = .speaking - await TalkSystemSpeechSynthesizer.shared.stop() - try await TalkSystemSpeechSynthesizer.shared.speak( - text: input.cleanedText, - language: input.language) - self.ttsLogger.info("talk system voice done") - } - - private func prepareForPlayback(generation: Int) async -> Bool { - await self.startRecognition() - return self.isCurrent(generation) - } - - private func resolveVoiceId(preferred: String?, apiKey: String) async -> String? { - let trimmed = preferred?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !trimmed.isEmpty { - if let resolved = self.resolveVoiceAlias(trimmed) { return resolved } - self.ttsLogger.warning("talk unknown voice alias \(trimmed, privacy: .public)") - } - if let fallbackVoiceId { return fallbackVoiceId } - - do { - let voices = try await ElevenLabsTTSClient(apiKey: apiKey).listVoices() - guard let first = voices.first else { - self.ttsLogger.error("elevenlabs voices list empty") - return nil - } - self.fallbackVoiceId = first.voiceId - if self.defaultVoiceId == nil { - self.defaultVoiceId = first.voiceId - } - if !self.voiceOverrideActive { - self.currentVoiceId = first.voiceId - } - let name = first.name ?? "unknown" - self.ttsLogger - .info("talk default voice selected \(name, privacy: .public) (\(first.voiceId, privacy: .public))") - return first.voiceId - } catch { - self.ttsLogger.error("elevenlabs list voices failed: \(error.localizedDescription, privacy: .public)") - return nil - } - } - - private func resolveVoiceAlias(_ value: String?) -> String? { - let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - let normalized = trimmed.lowercased() - if let mapped = self.voiceAliases[normalized] { return mapped } - if self.voiceAliases.values.contains(where: { $0.caseInsensitiveCompare(trimmed) == .orderedSame }) { - return trimmed - } - return Self.isLikelyVoiceId(trimmed) ? trimmed : nil - } - - private static func isLikelyVoiceId(_ value: String) -> Bool { - guard value.count >= 10 else { return false } - return value.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" } - } - - func stopSpeaking(reason: TalkStopReason) async { - let usePCM = self.lastPlaybackWasPCM - let interruptedAt = usePCM ? await self.stopPCM() : await self.stopMP3() - _ = usePCM ? await self.stopMP3() : await self.stopPCM() - await TalkSystemSpeechSynthesizer.shared.stop() - guard self.phase == .speaking else { return } - if reason == .speech, let interruptedAt { - self.lastInterruptedAtSeconds = interruptedAt - } - if reason == .manual { - return - } - if reason == .speech || reason == .userTap { - await self.startListening() - return - } - self.phase = .thinking - await MainActor.run { TalkModeController.shared.updatePhase(.thinking) } - } -} - -extension TalkModeRuntime { - // MARK: - Audio playback (MainActor helpers) - - @MainActor - private func playPCM( - stream: AsyncThrowingStream, - sampleRate: Double) async -> StreamingPlaybackResult - { - await PCMStreamingAudioPlayer.shared.play(stream: stream, sampleRate: sampleRate) - } - - @MainActor - private func playMP3(stream: AsyncThrowingStream) async -> StreamingPlaybackResult { - await StreamingAudioPlayer.shared.play(stream: stream) - } - - @MainActor - private func stopPCM() -> Double? { - PCMStreamingAudioPlayer.shared.stop() - } - - @MainActor - private func stopMP3() -> Double? { - StreamingAudioPlayer.shared.stop() - } - - // MARK: - Config - - private func reloadConfig() async { - let cfg = await self.fetchTalkConfig() - self.defaultVoiceId = cfg.voiceId - self.voiceAliases = cfg.voiceAliases - if !self.voiceOverrideActive { - self.currentVoiceId = cfg.voiceId - } - self.defaultModelId = cfg.modelId - if !self.modelOverrideActive { - self.currentModelId = cfg.modelId - } - self.defaultOutputFormat = cfg.outputFormat - self.interruptOnSpeech = cfg.interruptOnSpeech - self.apiKey = cfg.apiKey - let hasApiKey = (cfg.apiKey?.isEmpty == false) - let voiceLabel = (cfg.voiceId?.isEmpty == false) ? cfg.voiceId! : "none" - let modelLabel = (cfg.modelId?.isEmpty == false) ? cfg.modelId! : "none" - self.logger - .info( - "talk config voiceId=\(voiceLabel, privacy: .public) " + - "modelId=\(modelLabel, privacy: .public) " + - "apiKey=\(hasApiKey, privacy: .public) " + - "interrupt=\(cfg.interruptOnSpeech, privacy: .public)") - } - - private struct TalkRuntimeConfig { - let voiceId: String? - let voiceAliases: [String: String] - let modelId: String? - let outputFormat: String? - let interruptOnSpeech: Bool - let apiKey: String? - } - - private func fetchTalkConfig() async -> TalkRuntimeConfig { - let env = ProcessInfo.processInfo.environment - let envVoice = env["ELEVENLABS_VOICE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines) - let sagVoice = env["SAG_VOICE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines) - let envApiKey = env["ELEVENLABS_API_KEY"]?.trimmingCharacters(in: .whitespacesAndNewlines) - - do { - let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded( - method: .talkConfig, - params: ["includeSecrets": AnyCodable(true)], - timeoutMs: 8000) - let talk = snap.config?["talk"]?.dictionaryValue - let ui = snap.config?["ui"]?.dictionaryValue - let rawSeam = ui?["seamColor"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - await MainActor.run { - AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam - } - let voice = talk?["voiceId"]?.stringValue - let rawAliases = talk?["voiceAliases"]?.dictionaryValue - let resolvedAliases: [String: String] = - rawAliases?.reduce(into: [:]) { acc, entry in - let key = entry.key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let value = entry.value.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !key.isEmpty, !value.isEmpty else { return } - acc[key] = value - } ?? [:] - let model = talk?["modelId"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) - let resolvedModel = (model?.isEmpty == false) ? model! : Self.defaultModelIdFallback - let outputFormat = talk?["outputFormat"]?.stringValue - let interrupt = talk?["interruptOnSpeech"]?.boolValue - let apiKey = talk?["apiKey"]?.stringValue - let resolvedVoice = - (voice?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? voice : nil) ?? - (envVoice?.isEmpty == false ? envVoice : nil) ?? - (sagVoice?.isEmpty == false ? sagVoice : nil) - let resolvedApiKey = - (envApiKey?.isEmpty == false ? envApiKey : nil) ?? - (apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? apiKey : nil) - return TalkRuntimeConfig( - voiceId: resolvedVoice, - voiceAliases: resolvedAliases, - modelId: resolvedModel, - outputFormat: outputFormat, - interruptOnSpeech: interrupt ?? true, - apiKey: resolvedApiKey) - } catch { - let resolvedVoice = - (envVoice?.isEmpty == false ? envVoice : nil) ?? - (sagVoice?.isEmpty == false ? sagVoice : nil) - let resolvedApiKey = envApiKey?.isEmpty == false ? envApiKey : nil - return TalkRuntimeConfig( - voiceId: resolvedVoice, - voiceAliases: [:], - modelId: Self.defaultModelIdFallback, - outputFormat: nil, - interruptOnSpeech: true, - apiKey: resolvedApiKey) - } - } - - // MARK: - Audio level handling - - private func noteAudioLevel(rms: Double) async { - if self.phase != .listening, self.phase != .speaking { return } - let alpha: Double = rms < self.noiseFloorRMS ? 0.08 : 0.01 - self.noiseFloorRMS = max(1e-7, self.noiseFloorRMS + (rms - self.noiseFloorRMS) * alpha) - - let threshold = max(self.minSpeechRMS, self.noiseFloorRMS * self.speechBoostFactor) - if rms >= threshold { - let now = Date() - self.lastHeard = now - self.lastSpeechEnergyAt = now - } - - if self.phase == .listening { - let clamped = min(1.0, max(0.0, rms / max(self.minSpeechRMS, threshold))) - await MainActor.run { TalkModeController.shared.updateLevel(clamped) } - } - } - - private static func rmsLevel(buffer: AVAudioPCMBuffer) -> Double? { - guard let channelData = buffer.floatChannelData?.pointee else { return nil } - let frameCount = Int(buffer.frameLength) - guard frameCount > 0 else { return nil } - var sum: Double = 0 - for i in 0.. Bool { - let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.count >= 3 else { return false } - if self.isLikelyEcho(of: trimmed) { return false } - let now = Date() - if let lastSpeechEnergyAt, now.timeIntervalSince(lastSpeechEnergyAt) > 0.35 { - return false - } - return hasConfidence - } - - private func isLikelyEcho(of transcript: String) -> Bool { - guard let spoken = self.lastSpokenText?.lowercased(), !spoken.isEmpty else { return false } - let probe = transcript.lowercased() - if probe.count < 6 { - return spoken.contains(probe) - } - return spoken.contains(probe) - } - - private static func resolveSpeed(speed: Double?, rateWPM: Int?, logger: Logger) -> Double? { - if let rateWPM, rateWPM > 0 { - let resolved = Double(rateWPM) / 175.0 - if resolved <= 0.5 || resolved >= 2.0 { - logger.warning("talk rateWPM out of range: \(rateWPM, privacy: .public)") - return nil - } - return resolved - } - if let speed { - if speed <= 0.5 || speed >= 2.0 { - logger.warning("talk speed out of range: \(speed, privacy: .public)") - return nil - } - return speed - } - return nil - } - - private static func validatedUnit(_ value: Double?, name: String, logger: Logger) -> Double? { - guard let value else { return nil } - if value < 0 || value > 1 { - logger.warning("talk \(name, privacy: .public) out of range: \(value, privacy: .public)") - return nil - } - return value - } - - private static func validatedSeed(_ value: Int?, logger: Logger) -> UInt32? { - guard let value else { return nil } - if value < 0 || value > 4_294_967_295 { - logger.warning("talk seed out of range: \(value, privacy: .public)") - return nil - } - return UInt32(value) - } - - private static func validatedNormalize(_ value: String?, logger: Logger) -> String? { - guard let value else { return nil } - let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard ["auto", "on", "off"].contains(normalized) else { - logger.warning("talk normalize invalid: \(normalized, privacy: .public)") - return nil - } - return normalized - } -} diff --git a/apps/macos/Sources/OpenClaw/TalkModeTypes.swift b/apps/macos/Sources/OpenClaw/TalkModeTypes.swift deleted file mode 100644 index 3ae978255f4..00000000000 --- a/apps/macos/Sources/OpenClaw/TalkModeTypes.swift +++ /dev/null @@ -1,8 +0,0 @@ -import Foundation - -enum TalkModePhase: String { - case idle - case listening - case thinking - case speaking -} diff --git a/apps/macos/Sources/OpenClaw/TalkOverlay.swift b/apps/macos/Sources/OpenClaw/TalkOverlay.swift deleted file mode 100644 index 27e5dedc110..00000000000 --- a/apps/macos/Sources/OpenClaw/TalkOverlay.swift +++ /dev/null @@ -1,146 +0,0 @@ -import AppKit -import Observation -import OSLog -import SwiftUI - -@MainActor -@Observable -final class TalkOverlayController { - static let shared = TalkOverlayController() - static let overlaySize: CGFloat = 440 - static let orbSize: CGFloat = 96 - static let orbPadding: CGFloat = 12 - static let orbHitSlop: CGFloat = 10 - - private let logger = Logger(subsystem: "ai.openclaw", category: "talk.overlay") - - struct Model { - var isVisible: Bool = false - var phase: TalkModePhase = .idle - var isPaused: Bool = false - var level: Double = 0 - } - - var model = Model() - private var window: NSPanel? - private var hostingView: NSHostingView? - private let screenInset: CGFloat = 0 - - func present() { - self.ensureWindow() - self.hostingView?.rootView = TalkOverlayView(controller: self) - let target = self.targetFrame() - - guard let window else { return } - if !self.model.isVisible { - self.model.isVisible = true - let start = target.offsetBy(dx: 0, dy: -6) - window.setFrame(start, display: true) - window.alphaValue = 0 - window.orderFrontRegardless() - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.18 - context.timingFunction = CAMediaTimingFunction(name: .easeOut) - window.animator().setFrame(target, display: true) - window.animator().alphaValue = 1 - } - } else { - window.setFrame(target, display: true) - window.orderFrontRegardless() - } - } - - func dismiss() { - guard let window else { - self.model.isVisible = false - return - } - - let target = window.frame.offsetBy(dx: 6, dy: 6) - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.16 - context.timingFunction = CAMediaTimingFunction(name: .easeOut) - window.animator().setFrame(target, display: true) - window.animator().alphaValue = 0 - } completionHandler: { - Task { @MainActor in - window.orderOut(nil) - self.model.isVisible = false - } - } - } - - func updatePhase(_ phase: TalkModePhase) { - guard self.model.phase != phase else { return } - self.logger.info("talk overlay phase=\(phase.rawValue, privacy: .public)") - self.model.phase = phase - } - - func updatePaused(_ paused: Bool) { - guard self.model.isPaused != paused else { return } - self.logger.info("talk overlay paused=\(paused)") - self.model.isPaused = paused - } - - func updateLevel(_ level: Double) { - guard self.model.isVisible else { return } - self.model.level = max(0, min(1, level)) - } - - func currentWindowOrigin() -> CGPoint? { - self.window?.frame.origin - } - - func setWindowOrigin(_ origin: CGPoint) { - guard let window else { return } - window.setFrameOrigin(origin) - } - - // MARK: - Private - - private func ensureWindow() { - if self.window != nil { return } - let panel = NSPanel( - contentRect: NSRect(x: 0, y: 0, width: Self.overlaySize, height: Self.overlaySize), - styleMask: [.nonactivatingPanel, .borderless], - backing: .buffered, - defer: false) - panel.isOpaque = false - panel.backgroundColor = .clear - panel.hasShadow = false - panel.level = NSWindow.Level(rawValue: NSWindow.Level.popUpMenu.rawValue - 4) - panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient] - panel.hidesOnDeactivate = false - panel.isMovable = false - panel.acceptsMouseMovedEvents = true - panel.isFloatingPanel = true - panel.becomesKeyOnlyIfNeeded = true - panel.titleVisibility = .hidden - panel.titlebarAppearsTransparent = true - - let host = TalkOverlayHostingView(rootView: TalkOverlayView(controller: self)) - host.translatesAutoresizingMaskIntoConstraints = false - panel.contentView = host - self.hostingView = host - self.window = panel - } - - private func targetFrame() -> NSRect { - let screen = self.window?.screen - ?? NSScreen.main - ?? NSScreen.screens.first - guard let screen else { return .zero } - let size = NSSize(width: Self.overlaySize, height: Self.overlaySize) - let visible = screen.visibleFrame - let origin = CGPoint( - x: visible.maxX - size.width - self.screenInset, - y: visible.maxY - size.height - self.screenInset) - return NSRect(origin: origin, size: size) - } -} - -private final class TalkOverlayHostingView: NSHostingView { - override func acceptsFirstMouse(for event: NSEvent?) -> Bool { - true - } -} diff --git a/apps/macos/Sources/OpenClaw/TalkOverlayView.swift b/apps/macos/Sources/OpenClaw/TalkOverlayView.swift deleted file mode 100644 index 80599d55ec3..00000000000 --- a/apps/macos/Sources/OpenClaw/TalkOverlayView.swift +++ /dev/null @@ -1,225 +0,0 @@ -import AppKit -import SwiftUI - -struct TalkOverlayView: View { - var controller: TalkOverlayController - @State private var appState = AppStateStore.shared - @State private var hoveringWindow = false - - var body: some View { - ZStack(alignment: .topTrailing) { - let isPaused = self.controller.model.isPaused - Color.clear - TalkOrbView( - phase: self.controller.model.phase, - level: self.controller.model.level, - accent: self.seamColor, - isPaused: isPaused) - .frame(width: TalkOverlayController.orbSize, height: TalkOverlayController.orbSize) - .padding(.top, TalkOverlayController.orbPadding) - .padding(.trailing, TalkOverlayController.orbPadding) - .contentShape(Circle()) - .opacity(isPaused ? 0.55 : 1) - .background( - TalkOrbInteractionView( - onSingleClick: { TalkModeController.shared.togglePaused() }, - onDoubleClick: { TalkModeController.shared.stopSpeaking(reason: .userTap) }, - onDragStart: { TalkModeController.shared.setPaused(true) })) - .overlay(alignment: .topLeading) { - Button { - TalkModeController.shared.exitTalkMode() - } label: { - Image(systemName: "xmark") - .font(.system(size: 10, weight: .bold)) - .foregroundStyle(Color.white.opacity(0.95)) - .frame(width: 18, height: 18) - .background(Color.black.opacity(0.4)) - .clipShape(Circle()) - } - .buttonStyle(.plain) - .contentShape(Circle()) - .offset(x: -2, y: -2) - .opacity(self.hoveringWindow ? 1 : 0) - .animation(.easeOut(duration: 0.12), value: self.hoveringWindow) - } - .onHover { self.hoveringWindow = $0 } - } - .frame( - width: TalkOverlayController.overlaySize, - height: TalkOverlayController.overlaySize, - alignment: .topTrailing) - } - - private static let defaultSeamColor = Color(red: 79 / 255.0, green: 122 / 255.0, blue: 154 / 255.0) - - private var seamColor: Color { - Self.color(fromHex: self.appState.seamColorHex) ?? Self.defaultSeamColor - } - - private static func color(fromHex raw: String?) -> Color? { - let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed - guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil } - let r = Double((value >> 16) & 0xFF) / 255.0 - let g = Double((value >> 8) & 0xFF) / 255.0 - let b = Double(value & 0xFF) / 255.0 - return Color(red: r, green: g, blue: b) - } -} - -private struct TalkOrbInteractionView: NSViewRepresentable { - let onSingleClick: () -> Void - let onDoubleClick: () -> Void - let onDragStart: () -> Void - - func makeNSView(context: Context) -> NSView { - let view = OrbInteractionNSView() - view.onSingleClick = self.onSingleClick - view.onDoubleClick = self.onDoubleClick - view.onDragStart = self.onDragStart - view.wantsLayer = true - view.layer?.backgroundColor = NSColor.clear.cgColor - return view - } - - func updateNSView(_ nsView: NSView, context: Context) { - guard let view = nsView as? OrbInteractionNSView else { return } - view.onSingleClick = self.onSingleClick - view.onDoubleClick = self.onDoubleClick - view.onDragStart = self.onDragStart - } -} - -private final class OrbInteractionNSView: NSView { - var onSingleClick: (() -> Void)? - var onDoubleClick: (() -> Void)? - var onDragStart: (() -> Void)? - private var mouseDownEvent: NSEvent? - private var didDrag = false - private var suppressSingleClick = false - - override var acceptsFirstResponder: Bool { - true - } - - override func acceptsFirstMouse(for event: NSEvent?) -> Bool { - true - } - - override func mouseDown(with event: NSEvent) { - self.mouseDownEvent = event - self.didDrag = false - self.suppressSingleClick = event.clickCount > 1 - if event.clickCount == 2 { - self.onDoubleClick?() - } - } - - override func mouseDragged(with event: NSEvent) { - guard let startEvent = self.mouseDownEvent else { return } - if !self.didDrag { - let dx = event.locationInWindow.x - startEvent.locationInWindow.x - let dy = event.locationInWindow.y - startEvent.locationInWindow.y - if abs(dx) + abs(dy) < 2 { return } - self.didDrag = true - self.onDragStart?() - self.window?.performDrag(with: startEvent) - } - } - - override func mouseUp(with event: NSEvent) { - if !self.didDrag, !self.suppressSingleClick { - self.onSingleClick?() - } - self.mouseDownEvent = nil - self.didDrag = false - self.suppressSingleClick = false - } -} - -private struct TalkOrbView: View { - let phase: TalkModePhase - let level: Double - let accent: Color - let isPaused: Bool - - var body: some View { - if self.isPaused { - Circle() - .fill(self.orbGradient) - .overlay(Circle().stroke(Color.white.opacity(0.35), lineWidth: 1)) - .shadow(color: Color.black.opacity(0.18), radius: 10, x: 0, y: 5) - } else { - TimelineView(.animation) { context in - let t = context.date.timeIntervalSinceReferenceDate - let listenScale = self.phase == .listening ? (1 + CGFloat(self.level) * 0.12) : 1 - let pulse = self.phase == .speaking ? (1 + 0.06 * sin(t * 6)) : 1 - - ZStack { - Circle() - .fill(self.orbGradient) - .overlay(Circle().stroke(Color.white.opacity(0.45), lineWidth: 1)) - .shadow(color: Color.black.opacity(0.22), radius: 10, x: 0, y: 5) - .scaleEffect(pulse * listenScale) - - TalkWaveRings(phase: self.phase, level: self.level, time: t, accent: self.accent) - - if self.phase == .thinking { - TalkOrbitArcs(time: t) - } - } - } - } - } - - private var orbGradient: RadialGradient { - RadialGradient( - colors: [Color.white, self.accent], - center: .topLeading, - startRadius: 4, - endRadius: 52) - } -} - -private struct TalkWaveRings: View { - let phase: TalkModePhase - let level: Double - let time: TimeInterval - let accent: Color - - var body: some View { - ZStack { - ForEach(0..<3, id: \.self) { idx in - let speed = self.phase == .speaking ? 1.4 : self.phase == .listening ? 0.9 : 0.6 - let progress = (time * speed + Double(idx) * 0.28).truncatingRemainder(dividingBy: 1) - let amplitude = self.phase == .speaking ? 0.95 : self.phase == .listening ? 0.5 + self - .level * 0.7 : 0.35 - let scale = 0.75 + progress * amplitude + (self.phase == .listening ? self.level * 0.15 : 0) - let alpha = self.phase == .speaking ? 0.72 : self.phase == .listening ? 0.58 + self.level * 0.28 : 0.4 - Circle() - .stroke(self.accent.opacity(alpha - progress * 0.3), lineWidth: 1.6) - .scaleEffect(scale) - .opacity(alpha - progress * 0.6) - } - } - } -} - -private struct TalkOrbitArcs: View { - let time: TimeInterval - - var body: some View { - ZStack { - Circle() - .trim(from: 0.08, to: 0.26) - .stroke(Color.white.opacity(0.88), style: StrokeStyle(lineWidth: 1.6, lineCap: .round)) - .rotationEffect(.degrees(self.time * 42)) - Circle() - .trim(from: 0.62, to: 0.86) - .stroke(Color.white.opacity(0.7), style: StrokeStyle(lineWidth: 1.4, lineCap: .round)) - .rotationEffect(.degrees(-self.time * 35)) - } - .scaleEffect(1.08) - } -} diff --git a/apps/macos/Sources/OpenClaw/TerminationSignalWatcher.swift b/apps/macos/Sources/OpenClaw/TerminationSignalWatcher.swift deleted file mode 100644 index add543c3ebe..00000000000 --- a/apps/macos/Sources/OpenClaw/TerminationSignalWatcher.swift +++ /dev/null @@ -1,53 +0,0 @@ -import AppKit -import Foundation -import OSLog - -@MainActor -final class TerminationSignalWatcher { - static let shared = TerminationSignalWatcher() - - private let logger = Logger(subsystem: "ai.openclaw", category: "lifecycle") - private var sources: [DispatchSourceSignal] = [] - private var terminationRequested = false - - func start() { - guard self.sources.isEmpty else { return } - self.install(SIGTERM) - self.install(SIGINT) - } - - func stop() { - for s in self.sources { - s.cancel() - } - self.sources.removeAll(keepingCapacity: false) - self.terminationRequested = false - } - - private func install(_ sig: Int32) { - // Make sure the default action doesn't kill the process before we can gracefully shut down. - signal(sig, SIG_IGN) - let source = DispatchSource.makeSignalSource(signal: sig, queue: .main) - source.setEventHandler { [weak self] in - self?.handle(sig) - } - source.resume() - self.sources.append(source) - } - - private func handle(_ sig: Int32) { - guard !self.terminationRequested else { return } - self.terminationRequested = true - - self.logger.info("received signal \(sig, privacy: .public); terminating") - // Ensure any pairing prompt can't accidentally approve during shutdown. - NodePairingApprovalPrompter.shared.stop() - DevicePairingApprovalPrompter.shared.stop() - NSApp.terminate(nil) - - // Safety net: don't hang forever if something blocks termination. - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { - exit(0) - } - } -} diff --git a/apps/macos/Sources/OpenClaw/UsageCostData.swift b/apps/macos/Sources/OpenClaw/UsageCostData.swift deleted file mode 100644 index ca1fb5cc3e2..00000000000 --- a/apps/macos/Sources/OpenClaw/UsageCostData.swift +++ /dev/null @@ -1,60 +0,0 @@ -import Foundation - -struct GatewayCostUsageTotals: Codable { - let input: Int - let output: Int - let cacheRead: Int - let cacheWrite: Int - let totalTokens: Int - let totalCost: Double - let missingCostEntries: Int -} - -struct GatewayCostUsageDay: Codable { - let date: String - let input: Int - let output: Int - let cacheRead: Int - let cacheWrite: Int - let totalTokens: Int - let totalCost: Double - let missingCostEntries: Int -} - -struct GatewayCostUsageSummary: Codable { - let updatedAt: Double - let days: Int - let daily: [GatewayCostUsageDay] - let totals: GatewayCostUsageTotals -} - -enum CostUsageFormatting { - static func formatUsd(_ value: Double?) -> String? { - guard let value, value.isFinite else { return nil } - if value >= 1 { return String(format: "$%.2f", value) } - if value >= 0.01 { return String(format: "$%.2f", value) } - return String(format: "$%.4f", value) - } - - static func formatTokenCount(_ value: Int?) -> String? { - guard let value else { return nil } - let safe = max(0, value) - if safe >= 1_000_000 { return String(format: "%.1fm", Double(safe) / 1_000_000.0) } - if safe >= 1000 { return safe >= 10000 - ? String(format: "%.0fk", Double(safe) / 1000.0) - : String(format: "%.1fk", Double(safe) / 1000.0) - } - return String(safe) - } -} - -@MainActor -enum CostUsageLoader { - static func loadSummary() async throws -> GatewayCostUsageSummary { - let data = try await ControlChannel.shared.request( - method: "usage.cost", - params: nil, - timeoutMs: 7000) - return try JSONDecoder().decode(GatewayCostUsageSummary.self, from: data) - } -} diff --git a/apps/macos/Sources/OpenClaw/UsageData.swift b/apps/macos/Sources/OpenClaw/UsageData.swift deleted file mode 100644 index 3886c966edb..00000000000 --- a/apps/macos/Sources/OpenClaw/UsageData.swift +++ /dev/null @@ -1,103 +0,0 @@ -import Foundation - -struct GatewayUsageWindow: Codable { - let label: String - let usedPercent: Double - let resetAt: Double? -} - -struct GatewayUsageProvider: Codable { - let provider: String - let displayName: String - let windows: [GatewayUsageWindow] - let plan: String? - let error: String? -} - -struct GatewayUsageSummary: Codable { - let updatedAt: Double - let providers: [GatewayUsageProvider] -} - -struct UsageRow: Identifiable { - let id: String - let providerId: String - let displayName: String - let plan: String? - let windowLabel: String? - let usedPercent: Double? - let resetAt: Date? - let error: String? - - var hasError: Bool { - if let error, !error.isEmpty { return true } - return false - } - - var titleText: String { - if let plan, !plan.isEmpty { return "\(self.displayName) (\(plan))" } - return self.displayName - } - - var remainingPercent: Int? { - guard let usedPercent, usedPercent.isFinite else { return nil } - return max(0, min(100, Int(round(100 - usedPercent)))) - } - - func detailText(now: Date = .init()) -> String { - guard let remaining = self.remainingPercent else { return "No data" } - var parts = ["\(remaining)% left"] - if let windowLabel, !windowLabel.isEmpty { parts.append(windowLabel) } - if let resetAt { - let reset = UsageRow.formatResetRemaining(target: resetAt, now: now) - if let reset { parts.append("⏱\(reset)") } - } - return parts.joined(separator: " · ") - } - - private static func formatResetRemaining(target: Date, now: Date) -> String? { - let diff = target.timeIntervalSince(now) - if diff <= 0 { return "now" } - let minutes = Int(floor(diff / 60)) - if minutes < 60 { return "\(minutes)m" } - let hours = minutes / 60 - let mins = minutes % 60 - if hours < 24 { return mins > 0 ? "\(hours)h \(mins)m" : "\(hours)h" } - let days = hours / 24 - if days < 7 { return "\(days)d \(hours % 24)h" } - let formatter = DateFormatter() - formatter.dateFormat = "MMM d" - return formatter.string(from: target) - } -} - -extension GatewayUsageSummary { - func primaryRows() -> [UsageRow] { - self.providers.compactMap { provider in - guard let window = provider.windows.max(by: { $0.usedPercent < $1.usedPercent }) else { - return nil - } - - return UsageRow( - id: "\(provider.provider)-\(window.label)", - providerId: provider.provider, - displayName: provider.displayName, - plan: provider.plan, - windowLabel: window.label, - usedPercent: window.usedPercent, - resetAt: window.resetAt.map { Date(timeIntervalSince1970: $0 / 1000) }, - error: nil) - } - } -} - -@MainActor -enum UsageLoader { - static func loadSummary() async throws -> GatewayUsageSummary { - let data = try await ControlChannel.shared.request( - method: "usage.status", - params: nil, - timeoutMs: 5000) - return try JSONDecoder().decode(GatewayUsageSummary.self, from: data) - } -} diff --git a/apps/macos/Sources/OpenClaw/UsageMenuLabelView.swift b/apps/macos/Sources/OpenClaw/UsageMenuLabelView.swift deleted file mode 100644 index c7f95e47660..00000000000 --- a/apps/macos/Sources/OpenClaw/UsageMenuLabelView.swift +++ /dev/null @@ -1,59 +0,0 @@ -import SwiftUI - -struct UsageMenuLabelView: View { - let row: UsageRow - let width: CGFloat - var showsChevron: Bool = false - @Environment(\.menuItemHighlighted) private var isHighlighted - private let paddingLeading: CGFloat = 22 - private let paddingTrailing: CGFloat = 14 - private let barHeight: CGFloat = 6 - - private var primaryTextColor: Color { - self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary - } - - private var secondaryTextColor: Color { - self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary - } - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - if let used = row.usedPercent { - ContextUsageBar( - usedTokens: Int(round(used)), - contextTokens: 100, - width: max(1, self.width - (self.paddingLeading + self.paddingTrailing)), - height: self.barHeight) - } - - HStack(alignment: .firstTextBaseline, spacing: 6) { - Text(self.row.titleText) - .font(.caption.weight(.semibold)) - .foregroundStyle(self.primaryTextColor) - .lineLimit(1) - .truncationMode(.middle) - .layoutPriority(1) - - Spacer(minLength: 4) - - Text(self.row.detailText()) - .font(.caption.monospacedDigit()) - .foregroundStyle(self.secondaryTextColor) - .lineLimit(1) - .truncationMode(.tail) - .layoutPriority(2) - - if self.showsChevron { - Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) - .foregroundStyle(self.secondaryTextColor) - .padding(.leading, 2) - } - } - } - .padding(.vertical, 10) - .padding(.leading, self.paddingLeading) - .padding(.trailing, self.paddingTrailing) - } -} diff --git a/apps/macos/Sources/OpenClaw/UserDefaultsMigration.swift b/apps/macos/Sources/OpenClaw/UserDefaultsMigration.swift deleted file mode 100644 index 793e52baeb7..00000000000 --- a/apps/macos/Sources/OpenClaw/UserDefaultsMigration.swift +++ /dev/null @@ -1,16 +0,0 @@ -import Foundation - -private let legacyDefaultsPrefix = "openclaw." -private let defaultsPrefix = "openclaw." - -func migrateLegacyDefaults() { - let defaults = UserDefaults.standard - let snapshot = defaults.dictionaryRepresentation() - for (key, value) in snapshot where key.hasPrefix(legacyDefaultsPrefix) { - let suffix = key.dropFirst(legacyDefaultsPrefix.count) - let newKey = defaultsPrefix + suffix - if defaults.object(forKey: newKey) == nil { - defaults.set(value, forKey: newKey) - } - } -} diff --git a/apps/macos/Sources/OpenClaw/ViewMetrics.swift b/apps/macos/Sources/OpenClaw/ViewMetrics.swift deleted file mode 100644 index dfd7180de0f..00000000000 --- a/apps/macos/Sources/OpenClaw/ViewMetrics.swift +++ /dev/null @@ -1,29 +0,0 @@ -import SwiftUI - -private struct ViewWidthPreferenceKey: PreferenceKey { - static let defaultValue: CGFloat = 0 - - static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { - value = max(value, nextValue()) - } -} - -extension View { - func onWidthChange(_ onChange: @escaping (CGFloat) -> Void) -> some View { - self.background( - GeometryReader { proxy in - Color.clear.preference(key: ViewWidthPreferenceKey.self, value: proxy.size.width) - }) - .onPreferenceChange(ViewWidthPreferenceKey.self, perform: onChange) - } -} - -#if DEBUG -enum ViewMetricsTesting { - static func reduceWidth(current: CGFloat, next: CGFloat) -> CGFloat { - var value = current - ViewWidthPreferenceKey.reduce(value: &value, nextValue: { next }) - return value - } -} -#endif diff --git a/apps/macos/Sources/OpenClaw/VisualEffectView.swift b/apps/macos/Sources/OpenClaw/VisualEffectView.swift deleted file mode 100644 index b18971109ab..00000000000 --- a/apps/macos/Sources/OpenClaw/VisualEffectView.swift +++ /dev/null @@ -1,37 +0,0 @@ -import AppKit -import SwiftUI - -struct VisualEffectView: NSViewRepresentable { - var material: NSVisualEffectView.Material - var blendingMode: NSVisualEffectView.BlendingMode - var state: NSVisualEffectView.State - var emphasized: Bool - - init( - material: NSVisualEffectView.Material, - blendingMode: NSVisualEffectView.BlendingMode = .behindWindow, - state: NSVisualEffectView.State = .active, - emphasized: Bool = false) - { - self.material = material - self.blendingMode = blendingMode - self.state = state - self.emphasized = emphasized - } - - func makeNSView(context _: Context) -> NSVisualEffectView { - let view = NSVisualEffectView() - view.material = self.material - view.blendingMode = self.blendingMode - view.state = self.state - view.isEmphasized = self.emphasized - return view - } - - func updateNSView(_ nsView: NSVisualEffectView, context _: Context) { - nsView.material = self.material - nsView.blendingMode = self.blendingMode - nsView.state = self.state - nsView.isEmphasized = self.emphasized - } -} diff --git a/apps/macos/Sources/OpenClaw/VoicePushToTalk.swift b/apps/macos/Sources/OpenClaw/VoicePushToTalk.swift deleted file mode 100644 index e535ebd6616..00000000000 --- a/apps/macos/Sources/OpenClaw/VoicePushToTalk.swift +++ /dev/null @@ -1,421 +0,0 @@ -import AppKit -import AVFoundation -import Dispatch -import OSLog -import Speech - -/// Observes right Option and starts a push-to-talk capture while it is held. -final class VoicePushToTalkHotkey: @unchecked Sendable { - static let shared = VoicePushToTalkHotkey() - - private var globalMonitor: Any? - private var localMonitor: Any? - private var optionDown = false // right option only - private var active = false - - private let beginAction: @Sendable () async -> Void - private let endAction: @Sendable () async -> Void - - init( - beginAction: @escaping @Sendable () async -> Void = { await VoicePushToTalk.shared.begin() }, - endAction: @escaping @Sendable () async -> Void = { await VoicePushToTalk.shared.end() }) - { - self.beginAction = beginAction - self.endAction = endAction - } - - func setEnabled(_ enabled: Bool) { - if ProcessInfo.processInfo.isRunningTests { return } - self.withMainThread { [weak self] in - guard let self else { return } - if enabled { - self.startMonitoring() - } else { - self.stopMonitoring() - } - } - } - - private func startMonitoring() { - // assert(Thread.isMainThread) - Removed for Swift 6 - guard self.globalMonitor == nil, self.localMonitor == nil else { return } - // Listen-only global monitor; we rely on Input Monitoring permission to receive events. - self.globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in - let keyCode = event.keyCode - let flags = event.modifierFlags - self?.handleFlagsChanged(keyCode: keyCode, modifierFlags: flags) - } - // Also listen locally so we still catch events when the app is active/focused. - self.localMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in - let keyCode = event.keyCode - let flags = event.modifierFlags - self?.handleFlagsChanged(keyCode: keyCode, modifierFlags: flags) - return event - } - } - - private func stopMonitoring() { - // assert(Thread.isMainThread) - Removed for Swift 6 - if let globalMonitor { - NSEvent.removeMonitor(globalMonitor) - self.globalMonitor = nil - } - if let localMonitor { - NSEvent.removeMonitor(localMonitor) - self.localMonitor = nil - } - self.optionDown = false - self.active = false - } - - private func handleFlagsChanged(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) { - self.withMainThread { [weak self] in - self?.updateModifierState(keyCode: keyCode, modifierFlags: modifierFlags) - } - } - - private func withMainThread(_ block: @escaping @Sendable () -> Void) { - DispatchQueue.main.async(execute: block) - } - - private func updateModifierState(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) { - // assert(Thread.isMainThread) - Removed for Swift 6 - // Right Option (keyCode 61) acts as a hold-to-talk modifier. - if keyCode == 61 { - self.optionDown = modifierFlags.contains(.option) - } - - let chordActive = self.optionDown - if chordActive, !self.active { - self.active = true - Task { - Logger(subsystem: "ai.openclaw", category: "voicewake.ptt") - .info("ptt hotkey down") - await self.beginAction() - } - } else if !chordActive, self.active { - self.active = false - Task { - Logger(subsystem: "ai.openclaw", category: "voicewake.ptt") - .info("ptt hotkey up") - await self.endAction() - } - } - } - - func _testUpdateModifierState(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) { - self.updateModifierState(keyCode: keyCode, modifierFlags: modifierFlags) - } -} - -/// Short-lived speech recognizer that records while the hotkey is held. -actor VoicePushToTalk { - static let shared = VoicePushToTalk() - - private let logger = Logger(subsystem: "ai.openclaw", category: "voicewake.ptt") - - private var recognizer: SFSpeechRecognizer? - // Lazily created on begin() to avoid creating an AVAudioEngine at app launch, which can switch Bluetooth - // headphones into the low-quality headset profile even if push-to-talk is never used. - private var audioEngine: AVAudioEngine? - private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? - private var recognitionTask: SFSpeechRecognitionTask? - private var tapInstalled = false - - /// Session token used to drop stale callbacks when a new capture starts. - private var sessionID = UUID() - - private var committed: String = "" - private var volatile: String = "" - private var activeConfig: Config? - private var isCapturing = false - private var triggerChimePlayed = false - private var finalized = false - private var timeoutTask: Task? - private var overlayToken: UUID? - private var adoptedPrefix: String = "" - - private struct Config { - let micID: String? - let localeID: String? - let triggerChime: VoiceWakeChime - let sendChime: VoiceWakeChime - } - - func begin() async { - guard voiceWakeSupported else { return } - guard !self.isCapturing else { return } - - // Start a fresh session and invalidate any in-flight callbacks tied to an older one. - let sessionID = UUID() - self.sessionID = sessionID - - // Ensure permissions up front. - let granted = await PermissionManager.ensureVoiceWakePermissions(interactive: true) - guard granted else { return } - - let config = await MainActor.run { self.makeConfig() } - self.activeConfig = config - self.isCapturing = true - self.triggerChimePlayed = false - self.finalized = false - self.timeoutTask?.cancel(); self.timeoutTask = nil - let snapshot = await MainActor.run { VoiceSessionCoordinator.shared.snapshot() } - self.adoptedPrefix = snapshot.visible ? snapshot.text.trimmingCharacters(in: .whitespacesAndNewlines) : "" - self.logger.info("ptt begin adopted_prefix_len=\(self.adoptedPrefix.count, privacy: .public)") - if config.triggerChime != .none { - self.triggerChimePlayed = true - await MainActor.run { VoiceWakeChimePlayer.play(config.triggerChime, reason: "ptt.trigger") } - } - // Pause the always-on wake word recognizer so both pipelines don't fight over the mic tap. - await VoiceWakeRuntime.shared.pauseForPushToTalk() - let adoptedPrefix = self.adoptedPrefix - let adoptedAttributed: NSAttributedString? = adoptedPrefix.isEmpty ? nil : Self.makeAttributed( - committed: adoptedPrefix, - volatile: "", - isFinal: false) - self.overlayToken = await MainActor.run { - VoiceSessionCoordinator.shared.startSession( - source: .pushToTalk, - text: adoptedPrefix, - attributed: adoptedAttributed, - forwardEnabled: true) - } - - do { - try await self.startRecognition(localeID: config.localeID, sessionID: sessionID) - } catch { - await MainActor.run { - VoiceWakeOverlayController.shared.dismiss() - } - self.isCapturing = false - // If push-to-talk fails to start after pausing wake-word, ensure we resume listening. - await VoiceWakeRuntime.shared.applyPushToTalkCooldown() - await VoiceWakeRuntime.shared.refresh(state: AppStateStore.shared) - } - } - - func end() async { - guard self.isCapturing else { return } - self.isCapturing = false - let sessionID = self.sessionID - - // Stop feeding Speech buffers first, then end the request. Stopping the engine here can race with - // Speech draining its converter chain (and we already stop/cancel in finalize). - if self.tapInstalled { - self.audioEngine?.inputNode.removeTap(onBus: 0) - self.tapInstalled = false - } - self.recognitionRequest?.endAudio() - - // If we captured nothing, dismiss immediately when the user lets go. - if self.committed.isEmpty, self.volatile.isEmpty, self.adoptedPrefix.isEmpty { - await self.finalize(transcriptOverride: "", reason: "emptyOnRelease", sessionID: sessionID) - return - } - - // Otherwise, give Speech a brief window to deliver the final result; then fall back. - self.timeoutTask?.cancel() - self.timeoutTask = Task { [weak self] in - try? await Task.sleep(nanoseconds: 1_500_000_000) // 1.5s grace period to await final result - await self?.finalize(transcriptOverride: nil, reason: "timeout", sessionID: sessionID) - } - } - - // MARK: - Private - - private func startRecognition(localeID: String?, sessionID: UUID) async throws { - let locale = localeID.flatMap { Locale(identifier: $0) } ?? Locale(identifier: Locale.current.identifier) - self.recognizer = SFSpeechRecognizer(locale: locale) - guard let recognizer, recognizer.isAvailable else { - throw NSError( - domain: "VoicePushToTalk", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Recognizer unavailable"]) - } - - self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest() - self.recognitionRequest?.shouldReportPartialResults = true - guard let request = self.recognitionRequest else { return } - - // Lazily create the engine here so app launch doesn't grab audio resources / trigger Bluetooth HFP. - if self.audioEngine == nil { - self.audioEngine = AVAudioEngine() - } - guard let audioEngine = self.audioEngine else { return } - - let input = audioEngine.inputNode - let format = input.outputFormat(forBus: 0) - if self.tapInstalled { - input.removeTap(onBus: 0) - self.tapInstalled = false - } - // Pipe raw mic buffers into the Speech request while the chord is held. - input.installTap(onBus: 0, bufferSize: 2048, format: format) { [weak request] buffer, _ in - request?.append(buffer) - } - self.tapInstalled = true - - audioEngine.prepare() - try audioEngine.start() - - self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in - guard let self else { return } - if let error { - self.logger.debug("push-to-talk error: \(error.localizedDescription, privacy: .public)") - } - let transcript = result?.bestTranscription.formattedString - let isFinal = result?.isFinal ?? false - // Hop to a Task so UI updates stay off the Speech callback thread. - Task.detached { [weak self, transcript, isFinal, sessionID] in - guard let self else { return } - await self.handle(transcript: transcript, isFinal: isFinal, sessionID: sessionID) - } - } - } - - private func handle(transcript: String?, isFinal: Bool, sessionID: UUID) async { - guard sessionID == self.sessionID else { - self.logger.debug("push-to-talk drop transcript for stale session") - return - } - guard let transcript else { return } - if isFinal { - self.committed = transcript - self.volatile = "" - } else { - self.volatile = Self.delta(after: self.committed, current: transcript) - } - - let committedWithPrefix = Self.join(self.adoptedPrefix, self.committed) - let snapshot = Self.join(committedWithPrefix, self.volatile) - let attributed = Self.makeAttributed(committed: committedWithPrefix, volatile: self.volatile, isFinal: isFinal) - if let token = self.overlayToken { - await MainActor.run { - VoiceSessionCoordinator.shared.updatePartial( - token: token, - text: snapshot, - attributed: attributed) - } - } - } - - private func finalize(transcriptOverride: String?, reason: String, sessionID: UUID?) async { - if self.finalized { return } - if let sessionID, sessionID != self.sessionID { - self.logger.debug("push-to-talk drop finalize for stale session") - return - } - self.finalized = true - self.isCapturing = false - self.timeoutTask?.cancel(); self.timeoutTask = nil - - let finalRecognized: String = { - if let override = transcriptOverride?.trimmingCharacters(in: .whitespacesAndNewlines) { - return override - } - return (self.committed + self.volatile).trimmingCharacters(in: .whitespacesAndNewlines) - }() - let finalText = Self.join(self.adoptedPrefix, finalRecognized) - let chime = finalText.isEmpty ? .none : (self.activeConfig?.sendChime ?? .none) - - let token = self.overlayToken - let logger = self.logger - await MainActor.run { - logger.info("ptt finalize reason=\(reason, privacy: .public) len=\(finalText.count, privacy: .public)") - if let token { - VoiceSessionCoordinator.shared.finalize( - token: token, - text: finalText, - sendChime: chime, - autoSendAfter: nil) - VoiceSessionCoordinator.shared.sendNow(token: token, reason: reason) - } else if !finalText.isEmpty { - if chime != .none { - VoiceWakeChimePlayer.play(chime, reason: "ptt.fallback_send") - } - Task.detached { - await VoiceWakeForwarder.forward(transcript: finalText) - } - } - } - - self.recognitionTask?.cancel() - self.recognitionRequest = nil - self.recognitionTask = nil - if self.tapInstalled { - self.audioEngine?.inputNode.removeTap(onBus: 0) - self.tapInstalled = false - } - if self.audioEngine?.isRunning == true { - self.audioEngine?.stop() - self.audioEngine?.reset() - } - // Release the engine so we also release any audio session/resources when push-to-talk ends. - self.audioEngine = nil - - self.committed = "" - self.volatile = "" - self.activeConfig = nil - self.triggerChimePlayed = false - self.overlayToken = nil - self.adoptedPrefix = "" - - // Resume the wake-word runtime after push-to-talk finishes. - await VoiceWakeRuntime.shared.applyPushToTalkCooldown() - _ = await MainActor.run { Task { await VoiceWakeRuntime.shared.refresh(state: AppStateStore.shared) } } - } - - @MainActor - private func makeConfig() -> Config { - let state = AppStateStore.shared - return Config( - micID: state.voiceWakeMicID.isEmpty ? nil : state.voiceWakeMicID, - localeID: state.voiceWakeLocaleID, - triggerChime: state.voiceWakeTriggerChime, - sendChime: state.voiceWakeSendChime) - } - - // MARK: - Test helpers - - static func _testDelta(committed: String, current: String) -> String { - self.delta(after: committed, current: current) - } - - static func _testAttributedColors(isFinal: Bool) -> (NSColor, NSColor) { - let sample = self.makeAttributed(committed: "a", volatile: "b", isFinal: isFinal) - let committedColor = sample.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor ?? .clear - let volatileColor = sample.attribute(.foregroundColor, at: 1, effectiveRange: nil) as? NSColor ?? .clear - return (committedColor, volatileColor) - } - - private static func join(_ prefix: String, _ suffix: String) -> String { - if prefix.isEmpty { return suffix } - if suffix.isEmpty { return prefix } - return "\(prefix) \(suffix)" - } - - private static func delta(after committed: String, current: String) -> String { - if current.hasPrefix(committed) { - let start = current.index(current.startIndex, offsetBy: committed.count) - return String(current[start...]) - } - return current - } - - private static func makeAttributed(committed: String, volatile: String, isFinal: Bool) -> NSAttributedString { - let full = NSMutableAttributedString() - let committedAttr: [NSAttributedString.Key: Any] = [ - .foregroundColor: NSColor.labelColor, - .font: NSFont.systemFont(ofSize: 13, weight: .regular), - ] - full.append(NSAttributedString(string: committed, attributes: committedAttr)) - let volatileColor: NSColor = isFinal ? .labelColor : NSColor.tertiaryLabelColor - let volatileAttr: [NSAttributedString.Key: Any] = [ - .foregroundColor: volatileColor, - .font: NSFont.systemFont(ofSize: 13, weight: .regular), - ] - full.append(NSAttributedString(string: volatile, attributes: volatileAttr)) - return full - } -} diff --git a/apps/macos/Sources/OpenClaw/VoiceSessionCoordinator.swift b/apps/macos/Sources/OpenClaw/VoiceSessionCoordinator.swift deleted file mode 100644 index 87c32d26670..00000000000 --- a/apps/macos/Sources/OpenClaw/VoiceSessionCoordinator.swift +++ /dev/null @@ -1,134 +0,0 @@ -import AppKit -import Foundation -import Observation - -@MainActor -@Observable -final class VoiceSessionCoordinator { - static let shared = VoiceSessionCoordinator() - - enum Source: String { case wakeWord, pushToTalk } - - struct Session { - let token: UUID - let source: Source - var text: String - var attributed: NSAttributedString? - var isFinal: Bool - var sendChime: VoiceWakeChime - var autoSendDelay: TimeInterval? - } - - private let logger = Logger(subsystem: "ai.openclaw", category: "voicewake.coordinator") - private var session: Session? - - // MARK: - API - - func startSession( - source: Source, - text: String, - attributed: NSAttributedString? = nil, - forwardEnabled: Bool = false) -> UUID - { - let token = UUID() - self.logger.info("coordinator start token=\(token.uuidString) source=\(source.rawValue) len=\(text.count)") - let attributedText = attributed ?? VoiceWakeOverlayController.shared.makeAttributed(from: text) - let session = Session( - token: token, - source: source, - text: text, - attributed: attributedText, - isFinal: false, - sendChime: .none, - autoSendDelay: nil) - self.session = session - VoiceWakeOverlayController.shared.startSession( - token: token, - source: VoiceWakeOverlayController.Source(rawValue: source.rawValue) ?? .wakeWord, - transcript: text, - attributed: attributedText, - forwardEnabled: forwardEnabled, - isFinal: false) - return token - } - - func updatePartial(token: UUID, text: String, attributed: NSAttributedString? = nil) { - guard let session, session.token == token else { return } - self.session?.text = text - self.session?.attributed = attributed - VoiceWakeOverlayController.shared.updatePartial(token: token, transcript: text, attributed: attributed) - } - - func finalize( - token: UUID, - text: String, - sendChime: VoiceWakeChime, - autoSendAfter: TimeInterval?) - { - guard let session, session.token == token else { return } - self.logger - .info( - "coordinator finalize token=\(token.uuidString) len=\(text.count) autoSendAfter=\(autoSendAfter ?? -1)") - self.session?.text = text - self.session?.isFinal = true - self.session?.sendChime = sendChime - self.session?.autoSendDelay = autoSendAfter - - let attributed = VoiceWakeOverlayController.shared.makeAttributed(from: text) - VoiceWakeOverlayController.shared.presentFinal( - token: token, - transcript: text, - autoSendAfter: autoSendAfter, - sendChime: sendChime, - attributed: attributed) - } - - func sendNow(token: UUID, reason: String = "explicit") { - guard let session, session.token == token else { return } - let text = session.text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty else { - self.logger.info("coordinator sendNow \(reason) empty -> dismiss") - VoiceWakeOverlayController.shared.dismiss(token: token, reason: .empty, outcome: .empty) - self.clearSession() - return - } - VoiceWakeOverlayController.shared.beginSendUI(token: token, sendChime: session.sendChime) - Task.detached { - _ = await VoiceWakeForwarder.forward(transcript: text) - } - } - - func dismiss( - token: UUID, - reason: VoiceWakeOverlayController.DismissReason, - outcome: VoiceWakeOverlayController.SendOutcome) - { - guard let session, session.token == token else { return } - VoiceWakeOverlayController.shared.dismiss(token: token, reason: reason, outcome: outcome) - self.clearSession() - } - - func updateLevel(token: UUID, _ level: Double) { - guard let session, session.token == token else { return } - VoiceWakeOverlayController.shared.updateLevel(token: token, level) - } - - func snapshot() -> (token: UUID?, text: String, visible: Bool) { - (self.session?.token, self.session?.text ?? "", VoiceWakeOverlayController.shared.isVisible) - } - - // MARK: - Private - - private func clearSession() { - self.session = nil - } - - /// Overlay dismiss completion callback (manual X, empty, auto-dismiss after send). - /// Ensures the wake-word recognizer is resumed if Voice Wake is enabled. - func overlayDidDismiss(token: UUID?) { - if let token, self.session?.token == token { - self.clearSession() - } - Task { await VoiceWakeRuntime.shared.refresh(state: AppStateStore.shared) } - } -} diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeChime.swift b/apps/macos/Sources/OpenClaw/VoiceWakeChime.swift deleted file mode 100644 index 8a258389976..00000000000 --- a/apps/macos/Sources/OpenClaw/VoiceWakeChime.swift +++ /dev/null @@ -1,76 +0,0 @@ -import AppKit -import Foundation -import OSLog - -enum VoiceWakeChime: Codable, Equatable, Sendable { - case none - case system(name: String) - case custom(displayName: String, bookmark: Data) - - var systemName: String? { - if case let .system(name) = self { - return name - } - return nil - } - - var displayLabel: String { - switch self { - case .none: - "No Sound" - case let .system(name): - VoiceWakeChimeCatalog.displayName(for: name) - case let .custom(displayName, _): - displayName - } - } -} - -enum VoiceWakeChimeCatalog { - /// Options shown in the picker. - static var systemOptions: [String] { - SoundEffectCatalog.systemOptions - } - - static func displayName(for raw: String) -> String { - SoundEffectCatalog.displayName(for: raw) - } - - static func url(for name: String) -> URL? { - SoundEffectCatalog.url(for: name) - } -} - -@MainActor -enum VoiceWakeChimePlayer { - private static let logger = Logger(subsystem: "ai.openclaw", category: "voicewake.chime") - private static var lastSound: NSSound? - - static func play(_ chime: VoiceWakeChime, reason: String? = nil) { - guard let sound = self.sound(for: chime) else { return } - if let reason { - self.logger.log(level: .info, "chime play reason=\(reason, privacy: .public)") - } else { - self.logger.log(level: .info, "chime play") - } - DiagnosticsFileLog.shared.log(category: "voicewake.chime", event: "play", fields: [ - "reason": reason ?? "", - "chime": chime.displayLabel, - "systemName": chime.systemName ?? "", - ]) - SoundEffectPlayer.play(sound) - } - - private static func sound(for chime: VoiceWakeChime) -> NSSound? { - switch chime { - case .none: - nil - - case let .system(name): - SoundEffectPlayer.sound(named: name) - - case let .custom(_, bookmark): - SoundEffectPlayer.sound(from: bookmark) - } - } -} diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeForwarder.swift b/apps/macos/Sources/OpenClaw/VoiceWakeForwarder.swift deleted file mode 100644 index ee634a628ed..00000000000 --- a/apps/macos/Sources/OpenClaw/VoiceWakeForwarder.swift +++ /dev/null @@ -1,73 +0,0 @@ -import Foundation -import OSLog - -enum VoiceWakeForwarder { - private static let logger = Logger(subsystem: "ai.openclaw", category: "voicewake.forward") - - static func prefixedTranscript(_ transcript: String, machineName: String? = nil) -> String { - let resolvedMachine = machineName - .flatMap { name -> String? in - let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } - ?? Host.current().localizedName - ?? ProcessInfo.processInfo.hostName - - let safeMachine = resolvedMachine.isEmpty ? "this Mac" : resolvedMachine - return """ - User talked via voice recognition on \(safeMachine) - repeat prompt first \ - + remember some words might be incorrectly transcribed. - - \(transcript) - """ - } - - enum VoiceWakeForwardError: LocalizedError, Equatable { - case rpcFailed(String) - - var errorDescription: String? { - switch self { - case let .rpcFailed(message): message - } - } - } - - struct ForwardOptions: Sendable { - var sessionKey: String = "main" - var thinking: String = "low" - var deliver: Bool = true - var to: String? - var channel: GatewayAgentChannel = .last - } - - @discardableResult - static func forward( - transcript: String, - options: ForwardOptions = ForwardOptions()) async -> Result - { - let payload = Self.prefixedTranscript(transcript) - let deliver = options.channel.shouldDeliver(options.deliver) - let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation( - message: payload, - sessionKey: options.sessionKey, - thinking: options.thinking, - deliver: deliver, - to: options.to, - channel: options.channel)) - - if result.ok { - self.logger.info("voice wake forward ok") - return .success(()) - } - - let message = result.error ?? "agent rpc unavailable" - self.logger.error("voice wake forward failed: \(message, privacy: .public)") - return .failure(.rpcFailed(message)) - } - - static func checkConnection() async -> Result { - let status = await GatewayConnection.shared.status() - if status.ok { return .success(()) } - return .failure(.rpcFailed(status.error ?? "agent rpc unreachable")) - } -} diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeGlobalSettingsSync.swift b/apps/macos/Sources/OpenClaw/VoiceWakeGlobalSettingsSync.swift deleted file mode 100644 index af4fae356ee..00000000000 --- a/apps/macos/Sources/OpenClaw/VoiceWakeGlobalSettingsSync.swift +++ /dev/null @@ -1,66 +0,0 @@ -import Foundation -import OpenClawKit -import OSLog - -@MainActor -final class VoiceWakeGlobalSettingsSync { - static let shared = VoiceWakeGlobalSettingsSync() - - private let logger = Logger(subsystem: "ai.openclaw", category: "voicewake.sync") - private var task: Task? - - private struct VoiceWakePayload: Codable, Equatable { - let triggers: [String] - } - - func start() { - guard self.task == nil else { return } - self.task = Task { [weak self] in - guard let self else { return } - while !Task.isCancelled { - do { - try await GatewayConnection.shared.refresh() - } catch { - // Not configured / not reachable yet. - } - - await self.refreshFromGateway() - - let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200) - for await push in stream { - if Task.isCancelled { return } - await self.handle(push: push) - } - - // If the stream finishes (gateway shutdown / reconnect), loop and resubscribe. - try? await Task.sleep(nanoseconds: 600_000_000) - } - } - } - - func stop() { - self.task?.cancel() - self.task = nil - } - - private func refreshFromGateway() async { - do { - let triggers = try await GatewayConnection.shared.voiceWakeGetTriggers() - AppStateStore.shared.applyGlobalVoiceWakeTriggers(triggers) - } catch { - // Best-effort only. - } - } - - func handle(push: GatewayPush) async { - guard case let .event(evt) = push else { return } - guard evt.event == "voicewake.changed" else { return } - guard let payload = evt.payload else { return } - do { - let decoded = try GatewayPayloadDecoding.decode(payload, as: VoiceWakePayload.self) - AppStateStore.shared.applyGlobalVoiceWakeTriggers(decoded.triggers) - } catch { - self.logger.error("failed to decode voicewake.changed: \(error.localizedDescription, privacy: .public)") - } - } -} diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeHelpers.swift b/apps/macos/Sources/OpenClaw/VoiceWakeHelpers.swift deleted file mode 100644 index 98cdc0cb58a..00000000000 --- a/apps/macos/Sources/OpenClaw/VoiceWakeHelpers.swift +++ /dev/null @@ -1,24 +0,0 @@ -import Foundation - -func sanitizeVoiceWakeTriggers(_ words: [String]) -> [String] { - let cleaned = words - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - .prefix(voiceWakeMaxWords) - .map { String($0.prefix(voiceWakeMaxWordLength)) } - return cleaned.isEmpty ? defaultVoiceWakeTriggers : cleaned -} - -func normalizeLocaleIdentifier(_ raw: String) -> String { - var trimmed = raw - if let at = trimmed.firstIndex(of: "@") { - trimmed = String(trimmed[..? - var autoSendTask: Task? - var autoSendToken: UUID? - var activeToken: UUID? - var activeSource: Source? - var lastLevelUpdate: TimeInterval = 0 - - let width: CGFloat = 360 - let padding: CGFloat = 10 - let buttonWidth: CGFloat = 36 - let spacing: CGFloat = 8 - let verticalPadding: CGFloat = 8 - let maxHeight: CGFloat = 400 - let minHeight: CGFloat = 48 - let closeOverflow: CGFloat = 10 - let levelUpdateInterval: TimeInterval = 1.0 / 12.0 - - enum DismissReason { case explicit, empty } - enum SendOutcome { case sent, empty } - enum GuardOutcome { case accept, dropMismatch, dropNoActive } - - init(enableUI: Bool = true) { - self.enableUI = enableUI - } -} diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeOverlayController+Session.swift b/apps/macos/Sources/OpenClaw/VoiceWakeOverlayController+Session.swift deleted file mode 100644 index f021eac9859..00000000000 --- a/apps/macos/Sources/OpenClaw/VoiceWakeOverlayController+Session.swift +++ /dev/null @@ -1,281 +0,0 @@ -import AppKit -import QuartzCore - -extension VoiceWakeOverlayController { - @discardableResult - func startSession( - token: UUID = UUID(), - source: Source, - transcript: String, - attributed: NSAttributedString? = nil, - forwardEnabled: Bool = false, - isFinal: Bool = false) -> UUID - { - let message = """ - overlay session_start source=\(source.rawValue) \ - len=\(transcript.count) - """ - self.logger.log(level: .info, "\(message)") - self.activeToken = token - self.activeSource = source - self.autoSendTask?.cancel(); self.autoSendTask = nil; self.autoSendToken = nil - self.model.text = transcript - self.model.isFinal = isFinal - self.model.forwardEnabled = forwardEnabled - self.model.isSending = false - self.model.isEditing = false - self.model.attributed = attributed ?? self.makeAttributed(from: transcript) - self.model.level = 0 - self.lastLevelUpdate = 0 - self.present() - self.updateWindowFrame(animate: true) - return token - } - - func snapshot() -> (token: UUID?, source: Source?, text: String, isVisible: Bool) { - (self.activeToken, self.activeSource, self.model.text, self.model.isVisible) - } - - func updatePartial(token: UUID, transcript: String, attributed: NSAttributedString? = nil) { - guard self.guardToken(token, context: "partial") else { return } - guard !self.model.isFinal else { return } - let message = """ - overlay partial token=\(token.uuidString) \ - len=\(transcript.count) - """ - self.logger.log(level: .info, "\(message)") - self.autoSendTask?.cancel(); self.autoSendTask = nil; self.autoSendToken = nil - self.model.text = transcript - self.model.isFinal = false - self.model.forwardEnabled = false - self.model.isSending = false - self.model.isEditing = false - self.model.attributed = attributed ?? self.makeAttributed(from: transcript) - self.model.level = 0 - self.present() - self.updateWindowFrame(animate: true) - } - - func presentFinal( - token: UUID, - transcript: String, - autoSendAfter delay: TimeInterval?, - sendChime: VoiceWakeChime = .none, - attributed: NSAttributedString? = nil) - { - guard self.guardToken(token, context: "final") else { return } - let message = """ - overlay presentFinal token=\(token.uuidString) \ - len=\(transcript.count) \ - autoSendAfter=\(delay ?? -1) \ - forwardEnabled=\(!transcript.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - """ - self.logger.log(level: .info, "\(message)") - self.autoSendTask?.cancel() - self.autoSendToken = token - self.model.text = transcript - self.model.isFinal = true - self.model.forwardEnabled = !transcript.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - self.model.isSending = false - self.model.isEditing = false - self.model.attributed = attributed ?? self.makeAttributed(from: transcript) - self.model.level = 0 - self.present() - if let delay { - if delay <= 0 { - self.logger.log(level: .info, "overlay autoSend immediate token=\(token.uuidString)") - VoiceSessionCoordinator.shared.sendNow(token: token, reason: "autoSendImmediate") - } else { - self.scheduleAutoSend(token: token, after: delay) - } - } - } - - func userBeganEditing() { - self.autoSendTask?.cancel() - self.model.isSending = false - self.model.isEditing = true - } - - func cancelEditingAndDismiss() { - self.autoSendTask?.cancel() - self.model.isSending = false - self.model.isEditing = false - self.dismiss(reason: .explicit) - } - - func endEditing() { - self.model.isEditing = false - } - - func updateText(_ text: String) { - self.model.text = text - self.model.isSending = false - self.model.attributed = self.makeAttributed(from: text) - self.updateWindowFrame(animate: true) - } - - /// UI-only path: show sending state and dismiss; actual forwarding is handled by the coordinator. - func beginSendUI(token: UUID, sendChime: VoiceWakeChime = .none) { - guard self.guardToken(token, context: "beginSendUI") else { return } - self.autoSendTask?.cancel(); self.autoSendToken = nil - let message = """ - overlay beginSendUI token=\(token.uuidString) \ - isSending=\(self.model.isSending) \ - forwardEnabled=\(self.model.forwardEnabled) \ - textLen=\(self.model.text.count) - """ - self.logger.log(level: .info, "\(message)") - if self.model.isSending { return } - self.model.isEditing = false - - if sendChime != .none { - let message = "overlay beginSendUI playing sendChime=\(String(describing: sendChime))" - self.logger.log(level: .info, "\(message)") - VoiceWakeChimePlayer.play(sendChime, reason: "overlay.send") - } - - self.model.isSending = true - DispatchQueue.main.asyncAfter(deadline: .now() + 0.28) { - self.logger.log( - level: .info, - "overlay beginSendUI dismiss ticking token=\(self.activeToken?.uuidString ?? "nil")") - self.dismiss(token: token, reason: .explicit, outcome: .sent) - } - } - - func requestSend(token: UUID? = nil, reason: String = "overlay_request") { - guard self.guardToken(token, context: "requestSend") else { return } - guard let active = token ?? self.activeToken else { return } - VoiceSessionCoordinator.shared.sendNow(token: active, reason: reason) - } - - func dismiss(token: UUID? = nil, reason: DismissReason = .explicit, outcome: SendOutcome = .empty) { - guard self.guardToken(token, context: "dismiss") else { return } - let message = """ - overlay dismiss token=\(self.activeToken?.uuidString ?? "nil") \ - reason=\(String(describing: reason)) \ - outcome=\(String(describing: outcome)) \ - visible=\(self.model.isVisible) \ - sending=\(self.model.isSending) - """ - self.logger.log(level: .info, "\(message)") - self.autoSendTask?.cancel(); self.autoSendToken = nil - self.model.isSending = false - self.model.isEditing = false - - if !self.enableUI { - self.model.isVisible = false - self.model.level = 0 - self.lastLevelUpdate = 0 - self.activeToken = nil - self.activeSource = nil - return - } - guard let window else { - if ProcessInfo.processInfo.isRunningTests { - self.model.isVisible = false - self.model.level = 0 - self.activeToken = nil - self.activeSource = nil - } - return - } - let target = self.dismissTargetFrame(for: window.frame, reason: reason, outcome: outcome) - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.18 - context.timingFunction = CAMediaTimingFunction(name: .easeOut) - if let target { - window.animator().setFrame(target, display: true) - } - window.animator().alphaValue = 0 - } completionHandler: { - Task { @MainActor in - let dismissedToken = self.activeToken - window.orderOut(nil) - self.model.isVisible = false - self.model.level = 0 - self.lastLevelUpdate = 0 - self.activeToken = nil - self.activeSource = nil - if outcome == .empty { - AppStateStore.shared.blinkOnce() - } else if outcome == .sent { - AppStateStore.shared.celebrateSend() - } - AppStateStore.shared.stopVoiceEars() - VoiceSessionCoordinator.shared.overlayDidDismiss(token: dismissedToken) - } - } - } - - func updateLevel(token: UUID, _ level: Double) { - guard self.guardToken(token, context: "level") else { return } - guard self.model.isVisible else { return } - let now = ProcessInfo.processInfo.systemUptime - if level != 0, now - self.lastLevelUpdate < self.levelUpdateInterval { - return - } - self.lastLevelUpdate = now - self.model.level = max(0, min(1, level)) - } - - private func guardToken(_ token: UUID?, context: String) -> Bool { - switch Self.evaluateToken(active: self.activeToken, incoming: token) { - case .accept: - return true - case .dropMismatch: - self.logger.log( - level: .info, - """ - overlay drop \(context, privacy: .public) token_mismatch \ - active=\(self.activeToken?.uuidString ?? "nil", privacy: .public) \ - got=\(token?.uuidString ?? "nil", privacy: .public) - """) - return false - case .dropNoActive: - self.logger.log(level: .info, "overlay drop \(context, privacy: .public) no_active") - return false - } - } - - nonisolated static func evaluateToken(active: UUID?, incoming: UUID?) -> GuardOutcome { - guard let active else { return .dropNoActive } - if let incoming, incoming != active { return .dropMismatch } - return .accept - } - - func scheduleAutoSend(token: UUID, after delay: TimeInterval) { - self.logger.log( - level: .info, - """ - overlay scheduleAutoSend token=\(token.uuidString) \ - after=\(delay) - """) - self.autoSendTask?.cancel() - self.autoSendToken = token - self.autoSendTask = Task { [weak self, token] in - let nanos = UInt64(max(0, delay) * 1_000_000_000) - try? await Task.sleep(nanoseconds: nanos) - guard !Task.isCancelled else { return } - await MainActor.run { - guard let self else { return } - guard self.guardToken(token, context: "autoSend") else { return } - self.logger.log( - level: .info, - "overlay autoSend firing token=\(token.uuidString, privacy: .public)") - VoiceSessionCoordinator.shared.sendNow(token: token, reason: "autoSendDelay") - self.autoSendTask = nil - } - } - } - - func makeAttributed(from text: String) -> NSAttributedString { - NSAttributedString( - string: text, - attributes: [ - .foregroundColor: NSColor.labelColor, - .font: NSFont.systemFont(ofSize: 13, weight: .regular), - ]) - } -} diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeOverlayController+Testing.swift b/apps/macos/Sources/OpenClaw/VoiceWakeOverlayController+Testing.swift deleted file mode 100644 index af1111df909..00000000000 --- a/apps/macos/Sources/OpenClaw/VoiceWakeOverlayController+Testing.swift +++ /dev/null @@ -1,49 +0,0 @@ -import AppKit - -#if DEBUG -@MainActor -extension VoiceWakeOverlayController { - static func exerciseForTesting() async { - let controller = VoiceWakeOverlayController(enableUI: false) - let token = controller.startSession( - source: .wakeWord, - transcript: "Hello", - attributed: nil, - forwardEnabled: true, - isFinal: false) - - controller.updatePartial(token: token, transcript: "Hello world") - controller.presentFinal(token: token, transcript: "Final", autoSendAfter: nil) - controller.userBeganEditing() - controller.endEditing() - controller.updateText("Edited text") - - _ = controller.makeAttributed(from: "Attributed") - _ = controller.targetFrame() - _ = controller.measuredHeight() - _ = controller.dismissTargetFrame( - for: NSRect(x: 0, y: 0, width: 120, height: 60), - reason: .empty, - outcome: .empty) - _ = controller.dismissTargetFrame( - for: NSRect(x: 0, y: 0, width: 120, height: 60), - reason: .explicit, - outcome: .sent) - _ = controller.dismissTargetFrame( - for: NSRect(x: 0, y: 0, width: 120, height: 60), - reason: .explicit, - outcome: .empty) - - controller.beginSendUI(token: token, sendChime: .none) - try? await Task.sleep(nanoseconds: 350_000_000) - - controller.scheduleAutoSend(token: token, after: 10) - controller.autoSendTask?.cancel() - controller.autoSendTask = nil - controller.autoSendToken = nil - - controller.dismiss(token: token, reason: .explicit, outcome: .sent) - controller.bringToFrontIfVisible() - } -} -#endif diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeOverlayController+Window.swift b/apps/macos/Sources/OpenClaw/VoiceWakeOverlayController+Window.swift deleted file mode 100644 index fb5526a8d45..00000000000 --- a/apps/macos/Sources/OpenClaw/VoiceWakeOverlayController+Window.swift +++ /dev/null @@ -1,141 +0,0 @@ -import AppKit -import QuartzCore -import SwiftUI - -extension VoiceWakeOverlayController { - func present() { - if !self.enableUI || ProcessInfo.processInfo.isRunningTests { - if !self.model.isVisible { - self.model.isVisible = true - } - return - } - self.ensureWindow() - self.hostingView?.rootView = VoiceWakeOverlayView(controller: self) - let target = self.targetFrame() - - guard let window else { return } - if !self.model.isVisible { - self.model.isVisible = true - self.logger.log( - level: .info, - "overlay present windowShown textLen=\(self.model.text.count, privacy: .public)") - // Keep the status item in “listening” mode until we explicitly dismiss the overlay. - AppStateStore.shared.triggerVoiceEars(ttl: nil) - let start = target.offsetBy(dx: 0, dy: -6) - window.setFrame(start, display: true) - window.alphaValue = 0 - window.orderFrontRegardless() - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.18 - context.timingFunction = CAMediaTimingFunction(name: .easeOut) - window.animator().setFrame(target, display: true) - window.animator().alphaValue = 1 - } - } else { - self.updateWindowFrame(animate: true) - window.orderFrontRegardless() - } - } - - private func ensureWindow() { - if self.window != nil { return } - let borderPad = self.closeOverflow - let panel = NSPanel( - contentRect: NSRect(x: 0, y: 0, width: self.width + borderPad * 2, height: 60 + borderPad * 2), - styleMask: [.nonactivatingPanel, .borderless], - backing: .buffered, - defer: false) - panel.isOpaque = false - panel.backgroundColor = .clear - panel.hasShadow = false - panel.level = Self.preferredWindowLevel - panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient] - panel.hidesOnDeactivate = false - panel.isMovable = false - panel.isFloatingPanel = true - panel.becomesKeyOnlyIfNeeded = true - panel.titleVisibility = .hidden - panel.titlebarAppearsTransparent = true - - let host = NSHostingView(rootView: VoiceWakeOverlayView(controller: self)) - host.translatesAutoresizingMaskIntoConstraints = false - panel.contentView = host - self.hostingView = host - self.window = panel - } - - /// Reassert window ordering when other panels are shown. - func bringToFrontIfVisible() { - guard self.model.isVisible, let window = self.window else { return } - window.level = Self.preferredWindowLevel - window.orderFrontRegardless() - } - - func targetFrame() -> NSRect { - guard let screen = NSScreen.main else { return .zero } - let height = self.measuredHeight() - let size = NSSize(width: self.width + self.closeOverflow * 2, height: height + self.closeOverflow * 2) - let visible = screen.visibleFrame - let origin = CGPoint( - x: visible.maxX - size.width, - y: visible.maxY - size.height) - return NSRect(origin: origin, size: size) - } - - func updateWindowFrame(animate: Bool = false) { - guard let window else { return } - let frame = self.targetFrame() - if animate { - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.12 - context.timingFunction = CAMediaTimingFunction(name: .easeOut) - window.animator().setFrame(frame, display: true) - } - } else { - window.setFrame(frame, display: true) - } - } - - func measuredHeight() -> CGFloat { - let attributed = self.model.attributed.length > 0 ? self.model.attributed : self - .makeAttributed(from: self.model.text) - let maxWidth = self.width - (self.padding * 2) - self.spacing - self.buttonWidth - - let textInset = NSSize(width: 2, height: 6) - let lineFragmentPadding: CGFloat = 0 - let containerWidth = max(1, maxWidth - (textInset.width * 2) - (lineFragmentPadding * 2)) - - let storage = NSTextStorage(attributedString: attributed) - let container = NSTextContainer(containerSize: CGSize(width: containerWidth, height: .greatestFiniteMagnitude)) - container.lineFragmentPadding = lineFragmentPadding - container.lineBreakMode = .byWordWrapping - - let layout = NSLayoutManager() - layout.addTextContainer(container) - storage.addLayoutManager(layout) - - _ = layout.glyphRange(for: container) - let used = layout.usedRect(for: container) - - let contentHeight = ceil(used.height + (textInset.height * 2)) - let total = contentHeight + self.verticalPadding * 2 - self.model.isOverflowing = total > self.maxHeight - return max(self.minHeight, min(total, self.maxHeight)) - } - - func dismissTargetFrame(for frame: NSRect, reason: DismissReason, outcome: SendOutcome) -> NSRect? { - switch (reason, outcome) { - case (.empty, _): - let scale: CGFloat = 0.95 - let newSize = NSSize(width: frame.size.width * scale, height: frame.size.height * scale) - let dx = (frame.size.width - newSize.width) / 2 - let dy = (frame.size.height - newSize.height) / 2 - return NSRect(x: frame.origin.x + dx, y: frame.origin.y + dy, width: newSize.width, height: newSize.height) - case (.explicit, .sent): - return frame.offsetBy(dx: 8, dy: 6) - default: - return frame - } - } -} diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeOverlayTextViews.swift b/apps/macos/Sources/OpenClaw/VoiceWakeOverlayTextViews.swift deleted file mode 100644 index 8e88c86d45d..00000000000 --- a/apps/macos/Sources/OpenClaw/VoiceWakeOverlayTextViews.swift +++ /dev/null @@ -1,202 +0,0 @@ -import AppKit -import SwiftUI - -struct TranscriptTextView: NSViewRepresentable { - @Binding var text: String - var attributed: NSAttributedString - var isFinal: Bool - var isOverflowing: Bool - var onBeginEditing: () -> Void - var onEscape: () -> Void - var onEndEditing: () -> Void - var onSend: () -> Void - - func makeCoordinator() -> Coordinator { - Coordinator(self) - } - - func makeNSView(context: Context) -> NSScrollView { - let textView = TranscriptNSTextView() - textView.delegate = context.coordinator - textView.drawsBackground = false - textView.isRichText = true - textView.isAutomaticQuoteSubstitutionEnabled = false - textView.isAutomaticTextReplacementEnabled = false - textView.font = .systemFont(ofSize: 13, weight: .regular) - textView.textContainer?.lineBreakMode = .byWordWrapping - textView.textContainer?.lineFragmentPadding = 0 - textView.textContainerInset = NSSize(width: 2, height: 6) - - textView.minSize = .zero - textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) - textView.isHorizontallyResizable = false - textView.isVerticallyResizable = true - textView.autoresizingMask = [.width] - - textView.textContainer?.containerSize = NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude) - textView.textContainer?.widthTracksTextView = true - - textView.textStorage?.setAttributedString(self.attributed) - textView.typingAttributes = [ - .foregroundColor: NSColor.labelColor, - .font: NSFont.systemFont(ofSize: 13, weight: .regular), - ] - textView.focusRingType = .none - textView.onSend = { [weak textView] in - textView?.window?.makeFirstResponder(nil) - self.onSend() - } - textView.onBeginEditing = self.onBeginEditing - textView.onEscape = self.onEscape - textView.onEndEditing = self.onEndEditing - - let scroll = NSScrollView() - scroll.drawsBackground = false - scroll.borderType = .noBorder - scroll.hasVerticalScroller = true - scroll.autohidesScrollers = true - scroll.scrollerStyle = .overlay - scroll.hasHorizontalScroller = false - scroll.documentView = textView - return scroll - } - - func updateNSView(_ scrollView: NSScrollView, context: Context) { - guard let textView = scrollView.documentView as? TranscriptNSTextView else { return } - let isEditing = scrollView.window?.firstResponder == textView - if isEditing { - return - } - - if !textView.attributedString().isEqual(to: self.attributed) { - context.coordinator.isProgrammaticUpdate = true - defer { context.coordinator.isProgrammaticUpdate = false } - textView.textStorage?.setAttributedString(self.attributed) - } - } - - final class Coordinator: NSObject, NSTextViewDelegate { - var parent: TranscriptTextView - var isProgrammaticUpdate = false - - init(_ parent: TranscriptTextView) { - self.parent = parent - } - - func textDidBeginEditing(_ notification: Notification) { - self.parent.onBeginEditing() - } - - func textDidEndEditing(_ notification: Notification) { - self.parent.onEndEditing() - } - - func textDidChange(_ notification: Notification) { - guard !self.isProgrammaticUpdate else { return } - guard let view = notification.object as? NSTextView else { return } - guard view.window?.firstResponder === view else { return } - self.parent.text = view.string - } - } -} - -// MARK: - Vibrant display label - -struct VibrantLabelView: NSViewRepresentable { - var attributed: NSAttributedString - var onTap: () -> Void - - func makeNSView(context: Context) -> NSView { - let label = NSTextField(labelWithAttributedString: self.attributed) - label.isEditable = false - label.isBordered = false - label.drawsBackground = false - label.lineBreakMode = .byWordWrapping - label.maximumNumberOfLines = 0 - label.usesSingleLineMode = false - label.cell?.wraps = true - label.cell?.isScrollable = false - label.setContentHuggingPriority(.defaultLow, for: .horizontal) - label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - label.setContentHuggingPriority(.required, for: .vertical) - label.setContentCompressionResistancePriority(.required, for: .vertical) - label.textColor = .labelColor - - let container = ClickCatcher(onTap: onTap) - container.addSubview(label) - - label.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - label.leadingAnchor.constraint(equalTo: container.leadingAnchor), - label.trailingAnchor.constraint(equalTo: container.trailingAnchor), - label.topAnchor.constraint(equalTo: container.topAnchor), - label.bottomAnchor.constraint(equalTo: container.bottomAnchor), - ]) - return container - } - - func updateNSView(_ nsView: NSView, context: Context) { - guard let container = nsView as? ClickCatcher, - let label = container.subviews.first as? NSTextField else { return } - label.attributedStringValue = self.attributed.strippingForegroundColor() - label.textColor = .labelColor - } -} - -private final class ClickCatcher: NSView { - let onTap: () -> Void - init(onTap: @escaping () -> Void) { - self.onTap = onTap - super.init(frame: .zero) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func mouseDown(with event: NSEvent) { - super.mouseDown(with: event) - self.onTap() - } -} - -private final class TranscriptNSTextView: NSTextView { - var onSend: (() -> Void)? - var onBeginEditing: (() -> Void)? - var onEndEditing: (() -> Void)? - var onEscape: (() -> Void)? - - override func becomeFirstResponder() -> Bool { - self.onBeginEditing?() - return super.becomeFirstResponder() - } - - override func resignFirstResponder() -> Bool { - let result = super.resignFirstResponder() - self.onEndEditing?() - return result - } - - override func keyDown(with event: NSEvent) { - let isReturn = event.keyCode == 36 - let isEscape = event.keyCode == 53 - if isEscape { - self.onEscape?() - return - } - if isReturn, event.modifierFlags.contains(.command) { - self.onSend?() - return - } - if isReturn { - if event.modifierFlags.contains(.shift) { - super.insertNewline(nil) - return - } - self.onSend?() - return - } - super.keyDown(with: event) - } -} diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeOverlayView.swift b/apps/macos/Sources/OpenClaw/VoiceWakeOverlayView.swift deleted file mode 100644 index 516da776ace..00000000000 --- a/apps/macos/Sources/OpenClaw/VoiceWakeOverlayView.swift +++ /dev/null @@ -1,188 +0,0 @@ -import SwiftUI - -struct VoiceWakeOverlayView: View { - var controller: VoiceWakeOverlayController - @FocusState private var textFocused: Bool - @State private var isHovering: Bool = false - @State private var closeHovering: Bool = false - - var body: some View { - ZStack(alignment: .topLeading) { - HStack(alignment: .top, spacing: 8) { - if self.controller.model.isEditing { - TranscriptTextView( - text: Binding( - get: { self.controller.model.text }, - set: { self.controller.updateText($0) }), - attributed: self.controller.model.attributed, - isFinal: self.controller.model.isFinal, - isOverflowing: self.controller.model.isOverflowing, - onBeginEditing: { - self.controller.userBeganEditing() - }, - onEscape: { - self.controller.cancelEditingAndDismiss() - }, - onEndEditing: { - self.controller.endEditing() - }, - onSend: { - self.controller.requestSend() - }) - .focused(self.$textFocused) - .frame(maxWidth: .infinity, minHeight: 32, maxHeight: .infinity, alignment: .topLeading) - .id("editing") - } else { - VibrantLabelView( - attributed: self.controller.model.attributed, - onTap: { - self.controller.userBeganEditing() - self.textFocused = true - }) - .frame(maxWidth: .infinity, minHeight: 32, maxHeight: .infinity, alignment: .topLeading) - .focusable(false) - .id("display") - } - - Button { - self.controller.requestSend() - } label: { - let sending = self.controller.model.isSending - let level = self.controller.model.level - ZStack { - GeometryReader { geo in - let width = geo.size.width - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(Color.accentColor.opacity(0.12)) - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(Color.accentColor.opacity(0.25)) - .frame(width: width * max(0, min(1, level)), alignment: .leading) - .animation(.easeOut(duration: 0.08), value: level) - } - .frame(height: 28) - - ZStack { - Image(systemName: "paperplane.fill") - .opacity(sending ? 0 : 1) - .scaleEffect(sending ? 0.5 : 1) - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - .opacity(sending ? 1 : 0) - .scaleEffect(sending ? 1.05 : 0.8) - } - .imageScale(.small) - } - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - .frame(width: 32, height: 28) - .animation(.spring(response: 0.35, dampingFraction: 0.78), value: sending) - } - .buttonStyle(.plain) - .disabled(!self.controller.model.forwardEnabled || self.controller.model.isSending) - .keyboardShortcut(.return, modifiers: [.command]) - } - .padding(.vertical, 8) - .padding(.horizontal, 10) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .background { - OverlayBackground() - .equatable() - } - .shadow(color: Color.black.opacity(0.22), radius: 14, x: 0, y: -2) - .onHover { self.isHovering = $0 } - - // Close button rendered above and outside the clipped bubble - CloseButtonOverlay( - isVisible: self.controller.model.isEditing || self.isHovering || self.closeHovering, - onHover: { self.closeHovering = $0 }, - onClose: { self.controller.cancelEditingAndDismiss() }) - } - .padding(.top, self.controller.closeOverflow) - .padding(.leading, self.controller.closeOverflow) - .padding(.trailing, self.controller.closeOverflow) - .padding(.bottom, self.controller.closeOverflow) - .onAppear { - self.updateFocusState(visible: self.controller.model.isVisible, editing: self.controller.model.isEditing) - } - .onChange(of: self.controller.model.isVisible) { _, visible in - self.updateFocusState(visible: visible, editing: self.controller.model.isEditing) - } - .onChange(of: self.controller.model.isEditing) { _, editing in - self.updateFocusState(visible: self.controller.model.isVisible, editing: editing) - } - .onChange(of: self.controller.model.attributed) { _, _ in - self.controller.updateWindowFrame(animate: true) - } - } - - private func updateFocusState(visible: Bool, editing: Bool) { - let shouldFocus = visible && editing - guard self.textFocused != shouldFocus else { return } - self.textFocused = shouldFocus - } -} - -private struct OverlayBackground: View { - var body: some View { - let shape = RoundedRectangle(cornerRadius: 12, style: .continuous) - VisualEffectView(material: .hudWindow, blendingMode: .behindWindow) - .clipShape(shape) - .overlay(shape.strokeBorder(Color.white.opacity(0.16), lineWidth: 1)) - } -} - -extension OverlayBackground: @MainActor Equatable { - static func == (lhs: Self, rhs: Self) -> Bool { - true - } -} - -struct CloseHoverButton: View { - var onClose: () -> Void - - var body: some View { - Button(action: self.onClose) { - Image(systemName: "xmark") - .font(.system(size: 12, weight: .bold)) - .foregroundColor(Color.white.opacity(0.85)) - .frame(width: 22, height: 22) - .background(Color.black.opacity(0.35)) - .clipShape(Circle()) - .shadow(color: Color.black.opacity(0.35), radius: 6, y: 2) - } - .buttonStyle(.plain) - .focusable(false) - .contentShape(Circle()) - .padding(6) - } -} - -struct CloseButtonOverlay: View { - var isVisible: Bool - var onHover: (Bool) -> Void - var onClose: () -> Void - - var body: some View { - Group { - if self.isVisible { - Button(action: self.onClose) { - Image(systemName: "xmark") - .font(.system(size: 12, weight: .bold)) - .foregroundColor(Color.white.opacity(0.9)) - .frame(width: 22, height: 22) - .background(Color.black.opacity(0.4)) - .clipShape(Circle()) - .shadow(color: Color.black.opacity(0.45), radius: 10, x: 0, y: 3) - .shadow(color: Color.black.opacity(0.2), radius: 2, x: 0, y: 0) - } - .buttonStyle(.plain) - .focusable(false) - .contentShape(Circle()) - .padding(6) - .onHover { self.onHover($0) } - .offset(x: -9, y: -9) - .transition(.opacity) - } - } - .allowsHitTesting(self.isVisible) - } -} diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeRuntime.swift b/apps/macos/Sources/OpenClaw/VoiceWakeRuntime.swift deleted file mode 100644 index 61f913b9da8..00000000000 --- a/apps/macos/Sources/OpenClaw/VoiceWakeRuntime.swift +++ /dev/null @@ -1,805 +0,0 @@ -import AVFoundation -import Foundation -import OSLog -import Speech -import SwabbleKit -#if canImport(AppKit) -import AppKit -#endif - -/// Background listener that keeps the voice-wake pipeline alive outside the settings test view. -actor VoiceWakeRuntime { - static let shared = VoiceWakeRuntime() - - enum ListeningState { case idle, voiceWake, pushToTalk } - - private let logger = Logger(subsystem: "ai.openclaw", category: "voicewake.runtime") - - private var recognizer: SFSpeechRecognizer? - // Lazily created on start to avoid creating an AVAudioEngine at app launch, which can switch Bluetooth - // headphones into the low-quality headset profile even if Voice Wake is disabled. - private var audioEngine: AVAudioEngine? - private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? - private var recognitionTask: SFSpeechRecognitionTask? - private var recognitionGeneration: Int = 0 // drop stale callbacks after restarts - private var lastHeard: Date? - private var noiseFloorRMS: Double = 1e-4 - private var captureStartedAt: Date? - private var captureTask: Task? - private var capturedTranscript: String = "" - private var isCapturing: Bool = false - private var heardBeyondTrigger: Bool = false - private var triggerChimePlayed: Bool = false - private var committedTranscript: String = "" - private var volatileTranscript: String = "" - private var cooldownUntil: Date? - private var currentConfig: RuntimeConfig? - private var listeningState: ListeningState = .idle - private var overlayToken: UUID? - private var activeTriggerEndTime: TimeInterval? - private var scheduledRestartTask: Task? - private var lastLoggedText: String? - private var lastLoggedAt: Date? - private var lastTapLogAt: Date? - private var lastCallbackLogAt: Date? - private var lastTranscript: String? - private var lastTranscriptAt: Date? - private var preDetectTask: Task? - private var isStarting: Bool = false - private var triggerOnlyTask: Task? - - /// Tunables - /// Silence threshold once we've captured user speech (post-trigger). - private let silenceWindow: TimeInterval = 2.0 - /// Silence threshold when we only heard the trigger but no post-trigger speech yet. - private let triggerOnlySilenceWindow: TimeInterval = 5.0 - // Maximum capture duration from trigger until we force-send, to avoid runaway sessions. - private let captureHardStop: TimeInterval = 120.0 - private let debounceAfterSend: TimeInterval = 0.35 - // Voice activity detection parameters (RMS-based). - private let minSpeechRMS: Double = 1e-3 - private let speechBoostFactor: Double = 6.0 // how far above noise floor we require to mark speech - private let preDetectSilenceWindow: TimeInterval = 1.0 - private let triggerPauseWindow: TimeInterval = 0.55 - - /// Stops the active Speech pipeline without clearing the stored config, so we can restart cleanly. - private func haltRecognitionPipeline() { - // Bump generation first so any in-flight callbacks from the cancelled task get dropped. - self.recognitionGeneration &+= 1 - self.recognitionTask?.cancel() - self.recognitionTask = nil - self.recognitionRequest?.endAudio() - self.recognitionRequest = nil - self.audioEngine?.inputNode.removeTap(onBus: 0) - self.audioEngine?.stop() - // Release the engine so we also release any audio session/resources when Voice Wake is idle. - self.audioEngine = nil - } - - struct RuntimeConfig: Equatable { - let triggers: [String] - let micID: String? - let localeID: String? - let triggerChime: VoiceWakeChime - let sendChime: VoiceWakeChime - } - - private struct RecognitionUpdate { - let transcript: String? - let segments: [WakeWordSegment] - let isFinal: Bool - let error: Error? - let generation: Int - } - - func refresh(state: AppState) async { - let snapshot = await MainActor.run { () -> (Bool, RuntimeConfig) in - let enabled = state.swabbleEnabled - let config = RuntimeConfig( - triggers: sanitizeVoiceWakeTriggers(state.swabbleTriggerWords), - micID: state.voiceWakeMicID.isEmpty ? nil : state.voiceWakeMicID, - localeID: state.voiceWakeLocaleID.isEmpty ? nil : state.voiceWakeLocaleID, - triggerChime: state.voiceWakeTriggerChime, - sendChime: state.voiceWakeSendChime) - return (enabled, config) - } - - guard voiceWakeSupported, snapshot.0 else { - self.stop() - return - } - - guard PermissionManager.voiceWakePermissionsGranted() else { - self.logger.debug("voicewake runtime not starting: permissions missing") - self.stop() - return - } - - let config = snapshot.1 - - if self.isStarting { - return - } - - if self.scheduledRestartTask != nil, config == self.currentConfig, self.recognitionTask == nil { - return - } - - if self.scheduledRestartTask != nil { - self.scheduledRestartTask?.cancel() - self.scheduledRestartTask = nil - } - - if config == self.currentConfig, self.recognitionTask != nil { - return - } - - self.stop() - await self.start(with: config) - } - - private func start(with config: RuntimeConfig) async { - if self.isStarting { - return - } - self.isStarting = true - defer { self.isStarting = false } - do { - self.recognitionGeneration &+= 1 - let generation = self.recognitionGeneration - - self.configureSession(localeID: config.localeID) - - guard let recognizer, recognizer.isAvailable else { - self.logger.error("voicewake runtime: speech recognizer unavailable") - return - } - - self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest() - self.recognitionRequest?.shouldReportPartialResults = true - self.recognitionRequest?.taskHint = .dictation - guard let request = self.recognitionRequest else { return } - - // Lazily create the engine here so app launch doesn't grab audio resources / trigger Bluetooth HFP. - if self.audioEngine == nil { - self.audioEngine = AVAudioEngine() - } - guard let audioEngine = self.audioEngine else { return } - - let input = audioEngine.inputNode - let format = input.outputFormat(forBus: 0) - guard format.channelCount > 0, format.sampleRate > 0 else { - throw NSError( - domain: "VoiceWakeRuntime", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "No audio input available"]) - } - input.removeTap(onBus: 0) - input.installTap(onBus: 0, bufferSize: 2048, format: format) { [weak self, weak request] buffer, _ in - request?.append(buffer) - guard let rms = Self.rmsLevel(buffer: buffer) else { return } - Task.detached { [weak self] in - await self?.noteAudioLevel(rms: rms) - await self?.noteAudioTap(rms: rms) - } - } - - audioEngine.prepare() - try audioEngine.start() - - self.currentConfig = config - self.lastHeard = Date() - // Preserve any existing cooldownUntil so the debounce after send isn't wiped by a restart. - - self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self, generation] result, error in - guard let self else { return } - let transcript = result?.bestTranscription.formattedString - let segments = result.flatMap { result in - transcript - .map { WakeWordSpeechSegments.from(transcription: result.bestTranscription, transcript: $0) } - } ?? [] - let isFinal = result?.isFinal ?? false - Task { await self.noteRecognitionCallback(transcript: transcript, isFinal: isFinal, error: error) } - let update = RecognitionUpdate( - transcript: transcript, - segments: segments, - isFinal: isFinal, - error: error, - generation: generation) - Task { await self.handleRecognition(update, config: config) } - } - - let preferred = config.micID?.isEmpty == false ? config.micID! : "system-default" - self.logger.info( - "voicewake runtime input preferred=\(preferred, privacy: .public) " + - "\(AudioInputDeviceObserver.defaultInputDeviceSummary(), privacy: .public)") - self.logger.info("voicewake runtime started") - DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "started", fields: [ - "locale": config.localeID ?? "", - "micID": config.micID ?? "", - ]) - } catch { - self.logger.error("voicewake runtime failed to start: \(error.localizedDescription, privacy: .public)") - self.stop() - } - } - - private func stop(dismissOverlay: Bool = true, cancelScheduledRestart: Bool = true) { - if cancelScheduledRestart { - self.scheduledRestartTask?.cancel() - self.scheduledRestartTask = nil - } - self.captureTask?.cancel() - self.captureTask = nil - self.isCapturing = false - self.capturedTranscript = "" - self.captureStartedAt = nil - self.triggerChimePlayed = false - self.lastTranscript = nil - self.lastTranscriptAt = nil - self.preDetectTask?.cancel() - self.preDetectTask = nil - self.triggerOnlyTask?.cancel() - self.triggerOnlyTask = nil - self.haltRecognitionPipeline() - self.recognizer = nil - self.currentConfig = nil - self.listeningState = .idle - self.activeTriggerEndTime = nil - self.logger.debug("voicewake runtime stopped") - DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "stopped") - - let token = self.overlayToken - self.overlayToken = nil - guard dismissOverlay else { return } - Task { @MainActor in - if let token { - VoiceSessionCoordinator.shared.dismiss(token: token, reason: .explicit, outcome: .empty) - } else { - VoiceWakeOverlayController.shared.dismiss() - } - } - } - - private func configureSession(localeID: String?) { - let locale = localeID.flatMap { Locale(identifier: $0) } ?? Locale(identifier: Locale.current.identifier) - self.recognizer = SFSpeechRecognizer(locale: locale) - self.recognizer?.defaultTaskHint = .dictation - } - - private func handleRecognition(_ update: RecognitionUpdate, config: RuntimeConfig) async { - if update.generation != self.recognitionGeneration { - return // stale callback from a superseded recognizer session - } - if let error = update.error { - self.logger.debug("voicewake recognition error: \(error.localizedDescription, privacy: .public)") - } - - guard let transcript = update.transcript else { return } - - let now = Date() - if !transcript.isEmpty { - self.lastHeard = now - if !self.isCapturing { - self.lastTranscript = transcript - self.lastTranscriptAt = now - } - if self.isCapturing { - self.maybeLogRecognition( - transcript: transcript, - segments: update.segments, - triggers: config.triggers, - isFinal: update.isFinal, - match: nil, - usedFallback: false, - capturing: true) - let trimmed = Self.commandAfterTrigger( - transcript: transcript, - segments: update.segments, - triggerEndTime: self.activeTriggerEndTime, - triggers: config.triggers) - self.capturedTranscript = trimmed - self.updateHeardBeyondTrigger(withTrimmed: trimmed) - if update.isFinal { - self.committedTranscript = trimmed - self.volatileTranscript = "" - } else { - self.volatileTranscript = Self.delta(after: self.committedTranscript, current: trimmed) - } - - let attributed = Self.makeAttributed( - committed: self.committedTranscript, - volatile: self.volatileTranscript, - isFinal: update.isFinal) - let snapshot = self.committedTranscript + self.volatileTranscript - if let token = self.overlayToken { - await MainActor.run { - VoiceSessionCoordinator.shared.updatePartial( - token: token, - text: snapshot, - attributed: attributed) - } - } - } - } - - if self.isCapturing { return } - - let gateConfig = WakeWordGateConfig(triggers: config.triggers) - var usedFallback = false - var match = WakeWordGate.match(transcript: transcript, segments: update.segments, config: gateConfig) - if match == nil, update.isFinal { - match = self.textOnlyFallbackMatch( - transcript: transcript, - triggers: config.triggers, - config: gateConfig) - usedFallback = match != nil - } - self.maybeLogRecognition( - transcript: transcript, - segments: update.segments, - triggers: config.triggers, - isFinal: update.isFinal, - match: match, - usedFallback: usedFallback, - capturing: false) - - if let match { - if let cooldown = cooldownUntil, now < cooldown { - return - } - if usedFallback { - self.logger.info("voicewake runtime detected (text-only fallback) len=\(match.command.count)") - } else { - self.logger.info("voicewake runtime detected len=\(match.command.count)") - } - await self.beginCapture(command: match.command, triggerEndTime: match.triggerEndTime, config: config) - } else if !transcript.isEmpty, update.error == nil { - if self.isTriggerOnly(transcript: transcript, triggers: config.triggers) { - self.preDetectTask?.cancel() - self.preDetectTask = nil - self.scheduleTriggerOnlyPauseCheck(triggers: config.triggers, config: config) - } else { - self.triggerOnlyTask?.cancel() - self.triggerOnlyTask = nil - self.schedulePreDetectSilenceCheck( - triggers: config.triggers, - gateConfig: gateConfig, - config: config) - } - } - } - - private func maybeLogRecognition( - transcript: String, - segments: [WakeWordSegment], - triggers: [String], - isFinal: Bool, - match: WakeWordGateMatch?, - usedFallback: Bool, - capturing: Bool) - { - guard !transcript.isEmpty else { return } - let level = self.logger.logLevel - guard level == .debug || level == .trace else { return } - if transcript == self.lastLoggedText, !isFinal { - if let last = self.lastLoggedAt, Date().timeIntervalSince(last) < 0.25 { - return - } - } - self.lastLoggedText = transcript - self.lastLoggedAt = Date() - - let textOnly = WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers) - let timingCount = segments.count(where: { $0.start > 0 || $0.duration > 0 }) - let matchSummary = match.map { - "match=true gap=\(String(format: "%.2f", $0.postGap))s cmdLen=\($0.command.count)" - } ?? "match=false" - let segmentSummary = segments.map { seg in - let start = String(format: "%.2f", seg.start) - let end = String(format: "%.2f", seg.end) - return "\(seg.text)@\(start)-\(end)" - }.joined(separator: ", ") - - self.logger.debug( - "voicewake runtime transcript='\(transcript, privacy: .private)' textOnly=\(textOnly) " + - "isFinal=\(isFinal) timing=\(timingCount)/\(segments.count) " + - "capturing=\(capturing) fallback=\(usedFallback) " + - "\(matchSummary) segments=[\(segmentSummary, privacy: .private)]") - } - - private func noteAudioTap(rms: Double) { - let now = Date() - if let last = self.lastTapLogAt, now.timeIntervalSince(last) < 1.0 { - return - } - self.lastTapLogAt = now - let db = 20 * log10(max(rms, 1e-7)) - self.logger.debug( - "voicewake runtime audio tap rms=\(String(format: "%.6f", rms)) " + - "db=\(String(format: "%.1f", db)) capturing=\(self.isCapturing)") - } - - private func noteRecognitionCallback(transcript: String?, isFinal: Bool, error: Error?) { - guard transcript?.isEmpty ?? true else { return } - let now = Date() - if let last = self.lastCallbackLogAt, now.timeIntervalSince(last) < 1.0 { - return - } - self.lastCallbackLogAt = now - let errorSummary = error?.localizedDescription ?? "none" - self.logger.debug( - "voicewake runtime callback empty transcript isFinal=\(isFinal) error=\(errorSummary, privacy: .public)") - } - - private func scheduleTriggerOnlyPauseCheck(triggers: [String], config: RuntimeConfig) { - self.triggerOnlyTask?.cancel() - let lastSeenAt = self.lastTranscriptAt - let lastText = self.lastTranscript - let windowNanos = UInt64(self.triggerPauseWindow * 1_000_000_000) - self.triggerOnlyTask = Task { [weak self, lastSeenAt, lastText] in - try? await Task.sleep(nanoseconds: windowNanos) - guard let self else { return } - await self.triggerOnlyPauseCheck( - lastSeenAt: lastSeenAt, - lastText: lastText, - triggers: triggers, - config: config) - } - } - - private func schedulePreDetectSilenceCheck( - triggers: [String], - gateConfig: WakeWordGateConfig, - config: RuntimeConfig) - { - self.preDetectTask?.cancel() - let lastSeenAt = self.lastTranscriptAt - let lastText = self.lastTranscript - let windowNanos = UInt64(self.preDetectSilenceWindow * 1_000_000_000) - self.preDetectTask = Task { [weak self, lastSeenAt, lastText] in - try? await Task.sleep(nanoseconds: windowNanos) - guard let self else { return } - await self.preDetectSilenceCheck( - lastSeenAt: lastSeenAt, - lastText: lastText, - triggers: triggers, - gateConfig: gateConfig, - config: config) - } - } - - private func triggerOnlyPauseCheck( - lastSeenAt: Date?, - lastText: String?, - triggers: [String], - config: RuntimeConfig) async - { - guard !Task.isCancelled else { return } - guard !self.isCapturing else { return } - guard let lastSeenAt, let lastText else { return } - guard self.lastTranscriptAt == lastSeenAt, self.lastTranscript == lastText else { return } - guard self.isTriggerOnly(transcript: lastText, triggers: triggers) else { return } - if let cooldown = self.cooldownUntil, Date() < cooldown { - return - } - self.logger.info("voicewake runtime detected (trigger-only pause)") - await self.beginCapture(command: "", triggerEndTime: nil, config: config) - } - - private func textOnlyFallbackMatch( - transcript: String, - triggers: [String], - config: WakeWordGateConfig) -> WakeWordGateMatch? - { - guard let command = VoiceWakeTextUtils.textOnlyCommand( - transcript: transcript, - triggers: triggers, - minCommandLength: config.minCommandLength, - trimWake: Self.trimmedAfterTrigger) - else { return nil } - return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: command) - } - - private func isTriggerOnly(transcript: String, triggers: [String]) -> Bool { - guard WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers) else { return false } - guard VoiceWakeTextUtils.startsWithTrigger(transcript: transcript, triggers: triggers) else { return false } - return Self.trimmedAfterTrigger(transcript, triggers: triggers).isEmpty - } - - private func preDetectSilenceCheck( - lastSeenAt: Date?, - lastText: String?, - triggers: [String], - gateConfig: WakeWordGateConfig, - config: RuntimeConfig) async - { - guard !Task.isCancelled else { return } - guard !self.isCapturing else { return } - guard let lastSeenAt, let lastText else { return } - guard self.lastTranscriptAt == lastSeenAt, self.lastTranscript == lastText else { return } - guard let match = self.textOnlyFallbackMatch( - transcript: lastText, - triggers: triggers, - config: gateConfig) - else { return } - if let cooldown = self.cooldownUntil, Date() < cooldown { - return - } - self.logger.info("voicewake runtime detected (silence fallback) len=\(match.command.count)") - await self.beginCapture( - command: match.command, - triggerEndTime: match.triggerEndTime, - config: config) - } - - private func beginCapture(command: String, triggerEndTime: TimeInterval?, config: RuntimeConfig) async { - self.listeningState = .voiceWake - self.isCapturing = true - DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "beginCapture") - self.capturedTranscript = command - self.committedTranscript = "" - self.volatileTranscript = command - self.captureStartedAt = Date() - self.cooldownUntil = nil - self.heardBeyondTrigger = !command.isEmpty - self.triggerChimePlayed = false - self.activeTriggerEndTime = triggerEndTime - self.preDetectTask?.cancel() - self.preDetectTask = nil - self.triggerOnlyTask?.cancel() - self.triggerOnlyTask = nil - - if config.triggerChime != .none, !self.triggerChimePlayed { - self.triggerChimePlayed = true - await MainActor.run { VoiceWakeChimePlayer.play(config.triggerChime, reason: "voicewake.trigger") } - } - - let snapshot = self.committedTranscript + self.volatileTranscript - let attributed = Self.makeAttributed( - committed: self.committedTranscript, - volatile: self.volatileTranscript, - isFinal: false) - self.overlayToken = await MainActor.run { - VoiceSessionCoordinator.shared.startSession( - source: .wakeWord, - text: snapshot, - attributed: attributed, - forwardEnabled: true) - } - - // Keep the "ears" boosted for the capture window so the status icon animates while recording. - await MainActor.run { AppStateStore.shared.triggerVoiceEars(ttl: nil) } - - self.captureTask?.cancel() - self.captureTask = Task { [weak self] in - guard let self else { return } - await self.monitorCapture(config: config) - } - } - - private func monitorCapture(config: RuntimeConfig) async { - let start = self.captureStartedAt ?? Date() - let hardStop = start.addingTimeInterval(self.captureHardStop) - - while self.isCapturing { - let now = Date() - if now >= hardStop { - // Hard-stop after a maximum duration so we never leave the recognizer pinned open. - await self.finalizeCapture(config: config) - return - } - - let silenceThreshold = self.heardBeyondTrigger ? self.silenceWindow : self.triggerOnlySilenceWindow - if let last = self.lastHeard, now.timeIntervalSince(last) >= silenceThreshold { - await self.finalizeCapture(config: config) - return - } - - try? await Task.sleep(nanoseconds: 200_000_000) - } - } - - private func finalizeCapture(config: RuntimeConfig) async { - guard self.isCapturing else { return } - self.isCapturing = false - // Disarm trigger matching immediately (before halting recognition) to avoid double-trigger - // races from late callbacks that arrive after isCapturing is cleared. - self.cooldownUntil = Date().addingTimeInterval(self.debounceAfterSend) - self.captureTask?.cancel() - self.captureTask = nil - - let finalTranscript = self.capturedTranscript.trimmingCharacters(in: .whitespacesAndNewlines) - DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "finalizeCapture", fields: [ - "finalLen": "\(finalTranscript.count)", - ]) - // Stop further recognition events so we don't retrigger immediately with buffered audio. - self.haltRecognitionPipeline() - self.capturedTranscript = "" - self.captureStartedAt = nil - self.lastHeard = nil - self.heardBeyondTrigger = false - self.triggerChimePlayed = false - self.activeTriggerEndTime = nil - self.lastTranscript = nil - self.lastTranscriptAt = nil - self.preDetectTask?.cancel() - self.preDetectTask = nil - self.triggerOnlyTask?.cancel() - self.triggerOnlyTask = nil - - await MainActor.run { AppStateStore.shared.stopVoiceEars() } - if let token = self.overlayToken { - await MainActor.run { VoiceSessionCoordinator.shared.updateLevel(token: token, 0) } - } - - let delay: TimeInterval = 0.0 - let sendChime = finalTranscript.isEmpty ? .none : config.sendChime - if let token = self.overlayToken { - await MainActor.run { - VoiceSessionCoordinator.shared.finalize( - token: token, - text: finalTranscript, - sendChime: sendChime, - autoSendAfter: delay) - } - } else if !finalTranscript.isEmpty { - if sendChime != .none { - await MainActor.run { VoiceWakeChimePlayer.play(sendChime, reason: "voicewake.send") } - } - Task.detached { - await VoiceWakeForwarder.forward(transcript: finalTranscript) - } - } - self.overlayToken = nil - self.scheduleRestartRecognizer() - } - - // MARK: - Audio level handling - - private func noteAudioLevel(rms: Double) { - guard self.isCapturing else { return } - - // Update adaptive noise floor: faster when lower energy (quiet), slower when loud. - let alpha: Double = rms < self.noiseFloorRMS ? 0.08 : 0.01 - self.noiseFloorRMS = max(1e-7, self.noiseFloorRMS + (rms - self.noiseFloorRMS) * alpha) - - let threshold = max(self.minSpeechRMS, self.noiseFloorRMS * self.speechBoostFactor) - if rms >= threshold { - self.lastHeard = Date() - } - - // Normalize against the adaptive threshold so the UI meter stays roughly 0...1 across devices. - let clamped = min(1.0, max(0.0, rms / max(self.minSpeechRMS, threshold))) - if let token = self.overlayToken { - Task { @MainActor in - VoiceSessionCoordinator.shared.updateLevel(token: token, clamped) - } - } - } - - private static func rmsLevel(buffer: AVAudioPCMBuffer) -> Double? { - guard let channelData = buffer.floatChannelData?.pointee else { return nil } - let frameCount = Int(buffer.frameLength) - guard frameCount > 0 else { return nil } - var sum: Double = 0 - for i in 0.. String { - for trigger in triggers { - let token = trigger.trimmingCharacters(in: .whitespacesAndNewlines) - guard !token.isEmpty else { continue } - guard let range = text.range( - of: token, - options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive]) else { continue } - let trimmed = text[range.upperBound...].trimmingCharacters(in: .whitespacesAndNewlines) - return String(trimmed) - } - return text - } - - private static func commandAfterTrigger( - transcript: String, - segments: [WakeWordSegment], - triggerEndTime: TimeInterval?, - triggers: [String]) -> String - { - guard let triggerEndTime else { - return self.trimmedAfterTrigger(transcript, triggers: triggers) - } - let trimmed = WakeWordGate.commandText( - transcript: transcript, - segments: segments, - triggerEndTime: triggerEndTime) - return trimmed.isEmpty ? self.trimmedAfterTrigger(transcript, triggers: triggers) : trimmed - } - - #if DEBUG - static func _testTrimmedAfterTrigger(_ text: String, triggers: [String]) -> String { - self.trimmedAfterTrigger(text, triggers: triggers) - } - - static func _testHasContentAfterTrigger(_ text: String, triggers: [String]) -> Bool { - !self.trimmedAfterTrigger(text, triggers: triggers).isEmpty - } - - static func _testAttributedColor(isFinal: Bool) -> NSColor { - self.makeAttributed(committed: "sample", volatile: "", isFinal: isFinal) - .attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor ?? .clear - } - - #endif - - private static func delta(after committed: String, current: String) -> String { - if current.hasPrefix(committed) { - let start = current.index(current.startIndex, offsetBy: committed.count) - return String(current[start...]) - } - return current - } - - private static func makeAttributed(committed: String, volatile: String, isFinal: Bool) -> NSAttributedString { - let full = NSMutableAttributedString() - let committedAttr: [NSAttributedString.Key: Any] = [ - .foregroundColor: NSColor.labelColor, - .font: NSFont.systemFont(ofSize: 13, weight: .regular), - ] - full.append(NSAttributedString(string: committed, attributes: committedAttr)) - let volatileColor: NSColor = isFinal ? .labelColor : NSColor.tertiaryLabelColor - let volatileAttr: [NSAttributedString.Key: Any] = [ - .foregroundColor: volatileColor, - .font: NSFont.systemFont(ofSize: 13, weight: .regular), - ] - full.append(NSAttributedString(string: volatile, attributes: volatileAttr)) - return full - } -} diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift b/apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift deleted file mode 100644 index d4413618e11..00000000000 --- a/apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift +++ /dev/null @@ -1,675 +0,0 @@ -import AppKit -import AVFoundation -import Observation -import Speech -import SwabbleKit -import SwiftUI -import UniformTypeIdentifiers - -struct VoiceWakeSettings: View { - @Bindable var state: AppState - let isActive: Bool - @State private var testState: VoiceWakeTestState = .idle - @State private var tester = VoiceWakeTester() - @State private var isTesting = false - @State private var testTimeoutTask: Task? - @State private var availableMics: [AudioInputDevice] = [] - @State private var loadingMics = false - @State private var meterLevel: Double = 0 - @State private var meterError: String? - private let meter = MicLevelMonitor() - @State private var micObserver = AudioInputDeviceObserver() - @State private var micRefreshTask: Task? - @State private var availableLocales: [Locale] = [] - @State private var triggerEntries: [TriggerEntry] = [] - private let fieldLabelWidth: CGFloat = 140 - private let controlWidth: CGFloat = 240 - private let isPreview = ProcessInfo.processInfo.isPreview - - private struct AudioInputDevice: Identifiable, Equatable { - let uid: String - let name: String - var id: String { - self.uid - } - } - - private struct TriggerEntry: Identifiable { - let id: UUID - var value: String - } - - private var voiceWakeBinding: Binding { - Binding( - get: { self.state.swabbleEnabled }, - set: { newValue in - Task { await self.state.setVoiceWakeEnabled(newValue) } - }) - } - - var body: some View { - ScrollView(.vertical) { - VStack(alignment: .leading, spacing: 14) { - SettingsToggleRow( - title: "Enable Voice Wake", - subtitle: "Listen for a wake phrase (e.g. \"Claude\") before running voice commands. " - + "Voice recognition runs fully on-device.", - binding: self.voiceWakeBinding) - .disabled(!voiceWakeSupported) - - SettingsToggleRow( - title: "Hold Right Option to talk", - subtitle: """ - Push-to-talk mode that starts listening while you hold the key - and shows the preview overlay. - """, - binding: self.$state.voicePushToTalkEnabled) - .disabled(!voiceWakeSupported) - - if !voiceWakeSupported { - Label("Voice Wake requires macOS 26 or newer.", systemImage: "exclamationmark.triangle.fill") - .font(.callout) - .foregroundStyle(.yellow) - .padding(8) - .background(Color.secondary.opacity(0.15)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } - - self.localePicker - self.micPicker - self.levelMeter - - VoiceWakeTestCard( - testState: self.$testState, - isTesting: self.$isTesting, - onToggle: self.toggleTest) - - self.chimeSection - - self.triggerTable - - Spacer(minLength: 8) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 12) - } - .task { - guard !self.isPreview else { return } - await self.loadMicsIfNeeded() - } - .task { - guard !self.isPreview else { return } - await self.loadLocalesIfNeeded() - } - .task { - guard !self.isPreview else { return } - await self.restartMeter() - } - .onAppear { - guard !self.isPreview else { return } - self.startMicObserver() - self.loadTriggerEntries() - } - .onChange(of: self.state.voiceWakeMicID) { _, _ in - guard !self.isPreview else { return } - self.updateSelectedMicName() - Task { await self.restartMeter() } - } - .onChange(of: self.isActive) { _, active in - guard !self.isPreview else { return } - if !active { - self.tester.stop() - self.isTesting = false - self.testState = .idle - self.testTimeoutTask?.cancel() - self.micRefreshTask?.cancel() - self.micRefreshTask = nil - Task { await self.meter.stop() } - self.micObserver.stop() - self.syncTriggerEntriesToState() - } else { - self.startMicObserver() - self.loadTriggerEntries() - } - } - .onDisappear { - guard !self.isPreview else { return } - self.tester.stop() - self.isTesting = false - self.testState = .idle - self.testTimeoutTask?.cancel() - self.micRefreshTask?.cancel() - self.micRefreshTask = nil - self.micObserver.stop() - Task { await self.meter.stop() } - self.syncTriggerEntriesToState() - } - } - - private func loadTriggerEntries() { - self.triggerEntries = self.state.swabbleTriggerWords.map { TriggerEntry(id: UUID(), value: $0) } - } - - private func syncTriggerEntriesToState() { - self.state.swabbleTriggerWords = self.triggerEntries.map(\.value) - } - - private var triggerTable: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("Trigger words") - .font(.callout.weight(.semibold)) - Spacer() - Button { - self.addWord() - } label: { - Label("Add word", systemImage: "plus") - } - .disabled(self.triggerEntries - .contains(where: { $0.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })) - - Button("Reset defaults") { - self.triggerEntries = defaultVoiceWakeTriggers.map { TriggerEntry(id: UUID(), value: $0) } - self.syncTriggerEntriesToState() - } - } - - VStack(spacing: 0) { - ForEach(self.$triggerEntries) { $entry in - HStack(spacing: 8) { - TextField("Wake word", text: $entry.value) - .textFieldStyle(.roundedBorder) - .onSubmit { - self.syncTriggerEntriesToState() - } - - Button { - self.removeWord(id: entry.id) - } label: { - Image(systemName: "trash") - } - .buttonStyle(.borderless) - .help("Remove trigger word") - .frame(width: 24) - } - .padding(8) - - if entry.id != self.triggerEntries.last?.id { - Divider() - } - } - } - .frame(maxWidth: .infinity, minHeight: 180, alignment: .topLeading) - .background(Color(nsColor: .textBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 6)) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(Color.secondary.opacity(0.25), lineWidth: 1)) - - Text( - "OpenClaw reacts when any trigger appears in a transcription. " - + "Keep them short to avoid false positives.") - .font(.footnote) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - - private var chimeSection: some View { - VStack(alignment: .leading, spacing: 10) { - HStack(alignment: .firstTextBaseline, spacing: 10) { - Text("Sounds") - .font(.callout.weight(.semibold)) - Spacer() - } - - self.chimeRow( - title: "Trigger sound", - selection: self.$state.voiceWakeTriggerChime) - - self.chimeRow( - title: "Send sound", - selection: self.$state.voiceWakeSendChime) - } - .padding(.top, 4) - } - - private func addWord() { - self.triggerEntries.append(TriggerEntry(id: UUID(), value: "")) - } - - private func removeWord(id: UUID) { - self.triggerEntries.removeAll { $0.id == id } - self.syncTriggerEntriesToState() - } - - private func toggleTest() { - guard voiceWakeSupported else { - self.testState = .failed("Voice Wake requires macOS 26 or newer.") - return - } - if self.isTesting { - self.tester.finalize() - self.isTesting = false - self.testState = .finalizing - Task { @MainActor in - try? await Task.sleep(nanoseconds: 2_000_000_000) - if self.testState == .finalizing { - self.tester.stop() - self.testState = .failed("Stopped") - } - } - self.testTimeoutTask?.cancel() - return - } - - let triggers = self.sanitizedTriggers() - self.tester.stop() - self.testTimeoutTask?.cancel() - self.isTesting = true - self.testState = .requesting - Task { @MainActor in - do { - try await self.tester.start( - triggers: triggers, - micID: self.state.voiceWakeMicID.isEmpty ? nil : self.state.voiceWakeMicID, - localeID: self.state.voiceWakeLocaleID, - onUpdate: { newState in - DispatchQueue.main.async { [self] in - self.testState = newState - if case .detected = newState { self.isTesting = false } - if case .failed = newState { self.isTesting = false } - if case .detected = newState { self.testTimeoutTask?.cancel() } - if case .failed = newState { self.testTimeoutTask?.cancel() } - } - }) - self.testTimeoutTask?.cancel() - self.testTimeoutTask = Task { @MainActor in - try? await Task.sleep(nanoseconds: 10 * 1_000_000_000) - guard !Task.isCancelled else { return } - if self.isTesting { - self.tester.stop() - if case let .hearing(text) = self.testState, - let command = Self.textOnlyCommand(from: text, triggers: triggers) - { - self.testState = .detected(command) - } else { - self.testState = .failed("Timeout: no trigger heard") - } - self.isTesting = false - } - } - } catch { - self.tester.stop() - self.testState = .failed(error.localizedDescription) - self.isTesting = false - self.testTimeoutTask?.cancel() - } - } - } - - private func chimeRow(title: String, selection: Binding) -> some View { - HStack(alignment: .center, spacing: 10) { - Text(title) - .font(.callout.weight(.semibold)) - .frame(width: self.fieldLabelWidth, alignment: .leading) - - Menu { - Button("No Sound") { self.selectChime(.none, binding: selection) } - Divider() - ForEach(VoiceWakeChimeCatalog.systemOptions, id: \.self) { option in - Button(VoiceWakeChimeCatalog.displayName(for: option)) { - self.selectChime(.system(name: option), binding: selection) - } - } - Divider() - Button("Choose file…") { self.chooseCustomChime(for: selection) } - } label: { - HStack(spacing: 6) { - Text(selection.wrappedValue.displayLabel) - .lineLimit(1) - .truncationMode(.middle) - Spacer() - Image(systemName: "chevron.down") - .font(.caption) - .foregroundStyle(.secondary) - } - .padding(6) - .frame(minWidth: self.controlWidth, maxWidth: .infinity, alignment: .leading) - .background(Color(nsColor: .windowBackgroundColor)) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(Color.secondary.opacity(0.25), lineWidth: 1)) - .clipShape(RoundedRectangle(cornerRadius: 6)) - } - - Button("Play") { - VoiceWakeChimePlayer.play(selection.wrappedValue) - } - .keyboardShortcut(.space, modifiers: [.command]) - } - } - - private func chooseCustomChime(for selection: Binding) { - let panel = NSOpenPanel() - panel.allowedContentTypes = [.audio] - panel.allowsMultipleSelection = false - panel.canChooseDirectories = false - panel.resolvesAliases = true - panel.begin { response in - guard response == .OK, let url = panel.url else { return } - do { - let bookmark = try url.bookmarkData( - options: [.withSecurityScope], - includingResourceValuesForKeys: nil, - relativeTo: nil) - let chosen = VoiceWakeChime.custom(displayName: url.lastPathComponent, bookmark: bookmark) - selection.wrappedValue = chosen - VoiceWakeChimePlayer.play(chosen) - } catch { - // Ignore failures; user can retry. - } - } - } - - private func selectChime(_ chime: VoiceWakeChime, binding: Binding) { - binding.wrappedValue = chime - VoiceWakeChimePlayer.play(chime) - } - - private func sanitizedTriggers() -> [String] { - sanitizeVoiceWakeTriggers(self.state.swabbleTriggerWords) - } - - private static func textOnlyCommand(from transcript: String, triggers: [String]) -> String? { - VoiceWakeTextUtils.textOnlyCommand( - transcript: transcript, - triggers: triggers, - minCommandLength: 1, - trimWake: { WakeWordGate.stripWake(text: $0, triggers: $1) }) - } - - private var micPicker: some View { - VStack(alignment: .leading, spacing: 6) { - HStack(alignment: .firstTextBaseline, spacing: 10) { - Text("Microphone") - .font(.callout.weight(.semibold)) - .frame(width: self.fieldLabelWidth, alignment: .leading) - Picker("Microphone", selection: self.$state.voiceWakeMicID) { - Text("System default").tag("") - if self.isSelectedMicUnavailable { - Text(self.state.voiceWakeMicName.isEmpty ? "Unavailable" : self.state.voiceWakeMicName) - .tag(self.state.voiceWakeMicID) - } - ForEach(self.availableMics) { mic in - Text(mic.name).tag(mic.uid) - } - } - .labelsHidden() - .frame(width: self.controlWidth) - } - if self.isSelectedMicUnavailable { - HStack(spacing: 10) { - Color.clear.frame(width: self.fieldLabelWidth, height: 1) - Text("Disconnected (using System default)") - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - if self.loadingMics { - ProgressView().controlSize(.small) - } - } - } - - private var localePicker: some View { - VStack(alignment: .leading, spacing: 6) { - HStack(alignment: .firstTextBaseline, spacing: 10) { - Text("Recognition language") - .font(.callout.weight(.semibold)) - .frame(width: self.fieldLabelWidth, alignment: .leading) - Picker("Language", selection: self.$state.voiceWakeLocaleID) { - let current = Locale(identifier: Locale.current.identifier) - Text("\(self.friendlyName(for: current)) (System)").tag(Locale.current.identifier) - ForEach(self.availableLocales.map(\.identifier), id: \.self) { id in - if id != Locale.current.identifier { - Text(self.friendlyName(for: Locale(identifier: id))).tag(id) - } - } - } - .labelsHidden() - .frame(width: self.controlWidth) - } - - if !self.state.voiceWakeAdditionalLocaleIDs.isEmpty { - VStack(alignment: .leading, spacing: 8) { - Text("Additional languages") - .font(.footnote.weight(.semibold)) - ForEach( - Array(self.state.voiceWakeAdditionalLocaleIDs.enumerated()), - id: \.offset) - { idx, localeID in - HStack(spacing: 8) { - Picker("Extra \(idx + 1)", selection: Binding( - get: { localeID }, - set: { newValue in - guard self.state - .voiceWakeAdditionalLocaleIDs.indices - .contains(idx) else { return } - self.state - .voiceWakeAdditionalLocaleIDs[idx] = - newValue - })) { - ForEach(self.availableLocales.map(\.identifier), id: \.self) { id in - Text(self.friendlyName(for: Locale(identifier: id))).tag(id) - } - } - .labelsHidden() - .frame(width: 220) - - Button { - guard self.state.voiceWakeAdditionalLocaleIDs.indices.contains(idx) else { return } - self.state.voiceWakeAdditionalLocaleIDs.remove(at: idx) - } label: { - Image(systemName: "trash") - } - .buttonStyle(.borderless) - .help("Remove language") - } - } - - Button { - if let first = availableLocales.first { - self.state.voiceWakeAdditionalLocaleIDs.append(first.identifier) - } - } label: { - Label("Add language", systemImage: "plus") - } - .disabled(self.availableLocales.isEmpty) - } - .padding(.top, 4) - } else { - Button { - if let first = availableLocales.first { - self.state.voiceWakeAdditionalLocaleIDs.append(first.identifier) - } - } label: { - Label("Add additional language", systemImage: "plus") - } - .buttonStyle(.link) - .disabled(self.availableLocales.isEmpty) - .padding(.top, 4) - } - - Text("Languages are tried in order. Models may need a first-use download on macOS 26.") - .font(.caption) - .foregroundStyle(.secondary) - } - } - - @MainActor - private func loadMicsIfNeeded(force: Bool = false) async { - guard force || self.availableMics.isEmpty, !self.loadingMics else { return } - self.loadingMics = true - let discovery = AVCaptureDevice.DiscoverySession( - deviceTypes: [.external, .microphone], - mediaType: .audio, - position: .unspecified) - let aliveUIDs = AudioInputDeviceObserver.aliveInputDeviceUIDs() - let connectedDevices = discovery.devices.filter(\.isConnected) - let devices = aliveUIDs.isEmpty - ? connectedDevices - : connectedDevices.filter { aliveUIDs.contains($0.uniqueID) } - self.availableMics = devices.map { AudioInputDevice(uid: $0.uniqueID, name: $0.localizedName) } - self.updateSelectedMicName() - self.loadingMics = false - } - - private var isSelectedMicUnavailable: Bool { - let selected = self.state.voiceWakeMicID - guard !selected.isEmpty else { return false } - return !self.availableMics.contains(where: { $0.uid == selected }) - } - - @MainActor - private func updateSelectedMicName() { - let selected = self.state.voiceWakeMicID - if selected.isEmpty { - self.state.voiceWakeMicName = "" - return - } - if let match = self.availableMics.first(where: { $0.uid == selected }) { - self.state.voiceWakeMicName = match.name - } - } - - private func startMicObserver() { - self.micObserver.start { - Task { @MainActor in - self.scheduleMicRefresh() - } - } - } - - @MainActor - private func scheduleMicRefresh() { - self.micRefreshTask?.cancel() - self.micRefreshTask = Task { @MainActor in - try? await Task.sleep(nanoseconds: 300_000_000) - guard !Task.isCancelled else { return } - await self.loadMicsIfNeeded(force: true) - await self.restartMeter() - } - } - - @MainActor - private func loadLocalesIfNeeded() async { - guard self.availableLocales.isEmpty else { return } - self.availableLocales = Array(SFSpeechRecognizer.supportedLocales()).sorted { lhs, rhs in - self.friendlyName(for: lhs) - .localizedCaseInsensitiveCompare(self.friendlyName(for: rhs)) == .orderedAscending - } - } - - private func friendlyName(for locale: Locale) -> String { - let cleanedID = normalizeLocaleIdentifier(locale.identifier) - let cleanLocale = Locale(identifier: cleanedID) - - if let langCode = cleanLocale.language.languageCode?.identifier, - let lang = cleanLocale.localizedString(forLanguageCode: langCode), - let regionCode = cleanLocale.region?.identifier, - let region = cleanLocale.localizedString(forRegionCode: regionCode) - { - return "\(lang) (\(region))" - } - if let langCode = cleanLocale.language.languageCode?.identifier, - let lang = cleanLocale.localizedString(forLanguageCode: langCode) - { - return lang - } - return cleanLocale.localizedString(forIdentifier: cleanedID) ?? cleanedID - } - - private var levelMeter: some View { - VStack(alignment: .leading, spacing: 6) { - HStack(alignment: .center, spacing: 10) { - Text("Live level") - .font(.callout.weight(.semibold)) - .frame(width: self.fieldLabelWidth, alignment: .leading) - MicLevelBar(level: self.meterLevel) - .frame(width: self.controlWidth, alignment: .leading) - Text(self.levelLabel) - .font(.callout.monospacedDigit()) - .foregroundStyle(.secondary) - .frame(width: 60, alignment: .trailing) - } - if let meterError { - Text(meterError) - .font(.footnote) - .foregroundStyle(.secondary) - } - } - } - - private var levelLabel: String { - let db = (meterLevel * 50) - 50 - return String(format: "%.0f dB", db) - } - - @MainActor - private func restartMeter() async { - self.meterError = nil - await self.meter.stop() - do { - try await self.meter.start { [weak state] level in - Task { @MainActor in - guard state != nil else { return } - self.meterLevel = level - } - } - } catch { - self.meterError = error.localizedDescription - } - } -} - -#if DEBUG -struct VoiceWakeSettings_Previews: PreviewProvider { - static var previews: some View { - VoiceWakeSettings(state: .preview, isActive: true) - .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) - } -} - -@MainActor -extension VoiceWakeSettings { - static func exerciseForTesting() { - let state = AppState(preview: true) - state.swabbleEnabled = true - state.voicePushToTalkEnabled = true - state.swabbleTriggerWords = ["Claude", "Hey"] - - let view = VoiceWakeSettings(state: state, isActive: true) - view.availableMics = [AudioInputDevice(uid: "mic-1", name: "Built-in")] - view.availableLocales = [Locale(identifier: "en_US")] - view.meterLevel = 0.42 - view.meterError = "No input" - view.testState = .detected("ok") - view.isTesting = true - view.triggerEntries = [TriggerEntry(id: UUID(), value: "Claude")] - - _ = view.body - _ = view.localePicker - _ = view.micPicker - _ = view.levelMeter - _ = view.triggerTable - _ = view.chimeSection - - view.addWord() - if let entryId = view.triggerEntries.first?.id { - view.removeWord(id: entryId) - } - } -} -#endif diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeTestCard.swift b/apps/macos/Sources/OpenClaw/VoiceWakeTestCard.swift deleted file mode 100644 index 7de20885a6c..00000000000 --- a/apps/macos/Sources/OpenClaw/VoiceWakeTestCard.swift +++ /dev/null @@ -1,95 +0,0 @@ -import SwiftUI - -struct VoiceWakeTestCard: View { - @Binding var testState: VoiceWakeTestState - @Binding var isTesting: Bool - let onToggle: () -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - HStack { - Text("Test Voice Wake") - .font(.callout.weight(.semibold)) - Spacer() - Button(action: self.onToggle) { - Label( - self.isTesting ? "Stop" : "Start test", - systemImage: self.isTesting ? "stop.circle.fill" : "play.circle") - } - .buttonStyle(.borderedProminent) - .tint(self.isTesting ? .red : .accentColor) - } - - HStack(spacing: 8) { - self.statusIcon - VStack(alignment: .leading, spacing: 4) { - Text(self.statusText) - .font(.subheadline) - .frame(maxHeight: 22, alignment: .center) - if case let .detected(text) = testState { - Text("Heard: \(text)") - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(2) - } - } - Spacer() - } - .padding(10) - .background(.quaternary.opacity(0.2)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .frame(minHeight: 54) - } - .padding(.vertical, 2) - } - - private var statusIcon: some View { - switch self.testState { - case .idle: - AnyView(Image(systemName: "waveform").foregroundStyle(.secondary)) - - case .requesting: - AnyView(ProgressView().controlSize(.small)) - - case .listening, .hearing: - AnyView( - Image(systemName: "ear.and.waveform") - .symbolEffect(.pulse) - .foregroundStyle(Color.accentColor)) - - case .finalizing: - AnyView(ProgressView().controlSize(.small)) - - case .detected: - AnyView(Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)) - - case .failed: - AnyView(Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.yellow)) - } - } - - private var statusText: String { - switch self.testState { - case .idle: - "Press start, say a trigger word, and wait for detection." - - case .requesting: - "Requesting mic & speech permission…" - - case .listening: - "Listening… say your trigger word." - - case let .hearing(text): - "Heard: \(text)" - - case .finalizing: - "Finalizing…" - - case .detected: - "Voice wake detected!" - - case let .failed(reason): - reason - } - } -} diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeTester.swift b/apps/macos/Sources/OpenClaw/VoiceWakeTester.swift deleted file mode 100644 index b3d0c58d90c..00000000000 --- a/apps/macos/Sources/OpenClaw/VoiceWakeTester.swift +++ /dev/null @@ -1,473 +0,0 @@ -import AVFoundation -import Foundation -import Speech -import SwabbleKit - -enum VoiceWakeTestState: Equatable { - case idle - case requesting - case listening - case hearing(String) - case finalizing - case detected(String) - case failed(String) -} - -final class VoiceWakeTester { - private let recognizer: SFSpeechRecognizer? - private var audioEngine: AVAudioEngine? - private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? - private var recognitionTask: SFSpeechRecognitionTask? - private var isStopping = false - private var isFinalizing = false - private var detectionStart: Date? - private var lastHeard: Date? - private var lastLoggedText: String? - private var lastLoggedAt: Date? - private var lastTranscript: String? - private var lastTranscriptAt: Date? - private var silenceTask: Task? - private var currentTriggers: [String] = [] - private var holdingAfterDetect = false - private var detectedText: String? - private let logger = Logger(subsystem: "ai.openclaw", category: "voicewake") - private let silenceWindow: TimeInterval = 1.0 - - init(locale: Locale = .current) { - self.recognizer = SFSpeechRecognizer(locale: locale) - } - - func start( - triggers: [String], - micID: String?, - localeID: String?, - onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) async throws - { - guard self.recognitionTask == nil else { return } - self.isStopping = false - self.isFinalizing = false - self.holdingAfterDetect = false - self.detectedText = nil - self.lastHeard = nil - self.lastLoggedText = nil - self.lastLoggedAt = nil - self.lastTranscript = nil - self.lastTranscriptAt = nil - self.silenceTask?.cancel() - self.silenceTask = nil - self.currentTriggers = triggers - let chosenLocale = localeID.flatMap { Locale(identifier: $0) } ?? Locale.current - let recognizer = SFSpeechRecognizer(locale: chosenLocale) - guard let recognizer, recognizer.isAvailable else { - throw NSError( - domain: "VoiceWakeTester", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Speech recognition unavailable"]) - } - recognizer.defaultTaskHint = .dictation - - guard Self.hasPrivacyStrings else { - throw NSError( - domain: "VoiceWakeTester", - code: 3, - userInfo: [ - NSLocalizedDescriptionKey: """ - Missing mic/speech privacy strings. Rebuild the mac app (scripts/restart-mac.sh) \ - to include usage descriptions. - """, - ]) - } - - let granted = try await Self.ensurePermissions() - guard granted else { - throw NSError( - domain: "VoiceWakeTester", - code: 2, - userInfo: [NSLocalizedDescriptionKey: "Microphone or speech permission denied"]) - } - - self.logInputSelection(preferredMicID: micID) - self.configureSession(preferredMicID: micID) - - let engine = AVAudioEngine() - self.audioEngine = engine - - self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest() - self.recognitionRequest?.shouldReportPartialResults = true - self.recognitionRequest?.taskHint = .dictation - let request = self.recognitionRequest - - let inputNode = engine.inputNode - let format = inputNode.outputFormat(forBus: 0) - guard format.channelCount > 0, format.sampleRate > 0 else { - self.audioEngine = nil - throw NSError( - domain: "VoiceWakeTester", - code: 4, - userInfo: [NSLocalizedDescriptionKey: "No audio input available"]) - } - inputNode.removeTap(onBus: 0) - inputNode.installTap(onBus: 0, bufferSize: 2048, format: format) { [weak request] buffer, _ in - request?.append(buffer) - } - - engine.prepare() - try engine.start() - DispatchQueue.main.async { - onUpdate(.listening) - } - - self.detectionStart = Date() - self.lastHeard = self.detectionStart - - guard let request = recognitionRequest else { return } - - self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in - guard let self, !self.isStopping else { return } - let text = result?.bestTranscription.formattedString ?? "" - let segments = result.map { WakeWordSpeechSegments.from( - transcription: $0.bestTranscription, - transcript: text) } ?? [] - let isFinal = result?.isFinal ?? false - let gateConfig = WakeWordGateConfig(triggers: triggers) - var match = WakeWordGate.match(transcript: text, segments: segments, config: gateConfig) - if match == nil, isFinal { - match = self.textOnlyFallbackMatch( - transcript: text, - triggers: triggers, - config: gateConfig) - } - self.maybeLogDebug( - transcript: text, - segments: segments, - triggers: triggers, - match: match, - isFinal: isFinal) - let errorMessage = error?.localizedDescription - - Task { [weak self] in - guard let self, !self.isStopping else { return } - await self.handleResult( - match: match, - text: text, - isFinal: isFinal, - errorMessage: errorMessage, - onUpdate: onUpdate) - } - } - } - - func stop() { - self.stop(force: true) - } - - func finalize(timeout: TimeInterval = 1.5) { - guard self.recognitionTask != nil else { - self.stop(force: true) - return - } - self.isFinalizing = true - self.recognitionRequest?.endAudio() - if let engine = self.audioEngine { - engine.inputNode.removeTap(onBus: 0) - engine.stop() - } - Task { [weak self] in - guard let self else { return } - try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) - if !self.isStopping { - self.stop(force: true) - } - } - } - - private func stop(force: Bool) { - if force { self.isStopping = true } - self.isFinalizing = false - self.recognitionRequest?.endAudio() - self.recognitionTask?.cancel() - self.recognitionTask = nil - self.recognitionRequest = nil - if let engine = self.audioEngine { - engine.inputNode.removeTap(onBus: 0) - engine.stop() - } - self.audioEngine = nil - self.holdingAfterDetect = false - self.detectedText = nil - self.lastHeard = nil - self.detectionStart = nil - self.lastLoggedText = nil - self.lastLoggedAt = nil - self.lastTranscript = nil - self.lastTranscriptAt = nil - self.silenceTask?.cancel() - self.silenceTask = nil - self.currentTriggers = [] - } - - private func handleResult( - match: WakeWordGateMatch?, - text: String, - isFinal: Bool, - errorMessage: String?, - onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) async - { - if !text.isEmpty { - self.lastHeard = Date() - self.lastTranscript = text - self.lastTranscriptAt = Date() - } - if self.holdingAfterDetect { - return - } - if let match, !match.command.isEmpty { - self.holdingAfterDetect = true - self.detectedText = match.command - self.logger.info("voice wake detected (test) (len=\(match.command.count))") - await MainActor.run { AppStateStore.shared.triggerVoiceEars(ttl: nil) } - self.stop() - await MainActor.run { - AppStateStore.shared.stopVoiceEars() - onUpdate(.detected(match.command)) - } - return - } - if !isFinal, !text.isEmpty { - self.scheduleSilenceCheck( - triggers: self.currentTriggers, - onUpdate: onUpdate) - } - if self.isFinalizing { - Task { @MainActor in onUpdate(.finalizing) } - } - if let errorMessage { - self.stop(force: true) - Task { @MainActor in onUpdate(.failed(errorMessage)) } - return - } - if isFinal { - self.stop(force: true) - let state: VoiceWakeTestState = text.isEmpty - ? .failed("No speech detected") - : .failed("No trigger heard: “\(text)”") - Task { @MainActor in onUpdate(state) } - } else { - let state: VoiceWakeTestState = text.isEmpty ? .listening : .hearing(text) - Task { @MainActor in onUpdate(state) } - } - } - - private func maybeLogDebug( - transcript: String, - segments: [WakeWordSegment], - triggers: [String], - match: WakeWordGateMatch?, - isFinal: Bool) - { - guard !transcript.isEmpty else { return } - let level = self.logger.logLevel - guard level == .debug || level == .trace else { return } - if transcript == self.lastLoggedText, !isFinal { - if let last = self.lastLoggedAt, Date().timeIntervalSince(last) < 0.25 { - return - } - } - self.lastLoggedText = transcript - self.lastLoggedAt = Date() - - let textOnly = WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers) - let gaps = Self.debugCandidateGaps(triggers: triggers, segments: segments) - let segmentSummary = Self.debugSegments(segments) - let timingCount = segments.count(where: { $0.start > 0 || $0.duration > 0 }) - let matchSummary = match.map { - "match=true gap=\(String(format: "%.2f", $0.postGap))s cmdLen=\($0.command.count)" - } ?? "match=false" - - self.logger.debug( - "voicewake test transcript='\(transcript, privacy: .private)' textOnly=\(textOnly) " + - "isFinal=\(isFinal) timing=\(timingCount)/\(segments.count) " + - "\(matchSummary) gaps=[\(gaps, privacy: .private)] segments=[\(segmentSummary, privacy: .private)]") - } - - private static func debugSegments(_ segments: [WakeWordSegment]) -> String { - segments.map { seg in - let start = String(format: "%.2f", seg.start) - let end = String(format: "%.2f", seg.end) - return "\(seg.text)@\(start)-\(end)" - }.joined(separator: ", ") - } - - private static func debugCandidateGaps(triggers: [String], segments: [WakeWordSegment]) -> String { - let tokens = self.normalizeSegments(segments) - guard !tokens.isEmpty else { return "" } - let triggerTokens = self.normalizeTriggers(triggers) - var gaps: [String] = [] - - for trigger in triggerTokens { - let count = trigger.tokens.count - guard count > 0, tokens.count > count else { continue } - for i in 0...(tokens.count - count - 1) { - let matched = (0.. [DebugTriggerTokens] { - var output: [DebugTriggerTokens] = [] - for trigger in triggers { - let tokens = trigger - .split(whereSeparator: { $0.isWhitespace }) - .map { VoiceWakeTextUtils.normalizeToken(String($0)) } - .filter { !$0.isEmpty } - if tokens.isEmpty { continue } - output.append(DebugTriggerTokens(tokens: tokens)) - } - return output - } - - private static func normalizeSegments(_ segments: [WakeWordSegment]) -> [DebugToken] { - segments.compactMap { segment in - let normalized = VoiceWakeTextUtils.normalizeToken(segment.text) - guard !normalized.isEmpty else { return nil } - return DebugToken( - normalized: normalized, - start: segment.start, - end: segment.end) - } - } - - private func textOnlyFallbackMatch( - transcript: String, - triggers: [String], - config: WakeWordGateConfig) -> WakeWordGateMatch? - { - guard let command = VoiceWakeTextUtils.textOnlyCommand( - transcript: transcript, - triggers: triggers, - minCommandLength: config.minCommandLength, - trimWake: { WakeWordGate.stripWake(text: $0, triggers: $1) }) - else { return nil } - return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: command) - } - - private func holdUntilSilence(onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) { - Task { [weak self] in - guard let self else { return } - let detectedAt = Date() - let hardStop = detectedAt.addingTimeInterval(6) // cap overall listen after trigger - - while !self.isStopping { - let now = Date() - if now >= hardStop { break } - if let last = self.lastHeard, now.timeIntervalSince(last) >= silenceWindow { - break - } - try? await Task.sleep(nanoseconds: 200_000_000) - } - if !self.isStopping { - self.stop() - await MainActor.run { AppStateStore.shared.stopVoiceEars() } - if let detectedText { - self.logger.info("voice wake hold finished; len=\(detectedText.count)") - Task { @MainActor in onUpdate(.detected(detectedText)) } - } - } - } - } - - private func scheduleSilenceCheck( - triggers: [String], - onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) - { - self.silenceTask?.cancel() - let lastSeenAt = self.lastTranscriptAt - let lastText = self.lastTranscript - self.silenceTask = Task { [weak self] in - guard let self else { return } - try? await Task.sleep(nanoseconds: UInt64(self.silenceWindow * 1_000_000_000)) - guard !Task.isCancelled else { return } - guard !self.isStopping, !self.holdingAfterDetect else { return } - guard let lastSeenAt, let lastText else { return } - guard self.lastTranscriptAt == lastSeenAt, self.lastTranscript == lastText else { return } - guard let match = self.textOnlyFallbackMatch( - transcript: lastText, - triggers: triggers, - config: WakeWordGateConfig(triggers: triggers)) else { return } - self.holdingAfterDetect = true - self.detectedText = match.command - self.logger.info("voice wake detected (test, silence) (len=\(match.command.count))") - await MainActor.run { AppStateStore.shared.triggerVoiceEars(ttl: nil) } - self.stop() - await MainActor.run { - AppStateStore.shared.stopVoiceEars() - onUpdate(.detected(match.command)) - } - } - } - - private func configureSession(preferredMicID: String?) { - _ = preferredMicID - } - - private func logInputSelection(preferredMicID: String?) { - let preferred = (preferredMicID?.isEmpty == false) ? preferredMicID! : "system-default" - self.logger.info( - "voicewake test input preferred=\(preferred, privacy: .public) " + - "\(AudioInputDeviceObserver.defaultInputDeviceSummary(), privacy: .public)") - } - - private nonisolated static func ensurePermissions() async throws -> Bool { - let speechStatus = SFSpeechRecognizer.authorizationStatus() - if speechStatus == .notDetermined { - let granted = await withCheckedContinuation { continuation in - SFSpeechRecognizer.requestAuthorization { status in - continuation.resume(returning: status == .authorized) - } - } - guard granted else { return false } - } else if speechStatus != .authorized { - return false - } - - let micStatus = AVCaptureDevice.authorizationStatus(for: .audio) - switch micStatus { - case .authorized: return true - - case .notDetermined: - return await withCheckedContinuation { continuation in - AVCaptureDevice.requestAccess(for: .audio) { granted in - continuation.resume(returning: granted) - } - } - - default: - return false - } - } - - private static var hasPrivacyStrings: Bool { - let speech = Bundle.main.object(forInfoDictionaryKey: "NSSpeechRecognitionUsageDescription") as? String - let mic = Bundle.main.object(forInfoDictionaryKey: "NSMicrophoneUsageDescription") as? String - return speech?.isEmpty == false && mic?.isEmpty == false - } -} - -extension VoiceWakeTester: @unchecked Sendable {} diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeTextUtils.swift b/apps/macos/Sources/OpenClaw/VoiceWakeTextUtils.swift deleted file mode 100644 index 9311765ad5c..00000000000 --- a/apps/macos/Sources/OpenClaw/VoiceWakeTextUtils.swift +++ /dev/null @@ -1,48 +0,0 @@ -import Foundation -import SwabbleKit - -enum VoiceWakeTextUtils { - private static let whitespaceAndPunctuation = CharacterSet.whitespacesAndNewlines - .union(.punctuationCharacters) - typealias TrimWake = (String, [String]) -> String - - static func normalizeToken(_ token: String) -> String { - token - .trimmingCharacters(in: self.whitespaceAndPunctuation) - .lowercased() - } - - static func startsWithTrigger(transcript: String, triggers: [String]) -> Bool { - let tokens = transcript - .split(whereSeparator: { $0.isWhitespace }) - .map { self.normalizeToken(String($0)) } - .filter { !$0.isEmpty } - guard !tokens.isEmpty else { return false } - for trigger in triggers { - let triggerTokens = trigger - .split(whereSeparator: { $0.isWhitespace }) - .map { self.normalizeToken(String($0)) } - .filter { !$0.isEmpty } - guard !triggerTokens.isEmpty, tokens.count >= triggerTokens.count else { continue } - if zip(triggerTokens, tokens.prefix(triggerTokens.count)).allSatisfy({ $0 == $1 }) { - return true - } - } - return false - } - - static func textOnlyCommand( - transcript: String, - triggers: [String], - minCommandLength: Int, - trimWake: TrimWake) -> String? - { - guard !transcript.isEmpty else { return nil } - guard !self.normalizeToken(transcript).isEmpty else { return nil } - guard WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers) else { return nil } - guard self.startsWithTrigger(transcript: transcript, triggers: triggers) else { return nil } - let trimmed = trimWake(transcript, triggers) - guard trimmed.count >= minCommandLength else { return nil } - return trimmed - } -} diff --git a/apps/macos/Sources/OpenClaw/WebChatManager.swift b/apps/macos/Sources/OpenClaw/WebChatManager.swift deleted file mode 100644 index 61d1b4d39b7..00000000000 --- a/apps/macos/Sources/OpenClaw/WebChatManager.swift +++ /dev/null @@ -1,127 +0,0 @@ -import AppKit -import Foundation - -/// A borderless panel that can still accept key focus (needed for typing). -final class WebChatPanel: NSPanel { - override var canBecomeKey: Bool { - true - } - - override var canBecomeMain: Bool { - true - } -} - -enum WebChatPresentation { - case window - case panel(anchorProvider: () -> NSRect?) - - var isPanel: Bool { - if case .panel = self { return true } - return false - } -} - -@MainActor -final class WebChatManager { - static let shared = WebChatManager() - - private var windowController: WebChatSwiftUIWindowController? - private var windowSessionKey: String? - private var panelController: WebChatSwiftUIWindowController? - private var panelSessionKey: String? - private var cachedPreferredSessionKey: String? - - var onPanelVisibilityChanged: ((Bool) -> Void)? - - var activeSessionKey: String? { - self.panelSessionKey ?? self.windowSessionKey - } - - func show(sessionKey: String) { - self.closePanel() - if let controller = self.windowController { - if self.windowSessionKey == sessionKey { - controller.show() - return - } - - controller.close() - self.windowController = nil - self.windowSessionKey = nil - } - let controller = WebChatSwiftUIWindowController(sessionKey: sessionKey, presentation: .window) - controller.onVisibilityChanged = { [weak self] visible in - self?.onPanelVisibilityChanged?(visible) - } - self.windowController = controller - self.windowSessionKey = sessionKey - controller.show() - } - - func togglePanel(sessionKey: String, anchorProvider: @escaping () -> NSRect?) { - if let controller = self.panelController { - if self.panelSessionKey != sessionKey { - controller.close() - self.panelController = nil - self.panelSessionKey = nil - } else { - if controller.isVisible { - controller.close() - } else { - controller.presentAnchored(anchorProvider: anchorProvider) - } - return - } - } - - let controller = WebChatSwiftUIWindowController( - sessionKey: sessionKey, - presentation: .panel(anchorProvider: anchorProvider)) - controller.onClosed = { [weak self] in - self?.panelHidden() - } - controller.onVisibilityChanged = { [weak self] visible in - self?.onPanelVisibilityChanged?(visible) - } - self.panelController = controller - self.panelSessionKey = sessionKey - controller.presentAnchored(anchorProvider: anchorProvider) - } - - func closePanel() { - self.panelController?.close() - } - - func preferredSessionKey() async -> String { - if let cachedPreferredSessionKey { return cachedPreferredSessionKey } - let key = await GatewayConnection.shared.mainSessionKey() - self.cachedPreferredSessionKey = key - return key - } - - func resetTunnels() { - self.windowController?.close() - self.windowController = nil - self.windowSessionKey = nil - self.panelController?.close() - self.panelController = nil - self.panelSessionKey = nil - self.cachedPreferredSessionKey = nil - } - - func close() { - self.windowController?.close() - self.windowController = nil - self.windowSessionKey = nil - self.panelController?.close() - self.panelController = nil - self.panelSessionKey = nil - self.cachedPreferredSessionKey = nil - } - - private func panelHidden() { - self.onPanelVisibilityChanged?(false) - // Keep panel controller cached so reopening doesn't re-bootstrap. - } -} diff --git a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift deleted file mode 100644 index 5b866304b09..00000000000 --- a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift +++ /dev/null @@ -1,374 +0,0 @@ -import AppKit -import Foundation -import OpenClawChatUI -import OpenClawKit -import OpenClawProtocol -import OSLog -import QuartzCore -import SwiftUI - -private let webChatSwiftLogger = Logger(subsystem: "ai.openclaw", category: "WebChatSwiftUI") - -private enum WebChatSwiftUILayout { - static let windowSize = NSSize(width: 500, height: 840) - static let panelSize = NSSize(width: 480, height: 640) - static let windowMinSize = NSSize(width: 480, height: 360) - static let anchorPadding: CGFloat = 8 -} - -struct MacGatewayChatTransport: OpenClawChatTransport, Sendable { - func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload { - try await GatewayConnection.shared.chatHistory(sessionKey: sessionKey) - } - - func abortRun(sessionKey: String, runId: String) async throws { - _ = try await GatewayConnection.shared.request( - method: "chat.abort", - params: [ - "sessionKey": AnyCodable(sessionKey), - "runId": AnyCodable(runId), - ], - timeoutMs: 10000) - } - - func listSessions(limit: Int?) async throws -> OpenClawChatSessionsListResponse { - var params: [String: AnyCodable] = [ - "includeGlobal": AnyCodable(true), - "includeUnknown": AnyCodable(false), - ] - if let limit { - params["limit"] = AnyCodable(limit) - } - let data = try await GatewayConnection.shared.request( - method: "sessions.list", - params: params, - timeoutMs: 15000) - return try JSONDecoder().decode(OpenClawChatSessionsListResponse.self, from: data) - } - - func sendMessage( - sessionKey: String, - message: String, - thinking: String, - idempotencyKey: String, - attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse - { - try await GatewayConnection.shared.chatSend( - sessionKey: sessionKey, - message: message, - thinking: thinking, - idempotencyKey: idempotencyKey, - attachments: attachments) - } - - func requestHealth(timeoutMs: Int) async throws -> Bool { - try await GatewayConnection.shared.healthOK(timeoutMs: timeoutMs) - } - - func events() -> AsyncStream { - AsyncStream { continuation in - let task = Task { - do { - try await GatewayConnection.shared.refresh() - } catch { - webChatSwiftLogger.error("gateway refresh failed \(error.localizedDescription, privacy: .public)") - } - - let stream = await GatewayConnection.shared.subscribe() - for await push in stream { - if Task.isCancelled { return } - if let evt = Self.mapPushToTransportEvent(push) { - continuation.yield(evt) - } - } - } - - continuation.onTermination = { @Sendable _ in - task.cancel() - } - } - } - - static func mapPushToTransportEvent(_ push: GatewayPush) -> OpenClawChatTransportEvent? { - switch push { - case let .snapshot(hello): - let ok = (try? JSONDecoder().decode( - OpenClawGatewayHealthOK.self, - from: JSONEncoder().encode(hello.snapshot.health)))?.ok ?? true - return .health(ok: ok) - - case let .event(evt): - switch evt.event { - case "health": - guard let payload = evt.payload else { return nil } - let ok = (try? JSONDecoder().decode( - OpenClawGatewayHealthOK.self, - from: JSONEncoder().encode(payload)))?.ok ?? true - return .health(ok: ok) - case "tick": - return .tick - case "chat": - guard let payload = evt.payload else { return nil } - guard let chat = try? JSONDecoder().decode( - OpenClawChatEventPayload.self, - from: JSONEncoder().encode(payload)) - else { - return nil - } - return .chat(chat) - case "agent": - guard let payload = evt.payload else { return nil } - guard let agent = try? JSONDecoder().decode( - OpenClawAgentEventPayload.self, - from: JSONEncoder().encode(payload)) - else { - return nil - } - return .agent(agent) - default: - return nil - } - - case .seqGap: - return .seqGap - } - } -} - -// MARK: - Window controller - -@MainActor -final class WebChatSwiftUIWindowController { - private let presentation: WebChatPresentation - private let sessionKey: String - private let hosting: NSHostingController - private let contentController: NSViewController - private var window: NSWindow? - private var dismissMonitor: Any? - var onClosed: (() -> Void)? - var onVisibilityChanged: ((Bool) -> Void)? - - convenience init(sessionKey: String, presentation: WebChatPresentation) { - self.init(sessionKey: sessionKey, presentation: presentation, transport: MacGatewayChatTransport()) - } - - init(sessionKey: String, presentation: WebChatPresentation, transport: any OpenClawChatTransport) { - self.sessionKey = sessionKey - self.presentation = presentation - let vm = OpenClawChatViewModel(sessionKey: sessionKey, transport: transport) - let accent = Self.color(fromHex: AppStateStore.shared.seamColorHex) - self.hosting = NSHostingController(rootView: OpenClawChatView( - viewModel: vm, - showsSessionSwitcher: true, - userAccent: accent)) - self.contentController = Self.makeContentController(for: presentation, hosting: self.hosting) - self.window = Self.makeWindow(for: presentation, contentViewController: self.contentController) - } - - deinit {} - - var isVisible: Bool { - self.window?.isVisible ?? false - } - - func show() { - guard let window else { return } - self.ensureWindowSize() - window.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - self.onVisibilityChanged?(true) - } - - func presentAnchored(anchorProvider: () -> NSRect?) { - guard case .panel = self.presentation, let window else { return } - self.installDismissMonitor() - let target = self.reposition(using: anchorProvider) - - if !self.isVisible { - let start = target.offsetBy(dx: 0, dy: 8) - window.setFrame(start, display: true) - window.alphaValue = 0 - window.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.18 - context.timingFunction = CAMediaTimingFunction(name: .easeOut) - window.animator().setFrame(target, display: true) - window.animator().alphaValue = 1 - } - } else { - window.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - } - - self.onVisibilityChanged?(true) - } - - func close() { - self.window?.orderOut(nil) - self.onVisibilityChanged?(false) - self.onClosed?() - self.removeDismissMonitor() - } - - @discardableResult - private func reposition(using anchorProvider: () -> NSRect?) -> NSRect { - guard let window else { return .zero } - guard let anchor = anchorProvider() else { - let frame = WindowPlacement.topRightFrame( - size: WebChatSwiftUILayout.panelSize, - padding: WebChatSwiftUILayout.anchorPadding) - window.setFrame(frame, display: false) - return frame - } - let screen = NSScreen.screens.first { screen in - screen.frame.contains(anchor.origin) || screen.frame.contains(NSPoint(x: anchor.midX, y: anchor.midY)) - } ?? NSScreen.main - let bounds = (screen?.visibleFrame ?? .zero).insetBy( - dx: WebChatSwiftUILayout.anchorPadding, - dy: WebChatSwiftUILayout.anchorPadding) - let frame = WindowPlacement.anchoredBelowFrame( - size: WebChatSwiftUILayout.panelSize, - anchor: anchor, - padding: WebChatSwiftUILayout.anchorPadding, - in: bounds) - window.setFrame(frame, display: false) - return frame - } - - private func installDismissMonitor() { - if ProcessInfo.processInfo.isRunningTests { return } - guard self.dismissMonitor == nil, self.window != nil else { return } - self.dismissMonitor = NSEvent.addGlobalMonitorForEvents( - matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown]) - { [weak self] _ in - guard let self, let win = self.window else { return } - let pt = NSEvent.mouseLocation - if !win.frame.contains(pt) { - self.close() - } - } - } - - private func removeDismissMonitor() { - if let monitor = self.dismissMonitor { - NSEvent.removeMonitor(monitor) - self.dismissMonitor = nil - } - } - - private static func makeWindow( - for presentation: WebChatPresentation, - contentViewController: NSViewController) -> NSWindow - { - switch presentation { - case .window: - let window = NSWindow( - contentRect: NSRect(origin: .zero, size: WebChatSwiftUILayout.windowSize), - styleMask: [.titled, .closable, .resizable, .miniaturizable], - backing: .buffered, - defer: false) - window.title = "OpenClaw Chat" - window.contentViewController = contentViewController - window.isReleasedWhenClosed = false - window.titleVisibility = .visible - window.titlebarAppearsTransparent = false - window.backgroundColor = .clear - window.isOpaque = false - window.center() - WindowPlacement.ensureOnScreen(window: window, defaultSize: WebChatSwiftUILayout.windowSize) - window.minSize = WebChatSwiftUILayout.windowMinSize - window.contentView?.wantsLayer = true - window.contentView?.layer?.backgroundColor = NSColor.clear.cgColor - return window - case .panel: - let panel = WebChatPanel( - contentRect: NSRect(origin: .zero, size: WebChatSwiftUILayout.panelSize), - styleMask: [.borderless], - backing: .buffered, - defer: false) - panel.level = .statusBar - panel.hidesOnDeactivate = true - panel.hasShadow = true - panel.isMovable = false - panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] - panel.titleVisibility = .hidden - panel.titlebarAppearsTransparent = true - panel.backgroundColor = .clear - panel.isOpaque = false - panel.contentViewController = contentViewController - panel.becomesKeyOnlyIfNeeded = true - panel.contentView?.wantsLayer = true - panel.contentView?.layer?.backgroundColor = NSColor.clear.cgColor - panel.setFrame( - WindowPlacement.topRightFrame( - size: WebChatSwiftUILayout.panelSize, - padding: WebChatSwiftUILayout.anchorPadding), - display: false) - return panel - } - } - - private static func makeContentController( - for presentation: WebChatPresentation, - hosting: NSHostingController) -> NSViewController - { - let controller = NSViewController() - let effectView = NSVisualEffectView() - effectView.material = .sidebar - effectView.blendingMode = .behindWindow - effectView.state = .active - effectView.wantsLayer = true - effectView.layer?.cornerCurve = .continuous - let cornerRadius: CGFloat = switch presentation { - case .panel: - 16 - case .window: - 0 - } - effectView.layer?.cornerRadius = cornerRadius - effectView.layer?.masksToBounds = true - - effectView.translatesAutoresizingMaskIntoConstraints = true - effectView.autoresizingMask = [.width, .height] - let rootView = effectView - - hosting.view.translatesAutoresizingMaskIntoConstraints = false - hosting.view.wantsLayer = true - hosting.view.layer?.backgroundColor = NSColor.clear.cgColor - - controller.addChild(hosting) - effectView.addSubview(hosting.view) - controller.view = rootView - - NSLayoutConstraint.activate([ - hosting.view.leadingAnchor.constraint(equalTo: effectView.leadingAnchor), - hosting.view.trailingAnchor.constraint(equalTo: effectView.trailingAnchor), - hosting.view.topAnchor.constraint(equalTo: effectView.topAnchor), - hosting.view.bottomAnchor.constraint(equalTo: effectView.bottomAnchor), - ]) - - return controller - } - - private func ensureWindowSize() { - guard case .window = self.presentation, let window else { return } - let current = window.frame.size - let min = WebChatSwiftUILayout.windowMinSize - if current.width < min.width || current.height < min.height { - let frame = WindowPlacement.centeredFrame(size: WebChatSwiftUILayout.windowSize) - window.setFrame(frame, display: false) - } - } - - private static func color(fromHex raw: String?) -> Color? { - let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed - guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil } - let r = Double((value >> 16) & 0xFF) / 255.0 - let g = Double((value >> 8) & 0xFF) / 255.0 - let b = Double(value & 0xFF) / 255.0 - return Color(red: r, green: g, blue: b) - } -} diff --git a/apps/macos/Sources/OpenClaw/WindowPlacement.swift b/apps/macos/Sources/OpenClaw/WindowPlacement.swift deleted file mode 100644 index a088dd743b3..00000000000 --- a/apps/macos/Sources/OpenClaw/WindowPlacement.swift +++ /dev/null @@ -1,84 +0,0 @@ -import AppKit - -@MainActor -enum WindowPlacement { - static func centeredFrame(size: NSSize, on screen: NSScreen? = NSScreen.main) -> NSRect { - let bounds = (screen?.visibleFrame ?? NSScreen.screens.first?.visibleFrame ?? .zero) - return self.centeredFrame(size: size, in: bounds) - } - - static func topRightFrame( - size: NSSize, - padding: CGFloat, - on screen: NSScreen? = NSScreen.main) -> NSRect - { - let bounds = (screen?.visibleFrame ?? NSScreen.screens.first?.visibleFrame ?? .zero) - return self.topRightFrame(size: size, padding: padding, in: bounds) - } - - static func centeredFrame(size: NSSize, in bounds: NSRect) -> NSRect { - if bounds == .zero { - return NSRect(origin: .zero, size: size) - } - - let clampedWidth = min(size.width, bounds.width) - let clampedHeight = min(size.height, bounds.height) - - let x = round(bounds.minX + (bounds.width - clampedWidth) / 2) - let y = round(bounds.minY + (bounds.height - clampedHeight) / 2) - return NSRect(x: x, y: y, width: clampedWidth, height: clampedHeight) - } - - static func topRightFrame(size: NSSize, padding: CGFloat, in bounds: NSRect) -> NSRect { - if bounds == .zero { - return NSRect(origin: .zero, size: size) - } - - let clampedWidth = min(size.width, bounds.width) - let clampedHeight = min(size.height, bounds.height) - - let x = round(bounds.maxX - clampedWidth - padding) - let y = round(bounds.maxY - clampedHeight - padding) - return NSRect(x: x, y: y, width: clampedWidth, height: clampedHeight) - } - - static func anchoredBelowFrame(size: NSSize, anchor: NSRect, padding: CGFloat, in bounds: NSRect) -> NSRect { - if bounds == .zero { - let x = round(anchor.midX - size.width / 2) - let y = round(anchor.minY - size.height - padding) - return NSRect(x: x, y: y, width: size.width, height: size.height) - } - - let clampedWidth = min(size.width, bounds.width) - let clampedHeight = min(size.height, bounds.height) - - let desiredX = round(anchor.midX - clampedWidth / 2) - let desiredY = round(anchor.minY - clampedHeight - padding) - - let maxX = bounds.maxX - clampedWidth - let maxY = bounds.maxY - clampedHeight - - let x = maxX >= bounds.minX ? min(max(desiredX, bounds.minX), maxX) : bounds.minX - let y = maxY >= bounds.minY ? min(max(desiredY, bounds.minY), maxY) : bounds.minY - - return NSRect(x: x, y: y, width: clampedWidth, height: clampedHeight) - } - - static func ensureOnScreen( - window: NSWindow, - defaultSize: NSSize, - fallback: ((NSScreen?) -> NSRect)? = nil) - { - let frame = window.frame - let targetScreens = NSScreen.screens.isEmpty ? [NSScreen.main].compactMap(\.self) : NSScreen.screens - let isVisibleSomewhere = targetScreens.contains { screen in - frame.intersects(screen.visibleFrame.insetBy(dx: 12, dy: 12)) - } - - if isVisibleSomewhere { return } - - let screen = NSScreen.main ?? targetScreens.first - let next = fallback?(screen) ?? self.centeredFrame(size: defaultSize, on: screen) - window.setFrame(next, display: false) - } -} diff --git a/apps/macos/Sources/OpenClaw/WorkActivityStore.swift b/apps/macos/Sources/OpenClaw/WorkActivityStore.swift deleted file mode 100644 index 77d62963030..00000000000 --- a/apps/macos/Sources/OpenClaw/WorkActivityStore.swift +++ /dev/null @@ -1,262 +0,0 @@ -import Foundation -import Observation -import OpenClawKit -import OpenClawProtocol -import SwiftUI - -@MainActor -@Observable -final class WorkActivityStore { - static let shared = WorkActivityStore() - - struct Activity: Equatable { - let sessionKey: String - let role: SessionRole - let kind: ActivityKind - let label: String - let startedAt: Date - var lastUpdate: Date - } - - private(set) var current: Activity? - private(set) var iconState: IconState = .idle - private(set) var lastToolLabel: String? - private(set) var lastToolUpdatedAt: Date? - - private var jobs: [String: Activity] = [:] - private var tools: [String: Activity] = [:] - private var currentSessionKey: String? - private var toolSeqBySession: [String: Int] = [:] - - private var mainSessionKeyStorage = "main" - private let toolResultGrace: TimeInterval = 2.0 - - var mainSessionKey: String { - self.mainSessionKeyStorage - } - - func handleJob(sessionKey: String, state: String) { - let isStart = state.lowercased() == "started" || state.lowercased() == "streaming" - if isStart { - let activity = Activity( - sessionKey: sessionKey, - role: self.role(for: sessionKey), - kind: .job, - label: "job", - startedAt: Date(), - lastUpdate: Date()) - self.setJobActive(activity) - } else { - // Job ended (done/error/aborted/etc). Clear everything for this session. - self.clearTool(sessionKey: sessionKey) - self.clearJob(sessionKey: sessionKey) - } - } - - func handleTool( - sessionKey: String, - phase: String, - name: String?, - meta: String?, - args: [String: OpenClawProtocol.AnyCodable]?) - { - let toolKind = Self.mapToolKind(name) - let label = Self.buildLabel(name: name, meta: meta, args: args) - if phase.lowercased() == "start" { - self.lastToolLabel = label - self.lastToolUpdatedAt = Date() - self.toolSeqBySession[sessionKey, default: 0] += 1 - let activity = Activity( - sessionKey: sessionKey, - role: self.role(for: sessionKey), - kind: .tool(toolKind), - label: label, - startedAt: Date(), - lastUpdate: Date()) - self.setToolActive(activity) - } else { - // Delay removal slightly to avoid flicker on rapid result/start bursts. - let key = sessionKey - let seq = self.toolSeqBySession[key, default: 0] - Task { [weak self] in - let nsDelay = UInt64((self?.toolResultGrace ?? 0) * 1_000_000_000) - try? await Task.sleep(nanoseconds: nsDelay) - await MainActor.run { - guard let self else { return } - guard self.toolSeqBySession[key, default: 0] == seq else { return } - self.lastToolUpdatedAt = Date() - self.clearTool(sessionKey: key) - } - } - } - } - - func resolveIconState(override selection: IconOverrideSelection) { - switch selection { - case .system: - self.iconState = self.deriveIconState() - case .idle: - self.iconState = .idle - default: - let base = selection.toIconState() - switch base { - case let .workingMain(kind), - let .workingOther(kind): - self.iconState = .overridden(kind) - case let .overridden(kind): - self.iconState = .overridden(kind) - case .idle: - self.iconState = .idle - } - } - } - - private func setJobActive(_ activity: Activity) { - self.jobs[activity.sessionKey] = activity - // Main session preempts immediately. - if activity.role == .main { - self.currentSessionKey = activity.sessionKey - } else if self.currentSessionKey == nil || !self.isActive(sessionKey: self.currentSessionKey!) { - self.currentSessionKey = activity.sessionKey - } - self.refreshDerivedState() - } - - private func setToolActive(_ activity: Activity) { - self.tools[activity.sessionKey] = activity - // Main session preempts immediately. - if activity.role == .main { - self.currentSessionKey = activity.sessionKey - } else if self.currentSessionKey == nil || !self.isActive(sessionKey: self.currentSessionKey!) { - self.currentSessionKey = activity.sessionKey - } - self.refreshDerivedState() - } - - func setMainSessionKey(_ sessionKey: String) { - let trimmed = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - guard trimmed != self.mainSessionKeyStorage else { return } - self.mainSessionKeyStorage = trimmed - if let current = self.currentSessionKey, !self.isActive(sessionKey: current) { - self.pickNextSession() - } - self.refreshDerivedState() - } - - private func clearJob(sessionKey: String) { - guard self.jobs[sessionKey] != nil else { return } - self.jobs.removeValue(forKey: sessionKey) - - if self.currentSessionKey == sessionKey, !self.isActive(sessionKey: sessionKey) { - self.pickNextSession() - } - self.refreshDerivedState() - } - - private func clearTool(sessionKey: String) { - guard self.tools[sessionKey] != nil else { return } - self.tools.removeValue(forKey: sessionKey) - - if self.currentSessionKey == sessionKey, !self.isActive(sessionKey: sessionKey) { - self.pickNextSession() - } - self.refreshDerivedState() - } - - private func pickNextSession() { - // Prefer main if present. - if self.isActive(sessionKey: self.mainSessionKeyStorage) { - self.currentSessionKey = self.mainSessionKeyStorage - return - } - - // Otherwise, pick most recent by lastUpdate across job/tool. - let keys = Set(self.jobs.keys).union(self.tools.keys) - let next = keys.max(by: { self.lastUpdate(for: $0) < self.lastUpdate(for: $1) }) - self.currentSessionKey = next - } - - private func role(for sessionKey: String) -> SessionRole { - sessionKey == self.mainSessionKeyStorage ? .main : .other - } - - private func isActive(sessionKey: String) -> Bool { - self.jobs[sessionKey] != nil || self.tools[sessionKey] != nil - } - - private func lastUpdate(for sessionKey: String) -> Date { - max(self.jobs[sessionKey]?.lastUpdate ?? .distantPast, self.tools[sessionKey]?.lastUpdate ?? .distantPast) - } - - private func currentActivity(for sessionKey: String) -> Activity? { - // Prefer tool overlay if present, otherwise job. - self.tools[sessionKey] ?? self.jobs[sessionKey] - } - - private func refreshDerivedState() { - if let key = self.currentSessionKey, !self.isActive(sessionKey: key) { - self.currentSessionKey = nil - } - self.current = self.currentSessionKey.flatMap { self.currentActivity(for: $0) } - self.iconState = self.deriveIconState() - } - - private func deriveIconState() -> IconState { - guard let sessionKey = self.currentSessionKey, - let activity = self.currentActivity(for: sessionKey) - else { return .idle } - - switch activity.role { - case .main: return .workingMain(activity.kind) - case .other: return .workingOther(activity.kind) - } - } - - private static func mapToolKind(_ name: String?) -> ToolKind { - switch name?.lowercased() { - case "bash", "shell": .bash - case "read": .read - case "write": .write - case "edit": .edit - case "attach": .attach - default: .other - } - } - - private static func buildLabel( - name: String?, - meta: String?, - args: [String: OpenClawProtocol.AnyCodable]?) -> String - { - let wrappedArgs = self.wrapToolArgs(args) - let display = ToolDisplayRegistry.resolve(name: name ?? "tool", args: wrappedArgs, meta: meta) - if let detail = display.detailLine, !detail.isEmpty { - return "\(display.label): \(detail)" - } - - return display.label - } - - private static func wrapToolArgs(_ args: [String: OpenClawProtocol.AnyCodable]?) -> OpenClawKit.AnyCodable? { - guard let args else { return nil } - let converted: [String: Any] = args.mapValues { self.unwrapJSONValue($0.value) } - return OpenClawKit.AnyCodable(converted) - } - - private static func unwrapJSONValue(_ value: Any) -> Any { - if let dict = value as? [String: OpenClawProtocol.AnyCodable] { - return dict.mapValues { self.unwrapJSONValue($0.value) } - } - if let array = value as? [OpenClawProtocol.AnyCodable] { - return array.map { self.unwrapJSONValue($0.value) } - } - if let dict = value as? [String: Any] { - return dict.mapValues { self.unwrapJSONValue($0) } - } - if let array = value as? [Any] { - return array.map { self.unwrapJSONValue($0) } - } - return value - } -} diff --git a/apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift b/apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift deleted file mode 100644 index abd18efaa9a..00000000000 --- a/apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift +++ /dev/null @@ -1,682 +0,0 @@ -import Foundation -import Network -import Observation -import OpenClawKit -import OSLog - -@MainActor -@Observable -public final class GatewayDiscoveryModel { - public struct LocalIdentity: Equatable, Sendable { - public var hostTokens: Set - public var displayTokens: Set - - public init(hostTokens: Set, displayTokens: Set) { - self.hostTokens = hostTokens - self.displayTokens = displayTokens - } - } - - public struct DiscoveredGateway: Identifiable, Equatable, Sendable { - public var id: String { - self.stableID - } - - public var displayName: String - // Resolved service endpoint (SRV + A/AAAA). Used for routing; do not trust TXT for routing. - public var serviceHost: String? - public var servicePort: Int? - public var lanHost: String? - public var tailnetDns: String? - public var sshPort: Int - public var gatewayPort: Int? - public var cliPath: String? - public var stableID: String - public var debugID: String - public var isLocal: Bool - - public init( - displayName: String, - serviceHost: String? = nil, - servicePort: Int? = nil, - lanHost: String? = nil, - tailnetDns: String? = nil, - sshPort: Int, - gatewayPort: Int? = nil, - cliPath: String? = nil, - stableID: String, - debugID: String, - isLocal: Bool) - { - self.displayName = displayName - self.serviceHost = serviceHost - self.servicePort = servicePort - self.lanHost = lanHost - self.tailnetDns = tailnetDns - self.sshPort = sshPort - self.gatewayPort = gatewayPort - self.cliPath = cliPath - self.stableID = stableID - self.debugID = debugID - self.isLocal = isLocal - } - } - - public var gateways: [DiscoveredGateway] = [] - public var statusText: String = "Idle" - - private var browsers: [String: NWBrowser] = [:] - private var resultsByDomain: [String: Set] = [:] - private var gatewaysByDomain: [String: [DiscoveredGateway]] = [:] - private var statesByDomain: [String: NWBrowser.State] = [:] - private var localIdentity: LocalIdentity - private let localDisplayName: String? - private let filterLocalGateways: Bool - private var resolvedServiceByID: [String: ResolvedGatewayService] = [:] - private var pendingServiceResolvers: [String: GatewayServiceResolver] = [:] - private var wideAreaFallbackTask: Task? - private var wideAreaFallbackGateways: [DiscoveredGateway] = [] - private let logger = Logger(subsystem: "ai.openclaw", category: "gateway-discovery") - - public init( - localDisplayName: String? = nil, - filterLocalGateways: Bool = true) - { - self.localDisplayName = localDisplayName - self.filterLocalGateways = filterLocalGateways - self.localIdentity = Self.buildLocalIdentityFast(displayName: localDisplayName) - self.refreshLocalIdentity() - } - - public func start() { - if !self.browsers.isEmpty { return } - - for domain in OpenClawBonjour.gatewayServiceDomains { - let params = NWParameters.tcp - params.includePeerToPeer = true - let browser = NWBrowser( - for: .bonjour(type: OpenClawBonjour.gatewayServiceType, domain: domain), - using: params) - - browser.stateUpdateHandler = { [weak self] state in - Task { @MainActor in - guard let self else { return } - self.statesByDomain[domain] = state - self.updateStatusText() - } - } - - browser.browseResultsChangedHandler = { [weak self] results, _ in - Task { @MainActor in - guard let self else { return } - self.resultsByDomain[domain] = results - self.updateGateways(for: domain) - self.recomputeGateways() - } - } - - self.browsers[domain] = browser - browser.start(queue: DispatchQueue(label: "ai.openclaw.macos.gateway-discovery.\(domain)")) - } - - self.scheduleWideAreaFallback() - } - - public func refreshWideAreaFallbackNow(timeoutSeconds: TimeInterval = 5.0) { - guard let domain = OpenClawBonjour.wideAreaGatewayServiceDomain else { return } - Task.detached(priority: .utility) { [weak self] in - guard let self else { return } - let beacons = WideAreaGatewayDiscovery.discover(timeoutSeconds: timeoutSeconds) - await MainActor.run { [weak self] in - guard let self else { return } - self.wideAreaFallbackGateways = self.mapWideAreaBeacons(beacons, domain: domain) - self.recomputeGateways() - } - } - } - - public func stop() { - for browser in self.browsers.values { - browser.cancel() - } - self.browsers = [:] - self.resultsByDomain = [:] - self.gatewaysByDomain = [:] - self.statesByDomain = [:] - self.resolvedServiceByID = [:] - self.pendingServiceResolvers.values.forEach { $0.cancel() } - self.pendingServiceResolvers = [:] - self.wideAreaFallbackTask?.cancel() - self.wideAreaFallbackTask = nil - self.wideAreaFallbackGateways = [] - self.gateways = [] - self.statusText = "Stopped" - } - - private func mapWideAreaBeacons(_ beacons: [WideAreaGatewayBeacon], domain: String) -> [DiscoveredGateway] { - beacons.map { beacon in - let stableID = "wide-area|\(domain)|\(beacon.instanceName)" - let isLocal = Self.isLocalGateway( - lanHost: beacon.lanHost, - tailnetDns: beacon.tailnetDns, - displayName: beacon.displayName, - serviceName: beacon.instanceName, - local: self.localIdentity) - return DiscoveredGateway( - displayName: beacon.displayName, - serviceHost: beacon.host, - servicePort: beacon.port, - lanHost: beacon.lanHost, - tailnetDns: beacon.tailnetDns, - sshPort: beacon.sshPort ?? 22, - gatewayPort: beacon.gatewayPort, - cliPath: beacon.cliPath, - stableID: stableID, - debugID: "\(beacon.instanceName)@\(beacon.host):\(beacon.port)", - isLocal: isLocal) - } - } - - private func recomputeGateways() { - let primary = self.sortedDeduped(gateways: self.gatewaysByDomain.values.flatMap(\.self)) - let primaryFiltered = self.filterLocalGateways ? primary.filter { !$0.isLocal } : primary - if !primaryFiltered.isEmpty { - self.gateways = primaryFiltered - return - } - - // Bonjour can return only "local" results for the wide-area domain (or no results at all), - // which makes onboarding look empty even though Tailscale DNS-SD can already see gateways. - guard !self.wideAreaFallbackGateways.isEmpty else { - self.gateways = primaryFiltered - return - } - - let combined = self.sortedDeduped(gateways: primary + self.wideAreaFallbackGateways) - self.gateways = self.filterLocalGateways ? combined.filter { !$0.isLocal } : combined - } - - private func updateGateways(for domain: String) { - guard let results = self.resultsByDomain[domain] else { - self.gatewaysByDomain[domain] = [] - return - } - - self.gatewaysByDomain[domain] = results.compactMap { result -> DiscoveredGateway? in - guard case let .service(name, type, resultDomain, _) = result.endpoint else { return nil } - - let decodedName = BonjourEscapes.decode(name) - let stableID = GatewayEndpointID.stableID(result.endpoint) - let resolved = self.resolvedServiceByID[stableID] - let resolvedTXT = resolved?.txt ?? [:] - let txt = Self.txtDictionary(from: result).merging( - resolvedTXT, - uniquingKeysWith: { _, new in new }) - - let advertisedName = txt["displayName"] - .map(Self.prettifyInstanceName) - .flatMap { $0.isEmpty ? nil : $0 } - let prettyName = - advertisedName ?? Self.prettifyServiceName(decodedName) - - let parsedTXT = Self.parseGatewayTXT(txt) - - // Always attempt NetService resolution for the endpoint (host/port and TXT). - // TXT is unauthenticated; do not use it for routing. - if resolved == nil { - self.ensureServiceResolution( - stableID: stableID, - serviceName: name, - type: type, - domain: resultDomain) - } - - let isLocal = Self.isLocalGateway( - lanHost: parsedTXT.lanHost, - tailnetDns: parsedTXT.tailnetDns, - displayName: prettyName, - serviceName: decodedName, - local: self.localIdentity) - return DiscoveredGateway( - displayName: prettyName, - serviceHost: resolved?.host, - servicePort: resolved?.port, - lanHost: parsedTXT.lanHost, - tailnetDns: parsedTXT.tailnetDns, - sshPort: parsedTXT.sshPort, - gatewayPort: parsedTXT.gatewayPort, - cliPath: parsedTXT.cliPath, - stableID: stableID, - debugID: GatewayEndpointID.prettyDescription(result.endpoint), - isLocal: isLocal) - } - .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } - - if let wideAreaDomain = OpenClawBonjour.wideAreaGatewayServiceDomain, - domain == wideAreaDomain, - self.hasUsableWideAreaResults - { - self.wideAreaFallbackGateways = [] - } - } - - private func scheduleWideAreaFallback() { - guard let domain = OpenClawBonjour.wideAreaGatewayServiceDomain else { return } - if Self.isRunningTests { return } - guard self.wideAreaFallbackTask == nil else { return } - self.wideAreaFallbackTask = Task.detached(priority: .utility) { [weak self] in - guard let self else { return } - var attempt = 0 - let startedAt = Date() - while !Task.isCancelled, Date().timeIntervalSince(startedAt) < 35.0 { - let hasResults = await MainActor.run { - self.hasUsableWideAreaResults - } - if hasResults { return } - - // Wide-area discovery can be racy (Tailscale not yet up, DNS zone not - // published yet). Retry with a short backoff while onboarding is open. - let beacons = WideAreaGatewayDiscovery.discover(timeoutSeconds: 2.0) - if !beacons.isEmpty { - await MainActor.run { [weak self] in - guard let self else { return } - self.wideAreaFallbackGateways = self.mapWideAreaBeacons(beacons, domain: domain) - self.recomputeGateways() - } - return - } - - attempt += 1 - let backoff = min(8.0, 0.6 + (Double(attempt) * 0.7)) - try? await Task.sleep(nanoseconds: UInt64(backoff * 1_000_000_000)) - } - } - } - - private var hasUsableWideAreaResults: Bool { - guard let domain = OpenClawBonjour.wideAreaGatewayServiceDomain else { return false } - guard let gateways = self.gatewaysByDomain[domain], !gateways.isEmpty else { return false } - if !self.filterLocalGateways { return true } - return gateways.contains(where: { !$0.isLocal }) - } - - private func sortedDeduped(gateways: [DiscoveredGateway]) -> [DiscoveredGateway] { - var seen = Set() - let deduped = gateways.filter { gateway in - if seen.contains(gateway.stableID) { return false } - seen.insert(gateway.stableID) - return true - } - return deduped.sorted { - $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending - } - } - - private nonisolated static var isRunningTests: Bool { - // Keep discovery background work from running forever during SwiftPM test runs. - if Bundle.allBundles.contains(where: { $0.bundleURL.pathExtension == "xctest" }) { return true } - - let env = ProcessInfo.processInfo.environment - return env["XCTestConfigurationFilePath"] != nil - || env["XCTestBundlePath"] != nil - || env["XCTestSessionIdentifier"] != nil - } - - private func updateGatewaysForAllDomains() { - for domain in self.resultsByDomain.keys { - self.updateGateways(for: domain) - } - } - - private func updateStatusText() { - self.statusText = GatewayDiscoveryStatusText.make( - states: Array(self.statesByDomain.values), - hasBrowsers: !self.browsers.isEmpty) - } - - private static func txtDictionary(from result: NWBrowser.Result) -> [String: String] { - var merged: [String: String] = [:] - - if case let .bonjour(txt) = result.metadata { - merged.merge(txt.dictionary, uniquingKeysWith: { _, new in new }) - } - - if let endpointTxt = result.endpoint.txtRecord?.dictionary { - merged.merge(endpointTxt, uniquingKeysWith: { _, new in new }) - } - - return merged - } - - public struct GatewayTXT: Equatable { - public var lanHost: String? - public var tailnetDns: String? - public var sshPort: Int - public var gatewayPort: Int? - public var cliPath: String? - } - - public static func parseGatewayTXT(_ txt: [String: String]) -> GatewayTXT { - var lanHost: String? - var tailnetDns: String? - var sshPort = 22 - var gatewayPort: Int? - var cliPath: String? - - if let value = txt["lanHost"] { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - lanHost = trimmed.isEmpty ? nil : trimmed - } - if let value = txt["tailnetDns"] { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - tailnetDns = trimmed.isEmpty ? nil : trimmed - } - if let value = txt["sshPort"], - let parsed = Int(value.trimmingCharacters(in: .whitespacesAndNewlines)), - parsed > 0 - { - sshPort = parsed - } - if let value = txt["gatewayPort"], - let parsed = Int(value.trimmingCharacters(in: .whitespacesAndNewlines)), - parsed > 0 - { - gatewayPort = parsed - } - if let value = txt["cliPath"] { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - cliPath = trimmed.isEmpty ? nil : trimmed - } - - return GatewayTXT( - lanHost: lanHost, - tailnetDns: tailnetDns, - sshPort: sshPort, - gatewayPort: gatewayPort, - cliPath: cliPath) - } - - public static func buildSSHTarget(user: String, host: String, port: Int) -> String { - var target = "\(user)@\(host)" - if port != 22 { - target += ":\(port)" - } - return target - } - - private func ensureServiceResolution( - stableID: String, - serviceName: String, - type: String, - domain: String) - { - guard self.resolvedServiceByID[stableID] == nil else { return } - guard self.pendingServiceResolvers[stableID] == nil else { return } - - let resolver = GatewayServiceResolver( - name: serviceName, - type: type, - domain: domain, - logger: self.logger) - { [weak self] result in - Task { @MainActor in - guard let self else { return } - self.pendingServiceResolvers[stableID] = nil - switch result { - case let .success(resolved): - self.resolvedServiceByID[stableID] = resolved - self.updateGatewaysForAllDomains() - self.recomputeGateways() - case .failure: - break - } - } - } - - self.pendingServiceResolvers[stableID] = resolver - resolver.start() - } - - private nonisolated static func prettifyInstanceName(_ decodedName: String) -> String { - let normalized = decodedName.split(whereSeparator: \.isWhitespace).joined(separator: " ") - let stripped = normalized.replacingOccurrences(of: " (OpenClaw)", with: "") - .replacingOccurrences(of: #"\s+\(\d+\)$"#, with: "", options: .regularExpression) - return stripped.trimmingCharacters(in: .whitespacesAndNewlines) - } - - private nonisolated static func prettifyServiceName(_ decodedName: String) -> String { - let normalized = Self.prettifyInstanceName(decodedName) - var cleaned = normalized.replacingOccurrences(of: #"\s*-?gateway$"#, with: "", options: .regularExpression) - cleaned = cleaned - .replacingOccurrences(of: "_", with: " ") - .replacingOccurrences(of: "-", with: " ") - .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) - .trimmingCharacters(in: .whitespacesAndNewlines) - if cleaned.isEmpty { - cleaned = normalized - } - let words = cleaned.split(separator: " ") - let titled = words.map { word -> String in - let lower = word.lowercased() - guard let first = lower.first else { return "" } - return String(first).uppercased() + lower.dropFirst() - }.joined(separator: " ") - return titled.isEmpty ? normalized : titled - } - - public nonisolated static func isLocalGateway( - lanHost: String?, - tailnetDns: String?, - displayName: String?, - serviceName: String?, - local: LocalIdentity) -> Bool - { - if let host = normalizeHostToken(lanHost), - local.hostTokens.contains(host) - { - return true - } - if let host = normalizeHostToken(tailnetDns), - local.hostTokens.contains(host) - { - return true - } - if let name = normalizeDisplayToken(displayName), - local.displayTokens.contains(name) - { - return true - } - if let serviceHost = normalizeServiceHostToken(serviceName), - local.hostTokens.contains(serviceHost) - { - return true - } - return false - } - - private func refreshLocalIdentity() { - let fastIdentity = self.localIdentity - let displayName = self.localDisplayName - Task.detached(priority: .utility) { - let slowIdentity = Self.buildLocalIdentitySlow(displayName: displayName) - let merged = Self.mergeLocalIdentity(fast: fastIdentity, slow: slowIdentity) - await MainActor.run { [weak self] in - guard let self else { return } - guard self.localIdentity != merged else { return } - self.localIdentity = merged - self.recomputeGateways() - } - } - } - - private nonisolated static func mergeLocalIdentity( - fast: LocalIdentity, - slow: LocalIdentity) -> LocalIdentity - { - LocalIdentity( - hostTokens: fast.hostTokens.union(slow.hostTokens), - displayTokens: fast.displayTokens.union(slow.displayTokens)) - } - - private nonisolated static func buildLocalIdentityFast(displayName: String?) -> LocalIdentity { - var hostTokens: Set = [] - var displayTokens: Set = [] - - let hostName = ProcessInfo.processInfo.hostName - if let token = normalizeHostToken(hostName) { - hostTokens.insert(token) - } - - if let token = normalizeDisplayToken(displayName) { - displayTokens.insert(token) - } - - return LocalIdentity(hostTokens: hostTokens, displayTokens: displayTokens) - } - - private nonisolated static func buildLocalIdentitySlow(displayName: String?) -> LocalIdentity { - var hostTokens: Set = [] - var displayTokens: Set = [] - - if let host = Host.current().name, - let token = normalizeHostToken(host) - { - hostTokens.insert(token) - } - - if let token = normalizeDisplayToken(displayName) { - displayTokens.insert(token) - } - - if let token = normalizeDisplayToken(Host.current().localizedName) { - displayTokens.insert(token) - } - - return LocalIdentity(hostTokens: hostTokens, displayTokens: displayTokens) - } - - private nonisolated static func normalizeHostToken(_ raw: String?) -> String? { - guard let raw else { return nil } - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { return nil } - let lower = trimmed.lowercased() - let strippedTrailingDot = lower.hasSuffix(".") - ? String(lower.dropLast()) - : lower - let withoutLocal = strippedTrailingDot.hasSuffix(".local") - ? String(strippedTrailingDot.dropLast(6)) - : strippedTrailingDot - let firstLabel = withoutLocal.split(separator: ".").first.map(String.init) - let token = (firstLabel ?? withoutLocal).trimmingCharacters(in: .whitespacesAndNewlines) - return token.isEmpty ? nil : token - } - - private nonisolated static func normalizeDisplayToken(_ raw: String?) -> String? { - guard let raw else { return nil } - let prettified = Self.prettifyInstanceName(raw) - let trimmed = prettified.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { return nil } - return trimmed.lowercased() - } - - private nonisolated static func normalizeServiceHostToken(_ raw: String?) -> String? { - guard let raw else { return nil } - let prettified = Self.prettifyInstanceName(raw) - let strippedGateway = prettified.replacingOccurrences( - of: #"\s*-?\s*gateway$"#, - with: "", - options: .regularExpression) - return self.normalizeHostToken(strippedGateway) - } -} - -struct ResolvedGatewayService: Equatable, Sendable { - var txt: [String: String] - var host: String? - var port: Int? -} - -final class GatewayServiceResolver: NSObject, NetServiceDelegate { - private let service: NetService - private let completion: (Result) -> Void - private let logger: Logger - private var didFinish = false - - init( - name: String, - type: String, - domain: String, - logger: Logger, - completion: @escaping (Result) -> Void) - { - self.service = NetService(domain: domain, type: type, name: name) - self.completion = completion - self.logger = logger - super.init() - self.service.delegate = self - } - - func start(timeout: TimeInterval = 2.0) { - self.service.schedule(in: .main, forMode: .common) - self.service.resolve(withTimeout: timeout) - } - - func cancel() { - self.finish(result: .failure(GatewayServiceResolverError.cancelled)) - } - - func netServiceDidResolveAddress(_ sender: NetService) { - let txt = Self.decodeTXT(sender.txtRecordData()) - let host = Self.normalizeHost(sender.hostName) - let port = sender.port > 0 ? sender.port : nil - if !txt.isEmpty { - let payload = self.formatTXT(txt) - self.logger.debug( - "discovery: resolved TXT for \(sender.name, privacy: .public): \(payload, privacy: .public)") - } - let resolved = ResolvedGatewayService(txt: txt, host: host, port: port) - self.finish(result: .success(resolved)) - } - - func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) { - self.finish(result: .failure(GatewayServiceResolverError.resolveFailed(errorDict))) - } - - private func finish(result: Result) { - guard !self.didFinish else { return } - self.didFinish = true - self.service.stop() - self.service.remove(from: .main, forMode: .common) - self.completion(result) - } - - private static func decodeTXT(_ data: Data?) -> [String: String] { - guard let data else { return [:] } - let dict = NetService.dictionary(fromTXTRecord: data) - var out: [String: String] = [:] - out.reserveCapacity(dict.count) - for (key, value) in dict { - if let str = String(data: value, encoding: .utf8) { - out[key] = str - } - } - return out - } - - private static func normalizeHost(_ raw: String?) -> String? { - let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if trimmed.isEmpty { return nil } - return trimmed.hasSuffix(".") ? String(trimmed.dropLast()) : trimmed - } - - private func formatTXT(_ txt: [String: String]) -> String { - txt.sorted(by: { $0.key < $1.key }) - .map { "\($0.key)=\($0.value)" } - .joined(separator: " ") - } -} - -enum GatewayServiceResolverError: Error { - case cancelled - case resolveFailed([String: NSNumber]) -} diff --git a/apps/macos/Sources/OpenClawDiscovery/TailscaleNetwork.swift b/apps/macos/Sources/OpenClawDiscovery/TailscaleNetwork.swift deleted file mode 100644 index ef78e6f400f..00000000000 --- a/apps/macos/Sources/OpenClawDiscovery/TailscaleNetwork.swift +++ /dev/null @@ -1,46 +0,0 @@ -import Darwin -import Foundation - -public enum TailscaleNetwork { - public static func isTailnetIPv4(_ address: String) -> Bool { - let parts = address.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] - return a == 100 && b >= 64 && b <= 127 - } - - public static func detectTailnetIPv4() -> String? { - var addrList: UnsafeMutablePointer? - guard getifaddrs(&addrList) == 0, let first = addrList else { return nil } - 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 ip } - } - - return nil - } -} diff --git a/apps/macos/Sources/OpenClawDiscovery/WideAreaGatewayDiscovery.swift b/apps/macos/Sources/OpenClawDiscovery/WideAreaGatewayDiscovery.swift deleted file mode 100644 index fea0aca91c1..00000000000 --- a/apps/macos/Sources/OpenClawDiscovery/WideAreaGatewayDiscovery.swift +++ /dev/null @@ -1,375 +0,0 @@ -import Foundation -import OpenClawKit - -struct WideAreaGatewayBeacon: Sendable, Equatable { - var instanceName: String - var displayName: String - var host: String - var port: Int - var lanHost: String? - var tailnetDns: String? - var gatewayPort: Int? - var sshPort: Int? - var cliPath: String? -} - -enum WideAreaGatewayDiscovery { - private static let maxCandidates = 40 - private static let digPath = "/usr/bin/dig" - private static let defaultTimeoutSeconds: TimeInterval = 0.2 - private static let nameserverProbeConcurrency = 6 - - struct DiscoveryContext: Sendable { - var tailscaleStatus: @Sendable () -> String? - var dig: @Sendable (_ args: [String], _ timeout: TimeInterval) -> String? - - static let live = DiscoveryContext( - tailscaleStatus: { readTailscaleStatus() }, - dig: { args, timeout in - runDig(args: args, timeout: timeout) - }) - } - - static func discover( - timeoutSeconds: TimeInterval = 2.0, - context: DiscoveryContext = .live) -> [WideAreaGatewayBeacon] - { - let startedAt = Date() - let remaining = { - timeoutSeconds - Date().timeIntervalSince(startedAt) - } - - guard let ips = collectTailnetIPv4s( - statusJson: context.tailscaleStatus()).nonEmpty else { return [] } - var candidates = Array(ips.prefix(self.maxCandidates)) - guard let nameserver = findNameserver( - candidates: &candidates, - remaining: remaining, - dig: context.dig) - else { - return [] - } - - guard let domain = OpenClawBonjour.wideAreaGatewayServiceDomain else { return [] } - let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: ".")) - let probeName = "_openclaw-gw._tcp.\(domainTrimmed)" - guard let ptrLines = context.dig( - ["+short", "+time=1", "+tries=1", "@\(nameserver)", probeName, "PTR"], - min(defaultTimeoutSeconds, remaining()))?.split(whereSeparator: \.isNewline), - !ptrLines.isEmpty - else { - return [] - } - - var beacons: [WideAreaGatewayBeacon] = [] - for raw in ptrLines { - let ptr = raw.trimmingCharacters(in: .whitespacesAndNewlines) - if ptr.isEmpty { continue } - let ptrName = ptr.hasSuffix(".") ? String(ptr.dropLast()) : ptr - let suffix = "._openclaw-gw._tcp.\(domainTrimmed)" - let rawInstanceName = ptrName.hasSuffix(suffix) - ? String(ptrName.dropLast(suffix.count)) - : ptrName - let instanceName = self.decodeDnsSdEscapes(rawInstanceName) - - guard let srv = context.dig( - ["+short", "+time=1", "+tries=1", "@\(nameserver)", ptrName, "SRV"], - min(defaultTimeoutSeconds, remaining())) - else { continue } - guard let (host, port) = parseSrv(srv) else { continue } - - let txtRaw = context.dig( - ["+short", "+time=1", "+tries=1", "@\(nameserver)", ptrName, "TXT"], - min(self.defaultTimeoutSeconds, remaining())) - let txtTokens = txtRaw.map(self.parseTxtTokens) ?? [] - let txt = self.mapTxt(tokens: txtTokens) - - let displayName = txt["displayName"] ?? instanceName - let beacon = WideAreaGatewayBeacon( - instanceName: instanceName, - displayName: displayName, - host: host, - port: port, - lanHost: txt["lanHost"], - tailnetDns: txt["tailnetDns"], - gatewayPort: parseInt(txt["gatewayPort"]), - sshPort: parseInt(txt["sshPort"]), - cliPath: txt["cliPath"]) - beacons.append(beacon) - } - - return beacons - } - - private static func collectTailnetIPv4s(statusJson: String?) -> [String] { - guard let statusJson else { return [] } - let decoder = JSONDecoder() - guard let data = statusJson.data(using: .utf8), - let status = try? decoder.decode(TailscaleStatus.self, from: data) - else { return [] } - - var ips: [String] = [] - ips.append(contentsOf: status.selfNode?.resolvedIPs ?? []) - if let peers = status.peer { - for peer in peers.values { - ips.append(contentsOf: peer.resolvedIPs) - } - } - - var seen = Set() - return ips.filter { value in - guard self.isTailnetIPv4(value) else { return false } - if seen.contains(value) { return false } - seen.insert(value) - return true - } - } - - private static func readTailscaleStatus() -> String? { - let candidates = [ - "/usr/local/bin/tailscale", - "/opt/homebrew/bin/tailscale", - "/Applications/Tailscale.app/Contents/MacOS/Tailscale", - "tailscale", - ] - - var output: String? - for candidate in candidates { - if let result = run( - path: candidate, - args: ["status", "--json"], - timeout: 0.7) - { - output = result - break - } - } - - return output - } - - private static func findNameserver( - candidates: inout [String], - remaining: () -> TimeInterval, - dig: @escaping @Sendable (_ args: [String], _ timeout: TimeInterval) -> String?) -> String? - { - guard let domain = OpenClawBonjour.wideAreaGatewayServiceDomain else { return nil } - let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: ".")) - let probeName = "_openclaw-gw._tcp.\(domainTrimmed)" - - let ips = candidates - candidates.removeAll(keepingCapacity: true) - if ips.isEmpty { return nil } - - final class ProbeState: @unchecked Sendable { - let lock = NSLock() - var nextIndex = 0 - var found: String? - } - - let state = ProbeState() - let deadline = Date().addingTimeInterval(max(0, remaining())) - let workerCount = min(self.nameserverProbeConcurrency, ips.count) - let group = DispatchGroup() - - for _ in 0..= ips.count { return } - let ip = ips[i] - let budget = deadline.timeIntervalSinceNow - if budget <= 0 { return } - - if let stdout = dig( - ["+short", "+time=1", "+tries=1", "@\(ip)", probeName, "PTR"], - min(defaultTimeoutSeconds, budget)), - stdout.split(whereSeparator: \.isNewline).isEmpty == false - { - state.lock.lock() - if state.found == nil { - state.found = ip - } - state.lock.unlock() - return - } - } - } - } - - _ = group.wait(timeout: .now() + max(0.0, remaining())) - return state.found - } - - private static func runDig(args: [String], timeout: TimeInterval) -> String? { - self.run(path: self.digPath, args: args, timeout: timeout) - } - - private static func run(path: String, args: [String], timeout: TimeInterval) -> String? { - let process = Process() - process.executableURL = URL(fileURLWithPath: path) - process.arguments = args - let outPipe = Pipe() - process.standardOutput = outPipe - // Avoid stderr pipe backpressure; we don't consume it. - process.standardError = FileHandle.nullDevice - - do { - try process.run() - } catch { - return nil - } - - let deadline = Date().addingTimeInterval(timeout) - while process.isRunning, Date() < deadline { - Thread.sleep(forTimeInterval: 0.02) - } - if process.isRunning { - process.terminate() - } - process.waitUntilExit() - - let data = (try? outPipe.fileHandleForReading.readToEnd()) ?? Data() - let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) - return output?.isEmpty == false ? output : nil - } - - private static func parseSrv(_ stdout: String) -> (String, Int)? { - let line = stdout - .split(whereSeparator: \.isNewline) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .first(where: { !$0.isEmpty }) - guard let line else { return nil } - let parts = line.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init) - guard parts.count >= 4 else { return nil } - guard let port = Int(parts[2]), port > 0 else { return nil } - let host = parts[3].hasSuffix(".") ? String(parts[3].dropLast()) : parts[3] - return (host, port) - } - - private static func parseTxtTokens(_ stdout: String) -> [String] { - let lines = stdout.split(whereSeparator: \.isNewline) - var tokens: [String] = [] - for raw in lines { - let line = raw.trimmingCharacters(in: .whitespacesAndNewlines) - if line.isEmpty { continue } - let matches = line.matches(of: /"([^"]*)"/) - for match in matches { - tokens.append(self.unescapeTxt(String(match.1))) - } - } - return tokens - } - - private static func unescapeTxt(_ value: String) -> String { - value - .replacingOccurrences(of: "\\\\", with: "\\") - .replacingOccurrences(of: "\\\"", with: "\"") - .replacingOccurrences(of: "\\n", with: "\n") - } - - private static func mapTxt(tokens: [String]) -> [String: String] { - var out: [String: String] = [:] - for token in tokens { - guard let idx = token.firstIndex(of: "=") else { continue } - let key = String(token[.. Int? { - guard let value else { return nil } - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - return Int(trimmed) - } - - private static func isTailnetIPv4(_ value: String) -> Bool { - let parts = value.split(separator: ".") - if parts.count != 4 { return false } - let octets = parts.compactMap { Int($0) } - if octets.count != 4 { return false } - let a = octets[0] - let b = octets[1] - return a == 100 && b >= 64 && b <= 127 - } - - private static func decodeDnsSdEscapes(_ value: String) -> String { - var bytes: [UInt8] = [] - var pending = "" - - func flushPending() { - guard !pending.isEmpty else { return } - bytes.append(contentsOf: pending.utf8) - pending = "" - } - - let chars = Array(value) - var i = 0 - while i < chars.count { - let ch = chars[i] - if ch == "\\", i + 3 < chars.count { - let digits = String(chars[(i + 1)...(i + 3)]) - if digits.allSatisfy(\.isNumber), - let byte = UInt8(digits) - { - flushPending() - bytes.append(byte) - i += 4 - continue - } - } - pending.append(ch) - i += 1 - } - flushPending() - - if bytes.isEmpty { return value } - if let decoded = String(bytes: bytes, encoding: .utf8) { - return decoded - } - return value - } -} - -private struct TailscaleStatus: Decodable { - struct Node: Decodable { - let tailscaleIPs: [String]? - - var resolvedIPs: [String] { - self.tailscaleIPs ?? [] - } - - private enum CodingKeys: String, CodingKey { - case tailscaleIPs = "TailscaleIPs" - } - } - - let selfNode: Node? - let peer: [String: Node]? - - private enum CodingKeys: String, CodingKey { - case selfNode = "Self" - case peer = "Peer" - } -} - -extension Collection { - fileprivate var nonEmpty: Self? { - isEmpty ? nil : self - } -} diff --git a/apps/macos/Sources/OpenClawIPC/IPC.swift b/apps/macos/Sources/OpenClawIPC/IPC.swift deleted file mode 100644 index 13fbe8756ab..00000000000 --- a/apps/macos/Sources/OpenClawIPC/IPC.swift +++ /dev/null @@ -1,416 +0,0 @@ -import CoreGraphics -import Foundation - -// MARK: - Capabilities - -public enum Capability: String, Codable, CaseIterable, Sendable { - /// AppleScript / Automation access to control other apps (TCC Automation). - case appleScript - case notifications - case accessibility - case screenRecording - case microphone - case speechRecognition - case camera - case location -} - -public enum CameraFacing: String, Codable, Sendable { - case front - case back -} - -// MARK: - Requests - -/// Notification interruption level (maps to UNNotificationInterruptionLevel) -public enum NotificationPriority: String, Codable, Sendable { - case passive // silent, no wake - case active // default - case timeSensitive // breaks through Focus modes -} - -/// Notification delivery mechanism. -public enum NotificationDelivery: String, Codable, Sendable { - /// Use macOS notification center (UNUserNotificationCenter). - case system - /// Use an in-app overlay/toast (no Notification Center history). - case overlay - /// Prefer system; fall back to overlay when system isn't available. - case auto -} - -// MARK: - Canvas geometry - -/// Optional placement hints for the Canvas panel. -/// Values are in screen coordinates (same as `NSWindow` frame). -public struct CanvasPlacement: Codable, Sendable { - public var x: Double? - public var y: Double? - public var width: Double? - public var height: Double? - - public init(x: Double? = nil, y: Double? = nil, width: Double? = nil, height: Double? = nil) { - self.x = x - self.y = y - self.width = width - self.height = height - } -} - -// MARK: - Canvas show result - -public enum CanvasShowStatus: String, Codable, Sendable { - /// Panel was shown, but no navigation occurred (no target passed and session already existed). - case shown - /// Target was a direct URL (http(s) or file). - case web - /// Local canvas target resolved to an existing file. - case ok - /// Local canvas target did not resolve to a file (404 page). - case notFound - /// Local scaffold fallback (e.g., no index.html present). - case welcome -} - -public struct CanvasShowResult: Codable, Sendable { - /// Session directory on disk (e.g. `~/Library/Application Support/OpenClaw/canvas//`). - public var directory: String - /// Target as provided by the caller (may be nil/empty). - public var target: String? - /// Target actually navigated to (nil when no navigation occurred; defaults to "/" for a newly created session). - public var effectiveTarget: String? - public var status: CanvasShowStatus - /// URL that was loaded (nil when no navigation occurred). - public var url: String? - - public init( - directory: String, - target: String?, - effectiveTarget: String?, - status: CanvasShowStatus, - url: String?) - { - self.directory = directory - self.target = target - self.effectiveTarget = effectiveTarget - self.status = status - self.url = url - } -} - -// MARK: - Canvas A2UI - -public enum CanvasA2UICommand: String, Codable, Sendable { - case pushJSONL - case reset -} - -public enum Request: Sendable { - case notify( - title: String, - body: String, - sound: String?, - priority: NotificationPriority?, - delivery: NotificationDelivery?) - case ensurePermissions([Capability], interactive: Bool) - case runShell( - command: [String], - cwd: String?, - env: [String: String]?, - timeoutSec: Double?, - needsScreenRecording: Bool) - case status - case agent(message: String, thinking: String?, session: String?, deliver: Bool, to: String?) - case rpcStatus - case canvasPresent(session: String, path: String?, placement: CanvasPlacement?) - case canvasHide(session: String) - case canvasEval(session: String, javaScript: String) - case canvasSnapshot(session: String, outPath: String?) - case canvasA2UI(session: String, command: CanvasA2UICommand, jsonl: String?) - case nodeList - case nodeDescribe(nodeId: String) - case nodeInvoke(nodeId: String, command: String, paramsJSON: String?) - case cameraSnap(facing: CameraFacing?, maxWidth: Int?, quality: Double?, outPath: String?) - case cameraClip(facing: CameraFacing?, durationMs: Int?, includeAudio: Bool, outPath: String?) - case screenRecord(screenIndex: Int?, durationMs: Int?, fps: Double?, includeAudio: Bool, outPath: String?) -} - -// MARK: - Responses - -public struct Response: Codable, Sendable { - public var ok: Bool - public var message: String? - /// Optional payload (PNG bytes, stdout text, etc.). - public var payload: Data? - - public init(ok: Bool, message: String? = nil, payload: Data? = nil) { - self.ok = ok - self.message = message - self.payload = payload - } -} - -// MARK: - Codable conformance for Request - -extension Request: Codable { - private enum CodingKeys: String, CodingKey { - case type - case title, body, sound, priority, delivery - case caps, interactive - case command, cwd, env, timeoutSec, needsScreenRecording - case message, thinking, session, deliver, to - case rpcStatus - case path - case javaScript - case outPath - case screenIndex - case fps - case canvasA2UICommand - case jsonl - case facing - case maxWidth - case quality - case durationMs - case includeAudio - case placement - case nodeId - case nodeCommand - case paramsJSON - } - - private enum Kind: String, Codable { - case notify - case ensurePermissions - case runShell - case status - case agent - case rpcStatus - case canvasPresent - case canvasHide - case canvasEval - case canvasSnapshot - case canvasA2UI - case nodeList - case nodeDescribe - case nodeInvoke - case cameraSnap - case cameraClip - case screenRecord - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - switch self { - case let .notify(title, body, sound, priority, delivery): - try container.encode(Kind.notify, forKey: .type) - try container.encode(title, forKey: .title) - try container.encode(body, forKey: .body) - try container.encodeIfPresent(sound, forKey: .sound) - try container.encodeIfPresent(priority, forKey: .priority) - try container.encodeIfPresent(delivery, forKey: .delivery) - - case let .ensurePermissions(caps, interactive): - try container.encode(Kind.ensurePermissions, forKey: .type) - try container.encode(caps, forKey: .caps) - try container.encode(interactive, forKey: .interactive) - - case let .runShell(command, cwd, env, timeoutSec, needsSR): - try container.encode(Kind.runShell, forKey: .type) - try container.encode(command, forKey: .command) - try container.encodeIfPresent(cwd, forKey: .cwd) - try container.encodeIfPresent(env, forKey: .env) - try container.encodeIfPresent(timeoutSec, forKey: .timeoutSec) - try container.encode(needsSR, forKey: .needsScreenRecording) - - case .status: - try container.encode(Kind.status, forKey: .type) - - case let .agent(message, thinking, session, deliver, to): - try container.encode(Kind.agent, forKey: .type) - try container.encode(message, forKey: .message) - try container.encodeIfPresent(thinking, forKey: .thinking) - try container.encodeIfPresent(session, forKey: .session) - try container.encode(deliver, forKey: .deliver) - try container.encodeIfPresent(to, forKey: .to) - - case .rpcStatus: - try container.encode(Kind.rpcStatus, forKey: .type) - - case let .canvasPresent(session, path, placement): - try container.encode(Kind.canvasPresent, forKey: .type) - try container.encode(session, forKey: .session) - try container.encodeIfPresent(path, forKey: .path) - try container.encodeIfPresent(placement, forKey: .placement) - - case let .canvasHide(session): - try container.encode(Kind.canvasHide, forKey: .type) - try container.encode(session, forKey: .session) - - case let .canvasEval(session, javaScript): - try container.encode(Kind.canvasEval, forKey: .type) - try container.encode(session, forKey: .session) - try container.encode(javaScript, forKey: .javaScript) - - case let .canvasSnapshot(session, outPath): - try container.encode(Kind.canvasSnapshot, forKey: .type) - try container.encode(session, forKey: .session) - try container.encodeIfPresent(outPath, forKey: .outPath) - - case let .canvasA2UI(session, command, jsonl): - try container.encode(Kind.canvasA2UI, forKey: .type) - try container.encode(session, forKey: .session) - try container.encode(command, forKey: .canvasA2UICommand) - try container.encodeIfPresent(jsonl, forKey: .jsonl) - - case .nodeList: - try container.encode(Kind.nodeList, forKey: .type) - - case let .nodeDescribe(nodeId): - try container.encode(Kind.nodeDescribe, forKey: .type) - try container.encode(nodeId, forKey: .nodeId) - - case let .nodeInvoke(nodeId, command, paramsJSON): - try container.encode(Kind.nodeInvoke, forKey: .type) - try container.encode(nodeId, forKey: .nodeId) - try container.encode(command, forKey: .nodeCommand) - try container.encodeIfPresent(paramsJSON, forKey: .paramsJSON) - - case let .cameraSnap(facing, maxWidth, quality, outPath): - try container.encode(Kind.cameraSnap, forKey: .type) - try container.encodeIfPresent(facing, forKey: .facing) - try container.encodeIfPresent(maxWidth, forKey: .maxWidth) - try container.encodeIfPresent(quality, forKey: .quality) - try container.encodeIfPresent(outPath, forKey: .outPath) - - case let .cameraClip(facing, durationMs, includeAudio, outPath): - try container.encode(Kind.cameraClip, forKey: .type) - try container.encodeIfPresent(facing, forKey: .facing) - try container.encodeIfPresent(durationMs, forKey: .durationMs) - try container.encode(includeAudio, forKey: .includeAudio) - try container.encodeIfPresent(outPath, forKey: .outPath) - - case let .screenRecord(screenIndex, durationMs, fps, includeAudio, outPath): - try container.encode(Kind.screenRecord, forKey: .type) - try container.encodeIfPresent(screenIndex, forKey: .screenIndex) - try container.encodeIfPresent(durationMs, forKey: .durationMs) - try container.encodeIfPresent(fps, forKey: .fps) - try container.encode(includeAudio, forKey: .includeAudio) - try container.encodeIfPresent(outPath, forKey: .outPath) - } - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let kind = try container.decode(Kind.self, forKey: .type) - switch kind { - case .notify: - let title = try container.decode(String.self, forKey: .title) - let body = try container.decode(String.self, forKey: .body) - let sound = try container.decodeIfPresent(String.self, forKey: .sound) - let priority = try container.decodeIfPresent(NotificationPriority.self, forKey: .priority) - let delivery = try container.decodeIfPresent(NotificationDelivery.self, forKey: .delivery) - self = .notify(title: title, body: body, sound: sound, priority: priority, delivery: delivery) - - case .ensurePermissions: - let caps = try container.decode([Capability].self, forKey: .caps) - let interactive = try container.decode(Bool.self, forKey: .interactive) - self = .ensurePermissions(caps, interactive: interactive) - - case .runShell: - let command = try container.decode([String].self, forKey: .command) - let cwd = try container.decodeIfPresent(String.self, forKey: .cwd) - let env = try container.decodeIfPresent([String: String].self, forKey: .env) - let timeout = try container.decodeIfPresent(Double.self, forKey: .timeoutSec) - let needsSR = try container.decode(Bool.self, forKey: .needsScreenRecording) - self = .runShell(command: command, cwd: cwd, env: env, timeoutSec: timeout, needsScreenRecording: needsSR) - - case .status: - self = .status - - case .agent: - let message = try container.decode(String.self, forKey: .message) - let thinking = try container.decodeIfPresent(String.self, forKey: .thinking) - let session = try container.decodeIfPresent(String.self, forKey: .session) - let deliver = try container.decode(Bool.self, forKey: .deliver) - let to = try container.decodeIfPresent(String.self, forKey: .to) - self = .agent(message: message, thinking: thinking, session: session, deliver: deliver, to: to) - - case .rpcStatus: - self = .rpcStatus - - case .canvasPresent: - let session = try container.decode(String.self, forKey: .session) - let path = try container.decodeIfPresent(String.self, forKey: .path) - let placement = try container.decodeIfPresent(CanvasPlacement.self, forKey: .placement) - self = .canvasPresent(session: session, path: path, placement: placement) - - case .canvasHide: - let session = try container.decode(String.self, forKey: .session) - self = .canvasHide(session: session) - - case .canvasEval: - let session = try container.decode(String.self, forKey: .session) - let javaScript = try container.decode(String.self, forKey: .javaScript) - self = .canvasEval(session: session, javaScript: javaScript) - - case .canvasSnapshot: - let session = try container.decode(String.self, forKey: .session) - let outPath = try container.decodeIfPresent(String.self, forKey: .outPath) - self = .canvasSnapshot(session: session, outPath: outPath) - - case .canvasA2UI: - let session = try container.decode(String.self, forKey: .session) - let command = try container.decode(CanvasA2UICommand.self, forKey: .canvasA2UICommand) - let jsonl = try container.decodeIfPresent(String.self, forKey: .jsonl) - self = .canvasA2UI(session: session, command: command, jsonl: jsonl) - - case .nodeList: - self = .nodeList - - case .nodeDescribe: - let nodeId = try container.decode(String.self, forKey: .nodeId) - self = .nodeDescribe(nodeId: nodeId) - - case .nodeInvoke: - let nodeId = try container.decode(String.self, forKey: .nodeId) - let command = try container.decode(String.self, forKey: .nodeCommand) - let paramsJSON = try container.decodeIfPresent(String.self, forKey: .paramsJSON) - self = .nodeInvoke(nodeId: nodeId, command: command, paramsJSON: paramsJSON) - - case .cameraSnap: - let facing = try container.decodeIfPresent(CameraFacing.self, forKey: .facing) - let maxWidth = try container.decodeIfPresent(Int.self, forKey: .maxWidth) - let quality = try container.decodeIfPresent(Double.self, forKey: .quality) - let outPath = try container.decodeIfPresent(String.self, forKey: .outPath) - self = .cameraSnap(facing: facing, maxWidth: maxWidth, quality: quality, outPath: outPath) - - case .cameraClip: - let facing = try container.decodeIfPresent(CameraFacing.self, forKey: .facing) - let durationMs = try container.decodeIfPresent(Int.self, forKey: .durationMs) - let includeAudio = (try? container.decode(Bool.self, forKey: .includeAudio)) ?? true - let outPath = try container.decodeIfPresent(String.self, forKey: .outPath) - self = .cameraClip(facing: facing, durationMs: durationMs, includeAudio: includeAudio, outPath: outPath) - - case .screenRecord: - let screenIndex = try container.decodeIfPresent(Int.self, forKey: .screenIndex) - let durationMs = try container.decodeIfPresent(Int.self, forKey: .durationMs) - let fps = try container.decodeIfPresent(Double.self, forKey: .fps) - let includeAudio = (try? container.decode(Bool.self, forKey: .includeAudio)) ?? true - let outPath = try container.decodeIfPresent(String.self, forKey: .outPath) - self = .screenRecord( - screenIndex: screenIndex, - durationMs: durationMs, - fps: fps, - includeAudio: includeAudio, - outPath: outPath) - } - } -} - -/// Shared transport settings -public let controlSocketPath: String = { - let home = FileManager().homeDirectoryForCurrentUser - return home - .appendingPathComponent("Library/Application Support/OpenClaw/control.sock") - .path -}() diff --git a/apps/macos/Sources/OpenClawMacCLI/ConnectCommand.swift b/apps/macos/Sources/OpenClawMacCLI/ConnectCommand.swift deleted file mode 100644 index 0989164a01e..00000000000 --- a/apps/macos/Sources/OpenClawMacCLI/ConnectCommand.swift +++ /dev/null @@ -1,309 +0,0 @@ -import Foundation -import OpenClawDiscovery -import OpenClawKit -import OpenClawProtocol - -struct ConnectOptions { - var url: String? - var token: String? - var password: String? - var mode: String? - var timeoutMs: Int = 15000 - var json: Bool = false - var probe: Bool = false - var clientId: String = "openclaw-macos" - var clientMode: String = "ui" - var displayName: String? - var role: String = "operator" - var scopes: [String] = ["operator.admin", "operator.approvals", "operator.pairing"] - var help: Bool = false - - static func parse(_ args: [String]) -> ConnectOptions { - var opts = ConnectOptions() - let flagHandlers: [String: (inout ConnectOptions) -> Void] = [ - "-h": { $0.help = true }, - "--help": { $0.help = true }, - "--json": { $0.json = true }, - "--probe": { $0.probe = true }, - ] - let valueHandlers: [String: (inout ConnectOptions, String) -> Void] = [ - "--url": { $0.url = $1 }, - "--token": { $0.token = $1 }, - "--password": { $0.password = $1 }, - "--mode": { $0.mode = $1 }, - "--timeout": { opts, raw in - if let parsed = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines)) { - opts.timeoutMs = max(250, parsed) - } - }, - "--client-id": { $0.clientId = $1 }, - "--client-mode": { $0.clientMode = $1 }, - "--display-name": { $0.displayName = $1 }, - "--role": { $0.role = $1 }, - "--scopes": { opts, raw in - opts.scopes = raw.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - }, - ] - var i = 0 - while i < args.count { - let arg = args[i] - if let handler = flagHandlers[arg] { - handler(&opts) - i += 1 - continue - } - if let handler = valueHandlers[arg], let value = self.nextValue(args, index: &i) { - handler(&opts, value) - i += 1 - continue - } - i += 1 - } - return opts - } - - private static func nextValue(_ args: [String], index: inout Int) -> String? { - guard index + 1 < args.count else { return nil } - index += 1 - return args[index].trimmingCharacters(in: .whitespacesAndNewlines) - } -} - -struct ConnectOutput: Encodable { - var status: String - var url: String - var mode: String - var role: String - var clientId: String - var clientMode: String - var scopes: [String] - var snapshot: HelloOk? - var health: ProtoAnyCodable? - var error: String? -} - -actor SnapshotStore { - private var value: HelloOk? - - func set(_ snapshot: HelloOk) { - self.value = snapshot - } - - func get() -> HelloOk? { - self.value - } -} - -func runConnect(_ args: [String]) async { - let opts = ConnectOptions.parse(args) - if opts.help { - print(""" - openclaw-mac connect - - Usage: - openclaw-mac connect [--url ] [--token ] [--password ] - [--mode ] [--timeout ] [--probe] [--json] - [--client-id ] [--client-mode ] [--display-name ] - [--role ] [--scopes ] - - Options: - --url Gateway WebSocket URL (overrides config) - --token Gateway token (if required) - --password Gateway password (if required) - --mode Resolve from config: local|remote (default: config or local) - --timeout Request timeout (default: 15000) - --probe Force a fresh health probe - --json Emit JSON - --client-id Override client id (default: openclaw-macos) - --client-mode Override client mode (default: ui) - --display-name Override display name - --role Override role (default: operator) - --scopes Override scopes list - -h, --help Show help - """) - return - } - - let config = loadGatewayConfig() - do { - let endpoint = try resolveGatewayEndpoint(opts: opts, config: config) - let displayName = opts.displayName ?? Host.current().localizedName ?? "OpenClaw macOS Debug CLI" - let connectOptions = GatewayConnectOptions( - role: opts.role, - scopes: opts.scopes, - caps: [], - commands: [], - permissions: [:], - clientId: opts.clientId, - clientMode: opts.clientMode, - clientDisplayName: displayName) - - let snapshotStore = SnapshotStore() - let channel = GatewayChannelActor( - url: endpoint.url, - token: endpoint.token, - password: endpoint.password, - pushHandler: { push in - if case let .snapshot(ok) = push { - await snapshotStore.set(ok) - } - }, - connectOptions: connectOptions) - - let params: [String: KitAnyCodable]? = opts.probe ? ["probe": KitAnyCodable(true)] : nil - let data = try await channel.request( - method: "health", - params: params, - timeoutMs: Double(opts.timeoutMs)) - let health = try? JSONDecoder().decode(ProtoAnyCodable.self, from: data) - let snapshot = await snapshotStore.get() - await channel.shutdown() - - let output = ConnectOutput( - status: "ok", - url: endpoint.url.absoluteString, - mode: endpoint.mode, - role: opts.role, - clientId: opts.clientId, - clientMode: opts.clientMode, - scopes: opts.scopes, - snapshot: snapshot, - health: health, - error: nil) - printConnectOutput(output, json: opts.json) - } catch { - let endpoint = bestEffortEndpoint(opts: opts, config: config) - let fallbackMode = (opts.mode ?? config.mode ?? "local").lowercased() - let output = ConnectOutput( - status: "error", - url: endpoint?.url.absoluteString ?? "unknown", - mode: endpoint?.mode ?? fallbackMode, - role: opts.role, - clientId: opts.clientId, - clientMode: opts.clientMode, - scopes: opts.scopes, - snapshot: nil, - health: nil, - error: error.localizedDescription) - printConnectOutput(output, json: opts.json) - exit(1) - } -} - -private func printConnectOutput(_ output: ConnectOutput, json: Bool) { - if json { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - if let data = try? encoder.encode(output), - let text = String(data: data, encoding: .utf8) - { - print(text) - } else { - print("{\"error\":\"failed to encode JSON\"}") - } - return - } - - print("OpenClaw macOS Gateway Connect") - print("Status: \(output.status)") - print("URL: \(output.url)") - print("Mode: \(output.mode)") - print("Client: \(output.clientId) (\(output.clientMode))") - print("Role: \(output.role)") - print("Scopes: \(output.scopes.joined(separator: ", "))") - if let snapshot = output.snapshot { - print("Protocol: \(snapshot._protocol)") - if let version = snapshot.server["version"]?.value as? String { - print("Server: \(version)") - } - } - if let health = output.health, - let ok = (health.value as? [String: ProtoAnyCodable])?["ok"]?.value as? Bool - { - print("Health: \(ok ? "ok" : "error")") - } else if output.health != nil { - print("Health: received") - } - if let error = output.error { - print("Error: \(error)") - } -} - -private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig) throws -> GatewayEndpoint { - let resolvedMode = (opts.mode ?? config.mode ?? "local").lowercased() - if let raw = opts.url, !raw.isEmpty { - guard let url = URL(string: raw) else { - throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"]) - } - return GatewayEndpoint( - url: url, - token: resolvedToken(opts: opts, mode: resolvedMode, config: config), - password: resolvedPassword(opts: opts, mode: resolvedMode, config: config), - mode: resolvedMode) - } - - if resolvedMode == "remote" { - guard let raw = config.remoteUrl?.trimmingCharacters(in: .whitespacesAndNewlines), - !raw.isEmpty - else { - throw NSError( - domain: "Gateway", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url is missing"]) - } - guard let url = URL(string: raw) else { - throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"]) - } - return GatewayEndpoint( - url: url, - token: resolvedToken(opts: opts, mode: resolvedMode, config: config), - password: resolvedPassword(opts: opts, mode: resolvedMode, config: config), - mode: resolvedMode) - } - - let port = config.port ?? 18789 - let host = resolveLocalHost(bind: config.bind) - guard let url = URL(string: "ws://\(host):\(port)") else { - throw NSError( - domain: "Gateway", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "invalid url: ws://\(host):\(port)"]) - } - return GatewayEndpoint( - url: url, - token: resolvedToken(opts: opts, mode: resolvedMode, config: config), - password: resolvedPassword(opts: opts, mode: resolvedMode, config: config), - mode: resolvedMode) -} - -private func bestEffortEndpoint(opts: ConnectOptions, config: GatewayConfig) -> GatewayEndpoint? { - try? resolveGatewayEndpoint(opts: opts, config: config) -} - -private func resolvedToken(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? { - if let token = opts.token, !token.isEmpty { return token } - if mode == "remote" { - return config.remoteToken - } - return config.token -} - -private func resolvedPassword(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? { - if let password = opts.password, !password.isEmpty { return password } - if mode == "remote" { - return config.remotePassword - } - return config.password -} - -private func resolveLocalHost(bind: String?) -> String { - let normalized = (bind ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let tailnetIP = TailscaleNetwork.detectTailnetIPv4() - switch normalized { - case "tailnet": - return tailnetIP ?? "127.0.0.1" - default: - return "127.0.0.1" - } -} diff --git a/apps/macos/Sources/OpenClawMacCLI/DiscoverCommand.swift b/apps/macos/Sources/OpenClawMacCLI/DiscoverCommand.swift deleted file mode 100644 index b039ecdf411..00000000000 --- a/apps/macos/Sources/OpenClawMacCLI/DiscoverCommand.swift +++ /dev/null @@ -1,149 +0,0 @@ -import Foundation -import OpenClawDiscovery - -struct DiscoveryOptions { - var timeoutMs: Int = 2000 - var json: Bool = false - var includeLocal: Bool = false - var help: Bool = false - - static func parse(_ args: [String]) -> DiscoveryOptions { - var opts = DiscoveryOptions() - var i = 0 - while i < args.count { - let arg = args[i] - switch arg { - case "-h", "--help": - opts.help = true - case "--json": - opts.json = true - case "--include-local": - opts.includeLocal = true - case "--timeout": - let next = (i + 1 < args.count) ? args[i + 1] : nil - if let next, let parsed = Int(next.trimmingCharacters(in: .whitespacesAndNewlines)) { - opts.timeoutMs = max(100, parsed) - i += 1 - } - default: - break - } - i += 1 - } - return opts - } -} - -struct DiscoveryOutput: Encodable { - struct Gateway: Encodable { - var displayName: String - var lanHost: String? - var tailnetDns: String? - var sshPort: Int - var gatewayPort: Int? - var cliPath: String? - var stableID: String - var debugID: String - var isLocal: Bool - } - - var status: String - var timeoutMs: Int - var includeLocal: Bool - var count: Int - var gateways: [Gateway] -} - -func runDiscover(_ args: [String]) async { - let opts = DiscoveryOptions.parse(args) - if opts.help { - print(""" - openclaw-mac discover - - Usage: - openclaw-mac discover [--timeout ] [--json] [--include-local] - - Options: - --timeout Discovery window in milliseconds (default: 2000) - --json Emit JSON - --include-local Include gateways considered local - -h, --help Show help - """) - return - } - - let displayName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName - let model = await MainActor.run { - GatewayDiscoveryModel( - localDisplayName: displayName, - filterLocalGateways: !opts.includeLocal) - } - - await MainActor.run { - model.start() - } - - let nanos = UInt64(max(100, opts.timeoutMs)) * 1_000_000 - try? await Task.sleep(nanoseconds: nanos) - - let gateways = await MainActor.run { model.gateways } - let status = await MainActor.run { model.statusText } - - await MainActor.run { - model.stop() - } - - if opts.json { - let payload = DiscoveryOutput( - status: status, - timeoutMs: opts.timeoutMs, - includeLocal: opts.includeLocal, - count: gateways.count, - gateways: gateways.map { - DiscoveryOutput.Gateway( - displayName: $0.displayName, - lanHost: $0.lanHost, - tailnetDns: $0.tailnetDns, - sshPort: $0.sshPort, - gatewayPort: $0.gatewayPort, - cliPath: $0.cliPath, - stableID: $0.stableID, - debugID: $0.debugID, - isLocal: $0.isLocal) - }) - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - if let data = try? encoder.encode(payload), - let json = String(data: data, encoding: .utf8) - { - print(json) - } else { - print("{\"error\":\"failed to encode JSON\"}") - } - return - } - - print("Gateway Discovery (macOS NWBrowser)") - print("Status: \(status)") - print("Found \(gateways.count) gateway(s)\(opts.includeLocal ? "" : " (local filtered)")") - if gateways.isEmpty { return } - - for gateway in gateways { - let hosts = [gateway.tailnetDns, gateway.lanHost] - .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - .joined(separator: ", ") - print("- \(gateway.displayName)") - print(" hosts: \(hosts.isEmpty ? "(none)" : hosts)") - print(" ssh: \(gateway.sshPort)") - if let port = gateway.gatewayPort { - print(" gatewayPort: \(port)") - } - if let cliPath = gateway.cliPath { - print(" cliPath: \(cliPath)") - } - print(" isLocal: \(gateway.isLocal)") - print(" stableID: \(gateway.stableID)") - print(" debugID: \(gateway.debugID)") - } -} diff --git a/apps/macos/Sources/OpenClawMacCLI/EntryPoint.swift b/apps/macos/Sources/OpenClawMacCLI/EntryPoint.swift deleted file mode 100644 index 6cb4880cf91..00000000000 --- a/apps/macos/Sources/OpenClawMacCLI/EntryPoint.swift +++ /dev/null @@ -1,56 +0,0 @@ -import Foundation - -private struct RootCommand { - var name: String - var args: [String] -} - -@main -struct OpenClawMacCLI { - static func main() async { - let args = Array(CommandLine.arguments.dropFirst()) - let command = parseRootCommand(args) - switch command?.name { - case nil: - printUsage() - case "-h", "--help", "help": - printUsage() - case "connect": - await runConnect(command?.args ?? []) - case "discover": - await runDiscover(command?.args ?? []) - case "wizard": - await runWizardCommand(command?.args ?? []) - default: - fputs("openclaw-mac: unknown command\n", stderr) - printUsage() - exit(1) - } - } -} - -private func parseRootCommand(_ args: [String]) -> RootCommand? { - guard let first = args.first else { return nil } - return RootCommand(name: first, args: Array(args.dropFirst())) -} - -private func printUsage() { - print(""" - openclaw-mac - - Usage: - openclaw-mac connect [--url ] [--token ] [--password ] - [--mode ] [--timeout ] [--probe] [--json] - [--client-id ] [--client-mode ] [--display-name ] - [--role ] [--scopes ] - openclaw-mac discover [--timeout ] [--json] [--include-local] - openclaw-mac wizard [--url ] [--token ] [--password ] - [--mode ] [--workspace ] [--json] - - Examples: - openclaw-mac connect - openclaw-mac connect --url ws://127.0.0.1:18789 --json - openclaw-mac discover --timeout 3000 --json - openclaw-mac wizard --mode local - """) -} diff --git a/apps/macos/Sources/OpenClawMacCLI/GatewayConfig.swift b/apps/macos/Sources/OpenClawMacCLI/GatewayConfig.swift deleted file mode 100644 index c3c963b2531..00000000000 --- a/apps/macos/Sources/OpenClawMacCLI/GatewayConfig.swift +++ /dev/null @@ -1,62 +0,0 @@ -import Foundation - -struct GatewayConfig { - var mode: String? - var bind: String? - var port: Int? - var remoteUrl: String? - var token: String? - var password: String? - var remoteToken: String? - var remotePassword: String? -} - -struct GatewayEndpoint { - let url: URL - let token: String? - let password: String? - let mode: String -} - -func loadGatewayConfig() -> GatewayConfig { - let home = FileManager().homeDirectoryForCurrentUser - let candidates = [ - home.appendingPathComponent(".openclaw/openclaw.json"), - ] - let url = candidates.first { FileManager().isReadableFile(atPath: $0.path) } ?? candidates[0] - guard let data = try? Data(contentsOf: url) else { return GatewayConfig() } - guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return GatewayConfig() - } - - var cfg = GatewayConfig() - if let gateway = json["gateway"] as? [String: Any] { - cfg.mode = gateway["mode"] as? String - cfg.bind = gateway["bind"] as? String - cfg.port = gateway["port"] as? Int ?? parseInt(gateway["port"]) - - if let auth = gateway["auth"] as? [String: Any] { - cfg.token = auth["token"] as? String - cfg.password = auth["password"] as? String - } - if let remote = gateway["remote"] as? [String: Any] { - cfg.remoteUrl = remote["url"] as? String - cfg.remoteToken = remote["token"] as? String - cfg.remotePassword = remote["password"] as? String - } - } - return cfg -} - -func parseInt(_ value: Any?) -> Int? { - switch value { - case let number as Int: - number - case let number as Double: - Int(number) - case let raw as String: - Int(raw.trimmingCharacters(in: .whitespacesAndNewlines)) - default: - nil - } -} diff --git a/apps/macos/Sources/OpenClawMacCLI/TypeAliases.swift b/apps/macos/Sources/OpenClawMacCLI/TypeAliases.swift deleted file mode 100644 index 28b3a7ebdf2..00000000000 --- a/apps/macos/Sources/OpenClawMacCLI/TypeAliases.swift +++ /dev/null @@ -1,5 +0,0 @@ -import OpenClawKit -import OpenClawProtocol - -typealias ProtoAnyCodable = OpenClawProtocol.AnyCodable -typealias KitAnyCodable = OpenClawKit.AnyCodable diff --git a/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift b/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift deleted file mode 100644 index 0a73fc2108c..00000000000 --- a/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift +++ /dev/null @@ -1,548 +0,0 @@ -import Darwin -import Foundation -import OpenClawKit -import OpenClawProtocol - -struct WizardCliOptions { - var url: String? - var token: String? - var password: String? - var mode: String = "local" - var workspace: String? - var json: Bool = false - var help: Bool = false - - static func parse(_ args: [String]) -> WizardCliOptions { - var opts = WizardCliOptions() - var i = 0 - while i < args.count { - let arg = args[i] - switch arg { - case "-h", "--help": - opts.help = true - case "--json": - opts.json = true - case "--url": - opts.url = self.nextValue(args, index: &i) - case "--token": - opts.token = self.nextValue(args, index: &i) - case "--password": - opts.password = self.nextValue(args, index: &i) - case "--mode": - if let value = nextValue(args, index: &i) { - opts.mode = value - } - case "--workspace": - opts.workspace = self.nextValue(args, index: &i) - default: - break - } - i += 1 - } - return opts - } - - private static func nextValue(_ args: [String], index: inout Int) -> String? { - guard index + 1 < args.count else { return nil } - index += 1 - return args[index].trimmingCharacters(in: .whitespacesAndNewlines) - } -} - -enum WizardCliError: Error, CustomStringConvertible { - case invalidUrl(String) - case missingRemoteUrl - case gatewayError(String) - case decodeError(String) - case cancelled - - var description: String { - switch self { - case let .invalidUrl(raw): "Invalid URL: \(raw)" - case .missingRemoteUrl: "gateway.remote.url is missing" - case let .gatewayError(msg): msg - case let .decodeError(msg): msg - case .cancelled: "Wizard cancelled" - } - } -} - -func runWizardCommand(_ args: [String]) async { - let opts = WizardCliOptions.parse(args) - if opts.help { - print(""" - openclaw-mac wizard - - Usage: - openclaw-mac wizard [--url ] [--token ] [--password ] - [--mode ] [--workspace ] [--json] - - Options: - --url Gateway WebSocket URL (overrides config) - --token Gateway token (if required) - --password Gateway password (if required) - --mode Wizard mode (local|remote). Default: local - --workspace Wizard workspace override - --json Print raw wizard responses - -h, --help Show help - """) - return - } - - let config = loadGatewayConfig() - do { - guard isatty(STDIN_FILENO) != 0 else { - throw WizardCliError.gatewayError("Wizard requires an interactive TTY.") - } - let endpoint = try resolveWizardGatewayEndpoint(opts: opts, config: config) - let client = GatewayWizardClient( - url: endpoint.url, - token: endpoint.token, - password: endpoint.password, - json: opts.json) - try await client.connect() - defer { Task { await client.close() } } - try await runWizard(client: client, opts: opts) - } catch { - fputs("wizard: \(error)\n", stderr) - exit(1) - } -} - -private func resolveWizardGatewayEndpoint(opts: WizardCliOptions, config: GatewayConfig) throws -> GatewayEndpoint { - if let raw = opts.url, !raw.isEmpty { - guard let url = URL(string: raw) else { throw WizardCliError.invalidUrl(raw) } - return GatewayEndpoint( - url: url, - token: resolvedToken(opts: opts, config: config), - password: resolvedPassword(opts: opts, config: config), - mode: (config.mode ?? "local").lowercased()) - } - - let mode = (config.mode ?? "local").lowercased() - if mode == "remote" { - guard let raw = config.remoteUrl?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { - throw WizardCliError.missingRemoteUrl - } - guard let url = URL(string: raw) else { throw WizardCliError.invalidUrl(raw) } - return GatewayEndpoint( - url: url, - token: resolvedToken(opts: opts, config: config), - password: resolvedPassword(opts: opts, config: config), - mode: mode) - } - - let port = config.port ?? 18789 - let host = "127.0.0.1" - guard let url = URL(string: "ws://\(host):\(port)") else { - throw WizardCliError.invalidUrl("ws://\(host):\(port)") - } - return GatewayEndpoint( - url: url, - token: resolvedToken(opts: opts, config: config), - password: resolvedPassword(opts: opts, config: config), - mode: mode) -} - -private func resolvedToken(opts: WizardCliOptions, config: GatewayConfig) -> String? { - if let token = opts.token, !token.isEmpty { return token } - if (config.mode ?? "local").lowercased() == "remote" { - return config.remoteToken - } - return config.token -} - -private func resolvedPassword(opts: WizardCliOptions, config: GatewayConfig) -> String? { - if let password = opts.password, !password.isEmpty { return password } - if (config.mode ?? "local").lowercased() == "remote" { - return config.remotePassword - } - return config.password -} - -actor GatewayWizardClient { - private enum ConnectChallengeError: Error { - case timeout - } - - private let url: URL - private let token: String? - private let password: String? - private let json: Bool - private let encoder = JSONEncoder() - private let decoder = JSONDecoder() - private let session = URLSession(configuration: .default) - private let connectChallengeTimeoutSeconds: Double = 0.75 - private var task: URLSessionWebSocketTask? - - init(url: URL, token: String?, password: String?, json: Bool) { - self.url = url - self.token = token - self.password = password - self.json = json - } - - func connect() async throws { - let socket = self.session.webSocketTask(with: self.url) - socket.maximumMessageSize = 16 * 1024 * 1024 - socket.resume() - self.task = socket - try await self.sendConnect() - } - - func close() { - self.task?.cancel(with: .goingAway, reason: nil) - self.task = nil - } - - func request(method: String, params: [String: ProtoAnyCodable]?) async throws -> ResponseFrame { - guard let task = self.task else { - throw WizardCliError.gatewayError("gateway not connected") - } - let id = UUID().uuidString - let frame = RequestFrame( - type: "req", - id: id, - method: method, - params: params.map { ProtoAnyCodable($0) }) - let data = try self.encoder.encode(frame) - try await task.send(.data(data)) - - while true { - let message = try await task.receive() - let frame = try decodeFrame(message) - if case let .res(res) = frame, res.id == id { - if res.ok == false { - let msg = (res.error?["message"]?.value as? String) ?? "gateway error" - throw WizardCliError.gatewayError(msg) - } - return res - } - } - } - - func decodePayload(_ response: ResponseFrame, as _: T.Type) throws -> T { - guard let payload = response.payload else { - throw WizardCliError.decodeError("missing payload") - } - let data = try self.encoder.encode(payload) - return try self.decoder.decode(T.self, from: data) - } - - private func decodeFrame(_ message: URLSessionWebSocketTask.Message) throws -> GatewayFrame { - let data: Data? = switch message { - case let .data(data): data - case let .string(text): text.data(using: .utf8) - @unknown default: nil - } - guard let data else { - throw WizardCliError.decodeError("empty gateway response") - } - return try self.decoder.decode(GatewayFrame.self, from: data) - } - - private func sendConnect() async throws { - guard let task = self.task else { - throw WizardCliError.gatewayError("gateway not connected") - } - let osVersion = ProcessInfo.processInfo.operatingSystemVersion - let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)" - let clientId = "openclaw-macos" - let clientMode = "ui" - let role = "operator" - // Explicit scopes; gateway no longer defaults empty scopes to admin. - let scopes: [String] = ["operator.admin", "operator.approvals", "operator.pairing"] - let client: [String: ProtoAnyCodable] = [ - "id": ProtoAnyCodable(clientId), - "displayName": ProtoAnyCodable(Host.current().localizedName ?? "OpenClaw macOS Wizard CLI"), - "version": ProtoAnyCodable("dev"), - "platform": ProtoAnyCodable(platform), - "deviceFamily": ProtoAnyCodable("Mac"), - "mode": ProtoAnyCodable(clientMode), - "instanceId": ProtoAnyCodable(UUID().uuidString), - ] - - var params: [String: ProtoAnyCodable] = [ - "minProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION), - "maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION), - "client": ProtoAnyCodable(client), - "caps": ProtoAnyCodable([String]()), - "locale": ProtoAnyCodable(Locale.preferredLanguages.first ?? Locale.current.identifier), - "userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString), - "role": ProtoAnyCodable(role), - "scopes": ProtoAnyCodable(scopes), - ] - if let token = self.token { - params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)]) - } else if let password = self.password { - params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)]) - } - let connectNonce = try await self.waitForConnectChallenge() - let identity = DeviceIdentityStore.loadOrCreate() - let signedAtMs = Int(Date().timeIntervalSince1970 * 1000) - let scopesValue = scopes.joined(separator: ",") - var payloadParts = [ - connectNonce == nil ? "v1" : "v2", - identity.deviceId, - clientId, - clientMode, - role, - scopesValue, - String(signedAtMs), - self.token ?? "", - ] - if let connectNonce { - 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) - } - params["device"] = ProtoAnyCodable(device) - } - - let reqId = UUID().uuidString - let frame = RequestFrame( - type: "req", - id: reqId, - method: "connect", - params: ProtoAnyCodable(params)) - let data = try self.encoder.encode(frame) - try await task.send(.data(data)) - - while true { - let message = try await task.receive() - let frameResponse = try decodeFrame(message) - if case let .res(res) = frameResponse, res.id == reqId { - if res.ok == false { - let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed" - throw WizardCliError.gatewayError(msg) - } - _ = try self.decodePayload(res, as: HelloOk.self) - return - } - } - } - - private func waitForConnectChallenge() async throws -> String? { - guard let task = self.task else { return nil } - do { - return try await AsyncTimeout.withTimeout( - seconds: self.connectChallengeTimeoutSeconds, - onTimeout: { ConnectChallengeError.timeout }, - operation: { - while true { - let message = try await task.receive() - let frame = try await self.decodeFrame(message) - if case let .event(evt) = frame, evt.event == "connect.challenge" { - if let payload = evt.payload?.value as? [String: ProtoAnyCodable], - let nonce = payload["nonce"]?.value as? String - { - return nonce - } - } - } - }) - } catch { - if error is ConnectChallengeError { return nil } - throw error - } - } -} - -private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) async throws { - var params: [String: ProtoAnyCodable] = [:] - let mode = opts.mode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if mode == "local" || mode == "remote" { - params["mode"] = ProtoAnyCodable(mode) - } - if let workspace = opts.workspace?.trimmingCharacters(in: .whitespacesAndNewlines), !workspace.isEmpty { - params["workspace"] = ProtoAnyCodable(workspace) - } - - let startResponse = try await client.request(method: "wizard.start", params: params) - let startResult = try await client.decodePayload(startResponse, as: WizardStartResult.self) - if opts.json { - dumpResult(startResponse) - } - - let sessionId = startResult.sessionid - var nextResult = WizardNextResult( - done: startResult.done, - step: startResult.step, - status: startResult.status, - error: startResult.error) - - do { - while true { - let status = wizardStatusString(nextResult.status) ?? (nextResult.done ? "done" : "running") - if status == "cancelled" { - print("Wizard cancelled.") - return - } - if status == "error" || (nextResult.done && nextResult.error != nil) { - throw WizardCliError.gatewayError(nextResult.error ?? "wizard error") - } - if status == "done" || nextResult.done { - print("Wizard complete.") - return - } - - if let step = decodeWizardStep(nextResult.step) { - let answer = try promptAnswer(for: step) - var answerPayload: [String: ProtoAnyCodable] = [ - "stepId": ProtoAnyCodable(step.id), - ] - if !(answer is NSNull) { - answerPayload["value"] = ProtoAnyCodable(answer) - } - let response = try await client.request( - method: "wizard.next", - params: [ - "sessionId": ProtoAnyCodable(sessionId), - "answer": ProtoAnyCodable(answerPayload), - ]) - nextResult = try await client.decodePayload(response, as: WizardNextResult.self) - if opts.json { - dumpResult(response) - } - } else { - let response = try await client.request( - method: "wizard.next", - params: ["sessionId": ProtoAnyCodable(sessionId)]) - nextResult = try await client.decodePayload(response, as: WizardNextResult.self) - if opts.json { - dumpResult(response) - } - } - } - } catch WizardCliError.cancelled { - _ = try? await client.request( - method: "wizard.cancel", - params: ["sessionId": ProtoAnyCodable(sessionId)]) - throw WizardCliError.cancelled - } -} - -private func dumpResult(_ response: ResponseFrame) { - guard let payload = response.payload else { - print("{\"error\":\"missing payload\"}") - return - } - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - if let data = try? encoder.encode(payload), let text = String(data: data, encoding: .utf8) { - print(text) - } -} - -private func promptAnswer(for step: WizardStep) throws -> Any { - let type = wizardStepType(step) - if let title = step.title, !title.isEmpty { - print("\n\(title)") - } - if let message = step.message, !message.isEmpty { - print(message) - } - - switch type { - case "note": - _ = try readLineWithPrompt("Continue? (enter)") - return NSNull() - case "progress": - _ = try readLineWithPrompt("Continue? (enter)") - return NSNull() - case "action": - _ = try readLineWithPrompt("Run? (enter)") - return true - case "text": - let initial = anyCodableString(step.initialvalue) - let prompt = step.placeholder ?? "Value" - let value = try readLineWithPrompt("\(prompt)\(initial.isEmpty ? "" : " [\(initial)]")") - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? initial : trimmed - case "confirm": - let initial = anyCodableBool(step.initialvalue) - let value = try readLineWithPrompt("Confirm? (y/n) [\(initial ? "y" : "n")]") - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if trimmed.isEmpty { return initial } - return trimmed == "y" || trimmed == "yes" || trimmed == "true" - case "select": - return try promptSelect(step) - case "multiselect": - return try promptMultiSelect(step) - default: - _ = try readLineWithPrompt("Continue? (enter)") - return NSNull() - } -} - -private func promptSelect(_ step: WizardStep) throws -> Any { - let options = parseWizardOptions(step.options) - guard !options.isEmpty else { return NSNull() } - for (idx, option) in options.enumerated() { - let hint = option.hint?.isEmpty == false ? " — \(option.hint!)" : "" - print(" [\(idx + 1)] \(option.label)\(hint)") - } - let initialIndex = options.firstIndex(where: { anyCodableEqual($0.value, step.initialvalue) }) - let defaultLabel = initialIndex.map { " [\($0 + 1)]" } ?? "" - while true { - let input = try readLineWithPrompt("Select one\(defaultLabel)") - let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty, let initialIndex { - return options[initialIndex].value?.value ?? options[initialIndex].label - } - if trimmed.lowercased() == "q" { throw WizardCliError.cancelled } - if let number = Int(trimmed), (1...options.count).contains(number) { - let option = options[number - 1] - return option.value?.value ?? option.label - } - print("Invalid selection.") - } -} - -private func promptMultiSelect(_ step: WizardStep) throws -> [Any] { - let options = parseWizardOptions(step.options) - guard !options.isEmpty else { return [] } - for (idx, option) in options.enumerated() { - let hint = option.hint?.isEmpty == false ? " — \(option.hint!)" : "" - print(" [\(idx + 1)] \(option.label)\(hint)") - } - let initialValues = anyCodableArray(step.initialvalue) - let initialIndices = options.enumerated().compactMap { index, option in - initialValues.contains { anyCodableEqual($0, option.value) } ? index + 1 : nil - } - let defaultLabel = initialIndices.isEmpty ? "" : " [\(initialIndices.map(String.init).joined(separator: ","))]" - while true { - let input = try readLineWithPrompt("Select (comma-separated)\(defaultLabel)") - let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { - return initialIndices.map { options[$0 - 1].value?.value ?? options[$0 - 1].label } - } - if trimmed.lowercased() == "q" { throw WizardCliError.cancelled } - let parts = trimmed.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - let indices = parts.compactMap { Int($0) }.filter { (1...options.count).contains($0) } - if indices.isEmpty { - print("Invalid selection.") - continue - } - return indices.map { options[$0 - 1].value?.value ?? options[$0 - 1].label } - } -} - -private func readLineWithPrompt(_ prompt: String) throws -> String { - print("\(prompt): ", terminator: "") - guard let line = readLine() else { - throw WizardCliError.cancelled - } - return line -} diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift deleted file mode 100644 index 2f2dd7f6090..00000000000 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ /dev/null @@ -1,3053 +0,0 @@ -// Generated by scripts/protocol-gen-swift.ts — do not edit by hand -// swiftlint:disable file_length -import Foundation - -public let GATEWAY_PROTOCOL_VERSION = 3 - -public enum ErrorCode: String, Codable, Sendable { - case notLinked = "NOT_LINKED" - case notPaired = "NOT_PAIRED" - case agentTimeout = "AGENT_TIMEOUT" - case invalidRequest = "INVALID_REQUEST" - case unavailable = "UNAVAILABLE" -} - -public struct ConnectParams: Codable, Sendable { - public let minprotocol: Int - public let maxprotocol: Int - public let client: [String: AnyCodable] - public let caps: [String]? - public let commands: [String]? - public let permissions: [String: AnyCodable]? - public let pathenv: String? - public let role: String? - public let scopes: [String]? - public let device: [String: AnyCodable]? - public let auth: [String: AnyCodable]? - public let locale: String? - public let useragent: String? - - public init( - minprotocol: Int, - maxprotocol: Int, - client: [String: AnyCodable], - caps: [String]?, - commands: [String]?, - permissions: [String: AnyCodable]?, - pathenv: String?, - role: String?, - scopes: [String]?, - device: [String: AnyCodable]?, - auth: [String: AnyCodable]?, - locale: String?, - useragent: String?) - { - self.minprotocol = minprotocol - self.maxprotocol = maxprotocol - self.client = client - self.caps = caps - self.commands = commands - self.permissions = permissions - self.pathenv = pathenv - self.role = role - self.scopes = scopes - self.device = device - self.auth = auth - self.locale = locale - self.useragent = useragent - } - - private enum CodingKeys: String, CodingKey { - case minprotocol = "minProtocol" - case maxprotocol = "maxProtocol" - case client - case caps - case commands - case permissions - case pathenv = "pathEnv" - case role - case scopes - case device - case auth - case locale - case useragent = "userAgent" - } -} - -public struct HelloOk: Codable, Sendable { - public let type: String - public let _protocol: Int - public let server: [String: AnyCodable] - public let features: [String: AnyCodable] - public let snapshot: Snapshot - public let canvashosturl: String? - public let auth: [String: AnyCodable]? - public let policy: [String: AnyCodable] - - public init( - type: String, - _protocol: Int, - server: [String: AnyCodable], - features: [String: AnyCodable], - snapshot: Snapshot, - canvashosturl: String?, - auth: [String: AnyCodable]?, - policy: [String: AnyCodable]) - { - self.type = type - self._protocol = _protocol - self.server = server - self.features = features - self.snapshot = snapshot - self.canvashosturl = canvashosturl - self.auth = auth - self.policy = policy - } - - private enum CodingKeys: String, CodingKey { - case type - case _protocol = "protocol" - case server - case features - case snapshot - case canvashosturl = "canvasHostUrl" - case auth - case policy - } -} - -public struct RequestFrame: Codable, Sendable { - public let type: String - public let id: String - public let method: String - public let params: AnyCodable? - - public init( - type: String, - id: String, - method: String, - params: AnyCodable?) - { - self.type = type - self.id = id - self.method = method - self.params = params - } - - private enum CodingKeys: String, CodingKey { - case type - case id - case method - case params - } -} - -public struct ResponseFrame: Codable, Sendable { - public let type: String - public let id: String - public let ok: Bool - public let payload: AnyCodable? - public let error: [String: AnyCodable]? - - public init( - type: String, - id: String, - ok: Bool, - payload: AnyCodable?, - error: [String: AnyCodable]?) - { - self.type = type - self.id = id - self.ok = ok - self.payload = payload - self.error = error - } - - private enum CodingKeys: String, CodingKey { - case type - case id - case ok - case payload - case error - } -} - -public struct EventFrame: Codable, Sendable { - public let type: String - public let event: String - public let payload: AnyCodable? - public let seq: Int? - public let stateversion: [String: AnyCodable]? - - public init( - type: String, - event: String, - payload: AnyCodable?, - seq: Int?, - stateversion: [String: AnyCodable]?) - { - self.type = type - self.event = event - self.payload = payload - self.seq = seq - self.stateversion = stateversion - } - - private enum CodingKeys: String, CodingKey { - case type - case event - case payload - case seq - case stateversion = "stateVersion" - } -} - -public struct PresenceEntry: Codable, Sendable { - public let host: String? - public let ip: String? - public let version: String? - public let platform: String? - public let devicefamily: String? - public let modelidentifier: String? - public let mode: String? - public let lastinputseconds: Int? - public let reason: String? - public let tags: [String]? - public let text: String? - public let ts: Int - public let deviceid: String? - public let roles: [String]? - public let scopes: [String]? - public let instanceid: String? - - public init( - host: String?, - ip: String?, - version: String?, - platform: String?, - devicefamily: String?, - modelidentifier: String?, - mode: String?, - lastinputseconds: Int?, - reason: String?, - tags: [String]?, - text: String?, - ts: Int, - deviceid: String?, - roles: [String]?, - scopes: [String]?, - instanceid: String?) - { - self.host = host - self.ip = ip - self.version = version - self.platform = platform - self.devicefamily = devicefamily - self.modelidentifier = modelidentifier - self.mode = mode - self.lastinputseconds = lastinputseconds - self.reason = reason - self.tags = tags - self.text = text - self.ts = ts - self.deviceid = deviceid - self.roles = roles - self.scopes = scopes - self.instanceid = instanceid - } - - private enum CodingKeys: String, CodingKey { - case host - case ip - case version - case platform - case devicefamily = "deviceFamily" - case modelidentifier = "modelIdentifier" - case mode - case lastinputseconds = "lastInputSeconds" - case reason - case tags - case text - case ts - case deviceid = "deviceId" - case roles - case scopes - case instanceid = "instanceId" - } -} - -public struct StateVersion: Codable, Sendable { - public let presence: Int - public let health: Int - - public init( - presence: Int, - health: Int) - { - self.presence = presence - self.health = health - } - - private enum CodingKeys: String, CodingKey { - case presence - case health - } -} - -public struct Snapshot: Codable, Sendable { - public let presence: [PresenceEntry] - public let health: AnyCodable - public let stateversion: StateVersion - public let uptimems: Int - public let configpath: String? - public let statedir: String? - public let sessiondefaults: [String: AnyCodable]? - public let authmode: AnyCodable? - public let updateavailable: [String: AnyCodable]? - - public init( - presence: [PresenceEntry], - health: AnyCodable, - stateversion: StateVersion, - uptimems: Int, - configpath: String?, - statedir: String?, - sessiondefaults: [String: AnyCodable]?, - authmode: AnyCodable?, - updateavailable: [String: AnyCodable]?) - { - self.presence = presence - self.health = health - self.stateversion = stateversion - self.uptimems = uptimems - self.configpath = configpath - self.statedir = statedir - self.sessiondefaults = sessiondefaults - self.authmode = authmode - self.updateavailable = updateavailable - } - - private enum CodingKeys: String, CodingKey { - case presence - case health - case stateversion = "stateVersion" - case uptimems = "uptimeMs" - case configpath = "configPath" - case statedir = "stateDir" - case sessiondefaults = "sessionDefaults" - case authmode = "authMode" - case updateavailable = "updateAvailable" - } -} - -public struct ErrorShape: Codable, Sendable { - public let code: String - public let message: String - public let details: AnyCodable? - public let retryable: Bool? - public let retryafterms: Int? - - public init( - code: String, - message: String, - details: AnyCodable?, - retryable: Bool?, - retryafterms: Int?) - { - self.code = code - self.message = message - self.details = details - self.retryable = retryable - self.retryafterms = retryafterms - } - - private enum CodingKeys: String, CodingKey { - case code - case message - case details - case retryable - case retryafterms = "retryAfterMs" - } -} - -public struct AgentEvent: Codable, Sendable { - public let runid: String - public let seq: Int - public let stream: String - public let ts: Int - public let data: [String: AnyCodable] - - public init( - runid: String, - seq: Int, - stream: String, - ts: Int, - data: [String: AnyCodable]) - { - self.runid = runid - self.seq = seq - self.stream = stream - self.ts = ts - self.data = data - } - - private enum CodingKeys: String, CodingKey { - case runid = "runId" - case seq - case stream - case ts - case data - } -} - -public struct SendParams: Codable, Sendable { - public let to: String - public let message: String? - public let mediaurl: String? - public let mediaurls: [String]? - public let gifplayback: Bool? - public let channel: String? - public let accountid: String? - public let threadid: String? - public let sessionkey: String? - public let idempotencykey: String - - public init( - to: String, - message: String?, - mediaurl: String?, - mediaurls: [String]?, - gifplayback: Bool?, - channel: String?, - accountid: String?, - threadid: String?, - sessionkey: String?, - idempotencykey: String) - { - self.to = to - self.message = message - self.mediaurl = mediaurl - self.mediaurls = mediaurls - self.gifplayback = gifplayback - self.channel = channel - self.accountid = accountid - self.threadid = threadid - self.sessionkey = sessionkey - self.idempotencykey = idempotencykey - } - - private enum CodingKeys: String, CodingKey { - case to - case message - case mediaurl = "mediaUrl" - case mediaurls = "mediaUrls" - case gifplayback = "gifPlayback" - case channel - case accountid = "accountId" - case threadid = "threadId" - case sessionkey = "sessionKey" - case idempotencykey = "idempotencyKey" - } -} - -public struct PollParams: Codable, Sendable { - public let to: String - public let question: String - public let options: [String] - public let maxselections: Int? - public let durationseconds: Int? - public let durationhours: Int? - public let silent: Bool? - public let isanonymous: Bool? - public let threadid: String? - public let channel: String? - public let accountid: String? - public let idempotencykey: String - - public init( - to: String, - question: String, - options: [String], - maxselections: Int?, - durationseconds: Int?, - durationhours: Int?, - silent: Bool?, - isanonymous: Bool?, - threadid: String?, - channel: String?, - accountid: String?, - idempotencykey: String) - { - self.to = to - self.question = question - self.options = options - self.maxselections = maxselections - self.durationseconds = durationseconds - self.durationhours = durationhours - self.silent = silent - self.isanonymous = isanonymous - self.threadid = threadid - self.channel = channel - self.accountid = accountid - self.idempotencykey = idempotencykey - } - - private enum CodingKeys: String, CodingKey { - case to - case question - case options - case maxselections = "maxSelections" - case durationseconds = "durationSeconds" - case durationhours = "durationHours" - case silent - case isanonymous = "isAnonymous" - case threadid = "threadId" - case channel - case accountid = "accountId" - case idempotencykey = "idempotencyKey" - } -} - -public struct AgentParams: Codable, Sendable { - public let message: String - public let agentid: String? - public let to: String? - public let replyto: String? - public let sessionid: String? - public let sessionkey: String? - public let thinking: String? - public let deliver: Bool? - public let attachments: [AnyCodable]? - public let channel: String? - public let replychannel: String? - public let accountid: String? - public let replyaccountid: String? - public let threadid: String? - public let groupid: String? - public let groupchannel: String? - public let groupspace: String? - public let timeout: Int? - public let lane: String? - public let extrasystemprompt: String? - public let inputprovenance: [String: AnyCodable]? - public let idempotencykey: String - public let label: String? - public let spawnedby: String? - - public init( - message: String, - agentid: String?, - to: String?, - replyto: String?, - sessionid: String?, - sessionkey: String?, - thinking: String?, - deliver: Bool?, - attachments: [AnyCodable]?, - channel: String?, - replychannel: String?, - accountid: String?, - replyaccountid: String?, - threadid: String?, - groupid: String?, - groupchannel: String?, - groupspace: String?, - timeout: Int?, - lane: String?, - extrasystemprompt: String?, - inputprovenance: [String: AnyCodable]?, - idempotencykey: String, - label: String?, - spawnedby: String?) - { - self.message = message - self.agentid = agentid - self.to = to - self.replyto = replyto - self.sessionid = sessionid - self.sessionkey = sessionkey - self.thinking = thinking - self.deliver = deliver - self.attachments = attachments - self.channel = channel - self.replychannel = replychannel - self.accountid = accountid - self.replyaccountid = replyaccountid - self.threadid = threadid - self.groupid = groupid - self.groupchannel = groupchannel - self.groupspace = groupspace - self.timeout = timeout - self.lane = lane - self.extrasystemprompt = extrasystemprompt - self.inputprovenance = inputprovenance - self.idempotencykey = idempotencykey - self.label = label - self.spawnedby = spawnedby - } - - private enum CodingKeys: String, CodingKey { - case message - case agentid = "agentId" - case to - case replyto = "replyTo" - case sessionid = "sessionId" - case sessionkey = "sessionKey" - case thinking - case deliver - case attachments - case channel - case replychannel = "replyChannel" - case accountid = "accountId" - case replyaccountid = "replyAccountId" - case threadid = "threadId" - case groupid = "groupId" - case groupchannel = "groupChannel" - case groupspace = "groupSpace" - case timeout - case lane - case extrasystemprompt = "extraSystemPrompt" - case inputprovenance = "inputProvenance" - case idempotencykey = "idempotencyKey" - case label - case spawnedby = "spawnedBy" - } -} - -public struct AgentIdentityParams: Codable, Sendable { - public let agentid: String? - public let sessionkey: String? - - public init( - agentid: String?, - sessionkey: String?) - { - self.agentid = agentid - self.sessionkey = sessionkey - } - - private enum CodingKeys: String, CodingKey { - case agentid = "agentId" - case sessionkey = "sessionKey" - } -} - -public struct AgentIdentityResult: Codable, Sendable { - public let agentid: String - public let name: String? - public let avatar: String? - public let emoji: String? - - public init( - agentid: String, - name: String?, - avatar: String?, - emoji: String?) - { - self.agentid = agentid - self.name = name - self.avatar = avatar - self.emoji = emoji - } - - private enum CodingKeys: String, CodingKey { - case agentid = "agentId" - case name - case avatar - case emoji - } -} - -public struct AgentWaitParams: Codable, Sendable { - public let runid: String - public let timeoutms: Int? - - public init( - runid: String, - timeoutms: Int?) - { - self.runid = runid - self.timeoutms = timeoutms - } - - private enum CodingKeys: String, CodingKey { - case runid = "runId" - case timeoutms = "timeoutMs" - } -} - -public struct WakeParams: Codable, Sendable { - public let mode: AnyCodable - public let text: String - - public init( - mode: AnyCodable, - text: String) - { - self.mode = mode - self.text = text - } - - private enum CodingKeys: String, CodingKey { - case mode - case text - } -} - -public struct NodePairRequestParams: Codable, Sendable { - public let nodeid: String - public let displayname: String? - public let platform: String? - public let version: String? - public let coreversion: String? - public let uiversion: String? - public let devicefamily: String? - public let modelidentifier: String? - public let caps: [String]? - public let commands: [String]? - public let remoteip: String? - public let silent: Bool? - - public init( - nodeid: String, - displayname: String?, - platform: String?, - version: String?, - coreversion: String?, - uiversion: String?, - devicefamily: String?, - modelidentifier: String?, - caps: [String]?, - commands: [String]?, - remoteip: String?, - silent: Bool?) - { - self.nodeid = nodeid - self.displayname = displayname - self.platform = platform - self.version = version - self.coreversion = coreversion - self.uiversion = uiversion - self.devicefamily = devicefamily - self.modelidentifier = modelidentifier - self.caps = caps - self.commands = commands - self.remoteip = remoteip - self.silent = silent - } - - private enum CodingKeys: String, CodingKey { - case nodeid = "nodeId" - case displayname = "displayName" - case platform - case version - case coreversion = "coreVersion" - case uiversion = "uiVersion" - case devicefamily = "deviceFamily" - case modelidentifier = "modelIdentifier" - case caps - case commands - case remoteip = "remoteIp" - case silent - } -} - -public struct NodePairListParams: Codable, Sendable {} - -public struct NodePairApproveParams: Codable, Sendable { - public let requestid: String - - public init( - requestid: String) - { - self.requestid = requestid - } - - private enum CodingKeys: String, CodingKey { - case requestid = "requestId" - } -} - -public struct NodePairRejectParams: Codable, Sendable { - public let requestid: String - - public init( - requestid: String) - { - self.requestid = requestid - } - - private enum CodingKeys: String, CodingKey { - case requestid = "requestId" - } -} - -public struct NodePairVerifyParams: Codable, Sendable { - public let nodeid: String - public let token: String - - public init( - nodeid: String, - token: String) - { - self.nodeid = nodeid - self.token = token - } - - private enum CodingKeys: String, CodingKey { - case nodeid = "nodeId" - case token - } -} - -public struct NodeRenameParams: Codable, Sendable { - public let nodeid: String - public let displayname: String - - public init( - nodeid: String, - displayname: String) - { - self.nodeid = nodeid - self.displayname = displayname - } - - private enum CodingKeys: String, CodingKey { - case nodeid = "nodeId" - case displayname = "displayName" - } -} - -public struct NodeListParams: Codable, Sendable {} - -public struct NodeDescribeParams: Codable, Sendable { - public let nodeid: String - - public init( - nodeid: String) - { - self.nodeid = nodeid - } - - private enum CodingKeys: String, CodingKey { - case nodeid = "nodeId" - } -} - -public struct NodeInvokeParams: Codable, Sendable { - public let nodeid: String - public let command: String - public let params: AnyCodable? - public let timeoutms: Int? - public let idempotencykey: String - - public init( - nodeid: String, - command: String, - params: AnyCodable?, - timeoutms: Int?, - idempotencykey: String) - { - self.nodeid = nodeid - self.command = command - self.params = params - self.timeoutms = timeoutms - self.idempotencykey = idempotencykey - } - - private enum CodingKeys: String, CodingKey { - case nodeid = "nodeId" - case command - case params - case timeoutms = "timeoutMs" - case idempotencykey = "idempotencyKey" - } -} - -public struct NodeInvokeResultParams: Codable, Sendable { - public let id: String - public let nodeid: String - public let ok: Bool - public let payload: AnyCodable? - public let payloadjson: String? - public let error: [String: AnyCodable]? - - public init( - id: String, - nodeid: String, - ok: Bool, - payload: AnyCodable?, - payloadjson: String?, - error: [String: AnyCodable]?) - { - self.id = id - self.nodeid = nodeid - self.ok = ok - self.payload = payload - self.payloadjson = payloadjson - self.error = error - } - - private enum CodingKeys: String, CodingKey { - case id - case nodeid = "nodeId" - case ok - case payload - case payloadjson = "payloadJSON" - case error - } -} - -public struct NodeEventParams: Codable, Sendable { - public let event: String - public let payload: AnyCodable? - public let payloadjson: String? - - public init( - event: String, - payload: AnyCodable?, - payloadjson: String?) - { - self.event = event - self.payload = payload - self.payloadjson = payloadjson - } - - private enum CodingKeys: String, CodingKey { - case event - case payload - case payloadjson = "payloadJSON" - } -} - -public struct NodeInvokeRequestEvent: Codable, Sendable { - public let id: String - public let nodeid: String - public let command: String - public let paramsjson: String? - public let timeoutms: Int? - public let idempotencykey: String? - - public init( - id: String, - nodeid: String, - command: String, - paramsjson: String?, - timeoutms: Int?, - idempotencykey: String?) - { - self.id = id - self.nodeid = nodeid - self.command = command - self.paramsjson = paramsjson - self.timeoutms = timeoutms - self.idempotencykey = idempotencykey - } - - private enum CodingKeys: String, CodingKey { - case id - case nodeid = "nodeId" - case command - case paramsjson = "paramsJSON" - case timeoutms = "timeoutMs" - case idempotencykey = "idempotencyKey" - } -} - -public struct PushTestParams: Codable, Sendable { - public let nodeid: String - public let title: String? - public let body: String? - public let environment: String? - - public init( - nodeid: String, - title: String?, - body: String?, - environment: String?) - { - self.nodeid = nodeid - self.title = title - self.body = body - self.environment = environment - } - - private enum CodingKeys: String, CodingKey { - case nodeid = "nodeId" - case title - case body - case environment - } -} - -public struct PushTestResult: Codable, Sendable { - public let ok: Bool - public let status: Int - public let apnsid: String? - public let reason: String? - public let tokensuffix: String - public let topic: String - public let environment: String - - public init( - ok: Bool, - status: Int, - apnsid: String?, - reason: String?, - tokensuffix: String, - topic: String, - environment: String) - { - self.ok = ok - self.status = status - self.apnsid = apnsid - self.reason = reason - self.tokensuffix = tokensuffix - self.topic = topic - self.environment = environment - } - - private enum CodingKeys: String, CodingKey { - case ok - case status - case apnsid = "apnsId" - case reason - case tokensuffix = "tokenSuffix" - case topic - case environment - } -} - -public struct SessionsListParams: Codable, Sendable { - public let limit: Int? - public let activeminutes: Int? - public let includeglobal: Bool? - public let includeunknown: Bool? - public let includederivedtitles: Bool? - public let includelastmessage: Bool? - public let label: String? - public let spawnedby: String? - public let agentid: String? - public let search: String? - - public init( - limit: Int?, - activeminutes: Int?, - includeglobal: Bool?, - includeunknown: Bool?, - includederivedtitles: Bool?, - includelastmessage: Bool?, - label: String?, - spawnedby: String?, - agentid: String?, - search: String?) - { - self.limit = limit - self.activeminutes = activeminutes - self.includeglobal = includeglobal - self.includeunknown = includeunknown - self.includederivedtitles = includederivedtitles - self.includelastmessage = includelastmessage - self.label = label - self.spawnedby = spawnedby - self.agentid = agentid - self.search = search - } - - private enum CodingKeys: String, CodingKey { - case limit - case activeminutes = "activeMinutes" - case includeglobal = "includeGlobal" - case includeunknown = "includeUnknown" - case includederivedtitles = "includeDerivedTitles" - case includelastmessage = "includeLastMessage" - case label - case spawnedby = "spawnedBy" - case agentid = "agentId" - case search - } -} - -public struct SessionsPreviewParams: Codable, Sendable { - public let keys: [String] - public let limit: Int? - public let maxchars: Int? - - public init( - keys: [String], - limit: Int?, - maxchars: Int?) - { - self.keys = keys - self.limit = limit - self.maxchars = maxchars - } - - private enum CodingKeys: String, CodingKey { - case keys - case limit - case maxchars = "maxChars" - } -} - -public struct SessionsResolveParams: Codable, Sendable { - public let key: String? - public let sessionid: String? - public let label: String? - public let agentid: String? - public let spawnedby: String? - public let includeglobal: Bool? - public let includeunknown: Bool? - - public init( - key: String?, - sessionid: String?, - label: String?, - agentid: String?, - spawnedby: String?, - includeglobal: Bool?, - includeunknown: Bool?) - { - self.key = key - self.sessionid = sessionid - self.label = label - self.agentid = agentid - self.spawnedby = spawnedby - self.includeglobal = includeglobal - self.includeunknown = includeunknown - } - - private enum CodingKeys: String, CodingKey { - case key - case sessionid = "sessionId" - case label - case agentid = "agentId" - case spawnedby = "spawnedBy" - case includeglobal = "includeGlobal" - case includeunknown = "includeUnknown" - } -} - -public struct SessionsPatchParams: Codable, Sendable { - public let key: String - public let label: AnyCodable? - public let thinkinglevel: AnyCodable? - public let verboselevel: AnyCodable? - public let reasoninglevel: AnyCodable? - public let responseusage: AnyCodable? - public let elevatedlevel: AnyCodable? - public let exechost: AnyCodable? - public let execsecurity: AnyCodable? - public let execask: AnyCodable? - public let execnode: AnyCodable? - public let model: AnyCodable? - public let spawnedby: AnyCodable? - public let spawndepth: AnyCodable? - public let sendpolicy: AnyCodable? - public let groupactivation: AnyCodable? - - public init( - key: String, - label: AnyCodable?, - thinkinglevel: AnyCodable?, - verboselevel: AnyCodable?, - reasoninglevel: AnyCodable?, - responseusage: AnyCodable?, - elevatedlevel: AnyCodable?, - exechost: AnyCodable?, - execsecurity: AnyCodable?, - execask: AnyCodable?, - execnode: AnyCodable?, - model: AnyCodable?, - spawnedby: AnyCodable?, - spawndepth: AnyCodable?, - sendpolicy: AnyCodable?, - groupactivation: AnyCodable?) - { - self.key = key - self.label = label - self.thinkinglevel = thinkinglevel - self.verboselevel = verboselevel - self.reasoninglevel = reasoninglevel - self.responseusage = responseusage - self.elevatedlevel = elevatedlevel - self.exechost = exechost - self.execsecurity = execsecurity - self.execask = execask - self.execnode = execnode - self.model = model - self.spawnedby = spawnedby - self.spawndepth = spawndepth - self.sendpolicy = sendpolicy - self.groupactivation = groupactivation - } - - private enum CodingKeys: String, CodingKey { - case key - case label - case thinkinglevel = "thinkingLevel" - case verboselevel = "verboseLevel" - case reasoninglevel = "reasoningLevel" - case responseusage = "responseUsage" - case elevatedlevel = "elevatedLevel" - case exechost = "execHost" - case execsecurity = "execSecurity" - case execask = "execAsk" - case execnode = "execNode" - case model - case spawnedby = "spawnedBy" - case spawndepth = "spawnDepth" - case sendpolicy = "sendPolicy" - case groupactivation = "groupActivation" - } -} - -public struct SessionsResetParams: Codable, Sendable { - public let key: String - public let reason: AnyCodable? - - public init( - key: String, - reason: AnyCodable?) - { - self.key = key - self.reason = reason - } - - private enum CodingKeys: String, CodingKey { - case key - case reason - } -} - -public struct SessionsDeleteParams: Codable, Sendable { - public let key: String - public let deletetranscript: Bool? - public let emitlifecyclehooks: Bool? - - public init( - key: String, - deletetranscript: Bool?, - emitlifecyclehooks: Bool?) - { - self.key = key - self.deletetranscript = deletetranscript - self.emitlifecyclehooks = emitlifecyclehooks - } - - private enum CodingKeys: String, CodingKey { - case key - case deletetranscript = "deleteTranscript" - case emitlifecyclehooks = "emitLifecycleHooks" - } -} - -public struct SessionsCompactParams: Codable, Sendable { - public let key: String - public let maxlines: Int? - - public init( - key: String, - maxlines: Int?) - { - self.key = key - self.maxlines = maxlines - } - - private enum CodingKeys: String, CodingKey { - case key - case maxlines = "maxLines" - } -} - -public struct SessionsUsageParams: Codable, Sendable { - public let key: String? - public let startdate: String? - public let enddate: String? - public let mode: AnyCodable? - public let utcoffset: String? - public let limit: Int? - public let includecontextweight: Bool? - - public init( - key: String?, - startdate: String?, - enddate: String?, - mode: AnyCodable?, - utcoffset: String?, - limit: Int?, - includecontextweight: Bool?) - { - self.key = key - self.startdate = startdate - self.enddate = enddate - self.mode = mode - self.utcoffset = utcoffset - self.limit = limit - self.includecontextweight = includecontextweight - } - - private enum CodingKeys: String, CodingKey { - case key - case startdate = "startDate" - case enddate = "endDate" - case mode - case utcoffset = "utcOffset" - case limit - case includecontextweight = "includeContextWeight" - } -} - -public struct ConfigGetParams: Codable, Sendable {} - -public struct ConfigSetParams: Codable, Sendable { - public let raw: String - public let basehash: String? - - public init( - raw: String, - basehash: String?) - { - self.raw = raw - self.basehash = basehash - } - - private enum CodingKeys: String, CodingKey { - case raw - case basehash = "baseHash" - } -} - -public struct ConfigApplyParams: Codable, Sendable { - public let raw: String - public let basehash: String? - public let sessionkey: String? - public let note: String? - public let restartdelayms: Int? - - public init( - raw: String, - basehash: String?, - sessionkey: String?, - note: String?, - restartdelayms: Int?) - { - self.raw = raw - self.basehash = basehash - self.sessionkey = sessionkey - self.note = note - self.restartdelayms = restartdelayms - } - - private enum CodingKeys: String, CodingKey { - case raw - case basehash = "baseHash" - case sessionkey = "sessionKey" - case note - case restartdelayms = "restartDelayMs" - } -} - -public struct ConfigPatchParams: Codable, Sendable { - public let raw: String - public let basehash: String? - public let sessionkey: String? - public let note: String? - public let restartdelayms: Int? - - public init( - raw: String, - basehash: String?, - sessionkey: String?, - note: String?, - restartdelayms: Int?) - { - self.raw = raw - self.basehash = basehash - self.sessionkey = sessionkey - self.note = note - self.restartdelayms = restartdelayms - } - - private enum CodingKeys: String, CodingKey { - case raw - case basehash = "baseHash" - case sessionkey = "sessionKey" - case note - case restartdelayms = "restartDelayMs" - } -} - -public struct ConfigSchemaParams: Codable, Sendable {} - -public struct ConfigSchemaResponse: Codable, Sendable { - public let schema: AnyCodable - public let uihints: [String: AnyCodable] - public let version: String - public let generatedat: String - - public init( - schema: AnyCodable, - uihints: [String: AnyCodable], - version: String, - generatedat: String) - { - self.schema = schema - self.uihints = uihints - self.version = version - self.generatedat = generatedat - } - - private enum CodingKeys: String, CodingKey { - case schema - case uihints = "uiHints" - case version - case generatedat = "generatedAt" - } -} - -public struct WizardStartParams: Codable, Sendable { - public let mode: AnyCodable? - public let workspace: String? - - public init( - mode: AnyCodable?, - workspace: String?) - { - self.mode = mode - self.workspace = workspace - } - - private enum CodingKeys: String, CodingKey { - case mode - case workspace - } -} - -public struct WizardNextParams: Codable, Sendable { - public let sessionid: String - public let answer: [String: AnyCodable]? - - public init( - sessionid: String, - answer: [String: AnyCodable]?) - { - self.sessionid = sessionid - self.answer = answer - } - - private enum CodingKeys: String, CodingKey { - case sessionid = "sessionId" - case answer - } -} - -public struct WizardCancelParams: Codable, Sendable { - public let sessionid: String - - public init( - sessionid: String) - { - self.sessionid = sessionid - } - - private enum CodingKeys: String, CodingKey { - case sessionid = "sessionId" - } -} - -public struct WizardStatusParams: Codable, Sendable { - public let sessionid: String - - public init( - sessionid: String) - { - self.sessionid = sessionid - } - - private enum CodingKeys: String, CodingKey { - case sessionid = "sessionId" - } -} - -public struct WizardStep: Codable, Sendable { - public let id: String - public let type: AnyCodable - public let title: String? - public let message: String? - public let options: [[String: AnyCodable]]? - public let initialvalue: AnyCodable? - public let placeholder: String? - public let sensitive: Bool? - public let executor: AnyCodable? - - public init( - id: String, - type: AnyCodable, - title: String?, - message: String?, - options: [[String: AnyCodable]]?, - initialvalue: AnyCodable?, - placeholder: String?, - sensitive: Bool?, - executor: AnyCodable?) - { - self.id = id - self.type = type - self.title = title - self.message = message - self.options = options - self.initialvalue = initialvalue - self.placeholder = placeholder - self.sensitive = sensitive - self.executor = executor - } - - private enum CodingKeys: String, CodingKey { - case id - case type - case title - case message - case options - case initialvalue = "initialValue" - case placeholder - case sensitive - case executor - } -} - -public struct WizardNextResult: Codable, Sendable { - public let done: Bool - public let step: [String: AnyCodable]? - public let status: AnyCodable? - public let error: String? - - public init( - done: Bool, - step: [String: AnyCodable]?, - status: AnyCodable?, - error: String?) - { - self.done = done - self.step = step - self.status = status - self.error = error - } - - private enum CodingKeys: String, CodingKey { - case done - case step - case status - case error - } -} - -public struct WizardStartResult: Codable, Sendable { - public let sessionid: String - public let done: Bool - public let step: [String: AnyCodable]? - public let status: AnyCodable? - public let error: String? - - public init( - sessionid: String, - done: Bool, - step: [String: AnyCodable]?, - status: AnyCodable?, - error: String?) - { - self.sessionid = sessionid - self.done = done - self.step = step - self.status = status - self.error = error - } - - private enum CodingKeys: String, CodingKey { - case sessionid = "sessionId" - case done - case step - case status - case error - } -} - -public struct WizardStatusResult: Codable, Sendable { - public let status: AnyCodable - public let error: String? - - public init( - status: AnyCodable, - error: String?) - { - self.status = status - self.error = error - } - - private enum CodingKeys: String, CodingKey { - case status - case error - } -} - -public struct TalkModeParams: Codable, Sendable { - public let enabled: Bool - public let phase: String? - - public init( - enabled: Bool, - phase: String?) - { - self.enabled = enabled - self.phase = phase - } - - private enum CodingKeys: String, CodingKey { - case enabled - case phase - } -} - -public struct TalkConfigParams: Codable, Sendable { - public let includesecrets: Bool? - - public init( - includesecrets: Bool?) - { - self.includesecrets = includesecrets - } - - private enum CodingKeys: String, CodingKey { - case includesecrets = "includeSecrets" - } -} - -public struct TalkConfigResult: Codable, Sendable { - public let config: [String: AnyCodable] - - public init( - config: [String: AnyCodable]) - { - self.config = config - } - - private enum CodingKeys: String, CodingKey { - case config - } -} - -public struct ChannelsStatusParams: Codable, Sendable { - public let probe: Bool? - public let timeoutms: Int? - - public init( - probe: Bool?, - timeoutms: Int?) - { - self.probe = probe - self.timeoutms = timeoutms - } - - private enum CodingKeys: String, CodingKey { - case probe - case timeoutms = "timeoutMs" - } -} - -public struct ChannelsStatusResult: Codable, Sendable { - public let ts: Int - public let channelorder: [String] - public let channellabels: [String: AnyCodable] - public let channeldetaillabels: [String: AnyCodable]? - public let channelsystemimages: [String: AnyCodable]? - public let channelmeta: [[String: AnyCodable]]? - public let channels: [String: AnyCodable] - public let channelaccounts: [String: AnyCodable] - public let channeldefaultaccountid: [String: AnyCodable] - - public init( - ts: Int, - channelorder: [String], - channellabels: [String: AnyCodable], - channeldetaillabels: [String: AnyCodable]?, - channelsystemimages: [String: AnyCodable]?, - channelmeta: [[String: AnyCodable]]?, - channels: [String: AnyCodable], - channelaccounts: [String: AnyCodable], - channeldefaultaccountid: [String: AnyCodable]) - { - self.ts = ts - self.channelorder = channelorder - self.channellabels = channellabels - self.channeldetaillabels = channeldetaillabels - self.channelsystemimages = channelsystemimages - self.channelmeta = channelmeta - self.channels = channels - self.channelaccounts = channelaccounts - self.channeldefaultaccountid = channeldefaultaccountid - } - - private enum CodingKeys: String, CodingKey { - case ts - case channelorder = "channelOrder" - case channellabels = "channelLabels" - case channeldetaillabels = "channelDetailLabels" - case channelsystemimages = "channelSystemImages" - case channelmeta = "channelMeta" - case channels - case channelaccounts = "channelAccounts" - case channeldefaultaccountid = "channelDefaultAccountId" - } -} - -public struct ChannelsLogoutParams: Codable, Sendable { - public let channel: String - public let accountid: String? - - public init( - channel: String, - accountid: String?) - { - self.channel = channel - self.accountid = accountid - } - - private enum CodingKeys: String, CodingKey { - case channel - case accountid = "accountId" - } -} - -public struct WebLoginStartParams: Codable, Sendable { - public let force: Bool? - public let timeoutms: Int? - public let verbose: Bool? - public let accountid: String? - - public init( - force: Bool?, - timeoutms: Int?, - verbose: Bool?, - accountid: String?) - { - self.force = force - self.timeoutms = timeoutms - self.verbose = verbose - self.accountid = accountid - } - - private enum CodingKeys: String, CodingKey { - case force - case timeoutms = "timeoutMs" - case verbose - case accountid = "accountId" - } -} - -public struct WebLoginWaitParams: Codable, Sendable { - public let timeoutms: Int? - public let accountid: String? - - public init( - timeoutms: Int?, - accountid: String?) - { - self.timeoutms = timeoutms - self.accountid = accountid - } - - private enum CodingKeys: String, CodingKey { - case timeoutms = "timeoutMs" - case accountid = "accountId" - } -} - -public struct AgentSummary: Codable, Sendable { - public let id: String - public let name: String? - public let identity: [String: AnyCodable]? - - public init( - id: String, - name: String?, - identity: [String: AnyCodable]?) - { - self.id = id - self.name = name - self.identity = identity - } - - private enum CodingKeys: String, CodingKey { - case id - case name - case identity - } -} - -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 - public let missing: Bool - public let size: Int? - public let updatedatms: Int? - public let content: String? - - public init( - name: String, - path: String, - missing: Bool, - size: Int?, - updatedatms: Int?, - content: String?) - { - self.name = name - self.path = path - self.missing = missing - self.size = size - self.updatedatms = updatedatms - self.content = content - } - - private enum CodingKeys: String, CodingKey { - case name - case path - case missing - case size - case updatedatms = "updatedAtMs" - case content - } -} - -public struct AgentsFilesListParams: Codable, Sendable { - public let agentid: String - - public init( - agentid: String) - { - self.agentid = agentid - } - - private enum CodingKeys: String, CodingKey { - case agentid = "agentId" - } -} - -public struct AgentsFilesListResult: Codable, Sendable { - public let agentid: String - public let workspace: String - public let files: [AgentsFileEntry] - - public init( - agentid: String, - workspace: String, - files: [AgentsFileEntry]) - { - self.agentid = agentid - self.workspace = workspace - self.files = files - } - - private enum CodingKeys: String, CodingKey { - case agentid = "agentId" - case workspace - case files - } -} - -public struct AgentsFilesGetParams: Codable, Sendable { - public let agentid: String - public let name: String - - public init( - agentid: String, - name: String) - { - self.agentid = agentid - self.name = name - } - - private enum CodingKeys: String, CodingKey { - case agentid = "agentId" - case name - } -} - -public struct AgentsFilesGetResult: Codable, Sendable { - public let agentid: String - public let workspace: String - public let file: AgentsFileEntry - - public init( - agentid: String, - workspace: String, - file: AgentsFileEntry) - { - self.agentid = agentid - self.workspace = workspace - self.file = file - } - - private enum CodingKeys: String, CodingKey { - case agentid = "agentId" - case workspace - case file - } -} - -public struct AgentsFilesSetParams: Codable, Sendable { - public let agentid: String - public let name: String - public let content: String - - public init( - agentid: String, - name: String, - content: String) - { - self.agentid = agentid - self.name = name - self.content = content - } - - private enum CodingKeys: String, CodingKey { - case agentid = "agentId" - case name - case content - } -} - -public struct AgentsFilesSetResult: Codable, Sendable { - public let ok: Bool - public let agentid: String - public let workspace: String - public let file: AgentsFileEntry - - public init( - ok: Bool, - agentid: String, - workspace: String, - file: AgentsFileEntry) - { - self.ok = ok - self.agentid = agentid - self.workspace = workspace - self.file = file - } - - private enum CodingKeys: String, CodingKey { - case ok - case agentid = "agentId" - case workspace - case file - } -} - -public struct AgentsListParams: Codable, Sendable {} - -public struct AgentsListResult: Codable, Sendable { - public let defaultid: String - public let mainkey: String - public let scope: AnyCodable - public let agents: [AgentSummary] - - public init( - defaultid: String, - mainkey: String, - scope: AnyCodable, - agents: [AgentSummary]) - { - self.defaultid = defaultid - self.mainkey = mainkey - self.scope = scope - self.agents = agents - } - - private enum CodingKeys: String, CodingKey { - case defaultid = "defaultId" - case mainkey = "mainKey" - case scope - case agents - } -} - -public struct ModelChoice: Codable, Sendable { - public let id: String - public let name: String - public let provider: String - public let contextwindow: Int? - public let reasoning: Bool? - - public init( - id: String, - name: String, - provider: String, - contextwindow: Int?, - reasoning: Bool?) - { - self.id = id - self.name = name - self.provider = provider - self.contextwindow = contextwindow - self.reasoning = reasoning - } - - private enum CodingKeys: String, CodingKey { - case id - case name - case provider - case contextwindow = "contextWindow" - case reasoning - } -} - -public struct ModelsListParams: Codable, Sendable {} - -public struct ModelsListResult: Codable, Sendable { - public let models: [ModelChoice] - - public init( - models: [ModelChoice]) - { - self.models = models - } - - private enum CodingKeys: String, CodingKey { - case models - } -} - -public struct SkillsStatusParams: Codable, Sendable { - public let agentid: String? - - public init( - agentid: String?) - { - self.agentid = agentid - } - - private enum CodingKeys: String, CodingKey { - case agentid = "agentId" - } -} - -public struct SkillsBinsParams: Codable, Sendable {} - -public struct SkillsBinsResult: Codable, Sendable { - public let bins: [String] - - public init( - bins: [String]) - { - self.bins = bins - } - - private enum CodingKeys: String, CodingKey { - case bins - } -} - -public struct SkillsInstallParams: Codable, Sendable { - public let name: String - public let installid: String - public let timeoutms: Int? - - public init( - name: String, - installid: String, - timeoutms: Int?) - { - self.name = name - self.installid = installid - self.timeoutms = timeoutms - } - - private enum CodingKeys: String, CodingKey { - case name - case installid = "installId" - case timeoutms = "timeoutMs" - } -} - -public struct SkillsUpdateParams: Codable, Sendable { - public let skillkey: String - public let enabled: Bool? - public let apikey: String? - public let env: [String: AnyCodable]? - - public init( - skillkey: String, - enabled: Bool?, - apikey: String?, - env: [String: AnyCodable]?) - { - self.skillkey = skillkey - self.enabled = enabled - self.apikey = apikey - self.env = env - } - - private enum CodingKeys: String, CodingKey { - case skillkey = "skillKey" - case enabled - case apikey = "apiKey" - case env - } -} - -public struct CronJob: Codable, Sendable { - public let id: String - public let agentid: String? - public let sessionkey: String? - public let name: String - public let description: String? - public let enabled: Bool - public let deleteafterrun: Bool? - public let createdatms: Int - public let updatedatms: Int - public let schedule: AnyCodable - public let sessiontarget: AnyCodable - public let wakemode: AnyCodable - public let payload: AnyCodable - public let delivery: AnyCodable? - public let state: [String: AnyCodable] - - public init( - id: String, - agentid: String?, - sessionkey: String?, - name: String, - description: String?, - enabled: Bool, - deleteafterrun: Bool?, - createdatms: Int, - updatedatms: Int, - schedule: AnyCodable, - sessiontarget: AnyCodable, - wakemode: AnyCodable, - payload: AnyCodable, - delivery: AnyCodable?, - state: [String: AnyCodable]) - { - self.id = id - self.agentid = agentid - self.sessionkey = sessionkey - self.name = name - self.description = description - self.enabled = enabled - self.deleteafterrun = deleteafterrun - self.createdatms = createdatms - self.updatedatms = updatedatms - self.schedule = schedule - self.sessiontarget = sessiontarget - self.wakemode = wakemode - self.payload = payload - self.delivery = delivery - self.state = state - } - - private enum CodingKeys: String, CodingKey { - case id - case agentid = "agentId" - case sessionkey = "sessionKey" - case name - case description - case enabled - case deleteafterrun = "deleteAfterRun" - case createdatms = "createdAtMs" - case updatedatms = "updatedAtMs" - case schedule - case sessiontarget = "sessionTarget" - case wakemode = "wakeMode" - case payload - case delivery - case state - } -} - -public struct CronListParams: Codable, Sendable { - public let includedisabled: Bool? - - public init( - includedisabled: Bool?) - { - self.includedisabled = includedisabled - } - - private enum CodingKeys: String, CodingKey { - case includedisabled = "includeDisabled" - } -} - -public struct CronStatusParams: Codable, Sendable {} - -public struct CronAddParams: Codable, Sendable { - public let name: String - public let agentid: AnyCodable? - public let sessionkey: AnyCodable? - public let description: String? - public let enabled: Bool? - public let deleteafterrun: Bool? - public let schedule: AnyCodable - public let sessiontarget: AnyCodable - public let wakemode: AnyCodable - public let payload: AnyCodable - public let delivery: AnyCodable? - - public init( - name: String, - agentid: AnyCodable?, - sessionkey: AnyCodable?, - description: String?, - enabled: Bool?, - deleteafterrun: Bool?, - schedule: AnyCodable, - sessiontarget: AnyCodable, - wakemode: AnyCodable, - payload: AnyCodable, - delivery: AnyCodable?) - { - self.name = name - self.agentid = agentid - self.sessionkey = sessionkey - self.description = description - self.enabled = enabled - self.deleteafterrun = deleteafterrun - self.schedule = schedule - self.sessiontarget = sessiontarget - self.wakemode = wakemode - self.payload = payload - self.delivery = delivery - } - - private enum CodingKeys: String, CodingKey { - case name - case agentid = "agentId" - case sessionkey = "sessionKey" - case description - case enabled - case deleteafterrun = "deleteAfterRun" - case schedule - case sessiontarget = "sessionTarget" - case wakemode = "wakeMode" - case payload - case delivery - } -} - -public struct CronRunLogEntry: Codable, Sendable { - public let ts: Int - public let jobid: String - public let action: String - public let status: AnyCodable? - public let error: String? - public let summary: String? - public let sessionid: String? - public let sessionkey: String? - public let runatms: Int? - public let durationms: Int? - public let nextrunatms: Int? - - public init( - ts: Int, - jobid: String, - action: String, - status: AnyCodable?, - error: String?, - summary: String?, - sessionid: String?, - sessionkey: String?, - runatms: Int?, - durationms: Int?, - nextrunatms: Int?) - { - self.ts = ts - self.jobid = jobid - self.action = action - self.status = status - self.error = error - self.summary = summary - self.sessionid = sessionid - self.sessionkey = sessionkey - self.runatms = runatms - self.durationms = durationms - self.nextrunatms = nextrunatms - } - - private enum CodingKeys: String, CodingKey { - case ts - case jobid = "jobId" - case action - case status - case error - case summary - case sessionid = "sessionId" - case sessionkey = "sessionKey" - case runatms = "runAtMs" - case durationms = "durationMs" - case nextrunatms = "nextRunAtMs" - } -} - -public struct LogsTailParams: Codable, Sendable { - public let cursor: Int? - public let limit: Int? - public let maxbytes: Int? - - public init( - cursor: Int?, - limit: Int?, - maxbytes: Int?) - { - self.cursor = cursor - self.limit = limit - self.maxbytes = maxbytes - } - - private enum CodingKeys: String, CodingKey { - case cursor - case limit - case maxbytes = "maxBytes" - } -} - -public struct LogsTailResult: Codable, Sendable { - public let file: String - public let cursor: Int - public let size: Int - public let lines: [String] - public let truncated: Bool? - public let reset: Bool? - - public init( - file: String, - cursor: Int, - size: Int, - lines: [String], - truncated: Bool?, - reset: Bool?) - { - self.file = file - self.cursor = cursor - self.size = size - self.lines = lines - self.truncated = truncated - self.reset = reset - } - - private enum CodingKeys: String, CodingKey { - case file - case cursor - case size - case lines - case truncated - case reset - } -} - -public struct ExecApprovalsGetParams: Codable, Sendable {} - -public struct ExecApprovalsSetParams: Codable, Sendable { - public let file: [String: AnyCodable] - public let basehash: String? - - public init( - file: [String: AnyCodable], - basehash: String?) - { - self.file = file - self.basehash = basehash - } - - private enum CodingKeys: String, CodingKey { - case file - case basehash = "baseHash" - } -} - -public struct ExecApprovalsNodeGetParams: Codable, Sendable { - public let nodeid: String - - public init( - nodeid: String) - { - self.nodeid = nodeid - } - - private enum CodingKeys: String, CodingKey { - case nodeid = "nodeId" - } -} - -public struct ExecApprovalsNodeSetParams: Codable, Sendable { - public let nodeid: String - public let file: [String: AnyCodable] - public let basehash: String? - - public init( - nodeid: String, - file: [String: AnyCodable], - basehash: String?) - { - self.nodeid = nodeid - self.file = file - self.basehash = basehash - } - - private enum CodingKeys: String, CodingKey { - case nodeid = "nodeId" - case file - case basehash = "baseHash" - } -} - -public struct ExecApprovalsSnapshot: Codable, Sendable { - public let path: String - public let exists: Bool - public let hash: String - public let file: [String: AnyCodable] - - public init( - path: String, - exists: Bool, - hash: String, - file: [String: AnyCodable]) - { - self.path = path - self.exists = exists - self.hash = hash - self.file = file - } - - private enum CodingKeys: String, CodingKey { - case path - case exists - case hash - case file - } -} - -public struct ExecApprovalRequestParams: Codable, Sendable { - public let id: String? - public let command: String - public let cwd: AnyCodable? - public let host: AnyCodable? - public let security: AnyCodable? - public let ask: AnyCodable? - public let agentid: AnyCodable? - public let resolvedpath: AnyCodable? - public let sessionkey: AnyCodable? - public let timeoutms: Int? - public let twophase: Bool? - - public init( - id: String?, - command: String, - cwd: AnyCodable?, - host: AnyCodable?, - security: AnyCodable?, - ask: AnyCodable?, - agentid: AnyCodable?, - resolvedpath: AnyCodable?, - sessionkey: AnyCodable?, - timeoutms: Int?, - twophase: Bool?) - { - self.id = id - self.command = command - self.cwd = cwd - self.host = host - self.security = security - self.ask = ask - self.agentid = agentid - self.resolvedpath = resolvedpath - self.sessionkey = sessionkey - self.timeoutms = timeoutms - self.twophase = twophase - } - - private enum CodingKeys: String, CodingKey { - case id - case command - case cwd - case host - case security - case ask - case agentid = "agentId" - case resolvedpath = "resolvedPath" - case sessionkey = "sessionKey" - case timeoutms = "timeoutMs" - case twophase = "twoPhase" - } -} - -public struct ExecApprovalResolveParams: Codable, Sendable { - public let id: String - public let decision: String - - public init( - id: String, - decision: String) - { - self.id = id - self.decision = decision - } - - private enum CodingKeys: String, CodingKey { - case id - case decision - } -} - -public struct DevicePairListParams: Codable, Sendable {} - -public struct DevicePairApproveParams: Codable, Sendable { - public let requestid: String - - public init( - requestid: String) - { - self.requestid = requestid - } - - private enum CodingKeys: String, CodingKey { - case requestid = "requestId" - } -} - -public struct DevicePairRejectParams: Codable, Sendable { - public let requestid: String - - public init( - requestid: String) - { - self.requestid = requestid - } - - private enum CodingKeys: String, CodingKey { - case requestid = "requestId" - } -} - -public struct DevicePairRemoveParams: Codable, Sendable { - public let deviceid: String - - public init( - deviceid: String) - { - self.deviceid = deviceid - } - - private enum CodingKeys: String, CodingKey { - case deviceid = "deviceId" - } -} - -public struct DeviceTokenRotateParams: Codable, Sendable { - public let deviceid: String - public let role: String - public let scopes: [String]? - - public init( - deviceid: String, - role: String, - scopes: [String]?) - { - self.deviceid = deviceid - self.role = role - self.scopes = scopes - } - - private enum CodingKeys: String, CodingKey { - case deviceid = "deviceId" - case role - case scopes - } -} - -public struct DeviceTokenRevokeParams: Codable, Sendable { - public let deviceid: String - public let role: String - - public init( - deviceid: String, - role: String) - { - self.deviceid = deviceid - self.role = role - } - - private enum CodingKeys: String, CodingKey { - case deviceid = "deviceId" - case role - } -} - -public struct DevicePairRequestedEvent: Codable, Sendable { - public let requestid: String - public let deviceid: String - public let publickey: String - public let displayname: String? - public let platform: String? - public let clientid: String? - public let clientmode: String? - public let role: String? - public let roles: [String]? - public let scopes: [String]? - public let remoteip: String? - public let silent: Bool? - public let isrepair: Bool? - public let ts: Int - - public init( - requestid: String, - deviceid: String, - publickey: String, - displayname: String?, - platform: String?, - clientid: String?, - clientmode: String?, - role: String?, - roles: [String]?, - scopes: [String]?, - remoteip: String?, - silent: Bool?, - isrepair: Bool?, - ts: Int) - { - self.requestid = requestid - self.deviceid = deviceid - self.publickey = publickey - self.displayname = displayname - self.platform = platform - self.clientid = clientid - self.clientmode = clientmode - self.role = role - self.roles = roles - self.scopes = scopes - self.remoteip = remoteip - self.silent = silent - self.isrepair = isrepair - self.ts = ts - } - - private enum CodingKeys: String, CodingKey { - case requestid = "requestId" - case deviceid = "deviceId" - case publickey = "publicKey" - case displayname = "displayName" - case platform - case clientid = "clientId" - case clientmode = "clientMode" - case role - case roles - case scopes - case remoteip = "remoteIp" - case silent - case isrepair = "isRepair" - case ts - } -} - -public struct DevicePairResolvedEvent: Codable, Sendable { - public let requestid: String - public let deviceid: String - public let decision: String - public let ts: Int - - public init( - requestid: String, - deviceid: String, - decision: String, - ts: Int) - { - self.requestid = requestid - self.deviceid = deviceid - self.decision = decision - self.ts = ts - } - - private enum CodingKeys: String, CodingKey { - case requestid = "requestId" - case deviceid = "deviceId" - case decision - case ts - } -} - -public struct ChatHistoryParams: Codable, Sendable { - public let sessionkey: String - public let limit: Int? - - public init( - sessionkey: String, - limit: Int?) - { - self.sessionkey = sessionkey - self.limit = limit - } - - private enum CodingKeys: String, CodingKey { - case sessionkey = "sessionKey" - case limit - } -} - -public struct ChatSendParams: Codable, Sendable { - public let sessionkey: String - public let message: String - public let thinking: String? - public let deliver: Bool? - public let attachments: [AnyCodable]? - public let timeoutms: Int? - public let idempotencykey: String - - public init( - sessionkey: String, - message: String, - thinking: String?, - deliver: Bool?, - attachments: [AnyCodable]?, - timeoutms: Int?, - idempotencykey: String) - { - self.sessionkey = sessionkey - self.message = message - self.thinking = thinking - self.deliver = deliver - self.attachments = attachments - self.timeoutms = timeoutms - self.idempotencykey = idempotencykey - } - - private enum CodingKeys: String, CodingKey { - case sessionkey = "sessionKey" - case message - case thinking - case deliver - case attachments - case timeoutms = "timeoutMs" - case idempotencykey = "idempotencyKey" - } -} - -public struct ChatAbortParams: Codable, Sendable { - public let sessionkey: String - public let runid: String? - - public init( - sessionkey: String, - runid: String?) - { - self.sessionkey = sessionkey - self.runid = runid - } - - private enum CodingKeys: String, CodingKey { - case sessionkey = "sessionKey" - case runid = "runId" - } -} - -public struct ChatInjectParams: Codable, Sendable { - public let sessionkey: String - public let message: String - public let label: String? - - public init( - sessionkey: String, - message: String, - label: String?) - { - self.sessionkey = sessionkey - self.message = message - self.label = label - } - - private enum CodingKeys: String, CodingKey { - case sessionkey = "sessionKey" - case message - case label - } -} - -public struct ChatEvent: Codable, Sendable { - public let runid: String - public let sessionkey: String - public let seq: Int - public let state: AnyCodable - public let message: AnyCodable? - public let errormessage: String? - public let usage: AnyCodable? - public let stopreason: String? - - public init( - runid: String, - sessionkey: String, - seq: Int, - state: AnyCodable, - message: AnyCodable?, - errormessage: String?, - usage: AnyCodable?, - stopreason: String?) - { - self.runid = runid - self.sessionkey = sessionkey - self.seq = seq - self.state = state - self.message = message - self.errormessage = errormessage - self.usage = usage - self.stopreason = stopreason - } - - private enum CodingKeys: String, CodingKey { - case runid = "runId" - case sessionkey = "sessionKey" - case seq - case state - case message - case errormessage = "errorMessage" - case usage - case stopreason = "stopReason" - } -} - -public struct UpdateRunParams: Codable, Sendable { - public let sessionkey: String? - public let note: String? - public let restartdelayms: Int? - public let timeoutms: Int? - - public init( - sessionkey: String?, - note: String?, - restartdelayms: Int?, - timeoutms: Int?) - { - self.sessionkey = sessionkey - self.note = note - self.restartdelayms = restartdelayms - self.timeoutms = timeoutms - } - - private enum CodingKeys: String, CodingKey { - case sessionkey = "sessionKey" - case note - case restartdelayms = "restartDelayMs" - case timeoutms = "timeoutMs" - } -} - -public struct TickEvent: Codable, Sendable { - public let ts: Int - - public init( - ts: Int) - { - self.ts = ts - } - - private enum CodingKeys: String, CodingKey { - case ts - } -} - -public struct ShutdownEvent: Codable, Sendable { - public let reason: String - public let restartexpectedms: Int? - - public init( - reason: String, - restartexpectedms: Int?) - { - self.reason = reason - self.restartexpectedms = restartexpectedms - } - - private enum CodingKeys: String, CodingKey { - case reason - case restartexpectedms = "restartExpectedMs" - } -} - -public enum GatewayFrame: Codable, Sendable { - case req(RequestFrame) - case res(ResponseFrame) - case event(EventFrame) - case unknown(type: String, raw: [String: AnyCodable]) - - private enum CodingKeys: String, CodingKey { - case type - } - - public init(from decoder: Decoder) throws { - let typeContainer = try decoder.container(keyedBy: CodingKeys.self) - let type = try typeContainer.decode(String.self, forKey: .type) - switch type { - case "req": - self = try .req(RequestFrame(from: decoder)) - case "res": - self = try .res(ResponseFrame(from: decoder)) - case "event": - self = try .event(EventFrame(from: decoder)) - default: - let container = try decoder.singleValueContainer() - let raw = try container.decode([String: AnyCodable].self) - self = .unknown(type: type, raw: raw) - } - } - - public func encode(to encoder: Encoder) throws { - switch self { - case let .req(v): - try v.encode(to: encoder) - case let .res(v): - try v.encode(to: encoder) - case let .event(v): - try v.encode(to: encoder) - case let .unknown(_, raw): - var container = encoder.singleValueContainer() - try container.encode(raw) - } - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/AgentEventStoreTests.swift b/apps/macos/Tests/OpenClawIPCTests/AgentEventStoreTests.swift deleted file mode 100644 index 89754f86a71..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/AgentEventStoreTests.swift +++ /dev/null @@ -1,44 +0,0 @@ -import OpenClawProtocol -import Foundation -import Testing -@testable import OpenClaw - -@Suite -@MainActor -struct AgentEventStoreTests { - @Test - func appendAndClear() { - let store = AgentEventStore() - #expect(store.events.isEmpty) - - store.append(ControlAgentEvent( - runId: "run", - seq: 1, - stream: "test", - ts: 0, - data: [:] as [String: OpenClawProtocol.AnyCodable], - summary: nil)) - #expect(store.events.count == 1) - - store.clear() - #expect(store.events.isEmpty) - } - - @Test - func trimsToMaxEvents() { - let store = AgentEventStore() - for i in 1...401 { - store.append(ControlAgentEvent( - runId: "run", - seq: i, - stream: "test", - ts: Double(i), - data: [:] as [String: OpenClawProtocol.AnyCodable], - summary: nil)) - } - - #expect(store.events.count == 400) - #expect(store.events.first?.seq == 2) - #expect(store.events.last?.seq == 401) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/AgentWorkspaceTests.swift b/apps/macos/Tests/OpenClawIPCTests/AgentWorkspaceTests.swift deleted file mode 100644 index 6d5e4a37efd..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/AgentWorkspaceTests.swift +++ /dev/null @@ -1,123 +0,0 @@ -import Foundation -import Testing -@testable import OpenClaw - -@Suite -struct AgentWorkspaceTests { - @Test - func displayPathUsesTildeForHome() { - let home = FileManager().homeDirectoryForCurrentUser - #expect(AgentWorkspace.displayPath(for: home) == "~") - - let inside = home.appendingPathComponent("Projects", isDirectory: true) - #expect(AgentWorkspace.displayPath(for: inside).hasPrefix("~/")) - } - - @Test - func resolveWorkspaceURLExpandsTilde() { - let url = AgentWorkspace.resolveWorkspaceURL(from: "~/tmp") - #expect(url.path.hasSuffix("/tmp")) - } - - @Test - func agentsURLAppendsFilename() { - let root = URL(fileURLWithPath: "/tmp/ws", isDirectory: true) - let url = AgentWorkspace.agentsURL(workspaceURL: root) - #expect(url.lastPathComponent == AgentWorkspace.agentsFilename) - } - - @Test - func bootstrapCreatesAgentsFileWhenMissing() throws { - let tmp = FileManager().temporaryDirectory - .appendingPathComponent("openclaw-ws-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager().removeItem(at: tmp) } - - let agentsURL = try AgentWorkspace.bootstrap(workspaceURL: tmp) - #expect(FileManager().fileExists(atPath: agentsURL.path)) - - let contents = try String(contentsOf: agentsURL, encoding: .utf8) - #expect(contents.contains("# AGENTS.md")) - - let identityURL = tmp.appendingPathComponent(AgentWorkspace.identityFilename) - let userURL = tmp.appendingPathComponent(AgentWorkspace.userFilename) - let bootstrapURL = tmp.appendingPathComponent(AgentWorkspace.bootstrapFilename) - #expect(FileManager().fileExists(atPath: identityURL.path)) - #expect(FileManager().fileExists(atPath: userURL.path)) - #expect(FileManager().fileExists(atPath: bootstrapURL.path)) - - let second = try AgentWorkspace.bootstrap(workspaceURL: tmp) - #expect(second == agentsURL) - } - - @Test - func bootstrapSafetyRejectsNonEmptyFolderWithoutAgents() throws { - let tmp = FileManager().temporaryDirectory - .appendingPathComponent("openclaw-ws-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager().removeItem(at: tmp) } - try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true) - let marker = tmp.appendingPathComponent("notes.txt") - try "hello".write(to: marker, atomically: true, encoding: .utf8) - - let result = AgentWorkspace.bootstrapSafety(for: tmp) - switch result { - case .unsafe: - break - case .safe: - #expect(Bool(false), "Expected unsafe bootstrap safety result.") - } - } - - @Test - func bootstrapSafetyAllowsExistingAgentsFile() throws { - let tmp = FileManager().temporaryDirectory - .appendingPathComponent("openclaw-ws-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager().removeItem(at: tmp) } - try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true) - let agents = tmp.appendingPathComponent(AgentWorkspace.agentsFilename) - try "# AGENTS.md".write(to: agents, atomically: true, encoding: .utf8) - - let result = AgentWorkspace.bootstrapSafety(for: tmp) - switch result { - case .safe: - break - case .unsafe: - #expect(Bool(false), "Expected safe bootstrap safety result.") - } - } - - @Test - func bootstrapSkipsBootstrapFileWhenWorkspaceHasContent() throws { - let tmp = FileManager().temporaryDirectory - .appendingPathComponent("openclaw-ws-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager().removeItem(at: tmp) } - try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true) - let marker = tmp.appendingPathComponent("notes.txt") - try "hello".write(to: marker, atomically: true, encoding: .utf8) - - _ = try AgentWorkspace.bootstrap(workspaceURL: tmp) - - let bootstrapURL = tmp.appendingPathComponent(AgentWorkspace.bootstrapFilename) - #expect(!FileManager().fileExists(atPath: bootstrapURL.path)) - } - - @Test - func needsBootstrapFalseWhenIdentityAlreadySet() throws { - let tmp = FileManager().temporaryDirectory - .appendingPathComponent("openclaw-ws-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager().removeItem(at: tmp) } - try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true) - let identityURL = tmp.appendingPathComponent(AgentWorkspace.identityFilename) - try """ - # IDENTITY.md - Agent Identity - - - Name: Clawd - - Creature: Space Lobster - - Vibe: Helpful - - Emoji: lobster - """.write(to: identityURL, atomically: true, encoding: .utf8) - let bootstrapURL = tmp.appendingPathComponent(AgentWorkspace.bootstrapFilename) - try "bootstrap".write(to: bootstrapURL, atomically: true, encoding: .utf8) - - #expect(!AgentWorkspace.needsBootstrap(workspaceURL: tmp)) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/AnthropicAuthControlsSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/AnthropicAuthControlsSmokeTests.swift deleted file mode 100644 index 84c61833932..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/AnthropicAuthControlsSmokeTests.swift +++ /dev/null @@ -1,29 +0,0 @@ -import Testing -@testable import OpenClaw - -@Suite(.serialized) -@MainActor -struct AnthropicAuthControlsSmokeTests { - @Test func anthropicAuthControlsBuildsBodyLocal() { - let pkce = AnthropicOAuth.PKCE(verifier: "verifier", challenge: "challenge") - let view = AnthropicAuthControls( - connectionMode: .local, - oauthStatus: .connected(expiresAtMs: 1_700_000_000_000), - pkce: pkce, - code: "code#state", - statusText: "Detected code", - autoDetectClipboard: false, - autoConnectClipboard: false) - _ = view.body - } - - @Test func anthropicAuthControlsBuildsBodyRemote() { - let view = AnthropicAuthControls( - connectionMode: .remote, - oauthStatus: .missingFile, - pkce: nil, - code: "", - statusText: nil) - _ = view.body - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/AnthropicAuthResolverTests.swift b/apps/macos/Tests/OpenClawIPCTests/AnthropicAuthResolverTests.swift deleted file mode 100644 index c41b7f64be4..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/AnthropicAuthResolverTests.swift +++ /dev/null @@ -1,52 +0,0 @@ -import Foundation -import Testing -@testable import OpenClaw - -@Suite -struct AnthropicAuthResolverTests { - @Test - func prefersOAuthFileOverEnv() throws { - let dir = FileManager().temporaryDirectory - .appendingPathComponent("openclaw-oauth-\(UUID().uuidString)", isDirectory: true) - try FileManager().createDirectory(at: dir, withIntermediateDirectories: true) - let oauthFile = dir.appendingPathComponent("oauth.json") - let payload = [ - "anthropic": [ - "type": "oauth", - "refresh": "r1", - "access": "a1", - "expires": 1_234_567_890, - ], - ] - let data = try JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys]) - try data.write(to: oauthFile, options: [.atomic]) - - let status = OpenClawOAuthStore.anthropicOAuthStatus(at: oauthFile) - let mode = AnthropicAuthResolver.resolve(environment: [ - "ANTHROPIC_API_KEY": "sk-ant-ignored", - ], oauthStatus: status) - #expect(mode == .oauthFile) - } - - @Test - func reportsOAuthEnvWhenPresent() { - let mode = AnthropicAuthResolver.resolve(environment: [ - "ANTHROPIC_OAUTH_TOKEN": "token", - ], oauthStatus: .missingFile) - #expect(mode == .oauthEnv) - } - - @Test - func reportsAPIKeyEnvWhenPresent() { - let mode = AnthropicAuthResolver.resolve(environment: [ - "ANTHROPIC_API_KEY": "sk-ant-key", - ], oauthStatus: .missingFile) - #expect(mode == .apiKeyEnv) - } - - @Test - func reportsMissingWhenNothingConfigured() { - let mode = AnthropicAuthResolver.resolve(environment: [:], oauthStatus: .missingFile) - #expect(mode == .missing) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/AnthropicOAuthCodeStateTests.swift b/apps/macos/Tests/OpenClawIPCTests/AnthropicOAuthCodeStateTests.swift deleted file mode 100644 index 3d337c2b279..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/AnthropicOAuthCodeStateTests.swift +++ /dev/null @@ -1,31 +0,0 @@ -import Testing -@testable import OpenClaw - -@Suite -struct AnthropicOAuthCodeStateTests { - @Test - func parsesRawToken() { - let parsed = AnthropicOAuthCodeState.parse(from: "abcDEF1234#stateXYZ9876") - #expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876")) - } - - @Test - func parsesBacktickedToken() { - let parsed = AnthropicOAuthCodeState.parse(from: "`abcDEF1234#stateXYZ9876`") - #expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876")) - } - - @Test - func parsesCallbackURL() { - let raw = "https://console.anthropic.com/oauth/code/callback?code=abcDEF1234&state=stateXYZ9876" - let parsed = AnthropicOAuthCodeState.parse(from: raw) - #expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876")) - } - - @Test - func extractsFromSurroundingText() { - let raw = "Paste the code#state value: abcDEF1234#stateXYZ9876 then return." - let parsed = AnthropicOAuthCodeState.parse(from: raw) - #expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876")) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/AnyCodableEncodingTests.swift b/apps/macos/Tests/OpenClawIPCTests/AnyCodableEncodingTests.swift deleted file mode 100644 index 98ff08afb1f..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/AnyCodableEncodingTests.swift +++ /dev/null @@ -1,38 +0,0 @@ -import OpenClawProtocol -import Foundation -import Testing - -@testable import OpenClaw - -@Suite struct AnyCodableEncodingTests { - @Test func encodesSwiftArrayAndDictionaryValues() throws { - let payload: [String: Any] = [ - "tags": ["node", "ios"], - "meta": ["count": 2], - "null": NSNull(), - ] - - let data = try JSONEncoder().encode(OpenClawProtocol.AnyCodable(payload)) - let obj = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) - - #expect(obj["tags"] as? [String] == ["node", "ios"]) - #expect((obj["meta"] as? [String: Any])?["count"] as? Int == 2) - #expect(obj["null"] is NSNull) - } - - @Test func protocolAnyCodableEncodesPrimitiveArrays() throws { - let payload: [String: Any] = [ - "items": [1, "two", NSNull(), ["ok": true]], - ] - - let data = try JSONEncoder().encode(OpenClawProtocol.AnyCodable(payload)) - let obj = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) - - let items = try #require(obj["items"] as? [Any]) - #expect(items.count == 4) - #expect(items[0] as? Int == 1) - #expect(items[1] as? String == "two") - #expect(items[2] is NSNull) - #expect((items[3] as? [String: Any])?["ok"] as? Bool == true) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/CLIInstallerTests.swift b/apps/macos/Tests/OpenClawIPCTests/CLIInstallerTests.swift deleted file mode 100644 index 651dfeb4c15..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/CLIInstallerTests.swift +++ /dev/null @@ -1,34 +0,0 @@ -import Foundation -import Testing -@testable import OpenClaw - -@Suite(.serialized) -@MainActor -struct CLIInstallerTests { - @Test func installedLocationFindsExecutable() throws { - let fm = FileManager() - let root = fm.temporaryDirectory.appendingPathComponent( - "openclaw-cli-installer-\(UUID().uuidString)") - defer { try? fm.removeItem(at: root) } - - let binDir = root.appendingPathComponent("bin") - try fm.createDirectory(at: binDir, withIntermediateDirectories: true) - let cli = binDir.appendingPathComponent("openclaw") - fm.createFile(atPath: cli.path, contents: Data()) - try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: cli.path) - - let found = CLIInstaller.installedLocation( - searchPaths: [binDir.path], - fileManager: fm) - #expect(found == cli.path) - - try fm.removeItem(at: cli) - fm.createFile(atPath: cli.path, contents: Data()) - try fm.setAttributes([.posixPermissions: 0o644], ofItemAtPath: cli.path) - - let missing = CLIInstaller.installedLocation( - searchPaths: [binDir.path], - fileManager: fm) - #expect(missing == nil) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/CameraCaptureServiceTests.swift b/apps/macos/Tests/OpenClawIPCTests/CameraCaptureServiceTests.swift deleted file mode 100644 index 14b5e6058ff..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/CameraCaptureServiceTests.swift +++ /dev/null @@ -1,21 +0,0 @@ -import Testing - -@testable import OpenClaw - -@Suite struct CameraCaptureServiceTests { - @Test func normalizeSnapDefaults() { - let res = CameraCaptureService.normalizeSnap(maxWidth: nil, quality: nil) - #expect(res.maxWidth == 1600) - #expect(res.quality == 0.9) - } - - @Test func normalizeSnapClampsValues() { - let low = CameraCaptureService.normalizeSnap(maxWidth: -1, quality: -10) - #expect(low.maxWidth == 1600) - #expect(low.quality == 0.05) - - let high = CameraCaptureService.normalizeSnap(maxWidth: 9999, quality: 10) - #expect(high.maxWidth == 9999) - #expect(high.quality == 1.0) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/CameraIPCTests.swift b/apps/macos/Tests/OpenClawIPCTests/CameraIPCTests.swift deleted file mode 100644 index a233154af84..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/CameraIPCTests.swift +++ /dev/null @@ -1,61 +0,0 @@ -import OpenClawIPC -import Foundation -import Testing - -@Suite struct CameraIPCTests { - @Test func cameraSnapCodableRoundtrip() throws { - let req: Request = .cameraSnap( - facing: .front, - maxWidth: 640, - quality: 0.85, - outPath: "/tmp/test.jpg") - - let data = try JSONEncoder().encode(req) - let decoded = try JSONDecoder().decode(Request.self, from: data) - - switch decoded { - case let .cameraSnap(facing, maxWidth, quality, outPath): - #expect(facing == .front) - #expect(maxWidth == 640) - #expect(quality == 0.85) - #expect(outPath == "/tmp/test.jpg") - default: - Issue.record("expected cameraSnap, got \(decoded)") - } - } - - @Test func cameraClipCodableRoundtrip() throws { - let req: Request = .cameraClip( - facing: .back, - durationMs: 3000, - includeAudio: false, - outPath: "/tmp/test.mp4") - - let data = try JSONEncoder().encode(req) - let decoded = try JSONDecoder().decode(Request.self, from: data) - - switch decoded { - case let .cameraClip(facing, durationMs, includeAudio, outPath): - #expect(facing == .back) - #expect(durationMs == 3000) - #expect(includeAudio == false) - #expect(outPath == "/tmp/test.mp4") - default: - Issue.record("expected cameraClip, got \(decoded)") - } - } - - @Test func cameraClipDefaultsIncludeAudioToTrueWhenMissing() throws { - let json = """ - {"type":"cameraClip","durationMs":1234} - """ - let decoded = try JSONDecoder().decode(Request.self, from: Data(json.utf8)) - switch decoded { - case let .cameraClip(_, durationMs, includeAudio, _): - #expect(durationMs == 1234) - #expect(includeAudio == true) - default: - Issue.record("expected cameraClip, got \(decoded)") - } - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/CanvasFileWatcherTests.swift b/apps/macos/Tests/OpenClawIPCTests/CanvasFileWatcherTests.swift deleted file mode 100644 index 3c957161743..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/CanvasFileWatcherTests.swift +++ /dev/null @@ -1,78 +0,0 @@ -import Foundation -import os -import Testing -@testable import OpenClaw - -@Suite(.serialized) struct CanvasFileWatcherTests { - private func makeTempDir() throws -> URL { - let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - let dir = base.appendingPathComponent("openclaw-canvaswatch-\(UUID().uuidString)", isDirectory: true) - try FileManager().createDirectory(at: dir, withIntermediateDirectories: true) - return dir - } - - @Test func detectsInPlaceFileWrites() async throws { - let dir = try self.makeTempDir() - defer { try? FileManager().removeItem(at: dir) } - - let file = dir.appendingPathComponent("index.html") - try "hello".write(to: file, atomically: false, encoding: .utf8) - - let fired = OSAllocatedUnfairLock(initialState: false) - let waitState = OSAllocatedUnfairLock<(fired: Bool, cont: CheckedContinuation?)>( - initialState: (false, nil)) - - func waitForFire(timeoutNs: UInt64) async -> Bool { - await withTaskGroup(of: Bool.self) { group in - group.addTask { - await withCheckedContinuation { cont in - let resumeImmediately = waitState.withLock { state in - if state.fired { return true } - state.cont = cont - return false - } - if resumeImmediately { - cont.resume() - } - } - return true - } - - group.addTask { - try? await Task.sleep(nanoseconds: timeoutNs) - return false - } - - let result = await group.next() ?? false - group.cancelAll() - return result - } - } - - let watcher = CanvasFileWatcher(url: dir) { - fired.withLock { $0 = true } - let cont = waitState.withLock { state in - state.fired = true - let cont = state.cont - state.cont = nil - return cont - } - cont?.resume() - } - watcher.start() - defer { watcher.stop() } - - // Give the stream a moment to start. - try await Task.sleep(nanoseconds: 150 * 1_000_000) - - // Modify the file in-place (no rename). This used to be missed when only watching the directory vnode. - let handle = try FileHandle(forUpdating: file) - try handle.seekToEnd() - try handle.write(contentsOf: Data(" world".utf8)) - try handle.close() - - let ok = await waitForFire(timeoutNs: 2_000_000_000) - #expect(ok == true) - #expect(fired.withLock { $0 } == true) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/CanvasIPCTests.swift b/apps/macos/Tests/OpenClawIPCTests/CanvasIPCTests.swift deleted file mode 100644 index b509efd844d..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/CanvasIPCTests.swift +++ /dev/null @@ -1,41 +0,0 @@ -import OpenClawIPC -import Foundation -import Testing - -@Suite struct CanvasIPCTests { - @Test func canvasPresentCodableRoundtrip() throws { - let placement = CanvasPlacement(x: 10, y: 20, width: 640, height: 480) - let req: Request = .canvasPresent(session: "main", path: "/index.html", placement: placement) - - let data = try JSONEncoder().encode(req) - let decoded = try JSONDecoder().decode(Request.self, from: data) - - switch decoded { - case let .canvasPresent(session, path, placement): - #expect(session == "main") - #expect(path == "/index.html") - #expect(placement?.x == 10) - #expect(placement?.y == 20) - #expect(placement?.width == 640) - #expect(placement?.height == 480) - default: - Issue.record("expected canvasPresent, got \(decoded)") - } - } - - @Test func canvasPresentDecodesNilPlacementWhenMissing() throws { - let json = """ - {"type":"canvasPresent","session":"s","path":"/"} - """ - let decoded = try JSONDecoder().decode(Request.self, from: Data(json.utf8)) - - switch decoded { - case let .canvasPresent(session, path, placement): - #expect(session == "s") - #expect(path == "/") - #expect(placement == nil) - default: - Issue.record("expected canvasPresent, got \(decoded)") - } - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/CanvasWindowSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/CanvasWindowSmokeTests.swift deleted file mode 100644 index 4299ca74fad..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/CanvasWindowSmokeTests.swift +++ /dev/null @@ -1,49 +0,0 @@ -import AppKit -import OpenClawIPC -import Foundation -import Testing -@testable import OpenClaw - -@Suite(.serialized) -@MainActor -struct CanvasWindowSmokeTests { - @Test func panelControllerShowsAndHides() async throws { - let root = FileManager().temporaryDirectory - .appendingPathComponent("openclaw-canvas-test-\(UUID().uuidString)") - try FileManager().createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager().removeItem(at: root) } - - let anchor = { NSRect(x: 200, y: 400, width: 40, height: 40) } - let controller = try CanvasWindowController( - sessionKey: " main/invalid⚡️ ", - root: root, - presentation: .panel(anchorProvider: anchor)) - - #expect(controller.directoryPath.contains("main_invalid__") == true) - - controller.applyPreferredPlacement(CanvasPlacement(x: 120, y: 200, width: 520, height: 680)) - controller.showCanvas(path: "/") - _ = try await controller.eval(javaScript: "1 + 1") - controller.windowDidMove(Notification(name: NSWindow.didMoveNotification)) - controller.windowDidEndLiveResize(Notification(name: NSWindow.didEndLiveResizeNotification)) - controller.hideCanvas() - controller.close() - } - - @Test func windowControllerShowsAndCloses() async throws { - let root = FileManager().temporaryDirectory - .appendingPathComponent("openclaw-canvas-test-\(UUID().uuidString)") - try FileManager().createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager().removeItem(at: root) } - - let controller = try CanvasWindowController( - sessionKey: "main", - root: root, - presentation: .window) - - controller.showCanvas(path: "/") - controller.windowWillClose(Notification(name: NSWindow.willCloseNotification)) - controller.hideCanvas() - controller.close() - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/ChannelsSettingsSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/ChannelsSettingsSmokeTests.swift deleted file mode 100644 index 8810d12385b..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/ChannelsSettingsSmokeTests.swift +++ /dev/null @@ -1,164 +0,0 @@ -import OpenClawProtocol -import SwiftUI -import Testing -@testable import OpenClaw - -private typealias SnapshotAnyCodable = OpenClaw.AnyCodable - -@Suite(.serialized) -@MainActor -struct ChannelsSettingsSmokeTests { - @Test func channelsSettingsBuildsBodyWithSnapshot() { - let store = ChannelsStore(isPreview: true) - store.snapshot = ChannelsStatusSnapshot( - ts: 1_700_000_000_000, - channelOrder: ["whatsapp", "telegram", "signal", "imessage"], - channelLabels: [ - "whatsapp": "WhatsApp", - "telegram": "Telegram", - "signal": "Signal", - "imessage": "iMessage", - ], - channelDetailLabels: nil, - channelSystemImages: nil, - channelMeta: nil, - channels: [ - "whatsapp": SnapshotAnyCodable([ - "configured": true, - "linked": true, - "authAgeMs": 86_400_000, - "self": ["e164": "+15551234567"], - "running": true, - "connected": false, - "lastConnectedAt": 1_700_000_000_000, - "lastDisconnect": [ - "at": 1_700_000_050_000, - "status": 401, - "error": "logged out", - "loggedOut": true, - ], - "reconnectAttempts": 2, - "lastMessageAt": 1_700_000_060_000, - "lastEventAt": 1_700_000_060_000, - "lastError": "needs login", - ]), - "telegram": SnapshotAnyCodable([ - "configured": true, - "tokenSource": "env", - "running": true, - "mode": "polling", - "lastStartAt": 1_700_000_000_000, - "probe": [ - "ok": true, - "status": 200, - "elapsedMs": 120, - "bot": ["id": 123, "username": "openclawbot"], - "webhook": ["url": "https://example.com/hook", "hasCustomCert": false], - ], - "lastProbeAt": 1_700_000_050_000, - ]), - "signal": SnapshotAnyCodable([ - "configured": true, - "baseUrl": "http://127.0.0.1:8080", - "running": true, - "lastStartAt": 1_700_000_000_000, - "probe": [ - "ok": true, - "status": 200, - "elapsedMs": 140, - "version": "0.12.4", - ], - "lastProbeAt": 1_700_000_050_000, - ]), - "imessage": SnapshotAnyCodable([ - "configured": false, - "running": false, - "lastError": "not configured", - "probe": ["ok": false, "error": "imsg not found (imsg)"], - "lastProbeAt": 1_700_000_050_000, - ]), - ], - channelAccounts: [:], - channelDefaultAccountId: [ - "whatsapp": "default", - "telegram": "default", - "signal": "default", - "imessage": "default", - ]) - - store.whatsappLoginMessage = "Scan QR" - store.whatsappLoginQrDataUrl = - "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMB/ay7pS8AAAAASUVORK5CYII=" - - let view = ChannelsSettings(store: store) - _ = view.body - } - - @Test func channelsSettingsBuildsBodyWithoutSnapshot() { - let store = ChannelsStore(isPreview: true) - store.snapshot = ChannelsStatusSnapshot( - ts: 1_700_000_000_000, - channelOrder: ["whatsapp", "telegram", "signal", "imessage"], - channelLabels: [ - "whatsapp": "WhatsApp", - "telegram": "Telegram", - "signal": "Signal", - "imessage": "iMessage", - ], - channelDetailLabels: nil, - channelSystemImages: nil, - channelMeta: nil, - channels: [ - "whatsapp": SnapshotAnyCodable([ - "configured": false, - "linked": false, - "running": false, - "connected": false, - "reconnectAttempts": 0, - ]), - "telegram": SnapshotAnyCodable([ - "configured": false, - "running": false, - "lastError": "bot missing", - "probe": [ - "ok": false, - "status": 403, - "error": "unauthorized", - "elapsedMs": 120, - ], - "lastProbeAt": 1_700_000_100_000, - ]), - "signal": SnapshotAnyCodable([ - "configured": false, - "baseUrl": "http://127.0.0.1:8080", - "running": false, - "lastError": "not configured", - "probe": [ - "ok": false, - "status": 404, - "error": "unreachable", - "elapsedMs": 200, - ], - "lastProbeAt": 1_700_000_200_000, - ]), - "imessage": SnapshotAnyCodable([ - "configured": false, - "running": false, - "lastError": "not configured", - "cliPath": "imsg", - "probe": ["ok": false, "error": "imsg not found (imsg)"], - "lastProbeAt": 1_700_000_200_000, - ]), - ], - channelAccounts: [:], - channelDefaultAccountId: [ - "whatsapp": "default", - "telegram": "default", - "signal": "default", - "imessage": "default", - ]) - - let view = ChannelsSettings(store: store) - _ = view.body - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift b/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift deleted file mode 100644 index 7a71bc08b6e..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift +++ /dev/null @@ -1,171 +0,0 @@ -import Darwin -import Foundation -import Testing -@testable import OpenClaw - -@Suite(.serialized) struct CommandResolverTests { - private func makeDefaults() -> UserDefaults { - // Use a unique suite to avoid cross-suite concurrency on UserDefaults.standard. - UserDefaults(suiteName: "CommandResolverTests.\(UUID().uuidString)")! - } - - private func makeTempDir() throws -> URL { - let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - let dir = base.appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager().createDirectory(at: dir, withIntermediateDirectories: true) - return dir - } - - private func makeExec(at path: URL) throws { - try FileManager().createDirectory( - at: path.deletingLastPathComponent(), - withIntermediateDirectories: true) - FileManager().createFile(atPath: path.path, contents: Data("echo ok\n".utf8)) - try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path) - } - - @Test func prefersOpenClawBinary() async throws { - let defaults = self.makeDefaults() - defaults.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey) - - let tmp = try makeTempDir() - CommandResolver.setProjectRoot(tmp.path) - - let openclawPath = tmp.appendingPathComponent("node_modules/.bin/openclaw") - try self.makeExec(at: openclawPath) - - let cmd = CommandResolver.openclawCommand(subcommand: "gateway", defaults: defaults, configRoot: [:]) - #expect(cmd.prefix(2).elementsEqual([openclawPath.path, "gateway"])) - } - - @Test func fallsBackToNodeAndScript() async throws { - let defaults = self.makeDefaults() - defaults.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey) - - let tmp = try makeTempDir() - CommandResolver.setProjectRoot(tmp.path) - - let nodePath = tmp.appendingPathComponent("node_modules/.bin/node") - let scriptPath = tmp.appendingPathComponent("bin/openclaw.js") - try self.makeExec(at: nodePath) - try "#!/bin/sh\necho v22.0.0\n".write(to: nodePath, atomically: true, encoding: .utf8) - try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path) - try self.makeExec(at: scriptPath) - - let cmd = CommandResolver.openclawCommand( - subcommand: "rpc", - defaults: defaults, - configRoot: [:], - searchPaths: [tmp.appendingPathComponent("node_modules/.bin").path]) - - #expect(cmd.count >= 3) - if cmd.count >= 3 { - #expect(cmd[0] == nodePath.path) - #expect(cmd[1] == scriptPath.path) - #expect(cmd[2] == "rpc") - } - } - - @Test func fallsBackToPnpm() async throws { - let defaults = self.makeDefaults() - defaults.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey) - - let tmp = try makeTempDir() - CommandResolver.setProjectRoot(tmp.path) - - let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm") - try self.makeExec(at: pnpmPath) - - let cmd = CommandResolver.openclawCommand(subcommand: "rpc", defaults: defaults, configRoot: [:]) - - #expect(cmd.prefix(4).elementsEqual([pnpmPath.path, "--silent", "openclaw", "rpc"])) - } - - @Test func pnpmKeepsExtraArgsAfterSubcommand() async throws { - let defaults = self.makeDefaults() - defaults.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey) - - let tmp = try makeTempDir() - CommandResolver.setProjectRoot(tmp.path) - - let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm") - try self.makeExec(at: pnpmPath) - - let cmd = CommandResolver.openclawCommand( - subcommand: "health", - extraArgs: ["--json", "--timeout", "5"], - defaults: defaults, - configRoot: [:]) - - #expect(cmd.prefix(5).elementsEqual([pnpmPath.path, "--silent", "openclaw", "health", "--json"])) - #expect(cmd.suffix(2).elementsEqual(["--timeout", "5"])) - } - - @Test func preferredPathsStartWithProjectNodeBins() async throws { - let tmp = try makeTempDir() - CommandResolver.setProjectRoot(tmp.path) - - let first = CommandResolver.preferredPaths().first - #expect(first == tmp.appendingPathComponent("node_modules/.bin").path) - } - - @Test func buildsSSHCommandForRemoteMode() async throws { - let defaults = self.makeDefaults() - defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey) - defaults.set("openclaw@example.com:2222", forKey: remoteTargetKey) - defaults.set("/tmp/id_ed25519", forKey: remoteIdentityKey) - defaults.set("/srv/openclaw", forKey: remoteProjectRootKey) - - let cmd = CommandResolver.openclawCommand( - subcommand: "status", - extraArgs: ["--json"], - defaults: defaults, - configRoot: [:]) - - #expect(cmd.first == "/usr/bin/ssh") - if let marker = cmd.firstIndex(of: "--") { - #expect(cmd[marker + 1] == "openclaw@example.com") - } else { - #expect(Bool(false)) - } - #expect(cmd.contains("-i")) - #expect(cmd.contains("/tmp/id_ed25519")) - if let script = cmd.last { - #expect(script.contains("PRJ='/srv/openclaw'")) - #expect(script.contains("cd \"$PRJ\"")) - #expect(script.contains("openclaw")) - #expect(script.contains("status")) - #expect(script.contains("--json")) - #expect(script.contains("CLI=")) - } - } - - @Test func rejectsUnsafeSSHTargets() async throws { - #expect(CommandResolver.parseSSHTarget("-oProxyCommand=calc") == nil) - #expect(CommandResolver.parseSSHTarget("host:-oProxyCommand=calc") == nil) - #expect(CommandResolver.parseSSHTarget("user@host:2222")?.port == 2222) - } - - @Test func configRootLocalOverridesRemoteDefaults() async throws { - let defaults = self.makeDefaults() - defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey) - defaults.set("openclaw@example.com:2222", forKey: remoteTargetKey) - - let tmp = try makeTempDir() - CommandResolver.setProjectRoot(tmp.path) - - let openclawPath = tmp.appendingPathComponent("node_modules/.bin/openclaw") - try self.makeExec(at: openclawPath) - - let cmd = CommandResolver.openclawCommand( - subcommand: "daemon", - defaults: defaults, - configRoot: ["gateway": ["mode": "local"]]) - - #expect(cmd.first == openclawPath.path) - #expect(cmd.count >= 2) - if cmd.count >= 2 { - #expect(cmd[1] == "daemon") - } - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/ConfigStoreTests.swift b/apps/macos/Tests/OpenClawIPCTests/ConfigStoreTests.swift deleted file mode 100644 index 50f72241dd8..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/ConfigStoreTests.swift +++ /dev/null @@ -1,68 +0,0 @@ -import Testing -@testable import OpenClaw - -@Suite(.serialized) -@MainActor -struct ConfigStoreTests { - @Test func loadUsesRemoteInRemoteMode() async { - var localHit = false - var remoteHit = false - await ConfigStore._testSetOverrides(.init( - isRemoteMode: { true }, - loadLocal: { localHit = true; return ["local": true] }, - loadRemote: { remoteHit = true; return ["remote": true] })) - - let result = await ConfigStore.load() - - await ConfigStore._testClearOverrides() - #expect(remoteHit) - #expect(!localHit) - #expect(result["remote"] as? Bool == true) - } - - @Test func loadUsesLocalInLocalMode() async { - var localHit = false - var remoteHit = false - await ConfigStore._testSetOverrides(.init( - isRemoteMode: { false }, - loadLocal: { localHit = true; return ["local": true] }, - loadRemote: { remoteHit = true; return ["remote": true] })) - - let result = await ConfigStore.load() - - await ConfigStore._testClearOverrides() - #expect(localHit) - #expect(!remoteHit) - #expect(result["local"] as? Bool == true) - } - - @Test func saveRoutesToRemoteInRemoteMode() async throws { - var localHit = false - var remoteHit = false - await ConfigStore._testSetOverrides(.init( - isRemoteMode: { true }, - saveLocal: { _ in localHit = true }, - saveRemote: { _ in remoteHit = true })) - - try await ConfigStore.save(["remote": true]) - - await ConfigStore._testClearOverrides() - #expect(remoteHit) - #expect(!localHit) - } - - @Test func saveRoutesToLocalInLocalMode() async throws { - var localHit = false - var remoteHit = false - await ConfigStore._testSetOverrides(.init( - isRemoteMode: { false }, - saveLocal: { _ in localHit = true }, - saveRemote: { _ in remoteHit = true })) - - try await ConfigStore.save(["local": true]) - - await ConfigStore._testClearOverrides() - #expect(localHit) - #expect(!remoteHit) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/CoverageDumpTests.swift b/apps/macos/Tests/OpenClawIPCTests/CoverageDumpTests.swift deleted file mode 100644 index 278477448be..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/CoverageDumpTests.swift +++ /dev/null @@ -1,24 +0,0 @@ -import Darwin -import Foundation -import Testing - -@Suite(.serialized) -struct CoverageDumpTests { - @Test func periodicallyFlushCoverage() async { - guard ProcessInfo.processInfo.environment["LLVM_PROFILE_FILE"] != nil else { return } - guard let writeProfile = resolveProfileWriteFile() else { return } - let deadline = Date().addingTimeInterval(4) - while Date() < deadline { - _ = writeProfile() - try? await Task.sleep(nanoseconds: 250_000_000) - } - } -} - -private typealias ProfileWriteFn = @convention(c) () -> Int32 - -private func resolveProfileWriteFile() -> ProfileWriteFn? { - let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "__llvm_profile_write_file") - guard let symbol else { return nil } - return unsafeBitCast(symbol, to: ProfileWriteFn.self) -} diff --git a/apps/macos/Tests/OpenClawIPCTests/CritterIconRendererTests.swift b/apps/macos/Tests/OpenClawIPCTests/CritterIconRendererTests.swift deleted file mode 100644 index 41baee63e56..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/CritterIconRendererTests.swift +++ /dev/null @@ -1,37 +0,0 @@ -import AppKit -import Testing -@testable import OpenClaw - -@Suite -@MainActor -struct CritterIconRendererTests { - @Test func makeIconRendersExpectedSize() { - let image = CritterIconRenderer.makeIcon( - blink: 0.25, - legWiggle: 0.5, - earWiggle: 0.2, - earScale: 1, - earHoles: true, - badge: nil) - - #expect(image.size.width == 18) - #expect(image.size.height == 18) - #expect(image.tiffRepresentation != nil) - } - - @Test func makeIconRendersWithBadge() { - let image = CritterIconRenderer.makeIcon( - blink: 0, - legWiggle: 0, - earWiggle: 0, - earScale: 1, - earHoles: false, - badge: .init(symbolName: "terminal.fill", prominence: .primary)) - - #expect(image.tiffRepresentation != nil) - } - - @Test func critterStatusLabelExercisesHelpers() async { - await CritterStatusLabel.exerciseForTesting() - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/CronJobEditorSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/CronJobEditorSmokeTests.swift deleted file mode 100644 index ed8315b7c26..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/CronJobEditorSmokeTests.swift +++ /dev/null @@ -1,93 +0,0 @@ -import SwiftUI -import Testing -@testable import OpenClaw - -@Suite(.serialized) -@MainActor -struct CronJobEditorSmokeTests { - @Test func statusPillBuildsBody() { - _ = StatusPill(text: "ok", tint: .green).body - _ = StatusPill(text: "disabled", tint: .secondary).body - } - - @Test func cronJobEditorBuildsBodyForNewJob() { - let channelsStore = ChannelsStore(isPreview: true) - let view = CronJobEditor( - job: nil, - isSaving: .constant(false), - error: .constant(nil), - channelsStore: channelsStore, - onCancel: {}, - onSave: { _ in }) - _ = view.body - } - - @Test func cronJobEditorBuildsBodyForExistingJob() { - let channelsStore = ChannelsStore(isPreview: true) - let job = CronJob( - id: "job-1", - agentId: "ops", - name: "Daily summary", - description: nil, - enabled: true, - deleteAfterRun: nil, - createdAtMs: 1_700_000_000_000, - updatedAtMs: 1_700_000_000_000, - schedule: .every(everyMs: 3_600_000, anchorMs: 1_700_000_000_000), - sessionTarget: .isolated, - wakeMode: .nextHeartbeat, - payload: .agentTurn( - message: "Summarize the last day", - thinking: "low", - timeoutSeconds: 120, - deliver: nil, - channel: nil, - to: nil, - bestEffortDeliver: nil), - delivery: CronDelivery(mode: .announce, channel: "whatsapp", to: "+15551234567", bestEffort: true), - state: CronJobState( - nextRunAtMs: 1_700_000_100_000, - runningAtMs: nil, - lastRunAtMs: 1_700_000_050_000, - lastStatus: "ok", - lastError: nil, - lastDurationMs: 1000)) - - let view = CronJobEditor( - job: job, - isSaving: .constant(false), - error: .constant(nil), - channelsStore: channelsStore, - onCancel: {}, - onSave: { _ in }) - _ = view.body - } - - @Test func cronJobEditorExercisesBuilders() { - let channelsStore = ChannelsStore(isPreview: true) - var view = CronJobEditor( - job: nil, - isSaving: .constant(false), - error: .constant(nil), - channelsStore: channelsStore, - onCancel: {}, - onSave: { _ in }) - view.exerciseForTesting() - } - - @Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() throws { - let channelsStore = ChannelsStore(isPreview: true) - let view = CronJobEditor( - job: nil, - isSaving: .constant(false), - error: .constant(nil), - channelsStore: channelsStore, - onCancel: {}, - onSave: { _ in }) - - var root: [String: Any] = [:] - view.applyDeleteAfterRun(to: &root, scheduleKind: CronJobEditor.ScheduleKind.at, deleteAfterRun: true) - let raw = root["deleteAfterRun"] as? Bool - #expect(raw == true) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/CronModelsTests.swift b/apps/macos/Tests/OpenClawIPCTests/CronModelsTests.swift deleted file mode 100644 index f90ac25a9d7..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/CronModelsTests.swift +++ /dev/null @@ -1,141 +0,0 @@ -import Foundation -import Testing -@testable import OpenClaw - -@Suite -struct CronModelsTests { - @Test func scheduleAtEncodesAndDecodes() throws { - let schedule = CronSchedule.at(at: "2026-02-03T18:00:00Z") - let data = try JSONEncoder().encode(schedule) - let decoded = try JSONDecoder().decode(CronSchedule.self, from: data) - #expect(decoded == schedule) - } - - @Test func scheduleAtDecodesLegacyAtMs() throws { - let json = """ - {"kind":"at","atMs":1700000000000} - """ - let decoded = try JSONDecoder().decode(CronSchedule.self, from: Data(json.utf8)) - if case let .at(at) = decoded { - #expect(at.hasPrefix("2023-")) - } else { - #expect(Bool(false)) - } - } - - @Test func scheduleEveryEncodesAndDecodesWithAnchor() throws { - let schedule = CronSchedule.every(everyMs: 5000, anchorMs: 10000) - let data = try JSONEncoder().encode(schedule) - let decoded = try JSONDecoder().decode(CronSchedule.self, from: data) - #expect(decoded == schedule) - } - - @Test func scheduleCronEncodesAndDecodesWithTimezone() throws { - let schedule = CronSchedule.cron(expr: "*/5 * * * *", tz: "Europe/Vienna") - let data = try JSONEncoder().encode(schedule) - let decoded = try JSONDecoder().decode(CronSchedule.self, from: data) - #expect(decoded == schedule) - } - - @Test func payloadAgentTurnEncodesAndDecodes() throws { - let payload = CronPayload.agentTurn( - message: "hello", - thinking: "low", - timeoutSeconds: 15, - deliver: true, - channel: "whatsapp", - to: "+15551234567", - bestEffortDeliver: false) - let data = try JSONEncoder().encode(payload) - let decoded = try JSONDecoder().decode(CronPayload.self, from: data) - #expect(decoded == payload) - } - - @Test func jobEncodesAndDecodesDeleteAfterRun() throws { - let job = CronJob( - id: "job-1", - agentId: nil, - name: "One-shot", - description: nil, - enabled: true, - deleteAfterRun: true, - createdAtMs: 0, - updatedAtMs: 0, - schedule: .at(at: "2026-02-03T18:00:00Z"), - sessionTarget: .main, - wakeMode: .now, - payload: .systemEvent(text: "ping"), - delivery: nil, - state: CronJobState()) - let data = try JSONEncoder().encode(job) - let decoded = try JSONDecoder().decode(CronJob.self, from: data) - #expect(decoded.deleteAfterRun == true) - } - - @Test func scheduleDecodeRejectsUnknownKind() { - let json = """ - {"kind":"wat","at":"2026-02-03T18:00:00Z"} - """ - #expect(throws: DecodingError.self) { - _ = try JSONDecoder().decode(CronSchedule.self, from: Data(json.utf8)) - } - } - - @Test func payloadDecodeRejectsUnknownKind() { - let json = """ - {"kind":"wat","text":"hello"} - """ - #expect(throws: DecodingError.self) { - _ = try JSONDecoder().decode(CronPayload.self, from: Data(json.utf8)) - } - } - - @Test func displayNameTrimsWhitespaceAndFallsBack() { - let base = CronJob( - id: "x", - agentId: nil, - name: " hello ", - description: nil, - enabled: true, - deleteAfterRun: nil, - createdAtMs: 0, - updatedAtMs: 0, - schedule: .at(at: "2026-02-03T18:00:00Z"), - sessionTarget: .main, - wakeMode: .now, - payload: .systemEvent(text: "hi"), - delivery: nil, - state: CronJobState()) - #expect(base.displayName == "hello") - - var unnamed = base - unnamed.name = " " - #expect(unnamed.displayName == "Untitled job") - } - - @Test func nextRunDateAndLastRunDateDeriveFromState() { - let job = CronJob( - id: "x", - agentId: nil, - name: "t", - description: nil, - enabled: true, - deleteAfterRun: nil, - createdAtMs: 0, - updatedAtMs: 0, - schedule: .at(at: "2026-02-03T18:00:00Z"), - sessionTarget: .main, - wakeMode: .now, - payload: .systemEvent(text: "hi"), - delivery: nil, - state: CronJobState( - nextRunAtMs: 1_700_000_000_000, - runningAtMs: nil, - lastRunAtMs: 1_700_000_050_000, - lastStatus: nil, - lastError: nil, - lastDurationMs: nil)) - #expect(job.nextRunDate == Date(timeIntervalSince1970: 1_700_000_000)) - #expect(job.lastRunDate == Date(timeIntervalSince1970: 1_700_000_050)) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/DeepLinkAgentPolicyTests.swift b/apps/macos/Tests/OpenClawIPCTests/DeepLinkAgentPolicyTests.swift deleted file mode 100644 index ee537f1b62a..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/DeepLinkAgentPolicyTests.swift +++ /dev/null @@ -1,77 +0,0 @@ -import OpenClawKit -import Testing -@testable import OpenClaw - -@Suite struct DeepLinkAgentPolicyTests { - @Test func validateMessageForHandleRejectsTooLongWhenUnkeyed() { - let msg = String(repeating: "a", count: DeepLinkAgentPolicy.maxUnkeyedConfirmChars + 1) - let res = DeepLinkAgentPolicy.validateMessageForHandle(message: msg, allowUnattended: false) - switch res { - case let .failure(error): - #expect( - error == .messageTooLongForConfirmation( - max: DeepLinkAgentPolicy.maxUnkeyedConfirmChars, - actual: DeepLinkAgentPolicy.maxUnkeyedConfirmChars + 1)) - case .success: - Issue.record("expected failure, got success") - } - } - - @Test func validateMessageForHandleAllowsTooLongWhenKeyed() { - let msg = String(repeating: "a", count: DeepLinkAgentPolicy.maxUnkeyedConfirmChars + 1) - let res = DeepLinkAgentPolicy.validateMessageForHandle(message: msg, allowUnattended: true) - switch res { - case .success: - break - case let .failure(error): - Issue.record("expected success, got failure: \(error)") - } - } - - @Test func effectiveDeliveryIgnoresDeliveryFieldsWhenUnkeyed() { - let link = AgentDeepLink( - message: "Hello", - sessionKey: "s", - thinking: "low", - deliver: true, - to: "+15551234567", - channel: "whatsapp", - timeoutSeconds: 10, - key: nil) - let res = DeepLinkAgentPolicy.effectiveDelivery(link: link, allowUnattended: false) - #expect(res.deliver == false) - #expect(res.to == nil) - #expect(res.channel == .last) - } - - @Test func effectiveDeliveryHonorsDeliverForDeliverableChannelsWhenKeyed() { - let link = AgentDeepLink( - message: "Hello", - sessionKey: "s", - thinking: "low", - deliver: true, - to: " +15551234567 ", - channel: "whatsapp", - timeoutSeconds: 10, - key: "secret") - let res = DeepLinkAgentPolicy.effectiveDelivery(link: link, allowUnattended: true) - #expect(res.deliver == true) - #expect(res.to == "+15551234567") - #expect(res.channel == .whatsapp) - } - - @Test func effectiveDeliveryStillBlocksWebChatDeliveryWhenKeyed() { - let link = AgentDeepLink( - message: "Hello", - sessionKey: "s", - thinking: "low", - deliver: true, - to: "+15551234567", - channel: "webchat", - timeoutSeconds: 10, - key: "secret") - let res = DeepLinkAgentPolicy.effectiveDelivery(link: link, allowUnattended: true) - #expect(res.deliver == false) - #expect(res.channel == .webchat) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/DeviceModelCatalogTests.swift b/apps/macos/Tests/OpenClawIPCTests/DeviceModelCatalogTests.swift deleted file mode 100644 index 7d5f1ef6797..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/DeviceModelCatalogTests.swift +++ /dev/null @@ -1,41 +0,0 @@ -import Testing -@testable import OpenClaw - -@Suite -struct DeviceModelCatalogTests { - @Test - func symbolPrefersModelIdentifierPrefixes() { - #expect(DeviceModelCatalog - .symbol(deviceFamily: "iPad", modelIdentifier: "iPad16,6", friendlyName: nil) == "ipad") - #expect(DeviceModelCatalog - .symbol(deviceFamily: "iPhone", modelIdentifier: "iPhone17,3", friendlyName: nil) == "iphone") - } - - @Test - func symbolUsesFriendlyNameForMacVariants() { - #expect(DeviceModelCatalog.symbol( - deviceFamily: "Mac", - modelIdentifier: "Mac99,1", - friendlyName: "Mac Studio (2025)") == "macstudio") - #expect(DeviceModelCatalog.symbol( - deviceFamily: "Mac", - modelIdentifier: "Mac99,2", - friendlyName: "Mac mini (2024)") == "macmini") - #expect(DeviceModelCatalog.symbol( - deviceFamily: "Mac", - modelIdentifier: "Mac99,3", - friendlyName: "MacBook Pro (14-inch, 2024)") == "laptopcomputer") - } - - @Test - func symbolFallsBackToDeviceFamily() { - #expect(DeviceModelCatalog.symbol(deviceFamily: "Android", modelIdentifier: "", friendlyName: nil) == "android") - #expect(DeviceModelCatalog.symbol(deviceFamily: "Linux", modelIdentifier: "", friendlyName: nil) == "cpu") - } - - @Test - func presentationUsesBundledModelMappings() { - let presentation = DeviceModelCatalog.presentation(deviceFamily: "iPhone", modelIdentifier: "iPhone1,1") - #expect(presentation?.title == "iPhone") - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift deleted file mode 100644 index 17f4a1e24ce..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift +++ /dev/null @@ -1,197 +0,0 @@ -import Foundation -import Testing -@testable import OpenClaw - -/// These cases cover optional `security=allowlist` behavior. -/// Default install posture remains deny-by-default for exec on macOS node-host. -struct ExecAllowlistTests { - private struct ShellParserParityFixture: Decodable { - struct Case: Decodable { - let id: String - let command: String - let ok: Bool - let executables: [String] - } - - let cases: [Case] - } - - private static func loadShellParserParityCases() throws -> [ShellParserParityFixture.Case] { - let fixtureURL = self.shellParserParityFixtureURL() - let data = try Data(contentsOf: fixtureURL) - let fixture = try JSONDecoder().decode(ShellParserParityFixture.self, from: data) - return fixture.cases - } - - private static func shellParserParityFixtureURL() -> URL { - var repoRoot = URL(fileURLWithPath: #filePath) - for _ in 0..<5 { - repoRoot.deleteLastPathComponent() - } - return repoRoot - .appendingPathComponent("test") - .appendingPathComponent("fixtures") - .appendingPathComponent("exec-allowlist-shell-parser-parity.json") - } - - @Test func matchUsesResolvedPath() { - let entry = ExecAllowlistEntry(pattern: "/opt/homebrew/bin/rg") - let resolution = ExecCommandResolution( - rawExecutable: "rg", - resolvedPath: "/opt/homebrew/bin/rg", - executableName: "rg", - cwd: nil) - let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution) - #expect(match?.pattern == entry.pattern) - } - - @Test func matchIgnoresBasenamePattern() { - let entry = ExecAllowlistEntry(pattern: "rg") - let resolution = ExecCommandResolution( - rawExecutable: "rg", - resolvedPath: "/opt/homebrew/bin/rg", - executableName: "rg", - cwd: nil) - let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution) - #expect(match == nil) - } - - @Test func matchIgnoresBasenameForRelativeExecutable() { - let entry = ExecAllowlistEntry(pattern: "echo") - let resolution = ExecCommandResolution( - rawExecutable: "./echo", - resolvedPath: "/tmp/oc-basename/echo", - executableName: "echo", - cwd: "/tmp/oc-basename") - let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution) - #expect(match == nil) - } - - @Test func matchIsCaseInsensitive() { - let entry = ExecAllowlistEntry(pattern: "/OPT/HOMEBREW/BIN/RG") - let resolution = ExecCommandResolution( - rawExecutable: "rg", - resolvedPath: "/opt/homebrew/bin/rg", - executableName: "rg", - cwd: nil) - let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution) - #expect(match?.pattern == entry.pattern) - } - - @Test func matchSupportsGlobStar() { - let entry = ExecAllowlistEntry(pattern: "/opt/**/rg") - let resolution = ExecCommandResolution( - rawExecutable: "rg", - resolvedPath: "/opt/homebrew/bin/rg", - executableName: "rg", - cwd: nil) - let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution) - #expect(match?.pattern == entry.pattern) - } - - @Test func resolveForAllowlistSplitsShellChains() { - let command = ["/bin/sh", "-lc", "echo allowlisted && /usr/bin/touch /tmp/openclaw-allowlist-test"] - let resolutions = ExecCommandResolution.resolveForAllowlist( - command: command, - rawCommand: "echo allowlisted && /usr/bin/touch /tmp/openclaw-allowlist-test", - cwd: nil, - env: ["PATH": "/usr/bin:/bin"]) - #expect(resolutions.count == 2) - #expect(resolutions[0].executableName == "echo") - #expect(resolutions[1].executableName == "touch") - } - - @Test func resolveForAllowlistKeepsQuotedOperatorsInSingleSegment() { - let command = ["/bin/sh", "-lc", "echo \"a && b\""] - let resolutions = ExecCommandResolution.resolveForAllowlist( - command: command, - rawCommand: "echo \"a && b\"", - cwd: nil, - env: ["PATH": "/usr/bin:/bin"]) - #expect(resolutions.count == 1) - #expect(resolutions[0].executableName == "echo") - } - - @Test func resolveForAllowlistFailsClosedOnCommandSubstitution() { - let command = ["/bin/sh", "-lc", "echo $(/usr/bin/touch /tmp/openclaw-allowlist-test-subst)"] - let resolutions = ExecCommandResolution.resolveForAllowlist( - command: command, - rawCommand: "echo $(/usr/bin/touch /tmp/openclaw-allowlist-test-subst)", - cwd: nil, - env: ["PATH": "/usr/bin:/bin"]) - #expect(resolutions.isEmpty) - } - - @Test func resolveForAllowlistFailsClosedOnQuotedCommandSubstitution() { - let command = ["/bin/sh", "-lc", "echo \"ok $(/usr/bin/touch /tmp/openclaw-allowlist-test-quoted-subst)\""] - let resolutions = ExecCommandResolution.resolveForAllowlist( - command: command, - rawCommand: "echo \"ok $(/usr/bin/touch /tmp/openclaw-allowlist-test-quoted-subst)\"", - cwd: nil, - env: ["PATH": "/usr/bin:/bin"]) - #expect(resolutions.isEmpty) - } - - @Test func resolveForAllowlistFailsClosedOnQuotedBackticks() { - let command = ["/bin/sh", "-lc", "echo \"ok `/usr/bin/id`\""] - let resolutions = ExecCommandResolution.resolveForAllowlist( - command: command, - rawCommand: "echo \"ok `/usr/bin/id`\"", - cwd: nil, - env: ["PATH": "/usr/bin:/bin"]) - #expect(resolutions.isEmpty) - } - - @Test func resolveForAllowlistMatchesSharedShellParserFixture() throws { - let fixtures = try Self.loadShellParserParityCases() - for fixture in fixtures { - let resolutions = ExecCommandResolution.resolveForAllowlist( - command: ["/bin/sh", "-lc", fixture.command], - rawCommand: fixture.command, - cwd: nil, - env: ["PATH": "/usr/bin:/bin"]) - - #expect(!resolutions.isEmpty == fixture.ok) - if fixture.ok { - let executables = resolutions.map { $0.executableName.lowercased() } - let expected = fixture.executables.map { $0.lowercased() } - #expect(executables == expected) - } - } - } - - @Test func resolveForAllowlistTreatsPlainShInvocationAsDirectExec() { - let command = ["/bin/sh", "./script.sh"] - let resolutions = ExecCommandResolution.resolveForAllowlist( - command: command, - rawCommand: nil, - cwd: "/tmp", - env: ["PATH": "/usr/bin:/bin"]) - #expect(resolutions.count == 1) - #expect(resolutions[0].executableName == "sh") - } - - @Test func matchAllRequiresEverySegmentToMatch() { - let first = ExecCommandResolution( - rawExecutable: "echo", - resolvedPath: "/usr/bin/echo", - executableName: "echo", - cwd: nil) - let second = ExecCommandResolution( - rawExecutable: "/usr/bin/touch", - resolvedPath: "/usr/bin/touch", - executableName: "touch", - cwd: nil) - let resolutions = [first, second] - - let partial = ExecAllowlistMatcher.matchAll( - entries: [ExecAllowlistEntry(pattern: "/usr/bin/echo")], - resolutions: resolutions) - #expect(partial.isEmpty) - - let full = ExecAllowlistMatcher.matchAll( - entries: [ExecAllowlistEntry(pattern: "/USR/BIN/ECHO"), ExecAllowlistEntry(pattern: "/usr/bin/touch")], - resolutions: resolutions) - #expect(full.count == 2) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalHelpersTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalHelpersTests.swift deleted file mode 100644 index 455b4296753..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalHelpersTests.swift +++ /dev/null @@ -1,78 +0,0 @@ -import Foundation -import Testing -@testable import OpenClaw - -@Suite struct ExecApprovalHelpersTests { - @Test func parseDecisionTrimsAndRejectsInvalid() { - #expect(ExecApprovalHelpers.parseDecision("allow-once") == .allowOnce) - #expect(ExecApprovalHelpers.parseDecision(" allow-always ") == .allowAlways) - #expect(ExecApprovalHelpers.parseDecision("deny") == .deny) - #expect(ExecApprovalHelpers.parseDecision("") == nil) - #expect(ExecApprovalHelpers.parseDecision("nope") == nil) - } - - @Test func allowlistPatternPrefersResolution() { - let resolved = ExecCommandResolution( - rawExecutable: "rg", - resolvedPath: "/opt/homebrew/bin/rg", - executableName: "rg", - cwd: nil) - #expect(ExecApprovalHelpers.allowlistPattern(command: ["rg"], resolution: resolved) == resolved.resolvedPath) - - let rawOnly = ExecCommandResolution( - rawExecutable: "rg", - resolvedPath: nil, - executableName: "rg", - cwd: nil) - #expect(ExecApprovalHelpers.allowlistPattern(command: ["rg"], resolution: rawOnly) == "rg") - #expect(ExecApprovalHelpers.allowlistPattern(command: ["rg"], resolution: nil) == "rg") - #expect(ExecApprovalHelpers.allowlistPattern(command: [], resolution: nil) == nil) - } - - @Test func validateAllowlistPatternReturnsReasons() { - #expect(ExecApprovalHelpers.isPathPattern("/usr/bin/rg")) - #expect(ExecApprovalHelpers.isPathPattern(" ~/bin/rg ")) - #expect(!ExecApprovalHelpers.isPathPattern("rg")) - - if case .invalid(let reason) = ExecApprovalHelpers.validateAllowlistPattern(" ") { - #expect(reason == .empty) - } else { - Issue.record("Expected empty pattern rejection") - } - - if case .invalid(let reason) = ExecApprovalHelpers.validateAllowlistPattern("echo") { - #expect(reason == .missingPathComponent) - } else { - Issue.record("Expected basename pattern rejection") - } - } - - @Test func requiresAskMatchesPolicy() { - let entry = ExecAllowlistEntry(pattern: "/bin/ls", lastUsedAt: nil, lastUsedCommand: nil, lastResolvedPath: nil) - #expect(ExecApprovalHelpers.requiresAsk( - ask: .always, - security: .deny, - allowlistMatch: nil, - skillAllow: false)) - #expect(ExecApprovalHelpers.requiresAsk( - ask: .onMiss, - security: .allowlist, - allowlistMatch: nil, - skillAllow: false)) - #expect(!ExecApprovalHelpers.requiresAsk( - ask: .onMiss, - security: .allowlist, - allowlistMatch: entry, - skillAllow: false)) - #expect(!ExecApprovalHelpers.requiresAsk( - ask: .onMiss, - security: .allowlist, - allowlistMatch: nil, - skillAllow: true)) - #expect(!ExecApprovalHelpers.requiresAsk( - ask: .off, - security: .allowlist, - allowlistMatch: nil, - skillAllow: false)) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsGatewayPrompterTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsGatewayPrompterTests.swift deleted file mode 100644 index 4bc75405398..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsGatewayPrompterTests.swift +++ /dev/null @@ -1,56 +0,0 @@ -import Testing -@testable import OpenClaw - -@Suite -@MainActor -struct ExecApprovalsGatewayPrompterTests { - @Test func sessionMatchPrefersActiveSession() { - let matches = ExecApprovalsGatewayPrompter._testShouldPresent( - mode: .remote, - activeSession: " main ", - requestSession: "main", - lastInputSeconds: nil) - #expect(matches) - - let mismatched = ExecApprovalsGatewayPrompter._testShouldPresent( - mode: .remote, - activeSession: "other", - requestSession: "main", - lastInputSeconds: 0) - #expect(!mismatched) - } - - @Test func sessionFallbackUsesRecentActivity() { - let recent = ExecApprovalsGatewayPrompter._testShouldPresent( - mode: .remote, - activeSession: nil, - requestSession: "main", - lastInputSeconds: 10, - thresholdSeconds: 120) - #expect(recent) - - let stale = ExecApprovalsGatewayPrompter._testShouldPresent( - mode: .remote, - activeSession: nil, - requestSession: "main", - lastInputSeconds: 200, - thresholdSeconds: 120) - #expect(!stale) - } - - @Test func defaultBehaviorMatchesMode() { - let local = ExecApprovalsGatewayPrompter._testShouldPresent( - mode: .local, - activeSession: nil, - requestSession: nil, - lastInputSeconds: 400) - #expect(local) - - let remote = ExecApprovalsGatewayPrompter._testShouldPresent( - mode: .remote, - activeSession: nil, - requestSession: nil, - lastInputSeconds: 400) - #expect(!remote) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift deleted file mode 100644 index fa9eef87881..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift +++ /dev/null @@ -1,75 +0,0 @@ -import Foundation -import Testing -@testable import OpenClaw - -@Suite(.serialized) -struct ExecApprovalsStoreRefactorTests { - @Test - func ensureFileSkipsRewriteWhenUnchanged() async throws { - let stateDir = FileManager().temporaryDirectory - .appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager().removeItem(at: stateDir) } - - try await TestIsolation.withEnvValues(["OPENCLAW_STATE_DIR": stateDir.path]) { - _ = ExecApprovalsStore.ensureFile() - let url = ExecApprovalsStore.fileURL() - let firstWriteDate = try Self.modificationDate(at: url) - - try await Task.sleep(nanoseconds: 1_100_000_000) - _ = ExecApprovalsStore.ensureFile() - let secondWriteDate = try Self.modificationDate(at: url) - - #expect(firstWriteDate == secondWriteDate) - } - } - - @Test - func updateAllowlistReportsRejectedBasenamePattern() async throws { - let stateDir = FileManager().temporaryDirectory - .appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager().removeItem(at: stateDir) } - - await TestIsolation.withEnvValues(["OPENCLAW_STATE_DIR": stateDir.path]) { - let rejected = ExecApprovalsStore.updateAllowlist( - agentId: "main", - allowlist: [ - ExecAllowlistEntry(pattern: "echo"), - ExecAllowlistEntry(pattern: "/bin/echo"), - ]) - #expect(rejected.count == 1) - #expect(rejected.first?.reason == .missingPathComponent) - #expect(rejected.first?.pattern == "echo") - - let resolved = ExecApprovalsStore.resolve(agentId: "main") - #expect(resolved.allowlist.map(\.pattern) == ["/bin/echo"]) - } - } - - @Test - func updateAllowlistMigratesLegacyPatternFromResolvedPath() async throws { - let stateDir = FileManager().temporaryDirectory - .appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager().removeItem(at: stateDir) } - - await TestIsolation.withEnvValues(["OPENCLAW_STATE_DIR": stateDir.path]) { - let rejected = ExecApprovalsStore.updateAllowlist( - agentId: "main", - allowlist: [ - ExecAllowlistEntry(pattern: "echo", lastUsedAt: nil, lastUsedCommand: nil, lastResolvedPath: " /usr/bin/echo "), - ]) - #expect(rejected.isEmpty) - - let resolved = ExecApprovalsStore.resolve(agentId: "main") - #expect(resolved.allowlist.map(\.pattern) == ["/usr/bin/echo"]) - } - } - - private static func modificationDate(at url: URL) throws -> Date { - let attributes = try FileManager().attributesOfItem(atPath: url.path) - guard let date = attributes[.modificationDate] as? Date else { - struct MissingDateError: Error {} - throw MissingDateError() - } - return date - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/FileHandleLegacyAPIGuardTests.swift b/apps/macos/Tests/OpenClawIPCTests/FileHandleLegacyAPIGuardTests.swift deleted file mode 100644 index a6836aaa081..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/FileHandleLegacyAPIGuardTests.swift +++ /dev/null @@ -1,155 +0,0 @@ -import Foundation -import Testing - -@Suite struct FileHandleLegacyAPIGuardTests { - @Test func sourcesAvoidLegacyNonThrowingFileHandleReadAPIs() throws { - let testFile = URL(fileURLWithPath: #filePath) - let packageRoot = testFile - .deletingLastPathComponent() // OpenClawIPCTests - .deletingLastPathComponent() // Tests - .deletingLastPathComponent() // apps/macos - - let sourcesRoot = packageRoot.appendingPathComponent("Sources") - let swiftFiles = try Self.swiftFiles(under: sourcesRoot) - - var offenders: [String] = [] - for file in swiftFiles { - let raw = try String(contentsOf: file, encoding: .utf8) - let stripped = Self.stripCommentsAndStrings(from: raw) - - if stripped.contains("readDataToEndOfFile(") || stripped.contains(".availableData") { - offenders.append(file.path) - } - } - - if !offenders.isEmpty { - let message = "Found legacy FileHandle reads in:\n" + offenders.joined(separator: "\n") - throw NSError( - domain: "FileHandleLegacyAPIGuardTests", - code: 1, - userInfo: [NSLocalizedDescriptionKey: message]) - } - } - - private static func swiftFiles(under root: URL) throws -> [URL] { - let fm = FileManager() - guard let enumerator = fm.enumerator(at: root, includingPropertiesForKeys: [.isRegularFileKey]) else { - return [] - } - - var files: [URL] = [] - for case let url as URL in enumerator { - guard url.pathExtension == "swift" else { continue } - files.append(url) - } - return files - } - - private static func stripCommentsAndStrings(from source: String) -> String { - enum Mode { - case code - case lineComment - case blockComment(depth: Int) - case string(quoteCount: Int) // 1 = ", 3 = """ - } - - var mode: Mode = .code - var out = "" - out.reserveCapacity(source.count) - - var index = source.startIndex - func peek(_ offset: Int) -> Character? { - guard - let i = source.index(index, offsetBy: offset, limitedBy: source.endIndex), - i < source.endIndex - else { return nil } - return source[i] - } - - while index < source.endIndex { - let ch = source[index] - - switch mode { - case .code: - if ch == "/", peek(1) == "/" { - out.append(" ") - index = source.index(index, offsetBy: 2) - mode = .lineComment - continue - } - if ch == "/", peek(1) == "*" { - out.append(" ") - index = source.index(index, offsetBy: 2) - mode = .blockComment(depth: 1) - continue - } - if ch == "\"" { - let triple = (peek(1) == "\"") && (peek(2) == "\"") - out.append(triple ? " " : " ") - index = source.index(index, offsetBy: triple ? 3 : 1) - mode = .string(quoteCount: triple ? 3 : 1) - continue - } - out.append(ch) - index = source.index(after: index) - - case .lineComment: - if ch == "\n" { - out.append(ch) - index = source.index(after: index) - mode = .code - } else { - out.append(" ") - index = source.index(after: index) - } - - case let .blockComment(depth): - if ch == "/", peek(1) == "*" { - out.append(" ") - index = source.index(index, offsetBy: 2) - mode = .blockComment(depth: depth + 1) - continue - } - if ch == "*", peek(1) == "/" { - out.append(" ") - index = source.index(index, offsetBy: 2) - let newDepth = depth - 1 - mode = newDepth > 0 ? .blockComment(depth: newDepth) : .code - continue - } - out.append(ch == "\n" ? "\n" : " ") - index = source.index(after: index) - - case let .string(quoteCount): - if ch == "\\", quoteCount == 1 { - // Skip escaped character in normal strings. - out.append(" ") - index = source.index(after: index) - if index < source.endIndex { - out.append(" ") - index = source.index(after: index) - } - continue - } - if ch == "\"" { - if quoteCount == 3, peek(1) == "\"", peek(2) == "\"" { - out.append(" ") - index = source.index(index, offsetBy: 3) - mode = .code - continue - } - if quoteCount == 1 { - out.append(" ") - index = source.index(after: index) - mode = .code - continue - } - } - out.append(ch == "\n" ? "\n" : " ") - index = source.index(after: index) - } - } - - return out - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/FileHandleSafeReadTests.swift b/apps/macos/Tests/OpenClawIPCTests/FileHandleSafeReadTests.swift deleted file mode 100644 index 3b679a7d586..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/FileHandleSafeReadTests.swift +++ /dev/null @@ -1,47 +0,0 @@ -import Foundation -import Testing -@testable import OpenClaw - -@Suite struct FileHandleSafeReadTests { - @Test func readToEndSafelyReturnsEmptyForClosedHandle() { - let pipe = Pipe() - let handle = pipe.fileHandleForReading - try? handle.close() - - let data = handle.readToEndSafely() - #expect(data.isEmpty) - } - - @Test func readSafelyUpToCountReturnsEmptyForClosedHandle() { - let pipe = Pipe() - let handle = pipe.fileHandleForReading - try? handle.close() - - let data = handle.readSafely(upToCount: 16) - #expect(data.isEmpty) - } - - @Test func readToEndSafelyReadsPipeContents() { - let pipe = Pipe() - let writeHandle = pipe.fileHandleForWriting - writeHandle.write(Data("hello".utf8)) - try? writeHandle.close() - - let data = pipe.fileHandleForReading.readToEndSafely() - #expect(String(data: data, encoding: .utf8) == "hello") - } - - @Test func readSafelyUpToCountReadsIncrementally() { - let pipe = Pipe() - let writeHandle = pipe.fileHandleForWriting - writeHandle.write(Data("hello world".utf8)) - try? writeHandle.close() - - let readHandle = pipe.fileHandleForReading - let first = readHandle.readSafely(upToCount: 5) - let second = readHandle.readSafely(upToCount: 32) - - #expect(String(data: first, encoding: .utf8) == "hello") - #expect(String(data: second, encoding: .utf8) == " world") - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayAgentChannelTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayAgentChannelTests.swift deleted file mode 100644 index 18972a23bbc..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayAgentChannelTests.swift +++ /dev/null @@ -1,27 +0,0 @@ -import Testing -@testable import OpenClaw - -@Suite struct GatewayAgentChannelTests { - @Test func shouldDeliverBlocksWebChat() { - #expect(GatewayAgentChannel.webchat.shouldDeliver(true) == false) - #expect(GatewayAgentChannel.webchat.shouldDeliver(false) == false) - } - - @Test func shouldDeliverAllowsLastAndProviderChannels() { - #expect(GatewayAgentChannel.last.shouldDeliver(true) == true) - #expect(GatewayAgentChannel.whatsapp.shouldDeliver(true) == true) - #expect(GatewayAgentChannel.telegram.shouldDeliver(true) == true) - #expect(GatewayAgentChannel.googlechat.shouldDeliver(true) == true) - #expect(GatewayAgentChannel.bluebubbles.shouldDeliver(true) == true) - #expect(GatewayAgentChannel.last.shouldDeliver(false) == false) - } - - @Test func initRawNormalizesAndFallsBackToLast() { - #expect(GatewayAgentChannel(raw: nil) == .last) - #expect(GatewayAgentChannel(raw: " ") == .last) - #expect(GatewayAgentChannel(raw: "WEBCHAT") == .webchat) - #expect(GatewayAgentChannel(raw: "googlechat") == .googlechat) - #expect(GatewayAgentChannel(raw: "BLUEBUBBLES") == .bluebubbles) - #expect(GatewayAgentChannel(raw: "unknown") == .last) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayAutostartPolicyTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayAutostartPolicyTests.swift deleted file mode 100644 index f2fea5fc458..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayAutostartPolicyTests.swift +++ /dev/null @@ -1,24 +0,0 @@ -import Testing -@testable import OpenClaw - -@Suite(.serialized) -struct GatewayAutostartPolicyTests { - @Test func startsGatewayOnlyWhenLocalAndNotPaused() { - #expect(GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: false)) - #expect(!GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: true)) - #expect(!GatewayAutostartPolicy.shouldStartGateway(mode: .remote, paused: false)) - #expect(!GatewayAutostartPolicy.shouldStartGateway(mode: .unconfigured, paused: false)) - } - - @Test func ensuresLaunchAgentWhenLocalAndNotAttachOnly() { - #expect(GatewayAutostartPolicy.shouldEnsureLaunchAgent( - mode: .local, - paused: false)) - #expect(!GatewayAutostartPolicy.shouldEnsureLaunchAgent( - mode: .local, - paused: true)) - #expect(!GatewayAutostartPolicy.shouldEnsureLaunchAgent( - mode: .remote, - paused: false)) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConfigureTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConfigureTests.swift deleted file mode 100644 index ec2caf6057c..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConfigureTests.swift +++ /dev/null @@ -1,246 +0,0 @@ -import OpenClawKit -import Foundation -import os -import Testing -@testable import OpenClaw - -@Suite struct GatewayConnectionTests { - private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable { - private let connectRequestID = OSAllocatedUnfairLock(initialState: nil) - private let pendingReceiveHandler = - OSAllocatedUnfairLock<(@Sendable (Result) - -> Void)?>(initialState: nil) - private let cancelCount = OSAllocatedUnfairLock(initialState: 0) - private let sendCount = OSAllocatedUnfairLock(initialState: 0) - private let helloDelayMs: Int - - var state: URLSessionTask.State = .suspended - - init(helloDelayMs: Int = 0) { - self.helloDelayMs = helloDelayMs - } - - func snapshotCancelCount() -> Int { self.cancelCount.withLock { $0 } } - - func resume() { - self.state = .running - } - - func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { - _ = (closeCode, reason) - self.state = .canceling - self.cancelCount.withLock { $0 += 1 } - let handler = self.pendingReceiveHandler.withLock { handler in - defer { handler = nil } - return handler - } - handler?(Result.failure(URLError(.cancelled))) - } - - func send(_ message: URLSessionWebSocketTask.Message) async throws { - let currentSendCount = self.sendCount.withLock { count in - defer { count += 1 } - return count - } - - // First send is the connect handshake request. Subsequent sends are request frames. - if currentSendCount == 0 { - if let id = GatewayWebSocketTestSupport.connectRequestID(from: message) { - self.connectRequestID.withLock { $0 = id } - } - return - } - - guard case let .data(data) = message else { return } - guard - let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - (obj["type"] as? String) == "req", - let id = obj["id"] as? String - else { - return - } - - let response = GatewayWebSocketTestSupport.okResponseData(id: id) - let handler = self.pendingReceiveHandler.withLock { $0 } - handler?(Result.success(.data(response))) - } - - func receive() async throws -> URLSessionWebSocketTask.Message { - if self.helloDelayMs > 0 { - try await Task.sleep(nanoseconds: UInt64(self.helloDelayMs) * 1_000_000) - } - let id = self.connectRequestID.withLock { $0 } ?? "connect" - return .data(GatewayWebSocketTestSupport.connectOkData(id: id)) - } - - func receive( - completionHandler: @escaping @Sendable (Result) -> Void) - { - self.pendingReceiveHandler.withLock { $0 = completionHandler } - } - - func emitIncoming(_ data: Data) { - let handler = self.pendingReceiveHandler.withLock { $0 } - handler?(Result.success(.data(data))) - } - - } - - private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable { - private let makeCount = OSAllocatedUnfairLock(initialState: 0) - private let tasks = OSAllocatedUnfairLock(initialState: [FakeWebSocketTask]()) - private let helloDelayMs: Int - - init(helloDelayMs: Int = 0) { - self.helloDelayMs = helloDelayMs - } - - func snapshotMakeCount() -> Int { self.makeCount.withLock { $0 } } - func snapshotCancelCount() -> Int { - self.tasks.withLock { tasks in - tasks.reduce(0) { $0 + $1.snapshotCancelCount() } - } - } - - func latestTask() -> FakeWebSocketTask? { - self.tasks.withLock { $0.last } - } - - func makeWebSocketTask(url: URL) -> WebSocketTaskBox { - _ = url - self.makeCount.withLock { $0 += 1 } - let task = FakeWebSocketTask(helloDelayMs: self.helloDelayMs) - self.tasks.withLock { $0.append(task) } - return WebSocketTaskBox(task: task) - } - } - - private final class ConfigSource: @unchecked Sendable { - private let token = OSAllocatedUnfairLock(initialState: nil) - - init(token: String?) { - self.token.withLock { $0 = token } - } - - func snapshotToken() -> String? { self.token.withLock { $0 } } - func setToken(_ value: String?) { self.token.withLock { $0 = value } } - } - - @Test func requestReusesSingleWebSocketForSameConfig() async throws { - let session = FakeWebSocketSession() - let url = URL(string: "ws://example.invalid")! - let cfg = ConfigSource(token: nil) - let conn = GatewayConnection( - configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) }, - sessionBox: WebSocketSessionBox(session: session)) - - _ = try await conn.request(method: "status", params: nil) - #expect(session.snapshotMakeCount() == 1) - - _ = try await conn.request(method: "status", params: nil) - #expect(session.snapshotMakeCount() == 1) - #expect(session.snapshotCancelCount() == 0) - } - - @Test func requestReconfiguresAndCancelsOnTokenChange() async throws { - let session = FakeWebSocketSession() - let url = URL(string: "ws://example.invalid")! - let cfg = ConfigSource(token: "a") - let conn = GatewayConnection( - configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) }, - sessionBox: WebSocketSessionBox(session: session)) - - _ = try await conn.request(method: "status", params: nil) - #expect(session.snapshotMakeCount() == 1) - - cfg.setToken("b") - _ = try await conn.request(method: "status", params: nil) - #expect(session.snapshotMakeCount() == 2) - #expect(session.snapshotCancelCount() == 1) - } - - @Test func concurrentRequestsStillUseSingleWebSocket() async throws { - let session = FakeWebSocketSession(helloDelayMs: 150) - let url = URL(string: "ws://example.invalid")! - let cfg = ConfigSource(token: nil) - let conn = GatewayConnection( - configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) }, - sessionBox: WebSocketSessionBox(session: session)) - - async let r1: Data = conn.request(method: "status", params: nil) - async let r2: Data = conn.request(method: "status", params: nil) - _ = try await (r1, r2) - - #expect(session.snapshotMakeCount() == 1) - } - - @Test func subscribeReplaysLatestSnapshot() async throws { - let session = FakeWebSocketSession() - let url = URL(string: "ws://example.invalid")! - let cfg = ConfigSource(token: nil) - let conn = GatewayConnection( - configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) }, - sessionBox: WebSocketSessionBox(session: session)) - - _ = try await conn.request(method: "status", params: nil) - - let stream = await conn.subscribe(bufferingNewest: 10) - var iterator = stream.makeAsyncIterator() - let first = await iterator.next() - - guard case let .snapshot(snap) = first else { - Issue.record("expected snapshot, got \(String(describing: first))") - return - } - #expect(snap.type == "hello-ok") - } - - @Test func subscribeEmitsSeqGapBeforeEvent() async throws { - let session = FakeWebSocketSession() - let url = URL(string: "ws://example.invalid")! - let cfg = ConfigSource(token: nil) - let conn = GatewayConnection( - configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) }, - sessionBox: WebSocketSessionBox(session: session)) - - let stream = await conn.subscribe(bufferingNewest: 10) - var iterator = stream.makeAsyncIterator() - - _ = try await conn.request(method: "status", params: nil) - _ = await iterator.next() // snapshot - - let evt1 = Data( - """ - {"type":"event","event":"presence","payload":{"presence":[]},"seq":1} - """.utf8) - session.latestTask()?.emitIncoming(evt1) - - let firstEvent = await iterator.next() - guard case let .event(firstFrame) = firstEvent else { - Issue.record("expected event, got \(String(describing: firstEvent))") - return - } - #expect(firstFrame.seq == 1) - - let evt3 = Data( - """ - {"type":"event","event":"presence","payload":{"presence":[]},"seq":3} - """.utf8) - session.latestTask()?.emitIncoming(evt3) - - let gap = await iterator.next() - guard case let .seqGap(expected, received) = gap else { - Issue.record("expected seqGap, got \(String(describing: gap))") - return - } - #expect(expected == 2) - #expect(received == 3) - - let secondEvent = await iterator.next() - guard case let .event(secondFrame) = secondEvent else { - Issue.record("expected event, got \(String(describing: secondEvent))") - return - } - #expect(secondFrame.seq == 3) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift deleted file mode 100644 index afe9dea9e2c..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift +++ /dev/null @@ -1,127 +0,0 @@ -import OpenClawKit -import Foundation -import os -import Testing -@testable import OpenClaw - -@Suite struct GatewayChannelConnectTests { - private enum FakeResponse { - case helloOk(delayMs: Int) - case invalid(delayMs: Int) - } - - private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable { - private let response: FakeResponse - private let connectRequestID = OSAllocatedUnfairLock(initialState: nil) - private let pendingReceiveHandler = - OSAllocatedUnfairLock<(@Sendable (Result) -> Void)?>( - initialState: nil) - - var state: URLSessionTask.State = .suspended - - init(response: FakeResponse) { - self.response = response - } - - func resume() { - self.state = .running - } - - func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { - _ = (closeCode, reason) - self.state = .canceling - let handler = self.pendingReceiveHandler.withLock { handler in - defer { handler = nil } - return handler - } - handler?(Result.failure(URLError(.cancelled))) - } - - func send(_ message: URLSessionWebSocketTask.Message) async throws { - if let id = GatewayWebSocketTestSupport.connectRequestID(from: message) { - self.connectRequestID.withLock { $0 = id } - } - } - - func receive() async throws -> URLSessionWebSocketTask.Message { - let delayMs: Int - let msg: URLSessionWebSocketTask.Message - switch self.response { - case let .helloOk(ms): - delayMs = ms - let id = self.connectRequestID.withLock { $0 } ?? "connect" - msg = .data(GatewayWebSocketTestSupport.connectOkData(id: id)) - case let .invalid(ms): - delayMs = ms - msg = .string("not json") - } - try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000) - return msg - } - - func receive( - completionHandler: @escaping @Sendable (Result) -> Void) - { - // The production channel sets up a continuous receive loop after hello. - // Tests only need the handshake receive; keep the loop idle. - self.pendingReceiveHandler.withLock { $0 = completionHandler } - } - - } - - private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable { - private let response: FakeResponse - private let makeCount = OSAllocatedUnfairLock(initialState: 0) - - init(response: FakeResponse) { - self.response = response - } - - func snapshotMakeCount() -> Int { self.makeCount.withLock { $0 } } - - func makeWebSocketTask(url: URL) -> WebSocketTaskBox { - _ = url - self.makeCount.withLock { $0 += 1 } - let task = FakeWebSocketTask(response: self.response) - return WebSocketTaskBox(task: task) - } - } - - @Test func concurrentConnectIsSingleFlightOnSuccess() async throws { - let session = FakeWebSocketSession(response: .helloOk(delayMs: 200)) - let channel = GatewayChannelActor( - url: URL(string: "ws://example.invalid")!, - token: nil, - session: WebSocketSessionBox(session: session)) - - let t1 = Task { try await channel.connect() } - let t2 = Task { try await channel.connect() } - - _ = try await t1.value - _ = try await t2.value - - #expect(session.snapshotMakeCount() == 1) - } - - @Test func concurrentConnectSharesFailure() async { - let session = FakeWebSocketSession(response: .invalid(delayMs: 200)) - let channel = GatewayChannelActor( - url: URL(string: "ws://example.invalid")!, - token: nil, - session: WebSocketSessionBox(session: session)) - - let t1 = Task { try await channel.connect() } - let t2 = Task { try await channel.connect() } - - let r1 = await t1.result - let r2 = await t2.result - - #expect({ - if case .failure = r1 { true } else { false } - }()) - #expect({ - if case .failure = r2 { true } else { false } - }()) - #expect(session.snapshotMakeCount() == 1) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelRequestTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelRequestTests.swift deleted file mode 100644 index 4c788a959f5..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelRequestTests.swift +++ /dev/null @@ -1,101 +0,0 @@ -import OpenClawKit -import Foundation -import os -import Testing -@testable import OpenClaw - -@Suite struct GatewayChannelRequestTests { - private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable { - private let requestSendDelayMs: Int - private let connectRequestID = OSAllocatedUnfairLock(initialState: nil) - private let pendingReceiveHandler = - OSAllocatedUnfairLock<(@Sendable (Result) - -> Void)?>(initialState: nil) - private let sendCount = OSAllocatedUnfairLock(initialState: 0) - - var state: URLSessionTask.State = .suspended - - init(requestSendDelayMs: Int) { - self.requestSendDelayMs = requestSendDelayMs - } - - func resume() { - self.state = .running - } - - func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { - _ = (closeCode, reason) - self.state = .canceling - let handler = self.pendingReceiveHandler.withLock { handler in - defer { handler = nil } - return handler - } - handler?(Result.failure(URLError(.cancelled))) - } - - func send(_ message: URLSessionWebSocketTask.Message) async throws { - _ = message - let currentSendCount = self.sendCount.withLock { count in - defer { count += 1 } - return count - } - - // First send is the connect handshake. Second send is the request frame. - if currentSendCount == 0 { - if let id = GatewayWebSocketTestSupport.connectRequestID(from: message) { - self.connectRequestID.withLock { $0 = id } - } - } - if currentSendCount == 1 { - try await Task.sleep(nanoseconds: UInt64(self.requestSendDelayMs) * 1_000_000) - throw URLError(.cannotConnectToHost) - } - } - - func receive() async throws -> URLSessionWebSocketTask.Message { - let id = self.connectRequestID.withLock { $0 } ?? "connect" - return .data(GatewayWebSocketTestSupport.connectOkData(id: id)) - } - - func receive( - completionHandler: @escaping @Sendable (Result) -> Void) - { - self.pendingReceiveHandler.withLock { $0 = completionHandler } - } - - } - - private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable { - private let requestSendDelayMs: Int - - init(requestSendDelayMs: Int) { - self.requestSendDelayMs = requestSendDelayMs - } - - func makeWebSocketTask(url: URL) -> WebSocketTaskBox { - _ = url - let task = FakeWebSocketTask(requestSendDelayMs: self.requestSendDelayMs) - return WebSocketTaskBox(task: task) - } - } - - @Test func requestTimeoutThenSendFailureDoesNotDoubleResume() async { - let session = FakeWebSocketSession(requestSendDelayMs: 100) - let channel = GatewayChannelActor( - url: URL(string: "ws://example.invalid")!, - token: nil, - session: WebSocketSessionBox(session: session)) - - do { - _ = try await channel.request(method: "test", params: nil, timeoutMs: 10) - Issue.record("Expected request to time out") - } catch { - let ns = error as NSError - #expect(ns.domain == "Gateway") - #expect(ns.code == 5) - } - - // Give the delayed send failure task time to run; this used to crash due to a double-resume. - try? await Task.sleep(nanoseconds: 250 * 1_000_000) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelShutdownTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelShutdownTests.swift deleted file mode 100644 index 5f995cd394a..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelShutdownTests.swift +++ /dev/null @@ -1,96 +0,0 @@ -import OpenClawKit -import Foundation -import os -import Testing -@testable import OpenClaw - -@Suite struct GatewayChannelShutdownTests { - private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable { - private let connectRequestID = OSAllocatedUnfairLock(initialState: nil) - private let pendingReceiveHandler = - OSAllocatedUnfairLock<(@Sendable (Result) - -> Void)?>(initialState: nil) - private let cancelCount = OSAllocatedUnfairLock(initialState: 0) - - var state: URLSessionTask.State = .suspended - - func snapshotCancelCount() -> Int { self.cancelCount.withLock { $0 } } - - func resume() { - self.state = .running - } - - func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { - _ = (closeCode, reason) - self.state = .canceling - self.cancelCount.withLock { $0 += 1 } - let handler = self.pendingReceiveHandler.withLock { handler in - defer { handler = nil } - return handler - } - handler?(Result.failure(URLError(.cancelled))) - } - - func send(_ message: URLSessionWebSocketTask.Message) async throws { - if let id = GatewayWebSocketTestSupport.connectRequestID(from: message) { - self.connectRequestID.withLock { $0 = id } - } - } - - func receive() async throws -> URLSessionWebSocketTask.Message { - let id = self.connectRequestID.withLock { $0 } ?? "connect" - return .data(GatewayWebSocketTestSupport.connectOkData(id: id)) - } - - func receive( - completionHandler: @escaping @Sendable (Result) -> Void) - { - self.pendingReceiveHandler.withLock { $0 = completionHandler } - } - - func triggerReceiveFailure() { - let handler = self.pendingReceiveHandler.withLock { $0 } - handler?(Result.failure(URLError(.networkConnectionLost))) - } - - } - - private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable { - private let makeCount = OSAllocatedUnfairLock(initialState: 0) - private let tasks = OSAllocatedUnfairLock(initialState: [FakeWebSocketTask]()) - - func snapshotMakeCount() -> Int { self.makeCount.withLock { $0 } } - func latestTask() -> FakeWebSocketTask? { self.tasks.withLock { $0.last } } - - func makeWebSocketTask(url: URL) -> WebSocketTaskBox { - _ = url - self.makeCount.withLock { $0 += 1 } - let task = FakeWebSocketTask() - self.tasks.withLock { $0.append(task) } - return WebSocketTaskBox(task: task) - } - } - - @Test func shutdownPreventsReconnectLoopFromReceiveFailure() async throws { - let session = FakeWebSocketSession() - let channel = GatewayChannelActor( - url: URL(string: "ws://example.invalid")!, - token: nil, - session: WebSocketSessionBox(session: session)) - - // Establish a connection so `listen()` is active. - try await channel.connect() - #expect(session.snapshotMakeCount() == 1) - - // Simulate a socket receive failure, which would normally schedule a reconnect. - session.latestTask()?.triggerReceiveFailure() - - // Shut down quickly, before backoff reconnect triggers. - await channel.shutdown() - - // Wait longer than the default reconnect backoff (500ms) to ensure no reconnect happens. - try? await Task.sleep(nanoseconds: 750 * 1_000_000) - - #expect(session.snapshotMakeCount() == 1) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift deleted file mode 100644 index e95cf7a282d..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift +++ /dev/null @@ -1,59 +0,0 @@ -import OpenClawKit -import Foundation -import Testing -@testable import OpenClaw -@testable import OpenClawIPC - -private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable { - var state: URLSessionTask.State = .running - - func resume() {} - - func cancel(with _: URLSessionWebSocketTask.CloseCode, reason _: Data?) { - self.state = .canceling - } - - func send(_: URLSessionWebSocketTask.Message) async throws {} - - func receive() async throws -> URLSessionWebSocketTask.Message { - throw URLError(.cannotConnectToHost) - } - - func receive(completionHandler: @escaping @Sendable (Result) -> Void) { - completionHandler(.failure(URLError(.cannotConnectToHost))) - } -} - -private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable { - func makeWebSocketTask(url _: URL) -> WebSocketTaskBox { - WebSocketTaskBox(task: FakeWebSocketTask()) - } -} - -private func makeTestGatewayConnection() -> GatewayConnection { - GatewayConnection( - configProvider: { - (url: URL(string: "ws://127.0.0.1:1")!, token: nil, password: nil) - }, - sessionBox: WebSocketSessionBox(session: FakeWebSocketSession())) -} - -@Suite(.serialized) struct GatewayConnectionControlTests { - @Test func statusFailsWhenProcessMissing() async { - let connection = makeTestGatewayConnection() - let result = await connection.status() - #expect(result.ok == false) - #expect(result.error != nil) - } - - @Test func rejectEmptyMessage() async { - let connection = makeTestGatewayConnection() - let result = await connection.sendAgent( - message: "", - thinking: nil, - sessionKey: "main", - deliver: false, - to: nil) - #expect(result.ok == false) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryHelpersTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryHelpersTests.swift deleted file mode 100644 index 17ffec07d46..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryHelpersTests.swift +++ /dev/null @@ -1,98 +0,0 @@ -import Foundation -import OpenClawDiscovery -import Testing -@testable import OpenClaw - -@Suite -struct GatewayDiscoveryHelpersTests { - private func makeGateway( - serviceHost: String?, - servicePort: Int?, - lanHost: String? = "txt-host.local", - tailnetDns: String? = "txt-host.ts.net", - sshPort: Int = 22, - gatewayPort: Int? = 18789) -> GatewayDiscoveryModel.DiscoveredGateway - { - GatewayDiscoveryModel.DiscoveredGateway( - displayName: "Gateway", - serviceHost: serviceHost, - servicePort: servicePort, - lanHost: lanHost, - tailnetDns: tailnetDns, - sshPort: sshPort, - gatewayPort: gatewayPort, - cliPath: "/tmp/openclaw", - stableID: UUID().uuidString, - debugID: UUID().uuidString, - isLocal: false) - } - - @Test func sshTargetUsesResolvedServiceHostOnly() { - let gateway = self.makeGateway( - serviceHost: "resolved.example.ts.net", - servicePort: 18789, - sshPort: 2201) - - guard let target = GatewayDiscoveryHelpers.sshTarget(for: gateway) else { - Issue.record("expected ssh target") - return - } - let parsed = CommandResolver.parseSSHTarget(target) - #expect(parsed?.host == "resolved.example.ts.net") - #expect(parsed?.port == 2201) - } - - @Test func sshTargetAllowsMissingResolvedServicePort() { - let gateway = self.makeGateway( - serviceHost: "resolved.example.ts.net", - servicePort: nil, - sshPort: 2201) - - guard let target = GatewayDiscoveryHelpers.sshTarget(for: gateway) else { - Issue.record("expected ssh target") - return - } - let parsed = CommandResolver.parseSSHTarget(target) - #expect(parsed?.host == "resolved.example.ts.net") - #expect(parsed?.port == 2201) - } - - @Test func sshTargetRejectsTxtOnlyGateways() { - let gateway = self.makeGateway( - serviceHost: nil, - servicePort: nil, - lanHost: "txt-only.local", - tailnetDns: "txt-only.ts.net", - sshPort: 2222) - - #expect(GatewayDiscoveryHelpers.sshTarget(for: gateway) == nil) - } - - @Test func directUrlUsesResolvedServiceEndpointOnly() { - let tlsGateway = self.makeGateway( - serviceHost: "resolved.example.ts.net", - servicePort: 443) - #expect(GatewayDiscoveryHelpers.directUrl(for: tlsGateway) == "wss://resolved.example.ts.net") - - let wsGateway = self.makeGateway( - serviceHost: "resolved.example.ts.net", - servicePort: 18789) - #expect(GatewayDiscoveryHelpers.directUrl(for: wsGateway) == "wss://resolved.example.ts.net:18789") - - let localGateway = self.makeGateway( - serviceHost: "127.0.0.1", - servicePort: 18789) - #expect(GatewayDiscoveryHelpers.directUrl(for: localGateway) == "ws://127.0.0.1:18789") - } - - @Test func directUrlRejectsTxtOnlyFallback() { - let gateway = self.makeGateway( - serviceHost: nil, - servicePort: nil, - lanHost: "txt-only.local", - tailnetDns: "txt-only.ts.net", - gatewayPort: 22222) - - #expect(GatewayDiscoveryHelpers.directUrl(for: gateway) == nil) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryModelTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryModelTests.swift deleted file mode 100644 index 02888c73870..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryModelTests.swift +++ /dev/null @@ -1,124 +0,0 @@ -import OpenClawDiscovery -import Testing - -@Suite -@MainActor -struct GatewayDiscoveryModelTests { - @Test func localGatewayMatchesLanHost() { - let local = GatewayDiscoveryModel.LocalIdentity( - hostTokens: ["studio"], - displayTokens: []) - #expect(GatewayDiscoveryModel.isLocalGateway( - lanHost: "studio.local", - tailnetDns: nil, - displayName: nil, - serviceName: nil, - local: local)) - } - - @Test func localGatewayMatchesTailnetDns() { - let local = GatewayDiscoveryModel.LocalIdentity( - hostTokens: ["studio"], - displayTokens: []) - #expect(GatewayDiscoveryModel.isLocalGateway( - lanHost: nil, - tailnetDns: "studio.tailnet.example", - displayName: nil, - serviceName: nil, - local: local)) - } - - @Test func localGatewayMatchesDisplayName() { - let local = GatewayDiscoveryModel.LocalIdentity( - hostTokens: [], - displayTokens: ["peter's mac studio"]) - #expect(GatewayDiscoveryModel.isLocalGateway( - lanHost: nil, - tailnetDns: nil, - displayName: "Peter's Mac Studio (OpenClaw)", - serviceName: nil, - local: local)) - } - - @Test func remoteGatewayDoesNotMatch() { - let local = GatewayDiscoveryModel.LocalIdentity( - hostTokens: ["studio"], - displayTokens: ["peter's mac studio"]) - #expect(!GatewayDiscoveryModel.isLocalGateway( - lanHost: "other.local", - tailnetDns: "other.tailnet.example", - displayName: "Other Mac", - serviceName: "other-gateway", - local: local)) - } - - @Test func localGatewayMatchesServiceName() { - let local = GatewayDiscoveryModel.LocalIdentity( - hostTokens: ["studio"], - displayTokens: []) - #expect(GatewayDiscoveryModel.isLocalGateway( - lanHost: nil, - tailnetDns: nil, - displayName: nil, - serviceName: "studio-gateway", - local: local)) - } - - @Test func serviceNameDoesNotFalsePositiveOnSubstringHostToken() { - let local = GatewayDiscoveryModel.LocalIdentity( - hostTokens: ["steipete"], - displayTokens: []) - #expect(!GatewayDiscoveryModel.isLocalGateway( - lanHost: nil, - tailnetDns: nil, - displayName: nil, - serviceName: "steipetacstudio (OpenClaw)", - local: local)) - #expect(GatewayDiscoveryModel.isLocalGateway( - lanHost: nil, - tailnetDns: nil, - displayName: nil, - serviceName: "steipete (OpenClaw)", - local: local)) - } - - @Test func parsesGatewayTXTFields() { - let parsed = GatewayDiscoveryModel.parseGatewayTXT([ - "lanHost": " studio.local ", - "tailnetDns": " peters-mac-studio-1.ts.net ", - "sshPort": " 2222 ", - "gatewayPort": " 18799 ", - "cliPath": " /opt/openclaw ", - ]) - #expect(parsed.lanHost == "studio.local") - #expect(parsed.tailnetDns == "peters-mac-studio-1.ts.net") - #expect(parsed.sshPort == 2222) - #expect(parsed.gatewayPort == 18799) - #expect(parsed.cliPath == "/opt/openclaw") - } - - @Test func parsesGatewayTXTDefaults() { - let parsed = GatewayDiscoveryModel.parseGatewayTXT([ - "lanHost": " ", - "tailnetDns": "\n", - "gatewayPort": "nope", - "sshPort": "nope", - ]) - #expect(parsed.lanHost == nil) - #expect(parsed.tailnetDns == nil) - #expect(parsed.sshPort == 22) - #expect(parsed.gatewayPort == nil) - #expect(parsed.cliPath == nil) - } - - @Test func buildsSSHTarget() { - #expect(GatewayDiscoveryModel.buildSSHTarget( - user: "peter", - host: "studio.local", - port: 22) == "peter@studio.local") - #expect(GatewayDiscoveryModel.buildSSHTarget( - user: "peter", - host: "studio.local", - port: 2201) == "peter@studio.local:2201") - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift deleted file mode 100644 index bb969aeaec9..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift +++ /dev/null @@ -1,236 +0,0 @@ -import Foundation -import Testing -@testable import OpenClaw - -@Suite struct GatewayEndpointStoreTests { - private func makeDefaults() -> UserDefaults { - let suiteName = "GatewayEndpointStoreTests.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! - defaults.removePersistentDomain(forName: suiteName) - return defaults - } - - @Test func resolveGatewayTokenPrefersEnvAndFallsBackToLaunchd() { - let snapshot = LaunchAgentPlistSnapshot( - programArguments: [], - environment: ["OPENCLAW_GATEWAY_TOKEN": "launchd-token"], - stdoutPath: nil, - stderrPath: nil, - port: nil, - bind: nil, - token: "launchd-token", - password: nil) - - let envToken = GatewayEndpointStore._testResolveGatewayToken( - isRemote: false, - root: [:], - env: ["OPENCLAW_GATEWAY_TOKEN": "env-token"], - launchdSnapshot: snapshot) - #expect(envToken == "env-token") - - let fallbackToken = GatewayEndpointStore._testResolveGatewayToken( - isRemote: false, - root: [:], - env: [:], - launchdSnapshot: snapshot) - #expect(fallbackToken == "launchd-token") - } - - @Test func resolveGatewayTokenIgnoresLaunchdInRemoteMode() { - let snapshot = LaunchAgentPlistSnapshot( - programArguments: [], - environment: ["OPENCLAW_GATEWAY_TOKEN": "launchd-token"], - stdoutPath: nil, - stderrPath: nil, - port: nil, - bind: nil, - token: "launchd-token", - password: nil) - - let token = GatewayEndpointStore._testResolveGatewayToken( - isRemote: true, - root: [:], - env: [:], - launchdSnapshot: snapshot) - #expect(token == nil) - } - - @Test func resolveGatewayPasswordFallsBackToLaunchd() { - let snapshot = LaunchAgentPlistSnapshot( - programArguments: [], - environment: ["OPENCLAW_GATEWAY_PASSWORD": "launchd-pass"], - stdoutPath: nil, - stderrPath: nil, - port: nil, - bind: nil, - token: nil, - password: "launchd-pass") - - let password = GatewayEndpointStore._testResolveGatewayPassword( - isRemote: false, - root: [:], - env: [:], - launchdSnapshot: snapshot) - #expect(password == "launchd-pass") - } - - @Test func connectionModeResolverPrefersConfigModeOverDefaults() { - let defaults = self.makeDefaults() - defaults.set("remote", forKey: connectionModeKey) - - let root: [String: Any] = [ - "gateway": [ - "mode": " local ", - ], - ] - - let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults) - #expect(resolved.mode == .local) - } - - @Test func connectionModeResolverTrimsConfigMode() { - let defaults = self.makeDefaults() - defaults.set("local", forKey: connectionModeKey) - - let root: [String: Any] = [ - "gateway": [ - "mode": " remote ", - ], - ] - - let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults) - #expect(resolved.mode == .remote) - } - - @Test func connectionModeResolverFallsBackToDefaultsWhenMissingConfig() { - let defaults = self.makeDefaults() - defaults.set("remote", forKey: connectionModeKey) - - let resolved = ConnectionModeResolver.resolve(root: [:], defaults: defaults) - #expect(resolved.mode == .remote) - } - - @Test func connectionModeResolverFallsBackToDefaultsOnUnknownConfig() { - let defaults = self.makeDefaults() - defaults.set("local", forKey: connectionModeKey) - - let root: [String: Any] = [ - "gateway": [ - "mode": "staging", - ], - ] - - let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults) - #expect(resolved.mode == .local) - } - - @Test func connectionModeResolverPrefersRemoteURLWhenModeMissing() { - let defaults = self.makeDefaults() - defaults.set("local", forKey: connectionModeKey) - - let root: [String: Any] = [ - "gateway": [ - "remote": [ - "url": " ws://umbrel:18789 ", - ], - ], - ] - - let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults) - #expect(resolved.mode == .remote) - } - - @Test func resolveLocalGatewayHostUsesLoopbackForAutoEvenWithTailnet() { - let host = GatewayEndpointStore._testResolveLocalGatewayHost( - bindMode: "auto", - tailscaleIP: "100.64.1.2") - #expect(host == "127.0.0.1") - } - - @Test func resolveLocalGatewayHostUsesLoopbackForAutoWithoutTailnet() { - let host = GatewayEndpointStore._testResolveLocalGatewayHost( - bindMode: "auto", - tailscaleIP: nil) - #expect(host == "127.0.0.1") - } - - @Test func resolveLocalGatewayHostPrefersTailnetForTailnetMode() { - let host = GatewayEndpointStore._testResolveLocalGatewayHost( - bindMode: "tailnet", - tailscaleIP: "100.64.1.5") - #expect(host == "100.64.1.5") - } - - @Test func resolveLocalGatewayHostFallsBackToLoopbackForTailnetMode() { - let host = GatewayEndpointStore._testResolveLocalGatewayHost( - bindMode: "tailnet", - tailscaleIP: nil) - #expect(host == "127.0.0.1") - } - - @Test func resolveLocalGatewayHostUsesCustomBindHost() { - let host = GatewayEndpointStore._testResolveLocalGatewayHost( - bindMode: "custom", - tailscaleIP: "100.64.1.9", - customBindHost: "192.168.1.10") - #expect(host == "192.168.1.10") - } - - @Test func dashboardURLUsesLocalBasePathInLocalMode() throws { - let config: GatewayConnection.Config = ( - url: try #require(URL(string: "ws://127.0.0.1:18789")), - token: nil, - password: nil - ) - - let url = try GatewayEndpointStore.dashboardURL( - for: config, - mode: .local, - localBasePath: " control ") - #expect(url.absoluteString == "http://127.0.0.1:18789/control/") - } - - @Test func dashboardURLSkipsLocalBasePathInRemoteMode() throws { - let config: GatewayConnection.Config = ( - url: try #require(URL(string: "ws://gateway.example:18789")), - token: nil, - password: nil - ) - - let url = try GatewayEndpointStore.dashboardURL( - for: config, - mode: .remote, - localBasePath: "/local-ui") - #expect(url.absoluteString == "http://gateway.example:18789/") - } - - @Test func dashboardURLPrefersPathFromConfigURL() throws { - let config: GatewayConnection.Config = ( - url: try #require(URL(string: "wss://gateway.example:443/remote-ui")), - token: nil, - password: nil - ) - - let url = try GatewayEndpointStore.dashboardURL( - for: config, - mode: .remote, - localBasePath: "/local-ui") - #expect(url.absoluteString == "https://gateway.example:443/remote-ui/") - } - - @Test func normalizeGatewayUrlAddsDefaultPortForLoopbackWs() { - let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://127.0.0.1") - #expect(url?.port == 18789) - #expect(url?.absoluteString == "ws://127.0.0.1:18789") - } - - @Test func normalizeGatewayUrlRejectsNonLoopbackWs() { - let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://gateway.example:18789") - #expect(url == nil) - } - - @Test func normalizeGatewayUrlRejectsPrefixBypassLoopbackHost() { - let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://127.attacker.example") - #expect(url == nil) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayEnvironmentTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayEnvironmentTests.swift deleted file mode 100644 index 32dcbb737f9..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayEnvironmentTests.swift +++ /dev/null @@ -1,57 +0,0 @@ -import Foundation -import Testing -@testable import OpenClaw - -@Suite struct GatewayEnvironmentTests { - @Test func semverParsesCommonForms() { - #expect(Semver.parse("1.2.3") == Semver(major: 1, minor: 2, patch: 3)) - #expect(Semver.parse(" v1.2.3 \n") == Semver(major: 1, minor: 2, patch: 3)) - #expect(Semver.parse("v2.0.0") == Semver(major: 2, minor: 0, patch: 0)) - #expect(Semver.parse("3.4.5-beta.1") == Semver(major: 3, minor: 4, patch: 5)) // prerelease suffix stripped - #expect(Semver.parse("2026.1.11-4") == Semver(major: 2026, minor: 1, patch: 11)) // build suffix stripped - #expect(Semver.parse("1.0.5+build.123") == Semver(major: 1, minor: 0, patch: 5)) // metadata suffix stripped - #expect(Semver.parse("v1.2.3+build.9") == Semver(major: 1, minor: 2, patch: 3)) - #expect(Semver.parse("1.2.3+build.123") == Semver(major: 1, minor: 2, patch: 3)) - #expect(Semver.parse("1.2.3-rc.1+build.7") == Semver(major: 1, minor: 2, patch: 3)) - #expect(Semver.parse("v1.2.3-rc.1") == Semver(major: 1, minor: 2, patch: 3)) - #expect(Semver.parse("1.2.0") == Semver(major: 1, minor: 2, patch: 0)) - #expect(Semver.parse(nil) == nil) - #expect(Semver.parse("invalid") == nil) - #expect(Semver.parse("1.2") == nil) - #expect(Semver.parse("1.2.x") == nil) - } - - @Test func semverCompatibilityRequiresSameMajorAndNotOlder() { - let required = Semver(major: 2, minor: 1, patch: 0) - #expect(Semver(major: 2, minor: 1, patch: 0).compatible(with: required)) - #expect(Semver(major: 2, minor: 2, patch: 0).compatible(with: required)) - #expect(Semver(major: 2, minor: 1, patch: 1).compatible(with: required)) - #expect(Semver(major: 2, minor: 0, patch: 9).compatible(with: required) == false) - #expect(Semver(major: 3, minor: 0, patch: 0).compatible(with: required) == false) - #expect(Semver(major: 1, minor: 9, patch: 9).compatible(with: required) == false) - } - - @Test func gatewayPortDefaultsAndRespectsOverride() async { - let configPath = TestIsolation.tempConfigPath() - await TestIsolation.withIsolatedState( - env: ["OPENCLAW_CONFIG_PATH": configPath], - defaults: ["gatewayPort": nil]) - { - let defaultPort = GatewayEnvironment.gatewayPort() - #expect(defaultPort == 18789) - - UserDefaults.standard.set(19999, forKey: "gatewayPort") - defer { UserDefaults.standard.removeObject(forKey: "gatewayPort") } - #expect(GatewayEnvironment.gatewayPort() == 19999) - } - } - - @Test func expectedGatewayVersionFromStringUsesParser() { - #expect(GatewayEnvironment.expectedGatewayVersion(from: "v9.1.2") == Semver(major: 9, minor: 1, patch: 2)) - #expect(GatewayEnvironment.expectedGatewayVersion(from: "2026.1.11-4") == Semver( - major: 2026, - minor: 1, - patch: 11)) - #expect(GatewayEnvironment.expectedGatewayVersion(from: nil) == nil) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayFrameDecodeTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayFrameDecodeTests.swift deleted file mode 100644 index bda8ff0e443..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayFrameDecodeTests.swift +++ /dev/null @@ -1,98 +0,0 @@ -import OpenClawProtocol -import Foundation -import Testing - -@Suite struct GatewayFrameDecodeTests { - @Test func decodesEventFrameWithAnyCodablePayload() throws { - let json = """ - { - "type": "event", - "event": "presence", - "payload": { "foo": "bar", "count": 1 }, - "seq": 7 - } - """ - - let frame = try JSONDecoder().decode(GatewayFrame.self, from: Data(json.utf8)) - - #expect({ - if case .event = frame { true } else { false } - }(), "expected .event frame") - - guard case let .event(evt) = frame else { - return - } - - let payload = evt.payload?.value as? [String: AnyCodable] - #expect(payload?["foo"]?.value as? String == "bar") - #expect(payload?["count"]?.value as? Int == 1) - #expect(evt.seq == 7) - } - - @Test func decodesRequestFrameWithNestedParams() throws { - let json = """ - { - "type": "req", - "id": "1", - "method": "agent.send", - "params": { - "text": "hi", - "items": [1, null, {"ok": true}], - "meta": { "count": 2 } - } - } - """ - - let frame = try JSONDecoder().decode(GatewayFrame.self, from: Data(json.utf8)) - - #expect({ - if case .req = frame { true } else { false } - }(), "expected .req frame") - - guard case let .req(req) = frame else { - return - } - - let params = req.params?.value as? [String: AnyCodable] - #expect(params?["text"]?.value as? String == "hi") - - let items = params?["items"]?.value as? [AnyCodable] - #expect(items?.count == 3) - #expect(items?[0].value as? Int == 1) - #expect(items?[1].value is NSNull) - - let item2 = items?[2].value as? [String: AnyCodable] - #expect(item2?["ok"]?.value as? Bool == true) - - let meta = params?["meta"]?.value as? [String: AnyCodable] - #expect(meta?["count"]?.value as? Int == 2) - } - - @Test func decodesUnknownFrameAndPreservesRaw() throws { - let json = """ - { - "type": "made-up", - "foo": "bar", - "count": 1, - "nested": { "ok": true } - } - """ - - let frame = try JSONDecoder().decode(GatewayFrame.self, from: Data(json.utf8)) - - #expect({ - if case .unknown = frame { true } else { false } - }(), "expected .unknown frame") - - guard case let .unknown(type, raw) = frame else { - return - } - - #expect(type == "made-up") - #expect(raw["type"]?.value as? String == "made-up") - #expect(raw["foo"]?.value as? String == "bar") - #expect(raw["count"]?.value as? Int == 1) - let nested = raw["nested"]?.value as? [String: AnyCodable] - #expect(nested?["ok"]?.value as? Bool == true) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayLaunchAgentManagerTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayLaunchAgentManagerTests.swift deleted file mode 100644 index 685db8185fc..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayLaunchAgentManagerTests.swift +++ /dev/null @@ -1,41 +0,0 @@ -import Foundation -import Testing -@testable import OpenClaw - -@Suite struct GatewayLaunchAgentManagerTests { - @Test func launchAgentPlistSnapshotParsesArgsAndEnv() throws { - let url = FileManager().temporaryDirectory - .appendingPathComponent("openclaw-launchd-\(UUID().uuidString).plist") - let plist: [String: Any] = [ - "ProgramArguments": ["openclaw", "gateway-daemon", "--port", "18789", "--bind", "loopback"], - "EnvironmentVariables": [ - "OPENCLAW_GATEWAY_TOKEN": " secret ", - "OPENCLAW_GATEWAY_PASSWORD": "pw", - ], - ] - let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) - try data.write(to: url, options: [.atomic]) - defer { try? FileManager().removeItem(at: url) } - - let snapshot = try #require(LaunchAgentPlist.snapshot(url: url)) - #expect(snapshot.port == 18789) - #expect(snapshot.bind == "loopback") - #expect(snapshot.token == "secret") - #expect(snapshot.password == "pw") - } - - @Test func launchAgentPlistSnapshotAllowsMissingBind() throws { - let url = FileManager().temporaryDirectory - .appendingPathComponent("openclaw-launchd-\(UUID().uuidString).plist") - let plist: [String: Any] = [ - "ProgramArguments": ["openclaw", "gateway-daemon", "--port", "18789"], - ] - let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) - try data.write(to: url, options: [.atomic]) - defer { try? FileManager().removeItem(at: url) } - - let snapshot = try #require(LaunchAgentPlist.snapshot(url: url)) - #expect(snapshot.port == 18789) - #expect(snapshot.bind == nil) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift deleted file mode 100644 index dabb15f8bf1..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift +++ /dev/null @@ -1,107 +0,0 @@ -import OpenClawKit -import Foundation -import os -import Testing -@testable import OpenClaw - -@Suite(.serialized) -@MainActor -struct GatewayProcessManagerTests { - private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable { - private let connectRequestID = OSAllocatedUnfairLock(initialState: nil) - private let pendingReceiveHandler = - OSAllocatedUnfairLock<(@Sendable (Result) - -> Void)?>(initialState: nil) - private let cancelCount = OSAllocatedUnfairLock(initialState: 0) - private let sendCount = OSAllocatedUnfairLock(initialState: 0) - - var state: URLSessionTask.State = .suspended - - func resume() { - self.state = .running - } - - func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { - _ = (closeCode, reason) - self.state = .canceling - self.cancelCount.withLock { $0 += 1 } - let handler = self.pendingReceiveHandler.withLock { handler in - defer { handler = nil } - return handler - } - handler?(Result.failure(URLError(.cancelled))) - } - - func send(_ message: URLSessionWebSocketTask.Message) async throws { - let currentSendCount = self.sendCount.withLock { count in - defer { count += 1 } - return count - } - - if currentSendCount == 0 { - if let id = GatewayWebSocketTestSupport.connectRequestID(from: message) { - self.connectRequestID.withLock { $0 = id } - } - return - } - - guard case let .data(data) = message else { return } - guard - let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - (obj["type"] as? String) == "req", - let id = obj["id"] as? String - else { - return - } - - let response = GatewayWebSocketTestSupport.okResponseData(id: id) - let handler = self.pendingReceiveHandler.withLock { $0 } - handler?(Result.success(.data(response))) - } - - func receive() async throws -> URLSessionWebSocketTask.Message { - let id = self.connectRequestID.withLock { $0 } ?? "connect" - return .data(GatewayWebSocketTestSupport.connectOkData(id: id)) - } - - func receive( - completionHandler: @escaping @Sendable (Result) -> Void) - { - self.pendingReceiveHandler.withLock { $0 = completionHandler } - } - - } - - private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable { - private let tasks = OSAllocatedUnfairLock(initialState: [FakeWebSocketTask]()) - - func makeWebSocketTask(url: URL) -> WebSocketTaskBox { - _ = url - let task = FakeWebSocketTask() - self.tasks.withLock { $0.append(task) } - return WebSocketTaskBox(task: task) - } - } - - @Test func clearsLastFailureWhenHealthSucceeds() async { - let session = FakeWebSocketSession() - let url = URL(string: "ws://example.invalid")! - let connection = GatewayConnection( - configProvider: { (url: url, token: nil, password: nil) }, - sessionBox: WebSocketSessionBox(session: session)) - - let manager = GatewayProcessManager.shared - manager.setTestingConnection(connection) - manager.setTestingDesiredActive(true) - manager.setTestingLastFailureReason("health failed") - defer { - manager.setTestingConnection(nil) - manager.setTestingDesiredActive(false) - manager.setTestingLastFailureReason(nil) - } - - let ready = await manager.waitForGatewayReady(timeout: 0.5) - #expect(ready) - #expect(manager.lastFailureReason == nil) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift deleted file mode 100644 index 0ba41f2806b..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift +++ /dev/null @@ -1,63 +0,0 @@ -import OpenClawKit -import Foundation - -extension WebSocketTasking { - // Keep unit-test doubles resilient to protocol additions. - func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) { - pongReceiveHandler(nil) - } -} - -enum GatewayWebSocketTestSupport { - static func connectRequestID(from message: URLSessionWebSocketTask.Message) -> String? { - let data: Data? = switch message { - case let .data(d): d - case let .string(s): s.data(using: .utf8) - @unknown default: nil - } - guard let data else { return nil } - guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return nil - } - guard (obj["type"] as? String) == "req", (obj["method"] as? String) == "connect" else { - return nil - } - return obj["id"] as? String - } - - static func connectOkData(id: String) -> Data { - let json = """ - { - "type": "res", - "id": "\(id)", - "ok": true, - "payload": { - "type": "hello-ok", - "protocol": 2, - "server": { "version": "test", "connId": "test" }, - "features": { "methods": [], "events": [] }, - "snapshot": { - "presence": [ { "ts": 1 } ], - "health": {}, - "stateVersion": { "presence": 0, "health": 0 }, - "uptimeMs": 0 - }, - "policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 } - } - } - """ - return Data(json.utf8) - } - - static func okResponseData(id: String) -> Data { - let json = """ - { - "type": "res", - "id": "\(id)", - "ok": true, - "payload": { "ok": true } - } - """ - return Data(json.utf8) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/HealthDecodeTests.swift b/apps/macos/Tests/OpenClawIPCTests/HealthDecodeTests.swift deleted file mode 100644 index f6b65b154d1..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/HealthDecodeTests.swift +++ /dev/null @@ -1,32 +0,0 @@ -import Foundation -import Testing -@testable import OpenClaw - -@Suite struct HealthDecodeTests { - private let sampleJSON: String = // minimal but complete payload - """ - {"ts":1733622000,"durationMs":420,"channels":{"whatsapp":{"linked":true,"authAgeMs":120000},"telegram":{"configured":true,"probe":{"ok":true,"elapsedMs":800}}},"channelOrder":["whatsapp","telegram"],"heartbeatSeconds":60,"sessions":{"path":"/tmp/sessions.json","count":1,"recent":[{"key":"abc","updatedAt":1733621900,"age":120000}]}} - """ - - @Test func decodesCleanJSON() async throws { - let data = Data(sampleJSON.utf8) - let snap = decodeHealthSnapshot(from: data) - - #expect(snap?.channels["whatsapp"]?.linked == true) - #expect(snap?.sessions.count == 1) - } - - @Test func decodesWithLeadingNoise() async throws { - let noisy = "debug: something logged\n" + self.sampleJSON + "\ntrailer" - let snap = decodeHealthSnapshot(from: Data(noisy.utf8)) - - #expect(snap?.channels["telegram"]?.probe?.elapsedMs == 800) - } - - @Test func failsWithoutBraces() async throws { - let data = Data("no json here".utf8) - let snap = decodeHealthSnapshot(from: data) - - #expect(snap == nil) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/HealthStoreStateTests.swift b/apps/macos/Tests/OpenClawIPCTests/HealthStoreStateTests.swift deleted file mode 100644 index ca2601cf6fb..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/HealthStoreStateTests.swift +++ /dev/null @@ -1,42 +0,0 @@ -import Foundation -import Testing -@testable import OpenClaw - -@Suite struct HealthStoreStateTests { - @Test @MainActor func linkedChannelProbeFailureDegradesState() async throws { - let snap = HealthSnapshot( - ok: true, - ts: 0, - durationMs: 1, - channels: [ - "whatsapp": .init( - configured: true, - linked: true, - authAgeMs: 1, - probe: .init( - ok: false, - status: 503, - error: "gateway connect failed", - elapsedMs: 12, - bot: nil, - webhook: nil), - lastProbeAt: 0), - ], - channelOrder: ["whatsapp"], - channelLabels: ["whatsapp": "WhatsApp"], - heartbeatSeconds: 60, - sessions: .init(path: "/tmp/sessions.json", count: 0, recent: [])) - - let store = HealthStore.shared - store.__setSnapshotForTest(snap, lastError: nil) - - switch store.state { - case let .degraded(message): - #expect(!message.isEmpty) - default: - Issue.record("Expected degraded state when probe fails for linked channel") - } - - #expect(store.summaryLine.contains("probe degraded")) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/HoverHUDControllerTests.swift b/apps/macos/Tests/OpenClawIPCTests/HoverHUDControllerTests.swift deleted file mode 100644 index eff3ee6d814..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/HoverHUDControllerTests.swift +++ /dev/null @@ -1,26 +0,0 @@ -import AppKit -import Testing -@testable import OpenClaw - -@Suite(.serialized) -@MainActor -struct HoverHUDControllerTests { - @Test func hoverHUDControllerPresentsAndDismisses() async { - let controller = HoverHUDController() - controller.setSuppressed(false) - - controller.statusItemHoverChanged( - inside: true, - anchorProvider: { NSRect(x: 10, y: 10, width: 24, height: 24) }) - try? await Task.sleep(nanoseconds: 260_000_000) - - controller.panelHoverChanged(inside: true) - controller.panelHoverChanged(inside: false) - controller.statusItemHoverChanged( - inside: false, - anchorProvider: { NSRect(x: 10, y: 10, width: 24, height: 24) }) - - controller.dismiss(reason: "test") - controller.setSuppressed(true) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/InstancesSettingsSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/InstancesSettingsSmokeTests.swift deleted file mode 100644 index c43982ee82b..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/InstancesSettingsSmokeTests.swift +++ /dev/null @@ -1,59 +0,0 @@ -import Testing -@testable import OpenClaw - -@Suite(.serialized) -@MainActor -struct InstancesSettingsSmokeTests { - @Test func instancesSettingsBuildsBodyWithMultipleInstances() { - let store = InstancesStore(isPreview: true) - store.statusMessage = "Loaded" - store.instances = [ - InstanceInfo( - id: "macbook", - host: "macbook-pro", - ip: "10.0.0.2", - version: "1.2.3", - platform: "macOS 15.1", - deviceFamily: "Mac", - modelIdentifier: "MacBookPro18,1", - lastInputSeconds: 15, - mode: "local", - reason: "heartbeat", - text: "MacBook Pro local", - ts: 1_700_000_000_000), - InstanceInfo( - id: "android", - host: "pixel", - ip: "10.0.0.3", - version: "2.0.0", - platform: "Android 14", - deviceFamily: "Android", - modelIdentifier: nil, - lastInputSeconds: 120, - mode: "node", - reason: "presence", - text: "Android node", - ts: 1_700_000_100_000), - InstanceInfo( - id: "gateway", - host: "gateway", - ip: "10.0.0.4", - version: "3.0.0", - platform: "iOS 18", - deviceFamily: nil, - modelIdentifier: nil, - lastInputSeconds: nil, - mode: "gateway", - reason: "gateway", - text: "Gateway", - ts: 1_700_000_200_000), - ] - - let view = InstancesSettings(store: store) - _ = view.body - } - - @Test func instancesSettingsExercisesHelpers() { - InstancesSettings.exerciseForTesting() - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/InstancesStoreTests.swift b/apps/macos/Tests/OpenClawIPCTests/InstancesStoreTests.swift deleted file mode 100644 index f148c35fb21..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/InstancesStoreTests.swift +++ /dev/null @@ -1,36 +0,0 @@ -import OpenClawProtocol -import Testing -@testable import OpenClaw - -@Suite struct InstancesStoreTests { - @Test - @MainActor - func presenceEventPayloadDecodesViaJSONEncoder() { - // Build a payload that mirrors the gateway's presence event shape: - // { "presence": [ PresenceEntry ] } - let entry: [String: OpenClawProtocol.AnyCodable] = [ - "host": .init("gw"), - "ip": .init("10.0.0.1"), - "version": .init("2.0.0"), - "mode": .init("gateway"), - "lastInputSeconds": .init(5), - "reason": .init("test"), - "text": .init("Gateway node"), - "ts": .init(1_730_000_000), - ] - let payloadMap: [String: OpenClawProtocol.AnyCodable] = [ - "presence": .init([OpenClawProtocol.AnyCodable(entry)]), - ] - let payload = OpenClawProtocol.AnyCodable(payloadMap) - - let store = InstancesStore(isPreview: true) - store.handlePresenceEventPayload(payload) - - #expect(store.instances.count == 1) - let instance = store.instances.first - #expect(instance?.host == "gw") - #expect(instance?.ip == "10.0.0.1") - #expect(instance?.mode == "gateway") - #expect(instance?.reason == "test") - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/LogLocatorTests.swift b/apps/macos/Tests/OpenClawIPCTests/LogLocatorTests.swift deleted file mode 100644 index 6f7fc5dc016..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/LogLocatorTests.swift +++ /dev/null @@ -1,24 +0,0 @@ -import Darwin -import Foundation -import Testing -@testable import OpenClaw - -@Suite struct LogLocatorTests { - @Test func launchdGatewayLogPathEnsuresTmpDirExists() throws { - let fm = FileManager() - let baseDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - let logDir = baseDir.appendingPathComponent("openclaw-tests-\(UUID().uuidString)") - - setenv("OPENCLAW_LOG_DIR", logDir.path, 1) - defer { - unsetenv("OPENCLAW_LOG_DIR") - try? fm.removeItem(at: logDir) - } - - _ = LogLocator.launchdGatewayLogPath - - var isDir: ObjCBool = false - #expect(fm.fileExists(atPath: logDir.path, isDirectory: &isDir)) - #expect(isDir.boolValue == true) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/LowCoverageHelperTests.swift b/apps/macos/Tests/OpenClawIPCTests/LowCoverageHelperTests.swift deleted file mode 100644 index 174dc1d134c..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/LowCoverageHelperTests.swift +++ /dev/null @@ -1,215 +0,0 @@ -import AppKit -import OpenClawProtocol -import Foundation -import Testing - -@testable import OpenClaw - -@Suite(.serialized) -struct LowCoverageHelperTests { - private typealias ProtoAnyCodable = OpenClawProtocol.AnyCodable - - @Test func anyCodableHelperAccessors() throws { - let payload: [String: ProtoAnyCodable] = [ - "title": ProtoAnyCodable("Hello"), - "flag": ProtoAnyCodable(true), - "count": ProtoAnyCodable(3), - "ratio": ProtoAnyCodable(1.25), - "list": ProtoAnyCodable([ProtoAnyCodable("a"), ProtoAnyCodable(2)]), - ] - let any = ProtoAnyCodable(payload) - let dict = try #require(any.dictionaryValue) - #expect(dict["title"]?.stringValue == "Hello") - #expect(dict["flag"]?.boolValue == true) - #expect(dict["count"]?.intValue == 3) - #expect(dict["ratio"]?.doubleValue == 1.25) - #expect(dict["list"]?.arrayValue?.count == 2) - - let foundation = any.foundationValue as? [String: Any] - #expect((foundation?["title"] as? String) == "Hello") - } - - @Test func attributedStringStripsForegroundColor() { - let text = NSMutableAttributedString(string: "Test") - text.addAttribute(.foregroundColor, value: NSColor.red, range: NSRange(location: 0, length: 4)) - let stripped = text.strippingForegroundColor() - let color = stripped.attribute(.foregroundColor, at: 0, effectiveRange: nil) - #expect(color == nil) - } - - @Test func viewMetricsReduceWidth() { - let value = ViewMetricsTesting.reduceWidth(current: 120, next: 180) - #expect(value == 180) - } - - @Test func shellExecutorHandlesEmptyCommand() async { - let result = await ShellExecutor.runDetailed(command: [], cwd: nil, env: nil, timeout: nil) - #expect(result.success == false) - #expect(result.errorMessage != nil) - } - - @Test func shellExecutorRunsCommand() async { - let result = await ShellExecutor.runDetailed(command: ["/bin/echo", "ok"], cwd: nil, env: nil, timeout: 2) - #expect(result.success == true) - #expect(result.stdout.contains("ok") || result.stderr.contains("ok")) - } - - @Test func shellExecutorTimesOut() async { - let result = await ShellExecutor.runDetailed(command: ["/bin/sleep", "1"], cwd: nil, env: nil, timeout: 0.05) - #expect(result.timedOut == true) - } - - @Test func shellExecutorDrainsStdoutAndStderr() async { - let script = """ - i=0 - while [ $i -lt 2000 ]; do - echo "stdout-$i" - echo "stderr-$i" 1>&2 - i=$((i+1)) - done - """ - let result = await ShellExecutor.runDetailed( - command: ["/bin/sh", "-c", script], - cwd: nil, - env: nil, - timeout: 2) - #expect(result.success == true) - #expect(result.stdout.contains("stdout-1999")) - #expect(result.stderr.contains("stderr-1999")) - } - - @Test func nodeInfoCodableRoundTrip() throws { - let info = NodeInfo( - nodeId: "node-1", - displayName: "Node One", - platform: "macOS", - version: "1.0", - coreVersion: "1.0-core", - uiVersion: "1.0-ui", - deviceFamily: "Mac", - modelIdentifier: "MacBookPro", - remoteIp: "192.168.1.2", - caps: ["chat"], - commands: ["send"], - permissions: ["send": true], - paired: true, - connected: false) - let data = try JSONEncoder().encode(info) - let decoded = try JSONDecoder().decode(NodeInfo.self, from: data) - #expect(decoded.nodeId == "node-1") - #expect(decoded.isPaired == true) - #expect(decoded.isConnected == false) - } - - @Test @MainActor func presenceReporterHelpers() { - let summary = PresenceReporter._testComposePresenceSummary(mode: "local", reason: "test") - #expect(summary.contains("mode local")) - #expect(!PresenceReporter._testAppVersionString().isEmpty) - #expect(!PresenceReporter._testPlatformString().isEmpty) - _ = PresenceReporter._testLastInputSeconds() - _ = PresenceReporter._testPrimaryIPv4Address() - } - - @Test func portGuardianParsesListenersAndBuildsReports() { - let output = """ - p123 - cnode - uuser - p456 - cssh - uroot - """ - let listeners = PortGuardian._testParseListeners(output) - #expect(listeners.count == 2) - #expect(listeners[0].command == "node") - #expect(listeners[1].command == "ssh") - - let okReport = PortGuardian._testBuildReport( - port: 18789, - mode: .local, - listeners: [(pid: 1, command: "node", fullCommand: "node", user: "me")]) - #expect(okReport.offenders.isEmpty) - - let badReport = PortGuardian._testBuildReport( - port: 18789, - mode: .local, - listeners: [(pid: 2, command: "python", fullCommand: "python", user: "me")]) - #expect(!badReport.offenders.isEmpty) - - let emptyReport = PortGuardian._testBuildReport(port: 18789, mode: .local, listeners: []) - #expect(emptyReport.summary.contains("Nothing is listening")) - } - - @Test @MainActor func canvasSchemeHandlerResolvesFilesAndErrors() throws { - let root = FileManager().temporaryDirectory - .appendingPathComponent("canvas-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager().removeItem(at: root) } - try FileManager().createDirectory(at: root, withIntermediateDirectories: true) - let session = root.appendingPathComponent("main", isDirectory: true) - try FileManager().createDirectory(at: session, withIntermediateDirectories: true) - - let index = session.appendingPathComponent("index.html") - try "

Hello

".write(to: index, atomically: true, encoding: .utf8) - - let handler = CanvasSchemeHandler(root: root) - let url = try #require(CanvasScheme.makeURL(session: "main", path: "index.html")) - let response = handler._testResponse(for: url) - #expect(response.mime == "text/html") - #expect(String(data: response.data, encoding: .utf8)?.contains("Hello") == true) - - let invalid = URL(string: "https://example.com")! - let invalidResponse = handler._testResponse(for: invalid) - #expect(invalidResponse.mime == "text/html") - - let missing = try #require(CanvasScheme.makeURL(session: "missing", path: "/")) - let missingResponse = handler._testResponse(for: missing) - #expect(missingResponse.mime == "text/html") - - #expect(handler._testTextEncodingName(for: "text/html") == "utf-8") - #expect(handler._testTextEncodingName(for: "application/octet-stream") == nil) - } - - @Test @MainActor func menuContextCardInjectorInsertsAndFindsIndex() { - let injector = MenuContextCardInjector() - let menu = NSMenu() - menu.minimumWidth = 280 - menu.addItem(NSMenuItem(title: "Active", action: nil, keyEquivalent: "")) - menu.addItem(.separator()) - menu.addItem(NSMenuItem(title: "Send Heartbeats", action: nil, keyEquivalent: "")) - menu.addItem(NSMenuItem(title: "Quit", action: nil, keyEquivalent: "q")) - - let idx = injector._testFindInsertIndex(in: menu) - #expect(idx == 1) - #expect(injector._testInitialCardWidth(for: menu) >= 300) - - injector._testSetCache(rows: [SessionRow.previewRows[0]], errorText: nil, updatedAt: Date()) - injector.menuWillOpen(menu) - injector.menuDidClose(menu) - - let fallbackMenu = NSMenu() - fallbackMenu.addItem(NSMenuItem(title: "First", action: nil, keyEquivalent: "")) - #expect(injector._testFindInsertIndex(in: fallbackMenu) == 1) - } - - @Test @MainActor func canvasWindowHelperFunctions() { - #expect(CanvasWindowController._testSanitizeSessionKey(" main ") == "main") - #expect(CanvasWindowController._testSanitizeSessionKey("bad/..") == "bad___") - #expect(CanvasWindowController._testJSOptionalStringLiteral(nil) == "null") - - let rect = NSRect(x: 10, y: 12, width: 400, height: 420) - let key = CanvasWindowController._testStoredFrameKey(sessionKey: "test") - let loaded = CanvasWindowController._testStoreAndLoadFrame(sessionKey: "test", frame: rect) - UserDefaults.standard.removeObject(forKey: key) - #expect(loaded?.size.width == rect.size.width) - - let parsed = CanvasWindowController._testParseIPv4("192.168.1.2") - #expect(parsed != nil) - if let parsed { - #expect(CanvasWindowController._testIsLocalNetworkIPv4(parsed)) - } - - let url = URL(string: "http://192.168.1.2")! - #expect(CanvasWindowController._testIsLocalNetworkCanvasURL(url)) - #expect(CanvasWindowController._testParseIPv4("not-an-ip") == nil) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/LowCoverageViewSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/LowCoverageViewSmokeTests.swift deleted file mode 100644 index aea7f61679b..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/LowCoverageViewSmokeTests.swift +++ /dev/null @@ -1,99 +0,0 @@ -import AppKit -import OpenClawProtocol -import SwiftUI -import Testing - -@testable import OpenClaw - -@Suite(.serialized) -@MainActor -struct LowCoverageViewSmokeTests { - @Test func contextMenuCardBuildsBody() { - let loading = ContextMenuCardView(rows: [], statusText: "Loading…", isLoading: true) - _ = loading.body - - let empty = ContextMenuCardView(rows: [], statusText: nil, isLoading: false) - _ = empty.body - - let withRows = ContextMenuCardView(rows: SessionRow.previewRows, statusText: nil, isLoading: false) - _ = withRows.body - } - - @Test func settingsToggleRowBuildsBody() { - var flag = false - let binding = Binding(get: { flag }, set: { flag = $0 }) - let view = SettingsToggleRow(title: "Enable", subtitle: "Detail", binding: binding) - _ = view.body - } - - @Test func voiceWakeTestCardBuildsBodyAcrossStates() { - var state = VoiceWakeTestState.idle - var isTesting = false - let stateBinding = Binding(get: { state }, set: { state = $0 }) - let testingBinding = Binding(get: { isTesting }, set: { isTesting = $0 }) - - _ = VoiceWakeTestCard(testState: stateBinding, isTesting: testingBinding, onToggle: {}).body - - state = .hearing("hello") - _ = VoiceWakeTestCard(testState: stateBinding, isTesting: testingBinding, onToggle: {}).body - - state = .detected("command") - isTesting = true - _ = VoiceWakeTestCard(testState: stateBinding, isTesting: testingBinding, onToggle: {}).body - - state = .failed("No mic") - _ = VoiceWakeTestCard(testState: stateBinding, isTesting: testingBinding, onToggle: {}).body - } - - @Test func agentEventsWindowBuildsBodyWithEvent() { - AgentEventStore.shared.clear() - let sample = ControlAgentEvent( - runId: "run-1", - seq: 1, - stream: "tool", - ts: Date().timeIntervalSince1970 * 1000, - data: ["phase": AnyCodable("start"), "name": AnyCodable("test")], - summary: nil) - AgentEventStore.shared.append(sample) - _ = AgentEventsWindow().body - AgentEventStore.shared.clear() - } - - @Test func notifyOverlayPresentsAndDismisses() async { - let controller = NotifyOverlayController() - controller.present(title: "Hello", body: "World", autoDismissAfter: 0) - controller.present(title: "Updated", body: "Again", autoDismissAfter: 0) - controller.dismiss() - try? await Task.sleep(nanoseconds: 250_000_000) - } - - @Test func visualEffectViewHostsInNSHostingView() { - let hosting = NSHostingView(rootView: VisualEffectView(material: .sidebar)) - _ = hosting.fittingSize - hosting.rootView = VisualEffectView(material: .popover, emphasized: true) - _ = hosting.fittingSize - } - - @Test func menuHostedItemHostsContent() { - let view = MenuHostedItem(width: 240, rootView: AnyView(Text("Menu"))) - let hosting = NSHostingView(rootView: view) - _ = hosting.fittingSize - hosting.rootView = MenuHostedItem(width: 320, rootView: AnyView(Text("Updated"))) - _ = hosting.fittingSize - } - - @Test func dockIconManagerUpdatesVisibility() { - _ = NSApplication.shared - UserDefaults.standard.set(false, forKey: showDockIconKey) - DockIconManager.shared.updateDockVisibility() - DockIconManager.shared.temporarilyShowDock() - } - - @Test func voiceWakeSettingsExercisesHelpers() { - VoiceWakeSettings.exerciseForTesting() - } - - @Test func debugSettingsExercisesHelpers() async { - await DebugSettings.exerciseForTesting() - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift deleted file mode 100644 index 2d26b7c0538..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift +++ /dev/null @@ -1,101 +0,0 @@ -import OpenClawChatUI -import OpenClawProtocol -import Testing -@testable import OpenClaw - -@Suite struct MacGatewayChatTransportMappingTests { - @Test func snapshotMapsToHealth() { - let snapshot = Snapshot( - presence: [], - health: OpenClawProtocol.AnyCodable(["ok": OpenClawProtocol.AnyCodable(false)]), - stateversion: StateVersion(presence: 1, health: 1), - uptimems: 123, - configpath: nil, - statedir: nil, - sessiondefaults: nil, - authmode: nil, - updateavailable: nil) - - let hello = HelloOk( - type: "hello", - _protocol: 2, - server: [:], - features: [:], - snapshot: snapshot, - canvashosturl: nil, - auth: nil, - policy: [:]) - - let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.snapshot(hello)) - switch mapped { - case let .health(ok): - #expect(ok == false) - default: - Issue.record("expected .health from snapshot, got \(String(describing: mapped))") - } - } - - @Test func healthEventMapsToHealth() { - let frame = EventFrame( - type: "event", - event: "health", - payload: OpenClawProtocol.AnyCodable(["ok": OpenClawProtocol.AnyCodable(true)]), - seq: 1, - stateversion: nil) - - let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.event(frame)) - switch mapped { - case let .health(ok): - #expect(ok == true) - default: - Issue.record("expected .health from health event, got \(String(describing: mapped))") - } - } - - @Test func tickEventMapsToTick() { - let frame = EventFrame(type: "event", event: "tick", payload: nil, seq: 1, stateversion: nil) - let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.event(frame)) - #expect({ - if case .tick = mapped { return true } - return false - }()) - } - - @Test func chatEventMapsToChat() { - let payload = OpenClawProtocol.AnyCodable([ - "runId": OpenClawProtocol.AnyCodable("run-1"), - "sessionKey": OpenClawProtocol.AnyCodable("main"), - "state": OpenClawProtocol.AnyCodable("final"), - ]) - let frame = EventFrame(type: "event", event: "chat", payload: payload, seq: 1, stateversion: nil) - let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.event(frame)) - - switch mapped { - case let .chat(chat): - #expect(chat.runId == "run-1") - #expect(chat.sessionKey == "main") - #expect(chat.state == "final") - default: - Issue.record("expected .chat from chat event, got \(String(describing: mapped))") - } - } - - @Test func unknownEventMapsToNil() { - let frame = EventFrame( - type: "event", - event: "unknown", - payload: OpenClawProtocol.AnyCodable(["a": OpenClawProtocol.AnyCodable(1)]), - seq: 1, - stateversion: nil) - let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.event(frame)) - #expect(mapped == nil) - } - - @Test func seqGapMapsToSeqGap() { - let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.seqGap(expected: 1, received: 9)) - #expect({ - if case .seqGap = mapped { return true } - return false - }()) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/MacNodeRuntimeTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacNodeRuntimeTests.swift deleted file mode 100644 index 866256241a2..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/MacNodeRuntimeTests.swift +++ /dev/null @@ -1,97 +0,0 @@ -import OpenClawKit -import CoreLocation -import Foundation -import Testing -@testable import OpenClaw - -struct MacNodeRuntimeTests { - @Test func handleInvokeRejectsUnknownCommand() async { - let runtime = MacNodeRuntime() - let response = await runtime.handleInvoke( - BridgeInvokeRequest(id: "req-1", command: "unknown.command")) - #expect(response.ok == false) - } - - @Test func handleInvokeRejectsEmptySystemRun() async throws { - let runtime = MacNodeRuntime() - let params = OpenClawSystemRunParams(command: []) - let json = try String(data: JSONEncoder().encode(params), encoding: .utf8) - let response = await runtime.handleInvoke( - BridgeInvokeRequest(id: "req-2", command: OpenClawSystemCommand.run.rawValue, paramsJSON: json)) - #expect(response.ok == false) - } - - @Test func handleInvokeRejectsEmptySystemWhich() async throws { - let runtime = MacNodeRuntime() - let params = OpenClawSystemWhichParams(bins: []) - let json = try String(data: JSONEncoder().encode(params), encoding: .utf8) - let response = await runtime.handleInvoke( - BridgeInvokeRequest(id: "req-2b", command: OpenClawSystemCommand.which.rawValue, paramsJSON: json)) - #expect(response.ok == false) - } - - @Test func handleInvokeRejectsEmptyNotification() async throws { - let runtime = MacNodeRuntime() - let params = OpenClawSystemNotifyParams(title: "", body: "") - let json = try String(data: JSONEncoder().encode(params), encoding: .utf8) - let response = await runtime.handleInvoke( - BridgeInvokeRequest(id: "req-3", command: OpenClawSystemCommand.notify.rawValue, paramsJSON: json)) - #expect(response.ok == false) - } - - @Test func handleInvokeCameraListRequiresEnabledCamera() async { - await TestIsolation.withUserDefaultsValues([cameraEnabledKey: false]) { - let runtime = MacNodeRuntime() - let response = await runtime.handleInvoke( - BridgeInvokeRequest(id: "req-4", command: OpenClawCameraCommand.list.rawValue)) - #expect(response.ok == false) - #expect(response.error?.message.contains("CAMERA_DISABLED") == true) - } - } - - @Test func handleInvokeScreenRecordUsesInjectedServices() async throws { - @MainActor - final class FakeMainActorServices: MacNodeRuntimeMainActorServices, @unchecked Sendable { - func recordScreen( - screenIndex: Int?, - durationMs: Int?, - fps: Double?, - includeAudio: Bool?, - outPath: String?) async throws -> (path: String, hasAudio: Bool) - { - let url = FileManager().temporaryDirectory - .appendingPathComponent("openclaw-test-screen-record-\(UUID().uuidString).mp4") - try Data("ok".utf8).write(to: url) - return (path: url.path, hasAudio: false) - } - - func locationAuthorizationStatus() -> CLAuthorizationStatus { .authorizedAlways } - func locationAccuracyAuthorization() -> CLAccuracyAuthorization { .fullAccuracy } - func currentLocation( - desiredAccuracy: OpenClawLocationAccuracy, - maxAgeMs: Int?, - timeoutMs: Int?) async throws -> CLLocation - { - CLLocation(latitude: 0, longitude: 0) - } - } - - let services = await MainActor.run { FakeMainActorServices() } - let runtime = MacNodeRuntime(makeMainActorServices: { services }) - - let params = MacNodeScreenRecordParams(durationMs: 250) - let json = try String(data: JSONEncoder().encode(params), encoding: .utf8) - let response = await runtime.handleInvoke( - BridgeInvokeRequest(id: "req-5", command: MacNodeScreenCommand.record.rawValue, paramsJSON: json)) - #expect(response.ok == true) - let payloadJSON = try #require(response.payloadJSON) - - struct Payload: Decodable { - var format: String - var base64: String - } - let payload = try JSONDecoder().decode(Payload.self, from: Data(payloadJSON.utf8)) - #expect(payload.format == "mp4") - #expect(!payload.base64.isEmpty) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/MasterDiscoveryMenuSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/MasterDiscoveryMenuSmokeTests.swift deleted file mode 100644 index c6d58cc3a86..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/MasterDiscoveryMenuSmokeTests.swift +++ /dev/null @@ -1,78 +0,0 @@ -import OpenClawDiscovery -import SwiftUI -import Testing -@testable import OpenClaw - -@Suite(.serialized) -@MainActor -struct MasterDiscoveryMenuSmokeTests { - @Test func inlineListBuildsBodyWhenEmpty() { - let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName) - discovery.statusText = "Searching…" - discovery.gateways = [] - - let view = GatewayDiscoveryInlineList( - discovery: discovery, - currentTarget: nil, - currentUrl: nil, - transport: .ssh, - onSelect: { _ in }) - _ = view.body - } - - @Test func inlineListBuildsBodyWithMasterAndSelection() { - let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName) - discovery.statusText = "Found 1" - discovery.gateways = [ - GatewayDiscoveryModel.DiscoveredGateway( - displayName: "Office Mac", - lanHost: "office.local", - tailnetDns: "office.tailnet-123.ts.net", - sshPort: 2222, - gatewayPort: nil, - cliPath: nil, - stableID: "office", - debugID: "office", - isLocal: false), - ] - - let currentTarget = "\(NSUserName())@office.tailnet-123.ts.net:2222" - let view = GatewayDiscoveryInlineList( - discovery: discovery, - currentTarget: currentTarget, - currentUrl: nil, - transport: .ssh, - onSelect: { _ in }) - _ = view.body - } - - @Test func menuBuildsBodyWithMasters() { - let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName) - discovery.statusText = "Found 2" - discovery.gateways = [ - GatewayDiscoveryModel.DiscoveredGateway( - displayName: "A", - lanHost: "a.local", - tailnetDns: nil, - sshPort: 22, - gatewayPort: nil, - cliPath: nil, - stableID: "a", - debugID: "a", - isLocal: false), - GatewayDiscoveryModel.DiscoveredGateway( - displayName: "B", - lanHost: nil, - tailnetDns: "b.ts.net", - sshPort: 22, - gatewayPort: nil, - cliPath: nil, - stableID: "b", - debugID: "b", - isLocal: false), - ] - - let view = GatewayDiscoveryMenu(discovery: discovery, onSelect: { _ in }) - _ = view.body - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/MenuContentSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/MenuContentSmokeTests.swift deleted file mode 100644 index a57782148e4..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/MenuContentSmokeTests.swift +++ /dev/null @@ -1,41 +0,0 @@ -import SwiftUI -import Testing -@testable import OpenClaw - -@Suite(.serialized) -@MainActor -struct MenuContentSmokeTests { - @Test func menuContentBuildsBodyLocalMode() { - let state = AppState(preview: true) - state.connectionMode = .local - let view = MenuContent(state: state, updater: nil) - _ = view.body - } - - @Test func menuContentBuildsBodyRemoteMode() { - let state = AppState(preview: true) - state.connectionMode = .remote - let view = MenuContent(state: state, updater: nil) - _ = view.body - } - - @Test func menuContentBuildsBodyUnconfiguredMode() { - let state = AppState(preview: true) - state.connectionMode = .unconfigured - let view = MenuContent(state: state, updater: nil) - _ = view.body - } - - @Test func menuContentBuildsBodyWithDebugAndCanvas() { - let state = AppState(preview: true) - state.connectionMode = .local - state.debugPaneEnabled = true - state.canvasEnabled = true - state.canvasPanelVisible = true - state.swabbleEnabled = true - state.voicePushToTalkEnabled = true - state.heartbeatsEnabled = true - let view = MenuContent(state: state, updater: nil) - _ = view.body - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift b/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift deleted file mode 100644 index 8395ed145ce..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift +++ /dev/null @@ -1,96 +0,0 @@ -import AppKit -import Testing -@testable import OpenClaw - -@Suite(.serialized) -@MainActor -struct MenuSessionsInjectorTests { - @Test func injectsDisconnectedMessage() { - let injector = MenuSessionsInjector() - injector.setTestingControlChannelConnected(false) - injector.setTestingSnapshot(nil, errorText: nil) - - let menu = NSMenu() - menu.addItem(NSMenuItem(title: "Header", action: nil, keyEquivalent: "")) - menu.addItem(.separator()) - menu.addItem(NSMenuItem(title: "Send Heartbeats", action: nil, keyEquivalent: "")) - - injector.injectForTesting(into: menu) - #expect(menu.items.contains { $0.tag == 9_415_557 }) - } - - @Test func injectsSessionRows() { - let injector = MenuSessionsInjector() - injector.setTestingControlChannelConnected(true) - - let defaults = SessionDefaults(model: "anthropic/claude-opus-4-6", contextTokens: 200_000) - let rows = [ - SessionRow( - id: "main", - key: "main", - kind: .direct, - displayName: nil, - provider: nil, - subject: nil, - room: nil, - space: nil, - updatedAt: Date(), - sessionId: "s1", - thinkingLevel: "low", - verboseLevel: nil, - systemSent: false, - abortedLastRun: false, - tokens: SessionTokenStats(input: 10, output: 20, total: 30, contextTokens: 200_000), - model: "claude-opus-4-6"), - SessionRow( - id: "discord:group:alpha", - key: "discord:group:alpha", - kind: .group, - displayName: nil, - provider: nil, - subject: nil, - room: nil, - space: nil, - updatedAt: Date(timeIntervalSinceNow: -60), - sessionId: "s2", - thinkingLevel: "high", - verboseLevel: "debug", - systemSent: true, - abortedLastRun: true, - tokens: SessionTokenStats(input: 50, output: 50, total: 100, contextTokens: 200_000), - model: "claude-opus-4-6"), - ] - let snapshot = SessionStoreSnapshot( - storePath: "/tmp/sessions.json", - defaults: defaults, - rows: rows) - injector.setTestingSnapshot(snapshot, errorText: nil) - - let usage = GatewayUsageSummary( - updatedAt: Date().timeIntervalSince1970 * 1000, - providers: [ - GatewayUsageProvider( - provider: "anthropic", - displayName: "Claude", - windows: [GatewayUsageWindow(label: "5h", usedPercent: 12, resetAt: nil)], - plan: "Pro", - error: nil), - GatewayUsageProvider( - provider: "openai-codex", - displayName: "Codex", - windows: [GatewayUsageWindow(label: "day", usedPercent: 3, resetAt: nil)], - plan: nil, - error: nil), - ]) - injector.setTestingUsageSummary(usage, errorText: nil) - - let menu = NSMenu() - menu.addItem(NSMenuItem(title: "Header", action: nil, keyEquivalent: "")) - menu.addItem(.separator()) - menu.addItem(NSMenuItem(title: "Send Heartbeats", action: nil, keyEquivalent: "")) - - injector.injectForTesting(into: menu) - #expect(menu.items.contains { $0.tag == 9_415_557 }) - #expect(menu.items.contains { $0.tag == 9_415_557 && $0.isSeparatorItem }) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/ModelCatalogLoaderTests.swift b/apps/macos/Tests/OpenClawIPCTests/ModelCatalogLoaderTests.swift deleted file mode 100644 index 05ed6f8513b..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/ModelCatalogLoaderTests.swift +++ /dev/null @@ -1,53 +0,0 @@ -import Foundation -import Testing -@testable import OpenClaw - -@Suite -struct ModelCatalogLoaderTests { - @Test - func loadParsesModelsFromTypeScriptAndSorts() async throws { - let src = """ - export const MODELS = { - openai: { - "gpt-4o-mini": { name: "GPT-4o mini", contextWindow: 128000 } satisfies any, - "gpt-4o": { name: "GPT-4o", contextWindow: 128000 } as any, - "gpt-3.5": { contextWindow: 16000 }, - }, - anthropic: { - "claude-3": { name: "Claude 3", contextWindow: 200000 }, - }, - }; - """ - - let tmp = FileManager().temporaryDirectory - .appendingPathComponent("models-\(UUID().uuidString).ts") - defer { try? FileManager().removeItem(at: tmp) } - try src.write(to: tmp, atomically: true, encoding: .utf8) - - let choices = try await ModelCatalogLoader.load(from: tmp.path) - #expect(choices.count == 4) - #expect(choices.first?.provider == "anthropic") - #expect(choices.first?.id == "claude-3") - - let ids = Set(choices.map(\.id)) - #expect(ids == Set(["claude-3", "gpt-4o", "gpt-4o-mini", "gpt-3.5"])) - - let openai = choices.filter { $0.provider == "openai" } - let openaiNames = openai.map(\.name) - #expect(openaiNames == openaiNames.sorted { a, b in - a.localizedCaseInsensitiveCompare(b) == .orderedAscending - }) - } - - @Test - func loadWithNoExportReturnsEmptyChoices() async throws { - let src = "const NOPE = 1;" - let tmp = FileManager().temporaryDirectory - .appendingPathComponent("models-\(UUID().uuidString).ts") - defer { try? FileManager().removeItem(at: tmp) } - try src.write(to: tmp, atomically: true, encoding: .utf8) - - let choices = try await ModelCatalogLoader.load(from: tmp.path) - #expect(choices.isEmpty) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/NixModeStableSuiteTests.swift b/apps/macos/Tests/OpenClawIPCTests/NixModeStableSuiteTests.swift deleted file mode 100644 index 98f7b4c8607..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/NixModeStableSuiteTests.swift +++ /dev/null @@ -1,46 +0,0 @@ -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) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/NodeManagerPathsTests.swift b/apps/macos/Tests/OpenClawIPCTests/NodeManagerPathsTests.swift deleted file mode 100644 index 9ee41b4f7b9..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/NodeManagerPathsTests.swift +++ /dev/null @@ -1,45 +0,0 @@ -import Foundation -import Testing -@testable import OpenClaw - -@Suite struct NodeManagerPathsTests { - private func makeTempDir() throws -> URL { - let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - let dir = base.appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager().createDirectory(at: dir, withIntermediateDirectories: true) - return dir - } - - private func makeExec(at path: URL) throws { - try FileManager().createDirectory( - at: path.deletingLastPathComponent(), - withIntermediateDirectories: true) - FileManager().createFile(atPath: path.path, contents: Data("echo ok\n".utf8)) - try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path) - } - - @Test func fnmNodeBinsPreferNewestInstalledVersion() throws { - let home = try self.makeTempDir() - - let v20Bin = home - .appendingPathComponent(".local/share/fnm/node-versions/v20.19.5/installation/bin/node") - let v25Bin = home - .appendingPathComponent(".local/share/fnm/node-versions/v25.1.0/installation/bin/node") - try self.makeExec(at: v20Bin) - try self.makeExec(at: v25Bin) - - let bins = CommandResolver._testNodeManagerBinPaths(home: home) - #expect(bins.first == v25Bin.deletingLastPathComponent().path) - #expect(bins.contains(v20Bin.deletingLastPathComponent().path)) - } - - @Test func ignoresEntriesWithoutNodeExecutable() throws { - let home = try self.makeTempDir() - let missingNodeBin = home - .appendingPathComponent(".local/share/fnm/node-versions/v99.0.0/installation/bin") - try FileManager().createDirectory(at: missingNodeBin, withIntermediateDirectories: true) - - let bins = CommandResolver._testNodeManagerBinPaths(home: home) - #expect(!bins.contains(missingNodeBin.path)) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/NodePairingApprovalPrompterTests.swift b/apps/macos/Tests/OpenClawIPCTests/NodePairingApprovalPrompterTests.swift deleted file mode 100644 index 7c2a90e456e..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/NodePairingApprovalPrompterTests.swift +++ /dev/null @@ -1,10 +0,0 @@ -import Testing -@testable import OpenClaw - -@Suite(.serialized) -@MainActor -struct NodePairingApprovalPrompterTests { - @Test func nodePairingApprovalPrompterExercises() async { - await NodePairingApprovalPrompter.exerciseForTesting() - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/NodePairingReconcilePolicyTests.swift b/apps/macos/Tests/OpenClawIPCTests/NodePairingReconcilePolicyTests.swift deleted file mode 100644 index cc1113f789c..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/NodePairingReconcilePolicyTests.swift +++ /dev/null @@ -1,14 +0,0 @@ -import Testing -@testable import OpenClaw - -@Suite struct NodePairingReconcilePolicyTests { - @Test func policyPollsOnlyWhenActive() { - #expect(NodePairingReconcilePolicy.shouldPoll(pendingCount: 0, isPresenting: false) == false) - #expect(NodePairingReconcilePolicy.shouldPoll(pendingCount: 1, isPresenting: false)) - #expect(NodePairingReconcilePolicy.shouldPoll(pendingCount: 0, isPresenting: true)) - } - - @Test func policyUsesSlowSafetyInterval() { - #expect(NodePairingReconcilePolicy.activeIntervalMs >= 10000) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/OnboardingCoverageTests.swift b/apps/macos/Tests/OpenClawIPCTests/OnboardingCoverageTests.swift deleted file mode 100644 index e79d002683c..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/OnboardingCoverageTests.swift +++ /dev/null @@ -1,10 +0,0 @@ -import Testing -@testable import OpenClaw - -@Suite(.serialized) -@MainActor -struct OnboardingCoverageTests { - @Test func exerciseOnboardingPages() { - OnboardingView.exerciseForTesting() - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift deleted file mode 100644 index b824b2b0835..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift +++ /dev/null @@ -1,61 +0,0 @@ -import Foundation -import OpenClawDiscovery -import SwiftUI -import Testing -@testable import OpenClaw - -@Suite(.serialized) -@MainActor -struct OnboardingViewSmokeTests { - @Test func onboardingViewBuildsBody() { - let state = AppState(preview: true) - let view = OnboardingView( - state: state, - permissionMonitor: PermissionMonitor.shared, - discoveryModel: GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)) - _ = view.body - } - - @Test func pageOrderOmitsWorkspaceAndIdentitySteps() { - let order = OnboardingView.pageOrder(for: .local, showOnboardingChat: false) - #expect(!order.contains(7)) - #expect(order.contains(3)) - } - - @Test func pageOrderOmitsOnboardingChatWhenIdentityKnown() { - let order = OnboardingView.pageOrder(for: .local, showOnboardingChat: false) - #expect(!order.contains(8)) - } - - @Test func selectRemoteGatewayClearsStaleSshTargetWhenEndpointUnresolved() async { - let override = FileManager().temporaryDirectory - .appendingPathComponent("openclaw-config-\(UUID().uuidString)") - .appendingPathComponent("openclaw.json") - .path - - await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": override]) { - let state = AppState(preview: true) - state.remoteTransport = .ssh - state.remoteTarget = "user@old-host:2222" - let view = OnboardingView( - state: state, - permissionMonitor: PermissionMonitor.shared, - discoveryModel: GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)) - let gateway = GatewayDiscoveryModel.DiscoveredGateway( - displayName: "Unresolved", - serviceHost: nil, - servicePort: nil, - lanHost: "txt-host.local", - tailnetDns: "txt-host.ts.net", - sshPort: 22, - gatewayPort: 18789, - cliPath: "/tmp/openclaw", - stableID: UUID().uuidString, - debugID: UUID().uuidString, - isLocal: false) - - view.selectRemoteGateway(gateway) - #expect(state.remoteTarget.isEmpty) - } - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/OnboardingWizardStepViewTests.swift b/apps/macos/Tests/OpenClawIPCTests/OnboardingWizardStepViewTests.swift deleted file mode 100644 index 7211482fea2..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/OnboardingWizardStepViewTests.swift +++ /dev/null @@ -1,44 +0,0 @@ -import OpenClawProtocol -import SwiftUI -import Testing -@testable import OpenClaw - -private typealias ProtoAnyCodable = OpenClawProtocol.AnyCodable - -@Suite(.serialized) -@MainActor -struct OnboardingWizardStepViewTests { - @Test func noteStepBuilds() { - let step = WizardStep( - id: "step-1", - type: ProtoAnyCodable("note"), - title: "Welcome", - message: "Hello", - options: nil, - initialvalue: nil, - placeholder: nil, - sensitive: nil, - executor: nil) - let view = OnboardingWizardStepView(step: step, isSubmitting: false, onSubmit: { _ in }) - _ = view.body - } - - @Test func selectStepBuilds() { - let options: [[String: ProtoAnyCodable]] = [ - ["value": ProtoAnyCodable("local"), "label": ProtoAnyCodable("Local"), "hint": ProtoAnyCodable("This Mac")], - ["value": ProtoAnyCodable("remote"), "label": ProtoAnyCodable("Remote")], - ] - let step = WizardStep( - id: "step-2", - type: ProtoAnyCodable("select"), - title: "Mode", - message: "Choose a mode", - options: options, - initialvalue: ProtoAnyCodable("local"), - placeholder: nil, - sensitive: nil, - executor: nil) - let view = OnboardingWizardStepView(step: step, isSubmitting: false, onSubmit: { _ in }) - _ = view.body - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift b/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift deleted file mode 100644 index 2cd9d6432e2..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift +++ /dev/null @@ -1,143 +0,0 @@ -import Foundation -import Testing -@testable import OpenClaw - -@Suite(.serialized) -struct OpenClawConfigFileTests { - @Test - func configPathRespectsEnvOverride() async { - let override = FileManager().temporaryDirectory - .appendingPathComponent("openclaw-config-\(UUID().uuidString)") - .appendingPathComponent("openclaw.json") - .path - - await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": override]) { - #expect(OpenClawConfigFile.url().path == override) - } - } - - @MainActor - @Test - func remoteGatewayPortParsesAndMatchesHost() async { - let override = FileManager().temporaryDirectory - .appendingPathComponent("openclaw-config-\(UUID().uuidString)") - .appendingPathComponent("openclaw.json") - .path - - await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": override]) { - OpenClawConfigFile.saveDict([ - "gateway": [ - "remote": [ - "url": "ws://gateway.ts.net:19999", - ], - ], - ]) - #expect(OpenClawConfigFile.remoteGatewayPort() == 19999) - #expect(OpenClawConfigFile.remoteGatewayPort(matchingHost: "gateway.ts.net") == 19999) - #expect(OpenClawConfigFile.remoteGatewayPort(matchingHost: "gateway") == 19999) - #expect(OpenClawConfigFile.remoteGatewayPort(matchingHost: "other.ts.net") == nil) - } - } - - @MainActor - @Test - func setRemoteGatewayUrlPreservesScheme() async { - let override = FileManager().temporaryDirectory - .appendingPathComponent("openclaw-config-\(UUID().uuidString)") - .appendingPathComponent("openclaw.json") - .path - - await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": override]) { - OpenClawConfigFile.saveDict([ - "gateway": [ - "remote": [ - "url": "wss://old-host:111", - ], - ], - ]) - OpenClawConfigFile.setRemoteGatewayUrl(host: "new-host", port: 2222) - let root = OpenClawConfigFile.loadDict() - let url = ((root["gateway"] as? [String: Any])?["remote"] as? [String: Any])?["url"] as? String - #expect(url == "wss://new-host:2222") - } - } - - @MainActor - @Test - func clearRemoteGatewayUrlRemovesOnlyUrlField() async { - let override = FileManager().temporaryDirectory - .appendingPathComponent("openclaw-config-\(UUID().uuidString)") - .appendingPathComponent("openclaw.json") - .path - - await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": override]) { - OpenClawConfigFile.saveDict([ - "gateway": [ - "remote": [ - "url": "wss://old-host:111", - "token": "tok", - ], - ], - ]) - OpenClawConfigFile.clearRemoteGatewayUrl() - let root = OpenClawConfigFile.loadDict() - let remote = ((root["gateway"] as? [String: Any])?["remote"] as? [String: Any]) ?? [:] - #expect((remote["url"] as? String) == nil) - #expect((remote["token"] as? String) == "tok") - } - } - - @Test - func stateDirOverrideSetsConfigPath() async { - let dir = FileManager().temporaryDirectory - .appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true) - .path - - await TestIsolation.withEnvValues([ - "OPENCLAW_CONFIG_PATH": nil, - "OPENCLAW_STATE_DIR": dir, - ]) { - #expect(OpenClawConfigFile.stateDirURL().path == dir) - #expect(OpenClawConfigFile.url().path == "\(dir)/openclaw.json") - } - } - - @MainActor - @Test - func saveDictAppendsConfigAuditLog() async throws { - let stateDir = FileManager().temporaryDirectory - .appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true) - let configPath = stateDir.appendingPathComponent("openclaw.json") - let auditPath = stateDir.appendingPathComponent("logs/config-audit.jsonl") - - defer { try? FileManager().removeItem(at: stateDir) } - - try await TestIsolation.withEnvValues([ - "OPENCLAW_STATE_DIR": stateDir.path, - "OPENCLAW_CONFIG_PATH": configPath.path, - ]) { - OpenClawConfigFile.saveDict([ - "gateway": ["mode": "local"], - ]) - - let configData = try Data(contentsOf: configPath) - let configRoot = try JSONSerialization.jsonObject(with: configData) as? [String: Any] - #expect((configRoot?["meta"] as? [String: Any]) != nil) - - let rawAudit = try String(contentsOf: auditPath, encoding: .utf8) - let lines = rawAudit - .split(whereSeparator: \.isNewline) - .map(String.init) - #expect(!lines.isEmpty) - guard let last = lines.last else { - Issue.record("Missing config audit line") - return - } - let auditRoot = try JSONSerialization.jsonObject(with: Data(last.utf8)) as? [String: Any] - #expect(auditRoot?["source"] as? String == "macos-openclaw-config-file") - #expect(auditRoot?["event"] as? String == "config.write") - #expect(auditRoot?["result"] as? String == "success") - #expect(auditRoot?["configPath"] as? String == configPath.path) - } - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/OpenClawOAuthStoreTests.swift b/apps/macos/Tests/OpenClawIPCTests/OpenClawOAuthStoreTests.swift deleted file mode 100644 index b34e9c3008a..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/OpenClawOAuthStoreTests.swift +++ /dev/null @@ -1,97 +0,0 @@ -import Foundation -import Testing -@testable import OpenClaw - -@Suite -struct OpenClawOAuthStoreTests { - @Test - func returnsMissingWhenFileAbsent() { - let url = FileManager().temporaryDirectory - .appendingPathComponent("openclaw-oauth-\(UUID().uuidString)") - .appendingPathComponent("oauth.json") - #expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url) == .missingFile) - } - - @Test - func usesEnvOverrideForOpenClawOAuthDir() throws { - let key = "OPENCLAW_OAUTH_DIR" - let previous = ProcessInfo.processInfo.environment[key] - defer { - if let previous { - setenv(key, previous, 1) - } else { - unsetenv(key) - } - } - - let dir = FileManager().temporaryDirectory - .appendingPathComponent("openclaw-oauth-\(UUID().uuidString)", isDirectory: true) - setenv(key, dir.path, 1) - - #expect(OpenClawOAuthStore.oauthDir().standardizedFileURL == dir.standardizedFileURL) - } - - @Test - func acceptsPiFormatTokens() throws { - let url = try self.writeOAuthFile([ - "anthropic": [ - "type": "oauth", - "refresh": "r1", - "access": "a1", - "expires": 1_234_567_890, - ], - ]) - - #expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url).isConnected) - } - - @Test - func acceptsTokenKeyVariants() throws { - let url = try self.writeOAuthFile([ - "anthropic": [ - "type": "oauth", - "refresh_token": "r1", - "access_token": "a1", - ], - ]) - - #expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url).isConnected) - } - - @Test - func reportsMissingProviderEntry() throws { - let url = try self.writeOAuthFile([ - "other": [ - "type": "oauth", - "refresh": "r1", - "access": "a1", - ], - ]) - - #expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url) == .missingProviderEntry) - } - - @Test - func reportsMissingTokens() throws { - let url = try self.writeOAuthFile([ - "anthropic": [ - "type": "oauth", - "refresh": "", - "access": "a1", - ], - ]) - - #expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url) == .missingTokens) - } - - private func writeOAuthFile(_ json: [String: Any]) throws -> URL { - let dir = FileManager().temporaryDirectory - .appendingPathComponent("openclaw-oauth-\(UUID().uuidString)", isDirectory: true) - try FileManager().createDirectory(at: dir, withIntermediateDirectories: true) - - let url = dir.appendingPathComponent("oauth.json") - let data = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]) - try data.write(to: url, options: [.atomic]) - return url - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/PermissionManagerLocationTests.swift b/apps/macos/Tests/OpenClawIPCTests/PermissionManagerLocationTests.swift deleted file mode 100644 index 871998cb240..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/PermissionManagerLocationTests.swift +++ /dev/null @@ -1,20 +0,0 @@ -import CoreLocation -import Testing - -@testable import OpenClaw - -@Suite("PermissionManager Location") -struct PermissionManagerLocationTests { - @Test("authorizedAlways counts for both modes") - func authorizedAlwaysCountsForBothModes() { - #expect(PermissionManager.isLocationAuthorized(status: .authorizedAlways, requireAlways: false)) - #expect(PermissionManager.isLocationAuthorized(status: .authorizedAlways, requireAlways: true)) - } - - @Test("other statuses not authorized") - func otherStatusesNotAuthorized() { - #expect(!PermissionManager.isLocationAuthorized(status: .notDetermined, requireAlways: false)) - #expect(!PermissionManager.isLocationAuthorized(status: .denied, requireAlways: false)) - #expect(!PermissionManager.isLocationAuthorized(status: .restricted, requireAlways: false)) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/PermissionManagerTests.swift b/apps/macos/Tests/OpenClawIPCTests/PermissionManagerTests.swift deleted file mode 100644 index 5e41339f166..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/PermissionManagerTests.swift +++ /dev/null @@ -1,38 +0,0 @@ -import OpenClawIPC -import CoreLocation -import Testing -@testable import OpenClaw - -@Suite(.serialized) -@MainActor -struct PermissionManagerTests { - @Test func voiceWakePermissionHelpersMatchStatus() async { - let direct = PermissionManager.voiceWakePermissionsGranted() - let ensured = await PermissionManager.ensureVoiceWakePermissions(interactive: false) - #expect(ensured == direct) - } - - @Test func statusCanQueryNonInteractiveCaps() async { - let caps: [Capability] = [.microphone, .speechRecognition, .screenRecording] - let status = await PermissionManager.status(caps) - #expect(status.keys.count == caps.count) - } - - @Test func ensureNonInteractiveDoesNotThrow() async { - let caps: [Capability] = [.microphone, .speechRecognition, .screenRecording] - let ensured = await PermissionManager.ensure(caps, interactive: false) - #expect(ensured.keys.count == caps.count) - } - - @Test func locationStatusMatchesAuthorizationAlways() async { - let status = CLLocationManager().authorizationStatus - let results = await PermissionManager.status([.location]) - #expect(results[.location] == (status == .authorizedAlways)) - } - - @Test func ensureLocationNonInteractiveMatchesAuthorizationAlways() async { - let status = CLLocationManager().authorizationStatus - let ensured = await PermissionManager.ensure([.location], interactive: false) - #expect(ensured[.location] == (status == .authorizedAlways)) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/Placeholder.swift b/apps/macos/Tests/OpenClawIPCTests/Placeholder.swift deleted file mode 100644 index 14e5c056b09..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/Placeholder.swift +++ /dev/null @@ -1,7 +0,0 @@ -import Testing - -@Suite struct PlaceholderTests { - @Test func placeholder() { - #expect(true) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/RemotePortTunnelTests.swift b/apps/macos/Tests/OpenClawIPCTests/RemotePortTunnelTests.swift deleted file mode 100644 index 856af89676c..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/RemotePortTunnelTests.swift +++ /dev/null @@ -1,74 +0,0 @@ -import Testing -@testable import OpenClaw - -#if canImport(Darwin) -import Darwin -import Foundation - -@Suite struct RemotePortTunnelTests { - @Test func drainStderrDoesNotCrashWhenHandleClosed() { - let pipe = Pipe() - let handle = pipe.fileHandleForReading - try? handle.close() - - let drained = RemotePortTunnel._testDrainStderr(handle) - #expect(drained.isEmpty) - } - - @Test func portIsFreeDetectsIPv4Listener() { - var fd = socket(AF_INET, SOCK_STREAM, 0) - #expect(fd >= 0) - guard fd >= 0 else { return } - defer { - if fd >= 0 { _ = Darwin.close(fd) } - } - - var one: Int32 = 1 - _ = setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, socklen_t(MemoryLayout.size(ofValue: one))) - - var addr = sockaddr_in() - addr.sin_len = UInt8(MemoryLayout.size) - addr.sin_family = sa_family_t(AF_INET) - addr.sin_port = 0 - addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) - - let bound = withUnsafePointer(to: &addr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in - Darwin.bind(fd, sa, socklen_t(MemoryLayout.size)) - } - } - #expect(bound == 0) - guard bound == 0 else { return } - #expect(Darwin.listen(fd, 1) == 0) - - var name = sockaddr_in() - var nameLen = socklen_t(MemoryLayout.size) - let got = withUnsafeMutablePointer(to: &name) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in - getsockname(fd, sa, &nameLen) - } - } - #expect(got == 0) - guard got == 0 else { return } - - let port = UInt16(bigEndian: name.sin_port) - #expect(RemotePortTunnel._testPortIsFree(port) == false) - - _ = Darwin.close(fd) - fd = -1 - - // In parallel test runs, another test may briefly grab the same ephemeral port. - // Poll for a short window to avoid flakiness. - let deadline = Date().addingTimeInterval(0.5) - var free = false - while Date() < deadline { - if RemotePortTunnel._testPortIsFree(port) { - free = true - break - } - usleep(10000) // 10ms - } - #expect(free == true) - } -} -#endif diff --git a/apps/macos/Tests/OpenClawIPCTests/RuntimeLocatorTests.swift b/apps/macos/Tests/OpenClawIPCTests/RuntimeLocatorTests.swift deleted file mode 100644 index 6662132c9ac..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/RuntimeLocatorTests.swift +++ /dev/null @@ -1,71 +0,0 @@ -import Foundation -import Testing -@testable import OpenClaw - -@Suite struct RuntimeLocatorTests { - private func makeTempExecutable(contents: String) throws -> URL { - let dir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - .appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager().createDirectory(at: dir, withIntermediateDirectories: true) - let path = dir.appendingPathComponent("node") - try contents.write(to: path, atomically: true, encoding: .utf8) - try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path) - return path - } - - @Test func resolveSucceedsWithValidNode() throws { - let script = """ - #!/bin/sh - echo v22.5.0 - """ - let node = try self.makeTempExecutable(contents: script) - let result = RuntimeLocator.resolve(searchPaths: [node.deletingLastPathComponent().path]) - guard case let .success(res) = result else { - Issue.record("Expected success, got \(result)") - return - } - #expect(res.path == node.path) - #expect(res.version == RuntimeVersion(major: 22, minor: 5, patch: 0)) - } - - @Test func resolveFailsWhenTooOld() throws { - let script = """ - #!/bin/sh - echo v18.2.0 - """ - let node = try self.makeTempExecutable(contents: script) - let result = RuntimeLocator.resolve(searchPaths: [node.deletingLastPathComponent().path]) - guard case let .failure(.unsupported(_, found, _, path, _)) = result else { - Issue.record("Expected unsupported error, got \(result)") - return - } - #expect(found == RuntimeVersion(major: 18, minor: 2, patch: 0)) - #expect(path == node.path) - } - - @Test func resolveFailsWhenVersionUnparsable() throws { - let script = """ - #!/bin/sh - echo node-version:unknown - """ - let node = try self.makeTempExecutable(contents: script) - let result = RuntimeLocator.resolve(searchPaths: [node.deletingLastPathComponent().path]) - guard case let .failure(.versionParse(_, raw, path, _)) = result else { - Issue.record("Expected versionParse error, got \(result)") - return - } - #expect(raw.contains("unknown")) - #expect(path == node.path) - } - - @Test func describeFailureIncludesPaths() { - let msg = RuntimeLocator.describeFailure(.notFound(searchPaths: ["/tmp/a", "/tmp/b"])) - #expect(msg.contains("PATH searched: /tmp/a:/tmp/b")) - } - - @Test func runtimeVersionParsesWithLeadingVAndMetadata() { - #expect(RuntimeVersion.from(string: "v22.1.3") == RuntimeVersion(major: 22, minor: 1, patch: 3)) - #expect(RuntimeVersion.from(string: "node 22.3.0-alpha.1") == RuntimeVersion(major: 22, minor: 3, patch: 0)) - #expect(RuntimeVersion.from(string: "bogus") == nil) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/ScreenshotSizeTests.swift b/apps/macos/Tests/OpenClawIPCTests/ScreenshotSizeTests.swift deleted file mode 100644 index 84fe17751dd..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/ScreenshotSizeTests.swift +++ /dev/null @@ -1,21 +0,0 @@ -import Foundation -import Testing -@testable import OpenClaw - -@Suite -struct ScreenshotSizeTests { - @Test - func readPNGSizeReturnsDimensions() throws { - let pngBase64 = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+WZxkAAAAASUVORK5CYII=" - let data = try #require(Data(base64Encoded: pngBase64)) - let size = ScreenshotSize.readPNGSize(data: data) - #expect(size?.width == 1) - #expect(size?.height == 1) - } - - @Test - func readPNGSizeRejectsNonPNGData() { - #expect(ScreenshotSize.readPNGSize(data: Data("nope".utf8)) == nil) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/SemverTests.swift b/apps/macos/Tests/OpenClawIPCTests/SemverTests.swift deleted file mode 100644 index 83d8e8478f9..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/SemverTests.swift +++ /dev/null @@ -1,21 +0,0 @@ -import Testing -@testable import OpenClaw - -@Suite struct SemverTests { - @Test func comparisonOrdersByMajorMinorPatch() { - let a = Semver(major: 1, minor: 0, patch: 0) - let b = Semver(major: 1, minor: 1, patch: 0) - let c = Semver(major: 1, minor: 1, patch: 1) - let d = Semver(major: 2, minor: 0, patch: 0) - - #expect(a < b) - #expect(b < c) - #expect(c < d) - #expect(d > a) - } - - @Test func descriptionMatchesParts() { - let v = Semver(major: 3, minor: 2, patch: 1) - #expect(v.description == "3.2.1") - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/SessionDataTests.swift b/apps/macos/Tests/OpenClawIPCTests/SessionDataTests.swift deleted file mode 100644 index f1594ba7b54..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/SessionDataTests.swift +++ /dev/null @@ -1,48 +0,0 @@ -import Foundation -import Testing -@testable import OpenClaw - -@Suite -struct SessionDataTests { - @Test func sessionKindFromKeyDetectsCommonKinds() { - #expect(SessionKind.from(key: "global") == .global) - #expect(SessionKind.from(key: "discord:group:engineering") == .group) - #expect(SessionKind.from(key: "unknown") == .unknown) - #expect(SessionKind.from(key: "user@example.com") == .direct) - } - - @Test func sessionTokenStatsFormatKTokensRoundsAsExpected() { - #expect(SessionTokenStats.formatKTokens(999) == "999") - #expect(SessionTokenStats.formatKTokens(1000) == "1.0k") - #expect(SessionTokenStats.formatKTokens(12340) == "12k") - } - - @Test func sessionTokenStatsPercentUsedClampsTo100() { - let stats = SessionTokenStats(input: 0, output: 0, total: 250_000, contextTokens: 200_000) - #expect(stats.percentUsed == 100) - } - - @Test func sessionRowFlagLabelsIncludeNonDefaultFlags() { - let row = SessionRow( - id: "x", - key: "user@example.com", - kind: .direct, - displayName: nil, - provider: nil, - subject: nil, - room: nil, - space: nil, - updatedAt: Date(), - sessionId: nil, - thinkingLevel: "high", - verboseLevel: "debug", - systemSent: true, - abortedLastRun: true, - tokens: SessionTokenStats(input: 1, output: 2, total: 3, contextTokens: 10), - model: nil) - #expect(row.flagLabels.contains("think high")) - #expect(row.flagLabels.contains("verbose debug")) - #expect(row.flagLabels.contains("system sent")) - #expect(row.flagLabels.contains("aborted")) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/SessionMenuPreviewTests.swift b/apps/macos/Tests/OpenClawIPCTests/SessionMenuPreviewTests.swift deleted file mode 100644 index 44bb3c39c2c..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/SessionMenuPreviewTests.swift +++ /dev/null @@ -1,28 +0,0 @@ -import Foundation -import Testing -@testable import OpenClaw - -@Suite(.serialized) -struct SessionMenuPreviewTests { - @Test func loaderReturnsCachedItems() async { - await SessionPreviewCache.shared._testReset() - let items = [SessionPreviewItem(id: "1", role: .user, text: "Hi")] - let snapshot = SessionMenuPreviewSnapshot(items: items, status: .ready) - await SessionPreviewCache.shared._testSet(snapshot: snapshot, for: "main") - - let loaded = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10) - #expect(loaded.status == .ready) - #expect(loaded.items.count == 1) - #expect(loaded.items.first?.text == "Hi") - } - - @Test func loaderReturnsEmptyWhenCachedEmpty() async { - await SessionPreviewCache.shared._testReset() - let snapshot = SessionMenuPreviewSnapshot(items: [], status: .empty) - await SessionPreviewCache.shared._testSet(snapshot: snapshot, for: "main") - - let loaded = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10) - #expect(loaded.status == .empty) - #expect(loaded.items.isEmpty) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/SettingsViewSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/SettingsViewSmokeTests.swift deleted file mode 100644 index f9de602e259..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/SettingsViewSmokeTests.swift +++ /dev/null @@ -1,165 +0,0 @@ -import SwiftUI -import Testing -@testable import OpenClaw - -@Suite(.serialized) -@MainActor -struct SettingsViewSmokeTests { - @Test func cronSettingsBuildsBody() { - let store = CronJobsStore(isPreview: true) - store.schedulerEnabled = false - store.schedulerStorePath = "/tmp/openclaw-cron-store.json" - - let job1 = CronJob( - id: "job-1", - agentId: "ops", - name: " Morning Check-in ", - description: nil, - enabled: true, - deleteAfterRun: nil, - createdAtMs: 1_700_000_000_000, - updatedAtMs: 1_700_000_100_000, - schedule: .cron(expr: "0 8 * * *", tz: "UTC"), - sessionTarget: .main, - wakeMode: .now, - payload: .systemEvent(text: "ping"), - delivery: nil, - state: CronJobState( - nextRunAtMs: 1_700_000_200_000, - runningAtMs: nil, - lastRunAtMs: 1_700_000_050_000, - lastStatus: "ok", - lastError: nil, - lastDurationMs: 123)) - - let job2 = CronJob( - id: "job-2", - agentId: nil, - name: "", - description: nil, - enabled: false, - deleteAfterRun: nil, - createdAtMs: 1_700_000_000_000, - updatedAtMs: 1_700_000_100_000, - schedule: .every(everyMs: 30000, anchorMs: nil), - sessionTarget: .isolated, - wakeMode: .nextHeartbeat, - payload: .agentTurn( - message: "hello", - thinking: "low", - timeoutSeconds: 30, - deliver: nil, - channel: nil, - to: nil, - bestEffortDeliver: nil), - delivery: CronDelivery(mode: .announce, channel: "sms", to: "+15551234567", bestEffort: true), - state: CronJobState( - nextRunAtMs: nil, - runningAtMs: nil, - lastRunAtMs: nil, - lastStatus: nil, - lastError: nil, - lastDurationMs: nil)) - - store.jobs = [job1, job2] - store.selectedJobId = job1.id - store.runEntries = [ - CronRunLogEntry( - ts: 1_700_000_050_000, - jobId: job1.id, - action: "finished", - status: "ok", - error: nil, - summary: "ok", - runAtMs: 1_700_000_050_000, - durationMs: 123, - nextRunAtMs: 1_700_000_200_000), - ] - - let view = CronSettings(store: store) - _ = view.body - } - - @Test func cronSettingsExercisesPrivateViews() { - CronSettings.exerciseForTesting() - } - - @Test func configSettingsBuildsBody() { - let view = ConfigSettings() - _ = view.body - } - - @Test func debugSettingsBuildsBody() { - let view = DebugSettings() - _ = view.body - } - - @Test func generalSettingsBuildsBody() { - let state = AppState(preview: true) - let view = GeneralSettings(state: state) - _ = view.body - } - - @Test func generalSettingsExercisesBranches() { - GeneralSettings.exerciseForTesting() - } - - @Test func sessionsSettingsBuildsBody() { - let view = SessionsSettings(rows: SessionRow.previewRows, isPreview: true) - _ = view.body - } - - @Test func instancesSettingsBuildsBody() { - let store = InstancesStore(isPreview: true) - store.instances = [ - InstanceInfo( - id: "local", - host: "this-mac", - ip: "127.0.0.1", - version: "1.0", - platform: "macos 15.0", - deviceFamily: "Mac", - modelIdentifier: "MacPreview", - lastInputSeconds: 12, - mode: "local", - reason: "test", - text: "test instance", - ts: Date().timeIntervalSince1970 * 1000), - ] - let view = InstancesSettings(store: store) - _ = view.body - } - - @Test func permissionsSettingsBuildsBody() { - let view = PermissionsSettings( - status: [ - .notifications: true, - .screenRecording: false, - ], - refresh: {}, - showOnboarding: {}) - _ = view.body - } - - @Test func settingsRootViewBuildsBody() { - let state = AppState(preview: true) - let view = SettingsRootView(state: state, updater: nil, initialTab: .general) - _ = view.body - } - - @Test func aboutSettingsBuildsBody() { - let view = AboutSettings(updater: nil) - _ = view.body - } - - @Test func voiceWakeSettingsBuildsBody() { - let state = AppState(preview: true) - let view = VoiceWakeSettings(state: state, isActive: false) - _ = view.body - } - - @Test func skillsSettingsBuildsBody() { - let view = SkillsSettings(state: .preview) - _ = view.body - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/SkillsSettingsSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/SkillsSettingsSmokeTests.swift deleted file mode 100644 index 560f3d2f50b..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/SkillsSettingsSmokeTests.swift +++ /dev/null @@ -1,119 +0,0 @@ -import OpenClawProtocol -import Testing -@testable import OpenClaw - -@Suite(.serialized) -@MainActor -struct SkillsSettingsSmokeTests { - @Test func skillsSettingsBuildsBodyWithSkillsRemote() { - let model = SkillsSettingsModel() - model.statusMessage = "Loaded" - model.skills = [ - SkillStatus( - name: "Needs Setup", - description: "Missing bins and env", - source: "openclaw-managed", - filePath: "/tmp/skills/needs-setup", - baseDir: "/tmp/skills", - skillKey: "needs-setup", - primaryEnv: "API_KEY", - emoji: "🧰", - homepage: "https://example.com/needs-setup", - always: false, - disabled: false, - eligible: false, - requirements: SkillRequirements( - bins: ["python3"], - env: ["API_KEY"], - config: ["skills.needs-setup"]), - missing: SkillMissing( - bins: ["python3"], - env: ["API_KEY"], - config: ["skills.needs-setup"]), - configChecks: [ - SkillStatusConfigCheck(path: "skills.needs-setup", value: AnyCodable(false), satisfied: false), - ], - install: [ - SkillInstallOption(id: "brew", kind: "brew", label: "brew install python", bins: ["python3"]), - ]), - SkillStatus( - name: "Ready Skill", - description: "All set", - source: "openclaw-bundled", - filePath: "/tmp/skills/ready", - baseDir: "/tmp/skills", - skillKey: "ready", - primaryEnv: nil, - emoji: "✅", - homepage: "https://example.com/ready", - always: false, - disabled: false, - eligible: true, - requirements: SkillRequirements(bins: [], env: [], config: []), - missing: SkillMissing(bins: [], env: [], config: []), - configChecks: [ - SkillStatusConfigCheck(path: "skills.ready", value: AnyCodable(true), satisfied: true), - SkillStatusConfigCheck(path: "skills.limit", value: AnyCodable(5), satisfied: true), - ], - install: []), - SkillStatus( - name: "Disabled Skill", - description: "Disabled in config", - source: "openclaw-extra", - filePath: "/tmp/skills/disabled", - baseDir: "/tmp/skills", - skillKey: "disabled", - primaryEnv: nil, - emoji: "🚫", - homepage: nil, - always: false, - disabled: true, - eligible: false, - requirements: SkillRequirements(bins: [], env: [], config: []), - missing: SkillMissing(bins: [], env: [], config: []), - configChecks: [], - install: []), - ] - - let state = AppState(preview: true) - state.connectionMode = .remote - var view = SkillsSettings(state: state, model: model) - view.setFilterForTesting("all") - _ = view.body - view.setFilterForTesting("needsSetup") - _ = view.body - } - - @Test func skillsSettingsBuildsBodyWithLocalMode() { - let model = SkillsSettingsModel() - model.skills = [ - SkillStatus( - name: "Local Skill", - description: "Local ready", - source: "openclaw-workspace", - filePath: "/tmp/skills/local", - baseDir: "/tmp/skills", - skillKey: "local", - primaryEnv: nil, - emoji: "🏠", - homepage: nil, - always: false, - disabled: false, - eligible: true, - requirements: SkillRequirements(bins: [], env: [], config: []), - missing: SkillMissing(bins: [], env: [], config: []), - configChecks: [], - install: []), - ] - - let state = AppState(preview: true) - state.connectionMode = .local - var view = SkillsSettings(state: state, model: model) - view.setFilterForTesting("ready") - _ = view.body - } - - @Test func skillsSettingsExercisesPrivateViews() { - SkillsSettings.exerciseForTesting() - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/TailscaleIntegrationSectionTests.swift b/apps/macos/Tests/OpenClawIPCTests/TailscaleIntegrationSectionTests.swift deleted file mode 100644 index fdfa96cbebb..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/TailscaleIntegrationSectionTests.swift +++ /dev/null @@ -1,48 +0,0 @@ -import SwiftUI -import Testing -@testable import OpenClaw - -@Suite(.serialized) -@MainActor -struct TailscaleIntegrationSectionTests { - @Test func tailscaleSectionBuildsBodyWhenNotInstalled() { - let service = TailscaleService(isInstalled: false, isRunning: false, statusError: "not installed") - var view = TailscaleIntegrationSection(connectionMode: .local, isPaused: false) - view.setTestingService(service) - view.setTestingState(mode: "off", requireCredentials: false, statusMessage: "Idle") - _ = view.body - } - - @Test func tailscaleSectionBuildsBodyForServeMode() { - let service = TailscaleService( - isInstalled: true, - isRunning: true, - tailscaleHostname: "openclaw.tailnet.ts.net", - tailscaleIP: "100.64.0.1") - var view = TailscaleIntegrationSection(connectionMode: .local, isPaused: false) - view.setTestingService(service) - view.setTestingState( - mode: "serve", - requireCredentials: true, - password: "secret", - statusMessage: "Running") - _ = view.body - } - - @Test func tailscaleSectionBuildsBodyForFunnelMode() { - let service = TailscaleService( - isInstalled: true, - isRunning: false, - tailscaleHostname: nil, - tailscaleIP: nil, - statusError: "not running") - var view = TailscaleIntegrationSection(connectionMode: .remote, isPaused: false) - view.setTestingService(service) - view.setTestingState( - mode: "funnel", - requireCredentials: false, - statusMessage: "Needs start", - validationMessage: "Invalid token") - _ = view.body - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/TalkAudioPlayerTests.swift b/apps/macos/Tests/OpenClawIPCTests/TalkAudioPlayerTests.swift deleted file mode 100644 index bba233fa0c4..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/TalkAudioPlayerTests.swift +++ /dev/null @@ -1,97 +0,0 @@ -import Foundation -import Testing -@testable import OpenClaw - -@Suite(.serialized) struct TalkAudioPlayerTests { - @MainActor - @Test func playDoesNotHangWhenPlaybackEndsOrFails() async throws { - let wav = makeWav16Mono(sampleRate: 8000, samples: 80) - defer { _ = TalkAudioPlayer.shared.stop() } - - _ = try await withTimeout(seconds: 4.0) { - await TalkAudioPlayer.shared.play(data: wav) - } - - #expect(true) - } - - @MainActor - @Test func playDoesNotHangWhenPlayIsCalledTwice() async throws { - let wav = makeWav16Mono(sampleRate: 8000, samples: 800) - defer { _ = TalkAudioPlayer.shared.stop() } - - let first = Task { @MainActor in - await TalkAudioPlayer.shared.play(data: wav) - } - - await Task.yield() - _ = await TalkAudioPlayer.shared.play(data: wav) - - _ = try await withTimeout(seconds: 4.0) { - await first.value - } - #expect(true) - } -} - -private struct TimeoutError: Error {} - -private func withTimeout( - seconds: Double, - _ work: @escaping @Sendable () async throws -> T) async throws -> T -{ - try await withThrowingTaskGroup(of: T.self) { group in - group.addTask { - try await work() - } - group.addTask { - try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) - throw TimeoutError() - } - let result = try await group.next() - group.cancelAll() - guard let result else { throw TimeoutError() } - return result - } -} - -private func makeWav16Mono(sampleRate: UInt32, samples: Int) -> Data { - let channels: UInt16 = 1 - let bitsPerSample: UInt16 = 16 - let blockAlign = channels * (bitsPerSample / 8) - let byteRate = sampleRate * UInt32(blockAlign) - let dataSize = UInt32(samples) * UInt32(blockAlign) - - var data = Data() - data.append(contentsOf: [0x52, 0x49, 0x46, 0x46]) // RIFF - data.appendLEUInt32(36 + dataSize) - data.append(contentsOf: [0x57, 0x41, 0x56, 0x45]) // WAVE - - data.append(contentsOf: [0x66, 0x6D, 0x74, 0x20]) // fmt - data.appendLEUInt32(16) // PCM - data.appendLEUInt16(1) // audioFormat - data.appendLEUInt16(channels) - data.appendLEUInt32(sampleRate) - data.appendLEUInt32(byteRate) - data.appendLEUInt16(blockAlign) - data.appendLEUInt16(bitsPerSample) - - data.append(contentsOf: [0x64, 0x61, 0x74, 0x61]) // data - data.appendLEUInt32(dataSize) - - // Silence samples. - data.append(Data(repeating: 0, count: Int(dataSize))) - return data -} - -extension Data { - fileprivate mutating func appendLEUInt16(_ value: UInt16) { - var v = value.littleEndian - Swift.withUnsafeBytes(of: &v) { append(contentsOf: $0) } - } - - fileprivate mutating func appendLEUInt32(_ value: UInt32) { - var v = value.littleEndian - Swift.withUnsafeBytes(of: &v) { append(contentsOf: $0) } - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/TestIsolation.swift b/apps/macos/Tests/OpenClawIPCTests/TestIsolation.swift deleted file mode 100644 index 1002b7ed307..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/TestIsolation.swift +++ /dev/null @@ -1,116 +0,0 @@ -import Foundation - -actor TestIsolationLock { - static let shared = TestIsolationLock() - - private var locked = false - private var waiters: [CheckedContinuation] = [] - - func acquire() async { - if !self.locked { - self.locked = true - return - } - await withCheckedContinuation { cont in - self.waiters.append(cont) - } - // `unlock()` resumed us; lock is now held for this caller. - } - - func release() { - if self.waiters.isEmpty { - self.locked = false - return - } - let next = self.waiters.removeFirst() - next.resume() - } -} - -@MainActor -enum TestIsolation { - static func withIsolatedState( - env: [String: String?] = [:], - defaults: [String: Any?] = [:], - _ body: () async throws -> T) async rethrows -> T - { - await TestIsolationLock.shared.acquire() - var previousEnv: [String: String?] = [:] - for (key, value) in env { - previousEnv[key] = getenv(key).map { String(cString: $0) } - if let value { - setenv(key, value, 1) - } else { - unsetenv(key) - } - } - - let userDefaults = UserDefaults.standard - var previousDefaults: [String: Any?] = [:] - for (key, value) in defaults { - previousDefaults[key] = userDefaults.object(forKey: key) - if let value { - userDefaults.set(value, forKey: key) - } else { - userDefaults.removeObject(forKey: key) - } - } - - do { - let result = try await body() - for (key, value) in previousDefaults { - if let value { - userDefaults.set(value, forKey: key) - } else { - userDefaults.removeObject(forKey: key) - } - } - for (key, value) in previousEnv { - if let value { - setenv(key, value, 1) - } else { - unsetenv(key) - } - } - await TestIsolationLock.shared.release() - return result - } catch { - for (key, value) in previousDefaults { - if let value { - userDefaults.set(value, forKey: key) - } else { - userDefaults.removeObject(forKey: key) - } - } - for (key, value) in previousEnv { - if let value { - setenv(key, value, 1) - } else { - unsetenv(key) - } - } - await TestIsolationLock.shared.release() - throw error - } - } - - static func withEnvValues( - _ values: [String: String?], - _ body: () async throws -> T) async rethrows -> T - { - try await self.withIsolatedState(env: values, defaults: [:], body) - } - - static func withUserDefaultsValues( - _ values: [String: Any?], - _ body: () async throws -> T) async rethrows -> T - { - try await self.withIsolatedState(env: [:], defaults: values, body) - } - - nonisolated static func tempConfigPath() -> String { - FileManager().temporaryDirectory - .appendingPathComponent("openclaw-test-config-\(UUID().uuidString).json") - .path - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/UtilitiesTests.swift b/apps/macos/Tests/OpenClawIPCTests/UtilitiesTests.swift deleted file mode 100644 index ddeef38dc19..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/UtilitiesTests.swift +++ /dev/null @@ -1,83 +0,0 @@ -import Foundation -import Testing -@testable import OpenClaw - -@Suite(.serialized) struct UtilitiesTests { - @Test func ageStringsCoverCommonWindows() { - let now = Date(timeIntervalSince1970: 1_000_000) - #expect(age(from: now, now: now) == "just now") - #expect(age(from: now.addingTimeInterval(-45), now: now) == "just now") - #expect(age(from: now.addingTimeInterval(-75), now: now) == "1 minute ago") - #expect(age(from: now.addingTimeInterval(-10 * 60), now: now) == "10m ago") - #expect(age(from: now.addingTimeInterval(-3600), now: now) == "1 hour ago") - #expect(age(from: now.addingTimeInterval(-5 * 3600), now: now) == "5h ago") - #expect(age(from: now.addingTimeInterval(-26 * 3600), now: now) == "yesterday") - #expect(age(from: now.addingTimeInterval(-3 * 86400), now: now) == "3d ago") - } - - @Test func parseSSHTargetSupportsUserPortAndDefaults() { - let parsed1 = CommandResolver.parseSSHTarget("alice@example.com:2222") - #expect(parsed1?.user == "alice") - #expect(parsed1?.host == "example.com") - #expect(parsed1?.port == 2222) - - let parsed2 = CommandResolver.parseSSHTarget("example.com") - #expect(parsed2?.user == nil) - #expect(parsed2?.host == "example.com") - #expect(parsed2?.port == 22) - - let parsed3 = CommandResolver.parseSSHTarget("bob@host") - #expect(parsed3?.user == "bob") - #expect(parsed3?.host == "host") - #expect(parsed3?.port == 22) - } - - @Test func sanitizedTargetStripsLeadingSSHPrefix() { - let defaults = UserDefaults(suiteName: "UtilitiesTests.\(UUID().uuidString)")! - defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey) - defaults.set("ssh alice@example.com", forKey: remoteTargetKey) - - let settings = CommandResolver.connectionSettings(defaults: defaults, configRoot: [:]) - #expect(settings.mode == .remote) - #expect(settings.target == "alice@example.com") - } - - @Test func gatewayEntrypointPrefersDistOverBin() throws { - let tmp = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - .appendingPathComponent(UUID().uuidString, isDirectory: true) - let dist = tmp.appendingPathComponent("dist/index.js") - let bin = tmp.appendingPathComponent("bin/openclaw.js") - try FileManager().createDirectory(at: dist.deletingLastPathComponent(), withIntermediateDirectories: true) - try FileManager().createDirectory(at: bin.deletingLastPathComponent(), withIntermediateDirectories: true) - FileManager().createFile(atPath: dist.path, contents: Data()) - FileManager().createFile(atPath: bin.path, contents: Data()) - - let entry = CommandResolver.gatewayEntrypoint(in: tmp) - #expect(entry == dist.path) - } - - @Test func logLocatorPicksNewestLogFile() throws { - let fm = FileManager() - let dir = URL(fileURLWithPath: "/tmp/openclaw", isDirectory: true) - try? fm.createDirectory(at: dir, withIntermediateDirectories: true) - - let older = dir.appendingPathComponent("openclaw-old-\(UUID().uuidString).log") - let newer = dir.appendingPathComponent("openclaw-new-\(UUID().uuidString).log") - fm.createFile(atPath: older.path, contents: Data("old".utf8)) - fm.createFile(atPath: newer.path, contents: Data("new".utf8)) - try fm.setAttributes([.modificationDate: Date(timeIntervalSinceNow: -100)], ofItemAtPath: older.path) - try fm.setAttributes([.modificationDate: Date()], ofItemAtPath: newer.path) - - let best = LogLocator.bestLogFile() - #expect(best?.lastPathComponent == newer.lastPathComponent) - - try? fm.removeItem(at: older) - try? fm.removeItem(at: newer) - } - - @Test func gatewayEntrypointNilWhenMissing() { - let tmp = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - .appendingPathComponent(UUID().uuidString, isDirectory: true) - #expect(CommandResolver.gatewayEntrypoint(in: tmp) == nil) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/VoicePushToTalkHotkeyTests.swift b/apps/macos/Tests/OpenClawIPCTests/VoicePushToTalkHotkeyTests.swift deleted file mode 100644 index 85cd72932fe..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/VoicePushToTalkHotkeyTests.swift +++ /dev/null @@ -1,37 +0,0 @@ -import AppKit -import Testing -@testable import OpenClaw - -@Suite(.serialized) struct VoicePushToTalkHotkeyTests { - actor Counter { - private(set) var began = 0 - private(set) var ended = 0 - - func incBegin() { self.began += 1 } - func incEnd() { self.ended += 1 } - func snapshot() -> (began: Int, ended: Int) { (self.began, self.ended) } - } - - @Test func beginEndFiresOncePerHold() async { - let counter = Counter() - let hotkey = VoicePushToTalkHotkey( - beginAction: { await counter.incBegin() }, - endAction: { await counter.incEnd() }) - - await MainActor.run { - hotkey._testUpdateModifierState(keyCode: 61, modifierFlags: [.option]) - hotkey._testUpdateModifierState(keyCode: 61, modifierFlags: [.option]) - hotkey._testUpdateModifierState(keyCode: 61, modifierFlags: []) - } - - for _ in 0..<50 { - let snap = await counter.snapshot() - if snap.began == 1, snap.ended == 1 { break } - try? await Task.sleep(nanoseconds: 10_000_000) - } - - let snap = await counter.snapshot() - #expect(snap.began == 1) - #expect(snap.ended == 1) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/VoicePushToTalkTests.swift b/apps/macos/Tests/OpenClawIPCTests/VoicePushToTalkTests.swift deleted file mode 100644 index 4a69bfea941..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/VoicePushToTalkTests.swift +++ /dev/null @@ -1,24 +0,0 @@ -import Testing -@testable import OpenClaw - -@Suite struct VoicePushToTalkTests { - @Test func deltaTrimsCommittedPrefix() { - let delta = VoicePushToTalk._testDelta(committed: "hello ", current: "hello world again") - #expect(delta == "world again") - } - - @Test func deltaFallsBackWhenPrefixDiffers() { - let delta = VoicePushToTalk._testDelta(committed: "goodbye", current: "hello world") - #expect(delta == "hello world") - } - - @Test func attributedColorsDifferWhenNotFinal() { - let colors = VoicePushToTalk._testAttributedColors(isFinal: false) - #expect(colors.0 != colors.1) - } - - @Test func attributedColorsMatchWhenFinal() { - let colors = VoicePushToTalk._testAttributedColors(isFinal: true) - #expect(colors.0 == colors.1) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeForwarderTests.swift b/apps/macos/Tests/OpenClawIPCTests/VoiceWakeForwarderTests.swift deleted file mode 100644 index 46971ac314c..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeForwarderTests.swift +++ /dev/null @@ -1,22 +0,0 @@ -import Testing -@testable import OpenClaw - -@Suite(.serialized) struct VoiceWakeForwarderTests { - @Test func prefixedTranscriptUsesMachineName() { - let transcript = "hello world" - let prefixed = VoiceWakeForwarder.prefixedTranscript(transcript, machineName: "My-Mac") - - #expect(prefixed.starts(with: "User talked via voice recognition on")) - #expect(prefixed.contains("My-Mac")) - #expect(prefixed.hasSuffix("\n\nhello world")) - } - - @Test func forwardOptionsDefaults() { - let opts = VoiceWakeForwarder.ForwardOptions() - #expect(opts.sessionKey == "main") - #expect(opts.thinking == "low") - #expect(opts.deliver == true) - #expect(opts.to == nil) - #expect(opts.channel == .last) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeGlobalSettingsSyncTests.swift b/apps/macos/Tests/OpenClawIPCTests/VoiceWakeGlobalSettingsSyncTests.swift deleted file mode 100644 index 9065f6b67c2..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeGlobalSettingsSyncTests.swift +++ /dev/null @@ -1,56 +0,0 @@ -import OpenClawProtocol -import Foundation -import Testing -@testable import OpenClaw - -@Suite(.serialized) struct VoiceWakeGlobalSettingsSyncTests { - @Test func appliesVoiceWakeChangedEventToAppState() async { - let previous = await MainActor.run { AppStateStore.shared.swabbleTriggerWords } - - await MainActor.run { - AppStateStore.shared.applyGlobalVoiceWakeTriggers(["before"]) - } - - let payload = OpenClawProtocol.AnyCodable(["triggers": ["openclaw", "computer"]]) - let evt = EventFrame( - type: "event", - event: "voicewake.changed", - payload: payload, - seq: nil, - stateversion: nil) - - await VoiceWakeGlobalSettingsSync.shared.handle(push: .event(evt)) - - let updated = await MainActor.run { AppStateStore.shared.swabbleTriggerWords } - #expect(updated == ["openclaw", "computer"]) - - await MainActor.run { - AppStateStore.shared.applyGlobalVoiceWakeTriggers(previous) - } - } - - @Test func ignoresVoiceWakeChangedEventWithInvalidPayload() async { - let previous = await MainActor.run { AppStateStore.shared.swabbleTriggerWords } - - await MainActor.run { - AppStateStore.shared.applyGlobalVoiceWakeTriggers(["before"]) - } - - let payload = OpenClawProtocol.AnyCodable(["unexpected": 123]) - let evt = EventFrame( - type: "event", - event: "voicewake.changed", - payload: payload, - seq: nil, - stateversion: nil) - - await VoiceWakeGlobalSettingsSync.shared.handle(push: .event(evt)) - - let updated = await MainActor.run { AppStateStore.shared.swabbleTriggerWords } - #expect(updated == ["before"]) - - await MainActor.run { - AppStateStore.shared.applyGlobalVoiceWakeTriggers(previous) - } - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeHelpersTests.swift b/apps/macos/Tests/OpenClawIPCTests/VoiceWakeHelpersTests.swift deleted file mode 100644 index 20ba7d7c4f5..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeHelpersTests.swift +++ /dev/null @@ -1,35 +0,0 @@ -import Testing -@testable import OpenClaw - -struct VoiceWakeHelpersTests { - @Test func sanitizeTriggersTrimsAndDropsEmpty() { - let cleaned = sanitizeVoiceWakeTriggers([" hi ", " ", "\n", "there"]) - #expect(cleaned == ["hi", "there"]) - } - - @Test func sanitizeTriggersFallsBackToDefaults() { - let cleaned = sanitizeVoiceWakeTriggers([" ", ""]) - #expect(cleaned == defaultVoiceWakeTriggers) - } - - @Test func sanitizeTriggersLimitsWordLength() { - let long = String(repeating: "x", count: voiceWakeMaxWordLength + 5) - let cleaned = sanitizeVoiceWakeTriggers(["ok", long]) - #expect(cleaned[1].count == voiceWakeMaxWordLength) - } - - @Test func sanitizeTriggersLimitsWordCount() { - let words = (1...voiceWakeMaxWords + 3).map { "w\($0)" } - let cleaned = sanitizeVoiceWakeTriggers(words) - #expect(cleaned.count == voiceWakeMaxWords) - } - - @Test func normalizeLocaleStripsCollation() { - #expect(normalizeLocaleIdentifier("en_US@collation=phonebook") == "en_US") - } - - @Test func normalizeLocaleStripsUnicodeExtensions() { - #expect(normalizeLocaleIdentifier("de-DE-u-co-phonebk") == "de-DE") - #expect(normalizeLocaleIdentifier("ja-JP-t-ja") == "ja-JP") - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeOverlayControllerTests.swift b/apps/macos/Tests/OpenClawIPCTests/VoiceWakeOverlayControllerTests.swift deleted file mode 100644 index 5e5636aee89..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeOverlayControllerTests.swift +++ /dev/null @@ -1,68 +0,0 @@ -import Foundation -import Testing -@testable import OpenClaw - -@Suite(.serialized) -@MainActor -struct VoiceWakeOverlayControllerTests { - @Test func overlayControllerLifecycleWithoutUI() async { - let controller = VoiceWakeOverlayController(enableUI: false) - let token = controller.startSession( - source: .wakeWord, - transcript: "hello", - attributed: nil, - forwardEnabled: true, - isFinal: false) - - #expect(controller.snapshot().token == token) - #expect(controller.snapshot().isVisible == true) - - controller.updatePartial(token: token, transcript: "hello world") - #expect(controller.snapshot().text == "hello world") - - controller.updateLevel(token: token, -0.5) - #expect(controller.model.level == 0) - try? await Task.sleep(nanoseconds: 120_000_000) - controller.updateLevel(token: token, 2.0) - #expect(controller.model.level == 1) - - controller.dismiss(token: token, reason: .explicit, outcome: .empty) - #expect(controller.snapshot().isVisible == false) - #expect(controller.snapshot().token == nil) - } - - @Test func evaluateTokenDropsMismatchAndNoActive() { - let active = UUID() - #expect(VoiceWakeOverlayController.evaluateToken(active: nil, incoming: active) == .dropNoActive) - #expect(VoiceWakeOverlayController.evaluateToken(active: active, incoming: UUID()) == .dropMismatch) - #expect(VoiceWakeOverlayController.evaluateToken(active: active, incoming: active) == .accept) - #expect(VoiceWakeOverlayController.evaluateToken(active: active, incoming: nil) == .accept) - } - - @Test func updateLevelThrottlesRapidChanges() async { - let controller = VoiceWakeOverlayController(enableUI: false) - let token = controller.startSession( - source: .wakeWord, - transcript: "level test", - attributed: nil, - forwardEnabled: false, - isFinal: false) - - controller.updateLevel(token: token, 0.25) - let first = controller.model.level - - controller.updateLevel(token: token, 0.9) - #expect(controller.model.level == first) - - controller.updateLevel(token: token, 0) - #expect(controller.model.level == 0) - - try? await Task.sleep(nanoseconds: 120_000_000) - controller.updateLevel(token: token, 0.9) - #expect(controller.model.level == 0.9) - } - - @Test func overlayControllerExercisesHelpers() async { - await VoiceWakeOverlayController.exerciseForTesting() - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeOverlayTests.swift b/apps/macos/Tests/OpenClawIPCTests/VoiceWakeOverlayTests.swift deleted file mode 100644 index 7e8b0a17f70..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeOverlayTests.swift +++ /dev/null @@ -1,21 +0,0 @@ -import Foundation -import Testing -@testable import OpenClaw - -@Suite struct VoiceWakeOverlayTests { - @Test func guardTokenDropsWhenNoActive() { - let outcome = VoiceWakeOverlayController.evaluateToken(active: nil, incoming: UUID()) - #expect(outcome == .dropNoActive) - } - - @Test func guardTokenAcceptsMatching() { - let token = UUID() - let outcome = VoiceWakeOverlayController.evaluateToken(active: token, incoming: token) - #expect(outcome == .accept) - } - - @Test func guardTokenDropsMismatchWithoutDismissing() { - let outcome = VoiceWakeOverlayController.evaluateToken(active: UUID(), incoming: UUID()) - #expect(outcome == .dropMismatch) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeOverlayViewSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/VoiceWakeOverlayViewSmokeTests.swift deleted file mode 100644 index eaec98ab8b8..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeOverlayViewSmokeTests.swift +++ /dev/null @@ -1,28 +0,0 @@ -import SwiftUI -import Testing -@testable import OpenClaw - -@Suite(.serialized) -@MainActor -struct VoiceWakeOverlayViewSmokeTests { - @Test func overlayViewBuildsBodyInDisplayMode() { - let controller = VoiceWakeOverlayController(enableUI: false) - _ = controller.startSession(source: .wakeWord, transcript: "hello", forwardEnabled: true) - let view = VoiceWakeOverlayView(controller: controller) - _ = view.body - } - - @Test func overlayViewBuildsBodyInEditingMode() { - let controller = VoiceWakeOverlayController(enableUI: false) - let token = controller.startSession(source: .pushToTalk, transcript: "edit me", forwardEnabled: true) - controller.userBeganEditing() - controller.updateLevel(token: token, 0.6) - let view = VoiceWakeOverlayView(controller: controller) - _ = view.body - } - - @Test func closeButtonOverlayBuildsBody() { - let view = CloseButtonOverlay(isVisible: true, onHover: { _ in }, onClose: {}) - _ = view.body - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeRuntimeTests.swift b/apps/macos/Tests/OpenClawIPCTests/VoiceWakeRuntimeTests.swift deleted file mode 100644 index 89345914df6..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeRuntimeTests.swift +++ /dev/null @@ -1,91 +0,0 @@ -import Foundation -import SwabbleKit -import Testing -@testable import OpenClaw - -@Suite struct VoiceWakeRuntimeTests { - @Test func trimsAfterTriggerKeepsPostSpeech() { - let triggers = ["claude", "openclaw"] - let text = "hey Claude how are you" - #expect(VoiceWakeRuntime._testTrimmedAfterTrigger(text, triggers: triggers) == "how are you") - } - - @Test func trimsAfterTriggerReturnsOriginalWhenNoTrigger() { - let triggers = ["claude"] - let text = "good morning friend" - #expect(VoiceWakeRuntime._testTrimmedAfterTrigger(text, triggers: triggers) == text) - } - - @Test func trimsAfterFirstMatchingTrigger() { - let triggers = ["buddy", "claude"] - let text = "hello buddy this is after trigger claude also here" - #expect(VoiceWakeRuntime - ._testTrimmedAfterTrigger(text, triggers: triggers) == "this is after trigger claude also here") - } - - @Test func hasContentAfterTriggerFalseWhenOnlyTrigger() { - let triggers = ["openclaw"] - let text = "hey openclaw" - #expect(!VoiceWakeRuntime._testHasContentAfterTrigger(text, triggers: triggers)) - } - - @Test func hasContentAfterTriggerTrueWhenSpeechContinues() { - let triggers = ["claude"] - let text = "claude write a note" - #expect(VoiceWakeRuntime._testHasContentAfterTrigger(text, triggers: triggers)) - } - - @Test func trimsAfterChineseTriggerKeepsPostSpeech() { - let triggers = ["小爪", "openclaw"] - let text = "嘿 小爪 帮我打开设置" - #expect(VoiceWakeRuntime._testTrimmedAfterTrigger(text, triggers: triggers) == "帮我打开设置") - } - - @Test func trimsAfterTriggerHandlesWidthInsensitiveForms() { - let triggers = ["openclaw"] - let text = "OpenClaw 请帮我" - #expect(VoiceWakeRuntime._testTrimmedAfterTrigger(text, triggers: triggers) == "请帮我") - } - - @Test func gateRequiresGapBetweenTriggerAndCommand() { - let transcript = "hey openclaw do thing" - let segments = makeSegments( - transcript: transcript, - words: [ - ("hey", 0.0, 0.1), - ("openclaw", 0.2, 0.1), - ("do", 0.35, 0.1), - ("thing", 0.5, 0.1), - ]) - let config = WakeWordGateConfig(triggers: ["openclaw"], minPostTriggerGap: 0.3) - #expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config) == nil) - } - - @Test func gateAcceptsGapAndExtractsCommand() { - let transcript = "hey openclaw do thing" - let segments = makeSegments( - transcript: transcript, - words: [ - ("hey", 0.0, 0.1), - ("openclaw", 0.2, 0.1), - ("do", 0.9, 0.1), - ("thing", 1.1, 0.1), - ]) - let config = WakeWordGateConfig(triggers: ["openclaw"], minPostTriggerGap: 0.3) - #expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config)?.command == "do thing") - } -} - -private func makeSegments( - transcript: String, - words: [(String, TimeInterval, TimeInterval)]) --> [WakeWordSegment] { - var searchStart = transcript.startIndex - var output: [WakeWordSegment] = [] - for (word, start, duration) in words { - let range = transcript.range(of: word, range: searchStart.. [WakeWordSegment] { - var searchStart = transcript.startIndex - var output: [WakeWordSegment] = [] - for (word, start, duration) in words { - let range = transcript.range(of: word, range: searchStart.. OpenClawChatHistoryPayload { - let json = """ - {"sessionKey":"\(sessionKey)","sessionId":null,"messages":[],"thinkingLevel":"off"} - """ - return try JSONDecoder().decode(OpenClawChatHistoryPayload.self, from: Data(json.utf8)) - } - - func sendMessage( - sessionKey _: String, - message _: String, - thinking _: String, - idempotencyKey _: String, - attachments _: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse - { - let json = """ - {"runId":"\(UUID().uuidString)","status":"ok"} - """ - return try JSONDecoder().decode(OpenClawChatSendResponse.self, from: Data(json.utf8)) - } - - func requestHealth(timeoutMs _: Int) async throws -> Bool { true } - - func events() -> AsyncStream { - AsyncStream { continuation in - continuation.finish() - } - } - - func setActiveSessionKey(_: String) async throws {} - } - - @Test func windowControllerShowAndClose() { - let controller = WebChatSwiftUIWindowController( - sessionKey: "main", - presentation: .window, - transport: TestTransport()) - controller.show() - controller.close() - } - - @Test func panelControllerPresentAndClose() { - let anchor = { NSRect(x: 200, y: 400, width: 40, height: 40) } - let controller = WebChatSwiftUIWindowController( - sessionKey: "main", - presentation: .panel(anchorProvider: anchor), - transport: TestTransport()) - controller.presentAnchored(anchorProvider: anchor) - controller.close() - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/WideAreaGatewayDiscoveryTests.swift b/apps/macos/Tests/OpenClawIPCTests/WideAreaGatewayDiscoveryTests.swift deleted file mode 100644 index 24644a2f108..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/WideAreaGatewayDiscoveryTests.swift +++ /dev/null @@ -1,51 +0,0 @@ -import Darwin -import Testing -@testable import OpenClawDiscovery - -@Suite -struct WideAreaGatewayDiscoveryTests { - @Test func discoversBeaconFromTailnetDnsSdFallback() { - setenv("OPENCLAW_WIDE_AREA_DOMAIN", "openclaw.internal", 1) - let statusJson = """ - { - "Self": { "TailscaleIPs": ["100.69.232.64"] }, - "Peer": { - "peer-1": { "TailscaleIPs": ["100.123.224.76"] } - } - } - """ - - let context = WideAreaGatewayDiscovery.DiscoveryContext( - tailscaleStatus: { statusJson }, - dig: { args, _ in - let recordType = args.last ?? "" - let nameserver = args.first(where: { $0.hasPrefix("@") }) ?? "" - if recordType == "PTR" { - if nameserver == "@100.123.224.76" { - return "steipetacstudio-gateway._openclaw-gw._tcp.openclaw.internal.\n" - } - return "" - } - if recordType == "SRV" { - return "0 0 18789 steipetacstudio.openclaw.internal." - } - if recordType == "TXT" { - return "\"displayName=Peter\\226\\128\\153s Mac Studio (OpenClaw)\" \"gatewayPort=18789\" \"tailnetDns=peters-mac-studio-1.sheep-coho.ts.net\" \"cliPath=/Users/steipete/openclaw/src/entry.ts\"" - } - return "" - }) - - let beacons = WideAreaGatewayDiscovery.discover( - timeoutSeconds: 2.0, - context: context) - - #expect(beacons.count == 1) - let beacon = beacons[0] - let expectedDisplay = "Peter\u{2019}s Mac Studio (OpenClaw)" - #expect(beacon.displayName == expectedDisplay) - #expect(beacon.port == 18789) - #expect(beacon.gatewayPort == 18789) - #expect(beacon.tailnetDns == "peters-mac-studio-1.sheep-coho.ts.net") - #expect(beacon.cliPath == "/Users/steipete/openclaw/src/entry.ts") - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/WindowPlacementTests.swift b/apps/macos/Tests/OpenClawIPCTests/WindowPlacementTests.swift deleted file mode 100644 index 0afd3eb5b88..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/WindowPlacementTests.swift +++ /dev/null @@ -1,85 +0,0 @@ -import AppKit -import Testing -@testable import OpenClaw - -@Suite -@MainActor -struct WindowPlacementTests { - @Test - func centeredFrameZeroBoundsFallsBackToOrigin() { - let frame = WindowPlacement.centeredFrame(size: NSSize(width: 120, height: 80), in: NSRect.zero) - #expect(frame.origin == .zero) - #expect(frame.size == NSSize(width: 120, height: 80)) - } - - @Test - func centeredFrameClampsToBoundsAndCenters() { - let bounds = NSRect(x: 10, y: 20, width: 300, height: 200) - let frame = WindowPlacement.centeredFrame(size: NSSize(width: 600, height: 120), in: bounds) - #expect(frame.size.width == bounds.width) - #expect(frame.size.height == 120) - #expect(frame.minX == bounds.minX) - #expect(frame.midY == bounds.midY) - } - - @Test - func topRightFrameZeroBoundsFallsBackToOrigin() { - let frame = WindowPlacement.topRightFrame( - size: NSSize(width: 120, height: 80), - padding: 12, - in: NSRect.zero) - #expect(frame.origin == .zero) - #expect(frame.size == NSSize(width: 120, height: 80)) - } - - @Test - func topRightFrameClampsToBoundsAndAppliesPadding() { - let bounds = NSRect(x: 10, y: 20, width: 300, height: 200) - let frame = WindowPlacement.topRightFrame( - size: NSSize(width: 400, height: 50), - padding: 8, - in: bounds) - #expect(frame.size.width == bounds.width) - #expect(frame.size.height == 50) - #expect(frame.maxX == bounds.maxX - 8) - #expect(frame.maxY == bounds.maxY - 8) - } - - @Test - func ensureOnScreenUsesFallbackWhenWindowOffscreen() { - let window = NSWindow( - contentRect: NSRect(x: 100_000, y: 100_000, width: 200, height: 120), - styleMask: [.borderless], - backing: .buffered, - defer: false) - - WindowPlacement.ensureOnScreen( - window: window, - defaultSize: NSSize(width: 200, height: 120), - fallback: { _ in NSRect(x: 11, y: 22, width: 33, height: 44) }) - - #expect(window.frame == NSRect(x: 11, y: 22, width: 33, height: 44)) - } - - @Test - func ensureOnScreenDoesNotMoveVisibleWindow() { - let screen = NSScreen.main ?? NSScreen.screens.first - #expect(screen != nil) - guard let screen else { return } - - let visible = screen.visibleFrame.insetBy(dx: 40, dy: 40) - let window = NSWindow( - contentRect: NSRect(x: visible.minX, y: visible.minY, width: 200, height: 120), - styleMask: [.titled], - backing: .buffered, - defer: false) - let original = window.frame - - WindowPlacement.ensureOnScreen( - window: window, - defaultSize: NSSize(width: 200, height: 120), - fallback: { _ in NSRect(x: 11, y: 22, width: 33, height: 44) }) - - #expect(window.frame == original) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/WorkActivityStoreTests.swift b/apps/macos/Tests/OpenClawIPCTests/WorkActivityStoreTests.swift deleted file mode 100644 index 7882706430d..00000000000 --- a/apps/macos/Tests/OpenClawIPCTests/WorkActivityStoreTests.swift +++ /dev/null @@ -1,99 +0,0 @@ -import OpenClawProtocol -import Foundation -import Testing -@testable import OpenClaw - -@Suite -@MainActor -struct WorkActivityStoreTests { - @Test func mainSessionJobPreemptsOther() { - let store = WorkActivityStore() - - store.handleJob(sessionKey: "discord:group:1", state: "started") - #expect(store.iconState == .workingOther(.job)) - #expect(store.current?.sessionKey == "discord:group:1") - - store.handleJob(sessionKey: "main", state: "started") - #expect(store.iconState == .workingMain(.job)) - #expect(store.current?.sessionKey == "main") - - store.handleJob(sessionKey: "main", state: "finished") - #expect(store.iconState == .workingOther(.job)) - #expect(store.current?.sessionKey == "discord:group:1") - - store.handleJob(sessionKey: "discord:group:1", state: "finished") - #expect(store.iconState == .idle) - #expect(store.current == nil) - } - - @Test func jobStaysWorkingAfterToolResultGrace() async { - let store = WorkActivityStore() - - store.handleJob(sessionKey: "main", state: "started") - #expect(store.iconState == .workingMain(.job)) - - store.handleTool( - sessionKey: "main", - phase: "start", - name: "read", - meta: nil, - args: ["path": AnyCodable("/tmp/file.txt")]) - #expect(store.iconState == .workingMain(.tool(.read))) - - store.handleTool( - sessionKey: "main", - phase: "result", - name: "read", - meta: nil, - args: ["path": AnyCodable("/tmp/file.txt")]) - - for _ in 0..<50 { - if store.iconState == .workingMain(.job) { break } - try? await Task.sleep(nanoseconds: 100_000_000) - } - #expect(store.iconState == .workingMain(.job)) - - store.handleJob(sessionKey: "main", state: "done") - #expect(store.iconState == .idle) - } - - @Test func toolLabelExtractsFirstLineAndShortensHome() { - let store = WorkActivityStore() - let home = NSHomeDirectory() - - store.handleTool( - sessionKey: "main", - phase: "start", - name: "bash", - meta: nil, - args: [ - "command": AnyCodable("echo hi\necho bye"), - "path": AnyCodable("\(home)/Projects/openclaw"), - ]) - - #expect(store.current?.label == "bash: echo hi") - #expect(store.iconState == .workingMain(.tool(.bash))) - - store.handleTool( - sessionKey: "main", - phase: "start", - name: "read", - meta: nil, - args: ["path": AnyCodable("\(home)/secret.txt")]) - - #expect(store.current?.label == "read: ~/secret.txt") - #expect(store.iconState == .workingMain(.tool(.read))) - } - - @Test func resolveIconStateHonorsOverrideSelection() { - let store = WorkActivityStore() - store.handleJob(sessionKey: "main", state: "started") - #expect(store.iconState == .workingMain(.job)) - - store.resolveIconState(override: .idle) - #expect(store.iconState == .idle) - - store.resolveIconState(override: .otherEdit) - #expect(store.iconState == .overridden(.tool(.edit))) - } -} diff --git a/apps/shared/OpenClawKit/Package.swift b/apps/shared/OpenClawKit/Package.swift deleted file mode 100644 index 5c8132d2c9b..00000000000 --- a/apps/shared/OpenClawKit/Package.swift +++ /dev/null @@ -1,61 +0,0 @@ -// swift-tools-version: 6.2 - -import PackageDescription - -let package = Package( - name: "OpenClawKit", - platforms: [ - .iOS(.v18), - .macOS(.v15), - ], - products: [ - .library(name: "OpenClawProtocol", targets: ["OpenClawProtocol"]), - .library(name: "OpenClawKit", targets: ["OpenClawKit"]), - .library(name: "OpenClawChatUI", targets: ["OpenClawChatUI"]), - ], - dependencies: [ - .package(url: "https://github.com/steipete/ElevenLabsKit", exact: "0.1.0"), - .package(url: "https://github.com/gonzalezreal/textual", exact: "0.3.1"), - ], - targets: [ - .target( - name: "OpenClawProtocol", - path: "Sources/OpenClawProtocol", - swiftSettings: [ - .enableUpcomingFeature("StrictConcurrency"), - ]), - .target( - name: "OpenClawKit", - dependencies: [ - "OpenClawProtocol", - .product(name: "ElevenLabsKit", package: "ElevenLabsKit"), - ], - path: "Sources/OpenClawKit", - resources: [ - .process("Resources"), - ], - swiftSettings: [ - .enableUpcomingFeature("StrictConcurrency"), - ]), - .target( - name: "OpenClawChatUI", - dependencies: [ - "OpenClawKit", - .product( - name: "Textual", - package: "textual", - condition: .when(platforms: [.macOS, .iOS])), - ], - path: "Sources/OpenClawChatUI", - swiftSettings: [ - .enableUpcomingFeature("StrictConcurrency"), - ]), - .testTarget( - name: "OpenClawKitTests", - dependencies: ["OpenClawKit", "OpenClawChatUI"], - path: "Tests/OpenClawKitTests", - swiftSettings: [ - .enableUpcomingFeature("StrictConcurrency"), - .enableExperimentalFeature("SwiftTesting"), - ]), - ]) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/AssistantTextParser.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/AssistantTextParser.swift deleted file mode 100644 index c4395adfaea..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/AssistantTextParser.swift +++ /dev/null @@ -1,139 +0,0 @@ -import Foundation - -struct AssistantTextSegment: Identifiable { - enum Kind { - case thinking - case response - } - - let id = UUID() - let kind: Kind - let text: String -} - -enum AssistantTextParser { - static func segments(from raw: String) -> [AssistantTextSegment] { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return [] } - guard raw.contains("<") else { - return [AssistantTextSegment(kind: .response, text: trimmed)] - } - - var segments: [AssistantTextSegment] = [] - var cursor = raw.startIndex - var currentKind: AssistantTextSegment.Kind = .response - var matchedTag = false - - while let match = self.nextTag(in: raw, from: cursor) { - matchedTag = true - if match.range.lowerBound > cursor { - self.appendSegment(kind: currentKind, text: raw[cursor..", range: match.range.upperBound.. Bool { - !self.segments(from: raw).isEmpty - } - - private enum TagKind { - case think - case final - } - - private struct TagMatch { - let kind: TagKind - let closing: Bool - let range: Range - } - - private static func nextTag(in text: String, from start: String.Index) -> TagMatch? { - let candidates: [TagMatch] = [ - self.findTagStart(tag: "think", closing: false, in: text, from: start).map { - TagMatch(kind: .think, closing: false, range: $0) - }, - self.findTagStart(tag: "think", closing: true, in: text, from: start).map { - TagMatch(kind: .think, closing: true, range: $0) - }, - self.findTagStart(tag: "final", closing: false, in: text, from: start).map { - TagMatch(kind: .final, closing: false, range: $0) - }, - self.findTagStart(tag: "final", closing: true, in: text, from: start).map { - TagMatch(kind: .final, closing: true, range: $0) - }, - ].compactMap(\.self) - - return candidates.min { $0.range.lowerBound < $1.range.lowerBound } - } - - private static func findTagStart( - tag: String, - closing: Bool, - in text: String, - from start: String.Index) -> Range? - { - let token = closing ? "" || boundary.isWhitespace || (!closing && boundary == "/") - if isBoundary { - return range - } - searchRange = boundaryIndex..) -> Bool { - var cursor = tagEnd.lowerBound - while cursor > text.startIndex { - cursor = text.index(before: cursor) - let char = text[cursor] - if char.isWhitespace { continue } - return char == "/" - } - return false - } - - private static func appendSegment( - kind: AssistantTextSegment.Kind, - text: Substring, - to segments: inout [AssistantTextSegment]) - { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - segments.append(AssistantTextSegment(kind: kind, text: trimmed)) - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift deleted file mode 100644 index 145e17f3b7b..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift +++ /dev/null @@ -1,499 +0,0 @@ -import Foundation -import Observation -import SwiftUI - -#if !os(macOS) -import PhotosUI -import UniformTypeIdentifiers -#endif - -@MainActor -struct OpenClawChatComposer: View { - @Bindable var viewModel: OpenClawChatViewModel - let style: OpenClawChatView.Style - let showsSessionSwitcher: Bool - - #if !os(macOS) - @State private var pickerItems: [PhotosPickerItem] = [] - @FocusState private var isFocused: Bool - #else - @State private var shouldFocusTextView = false - #endif - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - if self.showsToolbar { - HStack(spacing: 6) { - if self.showsSessionSwitcher { - self.sessionPicker - } - self.thinkingPicker - Spacer() - self.refreshButton - self.attachmentPicker - } - } - - if self.showsAttachments, !self.viewModel.attachments.isEmpty { - self.attachmentsStrip - } - - self.editor - } - .padding(self.composerPadding) - .background { - let cornerRadius: CGFloat = 18 - - #if os(macOS) - if self.style == .standard { - let shape = UnevenRoundedRectangle( - cornerRadii: RectangleCornerRadii( - topLeading: 0, - bottomLeading: cornerRadius, - bottomTrailing: cornerRadius, - topTrailing: 0), - style: .continuous) - shape - .fill(OpenClawChatTheme.composerBackground) - .overlay(shape.strokeBorder(OpenClawChatTheme.composerBorder, lineWidth: 1)) - .shadow(color: .black.opacity(0.12), radius: 12, y: 6) - } else { - let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - shape - .fill(OpenClawChatTheme.composerBackground) - .overlay(shape.strokeBorder(OpenClawChatTheme.composerBorder, lineWidth: 1)) - .shadow(color: .black.opacity(0.12), radius: 12, y: 6) - } - #else - let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - shape - .fill(OpenClawChatTheme.composerBackground) - .overlay(shape.strokeBorder(OpenClawChatTheme.composerBorder, lineWidth: 1)) - .shadow(color: .black.opacity(0.12), radius: 12, y: 6) - #endif - } - #if os(macOS) - .onDrop(of: [.fileURL], isTargeted: nil) { providers in - self.handleDrop(providers) - } - .onAppear { - self.shouldFocusTextView = true - } - #endif - } - - private var thinkingPicker: some View { - Picker("Thinking", selection: self.$viewModel.thinkingLevel) { - Text("Off").tag("off") - Text("Low").tag("low") - Text("Medium").tag("medium") - Text("High").tag("high") - } - .labelsHidden() - .pickerStyle(.menu) - .controlSize(.small) - .frame(maxWidth: 140, alignment: .leading) - } - - private var sessionPicker: some View { - Picker( - "Session", - selection: Binding( - get: { self.viewModel.sessionKey }, - set: { next in self.viewModel.switchSession(to: next) })) - { - ForEach(self.viewModel.sessionChoices, id: \.key) { session in - Text(session.displayName ?? session.key) - .font(.system(.caption, design: .monospaced)) - .tag(session.key) - } - } - .labelsHidden() - .pickerStyle(.menu) - .controlSize(.small) - .frame(maxWidth: 160, alignment: .leading) - .help("Session") - } - - @ViewBuilder - private var attachmentPicker: some View { - #if os(macOS) - Button { - self.pickFilesMac() - } label: { - Image(systemName: "paperclip") - } - .help("Add Image") - .buttonStyle(.bordered) - .controlSize(.small) - #else - PhotosPicker(selection: self.$pickerItems, maxSelectionCount: 8, matching: .images) { - Image(systemName: "paperclip") - } - .help("Add Image") - .buttonStyle(.bordered) - .controlSize(.small) - .onChange(of: self.pickerItems) { _, newItems in - Task { await self.loadPhotosPickerItems(newItems) } - } - #endif - } - - private var attachmentsStrip: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 6) { - ForEach( - self.viewModel.attachments, - id: \OpenClawPendingAttachment.id) - { (att: OpenClawPendingAttachment) in - HStack(spacing: 6) { - if let img = att.preview { - OpenClawPlatformImageFactory.image(img) - .resizable() - .scaledToFill() - .frame(width: 22, height: 22) - .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) - } else { - Image(systemName: "photo") - } - - Text(att.fileName) - .lineLimit(1) - - Button { - self.viewModel.removeAttachment(att.id) - } label: { - Image(systemName: "xmark.circle.fill") - } - .buttonStyle(.plain) - } - .padding(.horizontal, 8) - .padding(.vertical, 5) - .background(Color.accentColor.opacity(0.08)) - .clipShape(Capsule()) - } - } - } - } - - private var editor: some View { - VStack(alignment: .leading, spacing: 8) { - self.editorOverlay - - if !self.isComposerCompacted { - Rectangle() - .fill(OpenClawChatTheme.divider) - .frame(height: 1) - .padding(.horizontal, 2) - } - - HStack(alignment: .center, spacing: 8) { - if self.showsConnectionPill { - self.connectionPill - } - Spacer(minLength: 0) - self.sendButton - } - } - .padding(.horizontal, 10) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(OpenClawChatTheme.composerField) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder(OpenClawChatTheme.composerBorder))) - .padding(self.editorPadding) - } - - private var connectionPill: some View { - HStack(spacing: 6) { - Circle() - .fill(self.viewModel.healthOK ? .green : .orange) - .frame(width: 7, height: 7) - Text(self.activeSessionLabel) - .font(.caption2.weight(.semibold)) - Text(self.viewModel.healthOK ? "Connected" : "Connecting…") - .font(.caption2) - .foregroundStyle(.secondary) - } - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(OpenClawChatTheme.subtleCard) - .clipShape(Capsule()) - } - - private var activeSessionLabel: String { - let match = self.viewModel.sessions.first { $0.key == self.viewModel.sessionKey } - let trimmed = match?.displayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return trimmed.isEmpty ? self.viewModel.sessionKey : trimmed - } - - private var editorOverlay: some View { - ZStack(alignment: .topLeading) { - if self.viewModel.input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - Text("Message OpenClaw…") - .foregroundStyle(.tertiary) - .padding(.horizontal, 4) - .padding(.vertical, 4) - } - - #if os(macOS) - ChatComposerTextView(text: self.$viewModel.input, shouldFocus: self.$shouldFocusTextView) { - self.viewModel.send() - } - .frame(minHeight: self.textMinHeight, idealHeight: self.textMinHeight, maxHeight: self.textMaxHeight) - .padding(.horizontal, 4) - .padding(.vertical, 3) - #else - TextEditor(text: self.$viewModel.input) - .font(.system(size: 15)) - .scrollContentBackground(.hidden) - .frame( - minHeight: self.textMinHeight, - idealHeight: self.textMinHeight, - maxHeight: self.textMaxHeight) - .padding(.horizontal, 4) - .padding(.vertical, 4) - .focused(self.$isFocused) - #endif - } - } - - private var sendButton: some View { - Group { - if self.viewModel.pendingRunCount > 0 { - Button { - self.viewModel.abort() - } label: { - if self.viewModel.isAborting { - ProgressView().controlSize(.mini) - } else { - Image(systemName: "stop.fill") - .font(.system(size: 13, weight: .semibold)) - } - } - .buttonStyle(.plain) - .foregroundStyle(.white) - .padding(6) - .background(Circle().fill(Color.red)) - .disabled(self.viewModel.isAborting) - } else { - Button { - self.viewModel.send() - } label: { - if self.viewModel.isSending { - ProgressView().controlSize(.mini) - } else { - Image(systemName: "arrow.up") - .font(.system(size: 13, weight: .semibold)) - } - } - .buttonStyle(.plain) - .foregroundStyle(.white) - .padding(6) - .background(Circle().fill(Color.accentColor)) - .disabled(!self.viewModel.canSend) - } - } - } - - private var refreshButton: some View { - Button { - self.viewModel.refresh() - } label: { - Image(systemName: "arrow.clockwise") - } - .buttonStyle(.bordered) - .controlSize(.small) - .help("Refresh") - } - - private var showsToolbar: Bool { - self.style == .standard && !self.isComposerCompacted - } - - private var showsAttachments: Bool { - self.style == .standard - } - - private var showsConnectionPill: Bool { - self.style == .standard && !self.isComposerCompacted - } - - private var composerPadding: CGFloat { - self.style == .onboarding ? 5 : (self.isComposerCompacted ? 4 : 6) - } - - private var editorPadding: CGFloat { - self.style == .onboarding ? 5 : (self.isComposerCompacted ? 4 : 6) - } - - private var textMinHeight: CGFloat { - self.style == .onboarding ? 24 : 28 - } - - private var textMaxHeight: CGFloat { - self.style == .onboarding ? 52 : 64 - } - - private var isComposerCompacted: Bool { - #if os(macOS) - false - #else - self.style == .standard && self.isFocused - #endif - } - - #if os(macOS) - private func pickFilesMac() { - let panel = NSOpenPanel() - panel.title = "Select image attachments" - panel.allowsMultipleSelection = true - panel.canChooseDirectories = false - panel.allowedContentTypes = [.image] - panel.begin { resp in - guard resp == .OK else { return } - self.viewModel.addAttachments(urls: panel.urls) - } - } - - private func handleDrop(_ providers: [NSItemProvider]) -> Bool { - let fileProviders = providers.filter { $0.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) } - guard !fileProviders.isEmpty else { return false } - for item in fileProviders { - item.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, _ in - guard let data = item as? Data, - let url = URL(dataRepresentation: data, relativeTo: nil) - else { return } - Task { @MainActor in - self.viewModel.addAttachments(urls: [url]) - } - } - } - return true - } - #else - private func loadPhotosPickerItems(_ items: [PhotosPickerItem]) async { - for item in items { - do { - guard let data = try await item.loadTransferable(type: Data.self) else { continue } - let type = item.supportedContentTypes.first ?? .image - let ext = type.preferredFilenameExtension ?? "jpg" - let mime = type.preferredMIMEType ?? "image/jpeg" - let name = "photo-\(UUID().uuidString.prefix(8)).\(ext)" - self.viewModel.addImageAttachment(data: data, fileName: name, mimeType: mime) - } catch { - self.viewModel.errorText = error.localizedDescription - } - } - self.pickerItems = [] - } - #endif -} - -#if os(macOS) -import AppKit -import UniformTypeIdentifiers - -private struct ChatComposerTextView: NSViewRepresentable { - @Binding var text: String - @Binding var shouldFocus: Bool - var onSend: () -> Void - - func makeCoordinator() -> Coordinator { Coordinator(self) } - - func makeNSView(context: Context) -> NSScrollView { - let textView = ChatComposerNSTextView() - textView.delegate = context.coordinator - textView.drawsBackground = false - textView.isRichText = false - textView.isAutomaticQuoteSubstitutionEnabled = false - textView.isAutomaticTextReplacementEnabled = false - textView.isAutomaticDashSubstitutionEnabled = false - textView.isAutomaticSpellingCorrectionEnabled = false - textView.font = .systemFont(ofSize: 14, weight: .regular) - textView.textContainer?.lineBreakMode = .byWordWrapping - textView.textContainer?.lineFragmentPadding = 0 - textView.textContainerInset = NSSize(width: 2, height: 4) - textView.focusRingType = .none - - textView.minSize = .zero - textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) - textView.isHorizontallyResizable = false - textView.isVerticallyResizable = true - textView.autoresizingMask = [.width] - textView.textContainer?.containerSize = NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude) - textView.textContainer?.widthTracksTextView = true - - textView.string = self.text - textView.onSend = { [weak textView] in - textView?.window?.makeFirstResponder(nil) - self.onSend() - } - - let scroll = NSScrollView() - scroll.drawsBackground = false - scroll.borderType = .noBorder - scroll.hasVerticalScroller = true - scroll.autohidesScrollers = true - scroll.scrollerStyle = .overlay - scroll.hasHorizontalScroller = false - scroll.documentView = textView - return scroll - } - - func updateNSView(_ scrollView: NSScrollView, context: Context) { - guard let textView = scrollView.documentView as? ChatComposerNSTextView else { return } - - if self.shouldFocus, let window = scrollView.window { - window.makeFirstResponder(textView) - self.shouldFocus = false - } - - let isEditing = scrollView.window?.firstResponder == textView - - // Always allow clearing the text (e.g. after send), even while editing. - // Only skip other updates while editing to avoid cursor jumps. - let shouldClear = self.text.isEmpty && !textView.string.isEmpty - if isEditing, !shouldClear { return } - - if textView.string != self.text { - context.coordinator.isProgrammaticUpdate = true - defer { context.coordinator.isProgrammaticUpdate = false } - textView.string = self.text - } - } - - final class Coordinator: NSObject, NSTextViewDelegate { - var parent: ChatComposerTextView - var isProgrammaticUpdate = false - - init(_ parent: ChatComposerTextView) { self.parent = parent } - - func textDidChange(_ notification: Notification) { - guard !self.isProgrammaticUpdate else { return } - guard let view = notification.object as? NSTextView else { return } - guard view.window?.firstResponder === view else { return } - self.parent.text = view.string - } - } -} - -private final class ChatComposerNSTextView: NSTextView { - var onSend: (() -> Void)? - - override func keyDown(with event: NSEvent) { - let isReturn = event.keyCode == 36 - if isReturn { - if event.modifierFlags.contains(.shift) { - super.insertNewline(nil) - return - } - self.onSend?() - return - } - super.keyDown(with: event) - } -} -#endif diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownPreprocessor.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownPreprocessor.swift deleted file mode 100644 index a96e288d7f4..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownPreprocessor.swift +++ /dev/null @@ -1,123 +0,0 @@ -import Foundation - -enum ChatMarkdownPreprocessor { - // Keep in sync with `src/auto-reply/reply/strip-inbound-meta.ts` - // (`INBOUND_META_SENTINELS`), and extend parser expectations in - // `ChatMarkdownPreprocessorTests` when sentinels change. - private static let inboundContextHeaders = [ - "Conversation info (untrusted metadata):", - "Sender (untrusted metadata):", - "Thread starter (untrusted, for context):", - "Replied message (untrusted, for context):", - "Forwarded message context (untrusted metadata):", - "Chat history since last reply (untrusted, for context):", - ] - - struct InlineImage: Identifiable { - let id = UUID() - let label: String - let image: OpenClawPlatformImage? - } - - struct Result { - let cleaned: String - let images: [InlineImage] - } - - static func preprocess(markdown raw: String) -> Result { - let withoutContextBlocks = self.stripInboundContextBlocks(raw) - let withoutTimestamps = self.stripPrefixedTimestamps(withoutContextBlocks) - let pattern = #"!\[([^\]]*)\]\((data:image\/[^;]+;base64,[^)]+)\)"# - guard let re = try? NSRegularExpression(pattern: pattern) else { - return Result(cleaned: self.normalize(withoutTimestamps), images: []) - } - - let ns = withoutTimestamps as NSString - let matches = re.matches( - in: withoutTimestamps, - range: NSRange(location: 0, length: ns.length)) - if matches.isEmpty { return Result(cleaned: self.normalize(withoutTimestamps), images: []) } - - var images: [InlineImage] = [] - var cleaned = withoutTimestamps - - for match in matches.reversed() { - guard match.numberOfRanges >= 3 else { continue } - let label = ns.substring(with: match.range(at: 1)) - let dataURL = ns.substring(with: match.range(at: 2)) - - let image: OpenClawPlatformImage? = { - guard let comma = dataURL.firstIndex(of: ",") else { return nil } - let b64 = String(dataURL[dataURL.index(after: comma)...]) - guard let data = Data(base64Encoded: b64) else { return nil } - return OpenClawPlatformImage(data: data) - }() - images.append(InlineImage(label: label, image: image)) - - let start = cleaned.index(cleaned.startIndex, offsetBy: match.range.location) - let end = cleaned.index(start, offsetBy: match.range.length) - cleaned.replaceSubrange(start.. String { - guard self.inboundContextHeaders.contains(where: raw.contains) else { - return raw - } - - let normalized = raw.replacingOccurrences(of: "\r\n", with: "\n") - var outputLines: [String] = [] - var inMetaBlock = false - var inFencedJson = false - - for line in normalized.split(separator: "\n", omittingEmptySubsequences: false) { - let currentLine = String(line) - - if !inMetaBlock && self.inboundContextHeaders.contains(where: currentLine.hasPrefix) { - inMetaBlock = true - inFencedJson = false - continue - } - - if inMetaBlock { - if !inFencedJson && currentLine.trimmingCharacters(in: .whitespacesAndNewlines) == "```json" { - inFencedJson = true - continue - } - - if inFencedJson { - if currentLine.trimmingCharacters(in: .whitespacesAndNewlines) == "```" { - inMetaBlock = false - inFencedJson = false - } - continue - } - - if currentLine.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - continue - } - - inMetaBlock = false - } - - outputLines.append(currentLine) - } - - return outputLines.joined(separator: "\n").replacingOccurrences(of: #"^\n+"#, with: "", options: .regularExpression) - } - - private static func stripPrefixedTimestamps(_ raw: String) -> String { - let pattern = #"(?m)^\[[A-Za-z]{3}\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}(?::\d{2})?\s+(?:GMT|UTC)[+-]?\d{0,2}\]\s*"# - return raw.replacingOccurrences(of: pattern, with: "", options: .regularExpression) - } - - private static func normalize(_ raw: String) -> String { - var output = raw - output = output.replacingOccurrences(of: "\r\n", with: "\n") - output = output.replacingOccurrences(of: "\n\n\n", with: "\n\n") - output = output.replacingOccurrences(of: "\n\n\n", with: "\n\n") - return output.trimmingCharacters(in: .whitespacesAndNewlines) - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift deleted file mode 100644 index e68c8591bcf..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift +++ /dev/null @@ -1,90 +0,0 @@ -import SwiftUI -import Textual - -public enum ChatMarkdownVariant: String, CaseIterable, Sendable { - case standard - case compact -} - -@MainActor -struct ChatMarkdownRenderer: View { - enum Context { - case user - case assistant - } - - let text: String - let context: Context - let variant: ChatMarkdownVariant - let font: Font - let textColor: Color - - var body: some View { - let processed = ChatMarkdownPreprocessor.preprocess(markdown: self.text) - VStack(alignment: .leading, spacing: 10) { - StructuredText(markdown: processed.cleaned) - .modifier(ChatMarkdownStyle( - variant: self.variant, - context: self.context, - font: self.font, - textColor: self.textColor)) - - if !processed.images.isEmpty { - InlineImageList(images: processed.images) - } - } - } -} - -private struct ChatMarkdownStyle: ViewModifier { - let variant: ChatMarkdownVariant - let context: ChatMarkdownRenderer.Context - let font: Font - let textColor: Color - - func body(content: Content) -> some View { - Group { - if self.variant == .compact { - content.textual.structuredTextStyle(.default) - } else { - content.textual.structuredTextStyle(.gitHub) - } - } - .font(self.font) - .foregroundStyle(self.textColor) - .textual.inlineStyle(self.inlineStyle) - .textual.textSelection(.enabled) - } - - private var inlineStyle: InlineStyle { - let linkColor: Color = self.context == .user ? self.textColor : .accentColor - let codeScale: CGFloat = self.variant == .compact ? 0.85 : 0.9 - return InlineStyle() - .code(.monospaced, .fontScale(codeScale)) - .link(.foregroundColor(linkColor)) - } -} - -@MainActor -private struct InlineImageList: View { - let images: [ChatMarkdownPreprocessor.InlineImage] - - var body: some View { - ForEach(images, id: \.id) { item in - if let img = item.image { - OpenClawPlatformImageFactory.image(img) - .resizable() - .scaledToFit() - .frame(maxHeight: 260) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder(Color.white.opacity(0.12), lineWidth: 1)) - } else { - Text(item.label.isEmpty ? "Image" : item.label) - .font(.footnote) - .foregroundStyle(.secondary) - } - } - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift deleted file mode 100644 index 22f28517d64..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift +++ /dev/null @@ -1,620 +0,0 @@ -import OpenClawKit -import Foundation -import SwiftUI - -private enum ChatUIConstants { - static let bubbleMaxWidth: CGFloat = 560 - static let bubbleCorner: CGFloat = 18 -} - -private struct ChatBubbleShape: InsettableShape { - enum Tail { - case left - case right - case none - } - - let cornerRadius: CGFloat - let tail: Tail - var insetAmount: CGFloat = 0 - - private let tailWidth: CGFloat = 7 - private let tailBaseHeight: CGFloat = 9 - - func inset(by amount: CGFloat) -> ChatBubbleShape { - var copy = self - copy.insetAmount += amount - return copy - } - - func path(in rect: CGRect) -> Path { - let rect = rect.insetBy(dx: self.insetAmount, dy: self.insetAmount) - switch self.tail { - case .left: - return self.leftTailPath(in: rect, radius: self.cornerRadius) - case .right: - return self.rightTailPath(in: rect, radius: self.cornerRadius) - case .none: - return Path(roundedRect: rect, cornerRadius: self.cornerRadius) - } - } - - private func rightTailPath(in rect: CGRect, radius r: CGFloat) -> Path { - var path = Path() - let bubbleMinX = rect.minX - let bubbleMaxX = rect.maxX - self.tailWidth - let bubbleMinY = rect.minY - let bubbleMaxY = rect.maxY - - let available = max(4, bubbleMaxY - bubbleMinY - 2 * r) - let baseH = min(tailBaseHeight, available) - let baseBottomY = bubbleMaxY - max(r * 0.45, 6) - let baseTopY = baseBottomY - baseH - let midY = (baseTopY + baseBottomY) / 2 - - let baseTop = CGPoint(x: bubbleMaxX, y: baseTopY) - let baseBottom = CGPoint(x: bubbleMaxX, y: baseBottomY) - let tip = CGPoint(x: bubbleMaxX + self.tailWidth, y: midY) - - path.move(to: CGPoint(x: bubbleMinX + r, y: bubbleMinY)) - path.addLine(to: CGPoint(x: bubbleMaxX - r, y: bubbleMinY)) - path.addQuadCurve( - to: CGPoint(x: bubbleMaxX, y: bubbleMinY + r), - control: CGPoint(x: bubbleMaxX, y: bubbleMinY)) - path.addLine(to: baseTop) - path.addCurve( - to: tip, - control1: CGPoint(x: bubbleMaxX + self.tailWidth * 0.2, y: baseTopY + baseH * 0.05), - control2: CGPoint(x: bubbleMaxX + self.tailWidth * 0.95, y: midY - baseH * 0.15)) - path.addCurve( - to: baseBottom, - control1: CGPoint(x: bubbleMaxX + self.tailWidth * 0.95, y: midY + baseH * 0.15), - control2: CGPoint(x: bubbleMaxX + self.tailWidth * 0.2, y: baseBottomY - baseH * 0.05)) - path.addQuadCurve( - to: CGPoint(x: bubbleMaxX - r, y: bubbleMaxY), - control: CGPoint(x: bubbleMaxX, y: bubbleMaxY)) - path.addLine(to: CGPoint(x: bubbleMinX + r, y: bubbleMaxY)) - path.addQuadCurve( - to: CGPoint(x: bubbleMinX, y: bubbleMaxY - r), - control: CGPoint(x: bubbleMinX, y: bubbleMaxY)) - path.addLine(to: CGPoint(x: bubbleMinX, y: bubbleMinY + r)) - path.addQuadCurve( - to: CGPoint(x: bubbleMinX + r, y: bubbleMinY), - control: CGPoint(x: bubbleMinX, y: bubbleMinY)) - - return path - } - - private func leftTailPath(in rect: CGRect, radius r: CGFloat) -> Path { - var path = Path() - let bubbleMinX = rect.minX + self.tailWidth - let bubbleMaxX = rect.maxX - let bubbleMinY = rect.minY - let bubbleMaxY = rect.maxY - - let available = max(4, bubbleMaxY - bubbleMinY - 2 * r) - let baseH = min(tailBaseHeight, available) - let baseBottomY = bubbleMaxY - max(r * 0.45, 6) - let baseTopY = baseBottomY - baseH - let midY = (baseTopY + baseBottomY) / 2 - - let baseTop = CGPoint(x: bubbleMinX, y: baseTopY) - let baseBottom = CGPoint(x: bubbleMinX, y: baseBottomY) - let tip = CGPoint(x: bubbleMinX - self.tailWidth, y: midY) - - path.move(to: CGPoint(x: bubbleMinX + r, y: bubbleMinY)) - path.addLine(to: CGPoint(x: bubbleMaxX - r, y: bubbleMinY)) - path.addQuadCurve( - to: CGPoint(x: bubbleMaxX, y: bubbleMinY + r), - control: CGPoint(x: bubbleMaxX, y: bubbleMinY)) - path.addLine(to: CGPoint(x: bubbleMaxX, y: bubbleMaxY - r)) - path.addQuadCurve( - to: CGPoint(x: bubbleMaxX - r, y: bubbleMaxY), - control: CGPoint(x: bubbleMaxX, y: bubbleMaxY)) - path.addLine(to: CGPoint(x: bubbleMinX + r, y: bubbleMaxY)) - path.addQuadCurve( - to: CGPoint(x: bubbleMinX, y: bubbleMaxY - r), - control: CGPoint(x: bubbleMinX, y: bubbleMaxY)) - path.addLine(to: baseBottom) - path.addCurve( - to: tip, - control1: CGPoint(x: bubbleMinX - self.tailWidth * 0.2, y: baseBottomY - baseH * 0.05), - control2: CGPoint(x: bubbleMinX - self.tailWidth * 0.95, y: midY + baseH * 0.15)) - path.addCurve( - to: baseTop, - control1: CGPoint(x: bubbleMinX - self.tailWidth * 0.95, y: midY - baseH * 0.15), - control2: CGPoint(x: bubbleMinX - self.tailWidth * 0.2, y: baseTopY + baseH * 0.05)) - path.addLine(to: CGPoint(x: bubbleMinX, y: bubbleMinY + r)) - path.addQuadCurve( - to: CGPoint(x: bubbleMinX + r, y: bubbleMinY), - control: CGPoint(x: bubbleMinX, y: bubbleMinY)) - - return path - } -} - -@MainActor -struct ChatMessageBubble: View { - let message: OpenClawChatMessage - let style: OpenClawChatView.Style - let markdownVariant: ChatMarkdownVariant - let userAccent: Color? - - var body: some View { - ChatMessageBody( - message: self.message, - isUser: self.isUser, - style: self.style, - markdownVariant: self.markdownVariant, - userAccent: self.userAccent) - .frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: self.isUser ? .trailing : .leading) - .frame(maxWidth: .infinity, alignment: self.isUser ? .trailing : .leading) - .padding(.horizontal, 2) - } - - private var isUser: Bool { self.message.role.lowercased() == "user" } -} - -@MainActor -private struct ChatMessageBody: View { - let message: OpenClawChatMessage - let isUser: Bool - let style: OpenClawChatView.Style - let markdownVariant: ChatMarkdownVariant - let userAccent: Color? - - var body: some View { - let text = self.primaryText - let textColor = self.isUser ? OpenClawChatTheme.userText : OpenClawChatTheme.assistantText - - VStack(alignment: .leading, spacing: 10) { - if self.isToolResultMessage { - if !text.isEmpty { - ToolResultCard( - title: self.toolResultTitle, - text: text, - isUser: self.isUser, - toolName: self.message.toolName) - } - } else if self.isUser { - ChatMarkdownRenderer( - text: text, - context: .user, - variant: self.markdownVariant, - font: .system(size: 14), - textColor: textColor) - } else { - ChatAssistantTextBody(text: text, markdownVariant: self.markdownVariant) - } - - if !self.inlineAttachments.isEmpty { - ForEach(self.inlineAttachments.indices, id: \.self) { idx in - AttachmentRow(att: self.inlineAttachments[idx], isUser: self.isUser) - } - } - - if !self.toolCalls.isEmpty { - ForEach(self.toolCalls.indices, id: \.self) { idx in - ToolCallCard( - content: self.toolCalls[idx], - isUser: self.isUser) - } - } - - if !self.inlineToolResults.isEmpty { - ForEach(self.inlineToolResults.indices, id: \.self) { idx in - let toolResult = self.inlineToolResults[idx] - let display = ToolDisplayRegistry.resolve(name: toolResult.name ?? "tool", args: nil) - ToolResultCard( - title: "\(display.emoji) \(display.title)", - text: toolResult.text ?? "", - isUser: self.isUser, - toolName: toolResult.name) - } - } - } - .textSelection(.enabled) - .padding(.vertical, 10) - .padding(.horizontal, 12) - .foregroundStyle(textColor) - .background(self.bubbleBackground) - .clipShape(self.bubbleShape) - .overlay(self.bubbleBorder) - .shadow(color: self.bubbleShadowColor, radius: self.bubbleShadowRadius, y: self.bubbleShadowYOffset) - .padding(.leading, self.tailPaddingLeading) - .padding(.trailing, self.tailPaddingTrailing) - } - - private var primaryText: String { - let parts = self.message.content.compactMap { content -> String? in - let kind = (content.type ?? "text").lowercased() - guard kind == "text" || kind.isEmpty else { return nil } - return content.text - } - return parts.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) - } - - private var inlineAttachments: [OpenClawChatMessageContent] { - self.message.content.filter { content in - switch content.type ?? "text" { - case "file", "attachment": - true - default: - false - } - } - } - - private var toolCalls: [OpenClawChatMessageContent] { - self.message.content.filter { content in - let kind = (content.type ?? "").lowercased() - if ["toolcall", "tool_call", "tooluse", "tool_use"].contains(kind) { - return true - } - return content.name != nil && content.arguments != nil - } - } - - private var inlineToolResults: [OpenClawChatMessageContent] { - self.message.content.filter { content in - let kind = (content.type ?? "").lowercased() - return kind == "toolresult" || kind == "tool_result" - } - } - - private var isToolResultMessage: Bool { - let role = self.message.role.lowercased() - return role == "toolresult" || role == "tool_result" - } - - private var toolResultTitle: String { - if let name = self.message.toolName, !name.isEmpty { - let display = ToolDisplayRegistry.resolve(name: name, args: nil) - return "\(display.emoji) \(display.title)" - } - let display = ToolDisplayRegistry.resolve(name: "tool", args: nil) - return "\(display.emoji) \(display.title)" - } - - private var bubbleFillColor: Color { - if self.isUser { - return self.userAccent ?? OpenClawChatTheme.userBubble - } - if self.style == .onboarding { - return OpenClawChatTheme.onboardingAssistantBubble - } - return OpenClawChatTheme.assistantBubble - } - - private var bubbleBackground: AnyShapeStyle { - AnyShapeStyle(self.bubbleFillColor) - } - - private var bubbleBorderColor: Color { - if self.isUser { - return Color.white.opacity(0.12) - } - if self.style == .onboarding { - return OpenClawChatTheme.onboardingAssistantBorder - } - return Color.white.opacity(0.08) - } - - private var bubbleBorderWidth: CGFloat { - if self.isUser { return 0.5 } - if self.style == .onboarding { return 0.8 } - return 1 - } - - private var bubbleBorder: some View { - self.bubbleShape.strokeBorder(self.bubbleBorderColor, lineWidth: self.bubbleBorderWidth) - } - - private var bubbleShape: ChatBubbleShape { - ChatBubbleShape(cornerRadius: ChatUIConstants.bubbleCorner, tail: self.bubbleTail) - } - - private var bubbleTail: ChatBubbleShape.Tail { - guard self.style == .onboarding else { return .none } - return self.isUser ? .right : .left - } - - private var tailPaddingLeading: CGFloat { - self.style == .onboarding && !self.isUser ? 8 : 0 - } - - private var tailPaddingTrailing: CGFloat { - self.style == .onboarding && self.isUser ? 8 : 0 - } - - private var bubbleShadowColor: Color { - self.style == .onboarding && !self.isUser ? Color.black.opacity(0.28) : .clear - } - - private var bubbleShadowRadius: CGFloat { - self.style == .onboarding && !self.isUser ? 6 : 0 - } - - private var bubbleShadowYOffset: CGFloat { - self.style == .onboarding && !self.isUser ? 2 : 0 - } -} - -private struct AttachmentRow: View { - let att: OpenClawChatMessageContent - let isUser: Bool - - var body: some View { - HStack(spacing: 8) { - Image(systemName: "paperclip") - Text(self.att.fileName ?? "Attachment") - .font(.footnote) - .lineLimit(1) - .foregroundStyle(self.isUser ? OpenClawChatTheme.userText : OpenClawChatTheme.assistantText) - Spacer() - } - .padding(10) - .background(self.isUser ? Color.white.opacity(0.2) : Color.black.opacity(0.04)) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - } -} - -private struct ToolCallCard: View { - let content: OpenClawChatMessageContent - let isUser: Bool - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 6) { - Text(self.toolName) - .font(.footnote.weight(.semibold)) - Spacer(minLength: 0) - } - - if let summary = self.summary, !summary.isEmpty { - Text(summary) - .font(.footnote.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(2) - } - } - .padding(10) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(OpenClawChatTheme.subtleCard) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder(Color.white.opacity(0.08), lineWidth: 1))) - } - - private var toolName: String { - "\(self.display.emoji) \(self.display.title)" - } - - private var summary: String? { - self.display.detailLine - } - - private var display: ToolDisplaySummary { - ToolDisplayRegistry.resolve(name: self.content.name ?? "tool", args: self.content.arguments) - } -} - -private struct ToolResultCard: View { - let title: String - let text: String - let isUser: Bool - let toolName: String? - @State private var expanded = false - - var body: some View { - if !self.displayContent.isEmpty { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 6) { - Text(self.title) - .font(.footnote.weight(.semibold)) - Spacer(minLength: 0) - } - - Text(self.displayText) - .font(.footnote.monospaced()) - .foregroundStyle(self.isUser ? OpenClawChatTheme.userText : OpenClawChatTheme.assistantText) - .lineLimit(self.expanded ? nil : Self.previewLineLimit) - - if self.shouldShowToggle { - Button(self.expanded ? "Show less" : "Show full output") { - self.expanded.toggle() - } - .buttonStyle(.plain) - .font(.caption) - .foregroundStyle(.secondary) - } - } - .padding(10) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(OpenClawChatTheme.subtleCard) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder(Color.white.opacity(0.08), lineWidth: 1))) - } - } - - private static let previewLineLimit = 8 - - private var displayContent: String { - ToolResultTextFormatter.format(text: self.text, toolName: self.toolName) - } - - private var lines: [Substring] { - self.displayContent.components(separatedBy: .newlines).map { Substring($0) } - } - - private var displayText: String { - guard !self.expanded, self.lines.count > Self.previewLineLimit else { return self.displayContent } - return self.lines.prefix(Self.previewLineLimit).joined(separator: "\n") + "\n…" - } - - private var shouldShowToggle: Bool { - self.lines.count > Self.previewLineLimit - } -} - -@MainActor -struct ChatTypingIndicatorBubble: View { - let style: OpenClawChatView.Style - - var body: some View { - HStack(spacing: 10) { - TypingDots() - Spacer(minLength: 0) - } - .padding(.vertical, self.style == .standard ? 12 : 10) - .padding(.horizontal, self.style == .standard ? 12 : 14) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(OpenClawChatTheme.assistantBubble)) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .strokeBorder(Color.white.opacity(0.08), lineWidth: 1)) - .frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading) - .focusable(false) - } -} - -extension ChatTypingIndicatorBubble: @MainActor Equatable { - static func == (lhs: Self, rhs: Self) -> Bool { - lhs.style == rhs.style - } -} - -@MainActor -struct ChatStreamingAssistantBubble: View { - let text: String - let markdownVariant: ChatMarkdownVariant - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - ChatAssistantTextBody(text: self.text, markdownVariant: self.markdownVariant) - } - .padding(12) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(OpenClawChatTheme.assistantBubble)) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .strokeBorder(Color.white.opacity(0.08), lineWidth: 1)) - .frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading) - .focusable(false) - } -} - -@MainActor -struct ChatPendingToolsBubble: View { - let toolCalls: [OpenClawChatPendingToolCall] - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - Label("Running tools…", systemImage: "hammer") - .font(.caption) - .foregroundStyle(.secondary) - - ForEach(self.toolCalls) { call in - let display = ToolDisplayRegistry.resolve(name: call.name, args: call.args) - VStack(alignment: .leading, spacing: 4) { - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text("\(display.emoji) \(display.label)") - .font(.footnote.monospaced()) - .lineLimit(1) - Spacer(minLength: 0) - ProgressView().controlSize(.mini) - } - if let detail = display.detailLine, !detail.isEmpty { - Text(detail) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(2) - } - } - .padding(10) - .background(Color.white.opacity(0.06)) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - } - } - .padding(12) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(OpenClawChatTheme.assistantBubble)) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .strokeBorder(Color.white.opacity(0.08), lineWidth: 1)) - .frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading) - .focusable(false) - } -} - -extension ChatPendingToolsBubble: @MainActor Equatable { - static func == (lhs: Self, rhs: Self) -> Bool { - lhs.toolCalls == rhs.toolCalls - } -} - -@MainActor -private struct TypingDots: View { - @Environment(\.accessibilityReduceMotion) private var reduceMotion - @Environment(\.scenePhase) private var scenePhase - @State private var animate = false - - var body: some View { - HStack(spacing: 5) { - ForEach(0..<3, id: \.self) { idx in - Circle() - .fill(Color.secondary.opacity(0.55)) - .frame(width: 7, height: 7) - .scaleEffect(self.reduceMotion ? 0.85 : (self.animate ? 1.05 : 0.70)) - .opacity(self.reduceMotion ? 0.55 : (self.animate ? 0.95 : 0.30)) - .animation( - self.reduceMotion ? nil : .easeInOut(duration: 0.55) - .repeatForever(autoreverses: true) - .delay(Double(idx) * 0.16), - value: self.animate) - } - } - .onAppear { self.updateAnimationState() } - .onDisappear { self.animate = false } - .onChange(of: self.scenePhase) { _, _ in - self.updateAnimationState() - } - .onChange(of: self.reduceMotion) { _, _ in - self.updateAnimationState() - } - } - - private func updateAnimationState() { - guard !self.reduceMotion, self.scenePhase == .active else { - self.animate = false - return - } - self.animate = true - } -} - -private struct ChatAssistantTextBody: View { - let text: String - let markdownVariant: ChatMarkdownVariant - - var body: some View { - let segments = AssistantTextParser.segments(from: self.text) - VStack(alignment: .leading, spacing: 10) { - ForEach(segments) { segment in - let font = segment.kind == .thinking ? Font.system(size: 14).italic() : Font.system(size: 14) - ChatMarkdownRenderer( - text: segment.text, - context: .assistant, - variant: self.markdownVariant, - font: font, - textColor: OpenClawChatTheme.assistantText) - } - } - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift deleted file mode 100644 index c58f2d702e4..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift +++ /dev/null @@ -1,332 +0,0 @@ -import OpenClawKit -import Foundation - -// NOTE: keep this file lightweight; decode must be resilient to varying transcript formats. - -#if canImport(AppKit) -import AppKit - -public typealias OpenClawPlatformImage = NSImage -#elseif canImport(UIKit) -import UIKit - -public typealias OpenClawPlatformImage = UIImage -#endif - -public struct OpenClawChatUsageCost: Codable, Hashable, Sendable { - public let input: Double? - public let output: Double? - public let cacheRead: Double? - public let cacheWrite: Double? - public let total: Double? -} - -public struct OpenClawChatUsage: Codable, Hashable, Sendable { - public let input: Int? - public let output: Int? - public let cacheRead: Int? - public let cacheWrite: Int? - public let cost: OpenClawChatUsageCost? - public let total: Int? - - enum CodingKeys: String, CodingKey { - case input - case output - case cacheRead - case cacheWrite - case cost - case total - case totalTokens - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.input = try container.decodeIfPresent(Int.self, forKey: .input) - self.output = try container.decodeIfPresent(Int.self, forKey: .output) - self.cacheRead = try container.decodeIfPresent(Int.self, forKey: .cacheRead) - self.cacheWrite = try container.decodeIfPresent(Int.self, forKey: .cacheWrite) - self.cost = try container.decodeIfPresent(OpenClawChatUsageCost.self, forKey: .cost) - self.total = - try container.decodeIfPresent(Int.self, forKey: .total) ?? - container.decodeIfPresent(Int.self, forKey: .totalTokens) - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encodeIfPresent(self.input, forKey: .input) - try container.encodeIfPresent(self.output, forKey: .output) - try container.encodeIfPresent(self.cacheRead, forKey: .cacheRead) - try container.encodeIfPresent(self.cacheWrite, forKey: .cacheWrite) - try container.encodeIfPresent(self.cost, forKey: .cost) - try container.encodeIfPresent(self.total, forKey: .total) - } -} - -public struct OpenClawChatMessageContent: Codable, Hashable, Sendable { - public let type: String? - public let text: String? - public let thinking: String? - public let thinkingSignature: String? - public let mimeType: String? - public let fileName: String? - public let content: AnyCodable? - - // Tool-call fields (when `type == "toolCall"` or similar) - public let id: String? - public let name: String? - public let arguments: AnyCodable? - - public init( - type: String?, - text: String?, - thinking: String? = nil, - thinkingSignature: String? = nil, - mimeType: String?, - fileName: String?, - content: AnyCodable?, - id: String? = nil, - name: String? = nil, - arguments: AnyCodable? = nil) - { - self.type = type - self.text = text - self.thinking = thinking - self.thinkingSignature = thinkingSignature - self.mimeType = mimeType - self.fileName = fileName - self.content = content - self.id = id - self.name = name - self.arguments = arguments - } - - enum CodingKeys: String, CodingKey { - case type - case text - case thinking - case thinkingSignature - case mimeType - case fileName - case content - case id - case name - case arguments - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.type = try container.decodeIfPresent(String.self, forKey: .type) - self.text = try container.decodeIfPresent(String.self, forKey: .text) - self.thinking = try container.decodeIfPresent(String.self, forKey: .thinking) - self.thinkingSignature = try container.decodeIfPresent(String.self, forKey: .thinkingSignature) - self.mimeType = try container.decodeIfPresent(String.self, forKey: .mimeType) - self.fileName = try container.decodeIfPresent(String.self, forKey: .fileName) - self.id = try container.decodeIfPresent(String.self, forKey: .id) - self.name = try container.decodeIfPresent(String.self, forKey: .name) - self.arguments = try container.decodeIfPresent(AnyCodable.self, forKey: .arguments) - - if let any = try container.decodeIfPresent(AnyCodable.self, forKey: .content) { - self.content = any - } else if let str = try container.decodeIfPresent(String.self, forKey: .content) { - self.content = AnyCodable(str) - } else { - self.content = nil - } - } -} - -public struct OpenClawChatMessage: Codable, Identifiable, Sendable { - public var id: UUID = .init() - public let role: String - public let content: [OpenClawChatMessageContent] - public let timestamp: Double? - public let toolCallId: String? - public let toolName: String? - public let usage: OpenClawChatUsage? - public let stopReason: String? - - enum CodingKeys: String, CodingKey { - case role - case content - case timestamp - case toolCallId - case tool_call_id - case toolName - case tool_name - case usage - case stopReason - } - - public init( - id: UUID = .init(), - role: String, - content: [OpenClawChatMessageContent], - timestamp: Double?, - toolCallId: String? = nil, - toolName: String? = nil, - usage: OpenClawChatUsage? = nil, - stopReason: String? = nil) - { - self.id = id - self.role = role - self.content = content - self.timestamp = timestamp - self.toolCallId = toolCallId - self.toolName = toolName - self.usage = usage - self.stopReason = stopReason - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.role = try container.decode(String.self, forKey: .role) - self.timestamp = try container.decodeIfPresent(Double.self, forKey: .timestamp) - self.toolCallId = - try container.decodeIfPresent(String.self, forKey: .toolCallId) ?? - container.decodeIfPresent(String.self, forKey: .tool_call_id) - self.toolName = - try container.decodeIfPresent(String.self, forKey: .toolName) ?? - container.decodeIfPresent(String.self, forKey: .tool_name) - self.usage = try container.decodeIfPresent(OpenClawChatUsage.self, forKey: .usage) - self.stopReason = try container.decodeIfPresent(String.self, forKey: .stopReason) - - if let decoded = try? container.decode([OpenClawChatMessageContent].self, forKey: .content) { - self.content = decoded - return - } - - // Some session log formats store `content` as a plain string. - if let text = try? container.decode(String.self, forKey: .content) { - self.content = [ - OpenClawChatMessageContent( - type: "text", - text: text, - thinking: nil, - thinkingSignature: nil, - mimeType: nil, - fileName: nil, - content: nil, - id: nil, - name: nil, - arguments: nil), - ] - return - } - - self.content = [] - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(self.role, forKey: .role) - try container.encodeIfPresent(self.timestamp, forKey: .timestamp) - try container.encodeIfPresent(self.toolCallId, forKey: .toolCallId) - try container.encodeIfPresent(self.toolName, forKey: .toolName) - try container.encodeIfPresent(self.usage, forKey: .usage) - try container.encodeIfPresent(self.stopReason, forKey: .stopReason) - try container.encode(self.content, forKey: .content) - } -} - -public struct OpenClawChatHistoryPayload: Codable, Sendable { - public let sessionKey: String - public let sessionId: String? - public let messages: [AnyCodable]? - public let thinkingLevel: String? -} - -public struct OpenClawSessionPreviewItem: Codable, Hashable, Sendable { - public let role: String - public let text: String -} - -public struct OpenClawSessionPreviewEntry: Codable, Sendable { - public let key: String - public let status: String - public let items: [OpenClawSessionPreviewItem] -} - -public struct OpenClawSessionsPreviewPayload: Codable, Sendable { - public let ts: Int - public let previews: [OpenClawSessionPreviewEntry] - - public init(ts: Int, previews: [OpenClawSessionPreviewEntry]) { - self.ts = ts - self.previews = previews - } -} - -public struct OpenClawChatSendResponse: Codable, Sendable { - public let runId: String - public let status: String -} - -public struct OpenClawChatEventPayload: Codable, Sendable { - public let runId: String? - public let sessionKey: String? - public let state: String? - public let message: AnyCodable? - public let errorMessage: String? -} - -public struct OpenClawAgentEventPayload: Codable, Sendable, Identifiable { - public var id: String { "\(self.runId)-\(self.seq ?? -1)" } - public let runId: String - public let seq: Int? - public let stream: String - public let ts: Int? - public let data: [String: AnyCodable] -} - -public struct OpenClawChatPendingToolCall: Identifiable, Hashable, Sendable { - public var id: String { self.toolCallId } - public let toolCallId: String - public let name: String - public let args: AnyCodable? - public let startedAt: Double? - public let isError: Bool? -} - -public struct OpenClawGatewayHealthOK: Codable, Sendable { - public let ok: Bool? -} - -public struct OpenClawPendingAttachment: Identifiable { - public let id = UUID() - public let url: URL? - public let data: Data - public let fileName: String - public let mimeType: String - public let type: String - public let preview: OpenClawPlatformImage? - - public init( - url: URL?, - data: Data, - fileName: String, - mimeType: String, - type: String = "file", - preview: OpenClawPlatformImage?) - { - self.url = url - self.data = data - self.fileName = fileName - self.mimeType = mimeType - self.type = type - self.preview = preview - } -} - -public struct OpenClawChatAttachmentPayload: Codable, Sendable, Hashable { - public let type: String - public let mimeType: String - public let fileName: String - public let content: String - - public init(type: String, mimeType: String, fileName: String, content: String) { - self.type = type - self.mimeType = mimeType - self.fileName = fileName - self.content = content - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatPayloadDecoding.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatPayloadDecoding.swift deleted file mode 100644 index 02636696d21..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatPayloadDecoding.swift +++ /dev/null @@ -1,9 +0,0 @@ -import OpenClawKit -import Foundation - -enum ChatPayloadDecoding { - static func decode(_ payload: AnyCodable, as _: T.Type = T.self) throws -> T { - let data = try JSONEncoder().encode(payload) - return try JSONDecoder().decode(T.self, from: data) - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessions.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessions.swift deleted file mode 100644 index febe69a3cbe..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessions.swift +++ /dev/null @@ -1,40 +0,0 @@ -import Foundation - -public struct OpenClawChatSessionsDefaults: Codable, Sendable { - public let model: String? - public let contextTokens: Int? -} - -public struct OpenClawChatSessionEntry: Codable, Identifiable, Sendable, Hashable { - public var id: String { self.key } - - public let key: String - public let kind: String? - public let displayName: String? - public let surface: String? - public let subject: String? - public let room: String? - public let space: String? - public let updatedAt: Double? - public let sessionId: String? - - public let systemSent: Bool? - public let abortedLastRun: Bool? - public let thinkingLevel: String? - public let verboseLevel: String? - - public let inputTokens: Int? - public let outputTokens: Int? - public let totalTokens: Int? - - public let model: String? - public let contextTokens: Int? -} - -public struct OpenClawChatSessionsListResponse: Codable, Sendable { - public let ts: Double? - public let path: String? - public let count: Int? - public let defaults: OpenClawChatSessionsDefaults? - public let sessions: [OpenClawChatSessionEntry] -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSheets.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSheets.swift deleted file mode 100644 index 678000d2cea..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSheets.swift +++ /dev/null @@ -1,69 +0,0 @@ -import Observation -import SwiftUI - -@MainActor -struct ChatSessionsSheet: View { - @Bindable var viewModel: OpenClawChatViewModel - @Environment(\.dismiss) private var dismiss - - var body: some View { - NavigationStack { - List(self.viewModel.sessions) { session in - Button { - self.viewModel.switchSession(to: session.key) - self.dismiss() - } label: { - VStack(alignment: .leading, spacing: 4) { - Text(session.displayName ?? session.key) - .font(.system(.body, design: .monospaced)) - .lineLimit(1) - if let updatedAt = session.updatedAt, updatedAt > 0 { - Text(Date(timeIntervalSince1970: updatedAt / 1000).formatted( - date: .abbreviated, - time: .shortened)) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - } - .navigationTitle("Sessions") - .toolbar { - #if os(macOS) - ToolbarItem(placement: .automatic) { - Button { - self.viewModel.refreshSessions(limit: 200) - } label: { - Image(systemName: "arrow.clockwise") - } - } - ToolbarItem(placement: .primaryAction) { - Button { - self.dismiss() - } label: { - Image(systemName: "xmark") - } - } - #else - ToolbarItem(placement: .topBarLeading) { - Button { - self.viewModel.refreshSessions(limit: 200) - } label: { - Image(systemName: "arrow.clockwise") - } - } - ToolbarItem(placement: .topBarTrailing) { - Button { - self.dismiss() - } label: { - Image(systemName: "xmark") - } - } - #endif - } - .onAppear { - self.viewModel.refreshSessions(limit: 200) - } - } - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTheme.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTheme.swift deleted file mode 100644 index c06ed4f46af..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTheme.swift +++ /dev/null @@ -1,174 +0,0 @@ -import SwiftUI - -#if os(macOS) -import AppKit -#else -import UIKit -#endif - -#if os(macOS) -extension NSAppearance { - fileprivate var isDarkAqua: Bool { - self.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua - } -} -#endif - -enum OpenClawChatTheme { - #if os(macOS) - static func resolvedAssistantBubbleColor(for appearance: NSAppearance) -> NSColor { - // NSColor semantic colors don't reliably resolve for arbitrary NSAppearance in SwiftPM. - // Use explicit light/dark values so the bubble updates when the system appearance flips. - appearance.isDarkAqua - ? NSColor(calibratedWhite: 0.18, alpha: 0.88) - : NSColor(calibratedWhite: 0.94, alpha: 0.92) - } - - static func resolvedOnboardingAssistantBubbleColor(for appearance: NSAppearance) -> NSColor { - appearance.isDarkAqua - ? NSColor(calibratedWhite: 0.20, alpha: 0.94) - : NSColor(calibratedWhite: 0.97, alpha: 0.98) - } - - static let assistantBubbleDynamicNSColor = NSColor( - name: NSColor.Name("OpenClawChatTheme.assistantBubble"), - dynamicProvider: resolvedAssistantBubbleColor(for:)) - - static let onboardingAssistantBubbleDynamicNSColor = NSColor( - name: NSColor.Name("OpenClawChatTheme.onboardingAssistantBubble"), - dynamicProvider: resolvedOnboardingAssistantBubbleColor(for:)) - #endif - - static var surface: Color { - #if os(macOS) - Color(nsColor: .windowBackgroundColor) - #else - Color(uiColor: .systemBackground) - #endif - } - - @ViewBuilder - static var background: some View { - #if os(macOS) - ZStack { - Rectangle() - .fill(.ultraThinMaterial) - LinearGradient( - colors: [ - Color.white.opacity(0.12), - Color(nsColor: .windowBackgroundColor).opacity(0.35), - Color.black.opacity(0.35), - ], - startPoint: .topLeading, - endPoint: .bottomTrailing) - RadialGradient( - colors: [ - Color(nsColor: .systemOrange).opacity(0.14), - .clear, - ], - center: .topLeading, - startRadius: 40, - endRadius: 320) - RadialGradient( - colors: [ - Color(nsColor: .systemTeal).opacity(0.12), - .clear, - ], - center: .topTrailing, - startRadius: 40, - endRadius: 280) - Color.black.opacity(0.08) - } - #else - Color(uiColor: .systemBackground) - #endif - } - - static var card: Color { - #if os(macOS) - Color(nsColor: .textBackgroundColor) - #else - Color(uiColor: .secondarySystemBackground) - #endif - } - - static var subtleCard: AnyShapeStyle { - #if os(macOS) - AnyShapeStyle(.ultraThinMaterial) - #else - AnyShapeStyle(Color(uiColor: .secondarySystemBackground).opacity(0.9)) - #endif - } - - static var userBubble: Color { - Color(red: 127 / 255.0, green: 184 / 255.0, blue: 212 / 255.0) - } - - static var assistantBubble: Color { - #if os(macOS) - Color(nsColor: self.assistantBubbleDynamicNSColor) - #else - Color(uiColor: .secondarySystemBackground) - #endif - } - - static var onboardingAssistantBubble: Color { - #if os(macOS) - Color(nsColor: self.onboardingAssistantBubbleDynamicNSColor) - #else - Color(uiColor: .secondarySystemBackground) - #endif - } - - static var onboardingAssistantBorder: Color { - #if os(macOS) - Color.white.opacity(0.12) - #else - Color.white.opacity(0.12) - #endif - } - - static var userText: Color { .white } - - static var assistantText: Color { - #if os(macOS) - Color(nsColor: .labelColor) - #else - Color(uiColor: .label) - #endif - } - - static var composerBackground: AnyShapeStyle { - #if os(macOS) - AnyShapeStyle(.ultraThinMaterial) - #else - AnyShapeStyle(Color(uiColor: .systemBackground)) - #endif - } - - static var composerField: AnyShapeStyle { - #if os(macOS) - AnyShapeStyle(.thinMaterial) - #else - AnyShapeStyle(Color(uiColor: .secondarySystemBackground)) - #endif - } - - static var composerBorder: Color { - Color.white.opacity(0.12) - } - - static var divider: Color { - Color.secondary.opacity(0.2) - } -} - -enum OpenClawPlatformImageFactory { - static func image(_ image: OpenClawPlatformImage) -> Image { - #if os(macOS) - Image(nsImage: image) - #else - Image(uiImage: image) - #endif - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift deleted file mode 100644 index 037c1352205..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift +++ /dev/null @@ -1,45 +0,0 @@ -import Foundation - -public enum OpenClawChatTransportEvent: Sendable { - case health(ok: Bool) - case tick - case chat(OpenClawChatEventPayload) - case agent(OpenClawAgentEventPayload) - case seqGap -} - -public protocol OpenClawChatTransport: Sendable { - func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload - func sendMessage( - sessionKey: String, - message: String, - thinking: String, - idempotencyKey: String, - attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse - - func abortRun(sessionKey: String, runId: String) async throws - func listSessions(limit: Int?) async throws -> OpenClawChatSessionsListResponse - - func requestHealth(timeoutMs: Int) async throws -> Bool - func events() -> AsyncStream - - func setActiveSessionKey(_ sessionKey: String) async throws -} - -extension OpenClawChatTransport { - public func setActiveSessionKey(_: String) async throws {} - - public func abortRun(sessionKey _: String, runId _: String) async throws { - throw NSError( - domain: "OpenClawChatTransport", - code: 0, - userInfo: [NSLocalizedDescriptionKey: "chat.abort not supported by this transport"]) - } - - public func listSessions(limit _: Int?) async throws -> OpenClawChatSessionsListResponse { - throw NSError( - domain: "OpenClawChatTransport", - code: 0, - userInfo: [NSLocalizedDescriptionKey: "sessions.list not supported by this transport"]) - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift deleted file mode 100644 index 0675ffc2139..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift +++ /dev/null @@ -1,527 +0,0 @@ -import SwiftUI -#if canImport(UIKit) -import UIKit -#endif - -@MainActor -public struct OpenClawChatView: View { - public enum Style { - case standard - case onboarding - } - - @State private var viewModel: OpenClawChatViewModel - @State private var scrollerBottomID = UUID() - @State private var scrollPosition: UUID? - @State private var showSessions = false - @State private var hasPerformedInitialScroll = false - @State private var isPinnedToBottom = true - @State private var lastUserMessageID: UUID? - private let showsSessionSwitcher: Bool - private let style: Style - private let markdownVariant: ChatMarkdownVariant - private let userAccent: Color? - - private enum Layout { - #if os(macOS) - static let outerPaddingHorizontal: CGFloat = 6 - static let outerPaddingVertical: CGFloat = 0 - static let composerPaddingHorizontal: CGFloat = 0 - static let stackSpacing: CGFloat = 0 - static let messageSpacing: CGFloat = 6 - static let messageListPaddingTop: CGFloat = 12 - static let messageListPaddingBottom: CGFloat = 16 - static let messageListPaddingHorizontal: CGFloat = 6 - #else - static let outerPaddingHorizontal: CGFloat = 6 - static let outerPaddingVertical: CGFloat = 6 - static let composerPaddingHorizontal: CGFloat = 6 - static let stackSpacing: CGFloat = 6 - static let messageSpacing: CGFloat = 12 - static let messageListPaddingTop: CGFloat = 10 - static let messageListPaddingBottom: CGFloat = 6 - static let messageListPaddingHorizontal: CGFloat = 8 - #endif - } - - public init( - viewModel: OpenClawChatViewModel, - showsSessionSwitcher: Bool = false, - style: Style = .standard, - markdownVariant: ChatMarkdownVariant = .standard, - userAccent: Color? = nil) - { - self._viewModel = State(initialValue: viewModel) - self.showsSessionSwitcher = showsSessionSwitcher - self.style = style - self.markdownVariant = markdownVariant - self.userAccent = userAccent - } - - public var body: some View { - ZStack { - if self.style == .standard { - OpenClawChatTheme.background - .ignoresSafeArea() - } - - VStack(spacing: Layout.stackSpacing) { - self.messageList - .padding(.horizontal, Layout.outerPaddingHorizontal) - OpenClawChatComposer( - viewModel: self.viewModel, - style: self.style, - showsSessionSwitcher: self.showsSessionSwitcher) - .padding(.horizontal, Layout.composerPaddingHorizontal) - } - .padding(.vertical, Layout.outerPaddingVertical) - .frame(maxWidth: .infinity) - .frame(maxHeight: .infinity, alignment: .top) - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .onAppear { self.viewModel.load() } - .sheet(isPresented: self.$showSessions) { - if self.showsSessionSwitcher { - ChatSessionsSheet(viewModel: self.viewModel) - } else { - EmptyView() - } - } - } - - private var messageList: some View { - ZStack { - ScrollView { - LazyVStack(spacing: Layout.messageSpacing) { - self.messageListRows - - Color.clear - #if os(macOS) - .frame(height: Layout.messageListPaddingBottom) - #else - .frame(height: Layout.messageListPaddingBottom + 1) - #endif - .id(self.scrollerBottomID) - } - // Use scroll targets for stable auto-scroll without ScrollViewReader relayout glitches. - .scrollTargetLayout() - .padding(.top, Layout.messageListPaddingTop) - .padding(.horizontal, Layout.messageListPaddingHorizontal) - } - #if !os(macOS) - .scrollDismissesKeyboard(.interactively) - #endif - // Keep the scroll pinned to the bottom for new messages. - .scrollPosition(id: self.$scrollPosition, anchor: .bottom) - .onChange(of: self.scrollPosition) { _, position in - guard let position else { return } - self.isPinnedToBottom = position == self.scrollerBottomID - } - - if self.viewModel.isLoading { - ProgressView() - .controlSize(.large) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - self.messageListOverlay - } - // Ensure the message list claims vertical space on the first layout pass. - .frame(maxHeight: .infinity, alignment: .top) - .layoutPriority(1) - .simultaneousGesture( - TapGesture().onEnded { - self.dismissKeyboardIfNeeded() - }) - .onChange(of: self.viewModel.isLoading) { _, isLoading in - guard !isLoading, !self.hasPerformedInitialScroll else { return } - self.scrollPosition = self.scrollerBottomID - self.hasPerformedInitialScroll = true - self.isPinnedToBottom = true - } - .onChange(of: self.viewModel.sessionKey) { _, _ in - self.hasPerformedInitialScroll = false - self.isPinnedToBottom = true - } - .onChange(of: self.viewModel.isSending) { _, isSending in - // Scroll to bottom when user sends a message, even if scrolled up. - guard isSending, self.hasPerformedInitialScroll else { return } - self.isPinnedToBottom = true - withAnimation(.snappy(duration: 0.22)) { - self.scrollPosition = self.scrollerBottomID - } - } - .onChange(of: self.viewModel.messages.count) { _, _ in - guard self.hasPerformedInitialScroll else { return } - if let lastMessage = self.viewModel.messages.last, - lastMessage.role.lowercased() == "user", - lastMessage.id != self.lastUserMessageID { - self.lastUserMessageID = lastMessage.id - self.isPinnedToBottom = true - withAnimation(.snappy(duration: 0.22)) { - self.scrollPosition = self.scrollerBottomID - } - return - } - - guard self.isPinnedToBottom else { return } - withAnimation(.snappy(duration: 0.22)) { - self.scrollPosition = self.scrollerBottomID - } - } - .onChange(of: self.viewModel.pendingRunCount) { _, _ in - guard self.hasPerformedInitialScroll, self.isPinnedToBottom else { return } - withAnimation(.snappy(duration: 0.22)) { - self.scrollPosition = self.scrollerBottomID - } - } - .onChange(of: self.viewModel.streamingAssistantText) { _, _ in - guard self.hasPerformedInitialScroll, self.isPinnedToBottom else { return } - withAnimation(.snappy(duration: 0.22)) { - self.scrollPosition = self.scrollerBottomID - } - } - } - - @ViewBuilder - private var messageListRows: some View { - ForEach(self.visibleMessages) { msg in - ChatMessageBubble( - message: msg, - style: self.style, - markdownVariant: self.markdownVariant, - userAccent: self.userAccent) - .frame( - maxWidth: .infinity, - alignment: msg.role.lowercased() == "user" ? .trailing : .leading) - } - - if self.viewModel.pendingRunCount > 0 { - HStack { - ChatTypingIndicatorBubble(style: self.style) - .equatable() - Spacer(minLength: 0) - } - } - - if !self.viewModel.pendingToolCalls.isEmpty { - ChatPendingToolsBubble(toolCalls: self.viewModel.pendingToolCalls) - .equatable() - .frame(maxWidth: .infinity, alignment: .leading) - } - - if let text = self.viewModel.streamingAssistantText, AssistantTextParser.hasVisibleContent(in: text) { - ChatStreamingAssistantBubble(text: text, markdownVariant: self.markdownVariant) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - - private var visibleMessages: [OpenClawChatMessage] { - let base: [OpenClawChatMessage] - if self.style == .onboarding { - guard let first = self.viewModel.messages.first else { return [] } - base = first.role.lowercased() == "user" ? Array(self.viewModel.messages.dropFirst()) : self.viewModel - .messages - } else { - base = self.viewModel.messages - } - return self.mergeToolResults(in: base) - } - - @ViewBuilder - private var messageListOverlay: some View { - if self.viewModel.isLoading { - EmptyView() - } else if let error = self.activeErrorText { - let presentation = self.errorPresentation(for: error) - if self.hasVisibleMessageListContent { - VStack(spacing: 0) { - ChatNoticeBanner( - systemImage: presentation.systemImage, - title: presentation.title, - message: error, - tint: presentation.tint, - dismiss: { self.viewModel.errorText = nil }, - refresh: { self.viewModel.refresh() }) - Spacer(minLength: 0) - } - .padding(.horizontal, 10) - .padding(.top, 8) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - } else { - ChatNoticeCard( - systemImage: presentation.systemImage, - title: presentation.title, - message: error, - tint: presentation.tint, - actionTitle: "Refresh", - action: { self.viewModel.refresh() }) - .padding(.horizontal, 24) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - } else if self.showsEmptyState { - ChatNoticeCard( - systemImage: "bubble.left.and.bubble.right.fill", - title: self.emptyStateTitle, - message: self.emptyStateMessage, - tint: .accentColor, - actionTitle: nil, - action: nil) - .padding(.horizontal, 24) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - } - - private var activeErrorText: String? { - guard let text = self.viewModel.errorText? - .trimmingCharacters(in: .whitespacesAndNewlines), - !text.isEmpty - else { - return nil - } - return text - } - - private var hasVisibleMessageListContent: Bool { - if !self.visibleMessages.isEmpty { - return true - } - if let text = self.viewModel.streamingAssistantText, - AssistantTextParser.hasVisibleContent(in: text) - { - return true - } - if self.viewModel.pendingRunCount > 0 { - return true - } - if !self.viewModel.pendingToolCalls.isEmpty { - return true - } - return false - } - - private var showsEmptyState: Bool { - self.viewModel.messages.isEmpty && - !(self.viewModel.streamingAssistantText.map { AssistantTextParser.hasVisibleContent(in: $0) } ?? false) && - self.viewModel.pendingRunCount == 0 && - self.viewModel.pendingToolCalls.isEmpty - } - - private var emptyStateTitle: String { - #if os(macOS) - "Web Chat" - #else - "Chat" - #endif - } - - private var emptyStateMessage: String { - #if os(macOS) - "Type a message below to start.\nReturn sends • Shift-Return adds a line break." - #else - "Type a message below to start." - #endif - } - - private func errorPresentation(for error: String) -> (title: String, systemImage: String, tint: Color) { - let lower = error.lowercased() - if lower.contains("not connected") || lower.contains("socket") { - return ("Disconnected", "wifi.slash", .orange) - } - if lower.contains("timed out") { - return ("Timed out", "clock.badge.exclamationmark", .orange) - } - return ("Error", "exclamationmark.triangle.fill", .orange) - } - - private func mergeToolResults(in messages: [OpenClawChatMessage]) -> [OpenClawChatMessage] { - var result: [OpenClawChatMessage] = [] - result.reserveCapacity(messages.count) - - for message in messages { - guard self.isToolResultMessage(message) else { - result.append(message) - continue - } - - guard let toolCallId = message.toolCallId, - let last = result.last, - self.toolCallIds(in: last).contains(toolCallId) - else { - result.append(message) - continue - } - - let toolText = self.toolResultText(from: message) - if toolText.isEmpty { - continue - } - - var content = last.content - content.append( - OpenClawChatMessageContent( - type: "tool_result", - text: toolText, - thinking: nil, - thinkingSignature: nil, - mimeType: nil, - fileName: nil, - content: nil, - id: toolCallId, - name: message.toolName, - arguments: nil)) - - let merged = OpenClawChatMessage( - id: last.id, - role: last.role, - content: content, - timestamp: last.timestamp, - toolCallId: last.toolCallId, - toolName: last.toolName, - usage: last.usage, - stopReason: last.stopReason) - result[result.count - 1] = merged - } - - return result - } - - private func isToolResultMessage(_ message: OpenClawChatMessage) -> Bool { - let role = message.role.lowercased() - return role == "toolresult" || role == "tool_result" - } - - private func toolCallIds(in message: OpenClawChatMessage) -> Set { - var ids = Set() - for content in message.content { - let kind = (content.type ?? "").lowercased() - let isTool = - ["toolcall", "tool_call", "tooluse", "tool_use"].contains(kind) || - (content.name != nil && content.arguments != nil) - if isTool, let id = content.id { - ids.insert(id) - } - } - if let toolCallId = message.toolCallId { - ids.insert(toolCallId) - } - return ids - } - - private func toolResultText(from message: OpenClawChatMessage) -> String { - let parts = message.content.compactMap { content -> String? in - let kind = (content.type ?? "text").lowercased() - guard kind == "text" || kind.isEmpty else { return nil } - return content.text - } - return parts.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) - } - - private func dismissKeyboardIfNeeded() { - #if canImport(UIKit) - UIApplication.shared.sendAction( - #selector(UIResponder.resignFirstResponder), - to: nil, - from: nil, - for: nil) - #endif - } -} - -private struct ChatNoticeCard: View { - let systemImage: String - let title: String - let message: String - let tint: Color - let actionTitle: String? - let action: (() -> Void)? - - var body: some View { - VStack(spacing: 12) { - ZStack { - Circle() - .fill(self.tint.opacity(0.16)) - Image(systemName: self.systemImage) - .font(.system(size: 24, weight: .semibold)) - .foregroundStyle(self.tint) - } - .frame(width: 52, height: 52) - - Text(self.title) - .font(.headline) - - Text(self.message) - .font(.callout) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .lineLimit(4) - .frame(maxWidth: 360) - - if let actionTitle, let action { - Button(actionTitle, action: action) - .buttonStyle(.borderedProminent) - .controlSize(.small) - } - } - .padding(18) - .background( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .fill(OpenClawChatTheme.subtleCard) - .overlay( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .strokeBorder(Color.white.opacity(0.12), lineWidth: 1))) - .shadow(color: .black.opacity(0.14), radius: 18, y: 8) - } -} - -private struct ChatNoticeBanner: View { - let systemImage: String - let title: String - let message: String - let tint: Color - let dismiss: () -> Void - let refresh: () -> Void - - var body: some View { - HStack(alignment: .top, spacing: 10) { - Image(systemName: self.systemImage) - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(self.tint) - .padding(.top, 1) - - VStack(alignment: .leading, spacing: 3) { - Text(self.title) - .font(.caption.weight(.semibold)) - - Text(self.message) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(2) - } - - Spacer(minLength: 0) - - Button(action: self.refresh) { - Image(systemName: "arrow.clockwise") - } - .buttonStyle(.bordered) - .controlSize(.small) - .help("Refresh") - - Button(action: self.dismiss) { - Image(systemName: "xmark") - } - .buttonStyle(.plain) - .foregroundStyle(.secondary) - .help("Dismiss") - } - .padding(.horizontal, 12) - .padding(.vertical, 10) - .background( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(OpenClawChatTheme.subtleCard) - .overlay( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .strokeBorder(Color.white.opacity(0.12), lineWidth: 1))) - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift deleted file mode 100644 index 62cb97a0e2f..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift +++ /dev/null @@ -1,685 +0,0 @@ -import OpenClawKit -import Foundation -import Observation -import OSLog -import UniformTypeIdentifiers - -#if canImport(AppKit) -import AppKit -#elseif canImport(UIKit) -import UIKit -#endif - -private let chatUILogger = Logger(subsystem: "ai.openclaw", category: "OpenClawChatUI") - -@MainActor -@Observable -public final class OpenClawChatViewModel { - public private(set) var messages: [OpenClawChatMessage] = [] - public var input: String = "" - public var thinkingLevel: String = "off" - public private(set) var isLoading = false - public private(set) var isSending = false - public private(set) var isAborting = false - public var errorText: String? - public var attachments: [OpenClawPendingAttachment] = [] - public private(set) var healthOK: Bool = false - public private(set) var pendingRunCount: Int = 0 - - public private(set) var sessionKey: String - public private(set) var sessionId: String? - public private(set) var streamingAssistantText: String? - public private(set) var pendingToolCalls: [OpenClawChatPendingToolCall] = [] - public private(set) var sessions: [OpenClawChatSessionEntry] = [] - private let transport: any OpenClawChatTransport - - @ObservationIgnored - private nonisolated(unsafe) var eventTask: Task? - private var pendingRuns = Set() { - didSet { self.pendingRunCount = self.pendingRuns.count } - } - - @ObservationIgnored - private nonisolated(unsafe) var pendingRunTimeoutTasks: [String: Task] = [:] - private let pendingRunTimeoutMs: UInt64 = 120_000 - - private var pendingToolCallsById: [String: OpenClawChatPendingToolCall] = [:] { - didSet { - self.pendingToolCalls = self.pendingToolCallsById.values - .sorted { ($0.startedAt ?? 0) < ($1.startedAt ?? 0) } - } - } - - private var lastHealthPollAt: Date? - - public init(sessionKey: String, transport: any OpenClawChatTransport) { - self.sessionKey = sessionKey - self.transport = transport - - self.eventTask = Task { [weak self] in - guard let self else { return } - let stream = self.transport.events() - for await evt in stream { - if Task.isCancelled { return } - await MainActor.run { [weak self] in - self?.handleTransportEvent(evt) - } - } - } - } - - deinit { - self.eventTask?.cancel() - for (_, task) in self.pendingRunTimeoutTasks { - task.cancel() - } - } - - public func load() { - Task { await self.bootstrap() } - } - - public func refresh() { - Task { await self.bootstrap() } - } - - public func send() { - Task { await self.performSend() } - } - - public func abort() { - Task { await self.performAbort() } - } - - public func refreshSessions(limit: Int? = nil) { - Task { await self.fetchSessions(limit: limit) } - } - - public func switchSession(to sessionKey: String) { - Task { await self.performSwitchSession(to: sessionKey) } - } - - public var sessionChoices: [OpenClawChatSessionEntry] { - let now = Date().timeIntervalSince1970 * 1000 - let cutoff = now - (24 * 60 * 60 * 1000) - let sorted = self.sessions.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) } - - var result: [OpenClawChatSessionEntry] = [] - var included = Set() - - // Always show the main session first, even if it hasn't been updated recently. - if let main = sorted.first(where: { $0.key == "main" }) { - result.append(main) - included.insert(main.key) - } else { - result.append(self.placeholderSession(key: "main")) - included.insert("main") - } - - for entry in sorted { - guard !included.contains(entry.key) else { continue } - guard (entry.updatedAt ?? 0) >= cutoff else { continue } - result.append(entry) - included.insert(entry.key) - } - - if !included.contains(self.sessionKey) { - if let current = sorted.first(where: { $0.key == self.sessionKey }) { - result.append(current) - } else { - result.append(self.placeholderSession(key: self.sessionKey)) - } - } - - return result - } - - public func addAttachments(urls: [URL]) { - Task { await self.loadAttachments(urls: urls) } - } - - public func addImageAttachment(data: Data, fileName: String, mimeType: String) { - Task { await self.addImageAttachment(url: nil, data: data, fileName: fileName, mimeType: mimeType) } - } - - public func removeAttachment(_ id: OpenClawPendingAttachment.ID) { - self.attachments.removeAll { $0.id == id } - } - - public var canSend: Bool { - let trimmed = self.input.trimmingCharacters(in: .whitespacesAndNewlines) - return !self.isSending && self.pendingRunCount == 0 && (!trimmed.isEmpty || !self.attachments.isEmpty) - } - - // MARK: - Internals - - private func bootstrap() async { - self.isLoading = true - self.errorText = nil - self.healthOK = false - self.clearPendingRuns(reason: nil) - self.pendingToolCallsById = [:] - self.streamingAssistantText = nil - self.sessionId = nil - defer { self.isLoading = false } - do { - do { - try await self.transport.setActiveSessionKey(self.sessionKey) - } catch { - // Best-effort only; history/send/health still work without push events. - } - - let payload = try await self.transport.requestHistory(sessionKey: self.sessionKey) - self.messages = Self.reconcileMessageIDs( - previous: self.messages, - incoming: Self.decodeMessages(payload.messages ?? [])) - self.sessionId = payload.sessionId - if let level = payload.thinkingLevel, !level.isEmpty { - self.thinkingLevel = level - } - await self.pollHealthIfNeeded(force: true) - await self.fetchSessions(limit: 50) - self.errorText = nil - } catch { - self.errorText = error.localizedDescription - chatUILogger.error("bootstrap failed \(error.localizedDescription, privacy: .public)") - } - } - - private static func decodeMessages(_ raw: [AnyCodable]) -> [OpenClawChatMessage] { - let decoded = raw.compactMap { item in - (try? ChatPayloadDecoding.decode(item, as: OpenClawChatMessage.self)) - .map { Self.stripInboundMetadata(from: $0) } - } - return Self.dedupeMessages(decoded) - } - - private static func stripInboundMetadata(from message: OpenClawChatMessage) -> OpenClawChatMessage { - guard message.role.lowercased() == "user" else { - return message - } - - let sanitizedContent = message.content.map { content -> OpenClawChatMessageContent in - guard let text = content.text else { return content } - let cleaned = ChatMarkdownPreprocessor.preprocess(markdown: text).cleaned - return OpenClawChatMessageContent( - type: content.type, - text: cleaned, - thinking: content.thinking, - thinkingSignature: content.thinkingSignature, - mimeType: content.mimeType, - fileName: content.fileName, - content: content.content, - id: content.id, - name: content.name, - arguments: content.arguments) - } - - return OpenClawChatMessage( - id: message.id, - role: message.role, - content: sanitizedContent, - timestamp: message.timestamp, - toolCallId: message.toolCallId, - toolName: message.toolName, - usage: message.usage, - stopReason: message.stopReason) - } - - private static func messageIdentityKey(for message: OpenClawChatMessage) -> String? { - let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !role.isEmpty else { return nil } - - let timestamp: String = { - guard let value = message.timestamp, value.isFinite else { return "" } - return String(format: "%.3f", value) - }() - - let contentFingerprint = message.content.map { item in - let type = (item.type ?? "text").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let text = (item.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - let id = (item.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - let name = (item.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - let fileName = (item.fileName ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - return [type, text, id, name, fileName].joined(separator: "\\u{001F}") - }.joined(separator: "\\u{001E}") - - let toolCallId = (message.toolCallId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - let toolName = (message.toolName ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - if timestamp.isEmpty, contentFingerprint.isEmpty, toolCallId.isEmpty, toolName.isEmpty { - return nil - } - return [role, timestamp, toolCallId, toolName, contentFingerprint].joined(separator: "|") - } - - private static func reconcileMessageIDs( - previous: [OpenClawChatMessage], - incoming: [OpenClawChatMessage]) -> [OpenClawChatMessage] - { - guard !previous.isEmpty, !incoming.isEmpty else { return incoming } - - var idsByKey: [String: [UUID]] = [:] - for message in previous { - guard let key = Self.messageIdentityKey(for: message) else { continue } - idsByKey[key, default: []].append(message.id) - } - - return incoming.map { message in - guard let key = Self.messageIdentityKey(for: message), - var ids = idsByKey[key], - let reusedId = ids.first - else { - return message - } - ids.removeFirst() - if ids.isEmpty { - idsByKey.removeValue(forKey: key) - } else { - idsByKey[key] = ids - } - guard reusedId != message.id else { return message } - return OpenClawChatMessage( - id: reusedId, - role: message.role, - content: message.content, - timestamp: message.timestamp, - toolCallId: message.toolCallId, - toolName: message.toolName, - usage: message.usage, - stopReason: message.stopReason) - } - } - - private static func dedupeMessages(_ messages: [OpenClawChatMessage]) -> [OpenClawChatMessage] { - var result: [OpenClawChatMessage] = [] - result.reserveCapacity(messages.count) - var seen = Set() - - for message in messages { - guard let key = Self.dedupeKey(for: message) else { - result.append(message) - continue - } - if seen.contains(key) { continue } - seen.insert(key) - result.append(message) - } - - return result - } - - private static func dedupeKey(for message: OpenClawChatMessage) -> String? { - guard let timestamp = message.timestamp else { return nil } - let text = message.content.compactMap(\.text).joined(separator: "\n") - .trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty else { return nil } - return "\(message.role)|\(timestamp)|\(text)" - } - - private func performSend() async { - guard !self.isSending else { return } - let trimmed = self.input.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty || !self.attachments.isEmpty else { return } - - guard self.healthOK else { - self.errorText = "Gateway health not OK; cannot send" - return - } - - self.isSending = true - self.errorText = nil - let runId = UUID().uuidString - let messageText = trimmed.isEmpty && !self.attachments.isEmpty ? "See attached." : trimmed - self.pendingRuns.insert(runId) - self.armPendingRunTimeout(runId: runId) - self.pendingToolCallsById = [:] - self.streamingAssistantText = nil - - // Optimistically append user message to UI. - var userContent: [OpenClawChatMessageContent] = [ - OpenClawChatMessageContent( - type: "text", - text: messageText, - thinking: nil, - thinkingSignature: nil, - mimeType: nil, - fileName: nil, - content: nil, - id: nil, - name: nil, - arguments: nil), - ] - let encodedAttachments = self.attachments.map { att -> OpenClawChatAttachmentPayload in - OpenClawChatAttachmentPayload( - type: att.type, - mimeType: att.mimeType, - fileName: att.fileName, - content: att.data.base64EncodedString()) - } - for att in encodedAttachments { - userContent.append( - OpenClawChatMessageContent( - type: att.type, - text: nil, - thinking: nil, - thinkingSignature: nil, - mimeType: att.mimeType, - fileName: att.fileName, - content: AnyCodable(att.content), - id: nil, - name: nil, - arguments: nil)) - } - self.messages.append( - OpenClawChatMessage( - id: UUID(), - role: "user", - content: userContent, - timestamp: Date().timeIntervalSince1970 * 1000)) - - // Clear input immediately for responsive UX (before network await) - self.input = "" - self.attachments = [] - - do { - let response = try await self.transport.sendMessage( - sessionKey: self.sessionKey, - message: messageText, - thinking: self.thinkingLevel, - idempotencyKey: runId, - attachments: encodedAttachments) - if response.runId != runId { - self.clearPendingRun(runId) - self.pendingRuns.insert(response.runId) - self.armPendingRunTimeout(runId: response.runId) - } - } catch { - self.clearPendingRun(runId) - self.errorText = error.localizedDescription - chatUILogger.error("chat.send failed \(error.localizedDescription, privacy: .public)") - } - - self.isSending = false - } - - private func performAbort() async { - guard !self.pendingRuns.isEmpty else { return } - guard !self.isAborting else { return } - self.isAborting = true - defer { self.isAborting = false } - - let runIds = Array(self.pendingRuns) - for runId in runIds { - do { - try await self.transport.abortRun(sessionKey: self.sessionKey, runId: runId) - } catch { - // Best-effort. - } - } - } - - private func fetchSessions(limit: Int?) async { - do { - let res = try await self.transport.listSessions(limit: limit) - self.sessions = res.sessions - } catch { - // Best-effort. - } - } - - private func performSwitchSession(to sessionKey: String) async { - let next = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) - guard !next.isEmpty else { return } - guard next != self.sessionKey else { return } - self.sessionKey = next - await self.bootstrap() - } - - private func placeholderSession(key: String) -> OpenClawChatSessionEntry { - OpenClawChatSessionEntry( - key: key, - kind: nil, - displayName: nil, - surface: nil, - subject: nil, - room: nil, - space: nil, - updatedAt: nil, - sessionId: nil, - systemSent: nil, - abortedLastRun: nil, - thinkingLevel: nil, - verboseLevel: nil, - inputTokens: nil, - outputTokens: nil, - totalTokens: nil, - model: nil, - contextTokens: nil) - } - - private func handleTransportEvent(_ evt: OpenClawChatTransportEvent) { - switch evt { - case let .health(ok): - self.healthOK = ok - case .tick: - Task { await self.pollHealthIfNeeded(force: false) } - case let .chat(chat): - self.handleChatEvent(chat) - case let .agent(agent): - self.handleAgentEvent(agent) - case .seqGap: - self.errorText = nil - self.clearPendingRuns(reason: nil) - Task { - await self.refreshHistoryAfterRun() - await self.pollHealthIfNeeded(force: true) - } - } - } - - private func handleChatEvent(_ chat: OpenClawChatEventPayload) { - let isOurRun = chat.runId.flatMap { self.pendingRuns.contains($0) } ?? false - - // Gateway may publish canonical session keys (for example "agent:main:main") - // even when this view currently uses an alias key (for example "main"). - // Never drop events for our own pending run on key mismatch, or the UI can stay - // stuck at "thinking" until the user reopens and forces a history reload. - if let sessionKey = chat.sessionKey, - !Self.matchesCurrentSessionKey(incoming: sessionKey, current: self.sessionKey), - !isOurRun - { - return - } - if !isOurRun { - // Keep multiple clients in sync: if another client finishes a run for our session, refresh history. - switch chat.state { - case "final", "aborted", "error": - self.streamingAssistantText = nil - self.pendingToolCallsById = [:] - Task { await self.refreshHistoryAfterRun() } - default: - break - } - return - } - - switch chat.state { - case "final", "aborted", "error": - if chat.state == "error" { - self.errorText = chat.errorMessage ?? "Chat failed" - } - if let runId = chat.runId { - self.clearPendingRun(runId) - } else if self.pendingRuns.count <= 1 { - self.clearPendingRuns(reason: nil) - } - self.pendingToolCallsById = [:] - self.streamingAssistantText = nil - Task { await self.refreshHistoryAfterRun() } - default: - break - } - } - - private static func matchesCurrentSessionKey(incoming: String, current: String) -> Bool { - let incomingNormalized = incoming.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let currentNormalized = current.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if incomingNormalized == currentNormalized { - return true - } - // Common alias pair in operator clients: UI uses "main" while gateway emits canonical. - if (incomingNormalized == "agent:main:main" && currentNormalized == "main") || - (incomingNormalized == "main" && currentNormalized == "agent:main:main") - { - return true - } - return false - } - - private func handleAgentEvent(_ evt: OpenClawAgentEventPayload) { - if let sessionId, evt.runId != sessionId { - return - } - - switch evt.stream { - case "assistant": - if let text = evt.data["text"]?.value as? String { - self.streamingAssistantText = text - } - case "tool": - guard let phase = evt.data["phase"]?.value as? String else { return } - guard let name = evt.data["name"]?.value as? String else { return } - guard let toolCallId = evt.data["toolCallId"]?.value as? String else { return } - if phase == "start" { - let args = evt.data["args"] - self.pendingToolCallsById[toolCallId] = OpenClawChatPendingToolCall( - toolCallId: toolCallId, - name: name, - args: args, - startedAt: evt.ts.map(Double.init) ?? Date().timeIntervalSince1970 * 1000, - isError: nil) - } else if phase == "result" { - self.pendingToolCallsById[toolCallId] = nil - } - default: - break - } - } - - private func refreshHistoryAfterRun() async { - do { - let payload = try await self.transport.requestHistory(sessionKey: self.sessionKey) - self.messages = Self.reconcileMessageIDs( - previous: self.messages, - incoming: Self.decodeMessages(payload.messages ?? [])) - self.sessionId = payload.sessionId - if let level = payload.thinkingLevel, !level.isEmpty { - self.thinkingLevel = level - } - } catch { - chatUILogger.error("refresh history failed \(error.localizedDescription, privacy: .public)") - } - } - - private func armPendingRunTimeout(runId: String) { - self.pendingRunTimeoutTasks[runId]?.cancel() - self.pendingRunTimeoutTasks[runId] = Task { [weak self] in - let timeoutMs = await MainActor.run { self?.pendingRunTimeoutMs ?? 0 } - try? await Task.sleep(nanoseconds: timeoutMs * 1_000_000) - await MainActor.run { [weak self] in - guard let self else { return } - guard self.pendingRuns.contains(runId) else { return } - self.clearPendingRun(runId) - self.errorText = "Timed out waiting for a reply; try again or refresh." - } - } - } - - private func clearPendingRun(_ runId: String) { - self.pendingRuns.remove(runId) - self.pendingRunTimeoutTasks[runId]?.cancel() - self.pendingRunTimeoutTasks[runId] = nil - } - - private func clearPendingRuns(reason: String?) { - for runId in self.pendingRuns { - self.pendingRunTimeoutTasks[runId]?.cancel() - } - self.pendingRunTimeoutTasks.removeAll() - self.pendingRuns.removeAll() - if let reason, !reason.isEmpty { - self.errorText = reason - } - } - - private func pollHealthIfNeeded(force: Bool) async { - if !force, let last = self.lastHealthPollAt, Date().timeIntervalSince(last) < 10 { - return - } - self.lastHealthPollAt = Date() - do { - let ok = try await self.transport.requestHealth(timeoutMs: 5000) - self.healthOK = ok - } catch { - self.healthOK = false - } - } - - private func loadAttachments(urls: [URL]) async { - for url in urls { - do { - let data = try await Task.detached { try Data(contentsOf: url) }.value - await self.addImageAttachment( - url: url, - data: data, - fileName: url.lastPathComponent, - mimeType: Self.mimeType(for: url) ?? "application/octet-stream") - } catch { - await MainActor.run { self.errorText = error.localizedDescription } - } - } - } - - private static func mimeType(for url: URL) -> String? { - let ext = url.pathExtension - guard !ext.isEmpty else { return nil } - return (UTType(filenameExtension: ext) ?? .data).preferredMIMEType - } - - private func addImageAttachment(url: URL?, data: Data, fileName: String, mimeType: String) async { - if data.count > 5_000_000 { - self.errorText = "Attachment \(fileName) exceeds 5 MB limit" - return - } - - let uti: UTType = { - if let url { - return UTType(filenameExtension: url.pathExtension) ?? .data - } - return UTType(mimeType: mimeType) ?? .data - }() - guard uti.conforms(to: .image) else { - self.errorText = "Only image attachments are supported right now" - return - } - - let preview = Self.previewImage(data: data) - self.attachments.append( - OpenClawPendingAttachment( - url: url, - data: data, - fileName: fileName, - mimeType: mimeType, - preview: preview)) - } - - private static func previewImage(data: Data) -> OpenClawPlatformImage? { - #if canImport(AppKit) - NSImage(data: data) - #elseif canImport(UIKit) - UIImage(data: data) - #else - nil - #endif - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ToolResultTextFormatter.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ToolResultTextFormatter.swift deleted file mode 100644 index 719e82cdf15..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ToolResultTextFormatter.swift +++ /dev/null @@ -1,157 +0,0 @@ -import Foundation - -enum ToolResultTextFormatter { - static func format(text: String, toolName: String?) -> String { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return "" } - - guard self.looksLikeJSON(trimmed), - let data = trimmed.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) - else { - return trimmed - } - - let normalizedTool = toolName?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - return self.renderJSON(json, toolName: normalizedTool) - } - - private static func looksLikeJSON(_ value: String) -> Bool { - guard let first = value.first else { return false } - return first == "{" || first == "[" - } - - private static func renderJSON(_ json: Any, toolName: String?) -> String { - if let dict = json as? [String: Any] { - return self.renderDictionary(dict, toolName: toolName) - } - if let array = json as? [Any] { - if array.isEmpty { return "No items." } - return "\(array.count) item\(array.count == 1 ? "" : "s")." - } - return "" - } - - private static func renderDictionary(_ dict: [String: Any], toolName: String?) -> String { - let status = (dict["status"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - let errorText = self.firstString(in: dict, keys: ["error", "reason"]) - let messageText = self.firstString(in: dict, keys: ["message", "result", "detail"]) - - if status?.lowercased() == "error" || errorText != nil { - if let errorText { - return "Error: \(self.sanitizeError(errorText))" - } - if let messageText { - return "Error: \(self.sanitizeError(messageText))" - } - return "Error" - } - - if toolName == "nodes", let summary = self.renderNodesSummary(dict) { - return summary - } - - if let message = messageText { - return message - } - - if let status, !status.isEmpty { - return "Status: \(status)" - } - - return "" - } - - private static func renderNodesSummary(_ dict: [String: Any]) -> String? { - if let nodes = dict["nodes"] as? [[String: Any]] { - if nodes.isEmpty { return "No nodes found." } - var lines: [String] = [] - lines.append("\(nodes.count) node\(nodes.count == 1 ? "" : "s") found.") - - for node in nodes.prefix(3) { - let label = self.firstString(in: node, keys: ["displayName", "name", "nodeId"]) ?? "Node" - var details: [String] = [] - - if let connected = node["connected"] as? Bool { - details.append(connected ? "connected" : "offline") - } - if let platform = self.firstString(in: node, keys: ["platform"]) { - details.append(platform) - } - if let version = self.firstString(in: node, keys: ["osVersion", "appVersion", "version"]) { - details.append(version) - } - if let pairing = self.pairingDetail(node) { - details.append(pairing) - } - - if details.isEmpty { - lines.append("• \(label)") - } else { - lines.append("• \(label) - \(details.joined(separator: ", "))") - } - } - - let extra = nodes.count - 3 - if extra > 0 { - lines.append("... +\(extra) more") - } - return lines.joined(separator: "\n") - } - - if let pending = dict["pending"] as? [Any], let paired = dict["paired"] as? [Any] { - return "Pairing requests: \(pending.count) pending, \(paired.count) paired." - } - - if let pending = dict["pending"] as? [Any] { - if pending.isEmpty { return "No pending pairing requests." } - return "\(pending.count) pending pairing request\(pending.count == 1 ? "" : "s")." - } - - return nil - } - - private static func pairingDetail(_ node: [String: Any]) -> String? { - if let paired = node["paired"] as? Bool, !paired { - return "pairing required" - } - - for key in ["status", "state", "deviceStatus"] { - if let raw = node[key] as? String, raw.lowercased().contains("pairing required") { - return "pairing required" - } - } - return nil - } - - private static func firstString(in dict: [String: Any], keys: [String]) -> String? { - for key in keys { - if let value = dict[key] as? String { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { - return trimmed - } - } - } - return nil - } - - private static func sanitizeError(_ raw: String) -> String { - var cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines) - if cleaned.contains("agent="), - cleaned.contains("action="), - let marker = cleaned.range(of: ": ") - { - cleaned = String(cleaned[marker.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines) - } - - if let firstLine = cleaned.split(separator: "\n").first { - cleaned = String(firstLine).trimmingCharacters(in: .whitespacesAndNewlines) - } - - if cleaned.count > 220 { - cleaned = String(cleaned.prefix(217)) + "..." - } - return cleaned - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/AnyCodable.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/AnyCodable.swift deleted file mode 100644 index 02b53e3c392..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/AnyCodable.swift +++ /dev/null @@ -1,4 +0,0 @@ -import OpenClawProtocol - -public typealias AnyCodable = OpenClawProtocol.AnyCodable - diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/AsyncTimeout.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/AsyncTimeout.swift deleted file mode 100644 index eed2d758ae7..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/AsyncTimeout.swift +++ /dev/null @@ -1,36 +0,0 @@ -import Foundation - -public enum AsyncTimeout { - public static func withTimeout( - seconds: Double, - onTimeout: @escaping @Sendable () -> Error, - operation: @escaping @Sendable () async throws -> T) async throws -> T - { - let clamped = max(0, seconds) - if clamped == 0 { - return try await operation() - } - - return try await withThrowingTaskGroup(of: T.self) { group in - group.addTask { try await operation() } - group.addTask { - try await Task.sleep(nanoseconds: UInt64(clamped * 1_000_000_000)) - throw onTimeout() - } - let result = try await group.next() - group.cancelAll() - if let result { return result } - throw onTimeout() - } - } - - public static func withTimeoutMs( - timeoutMs: Int, - onTimeout: @escaping @Sendable () -> Error, - operation: @escaping @Sendable () async throws -> T) async throws -> T - { - let clamped = max(0, timeoutMs) - let seconds = Double(clamped) / 1000.0 - return try await self.withTimeout(seconds: seconds, onTimeout: onTimeout, operation: operation) - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/AudioStreamingProtocols.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/AudioStreamingProtocols.swift deleted file mode 100644 index a211a4b3a2a..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/AudioStreamingProtocols.swift +++ /dev/null @@ -1,16 +0,0 @@ -import Foundation - -@MainActor -public protocol StreamingAudioPlaying { - func play(stream: AsyncThrowingStream) async -> StreamingPlaybackResult - func stop() -> Double? -} - -@MainActor -public protocol PCMStreamingAudioPlaying { - func play(stream: AsyncThrowingStream, sampleRate: Double) async -> StreamingPlaybackResult - func stop() -> Double? -} - -extension StreamingAudioPlayer: StreamingAudioPlaying {} -extension PCMStreamingAudioPlayer: PCMStreamingAudioPlaying {} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/BonjourEscapes.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/BonjourEscapes.swift deleted file mode 100644 index 0760314f727..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/BonjourEscapes.swift +++ /dev/null @@ -1,33 +0,0 @@ -import Foundation - -public enum BonjourEscapes { - /// mDNS / DNS-SD commonly escapes bytes in instance names as `\DDD` (decimal-encoded), - /// e.g. spaces are `\032`. - public static func decode(_ input: String) -> String { - var out = "" - var i = input.startIndex - while i < input.endIndex { - if input[i] == "\\", - let d0 = input.index(i, offsetBy: 1, limitedBy: input.index(before: input.endIndex)), - let d1 = input.index(i, offsetBy: 2, limitedBy: input.index(before: input.endIndex)), - let d2 = input.index(i, offsetBy: 3, limitedBy: input.index(before: input.endIndex)), - input[d0].isNumber, - input[d1].isNumber, - input[d2].isNumber - { - let digits = String(input[d0...d2]) - if let value = Int(digits), - let scalar = UnicodeScalar(value) - { - out.append(Character(scalar)) - i = input.index(i, offsetBy: 4) - continue - } - } - - out.append(input[i]) - i = input.index(after: i) - } - return out - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/BonjourTypes.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/BonjourTypes.swift deleted file mode 100644 index 5c3c50ca482..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/BonjourTypes.swift +++ /dev/null @@ -1,40 +0,0 @@ -import Foundation - -public enum OpenClawBonjour { - // v0: internal-only, subject to rename. - public static let gatewayServiceType = "_openclaw-gw._tcp" - public static let gatewayServiceDomain = "local." - public static var wideAreaGatewayServiceDomain: String? { - let env = ProcessInfo.processInfo.environment - return resolveWideAreaDomain(env["OPENCLAW_WIDE_AREA_DOMAIN"]) - } - - public static var gatewayServiceDomains: [String] { - var domains = [gatewayServiceDomain] - if let wideArea = wideAreaGatewayServiceDomain { - domains.append(wideArea) - } - return domains - } - - private static func resolveWideAreaDomain(_ raw: String?) -> String? { - let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { return nil } - let normalized = normalizeServiceDomain(trimmed) - return normalized == gatewayServiceDomain ? nil : normalized - } - - public static func normalizeServiceDomain(_ raw: String?) -> String { - let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { - return self.gatewayServiceDomain - } - - let lower = trimmed.lowercased() - if lower == "local" || lower == "local." { - return self.gatewayServiceDomain - } - - return lower.hasSuffix(".") ? lower : (lower + ".") - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/BridgeFrames.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/BridgeFrames.swift deleted file mode 100644 index 648b257bbb4..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/BridgeFrames.swift +++ /dev/null @@ -1,261 +0,0 @@ -import Foundation - -public struct BridgeBaseFrame: Codable, Sendable { - public let type: String - - public init(type: String) { - self.type = type - } -} - -public struct BridgeInvokeRequest: Codable, Sendable { - public let type: String - public let id: String - public let command: String - public let paramsJSON: String? - - public init(type: String = "invoke", id: String, command: String, paramsJSON: String? = nil) { - self.type = type - self.id = id - self.command = command - self.paramsJSON = paramsJSON - } -} - -public struct BridgeInvokeResponse: Codable, Sendable { - public let type: String - public let id: String - public let ok: Bool - public let payloadJSON: String? - public let error: OpenClawNodeError? - - public init( - type: String = "invoke-res", - id: String, - ok: Bool, - payloadJSON: String? = nil, - error: OpenClawNodeError? = nil) - { - self.type = type - self.id = id - self.ok = ok - self.payloadJSON = payloadJSON - self.error = error - } -} - -public struct BridgeEventFrame: Codable, Sendable { - public let type: String - public let event: String - public let payloadJSON: String? - - public init(type: String = "event", event: String, payloadJSON: String? = nil) { - self.type = type - self.event = event - self.payloadJSON = payloadJSON - } -} - -public struct BridgeHello: Codable, Sendable { - public let type: String - public let nodeId: String - public let displayName: String? - public let token: String? - public let platform: String? - public let version: String? - public let coreVersion: String? - public let uiVersion: String? - public let deviceFamily: String? - public let modelIdentifier: String? - public let caps: [String]? - public let commands: [String]? - public let permissions: [String: Bool]? - - public init( - type: String = "hello", - nodeId: String, - displayName: String?, - token: String?, - platform: String?, - version: String?, - coreVersion: String? = nil, - uiVersion: String? = nil, - deviceFamily: String? = nil, - modelIdentifier: String? = nil, - caps: [String]? = nil, - commands: [String]? = nil, - permissions: [String: Bool]? = nil) - { - self.type = type - self.nodeId = nodeId - self.displayName = displayName - self.token = token - self.platform = platform - self.version = version - self.coreVersion = coreVersion - self.uiVersion = uiVersion - self.deviceFamily = deviceFamily - self.modelIdentifier = modelIdentifier - self.caps = caps - self.commands = commands - self.permissions = permissions - } -} - -public struct BridgeHelloOk: Codable, Sendable { - public let type: String - public let serverName: String - public let canvasHostUrl: String? - public let mainSessionKey: String? - - public init( - type: String = "hello-ok", - serverName: String, - canvasHostUrl: String? = nil, - mainSessionKey: String? = nil) - { - self.type = type - self.serverName = serverName - self.canvasHostUrl = canvasHostUrl - self.mainSessionKey = mainSessionKey - } -} - -public struct BridgePairRequest: Codable, Sendable { - public let type: String - public let nodeId: String - public let displayName: String? - public let platform: String? - public let version: String? - public let coreVersion: String? - public let uiVersion: String? - public let deviceFamily: String? - public let modelIdentifier: String? - public let caps: [String]? - public let commands: [String]? - public let permissions: [String: Bool]? - public let remoteAddress: String? - public let silent: Bool? - - public init( - type: String = "pair-request", - nodeId: String, - displayName: String?, - platform: String?, - version: String?, - coreVersion: String? = nil, - uiVersion: String? = nil, - deviceFamily: String? = nil, - modelIdentifier: String? = nil, - caps: [String]? = nil, - commands: [String]? = nil, - permissions: [String: Bool]? = nil, - remoteAddress: String? = nil, - silent: Bool? = nil) - { - self.type = type - self.nodeId = nodeId - self.displayName = displayName - self.platform = platform - self.version = version - self.coreVersion = coreVersion - self.uiVersion = uiVersion - self.deviceFamily = deviceFamily - self.modelIdentifier = modelIdentifier - self.caps = caps - self.commands = commands - self.permissions = permissions - self.remoteAddress = remoteAddress - self.silent = silent - } -} - -public struct BridgePairOk: Codable, Sendable { - public let type: String - public let token: String - - public init(type: String = "pair-ok", token: String) { - self.type = type - self.token = token - } -} - -public struct BridgePing: Codable, Sendable { - public let type: String - public let id: String - - public init(type: String = "ping", id: String) { - self.type = type - self.id = id - } -} - -public struct BridgePong: Codable, Sendable { - public let type: String - public let id: String - - public init(type: String = "pong", id: String) { - self.type = type - self.id = id - } -} - -public struct BridgeErrorFrame: Codable, Sendable { - public let type: String - public let code: String - public let message: String - - public init(type: String = "error", code: String, message: String) { - self.type = type - self.code = code - self.message = message - } -} - -// MARK: - Optional RPC (node -> bridge) - -public struct BridgeRPCRequest: Codable, Sendable { - public let type: String - public let id: String - public let method: String - public let paramsJSON: String? - - public init(type: String = "req", id: String, method: String, paramsJSON: String? = nil) { - self.type = type - self.id = id - self.method = method - self.paramsJSON = paramsJSON - } -} - -public struct BridgeRPCError: Codable, Sendable, Equatable { - public let code: String - public let message: String - - public init(code: String, message: String) { - self.code = code - self.message = message - } -} - -public struct BridgeRPCResponse: Codable, Sendable { - public let type: String - public let id: String - public let ok: Bool - public let payloadJSON: String? - public let error: BridgeRPCError? - - public init( - type: String = "res", - id: String, - ok: Bool, - payloadJSON: String? = nil, - error: BridgeRPCError? = nil) - { - self.type = type - self.id = id - self.ok = ok - self.payloadJSON = payloadJSON - self.error = error - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/CalendarCommands.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/CalendarCommands.swift deleted file mode 100644 index 9935b81ba92..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/CalendarCommands.swift +++ /dev/null @@ -1,93 +0,0 @@ -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 - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/CameraCommands.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/CameraCommands.swift deleted file mode 100644 index c76ff8e97f9..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/CameraCommands.swift +++ /dev/null @@ -1,68 +0,0 @@ -import Foundation - -public enum OpenClawCameraCommand: String, Codable, Sendable { - case list = "camera.list" - case snap = "camera.snap" - case clip = "camera.clip" -} - -public enum OpenClawCameraFacing: String, Codable, Sendable { - case back - case front -} - -public enum OpenClawCameraImageFormat: String, Codable, Sendable { - case jpg - case jpeg -} - -public enum OpenClawCameraVideoFormat: String, Codable, Sendable { - case mp4 -} - -public struct OpenClawCameraSnapParams: Codable, Sendable, Equatable { - public var facing: OpenClawCameraFacing? - public var maxWidth: Int? - public var quality: Double? - public var format: OpenClawCameraImageFormat? - public var deviceId: String? - public var delayMs: Int? - - public init( - facing: OpenClawCameraFacing? = nil, - maxWidth: Int? = nil, - quality: Double? = nil, - format: OpenClawCameraImageFormat? = nil, - deviceId: String? = nil, - delayMs: Int? = nil) - { - self.facing = facing - self.maxWidth = maxWidth - self.quality = quality - self.format = format - self.deviceId = deviceId - self.delayMs = delayMs - } -} - -public struct OpenClawCameraClipParams: Codable, Sendable, Equatable { - public var facing: OpenClawCameraFacing? - public var durationMs: Int? - public var includeAudio: Bool? - public var format: OpenClawCameraVideoFormat? - public var deviceId: String? - - public init( - facing: OpenClawCameraFacing? = nil, - durationMs: Int? = nil, - includeAudio: Bool? = nil, - format: OpenClawCameraVideoFormat? = nil, - deviceId: String? = nil) - { - self.facing = facing - self.durationMs = durationMs - self.includeAudio = includeAudio - self.format = format - self.deviceId = deviceId - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/CanvasA2UIAction.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/CanvasA2UIAction.swift deleted file mode 100644 index 909f89a441f..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/CanvasA2UIAction.swift +++ /dev/null @@ -1,104 +0,0 @@ -import Foundation - -public enum OpenClawCanvasA2UIAction: Sendable { - public struct AgentMessageContext: Sendable { - public struct Session: Sendable { - public var key: String - public var surfaceId: String - - public init(key: String, surfaceId: String) { - self.key = key - self.surfaceId = surfaceId - } - } - - public struct Component: Sendable { - public var id: String - public var host: String - public var instanceId: String - - public init(id: String, host: String, instanceId: String) { - self.id = id - self.host = host - self.instanceId = instanceId - } - } - - public var actionName: String - public var session: Session - public var component: Component - public var contextJSON: String? - - public init(actionName: String, session: Session, component: Component, contextJSON: String?) { - self.actionName = actionName - self.session = session - self.component = component - self.contextJSON = contextJSON - } - } - - public static func extractActionName(_ userAction: [String: Any]) -> String? { - let keys = ["name", "action"] - for key in keys { - if let raw = userAction[key] as? String { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { return trimmed } - } - } - return nil - } - - public static func sanitizeTagValue(_ value: String) -> String { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - let nonEmpty = trimmed.isEmpty ? "-" : trimmed - let normalized = nonEmpty.replacingOccurrences(of: " ", with: "_") - let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.:") - let scalars = normalized.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" } - return String(scalars) - } - - public static func compactJSON(_ obj: Any?) -> String? { - guard let obj else { return nil } - guard JSONSerialization.isValidJSONObject(obj) else { return nil } - guard let data = try? JSONSerialization.data(withJSONObject: obj, options: []), - let str = String(data: data, encoding: .utf8) - else { return nil } - return str - } - - public static func formatAgentMessage(_ context: AgentMessageContext) -> String { - let ctxSuffix = context.contextJSON.flatMap { $0.isEmpty ? nil : " ctx=\($0)" } ?? "" - return [ - "CANVAS_A2UI", - "action=\(self.sanitizeTagValue(context.actionName))", - "session=\(self.sanitizeTagValue(context.session.key))", - "surface=\(self.sanitizeTagValue(context.session.surfaceId))", - "component=\(self.sanitizeTagValue(context.component.id))", - "host=\(self.sanitizeTagValue(context.component.host))", - "instance=\(self.sanitizeTagValue(context.component.instanceId))\(ctxSuffix)", - "default=update_canvas", - ].joined(separator: " ") - } - - public static func jsDispatchA2UIActionStatus(actionId: String, ok: Bool, error: String?) -> String { - let payload: [String: Any] = [ - "id": actionId, - "ok": ok, - "error": error ?? "", - ] - let json: String = { - if let data = try? JSONSerialization.data(withJSONObject: payload, options: []), - let str = String(data: data, encoding: .utf8) - { - return str - } - return "{\"id\":\"\(actionId)\",\"ok\":\(ok ? "true" : "false"),\"error\":\"\"}" - }() - return """ - (() => { - const detail = \(json); - window.dispatchEvent(new CustomEvent('openclaw:a2ui-action-status', { detail })); - })(); - """ - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/CanvasA2UICommands.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/CanvasA2UICommands.swift deleted file mode 100644 index ab3af0c367a..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/CanvasA2UICommands.swift +++ /dev/null @@ -1,26 +0,0 @@ -import Foundation - -public enum OpenClawCanvasA2UICommand: String, Codable, Sendable { - /// Render A2UI content on the device canvas. - case push = "canvas.a2ui.push" - /// Legacy alias for `push` when sending JSONL. - case pushJSONL = "canvas.a2ui.pushJSONL" - /// Reset the A2UI renderer state. - case reset = "canvas.a2ui.reset" -} - -public struct OpenClawCanvasA2UIPushParams: Codable, Sendable, Equatable { - public var messages: [AnyCodable] - - public init(messages: [AnyCodable]) { - self.messages = messages - } -} - -public struct OpenClawCanvasA2UIPushJSONLParams: Codable, Sendable, Equatable { - public var jsonl: String - - public init(jsonl: String) { - self.jsonl = jsonl - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/CanvasA2UIJSONL.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/CanvasA2UIJSONL.swift deleted file mode 100644 index d5026a8be7b..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/CanvasA2UIJSONL.swift +++ /dev/null @@ -1,81 +0,0 @@ -import Foundation - -public enum OpenClawCanvasA2UIJSONL: Sendable { - public struct ParsedItem: Sendable { - public var lineNumber: Int - public var message: AnyCodable - - public init(lineNumber: Int, message: AnyCodable) { - self.lineNumber = lineNumber - self.message = message - } - } - - public static func parse(_ text: String) throws -> [ParsedItem] { - var out: [ParsedItem] = [] - var lineNumber = 0 - for rawLine in text.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) { - lineNumber += 1 - let line = String(rawLine).trimmingCharacters(in: .whitespacesAndNewlines) - if line.isEmpty { continue } - let data = Data(line.utf8) - - let decoded = try JSONDecoder().decode(AnyCodable.self, from: data) - out.append(ParsedItem(lineNumber: lineNumber, message: decoded)) - } - return out - } - - public static func validateV0_8(_ items: [ParsedItem]) throws { - let allowed = Set([ - "beginRendering", - "surfaceUpdate", - "dataModelUpdate", - "deleteSurface", - ]) - for item in items { - guard let dict = item.message.value as? [String: AnyCodable] else { - throw NSError(domain: "A2UI", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "A2UI JSONL line \(item.lineNumber): expected a JSON object", - ]) - } - - if dict.keys.contains("createSurface") { - throw NSError(domain: "A2UI", code: 2, userInfo: [ - NSLocalizedDescriptionKey: """ - A2UI JSONL line \(item.lineNumber): looks like A2UI v0.9 (`createSurface`). - Canvas currently supports A2UI v0.8 server→client messages - (`beginRendering`, `surfaceUpdate`, `dataModelUpdate`, `deleteSurface`). - """, - ]) - } - - let matched = dict.keys.filter { allowed.contains($0) } - if matched.count != 1 { - let found = dict.keys.sorted().joined(separator: ", ") - throw NSError(domain: "A2UI", code: 3, userInfo: [ - NSLocalizedDescriptionKey: """ - A2UI JSONL line \(item.lineNumber): expected exactly one of \(allowed.sorted() - .joined(separator: ", ")); found: \(found) - """, - ]) - } - } - } - - public static func decodeMessagesFromJSONL(_ text: String) throws -> [AnyCodable] { - let items = try self.parse(text) - try self.validateV0_8(items) - return items.map(\.message) - } - - public static func encodeMessagesJSONArray(_ messages: [AnyCodable]) throws -> String { - let data = try JSONEncoder().encode(messages) - guard let json = String(data: data, encoding: .utf8) else { - throw NSError(domain: "A2UI", code: 10, userInfo: [ - NSLocalizedDescriptionKey: "Failed to encode messages payload as UTF-8", - ]) - } - return json - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/CanvasCommandParams.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/CanvasCommandParams.swift deleted file mode 100644 index 2c109cf2fda..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/CanvasCommandParams.swift +++ /dev/null @@ -1,76 +0,0 @@ -import Foundation - -public struct OpenClawCanvasNavigateParams: Codable, Sendable, Equatable { - public var url: String - - public init(url: String) { - self.url = url - } -} - -public struct OpenClawCanvasPlacement: Codable, Sendable, Equatable { - public var x: Double? - public var y: Double? - public var width: Double? - public var height: Double? - - public init(x: Double? = nil, y: Double? = nil, width: Double? = nil, height: Double? = nil) { - self.x = x - self.y = y - self.width = width - self.height = height - } -} - -public struct OpenClawCanvasPresentParams: Codable, Sendable, Equatable { - public var url: String? - public var placement: OpenClawCanvasPlacement? - - public init(url: String? = nil, placement: OpenClawCanvasPlacement? = nil) { - self.url = url - self.placement = placement - } -} - -public struct OpenClawCanvasEvalParams: Codable, Sendable, Equatable { - public var javaScript: String - - public init(javaScript: String) { - self.javaScript = javaScript - } -} - -public enum OpenClawCanvasSnapshotFormat: String, Codable, Sendable { - case png - case jpeg - - public init(from decoder: Decoder) throws { - let c = try decoder.singleValueContainer() - let raw = try c.decode(String.self).trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - switch raw { - case "png": - self = .png - case "jpeg", "jpg": - self = .jpeg - default: - throw DecodingError.dataCorruptedError(in: c, debugDescription: "Invalid snapshot format: \(raw)") - } - } - - public func encode(to encoder: Encoder) throws { - var c = encoder.singleValueContainer() - try c.encode(self.rawValue) - } -} - -public struct OpenClawCanvasSnapshotParams: Codable, Sendable, Equatable { - public var maxWidth: Int? - public var quality: Double? - public var format: OpenClawCanvasSnapshotFormat? - - public init(maxWidth: Int? = nil, quality: Double? = nil, format: OpenClawCanvasSnapshotFormat? = nil) { - self.maxWidth = maxWidth - self.quality = quality - self.format = format - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/CanvasCommands.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/CanvasCommands.swift deleted file mode 100644 index 544353bc063..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/CanvasCommands.swift +++ /dev/null @@ -1,9 +0,0 @@ -import Foundation - -public enum OpenClawCanvasCommand: String, Codable, Sendable { - case present = "canvas.present" - case hide = "canvas.hide" - case navigate = "canvas.navigate" - case evalJS = "canvas.eval" - case snapshot = "canvas.snapshot" -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/Capabilities.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/Capabilities.swift deleted file mode 100644 index 49f9efe996b..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/Capabilities.swift +++ /dev/null @@ -1,16 +0,0 @@ -import Foundation - -public enum OpenClawCapability: String, Codable, Sendable { - case canvas - case camera - case screen - case voiceWake - case location - case device - case watch - case photos - case contacts - case calendar - case reminders - case motion -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/ChatCommands.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/ChatCommands.swift deleted file mode 100644 index 98bac6205dd..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/ChatCommands.swift +++ /dev/null @@ -1,23 +0,0 @@ -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 - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/ContactsCommands.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/ContactsCommands.swift deleted file mode 100644 index d99f6b9e74a..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/ContactsCommands.swift +++ /dev/null @@ -1,85 +0,0 @@ -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 - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift deleted file mode 100644 index 50714884619..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift +++ /dev/null @@ -1,185 +0,0 @@ -import Foundation -import Network - -public enum DeepLinkRoute: Sendable, Equatable { - case agent(AgentDeepLink) - case gateway(GatewayConnectDeepLink) -} - -public struct GatewayConnectDeepLink: Codable, Sendable, Equatable { - public let host: String - public let port: Int - public let tls: Bool - public let token: String? - public let password: String? - - public init(host: String, port: Int, tls: Bool, token: String?, password: String?) { - self.host = host - self.port = port - self.tls = tls - self.token = token - self.password = password - } - - fileprivate static func isLoopbackHost(_ raw: String) -> Bool { - var host = raw - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - .trimmingCharacters(in: CharacterSet(charactersIn: "[]")) - if host.hasSuffix(".") { - host.removeLast() - } - if let zoneIndex = host.firstIndex(of: "%") { - host = String(host[.. GatewayConnectDeepLink? { - guard let data = Self.decodeBase64Url(code) else { return nil } - guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } - guard let urlString = json["url"] as? String, - let parsed = URLComponents(string: urlString), - let hostname = parsed.host, !hostname.isEmpty - else { return nil } - - let scheme = (parsed.scheme ?? "ws").lowercased() - guard scheme == "ws" || scheme == "wss" else { return nil } - let tls = scheme == "wss" - if !tls, !Self.isLoopbackHost(hostname) { - return nil - } - let port = parsed.port ?? (tls ? 443 : 18789) - let token = json["token"] as? String - let password = json["password"] as? String - return GatewayConnectDeepLink(host: hostname, port: port, tls: tls, token: token, password: password) - } - - private static func decodeBase64Url(_ input: String) -> Data? { - var base64 = input - .replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") - let remainder = base64.count % 4 - if remainder > 0 { - base64.append(contentsOf: String(repeating: "=", count: 4 - remainder)) - } - return Data(base64Encoded: base64) - } -} - -public struct AgentDeepLink: Codable, Sendable, Equatable { - public let message: String - public let sessionKey: String? - public let thinking: String? - public let deliver: Bool - public let to: String? - public let channel: String? - public let timeoutSeconds: Int? - public let key: String? - - public init( - message: String, - sessionKey: String?, - thinking: String?, - deliver: Bool, - to: String?, - channel: String?, - timeoutSeconds: Int?, - key: String?) - { - self.message = message - self.sessionKey = sessionKey - self.thinking = thinking - self.deliver = deliver - self.to = to - self.channel = channel - self.timeoutSeconds = timeoutSeconds - self.key = key - } -} - -public enum DeepLinkParser { - public static func parse(_ url: URL) -> DeepLinkRoute? { - guard let scheme = url.scheme?.lowercased(), - scheme == "openclaw" - else { - return nil - } - guard let host = url.host?.lowercased(), !host.isEmpty else { return nil } - guard let comps = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil } - - let query = (comps.queryItems ?? []).reduce(into: [String: String]()) { dict, item in - guard let value = item.value else { return } - dict[item.name] = value - } - - switch host { - case "agent": - guard let message = query["message"], - !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - else { - return nil - } - let deliver = (query["deliver"] as NSString?)?.boolValue ?? false - let timeoutSeconds = query["timeoutSeconds"].flatMap { Int($0) }.flatMap { $0 >= 0 ? $0 : nil } - return .agent( - .init( - message: message, - sessionKey: query["sessionKey"], - thinking: query["thinking"], - deliver: deliver, - to: query["to"], - channel: query["channel"], - timeoutSeconds: timeoutSeconds, - key: query["key"])) - - case "gateway": - guard let hostParam = query["host"], - !hostParam.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - else { - return nil - } - let port = query["port"].flatMap { Int($0) } ?? 18789 - let tls = (query["tls"] as NSString?)?.boolValue ?? false - if !tls, !GatewayConnectDeepLink.isLoopbackHost(hostParam) { - return nil - } - return .gateway( - .init( - host: hostParam, - port: port, - tls: tls, - token: query["token"], - password: query["password"])) - - default: - return nil - } - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceAuthStore.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceAuthStore.swift deleted file mode 100644 index 80ff20c3f35..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceAuthStore.swift +++ /dev/null @@ -1,107 +0,0 @@ -import Foundation - -public struct DeviceAuthEntry: Codable, Sendable { - public let token: String - public let role: String - public let scopes: [String] - public let updatedAtMs: Int - - public init(token: String, role: String, scopes: [String], updatedAtMs: Int) { - self.token = token - self.role = role - self.scopes = scopes - self.updatedAtMs = updatedAtMs - } -} - -private struct DeviceAuthStoreFile: Codable { - var version: Int - var deviceId: String - var tokens: [String: DeviceAuthEntry] -} - -public enum DeviceAuthStore { - private static let fileName = "device-auth.json" - - public static func loadToken(deviceId: String, role: String) -> DeviceAuthEntry? { - guard let store = readStore(), store.deviceId == deviceId else { return nil } - let role = normalizeRole(role) - return store.tokens[role] - } - - public static func storeToken( - deviceId: String, - role: String, - token: String, - scopes: [String] = [] - ) -> DeviceAuthEntry { - let normalizedRole = normalizeRole(role) - var next = readStore() - if next?.deviceId != deviceId { - next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:]) - } - let entry = DeviceAuthEntry( - token: token, - role: normalizedRole, - scopes: normalizeScopes(scopes), - updatedAtMs: Int(Date().timeIntervalSince1970 * 1000) - ) - if next == nil { - next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:]) - } - next?.tokens[normalizedRole] = entry - if let store = next { - writeStore(store) - } - return entry - } - - public static func clearToken(deviceId: String, role: String) { - guard var store = readStore(), store.deviceId == deviceId else { return } - let normalizedRole = normalizeRole(role) - guard store.tokens[normalizedRole] != nil else { return } - store.tokens.removeValue(forKey: normalizedRole) - writeStore(store) - } - - private static func normalizeRole(_ role: String) -> String { - role.trimmingCharacters(in: .whitespacesAndNewlines) - } - - private static func normalizeScopes(_ scopes: [String]) -> [String] { - let trimmed = scopes - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - return Array(Set(trimmed)).sorted() - } - - private static func fileURL() -> URL { - DeviceIdentityPaths.stateDirURL() - .appendingPathComponent("identity", isDirectory: true) - .appendingPathComponent(fileName, isDirectory: false) - } - - private static func readStore() -> DeviceAuthStoreFile? { - let url = fileURL() - guard let data = try? Data(contentsOf: url) else { return nil } - guard let decoded = try? JSONDecoder().decode(DeviceAuthStoreFile.self, from: data) else { - return nil - } - guard decoded.version == 1 else { return nil } - return decoded - } - - private static func writeStore(_ store: DeviceAuthStoreFile) { - let url = fileURL() - do { - try FileManager.default.createDirectory( - at: url.deletingLastPathComponent(), - withIntermediateDirectories: true) - let data = try JSONEncoder().encode(store) - try data.write(to: url, options: [.atomic]) - try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) - } catch { - // best-effort only - } - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceCommands.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceCommands.swift deleted file mode 100644 index c58224b3f14..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceCommands.swift +++ /dev/null @@ -1,134 +0,0 @@ -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 - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceIdentity.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceIdentity.swift deleted file mode 100644 index a992bc58f29..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceIdentity.swift +++ /dev/null @@ -1,112 +0,0 @@ -import CryptoKit -import Foundation - -public struct DeviceIdentity: Codable, Sendable { - public var deviceId: String - public var publicKey: String - public var privateKey: String - public var createdAtMs: Int - - public init(deviceId: String, publicKey: String, privateKey: String, createdAtMs: Int) { - self.deviceId = deviceId - self.publicKey = publicKey - self.privateKey = privateKey - self.createdAtMs = createdAtMs - } -} - -enum DeviceIdentityPaths { - private static let stateDirEnv = ["OPENCLAW_STATE_DIR"] - - static func stateDirURL() -> URL { - for key in self.stateDirEnv { - if let raw = getenv(key) { - let value = String(cString: raw).trimmingCharacters(in: .whitespacesAndNewlines) - if !value.isEmpty { - return URL(fileURLWithPath: value, isDirectory: true) - } - } - } - - if let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first { - return appSupport.appendingPathComponent("OpenClaw", isDirectory: true) - } - - return FileManager.default.temporaryDirectory.appendingPathComponent("openclaw", isDirectory: true) - } -} - -public enum DeviceIdentityStore { - private static let fileName = "device.json" - - public static func loadOrCreate() -> DeviceIdentity { - let url = self.fileURL() - if let data = try? Data(contentsOf: url), - let decoded = try? JSONDecoder().decode(DeviceIdentity.self, from: data), - !decoded.deviceId.isEmpty, - !decoded.publicKey.isEmpty, - !decoded.privateKey.isEmpty { - return decoded - } - let identity = self.generate() - self.save(identity) - return identity - } - - public static func signPayload(_ payload: String, identity: DeviceIdentity) -> String? { - guard let privateKeyData = Data(base64Encoded: identity.privateKey) else { return nil } - do { - let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: privateKeyData) - let signature = try privateKey.signature(for: Data(payload.utf8)) - return self.base64UrlEncode(signature) - } catch { - return nil - } - } - - private static func generate() -> DeviceIdentity { - let privateKey = Curve25519.Signing.PrivateKey() - let publicKey = privateKey.publicKey - let publicKeyData = publicKey.rawRepresentation - let privateKeyData = privateKey.rawRepresentation - let deviceId = SHA256.hash(data: publicKeyData).compactMap { String(format: "%02x", $0) }.joined() - return DeviceIdentity( - deviceId: deviceId, - publicKey: publicKeyData.base64EncodedString(), - privateKey: privateKeyData.base64EncodedString(), - createdAtMs: Int(Date().timeIntervalSince1970 * 1000)) - } - - private static func base64UrlEncode(_ data: Data) -> String { - let base64 = data.base64EncodedString() - return base64 - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } - - public static func publicKeyBase64Url(_ identity: DeviceIdentity) -> String? { - guard let data = Data(base64Encoded: identity.publicKey) else { return nil } - return self.base64UrlEncode(data) - } - - private static func save(_ identity: DeviceIdentity) { - let url = self.fileURL() - do { - try FileManager.default.createDirectory( - at: url.deletingLastPathComponent(), - withIntermediateDirectories: true) - let data = try JSONEncoder().encode(identity) - try data.write(to: url, options: [.atomic]) - } catch { - // best-effort only - } - } - - private static func fileURL() -> URL { - let base = DeviceIdentityPaths.stateDirURL() - return base - .appendingPathComponent("identity", isDirectory: true) - .appendingPathComponent(fileName, isDirectory: false) - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/ElevenLabsKitShim.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/ElevenLabsKitShim.swift deleted file mode 100644 index 07fe91ac37c..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/ElevenLabsKitShim.swift +++ /dev/null @@ -1,9 +0,0 @@ -@_exported import ElevenLabsKit - -public typealias ElevenLabsVoice = ElevenLabsKit.ElevenLabsVoice -public typealias ElevenLabsTTSRequest = ElevenLabsKit.ElevenLabsTTSRequest -public typealias ElevenLabsTTSClient = ElevenLabsKit.ElevenLabsTTSClient -public typealias TalkTTSValidation = ElevenLabsKit.TalkTTSValidation -public typealias StreamingAudioPlayer = ElevenLabsKit.StreamingAudioPlayer -public typealias PCMStreamingAudioPlayer = ElevenLabsKit.PCMStreamingAudioPlayer -public typealias StreamingPlaybackResult = ElevenLabsKit.StreamingPlaybackResult diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift deleted file mode 100644 index f6aac26977a..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift +++ /dev/null @@ -1,795 +0,0 @@ -import OpenClawProtocol -import Foundation -import OSLog - -public protocol WebSocketTasking: AnyObject { - var state: URLSessionTask.State { get } - func resume() - func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) - func send(_ message: URLSessionWebSocketTask.Message) async throws - func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) - func receive() async throws -> URLSessionWebSocketTask.Message - func receive(completionHandler: @escaping @Sendable (Result) -> Void) -} - -extension URLSessionWebSocketTask: WebSocketTasking {} - -public struct WebSocketTaskBox: @unchecked Sendable { - public let task: any WebSocketTasking - public init(task: any WebSocketTasking) { - self.task = task - } - - public var state: URLSessionTask.State { self.task.state } - - public func resume() { self.task.resume() } - - public func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { - self.task.cancel(with: closeCode, reason: reason) - } - - public func send(_ message: URLSessionWebSocketTask.Message) async throws { - try await self.task.send(message) - } - - public func receive() async throws -> URLSessionWebSocketTask.Message { - try await self.task.receive() - } - - public func receive( - completionHandler: @escaping @Sendable (Result) -> Void) - { - self.task.receive(completionHandler: completionHandler) - } - - public func sendPing() async throws { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - self.task.sendPing { error in - if let error { - continuation.resume(throwing: error) - } else { - continuation.resume(returning: ()) - } - } - } - } -} - -public protocol WebSocketSessioning: AnyObject { - func makeWebSocketTask(url: URL) -> WebSocketTaskBox -} - -extension URLSession: WebSocketSessioning { - public func makeWebSocketTask(url: URL) -> WebSocketTaskBox { - let task = self.webSocketTask(with: url) - // Avoid "Message too long" receive errors for large snapshots / history payloads. - task.maximumMessageSize = 16 * 1024 * 1024 // 16 MB - return WebSocketTaskBox(task: task) - } -} - -public struct WebSocketSessionBox: @unchecked Sendable { - public let session: any WebSocketSessioning - - public init(session: any WebSocketSessioning) { - self.session = session - } -} - -public struct GatewayConnectOptions: Sendable { - public var role: String - public var scopes: [String] - public var caps: [String] - public var commands: [String] - public var permissions: [String: Bool] - public var clientId: String - public var clientMode: String - public var clientDisplayName: String? - // When false, the connection omits the signed device identity payload and cannot use - // device-scoped auth (role/scope upgrades will require pairing). Keep this true for - // role/scoped sessions such as operator UI clients. - public var includeDeviceIdentity: Bool - - public init( - role: String, - scopes: [String], - caps: [String], - commands: [String], - permissions: [String: Bool], - clientId: String, - clientMode: String, - clientDisplayName: String?, - includeDeviceIdentity: Bool = true) - { - self.role = role - self.scopes = scopes - self.caps = caps - self.commands = commands - self.permissions = permissions - self.clientId = clientId - self.clientMode = clientMode - self.clientDisplayName = clientDisplayName - self.includeDeviceIdentity = includeDeviceIdentity - } -} - -public enum GatewayAuthSource: String, Sendable { - case deviceToken = "device-token" - case sharedToken = "shared-token" - case password = "password" - case none = "none" -} - -// Avoid ambiguity with the app's own AnyCodable type. -private typealias ProtoAnyCodable = OpenClawProtocol.AnyCodable - -private enum ConnectChallengeError: Error { - case timeout -} - -public actor GatewayChannelActor { - private let logger = Logger(subsystem: "ai.openclaw", category: "gateway") - private var task: WebSocketTaskBox? - private var pending: [String: CheckedContinuation] = [:] - private var connected = false - private var isConnecting = false - private var connectWaiters: [CheckedContinuation] = [] - private var url: URL - private var token: String? - private var password: String? - private let session: WebSocketSessioning - private var backoffMs: Double = 500 - private var shouldReconnect = true - private var lastSeq: Int? - private var lastTick: Date? - private var tickIntervalMs: Double = 30000 - private var lastAuthSource: GatewayAuthSource = .none - private let decoder = JSONDecoder() - private let encoder = JSONEncoder() - // Remote gateways (tailscale/wan) can take a bit longer to deliver the connect.challenge event, - // and we must include the nonce once the gateway requires v2 signing. - private let connectTimeoutSeconds: Double = 12 - private let connectChallengeTimeoutSeconds: Double = 6.0 - // Some networks will silently drop idle TCP/TLS flows around ~30s. The gateway tick is server->client, - // but NATs/proxies often require outbound traffic to keep the connection alive. - private let keepaliveIntervalSeconds: Double = 15.0 - private var watchdogTask: Task? - private var tickTask: Task? - private var keepaliveTask: Task? - private let defaultRequestTimeoutMs: Double = 15000 - private let pushHandler: (@Sendable (GatewayPush) async -> Void)? - private let connectOptions: GatewayConnectOptions? - private let disconnectHandler: (@Sendable (String) async -> Void)? - - public init( - url: URL, - token: String?, - password: String? = nil, - session: WebSocketSessionBox? = nil, - pushHandler: (@Sendable (GatewayPush) async -> Void)? = nil, - connectOptions: GatewayConnectOptions? = nil, - disconnectHandler: (@Sendable (String) async -> Void)? = nil) - { - self.url = url - self.token = token - self.password = password - self.session = session?.session ?? URLSession(configuration: .default) - self.pushHandler = pushHandler - self.connectOptions = connectOptions - self.disconnectHandler = disconnectHandler - Task { [weak self] in - await self?.startWatchdog() - } - } - - public func authSource() -> GatewayAuthSource { self.lastAuthSource } - - public func shutdown() async { - self.shouldReconnect = false - self.connected = false - - self.watchdogTask?.cancel() - self.watchdogTask = nil - - self.tickTask?.cancel() - self.tickTask = nil - - self.keepaliveTask?.cancel() - self.keepaliveTask = nil - - self.task?.cancel(with: .goingAway, reason: nil) - self.task = nil - - await self.failPending(NSError( - domain: "Gateway", - code: 0, - userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"])) - - let waiters = self.connectWaiters - self.connectWaiters.removeAll() - for waiter in waiters { - waiter.resume(throwing: NSError( - domain: "Gateway", - code: 0, - userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"])) - } - } - - private func startWatchdog() { - self.watchdogTask?.cancel() - self.watchdogTask = Task { [weak self] in - guard let self else { return } - await self.watchdogLoop() - } - } - - private func watchdogLoop() async { - // Keep nudging reconnect in case exponential backoff stalls. - while self.shouldReconnect { - guard await self.sleepUnlessCancelled(nanoseconds: 30 * 1_000_000_000) else { return } // 30s cadence - guard self.shouldReconnect else { return } - if self.connected { continue } - do { - try await self.connect() - } catch { - let wrapped = self.wrap(error, context: "gateway watchdog reconnect") - self.logger.error("gateway watchdog reconnect failed \(wrapped.localizedDescription, privacy: .public)") - } - } - } - - public func connect() async throws { - if self.connected, self.task?.state == .running { return } - if self.isConnecting { - try await withCheckedThrowingContinuation { cont in - self.connectWaiters.append(cont) - } - return - } - self.isConnecting = true - defer { self.isConnecting = false } - - self.task?.cancel(with: .goingAway, reason: nil) - self.task = self.session.makeWebSocketTask(url: self.url) - self.task?.resume() - do { - try await AsyncTimeout.withTimeout( - seconds: self.connectTimeoutSeconds, - onTimeout: { - NSError( - domain: "Gateway", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "connect timed out"]) - }, - operation: { try await self.sendConnect() }) - } catch { - let wrapped = self.wrap(error, context: "connect to gateway @ \(self.url.absoluteString)") - self.connected = false - self.task?.cancel(with: .goingAway, reason: nil) - await self.disconnectHandler?("connect failed: \(wrapped.localizedDescription)") - let waiters = self.connectWaiters - self.connectWaiters.removeAll() - for waiter in waiters { - waiter.resume(throwing: wrapped) - } - self.logger.error("gateway ws connect failed \(wrapped.localizedDescription, privacy: .public)") - throw wrapped - } - self.listen() - self.connected = true - self.backoffMs = 500 - self.lastSeq = nil - self.startKeepalive() - - let waiters = self.connectWaiters - self.connectWaiters.removeAll() - for waiter in waiters { - waiter.resume(returning: ()) - } - } - - private func startKeepalive() { - self.keepaliveTask?.cancel() - self.keepaliveTask = Task { [weak self] in - guard let self else { return } - await self.keepaliveLoop() - } - } - - private func keepaliveLoop() async { - while self.shouldReconnect { - guard await self.sleepUnlessCancelled( - nanoseconds: UInt64(self.keepaliveIntervalSeconds * 1_000_000_000)) - else { return } - guard self.shouldReconnect else { return } - guard self.connected else { continue } - guard let task = self.task else { continue } - // Best-effort ping keeps NAT/proxy state alive without generating RPC load. - do { - try await task.sendPing() - } catch { - // Avoid spamming logs; the reconnect paths will surface meaningful errors. - } - } - } - - private func sendConnect() async throws { - let platform = InstanceIdentity.platformString - let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier - let options = self.connectOptions ?? GatewayConnectOptions( - role: "operator", - scopes: ["operator.admin", "operator.approvals", "operator.pairing"], - caps: [], - commands: [], - permissions: [:], - clientId: "openclaw-macos", - clientMode: "ui", - clientDisplayName: InstanceIdentity.displayName) - let clientDisplayName = options.clientDisplayName ?? InstanceIdentity.displayName - let clientId = options.clientId - let clientMode = options.clientMode - let role = options.role - let scopes = options.scopes - - let reqId = UUID().uuidString - var client: [String: ProtoAnyCodable] = [ - "id": ProtoAnyCodable(clientId), - "displayName": ProtoAnyCodable(clientDisplayName), - "version": ProtoAnyCodable( - Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"), - "platform": ProtoAnyCodable(platform), - "mode": ProtoAnyCodable(clientMode), - "instanceId": ProtoAnyCodable(InstanceIdentity.instanceId), - ] - client["deviceFamily"] = ProtoAnyCodable(InstanceIdentity.deviceFamily) - if let model = InstanceIdentity.modelIdentifier { - client["modelIdentifier"] = ProtoAnyCodable(model) - } - var params: [String: ProtoAnyCodable] = [ - "minProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION), - "maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION), - "client": ProtoAnyCodable(client), - "caps": ProtoAnyCodable(options.caps), - "locale": ProtoAnyCodable(primaryLocale), - "userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString), - "role": ProtoAnyCodable(role), - "scopes": ProtoAnyCodable(scopes), - ] - if !options.commands.isEmpty { - params["commands"] = ProtoAnyCodable(options.commands) - } - if !options.permissions.isEmpty { - params["permissions"] = ProtoAnyCodable(options.permissions) - } - 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 - } else if authToken != nil { - authSource = .sharedToken - } else if self.password != nil { - authSource = .password - } else { - authSource = .none - } - self.lastAuthSource = authSource - self.logger.info("gateway connect auth=\(authSource.rawValue, privacy: .public)") - let canFallbackToShared = includeDeviceIdentity && storedToken != nil && self.token != nil - if let authToken { - params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(authToken)]) - } else if let password = self.password { - params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)]) - } - let signedAtMs = Int(Date().timeIntervalSince1970 * 1000) - let connectNonce = try await self.waitForConnectChallenge() - let scopesValue = scopes.joined(separator: ",") - var payloadParts = [ - connectNonce == nil ? "v1" : "v2", - identity?.deviceId ?? "", - clientId, - clientMode, - role, - scopesValue, - String(signedAtMs), - authToken ?? "", - ] - if let connectNonce { - payloadParts.append(connectNonce) - } - let payload = payloadParts.joined(separator: "|") - 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) - } - } - - let frame = RequestFrame( - type: "req", - id: reqId, - method: "connect", - params: ProtoAnyCodable(params)) - let data = try self.encoder.encode(frame) - try await self.task?.send(.data(data)) - do { - let response = try await self.waitForConnectResponse(reqId: reqId) - try await self.handleConnectResponse(response, identity: identity, role: role) - } catch { - if canFallbackToShared { - if let identity { - DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role) - } - } - throw error - } - } - - private func handleConnectResponse( - _ res: ResponseFrame, - identity: DeviceIdentity?, - role: String - ) async throws { - if res.ok == false { - let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed" - throw NSError(domain: "Gateway", code: 1008, userInfo: [NSLocalizedDescriptionKey: msg]) - } - guard let payload = res.payload else { - throw NSError( - domain: "Gateway", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "connect failed (missing payload)"]) - } - let payloadData = try self.encoder.encode(payload) - let ok = try decoder.decode(HelloOk.self, from: payloadData) - if let tick = ok.policy["tickIntervalMs"]?.value as? Double { - self.tickIntervalMs = tick - } else if let tick = ok.policy["tickIntervalMs"]?.value as? Int { - self.tickIntervalMs = Double(tick) - } - if let auth = ok.auth, - let deviceToken = auth["deviceToken"]?.value as? String { - let authRole = auth["role"]?.value as? String ?? role - let scopes = (auth["scopes"]?.value as? [ProtoAnyCodable])? - .compactMap { $0.value as? String } ?? [] - if let identity { - _ = DeviceAuthStore.storeToken( - deviceId: identity.deviceId, - role: authRole, - token: deviceToken, - scopes: scopes) - } - } - self.lastTick = Date() - self.tickTask?.cancel() - self.tickTask = Task { [weak self] in - guard let self else { return } - await self.watchTicks() - } - if let pushHandler = self.pushHandler { - Task { await pushHandler(.snapshot(ok)) } - } - } - - private func listen() { - self.task?.receive { [weak self] result in - guard let self else { return } - switch result { - case let .failure(err): - Task { await self.handleReceiveFailure(err) } - case let .success(msg): - Task { - await self.handle(msg) - await self.listen() - } - } - } - } - - private func handleReceiveFailure(_ err: Error) async { - let wrapped = self.wrap(err, context: "gateway receive") - self.logger.error("gateway ws receive failed \(wrapped.localizedDescription, privacy: .public)") - self.connected = false - self.keepaliveTask?.cancel() - self.keepaliveTask = nil - await self.disconnectHandler?("receive failed: \(wrapped.localizedDescription)") - await self.failPending(wrapped) - await self.scheduleReconnect() - } - - private func handle(_ msg: URLSessionWebSocketTask.Message) async { - let data: Data? = switch msg { - case let .data(d): d - case let .string(s): s.data(using: .utf8) - @unknown default: nil - } - guard let data else { return } - guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else { - self.logger.error("gateway decode failed") - return - } - switch frame { - case let .res(res): - let id = res.id - if let waiter = pending.removeValue(forKey: id) { - waiter.resume(returning: .res(res)) - } - case let .event(evt): - if evt.event == "connect.challenge" { return } - if let seq = evt.seq { - if let last = lastSeq, seq > last + 1 { - await self.pushHandler?(.seqGap(expected: last + 1, received: seq)) - } - self.lastSeq = seq - } - if evt.event == "tick" { self.lastTick = Date() } - await self.pushHandler?(.event(evt)) - default: - break - } - } - - private func waitForConnectChallenge() async throws -> String? { - guard let task = self.task else { return nil } - do { - return try await AsyncTimeout.withTimeout( - seconds: self.connectChallengeTimeoutSeconds, - onTimeout: { ConnectChallengeError.timeout }, - operation: { [weak self] in - guard let self else { return nil } - while true { - let msg = try await task.receive() - guard let data = self.decodeMessageData(msg) else { continue } - guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else { continue } - if case let .event(evt) = frame, evt.event == "connect.challenge" { - if let payload = evt.payload?.value as? [String: ProtoAnyCodable], - let nonce = payload["nonce"]?.value as? String { - return nonce - } - } - } - }) - } catch { - if error is ConnectChallengeError { - self.logger.warning("gateway connect challenge timed out") - return nil - } - throw error - } - } - - private func waitForConnectResponse(reqId: String) async throws -> ResponseFrame { - guard let task = self.task else { - throw NSError( - domain: "Gateway", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "connect failed (no response)"]) - } - while true { - let msg = try await task.receive() - guard let data = self.decodeMessageData(msg) else { continue } - guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else { - throw NSError( - domain: "Gateway", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "connect failed (invalid response)"]) - } - if case let .res(res) = frame, res.id == reqId { - return res - } - } - } - - private nonisolated func decodeMessageData(_ msg: URLSessionWebSocketTask.Message) -> Data? { - let data: Data? = switch msg { - case let .data(data): data - case let .string(text): text.data(using: .utf8) - @unknown default: nil - } - return data - } - - private func watchTicks() async { - let tolerance = self.tickIntervalMs * 2 - while self.connected { - guard await self.sleepUnlessCancelled(nanoseconds: UInt64(tolerance * 1_000_000)) else { return } - guard self.connected else { return } - if let last = self.lastTick { - let delta = Date().timeIntervalSince(last) * 1000 - if delta > tolerance { - self.logger.error("gateway tick missed; reconnecting") - self.connected = false - await self.failPending( - NSError( - domain: "Gateway", - code: 4, - userInfo: [NSLocalizedDescriptionKey: "gateway tick missed; reconnecting"])) - await self.scheduleReconnect() - return - } - } - } - } - - private func scheduleReconnect() async { - guard self.shouldReconnect else { return } - let delay = self.backoffMs / 1000 - self.backoffMs = min(self.backoffMs * 2, 30000) - guard await self.sleepUnlessCancelled(nanoseconds: UInt64(delay * 1_000_000_000)) else { return } - guard self.shouldReconnect else { return } - do { - try await self.connect() - } catch { - let wrapped = self.wrap(error, context: "gateway reconnect") - self.logger.error("gateway reconnect failed \(wrapped.localizedDescription, privacy: .public)") - await self.scheduleReconnect() - } - } - - private nonisolated func sleepUnlessCancelled(nanoseconds: UInt64) async -> Bool { - do { - try await Task.sleep(nanoseconds: nanoseconds) - } catch { - return false - } - return !Task.isCancelled - } - - public func request( - method: String, - params: [String: AnyCodable]?, - timeoutMs: Double? = nil) async throws -> Data - { - try await self.connectOrThrow(context: "gateway connect") - let effectiveTimeout = timeoutMs ?? self.defaultRequestTimeoutMs - let payload = try self.encodeRequest(method: method, params: params, kind: "request") - let response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - self.pending[payload.id] = cont - Task { [weak self] in - guard let self else { return } - try? await Task.sleep(nanoseconds: UInt64(effectiveTimeout * 1_000_000)) - await self.timeoutRequest(id: payload.id, timeoutMs: effectiveTimeout) - } - Task { - do { - try await self.task?.send(.data(payload.data)) - } catch { - let wrapped = self.wrap(error, context: "gateway send \(method)") - let waiter = self.pending.removeValue(forKey: payload.id) - // Treat send failures as a broken socket: mark disconnected and trigger reconnect. - self.connected = false - self.task?.cancel(with: .goingAway, reason: nil) - Task { [weak self] in - guard let self else { return } - await self.scheduleReconnect() - } - if let waiter { waiter.resume(throwing: wrapped) } - } - } - } - guard case let .res(res) = response else { - throw NSError(domain: "Gateway", code: 2, userInfo: [NSLocalizedDescriptionKey: "unexpected frame"]) - } - if res.ok == false { - let code = res.error?["code"]?.value as? String - let msg = res.error?["message"]?.value as? String - let details: [String: AnyCodable] = (res.error ?? [:]).reduce(into: [:]) { acc, pair in - acc[pair.key] = AnyCodable(pair.value.value) - } - throw GatewayResponseError(method: method, code: code, message: msg, details: details) - } - if let payload = res.payload { - // Encode back to JSON with Swift's encoder to preserve types and avoid ObjC bridging exceptions. - return try self.encoder.encode(payload) - } - return Data() // Should not happen, but tolerate empty payloads. - } - - public func send(method: String, params: [String: AnyCodable]?) async throws { - try await self.connectOrThrow(context: "gateway connect") - let payload = try self.encodeRequest(method: method, params: params, kind: "send") - guard let task = self.task else { - throw NSError( - domain: "Gateway", - code: 5, - userInfo: [NSLocalizedDescriptionKey: "gateway socket unavailable"]) - } - do { - try await task.send(.data(payload.data)) - } catch { - let wrapped = self.wrap(error, context: "gateway send \(method)") - self.connected = false - self.task?.cancel(with: .goingAway, reason: nil) - Task { [weak self] in - guard let self else { return } - await self.scheduleReconnect() - } - throw wrapped - } - } - - // Wrap low-level URLSession/WebSocket errors with context so UI can surface them. - private func wrap(_ error: Error, context: String) -> Error { - if let urlError = error as? URLError { - let desc = urlError.localizedDescription.isEmpty ? "cancelled" : urlError.localizedDescription - return NSError( - domain: URLError.errorDomain, - code: urlError.errorCode, - userInfo: [NSLocalizedDescriptionKey: "\(context): \(desc)"]) - } - let ns = error as NSError - let desc = ns.localizedDescription.isEmpty ? "unknown" : ns.localizedDescription - return NSError(domain: ns.domain, code: ns.code, userInfo: [NSLocalizedDescriptionKey: "\(context): \(desc)"]) - } - - private func connectOrThrow(context: String) async throws { - do { - try await self.connect() - } catch { - throw self.wrap(error, context: context) - } - } - - private func encodeRequest( - method: String, - params: [String: AnyCodable]?, - kind: String) throws -> (id: String, data: Data) - { - let id = UUID().uuidString - // Encode request using the generated models to avoid JSONSerialization/ObjC bridging pitfalls. - let paramsObject: ProtoAnyCodable? = params.map { entries in - let dict = entries.reduce(into: [String: ProtoAnyCodable]()) { dict, entry in - dict[entry.key] = ProtoAnyCodable(entry.value.value) - } - return ProtoAnyCodable(dict) - } - let frame = RequestFrame( - type: "req", - id: id, - method: method, - params: paramsObject) - do { - let data = try self.encoder.encode(frame) - return (id: id, data: data) - } catch { - self.logger.error( - "gateway \(kind) encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)") - throw error - } - } - - private func failPending(_ error: Error) async { - let waiters = self.pending - self.pending.removeAll() - for (_, waiter) in waiters { - waiter.resume(throwing: error) - } - } - - private func timeoutRequest(id: String, timeoutMs: Double) async { - guard let waiter = self.pending.removeValue(forKey: id) else { return } - let err = NSError( - domain: "Gateway", - code: 5, - userInfo: [NSLocalizedDescriptionKey: "gateway request timed out after \(Int(timeoutMs))ms"]) - waiter.resume(throwing: err) - } -} - -// Intentionally no `GatewayChannel` wrapper: the app should use the single shared `GatewayConnection`. diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayDiscoveryStatusText.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayDiscoveryStatusText.swift deleted file mode 100644 index e15baf17fdb..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayDiscoveryStatusText.swift +++ /dev/null @@ -1,39 +0,0 @@ -import Foundation -import Network - -public enum GatewayDiscoveryStatusText { - public static func make(states: [NWBrowser.State], hasBrowsers: Bool) -> String { - if states.isEmpty { - return hasBrowsers ? "Setup" : "Idle" - } - - if let failed = states.first(where: { state in - if case .failed = state { return true } - return false - }) { - if case let .failed(err) = failed { - return "Failed: \(err)" - } - } - - if let waiting = states.first(where: { state in - if case .waiting = state { return true } - return false - }) { - if case let .waiting(err) = waiting { - return "Waiting: \(err)" - } - } - - if states.contains(where: { if case .ready = $0 { true } else { false } }) { - return "Searching…" - } - - if states.contains(where: { if case .setup = $0 { true } else { false } }) { - return "Setup" - } - - return "Searching…" - } -} - diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayEndpointID.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayEndpointID.swift deleted file mode 100644 index eb2e94f51f4..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayEndpointID.swift +++ /dev/null @@ -1,25 +0,0 @@ -import Foundation -import Network - -public enum GatewayEndpointID { - public static func stableID(_ endpoint: NWEndpoint) -> String { - switch endpoint { - case let .service(name, type, domain, _): - // Keep stable across encoded/decoded differences (e.g. \032 for spaces). - let normalizedName = Self.normalizeServiceNameForID(name) - return "\(type)|\(domain)|\(normalizedName)" - default: - return String(describing: endpoint) - } - } - - public static func prettyDescription(_ endpoint: NWEndpoint) -> String { - BonjourEscapes.decode(String(describing: endpoint)) - } - - private static func normalizeServiceNameForID(_ rawName: String) -> String { - let decoded = BonjourEscapes.decode(rawName) - let normalized = decoded.split(whereSeparator: \.isWhitespace).joined(separator: " ") - return normalized.trimmingCharacters(in: .whitespacesAndNewlines) - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayErrors.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayErrors.swift deleted file mode 100644 index 6ca81dec445..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayErrors.swift +++ /dev/null @@ -1,38 +0,0 @@ -import OpenClawProtocol -import Foundation - -/// Structured error surfaced when the gateway responds with `{ ok: false }`. -public struct GatewayResponseError: LocalizedError, @unchecked Sendable { - public let method: String - public let code: String - public let message: String - public let details: [String: AnyCodable] - - public init(method: String, code: String?, message: String?, details: [String: AnyCodable]?) { - self.method = method - self.code = (code?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) - ? code!.trimmingCharacters(in: .whitespacesAndNewlines) - : "GATEWAY_ERROR" - self.message = (message?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) - ? message!.trimmingCharacters(in: .whitespacesAndNewlines) - : "gateway error" - self.details = details ?? [:] - } - - public var errorDescription: String? { - if self.code == "GATEWAY_ERROR" { return "\(self.method): \(self.message)" } - return "\(self.method): [\(self.code)] \(self.message)" - } -} - -public struct GatewayDecodingError: LocalizedError, Sendable { - public let method: String - public let message: String - - public init(method: String, message: String) { - self.method = method - self.message = message - } - - public var errorDescription: String? { "\(self.method): \(self.message)" } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift deleted file mode 100644 index 7dd2fe1eee1..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift +++ /dev/null @@ -1,442 +0,0 @@ -import OpenClawProtocol -import Foundation -import OSLog - -private struct NodeInvokeRequestPayload: Codable, Sendable { - var id: String - var nodeId: String - var command: String - var paramsJSON: String? - var timeoutMs: Int? - var idempotencyKey: String? -} - - -public actor GatewayNodeSession { - private let logger = Logger(subsystem: "ai.openclaw", category: "node.gateway") - private let decoder = JSONDecoder() - private let encoder = JSONEncoder() - private static let defaultInvokeTimeoutMs = 30_000 - private var channel: GatewayChannelActor? - 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)? - private var onInvoke: (@Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)? - private var hasEverConnected = false - private var hasNotifiedConnected = false - private var snapshotReceived = false - private var snapshotWaiters: [CheckedContinuation] = [] - - static func invokeWithTimeout( - request: BridgeInvokeRequest, - timeoutMs: Int?, - onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse - ) async -> BridgeInvokeResponse { - let timeoutLogger = Logger(subsystem: "ai.openclaw", category: "node.gateway") - let timeout: Int = { - if let timeoutMs { return max(0, timeoutMs) } - return Self.defaultInvokeTimeoutMs - }() - guard timeout > 0 else { - return await onInvoke(request) - } - - // Use an explicit latch so timeouts win even if onInvoke blocks (e.g., permission prompts). - final class InvokeLatch: @unchecked Sendable { - private let lock = NSLock() - private var continuation: CheckedContinuation? - private var resumed = false - - func setContinuation(_ continuation: CheckedContinuation) { - self.lock.lock() - defer { self.lock.unlock() } - self.continuation = continuation - } - - func resume(_ response: BridgeInvokeResponse) { - let cont: CheckedContinuation? - self.lock.lock() - if self.resumed { - self.lock.unlock() - return - } - self.resumed = true - cont = self.continuation - self.continuation = nil - self.lock.unlock() - cont?.resume(returning: response) - } - } - - let latch = InvokeLatch() - var onInvokeTask: Task? - var timeoutTask: Task? - defer { - onInvokeTask?.cancel() - timeoutTask?.cancel() - } - let response = await withCheckedContinuation { (cont: CheckedContinuation) in - latch.setContinuation(cont) - onInvokeTask = Task.detached { - let result = await onInvoke(request) - latch.resume(result) - } - timeoutTask = Task.detached { - do { - try await Task.sleep(nanoseconds: UInt64(timeout) * 1_000_000) - } catch { - // Expected when invoke finishes first and cancels the timeout task. - return - } - guard !Task.isCancelled else { return } - timeoutLogger.info("node invoke timeout fired id=\(request.id, privacy: .public)") - latch.resume(BridgeInvokeResponse( - id: request.id, - ok: false, - error: OpenClawNodeError( - code: .unavailable, - message: "node invoke timed out") - )) - } - } - timeoutLogger.info("node invoke race resolved id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)") - return response - } - private var serverEventSubscribers: [UUID: AsyncStream.Continuation] = [:] - private var canvasHostUrl: String? - - 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?, - password: String?, - connectOptions: GatewayConnectOptions, - sessionBox: WebSocketSessionBox?, - onConnected: @escaping @Sendable () async -> Void, - 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 - self.onConnected = onConnected - self.onDisconnected = onDisconnected - self.onInvoke = onInvoke - - if shouldReconnect { - self.resetConnectionState() - if let existing = self.channel { - await existing.shutdown() - } - let channel = GatewayChannelActor( - url: url, - token: token, - password: password, - session: sessionBox, - pushHandler: { [weak self] push in - await self?.handlePush(push) - }, - connectOptions: connectOptions, - disconnectHandler: { [weak self] reason in - 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 { - throw NSError(domain: "Gateway", code: 0, userInfo: [ - NSLocalizedDescriptionKey: "gateway channel unavailable", - ]) - } - - do { - try await channel.connect() - _ = await self.waitForSnapshot(timeoutMs: 500) - await self.notifyConnectedIfNeeded() - } catch { - throw error - } - } - - public func disconnect() async { - await self.channel?.shutdown() - self.channel = nil - self.activeURL = nil - self.activeToken = nil - self.activePassword = nil - self.activeConnectOptionsKey = nil - self.hasEverConnected = false - self.resetConnectionState() - } - - public func currentCanvasHostUrl() -> String? { - self.canvasHostUrl - } - - public func currentRemoteAddress() -> String? { - guard let url = self.activeURL else { return nil } - guard let host = url.host else { return url.absoluteString } - let port = url.port ?? (url.scheme == "wss" ? 443 : 80) - if host.contains(":") { - return "[\(host)]:\(port)" - } - return "\(host):\(port)" - } - - public func sendEvent(event: String, payloadJSON: String?) async { - guard let channel = self.channel else { return } - let params: [String: AnyCodable] = [ - "event": AnyCodable(event), - "payloadJSON": AnyCodable(payloadJSON ?? NSNull()), - ] - do { - try await channel.send(method: "node.event", params: params) - } catch { - self.logger.error("node event failed: \(error.localizedDescription, privacy: .public)") - } - } - - public func request(method: String, paramsJSON: String?, timeoutSeconds: Int = 15) async throws -> Data { - guard let channel = self.channel else { - throw NSError(domain: "Gateway", code: 11, userInfo: [ - NSLocalizedDescriptionKey: "not connected", - ]) - } - - let params = try self.decodeParamsJSON(paramsJSON) - return try await channel.request( - method: method, - params: params, - timeoutMs: Double(timeoutSeconds * 1000)) - } - - public func subscribeServerEvents(bufferingNewest: Int = 200) -> AsyncStream { - let id = UUID() - let session = self - return AsyncStream(bufferingPolicy: .bufferingNewest(bufferingNewest)) { continuation in - self.serverEventSubscribers[id] = continuation - continuation.onTermination = { @Sendable _ in - Task { await session.removeServerEventSubscriber(id) } - } - } - } - - private func handlePush(_ push: GatewayPush) async { - switch push { - case let .snapshot(ok): - let raw = ok.canvashosturl?.trimmingCharacters(in: .whitespacesAndNewlines) - self.canvasHostUrl = (raw?.isEmpty == false) ? raw : nil - if self.hasEverConnected { - self.broadcastServerEvent( - EventFrame(type: "event", event: "seqGap", payload: nil, seq: nil, stateversion: nil)) - } - self.hasEverConnected = true - self.markSnapshotReceived() - await self.notifyConnectedIfNeeded() - case let .event(evt): - await self.handleEvent(evt) - default: - break - } - } - - private func resetConnectionState() { - self.hasNotifiedConnected = false - self.snapshotReceived = false - if !self.snapshotWaiters.isEmpty { - let waiters = self.snapshotWaiters - self.snapshotWaiters.removeAll() - for waiter in waiters { - waiter.resume(returning: false) - } - } - } - - 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 { - let waiters = self.snapshotWaiters - self.snapshotWaiters.removeAll() - for waiter in waiters { - waiter.resume(returning: true) - } - } - } - - private func waitForSnapshot(timeoutMs: Int) async -> Bool { - if self.snapshotReceived { return true } - let clamped = max(0, timeoutMs) - return await withCheckedContinuation { cont in - self.snapshotWaiters.append(cont) - Task { [weak self] in - guard let self else { return } - try? await Task.sleep(nanoseconds: UInt64(clamped) * 1_000_000) - await self.timeoutSnapshotWaiters() - } - } - } - - private func timeoutSnapshotWaiters() { - guard !self.snapshotReceived else { return } - if !self.snapshotWaiters.isEmpty { - let waiters = self.snapshotWaiters - self.snapshotWaiters.removeAll() - for waiter in waiters { - waiter.resume(returning: false) - } - } - } - - private func notifyConnectedIfNeeded() async { - guard !self.hasNotifiedConnected else { return } - self.hasNotifiedConnected = true - await self.onConnected?() - } - - private func handleEvent(_ evt: EventFrame) async { - self.broadcastServerEvent(evt) - guard evt.event == "node.invoke.request" else { return } - self.logger.info("node invoke request received") - guard let payload = evt.payload else { return } - do { - let request = try self.decodeInvokeRequest(from: payload) - let timeoutLabel = request.timeoutMs.map(String.init) ?? "none" - self.logger.info("node invoke request decoded id=\(request.id, privacy: .public) command=\(request.command, privacy: .public) timeoutMs=\(timeoutLabel, privacy: .public)") - guard let onInvoke else { return } - let req = BridgeInvokeRequest(id: request.id, command: request.command, paramsJSON: request.paramsJSON) - self.logger.info("node invoke executing id=\(request.id, privacy: .public)") - let response = await Self.invokeWithTimeout( - request: req, - timeoutMs: request.timeoutMs, - onInvoke: onInvoke - ) - self.logger.info("node invoke completed id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)") - await self.sendInvokeResult(request: request, response: response) - } catch { - self.logger.error("node invoke decode failed: \(error.localizedDescription, privacy: .public)") - } - } - - private func decodeInvokeRequest(from payload: OpenClawProtocol.AnyCodable) throws -> NodeInvokeRequestPayload { - do { - let data = try self.encoder.encode(payload) - return try self.decoder.decode(NodeInvokeRequestPayload.self, from: data) - } catch { - if let raw = payload.value as? String, let data = raw.data(using: .utf8) { - return try self.decoder.decode(NodeInvokeRequestPayload.self, from: data) - } - throw error - } - } - - private func sendInvokeResult(request: NodeInvokeRequestPayload, response: BridgeInvokeResponse) async { - guard let channel = self.channel else { return } - self.logger.info("node invoke result sending id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)") - var params: [String: AnyCodable] = [ - "id": AnyCodable(request.id), - "nodeId": AnyCodable(request.nodeId), - "ok": AnyCodable(response.ok), - ] - if let payloadJSON = response.payloadJSON { - params["payloadJSON"] = AnyCodable(payloadJSON) - } - if let error = response.error { - params["error"] = AnyCodable([ - "code": error.code.rawValue, - "message": error.message, - ]) - } - do { - try await channel.send(method: "node.invoke.result", params: params) - } catch { - self.logger.error("node invoke result failed id=\(request.id, privacy: .public) error=\(error.localizedDescription, privacy: .public)") - } - } - - private func decodeParamsJSON( - _ paramsJSON: String?) throws -> [String: AnyCodable]? - { - guard let paramsJSON, !paramsJSON.isEmpty else { return nil } - guard let data = paramsJSON.data(using: .utf8) else { - throw NSError(domain: "Gateway", code: 12, userInfo: [ - NSLocalizedDescriptionKey: "paramsJSON not UTF-8", - ]) - } - let raw = try JSONSerialization.jsonObject(with: data) - guard let dict = raw as? [String: Any] else { - return nil - } - return dict.reduce(into: [:]) { acc, entry in - acc[entry.key] = AnyCodable(entry.value) - } - } - - private func broadcastServerEvent(_ evt: EventFrame) { - for (id, continuation) in self.serverEventSubscribers { - if case .terminated = continuation.yield(evt) { - self.serverEventSubscribers.removeValue(forKey: id) - } - } - } - - private func removeServerEventSubscriber(_ id: UUID) { - self.serverEventSubscribers.removeValue(forKey: id) - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayPayloadDecoding.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayPayloadDecoding.swift deleted file mode 100644 index 139aa7d2942..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayPayloadDecoding.swift +++ /dev/null @@ -1,20 +0,0 @@ -import OpenClawProtocol -import Foundation - -public enum GatewayPayloadDecoding { - public static func decode( - _ payload: AnyCodable, - as _: T.Type = T.self) throws -> T - { - let data = try JSONEncoder().encode(payload) - return try JSONDecoder().decode(T.self, from: data) - } - - public static func decodeIfPresent( - _ payload: AnyCodable?, - as _: T.Type = T.self) throws -> T? - { - guard let payload else { return nil } - return try self.decode(payload, as: T.self) - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayPush.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayPush.swift deleted file mode 100644 index 65e118ff14e..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayPush.swift +++ /dev/null @@ -1,13 +0,0 @@ -import OpenClawProtocol - -/// Server-push messages from the gateway websocket. -/// -/// This is the in-process replacement for the legacy `NotificationCenter` fan-out. -public enum GatewayPush: Sendable { - /// A full snapshot that arrives on connect (or reconnect). - case snapshot(HelloOk) - /// A server push event frame. - case event(EventFrame) - /// A detected sequence gap (`expected...received`) for event frames. - case seqGap(expected: Int, received: Int) -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift deleted file mode 100644 index a0cbcd375f6..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift +++ /dev/null @@ -1,119 +0,0 @@ -import CryptoKit -import Foundation -import Security - -public struct GatewayTLSParams: Sendable { - public let required: Bool - public let expectedFingerprint: String? - public let allowTOFU: Bool - public let storeKey: String? - - public init(required: Bool, expectedFingerprint: String?, allowTOFU: Bool, storeKey: String?) { - self.required = required - self.expectedFingerprint = expectedFingerprint - self.allowTOFU = allowTOFU - self.storeKey = storeKey - } -} - -public enum GatewayTLSStore { - private static let suiteName = "ai.openclaw.shared" - private static let keyPrefix = "gateway.tls." - - private static var defaults: UserDefaults { - UserDefaults(suiteName: suiteName) ?? .standard - } - - public static func loadFingerprint(stableID: String) -> String? { - let key = self.keyPrefix + stableID - let raw = self.defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) - if raw?.isEmpty == false { return raw } - return nil - } - - public static func saveFingerprint(_ value: String, stableID: String) { - let key = self.keyPrefix + stableID - self.defaults.set(value, forKey: key) - } -} - -public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLSessionDelegate, @unchecked Sendable { - private let params: GatewayTLSParams - private lazy var session: URLSession = { - let config = URLSessionConfiguration.default - config.waitsForConnectivity = true - return URLSession(configuration: config, delegate: self, delegateQueue: nil) - }() - - public init(params: GatewayTLSParams) { - self.params = params - super.init() - } - - public func makeWebSocketTask(url: URL) -> WebSocketTaskBox { - let task = self.session.webSocketTask(with: url) - task.maximumMessageSize = 16 * 1024 * 1024 - return WebSocketTaskBox(task: task) - } - - public func urlSession( - _ session: URLSession, - didReceive challenge: URLAuthenticationChallenge, - completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void - ) { - guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, - let trust = challenge.protectionSpace.serverTrust - else { - completionHandler(.performDefaultHandling, nil) - return - } - - let expected = params.expectedFingerprint.map(normalizeFingerprint) - if let fingerprint = certificateFingerprint(trust) { - if let expected { - if fingerprint == expected { - completionHandler(.useCredential, URLCredential(trust: trust)) - } else { - completionHandler(.cancelAuthenticationChallenge, nil) - } - return - } - if params.allowTOFU { - if let storeKey = params.storeKey { - GatewayTLSStore.saveFingerprint(fingerprint, stableID: storeKey) - } - completionHandler(.useCredential, URLCredential(trust: trust)) - return - } - } - - let ok = SecTrustEvaluateWithError(trust, nil) - if ok || !params.required { - completionHandler(.useCredential, URLCredential(trust: trust)) - } else { - completionHandler(.cancelAuthenticationChallenge, nil) - } - } -} - -private func certificateFingerprint(_ trust: SecTrust) -> String? { - guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate], - let cert = chain.first - else { - return nil - } - return sha256Hex(SecCertificateCopyData(cert) as Data) -} - -private func sha256Hex(_ data: Data) -> String { - let digest = SHA256.hash(data: data) - return digest.map { String(format: "%02x", $0) }.joined() -} - -private func normalizeFingerprint(_ raw: String) -> String { - let stripped = raw.replacingOccurrences( - of: #"(?i)^sha-?256\s*:?\s*"#, - with: "", - options: .regularExpression) - return stripped.lowercased().filter(\.isHexDigit) -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/InstanceIdentity.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/InstanceIdentity.swift deleted file mode 100644 index d18fa4e9fbf..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/InstanceIdentity.swift +++ /dev/null @@ -1,108 +0,0 @@ -import Foundation - -#if canImport(UIKit) -import UIKit -#endif - -public enum InstanceIdentity { - private static let suiteName = "ai.openclaw.shared" - private static let instanceIdKey = "instanceId" - - private static var defaults: UserDefaults { - UserDefaults(suiteName: suiteName) ?? .standard - } - -#if canImport(UIKit) - private static func readMainActor(_ body: @MainActor () -> T) -> T { - if Thread.isMainThread { - return MainActor.assumeIsolated { body() } - } - return DispatchQueue.main.sync { - MainActor.assumeIsolated { body() } - } - } -#endif - - public static let instanceId: String = { - let defaults = Self.defaults - if let existing = defaults.string(forKey: instanceIdKey)? - .trimmingCharacters(in: .whitespacesAndNewlines), - !existing.isEmpty - { - return existing - } - - let id = UUID().uuidString.lowercased() - defaults.set(id, forKey: instanceIdKey) - return id - }() - - public static let displayName: String = { -#if canImport(UIKit) - let name = Self.readMainActor { - UIDevice.current.name.trimmingCharacters(in: .whitespacesAndNewlines) - } - return name.isEmpty ? "openclaw" : name -#else - if let name = Host.current().localizedName?.trimmingCharacters(in: .whitespacesAndNewlines), - !name.isEmpty - { - return name - } - return "openclaw" -#endif - }() - - public static let modelIdentifier: String? = { -#if canImport(UIKit) - 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 ? nil : trimmed -#else - var size = 0 - guard sysctlbyname("hw.model", nil, &size, nil, 0) == 0, size > 1 else { return nil } - - var buffer = [CChar](repeating: 0, count: size) - guard sysctlbyname("hw.model", &buffer, &size, nil, 0) == 0 else { return nil } - - let bytes = buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) } - guard let raw = String(bytes: bytes, encoding: .utf8) else { return nil } - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed -#endif - }() - - public static let deviceFamily: String = { -#if canImport(UIKit) - return Self.readMainActor { - switch UIDevice.current.userInterfaceIdiom { - case .pad: return "iPad" - case .phone: return "iPhone" - default: return "iOS" - } - } -#else - return "Mac" -#endif - }() - - public static let platformString: String = { - let v = ProcessInfo.processInfo.operatingSystemVersion -#if canImport(UIKit) - let name = Self.readMainActor { - switch UIDevice.current.userInterfaceIdiom { - case .pad: return "iPadOS" - case .phone: return "iOS" - default: return "iOS" - } - } - return "\(name) \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" -#else - return "macOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" -#endif - }() -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/JPEGTranscoder.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/JPEGTranscoder.swift deleted file mode 100644 index f4b1cb95125..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/JPEGTranscoder.swift +++ /dev/null @@ -1,135 +0,0 @@ -import CoreGraphics -import Foundation -import ImageIO -import UniformTypeIdentifiers - -public enum JPEGTranscodeError: LocalizedError, Sendable { - case decodeFailed - case propertiesMissing - case encodeFailed - case sizeLimitExceeded(maxBytes: Int, actualBytes: Int) - - public var errorDescription: String? { - switch self { - case .decodeFailed: - "Failed to decode image data" - case .propertiesMissing: - "Failed to read image properties" - case .encodeFailed: - "Failed to encode JPEG" - case let .sizeLimitExceeded(maxBytes, actualBytes): - "JPEG exceeds size limit (\(actualBytes) bytes > \(maxBytes) bytes)" - } - } -} - -public struct JPEGTranscoder: Sendable { - public static func clampQuality(_ quality: Double) -> Double { - min(1.0, max(0.05, quality)) - } - - /// Re-encodes image data to JPEG, optionally downscaling so that the *oriented* pixel width is <= `maxWidthPx`. - /// - /// - Important: This normalizes EXIF orientation (the output pixels are rotated if needed; orientation tag is not - /// relied on). - public static func transcodeToJPEG( - imageData: Data, - maxWidthPx: Int?, - quality: Double, - maxBytes: Int? = nil) throws -> (data: Data, widthPx: Int, heightPx: Int) - { - guard let src = CGImageSourceCreateWithData(imageData as CFData, nil) else { - throw JPEGTranscodeError.decodeFailed - } - guard - let props = CGImageSourceCopyPropertiesAtIndex(src, 0, nil) as? [CFString: Any], - let rawWidth = props[kCGImagePropertyPixelWidth] as? NSNumber, - let rawHeight = props[kCGImagePropertyPixelHeight] as? NSNumber - else { - throw JPEGTranscodeError.propertiesMissing - } - - let pixelWidth = rawWidth.intValue - let pixelHeight = rawHeight.intValue - let orientation = (props[kCGImagePropertyOrientation] as? NSNumber)?.intValue ?? 1 - - guard pixelWidth > 0, pixelHeight > 0 else { - throw JPEGTranscodeError.propertiesMissing - } - - let rotates90 = orientation == 5 || orientation == 6 || orientation == 7 || orientation == 8 - let orientedWidth = rotates90 ? pixelHeight : pixelWidth - let orientedHeight = rotates90 ? pixelWidth : pixelHeight - - let maxDim = max(orientedWidth, orientedHeight) - var targetMaxPixelSize: Int = { - guard let maxWidthPx, maxWidthPx > 0 else { return maxDim } - guard orientedWidth > maxWidthPx else { return maxDim } // never upscale - - let scale = Double(maxWidthPx) / Double(orientedWidth) - return max(1, Int((Double(maxDim) * scale).rounded(.toNearestOrAwayFromZero))) - }() - - func encode(maxPixelSize: Int, quality: Double) throws -> (data: Data, widthPx: Int, heightPx: Int) { - let thumbOpts: [CFString: Any] = [ - kCGImageSourceCreateThumbnailFromImageAlways: true, - kCGImageSourceCreateThumbnailWithTransform: true, - kCGImageSourceThumbnailMaxPixelSize: maxPixelSize, - kCGImageSourceShouldCacheImmediately: true, - ] - - guard let img = CGImageSourceCreateThumbnailAtIndex(src, 0, thumbOpts as CFDictionary) else { - throw JPEGTranscodeError.decodeFailed - } - - let out = NSMutableData() - guard let dest = CGImageDestinationCreateWithData(out, UTType.jpeg.identifier as CFString, 1, nil) else { - throw JPEGTranscodeError.encodeFailed - } - let q = self.clampQuality(quality) - let encodeProps = [kCGImageDestinationLossyCompressionQuality: q] as CFDictionary - CGImageDestinationAddImage(dest, img, encodeProps) - guard CGImageDestinationFinalize(dest) else { - throw JPEGTranscodeError.encodeFailed - } - - return (out as Data, img.width, img.height) - } - - guard let maxBytes, maxBytes > 0 else { - return try encode(maxPixelSize: targetMaxPixelSize, quality: quality) - } - - let minQuality = max(0.2, self.clampQuality(quality) * 0.35) - let minPixelSize = 256 - var best = try encode(maxPixelSize: targetMaxPixelSize, quality: quality) - if best.data.count <= maxBytes { - return best - } - - for _ in 0..<6 { - var q = self.clampQuality(quality) - for _ in 0..<6 { - let candidate = try encode(maxPixelSize: targetMaxPixelSize, quality: q) - best = candidate - if candidate.data.count <= maxBytes { - return candidate - } - if q <= minQuality { break } - q = max(minQuality, q * 0.75) - } - - let nextPixelSize = max(Int(Double(targetMaxPixelSize) * 0.85), minPixelSize) - if nextPixelSize == targetMaxPixelSize { - break - } - targetMaxPixelSize = nextPixelSize - } - - if best.data.count > maxBytes { - throw JPEGTranscodeError.sizeLimitExceeded(maxBytes: maxBytes, actualBytes: best.data.count) - } - - return best - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/LocationCommands.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/LocationCommands.swift deleted file mode 100644 index c02bc84202d..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/LocationCommands.swift +++ /dev/null @@ -1,57 +0,0 @@ -import Foundation - -public enum OpenClawLocationCommand: String, Codable, Sendable { - case get = "location.get" -} - -public enum OpenClawLocationAccuracy: String, Codable, Sendable { - case coarse - case balanced - case precise -} - -public struct OpenClawLocationGetParams: Codable, Sendable, Equatable { - public var timeoutMs: Int? - public var maxAgeMs: Int? - public var desiredAccuracy: OpenClawLocationAccuracy? - - public init(timeoutMs: Int? = nil, maxAgeMs: Int? = nil, desiredAccuracy: OpenClawLocationAccuracy? = nil) { - self.timeoutMs = timeoutMs - self.maxAgeMs = maxAgeMs - self.desiredAccuracy = desiredAccuracy - } -} - -public struct OpenClawLocationPayload: Codable, Sendable, Equatable { - public var lat: Double - public var lon: Double - public var accuracyMeters: Double - public var altitudeMeters: Double? - public var speedMps: Double? - public var headingDeg: Double? - public var timestamp: String - public var isPrecise: Bool - public var source: String? - - public init( - lat: Double, - lon: Double, - accuracyMeters: Double, - altitudeMeters: Double? = nil, - speedMps: Double? = nil, - headingDeg: Double? = nil, - timestamp: String, - isPrecise: Bool, - source: String? = nil) - { - self.lat = lat - self.lon = lon - self.accuracyMeters = accuracyMeters - self.altitudeMeters = altitudeMeters - self.speedMps = speedMps - self.headingDeg = headingDeg - self.timestamp = timestamp - self.isPrecise = isPrecise - self.source = source - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/LocationSettings.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/LocationSettings.swift deleted file mode 100644 index 961e2980c51..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/LocationSettings.swift +++ /dev/null @@ -1,7 +0,0 @@ -import Foundation - -public enum OpenClawLocationMode: String, Codable, Sendable, CaseIterable { - case off - case whileUsing - case always -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/MotionCommands.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/MotionCommands.swift deleted file mode 100644 index ab487bfd00a..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/MotionCommands.swift +++ /dev/null @@ -1,95 +0,0 @@ -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 - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/NetworkInterfaces.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/NetworkInterfaces.swift deleted file mode 100644 index 3679ef54234..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/NetworkInterfaces.swift +++ /dev/null @@ -1,43 +0,0 @@ -import Darwin -import Foundation - -public enum NetworkInterfaces { - public static func primaryIPv4Address() -> String? { - var addrList: UnsafeMutablePointer? - guard getifaddrs(&addrList) == 0, let first = addrList else { return nil } - defer { freeifaddrs(addrList) } - - var fallback: String? - var en0: String? - - 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 name = String(cString: ptr.pointee.ifa_name) - 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 name == "en0" { en0 = ip; break } - if fallback == nil { fallback = ip } - } - - return en0 ?? fallback - } -} - diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/NodeError.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/NodeError.swift deleted file mode 100644 index 4fe3fd042ae..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/NodeError.swift +++ /dev/null @@ -1,28 +0,0 @@ -import Foundation - -public enum OpenClawNodeErrorCode: String, Codable, Sendable { - case notPaired = "NOT_PAIRED" - case unauthorized = "UNAUTHORIZED" - case backgroundUnavailable = "NODE_BACKGROUND_UNAVAILABLE" - case invalidRequest = "INVALID_REQUEST" - case unavailable = "UNAVAILABLE" -} - -public struct OpenClawNodeError: Error, Codable, Sendable, Equatable { - public var code: OpenClawNodeErrorCode - public var message: String - public var retryable: Bool? - public var retryAfterMs: Int? - - public init( - code: OpenClawNodeErrorCode, - message: String, - retryable: Bool? = nil, - retryAfterMs: Int? = nil) - { - self.code = code - self.message = message - self.retryable = retryable - self.retryAfterMs = retryAfterMs - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/OpenClawKitResources.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/OpenClawKitResources.swift deleted file mode 100644 index 5af33d1d35c..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/OpenClawKitResources.swift +++ /dev/null @@ -1,83 +0,0 @@ -import Foundation - -public enum OpenClawKitResources { - /// Resource bundle for OpenClawKit. - /// - /// Locates the SwiftPM-generated resource bundle, checking multiple locations: - /// 1. Inside Bundle.main (packaged apps) - /// 2. Bundle.module (SwiftPM development/tests) - /// 3. Falls back to Bundle.main if not found (resource lookups will return nil) - /// - /// This avoids a fatal crash when Bundle.module can't locate its resources - /// in packaged .app bundles where the resource bundle path differs from - /// SwiftPM's expectations. - public static let bundle: Bundle = locateBundle() - - private static let bundleName = "OpenClawKit_OpenClawKit" - - private static func locateBundle() -> Bundle { - // 1. Check inside Bundle.main (packaged apps copy resources here) - if let mainResourceURL = Bundle.main.resourceURL { - let bundleURL = mainResourceURL.appendingPathComponent("\(bundleName).bundle") - if let bundle = Bundle(url: bundleURL) { - return bundle - } - } - - // 2. Check Bundle.main directly for embedded resources - if Bundle.main.url(forResource: "tool-display", withExtension: "json") != nil { - return Bundle.main - } - - // 3. Try Bundle.module (works in SwiftPM development/tests) - // Wrap in a function to defer the fatalError until actually called - if let moduleBundle = loadModuleBundleSafely() { - return moduleBundle - } - - // 4. Fallback: return Bundle.main (resource lookups will return nil gracefully) - return Bundle.main - } - - private static func loadModuleBundleSafely() -> Bundle? { - // Bundle.module is generated by SwiftPM and will fatalError if not found. - // We check likely locations manually to avoid the crash. - let candidates: [URL?] = [ - Bundle.main.resourceURL, - Bundle.main.bundleURL, - Bundle(for: BundleLocator.self).resourceURL, - Bundle(for: BundleLocator.self).bundleURL, - ] - - for candidate in candidates { - guard let baseURL = candidate else { continue } - - // SwiftPM often places the resource bundle next to (or near) the test runner bundle, - // not inside it. Walk up a few levels and check common container paths. - var roots: [URL] = [] - roots.append(baseURL) - roots.append(baseURL.appendingPathComponent("Resources")) - roots.append(baseURL.appendingPathComponent("Contents/Resources")) - - var current = baseURL - for _ in 0 ..< 5 { - current = current.deletingLastPathComponent() - roots.append(current) - roots.append(current.appendingPathComponent("Resources")) - roots.append(current.appendingPathComponent("Contents/Resources")) - } - - for root in roots { - let bundleURL = root.appendingPathComponent("\(bundleName).bundle") - if let bundle = Bundle(url: bundleURL) { - return bundle - } - } - } - - return nil - } -} - -// Helper class for bundle lookup via Bundle(for:) -private final class BundleLocator {} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/PhotoCapture.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/PhotoCapture.swift deleted file mode 100644 index b5f00d34751..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/PhotoCapture.swift +++ /dev/null @@ -1,19 +0,0 @@ -import Foundation - -public enum PhotoCapture { - public static func transcodeJPEGForGateway( - rawData: Data, - maxWidthPx: Int, - quality: Double, - maxPayloadBytes: Int = 5 * 1024 * 1024 - ) throws -> (data: Data, widthPx: Int, heightPx: Int) { - // Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under maxPayloadBytes (API limit). - let maxEncodedBytes = (maxPayloadBytes / 4) * 3 - return try JPEGTranscoder.transcodeToJPEG( - imageData: rawData, - maxWidthPx: maxWidthPx, - quality: quality, - maxBytes: maxEncodedBytes) - } -} - diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/PhotosCommands.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/PhotosCommands.swift deleted file mode 100644 index 8d22f5d2791..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/PhotosCommands.swift +++ /dev/null @@ -1,41 +0,0 @@ -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 - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/RemindersCommands.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/RemindersCommands.swift deleted file mode 100644 index ac275d8036e..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/RemindersCommands.swift +++ /dev/null @@ -1,82 +0,0 @@ -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 - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasScaffold/scaffold.html b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasScaffold/scaffold.html deleted file mode 100644 index ceb7a975da4..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasScaffold/scaffold.html +++ /dev/null @@ -1,225 +0,0 @@ - - - - - - Canvas - - - - - -
-
-
Ready
-
Waiting for agent
-
-
- - - diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json deleted file mode 100644 index 9c0e57fc6ae..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json +++ /dev/null @@ -1,197 +0,0 @@ -{ - "version": 1, - "fallback": { - "emoji": "🧩", - "detailKeys": [ - "command", - "path", - "url", - "targetUrl", - "targetId", - "ref", - "element", - "node", - "nodeId", - "id", - "requestId", - "to", - "channelId", - "guildId", - "userId", - "name", - "query", - "pattern", - "messageId" - ] - }, - "tools": { - "bash": { - "emoji": "🛠️", - "title": "Bash", - "detailKeys": ["command"] - }, - "process": { - "emoji": "🧰", - "title": "Process", - "detailKeys": ["sessionId"] - }, - "read": { - "emoji": "📖", - "title": "Read", - "detailKeys": ["path"] - }, - "write": { - "emoji": "✍️", - "title": "Write", - "detailKeys": ["path"] - }, - "edit": { - "emoji": "📝", - "title": "Edit", - "detailKeys": ["path"] - }, - "attach": { - "emoji": "📎", - "title": "Attach", - "detailKeys": ["path", "url", "fileName"] - }, - "browser": { - "emoji": "🌐", - "title": "Browser", - "actions": { - "status": { "label": "status" }, - "start": { "label": "start" }, - "stop": { "label": "stop" }, - "tabs": { "label": "tabs" }, - "open": { "label": "open", "detailKeys": ["targetUrl"] }, - "focus": { "label": "focus", "detailKeys": ["targetId"] }, - "close": { "label": "close", "detailKeys": ["targetId"] }, - "snapshot": { - "label": "snapshot", - "detailKeys": ["targetUrl", "targetId", "ref", "element", "format"] - }, - "screenshot": { - "label": "screenshot", - "detailKeys": ["targetUrl", "targetId", "ref", "element"] - }, - "navigate": { - "label": "navigate", - "detailKeys": ["targetUrl", "targetId"] - }, - "console": { "label": "console", "detailKeys": ["level", "targetId"] }, - "pdf": { "label": "pdf", "detailKeys": ["targetId"] }, - "upload": { - "label": "upload", - "detailKeys": ["paths", "ref", "inputRef", "element", "targetId"] - }, - "dialog": { - "label": "dialog", - "detailKeys": ["accept", "promptText", "targetId"] - }, - "act": { - "label": "act", - "detailKeys": ["request.kind", "request.ref", "request.selector", "request.text", "request.value"] - } - } - }, - "canvas": { - "emoji": "🖼️", - "title": "Canvas", - "actions": { - "present": { "label": "present", "detailKeys": ["target", "node", "nodeId"] }, - "hide": { "label": "hide", "detailKeys": ["node", "nodeId"] }, - "navigate": { "label": "navigate", "detailKeys": ["url", "node", "nodeId"] }, - "eval": { "label": "eval", "detailKeys": ["javaScript", "node", "nodeId"] }, - "snapshot": { "label": "snapshot", "detailKeys": ["format", "node", "nodeId"] }, - "a2ui_push": { "label": "A2UI push", "detailKeys": ["jsonlPath", "node", "nodeId"] }, - "a2ui_reset": { "label": "A2UI reset", "detailKeys": ["node", "nodeId"] } - } - }, - "nodes": { - "emoji": "📱", - "title": "Nodes", - "actions": { - "status": { "label": "status" }, - "describe": { "label": "describe", "detailKeys": ["node", "nodeId"] }, - "pending": { "label": "pending" }, - "approve": { "label": "approve", "detailKeys": ["requestId"] }, - "reject": { "label": "reject", "detailKeys": ["requestId"] }, - "notify": { "label": "notify", "detailKeys": ["node", "nodeId", "title", "body"] }, - "camera_snap": { "label": "camera snap", "detailKeys": ["node", "nodeId", "facing", "deviceId"] }, - "camera_list": { "label": "camera list", "detailKeys": ["node", "nodeId"] }, - "camera_clip": { "label": "camera clip", "detailKeys": ["node", "nodeId", "facing", "duration", "durationMs"] }, - "screen_record": { - "label": "screen record", - "detailKeys": ["node", "nodeId", "duration", "durationMs", "fps", "screenIndex"] - } - } - }, - "cron": { - "emoji": "⏰", - "title": "Cron", - "actions": { - "status": { "label": "status" }, - "list": { "label": "list" }, - "add": { - "label": "add", - "detailKeys": ["job.name", "job.id", "job.schedule", "job.cron"] - }, - "update": { "label": "update", "detailKeys": ["id"] }, - "remove": { "label": "remove", "detailKeys": ["id"] }, - "run": { "label": "run", "detailKeys": ["id"] }, - "runs": { "label": "runs", "detailKeys": ["id"] }, - "wake": { "label": "wake", "detailKeys": ["text", "mode"] } - } - }, - "gateway": { - "emoji": "🔌", - "title": "Gateway", - "actions": { - "restart": { "label": "restart", "detailKeys": ["reason", "delayMs"] } - } - }, - "whatsapp_login": { - "emoji": "🟢", - "title": "WhatsApp Login", - "actions": { - "start": { "label": "start" }, - "wait": { "label": "wait" } - } - }, - "discord": { - "emoji": "💬", - "title": "Discord", - "actions": { - "react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji"] }, - "reactions": { "label": "reactions", "detailKeys": ["channelId", "messageId"] }, - "sticker": { "label": "sticker", "detailKeys": ["to", "stickerIds"] }, - "poll": { "label": "poll", "detailKeys": ["question", "to"] }, - "permissions": { "label": "permissions", "detailKeys": ["channelId"] }, - "readMessages": { "label": "read messages", "detailKeys": ["channelId", "limit"] }, - "sendMessage": { "label": "send", "detailKeys": ["to", "content"] }, - "editMessage": { "label": "edit", "detailKeys": ["channelId", "messageId"] }, - "deleteMessage": { "label": "delete", "detailKeys": ["channelId", "messageId"] }, - "threadCreate": { "label": "thread create", "detailKeys": ["channelId", "name"] }, - "threadList": { "label": "thread list", "detailKeys": ["guildId", "channelId"] }, - "threadReply": { "label": "thread reply", "detailKeys": ["channelId", "content"] }, - "pinMessage": { "label": "pin", "detailKeys": ["channelId", "messageId"] }, - "unpinMessage": { "label": "unpin", "detailKeys": ["channelId", "messageId"] }, - "listPins": { "label": "list pins", "detailKeys": ["channelId"] }, - "searchMessages": { "label": "search", "detailKeys": ["guildId", "content"] }, - "memberInfo": { "label": "member", "detailKeys": ["guildId", "userId"] }, - "roleInfo": { "label": "roles", "detailKeys": ["guildId"] }, - "emojiList": { "label": "emoji list", "detailKeys": ["guildId"] }, - "roleAdd": { "label": "role add", "detailKeys": ["guildId", "userId", "roleId"] }, - "roleRemove": { "label": "role remove", "detailKeys": ["guildId", "userId", "roleId"] }, - "channelInfo": { "label": "channel", "detailKeys": ["channelId"] }, - "channelList": { "label": "channels", "detailKeys": ["guildId"] }, - "voiceStatus": { "label": "voice", "detailKeys": ["guildId", "userId"] }, - "eventList": { "label": "events", "detailKeys": ["guildId"] }, - "eventCreate": { "label": "event create", "detailKeys": ["guildId", "name"] }, - "timeout": { "label": "timeout", "detailKeys": ["guildId", "userId"] }, - "kick": { "label": "kick", "detailKeys": ["guildId", "userId"] }, - "ban": { "label": "ban", "detailKeys": ["guildId", "userId"] } - } - } - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/ScreenCommands.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/ScreenCommands.swift deleted file mode 100644 index dfb57ce2ab2..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/ScreenCommands.swift +++ /dev/null @@ -1,27 +0,0 @@ -import Foundation - -public enum OpenClawScreenCommand: String, Codable, Sendable { - case record = "screen.record" -} - -public struct OpenClawScreenRecordParams: Codable, Sendable, Equatable { - public var screenIndex: Int? - public var durationMs: Int? - public var fps: Double? - public var format: String? - public var includeAudio: Bool? - - public init( - screenIndex: Int? = nil, - durationMs: Int? = nil, - fps: Double? = nil, - format: String? = nil, - includeAudio: Bool? = nil) - { - self.screenIndex = screenIndex - self.durationMs = durationMs - self.fps = fps - self.format = format - self.includeAudio = includeAudio - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareGatewayRelaySettings.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareGatewayRelaySettings.swift deleted file mode 100644 index 7b4c3864b37..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareGatewayRelaySettings.swift +++ /dev/null @@ -1,62 +0,0 @@ -import Foundation - -public struct ShareGatewayRelayConfig: Codable, Sendable, Equatable { - public let gatewayURLString: String - public let token: String? - public let password: String? - public let sessionKey: String - public let deliveryChannel: String? - public let deliveryTo: String? - - public init( - gatewayURLString: String, - token: String?, - password: String?, - sessionKey: String, - deliveryChannel: String? = nil, - deliveryTo: String? = nil) - { - self.gatewayURLString = gatewayURLString - self.token = token - self.password = password - self.sessionKey = sessionKey - self.deliveryChannel = deliveryChannel - self.deliveryTo = deliveryTo - } -} - -public enum ShareGatewayRelaySettings { - private static let suiteName = "group.ai.openclaw.shared" - private static let relayConfigKey = "share.gatewayRelay.config.v1" - private static let lastEventKey = "share.gatewayRelay.event.v1" - - private static var defaults: UserDefaults { - UserDefaults(suiteName: self.suiteName) ?? .standard - } - - public static func loadConfig() -> ShareGatewayRelayConfig? { - guard let data = self.defaults.data(forKey: self.relayConfigKey) else { return nil } - return try? JSONDecoder().decode(ShareGatewayRelayConfig.self, from: data) - } - - public static func saveConfig(_ config: ShareGatewayRelayConfig) { - guard let data = try? JSONEncoder().encode(config) else { return } - self.defaults.set(data, forKey: self.relayConfigKey) - } - - public static func clearConfig() { - self.defaults.removeObject(forKey: self.relayConfigKey) - } - - public static func saveLastEvent(_ message: String) { - let timestamp = ISO8601DateFormatter().string(from: Date()) - let payload = "[\(timestamp)] \(message)" - self.defaults.set(payload, forKey: self.lastEventKey) - } - - public static func loadLastEvent() -> String? { - let value = self.defaults.string(forKey: self.lastEventKey)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return value.isEmpty ? nil : value - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareToAgentDeepLink.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareToAgentDeepLink.swift deleted file mode 100644 index 08f06234334..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareToAgentDeepLink.swift +++ /dev/null @@ -1,62 +0,0 @@ -import Foundation - -public struct SharedContentPayload: Sendable, Equatable { - public let title: String? - public let url: URL? - public let text: String? - - public init(title: String?, url: URL?, text: String?) { - self.title = title - self.url = url - self.text = text - } -} - -public enum ShareToAgentDeepLink { - public static func buildURL(from payload: SharedContentPayload, instruction: String? = nil) -> URL? { - let message = self.buildMessage(from: payload, instruction: instruction) - guard !message.isEmpty else { return nil } - - var components = URLComponents() - components.scheme = "openclaw" - components.host = "agent" - components.queryItems = [ - URLQueryItem(name: "message", value: message), - URLQueryItem(name: "thinking", value: "low"), - ] - return components.url - } - - public static func buildMessage(from payload: SharedContentPayload, instruction: String? = nil) -> String { - let title = self.clean(payload.title) - let text = self.clean(payload.text) - let urlText = payload.url?.absoluteString.trimmingCharacters(in: .whitespacesAndNewlines) - let resolvedInstruction = self.clean(instruction) ?? ShareToAgentSettings.loadDefaultInstruction() - - var lines: [String] = ["Shared from iOS."] - if let title, !title.isEmpty { - lines.append("Title: \(title)") - } - if let urlText, !urlText.isEmpty { - lines.append("URL: \(urlText)") - } - if let text, !text.isEmpty { - lines.append("Text:\n\(text)") - } - lines.append(resolvedInstruction) - - let message = lines.joined(separator: "\n\n") - return self.limit(message, maxCharacters: 2400) - } - - private static func clean(_ value: String?) -> String? { - guard let value else { return nil } - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } - - private static func limit(_ value: String, maxCharacters: Int) -> String { - guard value.count > maxCharacters else { return value } - return String(value.prefix(maxCharacters)) - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareToAgentSettings.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareToAgentSettings.swift deleted file mode 100644 index 9034dcfe1b6..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareToAgentSettings.swift +++ /dev/null @@ -1,29 +0,0 @@ -import Foundation - -public enum ShareToAgentSettings { - private static let suiteName = "group.ai.openclaw.shared" - private static let defaultInstructionKey = "share.defaultInstruction" - private static let fallbackInstruction = "Please help me with this." - - private static var defaults: UserDefaults { - UserDefaults(suiteName: suiteName) ?? .standard - } - - public static func loadDefaultInstruction() -> String { - let raw = self.defaults.string(forKey: self.defaultInstructionKey)? - .trimmingCharacters(in: .whitespacesAndNewlines) - if let raw, !raw.isEmpty { - return raw - } - return self.fallbackInstruction - } - - public static func saveDefaultInstruction(_ value: String?) { - let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if trimmed.isEmpty { - self.defaults.removeObject(forKey: self.defaultInstructionKey) - return - } - self.defaults.set(trimmed, forKey: self.defaultInstructionKey) - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/StoragePaths.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/StoragePaths.swift deleted file mode 100644 index d7542295711..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/StoragePaths.swift +++ /dev/null @@ -1,37 +0,0 @@ -import Foundation - -public enum OpenClawNodeStorage { - public static func appSupportDir() throws -> URL { - let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first - guard let base else { - throw NSError(domain: "OpenClawNodeStorage", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Application Support directory unavailable", - ]) - } - return base.appendingPathComponent("OpenClaw", isDirectory: true) - } - - public static func canvasRoot(sessionKey: String) throws -> URL { - let root = try appSupportDir().appendingPathComponent("canvas", isDirectory: true) - let safe = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) - let session = safe.isEmpty ? "main" : safe - return root.appendingPathComponent(session, isDirectory: true) - } - - public static func cachesDir() throws -> URL { - let base = FileManager().urls(for: .cachesDirectory, in: .userDomainMask).first - guard let base else { - throw NSError(domain: "OpenClawNodeStorage", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "Caches directory unavailable", - ]) - } - return base.appendingPathComponent("OpenClaw", isDirectory: true) - } - - public static func canvasSnapshotsRoot(sessionKey: String) throws -> URL { - let root = try cachesDir().appendingPathComponent("canvas-snapshots", isDirectory: true) - let safe = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) - let session = safe.isEmpty ? "main" : safe - return root.appendingPathComponent(session, isDirectory: true) - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/SystemCommands.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/SystemCommands.swift deleted file mode 100644 index a2c8349058b..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/SystemCommands.swift +++ /dev/null @@ -1,88 +0,0 @@ -import Foundation - -public enum OpenClawSystemCommand: String, Codable, Sendable { - case run = "system.run" - case which = "system.which" - case notify = "system.notify" - case execApprovalsGet = "system.execApprovals.get" - case execApprovalsSet = "system.execApprovals.set" -} - -public enum OpenClawNotificationPriority: String, Codable, Sendable { - case passive - case active - case timeSensitive -} - -public enum OpenClawNotificationDelivery: String, Codable, Sendable { - case system - case overlay - case auto -} - -public struct OpenClawSystemRunParams: Codable, Sendable, Equatable { - public var command: [String] - public var rawCommand: String? - public var cwd: String? - public var env: [String: String]? - public var timeoutMs: Int? - public var needsScreenRecording: Bool? - public var agentId: String? - public var sessionKey: String? - public var approved: Bool? - public var approvalDecision: String? - - public init( - command: [String], - rawCommand: String? = nil, - cwd: String? = nil, - env: [String: String]? = nil, - timeoutMs: Int? = nil, - needsScreenRecording: Bool? = nil, - agentId: String? = nil, - sessionKey: String? = nil, - approved: Bool? = nil, - approvalDecision: String? = nil) - { - self.command = command - self.rawCommand = rawCommand - self.cwd = cwd - self.env = env - self.timeoutMs = timeoutMs - self.needsScreenRecording = needsScreenRecording - self.agentId = agentId - self.sessionKey = sessionKey - self.approved = approved - self.approvalDecision = approvalDecision - } -} - -public struct OpenClawSystemWhichParams: Codable, Sendable, Equatable { - public var bins: [String] - - public init(bins: [String]) { - self.bins = bins - } -} - -public struct OpenClawSystemNotifyParams: Codable, Sendable, Equatable { - public var title: String - public var body: String - public var sound: String? - public var priority: OpenClawNotificationPriority? - public var delivery: OpenClawNotificationDelivery? - - public init( - title: String, - body: String, - sound: String? = nil, - priority: OpenClawNotificationPriority? = nil, - delivery: OpenClawNotificationDelivery? = nil) - { - self.title = title - self.body = body - self.sound = sound - self.priority = priority - self.delivery = delivery - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkCommands.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkCommands.swift deleted file mode 100644 index 755fc97a984..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkCommands.swift +++ /dev/null @@ -1,28 +0,0 @@ -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 - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkDirective.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkDirective.swift deleted file mode 100644 index 6c460dc0267..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkDirective.swift +++ /dev/null @@ -1,201 +0,0 @@ -import Foundation - -public struct TalkDirective: Equatable, Sendable { - public var voiceId: String? - public var modelId: String? - public var speed: Double? - public var rateWPM: Int? - public var stability: Double? - public var similarity: Double? - public var style: Double? - public var speakerBoost: Bool? - public var seed: Int? - public var normalize: String? - public var language: String? - public var outputFormat: String? - public var latencyTier: Int? - public var once: Bool? - - public init( - voiceId: String? = nil, - modelId: String? = nil, - speed: Double? = nil, - rateWPM: Int? = nil, - stability: Double? = nil, - similarity: Double? = nil, - style: Double? = nil, - speakerBoost: Bool? = nil, - seed: Int? = nil, - normalize: String? = nil, - language: String? = nil, - outputFormat: String? = nil, - latencyTier: Int? = nil, - once: Bool? = nil) - { - self.voiceId = voiceId - self.modelId = modelId - self.speed = speed - self.rateWPM = rateWPM - self.stability = stability - self.similarity = similarity - self.style = style - self.speakerBoost = speakerBoost - self.seed = seed - self.normalize = normalize - self.language = language - self.outputFormat = outputFormat - self.latencyTier = latencyTier - self.once = once - } -} - -public struct TalkDirectiveParseResult: Equatable, Sendable { - public let directive: TalkDirective? - public let stripped: String - public let unknownKeys: [String] - - public init(directive: TalkDirective?, stripped: String, unknownKeys: [String]) { - self.directive = directive - self.stripped = stripped - self.unknownKeys = unknownKeys - } -} - -public enum TalkDirectiveParser { - public static func parse(_ text: String) -> TalkDirectiveParseResult { - let normalized = text.replacingOccurrences(of: "\r\n", with: "\n") - var lines = normalized.split(separator: "\n", omittingEmptySubsequences: false) - guard !lines.isEmpty else { return TalkDirectiveParseResult(directive: nil, stripped: text, unknownKeys: []) } - - guard let firstNonEmptyIndex = - lines.firstIndex(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) - else { - return TalkDirectiveParseResult(directive: nil, stripped: text, unknownKeys: []) - } - - var firstNonEmpty = firstNonEmptyIndex - if firstNonEmpty > 0 { - lines.removeSubrange(0.. String? { - for key in keys { - if let value = dict[key] as? String { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { return trimmed } - } - } - return nil - } - - private static func doubleValue(_ dict: [String: Any], keys: [String]) -> Double? { - for key in keys { - if let value = dict[key] as? Double { return value } - if let value = dict[key] as? Int { return Double(value) } - if let value = dict[key] as? String, let parsed = Double(value) { return parsed } - } - return nil - } - - private static func intValue(_ dict: [String: Any], keys: [String]) -> Int? { - for key in keys { - if let value = dict[key] as? Int { return value } - if let value = dict[key] as? Double { return Int(value) } - if let value = dict[key] as? String, let parsed = Int(value) { return parsed } - } - return nil - } - - private static func boolValue(_ dict: [String: Any], keys: [String]) -> Bool? { - for key in keys { - if let value = dict[key] as? Bool { return value } - if let value = dict[key] as? String { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if ["true", "yes", "1"].contains(trimmed) { return true } - if ["false", "no", "0"].contains(trimmed) { return false } - } - } - return nil - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkHistoryTimestamp.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkHistoryTimestamp.swift deleted file mode 100644 index 75f14ef85b4..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkHistoryTimestamp.swift +++ /dev/null @@ -1,12 +0,0 @@ -public enum TalkHistoryTimestamp: Sendable { - /// Gateway history timestamps have historically been emitted as either seconds (Double, epoch seconds) - /// or milliseconds (Double, epoch ms). This helper accepts either. - public static func isAfter(_ timestamp: Double, sinceSeconds: Double) -> Bool { - let sinceMs = sinceSeconds * 1000 - // ~2286-11-20 in epoch seconds. Anything bigger is almost certainly epoch milliseconds. - if timestamp > 10_000_000_000 { - return timestamp >= sinceMs - 500 - } - return timestamp >= sinceSeconds - 0.5 - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkPromptBuilder.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkPromptBuilder.swift deleted file mode 100644 index 2a2e39d68cf..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkPromptBuilder.swift +++ /dev/null @@ -1,26 +0,0 @@ -public enum TalkPromptBuilder: Sendable { - public static func build( - transcript: String, - interruptedAtSeconds: Double?, - includeVoiceDirectiveHint: Bool = true - ) -> String { - var lines: [String] = [ - "Talk Mode active. Reply in a concise, spoken tone.", - ] - - if includeVoiceDirectiveHint { - lines.append( - "You may optionally prefix the response with JSON (first line) to set ElevenLabs voice (id or alias), e.g. {\"voice\":\"\",\"once\":true}." - ) - } - - if let interruptedAtSeconds { - let formatted = String(format: "%.1f", interruptedAtSeconds) - lines.append("Assistant speech interrupted at \(formatted)s.") - } - - lines.append("") - lines.append(transcript) - return lines.joined(separator: "\n") - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkSystemSpeechSynthesizer.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkSystemSpeechSynthesizer.swift deleted file mode 100644 index 4cfc536da87..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkSystemSpeechSynthesizer.swift +++ /dev/null @@ -1,116 +0,0 @@ -import AVFoundation -import Foundation - -@MainActor -public final class TalkSystemSpeechSynthesizer: NSObject { - public enum SpeakError: Error { - case canceled - } - - public static let shared = TalkSystemSpeechSynthesizer() - - private let synth = AVSpeechSynthesizer() - private var speakContinuation: CheckedContinuation? - private var currentUtterance: AVSpeechUtterance? - private var currentToken = UUID() - private var watchdog: Task? - - public var isSpeaking: Bool { self.synth.isSpeaking } - - override private init() { - super.init() - self.synth.delegate = self - } - - public func stop() { - self.currentToken = UUID() - self.watchdog?.cancel() - self.watchdog = nil - self.synth.stopSpeaking(at: .immediate) - self.finishCurrent(with: SpeakError.canceled) - } - - public func speak(text: String, language: String? = nil) async throws { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - - self.stop() - let token = UUID() - self.currentToken = token - - let utterance = AVSpeechUtterance(string: trimmed) - if let language, let voice = AVSpeechSynthesisVoice(language: language) { - utterance.voice = voice - } - self.currentUtterance = utterance - - let estimatedSeconds = max(3.0, min(180.0, Double(trimmed.count) * 0.08)) - self.watchdog?.cancel() - self.watchdog = Task { @MainActor [weak self] in - guard let self else { return } - try? await Task.sleep(nanoseconds: UInt64(estimatedSeconds * 1_000_000_000)) - if Task.isCancelled { return } - guard self.currentToken == token else { return } - if self.synth.isSpeaking { - self.synth.stopSpeaking(at: .immediate) - } - self.finishCurrent( - with: NSError(domain: "TalkSystemSpeechSynthesizer", code: 408, userInfo: [ - NSLocalizedDescriptionKey: "system TTS timed out after \(estimatedSeconds)s", - ])) - } - - try await withTaskCancellationHandler(operation: { - try await withCheckedThrowingContinuation { cont in - self.speakContinuation = cont - self.synth.speak(utterance) - } - }, onCancel: { - Task { @MainActor in - self.stop() - } - }) - - if self.currentToken != token { - throw SpeakError.canceled - } - } - - private func handleFinish(error: Error?) { - guard self.currentUtterance != nil else { return } - self.watchdog?.cancel() - self.watchdog = nil - self.finishCurrent(with: error) - } - - private func finishCurrent(with error: Error?) { - self.currentUtterance = nil - let cont = self.speakContinuation - self.speakContinuation = nil - if let error { - cont?.resume(throwing: error) - } else { - cont?.resume(returning: ()) - } - } -} - -extension TalkSystemSpeechSynthesizer: AVSpeechSynthesizerDelegate { - public nonisolated func speechSynthesizer( - _ synthesizer: AVSpeechSynthesizer, - didFinish utterance: AVSpeechUtterance) - { - Task { @MainActor in - self.handleFinish(error: nil) - } - } - - public nonisolated func speechSynthesizer( - _ synthesizer: AVSpeechSynthesizer, - didCancel utterance: AVSpeechUtterance) - { - Task { @MainActor in - self.handleFinish(error: SpeakError.canceled) - } - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/ToolDisplay.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/ToolDisplay.swift deleted file mode 100644 index d52e24ca856..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/ToolDisplay.swift +++ /dev/null @@ -1,265 +0,0 @@ -import Foundation - -public struct ToolDisplaySummary: Sendable, Equatable { - public let name: String - public let emoji: String - public let title: String - public let label: String - public let verb: String? - public let detail: String? - - public var detailLine: String? { - var parts: [String] = [] - if let verb, !verb.isEmpty { parts.append(verb) } - if let detail, !detail.isEmpty { parts.append(detail) } - return parts.isEmpty ? nil : parts.joined(separator: " · ") - } - - public var summaryLine: String { - if let detailLine { - return "\(self.emoji) \(self.label): \(detailLine)" - } - return "\(self.emoji) \(self.label)" - } -} - -public enum ToolDisplayRegistry { - private struct ToolDisplayActionSpec: Decodable { - let label: String? - let detailKeys: [String]? - } - - private struct ToolDisplaySpec: Decodable { - let emoji: String? - let title: String? - let label: String? - let detailKeys: [String]? - let actions: [String: ToolDisplayActionSpec]? - } - - private struct ToolDisplayConfig: Decodable { - let version: Int? - let fallback: ToolDisplaySpec? - let tools: [String: ToolDisplaySpec]? - } - - private static let config: ToolDisplayConfig = loadConfig() - - public static func resolve(name: String?, args: AnyCodable?, meta: String? = nil) -> ToolDisplaySummary { - let trimmedName = name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "tool" - let key = trimmedName.lowercased() - let spec = self.config.tools?[key] - let fallback = self.config.fallback - - let emoji = spec?.emoji ?? fallback?.emoji ?? "🧩" - let title = spec?.title ?? self.titleFromName(trimmedName) - let label = spec?.label ?? trimmedName - - let actionRaw = self.valueForKeyPath(args, path: "action") as? String - let action = actionRaw?.trimmingCharacters(in: .whitespacesAndNewlines) - let actionSpec = action.flatMap { spec?.actions?[$0] } - let verb = self.normalizeVerb(actionSpec?.label ?? action) - - var detail: String? - if key == "read" { - detail = self.readDetail(args) - } else if key == "write" || key == "edit" || key == "attach" { - detail = self.pathDetail(args) - } - - let detailKeys = actionSpec?.detailKeys ?? spec?.detailKeys ?? fallback?.detailKeys ?? [] - if detail == nil { - detail = self.firstValue(args, keys: detailKeys) - } - - if detail == nil { - detail = meta - } - - if let detailValue = detail { - detail = self.shortenHomeInString(detailValue) - } - - return ToolDisplaySummary( - name: trimmedName, - emoji: emoji, - title: title, - label: label, - verb: verb, - detail: detail) - } - - private static func loadConfig() -> ToolDisplayConfig { - guard let url = OpenClawKitResources.bundle.url(forResource: "tool-display", withExtension: "json") else { - return self.defaultConfig() - } - do { - let data = try Data(contentsOf: url) - return try JSONDecoder().decode(ToolDisplayConfig.self, from: data) - } catch { - return self.defaultConfig() - } - } - - private static func defaultConfig() -> ToolDisplayConfig { - ToolDisplayConfig( - version: 1, - fallback: ToolDisplaySpec( - emoji: "🧩", - title: nil, - label: nil, - detailKeys: [ - "command", - "path", - "url", - "targetUrl", - "targetId", - "ref", - "element", - "node", - "nodeId", - "id", - "requestId", - "to", - "channelId", - "guildId", - "userId", - "name", - "query", - "pattern", - "messageId", - ], - actions: nil), - tools: [ - "bash": ToolDisplaySpec( - emoji: "🛠️", - title: "Bash", - label: nil, - detailKeys: ["command"], - actions: nil), - "read": ToolDisplaySpec( - emoji: "📖", - title: "Read", - label: nil, - detailKeys: ["path"], - actions: nil), - "write": ToolDisplaySpec( - emoji: "✍️", - title: "Write", - label: nil, - detailKeys: ["path"], - actions: nil), - "edit": ToolDisplaySpec( - emoji: "📝", - title: "Edit", - label: nil, - detailKeys: ["path"], - actions: nil), - "attach": ToolDisplaySpec( - emoji: "📎", - title: "Attach", - label: nil, - detailKeys: ["path", "url", "fileName"], - actions: nil), - "process": ToolDisplaySpec( - emoji: "🧰", - title: "Process", - label: nil, - detailKeys: ["sessionId"], - actions: nil), - ]) - } - - private static func titleFromName(_ name: String) -> String { - let cleaned = name.replacingOccurrences(of: "_", with: " ").trimmingCharacters(in: .whitespaces) - guard !cleaned.isEmpty else { return "Tool" } - return cleaned - .split(separator: " ") - .map { part in - let upper = part.uppercased() - if part.count <= 2, part == upper { return String(part) } - return String(upper.prefix(1)) + String(part.lowercased().dropFirst()) - } - .joined(separator: " ") - } - - private static func normalizeVerb(_ value: String?) -> String? { - let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !trimmed.isEmpty else { return nil } - return trimmed.replacingOccurrences(of: "_", with: " ") - } - - private static func readDetail(_ args: AnyCodable?) -> String? { - guard let path = valueForKeyPath(args, path: "path") as? String else { return nil } - let offsetAny = self.valueForKeyPath(args, path: "offset") - let limitAny = self.valueForKeyPath(args, path: "limit") - let offset = (offsetAny as? Double) ?? (offsetAny as? Int).map(Double.init) - let limit = (limitAny as? Double) ?? (limitAny as? Int).map(Double.init) - if let offset, let limit { - let end = offset + limit - return "\(path):\(Int(offset))-\(Int(end))" - } - return path - } - - private static func pathDetail(_ args: AnyCodable?) -> String? { - self.valueForKeyPath(args, path: "path") as? String - } - - private static func firstValue(_ args: AnyCodable?, keys: [String]) -> String? { - for key in keys { - if let value = valueForKeyPath(args, path: key), - let rendered = renderValue(value) - { - return rendered - } - } - return nil - } - - private static func renderValue(_ value: Any) -> String? { - if let str = value as? String { - let trimmed = str.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - let first = trimmed.split(whereSeparator: \.isNewline).first.map(String.init) ?? trimmed - if first.count > 160 { return String(first.prefix(157)) + "…" } - return first - } - if let num = value as? Int { return String(num) } - if let num = value as? Double { return String(num) } - if let bool = value as? Bool { return bool ? "true" : "false" } - if let array = value as? [Any] { - let items = array.compactMap { self.renderValue($0) } - guard !items.isEmpty else { return nil } - let preview = items.prefix(3).joined(separator: ", ") - return items.count > 3 ? "\(preview)…" : preview - } - if let dict = value as? [String: Any] { - if let label = dict["name"].flatMap({ renderValue($0) }) { return label } - if let label = dict["id"].flatMap({ renderValue($0) }) { return label } - } - return nil - } - - private static func valueForKeyPath(_ args: AnyCodable?, path: String) -> Any? { - guard let args else { return nil } - let parts = path.split(separator: ".").map(String.init) - var current: Any? = args.value - for part in parts { - if let dict = current as? [String: AnyCodable] { - current = dict[part]?.value - } else if let dict = current as? [String: Any] { - current = dict[part] - } else { - return nil - } - } - return current - } - - private static func shortenHomeInString(_ value: String) -> String { - let home = NSHomeDirectory() - guard !home.isEmpty else { return value } - return value.replacingOccurrences(of: home, with: "~") - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/WatchCommands.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/WatchCommands.swift deleted file mode 100644 index 0bd6990710c..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/WatchCommands.swift +++ /dev/null @@ -1,95 +0,0 @@ -import Foundation - -public enum OpenClawWatchCommand: String, Codable, Sendable { - case status = "watch.status" - case notify = "watch.notify" -} - -public enum OpenClawWatchRisk: String, Codable, Sendable, Equatable { - case low - case medium - case high -} - -public struct OpenClawWatchAction: Codable, Sendable, Equatable { - public var id: String - public var label: String - public var style: String? - - public init(id: String, label: String, style: String? = nil) { - self.id = id - self.label = label - self.style = style - } -} - -public struct OpenClawWatchStatusPayload: Codable, Sendable, Equatable { - public var supported: Bool - public var paired: Bool - public var appInstalled: Bool - public var reachable: Bool - public var activationState: String - - public init( - supported: Bool, - paired: Bool, - appInstalled: Bool, - reachable: Bool, - activationState: String) - { - self.supported = supported - self.paired = paired - self.appInstalled = appInstalled - self.reachable = reachable - self.activationState = activationState - } -} - -public struct OpenClawWatchNotifyParams: Codable, Sendable, Equatable { - public var title: String - public var body: String - public var priority: OpenClawNotificationPriority? - public var promptId: String? - public var sessionKey: String? - public var kind: String? - public var details: String? - public var expiresAtMs: Int? - public var risk: OpenClawWatchRisk? - public var actions: [OpenClawWatchAction]? - - public init( - title: String, - body: String, - priority: OpenClawNotificationPriority? = nil, - promptId: String? = nil, - sessionKey: String? = nil, - kind: String? = nil, - details: String? = nil, - expiresAtMs: Int? = nil, - risk: OpenClawWatchRisk? = nil, - actions: [OpenClawWatchAction]? = nil) - { - self.title = title - self.body = body - self.priority = priority - self.promptId = promptId - self.sessionKey = sessionKey - self.kind = kind - self.details = details - self.expiresAtMs = expiresAtMs - self.risk = risk - self.actions = actions - } -} - -public struct OpenClawWatchNotifyPayload: Codable, Sendable, Equatable { - public var deliveredImmediately: Bool - public var queuedForDelivery: Bool - public var transport: String - - public init(deliveredImmediately: Bool, queuedForDelivery: Bool, transport: String) { - self.deliveredImmediately = deliveredImmediately - self.queuedForDelivery = queuedForDelivery - self.transport = transport - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/AnyCodable.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/AnyCodable.swift deleted file mode 100644 index 4315bb073ef..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/AnyCodable.swift +++ /dev/null @@ -1,104 +0,0 @@ -import Foundation - -/// Lightweight `Codable` wrapper that round-trips heterogeneous JSON payloads. -/// -/// Marked `@unchecked Sendable` because it can hold reference types. -public struct AnyCodable: Codable, @unchecked Sendable, Hashable { - public let value: Any - - public init(_ value: Any) { self.value = Self.normalize(value) } - - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if let boolVal = try? container.decode(Bool.self) { self.value = boolVal; return } - if let intVal = try? container.decode(Int.self) { self.value = intVal; return } - if let doubleVal = try? container.decode(Double.self) { self.value = doubleVal; return } - if let stringVal = try? container.decode(String.self) { self.value = stringVal; return } - if container.decodeNil() { self.value = NSNull(); return } - if let dict = try? container.decode([String: AnyCodable].self) { self.value = dict; return } - if let array = try? container.decode([AnyCodable].self) { self.value = array; return } - throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported type") - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch self.value { - case let boolVal as Bool: try container.encode(boolVal) - case let intVal as Int: try container.encode(intVal) - case let doubleVal as Double: try container.encode(doubleVal) - case let stringVal as String: try container.encode(stringVal) - case let number as NSNumber where CFGetTypeID(number) == CFBooleanGetTypeID(): - try container.encode(number.boolValue) - case is NSNull: try container.encodeNil() - case let dict as [String: AnyCodable]: try container.encode(dict) - case let array as [AnyCodable]: try container.encode(array) - case let dict as [String: Any]: - try container.encode(dict.mapValues { AnyCodable($0) }) - case let array as [Any]: - try container.encode(array.map { AnyCodable($0) }) - case let dict as NSDictionary: - var converted: [String: AnyCodable] = [:] - for (k, v) in dict { - guard let key = k as? String else { continue } - converted[key] = AnyCodable(v) - } - try container.encode(converted) - case let array as NSArray: - try container.encode(array.map { AnyCodable($0) }) - default: - let context = EncodingError.Context( - codingPath: encoder.codingPath, - debugDescription: "Unsupported type") - throw EncodingError.invalidValue(self.value, context) - } - } - - private static func normalize(_ value: Any) -> Any { - if let number = value as? NSNumber, CFGetTypeID(number) == CFBooleanGetTypeID() { - return number.boolValue - } - return value - } - - public static func == (lhs: AnyCodable, rhs: AnyCodable) -> Bool { - switch (lhs.value, rhs.value) { - case let (l as Bool, r as Bool): l == r - case let (l as Int, r as Int): l == r - case let (l as Double, r as Double): l == r - case let (l as String, r as String): l == r - case (_ as NSNull, _ as NSNull): true - case let (l as [String: AnyCodable], r as [String: AnyCodable]): l == r - case let (l as [AnyCodable], r as [AnyCodable]): l == r - default: - false - } - } - - public func hash(into hasher: inout Hasher) { - switch self.value { - case let v as Bool: - hasher.combine(2); hasher.combine(v) - case let v as Int: - hasher.combine(0); hasher.combine(v) - case let v as Double: - hasher.combine(1); hasher.combine(v) - case let v as String: - hasher.combine(3); hasher.combine(v) - case _ as NSNull: - hasher.combine(4) - case let v as [String: AnyCodable]: - hasher.combine(5) - for (k, val) in v.sorted(by: { $0.key < $1.key }) { - hasher.combine(k) - hasher.combine(val) - } - case let v as [AnyCodable]: - hasher.combine(6) - for item in v { - hasher.combine(item) - } - default: - hasher.combine(999) - } - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift deleted file mode 100644 index 2f2dd7f6090..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ /dev/null @@ -1,3053 +0,0 @@ -// Generated by scripts/protocol-gen-swift.ts — do not edit by hand -// swiftlint:disable file_length -import Foundation - -public let GATEWAY_PROTOCOL_VERSION = 3 - -public enum ErrorCode: String, Codable, Sendable { - case notLinked = "NOT_LINKED" - case notPaired = "NOT_PAIRED" - case agentTimeout = "AGENT_TIMEOUT" - case invalidRequest = "INVALID_REQUEST" - case unavailable = "UNAVAILABLE" -} - -public struct ConnectParams: Codable, Sendable { - public let minprotocol: Int - public let maxprotocol: Int - public let client: [String: AnyCodable] - public let caps: [String]? - public let commands: [String]? - public let permissions: [String: AnyCodable]? - public let pathenv: String? - public let role: String? - public let scopes: [String]? - public let device: [String: AnyCodable]? - public let auth: [String: AnyCodable]? - public let locale: String? - public let useragent: String? - - public init( - minprotocol: Int, - maxprotocol: Int, - client: [String: AnyCodable], - caps: [String]?, - commands: [String]?, - permissions: [String: AnyCodable]?, - pathenv: String?, - role: String?, - scopes: [String]?, - device: [String: AnyCodable]?, - auth: [String: AnyCodable]?, - locale: String?, - useragent: String?) - { - self.minprotocol = minprotocol - self.maxprotocol = maxprotocol - self.client = client - self.caps = caps - self.commands = commands - self.permissions = permissions - self.pathenv = pathenv - self.role = role - self.scopes = scopes - self.device = device - self.auth = auth - self.locale = locale - self.useragent = useragent - } - - private enum CodingKeys: String, CodingKey { - case minprotocol = "minProtocol" - case maxprotocol = "maxProtocol" - case client - case caps - case commands - case permissions - case pathenv = "pathEnv" - case role - case scopes - case device - case auth - case locale - case useragent = "userAgent" - } -} - -public struct HelloOk: Codable, Sendable { - public let type: String - public let _protocol: Int - public let server: [String: AnyCodable] - public let features: [String: AnyCodable] - public let snapshot: Snapshot - public let canvashosturl: String? - public let auth: [String: AnyCodable]? - public let policy: [String: AnyCodable] - - public init( - type: String, - _protocol: Int, - server: [String: AnyCodable], - features: [String: AnyCodable], - snapshot: Snapshot, - canvashosturl: String?, - auth: [String: AnyCodable]?, - policy: [String: AnyCodable]) - { - self.type = type - self._protocol = _protocol - self.server = server - self.features = features - self.snapshot = snapshot - self.canvashosturl = canvashosturl - self.auth = auth - self.policy = policy - } - - private enum CodingKeys: String, CodingKey { - case type - case _protocol = "protocol" - case server - case features - case snapshot - case canvashosturl = "canvasHostUrl" - case auth - case policy - } -} - -public struct RequestFrame: Codable, Sendable { - public let type: String - public let id: String - public let method: String - public let params: AnyCodable? - - public init( - type: String, - id: String, - method: String, - params: AnyCodable?) - { - self.type = type - self.id = id - self.method = method - self.params = params - } - - private enum CodingKeys: String, CodingKey { - case type - case id - case method - case params - } -} - -public struct ResponseFrame: Codable, Sendable { - public let type: String - public let id: String - public let ok: Bool - public let payload: AnyCodable? - public let error: [String: AnyCodable]? - - public init( - type: String, - id: String, - ok: Bool, - payload: AnyCodable?, - error: [String: AnyCodable]?) - { - self.type = type - self.id = id - self.ok = ok - self.payload = payload - self.error = error - } - - private enum CodingKeys: String, CodingKey { - case type - case id - case ok - case payload - case error - } -} - -public struct EventFrame: Codable, Sendable { - public let type: String - public let event: String - public let payload: AnyCodable? - public let seq: Int? - public let stateversion: [String: AnyCodable]? - - public init( - type: String, - event: String, - payload: AnyCodable?, - seq: Int?, - stateversion: [String: AnyCodable]?) - { - self.type = type - self.event = event - self.payload = payload - self.seq = seq - self.stateversion = stateversion - } - - private enum CodingKeys: String, CodingKey { - case type - case event - case payload - case seq - case stateversion = "stateVersion" - } -} - -public struct PresenceEntry: Codable, Sendable { - public let host: String? - public let ip: String? - public let version: String? - public let platform: String? - public let devicefamily: String? - public let modelidentifier: String? - public let mode: String? - public let lastinputseconds: Int? - public let reason: String? - public let tags: [String]? - public let text: String? - public let ts: Int - public let deviceid: String? - public let roles: [String]? - public let scopes: [String]? - public let instanceid: String? - - public init( - host: String?, - ip: String?, - version: String?, - platform: String?, - devicefamily: String?, - modelidentifier: String?, - mode: String?, - lastinputseconds: Int?, - reason: String?, - tags: [String]?, - text: String?, - ts: Int, - deviceid: String?, - roles: [String]?, - scopes: [String]?, - instanceid: String?) - { - self.host = host - self.ip = ip - self.version = version - self.platform = platform - self.devicefamily = devicefamily - self.modelidentifier = modelidentifier - self.mode = mode - self.lastinputseconds = lastinputseconds - self.reason = reason - self.tags = tags - self.text = text - self.ts = ts - self.deviceid = deviceid - self.roles = roles - self.scopes = scopes - self.instanceid = instanceid - } - - private enum CodingKeys: String, CodingKey { - case host - case ip - case version - case platform - case devicefamily = "deviceFamily" - case modelidentifier = "modelIdentifier" - case mode - case lastinputseconds = "lastInputSeconds" - case reason - case tags - case text - case ts - case deviceid = "deviceId" - case roles - case scopes - case instanceid = "instanceId" - } -} - -public struct StateVersion: Codable, Sendable { - public let presence: Int - public let health: Int - - public init( - presence: Int, - health: Int) - { - self.presence = presence - self.health = health - } - - private enum CodingKeys: String, CodingKey { - case presence - case health - } -} - -public struct Snapshot: Codable, Sendable { - public let presence: [PresenceEntry] - public let health: AnyCodable - public let stateversion: StateVersion - public let uptimems: Int - public let configpath: String? - public let statedir: String? - public let sessiondefaults: [String: AnyCodable]? - public let authmode: AnyCodable? - public let updateavailable: [String: AnyCodable]? - - public init( - presence: [PresenceEntry], - health: AnyCodable, - stateversion: StateVersion, - uptimems: Int, - configpath: String?, - statedir: String?, - sessiondefaults: [String: AnyCodable]?, - authmode: AnyCodable?, - updateavailable: [String: AnyCodable]?) - { - self.presence = presence - self.health = health - self.stateversion = stateversion - self.uptimems = uptimems - self.configpath = configpath - self.statedir = statedir - self.sessiondefaults = sessiondefaults - self.authmode = authmode - self.updateavailable = updateavailable - } - - private enum CodingKeys: String, CodingKey { - case presence - case health - case stateversion = "stateVersion" - case uptimems = "uptimeMs" - case configpath = "configPath" - case statedir = "stateDir" - case sessiondefaults = "sessionDefaults" - case authmode = "authMode" - case updateavailable = "updateAvailable" - } -} - -public struct ErrorShape: Codable, Sendable { - public let code: String - public let message: String - public let details: AnyCodable? - public let retryable: Bool? - public let retryafterms: Int? - - public init( - code: String, - message: String, - details: AnyCodable?, - retryable: Bool?, - retryafterms: Int?) - { - self.code = code - self.message = message - self.details = details - self.retryable = retryable - self.retryafterms = retryafterms - } - - private enum CodingKeys: String, CodingKey { - case code - case message - case details - case retryable - case retryafterms = "retryAfterMs" - } -} - -public struct AgentEvent: Codable, Sendable { - public let runid: String - public let seq: Int - public let stream: String - public let ts: Int - public let data: [String: AnyCodable] - - public init( - runid: String, - seq: Int, - stream: String, - ts: Int, - data: [String: AnyCodable]) - { - self.runid = runid - self.seq = seq - self.stream = stream - self.ts = ts - self.data = data - } - - private enum CodingKeys: String, CodingKey { - case runid = "runId" - case seq - case stream - case ts - case data - } -} - -public struct SendParams: Codable, Sendable { - public let to: String - public let message: String? - public let mediaurl: String? - public let mediaurls: [String]? - public let gifplayback: Bool? - public let channel: String? - public let accountid: String? - public let threadid: String? - public let sessionkey: String? - public let idempotencykey: String - - public init( - to: String, - message: String?, - mediaurl: String?, - mediaurls: [String]?, - gifplayback: Bool?, - channel: String?, - accountid: String?, - threadid: String?, - sessionkey: String?, - idempotencykey: String) - { - self.to = to - self.message = message - self.mediaurl = mediaurl - self.mediaurls = mediaurls - self.gifplayback = gifplayback - self.channel = channel - self.accountid = accountid - self.threadid = threadid - self.sessionkey = sessionkey - self.idempotencykey = idempotencykey - } - - private enum CodingKeys: String, CodingKey { - case to - case message - case mediaurl = "mediaUrl" - case mediaurls = "mediaUrls" - case gifplayback = "gifPlayback" - case channel - case accountid = "accountId" - case threadid = "threadId" - case sessionkey = "sessionKey" - case idempotencykey = "idempotencyKey" - } -} - -public struct PollParams: Codable, Sendable { - public let to: String - public let question: String - public let options: [String] - public let maxselections: Int? - public let durationseconds: Int? - public let durationhours: Int? - public let silent: Bool? - public let isanonymous: Bool? - public let threadid: String? - public let channel: String? - public let accountid: String? - public let idempotencykey: String - - public init( - to: String, - question: String, - options: [String], - maxselections: Int?, - durationseconds: Int?, - durationhours: Int?, - silent: Bool?, - isanonymous: Bool?, - threadid: String?, - channel: String?, - accountid: String?, - idempotencykey: String) - { - self.to = to - self.question = question - self.options = options - self.maxselections = maxselections - self.durationseconds = durationseconds - self.durationhours = durationhours - self.silent = silent - self.isanonymous = isanonymous - self.threadid = threadid - self.channel = channel - self.accountid = accountid - self.idempotencykey = idempotencykey - } - - private enum CodingKeys: String, CodingKey { - case to - case question - case options - case maxselections = "maxSelections" - case durationseconds = "durationSeconds" - case durationhours = "durationHours" - case silent - case isanonymous = "isAnonymous" - case threadid = "threadId" - case channel - case accountid = "accountId" - case idempotencykey = "idempotencyKey" - } -} - -public struct AgentParams: Codable, Sendable { - public let message: String - public let agentid: String? - public let to: String? - public let replyto: String? - public let sessionid: String? - public let sessionkey: String? - public let thinking: String? - public let deliver: Bool? - public let attachments: [AnyCodable]? - public let channel: String? - public let replychannel: String? - public let accountid: String? - public let replyaccountid: String? - public let threadid: String? - public let groupid: String? - public let groupchannel: String? - public let groupspace: String? - public let timeout: Int? - public let lane: String? - public let extrasystemprompt: String? - public let inputprovenance: [String: AnyCodable]? - public let idempotencykey: String - public let label: String? - public let spawnedby: String? - - public init( - message: String, - agentid: String?, - to: String?, - replyto: String?, - sessionid: String?, - sessionkey: String?, - thinking: String?, - deliver: Bool?, - attachments: [AnyCodable]?, - channel: String?, - replychannel: String?, - accountid: String?, - replyaccountid: String?, - threadid: String?, - groupid: String?, - groupchannel: String?, - groupspace: String?, - timeout: Int?, - lane: String?, - extrasystemprompt: String?, - inputprovenance: [String: AnyCodable]?, - idempotencykey: String, - label: String?, - spawnedby: String?) - { - self.message = message - self.agentid = agentid - self.to = to - self.replyto = replyto - self.sessionid = sessionid - self.sessionkey = sessionkey - self.thinking = thinking - self.deliver = deliver - self.attachments = attachments - self.channel = channel - self.replychannel = replychannel - self.accountid = accountid - self.replyaccountid = replyaccountid - self.threadid = threadid - self.groupid = groupid - self.groupchannel = groupchannel - self.groupspace = groupspace - self.timeout = timeout - self.lane = lane - self.extrasystemprompt = extrasystemprompt - self.inputprovenance = inputprovenance - self.idempotencykey = idempotencykey - self.label = label - self.spawnedby = spawnedby - } - - private enum CodingKeys: String, CodingKey { - case message - case agentid = "agentId" - case to - case replyto = "replyTo" - case sessionid = "sessionId" - case sessionkey = "sessionKey" - case thinking - case deliver - case attachments - case channel - case replychannel = "replyChannel" - case accountid = "accountId" - case replyaccountid = "replyAccountId" - case threadid = "threadId" - case groupid = "groupId" - case groupchannel = "groupChannel" - case groupspace = "groupSpace" - case timeout - case lane - case extrasystemprompt = "extraSystemPrompt" - case inputprovenance = "inputProvenance" - case idempotencykey = "idempotencyKey" - case label - case spawnedby = "spawnedBy" - } -} - -public struct AgentIdentityParams: Codable, Sendable { - public let agentid: String? - public let sessionkey: String? - - public init( - agentid: String?, - sessionkey: String?) - { - self.agentid = agentid - self.sessionkey = sessionkey - } - - private enum CodingKeys: String, CodingKey { - case agentid = "agentId" - case sessionkey = "sessionKey" - } -} - -public struct AgentIdentityResult: Codable, Sendable { - public let agentid: String - public let name: String? - public let avatar: String? - public let emoji: String? - - public init( - agentid: String, - name: String?, - avatar: String?, - emoji: String?) - { - self.agentid = agentid - self.name = name - self.avatar = avatar - self.emoji = emoji - } - - private enum CodingKeys: String, CodingKey { - case agentid = "agentId" - case name - case avatar - case emoji - } -} - -public struct AgentWaitParams: Codable, Sendable { - public let runid: String - public let timeoutms: Int? - - public init( - runid: String, - timeoutms: Int?) - { - self.runid = runid - self.timeoutms = timeoutms - } - - private enum CodingKeys: String, CodingKey { - case runid = "runId" - case timeoutms = "timeoutMs" - } -} - -public struct WakeParams: Codable, Sendable { - public let mode: AnyCodable - public let text: String - - public init( - mode: AnyCodable, - text: String) - { - self.mode = mode - self.text = text - } - - private enum CodingKeys: String, CodingKey { - case mode - case text - } -} - -public struct NodePairRequestParams: Codable, Sendable { - public let nodeid: String - public let displayname: String? - public let platform: String? - public let version: String? - public let coreversion: String? - public let uiversion: String? - public let devicefamily: String? - public let modelidentifier: String? - public let caps: [String]? - public let commands: [String]? - public let remoteip: String? - public let silent: Bool? - - public init( - nodeid: String, - displayname: String?, - platform: String?, - version: String?, - coreversion: String?, - uiversion: String?, - devicefamily: String?, - modelidentifier: String?, - caps: [String]?, - commands: [String]?, - remoteip: String?, - silent: Bool?) - { - self.nodeid = nodeid - self.displayname = displayname - self.platform = platform - self.version = version - self.coreversion = coreversion - self.uiversion = uiversion - self.devicefamily = devicefamily - self.modelidentifier = modelidentifier - self.caps = caps - self.commands = commands - self.remoteip = remoteip - self.silent = silent - } - - private enum CodingKeys: String, CodingKey { - case nodeid = "nodeId" - case displayname = "displayName" - case platform - case version - case coreversion = "coreVersion" - case uiversion = "uiVersion" - case devicefamily = "deviceFamily" - case modelidentifier = "modelIdentifier" - case caps - case commands - case remoteip = "remoteIp" - case silent - } -} - -public struct NodePairListParams: Codable, Sendable {} - -public struct NodePairApproveParams: Codable, Sendable { - public let requestid: String - - public init( - requestid: String) - { - self.requestid = requestid - } - - private enum CodingKeys: String, CodingKey { - case requestid = "requestId" - } -} - -public struct NodePairRejectParams: Codable, Sendable { - public let requestid: String - - public init( - requestid: String) - { - self.requestid = requestid - } - - private enum CodingKeys: String, CodingKey { - case requestid = "requestId" - } -} - -public struct NodePairVerifyParams: Codable, Sendable { - public let nodeid: String - public let token: String - - public init( - nodeid: String, - token: String) - { - self.nodeid = nodeid - self.token = token - } - - private enum CodingKeys: String, CodingKey { - case nodeid = "nodeId" - case token - } -} - -public struct NodeRenameParams: Codable, Sendable { - public let nodeid: String - public let displayname: String - - public init( - nodeid: String, - displayname: String) - { - self.nodeid = nodeid - self.displayname = displayname - } - - private enum CodingKeys: String, CodingKey { - case nodeid = "nodeId" - case displayname = "displayName" - } -} - -public struct NodeListParams: Codable, Sendable {} - -public struct NodeDescribeParams: Codable, Sendable { - public let nodeid: String - - public init( - nodeid: String) - { - self.nodeid = nodeid - } - - private enum CodingKeys: String, CodingKey { - case nodeid = "nodeId" - } -} - -public struct NodeInvokeParams: Codable, Sendable { - public let nodeid: String - public let command: String - public let params: AnyCodable? - public let timeoutms: Int? - public let idempotencykey: String - - public init( - nodeid: String, - command: String, - params: AnyCodable?, - timeoutms: Int?, - idempotencykey: String) - { - self.nodeid = nodeid - self.command = command - self.params = params - self.timeoutms = timeoutms - self.idempotencykey = idempotencykey - } - - private enum CodingKeys: String, CodingKey { - case nodeid = "nodeId" - case command - case params - case timeoutms = "timeoutMs" - case idempotencykey = "idempotencyKey" - } -} - -public struct NodeInvokeResultParams: Codable, Sendable { - public let id: String - public let nodeid: String - public let ok: Bool - public let payload: AnyCodable? - public let payloadjson: String? - public let error: [String: AnyCodable]? - - public init( - id: String, - nodeid: String, - ok: Bool, - payload: AnyCodable?, - payloadjson: String?, - error: [String: AnyCodable]?) - { - self.id = id - self.nodeid = nodeid - self.ok = ok - self.payload = payload - self.payloadjson = payloadjson - self.error = error - } - - private enum CodingKeys: String, CodingKey { - case id - case nodeid = "nodeId" - case ok - case payload - case payloadjson = "payloadJSON" - case error - } -} - -public struct NodeEventParams: Codable, Sendable { - public let event: String - public let payload: AnyCodable? - public let payloadjson: String? - - public init( - event: String, - payload: AnyCodable?, - payloadjson: String?) - { - self.event = event - self.payload = payload - self.payloadjson = payloadjson - } - - private enum CodingKeys: String, CodingKey { - case event - case payload - case payloadjson = "payloadJSON" - } -} - -public struct NodeInvokeRequestEvent: Codable, Sendable { - public let id: String - public let nodeid: String - public let command: String - public let paramsjson: String? - public let timeoutms: Int? - public let idempotencykey: String? - - public init( - id: String, - nodeid: String, - command: String, - paramsjson: String?, - timeoutms: Int?, - idempotencykey: String?) - { - self.id = id - self.nodeid = nodeid - self.command = command - self.paramsjson = paramsjson - self.timeoutms = timeoutms - self.idempotencykey = idempotencykey - } - - private enum CodingKeys: String, CodingKey { - case id - case nodeid = "nodeId" - case command - case paramsjson = "paramsJSON" - case timeoutms = "timeoutMs" - case idempotencykey = "idempotencyKey" - } -} - -public struct PushTestParams: Codable, Sendable { - public let nodeid: String - public let title: String? - public let body: String? - public let environment: String? - - public init( - nodeid: String, - title: String?, - body: String?, - environment: String?) - { - self.nodeid = nodeid - self.title = title - self.body = body - self.environment = environment - } - - private enum CodingKeys: String, CodingKey { - case nodeid = "nodeId" - case title - case body - case environment - } -} - -public struct PushTestResult: Codable, Sendable { - public let ok: Bool - public let status: Int - public let apnsid: String? - public let reason: String? - public let tokensuffix: String - public let topic: String - public let environment: String - - public init( - ok: Bool, - status: Int, - apnsid: String?, - reason: String?, - tokensuffix: String, - topic: String, - environment: String) - { - self.ok = ok - self.status = status - self.apnsid = apnsid - self.reason = reason - self.tokensuffix = tokensuffix - self.topic = topic - self.environment = environment - } - - private enum CodingKeys: String, CodingKey { - case ok - case status - case apnsid = "apnsId" - case reason - case tokensuffix = "tokenSuffix" - case topic - case environment - } -} - -public struct SessionsListParams: Codable, Sendable { - public let limit: Int? - public let activeminutes: Int? - public let includeglobal: Bool? - public let includeunknown: Bool? - public let includederivedtitles: Bool? - public let includelastmessage: Bool? - public let label: String? - public let spawnedby: String? - public let agentid: String? - public let search: String? - - public init( - limit: Int?, - activeminutes: Int?, - includeglobal: Bool?, - includeunknown: Bool?, - includederivedtitles: Bool?, - includelastmessage: Bool?, - label: String?, - spawnedby: String?, - agentid: String?, - search: String?) - { - self.limit = limit - self.activeminutes = activeminutes - self.includeglobal = includeglobal - self.includeunknown = includeunknown - self.includederivedtitles = includederivedtitles - self.includelastmessage = includelastmessage - self.label = label - self.spawnedby = spawnedby - self.agentid = agentid - self.search = search - } - - private enum CodingKeys: String, CodingKey { - case limit - case activeminutes = "activeMinutes" - case includeglobal = "includeGlobal" - case includeunknown = "includeUnknown" - case includederivedtitles = "includeDerivedTitles" - case includelastmessage = "includeLastMessage" - case label - case spawnedby = "spawnedBy" - case agentid = "agentId" - case search - } -} - -public struct SessionsPreviewParams: Codable, Sendable { - public let keys: [String] - public let limit: Int? - public let maxchars: Int? - - public init( - keys: [String], - limit: Int?, - maxchars: Int?) - { - self.keys = keys - self.limit = limit - self.maxchars = maxchars - } - - private enum CodingKeys: String, CodingKey { - case keys - case limit - case maxchars = "maxChars" - } -} - -public struct SessionsResolveParams: Codable, Sendable { - public let key: String? - public let sessionid: String? - public let label: String? - public let agentid: String? - public let spawnedby: String? - public let includeglobal: Bool? - public let includeunknown: Bool? - - public init( - key: String?, - sessionid: String?, - label: String?, - agentid: String?, - spawnedby: String?, - includeglobal: Bool?, - includeunknown: Bool?) - { - self.key = key - self.sessionid = sessionid - self.label = label - self.agentid = agentid - self.spawnedby = spawnedby - self.includeglobal = includeglobal - self.includeunknown = includeunknown - } - - private enum CodingKeys: String, CodingKey { - case key - case sessionid = "sessionId" - case label - case agentid = "agentId" - case spawnedby = "spawnedBy" - case includeglobal = "includeGlobal" - case includeunknown = "includeUnknown" - } -} - -public struct SessionsPatchParams: Codable, Sendable { - public let key: String - public let label: AnyCodable? - public let thinkinglevel: AnyCodable? - public let verboselevel: AnyCodable? - public let reasoninglevel: AnyCodable? - public let responseusage: AnyCodable? - public let elevatedlevel: AnyCodable? - public let exechost: AnyCodable? - public let execsecurity: AnyCodable? - public let execask: AnyCodable? - public let execnode: AnyCodable? - public let model: AnyCodable? - public let spawnedby: AnyCodable? - public let spawndepth: AnyCodable? - public let sendpolicy: AnyCodable? - public let groupactivation: AnyCodable? - - public init( - key: String, - label: AnyCodable?, - thinkinglevel: AnyCodable?, - verboselevel: AnyCodable?, - reasoninglevel: AnyCodable?, - responseusage: AnyCodable?, - elevatedlevel: AnyCodable?, - exechost: AnyCodable?, - execsecurity: AnyCodable?, - execask: AnyCodable?, - execnode: AnyCodable?, - model: AnyCodable?, - spawnedby: AnyCodable?, - spawndepth: AnyCodable?, - sendpolicy: AnyCodable?, - groupactivation: AnyCodable?) - { - self.key = key - self.label = label - self.thinkinglevel = thinkinglevel - self.verboselevel = verboselevel - self.reasoninglevel = reasoninglevel - self.responseusage = responseusage - self.elevatedlevel = elevatedlevel - self.exechost = exechost - self.execsecurity = execsecurity - self.execask = execask - self.execnode = execnode - self.model = model - self.spawnedby = spawnedby - self.spawndepth = spawndepth - self.sendpolicy = sendpolicy - self.groupactivation = groupactivation - } - - private enum CodingKeys: String, CodingKey { - case key - case label - case thinkinglevel = "thinkingLevel" - case verboselevel = "verboseLevel" - case reasoninglevel = "reasoningLevel" - case responseusage = "responseUsage" - case elevatedlevel = "elevatedLevel" - case exechost = "execHost" - case execsecurity = "execSecurity" - case execask = "execAsk" - case execnode = "execNode" - case model - case spawnedby = "spawnedBy" - case spawndepth = "spawnDepth" - case sendpolicy = "sendPolicy" - case groupactivation = "groupActivation" - } -} - -public struct SessionsResetParams: Codable, Sendable { - public let key: String - public let reason: AnyCodable? - - public init( - key: String, - reason: AnyCodable?) - { - self.key = key - self.reason = reason - } - - private enum CodingKeys: String, CodingKey { - case key - case reason - } -} - -public struct SessionsDeleteParams: Codable, Sendable { - public let key: String - public let deletetranscript: Bool? - public let emitlifecyclehooks: Bool? - - public init( - key: String, - deletetranscript: Bool?, - emitlifecyclehooks: Bool?) - { - self.key = key - self.deletetranscript = deletetranscript - self.emitlifecyclehooks = emitlifecyclehooks - } - - private enum CodingKeys: String, CodingKey { - case key - case deletetranscript = "deleteTranscript" - case emitlifecyclehooks = "emitLifecycleHooks" - } -} - -public struct SessionsCompactParams: Codable, Sendable { - public let key: String - public let maxlines: Int? - - public init( - key: String, - maxlines: Int?) - { - self.key = key - self.maxlines = maxlines - } - - private enum CodingKeys: String, CodingKey { - case key - case maxlines = "maxLines" - } -} - -public struct SessionsUsageParams: Codable, Sendable { - public let key: String? - public let startdate: String? - public let enddate: String? - public let mode: AnyCodable? - public let utcoffset: String? - public let limit: Int? - public let includecontextweight: Bool? - - public init( - key: String?, - startdate: String?, - enddate: String?, - mode: AnyCodable?, - utcoffset: String?, - limit: Int?, - includecontextweight: Bool?) - { - self.key = key - self.startdate = startdate - self.enddate = enddate - self.mode = mode - self.utcoffset = utcoffset - self.limit = limit - self.includecontextweight = includecontextweight - } - - private enum CodingKeys: String, CodingKey { - case key - case startdate = "startDate" - case enddate = "endDate" - case mode - case utcoffset = "utcOffset" - case limit - case includecontextweight = "includeContextWeight" - } -} - -public struct ConfigGetParams: Codable, Sendable {} - -public struct ConfigSetParams: Codable, Sendable { - public let raw: String - public let basehash: String? - - public init( - raw: String, - basehash: String?) - { - self.raw = raw - self.basehash = basehash - } - - private enum CodingKeys: String, CodingKey { - case raw - case basehash = "baseHash" - } -} - -public struct ConfigApplyParams: Codable, Sendable { - public let raw: String - public let basehash: String? - public let sessionkey: String? - public let note: String? - public let restartdelayms: Int? - - public init( - raw: String, - basehash: String?, - sessionkey: String?, - note: String?, - restartdelayms: Int?) - { - self.raw = raw - self.basehash = basehash - self.sessionkey = sessionkey - self.note = note - self.restartdelayms = restartdelayms - } - - private enum CodingKeys: String, CodingKey { - case raw - case basehash = "baseHash" - case sessionkey = "sessionKey" - case note - case restartdelayms = "restartDelayMs" - } -} - -public struct ConfigPatchParams: Codable, Sendable { - public let raw: String - public let basehash: String? - public let sessionkey: String? - public let note: String? - public let restartdelayms: Int? - - public init( - raw: String, - basehash: String?, - sessionkey: String?, - note: String?, - restartdelayms: Int?) - { - self.raw = raw - self.basehash = basehash - self.sessionkey = sessionkey - self.note = note - self.restartdelayms = restartdelayms - } - - private enum CodingKeys: String, CodingKey { - case raw - case basehash = "baseHash" - case sessionkey = "sessionKey" - case note - case restartdelayms = "restartDelayMs" - } -} - -public struct ConfigSchemaParams: Codable, Sendable {} - -public struct ConfigSchemaResponse: Codable, Sendable { - public let schema: AnyCodable - public let uihints: [String: AnyCodable] - public let version: String - public let generatedat: String - - public init( - schema: AnyCodable, - uihints: [String: AnyCodable], - version: String, - generatedat: String) - { - self.schema = schema - self.uihints = uihints - self.version = version - self.generatedat = generatedat - } - - private enum CodingKeys: String, CodingKey { - case schema - case uihints = "uiHints" - case version - case generatedat = "generatedAt" - } -} - -public struct WizardStartParams: Codable, Sendable { - public let mode: AnyCodable? - public let workspace: String? - - public init( - mode: AnyCodable?, - workspace: String?) - { - self.mode = mode - self.workspace = workspace - } - - private enum CodingKeys: String, CodingKey { - case mode - case workspace - } -} - -public struct WizardNextParams: Codable, Sendable { - public let sessionid: String - public let answer: [String: AnyCodable]? - - public init( - sessionid: String, - answer: [String: AnyCodable]?) - { - self.sessionid = sessionid - self.answer = answer - } - - private enum CodingKeys: String, CodingKey { - case sessionid = "sessionId" - case answer - } -} - -public struct WizardCancelParams: Codable, Sendable { - public let sessionid: String - - public init( - sessionid: String) - { - self.sessionid = sessionid - } - - private enum CodingKeys: String, CodingKey { - case sessionid = "sessionId" - } -} - -public struct WizardStatusParams: Codable, Sendable { - public let sessionid: String - - public init( - sessionid: String) - { - self.sessionid = sessionid - } - - private enum CodingKeys: String, CodingKey { - case sessionid = "sessionId" - } -} - -public struct WizardStep: Codable, Sendable { - public let id: String - public let type: AnyCodable - public let title: String? - public let message: String? - public let options: [[String: AnyCodable]]? - public let initialvalue: AnyCodable? - public let placeholder: String? - public let sensitive: Bool? - public let executor: AnyCodable? - - public init( - id: String, - type: AnyCodable, - title: String?, - message: String?, - options: [[String: AnyCodable]]?, - initialvalue: AnyCodable?, - placeholder: String?, - sensitive: Bool?, - executor: AnyCodable?) - { - self.id = id - self.type = type - self.title = title - self.message = message - self.options = options - self.initialvalue = initialvalue - self.placeholder = placeholder - self.sensitive = sensitive - self.executor = executor - } - - private enum CodingKeys: String, CodingKey { - case id - case type - case title - case message - case options - case initialvalue = "initialValue" - case placeholder - case sensitive - case executor - } -} - -public struct WizardNextResult: Codable, Sendable { - public let done: Bool - public let step: [String: AnyCodable]? - public let status: AnyCodable? - public let error: String? - - public init( - done: Bool, - step: [String: AnyCodable]?, - status: AnyCodable?, - error: String?) - { - self.done = done - self.step = step - self.status = status - self.error = error - } - - private enum CodingKeys: String, CodingKey { - case done - case step - case status - case error - } -} - -public struct WizardStartResult: Codable, Sendable { - public let sessionid: String - public let done: Bool - public let step: [String: AnyCodable]? - public let status: AnyCodable? - public let error: String? - - public init( - sessionid: String, - done: Bool, - step: [String: AnyCodable]?, - status: AnyCodable?, - error: String?) - { - self.sessionid = sessionid - self.done = done - self.step = step - self.status = status - self.error = error - } - - private enum CodingKeys: String, CodingKey { - case sessionid = "sessionId" - case done - case step - case status - case error - } -} - -public struct WizardStatusResult: Codable, Sendable { - public let status: AnyCodable - public let error: String? - - public init( - status: AnyCodable, - error: String?) - { - self.status = status - self.error = error - } - - private enum CodingKeys: String, CodingKey { - case status - case error - } -} - -public struct TalkModeParams: Codable, Sendable { - public let enabled: Bool - public let phase: String? - - public init( - enabled: Bool, - phase: String?) - { - self.enabled = enabled - self.phase = phase - } - - private enum CodingKeys: String, CodingKey { - case enabled - case phase - } -} - -public struct TalkConfigParams: Codable, Sendable { - public let includesecrets: Bool? - - public init( - includesecrets: Bool?) - { - self.includesecrets = includesecrets - } - - private enum CodingKeys: String, CodingKey { - case includesecrets = "includeSecrets" - } -} - -public struct TalkConfigResult: Codable, Sendable { - public let config: [String: AnyCodable] - - public init( - config: [String: AnyCodable]) - { - self.config = config - } - - private enum CodingKeys: String, CodingKey { - case config - } -} - -public struct ChannelsStatusParams: Codable, Sendable { - public let probe: Bool? - public let timeoutms: Int? - - public init( - probe: Bool?, - timeoutms: Int?) - { - self.probe = probe - self.timeoutms = timeoutms - } - - private enum CodingKeys: String, CodingKey { - case probe - case timeoutms = "timeoutMs" - } -} - -public struct ChannelsStatusResult: Codable, Sendable { - public let ts: Int - public let channelorder: [String] - public let channellabels: [String: AnyCodable] - public let channeldetaillabels: [String: AnyCodable]? - public let channelsystemimages: [String: AnyCodable]? - public let channelmeta: [[String: AnyCodable]]? - public let channels: [String: AnyCodable] - public let channelaccounts: [String: AnyCodable] - public let channeldefaultaccountid: [String: AnyCodable] - - public init( - ts: Int, - channelorder: [String], - channellabels: [String: AnyCodable], - channeldetaillabels: [String: AnyCodable]?, - channelsystemimages: [String: AnyCodable]?, - channelmeta: [[String: AnyCodable]]?, - channels: [String: AnyCodable], - channelaccounts: [String: AnyCodable], - channeldefaultaccountid: [String: AnyCodable]) - { - self.ts = ts - self.channelorder = channelorder - self.channellabels = channellabels - self.channeldetaillabels = channeldetaillabels - self.channelsystemimages = channelsystemimages - self.channelmeta = channelmeta - self.channels = channels - self.channelaccounts = channelaccounts - self.channeldefaultaccountid = channeldefaultaccountid - } - - private enum CodingKeys: String, CodingKey { - case ts - case channelorder = "channelOrder" - case channellabels = "channelLabels" - case channeldetaillabels = "channelDetailLabels" - case channelsystemimages = "channelSystemImages" - case channelmeta = "channelMeta" - case channels - case channelaccounts = "channelAccounts" - case channeldefaultaccountid = "channelDefaultAccountId" - } -} - -public struct ChannelsLogoutParams: Codable, Sendable { - public let channel: String - public let accountid: String? - - public init( - channel: String, - accountid: String?) - { - self.channel = channel - self.accountid = accountid - } - - private enum CodingKeys: String, CodingKey { - case channel - case accountid = "accountId" - } -} - -public struct WebLoginStartParams: Codable, Sendable { - public let force: Bool? - public let timeoutms: Int? - public let verbose: Bool? - public let accountid: String? - - public init( - force: Bool?, - timeoutms: Int?, - verbose: Bool?, - accountid: String?) - { - self.force = force - self.timeoutms = timeoutms - self.verbose = verbose - self.accountid = accountid - } - - private enum CodingKeys: String, CodingKey { - case force - case timeoutms = "timeoutMs" - case verbose - case accountid = "accountId" - } -} - -public struct WebLoginWaitParams: Codable, Sendable { - public let timeoutms: Int? - public let accountid: String? - - public init( - timeoutms: Int?, - accountid: String?) - { - self.timeoutms = timeoutms - self.accountid = accountid - } - - private enum CodingKeys: String, CodingKey { - case timeoutms = "timeoutMs" - case accountid = "accountId" - } -} - -public struct AgentSummary: Codable, Sendable { - public let id: String - public let name: String? - public let identity: [String: AnyCodable]? - - public init( - id: String, - name: String?, - identity: [String: AnyCodable]?) - { - self.id = id - self.name = name - self.identity = identity - } - - private enum CodingKeys: String, CodingKey { - case id - case name - case identity - } -} - -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 - public let missing: Bool - public let size: Int? - public let updatedatms: Int? - public let content: String? - - public init( - name: String, - path: String, - missing: Bool, - size: Int?, - updatedatms: Int?, - content: String?) - { - self.name = name - self.path = path - self.missing = missing - self.size = size - self.updatedatms = updatedatms - self.content = content - } - - private enum CodingKeys: String, CodingKey { - case name - case path - case missing - case size - case updatedatms = "updatedAtMs" - case content - } -} - -public struct AgentsFilesListParams: Codable, Sendable { - public let agentid: String - - public init( - agentid: String) - { - self.agentid = agentid - } - - private enum CodingKeys: String, CodingKey { - case agentid = "agentId" - } -} - -public struct AgentsFilesListResult: Codable, Sendable { - public let agentid: String - public let workspace: String - public let files: [AgentsFileEntry] - - public init( - agentid: String, - workspace: String, - files: [AgentsFileEntry]) - { - self.agentid = agentid - self.workspace = workspace - self.files = files - } - - private enum CodingKeys: String, CodingKey { - case agentid = "agentId" - case workspace - case files - } -} - -public struct AgentsFilesGetParams: Codable, Sendable { - public let agentid: String - public let name: String - - public init( - agentid: String, - name: String) - { - self.agentid = agentid - self.name = name - } - - private enum CodingKeys: String, CodingKey { - case agentid = "agentId" - case name - } -} - -public struct AgentsFilesGetResult: Codable, Sendable { - public let agentid: String - public let workspace: String - public let file: AgentsFileEntry - - public init( - agentid: String, - workspace: String, - file: AgentsFileEntry) - { - self.agentid = agentid - self.workspace = workspace - self.file = file - } - - private enum CodingKeys: String, CodingKey { - case agentid = "agentId" - case workspace - case file - } -} - -public struct AgentsFilesSetParams: Codable, Sendable { - public let agentid: String - public let name: String - public let content: String - - public init( - agentid: String, - name: String, - content: String) - { - self.agentid = agentid - self.name = name - self.content = content - } - - private enum CodingKeys: String, CodingKey { - case agentid = "agentId" - case name - case content - } -} - -public struct AgentsFilesSetResult: Codable, Sendable { - public let ok: Bool - public let agentid: String - public let workspace: String - public let file: AgentsFileEntry - - public init( - ok: Bool, - agentid: String, - workspace: String, - file: AgentsFileEntry) - { - self.ok = ok - self.agentid = agentid - self.workspace = workspace - self.file = file - } - - private enum CodingKeys: String, CodingKey { - case ok - case agentid = "agentId" - case workspace - case file - } -} - -public struct AgentsListParams: Codable, Sendable {} - -public struct AgentsListResult: Codable, Sendable { - public let defaultid: String - public let mainkey: String - public let scope: AnyCodable - public let agents: [AgentSummary] - - public init( - defaultid: String, - mainkey: String, - scope: AnyCodable, - agents: [AgentSummary]) - { - self.defaultid = defaultid - self.mainkey = mainkey - self.scope = scope - self.agents = agents - } - - private enum CodingKeys: String, CodingKey { - case defaultid = "defaultId" - case mainkey = "mainKey" - case scope - case agents - } -} - -public struct ModelChoice: Codable, Sendable { - public let id: String - public let name: String - public let provider: String - public let contextwindow: Int? - public let reasoning: Bool? - - public init( - id: String, - name: String, - provider: String, - contextwindow: Int?, - reasoning: Bool?) - { - self.id = id - self.name = name - self.provider = provider - self.contextwindow = contextwindow - self.reasoning = reasoning - } - - private enum CodingKeys: String, CodingKey { - case id - case name - case provider - case contextwindow = "contextWindow" - case reasoning - } -} - -public struct ModelsListParams: Codable, Sendable {} - -public struct ModelsListResult: Codable, Sendable { - public let models: [ModelChoice] - - public init( - models: [ModelChoice]) - { - self.models = models - } - - private enum CodingKeys: String, CodingKey { - case models - } -} - -public struct SkillsStatusParams: Codable, Sendable { - public let agentid: String? - - public init( - agentid: String?) - { - self.agentid = agentid - } - - private enum CodingKeys: String, CodingKey { - case agentid = "agentId" - } -} - -public struct SkillsBinsParams: Codable, Sendable {} - -public struct SkillsBinsResult: Codable, Sendable { - public let bins: [String] - - public init( - bins: [String]) - { - self.bins = bins - } - - private enum CodingKeys: String, CodingKey { - case bins - } -} - -public struct SkillsInstallParams: Codable, Sendable { - public let name: String - public let installid: String - public let timeoutms: Int? - - public init( - name: String, - installid: String, - timeoutms: Int?) - { - self.name = name - self.installid = installid - self.timeoutms = timeoutms - } - - private enum CodingKeys: String, CodingKey { - case name - case installid = "installId" - case timeoutms = "timeoutMs" - } -} - -public struct SkillsUpdateParams: Codable, Sendable { - public let skillkey: String - public let enabled: Bool? - public let apikey: String? - public let env: [String: AnyCodable]? - - public init( - skillkey: String, - enabled: Bool?, - apikey: String?, - env: [String: AnyCodable]?) - { - self.skillkey = skillkey - self.enabled = enabled - self.apikey = apikey - self.env = env - } - - private enum CodingKeys: String, CodingKey { - case skillkey = "skillKey" - case enabled - case apikey = "apiKey" - case env - } -} - -public struct CronJob: Codable, Sendable { - public let id: String - public let agentid: String? - public let sessionkey: String? - public let name: String - public let description: String? - public let enabled: Bool - public let deleteafterrun: Bool? - public let createdatms: Int - public let updatedatms: Int - public let schedule: AnyCodable - public let sessiontarget: AnyCodable - public let wakemode: AnyCodable - public let payload: AnyCodable - public let delivery: AnyCodable? - public let state: [String: AnyCodable] - - public init( - id: String, - agentid: String?, - sessionkey: String?, - name: String, - description: String?, - enabled: Bool, - deleteafterrun: Bool?, - createdatms: Int, - updatedatms: Int, - schedule: AnyCodable, - sessiontarget: AnyCodable, - wakemode: AnyCodable, - payload: AnyCodable, - delivery: AnyCodable?, - state: [String: AnyCodable]) - { - self.id = id - self.agentid = agentid - self.sessionkey = sessionkey - self.name = name - self.description = description - self.enabled = enabled - self.deleteafterrun = deleteafterrun - self.createdatms = createdatms - self.updatedatms = updatedatms - self.schedule = schedule - self.sessiontarget = sessiontarget - self.wakemode = wakemode - self.payload = payload - self.delivery = delivery - self.state = state - } - - private enum CodingKeys: String, CodingKey { - case id - case agentid = "agentId" - case sessionkey = "sessionKey" - case name - case description - case enabled - case deleteafterrun = "deleteAfterRun" - case createdatms = "createdAtMs" - case updatedatms = "updatedAtMs" - case schedule - case sessiontarget = "sessionTarget" - case wakemode = "wakeMode" - case payload - case delivery - case state - } -} - -public struct CronListParams: Codable, Sendable { - public let includedisabled: Bool? - - public init( - includedisabled: Bool?) - { - self.includedisabled = includedisabled - } - - private enum CodingKeys: String, CodingKey { - case includedisabled = "includeDisabled" - } -} - -public struct CronStatusParams: Codable, Sendable {} - -public struct CronAddParams: Codable, Sendable { - public let name: String - public let agentid: AnyCodable? - public let sessionkey: AnyCodable? - public let description: String? - public let enabled: Bool? - public let deleteafterrun: Bool? - public let schedule: AnyCodable - public let sessiontarget: AnyCodable - public let wakemode: AnyCodable - public let payload: AnyCodable - public let delivery: AnyCodable? - - public init( - name: String, - agentid: AnyCodable?, - sessionkey: AnyCodable?, - description: String?, - enabled: Bool?, - deleteafterrun: Bool?, - schedule: AnyCodable, - sessiontarget: AnyCodable, - wakemode: AnyCodable, - payload: AnyCodable, - delivery: AnyCodable?) - { - self.name = name - self.agentid = agentid - self.sessionkey = sessionkey - self.description = description - self.enabled = enabled - self.deleteafterrun = deleteafterrun - self.schedule = schedule - self.sessiontarget = sessiontarget - self.wakemode = wakemode - self.payload = payload - self.delivery = delivery - } - - private enum CodingKeys: String, CodingKey { - case name - case agentid = "agentId" - case sessionkey = "sessionKey" - case description - case enabled - case deleteafterrun = "deleteAfterRun" - case schedule - case sessiontarget = "sessionTarget" - case wakemode = "wakeMode" - case payload - case delivery - } -} - -public struct CronRunLogEntry: Codable, Sendable { - public let ts: Int - public let jobid: String - public let action: String - public let status: AnyCodable? - public let error: String? - public let summary: String? - public let sessionid: String? - public let sessionkey: String? - public let runatms: Int? - public let durationms: Int? - public let nextrunatms: Int? - - public init( - ts: Int, - jobid: String, - action: String, - status: AnyCodable?, - error: String?, - summary: String?, - sessionid: String?, - sessionkey: String?, - runatms: Int?, - durationms: Int?, - nextrunatms: Int?) - { - self.ts = ts - self.jobid = jobid - self.action = action - self.status = status - self.error = error - self.summary = summary - self.sessionid = sessionid - self.sessionkey = sessionkey - self.runatms = runatms - self.durationms = durationms - self.nextrunatms = nextrunatms - } - - private enum CodingKeys: String, CodingKey { - case ts - case jobid = "jobId" - case action - case status - case error - case summary - case sessionid = "sessionId" - case sessionkey = "sessionKey" - case runatms = "runAtMs" - case durationms = "durationMs" - case nextrunatms = "nextRunAtMs" - } -} - -public struct LogsTailParams: Codable, Sendable { - public let cursor: Int? - public let limit: Int? - public let maxbytes: Int? - - public init( - cursor: Int?, - limit: Int?, - maxbytes: Int?) - { - self.cursor = cursor - self.limit = limit - self.maxbytes = maxbytes - } - - private enum CodingKeys: String, CodingKey { - case cursor - case limit - case maxbytes = "maxBytes" - } -} - -public struct LogsTailResult: Codable, Sendable { - public let file: String - public let cursor: Int - public let size: Int - public let lines: [String] - public let truncated: Bool? - public let reset: Bool? - - public init( - file: String, - cursor: Int, - size: Int, - lines: [String], - truncated: Bool?, - reset: Bool?) - { - self.file = file - self.cursor = cursor - self.size = size - self.lines = lines - self.truncated = truncated - self.reset = reset - } - - private enum CodingKeys: String, CodingKey { - case file - case cursor - case size - case lines - case truncated - case reset - } -} - -public struct ExecApprovalsGetParams: Codable, Sendable {} - -public struct ExecApprovalsSetParams: Codable, Sendable { - public let file: [String: AnyCodable] - public let basehash: String? - - public init( - file: [String: AnyCodable], - basehash: String?) - { - self.file = file - self.basehash = basehash - } - - private enum CodingKeys: String, CodingKey { - case file - case basehash = "baseHash" - } -} - -public struct ExecApprovalsNodeGetParams: Codable, Sendable { - public let nodeid: String - - public init( - nodeid: String) - { - self.nodeid = nodeid - } - - private enum CodingKeys: String, CodingKey { - case nodeid = "nodeId" - } -} - -public struct ExecApprovalsNodeSetParams: Codable, Sendable { - public let nodeid: String - public let file: [String: AnyCodable] - public let basehash: String? - - public init( - nodeid: String, - file: [String: AnyCodable], - basehash: String?) - { - self.nodeid = nodeid - self.file = file - self.basehash = basehash - } - - private enum CodingKeys: String, CodingKey { - case nodeid = "nodeId" - case file - case basehash = "baseHash" - } -} - -public struct ExecApprovalsSnapshot: Codable, Sendable { - public let path: String - public let exists: Bool - public let hash: String - public let file: [String: AnyCodable] - - public init( - path: String, - exists: Bool, - hash: String, - file: [String: AnyCodable]) - { - self.path = path - self.exists = exists - self.hash = hash - self.file = file - } - - private enum CodingKeys: String, CodingKey { - case path - case exists - case hash - case file - } -} - -public struct ExecApprovalRequestParams: Codable, Sendable { - public let id: String? - public let command: String - public let cwd: AnyCodable? - public let host: AnyCodable? - public let security: AnyCodable? - public let ask: AnyCodable? - public let agentid: AnyCodable? - public let resolvedpath: AnyCodable? - public let sessionkey: AnyCodable? - public let timeoutms: Int? - public let twophase: Bool? - - public init( - id: String?, - command: String, - cwd: AnyCodable?, - host: AnyCodable?, - security: AnyCodable?, - ask: AnyCodable?, - agentid: AnyCodable?, - resolvedpath: AnyCodable?, - sessionkey: AnyCodable?, - timeoutms: Int?, - twophase: Bool?) - { - self.id = id - self.command = command - self.cwd = cwd - self.host = host - self.security = security - self.ask = ask - self.agentid = agentid - self.resolvedpath = resolvedpath - self.sessionkey = sessionkey - self.timeoutms = timeoutms - self.twophase = twophase - } - - private enum CodingKeys: String, CodingKey { - case id - case command - case cwd - case host - case security - case ask - case agentid = "agentId" - case resolvedpath = "resolvedPath" - case sessionkey = "sessionKey" - case timeoutms = "timeoutMs" - case twophase = "twoPhase" - } -} - -public struct ExecApprovalResolveParams: Codable, Sendable { - public let id: String - public let decision: String - - public init( - id: String, - decision: String) - { - self.id = id - self.decision = decision - } - - private enum CodingKeys: String, CodingKey { - case id - case decision - } -} - -public struct DevicePairListParams: Codable, Sendable {} - -public struct DevicePairApproveParams: Codable, Sendable { - public let requestid: String - - public init( - requestid: String) - { - self.requestid = requestid - } - - private enum CodingKeys: String, CodingKey { - case requestid = "requestId" - } -} - -public struct DevicePairRejectParams: Codable, Sendable { - public let requestid: String - - public init( - requestid: String) - { - self.requestid = requestid - } - - private enum CodingKeys: String, CodingKey { - case requestid = "requestId" - } -} - -public struct DevicePairRemoveParams: Codable, Sendable { - public let deviceid: String - - public init( - deviceid: String) - { - self.deviceid = deviceid - } - - private enum CodingKeys: String, CodingKey { - case deviceid = "deviceId" - } -} - -public struct DeviceTokenRotateParams: Codable, Sendable { - public let deviceid: String - public let role: String - public let scopes: [String]? - - public init( - deviceid: String, - role: String, - scopes: [String]?) - { - self.deviceid = deviceid - self.role = role - self.scopes = scopes - } - - private enum CodingKeys: String, CodingKey { - case deviceid = "deviceId" - case role - case scopes - } -} - -public struct DeviceTokenRevokeParams: Codable, Sendable { - public let deviceid: String - public let role: String - - public init( - deviceid: String, - role: String) - { - self.deviceid = deviceid - self.role = role - } - - private enum CodingKeys: String, CodingKey { - case deviceid = "deviceId" - case role - } -} - -public struct DevicePairRequestedEvent: Codable, Sendable { - public let requestid: String - public let deviceid: String - public let publickey: String - public let displayname: String? - public let platform: String? - public let clientid: String? - public let clientmode: String? - public let role: String? - public let roles: [String]? - public let scopes: [String]? - public let remoteip: String? - public let silent: Bool? - public let isrepair: Bool? - public let ts: Int - - public init( - requestid: String, - deviceid: String, - publickey: String, - displayname: String?, - platform: String?, - clientid: String?, - clientmode: String?, - role: String?, - roles: [String]?, - scopes: [String]?, - remoteip: String?, - silent: Bool?, - isrepair: Bool?, - ts: Int) - { - self.requestid = requestid - self.deviceid = deviceid - self.publickey = publickey - self.displayname = displayname - self.platform = platform - self.clientid = clientid - self.clientmode = clientmode - self.role = role - self.roles = roles - self.scopes = scopes - self.remoteip = remoteip - self.silent = silent - self.isrepair = isrepair - self.ts = ts - } - - private enum CodingKeys: String, CodingKey { - case requestid = "requestId" - case deviceid = "deviceId" - case publickey = "publicKey" - case displayname = "displayName" - case platform - case clientid = "clientId" - case clientmode = "clientMode" - case role - case roles - case scopes - case remoteip = "remoteIp" - case silent - case isrepair = "isRepair" - case ts - } -} - -public struct DevicePairResolvedEvent: Codable, Sendable { - public let requestid: String - public let deviceid: String - public let decision: String - public let ts: Int - - public init( - requestid: String, - deviceid: String, - decision: String, - ts: Int) - { - self.requestid = requestid - self.deviceid = deviceid - self.decision = decision - self.ts = ts - } - - private enum CodingKeys: String, CodingKey { - case requestid = "requestId" - case deviceid = "deviceId" - case decision - case ts - } -} - -public struct ChatHistoryParams: Codable, Sendable { - public let sessionkey: String - public let limit: Int? - - public init( - sessionkey: String, - limit: Int?) - { - self.sessionkey = sessionkey - self.limit = limit - } - - private enum CodingKeys: String, CodingKey { - case sessionkey = "sessionKey" - case limit - } -} - -public struct ChatSendParams: Codable, Sendable { - public let sessionkey: String - public let message: String - public let thinking: String? - public let deliver: Bool? - public let attachments: [AnyCodable]? - public let timeoutms: Int? - public let idempotencykey: String - - public init( - sessionkey: String, - message: String, - thinking: String?, - deliver: Bool?, - attachments: [AnyCodable]?, - timeoutms: Int?, - idempotencykey: String) - { - self.sessionkey = sessionkey - self.message = message - self.thinking = thinking - self.deliver = deliver - self.attachments = attachments - self.timeoutms = timeoutms - self.idempotencykey = idempotencykey - } - - private enum CodingKeys: String, CodingKey { - case sessionkey = "sessionKey" - case message - case thinking - case deliver - case attachments - case timeoutms = "timeoutMs" - case idempotencykey = "idempotencyKey" - } -} - -public struct ChatAbortParams: Codable, Sendable { - public let sessionkey: String - public let runid: String? - - public init( - sessionkey: String, - runid: String?) - { - self.sessionkey = sessionkey - self.runid = runid - } - - private enum CodingKeys: String, CodingKey { - case sessionkey = "sessionKey" - case runid = "runId" - } -} - -public struct ChatInjectParams: Codable, Sendable { - public let sessionkey: String - public let message: String - public let label: String? - - public init( - sessionkey: String, - message: String, - label: String?) - { - self.sessionkey = sessionkey - self.message = message - self.label = label - } - - private enum CodingKeys: String, CodingKey { - case sessionkey = "sessionKey" - case message - case label - } -} - -public struct ChatEvent: Codable, Sendable { - public let runid: String - public let sessionkey: String - public let seq: Int - public let state: AnyCodable - public let message: AnyCodable? - public let errormessage: String? - public let usage: AnyCodable? - public let stopreason: String? - - public init( - runid: String, - sessionkey: String, - seq: Int, - state: AnyCodable, - message: AnyCodable?, - errormessage: String?, - usage: AnyCodable?, - stopreason: String?) - { - self.runid = runid - self.sessionkey = sessionkey - self.seq = seq - self.state = state - self.message = message - self.errormessage = errormessage - self.usage = usage - self.stopreason = stopreason - } - - private enum CodingKeys: String, CodingKey { - case runid = "runId" - case sessionkey = "sessionKey" - case seq - case state - case message - case errormessage = "errorMessage" - case usage - case stopreason = "stopReason" - } -} - -public struct UpdateRunParams: Codable, Sendable { - public let sessionkey: String? - public let note: String? - public let restartdelayms: Int? - public let timeoutms: Int? - - public init( - sessionkey: String?, - note: String?, - restartdelayms: Int?, - timeoutms: Int?) - { - self.sessionkey = sessionkey - self.note = note - self.restartdelayms = restartdelayms - self.timeoutms = timeoutms - } - - private enum CodingKeys: String, CodingKey { - case sessionkey = "sessionKey" - case note - case restartdelayms = "restartDelayMs" - case timeoutms = "timeoutMs" - } -} - -public struct TickEvent: Codable, Sendable { - public let ts: Int - - public init( - ts: Int) - { - self.ts = ts - } - - private enum CodingKeys: String, CodingKey { - case ts - } -} - -public struct ShutdownEvent: Codable, Sendable { - public let reason: String - public let restartexpectedms: Int? - - public init( - reason: String, - restartexpectedms: Int?) - { - self.reason = reason - self.restartexpectedms = restartexpectedms - } - - private enum CodingKeys: String, CodingKey { - case reason - case restartexpectedms = "restartExpectedMs" - } -} - -public enum GatewayFrame: Codable, Sendable { - case req(RequestFrame) - case res(ResponseFrame) - case event(EventFrame) - case unknown(type: String, raw: [String: AnyCodable]) - - private enum CodingKeys: String, CodingKey { - case type - } - - public init(from decoder: Decoder) throws { - let typeContainer = try decoder.container(keyedBy: CodingKeys.self) - let type = try typeContainer.decode(String.self, forKey: .type) - switch type { - case "req": - self = try .req(RequestFrame(from: decoder)) - case "res": - self = try .res(ResponseFrame(from: decoder)) - case "event": - self = try .event(EventFrame(from: decoder)) - default: - let container = try decoder.singleValueContainer() - let raw = try container.decode([String: AnyCodable].self) - self = .unknown(type: type, raw: raw) - } - } - - public func encode(to encoder: Encoder) throws { - switch self { - case let .req(v): - try v.encode(to: encoder) - case let .res(v): - try v.encode(to: encoder) - case let .event(v): - try v.encode(to: encoder) - case let .unknown(_, raw): - var container = encoder.singleValueContainer() - try container.encode(raw) - } - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/WizardHelpers.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/WizardHelpers.swift deleted file mode 100644 index d410914bfa5..00000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/WizardHelpers.swift +++ /dev/null @@ -1,106 +0,0 @@ -import Foundation - -public struct WizardOption: Sendable { - public let value: AnyCodable? - public let label: String - public let hint: String? - - public init(value: AnyCodable?, label: String, hint: String?) { - self.value = value - self.label = label - self.hint = hint - } -} - -public func decodeWizardStep(_ raw: [String: AnyCodable]?) -> WizardStep? { - guard let raw else { return nil } - do { - let data = try JSONEncoder().encode(raw) - return try JSONDecoder().decode(WizardStep.self, from: data) - } catch { - return nil - } -} - -public func parseWizardOptions(_ raw: [[String: AnyCodable]]?) -> [WizardOption] { - guard let raw else { return [] } - return raw.map { entry in - let value = entry["value"] - let label = (entry["label"]?.value as? String) ?? "" - let hint = entry["hint"]?.value as? String - return WizardOption(value: value, label: label, hint: hint) - } -} - -public func wizardStatusString(_ value: AnyCodable?) -> String? { - (value?.value as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() -} - -public func wizardStepType(_ step: WizardStep) -> String { - (step.type.value as? String) ?? "" -} - -public func anyCodableString(_ value: AnyCodable?) -> String { - switch value?.value { - case let string as String: - string - case let int as Int: - String(int) - case let double as Double: - String(double) - case let bool as Bool: - bool ? "true" : "false" - default: - "" - } -} - -public func anyCodableBool(_ value: AnyCodable?) -> Bool { - switch value?.value { - case let bool as Bool: - return bool - case let int as Int: - return int != 0 - case let double as Double: - return double != 0 - case let string as String: - let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - return trimmed == "true" || trimmed == "1" || trimmed == "yes" - default: - return false - } -} - -public func anyCodableArray(_ value: AnyCodable?) -> [AnyCodable] { - switch value?.value { - case let arr as [AnyCodable]: - return arr - case let arr as [Any]: - return arr.map { AnyCodable($0) } - default: - return [] - } -} - -public func anyCodableEqual(_ lhs: AnyCodable?, _ rhs: AnyCodable?) -> Bool { - switch (lhs?.value, rhs?.value) { - case let (l as String, r as String): - l == r - case let (l as Int, r as Int): - l == r - case let (l as Double, r as Double): - l == r - case let (l as Bool, r as Bool): - l == r - case let (l as String, r as Int): - l == String(r) - case let (l as Int, r as String): - String(l) == r - case let (l as String, r as Double): - l == String(r) - case let (l as Double, r as String): - String(l) == r - default: - false - } -} diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/AnyCodableTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/AnyCodableTests.swift deleted file mode 100644 index 3835f1186c0..00000000000 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/AnyCodableTests.swift +++ /dev/null @@ -1,40 +0,0 @@ -import Foundation -import Testing -import OpenClawProtocol - -struct AnyCodableTests { - @Test - func encodesNSNumberBooleansAsJSONBooleans() throws { - let trueData = try JSONEncoder().encode(AnyCodable(NSNumber(value: true))) - let falseData = try JSONEncoder().encode(AnyCodable(NSNumber(value: false))) - - #expect(String(data: trueData, encoding: .utf8) == "true") - #expect(String(data: falseData, encoding: .utf8) == "false") - } - - @Test - func preservesBooleanLiteralsFromJSONSerializationBridge() throws { - let raw = try #require( - JSONSerialization.jsonObject(with: Data(#"{"enabled":true,"nested":{"active":false}}"#.utf8)) - as? [String: Any] - ) - let enabled = try #require(raw["enabled"]) - let nested = try #require(raw["nested"]) - - struct RequestEnvelope: Codable { - let params: [String: AnyCodable] - } - - let envelope = RequestEnvelope( - params: [ - "enabled": AnyCodable(enabled), - "nested": AnyCodable(nested), - ] - ) - let data = try JSONEncoder().encode(envelope) - let json = try #require(String(data: data, encoding: .utf8)) - - #expect(json.contains(#""enabled":true"#)) - #expect(json.contains(#""active":false"#)) - } -} diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/AssistantTextParserTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/AssistantTextParserTests.swift deleted file mode 100644 index 5f36bb9c267..00000000000 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/AssistantTextParserTests.swift +++ /dev/null @@ -1,37 +0,0 @@ -import Testing -@testable import OpenClawChatUI - -@Suite struct AssistantTextParserTests { - @Test func splitsThinkAndFinalSegments() { - let segments = AssistantTextParser.segments( - from: "internal\n\nHello there") - - #expect(segments.count == 2) - #expect(segments[0].kind == .thinking) - #expect(segments[0].text == "internal") - #expect(segments[1].kind == .response) - #expect(segments[1].text == "Hello there") - } - - @Test func keepsTextWithoutTags() { - let segments = AssistantTextParser.segments(from: "Just text.") - - #expect(segments.count == 1) - #expect(segments[0].kind == .response) - #expect(segments[0].text == "Just text.") - } - - @Test func ignoresThinkingLikeTags() { - let raw = "example\nKeep this." - let segments = AssistantTextParser.segments(from: raw) - - #expect(segments.count == 1) - #expect(segments[0].kind == .response) - #expect(segments[0].text == raw.trimmingCharacters(in: .whitespacesAndNewlines)) - } - - @Test func dropsEmptyTaggedContent() { - let segments = AssistantTextParser.segments(from: "") - #expect(segments.isEmpty) - } -} diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/BonjourEscapesTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/BonjourEscapesTests.swift deleted file mode 100644 index a7fa1438d3c..00000000000 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/BonjourEscapesTests.swift +++ /dev/null @@ -1,26 +0,0 @@ -import OpenClawKit -import Testing - -@Suite struct BonjourEscapesTests { - @Test func decodePassThrough() { - #expect(BonjourEscapes.decode("hello") == "hello") - #expect(BonjourEscapes.decode("") == "") - } - - @Test func decodeSpaces() { - #expect(BonjourEscapes.decode("OpenClaw\\032Gateway") == "OpenClaw Gateway") - } - - @Test func decodeMultipleEscapes() { - #expect(BonjourEscapes.decode("A\\038B\\047C\\032D") == "A&B/C D") - } - - @Test func decodeIgnoresInvalidEscapeSequences() { - #expect(BonjourEscapes.decode("Hello\\03World") == "Hello\\03World") - #expect(BonjourEscapes.decode("Hello\\XYZWorld") == "Hello\\XYZWorld") - } - - @Test func decodeUsesDecimalUnicodeScalarValue() { - #expect(BonjourEscapes.decode("Hello\\065World") == "HelloAWorld") - } -} diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/CanvasA2UIActionTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/CanvasA2UIActionTests.swift deleted file mode 100644 index f6070f6de8d..00000000000 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/CanvasA2UIActionTests.swift +++ /dev/null @@ -1,36 +0,0 @@ -import OpenClawKit -import Foundation -import Testing - -@Suite struct CanvasA2UIActionTests { - @Test func sanitizeTagValueIsStable() { - #expect(OpenClawCanvasA2UIAction.sanitizeTagValue("Hello World!") == "Hello_World_") - #expect(OpenClawCanvasA2UIAction.sanitizeTagValue(" ") == "-") - #expect(OpenClawCanvasA2UIAction.sanitizeTagValue("macOS 26.2") == "macOS_26.2") - } - - @Test func extractActionNameAcceptsNameOrAction() { - #expect(OpenClawCanvasA2UIAction.extractActionName(["name": "Hello"]) == "Hello") - #expect(OpenClawCanvasA2UIAction.extractActionName(["action": "Wave"]) == "Wave") - #expect(OpenClawCanvasA2UIAction.extractActionName(["name": " ", "action": "Fallback"]) == "Fallback") - #expect(OpenClawCanvasA2UIAction.extractActionName(["action": " "]) == nil) - } - - @Test func formatAgentMessageIsTokenEfficientAndUnambiguous() { - let messageContext = OpenClawCanvasA2UIAction.AgentMessageContext( - actionName: "Get Weather", - session: .init(key: "main", surfaceId: "main"), - component: .init(id: "btnWeather", host: "Peter’s iPad", instanceId: "ipad16,6"), - contextJSON: "{\"city\":\"Vienna\"}") - let msg = OpenClawCanvasA2UIAction.formatAgentMessage(messageContext) - - #expect(msg.contains("CANVAS_A2UI ")) - #expect(msg.contains("action=Get_Weather")) - #expect(msg.contains("session=main")) - #expect(msg.contains("surface=main")) - #expect(msg.contains("component=btnWeather")) - #expect(msg.contains("host=Peter_s_iPad")) - #expect(msg.contains("instance=ipad16_6 ctx={\"city\":\"Vienna\"}")) - #expect(msg.hasSuffix(" default=update_canvas")) - } -} diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/CanvasA2UITests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/CanvasA2UITests.swift deleted file mode 100644 index 4c420cc944c..00000000000 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/CanvasA2UITests.swift +++ /dev/null @@ -1,42 +0,0 @@ -import OpenClawKit -import Testing - -@Suite struct CanvasA2UITests { - @Test func commandStringsAreStable() { - #expect(OpenClawCanvasA2UICommand.push.rawValue == "canvas.a2ui.push") - #expect(OpenClawCanvasA2UICommand.pushJSONL.rawValue == "canvas.a2ui.pushJSONL") - #expect(OpenClawCanvasA2UICommand.reset.rawValue == "canvas.a2ui.reset") - } - - @Test func jsonlDecodesAndValidatesV0_8() throws { - let jsonl = """ - {"beginRendering":{"surfaceId":"main","timestamp":1}} - {"surfaceUpdate":{"surfaceId":"main","ops":[]}} - {"dataModelUpdate":{"dataModel":{"title":"Hello"}}} - {"deleteSurface":{"surfaceId":"main"}} - """ - - let messages = try OpenClawCanvasA2UIJSONL.decodeMessagesFromJSONL(jsonl) - #expect(messages.count == 4) - } - - @Test func jsonlRejectsV0_9CreateSurface() { - let jsonl = """ - {"createSurface":{"surfaceId":"main"}} - """ - - #expect(throws: Error.self) { - _ = try OpenClawCanvasA2UIJSONL.decodeMessagesFromJSONL(jsonl) - } - } - - @Test func jsonlRejectsUnknownShape() { - let jsonl = """ - {"wat":{"nope":1}} - """ - - #expect(throws: Error.self) { - _ = try OpenClawCanvasA2UIJSONL.decodeMessagesFromJSONL(jsonl) - } - } -} diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/CanvasSnapshotFormatTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/CanvasSnapshotFormatTests.swift deleted file mode 100644 index ab49a4f465f..00000000000 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/CanvasSnapshotFormatTests.swift +++ /dev/null @@ -1,15 +0,0 @@ -import OpenClawKit -import Foundation -import Testing - -@Suite struct CanvasSnapshotFormatTests { - @Test func acceptsJpgAlias() throws { - struct Wrapper: Codable { - var format: OpenClawCanvasSnapshotFormat - } - - let data = try #require("{\"format\":\"jpg\"}".data(using: .utf8)) - let decoded = try JSONDecoder().decode(Wrapper.self, from: data) - #expect(decoded.format == .jpeg) - } -} diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMarkdownPreprocessorTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMarkdownPreprocessorTests.swift deleted file mode 100644 index 781a325f3cf..00000000000 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMarkdownPreprocessorTests.swift +++ /dev/null @@ -1,107 +0,0 @@ -import Testing -@testable import OpenClawChatUI - -@Suite("ChatMarkdownPreprocessor") -struct ChatMarkdownPreprocessorTests { - @Test func extractsDataURLImages() { - let base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4////GQAJ+wP/2hN8NwAAAABJRU5ErkJggg==" - let markdown = """ - Hello - - ![Pixel](data:image/png;base64,\(base64)) - """ - - let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown) - - #expect(result.cleaned == "Hello") - #expect(result.images.count == 1) - #expect(result.images.first?.image != nil) - } - - @Test func stripsInboundUntrustedContextBlocks() { - let markdown = """ - Conversation info (untrusted metadata): - ```json - { - "message_id": "123", - "sender": "openclaw-ios" - } - ``` - - Sender (untrusted metadata): - ```json - { - "label": "Razor" - } - ``` - - Razor? - """ - - let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown) - - #expect(result.cleaned == "Razor?") - } - - @Test func stripsSingleConversationInfoBlock() { - let text = """ - Conversation info (untrusted metadata): - ```json - {"x": 1} - ``` - - User message - """ - - let result = ChatMarkdownPreprocessor.preprocess(markdown: text) - - #expect(result.cleaned == "User message") - } - - @Test func stripsAllKnownInboundMetadataSentinels() { - let sentinels = [ - "Conversation info (untrusted metadata):", - "Sender (untrusted metadata):", - "Thread starter (untrusted, for context):", - "Replied message (untrusted, for context):", - "Forwarded message context (untrusted metadata):", - "Chat history since last reply (untrusted, for context):", - ] - - for sentinel in sentinels { - let markdown = """ - \(sentinel) - ```json - {"x": 1} - ``` - - User content - """ - let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown) - #expect(result.cleaned == "User content") - } - } - - @Test func preservesNonMetadataJsonFence() { - let markdown = """ - Here is some json: - ```json - {"x": 1} - ``` - """ - - let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown) - - #expect(result.cleaned == markdown.trimmingCharacters(in: .whitespacesAndNewlines)) - } - - @Test func stripsLeadingTimestampPrefix() { - let markdown = """ - [Fri 2026-02-20 18:45 GMT+1] How's it going? - """ - - let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown) - - #expect(result.cleaned == "How's it going?") - } -} diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatThemeTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatThemeTests.swift deleted file mode 100644 index 2c7a5fff1ee..00000000000 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatThemeTests.swift +++ /dev/null @@ -1,29 +0,0 @@ -import Foundation -import Testing -@testable import OpenClawChatUI - -#if os(macOS) -import AppKit -#endif - -#if os(macOS) -private func luminance(_ color: NSColor) throws -> CGFloat { - let rgb = try #require(color.usingColorSpace(.deviceRGB)) - return 0.2126 * rgb.redComponent + 0.7152 * rgb.greenComponent + 0.0722 * rgb.blueComponent -} -#endif - -@Suite struct ChatThemeTests { - @Test func assistantBubbleResolvesForLightAndDark() throws { - #if os(macOS) - let lightAppearance = try #require(NSAppearance(named: .aqua)) - let darkAppearance = try #require(NSAppearance(named: .darkAqua)) - - let lightResolved = OpenClawChatTheme.resolvedAssistantBubbleColor(for: lightAppearance) - let darkResolved = OpenClawChatTheme.resolvedAssistantBubbleColor(for: darkAppearance) - #expect(try luminance(lightResolved) > luminance(darkResolved)) - #else - #expect(Bool(true)) - #endif - } -} diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift deleted file mode 100644 index 147b80e5be1..00000000000 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift +++ /dev/null @@ -1,720 +0,0 @@ -import OpenClawKit -import Foundation -import Testing -@testable import OpenClawChatUI - -private struct TimeoutError: Error, CustomStringConvertible { - let label: String - var description: String { "Timeout waiting for: \(self.label)" } -} - -private func waitUntil( - _ label: String, - timeoutSeconds: Double = 2.0, - pollMs: UInt64 = 10, - _ condition: @escaping @Sendable () async -> Bool) async throws -{ - let deadline = Date().addingTimeInterval(timeoutSeconds) - while Date() < deadline { - if await condition() { - return - } - try await Task.sleep(nanoseconds: pollMs * 1_000_000) - } - throw TimeoutError(label: label) -} - -private actor TestChatTransportState { - var historyCallCount: Int = 0 - var sessionsCallCount: Int = 0 - var sentRunIds: [String] = [] - var abortedRunIds: [String] = [] -} - -private final class TestChatTransport: @unchecked Sendable, OpenClawChatTransport { - private let state = TestChatTransportState() - private let historyResponses: [OpenClawChatHistoryPayload] - private let sessionsResponses: [OpenClawChatSessionsListResponse] - - private let stream: AsyncStream - private let continuation: AsyncStream.Continuation - - init( - historyResponses: [OpenClawChatHistoryPayload], - sessionsResponses: [OpenClawChatSessionsListResponse] = []) - { - self.historyResponses = historyResponses - self.sessionsResponses = sessionsResponses - var cont: AsyncStream.Continuation! - self.stream = AsyncStream { c in - cont = c - } - self.continuation = cont - } - - func events() -> AsyncStream { - self.stream - } - - func setActiveSessionKey(_: String) async throws {} - - func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload { - let idx = await self.state.historyCallCount - await self.state.setHistoryCallCount(idx + 1) - if idx < self.historyResponses.count { - return self.historyResponses[idx] - } - return self.historyResponses.last ?? OpenClawChatHistoryPayload( - sessionKey: sessionKey, - sessionId: nil, - messages: [], - thinkingLevel: "off") - } - - func sendMessage( - sessionKey _: String, - message _: String, - thinking _: String, - idempotencyKey: String, - attachments _: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse - { - await self.state.sentRunIdsAppend(idempotencyKey) - return OpenClawChatSendResponse(runId: idempotencyKey, status: "ok") - } - - func abortRun(sessionKey _: String, runId: String) async throws { - await self.state.abortedRunIdsAppend(runId) - } - - func listSessions(limit _: Int?) async throws -> OpenClawChatSessionsListResponse { - let idx = await self.state.sessionsCallCount - await self.state.setSessionsCallCount(idx + 1) - if idx < self.sessionsResponses.count { - return self.sessionsResponses[idx] - } - return self.sessionsResponses.last ?? OpenClawChatSessionsListResponse( - ts: nil, - path: nil, - count: 0, - defaults: nil, - sessions: []) - } - - func requestHealth(timeoutMs _: Int) async throws -> Bool { - true - } - - func emit(_ evt: OpenClawChatTransportEvent) { - self.continuation.yield(evt) - } - - func lastSentRunId() async -> String? { - let ids = await self.state.sentRunIds - return ids.last - } - - func abortedRunIds() async -> [String] { - await self.state.abortedRunIds - } -} - -extension TestChatTransportState { - fileprivate func setHistoryCallCount(_ v: Int) { - self.historyCallCount = v - } - - fileprivate func setSessionsCallCount(_ v: Int) { - self.sessionsCallCount = v - } - - fileprivate func sentRunIdsAppend(_ v: String) { - self.sentRunIds.append(v) - } - - fileprivate func abortedRunIdsAppend(_ v: String) { - self.abortedRunIds.append(v) - } -} - -@Suite struct ChatViewModelTests { - @Test func streamsAssistantAndClearsOnFinal() async throws { - let sessionId = "sess-main" - let history1 = OpenClawChatHistoryPayload( - sessionKey: "main", - sessionId: sessionId, - messages: [], - thinkingLevel: "off") - let history2 = OpenClawChatHistoryPayload( - sessionKey: "main", - sessionId: sessionId, - messages: [ - AnyCodable([ - "role": "assistant", - "content": [["type": "text", "text": "final answer"]], - "timestamp": Date().timeIntervalSince1970 * 1000, - ]), - ], - thinkingLevel: "off") - - let transport = TestChatTransport(historyResponses: [history1, history2]) - let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) } - - await MainActor.run { vm.load() } - try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK && vm.sessionId == sessionId } } - - await MainActor.run { - vm.input = "hi" - vm.send() - } - try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } } - - transport.emit( - .agent( - OpenClawAgentEventPayload( - runId: sessionId, - seq: 1, - stream: "assistant", - ts: Int(Date().timeIntervalSince1970 * 1000), - data: ["text": AnyCodable("streaming…")]))) - - try await waitUntil("assistant stream visible") { - await MainActor.run { vm.streamingAssistantText == "streaming…" } - } - - transport.emit( - .agent( - OpenClawAgentEventPayload( - runId: sessionId, - seq: 2, - stream: "tool", - ts: Int(Date().timeIntervalSince1970 * 1000), - data: [ - "phase": AnyCodable("start"), - "name": AnyCodable("demo"), - "toolCallId": AnyCodable("t1"), - "args": AnyCodable(["x": 1]), - ]))) - - try await waitUntil("tool call pending") { await MainActor.run { vm.pendingToolCalls.count == 1 } } - - let runId = try #require(await transport.lastSentRunId()) - transport.emit( - .chat( - OpenClawChatEventPayload( - runId: runId, - sessionKey: "main", - state: "final", - message: nil, - errorMessage: nil))) - - try await waitUntil("pending run clears") { await MainActor.run { vm.pendingRunCount == 0 } } - try await waitUntil("history refresh") { - await MainActor.run { vm.messages.contains(where: { $0.role == "assistant" }) } - } - #expect(await MainActor.run { vm.streamingAssistantText } == nil) - #expect(await MainActor.run { vm.pendingToolCalls.isEmpty }) - } - - @Test func acceptsCanonicalSessionKeyEventsForOwnPendingRun() async throws { - let history1 = OpenClawChatHistoryPayload( - sessionKey: "main", - sessionId: "sess-main", - messages: [], - thinkingLevel: "off") - let history2 = OpenClawChatHistoryPayload( - sessionKey: "main", - sessionId: "sess-main", - messages: [ - AnyCodable([ - "role": "assistant", - "content": [["type": "text", "text": "from history"]], - "timestamp": Date().timeIntervalSince1970 * 1000, - ]), - ], - thinkingLevel: "off") - - let transport = TestChatTransport(historyResponses: [history1, history2]) - let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) } - - await MainActor.run { vm.load() } - try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK } } - - await MainActor.run { - vm.input = "hi" - vm.send() - } - try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } } - - let runId = try #require(await transport.lastSentRunId()) - transport.emit( - .chat( - OpenClawChatEventPayload( - runId: runId, - sessionKey: "agent:main:main", - state: "final", - message: nil, - errorMessage: nil))) - - try await waitUntil("pending run clears") { await MainActor.run { vm.pendingRunCount == 0 } } - try await waitUntil("history refresh") { - await MainActor.run { vm.messages.contains(where: { $0.role == "assistant" }) } - } - } - - @Test func acceptsCanonicalSessionKeyEventsForExternalRuns() async throws { - let now = Date().timeIntervalSince1970 * 1000 - let history1 = OpenClawChatHistoryPayload( - sessionKey: "main", - sessionId: "sess-main", - messages: [ - AnyCodable([ - "role": "user", - "content": [["type": "text", "text": "first"]], - "timestamp": now, - ]), - ], - thinkingLevel: "off") - let history2 = OpenClawChatHistoryPayload( - sessionKey: "main", - sessionId: "sess-main", - messages: [ - AnyCodable([ - "role": "user", - "content": [["type": "text", "text": "first"]], - "timestamp": now, - ]), - AnyCodable([ - "role": "assistant", - "content": [["type": "text", "text": "from external run"]], - "timestamp": now + 1, - ]), - ], - thinkingLevel: "off") - - let transport = TestChatTransport(historyResponses: [history1, history2]) - let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) } - - await MainActor.run { vm.load() } - try await waitUntil("bootstrap") { await MainActor.run { vm.messages.count == 1 } } - - transport.emit( - .chat( - OpenClawChatEventPayload( - runId: "external-run", - sessionKey: "agent:main:main", - state: "final", - message: nil, - errorMessage: nil))) - - try await waitUntil("history refresh after canonical external event") { - await MainActor.run { vm.messages.count == 2 } - } - } - - @Test func preservesMessageIDsAcrossHistoryRefreshes() async throws { - let now = Date().timeIntervalSince1970 * 1000 - let history1 = OpenClawChatHistoryPayload( - sessionKey: "main", - sessionId: "sess-main", - messages: [ - AnyCodable([ - "role": "user", - "content": [["type": "text", "text": "hello"]], - "timestamp": now, - ]), - ], - thinkingLevel: "off") - let history2 = OpenClawChatHistoryPayload( - sessionKey: "main", - sessionId: "sess-main", - messages: [ - AnyCodable([ - "role": "user", - "content": [["type": "text", "text": "hello"]], - "timestamp": now, - ]), - AnyCodable([ - "role": "assistant", - "content": [["type": "text", "text": "world"]], - "timestamp": now + 1, - ]), - ], - thinkingLevel: "off") - - let transport = TestChatTransport(historyResponses: [history1, history2]) - let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) } - - await MainActor.run { vm.load() } - try await waitUntil("bootstrap") { await MainActor.run { vm.messages.count == 1 } } - let firstIdBefore = try #require(await MainActor.run { vm.messages.first?.id }) - - transport.emit( - .chat( - OpenClawChatEventPayload( - runId: "other-run", - sessionKey: "main", - state: "final", - message: nil, - errorMessage: nil))) - - try await waitUntil("history refresh") { await MainActor.run { vm.messages.count == 2 } } - let firstIdAfter = try #require(await MainActor.run { vm.messages.first?.id }) - #expect(firstIdAfter == firstIdBefore) - } - - @Test func clearsStreamingOnExternalFinalEvent() async throws { - let sessionId = "sess-main" - let history = OpenClawChatHistoryPayload( - sessionKey: "main", - sessionId: sessionId, - messages: [], - thinkingLevel: "off") - let transport = TestChatTransport(historyResponses: [history, history]) - let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) } - - await MainActor.run { vm.load() } - try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK && vm.sessionId == sessionId } } - - transport.emit( - .agent( - OpenClawAgentEventPayload( - runId: sessionId, - seq: 1, - stream: "assistant", - ts: Int(Date().timeIntervalSince1970 * 1000), - data: ["text": AnyCodable("external stream")]))) - - transport.emit( - .agent( - OpenClawAgentEventPayload( - runId: sessionId, - seq: 2, - stream: "tool", - ts: Int(Date().timeIntervalSince1970 * 1000), - data: [ - "phase": AnyCodable("start"), - "name": AnyCodable("demo"), - "toolCallId": AnyCodable("t1"), - "args": AnyCodable(["x": 1]), - ]))) - - try await waitUntil("streaming active") { - await MainActor.run { vm.streamingAssistantText == "external stream" } - } - try await waitUntil("tool call pending") { await MainActor.run { vm.pendingToolCalls.count == 1 } } - - transport.emit( - .chat( - OpenClawChatEventPayload( - runId: "other-run", - sessionKey: "main", - state: "final", - message: nil, - errorMessage: nil))) - - try await waitUntil("streaming cleared") { await MainActor.run { vm.streamingAssistantText == nil } } - #expect(await MainActor.run { vm.pendingToolCalls.isEmpty }) - } - - @Test func seqGapClearsPendingRunsAndAutoRefreshesHistory() async throws { - let now = Date().timeIntervalSince1970 * 1000 - let history1 = OpenClawChatHistoryPayload( - sessionKey: "main", - sessionId: "sess-main", - messages: [], - thinkingLevel: "off") - let history2 = OpenClawChatHistoryPayload( - sessionKey: "main", - sessionId: "sess-main", - messages: [ - AnyCodable([ - "role": "assistant", - "content": [["type": "text", "text": "resynced after gap"]], - "timestamp": now, - ]), - ], - thinkingLevel: "off") - - let transport = TestChatTransport(historyResponses: [history1, history2]) - let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) } - - await MainActor.run { vm.load() } - try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK } } - - await MainActor.run { - vm.input = "hello" - vm.send() - } - try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } } - - transport.emit(.seqGap) - - try await waitUntil("pending run clears on seqGap") { - await MainActor.run { vm.pendingRunCount == 0 } - } - try await waitUntil("history refreshes on seqGap") { - await MainActor.run { vm.messages.contains(where: { $0.role == "assistant" }) } - } - #expect(await MainActor.run { vm.errorText == nil }) - } - - @Test func sessionChoicesPreferMainAndRecent() async throws { - let now = Date().timeIntervalSince1970 * 1000 - let recent = now - (2 * 60 * 60 * 1000) - let recentOlder = now - (5 * 60 * 60 * 1000) - let stale = now - (26 * 60 * 60 * 1000) - let history = OpenClawChatHistoryPayload( - sessionKey: "main", - sessionId: "sess-main", - messages: [], - thinkingLevel: "off") - let sessions = OpenClawChatSessionsListResponse( - ts: now, - path: nil, - count: 4, - defaults: nil, - sessions: [ - OpenClawChatSessionEntry( - key: "recent-1", - kind: nil, - displayName: nil, - surface: nil, - subject: nil, - room: nil, - space: nil, - updatedAt: recent, - sessionId: nil, - systemSent: nil, - abortedLastRun: nil, - thinkingLevel: nil, - verboseLevel: nil, - inputTokens: nil, - outputTokens: nil, - totalTokens: nil, - model: nil, - contextTokens: nil), - OpenClawChatSessionEntry( - key: "main", - kind: nil, - displayName: nil, - surface: nil, - subject: nil, - room: nil, - space: nil, - updatedAt: stale, - sessionId: nil, - systemSent: nil, - abortedLastRun: nil, - thinkingLevel: nil, - verboseLevel: nil, - inputTokens: nil, - outputTokens: nil, - totalTokens: nil, - model: nil, - contextTokens: nil), - OpenClawChatSessionEntry( - key: "recent-2", - kind: nil, - displayName: nil, - surface: nil, - subject: nil, - room: nil, - space: nil, - updatedAt: recentOlder, - sessionId: nil, - systemSent: nil, - abortedLastRun: nil, - thinkingLevel: nil, - verboseLevel: nil, - inputTokens: nil, - outputTokens: nil, - totalTokens: nil, - model: nil, - contextTokens: nil), - OpenClawChatSessionEntry( - key: "old-1", - kind: nil, - displayName: nil, - surface: nil, - subject: nil, - room: nil, - space: nil, - updatedAt: stale, - sessionId: nil, - systemSent: nil, - abortedLastRun: nil, - thinkingLevel: nil, - verboseLevel: nil, - inputTokens: nil, - outputTokens: nil, - totalTokens: nil, - model: nil, - contextTokens: nil), - ]) - - let transport = TestChatTransport( - historyResponses: [history], - sessionsResponses: [sessions]) - let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) } - await MainActor.run { vm.load() } - try await waitUntil("sessions loaded") { await MainActor.run { !vm.sessions.isEmpty } } - - let keys = await MainActor.run { vm.sessionChoices.map(\.key) } - #expect(keys == ["main", "recent-1", "recent-2"]) - } - - @Test func sessionChoicesIncludeCurrentWhenMissing() async throws { - let now = Date().timeIntervalSince1970 * 1000 - let recent = now - (30 * 60 * 1000) - let history = OpenClawChatHistoryPayload( - sessionKey: "custom", - sessionId: "sess-custom", - messages: [], - thinkingLevel: "off") - let sessions = OpenClawChatSessionsListResponse( - ts: now, - path: nil, - count: 1, - defaults: nil, - sessions: [ - OpenClawChatSessionEntry( - key: "main", - kind: nil, - displayName: nil, - surface: nil, - subject: nil, - room: nil, - space: nil, - updatedAt: recent, - sessionId: nil, - systemSent: nil, - abortedLastRun: nil, - thinkingLevel: nil, - verboseLevel: nil, - inputTokens: nil, - outputTokens: nil, - totalTokens: nil, - model: nil, - contextTokens: nil), - ]) - - let transport = TestChatTransport( - historyResponses: [history], - sessionsResponses: [sessions]) - let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "custom", transport: transport) } - await MainActor.run { vm.load() } - try await waitUntil("sessions loaded") { await MainActor.run { !vm.sessions.isEmpty } } - - let keys = await MainActor.run { vm.sessionChoices.map(\.key) } - #expect(keys == ["main", "custom"]) - } - - @Test func clearsStreamingOnExternalErrorEvent() async throws { - let sessionId = "sess-main" - let history = OpenClawChatHistoryPayload( - sessionKey: "main", - sessionId: sessionId, - messages: [], - thinkingLevel: "off") - let transport = TestChatTransport(historyResponses: [history, history]) - let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) } - - await MainActor.run { vm.load() } - try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK && vm.sessionId == sessionId } } - - transport.emit( - .agent( - OpenClawAgentEventPayload( - runId: sessionId, - seq: 1, - stream: "assistant", - ts: Int(Date().timeIntervalSince1970 * 1000), - data: ["text": AnyCodable("external stream")]))) - - try await waitUntil("streaming active") { - await MainActor.run { vm.streamingAssistantText == "external stream" } - } - - transport.emit( - .chat( - OpenClawChatEventPayload( - runId: "other-run", - sessionKey: "main", - state: "error", - message: nil, - errorMessage: "boom"))) - - try await waitUntil("streaming cleared") { await MainActor.run { vm.streamingAssistantText == nil } } - } - - @Test func stripsInboundMetadataFromHistoryMessages() async throws { - let history = OpenClawChatHistoryPayload( - sessionKey: "main", - sessionId: "sess-main", - messages: [ - AnyCodable([ - "role": "user", - "content": [["type": "text", "text": """ -Conversation info (untrusted metadata): -```json -{ \"sender\": \"openclaw-ios\" } -``` - -Hello? -"""]], - "timestamp": Date().timeIntervalSince1970 * 1000, - ]), - ], - thinkingLevel: "off") - let transport = TestChatTransport(historyResponses: [history]) - let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) } - - await MainActor.run { vm.load() } - try await waitUntil("history loaded") { await MainActor.run { !vm.messages.isEmpty } } - - let sanitized = await MainActor.run { vm.messages.first?.content.first?.text } - #expect(sanitized == "Hello?") - } - - @Test func abortRequestsDoNotClearPendingUntilAbortedEvent() async throws { - let sessionId = "sess-main" - let history = OpenClawChatHistoryPayload( - sessionKey: "main", - sessionId: sessionId, - messages: [], - thinkingLevel: "off") - let transport = TestChatTransport(historyResponses: [history, history]) - let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) } - - await MainActor.run { vm.load() } - try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK && vm.sessionId == sessionId } } - - await MainActor.run { - vm.input = "hi" - vm.send() - } - try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } } - - let runId = try #require(await transport.lastSentRunId()) - await MainActor.run { vm.abort() } - - try await waitUntil("abortRun called") { - let ids = await transport.abortedRunIds() - return ids == [runId] - } - - // Pending remains until the gateway broadcasts an aborted/final chat event. - #expect(await MainActor.run { vm.pendingRunCount } == 1) - - transport.emit( - .chat( - OpenClawChatEventPayload( - runId: runId, - sessionKey: "main", - state: "aborted", - message: nil, - errorMessage: nil))) - - try await waitUntil("pending run clears") { await MainActor.run { vm.pendingRunCount == 0 } } - } -} diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift deleted file mode 100644 index 8bbf4f8a650..00000000000 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift +++ /dev/null @@ -1,61 +0,0 @@ -import Foundation -import OpenClawKit -import Testing - -@Suite struct DeepLinksSecurityTests { - @Test func gatewayDeepLinkRejectsInsecureNonLoopbackWs() { - let url = URL( - string: "openclaw://gateway?host=attacker.example&port=18789&tls=0&token=abc")! - #expect(DeepLinkParser.parse(url) == nil) - } - - @Test func gatewayDeepLinkRejectsInsecurePrefixBypassHost() { - let url = URL( - string: "openclaw://gateway?host=127.attacker.example&port=18789&tls=0&token=abc")! - #expect(DeepLinkParser.parse(url) == nil) - } - - @Test func gatewayDeepLinkAllowsLoopbackWs() { - let url = URL( - string: "openclaw://gateway?host=127.0.0.1&port=18789&tls=0&token=abc")! - #expect( - DeepLinkParser.parse(url) == .gateway( - .init(host: "127.0.0.1", port: 18789, tls: false, token: "abc", password: nil))) - } - - @Test func setupCodeRejectsInsecureNonLoopbackWs() { - let payload = #"{"url":"ws://attacker.example:18789","token":"tok"}"# - let encoded = Data(payload.utf8) - .base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - #expect(GatewayConnectDeepLink.fromSetupCode(encoded) == nil) - } - - @Test func setupCodeRejectsInsecurePrefixBypassHost() { - let payload = #"{"url":"ws://127.attacker.example:18789","token":"tok"}"# - let encoded = Data(payload.utf8) - .base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - #expect(GatewayConnectDeepLink.fromSetupCode(encoded) == nil) - } - - @Test func setupCodeAllowsLoopbackWs() { - let payload = #"{"url":"ws://127.0.0.1:18789","token":"tok"}"# - let encoded = Data(payload.utf8) - .base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - #expect( - GatewayConnectDeepLink.fromSetupCode(encoded) == .init( - host: "127.0.0.1", - port: 18789, - tls: false, - token: "tok", - password: nil)) - } -} diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ElevenLabsTTSValidationTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ElevenLabsTTSValidationTests.swift deleted file mode 100644 index 1d672db353f..00000000000 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ElevenLabsTTSValidationTests.swift +++ /dev/null @@ -1,19 +0,0 @@ -import XCTest -@testable import OpenClawKit - -final class ElevenLabsTTSValidationTests: XCTestCase { - func testValidatedOutputFormatAllowsOnlyMp3Presets() { - XCTAssertEqual(ElevenLabsTTSClient.validatedOutputFormat("mp3_44100_128"), "mp3_44100_128") - XCTAssertEqual(ElevenLabsTTSClient.validatedOutputFormat("pcm_16000"), "pcm_16000") - } - - func testValidatedLanguageAcceptsTwoLetterCodes() { - XCTAssertEqual(ElevenLabsTTSClient.validatedLanguage("EN"), "en") - XCTAssertNil(ElevenLabsTTSClient.validatedLanguage("eng")) - } - - func testValidatedNormalizeAcceptsKnownValues() { - XCTAssertEqual(ElevenLabsTTSClient.validatedNormalize("AUTO"), "auto") - XCTAssertNil(ElevenLabsTTSClient.validatedNormalize("maybe")) - } -} diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift deleted file mode 100644 index 08a6ea2162a..00000000000 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift +++ /dev/null @@ -1,284 +0,0 @@ -import Foundation -import Testing -@testable import OpenClawKit -import OpenClawProtocol - -private struct TimeoutError: Error, CustomStringConvertible { - let label: String - var description: String { "Timeout waiting for: \(self.label)" } -} - -private func waitUntil( - _ label: String, - timeoutSeconds: Double = 3.0, - pollMs: UInt64 = 10, - _ condition: @escaping @Sendable () async -> Bool) async throws -{ - let deadline = Date().addingTimeInterval(timeoutSeconds) - while Date() < deadline { - if await condition() { - return - } - try await Task.sleep(nanoseconds: pollMs * 1_000_000) - } - throw TimeoutError(label: label) -} - -private extension NSLock { - func withLock(_ body: () -> T) -> T { - self.lock() - defer { self.unlock() } - return body() - } -} - -private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Sendable { - private let lock = NSLock() - private var _state: URLSessionTask.State = .suspended - private var connectRequestId: String? - private var receivePhase = 0 - private var pendingReceiveHandler: - (@Sendable (Result) -> Void)? - - var state: URLSessionTask.State { - get { self.lock.withLock { self._state } } - set { self.lock.withLock { self._state = newValue } } - } - - func resume() { - self.state = .running - } - - func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { - _ = (closeCode, reason) - self.state = .canceling - let handler = self.lock.withLock { () -> (@Sendable (Result) -> Void)? in - defer { self.pendingReceiveHandler = nil } - return self.pendingReceiveHandler - } - handler?(Result.failure(URLError(.cancelled))) - } - - func send(_ message: URLSessionWebSocketTask.Message) async throws { - let data: Data? = switch message { - case let .data(d): d - case let .string(s): s.data(using: .utf8) - @unknown default: nil - } - guard let data else { return } - if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - obj["type"] as? String == "req", - obj["method"] as? String == "connect", - let id = obj["id"] as? String - { - self.lock.withLock { self.connectRequestId = id } - } - } - - func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) { - pongReceiveHandler(nil) - } - - func receive() async throws -> URLSessionWebSocketTask.Message { - let phase = self.lock.withLock { () -> Int in - let current = self.receivePhase - self.receivePhase += 1 - return current - } - if phase == 0 { - return .data(Self.connectChallengeData(nonce: "nonce-1")) - } - for _ in 0..<50 { - let id = self.lock.withLock { self.connectRequestId } - if let id { - return .data(Self.connectOkData(id: id)) - } - try await Task.sleep(nanoseconds: 1_000_000) - } - return .data(Self.connectOkData(id: "connect")) - } - - func receive( - completionHandler: @escaping @Sendable (Result) -> Void) - { - self.lock.withLock { self.pendingReceiveHandler = completionHandler } - } - - func emitReceiveFailure() { - let handler = self.lock.withLock { () -> (@Sendable (Result) -> Void)? in - self._state = .canceling - defer { self.pendingReceiveHandler = nil } - return self.pendingReceiveHandler - } - handler?(Result.failure(URLError(.networkConnectionLost))) - } - - private static func connectChallengeData(nonce: String) -> Data { - let json = """ - { - "type": "event", - "event": "connect.challenge", - "payload": { "nonce": "\(nonce)" } - } - """ - return Data(json.utf8) - } - - private static func connectOkData(id: String) -> Data { - let json = """ - { - "type": "res", - "id": "\(id)", - "ok": true, - "payload": { - "type": "hello-ok", - "protocol": 2, - "server": { "version": "test", "connId": "test" }, - "features": { "methods": [], "events": [] }, - "snapshot": { - "presence": [ { "ts": 1 } ], - "health": {}, - "stateVersion": { "presence": 0, "health": 0 }, - "uptimeMs": 0 - }, - "policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 } - } - } - """ - return Data(json.utf8) - } -} - -private final class FakeGatewayWebSocketSession: WebSocketSessioning, @unchecked Sendable { - private let lock = NSLock() - private var tasks: [FakeGatewayWebSocketTask] = [] - private var makeCount = 0 - - func snapshotMakeCount() -> Int { - self.lock.withLock { self.makeCount } - } - - func latestTask() -> FakeGatewayWebSocketTask? { - self.lock.withLock { self.tasks.last } - } - - func makeWebSocketTask(url: URL) -> WebSocketTaskBox { - _ = url - return self.lock.withLock { - self.makeCount += 1 - let task = FakeGatewayWebSocketTask() - self.tasks.append(task) - return WebSocketTaskBox(task: task) - } - } -} - -private actor SeqGapProbe { - private var saw = false - func mark() { self.saw = true } - func value() -> Bool { self.saw } -} - -struct GatewayNodeSessionTests { - @Test - func invokeWithTimeoutReturnsUnderlyingResponseBeforeTimeout() async { - let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil) - let response = await GatewayNodeSession.invokeWithTimeout( - request: request, - timeoutMs: 50, - onInvoke: { req in - #expect(req.id == "1") - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: "{}", error: nil) - } - ) - - #expect(response.ok == true) - #expect(response.error == nil) - #expect(response.payloadJSON == "{}") - } - - @Test - func invokeWithTimeoutReturnsTimeoutError() async { - let request = BridgeInvokeRequest(id: "abc", command: "x", paramsJSON: nil) - let response = await GatewayNodeSession.invokeWithTimeout( - request: request, - timeoutMs: 10, - onInvoke: { _ in - try? await Task.sleep(nanoseconds: 200_000_000) // 200ms - return BridgeInvokeResponse(id: "abc", ok: true, payloadJSON: "{}", error: nil) - } - ) - - #expect(response.ok == false) - #expect(response.error?.code == .unavailable) - #expect(response.error?.message.contains("timed out") == true) - } - - @Test - func invokeWithTimeoutZeroDisablesTimeout() async { - let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil) - let response = await GatewayNodeSession.invokeWithTimeout( - request: request, - timeoutMs: 0, - onInvoke: { req in - try? await Task.sleep(nanoseconds: 5_000_000) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) - } - ) - - #expect(response.ok == true) - #expect(response.error == nil) - } - - @Test - func emitsSyntheticSeqGapAfterReconnectSnapshot() async throws { - let session = FakeGatewayWebSocketSession() - let gateway = GatewayNodeSession() - let options = GatewayConnectOptions( - role: "operator", - scopes: ["operator.read"], - caps: [], - commands: [], - permissions: [:], - clientId: "openclaw-ios-test", - clientMode: "ui", - clientDisplayName: "iOS Test", - includeDeviceIdentity: false) - - let stream = await gateway.subscribeServerEvents(bufferingNewest: 32) - let probe = SeqGapProbe() - let listenTask = Task { - for await evt in stream { - if evt.event == "seqGap" { - await probe.mark() - return - } - } - } - - try await gateway.connect( - url: URL(string: "ws://example.invalid")!, - token: nil, - password: nil, - connectOptions: options, - sessionBox: WebSocketSessionBox(session: session), - onConnected: {}, - onDisconnected: { _ in }, - onInvoke: { req in - BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) - }) - - let firstTask = try #require(session.latestTask()) - firstTask.emitReceiveFailure() - - try await waitUntil("reconnect socket created") { - session.snapshotMakeCount() >= 2 - } - try await waitUntil("synthetic seqGap broadcast") { - await probe.value() - } - - listenTask.cancel() - await gateway.disconnect() - } -} diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/JPEGTranscoderTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/JPEGTranscoderTests.swift deleted file mode 100644 index 5070a8b14e0..00000000000 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/JPEGTranscoderTests.swift +++ /dev/null @@ -1,129 +0,0 @@ -import OpenClawKit -import CoreGraphics -import ImageIO -import Testing -import UniformTypeIdentifiers - -@Suite struct JPEGTranscoderTests { - private func makeSolidJPEG(width: Int, height: Int, orientation: Int? = nil) throws -> Data { - let cs = CGColorSpaceCreateDeviceRGB() - let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue - guard - let ctx = CGContext( - data: nil, - width: width, - height: height, - bitsPerComponent: 8, - bytesPerRow: 0, - space: cs, - bitmapInfo: bitmapInfo) - else { - throw NSError(domain: "JPEGTranscoderTests", code: 1) - } - - ctx.setFillColor(red: 1, green: 0, blue: 0, alpha: 1) - ctx.fill(CGRect(x: 0, y: 0, width: width, height: height)) - guard let img = ctx.makeImage() else { - throw NSError(domain: "JPEGTranscoderTests", code: 5) - } - - let out = NSMutableData() - guard let dest = CGImageDestinationCreateWithData(out, UTType.jpeg.identifier as CFString, 1, nil) else { - throw NSError(domain: "JPEGTranscoderTests", code: 2) - } - - var props: [CFString: Any] = [ - kCGImageDestinationLossyCompressionQuality: 1.0, - ] - if let orientation { - props[kCGImagePropertyOrientation] = orientation - } - - CGImageDestinationAddImage(dest, img, props as CFDictionary) - guard CGImageDestinationFinalize(dest) else { - throw NSError(domain: "JPEGTranscoderTests", code: 3) - } - - return out as Data - } - - private func makeNoiseJPEG(width: Int, height: Int) throws -> Data { - let bytesPerPixel = 4 - let byteCount = width * height * bytesPerPixel - var data = Data(count: byteCount) - let cs = CGColorSpaceCreateDeviceRGB() - let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue - - let out = try data.withUnsafeMutableBytes { rawBuffer -> Data in - guard let base = rawBuffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - throw NSError(domain: "JPEGTranscoderTests", code: 6) - } - for idx in 0.. 0) - } - - @Test func doesNotUpscaleWhenSmallerThanMaxWidthPx() throws { - let input = try makeSolidJPEG(width: 800, height: 600) - let out = try JPEGTranscoder.transcodeToJPEG(imageData: input, maxWidthPx: 1600, quality: 0.9) - #expect(out.widthPx == 800) - #expect(out.heightPx == 600) - } - - @Test func normalizesOrientationAndUsesOrientedWidthForMaxWidthPx() throws { - // Encode a landscape image but mark it rotated 90° (orientation 6). Oriented width becomes 1000. - let input = try makeSolidJPEG(width: 2000, height: 1000, orientation: 6) - let out = try JPEGTranscoder.transcodeToJPEG(imageData: input, maxWidthPx: 1600, quality: 0.9) - #expect(out.widthPx == 1000) - #expect(out.heightPx == 2000) - } - - @Test func respectsMaxBytes() throws { - let input = try makeNoiseJPEG(width: 1600, height: 1200) - let out = try JPEGTranscoder.transcodeToJPEG( - imageData: input, - maxWidthPx: 1600, - quality: 0.95, - maxBytes: 180_000) - #expect(out.data.count <= 180_000) - } -} diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkDirectiveTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkDirectiveTests.swift deleted file mode 100644 index 11565ac7448..00000000000 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkDirectiveTests.swift +++ /dev/null @@ -1,74 +0,0 @@ -import XCTest -@testable import OpenClawKit - -final class TalkDirectiveTests: XCTestCase { - func testParsesDirectiveAndStripsLine() { - let text = """ - {"voice":"abc123","once":true} - Hello there. - """ - let result = TalkDirectiveParser.parse(text) - XCTAssertEqual(result.directive?.voiceId, "abc123") - XCTAssertEqual(result.directive?.once, true) - XCTAssertEqual(result.stripped, "Hello there.") - } - - func testIgnoresNonDirective() { - let text = "Hello world." - let result = TalkDirectiveParser.parse(text) - XCTAssertNil(result.directive) - XCTAssertEqual(result.stripped, text) - } - - func testKeepsDirectiveLineIfNoRecognizedFields() { - let text = """ - {"unknown":"value"} - Hello. - """ - let result = TalkDirectiveParser.parse(text) - XCTAssertNil(result.directive) - XCTAssertEqual(result.stripped, text) - } - - func testParsesExtendedOptions() { - let text = """ - {"voice_id":"v1","model_id":"m1","rate":200,"stability":0.5,"similarity":0.8,"style":0.2,"speaker_boost":true,"seed":1234,"normalize":"auto","lang":"en","output_format":"mp3_44100_128"} - Hello. - """ - let result = TalkDirectiveParser.parse(text) - XCTAssertEqual(result.directive?.voiceId, "v1") - XCTAssertEqual(result.directive?.modelId, "m1") - XCTAssertEqual(result.directive?.rateWPM, 200) - XCTAssertEqual(result.directive?.stability, 0.5) - XCTAssertEqual(result.directive?.similarity, 0.8) - XCTAssertEqual(result.directive?.style, 0.2) - XCTAssertEqual(result.directive?.speakerBoost, true) - XCTAssertEqual(result.directive?.seed, 1234) - XCTAssertEqual(result.directive?.normalize, "auto") - XCTAssertEqual(result.directive?.language, "en") - XCTAssertEqual(result.directive?.outputFormat, "mp3_44100_128") - XCTAssertEqual(result.stripped, "Hello.") - } - - func testSkipsLeadingEmptyLinesWhenParsingDirective() { - let text = """ - - - {"voice":"abc123"} - Hello there. - """ - let result = TalkDirectiveParser.parse(text) - XCTAssertEqual(result.directive?.voiceId, "abc123") - XCTAssertEqual(result.stripped, "Hello there.") - } - - func testTracksUnknownKeys() { - let text = """ - {"voice":"abc","mystery":"value","extra":1} - Hi. - """ - let result = TalkDirectiveParser.parse(text) - XCTAssertEqual(result.directive?.voiceId, "abc") - XCTAssertEqual(result.unknownKeys, ["extra", "mystery"]) - } -} diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkHistoryTimestampTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkHistoryTimestampTests.swift deleted file mode 100644 index e66c4e1e9ca..00000000000 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkHistoryTimestampTests.swift +++ /dev/null @@ -1,16 +0,0 @@ -import XCTest -@testable import OpenClawKit - -final class TalkHistoryTimestampTests: XCTestCase { - func testSecondsTimestampsAreAcceptedWithSmallTolerance() { - XCTAssertTrue(TalkHistoryTimestamp.isAfter(999.6, sinceSeconds: 1000)) - XCTAssertFalse(TalkHistoryTimestamp.isAfter(999.4, sinceSeconds: 1000)) - } - - func testMillisecondsTimestampsAreAcceptedWithSmallTolerance() { - let sinceSeconds = 1_700_000_000.0 - let sinceMs = sinceSeconds * 1000 - XCTAssertTrue(TalkHistoryTimestamp.isAfter(sinceMs - 500, sinceSeconds: sinceSeconds)) - XCTAssertFalse(TalkHistoryTimestamp.isAfter(sinceMs - 501, sinceSeconds: sinceSeconds)) - } -} diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkPromptBuilderTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkPromptBuilderTests.swift deleted file mode 100644 index 513b60d047a..00000000000 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkPromptBuilderTests.swift +++ /dev/null @@ -1,29 +0,0 @@ -import XCTest -@testable import OpenClawKit - -final class TalkPromptBuilderTests: XCTestCase { - func testBuildIncludesTranscript() { - let prompt = TalkPromptBuilder.build(transcript: "Hello", interruptedAtSeconds: nil) - XCTAssertTrue(prompt.contains("Talk Mode active.")) - XCTAssertTrue(prompt.hasSuffix("\n\nHello")) - } - - func testBuildIncludesInterruptionLineWhenProvided() { - let prompt = TalkPromptBuilder.build(transcript: "Hi", interruptedAtSeconds: 1.234) - XCTAssertTrue(prompt.contains("Assistant speech interrupted at 1.2s.")) - } - - func testBuildIncludesVoiceDirectiveHintByDefault() { - let prompt = TalkPromptBuilder.build(transcript: "Hello", interruptedAtSeconds: nil) - XCTAssertTrue(prompt.contains("ElevenLabs voice")) - } - - func testBuildExcludesVoiceDirectiveHintWhenDisabled() { - let prompt = TalkPromptBuilder.build( - transcript: "Hello", - interruptedAtSeconds: nil, - includeVoiceDirectiveHint: false) - XCTAssertFalse(prompt.contains("ElevenLabs voice")) - XCTAssertTrue(prompt.contains("Talk Mode active.")) - } -} diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ToolDisplayRegistryTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ToolDisplayRegistryTests.swift deleted file mode 100644 index dbf38138a4b..00000000000 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ToolDisplayRegistryTests.swift +++ /dev/null @@ -1,16 +0,0 @@ -import OpenClawKit -import Foundation -import Testing - -@Suite struct ToolDisplayRegistryTests { - @Test func loadsToolDisplayConfigFromBundle() { - let url = OpenClawKitResources.bundle.url(forResource: "tool-display", withExtension: "json") - #expect(url != nil) - } - - @Test func resolvesKnownToolFromConfig() { - let summary = ToolDisplayRegistry.resolve(name: "bash", args: nil) - #expect(summary.emoji == "🛠️") - #expect(summary.title == "Bash") - } -} diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ToolResultTextFormatterTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ToolResultTextFormatterTests.swift deleted file mode 100644 index 1688725c850..00000000000 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ToolResultTextFormatterTests.swift +++ /dev/null @@ -1,54 +0,0 @@ -import Testing -@testable import OpenClawChatUI - -@Suite("ToolResultTextFormatter") -struct ToolResultTextFormatterTests { - @Test func leavesPlainTextUntouched() { - let result = ToolResultTextFormatter.format(text: "All good", toolName: "nodes") - #expect(result == "All good") - } - - @Test func summarizesNodesListJSON() { - let json = """ - { - "ts": 1771610031380, - "nodes": [ - { - "displayName": "iPhone 16 Pro Max", - "connected": true, - "platform": "ios" - } - ] - } - """ - - let result = ToolResultTextFormatter.format(text: json, toolName: "nodes") - #expect(result.contains("1 node found.")) - #expect(result.contains("iPhone 16 Pro Max")) - #expect(result.contains("connected")) - } - - @Test func summarizesErrorJSONAndDropsAgentPrefix() { - let json = """ - { - "status": "error", - "tool": "nodes", - "error": "agent=main node=iPhone gateway=default action=invoke: pairing required" - } - """ - - let result = ToolResultTextFormatter.format(text: json, toolName: "nodes") - #expect(result == "Error: pairing required") - } - - @Test func suppressesUnknownStructuredPayload() { - let json = """ - { - "foo": "bar" - } - """ - - let result = ToolResultTextFormatter.format(text: json, toolName: "nodes") - #expect(result.isEmpty) - } -} diff --git a/apps/shared/OpenClawKit/Tools/CanvasA2UI/bootstrap.js b/apps/shared/OpenClawKit/Tools/CanvasA2UI/bootstrap.js deleted file mode 100644 index a9cb659876a..00000000000 --- a/apps/shared/OpenClawKit/Tools/CanvasA2UI/bootstrap.js +++ /dev/null @@ -1,489 +0,0 @@ -import { html, css, LitElement, unsafeCSS } from "lit"; -import { repeat } from "lit/directives/repeat.js"; -import { ContextProvider } from "@lit/context"; - -import { v0_8 } from "@a2ui/lit"; -import "@a2ui/lit/ui"; -import { themeContext } from "@openclaw/a2ui-theme-context"; - -const modalStyles = css` - dialog { - position: fixed; - inset: 0; - width: 100%; - height: 100%; - margin: 0; - padding: 24px; - border: none; - background: rgba(5, 8, 16, 0.65); - backdrop-filter: blur(6px); - display: grid; - place-items: center; - } - - dialog::backdrop { - background: rgba(5, 8, 16, 0.65); - backdrop-filter: blur(6px); - } -`; - -const modalElement = customElements.get("a2ui-modal"); -if (modalElement && Array.isArray(modalElement.styles)) { - modalElement.styles = [...modalElement.styles, modalStyles]; -} - -const emptyClasses = () => ({}); -const textHintStyles = () => ({ h1: {}, h2: {}, h3: {}, h4: {}, h5: {}, body: {}, caption: {} }); - -const isAndroid = /Android/i.test(globalThis.navigator?.userAgent ?? ""); -const cardShadow = isAndroid ? "0 2px 10px rgba(0,0,0,.18)" : "0 10px 30px rgba(0,0,0,.35)"; -const buttonShadow = isAndroid ? "0 2px 10px rgba(6, 182, 212, 0.14)" : "0 10px 25px rgba(6, 182, 212, 0.18)"; -const statusShadow = isAndroid ? "0 2px 10px rgba(0, 0, 0, 0.18)" : "0 10px 24px rgba(0, 0, 0, 0.25)"; -const statusBlur = isAndroid ? "10px" : "14px"; - -const openclawTheme = { - components: { - AudioPlayer: emptyClasses(), - Button: emptyClasses(), - Card: emptyClasses(), - Column: emptyClasses(), - CheckBox: { container: emptyClasses(), element: emptyClasses(), label: emptyClasses() }, - DateTimeInput: { container: emptyClasses(), element: emptyClasses(), label: emptyClasses() }, - Divider: emptyClasses(), - Image: { - all: emptyClasses(), - icon: emptyClasses(), - avatar: emptyClasses(), - smallFeature: emptyClasses(), - mediumFeature: emptyClasses(), - largeFeature: emptyClasses(), - header: emptyClasses(), - }, - Icon: emptyClasses(), - List: emptyClasses(), - Modal: { backdrop: emptyClasses(), element: emptyClasses() }, - MultipleChoice: { container: emptyClasses(), element: emptyClasses(), label: emptyClasses() }, - Row: emptyClasses(), - Slider: { container: emptyClasses(), element: emptyClasses(), label: emptyClasses() }, - Tabs: { container: emptyClasses(), element: emptyClasses(), controls: { all: emptyClasses(), selected: emptyClasses() } }, - Text: { - all: emptyClasses(), - h1: emptyClasses(), - h2: emptyClasses(), - h3: emptyClasses(), - h4: emptyClasses(), - h5: emptyClasses(), - caption: emptyClasses(), - body: emptyClasses(), - }, - TextField: { container: emptyClasses(), element: emptyClasses(), label: emptyClasses() }, - Video: emptyClasses(), - }, - elements: { - a: emptyClasses(), - audio: emptyClasses(), - body: emptyClasses(), - button: emptyClasses(), - h1: emptyClasses(), - h2: emptyClasses(), - h3: emptyClasses(), - h4: emptyClasses(), - h5: emptyClasses(), - iframe: emptyClasses(), - input: emptyClasses(), - p: emptyClasses(), - pre: emptyClasses(), - textarea: emptyClasses(), - video: emptyClasses(), - }, - markdown: { - p: [], - h1: [], - h2: [], - h3: [], - h4: [], - h5: [], - ul: [], - ol: [], - li: [], - a: [], - strong: [], - em: [], - }, - additionalStyles: { - Card: { - background: "linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.03))", - border: "1px solid rgba(255,255,255,.09)", - borderRadius: "14px", - padding: "14px", - boxShadow: cardShadow, - }, - Modal: { - background: "rgba(12, 16, 24, 0.92)", - border: "1px solid rgba(255,255,255,.12)", - borderRadius: "16px", - padding: "16px", - boxShadow: "0 30px 80px rgba(0,0,0,.6)", - width: "min(520px, calc(100vw - 48px))", - }, - Column: { gap: "10px" }, - Row: { gap: "10px", alignItems: "center" }, - Divider: { opacity: "0.25" }, - Button: { - background: "linear-gradient(135deg, #22c55e 0%, #06b6d4 100%)", - border: "0", - borderRadius: "12px", - padding: "10px 14px", - color: "#071016", - fontWeight: "650", - cursor: "pointer", - boxShadow: buttonShadow, - }, - Text: { - ...textHintStyles(), - h1: { fontSize: "20px", fontWeight: "750", margin: "0 0 6px 0" }, - h2: { fontSize: "16px", fontWeight: "700", margin: "0 0 6px 0" }, - body: { fontSize: "13px", lineHeight: "1.4" }, - caption: { opacity: "0.8" }, - }, - TextField: { display: "grid", gap: "6px" }, - Image: { borderRadius: "12px" }, - }, -}; - -class OpenClawA2UIHost extends LitElement { - static properties = { - surfaces: { state: true }, - pendingAction: { state: true }, - toast: { state: true }, - }; - - #processor = v0_8.Data.createSignalA2uiMessageProcessor(); - themeProvider = new ContextProvider(this, { - context: themeContext, - initialValue: openclawTheme, - }); - - surfaces = []; - pendingAction = null; - toast = null; - #statusListener = null; - - static styles = css` - :host { - display: block; - height: 100%; - position: relative; - box-sizing: border-box; - padding: - var(--openclaw-a2ui-inset-top, 0px) - var(--openclaw-a2ui-inset-right, 0px) - var(--openclaw-a2ui-inset-bottom, 0px) - var(--openclaw-a2ui-inset-left, 0px); - } - - #surfaces { - display: grid; - grid-template-columns: 1fr; - gap: 12px; - height: 100%; - overflow: auto; - padding-bottom: var(--openclaw-a2ui-scroll-pad-bottom, 0px); - } - - .status { - position: absolute; - left: 50%; - transform: translateX(-50%); - top: var(--openclaw-a2ui-status-top, 12px); - display: inline-flex; - align-items: center; - gap: 8px; - padding: 8px 10px; - border-radius: 12px; - background: rgba(0, 0, 0, 0.45); - border: 1px solid rgba(255, 255, 255, 0.18); - color: rgba(255, 255, 255, 0.92); - font: 13px/1.2 system-ui, -apple-system, BlinkMacSystemFont, "Roboto", sans-serif; - pointer-events: none; - backdrop-filter: blur(${unsafeCSS(statusBlur)}); - -webkit-backdrop-filter: blur(${unsafeCSS(statusBlur)}); - box-shadow: ${unsafeCSS(statusShadow)}; - z-index: 5; - } - - .toast { - position: absolute; - left: 50%; - transform: translateX(-50%); - bottom: var(--openclaw-a2ui-toast-bottom, 12px); - display: inline-flex; - align-items: center; - gap: 8px; - padding: 8px 10px; - border-radius: 12px; - background: rgba(0, 0, 0, 0.45); - border: 1px solid rgba(255, 255, 255, 0.18); - color: rgba(255, 255, 255, 0.92); - font: 13px/1.2 system-ui, -apple-system, BlinkMacSystemFont, "Roboto", sans-serif; - pointer-events: none; - backdrop-filter: blur(${unsafeCSS(statusBlur)}); - -webkit-backdrop-filter: blur(${unsafeCSS(statusBlur)}); - box-shadow: ${unsafeCSS(statusShadow)}; - z-index: 5; - } - - .toast.error { - border-color: rgba(255, 109, 109, 0.35); - color: rgba(255, 223, 223, 0.98); - } - - .empty { - position: absolute; - left: 50%; - transform: translateX(-50%); - top: var(--openclaw-a2ui-empty-top, var(--openclaw-a2ui-status-top, 12px)); - text-align: center; - opacity: 0.8; - padding: 10px 12px; - pointer-events: none; - } - - .empty-title { - font-weight: 700; - margin-bottom: 6px; - } - - .spinner { - width: 12px; - height: 12px; - border-radius: 999px; - border: 2px solid rgba(255, 255, 255, 0.25); - border-top-color: rgba(255, 255, 255, 0.92); - animation: spin 0.75s linear infinite; - } - - @keyframes spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } - } - `; - - connectedCallback() { - super.connectedCallback(); - const api = { - applyMessages: (messages) => this.applyMessages(messages), - reset: () => this.reset(), - getSurfaces: () => Array.from(this.#processor.getSurfaces().keys()), - }; - globalThis.openclawA2UI = api; - this.addEventListener("a2uiaction", (evt) => this.#handleA2UIAction(evt)); - this.#statusListener = (evt) => this.#handleActionStatus(evt); - for (const eventName of ["openclaw:a2ui-action-status"]) { - globalThis.addEventListener(eventName, this.#statusListener); - } - this.#syncSurfaces(); - } - - disconnectedCallback() { - super.disconnectedCallback(); - if (this.#statusListener) { - for (const eventName of ["openclaw:a2ui-action-status"]) { - globalThis.removeEventListener(eventName, this.#statusListener); - } - this.#statusListener = null; - } - } - - #makeActionId() { - return globalThis.crypto?.randomUUID?.() ?? `a2ui_${Date.now()}_${Math.random().toString(16).slice(2)}`; - } - - #setToast(text, kind = "ok", timeoutMs = 1400) { - const toast = { text, kind, expiresAt: Date.now() + timeoutMs }; - this.toast = toast; - this.requestUpdate(); - setTimeout(() => { - if (this.toast === toast) { - this.toast = null; - this.requestUpdate(); - } - }, timeoutMs + 30); - } - - #handleActionStatus(evt) { - const detail = evt?.detail ?? null; - if (!detail || typeof detail.id !== "string") {return;} - if (!this.pendingAction || this.pendingAction.id !== detail.id) {return;} - - if (detail.ok) { - this.pendingAction = { ...this.pendingAction, phase: "sent", sentAt: Date.now() }; - } else { - const msg = typeof detail.error === "string" && detail.error ? detail.error : "send failed"; - this.pendingAction = { ...this.pendingAction, phase: "error", error: msg }; - this.#setToast(`Failed: ${msg}`, "error", 4500); - } - this.requestUpdate(); - } - - #handleA2UIAction(evt) { - const payload = evt?.detail ?? evt?.payload ?? null; - if (!payload || payload.eventType !== "a2ui.action") { - return; - } - - const action = payload.action; - const name = action?.name; - if (!name) { - return; - } - - const sourceComponentId = payload.sourceComponentId ?? ""; - const surfaces = this.#processor.getSurfaces(); - - let surfaceId = null; - let sourceNode = null; - for (const [sid, surface] of surfaces.entries()) { - const node = surface?.components?.get?.(sourceComponentId) ?? null; - if (node) { - surfaceId = sid; - sourceNode = node; - break; - } - } - - const context = {}; - const ctxItems = Array.isArray(action?.context) ? action.context : []; - for (const item of ctxItems) { - const key = item?.key; - const value = item?.value ?? null; - if (!key || !value) {continue;} - - if (typeof value.path === "string") { - const resolved = sourceNode - ? this.#processor.getData(sourceNode, value.path, surfaceId ?? undefined) - : null; - context[key] = resolved; - continue; - } - if (Object.prototype.hasOwnProperty.call(value, "literalString")) { - context[key] = value.literalString ?? ""; - continue; - } - if (Object.prototype.hasOwnProperty.call(value, "literalNumber")) { - context[key] = value.literalNumber ?? 0; - continue; - } - if (Object.prototype.hasOwnProperty.call(value, "literalBoolean")) { - context[key] = value.literalBoolean ?? false; - continue; - } - } - - const actionId = this.#makeActionId(); - this.pendingAction = { id: actionId, name, phase: "sending", startedAt: Date.now() }; - this.requestUpdate(); - - const userAction = { - id: actionId, - name, - surfaceId: surfaceId ?? "main", - sourceComponentId, - timestamp: new Date().toISOString(), - ...(Object.keys(context).length ? { context } : {}), - }; - - globalThis.__openclawLastA2UIAction = userAction; - - const handler = - globalThis.webkit?.messageHandlers?.openclawCanvasA2UIAction ?? - globalThis.openclawCanvasA2UIAction; - if (handler?.postMessage) { - try { - // WebKit message handlers support structured objects; Android's JS interface expects strings. - if (handler === globalThis.openclawCanvasA2UIAction) { - handler.postMessage(JSON.stringify({ userAction })); - } else { - handler.postMessage({ userAction }); - } - } catch (e) { - const msg = String(e?.message ?? e); - this.pendingAction = { id: actionId, name, phase: "error", startedAt: Date.now(), error: msg }; - this.#setToast(`Failed: ${msg}`, "error", 4500); - } - } else { - this.pendingAction = { id: actionId, name, phase: "error", startedAt: Date.now(), error: "missing native bridge" }; - this.#setToast("Failed: missing native bridge", "error", 4500); - } - } - - applyMessages(messages) { - if (!Array.isArray(messages)) { - throw new Error("A2UI: expected messages array"); - } - this.#processor.processMessages(messages); - this.#syncSurfaces(); - if (this.pendingAction?.phase === "sent") { - this.#setToast(`Updated: ${this.pendingAction.name}`, "ok", 1100); - this.pendingAction = null; - } - this.requestUpdate(); - return { ok: true, surfaces: this.surfaces.map(([id]) => id) }; - } - - reset() { - this.#processor.clearSurfaces(); - this.#syncSurfaces(); - this.pendingAction = null; - this.requestUpdate(); - return { ok: true }; - } - - #syncSurfaces() { - this.surfaces = Array.from(this.#processor.getSurfaces().entries()); - } - - render() { - if (this.surfaces.length === 0) { - return html`
-
Canvas (A2UI)
-
`; - } - - const statusText = - this.pendingAction?.phase === "sent" - ? `Working: ${this.pendingAction.name}` - : this.pendingAction?.phase === "sending" - ? `Sending: ${this.pendingAction.name}` - : this.pendingAction?.phase === "error" - ? `Failed: ${this.pendingAction.name}` - : ""; - - return html` - ${this.pendingAction && this.pendingAction.phase !== "error" - ? html`
${statusText}
` - : ""} - ${this.toast - ? html`
${this.toast.text}
` - : ""} -
- ${repeat( - this.surfaces, - ([surfaceId]) => surfaceId, - ([surfaceId, surface]) => html`` - )} -
`; - } -} - -if (!customElements.get("openclaw-a2ui-host")) { - customElements.define("openclaw-a2ui-host", OpenClawA2UIHost); -} diff --git a/apps/shared/OpenClawKit/Tools/CanvasA2UI/rolldown.config.mjs b/apps/shared/OpenClawKit/Tools/CanvasA2UI/rolldown.config.mjs deleted file mode 100644 index ccf1683d565..00000000000 --- a/apps/shared/OpenClawKit/Tools/CanvasA2UI/rolldown.config.mjs +++ /dev/null @@ -1,67 +0,0 @@ -import path from "node:path"; -import { existsSync } from "node:fs"; -import { fileURLToPath } from "node:url"; - -const here = path.dirname(fileURLToPath(import.meta.url)); -const repoRoot = path.resolve(here, "../../../../.."); -const uiRoot = path.resolve(repoRoot, "ui"); -const fromHere = (p) => path.resolve(here, p); -const outputFile = path.resolve( - here, - "../../../../..", - "src", - "canvas-host", - "a2ui", - "a2ui.bundle.js", -); - -const a2uiLitDist = path.resolve(repoRoot, "vendor/a2ui/renderers/lit/dist/src"); -const a2uiThemeContext = path.resolve(a2uiLitDist, "0.8/ui/context/theme.js"); -const uiNodeModules = path.resolve(uiRoot, "node_modules"); -const repoNodeModules = path.resolve(repoRoot, "node_modules"); - -function resolveUiDependency(moduleId) { - const candidates = [ - path.resolve(uiNodeModules, moduleId), - path.resolve(repoNodeModules, moduleId), - ]; - for (const candidate of candidates) { - if (existsSync(candidate)) { - return candidate; - } - } - - const fallbackCandidates = candidates.join(", "); - throw new Error( - `A2UI bundle config cannot resolve ${moduleId}. Checked: ${fallbackCandidates}. ` + - "Keep dependency installed in ui workspace or repo root before bundling.", - ); -} - -export default { - input: fromHere("bootstrap.js"), - experimental: { - attachDebugInfo: "none", - }, - treeshake: false, - resolve: { - alias: { - "@a2ui/lit": path.resolve(a2uiLitDist, "index.js"), - "@a2ui/lit/ui": path.resolve(a2uiLitDist, "0.8/ui/ui.js"), - "@openclaw/a2ui-theme-context": a2uiThemeContext, - "@lit/context": resolveUiDependency("@lit/context"), - "@lit/context/": resolveUiDependency("@lit/context/"), - "@lit-labs/signals": resolveUiDependency("@lit-labs/signals"), - "@lit-labs/signals/": resolveUiDependency("@lit-labs/signals/"), - lit: resolveUiDependency("lit"), - "lit/": resolveUiDependency("lit/"), - "signal-utils/": resolveUiDependency("signal-utils/"), - }, - }, - output: { - file: outputFile, - format: "esm", - codeSplitting: false, - sourcemap: false, - }, -}; diff --git a/apps/web/app/api/workspace/init/route.test.ts b/apps/web/app/api/workspace/init/route.test.ts index 5ac5ef1e628..5af995c9f4b 100644 --- a/apps/web/app/api/workspace/init/route.test.ts +++ b/apps/web/app/api/workspace/init/route.test.ts @@ -112,7 +112,8 @@ describe("POST /api/workspace/init", () => { const workspaceDir = join(STATE_DIR, "workspace-work"); vi.mocked(existsSync).mockImplementation((p) => { const s = String(p); - if (s.endsWith("docs/reference/templates/AGENTS.md")) {return true;} + if (s.endsWith("package.json")) {return true;} + if (s.endsWith("assets/seed/workspace.duckdb")) {return true;} if (s.endsWith("skills/crm/SKILL.md")) {return true;} return false; }); @@ -143,7 +144,8 @@ describe("POST /api/workspace/init", () => { vi.mocked(existsSync).mockImplementation((p) => { const s = String(p); - if (s.endsWith("docs/reference/templates/AGENTS.md")) {return true;} + if (s.endsWith("package.json")) {return true;} + if (s.endsWith("assets/seed/workspace.duckdb")) {return true;} return false; }); diff --git a/apps/web/app/api/workspace/init/route.ts b/apps/web/app/api/workspace/init/route.ts index 537e492c4e3..f4d090c287b 100644 --- a/apps/web/app/api/workspace/init/route.ts +++ b/apps/web/app/api/workspace/init/route.ts @@ -4,7 +4,7 @@ import { writeFileSync, readFileSync, } from "node:fs"; -import { join, resolve } from "node:path"; +import { dirname, join } from "node:path"; import { discoverWorkspaces, setUIActiveWorkspace, @@ -15,10 +15,14 @@ import { resolveWorkspaceRoot, ensureAgentInConfig, } from "@/lib/workspace"; +import { + BOOTSTRAP_TEMPLATE_CONTENT, + type BootstrapTemplateName, +} from "@/lib/workspace-bootstrap-templates"; import { seedWorkspaceFromAssets, buildDenchClawIdentity, -} from "@repo/cli/workspace-seed"; +} from "@/lib/workspace-seed"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -37,15 +41,8 @@ const BOOTSTRAP_FILENAMES = [ "BOOTSTRAP.md", ] as const; -const FALLBACK_CONTENT: Record = { - "AGENTS.md": "# AGENTS.md - Your Workspace\n\nThis folder is home. Treat it that way.\n", - "SOUL.md": "# SOUL.md - Who You Are\n\nDescribe the personality and behavior of your agent here.\n", - "TOOLS.md": "# TOOLS.md - Local Notes\n\nSkills define how tools work. This file is for your specifics.\n", - "IDENTITY.md": "# IDENTITY.md - Who Am I?\n\nFill this in during your first conversation.\n", - "USER.md": "# USER.md - About Your Human\n\nDescribe yourself and how you'd like the agent to interact with you.\n", - "HEARTBEAT.md": "# HEARTBEAT.md\n\n# Keep this file empty (or with only comments) to skip heartbeat API calls.\n", - "BOOTSTRAP.md": "# BOOTSTRAP.md - Hello, World\n\nYou just woke up. Time to figure out who you are.\n", -}; +const ROOT_MARKER = join("assets", "seed", "workspace.duckdb"); +const TEMPLATE_DIR = join("assets", "seed", "templates"); const WORKSPACE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i; @@ -62,22 +59,24 @@ function stripFrontMatter(content: string): string { /** Try multiple candidate paths to find the monorepo root. */ function resolveProjectRoot(): string | null { - const marker = join("docs", "reference", "templates", "AGENTS.md"); - const cwd = process.cwd(); - - // CWD is the repo root (standalone builds) - if (existsSync(join(cwd, marker))) {return cwd;} - - // CWD is apps/web/ (dev mode) - const fromApps = resolve(cwd, "..", ".."); - if (existsSync(join(fromApps, marker))) {return fromApps;} + let dir = process.cwd(); + for (let index = 0; index < 10; index += 1) { + if (existsSync(join(dir, "package.json")) && existsSync(join(dir, ROOT_MARKER))) { + return dir; + } + const parent = dirname(dir); + if (parent === dir) { + break; + } + dir = parent; + } return null; } function loadTemplateContent(filename: string, projectRoot: string | null): string { if (projectRoot) { - const templatePath = join(projectRoot, "docs", "reference", "templates", filename); + const templatePath = join(projectRoot, TEMPLATE_DIR, filename); try { const raw = readFileSync(templatePath, "utf-8"); return stripFrontMatter(raw); @@ -85,7 +84,7 @@ function loadTemplateContent(filename: string, projectRoot: string | null): stri // fall through to fallback } } - return FALLBACK_CONTENT[filename] ?? ""; + return BOOTSTRAP_TEMPLATE_CONTENT[filename as BootstrapTemplateName] ?? ""; } // --------------------------------------------------------------------------- diff --git a/apps/web/app/components/workspace/workspace-sidebar.tsx b/apps/web/app/components/workspace/workspace-sidebar.tsx index e6b3834c7e2..078c442a852 100644 --- a/apps/web/app/components/workspace/workspace-sidebar.tsx +++ b/apps/web/app/components/workspace/workspace-sidebar.tsx @@ -591,13 +591,13 @@ export function WorkspaceSidebar({ style={{ borderColor: "var(--color-border)" }} > - denchclaw.sh + denchclaw.com
{onToggleHidden && ( diff --git a/apps/web/lib/workspace-bootstrap-templates.ts b/apps/web/lib/workspace-bootstrap-templates.ts new file mode 100644 index 00000000000..843082c2d27 --- /dev/null +++ b/apps/web/lib/workspace-bootstrap-templates.ts @@ -0,0 +1,14 @@ +export const BOOTSTRAP_TEMPLATE_CONTENT = { + "AGENTS.md": "# AGENTS.md - Your Workspace\n\nThis folder is home. Treat it that way.\n", + "SOUL.md": "# SOUL.md - Who You Are\n\nDescribe the personality and behavior of your agent here.\n", + "TOOLS.md": "# TOOLS.md - Local Notes\n\nSkills define how tools work. This file is for your specifics.\n", + "IDENTITY.md": "# IDENTITY.md - Who Am I?\n\nFill this in during your first conversation.\n", + "USER.md": + "# USER.md - About Your Human\n\nDescribe yourself and how you'd like the agent to interact with you.\n", + "HEARTBEAT.md": + "# HEARTBEAT.md\n\n# Keep this file empty (or with only comments) to skip heartbeat API calls.\n", + "BOOTSTRAP.md": + "# BOOTSTRAP.md - Hello, World\n\nYou just woke up. Time to figure out who you are.\n", +} as const; + +export type BootstrapTemplateName = keyof typeof BOOTSTRAP_TEMPLATE_CONTENT; diff --git a/apps/web/lib/workspace-seed.ts b/apps/web/lib/workspace-seed.ts new file mode 100644 index 00000000000..2dea1551bc2 --- /dev/null +++ b/apps/web/lib/workspace-seed.ts @@ -0,0 +1,4 @@ +export { + buildDenchClawIdentity, + seedWorkspaceFromAssets, +} from "../../../src/cli/workspace-seed"; diff --git a/apps/web/package.json b/apps/web/package.json index cfd75afe629..4ff041cf98d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -47,14 +47,13 @@ "framer-motion": "^12.34.0", "fuse.js": "^7.1.0", "html-to-docx": "^1.8.0", + "chokidar": "^5.0.0", "lucide-react": "^0.575.0", "mammoth": "^1.11.0", "monaco-editor": "^0.55.1", "next": "^15.3.3", - "next-themes": "^0.4.6", "react": "^19.1.0", "react-dom": "^19.1.0", - "react-is": "^19.2.4", "react-markdown": "^10.1.0", "react-spreadsheet": "^0.10.1", "recharts": "^3.7.0", @@ -63,7 +62,8 @@ "tailwind-merge": "^3.5.0", "unicode-animations": "^1.0.3", "ws": "^8.19.0", - "xlsx": "^0.18.5" + "xlsx": "^0.18.5", + "yaml": "^2.8.2" }, "devDependencies": { "@tailwindcss/postcss": "^4.1.8", diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index ae8f738581b..abf0a7d99a2 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -15,8 +15,7 @@ "incremental": true, "plugins": [{ "name": "next" }], "paths": { - "@/*": ["./*"], - "@repo/*": ["../../src/*"] + "@/*": ["./*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index f121f5569ff..bc955623132 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -9,7 +9,6 @@ export default defineConfig({ resolve: { alias: { "@": path.resolve(__dirname), - "@repo": path.resolve(__dirname, "../../src"), }, }, test: { diff --git a/assets/avatar-placeholder.svg b/assets/avatar-placeholder.svg deleted file mode 100644 index d0a6999abff..00000000000 --- a/assets/avatar-placeholder.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/assets/chrome-extension/README.md b/assets/chrome-extension/README.md deleted file mode 100644 index 4ee072c1f2b..00000000000 --- a/assets/chrome-extension/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# OpenClaw Chrome Extension (Browser Relay) - -Purpose: attach OpenClaw to an existing Chrome tab so the Gateway can automate it (via the local CDP relay server). - -## Dev / load unpacked - -1. Build/run OpenClaw Gateway with browser control enabled. -2. Ensure the relay server is reachable at `http://127.0.0.1:18792/` (default). -3. Install the extension to a stable path: - - ```bash - openclaw browser extension install - openclaw browser extension path - ``` - -4. Chrome → `chrome://extensions` → enable “Developer mode”. -5. “Load unpacked” → select the path printed above. -6. Pin the extension. Click the icon on a tab to attach/detach. - -## Options - -- `Relay port`: defaults to `18792`. -- `Gateway token`: required. Set this to `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`). diff --git a/assets/chrome-extension/background.js b/assets/chrome-extension/background.js deleted file mode 100644 index 7a1754e06c9..00000000000 --- a/assets/chrome-extension/background.js +++ /dev/null @@ -1,453 +0,0 @@ -const DEFAULT_PORT = 18792 - -const BADGE = { - on: { text: 'ON', color: '#FF5A36' }, - off: { text: '', color: '#000000' }, - connecting: { text: '…', color: '#F59E0B' }, - error: { text: '!', color: '#B91C1C' }, -} - -/** @type {WebSocket|null} */ -let relayWs = null -/** @type {Promise|null} */ -let relayConnectPromise = null - -let debuggerListenersInstalled = false - -let nextSession = 1 - -/** @type {Map} */ -const tabs = new Map() -/** @type {Map} */ -const tabBySession = new Map() -/** @type {Map} */ -const childSessionToTab = new Map() - -/** @type {Mapvoid, reject:(e:Error)=>void}>} */ -const pending = new Map() - -function nowStack() { - try { - return new Error().stack || '' - } catch { - return '' - } -} - -async function getRelayPort() { - const stored = await chrome.storage.local.get(['relayPort']) - const raw = stored.relayPort - const n = Number.parseInt(String(raw || ''), 10) - if (!Number.isFinite(n) || n <= 0 || n > 65535) return DEFAULT_PORT - return n -} - -async function getGatewayToken() { - const stored = await chrome.storage.local.get(['gatewayToken']) - const token = String(stored.gatewayToken || '').trim() - return token || '' -} - -function setBadge(tabId, kind) { - const cfg = BADGE[kind] - void chrome.action.setBadgeText({ tabId, text: cfg.text }) - void chrome.action.setBadgeBackgroundColor({ tabId, color: cfg.color }) - void chrome.action.setBadgeTextColor({ tabId, color: '#FFFFFF' }).catch(() => {}) -} - -async function ensureRelayConnection() { - if (relayWs && relayWs.readyState === WebSocket.OPEN) return - if (relayConnectPromise) return await relayConnectPromise - - relayConnectPromise = (async () => { - const port = await getRelayPort() - const gatewayToken = await getGatewayToken() - const httpBase = `http://127.0.0.1:${port}` - const wsUrl = gatewayToken - ? `ws://127.0.0.1:${port}/extension?token=${encodeURIComponent(gatewayToken)}` - : `ws://127.0.0.1:${port}/extension` - - // Fast preflight: is the relay server up? - try { - await fetch(`${httpBase}/`, { method: 'HEAD', signal: AbortSignal.timeout(2000) }) - } catch (err) { - throw new Error(`Relay server not reachable at ${httpBase} (${String(err)})`) - } - - if (!gatewayToken) { - throw new Error( - 'Missing gatewayToken in extension settings (chrome.storage.local.gatewayToken)', - ) - } - - const ws = new WebSocket(wsUrl) - relayWs = ws - - await new Promise((resolve, reject) => { - const t = setTimeout(() => reject(new Error('WebSocket connect timeout')), 5000) - ws.onopen = () => { - clearTimeout(t) - resolve() - } - ws.onerror = () => { - clearTimeout(t) - reject(new Error('WebSocket connect failed')) - } - ws.onclose = (ev) => { - clearTimeout(t) - reject(new Error(`WebSocket closed (${ev.code} ${ev.reason || 'no reason'})`)) - } - }) - - ws.onmessage = (event) => void onRelayMessage(String(event.data || '')) - ws.onclose = () => onRelayClosed('closed') - ws.onerror = () => onRelayClosed('error') - - if (!debuggerListenersInstalled) { - debuggerListenersInstalled = true - chrome.debugger.onEvent.addListener(onDebuggerEvent) - chrome.debugger.onDetach.addListener(onDebuggerDetach) - } - })() - - try { - await relayConnectPromise - } finally { - relayConnectPromise = null - } -} - -function onRelayClosed(reason) { - relayWs = null - for (const [id, p] of pending.entries()) { - pending.delete(id) - p.reject(new Error(`Relay disconnected (${reason})`)) - } - - for (const tabId of tabs.keys()) { - void chrome.debugger.detach({ tabId }).catch(() => {}) - setBadge(tabId, 'connecting') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay: disconnected (click to re-attach)', - }) - } - tabs.clear() - tabBySession.clear() - childSessionToTab.clear() -} - -function sendToRelay(payload) { - const ws = relayWs - if (!ws || ws.readyState !== WebSocket.OPEN) { - throw new Error('Relay not connected') - } - ws.send(JSON.stringify(payload)) -} - -async function maybeOpenHelpOnce() { - try { - const stored = await chrome.storage.local.get(['helpOnErrorShown']) - if (stored.helpOnErrorShown === true) return - await chrome.storage.local.set({ helpOnErrorShown: true }) - await chrome.runtime.openOptionsPage() - } catch { - // ignore - } -} - -function requestFromRelay(command) { - const id = command.id - return new Promise((resolve, reject) => { - pending.set(id, { resolve, reject }) - try { - sendToRelay(command) - } catch (err) { - pending.delete(id) - reject(err instanceof Error ? err : new Error(String(err))) - } - }) -} - -async function onRelayMessage(text) { - /** @type {any} */ - let msg - try { - msg = JSON.parse(text) - } catch { - return - } - - if (msg && msg.method === 'ping') { - try { - sendToRelay({ method: 'pong' }) - } catch { - // ignore - } - return - } - - if (msg && typeof msg.id === 'number' && (msg.result !== undefined || msg.error !== undefined)) { - const p = pending.get(msg.id) - if (!p) return - pending.delete(msg.id) - if (msg.error) p.reject(new Error(String(msg.error))) - else p.resolve(msg.result) - return - } - - if (msg && typeof msg.id === 'number' && msg.method === 'forwardCDPCommand') { - try { - const result = await handleForwardCdpCommand(msg) - sendToRelay({ id: msg.id, result }) - } catch (err) { - sendToRelay({ id: msg.id, error: err instanceof Error ? err.message : String(err) }) - } - } -} - -function getTabBySessionId(sessionId) { - const direct = tabBySession.get(sessionId) - if (direct) return { tabId: direct, kind: 'main' } - const child = childSessionToTab.get(sessionId) - if (child) return { tabId: child, kind: 'child' } - return null -} - -function getTabByTargetId(targetId) { - for (const [tabId, tab] of tabs.entries()) { - if (tab.targetId === targetId) return tabId - } - return null -} - -async function attachTab(tabId, opts = {}) { - const debuggee = { tabId } - await chrome.debugger.attach(debuggee, '1.3') - await chrome.debugger.sendCommand(debuggee, 'Page.enable').catch(() => {}) - - const info = /** @type {any} */ (await chrome.debugger.sendCommand(debuggee, 'Target.getTargetInfo')) - const targetInfo = info?.targetInfo - const targetId = String(targetInfo?.targetId || '').trim() - if (!targetId) { - throw new Error('Target.getTargetInfo returned no targetId') - } - - const sessionId = `cb-tab-${nextSession++}` - const attachOrder = nextSession - - tabs.set(tabId, { state: 'connected', sessionId, targetId, attachOrder }) - tabBySession.set(sessionId, tabId) - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay: attached (click to detach)', - }) - - if (!opts.skipAttachedEvent) { - sendToRelay({ - method: 'forwardCDPEvent', - params: { - method: 'Target.attachedToTarget', - params: { - sessionId, - targetInfo: { ...targetInfo, attached: true }, - waitingForDebugger: false, - }, - }, - }) - } - - setBadge(tabId, 'on') - return { sessionId, targetId } -} - -async function detachTab(tabId, reason) { - const tab = tabs.get(tabId) - if (tab?.sessionId && tab?.targetId) { - try { - sendToRelay({ - method: 'forwardCDPEvent', - params: { - method: 'Target.detachedFromTarget', - params: { sessionId: tab.sessionId, targetId: tab.targetId, reason }, - }, - }) - } catch { - // ignore - } - } - - if (tab?.sessionId) tabBySession.delete(tab.sessionId) - tabs.delete(tabId) - - for (const [childSessionId, parentTabId] of childSessionToTab.entries()) { - if (parentTabId === tabId) childSessionToTab.delete(childSessionId) - } - - try { - await chrome.debugger.detach({ tabId }) - } catch { - // ignore - } - - setBadge(tabId, 'off') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay (click to attach/detach)', - }) -} - -async function connectOrToggleForActiveTab() { - const [active] = await chrome.tabs.query({ active: true, currentWindow: true }) - const tabId = active?.id - if (!tabId) return - - const existing = tabs.get(tabId) - if (existing?.state === 'connected') { - await detachTab(tabId, 'toggle') - return - } - - tabs.set(tabId, { state: 'connecting' }) - setBadge(tabId, 'connecting') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay: connecting to local relay…', - }) - - try { - await ensureRelayConnection() - await attachTab(tabId) - } catch (err) { - tabs.delete(tabId) - setBadge(tabId, 'error') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay: relay not running (open options for setup)', - }) - void maybeOpenHelpOnce() - // Extra breadcrumbs in chrome://extensions service worker logs. - const message = err instanceof Error ? err.message : String(err) - console.warn('attach failed', message, nowStack()) - } -} - -async function handleForwardCdpCommand(msg) { - const method = String(msg?.params?.method || '').trim() - const params = msg?.params?.params || undefined - const sessionId = typeof msg?.params?.sessionId === 'string' ? msg.params.sessionId : undefined - - // Map command to tab - const bySession = sessionId ? getTabBySessionId(sessionId) : null - const targetId = typeof params?.targetId === 'string' ? params.targetId : undefined - const tabId = - bySession?.tabId || - (targetId ? getTabByTargetId(targetId) : null) || - (() => { - // No sessionId: pick the first connected tab (stable-ish). - for (const [id, tab] of tabs.entries()) { - if (tab.state === 'connected') return id - } - return null - })() - - if (!tabId) throw new Error(`No attached tab for method ${method}`) - - /** @type {chrome.debugger.DebuggerSession} */ - const debuggee = { tabId } - - if (method === 'Runtime.enable') { - try { - await chrome.debugger.sendCommand(debuggee, 'Runtime.disable') - await new Promise((r) => setTimeout(r, 50)) - } catch { - // ignore - } - return await chrome.debugger.sendCommand(debuggee, 'Runtime.enable', params) - } - - if (method === 'Target.createTarget') { - const url = typeof params?.url === 'string' ? params.url : 'about:blank' - const tab = await chrome.tabs.create({ url, active: false }) - if (!tab.id) throw new Error('Failed to create tab') - await new Promise((r) => setTimeout(r, 100)) - const attached = await attachTab(tab.id) - return { targetId: attached.targetId } - } - - if (method === 'Target.closeTarget') { - const target = typeof params?.targetId === 'string' ? params.targetId : '' - const toClose = target ? getTabByTargetId(target) : tabId - if (!toClose) return { success: false } - try { - await chrome.tabs.remove(toClose) - } catch { - return { success: false } - } - return { success: true } - } - - if (method === 'Target.activateTarget') { - const target = typeof params?.targetId === 'string' ? params.targetId : '' - const toActivate = target ? getTabByTargetId(target) : tabId - if (!toActivate) return {} - const tab = await chrome.tabs.get(toActivate).catch(() => null) - if (!tab) return {} - if (tab.windowId) { - await chrome.windows.update(tab.windowId, { focused: true }).catch(() => {}) - } - await chrome.tabs.update(toActivate, { active: true }).catch(() => {}) - return {} - } - - const tabState = tabs.get(tabId) - const mainSessionId = tabState?.sessionId - const debuggerSession = - sessionId && mainSessionId && sessionId !== mainSessionId - ? { ...debuggee, sessionId } - : debuggee - - return await chrome.debugger.sendCommand(debuggerSession, method, params) -} - -function onDebuggerEvent(source, method, params) { - const tabId = source.tabId - if (!tabId) return - const tab = tabs.get(tabId) - if (!tab?.sessionId) return - - if (method === 'Target.attachedToTarget' && params?.sessionId) { - childSessionToTab.set(String(params.sessionId), tabId) - } - - if (method === 'Target.detachedFromTarget' && params?.sessionId) { - childSessionToTab.delete(String(params.sessionId)) - } - - try { - sendToRelay({ - method: 'forwardCDPEvent', - params: { - sessionId: source.sessionId || tab.sessionId, - method, - params, - }, - }) - } catch { - // ignore - } -} - -function onDebuggerDetach(source, reason) { - const tabId = source.tabId - if (!tabId) return - if (!tabs.has(tabId)) return - void detachTab(tabId, reason) -} - -chrome.action.onClicked.addListener(() => void connectOrToggleForActiveTab()) - -chrome.runtime.onInstalled.addListener(() => { - // Useful: first-time instructions. - void chrome.runtime.openOptionsPage() -}) diff --git a/assets/chrome-extension/icons/icon128.png b/assets/chrome-extension/icons/icon128.png deleted file mode 100644 index 533cc812de7..00000000000 Binary files a/assets/chrome-extension/icons/icon128.png and /dev/null differ diff --git a/assets/chrome-extension/icons/icon16.png b/assets/chrome-extension/icons/icon16.png deleted file mode 100644 index 1be23ae89b4..00000000000 Binary files a/assets/chrome-extension/icons/icon16.png and /dev/null differ diff --git a/assets/chrome-extension/icons/icon32.png b/assets/chrome-extension/icons/icon32.png deleted file mode 100644 index f4c1be8a6a0..00000000000 Binary files a/assets/chrome-extension/icons/icon32.png and /dev/null differ diff --git a/assets/chrome-extension/icons/icon48.png b/assets/chrome-extension/icons/icon48.png deleted file mode 100644 index d2a278af59e..00000000000 Binary files a/assets/chrome-extension/icons/icon48.png and /dev/null differ diff --git a/assets/chrome-extension/manifest.json b/assets/chrome-extension/manifest.json deleted file mode 100644 index d6b593990de..00000000000 --- a/assets/chrome-extension/manifest.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "manifest_version": 3, - "name": "OpenClaw Browser Relay", - "version": "0.1.0", - "description": "Attach OpenClaw to your existing Chrome tab via a local CDP relay server.", - "icons": { - "16": "icons/icon16.png", - "32": "icons/icon32.png", - "48": "icons/icon48.png", - "128": "icons/icon128.png" - }, - "permissions": ["debugger", "tabs", "activeTab", "storage"], - "host_permissions": ["http://127.0.0.1/*", "http://localhost/*"], - "background": { "service_worker": "background.js", "type": "module" }, - "action": { - "default_title": "OpenClaw Browser Relay (click to attach/detach)", - "default_icon": { - "16": "icons/icon16.png", - "32": "icons/icon32.png", - "48": "icons/icon48.png", - "128": "icons/icon128.png" - } - }, - "options_ui": { "page": "options.html", "open_in_tab": true } -} diff --git a/assets/chrome-extension/options.html b/assets/chrome-extension/options.html deleted file mode 100644 index 17fc6a79eed..00000000000 --- a/assets/chrome-extension/options.html +++ /dev/null @@ -1,200 +0,0 @@ - - - - - - OpenClaw Browser Relay - - - -
-
- -
-

OpenClaw Browser Relay

-

Click the toolbar button on a tab to attach / detach.

-
-
- -
-
-

Getting started

-

- If you see a red ! badge on the extension icon, the relay server is not reachable. - Start OpenClaw’s browser relay on this machine (Gateway or node host), then click the toolbar button again. -

-

- Full guide (install, remote Gateway, security): docs.openclaw.ai/tools/chrome-extension -

-
- -
-

Relay connection

- -
- -
- -
- - -
-
- Default port: 18792. Extension connects to: http://127.0.0.1:<port>/. - Gateway token must match gateway.auth.token (or OPENCLAW_GATEWAY_TOKEN). -
-
-
-
- - -
- - diff --git a/assets/chrome-extension/options.js b/assets/chrome-extension/options.js deleted file mode 100644 index e4252ccae4c..00000000000 --- a/assets/chrome-extension/options.js +++ /dev/null @@ -1,83 +0,0 @@ -const DEFAULT_PORT = 18792 - -function clampPort(value) { - const n = Number.parseInt(String(value || ''), 10) - if (!Number.isFinite(n)) return DEFAULT_PORT - if (n <= 0 || n > 65535) return DEFAULT_PORT - return n -} - -function updateRelayUrl(port) { - const el = document.getElementById('relay-url') - if (!el) return - el.textContent = `http://127.0.0.1:${port}/` -} - -function relayHeaders(token) { - const t = String(token || '').trim() - if (!t) return {} - return { 'x-openclaw-relay-token': t } -} - -function setStatus(kind, message) { - const status = document.getElementById('status') - if (!status) return - status.dataset.kind = kind || '' - status.textContent = message || '' -} - -async function checkRelayReachable(port, token) { - const url = `http://127.0.0.1:${port}/json/version` - const trimmedToken = String(token || '').trim() - if (!trimmedToken) { - setStatus('error', 'Gateway token required. Save your gateway token to connect.') - return - } - const ctrl = new AbortController() - const t = setTimeout(() => ctrl.abort(), 1200) - try { - const res = await fetch(url, { - method: 'GET', - headers: relayHeaders(trimmedToken), - signal: ctrl.signal, - }) - if (res.status === 401) { - setStatus('error', 'Gateway token rejected. Check token and save again.') - return - } - if (!res.ok) throw new Error(`HTTP ${res.status}`) - setStatus('ok', `Relay reachable and authenticated at http://127.0.0.1:${port}/`) - } catch { - setStatus( - 'error', - `Relay not reachable/authenticated at http://127.0.0.1:${port}/. Start OpenClaw browser relay and verify token.`, - ) - } finally { - clearTimeout(t) - } -} - -async function load() { - const stored = await chrome.storage.local.get(['relayPort', 'gatewayToken']) - const port = clampPort(stored.relayPort) - const token = String(stored.gatewayToken || '').trim() - document.getElementById('port').value = String(port) - document.getElementById('token').value = token - updateRelayUrl(port) - await checkRelayReachable(port, token) -} - -async function save() { - const portInput = document.getElementById('port') - const tokenInput = document.getElementById('token') - const port = clampPort(portInput.value) - const token = String(tokenInput.value || '').trim() - await chrome.storage.local.set({ relayPort: port, gatewayToken: token }) - portInput.value = String(port) - tokenInput.value = token - updateRelayUrl(port) - await checkRelayReachable(port, token) -} - -document.getElementById('save').addEventListener('click', () => void save()) -void load() diff --git a/assets/dmg-background-small.png b/assets/dmg-background-small.png deleted file mode 100644 index 74fc56a9045..00000000000 Binary files a/assets/dmg-background-small.png and /dev/null differ diff --git a/assets/dmg-background.png b/assets/dmg-background.png deleted file mode 100644 index a3bff0382b9..00000000000 Binary files a/assets/dmg-background.png and /dev/null differ diff --git a/assets/openclaw-ai-sdk-banner.png b/assets/openclaw-ai-sdk-banner.png deleted file mode 100644 index 60e4c4eb5bf..00000000000 Binary files a/assets/openclaw-ai-sdk-banner.png and /dev/null differ diff --git a/openclaw.mjs b/denchclaw.mjs similarity index 100% rename from openclaw.mjs rename to denchclaw.mjs diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 614a1f8d533..00000000000 --- a/docker-compose.yml +++ /dev/null @@ -1,46 +0,0 @@ -services: - openclaw-gateway: - image: ${OPENCLAW_IMAGE:-openclaw:local} - environment: - HOME: /home/node - TERM: xterm-256color - OPENCLAW_GATEWAY_TOKEN: ${OPENCLAW_GATEWAY_TOKEN} - CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY} - CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY} - CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE} - volumes: - - ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw - - ${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace - ports: - - "${OPENCLAW_GATEWAY_PORT:-18789}:18789" - - "${OPENCLAW_BRIDGE_PORT:-18790}:18790" - init: true - restart: unless-stopped - command: - [ - "node", - "dist/index.js", - "gateway", - "--bind", - "${OPENCLAW_GATEWAY_BIND:-lan}", - "--port", - "18789", - ] - - openclaw-cli: - image: ${OPENCLAW_IMAGE:-openclaw:local} - environment: - HOME: /home/node - TERM: xterm-256color - OPENCLAW_GATEWAY_TOKEN: ${OPENCLAW_GATEWAY_TOKEN} - BROWSER: echo - CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY} - CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY} - CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE} - volumes: - - ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw - - ${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace - stdin_open: true - tty: true - init: true - entrypoint: ["node", "dist/index.js"] diff --git a/docker-setup.sh b/docker-setup.sh deleted file mode 100755 index 00c3cf1924f..00000000000 --- a/docker-setup.sh +++ /dev/null @@ -1,288 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -COMPOSE_FILE="$ROOT_DIR/docker-compose.yml" -EXTRA_COMPOSE_FILE="$ROOT_DIR/docker-compose.extra.yml" -IMAGE_NAME="${OPENCLAW_IMAGE:-openclaw:local}" -EXTRA_MOUNTS="${OPENCLAW_EXTRA_MOUNTS:-}" -HOME_VOLUME_NAME="${OPENCLAW_HOME_VOLUME:-}" - -fail() { - echo "ERROR: $*" >&2 - exit 1 -} - -require_cmd() { - if ! command -v "$1" >/dev/null 2>&1; then - echo "Missing dependency: $1" >&2 - exit 1 - fi -} - -contains_disallowed_chars() { - local value="$1" - [[ "$value" == *$'\n'* || "$value" == *$'\r'* || "$value" == *$'\t'* ]] -} - -validate_mount_path_value() { - local label="$1" - local value="$2" - if [[ -z "$value" ]]; then - fail "$label cannot be empty." - fi - if contains_disallowed_chars "$value"; then - fail "$label contains unsupported control characters." - fi - if [[ "$value" =~ [[:space:]] ]]; then - fail "$label cannot contain whitespace." - fi -} - -validate_named_volume() { - local value="$1" - if [[ ! "$value" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]]; then - fail "OPENCLAW_HOME_VOLUME must match [A-Za-z0-9][A-Za-z0-9_.-]* when using a named volume." - fi -} - -validate_mount_spec() { - local mount="$1" - if contains_disallowed_chars "$mount"; then - fail "OPENCLAW_EXTRA_MOUNTS entries cannot contain control characters." - fi - # Keep mount specs strict to avoid YAML structure injection. - # Expected format: source:target[:options] - if [[ ! "$mount" =~ ^[^[:space:],:]+:[^[:space:],:]+(:[^[:space:],:]+)?$ ]]; then - fail "Invalid mount format '$mount'. Expected source:target[:options] without spaces." - fi -} - -require_cmd docker -if ! docker compose version >/dev/null 2>&1; then - echo "Docker Compose not available (try: docker compose version)" >&2 - exit 1 -fi - -OPENCLAW_CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-$HOME/.openclaw}" -OPENCLAW_WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-$HOME/.openclaw/workspace}" - -validate_mount_path_value "OPENCLAW_CONFIG_DIR" "$OPENCLAW_CONFIG_DIR" -validate_mount_path_value "OPENCLAW_WORKSPACE_DIR" "$OPENCLAW_WORKSPACE_DIR" -if [[ -n "$HOME_VOLUME_NAME" ]]; then - if [[ "$HOME_VOLUME_NAME" == *"/"* ]]; then - validate_mount_path_value "OPENCLAW_HOME_VOLUME" "$HOME_VOLUME_NAME" - else - validate_named_volume "$HOME_VOLUME_NAME" - fi -fi -if contains_disallowed_chars "$EXTRA_MOUNTS"; then - fail "OPENCLAW_EXTRA_MOUNTS cannot contain control characters." -fi - -mkdir -p "$OPENCLAW_CONFIG_DIR" -mkdir -p "$OPENCLAW_WORKSPACE_DIR" - -export OPENCLAW_CONFIG_DIR -export OPENCLAW_WORKSPACE_DIR -export OPENCLAW_GATEWAY_PORT="${OPENCLAW_GATEWAY_PORT:-18789}" -export OPENCLAW_BRIDGE_PORT="${OPENCLAW_BRIDGE_PORT:-18790}" -export OPENCLAW_GATEWAY_BIND="${OPENCLAW_GATEWAY_BIND:-lan}" -export OPENCLAW_IMAGE="$IMAGE_NAME" -export OPENCLAW_DOCKER_APT_PACKAGES="${OPENCLAW_DOCKER_APT_PACKAGES:-}" -export OPENCLAW_EXTRA_MOUNTS="$EXTRA_MOUNTS" -export OPENCLAW_HOME_VOLUME="$HOME_VOLUME_NAME" - -if [[ -z "${OPENCLAW_GATEWAY_TOKEN:-}" ]]; then - if command -v openssl >/dev/null 2>&1; then - OPENCLAW_GATEWAY_TOKEN="$(openssl rand -hex 32)" - else - OPENCLAW_GATEWAY_TOKEN="$(python3 - <<'PY' -import secrets -print(secrets.token_hex(32)) -PY -)" - fi -fi -export OPENCLAW_GATEWAY_TOKEN - -COMPOSE_FILES=("$COMPOSE_FILE") -COMPOSE_ARGS=() - -write_extra_compose() { - local home_volume="$1" - shift - local mount - local gateway_home_mount - local gateway_config_mount - local gateway_workspace_mount - - cat >"$EXTRA_COMPOSE_FILE" <<'YAML' -services: - openclaw-gateway: - volumes: -YAML - - if [[ -n "$home_volume" ]]; then - gateway_home_mount="${home_volume}:/home/node" - gateway_config_mount="${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw" - gateway_workspace_mount="${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace" - validate_mount_spec "$gateway_home_mount" - validate_mount_spec "$gateway_config_mount" - validate_mount_spec "$gateway_workspace_mount" - printf ' - %s\n' "$gateway_home_mount" >>"$EXTRA_COMPOSE_FILE" - printf ' - %s\n' "$gateway_config_mount" >>"$EXTRA_COMPOSE_FILE" - printf ' - %s\n' "$gateway_workspace_mount" >>"$EXTRA_COMPOSE_FILE" - fi - - for mount in "$@"; do - validate_mount_spec "$mount" - printf ' - %s\n' "$mount" >>"$EXTRA_COMPOSE_FILE" - done - - cat >>"$EXTRA_COMPOSE_FILE" <<'YAML' - openclaw-cli: - volumes: -YAML - - if [[ -n "$home_volume" ]]; then - printf ' - %s\n' "$gateway_home_mount" >>"$EXTRA_COMPOSE_FILE" - printf ' - %s\n' "$gateway_config_mount" >>"$EXTRA_COMPOSE_FILE" - printf ' - %s\n' "$gateway_workspace_mount" >>"$EXTRA_COMPOSE_FILE" - fi - - for mount in "$@"; do - validate_mount_spec "$mount" - printf ' - %s\n' "$mount" >>"$EXTRA_COMPOSE_FILE" - done - - if [[ -n "$home_volume" && "$home_volume" != *"/"* ]]; then - validate_named_volume "$home_volume" - cat >>"$EXTRA_COMPOSE_FILE" <>"$tmp" - seen="$seen$k " - replaced=true - break - fi - done - if [[ "$replaced" == false ]]; then - printf '%s\n' "$line" >>"$tmp" - fi - done <"$file" - fi - - for k in "${keys[@]}"; do - if [[ "$seen" != *" $k "* ]]; then - printf '%s=%s\n' "$k" "${!k-}" >>"$tmp" - fi - done - - mv "$tmp" "$file" -} - -upsert_env "$ENV_FILE" \ - OPENCLAW_CONFIG_DIR \ - OPENCLAW_WORKSPACE_DIR \ - OPENCLAW_GATEWAY_PORT \ - OPENCLAW_BRIDGE_PORT \ - OPENCLAW_GATEWAY_BIND \ - OPENCLAW_GATEWAY_TOKEN \ - OPENCLAW_IMAGE \ - OPENCLAW_EXTRA_MOUNTS \ - OPENCLAW_HOME_VOLUME \ - OPENCLAW_DOCKER_APT_PACKAGES - -echo "==> Building Docker image: $IMAGE_NAME" -docker build \ - --build-arg "OPENCLAW_DOCKER_APT_PACKAGES=${OPENCLAW_DOCKER_APT_PACKAGES}" \ - -t "$IMAGE_NAME" \ - -f "$ROOT_DIR/Dockerfile" \ - "$ROOT_DIR" - -echo "" -echo "==> Onboarding (interactive)" -echo "When prompted:" -echo " - Gateway bind: lan" -echo " - Gateway auth: token" -echo " - Gateway token: $OPENCLAW_GATEWAY_TOKEN" -echo " - Tailscale exposure: Off" -echo " - Install Gateway daemon: No" -echo "" -docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli onboard --no-install-daemon - -echo "" -echo "==> Provider setup (optional)" -echo "WhatsApp (QR):" -echo " ${COMPOSE_HINT} run --rm openclaw-cli channels login" -echo "Telegram (bot token):" -echo " ${COMPOSE_HINT} run --rm openclaw-cli channels add --channel telegram --token " -echo "Discord (bot token):" -echo " ${COMPOSE_HINT} run --rm openclaw-cli channels add --channel discord --token " -echo "Docs: https://docs.openclaw.ai/channels" - -echo "" -echo "==> Starting gateway" -docker compose "${COMPOSE_ARGS[@]}" up -d openclaw-gateway - -echo "" -echo "Gateway running with host port mapping." -echo "Access from tailnet devices via the host's tailnet IP." -echo "Config: $OPENCLAW_CONFIG_DIR" -echo "Workspace: $OPENCLAW_WORKSPACE_DIR" -echo "Token: $OPENCLAW_GATEWAY_TOKEN" -echo "" -echo "Commands:" -echo " ${COMPOSE_HINT} logs -f openclaw-gateway" -echo " ${COMPOSE_HINT} exec openclaw-gateway node dist/index.js health --token \"$OPENCLAW_GATEWAY_TOKEN\"" diff --git a/docs.acp.md b/docs.acp.md deleted file mode 100644 index cfe7349c341..00000000000 --- a/docs.acp.md +++ /dev/null @@ -1,197 +0,0 @@ -# OpenClaw ACP Bridge - -This document describes how the OpenClaw ACP (Agent Client Protocol) bridge works, -how it maps ACP sessions to Gateway sessions, and how IDEs should invoke it. - -## Overview - -`openclaw acp` exposes an ACP agent over stdio and forwards prompts to a running -OpenClaw Gateway over WebSocket. It keeps ACP session ids mapped to Gateway -session keys so IDEs can reconnect to the same agent transcript or reset it on -request. - -Key goals: - -- Minimal ACP surface area (stdio, NDJSON). -- Stable session mapping across reconnects. -- Works with existing Gateway session store (list/resolve/reset). -- Safe defaults (isolated ACP session keys by default). - -## How can I use this - -Use ACP when an IDE or tooling speaks Agent Client Protocol and you want it to -drive a OpenClaw Gateway session. - -Quick steps: - -1. Run a Gateway (local or remote). -2. Configure the Gateway target (`gateway.remote.url` + auth) or pass flags. -3. Point the IDE to run `openclaw acp` over stdio. - -Example config: - -```bash -openclaw config set gateway.remote.url wss://gateway-host:18789 -openclaw config set gateway.remote.token -``` - -Example run: - -```bash -openclaw acp --url wss://gateway-host:18789 --token -``` - -## Selecting agents - -ACP does not pick agents directly. It routes by the Gateway session key. - -Use agent-scoped session keys to target a specific agent: - -```bash -openclaw acp --session agent:main:main -openclaw acp --session agent:design:main -openclaw acp --session agent:qa:bug-123 -``` - -Each ACP session maps to a single Gateway session key. One agent can have many -sessions; ACP defaults to an isolated `acp:` session unless you override -the key or label. - -## Zed editor setup - -Add a custom ACP agent in `~/.config/zed/settings.json`: - -```json -{ - "agent_servers": { - "OpenClaw ACP": { - "type": "custom", - "command": "openclaw", - "args": ["acp"], - "env": {} - } - } -} -``` - -To target a specific Gateway or agent: - -```json -{ - "agent_servers": { - "OpenClaw ACP": { - "type": "custom", - "command": "openclaw", - "args": [ - "acp", - "--url", - "wss://gateway-host:18789", - "--token", - "", - "--session", - "agent:design:main" - ], - "env": {} - } - } -} -``` - -In Zed, open the Agent panel and select “OpenClaw ACP” to start a thread. - -## Execution Model - -- ACP client spawns `openclaw acp` and speaks ACP messages over stdio. -- The bridge connects to the Gateway using existing auth config (or CLI flags). -- ACP `prompt` translates to Gateway `chat.send`. -- Gateway streaming events are translated back into ACP streaming events. -- ACP `cancel` maps to Gateway `chat.abort` for the active run. - -## Session Mapping - -By default each ACP session is mapped to a dedicated Gateway session key: - -- `acp:` unless overridden. - -You can override or reuse sessions in two ways: - -1. CLI defaults - -```bash -openclaw acp --session agent:main:main -openclaw acp --session-label "support inbox" -openclaw acp --reset-session -``` - -2. ACP metadata per session - -```json -{ - "_meta": { - "sessionKey": "agent:main:main", - "sessionLabel": "support inbox", - "resetSession": true, - "requireExisting": false - } -} -``` - -Rules: - -- `sessionKey`: direct Gateway session key. -- `sessionLabel`: resolve an existing session by label. -- `resetSession`: mint a new transcript for the key before first use. -- `requireExisting`: fail if the key/label does not exist. - -### Session Listing - -ACP `listSessions` maps to Gateway `sessions.list` and returns a filtered -summary suitable for IDE session pickers. `_meta.limit` can cap the number of -sessions returned. - -## Prompt Translation - -ACP prompt inputs are converted into a Gateway `chat.send`: - -- `text` and `resource` blocks become prompt text. -- `resource_link` with image mime types become attachments. -- The working directory can be prefixed into the prompt (default on, can be - disabled with `--no-prefix-cwd`). - -Gateway streaming events are translated into ACP `message` and `tool_call` -updates. Terminal Gateway states map to ACP `done` with stop reasons: - -- `complete` -> `stop` -- `aborted` -> `cancel` -- `error` -> `error` - -## Auth + Gateway Discovery - -`openclaw acp` resolves the Gateway URL and auth from CLI flags or config: - -- `--url` / `--token` / `--password` take precedence. -- Otherwise use configured `gateway.remote.*` settings. - -## Operational Notes - -- ACP sessions are stored in memory for the bridge process lifetime. -- Gateway session state is persisted by the Gateway itself. -- `--verbose` logs ACP/Gateway bridge events to stderr (never stdout). -- ACP runs can be canceled and the active run id is tracked per session. - -## Compatibility - -- ACP bridge uses `@agentclientprotocol/sdk` (currently 0.13.x). -- Works with ACP clients that implement `initialize`, `newSession`, - `loadSession`, `prompt`, `cancel`, and `listSessions`. - -## Testing - -- Unit: `src/acp/session.test.ts` covers run id lifecycle. -- Full gate: `pnpm build && pnpm check && pnpm test && pnpm docs:build`. - -## Related Docs - -- CLI usage: `docs/cli/acp.md` -- Session model: `docs/concepts/session.md` -- Session management internals: `docs/reference/session-management-compaction.md` diff --git a/docs/.i18n/README.md b/docs/.i18n/README.md deleted file mode 100644 index 8e751a11eaa..00000000000 --- a/docs/.i18n/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# OpenClaw docs i18n assets - -This folder stores **generated** and **config** files for documentation translations. - -## Files - -- `glossary..json` — preferred term mappings (used in prompt guidance). -- `.tm.jsonl` — translation memory (cache) keyed by workflow + model + text hash. - -## Glossary format - -`glossary..json` is an array of entries: - -```json -{ - "source": "troubleshooting", - "target": "故障排除", - "ignore_case": true, - "whole_word": false -} -``` - -Fields: - -- `source`: English (or source) phrase to prefer. -- `target`: preferred translation output. - -## Notes - -- Glossary entries are passed to the model as **prompt guidance** (no deterministic rewrites). -- The translation memory is updated by `scripts/docs-i18n`. diff --git a/docs/.i18n/glossary.ja-JP.json b/docs/.i18n/glossary.ja-JP.json deleted file mode 100644 index f7c59a187d3..00000000000 --- a/docs/.i18n/glossary.ja-JP.json +++ /dev/null @@ -1,14 +0,0 @@ -[ - { "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": "ウィザード" } -] diff --git a/docs/.i18n/glossary.zh-CN.json b/docs/.i18n/glossary.zh-CN.json deleted file mode 100644 index bde108074c2..00000000000 --- a/docs/.i18n/glossary.zh-CN.json +++ /dev/null @@ -1,210 +0,0 @@ -[ - { - "source": "OpenClaw", - "target": "OpenClaw" - }, - { - "source": "Gateway", - "target": "Gateway 网关" - }, - { - "source": "Pi", - "target": "Pi" - }, - { - "source": "Skills", - "target": "Skills" - }, - { - "source": "Skills config", - "target": "Skills 配置" - }, - { - "source": "Skills Config", - "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": "Docs directory", - "target": "文档目录" - }, - { - "source": "Credits", - "target": "致谢" - }, - { - "source": "Features", - "target": "功能" - }, - { - "source": "DMs", - "target": "私信" - }, - { - "source": "DM", - "target": "私信" - }, - { - "source": "sandbox", - "target": "沙箱" - }, - { - "source": "Sandbox", - "target": "沙箱" - }, - { - "source": "sandboxing", - "target": "沙箱隔离" - }, - { - "source": "Sandboxing", - "target": "沙箱隔离" - }, - { - "source": "sandboxed", - "target": "沙箱隔离" - }, - { - "source": "Sandboxed", - "target": "沙箱隔离" - }, - { - "source": "Sandboxing note", - "target": "沙箱注意事项" - }, - { - "source": "Companion apps", - "target": "配套应用" - }, - { - "source": "expected keys", - "target": "预期键名" - }, - { - "source": "block streaming", - "target": "分块流式传输" - }, - { - "source": "Block streaming", - "target": "分块流式传输" - }, - { - "source": "Discovery + transports", - "target": "设备发现 + 传输协议" - }, - { - "source": "Discovery", - "target": "设备发现" - }, - { - "source": "Network model", - "target": "网络模型" - }, - { - "source": "for full details", - "target": "了解详情" - }, - { - "source": "First 60 seconds", - "target": "最初的六十秒" - }, - { - "source": "Auth: where it lives (important)", - "target": "凭证:存储位置(重要)" - }, - { - "source": "agent", - "target": "智能体" - }, - { - "source": "channel", - "target": "渠道" - }, - { - "source": "session", - "target": "会话" - }, - { - "source": "provider", - "target": "提供商" - }, - { - "source": "model", - "target": "模型" - }, - { - "source": "tool", - "target": "工具" - }, - { - "source": "CLI", - "target": "CLI" - }, - { - "source": "install sanity", - "target": "安装完整性检查" - }, - { - "source": "get unstuck", - "target": "解决问题" - }, - { - "source": "troubleshooting", - "target": "故障排除" - }, - { - "source": "FAQ", - "target": "常见问题" - }, - { - "source": "onboarding", - "target": "新手引导" - }, - { - "source": "Onboarding", - "target": "新手引导" - }, - { - "source": "wizard", - "target": "向导" - }, - { - "source": "environment variables", - "target": "环境变量" - }, - { - "source": "environment variable", - "target": "环境变量" - }, - { - "source": "env vars", - "target": "环境变量" - }, - { - "source": "env var", - "target": "环境变量" - } -] diff --git a/docs/.i18n/ja-JP.tm.jsonl b/docs/.i18n/ja-JP.tm.jsonl deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/docs/.i18n/zh-CN.tm.jsonl b/docs/.i18n/zh-CN.tm.jsonl deleted file mode 100644 index 24076e5a08a..00000000000 --- a/docs/.i18n/zh-CN.tm.jsonl +++ /dev/null @@ -1,1329 +0,0 @@ -{"cache_key":"001616450ecb371df73ba42e487328ded133e15d365d7ddc15d47eaf467d2e6c","segment_id":"index.md:468886872909c70d","source_path":"index.md","text_hash":"468886872909c70d3bfb4836ec60a6485f4cbbd0f8a0acedbacb9b477f01a251","text":"Workspace templates","translated":"工作区模板","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:32:17Z"} -{"cache_key":"0090cc37997fe8660527538473feb7d0e0535dfb0015e52d13f2e7ad09bbe185","segment_id":"start/getting-started.md:edeb36e62e1bf30e","source_path":"start/getting-started.md","text_hash":"edeb36e62e1bf30e192bc1951ed9c3f6c65f7d300f926c071c245671dfb5855c","text":"If you’re hacking on OpenClaw itself, run from source:","translated":"如果您正在开发 OpenClaw 本身,请从源码运行:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:37:45Z"} -{"cache_key":"00ee1ece05b05ab7b12cfe673000c037bb2037fe93a069a71ec2368184e83944","segment_id":"index.md:45e6d69dbe995a36","source_path":"index.md","text_hash":"45e6d69dbe995a36f7bc20755eff4eb4d2afaaedbcac4668ab62540c57219f32","text":"macOS app","translated":"macOS 应用","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:05:06Z"} -{"cache_key":"00eeb87b1774979860c4b016d48e416ab9157539c41f5f3f0c58c1deb8f075c9","segment_id":"environment.md:frontmatter:read_when:2","source_path":"environment.md:frontmatter:read_when:2","text_hash":"822b3d74ce16c1be19059fad4ca5bf7ae9327f58fa1ff4e75e78d5afa75c038f","text":"You are documenting provider auth or deployment environments","translated":"你正在记录提供商认证或部署环境的相关文档","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:15:51Z"} -{"cache_key":"01063749652c55481b7da485911a80de3049ded0257874b376efbc55a14293a7","segment_id":"start/wizard.md:037b8f564390e097","source_path":"start/wizard.md","text_hash":"037b8f564390e09742421c621a1f785d2ee5338d0c680c76f7a9b991518e909d","text":" and optional ","translated":" 和可选的 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:48:48Z"} -{"cache_key":"01814fd9d09399c075081056c6fa2befa388c67ba4f8745122804fd044fd82d6","segment_id":"start/getting-started.md:d1564fd156e28160","source_path":"start/getting-started.md","text_hash":"d1564fd156e28160c83922ad7a18428ce2c966e790f477e740d1d9f6cadd51e9","text":"WhatsApp (QR login)","translated":"WhatsApp(二维码登录)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:37:08Z"} -{"cache_key":"01b87576d7ade6b91ca28935f65c167c2f4fb5d1b6bfd1189fd416b229500af4","segment_id":"start/getting-started.md:7421b911bc203f6f","source_path":"start/getting-started.md","text_hash":"7421b911bc203f6fe3c677d752379f23dc314719d39d18179406da675f58d039","text":"Scan via WhatsApp → Settings → Linked Devices.","translated":"通过 WhatsApp → 设置 → 已关联设备 进行扫描。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:37:10Z"} -{"cache_key":"01d8d8ec84ad8f4c74e29e254e56c02f7d75005160c27d99e9ce183767e16c55","segment_id":"index.md:6b8ebac7903757ce","source_path":"index.md","text_hash":"6b8ebac7903757ce7399cc729651a27e459903c24c64aa94827b20d8a2a411d2","text":"For Tailnet access, run ","translated":"如需 Tailnet 访问,请运行 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:00:08Z"} -{"cache_key":"024efbb5ac15e07c191effa78c0b23bf173c8af6725e988743ea055e9a4e8c3b","segment_id":"index.md:f9b8279bc46e847b","source_path":"index.md","text_hash":"f9b8279bc46e847bfcc47b8701fd5c5dc27baa304d5add8278a7f97925c3ec13","text":"Mattermost (plugin)","translated":"Mattermost(插件)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:32:40Z"} -{"cache_key":"02d1e10492e8721462f16e39467b94ad3197e4eb76f6d671a09b4246d5b4d27b","segment_id":"start/getting-started.md:7ac362063b9f2046","source_path":"start/getting-started.md","text_hash":"7ac362063b9f204602f38f9f1ec9cf047f03e0d7b83896571c9df6d31ad41e9c","text":"Nodes","translated":"节点","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:38:28Z"} -{"cache_key":"02f39c075115bee6bdb015a49436f2b2a56365b87558fdd7aff7b17cb83bff6c","segment_id":"environment.md:frontmatter:summary","source_path":"environment.md:frontmatter:summary","text_hash":"78351223e7068721146d2de022fdf440c2866b2ee02fbbb50bf64369b999820b","text":"Where OpenClaw loads environment variables and the precedence order","translated":"OpenClaw 加载环境变量的位置及优先级顺序","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:19:12Z"} -{"cache_key":"02f4067265058ed8929f3772d87e1c5dc0af8422b8e7b513b7db155108a422c3","segment_id":"start/wizard.md:961eb43699731759","source_path":"start/wizard.md","text_hash":"961eb43699731759fd0d04f177bb24f09971bddd41426702276e761269d0a5b9","text":" does ","translated":" 会 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:40:48Z"} -{"cache_key":"034b5fd57fbe77f6aabf0b737f4d387868eef9ac32fcc372692191887cb16759","segment_id":"environment.md:ab5aec4424cf678d","source_path":"environment.md","text_hash":"ab5aec4424cf678dcfb1ad3d2c2929c1e0b2b1ff61b82b961ada48ad033367b4","text":" (dotenv default; does not override).","translated":" (dotenv 默认行为;不覆盖已有值)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:12:07Z"} -{"cache_key":"0361ba41cec2736ce451f58e657735b2d4811f0c3af53356ee56277f825899a3","segment_id":"index.md:7e2735e5df8f4e9f","source_path":"index.md","text_hash":"7e2735e5df8f4e9f006d10e079fe8045612aa662b02a9d1948081d1173798dec","text":"MIT — Free as a lobster in the ocean 🦞","translated":"MIT —— 像海洋中的龙虾一样自由 🦞","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:55:01Z"} -{"cache_key":"039c1b4477a6425d1ad785665fa3ad0b9236c514cd807422215eeb4dc76d4378","segment_id":"start/getting-started.md:e3209251e20896ec","source_path":"start/getting-started.md","text_hash":"e3209251e20896ecc60fa4da2817639f317fbb576288a9fc52d11e5030ecc44a","text":"Windows (WSL2)","translated":"Windows (WSL2)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:35:44Z"} -{"cache_key":"0457a19cd3a82171f6cdb92d82d5a0f6358da4c1220d42d5b0575bde871e7f91","segment_id":"environment.md:e234227b0e001687","source_path":"environment.md","text_hash":"e234227b0e001687821541fac3af38fc6be293ec6e13910c6826b9afc8ca33be","text":" syntax:","translated":" 语法:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:13:00Z"} -{"cache_key":"045fb6f3989827561e347dfa56a164069bf8b7afaa50d2d02c20ad264495d351","segment_id":"index.md:e9f63c8876aec738","source_path":"index.md","text_hash":"e9f63c8876aec7381ffb5a68efb39f50525f9fc4e732857488561516d47f5654","text":" — Uses Baileys for WhatsApp Web protocol","translated":" — 使用 Baileys 实现 WhatsApp Web 协议","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:29:31Z"} -{"cache_key":"046c83b658b7dd8bce829f07bd09dcee3413753ab72cf95d638925aa163d3486","segment_id":"start/getting-started.md:f4117324994aaad1","source_path":"start/getting-started.md","text_hash":"f4117324994aaad1d3413064ade8f2037e43ab2fac0b385d731ff154925ec3b3","text":"Windows (PowerShell):","translated":"Windows (PowerShell):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:35:51Z"} -{"cache_key":"04d48cfdb6b444cb4691ea55a5deb23df20694659ae1bc5e082e242e749f5e3c","segment_id":"help/index.md:bfc5930cc2660330","source_path":"help/index.md","text_hash":"bfc5930cc2660330260afd407e98d86adaec0af48dd72b88dc33ef8e9066e2c9","text":"Install sanity (Node/npm/PATH):","translated":"安装完整性检查(Node/npm/PATH):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:39:38Z"} -{"cache_key":"05405710e256b2e1031234be855a7c11cf1505c627df14884d655fa42a1568a7","segment_id":"index.md:f0a7f9d068cb7a14","source_path":"index.md","text_hash":"f0a7f9d068cb7a146d0bb89b3703688d690ed0b92734b78bcdb909aace617dbf","text":"WhatsApp group messages","translated":"WhatsApp 群组消息","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:32:45Z"} -{"cache_key":"064dcdb5051313b748c0b775ec69683149e1861d84fa47a74c68ddd8086bdebc","segment_id":"index.md:81a1c0449ea684aa","source_path":"index.md","text_hash":"81a1c0449ea684aadad54a7f8575061ddc5bfa713b6ca3eb8a0228843d2a3ea1","text":"Nodes (iOS/Android)","translated":"节点(iOS/Android)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:52:57Z"} -{"cache_key":"0687549a28e71ec1e17b261001a9e818e27784ce3286b7d21e856e37c07915a6","segment_id":"start/getting-started.md:bad5d156dc5e0cd3","source_path":"start/getting-started.md","text_hash":"bad5d156dc5e0cd39db3a90645cd150e846743103f3acfa5182ad5a003a172dc","text":"0) Prereqs","translated":"0)前提条件","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:35:23Z"} -{"cache_key":"06c13f0dfc6cd5fa142e329fd2cfb2538e19e33de83c4b9d366542f0d03cdf08","segment_id":"index.md:c3af076f92c5ed8d","source_path":"index.md","text_hash":"c3af076f92c5ed8dcb0d0b0d36dd120bc31b68264efea96cf8019ca19f1c13a3","text":"Troubleshooting","translated":"故障排除","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:33:14Z"} -{"cache_key":"06dfb5ef154c29e0961021acb7bcdb34a790d58d9f6f7a59165e7a423ef0f2df","segment_id":"start/wizard.md:0be68bd5c21e5e4d","source_path":"start/wizard.md","text_hash":"0be68bd5c21e5e4de598fc71e32c131ce8c742976a344ac4d9973ef08942eacb","text":"Workspace default (or existing workspace)","translated":"默认工作区(或现有工作区)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:39:47Z"} -{"cache_key":"071b444d331ae100d5b17caba7748f4d01e9e829b6951ac8b8903bfdb7c00349","segment_id":"environment.md:frontmatter:read_when:2","source_path":"environment.md:frontmatter:read_when:2","text_hash":"822b3d74ce16c1be19059fad4ca5bf7ae9327f58fa1ff4e75e78d5afa75c038f","text":"You are documenting provider auth or deployment environments","translated":"您正在记录 提供商 的认证或部署环境","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:57:06Z"} -{"cache_key":"074a96a2803f1b7e25df2097aa35c976d3c4bf3355dcb878991d99ceb398cae6","segment_id":"start/wizard.md:2b39d5818b91d602","source_path":"start/wizard.md","text_hash":"2b39d5818b91d602d9aeaaaf38d7de37f9e89553f3edcdf114ae2f43cc8ca399","text":"Full workspace layout + backup guide: ","translated":"完整工作区布局 + 备份指南: ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:43:49Z"} -{"cache_key":"078dae6e59f75f6c474a6f696cdc942ab423be6fcd1bf1bd4b589a665766de76","segment_id":"index.md:310cc8cec6b20a30","source_path":"index.md","text_hash":"310cc8cec6b20a3003ffab12f5aade078a0e7a7d6a27ff166d62ab4c3a1ee23d","text":"If you ","translated":"如果您 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:51:52Z"} -{"cache_key":"07e0c1ac79c7958e1152f210f5fa32881aab6766711be06e94d0a324e62f4ea3","segment_id":"environment.md:e234227b0e001687","source_path":"environment.md","text_hash":"e234227b0e001687821541fac3af38fc6be293ec6e13910c6826b9afc8ca33be","text":" syntax:","translated":" 语法:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:22:34Z"} -{"cache_key":"083df0fcf5941871ec509cf41a264e951eb0dea21cf3572cde4180a766ac43c8","segment_id":"index.md:3c064c83b8d244fe","source_path":"index.md","text_hash":"3c064c83b8d244fef61e5fd8ce5f070b857a3578a71745e61eea02892788c020","text":" — Anthropic (Claude Pro/Max) + OpenAI (ChatGPT/Codex) via OAuth","translated":" — Anthropic(Claude Pro/Max)+ OpenAI(ChatGPT/Codex)通过 OAuth","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:01:54Z"} -{"cache_key":"086e200198761d02e2a28bec15b1df356262d9643c0fa8baded9caedae854526","segment_id":"environment.md:46ab081177a452aa","source_path":"environment.md","text_hash":"46ab081177a452aa62354b581730f4675cb03e58cde8282071da30cabe18fb2e","text":"Optional login-shell import","translated":"可选的登录 shell 导入","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:46:11Z"} -{"cache_key":"086eabdd4e052418c6234dccf807220465b0eaaef349b91be635a3128a71857a","segment_id":"index.md:9182ff69cf35cb47","source_path":"index.md","text_hash":"9182ff69cf35cb477c02452600d23b52a49db7bd7c9833a9a8bc1dcd90c25812","text":"Node ≥ 22","translated":"Node ≥ 22","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:31:10Z"} -{"cache_key":"08a071c1e71388ad18ffca39565a37edb304794146d2f7ea1e2bac93493f89d6","segment_id":"start/wizard.md:903ea1cf1f2831b3","source_path":"start/wizard.md","text_hash":"903ea1cf1f2831b3e836aff6e23c7d261a83381614361e65df16ade48e84b26c","text":" (API keys + OAuth).","translated":" (API 密钥 + OAuth)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:43:34Z"} -{"cache_key":"08b4ff7a8e04409d740ca4090c8d83bc3b05d7084bce4b83fa4c91b930eb7161","segment_id":"environment.md:62d66b8c36a6c9aa","source_path":"environment.md","text_hash":"62d66b8c36a6c9aa7134c8f9fe5912435cb0b3bfce3172712646a187954e7040","text":"See [Configuration: Env var substitution](/gateway/configuration#env-var-substitution-in-config) for full details.","translated":"详见 [配置:环境变量替换](/gateway/configuration#env-var-substitution-in-config)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:11:58Z"} -{"cache_key":"08f97e3d7baa10a515db441b79273f697f85c83da040cdf821f9e725243112f2","segment_id":"environment.md:f6b2ffe1d0d5f521","source_path":"environment.md","text_hash":"f6b2ffe1d0d5f521b76cabc67d6e96da2b1170eef8086d530558e9906a7f092d","text":"Models overview","translated":"模型概览","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:17:17Z"} -{"cache_key":"090f33f5db1fde14d7fc04aaa9febae674e9e6ed0d04ce8f1813dac53ccae3a2","segment_id":"start/wizard.md:ab4386608f0ebc6e","source_path":"start/wizard.md","text_hash":"ab4386608f0ebc6e151eab042c6de71d09863aab6dcb2551665e34210e4a4439","text":"What you’ll set:","translated":"您需要设置的内容:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:46:15Z"} -{"cache_key":"09824fcf1352f54ff162268163b8670ead0660d4e0a45d1f236b5b3ef938a56b","segment_id":"index.md:86e2bbbc305c31aa","source_path":"index.md","text_hash":"86e2bbbc305c31aa988751196a1e207da68801a48798c48b90485c11578443a0","text":"Providers and UX:","translated":"提供商 和用户体验:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:04:39Z"} -{"cache_key":"0a2b53b4943a0ba87fb991fef20f822df6c2fd0584f88d394de35b081daac564","segment_id":"environment.md:668e5590b5bb9990","source_path":"environment.md","text_hash":"668e5590b5bb9990eeb25bf657f7d17281a4c613ee4442036787cd4b2efd22bb","text":"If the config file is missing entirely, step 4 is skipped; shell import still runs if enabled.","translated":"如果配置文件完全缺失,则跳过第 4 步;如果已启用,shell 导入仍会运行。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:58:00Z"} -{"cache_key":"0a3f3c5b73fe0ebee73b07c3c4f4067a75ecf6a9ff30b8b77bf67227b125fee2","segment_id":"index.md:042c75df73389c8a","source_path":"index.md","text_hash":"042c75df73389c8a7c0871d2a451bd20431d24e908e2c192827a54022df95005","text":"Nacho Iacovino","translated":"Nacho Iacovino","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:54:50Z"} -{"cache_key":"0a4eb623efd2d7af50da4f933f490fa1b7addfe2619ab721d9fcd4f2a2302e6a","segment_id":"help/index.md:b79cac926e0b2e34","source_path":"help/index.md","text_hash":"b79cac926e0b2e347e72cc91d5174037c9e17ae7733fd7bdb570f71b10cd7bfc","text":"Help","translated":"帮助","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:44:29Z"} -{"cache_key":"0a4eb82ad59541cc64eceae3ce7e41ee4739d9a7c742146611fa96b11bdd272d","segment_id":"environment.md:frontmatter:read_when:2","source_path":"environment.md:frontmatter:read_when:2","text_hash":"822b3d74ce16c1be19059fad4ca5bf7ae9327f58fa1ff4e75e78d5afa75c038f","text":"You are documenting provider auth or deployment environments","translated":"你正在编写提供商认证或部署环境的文档","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:21:53Z"} -{"cache_key":"0a7b709303c429dd14ac8df8ef88c398cdf45a678f7beeacf08413bd16d2fc3d","segment_id":"index.md:e9f63c8876aec738","source_path":"index.md","text_hash":"e9f63c8876aec7381ffb5a68efb39f50525f9fc4e732857488561516d47f5654","text":" — Uses Baileys for WhatsApp Web protocol","translated":" — 使用 Baileys 实现 WhatsApp Web 协议","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:00:52Z"} -{"cache_key":"0aa65de003f8c68c46bc14dc4d66d04efaf025ddd6a1a3cdec1b51ecbe3ecc8a","segment_id":"start/getting-started.md:317f690133d02b19","source_path":"start/getting-started.md","text_hash":"317f690133d02b1969bfcbf6d76a7c0e6efa2b0839e8510227135359a535a5c0","text":"In a new terminal, send a test message:","translated":"在新终端中,发送一条测试消息:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:37:59Z"} -{"cache_key":"0aaaa653a1bad3c2f1d6bbf34819ea4ae8700ea5d6c593937aa6812051809168","segment_id":"environment.md:453c14128fbfb5f6","source_path":"environment.md","text_hash":"453c14128fbfb5f6757511557132a1dbb3bcbf243267630bfec49db8518c7780","text":"Env var substitution in config","translated":"配置中的 环境变量 替换","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:58:16Z"} -{"cache_key":"0b149311bd258e33ab5e06f16483d6b14bfb23bbf8137339bc4cf8d29e2d3d5c","segment_id":"environment.md:453c14128fbfb5f6","source_path":"environment.md","text_hash":"453c14128fbfb5f6757511557132a1dbb3bcbf243267630bfec49db8518c7780","text":"Env var substitution in config","translated":"配置中的环境变量替换","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:11:54Z"} -{"cache_key":"0b68a76b412628864a90e4b194a0db6bcc593e8700ee9228d04b45427a95c7af","segment_id":"environment.md:cf3f9ba035da9f09","source_path":"environment.md","text_hash":"cf3f9ba035da9f09202ba669adca3109148811ef31d484cc2efa1ff50a1621b1","text":" (what the Gateway process already has from the parent shell/daemon).","translated":" (Gateway 进程从父 shell/守护进程继承的已有环境变量)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:57:23Z"} -{"cache_key":"0bab5344d37eb10f7f0a1105ba4cf723e069867a7f745d016657752c1dc0c21a","segment_id":"environment.md:5105555b1be5f84b","source_path":"environment.md","text_hash":"5105555b1be5f84b47576d6ea432675cef742e63fa52f7b254ef2aa4c90e7cca","text":" (applied only if","translated":" (仅在","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:46:04Z"} -{"cache_key":"0bbc0779389fa7b103e39fff721c2df8f37e36a72350175e61b8334f79dd6555","segment_id":"index.md:0b7e778664921066","source_path":"index.md","text_hash":"0b7e77866492106632e98e7718a8e1e89e8cb0ee3f44c1572dfd9e54845023de","text":"/concepts/streaming","translated":"/concepts/streaming","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:30:06Z"} -{"cache_key":"0bda3d8fa9978471f16800fbab17622f054477505f8a680d6165e924184818eb","segment_id":"index.md:3fc5f55ea5862824","source_path":"index.md","text_hash":"3fc5f55ea5862824fc266d26cd39fb5da22cc56670c11905d5743adac10bc9ef","text":"Mattermost Bot (plugin)","translated":"Mattermost 机器人(插件)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:29:46Z"} -{"cache_key":"0bfc8cf7aac6d36a53bc389ddf8a828f323fa60964b005f84cb8aa00f8ab38e9","segment_id":"environment.md:aac7246f5e97142c","source_path":"environment.md","text_hash":"aac7246f5e97142c3f257b7d8b84976f10c29e1b89804bb9d3eb7c43cc03cb8e","text":"Environment variables","translated":"环境变量","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:40:19Z"} -{"cache_key":"0c1c52efad88743449d31ff51deb8a6275b8c13b9ceea60011208ad696fc3e8e","segment_id":"environment.md:61115f6649792387","source_path":"environment.md","text_hash":"61115f664979238731a390e84433a818965b7eaf1d38fa5b4b1507c33ef28c91","text":"Precedence (highest → lowest)","translated":"优先级(从高到低)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:11:57Z"} -{"cache_key":"0c828040f85df6effef7a39f7d06d9158493bcfd8c0981f5d6d2c5002f06181e","segment_id":"index.md:3c8aa7ad1cfe03c1","source_path":"index.md","text_hash":"3c8aa7ad1cfe03c1cb68d48f0c155903ca49f14c9b5626059d279bffc98a8f4e","text":": connect to the Gateway WebSocket (LAN/tailnet/SSH as needed); legacy TCP bridge is deprecated/removed.","translated":":通过 WebSocket 连接到 Gateway(根据需要使用局域网/Tailnet/SSH);旧版 TCP 桥接已弃用/移除。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:29:04Z"} -{"cache_key":"0cbb0b48efe1edd1ee9cf73b486ba3c4ecd0b316ed8355432c734a6ae2ff9828","segment_id":"index.md:a42f01be614f75f1","source_path":"index.md","text_hash":"a42f01be614f75f16278b390094dc43923f0b1b7d8e3209b3f43e356f42ed982","text":"), a single long-running process that owns channel connections and the WebSocket control plane.","translated":"进行,它是一个长期运行的单进程,负责管理渠道连接和 WebSocket 控制面。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:28:37Z"} -{"cache_key":"0cbb380393c622f57aedb33e0d64521926af5db0134afe3dbd8a1b24aaa3eac1","segment_id":"help/index.md:8ddb7fc8a87904de","source_path":"help/index.md","text_hash":"8ddb7fc8a87904dedc2afc16400fbe4e78582b302e01c30b1319c8a465d04684","text":"Troubleshooting:","translated":"故障排除:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:24:36Z"} -{"cache_key":"0cc5aed5ba117c3d267480c23a673d693068648c5bfffaacfdc33f4650533f2a","segment_id":"index.md:10bf8b343a32f7dc","source_path":"index.md","text_hash":"10bf8b343a32f7dc01276fc8ae5cf8082e1b39c61c12d0de8ec9b596e115c981","text":"WebChat","translated":"网页聊天","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:32:32Z"} -{"cache_key":"0d1e17e509bbc4aa27b446bec9af66b9246950ea0dfb619dd35f21534c317143","segment_id":"index.md:b5ccaf9b1449291c","source_path":"index.md","text_hash":"b5ccaf9b1449291c92f855b8318aeb2880a9aa1a75272d17f55cf646071b3eae","text":"Gmail hooks (Pub/Sub)","translated":"Gmail 钩子(Pub/Sub)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:33:09Z"} -{"cache_key":"0dad422eb096806e226c53202949206185916bda859e38301da854b681b25963","segment_id":"environment.md:frontmatter:read_when:1","source_path":"environment.md:frontmatter:read_when:1","text_hash":"a3a2d99a99de98220c8e0296d6f4e4b2a34024916bd2379d1b3b9179c8fae46f","text":"You are debugging missing API keys in the Gateway","translated":"你正在调试 Gateway 中缺失的 API 密钥","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:25:10Z"} -{"cache_key":"0dae07beb7b056f37a0ca939d95614b19e7c66b60741530d3b8697cc4a7bdbb7","segment_id":"index.md:bbf8779fd9010043","source_path":"index.md","text_hash":"bbf8779fd9010043ac23a2f89ba34901f3a1f58296539c3177d51a9040ea209d","text":") — Blogwatcher skill","translated":")— Blogwatcher 技能","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:33:53Z"} -{"cache_key":"0df8549623b0d7f6737342296a4696b34206e074d5a552cb6f37d6c439e85b79","segment_id":"environment.md:f15f5f9f4ef4d668","source_path":"environment.md","text_hash":"f15f5f9f4ef4d6688876c894f8eba251ed1db6eaf2209084028d43c9e76a8ba1","text":" (aka ","translated":" (即 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:25:41Z"} -{"cache_key":"0e01c4c62bbe121aee9533c17fd055ee1538caf46007bb0938ee7d361ae5d52b","segment_id":"environment.md:45ca56d179d4788c","source_path":"environment.md","text_hash":"45ca56d179d4788c55ba9f7653b376d62e7faa738e92259e3d4f6f5c1b554f28","text":"Related","translated":"相关内容","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:41:37Z"} -{"cache_key":"0e375d889ea9a49f6c0e03a2e49ea5a681ffd031303237117f0dfdf54fb917e8","segment_id":"start/wizard.md:abf42990b17ccc52","source_path":"start/wizard.md","text_hash":"abf42990b17ccc52870da0c8026ddafa221bc57d87d755a64d74fcd408395435","text":"Full reset (also removes workspace)","translated":"完全重置(同时移除工作区)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:41:43Z"} -{"cache_key":"0ed1d8cb8838fead312d097caca4f56b5c69e0486833919892e2fc368b933b15","segment_id":"help/index.md:6201111b83a0cb5b","source_path":"help/index.md","text_hash":"6201111b83a0cb5b0922cb37cc442b9a40e24e3b1ce100a4bb204f4c63fd2ac0","text":" and ","translated":" 和 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:15:30Z"} -{"cache_key":"0ef13ceaf849a114db93b4137cdc043c8ba6ba5d2b1cf2ddea7779850d137e5c","segment_id":"index.md:81023dcc765309dd","source_path":"index.md","text_hash":"81023dcc765309dd05af7638f927fd7faa070c58abe7cad33c378aa02db9baa2","text":" (token is required for non-loopback binds).","translated":" (非回环绑定时必须提供令牌)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:28:55Z"} -{"cache_key":"0ef99bf8be4557b02f6fc6a43848b16ec9656f205332bd687cbcc0c8b8fce99d","segment_id":"start/getting-started.md:c4b2896a2081395e","source_path":"start/getting-started.md","text_hash":"c4b2896a2081395e282313d6683f07c81e3339ef8b9d2b5a299ea5b626a0998f","text":").","translated":")。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:36:57Z"} -{"cache_key":"0f0645e14c15177b60a1a2e14a18e220e0ad397f483ad8a0cad68ea3d5a3bc44","segment_id":"start/wizard.md:4a85827ad80e8635","source_path":"start/wizard.md","text_hash":"4a85827ad80e8635fb4a2b41a3fce1d0f23ba1eb27db0aa84113a7b0ca415d42","text":"Synthetic","translated":"Synthetic","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:43:05Z"} -{"cache_key":"0f51ea5ec00d63f74853135fc04c205c09a3b3fd519a80fb8a83bf504ed6d041","segment_id":"index.md:032f5589cfa2b449","source_path":"index.md","text_hash":"032f5589cfa2b44973fe96c42e17dcc2692281413a05b16f48ff0f958f7f7ade","text":"Discord Bot","translated":"Discord 机器人","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:29:41Z"} -{"cache_key":"0f5a75040351402afe8493774c6b74576b064ee93723b03a03a345c5e6dcb986","segment_id":"environment.md:b4736422e64c0a36","source_path":"environment.md","text_hash":"b4736422e64c0a369663d1b2d386f1b8f4b31b8936b588e4a54453c61a24e0fd","text":"Process environment","translated":"进程环境","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:16:03Z"} -{"cache_key":"0f5fbe9d6968fcf81b97b0938d0191012b2512171268e21a0252476981018364","segment_id":"index.md:fdef9f917ee2f72f","source_path":"index.md","text_hash":"fdef9f917ee2f72fbd5c08b709272d28a2ae7ad8787c7d3b973063f0ebeeff7a","text":" to update the gateway service entrypoint.","translated":" 以更新 Gateway 服务入口点。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:31:15Z"} -{"cache_key":"0fc566f2207136599a99330b18f7b5a871db5129d3b99079d06a612b73acf825","segment_id":"index.md:268ebcd6be28e8d8","source_path":"index.md","text_hash":"268ebcd6be28e8d853ace3a6e28f269fbda1343b53e3f0de97ea3d5bf1a0e33e","text":"Clawd","translated":"Clawd","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:33:42Z"} -{"cache_key":"0fe42f35cd75dae1544040ac532880db182effb28cb15f90f3e180965d450f3c","segment_id":"start/wizard.md:ba5ec51d07a4ac0e","source_path":"start/wizard.md","text_hash":"ba5ec51d07a4ac0e951608704431d59a02b21a4e951acc10505a8dc407c501ee","text":")","translated":")","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:46:20Z"} -{"cache_key":"10424bff17e00e154be3be8a5c6595baabbbdbf533eb28142124ba7d3fe2f265","segment_id":"environment.md:582967534d0f909d","source_path":"environment.md","text_hash":"582967534d0f909d196b97f9e6921342777aea87b46fa52df165389db1fb8ccf","text":" in ","translated":" 在 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:57:45Z"} -{"cache_key":"105a552d339b5cc747f8939f6c1b54d6f7d6c411a850f38980a0fb1be67195e0","segment_id":"index.md:95cae5ed127bd44d","source_path":"index.md","text_hash":"95cae5ed127bd44dcc30345a1925d77f333284b43a6f97832f149a63dc38e0e0","text":"The wizard now generates a gateway token by default (even for loopback).","translated":"向导现在默认会生成一个 Gateway 令牌(即使在回环模式下也是如此)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:28:52Z"} -{"cache_key":"10920f79a810b1b47492e9ed0d361ef42a495b2f73a494ec40eb09e75c35bb96","segment_id":"index.md:95cae5ed127bd44d","source_path":"index.md","text_hash":"95cae5ed127bd44dcc30345a1925d77f333284b43a6f97832f149a63dc38e0e0","text":"The wizard now generates a gateway token by default (even for loopback).","translated":"向导 现在默认会生成一个网关令牌(即使是回环连接也是如此)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:48:43Z"} -{"cache_key":"10a05f1dce0af95edaca2aefc99dda7d1315b6b1d57e2b3021652fe20af68eb7","segment_id":"index.md:cf9f12b2c24ada73","source_path":"index.md","text_hash":"cf9f12b2c24ada73bb0474c0251333f65e6d5d50e56e605bdb264ff32ad0a588","text":"Config lives at ","translated":"配置文件位于 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:31:29Z"} -{"cache_key":"10a57e9dff1afe6e19b169eebc46fb2bc623dc74996f695c059c259a5d01b11f","segment_id":"environment.md:e234227b0e001687","source_path":"environment.md","text_hash":"e234227b0e001687821541fac3af38fc6be293ec6e13910c6826b9afc8ca33be","text":" syntax:","translated":" 语法:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:26:26Z"} -{"cache_key":"10ad8d1314a510acc92dd184df9be180aea8c032323637e317be12bff654aefa","segment_id":"start/wizard.md:7398946ba352a7c8","source_path":"start/wizard.md","text_hash":"7398946ba352a7c8b21e60b2474d1ba7190707d9a04a6904103217e177f67482","text":"Summary + next steps, including iOS/Android/macOS apps for extra features.","translated":"摘要 + 后续步骤,包括 iOS/Android/macOS 应用以获取额外功能。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:46:01Z"} -{"cache_key":"111e143c2961901491d16f639ad9d9bf0203700f41ad61862f0c0e09548d85ca","segment_id":"index.md:42bb365211decccb","source_path":"index.md","text_hash":"42bb365211decccb3509f3bf8c4dfcb5ae05fe36dfdedb000cbf44e59e420dc9","text":" — Local imsg CLI integration (macOS)","translated":" — 本地 imsg CLI 集成(macOS)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:29:53Z"} -{"cache_key":"11951539669d912b24dac16f9ed27e1de0a950a3baa481474a65de0ca85fbe7b","segment_id":"start/wizard.md:ec2d0a7d20f3b660","source_path":"start/wizard.md","text_hash":"ec2d0a7d20f3b6602a6593e0abef2337e84ba728ca8f6fef2534dc1e9dbfe06b","text":"Remote mode configures a local client to connect to a Gateway elsewhere.","translated":"远程模式配置本地客户端以连接到其他位置的 Gateway。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:46:13Z"} -{"cache_key":"11a42ddb57b9c1ba4022984efe25b463da52e7b9c5d7ec3a925d7a6d0e5a6c39","segment_id":"index.md:cdb4ee2aea69cc6a","source_path":"index.md","text_hash":"cdb4ee2aea69cc6a83331bbe96dc2caa9a299d21329efb0336fc02a82e1839a8","text":".","translated":".","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:28:19Z"} -{"cache_key":"11a6809809867ab84f2a66da213f7894876530602a0743b37fc93e614c7ccbfe","segment_id":"help/index.md:71095a6d42f5d9c2","source_path":"help/index.md","text_hash":"71095a6d42f5d9c2464a8e3f231fc53636d4ce0f9356b645d245874162ec07e2","text":"Gateway troubleshooting","translated":"Gateway 故障排除","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:39:44Z"} -{"cache_key":"1226fe0b47712f49a01581113142855bc5ae36e3289353b5d592ece5191b0159","segment_id":"start/wizard.md:c90e6f2be18d7e02","source_path":"start/wizard.md","text_hash":"c90e6f2be18d7e02413e18d4174fe7d855c9753005652614556204123b37c96e","text":": browser flow; paste the ","translated":":浏览器流程;粘贴 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:42:18Z"} -{"cache_key":"1249a5c279b0761418bca0826571d62b0526075a0c91018c35002331e3c6d6b5","segment_id":"environment.md:aac7246f5e97142c","source_path":"environment.md","text_hash":"aac7246f5e97142c3f257b7d8b84976f10c29e1b89804bb9d3eb7c43cc03cb8e","text":"Environment variables","translated":"环境变量","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:25:14Z"} -{"cache_key":"124e4ad52161941e1842f43e4f5d0c12d573babaf3f319ec7d5db46ba8ee7e84","segment_id":"index.md:0b60fe04b3c5c3c7","source_path":"index.md","text_hash":"0b60fe04b3c5c3c76371b6eca8b19c8e09a0e54c9010711ff87e782d87d2190b","text":"Android app","translated":"Android 应用","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:32:57Z"} -{"cache_key":"135f65a9b168054bcbe82dd61f4309c2dda482ef1e442ec7eec710c8f597b97c","segment_id":"start/getting-started.md:d2da561767068503","source_path":"start/getting-started.md","text_hash":"d2da56176706850367dee94ffc2a1daf962c84f7a9cbf61aa379ddc33bcbaf95","text":"If you want the deeper reference pages, jump to: ","translated":"如果您需要更详细的参考页面,请跳转至: ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:35:05Z"} -{"cache_key":"1370cc167f786bd13af7db472a718a3029e35e284c8a6878d5d0945490b59eec","segment_id":"start/getting-started.md:66354a1d3225edbf","source_path":"start/getting-started.md","text_hash":"66354a1d3225edbf01146504d06aaea1242dcf50424054c3001fc6fa2ddece0f","text":"Remote access","translated":"远程访问","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:38:31Z"} -{"cache_key":"13e78cfc5d44bb03f1de8ea274175eb17591ea86da3a5b78f04e97df1a74ff65","segment_id":"index.md:329f3c913c0a1636","source_path":"index.md","text_hash":"329f3c913c0a16363949eb8ee7eb0cda7e81137a3851108019f33e5d18b57d8f","text":"Switching between npm and git installs later is easy: install the other flavor and run ","translated":"之后在 npm 安装和 git 安装之间切换很简单:安装另一种方式并运行 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:31:13Z"} -{"cache_key":"147fcf3acfee0fe1de6932eed18455765effec1024bb00db4f6a2dd367cd9c23","segment_id":"index.md:1016b5bdce94a848","source_path":"index.md","text_hash":"1016b5bdce94a8484312c123416c1a18c29fab915ba2512155df3a82ee097f8f","text":"If the Gateway is running on the same computer, that link opens the browser Control UI\nimmediately. If it fails, start the Gateway first: ","translated":"如果 Gateway 运行在同一台计算机上,该链接会立即打开浏览器控制界面。如果打开失败,请先启动 Gateway: ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:59:25Z"} -{"cache_key":"1490f7c2fc1c4e0b651ef5d269a8acd623cb90b51d6b9814688a95ee8fed4772","segment_id":"index.md:9bd86b0bbc71de88","source_path":"index.md","text_hash":"9bd86b0bbc71de88337aa8ca00f0365c1333c43613b77aaa46394c431cb9afd8","text":"Maxim Vovshin","translated":"Maxim Vovshin","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:06:14Z"} -{"cache_key":"14f523713d1f9204bc80126a5fa7111149e72734cc1c958f6faf344ea347304b","segment_id":"index.md:c6e91f3b51641b1c","source_path":"index.md","text_hash":"c6e91f3b51641b1c43d297281ee782b40d9b3a0bdd7afc144ba86ba329d5f95f","text":"OpenClaw = CLAW + TARDIS","translated":"OpenClaw = CLAW + TARDIS","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:05:38Z"} -{"cache_key":"152ce96ff0d29caf9f3ce55d6a7aca272b4e335f580058a7790cc56b2470233c","segment_id":"index.md:a7a19d4f14d001a5","source_path":"index.md","text_hash":"a7a19d4f14d001a56c27f68a13ff267859a407c7a9ab457c0945693c9067dd1c","text":"Configuration (optional)","translated":"配置(可选)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:51:48Z"} -{"cache_key":"15ffd6a61896c7467d982847033889cbf92f11c42fa93b5f9a46b754780c41e4","segment_id":"start/wizard.md:c18a76f788d27ead","source_path":"start/wizard.md","text_hash":"c18a76f788d27eade089c5e57a4d8d0e64b0e69278ff24b71eb267d915d23646","text":"Model/auth (OpenAI Code (Codex) subscription OAuth, Anthropic API key (recommended) or setup-token (paste), plus MiniMax/GLM/Moonshot/AI Gateway options)","translated":"模型/认证(OpenAI Code (Codex) 订阅 OAuth、Anthropic API 密钥(推荐)或 setup-token(粘贴),以及 MiniMax/GLM/Moonshot/AI Gateway 选项)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:40:18Z"} -{"cache_key":"1609cb1df4c75a8648918d074322a56d17486584efc5dece6e10c3cbd4e37b7e","segment_id":"environment.md:d4a67341570f4656","source_path":"environment.md","text_hash":"d4a67341570f4656784c5f8fe1bfb48a738ace57b52544977431d50e2b718099","text":"FAQ: env vars and .env loading","translated":"常见问题:环境变量和 `.env` 加载","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:17:14Z"} -{"cache_key":"161305a0fe253398ae3cff640449ed26bc7a2f3c52cb3ae71ea8d861cbcce0a0","segment_id":"start/wizard.md:bb1460932d15b59c","source_path":"start/wizard.md","text_hash":"bb1460932d15b59cba3f47b5c93a8d1768a6ba842cd4aa3eba8d2e2540fc0f19","text":"Channel allowlists (Slack/Discord/Matrix/Microsoft Teams) when you opt in during the prompts (names resolve to IDs when possible).","translated":"渠道允许名单(Slack/Discord/Matrix/Microsoft Teams),在提示期间选择启用时生效(名称会尽可能解析为 ID)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:48:42Z"} -{"cache_key":"163bd5cf4e32a3b93891c0acaa17dcbec319fbab2e097d0d8785997528586f02","segment_id":"index.md:d08cec54f66c140c","source_path":"index.md","text_hash":"d08cec54f66c140c655a1631f6d629927c7c38b9c8bfa91c875df9bd3ad3c559","text":"OpenClaw assistant setup","translated":"OpenClaw 助手设置","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:52:39Z"} -{"cache_key":"1699b5d6dd8bd25127b31c4c1dde1c32c99d4d73e8928d3d4240cc4ca7a90948","segment_id":"index.md:872887e563e75957","source_path":"index.md","text_hash":"872887e563e75957ffc20b021332504f2ddd0a8f3964cb93070863bfaf13cdad","text":"Example:","translated":"示例:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:31:44Z"} -{"cache_key":"16df9d8c10cc2590ebcc2313fee468c319259a1c038fcf19a9844754a1c6d0cf","segment_id":"index.md:88d90e2eef3374ce","source_path":"index.md","text_hash":"88d90e2eef3374ce1a7b5e7fbd3b1159364b26a8ceb2493d6e546d4444b03cda","text":"Tailscale","translated":"Tailscale","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:28:28Z"} -{"cache_key":"170ac65dcb50a9c53c485160f6dac256ff7cd0a52f42110be2e831d8b8dfe2d8","segment_id":"index.md:6638cf2301d3109d","source_path":"index.md","text_hash":"6638cf2301d3109da66a44ee3506fbd35b29773fa4ca33ff35eb838c21609e19","text":"Features (high level)","translated":"功能(概览)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:00:46Z"} -{"cache_key":"177341748b72b186e14110d0c9976e378a203d89a6c13e049a92f3cdc3d750a5","segment_id":"index.md:872887e563e75957","source_path":"index.md","text_hash":"872887e563e75957ffc20b021332504f2ddd0a8f3964cb93070863bfaf13cdad","text":"Example:","translated":"示例:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:03:39Z"} -{"cache_key":"17bdf88db004d77259d1facc1c15dbb8745e59196159394aa7b079e5791cb188","segment_id":"index.md:cda454f61dfcac70","source_path":"index.md","text_hash":"cda454f61dfcac7007a9edc538f9f58cf38caa0652e253975979308162bccc53","text":"Gateway configuration","translated":"Gateway 配置","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:29:15Z"} -{"cache_key":"17e73f0432c41ef1e25bcb39e40a7fb845787238a577b53ddf27793a5397ec20","segment_id":"start/getting-started.md:185d41cd3982a2b1","source_path":"start/getting-started.md","text_hash":"185d41cd3982a2b1d9355a331c5270ca3bf6e8467b35dea265d2e3a279d05dea","text":" to the gateway host.","translated":" 到 Gateway 主机上。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:36:42Z"} -{"cache_key":"180848ab1dfb40b43095571666d7e635cec82592dd7b0ea3f406819694db95bd","segment_id":"index.md:1df4f2299f0d9cc4","source_path":"index.md","text_hash":"1df4f2299f0d9cc466fa05abeb2831e76e9f89583228174ffcd9af415fd869fe","text":"Send a test message (requires a running Gateway):","translated":"发送测试消息(需要 Gateway 正在运行):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:31:25Z"} -{"cache_key":"1851994b49e6bccd9901d48dea770f2271a4b0adf71a11555a7d49ea7433ab55","segment_id":"index.md:0d517afa83f91ec3","source_path":"index.md","text_hash":"0d517afa83f91ec33ee74f756c400a43b11ad2824719e518f8ca791659679ef4","text":"Web surfaces (Control UI)","translated":"Web 界面(控制界面)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:04:33Z"} -{"cache_key":"185a0aac0aa7e81682f9016aa8d0e4f95f86005abb5a52840876dc9b23129893","segment_id":"help/index.md:156597e2632411d1","source_path":"help/index.md","text_hash":"156597e2632411d1d5f634db15004072607ba45072a4e17dfa51790a37b6781f","text":"Gateway issues:","translated":"Gateway 问题:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:56:26Z"} -{"cache_key":"18869b3a6b51f154fdcbb622d54f07c567e9438608cf998f54e590550797ed35","segment_id":"index.md:9f4d843a5d04e23b","source_path":"index.md","text_hash":"9f4d843a5d04e23b22eb79b3bfa0fbad70ede435ddb5d047e7d77e830efa6019","text":" — Bot token + WebSocket events","translated":" — Bot 令牌 + WebSocket 事件","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:01:13Z"} -{"cache_key":"18bd8d592ca11411d1c02c1a70123dc798352f581db4c9ce297c5ebb04841fa3","segment_id":"index.md:03279877bfe1de07","source_path":"index.md","text_hash":"03279877bfe1de0766393b51e69853dec7e95c287ef887d65d91c8bbe84ff9ff","text":"WebChat + macOS app","translated":"网页聊天 + macOS 应用","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:02:30Z"} -{"cache_key":"190c49164ee5535fac803e9c0f057588d634e056d2c4fc072a0ca26e01ddc391","segment_id":"index.md:7d8b3819c6a9fb72","source_path":"index.md","text_hash":"7d8b3819c6a9fb726f40c191f606079b473f6f72d4080c13bf3b99063a736187","text":"Ops and safety:","translated":"运维和安全:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:05:19Z"} -{"cache_key":"19207e4ed0ae44f965f33707377a0217c1765cf57b09c0268ee36c10fb108dd9","segment_id":"index.md:c6e91f3b51641b1c","source_path":"index.md","text_hash":"c6e91f3b51641b1c43d297281ee782b40d9b3a0bdd7afc144ba86ba329d5f95f","text":"OpenClaw = CLAW + TARDIS","translated":"OpenClaw = CLAW + TARDIS","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:33:18Z"} -{"cache_key":"194e63ecfe45556973c28ccafc39f814f42d2478037734ce44eee72f6fc6fc66","segment_id":"index.md:856302569e24c4d6","source_path":"index.md","text_hash":"856302569e24c4d64997e2ec5c37729f852bcccf333ba1e2f71e189c9d172e6d","text":": SSH tunnel or tailnet/VPN; see ","translated":":SSH 隧道或 Tailnet/VPN;请参阅 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:29:21Z"} -{"cache_key":"196942db05e9e40cbdf74a89cdd1be042430343a64ac2185009414f0d092af66","segment_id":"environment.md:cda454f61dfcac70","source_path":"environment.md","text_hash":"cda454f61dfcac7007a9edc538f9f58cf38caa0652e253975979308162bccc53","text":"Gateway configuration","translated":"Gateway 配置","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:13:11Z"} -{"cache_key":"19c0ced45bb35a1d8801864910a9f7bc2c460229fdd97366f546255feeb1db0e","segment_id":"index.md:8816c52bc5877a2b","source_path":"index.md","text_hash":"8816c52bc5877a2b24e3a2f4ae7313d29cf4eba0ca568a36f2d00616cfe721d0","text":"Wizard","translated":"向导","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:59:12Z"} -{"cache_key":"19ca5db3b9a34663414fc437ede7163609ae09cf0a0873367e8a83c8c8dc9c1c","segment_id":"index.md:e64d6b29b9d90bba","source_path":"index.md","text_hash":"e64d6b29b9d90bba92ffe2539dc295a75c553684fed0350ee56bfd0aead01662","text":"Multiple gateways","translated":"多 Gateway 部署","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:28:45Z"} -{"cache_key":"19d8897086c397efdc874615a9503b47cb856584fc885631b1dac100e0bbf69e","segment_id":"start/wizard.md:c3f0c8edf2a35cb6","source_path":"start/wizard.md","text_hash":"c3f0c8edf2a35cb67c00b0fe92273695465fb1a1faa99a54b08a42c116cfc532","text":"Typical fields in ","translated":"中的典型字段 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:48:26Z"} -{"cache_key":"1ace42dd9735ec65580e321be5ec1b6956327ceb79da49867d3031743de01599","segment_id":"index.md:7ac362063b9f2046","source_path":"index.md","text_hash":"7ac362063b9f204602f38f9f1ec9cf047f03e0d7b83896571c9df6d31ad41e9c","text":"Nodes","translated":"节点","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:48:50Z"} -{"cache_key":"1add3ec58637e35e15f8ecce92a3064278889ebc567d4b15e12d7f73d43f829d","segment_id":"environment.md:cea23dd4b87e8b00","source_path":"environment.md","text_hash":"cea23dd4b87e8b00d19fb9ccaaef93e97353c7353e2070f3baf05aeb3995dff4","text":" expected","translated":" 预期","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:46:44Z"} -{"cache_key":"1aefff77e0f0e3d5d9204ec8bba8bc39215a10dc4242638faf2a000db1b7f6c4","segment_id":"index.md:032f5589cfa2b449","source_path":"index.md","text_hash":"032f5589cfa2b44973fe96c42e17dcc2692281413a05b16f48ff0f958f7f7ade","text":"Discord Bot","translated":"Discord 机器人","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:49:40Z"} -{"cache_key":"1b03b1a606f8d851e3a9744ceedc51773da3a8df1e44cea04e77f4cdcc482f4f","segment_id":"help/index.md:b79cac926e0b2e34","source_path":"help/index.md","text_hash":"b79cac926e0b2e347e72cc91d5174037c9e17ae7733fd7bdb570f71b10cd7bfc","text":"Help","translated":"帮助","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:11:07Z"} -{"cache_key":"1b0d0b676f8ad6e3cca80b5ba0962cfca425d38aba4dfdae950f4c645cc4648c","segment_id":"environment.md:c2d7247c8acb83a5","source_path":"environment.md","text_hash":"c2d7247c8acb83a5a020458fa836c2445922b51513dbdbf154ab5f7656cb04e9","text":"; does not override).","translated":";不会覆盖)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:40:46Z"} -{"cache_key":"1b30ed7712ade7f794a6fdc40334ac098d59fa26a77cb4dbee831ba2078a2575","segment_id":"environment.md:ab5aec4424cf678d","source_path":"environment.md","text_hash":"ab5aec4424cf678dcfb1ad3d2c2929c1e0b2b1ff61b82b961ada48ad033367b4","text":" (dotenv default; does not override).","translated":" (dotenv 默认行为;不会覆盖)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:25:34Z"} -{"cache_key":"1b46380759682daee5913f29666ad424b3e1b23a87ee5b8169484b9c4e4cce3f","segment_id":"index.md:7af023c43013b9a5","source_path":"index.md","text_hash":"7af023c43013b9a53fbff7dd4b5821588bba3319308878229740489152c43f6d","text":"Docs","translated":"文档","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:52:10Z"} -{"cache_key":"1b9c39af551716b27cb347a69279ef46cfe3c1fb503688b09287759b10390831","segment_id":"start/wizard.md:e86be3a8fc32914b","source_path":"start/wizard.md","text_hash":"e86be3a8fc32914baac6ea18f1b36fb282ea9648829cec3bba6434bdc6d78b9c","text":" before continuing.","translated":" 后再继续。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:41:22Z"} -{"cache_key":"1c12007db13e2183cd1fe644bbe1a01094186d612f9d4c719986819020e971df","segment_id":"start/getting-started.md:e2235b75234648f0","source_path":"start/getting-started.md","text_hash":"e2235b75234648f0959f35fae53aa627c01be06907b8596d69b01ae9187e1574","text":"Sandboxing note: ","translated":"沙箱注意事项: ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:35:15Z"} -{"cache_key":"1c4b67e17a4caf039722cea2dd696a8a7cdef2168d6518aaf603d3aeb69b9366","segment_id":"index.md:e47cdb55779aa06a","source_path":"index.md","text_hash":"e47cdb55779aa06a74ae994c998061bd9b7327f5f171c141caf2cf9f626bfe4b","text":"Peter Steinberger","translated":"Peter Steinberger","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:54:17Z"} -{"cache_key":"1c540694a0b8ce10fc354bd7f41b387f1d72d1759ffecbf35976cdf744305f0e","segment_id":"index.md:cec2be6f871d276b","source_path":"index.md","text_hash":"cec2be6f871d276b45d13e3010c788f01b03ae2f1caca3264bbf759afacace46","text":"Telegram Bot","translated":"Telegram 机器人","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:49:34Z"} -{"cache_key":"1c7aa162de30cece8f7d315f71cdc949464409fa4af6d15a34fe9e1355c65a07","segment_id":"index.md:0b60fe04b3c5c3c7","source_path":"index.md","text_hash":"0b60fe04b3c5c3c76371b6eca8b19c8e09a0e54c9010711ff87e782d87d2190b","text":"Android app","translated":"Android 应用","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:05:11Z"} -{"cache_key":"1c913763f7d014418cc6c1099fe8225a377347cffba038df43b8b36ddefb8667","segment_id":"start/wizard.md:053bc65874ad6098","source_path":"start/wizard.md","text_hash":"053bc65874ad6098e58c41c57b378a2f36b0220e5e0b46722245e6c2f796818c","text":"Discord","translated":"Discord","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:44:32Z"} -{"cache_key":"1cb210590e688dccd03dac9806c6ca974a62f36eb66841174c22bc2a92ba246b","segment_id":"index.md:1016b5bdce94a848","source_path":"index.md","text_hash":"1016b5bdce94a8484312c123416c1a18c29fab915ba2512155df3a82ee097f8f","text":"If the Gateway is running on the same computer, that link opens the browser Control UI\nimmediately. If it fails, start the Gateway first: ","translated":"如果 Gateway 运行在同一台计算机上,该链接会立即打开浏览器控制界面。如果打开失败,请先启动 Gateway: ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:48:00Z"} -{"cache_key":"1d79cadd479cb04568bc708432327edae80a4fd8ef388f88810aa943956e4c47","segment_id":"start/wizard.md:c8fa121316f27858","source_path":"start/wizard.md","text_hash":"c8fa121316f2785846379bef81073a1f3dd68979bd249b3953d671935e11de39","text":" on any machine, then paste the token (you can name it; blank = default).","translated":" 在任意机器上执行,然后粘贴令牌(可以命名;留空 = 默认)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:42:07Z"} -{"cache_key":"1db946531e000c45cc98cc20862f674ef6c61986d0ea1d47dfb1904d14218107","segment_id":"environment.md:e234227b0e001687","source_path":"environment.md","text_hash":"e234227b0e001687821541fac3af38fc6be293ec6e13910c6826b9afc8ca33be","text":" syntax:","translated":" 语法:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:58:20Z"} -{"cache_key":"1dec0d82356f133d86b7f5230d326009390aef97750e2e02a9f559c81af566c0","segment_id":"start/wizard.md:da4f7ea58d963b1a","source_path":"start/wizard.md","text_hash":"da4f7ea58d963b1a302b76b8fa5570190106c673b9cf2975468b8caea5e27384","text":"Notes:","translated":"注意事项:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:46:26Z"} -{"cache_key":"1e12a98dc0766a832c97dea693a841b86ec63df3e8303fb054a918e2b17ca0af","segment_id":"index.md:1df4f2299f0d9cc4","source_path":"index.md","text_hash":"1df4f2299f0d9cc466fa05abeb2831e76e9f89583228174ffcd9af415fd869fe","text":"Send a test message (requires a running Gateway):","translated":"发送测试消息(需要正在运行的 Gateway):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:51:45Z"} -{"cache_key":"1e12f10bc3ce3c2de7f740928fb2eb076893bf23694f69adc314d8496c436182","segment_id":"environment.md:frontmatter:summary","source_path":"environment.md:frontmatter:summary","text_hash":"78351223e7068721146d2de022fdf440c2866b2ee02fbbb50bf64369b999820b","text":"Where OpenClaw loads environment variables and the precedence order","translated":"其中 OpenClaw 加载 环境变量 及优先级顺序","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:40:10Z"} -{"cache_key":"1e290e5653bf89ffd9643bbb215d3b2ce8f30b26d3468a5b584482ea567fb499","segment_id":"index.md:075a4a45c3999f34","source_path":"index.md","text_hash":"075a4a45c3999f340be8487cd7c0dd2ed77ced931054d75e95e5e24d5539b45b","text":" — Pi (RPC mode) with tool streaming","translated":" — Pi(RPC 模式),支持工具流式传输","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:29:59Z"} -{"cache_key":"1e95353aafa09b6593d0a72b4957849c4bd481c529d0cd0c2c92a989b3be6314","segment_id":"index.md:cf9f12b2c24ada73","source_path":"index.md","text_hash":"cf9f12b2c24ada73bb0474c0251333f65e6d5d50e56e605bdb264ff32ad0a588","text":"Config lives at ","translated":"配置文件位于 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:51:50Z"} -{"cache_key":"1ecffd089b9e7ce60ff3c650b35056b17b3818bed3a6b56aad92c8aa31d7ef0a","segment_id":"index.md:723784fa2b6a0876","source_path":"index.md","text_hash":"723784fa2b6a0876540a92223ee1019f24603499d335d6d82afbc520ef5b5d57","text":") — Creator, lobster whisperer","translated":")— 创始人,龙虾低语者","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:05:57Z"} -{"cache_key":"1ee9e09b79b65f176e6502ee06df46982743679fd7dab8489796507a560b9061","segment_id":"start/wizard.md:dd6d876548037ec7","source_path":"start/wizard.md","text_hash":"dd6d876548037ec722252b45795206575e7040eba1ca076cf1732a4a903cadba","text":"recommended","translated":"推荐的","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:39:01Z"} -{"cache_key":"1f29b910c7c6522a295107b45bd56440780ea346e1080c11e5151d3ba113afca","segment_id":"environment.md:1fe7fd13379f249a","source_path":"environment.md","text_hash":"1fe7fd13379f249a1e554dc904ad7b921693805367609bcddba21f0e7777f4c6","text":" keys:","translated":" 密钥:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:46:46Z"} -{"cache_key":"1f36183a47c67ccafde914a43347afd754eafbb2963a3d0ad3d3942258443cdf","segment_id":"index.md:d00eca1bae674280","source_path":"index.md","text_hash":"d00eca1bae6742803906ab42a831e8b5396d15b6573ea13c139ec31631208ec1","text":"Getting Started","translated":"快速入门","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:28:04Z"} -{"cache_key":"1f429111895ed6cef256514a66a9adb27ec53f3d69a546a6a18c80495cacd604","segment_id":"start/getting-started.md:c65465f9a818c020","source_path":"start/getting-started.md","text_hash":"c65465f9a818c02008a391292f0086b37aa7e8fe7355aca80967b20a8b692e0b","text":"Dashboard (local loopback): ","translated":"仪表盘(本地回环): ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:36:53Z"} -{"cache_key":"1fb0683c4f7488278cb9251e361610c911ef766dd126666b7fc10f6f73a0c8b7","segment_id":"index.md:79a482cf546c23b0","source_path":"index.md","text_hash":"79a482cf546c23b04cd48a33d4ca8411f62e5b7dc8c3a8f30165e28e747f263a","text":"iMessage","translated":"iMessage","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:49:54Z"} -{"cache_key":"1ff7a3f0c5d86df89523e2dad0861b2ace45830858dd2ca1c4e778747334ffc0","segment_id":"start/wizard.md:ac12572a8df977e5","source_path":"start/wizard.md","text_hash":"ac12572a8df977e5ea70c8b1a24c2a84b1ecd1935e2ef9fe4c38c5849d4755f8","text":" if present.","translated":" (如果存在)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:41:59Z"} -{"cache_key":"2034fd5a1e8f055e05fbbdfae0533751d7f0d1c2c0f3d2808c9eeb4da918e89a","segment_id":"environment.md:907940a35852447a","source_path":"environment.md","text_hash":"907940a35852447aad5f21c5a180d993ff31cfd5807b1352ed0c24eabe183465","text":"never override existing values","translated":"永远不覆盖已有的值","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:15:56Z"} -{"cache_key":"204727bc1fb1c07814caf037d6fa475e7981c7b57ed1367943361cb5d56815bb","segment_id":"index.md:185beb968bd1a81d","source_path":"index.md","text_hash":"185beb968bd1a81d07ebcf82376642f7b29f1b5594b21fe9edee714efbdcaa44","text":"✈️ ","translated":"✈️ ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:29:33Z"} -{"cache_key":"20af820e30f9d07e1b6dce3866df5f8dff2be94881a44767228a1f6b9aa5d1bf","segment_id":"index.md:274162b77e02a189","source_path":"index.md","text_hash":"274162b77e02a1898044ea787db109077a2969634f007221c29b53c2e159b0cc","text":". Plugins add Mattermost (Bot API + WebSocket) and more.\nOpenClaw also powers the OpenClaw assistant.","translated":"。插件还支持 Mattermost(Bot API + WebSocket)等更多平台。\nOpenClaw 还为 OpenClaw 助手提供支持。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:47:37Z"} -{"cache_key":"20afa1e6ed4b34b77d13becaaffdcb038b92351654672578634c6f3761b82d38","segment_id":"help/index.md:6201111b83a0cb5b","source_path":"help/index.md","text_hash":"6201111b83a0cb5b0922cb37cc442b9a40e24e3b1ce100a4bb204f4c63fd2ac0","text":" and ","translated":" 和 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:44:52Z"} -{"cache_key":"2187914f759dffd9a960e25b4de5d07c68b9cf635f2d86e0497c90a80ec9fa57","segment_id":"environment.md:e234227b0e001687","source_path":"environment.md","text_hash":"e234227b0e001687821541fac3af38fc6be293ec6e13910c6826b9afc8ca33be","text":" syntax:","translated":" 语法:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:47:03Z"} -{"cache_key":"2194495894bf0f98ef0af4a8658521377e555a9fc6b7b1c7bfd99e305d7f023f","segment_id":"start/wizard.md:649cfa2f76a80b42","source_path":"start/wizard.md","text_hash":"649cfa2f76a80b42e1821c89edd348794689409dcdf619dcd10624fb577c676b","text":"not recommended","translated":"不推荐","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:45:35Z"} -{"cache_key":"21c3bf5b2b4eac5e8703e4e98cf9179524d16013e1324921b87acaa0cf085d2f","segment_id":"index.md:4b4051e77af8844f","source_path":"index.md","text_hash":"4b4051e77af8844fcf86a298214527e7840938258f99bfe97b900bbc0d8d2f4b","text":"The dashboard is the browser Control UI for chat, config, nodes, sessions, and more.\nLocal default: http://127.0.0.1:18789/\nRemote access: ","translated":"仪表板是用于聊天、配置、节点、会话 等功能的浏览器控制界面。\n本地默认地址:http://127.0.0.1:18789/\n远程访问: ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:48:07Z"} -{"cache_key":"21d5f361e852fbe5b69697313f954689d7f44d285c1d9039ba360a8907a1b7b8","segment_id":"environment.md:453c14128fbfb5f6","source_path":"environment.md","text_hash":"453c14128fbfb5f6757511557132a1dbb3bcbf243267630bfec49db8518c7780","text":"Env var substitution in config","translated":"配置中的环境变量替换","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:22:29Z"} -{"cache_key":"21e1ac9646c9b4ec91d366e85957a04c5b7f0c41c95e653c43925dd01c080501","segment_id":"index.md:4b4051e77af8844f","source_path":"index.md","text_hash":"4b4051e77af8844fcf86a298214527e7840938258f99bfe97b900bbc0d8d2f4b","text":"The dashboard is the browser Control UI for chat, config, nodes, sessions, and more.\nLocal default: http://127.0.0.1:18789/\nRemote access: ","translated":"仪表盘是用于聊天、配置、节点、会话等功能的浏览器控制界面。\n本地默认地址:http://127.0.0.1:18789/\n远程访问: ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:28:23Z"} -{"cache_key":"2208e96b11a53d5948e802dc055895cfdd8ee5ecbaca057c64038b30e25e1403","segment_id":"start/wizard.md:65d655d45a507243","source_path":"start/wizard.md","text_hash":"65d655d45a50724332fee040cd2c6a000778db0e122459fc48047206e699900a","text":"(or pass ","translated":"(或传入 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:41:15Z"} -{"cache_key":"221e7c2c0fe8b9bb39aa23d66ead440852512864ee62242cc3d9290dbd135860","segment_id":"index.md:9bd86b0bbc71de88","source_path":"index.md","text_hash":"9bd86b0bbc71de88337aa8ca00f0365c1333c43613b77aaa46394c431cb9afd8","text":"Maxim Vovshin","translated":"Maxim Vovshin","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:33:49Z"} -{"cache_key":"2220f5ebb94a086ce480f01165b1993d04e470d58154e2aa482056a2eecbb1f1","segment_id":"help/index.md:3c33340bd23b8db8","source_path":"help/index.md","text_hash":"3c33340bd23b8db89f18fe7d05a954738c0dd5ba9623cf6bdb7bb5d1a3729cfc","text":"FAQ (concepts)","translated":"常见问题(概念)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:24:59Z"} -{"cache_key":"2229ff2bff7c65fc1a4cd5515373b1b3319f43a26222f43787452e985cf5e4bb","segment_id":"index.md:11d28de5b79e3973","source_path":"index.md","text_hash":"11d28de5b79e3973f6a3e44d08725cdd5852e3e65e2ff188f6708ae9ce776afc","text":"Docs hubs (all pages linked)","translated":"文档中心(所有页面链接)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:31:49Z"} -{"cache_key":"22baac03ae69320ee9635f7e23e85e926ed40c441e97357b30b48e271e88770f","segment_id":"index.md:013e11a23ec9833f","source_path":"index.md","text_hash":"013e11a23ec9833f907b2ead492b0949015e25d10ba92461669609aee559335d","text":"Start here:","translated":"从这里开始:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:31:47Z"} -{"cache_key":"22bfdd3e9e4f7a5447edf31592e38d663a8907afca5f46061f314b924280a94b","segment_id":"index.md:d53b75d922286041","source_path":"index.md","text_hash":"d53b75d9222860417f783b0829023b450905d982011d35f0e71de8eed93d90fc","text":"New install from zero:","translated":"从零开始全新安装:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:47:41Z"} -{"cache_key":"22c7a06691f087acabe4321804edbb000eaf7520b16060ac2879f19252b639e3","segment_id":"index.md:31365ab9453d6a1e","source_path":"index.md","text_hash":"31365ab9453d6a1ec03731622803d3b44f345b6afad08040d7f3e97290c77913","text":"do nothing","translated":"不做任何操作","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:31:33Z"} -{"cache_key":"22d40e91dde10d2912781df931ab0fac2802d5b81e63fdd93bdb7856c8c43976","segment_id":"environment.md:7175517a370b5cd2","source_path":"environment.md","text_hash":"7175517a370b5cd2e664e3fd29c4ea9db5ce17058eb9772fe090a5485e49dad6","text":" or ","translated":" 或 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:26:00Z"} -{"cache_key":"23004dacbc322d02e170261429793a8b23569f398c4f21352a030b42543cdef9","segment_id":"index.md:6b65292dc52408c1","source_path":"index.md","text_hash":"6b65292dc52408c15bb07aa90735e215262df697d1a7bd2d907c9d1ff294ed5e","text":"If you don’t have a global install yet, run the onboarding step via ","translated":"如果您还没有全局安装,请通过以下方式运行 上手引导 步骤 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:51:37Z"} -{"cache_key":"231f5f501e89f219692ad075c657cf5933b0f1f238599ce9c071676a24e755f6","segment_id":"index.md:45e6d69dbe995a36","source_path":"index.md","text_hash":"45e6d69dbe995a36f7bc20755eff4eb4d2afaaedbcac4668ab62540c57219f32","text":"macOS app","translated":"macOS 应用","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:32:53Z"} -{"cache_key":"232f62d57ad6e5a82f4409553ea36a2922ef2c0d515cf24d030edd4c81c89e9f","segment_id":"help/index.md:8ddb7fc8a87904de","source_path":"help/index.md","text_hash":"8ddb7fc8a87904dedc2afc16400fbe4e78582b302e01c30b1319c8a465d04684","text":"Troubleshooting:","translated":"故障排除:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:11:10Z"} -{"cache_key":"2406d5725ab83e6898a33bba0fc2cd62ee455bd54fbe32831a88379d5e02d86f","segment_id":"index.md:c0aa8fcb6528510a","source_path":"index.md","text_hash":"c0aa8fcb6528510aea46361e8c871d88340063926a8dfdd4ba849b6190dec713","text":": it is the only process allowed to own the WhatsApp Web session. If you need a rescue bot or strict isolation, run multiple gateways with isolated profiles and ports; see ","translated":":它是唯一允许持有 WhatsApp Web 会话的进程。如果需要备用机器人或严格隔离,可使用独立配置文件和端口运行多个 Gateway;请参阅 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:28:43Z"} -{"cache_key":"241e91bd0b62e35fb2ec88322ec08e734dda812d53f7abab56928ef184075551","segment_id":"environment.md:6db0742daaf9f191","source_path":"environment.md","text_hash":"6db0742daaf9f191ab7816d2c9d317b1ea1693453a8c63b95af8b01477e0f5bb","text":" runs your login shell and imports only ","translated":" 运行你的登录 shell 并仅导入 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:46:39Z"} -{"cache_key":"2464c2f32b20d6e91fc9b63900ca12b81b1cb3fd185ad50d14ba4335d4e1b7a5","segment_id":"index.md:6e0f6eca4ff17d33","source_path":"index.md","text_hash":"6e0f6eca4ff17d3377c1c3e8e1f73457553ad3b9cfcd5e4f2b94cfb1028b6234","text":"iOS app","translated":"iOS 应用","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:32:55Z"} -{"cache_key":"24f4ed3c397e27f4a1d99dd6920c49327133c009ca1c9c5ba236d54ae50831f3","segment_id":"start/getting-started.md:8b31087991db3d3d","source_path":"start/getting-started.md","text_hash":"8b31087991db3d3d41b72b3dc31587adf140ea2bc46913b195c773810711388f","text":"and chat in the browser, or open ","translated":"然后在浏览器中聊天,或打开 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:34:35Z"} -{"cache_key":"24fe1d1819e7b7ad223dda1b2a6ce1ec91a1954bf95f40a7dcdbba28129b3930","segment_id":"environment.md:582967534d0f909d","source_path":"environment.md","text_hash":"582967534d0f909d196b97f9e6921342777aea87b46fa52df165389db1fb8ccf","text":" in ","translated":" 在 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:46:02Z"} -{"cache_key":"250eb34b1c8653641bb56ae814e663c3ddeaf7caa912b2b75e321788d4e7e9da","segment_id":"start/getting-started.md:053bc65874ad6098","source_path":"start/getting-started.md","text_hash":"053bc65874ad6098e58c41c57b378a2f36b0220e5e0b46722245e6c2f796818c","text":"Discord","translated":"Discord","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:37:26Z"} -{"cache_key":"250ebfe2db8b434d37c37a532f532102c1e6f30cfaa1c295af3c4fbe13ffc1ba","segment_id":"help/index.md:cad44fbae951d379","source_path":"help/index.md","text_hash":"cad44fbae951d3791565b0cee788c01c3bd10e0176167acb691b8dba0f7895f8","text":"Gateway logging","translated":"Gateway 日志记录","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:11:26Z"} -{"cache_key":"25861831dad7a8862f567594c9bc4b59c68dc56776ba50ff9d7295c536b23664","segment_id":"help/index.md:6cb77499abdccd9a","source_path":"help/index.md","text_hash":"6cb77499abdccd9a2dbb7c93a4d31eed01613dda06302933057970df9ecdeb54","text":"Logs:","translated":"日志:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:56:31Z"} -{"cache_key":"25cc8403b5816b888911443d2917b330bc530b2e338f51b2a7422b2a78b7870d","segment_id":"index.md:e64d6b29b9d90bba","source_path":"index.md","text_hash":"e64d6b29b9d90bba92ffe2539dc295a75c553684fed0350ee56bfd0aead01662","text":"Multiple gateways","translated":"多网关","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:48:36Z"} -{"cache_key":"25e8d037a4a0fb8c548da95825983c8a0af432d3220c14dd9908bbc344acbb2b","segment_id":"index.md:45808d75bf8911fa","source_path":"index.md","text_hash":"45808d75bf8911fa21637f9dd3f0dace1877748211976b5d61fcc5c15db594d0","text":"Webhooks","translated":"Webhooks","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:33:08Z"} -{"cache_key":"26087df3db46ce7741b72a3511fc552773df03f7de93d20d9d6c1aaf74ada2f0","segment_id":"environment.md:6f59001999ef7b71","source_path":"environment.md","text_hash":"6f59001999ef7b7128bab80d2034c419f3034497e05f69fbdf67f7b655cdc173","text":"Configuration: Env var substitution","translated":"配置:环境变量替换","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:22:40Z"} -{"cache_key":"2609a5fb897b0d40ef4bdfd04a26758f1b19819e28a2db1074ca89dd348c1834","segment_id":"environment.md:32ebb1abcc1c601c","source_path":"environment.md","text_hash":"32ebb1abcc1c601ceb9c4e3c4faba0caa5b85bb98c4f1e6612c40faa528a91c9","text":" (","translated":" (","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:41:00Z"} -{"cache_key":"2628c353f974405b473f8058fe5c80b4039449f51806dee3ced22ced458507c3","segment_id":"environment.md:frontmatter:read_when:0","source_path":"environment.md:frontmatter:read_when:0","text_hash":"90fc0487bff88009979cff1061c1a882df8c3b1baa9c43538331d9d5dab15479","text":"You need to know which env vars are loaded, and in what order","translated":"你需要了解加载了哪些环境变量,以及它们的加载顺序","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:25:07Z"} -{"cache_key":"2641fa57e655e2907092885b0b24665c212df5b58bb36fa826f14180e4ec67f3","segment_id":"index.md:99260acc29f71e4b","source_path":"index.md","text_hash":"99260acc29f71e4baeb36805a1fdbd2c17254b57c8e5a9cba29ee56518832397","text":" — Route provider accounts/peers to isolated agents (workspace + per-agent sessions)","translated":" — 将提供商账户/对等方路由到隔离的智能体(工作区 + 每智能体会话)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:30:12Z"} -{"cache_key":"26b05211cad1c3dae78e2e4aa1f9ed7abf9cb852044cd4f872c60d9017025c93","segment_id":"start/wizard.md:27914f11fd0ce999","source_path":"start/wizard.md","text_hash":"27914f11fd0ce99942e1903fecd5ac607d0dbc22ae97969a3819e223a32265aa","text":"Workspace location + bootstrap files","translated":"工作区位置 + 引导文件","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:40:21Z"} -{"cache_key":"272018c637ad26ec5622a5e164be99ef742f22f1cc1f14d3af9256471c3dbe98","segment_id":"index.md:acdd1e734125f341","source_path":"index.md","text_hash":"acdd1e734125f341604c0efbabdcc4c4b0597e8f6235d66c2445edd1812838c1","text":"Telegram","translated":"Telegram","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:53:13Z"} -{"cache_key":"2751e36b231341babf0dc82fbe5863659467382c8bf049600dd6042b26310190","segment_id":"index.md:42071940eb773f4d","source_path":"index.md","text_hash":"42071940eb773f4dcb7111f0626b4a7a823fc44098e143ff425db8a03528609d","text":" — because every space lobster needs a time-and-space machine.","translated":" — 因为每只太空龙虾都需要一台时空机器。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:05:41Z"} -{"cache_key":"278578406409f5c240b49ce02dbb5bf926ca1b0ed2c7ffaa4fe2fe66ae017223","segment_id":"start/getting-started.md:623b2b8c94dc9c42","source_path":"start/getting-started.md","text_hash":"623b2b8c94dc9c4272eef1ee15c7f60ac3a2525fa9e80235380c46f41ed38748","text":"4) Pair + connect your first chat surface","translated":"4)配对 + 连接您的第一个聊天界面","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:37:07Z"} -{"cache_key":"27a004245e98dcbaa5e48cc369f6f2aa4bcdcf81bb6da9f4b59f6a9c0aa4d950","segment_id":"index.md:42071940eb773f4d","source_path":"index.md","text_hash":"42071940eb773f4dcb7111f0626b4a7a823fc44098e143ff425db8a03528609d","text":" — because every space lobster needs a time-and-space machine.","translated":" —— 因为每只太空龙虾都需要一台时空机器。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:54:07Z"} -{"cache_key":"27a06da04c255a5ecf19b5022dd6180357807d50162a5698cd21d3eb78388ef3","segment_id":"environment.md:cda454f61dfcac70","source_path":"environment.md","text_hash":"cda454f61dfcac7007a9edc538f9f58cf38caa0652e253975979308162bccc53","text":"Gateway configuration","translated":"Gateway 配置","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:41:39Z"} -{"cache_key":"27d042d7c4de0149b07caa1eef12a5a6b13bad2607338e471254e32ea17ac4fe","segment_id":"index.md:6d6577cb1c128ac1","source_path":"index.md","text_hash":"6d6577cb1c128ac18a286d3c352755d1a265b1e3a03eded8885532c3f36e32ed","text":"Mario Zechner","translated":"Mario Zechner","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:54:25Z"} -{"cache_key":"27e979437c543d1b0d913e200ae97874a872dbe3fb1ae1e0ea7d6eb6ebbe334e","segment_id":"index.md:98a670e2fb754896","source_path":"index.md","text_hash":"98a670e2fb7548964e8b78b90fef47f679580423427bfd15e5869aca9681d0dd","text":"\"We're all just playing with our own prompts.\"","translated":"\"我们都只是在玩弄自己的提示词。\"","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:54:10Z"} -{"cache_key":"28006596cdda45f4da3d43d4aca5bf66c459d4553682e2dd295c7e256c0a7dc6","segment_id":"start/wizard.md:8999c63d838a1729","source_path":"start/wizard.md","text_hash":"8999c63d838a1729c88f4334c6fd73d735c69659f7e08989bd9d4bd0cc644748","text":" Node (recommended; required for WhatsApp/Telegram). Bun is ","translated":" Node(推荐;WhatsApp/Telegram 需要)。Bun ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:45:33Z"} -{"cache_key":"2816a7fdcd6be1cbfa2991a8e2e2a7547e4d6c8c24cea4a8cd4bd797e593002b","segment_id":"help/index.md:d3ef01b4a9c99103","source_path":"help/index.md","text_hash":"d3ef01b4a9c9910364c9b26b2499c8787a0461d2d24ab80376fff736a288b34c","text":"Logging","translated":"日志记录","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:39:48Z"} -{"cache_key":"28d24c047d26e2c1e65fd0bbb5ff062aa4ac050cf6a9d74ff349d775635b6ebd","segment_id":"index.md:aaa095329e21d86e","source_path":"index.md","text_hash":"aaa095329e21d86e24e8bec91bc001f7983d73a7a04c75646c0256448dac30ef","text":" — The space lobster who demanded a better name","translated":" —— 那只要求更好名字的太空龙虾","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:54:36Z"} -{"cache_key":"28e8ae5018d34b717de70ce7f23982de74c146a1f056b26e5e4ae8104534414e","segment_id":"index.md:6201111b83a0cb5b","source_path":"index.md","text_hash":"6201111b83a0cb5b0922cb37cc442b9a40e24e3b1ce100a4bb204f4c63fd2ac0","text":" and ","translated":" 和 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:28:26Z"} -{"cache_key":"28ef1da9761f650da74d92d311e4340eb104aa4bfe79c0770be44869d3d4388b","segment_id":"help/index.md:156597e2632411d1","source_path":"help/index.md","text_hash":"156597e2632411d1d5f634db15004072607ba45072a4e17dfa51790a37b6781f","text":"Gateway issues:","translated":"Gateway 问题:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:39:41Z"} -{"cache_key":"2915e64d137473ff7b41748d6e775157eeff0e1392db33707e68c51e7d2b3e4a","segment_id":"environment.md:6f59001999ef7b71","source_path":"environment.md","text_hash":"6f59001999ef7b7128bab80d2034c419f3034497e05f69fbdf67f7b655cdc173","text":"Configuration: Env var substitution","translated":"配置:环境变量替换","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:26:30Z"} -{"cache_key":"29a2f85f24db686837fe914b9726eff6a76c743da516c02abf9e7b37b6e7a822","segment_id":"index.md:76d6f9c532961885","source_path":"index.md","text_hash":"76d6f9c5329618856f133dc695e78f085545ae05fae74228fb1135cba7009fca","text":") — Pi creator, security pen-tester","translated":")— Pi 创始人,安全渗透测试员","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:06:04Z"} -{"cache_key":"29ad5ac78c867238eeea5d895c4831ef7fd4b4da6897dbbebfa2442fe9b4a55e","segment_id":"index.md:e3209251e20896ec","source_path":"index.md","text_hash":"e3209251e20896ecc60fa4da2817639f317fbb576288a9fc52d11e5030ecc44a","text":"Windows (WSL2)","translated":"Windows (WSL2)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:32:59Z"} -{"cache_key":"2a0917591bc5d0651e00107b3f0240ec8ef7f815194af495b214e011d1572e63","segment_id":"environment.md:cf0923bd0c80e86a","source_path":"environment.md","text_hash":"cf0923bd0c80e86a7aa644d04aa412cbd7baa3273153c40c625ceca9e012bde8","text":" runs your login shell and imports only **missing** expected keys:","translated":" 运行你的登录 shell 并仅导入**缺失的**预期键:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:11:51Z"} -{"cache_key":"2a125978841a8b745660c2fe10733f5a7ec04f35d6edccb62a3a6099827c9f31","segment_id":"help/index.md:frontmatter:summary","source_path":"help/index.md:frontmatter:summary","text_hash":"aece82a2d540ab1a9a21c7b038127cae6e9db2149491564bb1856b6f8999f205","text":"Help hub: common fixes, install sanity, and where to look when something breaks","translated":"帮助中心:常见修复方法、安装完整性检查,以及出现问题时的排查指南","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:56:09Z"} -{"cache_key":"2a272e89ec32a98ddfab85e3261d797830491c81beea1bc76f02a2f10056444a","segment_id":"environment.md:aac7246f5e97142c","source_path":"environment.md","text_hash":"aac7246f5e97142c3f257b7d8b84976f10c29e1b89804bb9d3eb7c43cc03cb8e","text":"Environment variables","translated":"环境变量","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:19:20Z"} -{"cache_key":"2a5de10a869ddf9795eb574cdf1669853adf68de0aa9b586340f9f98b19a2c1b","segment_id":"index.md:723fad6d27da9393","source_path":"index.md","text_hash":"723fad6d27da939353c65417bbaf646b65903b316eb4456297ff4a1c20811e8d","text":": HTTP file server on ","translated":":HTTP 文件服务器运行在 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:29:08Z"} -{"cache_key":"2a9244cf7264d7f417232bd9f92f966b46aa99b5cace7e6461e0b2d3a79e18fc","segment_id":"start/wizard.md:4646ca09dd863969","source_path":"start/wizard.md","text_hash":"4646ca09dd86396938b77d769471ccf591fb10f1e70b87c8e119921585c68647","text":"Anthropic API key (recommended)","translated":"Anthropic API 密钥(推荐)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:41:46Z"} -{"cache_key":"2a9bdab2f771b41c294a531f1d6df2023e4b67ee480cca4539599f2a60055a81","segment_id":"index.md:8fdfb6437318756c","source_path":"index.md","text_hash":"8fdfb6437318756c950bf2261538f06236e36040986891fa7b43452b987fb9f3","text":" — an AI, probably high on tokens","translated":" —— 一个可能被令牌冲昏头脑的 AI","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:54:13Z"} -{"cache_key":"2ac5a1447db5ab39cf2aa397324373ad9f62dc6a5dc80ce471170fb19c6f63e3","segment_id":"environment.md:f15f5f9f4ef4d668","source_path":"environment.md","text_hash":"f15f5f9f4ef4d6688876c894f8eba251ed1db6eaf2209084028d43c9e76a8ba1","text":" (aka ","translated":" (又称 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:45:52Z"} -{"cache_key":"2b3277f22f598b1a6f7a3131d92633b96fe7b09bfc6833b4283733bbb5e47a19","segment_id":"index.md:8f6fb4eb7f42c0e2","source_path":"index.md","text_hash":"8f6fb4eb7f42c0e245e29e63f5b82cc3ba19852681d1ed9aed291f59cf75ec0e","text":"Security","translated":"安全","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:33:12Z"} -{"cache_key":"2b5833fa7ce9898da69d1e64fc5c3a5eba6bb67c371a2b611ff4558aecdd62ca","segment_id":"environment.md:aac7246f5e97142c","source_path":"environment.md","text_hash":"aac7246f5e97142c3f257b7d8b84976f10c29e1b89804bb9d3eb7c43cc03cb8e","text":"Environment variables","translated":"环境变量","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:57:08Z"} -{"cache_key":"2baecfd26ff47bc7814b55f6a2cdeeb462776c8057428fe9125b6157e0185296","segment_id":"index.md:e1b33cfa2a781bde","source_path":"index.md","text_hash":"e1b33cfa2a781bde9ef6c1d08bf95993c62f780a6664f5c5b92e3d3633e1fcf8","text":" (@nachoiacovino, ","translated":" (@nachoiacovino, ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:06:27Z"} -{"cache_key":"2c1cb1cef6155b763b2262ef37c863de566330d14bf74280615cb6e549e58049","segment_id":"environment.md:32ebb1abcc1c601c","source_path":"environment.md","text_hash":"32ebb1abcc1c601ceb9c4e3c4faba0caa5b85bb98c4f1e6612c40faa528a91c9","text":" (","translated":" (","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:57:52Z"} -{"cache_key":"2c3188ffa72715b1d59025704a94f302614ca289ab2320901d5025dbba20e295","segment_id":"index.md:bf084dc7b82e1e62","source_path":"index.md","text_hash":"bf084dc7b82e1e62c63727b13451d1eba2269860e27db290d2d5908d7ade0529","text":" — Pairs as a node and exposes Canvas + Chat + Camera","translated":" —— 作为节点配对并暴露 Canvas + 聊天 + 相机","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:51:17Z"} -{"cache_key":"2c32c166aa68ab2e4ad5a305268b0b4fa3715c00d5c8711954f57c56bce5bf2f","segment_id":"index.md:7af023c43013b9a5","source_path":"index.md","text_hash":"7af023c43013b9a53fbff7dd4b5821588bba3319308878229740489152c43f6d","text":"Docs","translated":"文档","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:03:42Z"} -{"cache_key":"2c4fad6883c7306600d4b8017b42f71a49a9ef90d3f7c903931dcc1a42d6a629","segment_id":"start/getting-started.md:130fc173d131a8a8","source_path":"start/getting-started.md","text_hash":"130fc173d131a8a8e647eff6d934160e7ffc33c8a488d296f4952e43669efece","text":"Remote access (SSH tunnel / Tailscale Serve): ","translated":"远程访问(SSH 隧道 / Tailscale Serve): ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:38:30Z"} -{"cache_key":"2c8498d9a65196b921db3277f57a9f7a4d54f247bf632149a7e6f6d7852e3f8a","segment_id":"index.md:80fc402133201fbe","source_path":"index.md","text_hash":"80fc402133201fbe0e4e9962a9570e741856aa8b0c033f1a20a9bcb06c68e809","text":"Discovery","translated":"发现","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:00:43Z"} -{"cache_key":"2ca15829b0103ac379ae5ff09f282509d35a9e1dc45bbf196c72de71b74bb544","segment_id":"start/wizard.md:1d7b0a62c6b0c807","source_path":"start/wizard.md","text_hash":"1d7b0a62c6b0c8074693534632fba1f2651e07a43d627d9b033133f7be0a1e13","text":"Moonshot example:","translated":"Moonshot 示例:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:47:28Z"} -{"cache_key":"2cd61dfffeb36096d91b4e57fb246bbcee08cc8578906f516e40f38a3f0fd07b","segment_id":"start/getting-started.md:552d8f1e99b582e6","source_path":"start/getting-started.md","text_hash":"552d8f1e99b582e60aca716254ccebd754c93d319a7c4459e4d741e23ebf5e81","text":"Gateway token","translated":"Gateway 令牌","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:36:23Z"} -{"cache_key":"2ce482e209f5de8ec61a9b3c7a287df2841a981d3764b77fdcf48af2a7b85703","segment_id":"help/index.md:24669ff48290c187","source_path":"help/index.md","text_hash":"24669ff48290c1875d8067bbd241e8a55444839747bffb8ab99f3a34ef248436","text":"Doctor","translated":"诊断工具","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:44:58Z"} -{"cache_key":"2d0888805bfed46ef5b60cd62356c0e806d41c0b9121d293e637f9c246793517","segment_id":"start/wizard.md:f5f5d467d48ef0f0","source_path":"start/wizard.md","text_hash":"f5f5d467d48ef0f0285b3b241da9c210af806de0b975ef0d1c8caa8e43f02aca","text":" to route inbound messages (the wizard can do this).","translated":" 以路由入站消息(向导可以执行此操作)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:47:03Z"} -{"cache_key":"2d2da52fe8692965c9fb95b555b3aa3e2a2a66b0d5dda886a051d52f1a0ef1e3","segment_id":"index.md:c4b2896a2081395e","source_path":"index.md","text_hash":"c4b2896a2081395e282313d6683f07c81e3339ef8b9d2b5a299ea5b626a0998f","text":").","translated":")。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:49:09Z"} -{"cache_key":"2d3da03d164952a8c60693fdd97d8be29700340d6eaee19967b24342510d499a","segment_id":"index.md:83f4fc80f6b452f7","source_path":"index.md","text_hash":"83f4fc80f6b452f7cdf426f6b87f08346d7a2d9c74a0fb62815dce2bfddacf63","text":" — A space lobster, probably","translated":" —— 大概是一只太空龙虾说的","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:47:27Z"} -{"cache_key":"2d48b01c4769947be3c3683c4a8f28dd565d2db1936464b4c5d5731a12d79c60","segment_id":"index.md:30f035b33a6c35d5","source_path":"index.md","text_hash":"30f035b33a6c35d51e09f9241c61061355c872f2fb9a82822cd2f5f443fd4ad4","text":"Group Chat Support","translated":"群聊支持","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:50:41Z"} -{"cache_key":"2d797dc8210ef143472d22214940192b0a9192ba2fbb7c937caed61f06927d9b","segment_id":"index.md:74f99190ef66a7d5","source_path":"index.md","text_hash":"74f99190ef66a7d513049d31bafc76e05f9703f3320bf757fb2693447a48c25b","text":"Linux app","translated":"Linux 应用","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:33:02Z"} -{"cache_key":"2e074e5ac1705b797a4c77f7b223adcd0ae6a9f96032a837408a8435a639baff","segment_id":"index.md:37ed7c96b16160d4","source_path":"index.md","text_hash":"37ed7c96b16160d491e44676aa09fe625301de9c018ad086e263f59398b8be8a","text":"🎤 ","translated":"🎤 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:50:53Z"} -{"cache_key":"2e1d0951abfbe4efbae5e5ef595f231d5292c0fcb5b3ee23005ce2b68c1d79ee","segment_id":"help/index.md:0e4ea41f62f3485d","source_path":"help/index.md","text_hash":"0e4ea41f62f3485d38cc0e63e2ccf0b40ee1e32a060b3902767d612fe0823e0e","text":" here:","translated":" 这里:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:44:32Z"} -{"cache_key":"2e3290e6bc1d3f1822509afccd756cc87e8abd242e0141e0ee64721fdb864f3f","segment_id":"start/getting-started.md:f11e33a27b5b9a1c","source_path":"start/getting-started.md","text_hash":"f11e33a27b5b9a1c3aefd4fc3e37fd3effab8e9378119a2a21d20312adb940a7","text":"CLI onboarding wizard","translated":"CLI 上手引导向导","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:34:48Z"} -{"cache_key":"2e45b9544e6ff8d717250c9ddcf1e30690e094f474a744dda548b3e297c59cb7","segment_id":"environment.md:8d076464a84995bc","source_path":"environment.md","text_hash":"8d076464a84995bc095e934b0aa1e4419372f27cd71d033571e4dbba201ee5d8","text":"You can reference env vars directly in config string values using ","translated":"你可以在配置的字符串值中直接使用以下方式引用环境变量 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:12:58Z"} -{"cache_key":"2e681efe18de20b4f07ad32002c6fec86c06e56a12cd30d9c7bdbc9534bb6882","segment_id":"environment.md:frontmatter:summary","source_path":"environment.md:frontmatter:summary","text_hash":"78351223e7068721146d2de022fdf440c2866b2ee02fbbb50bf64369b999820b","text":"Where OpenClaw loads environment variables and the precedence order","translated":"OpenClaw 加载环境变量的位置及优先级顺序","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:15:44Z"} -{"cache_key":"2ef70367fb9aa09677565cc6176a971e2f70631d568dfe275604a6337f5ab6ad","segment_id":"environment.md:b1d6b91b67c2afa5","source_path":"environment.md","text_hash":"b1d6b91b67c2afa5e322988d9462638d354ddf8a1ef79dba987f815c22b4baee","text":" at ","translated":" 位于 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:57:32Z"} -{"cache_key":"2f07feeae0f7c7fd42259f8d149e737f19de5e6a5479067a97efdec3042fdd56","segment_id":"start/wizard.md:ca7981b46ecf2c17","source_path":"start/wizard.md","text_hash":"ca7981b46ecf2c1787b6d76d81d9fd7fa0ca95842e2fcc2a452869891a9334d1","text":"Off","translated":"关闭","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:40:02Z"} -{"cache_key":"2f42896cbef80e422a89e75bb6eff3784602d724b92e704a145d54f389869f2c","segment_id":"start/wizard.md:be297ea5bdb13e65","source_path":"start/wizard.md","text_hash":"be297ea5bdb13e6504ca452403bae1d77358398f376fc59ee9f4e06d566bc3e9","text":" even for loopback so local WS clients must authenticate.","translated":" 即使在回环地址上也使用,以确保本地 WS 客户端必须进行认证。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:44:03Z"} -{"cache_key":"300af3259f829741d51736865d7bf9f842f81f2138585d92d271370b4fb55164","segment_id":"environment.md:1734069c13c6a5b4","source_path":"environment.md","text_hash":"1734069c13c6a5b4de554e73a650ddce6651688b5771f03df706a836393aea3c","text":" override).","translated":" 覆盖)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:45:45Z"} -{"cache_key":"30608165f61f3a9ef054a6c564e06dfb89be246127382e61e93c52a93fa2aa9c","segment_id":"start/wizard.md:frontmatter:summary","source_path":"start/wizard.md:frontmatter:summary","text_hash":"37d4cb914a0312f3c0272449b49ff1a5b48ae22e79defb9680df63865bc21ea3","text":"CLI onboarding wizard: guided setup for gateway, workspace, channels, and skills","translated":"CLI 上手引导向导:Gateway、工作区、渠道和技能的引导式设置","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:38:51Z"} -{"cache_key":"30675512d7a61a650d56f0b23e4df35eee0be54824589dfe3cd69ef8055204a3","segment_id":"index.md:66d0f523a379b2de","source_path":"index.md","text_hash":"66d0f523a379b2de6f8d5fba3a817ebc395f7bcaa54cc132ca9dfa665d1e9378","text":"Skills","translated":"技能","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:04:17Z"} -{"cache_key":"307223a7ef9d756946e976426895e5f1195f544f15a205458dc725b42d4f6ee1","segment_id":"help/index.md:5c94724fa7810fa9","source_path":"help/index.md","text_hash":"5c94724fa7810fa9902e565cf66c5f5a973074f2961fcd3a40bad4ee4aeca5e0","text":"If you want a quick “get unstuck” flow, start here:","translated":"如果你想快速\"解决卡住的问题\",从这里开始:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:11:27Z"} -{"cache_key":"30a615ba735a73e7d3242363754be6841b011b06bcf0852eb50b1c2fad210ba1","segment_id":"index.md:9c870aa6e5e93270","source_path":"index.md","text_hash":"9c870aa6e5e93270170d5a81277ad3e623afe8d4efd186d3e28f3d2b646d52e6","text":"How it works","translated":"工作原理","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:48:18Z"} -{"cache_key":"30df5b02209abc9fe6dad8f8edb5d9e1ecc23a3dafd5c4df988491ab87667a35","segment_id":"start/wizard.md:acdd1e734125f341","source_path":"start/wizard.md","text_hash":"acdd1e734125f341604c0efbabdcc4c4b0597e8f6235d66c2445edd1812838c1","text":"Telegram","translated":"Telegram","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:44:27Z"} -{"cache_key":"314a94405174f8d50e035a204e5843da2f66b97b162a65bf2b933f01abbd59f9","segment_id":"start/wizard.md:e639687221fe4ab0","source_path":"start/wizard.md","text_hash":"e639687221fe4ab0824252705b8c5db6c8ece564b77025b0f6b6a4252abb9f86","text":"Seeds the workspace files needed for the agent bootstrap ritual.","translated":"生成智能体引导启动仪式所需的工作区文件。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:43:46Z"} -{"cache_key":"314dd665b6543d8aa3e07bfb4411985b9e65d96c9dd2d548df33fb32bd6a7137","segment_id":"start/getting-started.md:5ed525159ebd3715","source_path":"start/getting-started.md","text_hash":"5ed525159ebd371551c1615ae2782e61c74c0ed4149ffd117284ba9523eeda84","text":"1) Install the CLI (recommended)","translated":"1)安装 CLI(推荐)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:35:46Z"} -{"cache_key":"3160cff1ac3376c86eff02fa191d7effc632679f70ad9d0572805c87e0373938","segment_id":"start/wizard.md:4b57039163eb0a5c","source_path":"start/wizard.md","text_hash":"4b57039163eb0a5c8ee4015d016164636534a01cc8acf14b5ce9d191319954c3","text":" to your config.","translated":" 到您的配置中。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:48:06Z"} -{"cache_key":"31dec649c828923140b2b30d6a8b2d62591976002370e88c3d3de3ac115cb781","segment_id":"environment.md:45ca56d179d4788c","source_path":"environment.md","text_hash":"45ca56d179d4788c55ba9f7653b376d62e7faa738e92259e3d4f6f5c1b554f28","text":"Related","translated":"相关内容","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:17:10Z"} -{"cache_key":"3243c3ebdcaa9521501483661e48d2fdd966942cb24ea4dcde3d06b713aed8b4","segment_id":"index.md:3c8aa7ad1cfe03c1","source_path":"index.md","text_hash":"3c8aa7ad1cfe03c1cb68d48f0c155903ca49f14c9b5626059d279bffc98a8f4e","text":": connect to the Gateway WebSocket (LAN/tailnet/SSH as needed); legacy TCP bridge is deprecated/removed.","translated":":连接到 Gateway WebSocket(根据需要使用局域网/Tailnet/SSH);旧版 TCP 桥接已弃用/移除。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:48:52Z"} -{"cache_key":"3251f0f0403513aa1ec086eadd313880a5c01383a210ec45da22d6fa4782490e","segment_id":"index.md:ee8b06871d5e335e","source_path":"index.md","text_hash":"ee8b06871d5e335e6e686f4e2ee9c9e6de5d389ece6636e0b5e654e0d4dd5b7e","text":"Control UI (browser)","translated":"控制界面(浏览器)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:32:34Z"} -{"cache_key":"3276f34c2339c0658f45d0f009490234b47b5b52b8d90ee1387edff4a69ac8ae","segment_id":"index.md:93c89511a7a5dda3","source_path":"index.md","text_hash":"93c89511a7a5dda3b3f36253d17caee1e31f905813449d475bc6fed1a61f1430","text":"common fixes + troubleshooting","translated":"常见修复 + 故障排除","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:03:56Z"} -{"cache_key":"32a83090da1dc024c2a8cf8f0db8f301764d5bb1a471887273753a86569bd8cf","segment_id":"start/getting-started.md:frontmatter:read_when:1","source_path":"start/getting-started.md:frontmatter:read_when:1","text_hash":"8ffadc75217e7de913dec33459e2fc4726878cf78a1f8f6a6ce9b3b7305efa17","text":"You want the fastest path from install → onboarding → first message","translated":"您希望找到从安装 → 上手引导 → 发送第一条消息的最快路径","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:34:19Z"} -{"cache_key":"32aaa528f2fdc0ff7d03b917f5957bf4f19d264db511d3a6fcf39564f4c143f1","segment_id":"help/index.md:2adc964c084749b1","source_path":"help/index.md","text_hash":"2adc964c084749b1f2d8aef24030988b667dbda2e38a6a1699556c93e07c1cea","text":"Start here","translated":"从这里开始","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:24:38Z"} -{"cache_key":"32f6ccc5f301eef89b0add96d877ba4df42d5d5b8a9cd794abf9f467d5f12d54","segment_id":"help/index.md:8cd501e1124c3047","source_path":"help/index.md","text_hash":"8cd501e1124c30473473c06e536a2d145e2a14a6d7dc1b99028ce818e14442e2","text":"Repairs:","translated":"修复:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:24:52Z"} -{"cache_key":"3341a2da05aa7a14de4f05127a21a28fff121cd29d0c2dd4fe6bbf663fb59d7d","segment_id":"index.md:66354a1d3225edbf","source_path":"index.md","text_hash":"66354a1d3225edbf01146504d06aaea1242dcf50424054c3001fc6fa2ddece0f","text":"Remote access","translated":"远程访问","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:29:23Z"} -{"cache_key":"336e0d3df54cefc9b4f845d46763359b00993efe537c84b384ff77a19c5d95e9","segment_id":"start/wizard.md:593b35c1b027b42b","source_path":"start/wizard.md","text_hash":"593b35c1b027b42b1f14fcd3913017dae726062941e8039a72e3af3399f728df","text":"Gateway auth ","translated":"Gateway 认证 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:39:53Z"} -{"cache_key":"33bc493412fad9ad498a84a301ea34b43f9f34939896f8221f6e095724982543","segment_id":"start/wizard.md:0e3a130e3ae6be30","source_path":"start/wizard.md","text_hash":"0e3a130e3ae6be30792e3eeb94fed964dcceddef27f7e723da02c1d3a3a8df94","text":"Local gateway (loopback)","translated":"本地 Gateway(回环地址)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:39:45Z"} -{"cache_key":"33e6190920d1a6f91b3914cb673b9488a8bab0364506c16f588e85340bde439c","segment_id":"index.md:63a3abfa879299dd","source_path":"index.md","text_hash":"63a3abfa879299ddcc03558012bfd6075cbd72f7a175b739095bf979700297f7","text":"Multi-instance quickstart (optional):","translated":"多实例快速入门(可选):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:51:42Z"} -{"cache_key":"34058f497844c4ec778554dcaefe46e1ee1747532d1d13b1d71c9f0ce44c7514","segment_id":"environment.md:d08a8493f686363a","source_path":"environment.md","text_hash":"d08a8493f686363a78b913d45ebfbd87a3768d1c77b70f23b1fdade3c066e481","text":"Shell env import","translated":"Shell 环境导入","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:22:18Z"} -{"cache_key":"34086c013763b98b351cdd0b4f0249d6d22e5b03a465b1753e4de88e587c00ab","segment_id":"index.md:36ddb4d3cfcb494f","source_path":"index.md","text_hash":"36ddb4d3cfcb494fb96463d42b35ba923731677cfc9e084af9f25e3f231187d5","text":"💬 ","translated":"💬 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:49:51Z"} -{"cache_key":"34b531c230116c17ef31fc8a6f8428f6274208c2de206e4cdda99e9a1a9cb042","segment_id":"index.md:8f6fb4eb7f42c0e2","source_path":"index.md","text_hash":"8f6fb4eb7f42c0e245e29e63f5b82cc3ba19852681d1ed9aed291f59cf75ec0e","text":"Security","translated":"安全","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:05:29Z"} -{"cache_key":"34bfc7d107afffb5c740b7b90a1c6047e44c21fcac52d6b6f4859d91e803c9eb","segment_id":"environment.md:546f47a9170b7f79","source_path":"environment.md","text_hash":"546f47a9170b7f79afe6bb686aecab9c734c8e8a7d2b353d7e507ee932a0c348","text":"Environment variables\n\nOpenClaw pulls environment variables from multiple sources. The rule is ","translated":"环境变量\n\nOpenClaw 从多个来源获取环境变量。规则是 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:15:53Z"} -{"cache_key":"34e14035ff1a271359d67411ba4926d4dc09453dfd5418ece20924bcbfa96965","segment_id":"environment.md:496aca80e4d8f29f","source_path":"environment.md","text_hash":"496aca80e4d8f29fb8e8cd816c3afb48d3f103970b3a2ee1600c08ca67326dee","text":" block","translated":" 块","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:57:41Z"} -{"cache_key":"34ea3ba7fd58bb28161b7b4359bbcccffec2e9d4dbc54286ca2b0c1730769a8d","segment_id":"environment.md:cdb4ee2aea69cc6a","source_path":"environment.md","text_hash":"cdb4ee2aea69cc6a83331bbe96dc2caa9a299d21329efb0336fc02a82e1839a8","text":".","translated":".","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:57:15Z"} -{"cache_key":"35283f721f41d4ee600ccb4bbea1d3385ba23774e7a7790fdd45b6c18600469a","segment_id":"environment.md:d08a8493f686363a","source_path":"environment.md","text_hash":"d08a8493f686363a78b913d45ebfbd87a3768d1c77b70f23b1fdade3c066e481","text":"Shell env import","translated":"Shell 环境导入","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:11:49Z"} -{"cache_key":"352defd8fc42d9b060b6cd430d417fc2bd4c12fe53ba446d4a476ad42ccab112","segment_id":"start/wizard.md:0f3a1d92bc3a545d","source_path":"start/wizard.md","text_hash":"0f3a1d92bc3a545d9c34affb3f3116c0cc492f4a1045c05778fc4d4c442b9b96","text":" (plugin): bot token + base URL.","translated":" (插件):机器人令牌 + 基础 URL。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:44:43Z"} -{"cache_key":"35459952230518c31110b6b5f175abe88b59834c219cab3d71012db683db8121","segment_id":"start/getting-started.md:67b696468610b879","source_path":"start/getting-started.md","text_hash":"67b696468610b879ed7f224dbf6b0861f27e39d20454cb9d7af1ec52d3e5eeaa","text":"Dashboard","translated":"仪表盘","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:34:39Z"} -{"cache_key":"357d48a4c5e474910bc6ec1bc5ae8587a7e5f0207dd6c9102c7a1442f5696107","segment_id":"environment.md:cf3f9ba035da9f09","source_path":"environment.md","text_hash":"cf3f9ba035da9f09202ba669adca3109148811ef31d484cc2efa1ff50a1621b1","text":" (what the Gateway process already has from the parent shell/daemon).","translated":" (Gateway 进程从父 shell/守护进程中已获取的内容)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:16:06Z"} -{"cache_key":"364bf5b819ca7701a74bb51b78b68bb812f4e3f3590b3c69afe3efd9b0459c6b","segment_id":"environment.md:d4a67341570f4656","source_path":"environment.md","text_hash":"d4a67341570f4656784c5f8fe1bfb48a738ace57b52544977431d50e2b718099","text":"FAQ: env vars and .env loading","translated":"常见问题:环境变量和 `.env` 加载","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:26:40Z"} -{"cache_key":"3663f83bba62df5e7cb863e55c86882f54e5d3c7ee21fe4fa3335e3ea53f2d70","segment_id":"index.md:e64d6b29b9d90bba","source_path":"index.md","text_hash":"e64d6b29b9d90bba92ffe2539dc295a75c553684fed0350ee56bfd0aead01662","text":"Multiple gateways","translated":"多网关","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:59:58Z"} -{"cache_key":"36b17044c786e63bff17024017e7376bbbfab4b3abdcda6216a8ff4155e90b82","segment_id":"index.md:9182ff69cf35cb47","source_path":"index.md","text_hash":"9182ff69cf35cb477c02452600d23b52a49db7bd7c9833a9a8bc1dcd90c25812","text":"Node ≥ 22","translated":"Node ≥ 22","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:02:57Z"} -{"cache_key":"36ee9ff0bfd7f7a2fed757962b44a70c9130d57288004d2941c4090fe792a044","segment_id":"index.md:30f035b33a6c35d5","source_path":"index.md","text_hash":"30f035b33a6c35d51e09f9241c61061355c872f2fb9a82822cd2f5f443fd4ad4","text":"Group Chat Support","translated":"群聊支持","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:02:06Z"} -{"cache_key":"36f5535fb346a7e5a8ac7bf97f71b16da9e836aaac6004bc7f2baf2b4f74ee89","segment_id":"start/getting-started.md:f480ffb2979d1888","source_path":"start/getting-started.md","text_hash":"f480ffb2979d188849ef6ddeb7cefe0aec4406a459adc51df4808a3545d7095c","text":" uses ","translated":" 使用 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:35:17Z"} -{"cache_key":"3707b96e2e6a68d6b2b2cb1bc408bfdcc00b380bed0febd7847ebf22d0f0a144","segment_id":"start/wizard.md:acd0067e1ce6598b","source_path":"start/wizard.md","text_hash":"acd0067e1ce6598bac4486d7dec30e89e0cb9486eb7a5ab655327f2398d82ee2","text":"Stores it under ","translated":"将其存储在 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:48:01Z"} -{"cache_key":"3720955986bed01c1359c0e548caea0c5440fad4b43365d2fde56fb04a4e0759","segment_id":"start/wizard.md:610b6a1041c9c16b","source_path":"start/wizard.md","text_hash":"610b6a1041c9c16ba409d615ac9fc646e065c13b271889569a0f3cab45fb422b","text":"Signal setup (signal-cli)","translated":"Signal 设置 (signal-cli)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:47:49Z"} -{"cache_key":"3761d0df7c47bdf9c52491e5e93c6b9b8e7c948074b5925f77582063f787622c","segment_id":"index.md:42bb365211decccb","source_path":"index.md","text_hash":"42bb365211decccb3509f3bf8c4dfcb5ae05fe36dfdedb000cbf44e59e420dc9","text":" — Local imsg CLI integration (macOS)","translated":" —— 本地 imsg CLI 集成(macOS)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:49:56Z"} -{"cache_key":"3790b7cccef83371cab7a1989734dc2df8216f5cdb52d6e28db0e9e844c5671c","segment_id":"help/index.md:8cd501e1124c3047","source_path":"help/index.md","text_hash":"8cd501e1124c30473473c06e536a2d145e2a14a6d7dc1b99028ce818e14442e2","text":"Repairs:","translated":"修复:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:11:27Z"} -{"cache_key":"37b110b0b1d718b41a94fb3a9a4f13223dae87e68c5e0f999d287897a386511e","segment_id":"environment.md:cdb4ee2aea69cc6a","source_path":"environment.md","text_hash":"cdb4ee2aea69cc6a83331bbe96dc2caa9a299d21329efb0336fc02a82e1839a8","text":".","translated":"。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:22:02Z"} -{"cache_key":"37b7c0541ea6313c43233942e42fd671ee86f3f7a07973e395b38ad0ff8dbc0a","segment_id":"index.md:6b3f22c979b9e6f8","source_path":"index.md","text_hash":"6b3f22c979b9e6f8622031a6b638ec5f730c32de646d013e616078e03f5a6149","text":"iOS node","translated":"iOS 节点","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:30:55Z"} -{"cache_key":"380da2c38442028181e5e4e1bc68442ff14ff2bcd89177a4c1a3bc96b478155b","segment_id":"index.md:36ddb4d3cfcb494f","source_path":"index.md","text_hash":"36ddb4d3cfcb494fb96463d42b35ba923731677cfc9e084af9f25e3f231187d5","text":"💬 ","translated":"💬 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:29:49Z"} -{"cache_key":"3861d187be12abb8bb56846e66cdbe56efbfcc8ab9dc5fa49ad7526b34954f7c","segment_id":"help/index.md:729bc562eec2658b","source_path":"help/index.md","text_hash":"729bc562eec2658bd11ffdd522fe5277177dc73e86eaca7baac0b472a4d8f8b2","text":"If you’re looking for conceptual questions (not “something broke”):","translated":"如果你在寻找概念性问题(而非\"出了故障\"):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:56:47Z"} -{"cache_key":"38b96681367af140e653ee05ec7a261cf0941c0975166b3ac38008c0f1fd218d","segment_id":"help/index.md:71095a6d42f5d9c2","source_path":"help/index.md","text_hash":"71095a6d42f5d9c2464a8e3f231fc53636d4ce0f9356b645d245874162ec07e2","text":"Gateway troubleshooting","translated":"Gateway 故障排除","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:11:19Z"} -{"cache_key":"38ce802888d58badfba21b504c45ae2e126bfa2ff05300da807328abce6bb3ea","segment_id":"environment.md:cf3f9ba035da9f09","source_path":"environment.md","text_hash":"cf3f9ba035da9f09202ba669adca3109148811ef31d484cc2efa1ff50a1621b1","text":" (what the Gateway process already has from the parent shell/daemon).","translated":" (Gateway 进程从父 shell/守护进程中已继承的值)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:12:02Z"} -{"cache_key":"38e1bd28383c38781623486e59dac26bb496cbacc9e2eda9120b373298a51ff3","segment_id":"index.md:ba5ec51d07a4ac0e","source_path":"index.md","text_hash":"ba5ec51d07a4ac0e951608704431d59a02b21a4e951acc10505a8dc407c501ee","text":")","translated":")","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:28:11Z"} -{"cache_key":"3916f9bae114afcf8d795dfb5375fa24f3e06a8a118fe2ecbb2915900f6a9f82","segment_id":"index.md:e3209251e20896ec","source_path":"index.md","text_hash":"e3209251e20896ecc60fa4da2817639f317fbb576288a9fc52d11e5030ecc44a","text":"Windows (WSL2)","translated":"Windows (WSL2)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:05:14Z"} -{"cache_key":"394214a19e461907fb2a1cc918c6f38ae64e4715143377c7a9166d0b985547df","segment_id":"index.md:88d90e2eef3374ce","source_path":"index.md","text_hash":"88d90e2eef3374ce1a7b5e7fbd3b1159364b26a8ceb2493d6e546d4444b03cda","text":"Tailscale","translated":"Tailscale","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:48:16Z"} -{"cache_key":"3983900aa2122716086238320301d2ccc9ca38cbfbc5fadec4629f48bac4e248","segment_id":"index.md:5928d14b4d45263d","source_path":"index.md","text_hash":"5928d14b4d45263d4964dfd301c84ed2674ca8b4b698c5efeb88fb86076d2bf9","text":"🎮 ","translated":"🎮 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:01:02Z"} -{"cache_key":"3a153551510fda2c4710a20c9a4cc23057396667a7df9dd6e1abcab82c50b896","segment_id":"environment.md:61115f6649792387","source_path":"environment.md","text_hash":"61115f664979238731a390e84433a818965b7eaf1d38fa5b4b1507c33ef28c91","text":"Precedence (highest → lowest)","translated":"优先级(从高到低)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:22:04Z"} -{"cache_key":"3a1be3034443bc71b47581e4ea05266f8538eaa0a0fdc3da9bad0ed023893ac7","segment_id":"start/getting-started.md:e93372533f323b2f","source_path":"start/getting-started.md","text_hash":"e93372533f323b2f12783aa3a586135cf421486439c2cdcde47411b78f9839ec","text":"Node ","translated":"Node ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:35:24Z"} -{"cache_key":"3a3087543c4f5b0648ff7fc2645ae4cf40a2f985a95b29227569f1d421fab438","segment_id":"index.md:19525ac5e5b9c476","source_path":"index.md","text_hash":"19525ac5e5b9c476b36a38c5697063e37e8fe2fae8ef6611f620def69430cf74","text":"Canvas host","translated":"Canvas 主机","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:29:06Z"} -{"cache_key":"3aa380273cd8edfdd5f5b29a07a527e398f72e5526104fe71ae89a782551ca9e","segment_id":"help/index.md:8ddb7fc8a87904de","source_path":"help/index.md","text_hash":"8ddb7fc8a87904dedc2afc16400fbe4e78582b302e01c30b1319c8a465d04684","text":"Troubleshooting:","translated":"故障排除:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:15:14Z"} -{"cache_key":"3aa6384989e1547147163565da676d20d7c8194489e7a36036790d562a12ac49","segment_id":"index.md:f3047ab42a6a5bbf","source_path":"index.md","text_hash":"f3047ab42a6a5bbf164106356fa823ecada895064120c4e5a30e1f632741cc5f","text":"Web surfaces","translated":"Web 界面","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:48:11Z"} -{"cache_key":"3b073a8aca3cde51037eb2b555734543d6c8e5d498f8533174f1eb9496fc894d","segment_id":"environment.md:frontmatter:read_when:1","source_path":"environment.md:frontmatter:read_when:1","text_hash":"a3a2d99a99de98220c8e0296d6f4e4b2a34024916bd2379d1b3b9179c8fae46f","text":"You are debugging missing API keys in the Gateway","translated":"你正在调试 Gateway 中缺失的 API 密钥","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:11:45Z"} -{"cache_key":"3b0bba02beb661f8e7cf1069121bbd16a281b73b7a5a0c4447beb270d19cfa37","segment_id":"environment.md:b4736422e64c0a36","source_path":"environment.md","text_hash":"b4736422e64c0a369663d1b2d386f1b8f4b31b8936b588e4a54453c61a24e0fd","text":"Process environment","translated":"进程环境","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:57:20Z"} -{"cache_key":"3b1145589cf333c443556a480ae2d7f03f2014b1e8941d78e2bc2c9c128af7e4","segment_id":"start/wizard.md:c5c46554cb43b7f8","source_path":"start/wizard.md","text_hash":"c5c46554cb43b7f83f3e8fc3be0ad1f0370946ec6e0a19a114d9bab8a127947a","text":"OAuth credentials live in ","translated":"OAuth 凭据存储在 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:43:29Z"} -{"cache_key":"3b357b9dda6a24f40882977ca076c8800b43c2eae6f4cd8cbef8aa0f129fdc06","segment_id":"environment.md:b79606fb3afea5bd","source_path":"environment.md","text_hash":"b79606fb3afea5bd1609ed40b622142f1c98125abcfe89a76a661b0e8e343910","text":" config","translated":" 配置","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:46:59Z"} -{"cache_key":"3b40d9da2852e7fbd97da9ba5dffbd04f5d5c0fc00def960a206e2c94914b245","segment_id":"index.md:8fdfb6437318756c","source_path":"index.md","text_hash":"8fdfb6437318756c950bf2261538f06236e36040986891fa7b43452b987fb9f3","text":" — an AI, probably high on tokens","translated":" — 一个可能被令牌冲昏头脑的 AI","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:05:47Z"} -{"cache_key":"3b71135c0933181aa6bde7a58dd5a3707209324d5e2168571e948b9b5f2d67e8","segment_id":"index.md:15cd10b29ec14516","source_path":"index.md","text_hash":"15cd10b29ec1451670b80eae4b381e26e84fa8bdb3e8bea90ec943532411b189","text":" (@Hyaxia, ","translated":" (@Hyaxia, ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:54:44Z"} -{"cache_key":"3b738961b879475be2f5ff57712c2c1bf8bd3060d1cf15dbf5426794d16203b9","segment_id":"start/wizard.md:228b0332ec267772","source_path":"start/wizard.md","text_hash":"228b0332ec267772e57c8b59f1e9e3464839a76a98fc7bf9ba4b9a4509a1d2ff","text":" (defaults) vs ","translated":" (默认设置)与 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:39:37Z"} -{"cache_key":"3b75f262948cbbb512b4599733ba93e13681e31c930bbdc664ab71e885662b2e","segment_id":"environment.md:77ee4c8d363762a8","source_path":"environment.md","text_hash":"77ee4c8d363762a834617dcf68d6288847eba4544071d9e11e42cf8d08c579d6","text":"Shell env","translated":"Shell 环境","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:46:34Z"} -{"cache_key":"3b8197284023245c9a413e63d82ca9df26e860990475095d8c3bba3a2ea3cf3c","segment_id":"index.md:2a6b24ad28722034","source_path":"index.md","text_hash":"2a6b24ad287220345e96eb8021fe29d42b0785766c8df658827e7251da2d36dc","text":"Credits","translated":"致谢","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:05:50Z"} -{"cache_key":"3bb189a0fee15a008f7403303c01b5afa61f2762fbe8f30fb11a0b88c64d50ec","segment_id":"environment.md:668e5590b5bb9990","source_path":"environment.md","text_hash":"668e5590b5bb9990eeb25bf657f7d17281a4c613ee4442036787cd4b2efd22bb","text":"If the config file is missing entirely, step 4 is skipped; shell import still runs if enabled.","translated":"如果配置文件完全缺失,步骤 4 将被跳过;如果已启用,shell 导入仍会运行。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:11:43Z"} -{"cache_key":"3be0ad5ba6125bd8e82b0ef3fe5ce52ac6e8cc36295f873bb4eeb53295a493d7","segment_id":"environment.md:f6b2ffe1d0d5f521","source_path":"environment.md","text_hash":"f6b2ffe1d0d5f521b76cabc67d6e96da2b1170eef8086d530558e9906a7f092d","text":"Models overview","translated":"模型概览","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:26:42Z"} -{"cache_key":"3c2f6b5fb86a0b339fef1dce671429b0675d7f6c8e96131c14ae045e330c64ad","segment_id":"index.md:185beb968bd1a81d","source_path":"index.md","text_hash":"185beb968bd1a81d07ebcf82376642f7b29f1b5594b21fe9edee714efbdcaa44","text":"✈️ ","translated":"✈️ ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:00:55Z"} -{"cache_key":"3c3837312bb9e8d810155bdffa548dbde797f82ff7edf8ac411825656a304c4a","segment_id":"start/wizard.md:6f75d6dfebf55cc4","source_path":"start/wizard.md","text_hash":"6f75d6dfebf55cc4d7cb48ee42a6c6bc47c6bcd606f0dbbc145913b7854d46fd","text":"What it sets:","translated":"它会设置:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:46:55Z"} -{"cache_key":"3c7d5d086025aacdb08eff6300599c7eb8133d3becbaefcf7fac34ff2d733860","segment_id":"index.md:468886872909c70d","source_path":"index.md","text_hash":"468886872909c70d3bfb4836ec60a6485f4cbbd0f8a0acedbacb9b477f01a251","text":"Workspace templates","translated":"工作区模板","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:52:46Z"} -{"cache_key":"3c85fd86ca15e8381d7fe82eaffa4dbc27dd63f820415f9a09be673d0847aff8","segment_id":"start/wizard.md:48ced72d53b97892","source_path":"start/wizard.md","text_hash":"48ced72d53b9789268649241dadbca3f8646867df4eef54f7eadac8c1c6cefc0","text":"Reset uses ","translated":"重置使用 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:41:24Z"} -{"cache_key":"3ce65ae798c705018325eadd2d993e1a9b4bd37081ac208ecec458bb23cd1ad2","segment_id":"start/wizard.md:bb1547f6c875dff6","source_path":"start/wizard.md","text_hash":"bb1547f6c875dff692cde4cb57350780c86b3129399197067c8b5e0fc5a90df3","text":": no auth configured yet.","translated":":暂不配置认证。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:43:19Z"} -{"cache_key":"3cf8f452fc7a0bba84fdf6434ebeb287af7b726270c145cf753b4fe8bd082ee2","segment_id":"start/getting-started.md:f2e04e77070557f1","source_path":"start/getting-started.md","text_hash":"f2e04e77070557f154fb52bb7c75bf115d8981374d0dccc6027944b70bc6951b","text":" on the gateway host.\nDocs: ","translated":" (在 Gateway 主机上)。\n文档: ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:34:37Z"} -{"cache_key":"3d1656dbb878ef3bcba5b41e9ed57f4ce9c7f8963181f68a2fe1752a5e2e1c17","segment_id":"index.md:b0d125182029e6c5","source_path":"index.md","text_hash":"b0d125182029e6c500cbcc81011341df77de8fe24d9e80190c32be390c916ec2","text":"🤖 ","translated":"🤖 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:01:23Z"} -{"cache_key":"3d568ffb6b3b3d75349d653870affc28760361ff6599283374c4c7864f706f2d","segment_id":"index.md:2a6b24ad28722034","source_path":"index.md","text_hash":"2a6b24ad287220345e96eb8021fe29d42b0785766c8df658827e7251da2d36dc","text":"Credits","translated":"致谢","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:33:27Z"} -{"cache_key":"3d6a0cbc582dcbd551bd16cae84dfe04310466d13179af3d43e3a05493bbe1b4","segment_id":"index.md:10bf8b343a32f7dc","source_path":"index.md","text_hash":"10bf8b343a32f7dc01276fc8ae5cf8082e1b39c61c12d0de8ec9b596e115c981","text":"WebChat","translated":"网页聊天","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:04:41Z"} -{"cache_key":"3de4148817a3da24501b46c0db8c9432af68e4c926bfbb22af9a5310629c6f3c","segment_id":"index.md:3d8fed7c358b2ccf","source_path":"index.md","text_hash":"3d8fed7c358b2ccf225ee16857a0bb9b950fd414319749e0f6fff58c99fa5f22","text":"Subscription auth","translated":"订阅认证","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:30:16Z"} -{"cache_key":"3df33562454535183c5399ca80fa7d2817a4a79760aaa5230f128a95d3c78827","segment_id":"environment.md:3fe738a7ee6aaff5","source_path":"environment.md","text_hash":"3fe738a7ee6aaff51f099d9a8314510c99ced6a568eb38c67642cd43bb54eec0","text":" in the current working directory","translated":" 在当前工作目录中","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:25:32Z"} -{"cache_key":"3df86b9cf5fa329e474cd1383be56b18a77ca9f34aee7679099cf0a67853f799","segment_id":"start/wizard.md:ccc02bfc6371f274","source_path":"start/wizard.md","text_hash":"ccc02bfc6371f2743a3ab2ffd360c100414415a0b4c0f5fe6866820d50a58534","text":"Port, bind, auth mode, tailscale exposure.","translated":"端口、绑定、认证模式、Tailscale 暴露。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:43:56Z"} -{"cache_key":"3e19ae8f54b98c209753f915d52f542a7891896d7769f2f2e5ff828bfc49a093","segment_id":"start/wizard.md:7cecbbd299f4893d","source_path":"start/wizard.md","text_hash":"7cecbbd299f4893d61d339700773335a412ab1b532b435cd1aa290ab59e6391d","text":"Runtime selection:","translated":"运行时选择:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:45:30Z"} -{"cache_key":"3e685aee1a7051e665e054c4c774e80e188dc239d3df8193efef4838ae204fa8","segment_id":"environment.md:45ca56d179d4788c","source_path":"environment.md","text_hash":"45ca56d179d4788c55ba9f7653b376d62e7faa738e92259e3d4f6f5c1b554f28","text":"Related","translated":"相关内容","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:26:36Z"} -{"cache_key":"3e8225ab0a25cbca0c7a00bcb9033a5048108adac44a721f7249845d0925250c","segment_id":"index.md:4eb58187170dc141","source_path":"index.md","text_hash":"4eb58187170dc14198eacb534c8577bef076349c26f2479e1f6a2e31df8eb948","text":" — An AI, probably high on tokens","translated":" —— 一个可能被令牌冲昏头脑的 AI","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:55:04Z"} -{"cache_key":"3ec7dfcb17b352e2f217472e109e171fc287a2ee9197f39225034144554575e9","segment_id":"index.md:1a36bded6916228a","source_path":"index.md","text_hash":"1a36bded6916228a5664c8b2bcdaa5661d342fe3e632aa41453f647a3daa3a61","text":" — Pairs as a node and exposes a Canvas surface","translated":" —— 作为节点配对并暴露 Canvas 界面","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:51:12Z"} -{"cache_key":"3ecb8e3215b8818241e73a7e7ae26b1e5202a5384c8f99a2a5262d3bf88112e9","segment_id":"start/wizard.md:4fa6e54efd518fc2","source_path":"start/wizard.md","text_hash":"4fa6e54efd518fc2075e98b366621a5236355222198b8eac9efb802d681fcb8b","text":"Moonshot (Kimi K2)","translated":"Moonshot (Kimi K2)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:43:08Z"} -{"cache_key":"3f05d1942b5a9f6420ad25aea5697d040ea0fbe5470a1eed26a88ce86d2411af","segment_id":"index.md:f0a7f9d068cb7a14","source_path":"index.md","text_hash":"f0a7f9d068cb7a146d0bb89b3703688d690ed0b92734b78bcdb909aace617dbf","text":"WhatsApp group messages","translated":"WhatsApp 群组消息","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:04:56Z"} -{"cache_key":"3f27b3739c14942d3690e2325aa7381daf6a48d6732f3a116817bcbc3afe9a7e","segment_id":"index.md:36ddb4d3cfcb494f","source_path":"index.md","text_hash":"36ddb4d3cfcb494fb96463d42b35ba923731677cfc9e084af9f25e3f231187d5","text":"💬 ","translated":"💬 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:01:16Z"} -{"cache_key":"3f7a224f3597d7d8ffcb2fbad1804c6beb71966d8a12feeb601f0607121b1d58","segment_id":"environment.md:cda454f61dfcac70","source_path":"environment.md","text_hash":"cda454f61dfcac7007a9edc538f9f58cf38caa0652e253975979308162bccc53","text":"Gateway configuration","translated":"Gateway 配置","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:26:38Z"} -{"cache_key":"4009762bd6b11f2939c149dc1407c26b61e0ce9e64027f643467f2c9166ae069","segment_id":"index.md:ded906ea94d05152","source_path":"index.md","text_hash":"ded906ea94d0515249f0bcab1ba63835b5968c142e9c7ea0cb6925317444d98c","text":"Configuration examples","translated":"配置示例","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:32:01Z"} -{"cache_key":"4009d09aa8998e8257bb7d745d298f61d59560b852c09e3020f282f5f783755f","segment_id":"environment.md:4ac8551788fee477","source_path":"environment.md","text_hash":"4ac8551788fee477927fdee76e727261e4a655609502f2d6e0f2121b606ed978","text":"Env var substitution in config\n\nYou can reference env vars directly in config string values using ","translated":"配置中的环境变量替换\n\n你可以使用以下方式在配置字符串值中直接引用环境变量 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:16:58Z"} -{"cache_key":"4009d3c80d649dec28c2565eee518b693691a79c583c891076d67e01740b29f5","segment_id":"index.md:6fa3cbf451b2a1d5","source_path":"index.md","text_hash":"6fa3cbf451b2a1d54159d42c3ea5ab8725b0c8620d831f8c1602676b38ab00e6","text":"Sessions","translated":"会话","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:50:30Z"} -{"cache_key":"409cb7ef4fb0281a4a1a5cc53af8948139bd4735bddf8b050a93a62498745c6c","segment_id":"start/getting-started.md:0d3a30eb74e2166c","source_path":"start/getting-started.md","text_hash":"0d3a30eb74e2166c1fc51b99b180841f808f384be53fe1392cecb67fdc9363c4","text":" (default ","translated":" (默认 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:35:19Z"} -{"cache_key":"40e9b8fba7f586d8cd09924e5319af4d3402b2768f2965141866fc0113a5a85a","segment_id":"start/getting-started.md:a930fff865d3a7d8","source_path":"start/getting-started.md","text_hash":"a930fff865d3a7d8c09c82d884ce158733e3cf93f6d43d81c03785aeb15ff970","text":"7) Verify end-to-end","translated":"7)端到端验证","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:37:55Z"} -{"cache_key":"413b18be7bd7219a7ebe8bf16cc6b2a5151753b15628b92ef08461ee654bd44b","segment_id":"environment.md:6863067eb0a2c749","source_path":"environment.md","text_hash":"6863067eb0a2c7499425c6c189b2c88bac55ca754285a6ab1ef37b75b4cfad4d","text":"See ","translated":"参见 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:17:03Z"} -{"cache_key":"41768ab6d5c2eeb79122a8d917d38fdc9d8448e4ed992fcb2b7feaa905469dcf","segment_id":"index.md:add4778f9e60899d","source_path":"index.md","text_hash":"add4778f9e60899d7f44218483498c0baf7a0468154bc593a60747ee769c718c","text":"Android node","translated":"Android 节点","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:02:40Z"} -{"cache_key":"417f7a16c5b26835f5787b2bc94c938dba0e43717380c38007d621ec582c8c21","segment_id":"start/getting-started.md:fc0d3588a29e2b90","source_path":"start/getting-started.md","text_hash":"fc0d3588a29e2b90f3946e210636d98d8ad95cf9e9d615fd975193093d8a17df","text":" (with sane defaults) as quickly as possible.","translated":" (使用合理的默认配置)尽可能快地完成。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:34:32Z"} -{"cache_key":"418f62c195d775d18091feee2ea101a738425ab091aecd5487195cfedd3ede3f","segment_id":"start/getting-started.md:b8aa19c1dd24f84e","source_path":"start/getting-started.md","text_hash":"b8aa19c1dd24f84eb71288bebd10a4e6007ede5365d9572df511fe428dccb632","text":"macOS menu bar app + voice wake: ","translated":"macOS 菜单栏应用 + 语音唤醒: ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:38:21Z"} -{"cache_key":"41a9cde6dd29d317206f7b9ac006217c8ed29fdfa51d9cdc8b3aa4538ccd4415","segment_id":"start/wizard.md:a2198f472ce2fbee","source_path":"start/wizard.md","text_hash":"a2198f472ce2fbee82a5546090a5dd896b1da3bb678e8963d19eaa03e08ca092","text":"Onboarding","translated":"上手引导","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:49:05Z"} -{"cache_key":"420151a42113aa101a8d753080c801abe7deac20cde27edca350833dba6b9401","segment_id":"index.md:496bcd8a502babde","source_path":"index.md","text_hash":"496bcd8a502babde0470e7105dfed7ba95bbc3193b7c6ba196b3ed0997e84294","text":"Voice notes","translated":"语音消息","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:30:43Z"} -{"cache_key":"42363d142c5d5a1481ac3bb76aba070baba3e796a68efaac422608091bf9f72f","segment_id":"index.md:bbf8779fd9010043","source_path":"index.md","text_hash":"bbf8779fd9010043ac23a2f89ba34901f3a1f58296539c3177d51a9040ea209d","text":") — Blogwatcher skill","translated":")—— Blogwatcher 技能","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:54:47Z"} -{"cache_key":"42b861c7e5700483f345ddd4ae743bc47dbd0154e1a83326aae343859604bf6d","segment_id":"start/wizard.md:ecaaafe56fbfdf19","source_path":"start/wizard.md","text_hash":"ecaaafe56fbfdf19c4710b2509350b60bf5bce327e6e621952076da6372df33e","text":"Onboarding Wizard (CLI)","translated":"上手引导向导 (CLI)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:38:56Z"} -{"cache_key":"42ee1c8c7830b05cc1959ca709737c56bd2f92aff2ccf44de3dc51badc42622f","segment_id":"index.md:32ebb1abcc1c601c","source_path":"index.md","text_hash":"32ebb1abcc1c601ceb9c4e3c4faba0caa5b85bb98c4f1e6612c40faa528a91c9","text":" (","translated":" (","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:59:15Z"} -{"cache_key":"433cce86c05f0e0152efd90e723b067d686e917fb2054327fbf4ce5c40e30edb","segment_id":"start/wizard.md:13297db73d234731","source_path":"start/wizard.md","text_hash":"13297db73d234731958244575f85555e4aa3ff0aed3b07b5e9d4ea66cb462246","text":" (never ","translated":" (绝不使用 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:41:26Z"} -{"cache_key":"434e8917d28683bf448e8aabf49764186bb067e2efe6ff332497cb52f6016ddd","segment_id":"start/getting-started.md:7013af4c42fe4380","source_path":"start/getting-started.md","text_hash":"7013af4c42fe43802a9e8b0affc4f521fcd126160569969fb2ec09e1b7c422b1","text":"Setup","translated":"设置","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:35:10Z"} -{"cache_key":"436e752948ac7b7910f23cae9160a2e4040fb7df0e3c5bfb95398c280e3e3f41","segment_id":"index.md:1a36bded6916228a","source_path":"index.md","text_hash":"1a36bded6916228a5664c8b2bcdaa5661d342fe3e632aa41453f647a3daa3a61","text":" — Pairs as a node and exposes a Canvas surface","translated":" — 作为节点配对并提供 Canvas 界面","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:02:38Z"} -{"cache_key":"4375f1f048f79fc861b543d3d9c0eedc69b7a772a32d024e3c108d3a9657af00","segment_id":"start/wizard.md:95fcce5e5b146818","source_path":"start/wizard.md","text_hash":"95fcce5e5b146818ba279f6a1ec9b3333532b069ad6e3f709818fb9194198203","text":"Keep / Modify / Reset","translated":"保留 / 修改 / 重置","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:41:07Z"} -{"cache_key":"43cec56f6386efc26fbdda3820971f35c6a6df2c61a3125ef10c41b7a136e622","segment_id":"environment.md:frontmatter:read_when:1","source_path":"environment.md:frontmatter:read_when:1","text_hash":"a3a2d99a99de98220c8e0296d6f4e4b2a34024916bd2379d1b3b9179c8fae46f","text":"You are debugging missing API keys in the Gateway","translated":"你正在调试 Gateway 中缺失的 API 密钥","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:19:16Z"} -{"cache_key":"444c57554dc319fcc9cbfb96353a6f9c9184b4e1178353c5f92496ac99bf75b7","segment_id":"start/wizard.md:bdd5d35746968e3a","source_path":"start/wizard.md","text_hash":"bdd5d35746968e3ac912679a8a6dcd53117277e63feb28c474e582f2ada39027","text":") for scripts.","translated":")用于脚本。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:40:54Z"} -{"cache_key":"447176b9f6dcf6f83e411888711e6f57498e9034a0000a9e5e08b8600dfa6dd7","segment_id":"help/index.md:frontmatter:read_when:1","source_path":"help/index.md:frontmatter:read_when:1","text_hash":"857eafc389d179e83e21e46c10527fec40894fe064c63847ba06b946b7d5eb73","text":"Something broke and you want the fastest path to a fix","translated":"出了问题,你想找到最快的修复方法","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:24:30Z"} -{"cache_key":"45546bd54f25fcf7ebb35e346b789c2b8719664b05396941839eaa13d1181f5b","segment_id":"start/wizard.md:17c51cc78838cf2a","source_path":"start/wizard.md","text_hash":"17c51cc78838cf2af11aab8d6600db56cb50d4956069625db25bed4f15656a76","text":" (bun not recommended).","translated":" (不推荐 bun)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:45:52Z"} -{"cache_key":"455bca70dc67fd7daf15f2991ae1dde159fbb072397a5222ae919e12c89f6baf","segment_id":"help/index.md:frontmatter:read_when:0","source_path":"help/index.md:frontmatter:read_when:0","text_hash":"ee0615553374970664b58ebd8e5d0ebc9bc8a5f03387671afbfd0096b390aa9b","text":"You’re new and want the “what do I click/run” guide","translated":"你是新手,想要一份\"该点什么/该运行什么\"的操作指南","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:11:03Z"} -{"cache_key":"46356e773d9a1db34290e9332db52f9febdea4d6a86073399c8e5123a8c85d64","segment_id":"index.md:233cfad76c3aa9dd","source_path":"index.md","text_hash":"233cfad76c3aa9dd5cc0566746af197eac457a88c1e300ae788a8ada7f96b383","text":"From source (development):","translated":"从源码安装(开发):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:51:35Z"} -{"cache_key":"463a9a24eff5d3e3bd5240375798cafd0b6ddd708cb14e1037df77f591649f17","segment_id":"index.md:96be070791b7d545","source_path":"index.md","text_hash":"96be070791b7d545dc75084e59059d2170eed247350b351db5330fbd947e4be6","text":"👥 ","translated":"👥 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:02:04Z"} -{"cache_key":"467ace4c6c3c4e0032589ea19c3968e8869d0455ecde033420ea7e300959f288","segment_id":"help/index.md:2adc964c084749b1","source_path":"help/index.md","text_hash":"2adc964c084749b1f2d8aef24030988b667dbda2e38a6a1699556c93e07c1cea","text":"Start here","translated":"从这里开始","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:39:37Z"} -{"cache_key":"46bb3d229c815e2177cb4c1493857143b306b58d15abedb7abce66c9c5456f99","segment_id":"start/wizard.md:a9e83abe07e4c277","source_path":"start/wizard.md","text_hash":"a9e83abe07e4c2777f28ac3107308bd9178e7d0449fbf21f2098ebd37f17900e","text":" exposes every step (mode, workspace, gateway, channels, daemon, skills).","translated":" 展示每个步骤(模式、工作区、Gateway、渠道、守护进程、技能)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:40:09Z"} -{"cache_key":"47add7d7379785e56a63fec4ac7e41913e1c0bb8b25f6e8bb10ccbdae56f993d","segment_id":"start/wizard.md:254bb97b57f12e16","source_path":"start/wizard.md","text_hash":"254bb97b57f12e1608fefc4517de768427b2fd6d2cffbbfcbc09f3c818198d5f","text":"not","translated":"不会","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:40:39Z"} -{"cache_key":"47b5e5abc15bd0ac60233ab1d3fd967912accfa1934f36be76175302f173c24f","segment_id":"index.md:d53b75d922286041","source_path":"index.md","text_hash":"d53b75d9222860417f783b0829023b450905d982011d35f0e71de8eed93d90fc","text":"New install from zero:","translated":"从零开始全新安装:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:28:03Z"} -{"cache_key":"47e2cb719ca381b76b3e6b9692535fac0878a4d7dfface5796952396f9dbac0c","segment_id":"environment.md:45ca56d179d4788c","source_path":"environment.md","text_hash":"45ca56d179d4788c55ba9f7653b376d62e7faa738e92259e3d4f6f5c1b554f28","text":"Related","translated":"相关内容","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:19:49Z"} -{"cache_key":"47eeb5089f41d7da1014dffd88b45418309ad1273076ad53cad090d98d2cab0e","segment_id":"index.md:c7a5e268ddd8545e","source_path":"index.md","text_hash":"c7a5e268ddd8545e5a59a58ef1365189862f802cc7b61d4a3212c70565e2dff1","text":"WhatsApp Integration","translated":"WhatsApp 集成","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:00:50Z"} -{"cache_key":"48150c58a558cab37782c3ad92d481228e2baf17c9b76c6779b7c0ae19dbc3fa","segment_id":"index.md:e3572f8733529fd3","source_path":"index.md","text_hash":"e3572f8733529fd30a8604d41d624c15f4433df68f40bd092d1ee61f7d8d15e2","text":"Agent bridge","translated":"智能体 桥接","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:50:00Z"} -{"cache_key":"48483fc19877be4aa74fb6b2db7bb89e26c2c0e369d74946891db59c9fe7e7a6","segment_id":"help/index.md:6201111b83a0cb5b","source_path":"help/index.md","text_hash":"6201111b83a0cb5b0922cb37cc442b9a40e24e3b1ce100a4bb204f4c63fd2ac0","text":" and ","translated":" 和 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:56:37Z"} -{"cache_key":"4860a152d07b27cce8b414a96db2ae930f930f6c1c92cd682b9138e2a05cd6a5","segment_id":"index.md:a42f01be614f75f1","source_path":"index.md","text_hash":"a42f01be614f75f16278b390094dc43923f0b1b7d8e3209b3f43e356f42ed982","text":"), a single long-running process that owns channel connections and the WebSocket control plane.","translated":"),一个拥有 渠道 连接和 WebSocket 控制平面的单一长期运行进程。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:48:26Z"} -{"cache_key":"49557226fca3cfad19539dc5025d8556c1fbbc8f281440f95e52b12f86ea9c88","segment_id":"index.md:9bcda844990ec646","source_path":"index.md","text_hash":"9bcda844990ec646b3b6ee63cbdf10f70b0403727dea3b5ab601ca55e3949db9","text":" for node WebViews; see ","translated":" 用于节点 WebView;参见 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:00:29Z"} -{"cache_key":"495b27e9a0d8f141e73a810d800212340f9cbde9181f0f2d3fba03b48222976e","segment_id":"start/getting-started.md:b2727b53f573e590","source_path":"start/getting-started.md","text_hash":"b2727b53f573e590241952b2f1c4f4a0654a6c54c5407a1ac4a98c7360808b66","text":"3) Start the Gateway","translated":"3)启动 Gateway","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:36:47Z"} -{"cache_key":"496b2bcf0dbcf94d1b9bb3678101118c4ea49b1513f09378ba37b8c963882d15","segment_id":"index.md:41ed52921661c7f0","source_path":"index.md","text_hash":"41ed52921661c7f0d68d92511589cc9d7aaeab2b5db49fb27f0be336cbfdb7df","text":"Gateway","translated":"Gateway","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:59:47Z"} -{"cache_key":"4981d5c52ef98c93d181d87e1c2a1b8f6f862ebdb561c1be294d9137b2bb57b7","segment_id":"environment.md:b4736422e64c0a36","source_path":"environment.md","text_hash":"b4736422e64c0a369663d1b2d386f1b8f4b31b8936b588e4a54453c61a24e0fd","text":"Process environment","translated":"进程环境","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:11:59Z"} -{"cache_key":"49dba5d103054704ee4f938bc0a88e03d008f4944cb8cbae4a2885436d4740b2","segment_id":"start/wizard.md:c50ee45a8653de1c","source_path":"start/wizard.md","text_hash":"c50ee45a8653de1c4e2b19fb99d694cd339660b20d45c9ad30ee141b6606057e","text":"Gemini example:","translated":"Gemini 示例:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:47:20Z"} -{"cache_key":"4a98bbd6c4054f0b1a001141baff967905b1b14e1df3098677fc0e4d18ed325e","segment_id":"start/getting-started.md:2a6201c0c58ab546","source_path":"start/getting-started.md","text_hash":"2a6201c0c58ab546acacc4a77ca5dc80df9b0dd17abb7295095a6f17fe009dbe","text":" Brave Search API key for web search. Easiest path:","translated":" Brave Search API 密钥用于网络搜索。最简单的方式:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:35:31Z"} -{"cache_key":"4af220d89881b4bb625e5c2da4f6f1f1fcf9aa5e03c3d011a1719e95425b74ff","segment_id":"start/getting-started.md:3e86911991b89a88","source_path":"start/getting-started.md","text_hash":"3e86911991b89a88840294cff2374b6c01b6cf699d67a683d93176713ba4ca45","text":"Auth: where it lives (important)","translated":"认证:存储位置(重要)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:36:28Z"} -{"cache_key":"4bec757cc889c702c6234b140f6cfbd9f5667bc8d4c2ec078d752ca526c9799e","segment_id":"index.md:a42f01be614f75f1","source_path":"index.md","text_hash":"a42f01be614f75f16278b390094dc43923f0b1b7d8e3209b3f43e356f42ed982","text":"), a single long-running process that owns channel connections and the WebSocket control plane.","translated":"),一个拥有 渠道 连接和 WebSocket 控制平面的单一长期运行进程。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:59:49Z"} -{"cache_key":"4bf4e999ff90d118298d53d27c867bde5d3a64a39602eb35773dcd473ab68055","segment_id":"index.md:eec70d1d47ec5ac0","source_path":"index.md","text_hash":"eec70d1d47ec5ac00f04e59437e7d8b0988984c0cea3dddd81b1a2a10257960b","text":" — DMs + groups via grammY","translated":" — 通过 grammY 支持私信和群组","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:01:00Z"} -{"cache_key":"4c71126fdff0651fcaa439a12703ba33c43aa85a462fb7e2ad6acee8a8806c39","segment_id":"start/wizard.md:ddbd2d8bfe478133","source_path":"start/wizard.md","text_hash":"ddbd2d8bfe4781330c0adb796efa7fa7dcfb17d1fe9ed4307b023d66d8b8a35b","text":"OpenAI Code (Codex) subscription (OAuth)","translated":"OpenAI Code (Codex) 订阅 (OAuth)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:42:16Z"} -{"cache_key":"4c7a9d32a1bf7d55704300dc5899e3919b0f6bab738e60b30fc1fd85b58ede3a","segment_id":"start/getting-started.md:6d6dc68f9728c111","source_path":"start/getting-started.md","text_hash":"6d6dc68f9728c11122ce7459d5576d5302c97ec8e74870cb9c77db41f5c6ea0c","text":"Hetzner","translated":"Hetzner","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:38:41Z"} -{"cache_key":"4c851cab8cf02e3da31b63c083417b0bbd5a3d717405b1b27a0350415de4bd27","segment_id":"index.md:f0b349e90cb60b2f","source_path":"index.md","text_hash":"f0b349e90cb60b2f96222d0be1ff6532185f385f4909a19dd269ea3e9e77a04d","text":" (default); groups are isolated","translated":" (默认);群组是隔离的","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:02:01Z"} -{"cache_key":"4c93f44aa6a225228511440a8cca2a4ace485159eddb0d79727da03e98fefe9a","segment_id":"start/getting-started.md:a39d4188dfd32498","source_path":"start/getting-started.md","text_hash":"a39d4188dfd324984cf06e58ae8585aace52bc88d8a2a1f1e50b6fb1aca38f14","text":") asks the running gateway for a health snapshot.","translated":")向运行中的 Gateway 请求健康快照。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:38:13Z"} -{"cache_key":"4ccc6938b93e67b302401a1553a3b4331bae277f3b0cfbbd2221b77525949cd4","segment_id":"environment.md:f0442e6e05ccca16","source_path":"environment.md","text_hash":"f0442e6e05ccca160d17de0e7d509891b91b921366b2202b2b5c80435824e140","text":"Two equivalent ways to set inline env vars (both are non-overriding):","translated":"两种等效的内联环境变量设置方式(均为非覆盖模式):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:22:14Z"} -{"cache_key":"4cd593beb8d08acfb2a74edb256d16cd121e7ebd888e8d0569d819d6342f2d08","segment_id":"index.md:eef0107bb5a4e06b","source_path":"index.md","text_hash":"eef0107bb5a4e06b9de432b9e62bcf1e39ca5dfbbb9cb0cc1c803ca7671c06ab","text":"Gateway runbook","translated":"Gateway 运维手册","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:32:21Z"} -{"cache_key":"4d021d4ad8d2c4a6fa14ffae3a5273c55c88fa764c804d0ac7eb7877b3fd2e01","segment_id":"help/index.md:d3ef01b4a9c99103","source_path":"help/index.md","text_hash":"d3ef01b4a9c9910364c9b26b2499c8787a0461d2d24ab80376fff736a288b34c","text":"Logging","translated":"日志记录","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:44:50Z"} -{"cache_key":"4d31d3ef5331e6d1f9320391fae45b77e325f6e87ea39857dc34442559cccc01","segment_id":"index.md:0a4a282eda1af348","source_path":"index.md","text_hash":"0a4a282eda1af34874b588bce628b76331fbe907de07b57d39afdedccac2ba14","text":" http://127.0.0.1:18789/ (or http://localhost:18789/)","translated":" http://127.0.0.1:18789/(或 http://localhost:18789/)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:59:22Z"} -{"cache_key":"4d326b31ff8fc72fd166492afe47b0ae7beb5e3e5bf37baabdd302879d9c1d13","segment_id":"help/index.md:40281c54411735d1","source_path":"help/index.md","text_hash":"40281c54411735d1d2e4ffec7e0efc19ba0503751fa1d7358274b912604d1510","text":" broke”):","translated":" 问题\"):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:45:03Z"} -{"cache_key":"4daabe9de281d8ebea0b454d9bdc3fdb736a9eb954b51489ad1deb7ed2a4373c","segment_id":"index.md:ceee4f2088b9d5ba","source_path":"index.md","text_hash":"ceee4f2088b9d5ba7d417bac7395003acfbcef576fd4cc1dd3063972f038218a","text":"The name","translated":"名称","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:05:36Z"} -{"cache_key":"4db63f686185284d522be3518e148ebe548c53eb4626b6f1a02f052a09c301e9","segment_id":"index.md:11d28de5b79e3973","source_path":"index.md","text_hash":"11d28de5b79e3973f6a3e44d08725cdd5852e3e65e2ff188f6708ae9ce776afc","text":"Docs hubs (all pages linked)","translated":"文档中心(所有页面链接)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:52:15Z"} -{"cache_key":"4dd121c0efe15d606d29bd4960aa7aabaf0e999980bb6270b1088c55c742f415","segment_id":"help/index.md:6cb77499abdccd9a","source_path":"help/index.md","text_hash":"6cb77499abdccd9a2dbb7c93a4d31eed01613dda06302933057970df9ecdeb54","text":"Logs:","translated":"日志:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:15:26Z"} -{"cache_key":"4de22adc95e4976c28aa9a61533e5641e6fd5b9d045d3767ac21261a2335914c","segment_id":"index.md:9fc31bacba5cb332","source_path":"index.md","text_hash":"9fc31bacba5cb33207804b9e6a8775a3f9521c9a653133fd06e5d14206103e48","text":"Streaming + chunking","translated":"流式传输 + 分块","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:01:34Z"} -{"cache_key":"4e57e45dbeeb2f49d2f090659de7ad8342eedc7be647e92f8c4bc8947b7d85c6","segment_id":"start/getting-started.md:f07ac0638d44dcaa","source_path":"start/getting-started.md","text_hash":"f07ac0638d44dcaa5d24d65ea8205bd487968cdb28c4b8f55a9f35abf86e9b8e","text":"⚠️ ","translated":"⚠️ ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:36:59Z"} -{"cache_key":"4e7d8b9522fd7341a086f3de5bf4cd653a1d3446e763918236c087c43964aa4f","segment_id":"index.md:c491e0553683a70a","source_path":"index.md","text_hash":"c491e0553683a70a2fb52303f74675d2f7b725814ed70d5167473cb5fbe46450","text":"@steipete","translated":"@steipete","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:33:31Z"} -{"cache_key":"4e866938d12c4a92f34a54718f3e47f7cdd902f404e3fd33e5f5222e436f9b36","segment_id":"index.md:0d3a30eb74e2166c","source_path":"index.md","text_hash":"0d3a30eb74e2166c1fc51b99b180841f808f384be53fe1392cecb67fdc9363c4","text":" (default ","translated":" (默认 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:48:59Z"} -{"cache_key":"4ec0b2d188f9f730e3313959319d7b42ee967cdf4f93ad396f47590a16f996d2","segment_id":"start/wizard.md:71375dd64cd1fd1f","source_path":"start/wizard.md","text_hash":"71375dd64cd1fd1fe95d0263198b7d8e200c0705f4f183d7566aaf5e1f00bfc4","text":"Disable auth only if you fully trust every local process.","translated":"仅在您完全信任每个本地进程时才禁用认证。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:44:06Z"} -{"cache_key":"4f08a9653928ac3ab515e3d4b8ebe742f3dffa74cc24a72a01c96d8fe662140a","segment_id":"start/getting-started.md:e8f6d6288fe468ce","source_path":"start/getting-started.md","text_hash":"e8f6d6288fe468ce32979d08b723300ae13bfaaf0125ad98e9575f34d0135d5d","text":"Goal: go from ","translated":"目标:从 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:34:23Z"} -{"cache_key":"4f097f10df80b86a3a5591e19d0af1221fb2b938d0357f64ed981c1991f03936","segment_id":"start/wizard.md:f6b7825cb4029a0b","source_path":"start/wizard.md","text_hash":"f6b7825cb4029a0b60d38151752906e4dd2cee98bc62075b9b92745e71b0f3ec","text":" CLI path + DB access.","translated":" CLI 路径 + 数据库访问。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:44:59Z"} -{"cache_key":"4f238e68dde5c2b45bd0c18fb2483c798be3e0fa8d46bf9c1b7b14b5531d21a3","segment_id":"index.md:c4b2896a2081395e","source_path":"index.md","text_hash":"c4b2896a2081395e282313d6683f07c81e3339ef8b9d2b5a299ea5b626a0998f","text":").","translated":")。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:29:17Z"} -{"cache_key":"4ff9ae69dc48d779b5eab2d7fd9f54be2f2f31d6a7decf258f13342320f148b3","segment_id":"index.md:fb87b8dba88b3edc","source_path":"index.md","text_hash":"fb87b8dba88b3edced028edfe2efa5f884ab2639c1b26efa290ccd0469454d25","text":"Slash commands","translated":"斜杠命令","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:32:03Z"} -{"cache_key":"503ffef24b84433cfe5c8d4d7593a419616f5f14136cdb38d5cf1cd06b4a9a0e","segment_id":"index.md:0d517afa83f91ec3","source_path":"index.md","text_hash":"0d517afa83f91ec33ee74f756c400a43b11ad2824719e518f8ca791659679ef4","text":"Web surfaces (Control UI)","translated":"Web 界面(控制界面)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:52:59Z"} -{"cache_key":"5061a49721ce6c55da1a2514b0fd7683f5ce7d1d74f157660f8bd0bfdfe0bf6e","segment_id":"environment.md:1ec31258a6b45ea9","source_path":"environment.md","text_hash":"1ec31258a6b45ea903cd76f5b0190a99ab56afff6241a04f0681eb12b7a02484","text":"Env var equivalents:","translated":"等效的环境变量:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:16:55Z"} -{"cache_key":"5069b751d3f01eba9ff4cce578a200affed4e5065ca542d65061b2e2a93b8852","segment_id":"help/index.md:frontmatter:read_when:0","source_path":"help/index.md:frontmatter:read_when:0","text_hash":"ee0615553374970664b58ebd8e5d0ebc9bc8a5f03387671afbfd0096b390aa9b","text":"You’re new and want the “what do I click/run” guide","translated":"你是新手,想要一份\"我该点击/运行什么\"的指南","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:15:08Z"} -{"cache_key":"509d70fe9cd2a784c14c42ef3d0ca4a5767c2fa6959deb7ed3671b4ec368dfe8","segment_id":"help/index.md:frontmatter:read_when:1","source_path":"help/index.md:frontmatter:read_when:1","text_hash":"857eafc389d179e83e21e46c10527fec40894fe064c63847ba06b946b7d5eb73","text":"Something broke and you want the fastest path to a fix","translated":"出了问题,你想要最快的修复方法","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:18:59Z"} -{"cache_key":"50a84870e2aca4cf2eb629a6742293526dcc33fa7910e6ac417b9a604f50960b","segment_id":"start/wizard.md:822369845cd7506f","source_path":"start/wizard.md","text_hash":"822369845cd7506fbdc11a1e2e1410b3c4d56d1b38ce7e0e3ac68132daa3bc41","text":" (mode, bind, auth, tailscale)","translated":" (模式、绑定、认证、Tailscale)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:48:39Z"} -{"cache_key":"50b11cd9357a73a81c5f192b9ffe99a8d3c273a366ff83647c224fb21678c414","segment_id":"index.md:ec05222b3777fd7f","source_path":"index.md","text_hash":"ec05222b3777fd7f91a2964132f05e3cfc75777eaeec6f06a9a5c9c34a8fc3e9","text":"Nix mode","translated":"Nix 模式","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:52:36Z"} -{"cache_key":"50f4e14d41f688eeb460afe9ad362eb37e693b4ed995b45ee5d9bfaf1fab401b","segment_id":"start/getting-started.md:aa9e63906bb59344","source_path":"start/getting-started.md","text_hash":"aa9e63906bb5934462d7a9f29afd4a9562d5366c583706512cb48dce19c847df","text":"Web tools","translated":"网络工具","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:35:36Z"} -{"cache_key":"50f8833ee612dd2b521fe352a3b7fdf5c623c2ebbf2a839889a5ce67c5c0e461","segment_id":"help/index.md:5c94724fa7810fa9","source_path":"help/index.md","text_hash":"5c94724fa7810fa9902e565cf66c5f5a973074f2961fcd3a40bad4ee4aeca5e0","text":"If you want a quick “get unstuck” flow, start here:","translated":"如果你想要一个快速的\"解决卡点\"流程,从这里开始:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:19:03Z"} -{"cache_key":"50fd13ae6258975366903d5a2acb0d0b04ce90ba43f94a4d6c7beaca595f7ad2","segment_id":"help/index.md:bfc5930cc2660330","source_path":"help/index.md","text_hash":"bfc5930cc2660330260afd407e98d86adaec0af48dd72b88dc33ef8e9066e2c9","text":"Install sanity (Node/npm/PATH):","translated":"安装健全性检查(Node/npm/PATH):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:24:39Z"} -{"cache_key":"5101decf1454ab8482f88283cdff28292f81cb8c5b67c60c5540d20e58d322c3","segment_id":"start/wizard.md:32e1de6dc8abca82","source_path":"start/wizard.md","text_hash":"32e1de6dc8abca82d76e0f29f7946d2ee7a92d4966b491162f39ccb8a4dd545b","text":": optional QR login.","translated":":可选二维码登录。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:44:21Z"} -{"cache_key":"513ac8bfe713d5e2eb46a1c890b9a73bb5fd37a64fbf5ddd1761b54104f0ce75","segment_id":"index.md:25d853ca04397b6a","source_path":"index.md","text_hash":"25d853ca04397b6ae248036d4d029d19d94a4981290387e5c29ef61b0eca9021","text":"Media: audio","translated":"媒体:音频","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:05:01Z"} -{"cache_key":"5151695b8579a569668c665a07f985e54e47ae59b74de6c35d4eeb91d791b91c","segment_id":"environment.md:87e89abb4c1c551f","source_path":"environment.md","text_hash":"87e89abb4c1c551fe08d355d097f18b8de78edca5f556997085681662fce8eed","text":"Config ","translated":"配置 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:40:48Z"} -{"cache_key":"5164ecd8153172c108648dc8feef9d627a89b9324d78c60e951e3f1c3af844f2","segment_id":"index.md:bbf8779fd9010043","source_path":"index.md","text_hash":"bbf8779fd9010043ac23a2f89ba34901f3a1f58296539c3177d51a9040ea209d","text":") — Blogwatcher skill","translated":")— Blogwatcher 技能","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:06:21Z"} -{"cache_key":"5190486a1cf97164d85f07222ca6e274b6674eeda169c17a0eccc3c7be43b044","segment_id":"start/wizard.md:c8e1d64e1512e6b8","source_path":"start/wizard.md","text_hash":"c8e1d64e1512e6b81ad317afe04f71cc8ea0fe457ff607c007e34800a6e8e103","text":" keeps the defaults:","translated":" 保留默认设置:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:39:43Z"} -{"cache_key":"51c65ff267a63fd40e68e63331750ce27c6e882f309e162d6972e537cabf4072","segment_id":"environment.md:453c14128fbfb5f6","source_path":"environment.md","text_hash":"453c14128fbfb5f6757511557132a1dbb3bcbf243267630bfec49db8518c7780","text":"Env var substitution in config","translated":"配置中的 环境变量 替换","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:41:23Z"} -{"cache_key":"51e4a79d50e7b6faf4b5fbce36a1c2dc9d941659190740833308d280cb27a5bb","segment_id":"help/index.md:569ca49f4aaf7846","source_path":"help/index.md","text_hash":"569ca49f4aaf7846e952c1d4aeca72febd0b79fa1c4f9db08fd3127551218572","text":"Install","translated":"安装","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:15:19Z"} -{"cache_key":"5256dac86be4cff1bbfdd8b43cd74fa2633b518c084db7f691706eedee0e1d77","segment_id":"index.md:6b3f22c979b9e6f8","source_path":"index.md","text_hash":"6b3f22c979b9e6f8622031a6b638ec5f730c32de646d013e616078e03f5a6149","text":"iOS node","translated":"iOS 节点","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:02:35Z"} -{"cache_key":"525b28d219cb06fdcc377d15a5e9c3f60ba1e1169087c1b9ab43b1efe3a8349d","segment_id":"start/wizard.md:d92f3712b6e72ea2","source_path":"start/wizard.md","text_hash":"d92f3712b6e72ea2bac4e633c85d861d9f300aa323fd76c8781a8f56d8a4c009","text":"(configurable).","translated":"(可配置)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:43:43Z"} -{"cache_key":"5268570b8d2770d6d5f735117d493a2ea8bd09de727afcf860253c47a704ded3","segment_id":"start/getting-started.md:c2ab5611178d6d90","source_path":"start/getting-started.md","text_hash":"c2ab5611178d6d908636cc22a3aed2cb295c4108fc42f754094d3e67505358a6","text":"Recommended:","translated":"推荐:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:35:28Z"} -{"cache_key":"526b2832980f55051a3a71d889b1300bbb233cd79e79b885d4e785d9114dba2b","segment_id":"environment.md:d08a8493f686363a","source_path":"environment.md","text_hash":"d08a8493f686363a78b913d45ebfbd87a3768d1c77b70f23b1fdade3c066e481","text":"Shell env import","translated":"Shell 环境导入","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:41:12Z"} -{"cache_key":"5275f3af15c7dfcb4b24b60d848e0c9ed11c9e2abf25bc62a2b99a7cab4c7542","segment_id":"index.md:2adc964c084749b1","source_path":"index.md","text_hash":"2adc964c084749b1f2d8aef24030988b667dbda2e38a6a1699556c93e07c1cea","text":"Start here","translated":"从这里开始","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:59:03Z"} -{"cache_key":"52aeed244ab712fffb8102660391a13fc4a8f9a479605dd7bcbfa4b355c58834","segment_id":"start/wizard.md:8b1d44c58a75ff49","source_path":"start/wizard.md","text_hash":"8b1d44c58a75ff49adca5363a3cbd3e61bfee0645eddb1496b8a6750129b7bc8","text":"Skills: ","translated":"技能: ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:49:15Z"} -{"cache_key":"52ef5bab0c525d962f3d30c6cc5f251916fbadf5e697dc633e2ace0ecbbb42c2","segment_id":"start/wizard.md:d80ef914e27a7691","source_path":"start/wizard.md","text_hash":"d80ef914e27a7691f1ed9989a37a43dfd34cfef90ee4459a627bf718954df4a3","text":": config is auto-written.","translated":":配置会自动写入。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:42:57Z"} -{"cache_key":"531330c3712e720f0c442c71f862e191045a1b8230f18bd10f68e2319bf22155","segment_id":"index.md:468886872909c70d","source_path":"index.md","text_hash":"468886872909c70d3bfb4836ec60a6485f4cbbd0f8a0acedbacb9b477f01a251","text":"Workspace templates","translated":"工作区模板","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:04:23Z"} -{"cache_key":"534402481da76bfd10c88a10c6a4910b6e634bdddd9e419a006b283058cff637","segment_id":"environment.md:f0442e6e05ccca16","source_path":"environment.md","text_hash":"f0442e6e05ccca160d17de0e7d509891b91b921366b2202b2b5c80435824e140","text":"Two equivalent ways to set inline env vars (both are non-overriding):","translated":"两种等效的内联环境变量设置方式(均为非覆盖式):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:19:33Z"} -{"cache_key":"5348f8f12aaa8a00c0f5a88d0cad414c4fe54eaba081f2173b39cbf188125da3","segment_id":"index.md:4eb58187170dc141","source_path":"index.md","text_hash":"4eb58187170dc14198eacb534c8577bef076349c26f2479e1f6a2e31df8eb948","text":" — An AI, probably high on tokens","translated":" — 大概是一个嗑多了 token 的 AI 说的","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:34:09Z"} -{"cache_key":"53513c3f1d1bda6a78f2c08ae26548a59521cc465217abb5716c816e50b3f663","segment_id":"help/index.md:729bc562eec2658b","source_path":"help/index.md","text_hash":"729bc562eec2658bd11ffdd522fe5277177dc73e86eaca7baac0b472a4d8f8b2","text":"If you’re looking for conceptual questions (not “something broke”):","translated":"如果你在寻找概念性问题的答案(而不是\"出了什么问题\"):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:21:39Z"} -{"cache_key":"5360de70878292f462b4ab63b6b2169dbb4bfcdbb0826892292183e82654a749","segment_id":"help/index.md:569ca49f4aaf7846","source_path":"help/index.md","text_hash":"569ca49f4aaf7846e952c1d4aeca72febd0b79fa1c4f9db08fd3127551218572","text":"Install","translated":"安装","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:56:25Z"} -{"cache_key":"5474cc8e7d4c6f83b601be6d40ebc9282578264e3f75ebf4da637cac7907ed88","segment_id":"index.md:7d8b3819c6a9fb72","source_path":"index.md","text_hash":"7d8b3819c6a9fb726f40c191f606079b473f6f72d4080c13bf3b99063a736187","text":"Ops and safety:","translated":"运维和安全:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:53:45Z"} -{"cache_key":"549c689e9c5183f7b035a845a929a3bf6f9db58a4887d12d0cb2455f24ac5335","segment_id":"index.md:898e28d91a14b400","source_path":"index.md","text_hash":"898e28d91a14b400e7dc11f9dc861afe9143c18bf9424b1d1b274841615f38b1","text":"If you want to lock it down, start with ","translated":"如果你想进行锁定配置,请从以下内容开始 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:03:35Z"} -{"cache_key":"54d155bfaa944dfce20caeefa7452e7022732c19d46e7b3c31c6656fdb93f33d","segment_id":"environment.md:32ebb1abcc1c601c","source_path":"environment.md","text_hash":"32ebb1abcc1c601ceb9c4e3c4faba0caa5b85bb98c4f1e6612c40faa528a91c9","text":" (","translated":" (","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:25:58Z"} -{"cache_key":"550ba8127479edc00f347fd187d45bbfe02ae216e0dbe41b3d6a40db52c003e0","segment_id":"help/index.md:71095a6d42f5d9c2","source_path":"help/index.md","text_hash":"71095a6d42f5d9c2464a8e3f231fc53636d4ce0f9356b645d245874162ec07e2","text":"Gateway troubleshooting","translated":"Gateway 故障排除","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:56:29Z"} -{"cache_key":"557e4752d46bf19af760606c924d355964729b977705da1c324ed0d5488df7c5","segment_id":"environment.md:1ec31258a6b45ea9","source_path":"environment.md","text_hash":"1ec31258a6b45ea903cd76f5b0190a99ab56afff6241a04f0681eb12b7a02484","text":"Env var equivalents:","translated":"等效的环境变量:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:11:52Z"} -{"cache_key":"5588623b87444ab2f10920e15cae26afa748d33fcaa532d91d6190d720f1c44b","segment_id":"help/index.md:6cb77499abdccd9a","source_path":"help/index.md","text_hash":"6cb77499abdccd9a2dbb7c93a4d31eed01613dda06302933057970df9ecdeb54","text":"Logs:","translated":"日志:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:44:48Z"} -{"cache_key":"5598b6f2bb72cf8a1238b70f389ac26d5c39189a4b9ad8b76af4cd49eb33a713","segment_id":"start/wizard.md:4410e6ca609a533f","source_path":"start/wizard.md","text_hash":"4410e6ca609a533faee63dec02ec71a5c50e5b97062f0d93369139e0fe1b0d82","text":": optional ","translated":":可选 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:44:48Z"} -{"cache_key":"55a8e387b1f1138051af732f3f201a7f97a6e0a670aff0a74c5c5b4a509f6434","segment_id":"start/wizard.md:d2089be672953d11","source_path":"start/wizard.md","text_hash":"d2089be672953d1136faa84079af1b6f3967fed8932dabffba3032d30e3c0618","text":"Token","translated":"令牌","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:39:56Z"} -{"cache_key":"55bccdb299ca0c443fbee7ae8c28a88be0a40e6973b497cd17f15dd048a91558","segment_id":"start/wizard.md:dbd212a8183236f0","source_path":"start/wizard.md","text_hash":"dbd212a8183236f07f7a17afce31b2d18665e319b32dae90af1d04765fe2625d","text":"Config reference: ","translated":"配置参考: ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:49:08Z"} -{"cache_key":"56135692918078250894cd24cb873bc5e59b3913ca7c8f70192db42b5c1552a3","segment_id":"index.md:4d4d75c23a2982e1","source_path":"index.md","text_hash":"4d4d75c23a2982e184011f79e62190533f93cdad41ba760046419678fa68d430","text":"Runtime requirement: ","translated":"运行时要求: ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:31:08Z"} -{"cache_key":"562db402fedea0d89bb4b457c0bf0a29473db6c37066eb838cf16cee6491bcb0","segment_id":"index.md:81023dcc765309dd","source_path":"index.md","text_hash":"81023dcc765309dd05af7638f927fd7faa070c58abe7cad33c378aa02db9baa2","text":" (token is required for non-loopback binds).","translated":" (非回环绑定需要令牌)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:48:47Z"} -{"cache_key":"569404a9b6463e543124c499f821d6ac2f2c24b6ad6a2821e13fdf758bc5ae6e","segment_id":"environment.md:f0442e6e05ccca16","source_path":"environment.md","text_hash":"f0442e6e05ccca160d17de0e7d509891b91b921366b2202b2b5c80435824e140","text":"Two equivalent ways to set inline env vars (both are non-overriding):","translated":"两种等效的方式来设置内联环境变量(两者都是非覆盖的):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:11:47Z"} -{"cache_key":"56a23cfaebcc81253e55771aec19e4fd69bdd738fccc96d76b27a9229c483aa0","segment_id":"start/wizard.md:7c19f1358e5a91a8","source_path":"start/wizard.md","text_hash":"7c19f1358e5a91a8bf5165c597be85be56510330c5e754af349899104e6dca05","text":": if ","translated":":如果 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:42:11Z"} -{"cache_key":"56c452948915062308b68b03169a7e032ae1404911a12ac62b0234c408ec18a5","segment_id":"environment.md:61115f6649792387","source_path":"environment.md","text_hash":"61115f664979238731a390e84433a818965b7eaf1d38fa5b4b1507c33ef28c91","text":"Precedence (highest → lowest)","translated":"优先级(从高到低)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:25:24Z"} -{"cache_key":"56f0053c5e04eb858cff8d23ac7c15f18df295163eca33fcf5481592fe4c9e7e","segment_id":"index.md:11450a0f023dc48c","source_path":"index.md","text_hash":"11450a0f023dc48cc9cef026357e2b4569a2b756290191c45a9eb0120a919cb7","text":" and (for groups) mention rules.","translated":" 以及(针对群组的)提及规则。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:31:41Z"} -{"cache_key":"570aa7e523634c91d2e5091a861a2656c0af61316e18d9d53d28ff9b1dd32ee3","segment_id":"index.md:bf0e823c81b87c5d","source_path":"index.md","text_hash":"bf0e823c81b87c5de79676155debf20a29b52d6d7eb7e77deda73a56d0afbaaa","text":"🧠 ","translated":"🧠 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:50:15Z"} -{"cache_key":"576285ac236140f5584a71845cd3ab297a14b96100ac5c8efa39439ea81af132","segment_id":"start/getting-started.md:0b5979b793d7bafc","source_path":"start/getting-started.md","text_hash":"0b5979b793d7bafcae2346d1323747631b04df91cbbdbf878cb9b419233af218","text":"optional background service","translated":"可选的后台服务","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:35:02Z"} -{"cache_key":"5797b6a21c5b4993623f1b645eada0826ba49d498f93da9e4535b2bc66ecebe3","segment_id":"index.md:2f1626425f985d9a","source_path":"index.md","text_hash":"2f1626425f985d9ad8c124ea8ccb606e404ae5f43c58bd16b6c109d6d2694083","text":"Most operations flow through the ","translated":"大多数操作通过 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:28:33Z"} -{"cache_key":"57faa67fdf48914b9f543f1f16050dbd5161c7af426c58b0391d519d6506deca","segment_id":"help/index.md:569ca49f4aaf7846","source_path":"help/index.md","text_hash":"569ca49f4aaf7846e952c1d4aeca72febd0b79fa1c4f9db08fd3127551218572","text":"Install","translated":"安装","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:39:40Z"} -{"cache_key":"580953a19863c3d4f19ed1bfc789c4681bf548a213b44d05890c81e3c183bddc","segment_id":"index.md:e3572f8733529fd3","source_path":"index.md","text_hash":"e3572f8733529fd30a8604d41d624c15f4433df68f40bd092d1ee61f7d8d15e2","text":"Agent bridge","translated":"智能体 桥接","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:01:26Z"} -{"cache_key":"580e74e39c7246c3298ca172ea6f14364440b964fe9bc6a8d341194037dea02e","segment_id":"start/wizard.md:37e38f71b148eca2","source_path":"start/wizard.md","text_hash":"37e38f71b148eca2086a3c2186d62507e4f8cbb09a54edcb316d651bb1f29557","text":": local ","translated":":本地 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:44:56Z"} -{"cache_key":"581b9d2ca381a3956cb2c34ee44736e34f2fc8c9c12beacf66c3940034bf38b3","segment_id":"index.md:a97c0f391117ef55","source_path":"index.md","text_hash":"a97c0f391117ef554586ed43255ab3ff0e15adcfc1829c62b6d359672c0bec93","text":" — Mention-based by default; owner can toggle ","translated":" — 默认基于提及触发;所有者可切换 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:30:33Z"} -{"cache_key":"58518d711b63edb4aa641534a198510edcada769a3213a0a69a4f1c7a11cdc5f","segment_id":"start/wizard.md:7ddb0704314b289e","source_path":"start/wizard.md","text_hash":"7ddb0704314b289e7df028a91980144a09de964e2155c0b1d2b5263996c9bb7a","text":"Vercel AI Gateway","translated":"Vercel AI Gateway","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:42:53Z"} -{"cache_key":"5854b7c02dc640d3a8eefbdbcfe6257017838bee852fb6459e657c3d25dba670","segment_id":"environment.md:46ab081177a452aa","source_path":"environment.md","text_hash":"46ab081177a452aa62354b581730f4675cb03e58cde8282071da30cabe18fb2e","text":"Optional login-shell import","translated":"可选的登录 shell 导入","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:16:34Z"} -{"cache_key":"58685380a9fc556f1364bbc64e7d1471c341cc4b6e23c0f7792b7f1f83b90c0b","segment_id":"start/wizard.md:d3c2c33c63d513d7","source_path":"start/wizard.md","text_hash":"d3c2c33c63d513d77ca245c9b66527155c15adcf3b687fa72b4da67f80ed27b9","text":" exists, choose ","translated":" 存在,请选择 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:41:03Z"} -{"cache_key":"587a20717b7a463343b0975b76259c80107d7b353e2274384589d2b39f9426e1","segment_id":"start/wizard.md:320754cd5c316bdf","source_path":"start/wizard.md","text_hash":"320754cd5c316bdfec2957a249e26bef7cc1bcd3d7a6668b9378a14704714b40","text":"Wizard attempts to enable lingering via ","translated":"向导会尝试通过 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:45:19Z"} -{"cache_key":"58b9a1752caf49f80928c56d27ed1a2bc036495ad60d1dae62913fb293b46a55","segment_id":"start/wizard.md:0cfdc51cb2368973","source_path":"start/wizard.md","text_hash":"0cfdc51cb236897362d81cf81a533f21184ce1f5e83afe14713a943593ac3a0f","text":": stores the key for you.","translated":":为您存储密钥。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:42:46Z"} -{"cache_key":"58ff8f5714b801a09dbe57ef9e43f987926fb18ddfbf06dd710873990251b4c2","segment_id":"index.md:723fad6d27da9393","source_path":"index.md","text_hash":"723fad6d27da939353c65417bbaf646b65903b316eb4456297ff4a1c20811e8d","text":": HTTP file server on ","translated":":HTTP 文件服务器位于 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:48:57Z"} -{"cache_key":"5962bcb8baac7a5a279ff1ee2c82efe20db4c0c28e1f6244a25aa3de214a48e6","segment_id":"start/getting-started.md:frontmatter:summary","source_path":"start/getting-started.md:frontmatter:summary","text_hash":"f6955d3daff59d2b0a5cdb5731848998bfb3b6b1fa133c8587b5da1137b49dd1","text":"Beginner guide: from zero to first message (wizard, auth, channels, pairing)","translated":"新手指南:从零开始到发送第一条消息(向导、认证、渠道、配对)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:34:15Z"} -{"cache_key":"5999b798f983aca853bfa20dee21a17340561fab7d1e736475141ee6a6c6c9ef","segment_id":"help/index.md:bfc5930cc2660330","source_path":"help/index.md","text_hash":"bfc5930cc2660330260afd407e98d86adaec0af48dd72b88dc33ef8e9066e2c9","text":"Install sanity (Node/npm/PATH):","translated":"安装完整性检查(Node/npm/PATH):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:44:40Z"} -{"cache_key":"59c1f2c382072b7399fb13a23472af019ef5e481c697051eeacd4250a633a44a","segment_id":"index.md:76d6f9c532961885","source_path":"index.md","text_hash":"76d6f9c5329618856f133dc695e78f085545ae05fae74228fb1135cba7009fca","text":") — Pi creator, security pen-tester","translated":")— Pi 创作者,安全渗透测试员","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:33:40Z"} -{"cache_key":"59cd3f5bae0fc6f3c5c68e641d8023b8abc6683a5b46e97eeddd4252f7bd9cf3","segment_id":"index.md:a97c0f391117ef55","source_path":"index.md","text_hash":"a97c0f391117ef554586ed43255ab3ff0e15adcfc1829c62b6d359672c0bec93","text":" — Mention-based by default; owner can toggle ","translated":" —— 默认基于提及触发;所有者可以切换 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:50:43Z"} -{"cache_key":"5a54b33c72ae9262caf7fb631174cd7ba166e35d90a0fedada04f1341d480d2b","segment_id":"environment.md:907940a35852447a","source_path":"environment.md","text_hash":"907940a35852447aad5f21c5a180d993ff31cfd5807b1352ed0c24eabe183465","text":"never override existing values","translated":"永远不覆盖已有的值","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:11:53Z"} -{"cache_key":"5a584dcf9fe91e72165672d18b1e4edf88478a25cd6219842c6b26e791f4649f","segment_id":"index.md:9f4d843a5d04e23b","source_path":"index.md","text_hash":"9f4d843a5d04e23b22eb79b3bfa0fbad70ede435ddb5d047e7d77e830efa6019","text":" — Bot token + WebSocket events","translated":" — Bot 令牌 + WebSocket 事件","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:29:47Z"} -{"cache_key":"5ac64275154111e455f8ad15880f47a2dfd9dc428ac06b485dff574b25771a69","segment_id":"index.md:9dea37e7f1ff0e24","source_path":"index.md","text_hash":"9dea37e7f1ff0e24f7daecf6ea9cc38a58194f11fbeab1d3cfaa3a5645099ef4","text":"Updating / rollback","translated":"更新 / 回滚","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:04:05Z"} -{"cache_key":"5acb6159e9978fd41ab798d8bdb9d754f75409541ef52fc4f584c82e1d2111b0","segment_id":"help/index.md:5c94724fa7810fa9","source_path":"help/index.md","text_hash":"5c94724fa7810fa9902e565cf66c5f5a973074f2961fcd3a40bad4ee4aeca5e0","text":"If you want a quick “get unstuck” flow, start here:","translated":"如果你想快速\"解决卡住的问题\",从这里开始:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:24:34Z"} -{"cache_key":"5ad3f65c184c4a08ef211b77a6927634d4ffbb4237d717ea6df811df08f138ec","segment_id":"start/wizard.md:c2912d74db583b26","source_path":"start/wizard.md","text_hash":"c2912d74db583b2672bc6ee18cac65b4f95a547cf5535cf457fd7534981644b1","text":": prompts for ","translated":":提示输入 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:42:35Z"} -{"cache_key":"5af6e45efa085bf05463db83e35c7d337a67597574fe09331f7220925949bba6","segment_id":"help/index.md:frontmatter:read_when:1","source_path":"help/index.md:frontmatter:read_when:1","text_hash":"857eafc389d179e83e21e46c10527fec40894fe064c63847ba06b946b7d5eb73","text":"Something broke and you want the fastest path to a fix","translated":"出了问题,你想找到最快的修复方法","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:21:34Z"} -{"cache_key":"5af8a2aa97675994afa5c5fe3b78418fb9836171d4db6c883e7d26189c199a7e","segment_id":"index.md:4d87941d681ca4e8","source_path":"index.md","text_hash":"4d87941d681ca4e89ca303d033b7d383d3acfbb6d9d9616bd88d7c19cf92c3dd","text":"Pi","translated":"Pi","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:58:58Z"} -{"cache_key":"5b3300af7348ec2a1e6a63faed5d2d36b21f592dda0270c5431d43adae59b1d1","segment_id":"index.md:6201111b83a0cb5b","source_path":"index.md","text_hash":"6201111b83a0cb5b0922cb37cc442b9a40e24e3b1ce100a4bb204f4c63fd2ac0","text":" and ","translated":" 和 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:48:13Z"} -{"cache_key":"5b6535c3e3743405e859a5b26d0d96cc35789c5a3c642c76ea81b79386635441","segment_id":"start/wizard.md:b482e45229e19f5f","source_path":"start/wizard.md","text_hash":"b482e45229e19f5f7ba590b5ac81bdb25d5d24116ed961bfa0eb1a23c20a204c","text":" (or ","translated":" (或 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:42:37Z"} -{"cache_key":"5c1d43ddd497832b75c32dfbaaa644936f42480df0b9630a0974cf6e2f523656","segment_id":"start/wizard.md:8a5edab282632443","source_path":"start/wizard.md","text_hash":"8a5edab282632443219e051e4ade2d1d5bbc671c781051bf1437897cbdfea0f1","text":" / ","translated":" / ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:48:32Z"} -{"cache_key":"5c3c725a47f409c7342bc422f59aeb14d9de2c891fc092f6eda3299a14e1def4","segment_id":"environment.md:ffa63583dfa6706b","source_path":"environment.md","text_hash":"ffa63583dfa6706b87d284b86b0d693a161e4840aad2c5cf6b5d27c3b9621f7d","text":"missing","translated":"缺失的","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:12:49Z"} -{"cache_key":"5c5f7754116c27bc91d76e82699d3d46ae75acf3ebcebfd217d6a8c4667f47be","segment_id":"start/getting-started.md:c16fb1db14572857","source_path":"start/getting-started.md","text_hash":"c16fb1db145728574044899ab5577f464ecd30cd4b297b45b4385ce39dcbab70","text":"If you installed the service during onboarding, the Gateway should already be running:","translated":"如果您在上手引导过程中安装了服务,Gateway 应该已经在运行:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:36:49Z"} -{"cache_key":"5c775f8101cacd367ff5c7722b3b2c3a5dce43493d19e42aa30282c74ee24a7b","segment_id":"index.md:2b402c90e9b15d9c","source_path":"index.md","text_hash":"2b402c90e9b15d9c3ef65c432c4111108f54ee544cda5424db46f6ac974928e4","text":"🔐 ","translated":"🔐 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:50:23Z"} -{"cache_key":"5c780db9d3f1b4fef594dae3510c0bf35d432d48acf5889e9d4f4f6e844d46a5","segment_id":"start/getting-started.md:8816c52bc5877a2b","source_path":"start/getting-started.md","text_hash":"8816c52bc5877a2b24e3a2f4ae7313d29cf4eba0ca568a36f2d00616cfe721d0","text":"Wizard","translated":"向导","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:35:06Z"} -{"cache_key":"5c90f7114b3baa2b4a5d8c980df5c3be37909dda0be1f3318d91f8810181cd50","segment_id":"index.md:37ed7c96b16160d4","source_path":"index.md","text_hash":"37ed7c96b16160d491e44676aa09fe625301de9c018ad086e263f59398b8be8a","text":"🎤 ","translated":"🎤 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:02:19Z"} -{"cache_key":"5cfd51b6ed282f1ab9449da2a23c095cd4c28c738d56b5e6525e372d0f602907","segment_id":"index.md:cec2be6f871d276b","source_path":"index.md","text_hash":"cec2be6f871d276b45d13e3010c788f01b03ae2f1caca3264bbf759afacace46","text":"Telegram Bot","translated":"Telegram 机器人","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:29:35Z"} -{"cache_key":"5d5941af110e6856ee1abe848dc7404970d7e151226fe533a22e9a7f936292ea","segment_id":"index.md:88d90e2eef3374ce","source_path":"index.md","text_hash":"88d90e2eef3374ce1a7b5e7fbd3b1159364b26a8ceb2493d6e546d4444b03cda","text":"Tailscale","translated":"Tailscale","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:59:40Z"} -{"cache_key":"5d83de16c61f5425e1149b23d1001f7ef7ed0028c6f453ce8f74df31fd2a2262","segment_id":"environment.md:f0442e6e05ccca16","source_path":"environment.md","text_hash":"f0442e6e05ccca160d17de0e7d509891b91b921366b2202b2b5c80435824e140","text":"Two equivalent ways to set inline env vars (both are non-overriding):","translated":"两种等效的内联 环境变量 设置方式(均不会覆盖):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:41:10Z"} -{"cache_key":"5daf1c9089fed361745301c1f4a5ef332e660187481b4a0ee10f384c16d08098","segment_id":"index.md:ab201ddd7ab330d0","source_path":"index.md","text_hash":"ab201ddd7ab330d04be364c0ac14ce68c52073a0ee8d164a98c3034e91ce1848","text":" from the repo.","translated":" 从仓库中执行。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:51:40Z"} -{"cache_key":"5dbafe2a97e2fd217ed8ae3c96a68526e3aeb0924d460025aaed526821245bc7","segment_id":"index.md:297d5c673f5439aa","source_path":"index.md","text_hash":"297d5c673f5439aa31dca3bbc965cb657a89a643803997257defb3baef870f89","text":"Open the dashboard (local Gateway):","translated":"打开仪表板(本地 Gateway):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:47:55Z"} -{"cache_key":"5e1da320f32d4ee8aff0094e46c98e584ddfc953913e27eeb950f1a73cdbda40","segment_id":"environment.md:ffa63583dfa6706b","source_path":"environment.md","text_hash":"ffa63583dfa6706b87d284b86b0d693a161e4840aad2c5cf6b5d27c3b9621f7d","text":"missing","translated":"缺失的","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:46:42Z"} -{"cache_key":"5e2c30218fd9868878dbe2c938ebef80fe51f6b4f9e17f12144d1a8349afb7b3","segment_id":"start/wizard.md:2d8879a4fb313aa0","source_path":"start/wizard.md","text_hash":"2d8879a4fb313aa0515b0a575b00f60de6a2369e30129bc31c20ae0c25e538bd","text":" and chat in the browser. Docs: ","translated":" 然后在浏览器中对话。文档: ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:39:09Z"} -{"cache_key":"5e43eebc024e0b75c56a6288f219508bbe17b68fe85f1eca16ec03c1481bc99b","segment_id":"help/index.md:frontmatter:read_when:1","source_path":"help/index.md:frontmatter:read_when:1","text_hash":"857eafc389d179e83e21e46c10527fec40894fe064c63847ba06b946b7d5eb73","text":"Something broke and you want the fastest path to a fix","translated":"遇到故障了,你想找到最快的修复方法","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:56:14Z"} -{"cache_key":"5e4d7f1b9311d07ef5ce4f39f1e47a953b0916b1a5ca7df4b395179b564796fc","segment_id":"start/getting-started.md:4c3d9aa7ad8a4496","source_path":"start/getting-started.md","text_hash":"4c3d9aa7ad8a449660623429f93ee51afcf8e2d77d7ca16229a19d52262ecab6","text":"Next steps (optional, but great)","translated":"后续步骤(可选,但强烈推荐)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:38:16Z"} -{"cache_key":"5e58639dd12e22ac1e6e022b66e5c0edfaf2e17279bd951b86047e5ed952b65d","segment_id":"index.md:f1e3b32c8eb0df8e","source_path":"index.md","text_hash":"f1e3b32c8eb0df8ea105f043edf614005742c15581e2cebc5a9c3bafb0b90303","text":"Multi-agent routing","translated":"多 智能体 路由","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:50:18Z"} -{"cache_key":"5e6f4e17b35988acf25c0cf67edb5bb0267aa378b3a700d43efdf21503475c13","segment_id":"environment.md:f15f5f9f4ef4d668","source_path":"environment.md","text_hash":"f15f5f9f4ef4d6688876c894f8eba251ed1db6eaf2209084028d43c9e76a8ba1","text":" (aka ","translated":" (即 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:57:34Z"} -{"cache_key":"5e819d45951c185c25d0c543873e913e628e57d81aa0a617e31158890a669e15","segment_id":"help/index.md:5c94724fa7810fa9","source_path":"help/index.md","text_hash":"5c94724fa7810fa9902e565cf66c5f5a973074f2961fcd3a40bad4ee4aeca5e0","text":"If you want a quick “get unstuck” flow, start here:","translated":"如果你想要一个快速的\"快速排障\"流程,请从这里开始:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:56:18Z"} -{"cache_key":"5e941e344b30824bf659b63b0325cbbc0fe0a2b4a687eff1e79174aa1133b8e8","segment_id":"help/index.md:24669ff48290c187","source_path":"help/index.md","text_hash":"24669ff48290c1875d8067bbd241e8a55444839747bffb8ab99f3a34ef248436","text":"Doctor","translated":"诊断","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:56:44Z"} -{"cache_key":"5ee69009fd3da1ee495a4d68f7cce4f38b9e39bf9d9e6fca4bd1ddc066a70819","segment_id":"index.md:2566561f81db7a7c","source_path":"index.md","text_hash":"2566561f81db7a7c4adb6cee3e93139155a6b01d52ff0d3d5c11648f46bc79bb","text":"📱 ","translated":"📱 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:49:24Z"} -{"cache_key":"5eebf79b3ae425984310ea74305f2dfe6ce49f0942b2144044781ecb00b105c9","segment_id":"start/getting-started.md:41884234ba7e0041","source_path":"start/getting-started.md","text_hash":"41884234ba7e0041d39bd06003bd12c5b7811a92b95bb7dbba71bd33b2a1a896","text":"If a token is configured, paste it into the Control UI settings (stored as ","translated":"如果配置了令牌,请将其粘贴到控制界面设置中(存储为 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:36:55Z"} -{"cache_key":"5eecedb868edab3c718ba04090d4964a74779499d46101c0661259e7f90e4f65","segment_id":"environment.md:frontmatter:summary","source_path":"environment.md:frontmatter:summary","text_hash":"78351223e7068721146d2de022fdf440c2866b2ee02fbbb50bf64369b999820b","text":"Where OpenClaw loads environment variables and the precedence order","translated":"OpenClaw 加载环境变量的位置及优先级顺序","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:21:46Z"} -{"cache_key":"5f10dfed67a40e4e3332100f92b41fe1b3d47e0e6e6bf4bb6385fa478524c38b","segment_id":"index.md:898e28d91a14b400","source_path":"index.md","text_hash":"898e28d91a14b400e7dc11f9dc861afe9143c18bf9424b1d1b274841615f38b1","text":"If you want to lock it down, start with ","translated":"如果你想锁定访问权限,请从以下内容开始 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:31:39Z"} -{"cache_key":"5f5af7c41d6bd3bdcf3992da945b51ccd3a2d373b7acdfc6afb17d462e38e72b","segment_id":"start/wizard.md:c084c70e0e8978a4","source_path":"start/wizard.md","text_hash":"c084c70e0e8978a4add1624dfb4f3f6ddb9b8d09530122749fe443d68bae6ce0","text":"OpenCode Zen example:","translated":"OpenCode Zen 示例:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:47:34Z"} -{"cache_key":"5f87ef9c51ba75be8e473df32b40b1fd4a7c3000ad679f5551be4605640d997e","segment_id":"help/index.md:3c33340bd23b8db8","source_path":"help/index.md","text_hash":"3c33340bd23b8db89f18fe7d05a954738c0dd5ba9623cf6bdb7bb5d1a3729cfc","text":"FAQ (concepts)","translated":"常见问题(概念)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:45:05Z"} -{"cache_key":"5f9b30a629fbd7152505c6cd2a19f6200a808e96198dcce0f0a87efaa862a6ca","segment_id":"start/getting-started.md:6a40edf1fc87a29f","source_path":"start/getting-started.md","text_hash":"6a40edf1fc87a29f243a7eefdbed57d19bfe16ab2e039d7ae1a44c097297e2f3","text":"WhatsApp","translated":"WhatsApp","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:37:14Z"} -{"cache_key":"600dc3f3ca0e2b1ab03d4449dfd67b69ddc839f9bd15a60ba9e382f875570e53","segment_id":"start/wizard.md:cda454f61dfcac70","source_path":"start/wizard.md","text_hash":"cda454f61dfcac7007a9edc538f9f58cf38caa0652e253975979308162bccc53","text":"Gateway configuration","translated":"Gateway 配置","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:49:10Z"} -{"cache_key":"604e3fe87674a94bdb57317af8bf1685c73dd5bad1c72ed1570c2f0989c34fb0","segment_id":"index.md:b214cd10585678ca","source_path":"index.md","text_hash":"b214cd10585678ca1250ce1ae1a50ad4001de4577a10e36be396a3409314e442","text":"@badlogicc","translated":"@badlogicc","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:06:02Z"} -{"cache_key":"605a5c23c689ae3b3531bdda797af578f1b09bf2801b93d2a9956d79651c1d79","segment_id":"environment.md:3bfb78f689d2a990","source_path":"environment.md","text_hash":"3bfb78f689d2a9908d74fb3694eb6284201f276d61c8c83e50b9f258b83ff807","text":"), applied only for missing expected","translated":"),仅在缺少预期","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:46:19Z"} -{"cache_key":"606008c50996fdd54d71d6dd64dacb4235aea44dd1e4f38bef2dbd70e9bc71b0","segment_id":"start/wizard.md:72e16ab00d3e1b7f","source_path":"start/wizard.md","text_hash":"72e16ab00d3e1b7fe8d1c9127fc3f475192ad16f8c1a7f40e71a18b5541d7315","text":"); it tries without sudo first.","translated":");它会先尝试不使用 sudo。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:45:27Z"} -{"cache_key":"609cc86f40f9b958987a73b8bf63da515fc2edd851f410e949b9c29c097e3a77","segment_id":"start/wizard.md:aeb8df5ac5b2a23f","source_path":"start/wizard.md","text_hash":"aeb8df5ac5b2a23f4491dec84235080e499723987ce22d246e3a40face0afa55","text":"Vercel AI Gateway (multi-model proxy)","translated":"Vercel AI Gateway(多模型代理)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:42:48Z"} -{"cache_key":"609f00fe9446aa4a32a1eb8cdb850f7655716d5b18db42ac65dd51c107637f56","segment_id":"index.md:eec70d1d47ec5ac0","source_path":"index.md","text_hash":"eec70d1d47ec5ac00f04e59437e7d8b0988984c0cea3dddd81b1a2a10257960b","text":" — DMs + groups via grammY","translated":" — 通过 grammY 支持私聊和群组","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:29:37Z"} -{"cache_key":"60b4aabf7487a78610b106bb8bdece56d18d22bc8bc54fa17ebbaffd4d111e1b","segment_id":"start/wizard.md:28513cbd3be49624","source_path":"start/wizard.md","text_hash":"28513cbd3be496244d0e2e1f54d3bc382d466ca58f6b127dd6b5213e36c298b5","text":"If the config is invalid or contains legacy keys, the wizard stops and asks\n you to run ","translated":"如果配置无效或包含遗留键,向导会停止并要求您运行 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:41:19Z"} -{"cache_key":"60bfa0a92eff33a33728f88b482cb2663dc1e19e6a3dc3023021e231ee0a89db","segment_id":"index.md:3c8aa7ad1cfe03c1","source_path":"index.md","text_hash":"3c8aa7ad1cfe03c1cb68d48f0c155903ca49f14c9b5626059d279bffc98a8f4e","text":": connect to the Gateway WebSocket (LAN/tailnet/SSH as needed); legacy TCP bridge is deprecated/removed.","translated":":连接到 Gateway WebSocket(根据需要使用 LAN/tailnet/SSH);旧版 TCP 桥接已弃用/移除。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:00:16Z"} -{"cache_key":"60c75897f641a043a33c9a88656df4a3dc1cce376a938c3b6df6b981339df50c","segment_id":"start/getting-started.md:5f0802429b8d0ea9","source_path":"start/getting-started.md","text_hash":"5f0802429b8d0ea99aec0b3456fac2d5721bbddd7ca4edeb47bb71a2a6619e63","text":"Discord: ","translated":"Discord: ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:37:24Z"} -{"cache_key":"60cd1a8fee21c221c625fe6961c620592e9f99a88910d9f557d86f92e17d793c","segment_id":"start/wizard.md:1d6bc09c9a9a3dad","source_path":"start/wizard.md","text_hash":"1d6bc09c9a9a3dad8fcbe9ed89a206b2dba3d8cf16046315aee976577d534cae","text":"Downloads the appropriate release asset.","translated":"下载相应的发布资源。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:47:59Z"} -{"cache_key":"60f998f050fe63afd0938f40b2f1cf78a16d5dd9fa6abc631aa8e217ce1e7cc5","segment_id":"index.md:053bc65874ad6098","source_path":"index.md","text_hash":"053bc65874ad6098e58c41c57b378a2f36b0220e5e0b46722245e6c2f796818c","text":"Discord","translated":"Discord","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:53:15Z"} -{"cache_key":"61277a40a0e409e2f324452a28cc35c44e1ac080b4400e7bdaa3c161ce51d545","segment_id":"start/wizard.md:3fcf806de5c2ace5","source_path":"start/wizard.md","text_hash":"3fcf806de5c2ace5327f65078cfb2139aaa8dd33ffdc3b04e9fef6f11778423c","text":"MiniMax M2.1","translated":"MiniMax M2.1","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:42:55Z"} -{"cache_key":"613744b9849b1cacbbcdcebd3fcb2637696f177d0364b9e32042a74bf2c1b350","segment_id":"index.md:80fc402133201fbe","source_path":"index.md","text_hash":"80fc402133201fbe0e4e9962a9570e741856aa8b0c033f1a20a9bcb06c68e809","text":"Discovery","translated":"发现","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:49:20Z"} -{"cache_key":"613d01b2aa6e9a9127f428233d5f88e84e2c86b5079776f57becfe4143f86992","segment_id":"start/wizard.md:3ccbb3a92014470f","source_path":"start/wizard.md","text_hash":"3ccbb3a92014470f73c71c81684da45b1e07ee3a49cca372ec678ce89229ea58","text":"Vercel AI Gateway example:","translated":"Vercel AI Gateway 示例:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:47:26Z"} -{"cache_key":"614a1ff5ae5f98f2f46f1ee6bbb53ace3482d9d15a8842906f26dcbad10c4d71","segment_id":"index.md:084514e91f37c3ce","source_path":"index.md","text_hash":"084514e91f37c3ce85360e26c70b77fdc95f0d3551ce309db96fbcf956a53b01","text":"Dashboard (browser Control UI)","translated":"仪表板(浏览器控制界面)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:59:30Z"} -{"cache_key":"619f1d68210cd4f4763a855771ec7e821343568b465996ee365552e40ecaadc4","segment_id":"index.md:da22b9d6584e1d8a","source_path":"index.md","text_hash":"da22b9d6584e1d8aa709165be214e0f9bdf2be428816e9ce1c4506bf86218cb4","text":"Core Contributors","translated":"核心贡献者","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:33:47Z"} -{"cache_key":"61f5af5889e4b2f0a5990d65fdd5b3fdc94d5fd178ef4d80c9cb134a37745cd5","segment_id":"index.md:4818a3f84331b702","source_path":"index.md","text_hash":"4818a3f84331b702815c94b4402067e09e9e2d27ebc1a79258df8315f2c8600b","text":"📎 ","translated":"📎 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:02:13Z"} -{"cache_key":"6261f859049427393c85f0f32d3db92e9fd57735f4855522a37fb535f791a35a","segment_id":"environment.md:frontmatter:read_when:0","source_path":"environment.md:frontmatter:read_when:0","text_hash":"90fc0487bff88009979cff1061c1a882df8c3b1baa9c43538331d9d5dab15479","text":"You need to know which env vars are loaded, and in what order","translated":"你需要了解哪些环境变量会被加载,以及它们的加载顺序","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:11:44Z"} -{"cache_key":"62631b9bcaa4b5c5f2603b93e0f658180f8b3f6c897506e90a26feab650f09b8","segment_id":"index.md:3f8466cd9cb153d0","source_path":"index.md","text_hash":"3f8466cd9cb153d0c78a88f6a209e2206992db28c6dab45424132dc187974e2b","text":"Note: legacy Claude/Codex/Gemini/Opencode paths have been removed; Pi is the only coding-agent path.","translated":"注意:旧版 Claude/Codex/Gemini/Opencode 路径已被移除;Pi 是唯一的编程 智能体 路径。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:02:46Z"} -{"cache_key":"627572a4323c2872b6db51c5d819b4213c571df88da85e170f85d77f96eb55d8","segment_id":"index.md:81023dcc765309dd","source_path":"index.md","text_hash":"81023dcc765309dd05af7638f927fd7faa070c58abe7cad33c378aa02db9baa2","text":" (token is required for non-loopback binds).","translated":" (非回环绑定需要令牌)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:00:10Z"} -{"cache_key":"62bd68ff9cbf96b3905a12162b9474b1284e8e16101d63a711144fd5a7c311cc","segment_id":"help/index.md:8cd501e1124c3047","source_path":"help/index.md","text_hash":"8cd501e1124c30473473c06e536a2d145e2a14a6d7dc1b99028ce818e14442e2","text":"Repairs:","translated":"修复:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:56:42Z"} -{"cache_key":"62eab355a91a29c11800cfb1fa5d04f8a626123e8f9e9b12bb46a42fee12b00d","segment_id":"environment.md:f7e239a42b7cd986","source_path":"environment.md","text_hash":"f7e239a42b7cd986a1558fed234e975ed2e96e9d37cf0a93f381778c461c89dd","text":"OpenClaw pulls environment variables from multiple sources. The rule is ","translated":"OpenClaw 从多个来源获取环境变量。规则是 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:11:51Z"} -{"cache_key":"633b27d62b9c0884576b289c4417f9e9caf3a669d9743346c3713e6df7135d9d","segment_id":"start/getting-started.md:d6053f5f95b19aef","source_path":"start/getting-started.md","text_hash":"d6053f5f95b19aef2ba01e965f8caaf95fd2746c1965b907a7f8c0083680351d","text":"Wizard doc: ","translated":"向导文档: ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:36:26Z"} -{"cache_key":"63707c20cc5a0d1176ffd1db451cc4c84b2168e4ec534e052a7a0906e97abeb7","segment_id":"environment.md:ffa63583dfa6706b","source_path":"environment.md","text_hash":"ffa63583dfa6706b87d284b86b0d693a161e4840aad2c5cf6b5d27c3b9621f7d","text":"missing","translated":"缺失的","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:16:51Z"} -{"cache_key":"63a4d4ad29115c6bce10a5f64dad28c623d7d4c7b9e7c78d6281496a1e2f3d34","segment_id":"environment.md:28b1103adde15a9d","source_path":"environment.md","text_hash":"28b1103adde15a9ddd8fc71f0c57dc155395ade46a0564865ccb5135b01c99b7","text":"OpenClaw pulls environment variables from multiple sources. The rule is **never override existing values**.","translated":"OpenClaw 从多个来源拉取环境变量。规则是**永远不覆盖已有的值**。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:11:39Z"} -{"cache_key":"63aa81c04f67fe845a1309e5882381f68dcf88a5cbba1ebe971adb247324ff2d","segment_id":"help/index.md:cad44fbae951d379","source_path":"help/index.md","text_hash":"cad44fbae951d3791565b0cee788c01c3bd10e0176167acb691b8dba0f7895f8","text":"Gateway logging","translated":"Gateway 日志记录","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:39:52Z"} -{"cache_key":"63d76859e8f7dbf46bffcdaf3a95db6646334012c91a4d543fdf67f5e2c95e1a","segment_id":"index.md:4d4d75c23a2982e1","source_path":"index.md","text_hash":"4d4d75c23a2982e184011f79e62190533f93cdad41ba760046419678fa68d430","text":"Runtime requirement: ","translated":"运行时要求: ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:02:53Z"} -{"cache_key":"63da7e7d14afa27ec40108c6b4b12db69d8f34c281095027a0939c0191ac77d6","segment_id":"start/wizard.md:c127ea338fd00fac","source_path":"start/wizard.md","text_hash":"c127ea338fd00fac2629a67910d8cbeade17990294fede336b54298e9b13a40c","text":"Telegram + WhatsApp DMs default to ","translated":"Telegram + WhatsApp 私信默认为 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:40:04Z"} -{"cache_key":"63edf6f6952fda49b0c75bad9c622396999c6b488d33644d8098f6346a1b2a17","segment_id":"start/getting-started.md:d7b4edd9ca795c46","source_path":"start/getting-started.md","text_hash":"d7b4edd9ca795c469606230849212eb080f0591477cff35400f276649d3910a9","text":" shows “no auth configured”, go back to the wizard and set OAuth/key auth — the agent won’t be able to respond without it.","translated":" 显示\"未配置认证\",请返回向导设置 OAuth/密钥认证——智能体在没有认证的情况下将无法响应。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:38:04Z"} -{"cache_key":"64412b08e5674ea41e50286a0e96cecf117837ff2738c6ff030b0949cfb72990","segment_id":"index.md:0a4a282eda1af348","source_path":"index.md","text_hash":"0a4a282eda1af34874b588bce628b76331fbe907de07b57d39afdedccac2ba14","text":" http://127.0.0.1:18789/ (or http://localhost:18789/)","translated":" http://127.0.0.1:18789/(或 http://localhost:18789/)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:47:58Z"} -{"cache_key":"644dafc0cc5f8edf1a480b2645ce841416abfc28022f45d45aed1ace6e2f8e0a","segment_id":"start/getting-started.md:eea56a0072aa60af","source_path":"start/getting-started.md","text_hash":"eea56a0072aa60afb5d46c629647ded6ff689e0f44e5725c90788fd103a509fa","text":" is the best pasteable, read-only debug report.\nHealth probes: ","translated":" 是最佳的可粘贴只读调试报告。\n健康探针: ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:38:09Z"} -{"cache_key":"649d0c8a8010fa282e9856eafb02cc5527e7227c27b3c2dfe4eaa9713feb6a05","segment_id":"environment.md:0f18d564547eb32a","source_path":"environment.md","text_hash":"0f18d564547eb32aed995d190644ce9605af6b501b582d871359ebcd4fa51f66","text":" for full","translated":" 了解完整","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:47:09Z"} -{"cache_key":"64cf4d8cfd9d6b5409d1cff5433fe8065140174f76482589a8fb0e49fbdf3fee","segment_id":"start/wizard.md:f3e485ab2f76c031","source_path":"start/wizard.md","text_hash":"f3e485ab2f76c031c52bd164935ed8cac883a7aadf24bdf4fd09e484603968c0","text":"Anthropic OAuth (Claude Code CLI)","translated":"Anthropic OAuth (Claude Code CLI)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:41:55Z"} -{"cache_key":"64d70118440cf15f1338e65f651e5974d1a46e5c7a82edadd6ec50d965818d6d","segment_id":"start/wizard.md:f9225188070a558a","source_path":"start/wizard.md","text_hash":"f9225188070a558a048f29723fbee7dedb56bdc8f3e8caf0517b063bcc309c16","text":" walks you through:","translated":" 引导您完成:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:40:16Z"} -{"cache_key":"65168a9f26b2e2f8956203b7db6e482a46a9e40ffda04423b3ad3da71e0e1f5e","segment_id":"index.md:f12242785ecda793","source_path":"index.md","text_hash":"f12242785ecda7935ded50cd48418357d32d3bac290f7a199bc9f0c7fbd13123","text":") — Location parsing (Telegram + WhatsApp)","translated":")— 位置解析(Telegram + WhatsApp)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:34:02Z"} -{"cache_key":"65234bc3c31a162f6a66e59fb06d36ff735b520d4b8aad7c6f24230f5d0ec345","segment_id":"index.md:6638cf2301d3109d","source_path":"index.md","text_hash":"6638cf2301d3109da66a44ee3506fbd35b29773fa4ca33ff35eb838c21609e19","text":"Features (high level)","translated":"功能(概述)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:49:22Z"} -{"cache_key":"65a3efc02834181b87543fe4787306b30aed7810e28d100617fe8ba5465b15dc","segment_id":"help/index.md:frontmatter:summary","source_path":"help/index.md:frontmatter:summary","text_hash":"aece82a2d540ab1a9a21c7b038127cae6e9db2149491564bb1856b6f8999f205","text":"Help hub: common fixes, install sanity, and where to look when something breaks","translated":"帮助中心:常见修复方法、安装完整性检查,以及出现问题时的排查方向","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:11:00Z"} -{"cache_key":"65ab4527cf40950eae093485da13531121309a7404aabda07b243ec350c17d62","segment_id":"index.md:7e2735e5df8f4e9f","source_path":"index.md","text_hash":"7e2735e5df8f4e9f006d10e079fe8045612aa662b02a9d1948081d1173798dec","text":"MIT — Free as a lobster in the ocean 🦞","translated":"MIT — 像海洋中的龙虾一样自由 🦞","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:06:51Z"} -{"cache_key":"65d8ee3af16e324a3478a3d1f4e54621fb52f93e2508b55e9e9e991562425a84","segment_id":"index.md:37ed7c96b16160d4","source_path":"index.md","text_hash":"37ed7c96b16160d491e44676aa09fe625301de9c018ad086e263f59398b8be8a","text":"🎤 ","translated":"🎤 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:30:41Z"} -{"cache_key":"65e123a5806897466856108c21b4b513c5975eae6575984c019adafbb796a36d","segment_id":"start/wizard.md:b1ff7bd17092d95e","source_path":"start/wizard.md","text_hash":"b1ff7bd17092d95ea7811719ce3df6d79b0c3a576695636fc411f2d95dc908b2","text":"Mattermost","translated":"Mattermost","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:44:40Z"} -{"cache_key":"65e76ddd96b75b84f98d4165a471f8fcf85255313b695d5718400e8d5c87aada","segment_id":"index.md:d372b90f0ccffad0","source_path":"index.md","text_hash":"d372b90f0ccffad0ae6e3df3c3aaeccd7a17eb59b4bc492a5469dc05ac3629ec","text":", OpenClaw uses the bundled Pi binary in RPC mode with per-sender sessions.","translated":",OpenClaw 将使用内置的 Pi 二进制文件以 RPC 模式运行,并采用按发送者区分的会话。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:31:37Z"} -{"cache_key":"661b1fde84d92d603e939aea97a699f0c3d13d8ef1cf06240cdcf4086ea99e9a","segment_id":"start/getting-started.md:d087dd8116e1ea75","source_path":"start/getting-started.md","text_hash":"d087dd8116e1ea751e94c787e0c856f9fb51528528551b60ef7c610f12439120","text":"Telegram: ","translated":"Telegram: ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:37:21Z"} -{"cache_key":"6689e918af1f418c89cbfd79e956dccccd85d3f2c0d7d08ca42674bb5fb46837","segment_id":"index.md:b79cac926e0b2e34","source_path":"index.md","text_hash":"b79cac926e0b2e347e72cc91d5174037c9e17ae7733fd7bdb570f71b10cd7bfc","text":"Help","translated":"帮助","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:03:50Z"} -{"cache_key":"669bd1fca3c6f7a5810302de54fcba2375123e94dfe30b61bbbb0df0b365b558","segment_id":"start/wizard.md:dcae3eda386cc9bb","source_path":"start/wizard.md","text_hash":"dcae3eda386cc9bbc068aaf01dc3a2543abb6d0504e176138ad4fbc4087767b5","text":" if present or prompts for a key, then saves it for daemon use.","translated":" (如果存在)或提示输入密钥,然后保存供守护进程使用。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:41:52Z"} -{"cache_key":"66c7246d1b6097539541673d8d9c8153a54d4fdb537ad31be453e514fb3754b9","segment_id":"index.md:f0d82ba647b4a33d","source_path":"index.md","text_hash":"f0d82ba647b4a33da3008927253f9bed21e380f54eab0608b1136de4cbff1286","text":"OpenClaw bridges WhatsApp (via WhatsApp Web / Baileys), Telegram (Bot API / grammY), Discord (Bot API / channels.discord.js), and iMessage (imsg CLI) to coding agents like ","translated":"OpenClaw 将 WhatsApp(通过 WhatsApp Web / Baileys)、Telegram(Bot API / grammY)、Discord(Bot API / channels.discord.js)和 iMessage(imsg CLI)桥接至编程智能体,例如 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:27:55Z"} -{"cache_key":"66cb3d6e2c27c1ee009c0531751f45f9927e2b3d09a4702546a67a9275ee5c49","segment_id":"environment.md:a806a90c34d867e4","source_path":"environment.md","text_hash":"a806a90c34d867e4445dda95ff64422e0b9a527d8fdd03490f255cddbeb84fdb","text":"Env var","translated":"环境变量","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:46:48Z"} -{"cache_key":"66d9604676a679978159598a722db6d8c4c96e082051763e3b1d5f47576894e2","segment_id":"environment.md:ab5aec4424cf678d","source_path":"environment.md","text_hash":"ab5aec4424cf678dcfb1ad3d2c2929c1e0b2b1ff61b82b961ada48ad033367b4","text":" (dotenv default; does not override).","translated":" (dotenv 默认行为;不覆盖)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:57:27Z"} -{"cache_key":"66e16e494883bd6fd041d66e577243e277a362ce785db530887996c9c14b93a9","segment_id":"start/wizard.md:frontmatter:read_when:1","source_path":"start/wizard.md:frontmatter:read_when:1","text_hash":"9bd20424220aa1c64181f1dce46bd8fe5d63d8cd8544f5a1cbaddb1030ad108b","text":"Setting up a new machine","translated":"设置新机器","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:38:55Z"} -{"cache_key":"66f6fd3c85fe2be6d76b21dae8ef66bb76ee3ebd3d92291e61abee595aa0e39d","segment_id":"index.md:66354a1d3225edbf","source_path":"index.md","text_hash":"66354a1d3225edbf01146504d06aaea1242dcf50424054c3001fc6fa2ddece0f","text":"Remote access","translated":"远程访问","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:49:18Z"} -{"cache_key":"672567848acf9fed5207cc54f76e9d0bffe7cbbd85569cbe74fc4a01e703b1ff","segment_id":"index.md:ab201ddd7ab330d0","source_path":"index.md","text_hash":"ab201ddd7ab330d04be364c0ac14ce68c52073a0ee8d164a98c3034e91ce1848","text":" from the repo.","translated":" 从仓库中执行。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:03:12Z"} -{"cache_key":"67355c084d419c2ec0eddd93cd1bf67b68218e2c141a65ebcdd96022e1a446df","segment_id":"environment.md:0ec3a996c8167512","source_path":"environment.md","text_hash":"0ec3a996c81675128a64349203e6af81e6d257ceb3124b120e0b894b26024680","text":" (dotenv default; does not","translated":" (dotenv 默认;不会","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:45:43Z"} -{"cache_key":"673925efd6296fa4423b6a1607689bebb6f61f0f1273c1ebc52daa1b44eb43b5","segment_id":"index.md:8816c52bc5877a2b","source_path":"index.md","text_hash":"8816c52bc5877a2b24e3a2f4ae7313d29cf4eba0ca568a36f2d00616cfe721d0","text":"Wizard","translated":"向导","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:28:08Z"} -{"cache_key":"675e8725187ebc4c5ee0560ddc38667c783d61c37a69680ea73fb1ca832690d1","segment_id":"index.md:65fd6e65268ff905","source_path":"index.md","text_hash":"65fd6e65268ff9057a49d832cccfcd5a376e46a908a2129be5b43f945fa8d8ca","text":": Gateway WS defaults to ","translated":":Gateway WS 默认监听 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:28:50Z"} -{"cache_key":"67688daae57335b03c42a3b327f5dc0a54f849238f4d03b3b6e113d76e3c21b0","segment_id":"index.md:11d28de5b79e3973","source_path":"index.md","text_hash":"11d28de5b79e3973f6a3e44d08725cdd5852e3e65e2ff188f6708ae9ce776afc","text":"Docs hubs (all pages linked)","translated":"文档中心(所有页面链接)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:03:47Z"} -{"cache_key":"678ce4ebc70142dc95d2df24b30a8f49ed54750c7cbdee7128254851be4d33f6","segment_id":"start/wizard.md:e9643f092e1f762c","source_path":"start/wizard.md","text_hash":"e9643f092e1f762c9a7e15bf5429a6c0081c210e464e56a3a35830834a9d4d59","text":"To add more isolated agents (separate workspace + sessions + auth), use:","translated":"要添加更多隔离的智能体(独立的工作区 + 会话 + 认证),请使用:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:40:43Z"} -{"cache_key":"67bfbb214f6fd84279273d721c587d520c889b48409a1123879c6d79522f7558","segment_id":"start/wizard.md:47eea376ece81e4b","source_path":"start/wizard.md","text_hash":"47eea376ece81e4bb17a281eabb2ddc5aa0458acd4c91a43f576f337ef5ee175","text":" wipe anything unless you explicitly choose ","translated":" 不会删除任何内容,除非您明确选择 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:41:11Z"} -{"cache_key":"67db94076306469669c78e1176f5af9cd7a5e6ba4528ce053e203e96bd12fdc7","segment_id":"help/index.md:cad44fbae951d379","source_path":"help/index.md","text_hash":"cad44fbae951d3791565b0cee788c01c3bd10e0176167acb691b8dba0f7895f8","text":"Gateway logging","translated":"Gateway 日志记录","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:24:51Z"} -{"cache_key":"681929db09dc269d58c525c09744bdebddf03842689478831d1f59575161d74f","segment_id":"index.md:274162b77e02a189","source_path":"index.md","text_hash":"274162b77e02a1898044ea787db109077a2969634f007221c29b53c2e159b0cc","text":". Plugins add Mattermost (Bot API + WebSocket) and more.\nOpenClaw also powers the OpenClaw assistant.","translated":"。插件可添加 Mattermost(Bot API + WebSocket)等更多渠道支持。\nOpenClaw 同时也驱动着 OpenClaw 助手。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:27:58Z"} -{"cache_key":"685f06b3659dc0d7b4f41f7b15a9722e9de70a2794b168650f8414873d7c168f","segment_id":"index.md:7ac362063b9f2046","source_path":"index.md","text_hash":"7ac362063b9f204602f38f9f1ec9cf047f03e0d7b83896571c9df6d31ad41e9c","text":"Nodes","translated":"节点","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:00:13Z"} -{"cache_key":"6871c777c178113b08a646e4826f7bc149fa796d803947ab84e9d7a8af45cea7","segment_id":"environment.md:1ec31258a6b45ea9","source_path":"environment.md","text_hash":"1ec31258a6b45ea903cd76f5b0190a99ab56afff6241a04f0681eb12b7a02484","text":"Env var equivalents:","translated":"环境变量 等效项:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:58:13Z"} -{"cache_key":"687dd62cc9018f0e74dadb0abaab9318503de6d5071253dca9c789f4352b9efa","segment_id":"start/wizard.md:fd3ef9f6b8315cd4","source_path":"start/wizard.md","text_hash":"fd3ef9f6b8315cd4cdfef9c6e295ed50e858a820f31a9b6555366054af144907","text":"Recommended: set up a Brave Search API key so the agent can use ","translated":"推荐:设置 Brave Search API 密钥,以便智能体可以使用 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:39:17Z"} -{"cache_key":"68c5c21091478bdd701de8955d662e20de4e91e7cf7626d3a7ad444230c802d0","segment_id":"index.md:6b8ebac7903757ce","source_path":"index.md","text_hash":"6b8ebac7903757ce7399cc729651a27e459903c24c64aa94827b20d8a2a411d2","text":"For Tailnet access, run ","translated":"如需 Tailnet 访问,请运行 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:48:45Z"} -{"cache_key":"68e1b25eafa11dfe6ffd16682d3f4f72d3f1774db82b6cd7b08e0dc617d7dbf4","segment_id":"start/wizard.md:1e3abf61a37e3cad","source_path":"start/wizard.md","text_hash":"1e3abf61a37e3cad36b11b459b1cc39e76feb6a0c369fe5270957468288dcc5c","text":"If ","translated":"如果 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:41:01Z"} -{"cache_key":"690712dd7f5a91ba48c9cbced5b6696f3db30d836271a363f94e57d84e674554","segment_id":"start/wizard.md:873f11af0a4e26ee","source_path":"start/wizard.md","text_hash":"873f11af0a4e26ee426ad19295a3f36d0256b0a6da1e0744832fe62d7a0cdf27","text":"Model/Auth","translated":"模型/认证","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:41:44Z"} -{"cache_key":"690bea2d411c2b07780745f517baf643d26686e96f534e5bb1f2eaaf441448b5","segment_id":"start/getting-started.md:d059230b2daf747b","source_path":"start/getting-started.md","text_hash":"d059230b2daf747b7ca874e806c334070d67c8f02fa017ad61f2701d61354d55","text":"Recommended Anthropic path:","translated":"推荐的 Anthropic 路径:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:36:30Z"} -{"cache_key":"69223c700e3933064992a3b935b8e443e6475dd5e5f63ba2447d85b76f68b53e","segment_id":"environment.md:61115f6649792387","source_path":"environment.md","text_hash":"61115f664979238731a390e84433a818965b7eaf1d38fa5b4b1507c33ef28c91","text":"Precedence (highest → lowest)","translated":"优先级(从高到低)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:40:28Z"} -{"cache_key":"697717108124b156f8f1fb7c823e71fd6d5bbbe71e43ed8b4c62e2bbbdafa7bd","segment_id":"environment.md:a16d7a83f4f565a8","source_path":"environment.md","text_hash":"a16d7a83f4f565a8d1aca9d8646b3eaa76308e8307be4634f9261ed0a0dccd67","text":"Config `env` block","translated":"配置 `env` 块","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:11:45Z"} -{"cache_key":"698c8d3774af0dc5196c75622b024794a3f45792871ab62821e75607b64fe050","segment_id":"environment.md:cdb4ee2aea69cc6a","source_path":"environment.md","text_hash":"cdb4ee2aea69cc6a83331bbe96dc2caa9a299d21329efb0336fc02a82e1839a8","text":".","translated":"。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:11:54Z"} -{"cache_key":"69b9351215888d772cb7294618ccac031ec230dc7dd3d94db9a0fc323fdd68e1","segment_id":"index.md:be48ae89c73a75da","source_path":"index.md","text_hash":"be48ae89c73a75da3454d565526d777938c20664618905a9bc77d6a0a21a689d","text":"\"EXFOLIATE! EXFOLIATE!\"","translated":"\"去角质!去角质!\"","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:58:50Z"} -{"cache_key":"6a0067f355b734f73cc5d06c2415e833a0237f4e1c0f086d4be41b01ca666065","segment_id":"help/index.md:frontmatter:summary","source_path":"help/index.md:frontmatter:summary","text_hash":"aece82a2d540ab1a9a21c7b038127cae6e9db2149491564bb1856b6f8999f205","text":"Help hub: common fixes, install sanity, and where to look when something breaks","translated":"帮助中心:常见修复方法、安装健全性检查,以及出问题时该去哪里排查","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:24:25Z"} -{"cache_key":"6a24e3ca10f074b02180cd94a12d97d06ba02d3ed50c0b414b6e82f6a567e2aa","segment_id":"start/wizard.md:6a40edf1fc87a29f","source_path":"start/wizard.md","text_hash":"6a40edf1fc87a29f243a7eefdbed57d19bfe16ab2e039d7ae1a44c097297e2f3","text":"WhatsApp","translated":"WhatsApp","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:44:14Z"} -{"cache_key":"6a33a478d12a3b427e017a6e516fcb413a3a5342725b674ac3ac5c9e5aca3973","segment_id":"index.md:c0aa8fcb6528510a","source_path":"index.md","text_hash":"c0aa8fcb6528510aea46361e8c871d88340063926a8dfdd4ba849b6190dec713","text":": it is the only process allowed to own the WhatsApp Web session. If you need a rescue bot or strict isolation, run multiple gateways with isolated profiles and ports; see ","translated":":它是唯一允许拥有 WhatsApp Web 会话 的进程。如果需要救援机器人或严格隔离,请使用隔离的配置文件和端口运行多个网关;参见 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:59:56Z"} -{"cache_key":"6a386646cb5e09a391827ea6dd82475e9d6edbe2c6acbd9c797f030f3c24bcff","segment_id":"index.md:bf0e823c81b87c5d","source_path":"index.md","text_hash":"bf0e823c81b87c5de79676155debf20a29b52d6d7eb7e77deda73a56d0afbaaa","text":"🧠 ","translated":"🧠 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:01:41Z"} -{"cache_key":"6a47aab59ec3c6e8096fa2733fa963a3233dc254b4ec8a306635e196ffe0928f","segment_id":"index.md:316cd41f595f3095","source_path":"index.md","text_hash":"316cd41f595f3095f149f98af70f77ab85404307a1505467ee45a26b316a9984","text":"Guided setup (recommended):","translated":"引导式设置(推荐):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:28:06Z"} -{"cache_key":"6a5441f83cd64d6920441e8eedade5c472aa328b00ed9f05ccf638493bce1b10","segment_id":"environment.md:d4a67341570f4656","source_path":"environment.md","text_hash":"d4a67341570f4656784c5f8fe1bfb48a738ace57b52544977431d50e2b718099","text":"FAQ: env vars and .env loading","translated":"常见问题:环境变量 和 .env 加载","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:58:34Z"} -{"cache_key":"6a914cfd2e0ac0d7ccc179c0361f67fc4574234e9d8cbfce616285591ed37b2e","segment_id":"start/wizard.md:769e62863db91849","source_path":"start/wizard.md","text_hash":"769e62863db91849711d2b06aa7480c8874950c7764035a155268ae80bcaaa5d","text":". Docs: ","translated":"存储。文档: ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:39:25Z"} -{"cache_key":"6ac32faba08302a413cf9de9061c4f7ab03bd96929649f2c17c6a6d2b1c25ce2","segment_id":"environment.md:f0442e6e05ccca16","source_path":"environment.md","text_hash":"f0442e6e05ccca160d17de0e7d509891b91b921366b2202b2b5c80435824e140","text":"Two equivalent ways to set inline env vars (both are non-overriding):","translated":"两种等效的内联环境变量设置方式(两者都不会覆盖):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:26:07Z"} -{"cache_key":"6af973157939ccd33570e5047d622de4263119466c45f46681f300f577b79faf","segment_id":"start/getting-started.md:7bac3209ac343388","source_path":"start/getting-started.md","text_hash":"7bac3209ac3433880eb9d1d0a1867cd9a0617f43ca27493375bc005051d869b7","text":"OAuth credentials (legacy import): ","translated":"OAuth 凭据(旧版导入): ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:36:36Z"} -{"cache_key":"6b0625352ff5092cab159cf3242fc10826b63c47c7d7524d3c3ee8e85cbe8f9f","segment_id":"index.md:5afbb1c887f6d850","source_path":"index.md","text_hash":"5afbb1c887f6d8501dba36cd2113d8f8b6ce6fa711a0d3e7efdc66f170abd2c2","text":"Cron jobs","translated":"定时任务","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:05:22Z"} -{"cache_key":"6b14f5e839df1e54026ee6d3db5886a6e9360039fd681101a4a9a2b101ff0919","segment_id":"index.md:084514e91f37c3ce","source_path":"index.md","text_hash":"084514e91f37c3ce85360e26c70b77fdc95f0d3551ce309db96fbcf956a53b01","text":"Dashboard (browser Control UI)","translated":"仪表盘(浏览器控制界面)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:28:21Z"} -{"cache_key":"6b3dbfa396df75c279946f5b8741a67863a0107d3f08c55dc642a8fac173a4c8","segment_id":"index.md:1074116f823ec992","source_path":"index.md","text_hash":"1074116f823ec992e76d7e8be19d3235fec5ddd7020562b06e7242e410174686","text":"Remote use","translated":"远程使用","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:49:11Z"} -{"cache_key":"6b44e5cb8d21527ef6ad754e2792b9416080f2a132c8fd7b6d431fc76113aad9","segment_id":"environment.md:a42cc4a7174c83a8","source_path":"environment.md","text_hash":"a42cc4a7174c83a853752b3e74cb001a234f3eca099688fdf0dd2540c60bb1e2","text":" expected keys:","translated":" 预期的键:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:22:25Z"} -{"cache_key":"6c18bb32586b6c812ebf5323b8ed442c63be7b4014bc62e51f0d7f5eb46d223b","segment_id":"environment.md:582967534d0f909d","source_path":"environment.md","text_hash":"582967534d0f909d196b97f9e6921342777aea87b46fa52df165389db1fb8ccf","text":" in ","translated":" 在 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:40:54Z"} -{"cache_key":"6c1b9632694258c227417b61df6433ac71eca1f2d35ff31cb5e145a7188dacfe","segment_id":"start/getting-started.md:d7849463c3ab6a49","source_path":"start/getting-started.md","text_hash":"d7849463c3ab6a496d77b8e6745d00ad430324bc5ed419a859f7c9e494102d68","text":"Manual run (foreground):","translated":"手动运行(前台):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:36:51Z"} -{"cache_key":"6c3d2263be9d0d6dd77934bd87f882599e2e9449e67bdee4388f84ab0aa6571b","segment_id":"start/wizard.md:698fdfc9c55bd3e4","source_path":"start/wizard.md","text_hash":"698fdfc9c55bd3e4ed5a9365317ae70aac20783ec38057088da27012a470a901","text":"Gateway port ","translated":"Gateway 端口 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:39:50Z"} -{"cache_key":"6cbd9a98f43c5cd7ee42cde62e8bdf2fab7d10c65a1aebd8540be50477452284","segment_id":"environment.md:6db0742daaf9f191","source_path":"environment.md","text_hash":"6db0742daaf9f191ab7816d2c9d317b1ea1693453a8c63b95af8b01477e0f5bb","text":" runs your login shell and imports only ","translated":" 运行你的登录 shell,并仅导入 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:26:12Z"} -{"cache_key":"6ce678386b3562455734243bc70c673a1b20aeb902456025275220465b508211","segment_id":"index.md:274162b77e02a189","source_path":"index.md","text_hash":"274162b77e02a1898044ea787db109077a2969634f007221c29b53c2e159b0cc","text":". Plugins add Mattermost (Bot API + WebSocket) and more.\nOpenClaw also powers the OpenClaw assistant.","translated":"。插件可添加 Mattermost(Bot API + WebSocket)等更多平台。\nOpenClaw 还为 OpenClaw 助手提供支持。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:59:01Z"} -{"cache_key":"6d11b3d022e2bde1caefb2917a26aa0169fa2e6e13c62d6595b010d9130fecb9","segment_id":"environment.md:87e89abb4c1c551f","source_path":"environment.md","text_hash":"87e89abb4c1c551fe08d355d097f18b8de78edca5f556997085681662fce8eed","text":"Config ","translated":"配置 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:45:57Z"} -{"cache_key":"6d347e25d4ffcc9340ab0172ccb04190e3780bc68d026152202edbdce466b09e","segment_id":"index.md:82ba9b60b12da3ab","source_path":"index.md","text_hash":"82ba9b60b12da3ab4e7dbcb0d7d937214cff80c82268311423a6dc8c4bc09df5","text":"OpenClaw 🦞","translated":"OpenClaw 🦞","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:58:48Z"} -{"cache_key":"6d365b29cf4305b02714d7c3f130301954ffc45574d088db9bb553d065f20854","segment_id":"index.md:1e37e607483201e2","source_path":"index.md","text_hash":"1e37e607483201e2152d2e9c68874dd4027648efdd9cfccb7bf8c9837398d143","text":"), serving ","translated":"),提供 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:29:11Z"} -{"cache_key":"6d78259f49a7bf9fd44a58e590c3f18b7646b810d8e07e7d34b5fb0e73aa5844","segment_id":"start/wizard.md:fe21d672d145bf9d","source_path":"start/wizard.md","text_hash":"fe21d672d145bf9dcbb12ba1cc1677a0b8718bed342f5bfeb774b2996fed9889","text":"Lets you choose a node manager: ","translated":"让您选择一个 Node 管理器: ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:45:45Z"} -{"cache_key":"6da895990489d77240c02d6c6f51892e4f6b7a5ec658cca2e3f6e92084fa7a72","segment_id":"index.md:5cf9ea2e20780551","source_path":"index.md","text_hash":"5cf9ea2e2078055129b38cfbc394142ca6ca41556bd6e31cbd527425647c1d1e","text":"One Gateway per host (recommended)","translated":"每台主机一个 Gateway(推荐)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:59:53Z"} -{"cache_key":"6dc2e2b52833ce2b82f2a886285fffff2f3a6e1d9d23875ec57d61f1328cda06","segment_id":"environment.md:668e5590b5bb9990","source_path":"environment.md","text_hash":"668e5590b5bb9990eeb25bf657f7d17281a4c613ee4442036787cd4b2efd22bb","text":"If the config file is missing entirely, step 4 is skipped; shell import still runs if enabled.","translated":"如果配置文件完全缺失,则跳过第 4 步;如果已启用,shell 导入仍会运行。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:41:07Z"} -{"cache_key":"6e0b579a98c31961263b11f7805c45b08b9850506746275df639a7a236ceccf5","segment_id":"index.md:496bcd8a502babde","source_path":"index.md","text_hash":"496bcd8a502babde0470e7105dfed7ba95bbc3193b7c6ba196b3ed0997e84294","text":"Voice notes","translated":"语音消息","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:50:56Z"} -{"cache_key":"6e109ac5e9791bc39a36e94e14a107a5e270e18cd20cd2b8d50132d6170c9dc9","segment_id":"environment.md:6f59001999ef7b71","source_path":"environment.md","text_hash":"6f59001999ef7b7128bab80d2034c419f3034497e05f69fbdf67f7b655cdc173","text":"Configuration: Env var substitution","translated":"配置:环境变量替换","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:47:07Z"} -{"cache_key":"6e297c0b91d3a16fec1d93183437778addf7c0908be226b39d4ee3dacab5c6f4","segment_id":"start/getting-started.md:b1ff7bd17092d95e","source_path":"start/getting-started.md","text_hash":"b1ff7bd17092d95ea7811719ce3df6d79b0c3a576695636fc411f2d95dc908b2","text":"Mattermost","translated":"Mattermost","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:37:30Z"} -{"cache_key":"6e3de6afce0bcbadbe662882ab64575df2fa60edee81930593c1f854e7bed6e7","segment_id":"start/wizard.md:de500b08e6825815","source_path":"start/wizard.md","text_hash":"de500b08e6825815c64066def01809cd44b9b86fe3de9142c48edb43644e6ec5","text":"Z.AI example:","translated":"Z.AI 示例:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:47:24Z"} -{"cache_key":"6e7bb00810a1c89548ff84b0cadc9b0b1f2f3c12625ab3fcbc78216c60a02b81","segment_id":"help/index.md:6cb77499abdccd9a","source_path":"help/index.md","text_hash":"6cb77499abdccd9a2dbb7c93a4d31eed01613dda06302933057970df9ecdeb54","text":"Logs:","translated":"日志:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:11:21Z"} -{"cache_key":"6ed4c62933bde0307cf9d37c7cb57e20d5fecc3da59e7ea185f4c101f80f4344","segment_id":"index.md:1eb6926214b56b39","source_path":"index.md","text_hash":"1eb6926214b56b396336f22c22a6f8a4c360cfe7109c8be0f9869655b9ff6235","text":"Pairing (DM + nodes)","translated":"配对(私聊 + 节点)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:52:34Z"} -{"cache_key":"6f2bf42e3ab6c9dfe240a6e878f8d322a9ec3956cf722b0d0b6aa221467d33cd","segment_id":"start/wizard.md:fd5f5ef720b423af","source_path":"start/wizard.md","text_hash":"fd5f5ef720b423af38c9113f3fce3be2eeccfef9f35b56c075bc8145297ebe59","text":" (auto-installs UI deps).","translated":" (自动安装 UI 依赖项)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:46:09Z"} -{"cache_key":"6f721a6913c75aac92f1890a4942631c3370e08f7e4180950dcc08a03e0765ba","segment_id":"environment.md:9c85ab59cb358b12","source_path":"environment.md","text_hash":"9c85ab59cb358b1299c623e16f52f3aee204a81fb6d1c956e37607a220d13b08","text":"You can reference env vars directly in config string values using `${VAR_NAME}` syntax:","translated":"你可以使用 `${VAR_NAME}` 语法在配置字符串值中直接引用环境变量:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:11:56Z"} -{"cache_key":"6f9e33ab431225304bd1fa8a39bb0efd5d4279d45975ebd5b20f24e09bb98cbc","segment_id":"start/wizard.md:531995e8b52db462","source_path":"start/wizard.md","text_hash":"531995e8b52db462df5a6b23a5f7af4d5c57415a397438b002364edebcdc1e14","text":" writes ","translated":" 写入 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:48:45Z"} -{"cache_key":"6fc0fd840e70231dec1ea7ea900c6bab5c29245170836fc0625f3898b05f4edb","segment_id":"index.md:b5ccaf9b1449291c","source_path":"index.md","text_hash":"b5ccaf9b1449291c92f855b8318aeb2880a9aa1a75272d17f55cf646071b3eae","text":"Gmail hooks (Pub/Sub)","translated":"Gmail 钩子(Pub/Sub)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:53:53Z"} -{"cache_key":"6fe259de45e43265b4853300aa3cd6972b5cbbd607ab967eed0618c0f860247a","segment_id":"help/index.md:5c94724fa7810fa9","source_path":"help/index.md","text_hash":"5c94724fa7810fa9902e565cf66c5f5a973074f2961fcd3a40bad4ee4aeca5e0","text":"If you want a quick “get unstuck” flow, start here:","translated":"如果你想快速了解\"快速排障\"流程,请从这里开始:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:39:33Z"} -{"cache_key":"70155d1dc5c21119f33f83bea22521d80b2d73a2d089a04817d3cf20cb55177c","segment_id":"index.md:93c89511a7a5dda3","source_path":"index.md","text_hash":"93c89511a7a5dda3b3f36253d17caee1e31f905813449d475bc6fed1a61f1430","text":"common fixes + troubleshooting","translated":"常见修复 + 故障排除","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:52:22Z"} -{"cache_key":"70626932ec406bd2253ce4134561af2088dc5ee89aa51a4336f95288b4f863c2","segment_id":"environment.md:a16d7a83f4f565a8","source_path":"environment.md","text_hash":"a16d7a83f4f565a8d1aca9d8646b3eaa76308e8307be4634f9261ed0a0dccd67","text":"Config `env` block","translated":"配置 `env` 块","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:19:30Z"} -{"cache_key":"70d246c2dab8d15be4dc7dcf914f0df1b95aeb517a09c88d86fa095e1c465095","segment_id":"index.md:268ebcd6be28e8d8","source_path":"index.md","text_hash":"268ebcd6be28e8d853ace3a6e28f269fbda1343b53e3f0de97ea3d5bf1a0e33e","text":"Clawd","translated":"Clawd","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:06:07Z"} -{"cache_key":"71667555ad1cea654225fec33df1804c97a0b8167affbf3d3c426ccb778e780a","segment_id":"start/wizard.md:82e1216ede141cb1","source_path":"start/wizard.md","text_hash":"82e1216ede141cb1553d20be7356c3f1ab9da9a4a05303cf7cd05ef01142558f","text":"Gateway settings (port/bind/auth/tailscale)","translated":"Gateway 设置(端口/绑定/认证/Tailscale)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:40:23Z"} -{"cache_key":"7170dcd349905701fd3cde7dc5bce0aed2618717e87ffa06e9ab230041f689a1","segment_id":"environment.md:cdb4ee2aea69cc6a","source_path":"environment.md","text_hash":"cdb4ee2aea69cc6a83331bbe96dc2caa9a299d21329efb0336fc02a82e1839a8","text":".","translated":"。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:45:26Z"} -{"cache_key":"724a450b6cdfc09dd0fc5acf94bb7f20a45c43e524810239d0e6e7cac65ff74b","segment_id":"index.md:bd293e4db98037bc","source_path":"index.md","text_hash":"bd293e4db98037bc9da5137af50453ac9c81b49e14eb4c47f121b12bed880877","text":" — Direct chats collapse into shared ","translated":" — 直接聊天合并到共享的 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:01:59Z"} -{"cache_key":"72d5ce369dd6489f427c02710fae70f6426a51de9441678410a023761cee215b","segment_id":"start/wizard.md:8f7c7d2f15e90b42","source_path":"start/wizard.md","text_hash":"8f7c7d2f15e90b420fb6f2cc7632d7d7a433bc94eeb262d9718286e5ffd9b365","text":"Related docs","translated":"相关文档","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:49:00Z"} -{"cache_key":"730b6369e65b8f27f57a90f6ee355beca28d783793767209a7cfe7beb736769b","segment_id":"start/wizard.md:eda31fe8fb873697","source_path":"start/wizard.md","text_hash":"eda31fe8fb873697fd7d5bfba08f263eaa917808a644bddd2b6d89d3a6b1c868","text":"QuickStart vs Advanced","translated":"快速入门与高级模式","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:39:30Z"} -{"cache_key":"73164a8584f9cc4e546493100199d4ebcbb65ce74c33e21d06da689c6d7b9328","segment_id":"start/wizard.md:ce85fecfbffa2746","source_path":"start/wizard.md","text_hash":"ce85fecfbffa2746f0a9b66464140eb2ed5a085ce85fff062ef0ff8b5686a0a5","text":".\nSessions are stored under ","translated":"下。会话存储在 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:48:54Z"} -{"cache_key":"73343b447da60470e1e14745df6653212cfb901cb8591540364f6cc906d42fd8","segment_id":"index.md:f1e3b32c8eb0df8e","source_path":"index.md","text_hash":"f1e3b32c8eb0df8ea105f043edf614005742c15581e2cebc5a9c3bafb0b90303","text":"Multi-agent routing","translated":"多 智能体 路由","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:01:43Z"} -{"cache_key":"7354cca5c4abf7f22290854ebf29a79803372316786f435b6d197b844847c177","segment_id":"start/wizard.md:fdd0a77c1e77ac7b","source_path":"start/wizard.md","text_hash":"fdd0a77c1e77ac7bffeea35de300966019f55c682bd3046ae045d8d5db9e68cb","text":"Writes ","translated":"写入 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:48:04Z"} -{"cache_key":"7364ee790ee697a9be18a8878b36241f6087253c4952dec1d7f9ed766fb7ba57","segment_id":"index.md:c491e0553683a70a","source_path":"index.md","text_hash":"c491e0553683a70a2fb52303f74675d2f7b725814ed70d5167473cb5fbe46450","text":"@steipete","translated":"@steipete","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:05:55Z"} -{"cache_key":"7396e8f216f016e9505d0ce0709809834376675cca202b48cc8592fdc8461357","segment_id":"environment.md:f7e239a42b7cd986","source_path":"environment.md","text_hash":"f7e239a42b7cd986a1558fed234e975ed2e96e9d37cf0a93f381778c461c89dd","text":"OpenClaw pulls environment variables from multiple sources. The rule is ","translated":"OpenClaw 从多个来源获取环境变量。规则是 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:45:21Z"} -{"cache_key":"73b0734779c4cb925c37ffe5e84b08453879c88667f95afe39d096031f55d1ec","segment_id":"environment.md:aac7246f5e97142c","source_path":"environment.md","text_hash":"aac7246f5e97142c3f257b7d8b84976f10c29e1b89804bb9d3eb7c43cc03cb8e","text":"Environment variables","translated":"环境变量","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:21:55Z"} -{"cache_key":"73c3930f846d6be7446b678ee4181328fead10032d5ac3217dd5a7dad818f119","segment_id":"environment.md:6db0742daaf9f191","source_path":"environment.md","text_hash":"6db0742daaf9f191ab7816d2c9d317b1ea1693453a8c63b95af8b01477e0f5bb","text":" runs your login shell and imports only ","translated":" 运行你的登录 shell 并仅导入 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:16:49Z"} -{"cache_key":"740260cd2027024814eb7dbbe5605642907d5720259bea069322bf4422ab7abe","segment_id":"index.md:e1b33cfa2a781bde","source_path":"index.md","text_hash":"e1b33cfa2a781bde9ef6c1d08bf95993c62f780a6664f5c5b92e3d3633e1fcf8","text":" (@nachoiacovino, ","translated":" (@nachoiacovino, ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:54:53Z"} -{"cache_key":"741c513df5edc25161f93a87a83b3335320749b9560fb3dedddcd1a9e02f8309","segment_id":"start/wizard.md:9f6f919dc1088468","source_path":"start/wizard.md","text_hash":"9f6f919dc1088468f8197ef0c27501e1c0a71a94b9faed9d363410305d3a472b","text":"Agent workspace","translated":"智能体工作区","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:43:51Z"} -{"cache_key":"743cddc057adf1927666ea67a9d672d0e924c93c938fac5086b278e6d0dac789","segment_id":"index.md:e3209251e20896ec","source_path":"index.md","text_hash":"e3209251e20896ecc60fa4da2817639f317fbb576288a9fc52d11e5030ecc44a","text":"Windows (WSL2)","translated":"Windows (WSL2)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:53:40Z"} -{"cache_key":"74759aa496b1b361eadf49b7e6245708b41bb91d571dc6a34e0f79ab602f23f9","segment_id":"environment.md:aac7246f5e97142c","source_path":"environment.md","text_hash":"aac7246f5e97142c3f257b7d8b84976f10c29e1b89804bb9d3eb7c43cc03cb8e","text":"Environment variables","translated":"Could you please provide the file path or the full text about \"Environment variables\" that you'd like me to translate?","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:11:37Z"} -{"cache_key":"74784eac2b412aa6a24c3e0d7f66e14ac2ac8aeb85905dafd36f4f6c680fd94d","segment_id":"environment.md:39d9dca6df060f67","source_path":"environment.md","text_hash":"39d9dca6df060f6708b30f0f6b1581105c607e96a66f282bf4a0fe75e92dc205","text":"Env var substitution in","translated":"环境变量替换在","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:46:56Z"} -{"cache_key":"74a32b88954158955f70bb053e86f28337b079cf11feb27cec6a4af1d9926f6b","segment_id":"start/getting-started.md:13bf3a75f8be5632","source_path":"start/getting-started.md","text_hash":"13bf3a75f8be5632d9f92212f0c5a61750a0b4654af5db87a9d91ade89b72e5b","text":"Default posture: unknown DMs get a short code and messages are not processed until approved.\nIf your first DM gets no reply, approve the pairing:","translated":"默认策略:未知私信会收到一个短码,消息在批准之前不会被处理。\n如果您的第一条私信没有收到回复,请批准配对:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:37:39Z"} -{"cache_key":"74b57577f77fd5b865970d086c56b3f7d760c94e57a5c11babad0173e6b6bc1f","segment_id":"start/getting-started.md:df3db5b08f6e98f3","source_path":"start/getting-started.md","text_hash":"df3db5b08f6e98f31a9242361eb5d1f325c35f4acbb6c7cd8ac9afa85bf8eaa7","text":"Local vs Remote","translated":"本地 vs 远程","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:36:00Z"} -{"cache_key":"753971a27214ecb4551eb400d1bace8931dd2b658bef6bc8d55506b6adeba974","segment_id":"environment.md:668e5590b5bb9990","source_path":"environment.md","text_hash":"668e5590b5bb9990eeb25bf657f7d17281a4c613ee4442036787cd4b2efd22bb","text":"If the config file is missing entirely, step 4 is skipped; shell import still runs if enabled.","translated":"如果配置文件完全缺失,则跳过第 4 步;如果已启用,shell 导入仍会运行。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:22:07Z"} -{"cache_key":"759d3f3cb39af3e2d57e711d9680a2b999ef3bc08a4184eccd9b1d53c18f7b1f","segment_id":"index.md:95cae5ed127bd44d","source_path":"index.md","text_hash":"95cae5ed127bd44dcc30345a1925d77f333284b43a6f97832f149a63dc38e0e0","text":"The wizard now generates a gateway token by default (even for loopback).","translated":"向导 现在默认会生成网关令牌(即使是回环连接)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:00:06Z"} -{"cache_key":"75deb7505d1a042e24a4c83cdddf479326929a80edeffcfaa49863bf115ab848","segment_id":"help/index.md:24669ff48290c187","source_path":"help/index.md","text_hash":"24669ff48290c1875d8067bbd241e8a55444839747bffb8ab99f3a34ef248436","text":"Doctor","translated":"诊断工具","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:39:58Z"} -{"cache_key":"76148f338fd0041621eb7cda1759d78690a3cb0d0d9a1ca8c2cd4af02dfff679","segment_id":"start/wizard.md:9022ac86cfbabdac","source_path":"start/wizard.md","text_hash":"9022ac86cfbabdac3512fdd7797b7f0a3db628d4873e0b3d64b2f5c752724d03","text":"Tailscale exposure ","translated":"Tailscale 暴露 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:40:00Z"} -{"cache_key":"762684ab35f0d829663da7f4de49060030ad02772df37776113660266af70236","segment_id":"help/index.md:729bc562eec2658b","source_path":"help/index.md","text_hash":"729bc562eec2658bd11ffdd522fe5277177dc73e86eaca7baac0b472a4d8f8b2","text":"If you’re looking for conceptual questions (not “something broke”):","translated":"如果你在寻找概念性问题的解答(而不是\"出了问题\"):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:11:32Z"} -{"cache_key":"762e4d6415fd6b11bfb7837a3d751030659263ba7f6292f3defcf734097ada17","segment_id":"environment.md:6f59001999ef7b71","source_path":"environment.md","text_hash":"6f59001999ef7b7128bab80d2034c419f3034497e05f69fbdf67f7b655cdc173","text":"Configuration: Env var substitution","translated":"配置:环境变量替换","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:13:05Z"} -{"cache_key":"76653677000dc673dea8f2bff5f0d86e7118627d575314948b1592b82cd490be","segment_id":"environment.md:baa5be7f6320780b","source_path":"environment.md","text_hash":"baa5be7f6320780bd7bb7b7ddbb8cd1ffb26ccf7d94d363350668c50aedcf95f","text":" (applied only if missing).","translated":" (仅在缺失时应用)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:40:56Z"} -{"cache_key":"76934bd605258decffe7f40a10419e7a19405bc7a09f19c65a9447682a805337","segment_id":"start/wizard.md:8e70d4cdad7bdb70","source_path":"start/wizard.md","text_hash":"8e70d4cdad7bdb70b333c34e14862f46905fbfd6fb678a968f857747f2ee2389","text":"Pick a default model from detected options (or enter provider/model manually).","translated":"从检测到的选项中选择默认模型(或手动输入提供商/模型)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:43:22Z"} -{"cache_key":"76be6836deb53495879f2e5cc5f57af37d12f5fb67d27f22ecc8c7dc885a6c7a","segment_id":"index.md:4b4051e77af8844f","source_path":"index.md","text_hash":"4b4051e77af8844fcf86a298214527e7840938258f99bfe97b900bbc0d8d2f4b","text":"The dashboard is the browser Control UI for chat, config, nodes, sessions, and more.\nLocal default: http://127.0.0.1:18789/\nRemote access: ","translated":"仪表板是用于聊天、配置、节点、会话 等功能的浏览器控制界面。\n本地默认地址:http://127.0.0.1:18789/\n远程访问: ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:59:33Z"} -{"cache_key":"76ffac273c1ab348f20e97d66f7d9a8218789d2df1be41d676e824f64c949ef4","segment_id":"start/wizard.md:72ea058924a0acec","source_path":"start/wizard.md","text_hash":"72ea058924a0acecc4dd9dae83410a37dd2c43d9b526fb770f88685d27aed0b1","text":"Remote mode","translated":"远程模式","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:40:34Z"} -{"cache_key":"7748e8a6d7f7a768cc6003487448b27848640b4e9df17154fcf6ad93c707b4aa","segment_id":"environment.md:frontmatter:read_when:0","source_path":"environment.md:frontmatter:read_when:0","text_hash":"90fc0487bff88009979cff1061c1a882df8c3b1baa9c43538331d9d5dab15479","text":"You need to know which env vars are loaded, and in what order","translated":"你需要了解加载了哪些环境变量,以及加载的顺序","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:21:48Z"} -{"cache_key":"7749f2ac757311f26619b43b362bfc9265176ac801f7a38b42ab835c8af89d85","segment_id":"environment.md:6863067eb0a2c749","source_path":"environment.md","text_hash":"6863067eb0a2c7499425c6c189b2c88bac55ca754285a6ab1ef37b75b4cfad4d","text":"See ","translated":"参见 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:41:30Z"} -{"cache_key":"775fa7e7f44ee6a3a24c821c866f68606b4ecad281b47f4b744de729460eb521","segment_id":"index.md:cec2be6f871d276b","source_path":"index.md","text_hash":"cec2be6f871d276b45d13e3010c788f01b03ae2f1caca3264bbf759afacace46","text":"Telegram Bot","translated":"Telegram 机器人","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:00:58Z"} -{"cache_key":"7781b8121d68b25732c0ef1ac9c2b3c6573a376f2912bd6a3860f4d7f6bf3e45","segment_id":"environment.md:08ba1569cc7ada49","source_path":"environment.md","text_hash":"08ba1569cc7ada49ef908e8f19b1d36252072d5876086ae6726c55672d571603","text":" non-overriding):","translated":" 非覆盖的):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:46:31Z"} -{"cache_key":"77936e1851104e6dac3b5785a85c10f838ca0173ad387391588eb25f884c3a59","segment_id":"environment.md:e234227b0e001687","source_path":"environment.md","text_hash":"e234227b0e001687821541fac3af38fc6be293ec6e13910c6826b9afc8ca33be","text":" syntax:","translated":" 语法:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:17:01Z"} -{"cache_key":"77a1d6fe1f2415835e41760b0765c3b93b4853664de26c500cf7acf3c512d60e","segment_id":"index.md:7ac362063b9f2046","source_path":"index.md","text_hash":"7ac362063b9f204602f38f9f1ec9cf047f03e0d7b83896571c9df6d31ad41e9c","text":"Nodes","translated":"节点","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:28:57Z"} -{"cache_key":"77b6a43a45b36b25b51859a5b976fa12609b6d19ed351bc0e84fae2290d32da9","segment_id":"help/index.md:2adc964c084749b1","source_path":"help/index.md","text_hash":"2adc964c084749b1f2d8aef24030988b667dbda2e38a6a1699556c93e07c1cea","text":"Start here","translated":"从这里开始","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:11:12Z"} -{"cache_key":"7806b590e1e2ff8ab2244875f7a2c370ab3b11462fd2061e5f4af9cf72f70d19","segment_id":"start/wizard.md:9c706a2bb9ebcb20","source_path":"start/wizard.md","text_hash":"9c706a2bb9ebcb206633616f2a40867b0c02716657ac4c0e95c7c1939287d3d8","text":"; auth profiles live in ","translated":";认证配置存储在 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:43:31Z"} -{"cache_key":"78161c8de8a14607cd003796d4c4ace7048f9116ecbe036601136d7f0cef4ff3","segment_id":"start/getting-started.md:bfd99edf844f6205","source_path":"start/getting-started.md","text_hash":"bfd99edf844f62050af2f7d37df7cfa7f651b8e1be341eb4f07c3849ca4efc43","text":"Fastest chat: open the Control UI (no channel setup needed). Run ","translated":"最快聊天方式:打开控制界面(无需设置渠道)。运行 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:34:33Z"} -{"cache_key":"785cae01bc172c4c47e2e82cda4c5afd7d37d7069a008e44c8a4176eeacafe67","segment_id":"help/index.md:a8ab86b9313a9236","source_path":"help/index.md","text_hash":"a8ab86b9313a92362150f5e5ba8a19de4ee52f2e3162f9bd2bc6cf128a2fcd18","text":"If you’re looking for conceptual questions (not “something","translated":"如果你在寻找概念性问题(不是\"出了什么","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:45:00Z"} -{"cache_key":"78ae0fabb1aab02156d5bf1b4e148ba155369b079aa0b733aca5a750a3d0cdc2","segment_id":"index.md:329f3c913c0a1636","source_path":"index.md","text_hash":"329f3c913c0a16363949eb8ee7eb0cda7e81137a3851108019f33e5d18b57d8f","text":"Switching between npm and git installs later is easy: install the other flavor and run ","translated":"之后在 npm 和 git 安装之间切换很简单:安装另一种方式然后运行 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:51:30Z"} -{"cache_key":"791458a3464d7dd0036471e90590958905611942f9f0aefd8917c701e4e587d4","segment_id":"start/wizard.md:0516de0bbbd36c95","source_path":"start/wizard.md","text_hash":"0516de0bbbd36c95c5c45902d43caf2abdab59363114c4d6abae961f6ed1c1cb","text":" imply non-interactive mode. Use ","translated":" 意味着非交互模式。请使用 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:40:50Z"} -{"cache_key":"79156ca764918ee2f487da88a8917572551bbb6cac71a256748ba30bce781f0e","segment_id":"index.md:96be070791b7d545","source_path":"index.md","text_hash":"96be070791b7d545dc75084e59059d2170eed247350b351db5330fbd947e4be6","text":"👥 ","translated":"👥 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:50:37Z"} -{"cache_key":"791d43fa7f63c6479c1e7d2393c2b2790beee2cffe5aaebc547d18ed1b73741f","segment_id":"start/wizard.md:45e595586df0bdc3","source_path":"start/wizard.md","text_hash":"45e595586df0bdc3f10caef3511b7e215c0b32a1626548d1c8648501cdcb4c00","text":"If the Gateway is loopback‑only, use SSH tunneling or a tailnet.","translated":"如果 Gateway 仅绑定回环地址,请使用 SSH 隧道或 tailnet。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:46:32Z"} -{"cache_key":"7938daa81ee4f2884173676b22aaf901e847b654ce9f368be964ab10461b5852","segment_id":"environment.md:87e89abb4c1c551f","source_path":"environment.md","text_hash":"87e89abb4c1c551fe08d355d097f18b8de78edca5f556997085681662fce8eed","text":"Config ","translated":"配置 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:12:20Z"} -{"cache_key":"794456d8a1d905f17357dfe11942245d5486b210b163a8ed1608b11b27ce3508","segment_id":"index.md:45808d75bf8911fa","source_path":"index.md","text_hash":"45808d75bf8911fa21637f9dd3f0dace1877748211976b5d61fcc5c15db594d0","text":"Webhooks","translated":"Webhooks","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:05:24Z"} -{"cache_key":"794de06f9e33b312b5ae297991b32a00290f5f484059283f191e6169ee2e70c4","segment_id":"index.md:2566561f81db7a7c","source_path":"index.md","text_hash":"2566561f81db7a7c4adb6cee3e93139155a6b01d52ff0d3d5c11648f46bc79bb","text":"📱 ","translated":"📱 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:29:28Z"} -{"cache_key":"7956d19ecce3a275c0f47424d4f75e289b9f7e5742f3adbe0f6eee5ac4c906ca","segment_id":"environment.md:46ab081177a452aa","source_path":"environment.md","text_hash":"46ab081177a452aa62354b581730f4675cb03e58cde8282071da30cabe18fb2e","text":"Optional login-shell import","translated":"可选的登录 shell 导入","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:25:56Z"} -{"cache_key":"79a4ac9cc430df4e4cb0a8710c8d21a52baeef560c6220facc6199d5e5a383fc","segment_id":"index.md:98a670e2fb754896","source_path":"index.md","text_hash":"98a670e2fb7548964e8b78b90fef47f679580423427bfd15e5869aca9681d0dd","text":"\"We're all just playing with our own prompts.\"","translated":"\"我们都只是在玩弄自己的提示词罢了。\"","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:33:23Z"} -{"cache_key":"79b1bfcd80bed91b72fe6f15f1e6e1fdfd069cddc7f2fcc4fbd28f573a874866","segment_id":"index.md:eef0107bb5a4e06b","source_path":"index.md","text_hash":"eef0107bb5a4e06b9de432b9e62bcf1e39ca5dfbbb9cb0cc1c803ca7671c06ab","text":"Gateway runbook","translated":"Gateway 运行手册","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:04:28Z"} -{"cache_key":"7a2de748330ae022b15280d0444a6c2794d4c60313452e6f4c2470a395e58ca8","segment_id":"index.md:bd293e4db98037bc","source_path":"index.md","text_hash":"bd293e4db98037bc9da5137af50453ac9c81b49e14eb4c47f121b12bed880877","text":" — Direct chats collapse into shared ","translated":" — 私聊折叠为共享 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:30:25Z"} -{"cache_key":"7a3ab97c36c385c05719cd51ac71cf8b98e65cc27b71ffc71d3670f568d36e6c","segment_id":"start/getting-started.md:5a4d846f4fe5a72f","source_path":"start/getting-started.md","text_hash":"5a4d846f4fe5a72f693af0c9d3a98b2a2df8c99456429765f51706ff7b76b7f7","text":"Gateway (from this repo):","translated":"Gateway(从此仓库):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:37:54Z"} -{"cache_key":"7a4ee345ffcdd6965857db8eb1605eb4460433be999c17d2b03a9a6a9789bdaa","segment_id":"help/index.md:6201111b83a0cb5b","source_path":"help/index.md","text_hash":"6201111b83a0cb5b0922cb37cc442b9a40e24e3b1ce100a4bb204f4c63fd2ac0","text":" and ","translated":" 和 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:24:49Z"} -{"cache_key":"7a78848fc43c9758427283f5941f66521236977cbaeaeeb04577de87c4b55c59","segment_id":"index.md:11450a0f023dc48c","source_path":"index.md","text_hash":"11450a0f023dc48cc9cef026357e2b4569a2b756290191c45a9eb0120a919cb7","text":" and (for groups) mention rules.","translated":" 以及(针对群组的)提及规则。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:03:37Z"} -{"cache_key":"7a917e0cad8ee2f671d93d95939ff19d4545eac6723d3c7be11326cb65db5d25","segment_id":"index.md:c491e0553683a70a","source_path":"index.md","text_hash":"c491e0553683a70a2fb52303f74675d2f7b725814ed70d5167473cb5fbe46450","text":"@steipete","translated":"@steipete","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:54:20Z"} -{"cache_key":"7ab5c873654dc79ffc905151bf0bff3f05a41e3e7d6e20a368d64aa3c7c8300e","segment_id":"help/index.md:156597e2632411d1","source_path":"help/index.md","text_hash":"156597e2632411d1d5f634db15004072607ba45072a4e17dfa51790a37b6781f","text":"Gateway issues:","translated":"Gateway 问题:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:44:44Z"} -{"cache_key":"7adf10c75a13a4f39d1360a5f4f45f0e22b608904e9ca8616eed396a35b7c3c0","segment_id":"index.md:cda454f61dfcac70","source_path":"index.md","text_hash":"cda454f61dfcac7007a9edc538f9f58cf38caa0652e253975979308162bccc53","text":"Gateway configuration","translated":"Gateway 配置","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:49:07Z"} -{"cache_key":"7ae5133ee1cd21463f6a5373822eeddcebba9435512ebf67ec49d2f88d1a770f","segment_id":"index.md:1eb6926214b56b39","source_path":"index.md","text_hash":"1eb6926214b56b396336f22c22a6f8a4c360cfe7109c8be0f9869655b9ff6235","text":"Pairing (DM + nodes)","translated":"配对(私信 + 节点)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:04:08Z"} -{"cache_key":"7b14aa70efca84fdf7555ed77d263fae52ddaff260e935d45a3e22031f551c2a","segment_id":"environment.md:87e89abb4c1c551f","source_path":"environment.md","text_hash":"87e89abb4c1c551fe08d355d097f18b8de78edca5f556997085681662fce8eed","text":"Config ","translated":"配置 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:57:39Z"} -{"cache_key":"7b2f32af1ab182e748188eda2538cc9248faff3296bf459ee2365bf753cd3637","segment_id":"environment.md:907940a35852447a","source_path":"environment.md","text_hash":"907940a35852447aad5f21c5a180d993ff31cfd5807b1352ed0c24eabe183465","text":"never override existing values","translated":"永远不覆盖已有的值","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:22:00Z"} -{"cache_key":"7b38154bd954f484fa2247a129ccab23889881422b6d5415970eb7ef1dc4170f","segment_id":"environment.md:1ec31258a6b45ea9","source_path":"environment.md","text_hash":"1ec31258a6b45ea903cd76f5b0190a99ab56afff6241a04f0681eb12b7a02484","text":"Env var equivalents:","translated":"等效的环境变量:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:19:40Z"} -{"cache_key":"7b5e958eea98aae071adeeff78912bbce4b3a9616dabe5ade50538f31a372e6e","segment_id":"index.md:c011d6097bfbc8e9","source_path":"index.md","text_hash":"c011d6097bfbc8e936280addcf2e3e7d06ea2223ffd596973191b800a7035c32","text":"License","translated":"许可证","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:54:58Z"} -{"cache_key":"7b8d987fa7611cd9dfdf4089d114bce0fb150f5f3af5cfda3fdcf455be7afced","segment_id":"environment.md:3fe738a7ee6aaff5","source_path":"environment.md","text_hash":"3fe738a7ee6aaff51f099d9a8314510c99ced6a568eb38c67642cd43bb54eec0","text":" in the current working directory","translated":" 在当前工作目录中","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:16:08Z"} -{"cache_key":"7b91b68fc1e996f0909c890f91668386f9ef94974d0f8774a4d34d3cef43c638","segment_id":"help/index.md:frontmatter:read_when:1","source_path":"help/index.md:frontmatter:read_when:1","text_hash":"857eafc389d179e83e21e46c10527fec40894fe064c63847ba06b946b7d5eb73","text":"Something broke and you want the fastest path to a fix","translated":"出了问题,你想找到最快的修复方法","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:11:05Z"} -{"cache_key":"7c403e93e8396d4740e889ff8e6d078fe8079017dfda145496b3da29b0887144","segment_id":"environment.md:ab5aec4424cf678d","source_path":"environment.md","text_hash":"ab5aec4424cf678dcfb1ad3d2c2929c1e0b2b1ff61b82b961ada48ad033367b4","text":" (dotenv default; does not override).","translated":" (dotenv 默认行为;不会覆盖)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:40:38Z"} -{"cache_key":"7c487d305535a19790619ad3f172d0128d891e7b8488e84a7fb73ed9a7a9b32a","segment_id":"start/wizard.md:32ebb1abcc1c601c","source_path":"start/wizard.md","text_hash":"32ebb1abcc1c601ceb9c4e3c4faba0caa5b85bb98c4f1e6612c40faa528a91c9","text":"(","translated":"(","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:39:19Z"} -{"cache_key":"7c9e5b6fba8c9d85221b3c38a9d527d2d683facfd83a93e06f304e13c6771022","segment_id":"environment.md:b4736422e64c0a36","source_path":"environment.md","text_hash":"b4736422e64c0a369663d1b2d386f1b8f4b31b8936b588e4a54453c61a24e0fd","text":"Process environment","translated":"进程环境","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:25:27Z"} -{"cache_key":"7d14d64c713aa996c01b08945c1784ed78fa95b602c35f9fba369de60b9ffea5","segment_id":"environment.md:f7e239a42b7cd986","source_path":"environment.md","text_hash":"f7e239a42b7cd986a1558fed234e975ed2e96e9d37cf0a93f381778c461c89dd","text":"OpenClaw pulls environment variables from multiple sources. The rule is ","translated":"OpenClaw 从多个来源获取 环境变量。规则是 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:40:22Z"} -{"cache_key":"7d6ed7204c12f1d91564243dad60b560217dc6a29de3adf7a809de221b42c06a","segment_id":"start/wizard.md:bcd475104a873a42","source_path":"start/wizard.md","text_hash":"bcd475104a873a42ffaaed1aca9434981ce857adba97ebec4adc9e74e4d852f4","text":"allowlist","translated":"允许名单","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:40:05Z"} -{"cache_key":"7d8297d7b387f5a5302e0a09ea73266ba2e2075985e1af83b24a633c161cf10d","segment_id":"environment.md:46ab081177a452aa","source_path":"environment.md","text_hash":"46ab081177a452aa62354b581730f4675cb03e58cde8282071da30cabe18fb2e","text":"Optional login-shell import","translated":"可选的登录 shell 导入","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:12:29Z"} -{"cache_key":"7d82becc6d15c1be9fb97c06f9cc8ed5bf4949814e9d7af50d07380cb51e82f2","segment_id":"index.md:bd293e4db98037bc","source_path":"index.md","text_hash":"bd293e4db98037bc9da5137af50453ac9c81b49e14eb4c47f121b12bed880877","text":" — Direct chats collapse into shared ","translated":" —— 直接聊天折叠为共享的 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:50:32Z"} -{"cache_key":"7d927444fcf26b06d31a6f1f5d134416f8539e3aa1cc8dd3a4a7c5819fd1534a","segment_id":"index.md:f12242785ecda793","source_path":"index.md","text_hash":"f12242785ecda7935ded50cd48418357d32d3bac290f7a199bc9f0c7fbd13123","text":") — Location parsing (Telegram + WhatsApp)","translated":")— 位置解析(Telegram + WhatsApp)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:06:46Z"} -{"cache_key":"7da93ed9cf09f854e305f5deb4ddfac979802a4d1f6587247eea91555bdadc73","segment_id":"environment.md:1ec31258a6b45ea9","source_path":"environment.md","text_hash":"1ec31258a6b45ea903cd76f5b0190a99ab56afff6241a04f0681eb12b7a02484","text":"Env var equivalents:","translated":"等效的环境变量:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:12:54Z"} -{"cache_key":"7dca802a270f89fb4612993c8e7567b27da97fabd7b9ebd9c732ca2d53393244","segment_id":"help/index.md:729bc562eec2658b","source_path":"help/index.md","text_hash":"729bc562eec2658bd11ffdd522fe5277177dc73e86eaca7baac0b472a4d8f8b2","text":"If you’re looking for conceptual questions (not “something broke”):","translated":"如果你在寻找概念性问题的解答(而不是\"出了问题\"):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:24:56Z"} -{"cache_key":"7ddd9124594671bfd422e2bad8d56887ff8035a346f99a68d806deca63db0dcc","segment_id":"environment.md:46ab081177a452aa","source_path":"environment.md","text_hash":"46ab081177a452aa62354b581730f4675cb03e58cde8282071da30cabe18fb2e","text":"Optional login-shell import","translated":"可选的登录 shell 导入","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:40:58Z"} -{"cache_key":"7e1f91e8aeccc2aabf2dee95b6ee40ec27afb0aa5af4c97967b411ccded6826e","segment_id":"start/wizard.md:608acf5d419e2dad","source_path":"start/wizard.md","text_hash":"608acf5d419e2dadaef0b8082406cdbdb689e27953723644bf677feb09d1cf58","text":"Synthetic (Anthropic-compatible)","translated":"Synthetic(Anthropic 兼容)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:43:03Z"} -{"cache_key":"7e39b80026e0b63d7df55fcc142e8a2876ff972da67c4b263b619848e43417ec","segment_id":"index.md:19525ac5e5b9c476","source_path":"index.md","text_hash":"19525ac5e5b9c476b36a38c5697063e37e8fe2fae8ef6611f620def69430cf74","text":"Canvas host","translated":"Canvas 主机","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:00:18Z"} -{"cache_key":"7e93af146d7d04064fcba89e95f5a12c7320475af9b1c2e3208185cecb16a369","segment_id":"index.md:25d853ca04397b6a","source_path":"index.md","text_hash":"25d853ca04397b6ae248036d4d029d19d94a4981290387e5c29ef61b0eca9021","text":"Media: audio","translated":"媒体:音频","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:32:50Z"} -{"cache_key":"7ea531b1b4d75bfda8cb0a3a62e895b86310ab3f79a08cdcaddc1f2ccc61fbc2","segment_id":"index.md:3f8466cd9cb153d0","source_path":"index.md","text_hash":"3f8466cd9cb153d0c78a88f6a209e2206992db28c6dab45424132dc187974e2b","text":"Note: legacy Claude/Codex/Gemini/Opencode paths have been removed; Pi is the only coding-agent path.","translated":"注意:旧版 Claude/Codex/Gemini/Opencode 路径已移除;Pi 是唯一的编程智能体路径。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:31:04Z"} -{"cache_key":"7ec6a54cb7ceb1c31b47de147210b7193006db1d64b608c52b697512bd7e41aa","segment_id":"environment.md:32ebb1abcc1c601c","source_path":"environment.md","text_hash":"32ebb1abcc1c601ceb9c4e3c4faba0caa5b85bb98c4f1e6612c40faa528a91c9","text":" (","translated":" (","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:16:37Z"} -{"cache_key":"7eefff451137a5fd592db6fef6e65447cae69abe23699c34cb838a1c3cc04d73","segment_id":"start/wizard.md:d3745cec7a646b22","source_path":"start/wizard.md","text_hash":"d3745cec7a646b229f6d7123ef3557f68640f35a54a593f1e0e32776da0677c1","text":" (auto‑generated, even on loopback)","translated":" (自动生成,即使在回环地址上也是如此)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:39:58Z"} -{"cache_key":"7f2e9e14503f22acab8659b458900c0864bdc52ee5055d4a3a742508a8e41314","segment_id":"environment.md:45ca56d179d4788c","source_path":"environment.md","text_hash":"45ca56d179d4788c55ba9f7653b376d62e7faa738e92259e3d4f6f5c1b554f28","text":"Related","translated":"相关","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:47:13Z"} -{"cache_key":"7f4d30ae34bbfb95b016db35c14a77f46cdda52ff397a69b63ad655c6128f0f6","segment_id":"index.md:30f035b33a6c35d5","source_path":"index.md","text_hash":"30f035b33a6c35d51e09f9241c61061355c872f2fb9a82822cd2f5f443fd4ad4","text":"Group Chat Support","translated":"群聊支持","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:30:31Z"} -{"cache_key":"7f8a0ec6c0614299ed8aca539dde67e208ecc32d4022975fbb37f7930f3f70e5","segment_id":"start/getting-started.md:4cc7ae6d3b7fbaaf","source_path":"start/getting-started.md","text_hash":"4cc7ae6d3b7fbaaf56673ea3268caa38af191a587867ef1090c9f689ecccec96","text":"Headless/server tip: do OAuth on a normal machine first, then copy ","translated":"无头/服务器提示:先在普通机器上完成 OAuth,然后复制 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:36:40Z"} -{"cache_key":"7fec8c329b4438aef905e1918364b86faca2a2580bb29eded4850a67ba16109b","segment_id":"environment.md:496aca80e4d8f29f","source_path":"environment.md","text_hash":"496aca80e4d8f29fb8e8cd816c3afb48d3f103970b3a2ee1600c08ca67326dee","text":" block","translated":" 块","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:25:48Z"} -{"cache_key":"8070c35741bdfaa2f8878a7460406a597ccf7fec7994522389adeafea46b6e8e","segment_id":"environment.md:frontmatter:read_when:0","source_path":"environment.md:frontmatter:read_when:0","text_hash":"90fc0487bff88009979cff1061c1a882df8c3b1baa9c43538331d9d5dab15479","text":"You need to know which env vars are loaded, and in what order","translated":"你需要了解加载了哪些环境变量,以及加载的顺序","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:45:10Z"} -{"cache_key":"807dfa0f0d41dd91a0565a166afd1780eea045b0d30e177d91cc9dcf7dfce7db","segment_id":"help/index.md:569ca49f4aaf7846","source_path":"help/index.md","text_hash":"569ca49f4aaf7846e952c1d4aeca72febd0b79fa1c4f9db08fd3127551218572","text":"Install","translated":"安装","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:44:42Z"} -{"cache_key":"8088f34c0eace7e3e9131feea6cc0e9f333432c1fbc78720f10574d1b05197fb","segment_id":"help/index.md:6cb77499abdccd9a","source_path":"help/index.md","text_hash":"6cb77499abdccd9a2dbb7c93a4d31eed01613dda06302933057970df9ecdeb54","text":"Logs:","translated":"日志:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:39:46Z"} -{"cache_key":"81c954a409a9f6266a05f27d77a4b578631b1f6d86deca557279fd2f82ed29b5","segment_id":"index.md:2b402c90e9b15d9c","source_path":"index.md","text_hash":"2b402c90e9b15d9c3ef65c432c4111108f54ee544cda5424db46f6ac974928e4","text":"🔐 ","translated":"🔐 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:01:49Z"} -{"cache_key":"81cc99b7c2a7579fa478992233a19aeaea8c64586f07c24fe9e6f22f70610a96","segment_id":"index.md:1a36bded6916228a","source_path":"index.md","text_hash":"1a36bded6916228a5664c8b2bcdaa5661d342fe3e632aa41453f647a3daa3a61","text":" — Pairs as a node and exposes a Canvas surface","translated":" — 作为节点配对并提供 Canvas 界面","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:30:58Z"} -{"cache_key":"81e5e8a79bbaec139591a64a84574dfb14fcf22e8f803b68ddaa5950103c4c58","segment_id":"index.md:774f1d6b2910de20","source_path":"index.md","text_hash":"774f1d6b2910de200115afec1bd87fe1ea6b0bc2142ac729e121e10a45df4b5d","text":" ← ","translated":" ← ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:31:56Z"} -{"cache_key":"822efbc5bcf680421493847f6b76e9626f1d8202ff5ff47cd3e141ecdac58a9f","segment_id":"environment.md:496aca80e4d8f29f","source_path":"environment.md","text_hash":"496aca80e4d8f29fb8e8cd816c3afb48d3f103970b3a2ee1600c08ca67326dee","text":" block","translated":" 块","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:16:27Z"} -{"cache_key":"829cc48b5c60f16b09e63437a5de27acc17910473f8e3dfbc505a0d3e3b593c7","segment_id":"start/wizard.md:79a482cf546c23b0","source_path":"start/wizard.md","text_hash":"79a482cf546c23b04cd48a33d4ca8411f62e5b7dc8c3a8f30165e28e747f263a","text":"iMessage","translated":"iMessage","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:44:54Z"} -{"cache_key":"82d122e7cc5c895b61dec28850c3f07a68e69c19f554d9088318f62c6cd30fe1","segment_id":"environment.md:6d28a9f099e563d9","source_path":"environment.md","text_hash":"6d28a9f099e563d9322b5bcdea9ff98af87e9c213c2222462ae738d2fb27ecbe","text":" block\n\nTwo equivalent ways to set inline env vars (both are non-overriding):","translated":" 块\n\n设置内联环境变量的两种等效方式(均为非覆盖式):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:16:44Z"} -{"cache_key":"8334186d1a61e931ed7b3905a26e470159f86593819124c5626df7a012733ee9","segment_id":"environment.md:frontmatter:summary","source_path":"environment.md:frontmatter:summary","text_hash":"78351223e7068721146d2de022fdf440c2866b2ee02fbbb50bf64369b999820b","text":"Where OpenClaw loads environment variables and the precedence order","translated":"其中 OpenClaw 加载 环境变量 及优先级顺序","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:56:59Z"} -{"cache_key":"833685db37cf96f2342238018bd6a4a6e7812d1794a7389dc1e349917b140f50","segment_id":"environment.md:668e5590b5bb9990","source_path":"environment.md","text_hash":"668e5590b5bb9990eeb25bf657f7d17281a4c613ee4442036787cd4b2efd22bb","text":"If the config file is missing entirely, step 4 is skipped; shell import still runs if enabled.","translated":"如果配置文件完全缺失,则跳过第 4 步;如果启用了 shell 导入,它仍会运行。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:19:28Z"} -{"cache_key":"834bd8857aa5700b0ec493efb4625ba88e34c885a8254b13f6c44a75589021d2","segment_id":"index.md:9bcda844990ec646","source_path":"index.md","text_hash":"9bcda844990ec646b3b6ee63cbdf10f70b0403727dea3b5ab601ca55e3949db9","text":" for node WebViews; see ","translated":" 用于节点 WebView;请参阅 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:29:13Z"} -{"cache_key":"83b7bced23d11aea94d1b763db57522ce0fca9820fb5edcea109120ea955a46f","segment_id":"start/wizard.md:5cb343f0285df34e","source_path":"start/wizard.md","text_hash":"5cb343f0285df34e67f5215d063e3b53693dd3cdf65667f7d5c142f5db73f7a1","text":"Fastest first chat: open the Control UI (no channel setup needed). Run","translated":"最快的首次对话方式:打开 Control UI(无需设置渠道)。运行","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:39:07Z"} -{"cache_key":"83c4839813667de3bf67a2a050db86be067fa4bef9fa310d5baab23f82ebcfa5","segment_id":"index.md:aaa095329e21d86e","source_path":"index.md","text_hash":"aaa095329e21d86e24e8bec91bc001f7983d73a7a04c75646c0256448dac30ef","text":" — The space lobster who demanded a better name","translated":" — 那只要求取个更好名字的太空龙虾","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:06:09Z"} -{"cache_key":"841cbd53411a6aaa5b137d69b3feaa95de0ed0148f868dabd711786998382edb","segment_id":"index.md:6d6577cb1c128ac1","source_path":"index.md","text_hash":"6d6577cb1c128ac18a286d3c352755d1a265b1e3a03eded8885532c3f36e32ed","text":"Mario Zechner","translated":"Mario Zechner","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:33:36Z"} -{"cache_key":"842bdf601f288020ae8179f0d66f0d2d8a43112867d80efbb045acadbcd6626e","segment_id":"start/wizard.md:05bf3242414a96a7","source_path":"start/wizard.md","text_hash":"05bf3242414a96a764b57402b44b5852bbb0612ca017a9716e6364d47ecb0924","text":"Daemon install (LaunchAgent / systemd user unit)","translated":"守护进程安装(LaunchAgent / systemd 用户单元)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:40:28Z"} -{"cache_key":"843740adfdf616f31568963f47687512da2b547b5149b531b829112c01b57f5c","segment_id":"index.md:0b7e778664921066","source_path":"index.md","text_hash":"0b7e77866492106632e98e7718a8e1e89e8cb0ee3f44c1572dfd9e54845023de","text":"/concepts/streaming","translated":"/concepts/streaming","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:50:13Z"} -{"cache_key":"8469bc6049cc48c182c013768278e481d3ab521929f95cd267c63d0dc4bb5d38","segment_id":"index.md:5afbb1c887f6d850","source_path":"index.md","text_hash":"5afbb1c887f6d8501dba36cd2113d8f8b6ce6fa711a0d3e7efdc66f170abd2c2","text":"Cron jobs","translated":"定时任务","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:53:48Z"} -{"cache_key":"847900fa4457fc7d1dc92fa9688360479e337cedd03a88db8cd9f03cee8dbe51","segment_id":"index.md:99260acc29f71e4b","source_path":"index.md","text_hash":"99260acc29f71e4baeb36805a1fdbd2c17254b57c8e5a9cba29ee56518832397","text":" — Route provider accounts/peers to isolated agents (workspace + per-agent sessions)","translated":" —— 将 提供商 账户/对等方路由到隔离的 智能体(工作区 + 按 智能体 的 会话)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:50:21Z"} -{"cache_key":"84c686db4b4fc386bbb4efa35c380073babbc5fb4b2eb1ba3a8213a5f135a5bc","segment_id":"start/getting-started.md:161660030aa6c9e3","source_path":"start/getting-started.md","text_hash":"161660030aa6c9e32470cc1c023dab32dc748d80b0e61882b368cb775d12638e","text":" → ","translated":" → ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:34:27Z"} -{"cache_key":"84e200d4c823802e34a99da4faa8328d0e250aca858b0a32cc08e3ae12e0cc0e","segment_id":"start/wizard.md:e4442451c634e0db","source_path":"start/wizard.md","text_hash":"e4442451c634e0db2db0fae78725becbeafd567302e3ecbfeb5ccdc5887d29be","text":" from GitHub releases:","translated":" (从 GitHub 发布版本):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:47:56Z"} -{"cache_key":"85040674d9e2db6adb1ebb8c6215e72171d213a9dac8bd3c6bcb438178adc88b","segment_id":"index.md:0a4a282eda1af348","source_path":"index.md","text_hash":"0a4a282eda1af34874b588bce628b76331fbe907de07b57d39afdedccac2ba14","text":" http://127.0.0.1:18789/ (or http://localhost:18789/)","translated":" http://127.0.0.1:18789/(或 http://localhost:18789/)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:28:15Z"} -{"cache_key":"85cb0b7ed6991128b9fe65b7b103c5f32da742641cb24ffc1a3469002a2bcad6","segment_id":"start/getting-started.md:e24d86fa815827a4","source_path":"start/getting-started.md","text_hash":"e24d86fa815827a4dc5b8b22711caaf036427796512a74167ebaf615c495f9f8","text":"Telegram / Discord / others","translated":"Telegram / Discord / 其他","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:37:17Z"} -{"cache_key":"85e39779810391375b7241f2d999fbd5e6b2830ddf226a9ad561132c40d4fd47","segment_id":"start/wizard.md:21b111cbfe6e8fca","source_path":"start/wizard.md","text_hash":"21b111cbfe6e8fca2d181c43f53ad548b22e38aca955b9824706a504b0a07a2d","text":"Default ","translated":"默认 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:43:41Z"} -{"cache_key":"85fdea7998dfe111261588f998c93aceaa9b04ba174bc16bd188e3bbd8f3228a","segment_id":"environment.md:668e5590b5bb9990","source_path":"environment.md","text_hash":"668e5590b5bb9990eeb25bf657f7d17281a4c613ee4442036787cd4b2efd22bb","text":"If the config file is missing entirely, step 4 is skipped; shell import still runs if enabled.","translated":"如果配置文件完全缺失,则跳过第 4 步;如果已启用,shell 导入仍会运行。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:12:40Z"} -{"cache_key":"860ba81b08f80660c665451acb02944ed74cb09a277f70d86255a6bf6bb7b88f","segment_id":"index.md:f3047ab42a6a5bbf","source_path":"index.md","text_hash":"f3047ab42a6a5bbf164106356fa823ecada895064120c4e5a30e1f632741cc5f","text":"Web surfaces","translated":"Web 界面","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:59:35Z"} -{"cache_key":"861a980448662449d74017ee4183d74a8e54a772a24fefc3044d1dfffb8ca634","segment_id":"index.md:b332c3492d5eb10a","source_path":"index.md","text_hash":"b332c3492d5eb10a118eb6d8b0dcd689bc2477ce2ae16b303753b942b54377bc","text":"Configuration","translated":"配置","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:32:00Z"} -{"cache_key":"861c7c8e316f48bf6b0d22e066b9c76410b8d9b77a12fc7f28213c04ed5f1c30","segment_id":"help/index.md:frontmatter:read_when:0","source_path":"help/index.md:frontmatter:read_when:0","text_hash":"ee0615553374970664b58ebd8e5d0ebc9bc8a5f03387671afbfd0096b390aa9b","text":"You’re new and want the “what do I click/run” guide","translated":"你是新手,想要一份\"我该点什么/运行什么\"的指南","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:24:28Z"} -{"cache_key":"86305fdb36e4df79c4c5037e3d1a5117141feccd13f65dcf1b0fd366ec22c4bc","segment_id":"index.md:5583785669449fc8","source_path":"index.md","text_hash":"5583785669449fc81a8037458c908c11a8f345c21c28f7f3a95de742bd52199a","text":"Media Support","translated":"媒体支持","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:50:49Z"} -{"cache_key":"868f3087136c15f059a91fe3ef5ac07ed6a0791e0a14a9740fb959acda8fad28","segment_id":"help/index.md:569ca49f4aaf7846","source_path":"help/index.md","text_hash":"569ca49f4aaf7846e952c1d4aeca72febd0b79fa1c4f9db08fd3127551218572","text":"Install","translated":"安装","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:11:16Z"} -{"cache_key":"869c590a57afd29927a22cac794ad6fc2f4e464aebb30c0e06b85b2cc3be5bb4","segment_id":"start/wizard.md:69ba7688eac60797","source_path":"start/wizard.md","text_hash":"69ba7688eac60797286dd7bead426bcbd3405746cb3465ac44c997955bd95df2","text":"Config + credentials + sessions","translated":"配置 + 凭据 + 会话","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:41:41Z"} -{"cache_key":"86a9e0f21a152909fc53f77e35bd973583e2a5dc7cfe3837cf2c783d037a7a93","segment_id":"environment.md:frontmatter:read_when:1","source_path":"environment.md:frontmatter:read_when:1","text_hash":"a3a2d99a99de98220c8e0296d6f4e4b2a34024916bd2379d1b3b9179c8fae46f","text":"You are debugging missing API keys in the Gateway","translated":"你正在调试 Gateway 中缺失的 API 密钥","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:45:12Z"} -{"cache_key":"86ece013a8e276db3f513a6afa8df20c009f53d42bb3dc02b84dcb39f8697ffe","segment_id":"help/index.md:b79cac926e0b2e34","source_path":"help/index.md","text_hash":"b79cac926e0b2e347e72cc91d5174037c9e17ae7733fd7bdb570f71b10cd7bfc","text":"Help","translated":"帮助","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:19:01Z"} -{"cache_key":"87897ef5886d39e96385313c32bc5580aff102c89185c03679de8a223d712e01","segment_id":"index.md:a194ca16424ddd17","source_path":"index.md","text_hash":"a194ca16424ddd17dacc45f1cbd7d0e41376d8955a7b6d02bc38c295cedd04e4","text":"RPC adapters","translated":"RPC 适配器","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:52:49Z"} -{"cache_key":"879702cd9a8560ed1fa329cb78a77dcbe84c66bfa29f3a1460261552dca9dfb2","segment_id":"index.md:66d0f523a379b2de","source_path":"index.md","text_hash":"66d0f523a379b2de6f8d5fba3a817ebc395f7bcaa54cc132ca9dfa665d1e9378","text":"Skills","translated":"技能","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:52:42Z"} -{"cache_key":"87b42c17fb63bfdcd059198572016f6b8b3cd297aaa991c4c1dea8723a68fbfe","segment_id":"index.md:9abe8e9025013e78","source_path":"index.md","text_hash":"9abe8e9025013e78a6bf2913f8c20ee43134ad001ce29ced89e2af9c07096d8f","text":"Media: images","translated":"媒体:图片","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:32:48Z"} -{"cache_key":"87d80d180c9d4789c20123b3bc177f99c4d00909f70c6fe3c209c078bdcafdce","segment_id":"index.md:1074116f823ec992","source_path":"index.md","text_hash":"1074116f823ec992e76d7e8be19d3235fec5ddd7020562b06e7242e410174686","text":"Remote use","translated":"远程使用","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:00:36Z"} -{"cache_key":"87f8e99a729beb8e55fdef7ca70ebe4b11f4ff1c5dbbfcb3e654429198c6bf0f","segment_id":"help/index.md:729bc562eec2658b","source_path":"help/index.md","text_hash":"729bc562eec2658bd11ffdd522fe5277177dc73e86eaca7baac0b472a4d8f8b2","text":"If you’re looking for conceptual questions (not “something broke”):","translated":"如果你在寻找概念性问题(不是\"出了故障\"):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:19:05Z"} -{"cache_key":"88ab429b0aa43b0cfc93a1fc0e69576a2acbf64d0cd407fc1028488a0c27c9fc","segment_id":"index.md:fdef9f917ee2f72f","source_path":"index.md","text_hash":"fdef9f917ee2f72fbd5c08b709272d28a2ae7ad8787c7d3b973063f0ebeeff7a","text":" to update the gateway service entrypoint.","translated":" 以更新网关服务入口点。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:03:03Z"} -{"cache_key":"88d02146dbe2246af19afc2deecbb627547528cd1bf8b9839d358e8987a88a99","segment_id":"index.md:9c870aa6e5e93270","source_path":"index.md","text_hash":"9c870aa6e5e93270170d5a81277ad3e623afe8d4efd186d3e28f3d2b646d52e6","text":"How it works","translated":"工作原理","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:59:42Z"} -{"cache_key":"88f63f39528cb8bcb530a350a6b610125dbf6ab7034c2509a772e2ec28ed9476","segment_id":"help/index.md:frontmatter:read_when:1","source_path":"help/index.md:frontmatter:read_when:1","text_hash":"857eafc389d179e83e21e46c10527fec40894fe064c63847ba06b946b7d5eb73","text":"Something broke and you want the fastest path to a fix","translated":"出了问题,你想找到最快的修复方法","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:15:10Z"} -{"cache_key":"8901c6c35445bae28f7f5cc6059387556bb67f591a89a7b3ff0d7b65dc1e85fd","segment_id":"environment.md:frontmatter:read_when:2","source_path":"environment.md:frontmatter:read_when:2","text_hash":"822b3d74ce16c1be19059fad4ca5bf7ae9327f58fa1ff4e75e78d5afa75c038f","text":"You are documenting provider auth or deployment environments","translated":"你正在编写提供商认证或部署环境的文档","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:11:47Z"} -{"cache_key":"8969e9b451aa17e28e3e2c32711b88094beda02a41bf0b2eb68d21aa8e84a63a","segment_id":"environment.md:3fe738a7ee6aaff5","source_path":"environment.md","text_hash":"3fe738a7ee6aaff51f099d9a8314510c99ced6a568eb38c67642cd43bb54eec0","text":" in the current working directory","translated":" 在当前工作目录中","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:45:40Z"} -{"cache_key":"89f351289752118559ed644ee4aaac84085f4b398cf7c7c80c25aa46429e85c4","segment_id":"index.md:82ba9b60b12da3ab","source_path":"index.md","text_hash":"82ba9b60b12da3ab4e7dbcb0d7d937214cff80c82268311423a6dc8c4bc09df5","text":"OpenClaw 🦞","translated":"OpenClaw 🦞","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:27:48Z"} -{"cache_key":"8a0eac24b3b1941f1a90900aecb71bf1530e8645519ce53d22168a6ee01e583d","segment_id":"start/wizard.md:41ed52921661c7f0","source_path":"start/wizard.md","text_hash":"41ed52921661c7f0d68d92511589cc9d7aaeab2b5db49fb27f0be336cbfdb7df","text":"Gateway","translated":"Gateway","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:43:54Z"} -{"cache_key":"8a1775a68c3ab8aefdffaadd63ebe7cbc027e198b618fb72bb9a1b16edd08d3a","segment_id":"index.md:9f4d843a5d04e23b","source_path":"index.md","text_hash":"9f4d843a5d04e23b22eb79b3bfa0fbad70ede435ddb5d047e7d77e830efa6019","text":" — Bot token + WebSocket events","translated":" —— 机器人令牌 + WebSocket 事件","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:49:49Z"} -{"cache_key":"8a32ea50ef9d28ef09b557c184b4db0a493dd539261fde0ce5260a60bb881904","segment_id":"index.md:4818a3f84331b702","source_path":"index.md","text_hash":"4818a3f84331b702815c94b4402067e09e9e2d27ebc1a79258df8315f2c8600b","text":"📎 ","translated":"📎 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:50:47Z"} -{"cache_key":"8a81f73e519177081d755623ff45ac47552fa513f5aaf9c77335ce2c329087f3","segment_id":"start/getting-started.md:524bf322c2034388","source_path":"start/getting-started.md","text_hash":"524bf322c2034388f76cd94c1c7834341cedfa09bc4a864676749a08b243416d","text":"model/auth (OAuth recommended)","translated":"模型/认证(推荐使用 OAuth)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:34:53Z"} -{"cache_key":"8a83aabc21a6b84ce7552d72a9bc0a7c2d99864c31350064cbd39564354421f1","segment_id":"index.md:9adcfa4aa10a4e8b","source_path":"index.md","text_hash":"9adcfa4aa10a4e8b991a72ccc45261cd64f296aed5b257e4caf9c87aff1290a0","text":" — Send and receive images, audio, documents","translated":" —— 发送和接收图片、音频、文档","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:50:51Z"} -{"cache_key":"8a984f774ac8874be4797ffddd21cbdddc9379fa6bc51121620fbe9395cd91cf","segment_id":"help/index.md:bfc5930cc2660330","source_path":"help/index.md","text_hash":"bfc5930cc2660330260afd407e98d86adaec0af48dd72b88dc33ef8e9066e2c9","text":"Install sanity (Node/npm/PATH):","translated":"安装完整性检查(Node/npm/PATH):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:15:18Z"} -{"cache_key":"8ac55f265f3496db43dce513fde21c137826476afcff2ed1b3e86e613ff28b3c","segment_id":"start/wizard.md:44dab6c89cc5e6d9","source_path":"start/wizard.md","text_hash":"44dab6c89cc5e6d9a3112d3cb45c19cd16c3a9963082276015d4b624e5e67782","text":"Some channels are delivered as plugins. When you pick one during onboarding, the wizard\nwill prompt to install it (npm or a local path) before it can be configured.","translated":"部分渠道以插件形式提供。当您在上手引导期间选择某个渠道时,向导会提示先安装它(通过 npm 或本地路径),然后才能进行配置。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:48:57Z"} -{"cache_key":"8b2c90beec3893be65468e57df762fcbc285a9772042200eee3d4bf8f7ff9c0d","segment_id":"index.md:96be070791b7d545","source_path":"index.md","text_hash":"96be070791b7d545dc75084e59059d2170eed247350b351db5330fbd947e4be6","text":"👥 ","translated":"👥 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:30:29Z"} -{"cache_key":"8b921a960a8b92bc6210c2e228fe886cd93000a5a77f1cb5ac97233de2c4f965","segment_id":"index.md:fb87b8dba88b3edc","source_path":"index.md","text_hash":"fb87b8dba88b3edced028edfe2efa5f884ab2639c1b26efa290ccd0469454d25","text":"Slash commands","translated":"斜杠命令","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:04:03Z"} -{"cache_key":"8bdf5dbe57d7db52bbddf554ea4eed6bfa8e92209d24733e8f57355beba0ecb9","segment_id":"index.md:74926756385b8442","source_path":"index.md","text_hash":"74926756385b844294a215b2830576e3b2e93b84c5a8c8112b3816c5960f3022","text":" — DMs + guild channels via channels.discord.js","translated":" — 通过 channels.discord.js 支持私聊和服务器频道","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:29:43Z"} -{"cache_key":"8c2486104e938755d3aef3b6c16fe1e13db8efe500c6559d60fc003ad1acd319","segment_id":"environment.md:cf3f9ba035da9f09","source_path":"environment.md","text_hash":"cf3f9ba035da9f09202ba669adca3109148811ef31d484cc2efa1ff50a1621b1","text":" (what the Gateway process already has from the parent shell/daemon).","translated":" (Gateway 进程从父 shell/守护进程继承的已有环境变量)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:40:33Z"} -{"cache_key":"8c7d9724c491e8ae94a27ab7b11fb116922f6f3b5d61664aae76ab5fd88d2f0a","segment_id":"index.md:5cf9ea2e20780551","source_path":"index.md","text_hash":"5cf9ea2e2078055129b38cfbc394142ca6ca41556bd6e31cbd527425647c1d1e","text":"One Gateway per host (recommended)","translated":"每台主机一个 Gateway(推荐)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:28:40Z"} -{"cache_key":"8c84e9f488f94b32acf52cfc44019211a043af87a9c65b7305825db1a67fa421","segment_id":"environment.md:fefb88f0e707cf40","source_path":"environment.md","text_hash":"fefb88f0e707cf40854f27e99b81ac3cb08f0249f47ee200a80e6a5c16841b99","text":"Two equivalent ways to set inline env vars (both are","translated":"两种等效的内联环境变量设置方式(两者都是","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:46:29Z"} -{"cache_key":"8c9152b9d2d5dae37266587767f1afb6c33e8f471714b92f4a8cd7f91787afc2","segment_id":"environment.md:a258b30f88c30650","source_path":"environment.md","text_hash":"a258b30f88c30650e73073d5bdde5cfcc6987100ae62d37789e5c46a0d85b7c6","text":"Global ","translated":"全局 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:57:29Z"} -{"cache_key":"8cac6173616db6cb8fe86a594893041cbcf322a1e0faeaf01528aeac4ca00759","segment_id":"help/index.md:6201111b83a0cb5b","source_path":"help/index.md","text_hash":"6201111b83a0cb5b0922cb37cc442b9a40e24e3b1ce100a4bb204f4c63fd2ac0","text":" and ","translated":" 和 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:11:24Z"} -{"cache_key":"8ceb849e69710631dc80f2eaa57838541deab798818efa76d69b2923f4ab9815","segment_id":"start/wizard.md:b06d5b13b5a1b910","source_path":"start/wizard.md","text_hash":"b06d5b13b5a1b91014ecd8016bec44f379a5269376b602326c42a399004c8491","text":": run ","translated":":运行 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:42:04Z"} -{"cache_key":"8d99559630736c2a11c549fcfed24d9ff242793fbac2956e758c3ac5b9f5fe7d","segment_id":"start/wizard.md:361f035d290095c6","source_path":"start/wizard.md","text_hash":"361f035d290095c6a1a00757c6ff6d5208dcb600fd6dd4b130bb42047fe3f08b","text":"18789","translated":"18789","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:39:51Z"} -{"cache_key":"8dac40cb3bfd86d7cfbe99acdb4641cd63d389ea15bd6bfa948a1c734204e925","segment_id":"index.md:496bcd8a502babde","source_path":"index.md","text_hash":"496bcd8a502babde0470e7105dfed7ba95bbc3193b7c6ba196b3ed0997e84294","text":"Voice notes","translated":"语音消息","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:02:21Z"} -{"cache_key":"8db9b500f11e5390f21a454ab89a4193991966e384e452f49e968afb9e280a69","segment_id":"environment.md:453c14128fbfb5f6","source_path":"environment.md","text_hash":"453c14128fbfb5f6757511557132a1dbb3bcbf243267630bfec49db8518c7780","text":"Env var substitution in config","translated":"配置中的环境变量替换","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:12:56Z"} -{"cache_key":"8e5f833a1ebf122c23edb718754bcd685bfc77afbbac60eb1a2aff7234b60a27","segment_id":"index.md:a10f6ed8c1ddbc10","source_path":"index.md","text_hash":"a10f6ed8c1ddbc10d3528db7f7b6921c1dd5a5e78aa191ff017bf29ce2d26449","text":"⏱️ ","translated":"⏱️ ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:30:00Z"} -{"cache_key":"8e641ec66f3ae7edd18dd57ec47a5e61ff6ec0e585e0cbb966f09ebe803ed02f","segment_id":"index.md:ba5ec51d07a4ac0e","source_path":"index.md","text_hash":"ba5ec51d07a4ac0e951608704431d59a02b21a4e951acc10505a8dc407c501ee","text":")","translated":")","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:59:17Z"} -{"cache_key":"8e67881037945410ab1d9a08a670ff5947dfbd577da1a7c2d5c9fee74987194b","segment_id":"index.md:0eb95fb6244c03f1","source_path":"index.md","text_hash":"0eb95fb6244c03f1ccca696718a06766485c231347bf382424fb273145472355","text":"Quick start","translated":"快速开始","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:31:06Z"} -{"cache_key":"8e8e8756585cb2edf84845f3f8a7dfe14781b3b4acfe7ed6ef1d635aac3f4fef","segment_id":"start/wizard.md:1afc5c1f69b6ae2d","source_path":"start/wizard.md","text_hash":"1afc5c1f69b6ae2d91519459b548f196ead4eddba5882c0d3eb53032c35deee8","text":" so the Gateway stays up after logout.","translated":" 启用驻留,以便在注销后 Gateway 保持运行。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:45:22Z"} -{"cache_key":"8eb25fbece39afeec23d63e391e40f5e81d561838787d905058492ecc5a8b9df","segment_id":"index.md:15cd10b29ec14516","source_path":"index.md","text_hash":"15cd10b29ec1451670b80eae4b381e26e84fa8bdb3e8bea90ec943532411b189","text":" (@Hyaxia, ","translated":" (@Hyaxia, ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:33:51Z"} -{"cache_key":"8ebe2d251018b02257f263d2a16eff5974332bc1187abf5526d990777596d622","segment_id":"index.md:cf9f12b2c24ada73","source_path":"index.md","text_hash":"cf9f12b2c24ada73bb0474c0251333f65e6d5d50e56e605bdb264ff32ad0a588","text":"Config lives at ","translated":"配置文件位于 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:03:23Z"} -{"cache_key":"8f284740058a03d3e3f1eb5dce6f13b548a48866fa3b7bba381f06b93bc6fd88","segment_id":"environment.md:c2d7247c8acb83a5","source_path":"environment.md","text_hash":"c2d7247c8acb83a5a020458fa836c2445922b51513dbdbf154ab5f7656cb04e9","text":"; does not override).","translated":";不覆盖已有值)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:12:17Z"} -{"cache_key":"8f48c119b19172331a14c91c05b056ff50c806eac677b102b4ab6803687c663c","segment_id":"start/wizard.md:a274352ee48cdb04","source_path":"start/wizard.md","text_hash":"a274352ee48cdb048273ff9ca060d9f76b541a3df3e7d07cf07e4e8379475bb5","text":": on macOS the wizard checks Keychain item \"Claude Code-credentials\" (choose \"Always Allow\" so launchd starts don't block); on Linux/Windows it reuses ","translated":":在 macOS 上,向导会检查钥匙串项 \"Claude Code-credentials\"(请选择\"始终允许\"以避免 launchd 启动时被阻止);在 Linux/Windows 上,它会复用 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:41:57Z"} -{"cache_key":"8f638ba10519facb8bb0ca4a86605815fdb2358aaaca87ffe1e56dd9c59c18f9","segment_id":"start/getting-started.md:de10e3b2385f09a3","source_path":"start/getting-started.md","text_hash":"de10e3b2385f09a36e17e5e94d04d1b40b50fb1ea489a406db4c032d69683001","text":"pairing defaults (secure DMs)","translated":"配对默认设置(安全私信)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:34:58Z"} -{"cache_key":"8f85617fbbf28b9de5f1702889a5b731fb69ca71be4e7baac5388814f0946226","segment_id":"index.md:297d5c673f5439aa","source_path":"index.md","text_hash":"297d5c673f5439aa31dca3bbc965cb657a89a643803997257defb3baef870f89","text":"Open the dashboard (local Gateway):","translated":"打开仪表盘(本地 Gateway):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:28:13Z"} -{"cache_key":"8f8728437e28a03d18e6b0dc21669ea86fcfd4ae6b89c60e7442baf1975436b7","segment_id":"index.md:aaa095329e21d86e","source_path":"index.md","text_hash":"aaa095329e21d86e24e8bec91bc001f7983d73a7a04c75646c0256448dac30ef","text":" — The space lobster who demanded a better name","translated":" — 那只要求取个更好名字的太空龙虾","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:33:44Z"} -{"cache_key":"8fd4298d2a5388956240bc02b51a7b6c227ce0a1397da46538c00af6a564e8c1","segment_id":"index.md:c3af076f92c5ed8d","source_path":"index.md","text_hash":"c3af076f92c5ed8dcb0d0b0d36dd120bc31b68264efea96cf8019ca19f1c13a3","text":"Troubleshooting","translated":"故障排除","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:53:59Z"} -{"cache_key":"9031f7b1b0d90eab21e7d9029bd4eb11f1ed5a9e0c9c5638082744c233c92e43","segment_id":"index.md:83f4fc80f6b452f7","source_path":"index.md","text_hash":"83f4fc80f6b452f7cdf426f6b87f08346d7a2d9c74a0fb62815dce2bfddacf63","text":" — A space lobster, probably","translated":" —— 大概是一只太空龙虾说的","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:58:52Z"} -{"cache_key":"907c8f6a0763b469cdeb4bb64835922245714239ced9d3ed569f49fe760c3a54","segment_id":"index.md:9adcfa4aa10a4e8b","source_path":"index.md","text_hash":"9adcfa4aa10a4e8b991a72ccc45261cd64f296aed5b257e4caf9c87aff1290a0","text":" — Send and receive images, audio, documents","translated":" — 发送和接收图片、音频、文档","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:02:17Z"} -{"cache_key":"907e9eee8caead29b80ff841b945f1df714df32948bfa54c56cef29685b1bc00","segment_id":"index.md:b5ccaf9b1449291c","source_path":"index.md","text_hash":"b5ccaf9b1449291c92f855b8318aeb2880a9aa1a75272d17f55cf646071b3eae","text":"Gmail hooks (Pub/Sub)","translated":"Gmail 钩子(Pub/Sub)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:05:26Z"} -{"cache_key":"90c8b075eff65b5f916b6ffcb3f8305a95bb6c162e9f8cac12e7fecc3f2409b0","segment_id":"index.md:25d853ca04397b6a","source_path":"index.md","text_hash":"25d853ca04397b6ae248036d4d029d19d94a4981290387e5c29ef61b0eca9021","text":"Media: audio","translated":"媒体:音频","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:53:28Z"} -{"cache_key":"90eac59e70026b85f505d0d2bfe603ba2880721e5abafedd52bdeeaf21def2f5","segment_id":"start/getting-started.md:5ead037957578a63","source_path":"start/getting-started.md","text_hash":"5ead037957578a63002170be037d777c909bad991ab7ea1c606b55ddfa60ccad","text":"Alternative (global install):","translated":"替代方式(全局安装):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:35:53Z"} -{"cache_key":"917e181e50cd2d2f7596153167295a7294816bb3a66714820a4e205f06859a61","segment_id":"index.md:c0aa8fcb6528510a","source_path":"index.md","text_hash":"c0aa8fcb6528510aea46361e8c871d88340063926a8dfdd4ba849b6190dec713","text":": it is the only process allowed to own the WhatsApp Web session. If you need a rescue bot or strict isolation, run multiple gateways with isolated profiles and ports; see ","translated":":它是唯一被允许拥有 WhatsApp Web 会话 的进程。如果您需要救援机器人或严格隔离,请使用隔离的配置文件和端口运行多个网关;参见 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:48:33Z"} -{"cache_key":"91c6437ace26aa4f27b7bc4023db93c6cc1db80da1ebc4aea9d791e86fd125b5","segment_id":"start/wizard.md:67b696468610b879","source_path":"start/wizard.md","text_hash":"67b696468610b879ed7f224dbf6b0861f27e39d20454cb9d7af1ec52d3e5eeaa","text":"Dashboard","translated":"仪表盘","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:39:10Z"} -{"cache_key":"91d1558d4947a913141ec4bc1a247285174da3d016fcc62ed430c690fcad7dd3","segment_id":"index.md:f0e2018271f51504","source_path":"index.md","text_hash":"f0e2018271f515041084c8189f297236abe18f9ec77edad1a61c5413310bbd9e","text":"🖥️ ","translated":"🖥️ ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:51:00Z"} -{"cache_key":"91ff2d0607cc2fb36dcd28db903c8cd10df497a6ee53085072c1fab662322443","segment_id":"environment.md:f6b2ffe1d0d5f521","source_path":"environment.md","text_hash":"f6b2ffe1d0d5f521b76cabc67d6e96da2b1170eef8086d530558e9906a7f092d","text":"Models overview","translated":"模型 概述","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:58:36Z"} -{"cache_key":"92f138d40ad656de1f7508a72408aa280eb1010d096114a30af97085d0bfa447","segment_id":"start/wizard.md:42db531f91673e36","source_path":"start/wizard.md","text_hash":"42db531f91673e36e120292f33152cd0e1e53087f5668f4fec8e519809ee8d85","text":"macOS: LaunchAgent","translated":"macOS:LaunchAgent","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:45:10Z"} -{"cache_key":"93114bd8806e7d10e3c22b3415d23eec041357c24c8f6dc651d62cacc41ad375","segment_id":"start/getting-started.md:69c1cae4e20f3b2b","source_path":"start/getting-started.md","text_hash":"69c1cae4e20f3b2b4d3b3dd3ea7636d8faed8460af512aa7a7d3a3c09696f5fc","text":" Bun has known issues with these\nchannels. If you use WhatsApp or Telegram, run the Gateway with ","translated":" Bun 在这些渠道上存在已知问题。如果您使用 WhatsApp 或 Telegram,请使用 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:37:03Z"} -{"cache_key":"9332e0a6cbeffb7af77e26039be6a3fb42905022a7775699a3ff6aa4cd6bb862","segment_id":"start/wizard.md:5e52bafa51b66711","source_path":"start/wizard.md","text_hash":"5e52bafa51b667115904e942882f5aaf55262059621f3927b0d5699e08512c56","text":"DM security: default is pairing. First DM sends a code; approve via ","translated":"私信安全:默认为配对模式。首次私信会发送一个验证码;通过 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:45:02Z"} -{"cache_key":"93a02264b644d55b3fd01345f4e180207ab2e42e653686393c45e736ef355f86","segment_id":"environment.md:baa5be7f6320780b","source_path":"environment.md","text_hash":"baa5be7f6320780bd7bb7b7ddbb8cd1ffb26ccf7d94d363350668c50aedcf95f","text":" (applied only if missing).","translated":" (仅在缺失时应用)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:12:27Z"} -{"cache_key":"93e072d0402b8e5c6f32b9aabff58f378e0ceaf8815886e0b5a873fad83f9e36","segment_id":"environment.md:ab5aec4424cf678d","source_path":"environment.md","text_hash":"ab5aec4424cf678dcfb1ad3d2c2929c1e0b2b1ff61b82b961ada48ad033367b4","text":" (dotenv default; does not override).","translated":" (dotenv 默认行为;不会覆盖)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:16:11Z"} -{"cache_key":"93e5b6f997f3fd1199e507267858a37604727435b4fbbe418b39b953e7102fa6","segment_id":"environment.md:a258b30f88c30650","source_path":"environment.md","text_hash":"a258b30f88c30650e73073d5bdde5cfcc6987100ae62d37789e5c46a0d85b7c6","text":"Global ","translated":"全局 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:16:13Z"} -{"cache_key":"93ed507f9f7ad50ae11b95d49a6d547ac605a4bb966e1d9800da110bf2f85ff6","segment_id":"index.md:5583785669449fc8","source_path":"index.md","text_hash":"5583785669449fc81a8037458c908c11a8f345c21c28f7f3a95de742bd52199a","text":"Media Support","translated":"媒体支持","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:02:15Z"} -{"cache_key":"93f8819a09c973fae0ec648305d9c7e50ebee3771359b807686c605023b0b705","segment_id":"start/getting-started.md:eba2ed5d6cc0239d","source_path":"start/getting-started.md","text_hash":"eba2ed5d6cc0239dd5d0475d7ea57b120ff06eb1100c67f4cf713c3bb167f0a0","text":": Node (recommended; required for WhatsApp/Telegram). Bun is ","translated":":Node(推荐;WhatsApp/Telegram 必需)。Bun 为 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:36:19Z"} -{"cache_key":"94142aa44168395437a427ea262b059160a067ce005c11ceedb11f664eeec66e","segment_id":"start/wizard.md:3b0c9c223937ca13","source_path":"start/wizard.md","text_hash":"3b0c9c223937ca1308ceb186bb6cde91e811d0fefedcdf119c47e4d7cf58ec9a","text":"The Gateway exposes the wizard flow over RPC (","translated":"Gateway 通过 RPC 暴露向导流程(","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:47:43Z"} -{"cache_key":"9425ad8ab2d143b8d1558adc989c0b4416bf7144082034f0eb317527934e9936","segment_id":"index.md:d00eca1bae674280","source_path":"index.md","text_hash":"d00eca1bae6742803906ab42a831e8b5396d15b6573ea13c139ec31631208ec1","text":"Getting Started","translated":"快速入门","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:47:44Z"} -{"cache_key":"943aadb2a660dc3c85e9c5e1581741edaf50fd5be23f87f43f509ea1cdf162f0","segment_id":"index.md:add4778f9e60899d","source_path":"index.md","text_hash":"add4778f9e60899d7f44218483498c0baf7a0468154bc593a60747ee769c718c","text":"Android node","translated":"Android 节点","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:51:15Z"} -{"cache_key":"944265cf5c15fd945a3ceb8d27730e4839e78556bcec52463b9e83467b63df5f","segment_id":"start/wizard.md:d13b8b4ebb7477f9","source_path":"start/wizard.md","text_hash":"d13b8b4ebb7477f96681a90cc723fa7532710b595d8aba6f9a840f47299515fd","text":"macOS app onboarding: ","translated":"macOS 应用上手引导: ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:49:02Z"} -{"cache_key":"9481b030bd57d871b28ddf5316096a8cc8fc1fc317bd03e508a5d9239e3d93c5","segment_id":"start/getting-started.md:aeba20c4d03f146e","source_path":"start/getting-started.md","text_hash":"aeba20c4d03f146e967a7b748d8dee3859c34b0de6b6402851edd2ea08f9b09a","text":"5) DM safety (pairing approvals)","translated":"5)私信安全(配对审批)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:37:36Z"} -{"cache_key":"94a860b1ca3e9303574016027c1e3c170689f08e72bfd49acb315353f38633e8","segment_id":"environment.md:6f59001999ef7b71","source_path":"environment.md","text_hash":"6f59001999ef7b7128bab80d2034c419f3034497e05f69fbdf67f7b655cdc173","text":"Configuration: Env var substitution","translated":"配置:环境变量替换","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:17:05Z"} -{"cache_key":"94dd1cb509ea10b9fce425cbde9d55373457d90dd1ccd06ffe6362643b29cce2","segment_id":"start/wizard.md:4d5278f9b1f0b84c","source_path":"start/wizard.md","text_hash":"4d5278f9b1f0b84c0ad3f87ffbbd6ed35b2d223c2eb2f866682026b9d00e636d","text":"Token if the remote Gateway requires auth (recommended)","translated":"如果远程 Gateway 需要认证,则需提供令牌(推荐)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:46:23Z"} -{"cache_key":"950eae91a2abdbc1062fa1fb78a43a5b2883dda0245c95c5b02bceac0c1bfbc9","segment_id":"start/wizard.md:d6c64db69399b7ae","source_path":"start/wizard.md","text_hash":"d6c64db69399b7ae55bae206d47ae2efa6071a8e49f7cf1cd793d5994b5c2976","text":" to create a separate agent with its own workspace,\nsessions, and auth profiles. Running without ","translated":" 创建一个拥有独立工作区、会话和认证配置的单独智能体。不使用 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:46:50Z"} -{"cache_key":"958b1f38beeaa4e69690d389ff0191ffc0c3a9f97863ccc3c17a11097ec4c512","segment_id":"help/index.md:frontmatter:summary","source_path":"help/index.md:frontmatter:summary","text_hash":"aece82a2d540ab1a9a21c7b038127cae6e9db2149491564bb1856b6f8999f205","text":"Help hub: common fixes, install sanity, and where to look when something breaks","translated":"帮助中心:常见修复方法、安装完整性检查,以及出问题时该去哪里排查","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:44:23Z"} -{"cache_key":"95aad0f0fde6668b0ddea6c8212249c754769fe12739b8338c427f21860c8c7b","segment_id":"start/wizard.md:96192b2485e20320","source_path":"start/wizard.md","text_hash":"96192b2485e203201d62348dde087408b660e53f1df0fe65728759e16fac82bb","text":"Anthropic token (paste setup-token)","translated":"Anthropic 令牌(粘贴 setup-token)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:42:02Z"} -{"cache_key":"95b902406b2a68302a809d07f80f069bf4bac5d96fec5e55b4d76f4863492faf","segment_id":"environment.md:87e89abb4c1c551f","source_path":"environment.md","text_hash":"87e89abb4c1c551fe08d355d097f18b8de78edca5f556997085681662fce8eed","text":"Config ","translated":"配置 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:22:09Z"} -{"cache_key":"9638dca44bfb62a1f8c5f57d97d7c5636eb72da5567b4aa587b1cfd25592df76","segment_id":"environment.md:5b06ccc0bf4ede1b","source_path":"environment.md","text_hash":"5b06ccc0bf4ede1b00437d274b91d1a22cf7c0dc421b279348d9e333505fd264","text":" shell/daemon).","translated":" shell/守护进程中获得的内容)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:45:38Z"} -{"cache_key":"96fd19674037838fbf4ae365f247dad43d7d88f1eafecdd40527df1305639695","segment_id":"start/getting-started.md:73fc16837b0a6b13","source_path":"start/getting-started.md","text_hash":"73fc16837b0a6b13c23d4100f65a5e58460aac38cd66f884c5884b74a553f93a","text":"Control UI","translated":"控制界面","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:34:42Z"} -{"cache_key":"972e8e07b8a59145f0ed3291dc6b3f72f715e8299cd2078abe5588c64819a265","segment_id":"start/wizard.md:cdb4ee2aea69cc6a","source_path":"start/wizard.md","text_hash":"cdb4ee2aea69cc6a83331bbe96dc2caa9a299d21329efb0336fc02a82e1839a8","text":".","translated":"。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:39:13Z"} -{"cache_key":"975f5db56766c17a2b10af9d74333f67595593ac0a6513b908618c296cc4f605","segment_id":"index.md:3d8fed7c358b2ccf","source_path":"index.md","text_hash":"3d8fed7c358b2ccf225ee16857a0bb9b950fd414319749e0f6fff58c99fa5f22","text":"Subscription auth","translated":"订阅认证","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:01:51Z"} -{"cache_key":"9767a108ed5a174a4fd54d7d9c6213f6d294afe78f1a08a32f46eb624ee3c424","segment_id":"start/wizard.md:0ba91e19ba6d7b97","source_path":"start/wizard.md","text_hash":"0ba91e19ba6d7b970cdd563b05fd2c5f32751202c010c6c5adf4e40044023ed3","text":"Daemon install","translated":"守护进程安装","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:45:07Z"} -{"cache_key":"97b2e80f9008ad199527a8380466b8cb9e08c9f7bd52256f899ddd77b0c7f060","segment_id":"index.md:d00eca1bae674280","source_path":"index.md","text_hash":"d00eca1bae6742803906ab42a831e8b5396d15b6573ea13c139ec31631208ec1","text":"Getting Started","translated":"入门指南","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:59:07Z"} -{"cache_key":"97d83391e42ba7d3c2019f295e24a2981299c23dab40af45fab4ebd24ec272d7","segment_id":"environment.md:f15f5f9f4ef4d668","source_path":"environment.md","text_hash":"f15f5f9f4ef4d6688876c894f8eba251ed1db6eaf2209084028d43c9e76a8ba1","text":" (aka ","translated":" (即 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:16:20Z"} -{"cache_key":"97db9a094700b6722c7d627483ea58cf2263ec4a343062563c166cab96c324ca","segment_id":"index.md:fb87b8dba88b3edc","source_path":"index.md","text_hash":"fb87b8dba88b3edced028edfe2efa5f884ab2639c1b26efa290ccd0469454d25","text":"Slash commands","translated":"斜杠命令","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:52:29Z"} -{"cache_key":"97e64a162c2ed197b0aabfd9479dfc8f8a2be8f754e8170e70e152327f02fe5e","segment_id":"index.md:ee8b06871d5e335e","source_path":"index.md","text_hash":"ee8b06871d5e335e6e686f4e2ee9c9e6de5d389ece6636e0b5e654e0d4dd5b7e","text":"Control UI (browser)","translated":"控制界面(浏览器)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:04:43Z"} -{"cache_key":"983385317e20206e673531fcf329991718de8cd556d261974454832bf1222781","segment_id":"start/wizard.md:69f2e29c4496ba8d","source_path":"start/wizard.md","text_hash":"69f2e29c4496ba8d72788bdc5326ed5a74751c5b6e67115cd9a641ab49520997","text":" for a machine‑readable summary.","translated":" 以获取机器可读的摘要。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:47:17Z"} -{"cache_key":"986141bb219554b112fbaaa6ab9722f121b126f35d306b6398dae0e0d9d0fbcb","segment_id":"help/index.md:71095a6d42f5d9c2","source_path":"help/index.md","text_hash":"71095a6d42f5d9c2464a8e3f231fc53636d4ce0f9356b645d245874162ec07e2","text":"Gateway troubleshooting","translated":"Gateway 故障排除","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:44:46Z"} -{"cache_key":"9878b9b1f86218e0cf79b700dbb48857de209bbeb665a370642c6f25490ef0a3","segment_id":"start/wizard.md:d143f4078cca268c","source_path":"start/wizard.md","text_hash":"d143f4078cca268c9d6d569cbd06460e7ccc5af0a487c42e655ff1e1587b69fb","text":"Java 21","translated":"Java 21","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:48:12Z"} -{"cache_key":"98946553d70879085d2c248422199de47fecde1138aa7d97301da8415f5dd7a9","segment_id":"index.md:1074116f823ec992","source_path":"index.md","text_hash":"1074116f823ec992e76d7e8be19d3235fec5ddd7020562b06e7242e410174686","text":"Remote use","translated":"远程使用","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:29:19Z"} -{"cache_key":"98ccfc4d8aa8cbec392810df9c7ec0169da2595e35ee80950d991a26cee1dd8d","segment_id":"index.md:15cd10b29ec14516","source_path":"index.md","text_hash":"15cd10b29ec1451670b80eae4b381e26e84fa8bdb3e8bea90ec943532411b189","text":" (@Hyaxia, ","translated":" (@Hyaxia, ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:06:18Z"} -{"cache_key":"98d3cdf18a2db583a11d3eb9c8159f58997167f3983c6d8d64b80328eddb1b19","segment_id":"environment.md:aac7246f5e97142c","source_path":"environment.md","text_hash":"aac7246f5e97142c3f257b7d8b84976f10c29e1b89804bb9d3eb7c43cc03cb8e","text":"Environment variables","translated":"环境变量","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:11:49Z"} -{"cache_key":"98d73d2aa5399573ee48d8f16092a00b1d2eadae28493c02ff77524338175f2e","segment_id":"help/index.md:156597e2632411d1","source_path":"help/index.md","text_hash":"156597e2632411d1d5f634db15004072607ba45072a4e17dfa51790a37b6781f","text":"Gateway issues:","translated":"Gateway 问题:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:11:17Z"} -{"cache_key":"98ea6f91820d8c3481102e31672db08e7244fe1323f120f2fd8e61e89b94335d","segment_id":"index.md:9abe8e9025013e78","source_path":"index.md","text_hash":"9abe8e9025013e78a6bf2913f8c20ee43134ad001ce29ced89e2af9c07096d8f","text":"Media: images","translated":"媒体:图片","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:04:59Z"} -{"cache_key":"99777f2060729ab9a46bd52bd1987164d05761c7d99620992bc4cd4faaf79fdf","segment_id":"start/wizard.md:355a68267542db8b","source_path":"start/wizard.md","text_hash":"355a68267542db8b128049bdf8c3a39dda00fb9534370564874c04752aac8cd4","text":"which stores ","translated":"它会将 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:39:23Z"} -{"cache_key":"99d81c989d83fd644fbd647a5b6e583ccd7f640ef687e5a62751e2c1da8ba138","segment_id":"environment.md:e8c89c33e900bb9b","source_path":"environment.md","text_hash":"e8c89c33e900bb9b97f9c3b025f349fd3d91202293f3eff66c7fb4de7da892b6","text":" enabled.","translated":" 启用,shell 导入仍会运行。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:46:27Z"} -{"cache_key":"99ec1731cc9a06b32983cce57635d9b8df9ac1989c84ec9406049fba273998a4","segment_id":"environment.md:b1d6b91b67c2afa5","source_path":"environment.md","text_hash":"b1d6b91b67c2afa5e322988d9462638d354ddf8a1ef79dba987f815c22b4baee","text":" at ","translated":" 位于 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:12:12Z"} -{"cache_key":"99fddd046201ffe3b168ecf4215f0360e5fd2691e6027266a31bf76b2882ab71","segment_id":"start/wizard.md:a30cb0435098e376","source_path":"start/wizard.md","text_hash":"a30cb0435098e3761bf442f8085eb0abbc96b38de185a291bfc09c2c31540b51","text":"OpenAI Code (Codex) subscription (Codex CLI)","translated":"OpenAI Code (Codex) 订阅 (Codex CLI)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:42:09Z"} -{"cache_key":"99fe8d690ab356c552c582382dc05f3b5f26bedd3ccc366caa37f9ed80844213","segment_id":"start/wizard.md:a276c16f5217dcae","source_path":"start/wizard.md","text_hash":"a276c16f5217dcaede2670c6683c189989c1ef08d928f3cd563b92bf138a42ea","text":"Primary entrypoint:","translated":"主要入口:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:39:05Z"} -{"cache_key":"9a064e63d5ae22ffdcaf42bd4bf73604e1a87c082e0195db4c3d382a48d1276c","segment_id":"start/wizard.md:f56c761705123bae","source_path":"start/wizard.md","text_hash":"f56c761705123bae6b46571f53cc1d68b2da4a34b76aaf5c76a47438f42e2d8b","text":"/concepts/oauth","translated":"/concepts/oauth","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:43:36Z"} -{"cache_key":"9a6e0001bdbf4e254feed1fae82e1c51386ae1721b274ba14a89c4efe47ef794","segment_id":"environment.md:8d076464a84995bc","source_path":"environment.md","text_hash":"8d076464a84995bc095e934b0aa1e4419372f27cd71d033571e4dbba201ee5d8","text":"You can reference env vars directly in config string values using ","translated":"您可以使用以下方式在配置字符串值中直接引用 环境变量 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:58:18Z"} -{"cache_key":"9a6ff65f8974f826fabad2312ba3e0b54a6288f56782335b2ce21d931fe6b30a","segment_id":"start/getting-started.md:996c32b35f2182a9","source_path":"start/getting-started.md","text_hash":"996c32b35f2182a9c83815395113f92344269ebb4ab3525017c4cafaa3d1a8fd","text":"Providers","translated":"提供商","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:36:09Z"} -{"cache_key":"9a7478d471c30618239146c8b7adbd3669fd552a2fafba13cc6dc8b51c083243","segment_id":"index.md:a194ca16424ddd17","source_path":"index.md","text_hash":"a194ca16424ddd17dacc45f1cbd7d0e41376d8955a7b6d02bc38c295cedd04e4","text":"RPC adapters","translated":"RPC 适配器","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:04:25Z"} -{"cache_key":"9aaaeb76bc162fe216b19290b0978994ad43023335a81224b65bf7e4849ed5b6","segment_id":"index.md:frontmatter:summary","source_path":"index.md:frontmatter:summary","text_hash":"891b2aa093410f546b89f8cf1aa2b477ba958c2c06d2ae772e126d49786df061","text":"Top-level overview of OpenClaw, features, and purpose","translated":"OpenClaw 的顶层概述、功能和用途","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:47:18Z"} -{"cache_key":"9b0b553b6bb64b97bc340190fc4f10febadb5c4542122d2dea4661534f60b8b6","segment_id":"index.md:a10f6ed8c1ddbc10","source_path":"index.md","text_hash":"a10f6ed8c1ddbc10d3528db7f7b6921c1dd5a5e78aa191ff017bf29ce2d26449","text":"⏱️ ","translated":"⏱️ ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:50:04Z"} -{"cache_key":"9bb6f5ad39ff9d7aff3bca1fda6f474e19f25c0ffaaffaf3b19c924234d8c03a","segment_id":"index.md:f0d82ba647b4a33d","source_path":"index.md","text_hash":"f0d82ba647b4a33da3008927253f9bed21e380f54eab0608b1136de4cbff1286","text":"OpenClaw bridges WhatsApp (via WhatsApp Web / Baileys), Telegram (Bot API / grammY), Discord (Bot API / channels.discord.js), and iMessage (imsg CLI) to coding agents like ","translated":"OpenClaw 将 WhatsApp(通过 WhatsApp Web / Baileys)、Telegram(Bot API / grammY)、Discord(Bot API / 渠道.discord.js)和 iMessage(imsg CLI)桥接到编程 智能体,例如 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:47:31Z"} -{"cache_key":"9c03abf2c27129fa2698e7640a7b9add5936e84cf6d779d5f189bf9a27940aa6","segment_id":"index.md:310cc8cec6b20a30","source_path":"index.md","text_hash":"310cc8cec6b20a3003ffab12f5aade078a0e7a7d6a27ff166d62ab4c3a1ee23d","text":"If you ","translated":"如果你 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:03:25Z"} -{"cache_key":"9c11b2ec1c922e332f69000a8a937f0a2318b5356faa6278a7580cc49c3526d5","segment_id":"index.md:e47cdb55779aa06a","source_path":"index.md","text_hash":"e47cdb55779aa06a74ae994c998061bd9b7327f5f171c141caf2cf9f626bfe4b","text":"Peter Steinberger","translated":"Peter Steinberger","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:05:52Z"} -{"cache_key":"9c2360243508b8766d5d9813350a4c3153aeb8349b8ddf8f214ba33983b71f50","segment_id":"environment.md:1ec31258a6b45ea9","source_path":"environment.md","text_hash":"1ec31258a6b45ea903cd76f5b0190a99ab56afff6241a04f0681eb12b7a02484","text":"Env var equivalents:","translated":"环境变量 等效项:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:41:21Z"} -{"cache_key":"9c32d4d41cfaa814eacd6b9157f02b4ae0f9824751479280fd755479974d0695","segment_id":"index.md:ba5ec51d07a4ac0e","source_path":"index.md","text_hash":"ba5ec51d07a4ac0e951608704431d59a02b21a4e951acc10505a8dc407c501ee","text":")","translated":")","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:47:53Z"} -{"cache_key":"9c583361a5ae41c801a429bb6666f9a7b2ec6705ff7f1446fcf6281b40f2d5da","segment_id":"index.md:b332c3492d5eb10a","source_path":"index.md","text_hash":"b332c3492d5eb10a118eb6d8b0dcd689bc2477ce2ae16b303753b942b54377bc","text":"Configuration","translated":"配置","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:52:25Z"} -{"cache_key":"9c80e959862fdf2310d9719e9854c2424bb1e2fa55aabcde8b5caf060184bd85","segment_id":"start/wizard.md:197b37e09b318165","source_path":"start/wizard.md","text_hash":"197b37e09b3181655a23576caec90510709eacfecd39d7c55d9dca93cccaac9a","text":"npm / pnpm","translated":"npm / pnpm","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:45:49Z"} -{"cache_key":"9cbdb7ff14fdd8d015b7bcce3b3c0d48b1711e631ff86cae2c699684f8e4d143","segment_id":"start/wizard.md:c4b2896a2081395e","source_path":"start/wizard.md","text_hash":"c4b2896a2081395e282313d6683f07c81e3339ef8b9d2b5a299ea5b626a0998f","text":").","translated":")。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:41:17Z"} -{"cache_key":"9d44e8f510b7e2cf5ea7b08188a9c606937bc3db8c49e22d903828b34b8b04c1","segment_id":"start/wizard.md:19f53c2ccaf19969","source_path":"start/wizard.md","text_hash":"19f53c2ccaf199696e23d43812941e23fed0625900d2a551533304d6ca1980f6","text":" install or change anything on the remote host.","translated":" 在远程主机上安装或更改任何内容。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:40:40Z"} -{"cache_key":"9d7b3ce341253f712ecd8b4ca661ae0a6d85b1ee8e8ddf00b1ec02ca13d67237","segment_id":"help/index.md:569ca49f4aaf7846","source_path":"help/index.md","text_hash":"569ca49f4aaf7846e952c1d4aeca72febd0b79fa1c4f9db08fd3127551218572","text":"Install","translated":"安装","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:24:41Z"} -{"cache_key":"9db03f9dc7b789dbc3b4115e9b644cd22de2a63adeed02eb3b403a223d96b819","segment_id":"index.md:2b402c90e9b15d9c","source_path":"index.md","text_hash":"2b402c90e9b15d9c3ef65c432c4111108f54ee544cda5424db46f6ac974928e4","text":"🔐 ","translated":"🔐 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:30:14Z"} -{"cache_key":"9e0b7ed9895b612971d582145c837e95bfec8b051c6bccddd008d56dff778711","segment_id":"start/wizard.md:28d03596d24eeb4e","source_path":"start/wizard.md","text_hash":"28d03596d24eeb4eab2d6fe21ca1cb95be7cb1fa6f92933db05e2cc4f4cdfa06","text":"Skip","translated":"跳过","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:43:16Z"} -{"cache_key":"9e3a338fc3d6bce679ff4711d74e67c66877245b6ebd2c2a08f182a3a788dae6","segment_id":"start/getting-started.md:fd82e54418ec23cd","source_path":"start/getting-started.md","text_hash":"fd82e54418ec23cda00219878eaf76c3b37337b3dcb7560a941db6a0d2ec249e","text":": background install (launchd/systemd; WSL2 uses systemd)","translated":":后台安装(launchd/systemd;WSL2 使用 systemd)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:36:15Z"} -{"cache_key":"9e9a7f1005f6c8fc07bbbcded4f31d4f5564a378e2c4af541dbe1c1315165fa2","segment_id":"environment.md:b1d6b91b67c2afa5","source_path":"environment.md","text_hash":"b1d6b91b67c2afa5e322988d9462638d354ddf8a1ef79dba987f815c22b4baee","text":" at ","translated":" 位于 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:16:17Z"} -{"cache_key":"9f18072e77601b529d2c7b3ccba29effcace5e2ff848e7dc253434f6bbc94d39","segment_id":"start/getting-started.md:aa7fc908228260b4","source_path":"start/getting-started.md","text_hash":"aa7fc908228260b49b7837767419fdb1ab6be7f1a6930175fd00795cb1bd19fc","text":"Daemon","translated":"守护进程","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:36:13Z"} -{"cache_key":"9f321a29940495419d67ad4ba9b74534941c03957df80c8ddd22d40e2ed71d9c","segment_id":"environment.md:907940a35852447a","source_path":"environment.md","text_hash":"907940a35852447aad5f21c5a180d993ff31cfd5807b1352ed0c24eabe183465","text":"never override existing values","translated":"永远不覆盖已有的值","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:40:24Z"} -{"cache_key":"9f8debe489928579a649aee67a82d66af48bb993e545843a1ba939323fd52594","segment_id":"index.md:frontmatter:read_when:0","source_path":"index.md:frontmatter:read_when:0","text_hash":"08965a8ab25e66157009d1617fc167bcc2404fa0c0ca50b1e5e5750957be3b10","text":"Introducing OpenClaw to newcomers","translated":"向新用户介绍 OpenClaw","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:58:45Z"} -{"cache_key":"9f97e722bb08309f9f0490ef497ed3b8e9b5b00071c59dde29a3dd9471da6389","segment_id":"start/wizard.md:1bf470ef04c760ee","source_path":"start/wizard.md","text_hash":"1bf470ef04c760eeab30f680b75729f851e0045bd0c63a9f5fc56a8e3562b193","text":"Requires a logged-in user session; for headless, use a custom LaunchDaemon (not shipped).","translated":"需要已登录的用户会话;对于无头模式,请使用自定义 LaunchDaemon(未随附)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:45:14Z"} -{"cache_key":"9fd51e3ee4b19d2de868d2b4e8811d44509bdc07ae4fc5c9ad3f9cdffff41b4f","segment_id":"start/wizard.md:483a226d3bf316d4","source_path":"start/wizard.md","text_hash":"483a226d3bf316d46abacada3304da39fddb44f53ff4eb0cb627061a9ab44cab","text":" so launchd can read it.","translated":" 以便 launchd 可以读取。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:42:30Z"} -{"cache_key":"9fe10747da6ed5a4362c668166c1501624c52fc26255cde686f999a17e6186ca","segment_id":"environment.md:cda454f61dfcac70","source_path":"environment.md","text_hash":"cda454f61dfcac7007a9edc538f9f58cf38caa0652e253975979308162bccc53","text":"Gateway configuration","translated":"Gateway 配置","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:58:32Z"} -{"cache_key":"9fe3f448ea4b66ae71aaa710f4684b854e1de585336fa81f594ab40d91843b3c","segment_id":"help/index.md:bfc5930cc2660330","source_path":"help/index.md","text_hash":"bfc5930cc2660330260afd407e98d86adaec0af48dd72b88dc33ef8e9066e2c9","text":"Install sanity (Node/npm/PATH):","translated":"安装完整性检查(Node/npm/PATH):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:11:14Z"} -{"cache_key":"9ff4e3a77b7395b11a7ccb909093b21c475fe55afff74e5f7e5d2d8e6122b424","segment_id":"index.md:8fdfb6437318756c","source_path":"index.md","text_hash":"8fdfb6437318756c950bf2261538f06236e36040986891fa7b43452b987fb9f3","text":" — an AI, probably high on tokens","translated":" — 大概是一个嗑多了 token 的 AI 说的","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:33:25Z"} -{"cache_key":"9ff80a7969b1f50607f2046588db0ff9bfa745245e27cd65bcd5f3f5a7181354","segment_id":"start/wizard.md:6ea5cd459d660a33","source_path":"start/wizard.md","text_hash":"6ea5cd459d660a33a88276c5483ca067aaefa500b8b349067ed7eaeda6d871a8","text":"No remote installs or daemon changes are performed.","translated":"不会执行远程安装或守护进程更改。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:46:29Z"} -{"cache_key":"a00706550adc72d0953bfca3d2d9ba92c66c2462d8110da48a062d7618ab3092","segment_id":"index.md:10bf8b343a32f7dc","source_path":"index.md","text_hash":"10bf8b343a32f7dc01276fc8ae5cf8082e1b39c61c12d0de8ec9b596e115c981","text":"WebChat","translated":"WebChat","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:53:08Z"} -{"cache_key":"a05d1b3ace09d73190450de5094411e34c68a30679d27f7485cd5077e6eb93b4","segment_id":"environment.md:frontmatter:summary","source_path":"environment.md:frontmatter:summary","text_hash":"78351223e7068721146d2de022fdf440c2866b2ee02fbbb50bf64369b999820b","text":"Where OpenClaw loads environment variables and the precedence order","translated":"OpenClaw 加载环境变量的位置及优先级顺序","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:45:07Z"} -{"cache_key":"a066cc68d6f3b2d3eb59c1a8859348223e884c87eb512c19cde3cb5e14ebc7ca","segment_id":"start/wizard.md:ab744fe26b887abd","source_path":"start/wizard.md","text_hash":"ab744fe26b887abdb3558472d5bfe074f2716bbd88c8fab2b86bc745cbe7cf52","text":"Tip: ","translated":"提示: ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:40:45Z"} -{"cache_key":"a08b0f8129a90b28e13de7f9610a1f2d9421d75eed227b3d4036c3bfb91b06c5","segment_id":"start/wizard.md:fbb0f1b48888c121","source_path":"start/wizard.md","text_hash":"fbb0f1b48888c1213ed6d214e58b88f98b885fde7be5ea69b81caa8d32ffce29","text":"Sets ","translated":"设置 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:42:20Z"} -{"cache_key":"a09638c50f961a7ca5d6f411261c7dbc4a1c70677b9b54dd69f7c19300035a18","segment_id":"environment.md:baa5be7f6320780b","source_path":"environment.md","text_hash":"baa5be7f6320780bd7bb7b7ddbb8cd1ffb26ccf7d94d363350668c50aedcf95f","text":" (applied only if missing).","translated":" (仅在缺失时应用)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:25:53Z"} -{"cache_key":"a09a1449f338df66eb814dfd44c4ba2fb803af25fdb880365d9656fd10e68896","segment_id":"index.md:1eb6926214b56b39","source_path":"index.md","text_hash":"1eb6926214b56b396336f22c22a6f8a4c360cfe7109c8be0f9869655b9ff6235","text":"Pairing (DM + nodes)","translated":"配对(私聊 + 节点)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:32:07Z"} -{"cache_key":"a09eb80d6a469c1f8c38b2f519e5563d3e70b0c6d437c1379f9a1218996f56cb","segment_id":"index.md:frontmatter:read_when:0","source_path":"index.md:frontmatter:read_when:0","text_hash":"08965a8ab25e66157009d1617fc167bcc2404fa0c0ca50b1e5e5750957be3b10","text":"Introducing OpenClaw to newcomers","translated":"向新用户介绍 OpenClaw","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:27:26Z"} -{"cache_key":"a0a0b7d915a1d0f6cdedf629867e973881d7354388fd9ce112d4863e6d5e8e2f","segment_id":"start/wizard.md:656458ef5481a088","source_path":"start/wizard.md","text_hash":"656458ef5481a0885762810b02f1a4c75c6f6ffa968fd85028b9e810f5e1219f","text":"Re-running the wizard does ","translated":"重新运行向导 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:41:10Z"} -{"cache_key":"a0ba382a0fbf8fd57a0f05d2058dbf6147bcd4387a60e8b082c2009fc31db28b","segment_id":"help/index.md:3c33340bd23b8db8","source_path":"help/index.md","text_hash":"3c33340bd23b8db89f18fe7d05a954738c0dd5ba9623cf6bdb7bb5d1a3729cfc","text":"FAQ (concepts)","translated":"常见问题(概念)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:15:38Z"} -{"cache_key":"a0c0d04dc411248ead0dc8669af49162ca2857cc967670a1db53f5350ef36c7a","segment_id":"help/index.md:8ddb7fc8a87904de","source_path":"help/index.md","text_hash":"8ddb7fc8a87904dedc2afc16400fbe4e78582b302e01c30b1319c8a465d04684","text":"Troubleshooting:","translated":"故障排除:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:56:20Z"} -{"cache_key":"a0d2cd21a3b93f857394aa3ed248a36130e8edfcf329e3cf57411efb04382e5a","segment_id":"environment.md:f7e239a42b7cd986","source_path":"environment.md","text_hash":"f7e239a42b7cd986a1558fed234e975ed2e96e9d37cf0a93f381778c461c89dd","text":"OpenClaw pulls environment variables from multiple sources. The rule is ","translated":"OpenClaw 从多个来源获取环境变量。规则是 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:25:17Z"} -{"cache_key":"a0e99af5ca5e84733312e288abcca135768e88eccf093eceeb670be82e40d41f","segment_id":"help/index.md:24669ff48290c187","source_path":"help/index.md","text_hash":"24669ff48290c1875d8067bbd241e8a55444839747bffb8ab99f3a34ef248436","text":"Doctor","translated":"诊断工具","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:15:35Z"} -{"cache_key":"a21eca32ff057c4ce091d2964d7860ed8ec2edc05aa6c20fefc81f158d396755","segment_id":"help/index.md:729bc562eec2658b","source_path":"help/index.md","text_hash":"729bc562eec2658bd11ffdd522fe5277177dc73e86eaca7baac0b472a4d8f8b2","text":"If you’re looking for conceptual questions (not “something broke”):","translated":"如果你在寻找概念性问题(而不是\"出了问题\"):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:40:01Z"} -{"cache_key":"a235aca76de620b9ed0805727dc5f142a660dc6dac3254a01531acad96cb084d","segment_id":"index.md:d53b75d922286041","source_path":"index.md","text_hash":"d53b75d9222860417f783b0829023b450905d982011d35f0e71de8eed93d90fc","text":"New install from zero:","translated":"从零开始全新安装:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:59:05Z"} -{"cache_key":"a28528856eac855eaf431dc468f5d1a9b3918df6dc73a9bb54c488aa7c23faad","segment_id":"start/getting-started.md:387847437e10c06c","source_path":"start/getting-started.md","text_hash":"387847437e10c06cae87567a6579b38e71849aea9c2355eba4a8d090418360b9","text":"The wizard can write tokens/config for you. If you prefer manual config, start with:","translated":"向导可以为您写入令牌/配置。如果您更喜欢手动配置,请从以下内容开始:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:37:19Z"} -{"cache_key":"a28d9fd85bfd4afc9a62b3cfe12607c86001b32a9a97d72eeb6cd50993fb51ee","segment_id":"index.md:c6e91f3b51641b1c","source_path":"index.md","text_hash":"c6e91f3b51641b1c43d297281ee782b40d9b3a0bdd7afc144ba86ba329d5f95f","text":"OpenClaw = CLAW + TARDIS","translated":"OpenClaw = CLAW + TARDIS","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:54:04Z"} -{"cache_key":"a2c462e51d228b070aba2a14a09d41aa54e0962d795724d5a090c71c7e242dfe","segment_id":"start/getting-started.md:acdd1e734125f341","source_path":"start/getting-started.md","text_hash":"acdd1e734125f341604c0efbabdcc4c4b0597e8f6235d66c2445edd1812838c1","text":"Telegram","translated":"Telegram","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:37:22Z"} -{"cache_key":"a2f08193fbeb8a9400b75d96157bbbf488ab3aa51d50658094d00bb841646217","segment_id":"help/index.md:2adc964c084749b1","source_path":"help/index.md","text_hash":"2adc964c084749b1f2d8aef24030988b667dbda2e38a6a1699556c93e07c1cea","text":"Start here","translated":"从这里开始","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:44:37Z"} -{"cache_key":"a32d46351380765e1ec38639781fc9e5abaccdf74240eee7ab685f570551f487","segment_id":"index.md:7d8b3819c6a9fb72","source_path":"index.md","text_hash":"7d8b3819c6a9fb726f40c191f606079b473f6f72d4080c13bf3b99063a736187","text":"Ops and safety:","translated":"运维与安全:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:33:04Z"} -{"cache_key":"a33cc9039329637ba985cef1ca2948d9f26eb2445653d3b7530bec79b97f550e","segment_id":"index.md:774f1d6b2910de20","source_path":"index.md","text_hash":"774f1d6b2910de200115afec1bd87fe1ea6b0bc2142ac729e121e10a45df4b5d","text":" ← ","translated":" ← ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:52:20Z"} -{"cache_key":"a34a85b676726b7a90c88b91c5bb2a67ef320ebcac8bd9eabe626eefb3e8dee1","segment_id":"environment.md:62d66b8c36a6c9aa","source_path":"environment.md","text_hash":"62d66b8c36a6c9aa7134c8f9fe5912435cb0b3bfce3172712646a187954e7040","text":"See [Configuration: Env var substitution](/gateway/configuration#env-var-substitution-in-config) for full details.","translated":"详见 [配置:环境变量替换](/gateway/configuration#env-var-substitution-in-config)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:19:47Z"} -{"cache_key":"a34be228f3b2eda3844fb225eb35e1ebb8875ee64a19a2bba1e88f5c21146ec3","segment_id":"start/getting-started.md:6ae8b12a4b2d056a","source_path":"start/getting-started.md","text_hash":"6ae8b12a4b2d056ab9e19350d8bbffea9178d4fe1aad54e7cb6805578e75a34d","text":": OpenAI Code (Codex) subscription (OAuth) or API keys. For Anthropic we recommend an API key; ","translated":":OpenAI Code (Codex) 订阅(OAuth)或 API 密钥。对于 Anthropic,我们推荐使用 API 密钥; ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:36:06Z"} -{"cache_key":"a3909a297d0e74a4cb418a7a549f495f6eed24048ebf8f12f448eff8d7a20c50","segment_id":"environment.md:1ec31258a6b45ea9","source_path":"environment.md","text_hash":"1ec31258a6b45ea903cd76f5b0190a99ab56afff6241a04f0681eb12b7a02484","text":"Env var equivalents:","translated":"等效的环境变量:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:26:18Z"} -{"cache_key":"a3e59ee4578bdb5fd68940692f78e9389e163da63e350ba9f0689ffbc980d4a5","segment_id":"environment.md:28b1103adde15a9d","source_path":"environment.md","text_hash":"28b1103adde15a9ddd8fc71f0c57dc155395ade46a0564865ccb5135b01c99b7","text":"OpenClaw pulls environment variables from multiple sources. The rule is **never override existing values**.","translated":"OpenClaw 从多个来源拉取环境变量。规则是**永远不覆盖已有的值**。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:19:23Z"} -{"cache_key":"a4384986e5ce06eca0118051e6a851ac0fd3d922d4d1f31b60000687962a2288","segment_id":"start/wizard.md:ec1a3a5d6d6f0bac","source_path":"start/wizard.md","text_hash":"ec1a3a5d6d6f0baca7805bf1ea17fc7b02042416f02f80bc1970ad8c710abd89","text":"Flow details (local)","translated":"流程详情(本地)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:40:56Z"} -{"cache_key":"a46b3daf9b1e1045e72e437a283e8377ec9b4820cde181d05a24a9a582cbf914","segment_id":"start/wizard.md:12754931af777521","source_path":"start/wizard.md","text_hash":"12754931af777521bcb6a904d2a7d342d0d77e6c4f1f2eb1b8b3753d25a1ab4a","text":"If the Control UI assets are missing, the wizard attempts to build them; fallback is ","translated":"如果 Control UI 资源文件缺失,向导会尝试构建它们;后备方案是 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:46:06Z"} -{"cache_key":"a4a009f8c9411234d5dd3ef4a71fdf292ec59e29a2b74d197acea1c789825536","segment_id":"help/index.md:6cb77499abdccd9a","source_path":"help/index.md","text_hash":"6cb77499abdccd9a2dbb7c93a4d31eed01613dda06302933057970df9ecdeb54","text":"Logs:","translated":"日志:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:24:46Z"} -{"cache_key":"a4b963e5c58f681343b2e7b98ade4df71e3a328906ed382ffc8c0e4853fdf162","segment_id":"environment.md:b1d6b91b67c2afa5","source_path":"environment.md","text_hash":"b1d6b91b67c2afa5e322988d9462638d354ddf8a1ef79dba987f815c22b4baee","text":" at ","translated":" 位于 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:25:39Z"} -{"cache_key":"a4cca9ee9c91e2df4fbfddb735c879510d4ef8e808b15a6b2697d94e08e08696","segment_id":"index.md:233cfad76c3aa9dd","source_path":"index.md","text_hash":"233cfad76c3aa9dd5cc0566746af197eac457a88c1e300ae788a8ada7f96b383","text":"From source (development):","translated":"从源码安装(开发):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:03:06Z"} -{"cache_key":"a4f4e3c0c2201e9d7bb71be5c98cfd3035febf5faea9901a446d2acfabaf119f","segment_id":"start/wizard.md:35dbeb1dcbaf6ec1","source_path":"start/wizard.md","text_hash":"35dbeb1dcbaf6ec104ff612596126f8f6eb79bca9e75e88e93021b57b1c3590b","text":"Providers: ","translated":"提供商: ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:49:13Z"} -{"cache_key":"a501788647b1bdfef85962c5f388a3813fb838cf35407849bbce0d5f5090622d","segment_id":"environment.md:d942f64886578d87","source_path":"environment.md","text_hash":"d942f64886578d8747312e368ed92d9f6b2a8d45556f0f924e2444fe911d15af","text":" import","translated":" 导入","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:46:37Z"} -{"cache_key":"a52a1dde459a24de35447cda1771491fefcb09e9c555e0bbf08ee1a315353a2f","segment_id":"start/wizard.md:4fc4905e7b9c21f7","source_path":"start/wizard.md","text_hash":"4fc4905e7b9c21f7b34ec04b677a7f443624c0f724849ef2ca258da070ac35ca","text":" install + account config.","translated":" 安装 + 账户配置。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:44:51Z"} -{"cache_key":"a53c70efce0e16817f30acfd99d7db48bac27a7ec5d2c6235d65c8d97f59d781","segment_id":"start/wizard.md:daee7606b339f3c3","source_path":"start/wizard.md","text_hash":"daee7606b339f3c339076fe2c9f372a3ff40c8ee896005d829c7481b64ca5303","text":"Reset","translated":"重置","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:41:13Z"} -{"cache_key":"a5767baee89195aa9db45c28cde3149e24b750a0e2e80d3730e1b61daec207e6","segment_id":"start/wizard.md:5c462b6b373504d5","source_path":"start/wizard.md","text_hash":"5c462b6b373504d54bc3262921f4a1a0cf666b8653e4122b418630d3f35f3ed3","text":" launches the wizard.","translated":" 运行会启动向导。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:46:52Z"} -{"cache_key":"a58f3ba9f36e7098f425445110c616f706c428aa8cd60c3e31c7d027229fd02e","segment_id":"help/index.md:frontmatter:summary","source_path":"help/index.md:frontmatter:summary","text_hash":"aece82a2d540ab1a9a21c7b038127cae6e9db2149491564bb1856b6f8999f205","text":"Help hub: common fixes, install sanity, and where to look when something breaks","translated":"帮助中心:常见修复方法、安装完整性检查,以及出现问题时的排查方向","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:15:06Z"} -{"cache_key":"a5b98e5a231f8db1b639acf7d415ecc749f34a640c80228784de562431a620af","segment_id":"start/wizard.md:6b09602d76f9ec29","source_path":"start/wizard.md","text_hash":"6b09602d76f9ec29755127ad2eb6a286fc47675e58b2df4cd1749a5dc4e19376","text":") and offers scopes:","translated":")并提供作用域:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:41:29Z"} -{"cache_key":"a5ce1d689305d466562771f1be3de56b5a492ced09caf35aaaa25b35c0a314eb","segment_id":"index.md:0eb95fb6244c03f1","source_path":"index.md","text_hash":"0eb95fb6244c03f1ccca696718a06766485c231347bf382424fb273145472355","text":"Quick start","translated":"快速开始","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:02:49Z"} -{"cache_key":"a5f308741639ce5bbd185e1ebe60322316c02afc8eb8caf44b469ee2041fded0","segment_id":"start/getting-started.md:d03502c43d74a30b","source_path":"start/getting-started.md","text_hash":"d03502c43d74a30b936740a9517dc4ea2b2ad7168caa0a774cefe793ce0b33e7","text":", ","translated":", ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:35:09Z"} -{"cache_key":"a626e7fe04bc58aa97f7363efbdaa14d5804691b203594e954e7373d26bc5bbb","segment_id":"start/getting-started.md:f68f6c2d3e9114cf","source_path":"start/getting-started.md","text_hash":"f68f6c2d3e9114cfec906d6a20cd048091e580c6e1d00a8066165dba188f9b3e","text":"channels (WhatsApp/Telegram/Discord/Mattermost (plugin)/...)","translated":"渠道(WhatsApp/Telegram/Discord/Mattermost(插件)/...)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:34:56Z"} -{"cache_key":"a6765fa54adfb2c44e2c668f9e03bb6668ee81809487078ceafc3e25ab776985","segment_id":"index.md:ded906ea94d05152","source_path":"index.md","text_hash":"ded906ea94d0515249f0bcab1ba63835b5968c142e9c7ea0cb6925317444d98c","text":"Configuration examples","translated":"配置示例","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:04:00Z"} -{"cache_key":"a696427cc9f77535e0a437bc4ced6dbbed14ef7d40f617ee28ff6c96b03b3888","segment_id":"start/getting-started.md:8ed8fc3de6f7cb89","source_path":"start/getting-started.md","text_hash":"8ed8fc3de6f7cb899073925b4e51ad2ce2d41fc97493347125c0f501f96ae205","text":"workspace bootstrap + skills","translated":"工作区引导 + 技能","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:35:00Z"} -{"cache_key":"a6e1f8b003a9aa3df1fc6040ef393aff9f02788a25d88903604584ac44a7cfde","segment_id":"index.md:65fd6e65268ff905","source_path":"index.md","text_hash":"65fd6e65268ff9057a49d832cccfcd5a376e46a908a2129be5b43f945fa8d8ca","text":": Gateway WS defaults to ","translated":":Gateway WS 默认为 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:48:40Z"} -{"cache_key":"a708335602471087cfca37672f53ab2f79c69ddf48fdb3d9f18a79065b57d68c","segment_id":"index.md:6201111b83a0cb5b","source_path":"index.md","text_hash":"6201111b83a0cb5b0922cb37cc442b9a40e24e3b1ce100a4bb204f4c63fd2ac0","text":" and ","translated":" 和 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:59:38Z"} -{"cache_key":"a70d2c258834cd52f862bddbf79987e31a906cb42a011a4b01c5833810163e67","segment_id":"help/index.md:d3ef01b4a9c99103","source_path":"help/index.md","text_hash":"d3ef01b4a9c9910364c9b26b2499c8787a0461d2d24ab80376fff736a288b34c","text":"Logging","translated":"日志记录","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:15:28Z"} -{"cache_key":"a7acef28bba8cdb6a32f047b98acad22efeb347de55a39db88b2191da5e2b0d7","segment_id":"index.md:93c89511a7a5dda3","source_path":"index.md","text_hash":"93c89511a7a5dda3b3f36253d17caee1e31f905813449d475bc6fed1a61f1430","text":"common fixes + troubleshooting","translated":"常见修复方案 + 故障排除","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:31:58Z"} -{"cache_key":"a7ba9dcc031859590f1c52cf8ee0e6243302b15838ac61a0513bcdef5ad90138","segment_id":"help/index.md:bfc5930cc2660330","source_path":"help/index.md","text_hash":"bfc5930cc2660330260afd407e98d86adaec0af48dd72b88dc33ef8e9066e2c9","text":"Install sanity (Node/npm/PATH):","translated":"安装完整性检查(Node/npm/PATH):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:56:23Z"} -{"cache_key":"a86f676c046c31e9ec14194f2cd6b154bad22422ff1e0cd75504746b2e3ff3e9","segment_id":"index.md:2f1626425f985d9a","source_path":"index.md","text_hash":"2f1626425f985d9ad8c124ea8ccb606e404ae5f43c58bd16b6c109d6d2694083","text":"Most operations flow through the ","translated":"大多数操作通过 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:48:21Z"} -{"cache_key":"a8956cdeec536a6d374d68798de5d28d4415bd3929c6129b678f86333a476663","segment_id":"index.md:0d3a30eb74e2166c","source_path":"index.md","text_hash":"0d3a30eb74e2166c1fc51b99b180841f808f384be53fe1392cecb67fdc9363c4","text":" (default ","translated":" (默认 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:00:24Z"} -{"cache_key":"a8b62b93b0c0bf52adc8e6428cf65abd66a23a669fc4902297be8ac01330e248","segment_id":"environment.md:9e471951a1b4106e","source_path":"environment.md","text_hash":"9e471951a1b4106e54be128a21112b02914fe98cc79b2c92b49ee80c5464487c","text":"Environment","translated":"环境","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:45:17Z"} -{"cache_key":"a8c116397b4632fb63df875275cd7a20e4eb7bcccf5f1015140f94df02c46874","segment_id":"index.md:86e2bbbc305c31aa","source_path":"index.md","text_hash":"86e2bbbc305c31aa988751196a1e207da68801a48798c48b90485c11578443a0","text":"Providers and UX:","translated":"提供商与用户体验:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:32:30Z"} -{"cache_key":"a8c2c86f1c2602cf80227b2f202b054928d4713c034b77d5bffa32f45a43f662","segment_id":"help/index.md:8ddb7fc8a87904de","source_path":"help/index.md","text_hash":"8ddb7fc8a87904dedc2afc16400fbe4e78582b302e01c30b1319c8a465d04684","text":"Troubleshooting:","translated":"故障排除:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:44:35Z"} -{"cache_key":"a93199a15f18e1ac6b70e21111f3bcce4117105f7e56633e0ec5653e45402bd6","segment_id":"index.md:0c67abfaa5415391","source_path":"index.md","text_hash":"0c67abfaa5415391a31cf3a4624746b6b212b5ae66364be28ee2d131f014e0c6","text":"🧩 ","translated":"🧩 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:49:45Z"} -{"cache_key":"a935bca4180982ba3ca63187d99531e61543ace3acdb5664f583de4dadebb841","segment_id":"index.md:3fc5f55ea5862824","source_path":"index.md","text_hash":"3fc5f55ea5862824fc266d26cd39fb5da22cc56670c11905d5743adac10bc9ef","text":"Mattermost Bot (plugin)","translated":"Mattermost 机器人(插件)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:01:11Z"} -{"cache_key":"a95002f09359a779a93f9b9c36001885ec2e7db3ab63c978bcfe94052728248d","segment_id":"start/wizard.md:9f088dbebd6c3c70","source_path":"start/wizard.md","text_hash":"9f088dbebd6c3c70a5ddbc2c943b11e4ca9acea5757b0b4f2b32479f0dbb747e","text":"Advanced","translated":"高级","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:39:39Z"} -{"cache_key":"a9c30fa450ed436cb03bc256b3075761a9215bd99bcd7bd2891cf15317ffd34f","segment_id":"environment.md:d08a8493f686363a","source_path":"environment.md","text_hash":"d08a8493f686363a78b913d45ebfbd87a3768d1c77b70f23b1fdade3c066e481","text":"Shell env import","translated":"Shell 环境导入","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:16:47Z"} -{"cache_key":"aa80cfc76e76409c5ba7bf331e4fb8aadf72703ead80d203c94e74209da993f9","segment_id":"index.md:310cc8cec6b20a30","source_path":"index.md","text_hash":"310cc8cec6b20a3003ffab12f5aade078a0e7a7d6a27ff166d62ab4c3a1ee23d","text":"If you ","translated":"如果你 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:31:31Z"} -{"cache_key":"aaa5becdcd694b68de2e61f6a13bd932c3f80f8b0b5a959a054a61ad5911beef","segment_id":"index.md:81a1c0449ea684aa","source_path":"index.md","text_hash":"81a1c0449ea684aadad54a7f8575061ddc5bfa713b6ca3eb8a0228843d2a3ea1","text":"Nodes (iOS/Android)","translated":"节点(iOS/Android)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:32:22Z"} -{"cache_key":"aacffcbc2a97abf1a5eccd00e5893be1125e364251fa27f3e0c88ef2db2b0248","segment_id":"index.md:acdd1e734125f341","source_path":"index.md","text_hash":"acdd1e734125f341604c0efbabdcc4c4b0597e8f6235d66c2445edd1812838c1","text":"Telegram","translated":"Telegram","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:32:36Z"} -{"cache_key":"aad00bc21098071ff9c86ff467cb7f5c65d3467ce4bf7d707f560479783e9eaa","segment_id":"index.md:b79cac926e0b2e34","source_path":"index.md","text_hash":"b79cac926e0b2e347e72cc91d5174037c9e17ae7733fd7bdb570f71b10cd7bfc","text":"Help","translated":"帮助","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:31:51Z"} -{"cache_key":"aae01909516ef373ddb2e4996f9016675f297208f7f075a68490f1f48eb0c87f","segment_id":"environment.md:6a26e1694d9e8520","source_path":"environment.md","text_hash":"6a26e1694d9e852038e5a472ed6b54cc023b4ace8ac10d745cad426d5dc057f3","text":" details.","translated":" 详情。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:47:11Z"} -{"cache_key":"aae4e71d06b01c462919dcb88e06b6e65c9edf88f774847e6397e907b81af99b","segment_id":"environment.md:7175517a370b5cd2","source_path":"environment.md","text_hash":"7175517a370b5cd2e664e3fd29c4ea9db5ce17058eb9772fe090a5485e49dad6","text":" or ","translated":" 或 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:41:03Z"} -{"cache_key":"aaf2e5a5c90fbf43eb61201449ef2794c5a272f1262285178a51a22890816101","segment_id":"environment.md:d4a67341570f4656","source_path":"environment.md","text_hash":"d4a67341570f4656784c5f8fe1bfb48a738ace57b52544977431d50e2b718099","text":"FAQ: env vars and .env loading","translated":"常见问题:环境变量与 `.env` 加载","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:13:14Z"} -{"cache_key":"ab19c27dcd5a1799b5d41ad95ccd7cc9a8ec1e685e7b7bcbc6f620a57ba64c73","segment_id":"index.md:39bbb719fa2b9d22","source_path":"index.md","text_hash":"39bbb719fa2b9d2251039cbf2cd072e1120a414278263e2f11d99af0236c4262","text":"Groups","translated":"群组","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:32:42Z"} -{"cache_key":"ab2362ccd249b707169072e9b3e0030307eca102e4795d4252be21f596247a95","segment_id":"start/getting-started.md:624f09022ea974b9","source_path":"start/getting-started.md","text_hash":"624f09022ea974b98abb7e922576072ca4467f4f6cce62d39b5591207fca4232","text":" (Ubuntu recommended). WSL2 is strongly recommended; native Windows is untested, more problematic, and has poorer tool compatibility. Install WSL2 first, then run the Linux steps inside WSL. See ","translated":" (推荐 Ubuntu)。强烈推荐使用 WSL2;原生 Windows 未经测试,问题较多,且工具兼容性较差。请先安装 WSL2,然后在 WSL 内执行 Linux 步骤。参见 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:35:42Z"} -{"cache_key":"ab5a661b139f2271cc3da6eb98afbd8c56c9a47b1bfa2570aba46677a28bb509","segment_id":"index.md:6d6577cb1c128ac1","source_path":"index.md","text_hash":"6d6577cb1c128ac18a286d3c352755d1a265b1e3a03eded8885532c3f36e32ed","text":"Mario Zechner","translated":"Mario Zechner","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:06:00Z"} -{"cache_key":"abafe9669ff562150a4e76ad066e4ad761bb391e29ce4416a9b58e1583e500be","segment_id":"environment.md:7175517a370b5cd2","source_path":"environment.md","text_hash":"7175517a370b5cd2e664e3fd29c4ea9db5ce17058eb9772fe090a5485e49dad6","text":" or ","translated":" 或 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:57:55Z"} -{"cache_key":"abc5cfa176d8a536bccc8bdf09aa69c56c08c41f519d5e050384ecf88670ce2d","segment_id":"help/index.md:8cd501e1124c3047","source_path":"help/index.md","text_hash":"8cd501e1124c30473473c06e536a2d145e2a14a6d7dc1b99028ce818e14442e2","text":"Repairs:","translated":"修复:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:15:33Z"} -{"cache_key":"abdba3de87eed812fa8b91c34ca0a00364c0d432e6dd6229b58ca9ab81f3828a","segment_id":"index.md:316cd41f595f3095","source_path":"index.md","text_hash":"316cd41f595f3095f149f98af70f77ab85404307a1505467ee45a26b316a9984","text":"Guided setup (recommended):","translated":"引导式设置(推荐):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:47:46Z"} -{"cache_key":"abf5a32f0d0613c45205a09d8a62ba4454c5a1e6342938a841eb266f169648fa","segment_id":"environment.md:496aca80e4d8f29f","source_path":"environment.md","text_hash":"496aca80e4d8f29fb8e8cd816c3afb48d3f103970b3a2ee1600c08ca67326dee","text":" block","translated":" 块","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:12:22Z"} -{"cache_key":"ac1c676f8b0fc38c55da8beae422685700924821fc4a22af902f4028e6b6b1b4","segment_id":"start/getting-started.md:b97a7337efe8076b","source_path":"start/getting-started.md","text_hash":"b97a7337efe8076beea41f887d7fb1006d383c094728e3ddfe3e6228e47ca095","text":"macOS remote","translated":"macOS 远程","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:38:44Z"} -{"cache_key":"ac7c1c475e44053caaa8f0aad4a4c7cf61d349b95857ea7b44ac1e48836f9783","segment_id":"environment.md:6863067eb0a2c749","source_path":"environment.md","text_hash":"6863067eb0a2c7499425c6c189b2c88bac55ca754285a6ab1ef37b75b4cfad4d","text":"See ","translated":"参见 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:13:03Z"} -{"cache_key":"ad1eb4b87dcff4153a93d9aa9f3adb2be423b8f3eb61c8c69e79cc843b9b06dc","segment_id":"start/getting-started.md:7843665e87c6ef82","source_path":"start/getting-started.md","text_hash":"7843665e87c6ef82a8995362c43cacaf9aac743f9737aae4130de8fb3548e37b","text":").\n See ","translated":")。\n 参见 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:35:34Z"} -{"cache_key":"ad534581940aa1b636a88890801421659be78fa961141a10a595506ad9413584","segment_id":"index.md:3fc5f55ea5862824","source_path":"index.md","text_hash":"3fc5f55ea5862824fc266d26cd39fb5da22cc56670c11905d5743adac10bc9ef","text":"Mattermost Bot (plugin)","translated":"Mattermost 机器人(插件)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:49:47Z"} -{"cache_key":"ad6b8f87fd0971ae528bf36026dd7d1d1aecace6621ef3bfe00bc4f0195deece","segment_id":"start/wizard.md:325f237dda4ec247","source_path":"start/wizard.md","text_hash":"325f237dda4ec24753c4b157abd9645efd361ae1adf64a5a97f023c8bef7baff","text":"What the wizard does","translated":"向导的功能","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:40:11Z"} -{"cache_key":"ade480827c0ce46d4dfc9141efcaf7ff0afac9c4895ae48a4824049c1b079791","segment_id":"start/wizard.md:f3f51d88046314e4","source_path":"start/wizard.md","text_hash":"f3f51d88046314e4f0fb9e0e6d84a21ffd8ffeb7f8643f282c928a6176f84196","text":"The wizard starts with ","translated":"向导以 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:39:32Z"} -{"cache_key":"adfb3af54146b47b4e744815d9ccc0855ba7030eb0580a74b0bc2fc25be8825f","segment_id":"index.md:0b7e778664921066","source_path":"index.md","text_hash":"0b7e77866492106632e98e7718a8e1e89e8cb0ee3f44c1572dfd9e54845023de","text":"/concepts/streaming","translated":"/concepts/streaming","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:01:38Z"} -{"cache_key":"ae362832194711d0f893594a03e3bb80106c5f270cf53505aa8d85909cf18d1b","segment_id":"help/index.md:71095a6d42f5d9c2","source_path":"help/index.md","text_hash":"71095a6d42f5d9c2464a8e3f231fc53636d4ce0f9356b645d245874162ec07e2","text":"Gateway troubleshooting","translated":"Gateway 故障排除","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:24:44Z"} -{"cache_key":"ae38697f064f10b478a11c227a88eb0a8649159a6488fe8d31acc2cec8ad05fa","segment_id":"index.md:6e0f6eca4ff17d33","source_path":"index.md","text_hash":"6e0f6eca4ff17d3377c1c3e8e1f73457553ad3b9cfcd5e4f2b94cfb1028b6234","text":"iOS app","translated":"iOS 应用","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:05:08Z"} -{"cache_key":"ae6374b547927202ad7b2766b4b93d614453d51af2667ce8e8c4c50a1788ccda","segment_id":"index.md:79a482cf546c23b0","source_path":"index.md","text_hash":"79a482cf546c23b04cd48a33d4ca8411f62e5b7dc8c3a8f30165e28e747f263a","text":"iMessage","translated":"iMessage","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:01:18Z"} -{"cache_key":"ae7043304208a45c9727b207699b4a24db4fe776eee16a1c8f1bed9d9fcd7c5c","segment_id":"index.md:bf084dc7b82e1e62","source_path":"index.md","text_hash":"bf084dc7b82e1e62c63727b13451d1eba2269860e27db290d2d5908d7ade0529","text":" — Pairs as a node and exposes Canvas + Chat + Camera","translated":" — 作为节点配对并提供 Canvas + 聊天 + 相机","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:31:02Z"} -{"cache_key":"ae7343dadbee931e1ac99fcf3a1bdc0745e6960c0e075482401e7dc615439225","segment_id":"environment.md:46ab081177a452aa","source_path":"environment.md","text_hash":"46ab081177a452aa62354b581730f4675cb03e58cde8282071da30cabe18fb2e","text":"Optional login-shell import","translated":"可选的登录 shell 导入","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:57:50Z"} -{"cache_key":"ae96b8aee2f9be9d9843ec8c1f0c693d9d64a220bb3b226e904be86c041e5af4","segment_id":"index.md:41ed52921661c7f0","source_path":"index.md","text_hash":"41ed52921661c7f0d68d92511589cc9d7aaeab2b5db49fb27f0be336cbfdb7df","text":"Gateway","translated":"Gateway","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:28:34Z"} -{"cache_key":"aeac09e385428be1a6afe9d98844b4f45ffa5f9690937b11572543441dfbe93e","segment_id":"start/getting-started.md:frontmatter:read_when:0","source_path":"start/getting-started.md:frontmatter:read_when:0","text_hash":"1cbb4fd6536838366360092615465643e07ae65489e0d0a68f9b7500a7ac6c96","text":"First time setup from zero","translated":"从零开始的首次设置","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:34:17Z"} -{"cache_key":"aeb7007c273f0c7bca86dbf2cd6cd544ca79abb504054d08244ad9f11abd4fa5","segment_id":"index.md:b0d125182029e6c5","source_path":"index.md","text_hash":"b0d125182029e6c500cbcc81011341df77de8fe24d9e80190c32be390c916ec2","text":"🤖 ","translated":"🤖 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:29:55Z"} -{"cache_key":"af003c0a076e77417f3b2415efeeb038bf57f2a1eed124f692238fcdb66119e8","segment_id":"start/wizard.md:1f66d361f1307d4e","source_path":"start/wizard.md","text_hash":"1f66d361f1307d4e66676bb21e36b6bc6be07759ca8cd0b1c73561821e298188","text":"Discovery hints:","translated":"发现提示:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:46:34Z"} -{"cache_key":"af2c767d10d616dd189bb9ed963e45f036beb388e91afbaa60bf62be6ef35d1e","segment_id":"index.md:075a4a45c3999f34","source_path":"index.md","text_hash":"075a4a45c3999f340be8487cd7c0dd2ed77ced931054d75e95e5e24d5539b45b","text":" — Pi (RPC mode) with tool streaming","translated":" —— Pi(RPC 模式),支持 工具 流式传输","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:50:02Z"} -{"cache_key":"af2f58bcf5bdd60d1c98dcbc846239117cef3e202bea91afc78f0147c45c3a60","segment_id":"index.md:255ce77b7a6a015f","source_path":"index.md","text_hash":"255ce77b7a6a015f8595868a524b67c134e8fb405f4584fdac020e57f4ccd5f6","text":"Loopback-first","translated":"回环优先","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:48:37Z"} -{"cache_key":"af3c614067406b2bfae3faa3dc5c74b9ad7de00832ef2213f1d208a39e4eae92","segment_id":"index.md:3c064c83b8d244fe","source_path":"index.md","text_hash":"3c064c83b8d244fef61e5fd8ce5f070b857a3578a71745e61eea02892788c020","text":" — Anthropic (Claude Pro/Max) + OpenAI (ChatGPT/Codex) via OAuth","translated":" — 通过 OAuth 支持 Anthropic(Claude Pro/Max)+ OpenAI(ChatGPT/Codex)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:30:20Z"} -{"cache_key":"af41fffb606a61d612d3709f3732fc9cddca09ff369ed0bf469af9c994fcc648","segment_id":"environment.md:8d076464a84995bc","source_path":"environment.md","text_hash":"8d076464a84995bc095e934b0aa1e4419372f27cd71d033571e4dbba201ee5d8","text":"You can reference env vars directly in config string values using ","translated":"你可以使用以下方式在配置的字符串值中直接引用环境变量 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:22:32Z"} -{"cache_key":"af52d0343a217e1ebc960bf8e847f48d24146c9a5f695eb4d0cfb1c13bd92e1c","segment_id":"start/getting-started.md:b482e45229e19f5f","source_path":"start/getting-started.md","text_hash":"b482e45229e19f5f7ba590b5ac81bdb25d5d24116ed961bfa0eb1a23c20a204c","text":" (or ","translated":" (或 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:38:11Z"} -{"cache_key":"af7a992a13d7295a28b94032d28c8cc7ae177dba2b4f2fbb2008c3de7a74c3dc","segment_id":"start/wizard.md:a6c7a84baa6750fc","source_path":"start/wizard.md","text_hash":"a6c7a84baa6750fce33f7512acd6793e53def1d228b5f2efb8074b42648424fc","text":"Finish","translated":"完成","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:45:58Z"} -{"cache_key":"af9372d7088143330cee32dde6ee4ea2058a314debffdfacbf5343da8e95da7b","segment_id":"environment.md:ffa63583dfa6706b","source_path":"environment.md","text_hash":"ffa63583dfa6706b87d284b86b0d693a161e4840aad2c5cf6b5d27c3b9621f7d","text":"missing","translated":"缺失的","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:41:16Z"} -{"cache_key":"afb8249d9b9d237120a860c3f9c70470fc1ba2125f5b94110e50b3b0073032c5","segment_id":"environment.md:cda454f61dfcac70","source_path":"environment.md","text_hash":"cda454f61dfcac7007a9edc538f9f58cf38caa0652e253975979308162bccc53","text":"Gateway configuration","translated":"Gateway 配置","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:47:16Z"} -{"cache_key":"afba4d250aee5bbd63f27e2e64fdb895b043fdda06ee9b89a277422664d39428","segment_id":"environment.md:6863067eb0a2c749","source_path":"environment.md","text_hash":"6863067eb0a2c7499425c6c189b2c88bac55ca754285a6ab1ef37b75b4cfad4d","text":"See ","translated":"参见 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:47:05Z"} -{"cache_key":"afca6ce610f4888a3cfb0237a69f5700b984c656c5b829646fe19b2e61b8a190","segment_id":"start/wizard.md:316877bf8e401701","source_path":"start/wizard.md","text_hash":"316877bf8e401701c9ac95fdb7dee63577480e090eb586b6eb7cf7b36fa24cbf","text":"Google Chat","translated":"Google Chat","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:44:35Z"} -{"cache_key":"b004008c93e0b1ea2852beb2c430beae131fc3e19a69b11c5c9f2a24cda8590b","segment_id":"index.md:4818a3f84331b702","source_path":"index.md","text_hash":"4818a3f84331b702815c94b4402067e09e9e2d27ebc1a79258df8315f2c8600b","text":"📎 ","translated":"📎 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:30:35Z"} -{"cache_key":"b006bf9903fff3583e0a70cd9c332cdc44d30e5432e47628924d2b8d3f704444","segment_id":"index.md:053bc65874ad6098","source_path":"index.md","text_hash":"053bc65874ad6098e58c41c57b378a2f36b0220e5e0b46722245e6c2f796818c","text":"Discord","translated":"Discord","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:04:48Z"} -{"cache_key":"b0b8109bdac59602c326a82adc11b20713a0c0c2ad6595be6894fb0a3a489dc9","segment_id":"index.md:f0b349e90cb60b2f","source_path":"index.md","text_hash":"f0b349e90cb60b2f96222d0be1ff6532185f385f4909a19dd269ea3e9e77a04d","text":" (default); groups are isolated","translated":" (默认);群组为隔离","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:30:27Z"} -{"cache_key":"b0fcfd73b064dce0675db3e53661b400af1cfed802373334f865c27a3eda6303","segment_id":"help/index.md:frontmatter:summary","source_path":"help/index.md:frontmatter:summary","text_hash":"aece82a2d540ab1a9a21c7b038127cae6e9db2149491564bb1856b6f8999f205","text":"Help hub: common fixes, install sanity, and where to look when something breaks","translated":"帮助中心:常见修复方法、安装完整性检查,以及出现问题时的排查方向","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:18:54Z"} -{"cache_key":"b1248cc34d9a7c8ecfaa5612bfffbdaf26305acc8a6db269255ae6f591b4a841","segment_id":"start/getting-started.md:e16e5747158aac73","source_path":"start/getting-started.md","text_hash":"e16e5747158aac73e7f9e2ddb7c99efda2431fa25bb3effe93102c55fc7dbe77","text":": the wizard generates one by default (even on loopback) and stores it in ","translated":":向导默认会生成一个(即使在回环地址上)并将其存储在 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:36:25Z"} -{"cache_key":"b15acb5c7418f2e49045d730674b2f6470f73699aa97b03c7ada099d55cd53e8","segment_id":"environment.md:frontmatter:read_when:2","source_path":"environment.md:frontmatter:read_when:2","text_hash":"822b3d74ce16c1be19059fad4ca5bf7ae9327f58fa1ff4e75e78d5afa75c038f","text":"You are documenting provider auth or deployment environments","translated":"你正在编写提供商认证或部署环境的文档","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:19:18Z"} -{"cache_key":"b1a0214973416cbfb4dcac01605c51911f412a6b7d862a6b8aed7db6364bb93a","segment_id":"start/wizard.md:1a0f5fc7ca6e8a74","source_path":"start/wizard.md","text_hash":"1a0f5fc7ca6e8a74bc099d9c397a23564b55eca50c3b2e33c472acb7032a6f3b","text":" (if Minimax chosen)","translated":" (如果选择了 Minimax)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:48:35Z"} -{"cache_key":"b1babe6ce88663854adf02aa4a23f21c9a98e036c72bf36dbe4b518d5d025d8b","segment_id":"environment.md:8d076464a84995bc","source_path":"environment.md","text_hash":"8d076464a84995bc095e934b0aa1e4419372f27cd71d033571e4dbba201ee5d8","text":"You can reference env vars directly in config string values using ","translated":"您可以使用以下方式在配置字符串值中直接引用 环境变量 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:41:25Z"} -{"cache_key":"b1bfed2a2039ffc6f83d8201645caf18d6b942a8e5efbe2a28ca24978f750aa7","segment_id":"index.md:a97c0f391117ef55","source_path":"index.md","text_hash":"a97c0f391117ef554586ed43255ab3ff0e15adcfc1829c62b6d359672c0bec93","text":" — Mention-based by default; owner can toggle ","translated":" — 默认基于提及;所有者可切换 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:02:09Z"} -{"cache_key":"b1e93b43d06bcf0651c4bee0920f356e1f38bceca29db1936d449b4be99e77d2","segment_id":"index.md:8f6fb4eb7f42c0e2","source_path":"index.md","text_hash":"8f6fb4eb7f42c0e245e29e63f5b82cc3ba19852681d1ed9aed291f59cf75ec0e","text":"Security","translated":"安全","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:53:56Z"} -{"cache_key":"b212246fea49637bc0db899bd39dff2b1762ecf0d8cac3ec6160a8cd4c4da860","segment_id":"start/wizard.md:1f01936efef6e09c","source_path":"start/wizard.md","text_hash":"1f01936efef6e09cd29c9b1a9b6a64c1fcdb35682c9cf25db02dfde331f83fa7","text":" if present or prompts for a key, then saves it to ","translated":" (如果存在)或提示输入密钥,然后保存到 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:42:29Z"} -{"cache_key":"b240cb7927de51aca09fb318798ffd79fe597965722be259f799a2002cbe0f43","segment_id":"start/getting-started.md:4ea5ee68fea05586","source_path":"start/getting-started.md","text_hash":"4ea5ee68fea05586106890ded5733820bb77d919cda27bc4b8139b7cd33b8889","text":" gateway","translated":" Gateway","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:36:02Z"} -{"cache_key":"b27a4eb21eb3ee61916c2db4b356e29106524ae9d8e48aeb3f68f690c6cfb8f7","segment_id":"start/wizard.md:97f068362253059c","source_path":"start/wizard.md","text_hash":"97f068362253059c26de02d1c75c972c102f2ca201fca6015153c8077cfdbdd7","text":" way to set up OpenClaw on macOS,\nLinux, or Windows (via WSL2; strongly recommended).\nIt configures a local Gateway or a remote Gateway connection, plus channels, skills,\nand workspace defaults in one guided flow.","translated":" 在 macOS、Linux 或 Windows(通过 WSL2;强烈推荐)上设置 OpenClaw 的方式。它通过一个引导式流程配置本地 Gateway 或远程 Gateway 连接,以及渠道、技能和工作区默认设置。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:39:03Z"} -{"cache_key":"b2d36a6219cd6ef9fa18da47d2583999f398895d209ec3595c2c1f3789ded3f2","segment_id":"index.md:b79cac926e0b2e34","source_path":"index.md","text_hash":"b79cac926e0b2e347e72cc91d5174037c9e17ae7733fd7bdb570f71b10cd7bfc","text":"Help","translated":"帮助","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:52:17Z"} -{"cache_key":"b324a4a080cbe3b8cd7ae6ea1f8812027eeee42cdbd1db38d84f4240371db0ba","segment_id":"help/index.md:frontmatter:read_when:1","source_path":"help/index.md:frontmatter:read_when:1","text_hash":"857eafc389d179e83e21e46c10527fec40894fe064c63847ba06b946b7d5eb73","text":"Something broke and you want the fastest path to a fix","translated":"出了问题,你想要最快的修复方法","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:44:27Z"} -{"cache_key":"b36d09f6dceced206ef224552875840995ff1ad070c158298f356c7a308c4401","segment_id":"start/getting-started.md:f9194e73f9e9459e","source_path":"start/getting-started.md","text_hash":"f9194e73f9e9459e3450ea10a179cdf77aafa695beecd3b9344a98d111622243","text":"zero","translated":"零开始","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:34:26Z"} -{"cache_key":"b38f8218a329aa459516734a74c0141efdd0901ffc65900b9b5e3ffc338cb49d","segment_id":"start/getting-started.md:6201111b83a0cb5b","source_path":"start/getting-started.md","text_hash":"6201111b83a0cb5b0922cb37cc442b9a40e24e3b1ce100a4bb204f4c63fd2ac0","text":" and ","translated":" 和 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:34:40Z"} -{"cache_key":"b3eb30fbc137a10687841225ce40db87439bcd2052ede47102f01f5a3da81d12","segment_id":"environment.md:582967534d0f909d","source_path":"environment.md","text_hash":"582967534d0f909d196b97f9e6921342777aea87b46fa52df165389db1fb8ccf","text":" in ","translated":" 在 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:12:25Z"} -{"cache_key":"b3f38013bdfa47cf7e56f9f97e5f0c56d9ceb3fdede7720d31afe1aa3ed90d47","segment_id":"start/wizard.md:acde1b96aeebd08f","source_path":"start/wizard.md","text_hash":"acde1b96aeebd08fade2a26e1979ff55edee9a7e5b3b8d8bc7dd03b024ace1d0","text":"Skills (recommended)","translated":"技能(推荐)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:40:32Z"} -{"cache_key":"b3fd77464fffaf86fd7c4db02054abe4ad46b8da5cd9a6338a6a164a559039fb","segment_id":"index.md:1cce617e15b49dca","source_path":"index.md","text_hash":"1cce617e15b49dca89b212bb5290edfcfee010ef2eeef369b36af78c53756e1c","text":" — Optional transcription hook","translated":" — 可选的转录钩子","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:02:25Z"} -{"cache_key":"b42df969bf0af88635c8889e57849a3ae5110eab05a4f8e10b1c753221608cdb","segment_id":"environment.md:83848a0a1c101b44","source_path":"environment.md","text_hash":"83848a0a1c101b44035abecc16764b51778799d9824facbfaea7ac1f20205160","text":" missing).","translated":" 缺失时应用)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:46:09Z"} -{"cache_key":"b4368e7921aef9e2b39ca194a48d47f8f2f748e7fc40db1eaf6a96299c60c035","segment_id":"environment.md:frontmatter:summary","source_path":"environment.md:frontmatter:summary","text_hash":"78351223e7068721146d2de022fdf440c2866b2ee02fbbb50bf64369b999820b","text":"Where OpenClaw loads environment variables and the precedence order","translated":"OpenClaw 加载环境变量的位置及优先级顺序","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:11:42Z"} -{"cache_key":"b45cf341beae5b0925d7ae30c7cfb491da5599a692818f25de942e5f6d44fd5f","segment_id":"help/index.md:3c33340bd23b8db8","source_path":"help/index.md","text_hash":"3c33340bd23b8db89f18fe7d05a954738c0dd5ba9623cf6bdb7bb5d1a3729cfc","text":"FAQ (concepts)","translated":"常见问题(概念)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:40:03Z"} -{"cache_key":"b49a5c75c4eda0ec2bb03b48bc5f8fb35df49f92e48f750d30803e61536712db","segment_id":"environment.md:7af0b3e47c35820f","source_path":"environment.md","text_hash":"7af0b3e47c35820fabef69cc542392bd2d0f6e37c349851728f0c683013563ce","text":" variables","translated":" 变量","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:45:19Z"} -{"cache_key":"b49cd3645127938e9fa70191b75226ab757511ee636cdd90fd4dc9ef40062aac","segment_id":"index.md:a7a19d4f14d001a5","source_path":"index.md","text_hash":"a7a19d4f14d001a56c27f68a13ff267859a407c7a9ab457c0945693c9067dd1c","text":"Configuration (optional)","translated":"配置(可选)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:31:26Z"} -{"cache_key":"b4a0896a2b31bdc227d4c1aca7b2e0ff76155083208928f489c597b2ea6ec83d","segment_id":"start/getting-started.md:ab201ddd7ab330d0","source_path":"start/getting-started.md","text_hash":"ab201ddd7ab330d04be364c0ac14ce68c52073a0ee8d164a98c3034e91ce1848","text":" from the repo.","translated":" (从仓库中)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:37:49Z"} -{"cache_key":"b4fb8e7bfdb8c5557d0ae1e567ebe8d168cf15239a265bfc4f64adb97ce03bcf","segment_id":"environment.md:d08a8493f686363a","source_path":"environment.md","text_hash":"d08a8493f686363a78b913d45ebfbd87a3768d1c77b70f23b1fdade3c066e481","text":"Shell env import","translated":"Shell 环境导入","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:19:35Z"} -{"cache_key":"b51ede64ac884a3ba6ed593bb845e2b3e70fa2bcda693b30c537cde875c51011","segment_id":"index.md:9c870aa6e5e93270","source_path":"index.md","text_hash":"9c870aa6e5e93270170d5a81277ad3e623afe8d4efd186d3e28f3d2b646d52e6","text":"How it works","translated":"工作原理","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:28:30Z"} -{"cache_key":"b52208d557116bde692639f735198f71a925dc90223bf31e0b71e9ac7b5bf86d","segment_id":"help/index.md:24669ff48290c187","source_path":"help/index.md","text_hash":"24669ff48290c1875d8067bbd241e8a55444839747bffb8ab99f3a34ef248436","text":"Doctor","translated":"诊断工具","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:24:54Z"} -{"cache_key":"b5687bd5a0443bd1ccaa45996bbe3a784f66fa059e4aa68048a14712f853d56e","segment_id":"start/wizard.md:5f6a8991209034d4","source_path":"start/wizard.md","text_hash":"5f6a8991209034d4d6473c75e2f74dc3df90cc6cde2723d7d25085dbfc3fad24","text":"Providers (Telegram, WhatsApp, Discord, Google Chat, Mattermost (plugin), Signal)","translated":"提供商(Telegram、WhatsApp、Discord、Google Chat、Mattermost(插件)、Signal)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:40:26Z"} -{"cache_key":"b576ef2b8e071c57934d6ae354dfaa261e4f7db4bf4b3b56f33219032da96187","segment_id":"index.md:74926756385b8442","source_path":"index.md","text_hash":"74926756385b844294a215b2830576e3b2e93b84c5a8c8112b3816c5960f3022","text":" — DMs + guild channels via channels.discord.js","translated":" — 通过 channels.discord.js 支持私信和服务器 渠道","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:01:07Z"} -{"cache_key":"b5bd5c17c88739060e3c2a4e7a8f55897a310a69c59782ab65ef312c4d191057","segment_id":"environment.md:baa5be7f6320780b","source_path":"environment.md","text_hash":"baa5be7f6320780bd7bb7b7ddbb8cd1ffb26ccf7d94d363350668c50aedcf95f","text":" (applied only if missing).","translated":" (仅在缺失时应用)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:57:48Z"} -{"cache_key":"b5c57e3a1f3580ad70993c4901523fe0625b4b6b817da47743f3294dd6cf756e","segment_id":"environment.md:f0442e6e05ccca16","source_path":"environment.md","text_hash":"f0442e6e05ccca160d17de0e7d509891b91b921366b2202b2b5c80435824e140","text":"Two equivalent ways to set inline env vars (both are non-overriding):","translated":"设置内联环境变量的两种等效方式(均为不覆盖模式):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:12:43Z"} -{"cache_key":"b5d2ae67c5041c7e1d1c9eee9352714412cb0f7e25d70400ede3ec30640d3481","segment_id":"start/getting-started.md:0fe1f092dca5c0a5","source_path":"start/getting-started.md","text_hash":"0fe1f092dca5c0a52a3225794df21faacf2c8aecbb58e4b35256494e611b88bd","text":" your first DM returns a pairing code. Approve it (see next step) or the bot won’t respond.","translated":" 您的第一条私信会返回一个配对码。请批准它(参见下一步),否则机器人将不会响应。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:37:35Z"} -{"cache_key":"b634da14ec3675bd8c43260adb814ce2e1991550d8eec3a159a73a19bcae0a9a","segment_id":"environment.md:b1d6b91b67c2afa5","source_path":"environment.md","text_hash":"b1d6b91b67c2afa5e322988d9462638d354ddf8a1ef79dba987f815c22b4baee","text":" at ","translated":" 位于 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:40:41Z"} -{"cache_key":"b6ab46af0248d53e3135ec04e8fdf33e79acec2a08cc8870fdc18ceca6b5b032","segment_id":"start/wizard.md:16f0ee47f993d627","source_path":"start/wizard.md","text_hash":"16f0ee47f993d6270c9059450473eea493ca8ae037f8877782ae2bc176f24d18","text":"API key","translated":"API 密钥","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:42:43Z"} -{"cache_key":"b6acf6603c3288ff678824fbc05208f2ffb9265ef1bd538af6b719f3bd0a3117","segment_id":"index.md:2f1626425f985d9a","source_path":"index.md","text_hash":"2f1626425f985d9ad8c124ea8ccb606e404ae5f43c58bd16b6c109d6d2694083","text":"Most operations flow through the ","translated":"大多数操作通过 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:59:45Z"} -{"cache_key":"b728d99548c47aac6f8b5df5cba915a124aa44c07b51c9bc7a298f73f98caf13","segment_id":"start/wizard.md:1e9806e4227ba3b9","source_path":"start/wizard.md","text_hash":"1e9806e4227ba3b9a986732f1b09a21fd6b96043d12e5a4334a326ec5ad39842","text":"Signal","translated":"Signal","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:44:46Z"} -{"cache_key":"b74d2e77f6dff2d670948e7bc471317b3d93cdcbd69be8b2a1c8b1c1e29fa6e7","segment_id":"index.md:1cce617e15b49dca","source_path":"index.md","text_hash":"1cce617e15b49dca89b212bb5290edfcfee010ef2eeef369b36af78c53756e1c","text":" — Optional transcription hook","translated":" —— 可选的转录钩子","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:50:58Z"} -{"cache_key":"b798dd1056c4bde2b213da50c0deaf67f6fa43b67ba57c7e8608a4ba80573de8","segment_id":"index.md:c011d6097bfbc8e9","source_path":"index.md","text_hash":"c011d6097bfbc8e936280addcf2e3e7d06ea2223ffd596973191b800a7035c32","text":"License","translated":"许可证","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:34:04Z"} -{"cache_key":"b7b8bda0e930aa53d189e38c5844ac805547a37ca102751746c8e425c06a684c","segment_id":"index.md:0d3a30eb74e2166c","source_path":"index.md","text_hash":"0d3a30eb74e2166c1fc51b99b180841f808f384be53fe1392cecb67fdc9363c4","text":" (default ","translated":" (默认 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:29:09Z"} -{"cache_key":"b7c642921d34922bbb25206e04a52428c8707414d90f0aa9bbc07366d3165e09","segment_id":"start/getting-started.md:73526fff31f4fa0a","source_path":"start/getting-started.md","text_hash":"73526fff31f4fa0a98e4e135e0610652867bd8842a6abeb821e02ee87842bb96","text":"Telegram DM tip:","translated":"Telegram 私信提示:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:37:33Z"} -{"cache_key":"b7ef55ac0b21abd132744ee6daa0d8aebba830cf42b99105a8aa15035f636f7c","segment_id":"help/index.md:8cd501e1124c3047","source_path":"help/index.md","text_hash":"8cd501e1124c30473473c06e536a2d145e2a14a6d7dc1b99028ce818e14442e2","text":"Repairs:","translated":"修复:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:44:56Z"} -{"cache_key":"b842ea6173ee701821bd377af17913bb92cafb54dc487075c302ab3d329c88cc","segment_id":"index.md:723784fa2b6a0876","source_path":"index.md","text_hash":"723784fa2b6a0876540a92223ee1019f24603499d335d6d82afbc520ef5b5d57","text":") — Creator, lobster whisperer","translated":")— 创作者,龙虾低语者","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:33:34Z"} -{"cache_key":"b879fc6c7bf9bcf2521829ee80839cc6b64fa033303e0d3ad8f4c14519022dd7","segment_id":"index.md:b332c3492d5eb10a","source_path":"index.md","text_hash":"b332c3492d5eb10a118eb6d8b0dcd689bc2477ce2ae16b303753b942b54377bc","text":"Configuration","translated":"配置","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:03:58Z"} -{"cache_key":"b8d882b2664af754e0a9242db46afe45a00690cb9294ebf818b517be4eb004fd","segment_id":"index.md:a10f6ed8c1ddbc10","source_path":"index.md","text_hash":"a10f6ed8c1ddbc10d3528db7f7b6921c1dd5a5e78aa191ff017bf29ce2d26449","text":"⏱️ ","translated":"⏱️ ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:01:31Z"} -{"cache_key":"b8d9c5b6ac9e5a115d60a75c55a842231d71850c2d69bfb8c20b79e3e7744b35","segment_id":"environment.md:d4a67341570f4656","source_path":"environment.md","text_hash":"d4a67341570f4656784c5f8fe1bfb48a738ace57b52544977431d50e2b718099","text":"FAQ: env vars and .env loading","translated":"常见问题:环境变量和 .env 加载","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:47:18Z"} -{"cache_key":"b8efaaee77774922e208bb819be2e16edb0632860d03d901df8701e7582bec11","segment_id":"index.md:8816c52bc5877a2b","source_path":"index.md","text_hash":"8816c52bc5877a2b24e3a2f4ae7313d29cf4eba0ca568a36f2d00616cfe721d0","text":"Wizard","translated":"向导","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:47:48Z"} -{"cache_key":"b90215fad5e3df7edc635820365a97567dc5fed769e90812e8444decb4691cc5","segment_id":"start/getting-started.md:45e6d69dbe995a36","source_path":"start/getting-started.md","text_hash":"45e6d69dbe995a36f7bc20755eff4eb4d2afaaedbcac4668ab62540c57219f32","text":"macOS app","translated":"macOS 应用","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:38:23Z"} -{"cache_key":"b9140801ceed17bc6beff66df05f3fb6f825ffb03c25525672a9ed9d37cc8bef","segment_id":"index.md:be48ae89c73a75da","source_path":"index.md","text_hash":"be48ae89c73a75da3454d565526d777938c20664618905a9bc77d6a0a21a689d","text":"\"EXFOLIATE! EXFOLIATE!\"","translated":"\"EXFOLIATE! EXFOLIATE!\"","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:27:51Z"} -{"cache_key":"b91b95d70281a4d1d45bd8c853fa8bfa893d10fa36509361b58b83df1111b31b","segment_id":"help/index.md:cad44fbae951d379","source_path":"help/index.md","text_hash":"cad44fbae951d3791565b0cee788c01c3bd10e0176167acb691b8dba0f7895f8","text":"Gateway logging","translated":"Gateway 日志记录","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:44:54Z"} -{"cache_key":"b9b11fe51f278fc05b76b9e48d84a6b796c35bd940491457befd53ac08255496","segment_id":"help/index.md:b79cac926e0b2e34","source_path":"help/index.md","text_hash":"b79cac926e0b2e347e72cc91d5174037c9e17ae7733fd7bdb570f71b10cd7bfc","text":"Help","translated":"帮助","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:39:31Z"} -{"cache_key":"b9c7cee99b82c57ac554d288a0b5aee19b5ca5fc10cdd6f59b31a4fc7450c3a9","segment_id":"index.md:22159a426e4f2635","source_path":"index.md","text_hash":"22159a426e4f26356382cc3ac9b2e7af5123c1309250332f5dcbbc6e6f952b0e","text":"Network model","translated":"网络模型","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:28:38Z"} -{"cache_key":"b9cc5f90d0c6e7deedd6ec40f46f56ce1c36844b849a3acbd488724173d8b7f4","segment_id":"start/wizard.md:254a5988b52ecb17","source_path":"start/wizard.md","text_hash":"254a5988b52ecb1730f5ab74e7998f0789c62c194e32d6a29c9500129905438d","text":"More detail: ","translated":"更多详情: ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:42:50Z"} -{"cache_key":"ba73a727d35cc4ad3a5a48130b91107a13324a115d536a8b936ca4b56d0b8ebf","segment_id":"start/wizard.md:b248f2e01881f536","source_path":"start/wizard.md","text_hash":"b248f2e01881f536176ab4f5c76d6c067348339e0ddd2be6d2b0b0435c09f614","text":"MiniMax","translated":"MiniMax","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:43:00Z"} -{"cache_key":"ba7cd0c5865f73af1ced8609de573bce503046cc0135f10edf71d69ac7bed742","segment_id":"index.md:45808d75bf8911fa","source_path":"index.md","text_hash":"45808d75bf8911fa21637f9dd3f0dace1877748211976b5d61fcc5c15db594d0","text":"Webhooks","translated":"Webhooks","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:53:51Z"} -{"cache_key":"ba881a89787827ca73c2b6efade0f1b148a3093729931f54bcedd6516714ef9a","segment_id":"environment.md:3fe738a7ee6aaff5","source_path":"environment.md","text_hash":"3fe738a7ee6aaff51f099d9a8314510c99ced6a568eb38c67642cd43bb54eec0","text":" in the current working directory","translated":" 在当前工作目录中","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:57:25Z"} -{"cache_key":"bab136853746e7adb4f3d6a9085f276c4b3b60b32935bf0abee97c4b8b0847d2","segment_id":"index.md:frontmatter:read_when:0","source_path":"index.md:frontmatter:read_when:0","text_hash":"08965a8ab25e66157009d1617fc167bcc2404fa0c0ca50b1e5e5750957be3b10","text":"Introducing OpenClaw to newcomers","translated":"向新用户介绍 OpenClaw","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:47:20Z"} -{"cache_key":"bab5f315b0b714729442371eeede15ca920f42aa5f8a6a5bbf4f2831cec6bab7","segment_id":"start/getting-started.md:1e3abf61a37e3cad","source_path":"start/getting-started.md","text_hash":"1e3abf61a37e3cad36b11b459b1cc39e76feb6a0c369fe5270957468288dcc5c","text":"If ","translated":"如果 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:38:00Z"} -{"cache_key":"bb01273ae2008ef3e65dac6325f0912e3297b1445007151a0eb13c637d664344","segment_id":"environment.md:907940a35852447a","source_path":"environment.md","text_hash":"907940a35852447aad5f21c5a180d993ff31cfd5807b1352ed0c24eabe183465","text":"never override existing values","translated":"永远不覆盖已有的值","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:25:19Z"} -{"cache_key":"bb01eace88e3c0c55a9f90dcd2ef4db17d3ef428c9c3f0cbbc533816b3673889","segment_id":"start/getting-started.md:5ca32046e4b3e547","source_path":"start/getting-started.md","text_hash":"5ca32046e4b3e5476abcfc30f1d5abfcc42cf2cb6ad8b42b35ed51f62cddaead","text":"). It sets up:","translated":")。它会设置:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:34:51Z"} -{"cache_key":"bb8fe17d51b04ff11d3cdd2b428662d48746db111cb4bc5cb91f4c30ab33c86e","segment_id":"start/wizard.md:c10c181a3b7e8440","source_path":"start/wizard.md","text_hash":"c10c181a3b7e84404d307e21cf48264c7ff7e0d4a04ee15af969b08ebe47d7a3","text":" (and ","translated":" (以及 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:40:52Z"} -{"cache_key":"bbae60d9c55f0a4c17edd70724bf025a80c357507af18bc456b69d8e22351dd3","segment_id":"environment.md:d08a8493f686363a","source_path":"environment.md","text_hash":"d08a8493f686363a78b913d45ebfbd87a3768d1c77b70f23b1fdade3c066e481","text":"Shell env import","translated":"Shell 环境导入","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:26:10Z"} -{"cache_key":"bbd317ed227cca52a63156dec7f92240d0a532979467fb3bb2f12481519aa3a6","segment_id":"index.md:5583785669449fc8","source_path":"index.md","text_hash":"5583785669449fc81a8037458c908c11a8f345c21c28f7f3a95de742bd52199a","text":"Media Support","translated":"媒体支持","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:30:36Z"} -{"cache_key":"bbe1dd74f66fb83dcdd47e77fb4ce919331298e2a8b6bb3fc8b3d0ee940a031a","segment_id":"index.md:f0a7f9d068cb7a14","source_path":"index.md","text_hash":"f0a7f9d068cb7a146d0bb89b3703688d690ed0b92734b78bcdb909aace617dbf","text":"WhatsApp group messages","translated":"WhatsApp 群消息","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:53:23Z"} -{"cache_key":"bc2419d59f866ec8c3f5529c5f2e87039a9e0ec7403f6fa82664b7ef9af23d47","segment_id":"start/getting-started.md:b1c8a72bb57dc747","source_path":"start/getting-started.md","text_hash":"b1c8a72bb57dc747671a456250fab49db53d0fef744eae4b959a66a4abb7aba9","text":"exe.dev","translated":"exe.dev","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:38:39Z"} -{"cache_key":"bc2967e4d37bce4abb8008d556e2894e97049fdfe54c4a2d6b6bdf8639a1cfd3","segment_id":"environment.md:c2d7247c8acb83a5","source_path":"environment.md","text_hash":"c2d7247c8acb83a5a020458fa836c2445922b51513dbdbf154ab5f7656cb04e9","text":"; does not override).","translated":";不会覆盖)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:16:22Z"} -{"cache_key":"bc6f614c30433e6b3bff168a2080525f05fc94e339dc42f43f2e189e3a0b226e","segment_id":"index.md:329f3c913c0a1636","source_path":"index.md","text_hash":"329f3c913c0a16363949eb8ee7eb0cda7e81137a3851108019f33e5d18b57d8f","text":"Switching between npm and git installs later is easy: install the other flavor and run ","translated":"之后在 npm 和 git 安装之间切换很简单:安装另一种方式并运行 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:03:00Z"} -{"cache_key":"bcaa9af5387d4c16acfcf673ef371654f20d3f5740f64c92747435d166d53bee","segment_id":"start/wizard.md:d65be5fbfc8f6bc9","source_path":"start/wizard.md","text_hash":"d65be5fbfc8f6bc9316db63dff758f2a5758d3fa4ddde8562b89a9baa35c0b9d","text":"Starts the Gateway (if needed) and runs ","translated":"启动 Gateway(如需)并运行 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:45:38Z"} -{"cache_key":"bcc6f5f4ada16ba9c99157f111747d22c499aab86af420e947d68797df7d0dc2","segment_id":"environment.md:a258b30f88c30650","source_path":"environment.md","text_hash":"a258b30f88c30650e73073d5bdde5cfcc6987100ae62d37789e5c46a0d85b7c6","text":"Global ","translated":"全局 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:12:09Z"} -{"cache_key":"bd0afd9947ca223c780705636e9fd81efec6d821d54ead3dd5755b82ca6cabbb","segment_id":"index.md:86e2bbbc305c31aa","source_path":"index.md","text_hash":"86e2bbbc305c31aa988751196a1e207da68801a48798c48b90485c11578443a0","text":"Providers and UX:","translated":"提供商 和用户体验:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:53:04Z"} -{"cache_key":"bd1a787c9d8cd0ad83cfd8fd6de5a4da5fdb050d4b594904e623a1d17c5b8c21","segment_id":"index.md:c7a5e268ddd8545e","source_path":"index.md","text_hash":"c7a5e268ddd8545e5a59a58ef1365189862f802cc7b61d4a3212c70565e2dff1","text":"WhatsApp Integration","translated":"WhatsApp 集成","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:29:30Z"} -{"cache_key":"bde9f3ebeb2359b3b7aedd826d0a1d12e084a4a61310d0f5bd394d8eb5a120ba","segment_id":"index.md:39bbb719fa2b9d22","source_path":"index.md","text_hash":"39bbb719fa2b9d2251039cbf2cd072e1120a414278263e2f11d99af0236c4262","text":"Groups","translated":"群组","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:04:53Z"} -{"cache_key":"bdecbc1be817872a0692411e78c70677191fe2da6e081c369fb09de0ef2601cf","segment_id":"start/getting-started.md:618240b69ec6c809","source_path":"start/getting-started.md","text_hash":"618240b69ec6c8090801f0a1c0298939ec16e6c30607b1117173bd5e4770f27e","text":"first working chat","translated":"第一次成功聊天","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:34:29Z"} -{"cache_key":"bdf975c1288d74f830e912ea439f34bd12327414e67a0e3b0031b20daee8fa90","segment_id":"environment.md:a5839747a1cd90df","source_path":"environment.md","text_hash":"a5839747a1cd90df1cb7dbb6df6d1dddba552865d54e3e2fa0c6b87e6616c666","text":"; does not","translated":";不会","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:45:54Z"} -{"cache_key":"bdfbc7a0fd631f051f7b46e21b996d7aa66ab700fe12e4153d61fb8cccd72b43","segment_id":"environment.md:32ebb1abcc1c601c","source_path":"environment.md","text_hash":"32ebb1abcc1c601ceb9c4e3c4faba0caa5b85bb98c4f1e6612c40faa528a91c9","text":" (","translated":" (","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:46:15Z"} -{"cache_key":"be43620abdc44274f5a6124fe94af02680937d7b3c843471640e6d3cfdbcb11b","segment_id":"index.md:81a1c0449ea684aa","source_path":"index.md","text_hash":"81a1c0449ea684aadad54a7f8575061ddc5bfa713b6ca3eb8a0228843d2a3ea1","text":"Nodes (iOS/Android)","translated":"节点(iOS/Android)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:04:30Z"} -{"cache_key":"be853f7b692e34fd9acbac3fc2a4beaeffb4fccfc645083457d04704ce7e80a6","segment_id":"start/getting-started.md:32ebb1abcc1c601c","source_path":"start/getting-started.md","text_hash":"32ebb1abcc1c601ceb9c4e3c4faba0caa5b85bb98c4f1e6612c40faa528a91c9","text":" (","translated":" (","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:34:49Z"} -{"cache_key":"be9ef45489c42d85ef02ddbdc619e8f571efb5ad273236b11af6b087a73aac32","segment_id":"start/wizard.md:d03502c43d74a30b","source_path":"start/wizard.md","text_hash":"d03502c43d74a30b936740a9517dc4ea2b2ad7168caa0a774cefe793ce0b33e7","text":", ","translated":", ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:47:09Z"} -{"cache_key":"bed2a7f7ebfcaa0b5e9c08db15a562558ebb609b6ad450b19a160511fd76f36d","segment_id":"start/getting-started.md:569ca49f4aaf7846","source_path":"start/getting-started.md","text_hash":"569ca49f4aaf7846e952c1d4aeca72febd0b79fa1c4f9db08fd3127551218572","text":"Install","translated":"安装","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:35:50Z"} -{"cache_key":"bed8e880f819b1664d27c979ee19546883dc77b25b3f4c2bca3bb320cdb7a997","segment_id":"index.md:9bd86b0bbc71de88","source_path":"index.md","text_hash":"9bd86b0bbc71de88337aa8ca00f0365c1333c43613b77aaa46394c431cb9afd8","text":"Maxim Vovshin","translated":"Maxim Vovshin","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:54:41Z"} -{"cache_key":"bedbf38b79db3a7e2c68181e1c39bb9dbb0ec7872bd05c86a18d5a2cea9ff52d","segment_id":"environment.md:3fe738a7ee6aaff5","source_path":"environment.md","text_hash":"3fe738a7ee6aaff51f099d9a8314510c99ced6a568eb38c67642cd43bb54eec0","text":" in the current working directory","translated":" 在当前工作目录中","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:40:35Z"} -{"cache_key":"bf1804a981e692a3e488d95ec6501ae0a175844faed4b62050b2c407110d73e9","segment_id":"environment.md:e234227b0e001687","source_path":"environment.md","text_hash":"e234227b0e001687821541fac3af38fc6be293ec6e13910c6826b9afc8ca33be","text":" syntax:","translated":" 语法:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:41:28Z"} -{"cache_key":"bf2db6ac9b982594da3a3e3dd13d6ca4e223ff16e13a95d31ffb072ab1d32c8e","segment_id":"start/getting-started.md:883c79fabfe68ee2","source_path":"start/getting-started.md","text_hash":"883c79fabfe68ee271a7635815ea9c87295a436a075926633e8865ec60c4303e","text":" (optional; recommended if you build from source)","translated":" (可选;如果从源码构建则推荐安装)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:35:26Z"} -{"cache_key":"bf37699805bcf078c1b1ce444bf6d1198e99667a6db48d7eb7e641c41262087b","segment_id":"start/getting-started.md:76dfd9f9a399a76a","source_path":"start/getting-started.md","text_hash":"76dfd9f9a399a76a13b092e0ce512519b8fc0cfef720142556a8350f70a040ab","text":"Pairing","translated":"配对","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:35:12Z"} -{"cache_key":"bf6afd51b6d116b7f7aac3bb45cc7db66e823c2fc1abd26e91d2f29989e56a53","segment_id":"index.md:da22b9d6584e1d8a","source_path":"index.md","text_hash":"da22b9d6584e1d8aa709165be214e0f9bdf2be428816e9ce1c4506bf86218cb4","text":"Core Contributors","translated":"核心贡献者","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:54:38Z"} -{"cache_key":"bf6c745f2cf524d912a0e53b92bb7278771b97f3974e15bf7d7d5c21d2cb2bb7","segment_id":"start/wizard.md:fd42bd9065e9791f","source_path":"start/wizard.md","text_hash":"fd42bd9065e9791f5e6a611205a54d922d1b8046f78d72cb2b35a156a2ee379a","text":"WhatsApp credentials go under ","translated":"WhatsApp 凭据存储在 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:48:51Z"} -{"cache_key":"bf825adb6efc12b0b76cb65939a149b13d9affa681ea8c41f0ff54043e15afc1","segment_id":"environment.md:7175517a370b5cd2","source_path":"environment.md","text_hash":"7175517a370b5cd2e664e3fd29c4ea9db5ce17058eb9772fe090a5485e49dad6","text":" or ","translated":" 或 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:12:35Z"} -{"cache_key":"c01e11953c6ba2efa9bec09338b3e7fcbdc647a92bf09fd08678e0c6e1ee9598","segment_id":"index.md:ec05222b3777fd7f","source_path":"index.md","text_hash":"ec05222b3777fd7f91a2964132f05e3cfc75777eaeec6f06a9a5c9c34a8fc3e9","text":"Nix mode","translated":"Nix 模式","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:32:10Z"} -{"cache_key":"c0282f2e4f7da80d51262220226e41d0c83df835b9e776ed420a3fe11663d5b2","segment_id":"start/wizard.md:6301b8b1517facda","source_path":"start/wizard.md","text_hash":"6301b8b1517facda1ab48a0af2e5ed47f68867711466089050b20180cfc22433","text":"Synthetic example:","translated":"Synthetic 示例:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:47:31Z"} -{"cache_key":"c09d5cb253479a62ae433edd398f06b46aff64efd80ab44a984de33922120de6","segment_id":"start/getting-started.md:160d9109519d8d17","source_path":"start/getting-started.md","text_hash":"160d9109519d8d17b25b1d2f8202aaab71eafe0a21aa1384978dc89d2679d370","text":"From source (development)","translated":"从源码安装(开发)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:37:43Z"} -{"cache_key":"c100ce5c5170c043a021982a580ebd78cad6232e67057fb8d0d55dfa3fe1e8d3","segment_id":"environment.md:ffa63583dfa6706b","source_path":"environment.md","text_hash":"ffa63583dfa6706b87d284b86b0d693a161e4840aad2c5cf6b5d27c3b9621f7d","text":"missing","translated":"缺失的","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:22:23Z"} -{"cache_key":"c11f020ea3afe93aee794662658476af49988cea0ddc160f7f8c27b1b245076a","segment_id":"environment.md:9c85ab59cb358b12","source_path":"environment.md","text_hash":"9c85ab59cb358b1299c623e16f52f3aee204a81fb6d1c956e37607a220d13b08","text":"You can reference env vars directly in config string values using `${VAR_NAME}` syntax:","translated":"你可以在配置字符串值中使用 `${VAR_NAME}` 语法直接引用环境变量:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:19:44Z"} -{"cache_key":"c1300944d87f5d93e003235afb1a2a255806cb0ca7ca73081c0bda9081c00781","segment_id":"index.md:4d4d75c23a2982e1","source_path":"index.md","text_hash":"4d4d75c23a2982e184011f79e62190533f93cdad41ba760046419678fa68d430","text":"Runtime requirement: ","translated":"运行时要求: ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:51:25Z"} -{"cache_key":"c1ed30ab3d008a94d1201ceb1b0681fb09d05d747adddc2cd9bd9ec64544cddf","segment_id":"start/wizard.md:cb773b9bc6fc5373","source_path":"start/wizard.md","text_hash":"cb773b9bc6fc5373e0b338fbcb709df301cd8e11f0699de40cb0c1c4bf3def77","text":"Existing config detection","translated":"现有配置检测","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:40:58Z"} -{"cache_key":"c2188cce98f83b4b4240fddcdd99a6504e9b9e044a40f429257a85d2f2485f22","segment_id":"help/index.md:71095a6d42f5d9c2","source_path":"help/index.md","text_hash":"71095a6d42f5d9c2464a8e3f231fc53636d4ce0f9356b645d245874162ec07e2","text":"Gateway troubleshooting","translated":"Gateway 故障排除","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:15:23Z"} -{"cache_key":"c226a7b155f9acc3489810036a9112a8b4d498f14a983ecad4c72d8b48925751","segment_id":"index.md:63a3abfa879299dd","source_path":"index.md","text_hash":"63a3abfa879299ddcc03558012bfd6075cbd72f7a175b739095bf979700297f7","text":"Multi-instance quickstart (optional):","translated":"多实例快速开始(可选):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:03:15Z"} -{"cache_key":"c2802148a29fff6480dd7c4126df1d7787f83156807ce1f6e0abb05d2e0a7863","segment_id":"index.md:6e0f6eca4ff17d33","source_path":"index.md","text_hash":"6e0f6eca4ff17d3377c1c3e8e1f73457553ad3b9cfcd5e4f2b94cfb1028b6234","text":"iOS app","translated":"iOS 应用","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:53:36Z"} -{"cache_key":"c2acf62bea34b4557cbab8b7ceadd55c5cf37516c124b93afc1b8e9f08d62ab0","segment_id":"index.md:39bbb719fa2b9d22","source_path":"index.md","text_hash":"39bbb719fa2b9d2251039cbf2cd072e1120a414278263e2f11d99af0236c4262","text":"Groups","translated":"群组","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:53:21Z"} -{"cache_key":"c2e74d237df6614199282b8822741be509ff03e31b7319f3184bb2537860e8a9","segment_id":"index.md:bf084dc7b82e1e62","source_path":"index.md","text_hash":"bf084dc7b82e1e62c63727b13451d1eba2269860e27db290d2d5908d7ade0529","text":" — Pairs as a node and exposes Canvas + Chat + Camera","translated":" — 作为节点配对并提供 Canvas + 聊天 + 相机","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:02:43Z"} -{"cache_key":"c335a0e455574c0e23a45c10a55511400b6168c38aa7d8e43521b1c8650e58f9","segment_id":"environment.md:frontmatter:read_when:1","source_path":"environment.md:frontmatter:read_when:1","text_hash":"a3a2d99a99de98220c8e0296d6f4e4b2a34024916bd2379d1b3b9179c8fae46f","text":"You are debugging missing API keys in the Gateway","translated":"您正在调试 Gateway 中缺失的 API 密钥","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:40:15Z"} -{"cache_key":"c34f893f16dcd3b37a3752585df805b44212829550f3d82cb5f539fdb50a5a50","segment_id":"environment.md:87e89abb4c1c551f","source_path":"environment.md","text_hash":"87e89abb4c1c551fe08d355d097f18b8de78edca5f556997085681662fce8eed","text":"Config ","translated":"配置 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:25:46Z"} -{"cache_key":"c359a69d5e0e9e6470f36436f1b27a946ef28ef1069e7b7d59e0ea3132f6003c","segment_id":"start/wizard.md:4cd440e57b28aba7","source_path":"start/wizard.md","text_hash":"4cd440e57b28aba7f789ba11d0bb5837f09937ba45bab9a80b9a6a980894250e","text":"Follow‑up reconfiguration:","translated":"后续重新配置:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:39:15Z"} -{"cache_key":"c360b15d624bad75d881fff636c494f57b21345481b98d674df5baa6a31c7b06","segment_id":"index.md:cdb4ee2aea69cc6a","source_path":"index.md","text_hash":"cdb4ee2aea69cc6a83331bbe96dc2caa9a299d21329efb0336fc02a82e1839a8","text":".","translated":"。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:48:02Z"} -{"cache_key":"c363b2aa05942d39fd0ddcddc9b63daca312937a794a7bc8027a049c9befb2bb","segment_id":"environment.md:61115f6649792387","source_path":"environment.md","text_hash":"61115f664979238731a390e84433a818965b7eaf1d38fa5b4b1507c33ef28c91","text":"Precedence (highest → lowest)","translated":"优先级(从高到低)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:11:40Z"} -{"cache_key":"c3c32ca9a6e0b7331bd674903379664ef8c5ab9f675dbb531062af512452343e","segment_id":"index.md:acdd1e734125f341","source_path":"index.md","text_hash":"acdd1e734125f341604c0efbabdcc4c4b0597e8f6235d66c2445edd1812838c1","text":"Telegram","translated":"Telegram","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:04:46Z"} -{"cache_key":"c3f6ef9654ecec9e668759d52d4b3b337eb11cfc8c41c6c29afbd4c7a6b1a3aa","segment_id":"index.md:f0b349e90cb60b2f","source_path":"index.md","text_hash":"f0b349e90cb60b2f96222d0be1ff6532185f385f4909a19dd269ea3e9e77a04d","text":" (default); groups are isolated","translated":" (默认);群组是隔离的","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:50:35Z"} -{"cache_key":"c459652c41ab2b7b00625ccdcbb406c410e20c6427b9c7db02f6fdd47ba6f749","segment_id":"help/index.md:156597e2632411d1","source_path":"help/index.md","text_hash":"156597e2632411d1d5f634db15004072607ba45072a4e17dfa51790a37b6781f","text":"Gateway issues:","translated":"Gateway 问题:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:15:21Z"} -{"cache_key":"c483b6ba1c94a31f76c8e7312a407d38c30bce0f4712658564a6f18c1216a82d","segment_id":"environment.md:frontmatter:read_when:1","source_path":"environment.md:frontmatter:read_when:1","text_hash":"a3a2d99a99de98220c8e0296d6f4e4b2a34024916bd2379d1b3b9179c8fae46f","text":"You are debugging missing API keys in the Gateway","translated":"您正在调试 Gateway 中缺失的 API 密钥","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:57:04Z"} -{"cache_key":"c492c9a161bdd51ea2c2cdd14a9b9bb5db1cd52cf0c29fb37109d054b7ee9a0d","segment_id":"help/index.md:frontmatter:summary","source_path":"help/index.md:frontmatter:summary","text_hash":"aece82a2d540ab1a9a21c7b038127cae6e9db2149491564bb1856b6f8999f205","text":"Help hub: common fixes, install sanity, and where to look when something breaks","translated":"帮助中心:常见修复方法、安装健全性检查,以及出问题时该去哪里排查","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:21:29Z"} -{"cache_key":"c4cdc8fbd5869ac2ecac6d2aedef557b386f308d0e2819293e3c743d3cc6ae86","segment_id":"help/index.md:3c33340bd23b8db8","source_path":"help/index.md","text_hash":"3c33340bd23b8db89f18fe7d05a954738c0dd5ba9623cf6bdb7bb5d1a3729cfc","text":"FAQ (concepts)","translated":"常见问题(概念)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:56:50Z"} -{"cache_key":"c504cdf411edd24aafa9f9af4a1d9555dd4813e7717812dd30f12f6ce0f36335","segment_id":"start/wizard.md:14290e1d06812977","source_path":"start/wizard.md","text_hash":"14290e1d0681297772dedd7ea7e78b2d2492a46382251c6f8f49a2977978ece1","text":"Health check","translated":"健康检查","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:40:30Z"} -{"cache_key":"c50cf8a23b65ef73b5bfb717f8d28d8e2e13435175b38a9b94a6a92fd79c241a","segment_id":"start/wizard.md:2addbbaf06856d61","source_path":"start/wizard.md","text_hash":"2addbbaf06856d61875d46a98c898d3985a48f1028e2e5f1f8b68022902f5879","text":"Kimi Coding","translated":"Kimi Coding","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:43:10Z"} -{"cache_key":"c5341e93cca2bf9b412fba71811b5c75affd70642d5cac522c20f656fce8c171","segment_id":"start/getting-started.md:85ed1b061af844c7","source_path":"start/getting-started.md","text_hash":"85ed1b061af844c761d40a5328177c10aea1be3a6eb49e3ef2aad5e9724c5edc","text":"Always-on / VPN setups: ","translated":"常驻运行 / VPN 设置: ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:38:38Z"} -{"cache_key":"c547a034bcb14649348b839344981ab2abe4d278dd058e299ee088aca6d1cbb2","segment_id":"index.md:19525ac5e5b9c476","source_path":"index.md","text_hash":"19525ac5e5b9c476b36a38c5697063e37e8fe2fae8ef6611f620def69430cf74","text":"Canvas host","translated":"Canvas 主机","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:48:55Z"} -{"cache_key":"c577961fb1dd2981c632731703b6fa32ce458c2a1d22f624053d893601313a69","segment_id":"index.md:9fc31bacba5cb332","source_path":"index.md","text_hash":"9fc31bacba5cb33207804b9e6a8775a3f9521c9a653133fd06e5d14206103e48","text":"Streaming + chunking","translated":"流式传输与分块","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:30:02Z"} -{"cache_key":"c59753265e4fde2eba581fac96edc31fac666f999016b10be79673bb9f8fff01","segment_id":"index.md:f3047ab42a6a5bbf","source_path":"index.md","text_hash":"f3047ab42a6a5bbf164106356fa823ecada895064120c4e5a30e1f632741cc5f","text":"Web surfaces","translated":"Web 界面","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:28:25Z"} -{"cache_key":"c5a0c89316164529f7023f5315efd593449709ea51025067e3e3ac600c2b8955","segment_id":"index.md:f1e3b32c8eb0df8e","source_path":"index.md","text_hash":"f1e3b32c8eb0df8ea105f043edf614005742c15581e2cebc5a9c3bafb0b90303","text":"Multi-agent routing","translated":"多智能体路由","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:30:10Z"} -{"cache_key":"c5aa6ee094793c8175cf46e51a84ccfa7bfb96483511447ee39bffc5c5d621a6","segment_id":"index.md:f9b8279bc46e847b","source_path":"index.md","text_hash":"f9b8279bc46e847bfcc47b8701fd5c5dc27baa304d5add8278a7f97925c3ec13","text":"Mattermost (plugin)","translated":"Mattermost(插件)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:04:51Z"} -{"cache_key":"c600fc61e6150bfb212aae76377cc9c818199be5b646524cacdc83e1c8548e4c","segment_id":"help/index.md:2adc964c084749b1","source_path":"help/index.md","text_hash":"2adc964c084749b1f2d8aef24030988b667dbda2e38a6a1699556c93e07c1cea","text":"Start here","translated":"从这里开始","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:56:22Z"} -{"cache_key":"c63aa51793c779b2a349df7ffac1628bb2cc332f86766a178ca09f0d1ab6b9ef","segment_id":"environment.md:d08a8493f686363a","source_path":"environment.md","text_hash":"d08a8493f686363a78b913d45ebfbd87a3768d1c77b70f23b1fdade3c066e481","text":"Shell env import","translated":"Shell 环境导入","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:12:45Z"} -{"cache_key":"c6579c041588ea1bb181e9a8deb7415e62a84d1bd20b12e4570bfbd9c2ade3d8","segment_id":"index.md:233cfad76c3aa9dd","source_path":"index.md","text_hash":"233cfad76c3aa9dd5cc0566746af197eac457a88c1e300ae788a8ada7f96b383","text":"From source (development):","translated":"从源码安装(开发):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:31:17Z"} -{"cache_key":"c68def9780e982378b5c6b30c5c52340f6495cfc16ebe6378fa8655b7df12662","segment_id":"start/wizard.md:663ea1bfffe5038f","source_path":"start/wizard.md","text_hash":"663ea1bfffe5038f3f0cf667f14c4257eff52d77ce7f2a218f72e9286616ea39","text":" to ","translated":" 为 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:42:22Z"} -{"cache_key":"c6ca6417d36b17f38103f8c80bf1974f694de7f8e5cf6c75e2df8722898afc33","segment_id":"environment.md:frontmatter:read_when:0","source_path":"environment.md:frontmatter:read_when:0","text_hash":"90fc0487bff88009979cff1061c1a882df8c3b1baa9c43538331d9d5dab15479","text":"You need to know which env vars are loaded, and in what order","translated":"您需要了解加载了哪些 环境变量,以及加载顺序","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:40:12Z"} -{"cache_key":"c6f804812e9ae46bd893fabd31bc85463705d5a761157ef2dccfdc6f8a278d20","segment_id":"index.md:da22b9d6584e1d8a","source_path":"index.md","text_hash":"da22b9d6584e1d8aa709165be214e0f9bdf2be428816e9ce1c4506bf86218cb4","text":"Core Contributors","translated":"核心贡献者","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:06:12Z"} -{"cache_key":"c6fafa0a56fb2daa3b5eb1ab12bcde96e81c8b5eb8c495ac27c999aa7ece81f0","segment_id":"index.md:66354a1d3225edbf","source_path":"index.md","text_hash":"66354a1d3225edbf01146504d06aaea1242dcf50424054c3001fc6fa2ddece0f","text":"Remote access","translated":"远程访问","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:00:41Z"} -{"cache_key":"c749935b054cdee1215d5c7bc80ccadc89b5251b7b720e58f1fc750832c291cc","segment_id":"start/getting-started.md:f940dae2228542bc","source_path":"start/getting-started.md","text_hash":"f940dae2228542bc51f88220681f263413d5d91c47a84b411600abc82294299a","text":"),\nso group/channel sessions are sandboxed. If you want the main agent to always\nrun on host, set an explicit per-agent override:","translated":"),因此群组/渠道会话是沙箱化的。如果您希望主智能体始终在主机上运行,请设置显式的逐智能体覆盖:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:35:21Z"} -{"cache_key":"c7938f197a6a0913e762962c7a145a194fb20261b6366796749cfff9bec325f1","segment_id":"help/index.md:cad44fbae951d379","source_path":"help/index.md","text_hash":"cad44fbae951d3791565b0cee788c01c3bd10e0176167acb691b8dba0f7895f8","text":"Gateway logging","translated":"Gateway 日志记录","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:15:31Z"} -{"cache_key":"c7d8295ce2f5f373b188cd04e782c94c0d1c45ee5dfcf10d220f7a24629104a4","segment_id":"start/getting-started.md:8f6fb4eb7f42c0e2","source_path":"start/getting-started.md","text_hash":"8f6fb4eb7f42c0e245e29e63f5b82cc3ba19852681d1ed9aed291f59cf75ec0e","text":"Security","translated":"安全","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:35:14Z"} -{"cache_key":"c7f525205b3a6feadb47fbfcbcbfc68f82fe9ae13af1a5c16ac3d52b9c9bf288","segment_id":"index.md:2a6b24ad28722034","source_path":"index.md","text_hash":"2a6b24ad287220345e96eb8021fe29d42b0785766c8df658827e7251da2d36dc","text":"Credits","translated":"致谢","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:54:15Z"} -{"cache_key":"c804bb5b5895281827002da2fcf801becaeb7680046827908a93ec9b050d971b","segment_id":"index.md:03279877bfe1de07","source_path":"index.md","text_hash":"03279877bfe1de0766393b51e69853dec7e95c287ef887d65d91c8bbe84ff9ff","text":"WebChat + macOS app","translated":"WebChat + macOS 应用","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:51:02Z"} -{"cache_key":"c8226a707aea96b4c38666b0e128055b91ef72929100106d9e62985f5d0727bc","segment_id":"environment.md:a258b30f88c30650","source_path":"environment.md","text_hash":"a258b30f88c30650e73073d5bdde5cfcc6987100ae62d37789e5c46a0d85b7c6","text":"Global ","translated":"全局 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:40:39Z"} -{"cache_key":"c839449ed2f2e2c13f4b4732789e52a7884ced2ccc0f4f285709a62f52005527","segment_id":"index.md:6fa3cbf451b2a1d5","source_path":"index.md","text_hash":"6fa3cbf451b2a1d54159d42c3ea5ab8725b0c8620d831f8c1602676b38ab00e6","text":"Sessions","translated":"会话","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:01:56Z"} -{"cache_key":"c85ec585240e224ad62f0cde143c6625d9eb6e2dfc1a425098b24975e4aa43bf","segment_id":"index.md:856302569e24c4d6","source_path":"index.md","text_hash":"856302569e24c4d64997e2ec5c37729f852bcccf333ba1e2f71e189c9d172e6d","text":": SSH tunnel or tailnet/VPN; see ","translated":":SSH 隧道或 tailnet/VPN;参见 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:00:38Z"} -{"cache_key":"c8ced90adeff8e91c3f5418bdc11abd60378688097157d3e12c7b51e9841ca2c","segment_id":"environment.md:cdb4ee2aea69cc6a","source_path":"environment.md","text_hash":"cdb4ee2aea69cc6a83331bbe96dc2caa9a299d21329efb0336fc02a82e1839a8","text":".","translated":"。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:15:58Z"} -{"cache_key":"c982edd40183708dbf5bc791228db4bfd5b4b33d5a623e1173ee48a697e31c6f","segment_id":"index.md:f12242785ecda793","source_path":"index.md","text_hash":"f12242785ecda7935ded50cd48418357d32d3bac290f7a199bc9f0c7fbd13123","text":") — Location parsing (Telegram + WhatsApp)","translated":")—— 位置解析(Telegram + WhatsApp)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:54:56Z"} -{"cache_key":"c98b98cde5f7adf29bada7169968b0e7c20763589cdef21766c06460d88f0f22","segment_id":"index.md:0c67abfaa5415391","source_path":"index.md","text_hash":"0c67abfaa5415391a31cf3a4624746b6b212b5ae66364be28ee2d131f014e0c6","text":"🧩 ","translated":"🧩 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:29:44Z"} -{"cache_key":"c9939001c16700c5d35daa87cee1e92d3290b1c7987abc44df7af44cb0549c21","segment_id":"help/index.md:frontmatter:read_when:0","source_path":"help/index.md:frontmatter:read_when:0","text_hash":"ee0615553374970664b58ebd8e5d0ebc9bc8a5f03387671afbfd0096b390aa9b","text":"You’re new and want the “what do I click/run” guide","translated":"你是新手,想要一份\"该点击什么/运行什么\"的指南","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:39:27Z"} -{"cache_key":"c9dab76bf88e0e641fc671ad2fbd902a1ae1db96eb22db04db9410d8a5ce0a79","segment_id":"index.md:63a3abfa879299dd","source_path":"index.md","text_hash":"63a3abfa879299ddcc03558012bfd6075cbd72f7a175b739095bf979700297f7","text":"Multi-instance quickstart (optional):","translated":"多实例快速开始(可选):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:31:23Z"} -{"cache_key":"ca3d654d1f779e0df22c1f09273aac145e550253cf972adfbc28d2751bbce6c5","segment_id":"index.md:1e37e607483201e2","source_path":"index.md","text_hash":"1e37e607483201e2152d2e9c68874dd4027648efdd9cfccb7bf8c9837398d143","text":"), serving ","translated":"),提供 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:49:01Z"} -{"cache_key":"ca81625ad797b914689b25dc7631b7d3b910d56f40d3f807c8b9253cdfd4d17f","segment_id":"index.md:f0e2018271f51504","source_path":"index.md","text_hash":"f0e2018271f515041084c8189f297236abe18f9ec77edad1a61c5413310bbd9e","text":"🖥️ ","translated":"🖥️ ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:30:49Z"} -{"cache_key":"caf51be0c1458b52f94033eb1b629dba33d1d3a372efd533cd0b13846db8c4d0","segment_id":"index.md:4d87941d681ca4e8","source_path":"index.md","text_hash":"4d87941d681ca4e89ca303d033b7d383d3acfbb6d9d9616bd88d7c19cf92c3dd","text":"Pi","translated":"Pi","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:27:56Z"} -{"cache_key":"cb6c11ef1460261e35016f0eaa5966553e6f8f20982b69d042a4f62515d65ad7","segment_id":"help/index.md:frontmatter:read_when:0","source_path":"help/index.md:frontmatter:read_when:0","text_hash":"ee0615553374970664b58ebd8e5d0ebc9bc8a5f03387671afbfd0096b390aa9b","text":"You’re new and want the “what do I click/run” guide","translated":"你是新手,想要一份\"该点什么/该运行什么\"的指南","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:56:12Z"} -{"cache_key":"cb7224509e3500bfd50fd737857395338c005b21a5cb2142a81af37ebe5204ad","segment_id":"environment.md:frontmatter:read_when:2","source_path":"environment.md:frontmatter:read_when:2","text_hash":"822b3d74ce16c1be19059fad4ca5bf7ae9327f58fa1ff4e75e78d5afa75c038f","text":"You are documenting provider auth or deployment environments","translated":"您正在记录 提供商 的认证或部署环境","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:40:17Z"} -{"cache_key":"cbc65efaa2c9304b41332d4d4b06ec60e7b2eaf66b98f9c7de9db4ada5b50007","segment_id":"help/index.md:frontmatter:read_when:0","source_path":"help/index.md:frontmatter:read_when:0","text_hash":"ee0615553374970664b58ebd8e5d0ebc9bc8a5f03387671afbfd0096b390aa9b","text":"You’re new and want the “what do I click/run” guide","translated":"你是新手,想要一份\"我该点击/运行什么\"的指南","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:21:32Z"} -{"cache_key":"cbccccc9ef3c5b2bdfc9137d40fcc79548902f77addaa40d473cf39ed46d1658","segment_id":"index.md:255ce77b7a6a015f","source_path":"index.md","text_hash":"255ce77b7a6a015f8595868a524b67c134e8fb405f4584fdac020e57f4ccd5f6","text":"Loopback-first","translated":"优先回环","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:28:48Z"} -{"cache_key":"cbff4c4140b6a285569765e9905785ee009781edd57d605ffb9171aff34c2d79","segment_id":"index.md:6b3f22c979b9e6f8","source_path":"index.md","text_hash":"6b3f22c979b9e6f8622031a6b638ec5f730c32de646d013e616078e03f5a6149","text":"iOS node","translated":"iOS 节点","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:51:08Z"} -{"cache_key":"cc06ade0909671964a2ea7220e8ac6cbb33aaa9dffe47a13da27bea6d2d9a0d3","segment_id":"index.md:d08cec54f66c140c","source_path":"index.md","text_hash":"d08cec54f66c140c655a1631f6d629927c7c38b9c8bfa91c875df9bd3ad3c559","text":"OpenClaw assistant setup","translated":"OpenClaw 助手设置","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:04:14Z"} -{"cache_key":"cc58238acf407fd16eca091f737e3d7e588e27b1cad3453883c342c3c0e8e9b4","segment_id":"environment.md:cdb4ee2aea69cc6a","source_path":"environment.md","text_hash":"cdb4ee2aea69cc6a83331bbe96dc2caa9a299d21329efb0336fc02a82e1839a8","text":".","translated":".","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:40:26Z"} -{"cache_key":"cc63b18c7aa9315b943c74483a99d77ec5c26a9ca9a1120637ac7e346b98fc4d","segment_id":"start/getting-started.md:fa6eee60553a165b","source_path":"start/getting-started.md","text_hash":"fa6eee60553a165b731e236a48d54169a31fa39cccbc1967e13fba9e4cc38868","text":"Pairing doc: ","translated":"配对文档: ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:37:41Z"} -{"cache_key":"cc8f5dcfbe51a4638b375d367381be97b79d012b56b2c7eadd2e38d164cdd177","segment_id":"start/wizard.md:e18251a039a6b735","source_path":"start/wizard.md","text_hash":"e18251a039a6b7353675decc475898bfdb91d3bd9d37e83c8447d0359b8711c3","text":"Non-interactive flags: ","translated":"非交互标志: ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:47:05Z"} -{"cache_key":"cc906618700533ea8dd9d752b8e2ef28ffb8707654a557d7cef1b867cdd57f1a","segment_id":"index.md:ceee4f2088b9d5ba","source_path":"index.md","text_hash":"ceee4f2088b9d5ba7d417bac7395003acfbcef576fd4cc1dd3063972f038218a","text":"The name","translated":"名称","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:54:01Z"} -{"cache_key":"cc93bf5458542a509cb8460472bf3269d769fe1cdee6201ab736c4b5460d64d5","segment_id":"start/wizard.md:4bba41aa0148ebb4","source_path":"start/wizard.md","text_hash":"4bba41aa0148ebb49b33763f1b38a983af7c0a4dd22fff07d3cf94fdcb96ecd3","text":"Linux (and Windows via WSL2): systemd user unit","translated":"Linux(以及通过 WSL2 的 Windows):systemd 用户单元","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:45:17Z"} -{"cache_key":"cd2d7cce6f1c10e008e8efe49ecf02b6ac401d686667986409f7e6796e9f1140","segment_id":"environment.md:45ca56d179d4788c","source_path":"environment.md","text_hash":"45ca56d179d4788c55ba9f7653b376d62e7faa738e92259e3d4f6f5c1b554f28","text":"Related","translated":"相关内容","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:22:44Z"} -{"cache_key":"cd4cdcf85e185ce70df30cbda64fb2d77baa6a6c989e67cde3f80315c06b3839","segment_id":"index.md:45e6d69dbe995a36","source_path":"index.md","text_hash":"45e6d69dbe995a36f7bc20755eff4eb4d2afaaedbcac4668ab62540c57219f32","text":"macOS app","translated":"macOS 应用","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:53:33Z"} -{"cache_key":"cd82c395857efd6e374fec3ad86de5dd8989415770d38a86d8a1980cd372b7f5","segment_id":"start/wizard.md:c4e77a12a2c0b664","source_path":"start/wizard.md","text_hash":"c4e77a12a2c0b664f398de857da71528f66ffb4a70e65769897dcc7147167b2c","text":" or use allowlists.","translated":" 批准,或使用允许名单。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:45:05Z"} -{"cache_key":"cd9743cfc03baed1ac0aba354dec17a69f9204d0f64f1c71e9c137298bff5141","segment_id":"help/index.md:b79cac926e0b2e34","source_path":"help/index.md","text_hash":"b79cac926e0b2e347e72cc91d5174037c9e17ae7733fd7bdb570f71b10cd7bfc","text":"Help","translated":"帮助","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:56:16Z"} -{"cache_key":"cdb2a2bfea264785bf8cb89785edd4df2b7de3adf29db9507ad953dcbdd6d939","segment_id":"help/index.md:b79cac926e0b2e34","source_path":"help/index.md","text_hash":"b79cac926e0b2e347e72cc91d5174037c9e17ae7733fd7bdb570f71b10cd7bfc","text":"Help","translated":"帮助","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:21:35Z"} -{"cache_key":"cdd968f07504f0d2da9c8620421deda0bfb9153636d6be92a239eb6adcd9f2b9","segment_id":"environment.md:f0442e6e05ccca16","source_path":"environment.md","text_hash":"f0442e6e05ccca160d17de0e7d509891b91b921366b2202b2b5c80435824e140","text":"Two equivalent ways to set inline env vars (both are non-overriding):","translated":"两种等效的内联设置 环境变量 的方式(均为非覆盖):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:58:02Z"} -{"cache_key":"ce171e0ce019a7a095f4e7aaa27de6784ee9bb99d4353b3b3b9719eff6509d30","segment_id":"index.md:856302569e24c4d6","source_path":"index.md","text_hash":"856302569e24c4d64997e2ec5c37729f852bcccf333ba1e2f71e189c9d172e6d","text":": SSH tunnel or tailnet/VPN; see ","translated":":SSH 隧道或 Tailnet/VPN;参见 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:49:16Z"} -{"cache_key":"ce26a64b93065c2ad2be924884f77346c4578db84e4df79e49d5ee43b3ccb617","segment_id":"index.md:9fc31bacba5cb332","source_path":"index.md","text_hash":"9fc31bacba5cb33207804b9e6a8775a3f9521c9a653133fd06e5d14206103e48","text":"Streaming + chunking","translated":"流式传输 + 分块","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:50:06Z"} -{"cache_key":"ce31f98c669ba131d564edefa106a53c0d099661680c8872048ae0636dfbe73c","segment_id":"environment.md:c2d7247c8acb83a5","source_path":"environment.md","text_hash":"c2d7247c8acb83a5a020458fa836c2445922b51513dbdbf154ab5f7656cb04e9","text":"; does not override).","translated":";不覆盖)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:57:37Z"} -{"cache_key":"ce3c2713f373fff6ebab9c70141debe3262d0a7ff6214fd146fa277b67c1ab3e","segment_id":"start/wizard.md:bd8a6e0ff884f51d","source_path":"start/wizard.md","text_hash":"bd8a6e0ff884f51d6a4a9b70f4680033876871936c72cf8af5df4e4b2836c75c","text":"Wizard runs a model check and warns if the configured model is unknown or missing auth.","translated":"向导会运行模型检查,如果配置的模型未知或缺少认证则发出警告。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:43:24Z"} -{"cache_key":"ce618de323766f3aa222b534fb69a1502c03699a6b57e801e6f1a1b3c32d3431","segment_id":"index.md:9abe8e9025013e78","source_path":"index.md","text_hash":"9abe8e9025013e78a6bf2913f8c20ee43134ad001ce29ced89e2af9c07096d8f","text":"Media: images","translated":"媒体:图片","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:53:25Z"} -{"cache_key":"ce62c5006939b21f7a2d236f9cdb545ce653778800504e85668fe99075067cbf","segment_id":"environment.md:6db0742daaf9f191","source_path":"environment.md","text_hash":"6db0742daaf9f191ab7816d2c9d317b1ea1693453a8c63b95af8b01477e0f5bb","text":" runs your login shell and imports only ","translated":" 运行你的登录 shell,并仅导入 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:12:47Z"} -{"cache_key":"cf001b0403d7ae959797460c96aa4da24818c662362595f2da0be349caeb6a09","segment_id":"index.md:cda454f61dfcac70","source_path":"index.md","text_hash":"cda454f61dfcac7007a9edc538f9f58cf38caa0652e253975979308162bccc53","text":"Gateway configuration","translated":"Gateway 配置","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:00:31Z"} -{"cache_key":"cf9fc66b44905a0c47ca04f98d6e6507821789844f1e97ca2026f7df6e5b1451","segment_id":"environment.md:f7e239a42b7cd986","source_path":"environment.md","text_hash":"f7e239a42b7cd986a1558fed234e975ed2e96e9d37cf0a93f381778c461c89dd","text":"OpenClaw pulls environment variables from multiple sources. The rule is ","translated":"OpenClaw 从多个来源拉取 环境变量。规则是 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:57:11Z"} -{"cache_key":"cfc26997d872d590a2aba69f0aba6f704354d3aea9aa3bd433693ca7182cacdc","segment_id":"start/getting-started.md:1093115897879aa3","source_path":"start/getting-started.md","text_hash":"1093115897879aa3ad9511a1dc2850929cfb60ba45ec741605f69f5d20203472","text":"Runtime","translated":"运行时","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:36:17Z"} -{"cache_key":"d0594bae529c9774fa42aa68b86c4e1cb876bee2ffe9173b4d9f5a5f8325cae0","segment_id":"start/wizard.md:cac2e1b207fdd700","source_path":"start/wizard.md","text_hash":"cac2e1b207fdd700258939f1e7977375609e4b2e26785c93c230da25bc0cbd82","text":").\nClients (macOS app, Control UI) can render steps without re‑implementing onboarding logic.","translated":")。客户端(macOS 应用、Control UI)可以渲染步骤而无需重新实现上手引导逻辑。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:47:46Z"} -{"cache_key":"d076be47cee695839a6d7e0cd10b0c8b2a7da5ae5d222273b89c28de425b741e","segment_id":"environment.md:f6b2ffe1d0d5f521","source_path":"environment.md","text_hash":"f6b2ffe1d0d5f521b76cabc67d6e96da2b1170eef8086d530558e9906a7f092d","text":"Models overview","translated":"模型概览","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:47:21Z"} -{"cache_key":"d08787ed2ed706d9bd6e4f7296e7330177093a07d1b129a0487e2e0e151eb63e","segment_id":"start/getting-started.md:ea8c0ae0a9156b3b","source_path":"start/getting-started.md","text_hash":"ea8c0ae0a9156b3bf89fa7572f685a4d9fd24e89a7326fc7f41fc7e85f139b80","text":"WSL2","translated":"WSL2","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:35:39Z"} -{"cache_key":"d09b7c0117a630c96ed0f5f9c262caa47d5273ff6d51a8d46c0ca45721eaebe2","segment_id":"environment.md:3fe738a7ee6aaff5","source_path":"environment.md","text_hash":"3fe738a7ee6aaff51f099d9a8314510c99ced6a568eb38c67642cd43bb54eec0","text":" in the current working directory","translated":" 在当前工作目录中","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:12:04Z"} -{"cache_key":"d0d44c0cc0a3150ea2571f5ecd32d65671470df6b9b093decacc0852597b2201","segment_id":"start/wizard.md:5a5902a06688a396","source_path":"start/wizard.md","text_hash":"5a5902a06688a39618ade9c26292a6e3b13124cee42cc028d35943ccc1e21a5c","text":" (full control).","translated":" (完全控制)模式开始。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:39:41Z"} -{"cache_key":"d0f76abf14b1216bff9974f7e507a3c2a43f331f1ebd805279843692ae78f662","segment_id":"index.md:5cf9ea2e20780551","source_path":"index.md","text_hash":"5cf9ea2e2078055129b38cfbc394142ca6ca41556bd6e31cbd527425647c1d1e","text":"One Gateway per host (recommended)","translated":"每台主机一个 Gateway(推荐)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:48:30Z"} -{"cache_key":"d12af03e20c20a4ebdcdbf4c32f52081339c0aa7bd1bb44b311875547bb39918","segment_id":"start/wizard.md:14a01a1b76ad6311","source_path":"start/wizard.md","text_hash":"14a01a1b76ad63111eb126c1d124a893abcb5cc90fe893825a9c96362112ab4f","text":" adds gateway health probes to status output (requires a reachable gateway).","translated":" 将 Gateway 健康探测添加到状态输出中(需要可达的 Gateway)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:45:41Z"} -{"cache_key":"d1818c531bc4e1cca14e64f751cf8698cb0701a745fb3da03b37b4fd7129c18b","segment_id":"start/wizard.md:6d0323ac97e5a313","source_path":"start/wizard.md","text_hash":"6d0323ac97e5a3136bae41278bfd46f5985969ee57dea5f25d7faa78bb01c87e","text":" when model is unset or ","translated":" (当模型未设置或为 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:42:24Z"} -{"cache_key":"d1a349d8c1859f2d1c00367b86704fa95d4168c8615ada60834a6890215d1f58","segment_id":"index.md:3c064c83b8d244fe","source_path":"index.md","text_hash":"3c064c83b8d244fef61e5fd8ce5f070b857a3578a71745e61eea02892788c020","text":" — Anthropic (Claude Pro/Max) + OpenAI (ChatGPT/Codex) via OAuth","translated":" —— 通过 OAuth 支持 Anthropic(Claude Pro/Max)+ OpenAI(ChatGPT/Codex)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:50:28Z"} -{"cache_key":"d1d30ee69fb8519a966ebbb5cb51d2be029399b2951ef296b23f96d3fea4bc3a","segment_id":"start/wizard.md:3fad3d2e2c01a9ea","source_path":"start/wizard.md","text_hash":"3fad3d2e2c01a9ea3a66cbcb1b05a0d5982e3665cf0e1ec6dee0e031e83137e1","text":"Reads the available skills and checks requirements.","translated":"读取可用技能并检查依赖条件。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:45:43Z"} -{"cache_key":"d234d82b06f65337a5ab45e775d0f0abda696d4e04e6115c6a042853b3b11ca4","segment_id":"index.md:084514e91f37c3ce","source_path":"index.md","text_hash":"084514e91f37c3ce85360e26c70b77fdc95f0d3551ce309db96fbcf956a53b01","text":"Dashboard (browser Control UI)","translated":"仪表板(浏览器控制界面)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:48:04Z"} -{"cache_key":"d29dfffbf5c42410939bbb88504ae09e8009835fc6ba11b0bd27ae0da0839aee","segment_id":"environment.md:45ca56d179d4788c","source_path":"environment.md","text_hash":"45ca56d179d4788c55ba9f7653b376d62e7faa738e92259e3d4f6f5c1b554f28","text":"Related","translated":"相关内容","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:58:30Z"} -{"cache_key":"d2daa68c34089a85125ae39af1770f4ec07070bbe5b06c0d0c2d84ea0d10a6ec","segment_id":"index.md:ded906ea94d05152","source_path":"index.md","text_hash":"ded906ea94d0515249f0bcab1ba63835b5968c142e9c7ea0cb6925317444d98c","text":"Configuration examples","translated":"配置示例","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:52:27Z"} -{"cache_key":"d2daf9c0748530a05031c851236072d2d247919151adeb5afc085b3c1df0a5d2","segment_id":"index.md:b214cd10585678ca","source_path":"index.md","text_hash":"b214cd10585678ca1250ce1ae1a50ad4001de4577a10e36be396a3409314e442","text":"@badlogicc","translated":"@badlogicc","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:54:27Z"} -{"cache_key":"d2eeaf2250e691f5598d218f5ab0a4086d57b68635b5d91718b8895e00fd80e6","segment_id":"environment.md:61115f6649792387","source_path":"environment.md","text_hash":"61115f664979238731a390e84433a818965b7eaf1d38fa5b4b1507c33ef28c91","text":"Precedence (highest → lowest)","translated":"优先级(从高到低)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:19:25Z"} -{"cache_key":"d304bb8482d72d9a36276fe206308ad11314fae096c08348d76048bc1593c708","segment_id":"start/getting-started.md:d00eca1bae674280","source_path":"start/getting-started.md","text_hash":"d00eca1bae6742803906ab42a831e8b5396d15b6573ea13c139ec31631208ec1","text":"Getting Started","translated":"快速入门","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:34:21Z"} -{"cache_key":"d308117273fcffa99031b6168d0430801d743429421c4ebbc45aedace958b061","segment_id":"index.md:31365ab9453d6a1e","source_path":"index.md","text_hash":"31365ab9453d6a1ec03731622803d3b44f345b6afad08040d7f3e97290c77913","text":"do nothing","translated":"不做任何操作","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:03:30Z"} -{"cache_key":"d30d12f7e78eb693ff2d7b58b572be7efd8f8787f98a3ccad0b04752af019ce5","segment_id":"environment.md:b1d6b91b67c2afa5","source_path":"environment.md","text_hash":"b1d6b91b67c2afa5e322988d9462638d354ddf8a1ef79dba987f815c22b4baee","text":" at ","translated":" 位于 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:45:49Z"} -{"cache_key":"d41535413a3b4c62091cb7682dab05259a63ac65d34ea5b3463c5808ccb28960","segment_id":"index.md:268ebcd6be28e8d8","source_path":"index.md","text_hash":"268ebcd6be28e8d853ace3a6e28f269fbda1343b53e3f0de97ea3d5bf1a0e33e","text":"Clawd","translated":"Clawd","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:54:32Z"} -{"cache_key":"d43575ea070a82b8c1685440526dc44dd9aed79bc448b5192134b0fa0c008749","segment_id":"index.md:ee8b06871d5e335e","source_path":"index.md","text_hash":"ee8b06871d5e335e6e686f4e2ee9c9e6de5d389ece6636e0b5e654e0d4dd5b7e","text":"Control UI (browser)","translated":"控制界面(浏览器)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:53:10Z"} -{"cache_key":"d4735a7a8ac59df4f41d36c7c08984885356d88053b7708e6855dbd446102081","segment_id":"index.md:042c75df73389c8a","source_path":"index.md","text_hash":"042c75df73389c8a7c0871d2a451bd20431d24e908e2c192827a54022df95005","text":"Nacho Iacovino","translated":"Nacho Iacovino","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:33:55Z"} -{"cache_key":"d490073217e575e01f8f2e93e3101b423f269eba9ce72829891ee9b89843212e","segment_id":"index.md:0b60fe04b3c5c3c7","source_path":"index.md","text_hash":"0b60fe04b3c5c3c76371b6eca8b19c8e09a0e54c9010711ff87e782d87d2190b","text":"Android app","translated":"Android 应用","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:53:38Z"} -{"cache_key":"d49ac0479b4e2121af227b4a3bdcebe1d4a0d610b35baa6782dafb7fbf4fc4a6","segment_id":"environment.md:8d076464a84995bc","source_path":"environment.md","text_hash":"8d076464a84995bc095e934b0aa1e4419372f27cd71d033571e4dbba201ee5d8","text":"You can reference env vars directly in config string values using ","translated":"你可以使用以下方式在配置字符串值中直接引用环境变量 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:47:01Z"} -{"cache_key":"d4d0c17af1fe72f822af0009148f75c60a1ed6451e748c2b3df85d55e7124987","segment_id":"start/getting-started.md:ebaef508acb6f7b6","source_path":"start/getting-started.md","text_hash":"ebaef508acb6f7b6bb2a0a4342b2aafd862c3694450fe11789070419c1591681","text":"iOS/Android nodes (Canvas/camera/voice): ","translated":"iOS/Android 节点(Canvas/相机/语音): ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:38:25Z"} -{"cache_key":"d4f41acdb842e7c1c8bfec5ad3ab1dab8d98a4792e99dd535877f4201a21a031","segment_id":"start/wizard.md:9db982e2d3194ff1","source_path":"start/wizard.md","text_hash":"9db982e2d3194ff10f91d59646b6193c1b3d36f86f8d4da50b3d1bf8a5ae2ac6","text":": bot token.","translated":":机器人令牌。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:44:30Z"} -{"cache_key":"d4f4558ec6bd856ca63ddc3f050e7f129c76188a898fa7765d990b8e1ca6fdcd","segment_id":"environment.md:496aca80e4d8f29f","source_path":"environment.md","text_hash":"496aca80e4d8f29fb8e8cd816c3afb48d3f103970b3a2ee1600c08ca67326dee","text":" block","translated":" 块","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:40:51Z"} -{"cache_key":"d4f9bd5e931f08626a42d41967e2597390d16854993c968eeb9cc8720374a1a0","segment_id":"start/wizard.md:9fd728c66c9a256b","source_path":"start/wizard.md","text_hash":"9fd728c66c9a256b121472dabf32a34317aed01d8427d70ec830289cf23a7cc8","text":"Add ","translated":"添加 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:47:00Z"} -{"cache_key":"d5381f750f50864e130866c293987087c17d023cec62188df764f7e91dc7606a","segment_id":"index.md:e1b33cfa2a781bde","source_path":"index.md","text_hash":"e1b33cfa2a781bde9ef6c1d08bf95993c62f780a6664f5c5b92e3d3633e1fcf8","text":" (@nachoiacovino, ","translated":" (@nachoiacovino, ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:34:00Z"} -{"cache_key":"d5c1ce3309dbb00f67e9ff21a4f6dbef5f531ee3781b33f5fc1be91b6fd46196","segment_id":"start/wizard.md:5aa55e363e93c8bc","source_path":"start/wizard.md","text_hash":"5aa55e363e93c8bc3623dcb97e318cfc0784b4fb24e287f600192488208fd8f1","text":"Local mode (default)","translated":"本地模式(默认)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:40:13Z"} -{"cache_key":"d61140d8a641f1f6316535bc25bdb629b4508cb21951f549a6908b0e8c75b303","segment_id":"environment.md:a9d9b94d02c2f6ab","source_path":"environment.md","text_hash":"a9d9b94d02c2f6ab616036cab13ba821053514d384f064c56d338d748050ba7c","text":" lowest)","translated":" 最低)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:45:31Z"} -{"cache_key":"d62921e75336cfb2d915a837e650bc5d8690441848c19f2d5a57ecbf0247c33b","segment_id":"start/getting-started.md:88d90e2eef3374ce","source_path":"start/getting-started.md","text_hash":"88d90e2eef3374ce1a7b5e7fbd3b1159364b26a8ceb2493d6e546d4444b03cda","text":"Tailscale","translated":"Tailscale","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:38:34Z"} -{"cache_key":"d6390c8e7e97434033a209c981093a19cabd19baa33feab5462c9cbfe94ed51d","segment_id":"start/getting-started.md:8eb3ea9bbde63159","source_path":"start/getting-started.md","text_hash":"8eb3ea9bbde631592dfac3150044fabe4678c820a107c026035c13bf0c8ba9d7","text":"Auth","translated":"认证","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:36:03Z"} -{"cache_key":"d6402ced9e7963b9ac7e01b7552846636e06afd06ad8433345d9a108f4360fab","segment_id":"environment.md:453c14128fbfb5f6","source_path":"environment.md","text_hash":"453c14128fbfb5f6757511557132a1dbb3bcbf243267630bfec49db8518c7780","text":"Env var substitution in config","translated":"配置中的环境变量替换","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:26:21Z"} -{"cache_key":"d6cb6405997baeadc37aefa302a5594766dc76d757bdb0224e706a196265ab60","segment_id":"index.md:66d0f523a379b2de","source_path":"index.md","text_hash":"66d0f523a379b2de6f8d5fba3a817ebc395f7bcaa54cc132ca9dfa665d1e9378","text":"Skills","translated":"技能","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:32:13Z"} -{"cache_key":"d73268cbf726ddfd975878dfbe6a3b4d5418e1d7568bcb23d5745eea13b014b7","segment_id":"environment.md:frontmatter:read_when:0","source_path":"environment.md:frontmatter:read_when:0","text_hash":"90fc0487bff88009979cff1061c1a882df8c3b1baa9c43538331d9d5dab15479","text":"You need to know which env vars are loaded, and in what order","translated":"你需要了解加载了哪些环境变量,以及它们的加载顺序","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:19:14Z"} -{"cache_key":"d771dee3183c3dde95a035d4dd963966fff7f9d7d0d35eb1d66e26e34e6e0746","segment_id":"help/index.md:frontmatter:read_when:1","source_path":"help/index.md:frontmatter:read_when:1","text_hash":"857eafc389d179e83e21e46c10527fec40894fe064c63847ba06b946b7d5eb73","text":"Something broke and you want the fastest path to a fix","translated":"出了问题,你想找到最快的修复方法","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:39:29Z"} -{"cache_key":"d78b68adf70db59ac77fac9cfe5e338025bf15b23845ec12fa109dffffd26525","segment_id":"environment.md:6db0742daaf9f191","source_path":"environment.md","text_hash":"6db0742daaf9f191ab7816d2c9d317b1ea1693453a8c63b95af8b01477e0f5bb","text":" runs your login shell and imports only ","translated":" 运行您的登录 shell 并仅导入 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:41:14Z"} -{"cache_key":"d791df1af8af8b787e124642b683bf3a90723f32075b0da41ba134fab01e7b16","segment_id":"index.md:42071940eb773f4d","source_path":"index.md","text_hash":"42071940eb773f4dcb7111f0626b4a7a823fc44098e143ff425db8a03528609d","text":" — because every space lobster needs a time-and-space machine.","translated":" — 因为每只太空龙虾都需要一台时空机器。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:33:20Z"} -{"cache_key":"d798cc1cc5fcd0af14bb92521474c72168a3812ac48cc219e3c0757169d2f2b3","segment_id":"environment.md:1ec31258a6b45ea9","source_path":"environment.md","text_hash":"1ec31258a6b45ea903cd76f5b0190a99ab56afff6241a04f0681eb12b7a02484","text":"Env var equivalents:","translated":"等效的环境变量:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:22:27Z"} -{"cache_key":"d7efd30a0122aa5514cc6d7fb73d6279b2d1eee879e4ec731d2873142860e82b","segment_id":"index.md:723784fa2b6a0876","source_path":"index.md","text_hash":"723784fa2b6a0876540a92223ee1019f24603499d335d6d82afbc520ef5b5d57","text":") — Creator, lobster whisperer","translated":")—— 创建者,龙虾低语者","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:54:22Z"} -{"cache_key":"d82d08bf8b5d5245c698d1341d95dd2cbe797404269dffbb948000cab390fc3f","segment_id":"start/wizard.md:765dd901deb1679d","source_path":"start/wizard.md","text_hash":"765dd901deb1679d2fa08bebd5e5ca8a998e8c33b6203053cb18fd352ce22330","text":"Non‑interactive mode","translated":"非交互模式","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:47:11Z"} -{"cache_key":"d844ae7cf5ac1af1c8e77c093dc0a5b087dbe111241c75fcf0d68c38966fc760","segment_id":"environment.md:a258b30f88c30650","source_path":"environment.md","text_hash":"a258b30f88c30650e73073d5bdde5cfcc6987100ae62d37789e5c46a0d85b7c6","text":"Global ","translated":"全局 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:45:47Z"} -{"cache_key":"d89eb1af6a6780d9f17aa4ab63b9ea6fb426a6458811cc4898df06d14464d7cb","segment_id":"start/getting-started.md:7412cf3ea50ad037","source_path":"start/getting-started.md","text_hash":"7412cf3ea50ad0377e8450ef19d397a4b62fc2a44c9ab7f02cc012f80df90199","text":" (stores ","translated":" (存储 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:35:32Z"} -{"cache_key":"d8ad10373e0be1e5e296d2955307d55e6dca0e4e355d46968d5baed7c6914c70","segment_id":"environment.md:cda454f61dfcac70","source_path":"environment.md","text_hash":"cda454f61dfcac7007a9edc538f9f58cf38caa0652e253975979308162bccc53","text":"Gateway configuration","translated":"Gateway 配置","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:17:12Z"} -{"cache_key":"d8cc06094a692bfc0650f92c245909ce4308fcc945faabdc2b7bad4f9a1b9998","segment_id":"start/wizard.md:576ed4fd7e0e5fcd","source_path":"start/wizard.md","text_hash":"576ed4fd7e0e5fcd52f1a92c0b5a8df3ed8f33c4c280c9d15e53955d15633796","text":" (you’ll be prompted for your phone number)","translated":" (系统会提示您输入手机号码)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:40:07Z"} -{"cache_key":"d8fe9f40df201863d43f4937a52bac7d14019fae82150f1191fe4bb66819d827","segment_id":"help/index.md:3c33340bd23b8db8","source_path":"help/index.md","text_hash":"3c33340bd23b8db89f18fe7d05a954738c0dd5ba9623cf6bdb7bb5d1a3729cfc","text":"FAQ (concepts)","translated":"常见问题(概念)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:11:34Z"} -{"cache_key":"d93001811d4774893fac9a800d8e9c14259b90fc5ed85a3e5e6d381bfb591846","segment_id":"index.md:32ebb1abcc1c601c","source_path":"index.md","text_hash":"32ebb1abcc1c601ceb9c4e3c4faba0caa5b85bb98c4f1e6612c40faa528a91c9","text":" (","translated":" (","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:28:10Z"} -{"cache_key":"d952c24b47cb7a9f69823c976f2f5e103fdc731a8bd74cae1436d86f420022df","segment_id":"environment.md:frontmatter:read_when:1","source_path":"environment.md:frontmatter:read_when:1","text_hash":"a3a2d99a99de98220c8e0296d6f4e4b2a34024916bd2379d1b3b9179c8fae46f","text":"You are debugging missing API keys in the Gateway","translated":"你正在调试 Gateway 中缺失的 API 密钥","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:21:50Z"} -{"cache_key":"d972ebc19ef87492ca8c11159fd6342cced6b4e19743d79d81ae33fafe35bbd8","segment_id":"environment.md:f7e239a42b7cd986","source_path":"environment.md","text_hash":"f7e239a42b7cd986a1558fed234e975ed2e96e9d37cf0a93f381778c461c89dd","text":"OpenClaw pulls environment variables from multiple sources. The rule is ","translated":"OpenClaw 从多个来源获取环境变量。规则是 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:21:57Z"} -{"cache_key":"d9923f60f9531ccaaefa870fa682febfe862bf9a38ced5baa99ea8637d7fc5ae","segment_id":"start/getting-started.md:63d3b285bad7d501","source_path":"start/getting-started.md","text_hash":"63d3b285bad7d5015cea4d6e62f972e83221dfce48c6919bd536c5e894a6607d","text":" set an API key (wizard can store it for service use). ","translated":" 设置 API 密钥(向导可以将其存储以供服务使用)。 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:36:32Z"} -{"cache_key":"d9b22590788b6c0abf9a15102d23d2aeb6608cf4acc0339e69be4e52ae38af48","segment_id":"index.md:f9b8279bc46e847b","source_path":"index.md","text_hash":"f9b8279bc46e847bfcc47b8701fd5c5dc27baa304d5add8278a7f97925c3ec13","text":"Mattermost (plugin)","translated":"Mattermost(插件)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:53:18Z"} -{"cache_key":"da18bab9968b574835d3ac2fa578c4d9484bd549584abe26d6dd6ef900786186","segment_id":"start/wizard.md:b3903e5fd7656678","source_path":"start/wizard.md","text_hash":"b3903e5fd7656678464dd2a865aaddae81c1a9967b2b28de65963482c18101a4","text":", get it at ","translated":",请在 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:42:39Z"} -{"cache_key":"da2e517a094d1b2ea1a3ef7068f6a9d51fdfdf0a022f7219b7cee208042f347e","segment_id":"environment.md:cdb4ee2aea69cc6a","source_path":"environment.md","text_hash":"cdb4ee2aea69cc6a83331bbe96dc2caa9a299d21329efb0336fc02a82e1839a8","text":".","translated":"。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:25:22Z"} -{"cache_key":"da6fd0546d605ddbff40ee5eeab7a9dba889d06f55b7668b2ea364b8d13db5b0","segment_id":"start/getting-started.md:8026e8b07f2541e0","source_path":"start/getting-started.md","text_hash":"8026e8b07f2541e05438c325c641d6c725179032c826ab3d788f1d7f6ee6cc48","text":"gateway settings","translated":"Gateway 设置","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:34:54Z"} -{"cache_key":"da93b7ddfcae6217e6d6986b9ce70bb9a1a2d9cfad63f48bfad0bf32de42231f","segment_id":"environment.md:7175517a370b5cd2","source_path":"environment.md","text_hash":"7175517a370b5cd2e664e3fd29c4ea9db5ce17058eb9772fe090a5485e49dad6","text":" or ","translated":" 或 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:46:17Z"} -{"cache_key":"daa769cecb2f8416182d068699a32d5eb21c0317b3290a243cdecd970d5962a8","segment_id":"index.md:0d517afa83f91ec3","source_path":"index.md","text_hash":"0d517afa83f91ec33ee74f756c400a43b11ad2824719e518f8ca791659679ef4","text":"Web surfaces (Control UI)","translated":"Web 界面(控制界面)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:32:26Z"} -{"cache_key":"dab106a105f2b823c007205705cad6e89b432c5ca1ef47eabc0f310d8efc383c","segment_id":"environment.md:6863067eb0a2c749","source_path":"environment.md","text_hash":"6863067eb0a2c7499425c6c189b2c88bac55ca754285a6ab1ef37b75b4cfad4d","text":"See ","translated":"参见 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:58:23Z"} -{"cache_key":"dac6075e2d2a3911099dba69ad509d84086bcd1b1c736fee0152408194e15e11","segment_id":"start/getting-started.md:6dd923776874c55b","source_path":"start/getting-started.md","text_hash":"6dd923776874c55bce97640e624fb7a344d86ed45b1c54be63346b52026a1652","text":"Auth profiles (OAuth + API keys): ","translated":"认证配置文件(OAuth + API 密钥): ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:36:38Z"} -{"cache_key":"db301ad112142ff90fb1b8331377445ca0dd7eae4b68bba7cc6930841d303d06","segment_id":"start/wizard.md:0ed32a8e95d8664d","source_path":"start/wizard.md","text_hash":"0ed32a8e95d8664d39b5e673327e225f72eb6d6733b764db17d1bbc0536a2880","text":"Windows uses WSL2; signal-cli install follows the Linux flow inside WSL.","translated":"Windows 使用 WSL2;signal-cli 安装遵循 WSL 内的 Linux 流程。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:48:18Z"} -{"cache_key":"dbd450b11463a9da9dd0c640abc716c08a4705dd68c1b0a4c25b354b9311d439","segment_id":"environment.md:f6b2ffe1d0d5f521","source_path":"environment.md","text_hash":"f6b2ffe1d0d5f521b76cabc67d6e96da2b1170eef8086d530558e9906a7f092d","text":"Models overview","translated":"模型 概述","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:41:44Z"} -{"cache_key":"dbec24d595565c4c294a91f556c491976ccdeb4f7976d9258e6420af47259608","segment_id":"help/index.md:24669ff48290c187","source_path":"help/index.md","text_hash":"24669ff48290c1875d8067bbd241e8a55444839747bffb8ab99f3a34ef248436","text":"Doctor","translated":"诊断","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:11:29Z"} -{"cache_key":"dbeeb5b2ad003e4152107ceade1290b2001163df5f2fb93a792c8c9d94cec345","segment_id":"start/getting-started.md:922f3f28b57bdd14","source_path":"start/getting-started.md","text_hash":"922f3f28b57bdd146b8892adf494a28a0969d5eaf21333bfdb314db2eb6c8da8","text":"Installer options (install method, non-interactive, from GitHub): ","translated":"安装选项(安装方式、非交互式、从 GitHub 安装): ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:35:48Z"} -{"cache_key":"dbf5bae2a9b91c346475334bdb1294ace20ee07ca1e471c488c5311579ef37ab","segment_id":"index.md:b0d125182029e6c5","source_path":"index.md","text_hash":"b0d125182029e6c500cbcc81011341df77de8fe24d9e80190c32be390c916ec2","text":"🤖 ","translated":"🤖 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:49:58Z"} -{"cache_key":"dc745b075f86ec95e5a22fbb2ba14c5a6f2c00911dfa570cbe2f5123627e887d","segment_id":"environment.md:f15f5f9f4ef4d668","source_path":"environment.md","text_hash":"f15f5f9f4ef4d6688876c894f8eba251ed1db6eaf2209084028d43c9e76a8ba1","text":" (aka ","translated":" (即 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:40:44Z"} -{"cache_key":"dc8c80f84e5339af07824daa81e39f2801c9d6beb851b21e632b3eb6ddf79749","segment_id":"start/wizard.md:4b2a013a2a09958e","source_path":"start/wizard.md","text_hash":"4b2a013a2a09958e251e8998bdfa5fd89cc1c69abb1273fe2c1522cf54363cc6","text":"JVM builds require ","translated":"JVM 构建需要 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:48:09Z"} -{"cache_key":"dcb357452715d4a3fee760c79dfdee6719f235e48d176456a053646ffae10f44","segment_id":"environment.md:d08a8493f686363a","source_path":"environment.md","text_hash":"d08a8493f686363a78b913d45ebfbd87a3768d1c77b70f23b1fdade3c066e481","text":"Shell env import","translated":"Shell 环境导入","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:58:04Z"} -{"cache_key":"dcde0de1d52251a80249b1dfaee7ed01776cefba6298068afc3f6c3d9cc5588a","segment_id":"index.md:9dea37e7f1ff0e24","source_path":"index.md","text_hash":"9dea37e7f1ff0e24f7daecf6ea9cc38a58194f11fbeab1d3cfaa3a5645099ef4","text":"Updating / rollback","translated":"更新 / 回滚","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:32:05Z"} -{"cache_key":"dd09aaae7520593f0b225738900febfd800584c0d3739ac738ad25471bbebd96","segment_id":"environment.md:32ebb1abcc1c601c","source_path":"environment.md","text_hash":"32ebb1abcc1c601ceb9c4e3c4faba0caa5b85bb98c4f1e6612c40faa528a91c9","text":" (","translated":" (","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:12:33Z"} -{"cache_key":"dd2638564931b358d794aeef59c89f44ebfb1a77fcb1cd80b46b517854fc66c5","segment_id":"environment.md:f15f5f9f4ef4d668","source_path":"environment.md","text_hash":"f15f5f9f4ef4d6688876c894f8eba251ed1db6eaf2209084028d43c9e76a8ba1","text":" (aka ","translated":" (即 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:12:15Z"} -{"cache_key":"dd36df20c6faba4d8a51480a0d3230e61b4dea629b8457271019bd76b56796bc","segment_id":"index.md:9182ff69cf35cb47","source_path":"index.md","text_hash":"9182ff69cf35cb477c02452600d23b52a49db7bd7c9833a9a8bc1dcd90c25812","text":"Node ≥ 22","translated":"Node ≥ 22","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:51:27Z"} -{"cache_key":"dd92326da2311615e4319e5c3a8fdb740de38c95fabe4004d5464423cc665458","segment_id":"index.md:4d87941d681ca4e8","source_path":"index.md","text_hash":"4d87941d681ca4e89ca303d033b7d383d3acfbb6d9d9616bd88d7c19cf92c3dd","text":"Pi","translated":"Pi","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:47:33Z"} -{"cache_key":"de23354a5644ee88dd6cda719c74f7e42f04f3b92a74eae6a035e39e3836505b","segment_id":"start/wizard.md:211b0693ae6d4a20","source_path":"start/wizard.md","text_hash":"211b0693ae6d4a20d6c1dc31c560b94a9c12096f0711c9c3a114f7be1eb2c606","text":"Installs optional dependencies (some use Homebrew on macOS).","translated":"安装可选依赖项(部分在 macOS 上使用 Homebrew)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:45:54Z"} -{"cache_key":"de45cbfead4c307b1c63c8e70a26ffd33d21776ae05b33f924b12b5f28ee26c6","segment_id":"environment.md:ffa63583dfa6706b","source_path":"environment.md","text_hash":"ffa63583dfa6706b87d284b86b0d693a161e4840aad2c5cf6b5d27c3b9621f7d","text":"missing","translated":"缺失的","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:26:14Z"} -{"cache_key":"de5171e4493a39e50904a36b6951113b6d2301d68f06d7baac75051365bf6e21","segment_id":"index.md:frontmatter:summary","source_path":"index.md:frontmatter:summary","text_hash":"891b2aa093410f546b89f8cf1aa2b477ba958c2c06d2ae772e126d49786df061","text":"Top-level overview of OpenClaw, features, and purpose","translated":"OpenClaw 的顶层概述、功能和用途","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:58:43Z"} -{"cache_key":"de705dea3105826091973569de591048ac987d5a188374ba4aa8fcb94ea10a10","segment_id":"index.md:65fd6e65268ff905","source_path":"index.md","text_hash":"65fd6e65268ff9057a49d832cccfcd5a376e46a908a2129be5b43f945fa8d8ca","text":": Gateway WS defaults to ","translated":":Gateway WS 默认为 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:00:03Z"} -{"cache_key":"deab7aa2d56c6fe242f6f04eef414a3fed9e1ac64374fcba6ed245d7b2733f6b","segment_id":"start/getting-started.md:e0626242c2ea510e","source_path":"start/getting-started.md","text_hash":"e0626242c2ea510e9457d6fb1b2848fe7091b10201c13d28c9774e6450ad28b2","text":": WhatsApp QR login, Telegram/Discord bot tokens, Mattermost plugin tokens, etc.","translated":":WhatsApp 二维码登录、Telegram/Discord 机器人令牌、Mattermost 插件令牌等。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:36:11Z"} -{"cache_key":"debd94851d1f9c8951da5858e545b056346bbc436ba2720f05f5260a5dd44a44","segment_id":"start/wizard.md:aa9e63906bb59344","source_path":"start/wizard.md","text_hash":"aa9e63906bb5934462d7a9f29afd4a9562d5366c583706512cb48dce19c847df","text":"Web tools","translated":"网页工具","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:39:27Z"} -{"cache_key":"dfa9d0ec2c6831536363b0e1eed21cd1626774a92d3de45da94e234fae5386d2","segment_id":"environment.md:d4a67341570f4656","source_path":"environment.md","text_hash":"d4a67341570f4656784c5f8fe1bfb48a738ace57b52544977431d50e2b718099","text":"FAQ: env vars and .env loading","translated":"常见问题:环境变量 和 .env 加载","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:41:42Z"} -{"cache_key":"e02de3fd6a9838fd351be176f196cbde274da86bff8be2e8b2303bf6790df0cb","segment_id":"start/getting-started.md:698ca3e004f541ad","source_path":"start/getting-started.md","text_hash":"698ca3e004f541ad543cc5f936c56142f246a15f22c6dd5c9c7afd95532583c6","text":"3.5) Quick verify (2 min)","translated":"3.5)快速验证(2 分钟)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:37:04Z"} -{"cache_key":"e04e0151c02320e186d2fd1b07d89104ddb194e3ae4971af4bd1f9f1710bad19","segment_id":"index.md:032f5589cfa2b449","source_path":"index.md","text_hash":"032f5589cfa2b44973fe96c42e17dcc2692281413a05b16f48ff0f958f7f7ade","text":"Discord Bot","translated":"Discord 机器人","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:01:04Z"} -{"cache_key":"e08315c1cc4da73870b3503c2ab91309c6687ee63c42656605372e35941f2bfd","segment_id":"index.md:74f99190ef66a7d5","source_path":"index.md","text_hash":"74f99190ef66a7d513049d31bafc76e05f9703f3320bf757fb2693447a48c25b","text":"Linux app","translated":"Linux 应用","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:53:43Z"} -{"cache_key":"e0893eb3c8cefa57f80842b1e6f91535ad0274b358f897d7bb69a01346be4a59","segment_id":"index.md:5eeecff4ba2df15c","source_path":"index.md","text_hash":"5eeecff4ba2df15c51bcc1ba70a5a2198fbcac141ebe047a2db7acf0e1e83450","text":" — Local UI + menu bar companion for ops and voice wake","translated":" — 本地界面 + 菜单栏辅助工具,用于操作和语音唤醒","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:02:33Z"} -{"cache_key":"e09e0f7a58717d8dd5e93c9405e9e7a87e99b23ed91d6f88fef646f8686c4c06","segment_id":"index.md:c7a5e268ddd8545e","source_path":"index.md","text_hash":"c7a5e268ddd8545e5a59a58ef1365189862f802cc7b61d4a3212c70565e2dff1","text":"WhatsApp Integration","translated":"WhatsApp 集成","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:49:26Z"} -{"cache_key":"e0a3b73e10d18fd81805c5666c076468ea1043dd951f611550833d143c7c86c7","segment_id":"environment.md:frontmatter:read_when:2","source_path":"environment.md:frontmatter:read_when:2","text_hash":"822b3d74ce16c1be19059fad4ca5bf7ae9327f58fa1ff4e75e78d5afa75c038f","text":"You are documenting provider auth or deployment environments","translated":"你正在记录提供商认证或部署环境","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:45:15Z"} -{"cache_key":"e0bae3d42ce06b6edf1f45f8f78cb8923b3cab4aa4c1e89ef2b78a6b8116bf98","segment_id":"environment.md:fb135d32fb09abb6","source_path":"environment.md","text_hash":"fb135d32fb09abb6844f68b8fdb5545a2929cbc0a980fd7e19fc1fcba4d8cb32","text":" (what the Gateway process already has from the parent","translated":" (Gateway 进程已从父级","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:45:36Z"} -{"cache_key":"e10ee0a761ae715ddd355ff5b07c6707d58a7c4aa3d27284863c46797dce8eb9","segment_id":"environment.md:668e5590b5bb9990","source_path":"environment.md","text_hash":"668e5590b5bb9990eeb25bf657f7d17281a4c613ee4442036787cd4b2efd22bb","text":"If the config file is missing entirely, step 4 is skipped; shell import still runs if enabled.","translated":"如果配置文件完全缺失,则跳过步骤 4;如果已启用,shell 导入仍会运行。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:26:05Z"} -{"cache_key":"e116abb724100f07db70423f94752290e45ae7e83fbbb522f560aa75f8827bf3","segment_id":"index.md:6fa3cbf451b2a1d5","source_path":"index.md","text_hash":"6fa3cbf451b2a1d54159d42c3ea5ab8725b0c8620d831f8c1602676b38ab00e6","text":"Sessions","translated":"会话","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:30:23Z"} -{"cache_key":"e1190c4a2612cc2cea5658f3b586bfab859bd0df7c15f12bc4be6d0657f84734","segment_id":"index.md:013e11a23ec9833f","source_path":"index.md","text_hash":"013e11a23ec9833f907b2ead492b0949015e25d10ba92461669609aee559335d","text":"Start here:","translated":"从这里开始:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:52:12Z"} -{"cache_key":"e1257e0dfdcc12dc7e241311ad2ab6bd8b89cc6ced9cda94ef01fc35920887b8","segment_id":"environment.md:cf3f9ba035da9f09","source_path":"environment.md","text_hash":"cf3f9ba035da9f09202ba669adca3109148811ef31d484cc2efa1ff50a1621b1","text":" (what the Gateway process already has from the parent shell/daemon).","translated":" (Gateway 进程已从父 shell/守护进程继承的值)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:25:29Z"} -{"cache_key":"e1d82e4a5f815268a0e65b21f1722eb4d465937ea4849a800bfacf6dc70869bf","segment_id":"environment.md:6d4090fbae05a048","source_path":"environment.md","text_hash":"6d4090fbae05a048bc57d06313e19799dd5d4b3c1d2a18c6eb745b3dd3442593","text":" equivalents:","translated":" 等效项:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:46:50Z"} -{"cache_key":"e1f970bed34760abb7d362cd2f7f4af368a2c76a63130e47fb36466f738231ec","segment_id":"index.md:1016b5bdce94a848","source_path":"index.md","text_hash":"1016b5bdce94a8484312c123416c1a18c29fab915ba2512155df3a82ee097f8f","text":"If the Gateway is running on the same computer, that link opens the browser Control UI\nimmediately. If it fails, start the Gateway first: ","translated":"如果 Gateway 运行在同一台计算机上,该链接会立即打开浏览器控制界面。如果无法打开,请先启动 Gateway: ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:28:17Z"} -{"cache_key":"e288c66a34282338017002bdebb3d43b611e9268ee96a9a71377e27358f92a8d","segment_id":"start/wizard.md:0b7555ea7f832be2","source_path":"start/wizard.md","text_hash":"0b7555ea7f832be2c45b8912d6503cb867f500ab982c899ca3edf2bbd25da155","text":"Remote Gateway URL (","translated":"远程 Gateway URL(","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:46:18Z"} -{"cache_key":"e2a65642000db41fe132dae4ebaf8405dbcc8b607a6f85a2fe2b0ab89fc6113f","segment_id":"help/index.md:2adc964c084749b1","source_path":"help/index.md","text_hash":"2adc964c084749b1f2d8aef24030988b667dbda2e38a6a1699556c93e07c1cea","text":"Start here","translated":"从这里开始","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:15:15Z"} -{"cache_key":"e2be4f7a702604ffc4d6368630ac09d3dc83d72a7906b32ec1c6194d24768883","segment_id":"index.md:1cce617e15b49dca","source_path":"index.md","text_hash":"1cce617e15b49dca89b212bb5290edfcfee010ef2eeef369b36af78c53756e1c","text":" — Optional transcription hook","translated":" — 可选的转录钩子","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:30:46Z"} -{"cache_key":"e2bea8a812ba82ddc73f36802a93dadd6c4a0ab8d8c47a0b3959a2d3ac2e18e5","segment_id":"index.md:042c75df73389c8a","source_path":"index.md","text_hash":"042c75df73389c8a7c0871d2a451bd20431d24e908e2c192827a54022df95005","text":"Nacho Iacovino","translated":"Nacho Iacovino","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:06:25Z"} -{"cache_key":"e2f06db885ce6a775a64734d17016fb2c273cd62257def34b9ba0ca1a33a5b83","segment_id":"index.md:03279877bfe1de07","source_path":"index.md","text_hash":"03279877bfe1de0766393b51e69853dec7e95c287ef887d65d91c8bbe84ff9ff","text":"WebChat + macOS app","translated":"网页聊天 + macOS 应用","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:30:50Z"} -{"cache_key":"e3046cb9f63303cfbf56509890a29667276de5c6b954a6d62bfec56bbd2f5f6f","segment_id":"index.md:e47cdb55779aa06a","source_path":"index.md","text_hash":"e47cdb55779aa06a74ae994c998061bd9b7327f5f171c141caf2cf9f626bfe4b","text":"Peter Steinberger","translated":"Peter Steinberger","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:33:29Z"} -{"cache_key":"e355a25ce68c149fd415e9a5282ed06cf6d1b14a323d007e6e4bb63f516b63e2","segment_id":"start/wizard.md:frontmatter:read_when:0","source_path":"start/wizard.md:frontmatter:read_when:0","text_hash":"644fc34986851b3419d5dbb492d58c980aaef5ba5b75385e789421654bac2f0e","text":"Running or configuring the onboarding wizard","translated":"运行或配置上手引导向导","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:38:53Z"} -{"cache_key":"e35afed952675c044e4af3fb46f49fb19ffe702d850a44bd68d322846b87c3a8","segment_id":"index.md:2566561f81db7a7c","source_path":"index.md","text_hash":"2566561f81db7a7c4adb6cee3e93139155a6b01d52ff0d3d5c11648f46bc79bb","text":"📱 ","translated":"📱 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:00:49Z"} -{"cache_key":"e35cd8ef4c58d52ece200d844acc87c82675d4bfab5626181d5a19a80619121b","segment_id":"index.md:fdef9f917ee2f72f","source_path":"index.md","text_hash":"fdef9f917ee2f72fbd5c08b709272d28a2ae7ad8787c7d3b973063f0ebeeff7a","text":" to update the gateway service entrypoint.","translated":" 以更新网关服务入口点。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:51:32Z"} -{"cache_key":"e373ad3f709800b61550e436e63f2d836a6d8ef0d20d024a17c1e84979c3123b","segment_id":"start/getting-started.md:d7fc08e9364a1f77","source_path":"start/getting-started.md","text_hash":"d7fc08e9364a1f778246387363b55f32ca59ece0738ae543c994da0dab3dba09","text":"What you’ll choose:","translated":"您需要选择的内容:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:35:58Z"} -{"cache_key":"e47ee10a7f2c053bb20dc3cc1e1019c5043b6704d065442933270fd16de75cbc","segment_id":"index.md:774f1d6b2910de20","source_path":"index.md","text_hash":"774f1d6b2910de200115afec1bd87fe1ea6b0bc2142ac729e121e10a45df4b5d","text":" ← ","translated":" ← ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:03:52Z"} -{"cache_key":"e4d336ccc6430b7ad958c679f191372f6e1cd5ad513c5ce8f9b77cda4f2766e7","segment_id":"index.md:eec70d1d47ec5ac0","source_path":"index.md","text_hash":"eec70d1d47ec5ac00f04e59437e7d8b0988984c0cea3dddd81b1a2a10257960b","text":" — DMs + groups via grammY","translated":" —— 通过 grammY 支持私聊和群组","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:49:36Z"} -{"cache_key":"e51947f3a8d4e7ab79dc357deb1fcb7df73a48359733696bcd8ef64aaf7c4a45","segment_id":"index.md:297d5c673f5439aa","source_path":"index.md","text_hash":"297d5c673f5439aa31dca3bbc965cb657a89a643803997257defb3baef870f89","text":"Open the dashboard (local Gateway):","translated":"打开仪表板(本地 Gateway):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:59:19Z"} -{"cache_key":"e535ba32611dbfdfbdb030dbefbe4fa338d0de9c3dcf09e716b80b85ac6ec56e","segment_id":"start/getting-started.md:9995caf4a7d96e04","source_path":"start/getting-started.md","text_hash":"9995caf4a7d96e04d44f069d0e4b3ef3a2b210186fb92c3b1e846daf26b21a24","text":"macOS: if you plan to build the apps, install Xcode / CLT. For the CLI + gateway only, Node is enough.\nWindows: use ","translated":"macOS:如果您计划构建应用程序,请安装 Xcode / CLT。如果仅使用 CLI + Gateway,Node 就足够了。\nWindows:使用 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:35:38Z"} -{"cache_key":"e5714db86354a7868e7543683f77af47aa597f3bcddb61f46f2c313cb4cf8636","segment_id":"index.md:74926756385b8442","source_path":"index.md","text_hash":"74926756385b844294a215b2830576e3b2e93b84c5a8c8112b3816c5960f3022","text":" — DMs + guild channels via channels.discord.js","translated":" —— 通过 渠道.discord.js 支持私聊和服务器 渠道","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:49:43Z"} -{"cache_key":"e5857a240e24225783be67421286d47a4b18135d04c6f4f031e82d2a61cb02a3","segment_id":"index.md:f0e2018271f51504","source_path":"index.md","text_hash":"f0e2018271f515041084c8189f297236abe18f9ec77edad1a61c5413310bbd9e","text":"🖥️ ","translated":"🖥️ ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:02:28Z"} -{"cache_key":"e5a865669cc0d84b9b7f66db70c9b2536473891af6dc11f20fc3f7d5fccfceb6","segment_id":"environment.md:b4736422e64c0a36","source_path":"environment.md","text_hash":"b4736422e64c0a369663d1b2d386f1b8f4b31b8936b588e4a54453c61a24e0fd","text":"Process environment","translated":"进程环境","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:45:33Z"} -{"cache_key":"e5ab95a17152cbd3025b5413a5d7d8f0642fb9e3e3c72d241c7eec3e73b9104a","segment_id":"environment.md:7175517a370b5cd2","source_path":"environment.md","text_hash":"7175517a370b5cd2e664e3fd29c4ea9db5ce17058eb9772fe090a5485e49dad6","text":" or ","translated":" 或 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:16:39Z"} -{"cache_key":"e5b4eab0ca38617f4b76c99dc5fa36151812c02576b33f954a56cf5f77703696","segment_id":"index.md:bf0e823c81b87c5d","source_path":"index.md","text_hash":"bf0e823c81b87c5de79676155debf20a29b52d6d7eb7e77deda73a56d0afbaaa","text":"🧠 ","translated":"🧠 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:30:08Z"} -{"cache_key":"e5be378ff2d92da3de35b20680f90f5d1aa0a98ce205139d6fcaeac91ef06f65","segment_id":"index.md:9bcda844990ec646","source_path":"index.md","text_hash":"9bcda844990ec646b3b6ee63cbdf10f70b0403727dea3b5ab601ca55e3949db9","text":" for node WebViews; see ","translated":" 用于节点 WebView;参见 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:49:04Z"} -{"cache_key":"e5d655052f08f79672770734c9717dc24a5a9359defba7095dc7a9e2cf9e801b","segment_id":"start/wizard.md:bba52d8bacabbacc","source_path":"start/wizard.md","text_hash":"bba52d8bacabbacc510a1902b4eb35435f691903eb2db22fd110d41eadedec8d","text":" exists, the wizard can reuse it.","translated":" 存在,向导可以复用它。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:42:13Z"} -{"cache_key":"e628a7773be8d41e10dc53dcb383a11096e0573ec6b470aa13d2a14adcefb8e7","segment_id":"start/wizard.md:e3ba8a2959965f9c","source_path":"start/wizard.md","text_hash":"e3ba8a2959965f9c8360537e304016b2f75d561bdb03655a42adb02ce75a0e3f","text":"Default workspaces follow ","translated":"默认工作区遵循 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:46:57Z"} -{"cache_key":"e62ed5670f8283396dcc6a81182cda94667ff98973f153e4c86a04db364a4895","segment_id":"start/wizard.md:a8dbd136ed7c8e55","source_path":"start/wizard.md","text_hash":"a8dbd136ed7c8e55f9c0ae6e5acd2576d485f642d964a61f3693afc1c0c4ffdf","text":": uses ","translated":":使用 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:41:50Z"} -{"cache_key":"e66b34ec94f9a9c10b99b098ad8806551356222f1ac50f6fec7d719991faceee","segment_id":"start/wizard.md:c36d819e7bc6d2b7","source_path":"start/wizard.md","text_hash":"c36d819e7bc6d2b7da51394411c733db89c395987885ca6770167a3b9bc45c3c","text":"Use ","translated":"使用 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:46:45Z"} -{"cache_key":"e689f27ba4febf31a28a5b79eb2af514a15a3dff5dfe458bb3067cc59b2e7481","segment_id":"index.md:be48ae89c73a75da","source_path":"index.md","text_hash":"be48ae89c73a75da3454d565526d777938c20664618905a9bc77d6a0a21a689d","text":"\"EXFOLIATE! EXFOLIATE!\"","translated":"\"去角质!去角质!\"","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:47:25Z"} -{"cache_key":"e6b4ca13a3b7e39f521b1aadbb4f54f37875d228cd918c6406bd6519d5c7b6c8","segment_id":"index.md:6638cf2301d3109d","source_path":"index.md","text_hash":"6638cf2301d3109da66a44ee3506fbd35b29773fa4ca33ff35eb838c21609e19","text":"Features (high level)","translated":"功能特性(概览)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:29:26Z"} -{"cache_key":"e6e2a9985237253e0478229a54f3693bc7b0472bc450d53a4122dc20dfe08b21","segment_id":"environment.md:6863067eb0a2c749","source_path":"environment.md","text_hash":"6863067eb0a2c7499425c6c189b2c88bac55ca754285a6ab1ef37b75b4cfad4d","text":"See ","translated":"参见 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:22:37Z"} -{"cache_key":"e6e456289628d5a4b6cbbc0fbb263d656ba7d49427a2009ce3c5f608b8505ea0","segment_id":"index.md:f0d82ba647b4a33d","source_path":"index.md","text_hash":"f0d82ba647b4a33da3008927253f9bed21e380f54eab0608b1136de4cbff1286","text":"OpenClaw bridges WhatsApp (via WhatsApp Web / Baileys), Telegram (Bot API / grammY), Discord (Bot API / channels.discord.js), and iMessage (imsg CLI) to coding agents like ","translated":"OpenClaw 将 WhatsApp(通过 WhatsApp Web / Baileys)、Telegram(Bot API / grammY)、Discord(Bot API / channels.discord.js)和 iMessage(imsg CLI)桥接到编程 智能体,例如 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:58:56Z"} -{"cache_key":"e723a0b2ab360a74b84f4ccd08fdc4cc1639b85d5178d45d8103a18069bd3d8d","segment_id":"start/getting-started.md:1b59a1d9fa6d392f","source_path":"start/getting-started.md","text_hash":"1b59a1d9fa6d392f1f68642200583ed0f7b372af2fbc7c01d5f7f00463e229de","text":" also bundles A2UI assets; if you need to run just that step, use ","translated":" 也会打包 A2UI 资源;如果您只需要运行该步骤,请使用 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:37:51Z"} -{"cache_key":"e7279b78eeb5dccdf1897af612ce9f34bbae6f6ad7d8a7fed40a48f2f59c2367","segment_id":"environment.md:frontmatter:summary","source_path":"environment.md:frontmatter:summary","text_hash":"78351223e7068721146d2de022fdf440c2866b2ee02fbbb50bf64369b999820b","text":"Where OpenClaw loads environment variables and the precedence order","translated":"OpenClaw 加载环境变量的位置及优先级顺序","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:25:05Z"} -{"cache_key":"e73887cca1549bd1acf945a50dfbd054a3ec1c87741be5a0a4381a4840ce13e5","segment_id":"index.md:1df4f2299f0d9cc4","source_path":"index.md","text_hash":"1df4f2299f0d9cc466fa05abeb2831e76e9f89583228174ffcd9af415fd869fe","text":"Send a test message (requires a running Gateway):","translated":"发送测试消息(需要运行中的 Gateway):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:03:17Z"} -{"cache_key":"e747cc049257f34351f8e9510202e9a6f21541b6ab738d9c1e2aa1a41c519657","segment_id":"environment.md:cb133602d7dd4bc6","source_path":"environment.md","text_hash":"cb133602d7dd4bc6ecfe37a040de72b562547e609327bdd41ea294f9257b7248","text":" keys.","translated":" 密钥时应用。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:46:22Z"} -{"cache_key":"e758b5241da8091ae15d39a4bd67f4c86e9beb81d84def2a94118597695be1b4","segment_id":"index.md:42bb365211decccb","source_path":"index.md","text_hash":"42bb365211decccb3509f3bf8c4dfcb5ae05fe36dfdedb000cbf44e59e420dc9","text":" — Local imsg CLI integration (macOS)","translated":" — 本地 imsg CLI 集成(macOS)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:01:21Z"} -{"cache_key":"e7692faaf02464b2a4dd119d057cc5aced8c33764089e7974d2634ae997c09f2","segment_id":"environment.md:496aca80e4d8f29f","source_path":"environment.md","text_hash":"496aca80e4d8f29fb8e8cd816c3afb48d3f103970b3a2ee1600c08ca67326dee","text":" block","translated":" 块","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:22:11Z"} -{"cache_key":"e7bc8ffa042426610faa9c40c7191933bfda50deb769ef153580d4ab1c75d679","segment_id":"start/getting-started.md:cdb4ee2aea69cc6a","source_path":"start/getting-started.md","text_hash":"cdb4ee2aea69cc6a83331bbe96dc2caa9a299d21329efb0336fc02a82e1839a8","text":".","translated":"。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:34:45Z"} -{"cache_key":"e8160fc2a7763ac99c0933d4424a99f211b661b0d7649bb1d33f908c3ff5e0d2","segment_id":"start/getting-started.md:75e23f5184b23835","source_path":"start/getting-started.md","text_hash":"75e23f5184b23835efb6fdc64309312d3c9212d10566350b1a08ff7838c79d03","text":"2) Run the onboarding wizard (and install the service)","translated":"2)运行上手引导向导(并安装服务)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:35:55Z"} -{"cache_key":"e81fa8ea81e681a305d677a823722958c2fdf42c3afbf4149a2d5cdfc4c6e1df","segment_id":"index.md:4eb58187170dc141","source_path":"index.md","text_hash":"4eb58187170dc14198eacb534c8577bef076349c26f2479e1f6a2e31df8eb948","text":" — An AI, probably high on tokens","translated":" — 一个可能被令牌冲昏头脑的 AI","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:06:53Z"} -{"cache_key":"e87435d09fd52a520aeae4097eb83a149aeb498192ccfbdd63da8db57571de09","segment_id":"index.md:d08cec54f66c140c","source_path":"index.md","text_hash":"d08cec54f66c140c655a1631f6d629927c7c38b9c8bfa91c875df9bd3ad3c559","text":"OpenClaw assistant setup","translated":"OpenClaw 助手设置","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:32:12Z"} -{"cache_key":"e8a313447619fd5d7895acf1c467e347d47a8c35861910facf5ff08f88a8905e","segment_id":"index.md:5928d14b4d45263d","source_path":"index.md","text_hash":"5928d14b4d45263d4964dfd301c84ed2674ca8b4b698c5efeb88fb86076d2bf9","text":"🎮 ","translated":"🎮 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:29:39Z"} -{"cache_key":"e8bfa9777ff1ca6f2921ef47688f6ddb7d1a68c074dc27c7af195521940fb68f","segment_id":"help/index.md:frontmatter:summary","source_path":"help/index.md:frontmatter:summary","text_hash":"aece82a2d540ab1a9a21c7b038127cae6e9db2149491564bb1856b6f8999f205","text":"Help hub: common fixes, install sanity, and where to look when something breaks","translated":"帮助中心:常见修复方法、安装完整性检查,以及出现问题时的排查指南","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:39:25Z"} -{"cache_key":"e8ee67c09bdbe71798a5c6348316e32fa4bf8cdf688a0fba4493ffc836a62fde","segment_id":"environment.md:c2d7247c8acb83a5","source_path":"environment.md","text_hash":"c2d7247c8acb83a5a020458fa836c2445922b51513dbdbf154ab5f7656cb04e9","text":"; does not override).","translated":";不会覆盖)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:25:44Z"} -{"cache_key":"e8fb144a38ce4b1553a092808d81c2faabee7f47fc5f950ff809998508ead2a9","segment_id":"environment.md:6db0742daaf9f191","source_path":"environment.md","text_hash":"6db0742daaf9f191ab7816d2c9d317b1ea1693453a8c63b95af8b01477e0f5bb","text":" runs your login shell and imports only ","translated":" 运行您的登录 shell 并仅导入 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:58:07Z"} -{"cache_key":"e9196b72990174331920ecd407ae4e20e96e67c7a2bd9e9deecdf9dda0a49b1e","segment_id":"index.md:c4b2896a2081395e","source_path":"index.md","text_hash":"c4b2896a2081395e282313d6683f07c81e3339ef8b9d2b5a299ea5b626a0998f","text":").","translated":")。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:00:34Z"} -{"cache_key":"e96f23e744751e56e76bb4914d500540aa9e7477681bf82acf3e8d249b7443e9","segment_id":"start/wizard.md:fda4a25e07825d0e","source_path":"start/wizard.md","text_hash":"fda4a25e07825d0e741782945be50a3bbf326b9403943ae322f9ff2c9d959a99","text":"QuickStart","translated":"快速入门","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:39:34Z"} -{"cache_key":"e974c0b5a54da233c9d3202030578368ed8f3ea979c5c958f879fcce408ef324","segment_id":"index.md:frontmatter:summary","source_path":"index.md:frontmatter:summary","text_hash":"891b2aa093410f546b89f8cf1aa2b477ba958c2c06d2ae772e126d49786df061","text":"Top-level overview of OpenClaw, features, and purpose","translated":"OpenClaw 的顶层概述、功能特性与用途","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:27:24Z"} -{"cache_key":"ea45ea5fb5edafd10885c5996709509fad9abd882c5daacc6f032e390b66c408","segment_id":"start/wizard.md:b1f78eea9ea563ca","source_path":"start/wizard.md","text_hash":"b1f78eea9ea563cab0611c9d9f74199e0f1dc1b7855a0f4e0eb8f4e0b9848b9e","text":"Add agent (non‑interactive) example:","translated":"添加智能体(非交互)示例:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:47:37Z"} -{"cache_key":"eacecaae490a307e52e287a93f22dd76cb2bab3c62c7d3e8e95480d7333a1d84","segment_id":"index.md:0eb95fb6244c03f1","source_path":"index.md","text_hash":"0eb95fb6244c03f1ccca696718a06766485c231347bf382424fb273145472355","text":"Quick start","translated":"快速入门","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:51:22Z"} -{"cache_key":"eaf2d170adde0688e23ca9cabb4074fdfbddd11f9e9327e51891878b361dfb2d","segment_id":"index.md:d372b90f0ccffad0","source_path":"index.md","text_hash":"d372b90f0ccffad0ae6e3df3c3aaeccd7a17eb59b4bc492a5469dc05ac3629ec","text":", OpenClaw uses the bundled Pi binary in RPC mode with per-sender sessions.","translated":",OpenClaw 将使用捆绑的 Pi 二进制文件以 RPC 模式运行,并使用每个发送者的 会话。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:03:33Z"} -{"cache_key":"eba13072b1a354b471f3da30934e0e7c51b0a4954b7b528817a8f20be0ec9c53","segment_id":"index.md:ceee4f2088b9d5ba","source_path":"index.md","text_hash":"ceee4f2088b9d5ba7d417bac7395003acfbcef576fd4cc1dd3063972f038218a","text":"The name","translated":"名称由来","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:33:16Z"} -{"cache_key":"ec2ec567c80acb4eaebb70c38df1d9ab94f68714cb99694d11d947a071edfdd4","segment_id":"start/wizard.md:3f485847642a332e","source_path":"start/wizard.md","text_hash":"3f485847642a332ed0374201686055314594de14929920d4c40d44676929d972","text":" to automate or script onboarding:","translated":" 用于自动化或脚本化上手引导:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:47:14Z"} -{"cache_key":"ec52d1059c9865c5fc3c2a42df8c7a6bf0a0b11707cd03f66a7767f8ea7eb532","segment_id":"environment.md:453c14128fbfb5f6","source_path":"environment.md","text_hash":"453c14128fbfb5f6757511557132a1dbb3bcbf243267630bfec49db8518c7780","text":"Env var substitution in config","translated":"配置中的环境变量替换","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:19:42Z"} -{"cache_key":"ec6203797e9e6d7c8b84dff668fa87d4f4e18e599a55a3e235a54bcfa85dcc08","segment_id":"environment.md:6db0742daaf9f191","source_path":"environment.md","text_hash":"6db0742daaf9f191ab7816d2c9d317b1ea1693453a8c63b95af8b01477e0f5bb","text":" runs your login shell and imports only ","translated":" 运行你的登录 shell 并仅导入 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:22:20Z"} -{"cache_key":"ec6544cf9a2fdf796cb6d3311bf84b9d9f4212fd4491ceb30cc7830f1bfe7024","segment_id":"help/index.md:b79cac926e0b2e34","source_path":"help/index.md","text_hash":"b79cac926e0b2e347e72cc91d5174037c9e17ae7733fd7bdb570f71b10cd7bfc","text":"Help","translated":"帮助","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:24:32Z"} -{"cache_key":"ec84b886dc2d0638c71fa040e38e36a5fa259ee781decdf9493570c2fec604fa","segment_id":"index.md:e9f63c8876aec738","source_path":"index.md","text_hash":"e9f63c8876aec7381ffb5a68efb39f50525f9fc4e732857488561516d47f5654","text":" — Uses Baileys for WhatsApp Web protocol","translated":" —— 使用 Baileys 实现 WhatsApp Web 协议","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:49:29Z"} -{"cache_key":"ec9e3eb6d2bd790ee1b161f31b6bb70649ee6b5df85dd2b6a0178ee01c443f69","segment_id":"start/wizard.md:61c5ae608ddc7474","source_path":"start/wizard.md","text_hash":"61c5ae608ddc7474cd3aadc92c22059f7a539eefb0a56b02f625c39e552ff7f7","text":"The wizard can install ","translated":"向导可以安装 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:47:51Z"} -{"cache_key":"eca7489e62538a4b68a7d49f3a67df1c6bad8affc75d6411f68ca1e81bef47b2","segment_id":"environment.md:f6b2ffe1d0d5f521","source_path":"environment.md","text_hash":"f6b2ffe1d0d5f521b76cabc67d6e96da2b1170eef8086d530558e9906a7f092d","text":"Models overview","translated":"模型概览","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:13:17Z"} -{"cache_key":"ecb4df64e132ff6212066948863adabaa06122c77d8971d5c924dc2e744df845","segment_id":"index.md:98a670e2fb754896","source_path":"index.md","text_hash":"98a670e2fb7548964e8b78b90fef47f679580423427bfd15e5869aca9681d0dd","text":"\"We're all just playing with our own prompts.\"","translated":"\"我们都只是在玩弄自己的提示词。\"","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:05:43Z"} -{"cache_key":"ecd894720faa37450014e0fe1630be8382cf6ec23cbb9bfe76bc4125495d8fa5","segment_id":"index.md:9adcfa4aa10a4e8b","source_path":"index.md","text_hash":"9adcfa4aa10a4e8b991a72ccc45261cd64f296aed5b257e4caf9c87aff1290a0","text":" — Send and receive images, audio, documents","translated":" — 收发图片、音频、文档","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:30:38Z"} -{"cache_key":"ed10c233aa195883b17061f166f647efac5a27535a85ce4d16fc90d40e138882","segment_id":"help/index.md:8cd501e1124c3047","source_path":"help/index.md","text_hash":"8cd501e1124c30473473c06e536a2d145e2a14a6d7dc1b99028ce818e14442e2","text":"Repairs:","translated":"修复:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:39:56Z"} -{"cache_key":"ed15427258ffbf85620a0c9c0c42deb7f37be17b7abeff5993a34962964f0e96","segment_id":"index.md:a194ca16424ddd17","source_path":"index.md","text_hash":"a194ca16424ddd17dacc45f1cbd7d0e41376d8955a7b6d02bc38c295cedd04e4","text":"RPC adapters","translated":"RPC 适配器","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:32:19Z"} -{"cache_key":"ed24753e60b54d629cfd978be87185f4772676322534432302319caf28452d29","segment_id":"index.md:ab201ddd7ab330d0","source_path":"index.md","text_hash":"ab201ddd7ab330d04be364c0ac14ce68c52073a0ee8d164a98c3034e91ce1848","text":" from the repo.","translated":" (在仓库目录中执行)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:31:21Z"} -{"cache_key":"ed37a2b1a8c3351a6c04bee81df6f507f306be344485e69eb87b3b2451aad89f","segment_id":"help/index.md:d3ef01b4a9c99103","source_path":"help/index.md","text_hash":"d3ef01b4a9c9910364c9b26b2499c8787a0461d2d24ab80376fff736a288b34c","text":"Logging","translated":"日志记录","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:24:47Z"} -{"cache_key":"ee3f1647acf674397ba7f7e1aee0f9972b9830f978b622695d8ab5360de5a496","segment_id":"index.md:255ce77b7a6a015f","source_path":"index.md","text_hash":"255ce77b7a6a015f8595868a524b67c134e8fb405f4584fdac020e57f4ccd5f6","text":"Loopback-first","translated":"回环优先","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:00:01Z"} -{"cache_key":"ee582fba5363de60fb2c00f9238f2ac9ad6dc7615694d8d23d24d88bf7ec13e1","segment_id":"environment.md:582967534d0f909d","source_path":"environment.md","text_hash":"582967534d0f909d196b97f9e6921342777aea87b46fa52df165389db1fb8ccf","text":" in ","translated":" 在 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:16:29Z"} -{"cache_key":"eead1cfedffdef3e1e7e8bfc6339df973b1390f8cd648602a62448762b8963f4","segment_id":"start/wizard.md:15836cbac4abdca3","source_path":"start/wizard.md","text_hash":"15836cbac4abdca3c78de3c3470fdc7bea9a96d0f38a1d0e4ec941bfc18ecb26","text":"Config only","translated":"仅配置","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:41:30Z"} -{"cache_key":"eeebff3da1cf246a7ee248bd8bc9694ee3d98c0f3fe5a0dcbfefa5e252b113a2","segment_id":"index.md:c3af076f92c5ed8d","source_path":"index.md","text_hash":"c3af076f92c5ed8dcb0d0b0d36dd120bc31b68264efea96cf8019ca19f1c13a3","text":"Troubleshooting","translated":"故障排除","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:05:33Z"} -{"cache_key":"eeef5f9dd1ae51906bf8d4a97c86db5d9327f00c8117da5fe2276a1ac1b155f4","segment_id":"help/index.md:156597e2632411d1","source_path":"help/index.md","text_hash":"156597e2632411d1d5f634db15004072607ba45072a4e17dfa51790a37b6781f","text":"Gateway issues:","translated":"Gateway 问题:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:24:43Z"} -{"cache_key":"ef28fdc07b59ec5ce5915e3de7389d8d70ecb8ed31445ed4066d7118fe6dd63e","segment_id":"environment.md:6f59001999ef7b71","source_path":"environment.md","text_hash":"6f59001999ef7b7128bab80d2034c419f3034497e05f69fbdf67f7b655cdc173","text":"Configuration: Env var substitution","translated":"配置:环境变量 替换","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:41:33Z"} -{"cache_key":"ef3b396216400003eb534a0ab4fe41ae559b2fb39623ec3e2f9892c4f4cba9ef","segment_id":"index.md:ec05222b3777fd7f","source_path":"index.md","text_hash":"ec05222b3777fd7f91a2964132f05e3cfc75777eaeec6f06a9a5c9c34a8fc3e9","text":"Nix mode","translated":"Nix 模式","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:04:10Z"} -{"cache_key":"ef7f4605237a606f565a596c39809fa969774059be148db688b808634350bf09","segment_id":"index.md:5928d14b4d45263d","source_path":"index.md","text_hash":"5928d14b4d45263d4964dfd301c84ed2674ca8b4b698c5efeb88fb86076d2bf9","text":"🎮 ","translated":"🎮 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:49:38Z"} -{"cache_key":"efa246765d696f04600590562765687bb4f5fefce8a4df66bc2cbe3275f3f43e","segment_id":"start/wizard.md:426263b5cd4ab1f3","source_path":"start/wizard.md","text_hash":"426263b5cd4ab1f3211193944727955444c6454a1640bec5e6f35b017c6d285f","text":"Non‑loopback binds still require auth.","translated":"非回环绑定仍需认证。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:44:08Z"} -{"cache_key":"efd8767a5fede85377af51202b1450a0f73054f978162c2d8bcef5dfa6220323","segment_id":"start/getting-started.md:e67454c1b6dd66c2","source_path":"start/getting-started.md","text_hash":"e67454c1b6dd66c2f006a8a98ff9c6a1279f8283eab3a272c15436f164cefe7b","text":"Recommended path: use the ","translated":"推荐路径:使用 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:34:46Z"} -{"cache_key":"effbe87506ca4373185a6bd9eb8262362bc299b5fbd8da0ce76b0aa8fe73ff1d","segment_id":"environment.md:a258b30f88c30650","source_path":"environment.md","text_hash":"a258b30f88c30650e73073d5bdde5cfcc6987100ae62d37789e5c46a0d85b7c6","text":"Global ","translated":"全局 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:25:36Z"} -{"cache_key":"f02f949874120a6c5b691141073ad6c170eaa88039cdad423e870a2753e957b3","segment_id":"start/getting-started.md:caf33dca8b21dc18","source_path":"start/getting-started.md","text_hash":"caf33dca8b21dc18f96b1f009b0dba4d75ddc00ea245972e98d56b1d1a5a009d","text":"Mattermost (plugin): ","translated":"Mattermost(插件): ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:37:28Z"} -{"cache_key":"f04ed463aa434ea141889ce238029572813914c69789bd6fb5eacba8423f5768","segment_id":"help/index.md:frontmatter:read_when:0","source_path":"help/index.md:frontmatter:read_when:0","text_hash":"ee0615553374970664b58ebd8e5d0ebc9bc8a5f03387671afbfd0096b390aa9b","text":"You’re new and want the “what do I click/run” guide","translated":"你是新手,想要一份\"我该点击/运行什么\"的指南","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:44:25Z"} -{"cache_key":"f0bc0a82d8a06b403ce5154b870a817a8097bacdb2e4fe64ab876d6f084f389c","segment_id":"index.md:d372b90f0ccffad0","source_path":"index.md","text_hash":"d372b90f0ccffad0ae6e3df3c3aaeccd7a17eb59b4bc492a5469dc05ac3629ec","text":", OpenClaw uses the bundled Pi binary in RPC mode with per-sender sessions.","translated":",OpenClaw 将使用内置的 Pi 二进制文件以 RPC 模式运行,并为每个发送者提供 会话。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:51:58Z"} -{"cache_key":"f11584b1b8bb57dbe543960af7b37e9ff6fb5eab1a8da25c423f5780dd0d676c","segment_id":"start/getting-started.md:ab744fe26b887abd","source_path":"start/getting-started.md","text_hash":"ab744fe26b887abdb3558472d5bfe074f2716bbd88c8fab2b86bc745cbe7cf52","text":"Tip: ","translated":"提示: ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:38:07Z"} -{"cache_key":"f16eb7fed19f8561d7438f4379417058d14d6effa70a7e8ab163a2c08e69b70f","segment_id":"start/wizard.md:8ef5034a90ff178a","source_path":"start/wizard.md","text_hash":"8ef5034a90ff178aded1c6f9898a864b8af345b28b62274e520c62e4bc44dec8","text":"Native builds are used when available.","translated":"如有原生构建则优先使用。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:48:15Z"} -{"cache_key":"f18978cae4cb765c8959cd68c4897fde778c8cece0f3e6a778e862fc767efebe","segment_id":"index.md:013e11a23ec9833f","source_path":"index.md","text_hash":"013e11a23ec9833f907b2ead492b0949015e25d10ba92461669609aee559335d","text":"Start here:","translated":"从这里开始:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:03:44Z"} -{"cache_key":"f1bf5865e234c088f292333d3304a20f3b9b69544d67f32494540f263fa1e1cc","segment_id":"index.md:2adc964c084749b1","source_path":"index.md","text_hash":"2adc964c084749b1f2d8aef24030988b667dbda2e38a6a1699556c93e07c1cea","text":"Start here","translated":"从这里开始","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:47:39Z"} -{"cache_key":"f1f9640f4e20ead3c4890cd38fa2d2f83e102d190c71f31bf74e43411b220707","segment_id":"environment.md:3527b238ea049608","source_path":"environment.md","text_hash":"3527b238ea04960811e4f77378c46a6cddaf9dbf907d8affb0974772028b269e","text":"If the config file is missing entirely, step 4 is skipped; shell import still runs if","translated":"如果配置文件完全缺失,则跳过第 4 步;如果","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:46:24Z"} -{"cache_key":"f2078834885c634ec26e8903f4ed129d2fa2611d43b07c1b65d99b4207dd3f17","segment_id":"index.md:cdb4ee2aea69cc6a","source_path":"index.md","text_hash":"cdb4ee2aea69cc6a83331bbe96dc2caa9a299d21329efb0336fc02a82e1839a8","text":".","translated":"。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:59:28Z"} -{"cache_key":"f218922442b56c5e09b8f23fab26599a3631012ca6e296456125326f409f1f7e","segment_id":"help/index.md:cad44fbae951d379","source_path":"help/index.md","text_hash":"cad44fbae951d3791565b0cee788c01c3bd10e0176167acb691b8dba0f7895f8","text":"Gateway logging","translated":"Gateway 日志记录","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:56:40Z"} -{"cache_key":"f22c948c8f08bba03ae5ab9b17be95ed84ed98de50cbcbea09d5812b3d9fd4e1","segment_id":"start/wizard.md:7c2a0a6b7bb37dc2","source_path":"start/wizard.md","text_hash":"7c2a0a6b7bb37dc269429103bc13c5f5172b11631d7d44e84e0d5e4881354e4f","text":" works without a key). Easiest path: ","translated":" 无需密钥也可使用)。最简单的方式: ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:39:21Z"} -{"cache_key":"f23e602e5722bcb75d4969fec8ae88209555d9f30e4cc863e54cb0665c150f93","segment_id":"index.md:e3572f8733529fd3","source_path":"index.md","text_hash":"e3572f8733529fd30a8604d41d624c15f4433df68f40bd092d1ee61f7d8d15e2","text":"Agent bridge","translated":"智能体桥接","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:29:57Z"} -{"cache_key":"f258e8524ff328198c2d9437453a1d91d940664b2f522b1ec9ac79b0139fc660","segment_id":"start/wizard.md:54ec12801f42e556","source_path":"start/wizard.md","text_hash":"54ec12801f42e5568f617d1aad18c458515c72920de170a24ef0f2be60cd3d33","text":"Moonshot AI (Kimi + Kimi Coding)","translated":"Moonshot AI (Kimi + Kimi Coding)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:43:13Z"} -{"cache_key":"f25bc202081adc9aa4d305452fe50f1a9c63b9077c112ebdc0d9166737b3675a","segment_id":"index.md:99260acc29f71e4b","source_path":"index.md","text_hash":"99260acc29f71e4baeb36805a1fdbd2c17254b57c8e5a9cba29ee56518832397","text":" — Route provider accounts/peers to isolated agents (workspace + per-agent sessions)","translated":" — 将 提供商 账户/对等方路由到隔离的 智能体(工作区 + 每个 智能体 的 会话)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:01:46Z"} -{"cache_key":"f2a0941718593a4be66a7a033a4117a7b3a502ef64b25fd7d6d3475c77dd5a1a","segment_id":"environment.md:87e89abb4c1c551f","source_path":"environment.md","text_hash":"87e89abb4c1c551fe08d355d097f18b8de78edca5f556997085681662fce8eed","text":"Config ","translated":"配置 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:16:24Z"} -{"cache_key":"f2a0c70d8b9f94722b586320f11c58339d30dd1fe8ff7250a962bb2db84d5ab4","segment_id":"environment.md:ffa63583dfa6706b","source_path":"environment.md","text_hash":"ffa63583dfa6706b87d284b86b0d693a161e4840aad2c5cf6b5d27c3b9621f7d","text":"missing","translated":"缺失的","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:58:09Z"} -{"cache_key":"f2c14989f888bbff9c7330f2d5b3892af3b900910840435595031590dc8248e3","segment_id":"environment.md:frontmatter:read_when:0","source_path":"environment.md:frontmatter:read_when:0","text_hash":"90fc0487bff88009979cff1061c1a882df8c3b1baa9c43538331d9d5dab15479","text":"You need to know which env vars are loaded, and in what order","translated":"你需要了解加载了哪些环境变量,以及它们的加载顺序","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:15:47Z"} -{"cache_key":"f34789e2cb492196e8c057294dd98c5f9d4b8054d548a7b883a47f113efa1277","segment_id":"index.md:31365ab9453d6a1e","source_path":"index.md","text_hash":"31365ab9453d6a1ec03731622803d3b44f345b6afad08040d7f3e97290c77913","text":"do nothing","translated":"不做任何操作","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:51:55Z"} -{"cache_key":"f36f13a67a73f6768bfbf346d552067475ef4f8137e13edfd4f636e1b7ef2ef8","segment_id":"start/getting-started.md:649cfa2f76a80b42","source_path":"start/getting-started.md","text_hash":"649cfa2f76a80b42e1821c89edd348794689409dcdf619dcd10624fb577c676b","text":"not recommended","translated":"不推荐","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:36:21Z"} -{"cache_key":"f3701b1ce8ac7f8931cafd209250aa5ae388ecfdb0154dbbb21c03fd72ce5d08","segment_id":"help/index.md:729bc562eec2658b","source_path":"help/index.md","text_hash":"729bc562eec2658bd11ffdd522fe5277177dc73e86eaca7baac0b472a4d8f8b2","text":"If you’re looking for conceptual questions (not “something broke”):","translated":"如果你在寻找概念性问题(不是\"某个东西坏了\"):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:11:29Z"} -{"cache_key":"f37dcde1b1a3572f2e12cec637bb9435d7594f5d680ca4c8d2916587ceaa5b49","segment_id":"environment.md:baa5be7f6320780b","source_path":"environment.md","text_hash":"baa5be7f6320780bd7bb7b7ddbb8cd1ffb26ccf7d94d363350668c50aedcf95f","text":" (applied only if missing).","translated":" (仅在缺失时应用)。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:16:32Z"} -{"cache_key":"f3bae8376433842a2647a0f99681be1ae704993131bd626b47c7ead29db85121","segment_id":"index.md:41ed52921661c7f0","source_path":"index.md","text_hash":"41ed52921661c7f0d68d92511589cc9d7aaeab2b5db49fb27f0be336cbfdb7df","text":"Gateway","translated":"Gateway","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:48:23Z"} -{"cache_key":"f3d666bd4b1803904177f2fd15477daab9b1988d37873a621ff0ff20fc67430a","segment_id":"index.md:32ebb1abcc1c601c","source_path":"index.md","text_hash":"32ebb1abcc1c601ceb9c4e3c4faba0caa5b85bb98c4f1e6612c40faa528a91c9","text":" (","translated":" (","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:47:51Z"} -{"cache_key":"f44674e6fe8bdf7df11beea733dc32ed87d3f98aa27ab39d91af414342ea24ac","segment_id":"environment.md:frontmatter:read_when:1","source_path":"environment.md:frontmatter:read_when:1","text_hash":"a3a2d99a99de98220c8e0296d6f4e4b2a34024916bd2379d1b3b9179c8fae46f","text":"You are debugging missing API keys in the Gateway","translated":"你正在调试 Gateway 中缺失的 API 密钥","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:15:49Z"} -{"cache_key":"f4572fb2d4379ec9633f4e503fc4ffe1b6e5d42baf75386b995e4453a220112f","segment_id":"start/wizard.md:5c237035504bf1d8","source_path":"start/wizard.md","text_hash":"5c237035504bf1d829557c9f34d581e874170d29eb78178780d9de279686878b","text":": service account JSON + webhook audience.","translated":":服务账户 JSON + webhook 受众。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:44:37Z"} -{"cache_key":"f4b0c2b320a173553e165db9e33134bd687611509a67f872b3802da035e18003","segment_id":"start/wizard.md:c47c637c5420619c","source_path":"start/wizard.md","text_hash":"c47c637c5420619cf8a485038799bbf646ac4dd9fb434e4da93e49276e6c63cf","text":"Linux: Avahi (","translated":"Linux:Avahi(","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:46:39Z"} -{"cache_key":"f4b8ff8f3efbd8ee938358900957557c4222b284b44d2a7048b9d12bafcaccb3","segment_id":"environment.md:frontmatter:read_when:2","source_path":"environment.md:frontmatter:read_when:2","text_hash":"822b3d74ce16c1be19059fad4ca5bf7ae9327f58fa1ff4e75e78d5afa75c038f","text":"You are documenting provider auth or deployment environments","translated":"你正在记录提供商认证或部署环境的文档","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:25:12Z"} -{"cache_key":"f4bde41e2630aeb2a70bf71ad4d202512d708d38dd36418cd9ac8d4332cd2359","segment_id":"index.md:add4778f9e60899d","source_path":"index.md","text_hash":"add4778f9e60899d7f44218483498c0baf7a0468154bc593a60747ee769c718c","text":"Android node","translated":"Android 节点","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:30:59Z"} -{"cache_key":"f52a9c7d0d2374d22023815ee71b9d667d1f40014d21c495be00062bb7ff7e9d","segment_id":"start/wizard.md:9349cb3da677e30e","source_path":"start/wizard.md","text_hash":"9349cb3da677e30edeeea7e42cf0ef9b5bcbb063c2c1e11e4805728cfb809b27","text":"Auth recommendation: keep ","translated":"认证建议:保持 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:43:59Z"} -{"cache_key":"f560e7bf274a11b63b63dfc2b1e34b5d4f767099b60c828981323400825310c0","segment_id":"index.md:83f4fc80f6b452f7","source_path":"index.md","text_hash":"83f4fc80f6b452f7cdf426f6b87f08346d7a2d9c74a0fb62815dce2bfddacf63","text":" — A space lobster, probably","translated":" — 大概是一只太空龙虾说的","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:27:52Z"} -{"cache_key":"f5ce8d582224799c2c298caa9a9f7dfb7d86186f570cfddd641946668d1d13da","segment_id":"index.md:79a482cf546c23b0","source_path":"index.md","text_hash":"79a482cf546c23b04cd48a33d4ca8411f62e5b7dc8c3a8f30165e28e747f263a","text":"iMessage","translated":"iMessage","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:29:51Z"} -{"cache_key":"f5fa9cda34fd26fb939c24c123c64b46dd61b92c355cd4a750f394defd4a695c","segment_id":"index.md:2adc964c084749b1","source_path":"index.md","text_hash":"2adc964c084749b1f2d8aef24030988b667dbda2e38a6a1699556c93e07c1cea","text":"Start here","translated":"从这里开始","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:28:00Z"} -{"cache_key":"f5ffb1cdcefe6f0cd2d2b69e0756d6cc01a9c6a0e02b454f0e30b38b6ad7b2e2","segment_id":"index.md:723fad6d27da9393","source_path":"index.md","text_hash":"723fad6d27da939353c65417bbaf646b65903b316eb4456297ff4a1c20811e8d","text":": HTTP file server on ","translated":":HTTP 文件服务器位于 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:00:20Z"} -{"cache_key":"f60fee3592c356f74a3be54ab30e9b0a0715eb1a3bbf7e17b0f99aa6f3d33df7","segment_id":"environment.md:3f52403cd330847b","source_path":"environment.md","text_hash":"3f52403cd330847bbe6aabe3d447592616cdc1a8efcbc1f48fb6643f8384fe96","text":"Precedence (highest →","translated":"优先级(最高 →","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:45:28Z"} -{"cache_key":"f621705327b389ad82a822a75b8c7ca9f3373484abe4c0fa698439958d39456d","segment_id":"environment.md:6f59001999ef7b71","source_path":"environment.md","text_hash":"6f59001999ef7b7128bab80d2034c419f3034497e05f69fbdf67f7b655cdc173","text":"Configuration: Env var substitution","translated":"配置:环境变量 替换","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:58:25Z"} -{"cache_key":"f621dff6a1a64fd61fe1f234bee676aeae91455321dcee4f6e67091184df6c62","segment_id":"start/wizard.md:66d0f523a379b2de","source_path":"start/wizard.md","text_hash":"66d0f523a379b2de6f8d5fba3a817ebc395f7bcaa54cc132ca9dfa665d1e9378","text":"Skills","translated":"技能","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:49:18Z"} -{"cache_key":"f63fecf5eae55dcc313461e84c71dff7e4c62437c912b31e37160ab24e814c22","segment_id":"index.md:9dea37e7f1ff0e24","source_path":"index.md","text_hash":"9dea37e7f1ff0e24f7daecf6ea9cc38a58194f11fbeab1d3cfaa3a5645099ef4","text":"Updating / rollback","translated":"更新 / 回滚","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:52:31Z"} -{"cache_key":"f6b24d421bb819dd74d316c3be99e4848a1b48cd29aa83b5955b323ccf7a6c71","segment_id":"help/index.md:d3ef01b4a9c99103","source_path":"help/index.md","text_hash":"d3ef01b4a9c9910364c9b26b2499c8787a0461d2d24ab80376fff736a288b34c","text":"Logging","translated":"日志记录","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:56:33Z"} -{"cache_key":"f6bca6b4934d23476401fd77c2d68803d43a4cc7147a31663887d519bebad085","segment_id":"index.md:7af023c43013b9a5","source_path":"index.md","text_hash":"7af023c43013b9a53fbff7dd4b5821588bba3319308878229740489152c43f6d","text":"Docs","translated":"文档","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:31:46Z"} -{"cache_key":"f6bf8734b049080c670e9161d3f62cff12800947ad422096af488dda32c63f66","segment_id":"index.md:5eeecff4ba2df15c","source_path":"index.md","text_hash":"5eeecff4ba2df15c51bcc1ba70a5a2198fbcac141ebe047a2db7acf0e1e83450","text":" — Local UI + menu bar companion for ops and voice wake","translated":" — 本地界面 + 菜单栏辅助工具,支持操作和语音唤醒","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:30:54Z"} -{"cache_key":"f6cb43180d1cb38f88fcf0a8d2c978f67c90b54bde664ec85ac14abce14c1b83","segment_id":"help/index.md:8ddb7fc8a87904de","source_path":"help/index.md","text_hash":"8ddb7fc8a87904dedc2afc16400fbe4e78582b302e01c30b1319c8a465d04684","text":"Troubleshooting:","translated":"故障排除:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:39:35Z"} -{"cache_key":"f6f420edf7e69a495fa2341fbcbfcb89f4edd0193ad98bca1bf5bd34822e6914","segment_id":"index.md:316cd41f595f3095","source_path":"index.md","text_hash":"316cd41f595f3095f149f98af70f77ab85404307a1505467ee45a26b316a9984","text":"Guided setup (recommended):","translated":"引导式设置(推荐):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:59:10Z"} -{"cache_key":"f7109a2845e6fbe35c8bdf279b2c337808867d39dd637a5c7d9b2a1b91018916","segment_id":"start/getting-started.md:d48b35a5fde42ec0","source_path":"start/getting-started.md","text_hash":"d48b35a5fde42ec00cf04a49d5ddeb555c65a520eeb97108da303bc05673dc84","text":"WhatsApp doc: ","translated":"WhatsApp 文档: ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:37:12Z"} -{"cache_key":"f722cbdc201f4b5e079dd175c0f52bce3bf3aa1658174683d7b51d71a4e9cd84","segment_id":"index.md:6b8ebac7903757ce","source_path":"index.md","text_hash":"6b8ebac7903757ce7399cc729651a27e459903c24c64aa94827b20d8a2a411d2","text":"For Tailnet access, run ","translated":"如需 Tailnet 访问,请运行 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:28:53Z"} -{"cache_key":"f727381238c5d317e8cd685354a48f793bc0d76af5f89de378ced4f0307c043d","segment_id":"start/wizard.md:3dd83b614e806664","source_path":"start/wizard.md","text_hash":"3dd83b614e8066647eed34747cca7bd8ecd848f994ab0e1870611515a0947051","text":"macOS: Bonjour (","translated":"macOS:Bonjour(","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:46:36Z"} -{"cache_key":"f75c83d90f9118aeb4862c47a07a5896f5da054fa28cebd9a9770f2bd5fcbe1c","segment_id":"start/wizard.md:e7ac0786668e0ff0","source_path":"start/wizard.md","text_hash":"e7ac0786668e0ff0f02b62bd04f45ff636fd82db63b1104601c975dc005f3a67","text":":","translated":":","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:48:30Z"} -{"cache_key":"f76e7b041b6273a09aa1e9309c09963be833cac5d00695ee47013a664b4d68d7","segment_id":"help/index.md:frontmatter:read_when:0","source_path":"help/index.md:frontmatter:read_when:0","text_hash":"ee0615553374970664b58ebd8e5d0ebc9bc8a5f03387671afbfd0096b390aa9b","text":"You’re new and want the “what do I click/run” guide","translated":"你是新手,想要一份\"我该点击/运行什么\"的指南","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:18:57Z"} -{"cache_key":"f794b56056508717fd48cd6db6dc75a458a0fa23834757f5ab7a0993982c6594","segment_id":"environment.md:496aca80e4d8f29f","source_path":"environment.md","text_hash":"496aca80e4d8f29fb8e8cd816c3afb48d3f103970b3a2ee1600c08ca67326dee","text":" block","translated":" 块","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:45:59Z"} -{"cache_key":"f80879a2302c298e8c95d914d9d6c71f03acd6f6dd6f974af01bfc0bc6c2e1c5","segment_id":"start/wizard.md:b90faf89583190c7","source_path":"start/wizard.md","text_hash":"b90faf89583190c7e34f7f5da172378019ea35b5da533c04dd2f7eec4c22eb9b","text":"Add another agent","translated":"添加另一个智能体","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:46:43Z"} -{"cache_key":"f8ba17c2741fd5744982e25324fa40baf96c8bc58d317be0648263b55a430f7e","segment_id":"index.md:76d6f9c532961885","source_path":"index.md","text_hash":"76d6f9c5329618856f133dc695e78f085545ae05fae74228fb1135cba7009fca","text":") — Pi creator, security pen-tester","translated":")—— Pi 创建者,安全渗透测试员","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:54:30Z"} -{"cache_key":"f9105824cf6a7d20518e37b8bf0823c644d1c0f6ce291e122a94e6e6470b7533","segment_id":"index.md:898e28d91a14b400","source_path":"index.md","text_hash":"898e28d91a14b400e7dc11f9dc861afe9143c18bf9424b1d1b274841615f38b1","text":"If you want to lock it down, start with ","translated":"如果您想进行锁定配置,请从以下内容开始 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:52:00Z"} -{"cache_key":"f91d9117f2bb9b64cf66ea1411b0be3f171f40e08c8c9e9f26c55c7e8bfe7189","segment_id":"environment.md:6863067eb0a2c749","source_path":"environment.md","text_hash":"6863067eb0a2c7499425c6c189b2c88bac55ca754285a6ab1ef37b75b4cfad4d","text":"See ","translated":"参见 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:26:28Z"} -{"cache_key":"f94a81f9b0bf40ffe1357c455d8aa1521caf2e5b7567514ceebb6cddac71ed20","segment_id":"start/wizard.md:812ae9cc61bc8004","source_path":"start/wizard.md","text_hash":"812ae9cc61bc800431e08012a3e2dedf0f928f6f5d1266663f3f9c9009a33865","text":"What the wizard writes","translated":"向导写入的内容","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:48:22Z"} -{"cache_key":"f9d9e2053d57e1dbcea8393af82dbd0d30bed4822f1d89bfe03c7cfadb02ecd7","segment_id":"environment.md:8d076464a84995bc","source_path":"environment.md","text_hash":"8d076464a84995bc095e934b0aa1e4419372f27cd71d033571e4dbba201ee5d8","text":"You can reference env vars directly in config string values using ","translated":"你可以在配置的字符串值中直接引用环境变量,使用 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:26:23Z"} -{"cache_key":"f9f5b27505056942f667c21acc05200a9acbbdcb3fddaceca9d2a30e2dbe81a9","segment_id":"index.md:b214cd10585678ca","source_path":"index.md","text_hash":"b214cd10585678ca1250ce1ae1a50ad4001de4577a10e36be396a3409314e442","text":"@badlogicc","translated":"@badlogicc","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:33:38Z"} -{"cache_key":"fa024aedd372ab7765061298a10db13f4e5bcdc6133bc25a65c53f8236557315","segment_id":"environment.md:907940a35852447a","source_path":"environment.md","text_hash":"907940a35852447aad5f21c5a180d993ff31cfd5807b1352ed0c24eabe183465","text":"never override existing values","translated":"永远不要覆盖现有值","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T11:45:24Z"} -{"cache_key":"fa3713ea436d20ec73664c073e488b38fc0bb3809eaa3ac4dc08811132bee115","segment_id":"index.md:5afbb1c887f6d850","source_path":"index.md","text_hash":"5afbb1c887f6d8501dba36cd2113d8f8b6ce6fa711a0d3e7efdc66f170abd2c2","text":"Cron jobs","translated":"定时任务","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:33:06Z"} -{"cache_key":"fa78bcdd35b740179d777f1399ca259d74e49151d5fe68ebcb2e8e073e5cacbd","segment_id":"environment.md:582967534d0f909d","source_path":"environment.md","text_hash":"582967534d0f909d196b97f9e6921342777aea87b46fa52df165389db1fb8ccf","text":" in ","translated":" 在 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:25:51Z"} -{"cache_key":"fa7eadfbeb6089c235d526f5463bcba6bd1d0ab30fbc4eff7f170e3e03fb83be","segment_id":"help/index.md:5c94724fa7810fa9","source_path":"help/index.md","text_hash":"5c94724fa7810fa9902e565cf66c5f5a973074f2961fcd3a40bad4ee4aeca5e0","text":"If you want a quick “get unstuck” flow, start here:","translated":"如果你想快速排障,请从这里开始:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:11:09Z"} -{"cache_key":"fa8390ce00f9c591f6fb7e0d5d4753ca5f421b96668f90965f884e53f15ff87c","segment_id":"index.md:185beb968bd1a81d","source_path":"index.md","text_hash":"185beb968bd1a81d07ebcf82376642f7b29f1b5594b21fe9edee714efbdcaa44","text":"✈️ ","translated":"✈️ ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:49:31Z"} -{"cache_key":"fa9908d4e7381bb3cc4d9ce5dd90158a06ebae51ae44d2b138bc9191e25abc34","segment_id":"start/wizard.md:4c8906cf76f5740a","source_path":"start/wizard.md","text_hash":"4c8906cf76f5740ab8792aef9f0033fe21a92045e90b357816064e9f6860a03e","text":"Channels","translated":"渠道","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:44:11Z"} -{"cache_key":"faae749a1e3720731bd89450cc30ca39d65ca2d3968ac048373c3f6ba5087381","segment_id":"start/getting-started.md:9c7c1a1750d380e8","source_path":"start/getting-started.md","text_hash":"9c7c1a1750d380e8b4f5329437dd3e6066f20891e74af700595ddf8a5eac42a3","text":"Bun warning (WhatsApp + Telegram):","translated":"Bun 警告(WhatsApp + Telegram):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:37:01Z"} -{"cache_key":"fab1c40ef11182f7118f5528b5ba6ed5b5c169c37b302382107e3fbab3d200c1","segment_id":"index.md:3d8fed7c358b2ccf","source_path":"index.md","text_hash":"3d8fed7c358b2ccf225ee16857a0bb9b950fd414319749e0f6fff58c99fa5f22","text":"Subscription auth","translated":"订阅认证","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:50:25Z"} -{"cache_key":"fae191ae8b8380df30a34afd63fc9ba9125258cee9f76e625da9a9c41a858973","segment_id":"start/wizard.md:158ac20b77d1dc12","source_path":"start/wizard.md","text_hash":"158ac20b77d1dc1223a47723e75f03b49fe61d0a6d69de4c3bba9fdd4c123c04","text":" only configures the local client to connect to a Gateway elsewhere.\nIt does ","translated":" 仅配置本地客户端以连接到其他位置的 Gateway。它 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:40:36Z"} -{"cache_key":"faf6394b29b7de4f1af4a5c01405a2c33d4a1f8f58691915d75eedd3572b1d49","segment_id":"index.md:a7a19d4f14d001a5","source_path":"index.md","text_hash":"a7a19d4f14d001a56c27f68a13ff267859a407c7a9ab457c0945693c9067dd1c","text":"Configuration (optional)","translated":"配置(可选)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:03:21Z"} -{"cache_key":"fc41f7c0ff1d82b20353a8a79f2da756675af014a48e1c36b3e693e2030aca4c","segment_id":"help/index.md:6201111b83a0cb5b","source_path":"help/index.md","text_hash":"6201111b83a0cb5b0922cb37cc442b9a40e24e3b1ce100a4bb204f4c63fd2ac0","text":" and ","translated":" 和 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:39:50Z"} -{"cache_key":"fc43ec1fbbcff82d8d617e73687d1fa0c004b3fa731fdb6c9a1b0825ac2df2f5","segment_id":"start/wizard.md:d80c4025fe9728d6","source_path":"start/wizard.md","text_hash":"d80c4025fe9728d67b8330bdbb25a3062c7748ae6779d348b66687d5a796550f","text":"Gateway wizard RPC","translated":"Gateway 向导 RPC","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:47:41Z"} -{"cache_key":"fc503e5044847f8c5412b75ba55ec912df5577a3bc37a7a975393684059d9c12","segment_id":"environment.md:61115f6649792387","source_path":"environment.md","text_hash":"61115f664979238731a390e84433a818965b7eaf1d38fa5b4b1507c33ef28c91","text":"Precedence (highest → lowest)","translated":"优先级(从高到低)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:16:00Z"} -{"cache_key":"fc5a2a3c595c777506fa783ae7fdb46154bc1a9d2990062d2816de3f42b4a5a4","segment_id":"index.md:c011d6097bfbc8e9","source_path":"index.md","text_hash":"c011d6097bfbc8e936280addcf2e3e7d06ea2223ffd596973191b800a7035c32","text":"License","translated":"许可证","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:06:48Z"} -{"cache_key":"fc7b6106c6fe0ee6f9470690d4557420fe96c6bf88d32572c1c6bcebeeca0ba5","segment_id":"index.md:1e37e607483201e2","source_path":"index.md","text_hash":"1e37e607483201e2152d2e9c68874dd4027648efdd9cfccb7bf8c9837398d143","text":"), serving ","translated":"),提供 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:00:27Z"} -{"cache_key":"fc98ca0f83f0fb76119a9483b4e7cf04bba735dc5c4bac23b5fea356315322a6","segment_id":"start/wizard.md:78db1bd89a6a2b1c","source_path":"start/wizard.md","text_hash":"78db1bd89a6a2b1cfa5c7af25c03cdd0aaef049910f8532b3440fdf3e5d41759","text":"May prompt for sudo (writes ","translated":"可能会提示输入 sudo(写入 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:45:24Z"} -{"cache_key":"fca2c0b7fa4c88f595ccb62204b07c5d014cb1f1240a39a203bfe37e25fe8c07","segment_id":"index.md:eef0107bb5a4e06b","source_path":"index.md","text_hash":"eef0107bb5a4e06b9de432b9e62bcf1e39ca5dfbbb9cb0cc1c803ca7671c06ab","text":"Gateway runbook","translated":"Gateway 运行手册","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:52:54Z"} -{"cache_key":"fcb8a00898eb27b04a3ced786d117b0d7be079d0f45d8608b8a8fe87ad32f0eb","segment_id":"index.md:82ba9b60b12da3ab","source_path":"index.md","text_hash":"82ba9b60b12da3ab4e7dbcb0d7d937214cff80c82268311423a6dc8c4bc09df5","text":"OpenClaw 🦞","translated":"OpenClaw 🦞","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:47:22Z"} -{"cache_key":"fd042da779d8af0e3f90024d3ee3ed60dc05ed4220b6645c1c7afd148c481918","segment_id":"help/index.md:729bc562eec2658b","source_path":"help/index.md","text_hash":"729bc562eec2658bd11ffdd522fe5277177dc73e86eaca7baac0b472a4d8f8b2","text":"If you’re looking for conceptual questions (not “something broke”):","translated":"如果你在寻找概念性问题的答案(而不是\"出了问题\"):","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:15:36Z"} -{"cache_key":"fd16400d64e6f3b7376b1999211a6ed33688eeb2c9a6fd26ce226094628b2647","segment_id":"help/index.md:d3ef01b4a9c99103","source_path":"help/index.md","text_hash":"d3ef01b4a9c9910364c9b26b2499c8787a0461d2d24ab80376fff736a288b34c","text":"Logging","translated":"日志记录","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:11:22Z"} -{"cache_key":"fd42cd6d27d391746b39a68daf76869aab50130d11563f38793103f97b0cc634","segment_id":"environment.md:b4736422e64c0a36","source_path":"environment.md","text_hash":"b4736422e64c0a369663d1b2d386f1b8f4b31b8936b588e4a54453c61a24e0fd","text":"Process environment","translated":"进程环境","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:40:30Z"} -{"cache_key":"fd81a6834413dec93cb0fa720f94f980ebd8de062a9f03c67f8a5eac7dba177b","segment_id":"start/wizard.md:f9101c545949c8fd","source_path":"start/wizard.md","text_hash":"f9101c545949c8fd264de16e8705ea2867f73b1e72f14ed6701d37169226731b","text":"The onboarding wizard is the ","translated":"上手引导向导是 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:38:59Z"} -{"cache_key":"fdd0251e3da40ed9b7947f5fc52798e46adbdbe32b4687efe40bf1c34c3f8a54","segment_id":"environment.md:45ca56d179d4788c","source_path":"environment.md","text_hash":"45ca56d179d4788c55ba9f7653b376d62e7faa738e92259e3d4f6f5c1b554f28","text":"Related","translated":"相关内容","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:13:09Z"} -{"cache_key":"fe03652b8fbba7658cd3c33e1ecfc88bf7a2a2416727c8de537a1ff4a7d04c63","segment_id":"start/wizard.md:51aa8bdcedfdb0c9","source_path":"start/wizard.md","text_hash":"51aa8bdcedfdb0c9eefbf91a6fa25d78b4c367be285bd472553cc0b461d983c8","text":"OpenAI API key","translated":"OpenAI API 密钥","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:42:26Z"} -{"cache_key":"fe28d810ff498a350b586785445582bed45cf1b1de02ea8be1569cf0da546ecc","segment_id":"index.md:3f8466cd9cb153d0","source_path":"index.md","text_hash":"3f8466cd9cb153d0c78a88f6a209e2206992db28c6dab45424132dc187974e2b","text":"Note: legacy Claude/Codex/Gemini/Opencode paths have been removed; Pi is the only coding-agent path.","translated":"注意:旧版 Claude/Codex/Gemini/Opencode 路径已被移除;Pi 是唯一的编程 智能体 路径。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:51:20Z"} -{"cache_key":"fe4dd967a44b8e8082aa5b2441ea4e4fc4478e2e370087cf666830f23b215d1c","segment_id":"index.md:74f99190ef66a7d5","source_path":"index.md","text_hash":"74f99190ef66a7d513049d31bafc76e05f9703f3320bf757fb2693447a48c25b","text":"Linux app","translated":"Linux 应用","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:05:16Z"} -{"cache_key":"fe554549a7c67caf1f51ae69b2d4bdb126cc0bfeb0963610e8b0be605fb058e3","segment_id":"start/wizard.md:87bb59ba2f92f2a5","source_path":"start/wizard.md","text_hash":"87bb59ba2f92f2a5a9f13e021fd58dd14ae5c065b1046146875e6e68d5ebc8b7","text":"Workspace","translated":"工作区","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:43:38Z"} -{"cache_key":"fe81eef1d52c47b26c55cb74fd8c6fe31a5c648213d3dcf3de567d8125f222fd","segment_id":"index.md:11450a0f023dc48c","source_path":"index.md","text_hash":"11450a0f023dc48cc9cef026357e2b4569a2b756290191c45a9eb0120a919cb7","text":" and (for groups) mention rules.","translated":" 以及(针对群组的)提及规则。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:52:03Z"} -{"cache_key":"fe9fff29a8a3a18b8ba8f7493dc3331ffb90c4585bcdc2a3c03e402202f786ae","segment_id":"start/wizard.md:2f6975ca07f6b950","source_path":"start/wizard.md","text_hash":"2f6975ca07f6b95055db357fed97ef04d04d7ac57351e48bd69e0a0675ac47b1","text":"OpenCode Zen (multi-model proxy)","translated":"OpenCode Zen(多模型代理)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:42:32Z"} -{"cache_key":"fecfa9809bf3844fdc62208030ad2364304c83d2c2a278f691a06fe1d95eef29","segment_id":"environment.md:61115f6649792387","source_path":"environment.md","text_hash":"61115f664979238731a390e84433a818965b7eaf1d38fa5b4b1507c33ef28c91","text":"Precedence (highest → lowest)","translated":"优先级(从高到低)","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:57:18Z"} -{"cache_key":"fed9ca0b4a8c8162f989410401afbb3038f19d1060104d1804a5bacb2af45013","segment_id":"start/wizard.md:f7952490362d43d3","source_path":"start/wizard.md","text_hash":"f7952490362d43d362bce1e931f3e707e6b39369e9182fae26b54f677f778145","text":"If no GUI is detected, the wizard prints SSH port-forward instructions for the Control UI instead of opening a browser.","translated":"如果未检测到 GUI,向导会打印 Control UI 的 SSH 端口转发说明,而不是打开浏览器。","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:46:04Z"} -{"cache_key":"ff0818747bde0777bfd88d234d27b7ccd9e866cb8d477f1c022943f425735631","segment_id":"environment.md:frontmatter:read_when:0","source_path":"environment.md:frontmatter:read_when:0","text_hash":"90fc0487bff88009979cff1061c1a882df8c3b1baa9c43538331d9d5dab15479","text":"You need to know which env vars are loaded, and in what order","translated":"您需要了解哪些 环境变量 被加载,以及加载顺序","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:57:01Z"} -{"cache_key":"ff35a70223602ebd4e2ccb376f9a05a23436de50c0661a69a6c189e54386369c","segment_id":"environment.md:907940a35852447a","source_path":"environment.md","text_hash":"907940a35852447aad5f21c5a180d993ff31cfd5807b1352ed0c24eabe183465","text":"never override existing values","translated":"永远不覆盖已有的值","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:57:13Z"} -{"cache_key":"ff4870ed3d31dd15db9a3753847994b892bfbbcd169eaf654fa2a9347de1b80a","segment_id":"index.md:053bc65874ad6098","source_path":"index.md","text_hash":"053bc65874ad6098e58c41c57b378a2f36b0220e5e0b46722245e6c2f796818c","text":"Discord","translated":"Discord","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:32:38Z"} -{"cache_key":"ff9cd1150279b1783fc13d1d8deb389b0589027719aa184d39812dab44ad30c3","segment_id":"index.md:075a4a45c3999f34","source_path":"index.md","text_hash":"075a4a45c3999f340be8487cd7c0dd2ed77ced931054d75e95e5e24d5539b45b","text":" — Pi (RPC mode) with tool streaming","translated":" — Pi(RPC 模式)配合 工具 流式传输","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:01:29Z"} -{"cache_key":"ffc0edaae36968ae44b65f6baba8cef750ebcff415a26c7bbda8f59ed632b548","segment_id":"index.md:872887e563e75957","source_path":"index.md","text_hash":"872887e563e75957ffc20b021332504f2ddd0a8f3964cb93070863bfaf13cdad","text":"Example:","translated":"示例:","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T12:52:06Z"} -{"cache_key":"ffd04ac4efed00f848ef0d6f549a5e3f7237a0942d8d18a0ace2751a1f044099","segment_id":"index.md:0c67abfaa5415391","source_path":"index.md","text_hash":"0c67abfaa5415391a31cf3a4624746b6b212b5ae66364be28ee2d131f014e0c6","text":"🧩 ","translated":"🧩 ","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:01:09Z"} -{"cache_key":"ffd193a2ab6714302a69cbe3b1bc24f881807a3a8ce88558687554509a4c1c1c","segment_id":"index.md:7e2735e5df8f4e9f","source_path":"index.md","text_hash":"7e2735e5df8f4e9f006d10e079fe8045612aa662b02a9d1948081d1173798dec","text":"MIT — Free as a lobster in the ocean 🦞","translated":"MIT — 像大海中的龙虾一样自由 🦞","provider":"pi","model":"claude-opus-4-5","src_lang":"en","tgt_lang":"zh-CN","updated_at":"2026-02-01T13:34:07Z"} diff --git a/docs/CNAME b/docs/CNAME deleted file mode 100644 index 715bc9df52a..00000000000 --- a/docs/CNAME +++ /dev/null @@ -1 +0,0 @@ -docs.openclaw.ai diff --git a/docs/assets/install-script.svg b/docs/assets/install-script.svg deleted file mode 100644 index 78a6f975641..00000000000 --- a/docs/assets/install-script.svg +++ /dev/null @@ -1 +0,0 @@ -seb@ubuntu:~$curl-fsSLhttps://openclaw.ai/install.sh|bash╭─────────────────────────────────────────╮🦞OpenClawInstallerBecauseSiriwasn'tansweringat3AM.moderninstallermode╰─────────────────────────────────────────╯gumbootstrapped(temp,verified,v0.17.0)Detected:linuxInstallplanOSlinuxInstallmethodnpmRequestedversionlatest[1/3]PreparingenvironmentINFONode.jsnotfound,installingitnowINFOInstallingNode.jsviaNodeSourceConfiguringNodeSourcerepositoryConfiguringNodeSourcerepositoryConfiguringNodeSourcerepositoryConfiguringNodeSourcerepositoryConfiguringNodeSourcerepositoryConfiguringNodeSourcerepositoryConfiguringNodeSourcerepositoryConfiguringNodeSourcerepositoryInstallingNode.jsInstallingNode.jsInstallingNode.jsInstallingNode.jsInstallingNode.jsInstallingNode.jsInstallingNode.jsInstallingNode.jsNode.jsv22installed[2/3]InstallingOpenClawINFOGitnotfound,installingitnowUpdatingpackageindexInstallingGitInstallingGitInstallingGitInstallingGitInstallingGitInstallingGitInstallingGitInstallingGitGitinstalledINFOConfiguringnpmforuser-localinstallsnpmconfiguredforuserinstallsINFOInstallingOpenClawv2026.2.9InstallingOpenClawpackageInstallingOpenClawpackageInstallingOpenClawpackageInstallingOpenClawpackageInstallingOpenClawpackageInstallingOpenClawpackageInstallingOpenClawpackageInstallingOpenClawpackageOpenClawnpmpackageinstalledOpenClawinstalled[3/3]FinalizingsetupWARNPATHmissingnpmglobalbindir:/home/seb/.npm-global/binThiscanmakeopenclawshowas"commandnotfound"innewterminals.Fix(zsh:~/.zshrc,bash:~/.bashrc):exportPATH="/home/seb/.npm-global/bin:$PATH"🦞OpenClawinstalledsuccessfully(2026.2.9)!Finallyunpacked.Nowpointmeatyourproblems.INFOStartingsetup🦞OpenClaw2026.2.9(33c75cb)Thinkdifferent.Actuallythink.▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄██░▄▄▄░██░▄▄░██░▄▄▄██░▀██░██░▄▄▀██░████░▄▄▀██░███░████░███░██░▀▀░██░▄▄▄██░█░█░██░█████░████░▀▀░██░█░█░████░▀▀▀░██░█████░▀▀▀██░██▄░██░▀▀▄██░▀▀░█░██░██▄▀▄▀▄██▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀🦞OPENCLAW🦞OpenClawonboardingSecurity──────────────────────────────────────────────────────────────────────────────╮Securitywarningpleaseread.OpenClawisahobbyprojectandstillinbeta.Expectsharpedges.Thisbotcanreadfilesandrunactionsiftoolsareenabled.Abadpromptcantrickitintodoingunsafethings.Ifyou’renotcomfortablewithbasicsecurityandaccesscontrol,don’trunOpenClaw.Asksomeoneexperiencedtohelpbeforeenablingtoolsorexposingittotheinternet.Recommendedbaseline:-Pairing/allowlists+mentiongating.-Sandbox+least-privilegetools.-Keepsecretsoutoftheagent’sreachablefilesystem.-Usethestrongestavailablemodelforanybotwithtoolsoruntrustedinboxes.Runregularly:openclawsecurityaudit--deepopenclawsecurityaudit--fixMustread:https://docs.openclaw.ai/gateway/security├─────────────────────────────────────────────────────────────────────────────────────────╯Iunderstandthisispowerfulandinherentlyrisky.Continue?Yes/NoYes/Noseb@ubuntu:~$asciinemaseb@ubuntu:~$asciinemauploadseb@ubuntu:~$asciinemauploaddemo.castseb@ubuntu:~$seb@ubuntu:~$curl -fsSL https://openclaw.ai/install.sh | bashUpdatingpackageindexUpdatingpackageindexUpdatingpackageindexUpdatingpackageindexUpdatingpackageindexUpdatingpackageindexUpdatingpackageindexAbadpromptcantrickitintodoingunsafethings.-Keepsecretsoutoftheagent’sreachablefilesystem.seb@ubuntu:~$seb@ubuntu:~$aseb@ubuntu:~$asseb@ubuntu:~$ascseb@ubuntu:~$asciseb@ubuntu:~$asciiseb@ubuntu:~$asciinseb@ubuntu:~$asciineseb@ubuntu:~$asciinemseb@ubuntu:~$asciinemauseb@ubuntu:~$asciinemaupseb@ubuntu:~$asciinemauplseb@ubuntu:~$asciinemauploseb@ubuntu:~$asciinemauploaseb@ubuntu:~$asciinemauploaddseb@ubuntu:~$asciinemauploaddeseb@ubuntu:~$asciinemauploaddemseb@ubuntu:~$asciinemauploaddemoseb@ubuntu:~$asciinemauploaddemo.seb@ubuntu:~$asciinemauploaddemo.cseb@ubuntu:~$asciinemauploaddemo.caseb@ubuntu:~$asciinemauploaddemo.cas \ No newline at end of file diff --git a/docs/assets/macos-onboarding/01-macos-warning.jpeg b/docs/assets/macos-onboarding/01-macos-warning.jpeg deleted file mode 100644 index 255976fe51f..00000000000 Binary files a/docs/assets/macos-onboarding/01-macos-warning.jpeg and /dev/null differ diff --git a/docs/assets/macos-onboarding/02-local-networks.jpeg b/docs/assets/macos-onboarding/02-local-networks.jpeg deleted file mode 100644 index 0135e38f695..00000000000 Binary files a/docs/assets/macos-onboarding/02-local-networks.jpeg and /dev/null differ diff --git a/docs/assets/macos-onboarding/03-security-notice.png b/docs/assets/macos-onboarding/03-security-notice.png deleted file mode 100644 index ca0dac9684e..00000000000 Binary files a/docs/assets/macos-onboarding/03-security-notice.png and /dev/null differ diff --git a/docs/assets/macos-onboarding/04-choose-gateway.png b/docs/assets/macos-onboarding/04-choose-gateway.png deleted file mode 100644 index 4e0233c22d5..00000000000 Binary files a/docs/assets/macos-onboarding/04-choose-gateway.png and /dev/null differ diff --git a/docs/assets/macos-onboarding/05-permissions.png b/docs/assets/macos-onboarding/05-permissions.png deleted file mode 100644 index 910a5f8daa6..00000000000 Binary files a/docs/assets/macos-onboarding/05-permissions.png and /dev/null differ diff --git a/docs/assets/openclaw-logo-text-dark.png b/docs/assets/openclaw-logo-text-dark.png deleted file mode 100644 index b14e4233b69..00000000000 Binary files a/docs/assets/openclaw-logo-text-dark.png and /dev/null differ diff --git a/docs/assets/openclaw-logo-text.png b/docs/assets/openclaw-logo-text.png deleted file mode 100644 index 705d2c0ba17..00000000000 Binary files a/docs/assets/openclaw-logo-text.png and /dev/null differ diff --git a/docs/assets/pixel-lobster.svg b/docs/assets/pixel-lobster.svg deleted file mode 100644 index 7bfb7fc4d47..00000000000 --- a/docs/assets/pixel-lobster.svg +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/assets/showcase/agents-ui.jpg b/docs/assets/showcase/agents-ui.jpg deleted file mode 100644 index ae6ab6d18da..00000000000 Binary files a/docs/assets/showcase/agents-ui.jpg and /dev/null differ diff --git a/docs/assets/showcase/bambu-cli.png b/docs/assets/showcase/bambu-cli.png deleted file mode 100644 index 046f627ba43..00000000000 Binary files a/docs/assets/showcase/bambu-cli.png and /dev/null differ diff --git a/docs/assets/showcase/codexmonitor.png b/docs/assets/showcase/codexmonitor.png deleted file mode 100644 index 43952b92bd0..00000000000 Binary files a/docs/assets/showcase/codexmonitor.png and /dev/null differ diff --git a/docs/assets/showcase/gohome-grafana.png b/docs/assets/showcase/gohome-grafana.png deleted file mode 100644 index bd7cf077402..00000000000 Binary files a/docs/assets/showcase/gohome-grafana.png and /dev/null differ diff --git a/docs/assets/showcase/ios-testflight.jpg b/docs/assets/showcase/ios-testflight.jpg deleted file mode 100644 index 4e19768f974..00000000000 Binary files a/docs/assets/showcase/ios-testflight.jpg and /dev/null differ diff --git a/docs/assets/showcase/oura-health.png b/docs/assets/showcase/oura-health.png deleted file mode 100644 index b1e9f707248..00000000000 Binary files a/docs/assets/showcase/oura-health.png and /dev/null differ diff --git a/docs/assets/showcase/padel-cli.svg b/docs/assets/showcase/padel-cli.svg deleted file mode 100644 index 61eb6334d36..00000000000 --- a/docs/assets/showcase/padel-cli.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - $ padel search --location "Barcelona" --date 2026-01-08 --time 18:00-22:00 - Available courts (3): - - Vall d'Hebron 19:00 Court 2 (90m) EUR 34 - - Badalona 20:30 Court 1 (60m) EUR 28 - - Gracia 21:00 Court 4 (90m) EUR 36 - - diff --git a/docs/assets/showcase/padel-screenshot.jpg b/docs/assets/showcase/padel-screenshot.jpg deleted file mode 100644 index eb1ae39eaca..00000000000 Binary files a/docs/assets/showcase/padel-screenshot.jpg and /dev/null differ diff --git a/docs/assets/showcase/papla-tts.jpg b/docs/assets/showcase/papla-tts.jpg deleted file mode 100644 index 3e7af38386a..00000000000 Binary files a/docs/assets/showcase/papla-tts.jpg and /dev/null differ diff --git a/docs/assets/showcase/pr-review-telegram.jpg b/docs/assets/showcase/pr-review-telegram.jpg deleted file mode 100644 index 888a413295e..00000000000 Binary files a/docs/assets/showcase/pr-review-telegram.jpg and /dev/null differ diff --git a/docs/assets/showcase/roborock-screenshot.jpg b/docs/assets/showcase/roborock-screenshot.jpg deleted file mode 100644 index e31ba11eb91..00000000000 Binary files a/docs/assets/showcase/roborock-screenshot.jpg and /dev/null differ diff --git a/docs/assets/showcase/roborock-status.svg b/docs/assets/showcase/roborock-status.svg deleted file mode 100644 index 470840423cb..00000000000 --- a/docs/assets/showcase/roborock-status.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - $ gohome roborock status --device "Living Room" - Device: Roborock Q Revo - State: cleaning (zone) - Battery: 78% - Dustbin: 42% - Water tank: 61% - Last clean: 2026-01-06 19:42 - - diff --git a/docs/assets/showcase/roof-camera-sky.jpg b/docs/assets/showcase/roof-camera-sky.jpg deleted file mode 100644 index 3396f1405c0..00000000000 Binary files a/docs/assets/showcase/roof-camera-sky.jpg and /dev/null differ diff --git a/docs/assets/showcase/snag.png b/docs/assets/showcase/snag.png deleted file mode 100644 index c82c47acfcc..00000000000 Binary files a/docs/assets/showcase/snag.png and /dev/null differ diff --git a/docs/assets/showcase/tesco-shop.jpg b/docs/assets/showcase/tesco-shop.jpg deleted file mode 100644 index 66af85d3c15..00000000000 Binary files a/docs/assets/showcase/tesco-shop.jpg and /dev/null differ diff --git a/docs/assets/showcase/wienerlinien.png b/docs/assets/showcase/wienerlinien.png deleted file mode 100644 index 8bdf5ae66a8..00000000000 Binary files a/docs/assets/showcase/wienerlinien.png and /dev/null differ diff --git a/docs/assets/showcase/wine-cellar-skill.jpg b/docs/assets/showcase/wine-cellar-skill.jpg deleted file mode 100644 index 7cd2016cf09..00000000000 Binary files a/docs/assets/showcase/wine-cellar-skill.jpg and /dev/null differ diff --git a/docs/assets/showcase/winix-air-purifier.jpg b/docs/assets/showcase/winix-air-purifier.jpg deleted file mode 100644 index c8b99540c2c..00000000000 Binary files a/docs/assets/showcase/winix-air-purifier.jpg and /dev/null differ diff --git a/docs/assets/showcase/xuezh-pronunciation.jpeg b/docs/assets/showcase/xuezh-pronunciation.jpeg deleted file mode 100644 index 7f7d86a8fa0..00000000000 Binary files a/docs/assets/showcase/xuezh-pronunciation.jpeg and /dev/null differ diff --git a/docs/assets/sponsors/blacksmith.svg b/docs/assets/sponsors/blacksmith.svg deleted file mode 100644 index 5bb1bc2e72c..00000000000 --- a/docs/assets/sponsors/blacksmith.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/docs/assets/sponsors/openai.svg b/docs/assets/sponsors/openai.svg deleted file mode 100644 index 1c3491b9be9..00000000000 --- a/docs/assets/sponsors/openai.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/docs/automation/auth-monitoring.md b/docs/automation/auth-monitoring.md deleted file mode 100644 index 877a1c2ce21..00000000000 --- a/docs/automation/auth-monitoring.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -summary: "Monitor OAuth expiry for model providers" -read_when: - - Setting up auth expiry monitoring or alerts - - Automating Claude Code / Codex OAuth refresh checks -title: "Auth Monitoring" ---- - -# Auth monitoring - -OpenClaw exposes OAuth expiry health via `openclaw models status`. Use that for -automation and alerting; scripts are optional extras for phone workflows. - -## Preferred: CLI check (portable) - -```bash -openclaw models status --check -``` - -Exit codes: - -- `0`: OK -- `1`: expired or missing credentials -- `2`: expiring soon (within 24h) - -This works in cron/systemd and requires no extra scripts. - -## Optional scripts (ops / phone workflows) - -These live under `scripts/` and are **optional**. They assume SSH access to the -gateway host and are tuned for systemd + Termux. - -- `scripts/claude-auth-status.sh` now uses `openclaw models status --json` as the - source of truth (falling back to direct file reads if the CLI is unavailable), - so keep `openclaw` on `PATH` for timers. -- `scripts/auth-monitor.sh`: cron/systemd timer target; sends alerts (ntfy or phone). -- `scripts/systemd/openclaw-auth-monitor.{service,timer}`: systemd user timer. -- `scripts/claude-auth-status.sh`: Claude Code + OpenClaw auth checker (full/json/simple). -- `scripts/mobile-reauth.sh`: guided re‑auth flow over SSH. -- `scripts/termux-quick-auth.sh`: one‑tap widget status + open auth URL. -- `scripts/termux-auth-widget.sh`: full guided widget flow. -- `scripts/termux-sync-widget.sh`: sync Claude Code creds → OpenClaw. - -If you don’t need phone automation or systemd timers, skip these scripts. diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md deleted file mode 100644 index aae5f58fdf2..00000000000 --- a/docs/automation/cron-jobs.md +++ /dev/null @@ -1,542 +0,0 @@ ---- -summary: "Cron jobs + wakeups for the Gateway scheduler" -read_when: - - Scheduling background jobs or wakeups - - Wiring automation that should run with or alongside heartbeats - - Deciding between heartbeat and cron for scheduled tasks -title: "Cron Jobs" ---- - -# Cron jobs (Gateway scheduler) - -> **Cron vs Heartbeat?** See [Cron vs Heartbeat](/automation/cron-vs-heartbeat) for guidance on when to use each. - -Cron is the Gateway’s built-in scheduler. It persists jobs, wakes the agent at -the right time, and can optionally deliver output back to a chat. - -If you want _“run this every morning”_ or _“poke the agent in 20 minutes”_, -cron is the mechanism. - -Troubleshooting: [/automation/troubleshooting](/automation/troubleshooting) - -## TL;DR - -- Cron runs **inside the Gateway** (not inside the model). -- Jobs persist under `~/.openclaw/cron/` so restarts don’t lose schedules. -- Two execution styles: - - **Main session**: enqueue a system event, then run on the next heartbeat. - - **Isolated**: run a dedicated agent turn in `cron:`, with delivery (announce by default or none). -- Wakeups are first-class: a job can request “wake now” vs “next heartbeat”. -- Webhook posting is per job via `delivery.mode = "webhook"` + `delivery.to = ""`. -- Legacy fallback remains for stored jobs with `notify: true` when `cron.webhook` is set, migrate those jobs to webhook delivery mode. - -## Quick start (actionable) - -Create a one-shot reminder, verify it exists, and run it immediately: - -```bash -openclaw cron add \ - --name "Reminder" \ - --at "2026-02-01T16:00:00Z" \ - --session main \ - --system-event "Reminder: check the cron docs draft" \ - --wake now \ - --delete-after-run - -openclaw cron list -openclaw cron run -openclaw cron runs --id -``` - -Schedule a recurring isolated job with delivery: - -```bash -openclaw cron add \ - --name "Morning brief" \ - --cron "0 7 * * *" \ - --tz "America/Los_Angeles" \ - --session isolated \ - --message "Summarize overnight updates." \ - --announce \ - --channel slack \ - --to "channel:C1234567890" -``` - -## Tool-call equivalents (Gateway cron tool) - -For the canonical JSON shapes and examples, see [JSON schema for tool calls](/automation/cron-jobs#json-schema-for-tool-calls). - -## Where cron jobs are stored - -Cron jobs are persisted on the Gateway host at `~/.openclaw/cron/jobs.json` by default. -The Gateway loads the file into memory and writes it back on changes, so manual edits -are only safe when the Gateway is stopped. Prefer `openclaw cron add/edit` or the cron -tool call API for changes. - -## Beginner-friendly overview - -Think of a cron job as: **when** to run + **what** to do. - -1. **Choose a schedule** - - One-shot reminder → `schedule.kind = "at"` (CLI: `--at`) - - Repeating job → `schedule.kind = "every"` or `schedule.kind = "cron"` - - If your ISO timestamp omits a timezone, it is treated as **UTC**. - -2. **Choose where it runs** - - `sessionTarget: "main"` → run during the next heartbeat with main context. - - `sessionTarget: "isolated"` → run a dedicated agent turn in `cron:`. - -3. **Choose the payload** - - Main session → `payload.kind = "systemEvent"` - - Isolated session → `payload.kind = "agentTurn"` - -Optional: one-shot jobs (`schedule.kind = "at"`) delete after success by default. Set -`deleteAfterRun: false` to keep them (they will disable after success). - -## Concepts - -### Jobs - -A cron job is a stored record with: - -- a **schedule** (when it should run), -- a **payload** (what it should do), -- optional **delivery mode** (`announce`, `webhook`, or `none`). -- optional **agent binding** (`agentId`): run the job under a specific agent; if - missing or unknown, the gateway falls back to the default agent. - -Jobs are identified by a stable `jobId` (used by CLI/Gateway APIs). -In agent tool calls, `jobId` is canonical; legacy `id` is accepted for compatibility. -One-shot jobs auto-delete after success by default; set `deleteAfterRun: false` to keep them. - -### Schedules - -Cron supports three schedule kinds: - -- `at`: one-shot timestamp via `schedule.at` (ISO 8601). -- `every`: fixed interval (ms). -- `cron`: 5-field cron expression (or 6-field with seconds) with optional IANA timezone. - -Cron expressions use `croner`. If a timezone is omitted, the Gateway host’s -local timezone is used. - -To reduce top-of-hour load spikes across many gateways, OpenClaw applies a -deterministic per-job stagger window of up to 5 minutes for recurring -top-of-hour expressions (for example `0 * * * *`, `0 */2 * * *`). Fixed-hour -expressions such as `0 7 * * *` remain exact. - -For any cron schedule, you can set an explicit stagger window with `schedule.staggerMs` -(`0` keeps exact timing). CLI shortcuts: - -- `--stagger 30s` (or `1m`, `5m`) to set an explicit stagger window. -- `--exact` to force `staggerMs = 0`. - -### Main vs isolated execution - -#### Main session jobs (system events) - -Main jobs enqueue a system event and optionally wake the heartbeat runner. -They must use `payload.kind = "systemEvent"`. - -- `wakeMode: "now"` (default): event triggers an immediate heartbeat run. -- `wakeMode: "next-heartbeat"`: event waits for the next scheduled heartbeat. - -This is the best fit when you want the normal heartbeat prompt + main-session context. -See [Heartbeat](/gateway/heartbeat). - -#### Isolated jobs (dedicated cron sessions) - -Isolated jobs run a dedicated agent turn in session `cron:`. - -Key behaviors: - -- Prompt is prefixed with `[cron: ]` for traceability. -- Each run starts a **fresh session id** (no prior conversation carry-over). -- Default behavior: if `delivery` is omitted, isolated jobs announce a summary (`delivery.mode = "announce"`). -- `delivery.mode` chooses what happens: - - `announce`: deliver a summary to the target channel and post a brief summary to the main session. - - `webhook`: POST the finished event payload to `delivery.to` when the finished event includes a summary. - - `none`: internal only (no delivery, no main-session summary). -- `wakeMode` controls when the main-session summary posts: - - `now`: immediate heartbeat. - - `next-heartbeat`: waits for the next scheduled heartbeat. - -Use isolated jobs for noisy, frequent, or "background chores" that shouldn't spam -your main chat history. - -### Payload shapes (what runs) - -Two payload kinds are supported: - -- `systemEvent`: main-session only, routed through the heartbeat prompt. -- `agentTurn`: isolated-session only, runs a dedicated agent turn. - -Common `agentTurn` fields: - -- `message`: required text prompt. -- `model` / `thinking`: optional overrides (see below). -- `timeoutSeconds`: optional timeout override. - -Delivery config: - -- `delivery.mode`: `none` | `announce` | `webhook`. -- `delivery.channel`: `last` or a specific channel. -- `delivery.to`: channel-specific target (announce) or webhook URL (webhook mode). -- `delivery.bestEffort`: avoid failing the job if announce delivery fails. - -Announce delivery suppresses messaging tool sends for the run; use `delivery.channel`/`delivery.to` -to target the chat instead. When `delivery.mode = "none"`, no summary is posted to the main session. - -If `delivery` is omitted for isolated jobs, OpenClaw defaults to `announce`. - -#### Announce delivery flow - -When `delivery.mode = "announce"`, cron delivers directly via the outbound channel adapters. -The main agent is not spun up to craft or forward the message. - -Behavior details: - -- Content: delivery uses the isolated run's outbound payloads (text/media) with normal chunking and - channel formatting. -- Heartbeat-only responses (`HEARTBEAT_OK` with no real content) are not delivered. -- If the isolated run already sent a message to the same target via the message tool, delivery is - skipped to avoid duplicates. -- Missing or invalid delivery targets fail the job unless `delivery.bestEffort = true`. -- A short summary is posted to the main session only when `delivery.mode = "announce"`. -- The main-session summary respects `wakeMode`: `now` triggers an immediate heartbeat and - `next-heartbeat` waits for the next scheduled heartbeat. - -#### Webhook delivery flow - -When `delivery.mode = "webhook"`, cron posts the finished event payload to `delivery.to` when the finished event includes a summary. - -Behavior details: - -- The endpoint must be a valid HTTP(S) URL. -- No channel delivery is attempted in webhook mode. -- No main-session summary is posted in webhook mode. -- If `cron.webhookToken` is set, auth header is `Authorization: Bearer `. -- Deprecated fallback: stored legacy jobs with `notify: true` still post to `cron.webhook` (if configured), with a warning so you can migrate to `delivery.mode = "webhook"`. - -### Model and thinking overrides - -Isolated jobs (`agentTurn`) can override the model and thinking level: - -- `model`: Provider/model string (e.g., `anthropic/claude-sonnet-4-20250514`) or alias (e.g., `opus`) -- `thinking`: Thinking level (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`; GPT-5.2 + Codex models only) - -Note: You can set `model` on main-session jobs too, but it changes the shared main -session model. We recommend model overrides only for isolated jobs to avoid -unexpected context shifts. - -Resolution priority: - -1. Job payload override (highest) -2. Hook-specific defaults (e.g., `hooks.gmail.model`) -3. Agent config default - -### Delivery (channel + target) - -Isolated jobs can deliver output to a channel via the top-level `delivery` config: - -- `delivery.mode`: `announce` (channel delivery), `webhook` (HTTP POST), or `none`. -- `delivery.channel`: `whatsapp` / `telegram` / `discord` / `slack` / `mattermost` (plugin) / `signal` / `imessage` / `last`. -- `delivery.to`: channel-specific recipient target. - -`announce` delivery is only valid for isolated jobs (`sessionTarget: "isolated"`). -`webhook` delivery is valid for both main and isolated jobs. - -If `delivery.channel` or `delivery.to` is omitted, cron can fall back to the main session’s -“last route” (the last place the agent replied). - -Target format reminders: - -- Slack/Discord/Mattermost (plugin) targets should use explicit prefixes (e.g. `channel:`, `user:`) to avoid ambiguity. -- Telegram topics should use the `:topic:` form (see below). - -#### Telegram delivery targets (topics / forum threads) - -Telegram supports forum topics via `message_thread_id`. For cron delivery, you can encode -the topic/thread into the `to` field: - -- `-1001234567890` (chat id only) -- `-1001234567890:topic:123` (preferred: explicit topic marker) -- `-1001234567890:123` (shorthand: numeric suffix) - -Prefixed targets like `telegram:...` / `telegram:group:...` are also accepted: - -- `telegram:group:-1001234567890:topic:123` - -## JSON schema for tool calls - -Use these shapes when calling Gateway `cron.*` tools directly (agent tool calls or RPC). -CLI flags accept human durations like `20m`, but tool calls should use an ISO 8601 string -for `schedule.at` and milliseconds for `schedule.everyMs`. - -### cron.add params - -One-shot, main session job (system event): - -```json -{ - "name": "Reminder", - "schedule": { "kind": "at", "at": "2026-02-01T16:00:00Z" }, - "sessionTarget": "main", - "wakeMode": "now", - "payload": { "kind": "systemEvent", "text": "Reminder text" }, - "deleteAfterRun": true -} -``` - -Recurring, isolated job with delivery: - -```json -{ - "name": "Morning brief", - "schedule": { "kind": "cron", "expr": "0 7 * * *", "tz": "America/Los_Angeles" }, - "sessionTarget": "isolated", - "wakeMode": "next-heartbeat", - "payload": { - "kind": "agentTurn", - "message": "Summarize overnight updates." - }, - "delivery": { - "mode": "announce", - "channel": "slack", - "to": "channel:C1234567890", - "bestEffort": true - } -} -``` - -Notes: - -- `schedule.kind`: `at` (`at`), `every` (`everyMs`), or `cron` (`expr`, optional `tz`). -- `schedule.at` accepts ISO 8601 (timezone optional; treated as UTC when omitted). -- `everyMs` is milliseconds. -- `sessionTarget` must be `"main"` or `"isolated"` and must match `payload.kind`. -- Optional fields: `agentId`, `description`, `enabled`, `deleteAfterRun` (defaults to true for `at`), - `delivery`. -- `wakeMode` defaults to `"now"` when omitted. - -### cron.update params - -```json -{ - "jobId": "job-123", - "patch": { - "enabled": false, - "schedule": { "kind": "every", "everyMs": 3600000 } - } -} -``` - -Notes: - -- `jobId` is canonical; `id` is accepted for compatibility. -- Use `agentId: null` in the patch to clear an agent binding. - -### cron.run and cron.remove params - -```json -{ "jobId": "job-123", "mode": "force" } -``` - -```json -{ "jobId": "job-123" } -``` - -## Storage & history - -- Job store: `~/.openclaw/cron/jobs.json` (Gateway-managed JSON). -- Run history: `~/.openclaw/cron/runs/.jsonl` (JSONL, auto-pruned). -- Override store path: `cron.store` in config. - -## Configuration - -```json5 -{ - cron: { - enabled: true, // default true - store: "~/.openclaw/cron/jobs.json", - maxConcurrentRuns: 1, // default 1 - webhook: "https://example.invalid/legacy", // deprecated fallback for stored notify:true jobs - webhookToken: "replace-with-dedicated-webhook-token", // optional bearer token for webhook mode - }, -} -``` - -Webhook behavior: - -- Preferred: set `delivery.mode: "webhook"` with `delivery.to: "https://..."` per job. -- Webhook URLs must be valid `http://` or `https://` URLs. -- When posted, payload is the cron finished event JSON. -- If `cron.webhookToken` is set, auth header is `Authorization: Bearer `. -- If `cron.webhookToken` is not set, no `Authorization` header is sent. -- Deprecated fallback: stored legacy jobs with `notify: true` still use `cron.webhook` when present. - -Disable cron entirely: - -- `cron.enabled: false` (config) -- `OPENCLAW_SKIP_CRON=1` (env) - -## CLI quickstart - -One-shot reminder (UTC ISO, auto-delete after success): - -```bash -openclaw cron add \ - --name "Send reminder" \ - --at "2026-01-12T18:00:00Z" \ - --session main \ - --system-event "Reminder: submit expense report." \ - --wake now \ - --delete-after-run -``` - -One-shot reminder (main session, wake immediately): - -```bash -openclaw cron add \ - --name "Calendar check" \ - --at "20m" \ - --session main \ - --system-event "Next heartbeat: check calendar." \ - --wake now -``` - -Recurring isolated job (announce to WhatsApp): - -```bash -openclaw cron add \ - --name "Morning status" \ - --cron "0 7 * * *" \ - --tz "America/Los_Angeles" \ - --session isolated \ - --message "Summarize inbox + calendar for today." \ - --announce \ - --channel whatsapp \ - --to "+15551234567" -``` - -Recurring cron job with explicit 30-second stagger: - -```bash -openclaw cron add \ - --name "Minute watcher" \ - --cron "0 * * * * *" \ - --tz "UTC" \ - --stagger 30s \ - --session isolated \ - --message "Run minute watcher checks." \ - --announce -``` - -Recurring isolated job (deliver to a Telegram topic): - -```bash -openclaw cron add \ - --name "Nightly summary (topic)" \ - --cron "0 22 * * *" \ - --tz "America/Los_Angeles" \ - --session isolated \ - --message "Summarize today; send to the nightly topic." \ - --announce \ - --channel telegram \ - --to "-1001234567890:topic:123" -``` - -Isolated job with model and thinking override: - -```bash -openclaw cron add \ - --name "Deep analysis" \ - --cron "0 6 * * 1" \ - --tz "America/Los_Angeles" \ - --session isolated \ - --message "Weekly deep analysis of project progress." \ - --model "opus" \ - --thinking high \ - --announce \ - --channel whatsapp \ - --to "+15551234567" -``` - -Agent selection (multi-agent setups): - -```bash -# Pin a job to agent "ops" (falls back to default if that agent is missing) -openclaw cron add --name "Ops sweep" --cron "0 6 * * *" --session isolated --message "Check ops queue" --agent ops - -# Switch or clear the agent on an existing job -openclaw cron edit --agent ops -openclaw cron edit --clear-agent -``` - -Manual run (force is the default, use `--due` to only run when due): - -```bash -openclaw cron run -openclaw cron run --due -``` - -Edit an existing job (patch fields): - -```bash -openclaw cron edit \ - --message "Updated prompt" \ - --model "opus" \ - --thinking low -``` - -Force an existing cron job to run exactly on schedule (no stagger): - -```bash -openclaw cron edit --exact -``` - -Run history: - -```bash -openclaw cron runs --id --limit 50 -``` - -Immediate system event without creating a job: - -```bash -openclaw system event --mode now --text "Next heartbeat: check battery." -``` - -## Gateway API surface - -- `cron.list`, `cron.status`, `cron.add`, `cron.update`, `cron.remove` -- `cron.run` (force or due), `cron.runs` - For immediate system events without a job, use [`openclaw system event`](/cli/system). - -## Troubleshooting - -### “Nothing runs” - -- Check cron is enabled: `cron.enabled` and `OPENCLAW_SKIP_CRON`. -- 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:` so it’s explicit and unambiguous. -- If you see `telegram:...` prefixes in logs or stored “last route” targets, that’s normal; - cron delivery accepts them and still parses topic IDs correctly. - -### Subagent announce delivery retries - -- When a subagent run completes, the gateway announces the result to the requester session. -- If the announce flow returns `false` (e.g. requester session is busy), the gateway retries up to 3 times with tracking via `announceRetryCount`. -- Announces older than 5 minutes past `endedAt` are force-expired to prevent stale entries from looping indefinitely. -- If you see repeated announce deliveries in logs, check the subagent registry for entries with high `announceRetryCount` values. diff --git a/docs/automation/cron-vs-heartbeat.md b/docs/automation/cron-vs-heartbeat.md deleted file mode 100644 index c25cbcb80db..00000000000 --- a/docs/automation/cron-vs-heartbeat.md +++ /dev/null @@ -1,286 +0,0 @@ ---- -summary: "Guidance for choosing between heartbeat and cron jobs for automation" -read_when: - - Deciding how to schedule recurring tasks - - Setting up background monitoring or notifications - - Optimizing token usage for periodic checks -title: "Cron vs Heartbeat" ---- - -# Cron vs Heartbeat: When to Use Each - -Both heartbeats and cron jobs let you run tasks on a schedule. This guide helps you choose the right mechanism for your use case. - -## Quick Decision Guide - -| Use Case | Recommended | Why | -| ------------------------------------ | ------------------- | ---------------------------------------- | -| Check inbox every 30 min | Heartbeat | Batches with other checks, context-aware | -| Send daily report at 9am sharp | Cron (isolated) | Exact timing needed | -| Monitor calendar for upcoming events | Heartbeat | Natural fit for periodic awareness | -| Run weekly deep analysis | Cron (isolated) | Standalone task, can use different model | -| Remind me in 20 minutes | Cron (main, `--at`) | One-shot with precise timing | -| Background project health check | Heartbeat | Piggybacks on existing cycle | - -## Heartbeat: Periodic Awareness - -Heartbeats run in the **main session** at a regular interval (default: 30 min). They're designed for the agent to check on things and surface anything important. - -### When to use heartbeat - -- **Multiple periodic checks**: Instead of 5 separate cron jobs checking inbox, calendar, weather, notifications, and project status, a single heartbeat can batch all of these. -- **Context-aware decisions**: The agent has full main-session context, so it can make smart decisions about what's urgent vs. what can wait. -- **Conversational continuity**: Heartbeat runs share the same session, so the agent remembers recent conversations and can follow up naturally. -- **Low-overhead monitoring**: One heartbeat replaces many small polling tasks. - -### Heartbeat advantages - -- **Batches multiple checks**: One agent turn can review inbox, calendar, and notifications together. -- **Reduces API calls**: A single heartbeat is cheaper than 5 isolated cron jobs. -- **Context-aware**: The agent knows what you've been working on and can prioritize accordingly. -- **Smart suppression**: If nothing needs attention, the agent replies `HEARTBEAT_OK` and no message is delivered. -- **Natural timing**: Drifts slightly based on queue load, which is fine for most monitoring. - -### Heartbeat example: HEARTBEAT.md checklist - -```md -# Heartbeat checklist - -- Check email for urgent messages -- Review calendar for events in next 2 hours -- If a background task finished, summarize results -- If idle for 8+ hours, send a brief check-in -``` - -The agent reads this on each heartbeat and handles all items in one turn. - -### Configuring heartbeat - -```json5 -{ - agents: { - defaults: { - heartbeat: { - every: "30m", // interval - target: "last", // where to deliver alerts - activeHours: { start: "08:00", end: "22:00" }, // optional - }, - }, - }, -} -``` - -See [Heartbeat](/gateway/heartbeat) for full configuration. - -## Cron: Precise Scheduling - -Cron jobs run at precise times and can run in isolated sessions without affecting main context. -Recurring top-of-hour schedules are automatically spread by a deterministic -per-job offset in a 0-5 minute window. - -### When to use cron - -- **Exact timing required**: "Send this at 9:00 AM every Monday" (not "sometime around 9"). -- **Standalone tasks**: Tasks that don't need conversational context. -- **Different model/thinking**: Heavy analysis that warrants a more powerful model. -- **One-shot reminders**: "Remind me in 20 minutes" with `--at`. -- **Noisy/frequent tasks**: Tasks that would clutter main session history. -- **External triggers**: Tasks that should run independently of whether the agent is otherwise active. - -### Cron advantages - -- **Precise timing**: 5-field or 6-field (seconds) cron expressions with timezone support. -- **Built-in load spreading**: recurring top-of-hour schedules are staggered by up to 5 minutes by default. -- **Per-job control**: override stagger with `--stagger ` or force exact timing with `--exact`. -- **Session isolation**: Runs in `cron:` without polluting main history. -- **Model overrides**: Use a cheaper or more powerful model per job. -- **Delivery control**: Isolated jobs default to `announce` (summary); choose `none` as needed. -- **Immediate delivery**: Announce mode posts directly without waiting for heartbeat. -- **No agent context needed**: Runs even if main session is idle or compacted. -- **One-shot support**: `--at` for precise future timestamps. - -### Cron example: Daily morning briefing - -```bash -openclaw cron add \ - --name "Morning briefing" \ - --cron "0 7 * * *" \ - --tz "America/New_York" \ - --session isolated \ - --message "Generate today's briefing: weather, calendar, top emails, news summary." \ - --model opus \ - --announce \ - --channel whatsapp \ - --to "+15551234567" -``` - -This runs at exactly 7:00 AM New York time, uses Opus for quality, and announces a summary directly to WhatsApp. - -### Cron example: One-shot reminder - -```bash -openclaw cron add \ - --name "Meeting reminder" \ - --at "20m" \ - --session main \ - --system-event "Reminder: standup meeting starts in 10 minutes." \ - --wake now \ - --delete-after-run -``` - -See [Cron jobs](/automation/cron-jobs) for full CLI reference. - -## Decision Flowchart - -``` -Does the task need to run at an EXACT time? - YES -> Use cron - NO -> Continue... - -Does the task need isolation from main session? - YES -> Use cron (isolated) - NO -> Continue... - -Can this task be batched with other periodic checks? - YES -> Use heartbeat (add to HEARTBEAT.md) - NO -> Use cron - -Is this a one-shot reminder? - YES -> Use cron with --at - NO -> Continue... - -Does it need a different model or thinking level? - YES -> Use cron (isolated) with --model/--thinking - NO -> Use heartbeat -``` - -## Combining Both - -The most efficient setup uses **both**: - -1. **Heartbeat** handles routine monitoring (inbox, calendar, notifications) in one batched turn every 30 minutes. -2. **Cron** handles precise schedules (daily reports, weekly reviews) and one-shot reminders. - -### Example: Efficient automation setup - -**HEARTBEAT.md** (checked every 30 min): - -```md -# Heartbeat checklist - -- Scan inbox for urgent emails -- Check calendar for events in next 2h -- Review any pending tasks -- Light check-in if quiet for 8+ hours -``` - -**Cron jobs** (precise timing): - -```bash -# Daily morning briefing at 7am -openclaw cron add --name "Morning brief" --cron "0 7 * * *" --session isolated --message "..." --announce - -# Weekly project review on Mondays at 9am -openclaw cron add --name "Weekly review" --cron "0 9 * * 1" --session isolated --message "..." --model opus - -# One-shot reminder -openclaw cron add --name "Call back" --at "2h" --session main --system-event "Call back the client" --wake now -``` - -## Lobster: Deterministic workflows with approvals - -Lobster is the workflow runtime for **multi-step tool pipelines** that need deterministic execution and explicit approvals. -Use it when the task is more than a single agent turn, and you want a resumable workflow with human checkpoints. - -### When Lobster fits - -- **Multi-step automation**: You need a fixed pipeline of tool calls, not a one-off prompt. -- **Approval gates**: Side effects should pause until you approve, then resume. -- **Resumable runs**: Continue a paused workflow without re-running earlier steps. - -### How it pairs with heartbeat and cron - -- **Heartbeat/cron** decide _when_ a run happens. -- **Lobster** defines _what steps_ happen once the run starts. - -For scheduled workflows, use cron or heartbeat to trigger an agent turn that calls Lobster. -For ad-hoc workflows, call Lobster directly. - -### Operational notes (from the code) - -- Lobster runs as a **local subprocess** (`lobster` CLI) in tool mode and returns a **JSON envelope**. -- If the tool returns `needs_approval`, you resume with a `resumeToken` and `approve` flag. -- The tool is an **optional plugin**; enable it additively via `tools.alsoAllow: ["lobster"]` (recommended). -- Lobster expects the `lobster` CLI to be available on `PATH`. - -See [Lobster](/tools/lobster) for full usage and examples. - -## Main Session vs Isolated Session - -Both heartbeat and cron can interact with the main session, but differently: - -| | Heartbeat | Cron (main) | Cron (isolated) | -| ------- | ------------------------------- | ------------------------ | -------------------------- | -| Session | Main | Main (via system event) | `cron:` | -| History | Shared | Shared | Fresh each run | -| Context | Full | Full | None (starts clean) | -| Model | Main session model | Main session model | Can override | -| Output | Delivered if not `HEARTBEAT_OK` | Heartbeat prompt + event | Announce summary (default) | - -### When to use main session cron - -Use `--session main` with `--system-event` when you want: - -- The reminder/event to appear in main session context -- The agent to handle it during the next heartbeat with full context -- No separate isolated run - -```bash -openclaw cron add \ - --name "Check project" \ - --every "4h" \ - --session main \ - --system-event "Time for a project health check" \ - --wake now -``` - -### When to use isolated cron - -Use `--session isolated` when you want: - -- A clean slate without prior context -- Different model or thinking settings -- Announce summaries directly to a channel -- History that doesn't clutter main session - -```bash -openclaw cron add \ - --name "Deep analysis" \ - --cron "0 6 * * 0" \ - --session isolated \ - --message "Weekly codebase analysis..." \ - --model opus \ - --thinking high \ - --announce -``` - -## Cost Considerations - -| Mechanism | Cost Profile | -| --------------- | ------------------------------------------------------- | -| Heartbeat | One turn every N minutes; scales with HEARTBEAT.md size | -| Cron (main) | Adds event to next heartbeat (no isolated turn) | -| Cron (isolated) | Full agent turn per job; can use cheaper model | - -**Tips**: - -- Keep `HEARTBEAT.md` small to minimize token overhead. -- Batch similar checks into heartbeat instead of multiple cron jobs. -- Use `target: "none"` on heartbeat if you only want internal processing. -- Use isolated cron with a cheaper model for routine tasks. - -## Related - -- [Heartbeat](/gateway/heartbeat) - full heartbeat configuration -- [Cron jobs](/automation/cron-jobs) - full cron CLI and API reference -- [System](/cli/system) - system events + heartbeat controls diff --git a/docs/automation/gmail-pubsub.md b/docs/automation/gmail-pubsub.md deleted file mode 100644 index b853b995599..00000000000 --- a/docs/automation/gmail-pubsub.md +++ /dev/null @@ -1,256 +0,0 @@ ---- -summary: "Gmail Pub/Sub push wired into OpenClaw webhooks via gogcli" -read_when: - - Wiring Gmail inbox triggers to OpenClaw - - Setting up Pub/Sub push for agent wake -title: "Gmail PubSub" ---- - -# Gmail Pub/Sub -> OpenClaw - -Goal: Gmail watch -> Pub/Sub push -> `gog gmail watch serve` -> OpenClaw webhook. - -## Prereqs - -- `gcloud` installed and logged in ([install guide](https://docs.cloud.google.com/sdk/docs/install-sdk)). -- `gog` (gogcli) installed and authorized for the Gmail account ([gogcli.sh](https://gogcli.sh/)). -- OpenClaw hooks enabled (see [Webhooks](/automation/webhook)). -- `tailscale` logged in ([tailscale.com](https://tailscale.com/)). Supported setup uses Tailscale Funnel for the public HTTPS endpoint. - Other tunnel services can work, but are DIY/unsupported and require manual wiring. - Right now, Tailscale is what we support. - -Example hook config (enable Gmail preset mapping): - -```json5 -{ - hooks: { - enabled: true, - token: "OPENCLAW_HOOK_TOKEN", - path: "/hooks", - presets: ["gmail"], - }, -} -``` - -To deliver the Gmail summary to a chat surface, override the preset with a mapping -that sets `deliver` + optional `channel`/`to`: - -```json5 -{ - hooks: { - enabled: true, - token: "OPENCLAW_HOOK_TOKEN", - presets: ["gmail"], - mappings: [ - { - match: { path: "gmail" }, - action: "agent", - wakeMode: "now", - name: "Gmail", - sessionKey: "hook:gmail:{{messages[0].id}}", - messageTemplate: "New email from {{messages[0].from}}\nSubject: {{messages[0].subject}}\n{{messages[0].snippet}}\n{{messages[0].body}}", - model: "openai/gpt-5.2-mini", - deliver: true, - channel: "last", - // to: "+15551234567" - }, - ], - }, -} -``` - -If you want a fixed channel, set `channel` + `to`. Otherwise `channel: "last"` -uses the last delivery route (falls back to WhatsApp). - -To force a cheaper model for Gmail runs, set `model` in the mapping -(`provider/model` or alias). If you enforce `agents.defaults.models`, include it there. - -To set a default model and thinking level specifically for Gmail hooks, add -`hooks.gmail.model` / `hooks.gmail.thinking` in your config: - -```json5 -{ - hooks: { - gmail: { - model: "openrouter/meta-llama/llama-3.3-70b-instruct:free", - thinking: "off", - }, - }, -} -``` - -Notes: - -- Per-hook `model`/`thinking` in the mapping still overrides these defaults. -- Fallback order: `hooks.gmail.model` → `agents.defaults.model.fallbacks` → primary (auth/rate-limit/timeouts). -- If `agents.defaults.models` is set, the Gmail model must be in the allowlist. -- Gmail hook content is wrapped with external-content safety boundaries by default. - To disable (dangerous), set `hooks.gmail.allowUnsafeExternalContent: true`. - -To customize payload handling further, add `hooks.mappings` or a JS/TS transform module -under `~/.openclaw/hooks/transforms` (see [Webhooks](/automation/webhook)). - -## Wizard (recommended) - -Use the OpenClaw helper to wire everything together (installs deps on macOS via brew): - -```bash -openclaw webhooks gmail setup \ - --account openclaw@gmail.com -``` - -Defaults: - -- Uses Tailscale Funnel for the public push endpoint. -- Writes `hooks.gmail` config for `openclaw webhooks gmail run`. -- Enables the Gmail hook preset (`hooks.presets: ["gmail"]`). - -Path note: when `tailscale.mode` is enabled, OpenClaw automatically sets -`hooks.gmail.serve.path` to `/` and keeps the public path at -`hooks.gmail.tailscale.path` (default `/gmail-pubsub`) because Tailscale -strips the set-path prefix before proxying. -If you need the backend to receive the prefixed path, set -`hooks.gmail.tailscale.target` (or `--tailscale-target`) to a full URL like -`http://127.0.0.1:8788/gmail-pubsub` and match `hooks.gmail.serve.path`. - -Want a custom endpoint? Use `--push-endpoint ` or `--tailscale off`. - -Platform note: on macOS the wizard installs `gcloud`, `gogcli`, and `tailscale` -via Homebrew; on Linux install them manually first. - -Gateway auto-start (recommended): - -- When `hooks.enabled=true` and `hooks.gmail.account` is set, the Gateway starts - `gog gmail watch serve` on boot and auto-renews the watch. -- Set `OPENCLAW_SKIP_GMAIL_WATCHER=1` to opt out (useful if you run the daemon yourself). -- Do not run the manual daemon at the same time, or you will hit - `listen tcp 127.0.0.1:8788: bind: address already in use`. - -Manual daemon (starts `gog gmail watch serve` + auto-renew): - -```bash -openclaw webhooks gmail run -``` - -## One-time setup - -1. Select the GCP project **that owns the OAuth client** used by `gog`. - -```bash -gcloud auth login -gcloud config set project -``` - -Note: Gmail watch requires the Pub/Sub topic to live in the same project as the OAuth client. - -2. Enable APIs: - -```bash -gcloud services enable gmail.googleapis.com pubsub.googleapis.com -``` - -3. Create a topic: - -```bash -gcloud pubsub topics create gog-gmail-watch -``` - -4. Allow Gmail push to publish: - -```bash -gcloud pubsub topics add-iam-policy-binding gog-gmail-watch \ - --member=serviceAccount:gmail-api-push@system.gserviceaccount.com \ - --role=roles/pubsub.publisher -``` - -## Start the watch - -```bash -gog gmail watch start \ - --account openclaw@gmail.com \ - --label INBOX \ - --topic projects//topics/gog-gmail-watch -``` - -Save the `history_id` from the output (for debugging). - -## Run the push handler - -Local example (shared token auth): - -```bash -gog gmail watch serve \ - --account openclaw@gmail.com \ - --bind 127.0.0.1 \ - --port 8788 \ - --path /gmail-pubsub \ - --token \ - --hook-url http://127.0.0.1:18789/hooks/gmail \ - --hook-token OPENCLAW_HOOK_TOKEN \ - --include-body \ - --max-bytes 20000 -``` - -Notes: - -- `--token` protects the push endpoint (`x-gog-token` or `?token=`). -- `--hook-url` points to OpenClaw `/hooks/gmail` (mapped; isolated run + summary to main). -- `--include-body` and `--max-bytes` control the body snippet sent to OpenClaw. - -Recommended: `openclaw webhooks gmail run` wraps the same flow and auto-renews the watch. - -## Expose the handler (advanced, unsupported) - -If you need a non-Tailscale tunnel, wire it manually and use the public URL in the push -subscription (unsupported, no guardrails): - -```bash -cloudflared tunnel --url http://127.0.0.1:8788 --no-autoupdate -``` - -Use the generated URL as the push endpoint: - -```bash -gcloud pubsub subscriptions create gog-gmail-watch-push \ - --topic gog-gmail-watch \ - --push-endpoint "https:///gmail-pubsub?token=" -``` - -Production: use a stable HTTPS endpoint and configure Pub/Sub OIDC JWT, then run: - -```bash -gog gmail watch serve --verify-oidc --oidc-email -``` - -## Test - -Send a message to the watched inbox: - -```bash -gog gmail send \ - --account openclaw@gmail.com \ - --to openclaw@gmail.com \ - --subject "watch test" \ - --body "ping" -``` - -Check watch state and history: - -```bash -gog gmail watch status --account openclaw@gmail.com -gog gmail history --account openclaw@gmail.com --since -``` - -## Troubleshooting - -- `Invalid topicName`: project mismatch (topic not in the OAuth client project). -- `User not authorized`: missing `roles/pubsub.publisher` on the topic. -- Empty messages: Gmail push only provides `historyId`; fetch via `gog gmail history`. - -## Cleanup - -```bash -gog gmail watch stop --account openclaw@gmail.com -gcloud pubsub subscriptions delete gog-gmail-watch-push -gcloud pubsub topics delete gog-gmail-watch -``` diff --git a/docs/automation/hooks.md b/docs/automation/hooks.md deleted file mode 100644 index 66b96cd1e9e..00000000000 --- a/docs/automation/hooks.md +++ /dev/null @@ -1,1001 +0,0 @@ ---- -summary: "Hooks: event-driven automation for commands and lifecycle events" -read_when: - - You want event-driven automation for /new, /reset, /stop, and agent lifecycle events - - You want to build, install, or debug hooks -title: "Hooks" ---- - -# Hooks - -Hooks provide an extensible event-driven system for automating actions in response to agent commands and events. Hooks are automatically discovered from directories and can be managed via CLI commands, similar to how skills work in OpenClaw. - -## Getting Oriented - -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](/tools/plugin#plugin-hooks). - -Common uses: - -- Save a memory snapshot when you reset a session -- Keep an audit trail of commands for troubleshooting or compliance -- Trigger follow-up automation when a session starts or ends -- Write files into the agent workspace or call external APIs when events fire - -If you can write a small TypeScript function, you can write a hook. Hooks are discovered automatically, and you enable or disable them via the CLI. - -## Overview - -The hooks system allows you to: - -- Save session context to memory when `/new` is issued -- Log all commands for auditing -- Trigger custom automations on agent lifecycle events -- Extend OpenClaw's behavior without modifying core code - -## Getting Started - -### Bundled Hooks - -OpenClaw ships with four bundled hooks that are automatically discovered: - -- **💾 session-memory**: Saves session context to your agent workspace (default `~/.openclaw/workspace/memory/`) when you issue `/new` -- **📎 bootstrap-extra-files**: Injects additional workspace bootstrap files from configured glob/path patterns during `agent:bootstrap` -- **📝 command-logger**: Logs all command events to `~/.openclaw/logs/commands.log` -- **🚀 boot-md**: Runs `BOOT.md` when the gateway starts (requires internal hooks enabled) - -List available hooks: - -```bash -openclaw hooks list -``` - -Enable a hook: - -```bash -openclaw hooks enable session-memory -``` - -Check hook status: - -```bash -openclaw hooks check -``` - -Get detailed information: - -```bash -openclaw hooks info session-memory -``` - -### Onboarding - -During onboarding (`openclaw onboard`), you'll be prompted to enable recommended hooks. The wizard automatically discovers eligible hooks and presents them for selection. - -## Hook Discovery - -Hooks are automatically discovered from three directories (in order of precedence): - -1. **Workspace hooks**: `/hooks/` (per-agent, highest precedence) -2. **Managed hooks**: `~/.openclaw/hooks/` (user-installed, shared across workspaces) -3. **Bundled hooks**: `/dist/hooks/bundled/` (shipped with OpenClaw) - -Managed hook directories can be either a **single hook** or a **hook pack** (package directory). - -Each hook is a directory containing: - -``` -my-hook/ -├── HOOK.md # Metadata + documentation -└── handler.ts # Handler implementation -``` - -## Hook Packs (npm/archives) - -Hook packs are standard npm packages that export one or more hooks via `openclaw.hooks` in -`package.json`. Install them with: - -```bash -openclaw hooks install -``` - -Npm specs are registry-only (package name + optional version/tag). Git/URL/file specs are rejected. - -Example `package.json`: - -```json -{ - "name": "@acme/my-hooks", - "version": "0.1.0", - "openclaw": { - "hooks": ["./hooks/my-hook", "./hooks/other-hook"] - } -} -``` - -Each entry points to a hook directory containing `HOOK.md` and `handler.ts` (or `index.ts`). -Hook packs can ship dependencies; they will be installed under `~/.openclaw/hooks/`. -Each `openclaw.hooks` entry must stay inside the package directory after symlink -resolution; entries that escape are rejected. - -Security note: `openclaw hooks install` installs dependencies with `npm install --ignore-scripts` -(no lifecycle scripts). Keep hook pack dependency trees "pure JS/TS" and avoid packages that rely -on `postinstall` builds. - -## Hook Structure - -### HOOK.md Format - -The `HOOK.md` file contains metadata in YAML frontmatter plus Markdown documentation: - -```markdown ---- -name: my-hook -description: "Short description of what this hook does" -homepage: https://docs.openclaw.ai/automation/hooks#my-hook -metadata: - { "openclaw": { "emoji": "🔗", "events": ["command:new"], "requires": { "bins": ["node"] } } } ---- - -# My Hook - -Detailed documentation goes here... - -## What It Does - -- Listens for `/new` commands -- Performs some action -- Logs the result - -## Requirements - -- Node.js must be installed - -## Configuration - -No configuration needed. -``` - -### Metadata Fields - -The `metadata.openclaw` object supports: - -- **`emoji`**: Display emoji for CLI (e.g., `"💾"`) -- **`events`**: Array of events to listen for (e.g., `["command:new", "command:reset"]`) -- **`export`**: Named export to use (defaults to `"default"`) -- **`homepage`**: Documentation URL -- **`requires`**: Optional requirements - - **`bins`**: Required binaries on PATH (e.g., `["git", "node"]`) - - **`anyBins`**: At least one of these binaries must be present - - **`env`**: Required environment variables - - **`config`**: Required config paths (e.g., `["workspace.dir"]`) - - **`os`**: Required platforms (e.g., `["darwin", "linux"]`) -- **`always`**: Bypass eligibility checks (boolean) -- **`install`**: Installation methods (for bundled hooks: `[{"id":"bundled","kind":"bundled"}]`) - -### Handler Implementation - -The `handler.ts` file exports a `HookHandler` function: - -```typescript -import type { HookHandler } from "../../src/hooks/hooks.js"; - -const myHandler: HookHandler = async (event) => { - // Only trigger on 'new' command - if (event.type !== "command" || event.action !== "new") { - return; - } - - console.log(`[my-hook] New command triggered`); - console.log(` Session: ${event.sessionKey}`); - console.log(` Timestamp: ${event.timestamp.toISOString()}`); - - // Your custom logic here - - // Optionally send message to user - event.messages.push("✨ My hook executed!"); -}; - -export default myHandler; -``` - -#### Event Context - -Each event includes: - -```typescript -{ - type: 'command' | 'session' | 'agent' | 'gateway' | 'message', - action: string, // e.g., 'new', 'reset', 'stop', 'received', 'sent' - sessionKey: string, // Session identifier - timestamp: Date, // When the event occurred - messages: string[], // Push messages here to send to user - context: { - // Command events: - sessionEntry?: SessionEntry, - sessionId?: string, - sessionFile?: string, - commandSource?: string, // e.g., 'whatsapp', 'telegram' - senderId?: string, - workspaceDir?: string, - bootstrapFiles?: WorkspaceBootstrapFile[], - cfg?: OpenClawConfig, - // Message events (see Message Events section for full details): - from?: string, // message:received - to?: string, // message:sent - content?: string, - channelId?: string, - success?: boolean, // message:sent - } -} -``` - -## Event Types - -### Command Events - -Triggered when agent commands are issued: - -- **`command`**: All command events (general listener) -- **`command:new`**: When `/new` command is issued -- **`command:reset`**: When `/reset` command is issued -- **`command:stop`**: When `/stop` command is issued - -### Agent Events - -- **`agent:bootstrap`**: Before workspace bootstrap files are injected (hooks may mutate `context.bootstrapFiles`) - -### Gateway Events - -Triggered when the gateway starts: - -- **`gateway:startup`**: After channels start and hooks are loaded - -### Message Events - -Triggered when messages are received or sent: - -- **`message`**: All message events (general listener) -- **`message:received`**: When an inbound message is received from any channel -- **`message:sent`**: When an outbound message is successfully sent - -#### Message Event Context - -Message events include rich context about the message: - -```typescript -// message:received context -{ - from: string, // Sender identifier (phone number, user ID, etc.) - content: string, // Message content - timestamp?: number, // Unix timestamp when received - channelId: string, // Channel (e.g., "whatsapp", "telegram", "discord") - accountId?: string, // Provider account ID for multi-account setups - conversationId?: string, // Chat/conversation ID - messageId?: string, // Message ID from the provider - metadata?: { // Additional provider-specific data - to?: string, - provider?: string, - surface?: string, - threadId?: string, - senderId?: string, - senderName?: string, - senderUsername?: string, - senderE164?: string, - } -} - -// message:sent context -{ - to: string, // Recipient identifier - content: string, // Message content that was sent - success: boolean, // Whether the send succeeded - error?: string, // Error message if sending failed - channelId: string, // Channel (e.g., "whatsapp", "telegram", "discord") - accountId?: string, // Provider account ID - conversationId?: string, // Chat/conversation ID - messageId?: string, // Message ID returned by the provider -} -``` - -#### Example: Message Logger Hook - -```typescript -import type { HookHandler } from "../../src/hooks/hooks.js"; -import { isMessageReceivedEvent, isMessageSentEvent } from "../../src/hooks/internal-hooks.js"; - -const handler: HookHandler = async (event) => { - if (isMessageReceivedEvent(event)) { - console.log(`[message-logger] Received from ${event.context.from}: ${event.context.content}`); - } else if (isMessageSentEvent(event)) { - console.log(`[message-logger] Sent to ${event.context.to}: ${event.context.content}`); - } -}; - -export default handler; -``` - -### Tool Result Hooks (Plugin API) - -These hooks are not event-stream listeners; they let plugins synchronously adjust tool results before OpenClaw persists them. - -- **`tool_result_persist`**: transform tool results before they are written to the session transcript. Must be synchronous; return the updated tool result payload or `undefined` to keep it as-is. See [Agent Loop](/concepts/agent-loop). - -### Future Events - -Planned event types: - -- **`session:start`**: When a new session begins -- **`session:end`**: When a session ends -- **`agent:error`**: When an agent encounters an error - -## Creating Custom Hooks - -### 1. Choose Location - -- **Workspace hooks** (`/hooks/`): Per-agent, highest precedence -- **Managed hooks** (`~/.openclaw/hooks/`): Shared across workspaces - -### 2. Create Directory Structure - -```bash -mkdir -p ~/.openclaw/hooks/my-hook -cd ~/.openclaw/hooks/my-hook -``` - -### 3. Create HOOK.md - -```markdown ---- -name: my-hook -description: "Does something useful" -metadata: { "openclaw": { "emoji": "🎯", "events": ["command:new"] } } ---- - -# My Custom Hook - -This hook does something useful when you issue `/new`. -``` - -### 4. Create handler.ts - -```typescript -import type { HookHandler } from "../../src/hooks/hooks.js"; - -const handler: HookHandler = async (event) => { - if (event.type !== "command" || event.action !== "new") { - return; - } - - console.log("[my-hook] Running!"); - // Your logic here -}; - -export default handler; -``` - -### 5. Enable and Test - -```bash -# Verify hook is discovered -openclaw hooks list - -# Enable it -openclaw hooks enable my-hook - -# Restart your gateway process (menu bar app restart on macOS, or restart your dev process) - -# Trigger the event -# Send /new via your messaging channel -``` - -## Configuration - -### New Config Format (Recommended) - -```json -{ - "hooks": { - "internal": { - "enabled": true, - "entries": { - "session-memory": { "enabled": true }, - "command-logger": { "enabled": false } - } - } - } -} -``` - -### Per-Hook Configuration - -Hooks can have custom configuration: - -```json -{ - "hooks": { - "internal": { - "enabled": true, - "entries": { - "my-hook": { - "enabled": true, - "env": { - "MY_CUSTOM_VAR": "value" - } - } - } - } - } -} -``` - -### Extra Directories - -Load hooks from additional directories: - -```json -{ - "hooks": { - "internal": { - "enabled": true, - "load": { - "extraDirs": ["/path/to/more/hooks"] - } - } - } -} -``` - -### Legacy Config Format (Still Supported) - -The old config format still works for backwards compatibility: - -```json -{ - "hooks": { - "internal": { - "enabled": true, - "handlers": [ - { - "event": "command:new", - "module": "./hooks/handlers/my-handler.ts", - "export": "default" - } - ] - } - } -} -``` - -Note: `module` must be a workspace-relative path. Absolute paths and traversal outside the workspace are rejected. - -**Migration**: Use the new discovery-based system for new hooks. Legacy handlers are loaded after directory-based hooks. - -## CLI Commands - -### List Hooks - -```bash -# List all hooks -openclaw hooks list - -# Show only eligible hooks -openclaw hooks list --eligible - -# Verbose output (show missing requirements) -openclaw hooks list --verbose - -# JSON output -openclaw hooks list --json -``` - -### Hook Information - -```bash -# Show detailed info about a hook -openclaw hooks info session-memory - -# JSON output -openclaw hooks info session-memory --json -``` - -### Check Eligibility - -```bash -# Show eligibility summary -openclaw hooks check - -# JSON output -openclaw hooks check --json -``` - -### Enable/Disable - -```bash -# Enable a hook -openclaw hooks enable session-memory - -# Disable a hook -openclaw hooks disable command-logger -``` - -## Bundled hook reference - -### session-memory - -Saves session context to memory when you issue `/new`. - -**Events**: `command:new` - -**Requirements**: `workspace.dir` must be configured - -**Output**: `/memory/YYYY-MM-DD-slug.md` (defaults to `~/.openclaw/workspace`) - -**What it does**: - -1. Uses the pre-reset session entry to locate the correct transcript -2. Extracts the last 15 lines of conversation -3. Uses LLM to generate a descriptive filename slug -4. Saves session metadata to a dated memory file - -**Example output**: - -```markdown -# Session: 2026-01-16 14:30:00 UTC - -- **Session Key**: agent:main:main -- **Session ID**: abc123def456 -- **Source**: telegram -``` - -**Filename examples**: - -- `2026-01-16-vendor-pitch.md` -- `2026-01-16-api-design.md` -- `2026-01-16-1430.md` (fallback timestamp if slug generation fails) - -**Enable**: - -```bash -openclaw hooks enable session-memory -``` - -### bootstrap-extra-files - -Injects additional bootstrap files (for example monorepo-local `AGENTS.md` / `TOOLS.md`) during `agent:bootstrap`. - -**Events**: `agent:bootstrap` - -**Requirements**: `workspace.dir` must be configured - -**Output**: No files written; bootstrap context is modified in-memory only. - -**Config**: - -```json -{ - "hooks": { - "internal": { - "enabled": true, - "entries": { - "bootstrap-extra-files": { - "enabled": true, - "paths": ["packages/*/AGENTS.md", "packages/*/TOOLS.md"] - } - } - } - } -} -``` - -**Notes**: - -- Paths are resolved relative to workspace. -- Files must stay inside workspace (realpath-checked). -- Only recognized bootstrap basenames are loaded. -- Subagent allowlist is preserved (`AGENTS.md` and `TOOLS.md` only). - -**Enable**: - -```bash -openclaw hooks enable bootstrap-extra-files -``` - -### command-logger - -Logs all command events to a centralized audit file. - -**Events**: `command` - -**Requirements**: None - -**Output**: `~/.openclaw/logs/commands.log` - -**What it does**: - -1. Captures event details (command action, timestamp, session key, sender ID, source) -2. Appends to log file in JSONL format -3. Runs silently in the background - -**Example log entries**: - -```jsonl -{"timestamp":"2026-01-16T14:30:00.000Z","action":"new","sessionKey":"agent:main:main","senderId":"+1234567890","source":"telegram"} -{"timestamp":"2026-01-16T15:45:22.000Z","action":"stop","sessionKey":"agent:main:main","senderId":"user@example.com","source":"whatsapp"} -``` - -**View logs**: - -```bash -# View recent commands -tail -n 20 ~/.openclaw/logs/commands.log - -# Pretty-print with jq -cat ~/.openclaw/logs/commands.log | jq . - -# Filter by action -grep '"action":"new"' ~/.openclaw/logs/commands.log | jq . -``` - -**Enable**: - -```bash -openclaw hooks enable command-logger -``` - -### boot-md - -Runs `BOOT.md` when the gateway starts (after channels start). -Internal hooks must be enabled for this to run. - -**Events**: `gateway:startup` - -**Requirements**: `workspace.dir` must be configured - -**What it does**: - -1. Reads `BOOT.md` from your workspace -2. Runs the instructions via the agent runner -3. Sends any requested outbound messages via the message tool - -**Enable**: - -```bash -openclaw hooks enable boot-md -``` - -## Best Practices - -### Keep Handlers Fast - -Hooks run during command processing. Keep them lightweight: - -```typescript -// ✓ Good - async work, returns immediately -const handler: HookHandler = async (event) => { - void processInBackground(event); // Fire and forget -}; - -// ✗ Bad - blocks command processing -const handler: HookHandler = async (event) => { - await slowDatabaseQuery(event); - await evenSlowerAPICall(event); -}; -``` - -### Handle Errors Gracefully - -Always wrap risky operations: - -```typescript -const handler: HookHandler = async (event) => { - try { - await riskyOperation(event); - } catch (err) { - console.error("[my-handler] Failed:", err instanceof Error ? err.message : String(err)); - // Don't throw - let other handlers run - } -}; -``` - -### Filter Events Early - -Return early if the event isn't relevant: - -```typescript -const handler: HookHandler = async (event) => { - // Only handle 'new' commands - if (event.type !== "command" || event.action !== "new") { - return; - } - - // Your logic here -}; -``` - -### Use Specific Event Keys - -Specify exact events in metadata when possible: - -```yaml -metadata: { "openclaw": { "events": ["command:new"] } } # Specific -``` - -Rather than: - -```yaml -metadata: { "openclaw": { "events": ["command"] } } # General - more overhead -``` - -## Debugging - -### Enable Hook Logging - -The gateway logs hook loading at startup: - -``` -Registered hook: session-memory -> command:new -Registered hook: bootstrap-extra-files -> agent:bootstrap -Registered hook: command-logger -> command -Registered hook: boot-md -> gateway:startup -``` - -### Check Discovery - -List all discovered hooks: - -```bash -openclaw hooks list --verbose -``` - -### Check Registration - -In your handler, log when it's called: - -```typescript -const handler: HookHandler = async (event) => { - console.log("[my-handler] Triggered:", event.type, event.action); - // Your logic -}; -``` - -### Verify Eligibility - -Check why a hook isn't eligible: - -```bash -openclaw hooks info my-hook -``` - -Look for missing requirements in the output. - -## Testing - -### Gateway Logs - -Monitor gateway logs to see hook execution: - -```bash -# macOS -./scripts/clawlog.sh -f - -# Other platforms -tail -f ~/.openclaw/gateway.log -``` - -### Test Hooks Directly - -Test your handlers in isolation: - -```typescript -import { test } from "vitest"; -import { createHookEvent } from "./src/hooks/hooks.js"; -import myHandler from "./hooks/my-hook/handler.js"; - -test("my handler works", async () => { - const event = createHookEvent("command", "new", "test-session", { - foo: "bar", - }); - - await myHandler(event); - - // Assert side effects -}); -``` - -## Architecture - -### Core Components - -- **`src/hooks/types.ts`**: Type definitions -- **`src/hooks/workspace.ts`**: Directory scanning and loading -- **`src/hooks/frontmatter.ts`**: HOOK.md metadata parsing -- **`src/hooks/config.ts`**: Eligibility checking -- **`src/hooks/hooks-status.ts`**: Status reporting -- **`src/hooks/loader.ts`**: Dynamic module loader -- **`src/cli/hooks-cli.ts`**: CLI commands -- **`src/gateway/server-startup.ts`**: Loads hooks at gateway start -- **`src/auto-reply/reply/commands-core.ts`**: Triggers command events - -### Discovery Flow - -``` -Gateway startup - ↓ -Scan directories (workspace → managed → bundled) - ↓ -Parse HOOK.md files - ↓ -Check eligibility (bins, env, config, os) - ↓ -Load handlers from eligible hooks - ↓ -Register handlers for events -``` - -### Event Flow - -``` -User sends /new - ↓ -Command validation - ↓ -Create hook event - ↓ -Trigger hook (all registered handlers) - ↓ -Command processing continues - ↓ -Session reset -``` - -## Troubleshooting - -### Hook Not Discovered - -1. Check directory structure: - - ```bash - ls -la ~/.openclaw/hooks/my-hook/ - # Should show: HOOK.md, handler.ts - ``` - -2. Verify HOOK.md format: - - ```bash - cat ~/.openclaw/hooks/my-hook/HOOK.md - # Should have YAML frontmatter with name and metadata - ``` - -3. List all discovered hooks: - - ```bash - openclaw hooks list - ``` - -### Hook Not Eligible - -Check requirements: - -```bash -openclaw hooks info my-hook -``` - -Look for missing: - -- Binaries (check PATH) -- Environment variables -- Config values -- OS compatibility - -### Hook Not Executing - -1. Verify hook is enabled: - - ```bash - openclaw hooks list - # Should show ✓ next to enabled hooks - ``` - -2. Restart your gateway process so hooks reload. - -3. Check gateway logs for errors: - - ```bash - ./scripts/clawlog.sh | grep hook - ``` - -### Handler Errors - -Check for TypeScript/import errors: - -```bash -# Test import directly -node -e "import('./path/to/handler.ts').then(console.log)" -``` - -## Migration Guide - -### From Legacy Config to Discovery - -**Before**: - -```json -{ - "hooks": { - "internal": { - "enabled": true, - "handlers": [ - { - "event": "command:new", - "module": "./hooks/handlers/my-handler.ts" - } - ] - } - } -} -``` - -**After**: - -1. Create hook directory: - - ```bash - mkdir -p ~/.openclaw/hooks/my-hook - mv ./hooks/handlers/my-handler.ts ~/.openclaw/hooks/my-hook/handler.ts - ``` - -2. Create HOOK.md: - - ```markdown - --- - name: my-hook - description: "My custom hook" - metadata: { "openclaw": { "emoji": "🎯", "events": ["command:new"] } } - --- - - # My Hook - - Does something useful. - ``` - -3. Update config: - - ```json - { - "hooks": { - "internal": { - "enabled": true, - "entries": { - "my-hook": { "enabled": true } - } - } - } - } - ``` - -4. Verify and restart your gateway process: - - ```bash - openclaw hooks list - # Should show: 🎯 my-hook ✓ - ``` - -**Benefits of migration**: - -- Automatic discovery -- CLI management -- Eligibility checking -- Better documentation -- Consistent structure - -## See Also - -- [CLI Reference: hooks](/cli/hooks) -- [Bundled Hooks README](https://github.com/openclaw/openclaw/tree/main/src/hooks/bundled) -- [Webhook Hooks](/automation/webhook) -- [Configuration](/gateway/configuration#hooks) diff --git a/docs/automation/poll.md b/docs/automation/poll.md deleted file mode 100644 index fab0b0e0738..00000000000 --- a/docs/automation/poll.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -summary: "Poll sending via gateway + CLI" -read_when: - - Adding or modifying poll support - - Debugging poll sends from the CLI or gateway -title: "Polls" ---- - -# Polls - -## Supported channels - -- WhatsApp (web channel) -- Discord -- MS Teams (Adaptive Cards) - -## CLI - -```bash -# WhatsApp -openclaw message poll --target +15555550123 \ - --poll-question "Lunch today?" --poll-option "Yes" --poll-option "No" --poll-option "Maybe" -openclaw message poll --target 123456789@g.us \ - --poll-question "Meeting time?" --poll-option "10am" --poll-option "2pm" --poll-option "4pm" --poll-multi - -# Discord -openclaw message poll --channel discord --target channel:123456789 \ - --poll-question "Snack?" --poll-option "Pizza" --poll-option "Sushi" -openclaw message poll --channel discord --target channel:123456789 \ - --poll-question "Plan?" --poll-option "A" --poll-option "B" --poll-duration-hours 48 - -# MS Teams -openclaw message poll --channel msteams --target conversation:19:abc@thread.tacv2 \ - --poll-question "Lunch?" --poll-option "Pizza" --poll-option "Sushi" -``` - -Options: - -- `--channel`: `whatsapp` (default), `discord`, or `msteams` -- `--poll-multi`: allow selecting multiple options -- `--poll-duration-hours`: Discord-only (defaults to 24 when omitted) - -## Gateway RPC - -Method: `poll` - -Params: - -- `to` (string, required) -- `question` (string, required) -- `options` (string[], required) -- `maxSelections` (number, optional) -- `durationHours` (number, optional) -- `channel` (string, optional, default: `whatsapp`) -- `idempotencyKey` (string, required) - -## Channel differences - -- WhatsApp: 2-12 options, `maxSelections` must be within option count, ignores `durationHours`. -- Discord: 2-10 options, `durationHours` clamped to 1-768 hours (default 24). `maxSelections > 1` enables multi-select; Discord does not support a strict selection count. -- MS Teams: Adaptive Card polls (OpenClaw-managed). No native poll API; `durationHours` is ignored. - -## Agent tool (Message) - -Use the `message` tool with `poll` action (`to`, `pollQuestion`, `pollOption`, optional `pollMulti`, `pollDurationHours`, `channel`). - -Note: Discord has no “pick exactly N” mode; `pollMulti` maps to multi-select. -Teams polls are rendered as Adaptive Cards and require the gateway to stay online -to record votes in `~/.openclaw/msteams-polls.json`. diff --git a/docs/automation/troubleshooting.md b/docs/automation/troubleshooting.md deleted file mode 100644 index 9190855dd59..00000000000 --- a/docs/automation/troubleshooting.md +++ /dev/null @@ -1,122 +0,0 @@ ---- -summary: "Troubleshoot cron and heartbeat scheduling and delivery" -read_when: - - Cron did not run - - Cron ran but no message was delivered - - Heartbeat seems silent or skipped -title: "Automation Troubleshooting" ---- - -# Automation troubleshooting - -Use this page for scheduler and delivery issues (`cron` + `heartbeat`). - -## Command ladder - -```bash -openclaw status -openclaw gateway status -openclaw logs --follow -openclaw doctor -openclaw channels status --probe -``` - -Then run automation checks: - -```bash -openclaw cron status -openclaw cron list -openclaw system heartbeat last -``` - -## Cron not firing - -```bash -openclaw cron status -openclaw cron list -openclaw cron runs --id --limit 20 -openclaw logs --follow -``` - -Good output looks like: - -- `cron status` reports enabled and a future `nextWakeAtMs`. -- Job is enabled and has a valid schedule/timezone. -- `cron runs` shows `ok` or explicit skip reason. - -Common signatures: - -- `cron: scheduler disabled; jobs will not run automatically` → cron disabled in config/env. -- `cron: timer tick failed` → scheduler tick crashed; inspect surrounding stack/log context. -- `reason: not-due` in run output → manual run called without `--force` and job not due yet. - -## Cron fired but no delivery - -```bash -openclaw cron runs --id --limit 20 -openclaw cron list -openclaw channels status --probe -openclaw logs --follow -``` - -Good output looks like: - -- Run status is `ok`. -- Delivery mode/target are set for isolated jobs. -- Channel probe reports target channel connected. - -Common signatures: - -- Run succeeded but delivery mode is `none` → no external message is expected. -- Delivery target missing/invalid (`channel`/`to`) → run may succeed internally but skip outbound. -- Channel auth errors (`unauthorized`, `missing_scope`, `Forbidden`) → delivery blocked by channel credentials/permissions. - -## Heartbeat suppressed or skipped - -```bash -openclaw system heartbeat last -openclaw logs --follow -openclaw config get agents.defaults.heartbeat -openclaw channels status --probe -``` - -Good output looks like: - -- Heartbeat enabled with non-zero interval. -- Last heartbeat result is `ran` (or skip reason is understood). - -Common signatures: - -- `heartbeat skipped` with `reason=quiet-hours` → outside `activeHours`. -- `requests-in-flight` → main lane busy; heartbeat deferred. -- `empty-heartbeat-file` → interval heartbeat skipped because `HEARTBEAT.md` has no actionable content and no tagged cron event is queued. -- `alerts-disabled` → visibility settings suppress outbound heartbeat messages. - -## Timezone and activeHours gotchas - -```bash -openclaw config get agents.defaults.heartbeat.activeHours -openclaw config get agents.defaults.heartbeat.activeHours.timezone -openclaw config get agents.defaults.userTimezone || echo "agents.defaults.userTimezone not set" -openclaw cron list -openclaw logs --follow -``` - -Quick rules: - -- `Config path not found: agents.defaults.userTimezone` means the key is unset; heartbeat falls back to host timezone (or `activeHours.timezone` if set). -- Cron without `--tz` uses gateway host timezone. -- Heartbeat `activeHours` uses configured timezone resolution (`user`, `local`, or explicit IANA tz). -- ISO timestamps without timezone are treated as UTC for cron `at` schedules. - -Common signatures: - -- Jobs run at the wrong wall-clock time after host timezone changes. -- Heartbeat always skipped during your daytime because `activeHours.timezone` is wrong. - -Related: - -- [/automation/cron-jobs](/automation/cron-jobs) -- [/gateway/heartbeat](/gateway/heartbeat) -- [/automation/cron-vs-heartbeat](/automation/cron-vs-heartbeat) -- [/concepts/timezone](/concepts/timezone) diff --git a/docs/automation/webhook.md b/docs/automation/webhook.md deleted file mode 100644 index 8072b4a1a3f..00000000000 --- a/docs/automation/webhook.md +++ /dev/null @@ -1,215 +0,0 @@ ---- -summary: "Webhook ingress for wake and isolated agent runs" -read_when: - - Adding or changing webhook endpoints - - Wiring external systems into OpenClaw -title: "Webhooks" ---- - -# Webhooks - -Gateway can expose a small HTTP webhook endpoint for external triggers. - -## Enable - -```json5 -{ - hooks: { - enabled: true, - token: "shared-secret", - path: "/hooks", - // Optional: restrict explicit `agentId` routing to this allowlist. - // Omit or include "*" to allow any agent. - // Set [] to deny all explicit `agentId` routing. - allowedAgentIds: ["hooks", "main"], - }, -} -``` - -Notes: - -- `hooks.token` is required when `hooks.enabled=true`. -- `hooks.path` defaults to `/hooks`. - -## Auth - -Every request must include the hook token. Prefer headers: - -- `Authorization: Bearer ` (recommended) -- `x-openclaw-token: ` -- Query-string tokens are rejected (`?token=...` returns `400`). - -## Endpoints - -### `POST /hooks/wake` - -Payload: - -```json -{ "text": "System line", "mode": "now" } -``` - -- `text` **required** (string): The description of the event (e.g., "New email received"). -- `mode` optional (`now` | `next-heartbeat`): Whether to trigger an immediate heartbeat (default `now`) or wait for the next periodic check. - -Effect: - -- Enqueues a system event for the **main** session -- If `mode=now`, triggers an immediate heartbeat - -### `POST /hooks/agent` - -Payload: - -```json -{ - "message": "Run this", - "name": "Email", - "agentId": "hooks", - "sessionKey": "hook:email:msg-123", - "wakeMode": "now", - "deliver": true, - "channel": "last", - "to": "+15551234567", - "model": "openai/gpt-5.2-mini", - "thinking": "low", - "timeoutSeconds": 120 -} -``` - -- `message` **required** (string): The prompt or message for the agent to process. -- `name` optional (string): Human-readable name for the hook (e.g., "GitHub"), used as a prefix in session summaries. -- `agentId` optional (string): Route this hook to a specific agent. Unknown IDs fall back to the default agent. When set, the hook runs using the resolved agent's workspace and configuration. -- `sessionKey` optional (string): The key used to identify the agent's session. By default this field is rejected unless `hooks.allowRequestSessionKey=true`. -- `wakeMode` optional (`now` | `next-heartbeat`): Whether to trigger an immediate heartbeat (default `now`) or wait for the next periodic check. -- `deliver` optional (boolean): If `true`, the agent's response will be sent to the messaging channel. Defaults to `true`. Responses that are only heartbeat acknowledgments are automatically skipped. -- `channel` optional (string): The messaging channel for delivery. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `mattermost` (plugin), `signal`, `imessage`, `msteams`. Defaults to `last`. -- `to` optional (string): The recipient identifier for the channel (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack/Mattermost (plugin), conversation ID for MS Teams). Defaults to the last recipient in the main session. -- `model` optional (string): Model override (e.g., `anthropic/claude-3-5-sonnet` or an alias). Must be in the allowed model list if restricted. -- `thinking` optional (string): Thinking level override (e.g., `low`, `medium`, `high`). -- `timeoutSeconds` optional (number): Maximum duration for the agent run in seconds. - -Effect: - -- Runs an **isolated** agent turn (own session key) -- Always posts a summary into the **main** session -- If `wakeMode=now`, triggers an immediate heartbeat - -## Session key policy (breaking change) - -`/hooks/agent` payload `sessionKey` overrides are disabled by default. - -- Recommended: set a fixed `hooks.defaultSessionKey` and keep request overrides off. -- Optional: allow request overrides only when needed, and restrict prefixes. - -Recommended config: - -```json5 -{ - hooks: { - enabled: true, - token: "${OPENCLAW_HOOKS_TOKEN}", - defaultSessionKey: "hook:ingress", - allowRequestSessionKey: false, - allowedSessionKeyPrefixes: ["hook:"], - }, -} -``` - -Compatibility config (legacy behavior): - -```json5 -{ - hooks: { - enabled: true, - token: "${OPENCLAW_HOOKS_TOKEN}", - allowRequestSessionKey: true, - allowedSessionKeyPrefixes: ["hook:"], // strongly recommended - }, -} -``` - -### `POST /hooks/` (mapped) - -Custom hook names are resolved via `hooks.mappings` (see configuration). A mapping can -turn arbitrary payloads into `wake` or `agent` actions, with optional templates or -code transforms. - -Mapping options (summary): - -- `hooks.presets: ["gmail"]` enables the built-in Gmail mapping. -- `hooks.mappings` lets you define `match`, `action`, and templates in config. -- `hooks.transformsDir` + `transform.module` loads a JS/TS module for custom logic. - - `hooks.transformsDir` (if set) must stay within the transforms root under your OpenClaw config directory (typically `~/.openclaw/hooks/transforms`). - - `transform.module` must resolve within the effective transforms directory (traversal/escape paths are rejected). -- Use `match.source` to keep a generic ingest endpoint (payload-driven routing). -- TS transforms require a TS loader (e.g. `bun` or `tsx`) or precompiled `.js` at runtime. -- Set `deliver: true` + `channel`/`to` on mappings to route replies to a chat surface - (`channel` defaults to `last` and falls back to WhatsApp). -- `agentId` routes the hook to a specific agent; unknown IDs fall back to the default agent. -- `hooks.allowedAgentIds` restricts explicit `agentId` routing. Omit it (or include `*`) to allow any agent. Set `[]` to deny explicit `agentId` routing. -- `hooks.defaultSessionKey` sets the default session for hook agent runs when no explicit key is provided. -- `hooks.allowRequestSessionKey` controls whether `/hooks/agent` payloads may set `sessionKey` (default: `false`). -- `hooks.allowedSessionKeyPrefixes` optionally restricts explicit `sessionKey` values from request payloads and mappings. -- `allowUnsafeExternalContent: true` disables the external content safety wrapper for that hook - (dangerous; only for trusted internal sources). -- `openclaw webhooks gmail setup` writes `hooks.gmail` config for `openclaw webhooks gmail run`. - See [Gmail Pub/Sub](/automation/gmail-pubsub) for the full Gmail watch flow. - -## Responses - -- `200` for `/hooks/wake` -- `202` for `/hooks/agent` (async run started) -- `401` on auth failure -- `429` after repeated auth failures from the same client (check `Retry-After`) -- `400` on invalid payload -- `413` on oversized payloads - -## Examples - -```bash -curl -X POST http://127.0.0.1:18789/hooks/wake \ - -H 'Authorization: Bearer SECRET' \ - -H 'Content-Type: application/json' \ - -d '{"text":"New email received","mode":"now"}' -``` - -```bash -curl -X POST http://127.0.0.1:18789/hooks/agent \ - -H 'x-openclaw-token: SECRET' \ - -H 'Content-Type: application/json' \ - -d '{"message":"Summarize inbox","name":"Email","wakeMode":"next-heartbeat"}' -``` - -### Use a different model - -Add `model` to the agent payload (or mapping) to override the model for that run: - -```bash -curl -X POST http://127.0.0.1:18789/hooks/agent \ - -H 'x-openclaw-token: SECRET' \ - -H 'Content-Type: application/json' \ - -d '{"message":"Summarize inbox","name":"Email","model":"openai/gpt-5.2-mini"}' -``` - -If you enforce `agents.defaults.models`, make sure the override model is included there. - -```bash -curl -X POST http://127.0.0.1:18789/hooks/gmail \ - -H 'Authorization: Bearer SECRET' \ - -H 'Content-Type: application/json' \ - -d '{"source":"gmail","messages":[{"from":"Ada","subject":"Hello","snippet":"Hi"}]}' -``` - -## Security - -- Keep hook endpoints behind loopback, tailnet, or trusted reverse proxy. -- Use a dedicated hook token; do not reuse gateway auth tokens. -- Repeated auth failures are rate-limited per client address to slow brute-force attempts. -- If you use multi-agent routing, set `hooks.allowedAgentIds` to limit explicit `agentId` selection. -- Keep `hooks.allowRequestSessionKey=false` unless you require caller-selected sessions. -- If you enable request `sessionKey`, restrict `hooks.allowedSessionKeyPrefixes` (for example, `["hook:"]`). -- Avoid including sensitive raw payloads in webhook logs. -- Hook payloads are treated as untrusted and wrapped with safety boundaries by default. - If you must disable this for a specific hook, set `allowUnsafeExternalContent: true` - in that hook's mapping (dangerous). diff --git a/docs/brave-search.md b/docs/brave-search.md deleted file mode 100644 index ba18a6c552d..00000000000 --- a/docs/brave-search.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -summary: "Brave Search API setup for web_search" -read_when: - - You want to use Brave Search for web_search - - You need a BRAVE_API_KEY or plan details -title: "Brave Search" ---- - -# Brave Search API - -OpenClaw uses Brave Search as the default provider for `web_search`. - -## Get an API key - -1. Create a Brave Search API account at [https://brave.com/search/api/](https://brave.com/search/api/) -2. In the dashboard, choose the **Data for Search** plan and generate an API key. -3. Store the key in config (recommended) or set `BRAVE_API_KEY` in the Gateway environment. - -## Config example - -```json5 -{ - tools: { - web: { - search: { - provider: "brave", - apiKey: "BRAVE_API_KEY_HERE", - maxResults: 5, - timeoutSeconds: 30, - }, - }, - }, -} -``` - -## Notes - -- The Data for AI plan is **not** compatible with `web_search`. -- Brave provides a free tier plus paid plans; check the Brave API portal for current limits. - -See [Web tools](/tools/web) for the full web_search configuration. diff --git a/docs/channels/bluebubbles.md b/docs/channels/bluebubbles.md deleted file mode 100644 index 8c8267498b7..00000000000 --- a/docs/channels/bluebubbles.md +++ /dev/null @@ -1,346 +0,0 @@ ---- -summary: "iMessage via BlueBubbles macOS server (REST send/receive, typing, reactions, pairing, advanced actions)." -read_when: - - Setting up BlueBubbles channel - - Troubleshooting webhook pairing - - Configuring iMessage on macOS -title: "BlueBubbles" ---- - -# BlueBubbles (macOS REST) - -Status: bundled plugin that talks to the BlueBubbles macOS server over HTTP. **Recommended for iMessage integration** due to its richer API and easier setup compared to the legacy imsg channel. - -## Overview - -- Runs on macOS via the BlueBubbles helper app ([bluebubbles.app](https://bluebubbles.app)). -- Recommended/tested: macOS Sequoia (15). macOS Tahoe (26) works; edit is currently broken on Tahoe, and group icon updates may report success but not sync. -- 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 (`/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. - -## Quick start - -1. Install the BlueBubbles server on your Mac (follow the instructions at [bluebubbles.app/install](https://bluebubbles.app/install)). -2. In the BlueBubbles config, enable the web API and set a password. -3. Run `openclaw onboard` and select BlueBubbles, or configure manually: - - ```json5 - { - channels: { - bluebubbles: { - enabled: true, - serverUrl: "http://192.168.1.100:1234", - password: "example-password", - webhookPath: "/bluebubbles-webhook", - }, - }, - } - ``` - -4. Point BlueBubbles webhooks to your gateway (example: `https://your-gateway-host:3000/bluebubbles-webhook?password=`). -5. Start the gateway; it will register the webhook handler and start pairing. - -Security note: - -- Always set a webhook password. -- Webhook authentication is always required. OpenClaw rejects BlueBubbles webhook requests unless they include a password/guid that matches `channels.bluebubbles.password` (for example `?password=` or `x-password`), regardless of loopback/proxy topology. - -## Keeping Messages.app alive (VM / headless setups) - -Some macOS VM / always-on setups can end up with Messages.app going “idle” (incoming events stop until the app is opened/foregrounded). A simple workaround is to **poke Messages every 5 minutes** using an AppleScript + LaunchAgent. - -### 1) Save the AppleScript - -Save this as: - -- `~/Scripts/poke-messages.scpt` - -Example script (non-interactive; does not steal focus): - -```applescript -try - tell application "Messages" - if not running then - launch - end if - - -- Touch the scripting interface to keep the process responsive. - set _chatCount to (count of chats) - end tell -on error - -- Ignore transient failures (first-run prompts, locked session, etc). -end try -``` - -### 2) Install a LaunchAgent - -Save this as: - -- `~/Library/LaunchAgents/com.user.poke-messages.plist` - -```xml - - - - - Label - com.user.poke-messages - - ProgramArguments - - /bin/bash - -lc - /usr/bin/osascript "$HOME/Scripts/poke-messages.scpt" - - - RunAtLoad - - - StartInterval - 300 - - StandardOutPath - /tmp/poke-messages.log - StandardErrorPath - /tmp/poke-messages.err - - -``` - -Notes: - -- This runs **every 300 seconds** and **on login**. -- The first run may trigger macOS **Automation** prompts (`osascript` → Messages). Approve them in the same user session that runs the LaunchAgent. - -Load it: - -```bash -launchctl unload ~/Library/LaunchAgents/com.user.poke-messages.plist 2>/dev/null || true -launchctl load ~/Library/LaunchAgents/com.user.poke-messages.plist -``` - -## Onboarding - -BlueBubbles is available in the interactive setup wizard: - -``` -openclaw onboard -``` - -The wizard prompts for: - -- **Server URL** (required): BlueBubbles server address (e.g., `http://192.168.1.100:1234`) -- **Password** (required): API password from BlueBubbles Server settings -- **Webhook path** (optional): Defaults to `/bluebubbles-webhook` -- **DM policy**: pairing, allowlist, open, or disabled -- **Allow list**: Phone numbers, emails, or chat targets - -You can also add BlueBubbles via CLI: - -``` -openclaw channels add bluebubbles --http-url http://192.168.1.100:1234 --password -``` - -## Access control (DMs + groups) - -DMs: - -- Default: `channels.bluebubbles.dmPolicy = "pairing"`. -- Unknown senders receive a pairing code; messages are ignored until approved (codes expire after 1 hour). -- Approve via: - - `openclaw pairing list bluebubbles` - - `openclaw pairing approve bluebubbles ` -- Pairing is the default token exchange. Details: [Pairing](/channels/pairing) - -Groups: - -- `channels.bluebubbles.groupPolicy = open | allowlist | disabled` (default: `allowlist`). -- `channels.bluebubbles.groupAllowFrom` controls who can trigger in groups when `allowlist` is set. - -### Mention gating (groups) - -BlueBubbles supports mention gating for group chats, matching iMessage/WhatsApp behavior: - -- Uses `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) to detect mentions. -- When `requireMention` is enabled for a group, the agent only responds when mentioned. -- Control commands from authorized senders bypass mention gating. - -Per-group configuration: - -```json5 -{ - channels: { - bluebubbles: { - groupPolicy: "allowlist", - groupAllowFrom: ["+15555550123"], - groups: { - "*": { requireMention: true }, // default for all groups - "iMessage;-;chat123": { requireMention: false }, // override for specific group - }, - }, - }, -} -``` - -### Command gating - -- Control commands (e.g., `/config`, `/model`) require authorization. -- Uses `allowFrom` and `groupAllowFrom` to determine command authorization. -- Authorized senders can run control commands even without mentioning in groups. - -## Typing + read receipts - -- **Typing indicators**: Sent automatically before and during response generation. -- **Read receipts**: Controlled by `channels.bluebubbles.sendReadReceipts` (default: `true`). -- **Typing indicators**: OpenClaw sends typing start events; BlueBubbles clears typing automatically on send or timeout (manual stop via DELETE is unreliable). - -```json5 -{ - channels: { - bluebubbles: { - sendReadReceipts: false, // disable read receipts - }, - }, -} -``` - -## Advanced actions - -BlueBubbles supports advanced message actions when enabled in config: - -```json5 -{ - channels: { - bluebubbles: { - actions: { - reactions: true, // tapbacks (default: true) - edit: true, // edit sent messages (macOS 13+, broken on macOS 26 Tahoe) - unsend: true, // unsend messages (macOS 13+) - reply: true, // reply threading by message GUID - sendWithEffect: true, // message effects (slam, loud, etc.) - renameGroup: true, // rename group chats - setGroupIcon: true, // set group chat icon/photo (flaky on macOS 26 Tahoe) - addParticipant: true, // add participants to groups - removeParticipant: true, // remove participants from groups - leaveGroup: true, // leave group chats - sendAttachment: true, // send attachments/media - }, - }, - }, -} -``` - -Available actions: - -- **react**: Add/remove tapback reactions (`messageId`, `emoji`, `remove`) -- **edit**: Edit a sent message (`messageId`, `text`) -- **unsend**: Unsend a message (`messageId`) -- **reply**: Reply to a specific message (`messageId`, `text`, `to`) -- **sendWithEffect**: Send with iMessage effect (`text`, `to`, `effectId`) -- **renameGroup**: Rename a group chat (`chatGuid`, `displayName`) -- **setGroupIcon**: Set a group chat's icon/photo (`chatGuid`, `media`) — flaky on macOS 26 Tahoe (API may return success but the icon does not sync). -- **addParticipant**: Add someone to a group (`chatGuid`, `address`) -- **removeParticipant**: Remove someone from a group (`chatGuid`, `address`) -- **leaveGroup**: Leave a group chat (`chatGuid`) -- **sendAttachment**: Send media/files (`to`, `buffer`, `filename`, `asVoice`) - - Voice memos: set `asVoice: true` with **MP3** or **CAF** audio to send as an iMessage voice message. BlueBubbles converts MP3 → CAF when sending voice memos. - -### Message IDs (short vs full) - -OpenClaw may surface _short_ message IDs (e.g., `1`, `2`) to save tokens. - -- `MessageSid` / `ReplyToId` can be short IDs. -- `MessageSidFull` / `ReplyToIdFull` contain the provider full IDs. -- Short IDs are in-memory; they can expire on restart or cache eviction. -- Actions accept short or full `messageId`, but short IDs will error if no longer available. - -Use full IDs for durable automations and storage: - -- Templates: `{{MessageSidFull}}`, `{{ReplyToIdFull}}` -- Context: `MessageSidFull` / `ReplyToIdFull` in inbound payloads - -See [Configuration](/gateway/configuration) for template variables. - -## Block streaming - -Control whether responses are sent as a single message or streamed in blocks: - -```json5 -{ - channels: { - bluebubbles: { - blockStreaming: true, // enable block streaming (off by default) - }, - }, -} -``` - -## Media + limits - -- Inbound attachments are downloaded and stored in the media cache. -- Media cap via `channels.bluebubbles.mediaMaxMb` (default: 8 MB). -- Outbound text is chunked to `channels.bluebubbles.textChunkLimit` (default: 4000 chars). - -## Configuration reference - -Full configuration: [Configuration](/gateway/configuration) - -Provider options: - -- `channels.bluebubbles.enabled`: Enable/disable the channel. -- `channels.bluebubbles.serverUrl`: BlueBubbles REST API base URL. -- `channels.bluebubbles.password`: API password. -- `channels.bluebubbles.webhookPath`: Webhook endpoint path (default: `/bluebubbles-webhook`). -- `channels.bluebubbles.dmPolicy`: `pairing | allowlist | open | disabled` (default: `pairing`). -- `channels.bluebubbles.allowFrom`: DM allowlist (handles, emails, E.164 numbers, `chat_id:*`, `chat_guid:*`). -- `channels.bluebubbles.groupPolicy`: `open | allowlist | disabled` (default: `allowlist`). -- `channels.bluebubbles.groupAllowFrom`: Group sender allowlist. -- `channels.bluebubbles.groups`: Per-group config (`requireMention`, etc.). -- `channels.bluebubbles.sendReadReceipts`: Send read receipts (default: `true`). -- `channels.bluebubbles.blockStreaming`: Enable block streaming (default: `false`; required for streaming replies). -- `channels.bluebubbles.textChunkLimit`: Outbound chunk size in chars (default: 4000). -- `channels.bluebubbles.chunkMode`: `length` (default) splits only when exceeding `textChunkLimit`; `newline` splits on blank lines (paragraph boundaries) before length chunking. -- `channels.bluebubbles.mediaMaxMb`: Inbound media cap in MB (default: 8). -- `channels.bluebubbles.mediaLocalRoots`: Explicit allowlist of absolute local directories permitted for outbound local media paths. Local path sends are denied by default unless this is configured. Per-account override: `channels.bluebubbles.accounts..mediaLocalRoots`. -- `channels.bluebubbles.historyLimit`: Max group messages for context (0 disables). -- `channels.bluebubbles.dmHistoryLimit`: DM history limit. -- `channels.bluebubbles.actions`: Enable/disable specific actions. -- `channels.bluebubbles.accounts`: Multi-account configuration. - -Related global options: - -- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`). -- `messages.responsePrefix`. - -## Addressing / delivery targets - -Prefer `chat_guid` for stable routing: - -- `chat_guid:iMessage;-;+15555550123` (preferred for groups) -- `chat_id:123` -- `chat_identifier:...` -- Direct handles: `+15555550123`, `user@example.com` - - If a direct handle does not have an existing DM chat, OpenClaw will create one via `POST /api/v1/chat/new`. This requires the BlueBubbles Private API to be enabled. - -## Security - -- Webhook requests are authenticated by comparing `guid`/`password` query params or headers against `channels.bluebubbles.password`. Requests from `localhost` are also accepted. -- Keep the API password and webhook endpoint secret (treat them like credentials). -- Localhost trust means a same-host reverse proxy can unintentionally bypass the password. If you proxy the gateway, require auth at the proxy and configure `gateway.trustedProxies`. See [Gateway security](/gateway/security#reverse-proxy-configuration). -- Enable HTTPS + firewall rules on the BlueBubbles server if exposing it outside your LAN. - -## Troubleshooting - -- If typing/read events stop working, check the BlueBubbles webhook logs and verify the gateway path matches `channels.bluebubbles.webhookPath`. -- Pairing codes expire after one hour; use `openclaw pairing list bluebubbles` and `openclaw pairing approve bluebubbles `. -- Reactions require the BlueBubbles private API (`POST /api/v1/message/react`); ensure the server version exposes it. -- Edit/unsend require macOS 13+ and a compatible BlueBubbles server version. On macOS 26 (Tahoe), edit is currently broken due to private API changes. -- Group icon updates can be flaky on macOS 26 (Tahoe): the API may return success but the new icon does not sync. -- 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](/tools/plugin) guide. diff --git a/docs/channels/broadcast-groups.md b/docs/channels/broadcast-groups.md deleted file mode 100644 index 2d47d7c5943..00000000000 --- a/docs/channels/broadcast-groups.md +++ /dev/null @@ -1,442 +0,0 @@ ---- -summary: "Broadcast a WhatsApp message to multiple agents" -read_when: - - Configuring broadcast groups - - Debugging multi-agent replies in WhatsApp -status: experimental -title: "Broadcast Groups" ---- - -# Broadcast Groups - -**Status:** Experimental -**Version:** Added in 2026.1.9 - -## Overview - -Broadcast Groups enable multiple agents to process and respond to the same message simultaneously. This allows you to create specialized agent teams that work together in a single WhatsApp group or DM — all using one phone number. - -Current scope: **WhatsApp only** (web channel). - -Broadcast groups are evaluated after channel allowlists and group activation rules. In WhatsApp groups, this means broadcasts happen when OpenClaw would normally reply (for example: on mention, depending on your group settings). - -## Use Cases - -### 1. Specialized Agent Teams - -Deploy multiple agents with atomic, focused responsibilities: - -``` -Group: "Development Team" -Agents: - - CodeReviewer (reviews code snippets) - - DocumentationBot (generates docs) - - SecurityAuditor (checks for vulnerabilities) - - TestGenerator (suggests test cases) -``` - -Each agent processes the same message and provides its specialized perspective. - -### 2. Multi-Language Support - -``` -Group: "International Support" -Agents: - - Agent_EN (responds in English) - - Agent_DE (responds in German) - - Agent_ES (responds in Spanish) -``` - -### 3. Quality Assurance Workflows - -``` -Group: "Customer Support" -Agents: - - SupportAgent (provides answer) - - QAAgent (reviews quality, only responds if issues found) -``` - -### 4. Task Automation - -``` -Group: "Project Management" -Agents: - - TaskTracker (updates task database) - - TimeLogger (logs time spent) - - ReportGenerator (creates summaries) -``` - -## Configuration - -### Basic Setup - -Add a top-level `broadcast` section (next to `bindings`). Keys are WhatsApp peer ids: - -- group chats: group JID (e.g. `120363403215116621@g.us`) -- DMs: E.164 phone number (e.g. `+15551234567`) - -```json -{ - "broadcast": { - "120363403215116621@g.us": ["alfred", "baerbel", "assistant3"] - } -} -``` - -**Result:** When OpenClaw would reply in this chat, it will run all three agents. - -### Processing Strategy - -Control how agents process messages: - -#### Parallel (Default) - -All agents process simultaneously: - -```json -{ - "broadcast": { - "strategy": "parallel", - "120363403215116621@g.us": ["alfred", "baerbel"] - } -} -``` - -#### Sequential - -Agents process in order (one waits for previous to finish): - -```json -{ - "broadcast": { - "strategy": "sequential", - "120363403215116621@g.us": ["alfred", "baerbel"] - } -} -``` - -### Complete Example - -```json -{ - "agents": { - "list": [ - { - "id": "code-reviewer", - "name": "Code Reviewer", - "workspace": "/path/to/code-reviewer", - "sandbox": { "mode": "all" } - }, - { - "id": "security-auditor", - "name": "Security Auditor", - "workspace": "/path/to/security-auditor", - "sandbox": { "mode": "all" } - }, - { - "id": "docs-generator", - "name": "Documentation Generator", - "workspace": "/path/to/docs-generator", - "sandbox": { "mode": "all" } - } - ] - }, - "broadcast": { - "strategy": "parallel", - "120363403215116621@g.us": ["code-reviewer", "security-auditor", "docs-generator"], - "120363424282127706@g.us": ["support-en", "support-de"], - "+15555550123": ["assistant", "logger"] - } -} -``` - -## How It Works - -### Message Flow - -1. **Incoming message** arrives in a WhatsApp group -2. **Broadcast check**: System checks if peer ID is in `broadcast` -3. **If in broadcast list**: - - All listed agents process the message - - Each agent has its own session key and isolated context - - Agents process in parallel (default) or sequentially -4. **If not in broadcast list**: - - Normal routing applies (first matching binding) - -Note: broadcast groups do not bypass channel allowlists or group activation rules (mentions/commands/etc). They only change _which agents run_ when a message is eligible for processing. - -### Session Isolation - -Each agent in a broadcast group maintains completely separate: - -- **Session keys** (`agent:alfred:whatsapp:group:120363...` vs `agent:baerbel:whatsapp:group:120363...`) -- **Conversation history** (agent doesn't see other agents' messages) -- **Workspace** (separate sandboxes if configured) -- **Tool access** (different allow/deny lists) -- **Memory/context** (separate IDENTITY.md, SOUL.md, etc.) -- **Group context buffer** (recent group messages used for context) is shared per peer, so all broadcast agents see the same context when triggered - -This allows each agent to have: - -- Different personalities -- Different tool access (e.g., read-only vs. read-write) -- Different models (e.g., opus vs. sonnet) -- Different skills installed - -### Example: Isolated Sessions - -In group `120363403215116621@g.us` with agents `["alfred", "baerbel"]`: - -**Alfred's context:** - -``` -Session: agent:alfred:whatsapp:group:120363403215116621@g.us -History: [user message, alfred's previous responses] -Workspace: /Users/pascal/openclaw-alfred/ -Tools: read, write, exec -``` - -**Bärbel's context:** - -``` -Session: agent:baerbel:whatsapp:group:120363403215116621@g.us -History: [user message, baerbel's previous responses] -Workspace: /Users/pascal/openclaw-baerbel/ -Tools: read only -``` - -## Best Practices - -### 1. Keep Agents Focused - -Design each agent with a single, clear responsibility: - -```json -{ - "broadcast": { - "DEV_GROUP": ["formatter", "linter", "tester"] - } -} -``` - -✅ **Good:** Each agent has one job -❌ **Bad:** One generic "dev-helper" agent - -### 2. Use Descriptive Names - -Make it clear what each agent does: - -```json -{ - "agents": { - "security-scanner": { "name": "Security Scanner" }, - "code-formatter": { "name": "Code Formatter" }, - "test-generator": { "name": "Test Generator" } - } -} -``` - -### 3. Configure Different Tool Access - -Give agents only the tools they need: - -```json -{ - "agents": { - "reviewer": { - "tools": { "allow": ["read", "exec"] } // Read-only - }, - "fixer": { - "tools": { "allow": ["read", "write", "edit", "exec"] } // Read-write - } - } -} -``` - -### 4. Monitor Performance - -With many agents, consider: - -- Using `"strategy": "parallel"` (default) for speed -- Limiting broadcast groups to 5-10 agents -- Using faster models for simpler agents - -### 5. Handle Failures Gracefully - -Agents fail independently. One agent's error doesn't block others: - -``` -Message → [Agent A ✓, Agent B ✗ error, Agent C ✓] -Result: Agent A and C respond, Agent B logs error -``` - -## Compatibility - -### Providers - -Broadcast groups currently work with: - -- ✅ WhatsApp (implemented) -- 🚧 Telegram (planned) -- 🚧 Discord (planned) -- 🚧 Slack (planned) - -### Routing - -Broadcast groups work alongside existing routing: - -```json -{ - "bindings": [ - { - "match": { "channel": "whatsapp", "peer": { "kind": "group", "id": "GROUP_A" } }, - "agentId": "alfred" - } - ], - "broadcast": { - "GROUP_B": ["agent1", "agent2"] - } -} -``` - -- `GROUP_A`: Only alfred responds (normal routing) -- `GROUP_B`: agent1 AND agent2 respond (broadcast) - -**Precedence:** `broadcast` takes priority over `bindings`. - -## Troubleshooting - -### Agents Not Responding - -**Check:** - -1. Agent IDs exist in `agents.list` -2. Peer ID format is correct (e.g., `120363403215116621@g.us`) -3. Agents are not in deny lists - -**Debug:** - -```bash -tail -f ~/.openclaw/logs/gateway.log | grep broadcast -``` - -### Only One Agent Responding - -**Cause:** Peer ID might be in `bindings` but not `broadcast`. - -**Fix:** Add to broadcast config or remove from bindings. - -### Performance Issues - -**If slow with many agents:** - -- Reduce number of agents per group -- Use lighter models (sonnet instead of opus) -- Check sandbox startup time - -## Examples - -### Example 1: Code Review Team - -```json -{ - "broadcast": { - "strategy": "parallel", - "120363403215116621@g.us": [ - "code-formatter", - "security-scanner", - "test-coverage", - "docs-checker" - ] - }, - "agents": { - "list": [ - { - "id": "code-formatter", - "workspace": "~/agents/formatter", - "tools": { "allow": ["read", "write"] } - }, - { - "id": "security-scanner", - "workspace": "~/agents/security", - "tools": { "allow": ["read", "exec"] } - }, - { - "id": "test-coverage", - "workspace": "~/agents/testing", - "tools": { "allow": ["read", "exec"] } - }, - { "id": "docs-checker", "workspace": "~/agents/docs", "tools": { "allow": ["read"] } } - ] - } -} -``` - -**User sends:** Code snippet -**Responses:** - -- code-formatter: "Fixed indentation and added type hints" -- security-scanner: "⚠️ SQL injection vulnerability in line 12" -- test-coverage: "Coverage is 45%, missing tests for error cases" -- docs-checker: "Missing docstring for function `process_data`" - -### Example 2: Multi-Language Support - -```json -{ - "broadcast": { - "strategy": "sequential", - "+15555550123": ["detect-language", "translator-en", "translator-de"] - }, - "agents": { - "list": [ - { "id": "detect-language", "workspace": "~/agents/lang-detect" }, - { "id": "translator-en", "workspace": "~/agents/translate-en" }, - { "id": "translator-de", "workspace": "~/agents/translate-de" } - ] - } -} -``` - -## API Reference - -### Config Schema - -```typescript -interface OpenClawConfig { - broadcast?: { - strategy?: "parallel" | "sequential"; - [peerId: string]: string[]; - }; -} -``` - -### Fields - -- `strategy` (optional): How to process agents - - `"parallel"` (default): All agents process simultaneously - - `"sequential"`: Agents process in array order -- `[peerId]`: WhatsApp group JID, E.164 number, or other peer ID - - Value: Array of agent IDs that should process messages - -## Limitations - -1. **Max agents:** No hard limit, but 10+ agents may be slow -2. **Shared context:** Agents don't see each other's responses (by design) -3. **Message ordering:** Parallel responses may arrive in any order -4. **Rate limits:** All agents count toward WhatsApp rate limits - -## Future Enhancements - -Planned features: - -- [ ] Shared context mode (agents see each other's responses) -- [ ] Agent coordination (agents can signal each other) -- [ ] Dynamic agent selection (choose agents based on message content) -- [ ] Agent priorities (some agents respond before others) - -## See Also - -- [Multi-Agent Configuration](/tools/multi-agent-sandbox-tools) -- [Routing Configuration](/channels/channel-routing) -- [Session Management](/concepts/sessions) diff --git a/docs/channels/channel-routing.md b/docs/channels/channel-routing.md deleted file mode 100644 index 49c4a6120d6..00000000000 --- a/docs/channels/channel-routing.md +++ /dev/null @@ -1,118 +0,0 @@ ---- -summary: "Routing rules per channel (WhatsApp, Telegram, Discord, Slack) and shared context" -read_when: - - Changing channel routing or inbox behavior -title: "Channel Routing" ---- - -# Channels & routing - -OpenClaw routes replies **back to the channel where a message came from**. The -model does not choose a channel; routing is deterministic and controlled by the -host configuration. - -## Key terms - -- **Channel**: `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `webchat`. -- **AccountId**: per‑channel account instance (when supported). -- **AgentId**: an isolated workspace + session store (“brain”). -- **SessionKey**: the bucket key used to store context and control concurrency. - -## Session key shapes (examples) - -Direct messages collapse to the agent’s **main** session: - -- `agent::` (default: `agent:main:main`) - -Groups and channels remain isolated per channel: - -- Groups: `agent:::group:` -- Channels/rooms: `agent:::channel:` - -Threads: - -- Slack/Discord threads append `:thread:` to the base key. -- Telegram forum topics embed `:topic:` in the group key. - -Examples: - -- `agent:main:telegram:group:-1001234567890:topic:42` -- `agent:main:discord:channel:123456:thread:987654` - -## Routing rules (how an agent is chosen) - -Routing picks **one agent** for each inbound message: - -1. **Exact peer match** (`bindings` with `peer.kind` + `peer.id`). -2. **Parent peer match** (thread inheritance). -3. **Guild + roles match** (Discord) via `guildId` + `roles`. -4. **Guild match** (Discord) via `guildId`. -5. **Team match** (Slack) via `teamId`. -6. **Account match** (`accountId` on the channel). -7. **Channel match** (any account on that channel, `accountId: "*"`). -8. **Default agent** (`agents.list[].default`, else first list entry, fallback to `main`). - -When a binding includes multiple match fields (`peer`, `guildId`, `teamId`, `roles`), **all provided fields must match** for that binding to apply. - -The matched agent determines which workspace and session store are used. - -## Broadcast groups (run multiple agents) - -Broadcast groups let you run **multiple agents** for the same peer **when OpenClaw would normally reply** (for example: in WhatsApp groups, after mention/activation gating). - -Config: - -```json5 -{ - broadcast: { - strategy: "parallel", - "120363403215116621@g.us": ["alfred", "baerbel"], - "+15555550123": ["support", "logger"], - }, -} -``` - -See: [Broadcast Groups](/channels/broadcast-groups). - -## Config overview - -- `agents.list`: named agent definitions (workspace, model, etc.). -- `bindings`: map inbound channels/accounts/peers to agents. - -Example: - -```json5 -{ - agents: { - list: [{ id: "support", name: "Support", workspace: "~/.openclaw/workspace-support" }], - }, - bindings: [ - { match: { channel: "slack", teamId: "T123" }, agentId: "support" }, - { match: { channel: "telegram", peer: { kind: "group", id: "-100123" } }, agentId: "support" }, - ], -} -``` - -## Session storage - -Session stores live under the state directory (default `~/.openclaw`): - -- `~/.openclaw/agents//sessions/sessions.json` -- JSONL transcripts live alongside the store - -You can override the store path via `session.store` and `{agentId}` templating. - -## WebChat behavior - -WebChat attaches to the **selected agent** and defaults to the agent’s main -session. Because of this, WebChat lets you see cross‑channel context for that -agent in one place. - -## Reply context - -Inbound replies include: - -- `ReplyToId`, `ReplyToBody`, and `ReplyToSender` when available. -- Quoted context is appended to `Body` as a `[Replying to ...]` block. - -This is consistent across channels. diff --git a/docs/channels/discord.md b/docs/channels/discord.md deleted file mode 100644 index d725b5c2edd..00000000000 --- a/docs/channels/discord.md +++ /dev/null @@ -1,1044 +0,0 @@ ---- -summary: "Discord bot support status, capabilities, and configuration" -read_when: - - Working on Discord channel features -title: "Discord" ---- - -# Discord (Bot API) - -Status: ready for DMs and guild channels via the official Discord gateway. - - - - Discord DMs default to pairing mode. - - - Native command behavior and command catalog. - - - Cross-channel diagnostics and repair flow. - - - -## Quick setup - -You will need to create a new application with a bot, add the bot to your server, and pair it to OpenClaw. We recommend adding your bot to your own private server. If you don't have one yet, [create one first](https://support.discord.com/hc/en-us/articles/204849977-How-do-I-create-a-server) (choose **Create My Own > For me and my friends**). - - - - Go to the [Discord Developer Portal](https://discord.com/developers/applications) and click **New Application**. Name it something like "OpenClaw". - - Click **Bot** on the sidebar. Set the **Username** to whatever you call your OpenClaw agent. - - - - - Still on the **Bot** page, scroll down to **Privileged Gateway Intents** and enable: - - - **Message Content Intent** (required) - - **Server Members Intent** (recommended; required for role allowlists and name-to-ID matching) - - **Presence Intent** (optional; only needed for presence updates) - - - - - Scroll back up on the **Bot** page and click **Reset Token**. - - - Despite the name, this generates your first token — nothing is being "reset." - - - Copy the token and save it somewhere. This is your **Bot Token** and you will need it shortly. - - - - - Click **OAuth2** on the sidebar. You'll generate an invite URL with the right permissions to add the bot to your server. - - Scroll down to **OAuth2 URL Generator** and enable: - - - `bot` - - `applications.commands` - - A **Bot Permissions** section will appear below. Enable: - - - View Channels - - Send Messages - - Read Message History - - Embed Links - - Attach Files - - Add Reactions (optional) - - Copy the generated URL at the bottom, paste it into your browser, select your server, and click **Continue** to connect. You should now see your bot in the Discord server. - - - - - Back in the Discord app, you need to enable Developer Mode so you can copy internal IDs. - - 1. Click **User Settings** (gear icon next to your avatar) → **Advanced** → toggle on **Developer Mode** - 2. Right-click your **server icon** in the sidebar → **Copy Server ID** - 3. Right-click your **own avatar** → **Copy User ID** - - Save your **Server ID** and **User ID** alongside your Bot Token — you'll send all three to OpenClaw in the next step. - - - - - For pairing to work, Discord needs to allow your bot to DM you. Right-click your **server icon** → **Privacy Settings** → toggle on **Direct Messages**. - - This lets server members (including bots) send you DMs. Keep this enabled if you want to use Discord DMs with OpenClaw. If you only plan to use guild channels, you can disable DMs after pairing. - - - - - Your Discord bot token is a secret (like a password). Set it on the machine running OpenClaw before messaging your agent. - -```bash -openclaw config set channels.discord.token '"YOUR_BOT_TOKEN"' --json -openclaw config set channels.discord.enabled true --json -openclaw gateway -``` - - If OpenClaw is already running as a background service, use `openclaw gateway restart` instead. - - - - - - - - Chat with your OpenClaw agent on any existing channel (e.g. Telegram) and tell it. If Discord is your first channel, use the CLI / config tab instead. - - > "I already set my Discord bot token in config. Please finish Discord setup with User ID `` and Server ID ``." - - - If you prefer file-based config, set: - -```json5 -{ - channels: { - discord: { - enabled: true, - token: "YOUR_BOT_TOKEN", - }, - }, -} -``` - - Env fallback for the default account: - -```bash -DISCORD_BOT_TOKEN=... -``` - - - - - - - - Wait until the gateway is running, then DM your bot in Discord. It will respond with a pairing code. - - - - Send the pairing code to your agent on your existing channel: - - > "Approve this Discord pairing code: ``" - - - -```bash -openclaw pairing list discord -openclaw pairing approve discord -``` - - - - - Pairing codes expire after 1 hour. - - You should now be able to chat with your agent in Discord via DM. - - - - - -Token resolution is account-aware. Config token values win over env fallback. `DISCORD_BOT_TOKEN` is only used for the default account. - - -## Recommended: Set up a guild workspace - -Once DMs are working, you can set up your Discord server as a full workspace where each channel gets its own agent session with its own context. This is recommended for private servers where it's just you and your bot. - - - - This enables your agent to respond in any channel on your server, not just DMs. - - - - > "Add my Discord Server ID `` to the guild allowlist" - - - -```json5 -{ - channels: { - discord: { - groupPolicy: "allowlist", - guilds: { - YOUR_SERVER_ID: { - requireMention: true, - users: ["YOUR_USER_ID"], - }, - }, - }, - }, -} -``` - - - - - - - - By default, your agent only responds in guild channels when @mentioned. For a private server, you probably want it to respond to every message. - - - - > "Allow my agent to respond on this server without having to be @mentioned" - - - Set `requireMention: false` in your guild config: - -```json5 -{ - channels: { - discord: { - guilds: { - YOUR_SERVER_ID: { - requireMention: false, - }, - }, - }, - }, -} -``` - - - - - - - - By default, long-term memory (MEMORY.md) only loads in DM sessions. Guild channels do not auto-load MEMORY.md. - - - - > "When I ask questions in Discord channels, use memory_search or memory_get if you need long-term context from MEMORY.md." - - - If you need shared context in every channel, put the stable instructions in `AGENTS.md` or `USER.md` (they are injected for every session). Keep long-term notes in `MEMORY.md` and access them on demand with memory tools. - - - - - - -Now create some channels on your Discord server and start chatting. Your agent can see the channel name, and each channel gets its own isolated session — so you can set up `#coding`, `#home`, `#research`, or whatever fits your workflow. - -## Runtime model - -- Gateway owns the Discord connection. -- Reply routing is deterministic: Discord inbound replies back to Discord. -- By default (`session.dmScope=main`), direct chats share the agent main session (`agent:main:main`). -- Guild channels are isolated session keys (`agent::discord:channel:`). -- Group DMs are ignored by default (`channels.discord.dm.groupEnabled=false`). -- Native slash commands run in isolated command sessions (`agent::discord:slash:`), while still carrying `CommandTargetSessionKey` to the routed conversation session. - -## Forum channels - -Discord forum and media channels only accept thread posts. OpenClaw supports two ways to create them: - -- Send a message to the forum parent (`channel:`) to auto-create a thread. The thread title uses the first non-empty line of your message. -- Use `openclaw message thread create` to create a thread directly. Do not pass `--message-id` for forum channels. - -Example: send to forum parent to create a thread - -```bash -openclaw message send --channel discord --target channel: \ - --message "Topic title\nBody of the post" -``` - -Example: create a forum thread explicitly - -```bash -openclaw message thread create --channel discord --target channel: \ - --thread-name "Topic title" --message "Body of the post" -``` - -Forum parents do not accept Discord components. If you need components, send to the thread itself (`channel:`). - -## Interactive components - -OpenClaw supports Discord components v2 containers for agent messages. Use the message tool with a `components` payload. Interaction results are routed back to the agent as normal inbound messages and follow the existing Discord `replyToMode` settings. - -Supported blocks: - -- `text`, `section`, `separator`, `actions`, `media-gallery`, `file` -- Action rows allow up to 5 buttons or a single select menu -- Select types: `string`, `user`, `role`, `mentionable`, `channel` - -By default, components are single use. Set `components.reusable=true` to allow buttons, selects, and forms to be used multiple times until they expire. - -To restrict who can click a button, set `allowedUsers` on that button (Discord user IDs, tags, or `*`). When configured, unmatched users receive an ephemeral denial. - -The `/model` and `/models` slash commands open an interactive model picker with provider and model dropdowns plus a Submit step. The picker reply is ephemeral and only the invoking user can use it. - -File attachments: - -- `file` blocks must point to an attachment reference (`attachment://`) -- Provide the attachment via `media`/`path`/`filePath` (single file); use `media-gallery` for multiple files -- Use `filename` to override the upload name when it should match the attachment reference - -Modal forms: - -- Add `components.modal` with up to 5 fields -- Field types: `text`, `checkbox`, `radio`, `select`, `role-select`, `user-select` -- OpenClaw adds a trigger button automatically - -Example: - -```json5 -{ - channel: "discord", - action: "send", - to: "channel:123456789012345678", - message: "Optional fallback text", - components: { - reusable: true, - text: "Choose a path", - blocks: [ - { - type: "actions", - buttons: [ - { - label: "Approve", - style: "success", - allowedUsers: ["123456789012345678"], - }, - { label: "Decline", style: "danger" }, - ], - }, - { - type: "actions", - select: { - type: "string", - placeholder: "Pick an option", - options: [ - { label: "Option A", value: "a" }, - { label: "Option B", value: "b" }, - ], - }, - }, - ], - modal: { - title: "Details", - triggerLabel: "Open form", - fields: [ - { type: "text", label: "Requester" }, - { - type: "select", - label: "Priority", - options: [ - { label: "Low", value: "low" }, - { label: "High", value: "high" }, - ], - }, - ], - }, - }, -} -``` - -## Access control and routing - - - - `channels.discord.dmPolicy` controls DM access (legacy: `channels.discord.dm.policy`): - - - `pairing` (default) - - `allowlist` - - `open` (requires `channels.discord.allowFrom` to include `"*"`; legacy: `channels.discord.dm.allowFrom`) - - `disabled` - - If DM policy is not open, unknown users are blocked (or prompted for pairing in `pairing` mode). - - DM target format for delivery: - - - `user:` - - `<@id>` mention - - Bare numeric IDs are ambiguous and rejected unless an explicit user/channel target kind is provided. - - - - - Guild handling is controlled by `channels.discord.groupPolicy`: - - - `open` - - `allowlist` - - `disabled` - - Secure baseline when `channels.discord` exists is `allowlist`. - - `allowlist` behavior: - - - guild must match `channels.discord.guilds` (`id` preferred, slug accepted) - - optional sender allowlists: `users` (IDs or names) and `roles` (role IDs only); if either is configured, senders are allowed when they match `users` OR `roles` - - names/tags are supported for `users`, but IDs are safer; `openclaw security audit` warns when name/tag entries are used - - if a guild has `channels` configured, non-listed channels are denied - - if a guild has no `channels` block, all channels in that allowlisted guild are allowed - - Example: - -```json5 -{ - channels: { - discord: { - groupPolicy: "allowlist", - guilds: { - "123456789012345678": { - requireMention: true, - users: ["987654321098765432"], - roles: ["123456789012345678"], - channels: { - general: { allow: true }, - help: { allow: true, requireMention: true }, - }, - }, - }, - }, - }, -} -``` - - If you only set `DISCORD_BOT_TOKEN` and do not create a `channels.discord` block, runtime fallback is `groupPolicy="open"` (with a warning in logs). - - - - - Guild messages are mention-gated by default. - - Mention detection includes: - - - explicit bot mention - - configured mention patterns (`agents.list[].groupChat.mentionPatterns`, fallback `messages.groupChat.mentionPatterns`) - - implicit reply-to-bot behavior in supported cases - - `requireMention` is configured per guild/channel (`channels.discord.guilds...`). - - Group DMs: - - - default: ignored (`dm.groupEnabled=false`) - - optional allowlist via `dm.groupChannels` (channel IDs or slugs) - - - - -### Role-based agent routing - -Use `bindings[].match.roles` to route Discord guild members to different agents by role ID. Role-based bindings accept role IDs only and are evaluated after peer or parent-peer bindings and before guild-only bindings. If a binding also sets other match fields (for example `peer` + `guildId` + `roles`), all configured fields must match. - -```json5 -{ - bindings: [ - { - agentId: "opus", - match: { - channel: "discord", - guildId: "123456789012345678", - roles: ["111111111111111111"], - }, - }, - { - agentId: "sonnet", - match: { - channel: "discord", - guildId: "123456789012345678", - }, - }, - ], -} -``` - -## Developer Portal setup - - - - - 1. Discord Developer Portal -> **Applications** -> **New Application** - 2. **Bot** -> **Add Bot** - 3. Copy bot token - - - - - In **Bot -> Privileged Gateway Intents**, enable: - - - Message Content Intent - - Server Members Intent (recommended) - - Presence intent is optional and only required if you want to receive presence updates. Setting bot presence (`setPresence`) does not require enabling presence updates for members. - - - - - OAuth URL generator: - - - scopes: `bot`, `applications.commands` - - Typical baseline permissions: - - - View Channels - - Send Messages - - Read Message History - - Embed Links - - Attach Files - - Add Reactions (optional) - - Avoid `Administrator` unless explicitly needed. - - - - - Enable Discord Developer Mode, then copy: - - - server ID - - channel ID - - user ID - - Prefer numeric IDs in OpenClaw config for reliable audits and probes. - - - - -## Native commands and command auth - -- `commands.native` defaults to `"auto"` and is enabled for Discord. -- Per-channel override: `channels.discord.commands.native`. -- `commands.native=false` explicitly clears previously registered Discord native commands. -- Native command auth uses the same Discord allowlists/policies as normal message handling. -- Commands may still be visible in Discord UI for users who are not authorized; execution still enforces OpenClaw auth and returns "not authorized". - -See [Slash commands](/tools/slash-commands) for command catalog and behavior. - -Default slash command settings: - -- `ephemeral: true` - -## Feature details - - - - Discord supports reply tags in agent output: - - - `[[reply_to_current]]` - - `[[reply_to:]]` - - Controlled by `channels.discord.replyToMode`: - - - `off` (default) - - `first` - - `all` - - Note: `off` disables implicit reply threading. Explicit `[[reply_to_*]]` tags are still honored. - - Message IDs are surfaced in context/history so agents can target specific messages. - - - - - OpenClaw can stream draft replies by sending a temporary message and editing it as text arrives. - - - `channels.discord.streaming` controls preview streaming (`off` | `partial` | `block` | `progress`, default: `off`). - - `progress` is accepted for cross-channel consistency and maps to `partial` on Discord. - - `channels.discord.streamMode` is a legacy alias and is auto-migrated. - - `partial` edits a single preview message as tokens arrive. - - `block` emits draft-sized chunks (use `draftChunk` to tune size and breakpoints). - - Example: - -```json5 -{ - channels: { - discord: { - streaming: "partial", - }, - }, -} -``` - - `block` mode chunking defaults (clamped to `channels.discord.textChunkLimit`): - -```json5 -{ - channels: { - discord: { - streaming: "block", - draftChunk: { - minChars: 200, - maxChars: 800, - breakPreference: "paragraph", - }, - }, - }, -} -``` - - Preview streaming is text-only; media replies fall back to normal delivery. - - Note: preview streaming is separate from block streaming. When block streaming is explicitly - enabled for Discord, OpenClaw skips the preview stream to avoid double streaming. - - - - - Guild history context: - - - `channels.discord.historyLimit` default `20` - - fallback: `messages.groupChat.historyLimit` - - `0` disables - - DM history controls: - - - `channels.discord.dmHistoryLimit` - - `channels.discord.dms[""].historyLimit` - - Thread behavior: - - - Discord threads are routed as channel sessions - - parent thread metadata can be used for parent-session linkage - - thread config inherits parent channel config unless a thread-specific entry exists - - Channel topics are injected as **untrusted** context (not as system prompt). - - - - - Discord can bind a thread to a session target so follow-up messages in that thread keep routing to the same session (including subagent sessions). - - Commands: - - - `/focus ` bind current/new thread to a subagent/session target - - `/unfocus` remove current thread binding - - `/agents` show active runs and binding state - - `/session ttl ` inspect/update auto-unfocus TTL for focused bindings - - Config: - -```json5 -{ - session: { - threadBindings: { - enabled: true, - ttlHours: 24, - }, - }, - channels: { - discord: { - threadBindings: { - enabled: true, - ttlHours: 24, - spawnSubagentSessions: false, // opt-in - }, - }, - }, -} -``` - - Notes: - - - `session.threadBindings.*` sets global defaults. - - `channels.discord.threadBindings.*` overrides Discord behavior. - - `spawnSubagentSessions` must be true to auto-create/bind threads for `sessions_spawn({ thread: true })`. - - If thread bindings are disabled for an account, `/focus` and related thread binding operations are unavailable. - - See [Sub-agents](/tools/subagents) and [Configuration Reference](/gateway/configuration-reference). - - - - - Per-guild reaction notification mode: - - - `off` - - `own` (default) - - `all` - - `allowlist` (uses `guilds..users`) - - Reaction events are turned into system events and attached to the routed Discord session. - - - - - `ackReaction` sends an acknowledgement emoji while OpenClaw is processing an inbound message. - - Resolution order: - - - `channels.discord.accounts..ackReaction` - - `channels.discord.ackReaction` - - `messages.ackReaction` - - agent identity emoji fallback (`agents.list[].identity.emoji`, else "👀") - - Notes: - - - Discord accepts unicode emoji or custom emoji names. - - Use `""` to disable the reaction for a channel or account. - - - - - Channel-initiated config writes are enabled by default. - - This affects `/config set|unset` flows (when command features are enabled). - - Disable: - -```json5 -{ - channels: { - discord: { - configWrites: false, - }, - }, -} -``` - - - - - Route Discord gateway WebSocket traffic and startup REST lookups (application ID + allowlist resolution) through an HTTP(S) proxy with `channels.discord.proxy`. - -```json5 -{ - channels: { - discord: { - proxy: "http://proxy.example:8080", - }, - }, -} -``` - - Per-account override: - -```json5 -{ - channels: { - discord: { - accounts: { - primary: { - proxy: "http://proxy.example:8080", - }, - }, - }, - }, -} -``` - - - - - Enable PluralKit resolution to map proxied messages to system member identity: - -```json5 -{ - channels: { - discord: { - pluralkit: { - enabled: true, - token: "pk_live_...", // optional; needed for private systems - }, - }, - }, -} -``` - - Notes: - - - allowlists can use `pk:` - - member display names are matched by name/slug - - lookups use original message ID and are time-window constrained - - if lookup fails, proxied messages are treated as bot messages and dropped unless `allowBots=true` - - - - - Presence updates are applied only when you set a status or activity field. - - Status only example: - -```json5 -{ - channels: { - discord: { - status: "idle", - }, - }, -} -``` - - Activity example (custom status is the default activity type): - -```json5 -{ - channels: { - discord: { - activity: "Focus time", - activityType: 4, - }, - }, -} -``` - - Streaming example: - -```json5 -{ - channels: { - discord: { - activity: "Live coding", - activityType: 1, - activityUrl: "https://twitch.tv/openclaw", - }, - }, -} -``` - - Activity type map: - - - 0: Playing - - 1: Streaming (requires `activityUrl`) - - 2: Listening - - 3: Watching - - 4: Custom (uses the activity text as the status state; emoji is optional) - - 5: Competing - - - - - Discord supports button-based exec approvals in DMs and can optionally post approval prompts in the originating channel. - - Config path: - - - `channels.discord.execApprovals.enabled` - - `channels.discord.execApprovals.approvers` - - `channels.discord.execApprovals.target` (`dm` | `channel` | `both`, default: `dm`) - - `agentFilter`, `sessionFilter`, `cleanupAfterResolve` - - When `target` is `channel` or `both`, the approval prompt is visible in the channel. Only configured approvers can use the buttons; other users receive an ephemeral denial. Approval prompts include the command text, so only enable channel delivery in trusted channels. If the channel ID cannot be derived from the session key, OpenClaw falls back to DM delivery. - - If approvals fail with unknown approval IDs, verify approver list and feature enablement. - - Related docs: [Exec approvals](/tools/exec-approvals) - - - - -## Tools and action gates - -Discord message actions include messaging, channel admin, moderation, presence, and metadata actions. - -Core examples: - -- messaging: `sendMessage`, `readMessages`, `editMessage`, `deleteMessage`, `threadReply` -- reactions: `react`, `reactions`, `emojiList` -- moderation: `timeout`, `kick`, `ban` -- presence: `setPresence` - -Action gates live under `channels.discord.actions.*`. - -Default gate behavior: - -| Action group | Default | -| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | -| reactions, messages, threads, pins, polls, search, memberInfo, roleInfo, channelInfo, channels, voiceStatus, events, stickers, emojiUploads, stickerUploads, permissions | enabled | -| roles | disabled | -| moderation | disabled | -| presence | disabled | - -## Components v2 UI - -OpenClaw uses Discord components v2 for exec approvals and cross-context markers. Discord message actions can also accept `components` for custom UI (advanced; requires Carbon component instances), while legacy `embeds` remain available but are not recommended. - -- `channels.discord.ui.components.accentColor` sets the accent color used by Discord component containers (hex). -- Set per account with `channels.discord.accounts..ui.components.accentColor`. -- `embeds` are ignored when components v2 are present. - -Example: - -```json5 -{ - channels: { - discord: { - ui: { - components: { - accentColor: "#5865F2", - }, - }, - }, - }, -} -``` - -## Voice channels - -OpenClaw can join Discord voice channels for realtime, continuous conversations. This is separate from voice message attachments. - -Requirements: - -- Enable native commands (`commands.native` or `channels.discord.commands.native`). -- Configure `channels.discord.voice`. -- The bot needs Connect + Speak permissions in the target voice channel. - -Use the Discord-only native command `/vc join|leave|status` to control sessions. The command uses the account default agent and follows the same allowlist and group policy rules as other Discord commands. - -Auto-join example: - -```json5 -{ - channels: { - discord: { - voice: { - enabled: true, - autoJoin: [ - { - guildId: "123456789012345678", - channelId: "234567890123456789", - }, - ], - tts: { - provider: "openai", - openai: { voice: "alloy" }, - }, - }, - }, - }, -} -``` - -Notes: - -- `voice.tts` overrides `messages.tts` for voice playback only. -- Voice is enabled by default; set `channels.discord.voice.enabled=false` to disable it. - -## Voice messages - -Discord voice messages show a waveform preview and require OGG/Opus audio plus metadata. OpenClaw generates the waveform automatically, but it needs `ffmpeg` and `ffprobe` available on the gateway host to inspect and convert audio files. - -Requirements and constraints: - -- Provide a **local file path** (URLs are rejected). -- Omit text content (Discord does not allow text + voice message in the same payload). -- Any audio format is accepted; OpenClaw converts to OGG/Opus when needed. - -Example: - -```bash -message(action="send", channel="discord", target="channel:123", path="/path/to/audio.mp3", asVoice=true) -``` - -## Troubleshooting - - - - - - enable Message Content Intent - - enable Server Members Intent when you depend on user/member resolution - - restart gateway after changing intents - - - - - - - verify `groupPolicy` - - verify guild allowlist under `channels.discord.guilds` - - if guild `channels` map exists, only listed channels are allowed - - verify `requireMention` behavior and mention patterns - - Useful checks: - -```bash -openclaw doctor -openclaw channels status --probe -openclaw logs --follow -``` - - - - - Common causes: - - - `groupPolicy="allowlist"` without matching guild/channel allowlist - - `requireMention` configured in the wrong place (must be under `channels.discord.guilds` or channel entry) - - sender blocked by guild/channel `users` allowlist - - - - - `channels status --probe` permission checks only work for numeric channel IDs. - - If you use slug keys, runtime matching can still work, but probe cannot fully verify permissions. - - - - - - - DM disabled: `channels.discord.dm.enabled=false` - - DM policy disabled: `channels.discord.dmPolicy="disabled"` (legacy: `channels.discord.dm.policy`) - - awaiting pairing approval in `pairing` mode - - - - - By default bot-authored messages are ignored. - - If you set `channels.discord.allowBots=true`, use strict mention and allowlist rules to avoid loop behavior. - - - - -## Configuration reference pointers - -Primary reference: - -- [Configuration reference - Discord](/gateway/configuration-reference#discord) - -High-signal Discord fields: - -- startup/auth: `enabled`, `token`, `accounts.*`, `allowBots` -- policy: `groupPolicy`, `dm.*`, `guilds.*`, `guilds.*.channels.*` -- command: `commands.native`, `commands.useAccessGroups`, `configWrites`, `slashCommand.*` -- reply/history: `replyToMode`, `historyLimit`, `dmHistoryLimit`, `dms.*.historyLimit` -- delivery: `textChunkLimit`, `chunkMode`, `maxLinesPerMessage` -- streaming: `streaming` (legacy alias: `streamMode`), `draftChunk`, `blockStreaming`, `blockStreamingCoalesce` -- media/retry: `mediaMaxMb`, `retry` -- actions: `actions.*` -- presence: `activity`, `status`, `activityType`, `activityUrl` -- UI: `ui.components.accentColor` -- features: `pluralkit`, `execApprovals`, `intents`, `agentComponents`, `heartbeat`, `responsePrefix` - -## Safety and operations - -- Treat bot tokens as secrets (`DISCORD_BOT_TOKEN` preferred in supervised environments). -- Grant least-privilege Discord permissions. -- If command deploy/state is stale, restart gateway and re-check with `openclaw channels status --probe`. - -## Related - -- [Pairing](/channels/pairing) -- [Channel routing](/channels/channel-routing) -- [Multi-agent routing](/concepts/multi-agent) -- [Troubleshooting](/channels/troubleshooting) -- [Slash commands](/tools/slash-commands) diff --git a/docs/channels/feishu.md b/docs/channels/feishu.md deleted file mode 100644 index e92f84460d3..00000000000 --- a/docs/channels/feishu.md +++ /dev/null @@ -1,586 +0,0 @@ ---- -summary: "Feishu bot overview, features, and configuration" -read_when: - - You want to connect a Feishu/Lark bot - - You are configuring the Feishu channel -title: Feishu ---- - -# Feishu bot - -Feishu (Lark) is a team chat platform used by companies for messaging and collaboration. This plugin connects OpenClaw to a Feishu/Lark bot using the platform’s WebSocket event subscription so messages can be received without exposing a public webhook URL. - ---- - -## Plugin required - -Install the Feishu plugin: - -```bash -openclaw plugins install @openclaw/feishu -``` - -Local checkout (when running from a git repo): - -```bash -openclaw plugins install ./extensions/feishu -``` - ---- - -## Quickstart - -There are two ways to add the Feishu channel: - -### Method 1: onboarding wizard (recommended) - -If you just installed OpenClaw, run the wizard: - -```bash -openclaw onboard -``` - -The wizard guides you through: - -1. Creating a Feishu app and collecting credentials -2. Configuring app credentials in OpenClaw -3. Starting the gateway - -✅ **After configuration**, check gateway status: - -- `openclaw gateway status` -- `openclaw logs --follow` - -### Method 2: CLI setup - -If you already completed initial install, add the channel via CLI: - -```bash -openclaw channels add -``` - -Choose **Feishu**, then enter the App ID and App Secret. - -✅ **After configuration**, manage the gateway: - -- `openclaw gateway status` -- `openclaw gateway restart` -- `openclaw logs --follow` - ---- - -## Step 1: Create a Feishu app - -### 1. Open Feishu Open Platform - -Visit [Feishu Open Platform](https://open.feishu.cn/app) and sign in. - -Lark (global) tenants should use [https://open.larksuite.com/app](https://open.larksuite.com/app) and set `domain: "lark"` in the Feishu config. - -### 2. Create an app - -1. Click **Create enterprise app** -2. Fill in the app name + description -3. Choose an app icon - -![Create enterprise app](../images/feishu-step2-create-app.png) - -### 3. Copy credentials - -From **Credentials & Basic Info**, copy: - -- **App ID** (format: `cli_xxx`) -- **App Secret** - -❗ **Important:** keep the App Secret private. - -![Get credentials](../images/feishu-step3-credentials.png) - -### 4. Configure permissions - -On **Permissions**, click **Batch import** and paste: - -```json -{ - "scopes": { - "tenant": [ - "aily:file:read", - "aily:file:write", - "application:application.app_message_stats.overview:readonly", - "application:application:self_manage", - "application:bot.menu:write", - "contact:user.employee_id:readonly", - "corehr:file:download", - "event:ip_list", - "im:chat.access_event.bot_p2p_chat:read", - "im:chat.members:bot_access", - "im:message", - "im:message.group_at_msg:readonly", - "im:message.p2p_msg:readonly", - "im:message:readonly", - "im:message:send_as_bot", - "im:resource" - ], - "user": ["aily:file:read", "aily:file:write", "im:chat.access_event.bot_p2p_chat:read"] - } -} -``` - -![Configure permissions](../images/feishu-step4-permissions.png) - -### 5. Enable bot capability - -In **App Capability** > **Bot**: - -1. Enable bot capability -2. Set the bot name - -![Enable bot capability](../images/feishu-step5-bot-capability.png) - -### 6. Configure event subscription - -⚠️ **Important:** before setting event subscription, make sure: - -1. You already ran `openclaw channels add` for Feishu -2. The gateway is running (`openclaw gateway status`) - -In **Event Subscription**: - -1. Choose **Use long connection to receive events** (WebSocket) -2. Add the event: `im.message.receive_v1` - -⚠️ If the gateway is not running, the long-connection setup may fail to save. - -![Configure event subscription](../images/feishu-step6-event-subscription.png) - -### 7. Publish the app - -1. Create a version in **Version Management & Release** -2. Submit for review and publish -3. Wait for admin approval (enterprise apps usually auto-approve) - ---- - -## Step 2: Configure OpenClaw - -### Configure with the wizard (recommended) - -```bash -openclaw channels add -``` - -Choose **Feishu** and paste your App ID + App Secret. - -### Configure via config file - -Edit `~/.openclaw/openclaw.json`: - -```json5 -{ - channels: { - feishu: { - enabled: true, - dmPolicy: "pairing", - accounts: { - main: { - appId: "cli_xxx", - appSecret: "xxx", - botName: "My AI assistant", - }, - }, - }, - }, -} -``` - -If you use `connectionMode: "webhook"`, set `verificationToken`. The Feishu webhook server binds to `127.0.0.1` by default; set `webhookHost` only if you intentionally need a different bind address. - -### Configure via environment variables - -```bash -export FEISHU_APP_ID="cli_xxx" -export FEISHU_APP_SECRET="xxx" -``` - -### Lark (global) domain - -If your tenant is on Lark (international), set the domain to `lark` (or a full domain string). You can set it at `channels.feishu.domain` or per account (`channels.feishu.accounts..domain`). - -```json5 -{ - channels: { - feishu: { - domain: "lark", - accounts: { - main: { - appId: "cli_xxx", - appSecret: "xxx", - }, - }, - }, - }, -} -``` - ---- - -## Step 3: Start + test - -### 1. Start the gateway - -```bash -openclaw gateway -``` - -### 2. Send a test message - -In Feishu, find your bot and send a message. - -### 3. Approve pairing - -By default, the bot replies with a pairing code. Approve it: - -```bash -openclaw pairing approve feishu -``` - -After approval, you can chat normally. - ---- - -## Overview - -- **Feishu bot channel**: Feishu bot managed by the gateway -- **Deterministic routing**: replies always return to Feishu -- **Session isolation**: DMs share a main session; groups are isolated -- **WebSocket connection**: long connection via Feishu SDK, no public URL needed - ---- - -## Access control - -### Direct messages - -- **Default**: `dmPolicy: "pairing"` (unknown users get a pairing code) -- **Approve pairing**: - - ```bash - openclaw pairing list feishu - openclaw pairing approve feishu - ``` - -- **Allowlist mode**: set `channels.feishu.allowFrom` with allowed Open IDs - -### Group chats - -**1. Group policy** (`channels.feishu.groupPolicy`): - -- `"open"` = allow everyone in groups (default) -- `"allowlist"` = only allow `groupAllowFrom` -- `"disabled"` = disable group messages - -**2. Mention requirement** (`channels.feishu.groups..requireMention`): - -- `true` = require @mention (default) -- `false` = respond without mentions - ---- - -## Group configuration examples - -### Allow all groups, require @mention (default) - -```json5 -{ - channels: { - feishu: { - groupPolicy: "open", - // Default requireMention: true - }, - }, -} -``` - -### Allow all groups, no @mention required - -```json5 -{ - channels: { - feishu: { - groups: { - oc_xxx: { requireMention: false }, - }, - }, - }, -} -``` - -### Allow specific users in groups only - -```json5 -{ - channels: { - feishu: { - groupPolicy: "allowlist", - groupAllowFrom: ["ou_xxx", "ou_yyy"], - }, - }, -} -``` - ---- - -## Get group/user IDs - -### Group IDs (chat_id) - -Group IDs look like `oc_xxx`. - -**Method 1 (recommended)** - -1. Start the gateway and @mention the bot in the group -2. Run `openclaw logs --follow` and look for `chat_id` - -**Method 2** - -Use the Feishu API debugger to list group chats. - -### User IDs (open_id) - -User IDs look like `ou_xxx`. - -**Method 1 (recommended)** - -1. Start the gateway and DM the bot -2. Run `openclaw logs --follow` and look for `open_id` - -**Method 2** - -Check pairing requests for user Open IDs: - -```bash -openclaw pairing list feishu -``` - ---- - -## Common commands - -| Command | Description | -| --------- | ----------------- | -| `/status` | Show bot status | -| `/reset` | Reset the session | -| `/model` | Show/switch model | - -> Note: Feishu does not support native command menus yet, so commands must be sent as text. - -## Gateway management commands - -| Command | Description | -| -------------------------- | ----------------------------- | -| `openclaw gateway status` | Show gateway status | -| `openclaw gateway install` | Install/start gateway service | -| `openclaw gateway stop` | Stop gateway service | -| `openclaw gateway restart` | Restart gateway service | -| `openclaw logs --follow` | Tail gateway logs | - ---- - -## Troubleshooting - -### Bot does not respond in group chats - -1. Ensure the bot is added to the group -2. Ensure you @mention the bot (default behavior) -3. Check `groupPolicy` is not set to `"disabled"` -4. Check logs: `openclaw logs --follow` - -### Bot does not receive messages - -1. Ensure the app is published and approved -2. Ensure event subscription includes `im.message.receive_v1` -3. Ensure **long connection** is enabled -4. Ensure app permissions are complete -5. Ensure the gateway is running: `openclaw gateway status` -6. Check logs: `openclaw logs --follow` - -### App Secret leak - -1. Reset the App Secret in Feishu Open Platform -2. Update the App Secret in your config -3. Restart the gateway - -### Message send failures - -1. Ensure the app has `im:message:send_as_bot` permission -2. Ensure the app is published -3. Check logs for detailed errors - ---- - -## Advanced configuration - -### Multiple accounts - -```json5 -{ - channels: { - feishu: { - accounts: { - main: { - appId: "cli_xxx", - appSecret: "xxx", - botName: "Primary bot", - }, - backup: { - appId: "cli_yyy", - appSecret: "yyy", - botName: "Backup bot", - enabled: false, - }, - }, - }, - }, -} -``` - -### Message limits - -- `textChunkLimit`: outbound text chunk size (default: 2000 chars) -- `mediaMaxMb`: media upload/download limit (default: 30MB) - -### Streaming - -Feishu supports streaming replies via interactive cards. When enabled, the bot updates a card as it generates text. - -```json5 -{ - channels: { - feishu: { - streaming: true, // enable streaming card output (default true) - blockStreaming: true, // enable block-level streaming (default true) - }, - }, -} -``` - -Set `streaming: false` to wait for the full reply before sending. - -### Multi-agent routing - -Use `bindings` to route Feishu DMs or groups to different agents. - -```json5 -{ - agents: { - list: [ - { id: "main" }, - { - id: "clawd-fan", - workspace: "/home/user/clawd-fan", - agentDir: "/home/user/.openclaw/agents/clawd-fan/agent", - }, - { - id: "clawd-xi", - workspace: "/home/user/clawd-xi", - agentDir: "/home/user/.openclaw/agents/clawd-xi/agent", - }, - ], - }, - bindings: [ - { - agentId: "main", - match: { - channel: "feishu", - peer: { kind: "direct", id: "ou_xxx" }, - }, - }, - { - agentId: "clawd-fan", - match: { - channel: "feishu", - peer: { kind: "direct", id: "ou_yyy" }, - }, - }, - { - agentId: "clawd-xi", - match: { - channel: "feishu", - peer: { kind: "group", id: "oc_zzz" }, - }, - }, - ], -} -``` - -Routing fields: - -- `match.channel`: `"feishu"` -- `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. - ---- - -## Configuration reference - -Full configuration: [Gateway configuration](/gateway/configuration) - -Key options: - -| Setting | Description | Default | -| ------------------------------------------------- | ------------------------------- | ---------------- | -| `channels.feishu.enabled` | Enable/disable channel | `true` | -| `channels.feishu.domain` | API domain (`feishu` or `lark`) | `feishu` | -| `channels.feishu.connectionMode` | Event transport mode | `websocket` | -| `channels.feishu.verificationToken` | Required for webhook mode | - | -| `channels.feishu.webhookPath` | Webhook route path | `/feishu/events` | -| `channels.feishu.webhookHost` | Webhook bind host | `127.0.0.1` | -| `channels.feishu.webhookPort` | Webhook bind port | `3000` | -| `channels.feishu.accounts..appId` | App ID | - | -| `channels.feishu.accounts..appSecret` | App Secret | - | -| `channels.feishu.accounts..domain` | Per-account API domain override | `feishu` | -| `channels.feishu.dmPolicy` | DM policy | `pairing` | -| `channels.feishu.allowFrom` | DM allowlist (open_id list) | - | -| `channels.feishu.groupPolicy` | Group policy | `open` | -| `channels.feishu.groupAllowFrom` | Group allowlist | - | -| `channels.feishu.groups..requireMention` | Require @mention | `true` | -| `channels.feishu.groups..enabled` | Enable group | `true` | -| `channels.feishu.textChunkLimit` | Message chunk size | `2000` | -| `channels.feishu.mediaMaxMb` | Media size limit | `30` | -| `channels.feishu.streaming` | Enable streaming card output | `true` | -| `channels.feishu.blockStreaming` | Enable block streaming | `true` | - ---- - -## dmPolicy reference - -| Value | Behavior | -| ------------- | --------------------------------------------------------------- | -| `"pairing"` | **Default.** Unknown users get a pairing code; must be approved | -| `"allowlist"` | Only users in `allowFrom` can chat | -| `"open"` | Allow all users (requires `"*"` in allowFrom) | -| `"disabled"` | Disable DMs | - ---- - -## Supported message types - -### Receive - -- ✅ Text -- ✅ Rich text (post) -- ✅ Images -- ✅ Files -- ✅ Audio -- ✅ Video -- ✅ Stickers - -### Send - -- ✅ Text -- ✅ Images -- ✅ Files -- ✅ Audio -- ⚠️ Rich text (partial support) diff --git a/docs/channels/googlechat.md b/docs/channels/googlechat.md deleted file mode 100644 index 818a8288f5d..00000000000 --- a/docs/channels/googlechat.md +++ /dev/null @@ -1,253 +0,0 @@ ---- -summary: "Google Chat app support status, capabilities, and configuration" -read_when: - - Working on Google Chat channel features -title: "Google Chat" ---- - -# Google Chat (Chat API) - -Status: ready for DMs + spaces via Google Chat API webhooks (HTTP only). - -## Quick setup (beginner) - -1. Create a Google Cloud project and enable the **Google Chat API**. - - Go to: [Google Chat API Credentials](https://console.cloud.google.com/apis/api/chat.googleapis.com/credentials) - - Enable the API if it is not already enabled. -2. Create a **Service Account**: - - Press **Create Credentials** > **Service Account**. - - Name it whatever you want (e.g., `openclaw-chat`). - - Leave permissions blank (press **Continue**). - - Leave principals with access blank (press **Done**). -3. Create and download the **JSON Key**: - - In the list of service accounts, click on the one you just created. - - Go to the **Keys** tab. - - Click **Add Key** > **Create new key**. - - Select **JSON** and press **Create**. -4. Store the downloaded JSON file on your gateway host (e.g., `~/.openclaw/googlechat-service-account.json`). -5. Create a Google Chat app in the [Google Cloud Console Chat Configuration](https://console.cloud.google.com/apis/api/chat.googleapis.com/hangouts-chat): - - Fill in the **Application info**: - - **App name**: (e.g. `OpenClaw`) - - **Avatar URL**: (e.g. `https://openclaw.ai/logo.png`) - - **Description**: (e.g. `Personal AI Assistant`) - - Enable **Interactive features**. - - Under **Functionality**, check **Join spaces and group conversations**. - - Under **Connection settings**, select **HTTP endpoint URL**. - - Under **Triggers**, select **Use a common HTTP endpoint URL for all triggers** and set it to your gateway's public URL followed by `/googlechat`. - - _Tip: Run `openclaw status` to find your gateway's public URL._ - - Under **Visibility**, check **Make this Chat app available to specific people and groups in <Your Domain>**. - - Enter your email address (e.g. `user@example.com`) in the text box. - - Click **Save** at the bottom. -6. **Enable the app status**: - - After saving, **refresh the page**. - - Look for the **App status** section (usually near the top or bottom after saving). - - Change the status to **Live - available to users**. - - Click **Save** again. -7. Configure OpenClaw with the service account path + webhook audience: - - Env: `GOOGLE_CHAT_SERVICE_ACCOUNT_FILE=/path/to/service-account.json` - - Or config: `channels.googlechat.serviceAccountFile: "/path/to/service-account.json"`. -8. Set the webhook audience type + value (matches your Chat app config). -9. Start the gateway. Google Chat will POST to your webhook path. - -## Add to Google Chat - -Once the gateway is running and your email is added to the visibility list: - -1. Go to [Google Chat](https://chat.google.com/). -2. Click the **+** (plus) icon next to **Direct Messages**. -3. In the search bar (where you usually add people), type the **App name** you configured in the Google Cloud Console. - - **Note**: The bot will _not_ appear in the "Marketplace" browse list because it is a private app. You must search for it by name. -4. Select your bot from the results. -5. Click **Add** or **Chat** to start a 1:1 conversation. -6. Send "Hello" to trigger the assistant! - -## Public URL (Webhook-only) - -Google Chat webhooks require a public HTTPS endpoint. For security, **only expose the `/googlechat` path** to the internet. Keep the OpenClaw dashboard and other sensitive endpoints on your private network. - -### Option A: Tailscale Funnel (Recommended) - -Use Tailscale Serve for the private dashboard and Funnel for the public webhook path. This keeps `/` private while exposing only `/googlechat`. - -1. **Check what address your gateway is bound to:** - - ```bash - ss -tlnp | grep 18789 - ``` - - Note the IP address (e.g., `127.0.0.1`, `0.0.0.0`, or your Tailscale IP like `100.x.x.x`). - -2. **Expose the dashboard to the tailnet only (port 8443):** - - ```bash - # If bound to localhost (127.0.0.1 or 0.0.0.0): - tailscale serve --bg --https 8443 http://127.0.0.1:18789 - - # If bound to Tailscale IP only (e.g., 100.106.161.80): - tailscale serve --bg --https 8443 http://100.106.161.80:18789 - ``` - -3. **Expose only the webhook path publicly:** - - ```bash - # If bound to localhost (127.0.0.1 or 0.0.0.0): - tailscale funnel --bg --set-path /googlechat http://127.0.0.1:18789/googlechat - - # If bound to Tailscale IP only (e.g., 100.106.161.80): - tailscale funnel --bg --set-path /googlechat http://100.106.161.80:18789/googlechat - ``` - -4. **Authorize the node for Funnel access:** - If prompted, visit the authorization URL shown in the output to enable Funnel for this node in your tailnet policy. - -5. **Verify the configuration:** - - ```bash - tailscale serve status - tailscale funnel status - ``` - -Your public webhook URL will be: -`https://..ts.net/googlechat` - -Your private dashboard stays tailnet-only: -`https://..ts.net:8443/` - -Use the public URL (without `:8443`) in the Google Chat app config. - -> Note: This configuration persists across reboots. To remove it later, run `tailscale funnel reset` and `tailscale serve reset`. - -### Option B: Reverse Proxy (Caddy) - -If you use a reverse proxy like Caddy, only proxy the specific path: - -```caddy -your-domain.com { - reverse_proxy /googlechat* localhost:18789 -} -``` - -With this config, any request to `your-domain.com/` will be ignored or returned as 404, while `your-domain.com/googlechat` is safely routed to OpenClaw. - -### Option C: Cloudflare Tunnel - -Configure your tunnel's ingress rules to only route the webhook path: - -- **Path**: `/googlechat` -> `http://localhost:18789/googlechat` -- **Default Rule**: HTTP 404 (Not Found) - -## How it works - -1. Google Chat sends webhook POSTs to the gateway. Each request includes an `Authorization: Bearer ` header. -2. OpenClaw verifies the token against the configured `audienceType` + `audience`: - - `audienceType: "app-url"` → audience is your HTTPS webhook URL. - - `audienceType: "project-number"` → audience is the Cloud project number. -3. Messages are routed by space: - - DMs use session key `agent::googlechat:dm:`. - - Spaces use session key `agent::googlechat:group:`. -4. DM access is pairing by default. Unknown senders receive a pairing code; approve with: - - `openclaw pairing approve googlechat ` -5. Group spaces require @-mention by default. Use `botUser` if mention detection needs the app’s user name. - -## Targets - -Use these identifiers for delivery and allowlists: - -- Direct messages: `users/` (recommended) or raw email `name@example.com` (mutable principal). -- Deprecated: `users/` is treated as a user id, not an email allowlist. -- Spaces: `spaces/`. - -## Config highlights - -```json5 -{ - channels: { - googlechat: { - enabled: true, - serviceAccountFile: "/path/to/service-account.json", - audienceType: "app-url", - audience: "https://gateway.example.com/googlechat", - webhookPath: "/googlechat", - botUser: "users/1234567890", // optional; helps mention detection - dm: { - policy: "pairing", - allowFrom: ["users/1234567890", "name@example.com"], - }, - groupPolicy: "allowlist", - groups: { - "spaces/AAAA": { - allow: true, - requireMention: true, - users: ["users/1234567890"], - systemPrompt: "Short answers only.", - }, - }, - actions: { reactions: true }, - typingIndicator: "message", - mediaMaxMb: 20, - }, - }, -} -``` - -Notes: - -- Service account credentials can also be passed inline with `serviceAccount` (JSON string). -- Default webhook path is `/googlechat` if `webhookPath` isn’t set. -- Reactions are available via the `reactions` tool and `channels action` when `actions.reactions` is enabled. -- `typingIndicator` supports `none`, `message` (default), and `reaction` (reaction requires user OAuth). -- Attachments are downloaded through the Chat API and stored in the media pipeline (size capped by `mediaMaxMb`). - -## Troubleshooting - -### 405 Method Not Allowed - -If Google Cloud Logs Explorer shows errors like: - -``` -status code: 405, reason phrase: HTTP error response: HTTP/1.1 405 Method Not Allowed -``` - -This means the webhook handler isn't registered. Common causes: - -1. **Channel not configured**: The `channels.googlechat` section is missing from your config. Verify with: - - ```bash - openclaw config get channels.googlechat - ``` - - If it returns "Config path not found", add the configuration (see [Config highlights](#config-highlights)). - -2. **Plugin not enabled**: Check plugin status: - - ```bash - openclaw plugins list | grep googlechat - ``` - - If it shows "disabled", add `plugins.entries.googlechat.enabled: true` to your config. - -3. **Gateway not restarted**: After adding config, restart the gateway: - - ```bash - openclaw gateway restart - ``` - -Verify the channel is running: - -```bash -openclaw channels status -# Should show: Google Chat default: enabled, configured, ... -``` - -### Other issues - -- Check `openclaw channels status --probe` for auth errors or missing audience config. -- If no messages arrive, confirm the Chat app's webhook URL + event subscriptions. -- If mention gating blocks replies, set `botUser` to the app's user resource name and verify `requireMention`. -- Use `openclaw logs --follow` while sending a test message to see if requests reach the gateway. - -Related docs: - -- [Gateway configuration](/gateway/configuration) -- [Security](/gateway/security) -- [Reactions](/tools/reactions) diff --git a/docs/channels/grammy.md b/docs/channels/grammy.md deleted file mode 100644 index 25c197116f6..00000000000 --- a/docs/channels/grammy.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -summary: "Telegram Bot API integration via grammY with setup notes" -read_when: - - Working on Telegram or grammY pathways -title: grammY ---- - -# grammY Integration (Telegram Bot API) - -# Why grammY - -- TS-first Bot API client with built-in long-poll + webhook helpers, middleware, error handling, rate limiter. -- Cleaner media helpers than hand-rolling fetch + FormData; supports all Bot API methods. -- Extensible: proxy support via custom fetch, session middleware (optional), type-safe context. - -# What we shipped - -- **Single client path:** fetch-based implementation removed; grammY is now the sole Telegram client (send + gateway) with the grammY throttler enabled by default. -- **Gateway:** `monitorTelegramProvider` builds a grammY `Bot`, wires mention/allowlist gating, media download via `getFile`/`download`, and delivers replies with `sendMessage/sendPhoto/sendVideo/sendAudio/sendDocument`. Supports long-poll or webhook via `webhookCallback`. -- **Proxy:** optional `channels.telegram.proxy` uses `undici.ProxyAgent` through grammY’s `client.baseFetch`. -- **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `channels.telegram.webhookUrl` + `channels.telegram.webhookSecret` are set (otherwise it long-polls). -- **Sessions:** direct chats collapse into the agent main session (`agent::`); groups use `agent::telegram:group:`; replies route back to the same channel. -- **Config knobs:** `channels.telegram.botToken`, `channels.telegram.dmPolicy`, `channels.telegram.groups` (allowlist + mention defaults), `channels.telegram.allowFrom`, `channels.telegram.groupAllowFrom`, `channels.telegram.groupPolicy`, `channels.telegram.mediaMaxMb`, `channels.telegram.linkPreview`, `channels.telegram.proxy`, `channels.telegram.webhookSecret`, `channels.telegram.webhookUrl`, `channels.telegram.webhookHost`. -- **Live stream preview:** `channels.telegram.streaming` (`off | partial | block | progress`) sends a temporary message and updates it with `editMessageText`. This is separate from channel block streaming. -- **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome. - -Open questions - -- Optional grammY plugins (throttler) if we hit Bot API 429s. -- Add more structured media tests (stickers, voice notes). -- Make webhook listen port configurable (currently fixed to 8787 unless wired through the gateway). diff --git a/docs/channels/group-messages.md b/docs/channels/group-messages.md deleted file mode 100644 index e6a00ab5c5e..00000000000 --- a/docs/channels/group-messages.md +++ /dev/null @@ -1,84 +0,0 @@ ---- -summary: "Behavior and config for WhatsApp group message handling (mentionPatterns are shared across surfaces)" -read_when: - - Changing group message rules or mentions -title: "Group Messages" ---- - -# Group messages (WhatsApp web channel) - -Goal: let Clawd sit in WhatsApp groups, wake up only when pinged, and keep that thread separate from the personal DM session. - -Note: `agents.list[].groupChat.mentionPatterns` is now used by Telegram/Discord/Slack/iMessage as well; this doc focuses on WhatsApp-specific behavior. For multi-agent setups, set `agents.list[].groupChat.mentionPatterns` per agent (or use `messages.groupChat.mentionPatterns` as a global fallback). - -## What’s implemented (2025-12-03) - -- Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bot’s E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. Defaults can be set in config (`channels.whatsapp.groups`) and overridden per group via `/activation`. When `channels.whatsapp.groups` is set, it also acts as a group allowlist (include `"*"` to allow all). -- Group policy: `channels.whatsapp.groupPolicy` controls whether group messages are accepted (`open|disabled|allowlist`). `allowlist` uses `channels.whatsapp.groupAllowFrom` (fallback: explicit `channels.whatsapp.allowFrom`). Default is `allowlist` (blocked until you add senders). -- Per-group sessions: session keys look like `agent::whatsapp:group:` so commands such as `/verbose on` or `/think high` (sent as standalone messages) are scoped to that group; personal DM state is untouched. Heartbeats are skipped for group threads. -- Context injection: **pending-only** group messages (default 50) that _did not_ trigger a run are prefixed under `[Chat messages since your last reply - for context]`, with the triggering line under `[Current message - respond to this]`. Messages already in the session are not re-injected. -- Sender surfacing: every group batch now ends with `[from: Sender Name (+E164)]` so Pi knows who is speaking. -- Ephemeral/view-once: we unwrap those before extracting text/mentions, so pings inside them still trigger. -- Group system prompt: on the first turn of a group session (and whenever `/activation` changes the mode) we inject a short blurb into the system prompt like `You are replying inside the WhatsApp group "". Group members: Alice (+44...), Bob (+43...), … Activation: trigger-only … Address the specific sender noted in the message context.` If metadata isn’t available we still tell the agent it’s a group chat. - -## Config example (WhatsApp) - -Add a `groupChat` block to `~/.openclaw/openclaw.json` so display-name pings work even when WhatsApp strips the visual `@` in the text body: - -```json5 -{ - channels: { - whatsapp: { - groups: { - "*": { requireMention: true }, - }, - }, - }, - agents: { - list: [ - { - id: "main", - groupChat: { - historyLimit: 50, - mentionPatterns: ["@?openclaw", "\\+?15555550123"], - }, - }, - ], - }, -} -``` - -Notes: - -- The regexes are case-insensitive; they cover a display-name ping like `@openclaw` and the raw number with or without `+`/spaces. -- WhatsApp still sends canonical mentions via `mentionedJids` when someone taps the contact, so the number fallback is rarely needed but is a useful safety net. - -### Activation command (owner-only) - -Use the group chat command: - -- `/activation mention` -- `/activation always` - -Only the owner number (from `channels.whatsapp.allowFrom`, or the bot’s own E.164 when unset) can change this. Send `/status` as a standalone message in the group to see the current activation mode. - -## How to use - -1. Add your WhatsApp account (the one running OpenClaw) to the group. -2. Say `@openclaw …` (or include the number). Only allowlisted senders can trigger it unless you set `groupPolicy: "open"`. -3. The agent prompt will include recent group context plus the trailing `[from: …]` marker so it can address the right person. -4. Session-level directives (`/verbose on`, `/think high`, `/new` or `/reset`, `/compact`) apply only to that group’s session; send them as standalone messages so they register. Your personal DM session remains independent. - -## Testing / verification - -- Manual smoke: - - Send an `@openclaw` ping in the group and confirm a reply that references the sender name. - - Send a second ping and verify the history block is included then cleared on the next turn. -- Check gateway logs (run with `--verbose`) to see `inbound web message` entries showing `from: ` and the `[from: …]` suffix. - -## Known considerations - -- Heartbeats are intentionally skipped for groups to avoid noisy broadcasts. -- Echo suppression uses the combined batch string; if you send identical text twice without mentions, only the first will get a response. -- Session store entries will appear as `agent::whatsapp:group:` in the session store (`~/.openclaw/agents//sessions/sessions.json` by default); a missing entry just means the group hasn’t triggered a run yet. -- Typing indicators in groups follow `agents.defaults.typingMode` (default: `message` when unmentioned). diff --git a/docs/channels/groups.md b/docs/channels/groups.md deleted file mode 100644 index 6bd278846c5..00000000000 --- a/docs/channels/groups.md +++ /dev/null @@ -1,374 +0,0 @@ ---- -summary: "Group chat behavior across surfaces (WhatsApp/Telegram/Discord/Slack/Signal/iMessage/Microsoft Teams)" -read_when: - - Changing group chat behavior or mention gating -title: "Groups" ---- - -# Groups - -OpenClaw treats group chats consistently across surfaces: WhatsApp, Telegram, Discord, Slack, Signal, iMessage, Microsoft Teams. - -## Beginner intro (2 minutes) - -OpenClaw “lives” on your own messaging accounts. There is no separate WhatsApp bot user. -If **you** are in a group, OpenClaw can see that group and respond there. - -Default behavior: - -- Groups are restricted (`groupPolicy: "allowlist"`). -- Replies require a mention unless you explicitly disable mention gating. - -Translation: allowlisted senders can trigger OpenClaw by mentioning it. - -> TL;DR -> -> - **DM access** is controlled by `*.allowFrom`. -> - **Group access** is controlled by `*.groupPolicy` + allowlists (`*.groups`, `*.groupAllowFrom`). -> - **Reply triggering** is controlled by mention gating (`requireMention`, `/activation`). - -Quick flow (what happens to a group message): - -``` -groupPolicy? disabled -> drop -groupPolicy? allowlist -> group allowed? no -> drop -requireMention? yes -> mentioned? no -> store for context only -otherwise -> reply -``` - -![Group message flow](/images/groups-flow.svg) - -If you want... - -| Goal | What to set | -| -------------------------------------------- | ---------------------------------------------------------- | -| Allow all groups but only reply on @mentions | `groups: { "*": { requireMention: true } }` | -| Disable all group replies | `groupPolicy: "disabled"` | -| Only specific groups | `groups: { "": { ... } }` (no `"*"` key) | -| Only you can trigger in groups | `groupPolicy: "allowlist"`, `groupAllowFrom: ["+1555..."]` | - -## Session keys - -- Group sessions use `agent:::group:` session keys (rooms/channels use `agent:::channel:`). -- Telegram forum topics add `:topic:` to the group id so each topic has its own session. -- Direct chats use the main session (or per-sender if configured). -- Heartbeats are skipped for group sessions. - -## Pattern: personal DMs + public groups (single agent) - -Yes — this works well if your “personal” traffic is **DMs** and your “public” traffic is **groups**. - -Why: in single-agent mode, DMs typically land in the **main** session key (`agent:main:main`), while groups always use **non-main** session keys (`agent:main::group:`). If you enable sandboxing with `mode: "non-main"`, those group sessions run in Docker while your main DM session stays on-host. - -This gives you one agent “brain” (shared workspace + memory), but two execution postures: - -- **DMs**: full tools (host) -- **Groups**: sandbox + restricted tools (Docker) - -> If you need truly separate workspaces/personas (“personal” and “public” must never mix), use a second agent + bindings. See [Multi-Agent Routing](/concepts/multi-agent). - -Example (DMs on host, groups sandboxed + messaging-only tools): - -```json5 -{ - agents: { - defaults: { - sandbox: { - mode: "non-main", // groups/channels are non-main -> sandboxed - scope: "session", // strongest isolation (one container per group/channel) - workspaceAccess: "none", - }, - }, - }, - tools: { - sandbox: { - tools: { - // If allow is non-empty, everything else is blocked (deny still wins). - allow: ["group:messaging", "group:sessions"], - deny: ["group:runtime", "group:fs", "group:ui", "nodes", "cron", "gateway"], - }, - }, - }, -} -``` - -Want “groups can only see folder X” instead of “no host access”? Keep `workspaceAccess: "none"` and mount only allowlisted paths into the sandbox: - -```json5 -{ - agents: { - defaults: { - sandbox: { - mode: "non-main", - scope: "session", - workspaceAccess: "none", - docker: { - binds: [ - // hostPath:containerPath:mode - "/home/user/FriendsShared:/data:ro", - ], - }, - }, - }, - }, -} -``` - -Related: - -- Configuration keys and defaults: [Gateway configuration](/gateway/configuration#agentsdefaultssandbox) -- Debugging why a tool is blocked: [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated) -- Bind mounts details: [Sandboxing](/gateway/sandboxing#custom-bind-mounts) - -## Display labels - -- UI labels use `displayName` when available, formatted as `:`. -- `#room` is reserved for rooms/channels; group chats use `g-` (lowercase, spaces -> `-`, keep `#@+._-`). - -## Group policy - -Control how group/room messages are handled per channel: - -```json5 -{ - channels: { - whatsapp: { - groupPolicy: "disabled", // "open" | "disabled" | "allowlist" - groupAllowFrom: ["+15551234567"], - }, - telegram: { - groupPolicy: "disabled", - groupAllowFrom: ["123456789"], // numeric Telegram user id (wizard can resolve @username) - }, - signal: { - groupPolicy: "disabled", - groupAllowFrom: ["+15551234567"], - }, - imessage: { - groupPolicy: "disabled", - groupAllowFrom: ["chat_id:123"], - }, - msteams: { - groupPolicy: "disabled", - groupAllowFrom: ["user@org.com"], - }, - discord: { - groupPolicy: "allowlist", - guilds: { - GUILD_ID: { channels: { help: { allow: true } } }, - }, - }, - slack: { - groupPolicy: "allowlist", - channels: { "#general": { allow: true } }, - }, - matrix: { - groupPolicy: "allowlist", - groupAllowFrom: ["@owner:example.org"], - groups: { - "!roomId:example.org": { allow: true }, - "#alias:example.org": { allow: true }, - }, - }, - }, -} -``` - -| Policy | Behavior | -| ------------- | ------------------------------------------------------------ | -| `"open"` | Groups bypass allowlists; mention-gating still applies. | -| `"disabled"` | Block all group messages entirely. | -| `"allowlist"` | Only allow groups/rooms that match the configured allowlist. | - -Notes: - -- `groupPolicy` is separate from mention-gating (which requires @mentions). -- WhatsApp/Telegram/Signal/iMessage/Microsoft Teams: use `groupAllowFrom` (fallback: explicit `allowFrom`). -- Discord: allowlist uses `channels.discord.guilds..channels`. -- Slack: allowlist uses `channels.slack.channels`. -- Matrix: allowlist uses `channels.matrix.groups` (room IDs, aliases, or names). Use `channels.matrix.groupAllowFrom` to restrict senders; per-room `users` allowlists are also supported. -- Group DMs are controlled separately (`channels.discord.dm.*`, `channels.slack.dm.*`). -- Telegram allowlist can match user IDs (`"123456789"`, `"telegram:123456789"`, `"tg:123456789"`) or usernames (`"@alice"` or `"alice"`); prefixes are case-insensitive. -- Default is `groupPolicy: "allowlist"`; if your group allowlist is empty, group messages are blocked. - -Quick mental model (evaluation order for group messages): - -1. `groupPolicy` (open/disabled/allowlist) -2. group allowlists (`*.groups`, `*.groupAllowFrom`, channel-specific allowlist) -3. mention gating (`requireMention`, `/activation`) - -## Mention gating (default) - -Group messages require a mention unless overridden per group. Defaults live per subsystem under `*.groups."*"`. - -Replying to a bot message counts as an implicit mention (when the channel supports reply metadata). This applies to Telegram, WhatsApp, Slack, Discord, and Microsoft Teams. - -```json5 -{ - channels: { - whatsapp: { - groups: { - "*": { requireMention: true }, - "123@g.us": { requireMention: false }, - }, - }, - telegram: { - groups: { - "*": { requireMention: true }, - "123456789": { requireMention: false }, - }, - }, - imessage: { - groups: { - "*": { requireMention: true }, - "123": { requireMention: false }, - }, - }, - }, - agents: { - list: [ - { - id: "main", - groupChat: { - mentionPatterns: ["@openclaw", "openclaw", "\\+15555550123"], - historyLimit: 50, - }, - }, - ], - }, -} -``` - -Notes: - -- `mentionPatterns` are case-insensitive regexes. -- Surfaces that provide explicit mentions still pass; patterns are a fallback. -- Per-agent override: `agents.list[].groupChat.mentionPatterns` (useful when multiple agents share a group). -- Mention gating is only enforced when mention detection is possible (native mentions or `mentionPatterns` are configured). -- Discord defaults live in `channels.discord.guilds."*"` (overridable per guild/channel). -- Group history context is wrapped uniformly across channels and is **pending-only** (messages skipped due to mention gating); use `messages.groupChat.historyLimit` for the global default and `channels..historyLimit` (or `channels..accounts.*.historyLimit`) for overrides. Set `0` to disable. - -## Group/channel tool restrictions (optional) - -Some channel configs support restricting which tools are available **inside a specific group/room/channel**. - -- `tools`: allow/deny tools for the whole group. -- `toolsBySender`: per-sender overrides within the group (keys are sender IDs/usernames/emails/phone numbers depending on the channel). Use `"*"` as a wildcard. - -Resolution order (most specific wins): - -1. group/channel `toolsBySender` match -2. group/channel `tools` -3. default (`"*"`) `toolsBySender` match -4. default (`"*"`) `tools` - -Example (Telegram): - -```json5 -{ - channels: { - telegram: { - groups: { - "*": { tools: { deny: ["exec"] } }, - "-1001234567890": { - tools: { deny: ["exec", "read", "write"] }, - toolsBySender: { - "123456789": { alsoAllow: ["exec"] }, - }, - }, - }, - }, - }, -} -``` - -Notes: - -- Group/channel tool restrictions are applied in addition to global/agent tool policy (deny still wins). -- Some channels use different nesting for rooms/channels (e.g., Discord `guilds.*.channels.*`, Slack `channels.*`, MS Teams `teams.*.channels.*`). - -## Group allowlists - -When `channels.whatsapp.groups`, `channels.telegram.groups`, or `channels.imessage.groups` is configured, the keys act as a group allowlist. Use `"*"` to allow all groups while still setting default mention behavior. - -Common intents (copy/paste): - -1. Disable all group replies - -```json5 -{ - channels: { whatsapp: { groupPolicy: "disabled" } }, -} -``` - -2. Allow only specific groups (WhatsApp) - -```json5 -{ - channels: { - whatsapp: { - groups: { - "123@g.us": { requireMention: true }, - "456@g.us": { requireMention: false }, - }, - }, - }, -} -``` - -3. Allow all groups but require mention (explicit) - -```json5 -{ - channels: { - whatsapp: { - groups: { "*": { requireMention: true } }, - }, - }, -} -``` - -4. Only the owner can trigger in groups (WhatsApp) - -```json5 -{ - channels: { - whatsapp: { - groupPolicy: "allowlist", - groupAllowFrom: ["+15551234567"], - groups: { "*": { requireMention: true } }, - }, - }, -} -``` - -## Activation (owner-only) - -Group owners can toggle per-group activation: - -- `/activation mention` -- `/activation always` - -Owner is determined by `channels.whatsapp.allowFrom` (or the bot’s self E.164 when unset). Send the command as a standalone message. Other surfaces currently ignore `/activation`. - -## Context fields - -Group inbound payloads set: - -- `ChatType=group` -- `GroupSubject` (if known) -- `GroupMembers` (if known) -- `WasMentioned` (mention gating result) -- Telegram forum topics also include `MessageThreadId` and `IsForum`. - -The agent system prompt includes a group intro on the first turn of a new group session. It reminds the model to respond like a human, avoid Markdown tables, and avoid typing literal `\n` sequences. - -## iMessage specifics - -- Prefer `chat_id:` when routing or allowlisting. -- List chats: `imsg chats --limit 20`. -- Group replies always go back to the same `chat_id`. - -## WhatsApp specifics - -See [Group messages](/channels/group-messages) for WhatsApp-only behavior (history injection, mention handling details). diff --git a/docs/channels/imessage.md b/docs/channels/imessage.md deleted file mode 100644 index d7a1b633597..00000000000 --- a/docs/channels/imessage.md +++ /dev/null @@ -1,366 +0,0 @@ ---- -summary: "Legacy iMessage support via imsg (JSON-RPC over stdio). New setups should use BlueBubbles." -read_when: - - Setting up iMessage support - - Debugging iMessage send/receive -title: "iMessage" ---- - -# iMessage (legacy: imsg) - - -For new iMessage deployments, use BlueBubbles. - -The `imsg` integration is legacy and may be removed in a future release. - - -Status: legacy external CLI integration. Gateway spawns `imsg rpc` and communicates over JSON-RPC on stdio (no separate daemon/port). - - - - Preferred iMessage path for new setups. - - - iMessage DMs default to pairing mode. - - - Full iMessage field reference. - - - -## Quick setup - - - - - - -```bash -brew install steipete/tap/imsg -imsg rpc --help -``` - - - - - -```json5 -{ - channels: { - imessage: { - enabled: true, - cliPath: "/usr/local/bin/imsg", - dbPath: "/Users//Library/Messages/chat.db", - }, - }, -} -``` - - - - - -```bash -openclaw gateway -``` - - - - - -```bash -openclaw pairing list imessage -openclaw pairing approve imessage -``` - - Pairing requests expire after 1 hour. - - - - - - - OpenClaw only requires a stdio-compatible `cliPath`, so you can point `cliPath` at a wrapper script that SSHes to a remote Mac and runs `imsg`. - -```bash -#!/usr/bin/env bash -exec ssh -T gateway-host imsg "$@" -``` - - Recommended config when attachments are enabled: - -```json5 -{ - channels: { - imessage: { - enabled: true, - cliPath: "~/.openclaw/scripts/imsg-ssh", - remoteHost: "user@gateway-host", // used for SCP attachment fetches - includeAttachments: true, - // Optional: override allowed attachment roots. - // Defaults include /Users/*/Library/Messages/Attachments - attachmentRoots: ["/Users/*/Library/Messages/Attachments"], - remoteAttachmentRoots: ["/Users/*/Library/Messages/Attachments"], - }, - }, -} -``` - - If `remoteHost` is not set, OpenClaw attempts to auto-detect it by parsing the SSH wrapper script. - `remoteHost` must be `host` or `user@host` (no spaces or SSH options). - OpenClaw uses strict host-key checking for SCP, so the relay host key must already exist in `~/.ssh/known_hosts`. - Attachment paths are validated against allowed roots (`attachmentRoots` / `remoteAttachmentRoots`). - - - - -## Requirements and permissions (macOS) - -- Messages must be signed in on the Mac running `imsg`. -- Full Disk Access is required for the process context running OpenClaw/`imsg` (Messages DB access). -- Automation permission is required to send messages through Messages.app. - - -Permissions are granted per process context. If gateway runs headless (LaunchAgent/SSH), run a one-time interactive command in that same context to trigger prompts: - -```bash -imsg chats --limit 1 -# or -imsg send "test" -``` - - - -## Access control and routing - - - - `channels.imessage.dmPolicy` controls direct messages: - - - `pairing` (default) - - `allowlist` - - `open` (requires `allowFrom` to include `"*"`) - - `disabled` - - Allowlist field: `channels.imessage.allowFrom`. - - Allowlist entries can be handles or chat targets (`chat_id:*`, `chat_guid:*`, `chat_identifier:*`). - - - - - `channels.imessage.groupPolicy` controls group handling: - - - `allowlist` (default when configured) - - `open` - - `disabled` - - Group sender allowlist: `channels.imessage.groupAllowFrom`. - - Runtime fallback: if `groupAllowFrom` is unset, iMessage group sender checks fall back to `allowFrom` when available. - - Mention gating for groups: - - - iMessage has no native mention metadata - - mention detection uses regex patterns (`agents.list[].groupChat.mentionPatterns`, fallback `messages.groupChat.mentionPatterns`) - - with no configured patterns, mention gating cannot be enforced - - Control commands from authorized senders can bypass mention gating in groups. - - - - - - DMs use direct routing; groups use group routing. - - With default `session.dmScope=main`, iMessage DMs collapse into the agent main session. - - Group sessions are isolated (`agent::imessage:group:`). - - Replies route back to iMessage using originating channel/target metadata. - - Group-ish thread behavior: - - Some multi-participant iMessage threads can arrive with `is_group=false`. - If that `chat_id` is explicitly configured under `channels.imessage.groups`, OpenClaw treats it as group traffic (group gating + group session isolation). - - - - -## Deployment patterns - - - - Use a dedicated Apple ID and macOS user so bot traffic is isolated from your personal Messages profile. - - Typical flow: - - 1. Create/sign in a dedicated macOS user. - 2. Sign into Messages with the bot Apple ID in that user. - 3. Install `imsg` in that user. - 4. Create SSH wrapper so OpenClaw can run `imsg` in that user context. - 5. Point `channels.imessage.accounts..cliPath` and `.dbPath` to that user profile. - - First run may require GUI approvals (Automation + Full Disk Access) in that bot user session. - - - - - Common topology: - - - gateway runs on Linux/VM - - iMessage + `imsg` runs on a Mac in your tailnet - - `cliPath` wrapper uses SSH to run `imsg` - - `remoteHost` enables SCP attachment fetches - - Example: - -```json5 -{ - channels: { - imessage: { - enabled: true, - cliPath: "~/.openclaw/scripts/imsg-ssh", - remoteHost: "bot@mac-mini.tailnet-1234.ts.net", - includeAttachments: true, - dbPath: "/Users/bot/Library/Messages/chat.db", - }, - }, -} -``` - -```bash -#!/usr/bin/env bash -exec ssh -T bot@mac-mini.tailnet-1234.ts.net imsg "$@" -``` - - Use SSH keys so both SSH and SCP are non-interactive. - Ensure the host key is trusted first (for example `ssh bot@mac-mini.tailnet-1234.ts.net`) so `known_hosts` is populated. - - - - - iMessage supports per-account config under `channels.imessage.accounts`. - - Each account can override fields such as `cliPath`, `dbPath`, `allowFrom`, `groupPolicy`, `mediaMaxMb`, history settings, and attachment root allowlists. - - - - -## Media, chunking, and delivery targets - - - - - inbound attachment ingestion is optional: `channels.imessage.includeAttachments` - - remote attachment paths can be fetched via SCP when `remoteHost` is set - - attachment paths must match allowed roots: - - `channels.imessage.attachmentRoots` (local) - - `channels.imessage.remoteAttachmentRoots` (remote SCP mode) - - default root pattern: `/Users/*/Library/Messages/Attachments` - - SCP uses strict host-key checking (`StrictHostKeyChecking=yes`) - - outbound media size uses `channels.imessage.mediaMaxMb` (default 16 MB) - - - - - text chunk limit: `channels.imessage.textChunkLimit` (default 4000) - - chunk mode: `channels.imessage.chunkMode` - - `length` (default) - - `newline` (paragraph-first splitting) - - - - Preferred explicit targets: - - - `chat_id:123` (recommended for stable routing) - - `chat_guid:...` - - `chat_identifier:...` - - Handle targets are also supported: - - - `imessage:+1555...` - - `sms:+1555...` - - `user@example.com` - -```bash -imsg chats --limit 20 -``` - - - - -## Config writes - -iMessage allows channel-initiated config writes by default (for `/config set|unset` when `commands.config: true`). - -Disable: - -```json5 -{ - channels: { - imessage: { - configWrites: false, - }, - }, -} -``` - -## Troubleshooting - - - - Validate the binary and RPC support: - -```bash -imsg rpc --help -openclaw channels status --probe -``` - - If probe reports RPC unsupported, update `imsg`. - - - - - Check: - - - `channels.imessage.dmPolicy` - - `channels.imessage.allowFrom` - - pairing approvals (`openclaw pairing list imessage`) - - - - - Check: - - - `channels.imessage.groupPolicy` - - `channels.imessage.groupAllowFrom` - - `channels.imessage.groups` allowlist behavior - - mention pattern configuration (`agents.list[].groupChat.mentionPatterns`) - - - - - Check: - - - `channels.imessage.remoteHost` - - `channels.imessage.remoteAttachmentRoots` - - SSH/SCP key auth from the gateway host - - host key exists in `~/.ssh/known_hosts` on the gateway host - - remote path readability on the Mac running Messages - - - - - Re-run in an interactive GUI terminal in the same user/session context and approve prompts: - -```bash -imsg chats --limit 1 -imsg send "test" -``` - - Confirm Full Disk Access + Automation are granted for the process context that runs OpenClaw/`imsg`. - - - - -## Configuration reference pointers - -- [Configuration reference - iMessage](/gateway/configuration-reference#imessage) -- [Gateway configuration](/gateway/configuration) -- [Pairing](/channels/pairing) -- [BlueBubbles](/channels/bluebubbles) diff --git a/docs/channels/index.md b/docs/channels/index.md deleted file mode 100644 index 181b8d080aa..00000000000 --- a/docs/channels/index.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -summary: "Messaging platforms OpenClaw can connect to" -read_when: - - You want to choose a chat channel for OpenClaw - - You need a quick overview of supported messaging platforms -title: "Chat Channels" ---- - -# Chat Channels - -OpenClaw can talk to you on any chat app you already use. Each channel connects via the Gateway. -Text is supported everywhere; media and reactions vary by channel. - -## Supported channels - -- [WhatsApp](/channels/whatsapp) — Most popular; uses Baileys and requires QR pairing. -- [Telegram](/channels/telegram) — Bot API via grammY; supports groups. -- [Discord](/channels/discord) — Discord Bot API + Gateway; supports servers, channels, and DMs. -- [IRC](/channels/irc) — Classic IRC servers; channels + DMs with pairing/allowlist controls. -- [Slack](/channels/slack) — Bolt SDK; workspace apps. -- [Feishu](/channels/feishu) — Feishu/Lark bot via WebSocket (plugin, installed separately). -- [Google Chat](/channels/googlechat) — Google Chat API app via HTTP webhook. -- [Mattermost](/channels/mattermost) — Bot API + WebSocket; channels, groups, DMs (plugin, installed separately). -- [Signal](/channels/signal) — signal-cli; privacy-focused. -- [BlueBubbles](/channels/bluebubbles) — **Recommended for iMessage**; uses the BlueBubbles macOS server REST API with full feature support (edit, unsend, effects, reactions, group management — edit currently broken on macOS 26 Tahoe). -- [iMessage (legacy)](/channels/imessage) — Legacy macOS integration via imsg CLI (deprecated, use BlueBubbles for new setups). -- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately). -- [LINE](/channels/line) — LINE Messaging API bot (plugin, installed separately). -- [Nextcloud Talk](/channels/nextcloud-talk) — Self-hosted chat via Nextcloud Talk (plugin, installed separately). -- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately). -- [Nostr](/channels/nostr) — Decentralized DMs via NIP-04 (plugin, installed separately). -- [Tlon](/channels/tlon) — Urbit-based messenger (plugin, installed separately). -- [Twitch](/channels/twitch) — Twitch chat via IRC connection (plugin, installed separately). -- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately). -- [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately). -- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket. - -## Notes - -- 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](/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). -- Model providers are documented separately; see [Model Providers](/providers/models). diff --git a/docs/channels/irc.md b/docs/channels/irc.md deleted file mode 100644 index 2bf6fb4eb4f..00000000000 --- a/docs/channels/irc.md +++ /dev/null @@ -1,234 +0,0 @@ ---- -title: IRC -description: Connect OpenClaw to IRC channels and direct messages. ---- - -Use IRC when you want OpenClaw in classic channels (`#room`) and direct messages. -IRC ships as an extension plugin, but it is configured in the main config under `channels.irc`. - -## Quick start - -1. Enable IRC config in `~/.openclaw/openclaw.json`. -2. Set at least: - -```json -{ - "channels": { - "irc": { - "enabled": true, - "host": "irc.libera.chat", - "port": 6697, - "tls": true, - "nick": "openclaw-bot", - "channels": ["#openclaw"] - } - } -} -``` - -3. Start/restart gateway: - -```bash -openclaw gateway run -``` - -## Security defaults - -- `channels.irc.dmPolicy` defaults to `"pairing"`. -- `channels.irc.groupPolicy` defaults to `"allowlist"`. -- With `groupPolicy="allowlist"`, set `channels.irc.groups` to define allowed channels. -- Use TLS (`channels.irc.tls=true`) unless you intentionally accept plaintext transport. - -## Access control - -There are two separate “gates” for IRC channels: - -1. **Channel access** (`groupPolicy` + `groups`): whether the bot accepts messages from a channel at all. -2. **Sender access** (`groupAllowFrom` / per-channel `groups["#channel"].allowFrom`): who is allowed to trigger the bot inside that channel. - -Config keys: - -- DM allowlist (DM sender access): `channels.irc.allowFrom` -- Group sender allowlist (channel sender access): `channels.irc.groupAllowFrom` -- Per-channel controls (channel + sender + mention rules): `channels.irc.groups["#channel"]` -- `channels.irc.groupPolicy="open"` allows unconfigured channels (**still mention-gated by default**) - -Allowlist entries can use nick or `nick!user@host` forms. - -### Common gotcha: `allowFrom` is for DMs, not channels - -If you see logs like: - -- `irc: drop group sender alice!ident@host (policy=allowlist)` - -…it means the sender wasn’t allowed for **group/channel** messages. Fix it by either: - -- setting `channels.irc.groupAllowFrom` (global for all channels), or -- setting per-channel sender allowlists: `channels.irc.groups["#channel"].allowFrom` - -Example (allow anyone in `#tuirc-dev` to talk to the bot): - -```json5 -{ - channels: { - irc: { - groupPolicy: "allowlist", - groups: { - "#tuirc-dev": { allowFrom: ["*"] }, - }, - }, - }, -} -``` - -## Reply triggering (mentions) - -Even if a channel is allowed (via `groupPolicy` + `groups`) and the sender is allowed, OpenClaw defaults to **mention-gating** in group contexts. - -That means you may see logs like `drop channel … (missing-mention)` unless the message includes a mention pattern that matches the bot. - -To make the bot reply in an IRC channel **without needing a mention**, disable mention gating for that channel: - -```json5 -{ - channels: { - irc: { - groupPolicy: "allowlist", - groups: { - "#tuirc-dev": { - requireMention: false, - allowFrom: ["*"], - }, - }, - }, - }, -} -``` - -Or to allow **all** IRC channels (no per-channel allowlist) and still reply without mentions: - -```json5 -{ - channels: { - irc: { - groupPolicy: "open", - groups: { - "*": { requireMention: false, allowFrom: ["*"] }, - }, - }, - }, -} -``` - -## Security note (recommended for public channels) - -If you allow `allowFrom: ["*"]` in a public channel, anyone can prompt the bot. -To reduce risk, restrict tools for that channel. - -### Same tools for everyone in the channel - -```json5 -{ - channels: { - irc: { - groups: { - "#tuirc-dev": { - allowFrom: ["*"], - tools: { - deny: ["group:runtime", "group:fs", "gateway", "nodes", "cron", "browser"], - }, - }, - }, - }, - }, -} -``` - -### Different tools per sender (owner gets more power) - -Use `toolsBySender` to apply a stricter policy to `"*"` and a looser one to your nick: - -```json5 -{ - channels: { - irc: { - groups: { - "#tuirc-dev": { - allowFrom: ["*"], - toolsBySender: { - "*": { - deny: ["group:runtime", "group:fs", "gateway", "nodes", "cron", "browser"], - }, - eigen: { - deny: ["gateway", "nodes", "cron"], - }, - }, - }, - }, - }, - }, -} -``` - -Notes: - -- `toolsBySender` keys can be a nick (e.g. `"eigen"`) or a full hostmask (`"eigen!~eigen@174.127.248.171"`) for stronger identity matching. -- The first matching sender policy wins; `"*"` is the wildcard fallback. - -For more on group access vs mention-gating (and how they interact), see: [/channels/groups](/channels/groups). - -## NickServ - -To identify with NickServ after connect: - -```json -{ - "channels": { - "irc": { - "nickserv": { - "enabled": true, - "service": "NickServ", - "password": "your-nickserv-password" - } - } - } -} -``` - -Optional one-time registration on connect: - -```json -{ - "channels": { - "irc": { - "nickserv": { - "register": true, - "registerEmail": "bot@example.com" - } - } - } -} -``` - -Disable `register` after the nick is registered to avoid repeated REGISTER attempts. - -## Environment variables - -Default account supports: - -- `IRC_HOST` -- `IRC_PORT` -- `IRC_TLS` -- `IRC_NICK` -- `IRC_USERNAME` -- `IRC_REALNAME` -- `IRC_PASSWORD` -- `IRC_CHANNELS` (comma-separated) -- `IRC_NICKSERV_PASSWORD` -- `IRC_NICKSERV_REGISTER_EMAIL` - -## Troubleshooting - -- If the bot connects but never replies in channels, verify `channels.irc.groups` **and** whether mention-gating is dropping messages (`missing-mention`). If you want it to reply without pings, set `requireMention:false` for the channel. -- If login fails, verify nick availability and server password. -- If TLS fails on a custom network, verify host/port and certificate setup. diff --git a/docs/channels/line.md b/docs/channels/line.md deleted file mode 100644 index d32e683fbeb..00000000000 --- a/docs/channels/line.md +++ /dev/null @@ -1,186 +0,0 @@ ---- -summary: "LINE Messaging API plugin setup, config, and usage" -read_when: - - You want to connect OpenClaw to LINE - - You need LINE webhook + credential setup - - You want LINE-specific message options -title: LINE ---- - -# LINE (plugin) - -LINE connects to OpenClaw via the LINE Messaging API. The plugin runs as a webhook -receiver on the gateway and uses your channel access token + channel secret for -authentication. - -Status: supported via plugin. Direct messages, group chats, media, locations, Flex -messages, template messages, and quick replies are supported. Reactions and threads -are not supported. - -## Plugin required - -Install the LINE plugin: - -```bash -openclaw plugins install @openclaw/line -``` - -Local checkout (when running from a git repo): - -```bash -openclaw plugins install ./extensions/line -``` - -## Setup - -1. Create a LINE Developers account and open the Console: - [https://developers.line.biz/console/](https://developers.line.biz/console/) -2. Create (or pick) a Provider and add a **Messaging API** channel. -3. Copy the **Channel access token** and **Channel secret** from the channel settings. -4. Enable **Use webhook** in the Messaging API settings. -5. Set the webhook URL to your gateway endpoint (HTTPS required): - -``` -https://gateway-host/line/webhook -``` - -The gateway responds to LINE’s webhook verification (GET) and inbound events (POST). -If you need a custom path, set `channels.line.webhookPath` or -`channels.line.accounts..webhookPath` and update the URL accordingly. - -## Configure - -Minimal config: - -```json5 -{ - channels: { - line: { - enabled: true, - channelAccessToken: "LINE_CHANNEL_ACCESS_TOKEN", - channelSecret: "LINE_CHANNEL_SECRET", - dmPolicy: "pairing", - }, - }, -} -``` - -Env vars (default account only): - -- `LINE_CHANNEL_ACCESS_TOKEN` -- `LINE_CHANNEL_SECRET` - -Token/secret files: - -```json5 -{ - channels: { - line: { - tokenFile: "/path/to/line-token.txt", - secretFile: "/path/to/line-secret.txt", - }, - }, -} -``` - -Multiple accounts: - -```json5 -{ - channels: { - line: { - accounts: { - marketing: { - channelAccessToken: "...", - channelSecret: "...", - webhookPath: "/line/marketing", - }, - }, - }, - }, -} -``` - -## Access control - -Direct messages default to pairing. Unknown senders get a pairing code and their -messages are ignored until approved. - -```bash -openclaw pairing list line -openclaw pairing approve line -``` - -Allowlists and policies: - -- `channels.line.dmPolicy`: `pairing | allowlist | open | disabled` -- `channels.line.allowFrom`: allowlisted LINE user IDs for DMs -- `channels.line.groupPolicy`: `allowlist | open | disabled` -- `channels.line.groupAllowFrom`: allowlisted LINE user IDs for groups -- Per-group overrides: `channels.line.groups..allowFrom` - -LINE IDs are case-sensitive. Valid IDs look like: - -- User: `U` + 32 hex chars -- Group: `C` + 32 hex chars -- Room: `R` + 32 hex chars - -## Message behavior - -- Text is chunked at 5000 characters. -- Markdown formatting is stripped; code blocks and tables are converted into Flex - cards when possible. -- Streaming responses are buffered; LINE receives full chunks with a loading - animation while the agent works. -- Media downloads are capped by `channels.line.mediaMaxMb` (default 10). - -## Channel data (rich messages) - -Use `channelData.line` to send quick replies, locations, Flex cards, or template -messages. - -```json5 -{ - text: "Here you go", - channelData: { - line: { - quickReplies: ["Status", "Help"], - location: { - title: "Office", - address: "123 Main St", - latitude: 35.681236, - longitude: 139.767125, - }, - flexMessage: { - altText: "Status card", - contents: { - /* Flex payload */ - }, - }, - templateMessage: { - type: "confirm", - text: "Proceed?", - confirmLabel: "Yes", - confirmData: "yes", - cancelLabel: "No", - cancelData: "no", - }, - }, - }, -} -``` - -The LINE plugin also ships a `/card` command for Flex message presets: - -``` -/card info "Welcome" "Thanks for joining!" -``` - -## Troubleshooting - -- **Webhook verification fails:** ensure the webhook URL is HTTPS and the - `channelSecret` matches the LINE console. -- **No inbound events:** confirm the webhook path matches `channels.line.webhookPath` - and that the gateway is reachable from LINE. -- **Media download errors:** raise `channels.line.mediaMaxMb` if media exceeds the - default limit. diff --git a/docs/channels/location.md b/docs/channels/location.md deleted file mode 100644 index 103f57663c4..00000000000 --- a/docs/channels/location.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -summary: "Inbound channel location parsing (Telegram + WhatsApp) and context fields" -read_when: - - Adding or modifying channel location parsing - - Using location context fields in agent prompts or tools -title: "Channel Location Parsing" ---- - -# Channel location parsing - -OpenClaw normalizes shared locations from chat channels into: - -- human-readable text appended to the inbound body, and -- structured fields in the auto-reply context payload. - -Currently supported: - -- **Telegram** (location pins + venues + live locations) -- **WhatsApp** (locationMessage + liveLocationMessage) -- **Matrix** (`m.location` with `geo_uri`) - -## Text formatting - -Locations are rendered as friendly lines without brackets: - -- Pin: - - `📍 48.858844, 2.294351 ±12m` -- Named place: - - `📍 Eiffel Tower — Champ de Mars, Paris (48.858844, 2.294351 ±12m)` -- Live share: - - `🛰 Live location: 48.858844, 2.294351 ±12m` - -If the channel includes a caption/comment, it is appended on the next line: - -``` -📍 48.858844, 2.294351 ±12m -Meet here -``` - -## Context fields - -When a location is present, these fields are added to `ctx`: - -- `LocationLat` (number) -- `LocationLon` (number) -- `LocationAccuracy` (number, meters; optional) -- `LocationName` (string; optional) -- `LocationAddress` (string; optional) -- `LocationSource` (`pin | place | live`) -- `LocationIsLive` (boolean) - -## Channel notes - -- **Telegram**: venues map to `LocationName/LocationAddress`; live locations use `live_period`. -- **WhatsApp**: `locationMessage.comment` and `liveLocationMessage.caption` are appended as the caption line. -- **Matrix**: `geo_uri` is parsed as a pin location; altitude is ignored and `LocationIsLive` is always false. diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md deleted file mode 100644 index 04205d94971..00000000000 --- a/docs/channels/matrix.md +++ /dev/null @@ -1,302 +0,0 @@ ---- -summary: "Matrix support status, capabilities, and configuration" -read_when: - - Working on Matrix channel features -title: "Matrix" ---- - -# Matrix (plugin) - -Matrix is an open, decentralized messaging protocol. OpenClaw connects as a Matrix **user** -on any homeserver, so you need a Matrix account for the bot. Once it is logged in, you can DM -the bot directly or invite it to rooms (Matrix "groups"). Beeper is a valid client option too, -but it requires E2EE to be enabled. - -Status: supported via plugin (@vector-im/matrix-bot-sdk). Direct messages, rooms, threads, media, reactions, -polls (send + poll-start as text), location, and E2EE (with crypto support). - -## Plugin required - -Matrix ships as a plugin and is not bundled with the core install. - -Install via CLI (npm registry): - -```bash -openclaw plugins install @openclaw/matrix -``` - -Local checkout (when running from a git repo): - -```bash -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](/tools/plugin) - -## Setup - -1. Install the Matrix plugin: - - From npm: `openclaw plugins install @openclaw/matrix` - - From a local checkout: `openclaw plugins install ./extensions/matrix` -2. Create a Matrix account on a homeserver: - - Browse hosting options at [https://matrix.org/ecosystem/hosting/](https://matrix.org/ecosystem/hosting/) - - Or host it yourself. -3. Get an access token for the bot account: - - Use the Matrix login API with `curl` at your home server: - - ```bash - curl --request POST \ - --url https://matrix.example.org/_matrix/client/v3/login \ - --header 'Content-Type: application/json' \ - --data '{ - "type": "m.login.password", - "identifier": { - "type": "m.id.user", - "user": "your-user-name" - }, - "password": "your-password" - }' - ``` - - - Replace `matrix.example.org` with your homeserver URL. - - Or set `channels.matrix.userId` + `channels.matrix.password`: OpenClaw calls the same - login endpoint, stores the access token in `~/.openclaw/credentials/matrix/credentials.json`, - and reuses it on next start. - -4. Configure credentials: - - Env: `MATRIX_HOMESERVER`, `MATRIX_ACCESS_TOKEN` (or `MATRIX_USER_ID` + `MATRIX_PASSWORD`) - - Or config: `channels.matrix.*` - - If both are set, config takes precedence. - - With access token: user ID is fetched automatically via `/whoami`. - - When set, `channels.matrix.userId` should be the full Matrix ID (example: `@bot:example.org`). -5. Restart the gateway (or finish onboarding). -6. Start a DM with the bot or invite it to a room from any Matrix client - (Element, Beeper, etc.; see [https://matrix.org/ecosystem/clients/](https://matrix.org/ecosystem/clients/)). Beeper requires E2EE, - so set `channels.matrix.encryption: true` and verify the device. - -Minimal config (access token, user ID auto-fetched): - -```json5 -{ - channels: { - matrix: { - enabled: true, - homeserver: "https://matrix.example.org", - accessToken: "syt_***", - dm: { policy: "pairing" }, - }, - }, -} -``` - -E2EE config (end to end encryption enabled): - -```json5 -{ - channels: { - matrix: { - enabled: true, - homeserver: "https://matrix.example.org", - accessToken: "syt_***", - encryption: true, - dm: { policy: "pairing" }, - }, - }, -} -``` - -## Encryption (E2EE) - -End-to-end encryption is **supported** via the Rust crypto SDK. - -Enable with `channels.matrix.encryption: true`: - -- If the crypto module loads, encrypted rooms are decrypted automatically. -- Outbound media is encrypted when sending to encrypted rooms. -- On first connection, OpenClaw requests device verification from your other sessions. -- Verify the device in another Matrix client (Element, etc.) to enable key sharing. -- If the crypto module cannot be loaded, E2EE is disabled and encrypted rooms will not decrypt; - OpenClaw logs a warning. -- If you see missing crypto module errors (for example, `@matrix-org/matrix-sdk-crypto-nodejs-*`), - allow build scripts for `@matrix-org/matrix-sdk-crypto-nodejs` and run - `pnpm rebuild @matrix-org/matrix-sdk-crypto-nodejs` or fetch the binary with - `node node_modules/@matrix-org/matrix-sdk-crypto-nodejs/download-lib.js`. - -Crypto state is stored per account + access token in -`~/.openclaw/matrix/accounts//__//crypto/` -(SQLite database). Sync state lives alongside it in `bot-storage.json`. -If the access token (device) changes, a new store is created and the bot must be -re-verified for encrypted rooms. - -**Device verification:** -When E2EE is enabled, the bot will request verification from your other sessions on startup. -Open Element (or another client) and approve the verification request to establish trust. -Once verified, the bot can decrypt messages in encrypted rooms. - -## Multi-account - -Multi-account support: use `channels.matrix.accounts` with per-account credentials and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern. - -Each account runs as a separate Matrix user on any homeserver. Per-account config -inherits from the top-level `channels.matrix` settings and can override any option -(DM policy, groups, encryption, etc.). - -```json5 -{ - channels: { - matrix: { - enabled: true, - dm: { policy: "pairing" }, - accounts: { - assistant: { - name: "Main assistant", - homeserver: "https://matrix.example.org", - accessToken: "syt_assistant_***", - encryption: true, - }, - alerts: { - name: "Alerts bot", - homeserver: "https://matrix.example.org", - accessToken: "syt_alerts_***", - dm: { policy: "allowlist", allowFrom: ["@admin:example.org"] }, - }, - }, - }, - }, -} -``` - -Notes: - -- Account startup is serialized to avoid race conditions with concurrent module imports. -- Env variables (`MATRIX_HOMESERVER`, `MATRIX_ACCESS_TOKEN`, etc.) only apply to the **default** account. -- Base channel settings (DM policy, group policy, mention gating, etc.) apply to all accounts unless overridden per account. -- Use `bindings[].match.accountId` to route each account to a different agent. -- Crypto state is stored per account + access token (separate key stores per account). - -## Routing model - -- Replies always go back to Matrix. -- DMs share the agent's main session; rooms map to group sessions. - -## Access control (DMs) - -- Default: `channels.matrix.dm.policy = "pairing"`. Unknown senders get a pairing code. -- Approve via: - - `openclaw pairing list matrix` - - `openclaw pairing approve matrix ` -- Public DMs: `channels.matrix.dm.policy="open"` plus `channels.matrix.dm.allowFrom=["*"]`. -- `channels.matrix.dm.allowFrom` accepts full Matrix user IDs (example: `@user:server`). The wizard resolves display names to user IDs when directory search finds a single exact match. -- Do not use display names or bare localparts (example: `"Alice"` or `"alice"`). They are ambiguous and are ignored for allowlist matching. Use full `@user:server` IDs. - -## Rooms (groups) - -- Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated). Use `channels.defaults.groupPolicy` to override the default when unset. -- Allowlist rooms with `channels.matrix.groups` (room IDs or aliases; names are resolved to IDs when directory search finds a single exact match): - -```json5 -{ - channels: { - matrix: { - groupPolicy: "allowlist", - groups: { - "!roomId:example.org": { allow: true }, - "#alias:example.org": { allow: true }, - }, - groupAllowFrom: ["@owner:example.org"], - }, - }, -} -``` - -- `requireMention: false` enables auto-reply in that room. -- `groups."*"` can set defaults for mention gating across rooms. -- `groupAllowFrom` restricts which senders can trigger the bot in rooms (full Matrix user IDs). -- Per-room `users` allowlists can further restrict senders inside a specific room (use full Matrix user IDs). -- The configure wizard prompts for room allowlists (room IDs, aliases, or names) and resolves names only on an exact, unique match. -- On startup, OpenClaw resolves room/user names in allowlists to IDs and logs the mapping; unresolved entries are ignored for allowlist matching. -- Invites are auto-joined by default; control with `channels.matrix.autoJoin` and `channels.matrix.autoJoinAllowlist`. -- To allow **no rooms**, set `channels.matrix.groupPolicy: "disabled"` (or keep an empty allowlist). -- Legacy key: `channels.matrix.rooms` (same shape as `groups`). - -## Threads - -- Reply threading is supported. -- `channels.matrix.threadReplies` controls whether replies stay in threads: - - `off`, `inbound` (default), `always` -- `channels.matrix.replyToMode` controls reply-to metadata when not replying in a thread: - - `off` (default), `first`, `all` - -## Capabilities - -| Feature | Status | -| --------------- | ------------------------------------------------------------------------------------- | -| Direct messages | ✅ Supported | -| Rooms | ✅ Supported | -| Threads | ✅ Supported | -| Media | ✅ Supported | -| E2EE | ✅ Supported (crypto module required) | -| Reactions | ✅ Supported (send/read via tools) | -| Polls | ✅ Send supported; inbound poll starts are converted to text (responses/ends ignored) | -| Location | ✅ Supported (geo URI; altitude ignored) | -| Native commands | ✅ Supported | - -## Troubleshooting - -Run this ladder first: - -```bash -openclaw status -openclaw gateway status -openclaw logs --follow -openclaw doctor -openclaw channels status --probe -``` - -Then confirm DM pairing state if needed: - -```bash -openclaw pairing list matrix -``` - -Common failures: - -- Logged in but room messages ignored: room blocked by `groupPolicy` or room allowlist. -- DMs ignored: sender pending approval when `channels.matrix.dm.policy="pairing"`. -- Encrypted rooms fail: crypto support or encryption settings mismatch. - -For triage flow: [/channels/troubleshooting](/channels/troubleshooting). - -## Configuration reference (Matrix) - -Full configuration: [Configuration](/gateway/configuration) - -Provider options: - -- `channels.matrix.enabled`: enable/disable channel startup. -- `channels.matrix.homeserver`: homeserver URL. -- `channels.matrix.userId`: Matrix user ID (optional with access token). -- `channels.matrix.accessToken`: access token. -- `channels.matrix.password`: password for login (token stored). -- `channels.matrix.deviceName`: device display name. -- `channels.matrix.encryption`: enable E2EE (default: false). -- `channels.matrix.initialSyncLimit`: initial sync limit. -- `channels.matrix.threadReplies`: `off | inbound | always` (default: inbound). -- `channels.matrix.textChunkLimit`: outbound text chunk size (chars). -- `channels.matrix.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking. -- `channels.matrix.dm.policy`: `pairing | allowlist | open | disabled` (default: pairing). -- `channels.matrix.dm.allowFrom`: DM allowlist (full Matrix user IDs). `open` requires `"*"`. The wizard resolves names to IDs when possible. -- `channels.matrix.groupPolicy`: `allowlist | open | disabled` (default: allowlist). -- `channels.matrix.groupAllowFrom`: allowlisted senders for group messages (full Matrix user IDs). -- `channels.matrix.allowlistOnly`: force allowlist rules for DMs + rooms. -- `channels.matrix.groups`: group allowlist + per-room settings map. -- `channels.matrix.rooms`: legacy group allowlist/config. -- `channels.matrix.replyToMode`: reply-to mode for threads/tags. -- `channels.matrix.mediaMaxMb`: inbound/outbound media cap (MB). -- `channels.matrix.autoJoin`: invite handling (`always | allowlist | off`, default: always). -- `channels.matrix.autoJoinAllowlist`: allowed room IDs/aliases for auto-join. -- `channels.matrix.accounts`: multi-account configuration keyed by account ID (each account inherits top-level settings). -- `channels.matrix.actions`: per-action tool gating (reactions/messages/pins/memberInfo/channelInfo). diff --git a/docs/channels/mattermost.md b/docs/channels/mattermost.md deleted file mode 100644 index fa0d9393e0f..00000000000 --- a/docs/channels/mattermost.md +++ /dev/null @@ -1,158 +0,0 @@ ---- -summary: "Mattermost bot setup and OpenClaw config" -read_when: - - Setting up Mattermost - - Debugging Mattermost routing -title: "Mattermost" ---- - -# Mattermost (plugin) - -Status: supported via plugin (bot token + WebSocket events). Channels, groups, and DMs are supported. -Mattermost is a self-hostable team messaging platform; see the official site at -[mattermost.com](https://mattermost.com) for product details and downloads. - -## Plugin required - -Mattermost ships as a plugin and is not bundled with the core install. - -Install via CLI (npm registry): - -```bash -openclaw plugins install @openclaw/mattermost -``` - -Local checkout (when running from a git repo): - -```bash -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](/tools/plugin) - -## Quick setup - -1. Install the Mattermost plugin. -2. Create a Mattermost bot account and copy the **bot token**. -3. Copy the Mattermost **base URL** (e.g., `https://chat.example.com`). -4. Configure OpenClaw and start the gateway. - -Minimal config: - -```json5 -{ - channels: { - mattermost: { - enabled: true, - botToken: "mm-token", - baseUrl: "https://chat.example.com", - dmPolicy: "pairing", - }, - }, -} -``` - -## Environment variables (default account) - -Set these on the gateway host if you prefer env vars: - -- `MATTERMOST_BOT_TOKEN=...` -- `MATTERMOST_URL=https://chat.example.com` - -Env vars apply only to the **default** account (`default`). Other accounts must use config values. - -## Chat modes - -Mattermost responds to DMs automatically. Channel behavior is controlled by `chatmode`: - -- `oncall` (default): respond only when @mentioned in channels. -- `onmessage`: respond to every channel message. -- `onchar`: respond when a message starts with a trigger prefix. - -Config example: - -```json5 -{ - channels: { - mattermost: { - chatmode: "onchar", - oncharPrefixes: [">", "!"], - }, - }, -} -``` - -Notes: - -- `onchar` still responds to explicit @mentions. -- `channels.mattermost.requireMention` is honored for legacy configs but `chatmode` is preferred. - -## Access control (DMs) - -- Default: `channels.mattermost.dmPolicy = "pairing"` (unknown senders get a pairing code). -- Approve via: - - `openclaw pairing list mattermost` - - `openclaw pairing approve mattermost ` -- Public DMs: `channels.mattermost.dmPolicy="open"` plus `channels.mattermost.allowFrom=["*"]`. - -## Channels (groups) - -- Default: `channels.mattermost.groupPolicy = "allowlist"` (mention-gated). -- Allowlist senders with `channels.mattermost.groupAllowFrom` (user IDs or `@username`). -- Open channels: `channels.mattermost.groupPolicy="open"` (mention-gated). - -## Targets for outbound delivery - -Use these target formats with `openclaw message send` or cron/webhooks: - -- `channel:` for a channel -- `user:` for a DM -- `@username` for a DM (resolved via the Mattermost API) - -Bare IDs are treated as channels. - -## Reactions (message tool) - -- Use `message action=react` with `channel=mattermost`. -- `messageId` is the Mattermost post id. -- `emoji` accepts names like `thumbsup` or `:+1:` (colons are optional). -- Set `remove=true` (boolean) to remove a reaction. -- Reaction add/remove events are forwarded as system events to the routed agent session. - -Examples: - -``` -message action=react channel=mattermost target=channel: messageId= emoji=thumbsup -message action=react channel=mattermost target=channel: messageId= emoji=thumbsup remove=true -``` - -Config: - -- `channels.mattermost.actions.reactions`: enable/disable reaction actions (default true). -- Per-account override: `channels.mattermost.accounts..actions.reactions`. - -## Multi-account - -Mattermost supports multiple accounts under `channels.mattermost.accounts`: - -```json5 -{ - channels: { - mattermost: { - accounts: { - default: { name: "Primary", botToken: "mm-token", baseUrl: "https://chat.example.com" }, - alerts: { name: "Alerts", botToken: "mm-token-2", baseUrl: "https://alerts.example.com" }, - }, - }, - }, -} -``` - -## Troubleshooting - -- No replies in channels: ensure the bot is in the channel and mention it (oncall), use a trigger prefix (onchar), or set `chatmode: "onmessage"`. -- Auth errors: check the bot token, base URL, and whether the account is enabled. -- Multi-account issues: env vars only apply to the `default` account. diff --git a/docs/channels/msteams.md b/docs/channels/msteams.md deleted file mode 100644 index 2232582610a..00000000000 --- a/docs/channels/msteams.md +++ /dev/null @@ -1,771 +0,0 @@ ---- -summary: "Microsoft Teams bot support status, capabilities, and configuration" -read_when: - - Working on MS Teams channel features -title: "Microsoft Teams" ---- - -# Microsoft Teams (plugin) - -> "Abandon all hope, ye who enter here." - -Updated: 2026-01-21 - -Status: text + DM attachments are supported; channel/group file sending requires `sharePointSiteId` + Graph permissions (see [Sending files in group chats](#sending-files-in-group-chats)). Polls are sent via Adaptive Cards. - -## Plugin required - -Microsoft Teams ships as a plugin and is not bundled with the core install. - -**Breaking change (2026.1.15):** MS Teams moved out of core. If you use it, you must install the plugin. - -Explainable: keeps core installs lighter and lets MS Teams dependencies update independently. - -Install via CLI (npm registry): - -```bash -openclaw plugins install @openclaw/msteams -``` - -Local checkout (when running from a git repo): - -```bash -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](/tools/plugin) - -## Quick setup (beginner) - -1. Install the Microsoft Teams plugin. -2. Create an **Azure Bot** (App ID + client secret + tenant ID). -3. Configure OpenClaw with those credentials. -4. Expose `/api/messages` (port 3978 by default) via a public URL or tunnel. -5. Install the Teams app package and start the gateway. - -Minimal config: - -```json5 -{ - channels: { - msteams: { - enabled: true, - appId: "", - appPassword: "", - tenantId: "", - webhook: { port: 3978, path: "/api/messages" }, - }, - }, -} -``` - -Note: group chats are blocked by default (`channels.msteams.groupPolicy: "allowlist"`). To allow group replies, set `channels.msteams.groupAllowFrom` (or use `groupPolicy: "open"` to allow any member, mention-gated). - -## Goals - -- Talk to OpenClaw via Teams DMs, group chats, or channels. -- Keep routing deterministic: replies always go back to the channel they arrived on. -- Default to safe channel behavior (mentions required unless configured otherwise). - -## Config writes - -By default, Microsoft Teams is allowed to write config updates triggered by `/config set|unset` (requires `commands.config: true`). - -Disable with: - -```json5 -{ - channels: { msteams: { configWrites: false } }, -} -``` - -## Access control (DMs + groups) - -**DM access** - -- Default: `channels.msteams.dmPolicy = "pairing"`. Unknown senders are ignored until approved. -- `channels.msteams.allowFrom` accepts AAD object IDs, UPNs, or display names. The wizard resolves names to IDs via Microsoft Graph when credentials allow. - -**Group access** - -- Default: `channels.msteams.groupPolicy = "allowlist"` (blocked unless you add `groupAllowFrom`). Use `channels.defaults.groupPolicy` to override the default when unset. -- `channels.msteams.groupAllowFrom` controls which senders can trigger in group chats/channels (falls back to `channels.msteams.allowFrom`). -- Set `groupPolicy: "open"` to allow any member (still mention‑gated by default). -- To allow **no channels**, set `channels.msteams.groupPolicy: "disabled"`. - -Example: - -```json5 -{ - channels: { - msteams: { - groupPolicy: "allowlist", - groupAllowFrom: ["user@org.com"], - }, - }, -} -``` - -**Teams + channel allowlist** - -- Scope group/channel replies by listing teams and channels under `channels.msteams.teams`. -- Keys can be team IDs or names; channel keys can be conversation IDs or names. -- When `groupPolicy="allowlist"` and a teams allowlist is present, only listed teams/channels are accepted (mention‑gated). -- The configure wizard accepts `Team/Channel` entries and stores them for you. -- On startup, OpenClaw resolves team/channel and user allowlist names to IDs (when Graph permissions allow) - and logs the mapping; unresolved entries are kept as typed. - -Example: - -```json5 -{ - channels: { - msteams: { - groupPolicy: "allowlist", - teams: { - "My Team": { - channels: { - General: { requireMention: true }, - }, - }, - }, - }, - }, -} -``` - -## How it works - -1. Install the Microsoft Teams plugin. -2. Create an **Azure Bot** (App ID + secret + tenant ID). -3. Build a **Teams app package** that references the bot and includes the RSC permissions below. -4. Upload/install the Teams app into a team (or personal scope for DMs). -5. Configure `msteams` in `~/.openclaw/openclaw.json` (or env vars) and start the gateway. -6. The gateway listens for Bot Framework webhook traffic on `/api/messages` by default. - -## Azure Bot Setup (Prerequisites) - -Before configuring OpenClaw, you need to create an Azure Bot resource. - -### Step 1: Create Azure Bot - -1. Go to [Create Azure Bot](https://portal.azure.com/#create/Microsoft.AzureBot) -2. Fill in the **Basics** tab: - - | Field | Value | - | ------------------ | -------------------------------------------------------- | - | **Bot handle** | Your bot name, e.g., `openclaw-msteams` (must be unique) | - | **Subscription** | Select your Azure subscription | - | **Resource group** | Create new or use existing | - | **Pricing tier** | **Free** for dev/testing | - | **Type of App** | **Single Tenant** (recommended - see note below) | - | **Creation type** | **Create new Microsoft App ID** | - -> **Deprecation notice:** Creation of new multi-tenant bots was deprecated after 2025-07-31. Use **Single Tenant** for new bots. - -3. Click **Review + create** → **Create** (wait ~1-2 minutes) - -### Step 2: Get Credentials - -1. Go to your Azure Bot resource → **Configuration** -2. Copy **Microsoft App ID** → this is your `appId` -3. Click **Manage Password** → go to the App Registration -4. Under **Certificates & secrets** → **New client secret** → copy the **Value** → this is your `appPassword` -5. Go to **Overview** → copy **Directory (tenant) ID** → this is your `tenantId` - -### Step 3: Configure Messaging Endpoint - -1. In Azure Bot → **Configuration** -2. Set **Messaging endpoint** to your webhook URL: - - Production: `https://your-domain.com/api/messages` - - Local dev: Use a tunnel (see [Local Development](#local-development-tunneling) below) - -### Step 4: Enable Teams Channel - -1. In Azure Bot → **Channels** -2. Click **Microsoft Teams** → Configure → Save -3. Accept the Terms of Service - -## Local Development (Tunneling) - -Teams can't reach `localhost`. Use a tunnel for local development: - -**Option A: ngrok** - -```bash -ngrok http 3978 -# Copy the https URL, e.g., https://abc123.ngrok.io -# Set messaging endpoint to: https://abc123.ngrok.io/api/messages -``` - -**Option B: Tailscale Funnel** - -```bash -tailscale funnel 3978 -# Use your Tailscale funnel URL as the messaging endpoint -``` - -## Teams Developer Portal (Alternative) - -Instead of manually creating a manifest ZIP, you can use the [Teams Developer Portal](https://dev.teams.microsoft.com/apps): - -1. Click **+ New app** -2. Fill in basic info (name, description, developer info) -3. Go to **App features** → **Bot** -4. Select **Enter a bot ID manually** and paste your Azure Bot App ID -5. Check scopes: **Personal**, **Team**, **Group Chat** -6. Click **Distribute** → **Download app package** -7. In Teams: **Apps** → **Manage your apps** → **Upload a custom app** → select the ZIP - -This is often easier than hand-editing JSON manifests. - -## Testing the Bot - -**Option A: Azure Web Chat (verify webhook first)** - -1. In Azure Portal → your Azure Bot resource → **Test in Web Chat** -2. Send a message - you should see a response -3. This confirms your webhook endpoint works before Teams setup - -**Option B: Teams (after app installation)** - -1. Install the Teams app (sideload or org catalog) -2. Find the bot in Teams and send a DM -3. Check gateway logs for incoming activity - -## Setup (minimal text-only) - -1. **Install the Microsoft Teams plugin** - - From npm: `openclaw plugins install @openclaw/msteams` - - From a local checkout: `openclaw plugins install ./extensions/msteams` - -2. **Bot registration** - - Create an Azure Bot (see above) and note: - - App ID - - Client secret (App password) - - Tenant ID (single-tenant) - -3. **Teams app manifest** - - Include a `bot` entry with `botId = `. - - Scopes: `personal`, `team`, `groupChat`. - - `supportsFiles: true` (required for personal scope file handling). - - Add RSC permissions (below). - - Create icons: `outline.png` (32x32) and `color.png` (192x192). - - Zip all three files together: `manifest.json`, `outline.png`, `color.png`. - -4. **Configure OpenClaw** - - ```json - { - "msteams": { - "enabled": true, - "appId": "", - "appPassword": "", - "tenantId": "", - "webhook": { "port": 3978, "path": "/api/messages" } - } - } - ``` - - You can also use environment variables instead of config keys: - - `MSTEAMS_APP_ID` - - `MSTEAMS_APP_PASSWORD` - - `MSTEAMS_TENANT_ID` - -5. **Bot endpoint** - - Set the Azure Bot Messaging Endpoint to: - - `https://:3978/api/messages` (or your chosen path/port). - -6. **Run the gateway** - - The Teams channel starts automatically when the plugin is installed and `msteams` config exists with credentials. - -## History context - -- `channels.msteams.historyLimit` controls how many recent channel/group messages are wrapped into the prompt. -- Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50). -- DM history can be limited with `channels.msteams.dmHistoryLimit` (user turns). Per-user overrides: `channels.msteams.dms[""].historyLimit`. - -## Current Teams RSC Permissions (Manifest) - -These are the **existing resourceSpecific permissions** in our Teams app manifest. They only apply inside the team/chat where the app is installed. - -**For channels (team scope):** - -- `ChannelMessage.Read.Group` (Application) - receive all channel messages without @mention -- `ChannelMessage.Send.Group` (Application) -- `Member.Read.Group` (Application) -- `Owner.Read.Group` (Application) -- `ChannelSettings.Read.Group` (Application) -- `TeamMember.Read.Group` (Application) -- `TeamSettings.Read.Group` (Application) - -**For group chats:** - -- `ChatMessage.Read.Chat` (Application) - receive all group chat messages without @mention - -## Example Teams Manifest (redacted) - -Minimal, valid example with the required fields. Replace IDs and URLs. - -```json -{ - "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.23/MicrosoftTeams.schema.json", - "manifestVersion": "1.23", - "version": "1.0.0", - "id": "00000000-0000-0000-0000-000000000000", - "name": { "short": "OpenClaw" }, - "developer": { - "name": "Your Org", - "websiteUrl": "https://example.com", - "privacyUrl": "https://example.com/privacy", - "termsOfUseUrl": "https://example.com/terms" - }, - "description": { "short": "OpenClaw in Teams", "full": "OpenClaw in Teams" }, - "icons": { "outline": "outline.png", "color": "color.png" }, - "accentColor": "#5B6DEF", - "bots": [ - { - "botId": "11111111-1111-1111-1111-111111111111", - "scopes": ["personal", "team", "groupChat"], - "isNotificationOnly": false, - "supportsCalling": false, - "supportsVideo": false, - "supportsFiles": true - } - ], - "webApplicationInfo": { - "id": "11111111-1111-1111-1111-111111111111" - }, - "authorization": { - "permissions": { - "resourceSpecific": [ - { "name": "ChannelMessage.Read.Group", "type": "Application" }, - { "name": "ChannelMessage.Send.Group", "type": "Application" }, - { "name": "Member.Read.Group", "type": "Application" }, - { "name": "Owner.Read.Group", "type": "Application" }, - { "name": "ChannelSettings.Read.Group", "type": "Application" }, - { "name": "TeamMember.Read.Group", "type": "Application" }, - { "name": "TeamSettings.Read.Group", "type": "Application" }, - { "name": "ChatMessage.Read.Chat", "type": "Application" } - ] - } - } -} -``` - -### Manifest caveats (must-have fields) - -- `bots[].botId` **must** match the Azure Bot App ID. -- `webApplicationInfo.id` **must** match the Azure Bot App ID. -- `bots[].scopes` must include the surfaces you plan to use (`personal`, `team`, `groupChat`). -- `bots[].supportsFiles: true` is required for file handling in personal scope. -- `authorization.permissions.resourceSpecific` must include channel read/send if you want channel traffic. - -### Updating an existing app - -To update an already-installed Teams app (e.g., to add RSC permissions): - -1. Update your `manifest.json` with the new settings -2. **Increment the `version` field** (e.g., `1.0.0` → `1.1.0`) -3. **Re-zip** the manifest with icons (`manifest.json`, `outline.png`, `color.png`) -4. Upload the new zip: - - **Option A (Teams Admin Center):** Teams Admin Center → Teams apps → Manage apps → find your app → Upload new version - - **Option B (Sideload):** In Teams → Apps → Manage your apps → Upload a custom app -5. **For team channels:** Reinstall the app in each team for new permissions to take effect -6. **Fully quit and relaunch Teams** (not just close the window) to clear cached app metadata - -## Capabilities: RSC only vs Graph - -### With **Teams RSC only** (app installed, no Graph API permissions) - -Works: - -- Read channel message **text** content. -- Send channel message **text** content. -- Receive **personal (DM)** file attachments. - -Does NOT work: - -- Channel/group **image or file contents** (payload only includes HTML stub). -- Downloading attachments stored in SharePoint/OneDrive. -- Reading message history (beyond the live webhook event). - -### With **Teams RSC + Microsoft Graph Application permissions** - -Adds: - -- Downloading hosted contents (images pasted into messages). -- Downloading file attachments stored in SharePoint/OneDrive. -- Reading channel/chat message history via Graph. - -### RSC vs Graph API - -| Capability | RSC Permissions | Graph API | -| ----------------------- | -------------------- | ----------------------------------- | -| **Real-time messages** | Yes (via webhook) | No (polling only) | -| **Historical messages** | No | Yes (can query history) | -| **Setup complexity** | App manifest only | Requires admin consent + token flow | -| **Works offline** | No (must be running) | Yes (query anytime) | - -**Bottom line:** RSC is for real-time listening; Graph API is for historical access. For catching up on missed messages while offline, you need Graph API with `ChannelMessage.Read.All` (requires admin consent). - -## Graph-enabled media + history (required for channels) - -If you need images/files in **channels** or want to fetch **message history**, you must enable Microsoft Graph permissions and grant admin consent. - -1. In Entra ID (Azure AD) **App Registration**, add Microsoft Graph **Application permissions**: - - `ChannelMessage.Read.All` (channel attachments + history) - - `Chat.Read.All` or `ChatMessage.Read.All` (group chats) -2. **Grant admin consent** for the tenant. -3. Bump the Teams app **manifest version**, re-upload, and **reinstall the app in Teams**. -4. **Fully quit and relaunch Teams** to clear cached app metadata. - -**Additional permission for user mentions:** User @mentions work out of the box for users in the conversation. However, if you want to dynamically search and mention users who are **not in the current conversation**, add `User.Read.All` (Application) permission and grant admin consent. - -## Known Limitations - -### Webhook timeouts - -Teams delivers messages via HTTP webhook. If processing takes too long (e.g., slow LLM responses), you may see: - -- Gateway timeouts -- Teams retrying the message (causing duplicates) -- Dropped replies - -OpenClaw handles this by returning quickly and sending replies proactively, but very slow responses may still cause issues. - -### Formatting - -Teams markdown is more limited than Slack or Discord: - -- Basic formatting works: **bold**, _italic_, `code`, links -- Complex markdown (tables, nested lists) may not render correctly -- Adaptive Cards are supported for polls and arbitrary card sends (see below) - -## Configuration - -Key settings (see `/gateway/configuration` for shared channel patterns): - -- `channels.msteams.enabled`: enable/disable the channel. -- `channels.msteams.appId`, `channels.msteams.appPassword`, `channels.msteams.tenantId`: bot credentials. -- `channels.msteams.webhook.port` (default `3978`) -- `channels.msteams.webhook.path` (default `/api/messages`) -- `channels.msteams.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing) -- `channels.msteams.allowFrom`: allowlist for DMs (AAD object IDs, UPNs, or display names). The wizard resolves names to IDs during setup when Graph access is available. -- `channels.msteams.textChunkLimit`: outbound text chunk size. -- `channels.msteams.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking. -- `channels.msteams.mediaAllowHosts`: allowlist for inbound attachment hosts (defaults to Microsoft/Teams domains). -- `channels.msteams.mediaAuthAllowHosts`: allowlist for attaching Authorization headers on media retries (defaults to Graph + Bot Framework hosts). -- `channels.msteams.requireMention`: require @mention in channels/groups (default true). -- `channels.msteams.replyStyle`: `thread | top-level` (see [Reply Style](#reply-style-threads-vs-posts)). -- `channels.msteams.teams..replyStyle`: per-team override. -- `channels.msteams.teams..requireMention`: per-team override. -- `channels.msteams.teams..tools`: default per-team tool policy overrides (`allow`/`deny`/`alsoAllow`) used when a channel override is missing. -- `channels.msteams.teams..toolsBySender`: default per-team per-sender tool policy overrides (`"*"` wildcard supported). -- `channels.msteams.teams..channels..replyStyle`: per-channel override. -- `channels.msteams.teams..channels..requireMention`: per-channel override. -- `channels.msteams.teams..channels..tools`: per-channel tool policy overrides (`allow`/`deny`/`alsoAllow`). -- `channels.msteams.teams..channels..toolsBySender`: per-channel per-sender tool policy overrides (`"*"` wildcard supported). -- `channels.msteams.sharePointSiteId`: SharePoint site ID for file uploads in group chats/channels (see [Sending files in group chats](#sending-files-in-group-chats)). - -## Routing & Sessions - -- Session keys follow the standard agent format (see [/concepts/session](/concepts/session)): - - Direct messages share the main session (`agent::`). - - Channel/group messages use conversation id: - - `agent::msteams:channel:` - - `agent::msteams:group:` - -## Reply Style: Threads vs Posts - -Teams recently introduced two channel UI styles over the same underlying data model: - -| Style | Description | Recommended `replyStyle` | -| ------------------------ | --------------------------------------------------------- | ------------------------ | -| **Posts** (classic) | Messages appear as cards with threaded replies underneath | `thread` (default) | -| **Threads** (Slack-like) | Messages flow linearly, more like Slack | `top-level` | - -**The problem:** The Teams API does not expose which UI style a channel uses. If you use the wrong `replyStyle`: - -- `thread` in a Threads-style channel → replies appear nested awkwardly -- `top-level` in a Posts-style channel → replies appear as separate top-level posts instead of in-thread - -**Solution:** Configure `replyStyle` per-channel based on how the channel is set up: - -```json -{ - "msteams": { - "replyStyle": "thread", - "teams": { - "19:abc...@thread.tacv2": { - "channels": { - "19:xyz...@thread.tacv2": { - "replyStyle": "top-level" - } - } - } - } - } -} -``` - -## Attachments & Images - -**Current limitations:** - -- **DMs:** Images and file attachments work via Teams bot file APIs. -- **Channels/groups:** Attachments live in M365 storage (SharePoint/OneDrive). The webhook payload only includes an HTML stub, not the actual file bytes. **Graph API permissions are required** to download channel attachments. - -Without Graph permissions, channel messages with images will be received as text-only (the image content is not accessible to the bot). -By default, OpenClaw only downloads media from Microsoft/Teams hostnames. Override with `channels.msteams.mediaAllowHosts` (use `["*"]` to allow any host). -Authorization headers are only attached for hosts in `channels.msteams.mediaAuthAllowHosts` (defaults to Graph + Bot Framework hosts). Keep this list strict (avoid multi-tenant suffixes). - -## Sending files in group chats - -Bots can send files in DMs using the FileConsentCard flow (built-in). However, **sending files in group chats/channels** requires additional setup: - -| Context | How files are sent | Setup needed | -| ------------------------ | -------------------------------------------- | ----------------------------------------------- | -| **DMs** | FileConsentCard → user accepts → bot uploads | Works out of the box | -| **Group chats/channels** | Upload to SharePoint → share link | Requires `sharePointSiteId` + Graph permissions | -| **Images (any context)** | Base64-encoded inline | Works out of the box | - -### Why group chats need SharePoint - -Bots don't have a personal OneDrive drive (the `/me/drive` Graph API endpoint doesn't work for application identities). To send files in group chats/channels, the bot uploads to a **SharePoint site** and creates a sharing link. - -### Setup - -1. **Add Graph API permissions** in Entra ID (Azure AD) → App Registration: - - `Sites.ReadWrite.All` (Application) - upload files to SharePoint - - `Chat.Read.All` (Application) - optional, enables per-user sharing links - -2. **Grant admin consent** for the tenant. - -3. **Get your SharePoint site ID:** - - ```bash - # Via Graph Explorer or curl with a valid token: - curl -H "Authorization: Bearer $TOKEN" \ - "https://graph.microsoft.com/v1.0/sites/{hostname}:/{site-path}" - - # Example: for a site at "contoso.sharepoint.com/sites/BotFiles" - curl -H "Authorization: Bearer $TOKEN" \ - "https://graph.microsoft.com/v1.0/sites/contoso.sharepoint.com:/sites/BotFiles" - - # Response includes: "id": "contoso.sharepoint.com,guid1,guid2" - ``` - -4. **Configure OpenClaw:** - - ```json5 - { - channels: { - msteams: { - // ... other config ... - sharePointSiteId: "contoso.sharepoint.com,guid1,guid2", - }, - }, - } - ``` - -### Sharing behavior - -| Permission | Sharing behavior | -| --------------------------------------- | --------------------------------------------------------- | -| `Sites.ReadWrite.All` only | Organization-wide sharing link (anyone in org can access) | -| `Sites.ReadWrite.All` + `Chat.Read.All` | Per-user sharing link (only chat members can access) | - -Per-user sharing is more secure as only the chat participants can access the file. If `Chat.Read.All` permission is missing, the bot falls back to organization-wide sharing. - -### Fallback behavior - -| Scenario | Result | -| ------------------------------------------------- | -------------------------------------------------- | -| Group chat + file + `sharePointSiteId` configured | Upload to SharePoint, send sharing link | -| Group chat + file + no `sharePointSiteId` | Attempt OneDrive upload (may fail), send text only | -| Personal chat + file | FileConsentCard flow (works without SharePoint) | -| Any context + image | Base64-encoded inline (works without SharePoint) | - -### Files stored location - -Uploaded files are stored in a `/OpenClawShared/` folder in the configured SharePoint site's default document library. - -## Polls (Adaptive Cards) - -OpenClaw sends Teams polls as Adaptive Cards (there is no native Teams poll API). - -- CLI: `openclaw message poll --channel msteams --target conversation: ...` -- Votes are recorded by the gateway in `~/.openclaw/msteams-polls.json`. -- The gateway must stay online to record votes. -- Polls do not auto-post result summaries yet (inspect the store file if needed). - -## Adaptive Cards (arbitrary) - -Send any Adaptive Card JSON to Teams users or conversations using the `message` tool or CLI. - -The `card` parameter accepts an Adaptive Card JSON object. When `card` is provided, the message text is optional. - -**Agent tool:** - -```json -{ - "action": "send", - "channel": "msteams", - "target": "user:", - "card": { - "type": "AdaptiveCard", - "version": "1.5", - "body": [{ "type": "TextBlock", "text": "Hello!" }] - } -} -``` - -**CLI:** - -```bash -openclaw message send --channel msteams \ - --target "conversation:19:abc...@thread.tacv2" \ - --card '{"type":"AdaptiveCard","version":"1.5","body":[{"type":"TextBlock","text":"Hello!"}]}' -``` - -See [Adaptive Cards documentation](https://adaptivecards.io/) for card schema and examples. For target format details, see [Target formats](#target-formats) below. - -## Target formats - -MSTeams targets use prefixes to distinguish between users and conversations: - -| Target type | Format | Example | -| ------------------- | -------------------------------- | --------------------------------------------------- | -| User (by ID) | `user:` | `user:40a1a0ed-4ff2-4164-a219-55518990c197` | -| User (by name) | `user:` | `user:John Smith` (requires Graph API) | -| Group/channel | `conversation:` | `conversation:19:abc123...@thread.tacv2` | -| Group/channel (raw) | `` | `19:abc123...@thread.tacv2` (if contains `@thread`) | - -**CLI examples:** - -```bash -# Send to a user by ID -openclaw message send --channel msteams --target "user:40a1a0ed-..." --message "Hello" - -# Send to a user by display name (triggers Graph API lookup) -openclaw message send --channel msteams --target "user:John Smith" --message "Hello" - -# Send to a group chat or channel -openclaw message send --channel msteams --target "conversation:19:abc...@thread.tacv2" --message "Hello" - -# Send an Adaptive Card to a conversation -openclaw message send --channel msteams --target "conversation:19:abc...@thread.tacv2" \ - --card '{"type":"AdaptiveCard","version":"1.5","body":[{"type":"TextBlock","text":"Hello"}]}' -``` - -**Agent tool examples:** - -```json -{ - "action": "send", - "channel": "msteams", - "target": "user:John Smith", - "message": "Hello!" -} -``` - -```json -{ - "action": "send", - "channel": "msteams", - "target": "conversation:19:abc...@thread.tacv2", - "card": { - "type": "AdaptiveCard", - "version": "1.5", - "body": [{ "type": "TextBlock", "text": "Hello" }] - } -} -``` - -Note: Without the `user:` prefix, names default to group/team resolution. Always use `user:` when targeting people by display name. - -## Proactive messaging - -- Proactive messages are only possible **after** a user has interacted, because we store conversation references at that point. -- See `/gateway/configuration` for `dmPolicy` and allowlist gating. - -## Team and Channel IDs (Common Gotcha) - -The `groupId` query parameter in Teams URLs is **NOT** the team ID used for configuration. Extract IDs from the URL path instead: - -**Team URL:** - -``` -https://teams.microsoft.com/l/team/19%3ABk4j...%40thread.tacv2/conversations?groupId=... - └────────────────────────────┘ - Team ID (URL-decode this) -``` - -**Channel URL:** - -``` -https://teams.microsoft.com/l/channel/19%3A15bc...%40thread.tacv2/ChannelName?groupId=... - └─────────────────────────┘ - Channel ID (URL-decode this) -``` - -**For config:** - -- Team ID = path segment after `/team/` (URL-decoded, e.g., `19:Bk4j...@thread.tacv2`) -- Channel ID = path segment after `/channel/` (URL-decoded) -- **Ignore** the `groupId` query parameter - -## Private Channels - -Bots have limited support in private channels: - -| Feature | Standard Channels | Private Channels | -| ---------------------------- | ----------------- | ---------------------- | -| Bot installation | Yes | Limited | -| Real-time messages (webhook) | Yes | May not work | -| RSC permissions | Yes | May behave differently | -| @mentions | Yes | If bot is accessible | -| Graph API history | Yes | Yes (with permissions) | - -**Workarounds if private channels don't work:** - -1. Use standard channels for bot interactions -2. Use DMs - users can always message the bot directly -3. Use Graph API for historical access (requires `ChannelMessage.Read.All`) - -## Troubleshooting - -### Common issues - -- **Images not showing in channels:** Graph permissions or admin consent missing. Reinstall the Teams app and fully quit/reopen Teams. -- **No responses in channel:** mentions are required by default; set `channels.msteams.requireMention=false` or configure per team/channel. -- **Version mismatch (Teams still shows old manifest):** remove + re-add the app and fully quit Teams to refresh. -- **401 Unauthorized from webhook:** Expected when testing manually without Azure JWT - means endpoint is reachable but auth failed. Use Azure Web Chat to test properly. - -### Manifest upload errors - -- **"Icon file cannot be empty":** The manifest references icon files that are 0 bytes. Create valid PNG icons (32x32 for `outline.png`, 192x192 for `color.png`). -- **"webApplicationInfo.Id already in use":** The app is still installed in another team/chat. Find and uninstall it first, or wait 5-10 minutes for propagation. -- **"Something went wrong" on upload:** Upload via [https://admin.teams.microsoft.com](https://admin.teams.microsoft.com) instead, open browser DevTools (F12) → Network tab, and check the response body for the actual error. -- **Sideload failing:** Try "Upload an app to your org's app catalog" instead of "Upload a custom app" - this often bypasses sideload restrictions. - -### RSC permissions not working - -1. Verify `webApplicationInfo.id` matches your bot's App ID exactly -2. Re-upload the app and reinstall in the team/chat -3. Check if your org admin has blocked RSC permissions -4. Confirm you're using the right scope: `ChannelMessage.Read.Group` for teams, `ChatMessage.Read.Chat` for group chats - -## References - -- [Create Azure Bot](https://learn.microsoft.com/en-us/azure/bot-service/bot-service-quickstart-registration) - Azure Bot setup guide -- [Teams Developer Portal](https://dev.teams.microsoft.com/apps) - create/manage Teams apps -- [Teams app manifest schema](https://learn.microsoft.com/en-us/microsoftteams/platform/resources/schema/manifest-schema) -- [Receive channel messages with RSC](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/channel-messages-with-rsc) -- [RSC permissions reference](https://learn.microsoft.com/en-us/microsoftteams/platform/graph-api/rsc/resource-specific-consent) -- [Teams bot file handling](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/bots-filesv4) (channel/group requires Graph) -- [Proactive messaging](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/send-proactive-messages) diff --git a/docs/channels/nextcloud-talk.md b/docs/channels/nextcloud-talk.md deleted file mode 100644 index d4ab9e2c397..00000000000 --- a/docs/channels/nextcloud-talk.md +++ /dev/null @@ -1,138 +0,0 @@ ---- -summary: "Nextcloud Talk support status, capabilities, and configuration" -read_when: - - Working on Nextcloud Talk channel features -title: "Nextcloud Talk" ---- - -# Nextcloud Talk (plugin) - -Status: supported via plugin (webhook bot). Direct messages, rooms, reactions, and markdown messages are supported. - -## Plugin required - -Nextcloud Talk ships as a plugin and is not bundled with the core install. - -Install via CLI (npm registry): - -```bash -openclaw plugins install @openclaw/nextcloud-talk -``` - -Local checkout (when running from a git repo): - -```bash -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](/tools/plugin) - -## Quick setup (beginner) - -1. Install the Nextcloud Talk plugin. -2. On your Nextcloud server, create a bot: - - ```bash - ./occ talk:bot:install "OpenClaw" "" "" --feature reaction - ``` - -3. Enable the bot in the target room settings. -4. Configure OpenClaw: - - Config: `channels.nextcloud-talk.baseUrl` + `channels.nextcloud-talk.botSecret` - - Or env: `NEXTCLOUD_TALK_BOT_SECRET` (default account only) -5. Restart the gateway (or finish onboarding). - -Minimal config: - -```json5 -{ - channels: { - "nextcloud-talk": { - enabled: true, - baseUrl: "https://cloud.example.com", - botSecret: "shared-secret", - dmPolicy: "pairing", - }, - }, -} -``` - -## Notes - -- Bots cannot initiate DMs. The user must message the bot first. -- Webhook URL must be reachable by the Gateway; set `webhookPublicUrl` if behind a proxy. -- Media uploads are not supported by the bot API; media is sent as URLs. -- The webhook payload does not distinguish DMs vs rooms; set `apiUser` + `apiPassword` to enable room-type lookups (otherwise DMs are treated as rooms). - -## Access control (DMs) - -- Default: `channels.nextcloud-talk.dmPolicy = "pairing"`. Unknown senders get a pairing code. -- Approve via: - - `openclaw pairing list nextcloud-talk` - - `openclaw pairing approve nextcloud-talk ` -- Public DMs: `channels.nextcloud-talk.dmPolicy="open"` plus `channels.nextcloud-talk.allowFrom=["*"]`. -- `allowFrom` matches Nextcloud user IDs only; display names are ignored. - -## Rooms (groups) - -- Default: `channels.nextcloud-talk.groupPolicy = "allowlist"` (mention-gated). -- Allowlist rooms with `channels.nextcloud-talk.rooms`: - -```json5 -{ - channels: { - "nextcloud-talk": { - rooms: { - "room-token": { requireMention: true }, - }, - }, - }, -} -``` - -- To allow no rooms, keep the allowlist empty or set `channels.nextcloud-talk.groupPolicy="disabled"`. - -## Capabilities - -| Feature | Status | -| --------------- | ------------- | -| Direct messages | Supported | -| Rooms | Supported | -| Threads | Not supported | -| Media | URL-only | -| Reactions | Supported | -| Native commands | Not supported | - -## Configuration reference (Nextcloud Talk) - -Full configuration: [Configuration](/gateway/configuration) - -Provider options: - -- `channels.nextcloud-talk.enabled`: enable/disable channel startup. -- `channels.nextcloud-talk.baseUrl`: Nextcloud instance URL. -- `channels.nextcloud-talk.botSecret`: bot shared secret. -- `channels.nextcloud-talk.botSecretFile`: secret file path. -- `channels.nextcloud-talk.apiUser`: API user for room lookups (DM detection). -- `channels.nextcloud-talk.apiPassword`: API/app password for room lookups. -- `channels.nextcloud-talk.apiPasswordFile`: API password file path. -- `channels.nextcloud-talk.webhookPort`: webhook listener port (default: 8788). -- `channels.nextcloud-talk.webhookHost`: webhook host (default: 0.0.0.0). -- `channels.nextcloud-talk.webhookPath`: webhook path (default: /nextcloud-talk-webhook). -- `channels.nextcloud-talk.webhookPublicUrl`: externally reachable webhook URL. -- `channels.nextcloud-talk.dmPolicy`: `pairing | allowlist | open | disabled`. -- `channels.nextcloud-talk.allowFrom`: DM allowlist (user IDs). `open` requires `"*"`. -- `channels.nextcloud-talk.groupPolicy`: `allowlist | open | disabled`. -- `channels.nextcloud-talk.groupAllowFrom`: group allowlist (user IDs). -- `channels.nextcloud-talk.rooms`: per-room settings and allowlist. -- `channels.nextcloud-talk.historyLimit`: group history limit (0 disables). -- `channels.nextcloud-talk.dmHistoryLimit`: DM history limit (0 disables). -- `channels.nextcloud-talk.dms`: per-DM overrides (historyLimit). -- `channels.nextcloud-talk.textChunkLimit`: outbound text chunk size (chars). -- `channels.nextcloud-talk.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking. -- `channels.nextcloud-talk.blockStreaming`: disable block streaming for this channel. -- `channels.nextcloud-talk.blockStreamingCoalesce`: block streaming coalesce tuning. -- `channels.nextcloud-talk.mediaMaxMb`: inbound media cap (MB). diff --git a/docs/channels/nostr.md b/docs/channels/nostr.md deleted file mode 100644 index 3368933d6c4..00000000000 --- a/docs/channels/nostr.md +++ /dev/null @@ -1,233 +0,0 @@ ---- -summary: "Nostr DM channel via NIP-04 encrypted messages" -read_when: - - You want OpenClaw to receive DMs via Nostr - - You're setting up decentralized messaging -title: "Nostr" ---- - -# Nostr - -**Status:** Optional plugin (disabled by default). - -Nostr is a decentralized protocol for social networking. This channel enables OpenClaw to receive and respond to encrypted direct messages (DMs) via NIP-04. - -## Install (on demand) - -### Onboarding (recommended) - -- The onboarding wizard (`openclaw onboard`) and `openclaw channels add` list optional channel plugins. -- Selecting Nostr prompts you to install the plugin on demand. - -Install defaults: - -- **Dev channel + git checkout available:** uses the local plugin path. -- **Stable/Beta:** downloads from npm. - -You can always override the choice in the prompt. - -### Manual install - -```bash -openclaw plugins install @openclaw/nostr -``` - -Use a local checkout (dev workflows): - -```bash -openclaw plugins install --link /extensions/nostr -``` - -Restart the Gateway after installing or enabling plugins. - -## Quick setup - -1. Generate a Nostr keypair (if needed): - -```bash -# Using nak -nak key generate -``` - -2. Add to config: - -```json -{ - "channels": { - "nostr": { - "privateKey": "${NOSTR_PRIVATE_KEY}" - } - } -} -``` - -3. Export the key: - -```bash -export NOSTR_PRIVATE_KEY="nsec1..." -``` - -4. Restart the Gateway. - -## Configuration reference - -| Key | Type | Default | Description | -| ------------ | -------- | ------------------------------------------- | ----------------------------------- | -| `privateKey` | string | required | Private key in `nsec` or hex format | -| `relays` | string[] | `['wss://relay.damus.io', 'wss://nos.lol']` | Relay URLs (WebSocket) | -| `dmPolicy` | string | `pairing` | DM access policy | -| `allowFrom` | string[] | `[]` | Allowed sender pubkeys | -| `enabled` | boolean | `true` | Enable/disable channel | -| `name` | string | - | Display name | -| `profile` | object | - | NIP-01 profile metadata | - -## Profile metadata - -Profile data is published as a NIP-01 `kind:0` event. You can manage it from the Control UI (Channels -> Nostr -> Profile) or set it directly in config. - -Example: - -```json -{ - "channels": { - "nostr": { - "privateKey": "${NOSTR_PRIVATE_KEY}", - "profile": { - "name": "openclaw", - "displayName": "OpenClaw", - "about": "Personal assistant DM bot", - "picture": "https://example.com/avatar.png", - "banner": "https://example.com/banner.png", - "website": "https://example.com", - "nip05": "openclaw@example.com", - "lud16": "openclaw@example.com" - } - } - } -} -``` - -Notes: - -- Profile URLs must use `https://`. -- Importing from relays merges fields and preserves local overrides. - -## Access control - -### DM policies - -- **pairing** (default): unknown senders get a pairing code. -- **allowlist**: only pubkeys in `allowFrom` can DM. -- **open**: public inbound DMs (requires `allowFrom: ["*"]`). -- **disabled**: ignore inbound DMs. - -### Allowlist example - -```json -{ - "channels": { - "nostr": { - "privateKey": "${NOSTR_PRIVATE_KEY}", - "dmPolicy": "allowlist", - "allowFrom": ["npub1abc...", "npub1xyz..."] - } - } -} -``` - -## Key formats - -Accepted formats: - -- **Private key:** `nsec...` or 64-char hex -- **Pubkeys (`allowFrom`):** `npub...` or hex - -## Relays - -Defaults: `relay.damus.io` and `nos.lol`. - -```json -{ - "channels": { - "nostr": { - "privateKey": "${NOSTR_PRIVATE_KEY}", - "relays": ["wss://relay.damus.io", "wss://relay.primal.net", "wss://nostr.wine"] - } - } -} -``` - -Tips: - -- Use 2-3 relays for redundancy. -- Avoid too many relays (latency, duplication). -- Paid relays can improve reliability. -- Local relays are fine for testing (`ws://localhost:7777`). - -## Protocol support - -| NIP | Status | Description | -| ------ | --------- | ------------------------------------- | -| NIP-01 | Supported | Basic event format + profile metadata | -| NIP-04 | Supported | Encrypted DMs (`kind:4`) | -| NIP-17 | Planned | Gift-wrapped DMs | -| NIP-44 | Planned | Versioned encryption | - -## Testing - -### Local relay - -```bash -# Start strfry -docker run -p 7777:7777 ghcr.io/hoytech/strfry -``` - -```json -{ - "channels": { - "nostr": { - "privateKey": "${NOSTR_PRIVATE_KEY}", - "relays": ["ws://localhost:7777"] - } - } -} -``` - -### Manual test - -1. Note the bot pubkey (npub) from logs. -2. Open a Nostr client (Damus, Amethyst, etc.). -3. DM the bot pubkey. -4. Verify the response. - -## Troubleshooting - -### Not receiving messages - -- Verify the private key is valid. -- Ensure relay URLs are reachable and use `wss://` (or `ws://` for local). -- Confirm `enabled` is not `false`. -- Check Gateway logs for relay connection errors. - -### Not sending responses - -- Check relay accepts writes. -- Verify outbound connectivity. -- Watch for relay rate limits. - -### Duplicate responses - -- Expected when using multiple relays. -- Messages are deduplicated by event ID; only the first delivery triggers a response. - -## Security - -- Never commit private keys. -- Use environment variables for keys. -- Consider `allowlist` for production bots. - -## Limitations (MVP) - -- Direct messages only (no group chats). -- No media attachments. -- NIP-04 only (NIP-17 gift-wrap planned). diff --git a/docs/channels/pairing.md b/docs/channels/pairing.md deleted file mode 100644 index 4b575eb87c7..00000000000 --- a/docs/channels/pairing.md +++ /dev/null @@ -1,103 +0,0 @@ ---- -summary: "Pairing overview: approve who can DM you + which nodes can join" -read_when: - - Setting up DM access control - - Pairing a new iOS/Android node - - Reviewing OpenClaw security posture -title: "Pairing" ---- - -# Pairing - -“Pairing” is OpenClaw’s explicit **owner approval** step. -It is used in two places: - -1. **DM pairing** (who is allowed to talk to the bot) -2. **Node pairing** (which devices/nodes are allowed to join the gateway network) - -Security context: [Security](/gateway/security) - -## 1) DM pairing (inbound chat access) - -When a channel is configured with DM policy `pairing`, unknown senders get a short code and their message is **not processed** until you approve. - -Default DM policies are documented in: [Security](/gateway/security) - -Pairing codes: - -- 8 characters, uppercase, no ambiguous chars (`0O1I`). -- **Expire after 1 hour**. The bot only sends the pairing message when a new request is created (roughly once per hour per sender). -- Pending DM pairing requests are capped at **3 per channel** by default; additional requests are ignored until one expires or is approved. - -### Approve a sender - -```bash -openclaw pairing list telegram -openclaw pairing approve telegram -``` - -Supported channels: `telegram`, `whatsapp`, `signal`, `imessage`, `discord`, `slack`, `feishu`. - -### Where the state lives - -Stored under `~/.openclaw/credentials/`: - -- Pending requests: `-pairing.json` -- Approved allowlist store: `-allowFrom.json` - -Treat these as sensitive (they gate access to your assistant). - -## 2) Node device pairing (iOS/Android/macOS/headless nodes) - -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 -openclaw devices list -openclaw devices approve -openclaw devices reject -``` - -### Node pairing state storage - -Stored under `~/.openclaw/devices/`: - -- `pending.json` (short-lived; pending requests expire) -- `paired.json` (paired devices + tokens) - -### Notes - -- The legacy `node.pair.*` API (CLI: `openclaw nodes pending/approve`) is a - separate gateway-owned pairing store. WS nodes still require device pairing. - -## Related docs - -- Security model + prompt injection: [Security](/gateway/security) -- Updating safely (run doctor): [Updating](/install/updating) -- Channel configs: - - Telegram: [Telegram](/channels/telegram) - - WhatsApp: [WhatsApp](/channels/whatsapp) - - Signal: [Signal](/channels/signal) - - BlueBubbles (iMessage): [BlueBubbles](/channels/bluebubbles) - - iMessage (legacy): [iMessage](/channels/imessage) - - Discord: [Discord](/channels/discord) - - Slack: [Slack](/channels/slack) diff --git a/docs/channels/signal.md b/docs/channels/signal.md deleted file mode 100644 index 60bb5f7ce92..00000000000 --- a/docs/channels/signal.md +++ /dev/null @@ -1,324 +0,0 @@ ---- -summary: "Signal support via signal-cli (JSON-RPC + SSE), setup paths, and number model" -read_when: - - Setting up Signal support - - Debugging Signal send/receive -title: "Signal" ---- - -# Signal (signal-cli) - -Status: external CLI integration. Gateway talks to `signal-cli` over HTTP JSON-RPC + SSE. - -## Prerequisites - -- OpenClaw installed on your server (Linux flow below tested on Ubuntu 24). -- `signal-cli` available on the host where the gateway runs. -- A phone number that can receive one verification SMS (for SMS registration path). -- Browser access for Signal captcha (`signalcaptchas.org`) during registration. - -## Quick setup (beginner) - -1. Use a **separate Signal number** for the bot (recommended). -2. Install `signal-cli` (Java required if you use the JVM build). -3. Choose one setup path: - - **Path A (QR link):** `signal-cli link -n "OpenClaw"` and scan with Signal. - - **Path B (SMS register):** register a dedicated number with captcha + SMS verification. -4. Configure OpenClaw and restart the gateway. -5. Send a first DM and approve pairing (`openclaw pairing approve signal `). - -Minimal config: - -```json5 -{ - channels: { - signal: { - enabled: true, - account: "+15551234567", - cliPath: "signal-cli", - dmPolicy: "pairing", - allowFrom: ["+15557654321"], - }, - }, -} -``` - -Field reference: - -| Field | Description | -| ----------- | ------------------------------------------------- | -| `account` | Bot phone number in E.164 format (`+15551234567`) | -| `cliPath` | Path to `signal-cli` (`signal-cli` if on `PATH`) | -| `dmPolicy` | DM access policy (`pairing` recommended) | -| `allowFrom` | Phone numbers or `uuid:` values allowed to DM | - -## What it is - -- Signal channel via `signal-cli` (not embedded libsignal). -- Deterministic routing: replies always go back to Signal. -- DMs share the agent's main session; groups are isolated (`agent::signal:group:`). - -## Config writes - -By default, Signal is allowed to write config updates triggered by `/config set|unset` (requires `commands.config: true`). - -Disable with: - -```json5 -{ - channels: { signal: { configWrites: false } }, -} -``` - -## The number model (important) - -- The gateway connects to a **Signal device** (the `signal-cli` account). -- If you run the bot on **your personal Signal account**, it will ignore your own messages (loop protection). -- For "I text the bot and it replies," use a **separate bot number**. - -## Setup path A: link existing Signal account (QR) - -1. Install `signal-cli` (JVM or native build). -2. Link a bot account: - - `signal-cli link -n "OpenClaw"` then scan the QR in Signal. -3. Configure Signal and start the gateway. - -Example: - -```json5 -{ - channels: { - signal: { - enabled: true, - account: "+15551234567", - cliPath: "signal-cli", - dmPolicy: "pairing", - allowFrom: ["+15557654321"], - }, - }, -} -``` - -Multi-account support: use `channels.signal.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern. - -## Setup path B: register dedicated bot number (SMS, Linux) - -Use this when you want a dedicated bot number instead of linking an existing Signal app account. - -1. Get a number that can receive SMS (or voice verification for landlines). - - Use a dedicated bot number to avoid account/session conflicts. -2. Install `signal-cli` on the gateway host: - -```bash -VERSION=$(curl -Ls -o /dev/null -w %{url_effective} https://github.com/AsamK/signal-cli/releases/latest | sed -e 's/^.*\/v//') -curl -L -O "https://github.com/AsamK/signal-cli/releases/download/v${VERSION}/signal-cli-${VERSION}-Linux-native.tar.gz" -sudo tar xf "signal-cli-${VERSION}-Linux-native.tar.gz" -C /opt -sudo ln -sf /opt/signal-cli /usr/local/bin/ -signal-cli --version -``` - -If you use the JVM build (`signal-cli-${VERSION}.tar.gz`), install JRE 25+ first. -Keep `signal-cli` updated; upstream notes that old releases can break as Signal server APIs change. - -3. Register and verify the number: - -```bash -signal-cli -a + register -``` - -If captcha is required: - -1. Open `https://signalcaptchas.org/registration/generate.html`. -2. Complete captcha, copy the `signalcaptcha://...` link target from "Open Signal". -3. Run from the same external IP as the browser session when possible. -4. Run registration again immediately (captcha tokens expire quickly): - -```bash -signal-cli -a + register --captcha '' -signal-cli -a + verify -``` - -4. Configure OpenClaw, restart gateway, verify channel: - -```bash -# If you run the gateway as a user systemd service: -systemctl --user restart openclaw-gateway - -# Then verify: -openclaw doctor -openclaw channels status --probe -``` - -5. Pair your DM sender: - - Send any message to the bot number. - - Approve code on the server: `openclaw pairing approve signal `. - - Save the bot number as a contact on your phone to avoid "Unknown contact". - -Important: registering a phone number account with `signal-cli` can de-authenticate the main Signal app session for that number. Prefer a dedicated bot number, or use QR link mode if you need to keep your existing phone app setup. - -Upstream references: - -- `signal-cli` README: `https://github.com/AsamK/signal-cli` -- Captcha flow: `https://github.com/AsamK/signal-cli/wiki/Registration-with-captcha` -- Linking flow: `https://github.com/AsamK/signal-cli/wiki/Linking-other-devices-(Provisioning)` - -## External daemon mode (httpUrl) - -If you want to manage `signal-cli` yourself (slow JVM cold starts, container init, or shared CPUs), run the daemon separately and point OpenClaw at it: - -```json5 -{ - channels: { - signal: { - httpUrl: "http://127.0.0.1:8080", - autoStart: false, - }, - }, -} -``` - -This skips auto-spawn and the startup wait inside OpenClaw. For slow starts when auto-spawning, set `channels.signal.startupTimeoutMs`. - -## Access control (DMs + groups) - -DMs: - -- Default: `channels.signal.dmPolicy = "pairing"`. -- Unknown senders receive a pairing code; messages are ignored until approved (codes expire after 1 hour). -- Approve via: - - `openclaw pairing list signal` - - `openclaw pairing approve signal ` -- Pairing is the default token exchange for Signal DMs. Details: [Pairing](/channels/pairing) -- UUID-only senders (from `sourceUuid`) are stored as `uuid:` in `channels.signal.allowFrom`. - -Groups: - -- `channels.signal.groupPolicy = open | allowlist | disabled`. -- `channels.signal.groupAllowFrom` controls who can trigger in groups when `allowlist` is set. - -## How it works (behavior) - -- `signal-cli` runs as a daemon; the gateway reads events via SSE. -- Inbound messages are normalized into the shared channel envelope. -- Replies always route back to the same number or group. - -## Media + limits - -- Outbound text is chunked to `channels.signal.textChunkLimit` (default 4000). -- Optional newline chunking: set `channels.signal.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking. -- Attachments supported (base64 fetched from `signal-cli`). -- Default media cap: `channels.signal.mediaMaxMb` (default 8). -- Use `channels.signal.ignoreAttachments` to skip downloading media. -- Group history context uses `channels.signal.historyLimit` (or `channels.signal.accounts.*.historyLimit`), falling back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50). - -## Typing + read receipts - -- **Typing indicators**: OpenClaw sends typing signals via `signal-cli sendTyping` and refreshes them while a reply is running. -- **Read receipts**: when `channels.signal.sendReadReceipts` is true, OpenClaw forwards read receipts for allowed DMs. -- Signal-cli does not expose read receipts for groups. - -## Reactions (message tool) - -- Use `message action=react` with `channel=signal`. -- Targets: sender E.164 or UUID (use `uuid:` from pairing output; bare UUID works too). -- `messageId` is the Signal timestamp for the message you’re reacting to. -- Group reactions require `targetAuthor` or `targetAuthorUuid`. - -Examples: - -``` -message action=react channel=signal target=uuid:123e4567-e89b-12d3-a456-426614174000 messageId=1737630212345 emoji=🔥 -message action=react channel=signal target=+15551234567 messageId=1737630212345 emoji=🔥 remove=true -message action=react channel=signal target=signal:group: targetAuthor=uuid: messageId=1737630212345 emoji=✅ -``` - -Config: - -- `channels.signal.actions.reactions`: enable/disable reaction actions (default true). -- `channels.signal.reactionLevel`: `off | ack | minimal | extensive`. - - `off`/`ack` disables agent reactions (message tool `react` will error). - - `minimal`/`extensive` enables agent reactions and sets the guidance level. -- Per-account overrides: `channels.signal.accounts..actions.reactions`, `channels.signal.accounts..reactionLevel`. - -## Delivery targets (CLI/cron) - -- DMs: `signal:+15551234567` (or plain E.164). -- UUID DMs: `uuid:` (or bare UUID). -- Groups: `signal:group:`. -- Usernames: `username:` (if supported by your Signal account). - -## Troubleshooting - -Run this ladder first: - -```bash -openclaw status -openclaw gateway status -openclaw logs --follow -openclaw doctor -openclaw channels status --probe -``` - -Then confirm DM pairing state if needed: - -```bash -openclaw pairing list signal -``` - -Common failures: - -- Daemon reachable but no replies: verify account/daemon settings (`httpUrl`, `account`) and receive mode. -- DMs ignored: sender is pending pairing approval. -- Group messages ignored: group sender/mention gating blocks delivery. -- Config validation errors after edits: run `openclaw doctor --fix`. -- Signal missing from diagnostics: confirm `channels.signal.enabled: true`. - -Extra checks: - -```bash -openclaw pairing list signal -pgrep -af signal-cli -grep -i "signal" "/tmp/openclaw/openclaw-$(date +%Y-%m-%d).log" | tail -20 -``` - -For triage flow: [/channels/troubleshooting](/channels/troubleshooting). - -## Security notes - -- `signal-cli` stores account keys locally (typically `~/.local/share/signal-cli/data/`). -- Back up Signal account state before server migration or rebuild. -- Keep `channels.signal.dmPolicy: "pairing"` unless you explicitly want broader DM access. -- SMS verification is only needed for registration or recovery flows, but losing control of the number/account can complicate re-registration. - -## Configuration reference (Signal) - -Full configuration: [Configuration](/gateway/configuration) - -Provider options: - -- `channels.signal.enabled`: enable/disable channel startup. -- `channels.signal.account`: E.164 for the bot account. -- `channels.signal.cliPath`: path to `signal-cli`. -- `channels.signal.httpUrl`: full daemon URL (overrides host/port). -- `channels.signal.httpHost`, `channels.signal.httpPort`: daemon bind (default 127.0.0.1:8080). -- `channels.signal.autoStart`: auto-spawn daemon (default true if `httpUrl` unset). -- `channels.signal.startupTimeoutMs`: startup wait timeout in ms (cap 120000). -- `channels.signal.receiveMode`: `on-start | manual`. -- `channels.signal.ignoreAttachments`: skip attachment downloads. -- `channels.signal.ignoreStories`: ignore stories from the daemon. -- `channels.signal.sendReadReceipts`: forward read receipts. -- `channels.signal.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing). -- `channels.signal.allowFrom`: DM allowlist (E.164 or `uuid:`). `open` requires `"*"`. Signal has no usernames; use phone/UUID ids. -- `channels.signal.groupPolicy`: `open | allowlist | disabled` (default: allowlist). -- `channels.signal.groupAllowFrom`: group sender allowlist. -- `channels.signal.historyLimit`: max group messages to include as context (0 disables). -- `channels.signal.dmHistoryLimit`: DM history limit in user turns. Per-user overrides: `channels.signal.dms[""].historyLimit`. -- `channels.signal.textChunkLimit`: outbound chunk size (chars). -- `channels.signal.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking. -- `channels.signal.mediaMaxMb`: inbound/outbound media cap (MB). - -Related global options: - -- `agents.list[].groupChat.mentionPatterns` (Signal does not support native mentions). -- `messages.groupChat.mentionPatterns` (global fallback). -- `messages.responsePrefix`. diff --git a/docs/channels/slack.md b/docs/channels/slack.md deleted file mode 100644 index 0d0bba3cb27..00000000000 --- a/docs/channels/slack.md +++ /dev/null @@ -1,525 +0,0 @@ ---- -summary: "Slack setup and runtime behavior (Socket Mode + HTTP Events API)" -read_when: - - Setting up Slack or debugging Slack socket/HTTP mode -title: "Slack" ---- - -# Slack - -Status: production-ready for DMs + channels via Slack app integrations. Default mode is Socket Mode; HTTP Events API mode is also supported. - - - - Slack DMs default to pairing mode. - - - Native command behavior and command catalog. - - - Cross-channel diagnostics and repair playbooks. - - - -## Quick setup - - - - - - In Slack app settings: - - - enable **Socket Mode** - - create **App Token** (`xapp-...`) with `connections:write` - - install app and copy **Bot Token** (`xoxb-...`) - - - - -```json5 -{ - channels: { - slack: { - enabled: true, - mode: "socket", - appToken: "xapp-...", - botToken: "xoxb-...", - }, - }, -} -``` - - Env fallback (default account only): - -```bash -SLACK_APP_TOKEN=xapp-... -SLACK_BOT_TOKEN=xoxb-... -``` - - - - - Subscribe bot events for: - - - `app_mention` - - `message.channels`, `message.groups`, `message.im`, `message.mpim` - - `reaction_added`, `reaction_removed` - - `member_joined_channel`, `member_left_channel` - - `channel_rename` - - `pin_added`, `pin_removed` - - Also enable App Home **Messages Tab** for DMs. - - - - -```bash -openclaw gateway -``` - - - - - - - - - - - - set mode to HTTP (`channels.slack.mode="http"`) - - copy Slack **Signing Secret** - - set Event Subscriptions + Interactivity + Slash command Request URL to the same webhook path (default `/slack/events`) - - - - - -```json5 -{ - channels: { - slack: { - enabled: true, - mode: "http", - botToken: "xoxb-...", - signingSecret: "your-signing-secret", - webhookPath: "/slack/events", - }, - }, -} -``` - - - - - Per-account HTTP mode is supported. - - Give each account a distinct `webhookPath` so registrations do not collide. - - - - - - -## Token model - -- `botToken` + `appToken` are required for Socket Mode. -- HTTP mode requires `botToken` + `signingSecret`. -- Config tokens override env fallback. -- `SLACK_BOT_TOKEN` / `SLACK_APP_TOKEN` env fallback applies only to the default account. -- `userToken` (`xoxp-...`) is config-only (no env fallback) and defaults to read-only behavior (`userTokenReadOnly: true`). -- Optional: add `chat:write.customize` if you want outgoing messages to use the active agent identity (custom `username` and icon). `icon_emoji` uses `:emoji_name:` syntax. - - -For actions/directory reads, user token can be preferred when configured. For writes, bot token remains preferred; user-token writes are only allowed when `userTokenReadOnly: false` and bot token is unavailable. - - -## Access control and routing - - - - `channels.slack.dmPolicy` controls DM access (legacy: `channels.slack.dm.policy`): - - - `pairing` (default) - - `allowlist` - - `open` (requires `channels.slack.allowFrom` to include `"*"`; legacy: `channels.slack.dm.allowFrom`) - - `disabled` - - DM flags: - - - `dm.enabled` (default true) - - `channels.slack.allowFrom` (preferred) - - `dm.allowFrom` (legacy) - - `dm.groupEnabled` (group DMs default false) - - `dm.groupChannels` (optional MPIM allowlist) - - Pairing in DMs uses `openclaw pairing approve slack `. - - - - - `channels.slack.groupPolicy` controls channel handling: - - - `open` - - `allowlist` - - `disabled` - - Channel allowlist lives under `channels.slack.channels`. - - Runtime note: if `channels.slack` is completely missing (env-only setup) and `channels.defaults.groupPolicy` is unset, runtime falls back to `groupPolicy="open"` and logs a warning. - - Name/ID resolution: - - - channel allowlist entries and DM allowlist entries are resolved at startup when token access allows - - unresolved entries are kept as configured - - - - - Channel messages are mention-gated by default. - - Mention sources: - - - explicit app mention (`<@botId>`) - - mention regex patterns (`agents.list[].groupChat.mentionPatterns`, fallback `messages.groupChat.mentionPatterns`) - - implicit reply-to-bot thread behavior - - Per-channel controls (`channels.slack.channels.`): - - - `requireMention` - - `users` (allowlist) - - `allowBots` - - `skills` - - `systemPrompt` - - `tools`, `toolsBySender` - - - - -## Commands and slash behavior - -- Native command auto-mode is **off** for Slack (`commands.native: "auto"` does not enable Slack native commands). -- Enable native Slack command handlers with `channels.slack.commands.native: true` (or global `commands.native: true`). -- When native commands are enabled, register matching slash commands in Slack (`/` names). -- If native commands are not enabled, you can run a single configured slash command via `channels.slack.slashCommand`. -- Native arg menus now adapt their rendering strategy: - - up to 5 options: button blocks - - 6-100 options: static select menu - - more than 100 options: external select with async option filtering when interactivity options handlers are available - - if encoded option values exceed Slack limits, the flow falls back to buttons -- For long option payloads, Slash command argument menus use a confirm dialog before dispatching a selected value. - -Default slash command settings: - -- `enabled: false` -- `name: "openclaw"` -- `sessionPrefix: "slack:slash"` -- `ephemeral: true` - -Slash sessions use isolated keys: - -- `agent::slack:slash:` - -and still route command execution against the target conversation session (`CommandTargetSessionKey`). - -## Threading, sessions, and reply tags - -- DMs route as `direct`; channels as `channel`; MPIMs as `group`. -- With default `session.dmScope=main`, Slack DMs collapse to agent main session. -- Channel sessions: `agent::slack:channel:`. -- Thread replies can create thread session suffixes (`:thread:`) when applicable. -- `channels.slack.thread.historyScope` default is `thread`; `thread.inheritParent` default is `false`. -- `channels.slack.thread.initialHistoryLimit` controls how many existing thread messages are fetched when a new thread session starts (default `20`; set `0` to disable). - -Reply threading controls: - -- `channels.slack.replyToMode`: `off|first|all` (default `off`) -- `channels.slack.replyToModeByChatType`: per `direct|group|channel` -- legacy fallback for direct chats: `channels.slack.dm.replyToMode` - -Manual reply tags are supported: - -- `[[reply_to_current]]` -- `[[reply_to:]]` - -Note: `replyToMode="off"` disables implicit reply threading. Explicit `[[reply_to_*]]` tags are still honored. - -## Media, chunking, and delivery - - - - Slack file attachments are downloaded from Slack-hosted private URLs (token-authenticated request flow) and written to the media store when fetch succeeds and size limits permit. - - Runtime inbound size cap defaults to `20MB` unless overridden by `channels.slack.mediaMaxMb`. - - - - - - text chunks use `channels.slack.textChunkLimit` (default 4000) - - `channels.slack.chunkMode="newline"` enables paragraph-first splitting - - file sends use Slack upload APIs and can include thread replies (`thread_ts`) - - outbound media cap follows `channels.slack.mediaMaxMb` when configured; otherwise channel sends use MIME-kind defaults from media pipeline - - - - Preferred explicit targets: - - - `user:` for DMs - - `channel:` for channels - - Slack DMs are opened via Slack conversation APIs when sending to user targets. - - - - -## Actions and gates - -Slack actions are controlled by `channels.slack.actions.*`. - -Available action groups in current Slack tooling: - -| Group | Default | -| ---------- | ------- | -| messages | enabled | -| reactions | enabled | -| pins | enabled | -| memberInfo | enabled | -| emojiList | enabled | - -## Events and operational behavior - -- Message edits/deletes/thread broadcasts are mapped into system events. -- Reaction add/remove events are mapped into system events. -- Member join/leave, channel created/renamed, and pin add/remove events are mapped into system events. -- Assistant thread status updates (for "is typing..." indicators in threads) use `assistant.threads.setStatus` and require bot scope `assistant:write`. -- `channel_id_changed` can migrate channel config keys when `configWrites` is enabled. -- Channel topic/purpose metadata is treated as untrusted context and can be injected into routing context. -- Block actions and modal interactions emit structured `Slack interaction: ...` system events with rich payload fields: - - block actions: selected values, labels, picker values, and `workflow_*` metadata - - modal `view_submission` and `view_closed` events with routed channel metadata and form inputs - -## Ack reactions - -`ackReaction` sends an acknowledgement emoji while OpenClaw is processing an inbound message. - -Resolution order: - -- `channels.slack.accounts..ackReaction` -- `channels.slack.ackReaction` -- `messages.ackReaction` -- agent identity emoji fallback (`agents.list[].identity.emoji`, else "👀") - -Notes: - -- Slack expects shortcodes (for example `"eyes"`). -- Use `""` to disable the reaction for a channel or account. - -## Manifest and scope checklist - - - - -```json -{ - "display_information": { - "name": "OpenClaw", - "description": "Slack connector for OpenClaw" - }, - "features": { - "bot_user": { - "display_name": "OpenClaw", - "always_online": false - }, - "app_home": { - "messages_tab_enabled": true, - "messages_tab_read_only_enabled": false - }, - "slash_commands": [ - { - "command": "/openclaw", - "description": "Send a message to OpenClaw", - "should_escape": false - } - ] - }, - "oauth_config": { - "scopes": { - "bot": [ - "chat:write", - "channels:history", - "channels:read", - "groups:history", - "im:history", - "mpim:history", - "users:read", - "app_mentions:read", - "assistant:write", - "reactions:read", - "reactions:write", - "pins:read", - "pins:write", - "emoji:read", - "commands", - "files:read", - "files:write" - ] - } - }, - "settings": { - "socket_mode_enabled": true, - "event_subscriptions": { - "bot_events": [ - "app_mention", - "message.channels", - "message.groups", - "message.im", - "message.mpim", - "reaction_added", - "reaction_removed", - "member_joined_channel", - "member_left_channel", - "channel_rename", - "pin_added", - "pin_removed" - ] - } - } -} -``` - - - - - If you configure `channels.slack.userToken`, typical read scopes are: - - - `channels:history`, `groups:history`, `im:history`, `mpim:history` - - `channels:read`, `groups:read`, `im:read`, `mpim:read` - - `users:read` - - `reactions:read` - - `pins:read` - - `emoji:read` - - `search:read` (if you depend on Slack search reads) - - - - -## Troubleshooting - - - - Check, in order: - - - `groupPolicy` - - channel allowlist (`channels.slack.channels`) - - `requireMention` - - per-channel `users` allowlist - - Useful commands: - -```bash -openclaw channels status --probe -openclaw logs --follow -openclaw doctor -``` - - - - - Check: - - - `channels.slack.dm.enabled` - - `channels.slack.dmPolicy` (or legacy `channels.slack.dm.policy`) - - pairing approvals / allowlist entries - -```bash -openclaw pairing list slack -``` - - - - - Validate bot + app tokens and Socket Mode enablement in Slack app settings. - - - - Validate: - - - signing secret - - webhook path - - Slack Request URLs (Events + Interactivity + Slash Commands) - - unique `webhookPath` per HTTP account - - - - - Verify whether you intended: - - - native command mode (`channels.slack.commands.native: true`) with matching slash commands registered in Slack - - or single slash command mode (`channels.slack.slashCommand.enabled: true`) - - Also check `commands.useAccessGroups` and channel/user allowlists. - - - - -## Text streaming - -OpenClaw supports Slack native text streaming via the Agents and AI Apps API. - -`channels.slack.streaming` controls live preview behavior: - -- `off`: disable live preview streaming. -- `partial` (default): replace preview text with the latest partial output. -- `block`: append chunked preview updates. -- `progress`: show progress status text while generating, then send final text. - -`channels.slack.nativeStreaming` controls Slack's native streaming API (`chat.startStream` / `chat.appendStream` / `chat.stopStream`) when `streaming` is `partial` (default: `true`). - -Disable native Slack streaming (keep draft preview behavior): - -```yaml -channels: - slack: - streaming: partial - nativeStreaming: false -``` - -Legacy keys: - -- `channels.slack.streamMode` (`replace | status_final | append`) is auto-migrated to `channels.slack.streaming`. -- boolean `channels.slack.streaming` is auto-migrated to `channels.slack.nativeStreaming`. - -### Requirements - -1. Enable **Agents and AI Apps** in your Slack app settings. -2. Ensure the app has the `assistant:write` scope. -3. A reply thread must be available for that message. Thread selection still follows `replyToMode`. - -### Behavior - -- First text chunk starts a stream (`chat.startStream`). -- Later text chunks append to the same stream (`chat.appendStream`). -- End of reply finalizes stream (`chat.stopStream`). -- Media and non-text payloads fall back to normal delivery. -- If streaming fails mid-reply, OpenClaw falls back to normal delivery for remaining payloads. - -## Configuration reference pointers - -Primary reference: - -- [Configuration reference - Slack](/gateway/configuration-reference#slack) - - High-signal Slack fields: - - mode/auth: `mode`, `botToken`, `appToken`, `signingSecret`, `webhookPath`, `accounts.*` - - DM access: `dm.enabled`, `dmPolicy`, `allowFrom` (legacy: `dm.policy`, `dm.allowFrom`), `dm.groupEnabled`, `dm.groupChannels` - - channel access: `groupPolicy`, `channels.*`, `channels.*.users`, `channels.*.requireMention` - - threading/history: `replyToMode`, `replyToModeByChatType`, `thread.*`, `historyLimit`, `dmHistoryLimit`, `dms.*.historyLimit` - - delivery: `textChunkLimit`, `chunkMode`, `mediaMaxMb`, `streaming`, `nativeStreaming` - - ops/features: `configWrites`, `commands.native`, `slashCommand.*`, `actions.*`, `userToken`, `userTokenReadOnly` - -## Related - -- [Pairing](/channels/pairing) -- [Channel routing](/channels/channel-routing) -- [Troubleshooting](/channels/troubleshooting) -- [Configuration](/gateway/configuration) -- [Slash commands](/tools/slash-commands) diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md deleted file mode 100644 index 8676bce4e97..00000000000 --- a/docs/channels/telegram.md +++ /dev/null @@ -1,749 +0,0 @@ ---- -summary: "Telegram bot support status, capabilities, and configuration" -read_when: - - Working on Telegram features or webhooks -title: "Telegram" ---- - -# Telegram (Bot API) - -Status: production-ready for bot DMs + groups via grammY. Long polling is the default mode; webhook mode is optional. - - - - Default DM policy for Telegram is pairing. - - - Cross-channel diagnostics and repair playbooks. - - - Full channel config patterns and examples. - - - -## Quick setup - - - - Open Telegram and chat with **@BotFather** (confirm the handle is exactly `@BotFather`). - - Run `/newbot`, follow prompts, and save the token. - - - - - -```json5 -{ - channels: { - telegram: { - enabled: true, - botToken: "123:abc", - dmPolicy: "pairing", - groups: { "*": { requireMention: true } }, - }, - }, -} -``` - - Env fallback: `TELEGRAM_BOT_TOKEN=...` (default account only). - - - - - -```bash -openclaw gateway -openclaw pairing list telegram -openclaw pairing approve telegram -``` - - Pairing codes expire after 1 hour. - - - - - Add the bot to your group, then set `channels.telegram.groups` and `groupPolicy` to match your access model. - - - - -Token resolution order is account-aware. In practice, config values win over env fallback, and `TELEGRAM_BOT_TOKEN` only applies to the default account. - - -## Telegram side settings - - - - Telegram bots default to **Privacy Mode**, which limits what group messages they receive. - - If the bot must see all group messages, either: - - - disable privacy mode via `/setprivacy`, or - - make the bot a group admin. - - When toggling privacy mode, remove + re-add the bot in each group so Telegram applies the change. - - - - - Admin status is controlled in Telegram group settings. - - Admin bots receive all group messages, which is useful for always-on group behavior. - - - - - - - `/setjoingroups` to allow/deny group adds - - `/setprivacy` for group visibility behavior - - - - -## Access control and activation - - - - `channels.telegram.dmPolicy` controls direct message access: - - - `pairing` (default) - - `allowlist` - - `open` (requires `allowFrom` to include `"*"`) - - `disabled` - - `channels.telegram.allowFrom` accepts numeric Telegram user IDs. `telegram:` / `tg:` prefixes are accepted and normalized. - The onboarding wizard accepts `@username` input and resolves it to numeric IDs. - If you upgraded and your config contains `@username` allowlist entries, run `openclaw doctor --fix` to resolve them (best-effort; requires a Telegram bot token). - - ### Finding your Telegram user ID - - Safer (no third-party bot): - - 1. DM your bot. - 2. Run `openclaw logs --follow`. - 3. Read `from.id`. - - Official Bot API method: - -```bash -curl "https://api.telegram.org/bot/getUpdates" -``` - - Third-party method (less private): `@userinfobot` or `@getidsbot`. - - - - - There are two independent controls: - - 1. **Which groups are allowed** (`channels.telegram.groups`) - - no `groups` config: all groups allowed - - `groups` configured: acts as allowlist (explicit IDs or `"*"`) - - 2. **Which senders are allowed in groups** (`channels.telegram.groupPolicy`) - - `open` - - `allowlist` (default) - - `disabled` - - `groupAllowFrom` is used for group sender filtering. If not set, Telegram falls back to `allowFrom`. - `groupAllowFrom` entries must be numeric Telegram user IDs. - - Example: allow any member in one specific group: - -```json5 -{ - channels: { - telegram: { - groups: { - "-1001234567890": { - groupPolicy: "open", - requireMention: false, - }, - }, - }, - }, -} -``` - - - - - Group replies require mention by default. - - Mention can come from: - - - native `@botusername` mention, or - - mention patterns in: - - `agents.list[].groupChat.mentionPatterns` - - `messages.groupChat.mentionPatterns` - - Session-level command toggles: - - - `/activation always` - - `/activation mention` - - These update session state only. Use config for persistence. - - Persistent config example: - -```json5 -{ - channels: { - telegram: { - groups: { - "*": { requireMention: false }, - }, - }, - }, -} -``` - - Getting the group chat ID: - - - forward a group message to `@userinfobot` / `@getidsbot` - - or read `chat.id` from `openclaw logs --follow` - - or inspect Bot API `getUpdates` - - - - -## Runtime behavior - -- Telegram is owned by the gateway process. -- Routing is deterministic: Telegram inbound replies back to Telegram (the model does not pick channels). -- Inbound messages normalize into the shared channel envelope with reply metadata and media placeholders. -- Group sessions are isolated by group ID. Forum topics append `:topic:` to keep topics isolated. -- DM messages can carry `message_thread_id`; OpenClaw routes them with thread-aware session keys and preserves thread ID for replies. -- Long polling uses grammY runner with per-chat/per-thread sequencing. Overall runner sink concurrency uses `agents.defaults.maxConcurrent`. -- Telegram Bot API has no read-receipt support (`sendReadReceipts` does not apply). - -## Feature reference - - - - OpenClaw can stream partial replies by sending a temporary Telegram message and editing it as text arrives. - - Requirement: - - - `channels.telegram.streaming` is `off | partial | block | progress` (default: `off`) - - `progress` maps to `partial` on Telegram (compat with cross-channel naming) - - legacy `channels.telegram.streamMode` and boolean `streaming` values are auto-mapped - - This works in direct chats and groups/topics. - - For text-only replies, OpenClaw keeps the same preview message and performs a final edit in place (no second message). - - For complex replies (for example media payloads), OpenClaw falls back to normal final delivery and then cleans up the preview message. - - Preview streaming is separate from block streaming. When block streaming is explicitly enabled for Telegram, OpenClaw skips the preview stream to avoid double-streaming. - - Telegram-only reasoning stream: - - - `/reasoning stream` sends reasoning to the live preview while generating - - final answer is sent without reasoning text - - - - - Outbound text uses Telegram `parse_mode: "HTML"`. - - - Markdown-ish text is rendered to Telegram-safe HTML. - - Raw model HTML is escaped to reduce Telegram parse failures. - - If Telegram rejects parsed HTML, OpenClaw retries as plain text. - - Link previews are enabled by default and can be disabled with `channels.telegram.linkPreview: false`. - - - - - Telegram command menu registration is handled at startup with `setMyCommands`. - - Native command defaults: - - - `commands.native: "auto"` enables native commands for Telegram - - Add custom command menu entries: - -```json5 -{ - channels: { - telegram: { - customCommands: [ - { command: "backup", description: "Git backup" }, - { command: "generate", description: "Create an image" }, - ], - }, - }, -} -``` - - Rules: - - - names are normalized (strip leading `/`, lowercase) - - valid pattern: `a-z`, `0-9`, `_`, length `1..32` - - custom commands cannot override native commands - - conflicts/duplicates are skipped and logged - - Notes: - - - custom commands are menu entries only; they do not auto-implement behavior - - plugin/skill commands can still work when typed even if not shown in Telegram menu - - If native commands are disabled, built-ins are removed. Custom/plugin commands may still register if configured. - - Common setup failure: - - - `setMyCommands failed` usually means outbound DNS/HTTPS to `api.telegram.org` is blocked. - - ### Device pairing commands (`device-pair` plugin) - - When the `device-pair` plugin is installed: - - 1. `/pair` generates setup code - 2. paste code in iOS app - 3. `/pair approve` approves latest pending request - - More details: [Pairing](/channels/pairing#pair-via-telegram-recommended-for-ios). - - - - - Configure inline keyboard scope: - -```json5 -{ - channels: { - telegram: { - capabilities: { - inlineButtons: "allowlist", - }, - }, - }, -} -``` - - Per-account override: - -```json5 -{ - channels: { - telegram: { - accounts: { - main: { - capabilities: { - inlineButtons: "allowlist", - }, - }, - }, - }, - }, -} -``` - - Scopes: - - - `off` - - `dm` - - `group` - - `all` - - `allowlist` (default) - - Legacy `capabilities: ["inlineButtons"]` maps to `inlineButtons: "all"`. - - Message action example: - -```json5 -{ - action: "send", - channel: "telegram", - to: "123456789", - message: "Choose an option:", - buttons: [ - [ - { text: "Yes", callback_data: "yes" }, - { text: "No", callback_data: "no" }, - ], - [{ text: "Cancel", callback_data: "cancel" }], - ], -} -``` - - Callback clicks are passed to the agent as text: - `callback_data: ` - - - - - Telegram tool actions include: - - - `sendMessage` (`to`, `content`, optional `mediaUrl`, `replyToMessageId`, `messageThreadId`) - - `react` (`chatId`, `messageId`, `emoji`) - - `deleteMessage` (`chatId`, `messageId`) - - `editMessage` (`chatId`, `messageId`, `content`) - - Channel message actions expose ergonomic aliases (`send`, `react`, `delete`, `edit`, `sticker`, `sticker-search`). - - Gating controls: - - - `channels.telegram.actions.sendMessage` - - `channels.telegram.actions.editMessage` - - `channels.telegram.actions.deleteMessage` - - `channels.telegram.actions.reactions` - - `channels.telegram.actions.sticker` (default: disabled) - - Reaction removal semantics: [/tools/reactions](/tools/reactions) - - - - - Telegram supports explicit reply threading tags in generated output: - - - `[[reply_to_current]]` replies to the triggering message - - `[[reply_to:]]` replies to a specific Telegram message ID - - `channels.telegram.replyToMode` controls handling: - - - `off` (default) - - `first` - - `all` - - Note: `off` disables implicit reply threading. Explicit `[[reply_to_*]]` tags are still honored. - - - - - Forum supergroups: - - - topic session keys append `:topic:` - - replies and typing target the topic thread - - topic config path: - `channels.telegram.groups..topics.` - - General topic (`threadId=1`) special-case: - - - message sends omit `message_thread_id` (Telegram rejects `sendMessage(...thread_id=1)`) - - typing actions still include `message_thread_id` - - Topic inheritance: topic entries inherit group settings unless overridden (`requireMention`, `allowFrom`, `skills`, `systemPrompt`, `enabled`, `groupPolicy`). - - Template context includes: - - - `MessageThreadId` - - `IsForum` - - DM thread behavior: - - - private chats with `message_thread_id` keep DM routing but use thread-aware session keys/reply targets. - - - - - ### Audio messages - - Telegram distinguishes voice notes vs audio files. - - - default: audio file behavior - - tag `[[audio_as_voice]]` in agent reply to force voice-note send - - Message action example: - -```json5 -{ - action: "send", - channel: "telegram", - to: "123456789", - media: "https://example.com/voice.ogg", - asVoice: true, -} -``` - - ### Video messages - - Telegram distinguishes video files vs video notes. - - Message action example: - -```json5 -{ - action: "send", - channel: "telegram", - to: "123456789", - media: "https://example.com/video.mp4", - asVideoNote: true, -} -``` - - Video notes do not support captions; provided message text is sent separately. - - ### Stickers - - Inbound sticker handling: - - - static WEBP: downloaded and processed (placeholder ``) - - animated TGS: skipped - - video WEBM: skipped - - Sticker context fields: - - - `Sticker.emoji` - - `Sticker.setName` - - `Sticker.fileId` - - `Sticker.fileUniqueId` - - `Sticker.cachedDescription` - - Sticker cache file: - - - `~/.openclaw/telegram/sticker-cache.json` - - Stickers are described once (when possible) and cached to reduce repeated vision calls. - - Enable sticker actions: - -```json5 -{ - channels: { - telegram: { - actions: { - sticker: true, - }, - }, - }, -} -``` - - Send sticker action: - -```json5 -{ - action: "sticker", - channel: "telegram", - to: "123456789", - fileId: "CAACAgIAAxkBAAI...", -} -``` - - Search cached stickers: - -```json5 -{ - action: "sticker-search", - channel: "telegram", - query: "cat waving", - limit: 5, -} -``` - - - - - Telegram reactions arrive as `message_reaction` updates (separate from message payloads). - - When enabled, OpenClaw enqueues system events like: - - - `Telegram reaction added: 👍 by Alice (@alice) on msg 42` - - Config: - - - `channels.telegram.reactionNotifications`: `off | own | all` (default: `own`) - - `channels.telegram.reactionLevel`: `off | ack | minimal | extensive` (default: `minimal`) - - Notes: - - - `own` means user reactions to bot-sent messages only (best-effort via sent-message cache). - - Telegram does not provide thread IDs in reaction updates. - - non-forum groups route to group chat session - - forum groups route to the group general-topic session (`:topic:1`), not the exact originating topic - - `allowed_updates` for polling/webhook include `message_reaction` automatically. - - - - - `ackReaction` sends an acknowledgement emoji while OpenClaw is processing an inbound message. - - Resolution order: - - - `channels.telegram.accounts..ackReaction` - - `channels.telegram.ackReaction` - - `messages.ackReaction` - - agent identity emoji fallback (`agents.list[].identity.emoji`, else "👀") - - Notes: - - - Telegram expects unicode emoji (for example "👀"). - - Use `""` to disable the reaction for a channel or account. - - - - - Channel config writes are enabled by default (`configWrites !== false`). - - Telegram-triggered writes include: - - - group migration events (`migrate_to_chat_id`) to update `channels.telegram.groups` - - `/config set` and `/config unset` (requires command enablement) - - Disable: - -```json5 -{ - channels: { - telegram: { - configWrites: false, - }, - }, -} -``` - - - - - Default: long polling. - - Webhook mode: - - - set `channels.telegram.webhookUrl` - - set `channels.telegram.webhookSecret` (required when webhook URL is set) - - optional `channels.telegram.webhookPath` (default `/telegram-webhook`) - - optional `channels.telegram.webhookHost` (default `127.0.0.1`) - - Default local listener for webhook mode binds to `127.0.0.1:8787`. - - If your public endpoint differs, place a reverse proxy in front and point `webhookUrl` at the public URL. - Set `webhookHost` (for example `0.0.0.0`) when you intentionally need external ingress. - - - - - - `channels.telegram.textChunkLimit` default is 4000. - - `channels.telegram.chunkMode="newline"` prefers paragraph boundaries (blank lines) before length splitting. - - `channels.telegram.mediaMaxMb` (default 5) caps inbound Telegram media download/processing size. - - `channels.telegram.timeoutSeconds` overrides Telegram API client timeout (if unset, grammY default applies). - - group context history uses `channels.telegram.historyLimit` or `messages.groupChat.historyLimit` (default 50); `0` disables. - - DM history controls: - - `channels.telegram.dmHistoryLimit` - - `channels.telegram.dms[""].historyLimit` - - outbound Telegram API retries are configurable via `channels.telegram.retry`. - - CLI send target can be numeric chat ID or username: - -```bash -openclaw message send --channel telegram --target 123456789 --message "hi" -openclaw message send --channel telegram --target @name --message "hi" -``` - - - - -## Troubleshooting - - - - - - If `requireMention=false`, Telegram privacy mode must allow full visibility. - - BotFather: `/setprivacy` -> Disable - - then remove + re-add bot to group - - `openclaw channels status` warns when config expects unmentioned group messages. - - `openclaw channels status --probe` can check explicit numeric group IDs; wildcard `"*"` cannot be membership-probed. - - quick session test: `/activation always`. - - - - - - - when `channels.telegram.groups` exists, group must be listed (or include `"*"`) - - verify bot membership in group - - review logs: `openclaw logs --follow` for skip reasons - - - - - - - authorize your sender identity (pairing and/or numeric `allowFrom`) - - command authorization still applies even when group policy is `open` - - `setMyCommands failed` usually indicates DNS/HTTPS reachability issues to `api.telegram.org` - - - - - - - Node 22+ + custom fetch/proxy can trigger immediate abort behavior if AbortSignal types mismatch. - - Some hosts resolve `api.telegram.org` to IPv6 first; broken IPv6 egress can cause intermittent Telegram API failures. - - Validate DNS answers: - -```bash -dig +short api.telegram.org A -dig +short api.telegram.org AAAA -``` - - - - -More help: [Channel troubleshooting](/channels/troubleshooting). - -## Telegram config reference pointers - -Primary reference: - -- `channels.telegram.enabled`: enable/disable channel startup. -- `channels.telegram.botToken`: bot token (BotFather). -- `channels.telegram.tokenFile`: read token from file path. -- `channels.telegram.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing). -- `channels.telegram.allowFrom`: DM allowlist (numeric Telegram user IDs). `open` requires `"*"`. `openclaw doctor --fix` can resolve legacy `@username` entries to IDs. -- `channels.telegram.groupPolicy`: `open | allowlist | disabled` (default: allowlist). -- `channels.telegram.groupAllowFrom`: group sender allowlist (numeric Telegram user IDs). `openclaw doctor --fix` can resolve legacy `@username` entries to IDs. -- `channels.telegram.groups`: per-group defaults + allowlist (use `"*"` for global defaults). - - `channels.telegram.groups..groupPolicy`: per-group override for groupPolicy (`open | allowlist | disabled`). - - `channels.telegram.groups..requireMention`: mention gating default. - - `channels.telegram.groups..skills`: skill filter (omit = all skills, empty = none). - - `channels.telegram.groups..allowFrom`: per-group sender allowlist override. - - `channels.telegram.groups..systemPrompt`: extra system prompt for the group. - - `channels.telegram.groups..enabled`: disable the group when `false`. - - `channels.telegram.groups..topics..*`: per-topic overrides (same fields as group). - - `channels.telegram.groups..topics..groupPolicy`: per-topic override for groupPolicy (`open | allowlist | disabled`). - - `channels.telegram.groups..topics..requireMention`: per-topic mention gating override. -- `channels.telegram.capabilities.inlineButtons`: `off | dm | group | all | allowlist` (default: allowlist). -- `channels.telegram.accounts..capabilities.inlineButtons`: per-account override. -- `channels.telegram.replyToMode`: `off | first | all` (default: `off`). -- `channels.telegram.textChunkLimit`: outbound chunk size (chars). -- `channels.telegram.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking. -- `channels.telegram.linkPreview`: toggle link previews for outbound messages (default: true). -- `channels.telegram.streaming`: `off | partial | block | progress` (live stream preview; default: `off`; `progress` maps to `partial`). -- `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB). -- `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter). -- `channels.telegram.network.autoSelectFamily`: override Node autoSelectFamily (true=enable, false=disable). Defaults to disabled on Node 22 to avoid Happy Eyeballs timeouts. -- `channels.telegram.proxy`: proxy URL for Bot API calls (SOCKS/HTTP). -- `channels.telegram.webhookUrl`: enable webhook mode (requires `channels.telegram.webhookSecret`). -- `channels.telegram.webhookSecret`: webhook secret (required when webhookUrl is set). -- `channels.telegram.webhookPath`: local webhook path (default `/telegram-webhook`). -- `channels.telegram.webhookHost`: local webhook bind host (default `127.0.0.1`). -- `channels.telegram.actions.reactions`: gate Telegram tool reactions. -- `channels.telegram.actions.sendMessage`: gate Telegram tool message sends. -- `channels.telegram.actions.deleteMessage`: gate Telegram tool message deletes. -- `channels.telegram.actions.sticker`: gate Telegram sticker actions — send and search (default: false). -- `channels.telegram.reactionNotifications`: `off | own | all` — control which reactions trigger system events (default: `own` when not set). -- `channels.telegram.reactionLevel`: `off | ack | minimal | extensive` — control agent's reaction capability (default: `minimal` when not set). - -- [Configuration reference - Telegram](/gateway/configuration-reference#telegram) - -Telegram-specific high-signal fields: - -- startup/auth: `enabled`, `botToken`, `tokenFile`, `accounts.*` -- access control: `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups`, `groups.*.topics.*` -- command/menu: `commands.native`, `customCommands` -- threading/replies: `replyToMode` -- streaming: `streaming` (preview), `blockStreaming` -- formatting/delivery: `textChunkLimit`, `chunkMode`, `linkPreview`, `responsePrefix` -- media/network: `mediaMaxMb`, `timeoutSeconds`, `retry`, `network.autoSelectFamily`, `proxy` -- webhook: `webhookUrl`, `webhookSecret`, `webhookPath`, `webhookHost` -- actions/capabilities: `capabilities.inlineButtons`, `actions.sendMessage|editMessage|deleteMessage|reactions|sticker` -- reactions: `reactionNotifications`, `reactionLevel` -- writes/history: `configWrites`, `historyLimit`, `dmHistoryLimit`, `dms.*.historyLimit` - -## Related - -- [Pairing](/channels/pairing) -- [Channel routing](/channels/channel-routing) -- [Multi-agent routing](/concepts/multi-agent) -- [Troubleshooting](/channels/troubleshooting) diff --git a/docs/channels/tlon.md b/docs/channels/tlon.md deleted file mode 100644 index dbd2015c4ef..00000000000 --- a/docs/channels/tlon.md +++ /dev/null @@ -1,148 +0,0 @@ ---- -summary: "Tlon/Urbit support status, capabilities, and configuration" -read_when: - - Working on Tlon/Urbit channel features -title: "Tlon" ---- - -# Tlon (plugin) - -Tlon is a decentralized messenger built on Urbit. OpenClaw connects to your Urbit ship and can -respond to DMs and group chat messages. Group replies require an @ mention by default and can -be further restricted via allowlists. - -Status: supported via plugin. DMs, group mentions, thread replies, and text-only media fallback -(URL appended to caption). Reactions, polls, and native media uploads are not supported. - -## Plugin required - -Tlon ships as a plugin and is not bundled with the core install. - -Install via CLI (npm registry): - -```bash -openclaw plugins install @openclaw/tlon -``` - -Local checkout (when running from a git repo): - -```bash -openclaw plugins install ./extensions/tlon -``` - -Details: [Plugins](/tools/plugin) - -## Setup - -1. Install the Tlon plugin. -2. Gather your ship URL and login code. -3. Configure `channels.tlon`. -4. Restart the gateway. -5. DM the bot or mention it in a group channel. - -Minimal config (single account): - -```json5 -{ - channels: { - tlon: { - enabled: true, - ship: "~sampel-palnet", - url: "https://your-ship-host", - code: "lidlut-tabwed-pillex-ridrup", - }, - }, -} -``` - -Private/LAN ship URLs (advanced): - -By default, OpenClaw blocks private/internal hostnames and IP ranges for this plugin (SSRF hardening). -If your ship URL is on a private network (for example `http://192.168.1.50:8080` or `http://localhost:8080`), -you must explicitly opt in: - -```json5 -{ - channels: { - tlon: { - allowPrivateNetwork: true, - }, - }, -} -``` - -## Group channels - -Auto-discovery is enabled by default. You can also pin channels manually: - -```json5 -{ - channels: { - tlon: { - groupChannels: ["chat/~host-ship/general", "chat/~host-ship/support"], - }, - }, -} -``` - -Disable auto-discovery: - -```json5 -{ - channels: { - tlon: { - autoDiscoverChannels: false, - }, - }, -} -``` - -## Access control - -DM allowlist (empty = allow all): - -```json5 -{ - channels: { - tlon: { - dmAllowlist: ["~zod", "~nec"], - }, - }, -} -``` - -Group authorization (restricted by default): - -```json5 -{ - channels: { - tlon: { - defaultAuthorizedShips: ["~zod"], - authorization: { - channelRules: { - "chat/~host-ship/general": { - mode: "restricted", - allowedShips: ["~zod", "~nec"], - }, - "chat/~host-ship/announcements": { - mode: "open", - }, - }, - }, - }, - }, -} -``` - -## Delivery targets (CLI/cron) - -Use these with `openclaw message send` or cron delivery: - -- DM: `~sampel-palnet` or `dm/~sampel-palnet` -- Group: `chat/~host-ship/channel` or `group:~host-ship/channel` - -## Notes - -- Group replies require a mention (e.g. `~your-bot-ship`) to respond. -- Thread replies: if the inbound message is in a thread, OpenClaw replies in-thread. -- Media: `sendMedia` falls back to text + URL (no native upload). diff --git a/docs/channels/troubleshooting.md b/docs/channels/troubleshooting.md deleted file mode 100644 index 2848947c479..00000000000 --- a/docs/channels/troubleshooting.md +++ /dev/null @@ -1,117 +0,0 @@ ---- -summary: "Fast channel level troubleshooting with per channel failure signatures and fixes" -read_when: - - Channel transport says connected but replies fail - - You need channel specific checks before deep provider docs -title: "Channel Troubleshooting" ---- - -# Channel troubleshooting - -Use this page when a channel connects but behavior is wrong. - -## Command ladder - -Run these in order first: - -```bash -openclaw status -openclaw gateway status -openclaw logs --follow -openclaw doctor -openclaw channels status --probe -``` - -Healthy baseline: - -- `Runtime: running` -- `RPC probe: ok` -- Channel probe shows connected/ready - -## WhatsApp - -### WhatsApp failure signatures - -| Symptom | Fastest check | Fix | -| ------------------------------- | --------------------------------------------------- | ------------------------------------------------------- | -| Connected but no DM replies | `openclaw pairing list whatsapp` | Approve sender or switch DM policy/allowlist. | -| Group messages ignored | Check `requireMention` + mention patterns in config | Mention the bot or relax mention policy for that group. | -| Random disconnect/relogin loops | `openclaw channels status --probe` + logs | Re-login and verify credentials directory is healthy. | - -Full troubleshooting: [/channels/whatsapp#troubleshooting-quick](/channels/whatsapp#troubleshooting-quick) - -## Telegram - -### Telegram failure signatures - -| Symptom | Fastest check | Fix | -| --------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------- | -| `/start` but no usable reply flow | `openclaw pairing list telegram` | Approve pairing or change DM policy. | -| Bot online but group stays silent | Verify mention requirement and bot privacy mode | Disable privacy mode for group visibility or mention bot. | -| Send failures with network errors | Inspect logs for Telegram API call failures | Fix DNS/IPv6/proxy routing to `api.telegram.org`. | -| Upgraded and allowlist blocks you | `openclaw security audit` and config allowlists | Run `openclaw doctor --fix` or replace `@username` with numeric sender IDs. | - -Full troubleshooting: [/channels/telegram#troubleshooting](/channels/telegram#troubleshooting) - -## Discord - -### Discord failure signatures - -| Symptom | Fastest check | Fix | -| ------------------------------- | ----------------------------------- | --------------------------------------------------------- | -| Bot online but no guild replies | `openclaw channels status --probe` | Allow guild/channel and verify message content intent. | -| Group messages ignored | Check logs for mention gating drops | Mention bot or set guild/channel `requireMention: false`. | -| DM replies missing | `openclaw pairing list discord` | Approve DM pairing or adjust DM policy. | - -Full troubleshooting: [/channels/discord#troubleshooting](/channels/discord#troubleshooting) - -## Slack - -### Slack failure signatures - -| Symptom | Fastest check | Fix | -| -------------------------------------- | ----------------------------------------- | ------------------------------------------------- | -| Socket mode connected but no responses | `openclaw channels status --probe` | Verify app token + bot token and required scopes. | -| DMs blocked | `openclaw pairing list slack` | Approve pairing or relax DM policy. | -| Channel message ignored | Check `groupPolicy` and channel allowlist | Allow the channel or switch policy to `open`. | - -Full troubleshooting: [/channels/slack#troubleshooting](/channels/slack#troubleshooting) - -## iMessage and BlueBubbles - -### iMessage and BlueBubbles failure signatures - -| Symptom | Fastest check | Fix | -| -------------------------------- | ----------------------------------------------------------------------- | ----------------------------------------------------- | -| No inbound events | Verify webhook/server reachability and app permissions | Fix webhook URL or BlueBubbles server state. | -| Can send but no receive on macOS | Check macOS privacy permissions for Messages automation | Re-grant TCC permissions and restart channel process. | -| DM sender blocked | `openclaw pairing list imessage` or `openclaw pairing list bluebubbles` | Approve pairing or update allowlist. | - -Full troubleshooting: - -- [/channels/imessage#troubleshooting-macos-privacy-and-security-tcc](/channels/imessage#troubleshooting-macos-privacy-and-security-tcc) -- [/channels/bluebubbles#troubleshooting](/channels/bluebubbles#troubleshooting) - -## Signal - -### Signal failure signatures - -| Symptom | Fastest check | Fix | -| ------------------------------- | ------------------------------------------ | -------------------------------------------------------- | -| Daemon reachable but bot silent | `openclaw channels status --probe` | Verify `signal-cli` daemon URL/account and receive mode. | -| DM blocked | `openclaw pairing list signal` | Approve sender or adjust DM policy. | -| Group replies do not trigger | Check group allowlist and mention patterns | Add sender/group or loosen gating. | - -Full troubleshooting: [/channels/signal#troubleshooting](/channels/signal#troubleshooting) - -## Matrix - -### Matrix failure signatures - -| Symptom | Fastest check | Fix | -| ----------------------------------- | -------------------------------------------- | ----------------------------------------------- | -| Logged in but ignores room messages | `openclaw channels status --probe` | Check `groupPolicy` and room allowlist. | -| DMs do not process | `openclaw pairing list matrix` | Approve sender or adjust DM policy. | -| Encrypted rooms fail | Verify crypto module and encryption settings | Enable encryption support and rejoin/sync room. | - -Full troubleshooting: [/channels/matrix#troubleshooting](/channels/matrix#troubleshooting) diff --git a/docs/channels/twitch.md b/docs/channels/twitch.md deleted file mode 100644 index 32670f31540..00000000000 --- a/docs/channels/twitch.md +++ /dev/null @@ -1,379 +0,0 @@ ---- -summary: "Twitch chat bot configuration and setup" -read_when: - - Setting up Twitch chat integration for OpenClaw -title: "Twitch" ---- - -# Twitch (plugin) - -Twitch chat support via IRC connection. OpenClaw connects as a Twitch user (bot account) to receive and send messages in channels. - -## Plugin required - -Twitch ships as a plugin and is not bundled with the core install. - -Install via CLI (npm registry): - -```bash -openclaw plugins install @openclaw/twitch -``` - -Local checkout (when running from a git repo): - -```bash -openclaw plugins install ./extensions/twitch -``` - -Details: [Plugins](/tools/plugin) - -## Quick setup (beginner) - -1. Create a dedicated Twitch account for the bot (or use an existing account). -2. Generate credentials: [Twitch Token Generator](https://twitchtokengenerator.com/) - - Select **Bot Token** - - Verify scopes `chat:read` and `chat:write` are selected - - Copy the **Client ID** and **Access Token** -3. Find your Twitch user ID: [https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/](https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/) -4. Configure the token: - - Env: `OPENCLAW_TWITCH_ACCESS_TOKEN=...` (default account only) - - Or config: `channels.twitch.accessToken` - - If both are set, config takes precedence (env fallback is default-account only). -5. Start the gateway. - -**⚠️ Important:** Add access control (`allowFrom` or `allowedRoles`) to prevent unauthorized users from triggering the bot. `requireMention` defaults to `true`. - -Minimal config: - -```json5 -{ - channels: { - twitch: { - enabled: true, - username: "openclaw", // Bot's Twitch account - accessToken: "oauth:abc123...", // OAuth Access Token (or use OPENCLAW_TWITCH_ACCESS_TOKEN env var) - clientId: "xyz789...", // Client ID from Token Generator - channel: "vevisk", // Which Twitch channel's chat to join (required) - allowFrom: ["123456789"], // (recommended) Your Twitch user ID only - get it from https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/ - }, - }, -} -``` - -## What it is - -- A Twitch channel owned by the Gateway. -- Deterministic routing: replies always go back to Twitch. -- Each account maps to an isolated session key `agent::twitch:`. -- `username` is the bot's account (who authenticates), `channel` is which chat room to join. - -## Setup (detailed) - -### Generate credentials - -Use [Twitch Token Generator](https://twitchtokengenerator.com/): - -- Select **Bot Token** -- Verify scopes `chat:read` and `chat:write` are selected -- Copy the **Client ID** and **Access Token** - -No manual app registration needed. Tokens expire after several hours. - -### Configure the bot - -**Env var (default account only):** - -```bash -OPENCLAW_TWITCH_ACCESS_TOKEN=oauth:abc123... -``` - -**Or config:** - -```json5 -{ - channels: { - twitch: { - enabled: true, - username: "openclaw", - accessToken: "oauth:abc123...", - clientId: "xyz789...", - channel: "vevisk", - }, - }, -} -``` - -If both env and config are set, config takes precedence. - -### Access control (recommended) - -```json5 -{ - channels: { - twitch: { - allowFrom: ["123456789"], // (recommended) Your Twitch user ID only - }, - }, -} -``` - -Prefer `allowFrom` for a hard allowlist. Use `allowedRoles` instead if you want role-based access. - -**Available roles:** `"moderator"`, `"owner"`, `"vip"`, `"subscriber"`, `"all"`. - -**Why user IDs?** Usernames can change, allowing impersonation. User IDs are permanent. - -Find your Twitch user ID: [https://www.streamweasels.com/tools/convert-twitch-username-%20to-user-id/](https://www.streamweasels.com/tools/convert-twitch-username-%20to-user-id/) (Convert your Twitch username to ID) - -## Token refresh (optional) - -Tokens from [Twitch Token Generator](https://twitchtokengenerator.com/) cannot be automatically refreshed - regenerate when expired. - -For automatic token refresh, create your own Twitch application at [Twitch Developer Console](https://dev.twitch.tv/console) and add to config: - -```json5 -{ - channels: { - twitch: { - clientSecret: "your_client_secret", - refreshToken: "your_refresh_token", - }, - }, -} -``` - -The bot automatically refreshes tokens before expiration and logs refresh events. - -## Multi-account support - -Use `channels.twitch.accounts` with per-account tokens. See [`gateway/configuration`](/gateway/configuration) for the shared pattern. - -Example (one bot account in two channels): - -```json5 -{ - channels: { - twitch: { - accounts: { - channel1: { - username: "openclaw", - accessToken: "oauth:abc123...", - clientId: "xyz789...", - channel: "vevisk", - }, - channel2: { - username: "openclaw", - accessToken: "oauth:def456...", - clientId: "uvw012...", - channel: "secondchannel", - }, - }, - }, - }, -} -``` - -**Note:** Each account needs its own token (one token per channel). - -## Access control - -### Role-based restrictions - -```json5 -{ - channels: { - twitch: { - accounts: { - default: { - allowedRoles: ["moderator", "vip"], - }, - }, - }, - }, -} -``` - -### Allowlist by User ID (most secure) - -```json5 -{ - channels: { - twitch: { - accounts: { - default: { - allowFrom: ["123456789", "987654321"], - }, - }, - }, - }, -} -``` - -### Role-based access (alternative) - -`allowFrom` is a hard allowlist. When set, only those user IDs are allowed. -If you want role-based access, leave `allowFrom` unset and configure `allowedRoles` instead: - -```json5 -{ - channels: { - twitch: { - accounts: { - default: { - allowedRoles: ["moderator"], - }, - }, - }, - }, -} -``` - -### Disable @mention requirement - -By default, `requireMention` is `true`. To disable and respond to all messages: - -```json5 -{ - channels: { - twitch: { - accounts: { - default: { - requireMention: false, - }, - }, - }, - }, -} -``` - -## Troubleshooting - -First, run diagnostic commands: - -```bash -openclaw doctor -openclaw channels status --probe -``` - -### Bot doesn't respond to messages - -**Check access control:** Ensure your user ID is in `allowFrom`, or temporarily remove -`allowFrom` and set `allowedRoles: ["all"]` to test. - -**Check the bot is in the channel:** The bot must join the channel specified in `channel`. - -### Token issues - -**"Failed to connect" or authentication errors:** - -- Verify `accessToken` is the OAuth access token value (typically starts with `oauth:` prefix) -- Check token has `chat:read` and `chat:write` scopes -- If using token refresh, verify `clientSecret` and `refreshToken` are set - -### Token refresh not working - -**Check logs for refresh events:** - -``` -Using env token source for mybot -Access token refreshed for user 123456 (expires in 14400s) -``` - -If you see "token refresh disabled (no refresh token)": - -- Ensure `clientSecret` is provided -- Ensure `refreshToken` is provided - -## Config - -**Account config:** - -- `username` - Bot username -- `accessToken` - OAuth access token with `chat:read` and `chat:write` -- `clientId` - Twitch Client ID (from Token Generator or your app) -- `channel` - Channel to join (required) -- `enabled` - Enable this account (default: `true`) -- `clientSecret` - Optional: For automatic token refresh -- `refreshToken` - Optional: For automatic token refresh -- `expiresIn` - Token expiry in seconds -- `obtainmentTimestamp` - Token obtained timestamp -- `allowFrom` - User ID allowlist -- `allowedRoles` - Role-based access control (`"moderator" | "owner" | "vip" | "subscriber" | "all"`) -- `requireMention` - Require @mention (default: `true`) - -**Provider options:** - -- `channels.twitch.enabled` - Enable/disable channel startup -- `channels.twitch.username` - Bot username (simplified single-account config) -- `channels.twitch.accessToken` - OAuth access token (simplified single-account config) -- `channels.twitch.clientId` - Twitch Client ID (simplified single-account config) -- `channels.twitch.channel` - Channel to join (simplified single-account config) -- `channels.twitch.accounts.` - Multi-account config (all account fields above) - -Full example: - -```json5 -{ - channels: { - twitch: { - enabled: true, - username: "openclaw", - accessToken: "oauth:abc123...", - clientId: "xyz789...", - channel: "vevisk", - clientSecret: "secret123...", - refreshToken: "refresh456...", - allowFrom: ["123456789"], - allowedRoles: ["moderator", "vip"], - accounts: { - default: { - username: "mybot", - accessToken: "oauth:abc123...", - clientId: "xyz789...", - channel: "your_channel", - enabled: true, - clientSecret: "secret123...", - refreshToken: "refresh456...", - expiresIn: 14400, - obtainmentTimestamp: 1706092800000, - allowFrom: ["123456789", "987654321"], - allowedRoles: ["moderator"], - }, - }, - }, - }, -} -``` - -## Tool actions - -The agent can call `twitch` with action: - -- `send` - Send a message to a channel - -Example: - -```json5 -{ - action: "twitch", - params: { - message: "Hello Twitch!", - to: "#mychannel", - }, -} -``` - -## Safety & ops - -- **Treat tokens like passwords** - Never commit tokens to git -- **Use automatic token refresh** for long-running bots -- **Use user ID allowlists** instead of usernames for access control -- **Monitor logs** for token refresh events and connection status -- **Scope tokens minimally** - Only request `chat:read` and `chat:write` -- **If stuck**: Restart the gateway after confirming no other process owns the session - -## Limits - -- **500 characters** per message (auto-chunked at word boundaries) -- Markdown is stripped before chunking -- No rate limiting (uses Twitch's built-in rate limits) diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md deleted file mode 100644 index a6fb427bdc2..00000000000 --- a/docs/channels/whatsapp.md +++ /dev/null @@ -1,444 +0,0 @@ ---- -summary: "WhatsApp channel support, access controls, delivery behavior, and operations" -read_when: - - Working on WhatsApp/web channel behavior or inbox routing -title: "WhatsApp" ---- - -# WhatsApp (Web channel) - -Status: production-ready via WhatsApp Web (Baileys). Gateway owns linked session(s). - - - - Default DM policy is pairing for unknown senders. - - - Cross-channel diagnostics and repair playbooks. - - - Full channel config patterns and examples. - - - -## Quick setup - - - - -```json5 -{ - channels: { - whatsapp: { - dmPolicy: "pairing", - allowFrom: ["+15551234567"], - groupPolicy: "allowlist", - groupAllowFrom: ["+15551234567"], - }, - }, -} -``` - - - - - -```bash -openclaw channels login --channel whatsapp -``` - - For a specific account: - -```bash -openclaw channels login --channel whatsapp --account work -``` - - - - - -```bash -openclaw gateway -``` - - - - - -```bash -openclaw pairing list whatsapp -openclaw pairing approve whatsapp -``` - - Pairing requests expire after 1 hour. Pending requests are capped at 3 per channel. - - - - - -OpenClaw recommends running WhatsApp on a separate number when possible. (The channel metadata and onboarding flow are optimized for that setup, but personal-number setups are also supported.) - - -## Deployment patterns - - - - This is the cleanest operational mode: - - - separate WhatsApp identity for OpenClaw - - clearer DM allowlists and routing boundaries - - lower chance of self-chat confusion - - Minimal policy pattern: - - ```json5 - { - channels: { - whatsapp: { - dmPolicy: "allowlist", - allowFrom: ["+15551234567"], - }, - }, - } - ``` - - - - - Onboarding supports personal-number mode and writes a self-chat-friendly baseline: - - - `dmPolicy: "allowlist"` - - `allowFrom` includes your personal number - - `selfChatMode: true` - - In runtime, self-chat protections key off the linked self number and `allowFrom`. - - - - - The messaging platform channel is WhatsApp Web-based (`Baileys`) in current OpenClaw channel architecture. - - There is no separate Twilio WhatsApp messaging channel in the built-in chat-channel registry. - - - - -## Runtime model - -- Gateway owns the WhatsApp socket and reconnect loop. -- Outbound sends require an active WhatsApp listener for the target account. -- Status and broadcast chats are ignored (`@status`, `@broadcast`). -- Direct chats use DM session rules (`session.dmScope`; default `main` collapses DMs to the agent main session). -- Group sessions are isolated (`agent::whatsapp:group:`). - -## Access control and activation - - - - `channels.whatsapp.dmPolicy` controls direct chat access: - - - `pairing` (default) - - `allowlist` - - `open` (requires `allowFrom` to include `"*"`) - - `disabled` - - `allowFrom` accepts E.164-style numbers (normalized internally). - - Multi-account override: `channels.whatsapp.accounts..dmPolicy` (and `allowFrom`) take precedence over channel-level defaults for that account. - - Runtime behavior details: - - - pairings are persisted in channel allow-store and merged with configured `allowFrom` - - if no allowlist is configured, the linked self number is allowed by default - - outbound `fromMe` DMs are never auto-paired - - - - - Group access has two layers: - - 1. **Group membership allowlist** (`channels.whatsapp.groups`) - - if `groups` is omitted, all groups are eligible - - if `groups` is present, it acts as a group allowlist (`"*"` allowed) - - 2. **Group sender policy** (`channels.whatsapp.groupPolicy` + `groupAllowFrom`) - - `open`: sender allowlist bypassed - - `allowlist`: sender must match `groupAllowFrom` (or `*`) - - `disabled`: block all group inbound - - Sender allowlist fallback: - - - if `groupAllowFrom` is unset, runtime falls back to `allowFrom` when available - - sender allowlists are evaluated before mention/reply activation - - Note: if no `channels.whatsapp` block exists at all, runtime group-policy fallback is effectively `open`. - - - - - Group replies require mention by default. - - Mention detection includes: - - - explicit WhatsApp mentions of the bot identity - - configured mention regex patterns (`agents.list[].groupChat.mentionPatterns`, fallback `messages.groupChat.mentionPatterns`) - - implicit reply-to-bot detection (reply sender matches bot identity) - - Security note: - - - quote/reply only satisfies mention gating; it does **not** grant sender authorization - - with `groupPolicy: "allowlist"`, non-allowlisted senders are still blocked even if they reply to an allowlisted user's message - - Session-level activation command: - - - `/activation mention` - - `/activation always` - - `activation` updates session state (not global config). It is owner-gated. - - - - -## Personal-number and self-chat behavior - -When the linked self number is also present in `allowFrom`, WhatsApp self-chat safeguards activate: - -- skip read receipts for self-chat turns -- ignore mention-JID auto-trigger behavior that would otherwise ping yourself -- if `messages.responsePrefix` is unset, self-chat replies default to `[{identity.name}]` or `[openclaw]` - -## Message normalization and context - - - - Incoming WhatsApp messages are wrapped in the shared inbound envelope. - - If a quoted reply exists, context is appended in this form: - - ```text - [Replying to id:] - - [/Replying] - ``` - - Reply metadata fields are also populated when available (`ReplyToId`, `ReplyToBody`, `ReplyToSender`, sender JID/E.164). - - - - - Media-only inbound messages are normalized with placeholders such as: - - - `` - - `` - - `` - - `` - - `` - - Location and contact payloads are normalized into textual context before routing. - - - - - For groups, unprocessed messages can be buffered and injected as context when the bot is finally triggered. - - - default limit: `50` - - config: `channels.whatsapp.historyLimit` - - fallback: `messages.groupChat.historyLimit` - - `0` disables - - Injection markers: - - - `[Chat messages since your last reply - for context]` - - `[Current message - respond to this]` - - - - - Read receipts are enabled by default for accepted inbound WhatsApp messages. - - Disable globally: - - ```json5 - { - channels: { - whatsapp: { - sendReadReceipts: false, - }, - }, - } - ``` - - Per-account override: - - ```json5 - { - channels: { - whatsapp: { - accounts: { - work: { - sendReadReceipts: false, - }, - }, - }, - }, - } - ``` - - Self-chat turns skip read receipts even when globally enabled. - - - - -## Delivery, chunking, and media - - - - - default chunk limit: `channels.whatsapp.textChunkLimit = 4000` - - `channels.whatsapp.chunkMode = "length" | "newline"` - - `newline` mode prefers paragraph boundaries (blank lines), then falls back to length-safe chunking - - - - - supports image, video, audio (PTT voice-note), and document payloads - - `audio/ogg` is rewritten to `audio/ogg; codecs=opus` for voice-note compatibility - - animated GIF playback is supported via `gifPlayback: true` on video sends - - captions are applied to the first media item when sending multi-media reply payloads - - media source can be HTTP(S), `file://`, or local paths - - - - - inbound media save cap: `channels.whatsapp.mediaMaxMb` (default `50`) - - outbound media cap for auto-replies: `agents.defaults.mediaMaxMb` (default `5MB`) - - images are auto-optimized (resize/quality sweep) to fit limits - - on media send failure, first-item fallback sends text warning instead of dropping the response silently - - - -## Acknowledgment reactions - -WhatsApp supports immediate ack reactions on inbound receipt via `channels.whatsapp.ackReaction`. - -```json5 -{ - channels: { - whatsapp: { - ackReaction: { - emoji: "👀", - direct: true, - group: "mentions", // always | mentions | never - }, - }, - }, -} -``` - -Behavior notes: - -- sent immediately after inbound is accepted (pre-reply) -- failures are logged but do not block normal reply delivery -- group mode `mentions` reacts on mention-triggered turns; group activation `always` acts as bypass for this check -- WhatsApp uses `channels.whatsapp.ackReaction` (legacy `messages.ackReaction` is not used here) - -## Multi-account and credentials - - - - - account ids come from `channels.whatsapp.accounts` - - default account selection: `default` if present, otherwise first configured account id (sorted) - - account ids are normalized internally for lookup - - - - - current auth path: `~/.openclaw/credentials/whatsapp//creds.json` - - backup file: `creds.json.bak` - - legacy default auth in `~/.openclaw/credentials/` is still recognized/migrated for default-account flows - - - - `openclaw channels logout --channel whatsapp [--account ]` clears WhatsApp auth state for that account. - - In legacy auth directories, `oauth.json` is preserved while Baileys auth files are removed. - - - - -## Tools, actions, and config writes - -- Agent tool support includes WhatsApp reaction action (`react`). -- Action gates: - - `channels.whatsapp.actions.reactions` - - `channels.whatsapp.actions.polls` -- Channel-initiated config writes are enabled by default (disable via `channels.whatsapp.configWrites=false`). - -## Troubleshooting - - - - Symptom: channel status reports not linked. - - Fix: - - ```bash - openclaw channels login --channel whatsapp - openclaw channels status - ``` - - - - - Symptom: linked account with repeated disconnects or reconnect attempts. - - Fix: - - ```bash - openclaw doctor - openclaw logs --follow - ``` - - If needed, re-link with `channels login`. - - - - - Outbound sends fail fast when no active gateway listener exists for the target account. - - Make sure gateway is running and the account is linked. - - - - - Check in this order: - - - `groupPolicy` - - `groupAllowFrom` / `allowFrom` - - `groups` allowlist entries - - mention gating (`requireMention` + mention patterns) - - duplicate keys in `openclaw.json` (JSON5): later entries override earlier ones, so keep a single `groupPolicy` per scope - - - - - WhatsApp gateway runtime should use Node. Bun is flagged as incompatible for stable WhatsApp/Telegram gateway operation. - - - -## Configuration reference pointers - -Primary reference: - -- [Configuration reference - WhatsApp](/gateway/configuration-reference#whatsapp) - -High-signal WhatsApp fields: - -- access: `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups` -- delivery: `textChunkLimit`, `chunkMode`, `mediaMaxMb`, `sendReadReceipts`, `ackReaction` -- multi-account: `accounts..enabled`, `accounts..authDir`, account-level overrides -- operations: `configWrites`, `debounceMs`, `web.enabled`, `web.heartbeatSeconds`, `web.reconnect.*` -- session behavior: `session.dmScope`, `historyLimit`, `dmHistoryLimit`, `dms..historyLimit` - -## Related - -- [Pairing](/channels/pairing) -- [Channel routing](/channels/channel-routing) -- [Multi-agent routing](/concepts/multi-agent) -- [Troubleshooting](/channels/troubleshooting) diff --git a/docs/channels/zalo.md b/docs/channels/zalo.md deleted file mode 100644 index cda126f5649..00000000000 --- a/docs/channels/zalo.md +++ /dev/null @@ -1,192 +0,0 @@ ---- -summary: "Zalo bot support status, capabilities, and configuration" -read_when: - - Working on Zalo features or webhooks -title: "Zalo" ---- - -# Zalo (Bot API) - -Status: experimental. Direct messages only; groups coming soon per Zalo docs. - -## Plugin required - -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](/tools/plugin) - -## Quick setup (beginner) - -1. Install the Zalo plugin: - - From a source checkout: `openclaw plugins install ./extensions/zalo` - - From npm (if published): `openclaw plugins install @openclaw/zalo` - - Or pick **Zalo** in onboarding and confirm the install prompt -2. Set the token: - - Env: `ZALO_BOT_TOKEN=...` - - Or config: `channels.zalo.botToken: "..."`. -3. Restart the gateway (or finish onboarding). -4. DM access is pairing by default; approve the pairing code on first contact. - -Minimal config: - -```json5 -{ - channels: { - zalo: { - enabled: true, - botToken: "12345689:abc-xyz", - dmPolicy: "pairing", - }, - }, -} -``` - -## What it is - -Zalo is a Vietnam-focused messaging app; its Bot API lets the Gateway run a bot for 1:1 conversations. -It is a good fit for support or notifications where you want deterministic routing back to Zalo. - -- A Zalo Bot API channel owned by the Gateway. -- Deterministic routing: replies go back to Zalo; the model never chooses channels. -- DMs share the agent's main session. -- Groups are not yet supported (Zalo docs state "coming soon"). - -## Setup (fast path) - -### 1) Create a bot token (Zalo Bot Platform) - -1. Go to [https://bot.zaloplatforms.com](https://bot.zaloplatforms.com) and sign in. -2. Create a new bot and configure its settings. -3. Copy the bot token (format: `12345689:abc-xyz`). - -### 2) Configure the token (env or config) - -Example: - -```json5 -{ - channels: { - zalo: { - enabled: true, - botToken: "12345689:abc-xyz", - dmPolicy: "pairing", - }, - }, -} -``` - -Env option: `ZALO_BOT_TOKEN=...` (works for the default account only). - -Multi-account support: use `channels.zalo.accounts` with per-account tokens and optional `name`. - -3. Restart the gateway. Zalo starts when a token is resolved (env or config). -4. DM access defaults to pairing. Approve the code when the bot is first contacted. - -## How it works (behavior) - -- Inbound messages are normalized into the shared channel envelope with media placeholders. -- Replies always route back to the same Zalo chat. -- Long-polling by default; webhook mode available with `channels.zalo.webhookUrl`. - -## Limits - -- Outbound text is chunked to 2000 characters (Zalo API limit). -- Media downloads/uploads are capped by `channels.zalo.mediaMaxMb` (default 5). -- Streaming is blocked by default due to the 2000 char limit making streaming less useful. - -## Access control (DMs) - -### DM access - -- Default: `channels.zalo.dmPolicy = "pairing"`. Unknown senders receive a pairing code; messages are ignored until approved (codes expire after 1 hour). -- Approve via: - - `openclaw pairing list zalo` - - `openclaw pairing approve zalo ` -- 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 - -- Default: long-polling (no public URL required). -- Webhook mode: set `channels.zalo.webhookUrl` and `channels.zalo.webhookSecret`. - - The webhook secret must be 8-256 characters. - - Webhook URL must use HTTPS. - - Zalo sends events with `X-Bot-Api-Secret-Token` header for verification. - - Gateway HTTP handles webhook requests at `channels.zalo.webhookPath` (defaults to the webhook URL path). - - Requests must use `Content-Type: application/json` (or `+json` media types). - - Duplicate events (`event_name + message_id`) are ignored for a short replay window. - - Burst traffic is rate-limited per path/source and may return HTTP 429. - -**Note:** getUpdates (polling) and webhook are mutually exclusive per Zalo API docs. - -## Supported message types - -- **Text messages**: Full support with 2000 character chunking. -- **Image messages**: Download and process inbound images; send images via `sendPhoto`. -- **Stickers**: Logged but not fully processed (no agent response). -- **Unsupported types**: Logged (e.g., messages from protected users). - -## Capabilities - -| Feature | Status | -| --------------- | ------------------------------ | -| Direct messages | ✅ Supported | -| Groups | ❌ Coming soon (per Zalo docs) | -| Media (images) | ✅ Supported | -| Reactions | ❌ Not supported | -| Threads | ❌ Not supported | -| Polls | ❌ Not supported | -| Native commands | ❌ Not supported | -| Streaming | ⚠️ Blocked (2000 char limit) | - -## Delivery targets (CLI/cron) - -- Use a chat id as the target. -- Example: `openclaw message send --channel zalo --target 123456789 --message "hi"`. - -## Troubleshooting - -**Bot doesn't respond:** - -- Check that the token is valid: `openclaw channels status --probe` -- Verify the sender is approved (pairing or allowFrom) -- Check gateway logs: `openclaw logs --follow` - -**Webhook not receiving events:** - -- Ensure webhook URL uses HTTPS -- Verify secret token is 8-256 characters -- Confirm the gateway HTTP endpoint is reachable on the configured path -- Check that getUpdates polling is not running (they're mutually exclusive) - -## Configuration reference (Zalo) - -Full configuration: [Configuration](/gateway/configuration) - -Provider options: - -- `channels.zalo.enabled`: enable/disable channel startup. -- `channels.zalo.botToken`: bot token from Zalo Bot Platform. -- `channels.zalo.tokenFile`: read token from file path. -- `channels.zalo.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing). -- `channels.zalo.allowFrom`: DM allowlist (user IDs). `open` requires `"*"`. The wizard will ask for numeric IDs. -- `channels.zalo.mediaMaxMb`: inbound/outbound media cap (MB, default 5). -- `channels.zalo.webhookUrl`: enable webhook mode (HTTPS required). -- `channels.zalo.webhookSecret`: webhook secret (8-256 chars). -- `channels.zalo.webhookPath`: webhook path on the gateway HTTP server. -- `channels.zalo.proxy`: proxy URL for API requests. - -Multi-account options: - -- `channels.zalo.accounts..botToken`: per-account token. -- `channels.zalo.accounts..tokenFile`: per-account token file. -- `channels.zalo.accounts..name`: display name. -- `channels.zalo.accounts..enabled`: enable/disable account. -- `channels.zalo.accounts..dmPolicy`: per-account DM policy. -- `channels.zalo.accounts..allowFrom`: per-account allowlist. -- `channels.zalo.accounts..webhookUrl`: per-account webhook URL. -- `channels.zalo.accounts..webhookSecret`: per-account webhook secret. -- `channels.zalo.accounts..webhookPath`: per-account webhook path. -- `channels.zalo.accounts..proxy`: per-account proxy URL. diff --git a/docs/channels/zalouser.md b/docs/channels/zalouser.md deleted file mode 100644 index e93e71a6f7e..00000000000 --- a/docs/channels/zalouser.md +++ /dev/null @@ -1,140 +0,0 @@ ---- -summary: "Zalo personal account support via zca-cli (QR login), capabilities, and configuration" -read_when: - - Setting up Zalo Personal for OpenClaw - - Debugging Zalo Personal login or message flow -title: "Zalo Personal" ---- - -# Zalo Personal (unofficial) - -Status: experimental. This integration automates a **personal Zalo account** via `zca-cli`. - -> **Warning:** This is an unofficial integration and may result in account suspension/ban. Use at your own risk. - -## Plugin required - -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](/tools/plugin) - -## Prerequisite: zca-cli - -The Gateway machine must have the `zca` binary available in `PATH`. - -- Verify: `zca --version` -- If missing, install zca-cli (see `extensions/zalouser/README.md` or the upstream zca-cli docs). - -## Quick setup (beginner) - -1. Install the plugin (see above). -2. Login (QR, on the Gateway machine): - - `openclaw channels login --channel zalouser` - - Scan the QR code in the terminal with the Zalo mobile app. -3. Enable the channel: - -```json5 -{ - channels: { - zalouser: { - enabled: true, - dmPolicy: "pairing", - }, - }, -} -``` - -4. Restart the Gateway (or finish onboarding). -5. DM access defaults to pairing; approve the pairing code on first contact. - -## What it is - -- Uses `zca listen` to receive inbound messages. -- Uses `zca msg ...` to send replies (text/media/link). -- Designed for “personal account” use cases where Zalo Bot API is not available. - -## Naming - -Channel id is `zalouser` to make it explicit this automates a **personal Zalo user account** (unofficial). We keep `zalo` reserved for a potential future official Zalo API integration. - -## Finding IDs (directory) - -Use the directory CLI to discover peers/groups and their IDs: - -```bash -openclaw directory self --channel zalouser -openclaw directory peers list --channel zalouser --query "name" -openclaw directory groups list --channel zalouser --query "work" -``` - -## Limits - -- Outbound text is chunked to ~2000 characters (Zalo client limits). -- Streaming is blocked by default. - -## Access control (DMs) - -`channels.zalouser.dmPolicy` supports: `pairing | allowlist | open | disabled` (default: `pairing`). -`channels.zalouser.allowFrom` accepts user IDs or names. The wizard resolves names to IDs via `zca friend find` when available. - -Approve via: - -- `openclaw pairing list zalouser` -- `openclaw pairing approve zalouser ` - -## Group access (optional) - -- Default: `channels.zalouser.groupPolicy = "open"` (groups allowed). Use `channels.defaults.groupPolicy` to override the default when unset. -- Restrict to an allowlist with: - - `channels.zalouser.groupPolicy = "allowlist"` - - `channels.zalouser.groups` (keys are group IDs or names) -- Block all groups: `channels.zalouser.groupPolicy = "disabled"`. -- The configure wizard can prompt for group allowlists. -- On startup, OpenClaw resolves group/user names in allowlists to IDs and logs the mapping; unresolved entries are kept as typed. - -Example: - -```json5 -{ - channels: { - zalouser: { - groupPolicy: "allowlist", - groups: { - "123456789": { allow: true }, - "Work Chat": { allow: true }, - }, - }, - }, -} -``` - -## Multi-account - -Accounts map to zca profiles. Example: - -```json5 -{ - channels: { - zalouser: { - enabled: true, - defaultAccount: "default", - accounts: { - work: { enabled: true, profile: "work" }, - }, - }, - }, -} -``` - -## Troubleshooting - -**`zca` not found:** - -- Install zca-cli and ensure it’s on `PATH` for the Gateway process. - -**Login doesn’t stick:** - -- `openclaw channels status --probe` -- Re-login: `openclaw channels logout --channel zalouser && openclaw channels login --channel zalouser` diff --git a/docs/ci.md b/docs/ci.md deleted file mode 100644 index 64d4df0ec1c..00000000000 --- a/docs/ci.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -title: CI Pipeline -description: How the OpenClaw CI pipeline works ---- - -# CI Pipeline - -The CI runs on every push to `main` and every pull request. It uses smart scoping to skip expensive jobs when only docs or native code changed. - -## Job Overview - -| Job | Purpose | When it runs | -| ----------------- | ----------------------------------------------- | ------------------------- | -| `docs-scope` | Detect docs-only changes | Always | -| `changed-scope` | Detect which areas changed (node/macos/android) | Non-docs PRs | -| `check` | TypeScript types, lint, format | Non-docs changes | -| `check-docs` | Markdown lint + broken link check | Docs changed | -| `code-analysis` | LOC threshold check (1000 lines) | PRs only | -| `secrets` | Detect leaked secrets | Always | -| `build-artifacts` | Build dist once, share with other jobs | Non-docs, node changes | -| `release-check` | Validate npm pack contents | After build | -| `checks` | Node/Bun tests + protocol check | Non-docs, node changes | -| `checks-windows` | Windows-specific tests | Non-docs, node changes | -| `macos` | Swift lint/build/test + TS tests | PRs with macos changes | -| `android` | Gradle build + tests | Non-docs, android changes | - -## Fail-Fast Order - -Jobs are ordered so cheap checks fail before expensive ones run: - -1. `docs-scope` + `code-analysis` + `check` (parallel, ~1-2 min) -2. `build-artifacts` (blocked on above) -3. `checks`, `checks-windows`, `macos`, `android` (blocked on build) - -## Runners - -| Runner | Jobs | -| -------------------------------- | ------------------------------------------ | -| `blacksmith-16vcpu-ubuntu-2404` | Most Linux jobs, including scope detection | -| `blacksmith-16vcpu-windows-2025` | `checks-windows` | -| `macos-latest` | `macos`, `ios` | - -## Local Equivalents - -```bash -pnpm check # types + lint + format -pnpm test # vitest tests -pnpm check:docs # docs format + lint + broken links -pnpm release:check # validate npm pack -``` diff --git a/docs/cli/acp.md b/docs/cli/acp.md deleted file mode 100644 index 9535509016d..00000000000 --- a/docs/cli/acp.md +++ /dev/null @@ -1,182 +0,0 @@ ---- -summary: "Run the ACP bridge for IDE integrations" -read_when: - - Setting up ACP-based IDE integrations - - Debugging ACP session routing to the Gateway -title: "acp" ---- - -# acp - -Run the ACP (Agent Client Protocol) bridge that talks to a OpenClaw Gateway. - -This command speaks ACP over stdio for IDEs and forwards prompts to the Gateway -over WebSocket. It keeps ACP sessions mapped to Gateway session keys. - -## Usage - -```bash -openclaw acp - -# Remote Gateway -openclaw acp --url wss://gateway-host:18789 --token - -# Remote Gateway (token from file) -openclaw acp --url wss://gateway-host:18789 --token-file ~/.openclaw/gateway.token - -# Attach to an existing session key -openclaw acp --session agent:main:main - -# Attach by label (must already exist) -openclaw acp --session-label "support inbox" - -# Reset the session key before the first prompt -openclaw acp --session agent:main:main --reset-session -``` - -## ACP client (debug) - -Use the built-in ACP client to sanity-check the bridge without an IDE. -It spawns the ACP bridge and lets you type prompts interactively. - -```bash -openclaw acp client - -# Point the spawned bridge at a remote Gateway -openclaw acp client --server-args --url wss://gateway-host:18789 --token-file ~/.openclaw/gateway.token - -# Override the server command (default: openclaw) -openclaw acp client --server "node" --server-args openclaw.mjs acp --url ws://127.0.0.1:19001 -``` - -## How to use this - -Use ACP when an IDE (or other client) speaks Agent Client Protocol and you want -it to drive a OpenClaw Gateway session. - -1. Ensure the Gateway is running (local or remote). -2. Configure the Gateway target (config or flags). -3. Point your IDE to run `openclaw acp` over stdio. - -Example config (persisted): - -```bash -openclaw config set gateway.remote.url wss://gateway-host:18789 -openclaw config set gateway.remote.token -``` - -Example direct run (no config write): - -```bash -openclaw acp --url wss://gateway-host:18789 --token -# preferred for local process safety -openclaw acp --url wss://gateway-host:18789 --token-file ~/.openclaw/gateway.token -``` - -## Selecting agents - -ACP does not pick agents directly. It routes by the Gateway session key. - -Use agent-scoped session keys to target a specific agent: - -```bash -openclaw acp --session agent:main:main -openclaw acp --session agent:design:main -openclaw acp --session agent:qa:bug-123 -``` - -Each ACP session maps to a single Gateway session key. One agent can have many -sessions; ACP defaults to an isolated `acp:` session unless you override -the key or label. - -## Zed editor setup - -Add a custom ACP agent in `~/.config/zed/settings.json` (or use Zed’s Settings UI): - -```json -{ - "agent_servers": { - "OpenClaw ACP": { - "type": "custom", - "command": "openclaw", - "args": ["acp"], - "env": {} - } - } -} -``` - -To target a specific Gateway or agent: - -```json -{ - "agent_servers": { - "OpenClaw ACP": { - "type": "custom", - "command": "openclaw", - "args": [ - "acp", - "--url", - "wss://gateway-host:18789", - "--token", - "", - "--session", - "agent:design:main" - ], - "env": {} - } - } -} -``` - -In Zed, open the Agent panel and select “OpenClaw ACP” to start a thread. - -## Session mapping - -By default, ACP sessions get an isolated Gateway session key with an `acp:` prefix. -To reuse a known session, pass a session key or label: - -- `--session `: use a specific Gateway session key. -- `--session-label