Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3762e876b3 | ||
|
|
9b4d06002a | ||
|
|
d2d336fc3a |
No files matched your search
@@ -1,48 +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/
|
||||
@@ -1,28 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Report a problem or unexpected behavior in Clawdbot.
|
||||
title: "[Bug]: "
|
||||
labels: bug
|
||||
---
|
||||
|
||||
## Summary
|
||||
What went wrong?
|
||||
|
||||
## Steps to reproduce
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Expected behavior
|
||||
What did you expect to happen?
|
||||
|
||||
## Actual behavior
|
||||
What actually happened?
|
||||
|
||||
## Environment
|
||||
- Clawdbot version:
|
||||
- OS:
|
||||
- Install method (pnpm/npx/docker/etc):
|
||||
|
||||
## Logs or screenshots
|
||||
Paste relevant logs or add screenshots (redact secrets).
|
||||
@@ -1,8 +0,0 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Onboarding
|
||||
url: https://discord.gg/clawd
|
||||
about: New to Clawdbot? Join Discord for setup guidance from Krill in #help.
|
||||
- name: Support
|
||||
url: https://discord.gg/clawd
|
||||
about: Get help from Krill and the community on Discord in #help.
|
||||
@@ -1,18 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea or improvement for Clawdbot.
|
||||
title: "[Feature]: "
|
||||
labels: enhancement
|
||||
---
|
||||
|
||||
## Summary
|
||||
Describe the problem you are trying to solve or the opportunity you see.
|
||||
|
||||
## Proposed solution
|
||||
What would you like Clawdbot to do?
|
||||
|
||||
## Alternatives considered
|
||||
Any other approaches you have considered?
|
||||
|
||||
## Additional context
|
||||
Links, screenshots, or related issues.
|
||||
+65
-100
@@ -5,33 +5,12 @@ on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runtime: node
|
||||
task: lint
|
||||
command: pnpm lint
|
||||
- runtime: node
|
||||
task: test
|
||||
command: pnpm test
|
||||
- runtime: node
|
||||
task: build
|
||||
command: pnpm build
|
||||
- runtime: node
|
||||
task: protocol
|
||||
command: pnpm protocol:check
|
||||
- runtime: bun
|
||||
task: lint
|
||||
command: bunx biome check src
|
||||
- runtime: bun
|
||||
task: test
|
||||
command: bunx vitest run
|
||||
- runtime: bun
|
||||
task: build
|
||||
command: bunx tsc -p tsconfig.json
|
||||
runtime: [node, bun]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -52,21 +31,32 @@ jobs:
|
||||
exit 1
|
||||
|
||||
- name: Setup Node.js
|
||||
if: matrix.runtime == 'node'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 22
|
||||
check-latest: true
|
||||
|
||||
- name: Setup Bun
|
||||
if: matrix.runtime == 'bun'
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
# bun.sh downloads currently fail with:
|
||||
# "Failed to list releases from GitHub: 401" -> "Unexpected HTTP response: 400"
|
||||
bun-download-url: "https://github.com/oven-sh/bun/releases/latest/download/bun-linux-x64.zip"
|
||||
|
||||
- name: Setup Node.js (tooling for bun)
|
||||
if: matrix.runtime == 'bun'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
check-latest: true
|
||||
|
||||
- name: Runtime versions
|
||||
run: |
|
||||
node -v
|
||||
npm -v
|
||||
bun -v
|
||||
if [ "${{ matrix.runtime }}" = "bun" ]; then bun -v; fi
|
||||
|
||||
- name: Capture node path
|
||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
||||
@@ -87,42 +77,36 @@ jobs:
|
||||
pnpm -v
|
||||
pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
|
||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||||
run: ${{ matrix.command }}
|
||||
- name: Lint (node)
|
||||
if: matrix.runtime == 'node'
|
||||
run: pnpm lint
|
||||
|
||||
- name: Test (node)
|
||||
if: matrix.runtime == 'node'
|
||||
run: pnpm test
|
||||
|
||||
- name: Build (node)
|
||||
if: matrix.runtime == 'node'
|
||||
run: pnpm build
|
||||
|
||||
- name: Protocol check (node)
|
||||
if: matrix.runtime == 'node'
|
||||
run: pnpm protocol:check
|
||||
|
||||
- name: Lint (bun)
|
||||
if: matrix.runtime == 'bun'
|
||||
run: bunx biome check src
|
||||
|
||||
- name: Test (bun)
|
||||
if: matrix.runtime == 'bun'
|
||||
run: bunx vitest run
|
||||
|
||||
- name: Build (bun)
|
||||
if: matrix.runtime == 'bun'
|
||||
run: bunx tsc -p tsconfig.json
|
||||
|
||||
macos-app:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: macos-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- task: lint
|
||||
command: |
|
||||
swiftlint --config .swiftlint.yml
|
||||
swiftformat --lint apps/macos/Sources --config .swiftformat
|
||||
- task: build
|
||||
command: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if swift build --package-path apps/macos --configuration release; then
|
||||
exit 0
|
||||
fi
|
||||
echo "swift build failed (attempt $attempt/3). Retrying…"
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
exit 1
|
||||
- task: test
|
||||
command: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then
|
||||
exit 0
|
||||
fi
|
||||
echo "swift test failed (attempt $attempt/3). Retrying…"
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
exit 1
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -157,46 +141,35 @@ jobs:
|
||||
xcodebuild -version
|
||||
swift --version
|
||||
|
||||
- name: Run ${{ matrix.task }}
|
||||
run: ${{ matrix.command }}
|
||||
ios:
|
||||
if: false # ignore iOS in CI for now
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
- name: SwiftLint
|
||||
run: swiftlint --config .swiftlint.yml
|
||||
|
||||
- name: Checkout submodules (retry)
|
||||
- name: SwiftFormat (lint mode)
|
||||
run: swiftformat --lint apps/macos/Sources --config .swiftformat
|
||||
|
||||
- name: Swift build (release)
|
||||
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
|
||||
for attempt in 1 2 3; do
|
||||
if swift build --package-path apps/macos --configuration release; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
||||
sleep $((attempt * 10))
|
||||
echo "swift build failed (attempt $attempt/3). Retrying…"
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Select Xcode 26.1
|
||||
- name: Swift tests (coverage)
|
||||
run: |
|
||||
sudo xcode-select -s /Applications/Xcode_26.1.app
|
||||
xcodebuild -version
|
||||
|
||||
- name: Install XcodeGen
|
||||
run: brew install xcodegen
|
||||
|
||||
- name: Install SwiftLint / SwiftFormat
|
||||
run: brew install swiftlint swiftformat
|
||||
|
||||
- name: Show toolchain
|
||||
run: |
|
||||
sw_vers
|
||||
xcodebuild -version
|
||||
swift --version
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then
|
||||
exit 0
|
||||
fi
|
||||
echo "swift test failed (attempt $attempt/3). Retrying…"
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Generate iOS project
|
||||
run: |
|
||||
@@ -334,14 +307,6 @@ jobs:
|
||||
|
||||
android:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- task: test
|
||||
command: ./gradlew --no-daemon :app:testDebugUnitTest
|
||||
- task: build
|
||||
command: ./gradlew --no-daemon :app:assembleDebug
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -381,6 +346,6 @@ jobs:
|
||||
"platforms;android-36" \
|
||||
"build-tools;36.0.0"
|
||||
|
||||
- name: Run Android ${{ matrix.task }}
|
||||
- name: Android unit tests + debug build
|
||||
working-directory: apps/android
|
||||
run: ${{ matrix.command }}
|
||||
run: ./gradlew --no-daemon :app:testDebugUnitTest :app:assembleDebug
|
||||
+2
-14
@@ -1,39 +1,28 @@
|
||||
node_modules
|
||||
.env
|
||||
dist
|
||||
*.bun-build
|
||||
pnpm-lock.yaml
|
||||
bun.lock
|
||||
bun.lockb
|
||||
coverage
|
||||
.pnpm-store
|
||||
.worktrees/
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
ui/src/ui/__screenshots__/
|
||||
ui/playwright-report/
|
||||
ui/test-results/
|
||||
|
||||
# Bun build artifacts
|
||||
*.bun-build
|
||||
apps/macos/.build/
|
||||
apps/shared/ClawdbotKit/.build/
|
||||
**/ModuleCache/
|
||||
bin/
|
||||
bin/clawdbot-mac
|
||||
apps/shared/ClawdisKit/.build/
|
||||
bin/clawdis-mac
|
||||
bin/docs-list
|
||||
apps/macos/.build-local/
|
||||
apps/macos/.swiftpm/
|
||||
apps/shared/ClawdbotKit/.swiftpm/
|
||||
Core/
|
||||
apps/ios/*.xcodeproj/
|
||||
apps/ios/*.xcworkspace/
|
||||
apps/ios/.swiftpm/
|
||||
vendor/
|
||||
|
||||
# Vendor build artifacts
|
||||
vendor/a2ui/renderers/lit/dist/
|
||||
.bundle.hash
|
||||
|
||||
# fastlane (iOS)
|
||||
apps/ios/fastlane/README.md
|
||||
@@ -49,4 +38,3 @@ apps/ios/*.dSYM.zip
|
||||
|
||||
# provisioning profiles (local)
|
||||
apps/ios/*.mobileprovision
|
||||
.env
|
||||
@@ -1 +1 @@
|
||||
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty
|
||||
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext
|
||||
+1
-1
@@ -48,4 +48,4 @@
|
||||
--allman false
|
||||
|
||||
# Exclusions
|
||||
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,apps/macos/Sources/ClawdisProtocol,apps/macos/Sources/ClawdbotProtocol
|
||||
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,apps/macos/Sources/ClawdisProtocol
|
||||
@@ -108,10 +108,6 @@ function_body_length:
|
||||
warning: 150
|
||||
error: 300
|
||||
|
||||
function_parameter_count:
|
||||
warning: 7
|
||||
error: 10
|
||||
|
||||
file_length:
|
||||
warning: 1500
|
||||
error: 2500
|
||||
|
||||
@@ -7,9 +7,7 @@
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- Install deps: `pnpm install`
|
||||
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
|
||||
- Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`.
|
||||
- Node remains supported for running built output (`dist/*`) and production installs.
|
||||
- Run CLI in dev: `pnpm clawdis ...` (tsx entry) or `pnpm dev` for `src/index.ts`.
|
||||
- Type-check/build: `pnpm build` (tsc)
|
||||
- Lint/format: `pnpm lint` (biome check), `pnpm format` (biome format)
|
||||
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
|
||||
@@ -18,69 +16,53 @@
|
||||
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
|
||||
- Formatting/linting via Biome; run `pnpm lint` before commits.
|
||||
- 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.
|
||||
- Keep every file ≤ 500 LOC; refactor or split before exceeding and check frequently.
|
||||
|
||||
## 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.
|
||||
- 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
|
||||
- Create commits with `scripts/committer "<msg>" <file...>`; 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.
|
||||
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
|
||||
- When working on a PR: add a changelog entry with the PR ID and thank the contributor.
|
||||
- When working on an issue: reference the issue in the changelog entry.
|
||||
- When merging a PR: leave a PR comment that explains exactly what we did.
|
||||
- When merging a PR from a new contributor: add their avatar to the README “Thanks to all clawtributors” thumbnail list.
|
||||
|
||||
## Security & Configuration Tips
|
||||
- Web provider stores creds at `~/.clawdbot/credentials/`; rerun `clawdbot login` if logged out.
|
||||
- Pi sessions live under `~/.clawdbot/sessions/` by default; the base directory is not configurable.
|
||||
- Web provider stores creds at `~/.clawdis/credentials/`; rerun `clawdis login` if logged out.
|
||||
- Pi sessions live under `~/.clawdis/sessions/` by default; the base directory is not configurable.
|
||||
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
|
||||
|
||||
## Agent-Specific Notes
|
||||
- Gateway currently runs only as the menubar app (launchctl shows `application.com.steipete.clawdbot.debug.*`), there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than expecting `com.steipete.clawdbot`. **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` (aka `vtlog`) to query unified logs for subsystem `com.steipete.clawdbot`; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
|
||||
- Gateway currently runs only as the menubar app (launchctl shows `application.com.steipete.clawdis.debug.*`), there is no separate LaunchAgent/helper label installed. Restart via the Clawdis Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdis` rather than expecting `com.steipete.clawdis`. **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` (aka `vtlog`) to query unified logs for subsystem `com.steipete.clawdis`; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
|
||||
- Also read the shared guardrails at `~/Projects/oracle/AGENTS.md` and `~/Projects/agent-scripts/AGENTS.MD` before making changes; align with any cross-repo rules noted there.
|
||||
- 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.
|
||||
- **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.
|
||||
- Notary key file lives at `~/Library/CloudStorage/Dropbox/Backup/AppStore/AuthKey_NJF3NFGTS3.p8` (Sparkle keys live under `~/Library/CloudStorage/Dropbox/Backup/Sparkle`).
|
||||
- Notary auth env vars (`APP_STORE_CONNECT_ISSUER_ID`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_API_KEY_P8`) are in `~/.profile`.
|
||||
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless Peter explicitly asks (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 Peter says "push", you may `git pull --rebase` to integrate latest changes (never discard other agents' work). When Peter says "commit", scope to your changes only. When Peter says "commit all", commit everything in grouped chunks.
|
||||
- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless Peter explicitly asks.
|
||||
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless Peter explicitly asks.
|
||||
- **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.
|
||||
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from Mac Studio, SSH via Tailscale and read the same path there.
|
||||
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
|
||||
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
|
||||
- When asked to open a “session” file, open the Pi session logs under `~/.clawdis/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`.
|
||||
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdis variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
|
||||
- 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 `clawdbot-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 sets PATH to include `/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Users/steipete/Library/pnpm` so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
|
||||
- For manual `clawdbot send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
|
||||
- Command template should stay `clawdis-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 sets PATH to include `/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Users/steipete/Library/pnpm` so `pnpm`/`clawdis` binaries resolve when invoked via `clawdis-mac`.
|
||||
- For manual `clawdis send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
|
||||
|
||||
## Exclamation Mark Escaping Workaround
|
||||
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot send` with messages containing exclamation marks, use heredoc syntax:
|
||||
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdis send` with messages containing exclamation marks, use heredoc syntax:
|
||||
|
||||
```bash
|
||||
# WRONG - will send "Hello\\!" with backslash
|
||||
clawdbot send --to "+1234" --message 'Hello!'
|
||||
clawdis send --to "+1234" --message 'Hello!'
|
||||
|
||||
# CORRECT - use heredoc to avoid escaping
|
||||
clawdbot send --to "+1234" --message "$(cat <<'EOF'
|
||||
clawdis send --to "+1234" --message "$(cat <<'EOF'
|
||||
Hello!
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
This is a Claude Code quirk, not a clawdbot bug.
|
||||
This is a Claude Code quirk, not a clawdis bug.
|
||||
+164
-221
@@ -1,253 +1,196 @@
|
||||
# Changelog
|
||||
|
||||
**Why this looks different:** the project was renamed from **Clawdis → Clawdbot**. To make the transition clear, releases now use **date-based versions** (`YYYY.M.D`) and the changelog is **compressed** into milestone summaries. Full detail still lives in git history and the docs.
|
||||
## 2.0.0-beta1 — 2025-12-19
|
||||
|
||||
## Unreleased
|
||||
First Clawdis release post rebrand. This is a semver-major because we dropped legacy providers/agents and moved defaults to new paths while adding a full macOS companion app, a WebSocket Gateway, and an iOS node.
|
||||
|
||||
### Bug Fixes
|
||||
- macOS: Voice Wake / push-to-talk no longer initialize `AVAudioEngine` at app launch, preventing Bluetooth headphones from switching into headset profile when voice features are unused. (Thanks @Nachx639)
|
||||
|
||||
### Breaking
|
||||
- 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.
|
||||
- Renamed to **Clawdis**: defaults now live under `~/.clawdis` (sessions in `~/.clawdis/sessions/`, IPC at `~/.clawdis/clawdis.sock`, logs in `/tmp/clawdis`). Launchd labels and config filenames follow the new name; legacy stores are copied forward on first run.
|
||||
- Pi only: `inbound.reply.agent.kind` accepts only `"pi"`, and the agent CLI/CLI flags for Claude/Codex/Gemini were removed. The Pi CLI runs in RPC mode with a persistent worker.
|
||||
- WhatsApp Web is the only transport; Twilio support and related CLI flags/tests were removed.
|
||||
- Direct chats now collapse into a single `main` session by default (no config needed); groups stay isolated as `group:<jid>`.
|
||||
- Gateway is now a loopback-only WebSocket daemon (`ws://127.0.0.1:18789`) that owns all providers/state; clients (CLI, WebChat, macOS app, nodes) connect to it. Start it explicitly (`clawdis gateway …`) or via Clawdis.app; helper subcommands no longer auto-spawn a gateway.
|
||||
|
||||
### Fixes
|
||||
- Messages: stop defaulting ack reactions to 👀 when identity emoji is missing.
|
||||
- Auto-reply: require slash for control commands to avoid false triggers in normal text.
|
||||
- Auto-reply: treat steer during compaction as a follow-up, queued until compaction completes.
|
||||
- Auth: lock auth profile refreshes to avoid multi-instance OAuth logouts; keep credentials on refresh failure.
|
||||
- Onboarding: prompt immediately for OpenAI Codex redirect URL on remote/headless logins.
|
||||
- Configure: add OpenAI Codex (ChatGPT OAuth) auth choice (align with onboarding).
|
||||
- Doctor: suggest adding the workspace memory system when missing (opt-out via `--no-workspace-suggestions`).
|
||||
- Build: fix duplicate protocol export, align Codex OAuth options, and add proper-lockfile typings.
|
||||
- Build: install Bun in the Dockerfile so `pnpm build` can run Bun scripts. Thanks @loukotal for PR #284.
|
||||
- Typing indicators: stop typing once the reply dispatcher drains to prevent stuck typing across Discord/Telegram/WhatsApp.
|
||||
- Typing indicators: fix a race that could keep the typing indicator stuck after quick replies. Thanks @thewilloftheshadow for PR #270.
|
||||
- Google: merge consecutive messages to satisfy strict role alternation for Google provider models. Thanks @Asleep123 for PR #266.
|
||||
- Postinstall: handle targetDir symlinks in the install script. Thanks @obviyus for PR #272.
|
||||
- WhatsApp/Telegram: add groupPolicy handling for group messages and normalize allowFrom matching (tg/telegram prefixes). Thanks @mneves75.
|
||||
- Auto-reply: add configurable ack reactions for inbound messages (default 👀 or `identity.emoji`) with scope controls. Thanks @obviyus for PR #178.
|
||||
- Polls: unify WhatsApp + Discord poll sends via the gateway + CLI (`clawdbot poll`). (#123) — thanks @dbhurley
|
||||
- Onboarding: resolve CLI entrypoint when running via `npx` so gateway daemon install works without a build step.
|
||||
- Onboarding: when OpenAI Codex OAuth is used, default to `openai-codex/gpt-5.2` and warn if the selected model lacks auth.
|
||||
- CLI: auto-migrate legacy config entries on command start (same behavior as gateway startup).
|
||||
- Gateway: add `gateway stop|restart` helpers and surface launchd/systemd/schtasks stop hints when the gateway is already running.
|
||||
- Gateway: honor `agent.timeoutSeconds` for `chat.send` and share timeout defaults across chat/cron/auto-reply. Thanks @MSch for PR #229.
|
||||
- Auth: prioritize OAuth profiles but fall back to API keys when refresh fails; stored profiles now load without explicit auth order.
|
||||
- Control UI: harden config Form view with schema normalization, map editing, and guardrails to prevent data loss on save.
|
||||
- Cron: normalize cron.add/update inputs, align channel enums/status fields across gateway/CLI/UI/macOS, and add protocol conformance tests. Thanks @mneves75 for PR #256.
|
||||
- Docs: add group chat participation guidance to the AGENTS template.
|
||||
- Gmail: stop restart loop when `gog gmail watch serve` fails to bind (address already in use).
|
||||
- Linux: auto-attempt lingering during onboarding (try without sudo, fallback to sudo) and prompt on install/restart to keep the gateway alive after logout/idle. Thanks @tobiasbischoff for PR #237.
|
||||
- TUI: migrate key handling to the updated pi-tui Key matcher API.
|
||||
- TUI: add `/elev` alias for `/elevated`.
|
||||
- Logging: redact sensitive tokens in verbose tool summaries by default (configurable patterns).
|
||||
- macOS: prefer gateway config reads/writes in local mode (fall back to disk if the gateway is unavailable).
|
||||
- macOS: local gateway now connects via tailnet IP when bind mode is `tailnet`/`auto`.
|
||||
- macOS: Connections settings now use a custom sidebar to avoid toolbar toggle issues, with rounded styling and full-width row hit targets.
|
||||
- macOS: drop deprecated `afterMs` from agent wait params to match gateway schema.
|
||||
- Auth: add OpenAI Codex OAuth support and migrate legacy oauth.json into auth.json.
|
||||
- Model: `/model` list shows auth source (masked key or OAuth email) per provider.
|
||||
- Model: `/model list` is an alias for `/model`.
|
||||
- Model: `/model` output now includes auth source location (env/auth.json/models.json).
|
||||
- Model: avoid duplicate `missing (missing)` auth labels in `/model` list output.
|
||||
- Auth: when `openai` has no API key but Codex OAuth exists, suggest `openai-codex/gpt-5.2` vs `OPENAI_API_KEY`.
|
||||
- Docs: clarify auth storage, migration, and OpenAI Codex OAuth onboarding.
|
||||
- Sandbox: copy inbound media into sandbox workspaces so agent tools can read attachments.
|
||||
- Sandbox: enable session tools in sandboxed sessions with spawned-only visibility by default (opt-in `agent.sandbox.sessionToolsVisibility = "all"`).
|
||||
- Control UI: show a reading indicator bubble while the assistant is responding.
|
||||
- Control UI: animate reading indicator dots (honors reduced-motion).
|
||||
- Control UI: stabilize chat streaming during tool runs (no flicker/vanishing text; correct run scoping).
|
||||
- Control UI: let config-form enums select empty-string values. Thanks @sreekaransrinath for PR #268.
|
||||
- Control UI: scroll chat to bottom on initial load. Thanks @kiranjd for PR #274.
|
||||
- Control UI: add Chat focus mode toggle to collapse header + sidebar.
|
||||
- Control UI: standardize UI build instructions on `bun run ui:*` (fallback supported).
|
||||
- Status: show runtime (docker/direct) and move shortcuts to `/help`.
|
||||
- Status: show model auth source (api-key/oauth).
|
||||
- Block streaming: avoid splitting Markdown fenced blocks and reopen fences when forced to split.
|
||||
- Block streaming: preserve leading indentation in block replies (lists, indented fences).
|
||||
- Docs: document systemd lingering and logged-in session requirements on macOS/Windows.
|
||||
- Auto-reply: centralize tool/block/final dispatch across providers for consistent streaming + heartbeat/prefix handling. Thanks @MSch for PR #225.
|
||||
- Heartbeat: make HEARTBEAT_OK ack padding configurable across heartbeat and cron delivery. (#238) — thanks @jalehman
|
||||
- Skills: emit MEDIA token after Nano Banana Pro image generation. Thanks @Iamadig for PR #271.
|
||||
- WhatsApp: set sender E.164 for direct chats so owner commands work in DMs.
|
||||
- Slack: keep auto-replies in the original thread when responding to thread messages. Thanks @scald for PR #251.
|
||||
- Discord: surface missing-permission hints (muted/role overrides) when replies fail.
|
||||
- Discord: use channel IDs for DMs instead of user IDs. Thanks @VACInc for PR #261.
|
||||
- Docs: clarify Slack manifest scopes (current vs optional) with references. Thanks @jarvis-medmatic for PR #235.
|
||||
- Control UI: avoid Slack config ReferenceError by reading slack config snapshots. Thanks @sreekaransrinath for PR #249.
|
||||
- Auth: rotate across multiple OAuth profiles with cooldown tracking and email-based profile IDs. Thanks @mukhtharcm for PR #269.
|
||||
- Auth: fix multi-account OAuth rotation so round-robin alternates instead of pinning to lastGood. Thanks @mukhtharcm for PR #281.
|
||||
- Configure: stop auto-writing `auth.order` for newly added auth profiles (round-robin default unless explicitly pinned).
|
||||
- Telegram: honor routing.groupChat.mentionPatterns for group mention gating. Thanks Kevin Kern (@regenrek) for PR #242.
|
||||
- Telegram: gate groups via `telegram.groups` allowlist (align with WhatsApp/iMessage). Thanks @kitze for PR #241.
|
||||
- Telegram: support media groups (multi-image messages). Thanks @obviyus for PR #220.
|
||||
- Telegram/WhatsApp: parse shared locations (pins, places, live) and expose structured ctx fields. Thanks @nachoiacovino for PR #194.
|
||||
- Auto-reply: block unauthorized `/reset` and infer WhatsApp senders from E.164 inputs.
|
||||
- Auto-reply: track compaction count in session status; verbose mode announces auto-compactions.
|
||||
- Telegram: send GIF media as animations (auto-play) and improve filename sniffing.
|
||||
- Bash tool: inherit gateway PATH so Nix-provided tools resolve during commands. Thanks @joshp123 for PR #202.
|
||||
### Gateway, nodes, and automation
|
||||
- New typed Gateway WS protocol (JSON schema validated) with `clawdis gateway {health,status,send,agent,call}` helpers and structured presence/instance updates for all clients.
|
||||
- Optional LAN-facing bridge (`tcp://0.0.0.0:18790`) keeps the Gateway loopback-only while enabling direct Bonjour-discovered connections for paired nodes.
|
||||
- Node pairing + management via `clawdis nodes {pending,approve,reject,invoke}` (used by the iOS node and future remote nodes).
|
||||
- Cron jobs are Gateway-owned (`clawdis cron …`) with run history stored as JSONL and support for “isolated summary” posting into the main session.
|
||||
|
||||
### Maintenance
|
||||
- Tooling: replace tsx with bun for TypeScript execution. Thanks @obviyus for PR #278.
|
||||
- Deps: bump pi-* stack, Slack SDK, discord-api-types, file-type, zod, and Biome.
|
||||
- Skills: add CodexBar model usage helper with macOS requirement metadata.
|
||||
- Skills: add 1Password CLI skill with op examples.
|
||||
- Lint: organize imports and wrap long lines in reply commands.
|
||||
- Refactor: centralize group allowlist/mention policy across providers.
|
||||
- Deps: update to latest across the repo.
|
||||
### macOS companion app
|
||||
- **Clawdis.app menu bar companion**: packaged, signed bundle with gateway start/stop, launchd toggle, project-root and pnpm/node auto-resolution, live log shortcut, restart button, and status/recipient table plus badges/dimming for attention and paused states.
|
||||
- **On-device Voice Wake**: Apple speech recognizer with wake-word table, language picker, live mic meter, “hold until silence,” animated ears/legs, and main-session routing that replies on the **last used surface** (WhatsApp/Telegram/WebChat). Delivery failures are logged, and the run remains visible via WebChat/session logs.
|
||||
- **WebChat & Debugging**: bundled WebChat UI, Debug tab with heartbeat sliders, session-store picker, log opener (`clawlog`), gateway restart, health probes, and scrollable settings panes.
|
||||
- **Browser control**: manage clawd’s dedicated Chrome/Chromium with tab listing/open/focus/close, screenshots, DOM query/dump, and “AI snapshots” (aria/domSnapshot/ai) via `clawdis browser …` and UI controls.
|
||||
- **Remote gateway control**: Bonjour discovery for local masters plus SSH-tunnel fallback for remote control when multicast is unavailable.
|
||||
|
||||
## 2026.1.5-3
|
||||
### iOS node
|
||||
- New iOS companion app that pairs to the Gateway bridge, reports presence as a node, and exposes a WKWebView “Canvas” for agent-driven UI.
|
||||
- `clawdis nodes invoke` supports `canvas.eval` and `canvas.snapshot` to drive and verify the iOS Canvas (fails fast when the iOS node is backgrounded).
|
||||
- Voice wake words are configurable in-app; the iOS node reconnects to the last bridge when credentials are still present in Keychain.
|
||||
|
||||
### Fixes
|
||||
- NPM package: include missing runtime dist folders (slack/signal/imessage/tui/wizard/control-ui/daemon) to avoid `ERR_MODULE_NOT_FOUND` in Node 25 npx installs.
|
||||
### WhatsApp & agent experience
|
||||
- Group chats fully supported: mention-gated triggers (including media-only captions), sender attribution, session primer with subject/member roster, allowlist bypass when you’re @‑mentioned, and safer handling of view-once/ephemeral media.
|
||||
- Thinking/verbosity directives: `/think` and `/verbose` acknowledge and persist per session while allowing inline overrides; verbose mode streams tool metadata with emoji/args/previews and coalesces bursts to reduce WhatsApp noise.
|
||||
- Heartbeats: configurable cadence with CLI/GUI toggles; directive acks suppressed during heartbeats; array/multi-payload replies normalized for Baileys.
|
||||
- Reply quality: smarter chunking on words/newlines, fallback warnings when media fails to send, self-number mention detection, and primed group sessions send the roster on first turn.
|
||||
- In-chat `/status`: prints agent readiness, session context usage %, current thinking/verbose options, and when the WhatsApp web creds were refreshed (helps decide when to re-scan QR); still available via `clawdis status` CLI for web session health.
|
||||
|
||||
## 2026.1.5-2
|
||||
### CLI, RPC, and health
|
||||
- New `clawdis agent` command plus a persistent Pi RPC worker (auto-started) enables direct agent chats; `clawdis status` renders a colored session/recipient table.
|
||||
- `clawdis health` probes WhatsApp link status, connect latency, heartbeat interval, session-store recency, and IPC socket presence (JSON mode for monitors).
|
||||
- Added `--help`/`--version` flags; login/logout accept `--provider` (WhatsApp default). Console output is mirrored into pino logs under `/tmp/clawdis`.
|
||||
- RPC stability: stdin/stdout loop for Pi, auto-restart worker, raw error surfacing, and deliver-via-RPC when JSON agent output is returned.
|
||||
|
||||
### Fixes
|
||||
- NPM package: include `dist/sessions` so `clawdbot agent` resolves session helpers in npx installs.
|
||||
- Node 25: avoid unsupported directory import by targeting `qrcode-terminal/vendor/QRCode/*.js` modules.
|
||||
|
||||
## 2026.1.5-1
|
||||
|
||||
### Fixes
|
||||
- NPM package: include `dist/sessions` so `clawdbot agent` resolves session helpers in npx installs.
|
||||
- Node 25: avoid unsupported directory import by targeting `qrcode-terminal/vendor/QRCode/index.js`.
|
||||
|
||||
## 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 `$CLAWDBOT_STATE_DIR/.env` (`~/.clawdbot/.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 `clawdbot agent` via the Gateway by default; use `--local` to force embedded mode.
|
||||
|
||||
## 2026.1.5
|
||||
|
||||
### Fixes
|
||||
- Control UI: render Markdown in chat messages (sanitized).
|
||||
|
||||
|
||||
## 2026.1.4
|
||||
|
||||
### Highlights
|
||||
- Rename completion: all CLIs, paths, bundle IDs, env vars, and docs standardized on **Clawdbot**.
|
||||
- Agent-to-agent relay: `sessions_send` ping‑pong with `REPLY_SKIP` plus announce step with `ANNOUNCE_SKIP`.
|
||||
- Gateway quality-of-life: config hot reload, port config support, and Control UI base paths.
|
||||
- Sandbox additions: per-session Docker sandbox with hardened limits + optional sandboxed Chromium.
|
||||
- New node capability: `location.get` across macOS/iOS/Android (CLI + tools).
|
||||
- Models CLI: scan OpenRouter free models (tools/images), manage aliases/fallbacks, and show last-used model in status.
|
||||
|
||||
### Breaking
|
||||
- Tool names drop the `clawdbot_` prefix (`browser`, `canvas`, `nodes`, `cron`, `gateway`).
|
||||
- Bash tool removes node-pty `stdinMode: "pty"` support (use tmux for real TTYs).
|
||||
- Primary session key is fixed to `main` (or `global` for global scope).
|
||||
|
||||
### Fixes
|
||||
- Doctor migrates legacy Clawdis config/service installs and normalizes sandbox Docker names.
|
||||
- Doctor checks sandbox image availability and offers to build or fall back to legacy images.
|
||||
- Presence beacons keep node lists fresh; Instances view stays accurate.
|
||||
- Block streaming/chunking reliability (Telegram/Discord ordering, fewer duplicates).
|
||||
- WhatsApp GIF playback for MP4-based GIFs.
|
||||
- Onboarding + Control UI basePath handling fixes and UI polish.
|
||||
- Clearer tool summaries, reduced log noise, and safer watchdog/queue behavior.
|
||||
- Canvas host watcher resilience; build and packaging edge cases cleaned up.
|
||||
### Security & hardening
|
||||
- Media server blocks symlink/path traversal, clears temporary downloads, and rotates logs daily (24h retention).
|
||||
- Session store purged on logout; IPC socket directory permissions tightened (0700/0600).
|
||||
- Launchd PATH and helper lookup hardened for packaged macOS builds; health probes surface missing binaries quickly.
|
||||
|
||||
### Docs
|
||||
- Sandbox setup, hot reload, port config, and session announce step coverage.
|
||||
- Skills and onboarding clarifications + additional examples.
|
||||
- Added `docs/telegram.md` outlining the Telegram Bot API provider (grammY) and how it shares the `main` session. Default grammY throttler keeps Bot API calls under rate limits.
|
||||
- Gateway can run WhatsApp + Telegram together when configured; `clawdis send --provider telegram …` sends via the Telegram bot (webhook/proxy options documented).
|
||||
|
||||
## 2026.1.3 (beta 5)
|
||||
## 1.5.0 — 2025-12-05
|
||||
|
||||
### Breaking
|
||||
- Skills config moved under `skills.*` (new `skills.entries`, `skills.allowBundled`).
|
||||
- Group session keys now `surface:group:<id>` / `surface:channel:<id>`; legacy `group:*` removed.
|
||||
- Discord config refactor; `discord.allowFrom` + `discord.requireMention` removed.
|
||||
- Discord/Telegram require `enabled: true` in config when using env tokens.
|
||||
- Routing `allowFrom`/mention settings moved to per-surface group settings.
|
||||
- Dropped all non-Pi agents (Claude, Codex, Gemini, Opencode); `inbound.reply.agent.kind` now only accepts `"pi"` and related CLI helpers have been removed.
|
||||
- Removed Twilio support and all related commands/options (webhook/up/provider flags/wait-poll); CLAWDIS is Baileys Web-only.
|
||||
|
||||
### Changes
|
||||
- Default agent handling now favors Pi RPC while falling back to plain command execution for non-Pi invocations, keeping heartbeat/session plumbing intact.
|
||||
- Documentation updated to reflect Pi-only support and to mark legacy Claude paths as historical.
|
||||
- Status command reports web session health + session recipients; config paths are locked to `~/.clawdis` with session metadata stored under `~/.clawdis/sessions/`.
|
||||
- Simplified send/agent/gateway/heartbeat to web-only delivery; removed Twilio mocks/tests and dead code.
|
||||
- Pi RPC timeout is now inactivity-based (5m without events) and error messages show seconds only.
|
||||
- Pi sessions now write to `~/.clawdis/sessions/` by default (legacy session logs from older installs are copied over when present).
|
||||
- Directive triggers (`/think`, `/verbose`, `/stop` et al.) now reply immediately using normalized bodies (timestamps/group prefixes stripped) without waiting for the agent.
|
||||
- Directive/system acks carry a `⚙️` prefix and verbose parsing rejects typoed `/ver*` strings so unrelated text doesn’t flip verbosity.
|
||||
- Batched history blocks no longer trip directive parsing; `/think` in prior messages won't emit stray acknowledgements.
|
||||
- RPC fallbacks no longer echo the user's prompt (e.g., pasting a link) when the agent returns no assistant text.
|
||||
- Heartbeat prompts with `/think` no longer send directive acks; heartbeat replies stay silent on settings.
|
||||
- `clawdis sessions` now renders a colored table (a la oracle) with context usage shown in k tokens and percent of the context window.
|
||||
|
||||
## 1.4.1 — 2025-12-04
|
||||
|
||||
### Changes
|
||||
- Added `clawdis agent` CLI command to talk directly to the configured agent using existing session handling (no WhatsApp send), with JSON output and delivery option.
|
||||
- `/new` reset trigger now works even when inbound messages have timestamp prefixes (e.g., `[Dec 4 17:35]`).
|
||||
- WhatsApp mention parsing accepts nullable arrays and flattens safely to avoid missed mentions.
|
||||
|
||||
## 1.4.0 — 2025-12-03
|
||||
|
||||
### Highlights
|
||||
- Talk Mode (continuous voice) with ElevenLabs TTS on macOS/iOS/Android.
|
||||
- Discord: expanded tool actions, richer routing, and threaded reply tags.
|
||||
- Auto-reply queue modes + session model overrides; TUI upgrades.
|
||||
- Nix mode (declarative config) and Docker setup flow.
|
||||
- Onboarding wizard + configure/doctor/update flows.
|
||||
- Signal + iMessage providers; new skills (Trello, Things, Notes/Reminders, tmux coding).
|
||||
- Browser tooling upgrades (remote CDP, no-sandbox, profiles).
|
||||
- **Thinking directives & state:** `/t|/think|/thinking <level>` (aliases off|minimal|low|medium|high|max/highest). Inline applies to that message; directive-only message pins the level for the session; `/think:off` clears. Resolution: inline > session override > `inbound.reply.thinkingDefault` > off. Pi gets `--thinking <level>` (except off); other agents append cue words (`think` → `think hard` → `think harder` → `ultrathink`). Heartbeat probe uses `HEARTBEAT /think:high`.
|
||||
- **Group chats (web provider):** Clawdis now fully supports WhatsApp groups: mention-gated triggers (including image-only @ mentions), recent group history injection, per-group sessions, sender attribution, and a first-turn primer with group subject/member roster; heartbeats are skipped for groups.
|
||||
- **Group session primer:** The first turn of a group session now tells the agent it is in a WhatsApp group and lists known members/subject so it can address the right speaker.
|
||||
- **Media failures are surfaced:** When a web auto-reply media fetch/send fails (e.g., HTTP 404), we now append a warning to the fallback text so you know the attachment was skipped.
|
||||
- **Verbose directives + session hints:** `/v|/verbose on|full|off` mirrors thinking: inline > session > config default. Directive-only replies with an acknowledgement; invalid levels return a hint. When enabled, tool results from JSON-emitting agents (Pi, etc.) are forwarded as metadata-only `[🛠️ <tool-name> <arg>]` messages (now streamed as they happen), and new sessions surface a `🧭 New session: <id>` hint.
|
||||
- **Verbose tool coalescing:** successive tool results of the same tool within ~1s are batched into one `[🛠️ tool] arg1, arg2` message to reduce WhatsApp noise.
|
||||
- **Directive confirmations:** Directive-only messages now reply with an acknowledgement (`Thinking level set to high.` / `Thinking disabled.`) and reject unknown levels with a helpful hint (state is unchanged).
|
||||
- **Pi stability:** RPC replies buffered until the assistant turn finishes; parsers return consistent `texts[]`; web auto-replies keep a warm Pi RPC process to avoid cold starts.
|
||||
- **Claude prompt flow:** One-time `sessionIntro` with per-message `/think:high` bodyPrefix; system prompt always sent on first turn even with `sendSystemOnce`.
|
||||
- **Heartbeat UX:** Backpressure skips reply heartbeats while other commands run; skips don’t refresh session `updatedAt`; web heartbeats normalize array payloads and optional `heartbeatCommand`.
|
||||
- **Control via WhatsApp:** Send `/restart` to restart the launchd service (`com.steipete.clawdis`) from your allowed numbers.
|
||||
- **Pi completion signal:** RPC now resolves on Pi’s `agent_end` (or process exit) so late assistant messages aren’t truncated; 5-minute hard cap only as a failsafe.
|
||||
|
||||
### Fixes
|
||||
- macOS codesign/TCC hardening and menu/UI stability improvements.
|
||||
- Streaming/typing fixes; per-provider chunk limit tuning.
|
||||
- Remote gateway auth + token handling tightened.
|
||||
- Camera capture reliability and media sizing fixes.
|
||||
### Reliability & UX
|
||||
- Outbound chunking prefers newlines/word boundaries and enforces caps (~4000 chars for web/WhatsApp).
|
||||
- Web auto-replies fall back to caption-only if media send fails; hosted media MIME-sniffed and cleaned up immediately.
|
||||
- IPC gateway send shows typing indicator; batched inbound messages keep timestamps; watchdog restarts WhatsApp after long inactivity.
|
||||
- Early `allowFrom` filtering prevents decryption errors; same-phone mode supported with echo suppression.
|
||||
- All console output is now mirrored into pino logs (still printed to stdout/stderr), so verbose runs keep full traces.
|
||||
- `--verbose` now forces log level `trace` (was `debug`) to capture every event.
|
||||
- Verbose tool messages now include emoji + args + a short result preview for bash/read/edit/write/attach (derived from RPC tool start/end events).
|
||||
|
||||
## 2025.12.27 (betas 3–4)
|
||||
### Security / Hardening
|
||||
- IPC socket hardened (0700 dir / 0600 socket, no symlinks/foreign owners); `clawdis logout` also prunes session store.
|
||||
- Media server blocks symlinks and enforces path containment; logging rotates daily and prunes >24h.
|
||||
|
||||
### Bug Fixes
|
||||
- Web group chats now bypass the second `allowFrom` check (we still enforce it on the group participant at inbox ingest), so mentioned group messages reply even when the group JID isn’t in your allowlist.
|
||||
- `logVerbose` also writes to the configured Pino logger at debug level (without breaking stdout).
|
||||
- Group auto-replies now append the triggering sender (`[from: Name (+E164)]`) to the batch body so agents can address the right person in group chats.
|
||||
- Media-only pings now pick up mentions inside captions (image/video/etc.), so @-mentions on media-only messages trigger replies.
|
||||
- MIME sniffing and redirect handling for downloads/hosted media.
|
||||
- Response prefix applied to heartbeat alerts; heartbeat array payloads handled for both providers.
|
||||
- Pi RPC typing exposes `signal`/`killed`; NDJSON parsers normalized across agents.
|
||||
- Pi session resumes now append `--continue`, so existing history/think level are reloaded instead of starting empty.
|
||||
|
||||
### Testing
|
||||
- Fixtures isolate session stores; added coverage for thinking directives, stateful levels, heartbeat backpressure, and agent parsing.
|
||||
|
||||
## 1.3.0 — 2025-12-02
|
||||
|
||||
### Highlights
|
||||
- First-class tools replace `clawdbot-*` skills (browser, canvas, nodes, cron).
|
||||
- Per-session model selection and custom model providers.
|
||||
- Group activation commands; Discord provider for DMs/guilds.
|
||||
- Gateway webhooks + Gmail Pub/Sub hooks.
|
||||
- Command queue modes + `agent.maxConcurrent` cap.
|
||||
- Background bash tasks with `process` tool; gateway in-process restart.
|
||||
- **Pluggable agents (Claude, Pi, Codex, Opencode):** `inbound.reply.agent` selects CLI/parser; per-agent argv builders and NDJSON parsers enable swapping without template changes.
|
||||
- **Safety stop words:** `stop|esc|abort|wait|exit` immediately reply “Agent was aborted.” and mark the session so the next prompt is prefixed with an abort reminder.
|
||||
- **Agent session reliability:** Only Claude returns a stable `session_id`; others may reset between runs.
|
||||
|
||||
### Fixes
|
||||
- Packaging fixes, heartbeat cleanup, WhatsApp reconnect reliability.
|
||||
- macOS menu/Chat UI polish and presence reporting fixes.
|
||||
### Bug Fixes
|
||||
- Empty `result` fields no longer leak raw JSON to users.
|
||||
- Heartbeat alerts now honor `responsePrefix`.
|
||||
- Command failures return user-friendly messages.
|
||||
- Test session isolation to avoid touching real `sessions.json`.
|
||||
- (Removed in 2.0.0) IPC reuse for `clawdis send/heartbeat` prevents Signal/WhatsApp session corruption.
|
||||
- Web send respects media kind (image/audio/video/document) with correct limits.
|
||||
|
||||
## 2025.12.21 (beta 2)
|
||||
### Changes
|
||||
- (Removed in 2.0.0) IPC gateway socket at `~/.clawdis/ipc/gateway.sock` with automatic CLI fallback.
|
||||
- Batched inbound messages with timestamps; typing indicator after sends.
|
||||
- Watchdog restarts WhatsApp after long inactivity; heartbeat logging includes minutes since last message.
|
||||
- Early `allowFrom` filtering before decryption.
|
||||
- Same-phone mode with echo detection and optional `inbound.samePhoneMarker`.
|
||||
|
||||
### Highlights
|
||||
- Bundled gateway packaging + DMG distribution pipeline.
|
||||
- Skills platform (bundled/managed/workspace) with install gating + UI.
|
||||
- Onboarding polish and agent UX improvements.
|
||||
- Canvas host served from Gateway; browser control simplification.
|
||||
## 1.2.2 — 2025-11-28
|
||||
|
||||
## 2025.12.19 (beta 1)
|
||||
### Changes
|
||||
- Manual heartbeat sends: `clawdis heartbeat --message/--body` (web provider only); `--dry-run` previews payloads.
|
||||
|
||||
### Highlights
|
||||
- First Clawdbot release: Gateway WS control plane + optional Bridge.
|
||||
- macOS menu bar companion app with Voice Wake + WebChat.
|
||||
- iOS node pairing with Canvas surface.
|
||||
- WhatsApp groups, thinking/verbose directives, health/status tooling.
|
||||
## 1.2.1 — 2025-11-28
|
||||
|
||||
### Breaking
|
||||
- Switched to Pi-only agent runtime; legacy providers removed.
|
||||
- Gateway became the single source of truth (no ad-hoc direct sends).
|
||||
### Changes
|
||||
- Media MIME-first handling; hosted media extensions derived from detected MIME with tests.
|
||||
|
||||
## 2025.12.05–2025.12.03 (pre-Clawdbot)
|
||||
### Planned / in progress (from prior notes)
|
||||
- Heartbeat targeting quality: clearer recipient resolution and verbose logs.
|
||||
- Heartbeat delivery preview (Claude path) dry-run.
|
||||
- Simulated inbound hook for local testing.
|
||||
|
||||
### Highlights
|
||||
- Pi-only agent path and web-only gateway workflow.
|
||||
- Thinking/verbose directives, group chat support, and heartbeat controls.
|
||||
- `clawdbot agent` CLI added; session tables and health reporting.
|
||||
## 1.2.0 — 2025-11-27
|
||||
|
||||
## 2025.11.28–2025.11.25 (early web-only)
|
||||
### Changes
|
||||
- Heartbeat interval default 10m for command mode; prompt `HEARTBEAT /think:high`; skips don’t refresh session; session `heartbeatIdleMinutes` support.
|
||||
- Heartbeat tooling: `--session-id`, `--heartbeat-now` (inline flag on `gateway`) for immediate startup probes.
|
||||
- Prompt structure: `sessionIntro` plus per-message `/think:high`; session idle up to 7 days.
|
||||
- Thinking directives: `/think:<level>`; Pi uses `--thinking`; others append cue; `/think:off` no-op.
|
||||
- Robustness: Baileys/WebSocket guards; global unhandled error handlers; WhatsApp LID mapping; hosted media MIME-sniffing and cleanup.
|
||||
- Docs: README Clawd setup; `docs/claude-config.md` for live config.
|
||||
|
||||
- Heartbeat CLI + interval handling.
|
||||
- Media MIME sniffing, size caps, and timeout fallbacks.
|
||||
- Web provider reconnects and early stability fixes.
|
||||
## 1.1.0 — 2025-11-26
|
||||
|
||||
### Changes
|
||||
- Web auto-replies resize/recompress media and honor `inbound.reply.mediaMaxMb`.
|
||||
- Detect media kind, enforce provider caps (images ≤6MB, audio/video ≤16MB, docs ≤100MB).
|
||||
- `session.sendSystemOnce` and optional `sessionIntro`.
|
||||
- Typing indicator refresh during commands; configurable via `inbound.reply.typingIntervalSeconds`.
|
||||
- Optional audio transcription via external CLI.
|
||||
- Command replies return structured payload/meta; respect `mediaMaxMb`; log Claude metadata; include `cwd` in timeout messages.
|
||||
- Web provider refactor; logout command; web-only gateway start helper.
|
||||
- Structured reconnect/heartbeat logging; bounded backoff with CLI/config knobs; troubleshooting guide.
|
||||
- Relay help prints effective heartbeat/backoff when in web mode.
|
||||
|
||||
## 1.0.4 — 2025-11-25
|
||||
|
||||
### Changes
|
||||
- Timeout fallbacks send partial stdout (≤800 chars) to the user instead of silence; tests added.
|
||||
- Web gateway auto-reconnects after Baileys/WebSocket drops; close propagation tests.
|
||||
|
||||
## 0.1.3 — 2025-11-25
|
||||
|
||||
### Changes
|
||||
- Auto-replies send a WhatsApp fallback message on command/Claude timeout with truncated stdout.
|
||||
- Added tests for timeout fallback and partial-output truncation.
|
||||
@@ -1,42 +0,0 @@
|
||||
# Contributing to Clawdbot
|
||||
|
||||
Welcome to the lobster tank! 🦞
|
||||
|
||||
## Quick Links
|
||||
- **GitHub:** https://github.com/clawdbot/clawdbot
|
||||
- **Discord:** https://discord.gg/qkhbAGHRBT
|
||||
- **X/Twitter:** [@steipete](https://x.com/steipete) / [@clawdbot](https://x.com/clawdbot)
|
||||
|
||||
## Maintainers
|
||||
|
||||
- **Peter Steinberger** - Benevolent Dictator
|
||||
- GitHub: [@steipete](https://github.com/steipete) · X: [@steipete](https://x.com/steipete)
|
||||
|
||||
- **Shadow** - Discord + Slack subsystem
|
||||
- GitHub: [@4shadowed](https://github.com/4shadowed) · X: [@4shad0wed](https://x.com/4shad0wed)
|
||||
|
||||
- **Jos** - Telegram, API, Nix mode
|
||||
- GitHub: [@joshp123](https://github.com/joshp123) · X: [@jjpcodes](https://x.com/jjpcodes)
|
||||
|
||||
## How to Contribute
|
||||
1. **Bugs & small fixes** → Open a PR!
|
||||
2. **New features / architecture** → Start a [GitHub Discussion](https://github.com/clawdbot/clawdbot/discussions) or ask in Discord first
|
||||
3. **Questions** → Discord #setup-help
|
||||
|
||||
## Before You PR
|
||||
- Test locally with your Clawdbot instance
|
||||
- Run linter: `npm run lint`
|
||||
- Keep PRs focused (one thing per PR)
|
||||
- Describe what & why
|
||||
|
||||
## 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.
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
FROM node:22-bookworm
|
||||
|
||||
# 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
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN pnpm build
|
||||
RUN pnpm ui:install
|
||||
RUN pnpm ui:build
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
CMD ["node", "dist/index.js"]
|
||||
@@ -1,16 +0,0 @@
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
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/*
|
||||
|
||||
CMD ["sleep", "infinity"]
|
||||
@@ -1,27 +0,0 @@
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
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 \
|
||||
websockify \
|
||||
x11vnc \
|
||||
xvfb \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY scripts/sandbox-browser-entrypoint.sh /usr/local/bin/clawdbot-sandbox-browser
|
||||
RUN chmod +x /usr/local/bin/clawdbot-sandbox-browser
|
||||
|
||||
EXPOSE 9222 5900 6080
|
||||
|
||||
CMD ["clawdbot-sandbox-browser"]
|
||||
+1
-1
Submodule Peekaboo updated: c1243a7978...be03d6e3df.
@@ -1,7 +1,7 @@
|
||||
# 🦞 CLAWDBOT — Personal AI Assistant
|
||||
# 🦞 CLAWDIS — Personal AI Assistant
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/clawdbot/clawdbot/main/docs/whatsapp-clawd.jpg" alt="CLAWDBOT" width="400">
|
||||
<img src="docs/whatsapp-clawd.jpg" alt="CLAWDIS" width="400">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -9,292 +9,143 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/clawdbot/clawdbot/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/clawdbot/clawdbot/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
|
||||
<a href="https://github.com/clawdbot/clawdbot/releases"><img src="https://img.shields.io/github/v/release/clawdbot/clawdbot?include_prereleases&style=for-the-badge" alt="GitHub release"></a>
|
||||
<a href="https://discord.gg/clawd"><img src="https://img.shields.io/discord/1456350064065904867?label=Discord&logo=discord&logoColor=white&color=5865F2&style=for-the-badge" alt="Discord"></a>
|
||||
<a href="https://github.com/steipete/clawdis/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/steipete/clawdis/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
|
||||
<a href="https://github.com/steipete/clawdis/releases"><img src="https://img.shields.io/github/v/release/steipete/clawdis?include_prereleases&style=for-the-badge" alt="GitHub release"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
|
||||
</p>
|
||||
|
||||
**Clawdbot** is a *personal AI assistant* you run on your own devices.
|
||||
It answers you on the surfaces you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat), can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||
**Clawdis** is a *personal AI assistant* you run on your own devices.
|
||||
It answers you on the surfaces you already use (WhatsApp, Telegram, WebChat), can speak and listen on macOS/iOS, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||
|
||||
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
|
||||
|
||||
[Website](https://clawdbot.com) · [Docs](https://github.com/clawdbot/clawdbot/blob/main/docs/index.md) · Showcase: [https://github.com/clawdbot/clawdbot/blob/main/docs/showcase.md](https://github.com/clawdbot/clawdbot/blob/main/docs/showcase.md) · FAQ: [https://github.com/clawdbot/clawdbot/blob/main/docs/faq.md](https://github.com/clawdbot/clawdbot/blob/main/docs/faq.md) · Wizard: [https://github.com/clawdbot/clawdbot/blob/main/docs/wizard.md](https://github.com/clawdbot/clawdbot/blob/main/docs/wizard.md) · Nix: [https://github.com/clawdbot/nix-clawdbot](https://github.com/clawdbot/nix-clawdbot) · Docker: [https://github.com/clawdbot/clawdbot/blob/main/docs/docker.md](https://github.com/clawdbot/clawdbot/blob/main/docs/docker.md) · Discord: [https://discord.gg/clawd](https://discord.gg/clawd)
|
||||
|
||||
Preferred setup: run the onboarding wizard (`clawdbot onboard`). It walks through gateway, workspace, providers, and skills. The CLI wizard is the recommended path and works on **macOS, Windows, and Linux**.
|
||||
Works with npm, pnpm, or bun.
|
||||
|
||||
**Subscriptions (OAuth):**
|
||||
- **Anthropic** (Claude Pro/Max)
|
||||
- **OpenAI** (ChatGPT/Codex)
|
||||
|
||||
Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.5** for long‑context strength and better prompt‑injection resistance. See [Onboarding](https://github.com/clawdbot/clawdbot/blob/main/docs/onboarding.md).
|
||||
|
||||
## Recommended setup (from source)
|
||||
|
||||
Do **not** download prebuilt binaries. Build from source.
|
||||
|
||||
```bash
|
||||
# Clone this repo
|
||||
git clone https://github.com/clawdbot/clawdbot.git
|
||||
cd clawdbot
|
||||
|
||||
bun install
|
||||
bun run build
|
||||
bun run ui:build
|
||||
bun run clawdbot onboard
|
||||
```
|
||||
|
||||
## Quick start (from source)
|
||||
|
||||
Runtime: **Node ≥22**.
|
||||
|
||||
From source, **pnpm** is the default workflow. Bun is supported as an optional local workflow; see [`docs/bun.md`](docs/bun.md).
|
||||
|
||||
```bash
|
||||
# Install deps (no Bun lockfile)
|
||||
bun install --no-save
|
||||
|
||||
# Build TypeScript
|
||||
bun run build
|
||||
|
||||
# Build Control UI
|
||||
bun install --cwd ui --no-save
|
||||
bun run --cwd ui build
|
||||
|
||||
# Recommended: run the onboarding wizard
|
||||
bun run clawdbot onboard
|
||||
|
||||
# Link WhatsApp (stores creds in ~/.clawdbot/credentials)
|
||||
bun run clawdbot login
|
||||
|
||||
# Start the gateway
|
||||
bun run clawdbot gateway --port 18789 --verbose
|
||||
|
||||
# Dev loop (auto-reload on TS changes)
|
||||
bun run gateway:watch
|
||||
|
||||
# Send a message
|
||||
bun run clawdbot send --to +1234567890 --message "Hello from Clawdbot"
|
||||
|
||||
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord)
|
||||
bun run clawdbot agent --message "Ship checklist" --thinking high
|
||||
```
|
||||
|
||||
Upgrading? `clawdbot doctor`.
|
||||
|
||||
If you run from source, prefer `bun run clawdbot …` or `pnpm clawdbot …` (not global `clawdbot`).
|
||||
|
||||
## Highlights
|
||||
|
||||
- **[Local-first Gateway](https://github.com/clawdbot/clawdbot/blob/main/docs/gateway.md)** — single control plane for sessions, providers, tools, and events.
|
||||
- **[Multi-surface inbox](https://github.com/clawdbot/clawdbot/blob/main/docs/surface.md)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat, macOS, iOS/Android.
|
||||
- **[Voice Wake](https://github.com/clawdbot/clawdbot/blob/main/docs/voicewake.md) + [Talk Mode](https://github.com/clawdbot/clawdbot/blob/main/docs/talk.md)** — always-on speech for macOS/iOS/Android with ElevenLabs.
|
||||
- **[Live Canvas](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/canvas.md)** — agent-driven visual workspace with [A2UI](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/canvas.md#canvas-a2ui).
|
||||
- **[First-class tools](https://github.com/clawdbot/clawdbot/blob/main/docs/tools.md)** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions.
|
||||
- **[Companion apps](https://github.com/clawdbot/clawdbot/blob/main/docs/macos.md)** — macOS menu bar app + iOS/Android [nodes](https://github.com/clawdbot/clawdbot/blob/main/docs/nodes.md).
|
||||
- **[Onboarding](https://github.com/clawdbot/clawdbot/blob/main/docs/wizard.md) + [skills](https://github.com/clawdbot/clawdbot/blob/main/docs/skills.md)** — wizard-driven setup with bundled/managed/workspace skills.
|
||||
|
||||
## Everything we built so far
|
||||
|
||||
### Core platform
|
||||
- [Gateway WS control plane](https://github.com/clawdbot/clawdbot/blob/main/docs/gateway.md) with sessions, presence, config, cron, webhooks, [Control UI](https://github.com/clawdbot/clawdbot/blob/main/docs/web.md), and [Canvas host](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/canvas.md#canvas-a2ui).
|
||||
- [CLI surface](https://github.com/clawdbot/clawdbot/blob/main/docs/agent-send.md): gateway, agent, send, [wizard](https://github.com/clawdbot/clawdbot/blob/main/docs/wizard.md), and [doctor](https://github.com/clawdbot/clawdbot/blob/main/docs/doctor.md).
|
||||
- [Pi agent runtime](https://github.com/clawdbot/clawdbot/blob/main/docs/agent.md) in RPC mode with tool streaming and block streaming.
|
||||
- [Session model](https://github.com/clawdbot/clawdbot/blob/main/docs/session.md): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://github.com/clawdbot/clawdbot/blob/main/docs/groups.md).
|
||||
- [Media pipeline](https://github.com/clawdbot/clawdbot/blob/main/docs/images.md): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://github.com/clawdbot/clawdbot/blob/main/docs/audio.md).
|
||||
|
||||
### Surfaces + providers
|
||||
- [Providers](https://github.com/clawdbot/clawdbot/blob/main/docs/surface.md): [WhatsApp](https://github.com/clawdbot/clawdbot/blob/main/docs/whatsapp.md) (Baileys), [Telegram](https://github.com/clawdbot/clawdbot/blob/main/docs/telegram.md) (grammY), [Slack](https://github.com/clawdbot/clawdbot/blob/main/docs/slack.md) (Bolt), [Discord](https://github.com/clawdbot/clawdbot/blob/main/docs/discord.md) (discord.js), [Signal](https://github.com/clawdbot/clawdbot/blob/main/docs/signal.md) (signal-cli), [iMessage](https://github.com/clawdbot/clawdbot/blob/main/docs/imessage.md) (imsg), [WebChat](https://github.com/clawdbot/clawdbot/blob/main/docs/webchat.md).
|
||||
- [Group routing](https://github.com/clawdbot/clawdbot/blob/main/docs/group-messages.md): mention gating, reply tags, per-surface chunking and routing. Surface rules: [Surface routing](https://github.com/clawdbot/clawdbot/blob/main/docs/surface.md).
|
||||
|
||||
### Apps + nodes
|
||||
- [macOS app](https://github.com/clawdbot/clawdbot/blob/main/docs/macos.md): menu bar control plane, [Voice Wake](https://github.com/clawdbot/clawdbot/blob/main/docs/voicewake.md)/PTT, [Talk Mode](https://github.com/clawdbot/clawdbot/blob/main/docs/talk.md) overlay, [WebChat](https://github.com/clawdbot/clawdbot/blob/main/docs/webchat.md), debug tools, [remote gateway](https://github.com/clawdbot/clawdbot/blob/main/docs/remote.md) control.
|
||||
- [iOS node](https://github.com/clawdbot/clawdbot/blob/main/docs/ios.md): [Canvas](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/canvas.md), [Voice Wake](https://github.com/clawdbot/clawdbot/blob/main/docs/voicewake.md), [Talk Mode](https://github.com/clawdbot/clawdbot/blob/main/docs/talk.md), camera, screen recording, Bonjour pairing.
|
||||
- [Android node](https://github.com/clawdbot/clawdbot/blob/main/docs/android.md): [Canvas](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/canvas.md), [Talk Mode](https://github.com/clawdbot/clawdbot/blob/main/docs/talk.md), camera, screen recording, optional SMS.
|
||||
- [macOS node mode](https://github.com/clawdbot/clawdbot/blob/main/docs/nodes.md): system.run/notify + canvas/camera exposure.
|
||||
|
||||
### Tools + automation
|
||||
- [Browser control](https://github.com/clawdbot/clawdbot/blob/main/docs/browser.md): dedicated clawd Chrome/Chromium, snapshots, actions, uploads, profiles.
|
||||
- [Canvas](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/canvas.md): [A2UI](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/canvas.md#canvas-a2ui) push/reset, eval, snapshot.
|
||||
- [Nodes](https://github.com/clawdbot/clawdbot/blob/main/docs/nodes.md): camera snap/clip, screen record, [location.get](https://github.com/clawdbot/clawdbot/blob/main/docs/location-command.md), notifications.
|
||||
- [Cron + wakeups](https://github.com/clawdbot/clawdbot/blob/main/docs/cron.md); [webhooks](https://github.com/clawdbot/clawdbot/blob/main/docs/webhook.md); [Gmail Pub/Sub](https://github.com/clawdbot/clawdbot/blob/main/docs/gmail-pubsub.md).
|
||||
- [Skills platform](https://github.com/clawdbot/clawdbot/blob/main/docs/skills.md): bundled, managed, and workspace skills with install gating + UI.
|
||||
|
||||
### Ops + packaging
|
||||
- [Control UI](https://github.com/clawdbot/clawdbot/blob/main/docs/web.md) + [WebChat](https://github.com/clawdbot/clawdbot/blob/main/docs/webchat.md) served directly from the Gateway.
|
||||
- [Tailscale Serve/Funnel](https://github.com/clawdbot/clawdbot/blob/main/docs/tailscale.md) or [SSH tunnels](https://github.com/clawdbot/clawdbot/blob/main/docs/remote.md) with token/password auth.
|
||||
- [Nix mode](https://github.com/clawdbot/clawdbot/blob/main/docs/nix.md) for declarative config; [Docker](https://github.com/clawdbot/clawdbot/blob/main/docs/docker.md)-based installs.
|
||||
- [Doctor](https://github.com/clawdbot/clawdbot/blob/main/docs/doctor.md) migrations, [logging](https://github.com/clawdbot/clawdbot/blob/main/docs/logging.md).
|
||||
|
||||
## How it works (short)
|
||||
If you want a private, single-user assistant that feels local, fast, and always-on, this is it.
|
||||
|
||||
```
|
||||
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / WebChat
|
||||
│
|
||||
▼
|
||||
Your surfaces
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────┐
|
||||
│ Gateway │ ws://127.0.0.1:18789
|
||||
│ (control plane) │ bridge: tcp://0.0.0.0:18790
|
||||
│ (control plane) │ tcp://0.0.0.0:18790 (optional Bridge)
|
||||
└──────────────┬────────────────┘
|
||||
│
|
||||
├─ Pi agent (RPC)
|
||||
├─ CLI (clawdbot …)
|
||||
├─ WebChat UI
|
||||
├─ macOS app
|
||||
└─ iOS/Android nodes
|
||||
├─ CLI (clawdis …)
|
||||
├─ WebChat (browser)
|
||||
├─ macOS app (Clawdis.app)
|
||||
└─ iOS node (Canvas + voice)
|
||||
```
|
||||
|
||||
## Key subsystems
|
||||
## What Clawdis does
|
||||
|
||||
- **[Gateway WebSocket network](https://github.com/clawdbot/clawdbot/blob/main/docs/architecture.md)** — single WS control plane for clients, tools, and events (plus ops: [Gateway runbook](https://github.com/clawdbot/clawdbot/blob/main/docs/gateway.md)).
|
||||
- **[Tailscale exposure](https://github.com/clawdbot/clawdbot/blob/main/docs/tailscale.md)** — Serve/Funnel for the Gateway dashboard + WS (remote access: [Remote](https://github.com/clawdbot/clawdbot/blob/main/docs/remote.md)).
|
||||
- **[Browser control](https://github.com/clawdbot/clawdbot/blob/main/docs/browser.md)** — clawd‑managed Chrome/Chromium with CDP control.
|
||||
- **[Canvas + A2UI](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/canvas.md)** — agent‑driven visual workspace (A2UI host: [Canvas/A2UI](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/canvas.md#canvas-a2ui)).
|
||||
- **[Voice Wake](https://github.com/clawdbot/clawdbot/blob/main/docs/voicewake.md) + [Talk Mode](https://github.com/clawdbot/clawdbot/blob/main/docs/talk.md)** — always‑on speech and continuous conversation.
|
||||
- **[Nodes](https://github.com/clawdbot/clawdbot/blob/main/docs/nodes.md)** — Canvas, camera snap/clip, screen record, `location.get`, notifications, plus macOS‑only `system.run`/`system.notify`.
|
||||
- **Personal assistant** — one user, one identity, one memory surface.
|
||||
- **Multi-surface inbox** — WhatsApp, Telegram, WebChat, macOS, iOS.
|
||||
- **Voice wake + push-to-talk** — local speech recognition on macOS/iOS.
|
||||
- **Canvas** — a live visual workspace you can drive from the agent.
|
||||
- **Automation-ready** — browser control, media handling, and tool streaming.
|
||||
- **Local-first control plane** — the Gateway owns state, everything else connects.
|
||||
|
||||
## Tailscale access (Gateway dashboard)
|
||||
## How it works (short)
|
||||
|
||||
Clawdbot can auto-configure Tailscale **Serve** (tailnet-only) or **Funnel** (public) while the Gateway stays bound to loopback. Configure `gateway.tailscale.mode`:
|
||||
- **Gateway** is the single source of truth for sessions/providers.
|
||||
- **Loopback-first**: `ws://127.0.0.1:18789` by default.
|
||||
- **Bridge** (optional) exposes a paired-node port for iOS/Android.
|
||||
- **Agent runtime** is **Pi** in RPC mode.
|
||||
|
||||
- `off`: no Tailscale automation (default).
|
||||
- `serve`: tailnet-only HTTPS via `tailscale serve` (uses Tailscale identity headers by default).
|
||||
- `funnel`: public HTTPS via `tailscale funnel` (requires shared password auth).
|
||||
## Quick start (from source)
|
||||
|
||||
Notes:
|
||||
- `gateway.bind` must stay `loopback` when Serve/Funnel is enabled (Clawdbot enforces this).
|
||||
- Serve can be forced to require a password by setting `gateway.auth.mode: "password"` or `gateway.auth.allowTailscale: false`.
|
||||
- Funnel refuses to start unless `gateway.auth.mode: "password"` is set.
|
||||
- Optional: `gateway.tailscale.resetOnExit` to undo Serve/Funnel on shutdown.
|
||||
|
||||
Details: [Tailscale guide](https://github.com/clawdbot/clawdbot/blob/main/docs/tailscale.md) · [Web surfaces](https://github.com/clawdbot/clawdbot/blob/main/docs/web.md)
|
||||
|
||||
## Remote Gateway (Linux is great)
|
||||
|
||||
It’s perfectly fine to run the Gateway on a small Linux instance. Clients (macOS app, CLI, WebChat) can connect over **Tailscale Serve/Funnel** or **SSH tunnels**, and you can still pair device nodes (macOS/iOS/Android) to execute device‑local actions when needed.
|
||||
|
||||
- **Gateway host** runs the bash tool and provider connections by default.
|
||||
- **Device nodes** run device‑local actions (`system.run`, camera, screen recording, notifications) via `node.invoke`.
|
||||
In short: bash runs where the Gateway lives; device actions run where the device lives.
|
||||
|
||||
Details: [Remote access](https://github.com/clawdbot/clawdbot/blob/main/docs/remote.md) · [Nodes](https://github.com/clawdbot/clawdbot/blob/main/docs/nodes.md) · [Security](https://github.com/clawdbot/clawdbot/blob/main/docs/security.md)
|
||||
|
||||
## macOS permissions via the Gateway protocol
|
||||
|
||||
The macOS app can run in **node mode** and advertises its capabilities + permission map over the Gateway WebSocket (`node.list` / `node.describe`). Clients can then execute local actions via `node.invoke`:
|
||||
|
||||
- `system.run` runs a local command and returns stdout/stderr/exit code; set `needsScreenRecording: true` to require screen-recording permission (otherwise you’ll get `PERMISSION_MISSING`).
|
||||
- `system.notify` posts a user notification and fails if notifications are denied.
|
||||
- `canvas.*`, `camera.*`, `screen.record`, and `location.get` are also routed via `node.invoke` and follow TCC permission status.
|
||||
|
||||
Elevated bash (host permissions) is separate from macOS TCC:
|
||||
|
||||
- Use `/elevated on|off` to toggle per‑session elevated access when enabled + allowlisted.
|
||||
- Gateway persists the per‑session toggle via `sessions.patch` (WS method) alongside `thinkingLevel`, `verboseLevel`, `model`, `sendPolicy`, and `groupActivation`.
|
||||
|
||||
Details: [Nodes](https://github.com/clawdbot/clawdbot/blob/main/docs/nodes.md) · [macOS app](https://github.com/clawdbot/clawdbot/blob/main/docs/macos.md) · [Gateway protocol](https://github.com/clawdbot/clawdbot/blob/main/docs/architecture.md)
|
||||
|
||||
## Agent to Agent (sessions_* tools)
|
||||
|
||||
- Use these to coordinate work across sessions without jumping between chat surfaces.
|
||||
- `sessions_list` — discover active sessions (agents) and their metadata.
|
||||
- `sessions_history` — fetch transcript logs for a session.
|
||||
- `sessions_send` — message another session; optional reply‑back ping‑pong + announce step (`REPLY_SKIP`, `ANNOUNCE_SKIP`).
|
||||
|
||||
Details: [Session tools](https://github.com/clawdbot/clawdbot/blob/main/docs/session-tool.md)
|
||||
|
||||
## Skills registry (ClawdHub)
|
||||
|
||||
ClawdHub is a minimal skill registry. With ClawdHub enabled, the agent can search for skills automatically and pull in new ones as needed.
|
||||
|
||||
https://ClawdHub.com
|
||||
|
||||
## Chat commands
|
||||
|
||||
Send these in WhatsApp/Telegram/Slack/WebChat (group commands are owner-only):
|
||||
|
||||
- `/status` — health + session info (group shows activation mode)
|
||||
- `/new` or `/reset` — reset the session
|
||||
- `/compact` — compact session context (summary)
|
||||
- `/think <level>` — off|minimal|low|medium|high
|
||||
- `/verbose on|off`
|
||||
- `/restart` — restart the gateway (owner-only in groups)
|
||||
- `/activation mention|always` — group activation toggle (groups only)
|
||||
|
||||
## macOS app (optional)
|
||||
|
||||
The Gateway alone delivers a great experience. All apps are optional and add extra features.
|
||||
|
||||
If you plan to build/run companion apps, initialize submodules first:
|
||||
Runtime: **Node ≥22** + **pnpm**.
|
||||
|
||||
```bash
|
||||
git submodule update --init --recursive
|
||||
./scripts/restart-mac.sh
|
||||
pnpm install
|
||||
pnpm build
|
||||
pnpm ui:build
|
||||
|
||||
# Link WhatsApp (stores creds in ~/.clawdis/credentials)
|
||||
pnpm clawdis login
|
||||
|
||||
# Start the gateway
|
||||
pnpm clawdis gateway --port 18789 --verbose
|
||||
|
||||
# Send a message
|
||||
pnpm clawdis send --to +1234567890 --message "Hello from Clawdis"
|
||||
|
||||
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram)
|
||||
pnpm clawdis agent --message "Ship checklist" --thinking high
|
||||
```
|
||||
|
||||
### macOS (Clawdbot.app) (optional)
|
||||
If you run from source, prefer `pnpm clawdis …` (not global `clawdis`).
|
||||
|
||||
## Architecture
|
||||
|
||||
### TypeScript Gateway (src/gateway/server.ts)
|
||||
- **Single HTTP+WS server** on `ws://127.0.0.1:18789` (bind policy: loopback/lan/tailnet/auto). The first frame must be `connect`; AJV validates frames against TypeBox schemas (`src/gateway/protocol`).
|
||||
- **Single source of truth** for sessions, providers, cron, voice wake, and presence. Methods cover `send`, `agent`, `chat.*`, `sessions.*`, `config.*`, `cron.*`, `voicewake.*`, `node.*`, `system-*`, `wake`.
|
||||
- **Events + snapshot**: handshake returns a snapshot (presence/health) and declares event types; runtime events include `agent`, `chat`, `presence`, `tick`, `health`, `heartbeat`, `cron`, `node.pair.*`, `voicewake.changed`, `shutdown`.
|
||||
- **Idempotency & safety**: `send`/`agent`/`chat.send` require idempotency keys with a TTL cache (5 min, cap 1000) to avoid double‑sends on reconnects; payload sizes are capped per connection.
|
||||
- **Bridge for nodes**: optional TCP bridge (`src/infra/bridge/server.ts`) is newline‑delimited JSON frames (`hello`, pairing, RPC, `invoke`); node connect/disconnect is surfaced into presence.
|
||||
- **Control UI + Canvas Host**: HTTP serves `/ui` assets (if built) and can host a live‑reload Canvas host for nodes (`src/canvas-host/server.ts`), injecting the A2UI postMessage bridge.
|
||||
|
||||
### iOS app (apps/ios)
|
||||
- **Discovery + pairing**: Bonjour discovery via `BridgeDiscoveryModel` (NWBrowser). `BridgeConnectionController` auto‑connects using Keychain token or allows manual host/port.
|
||||
- **Node runtime**: `BridgeSession` (actor) maintains the `NWConnection`, hello handshake, ping/pong, RPC requests, and `invoke` callbacks.
|
||||
- **Capabilities + commands**: advertises `canvas`, `screen`, `camera`, `voiceWake` (settings‑driven) and executes `canvas.*`, `canvas.a2ui.*`, `camera.*`, `screen.record` (`NodeAppModel.handleInvoke`).
|
||||
- **Canvas**: `WKWebView` with bundled Canvas scaffold + A2UI, JS eval, snapshot capture, and `clawdis://` deep‑link interception (`ScreenController`).
|
||||
- **Voice + deep links**: voice wake sends `voice.transcript` events; `clawdis://agent` links emit `agent.request`. Voice wake triggers sync via `voicewake.get` + `voicewake.changed`.
|
||||
|
||||
## Companion apps
|
||||
|
||||
The **macOS app is critical**: it runs the menu‑bar control plane, owns local permissions (TCC), hosts Voice Wake, exposes WebChat/debug tools, and coordinates local/remote gateway mode. Most “assistant” UX lives here.
|
||||
|
||||
### macOS (Clawdis.app)
|
||||
|
||||
- Menu bar control for the Gateway and health.
|
||||
- Voice Wake + push-to-talk overlay.
|
||||
- WebChat + debug tools.
|
||||
- Remote gateway control over SSH.
|
||||
|
||||
Note: signed builds required for macOS permissions to stick across rebuilds (see `docs/mac/permissions.md`).
|
||||
Build/run: `./scripts/restart-mac.sh` (packages + launches).
|
||||
|
||||
### iOS node (optional)
|
||||
### iOS node (internal)
|
||||
|
||||
- Pairs as a node via the Bridge.
|
||||
- Voice trigger forwarding + Canvas surface.
|
||||
- Controlled via `clawdbot nodes …`.
|
||||
- Controlled via `clawdis nodes …`.
|
||||
|
||||
Runbook: [iOS connect](https://github.com/clawdbot/clawdbot/blob/main/docs/ios.md).
|
||||
Runbook: `docs/ios/connect.md`.
|
||||
|
||||
### Android node (optional)
|
||||
### Android node (internal)
|
||||
|
||||
- Pairs via the same Bridge + pairing flow as iOS.
|
||||
- Exposes Canvas, Camera, and Screen capture commands.
|
||||
- Runbook: [Android connect](https://github.com/clawdbot/clawdbot/blob/main/docs/android.md).
|
||||
- Runbook: `docs/android/connect.md`.
|
||||
|
||||
## Agent workspace + skills
|
||||
|
||||
- Workspace root: `~/clawd` (configurable via `agent.workspace`).
|
||||
- Workspace root: `~/clawd` (configurable via `inbound.workspace`).
|
||||
- Injected prompt files: `AGENTS.md`, `SOUL.md`, `TOOLS.md`.
|
||||
- Skills: `~/clawd/skills/<skill>/SKILL.md`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Minimal `~/.clawdbot/clawdbot.json` (model + defaults):
|
||||
Minimal `~/.clawdis/clawdis.json`:
|
||||
|
||||
```json5
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5"
|
||||
inbound: {
|
||||
allowFrom: ["+1234567890"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
[Full configuration reference (all keys + examples).](https://github.com/clawdbot/clawdbot/blob/main/docs/configuration.md)
|
||||
### WhatsApp
|
||||
|
||||
## Security model (important)
|
||||
- Link the device: `pnpm clawdis login` (stores creds in `~/.clawdis/credentials`).
|
||||
- Allowlist who can talk to the assistant via `inbound.allowFrom`.
|
||||
|
||||
- **Default:** tools run on the host for the **main** session, so the agent has full access when it’s just you.
|
||||
- **Group/channel safety:** set `agent.sandbox.mode: "non-main"` to run **non‑main sessions** (groups/channels) inside per‑session Docker sandboxes; bash then runs in Docker for those sessions.
|
||||
- **Sandbox defaults:** allowlist `bash`, `process`, `read`, `write`, `edit`; denylist `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway`.
|
||||
|
||||
Details: [Security guide](https://github.com/clawdbot/clawdbot/blob/main/docs/security.md) · [Docker + sandboxing](https://github.com/clawdbot/clawdbot/blob/main/docs/docker.md) · [Sandbox config](https://github.com/clawdbot/clawdbot/blob/main/docs/configuration.md)
|
||||
|
||||
### [WhatsApp](https://github.com/clawdbot/clawdbot/blob/main/docs/whatsapp.md)
|
||||
|
||||
- Link the device: `pnpm clawdbot login` (stores creds in `~/.clawdbot/credentials`).
|
||||
- Allowlist who can talk to the assistant via `whatsapp.allowFrom`.
|
||||
- If `whatsapp.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
||||
|
||||
### [Telegram](https://github.com/clawdbot/clawdbot/blob/main/docs/telegram.md)
|
||||
### Telegram
|
||||
|
||||
- Set `TELEGRAM_BOT_TOKEN` or `telegram.botToken` (env wins).
|
||||
- Optional: set `telegram.groups` (with `telegram.groups."*".requireMention`); when set, it is a group allowlist (include `"*"` to allow all). Also `telegram.allowFrom` or `telegram.webhookUrl` as needed.
|
||||
- Optional: set `telegram.requireMention`, `telegram.allowFrom`, or `telegram.webhookUrl` as needed.
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -304,36 +155,6 @@ Details: [Security guide](https://github.com/clawdbot/clawdbot/blob/main/docs/se
|
||||
}
|
||||
```
|
||||
|
||||
### [Slack](https://github.com/clawdbot/clawdbot/blob/main/docs/slack.md)
|
||||
|
||||
- Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` (or `slack.botToken` + `slack.appToken`).
|
||||
|
||||
### [Discord](https://github.com/clawdbot/clawdbot/blob/main/docs/discord.md)
|
||||
|
||||
- Set `DISCORD_BOT_TOKEN` or `discord.token` (env wins).
|
||||
- Optional: set `discord.slashCommand`, `discord.dm.allowFrom`, `discord.guilds`, or `discord.mediaMaxMb` as needed.
|
||||
|
||||
```json5
|
||||
{
|
||||
discord: {
|
||||
token: "1234abcd"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### [Signal](https://github.com/clawdbot/clawdbot/blob/main/docs/signal.md)
|
||||
|
||||
- Requires `signal-cli` and a `signal` config section.
|
||||
|
||||
### [iMessage](https://github.com/clawdbot/clawdbot/blob/main/docs/imessage.md)
|
||||
|
||||
- macOS only; Messages must be signed in.
|
||||
- If `imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
||||
|
||||
### [WebChat](https://github.com/clawdbot/clawdbot/blob/main/docs/webchat.md)
|
||||
|
||||
- Uses the Gateway WebSocket; no separate WebChat port/config.
|
||||
|
||||
Browser control (optional):
|
||||
|
||||
```json5
|
||||
@@ -348,99 +169,21 @@ Browser control (optional):
|
||||
|
||||
## Docs
|
||||
|
||||
Use these when you’re past the onboarding flow and want the deeper reference.
|
||||
- [Start with the docs index for navigation and “what’s where.”](https://github.com/clawdbot/clawdbot/blob/main/docs/index.md)
|
||||
- [Read the architecture overview for the gateway + protocol model.](https://github.com/clawdbot/clawdbot/blob/main/docs/architecture.md)
|
||||
- [Use the full configuration reference when you need every key and example.](https://github.com/clawdbot/clawdbot/blob/main/docs/configuration.md)
|
||||
- [Run the Gateway by the book with the operational runbook.](https://github.com/clawdbot/clawdbot/blob/main/docs/gateway.md)
|
||||
- [Learn how the Control UI/Web surfaces work and how to expose them safely.](https://github.com/clawdbot/clawdbot/blob/main/docs/web.md)
|
||||
- [Understand remote access over SSH tunnels or tailnets.](https://github.com/clawdbot/clawdbot/blob/main/docs/remote.md)
|
||||
- [Follow the onboarding wizard flow for a guided setup.](https://github.com/clawdbot/clawdbot/blob/main/docs/wizard.md)
|
||||
- [Wire external triggers via the webhook surface.](https://github.com/clawdbot/clawdbot/blob/main/docs/webhook.md)
|
||||
- [Set up Gmail Pub/Sub triggers.](https://github.com/clawdbot/clawdbot/blob/main/docs/gmail-pubsub.md)
|
||||
- [Learn the macOS menu bar companion details.](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/menu-bar.md)
|
||||
- [Platform guides: Windows](https://github.com/clawdbot/clawdbot/blob/main/docs/windows.md), [Linux](https://github.com/clawdbot/clawdbot/blob/main/docs/linux.md), [macOS](https://github.com/clawdbot/clawdbot/blob/main/docs/macos.md), [iOS](https://github.com/clawdbot/clawdbot/blob/main/docs/ios.md), [Android](https://github.com/clawdbot/clawdbot/blob/main/docs/android.md)
|
||||
- [Debug common failures with the troubleshooting guide.](https://github.com/clawdbot/clawdbot/blob/main/docs/troubleshooting.md)
|
||||
- [Review security guidance before exposing anything.](https://github.com/clawdbot/clawdbot/blob/main/docs/security.md)
|
||||
|
||||
## Advanced docs (discovery + control)
|
||||
|
||||
- [Discovery + transports](https://github.com/clawdbot/clawdbot/blob/main/docs/discovery.md)
|
||||
- [Bonjour/mDNS](https://github.com/clawdbot/clawdbot/blob/main/docs/bonjour.md)
|
||||
- [Gateway pairing](https://github.com/clawdbot/clawdbot/blob/main/docs/gateway/pairing.md)
|
||||
- [Remote gateway README](https://github.com/clawdbot/clawdbot/blob/main/docs/remote-gateway-readme.md)
|
||||
- [Control UI](https://github.com/clawdbot/clawdbot/blob/main/docs/control-ui.md)
|
||||
- [Dashboard](https://github.com/clawdbot/clawdbot/blob/main/docs/dashboard.md)
|
||||
|
||||
## Operations & troubleshooting
|
||||
|
||||
- [Health checks](https://github.com/clawdbot/clawdbot/blob/main/docs/health.md)
|
||||
- [Gateway lock](https://github.com/clawdbot/clawdbot/blob/main/docs/gateway-lock.md)
|
||||
- [Background process](https://github.com/clawdbot/clawdbot/blob/main/docs/background-process.md)
|
||||
- [Browser troubleshooting (Linux)](https://github.com/clawdbot/clawdbot/blob/main/docs/browser-linux-troubleshooting.md)
|
||||
- [Logging](https://github.com/clawdbot/clawdbot/blob/main/docs/logging.md)
|
||||
|
||||
## Deep dives
|
||||
|
||||
- [Agent loop](https://github.com/clawdbot/clawdbot/blob/main/docs/agent-loop.md)
|
||||
- [Presence](https://github.com/clawdbot/clawdbot/blob/main/docs/presence.md)
|
||||
- [TypeBox schemas](https://github.com/clawdbot/clawdbot/blob/main/docs/typebox.md)
|
||||
- [RPC adapters](https://github.com/clawdbot/clawdbot/blob/main/docs/rpc.md)
|
||||
- [Queue](https://github.com/clawdbot/clawdbot/blob/main/docs/queue.md)
|
||||
|
||||
## Workspace & skills
|
||||
|
||||
- [Skills config](https://github.com/clawdbot/clawdbot/blob/main/docs/skills-config.md)
|
||||
- [Default AGENTS](https://github.com/clawdbot/clawdbot/blob/main/docs/AGENTS.default.md)
|
||||
- [Templates: AGENTS](https://github.com/clawdbot/clawdbot/blob/main/docs/templates/AGENTS.md)
|
||||
- [Templates: BOOTSTRAP](https://github.com/clawdbot/clawdbot/blob/main/docs/templates/BOOTSTRAP.md)
|
||||
- [Templates: IDENTITY](https://github.com/clawdbot/clawdbot/blob/main/docs/templates/IDENTITY.md)
|
||||
- [Templates: SOUL](https://github.com/clawdbot/clawdbot/blob/main/docs/templates/SOUL.md)
|
||||
- [Templates: TOOLS](https://github.com/clawdbot/clawdbot/blob/main/docs/templates/TOOLS.md)
|
||||
- [Templates: USER](https://github.com/clawdbot/clawdbot/blob/main/docs/templates/USER.md)
|
||||
|
||||
## Platform internals
|
||||
|
||||
- [macOS dev setup](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/dev-setup.md)
|
||||
- [macOS menu bar](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/menu-bar.md)
|
||||
- [macOS voice wake](https://github.com/clawdbot/clawdbot/blob/main/docs/mac/voicewake.md)
|
||||
- [iOS node](https://github.com/clawdbot/clawdbot/blob/main/docs/ios.md)
|
||||
- [Android node](https://github.com/clawdbot/clawdbot/blob/main/docs/android.md)
|
||||
- [Windows app](https://github.com/clawdbot/clawdbot/blob/main/docs/windows.md)
|
||||
- [Linux app](https://github.com/clawdbot/clawdbot/blob/main/docs/linux.md)
|
||||
|
||||
## Email hooks (Gmail)
|
||||
|
||||
[Gmail Pub/Sub wiring (gcloud + gogcli), hook tokens, and auto-watch behavior are documented here.](https://github.com/clawdbot/clawdbot/blob/main/docs/gmail-pubsub.md)
|
||||
|
||||
Gateway auto-starts the watcher when `hooks.enabled=true` and `hooks.gmail.account` is set; `clawdbot hooks gmail run` is the manual daemon wrapper if you don’t want auto-start.
|
||||
|
||||
```bash
|
||||
clawdbot hooks gmail setup --account you@gmail.com
|
||||
clawdbot hooks gmail run
|
||||
```
|
||||
- [`docs/index.md`](docs/index.md) (overview)
|
||||
- [`docs/configuration.md`](docs/configuration.md)
|
||||
- [`docs/gateway.md`](docs/gateway.md)
|
||||
- [`docs/web.md`](docs/web.md)
|
||||
- [`docs/discovery.md`](docs/discovery.md)
|
||||
- [`docs/agent.md`](docs/agent.md)
|
||||
- [`docs/security.md`](docs/security.md)
|
||||
- [`docs/troubleshooting.md`](docs/troubleshooting.md)
|
||||
- [`docs/ios/connect.md`](docs/ios/connect.md)
|
||||
- [`docs/clawdis-mac.md`](docs/clawdis-mac.md)
|
||||
|
||||
## Clawd
|
||||
|
||||
Clawdbot was built for **Clawd**, a space lobster AI assistant. 🦞
|
||||
by Peter Steinberger and the community.
|
||||
Clawdis was built for **Clawd**, a space lobster AI assistant.
|
||||
|
||||
- https://clawd.me
|
||||
- https://soul.md
|
||||
- https://steipete.me
|
||||
|
||||
## Community
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
|
||||
AI/vibe-coded PRs welcome! 🤖
|
||||
|
||||
Thanks to all clawtributors:
|
||||
|
||||
<p align="left">
|
||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="Nachx639" title="Nachx639"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a>
|
||||
<a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="mbelinky" title="mbelinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="omniwired" title="omniwired"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="vsabavat" title="vsabavat"/></a>
|
||||
<a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="djangonavarro220" title="djangonavarro220"/></a>
|
||||
<a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a>
|
||||
<a href="https://github.com/adamgall"><img src="https://avatars.githubusercontent.com/u/706929?v=4&s=48" width="48" height="48" alt="adamgall" title="adamgall"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/regenrek"><img src="https://avatars.githubusercontent.com/u/5182020?v=4&s=48" width="48" height="48" alt="regenrek" title="regenrek"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="tobiasbischoff" title="tobiasbischoff"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a>
|
||||
<a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="Iamadig" title="Iamadig"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a>
|
||||
</p>
|
||||
@@ -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.
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"originHash" : "5d29ee82825e0764775562242cfa1ff4dc79584797dd638f76c9876545454748",
|
||||
"originHash" : "3018b2c8c183d55b57ad0c4526b2380ac3b957d13a3a86e1b2845e81323c443a",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "elevenlabskit",
|
||||
"identity" : "commander",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/ElevenLabsKit",
|
||||
"location" : "https://github.com/steipete/Commander.git",
|
||||
"state" : {
|
||||
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
|
||||
"version" : "0.1.0"
|
||||
"revision" : "8b8cb4f34315ce9e5307b3a2bcd77ff73f586a02",
|
||||
"version" : "0.2.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+2
-21
@@ -4,16 +4,14 @@ import PackageDescription
|
||||
let package = Package(
|
||||
name: "swabble",
|
||||
platforms: [
|
||||
.macOS(.v15),
|
||||
.iOS(.v17),
|
||||
.macOS(.v26),
|
||||
],
|
||||
products: [
|
||||
.library(name: "Swabble", targets: ["Swabble"]),
|
||||
.library(name: "SwabbleKit", targets: ["SwabbleKit"]),
|
||||
.executable(name: "swabble", targets: ["SwabbleCLI"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../Peekaboo/Commander"),
|
||||
.package(url: "https://github.com/steipete/Commander.git", from: "0.2.0"),
|
||||
.package(url: "https://github.com/apple/swift-testing", from: "0.99.0"),
|
||||
],
|
||||
targets: [
|
||||
@@ -21,30 +19,13 @@ let package = Package(
|
||||
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: [
|
||||
|
||||
+4
-8
@@ -1,10 +1,9 @@
|
||||
# 🎙️ 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.
|
||||
swabble is a Swift 6.2, macOS 26-only rewrite of the brabble voice daemon. It listens on your mic, gates on a wake word, transcribes locally using Apple's new SpeechAnalyzer + SpeechTranscriber, then fires a shell hook with the transcript. No cloud calls, no Whisper binaries.
|
||||
|
||||
- **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).
|
||||
@@ -31,7 +30,7 @@ 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:
|
||||
Add swabble as a SwiftPM dependency and import the `Swabble` product to reuse the Speech pipeline, config loader, hook executor, and transcript store in your own app:
|
||||
|
||||
```swift
|
||||
// Package.swift
|
||||
@@ -39,10 +38,7 @@ 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+)
|
||||
]),
|
||||
.target(name: "MyApp", dependencies: [.product(name: "Swabble", package: "swabble")]),
|
||||
]
|
||||
```
|
||||
|
||||
@@ -97,7 +93,7 @@ Environment variables:
|
||||
|
||||
## Speech pipeline
|
||||
- `AVAudioEngine` tap → `BufferConverter` → `AnalyzerInput` → `SpeechAnalyzer` with a `SpeechTranscriber` module.
|
||||
- Requests volatile + final results; the CLI uses text-only wake gating today.
|
||||
- Requests volatile + final results; wake gating is string match on partial/final.
|
||||
- Authorization requested at first start; requires macOS 26 + new Speech.framework APIs.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -2,13 +2,11 @@ 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
|
||||
@@ -16,7 +14,6 @@ public enum SpeechPipelineError: Error {
|
||||
}
|
||||
|
||||
/// Live microphone → SpeechAnalyzer → SpeechTranscriber pipeline.
|
||||
@available(macOS 26.0, iOS 26.0, *)
|
||||
public actor SpeechPipeline {
|
||||
private struct UnsafeBuffer: @unchecked Sendable { let buffer: AVAudioPCMBuffer }
|
||||
|
||||
|
||||
@@ -1,192 +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<String.Index>?
|
||||
|
||||
public init(text: String, start: TimeInterval, duration: TimeInterval, range: Range<String.Index>? = nil) {
|
||||
self.text = text
|
||||
self.start = start
|
||||
self.duration = duration
|
||||
self.range = range
|
||||
}
|
||||
|
||||
public var end: TimeInterval { self.start + self.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<String.Index>?
|
||||
let text: String
|
||||
}
|
||||
|
||||
private struct TriggerTokens {
|
||||
let tokens: [String]
|
||||
}
|
||||
|
||||
public static func match(
|
||||
transcript: String,
|
||||
segments: [WakeWordSegment],
|
||||
config: WakeWordGateConfig)
|
||||
-> WakeWordGateMatch? {
|
||||
let triggerTokens = self.normalizeTriggers(config.triggers)
|
||||
guard !triggerTokens.isEmpty else { return nil }
|
||||
|
||||
let tokens = self.normalizeSegments(segments)
|
||||
guard !tokens.isEmpty else { return nil }
|
||||
|
||||
var best: (index: Int, triggerEnd: TimeInterval, gap: TimeInterval)?
|
||||
|
||||
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..<count).allSatisfy { tokens[i + $0].normalized == trigger.tokens[$0] }
|
||||
if !matched { continue }
|
||||
|
||||
let triggerEnd = tokens[i + count - 1].end
|
||||
let nextToken = tokens[i + count]
|
||||
let gap = nextToken.start - triggerEnd
|
||||
if gap < config.minPostTriggerGap { continue }
|
||||
|
||||
if let best, i <= best.index { continue }
|
||||
|
||||
best = (i, triggerEnd, gap)
|
||||
}
|
||||
}
|
||||
|
||||
guard let best else { return nil }
|
||||
let command = self.commandText(transcript: transcript, segments: segments, triggerEndTime: best.triggerEnd)
|
||||
.trimmingCharacters(in: Self.whitespaceAndPunctuation)
|
||||
guard command.count >= 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 && !self.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: self.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: self.whitespaceAndPunctuation)
|
||||
guard !token.isEmpty else { continue }
|
||||
out = out.replacingOccurrences(of: token, with: "", options: [.caseInsensitive])
|
||||
}
|
||||
return out.trimmingCharacters(in: self.whitespaceAndPunctuation)
|
||||
}
|
||||
|
||||
private static func normalizeTriggers(_ triggers: [String]) -> [TriggerTokens] {
|
||||
var output: [TriggerTokens] = []
|
||||
for trigger in triggers {
|
||||
let tokens = trigger
|
||||
.split(whereSeparator: { $0.isWhitespace })
|
||||
.map { self.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 = self.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: self.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
|
||||
@@ -1,7 +1,6 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
@MainActor
|
||||
enum CLIRegistry {
|
||||
static var descriptors: [CommandDescriptor] {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
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?
|
||||
@@ -70,12 +68,17 @@ struct ServeCommand: ParsableCommand {
|
||||
}
|
||||
|
||||
private static func matchesWake(text: String, cfg: SwabbleConfig) -> Bool {
|
||||
let triggers = [cfg.wake.word] + cfg.wake.aliases
|
||||
return WakeWordGate.matchesTextOnly(text: text, triggers: triggers)
|
||||
let lowered = text.lowercased()
|
||||
if lowered.contains(cfg.wake.word.lowercased()) { return true }
|
||||
return cfg.wake.aliases.contains(where: { lowered.contains($0.lowercased()) })
|
||||
}
|
||||
|
||||
private static func stripWake(text: String, cfg: SwabbleConfig) -> String {
|
||||
let triggers = [cfg.wake.word] + cfg.wake.aliases
|
||||
return WakeWordGate.stripWake(text: text, triggers: triggers)
|
||||
var out = text
|
||||
out = out.replacingOccurrences(of: cfg.wake.word, with: "", options: [.caseInsensitive])
|
||||
for alias in cfg.wake.aliases {
|
||||
out = out.replacingOccurrences(of: alias, with: "", options: [.caseInsensitive])
|
||||
}
|
||||
return out.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
@MainActor
|
||||
private func runCLI() async -> Int32 {
|
||||
do {
|
||||
@@ -16,7 +15,6 @@ private func runCLI() async -> Int32 {
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
@MainActor
|
||||
private func dispatch(invocation: CommandInvocation) async throws {
|
||||
let parsed = invocation.parsedValues
|
||||
@@ -97,10 +95,5 @@ private func dispatch(invocation: CommandInvocation) async throws {
|
||||
}
|
||||
}
|
||||
|
||||
if #available(macOS 26.0, *) {
|
||||
let exitCode = await runCLI()
|
||||
exit(exitCode)
|
||||
} else {
|
||||
fputs("error: swabble requires macOS 26 or newer\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
let exitCode = await runCLI()
|
||||
exit(exitCode)
|
||||
@@ -1,63 +0,0 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import SwabbleKit
|
||||
|
||||
@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..<transcript.endIndex)
|
||||
output.append(WakeWordSegment(text: word, start: start, duration: duration, range: range))
|
||||
if let range { searchStart = range.upperBound }
|
||||
}
|
||||
return output
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
# swabble — macOS 26 speech hook daemon (Swift 6.2)
|
||||
|
||||
Goal: brabble-style always-on voice hook for macOS 26 using Apple Speech.framework (SpeechAnalyzer + SpeechTranscriber) instead of whisper.cpp. Local-only, wake word gated, dispatches a shell hook with the transcript. Shared wake-gate utilities live in `SwabbleKit` for reuse by other apps (iOS/macOS).
|
||||
Goal: brabble-style always-on voice hook for macOS 26 using Apple Speech.framework (SpeechAnalyzer + SpeechTranscriber) instead of whisper.cpp. Local-only, wake word gated, dispatches a shell hook with the transcript.
|
||||
|
||||
## Requirements
|
||||
- macOS 26+, Swift 6.2, Speech.framework with on-device assets.
|
||||
- Local only; no network calls during transcription.
|
||||
- Wake word gating (default "clawd" plus aliases) with bypass flag `--no-wake`.
|
||||
- `SwabbleKit` target (multi-platform) providing wake-word gating helpers that can use speech segment timing to require a post-trigger gap.
|
||||
- Hook execution with cooldown, min_chars, timeout, prefix, env vars.
|
||||
- Simple config at `~/.config/swabble/config.json` (JSON, Codable) — no TOML.
|
||||
- CLI implemented with Commander (SwiftPM package `steipete/Commander`); core types are available via the SwiftPM library product `Swabble` for embedding.
|
||||
@@ -18,7 +17,7 @@ Goal: brabble-style always-on voice hook for macOS 26 using Apple Speech.framewo
|
||||
- **CLI layer (Commander)**: Root command `swabble` with subcommands `serve`, `transcribe`, `test-hook`, `mic list|set`, `doctor`, `health`, `tail-log`. Runtime flags from Commander (`-v/--verbose`, `--json-output`, `--log-level`). Custom `--config` path applies everywhere.
|
||||
- **Config**: `SwabbleConfig` Codable. Fields: audio device name/index, wake (enabled/word/aliases/sensitivity placeholder), hook (command/args/prefix/cooldown/min_chars/timeout/env), logging (level, format), transcripts (enabled, max kept), speech (locale, enableEtiquetteReplacements flag). Stored JSON; default written by `setup`.
|
||||
- **Audio + Speech pipeline**: `SpeechPipeline` wraps `AVAudioEngine` input → `SpeechAnalyzer` with `SpeechTranscriber` module. Emits partial/final transcripts via async stream. Requests `.audioTimeRange` when transcripts enabled. Handles Speech permission and asset download prompts ahead of capture.
|
||||
- **Wake gate**: CLI currently uses text-only keyword match; shared `SwabbleKit` gate can enforce a minimum pause between the wake word and the next token when speech segments are available. `--no-wake` disables gating.
|
||||
- **Wake gate**: text-based keyword match against latest partial/final; strips wake term before hook dispatch. `--no-wake` disables.
|
||||
- **Hook executor**: async `HookExecutor` spawns `Process` with configured args, prefix substitution `${hostname}`. Enforces cooldown + timeout; injects env `SWABBLE_TEXT`, `SWABBLE_PREFIX` plus user env map.
|
||||
- **Transcripts store**: in-memory ring buffer; optional persisted JSON lines under `~/Library/Application Support/swabble/transcripts.log`.
|
||||
- **Logging**: simple structured logger to stderr; respects log level.
|
||||
@@ -26,7 +25,7 @@ Goal: brabble-style always-on voice hook for macOS 26 using Apple Speech.framewo
|
||||
## Out of scope (initial cut)
|
||||
- Model management (Speech handles assets).
|
||||
- Launchd helper (planned follow-up).
|
||||
- Advanced wake-word detector (segment-aware gate now lives in `SwabbleKit`; CLI still text-only until segment timing is plumbed through).
|
||||
- Advanced wake-word detector (text match only for now).
|
||||
|
||||
## Open decisions
|
||||
- Whether to expose a UNIX control socket for `status`/`health` (currently planned as stdin/out direct calls).
|
||||
|
||||
+21
-55
@@ -1,55 +1,21 @@
|
||||
<?xml version="1.0" standalone="yes"?>
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>Clawdis</title>
|
||||
<item>
|
||||
<title>2026.1.5-3</title>
|
||||
<pubDate>Mon, 05 Jan 2026 04:30:46 +0100</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>3095</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.5-3</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.5-3</h2>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>NPM package: include missing runtime dist folders (slack/signal/imessage/tui/wizard/control-ui/daemon) to avoid <code>ERR_MODULE_NOT_FOUND</code> in Node 25 npx installs.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.5-3/Clawdbot-2026.1.5-3.zip" length="160800596" type="application/octet-stream" sparkle:edSignature="P8U3nvIFpbGmRItT/NGPmJ/i370OMVvDHYQL/znYsLI0MrbGfXgMGEvR5A0uwW+cJevlX/hrJLiY51zo4rAMBg=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.5-3</title>
|
||||
<pubDate>Mon, 05 Jan 2026 03:57:59 +0100</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>3091</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.5-3</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.5-3</h2>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>NPM package: include missing runtime dist folders (slack/signal/imessage/tui/wizard/control-ui/daemon) to avoid <code>ERR_MODULE_NOT_FOUND</code> in Node 25 npx installs.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.5-3/Clawdbot-2026.1.5-3.zip" length="160797048" type="application/octet-stream" sparkle:edSignature="5KYFg0SW7liwLxLJbfzd2KsAxbX06gMH0rH/W3a4V0p4N48hjz4AsSrfFLdGZSnW+6XaJjC3MN6Ynh+l7kffDQ=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.5-2</title>
|
||||
<pubDate>Mon, 05 Jan 2026 03:51:30 +0100</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>3089</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.5-2</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.5-2</h2>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>NPM package: include <code>dist/sessions</code> so <code>clawdbot agent</code> resolves session helpers in npx installs.</li>
|
||||
<li>Node 25: avoid unsupported directory import by targeting <code>qrcode-terminal/vendor/QRCode/*.js</code> modules.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.5-2/Clawdbot-2026.1.5-2.zip" length="150250417" type="application/octet-stream" sparkle:edSignature="ntHNmwyHrv6cPk6NAKOT3AUkwdt5ZadrGU6mJK4GmVxi44uIMT3ZXluvnqK9SxXQwA0H0dXjiGMS/cg8NbgqDA=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0"
|
||||
xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<channel>
|
||||
<title>Clawdis Updates</title>
|
||||
<link>https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml</link>
|
||||
<description>Signed update feed for the Clawdis macOS companion app.</description>
|
||||
<item>
|
||||
<title>Clawdis 2.0.0-beta1</title>
|
||||
<sparkle:releaseNotesLink>https://github.com/steipete/clawdis/releases/tag/v2.0.0-beta1</sparkle:releaseNotesLink>
|
||||
<pubDate>Fri, 19 Dec 2025 17:19:50 +0000</pubDate>
|
||||
<enclosure url="https://github.com/steipete/clawdis/releases/download/v2.0.0-beta1/Clawdis-2.0.0-beta1.zip"
|
||||
sparkle:edSignature="oEpGD46U4ZyBBSY9/piUIFDJU+KlFB751JIWOW2yS0sRNHKszyG5khDHg9o9bV9Zo8DOCNF/HOi88jmtHJAaCQ=="
|
||||
sparkle:version="2.0.0-beta1"
|
||||
sparkle:shortVersionString="2.0.0-beta1"
|
||||
length="72410016"
|
||||
type="application/octet-stream" />
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -1,6 +1,6 @@
|
||||
## Clawdbot Node (Android) (internal)
|
||||
## Clawdis Node (Android) (internal)
|
||||
|
||||
Modern Android node app: connects to the **Gateway-owned bridge** (`_clawdbot-bridge._tcp`) over TCP and exposes **Canvas + Chat + Camera**.
|
||||
Modern Android node app: connects to the **Gateway-owned bridge** (`_clawdis-bridge._tcp`) over TCP and exposes **Canvas + Chat + Camera**.
|
||||
|
||||
Notes:
|
||||
- The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action).
|
||||
@@ -25,7 +25,7 @@ cd apps/android
|
||||
|
||||
1) Start the gateway (on your “master” machine):
|
||||
```bash
|
||||
pnpm clawdbot gateway --port 18789 --verbose
|
||||
pnpm clawdis gateway --port 18789 --verbose
|
||||
```
|
||||
|
||||
2) In the Android app:
|
||||
@@ -34,8 +34,8 @@ pnpm clawdbot gateway --port 18789 --verbose
|
||||
|
||||
3) Approve pairing (on the gateway machine):
|
||||
```bash
|
||||
clawdbot nodes pending
|
||||
clawdbot nodes approve <requestId>
|
||||
clawdis nodes pending
|
||||
clawdis nodes approve <requestId>
|
||||
```
|
||||
|
||||
More details: `docs/android/connect.md`.
|
||||
|
||||
@@ -6,21 +6,21 @@ plugins {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.clawdbot.android"
|
||||
namespace = "com.steipete.clawdis.node"
|
||||
compileSdk = 36
|
||||
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
assets.srcDir(file("../../shared/ClawdbotKit/Sources/ClawdbotKit/Resources"))
|
||||
assets.srcDir(file("../../shared/ClawdisKit/Sources/ClawdisKit/Resources"))
|
||||
}
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.clawdbot.android"
|
||||
applicationId = "com.steipete.clawdis.node"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "2.0.0-beta3"
|
||||
versionName = "0.1"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -31,7 +31,6 @@ android {
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
@@ -39,25 +38,15 @@ android {
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
disable += setOf("IconLauncherShape")
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests.isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -67,8 +56,7 @@ dependencies {
|
||||
|
||||
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.activity:activity-compose:1.12.1")
|
||||
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
@@ -98,12 +86,4 @@ dependencies {
|
||||
|
||||
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.1")
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
@@ -9,30 +9,21 @@
|
||||
<uses-permission
|
||||
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
||||
android:usesPermissionFlags="neverForLocation" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||
<uses-permission
|
||||
android:name="android.permission.ACCESS_FINE_LOCATION"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.SEND_SMS" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.telephony"
|
||||
android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".NodeApp"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:theme="@style/Theme.ClawdbotNode">
|
||||
android:theme="@style/Theme.ClawdisNode">
|
||||
<service
|
||||
android:name=".NodeForegroundService"
|
||||
android:exported="false"
|
||||
|
||||
@@ -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"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package com.clawdbot.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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
package com.clawdbot.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
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
package com.clawdbot.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<String>,
|
||||
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<String>,
|
||||
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<String>,
|
||||
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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
package com.clawdbot.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 com.clawdbot.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<String>,
|
||||
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<String>,
|
||||
): 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<String, JsonElement>(
|
||||
"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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
package com.clawdbot.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<String>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class ToolDisplaySpec(
|
||||
val emoji: String? = null,
|
||||
val title: String? = null,
|
||||
val label: String? = null,
|
||||
val detailKeys: List<String>? = null,
|
||||
val actions: Map<String, ToolDisplayActionSpec>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class ToolDisplayConfig(
|
||||
val version: Int? = null,
|
||||
val fallback: ToolDisplaySpec? = null,
|
||||
val tools: Map<String, ToolDisplaySpec>? = 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<String>()
|
||||
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>): 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()
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package com.clawdbot.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),
|
||||
)
|
||||
}
|
||||
@@ -1,444 +0,0 @@
|
||||
package com.clawdbot.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.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.ScreenShare
|
||||
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 com.clawdbot.android.CameraHudKind
|
||||
import com.clawdbot.android.MainViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RootScreen(viewModel: MainViewModel) {
|
||||
var sheet by remember { mutableStateOf<Sheet?>(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 bridge status ("Connecting…") in the activity slot.
|
||||
|
||||
if (screenRecordActive) {
|
||||
return@remember StatusActivity(
|
||||
title = "Recording screen…",
|
||||
icon = Icons.Default.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 bridgeState =
|
||||
remember(serverName, statusText) {
|
||||
when {
|
||||
serverName != null -> BridgeState.Connected
|
||||
statusText.contains("connecting", ignoreCase = true) ||
|
||||
statusText.contains("reconnecting", ignoreCase = true) -> BridgeState.Connecting
|
||||
statusText.contains("error", ignoreCase = true) -> BridgeState.Error
|
||||
else -> BridgeState.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(
|
||||
bridge = bridgeState,
|
||||
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.FORCE_DARK)) {
|
||||
WebSettingsCompat.setForceDark(settings, WebSettingsCompat.FORCE_DARK_OFF)
|
||||
}
|
||||
if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) {
|
||||
WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, false)
|
||||
}
|
||||
if (isDebuggable) {
|
||||
Log.d("ClawdbotWebView", "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("ClawdbotWebView", "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(
|
||||
"ClawdbotWebView",
|
||||
"onReceivedHttpError: ${errorResponse.statusCode} ${errorResponse.reasonPhrase} ${request.url}",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView, url: String?) {
|
||||
if (isDebuggable) {
|
||||
Log.d("ClawdbotWebView", "onPageFinished: $url")
|
||||
}
|
||||
viewModel.canvas.onPageFinished()
|
||||
}
|
||||
|
||||
override fun onRenderProcessGone(
|
||||
view: WebView,
|
||||
detail: android.webkit.RenderProcessGoneDetail,
|
||||
): Boolean {
|
||||
if (isDebuggable) {
|
||||
Log.e(
|
||||
"ClawdbotWebView",
|
||||
"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(
|
||||
"ClawdbotWebView",
|
||||
"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)
|
||||
addJavascriptInterface(
|
||||
CanvasA2UIActionLegacyBridge(a2uiBridge),
|
||||
CanvasA2UIActionLegacyBridge.interfaceName,
|
||||
)
|
||||
viewModel.canvas.attach(this)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
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 = "clawdbotCanvasA2UIAction"
|
||||
}
|
||||
}
|
||||
|
||||
private class CanvasA2UIActionLegacyBridge(private val bridge: CanvasA2UIActionBridge) {
|
||||
@JavascriptInterface
|
||||
fun canvasAction(payload: String?) {
|
||||
bridge.postMessage(payload)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun postMessage(payload: String?) {
|
||||
bridge.postMessage(payload)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val interfaceName: String = "Android"
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
package com.clawdbot.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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package com.clawdbot.android.ui.chat
|
||||
|
||||
import com.clawdbot.android.chat.ChatSessionEntry
|
||||
|
||||
private const val MAIN_SESSION_KEY = "main"
|
||||
private const val RECENT_WINDOW_MS = 24 * 60 * 60 * 1000L
|
||||
|
||||
fun resolveSessionChoices(
|
||||
currentSessionKey: String,
|
||||
sessions: List<ChatSessionEntry>,
|
||||
nowMs: Long = System.currentTimeMillis(),
|
||||
): List<ChatSessionEntry> {
|
||||
val current = currentSessionKey.trim()
|
||||
val cutoff = nowMs - RECENT_WINDOW_MS
|
||||
val sorted = sessions.sortedByDescending { it.updatedAtMs ?: 0L }
|
||||
val recent = mutableListOf<ChatSessionEntry>()
|
||||
val seen = mutableSetOf<String>()
|
||||
for (entry in sorted) {
|
||||
if (!seen.add(entry.key)) continue
|
||||
if ((entry.updatedAtMs ?: 0L) < cutoff) continue
|
||||
recent.add(entry)
|
||||
}
|
||||
|
||||
val result = mutableListOf<ChatSessionEntry>()
|
||||
val included = mutableSetOf<String>()
|
||||
val mainEntry = sorted.firstOrNull { it.key == MAIN_SESSION_KEY }
|
||||
if (mainEntry != null) {
|
||||
result.add(mainEntry)
|
||||
included.add(MAIN_SESSION_KEY)
|
||||
} else if (current == MAIN_SESSION_KEY) {
|
||||
result.add(ChatSessionEntry(key = MAIN_SESSION_KEY, updatedAtMs = null))
|
||||
included.add(MAIN_SESSION_KEY)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
package com.clawdbot.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<Chunk>()
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
package com.clawdbot.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<String>,
|
||||
)
|
||||
|
||||
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>): 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<String>): Double? {
|
||||
for (key in keys) {
|
||||
val value = obj[key].asDoubleOrNull()
|
||||
if (value != null) return value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun intValue(obj: JsonObject, keys: List<String>): Int? {
|
||||
for (key in keys) {
|
||||
val value = obj[key].asIntOrNull()
|
||||
if (value != null) return value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun longValue(obj: JsonObject, keys: List<String>): Long? {
|
||||
for (key in keys) {
|
||||
val value = obj[key].asLongOrNull()
|
||||
if (value != null) return value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun boolValue(obj: JsonObject, keys: List<String>): 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
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.clawdbot.android
|
||||
package com.steipete.clawdis.node
|
||||
|
||||
enum class CameraHudKind {
|
||||
Photo,
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.clawdbot.android
|
||||
package com.steipete.clawdis.node
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
+4
-5
@@ -1,4 +1,4 @@
|
||||
package com.clawdbot.android
|
||||
package com.steipete.clawdis.node
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.ApplicationInfo
|
||||
@@ -18,8 +18,8 @@ import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.clawdbot.android.ui.RootScreen
|
||||
import com.clawdbot.android.ui.ClawdbotTheme
|
||||
import com.steipete.clawdis.node.ui.RootScreen
|
||||
import com.steipete.clawdis.node.ui.ClawdisTheme
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
@@ -39,7 +39,6 @@ class MainActivity : ComponentActivity() {
|
||||
screenCaptureRequester = ScreenCaptureRequester(this)
|
||||
viewModel.camera.attachLifecycleOwner(this)
|
||||
viewModel.camera.attachPermissionRequester(permissionRequester)
|
||||
viewModel.sms.attachPermissionRequester(permissionRequester)
|
||||
viewModel.screenRecorder.attachScreenCaptureRequester(screenCaptureRequester)
|
||||
viewModel.screenRecorder.attachPermissionRequester(permissionRequester)
|
||||
|
||||
@@ -56,7 +55,7 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
setContent {
|
||||
ClawdbotTheme {
|
||||
ClawdisTheme {
|
||||
Surface(modifier = Modifier) {
|
||||
RootScreen(viewModel = viewModel)
|
||||
}
|
||||
+6
-38
@@ -1,13 +1,12 @@
|
||||
package com.clawdbot.android
|
||||
package com.steipete.clawdis.node
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import com.clawdbot.android.bridge.BridgeEndpoint
|
||||
import com.clawdbot.android.chat.OutgoingAttachment
|
||||
import com.clawdbot.android.node.CameraCaptureManager
|
||||
import com.clawdbot.android.node.CanvasController
|
||||
import com.clawdbot.android.node.ScreenRecordManager
|
||||
import com.clawdbot.android.node.SmsManager
|
||||
import com.steipete.clawdis.node.bridge.BridgeEndpoint
|
||||
import com.steipete.clawdis.node.chat.OutgoingAttachment
|
||||
import com.steipete.clawdis.node.node.CameraCaptureManager
|
||||
import com.steipete.clawdis.node.node.CanvasController
|
||||
import com.steipete.clawdis.node.node.ScreenRecordManager
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
@@ -16,7 +15,6 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val canvas: CanvasController = runtime.canvas
|
||||
val camera: CameraCaptureManager = runtime.camera
|
||||
val screenRecorder: ScreenRecordManager = runtime.screenRecorder
|
||||
val sms: SmsManager = runtime.sms
|
||||
|
||||
val bridges: StateFlow<List<BridgeEndpoint>> = runtime.bridges
|
||||
val discoveryStatusText: StateFlow<String> = runtime.discoveryStatusText
|
||||
@@ -25,31 +23,21 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val statusText: StateFlow<String> = runtime.statusText
|
||||
val serverName: StateFlow<String?> = runtime.serverName
|
||||
val remoteAddress: StateFlow<String?> = runtime.remoteAddress
|
||||
val isForeground: StateFlow<Boolean> = runtime.isForeground
|
||||
val seamColorArgb: StateFlow<Long> = runtime.seamColorArgb
|
||||
|
||||
val cameraHud: StateFlow<CameraHudState?> = runtime.cameraHud
|
||||
val cameraFlashToken: StateFlow<Long> = runtime.cameraFlashToken
|
||||
val screenRecordActive: StateFlow<Boolean> = runtime.screenRecordActive
|
||||
|
||||
val instanceId: StateFlow<String> = runtime.instanceId
|
||||
val displayName: StateFlow<String> = runtime.displayName
|
||||
val cameraEnabled: StateFlow<Boolean> = runtime.cameraEnabled
|
||||
val locationMode: StateFlow<LocationMode> = runtime.locationMode
|
||||
val locationPreciseEnabled: StateFlow<Boolean> = runtime.locationPreciseEnabled
|
||||
val preventSleep: StateFlow<Boolean> = runtime.preventSleep
|
||||
val wakeWords: StateFlow<List<String>> = runtime.wakeWords
|
||||
val voiceWakeMode: StateFlow<VoiceWakeMode> = runtime.voiceWakeMode
|
||||
val voiceWakeStatusText: StateFlow<String> = runtime.voiceWakeStatusText
|
||||
val voiceWakeIsListening: StateFlow<Boolean> = runtime.voiceWakeIsListening
|
||||
val talkEnabled: StateFlow<Boolean> = runtime.talkEnabled
|
||||
val talkStatusText: StateFlow<String> = runtime.talkStatusText
|
||||
val talkIsListening: StateFlow<Boolean> = runtime.talkIsListening
|
||||
val talkIsSpeaking: StateFlow<Boolean> = runtime.talkIsSpeaking
|
||||
val manualEnabled: StateFlow<Boolean> = runtime.manualEnabled
|
||||
val manualHost: StateFlow<String> = runtime.manualHost
|
||||
val manualPort: StateFlow<Int> = runtime.manualPort
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = runtime.canvasDebugStatusEnabled
|
||||
|
||||
val chatSessionKey: StateFlow<String> = runtime.chatSessionKey
|
||||
val chatSessionId: StateFlow<String?> = runtime.chatSessionId
|
||||
@@ -74,14 +62,6 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
runtime.setCameraEnabled(value)
|
||||
}
|
||||
|
||||
fun setLocationMode(mode: LocationMode) {
|
||||
runtime.setLocationMode(mode)
|
||||
}
|
||||
|
||||
fun setLocationPreciseEnabled(value: Boolean) {
|
||||
runtime.setLocationPreciseEnabled(value)
|
||||
}
|
||||
|
||||
fun setPreventSleep(value: Boolean) {
|
||||
runtime.setPreventSleep(value)
|
||||
}
|
||||
@@ -98,10 +78,6 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
runtime.setManualPort(value)
|
||||
}
|
||||
|
||||
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
||||
runtime.setCanvasDebugStatusEnabled(value)
|
||||
}
|
||||
|
||||
fun setWakeWords(words: List<String>) {
|
||||
runtime.setWakeWords(words)
|
||||
}
|
||||
@@ -114,14 +90,6 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
runtime.setVoiceWakeMode(mode)
|
||||
}
|
||||
|
||||
fun setTalkEnabled(enabled: Boolean) {
|
||||
runtime.setTalkEnabled(enabled)
|
||||
}
|
||||
|
||||
fun refreshBridgeHello() {
|
||||
runtime.refreshBridgeHello()
|
||||
}
|
||||
|
||||
fun connect(endpoint: BridgeEndpoint) {
|
||||
runtime.connect(endpoint)
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.clawdbot.android
|
||||
package com.steipete.clawdis.node
|
||||
|
||||
import android.app.Application
|
||||
|
||||
+24
-25
@@ -1,4 +1,4 @@
|
||||
package com.clawdbot.android
|
||||
package com.steipete.clawdis.node
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
@@ -10,6 +10,7 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -29,7 +30,7 @@ class NodeForegroundService : Service() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
ensureChannel()
|
||||
val initial = buildNotification(title = "Clawdbot Node", text = "Starting…")
|
||||
val initial = buildNotification(title = "Clawdis Node", text = "Starting…")
|
||||
startForegroundWithTypes(notification = initial, requiresMic = false)
|
||||
|
||||
val runtime = (application as NodeApp).runtime
|
||||
@@ -44,7 +45,7 @@ class NodeForegroundService : Service() {
|
||||
) { status, server, connected, voiceMode, voiceListening ->
|
||||
Quint(status, server, connected, voiceMode, voiceListening)
|
||||
}.collect { (status, server, connected, voiceMode, voiceListening) ->
|
||||
val title = if (connected) "Clawdbot Node · Connected" else "Clawdbot Node"
|
||||
val title = if (connected) "Clawdis Node · Connected" else "Clawdis Node"
|
||||
val voiceSuffix =
|
||||
if (voiceMode == VoiceWakeMode.Always) {
|
||||
if (voiceListening) " · Voice Wake: Listening" else " · Voice Wake: Paused"
|
||||
@@ -84,6 +85,7 @@ class NodeForegroundService : Service() {
|
||||
override fun onBind(intent: Intent?) = null
|
||||
|
||||
private fun ensureChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val mgr = getSystemService(NotificationManager::class.java)
|
||||
val channel =
|
||||
NotificationChannel(
|
||||
@@ -91,38 +93,26 @@ class NodeForegroundService : Service() {
|
||||
"Connection",
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply {
|
||||
description = "Clawdbot node connection status"
|
||||
description = "Clawdis 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,
|
||||
)
|
||||
val flags =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
} else {
|
||||
PendingIntent.FLAG_UPDATE_CURRENT
|
||||
}
|
||||
val stopPending = PendingIntent.getService(this, 2, stopIntent, flags)
|
||||
|
||||
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)
|
||||
@@ -136,6 +126,11 @@ class NodeForegroundService : Service() {
|
||||
}
|
||||
|
||||
private fun startForegroundWithTypes(notification: Notification, requiresMic: Boolean) {
|
||||
if (Build.VERSION.SDK_INT < 29) {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
return
|
||||
}
|
||||
|
||||
if (didStartForeground && requiresMic == lastRequiresMic) {
|
||||
updateNotification(notification)
|
||||
return
|
||||
@@ -163,11 +158,15 @@ class NodeForegroundService : Service() {
|
||||
private const val CHANNEL_ID = "connection"
|
||||
private const val NOTIFICATION_ID = 1
|
||||
|
||||
private const val ACTION_STOP = "com.clawdbot.android.action.STOP"
|
||||
private const val ACTION_STOP = "com.steipete.clawdis.node.action.STOP"
|
||||
|
||||
fun start(context: Context) {
|
||||
val intent = Intent(context, NodeForegroundService::class.java)
|
||||
context.startForegroundService(intent)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
+116
-398
@@ -1,42 +1,34 @@
|
||||
package com.clawdbot.android
|
||||
package com.steipete.clawdis.node
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.LocationManager
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.clawdbot.android.chat.ChatController
|
||||
import com.clawdbot.android.chat.ChatMessage
|
||||
import com.clawdbot.android.chat.ChatPendingToolCall
|
||||
import com.clawdbot.android.chat.ChatSessionEntry
|
||||
import com.clawdbot.android.chat.OutgoingAttachment
|
||||
import com.clawdbot.android.bridge.BridgeDiscovery
|
||||
import com.clawdbot.android.bridge.BridgeEndpoint
|
||||
import com.clawdbot.android.bridge.BridgePairingClient
|
||||
import com.clawdbot.android.bridge.BridgeSession
|
||||
import com.clawdbot.android.node.CameraCaptureManager
|
||||
import com.clawdbot.android.node.LocationCaptureManager
|
||||
import com.clawdbot.android.BuildConfig
|
||||
import com.clawdbot.android.node.CanvasController
|
||||
import com.clawdbot.android.node.ScreenRecordManager
|
||||
import com.clawdbot.android.node.SmsManager
|
||||
import com.clawdbot.android.protocol.ClawdbotCapability
|
||||
import com.clawdbot.android.protocol.ClawdbotCameraCommand
|
||||
import com.clawdbot.android.protocol.ClawdbotCanvasA2UIAction
|
||||
import com.clawdbot.android.protocol.ClawdbotCanvasA2UICommand
|
||||
import com.clawdbot.android.protocol.ClawdbotCanvasCommand
|
||||
import com.clawdbot.android.protocol.ClawdbotScreenCommand
|
||||
import com.clawdbot.android.protocol.ClawdbotLocationCommand
|
||||
import com.clawdbot.android.protocol.ClawdbotSmsCommand
|
||||
import com.clawdbot.android.voice.TalkModeManager
|
||||
import com.clawdbot.android.voice.VoiceWakeManager
|
||||
import com.steipete.clawdis.node.chat.ChatController
|
||||
import com.steipete.clawdis.node.chat.ChatMessage
|
||||
import com.steipete.clawdis.node.chat.ChatPendingToolCall
|
||||
import com.steipete.clawdis.node.chat.ChatSessionEntry
|
||||
import com.steipete.clawdis.node.chat.OutgoingAttachment
|
||||
import com.steipete.clawdis.node.bridge.BridgeDiscovery
|
||||
import com.steipete.clawdis.node.bridge.BridgeEndpoint
|
||||
import com.steipete.clawdis.node.bridge.BridgePairingClient
|
||||
import com.steipete.clawdis.node.bridge.BridgeSession
|
||||
import com.steipete.clawdis.node.node.CameraCaptureManager
|
||||
import com.steipete.clawdis.node.node.CanvasController
|
||||
import com.steipete.clawdis.node.node.ScreenRecordManager
|
||||
import com.steipete.clawdis.node.protocol.ClawdisCapability
|
||||
import com.steipete.clawdis.node.protocol.ClawdisCameraCommand
|
||||
import com.steipete.clawdis.node.protocol.ClawdisCanvasA2UIAction
|
||||
import com.steipete.clawdis.node.protocol.ClawdisCanvasA2UICommand
|
||||
import com.steipete.clawdis.node.protocol.ClawdisCanvasCommand
|
||||
import com.steipete.clawdis.node.protocol.ClawdisScreenCommand
|
||||
import com.steipete.clawdis.node.voice.VoiceWakeManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -61,9 +53,7 @@ class NodeRuntime(context: Context) {
|
||||
val prefs = SecurePrefs(appContext)
|
||||
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)
|
||||
@@ -78,7 +68,7 @@ class NodeRuntime(context: Context) {
|
||||
payloadJson =
|
||||
buildJsonObject {
|
||||
put("message", JsonPrimitive(command))
|
||||
put("sessionKey", JsonPrimitive(mainSessionKey.value))
|
||||
put("sessionKey", JsonPrimitive("main"))
|
||||
put("thinking", JsonPrimitive(chatThinkingLevel.value))
|
||||
put("deliver", JsonPrimitive(false))
|
||||
}.toString(),
|
||||
@@ -93,15 +83,6 @@ class NodeRuntime(context: Context) {
|
||||
val voiceWakeStatusText: StateFlow<String>
|
||||
get() = voiceWake.statusText
|
||||
|
||||
val talkStatusText: StateFlow<String>
|
||||
get() = talkMode.statusText
|
||||
|
||||
val talkIsListening: StateFlow<Boolean>
|
||||
get() = talkMode.isListening
|
||||
|
||||
val talkIsSpeaking: StateFlow<Boolean>
|
||||
get() = talkMode.isSpeaking
|
||||
|
||||
private val discovery = BridgeDiscovery(appContext, scope = scope)
|
||||
val bridges: StateFlow<List<BridgeEndpoint>> = discovery.bridges
|
||||
val discoveryStatusText: StateFlow<String> = discovery.statusText
|
||||
@@ -112,9 +93,6 @@ class NodeRuntime(context: Context) {
|
||||
private val _statusText = MutableStateFlow("Offline")
|
||||
val statusText: StateFlow<String> = _statusText.asStateFlow()
|
||||
|
||||
private val _mainSessionKey = MutableStateFlow("main")
|
||||
val mainSessionKey: StateFlow<String> = _mainSessionKey.asStateFlow()
|
||||
|
||||
private val cameraHudSeq = AtomicLong(0)
|
||||
private val _cameraHud = MutableStateFlow<CameraHudState?>(null)
|
||||
val cameraHud: StateFlow<CameraHudState?> = _cameraHud.asStateFlow()
|
||||
@@ -122,23 +100,15 @@ class NodeRuntime(context: Context) {
|
||||
private val _cameraFlashToken = MutableStateFlow(0L)
|
||||
val cameraFlashToken: StateFlow<Long> = _cameraFlashToken.asStateFlow()
|
||||
|
||||
private val _screenRecordActive = MutableStateFlow(false)
|
||||
val screenRecordActive: StateFlow<Boolean> = _screenRecordActive.asStateFlow()
|
||||
|
||||
private val _serverName = MutableStateFlow<String?>(null)
|
||||
val serverName: StateFlow<String?> = _serverName.asStateFlow()
|
||||
|
||||
private val _remoteAddress = MutableStateFlow<String?>(null)
|
||||
val remoteAddress: StateFlow<String?> = _remoteAddress.asStateFlow()
|
||||
|
||||
private val _seamColorArgb = MutableStateFlow(DEFAULT_SEAM_COLOR_ARGB)
|
||||
val seamColorArgb: StateFlow<Long> = _seamColorArgb.asStateFlow()
|
||||
|
||||
private val _isForeground = MutableStateFlow(true)
|
||||
val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
|
||||
|
||||
private var lastAutoA2uiUrl: String? = null
|
||||
|
||||
private val session =
|
||||
BridgeSession(
|
||||
scope = scope,
|
||||
@@ -147,10 +117,7 @@ class NodeRuntime(context: Context) {
|
||||
_serverName.value = name
|
||||
_remoteAddress.value = remote
|
||||
_isConnected.value = true
|
||||
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
||||
scope.launch { refreshBrandingFromGateway() }
|
||||
scope.launch { refreshWakeWordsFromGateway() }
|
||||
maybeNavigateToA2uiOnConnect()
|
||||
},
|
||||
onDisconnected = { message -> handleSessionDisconnected(message) },
|
||||
onEvent = { event, payloadJson ->
|
||||
@@ -162,49 +129,25 @@ class NodeRuntime(context: Context) {
|
||||
)
|
||||
|
||||
private val chat = ChatController(scope = scope, session = session, json = json)
|
||||
private val talkMode: TalkModeManager by lazy {
|
||||
TalkModeManager(context = appContext, scope = scope).also { it.attachSession(session) }
|
||||
}
|
||||
|
||||
private fun handleSessionDisconnected(message: String) {
|
||||
_statusText.value = message
|
||||
_serverName.value = null
|
||||
_remoteAddress.value = null
|
||||
_isConnected.value = false
|
||||
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
||||
_mainSessionKey.value = "main"
|
||||
chat.onDisconnected(message)
|
||||
showLocalCanvasOnDisconnect()
|
||||
}
|
||||
|
||||
private fun maybeNavigateToA2uiOnConnect() {
|
||||
val a2uiUrl = 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<String> = prefs.instanceId
|
||||
val displayName: StateFlow<String> = prefs.displayName
|
||||
val cameraEnabled: StateFlow<Boolean> = prefs.cameraEnabled
|
||||
val locationMode: StateFlow<LocationMode> = prefs.locationMode
|
||||
val locationPreciseEnabled: StateFlow<Boolean> = prefs.locationPreciseEnabled
|
||||
val preventSleep: StateFlow<Boolean> = prefs.preventSleep
|
||||
val wakeWords: StateFlow<List<String>> = prefs.wakeWords
|
||||
val voiceWakeMode: StateFlow<VoiceWakeMode> = prefs.voiceWakeMode
|
||||
val talkEnabled: StateFlow<Boolean> = prefs.talkEnabled
|
||||
val manualEnabled: StateFlow<Boolean> = prefs.manualEnabled
|
||||
val manualHost: StateFlow<String> = prefs.manualHost
|
||||
val manualPort: StateFlow<Int> = prefs.manualPort
|
||||
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
|
||||
|
||||
private var didAutoConnect = false
|
||||
private var suppressWakeWordsSync = false
|
||||
@@ -255,13 +198,6 @@ class NodeRuntime(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
talkEnabled.collect { enabled ->
|
||||
talkMode.setEnabled(enabled)
|
||||
externalAudioCaptureActive.value = enabled
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch(Dispatchers.Default) {
|
||||
bridges.collect { list ->
|
||||
if (list.isNotEmpty()) {
|
||||
@@ -292,22 +228,6 @@ class NodeRuntime(context: Context) {
|
||||
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) {
|
||||
@@ -322,14 +242,6 @@ class NodeRuntime(context: Context) {
|
||||
prefs.setCameraEnabled(value)
|
||||
}
|
||||
|
||||
fun setLocationMode(mode: LocationMode) {
|
||||
prefs.setLocationMode(mode)
|
||||
}
|
||||
|
||||
fun setLocationPreciseEnabled(value: Boolean) {
|
||||
prefs.setLocationPreciseEnabled(value)
|
||||
}
|
||||
|
||||
fun setPreventSleep(value: Boolean) {
|
||||
prefs.setPreventSleep(value)
|
||||
}
|
||||
@@ -346,10 +258,6 @@ class NodeRuntime(context: Context) {
|
||||
prefs.setManualPort(value)
|
||||
}
|
||||
|
||||
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
||||
prefs.setCanvasDebugStatusEnabled(value)
|
||||
}
|
||||
|
||||
fun setWakeWords(words: List<String>) {
|
||||
prefs.setWakeWords(words)
|
||||
scheduleWakeWordsSyncIfNeeded()
|
||||
@@ -363,124 +271,63 @@ class NodeRuntime(context: Context) {
|
||||
prefs.setVoiceWakeMode(mode)
|
||||
}
|
||||
|
||||
fun setTalkEnabled(value: Boolean) {
|
||||
prefs.setTalkEnabled(value)
|
||||
}
|
||||
|
||||
private fun buildInvokeCommands(): List<String> =
|
||||
buildList {
|
||||
add(ClawdbotCanvasCommand.Present.rawValue)
|
||||
add(ClawdbotCanvasCommand.Hide.rawValue)
|
||||
add(ClawdbotCanvasCommand.Navigate.rawValue)
|
||||
add(ClawdbotCanvasCommand.Eval.rawValue)
|
||||
add(ClawdbotCanvasCommand.Snapshot.rawValue)
|
||||
add(ClawdbotCanvasA2UICommand.Push.rawValue)
|
||||
add(ClawdbotCanvasA2UICommand.PushJSONL.rawValue)
|
||||
add(ClawdbotCanvasA2UICommand.Reset.rawValue)
|
||||
add(ClawdbotScreenCommand.Record.rawValue)
|
||||
if (cameraEnabled.value) {
|
||||
add(ClawdbotCameraCommand.Snap.rawValue)
|
||||
add(ClawdbotCameraCommand.Clip.rawValue)
|
||||
}
|
||||
if (locationMode.value != LocationMode.Off) {
|
||||
add(ClawdbotLocationCommand.Get.rawValue)
|
||||
}
|
||||
if (sms.canSendSms()) {
|
||||
add(ClawdbotSmsCommand.Send.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildCapabilities(): List<String> =
|
||||
buildList {
|
||||
add(ClawdbotCapability.Canvas.rawValue)
|
||||
add(ClawdbotCapability.Screen.rawValue)
|
||||
if (cameraEnabled.value) add(ClawdbotCapability.Camera.rawValue)
|
||||
if (sms.canSendSms()) add(ClawdbotCapability.Sms.rawValue)
|
||||
if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
|
||||
add(ClawdbotCapability.VoiceWake.rawValue)
|
||||
}
|
||||
if (locationMode.value != LocationMode.Off) {
|
||||
add(ClawdbotCapability.Location.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildPairingHello(token: String?): BridgePairingClient.Hello {
|
||||
val modelIdentifier = listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
||||
.joinToString(" ")
|
||||
.trim()
|
||||
.ifEmpty { null }
|
||||
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
|
||||
val advertisedVersion =
|
||||
if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
|
||||
"$versionName-dev"
|
||||
} else {
|
||||
versionName
|
||||
}
|
||||
return BridgePairingClient.Hello(
|
||||
nodeId = instanceId.value,
|
||||
displayName = displayName.value,
|
||||
token = token,
|
||||
platform = "Android",
|
||||
version = advertisedVersion,
|
||||
deviceFamily = "Android",
|
||||
modelIdentifier = modelIdentifier,
|
||||
caps = buildCapabilities(),
|
||||
commands = buildInvokeCommands(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildSessionHello(token: String?): BridgeSession.Hello {
|
||||
val modelIdentifier = listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
||||
.joinToString(" ")
|
||||
.trim()
|
||||
.ifEmpty { null }
|
||||
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
|
||||
val advertisedVersion =
|
||||
if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
|
||||
"$versionName-dev"
|
||||
} else {
|
||||
versionName
|
||||
}
|
||||
return BridgeSession.Hello(
|
||||
nodeId = instanceId.value,
|
||||
displayName = displayName.value,
|
||||
token = token,
|
||||
platform = "Android",
|
||||
version = advertisedVersion,
|
||||
deviceFamily = "Android",
|
||||
modelIdentifier = modelIdentifier,
|
||||
caps = buildCapabilities(),
|
||||
commands = buildInvokeCommands(),
|
||||
)
|
||||
}
|
||||
|
||||
fun refreshBridgeHello() {
|
||||
scope.launch {
|
||||
if (!_isConnected.value) return@launch
|
||||
val token = prefs.loadBridgeToken()
|
||||
if (token.isNullOrBlank()) return@launch
|
||||
session.updateHello(buildSessionHello(token))
|
||||
}
|
||||
}
|
||||
|
||||
fun connect(endpoint: BridgeEndpoint) {
|
||||
scope.launch {
|
||||
_statusText.value = "Connecting…"
|
||||
val storedToken = prefs.loadBridgeToken()
|
||||
val modelIdentifier = listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
||||
.joinToString(" ")
|
||||
.trim()
|
||||
.ifEmpty { null }
|
||||
|
||||
val invokeCommands =
|
||||
buildList {
|
||||
add(ClawdisCanvasCommand.Present.rawValue)
|
||||
add(ClawdisCanvasCommand.Hide.rawValue)
|
||||
add(ClawdisCanvasCommand.Navigate.rawValue)
|
||||
add(ClawdisCanvasCommand.Eval.rawValue)
|
||||
add(ClawdisCanvasCommand.Snapshot.rawValue)
|
||||
add(ClawdisCanvasA2UICommand.Push.rawValue)
|
||||
add(ClawdisCanvasA2UICommand.PushJSONL.rawValue)
|
||||
add(ClawdisCanvasA2UICommand.Reset.rawValue)
|
||||
add(ClawdisScreenCommand.Record.rawValue)
|
||||
if (cameraEnabled.value) {
|
||||
add(ClawdisCameraCommand.Snap.rawValue)
|
||||
add(ClawdisCameraCommand.Clip.rawValue)
|
||||
}
|
||||
}
|
||||
val resolved =
|
||||
if (storedToken.isNullOrBlank()) {
|
||||
_statusText.value = "Pairing…"
|
||||
val caps = buildList {
|
||||
add(ClawdisCapability.Canvas.rawValue)
|
||||
add(ClawdisCapability.Screen.rawValue)
|
||||
if (cameraEnabled.value) add(ClawdisCapability.Camera.rawValue)
|
||||
if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
|
||||
add(ClawdisCapability.VoiceWake.rawValue)
|
||||
}
|
||||
}
|
||||
BridgePairingClient().pairAndHello(
|
||||
endpoint = endpoint,
|
||||
hello = buildPairingHello(token = null),
|
||||
hello =
|
||||
BridgePairingClient.Hello(
|
||||
nodeId = instanceId.value,
|
||||
displayName = displayName.value,
|
||||
token = null,
|
||||
platform = "Android",
|
||||
version = "dev",
|
||||
deviceFamily = "Android",
|
||||
modelIdentifier = modelIdentifier,
|
||||
caps = caps,
|
||||
commands = invokeCommands,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
BridgePairingClient.PairResult(ok = true, token = storedToken.trim())
|
||||
}
|
||||
|
||||
if (!resolved.ok || resolved.token.isNullOrBlank()) {
|
||||
val errorMessage = resolved.error?.trim().orEmpty().ifEmpty { "pairing required" }
|
||||
_statusText.value = "Failed: $errorMessage"
|
||||
_statusText.value = "Failed: pairing required"
|
||||
return@launch
|
||||
}
|
||||
|
||||
@@ -488,7 +335,26 @@ class NodeRuntime(context: Context) {
|
||||
prefs.saveBridgeToken(authToken)
|
||||
session.connect(
|
||||
endpoint = endpoint,
|
||||
hello = buildSessionHello(token = authToken),
|
||||
hello =
|
||||
BridgeSession.Hello(
|
||||
nodeId = instanceId.value,
|
||||
displayName = displayName.value,
|
||||
token = authToken,
|
||||
platform = "Android",
|
||||
version = "dev",
|
||||
deviceFamily = "Android",
|
||||
modelIdentifier = modelIdentifier,
|
||||
caps =
|
||||
buildList {
|
||||
add(ClawdisCapability.Canvas.rawValue)
|
||||
add(ClawdisCapability.Screen.rawValue)
|
||||
if (cameraEnabled.value) add(ClawdisCapability.Camera.rawValue)
|
||||
if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
|
||||
add(ClawdisCapability.VoiceWake.rawValue)
|
||||
}
|
||||
},
|
||||
commands = invokeCommands,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -500,27 +366,6 @@ class NodeRuntime(context: Context) {
|
||||
)
|
||||
}
|
||||
|
||||
private fun hasFineLocationPermission(): Boolean {
|
||||
return (
|
||||
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
|
||||
private fun hasCoarseLocationPermission(): Boolean {
|
||||
return (
|
||||
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_COARSE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
|
||||
private fun hasBackgroundLocationPermission(): Boolean {
|
||||
return (
|
||||
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
|
||||
fun connectManual() {
|
||||
val host = manualHost.value.trim()
|
||||
val port = manualPort.value
|
||||
@@ -551,7 +396,8 @@ class NodeRuntime(context: Context) {
|
||||
val actionId = (userActionObj["id"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty {
|
||||
java.util.UUID.randomUUID().toString()
|
||||
}
|
||||
val name = ClawdbotCanvasA2UIAction.extractActionName(userActionObj) ?: return@launch
|
||||
val name = (userActionObj["name"] as? JsonPrimitive)?.content?.trim().orEmpty()
|
||||
if (name.isEmpty()) return@launch
|
||||
|
||||
val surfaceId =
|
||||
(userActionObj["surfaceId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "main" }
|
||||
@@ -561,7 +407,7 @@ class NodeRuntime(context: Context) {
|
||||
|
||||
val sessionKey = "main"
|
||||
val message =
|
||||
ClawdbotCanvasA2UIAction.formatAgentMessage(
|
||||
ClawdisCanvasA2UIAction.formatAgentMessage(
|
||||
actionName = name,
|
||||
sessionKey = sessionKey,
|
||||
surfaceId = surfaceId,
|
||||
@@ -595,7 +441,7 @@ class NodeRuntime(context: Context) {
|
||||
|
||||
try {
|
||||
canvas.eval(
|
||||
ClawdbotCanvasA2UIAction.jsDispatchA2UIActionStatus(
|
||||
ClawdisCanvasA2UIAction.jsDispatchA2UIActionStatus(
|
||||
actionId = actionId,
|
||||
ok = connected && error == null,
|
||||
error = error,
|
||||
@@ -649,7 +495,6 @@ class NodeRuntime(context: Context) {
|
||||
return
|
||||
}
|
||||
|
||||
talkMode.handleBridgeEvent(event, payloadJson)
|
||||
chat.handleBridgeEvent(event, payloadJson)
|
||||
}
|
||||
|
||||
@@ -691,31 +536,12 @@ class NodeRuntime(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshBrandingFromGateway() {
|
||||
if (!_isConnected.value) return
|
||||
try {
|
||||
val res = session.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 rawMainKey = sessionCfg?.get("mainKey").asStringOrNull()?.trim()
|
||||
_mainSessionKey.value = rawMainKey?.takeIf { it.isNotEmpty() } ?: "main"
|
||||
|
||||
val parsed = parseHexColorArgb(raw)
|
||||
_seamColorArgb.value = parsed ?: DEFAULT_SEAM_COLOR_ARGB
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleInvoke(command: String, paramsJson: String?): BridgeSession.InvokeResult {
|
||||
if (
|
||||
command.startsWith(ClawdbotCanvasCommand.NamespacePrefix) ||
|
||||
command.startsWith(ClawdbotCanvasA2UICommand.NamespacePrefix) ||
|
||||
command.startsWith(ClawdbotCameraCommand.NamespacePrefix) ||
|
||||
command.startsWith(ClawdbotScreenCommand.NamespacePrefix)
|
||||
command.startsWith(ClawdisCanvasCommand.NamespacePrefix) ||
|
||||
command.startsWith(ClawdisCanvasA2UICommand.NamespacePrefix) ||
|
||||
command.startsWith(ClawdisCameraCommand.NamespacePrefix) ||
|
||||
command.startsWith(ClawdisScreenCommand.NamespacePrefix)
|
||||
) {
|
||||
if (!isForeground.value) {
|
||||
return BridgeSession.InvokeResult.error(
|
||||
@@ -724,34 +550,26 @@ class NodeRuntime(context: Context) {
|
||||
)
|
||||
}
|
||||
}
|
||||
if (command.startsWith(ClawdbotCameraCommand.NamespacePrefix) && !cameraEnabled.value) {
|
||||
if (command.startsWith(ClawdisCameraCommand.NamespacePrefix) && !cameraEnabled.value) {
|
||||
return BridgeSession.InvokeResult.error(
|
||||
code = "CAMERA_DISABLED",
|
||||
message = "CAMERA_DISABLED: enable Camera in Settings",
|
||||
)
|
||||
}
|
||||
if (command.startsWith(ClawdbotLocationCommand.NamespacePrefix) &&
|
||||
locationMode.value == LocationMode.Off
|
||||
) {
|
||||
return BridgeSession.InvokeResult.error(
|
||||
code = "LOCATION_DISABLED",
|
||||
message = "LOCATION_DISABLED: enable Location in Settings",
|
||||
)
|
||||
}
|
||||
|
||||
return when (command) {
|
||||
ClawdbotCanvasCommand.Present.rawValue -> {
|
||||
ClawdisCanvasCommand.Present.rawValue -> {
|
||||
val url = CanvasController.parseNavigateUrl(paramsJson)
|
||||
canvas.navigate(url)
|
||||
BridgeSession.InvokeResult.ok(null)
|
||||
}
|
||||
ClawdbotCanvasCommand.Hide.rawValue -> BridgeSession.InvokeResult.ok(null)
|
||||
ClawdbotCanvasCommand.Navigate.rawValue -> {
|
||||
ClawdisCanvasCommand.Hide.rawValue -> BridgeSession.InvokeResult.ok(null)
|
||||
ClawdisCanvasCommand.Navigate.rawValue -> {
|
||||
val url = CanvasController.parseNavigateUrl(paramsJson)
|
||||
canvas.navigate(url)
|
||||
BridgeSession.InvokeResult.ok(null)
|
||||
}
|
||||
ClawdbotCanvasCommand.Eval.rawValue -> {
|
||||
ClawdisCanvasCommand.Eval.rawValue -> {
|
||||
val js =
|
||||
CanvasController.parseEvalJs(paramsJson)
|
||||
?: return BridgeSession.InvokeResult.error(
|
||||
@@ -769,7 +587,7 @@ class NodeRuntime(context: Context) {
|
||||
}
|
||||
BridgeSession.InvokeResult.ok("""{"result":${result.toJsonString()}}""")
|
||||
}
|
||||
ClawdbotCanvasCommand.Snapshot.rawValue -> {
|
||||
ClawdisCanvasCommand.Snapshot.rawValue -> {
|
||||
val snapshotParams = CanvasController.parseSnapshotParams(paramsJson)
|
||||
val base64 =
|
||||
try {
|
||||
@@ -786,7 +604,7 @@ class NodeRuntime(context: Context) {
|
||||
}
|
||||
BridgeSession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""")
|
||||
}
|
||||
ClawdbotCanvasA2UICommand.Reset.rawValue -> {
|
||||
ClawdisCanvasA2UICommand.Reset.rawValue -> {
|
||||
val a2uiUrl = resolveA2uiHostUrl()
|
||||
?: return BridgeSession.InvokeResult.error(
|
||||
code = "A2UI_HOST_NOT_CONFIGURED",
|
||||
@@ -802,7 +620,7 @@ class NodeRuntime(context: Context) {
|
||||
val res = canvas.eval(a2uiResetJS)
|
||||
BridgeSession.InvokeResult.ok(res)
|
||||
}
|
||||
ClawdbotCanvasA2UICommand.Push.rawValue, ClawdbotCanvasA2UICommand.PushJSONL.rawValue -> {
|
||||
ClawdisCanvasA2UICommand.Push.rawValue, ClawdisCanvasA2UICommand.PushJSONL.rawValue -> {
|
||||
val messages =
|
||||
try {
|
||||
decodeA2uiMessages(command, paramsJson)
|
||||
@@ -825,7 +643,7 @@ class NodeRuntime(context: Context) {
|
||||
val res = canvas.eval(js)
|
||||
BridgeSession.InvokeResult.ok(res)
|
||||
}
|
||||
ClawdbotCameraCommand.Snap.rawValue -> {
|
||||
ClawdisCameraCommand.Snap.rawValue -> {
|
||||
showCameraHud(message = "Taking photo…", kind = CameraHudKind.Photo)
|
||||
triggerCameraFlash()
|
||||
val res =
|
||||
@@ -839,7 +657,7 @@ class NodeRuntime(context: Context) {
|
||||
showCameraHud(message = "Photo captured", kind = CameraHudKind.Success, autoHideMs = 1600)
|
||||
BridgeSession.InvokeResult.ok(res.payloadJson)
|
||||
}
|
||||
ClawdbotCameraCommand.Clip.rawValue -> {
|
||||
ClawdisCameraCommand.Clip.rawValue -> {
|
||||
val includeAudio = paramsJson?.contains("\"includeAudio\":true") != false
|
||||
if (includeAudio) externalAudioCaptureActive.value = true
|
||||
try {
|
||||
@@ -858,85 +676,15 @@ class NodeRuntime(context: Context) {
|
||||
if (includeAudio) externalAudioCaptureActive.value = false
|
||||
}
|
||||
}
|
||||
ClawdbotLocationCommand.Get.rawValue -> {
|
||||
val mode = locationMode.value
|
||||
if (!isForeground.value && mode != LocationMode.Always) {
|
||||
return BridgeSession.InvokeResult.error(
|
||||
code = "LOCATION_BACKGROUND_UNAVAILABLE",
|
||||
message = "LOCATION_BACKGROUND_UNAVAILABLE: background location requires Always",
|
||||
)
|
||||
}
|
||||
if (!hasFineLocationPermission() && !hasCoarseLocationPermission()) {
|
||||
return BridgeSession.InvokeResult.error(
|
||||
code = "LOCATION_PERMISSION_REQUIRED",
|
||||
message = "LOCATION_PERMISSION_REQUIRED: grant Location permission",
|
||||
)
|
||||
}
|
||||
if (!isForeground.value && mode == LocationMode.Always && !hasBackgroundLocationPermission()) {
|
||||
return BridgeSession.InvokeResult.error(
|
||||
code = "LOCATION_PERMISSION_REQUIRED",
|
||||
message = "LOCATION_PERMISSION_REQUIRED: enable Always in system Settings",
|
||||
)
|
||||
}
|
||||
val (maxAgeMs, timeoutMs, desiredAccuracy) = parseLocationParams(paramsJson)
|
||||
val preciseEnabled = locationPreciseEnabled.value
|
||||
val accuracy =
|
||||
when (desiredAccuracy) {
|
||||
"precise" -> if (preciseEnabled && hasFineLocationPermission()) "precise" else "balanced"
|
||||
"coarse" -> "coarse"
|
||||
else -> if (preciseEnabled && hasFineLocationPermission()) "precise" else "balanced"
|
||||
ClawdisScreenCommand.Record.rawValue -> {
|
||||
val res =
|
||||
try {
|
||||
screenRecorder.record(paramsJson)
|
||||
} catch (err: Throwable) {
|
||||
val (code, message) = invokeErrorFromThrowable(err)
|
||||
return BridgeSession.InvokeResult.error(code = code, message = message)
|
||||
}
|
||||
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",
|
||||
)
|
||||
BridgeSession.InvokeResult.ok(payload.payloadJson)
|
||||
} catch (err: TimeoutCancellationException) {
|
||||
BridgeSession.InvokeResult.error(
|
||||
code = "LOCATION_TIMEOUT",
|
||||
message = "LOCATION_TIMEOUT: no fix in time",
|
||||
)
|
||||
} catch (err: Throwable) {
|
||||
val message = err.message ?: "LOCATION_UNAVAILABLE: no fix"
|
||||
BridgeSession.InvokeResult.error(code = "LOCATION_UNAVAILABLE", message = message)
|
||||
}
|
||||
}
|
||||
ClawdbotScreenCommand.Record.rawValue -> {
|
||||
// Status pill mirrors screen recording state so it stays visible without overlay stacking.
|
||||
_screenRecordActive.value = true
|
||||
try {
|
||||
val res =
|
||||
try {
|
||||
screenRecorder.record(paramsJson)
|
||||
} catch (err: Throwable) {
|
||||
val (code, message) = invokeErrorFromThrowable(err)
|
||||
return BridgeSession.InvokeResult.error(code = code, message = message)
|
||||
}
|
||||
BridgeSession.InvokeResult.ok(res.payloadJson)
|
||||
} finally {
|
||||
_screenRecordActive.value = false
|
||||
}
|
||||
}
|
||||
ClawdbotSmsCommand.Send.rawValue -> {
|
||||
val res = sms.send(paramsJson)
|
||||
if (res.ok) {
|
||||
BridgeSession.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"
|
||||
BridgeSession.InvokeResult.error(code = code, message = error)
|
||||
}
|
||||
BridgeSession.InvokeResult.ok(res.payloadJson)
|
||||
}
|
||||
else ->
|
||||
BridgeSession.InvokeResult.error(
|
||||
@@ -975,30 +723,11 @@ class NodeRuntime(context: Context) {
|
||||
return code to "$code: $message"
|
||||
}
|
||||
|
||||
private fun parseLocationParams(paramsJson: String?): Triple<Long?, Long, String?> {
|
||||
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)
|
||||
}
|
||||
|
||||
private fun resolveA2uiHostUrl(): String? {
|
||||
val raw = session.currentCanvasHostUrl()?.trim().orEmpty()
|
||||
if (raw.isBlank()) return null
|
||||
val base = raw.trimEnd('/')
|
||||
return "${base}/__clawdbot__/a2ui/?platform=android"
|
||||
return "${base}/__clawdis__/a2ui/"
|
||||
}
|
||||
|
||||
private suspend fun ensureA2uiReady(a2uiUrl: String): Boolean {
|
||||
@@ -1033,7 +762,7 @@ class NodeRuntime(context: Context) {
|
||||
val jsonlField = (obj["jsonl"] as? JsonPrimitive)?.content?.trim().orEmpty()
|
||||
val hasMessagesArray = obj["messages"] is JsonArray
|
||||
|
||||
if (command == ClawdbotCanvasA2UICommand.PushJSONL.rawValue || (!hasMessagesArray && jsonlField.isNotBlank())) {
|
||||
if (command == ClawdisCanvasA2UICommand.PushJSONL.rawValue || (!hasMessagesArray && jsonlField.isNotBlank())) {
|
||||
val jsonl = jsonlField
|
||||
if (jsonl.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: jsonl required")
|
||||
val messages =
|
||||
@@ -1084,13 +813,11 @@ class NodeRuntime(context: Context) {
|
||||
|
||||
private data class Quad<A, B, C, D>(val first: A, val second: B, val third: C, val fourth: D)
|
||||
|
||||
private const val DEFAULT_SEAM_COLOR_ARGB: Long = 0xFF4F7A9A
|
||||
|
||||
private const val a2uiReadyCheckJS: String =
|
||||
"""
|
||||
(() => {
|
||||
try {
|
||||
return !!globalThis.clawdbotA2UI && typeof globalThis.clawdbotA2UI.applyMessages === 'function';
|
||||
return !!globalThis.clawdisA2UI && typeof globalThis.clawdisA2UI.applyMessages === 'function';
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
@@ -1101,8 +828,8 @@ private const val a2uiResetJS: String =
|
||||
"""
|
||||
(() => {
|
||||
try {
|
||||
if (!globalThis.clawdbotA2UI) return { ok: false, error: "missing clawdbotA2UI" };
|
||||
return globalThis.clawdbotA2UI.reset();
|
||||
if (!globalThis.clawdisA2UI) return { ok: false, error: "missing clawdisA2UI" };
|
||||
return globalThis.clawdisA2UI.reset();
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e?.message ?? e) };
|
||||
}
|
||||
@@ -1113,9 +840,9 @@ private fun a2uiApplyMessagesJS(messagesJson: String): String {
|
||||
return """
|
||||
(() => {
|
||||
try {
|
||||
if (!globalThis.clawdbotA2UI) return { ok: false, error: "missing clawdbotA2UI" };
|
||||
if (!globalThis.clawdisA2UI) return { ok: false, error: "missing clawdisA2UI" };
|
||||
const messages = $messagesJson;
|
||||
return globalThis.clawdbotA2UI.applyMessages(messages);
|
||||
return globalThis.clawdisA2UI.applyMessages(messages);
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e?.message ?? e) };
|
||||
}
|
||||
@@ -1140,12 +867,3 @@ private fun JsonElement?.asStringOrNull(): String? =
|
||||
is JsonPrimitive -> content
|
||||
else -> null
|
||||
}
|
||||
|
||||
private 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
|
||||
}
|
||||
+2
-3
@@ -1,4 +1,4 @@
|
||||
package com.clawdbot.android
|
||||
package com.steipete.clawdis.node
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.Intent
|
||||
@@ -115,7 +115,7 @@ class PermissionRequester(private val activity: ComponentActivity) {
|
||||
|
||||
private fun buildRationaleMessage(permissions: List<String>): String {
|
||||
val labels = permissions.map { permissionLabel(it) }
|
||||
return "Clawdbot needs ${labels.joinToString(", ")} permissions to continue."
|
||||
return "Clawdis needs ${labels.joinToString(", ")} to capture camera media."
|
||||
}
|
||||
|
||||
private fun buildSettingsMessage(permissions: List<String>): String {
|
||||
@@ -127,7 +127,6 @@ class PermissionRequester(private val activity: ComponentActivity) {
|
||||
when (permission) {
|
||||
Manifest.permission.CAMERA -> "Camera"
|
||||
Manifest.permission.RECORD_AUDIO -> "Microphone"
|
||||
Manifest.permission.SEND_SMS -> "SMS"
|
||||
else -> permission
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package com.clawdbot.android
|
||||
package com.steipete.clawdis.node
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
@@ -55,7 +55,7 @@ class ScreenCaptureRequester(private val activity: ComponentActivity) {
|
||||
suspendCancellableCoroutine { cont ->
|
||||
AlertDialog.Builder(activity)
|
||||
.setTitle("Screen recording required")
|
||||
.setMessage("Clawdbot needs to record the screen for this command.")
|
||||
.setMessage("Clawdis needs to record the screen for this command.")
|
||||
.setPositiveButton("Continue") { _, _ -> cont.resume(true) }
|
||||
.setNegativeButton("Not now") { _, _ -> cont.resume(false) }
|
||||
.setOnCancelListener { cont.resume(false) }
|
||||
+15
-51
@@ -1,9 +1,8 @@
|
||||
@file:Suppress("DEPRECATION")
|
||||
|
||||
package com.clawdbot.android
|
||||
package com.steipete.clawdis.node
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.edit
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -31,7 +30,7 @@ class SecurePrefs(context: Context) {
|
||||
private val prefs =
|
||||
EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"clawdbot.node.secure",
|
||||
"clawdis.node.secure",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
@@ -47,14 +46,6 @@ class SecurePrefs(context: Context) {
|
||||
private val _cameraEnabled = MutableStateFlow(prefs.getBoolean("camera.enabled", true))
|
||||
val cameraEnabled: StateFlow<Boolean> = _cameraEnabled
|
||||
|
||||
private val _locationMode =
|
||||
MutableStateFlow(LocationMode.fromRawValue(prefs.getString("location.enabledMode", "off")))
|
||||
val locationMode: StateFlow<LocationMode> = _locationMode
|
||||
|
||||
private val _locationPreciseEnabled =
|
||||
MutableStateFlow(prefs.getBoolean("location.preciseEnabled", true))
|
||||
val locationPreciseEnabled: StateFlow<Boolean> = _locationPreciseEnabled
|
||||
|
||||
private val _preventSleep = MutableStateFlow(prefs.getBoolean("screen.preventSleep", true))
|
||||
val preventSleep: StateFlow<Boolean> = _preventSleep
|
||||
|
||||
@@ -71,72 +62,50 @@ class SecurePrefs(context: Context) {
|
||||
MutableStateFlow(prefs.getString("bridge.lastDiscoveredStableId", "")!!)
|
||||
val lastDiscoveredStableId: StateFlow<String> = _lastDiscoveredStableId
|
||||
|
||||
private val _canvasDebugStatusEnabled =
|
||||
MutableStateFlow(prefs.getBoolean("canvas.debugStatusEnabled", false))
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = _canvasDebugStatusEnabled
|
||||
|
||||
private val _wakeWords = MutableStateFlow(loadWakeWords())
|
||||
val wakeWords: StateFlow<List<String>> = _wakeWords
|
||||
|
||||
private val _voiceWakeMode = MutableStateFlow(loadVoiceWakeMode())
|
||||
val voiceWakeMode: StateFlow<VoiceWakeMode> = _voiceWakeMode
|
||||
|
||||
private val _talkEnabled = MutableStateFlow(prefs.getBoolean("talk.enabled", false))
|
||||
val talkEnabled: StateFlow<Boolean> = _talkEnabled
|
||||
|
||||
fun setLastDiscoveredStableId(value: String) {
|
||||
val trimmed = value.trim()
|
||||
prefs.edit { putString("bridge.lastDiscoveredStableId", trimmed) }
|
||||
prefs.edit().putString("bridge.lastDiscoveredStableId", trimmed).apply()
|
||||
_lastDiscoveredStableId.value = trimmed
|
||||
}
|
||||
|
||||
fun setDisplayName(value: String) {
|
||||
val trimmed = value.trim()
|
||||
prefs.edit { putString(displayNameKey, trimmed) }
|
||||
prefs.edit().putString(displayNameKey, trimmed).apply()
|
||||
_displayName.value = trimmed
|
||||
}
|
||||
|
||||
fun setCameraEnabled(value: Boolean) {
|
||||
prefs.edit { putBoolean("camera.enabled", value) }
|
||||
prefs.edit().putBoolean("camera.enabled", value).apply()
|
||||
_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) }
|
||||
prefs.edit().putBoolean("screen.preventSleep", value).apply()
|
||||
_preventSleep.value = value
|
||||
}
|
||||
|
||||
fun setManualEnabled(value: Boolean) {
|
||||
prefs.edit { putBoolean("bridge.manual.enabled", value) }
|
||||
prefs.edit().putBoolean("bridge.manual.enabled", value).apply()
|
||||
_manualEnabled.value = value
|
||||
}
|
||||
|
||||
fun setManualHost(value: String) {
|
||||
val trimmed = value.trim()
|
||||
prefs.edit { putString("bridge.manual.host", trimmed) }
|
||||
prefs.edit().putString("bridge.manual.host", trimmed).apply()
|
||||
_manualHost.value = trimmed
|
||||
}
|
||||
|
||||
fun setManualPort(value: Int) {
|
||||
prefs.edit { putInt("bridge.manual.port", value) }
|
||||
prefs.edit().putInt("bridge.manual.port", value).apply()
|
||||
_manualPort.value = value
|
||||
}
|
||||
|
||||
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
||||
prefs.edit { putBoolean("canvas.debugStatusEnabled", value) }
|
||||
_canvasDebugStatusEnabled.value = value
|
||||
}
|
||||
|
||||
fun loadBridgeToken(): String? {
|
||||
val key = "bridge.token.${_instanceId.value}"
|
||||
return prefs.getString(key, null)
|
||||
@@ -144,14 +113,14 @@ class SecurePrefs(context: Context) {
|
||||
|
||||
fun saveBridgeToken(token: String) {
|
||||
val key = "bridge.token.${_instanceId.value}"
|
||||
prefs.edit { putString(key, token.trim()) }
|
||||
prefs.edit().putString(key, token.trim()).apply()
|
||||
}
|
||||
|
||||
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) }
|
||||
prefs.edit().putString("node.instanceId", fresh).apply()
|
||||
return fresh
|
||||
}
|
||||
|
||||
@@ -162,7 +131,7 @@ class SecurePrefs(context: Context) {
|
||||
val candidate = DeviceNames.bestDefaultNodeName(context).trim()
|
||||
val resolved = candidate.ifEmpty { "Android Node" }
|
||||
|
||||
prefs.edit { putString(displayNameKey, resolved) }
|
||||
prefs.edit().putString(displayNameKey, resolved).apply()
|
||||
return resolved
|
||||
}
|
||||
|
||||
@@ -170,27 +139,22 @@ class SecurePrefs(context: Context) {
|
||||
val sanitized = WakeWords.sanitize(words, defaultWakeWords)
|
||||
val encoded =
|
||||
JsonArray(sanitized.map { JsonPrimitive(it) }).toString()
|
||||
prefs.edit { putString("voiceWake.triggerWords", encoded) }
|
||||
prefs.edit().putString("voiceWake.triggerWords", encoded).apply()
|
||||
_wakeWords.value = sanitized
|
||||
}
|
||||
|
||||
fun setVoiceWakeMode(mode: VoiceWakeMode) {
|
||||
prefs.edit { putString(voiceWakeModeKey, mode.rawValue) }
|
||||
prefs.edit().putString(voiceWakeModeKey, mode.rawValue).apply()
|
||||
_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) }
|
||||
prefs.edit().putString(voiceWakeModeKey, resolved.rawValue).apply()
|
||||
}
|
||||
|
||||
return resolved
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.clawdbot.android
|
||||
package com.steipete.clawdis.node
|
||||
|
||||
enum class VoiceWakeMode(val rawValue: String) {
|
||||
Off("off"),
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.clawdbot.android
|
||||
package com.steipete.clawdis.node
|
||||
|
||||
object WakeWords {
|
||||
const val maxWords: Int = 32
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.clawdbot.android.bridge
|
||||
package com.steipete.clawdis.node.bridge
|
||||
|
||||
object BonjourEscapes {
|
||||
fun decode(input: String): String {
|
||||
+21
-60
@@ -1,4 +1,4 @@
|
||||
package com.clawdbot.android.bridge
|
||||
package com.steipete.clawdis.node.bridge
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
@@ -6,6 +6,7 @@ import android.net.DnsResolver
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.nsd.NsdManager
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.CancellationSignal
|
||||
import android.util.Log
|
||||
import java.io.IOException
|
||||
@@ -43,7 +44,6 @@ import org.xbill.DNS.Type
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
class BridgeDiscovery(
|
||||
context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
@@ -51,9 +51,9 @@ class BridgeDiscovery(
|
||||
private val nsd = context.getSystemService(NsdManager::class.java)
|
||||
private val connectivity = context.getSystemService(ConnectivityManager::class.java)
|
||||
private val dns = DnsResolver.getInstance()
|
||||
private val serviceType = "_clawdbot-bridge._tcp."
|
||||
private val wideAreaDomain = "clawdbot.internal."
|
||||
private val logTag = "Clawdbot/BridgeDiscovery"
|
||||
private val serviceType = "_clawdis-bridge._tcp."
|
||||
private val wideAreaDomain = "clawdis.internal."
|
||||
private val logTag = "Clawdis/BridgeDiscovery"
|
||||
|
||||
private val localById = ConcurrentHashMap<String, BridgeEndpoint>()
|
||||
private val unicastById = ConcurrentHashMap<String, BridgeEndpoint>()
|
||||
@@ -130,36 +130,20 @@ class BridgeDiscovery(
|
||||
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
|
||||
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 bridgePort = txtInt(resolved, "bridgePort")
|
||||
val canvasPort = txtInt(resolved, "canvasPort")
|
||||
val id = stableId(serviceName, "local.")
|
||||
localById[id] =
|
||||
BridgeEndpoint(
|
||||
stableId = id,
|
||||
name = displayName,
|
||||
host = host,
|
||||
port = port,
|
||||
lanHost = lanHost,
|
||||
tailnetDns = tailnetDns,
|
||||
gatewayPort = gatewayPort,
|
||||
bridgePort = bridgePort,
|
||||
canvasPort = canvasPort,
|
||||
)
|
||||
publish()
|
||||
}
|
||||
},
|
||||
)
|
||||
val rawServiceName = resolved.serviceName
|
||||
val serviceName = BonjourEscapes.decode(rawServiceName)
|
||||
val displayName = BonjourEscapes.decode(txt(resolved, "displayName") ?: serviceName)
|
||||
val id = stableId(serviceName, "local.")
|
||||
localById[id] = BridgeEndpoint(stableId = id, name = displayName, host = host, port = port)
|
||||
publish()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun publish() {
|
||||
@@ -197,6 +181,7 @@ class BridgeDiscovery(
|
||||
}
|
||||
|
||||
private fun txt(info: NsdServiceInfo, key: String): String? {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) return null
|
||||
val bytes = info.attributes[key] ?: return null
|
||||
return try {
|
||||
String(bytes, Charsets.UTF_8).trim().ifEmpty { null }
|
||||
@@ -205,10 +190,6 @@ class BridgeDiscovery(
|
||||
}
|
||||
}
|
||||
|
||||
private fun txtInt(info: NsdServiceInfo, key: String): Int? {
|
||||
return txt(info, key)?.toIntOrNull()
|
||||
}
|
||||
|
||||
private suspend fun refreshUnicast(domain: String) {
|
||||
val ptrName = "${serviceType}${domain}"
|
||||
val ptrMsg = lookupUnicastMessage(ptrName, Type.PTR) ?: return
|
||||
@@ -247,24 +228,8 @@ class BridgeDiscovery(
|
||||
}
|
||||
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 bridgePort = txtIntValue(txt, "bridgePort")
|
||||
val canvasPort = txtIntValue(txt, "canvasPort")
|
||||
val id = stableId(instanceName, domain)
|
||||
next[id] =
|
||||
BridgeEndpoint(
|
||||
stableId = id,
|
||||
name = displayName,
|
||||
host = host,
|
||||
port = port,
|
||||
lanHost = lanHost,
|
||||
tailnetDns = tailnetDns,
|
||||
gatewayPort = gatewayPort,
|
||||
bridgePort = bridgePort,
|
||||
canvasPort = canvasPort,
|
||||
)
|
||||
next[id] = BridgeEndpoint(stableId = id, name = displayName, host = host, port = port)
|
||||
}
|
||||
|
||||
unicastById.clear()
|
||||
@@ -436,7 +401,7 @@ class BridgeDiscovery(
|
||||
dns.rawQuery(
|
||||
network,
|
||||
wireQuery,
|
||||
DnsResolver.FLAG_EMPTY,
|
||||
0,
|
||||
dnsExecutor,
|
||||
signal,
|
||||
object : DnsResolver.Callback<ByteArray> {
|
||||
@@ -470,10 +435,6 @@ class BridgeDiscovery(
|
||||
return null
|
||||
}
|
||||
|
||||
private fun txtIntValue(records: List<TXTRecord>, key: String): Int? {
|
||||
return txtValue(records, key)?.toIntOrNull()
|
||||
}
|
||||
|
||||
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.
|
||||
+2
-6
@@ -1,15 +1,10 @@
|
||||
package com.clawdbot.android.bridge
|
||||
package com.steipete.clawdis.node.bridge
|
||||
|
||||
data class BridgeEndpoint(
|
||||
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 bridgePort: Int? = null,
|
||||
val canvasPort: Int? = null,
|
||||
) {
|
||||
companion object {
|
||||
fun manual(host: String, port: Int): BridgeEndpoint =
|
||||
@@ -21,3 +16,4 @@ data class BridgeEndpoint(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+15
-18
@@ -1,4 +1,4 @@
|
||||
package com.clawdbot.android.bridge
|
||||
package com.steipete.clawdis.node.bridge
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -37,21 +37,21 @@ class BridgePairingClient {
|
||||
withContext(Dispatchers.IO) {
|
||||
val socket = Socket()
|
||||
socket.tcpNoDelay = true
|
||||
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
||||
socket.soTimeout = 60_000
|
||||
|
||||
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
||||
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
||||
|
||||
fun send(line: String) {
|
||||
writer.write(line)
|
||||
writer.write("\n")
|
||||
writer.flush()
|
||||
}
|
||||
|
||||
fun sendJson(obj: JsonObject) = send(obj.toString())
|
||||
|
||||
try {
|
||||
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
||||
socket.soTimeout = 60_000
|
||||
|
||||
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
||||
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
||||
|
||||
fun send(line: String) {
|
||||
writer.write(line)
|
||||
writer.write("\n")
|
||||
writer.flush()
|
||||
}
|
||||
|
||||
fun sendJson(obj: JsonObject) = send(obj.toString())
|
||||
|
||||
sendJson(
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("hello"))
|
||||
@@ -111,9 +111,6 @@ class BridgePairingClient {
|
||||
}
|
||||
else -> PairResult(ok = false, token = null, error = "unexpected bridge response")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
val message = e.message?.trim().orEmpty().ifEmpty { "gateway unreachable" }
|
||||
PairResult(ok = false, token = null, error = message)
|
||||
} finally {
|
||||
try {
|
||||
socket.close()
|
||||
+16
-69
@@ -1,4 +1,4 @@
|
||||
package com.clawdbot.android.bridge
|
||||
package com.steipete.clawdis.node.bridge
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -11,7 +11,6 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.clawdbot.android.BuildConfig
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
@@ -24,7 +23,6 @@ import java.io.BufferedWriter
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStreamWriter
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.URI
|
||||
import java.net.Socket
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
@@ -75,17 +73,8 @@ class BridgeSession(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateHello(hello: Hello) {
|
||||
val target = desired ?: return
|
||||
desired = target.first to hello
|
||||
val conn = currentConnection ?: return
|
||||
conn.sendJson(buildHelloJson(hello))
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
desired = null
|
||||
// Unblock connectOnce() read loop. Coroutine cancellation alone won't interrupt BufferedReader.readLine().
|
||||
currentConnection?.closeQuietly()
|
||||
scope.launch(Dispatchers.IO) {
|
||||
job?.cancelAndJoin()
|
||||
job = null
|
||||
@@ -203,7 +192,20 @@ class BridgeSession(
|
||||
currentConnection = conn
|
||||
|
||||
try {
|
||||
conn.sendJson(buildHelloJson(hello))
|
||||
conn.sendJson(
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("hello"))
|
||||
put("nodeId", JsonPrimitive(hello.nodeId))
|
||||
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
||||
hello.token?.let { put("token", JsonPrimitive(it)) }
|
||||
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
||||
hello.version?.let { put("version", JsonPrimitive(it)) }
|
||||
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
||||
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
||||
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
||||
},
|
||||
)
|
||||
|
||||
val firstLine = reader.readLine() ?: throw IllegalStateException("bridge closed connection")
|
||||
val first = json.parseToJsonElement(firstLine).asObjectOrNull()
|
||||
@@ -211,17 +213,7 @@ class BridgeSession(
|
||||
when (first["type"].asStringOrNull()) {
|
||||
"hello-ok" -> {
|
||||
val name = first["serverName"].asStringOrNull() ?: "Bridge"
|
||||
val rawCanvasUrl = first["canvasHostUrl"].asStringOrNull()?.trim()?.ifEmpty { null }
|
||||
canvasHostUrl = normalizeCanvasHostUrl(rawCanvasUrl, endpoint)
|
||||
if (BuildConfig.DEBUG) {
|
||||
// Local JVM unit tests use android.jar stubs; Log.d can throw "not mocked".
|
||||
runCatching {
|
||||
android.util.Log.d(
|
||||
"ClawdbotBridge",
|
||||
"canvasHostUrl resolved=${canvasHostUrl ?: "none"} (raw=${rawCanvasUrl ?: "none"})",
|
||||
)
|
||||
}
|
||||
}
|
||||
canvasHostUrl = first["canvasHostUrl"].asStringOrNull()?.trim()?.ifEmpty { null }
|
||||
onConnected(name, conn.remoteAddress)
|
||||
}
|
||||
"error" -> {
|
||||
@@ -300,51 +292,6 @@ class BridgeSession(
|
||||
conn.closeQuietly()
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildHelloJson(hello: Hello): JsonObject =
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("hello"))
|
||||
put("nodeId", JsonPrimitive(hello.nodeId))
|
||||
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
||||
hello.token?.let { put("token", JsonPrimitive(it)) }
|
||||
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
||||
hello.version?.let { put("version", JsonPrimitive(it)) }
|
||||
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
||||
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
||||
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
||||
}
|
||||
|
||||
private fun normalizeCanvasHostUrl(raw: String?, endpoint: BridgeEndpoint): String? {
|
||||
val trimmed = raw?.trim().orEmpty()
|
||||
val parsed = trimmed.takeIf { it.isNotBlank() }?.let { runCatching { URI(it) }.getOrNull() }
|
||||
val host = parsed?.host?.trim().orEmpty()
|
||||
val port = parsed?.port ?: -1
|
||||
val scheme = parsed?.scheme?.trim().orEmpty().ifBlank { "http" }
|
||||
|
||||
if (trimmed.isNotBlank() && !isLoopbackHost(host)) {
|
||||
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 }
|
||||
|
||||
val fallbackPort = endpoint.canvasPort ?: if (port > 0) port else 18793
|
||||
val formattedHost = if (fallbackHost.contains(":")) "[${fallbackHost}]" else fallbackHost
|
||||
return "$scheme://$formattedHost:$fallbackPort"
|
||||
}
|
||||
|
||||
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
|
||||
+3
-6
@@ -1,6 +1,6 @@
|
||||
package com.clawdbot.android.chat
|
||||
package com.steipete.clawdis.node.chat
|
||||
|
||||
import com.clawdbot.android.bridge.BridgeSession
|
||||
import com.steipete.clawdis.node.bridge.BridgeSession
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -361,12 +361,10 @@ class ChatController(
|
||||
|
||||
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,
|
||||
)
|
||||
@@ -471,8 +469,7 @@ class ChatController(
|
||||
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)
|
||||
ChatSessionEntry(key = key, updatedAtMs = updatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-3
@@ -1,4 +1,4 @@
|
||||
package com.clawdbot.android.chat
|
||||
package com.steipete.clawdis.node.chat
|
||||
|
||||
data class ChatMessage(
|
||||
val id: String,
|
||||
@@ -18,7 +18,6 @@ data class ChatMessageContent(
|
||||
data class ChatPendingToolCall(
|
||||
val toolCallId: String,
|
||||
val name: String,
|
||||
val args: kotlinx.serialization.json.JsonObject? = null,
|
||||
val startedAtMs: Long,
|
||||
val isError: Boolean? = null,
|
||||
)
|
||||
@@ -26,7 +25,6 @@ data class ChatPendingToolCall(
|
||||
data class ChatSessionEntry(
|
||||
val key: String,
|
||||
val updatedAtMs: Long?,
|
||||
val displayName: String? = null,
|
||||
)
|
||||
|
||||
data class ChatHistory(
|
||||
+12
-38
@@ -1,8 +1,7 @@
|
||||
package com.clawdbot.android.node
|
||||
package com.steipete.clawdis.node.node
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.annotation.SuppressLint
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
@@ -19,8 +18,7 @@ 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 com.clawdbot.android.PermissionRequester
|
||||
import com.steipete.clawdis.node.PermissionRequester
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeout
|
||||
@@ -28,7 +26,6 @@ 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
|
||||
|
||||
@@ -95,45 +92,22 @@ class CameraCaptureManager(private val context: Context) {
|
||||
(decoded.height.toDouble() * (maxWidth.toDouble() / decoded.width.toDouble()))
|
||||
.toInt()
|
||||
.coerceAtLeast(1)
|
||||
decoded.scale(maxWidth, h)
|
||||
Bitmap.createScaledBitmap(decoded, maxWidth, h, true)
|
||||
} else {
|
||||
decoded
|
||||
}
|
||||
|
||||
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)
|
||||
val out = ByteArrayOutputStream()
|
||||
val jpegQuality = (quality * 100.0).toInt().coerceIn(10, 100)
|
||||
if (!scaled.compress(Bitmap.CompressFormat.JPEG, jpegQuality, out)) {
|
||||
throw IllegalStateException("UNAVAILABLE: failed to encode JPEG")
|
||||
}
|
||||
val base64 = Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
|
||||
Payload(
|
||||
"""{"format":"jpg","base64":"$base64","width":${result.width},"height":${result.height}}""",
|
||||
"""{"format":"jpg","base64":"$base64","width":${scaled.width},"height":${scaled.height}}""",
|
||||
)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
suspend fun clip(paramsJson: String?): Payload =
|
||||
withContext(Dispatchers.Main) {
|
||||
ensureCameraPermission()
|
||||
@@ -152,7 +126,7 @@ class CameraCaptureManager(private val context: Context) {
|
||||
provider.unbindAll()
|
||||
provider.bindToLifecycle(owner, selector, videoCapture)
|
||||
|
||||
val file = File.createTempFile("clawdbot-clip-", ".mp4")
|
||||
val file = File.createTempFile("clawdis-clip-", ".mp4")
|
||||
val outputOptions = FileOutputOptions.Builder(file).build()
|
||||
|
||||
val finalized = kotlinx.coroutines.CompletableDeferred<VideoRecordEvent.Finalize>()
|
||||
@@ -256,7 +230,7 @@ private suspend fun Context.cameraProvider(): ProcessCameraProvider =
|
||||
|
||||
private suspend fun ImageCapture.takeJpegBytes(executor: Executor): ByteArray =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
val file = File.createTempFile("clawdbot-snap-", ".jpg")
|
||||
val file = File.createTempFile("clawdis-snap-", ".jpg")
|
||||
val options = ImageCapture.OutputFileOptions.Builder(file).build()
|
||||
takePicture(
|
||||
options,
|
||||
+5
-64
@@ -1,23 +1,19 @@
|
||||
package com.clawdbot.android.node
|
||||
package com.steipete.clawdis.node.node
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Build
|
||||
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 com.clawdbot.android.BuildConfig
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class CanvasController {
|
||||
@@ -28,9 +24,6 @@ class CanvasController {
|
||||
|
||||
@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"
|
||||
|
||||
@@ -42,7 +35,6 @@ class CanvasController {
|
||||
fun attach(webView: WebView) {
|
||||
this.webView = webView
|
||||
reload()
|
||||
applyDebugStatus()
|
||||
}
|
||||
|
||||
fun navigate(url: String) {
|
||||
@@ -51,25 +43,6 @@ class CanvasController {
|
||||
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()) {
|
||||
@@ -83,45 +56,13 @@ class CanvasController {
|
||||
val currentUrl = url
|
||||
withWebViewOnMain { wv ->
|
||||
if (currentUrl == null) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.d("ClawdbotCanvas", "load scaffold: $scaffoldAssetUrl")
|
||||
}
|
||||
wv.loadUrl(scaffoldAssetUrl)
|
||||
} else {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.d("ClawdbotCanvas", "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.__clawdbot;
|
||||
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")
|
||||
@@ -139,7 +80,7 @@ class CanvasController {
|
||||
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)
|
||||
Bitmap.createScaledBitmap(bmp, maxWidth, h, true)
|
||||
} else {
|
||||
bmp
|
||||
}
|
||||
@@ -156,7 +97,7 @@ class CanvasController {
|
||||
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)
|
||||
Bitmap.createScaledBitmap(bmp, maxWidth, h, true)
|
||||
} else {
|
||||
bmp
|
||||
}
|
||||
@@ -175,7 +116,7 @@ class CanvasController {
|
||||
suspendCancellableCoroutine { cont ->
|
||||
val width = width.coerceAtLeast(1)
|
||||
val height = height.coerceAtLeast(1)
|
||||
val bitmap = createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
val bitmap = 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.
|
||||
+6
-6
@@ -1,11 +1,11 @@
|
||||
package com.clawdbot.android.node
|
||||
package com.steipete.clawdis.node.node
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.media.MediaRecorder
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import android.util.Base64
|
||||
import com.clawdbot.android.ScreenCaptureRequester
|
||||
import com.steipete.clawdis.node.ScreenCaptureRequester
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -16,13 +16,13 @@ class ScreenRecordManager(private val context: Context) {
|
||||
data class Payload(val payloadJson: String)
|
||||
|
||||
@Volatile private var screenCaptureRequester: ScreenCaptureRequester? = null
|
||||
@Volatile private var permissionRequester: com.clawdbot.android.PermissionRequester? = null
|
||||
@Volatile private var permissionRequester: com.steipete.clawdis.node.PermissionRequester? = null
|
||||
|
||||
fun attachScreenCaptureRequester(requester: ScreenCaptureRequester) {
|
||||
screenCaptureRequester = requester
|
||||
}
|
||||
|
||||
fun attachPermissionRequester(requester: com.clawdbot.android.PermissionRequester) {
|
||||
fun attachPermissionRequester(requester: com.steipete.clawdis.node.PermissionRequester) {
|
||||
permissionRequester = requester
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ class ScreenRecordManager(private val context: Context) {
|
||||
val height = metrics.heightPixels
|
||||
val densityDpi = metrics.densityDpi
|
||||
|
||||
val file = File.createTempFile("clawdbot-screen-", ".mp4")
|
||||
val file = File.createTempFile("clawdis-screen-", ".mp4")
|
||||
if (includeAudio) ensureMicPermission()
|
||||
|
||||
val recorder = MediaRecorder()
|
||||
@@ -89,7 +89,7 @@ class ScreenRecordManager(private val context: Context) {
|
||||
val surface = recorder.surface
|
||||
virtualDisplay =
|
||||
projection.createVirtualDisplay(
|
||||
"clawdbot-screen",
|
||||
"clawdis-screen",
|
||||
width,
|
||||
height,
|
||||
densityDpi,
|
||||
+4
-21
@@ -1,24 +1,6 @@
|
||||
package com.clawdbot.android.protocol
|
||||
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
object ClawdbotCanvasA2UIAction {
|
||||
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 }
|
||||
}
|
||||
package com.steipete.clawdis.node.protocol
|
||||
|
||||
object ClawdisCanvasA2UIAction {
|
||||
fun sanitizeTagValue(value: String): String {
|
||||
val trimmed = value.trim().ifEmpty { "-" }
|
||||
val normalized = trimmed.replace(" ", "_")
|
||||
@@ -61,6 +43,7 @@ object ClawdbotCanvasA2UIAction {
|
||||
val err = (error ?: "").replace("\\", "\\\\").replace("\"", "\\\"")
|
||||
val okLiteral = if (ok) "true" else "false"
|
||||
val idEscaped = actionId.replace("\\", "\\\\").replace("\"", "\\\"")
|
||||
return "window.dispatchEvent(new CustomEvent('clawdbot:a2ui-action-status', { detail: { id: \"${idEscaped}\", ok: ${okLiteral}, error: \"${err}\" } }));"
|
||||
return "window.dispatchEvent(new CustomEvent('clawdis:a2ui-action-status', { detail: { id: \"${idEscaped}\", ok: ${okLiteral}, error: \"${err}\" } }));"
|
||||
}
|
||||
}
|
||||
|
||||
+6
-26
@@ -1,15 +1,13 @@
|
||||
package com.clawdbot.android.protocol
|
||||
package com.steipete.clawdis.node.protocol
|
||||
|
||||
enum class ClawdbotCapability(val rawValue: String) {
|
||||
enum class ClawdisCapability(val rawValue: String) {
|
||||
Canvas("canvas"),
|
||||
Camera("camera"),
|
||||
Screen("screen"),
|
||||
Sms("sms"),
|
||||
VoiceWake("voiceWake"),
|
||||
Location("location"),
|
||||
}
|
||||
|
||||
enum class ClawdbotCanvasCommand(val rawValue: String) {
|
||||
enum class ClawdisCanvasCommand(val rawValue: String) {
|
||||
Present("canvas.present"),
|
||||
Hide("canvas.hide"),
|
||||
Navigate("canvas.navigate"),
|
||||
@@ -22,7 +20,7 @@ enum class ClawdbotCanvasCommand(val rawValue: String) {
|
||||
}
|
||||
}
|
||||
|
||||
enum class ClawdbotCanvasA2UICommand(val rawValue: String) {
|
||||
enum class ClawdisCanvasA2UICommand(val rawValue: String) {
|
||||
Push("canvas.a2ui.push"),
|
||||
PushJSONL("canvas.a2ui.pushJSONL"),
|
||||
Reset("canvas.a2ui.reset"),
|
||||
@@ -33,7 +31,7 @@ enum class ClawdbotCanvasA2UICommand(val rawValue: String) {
|
||||
}
|
||||
}
|
||||
|
||||
enum class ClawdbotCameraCommand(val rawValue: String) {
|
||||
enum class ClawdisCameraCommand(val rawValue: String) {
|
||||
Snap("camera.snap"),
|
||||
Clip("camera.clip"),
|
||||
;
|
||||
@@ -43,7 +41,7 @@ enum class ClawdbotCameraCommand(val rawValue: String) {
|
||||
}
|
||||
}
|
||||
|
||||
enum class ClawdbotScreenCommand(val rawValue: String) {
|
||||
enum class ClawdisScreenCommand(val rawValue: String) {
|
||||
Record("screen.record"),
|
||||
;
|
||||
|
||||
@@ -51,21 +49,3 @@ enum class ClawdbotScreenCommand(val rawValue: String) {
|
||||
const val NamespacePrefix: String = "screen."
|
||||
}
|
||||
}
|
||||
|
||||
enum class ClawdbotSmsCommand(val rawValue: String) {
|
||||
Send("sms.send"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "sms."
|
||||
}
|
||||
}
|
||||
|
||||
enum class ClawdbotLocationCommand(val rawValue: String) {
|
||||
Get("location.get"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "location."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package com.steipete.clawdis.node.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
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.material3.CircularProgressIndicator
|
||||
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.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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.steipete.clawdis.node.CameraHudKind
|
||||
import com.steipete.clawdis.node.CameraHudState
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
fun CameraHudOverlay(
|
||||
hud: CameraHudState?,
|
||||
flashToken: Long,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
CameraFlash(token = flashToken)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = hud != null,
|
||||
enter = slideInVertically(initialOffsetY = { -it / 2 }) + fadeIn(),
|
||||
exit = slideOutVertically(targetOffsetY = { -it / 2 }) + fadeOut(),
|
||||
modifier = Modifier.align(Alignment.TopStart).statusBarsPadding().padding(start = 12.dp, top = 58.dp),
|
||||
) {
|
||||
if (hud != null) {
|
||||
Toast(hud = hud)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Toast(hud: CameraHudState) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f),
|
||||
tonalElevation = 2.dp,
|
||||
shadowElevation = 8.dp,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(vertical = 10.dp, horizontal = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
when (hud.kind) {
|
||||
CameraHudKind.Photo -> {
|
||||
Icon(Icons.Default.PhotoCamera, contentDescription = null)
|
||||
Spacer(Modifier.size(10.dp))
|
||||
CircularProgressIndicator(modifier = Modifier.size(14.dp), strokeWidth = 2.dp)
|
||||
}
|
||||
CameraHudKind.Recording -> {
|
||||
Icon(Icons.Default.FiberManualRecord, contentDescription = null, tint = Color.Red)
|
||||
}
|
||||
CameraHudKind.Success -> {
|
||||
Icon(Icons.Default.CheckCircle, contentDescription = null)
|
||||
}
|
||||
CameraHudKind.Error -> {
|
||||
Icon(Icons.Default.Error, contentDescription = null)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.size(10.dp))
|
||||
Text(
|
||||
text = hud.message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
package com.clawdbot.android.ui
|
||||
package com.steipete.clawdis.node.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.clawdbot.android.MainViewModel
|
||||
import com.clawdbot.android.ui.chat.ChatSheetContent
|
||||
import com.steipete.clawdis.node.MainViewModel
|
||||
import com.steipete.clawdis.node.ui.chat.ChatSheetContent
|
||||
|
||||
@Composable
|
||||
fun ChatSheet(viewModel: MainViewModel) {
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package com.clawdbot.android.ui
|
||||
package com.steipete.clawdis.node.ui
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -9,7 +9,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
@Composable
|
||||
fun ClawdbotTheme(content: @Composable () -> Unit) {
|
||||
fun ClawdisTheme(content: @Composable () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val isDark = isSystemInDarkTheme()
|
||||
val colorScheme = if (isDark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
@@ -0,0 +1,217 @@
|
||||
package com.steipete.clawdis.node.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.WebView
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebResourceError
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebViewClient
|
||||
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.ModalBottomSheet
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ChatBubble
|
||||
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.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 com.steipete.clawdis.node.MainViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RootScreen(viewModel: MainViewModel) {
|
||||
var sheet by remember { mutableStateOf<Sheet?>(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 bridgeState =
|
||||
remember(serverName, statusText) {
|
||||
when {
|
||||
serverName != null -> BridgeState.Connected
|
||||
statusText.contains("connecting", ignoreCase = true) ||
|
||||
statusText.contains("reconnecting", ignoreCase = true) -> BridgeState.Connecting
|
||||
statusText.contains("error", ignoreCase = true) -> BridgeState.Error
|
||||
else -> BridgeState.Disconnected
|
||||
}
|
||||
}
|
||||
|
||||
val voiceEnabled =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
CanvasView(viewModel = viewModel, modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
|
||||
// Camera HUD (flash + toast) must be in a Popup to render above the WebView.
|
||||
Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) {
|
||||
CameraHudOverlay(hud = cameraHud, flashToken = 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(
|
||||
bridge = bridgeState,
|
||||
voiceEnabled = voiceEnabled,
|
||||
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") },
|
||||
)
|
||||
|
||||
OverlayIconButton(
|
||||
onClick = { sheet = Sheet.Settings },
|
||||
icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
) {
|
||||
FilledTonalIconButton(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.size(44.dp),
|
||||
colors =
|
||||
IconButtonDefaults.filledTonalIconButtonColors(
|
||||
containerColor = overlayContainerColor(),
|
||||
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
|
||||
webViewClient =
|
||||
object : WebViewClient() {
|
||||
override fun onReceivedError(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
error: WebResourceError,
|
||||
) {
|
||||
if (!isDebuggable) return
|
||||
if (!request.isForMainFrame) return
|
||||
Log.e("ClawdisWebView", "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(
|
||||
"ClawdisWebView",
|
||||
"onReceivedHttpError: ${errorResponse.statusCode} ${errorResponse.reasonPhrase} ${request.url}",
|
||||
)
|
||||
}
|
||||
}
|
||||
setBackgroundColor(Color.BLACK)
|
||||
setLayerType(View.LAYER_TYPE_HARDWARE, null)
|
||||
|
||||
addJavascriptInterface(
|
||||
CanvasA2UIActionBridge { payload ->
|
||||
viewModel.handleCanvasA2UIActionFromWebView(payload)
|
||||
},
|
||||
CanvasA2UIActionBridge.interfaceName,
|
||||
)
|
||||
viewModel.canvas.attach(this)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
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 = "clawdisCanvasA2UIAction"
|
||||
}
|
||||
}
|
||||
+9
-273
@@ -1,12 +1,7 @@
|
||||
package com.clawdbot.android.ui
|
||||
package com.steipete.clawdis.node.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
|
||||
@@ -46,17 +41,14 @@ 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.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.clawdbot.android.BuildConfig
|
||||
import com.clawdbot.android.LocationMode
|
||||
import com.clawdbot.android.MainViewModel
|
||||
import com.clawdbot.android.NodeForegroundService
|
||||
import com.clawdbot.android.VoiceWakeMode
|
||||
import com.steipete.clawdis.node.MainViewModel
|
||||
import com.steipete.clawdis.node.NodeForegroundService
|
||||
import com.steipete.clawdis.node.VoiceWakeMode
|
||||
|
||||
@Composable
|
||||
fun SettingsSheet(viewModel: MainViewModel) {
|
||||
@@ -64,8 +56,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
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()
|
||||
@@ -74,7 +64,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
val manualEnabled by viewModel.manualEnabled.collectAsState()
|
||||
val manualHost by viewModel.manualHost.collectAsState()
|
||||
val manualPort by viewModel.manualPort.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()
|
||||
@@ -84,22 +73,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
val listState = rememberLazyListState()
|
||||
val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") }
|
||||
val (advancedExpanded, setAdvancedExpanded) = 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
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) }
|
||||
|
||||
@@ -109,63 +82,11 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
viewModel.setCameraEnabled(cameraOk)
|
||||
}
|
||||
|
||||
var pendingLocationMode by remember { mutableStateOf<LocationMode?>(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.refreshBridgeHello()
|
||||
}
|
||||
|
||||
fun setCameraEnabledChecked(checked: Boolean) {
|
||||
if (!checked) {
|
||||
viewModel.setCameraEnabled(false)
|
||||
@@ -182,47 +103,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
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 visibleBridges =
|
||||
if (isConnected && remoteAddress != null) {
|
||||
bridges.filterNot { "${it.host}:${it.port}" == remoteAddress }
|
||||
@@ -250,7 +130,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
// Order parity: Node → Bridge → Voice → Camera → Messaging → Location → Screen.
|
||||
// Order parity: Node → Bridge → Voice → Camera → Screen.
|
||||
item { Text("Node", style = MaterialTheme.typography.titleSmall) }
|
||||
item {
|
||||
OutlinedTextField(
|
||||
@@ -261,8 +141,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
)
|
||||
}
|
||||
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() }
|
||||
|
||||
@@ -302,27 +180,9 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
item { Text("No bridges found yet.", color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
} else {
|
||||
items(items = visibleBridges, key = { it.stableId }) { bridge ->
|
||||
val detailLines =
|
||||
buildList {
|
||||
add("IP: ${bridge.host}:${bridge.port}")
|
||||
bridge.lanHost?.let { add("LAN: $it") }
|
||||
bridge.tailnetDns?.let { add("Tailnet: $it") }
|
||||
if (bridge.gatewayPort != null || bridge.bridgePort != null || bridge.canvasPort != null) {
|
||||
val gw = bridge.gatewayPort?.toString() ?: "—"
|
||||
val br = (bridge.bridgePort ?: bridge.port).toString()
|
||||
val canvas = bridge.canvasPort?.toString() ?: "—"
|
||||
add("Ports: gw $gw · bridge $br · canvas $canvas")
|
||||
}
|
||||
}
|
||||
ListItem(
|
||||
headlineContent = { Text(bridge.name) },
|
||||
supportingContent = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
detailLines.forEach { line ->
|
||||
Text(line, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
},
|
||||
supportingContent = { Text("${bridge.host}:${bridge.port}") },
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = {
|
||||
@@ -436,7 +296,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
ListItem(
|
||||
headlineContent = { Text("Foreground Only") },
|
||||
supportingContent = { Text("Listens only while Clawdbot is open.") },
|
||||
supportingContent = { Text("Listens only while Clawdis is open.") },
|
||||
trailingContent = {
|
||||
RadioButton(
|
||||
selected = voiceWakeMode == VoiceWakeMode.Foreground,
|
||||
@@ -482,7 +342,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Button(
|
||||
onClick = {
|
||||
val parsed = com.clawdbot.android.WakeWords.parseCommaSeparated(wakeWordsText)
|
||||
val parsed = com.steipete.clawdis.node.WakeWords.parseCommaSeparated(wakeWordsText)
|
||||
viewModel.setWakeWords(parsed)
|
||||
},
|
||||
enabled = isConnected,
|
||||
@@ -524,140 +384,16 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
|
||||
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 bridge 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 Clawdbot 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 Clawdbot is open.") },
|
||||
supportingContent = { Text("Keeps the screen awake while Clawdis 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)
|
||||
}
|
||||
+12
-39
@@ -1,4 +1,4 @@
|
||||
package com.clawdbot.android.ui
|
||||
package com.steipete.clawdis.node.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -30,7 +30,6 @@ fun StatusPill(
|
||||
voiceEnabled: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
activity: StatusActivity? = null,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
@@ -63,49 +62,23 @@ fun StatusPill(
|
||||
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),
|
||||
)
|
||||
}
|
||||
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 BridgeState(val title: String, val color: Color) {
|
||||
Connected("Connected", Color(0xFF2ECC71)),
|
||||
Connecting("Connecting…", Color(0xFFF1C40F)),
|
||||
+10
-40
@@ -1,4 +1,4 @@
|
||||
package com.clawdbot.android.ui.chat
|
||||
package com.steipete.clawdis.node.ui.chat
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -15,6 +15,7 @@ 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.FolderOpen
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
@@ -38,12 +39,10 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.clawdbot.android.chat.ChatSessionEntry
|
||||
|
||||
@Composable
|
||||
fun ChatComposer(
|
||||
sessionKey: String,
|
||||
sessions: List<ChatSessionEntry>,
|
||||
healthOk: Boolean,
|
||||
thinkingLevel: String,
|
||||
pendingRunCount: Int,
|
||||
@@ -52,18 +51,13 @@ fun ChatComposer(
|
||||
onPickImages: () -> Unit,
|
||||
onRemoveAttachment: (id: String) -> Unit,
|
||||
onSetThinkingLevel: (level: String) -> Unit,
|
||||
onSelectSession: (sessionKey: String) -> Unit,
|
||||
onShowSessions: () -> 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)
|
||||
val currentSessionLabel =
|
||||
sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey
|
||||
|
||||
val canSend = pendingRunCount == 0 && (input.trim().isNotEmpty() || attachments.isNotEmpty()) && healthOk
|
||||
|
||||
@@ -79,34 +73,6 @@ fun ChatComposer(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box {
|
||||
FilledTonalButton(
|
||||
onClick = { showSessionMenu = true },
|
||||
contentPadding = ButtonDefaults.ContentPadding,
|
||||
) {
|
||||
Text("Session: $currentSessionLabel")
|
||||
}
|
||||
|
||||
DropdownMenu(expanded = showSessionMenu, onDismissRequest = { showSessionMenu = false }) {
|
||||
for (entry in sessionOptions) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(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 },
|
||||
@@ -125,6 +91,10 @@ fun ChatComposer(
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
FilledTonalIconButton(onClick = onShowSessions, modifier = Modifier.size(42.dp)) {
|
||||
Icon(Icons.Default.FolderOpen, contentDescription = "Sessions")
|
||||
}
|
||||
|
||||
FilledTonalIconButton(onClick = onRefresh, modifier = Modifier.size(42.dp)) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
||||
}
|
||||
@@ -148,7 +118,7 @@ fun ChatComposer(
|
||||
)
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
ConnectionPill(sessionLabel = currentSessionLabel, healthOk = healthOk)
|
||||
ConnectionPill(sessionKey = sessionKey, healthOk = healthOk)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
if (pendingRunCount > 0) {
|
||||
@@ -186,7 +156,7 @@ fun ChatComposer(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectionPill(sessionLabel: String, healthOk: Boolean) {
|
||||
private fun ConnectionPill(sessionKey: String, healthOk: Boolean) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
@@ -201,7 +171,7 @@ private fun ConnectionPill(sessionLabel: String, healthOk: Boolean) {
|
||||
shape = androidx.compose.foundation.shape.CircleShape,
|
||||
color = if (healthOk) Color(0xFF2ECC71) else Color(0xFFF39C12),
|
||||
) {}
|
||||
Text(sessionLabel, style = MaterialTheme.typography.labelSmall)
|
||||
Text(sessionKey, style = MaterialTheme.typography.labelSmall)
|
||||
Text(
|
||||
if (healthOk) "Connected" else "Connecting…",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
+3
-4
@@ -1,4 +1,4 @@
|
||||
package com.clawdbot.android.ui.chat
|
||||
package com.steipete.clawdis.node.ui.chat
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
@@ -17,7 +17,6 @@ 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
|
||||
@@ -32,7 +31,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun ChatMarkdown(text: String, textColor: Color) {
|
||||
fun ChatMarkdown(text: String) {
|
||||
val blocks = remember(text) { splitMarkdown(text) }
|
||||
val inlineCodeBg = MaterialTheme.colorScheme.surfaceContainerLow
|
||||
|
||||
@@ -45,7 +44,7 @@ fun ChatMarkdown(text: String, textColor: Color) {
|
||||
Text(
|
||||
text = parseInlineMarkdown(trimmed, inlineCodeBg = inlineCodeBg),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = textColor,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
is ChatMarkdownBlock.Code -> {
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
package com.clawdbot.android.ui.chat
|
||||
package com.steipete.clawdis.node.ui.chat
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -20,8 +20,8 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.clawdbot.android.chat.ChatMessage
|
||||
import com.clawdbot.android.chat.ChatPendingToolCall
|
||||
import com.steipete.clawdis.node.chat.ChatMessage
|
||||
import com.steipete.clawdis.node.chat.ChatPendingToolCall
|
||||
|
||||
@Composable
|
||||
fun ChatMessageListCard(
|
||||
+14
-48
@@ -1,4 +1,4 @@
|
||||
package com.clawdbot.android.ui.chat
|
||||
package com.steipete.clawdis.node.ui.chat
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
@@ -7,9 +7,11 @@ 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.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -31,13 +33,11 @@ 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 com.clawdbot.android.chat.ChatMessage
|
||||
import com.clawdbot.android.chat.ChatMessageContent
|
||||
import com.clawdbot.android.chat.ChatPendingToolCall
|
||||
import com.clawdbot.android.tools.ToolDisplayRegistry
|
||||
import com.steipete.clawdis.node.chat.ChatMessage
|
||||
import com.steipete.clawdis.node.chat.ChatMessageContent
|
||||
import com.steipete.clawdis.node.chat.ChatPendingToolCall
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
@Composable
|
||||
fun ChatMessageBubble(message: ChatMessage) {
|
||||
@@ -60,21 +60,20 @@ fun ChatMessageBubble(message: ChatMessage) {
|
||||
.background(bubbleBackground(isUser))
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
val textColor = textColorOverBubble(isUser)
|
||||
ChatMessageBody(content = message.content, textColor = textColor)
|
||||
ChatMessageBody(content = message.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatMessageBody(content: List<ChatMessageContent>, textColor: Color) {
|
||||
private fun ChatMessageBody(content: List<ChatMessageContent>) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
for (part in content) {
|
||||
when (part.type) {
|
||||
"text" -> {
|
||||
val text = part.text ?: continue
|
||||
ChatMarkdown(text = text, textColor = textColor)
|
||||
ChatMarkdown(text = text)
|
||||
}
|
||||
else -> {
|
||||
val b64 = part.base64 ?: continue
|
||||
@@ -106,42 +105,18 @@ fun ChatTypingIndicatorBubble() {
|
||||
|
||||
@Composable
|
||||
fun ChatPendingToolsBubble(toolCalls: List<ChatPendingToolCall>) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text("Tools", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
|
||||
for (t in toolCalls.take(6)) {
|
||||
Text("· ${t.name}", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
if (toolCalls.size > 6) {
|
||||
Text(
|
||||
"… +${toolCalls.size - 6} more",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text("… +${toolCalls.size - 6} more", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,7 +131,7 @@ fun ChatStreamingAssistantBubble(text: String) {
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
) {
|
||||
Box(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) {
|
||||
ChatMarkdown(text = text, textColor = MaterialTheme.colorScheme.onSurface)
|
||||
ChatMarkdown(text = text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,15 +150,6 @@ private fun bubbleBackground(isUser: Boolean): Brush {
|
||||
}
|
||||
}
|
||||
|
||||
@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<androidx.compose.ui.graphics.ImageBitmap?>(null) }
|
||||
+4
-3
@@ -1,4 +1,4 @@
|
||||
package com.clawdbot.android.ui.chat
|
||||
package com.steipete.clawdis.node.ui.chat
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -20,7 +20,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.clawdbot.android.chat.ChatSessionEntry
|
||||
import com.steipete.clawdis.node.chat.ChatSessionEntry
|
||||
|
||||
@Composable
|
||||
fun ChatSessionsDialog(
|
||||
@@ -82,7 +82,7 @@ private fun SessionRow(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(entry.displayName ?: entry.key, style = MaterialTheme.typography.bodyMedium)
|
||||
Text(entry.key, style = MaterialTheme.typography.bodyMedium)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
if (isCurrent) {
|
||||
Text("Current", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
@@ -90,3 +90,4 @@ private fun SessionRow(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-10
@@ -1,4 +1,4 @@
|
||||
package com.clawdbot.android.ui.chat
|
||||
package com.steipete.clawdis.node.ui.chat
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
@@ -14,13 +14,15 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.clawdbot.android.MainViewModel
|
||||
import com.clawdbot.android.chat.OutgoingAttachment
|
||||
import com.steipete.clawdis.node.MainViewModel
|
||||
import com.steipete.clawdis.node.chat.OutgoingAttachment
|
||||
import java.io.ByteArrayOutputStream
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -38,9 +40,10 @@ fun ChatSheetContent(viewModel: MainViewModel) {
|
||||
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
|
||||
val sessions by viewModel.chatSessions.collectAsState()
|
||||
|
||||
var showSessions by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadChat("main")
|
||||
viewModel.refreshChatSessions(limit = 200)
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
@@ -84,7 +87,6 @@ fun ChatSheetContent(viewModel: MainViewModel) {
|
||||
|
||||
ChatComposer(
|
||||
sessionKey = sessionKey,
|
||||
sessions = sessions,
|
||||
healthOk = healthOk,
|
||||
thinkingLevel = thinkingLevel,
|
||||
pendingRunCount = pendingRunCount,
|
||||
@@ -93,11 +95,8 @@ fun ChatSheetContent(viewModel: MainViewModel) {
|
||||
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)
|
||||
},
|
||||
onShowSessions = { showSessions = true },
|
||||
onRefresh = { viewModel.refreshChat() },
|
||||
onAbort = { viewModel.abortChat() },
|
||||
onSend = { text ->
|
||||
val outgoing =
|
||||
@@ -114,6 +113,19 @@ fun ChatSheetContent(viewModel: MainViewModel) {
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (showSessions) {
|
||||
ChatSessionsDialog(
|
||||
currentSessionKey = sessionKey,
|
||||
sessions = sessions,
|
||||
onDismiss = { showSessions = false },
|
||||
onRefresh = { viewModel.refreshChatSessions(limit = 50) },
|
||||
onSelect = { key ->
|
||||
viewModel.switchChatSession(key)
|
||||
showSessions = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class PendingImageAttachment(
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.clawdbot.android.voice
|
||||
package com.steipete.clawdis.node.voice
|
||||
|
||||
object VoiceWakeCommandExtractor {
|
||||
fun extractCommand(text: String, triggerWords: List<String>): String? {
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.clawdbot.android.voice
|
||||
package com.steipete.clawdis.node.voice
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
+1
-1
@@ -2,5 +2,5 @@
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
||||
+1
-1
@@ -2,5 +2,5 @@
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<resources>
|
||||
<string name="app_name">Clawdbot Node</string>
|
||||
<string name="app_name">Clawdis Node</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<resources>
|
||||
<style name="Theme.ClawdbotNode" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<style name="Theme.ClawdisNode" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
<item name="android:windowLightStatusBar" tools:targetApi="m">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<full-backup-content>
|
||||
<include domain="file" path="." />
|
||||
</full-backup-content>
|
||||
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<include domain="file" path="." />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<include domain="file" path="." />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
@@ -1,10 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config xmlns:tools="http://schemas.android.com/tools">
|
||||
<network-security-config>
|
||||
<!-- This app is primarily used on a trusted tailnet; allow cleartext for IP-based endpoints too. -->
|
||||
<base-config cleartextTrafficPermitted="true" tools:ignore="InsecureBaseConfiguration" />
|
||||
<base-config cleartextTrafficPermitted="true" />
|
||||
<!-- Allow HTTP for tailnet/local dev endpoints (e.g. canvas/background web). -->
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="true">clawdbot.internal</domain>
|
||||
<domain includeSubdomains="true">clawdis.internal</domain>
|
||||
</domain-config>
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="true">ts.net</domain>
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
package com.clawdbot.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
|
||||
}
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
package com.clawdbot.android.bridge
|
||||
|
||||
import io.kotest.core.spec.style.StringSpec
|
||||
import io.kotest.matchers.shouldBe
|
||||
|
||||
class BridgeEndpointKotestTest : StringSpec({
|
||||
"manual endpoint builds stable id + name" {
|
||||
val endpoint = BridgeEndpoint.manual("10.0.0.5", 18790)
|
||||
endpoint.stableId shouldBe "manual|10.0.0.5|18790"
|
||||
endpoint.name shouldBe "10.0.0.5:18790"
|
||||
endpoint.host shouldBe "10.0.0.5"
|
||||
endpoint.port shouldBe 18790
|
||||
}
|
||||
})
|
||||
@@ -1,47 +0,0 @@
|
||||
package com.clawdbot.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)
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
package com.clawdbot.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)
|
||||
}
|
||||
}
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
package com.clawdbot.android.protocol
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ClawdbotCanvasA2UIActionTest {
|
||||
@Test
|
||||
fun extractActionNameAcceptsNameOrAction() {
|
||||
val nameObj = Json.parseToJsonElement("{\"name\":\"Hello\"}").jsonObject
|
||||
assertEquals("Hello", ClawdbotCanvasA2UIAction.extractActionName(nameObj))
|
||||
|
||||
val actionObj = Json.parseToJsonElement("{\"action\":\"Wave\"}").jsonObject
|
||||
assertEquals("Wave", ClawdbotCanvasA2UIAction.extractActionName(actionObj))
|
||||
|
||||
val fallbackObj =
|
||||
Json.parseToJsonElement("{\"name\":\" \",\"action\":\"Fallback\"}").jsonObject
|
||||
assertEquals("Fallback", ClawdbotCanvasA2UIAction.extractActionName(fallbackObj))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun formatAgentMessageMatchesSharedSpec() {
|
||||
val msg =
|
||||
ClawdbotCanvasA2UIAction.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 = ClawdbotCanvasA2UIAction.jsDispatchA2UIActionStatus(actionId = "a1", ok = true, error = null)
|
||||
assertEquals(
|
||||
"window.dispatchEvent(new CustomEvent('clawdbot:a2ui-action-status', { detail: { id: \"a1\", ok: true, error: \"\" } }));",
|
||||
js,
|
||||
)
|
||||
}
|
||||
}
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
package com.clawdbot.android.protocol
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ClawdbotProtocolConstantsTest {
|
||||
@Test
|
||||
fun canvasCommandsUseStableStrings() {
|
||||
assertEquals("canvas.present", ClawdbotCanvasCommand.Present.rawValue)
|
||||
assertEquals("canvas.hide", ClawdbotCanvasCommand.Hide.rawValue)
|
||||
assertEquals("canvas.navigate", ClawdbotCanvasCommand.Navigate.rawValue)
|
||||
assertEquals("canvas.eval", ClawdbotCanvasCommand.Eval.rawValue)
|
||||
assertEquals("canvas.snapshot", ClawdbotCanvasCommand.Snapshot.rawValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun a2uiCommandsUseStableStrings() {
|
||||
assertEquals("canvas.a2ui.push", ClawdbotCanvasA2UICommand.Push.rawValue)
|
||||
assertEquals("canvas.a2ui.pushJSONL", ClawdbotCanvasA2UICommand.PushJSONL.rawValue)
|
||||
assertEquals("canvas.a2ui.reset", ClawdbotCanvasA2UICommand.Reset.rawValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun capabilitiesUseStableStrings() {
|
||||
assertEquals("canvas", ClawdbotCapability.Canvas.rawValue)
|
||||
assertEquals("camera", ClawdbotCapability.Camera.rawValue)
|
||||
assertEquals("screen", ClawdbotCapability.Screen.rawValue)
|
||||
assertEquals("voiceWake", ClawdbotCapability.VoiceWake.rawValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun screenCommandsUseStableStrings() {
|
||||
assertEquals("screen.record", ClawdbotScreenCommand.Record.rawValue)
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package com.clawdbot.android.ui.chat
|
||||
|
||||
import com.clawdbot.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, 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, nowMs = now).map { it.key }
|
||||
assertEquals(listOf("main", "custom"), result)
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package com.clawdbot.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)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.clawdbot.android
|
||||
package com.steipete.clawdis.node
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package com.clawdbot.android.bridge
|
||||
package com.steipete.clawdis.node.bridge
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
@@ -12,7 +12,7 @@ class BonjourEscapesTest {
|
||||
|
||||
@Test
|
||||
fun decodeDecodesDecimalEscapes() {
|
||||
assertEquals("Clawdbot Gateway", BonjourEscapes.decode("Clawdbot\\032Gateway"))
|
||||
assertEquals("Clawdis Gateway", BonjourEscapes.decode("Clawdis\\032Gateway"))
|
||||
assertEquals("A B", BonjourEscapes.decode("A\\032B"))
|
||||
assertEquals("Peter\u2019s Mac", BonjourEscapes.decode("Peter\\226\\128\\153s Mac"))
|
||||
}
|
||||
Loaded 100 of 1345 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user