Compare commits
368
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2401abe17e | ||
|
|
56ea6b6e43 | ||
|
|
04691ed598 | ||
|
|
fe5e58af91 | ||
|
|
1a539b9830 | ||
|
|
3addd3420b | ||
|
|
6ea10dd153 | ||
|
|
bf0bee58b3 | ||
|
|
fbcbc60e85 | ||
|
|
0a9f06d60f | ||
|
|
f6956320f9 | ||
|
|
20bc323963 | ||
|
|
bcead5f0f4 | ||
|
|
cf3049ae34 | ||
|
|
ad9a9d8d35 | ||
|
|
14e9077584 | ||
|
|
43cf526b5f | ||
|
|
2d5c401d11 | ||
|
|
78cf68549f | ||
|
|
dececccd8e | ||
|
|
941ad27551 | ||
|
|
24e95ab38e | ||
|
|
c4de0b8255 | ||
|
|
7baaca4a76 | ||
|
|
ea248f6743 | ||
|
|
f03605d8ae | ||
|
|
0babf08926 | ||
|
|
6517b05abe | ||
|
|
fa91b5fd03 | ||
|
|
f831ccfc63 | ||
|
|
12084fc4f9 | ||
|
|
21237dae98 | ||
|
|
4bdc25d072 | ||
|
|
2f55abace2 | ||
|
|
3213e5df2d | ||
|
|
7e40147aa3 | ||
|
|
a2a26b26fb | ||
|
|
b3cf07d6cb | ||
|
|
ed76cd7574 | ||
|
|
01b8a71ee6 | ||
|
|
cc86bbf27d | ||
|
|
42cbb11de8 | ||
|
|
52303e8eda | ||
|
|
cf903be4a7 | ||
|
|
6306786645 | ||
|
|
d7b267843e | ||
|
|
3aefe375c1 | ||
|
|
3d6cc435ef | ||
|
|
973bd3a427 | ||
|
|
7d1ec51df5 | ||
|
|
9fb74399c8 | ||
|
|
bc0a6fffd1 | ||
|
|
fa85dd6527 | ||
|
|
73d595eecc | ||
|
|
3bf8b9ccf4 | ||
|
|
83262a67b1 | ||
|
|
66952a682d | ||
|
|
9df22c0093 | ||
|
|
27adfb76fa | ||
|
|
9c532eac07 | ||
|
|
2814815312 | ||
|
|
ab27586674 | ||
|
|
2749c5cac3 | ||
|
|
715cf311df | ||
|
|
312443235d | ||
|
|
0d95d63258 | ||
|
|
f86772f26c | ||
|
|
a7617e4d79 | ||
|
|
7612a83fa2 | ||
|
|
afbd18e8df | ||
|
|
be2bc61d38 | ||
|
|
dcee8beb99 | ||
|
|
fb8f72d5a9 | ||
|
|
b3f2416a09 | ||
|
|
b5ae2ccc3c | ||
|
|
05efc3eace | ||
|
|
24f8ff7548 | ||
|
|
c0c6782a17 | ||
|
|
d2ac672f47 | ||
|
|
e3d8d5f300 | ||
|
|
c5d5c9fcb5 | ||
|
|
2e040ee07a | ||
|
|
9846c46434 | ||
|
|
5c7c1af44e | ||
|
|
e119a82334 | ||
|
|
02db68aa67 | ||
|
|
10e1e7fd44 | ||
|
|
7aabe73521 | ||
|
|
37f85bb2d1 | ||
|
|
39fccc3699 | ||
|
|
53eccc1c1e | ||
|
|
c56292a6ec | ||
|
|
857cd6a28a | ||
|
|
303954ae8c | ||
|
|
3c338d1858 | ||
|
|
20d7882033 | ||
|
|
6927b0fb8d | ||
|
|
6e83f95c83 | ||
|
|
8f0c8a6561 | ||
|
|
a61b7056d5 | ||
|
|
f41ade9417 | ||
|
|
b0396e196f | ||
|
|
cf42fabfd8 | ||
|
|
52263bd5a3 | ||
|
|
24151a2028 | ||
|
|
c11e2d9e5e | ||
|
|
a8c9b2810b | ||
|
|
7a849ab7d1 | ||
|
|
c14d738d37 | ||
|
|
65478a6ff3 | ||
|
|
41be9232fe | ||
|
|
653932e50d | ||
|
|
09ef991e1a | ||
|
|
0f7029583c | ||
|
|
10eced9971 | ||
|
|
1d8b47785c | ||
|
|
ced271bec1 | ||
|
|
5d19afd422 | ||
|
|
b7363f7c18 | ||
|
|
aa2700ffa7 | ||
|
|
510e2a1d17 | ||
|
|
ebfe55f909 | ||
|
|
26fa9dea97 | ||
|
|
3bb4c0c237 | ||
|
|
255a875a2a | ||
|
|
2b5f3f1361 | ||
|
|
eb158545fc | ||
|
|
cade7b1132 | ||
|
|
d529736597 | ||
|
|
8dfc031c4d | ||
|
|
91c9859000 | ||
|
|
3a485a14a4 | ||
|
|
a61c27c4d0 | ||
|
|
e5cae2a2e4 | ||
|
|
7f961237f9 | ||
|
|
69a6538567 | ||
|
|
5b3c18ab84 | ||
|
|
907371453d | ||
|
|
81abffd145 | ||
|
|
44ef8fe5c8 | ||
|
|
cae78b3f91 | ||
|
|
c0fb814658 | ||
|
|
7ce0140c81 | ||
|
|
12b3034921 | ||
|
|
ec482ac867 | ||
|
|
ae52fb7a01 | ||
|
|
e8ff08e121 | ||
|
|
cc8e104cd6 | ||
|
|
5919a277bb | ||
|
|
96911d7790 | ||
|
|
acd3f7dba7 | ||
|
|
8aff3979db | ||
|
|
eafcd862be | ||
|
|
8826170635 | ||
|
|
c54e4d0900 | ||
|
|
52ca5c4aa2 | ||
|
|
95f8f80e74 | ||
|
|
7e380bb6f8 | ||
|
|
2477ffd860 | ||
|
|
a3dc46bf9d | ||
|
|
5c8e1b6eef | ||
|
|
ae9a8ce34c | ||
|
|
67b9a675f5 | ||
|
|
fae11e5a55 | ||
|
|
4daf75a469 | ||
|
|
d0293649cd | ||
|
|
353366ac54 | ||
|
|
1a8ffebb00 | ||
|
|
5ffbddcc57 | ||
|
|
5fbcbe7e52 | ||
|
|
7daa93cf5a | ||
|
|
9e32f29d19 | ||
|
|
1f25e38c2d | ||
|
|
c10a386d17 | ||
|
|
a13db82d28 | ||
|
|
ec392dc870 | ||
|
|
90d00fb095 | ||
|
|
e336b7f27e | ||
|
|
7f4c992dd7 | ||
|
|
ba1626a5b9 | ||
|
|
ab73c40bfe | ||
|
|
4016bc2416 | ||
|
|
9302daadc1 | ||
|
|
de7429e148 | ||
|
|
5892bd45d8 | ||
|
|
9317eccfc8 | ||
|
|
1236c4dafb | ||
|
|
f50f18f65a | ||
|
|
747cc4daa5 | ||
|
|
51b6a785e6 | ||
|
|
f4d41ef254 | ||
|
|
b9d80aa535 | ||
|
|
2f8213ca9a | ||
|
|
541b8cbb6c | ||
|
|
ed2e738ea4 | ||
|
|
17d9ba256b | ||
|
|
15dbac8193 | ||
|
|
2119854246 | ||
|
|
034c93fd65 | ||
|
|
ce91aba4de | ||
|
|
e33c09f8d4 | ||
|
|
a678c3f53e | ||
|
|
3e4fc7ff7f | ||
|
|
8dda07a1e9 | ||
|
|
e9f1851c5d | ||
|
|
ac659ff5a7 | ||
|
|
557f8e5a04 | ||
|
|
54de5ad3fa | ||
|
|
0709586e3a | ||
|
|
82ced33747 | ||
|
|
d31c5d7a2c | ||
|
|
2045487d5e | ||
|
|
4611e799b7 | ||
|
|
ffe9a2435b | ||
|
|
f5d8876384 | ||
|
|
d28265cfbe | ||
|
|
8059e83c49 | ||
|
|
d6f07c9f91 | ||
|
|
917cb8fa67 | ||
|
|
461db9e469 | ||
|
|
112908886c | ||
|
|
f734801da1 | ||
|
|
ea6dc7c710 | ||
|
|
cd81348ca5 | ||
|
|
ad91a09b07 | ||
|
|
040f73a3f4 | ||
|
|
0d8e0ddc4f | ||
|
|
8f9d7405ed | ||
|
|
72267e97ca | ||
|
|
19f87f0a89 | ||
|
|
9f7b1f0942 | ||
|
|
1ef888ca23 | ||
|
|
8b815bce94 | ||
|
|
97539db36d | ||
|
|
655fa5b8e0 | ||
|
|
9fbd3cc16f | ||
|
|
2295cbb815 | ||
|
|
198f8ea700 | ||
|
|
9fa9199747 | ||
|
|
1cd167a59a | ||
|
|
2868dc975c | ||
|
|
214ab16eb2 | ||
|
|
1c88d9575e | ||
|
|
1e4e02ddd3 | ||
|
|
f6fcddbe0b | ||
|
|
474180c112 | ||
|
|
c860573f13 | ||
|
|
c9c7354009 | ||
|
|
42eb7640f9 | ||
|
|
aafcd569b1 | ||
|
|
b549307ccf | ||
|
|
57090d4f8d | ||
|
|
764f7586de | ||
|
|
d96f2abc4e | ||
|
|
92f467e81c | ||
|
|
2442186a31 | ||
|
|
9fb74cb58a | ||
|
|
81e11c1d91 | ||
|
|
dc93350e0a | ||
|
|
3c6432da1f | ||
|
|
4eecb6841a | ||
|
|
3b83d3ff3a | ||
|
|
88b92a9605 | ||
|
|
3bb5baa6d2 | ||
|
|
59443d7ec6 | ||
|
|
c1d170e13d | ||
|
|
cffac6e11a | ||
|
|
79870472e1 | ||
|
|
1b69c94f76 | ||
|
|
cf8d1cf0e7 | ||
|
|
009fbeb543 | ||
|
|
9ceb8731d3 | ||
|
|
8f934bf817 | ||
|
|
88be2701f4 | ||
|
|
8ee62f0ac8 | ||
|
|
4d4308af78 | ||
|
|
f7c5eff35e | ||
|
|
3bc1644f34 | ||
|
|
27025b71db | ||
|
|
523d9ec3c2 | ||
|
|
aeb5455555 | ||
|
|
337390b590 | ||
|
|
836d950e05 | ||
|
|
ad096f77fc | ||
|
|
3774494f7e | ||
|
|
14fae5af9e | ||
|
|
65b48561a9 | ||
|
|
842dc14c18 | ||
|
|
af1afa7ba6 | ||
|
|
8c4c5e524b | ||
|
|
204bd7d2c4 | ||
|
|
f44014ff00 | ||
|
|
01719b02e2 | ||
|
|
4ba86bbe00 | ||
|
|
b85503b3b2 | ||
|
|
131a9aa1ac | ||
|
|
bd223606b1 | ||
|
|
f4fb80e523 | ||
|
|
49e466dd40 | ||
|
|
deec315f6a | ||
|
|
7fafe54e16 | ||
|
|
bdcbc829a0 | ||
|
|
4a64e86ecb | ||
|
|
1e2946ebc6 | ||
|
|
1ed5ca3fde | ||
|
|
aa62ac4042 | ||
|
|
e8f24910bd | ||
|
|
8d34e54dc5 | ||
|
|
c5ede3f167 | ||
|
|
1cd108e891 | ||
|
|
8878fd3028 | ||
|
|
a22d4e7962 | ||
|
|
25d2d7389f | ||
|
|
816b784399 | ||
|
|
c250f092bb | ||
|
|
b9c2bdf641 | ||
|
|
5ba90db049 | ||
|
|
88d20c5419 | ||
|
|
e158bee95f | ||
|
|
0139a77e94 | ||
|
|
e76d1b899b | ||
|
|
3fcdd6c9d7 | ||
|
|
bc916dbf35 | ||
|
|
96da2efb13 | ||
|
|
267cdf20e1 | ||
|
|
20c7df35c4 | ||
|
|
0f06e9926b | ||
|
|
93af424ce5 | ||
|
|
5e07400cd1 | ||
|
|
364a6a9444 | ||
|
|
b6bfd8e34f | ||
|
|
b05981ef27 | ||
|
|
42f1a56832 | ||
|
|
f667d56701 | ||
|
|
df5284beaf | ||
|
|
6d551b0d6e | ||
|
|
25e6339e2e | ||
|
|
f70fd30cd3 | ||
|
|
863d26558a | ||
|
|
cba12a1abd | ||
|
|
96d57a18ee | ||
|
|
e54ed10bc1 | ||
|
|
c8c807adcc | ||
|
|
cd6ed79433 | ||
|
|
ea4b3b74bb | ||
|
|
facfd64787 | ||
|
|
760a83d256 | ||
|
|
bbff19698b | ||
|
|
6f38cb162c | ||
|
|
af82224f82 | ||
|
|
a938e9473b | ||
|
|
3e88553d52 | ||
|
|
56245d5646 | ||
|
|
4af08b1606 | ||
|
|
fc4a395c88 | ||
|
|
de1813ab32 | ||
|
|
89ace66972 | ||
|
|
63f1857bda | ||
|
|
279500cba4 | ||
|
|
183270b443 | ||
|
|
a5f4332f21 | ||
|
|
6fad79f581 | ||
|
|
dff6274a93 | ||
|
|
082c872469 | ||
|
|
67a3dda53a | ||
|
|
950432eac0 | ||
|
|
6550e7d562 | ||
|
|
ffe75f3e20 |
No files matched your search
@@ -34,7 +34,7 @@ jobs:
|
|||||||
if: matrix.runtime == 'node'
|
if: matrix.runtime == 'node'
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
check-latest: true
|
check-latest: true
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
@@ -49,7 +49,7 @@ jobs:
|
|||||||
if: matrix.runtime == 'bun'
|
if: matrix.runtime == 'bun'
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
check-latest: true
|
check-latest: true
|
||||||
|
|
||||||
- name: Runtime versions
|
- name: Runtime versions
|
||||||
@@ -106,6 +106,7 @@ jobs:
|
|||||||
run: bunx tsc -p tsconfig.json
|
run: bunx tsc -p tsconfig.json
|
||||||
|
|
||||||
macos-app:
|
macos-app:
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -171,6 +172,7 @@ jobs:
|
|||||||
done
|
done
|
||||||
exit 1
|
exit 1
|
||||||
ios:
|
ios:
|
||||||
|
if: false # ignore iOS in CI for now
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.env
|
.env
|
||||||
dist
|
dist
|
||||||
|
*.bun-build
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
coverage
|
coverage
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
.worktrees/
|
.worktrees/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
**/.DS_Store
|
**/.DS_Store
|
||||||
|
ui/src/ui/__screenshots__/
|
||||||
|
|
||||||
# Bun build artifacts
|
# Bun build artifacts
|
||||||
*.bun-build
|
*.bun-build
|
||||||
apps/macos/.build/
|
apps/macos/.build/
|
||||||
apps/shared/ClawdisKit/.build/
|
apps/shared/ClawdisKit/.build/
|
||||||
|
bin/
|
||||||
bin/clawdis-mac
|
bin/clawdis-mac
|
||||||
bin/docs-list
|
bin/docs-list
|
||||||
apps/macos/.build-local/
|
apps/macos/.build-local/
|
||||||
@@ -21,6 +24,7 @@ Core/
|
|||||||
apps/ios/*.xcodeproj/
|
apps/ios/*.xcodeproj/
|
||||||
apps/ios/*.xcworkspace/
|
apps/ios/*.xcworkspace/
|
||||||
apps/ios/.swiftpm/
|
apps/ios/.swiftpm/
|
||||||
|
vendor/
|
||||||
|
|
||||||
# Vendor build artifacts
|
# Vendor build artifacts
|
||||||
vendor/a2ui/renderers/lit/dist/
|
vendor/a2ui/renderers/lit/dist/
|
||||||
|
|||||||
@@ -16,13 +16,14 @@
|
|||||||
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
|
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
|
||||||
- Formatting/linting via Biome; run `pnpm lint` before commits.
|
- 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`.
|
- Keep files concise; extract helpers instead of “V2” copies. Use existing patterns for CLI options and dependency injection via `createDefaultDeps`.
|
||||||
- Keep every file ≤ 500 LOC; refactor or split before exceeding and check frequently.
|
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.
|
||||||
|
|
||||||
## Testing Guidelines
|
## Testing Guidelines
|
||||||
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
|
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
|
||||||
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
|
- 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.
|
- 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.
|
- 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
|
## Commit & Pull Request Guidelines
|
||||||
- Create commits with `scripts/committer "<msg>" <file...>`; avoid manual `git add`/`git commit` so staging stays scoped.
|
- Create commits with `scripts/committer "<msg>" <file...>`; avoid manual `git add`/`git commit` so staging stays scoped.
|
||||||
@@ -41,11 +42,15 @@
|
|||||||
- 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.
|
- 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.
|
- 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.
|
||||||
- **Restart apps:** “restart iOS/Android apps” means rebuild (recompile/install) and relaunch, not just kill/launch.
|
- **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; regenerate via `pnpm canvas:a2ui:bundle` (or `scripts/bundle-a2ui.sh`) instead of manual conflict resolution.
|
||||||
- Notary key file lives at `~/Library/CloudStorage/Dropbox/Backup/AppStore/AuthKey_NJF3NFGTS3.p8` (Sparkle keys live under `~/Library/CloudStorage/Dropbox/Backup/Sparkle`).
|
- Notary key file lives at `~/Library/CloudStorage/Dropbox/Backup/AppStore/AuthKey_NJF3NFGTS3.p8` (Sparkle keys live under `~/Library/CloudStorage/Dropbox/Backup/Sparkle`).
|
||||||
- **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:** 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:** 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** 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:** 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.
|
||||||
- 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`. If logs are needed from Mac Studio, SSH via Tailscale and read the same path there.
|
- 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`. 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 Clawdis variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
|
- 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.
|
- 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.
|
||||||
|
|||||||
+195
-9
@@ -1,9 +1,195 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## Unreleased — 2025-12-23
|
## 2.0.0-beta5 — Unreleased
|
||||||
|
|
||||||
|
### Breaking
|
||||||
|
- Skills config schema moved under `skills.*`:
|
||||||
|
- `skillsLoad.extraDirs` → `skills.load.extraDirs`
|
||||||
|
- `skillsInstall.*` → `skills.install.*`
|
||||||
|
- per-skill config map moved to `skills.entries` (e.g. `skills.peekaboo.enabled` → `skills.entries.peekaboo.enabled`)
|
||||||
|
- new optional bundled allowlist: `skills.allowBundled` (only affects bundled skills)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech.
|
||||||
|
- UI: add optional `ui.seamColor` accent to tint the Talk Mode side bubble (macOS/iOS/Android).
|
||||||
|
- Nix mode: opt-in declarative config + read-only settings UI when `CLAWDIS_NIX_MODE=1` (thanks @joshp123 for the persistence — earned my trust; I'll merge these going forward).
|
||||||
|
- Agent runtime: accept legacy `Z_AI_API_KEY` for Z.AI provider auth (maps to `ZAI_API_KEY`).
|
||||||
|
- Tests: add a Z.AI live test gate for smoke validation when keys are present.
|
||||||
|
- macOS Debug: add app log verbosity and rolling file log toggle for swift-log-backed app logs.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Telegram/WhatsApp: native replies now target the original inbound message; reply context is captured in `ReplyTo*` fields for templates. (Thanks @joshp123 for the PR and follow-up question.)
|
- macOS codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b
|
||||||
|
- Docs/agent tools: clarify that browser `wait` should be avoided by default and used only in exceptional cases.
|
||||||
|
- Browser tools: `upload` supports auto-click refs, direct `inputRef`/`element` file inputs, and emits input/change after `setFiles` so JS-heavy sites pick up attachments.
|
||||||
|
- macOS: Voice Wake now fully tears down the Speech pipeline when disabled (cancel pending restarts, drop stale callbacks) to avoid high CPU in the background.
|
||||||
|
- macOS menu: add a Talk Mode action alongside the Open Dashboard/Chat/Canvas entries.
|
||||||
|
- macOS Debug: hide “Restart Gateway” when the app won’t start a local gateway (remote mode / attach-only).
|
||||||
|
- macOS Talk Mode: orb overlay refresh, ElevenLabs request logging, API key status in settings, and auto-select first voice when none is configured.
|
||||||
|
- macOS Talk Mode: add hard timeout around ElevenLabs TTS synthesis to avoid getting stuck “speaking” forever on hung requests.
|
||||||
|
- macOS Talk Mode: avoid stuck playback when the audio player never starts (fail-fast + watchdog).
|
||||||
|
- macOS Talk Mode: fix audio stop ordering so disabling Talk Mode always stops in-flight playback.
|
||||||
|
- macOS Talk Mode: throttle audio-level updates (avoid per-buffer task creation) to reduce CPU/task churn.
|
||||||
|
- macOS Talk Mode: increase overlay window size so wave rings don’t clip; close button is hover-only and closer to the orb.
|
||||||
|
- Talk Mode: fall back to system TTS when ElevenLabs is unavailable, returns non-audio, or playback fails (macOS/iOS/Android).
|
||||||
|
- Talk Mode: stream PCM on macOS/iOS for lower latency (incremental playback); Android continues MP3 streaming.
|
||||||
|
- Talk Mode: validate ElevenLabs v3 stability and latency tier directives before sending requests.
|
||||||
|
- iOS/Android Talk Mode: auto-select the first ElevenLabs voice when none is configured.
|
||||||
|
- ElevenLabs: add retry/backoff for 429/5xx and include content-type in errors for debugging.
|
||||||
|
- Talk Mode: align to the gateway’s main session key and fall back to history polling when chat events drop (prevents stuck “thinking” / missing messages).
|
||||||
|
- Talk Mode: treat history timestamps as seconds or milliseconds to avoid stale assistant picks (macOS/iOS/Android).
|
||||||
|
- Chat UI: clear streaming/tool bubbles when external runs finish, preventing duplicate assistant bubbles.
|
||||||
|
- Chat UI: user bubbles use `ui.seamColor` (fallback to a calmer default blue).
|
||||||
|
- Android Chat UI: use `onPrimary` for user bubble text to preserve contrast (thanks @Syhids).
|
||||||
|
- Control UI: sync sidebar navigation with the URL for deep-linking, and auto-scroll chat to the latest message.
|
||||||
|
- Control UI: disable Web Chat + Talk when no iOS/Android node is connected; refreshed Web Chat styling and keyboard send.
|
||||||
|
- macOS Web Chat: fix composer layout so the connection pill and send button stay inside the input field.
|
||||||
|
- macOS: bundle Control UI assets into the app relay so the packaged app can serve them (thanks @mbelinky).
|
||||||
|
- Talk Mode: wait for chat history to surface the assistant reply before starting TTS (macOS/iOS/Android).
|
||||||
|
- iOS Talk Mode: fix chat completion wait to time out even if no events arrive (prevents “Thinking…” hangs).
|
||||||
|
- iOS Talk Mode: keep recognition running during playback to support interrupt-on-speech.
|
||||||
|
- iOS Talk Mode: preserve directive voice/model overrides across config reloads and add ElevenLabs request timeouts.
|
||||||
|
- iOS/Android Talk Mode: explicitly `chat.subscribe` when Talk Mode is active, so completion events arrive even if the Chat UI isn’t open.
|
||||||
|
- Chat UI: refresh history when another client finishes a run in the same session, so Talk Mode + Voice Wake transcripts appear consistently.
|
||||||
|
- Gateway: `voice.transcript` now also maps agent bus output to `chat` events, ensuring chat UIs refresh for voice-triggered runs.
|
||||||
|
- iOS/Android: show a centered Talk Mode orb overlay while Talk Mode is enabled.
|
||||||
|
- Gateway config: inject `talk.apiKey` from `ELEVENLABS_API_KEY`/shell profile so nodes can fetch it on demand.
|
||||||
|
- Canvas A2UI: tag requests with `platform=android|ios|macos` and boost Android canvas background contrast.
|
||||||
|
- iOS/Android nodes: enable scrolling for loaded web pages in the Canvas WebView (default scaffold stays touch-first).
|
||||||
|
- macOS menu: device list now uses `node.list` (devices only; no agent/tool presence entries).
|
||||||
|
- macOS menu: device list now shows connected nodes only.
|
||||||
|
- macOS menu: device rows now pack platform/version on the first line, and command lists wrap in submenus.
|
||||||
|
- macOS menu: split device platform/version across first and second rows for better fit.
|
||||||
|
- iOS node: fix ReplayKit screen recording crash caused by queue isolation assertions during capture.
|
||||||
|
- iOS Talk Mode: avoid audio tap queue assertions when starting recognition.
|
||||||
|
- macOS: use $HOME/Library/pnpm for SSH PATH exports (thanks @mbelinky).
|
||||||
|
- iOS/Android nodes: bridge auto-connect refreshes stale tokens and settings now show richer bridge/device details.
|
||||||
|
- macOS: bundle device model resources to prevent Instances crashes (thanks @mbelinky).
|
||||||
|
- iOS/Android nodes: status pill now surfaces camera activity instead of overlay toasts.
|
||||||
|
- iOS/Android/macOS nodes: camera snaps recompress to keep base64 payloads under 5 MB.
|
||||||
|
- iOS/Android nodes: status pill now surfaces pairing, screen recording, voice wake, and foreground-required states.
|
||||||
|
- iOS/Android nodes: avoid duplicating “Gateway reconnecting…” when the bridge is already connecting.
|
||||||
|
- iOS/Android nodes: Talk Mode now lives on a side bubble (with an iOS toggle to hide it), and Android settings no longer show the Talk Mode switch.
|
||||||
|
- macOS menu: top status line now shows pending node pairing approvals (incl. repairs).
|
||||||
|
- CLI: avoid spurious gateway close errors after successful request/response cycles.
|
||||||
|
- Agent runtime: clamp tool-result images to the 5MB Anthropic limit to avoid hard request rejections.
|
||||||
|
- Tests: add Swift Testing coverage for camera errors and Kotest coverage for Android bridge endpoints.
|
||||||
|
|
||||||
|
## 2.0.0-beta4 — 2025-12-27
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Package contents: include Discord/hooks build outputs in the npm tarball to avoid missing module errors.
|
||||||
|
- Heartbeat replies now drop any output containing `HEARTBEAT_OK`, preventing stray emoji/text from being delivered.
|
||||||
|
- macOS menu now refreshes the control channel after the gateway starts and shows “Connecting to gateway…” while the gateway is coming up.
|
||||||
|
- macOS local mode now waits for the gateway to be ready before configuring the control channel, avoiding false “no connection” flashes.
|
||||||
|
- WhatsApp watchdog now forces a reconnect even if the socket close event stalls (force-close to unblock reconnect loop).
|
||||||
|
- Gateway presence now reports macOS product version (via `sw_vers`) instead of Darwin kernel version.
|
||||||
|
|
||||||
|
## 2.0.0-beta3 — 2025-12-27
|
||||||
|
|
||||||
|
### Highlights
|
||||||
|
- First-class Clawdis tools (browser, canvas, nodes, cron) replace the old `clawdis-*` skills; tool schemas are now injected directly into the agent runtime.
|
||||||
|
- Per-session model selection + custom model providers: `models.providers` merges into `~/.clawdis/agent/models.json` (merge/replace modes) for LiteLLM, local OpenAI-compatible servers, Anthropic proxies, etc.
|
||||||
|
- Group chat activation modes: per-group `/activation mention|always` command with status visibility.
|
||||||
|
- Discord bot transport for DMs and guild text channels, with allowlists + mention gating.
|
||||||
|
- Gateway webhooks: external `wake` and isolated `agent` hooks with dedicated token auth.
|
||||||
|
- Hook mappings + Gmail Pub/Sub helper (`clawdis hooks gmail setup/run`) with auto-renew + Tailscale Funnel support.
|
||||||
|
- Command queue modes + per-session overrides (`/queue ...`) and new `agent.maxConcurrent` cap for safe parallelism across sessions.
|
||||||
|
- Background bash tasks: `bash` auto-yields after 20s (or on demand) with a `process` tool to list/poll/log/write/kill sessions.
|
||||||
|
- Gateway in-process restart: `clawdis_gateway` tool action triggers a SIGUSR1 restart without needing a supervisor.
|
||||||
|
|
||||||
|
### Breaking
|
||||||
|
- Config refactor: `inbound.*` removed; use top-level `routing` (allowlists + group rules + transcription), `messages` (prefixes/timestamps), and `session` (scoping/store/mainKey). No legacy keys read.
|
||||||
|
- Heartbeat config moved to `agent.heartbeat`: set `every: "30m"` (duration string) and optional `model`. `agent.heartbeatMinutes` is removed, and heartbeats are disabled unless `agent.heartbeat.every` is set.
|
||||||
|
- Heartbeats now run via the gateway runner (main session) and deliver to the last used channel by default. WhatsApp reply-heartbeat behavior is removed; use `agent.heartbeat.target`/`to` (or `target: "none"`) to control delivery.
|
||||||
|
- Browser `act` no longer accepts CSS `selector`; use `snapshot` refs (default `ai`) or `evaluate` as an escape hatch.
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Heartbeat replies now strip repeated `HEARTBEAT_OK` tails to avoid accidental “OK OK” spam.
|
||||||
|
- Heartbeat delivery now uses the last non-empty payload, preventing tool preambles from swallowing the final reply.
|
||||||
|
- Heartbeats now skip WhatsApp delivery when the web provider is inactive or unlinked (instead of logging “no active gateway listener”).
|
||||||
|
- Heartbeat failure logs now include the error reason instead of `[object Object]`.
|
||||||
|
- Duration strings now accept `h` (hours) where durations are parsed (e.g., heartbeat intervals).
|
||||||
|
- WhatsApp inbound now normalizes more wrapper types so quoted reply bodies are extracted reliably.
|
||||||
|
- WhatsApp send now preserves existing JIDs (including group `@g.us`) instead of coercing to `@s.whatsapp.net`. (Thanks @arun-8687.)
|
||||||
|
- Telegram/WhatsApp: reply context stays in `Body`/`ReplyTo*`, but outbound replies no longer thread to the original message. (Thanks @joshp123 for the PR and follow-up question.)
|
||||||
|
- Suppressed libsignal session cleanup spam from console logs unless verbose mode is enabled.
|
||||||
|
- WhatsApp web creds persistence hardened; credentials are restored before auth checks and QR login auto-restarts if it stalls.
|
||||||
|
- Group chats now honor `routing.groupChat.requireMention=false` as the default activation when no per-group override exists.
|
||||||
|
- Gateway auth no longer supports PAM/system mode; use token or shared password.
|
||||||
|
- Tailscale Funnel now requires password auth (no token-only public exposure).
|
||||||
|
- Group `/new` resets now work with @mentions so activation guidance appears on fresh sessions.
|
||||||
|
- Group chat activation context is now injected into the system prompt at session start (and after activation changes), including /new greetings.
|
||||||
|
- Typing indicators now start only once a reply payload is produced (no "thinking" typing for silent runs).
|
||||||
|
- WhatsApp group typing now starts immediately only when the bot is mentioned; otherwise it waits until real output exists.
|
||||||
|
- Streamed `<think>` segments are stripped before partial replies are emitted.
|
||||||
|
- System prompt now tags allowlisted owner numbers as the user identity to avoid mistaken “friend” assumptions.
|
||||||
|
- LM Studio/Ollama replies now require <final> tags; streaming ignores content until <final> begins.
|
||||||
|
- LM Studio responses API: tools payloads no longer include `strict: null`, and LM Studio no longer gets forced `<think>/<final>` tags.
|
||||||
|
- Identity emoji no longer auto-prefixes replies (set `messages.responsePrefix` explicitly if desired).
|
||||||
|
- Model switches now enqueue a system event so the next run knows the active model.
|
||||||
|
- `/model status` now lists available models (same as `/model`).
|
||||||
|
- `process log` pagination is now line-based (omit `offset` to grab the last N lines).
|
||||||
|
- macOS WebChat: assistant bubbles now update correctly when toggling light/dark mode.
|
||||||
|
- macOS: avoid spawning a duplicate gateway process when an external listener already exists.
|
||||||
|
- Node bridge: when binding to a non-loopback host (e.g. Tailnet IP), also listens on `127.0.0.1` for local connections (without creating duplicate loopback listeners for `0.0.0.0`/`127.0.0.1` binds).
|
||||||
|
- UI perf: pause repeat animations when scenes are inactive (typing dots, onboarding glow, iOS status pulse), throttle voice overlay level updates, and reduce overlay focus churn.
|
||||||
|
- Canvas defaults/A2UI auto-nav aligned; debug status overlay centered; redundant await removed in `CanvasManager`.
|
||||||
|
- Gateway launchd loop fixed by removing redundant `kickstart -k`.
|
||||||
|
- CLI now hints when Peekaboo is unauthorized.
|
||||||
|
- WhatsApp web inbox listeners now clean up on close to avoid duplicate handlers.
|
||||||
|
- Gateway startup now brings up browser control before external providers; WhatsApp/Telegram/Discord auto-start can be disabled with `web.enabled`, `telegram.enabled`, or `discord.enabled`.
|
||||||
|
|
||||||
|
### Providers & Routing
|
||||||
|
- New Discord provider for DMs + guild text channels with allowlists and mention-gated replies by default.
|
||||||
|
- `routing.queue` now controls queue vs interrupt behavior globally + per surface (defaults: WhatsApp/Telegram interrupt, Discord/WebChat queue).
|
||||||
|
- `/queue <mode>` supports one-shot or per-session overrides; `/queue reset|default` clears overrides.
|
||||||
|
- `agent.maxConcurrent` caps global parallel runs while keeping per-session serialization.
|
||||||
|
|
||||||
|
### macOS app
|
||||||
|
- Update-ready state surfaced in the menu; menu sections regrouped with session submenus.
|
||||||
|
- Menu bar now shows a dedicated Nodes section under Context with inline rows, overflow submenu, and iconized actions.
|
||||||
|
- Nodes now expose consistent inline details with per-node submenus for quick copy of key fields.
|
||||||
|
- Node rows now show compact app versions (build numbers moved to submenus) and offer SSH launch from Bonjour when available.
|
||||||
|
- Menu actions are grouped below toggles; Open Canvas hides when disabled and Voice Wake now anchors the mic picker.
|
||||||
|
- Connections now include Discord provider status + configuration UI.
|
||||||
|
- Menu bar gains an Allow Camera toggle alongside Canvas.
|
||||||
|
- Session list polish: sleeping/disconnected/error states, usage bar restored, padding + bar sizing tuned, syncing menu removed, header hidden when disconnected.
|
||||||
|
- Chat UI polish: tool call cards + merged tool results, glass background, tighter composer spacing, visual effect host tweaks.
|
||||||
|
- OAuth storage moved; legacy session syncing metadata removed.
|
||||||
|
- Remote SSH tunnels now get health checks; Debug → Ports highlights unhealthy tunnels and offers Reset SSH tunnel.
|
||||||
|
- Menu bar session/node sections no longer reflow while open, keeping hover highlights aligned.
|
||||||
|
- Menu hover highlights now span the full width (including submenu arrows).
|
||||||
|
- Menu session rows now refresh while open without width changes (no more stuck “Loading sessions…”).
|
||||||
|
- Menu width no longer grows on hover when moving the mouse across rows.
|
||||||
|
- Context usage bars now have higher contrast in light mode.
|
||||||
|
- macOS node timeouts now share a single async timeout helper for consistent behavior.
|
||||||
|
- WebChat window defaults tightened (narrower width, edge-to-edge layout) and the SwiftUI tag removed from the title.
|
||||||
|
|
||||||
|
### Nodes & Canvas
|
||||||
|
- Debug status overlay gated and toggleable on macOS/iOS/Android nodes.
|
||||||
|
- Gateway now derives the canvas host URL via a shared helper for bridge + WS handshakes (avoids loopback pitfalls).
|
||||||
|
- `canvas a2ui push` validates JSONL with line errors, rejects v0.9 payloads, and supports `--text` quick renders.
|
||||||
|
- `nodes rename` lets you override paired node display names without editing JSON.
|
||||||
|
- Android scaffold asset cleanup; iOS canvas/voice wake adjustments.
|
||||||
|
|
||||||
|
### Logging & Observability
|
||||||
|
- New subsystem console formatter with color modes, shortened prefixes, and TTY detection; browser/gateway logs route through the subsystem logger.
|
||||||
|
- WhatsApp console output streamlined; chalk/tslog typing fixes.
|
||||||
|
|
||||||
|
### Web UI
|
||||||
|
- Chat is now the dashboard landing view; health status simplified; initial scroll animation removed.
|
||||||
|
|
||||||
|
### Build, Dev, Docs
|
||||||
|
- Notarization flow added for macOS release artifacts; packaging scripts updated.
|
||||||
|
- macOS signing auto-selects Developer ID → Apple Distribution → Apple Development; no ad-hoc fallback.
|
||||||
|
- Added type-aware oxlint; docs list resolves from cwd; formatting/lint cleanup and dependency bumps (Peekaboo).
|
||||||
|
- Docs refreshed for tools, custom model providers, Discord, queue/routing, group activation commands, logging, restart semantics, release notes, GitHub pages CTAs, and npm pitfalls.
|
||||||
|
- `pnpm build` now skips A2UI bundling for faster builds (run `pnpm canvas:a2ui:bundle` when needed).
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- Coverage added for models config merging, WhatsApp reply context, QR login flows, auto-reply behavior, and gateway SIGTERM timeouts.
|
||||||
|
- Added gateway webhook coverage (auth, validation, and summary posting).
|
||||||
|
- Vitest now isolates HOME/XDG config roots so tests never touch a real `~/.clawdis` install.
|
||||||
|
|
||||||
## 2.0.0-beta2 — 2025-12-21
|
## 2.0.0-beta2 — 2025-12-21
|
||||||
|
|
||||||
@@ -45,7 +231,7 @@ First Clawdis release post rebrand. This is a semver-major because we dropped le
|
|||||||
|
|
||||||
### Breaking
|
### Breaking
|
||||||
- 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.
|
- 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.
|
- Pi only: only the embedded Pi runtime remains, 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.
|
- 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>`.
|
- 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.
|
- 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.
|
||||||
@@ -93,7 +279,7 @@ First Clawdis release post rebrand. This is a semver-major because we dropped le
|
|||||||
## 1.5.0 — 2025-12-05
|
## 1.5.0 — 2025-12-05
|
||||||
|
|
||||||
### Breaking
|
### Breaking
|
||||||
- Dropped all non-Pi agents (Claude, Codex, Gemini, Opencode); `inbound.reply.agent.kind` now only accepts `"pi"` and related CLI helpers have been removed.
|
- Dropped all non-Pi agents (Claude, Codex, Gemini, Opencode); only the embedded Pi runtime remains 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.
|
- Removed Twilio support and all related commands/options (webhook/up/provider flags/wait-poll); CLAWDIS is Baileys Web-only.
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
@@ -120,7 +306,7 @@ First Clawdis release post rebrand. This is a semver-major because we dropped le
|
|||||||
## 1.4.0 — 2025-12-03
|
## 1.4.0 — 2025-12-03
|
||||||
|
|
||||||
### Highlights
|
### Highlights
|
||||||
- **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`.
|
- **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 > `agent.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 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.
|
- **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.
|
- **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.
|
||||||
@@ -162,7 +348,7 @@ First Clawdis release post rebrand. This is a semver-major because we dropped le
|
|||||||
## 1.3.0 — 2025-12-02
|
## 1.3.0 — 2025-12-02
|
||||||
|
|
||||||
### Highlights
|
### Highlights
|
||||||
- **Pluggable agents (Claude, Pi, Codex, Opencode):** `inbound.reply.agent` selects CLI/parser; per-agent argv builders and NDJSON parsers enable swapping without template changes.
|
- **Pluggable agents (Claude, Pi, Codex, Opencode):** agent selection via config/CLI plus 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.
|
- **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.
|
- **Agent session reliability:** Only Claude returns a stable `session_id`; others may reset between runs.
|
||||||
|
|
||||||
@@ -179,7 +365,7 @@ First Clawdis release post rebrand. This is a semver-major because we dropped le
|
|||||||
- Batched inbound messages with timestamps; typing indicator after sends.
|
- Batched inbound messages with timestamps; typing indicator after sends.
|
||||||
- Watchdog restarts WhatsApp after long inactivity; heartbeat logging includes minutes since last message.
|
- Watchdog restarts WhatsApp after long inactivity; heartbeat logging includes minutes since last message.
|
||||||
- Early `allowFrom` filtering before decryption.
|
- Early `allowFrom` filtering before decryption.
|
||||||
- Same-phone mode with echo detection and optional `inbound.samePhoneMarker`.
|
- Same-phone mode with echo detection and optional message prefix marker.
|
||||||
|
|
||||||
## 1.2.2 — 2025-11-28
|
## 1.2.2 — 2025-11-28
|
||||||
|
|
||||||
@@ -209,10 +395,10 @@ First Clawdis release post rebrand. This is a semver-major because we dropped le
|
|||||||
## 1.1.0 — 2025-11-26
|
## 1.1.0 — 2025-11-26
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
- Web auto-replies resize/recompress media and honor `inbound.reply.mediaMaxMb`.
|
- Web auto-replies resize/recompress media and honor `agent.mediaMaxMb`.
|
||||||
- Detect media kind, enforce provider caps (images ≤6MB, audio/video ≤16MB, docs ≤100MB).
|
- Detect media kind, enforce provider caps (images ≤6MB, audio/video ≤16MB, docs ≤100MB).
|
||||||
- `session.sendSystemOnce` and optional `sessionIntro`.
|
- `session.sendSystemOnce` and optional `sessionIntro`.
|
||||||
- Typing indicator refresh during commands; configurable via `inbound.reply.typingIntervalSeconds`.
|
- Typing indicator refresh during commands; configurable via `agent.typingIntervalSeconds`.
|
||||||
- Optional audio transcription via external CLI.
|
- Optional audio transcription via external CLI.
|
||||||
- Command replies return structured payload/meta; respect `mediaMaxMb`; log Claude metadata; include `cwd` in timeout messages.
|
- 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.
|
- Web provider refactor; logout command; web-only gateway start helper.
|
||||||
|
|||||||
@@ -15,10 +15,12 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
**Clawdis** is a *personal AI assistant* you run on your own devices.
|
**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.
|
It answers you on the surfaces you already use (WhatsApp, Telegram, Discord, 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 private, single-user assistant that feels local, fast, and always-on, this is it.
|
If you want a private, single-user assistant that feels local, fast, and always-on, this is it.
|
||||||
|
|
||||||
|
Using Claude Pro/Max subscription? See `docs/onboarding.md` for the Anthropic OAuth setup.
|
||||||
|
|
||||||
```
|
```
|
||||||
Your surfaces
|
Your surfaces
|
||||||
│
|
│
|
||||||
@@ -38,12 +40,13 @@ Your surfaces
|
|||||||
## What Clawdis does
|
## What Clawdis does
|
||||||
|
|
||||||
- **Personal assistant** — one user, one identity, one memory surface.
|
- **Personal assistant** — one user, one identity, one memory surface.
|
||||||
- **Multi-surface inbox** — WhatsApp, Telegram, WebChat, macOS, iOS.
|
- **Multi-surface inbox** — WhatsApp, Telegram, Discord, WebChat, macOS, iOS.
|
||||||
- **Voice wake + push-to-talk** — local speech recognition on macOS/iOS.
|
- **Voice wake + push-to-talk** — local speech recognition on macOS/iOS.
|
||||||
- **Canvas** — a live visual workspace you can drive from the agent.
|
- **Canvas** — a live visual workspace you can drive from the agent.
|
||||||
- **Automation-ready** — browser control, media handling, and tool streaming.
|
- **Automation-ready** — browser control, media handling, and tool streaming.
|
||||||
- **Local-first control plane** — the Gateway owns state, everything else connects.
|
- **Local-first control plane** — the Gateway owns state, everything else connects.
|
||||||
- **Group chats** — mention-based by default, `/activation always|mention` per group (owner-only).
|
- **Group chats** — mention-based by default, `/activation always|mention` per group (owner-only).
|
||||||
|
- **Nix mode** — opt-in declarative config + read-only UI when `CLAWDIS_NIX_MODE=1`.
|
||||||
|
|
||||||
## How it works (short)
|
## How it works (short)
|
||||||
|
|
||||||
@@ -67,10 +70,13 @@ pnpm clawdis login
|
|||||||
# Start the gateway
|
# Start the gateway
|
||||||
pnpm clawdis gateway --port 18789 --verbose
|
pnpm clawdis gateway --port 18789 --verbose
|
||||||
|
|
||||||
|
# Dev loop (auto-reload on TS changes)
|
||||||
|
pnpm gateway:watch
|
||||||
|
|
||||||
# Send a message
|
# Send a message
|
||||||
pnpm clawdis send --to +1234567890 --message "Hello from Clawdis"
|
pnpm clawdis send --to +1234567890 --message "Hello from Clawdis"
|
||||||
|
|
||||||
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram)
|
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Discord)
|
||||||
pnpm clawdis agent --message "Ship checklist" --thinking high
|
pnpm clawdis agent --message "Ship checklist" --thinking high
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -133,7 +139,7 @@ Runbook: `docs/ios/connect.md`.
|
|||||||
|
|
||||||
## Agent workspace + skills
|
## Agent workspace + skills
|
||||||
|
|
||||||
- Workspace root: `~/clawd` (configurable via `inbound.workspace`).
|
- Workspace root: `~/clawd` (configurable via `agent.workspace`).
|
||||||
- Injected prompt files: `AGENTS.md`, `SOUL.md`, `TOOLS.md`.
|
- Injected prompt files: `AGENTS.md`, `SOUL.md`, `TOOLS.md`.
|
||||||
- Skills: `~/clawd/skills/<skill>/SKILL.md`.
|
- Skills: `~/clawd/skills/<skill>/SKILL.md`.
|
||||||
|
|
||||||
@@ -143,7 +149,7 @@ Minimal `~/.clawdis/clawdis.json`:
|
|||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
inbound: {
|
routing: {
|
||||||
allowFrom: ["+1234567890"]
|
allowFrom: ["+1234567890"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -152,7 +158,7 @@ Minimal `~/.clawdis/clawdis.json`:
|
|||||||
### WhatsApp
|
### WhatsApp
|
||||||
|
|
||||||
- Link the device: `pnpm clawdis login` (stores creds in `~/.clawdis/credentials`).
|
- Link the device: `pnpm clawdis login` (stores creds in `~/.clawdis/credentials`).
|
||||||
- Allowlist who can talk to the assistant via `inbound.allowFrom`.
|
- Allowlist who can talk to the assistant via `routing.allowFrom`.
|
||||||
|
|
||||||
### Telegram
|
### Telegram
|
||||||
|
|
||||||
@@ -167,6 +173,19 @@ Minimal `~/.clawdis/clawdis.json`:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Discord
|
||||||
|
|
||||||
|
- Set `DISCORD_BOT_TOKEN` or `discord.token` (env wins).
|
||||||
|
- Optional: set `discord.requireMention`, `discord.allowFrom`, or `discord.mediaMaxMb` as needed.
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
discord: {
|
||||||
|
token: "1234abcd"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
Browser control (optional):
|
Browser control (optional):
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
@@ -188,6 +207,16 @@ Browser control (optional):
|
|||||||
- [`docs/web.md`](docs/web.md)
|
- [`docs/web.md`](docs/web.md)
|
||||||
- [`docs/discovery.md`](docs/discovery.md)
|
- [`docs/discovery.md`](docs/discovery.md)
|
||||||
- [`docs/agent.md`](docs/agent.md)
|
- [`docs/agent.md`](docs/agent.md)
|
||||||
|
- [`docs/discord.md`](docs/discord.md)
|
||||||
|
- Webhooks + external triggers: [`docs/webhook.md`](docs/webhook.md)
|
||||||
|
- Gmail hooks (email → wake): [`docs/gmail-pubsub.md`](docs/gmail-pubsub.md)
|
||||||
|
|
||||||
|
## Email hooks (Gmail)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdis hooks gmail setup --account you@gmail.com
|
||||||
|
clawdis hooks gmail run
|
||||||
|
```
|
||||||
- [`docs/security.md`](docs/security.md)
|
- [`docs/security.md`](docs/security.md)
|
||||||
- [`docs/troubleshooting.md`](docs/troubleshooting.md)
|
- [`docs/troubleshooting.md`](docs/troubleshooting.md)
|
||||||
- [`docs/ios/connect.md`](docs/ios/connect.md)
|
- [`docs/ios/connect.md`](docs/ios/connect.md)
|
||||||
|
|||||||
@@ -1,15 +1,6 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "3018b2c8c183d55b57ad0c4526b2380ac3b957d13a3a86e1b2845e81323c443a",
|
"originHash" : "2012d083159d375d07febbc184c592c569d7ab48247045e35a762e3269d4cadc",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
|
||||||
"identity" : "commander",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/steipete/Commander.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "8b8cb4f34315ce9e5307b3a2bcd77ff73f586a02",
|
|
||||||
"version" : "0.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"identity" : "swift-syntax",
|
"identity" : "swift-syntax",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ let package = Package(
|
|||||||
.executable(name: "swabble", targets: ["SwabbleCLI"]),
|
.executable(name: "swabble", targets: ["SwabbleCLI"]),
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(url: "https://github.com/steipete/Commander.git", from: "0.2.0"),
|
.package(path: "../Peekaboo/Commander"),
|
||||||
.package(url: "https://github.com/apple/swift-testing", from: "0.99.0"),
|
.package(url: "https://github.com/apple/swift-testing", from: "0.99.0"),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
|
|||||||
@@ -68,21 +68,13 @@ public enum WakeWordGate {
|
|||||||
let tokens = self.normalizeSegments(segments)
|
let tokens = self.normalizeSegments(segments)
|
||||||
guard !tokens.isEmpty else { return nil }
|
guard !tokens.isEmpty else { return nil }
|
||||||
|
|
||||||
var bestIndex: Int?
|
var best: (index: Int, triggerEnd: TimeInterval, gap: TimeInterval)?
|
||||||
var bestTriggerEnd: TimeInterval = 0
|
|
||||||
var bestGap: TimeInterval = 0
|
|
||||||
|
|
||||||
for trigger in triggerTokens {
|
for trigger in triggerTokens {
|
||||||
let count = trigger.tokens.count
|
let count = trigger.tokens.count
|
||||||
guard count > 0, tokens.count > count else { continue }
|
guard count > 0, tokens.count > count else { continue }
|
||||||
for i in 0...(tokens.count - count - 1) {
|
for i in 0...(tokens.count - count - 1) {
|
||||||
var matched = true
|
let matched = (0..<count).allSatisfy { tokens[i + $0].normalized == trigger.tokens[$0] }
|
||||||
for t in 0..<count {
|
|
||||||
if tokens[i + t].normalized != trigger.tokens[t] {
|
|
||||||
matched = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !matched { continue }
|
if !matched { continue }
|
||||||
|
|
||||||
let triggerEnd = tokens[i + count - 1].end
|
let triggerEnd = tokens[i + count - 1].end
|
||||||
@@ -90,19 +82,17 @@ public enum WakeWordGate {
|
|||||||
let gap = nextToken.start - triggerEnd
|
let gap = nextToken.start - triggerEnd
|
||||||
if gap < config.minPostTriggerGap { continue }
|
if gap < config.minPostTriggerGap { continue }
|
||||||
|
|
||||||
if let bestIndex, i <= bestIndex { continue }
|
if let best, i <= best.index { continue }
|
||||||
|
|
||||||
bestIndex = i
|
best = (i, triggerEnd, gap)
|
||||||
bestTriggerEnd = triggerEnd
|
|
||||||
bestGap = gap
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let bestIndex else { return nil }
|
guard let best else { return nil }
|
||||||
let command = self.commandText(transcript: transcript, segments: segments, triggerEndTime: bestTriggerEnd)
|
let command = self.commandText(transcript: transcript, segments: segments, triggerEndTime: best.triggerEnd)
|
||||||
.trimmingCharacters(in: Self.whitespaceAndPunctuation)
|
.trimmingCharacters(in: Self.whitespaceAndPunctuation)
|
||||||
guard command.count >= config.minCommandLength else { return nil }
|
guard command.count >= config.minCommandLength else { return nil }
|
||||||
return WakeWordGateMatch(triggerEndTime: bestTriggerEnd, postGap: bestGap, command: command)
|
return WakeWordGateMatch(triggerEndTime: best.triggerEnd, postGap: best.gap, command: command)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func commandText(
|
public static func commandText(
|
||||||
|
|||||||
+30
-31
@@ -1,32 +1,31 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" standalone="yes"?>
|
||||||
<rss version="2.0"
|
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||||
xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle"
|
<channel>
|
||||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
<title>Clawdis</title>
|
||||||
<channel>
|
<item>
|
||||||
<title>Clawdis Updates</title>
|
<title>2.0.0-beta3</title>
|
||||||
<link>https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml</link>
|
<pubDate>Sat, 27 Dec 2025 19:02:02 +0100</pubDate>
|
||||||
<description>Signed update feed for the Clawdis macOS companion app.</description>
|
<link>https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml</link>
|
||||||
<item>
|
<sparkle:version>2.0.0-beta3</sparkle:version>
|
||||||
<title>Clawdis 2.0.0-beta2</title>
|
<sparkle:shortVersionString>2.0.0-beta3</sparkle:shortVersionString>
|
||||||
<sparkle:releaseNotesLink>https://github.com/steipete/clawdis/releases/tag/v2.0.0-beta2</sparkle:releaseNotesLink>
|
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||||
<pubDate>Sun, 21 Dec 2025 02:25:39 +0000</pubDate>
|
<enclosure url="https://github.com/steipete/clawdis/releases/download/v2.0.0-beta3/Clawdis-2.0.0-beta3.zip" length="70407960" type="application/octet-stream" sparkle:edSignature="A8ySMmbLRrpIkqkrmc9QrC+6om8Iyqray/6x/YNiJxDoJeXjp2T5t8XT0CKJeNBUlDkzIj/fwiK53v0qQ59cDQ=="/>
|
||||||
<enclosure url="https://github.com/steipete/clawdis/releases/download/v2.0.0-beta2/Clawdis-2.0.0-beta2.zip"
|
</item>
|
||||||
sparkle:edSignature="voRWLh2Cbg/i2KtUV6ci/MW3b7hK/u1ZPoiryKs+S36ua3xnc51R97JGwmIaToCfTHg2mgFWF7M6qppfe7YsAw=="
|
<item>
|
||||||
sparkle:version="2.0.0-beta2"
|
<title>2.0.0-beta4</title>
|
||||||
sparkle:shortVersionString="2.0.0-beta2"
|
<pubDate>Sat, 27 Dec 2025 19:43:22 +0100</pubDate>
|
||||||
length="67435891"
|
<link>https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml</link>
|
||||||
type="application/octet-stream" />
|
<sparkle:version>2.0.0-beta4</sparkle:version>
|
||||||
</item>
|
<sparkle:shortVersionString>2.0.0-beta4</sparkle:shortVersionString>
|
||||||
<item>
|
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||||
<title>Clawdis 2.0.0-beta1</title>
|
<description><![CDATA[<h2>Clawdis 2.0.0-beta4</h2>
|
||||||
<sparkle:releaseNotesLink>https://github.com/steipete/clawdis/releases/tag/v2.0.0-beta1</sparkle:releaseNotesLink>
|
<h3>Fixes</h3>
|
||||||
<pubDate>Fri, 19 Dec 2025 17:19:50 +0000</pubDate>
|
<ul>
|
||||||
<enclosure url="https://github.com/steipete/clawdis/releases/download/v2.0.0-beta1/Clawdis-2.0.0-beta1.zip"
|
<li>Package contents: include Discord/hooks build outputs in the npm tarball to avoid missing module errors.</li>
|
||||||
sparkle:edSignature="oEpGD46U4ZyBBSY9/piUIFDJU+KlFB751JIWOW2yS0sRNHKszyG5khDHg9o9bV9Zo8DOCNF/HOi88jmtHJAaCQ=="
|
</ul>
|
||||||
sparkle:version="2.0.0-beta1"
|
<p><a href="https://github.com/steipete/clawdis/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||||
sparkle:shortVersionString="2.0.0-beta1"
|
]]></description>
|
||||||
length="72410016"
|
<enclosure url="https://github.com/steipete/clawdis/releases/download/v2.0.0-beta4/Clawdis-2.0.0-beta4.zip" length="70407894" type="application/octet-stream" sparkle:edSignature="HmuiC7TnUn80ZApnKfb6w+JGSrjc3uUOndMrtbTp42bkBSVifbttNVazqvJueGBo4MgoJV8CP+zQNzVmtVihAQ=="/>
|
||||||
type="application/octet-stream" />
|
</item>
|
||||||
</item>
|
</channel>
|
||||||
</channel>
|
|
||||||
</rss>
|
</rss>
|
||||||
@@ -20,7 +20,7 @@ android {
|
|||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "0.1"
|
versionName = "2.0.0-beta3"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@@ -31,6 +31,7 @@ android {
|
|||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
@@ -63,6 +64,7 @@ dependencies {
|
|||||||
implementation("androidx.core:core-ktx:1.17.0")
|
implementation("androidx.core:core-ktx:1.17.0")
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
||||||
implementation("androidx.activity:activity-compose:1.12.2")
|
implementation("androidx.activity:activity-compose:1.12.2")
|
||||||
|
implementation("androidx.webkit:webkit:1.14.0")
|
||||||
|
|
||||||
implementation("androidx.compose.ui:ui")
|
implementation("androidx.compose.ui:ui")
|
||||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||||
@@ -92,4 +94,11 @@ dependencies {
|
|||||||
|
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.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")
|
||||||
|
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:5.13.3")
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<Test>().configureEach {
|
||||||
|
useJUnitPlatform()
|
||||||
}
|
}
|
||||||
@@ -23,9 +23,12 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
val statusText: StateFlow<String> = runtime.statusText
|
val statusText: StateFlow<String> = runtime.statusText
|
||||||
val serverName: StateFlow<String?> = runtime.serverName
|
val serverName: StateFlow<String?> = runtime.serverName
|
||||||
val remoteAddress: StateFlow<String?> = runtime.remoteAddress
|
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 cameraHud: StateFlow<CameraHudState?> = runtime.cameraHud
|
||||||
val cameraFlashToken: StateFlow<Long> = runtime.cameraFlashToken
|
val cameraFlashToken: StateFlow<Long> = runtime.cameraFlashToken
|
||||||
|
val screenRecordActive: StateFlow<Boolean> = runtime.screenRecordActive
|
||||||
|
|
||||||
val instanceId: StateFlow<String> = runtime.instanceId
|
val instanceId: StateFlow<String> = runtime.instanceId
|
||||||
val displayName: StateFlow<String> = runtime.displayName
|
val displayName: StateFlow<String> = runtime.displayName
|
||||||
@@ -35,6 +38,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
val voiceWakeMode: StateFlow<VoiceWakeMode> = runtime.voiceWakeMode
|
val voiceWakeMode: StateFlow<VoiceWakeMode> = runtime.voiceWakeMode
|
||||||
val voiceWakeStatusText: StateFlow<String> = runtime.voiceWakeStatusText
|
val voiceWakeStatusText: StateFlow<String> = runtime.voiceWakeStatusText
|
||||||
val voiceWakeIsListening: StateFlow<Boolean> = runtime.voiceWakeIsListening
|
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 manualEnabled: StateFlow<Boolean> = runtime.manualEnabled
|
||||||
val manualHost: StateFlow<String> = runtime.manualHost
|
val manualHost: StateFlow<String> = runtime.manualHost
|
||||||
val manualPort: StateFlow<Int> = runtime.manualPort
|
val manualPort: StateFlow<Int> = runtime.manualPort
|
||||||
@@ -95,6 +102,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
runtime.setVoiceWakeMode(mode)
|
runtime.setVoiceWakeMode(mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setTalkEnabled(enabled: Boolean) {
|
||||||
|
runtime.setTalkEnabled(enabled)
|
||||||
|
}
|
||||||
|
|
||||||
fun connect(endpoint: BridgeEndpoint) {
|
fun connect(endpoint: BridgeEndpoint) {
|
||||||
runtime.connect(endpoint)
|
runtime.connect(endpoint)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import com.steipete.clawdis.node.bridge.BridgeEndpoint
|
|||||||
import com.steipete.clawdis.node.bridge.BridgePairingClient
|
import com.steipete.clawdis.node.bridge.BridgePairingClient
|
||||||
import com.steipete.clawdis.node.bridge.BridgeSession
|
import com.steipete.clawdis.node.bridge.BridgeSession
|
||||||
import com.steipete.clawdis.node.node.CameraCaptureManager
|
import com.steipete.clawdis.node.node.CameraCaptureManager
|
||||||
|
import com.steipete.clawdis.node.BuildConfig
|
||||||
import com.steipete.clawdis.node.node.CanvasController
|
import com.steipete.clawdis.node.node.CanvasController
|
||||||
import com.steipete.clawdis.node.node.ScreenRecordManager
|
import com.steipete.clawdis.node.node.ScreenRecordManager
|
||||||
import com.steipete.clawdis.node.protocol.ClawdisCapability
|
import com.steipete.clawdis.node.protocol.ClawdisCapability
|
||||||
@@ -24,6 +25,7 @@ import com.steipete.clawdis.node.protocol.ClawdisCanvasA2UIAction
|
|||||||
import com.steipete.clawdis.node.protocol.ClawdisCanvasA2UICommand
|
import com.steipete.clawdis.node.protocol.ClawdisCanvasA2UICommand
|
||||||
import com.steipete.clawdis.node.protocol.ClawdisCanvasCommand
|
import com.steipete.clawdis.node.protocol.ClawdisCanvasCommand
|
||||||
import com.steipete.clawdis.node.protocol.ClawdisScreenCommand
|
import com.steipete.clawdis.node.protocol.ClawdisScreenCommand
|
||||||
|
import com.steipete.clawdis.node.voice.TalkModeManager
|
||||||
import com.steipete.clawdis.node.voice.VoiceWakeManager
|
import com.steipete.clawdis.node.voice.VoiceWakeManager
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -68,7 +70,7 @@ class NodeRuntime(context: Context) {
|
|||||||
payloadJson =
|
payloadJson =
|
||||||
buildJsonObject {
|
buildJsonObject {
|
||||||
put("message", JsonPrimitive(command))
|
put("message", JsonPrimitive(command))
|
||||||
put("sessionKey", JsonPrimitive("main"))
|
put("sessionKey", JsonPrimitive(mainSessionKey.value))
|
||||||
put("thinking", JsonPrimitive(chatThinkingLevel.value))
|
put("thinking", JsonPrimitive(chatThinkingLevel.value))
|
||||||
put("deliver", JsonPrimitive(false))
|
put("deliver", JsonPrimitive(false))
|
||||||
}.toString(),
|
}.toString(),
|
||||||
@@ -83,6 +85,15 @@ class NodeRuntime(context: Context) {
|
|||||||
val voiceWakeStatusText: StateFlow<String>
|
val voiceWakeStatusText: StateFlow<String>
|
||||||
get() = voiceWake.statusText
|
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)
|
private val discovery = BridgeDiscovery(appContext, scope = scope)
|
||||||
val bridges: StateFlow<List<BridgeEndpoint>> = discovery.bridges
|
val bridges: StateFlow<List<BridgeEndpoint>> = discovery.bridges
|
||||||
val discoveryStatusText: StateFlow<String> = discovery.statusText
|
val discoveryStatusText: StateFlow<String> = discovery.statusText
|
||||||
@@ -93,6 +104,9 @@ class NodeRuntime(context: Context) {
|
|||||||
private val _statusText = MutableStateFlow("Offline")
|
private val _statusText = MutableStateFlow("Offline")
|
||||||
val statusText: StateFlow<String> = _statusText.asStateFlow()
|
val statusText: StateFlow<String> = _statusText.asStateFlow()
|
||||||
|
|
||||||
|
private val _mainSessionKey = MutableStateFlow("main")
|
||||||
|
val mainSessionKey: StateFlow<String> = _mainSessionKey.asStateFlow()
|
||||||
|
|
||||||
private val cameraHudSeq = AtomicLong(0)
|
private val cameraHudSeq = AtomicLong(0)
|
||||||
private val _cameraHud = MutableStateFlow<CameraHudState?>(null)
|
private val _cameraHud = MutableStateFlow<CameraHudState?>(null)
|
||||||
val cameraHud: StateFlow<CameraHudState?> = _cameraHud.asStateFlow()
|
val cameraHud: StateFlow<CameraHudState?> = _cameraHud.asStateFlow()
|
||||||
@@ -100,12 +114,18 @@ class NodeRuntime(context: Context) {
|
|||||||
private val _cameraFlashToken = MutableStateFlow(0L)
|
private val _cameraFlashToken = MutableStateFlow(0L)
|
||||||
val cameraFlashToken: StateFlow<Long> = _cameraFlashToken.asStateFlow()
|
val cameraFlashToken: StateFlow<Long> = _cameraFlashToken.asStateFlow()
|
||||||
|
|
||||||
|
private val _screenRecordActive = MutableStateFlow(false)
|
||||||
|
val screenRecordActive: StateFlow<Boolean> = _screenRecordActive.asStateFlow()
|
||||||
|
|
||||||
private val _serverName = MutableStateFlow<String?>(null)
|
private val _serverName = MutableStateFlow<String?>(null)
|
||||||
val serverName: StateFlow<String?> = _serverName.asStateFlow()
|
val serverName: StateFlow<String?> = _serverName.asStateFlow()
|
||||||
|
|
||||||
private val _remoteAddress = MutableStateFlow<String?>(null)
|
private val _remoteAddress = MutableStateFlow<String?>(null)
|
||||||
val remoteAddress: StateFlow<String?> = _remoteAddress.asStateFlow()
|
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)
|
private val _isForeground = MutableStateFlow(true)
|
||||||
val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
|
val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
|
||||||
|
|
||||||
@@ -119,6 +139,8 @@ class NodeRuntime(context: Context) {
|
|||||||
_serverName.value = name
|
_serverName.value = name
|
||||||
_remoteAddress.value = remote
|
_remoteAddress.value = remote
|
||||||
_isConnected.value = true
|
_isConnected.value = true
|
||||||
|
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
||||||
|
scope.launch { refreshBrandingFromGateway() }
|
||||||
scope.launch { refreshWakeWordsFromGateway() }
|
scope.launch { refreshWakeWordsFromGateway() }
|
||||||
maybeNavigateToA2uiOnConnect()
|
maybeNavigateToA2uiOnConnect()
|
||||||
},
|
},
|
||||||
@@ -132,12 +154,17 @@ class NodeRuntime(context: Context) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
private val chat = ChatController(scope = scope, session = session, json = json)
|
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) {
|
private fun handleSessionDisconnected(message: String) {
|
||||||
_statusText.value = message
|
_statusText.value = message
|
||||||
_serverName.value = null
|
_serverName.value = null
|
||||||
_remoteAddress.value = null
|
_remoteAddress.value = null
|
||||||
_isConnected.value = false
|
_isConnected.value = false
|
||||||
|
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
||||||
|
_mainSessionKey.value = "main"
|
||||||
chat.onDisconnected(message)
|
chat.onDisconnected(message)
|
||||||
showLocalCanvasOnDisconnect()
|
showLocalCanvasOnDisconnect()
|
||||||
}
|
}
|
||||||
@@ -162,6 +189,7 @@ class NodeRuntime(context: Context) {
|
|||||||
val preventSleep: StateFlow<Boolean> = prefs.preventSleep
|
val preventSleep: StateFlow<Boolean> = prefs.preventSleep
|
||||||
val wakeWords: StateFlow<List<String>> = prefs.wakeWords
|
val wakeWords: StateFlow<List<String>> = prefs.wakeWords
|
||||||
val voiceWakeMode: StateFlow<VoiceWakeMode> = prefs.voiceWakeMode
|
val voiceWakeMode: StateFlow<VoiceWakeMode> = prefs.voiceWakeMode
|
||||||
|
val talkEnabled: StateFlow<Boolean> = prefs.talkEnabled
|
||||||
val manualEnabled: StateFlow<Boolean> = prefs.manualEnabled
|
val manualEnabled: StateFlow<Boolean> = prefs.manualEnabled
|
||||||
val manualHost: StateFlow<String> = prefs.manualHost
|
val manualHost: StateFlow<String> = prefs.manualHost
|
||||||
val manualPort: StateFlow<Int> = prefs.manualPort
|
val manualPort: StateFlow<Int> = prefs.manualPort
|
||||||
@@ -217,6 +245,13 @@ class NodeRuntime(context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
talkEnabled.collect { enabled ->
|
||||||
|
talkMode.setEnabled(enabled)
|
||||||
|
externalAudioCaptureActive.value = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
scope.launch(Dispatchers.Default) {
|
scope.launch(Dispatchers.Default) {
|
||||||
bridges.collect { list ->
|
bridges.collect { list ->
|
||||||
if (list.isNotEmpty()) {
|
if (list.isNotEmpty()) {
|
||||||
@@ -310,6 +345,10 @@ class NodeRuntime(context: Context) {
|
|||||||
prefs.setVoiceWakeMode(mode)
|
prefs.setVoiceWakeMode(mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setTalkEnabled(value: Boolean) {
|
||||||
|
prefs.setTalkEnabled(value)
|
||||||
|
}
|
||||||
|
|
||||||
fun connect(endpoint: BridgeEndpoint) {
|
fun connect(endpoint: BridgeEndpoint) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
_statusText.value = "Connecting…"
|
_statusText.value = "Connecting…"
|
||||||
@@ -346,6 +385,13 @@ class NodeRuntime(context: Context) {
|
|||||||
add(ClawdisCapability.VoiceWake.rawValue)
|
add(ClawdisCapability.VoiceWake.rawValue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
|
||||||
|
val advertisedVersion =
|
||||||
|
if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
|
||||||
|
"$versionName-dev"
|
||||||
|
} else {
|
||||||
|
versionName
|
||||||
|
}
|
||||||
BridgePairingClient().pairAndHello(
|
BridgePairingClient().pairAndHello(
|
||||||
endpoint = endpoint,
|
endpoint = endpoint,
|
||||||
hello =
|
hello =
|
||||||
@@ -354,7 +400,7 @@ class NodeRuntime(context: Context) {
|
|||||||
displayName = displayName.value,
|
displayName = displayName.value,
|
||||||
token = null,
|
token = null,
|
||||||
platform = "Android",
|
platform = "Android",
|
||||||
version = "dev",
|
version = advertisedVersion,
|
||||||
deviceFamily = "Android",
|
deviceFamily = "Android",
|
||||||
modelIdentifier = modelIdentifier,
|
modelIdentifier = modelIdentifier,
|
||||||
caps = caps,
|
caps = caps,
|
||||||
@@ -372,6 +418,13 @@ class NodeRuntime(context: Context) {
|
|||||||
|
|
||||||
val authToken = requireNotNull(resolved.token).trim()
|
val authToken = requireNotNull(resolved.token).trim()
|
||||||
prefs.saveBridgeToken(authToken)
|
prefs.saveBridgeToken(authToken)
|
||||||
|
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
|
||||||
|
val advertisedVersion =
|
||||||
|
if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
|
||||||
|
"$versionName-dev"
|
||||||
|
} else {
|
||||||
|
versionName
|
||||||
|
}
|
||||||
session.connect(
|
session.connect(
|
||||||
endpoint = endpoint,
|
endpoint = endpoint,
|
||||||
hello =
|
hello =
|
||||||
@@ -380,7 +433,7 @@ class NodeRuntime(context: Context) {
|
|||||||
displayName = displayName.value,
|
displayName = displayName.value,
|
||||||
token = authToken,
|
token = authToken,
|
||||||
platform = "Android",
|
platform = "Android",
|
||||||
version = "dev",
|
version = advertisedVersion,
|
||||||
deviceFamily = "Android",
|
deviceFamily = "Android",
|
||||||
modelIdentifier = modelIdentifier,
|
modelIdentifier = modelIdentifier,
|
||||||
caps =
|
caps =
|
||||||
@@ -533,6 +586,7 @@ class NodeRuntime(context: Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
talkMode.handleBridgeEvent(event, payloadJson)
|
||||||
chat.handleBridgeEvent(event, payloadJson)
|
chat.handleBridgeEvent(event, payloadJson)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,6 +628,25 @@ 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 {
|
private suspend fun handleInvoke(command: String, paramsJson: String?): BridgeSession.InvokeResult {
|
||||||
if (
|
if (
|
||||||
command.startsWith(ClawdisCanvasCommand.NamespacePrefix) ||
|
command.startsWith(ClawdisCanvasCommand.NamespacePrefix) ||
|
||||||
@@ -715,14 +788,20 @@ class NodeRuntime(context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ClawdisScreenCommand.Record.rawValue -> {
|
ClawdisScreenCommand.Record.rawValue -> {
|
||||||
val res =
|
// Status pill mirrors screen recording state so it stays visible without overlay stacking.
|
||||||
try {
|
_screenRecordActive.value = true
|
||||||
screenRecorder.record(paramsJson)
|
try {
|
||||||
} catch (err: Throwable) {
|
val res =
|
||||||
val (code, message) = invokeErrorFromThrowable(err)
|
try {
|
||||||
return BridgeSession.InvokeResult.error(code = code, message = message)
|
screenRecorder.record(paramsJson)
|
||||||
}
|
} catch (err: Throwable) {
|
||||||
BridgeSession.InvokeResult.ok(res.payloadJson)
|
val (code, message) = invokeErrorFromThrowable(err)
|
||||||
|
return BridgeSession.InvokeResult.error(code = code, message = message)
|
||||||
|
}
|
||||||
|
BridgeSession.InvokeResult.ok(res.payloadJson)
|
||||||
|
} finally {
|
||||||
|
_screenRecordActive.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else ->
|
else ->
|
||||||
BridgeSession.InvokeResult.error(
|
BridgeSession.InvokeResult.error(
|
||||||
@@ -765,7 +844,7 @@ class NodeRuntime(context: Context) {
|
|||||||
val raw = session.currentCanvasHostUrl()?.trim().orEmpty()
|
val raw = session.currentCanvasHostUrl()?.trim().orEmpty()
|
||||||
if (raw.isBlank()) return null
|
if (raw.isBlank()) return null
|
||||||
val base = raw.trimEnd('/')
|
val base = raw.trimEnd('/')
|
||||||
return "${base}/__clawdis__/a2ui/"
|
return "${base}/__clawdis__/a2ui/?platform=android"
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun ensureA2uiReady(a2uiUrl: String): Boolean {
|
private suspend fun ensureA2uiReady(a2uiUrl: String): Boolean {
|
||||||
@@ -851,6 +930,8 @@ 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 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 =
|
private const val a2uiReadyCheckJS: String =
|
||||||
"""
|
"""
|
||||||
(() => {
|
(() => {
|
||||||
@@ -905,3 +986,12 @@ private fun JsonElement?.asStringOrNull(): String? =
|
|||||||
is JsonPrimitive -> content
|
is JsonPrimitive -> content
|
||||||
else -> null
|
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
|
||||||
|
}
|
||||||
@@ -73,6 +73,9 @@ class SecurePrefs(context: Context) {
|
|||||||
private val _voiceWakeMode = MutableStateFlow(loadVoiceWakeMode())
|
private val _voiceWakeMode = MutableStateFlow(loadVoiceWakeMode())
|
||||||
val voiceWakeMode: StateFlow<VoiceWakeMode> = _voiceWakeMode
|
val voiceWakeMode: StateFlow<VoiceWakeMode> = _voiceWakeMode
|
||||||
|
|
||||||
|
private val _talkEnabled = MutableStateFlow(prefs.getBoolean("talk.enabled", false))
|
||||||
|
val talkEnabled: StateFlow<Boolean> = _talkEnabled
|
||||||
|
|
||||||
fun setLastDiscoveredStableId(value: String) {
|
fun setLastDiscoveredStableId(value: String) {
|
||||||
val trimmed = value.trim()
|
val trimmed = value.trim()
|
||||||
prefs.edit { putString("bridge.lastDiscoveredStableId", trimmed) }
|
prefs.edit { putString("bridge.lastDiscoveredStableId", trimmed) }
|
||||||
@@ -158,6 +161,11 @@ class SecurePrefs(context: Context) {
|
|||||||
_voiceWakeMode.value = mode
|
_voiceWakeMode.value = mode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setTalkEnabled(value: Boolean) {
|
||||||
|
prefs.edit { putBoolean("talk.enabled", value) }
|
||||||
|
_talkEnabled.value = value
|
||||||
|
}
|
||||||
|
|
||||||
private fun loadVoiceWakeMode(): VoiceWakeMode {
|
private fun loadVoiceWakeMode(): VoiceWakeMode {
|
||||||
val raw = prefs.getString(voiceWakeModeKey, null)
|
val raw = prefs.getString(voiceWakeModeKey, null)
|
||||||
val resolved = VoiceWakeMode.fromRawValue(raw)
|
val resolved = VoiceWakeMode.fromRawValue(raw)
|
||||||
|
|||||||
+54
-14
@@ -130,20 +130,36 @@ class BridgeDiscovery(
|
|||||||
object : NsdManager.ResolveListener {
|
object : NsdManager.ResolveListener {
|
||||||
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {}
|
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {}
|
||||||
|
|
||||||
override fun onServiceResolved(resolved: NsdServiceInfo) {
|
override fun onServiceResolved(resolved: NsdServiceInfo) {
|
||||||
val host = resolved.host?.hostAddress ?: return
|
val host = resolved.host?.hostAddress ?: return
|
||||||
val port = resolved.port
|
val port = resolved.port
|
||||||
if (port <= 0) return
|
if (port <= 0) return
|
||||||
|
|
||||||
val rawServiceName = resolved.serviceName
|
val rawServiceName = resolved.serviceName
|
||||||
val serviceName = BonjourEscapes.decode(rawServiceName)
|
val serviceName = BonjourEscapes.decode(rawServiceName)
|
||||||
val displayName = BonjourEscapes.decode(txt(resolved, "displayName") ?: serviceName)
|
val displayName = BonjourEscapes.decode(txt(resolved, "displayName") ?: serviceName)
|
||||||
val id = stableId(serviceName, "local.")
|
val lanHost = txt(resolved, "lanHost")
|
||||||
localById[id] = BridgeEndpoint(stableId = id, name = displayName, host = host, port = port)
|
val tailnetDns = txt(resolved, "tailnetDns")
|
||||||
publish()
|
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()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun publish() {
|
private fun publish() {
|
||||||
@@ -189,6 +205,10 @@ class BridgeDiscovery(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun txtInt(info: NsdServiceInfo, key: String): Int? {
|
||||||
|
return txt(info, key)?.toIntOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun refreshUnicast(domain: String) {
|
private suspend fun refreshUnicast(domain: String) {
|
||||||
val ptrName = "${serviceType}${domain}"
|
val ptrName = "${serviceType}${domain}"
|
||||||
val ptrMsg = lookupUnicastMessage(ptrName, Type.PTR) ?: return
|
val ptrMsg = lookupUnicastMessage(ptrName, Type.PTR) ?: return
|
||||||
@@ -227,8 +247,24 @@ class BridgeDiscovery(
|
|||||||
}
|
}
|
||||||
val instanceName = BonjourEscapes.decode(decodeInstanceName(instanceFqdn, domain))
|
val instanceName = BonjourEscapes.decode(decodeInstanceName(instanceFqdn, domain))
|
||||||
val displayName = BonjourEscapes.decode(txtValue(txt, "displayName") ?: instanceName)
|
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)
|
val id = stableId(instanceName, domain)
|
||||||
next[id] = BridgeEndpoint(stableId = id, name = displayName, host = host, port = port)
|
next[id] =
|
||||||
|
BridgeEndpoint(
|
||||||
|
stableId = id,
|
||||||
|
name = displayName,
|
||||||
|
host = host,
|
||||||
|
port = port,
|
||||||
|
lanHost = lanHost,
|
||||||
|
tailnetDns = tailnetDns,
|
||||||
|
gatewayPort = gatewayPort,
|
||||||
|
bridgePort = bridgePort,
|
||||||
|
canvasPort = canvasPort,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
unicastById.clear()
|
unicastById.clear()
|
||||||
@@ -434,6 +470,10 @@ class BridgeDiscovery(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun txtIntValue(records: List<TXTRecord>, key: String): Int? {
|
||||||
|
return txtValue(records, key)?.toIntOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
private fun decodeDnsTxtString(raw: String): String {
|
private fun decodeDnsTxtString(raw: String): String {
|
||||||
// dnsjava treats TXT as opaque bytes and decodes as ISO-8859-1 to preserve bytes.
|
// 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.
|
// Our TXT payload is UTF-8 (written by the gateway), so re-decode when possible.
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ data class BridgeEndpoint(
|
|||||||
val name: String,
|
val name: String,
|
||||||
val host: String,
|
val host: String,
|
||||||
val port: Int,
|
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 {
|
companion object {
|
||||||
fun manual(host: String, port: Int): BridgeEndpoint =
|
fun manual(host: String, port: Int): BridgeEndpoint =
|
||||||
@@ -16,4 +21,3 @@ data class BridgeEndpoint(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import com.steipete.clawdis.node.BuildConfig
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonArray
|
import kotlinx.serialization.json.JsonArray
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
@@ -23,6 +24,7 @@ import java.io.BufferedWriter
|
|||||||
import java.io.InputStreamReader
|
import java.io.InputStreamReader
|
||||||
import java.io.OutputStreamWriter
|
import java.io.OutputStreamWriter
|
||||||
import java.net.InetSocketAddress
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.URI
|
||||||
import java.net.Socket
|
import java.net.Socket
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
@@ -75,6 +77,8 @@ class BridgeSession(
|
|||||||
|
|
||||||
fun disconnect() {
|
fun disconnect() {
|
||||||
desired = null
|
desired = null
|
||||||
|
// Unblock connectOnce() read loop. Coroutine cancellation alone won't interrupt BufferedReader.readLine().
|
||||||
|
currentConnection?.closeQuietly()
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
job?.cancelAndJoin()
|
job?.cancelAndJoin()
|
||||||
job = null
|
job = null
|
||||||
@@ -213,7 +217,17 @@ class BridgeSession(
|
|||||||
when (first["type"].asStringOrNull()) {
|
when (first["type"].asStringOrNull()) {
|
||||||
"hello-ok" -> {
|
"hello-ok" -> {
|
||||||
val name = first["serverName"].asStringOrNull() ?: "Bridge"
|
val name = first["serverName"].asStringOrNull() ?: "Bridge"
|
||||||
canvasHostUrl = first["canvasHostUrl"].asStringOrNull()?.trim()?.ifEmpty { null }
|
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(
|
||||||
|
"ClawdisBridge",
|
||||||
|
"canvasHostUrl resolved=${canvasHostUrl ?: "none"} (raw=${rawCanvasUrl ?: "none"})",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
onConnected(name, conn.remoteAddress)
|
onConnected(name, conn.remoteAddress)
|
||||||
}
|
}
|
||||||
"error" -> {
|
"error" -> {
|
||||||
@@ -292,6 +306,37 @@ class BridgeSession(
|
|||||||
conn.closeQuietly()
|
conn.closeQuietly()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||||
|
|||||||
+30
-7
@@ -28,6 +28,7 @@ import kotlinx.coroutines.withContext
|
|||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.concurrent.Executor
|
import java.util.concurrent.Executor
|
||||||
|
import kotlin.math.roundToInt
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
import kotlin.coroutines.resumeWithException
|
import kotlin.coroutines.resumeWithException
|
||||||
|
|
||||||
@@ -99,14 +100,36 @@ class CameraCaptureManager(private val context: Context) {
|
|||||||
decoded
|
decoded
|
||||||
}
|
}
|
||||||
|
|
||||||
val out = ByteArrayOutputStream()
|
val maxPayloadBytes = 5 * 1024 * 1024
|
||||||
val jpegQuality = (quality * 100.0).toInt().coerceIn(10, 100)
|
// Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under 5MB (API limit).
|
||||||
if (!scaled.compress(Bitmap.CompressFormat.JPEG, jpegQuality, out)) {
|
val maxEncodedBytes = (maxPayloadBytes / 4) * 3
|
||||||
throw IllegalStateException("UNAVAILABLE: failed to encode JPEG")
|
val result =
|
||||||
}
|
JpegSizeLimiter.compressToLimit(
|
||||||
val base64 = Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
|
initialWidth = scaled.width,
|
||||||
|
initialHeight = scaled.height,
|
||||||
|
startQuality = (quality * 100.0).roundToInt().coerceIn(10, 100),
|
||||||
|
maxBytes = maxEncodedBytes,
|
||||||
|
encode = { width, height, q ->
|
||||||
|
val bitmap =
|
||||||
|
if (width == scaled.width && height == scaled.height) {
|
||||||
|
scaled
|
||||||
|
} else {
|
||||||
|
scaled.scale(width, height)
|
||||||
|
}
|
||||||
|
val out = ByteArrayOutputStream()
|
||||||
|
if (!bitmap.compress(Bitmap.CompressFormat.JPEG, q, out)) {
|
||||||
|
if (bitmap !== scaled) bitmap.recycle()
|
||||||
|
throw IllegalStateException("UNAVAILABLE: failed to encode JPEG")
|
||||||
|
}
|
||||||
|
if (bitmap !== scaled) {
|
||||||
|
bitmap.recycle()
|
||||||
|
}
|
||||||
|
out.toByteArray()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val base64 = Base64.encodeToString(result.bytes, Base64.NO_WRAP)
|
||||||
Payload(
|
Payload(
|
||||||
"""{"format":"jpg","base64":"$base64","width":${scaled.width},"height":${scaled.height}}""",
|
"""{"format":"jpg","base64":"$base64","width":${result.width},"height":${result.height}}""",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.steipete.clawdis.node.node
|
|||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.Canvas
|
import android.graphics.Canvas
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import androidx.core.graphics.createBitmap
|
import androidx.core.graphics.createBitmap
|
||||||
import androidx.core.graphics.scale
|
import androidx.core.graphics.scale
|
||||||
@@ -16,6 +17,7 @@ import kotlinx.serialization.json.Json
|
|||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import com.steipete.clawdis.node.BuildConfig
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
|
|
||||||
class CanvasController {
|
class CanvasController {
|
||||||
@@ -81,8 +83,14 @@ class CanvasController {
|
|||||||
val currentUrl = url
|
val currentUrl = url
|
||||||
withWebViewOnMain { wv ->
|
withWebViewOnMain { wv ->
|
||||||
if (currentUrl == null) {
|
if (currentUrl == null) {
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Log.d("ClawdisCanvas", "load scaffold: $scaffoldAssetUrl")
|
||||||
|
}
|
||||||
wv.loadUrl(scaffoldAssetUrl)
|
wv.loadUrl(scaffoldAssetUrl)
|
||||||
} else {
|
} else {
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Log.d("ClawdisCanvas", "load url: $currentUrl")
|
||||||
|
}
|
||||||
wv.loadUrl(currentUrl)
|
wv.loadUrl(currentUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package com.steipete.clawdis.node.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,64 +1,26 @@
|
|||||||
package com.steipete.clawdis.node.ui
|
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.background
|
||||||
import androidx.compose.foundation.layout.Box
|
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.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.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.graphics.Color
|
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
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CameraHudOverlay(
|
fun CameraFlashOverlay(
|
||||||
hud: CameraHudState?,
|
token: Long,
|
||||||
flashToken: Long,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Box(modifier = modifier.fillMaxSize()) {
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
CameraFlash(token = flashToken)
|
CameraFlash(token = token)
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,44 +42,3 @@ private fun CameraFlash(token: Long) {
|
|||||||
.background(Color.White),
|
.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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -7,12 +7,18 @@ import android.graphics.Color
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.webkit.JavascriptInterface
|
import android.webkit.JavascriptInterface
|
||||||
|
import android.webkit.ConsoleMessage
|
||||||
|
import android.webkit.WebChromeClient
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import android.webkit.WebSettings
|
import android.webkit.WebSettings
|
||||||
import android.webkit.WebResourceError
|
import android.webkit.WebResourceError
|
||||||
import android.webkit.WebResourceRequest
|
import android.webkit.WebResourceRequest
|
||||||
import android.webkit.WebResourceResponse
|
import android.webkit.WebResourceResponse
|
||||||
import android.webkit.WebViewClient
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -28,10 +34,20 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
|||||||
import androidx.compose.material3.FilledTonalIconButton
|
import androidx.compose.material3.FilledTonalIconButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButtonDefaults
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ChatBubble
|
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.material.icons.filled.Settings
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
@@ -41,12 +57,15 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.compose.ui.window.Popup
|
import androidx.compose.ui.window.Popup
|
||||||
import androidx.compose.ui.window.PopupProperties
|
import androidx.compose.ui.window.PopupProperties
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.steipete.clawdis.node.CameraHudKind
|
||||||
import com.steipete.clawdis.node.MainViewModel
|
import com.steipete.clawdis.node.MainViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -60,6 +79,105 @@ fun RootScreen(viewModel: MainViewModel) {
|
|||||||
val statusText by viewModel.statusText.collectAsState()
|
val statusText by viewModel.statusText.collectAsState()
|
||||||
val cameraHud by viewModel.cameraHud.collectAsState()
|
val cameraHud by viewModel.cameraHud.collectAsState()
|
||||||
val cameraFlashToken by viewModel.cameraFlashToken.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 =
|
val bridgeState =
|
||||||
remember(serverName, statusText) {
|
remember(serverName, statusText) {
|
||||||
@@ -80,9 +198,9 @@ fun RootScreen(viewModel: MainViewModel) {
|
|||||||
CanvasView(viewModel = viewModel, modifier = Modifier.fillMaxSize())
|
CanvasView(viewModel = viewModel, modifier = Modifier.fillMaxSize())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Camera HUD (flash + toast) must be in a Popup to render above the WebView.
|
// Camera flash must be in a Popup to render above the WebView.
|
||||||
Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) {
|
Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) {
|
||||||
CameraHudOverlay(hud = cameraHud, flashToken = cameraFlashToken, modifier = Modifier.fillMaxSize())
|
CameraFlashOverlay(token = cameraFlashToken, modifier = Modifier.fillMaxSize())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep the overlay buttons above the WebView canvas (AndroidView), otherwise they may not receive touches.
|
// Keep the overlay buttons above the WebView canvas (AndroidView), otherwise they may not receive touches.
|
||||||
@@ -90,6 +208,7 @@ fun RootScreen(viewModel: MainViewModel) {
|
|||||||
StatusPill(
|
StatusPill(
|
||||||
bridge = bridgeState,
|
bridge = bridgeState,
|
||||||
voiceEnabled = voiceEnabled,
|
voiceEnabled = voiceEnabled,
|
||||||
|
activity = activity,
|
||||||
onClick = { sheet = Sheet.Settings },
|
onClick = { sheet = Sheet.Settings },
|
||||||
modifier = Modifier.windowInsetsPadding(safeOverlayInsets).padding(start = 12.dp, top = 12.dp),
|
modifier = Modifier.windowInsetsPadding(safeOverlayInsets).padding(start = 12.dp, top = 12.dp),
|
||||||
)
|
)
|
||||||
@@ -106,6 +225,38 @@ fun RootScreen(viewModel: MainViewModel) {
|
|||||||
icon = { Icon(Icons.Default.ChatBubble, contentDescription = "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(
|
OverlayIconButton(
|
||||||
onClick = { sheet = Sheet.Settings },
|
onClick = { sheet = Sheet.Settings },
|
||||||
icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") },
|
icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") },
|
||||||
@@ -113,6 +264,17 @@ fun RootScreen(viewModel: MainViewModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (talkEnabled) {
|
||||||
|
Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) {
|
||||||
|
TalkOrbOverlay(
|
||||||
|
seamColor = seamColor,
|
||||||
|
statusText = talkStatusText,
|
||||||
|
isListening = talkIsListening,
|
||||||
|
isSpeaking = talkIsSpeaking,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val currentSheet = sheet
|
val currentSheet = sheet
|
||||||
if (currentSheet != null) {
|
if (currentSheet != null) {
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
@@ -136,14 +298,16 @@ private enum class Sheet {
|
|||||||
private fun OverlayIconButton(
|
private fun OverlayIconButton(
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
icon: @Composable () -> Unit,
|
icon: @Composable () -> Unit,
|
||||||
|
containerColor: ComposeColor? = null,
|
||||||
|
contentColor: ComposeColor? = null,
|
||||||
) {
|
) {
|
||||||
FilledTonalIconButton(
|
FilledTonalIconButton(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = Modifier.size(44.dp),
|
modifier = Modifier.size(44.dp),
|
||||||
colors =
|
colors =
|
||||||
IconButtonDefaults.filledTonalIconButtonColors(
|
IconButtonDefaults.filledTonalIconButtonColors(
|
||||||
containerColor = overlayContainerColor(),
|
containerColor = containerColor ?: overlayContainerColor(),
|
||||||
contentColor = overlayIconColor(),
|
contentColor = contentColor ?: overlayIconColor(),
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
icon()
|
icon()
|
||||||
@@ -163,6 +327,19 @@ private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier)
|
|||||||
// Some embedded web UIs (incl. the "background website") use localStorage/sessionStorage.
|
// Some embedded web UIs (incl. the "background website") use localStorage/sessionStorage.
|
||||||
settings.domStorageEnabled = true
|
settings.domStorageEnabled = true
|
||||||
settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
|
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("ClawdisWebView", "userAgent: ${settings.userAgentString}")
|
||||||
|
}
|
||||||
|
isScrollContainer = true
|
||||||
|
overScrollMode = View.OVER_SCROLL_IF_CONTENT_SCROLLS
|
||||||
|
isVerticalScrollBarEnabled = true
|
||||||
|
isHorizontalScrollBarEnabled = true
|
||||||
webViewClient =
|
webViewClient =
|
||||||
object : WebViewClient() {
|
object : WebViewClient() {
|
||||||
override fun onReceivedError(
|
override fun onReceivedError(
|
||||||
@@ -189,11 +366,38 @@ private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier)
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onPageFinished(view: WebView, url: String?) {
|
override fun onPageFinished(view: WebView, url: String?) {
|
||||||
|
if (isDebuggable) {
|
||||||
|
Log.d("ClawdisWebView", "onPageFinished: $url")
|
||||||
|
}
|
||||||
viewModel.canvas.onPageFinished()
|
viewModel.canvas.onPageFinished()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onRenderProcessGone(
|
||||||
|
view: WebView,
|
||||||
|
detail: android.webkit.RenderProcessGoneDetail,
|
||||||
|
): Boolean {
|
||||||
|
if (isDebuggable) {
|
||||||
|
Log.e(
|
||||||
|
"ClawdisWebView",
|
||||||
|
"onRenderProcessGone didCrash=${detail.didCrash()} priorityAtExit=${detail.rendererPriorityAtExit()}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setBackgroundColor(Color.BLACK)
|
webChromeClient =
|
||||||
setLayerType(View.LAYER_TYPE_HARDWARE, null)
|
object : WebChromeClient() {
|
||||||
|
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
|
||||||
|
if (!isDebuggable) return false
|
||||||
|
val msg = consoleMessage ?: return false
|
||||||
|
Log.d(
|
||||||
|
"ClawdisWebView",
|
||||||
|
"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 =
|
val a2uiBridge =
|
||||||
CanvasA2UIActionBridge { payload ->
|
CanvasA2UIActionBridge { payload ->
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.steipete.clawdis.node.ui
|
|||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
@@ -46,6 +47,7 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.steipete.clawdis.node.BuildConfig
|
||||||
import com.steipete.clawdis.node.MainViewModel
|
import com.steipete.clawdis.node.MainViewModel
|
||||||
import com.steipete.clawdis.node.NodeForegroundService
|
import com.steipete.clawdis.node.NodeForegroundService
|
||||||
import com.steipete.clawdis.node.VoiceWakeMode
|
import com.steipete.clawdis.node.VoiceWakeMode
|
||||||
@@ -74,6 +76,22 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") }
|
val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") }
|
||||||
val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) }
|
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(", ")) }
|
LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) }
|
||||||
|
|
||||||
@@ -142,6 +160,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
item { Text("Instance ID: $instanceId", color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
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() }
|
item { HorizontalDivider() }
|
||||||
|
|
||||||
@@ -181,9 +201,27 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
item { Text("No bridges found yet.", color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
item { Text("No bridges found yet.", color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||||
} else {
|
} else {
|
||||||
items(items = visibleBridges, key = { it.stableId }) { bridge ->
|
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(
|
ListItem(
|
||||||
headlineContent = { Text(bridge.name) },
|
headlineContent = { Text(bridge.name) },
|
||||||
supportingContent = { Text("${bridge.host}:${bridge.port}") },
|
supportingContent = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
|
detailLines.forEach { line ->
|
||||||
|
Text(line, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
fun StatusPill(
|
fun StatusPill(
|
||||||
bridge: BridgeState,
|
bridge: BridgeState,
|
||||||
voiceEnabled: Boolean,
|
voiceEnabled: Boolean,
|
||||||
|
activity: StatusActivity? = null,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
@@ -62,23 +63,49 @@ fun StatusPill(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
||||||
Icon(
|
if (activity != null) {
|
||||||
imageVector = if (voiceEnabled) Icons.Default.Mic else Icons.Default.MicOff,
|
Row(
|
||||||
contentDescription = if (voiceEnabled) "Voice enabled" else "Voice disabled",
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
tint =
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
if (voiceEnabled) {
|
) {
|
||||||
overlayIconColor()
|
Icon(
|
||||||
} else {
|
imageVector = activity.icon,
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
contentDescription = activity.contentDescription,
|
||||||
},
|
tint = activity.tint ?: overlayIconColor(),
|
||||||
modifier = Modifier.size(18.dp),
|
modifier = Modifier.size(18.dp),
|
||||||
)
|
)
|
||||||
|
Text(
|
||||||
|
text = activity.title,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (voiceEnabled) Icons.Default.Mic else Icons.Default.MicOff,
|
||||||
|
contentDescription = if (voiceEnabled) "Voice enabled" else "Voice disabled",
|
||||||
|
tint =
|
||||||
|
if (voiceEnabled) {
|
||||||
|
overlayIconColor()
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(2.dp))
|
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) {
|
enum class BridgeState(val title: String, val color: Color) {
|
||||||
Connected("Connected", Color(0xFF2ECC71)),
|
Connected("Connected", Color(0xFF2ECC71)),
|
||||||
Connecting("Connecting…", Color(0xFFF1C40F)),
|
Connecting("Connecting…", Color(0xFFF1C40F)),
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package com.steipete.clawdis.node.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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
@@ -31,7 +32,7 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatMarkdown(text: String) {
|
fun ChatMarkdown(text: String, textColor: Color) {
|
||||||
val blocks = remember(text) { splitMarkdown(text) }
|
val blocks = remember(text) { splitMarkdown(text) }
|
||||||
val inlineCodeBg = MaterialTheme.colorScheme.surfaceContainerLow
|
val inlineCodeBg = MaterialTheme.colorScheme.surfaceContainerLow
|
||||||
|
|
||||||
@@ -44,7 +45,7 @@ fun ChatMarkdown(text: String) {
|
|||||||
Text(
|
Text(
|
||||||
text = parseInlineMarkdown(trimmed, inlineCodeBg = inlineCodeBg),
|
text = parseInlineMarkdown(trimmed, inlineCodeBg = inlineCodeBg),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = textColor,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is ChatMarkdownBlock.Code -> {
|
is ChatMarkdownBlock.Code -> {
|
||||||
|
|||||||
+14
-6
@@ -7,11 +7,9 @@ import androidx.compose.foundation.layout.Arrangement
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -60,20 +58,21 @@ fun ChatMessageBubble(message: ChatMessage) {
|
|||||||
.background(bubbleBackground(isUser))
|
.background(bubbleBackground(isUser))
|
||||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||||
) {
|
) {
|
||||||
ChatMessageBody(content = message.content)
|
val textColor = textColorOverBubble(isUser)
|
||||||
|
ChatMessageBody(content = message.content, textColor = textColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChatMessageBody(content: List<ChatMessageContent>) {
|
private fun ChatMessageBody(content: List<ChatMessageContent>, textColor: Color) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
for (part in content) {
|
for (part in content) {
|
||||||
when (part.type) {
|
when (part.type) {
|
||||||
"text" -> {
|
"text" -> {
|
||||||
val text = part.text ?: continue
|
val text = part.text ?: continue
|
||||||
ChatMarkdown(text = text)
|
ChatMarkdown(text = text, textColor = textColor)
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
val b64 = part.base64 ?: continue
|
val b64 = part.base64 ?: continue
|
||||||
@@ -131,7 +130,7 @@ fun ChatStreamingAssistantBubble(text: String) {
|
|||||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
) {
|
) {
|
||||||
Box(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) {
|
Box(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) {
|
||||||
ChatMarkdown(text = text)
|
ChatMarkdown(text = text, textColor = MaterialTheme.colorScheme.onSurface)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,6 +149,15 @@ private fun bubbleBackground(isUser: Boolean): Brush {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun textColorOverBubble(isUser: Boolean): Color {
|
||||||
|
return if (isUser) {
|
||||||
|
MaterialTheme.colorScheme.onPrimary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChatBase64Image(base64: String, mimeType: String?) {
|
private fun ChatBase64Image(base64: String, mimeType: String?) {
|
||||||
var image by remember(base64) { mutableStateOf<androidx.compose.ui.graphics.ImageBitmap?>(null) }
|
var image by remember(base64) { mutableStateOf<androidx.compose.ui.graphics.ImageBitmap?>(null) }
|
||||||
|
|||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
package com.steipete.clawdis.node.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
|
||||||
|
}
|
||||||
|
}
|
||||||
+191
@@ -0,0 +1,191 @@
|
|||||||
|
package com.steipete.clawdis.node.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
+14
@@ -0,0 +1,14 @@
|
|||||||
|
package com.steipete.clawdis.node.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
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.steipete.clawdis.node.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
package com.steipete.clawdis.node.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,15 @@ import Observation
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
protocol BridgePairingClient: Sendable {
|
||||||
|
func pairAndHello(
|
||||||
|
endpoint: NWEndpoint,
|
||||||
|
hello: BridgeHello,
|
||||||
|
onStatus: (@Sendable (String) -> Void)?) async throws -> String
|
||||||
|
}
|
||||||
|
|
||||||
|
extension BridgeClient: BridgePairingClient {}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
final class BridgeConnectionController {
|
final class BridgeConnectionController {
|
||||||
@@ -16,10 +25,16 @@ final class BridgeConnectionController {
|
|||||||
private let discovery = BridgeDiscoveryModel()
|
private let discovery = BridgeDiscoveryModel()
|
||||||
private weak var appModel: NodeAppModel?
|
private weak var appModel: NodeAppModel?
|
||||||
private var didAutoConnect = false
|
private var didAutoConnect = false
|
||||||
private var seenStableIDs = Set<String>()
|
|
||||||
|
|
||||||
init(appModel: NodeAppModel, startDiscovery: Bool = true) {
|
private let bridgeClientFactory: @Sendable () -> any BridgePairingClient
|
||||||
|
|
||||||
|
init(
|
||||||
|
appModel: NodeAppModel,
|
||||||
|
startDiscovery: Bool = true,
|
||||||
|
bridgeClientFactory: @escaping @Sendable () -> any BridgePairingClient = { BridgeClient() })
|
||||||
|
{
|
||||||
self.appModel = appModel
|
self.appModel = appModel
|
||||||
|
self.bridgeClientFactory = bridgeClientFactory
|
||||||
|
|
||||||
BridgeSettingsStore.bootstrapPersistence()
|
BridgeSettingsStore.bootstrapPersistence()
|
||||||
let defaults = UserDefaults.standard
|
let defaults = UserDefaults.standard
|
||||||
@@ -85,7 +100,7 @@ final class BridgeConnectionController {
|
|||||||
|
|
||||||
let token = KeychainStore.loadString(
|
let token = KeychainStore.loadString(
|
||||||
service: "com.steipete.clawdis.bridge",
|
service: "com.steipete.clawdis.bridge",
|
||||||
account: "bridge-token.\(instanceId)")?
|
account: self.keychainAccount(instanceId: instanceId))?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
guard !token.isEmpty else { return }
|
guard !token.isEmpty else { return }
|
||||||
|
|
||||||
@@ -99,28 +114,40 @@ final class BridgeConnectionController {
|
|||||||
guard let port = NWEndpoint.Port(rawValue: UInt16(resolvedPort)) else { return }
|
guard let port = NWEndpoint.Port(rawValue: UInt16(resolvedPort)) else { return }
|
||||||
|
|
||||||
self.didAutoConnect = true
|
self.didAutoConnect = true
|
||||||
appModel.connectToBridge(
|
let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(manualHost), port: port)
|
||||||
endpoint: .hostPort(host: NWEndpoint.Host(manualHost), port: port),
|
self.startAutoConnect(endpoint: endpoint, token: token, instanceId: instanceId)
|
||||||
hello: self.makeHello(token: token))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let targetStableID = defaults.string(forKey: "bridge.lastDiscoveredStableID")?
|
let preferredStableID = defaults.string(forKey: "bridge.preferredStableID")?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
guard !targetStableID.isEmpty else { return }
|
let lastDiscoveredStableID = defaults.string(forKey: "bridge.lastDiscoveredStableID")?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
|
||||||
|
let candidates = [preferredStableID, lastDiscoveredStableID].filter { !$0.isEmpty }
|
||||||
|
guard let targetStableID = candidates.first(where: { id in
|
||||||
|
self.bridges.contains(where: { $0.stableID == id })
|
||||||
|
}) else { return }
|
||||||
|
|
||||||
guard let target = self.bridges.first(where: { $0.stableID == targetStableID }) else { return }
|
guard let target = self.bridges.first(where: { $0.stableID == targetStableID }) else { return }
|
||||||
|
|
||||||
self.didAutoConnect = true
|
self.didAutoConnect = true
|
||||||
appModel.connectToBridge(endpoint: target.endpoint, hello: self.makeHello(token: token))
|
self.startAutoConnect(endpoint: target.endpoint, token: token, instanceId: instanceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateLastDiscoveredBridge(from bridges: [BridgeDiscoveryModel.DiscoveredBridge]) {
|
private func updateLastDiscoveredBridge(from bridges: [BridgeDiscoveryModel.DiscoveredBridge]) {
|
||||||
let newlyDiscovered = bridges.filter { self.seenStableIDs.insert($0.stableID).inserted }
|
let defaults = UserDefaults.standard
|
||||||
guard let last = newlyDiscovered.last else { return }
|
let preferred = defaults.string(forKey: "bridge.preferredStableID")?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let existingLast = defaults.string(forKey: "bridge.lastDiscoveredStableID")?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
|
||||||
UserDefaults.standard.set(last.stableID, forKey: "bridge.lastDiscoveredStableID")
|
// Avoid overriding user intent (preferred/lastDiscovered are also set on manual Connect).
|
||||||
BridgeSettingsStore.saveLastDiscoveredBridgeStableID(last.stableID)
|
guard preferred.isEmpty, existingLast.isEmpty else { return }
|
||||||
|
guard let first = bridges.first else { return }
|
||||||
|
|
||||||
|
defaults.set(first.stableID, forKey: "bridge.lastDiscoveredStableID")
|
||||||
|
BridgeSettingsStore.saveLastDiscoveredBridgeStableID(first.stableID)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeHello(token: String) -> BridgeHello {
|
private func makeHello(token: String) -> BridgeHello {
|
||||||
@@ -140,6 +167,40 @@ final class BridgeConnectionController {
|
|||||||
commands: self.currentCommands())
|
commands: self.currentCommands())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func keychainAccount(instanceId: String) -> String {
|
||||||
|
"bridge-token.\(instanceId)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startAutoConnect(endpoint: NWEndpoint, token: String, instanceId: String) {
|
||||||
|
guard let appModel else { return }
|
||||||
|
Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
do {
|
||||||
|
let hello = self.makeHello(token: token)
|
||||||
|
let refreshed = try await self.bridgeClientFactory().pairAndHello(
|
||||||
|
endpoint: endpoint,
|
||||||
|
hello: hello,
|
||||||
|
onStatus: { status in
|
||||||
|
Task { @MainActor in
|
||||||
|
appModel.bridgeStatusText = status
|
||||||
|
}
|
||||||
|
})
|
||||||
|
let resolvedToken = refreshed.isEmpty ? token : refreshed
|
||||||
|
if !refreshed.isEmpty, refreshed != token {
|
||||||
|
_ = KeychainStore.saveString(
|
||||||
|
refreshed,
|
||||||
|
service: "com.steipete.clawdis.bridge",
|
||||||
|
account: self.keychainAccount(instanceId: instanceId))
|
||||||
|
}
|
||||||
|
appModel.connectToBridge(endpoint: endpoint, hello: self.makeHello(token: resolvedToken))
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
appModel.bridgeStatusText = "Bridge error: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func resolvedDisplayName(defaults: UserDefaults) -> String {
|
private func resolvedDisplayName(defaults: UserDefaults) -> String {
|
||||||
let key = "node.displayName"
|
let key = "node.displayName"
|
||||||
let existing = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let existing = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
@@ -231,3 +292,47 @@ final class BridgeConnectionController {
|
|||||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"
|
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
extension BridgeConnectionController {
|
||||||
|
func _test_makeHello(token: String) -> BridgeHello {
|
||||||
|
self.makeHello(token: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _test_resolvedDisplayName(defaults: UserDefaults) -> String {
|
||||||
|
self.resolvedDisplayName(defaults: defaults)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _test_currentCaps() -> [String] {
|
||||||
|
self.currentCaps()
|
||||||
|
}
|
||||||
|
|
||||||
|
func _test_currentCommands() -> [String] {
|
||||||
|
self.currentCommands()
|
||||||
|
}
|
||||||
|
|
||||||
|
func _test_platformString() -> String {
|
||||||
|
self.platformString()
|
||||||
|
}
|
||||||
|
|
||||||
|
func _test_deviceFamily() -> String {
|
||||||
|
self.deviceFamily()
|
||||||
|
}
|
||||||
|
|
||||||
|
func _test_modelIdentifier() -> String {
|
||||||
|
self.modelIdentifier()
|
||||||
|
}
|
||||||
|
|
||||||
|
func _test_appVersion() -> String {
|
||||||
|
self.appVersion()
|
||||||
|
}
|
||||||
|
|
||||||
|
func _test_setBridges(_ bridges: [BridgeDiscoveryModel.DiscoveredBridge]) {
|
||||||
|
self.bridges = bridges
|
||||||
|
}
|
||||||
|
|
||||||
|
func _test_triggerAutoConnect() {
|
||||||
|
self.maybeAutoConnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -18,6 +18,12 @@ final class BridgeDiscoveryModel {
|
|||||||
var endpoint: NWEndpoint
|
var endpoint: NWEndpoint
|
||||||
var stableID: String
|
var stableID: String
|
||||||
var debugID: String
|
var debugID: String
|
||||||
|
var lanHost: String?
|
||||||
|
var tailnetDns: String?
|
||||||
|
var gatewayPort: Int?
|
||||||
|
var bridgePort: Int?
|
||||||
|
var canvasPort: Int?
|
||||||
|
var cliPath: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
var bridges: [DiscoveredBridge] = []
|
var bridges: [DiscoveredBridge] = []
|
||||||
@@ -68,7 +74,8 @@ final class BridgeDiscoveryModel {
|
|||||||
switch result.endpoint {
|
switch result.endpoint {
|
||||||
case let .service(name, _, _, _):
|
case let .service(name, _, _, _):
|
||||||
let decodedName = BonjourEscapes.decode(name)
|
let decodedName = BonjourEscapes.decode(name)
|
||||||
let advertisedName = result.endpoint.txtRecord?.dictionary["displayName"]
|
let txt = result.endpoint.txtRecord?.dictionary ?? [:]
|
||||||
|
let advertisedName = txt["displayName"]
|
||||||
let prettyAdvertised = advertisedName
|
let prettyAdvertised = advertisedName
|
||||||
.map(Self.prettifyInstanceName)
|
.map(Self.prettifyInstanceName)
|
||||||
.flatMap { $0.isEmpty ? nil : $0 }
|
.flatMap { $0.isEmpty ? nil : $0 }
|
||||||
@@ -77,7 +84,13 @@ final class BridgeDiscoveryModel {
|
|||||||
name: prettyName,
|
name: prettyName,
|
||||||
endpoint: result.endpoint,
|
endpoint: result.endpoint,
|
||||||
stableID: BridgeEndpointID.stableID(result.endpoint),
|
stableID: BridgeEndpointID.stableID(result.endpoint),
|
||||||
debugID: BridgeEndpointID.prettyDescription(result.endpoint))
|
debugID: BridgeEndpointID.prettyDescription(result.endpoint),
|
||||||
|
lanHost: Self.txtValue(txt, key: "lanHost"),
|
||||||
|
tailnetDns: Self.txtValue(txt, key: "tailnetDns"),
|
||||||
|
gatewayPort: Self.txtIntValue(txt, key: "gatewayPort"),
|
||||||
|
bridgePort: Self.txtIntValue(txt, key: "bridgePort"),
|
||||||
|
canvasPort: Self.txtIntValue(txt, key: "canvasPort"),
|
||||||
|
cliPath: Self.txtValue(txt, key: "cliPath"))
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -191,4 +204,14 @@ final class BridgeDiscoveryModel {
|
|||||||
.replacingOccurrences(of: #"\s+\(\d+\)$"#, with: "", options: .regularExpression)
|
.replacingOccurrences(of: #"\s+\(\d+\)$"#, with: "", options: .regularExpression)
|
||||||
return stripped.trimmingCharacters(in: .whitespacesAndNewlines)
|
return stripped.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func txtValue(_ dict: [String: String], key: String) -> String? {
|
||||||
|
let raw = dict[key]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
return raw.isEmpty ? nil : raw
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func txtIntValue(_ dict: [String: String], key: String) -> Int? {
|
||||||
|
guard let raw = self.txtValue(dict, key: key) else { return nil }
|
||||||
|
return Int(raw)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -84,10 +84,14 @@ actor CameraController {
|
|||||||
}
|
}
|
||||||
withExtendedLifetime(delegate) {}
|
withExtendedLifetime(delegate) {}
|
||||||
|
|
||||||
|
let maxPayloadBytes = 5 * 1024 * 1024
|
||||||
|
// Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under 5MB (API limit).
|
||||||
|
let maxEncodedBytes = (maxPayloadBytes / 4) * 3
|
||||||
let res = try JPEGTranscoder.transcodeToJPEG(
|
let res = try JPEGTranscoder.transcodeToJPEG(
|
||||||
imageData: rawData,
|
imageData: rawData,
|
||||||
maxWidthPx: maxWidth,
|
maxWidthPx: maxWidth,
|
||||||
quality: quality)
|
quality: quality,
|
||||||
|
maxBytes: maxEncodedBytes)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
format: format.rawValue,
|
format: format.rawValue,
|
||||||
|
|||||||
@@ -4,18 +4,20 @@ import SwiftUI
|
|||||||
struct ChatSheet: View {
|
struct ChatSheet: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var viewModel: ClawdisChatViewModel
|
@State private var viewModel: ClawdisChatViewModel
|
||||||
|
private let userAccent: Color?
|
||||||
|
|
||||||
init(bridge: BridgeSession, sessionKey: String = "main") {
|
init(bridge: BridgeSession, sessionKey: String = "main", userAccent: Color? = nil) {
|
||||||
let transport = IOSBridgeChatTransport(bridge: bridge)
|
let transport = IOSBridgeChatTransport(bridge: bridge)
|
||||||
self._viewModel = State(
|
self._viewModel = State(
|
||||||
initialValue: ClawdisChatViewModel(
|
initialValue: ClawdisChatViewModel(
|
||||||
sessionKey: sessionKey,
|
sessionKey: sessionKey,
|
||||||
transport: transport))
|
transport: transport))
|
||||||
|
self.userAccent = userAccent
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ClawdisChatView(viewModel: self.viewModel)
|
ClawdisChatView(viewModel: self.viewModel, userAccent: self.userAccent)
|
||||||
.navigationTitle("Chat")
|
.navigationTitle("Chat")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
|||||||
@@ -50,5 +50,19 @@
|
|||||||
</array>
|
</array>
|
||||||
<key>UILaunchScreen</key>
|
<key>UILaunchScreen</key>
|
||||||
<dict/>
|
<dict/>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -22,12 +22,15 @@ final class NodeAppModel {
|
|||||||
var bridgeServerName: String?
|
var bridgeServerName: String?
|
||||||
var bridgeRemoteAddress: String?
|
var bridgeRemoteAddress: String?
|
||||||
var connectedBridgeID: String?
|
var connectedBridgeID: String?
|
||||||
|
var seamColorHex: String?
|
||||||
|
var mainSessionKey: String = "main"
|
||||||
|
|
||||||
private let bridge = BridgeSession()
|
private let bridge = BridgeSession()
|
||||||
private var bridgeTask: Task<Void, Never>?
|
private var bridgeTask: Task<Void, Never>?
|
||||||
private var voiceWakeSyncTask: Task<Void, Never>?
|
private var voiceWakeSyncTask: Task<Void, Never>?
|
||||||
@ObservationIgnored private var cameraHUDDismissTask: Task<Void, Never>?
|
@ObservationIgnored private var cameraHUDDismissTask: Task<Void, Never>?
|
||||||
let voiceWake = VoiceWakeManager()
|
let voiceWake = VoiceWakeManager()
|
||||||
|
let talkMode = TalkModeManager()
|
||||||
private var lastAutoA2uiURL: String?
|
private var lastAutoA2uiURL: String?
|
||||||
|
|
||||||
var bridgeSession: BridgeSession { self.bridge }
|
var bridgeSession: BridgeSession { self.bridge }
|
||||||
@@ -35,11 +38,12 @@ final class NodeAppModel {
|
|||||||
var cameraHUDText: String?
|
var cameraHUDText: String?
|
||||||
var cameraHUDKind: CameraHUDKind?
|
var cameraHUDKind: CameraHUDKind?
|
||||||
var cameraFlashNonce: Int = 0
|
var cameraFlashNonce: Int = 0
|
||||||
|
var screenRecordActive: Bool = false
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
self.voiceWake.configure { [weak self] cmd in
|
self.voiceWake.configure { [weak self] cmd in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
let sessionKey = "main"
|
let sessionKey = await MainActor.run { self.mainSessionKey }
|
||||||
do {
|
do {
|
||||||
try await self.sendVoiceTranscript(text: cmd, sessionKey: sessionKey)
|
try await self.sendVoiceTranscript(text: cmd, sessionKey: sessionKey)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -49,6 +53,9 @@ final class NodeAppModel {
|
|||||||
|
|
||||||
let enabled = UserDefaults.standard.bool(forKey: "voiceWake.enabled")
|
let enabled = UserDefaults.standard.bool(forKey: "voiceWake.enabled")
|
||||||
self.voiceWake.setEnabled(enabled)
|
self.voiceWake.setEnabled(enabled)
|
||||||
|
self.talkMode.attachBridge(self.bridge)
|
||||||
|
let talkEnabled = UserDefaults.standard.bool(forKey: "talk.enabled")
|
||||||
|
self.talkMode.setEnabled(talkEnabled)
|
||||||
|
|
||||||
// Wire up deep links from canvas taps
|
// Wire up deep links from canvas taps
|
||||||
self.screen.onDeepLink = { [weak self] url in
|
self.screen.onDeepLink = { [weak self] url in
|
||||||
@@ -145,7 +152,7 @@ final class NodeAppModel {
|
|||||||
guard let raw = await self.bridge.currentCanvasHostUrl() else { return nil }
|
guard let raw = await self.bridge.currentCanvasHostUrl() else { return nil }
|
||||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil }
|
guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil }
|
||||||
return base.appendingPathComponent("__clawdis__/a2ui/").absoluteString
|
return base.appendingPathComponent("__clawdis__/a2ui/").absoluteString + "?platform=ios"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func showA2UIOnConnectIfNeeded() async {
|
private func showA2UIOnConnectIfNeeded() async {
|
||||||
@@ -177,6 +184,10 @@ final class NodeAppModel {
|
|||||||
self.voiceWake.setEnabled(enabled)
|
self.voiceWake.setEnabled(enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setTalkEnabled(_ enabled: Bool) {
|
||||||
|
self.talkMode.setEnabled(enabled)
|
||||||
|
}
|
||||||
|
|
||||||
func connectToBridge(
|
func connectToBridge(
|
||||||
endpoint: NWEndpoint,
|
endpoint: NWEndpoint,
|
||||||
hello: BridgeHello)
|
hello: BridgeHello)
|
||||||
@@ -216,6 +227,7 @@ final class NodeAppModel {
|
|||||||
self.bridgeRemoteAddress = addr
|
self.bridgeRemoteAddress = addr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await self.refreshBrandingFromGateway()
|
||||||
await self.startVoiceWakeSync()
|
await self.startVoiceWakeSync()
|
||||||
await self.showA2UIOnConnectIfNeeded()
|
await self.showA2UIOnConnectIfNeeded()
|
||||||
},
|
},
|
||||||
@@ -255,6 +267,8 @@ final class NodeAppModel {
|
|||||||
self.bridgeServerName = nil
|
self.bridgeServerName = nil
|
||||||
self.bridgeRemoteAddress = nil
|
self.bridgeRemoteAddress = nil
|
||||||
self.connectedBridgeID = nil
|
self.connectedBridgeID = nil
|
||||||
|
self.seamColorHex = nil
|
||||||
|
self.mainSessionKey = "main"
|
||||||
self.showLocalCanvasOnDisconnect()
|
self.showLocalCanvasOnDisconnect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -270,9 +284,47 @@ final class NodeAppModel {
|
|||||||
self.bridgeServerName = nil
|
self.bridgeServerName = nil
|
||||||
self.bridgeRemoteAddress = nil
|
self.bridgeRemoteAddress = nil
|
||||||
self.connectedBridgeID = nil
|
self.connectedBridgeID = nil
|
||||||
|
self.seamColorHex = nil
|
||||||
|
self.mainSessionKey = "main"
|
||||||
self.showLocalCanvasOnDisconnect()
|
self.showLocalCanvasOnDisconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var seamColor: Color {
|
||||||
|
Self.color(fromHex: self.seamColorHex) ?? Self.defaultSeamColor
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let defaultSeamColor = Color(red: 79 / 255.0, green: 122 / 255.0, blue: 154 / 255.0)
|
||||||
|
|
||||||
|
private static func color(fromHex raw: String?) -> Color? {
|
||||||
|
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed
|
||||||
|
guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil }
|
||||||
|
let r = Double((value >> 16) & 0xFF) / 255.0
|
||||||
|
let g = Double((value >> 8) & 0xFF) / 255.0
|
||||||
|
let b = Double(value & 0xFF) / 255.0
|
||||||
|
return Color(red: r, green: g, blue: b)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshBrandingFromGateway() async {
|
||||||
|
do {
|
||||||
|
let res = try await self.bridge.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8)
|
||||||
|
guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return }
|
||||||
|
guard let config = json["config"] as? [String: Any] else { return }
|
||||||
|
let ui = config["ui"] as? [String: Any]
|
||||||
|
let raw = (ui?["seamColor"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let session = config["session"] as? [String: Any]
|
||||||
|
let rawMainKey = (session?["mainKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let mainKey = rawMainKey.isEmpty ? "main" : rawMainKey
|
||||||
|
await MainActor.run {
|
||||||
|
self.seamColorHex = raw.isEmpty ? nil : raw
|
||||||
|
self.mainSessionKey = mainKey
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func setGlobalWakeWords(_ words: [String]) async {
|
func setGlobalWakeWords(_ words: [String]) async {
|
||||||
let sanitized = VoiceWakePreferences.sanitizeTriggerWords(words)
|
let sanitized = VoiceWakePreferences.sanitizeTriggerWords(words)
|
||||||
|
|
||||||
@@ -590,6 +642,9 @@ final class NodeAppModel {
|
|||||||
NSLocalizedDescriptionKey: "INVALID_REQUEST: screen format must be mp4",
|
NSLocalizedDescriptionKey: "INVALID_REQUEST: screen format must be mp4",
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
// Status pill mirrors screen recording state so it stays visible without overlay stacking.
|
||||||
|
self.screenRecordActive = true
|
||||||
|
defer { self.screenRecordActive = false }
|
||||||
let path = try await self.screenRecorder.record(
|
let path = try await self.screenRecorder.record(
|
||||||
screenIndex: params.screenIndex,
|
screenIndex: params.screenIndex,
|
||||||
durationMs: params.durationMs,
|
durationMs: params.durationMs,
|
||||||
@@ -680,3 +735,43 @@ final class NodeAppModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
extension NodeAppModel {
|
||||||
|
func _test_handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
|
||||||
|
await self.handleInvoke(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func _test_decodeParams<T: Decodable>(_ type: T.Type, from json: String?) throws -> T {
|
||||||
|
try self.decodeParams(type, from: json)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func _test_encodePayload(_ obj: some Encodable) throws -> String {
|
||||||
|
try self.encodePayload(obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _test_isCameraEnabled() -> Bool {
|
||||||
|
self.isCameraEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
func _test_triggerCameraFlash() {
|
||||||
|
self.triggerCameraFlash()
|
||||||
|
}
|
||||||
|
|
||||||
|
func _test_showCameraHUD(text: String, kind: CameraHUDKind, autoHideSeconds: Double? = nil) {
|
||||||
|
self.showCameraHUD(text: text, kind: kind, autoHideSeconds: autoHideSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _test_handleCanvasA2UIAction(body: [String: Any]) async {
|
||||||
|
await self.handleCanvasA2UIAction(body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _test_resolveA2UIHostURL() async -> String? {
|
||||||
|
await self.resolveA2UIHostURL()
|
||||||
|
}
|
||||||
|
|
||||||
|
func _test_showLocalCanvasOnDisconnect() {
|
||||||
|
self.showLocalCanvasOnDisconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -51,7 +51,10 @@ struct RootCanvas: View {
|
|||||||
case .settings:
|
case .settings:
|
||||||
SettingsTab()
|
SettingsTab()
|
||||||
case .chat:
|
case .chat:
|
||||||
ChatSheet(bridge: self.appModel.bridgeSession)
|
ChatSheet(
|
||||||
|
bridge: self.appModel.bridgeSession,
|
||||||
|
sessionKey: self.appModel.mainSessionKey,
|
||||||
|
userAccent: self.appModel.seamColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear { self.updateIdleTimer() }
|
.onAppear { self.updateIdleTimer() }
|
||||||
@@ -119,6 +122,9 @@ struct RootCanvas: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private struct CanvasContent: View {
|
private struct CanvasContent: View {
|
||||||
|
@Environment(NodeAppModel.self) private var appModel
|
||||||
|
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
|
||||||
|
@AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true
|
||||||
var systemColorScheme: ColorScheme
|
var systemColorScheme: ColorScheme
|
||||||
var bridgeStatus: StatusPill.BridgeState
|
var bridgeStatus: StatusPill.BridgeState
|
||||||
var voiceWakeEnabled: Bool
|
var voiceWakeEnabled: Bool
|
||||||
@@ -140,6 +146,21 @@ private struct CanvasContent: View {
|
|||||||
}
|
}
|
||||||
.accessibilityLabel("Chat")
|
.accessibilityLabel("Chat")
|
||||||
|
|
||||||
|
if self.talkButtonEnabled {
|
||||||
|
// Talk mode lives on a side bubble so it doesn't get buried in settings.
|
||||||
|
OverlayButton(
|
||||||
|
systemImage: self.appModel.talkMode.isEnabled ? "waveform.circle.fill" : "waveform.circle",
|
||||||
|
brighten: self.brightenButtons,
|
||||||
|
tint: self.appModel.seamColor,
|
||||||
|
isActive: self.appModel.talkMode.isEnabled)
|
||||||
|
{
|
||||||
|
let next = !self.appModel.talkMode.isEnabled
|
||||||
|
self.talkEnabled = next
|
||||||
|
self.appModel.setTalkEnabled(next)
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Talk Mode")
|
||||||
|
}
|
||||||
|
|
||||||
OverlayButton(systemImage: "gearshape.fill", brighten: self.brightenButtons) {
|
OverlayButton(systemImage: "gearshape.fill", brighten: self.brightenButtons) {
|
||||||
self.openSettings()
|
self.openSettings()
|
||||||
}
|
}
|
||||||
@@ -148,10 +169,17 @@ private struct CanvasContent: View {
|
|||||||
.padding(.top, 10)
|
.padding(.top, 10)
|
||||||
.padding(.trailing, 10)
|
.padding(.trailing, 10)
|
||||||
}
|
}
|
||||||
|
.overlay(alignment: .center) {
|
||||||
|
if self.appModel.talkMode.isEnabled {
|
||||||
|
TalkOrbOverlay()
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
.overlay(alignment: .topLeading) {
|
.overlay(alignment: .topLeading) {
|
||||||
StatusPill(
|
StatusPill(
|
||||||
bridge: self.bridgeStatus,
|
bridge: self.bridgeStatus,
|
||||||
voiceWakeEnabled: self.voiceWakeEnabled,
|
voiceWakeEnabled: self.voiceWakeEnabled,
|
||||||
|
activity: self.statusActivity,
|
||||||
brighten: self.brightenButtons,
|
brighten: self.brightenButtons,
|
||||||
onTap: {
|
onTap: {
|
||||||
self.openSettings()
|
self.openSettings()
|
||||||
@@ -169,45 +197,78 @@ private struct CanvasContent: View {
|
|||||||
.transition(.move(edge: .top).combined(with: .opacity))
|
.transition(.move(edge: .top).combined(with: .opacity))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.overlay(alignment: .topLeading) {
|
|
||||||
if let cameraHUDText, !cameraHUDText.isEmpty, let cameraHUDKind {
|
|
||||||
CameraCaptureToast(
|
|
||||||
text: cameraHUDText,
|
|
||||||
kind: self.mapCameraKind(cameraHUDKind),
|
|
||||||
brighten: self.brightenButtons)
|
|
||||||
.padding(SwiftUI.Edge.Set.leading, 10)
|
|
||||||
.safeAreaPadding(SwiftUI.Edge.Set.top, 106)
|
|
||||||
.transition(
|
|
||||||
AnyTransition.move(edge: SwiftUI.Edge.top)
|
|
||||||
.combined(with: AnyTransition.opacity))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func mapCameraKind(_ kind: NodeAppModel.CameraHUDKind) -> CameraCaptureToast.Kind {
|
private var statusActivity: StatusPill.Activity? {
|
||||||
switch kind {
|
// Status pill owns transient activity state so it doesn't overlap the connection indicator.
|
||||||
case .photo:
|
if self.appModel.isBackgrounded {
|
||||||
.photo
|
return StatusPill.Activity(
|
||||||
case .recording:
|
title: "Foreground required",
|
||||||
.recording
|
systemImage: "exclamationmark.triangle.fill",
|
||||||
case .success:
|
tint: .orange)
|
||||||
.success
|
|
||||||
case .error:
|
|
||||||
.error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let bridgeStatus = self.appModel.bridgeStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let bridgeLower = bridgeStatus.lowercased()
|
||||||
|
if bridgeLower.contains("repair") {
|
||||||
|
return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange)
|
||||||
|
}
|
||||||
|
if bridgeLower.contains("approval") || bridgeLower.contains("pairing") {
|
||||||
|
return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock")
|
||||||
|
}
|
||||||
|
// Avoid duplicating the primary bridge status ("Connecting…") in the activity slot.
|
||||||
|
|
||||||
|
if self.appModel.screenRecordActive {
|
||||||
|
return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let cameraHUDText, !cameraHUDText.isEmpty, let cameraHUDKind {
|
||||||
|
let systemImage: String
|
||||||
|
let tint: Color?
|
||||||
|
switch cameraHUDKind {
|
||||||
|
case .photo:
|
||||||
|
systemImage = "camera.fill"
|
||||||
|
tint = nil
|
||||||
|
case .recording:
|
||||||
|
systemImage = "video.fill"
|
||||||
|
tint = .red
|
||||||
|
case .success:
|
||||||
|
systemImage = "checkmark.circle.fill"
|
||||||
|
tint = .green
|
||||||
|
case .error:
|
||||||
|
systemImage = "exclamationmark.triangle.fill"
|
||||||
|
tint = .red
|
||||||
|
}
|
||||||
|
return StatusPill.Activity(title: cameraHUDText, systemImage: systemImage, tint: tint)
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.voiceWakeEnabled {
|
||||||
|
let voiceStatus = self.appModel.voiceWake.statusText
|
||||||
|
if voiceStatus.localizedCaseInsensitiveContains("microphone permission") {
|
||||||
|
return StatusPill.Activity(title: "Mic permission", systemImage: "mic.slash", tint: .orange)
|
||||||
|
}
|
||||||
|
if voiceStatus == "Paused" {
|
||||||
|
let suffix = self.appModel.isBackgrounded ? " (background)" : ""
|
||||||
|
return StatusPill.Activity(title: "Voice Wake paused\(suffix)", systemImage: "pause.circle.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct OverlayButton: View {
|
private struct OverlayButton: View {
|
||||||
let systemImage: String
|
let systemImage: String
|
||||||
let brighten: Bool
|
let brighten: Bool
|
||||||
|
var tint: Color?
|
||||||
|
var isActive: Bool = false
|
||||||
let action: () -> Void
|
let action: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: self.action) {
|
Button(action: self.action) {
|
||||||
Image(systemName: self.systemImage)
|
Image(systemName: self.systemImage)
|
||||||
.font(.system(size: 16, weight: .semibold))
|
.font(.system(size: 16, weight: .semibold))
|
||||||
.foregroundStyle(.primary)
|
.foregroundStyle(self.isActive ? (self.tint ?? .primary) : .primary)
|
||||||
.padding(10)
|
.padding(10)
|
||||||
.background {
|
.background {
|
||||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
@@ -225,9 +286,26 @@ private struct OverlayButton: View {
|
|||||||
endPoint: .bottomTrailing))
|
endPoint: .bottomTrailing))
|
||||||
.blendMode(.overlay)
|
.blendMode(.overlay)
|
||||||
}
|
}
|
||||||
|
.overlay {
|
||||||
|
if let tint {
|
||||||
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
|
.fill(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
tint.opacity(self.isActive ? 0.22 : 0.14),
|
||||||
|
tint.opacity(self.isActive ? 0.10 : 0.06),
|
||||||
|
.clear,
|
||||||
|
],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing))
|
||||||
|
.blendMode(.overlay)
|
||||||
|
}
|
||||||
|
}
|
||||||
.overlay {
|
.overlay {
|
||||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
.strokeBorder(.white.opacity(self.brighten ? 0.24 : 0.18), lineWidth: 0.5)
|
.strokeBorder(
|
||||||
|
(self.tint ?? .white).opacity(self.isActive ? 0.34 : (self.brighten ? 0.24 : 0.18)),
|
||||||
|
lineWidth: self.isActive ? 0.7 : 0.5)
|
||||||
}
|
}
|
||||||
.shadow(color: .black.opacity(0.35), radius: 12, y: 6)
|
.shadow(color: .black.opacity(0.35), radius: 12, y: 6)
|
||||||
}
|
}
|
||||||
@@ -261,59 +339,3 @@ private struct CameraFlashOverlay: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct CameraCaptureToast: View {
|
|
||||||
enum Kind {
|
|
||||||
case photo
|
|
||||||
case recording
|
|
||||||
case success
|
|
||||||
case error
|
|
||||||
}
|
|
||||||
|
|
||||||
var text: String
|
|
||||||
var kind: Kind
|
|
||||||
var brighten: Bool = false
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: 10) {
|
|
||||||
self.icon
|
|
||||||
.font(.system(size: 14, weight: .semibold))
|
|
||||||
.foregroundStyle(.primary)
|
|
||||||
|
|
||||||
Text(self.text)
|
|
||||||
.font(.system(size: 14, weight: .semibold))
|
|
||||||
.foregroundStyle(.primary)
|
|
||||||
.lineLimit(1)
|
|
||||||
.truncationMode(.tail)
|
|
||||||
}
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.background {
|
|
||||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
||||||
.fill(.ultraThinMaterial)
|
|
||||||
.overlay {
|
|
||||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
||||||
.strokeBorder(.white.opacity(self.brighten ? 0.24 : 0.18), lineWidth: 0.5)
|
|
||||||
}
|
|
||||||
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
|
|
||||||
}
|
|
||||||
.accessibilityLabel("Camera")
|
|
||||||
.accessibilityValue(self.text)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var icon: some View {
|
|
||||||
switch self.kind {
|
|
||||||
case .photo:
|
|
||||||
Image(systemName: "camera.fill")
|
|
||||||
case .recording:
|
|
||||||
Image(systemName: "record.circle.fill")
|
|
||||||
.symbolRenderingMode(.palette)
|
|
||||||
.foregroundStyle(.red, .primary)
|
|
||||||
case .success:
|
|
||||||
Image(systemName: "checkmark.circle.fill")
|
|
||||||
case .error:
|
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -26,6 +26,7 @@ struct RootTabs: View {
|
|||||||
StatusPill(
|
StatusPill(
|
||||||
bridge: self.bridgeStatus,
|
bridge: self.bridgeStatus,
|
||||||
voiceWakeEnabled: self.voiceWakeEnabled,
|
voiceWakeEnabled: self.voiceWakeEnabled,
|
||||||
|
activity: self.statusActivity,
|
||||||
onTap: { self.selectedTab = 2 })
|
onTap: { self.selectedTab = 2 })
|
||||||
.padding(.leading, 10)
|
.padding(.leading, 10)
|
||||||
.safeAreaPadding(.top, 10)
|
.safeAreaPadding(.top, 10)
|
||||||
@@ -79,4 +80,64 @@ struct RootTabs: View {
|
|||||||
|
|
||||||
return .disconnected
|
return .disconnected
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var statusActivity: StatusPill.Activity? {
|
||||||
|
// Keep the top pill consistent across tabs (camera + voice wake + pairing states).
|
||||||
|
if self.appModel.isBackgrounded {
|
||||||
|
return StatusPill.Activity(
|
||||||
|
title: "Foreground required",
|
||||||
|
systemImage: "exclamationmark.triangle.fill",
|
||||||
|
tint: .orange)
|
||||||
|
}
|
||||||
|
|
||||||
|
let bridgeStatus = self.appModel.bridgeStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let bridgeLower = bridgeStatus.lowercased()
|
||||||
|
if bridgeLower.contains("repair") {
|
||||||
|
return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange)
|
||||||
|
}
|
||||||
|
if bridgeLower.contains("approval") || bridgeLower.contains("pairing") {
|
||||||
|
return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock")
|
||||||
|
}
|
||||||
|
// Avoid duplicating the primary bridge status ("Connecting…") in the activity slot.
|
||||||
|
|
||||||
|
if self.appModel.screenRecordActive {
|
||||||
|
return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let cameraHUDText = self.appModel.cameraHUDText,
|
||||||
|
let cameraHUDKind = self.appModel.cameraHUDKind,
|
||||||
|
!cameraHUDText.isEmpty
|
||||||
|
{
|
||||||
|
let systemImage: String
|
||||||
|
let tint: Color?
|
||||||
|
switch cameraHUDKind {
|
||||||
|
case .photo:
|
||||||
|
systemImage = "camera.fill"
|
||||||
|
tint = nil
|
||||||
|
case .recording:
|
||||||
|
systemImage = "video.fill"
|
||||||
|
tint = .red
|
||||||
|
case .success:
|
||||||
|
systemImage = "checkmark.circle.fill"
|
||||||
|
tint = .green
|
||||||
|
case .error:
|
||||||
|
systemImage = "exclamationmark.triangle.fill"
|
||||||
|
tint = .red
|
||||||
|
}
|
||||||
|
return StatusPill.Activity(title: cameraHUDText, systemImage: systemImage, tint: tint)
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.voiceWakeEnabled {
|
||||||
|
let voiceStatus = self.appModel.voiceWake.statusText
|
||||||
|
if voiceStatus.localizedCaseInsensitiveContains("microphone permission") {
|
||||||
|
return StatusPill.Activity(title: "Mic permission", systemImage: "mic.slash", tint: .orange)
|
||||||
|
}
|
||||||
|
if voiceStatus == "Paused" {
|
||||||
|
let suffix = self.appModel.isBackgrounded ? " (background)" : ""
|
||||||
|
return StatusPill.Activity(title: "Voice Wake paused\(suffix)", systemImage: "pause.circle.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -43,9 +43,7 @@ final class ScreenController {
|
|||||||
self.webView.scrollView.contentInset = .zero
|
self.webView.scrollView.contentInset = .zero
|
||||||
self.webView.scrollView.scrollIndicatorInsets = .zero
|
self.webView.scrollView.scrollIndicatorInsets = .zero
|
||||||
self.webView.scrollView.automaticallyAdjustsScrollIndicatorInsets = false
|
self.webView.scrollView.automaticallyAdjustsScrollIndicatorInsets = false
|
||||||
// Disable scroll to allow touch events to pass through to canvas
|
self.applyScrollBehavior()
|
||||||
self.webView.scrollView.isScrollEnabled = false
|
|
||||||
self.webView.scrollView.bounces = false
|
|
||||||
self.webView.navigationDelegate = self.navigationDelegate
|
self.webView.navigationDelegate = self.navigationDelegate
|
||||||
self.navigationDelegate.controller = self
|
self.navigationDelegate.controller = self
|
||||||
a2uiActionHandler.controller = self
|
a2uiActionHandler.controller = self
|
||||||
@@ -60,6 +58,7 @@ final class ScreenController {
|
|||||||
|
|
||||||
func reload() {
|
func reload() {
|
||||||
let trimmed = self.urlString.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = self.urlString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
self.applyScrollBehavior()
|
||||||
if trimmed.isEmpty {
|
if trimmed.isEmpty {
|
||||||
guard let url = Self.canvasScaffoldURL else { return }
|
guard let url = Self.canvasScaffoldURL else { return }
|
||||||
self.errorText = nil
|
self.errorText = nil
|
||||||
@@ -129,7 +128,8 @@ final class ScreenController {
|
|||||||
} catch (_) { return false; }
|
} catch (_) { return false; }
|
||||||
})()
|
})()
|
||||||
""")
|
""")
|
||||||
if res == "true" { return true }
|
let trimmed = res.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
if trimmed == "true" || trimmed == "1" { return true }
|
||||||
} catch {
|
} catch {
|
||||||
// ignore; page likely still loading
|
// ignore; page likely still loading
|
||||||
}
|
}
|
||||||
@@ -249,6 +249,15 @@ final class ScreenController {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func applyScrollBehavior() {
|
||||||
|
let trimmed = self.urlString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let allowScroll = !trimmed.isEmpty
|
||||||
|
let scrollView = self.webView.scrollView
|
||||||
|
// Default canvas needs raw touch events; external pages should scroll.
|
||||||
|
scrollView.isScrollEnabled = allowScroll
|
||||||
|
scrollView.bounces = allowScroll
|
||||||
|
}
|
||||||
|
|
||||||
private static func jsValue(_ value: String?) -> String {
|
private static func jsValue(_ value: String?) -> String {
|
||||||
guard let value else { return "null" }
|
guard let value else { return "null" }
|
||||||
if let data = try? JSONSerialization.data(withJSONObject: [value]),
|
if let data = try? JSONSerialization.data(withJSONObject: [value]),
|
||||||
|
|||||||
@@ -1,12 +1,28 @@
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
import ReplayKit
|
import ReplayKit
|
||||||
|
|
||||||
@MainActor
|
final class ScreenRecordService: @unchecked Sendable {
|
||||||
final class ScreenRecordService {
|
|
||||||
private struct UncheckedSendableBox<T>: @unchecked Sendable {
|
private struct UncheckedSendableBox<T>: @unchecked Sendable {
|
||||||
let value: T
|
let value: T
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private final class CaptureState: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
var writer: AVAssetWriter?
|
||||||
|
var videoInput: AVAssetWriterInput?
|
||||||
|
var audioInput: AVAssetWriterInput?
|
||||||
|
var started = false
|
||||||
|
var sawVideo = false
|
||||||
|
var lastVideoTime: CMTime?
|
||||||
|
var handlerError: Error?
|
||||||
|
|
||||||
|
func withLock<T>(_ body: (CaptureState) -> T) -> T {
|
||||||
|
self.lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return body(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum ScreenRecordError: LocalizedError {
|
enum ScreenRecordError: LocalizedError {
|
||||||
case invalidScreenIndex(Int)
|
case invalidScreenIndex(Int)
|
||||||
case captureFailed(String)
|
case captureFailed(String)
|
||||||
@@ -51,126 +67,158 @@ final class ScreenRecordService {
|
|||||||
}()
|
}()
|
||||||
try? FileManager.default.removeItem(at: outURL)
|
try? FileManager.default.removeItem(at: outURL)
|
||||||
|
|
||||||
let recorder = RPScreenRecorder.shared()
|
let state = CaptureState()
|
||||||
recorder.isMicrophoneEnabled = includeAudio
|
let recordQueue = DispatchQueue(label: "com.steipete.clawdis.screenrecord")
|
||||||
|
|
||||||
var writer: AVAssetWriter?
|
|
||||||
var videoInput: AVAssetWriterInput?
|
|
||||||
var audioInput: AVAssetWriterInput?
|
|
||||||
var started = false
|
|
||||||
var sawVideo = false
|
|
||||||
var lastVideoTime: CMTime?
|
|
||||||
var handlerError: Error?
|
|
||||||
let lock = NSLock()
|
|
||||||
|
|
||||||
func setHandlerError(_ error: Error) {
|
|
||||||
lock.lock()
|
|
||||||
defer { lock.unlock() }
|
|
||||||
if handlerError == nil { handlerError = error }
|
|
||||||
}
|
|
||||||
|
|
||||||
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
||||||
recorder.startCapture(handler: { sample, type, error in
|
let handler: @Sendable (CMSampleBuffer, RPSampleBufferType, Error?) -> Void = { sample, type, error in
|
||||||
if let error {
|
// ReplayKit can call the capture handler on a background queue.
|
||||||
setHandlerError(error)
|
// Serialize writes to avoid queue asserts.
|
||||||
return
|
recordQueue.async {
|
||||||
}
|
if let error {
|
||||||
guard CMSampleBufferDataIsReady(sample) else { return }
|
state.withLock { state in
|
||||||
|
if state.handlerError == nil { state.handlerError = error }
|
||||||
switch type {
|
|
||||||
case .video:
|
|
||||||
let pts = CMSampleBufferGetPresentationTimeStamp(sample)
|
|
||||||
if let lastVideoTime {
|
|
||||||
let delta = CMTimeSubtract(pts, lastVideoTime)
|
|
||||||
if delta.seconds < (1.0 / fpsValue) { return }
|
|
||||||
}
|
|
||||||
|
|
||||||
if writer == nil {
|
|
||||||
guard let imageBuffer = CMSampleBufferGetImageBuffer(sample) else {
|
|
||||||
setHandlerError(ScreenRecordError.captureFailed("Missing image buffer"))
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
let width = CVPixelBufferGetWidth(imageBuffer)
|
return
|
||||||
let height = CVPixelBufferGetHeight(imageBuffer)
|
}
|
||||||
do {
|
guard CMSampleBufferDataIsReady(sample) else { return }
|
||||||
let w = try AVAssetWriter(outputURL: outURL, fileType: .mp4)
|
|
||||||
let settings: [String: Any] = [
|
|
||||||
AVVideoCodecKey: AVVideoCodecType.h264,
|
|
||||||
AVVideoWidthKey: width,
|
|
||||||
AVVideoHeightKey: height,
|
|
||||||
]
|
|
||||||
let vInput = AVAssetWriterInput(mediaType: .video, outputSettings: settings)
|
|
||||||
vInput.expectsMediaDataInRealTime = true
|
|
||||||
guard w.canAdd(vInput) else {
|
|
||||||
throw ScreenRecordError.writeFailed("Cannot add video input")
|
|
||||||
}
|
|
||||||
w.add(vInput)
|
|
||||||
|
|
||||||
if includeAudio {
|
switch type {
|
||||||
let aInput = AVAssetWriterInput(mediaType: .audio, outputSettings: nil)
|
case .video:
|
||||||
aInput.expectsMediaDataInRealTime = true
|
let pts = CMSampleBufferGetPresentationTimeStamp(sample)
|
||||||
if w.canAdd(aInput) {
|
let shouldSkip = state.withLock { state in
|
||||||
w.add(aInput)
|
if let lastVideoTime = state.lastVideoTime {
|
||||||
audioInput = aInput
|
let delta = CMTimeSubtract(pts, lastVideoTime)
|
||||||
|
return delta.seconds < (1.0 / fpsValue)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if shouldSkip { return }
|
||||||
|
|
||||||
|
if state.withLock({ $0.writer == nil }) {
|
||||||
|
guard let imageBuffer = CMSampleBufferGetImageBuffer(sample) else {
|
||||||
|
state.withLock { state in
|
||||||
|
if state.handlerError == nil {
|
||||||
|
state.handlerError = ScreenRecordError.captureFailed("Missing image buffer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let width = CVPixelBufferGetWidth(imageBuffer)
|
||||||
|
let height = CVPixelBufferGetHeight(imageBuffer)
|
||||||
|
do {
|
||||||
|
let w = try AVAssetWriter(outputURL: outURL, fileType: .mp4)
|
||||||
|
let settings: [String: Any] = [
|
||||||
|
AVVideoCodecKey: AVVideoCodecType.h264,
|
||||||
|
AVVideoWidthKey: width,
|
||||||
|
AVVideoHeightKey: height,
|
||||||
|
]
|
||||||
|
let vInput = AVAssetWriterInput(mediaType: .video, outputSettings: settings)
|
||||||
|
vInput.expectsMediaDataInRealTime = true
|
||||||
|
guard w.canAdd(vInput) else {
|
||||||
|
throw ScreenRecordError.writeFailed("Cannot add video input")
|
||||||
|
}
|
||||||
|
w.add(vInput)
|
||||||
|
|
||||||
|
if includeAudio {
|
||||||
|
let aInput = AVAssetWriterInput(mediaType: .audio, outputSettings: nil)
|
||||||
|
aInput.expectsMediaDataInRealTime = true
|
||||||
|
if w.canAdd(aInput) {
|
||||||
|
w.add(aInput)
|
||||||
|
state.withLock { state in
|
||||||
|
state.audioInput = aInput
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard w.startWriting() else {
|
||||||
|
throw ScreenRecordError
|
||||||
|
.writeFailed(w.error?.localizedDescription ?? "Failed to start writer")
|
||||||
|
}
|
||||||
|
w.startSession(atSourceTime: pts)
|
||||||
|
state.withLock { state in
|
||||||
|
state.writer = w
|
||||||
|
state.videoInput = vInput
|
||||||
|
state.started = true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
state.withLock { state in
|
||||||
|
if state.handlerError == nil { state.handlerError = error }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let vInput = state.withLock { $0.videoInput }
|
||||||
|
let isStarted = state.withLock { $0.started }
|
||||||
|
guard let vInput, isStarted else { return }
|
||||||
|
if vInput.isReadyForMoreMediaData {
|
||||||
|
if vInput.append(sample) {
|
||||||
|
state.withLock { state in
|
||||||
|
state.sawVideo = true
|
||||||
|
state.lastVideoTime = pts
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let err = state.withLock { $0.writer?.error }
|
||||||
|
if let err {
|
||||||
|
state.withLock { state in
|
||||||
|
if state.handlerError == nil {
|
||||||
|
state.handlerError = ScreenRecordError.writeFailed(err.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
guard w.startWriting() else {
|
|
||||||
throw ScreenRecordError
|
|
||||||
.writeFailed(w.error?.localizedDescription ?? "Failed to start writer")
|
|
||||||
}
|
|
||||||
w.startSession(atSourceTime: pts)
|
|
||||||
writer = w
|
|
||||||
videoInput = vInput
|
|
||||||
started = true
|
|
||||||
} catch {
|
|
||||||
setHandlerError(error)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
guard let vInput = videoInput, started else { return }
|
case .audioApp, .audioMic:
|
||||||
if vInput.isReadyForMoreMediaData {
|
let aInput = state.withLock { $0.audioInput }
|
||||||
if vInput.append(sample) {
|
let isStarted = state.withLock { $0.started }
|
||||||
sawVideo = true
|
guard includeAudio, let aInput, isStarted else { return }
|
||||||
lastVideoTime = pts
|
if aInput.isReadyForMoreMediaData {
|
||||||
} else {
|
_ = aInput.append(sample)
|
||||||
if let err = writer?.error {
|
|
||||||
setHandlerError(ScreenRecordError.writeFailed(err.localizedDescription))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
case .audioApp, .audioMic:
|
@unknown default:
|
||||||
guard includeAudio, let aInput = audioInput, started else { return }
|
break
|
||||||
if aInput.isReadyForMoreMediaData {
|
|
||||||
_ = aInput.append(sample)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@unknown default:
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}, completionHandler: { error in
|
}
|
||||||
|
|
||||||
|
let completion: @Sendable (Error?) -> Void = { error in
|
||||||
if let error { cont.resume(throwing: error) } else { cont.resume() }
|
if let error { cont.resume(throwing: error) } else { cont.resume() }
|
||||||
})
|
}
|
||||||
|
|
||||||
|
Task { @MainActor in
|
||||||
|
startReplayKitCapture(
|
||||||
|
includeAudio: includeAudio,
|
||||||
|
handler: handler,
|
||||||
|
completion: completion)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try await Task.sleep(nanoseconds: UInt64(durationMs) * 1_000_000)
|
try await Task.sleep(nanoseconds: UInt64(durationMs) * 1_000_000)
|
||||||
|
|
||||||
let stopError = await withCheckedContinuation { cont in
|
let stopError = await withCheckedContinuation { cont in
|
||||||
recorder.stopCapture { error in cont.resume(returning: error) }
|
Task { @MainActor in
|
||||||
|
stopReplayKitCapture { error in cont.resume(returning: error) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let stopError { throw stopError }
|
if let stopError { throw stopError }
|
||||||
|
|
||||||
if let handlerError { throw handlerError }
|
let handlerErrorSnapshot = state.withLock { $0.handlerError }
|
||||||
guard let writer, let videoInput, sawVideo else {
|
if let handlerErrorSnapshot { throw handlerErrorSnapshot }
|
||||||
|
let writerSnapshot = state.withLock { $0.writer }
|
||||||
|
let videoInputSnapshot = state.withLock { $0.videoInput }
|
||||||
|
let audioInputSnapshot = state.withLock { $0.audioInput }
|
||||||
|
let sawVideoSnapshot = state.withLock { $0.sawVideo }
|
||||||
|
guard let writerSnapshot, let videoInputSnapshot, sawVideoSnapshot else {
|
||||||
throw ScreenRecordError.captureFailed("No frames captured")
|
throw ScreenRecordError.captureFailed("No frames captured")
|
||||||
}
|
}
|
||||||
|
|
||||||
videoInput.markAsFinished()
|
videoInputSnapshot.markAsFinished()
|
||||||
audioInput?.markAsFinished()
|
audioInputSnapshot?.markAsFinished()
|
||||||
|
|
||||||
let writerBox = UncheckedSendableBox(value: writer)
|
let writerBox = UncheckedSendableBox(value: writerSnapshot)
|
||||||
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
||||||
writerBox.value.finishWriting {
|
writerBox.value.finishWriting {
|
||||||
let writer = writerBox.value
|
let writer = writerBox.value
|
||||||
@@ -198,3 +246,31 @@ final class ScreenRecordService {
|
|||||||
return min(30, max(1, v))
|
return min(30, max(1, v))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func startReplayKitCapture(
|
||||||
|
includeAudio: Bool,
|
||||||
|
handler: @escaping @Sendable (CMSampleBuffer, RPSampleBufferType, Error?) -> Void,
|
||||||
|
completion: @escaping @Sendable (Error?) -> Void)
|
||||||
|
{
|
||||||
|
let recorder = RPScreenRecorder.shared()
|
||||||
|
recorder.isMicrophoneEnabled = includeAudio
|
||||||
|
recorder.startCapture(handler: handler, completionHandler: completion)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func stopReplayKitCapture(_ completion: @escaping @Sendable (Error?) -> Void) {
|
||||||
|
RPScreenRecorder.shared().stopCapture { error in completion(error) }
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
extension ScreenRecordService {
|
||||||
|
nonisolated static func _test_clampDurationMs(_ ms: Int?) -> Int {
|
||||||
|
self.clampDurationMs(ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func _test_clampFps(_ fps: Double?) -> Double {
|
||||||
|
self.clampFps(fps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -20,6 +20,8 @@ struct SettingsTab: View {
|
|||||||
@AppStorage("node.displayName") private var displayName: String = "iOS Node"
|
@AppStorage("node.displayName") private var displayName: String = "iOS Node"
|
||||||
@AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
|
@AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
|
||||||
@AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false
|
@AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false
|
||||||
|
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
|
||||||
|
@AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true
|
||||||
@AppStorage("camera.enabled") private var cameraEnabled: Bool = true
|
@AppStorage("camera.enabled") private var cameraEnabled: Bool = true
|
||||||
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
|
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
|
||||||
@AppStorage("bridge.preferredStableID") private var preferredBridgeStableID: String = ""
|
@AppStorage("bridge.preferredStableID") private var preferredBridgeStableID: String = ""
|
||||||
@@ -51,6 +53,9 @@ struct SettingsTab: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
LabeledContent("Platform", value: self.platformString())
|
||||||
|
LabeledContent("Version", value: self.appVersion())
|
||||||
|
LabeledContent("Model", value: self.modelIdentifier())
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Bridge") {
|
Section("Bridge") {
|
||||||
@@ -153,6 +158,12 @@ struct SettingsTab: View {
|
|||||||
.onChange(of: self.voiceWakeEnabled) { _, newValue in
|
.onChange(of: self.voiceWakeEnabled) { _, newValue in
|
||||||
self.appModel.setVoiceWakeEnabled(newValue)
|
self.appModel.setVoiceWakeEnabled(newValue)
|
||||||
}
|
}
|
||||||
|
Toggle("Talk Mode", isOn: self.$talkEnabled)
|
||||||
|
.onChange(of: self.talkEnabled) { _, newValue in
|
||||||
|
self.appModel.setTalkEnabled(newValue)
|
||||||
|
}
|
||||||
|
// Keep this separate so users can hide the side bubble without disabling Talk Mode.
|
||||||
|
Toggle("Show Talk Button", isOn: self.$talkButtonEnabled)
|
||||||
|
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
VoiceWakeWordsSettingsView()
|
VoiceWakeWordsSettingsView()
|
||||||
@@ -227,6 +238,12 @@ struct SettingsTab: View {
|
|||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(bridge.name)
|
Text(bridge.name)
|
||||||
|
let detailLines = self.bridgeDetailLines(bridge)
|
||||||
|
ForEach(detailLines, id: \.self) { line in
|
||||||
|
Text(line)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
@@ -504,4 +521,26 @@ struct SettingsTab: View {
|
|||||||
private static func httpURLString(host: String?, port: Int?, fallback: String) -> String {
|
private static func httpURLString(host: String?, port: Int?, fallback: String) -> String {
|
||||||
SettingsNetworkingHelpers.httpURLString(host: host, port: port, fallback: fallback)
|
SettingsNetworkingHelpers.httpURLString(host: host, port: port, fallback: fallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func bridgeDetailLines(_ bridge: BridgeDiscoveryModel.DiscoveredBridge) -> [String] {
|
||||||
|
var lines: [String] = []
|
||||||
|
if let lanHost = bridge.lanHost { lines.append("LAN: \(lanHost)") }
|
||||||
|
if let tailnet = bridge.tailnetDns { lines.append("Tailnet: \(tailnet)") }
|
||||||
|
|
||||||
|
let gatewayPort = bridge.gatewayPort
|
||||||
|
let bridgePort = bridge.bridgePort
|
||||||
|
let canvasPort = bridge.canvasPort
|
||||||
|
if gatewayPort != nil || bridgePort != nil || canvasPort != nil {
|
||||||
|
let gw = gatewayPort.map(String.init) ?? "—"
|
||||||
|
let br = bridgePort.map(String.init) ?? "—"
|
||||||
|
let canvas = canvasPort.map(String.init) ?? "—"
|
||||||
|
lines.append("Ports: gw \(gw) · bridge \(br) · canvas \(canvas)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if lines.isEmpty {
|
||||||
|
lines.append(bridge.debugID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct StatusPill: View {
|
struct StatusPill: View {
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
enum BridgeState: Equatable {
|
enum BridgeState: Equatable {
|
||||||
case connected
|
case connected
|
||||||
case connecting
|
case connecting
|
||||||
@@ -26,8 +28,15 @@ struct StatusPill: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct Activity: Equatable {
|
||||||
|
var title: String
|
||||||
|
var systemImage: String
|
||||||
|
var tint: Color?
|
||||||
|
}
|
||||||
|
|
||||||
var bridge: BridgeState
|
var bridge: BridgeState
|
||||||
var voiceWakeEnabled: Bool
|
var voiceWakeEnabled: Bool
|
||||||
|
var activity: Activity?
|
||||||
var brighten: Bool = false
|
var brighten: Bool = false
|
||||||
var onTap: () -> Void
|
var onTap: () -> Void
|
||||||
|
|
||||||
@@ -52,10 +61,24 @@ struct StatusPill: View {
|
|||||||
.frame(height: 14)
|
.frame(height: 14)
|
||||||
.opacity(0.35)
|
.opacity(0.35)
|
||||||
|
|
||||||
Image(systemName: self.voiceWakeEnabled ? "mic.fill" : "mic.slash")
|
if let activity {
|
||||||
.font(.system(size: 13, weight: .semibold))
|
HStack(spacing: 6) {
|
||||||
.foregroundStyle(self.voiceWakeEnabled ? .primary : .secondary)
|
Image(systemName: activity.systemImage)
|
||||||
.accessibilityLabel(self.voiceWakeEnabled ? "Voice Wake enabled" : "Voice Wake disabled")
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(activity.tint ?? .primary)
|
||||||
|
Text(activity.title)
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||||
|
} else {
|
||||||
|
Image(systemName: self.voiceWakeEnabled ? "mic.fill" : "mic.slash")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(self.voiceWakeEnabled ? .primary : .secondary)
|
||||||
|
.accessibilityLabel(self.voiceWakeEnabled ? "Voice Wake enabled" : "Voice Wake disabled")
|
||||||
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
@@ -71,15 +94,27 @@ struct StatusPill: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel("Status")
|
.accessibilityLabel("Status")
|
||||||
.accessibilityValue("\(self.bridge.title), Voice Wake \(self.voiceWakeEnabled ? "enabled" : "disabled")")
|
.accessibilityValue(self.accessibilityValue)
|
||||||
.onAppear { self.updatePulse(for: self.bridge) }
|
.onAppear { self.updatePulse(for: self.bridge, scenePhase: self.scenePhase) }
|
||||||
|
.onDisappear { self.pulse = false }
|
||||||
.onChange(of: self.bridge) { _, newValue in
|
.onChange(of: self.bridge) { _, newValue in
|
||||||
self.updatePulse(for: newValue)
|
self.updatePulse(for: newValue, scenePhase: self.scenePhase)
|
||||||
}
|
}
|
||||||
|
.onChange(of: self.scenePhase) { _, newValue in
|
||||||
|
self.updatePulse(for: self.bridge, scenePhase: newValue)
|
||||||
|
}
|
||||||
|
.animation(.easeInOut(duration: 0.18), value: self.activity?.title)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updatePulse(for bridge: BridgeState) {
|
private var accessibilityValue: String {
|
||||||
guard bridge == .connecting else {
|
if let activity {
|
||||||
|
return "\(self.bridge.title), \(activity.title)"
|
||||||
|
}
|
||||||
|
return "\(self.bridge.title), Voice Wake \(self.voiceWakeEnabled ? "enabled" : "disabled")"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updatePulse(for bridge: BridgeState, scenePhase: ScenePhase) {
|
||||||
|
guard bridge == .connecting, scenePhase == .active else {
|
||||||
withAnimation(.easeOut(duration: 0.2)) { self.pulse = false }
|
withAnimation(.easeOut(duration: 0.2)) { self.pulse = false }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,713 @@
|
|||||||
|
import AVFAudio
|
||||||
|
import ClawdisKit
|
||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import OSLog
|
||||||
|
import Speech
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class TalkModeManager: NSObject {
|
||||||
|
private typealias SpeechRequest = SFSpeechAudioBufferRecognitionRequest
|
||||||
|
private static let defaultModelIdFallback = "eleven_v3"
|
||||||
|
var isEnabled: Bool = false
|
||||||
|
var isListening: Bool = false
|
||||||
|
var isSpeaking: Bool = false
|
||||||
|
var statusText: String = "Off"
|
||||||
|
|
||||||
|
private let audioEngine = AVAudioEngine()
|
||||||
|
private var speechRecognizer: SFSpeechRecognizer?
|
||||||
|
private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
|
||||||
|
private var recognitionTask: SFSpeechRecognitionTask?
|
||||||
|
private var silenceTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
private var lastHeard: Date?
|
||||||
|
private var lastTranscript: String = ""
|
||||||
|
private var lastSpokenText: String?
|
||||||
|
private var lastInterruptedAtSeconds: Double?
|
||||||
|
|
||||||
|
private var defaultVoiceId: String?
|
||||||
|
private var currentVoiceId: String?
|
||||||
|
private var defaultModelId: String?
|
||||||
|
private var currentModelId: String?
|
||||||
|
private var voiceOverrideActive = false
|
||||||
|
private var modelOverrideActive = false
|
||||||
|
private var defaultOutputFormat: String?
|
||||||
|
private var apiKey: String?
|
||||||
|
private var voiceAliases: [String: String] = [:]
|
||||||
|
private var interruptOnSpeech: Bool = true
|
||||||
|
private var mainSessionKey: String = "main"
|
||||||
|
private var fallbackVoiceId: String?
|
||||||
|
private var lastPlaybackWasPCM: Bool = false
|
||||||
|
var pcmPlayer: PCMStreamingAudioPlaying = PCMStreamingAudioPlayer.shared
|
||||||
|
var mp3Player: StreamingAudioPlaying = StreamingAudioPlayer.shared
|
||||||
|
|
||||||
|
private var bridge: BridgeSession?
|
||||||
|
private let silenceWindow: TimeInterval = 0.7
|
||||||
|
|
||||||
|
private var chatSubscribedSessionKeys = Set<String>()
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "TalkMode")
|
||||||
|
|
||||||
|
func attachBridge(_ bridge: BridgeSession) {
|
||||||
|
self.bridge = bridge
|
||||||
|
}
|
||||||
|
|
||||||
|
func setEnabled(_ enabled: Bool) {
|
||||||
|
self.isEnabled = enabled
|
||||||
|
if enabled {
|
||||||
|
self.logger.info("enabled")
|
||||||
|
Task { await self.start() }
|
||||||
|
} else {
|
||||||
|
self.logger.info("disabled")
|
||||||
|
self.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() async {
|
||||||
|
guard self.isEnabled else { return }
|
||||||
|
if self.isListening { return }
|
||||||
|
|
||||||
|
self.logger.info("start")
|
||||||
|
self.statusText = "Requesting permissions…"
|
||||||
|
let micOk = await Self.requestMicrophonePermission()
|
||||||
|
guard micOk else {
|
||||||
|
self.logger.warning("start blocked: microphone permission denied")
|
||||||
|
self.statusText = "Microphone permission denied"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let speechOk = await Self.requestSpeechPermission()
|
||||||
|
guard speechOk else {
|
||||||
|
self.logger.warning("start blocked: speech permission denied")
|
||||||
|
self.statusText = "Speech recognition permission denied"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await self.reloadConfig()
|
||||||
|
do {
|
||||||
|
try Self.configureAudioSession()
|
||||||
|
try self.startRecognition()
|
||||||
|
self.isListening = true
|
||||||
|
self.statusText = "Listening"
|
||||||
|
self.startSilenceMonitor()
|
||||||
|
await self.subscribeChatIfNeeded(sessionKey: self.mainSessionKey)
|
||||||
|
self.logger.info("listening")
|
||||||
|
} catch {
|
||||||
|
self.isListening = false
|
||||||
|
self.statusText = "Start failed: \(error.localizedDescription)"
|
||||||
|
self.logger.error("start failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
self.isEnabled = false
|
||||||
|
self.isListening = false
|
||||||
|
self.statusText = "Off"
|
||||||
|
self.lastTranscript = ""
|
||||||
|
self.lastHeard = nil
|
||||||
|
self.silenceTask?.cancel()
|
||||||
|
self.silenceTask = nil
|
||||||
|
self.stopRecognition()
|
||||||
|
self.stopSpeaking()
|
||||||
|
self.lastInterruptedAtSeconds = nil
|
||||||
|
TalkSystemSpeechSynthesizer.shared.stop()
|
||||||
|
do {
|
||||||
|
try AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation])
|
||||||
|
} catch {
|
||||||
|
self.logger.warning("audio session deactivate failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
Task { await self.unsubscribeAllChats() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func userTappedOrb() {
|
||||||
|
self.stopSpeaking()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startRecognition() throws {
|
||||||
|
self.stopRecognition()
|
||||||
|
self.speechRecognizer = SFSpeechRecognizer()
|
||||||
|
guard let recognizer = self.speechRecognizer else {
|
||||||
|
throw NSError(domain: "TalkMode", code: 1, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "Speech recognizer unavailable",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest()
|
||||||
|
self.recognitionRequest?.shouldReportPartialResults = true
|
||||||
|
guard let request = self.recognitionRequest else { return }
|
||||||
|
|
||||||
|
let input = self.audioEngine.inputNode
|
||||||
|
let format = input.outputFormat(forBus: 0)
|
||||||
|
input.removeTap(onBus: 0)
|
||||||
|
let tapBlock = Self.makeAudioTapAppendCallback(request: request)
|
||||||
|
input.installTap(onBus: 0, bufferSize: 2048, format: format, block: tapBlock)
|
||||||
|
|
||||||
|
self.audioEngine.prepare()
|
||||||
|
try self.audioEngine.start()
|
||||||
|
|
||||||
|
self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in
|
||||||
|
guard let self else { return }
|
||||||
|
if let error {
|
||||||
|
if !self.isSpeaking {
|
||||||
|
self.statusText = "Speech error: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
self.logger.debug("speech recognition error: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
guard let result else { return }
|
||||||
|
let transcript = result.bestTranscription.formattedString
|
||||||
|
Task { @MainActor in
|
||||||
|
await self.handleTranscript(transcript: transcript, isFinal: result.isFinal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopRecognition() {
|
||||||
|
self.recognitionTask?.cancel()
|
||||||
|
self.recognitionTask = nil
|
||||||
|
self.recognitionRequest?.endAudio()
|
||||||
|
self.recognitionRequest = nil
|
||||||
|
self.audioEngine.inputNode.removeTap(onBus: 0)
|
||||||
|
self.audioEngine.stop()
|
||||||
|
self.speechRecognizer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func makeAudioTapAppendCallback(request: SpeechRequest) -> AVAudioNodeTapBlock {
|
||||||
|
{ buffer, _ in
|
||||||
|
request.append(buffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleTranscript(transcript: String, isFinal: Bool) async {
|
||||||
|
let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if self.isSpeaking, self.interruptOnSpeech {
|
||||||
|
if self.shouldInterrupt(with: trimmed) {
|
||||||
|
self.stopSpeaking()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard self.isListening else { return }
|
||||||
|
if !trimmed.isEmpty {
|
||||||
|
self.lastTranscript = trimmed
|
||||||
|
self.lastHeard = Date()
|
||||||
|
}
|
||||||
|
if isFinal {
|
||||||
|
self.lastTranscript = trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startSilenceMonitor() {
|
||||||
|
self.silenceTask?.cancel()
|
||||||
|
self.silenceTask = Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
while self.isEnabled {
|
||||||
|
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||||
|
await self.checkSilence()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func checkSilence() async {
|
||||||
|
guard self.isListening, !self.isSpeaking else { return }
|
||||||
|
let transcript = self.lastTranscript.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !transcript.isEmpty else { return }
|
||||||
|
guard let lastHeard else { return }
|
||||||
|
if Date().timeIntervalSince(lastHeard) < self.silenceWindow { return }
|
||||||
|
await self.finalizeTranscript(transcript)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finalizeTranscript(_ transcript: String) async {
|
||||||
|
self.isListening = false
|
||||||
|
self.statusText = "Thinking…"
|
||||||
|
self.lastTranscript = ""
|
||||||
|
self.lastHeard = nil
|
||||||
|
self.stopRecognition()
|
||||||
|
|
||||||
|
await self.reloadConfig()
|
||||||
|
let prompt = self.buildPrompt(transcript: transcript)
|
||||||
|
guard let bridge else {
|
||||||
|
self.statusText = "Bridge not connected"
|
||||||
|
self.logger.warning("finalize: bridge not connected")
|
||||||
|
await self.start()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let startedAt = Date().timeIntervalSince1970
|
||||||
|
let sessionKey = self.mainSessionKey
|
||||||
|
await self.subscribeChatIfNeeded(sessionKey: sessionKey)
|
||||||
|
self.logger.info(
|
||||||
|
"chat.send start sessionKey=\(sessionKey, privacy: .public) chars=\(prompt.count, privacy: .public)")
|
||||||
|
let runId = try await self.sendChat(prompt, bridge: bridge)
|
||||||
|
self.logger.info("chat.send ok runId=\(runId, privacy: .public)")
|
||||||
|
let completion = await self.waitForChatCompletion(runId: runId, bridge: bridge, timeoutSeconds: 120)
|
||||||
|
if completion == .timeout {
|
||||||
|
self.logger.warning(
|
||||||
|
"chat completion timeout runId=\(runId, privacy: .public); attempting history fallback")
|
||||||
|
} else if completion == .aborted {
|
||||||
|
self.statusText = "Aborted"
|
||||||
|
self.logger.warning("chat completion aborted runId=\(runId, privacy: .public)")
|
||||||
|
await self.start()
|
||||||
|
return
|
||||||
|
} else if completion == .error {
|
||||||
|
self.statusText = "Chat error"
|
||||||
|
self.logger.warning("chat completion error runId=\(runId, privacy: .public)")
|
||||||
|
await self.start()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let assistantText = try await self.waitForAssistantText(
|
||||||
|
bridge: bridge,
|
||||||
|
since: startedAt,
|
||||||
|
timeoutSeconds: completion == .final ? 12 : 25)
|
||||||
|
else {
|
||||||
|
self.statusText = "No reply"
|
||||||
|
self.logger.warning("assistant text timeout runId=\(runId, privacy: .public)")
|
||||||
|
await self.start()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.logger.info("assistant text ok chars=\(assistantText.count, privacy: .public)")
|
||||||
|
await self.playAssistant(text: assistantText)
|
||||||
|
} catch {
|
||||||
|
self.statusText = "Talk failed: \(error.localizedDescription)"
|
||||||
|
self.logger.error("finalize failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
|
await self.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func subscribeChatIfNeeded(sessionKey: String) async {
|
||||||
|
let key = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !key.isEmpty else { return }
|
||||||
|
guard let bridge else { return }
|
||||||
|
guard !self.chatSubscribedSessionKeys.contains(key) else { return }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let payload = "{\"sessionKey\":\"\(key)\"}"
|
||||||
|
try await bridge.sendEvent(event: "chat.subscribe", payloadJSON: payload)
|
||||||
|
self.chatSubscribedSessionKeys.insert(key)
|
||||||
|
self.logger.info("chat.subscribe ok sessionKey=\(key, privacy: .public)")
|
||||||
|
} catch {
|
||||||
|
self.logger
|
||||||
|
.warning(
|
||||||
|
"chat.subscribe failed sessionKey=\(key, privacy: .public) err=\(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func unsubscribeAllChats() async {
|
||||||
|
guard let bridge else { return }
|
||||||
|
let keys = self.chatSubscribedSessionKeys
|
||||||
|
self.chatSubscribedSessionKeys.removeAll()
|
||||||
|
for key in keys {
|
||||||
|
do {
|
||||||
|
let payload = "{\"sessionKey\":\"\(key)\"}"
|
||||||
|
try await bridge.sendEvent(event: "chat.unsubscribe", payloadJSON: payload)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildPrompt(transcript: String) -> String {
|
||||||
|
let interrupted = self.lastInterruptedAtSeconds
|
||||||
|
self.lastInterruptedAtSeconds = nil
|
||||||
|
return TalkPromptBuilder.build(transcript: transcript, interruptedAtSeconds: interrupted)
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum ChatCompletionState: CustomStringConvertible {
|
||||||
|
case final
|
||||||
|
case aborted
|
||||||
|
case error
|
||||||
|
case timeout
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .final: "final"
|
||||||
|
case .aborted: "aborted"
|
||||||
|
case .error: "error"
|
||||||
|
case .timeout: "timeout"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendChat(_ message: String, bridge: BridgeSession) async throws -> String {
|
||||||
|
struct SendResponse: Decodable { let runId: String }
|
||||||
|
let payload: [String: Any] = [
|
||||||
|
"sessionKey": self.mainSessionKey,
|
||||||
|
"message": message,
|
||||||
|
"thinking": "low",
|
||||||
|
"timeoutMs": 30000,
|
||||||
|
"idempotencyKey": UUID().uuidString,
|
||||||
|
]
|
||||||
|
let data = try JSONSerialization.data(withJSONObject: payload)
|
||||||
|
let json = String(decoding: data, as: UTF8.self)
|
||||||
|
let res = try await bridge.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 30)
|
||||||
|
let decoded = try JSONDecoder().decode(SendResponse.self, from: res)
|
||||||
|
return decoded.runId
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitForChatCompletion(
|
||||||
|
runId: String,
|
||||||
|
bridge: BridgeSession,
|
||||||
|
timeoutSeconds: Int = 120) async -> ChatCompletionState
|
||||||
|
{
|
||||||
|
let stream = await bridge.subscribeServerEvents(bufferingNewest: 200)
|
||||||
|
return await withTaskGroup(of: ChatCompletionState.self) { group in
|
||||||
|
group.addTask { [runId] in
|
||||||
|
for await evt in stream {
|
||||||
|
if Task.isCancelled { return .timeout }
|
||||||
|
guard evt.event == "chat", let payload = evt.payloadJSON else { continue }
|
||||||
|
guard let data = payload.data(using: .utf8) else { continue }
|
||||||
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { continue }
|
||||||
|
if (json["runId"] as? String) != runId { continue }
|
||||||
|
if let state = json["state"] as? String {
|
||||||
|
switch state {
|
||||||
|
case "final": return .final
|
||||||
|
case "aborted": return .aborted
|
||||||
|
case "error": return .error
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return .timeout
|
||||||
|
}
|
||||||
|
group.addTask {
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(timeoutSeconds) * 1_000_000_000)
|
||||||
|
return .timeout
|
||||||
|
}
|
||||||
|
let result = await group.next() ?? .timeout
|
||||||
|
group.cancelAll()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitForAssistantText(
|
||||||
|
bridge: BridgeSession,
|
||||||
|
since: Double,
|
||||||
|
timeoutSeconds: Int) async throws -> String?
|
||||||
|
{
|
||||||
|
let deadline = Date().addingTimeInterval(TimeInterval(timeoutSeconds))
|
||||||
|
while Date() < deadline {
|
||||||
|
if let text = try await self.fetchLatestAssistantText(bridge: bridge, since: since) {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchLatestAssistantText(bridge: BridgeSession, since: Double? = nil) async throws -> String? {
|
||||||
|
let res = try await bridge.request(
|
||||||
|
method: "chat.history",
|
||||||
|
paramsJSON: "{\"sessionKey\":\"\(self.mainSessionKey)\"}",
|
||||||
|
timeoutSeconds: 15)
|
||||||
|
guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return nil }
|
||||||
|
guard let messages = json["messages"] as? [[String: Any]] else { return nil }
|
||||||
|
for msg in messages.reversed() {
|
||||||
|
guard (msg["role"] as? String) == "assistant" else { continue }
|
||||||
|
if let since, let timestamp = msg["timestamp"] as? Double,
|
||||||
|
TalkHistoryTimestamp.isAfter(timestamp, sinceSeconds: since) == false
|
||||||
|
{
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
guard let content = msg["content"] as? [[String: Any]] else { continue }
|
||||||
|
let text = content.compactMap { $0["text"] as? String }.joined(separator: "\n")
|
||||||
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !trimmed.isEmpty { return trimmed }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func playAssistant(text: String) async {
|
||||||
|
let parsed = TalkDirectiveParser.parse(text)
|
||||||
|
let directive = parsed.directive
|
||||||
|
let cleaned = parsed.stripped.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !cleaned.isEmpty else { return }
|
||||||
|
|
||||||
|
let requestedVoice = directive?.voiceId?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let resolvedVoice = self.resolveVoiceAlias(requestedVoice)
|
||||||
|
if requestedVoice?.isEmpty == false, resolvedVoice == nil {
|
||||||
|
self.logger.warning("unknown voice alias \(requestedVoice ?? "?", privacy: .public)")
|
||||||
|
}
|
||||||
|
if let voice = resolvedVoice {
|
||||||
|
if directive?.once != true {
|
||||||
|
self.currentVoiceId = voice
|
||||||
|
self.voiceOverrideActive = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let model = directive?.modelId {
|
||||||
|
if directive?.once != true {
|
||||||
|
self.currentModelId = model
|
||||||
|
self.modelOverrideActive = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.statusText = "Generating voice…"
|
||||||
|
self.isSpeaking = true
|
||||||
|
self.lastSpokenText = cleaned
|
||||||
|
|
||||||
|
do {
|
||||||
|
let started = Date()
|
||||||
|
let language = ElevenLabsTTSClient.validatedLanguage(directive?.language)
|
||||||
|
|
||||||
|
let resolvedKey =
|
||||||
|
(self.apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? self.apiKey : nil) ??
|
||||||
|
ProcessInfo.processInfo.environment["ELEVENLABS_API_KEY"]
|
||||||
|
let apiKey = resolvedKey?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let preferredVoice = resolvedVoice ?? self.currentVoiceId ?? self.defaultVoiceId
|
||||||
|
let voiceId: String? = if let apiKey, !apiKey.isEmpty {
|
||||||
|
await self.resolveVoiceId(preferred: preferredVoice, apiKey: apiKey)
|
||||||
|
} else {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
let canUseElevenLabs = (voiceId?.isEmpty == false) && (apiKey?.isEmpty == false)
|
||||||
|
|
||||||
|
if canUseElevenLabs, let voiceId, let apiKey {
|
||||||
|
let desiredOutputFormat = (directive?.outputFormat ?? self.defaultOutputFormat)?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let requestedOutputFormat = (desiredOutputFormat?.isEmpty == false) ? desiredOutputFormat : nil
|
||||||
|
let outputFormat = ElevenLabsTTSClient.validatedOutputFormat(requestedOutputFormat ?? "pcm_44100")
|
||||||
|
if outputFormat == nil, let requestedOutputFormat {
|
||||||
|
self.logger.warning(
|
||||||
|
"talk output_format unsupported for local playback: \(requestedOutputFormat, privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let modelId = directive?.modelId ?? self.currentModelId ?? self.defaultModelId
|
||||||
|
func makeRequest(outputFormat: String?) -> ElevenLabsTTSRequest {
|
||||||
|
ElevenLabsTTSRequest(
|
||||||
|
text: cleaned,
|
||||||
|
modelId: modelId,
|
||||||
|
outputFormat: outputFormat,
|
||||||
|
speed: TalkTTSValidation.resolveSpeed(speed: directive?.speed, rateWPM: directive?.rateWPM),
|
||||||
|
stability: TalkTTSValidation.validatedStability(directive?.stability, modelId: modelId),
|
||||||
|
similarity: TalkTTSValidation.validatedUnit(directive?.similarity),
|
||||||
|
style: TalkTTSValidation.validatedUnit(directive?.style),
|
||||||
|
speakerBoost: directive?.speakerBoost,
|
||||||
|
seed: TalkTTSValidation.validatedSeed(directive?.seed),
|
||||||
|
normalize: ElevenLabsTTSClient.validatedNormalize(directive?.normalize),
|
||||||
|
language: language,
|
||||||
|
latencyTier: TalkTTSValidation.validatedLatencyTier(directive?.latencyTier))
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = makeRequest(outputFormat: outputFormat)
|
||||||
|
|
||||||
|
let client = ElevenLabsTTSClient(apiKey: apiKey)
|
||||||
|
let stream = client.streamSynthesize(voiceId: voiceId, request: request)
|
||||||
|
|
||||||
|
if self.interruptOnSpeech {
|
||||||
|
do {
|
||||||
|
try self.startRecognition()
|
||||||
|
} catch {
|
||||||
|
self.logger.warning(
|
||||||
|
"startRecognition during speak failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.statusText = "Speaking…"
|
||||||
|
let sampleRate = TalkTTSValidation.pcmSampleRate(from: outputFormat)
|
||||||
|
let result: StreamingPlaybackResult
|
||||||
|
if let sampleRate {
|
||||||
|
self.lastPlaybackWasPCM = true
|
||||||
|
var playback = await self.pcmPlayer.play(stream: stream, sampleRate: sampleRate)
|
||||||
|
if !playback.finished, playback.interruptedAt == nil {
|
||||||
|
let mp3Format = ElevenLabsTTSClient.validatedOutputFormat("mp3_44100")
|
||||||
|
self.logger.warning("pcm playback failed; retrying mp3")
|
||||||
|
self.lastPlaybackWasPCM = false
|
||||||
|
let mp3Stream = client.streamSynthesize(
|
||||||
|
voiceId: voiceId,
|
||||||
|
request: makeRequest(outputFormat: mp3Format))
|
||||||
|
playback = await self.mp3Player.play(stream: mp3Stream)
|
||||||
|
}
|
||||||
|
result = playback
|
||||||
|
} else {
|
||||||
|
self.lastPlaybackWasPCM = false
|
||||||
|
result = await self.mp3Player.play(stream: stream)
|
||||||
|
}
|
||||||
|
self.logger
|
||||||
|
.info(
|
||||||
|
"elevenlabs stream finished=\(result.finished, privacy: .public) dur=\(Date().timeIntervalSince(started), privacy: .public)s")
|
||||||
|
if !result.finished, let interruptedAt = result.interruptedAt {
|
||||||
|
self.lastInterruptedAtSeconds = interruptedAt
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.logger.warning("tts unavailable; falling back to system voice (missing key or voiceId)")
|
||||||
|
if self.interruptOnSpeech {
|
||||||
|
do {
|
||||||
|
try self.startRecognition()
|
||||||
|
} catch {
|
||||||
|
self.logger.warning(
|
||||||
|
"startRecognition during speak failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.statusText = "Speaking (System)…"
|
||||||
|
try await TalkSystemSpeechSynthesizer.shared.speak(text: cleaned, language: language)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
self.logger.error(
|
||||||
|
"tts failed: \(error.localizedDescription, privacy: .public); falling back to system voice")
|
||||||
|
do {
|
||||||
|
if self.interruptOnSpeech {
|
||||||
|
do {
|
||||||
|
try self.startRecognition()
|
||||||
|
} catch {
|
||||||
|
self.logger.warning(
|
||||||
|
"startRecognition during speak failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.statusText = "Speaking (System)…"
|
||||||
|
let language = ElevenLabsTTSClient.validatedLanguage(directive?.language)
|
||||||
|
try await TalkSystemSpeechSynthesizer.shared.speak(text: cleaned, language: language)
|
||||||
|
} catch {
|
||||||
|
self.statusText = "Speak failed: \(error.localizedDescription)"
|
||||||
|
self.logger.error("system voice failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.stopRecognition()
|
||||||
|
self.isSpeaking = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopSpeaking(storeInterruption: Bool = true) {
|
||||||
|
guard self.isSpeaking else { return }
|
||||||
|
let interruptedAt = self.lastPlaybackWasPCM
|
||||||
|
? self.pcmPlayer.stop()
|
||||||
|
: self.mp3Player.stop()
|
||||||
|
if storeInterruption {
|
||||||
|
self.lastInterruptedAtSeconds = interruptedAt
|
||||||
|
}
|
||||||
|
_ = self.lastPlaybackWasPCM
|
||||||
|
? self.mp3Player.stop()
|
||||||
|
: self.pcmPlayer.stop()
|
||||||
|
TalkSystemSpeechSynthesizer.shared.stop()
|
||||||
|
self.isSpeaking = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shouldInterrupt(with transcript: String) -> Bool {
|
||||||
|
let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard trimmed.count >= 3 else { return false }
|
||||||
|
if let spoken = self.lastSpokenText?.lowercased(), spoken.contains(trimmed.lowercased()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveVoiceAlias(_ value: String?) -> String? {
|
||||||
|
let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
let normalized = trimmed.lowercased()
|
||||||
|
if let mapped = self.voiceAliases[normalized] { return mapped }
|
||||||
|
if self.voiceAliases.values.contains(where: { $0.caseInsensitiveCompare(trimmed) == .orderedSame }) {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
return Self.isLikelyVoiceId(trimmed) ? trimmed : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveVoiceId(preferred: String?, apiKey: String) async -> String? {
|
||||||
|
let trimmed = preferred?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmed.isEmpty {
|
||||||
|
if let resolved = self.resolveVoiceAlias(trimmed) { return resolved }
|
||||||
|
self.logger.warning("unknown voice alias \(trimmed, privacy: .public)")
|
||||||
|
}
|
||||||
|
if let fallbackVoiceId { return fallbackVoiceId }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let voices = try await ElevenLabsTTSClient(apiKey: apiKey).listVoices()
|
||||||
|
guard let first = voices.first else {
|
||||||
|
self.logger.warning("elevenlabs voices list empty")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.fallbackVoiceId = first.voiceId
|
||||||
|
if self.defaultVoiceId == nil {
|
||||||
|
self.defaultVoiceId = first.voiceId
|
||||||
|
}
|
||||||
|
if !self.voiceOverrideActive {
|
||||||
|
self.currentVoiceId = first.voiceId
|
||||||
|
}
|
||||||
|
let name = first.name ?? "unknown"
|
||||||
|
self.logger
|
||||||
|
.info("default voice selected \(name, privacy: .public) (\(first.voiceId, privacy: .public))")
|
||||||
|
return first.voiceId
|
||||||
|
} catch {
|
||||||
|
self.logger.error("elevenlabs list voices failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isLikelyVoiceId(_ value: String) -> Bool {
|
||||||
|
guard value.count >= 10 else { return false }
|
||||||
|
return value.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reloadConfig() async {
|
||||||
|
guard let bridge else { return }
|
||||||
|
do {
|
||||||
|
let res = try await bridge.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8)
|
||||||
|
guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return }
|
||||||
|
guard let config = json["config"] as? [String: Any] else { return }
|
||||||
|
let talk = config["talk"] as? [String: Any]
|
||||||
|
let session = config["session"] as? [String: Any]
|
||||||
|
let rawMainKey = (session?["mainKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
self.mainSessionKey = rawMainKey.isEmpty ? "main" : rawMainKey
|
||||||
|
self.defaultVoiceId = (talk?["voiceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if let aliases = talk?["voiceAliases"] as? [String: Any] {
|
||||||
|
var resolved: [String: String] = [:]
|
||||||
|
for (key, value) in aliases {
|
||||||
|
guard let id = value as? String else { continue }
|
||||||
|
let normalizedKey = key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
let trimmedId = id.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !normalizedKey.isEmpty, !trimmedId.isEmpty else { continue }
|
||||||
|
resolved[normalizedKey] = trimmedId
|
||||||
|
}
|
||||||
|
self.voiceAliases = resolved
|
||||||
|
} else {
|
||||||
|
self.voiceAliases = [:]
|
||||||
|
}
|
||||||
|
if !self.voiceOverrideActive {
|
||||||
|
self.currentVoiceId = self.defaultVoiceId
|
||||||
|
}
|
||||||
|
let model = (talk?["modelId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
self.defaultModelId = (model?.isEmpty == false) ? model : Self.defaultModelIdFallback
|
||||||
|
if !self.modelOverrideActive {
|
||||||
|
self.currentModelId = self.defaultModelId
|
||||||
|
}
|
||||||
|
self.defaultOutputFormat = (talk?["outputFormat"] as? String)?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
self.apiKey = (talk?["apiKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if let interrupt = talk?["interruptOnSpeech"] as? Bool {
|
||||||
|
self.interruptOnSpeech = interrupt
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
self.defaultModelId = Self.defaultModelIdFallback
|
||||||
|
if !self.modelOverrideActive {
|
||||||
|
self.currentModelId = self.defaultModelId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func configureAudioSession() throws {
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
try session.setCategory(.playAndRecord, mode: .voiceChat, options: [
|
||||||
|
.duckOthers,
|
||||||
|
.mixWithOthers,
|
||||||
|
.allowBluetoothHFP,
|
||||||
|
.defaultToSpeaker,
|
||||||
|
])
|
||||||
|
try session.setActive(true, options: [])
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func requestMicrophonePermission() async -> Bool {
|
||||||
|
await withCheckedContinuation(isolation: nil) { cont in
|
||||||
|
AVAudioApplication.requestRecordPermission { ok in
|
||||||
|
cont.resume(returning: ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func requestSpeechPermission() async -> Bool {
|
||||||
|
await withCheckedContinuation(isolation: nil) { cont in
|
||||||
|
SFSpeechRecognizer.requestAuthorization { status in
|
||||||
|
cont.resume(returning: status == .authorized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TalkOrbOverlay: View {
|
||||||
|
@Environment(NodeAppModel.self) private var appModel
|
||||||
|
@State private var pulse: Bool = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let seam = self.appModel.seamColor
|
||||||
|
let status = self.appModel.talkMode.statusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
|
VStack(spacing: 14) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.stroke(seam.opacity(0.26), lineWidth: 2)
|
||||||
|
.frame(width: 320, height: 320)
|
||||||
|
.scaleEffect(self.pulse ? 1.15 : 0.96)
|
||||||
|
.opacity(self.pulse ? 0.0 : 1.0)
|
||||||
|
.animation(.easeOut(duration: 1.3).repeatForever(autoreverses: false), value: self.pulse)
|
||||||
|
|
||||||
|
Circle()
|
||||||
|
.stroke(seam.opacity(0.18), lineWidth: 2)
|
||||||
|
.frame(width: 320, height: 320)
|
||||||
|
.scaleEffect(self.pulse ? 1.45 : 1.02)
|
||||||
|
.opacity(self.pulse ? 0.0 : 0.9)
|
||||||
|
.animation(.easeOut(duration: 1.9).repeatForever(autoreverses: false).delay(0.2), value: self.pulse)
|
||||||
|
|
||||||
|
Circle()
|
||||||
|
.fill(
|
||||||
|
RadialGradient(
|
||||||
|
colors: [
|
||||||
|
seam.opacity(0.95),
|
||||||
|
seam.opacity(0.40),
|
||||||
|
Color.black.opacity(0.55),
|
||||||
|
],
|
||||||
|
center: .center,
|
||||||
|
startRadius: 1,
|
||||||
|
endRadius: 112))
|
||||||
|
.frame(width: 190, height: 190)
|
||||||
|
.overlay(
|
||||||
|
Circle()
|
||||||
|
.stroke(seam.opacity(0.35), lineWidth: 1))
|
||||||
|
.shadow(color: seam.opacity(0.32), radius: 26, x: 0, y: 0)
|
||||||
|
.shadow(color: Color.black.opacity(0.50), radius: 22, x: 0, y: 10)
|
||||||
|
}
|
||||||
|
.contentShape(Circle())
|
||||||
|
.onTapGesture {
|
||||||
|
self.appModel.talkMode.userTappedOrb()
|
||||||
|
}
|
||||||
|
|
||||||
|
if !status.isEmpty, status != "Off" {
|
||||||
|
Text(status)
|
||||||
|
.font(.system(.footnote, design: .rounded).weight(.semibold))
|
||||||
|
.foregroundStyle(Color.white.opacity(0.92))
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(
|
||||||
|
Capsule()
|
||||||
|
.fill(Color.black.opacity(0.40))
|
||||||
|
.overlay(
|
||||||
|
Capsule().stroke(seam.opacity(0.22), lineWidth: 1)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(28)
|
||||||
|
.onAppear {
|
||||||
|
self.pulse = true
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityLabel("Talk Mode \(status)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ struct VoiceTab: View {
|
|||||||
@Environment(NodeAppModel.self) private var appModel
|
@Environment(NodeAppModel.self) private var appModel
|
||||||
@Environment(VoiceWakeManager.self) private var voiceWake
|
@Environment(VoiceWakeManager.self) private var voiceWake
|
||||||
@AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false
|
@AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false
|
||||||
|
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -14,6 +15,7 @@ struct VoiceTab: View {
|
|||||||
Text(self.voiceWake.statusText)
|
Text(self.voiceWake.statusText)
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
LabeledContent("Talk Mode", value: self.talkEnabled ? "Enabled" : "Disabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Notes") {
|
Section("Notes") {
|
||||||
@@ -36,6 +38,9 @@ struct VoiceTab: View {
|
|||||||
.onChange(of: self.voiceWakeEnabled) { _, newValue in
|
.onChange(of: self.voiceWakeEnabled) { _, newValue in
|
||||||
self.appModel.setVoiceWakeEnabled(newValue)
|
self.appModel.setVoiceWakeEnabled(newValue)
|
||||||
}
|
}
|
||||||
|
.onChange(of: self.talkEnabled) { _, newValue in
|
||||||
|
self.appModel.setTalkEnabled(newValue)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -379,3 +379,11 @@ final class VoiceWakeManager: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
extension VoiceWakeManager {
|
||||||
|
func _test_handleRecognitionCallback(transcript: String?, segments: [WakeWordSegment], errorText: String?) {
|
||||||
|
self.handleRecognitionCallback(transcript: transcript, segments: segments, errorText: errorText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -54,4 +54,7 @@ Sources/Voice/VoiceWakePreferences.swift
|
|||||||
../shared/ClawdisKit/Sources/ClawdisKit/ScreenCommands.swift
|
../shared/ClawdisKit/Sources/ClawdisKit/ScreenCommands.swift
|
||||||
../shared/ClawdisKit/Sources/ClawdisKit/StoragePaths.swift
|
../shared/ClawdisKit/Sources/ClawdisKit/StoragePaths.swift
|
||||||
../shared/ClawdisKit/Sources/ClawdisKit/SystemCommands.swift
|
../shared/ClawdisKit/Sources/ClawdisKit/SystemCommands.swift
|
||||||
|
../shared/ClawdisKit/Sources/ClawdisKit/TalkDirective.swift
|
||||||
../../Swabble/Sources/SwabbleKit/WakeWordGate.swift
|
../../Swabble/Sources/SwabbleKit/WakeWordGate.swift
|
||||||
|
Sources/Voice/TalkModeManager.swift
|
||||||
|
Sources/Voice/TalkOrbOverlay.swift
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
import ClawdisKit
|
||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
import Testing
|
||||||
|
import UIKit
|
||||||
|
@testable import Clawdis
|
||||||
|
|
||||||
|
private struct KeychainEntry: Hashable {
|
||||||
|
let service: String
|
||||||
|
let account: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private let bridgeService = "com.steipete.clawdis.bridge"
|
||||||
|
private let nodeService = "com.steipete.clawdis.node"
|
||||||
|
private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId")
|
||||||
|
private let preferredBridgeEntry = KeychainEntry(service: bridgeService, account: "preferredStableID")
|
||||||
|
private let lastBridgeEntry = KeychainEntry(service: bridgeService, account: "lastDiscoveredStableID")
|
||||||
|
|
||||||
|
private actor MockBridgePairingClient: BridgePairingClient {
|
||||||
|
private(set) var lastToken: String?
|
||||||
|
private let resultToken: String
|
||||||
|
|
||||||
|
init(resultToken: String) {
|
||||||
|
self.resultToken = resultToken
|
||||||
|
}
|
||||||
|
|
||||||
|
func pairAndHello(
|
||||||
|
endpoint: NWEndpoint,
|
||||||
|
hello: BridgeHello,
|
||||||
|
onStatus: (@Sendable (String) -> Void)?) async throws -> String
|
||||||
|
{
|
||||||
|
self.lastToken = hello.token
|
||||||
|
onStatus?("Testing…")
|
||||||
|
return self.resultToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws -> T) rethrows -> T {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
var snapshot: [String: Any?] = [:]
|
||||||
|
for key in updates.keys {
|
||||||
|
snapshot[key] = defaults.object(forKey: key)
|
||||||
|
}
|
||||||
|
for (key, value) in updates {
|
||||||
|
if let value {
|
||||||
|
defaults.set(value, forKey: key)
|
||||||
|
} else {
|
||||||
|
defaults.removeObject(forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer {
|
||||||
|
for (key, value) in snapshot {
|
||||||
|
if let value {
|
||||||
|
defaults.set(value, forKey: key)
|
||||||
|
} else {
|
||||||
|
defaults.removeObject(forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return try body()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func withUserDefaults<T>(
|
||||||
|
_ updates: [String: Any?],
|
||||||
|
_ body: () async throws -> T) async rethrows -> T
|
||||||
|
{
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
var snapshot: [String: Any?] = [:]
|
||||||
|
for key in updates.keys {
|
||||||
|
snapshot[key] = defaults.object(forKey: key)
|
||||||
|
}
|
||||||
|
for (key, value) in updates {
|
||||||
|
if let value {
|
||||||
|
defaults.set(value, forKey: key)
|
||||||
|
} else {
|
||||||
|
defaults.removeObject(forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer {
|
||||||
|
for (key, value) in snapshot {
|
||||||
|
if let value {
|
||||||
|
defaults.set(value, forKey: key)
|
||||||
|
} else {
|
||||||
|
defaults.removeObject(forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return try await body()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func withKeychainValues<T>(_ updates: [KeychainEntry: String?], _ body: () throws -> T) rethrows -> T {
|
||||||
|
var snapshot: [KeychainEntry: String?] = [:]
|
||||||
|
for entry in updates.keys {
|
||||||
|
snapshot[entry] = KeychainStore.loadString(service: entry.service, account: entry.account)
|
||||||
|
}
|
||||||
|
for (entry, value) in updates {
|
||||||
|
if let value {
|
||||||
|
_ = KeychainStore.saveString(value, service: entry.service, account: entry.account)
|
||||||
|
} else {
|
||||||
|
_ = KeychainStore.delete(service: entry.service, account: entry.account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer {
|
||||||
|
for (entry, value) in snapshot {
|
||||||
|
if let value {
|
||||||
|
_ = KeychainStore.saveString(value, service: entry.service, account: entry.account)
|
||||||
|
} else {
|
||||||
|
_ = KeychainStore.delete(service: entry.service, account: entry.account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return try body()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func withKeychainValues<T>(
|
||||||
|
_ updates: [KeychainEntry: String?],
|
||||||
|
_ body: () async throws -> T) async rethrows -> T
|
||||||
|
{
|
||||||
|
var snapshot: [KeychainEntry: String?] = [:]
|
||||||
|
for entry in updates.keys {
|
||||||
|
snapshot[entry] = KeychainStore.loadString(service: entry.service, account: entry.account)
|
||||||
|
}
|
||||||
|
for (entry, value) in updates {
|
||||||
|
if let value {
|
||||||
|
_ = KeychainStore.saveString(value, service: entry.service, account: entry.account)
|
||||||
|
} else {
|
||||||
|
_ = KeychainStore.delete(service: entry.service, account: entry.account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer {
|
||||||
|
for (entry, value) in snapshot {
|
||||||
|
if let value {
|
||||||
|
_ = KeychainStore.saveString(value, service: entry.service, account: entry.account)
|
||||||
|
} else {
|
||||||
|
_ = KeychainStore.delete(service: entry.service, account: entry.account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return try await body()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite(.serialized) struct BridgeConnectionControllerTests {
|
||||||
|
@Test @MainActor func resolvedDisplayNameSetsDefaultWhenMissing() {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
let displayKey = "node.displayName"
|
||||||
|
|
||||||
|
withKeychainValues([instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil]) {
|
||||||
|
withUserDefaults([displayKey: nil, "node.instanceId": "ios-test"]) {
|
||||||
|
let appModel = NodeAppModel()
|
||||||
|
let controller = BridgeConnectionController(appModel: appModel, startDiscovery: false)
|
||||||
|
|
||||||
|
let resolved = controller._test_resolvedDisplayName(defaults: defaults)
|
||||||
|
#expect(!resolved.isEmpty)
|
||||||
|
#expect(defaults.string(forKey: displayKey) == resolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func resolvedDisplayNamePreservesCustomValue() {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
let displayKey = "node.displayName"
|
||||||
|
|
||||||
|
withKeychainValues([instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil]) {
|
||||||
|
withUserDefaults([displayKey: "My iOS Node", "node.instanceId": "ios-test"]) {
|
||||||
|
let appModel = NodeAppModel()
|
||||||
|
let controller = BridgeConnectionController(appModel: appModel, startDiscovery: false)
|
||||||
|
|
||||||
|
let resolved = controller._test_resolvedDisplayName(defaults: defaults)
|
||||||
|
#expect(resolved == "My iOS Node")
|
||||||
|
#expect(defaults.string(forKey: displayKey) == "My iOS Node")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func makeHelloBuildsCapsAndCommands() {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
let voiceWakeKey = VoiceWakePreferences.enabledKey
|
||||||
|
|
||||||
|
withKeychainValues([instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil]) {
|
||||||
|
withUserDefaults([
|
||||||
|
"node.instanceId": "ios-test",
|
||||||
|
"node.displayName": "Test Node",
|
||||||
|
"camera.enabled": false,
|
||||||
|
voiceWakeKey: true,
|
||||||
|
]) {
|
||||||
|
let appModel = NodeAppModel()
|
||||||
|
let controller = BridgeConnectionController(appModel: appModel, startDiscovery: false)
|
||||||
|
let hello = controller._test_makeHello(token: "token-123")
|
||||||
|
|
||||||
|
#expect(hello.nodeId == "ios-test")
|
||||||
|
#expect(hello.displayName == "Test Node")
|
||||||
|
#expect(hello.token == "token-123")
|
||||||
|
|
||||||
|
let caps = Set(hello.caps ?? [])
|
||||||
|
#expect(caps.contains(ClawdisCapability.canvas.rawValue))
|
||||||
|
#expect(caps.contains(ClawdisCapability.screen.rawValue))
|
||||||
|
#expect(caps.contains(ClawdisCapability.voiceWake.rawValue))
|
||||||
|
#expect(!caps.contains(ClawdisCapability.camera.rawValue))
|
||||||
|
|
||||||
|
let commands = Set(hello.commands ?? [])
|
||||||
|
#expect(commands.contains(ClawdisCanvasCommand.present.rawValue))
|
||||||
|
#expect(commands.contains(ClawdisScreenCommand.record.rawValue))
|
||||||
|
#expect(!commands.contains(ClawdisCameraCommand.snap.rawValue))
|
||||||
|
|
||||||
|
#expect(!(hello.platform ?? "").isEmpty)
|
||||||
|
#expect(!(hello.deviceFamily ?? "").isEmpty)
|
||||||
|
#expect(!(hello.modelIdentifier ?? "").isEmpty)
|
||||||
|
#expect(!(hello.version ?? "").isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func makeHelloIncludesCameraCommandsWhenEnabled() {
|
||||||
|
withKeychainValues([instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil]) {
|
||||||
|
withUserDefaults([
|
||||||
|
"node.instanceId": "ios-test",
|
||||||
|
"node.displayName": "Test Node",
|
||||||
|
"camera.enabled": true,
|
||||||
|
VoiceWakePreferences.enabledKey: false,
|
||||||
|
]) {
|
||||||
|
let appModel = NodeAppModel()
|
||||||
|
let controller = BridgeConnectionController(appModel: appModel, startDiscovery: false)
|
||||||
|
let hello = controller._test_makeHello(token: "token-456")
|
||||||
|
|
||||||
|
let caps = Set(hello.caps ?? [])
|
||||||
|
#expect(caps.contains(ClawdisCapability.camera.rawValue))
|
||||||
|
|
||||||
|
let commands = Set(hello.commands ?? [])
|
||||||
|
#expect(commands.contains(ClawdisCameraCommand.snap.rawValue))
|
||||||
|
#expect(commands.contains(ClawdisCameraCommand.clip.rawValue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func autoConnectRefreshesTokenOnUnauthorized() async {
|
||||||
|
let bridge = BridgeDiscoveryModel.DiscoveredBridge(
|
||||||
|
name: "Gateway",
|
||||||
|
endpoint: .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: 18790),
|
||||||
|
stableID: "bridge-1",
|
||||||
|
debugID: "bridge-debug",
|
||||||
|
lanHost: "Mac.local",
|
||||||
|
tailnetDns: nil,
|
||||||
|
gatewayPort: 18789,
|
||||||
|
bridgePort: 18790,
|
||||||
|
canvasPort: 18793,
|
||||||
|
cliPath: nil)
|
||||||
|
let mock = MockBridgePairingClient(resultToken: "new-token")
|
||||||
|
let account = "bridge-token.ios-test"
|
||||||
|
|
||||||
|
await withKeychainValues([
|
||||||
|
instanceIdEntry: nil,
|
||||||
|
preferredBridgeEntry: nil,
|
||||||
|
lastBridgeEntry: nil,
|
||||||
|
KeychainEntry(service: bridgeService, account: account): "old-token",
|
||||||
|
]) {
|
||||||
|
await withUserDefaults([
|
||||||
|
"node.instanceId": "ios-test",
|
||||||
|
"bridge.lastDiscoveredStableID": "bridge-1",
|
||||||
|
"bridge.manual.enabled": false,
|
||||||
|
]) {
|
||||||
|
let appModel = NodeAppModel()
|
||||||
|
let controller = BridgeConnectionController(
|
||||||
|
appModel: appModel,
|
||||||
|
startDiscovery: false,
|
||||||
|
bridgeClientFactory: { mock })
|
||||||
|
controller._test_setBridges([bridge])
|
||||||
|
controller._test_triggerAutoConnect()
|
||||||
|
|
||||||
|
for _ in 0..<20 {
|
||||||
|
if appModel.connectedBridgeID == bridge.stableID { break }
|
||||||
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(appModel.connectedBridgeID == bridge.stableID)
|
||||||
|
let stored = KeychainStore.loadString(service: bridgeService, account: account)
|
||||||
|
#expect(stored == "new-token")
|
||||||
|
let lastToken = await mock.lastToken
|
||||||
|
#expect(lastToken == "old-token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func autoConnectPrefersPreferredBridgeOverLastDiscovered() async {
|
||||||
|
let bridgeA = BridgeDiscoveryModel.DiscoveredBridge(
|
||||||
|
name: "Gateway A",
|
||||||
|
endpoint: .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: 18790),
|
||||||
|
stableID: "bridge-1",
|
||||||
|
debugID: "bridge-a",
|
||||||
|
lanHost: "MacA.local",
|
||||||
|
tailnetDns: nil,
|
||||||
|
gatewayPort: 18789,
|
||||||
|
bridgePort: 18790,
|
||||||
|
canvasPort: 18793,
|
||||||
|
cliPath: nil)
|
||||||
|
let bridgeB = BridgeDiscoveryModel.DiscoveredBridge(
|
||||||
|
name: "Gateway B",
|
||||||
|
endpoint: .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: 28790),
|
||||||
|
stableID: "bridge-2",
|
||||||
|
debugID: "bridge-b",
|
||||||
|
lanHost: "MacB.local",
|
||||||
|
tailnetDns: nil,
|
||||||
|
gatewayPort: 28789,
|
||||||
|
bridgePort: 28790,
|
||||||
|
canvasPort: 28793,
|
||||||
|
cliPath: nil)
|
||||||
|
|
||||||
|
let mock = MockBridgePairingClient(resultToken: "token-ok")
|
||||||
|
let account = "bridge-token.ios-test"
|
||||||
|
|
||||||
|
await withKeychainValues([
|
||||||
|
instanceIdEntry: nil,
|
||||||
|
preferredBridgeEntry: nil,
|
||||||
|
lastBridgeEntry: nil,
|
||||||
|
KeychainEntry(service: bridgeService, account: account): "old-token",
|
||||||
|
]) {
|
||||||
|
await withUserDefaults([
|
||||||
|
"node.instanceId": "ios-test",
|
||||||
|
"bridge.preferredStableID": "bridge-2",
|
||||||
|
"bridge.lastDiscoveredStableID": "bridge-1",
|
||||||
|
"bridge.manual.enabled": false,
|
||||||
|
]) {
|
||||||
|
let appModel = NodeAppModel()
|
||||||
|
let controller = BridgeConnectionController(
|
||||||
|
appModel: appModel,
|
||||||
|
startDiscovery: false,
|
||||||
|
bridgeClientFactory: { mock })
|
||||||
|
controller._test_setBridges([bridgeA, bridgeB])
|
||||||
|
controller._test_triggerAutoConnect()
|
||||||
|
|
||||||
|
for _ in 0..<20 {
|
||||||
|
if appModel.connectedBridgeID == bridgeB.stableID { break }
|
||||||
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(appModel.connectedBridgeID == bridgeB.stableID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import Clawdis
|
||||||
|
|
||||||
|
@Suite(.serialized) struct BridgeDiscoveryModelTests {
|
||||||
|
@Test @MainActor func debugLoggingCapturesLifecycleAndResets() {
|
||||||
|
let model = BridgeDiscoveryModel()
|
||||||
|
|
||||||
|
#expect(model.debugLog.isEmpty)
|
||||||
|
#expect(model.statusText == "Idle")
|
||||||
|
|
||||||
|
model.setDebugLoggingEnabled(true)
|
||||||
|
#expect(model.debugLog.count >= 2)
|
||||||
|
|
||||||
|
model.stop()
|
||||||
|
#expect(model.statusText == "Stopped")
|
||||||
|
#expect(model.bridges.isEmpty)
|
||||||
|
#expect(model.debugLog.count >= 3)
|
||||||
|
|
||||||
|
model.setDebugLoggingEnabled(false)
|
||||||
|
#expect(model.debugLog.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Clawdis
|
||||||
|
|
||||||
|
private struct KeychainEntry: Hashable {
|
||||||
|
let service: String
|
||||||
|
let account: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private let bridgeService = "com.steipete.clawdis.bridge"
|
||||||
|
private let nodeService = "com.steipete.clawdis.node"
|
||||||
|
private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId")
|
||||||
|
private let preferredBridgeEntry = KeychainEntry(service: bridgeService, account: "preferredStableID")
|
||||||
|
private let lastBridgeEntry = KeychainEntry(service: bridgeService, account: "lastDiscoveredStableID")
|
||||||
|
|
||||||
|
private func snapshotDefaults(_ keys: [String]) -> [String: Any?] {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
var snapshot: [String: Any?] = [:]
|
||||||
|
for key in keys {
|
||||||
|
snapshot[key] = defaults.object(forKey: key)
|
||||||
|
}
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyDefaults(_ values: [String: Any?]) {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
for (key, value) in values {
|
||||||
|
if let value {
|
||||||
|
defaults.set(value, forKey: key)
|
||||||
|
} else {
|
||||||
|
defaults.removeObject(forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func restoreDefaults(_ snapshot: [String: Any?]) {
|
||||||
|
applyDefaults(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func snapshotKeychain(_ entries: [KeychainEntry]) -> [KeychainEntry: String?] {
|
||||||
|
var snapshot: [KeychainEntry: String?] = [:]
|
||||||
|
for entry in entries {
|
||||||
|
snapshot[entry] = KeychainStore.loadString(service: entry.service, account: entry.account)
|
||||||
|
}
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyKeychain(_ values: [KeychainEntry: String?]) {
|
||||||
|
for (entry, value) in values {
|
||||||
|
if let value {
|
||||||
|
_ = KeychainStore.saveString(value, service: entry.service, account: entry.account)
|
||||||
|
} else {
|
||||||
|
_ = KeychainStore.delete(service: entry.service, account: entry.account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func restoreKeychain(_ snapshot: [KeychainEntry: String?]) {
|
||||||
|
applyKeychain(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite(.serialized) struct BridgeSettingsStoreTests {
|
||||||
|
@Test func bootstrapCopiesDefaultsToKeychainWhenMissing() {
|
||||||
|
let defaultsKeys = [
|
||||||
|
"node.instanceId",
|
||||||
|
"bridge.preferredStableID",
|
||||||
|
"bridge.lastDiscoveredStableID",
|
||||||
|
]
|
||||||
|
let entries = [instanceIdEntry, preferredBridgeEntry, lastBridgeEntry]
|
||||||
|
let defaultsSnapshot = snapshotDefaults(defaultsKeys)
|
||||||
|
let keychainSnapshot = snapshotKeychain(entries)
|
||||||
|
defer {
|
||||||
|
restoreDefaults(defaultsSnapshot)
|
||||||
|
restoreKeychain(keychainSnapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
applyDefaults([
|
||||||
|
"node.instanceId": "node-test",
|
||||||
|
"bridge.preferredStableID": "preferred-test",
|
||||||
|
"bridge.lastDiscoveredStableID": "last-test",
|
||||||
|
])
|
||||||
|
applyKeychain([
|
||||||
|
instanceIdEntry: nil,
|
||||||
|
preferredBridgeEntry: nil,
|
||||||
|
lastBridgeEntry: nil,
|
||||||
|
])
|
||||||
|
|
||||||
|
BridgeSettingsStore.bootstrapPersistence()
|
||||||
|
|
||||||
|
#expect(KeychainStore.loadString(service: nodeService, account: "instanceId") == "node-test")
|
||||||
|
#expect(KeychainStore.loadString(service: bridgeService, account: "preferredStableID") == "preferred-test")
|
||||||
|
#expect(KeychainStore.loadString(service: bridgeService, account: "lastDiscoveredStableID") == "last-test")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func bootstrapCopiesKeychainToDefaultsWhenMissing() {
|
||||||
|
let defaultsKeys = [
|
||||||
|
"node.instanceId",
|
||||||
|
"bridge.preferredStableID",
|
||||||
|
"bridge.lastDiscoveredStableID",
|
||||||
|
]
|
||||||
|
let entries = [instanceIdEntry, preferredBridgeEntry, lastBridgeEntry]
|
||||||
|
let defaultsSnapshot = snapshotDefaults(defaultsKeys)
|
||||||
|
let keychainSnapshot = snapshotKeychain(entries)
|
||||||
|
defer {
|
||||||
|
restoreDefaults(defaultsSnapshot)
|
||||||
|
restoreKeychain(keychainSnapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
applyDefaults([
|
||||||
|
"node.instanceId": nil,
|
||||||
|
"bridge.preferredStableID": nil,
|
||||||
|
"bridge.lastDiscoveredStableID": nil,
|
||||||
|
])
|
||||||
|
applyKeychain([
|
||||||
|
instanceIdEntry: "node-from-keychain",
|
||||||
|
preferredBridgeEntry: "preferred-from-keychain",
|
||||||
|
lastBridgeEntry: "last-from-keychain",
|
||||||
|
])
|
||||||
|
|
||||||
|
BridgeSettingsStore.bootstrapPersistence()
|
||||||
|
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
#expect(defaults.string(forKey: "node.instanceId") == "node-from-keychain")
|
||||||
|
#expect(defaults.string(forKey: "bridge.preferredStableID") == "preferred-from-keychain")
|
||||||
|
#expect(defaults.string(forKey: "bridge.lastDiscoveredStableID") == "last-from-keychain")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import Clawdis
|
||||||
|
|
||||||
|
@Suite struct CameraControllerErrorTests {
|
||||||
|
@Test func errorDescriptionsAreStable() {
|
||||||
|
#expect(CameraController.CameraError.cameraUnavailable.errorDescription == "Camera unavailable")
|
||||||
|
#expect(CameraController.CameraError.microphoneUnavailable.errorDescription == "Microphone unavailable")
|
||||||
|
#expect(CameraController.CameraError.permissionDenied(kind: "Camera").errorDescription == "Camera permission denied")
|
||||||
|
#expect(CameraController.CameraError.invalidParams("bad").errorDescription == "bad")
|
||||||
|
#expect(CameraController.CameraError.captureFailed("nope").errorDescription == "nope")
|
||||||
|
#expect(CameraController.CameraError.exportFailed("export").errorDescription == "export")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import ClawdisKit
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
import UIKit
|
||||||
|
@testable import Clawdis
|
||||||
|
|
||||||
|
private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws -> T) rethrows -> T {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
var snapshot: [String: Any?] = [:]
|
||||||
|
for key in updates.keys {
|
||||||
|
snapshot[key] = defaults.object(forKey: key)
|
||||||
|
}
|
||||||
|
for (key, value) in updates {
|
||||||
|
if let value {
|
||||||
|
defaults.set(value, forKey: key)
|
||||||
|
} else {
|
||||||
|
defaults.removeObject(forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer {
|
||||||
|
for (key, value) in snapshot {
|
||||||
|
if let value {
|
||||||
|
defaults.set(value, forKey: key)
|
||||||
|
} else {
|
||||||
|
defaults.removeObject(forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return try body()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite(.serialized) struct NodeAppModelInvokeTests {
|
||||||
|
@Test @MainActor func decodeParamsFailsWithoutJSON() {
|
||||||
|
#expect(throws: Error.self) {
|
||||||
|
_ = try NodeAppModel._test_decodeParams(ClawdisCanvasNavigateParams.self, from: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func encodePayloadEmitsJSON() throws {
|
||||||
|
struct Payload: Codable, Equatable {
|
||||||
|
var value: String
|
||||||
|
}
|
||||||
|
let json = try NodeAppModel._test_encodePayload(Payload(value: "ok"))
|
||||||
|
#expect(json.contains("\"value\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func handleInvokeRejectsBackgroundCommands() async {
|
||||||
|
let appModel = NodeAppModel()
|
||||||
|
appModel.setScenePhase(.background)
|
||||||
|
|
||||||
|
let req = BridgeInvokeRequest(id: "bg", command: ClawdisCanvasCommand.present.rawValue)
|
||||||
|
let res = await appModel._test_handleInvoke(req)
|
||||||
|
#expect(res.ok == false)
|
||||||
|
#expect(res.error?.code == .backgroundUnavailable)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func handleInvokeRejectsCameraWhenDisabled() async {
|
||||||
|
let appModel = NodeAppModel()
|
||||||
|
let req = BridgeInvokeRequest(id: "cam", command: ClawdisCameraCommand.snap.rawValue)
|
||||||
|
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
let key = "camera.enabled"
|
||||||
|
let previous = defaults.object(forKey: key)
|
||||||
|
defaults.set(false, forKey: key)
|
||||||
|
defer {
|
||||||
|
if let previous {
|
||||||
|
defaults.set(previous, forKey: key)
|
||||||
|
} else {
|
||||||
|
defaults.removeObject(forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let res = await appModel._test_handleInvoke(req)
|
||||||
|
#expect(res.ok == false)
|
||||||
|
#expect(res.error?.code == .unavailable)
|
||||||
|
#expect(res.error?.message.contains("CAMERA_DISABLED") == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func handleInvokeRejectsInvalidScreenFormat() async {
|
||||||
|
let appModel = NodeAppModel()
|
||||||
|
let params = ClawdisScreenRecordParams(format: "gif")
|
||||||
|
let data = try? JSONEncoder().encode(params)
|
||||||
|
let json = data.flatMap { String(data: $0, encoding: .utf8) }
|
||||||
|
|
||||||
|
let req = BridgeInvokeRequest(
|
||||||
|
id: "screen",
|
||||||
|
command: ClawdisScreenCommand.record.rawValue,
|
||||||
|
paramsJSON: json)
|
||||||
|
|
||||||
|
let res = await appModel._test_handleInvoke(req)
|
||||||
|
#expect(res.ok == false)
|
||||||
|
#expect(res.error?.message.contains("screen format must be mp4") == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func handleInvokeCanvasCommandsUpdateScreen() async throws {
|
||||||
|
let appModel = NodeAppModel()
|
||||||
|
appModel.screen.navigate(to: "http://example.com")
|
||||||
|
|
||||||
|
let present = BridgeInvokeRequest(id: "present", command: ClawdisCanvasCommand.present.rawValue)
|
||||||
|
let presentRes = await appModel._test_handleInvoke(present)
|
||||||
|
#expect(presentRes.ok == true)
|
||||||
|
#expect(appModel.screen.urlString.isEmpty)
|
||||||
|
|
||||||
|
let navigateParams = ClawdisCanvasNavigateParams(url: "http://localhost:18789/")
|
||||||
|
let navData = try JSONEncoder().encode(navigateParams)
|
||||||
|
let navJSON = String(decoding: navData, as: UTF8.self)
|
||||||
|
let navigate = BridgeInvokeRequest(
|
||||||
|
id: "nav",
|
||||||
|
command: ClawdisCanvasCommand.navigate.rawValue,
|
||||||
|
paramsJSON: navJSON)
|
||||||
|
let navRes = await appModel._test_handleInvoke(navigate)
|
||||||
|
#expect(navRes.ok == true)
|
||||||
|
#expect(appModel.screen.urlString == "http://localhost:18789/")
|
||||||
|
|
||||||
|
let evalParams = ClawdisCanvasEvalParams(javaScript: "1+1")
|
||||||
|
let evalData = try JSONEncoder().encode(evalParams)
|
||||||
|
let evalJSON = String(decoding: evalData, as: UTF8.self)
|
||||||
|
let eval = BridgeInvokeRequest(
|
||||||
|
id: "eval",
|
||||||
|
command: ClawdisCanvasCommand.evalJS.rawValue,
|
||||||
|
paramsJSON: evalJSON)
|
||||||
|
let evalRes = await appModel._test_handleInvoke(eval)
|
||||||
|
#expect(evalRes.ok == true)
|
||||||
|
let payloadData = try #require(evalRes.payloadJSON?.data(using: .utf8))
|
||||||
|
let payload = try JSONSerialization.jsonObject(with: payloadData) as? [String: Any]
|
||||||
|
#expect(payload?["result"] as? String == "2")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func handleInvokeA2UICommandsFailWhenHostMissing() async throws {
|
||||||
|
let appModel = NodeAppModel()
|
||||||
|
|
||||||
|
let reset = BridgeInvokeRequest(id: "reset", command: ClawdisCanvasA2UICommand.reset.rawValue)
|
||||||
|
let resetRes = await appModel._test_handleInvoke(reset)
|
||||||
|
#expect(resetRes.ok == false)
|
||||||
|
#expect(resetRes.error?.message.contains("A2UI_HOST_NOT_CONFIGURED") == true)
|
||||||
|
|
||||||
|
let jsonl = "{\"beginRendering\":{}}"
|
||||||
|
let pushParams = ClawdisCanvasA2UIPushJSONLParams(jsonl: jsonl)
|
||||||
|
let pushData = try JSONEncoder().encode(pushParams)
|
||||||
|
let pushJSON = String(decoding: pushData, as: UTF8.self)
|
||||||
|
let push = BridgeInvokeRequest(
|
||||||
|
id: "push",
|
||||||
|
command: ClawdisCanvasA2UICommand.pushJSONL.rawValue,
|
||||||
|
paramsJSON: pushJSON)
|
||||||
|
let pushRes = await appModel._test_handleInvoke(push)
|
||||||
|
#expect(pushRes.ok == false)
|
||||||
|
#expect(pushRes.error?.message.contains("A2UI_HOST_NOT_CONFIGURED") == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func handleInvokeUnknownCommandReturnsInvalidRequest() async {
|
||||||
|
let appModel = NodeAppModel()
|
||||||
|
let req = BridgeInvokeRequest(id: "unknown", command: "nope")
|
||||||
|
let res = await appModel._test_handleInvoke(req)
|
||||||
|
#expect(res.ok == false)
|
||||||
|
#expect(res.error?.code == .invalidRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func handleDeepLinkSetsErrorWhenNotConnected() async {
|
||||||
|
let appModel = NodeAppModel()
|
||||||
|
let url = URL(string: "clawdis://agent?message=hello")!
|
||||||
|
await appModel.handleDeepLink(url: url)
|
||||||
|
#expect(appModel.screen.errorText?.contains("Bridge not connected") == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func handleDeepLinkRejectsOversizedMessage() async {
|
||||||
|
let appModel = NodeAppModel()
|
||||||
|
let msg = String(repeating: "a", count: 20001)
|
||||||
|
let url = URL(string: "clawdis://agent?message=\(msg)")!
|
||||||
|
await appModel.handleDeepLink(url: url)
|
||||||
|
#expect(appModel.screen.errorText?.contains("Deep link too large") == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func sendVoiceTranscriptThrowsWhenBridgeOffline() async {
|
||||||
|
let appModel = NodeAppModel()
|
||||||
|
await #expect(throws: Error.self) {
|
||||||
|
try await appModel.sendVoiceTranscript(text: "hello", sessionKey: "main")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func canvasA2UIActionDispatchesStatus() async {
|
||||||
|
let appModel = NodeAppModel()
|
||||||
|
let body: [String: Any] = [
|
||||||
|
"userAction": [
|
||||||
|
"name": "tap",
|
||||||
|
"id": "action-1",
|
||||||
|
"surfaceId": "main",
|
||||||
|
"sourceComponentId": "button-1",
|
||||||
|
"context": ["value": "ok"],
|
||||||
|
],
|
||||||
|
]
|
||||||
|
await appModel._test_handleCanvasA2UIAction(body: body)
|
||||||
|
#expect(appModel.screen.urlString.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,15 @@ import WebKit
|
|||||||
#expect(scrollView.bounces == false)
|
#expect(scrollView.bounces == false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func navigateEnablesScrollForWebPages() {
|
||||||
|
let screen = ScreenController()
|
||||||
|
screen.navigate(to: "https://example.com")
|
||||||
|
|
||||||
|
let scrollView = screen.webView.scrollView
|
||||||
|
#expect(scrollView.isScrollEnabled == true)
|
||||||
|
#expect(scrollView.bounces == true)
|
||||||
|
}
|
||||||
|
|
||||||
@Test @MainActor func navigateSlashShowsDefaultCanvas() {
|
@Test @MainActor func navigateSlashShowsDefaultCanvas() {
|
||||||
let screen = ScreenController()
|
let screen = ScreenController()
|
||||||
screen.navigate(to: "/")
|
screen.navigate(to: "/")
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import Clawdis
|
||||||
|
|
||||||
|
@Suite(.serialized) struct ScreenRecordServiceTests {
|
||||||
|
@Test func clampDefaultsAndBounds() {
|
||||||
|
#expect(ScreenRecordService._test_clampDurationMs(nil) == 10000)
|
||||||
|
#expect(ScreenRecordService._test_clampDurationMs(0) == 250)
|
||||||
|
#expect(ScreenRecordService._test_clampDurationMs(60001) == 60000)
|
||||||
|
|
||||||
|
#expect(ScreenRecordService._test_clampFps(nil) == 10)
|
||||||
|
#expect(ScreenRecordService._test_clampFps(0) == 1)
|
||||||
|
#expect(ScreenRecordService._test_clampFps(120) == 30)
|
||||||
|
#expect(ScreenRecordService._test_clampFps(.infinity) == 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func recordRejectsInvalidScreenIndex() async {
|
||||||
|
let recorder = ScreenRecordService()
|
||||||
|
do {
|
||||||
|
_ = try await recorder.record(
|
||||||
|
screenIndex: 1,
|
||||||
|
durationMs: 250,
|
||||||
|
fps: 5,
|
||||||
|
includeAudio: false,
|
||||||
|
outPath: nil)
|
||||||
|
Issue.record("Expected invalid screen index to throw")
|
||||||
|
} catch let error as ScreenRecordService.ScreenRecordError {
|
||||||
|
#expect(error.localizedDescription.contains("Invalid screen index") == true)
|
||||||
|
} catch {
|
||||||
|
Issue.record("Unexpected error type: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
import SwabbleKit
|
||||||
|
@testable import Clawdis
|
||||||
|
|
||||||
|
@Suite(.serialized) struct VoiceWakeManagerStateTests {
|
||||||
|
@Test @MainActor func suspendAndResumeCycleUpdatesState() async {
|
||||||
|
let manager = VoiceWakeManager()
|
||||||
|
manager.isEnabled = true
|
||||||
|
manager.isListening = true
|
||||||
|
manager.statusText = "Listening"
|
||||||
|
|
||||||
|
let suspended = manager.suspendForExternalAudioCapture()
|
||||||
|
#expect(suspended == true)
|
||||||
|
#expect(manager.isListening == false)
|
||||||
|
#expect(manager.statusText == "Paused")
|
||||||
|
|
||||||
|
manager.resumeAfterExternalAudioCapture(wasSuspended: true)
|
||||||
|
try? await Task.sleep(nanoseconds: 900_000_000)
|
||||||
|
#expect(manager.statusText.contains("Voice Wake") == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func handleRecognitionCallbackRestartsOnError() async {
|
||||||
|
let manager = VoiceWakeManager()
|
||||||
|
manager.isEnabled = true
|
||||||
|
manager.isListening = true
|
||||||
|
|
||||||
|
manager._test_handleRecognitionCallback(transcript: nil, segments: [], errorText: "boom")
|
||||||
|
#expect(manager.statusText.contains("Recognizer error") == true)
|
||||||
|
#expect(manager.isListening == false)
|
||||||
|
|
||||||
|
try? await Task.sleep(nanoseconds: 900_000_000)
|
||||||
|
#expect(manager.statusText.contains("Voice Wake") == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func handleRecognitionCallbackDispatchesCommand() async {
|
||||||
|
let manager = VoiceWakeManager()
|
||||||
|
manager.triggerWords = ["clawd"]
|
||||||
|
manager.isEnabled = true
|
||||||
|
|
||||||
|
actor CaptureBox {
|
||||||
|
var value: String?
|
||||||
|
func set(_ next: String) { self.value = next }
|
||||||
|
}
|
||||||
|
let capture = CaptureBox()
|
||||||
|
manager.configure { cmd in
|
||||||
|
await capture.set(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
let transcript = "clawd hello"
|
||||||
|
let clawdRange = transcript.range(of: "clawd")!
|
||||||
|
let helloRange = transcript.range(of: "hello")!
|
||||||
|
let segments = [
|
||||||
|
WakeWordSegment(text: "clawd", start: 0.0, duration: 0.2, range: clawdRange),
|
||||||
|
WakeWordSegment(text: "hello", start: 0.8, duration: 0.2, range: helloRange),
|
||||||
|
]
|
||||||
|
|
||||||
|
manager._test_handleRecognitionCallback(transcript: transcript, segments: segments, errorText: nil)
|
||||||
|
#expect(manager.lastTriggeredCommand == "hello")
|
||||||
|
#expect(manager.statusText == "Triggered")
|
||||||
|
|
||||||
|
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||||
|
#expect(await capture.value == "hello")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
require "shellwords"
|
||||||
|
|
||||||
default_platform(:ios)
|
default_platform(:ios)
|
||||||
|
|
||||||
def load_env_file(path)
|
def load_env_file(path)
|
||||||
@@ -61,6 +63,12 @@ platform :ios do
|
|||||||
api_key = asc_api_key
|
api_key = asc_api_key
|
||||||
|
|
||||||
team_id = ENV["IOS_DEVELOPMENT_TEAM"]
|
team_id = ENV["IOS_DEVELOPMENT_TEAM"]
|
||||||
|
if team_id.nil? || team_id.strip.empty?
|
||||||
|
helper_path = File.expand_path("../../scripts/ios-team-id.sh", __dir__)
|
||||||
|
if File.exist?(helper_path)
|
||||||
|
team_id = sh("bash #{helper_path.shellescape}").strip
|
||||||
|
end
|
||||||
|
end
|
||||||
UI.user_error!("Missing IOS_DEVELOPMENT_TEAM (Apple Team ID). Add it to fastlane/.env or export it in your shell.") if team_id.nil? || team_id.strip.empty?
|
UI.user_error!("Missing IOS_DEVELOPMENT_TEAM (Apple Team ID). Add it to fastlane/.env or export it in your shell.") if team_id.nil? || team_id.strip.empty?
|
||||||
|
|
||||||
build_app(
|
build_app(
|
||||||
|
|||||||
@@ -22,10 +22,11 @@ ASC_KEY_PATH=/absolute/path/to/AuthKey_XXXXXXXXXX.p8
|
|||||||
IOS_DEVELOPMENT_TEAM=YOUR_TEAM_ID
|
IOS_DEVELOPMENT_TEAM=YOUR_TEAM_ID
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Tip: run `scripts/ios-team-id.sh` from the repo root to print a Team ID to paste into `.env`. Fastlane falls back to this helper if `IOS_DEVELOPMENT_TEAM` is missing.
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd apps/ios
|
cd apps/ios
|
||||||
fastlane beta
|
fastlane beta
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -62,7 +62,11 @@ targets:
|
|||||||
swiftlint lint --config "$SRCROOT/.swiftlint.yml" --use-script-input-file-lists
|
swiftlint lint --config "$SRCROOT/.swiftlint.yml" --use-script-input-file-lists
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
|
CODE_SIGN_IDENTITY: "Apple Development"
|
||||||
|
CODE_SIGN_STYLE: Manual
|
||||||
|
DEVELOPMENT_TEAM: Y5PE65HELJ
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: com.steipete.clawdis.ios
|
PRODUCT_BUNDLE_IDENTIFIER: com.steipete.clawdis.ios
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER: "com.steipete.clawdis.ios Development"
|
||||||
SWIFT_VERSION: "6.0"
|
SWIFT_VERSION: "6.0"
|
||||||
info:
|
info:
|
||||||
path: Sources/Info.plist
|
path: Sources/Info.plist
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ let package = Package(
|
|||||||
dependencies: [
|
dependencies: [
|
||||||
.package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"),
|
.package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"),
|
||||||
.package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.1.0"),
|
.package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.1.0"),
|
||||||
|
.package(url: "https://github.com/apple/swift-log.git", from: "1.8.0"),
|
||||||
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.8.1"),
|
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.8.1"),
|
||||||
.package(path: "../shared/ClawdisKit"),
|
.package(path: "../shared/ClawdisKit"),
|
||||||
.package(path: "../../Swabble"),
|
.package(path: "../../Swabble"),
|
||||||
@@ -45,10 +46,14 @@ let package = Package(
|
|||||||
.product(name: "SwabbleKit", package: "swabble"),
|
.product(name: "SwabbleKit", package: "swabble"),
|
||||||
.product(name: "MenuBarExtraAccess", package: "MenuBarExtraAccess"),
|
.product(name: "MenuBarExtraAccess", package: "MenuBarExtraAccess"),
|
||||||
.product(name: "Subprocess", package: "swift-subprocess"),
|
.product(name: "Subprocess", package: "swift-subprocess"),
|
||||||
|
.product(name: "Logging", package: "swift-log"),
|
||||||
.product(name: "Sparkle", package: "Sparkle"),
|
.product(name: "Sparkle", package: "Sparkle"),
|
||||||
.product(name: "PeekabooBridge", package: "PeekabooCore"),
|
.product(name: "PeekabooBridge", package: "PeekabooCore"),
|
||||||
.product(name: "PeekabooAutomationKit", package: "PeekabooAutomationKit"),
|
.product(name: "PeekabooAutomationKit", package: "PeekabooAutomationKit"),
|
||||||
],
|
],
|
||||||
|
exclude: [
|
||||||
|
"Resources/Info.plist",
|
||||||
|
],
|
||||||
resources: [
|
resources: [
|
||||||
.copy("Resources/Clawdis.icns"),
|
.copy("Resources/Clawdis.icns"),
|
||||||
.copy("Resources/DeviceModels"),
|
.copy("Resources/DeviceModels"),
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// Human-friendly age string (e.g., "2m ago").
|
||||||
|
func age(from date: Date, now: Date = .init()) -> String {
|
||||||
|
let seconds = max(0, Int(now.timeIntervalSince(date)))
|
||||||
|
let minutes = seconds / 60
|
||||||
|
let hours = minutes / 60
|
||||||
|
let days = hours / 24
|
||||||
|
|
||||||
|
if seconds < 60 { return "just now" }
|
||||||
|
if minutes == 1 { return "1 minute ago" }
|
||||||
|
if minutes < 60 { return "\(minutes)m ago" }
|
||||||
|
if hours == 1 { return "1 hour ago" }
|
||||||
|
if hours < 24 { return "\(hours)h ago" }
|
||||||
|
if days == 1 { return "yesterday" }
|
||||||
|
return "\(days)d ago"
|
||||||
|
}
|
||||||
@@ -15,7 +15,14 @@ struct AnthropicAuthControls: View {
|
|||||||
@State private var autoConnectClipboard = true
|
@State private var autoConnectClipboard = true
|
||||||
@State private var lastPasteboardChangeCount = NSPasteboard.general.changeCount
|
@State private var lastPasteboardChangeCount = NSPasteboard.general.changeCount
|
||||||
|
|
||||||
private static let clipboardPoll = Timer.publish(every: 0.4, on: .main, in: .common).autoconnect()
|
private static let clipboardPoll: AnyPublisher<Date, Never> = {
|
||||||
|
if ProcessInfo.processInfo.isRunningTests {
|
||||||
|
return Empty(completeImmediately: false).eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
return Timer.publish(every: 0.4, on: .main, in: .common)
|
||||||
|
.autoconnect()
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
@@ -200,3 +207,28 @@ struct AnthropicAuthControls: View {
|
|||||||
Task { await self.finishOAuth() }
|
Task { await self.finishOAuth() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
extension AnthropicAuthControls {
|
||||||
|
init(
|
||||||
|
connectionMode: AppState.ConnectionMode,
|
||||||
|
oauthStatus: ClawdisOAuthStore.AnthropicOAuthStatus,
|
||||||
|
pkce: AnthropicOAuth.PKCE? = nil,
|
||||||
|
code: String = "",
|
||||||
|
busy: Bool = false,
|
||||||
|
statusText: String? = nil,
|
||||||
|
autoDetectClipboard: Bool = true,
|
||||||
|
autoConnectClipboard: Bool = true)
|
||||||
|
{
|
||||||
|
self.connectionMode = connectionMode
|
||||||
|
self._oauthStatus = State(initialValue: oauthStatus)
|
||||||
|
self._pkce = State(initialValue: pkce)
|
||||||
|
self._code = State(initialValue: code)
|
||||||
|
self._busy = State(initialValue: busy)
|
||||||
|
self._statusText = State(initialValue: statusText)
|
||||||
|
self._autoDetectClipboard = State(initialValue: autoDetectClipboard)
|
||||||
|
self._autoConnectClipboard = State(initialValue: autoConnectClipboard)
|
||||||
|
self._lastPasteboardChangeCount = State(initialValue: NSPasteboard.general.changeCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -36,8 +36,8 @@ enum AnthropicAuthMode: Equatable {
|
|||||||
enum AnthropicAuthResolver {
|
enum AnthropicAuthResolver {
|
||||||
static func resolve(
|
static func resolve(
|
||||||
environment: [String: String] = ProcessInfo.processInfo.environment,
|
environment: [String: String] = ProcessInfo.processInfo.environment,
|
||||||
oauthStatus: ClawdisOAuthStore.AnthropicOAuthStatus = ClawdisOAuthStore.anthropicOAuthStatus()
|
oauthStatus: ClawdisOAuthStore.AnthropicOAuthStatus = ClawdisOAuthStore
|
||||||
) -> AnthropicAuthMode
|
.anthropicOAuthStatus()) -> AnthropicAuthMode
|
||||||
{
|
{
|
||||||
if oauthStatus.isConnected { return .oauthFile }
|
if oauthStatus.isConnected { return .oauthFile }
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,18 @@ final class AppState {
|
|||||||
forKey: voicePushToTalkEnabledKey) } }
|
forKey: voicePushToTalkEnabledKey) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var talkEnabled: Bool {
|
||||||
|
didSet {
|
||||||
|
self.ifNotPreview {
|
||||||
|
UserDefaults.standard.set(self.talkEnabled, forKey: talkEnabledKey)
|
||||||
|
Task { await TalkModeController.shared.setEnabled(self.talkEnabled) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gateway-provided UI accent color (hex). Optional; clients provide a default.
|
||||||
|
var seamColorHex: String?
|
||||||
|
|
||||||
var iconOverride: IconOverrideSelection {
|
var iconOverride: IconOverrideSelection {
|
||||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.iconOverride.rawValue, forKey: iconOverrideKey) } }
|
didSet { self.ifNotPreview { UserDefaults.standard.set(self.iconOverride.rawValue, forKey: iconOverrideKey) } }
|
||||||
}
|
}
|
||||||
@@ -216,6 +228,8 @@ final class AppState {
|
|||||||
.stringArray(forKey: voiceWakeAdditionalLocalesKey) ?? []
|
.stringArray(forKey: voiceWakeAdditionalLocalesKey) ?? []
|
||||||
self.voicePushToTalkEnabled = UserDefaults.standard
|
self.voicePushToTalkEnabled = UserDefaults.standard
|
||||||
.object(forKey: voicePushToTalkEnabledKey) as? Bool ?? false
|
.object(forKey: voicePushToTalkEnabledKey) as? Bool ?? false
|
||||||
|
self.talkEnabled = UserDefaults.standard.bool(forKey: talkEnabledKey)
|
||||||
|
self.seamColorHex = nil
|
||||||
if let storedHeartbeats = UserDefaults.standard.object(forKey: heartbeatsEnabledKey) as? Bool {
|
if let storedHeartbeats = UserDefaults.standard.object(forKey: heartbeatsEnabledKey) as? Bool {
|
||||||
self.heartbeatsEnabled = storedHeartbeats
|
self.heartbeatsEnabled = storedHeartbeats
|
||||||
} else {
|
} else {
|
||||||
@@ -256,9 +270,13 @@ final class AppState {
|
|||||||
if self.swabbleEnabled, !PermissionManager.voiceWakePermissionsGranted() {
|
if self.swabbleEnabled, !PermissionManager.voiceWakePermissionsGranted() {
|
||||||
self.swabbleEnabled = false
|
self.swabbleEnabled = false
|
||||||
}
|
}
|
||||||
|
if self.talkEnabled, !PermissionManager.voiceWakePermissionsGranted() {
|
||||||
|
self.talkEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
if !self.isPreview {
|
if !self.isPreview {
|
||||||
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
|
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
|
||||||
|
Task { await TalkModeController.shared.setEnabled(self.talkEnabled) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,6 +330,31 @@ final class AppState {
|
|||||||
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
|
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setTalkEnabled(_ enabled: Bool) async {
|
||||||
|
guard voiceWakeSupported else {
|
||||||
|
self.talkEnabled = false
|
||||||
|
await GatewayConnection.shared.talkMode(enabled: false, phase: "disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.talkEnabled = enabled
|
||||||
|
guard !self.isPreview else { return }
|
||||||
|
|
||||||
|
if !enabled {
|
||||||
|
await GatewayConnection.shared.talkMode(enabled: false, phase: "disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if PermissionManager.voiceWakePermissionsGranted() {
|
||||||
|
await GatewayConnection.shared.talkMode(enabled: true, phase: "enabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let granted = await PermissionManager.ensureVoiceWakePermissions(interactive: true)
|
||||||
|
self.talkEnabled = granted
|
||||||
|
await GatewayConnection.shared.talkMode(enabled: granted, phase: granted ? "enabled" : "denied")
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Global wake words sync (Gateway-owned)
|
// MARK: - Global wake words sync (Gateway-owned)
|
||||||
|
|
||||||
func applyGlobalVoiceWakeTriggers(_ triggers: [String]) {
|
func applyGlobalVoiceWakeTriggers(_ triggers: [String]) {
|
||||||
@@ -367,6 +410,7 @@ extension AppState {
|
|||||||
state.voiceWakeLocaleID = Locale.current.identifier
|
state.voiceWakeLocaleID = Locale.current.identifier
|
||||||
state.voiceWakeAdditionalLocaleIDs = ["en-US", "de-DE"]
|
state.voiceWakeAdditionalLocaleIDs = ["en-US", "de-DE"]
|
||||||
state.voicePushToTalkEnabled = false
|
state.voicePushToTalkEnabled = false
|
||||||
|
state.talkEnabled = false
|
||||||
state.iconOverride = .system
|
state.iconOverride = .system
|
||||||
state.heartbeatsEnabled = true
|
state.heartbeatsEnabled = true
|
||||||
state.connectionMode = .local
|
state.connectionMode = .local
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum AsyncTimeout {
|
||||||
|
static func withTimeout<T: Sendable>(
|
||||||
|
seconds: Double,
|
||||||
|
onTimeout: @escaping @Sendable () -> Error,
|
||||||
|
operation: @escaping @Sendable () async throws -> T) async throws -> T
|
||||||
|
{
|
||||||
|
let clamped = max(0, seconds)
|
||||||
|
return try await withThrowingTaskGroup(of: T.self) { group in
|
||||||
|
group.addTask { try await operation() }
|
||||||
|
group.addTask {
|
||||||
|
try await Task.sleep(nanoseconds: UInt64(clamped * 1_000_000_000))
|
||||||
|
throw onTimeout()
|
||||||
|
}
|
||||||
|
let result = try await group.next()
|
||||||
|
group.cancelAll()
|
||||||
|
if let result { return result }
|
||||||
|
throw onTimeout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -503,3 +503,40 @@ enum BridgePairingApprover {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
extension BridgeServer {
|
||||||
|
func exerciseForTesting() async {
|
||||||
|
let conn = NWConnection(to: .hostPort(host: "127.0.0.1", port: 22), using: .tcp)
|
||||||
|
let handler = BridgeConnectionHandler(connection: conn, logger: self.logger)
|
||||||
|
self.connections["node-1"] = handler
|
||||||
|
self.nodeInfoById["node-1"] = BridgeNodeInfo(
|
||||||
|
nodeId: "node-1",
|
||||||
|
displayName: "Node One",
|
||||||
|
platform: "macOS",
|
||||||
|
version: "1.0.0",
|
||||||
|
deviceFamily: "Mac",
|
||||||
|
modelIdentifier: "MacBookPro18,1",
|
||||||
|
remoteAddress: "127.0.0.1",
|
||||||
|
caps: ["chat", "voice"])
|
||||||
|
|
||||||
|
_ = self.connectedNodeIds()
|
||||||
|
_ = self.connectedNodes()
|
||||||
|
|
||||||
|
self.handleListenerState(.ready)
|
||||||
|
self.handleListenerState(.failed(NWError.posix(.ECONNREFUSED)))
|
||||||
|
self.handleListenerState(.waiting(NWError.posix(.ETIMEDOUT)))
|
||||||
|
self.handleListenerState(.cancelled)
|
||||||
|
self.handleListenerState(.setup)
|
||||||
|
|
||||||
|
let subscribe = BridgeEventFrame(event: "chat.subscribe", payloadJSON: "{\"sessionKey\":\"main\"}")
|
||||||
|
await self.handleEvent(nodeId: "node-1", evt: subscribe)
|
||||||
|
|
||||||
|
let unsubscribe = BridgeEventFrame(event: "chat.unsubscribe", payloadJSON: "{\"sessionKey\":\"main\"}")
|
||||||
|
await self.handleEvent(nodeId: "node-1", evt: unsubscribe)
|
||||||
|
|
||||||
|
let invalid = BridgeRPCRequest(id: "req-1", method: "invalid.method", paramsJSON: nil)
|
||||||
|
_ = await self.handleRequest(nodeId: "node-1", req: invalid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
enum CLIInstaller {
|
||||||
|
private static func embeddedHelperURL() -> URL {
|
||||||
|
Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/Relay/clawdis")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func installedLocation() -> String? {
|
||||||
|
self.installedLocation(
|
||||||
|
searchPaths: cliHelperSearchPaths,
|
||||||
|
embeddedHelper: self.embeddedHelperURL(),
|
||||||
|
fileManager: .default)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func installedLocation(
|
||||||
|
searchPaths: [String],
|
||||||
|
embeddedHelper: URL,
|
||||||
|
fileManager: FileManager) -> String?
|
||||||
|
{
|
||||||
|
let embedded = embeddedHelper.resolvingSymlinksInPath()
|
||||||
|
|
||||||
|
for basePath in searchPaths {
|
||||||
|
let candidate = URL(fileURLWithPath: basePath).appendingPathComponent("clawdis").path
|
||||||
|
var isDirectory: ObjCBool = false
|
||||||
|
|
||||||
|
guard fileManager.fileExists(atPath: candidate, isDirectory: &isDirectory),
|
||||||
|
!isDirectory.boolValue
|
||||||
|
else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
guard fileManager.isExecutableFile(atPath: candidate) else { continue }
|
||||||
|
|
||||||
|
let resolved = URL(fileURLWithPath: candidate).resolvingSymlinksInPath()
|
||||||
|
if resolved == embedded {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isInstalled() -> Bool {
|
||||||
|
self.installedLocation() != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func install(statusHandler: @escaping @Sendable (String) async -> Void) async {
|
||||||
|
let helper = self.embeddedHelperURL()
|
||||||
|
guard FileManager.default.isExecutableFile(atPath: helper.path) else {
|
||||||
|
await statusHandler(
|
||||||
|
"Embedded CLI missing in bundle; repackage via scripts/package-mac-app.sh " +
|
||||||
|
"(or restart-mac.sh without SKIP_GATEWAY_PACKAGE=1).")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let targets = cliHelperSearchPaths.map { "\($0)/clawdis" }
|
||||||
|
let result = await self.privilegedSymlink(source: helper.path, targets: targets)
|
||||||
|
await statusHandler(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func privilegedSymlink(source: String, targets: [String]) async -> String {
|
||||||
|
let escapedSource = self.shellEscape(source)
|
||||||
|
let targetList = targets.map(self.shellEscape).joined(separator: " ")
|
||||||
|
let cmds = [
|
||||||
|
"mkdir -p /usr/local/bin /opt/homebrew/bin",
|
||||||
|
targets.map { "ln -sf \(escapedSource) \($0)" }.joined(separator: "; "),
|
||||||
|
].joined(separator: "; ")
|
||||||
|
|
||||||
|
let script = """
|
||||||
|
do shell script "\(cmds)" with administrator privileges
|
||||||
|
"""
|
||||||
|
|
||||||
|
let proc = Process()
|
||||||
|
proc.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
|
||||||
|
proc.arguments = ["-e", script]
|
||||||
|
|
||||||
|
let pipe = Pipe()
|
||||||
|
proc.standardOutput = pipe
|
||||||
|
proc.standardError = pipe
|
||||||
|
|
||||||
|
do {
|
||||||
|
try proc.run()
|
||||||
|
proc.waitUntilExit()
|
||||||
|
let data = pipe.fileHandleForReading.readToEndSafely()
|
||||||
|
let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if proc.terminationStatus == 0 {
|
||||||
|
return output.isEmpty ? "CLI helper linked into \(targetList)" : output
|
||||||
|
}
|
||||||
|
if output.lowercased().contains("user canceled") {
|
||||||
|
return "Install canceled"
|
||||||
|
}
|
||||||
|
return "Failed to install CLI helper: \(output)"
|
||||||
|
} catch {
|
||||||
|
return "Failed to run installer: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func shellEscape(_ path: String) -> String {
|
||||||
|
"'" + path.replacingOccurrences(of: "'", with: "'\"'\"'") + "'"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,7 +79,14 @@ actor CameraCaptureService {
|
|||||||
}
|
}
|
||||||
withExtendedLifetime(delegate) {}
|
withExtendedLifetime(delegate) {}
|
||||||
|
|
||||||
let res = try JPEGTranscoder.transcodeToJPEG(imageData: rawData, maxWidthPx: maxWidth, quality: quality)
|
let maxPayloadBytes = 5 * 1024 * 1024
|
||||||
|
// Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under 5MB (API limit).
|
||||||
|
let maxEncodedBytes = (maxPayloadBytes / 4) * 3
|
||||||
|
let res = try JPEGTranscoder.transcodeToJPEG(
|
||||||
|
imageData: rawData,
|
||||||
|
maxWidthPx: maxWidth,
|
||||||
|
quality: quality,
|
||||||
|
maxBytes: maxEncodedBytes)
|
||||||
return (data: res.data, size: CGSize(width: res.widthPx, height: res.heightPx))
|
return (data: res.data, size: CGSize(width: res.widthPx, height: res.heightPx))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import AppKit
|
||||||
|
import ClawdisIPC
|
||||||
|
import ClawdisKit
|
||||||
|
import Foundation
|
||||||
|
import WebKit
|
||||||
|
|
||||||
|
final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
|
||||||
|
static let messageName = "clawdisCanvasA2UIAction"
|
||||||
|
|
||||||
|
private let sessionKey: String
|
||||||
|
|
||||||
|
init(sessionKey: String) {
|
||||||
|
self.sessionKey = sessionKey
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||||
|
guard message.name == Self.messageName else { return }
|
||||||
|
|
||||||
|
// Only accept actions from local Canvas content (not arbitrary web pages).
|
||||||
|
guard let webView = message.webView, let url = webView.url else { return }
|
||||||
|
if url.scheme == CanvasScheme.scheme {
|
||||||
|
// ok
|
||||||
|
} else if Self.isLocalNetworkCanvasURL(url) {
|
||||||
|
// ok
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: [String: Any] = {
|
||||||
|
if let dict = message.body as? [String: Any] { return dict }
|
||||||
|
if let dict = message.body as? [AnyHashable: Any] {
|
||||||
|
return dict.reduce(into: [String: Any]()) { acc, pair in
|
||||||
|
guard let key = pair.key as? String else { return }
|
||||||
|
acc[key] = pair.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [:]
|
||||||
|
}()
|
||||||
|
guard !body.isEmpty else { return }
|
||||||
|
|
||||||
|
let userActionAny = body["userAction"] ?? body
|
||||||
|
let userAction: [String: Any] = {
|
||||||
|
if let dict = userActionAny as? [String: Any] { return dict }
|
||||||
|
if let dict = userActionAny as? [AnyHashable: Any] {
|
||||||
|
return dict.reduce(into: [String: Any]()) { acc, pair in
|
||||||
|
guard let key = pair.key as? String else { return }
|
||||||
|
acc[key] = pair.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [:]
|
||||||
|
}()
|
||||||
|
guard !userAction.isEmpty else { return }
|
||||||
|
|
||||||
|
guard let name = ClawdisCanvasA2UIAction.extractActionName(userAction) else { return }
|
||||||
|
let actionId =
|
||||||
|
(userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||||
|
?? UUID().uuidString
|
||||||
|
|
||||||
|
canvasWindowLogger.info("A2UI action \(name, privacy: .public) session=\(self.sessionKey, privacy: .public)")
|
||||||
|
|
||||||
|
let surfaceId = (userAction["surfaceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.nonEmpty ?? "main"
|
||||||
|
let sourceComponentId = (userAction["sourceComponentId"] as? String)?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "-"
|
||||||
|
let instanceId = InstanceIdentity.instanceId.lowercased()
|
||||||
|
let contextJSON = ClawdisCanvasA2UIAction.compactJSON(userAction["context"])
|
||||||
|
|
||||||
|
// Token-efficient and unambiguous. The agent should treat this as a UI event and (by default) update Canvas.
|
||||||
|
let messageContext = ClawdisCanvasA2UIAction.AgentMessageContext(
|
||||||
|
actionName: name,
|
||||||
|
session: .init(key: self.sessionKey, surfaceId: surfaceId),
|
||||||
|
component: .init(id: sourceComponentId, host: InstanceIdentity.displayName, instanceId: instanceId),
|
||||||
|
contextJSON: contextJSON)
|
||||||
|
let text = ClawdisCanvasA2UIAction.formatAgentMessage(messageContext)
|
||||||
|
|
||||||
|
Task { [weak webView] in
|
||||||
|
if AppStateStore.shared.connectionMode == .local {
|
||||||
|
GatewayProcessManager.shared.setActive(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
|
||||||
|
message: text,
|
||||||
|
sessionKey: self.sessionKey,
|
||||||
|
thinking: "low",
|
||||||
|
deliver: false,
|
||||||
|
to: nil,
|
||||||
|
channel: .last,
|
||||||
|
idempotencyKey: actionId))
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
guard let webView else { return }
|
||||||
|
let js = ClawdisCanvasA2UIAction.jsDispatchA2UIActionStatus(
|
||||||
|
actionId: actionId,
|
||||||
|
ok: result.ok,
|
||||||
|
error: result.error)
|
||||||
|
webView.evaluateJavaScript(js) { _, _ in }
|
||||||
|
}
|
||||||
|
if !result.ok {
|
||||||
|
canvasWindowLogger.error(
|
||||||
|
"""
|
||||||
|
A2UI action send failed name=\(name, privacy: .public) \
|
||||||
|
error=\(result.error ?? "unknown", privacy: .public)
|
||||||
|
""")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isLocalNetworkCanvasURL(_ url: URL) -> Bool {
|
||||||
|
guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if host == "localhost" { return true }
|
||||||
|
if host.hasSuffix(".local") { return true }
|
||||||
|
if host.hasSuffix(".ts.net") { return true }
|
||||||
|
if host.hasSuffix(".tailscale.net") { return true }
|
||||||
|
if !host.contains("."), !host.contains(":") { return true }
|
||||||
|
if let ipv4 = Self.parseIPv4(host) {
|
||||||
|
return Self.isLocalNetworkIPv4(ipv4)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
static func parseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? {
|
||||||
|
let parts = host.split(separator: ".", omittingEmptySubsequences: false)
|
||||||
|
guard parts.count == 4 else { return nil }
|
||||||
|
let bytes: [UInt8] = parts.compactMap { UInt8($0) }
|
||||||
|
guard bytes.count == 4 else { return nil }
|
||||||
|
return (bytes[0], bytes[1], bytes[2], bytes[3])
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool {
|
||||||
|
let (a, b, _, _) = ip
|
||||||
|
if a == 10 { return true }
|
||||||
|
if a == 172, (16...31).contains(Int(b)) { return true }
|
||||||
|
if a == 192, b == 168 { return true }
|
||||||
|
if a == 127 { return true }
|
||||||
|
if a == 169, b == 254 { return true }
|
||||||
|
if a == 100, (64...127).contains(Int(b)) { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formatting helpers live in ClawdisKit (`ClawdisCanvasA2UIAction`).
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import AppKit
|
||||||
|
import QuartzCore
|
||||||
|
|
||||||
|
final class HoverChromeContainerView: NSView {
|
||||||
|
private let content: NSView
|
||||||
|
private let chrome: CanvasChromeOverlayView
|
||||||
|
private var tracking: NSTrackingArea?
|
||||||
|
var onClose: (() -> Void)?
|
||||||
|
|
||||||
|
init(containing content: NSView) {
|
||||||
|
self.content = content
|
||||||
|
self.chrome = CanvasChromeOverlayView(frame: .zero)
|
||||||
|
super.init(frame: .zero)
|
||||||
|
|
||||||
|
self.wantsLayer = true
|
||||||
|
self.layer?.cornerRadius = 12
|
||||||
|
self.layer?.masksToBounds = true
|
||||||
|
self.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor
|
||||||
|
|
||||||
|
self.content.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
self.addSubview(self.content)
|
||||||
|
|
||||||
|
self.chrome.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
self.chrome.alphaValue = 0
|
||||||
|
self.chrome.onClose = { [weak self] in self?.onClose?() }
|
||||||
|
self.addSubview(self.chrome)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
self.content.leadingAnchor.constraint(equalTo: self.leadingAnchor),
|
||||||
|
self.content.trailingAnchor.constraint(equalTo: self.trailingAnchor),
|
||||||
|
self.content.topAnchor.constraint(equalTo: self.topAnchor),
|
||||||
|
self.content.bottomAnchor.constraint(equalTo: self.bottomAnchor),
|
||||||
|
|
||||||
|
self.chrome.leadingAnchor.constraint(equalTo: self.leadingAnchor),
|
||||||
|
self.chrome.trailingAnchor.constraint(equalTo: self.trailingAnchor),
|
||||||
|
self.chrome.topAnchor.constraint(equalTo: self.topAnchor),
|
||||||
|
self.chrome.bottomAnchor.constraint(equalTo: self.bottomAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
override func updateTrackingAreas() {
|
||||||
|
super.updateTrackingAreas()
|
||||||
|
if let tracking {
|
||||||
|
self.removeTrackingArea(tracking)
|
||||||
|
}
|
||||||
|
let area = NSTrackingArea(
|
||||||
|
rect: self.bounds,
|
||||||
|
options: [.activeAlways, .mouseEnteredAndExited, .inVisibleRect],
|
||||||
|
owner: self,
|
||||||
|
userInfo: nil)
|
||||||
|
self.addTrackingArea(area)
|
||||||
|
self.tracking = area
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class CanvasDragHandleView: NSView {
|
||||||
|
override func mouseDown(with event: NSEvent) {
|
||||||
|
self.window?.performDrag(with: event)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func acceptsFirstMouse(for _: NSEvent?) -> Bool { true }
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class CanvasResizeHandleView: NSView {
|
||||||
|
private var startPoint: NSPoint = .zero
|
||||||
|
private var startFrame: NSRect = .zero
|
||||||
|
|
||||||
|
override func acceptsFirstMouse(for _: NSEvent?) -> Bool { true }
|
||||||
|
|
||||||
|
override func mouseDown(with event: NSEvent) {
|
||||||
|
guard let window else { return }
|
||||||
|
_ = window.makeFirstResponder(self)
|
||||||
|
self.startPoint = NSEvent.mouseLocation
|
||||||
|
self.startFrame = window.frame
|
||||||
|
super.mouseDown(with: event)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func mouseDragged(with _: NSEvent) {
|
||||||
|
guard let window else { return }
|
||||||
|
let current = NSEvent.mouseLocation
|
||||||
|
let dx = current.x - self.startPoint.x
|
||||||
|
let dy = current.y - self.startPoint.y
|
||||||
|
|
||||||
|
var frame = self.startFrame
|
||||||
|
frame.size.width = max(CanvasLayout.minPanelSize.width, frame.size.width + dx)
|
||||||
|
frame.origin.y += dy
|
||||||
|
frame.size.height = max(CanvasLayout.minPanelSize.height, frame.size.height - dy)
|
||||||
|
|
||||||
|
if let screen = window.screen {
|
||||||
|
frame = CanvasWindowController.constrainFrame(frame, toVisibleFrame: screen.visibleFrame)
|
||||||
|
}
|
||||||
|
window.setFrame(frame, display: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class CanvasChromeOverlayView: NSView {
|
||||||
|
var onClose: (() -> Void)?
|
||||||
|
|
||||||
|
private let dragHandle = CanvasDragHandleView(frame: .zero)
|
||||||
|
private let resizeHandle = CanvasResizeHandleView(frame: .zero)
|
||||||
|
|
||||||
|
private final class PassthroughVisualEffectView: NSVisualEffectView {
|
||||||
|
override func hitTest(_: NSPoint) -> NSView? { nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
private let closeBackground: NSVisualEffectView = {
|
||||||
|
let v = PassthroughVisualEffectView(frame: .zero)
|
||||||
|
v.material = .hudWindow
|
||||||
|
v.blendingMode = .withinWindow
|
||||||
|
v.state = .active
|
||||||
|
v.appearance = NSAppearance(named: .vibrantDark)
|
||||||
|
v.wantsLayer = true
|
||||||
|
v.layer?.cornerRadius = 10
|
||||||
|
v.layer?.masksToBounds = true
|
||||||
|
v.layer?.borderWidth = 1
|
||||||
|
v.layer?.borderColor = NSColor.white.withAlphaComponent(0.22).cgColor
|
||||||
|
v.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.28).cgColor
|
||||||
|
v.layer?.shadowColor = NSColor.black.withAlphaComponent(0.35).cgColor
|
||||||
|
v.layer?.shadowOpacity = 0.35
|
||||||
|
v.layer?.shadowRadius = 8
|
||||||
|
v.layer?.shadowOffset = .zero
|
||||||
|
return v
|
||||||
|
}()
|
||||||
|
|
||||||
|
private let closeButton: NSButton = {
|
||||||
|
let cfg = NSImage.SymbolConfiguration(pointSize: 8, weight: .semibold)
|
||||||
|
let img = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close")?
|
||||||
|
.withSymbolConfiguration(cfg)
|
||||||
|
?? NSImage(size: NSSize(width: 18, height: 18))
|
||||||
|
let btn = NSButton(image: img, target: nil, action: nil)
|
||||||
|
btn.isBordered = false
|
||||||
|
btn.bezelStyle = .regularSquare
|
||||||
|
btn.imageScaling = .scaleProportionallyDown
|
||||||
|
btn.contentTintColor = NSColor.white.withAlphaComponent(0.92)
|
||||||
|
btn.toolTip = "Close"
|
||||||
|
return btn
|
||||||
|
}()
|
||||||
|
|
||||||
|
override init(frame frameRect: NSRect) {
|
||||||
|
super.init(frame: frameRect)
|
||||||
|
|
||||||
|
self.wantsLayer = true
|
||||||
|
self.layer?.cornerRadius = 12
|
||||||
|
self.layer?.masksToBounds = true
|
||||||
|
self.layer?.borderWidth = 1
|
||||||
|
self.layer?.borderColor = NSColor.black.withAlphaComponent(0.18).cgColor
|
||||||
|
self.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.02).cgColor
|
||||||
|
|
||||||
|
self.dragHandle.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
self.dragHandle.wantsLayer = true
|
||||||
|
self.dragHandle.layer?.backgroundColor = NSColor.clear.cgColor
|
||||||
|
self.addSubview(self.dragHandle)
|
||||||
|
|
||||||
|
self.resizeHandle.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
self.resizeHandle.wantsLayer = true
|
||||||
|
self.resizeHandle.layer?.backgroundColor = NSColor.clear.cgColor
|
||||||
|
self.addSubview(self.resizeHandle)
|
||||||
|
|
||||||
|
self.closeBackground.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
self.addSubview(self.closeBackground)
|
||||||
|
|
||||||
|
self.closeButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
self.closeButton.target = self
|
||||||
|
self.closeButton.action = #selector(self.handleClose)
|
||||||
|
self.addSubview(self.closeButton)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
self.dragHandle.leadingAnchor.constraint(equalTo: self.leadingAnchor),
|
||||||
|
self.dragHandle.trailingAnchor.constraint(equalTo: self.trailingAnchor),
|
||||||
|
self.dragHandle.topAnchor.constraint(equalTo: self.topAnchor),
|
||||||
|
self.dragHandle.heightAnchor.constraint(equalToConstant: 30),
|
||||||
|
|
||||||
|
self.closeBackground.centerXAnchor.constraint(equalTo: self.closeButton.centerXAnchor),
|
||||||
|
self.closeBackground.centerYAnchor.constraint(equalTo: self.closeButton.centerYAnchor),
|
||||||
|
self.closeBackground.widthAnchor.constraint(equalToConstant: 20),
|
||||||
|
self.closeBackground.heightAnchor.constraint(equalToConstant: 20),
|
||||||
|
|
||||||
|
self.closeButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -8),
|
||||||
|
self.closeButton.topAnchor.constraint(equalTo: self.topAnchor, constant: 8),
|
||||||
|
self.closeButton.widthAnchor.constraint(equalToConstant: 16),
|
||||||
|
self.closeButton.heightAnchor.constraint(equalToConstant: 16),
|
||||||
|
|
||||||
|
self.resizeHandle.trailingAnchor.constraint(equalTo: self.trailingAnchor),
|
||||||
|
self.resizeHandle.bottomAnchor.constraint(equalTo: self.bottomAnchor),
|
||||||
|
self.resizeHandle.widthAnchor.constraint(equalToConstant: 18),
|
||||||
|
self.resizeHandle.heightAnchor.constraint(equalToConstant: 18),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
override func hitTest(_ point: NSPoint) -> NSView? {
|
||||||
|
// When the chrome is hidden, do not intercept any mouse events (let the WKWebView receive them).
|
||||||
|
guard self.alphaValue > 0.02 else { return nil }
|
||||||
|
|
||||||
|
if self.closeButton.frame.contains(point) { return self.closeButton }
|
||||||
|
if self.dragHandle.frame.contains(point) { return self.dragHandle }
|
||||||
|
if self.resizeHandle.frame.contains(point) { return self.resizeHandle }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handleClose() {
|
||||||
|
self.onClose?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func mouseEntered(with _: NSEvent) {
|
||||||
|
NSAnimationContext.runAnimationGroup { ctx in
|
||||||
|
ctx.duration = 0.12
|
||||||
|
ctx.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||||
|
self.chrome.animator().alphaValue = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func mouseExited(with _: NSEvent) {
|
||||||
|
NSAnimationContext.runAnimationGroup { ctx in
|
||||||
|
ctx.duration = 0.16
|
||||||
|
ctx.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||||
|
self.chrome.animator().alphaValue = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -190,7 +190,7 @@ final class CanvasManager {
|
|||||||
private static func resolveA2UIHostUrl(from raw: String?) -> String? {
|
private static func resolveA2UIHostUrl(from raw: String?) -> String? {
|
||||||
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil }
|
guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil }
|
||||||
return base.appendingPathComponent("__clawdis__/a2ui/").absoluteString
|
return base.appendingPathComponent("__clawdis__/a2ui/").absoluteString + "?platform=macos"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Anchoring
|
// MARK: - Anchoring
|
||||||
|
|||||||
@@ -240,3 +240,20 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
extension CanvasSchemeHandler {
|
||||||
|
func _testResponse(for url: URL) -> (mime: String, data: Data) {
|
||||||
|
let response = self.response(for: url)
|
||||||
|
return (response.mime, response.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _testResolveFileURL(sessionRoot: URL, requestPath: String) -> URL? {
|
||||||
|
self.resolveFileURL(sessionRoot: sessionRoot, requestPath: requestPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _testTextEncodingName(for mimeType: String) -> String? {
|
||||||
|
self.textEncodingName(forMimeType: mimeType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,43 @@
|
|||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension CanvasWindowController {
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
static func sanitizeSessionKey(_ key: String) -> String {
|
||||||
|
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmed.isEmpty { return "main" }
|
||||||
|
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-+")
|
||||||
|
let scalars = trimmed.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" }
|
||||||
|
return String(scalars)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func jsStringLiteral(_ value: String) -> String {
|
||||||
|
let data = try? JSONEncoder().encode(value)
|
||||||
|
return data.flatMap { String(data: $0, encoding: .utf8) } ?? "\"\""
|
||||||
|
}
|
||||||
|
|
||||||
|
static func jsOptionalStringLiteral(_ value: String?) -> String {
|
||||||
|
guard let value else { return "null" }
|
||||||
|
return Self.jsStringLiteral(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func storedFrameDefaultsKey(sessionKey: String) -> String {
|
||||||
|
"clawdis.canvas.frame.\(self.sanitizeSessionKey(sessionKey))"
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadRestoredFrame(sessionKey: String) -> NSRect? {
|
||||||
|
let key = self.storedFrameDefaultsKey(sessionKey: sessionKey)
|
||||||
|
guard let arr = UserDefaults.standard.array(forKey: key) as? [Double], arr.count == 4 else { return nil }
|
||||||
|
let rect = NSRect(x: arr[0], y: arr[1], width: arr[2], height: arr[3])
|
||||||
|
if rect.width < CanvasLayout.minPanelSize.width || rect.height < CanvasLayout.minPanelSize.height { return nil }
|
||||||
|
return rect
|
||||||
|
}
|
||||||
|
|
||||||
|
static func storeRestoredFrame(_ frame: NSRect, sessionKey: String) {
|
||||||
|
let key = self.storedFrameDefaultsKey(sessionKey: sessionKey)
|
||||||
|
UserDefaults.standard.set(
|
||||||
|
[Double(frame.origin.x), Double(frame.origin.y), Double(frame.size.width), Double(frame.size.height)],
|
||||||
|
forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import AppKit
|
||||||
|
import WebKit
|
||||||
|
|
||||||
|
extension CanvasWindowController {
|
||||||
|
// MARK: - WKNavigationDelegate
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func webView(
|
||||||
|
_: WKWebView,
|
||||||
|
decidePolicyFor navigationAction: WKNavigationAction,
|
||||||
|
decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void)
|
||||||
|
{
|
||||||
|
guard let url = navigationAction.request.url else {
|
||||||
|
decisionHandler(.cancel)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let scheme = url.scheme?.lowercased()
|
||||||
|
|
||||||
|
// Deep links: allow local Canvas content to invoke the agent without bouncing through NSWorkspace.
|
||||||
|
if scheme == "clawdis" {
|
||||||
|
if self.webView.url?.scheme == CanvasScheme.scheme {
|
||||||
|
Task { await DeepLinkHandler.shared.handle(url: url) }
|
||||||
|
} else {
|
||||||
|
canvasWindowLogger
|
||||||
|
.debug("ignoring deep link from non-canvas page \(url.absoluteString, privacy: .public)")
|
||||||
|
}
|
||||||
|
decisionHandler(.cancel)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep web content inside the panel when reasonable.
|
||||||
|
// `about:blank` and friends are common internal navigations for WKWebView; never send them to NSWorkspace.
|
||||||
|
if scheme == CanvasScheme.scheme
|
||||||
|
|| scheme == "https"
|
||||||
|
|| scheme == "http"
|
||||||
|
|| scheme == "about"
|
||||||
|
|| scheme == "blob"
|
||||||
|
|| scheme == "data"
|
||||||
|
|| scheme == "javascript"
|
||||||
|
{
|
||||||
|
decisionHandler(.allow)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only open external URLs when there is a registered handler, otherwise macOS will show a confusing
|
||||||
|
// "There is no application set to open the URL ..." alert (e.g. for about:blank).
|
||||||
|
if let appURL = NSWorkspace.shared.urlForApplication(toOpen: url) {
|
||||||
|
NSWorkspace.shared.open(
|
||||||
|
[url],
|
||||||
|
withApplicationAt: appURL,
|
||||||
|
configuration: NSWorkspace.OpenConfiguration(),
|
||||||
|
completionHandler: nil)
|
||||||
|
} else {
|
||||||
|
canvasWindowLogger.debug("no application to open url \(url.absoluteString, privacy: .public)")
|
||||||
|
}
|
||||||
|
decisionHandler(.cancel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_: WKWebView, didFinish _: WKNavigation?) {
|
||||||
|
self.applyDebugStatusIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#if DEBUG
|
||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension CanvasWindowController {
|
||||||
|
static func _testSanitizeSessionKey(_ key: String) -> String {
|
||||||
|
self.sanitizeSessionKey(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func _testJSStringLiteral(_ value: String) -> String {
|
||||||
|
self.jsStringLiteral(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func _testJSOptionalStringLiteral(_ value: String?) -> String {
|
||||||
|
self.jsOptionalStringLiteral(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func _testStoredFrameKey(sessionKey: String) -> String {
|
||||||
|
self.storedFrameDefaultsKey(sessionKey: sessionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func _testStoreAndLoadFrame(sessionKey: String, frame: NSRect) -> NSRect? {
|
||||||
|
self.storeRestoredFrame(frame, sessionKey: sessionKey)
|
||||||
|
return self.loadRestoredFrame(sessionKey: sessionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func _testParseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? {
|
||||||
|
CanvasA2UIActionMessageHandler.parseIPv4(host)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func _testIsLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool {
|
||||||
|
CanvasA2UIActionMessageHandler.isLocalNetworkIPv4(ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func _testIsLocalNetworkCanvasURL(_ url: URL) -> Bool {
|
||||||
|
CanvasA2UIActionMessageHandler.isLocalNetworkCanvasURL(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import AppKit
|
||||||
|
import ClawdisIPC
|
||||||
|
|
||||||
|
extension CanvasWindowController {
|
||||||
|
// MARK: - Window
|
||||||
|
|
||||||
|
static func makeWindow(for presentation: CanvasPresentation, contentView: NSView) -> NSWindow {
|
||||||
|
switch presentation {
|
||||||
|
case .window:
|
||||||
|
let window = NSWindow(
|
||||||
|
contentRect: NSRect(origin: .zero, size: CanvasLayout.windowSize),
|
||||||
|
styleMask: [.titled, .closable, .resizable, .miniaturizable],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false)
|
||||||
|
window.title = "Clawdis Canvas"
|
||||||
|
window.isReleasedWhenClosed = false
|
||||||
|
window.contentView = contentView
|
||||||
|
window.center()
|
||||||
|
window.minSize = NSSize(width: 880, height: 680)
|
||||||
|
return window
|
||||||
|
|
||||||
|
case .panel:
|
||||||
|
let panel = CanvasPanel(
|
||||||
|
contentRect: NSRect(origin: .zero, size: CanvasLayout.panelSize),
|
||||||
|
styleMask: [.borderless, .resizable],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false)
|
||||||
|
// Keep Canvas below the Voice Wake overlay panel.
|
||||||
|
panel.level = NSWindow.Level(rawValue: NSWindow.Level.statusBar.rawValue - 1)
|
||||||
|
panel.hasShadow = true
|
||||||
|
panel.isMovable = false
|
||||||
|
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||||||
|
panel.titleVisibility = .hidden
|
||||||
|
panel.titlebarAppearsTransparent = true
|
||||||
|
panel.backgroundColor = .clear
|
||||||
|
panel.isOpaque = false
|
||||||
|
panel.contentView = contentView
|
||||||
|
panel.becomesKeyOnlyIfNeeded = true
|
||||||
|
panel.hidesOnDeactivate = false
|
||||||
|
panel.minSize = CanvasLayout.minPanelSize
|
||||||
|
return panel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func presentAnchoredPanel(anchorProvider: @escaping () -> NSRect?) {
|
||||||
|
guard case .panel = self.presentation, let window else { return }
|
||||||
|
self.repositionPanel(using: anchorProvider)
|
||||||
|
window.makeKeyAndOrderFront(nil)
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
window.makeFirstResponder(self.webView)
|
||||||
|
VoiceWakeOverlayController.shared.bringToFrontIfVisible()
|
||||||
|
self.onVisibilityChanged?(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func repositionPanel(using anchorProvider: () -> NSRect?) {
|
||||||
|
guard let panel = self.window else { return }
|
||||||
|
let anchor = anchorProvider()
|
||||||
|
let targetScreen = Self.screen(forAnchor: anchor)
|
||||||
|
?? Self.screenContainingMouseCursor()
|
||||||
|
?? panel.screen
|
||||||
|
?? NSScreen.main
|
||||||
|
?? NSScreen.screens.first
|
||||||
|
|
||||||
|
let restored = Self.loadRestoredFrame(sessionKey: self.sessionKey)
|
||||||
|
let restoredIsValid = if let restored, let targetScreen {
|
||||||
|
Self.isFrameMeaningfullyVisible(restored, on: targetScreen)
|
||||||
|
} else {
|
||||||
|
restored != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var frame = if let restored, restoredIsValid {
|
||||||
|
restored
|
||||||
|
} else {
|
||||||
|
Self.defaultTopRightFrame(panel: panel, screen: targetScreen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply agent placement as partial overrides:
|
||||||
|
// - If agent provides x/y, override origin.
|
||||||
|
// - If agent provides width/height, override size.
|
||||||
|
// - If agent provides only size, keep the remembered origin.
|
||||||
|
if let placement = self.preferredPlacement {
|
||||||
|
if let x = placement.x { frame.origin.x = x }
|
||||||
|
if let y = placement.y { frame.origin.y = y }
|
||||||
|
if let w = placement.width { frame.size.width = max(CanvasLayout.minPanelSize.width, CGFloat(w)) }
|
||||||
|
if let h = placement.height { frame.size.height = max(CanvasLayout.minPanelSize.height, CGFloat(h)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
self.setPanelFrame(frame, on: targetScreen)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func defaultTopRightFrame(panel: NSWindow, screen: NSScreen?) -> NSRect {
|
||||||
|
let w = max(CanvasLayout.minPanelSize.width, panel.frame.width)
|
||||||
|
let h = max(CanvasLayout.minPanelSize.height, panel.frame.height)
|
||||||
|
return WindowPlacement.topRightFrame(
|
||||||
|
size: NSSize(width: w, height: h),
|
||||||
|
padding: CanvasLayout.defaultPadding,
|
||||||
|
on: screen)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setPanelFrame(_ frame: NSRect, on screen: NSScreen?) {
|
||||||
|
guard let panel = self.window else { return }
|
||||||
|
guard let s = screen ?? panel.screen ?? NSScreen.main ?? NSScreen.screens.first else {
|
||||||
|
panel.setFrame(frame, display: false)
|
||||||
|
self.persistFrameIfPanel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let constrained = Self.constrainFrame(frame, toVisibleFrame: s.visibleFrame)
|
||||||
|
panel.setFrame(constrained, display: false)
|
||||||
|
self.persistFrameIfPanel()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func screen(forAnchor anchor: NSRect?) -> NSScreen? {
|
||||||
|
guard let anchor else { return nil }
|
||||||
|
let center = NSPoint(x: anchor.midX, y: anchor.midY)
|
||||||
|
return NSScreen.screens.first { screen in
|
||||||
|
screen.frame.contains(anchor.origin) || screen.frame.contains(center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func screenContainingMouseCursor() -> NSScreen? {
|
||||||
|
let point = NSEvent.mouseLocation
|
||||||
|
return NSScreen.screens.first { $0.frame.contains(point) }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isFrameMeaningfullyVisible(_ frame: NSRect, on screen: NSScreen) -> Bool {
|
||||||
|
frame.intersects(screen.visibleFrame.insetBy(dx: 12, dy: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func constrainFrame(_ frame: NSRect, toVisibleFrame bounds: NSRect) -> NSRect {
|
||||||
|
if bounds == .zero { return frame }
|
||||||
|
|
||||||
|
var next = frame
|
||||||
|
next.size.width = min(max(CanvasLayout.minPanelSize.width, next.size.width), bounds.width)
|
||||||
|
next.size.height = min(max(CanvasLayout.minPanelSize.height, next.size.height), bounds.height)
|
||||||
|
|
||||||
|
let maxX = bounds.maxX - next.size.width
|
||||||
|
let maxY = bounds.maxY - next.size.height
|
||||||
|
|
||||||
|
next.origin.x = maxX >= bounds.minX ? min(max(next.origin.x, bounds.minX), maxX) : bounds.minX
|
||||||
|
next.origin.y = maxY >= bounds.minY ? min(max(next.origin.y, bounds.minY), maxY) : bounds.minY
|
||||||
|
|
||||||
|
next.origin.x = round(next.origin.x)
|
||||||
|
next.origin.y = round(next.origin.y)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - NSWindowDelegate
|
||||||
|
|
||||||
|
func windowWillClose(_: Notification) {
|
||||||
|
self.onVisibilityChanged?(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func windowDidMove(_: Notification) {
|
||||||
|
self.persistFrameIfPanel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func windowDidEndLiveResize(_: Notification) {
|
||||||
|
self.persistFrameIfPanel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func persistFrameIfPanel() {
|
||||||
|
guard case .panel = self.presentation, let window else { return }
|
||||||
|
Self.storeRestoredFrame(window.frame, sessionKey: self.sessionKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
import AppKit
|
||||||
|
import ClawdisIPC
|
||||||
|
import ClawdisKit
|
||||||
|
import Foundation
|
||||||
|
import WebKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NSWindowDelegate {
|
||||||
|
let sessionKey: String
|
||||||
|
private let root: URL
|
||||||
|
private let sessionDir: URL
|
||||||
|
private let schemeHandler: CanvasSchemeHandler
|
||||||
|
let webView: WKWebView
|
||||||
|
private var a2uiActionMessageHandler: CanvasA2UIActionMessageHandler?
|
||||||
|
private let watcher: CanvasFileWatcher
|
||||||
|
private let container: HoverChromeContainerView
|
||||||
|
let presentation: CanvasPresentation
|
||||||
|
var preferredPlacement: CanvasPlacement?
|
||||||
|
private(set) var currentTarget: String?
|
||||||
|
private var debugStatusEnabled = false
|
||||||
|
private var debugStatusTitle: String?
|
||||||
|
private var debugStatusSubtitle: String?
|
||||||
|
|
||||||
|
var onVisibilityChanged: ((Bool) -> Void)?
|
||||||
|
|
||||||
|
init(sessionKey: String, root: URL, presentation: CanvasPresentation) throws {
|
||||||
|
self.sessionKey = sessionKey
|
||||||
|
self.root = root
|
||||||
|
self.presentation = presentation
|
||||||
|
|
||||||
|
canvasWindowLogger.debug("CanvasWindowController init start session=\(sessionKey, privacy: .public)")
|
||||||
|
let safeSessionKey = CanvasWindowController.sanitizeSessionKey(sessionKey)
|
||||||
|
canvasWindowLogger.debug("CanvasWindowController init sanitized session=\(safeSessionKey, privacy: .public)")
|
||||||
|
self.sessionDir = root.appendingPathComponent(safeSessionKey, isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: self.sessionDir, withIntermediateDirectories: true)
|
||||||
|
canvasWindowLogger.debug("CanvasWindowController init session dir ready")
|
||||||
|
|
||||||
|
self.schemeHandler = CanvasSchemeHandler(root: root)
|
||||||
|
canvasWindowLogger.debug("CanvasWindowController init scheme handler ready")
|
||||||
|
|
||||||
|
let config = WKWebViewConfiguration()
|
||||||
|
config.userContentController = WKUserContentController()
|
||||||
|
config.preferences.isElementFullscreenEnabled = true
|
||||||
|
config.preferences.setValue(true, forKey: "developerExtrasEnabled")
|
||||||
|
canvasWindowLogger.debug("CanvasWindowController init config ready")
|
||||||
|
config.setURLSchemeHandler(self.schemeHandler, forURLScheme: CanvasScheme.scheme)
|
||||||
|
canvasWindowLogger.debug("CanvasWindowController init scheme handler installed")
|
||||||
|
|
||||||
|
// Bridge A2UI "a2uiaction" DOM events back into the native agent loop.
|
||||||
|
//
|
||||||
|
// Prefer WKScriptMessageHandler when WebKit exposes it, otherwise fall back to an unattended deep link
|
||||||
|
// (includes the app-generated key so it won't prompt).
|
||||||
|
canvasWindowLogger.debug("CanvasWindowController init building A2UI bridge script")
|
||||||
|
let deepLinkKey = DeepLinkHandler.currentCanvasKey()
|
||||||
|
let injectedSessionKey = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "main"
|
||||||
|
let bridgeScript = """
|
||||||
|
(() => {
|
||||||
|
try {
|
||||||
|
if (location.protocol !== '\(CanvasScheme.scheme):') return;
|
||||||
|
if (globalThis.__clawdisA2UIBridgeInstalled) return;
|
||||||
|
globalThis.__clawdisA2UIBridgeInstalled = true;
|
||||||
|
|
||||||
|
const deepLinkKey = \(Self.jsStringLiteral(deepLinkKey));
|
||||||
|
const sessionKey = \(Self.jsStringLiteral(injectedSessionKey));
|
||||||
|
const machineName = \(Self.jsStringLiteral(InstanceIdentity.displayName));
|
||||||
|
const instanceId = \(Self.jsStringLiteral(InstanceIdentity.instanceId));
|
||||||
|
|
||||||
|
globalThis.addEventListener('a2uiaction', (evt) => {
|
||||||
|
try {
|
||||||
|
const payload = evt?.detail ?? evt?.payload ?? null;
|
||||||
|
if (!payload || payload.eventType !== 'a2ui.action') return;
|
||||||
|
|
||||||
|
const action = payload.action ?? null;
|
||||||
|
const name = action?.name ?? '';
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
const context = Array.isArray(action?.context) ? action.context : [];
|
||||||
|
const userAction = {
|
||||||
|
id: (globalThis.crypto?.randomUUID?.() ?? String(Date.now())),
|
||||||
|
name,
|
||||||
|
surfaceId: payload.surfaceId ?? 'main',
|
||||||
|
sourceComponentId: payload.sourceComponentId ?? '',
|
||||||
|
dataContextPath: payload.dataContextPath ?? '',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
...(context.length ? { context } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const handler = globalThis.webkit?.messageHandlers?.clawdisCanvasA2UIAction;
|
||||||
|
|
||||||
|
// If the bundled A2UI shell is present, let it forward actions so we keep its richer
|
||||||
|
// context resolution (data model path lookups, surface detection, etc.).
|
||||||
|
const hasBundledA2UIHost = !!globalThis.clawdisA2UI || !!document.querySelector('clawdis-a2ui-host');
|
||||||
|
if (hasBundledA2UIHost && handler?.postMessage) return;
|
||||||
|
|
||||||
|
// Otherwise, forward directly when possible.
|
||||||
|
if (!hasBundledA2UIHost && handler?.postMessage) {
|
||||||
|
handler.postMessage({ userAction });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = userAction.context ? (' ctx=' + JSON.stringify(userAction.context)) : '';
|
||||||
|
const message =
|
||||||
|
'CANVAS_A2UI action=' + userAction.name +
|
||||||
|
' session=' + sessionKey +
|
||||||
|
' surface=' + userAction.surfaceId +
|
||||||
|
' component=' + (userAction.sourceComponentId || '-') +
|
||||||
|
' host=' + machineName.replace(/\\s+/g, '_') +
|
||||||
|
' instance=' + instanceId +
|
||||||
|
ctx +
|
||||||
|
' default=update_canvas';
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('message', message);
|
||||||
|
params.set('sessionKey', sessionKey);
|
||||||
|
params.set('thinking', 'low');
|
||||||
|
params.set('deliver', 'false');
|
||||||
|
params.set('channel', 'last');
|
||||||
|
params.set('key', deepLinkKey);
|
||||||
|
location.href = 'clawdis://agent?' + params.toString();
|
||||||
|
} catch {}
|
||||||
|
}, true);
|
||||||
|
} catch {}
|
||||||
|
})();
|
||||||
|
"""
|
||||||
|
config.userContentController.addUserScript(
|
||||||
|
WKUserScript(source: bridgeScript, injectionTime: .atDocumentStart, forMainFrameOnly: true))
|
||||||
|
canvasWindowLogger.debug("CanvasWindowController init A2UI bridge installed")
|
||||||
|
|
||||||
|
canvasWindowLogger.debug("CanvasWindowController init creating WKWebView")
|
||||||
|
self.webView = WKWebView(frame: .zero, configuration: config)
|
||||||
|
// Canvas scaffold is a fully self-contained HTML page; avoid relying on transparency underlays.
|
||||||
|
self.webView.setValue(true, forKey: "drawsBackground")
|
||||||
|
|
||||||
|
let sessionDir = self.sessionDir
|
||||||
|
let webView = self.webView
|
||||||
|
self.watcher = CanvasFileWatcher(url: sessionDir) { [weak webView] in
|
||||||
|
Task { @MainActor in
|
||||||
|
guard let webView else { return }
|
||||||
|
|
||||||
|
// Only auto-reload when we are showing local canvas content.
|
||||||
|
guard webView.url?.scheme == CanvasScheme.scheme else { return }
|
||||||
|
|
||||||
|
let path = webView.url?.path ?? ""
|
||||||
|
if path == "/" || path.isEmpty {
|
||||||
|
let indexA = sessionDir.appendingPathComponent("index.html", isDirectory: false)
|
||||||
|
let indexB = sessionDir.appendingPathComponent("index.htm", isDirectory: false)
|
||||||
|
if !FileManager.default.fileExists(atPath: indexA.path),
|
||||||
|
!FileManager.default.fileExists(atPath: indexB.path)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
webView.reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.container = HoverChromeContainerView(containing: self.webView)
|
||||||
|
let window = Self.makeWindow(for: presentation, contentView: self.container)
|
||||||
|
canvasWindowLogger.debug("CanvasWindowController init makeWindow done")
|
||||||
|
super.init(window: window)
|
||||||
|
|
||||||
|
let handler = CanvasA2UIActionMessageHandler(sessionKey: sessionKey)
|
||||||
|
self.a2uiActionMessageHandler = handler
|
||||||
|
self.webView.configuration.userContentController.add(handler, name: CanvasA2UIActionMessageHandler.messageName)
|
||||||
|
|
||||||
|
self.webView.navigationDelegate = self
|
||||||
|
self.window?.delegate = self
|
||||||
|
self.container.onClose = { [weak self] in
|
||||||
|
self?.hideCanvas()
|
||||||
|
}
|
||||||
|
|
||||||
|
self.watcher.start()
|
||||||
|
canvasWindowLogger.debug("CanvasWindowController init done")
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||||
|
|
||||||
|
@MainActor deinit {
|
||||||
|
self.webView.configuration.userContentController
|
||||||
|
.removeScriptMessageHandler(forName: CanvasA2UIActionMessageHandler.messageName)
|
||||||
|
self.watcher.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyPreferredPlacement(_ placement: CanvasPlacement?) {
|
||||||
|
self.preferredPlacement = placement
|
||||||
|
}
|
||||||
|
|
||||||
|
func showCanvas(path: String? = nil) {
|
||||||
|
if case let .panel(anchorProvider) = self.presentation {
|
||||||
|
self.presentAnchoredPanel(anchorProvider: anchorProvider)
|
||||||
|
if let path {
|
||||||
|
self.load(target: path)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.showWindow(nil)
|
||||||
|
self.window?.makeKeyAndOrderFront(nil)
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
if let path {
|
||||||
|
self.load(target: path)
|
||||||
|
}
|
||||||
|
self.onVisibilityChanged?(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hideCanvas() {
|
||||||
|
if case .panel = self.presentation {
|
||||||
|
self.persistFrameIfPanel()
|
||||||
|
}
|
||||||
|
self.window?.orderOut(nil)
|
||||||
|
self.onVisibilityChanged?(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func load(target: String) {
|
||||||
|
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
self.currentTarget = trimmed
|
||||||
|
|
||||||
|
if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased() {
|
||||||
|
if scheme == "https" || scheme == "http" {
|
||||||
|
canvasWindowLogger.debug("canvas load url \(url.absoluteString, privacy: .public)")
|
||||||
|
self.webView.load(URLRequest(url: url))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if scheme == "file" {
|
||||||
|
canvasWindowLogger.debug("canvas load file \(url.absoluteString, privacy: .public)")
|
||||||
|
self.loadFile(url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience: absolute file paths resolve as local files when they exist.
|
||||||
|
// (Avoid treating Canvas routes like "/" as filesystem paths.)
|
||||||
|
if trimmed.hasPrefix("/") {
|
||||||
|
var isDir: ObjCBool = false
|
||||||
|
if FileManager.default.fileExists(atPath: trimmed, isDirectory: &isDir), !isDir.boolValue {
|
||||||
|
let url = URL(fileURLWithPath: trimmed)
|
||||||
|
canvasWindowLogger.debug("canvas load file \(url.absoluteString, privacy: .public)")
|
||||||
|
self.loadFile(url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let url = CanvasScheme.makeURL(
|
||||||
|
session: CanvasWindowController.sanitizeSessionKey(self.sessionKey),
|
||||||
|
path: trimmed)
|
||||||
|
else {
|
||||||
|
canvasWindowLogger
|
||||||
|
.error(
|
||||||
|
"invalid canvas url session=\(self.sessionKey, privacy: .public) path=\(trimmed, privacy: .public)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
canvasWindowLogger.debug("canvas load canvas \(url.absoluteString, privacy: .public)")
|
||||||
|
self.webView.load(URLRequest(url: url))
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateDebugStatus(enabled: Bool, title: String?, subtitle: String?) {
|
||||||
|
self.debugStatusEnabled = enabled
|
||||||
|
self.debugStatusTitle = title
|
||||||
|
self.debugStatusSubtitle = subtitle
|
||||||
|
self.applyDebugStatusIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyDebugStatusIfNeeded() {
|
||||||
|
let enabled = self.debugStatusEnabled
|
||||||
|
let title = Self.jsOptionalStringLiteral(self.debugStatusTitle)
|
||||||
|
let subtitle = Self.jsOptionalStringLiteral(self.debugStatusSubtitle)
|
||||||
|
let js = """
|
||||||
|
(() => {
|
||||||
|
try {
|
||||||
|
const api = globalThis.__clawdis;
|
||||||
|
if (!api) return;
|
||||||
|
if (typeof api.setDebugStatusEnabled === 'function') {
|
||||||
|
api.setDebugStatusEnabled(\(enabled ? "true" : "false"));
|
||||||
|
}
|
||||||
|
if (!\(enabled ? "true" : "false")) return;
|
||||||
|
if (typeof api.setStatus === 'function') {
|
||||||
|
api.setStatus(\(title), \(subtitle));
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
})();
|
||||||
|
"""
|
||||||
|
self.webView.evaluateJavaScript(js) { _, _ in }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadFile(_ url: URL) {
|
||||||
|
let fileURL = url.isFileURL ? url : URL(fileURLWithPath: url.path)
|
||||||
|
let accessDir = fileURL.deletingLastPathComponent()
|
||||||
|
self.webView.loadFileURL(fileURL, allowingReadAccessTo: accessDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func eval(javaScript: String) async throws -> String {
|
||||||
|
try await withCheckedThrowingContinuation { cont in
|
||||||
|
self.webView.evaluateJavaScript(javaScript) { result, error in
|
||||||
|
if let error {
|
||||||
|
cont.resume(throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let result {
|
||||||
|
cont.resume(returning: String(describing: result))
|
||||||
|
} else {
|
||||||
|
cont.resume(returning: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshot(to outPath: String?) async throws -> String {
|
||||||
|
let image: NSImage = try await withCheckedThrowingContinuation { cont in
|
||||||
|
self.webView.takeSnapshot(with: nil) { image, error in
|
||||||
|
if let error {
|
||||||
|
cont.resume(throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let image else {
|
||||||
|
cont.resume(throwing: NSError(domain: "Canvas", code: 11, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "snapshot returned nil image",
|
||||||
|
]))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cont.resume(returning: image)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let tiff = image.tiffRepresentation,
|
||||||
|
let rep = NSBitmapImageRep(data: tiff),
|
||||||
|
let png = rep.representation(using: .png, properties: [:])
|
||||||
|
else {
|
||||||
|
throw NSError(domain: "Canvas", code: 12, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "failed to encode png",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
let path: String
|
||||||
|
if let outPath, !outPath.isEmpty {
|
||||||
|
path = outPath
|
||||||
|
} else {
|
||||||
|
let ts = Int(Date().timeIntervalSince1970)
|
||||||
|
path = "/tmp/clawdis-canvas-\(CanvasWindowController.sanitizeSessionKey(self.sessionKey))-\(ts).png"
|
||||||
|
}
|
||||||
|
|
||||||
|
try png.write(to: URL(fileURLWithPath: path), options: [.atomic])
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
var directoryPath: String {
|
||||||
|
self.sessionDir.path
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldAutoNavigateToA2UI(lastAutoTarget: String?) -> Bool {
|
||||||
|
let trimmed = (self.currentTarget ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmed.isEmpty || trimmed == "/" { return true }
|
||||||
|
if let lastAuto = lastAutoTarget?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!lastAuto.isEmpty,
|
||||||
|
trimmed == lastAuto
|
||||||
|
{
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,25 +1,38 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum ClawdisConfigFile {
|
enum ClawdisConfigFile {
|
||||||
|
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "config")
|
||||||
|
|
||||||
static func url() -> URL {
|
static func url() -> URL {
|
||||||
FileManager.default.homeDirectoryForCurrentUser
|
ClawdisPaths.configURL
|
||||||
.appendingPathComponent(".clawdis")
|
}
|
||||||
.appendingPathComponent("clawdis.json")
|
|
||||||
|
static func stateDirURL() -> URL {
|
||||||
|
ClawdisPaths.stateDirURL
|
||||||
}
|
}
|
||||||
|
|
||||||
static func defaultWorkspaceURL() -> URL {
|
static func defaultWorkspaceURL() -> URL {
|
||||||
FileManager.default.homeDirectoryForCurrentUser
|
ClawdisPaths.workspaceURL
|
||||||
.appendingPathComponent(".clawdis")
|
|
||||||
.appendingPathComponent("workspace", isDirectory: true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func loadDict() -> [String: Any] {
|
static func loadDict() -> [String: Any] {
|
||||||
let url = self.url()
|
let url = self.url()
|
||||||
guard let data = try? Data(contentsOf: url) else { return [:] }
|
guard FileManager.default.fileExists(atPath: url.path) else { return [:] }
|
||||||
return (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:]
|
do {
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
|
guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||||
|
self.logger.warning("config JSON root invalid")
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
return root
|
||||||
|
} catch {
|
||||||
|
self.logger.warning("config read failed: \(error.localizedDescription)")
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func saveDict(_ dict: [String: Any]) {
|
static func saveDict(_ dict: [String: Any]) {
|
||||||
|
if ProcessInfo.processInfo.isNixMode { return }
|
||||||
do {
|
do {
|
||||||
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
|
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
|
||||||
let url = self.url()
|
let url = self.url()
|
||||||
@@ -27,7 +40,9 @@ enum ClawdisConfigFile {
|
|||||||
at: url.deletingLastPathComponent(),
|
at: url.deletingLastPathComponent(),
|
||||||
withIntermediateDirectories: true)
|
withIntermediateDirectories: true)
|
||||||
try data.write(to: url, options: [.atomic])
|
try data.write(to: url, options: [.atomic])
|
||||||
} catch {}
|
} catch {
|
||||||
|
self.logger.error("config save failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func loadGatewayDict() -> [String: Any] {
|
static func loadGatewayDict() -> [String: Any] {
|
||||||
@@ -59,24 +74,27 @@ enum ClawdisConfigFile {
|
|||||||
browser["enabled"] = enabled
|
browser["enabled"] = enabled
|
||||||
root["browser"] = browser
|
root["browser"] = browser
|
||||||
self.saveDict(root)
|
self.saveDict(root)
|
||||||
|
self.logger.debug("browser control updated enabled=\(enabled)")
|
||||||
}
|
}
|
||||||
|
|
||||||
static func inboundWorkspace() -> String? {
|
static func agentWorkspace() -> String? {
|
||||||
let root = self.loadDict()
|
let root = self.loadDict()
|
||||||
let inbound = root["inbound"] as? [String: Any]
|
let agent = root["agent"] as? [String: Any]
|
||||||
return inbound?["workspace"] as? String
|
return agent?["workspace"] as? String
|
||||||
}
|
}
|
||||||
|
|
||||||
static func setInboundWorkspace(_ workspace: String?) {
|
static func setAgentWorkspace(_ workspace: String?) {
|
||||||
var root = self.loadDict()
|
var root = self.loadDict()
|
||||||
var inbound = root["inbound"] as? [String: Any] ?? [:]
|
var agent = root["agent"] as? [String: Any] ?? [:]
|
||||||
let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
if trimmed.isEmpty {
|
if trimmed.isEmpty {
|
||||||
inbound.removeValue(forKey: "workspace")
|
agent.removeValue(forKey: "workspace")
|
||||||
} else {
|
} else {
|
||||||
inbound["workspace"] = trimmed
|
agent["workspace"] = trimmed
|
||||||
}
|
}
|
||||||
root["inbound"] = inbound
|
root["agent"] = agent
|
||||||
self.saveDict(root)
|
self.saveDict(root)
|
||||||
|
self.logger.debug("agent workspace updated set=\(!trimmed.isEmpty)")
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum ClawdisEnv {
|
||||||
|
static func path(_ key: String) -> String? {
|
||||||
|
// Normalize env overrides once so UI + file IO stay consistent.
|
||||||
|
guard let value = ProcessInfo.processInfo.environment[key]?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!value.isEmpty
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ClawdisPaths {
|
||||||
|
private static let configPathEnv = "CLAWDIS_CONFIG_PATH"
|
||||||
|
private static let stateDirEnv = "CLAWDIS_STATE_DIR"
|
||||||
|
|
||||||
|
static var stateDirURL: URL {
|
||||||
|
if let override = ClawdisEnv.path(self.stateDirEnv) {
|
||||||
|
return URL(fileURLWithPath: override, isDirectory: true)
|
||||||
|
}
|
||||||
|
return FileManager.default.homeDirectoryForCurrentUser
|
||||||
|
.appendingPathComponent(".clawdis", isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
static var configURL: URL {
|
||||||
|
if let override = ClawdisEnv.path(self.configPathEnv) {
|
||||||
|
return URL(fileURLWithPath: override)
|
||||||
|
}
|
||||||
|
return self.stateDirURL.appendingPathComponent("clawdis.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
static var workspaceURL: URL {
|
||||||
|
self.stateDirURL.appendingPathComponent("workspace", isDirectory: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
-248
@@ -1,237 +1,5 @@
|
|||||||
import AppKit
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
extension ProcessInfo {
|
|
||||||
var isPreview: Bool {
|
|
||||||
self.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
|
|
||||||
}
|
|
||||||
|
|
||||||
var isRunningTests: Bool {
|
|
||||||
// SwiftPM tests load one or more `.xctest` bundles. With Swift Testing, `Bundle.main` is not
|
|
||||||
// guaranteed to be the `.xctest` bundle, so check all loaded bundles.
|
|
||||||
if Bundle.allBundles.contains(where: { $0.bundleURL.pathExtension == "xctest" }) { return true }
|
|
||||||
if Bundle.main.bundleURL.pathExtension == "xctest" { return true }
|
|
||||||
|
|
||||||
// Backwards-compatible fallbacks for runners that still set XCTest env vars.
|
|
||||||
return self.environment["XCTestConfigurationFilePath"] != nil
|
|
||||||
|| self.environment["XCTestBundlePath"] != nil
|
|
||||||
|| self.environment["XCTestSessionIdentifier"] != nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum LaunchdManager {
|
|
||||||
private static func runLaunchctl(_ args: [String]) {
|
|
||||||
let process = Process()
|
|
||||||
process.launchPath = "/bin/launchctl"
|
|
||||||
process.arguments = args
|
|
||||||
try? process.run()
|
|
||||||
}
|
|
||||||
|
|
||||||
static func startClawdis() {
|
|
||||||
let userTarget = "gui/\(getuid())/\(launchdLabel)"
|
|
||||||
self.runLaunchctl(["kickstart", "-k", userTarget])
|
|
||||||
}
|
|
||||||
|
|
||||||
static func stopClawdis() {
|
|
||||||
let userTarget = "gui/\(getuid())/\(launchdLabel)"
|
|
||||||
self.runLaunchctl(["stop", userTarget])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum LaunchAgentManager {
|
|
||||||
private static var plistURL: URL {
|
|
||||||
FileManager.default.homeDirectoryForCurrentUser
|
|
||||||
.appendingPathComponent("Library/LaunchAgents/com.steipete.clawdis.plist")
|
|
||||||
}
|
|
||||||
|
|
||||||
static func status() async -> Bool {
|
|
||||||
guard FileManager.default.fileExists(atPath: self.plistURL.path) else { return false }
|
|
||||||
let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(launchdLabel)"])
|
|
||||||
return result == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
static func set(enabled: Bool, bundlePath: String) async {
|
|
||||||
if enabled {
|
|
||||||
self.writePlist(bundlePath: bundlePath)
|
|
||||||
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"])
|
|
||||||
_ = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path])
|
|
||||||
_ = await self.runLaunchctl(["kickstart", "-k", "gui/\(getuid())/\(launchdLabel)"])
|
|
||||||
} else {
|
|
||||||
// Disable autostart going forward but leave the current app running.
|
|
||||||
// bootout would terminate the launchd job immediately (and crash the app if launched via agent).
|
|
||||||
try? FileManager.default.removeItem(at: self.plistURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func writePlist(bundlePath: String) {
|
|
||||||
let plist = """
|
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>Label</key>
|
|
||||||
<string>com.steipete.clawdis</string>
|
|
||||||
<key>ProgramArguments</key>
|
|
||||||
<array>
|
|
||||||
<string>\(bundlePath)/Contents/MacOS/Clawdis</string>
|
|
||||||
</array>
|
|
||||||
<key>WorkingDirectory</key>
|
|
||||||
<string>\(FileManager.default.homeDirectoryForCurrentUser.path)</string>
|
|
||||||
<key>RunAtLoad</key>
|
|
||||||
<true/>
|
|
||||||
<key>KeepAlive</key>
|
|
||||||
<true/>
|
|
||||||
<key>EnvironmentVariables</key>
|
|
||||||
<dict>
|
|
||||||
<key>PATH</key>
|
|
||||||
<string>\(CommandResolver.preferredPaths().joined(separator: ":"))</string>
|
|
||||||
</dict>
|
|
||||||
<key>StandardOutPath</key>
|
|
||||||
<string>\(LogLocator.launchdLogPath)</string>
|
|
||||||
<key>StandardErrorPath</key>
|
|
||||||
<string>\(LogLocator.launchdLogPath)</string>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
"""
|
|
||||||
try? plist.write(to: self.plistURL, atomically: true, encoding: .utf8)
|
|
||||||
}
|
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
private static func runLaunchctl(_ args: [String]) async -> Int32 {
|
|
||||||
await Task.detached(priority: .utility) { () -> Int32 in
|
|
||||||
let process = Process()
|
|
||||||
process.launchPath = "/bin/launchctl"
|
|
||||||
process.arguments = args
|
|
||||||
process.standardOutput = Pipe()
|
|
||||||
process.standardError = Pipe()
|
|
||||||
do {
|
|
||||||
try process.run()
|
|
||||||
process.waitUntilExit()
|
|
||||||
return process.terminationStatus
|
|
||||||
} catch {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
}.value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Human-friendly age string (e.g., "2m ago").
|
|
||||||
func age(from date: Date, now: Date = .init()) -> String {
|
|
||||||
let seconds = max(0, Int(now.timeIntervalSince(date)))
|
|
||||||
let minutes = seconds / 60
|
|
||||||
let hours = minutes / 60
|
|
||||||
let days = hours / 24
|
|
||||||
|
|
||||||
if seconds < 60 { return "just now" }
|
|
||||||
if minutes == 1 { return "1 minute ago" }
|
|
||||||
if minutes < 60 { return "\(minutes)m ago" }
|
|
||||||
if hours == 1 { return "1 hour ago" }
|
|
||||||
if hours < 24 { return "\(hours)h ago" }
|
|
||||||
if days == 1 { return "yesterday" }
|
|
||||||
return "\(days)d ago"
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
enum CLIInstaller {
|
|
||||||
private static func embeddedHelperURL() -> URL {
|
|
||||||
Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/Relay/clawdis")
|
|
||||||
}
|
|
||||||
|
|
||||||
static func installedLocation() -> String? {
|
|
||||||
self.installedLocation(
|
|
||||||
searchPaths: cliHelperSearchPaths,
|
|
||||||
embeddedHelper: self.embeddedHelperURL(),
|
|
||||||
fileManager: .default)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func installedLocation(
|
|
||||||
searchPaths: [String],
|
|
||||||
embeddedHelper: URL,
|
|
||||||
fileManager: FileManager) -> String?
|
|
||||||
{
|
|
||||||
let embedded = embeddedHelper.resolvingSymlinksInPath()
|
|
||||||
|
|
||||||
for basePath in searchPaths {
|
|
||||||
let candidate = URL(fileURLWithPath: basePath).appendingPathComponent("clawdis").path
|
|
||||||
var isDirectory: ObjCBool = false
|
|
||||||
|
|
||||||
guard fileManager.fileExists(atPath: candidate, isDirectory: &isDirectory),
|
|
||||||
!isDirectory.boolValue
|
|
||||||
else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
guard fileManager.isExecutableFile(atPath: candidate) else { continue }
|
|
||||||
|
|
||||||
let resolved = URL(fileURLWithPath: candidate).resolvingSymlinksInPath()
|
|
||||||
if resolved == embedded {
|
|
||||||
return candidate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
static func isInstalled() -> Bool {
|
|
||||||
self.installedLocation() != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
static func install(statusHandler: @escaping @Sendable (String) async -> Void) async {
|
|
||||||
let helper = self.embeddedHelperURL()
|
|
||||||
guard FileManager.default.isExecutableFile(atPath: helper.path) else {
|
|
||||||
await statusHandler(
|
|
||||||
"Embedded CLI missing in bundle; repackage via scripts/package-mac-app.sh " +
|
|
||||||
"(or restart-mac.sh without SKIP_GATEWAY_PACKAGE=1).")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let targets = cliHelperSearchPaths.map { "\($0)/clawdis" }
|
|
||||||
let result = await self.privilegedSymlink(source: helper.path, targets: targets)
|
|
||||||
await statusHandler(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func privilegedSymlink(source: String, targets: [String]) async -> String {
|
|
||||||
let escapedSource = self.shellEscape(source)
|
|
||||||
let targetList = targets.map(self.shellEscape).joined(separator: " ")
|
|
||||||
let cmds = [
|
|
||||||
"mkdir -p /usr/local/bin /opt/homebrew/bin",
|
|
||||||
targets.map { "ln -sf \(escapedSource) \($0)" }.joined(separator: "; "),
|
|
||||||
].joined(separator: "; ")
|
|
||||||
|
|
||||||
let script = """
|
|
||||||
do shell script "\(cmds)" with administrator privileges
|
|
||||||
"""
|
|
||||||
|
|
||||||
let proc = Process()
|
|
||||||
proc.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
|
|
||||||
proc.arguments = ["-e", script]
|
|
||||||
|
|
||||||
let pipe = Pipe()
|
|
||||||
proc.standardOutput = pipe
|
|
||||||
proc.standardError = pipe
|
|
||||||
|
|
||||||
do {
|
|
||||||
try proc.run()
|
|
||||||
proc.waitUntilExit()
|
|
||||||
let data = pipe.fileHandleForReading.readToEndSafely()
|
|
||||||
let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
if proc.terminationStatus == 0 {
|
|
||||||
return output.isEmpty ? "CLI helper linked into \(targetList)" : output
|
|
||||||
}
|
|
||||||
if output.lowercased().contains("user canceled") {
|
|
||||||
return "Install canceled"
|
|
||||||
}
|
|
||||||
return "Failed to install CLI helper: \(output)"
|
|
||||||
} catch {
|
|
||||||
return "Failed to run installer: \(error.localizedDescription)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func shellEscape(_ path: String) -> String {
|
|
||||||
"'" + path.replacingOccurrences(of: "'", with: "'\"'\"'") + "'"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum CommandResolver {
|
enum CommandResolver {
|
||||||
private static let projectRootDefaultsKey = "clawdis.gatewayProjectRootPath"
|
private static let projectRootDefaultsKey = "clawdis.gatewayProjectRootPath"
|
||||||
private static let helperName = "clawdis"
|
private static let helperName = "clawdis"
|
||||||
@@ -248,6 +16,10 @@ enum CommandResolver {
|
|||||||
RuntimeLocator.resolve(searchPaths: self.preferredPaths())
|
RuntimeLocator.resolve(searchPaths: self.preferredPaths())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func runtimeResolution(searchPaths: [String]?) -> Result<RuntimeResolution, RuntimeResolutionError> {
|
||||||
|
RuntimeLocator.resolve(searchPaths: searchPaths ?? self.preferredPaths())
|
||||||
|
}
|
||||||
|
|
||||||
static func makeRuntimeCommand(
|
static func makeRuntimeCommand(
|
||||||
runtime: RuntimeResolution,
|
runtime: RuntimeResolution,
|
||||||
entrypoint: String,
|
entrypoint: String,
|
||||||
@@ -384,8 +156,8 @@ enum CommandResolver {
|
|||||||
return paths
|
return paths
|
||||||
}
|
}
|
||||||
|
|
||||||
static func findExecutable(named name: String) -> String? {
|
static func findExecutable(named name: String, searchPaths: [String]? = nil) -> String? {
|
||||||
for dir in self.preferredPaths() {
|
for dir in (searchPaths ?? self.preferredPaths()) {
|
||||||
let candidate = (dir as NSString).appendingPathComponent(name)
|
let candidate = (dir as NSString).appendingPathComponent(name)
|
||||||
if FileManager.default.isExecutableFile(atPath: candidate) {
|
if FileManager.default.isExecutableFile(atPath: candidate) {
|
||||||
return candidate
|
return candidate
|
||||||
@@ -394,8 +166,14 @@ enum CommandResolver {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
static func clawdisExecutable() -> String? {
|
static func clawdisExecutable(searchPaths: [String]? = nil) -> String? {
|
||||||
self.findExecutable(named: self.helperName)
|
self.findExecutable(named: self.helperName, searchPaths: searchPaths)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func projectClawdisExecutable(projectRoot: URL? = nil) -> String? {
|
||||||
|
let root = projectRoot ?? self.projectRoot()
|
||||||
|
let candidate = root.appendingPathComponent("node_modules/.bin").appendingPathComponent(self.helperName).path
|
||||||
|
return FileManager.default.isExecutableFile(atPath: candidate) ? candidate : nil
|
||||||
}
|
}
|
||||||
|
|
||||||
static func nodeCliPath() -> String? {
|
static func nodeCliPath() -> String? {
|
||||||
@@ -403,17 +181,18 @@ enum CommandResolver {
|
|||||||
return FileManager.default.isReadableFile(atPath: candidate) ? candidate : nil
|
return FileManager.default.isReadableFile(atPath: candidate) ? candidate : nil
|
||||||
}
|
}
|
||||||
|
|
||||||
static func hasAnyClawdisInvoker() -> Bool {
|
static func hasAnyClawdisInvoker(searchPaths: [String]? = nil) -> Bool {
|
||||||
if self.clawdisExecutable() != nil { return true }
|
if self.clawdisExecutable(searchPaths: searchPaths) != nil { return true }
|
||||||
if self.findExecutable(named: "pnpm") != nil { return true }
|
if self.findExecutable(named: "pnpm", searchPaths: searchPaths) != nil { return true }
|
||||||
if self.findExecutable(named: "node") != nil, self.nodeCliPath() != nil { return true }
|
if self.findExecutable(named: "node", searchPaths: searchPaths) != nil, self.nodeCliPath() != nil { return true }
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
static func clawdisNodeCommand(
|
static func clawdisNodeCommand(
|
||||||
subcommand: String,
|
subcommand: String,
|
||||||
extraArgs: [String] = [],
|
extraArgs: [String] = [],
|
||||||
defaults: UserDefaults = .standard) -> [String]
|
defaults: UserDefaults = .standard,
|
||||||
|
searchPaths: [String]? = nil) -> [String]
|
||||||
{
|
{
|
||||||
let settings = self.connectionSettings(defaults: defaults)
|
let settings = self.connectionSettings(defaults: defaults)
|
||||||
if settings.mode == .remote, let ssh = self.sshNodeCommand(
|
if settings.mode == .remote, let ssh = self.sshNodeCommand(
|
||||||
@@ -424,25 +203,29 @@ enum CommandResolver {
|
|||||||
return ssh
|
return ssh
|
||||||
}
|
}
|
||||||
|
|
||||||
let runtimeResult = self.runtimeResolution()
|
let runtimeResult = self.runtimeResolution(searchPaths: searchPaths)
|
||||||
|
|
||||||
switch runtimeResult {
|
switch runtimeResult {
|
||||||
case let .success(runtime):
|
case let .success(runtime):
|
||||||
if let clawdisPath = self.clawdisExecutable() {
|
let root = self.projectRoot()
|
||||||
|
if let clawdisPath = self.projectClawdisExecutable(projectRoot: root) {
|
||||||
return [clawdisPath, subcommand] + extraArgs
|
return [clawdisPath, subcommand] + extraArgs
|
||||||
}
|
}
|
||||||
|
|
||||||
if let entry = self.gatewayEntrypoint(in: self.projectRoot()) {
|
if let entry = self.gatewayEntrypoint(in: root) {
|
||||||
return self.makeRuntimeCommand(
|
return self.makeRuntimeCommand(
|
||||||
runtime: runtime,
|
runtime: runtime,
|
||||||
entrypoint: entry,
|
entrypoint: entry,
|
||||||
subcommand: subcommand,
|
subcommand: subcommand,
|
||||||
extraArgs: extraArgs)
|
extraArgs: extraArgs)
|
||||||
}
|
}
|
||||||
if let pnpm = self.findExecutable(named: "pnpm") {
|
if let pnpm = self.findExecutable(named: "pnpm", searchPaths: searchPaths) {
|
||||||
// Use --silent to avoid pnpm lifecycle banners that would corrupt JSON outputs.
|
// Use --silent to avoid pnpm lifecycle banners that would corrupt JSON outputs.
|
||||||
return [pnpm, "--silent", "clawdis", subcommand] + extraArgs
|
return [pnpm, "--silent", "clawdis", subcommand] + extraArgs
|
||||||
}
|
}
|
||||||
|
if let clawdisPath = self.clawdisExecutable(searchPaths: searchPaths) {
|
||||||
|
return [clawdisPath, subcommand] + extraArgs
|
||||||
|
}
|
||||||
|
|
||||||
let missingEntry = """
|
let missingEntry = """
|
||||||
clawdis entrypoint missing (looked for dist/index.js or bin/clawdis.js); run pnpm build.
|
clawdis entrypoint missing (looked for dist/index.js or bin/clawdis.js); run pnpm build.
|
||||||
@@ -458,9 +241,10 @@ enum CommandResolver {
|
|||||||
static func clawdisCommand(
|
static func clawdisCommand(
|
||||||
subcommand: String,
|
subcommand: String,
|
||||||
extraArgs: [String] = [],
|
extraArgs: [String] = [],
|
||||||
defaults: UserDefaults = .standard) -> [String]
|
defaults: UserDefaults = .standard,
|
||||||
|
searchPaths: [String]? = nil) -> [String]
|
||||||
{
|
{
|
||||||
self.clawdisNodeCommand(subcommand: subcommand, extraArgs: extraArgs, defaults: defaults)
|
self.clawdisNodeCommand(subcommand: subcommand, extraArgs: extraArgs, defaults: defaults, searchPaths: searchPaths)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - SSH helpers
|
// MARK: - SSH helpers
|
||||||
@@ -490,7 +274,7 @@ enum CommandResolver {
|
|||||||
"/bin",
|
"/bin",
|
||||||
"/usr/sbin",
|
"/usr/sbin",
|
||||||
"/sbin",
|
"/sbin",
|
||||||
"/Users/steipete/Library/pnpm",
|
"$HOME/Library/pnpm",
|
||||||
"$PATH",
|
"$PATH",
|
||||||
].joined(separator: ":")
|
].joined(separator: ":")
|
||||||
let quotedArgs = ([subcommand] + extraArgs).map(self.shellQuote).joined(separator: " ")
|
let quotedArgs = ([subcommand] + extraArgs).map(self.shellQuote).joined(separator: " ")
|
||||||
@@ -3,6 +3,7 @@ import SwiftUI
|
|||||||
@MainActor
|
@MainActor
|
||||||
struct ConfigSettings: View {
|
struct ConfigSettings: View {
|
||||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||||
|
private let isNixMode = ProcessInfo.processInfo.isNixMode
|
||||||
private let state = AppStateStore.shared
|
private let state = AppStateStore.shared
|
||||||
private let labelColumnWidth: CGFloat = 120
|
private let labelColumnWidth: CGFloat = 120
|
||||||
private static let browserAttachOnlyHelp =
|
private static let browserAttachOnlyHelp =
|
||||||
@@ -30,6 +31,12 @@ struct ConfigSettings: View {
|
|||||||
@State private var browserColorHex: String = "#FF4500"
|
@State private var browserColorHex: String = "#FF4500"
|
||||||
@State private var browserAttachOnly: Bool = false
|
@State private var browserAttachOnly: Bool = false
|
||||||
|
|
||||||
|
// Talk mode settings (stored in ~/.clawdis/clawdis.json under "talk")
|
||||||
|
@State private var talkVoiceId: String = ""
|
||||||
|
@State private var talkInterruptOnSpeech: Bool = true
|
||||||
|
@State private var talkApiKey: String = ""
|
||||||
|
@State private var gatewayApiKeyFound = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView { self.content }
|
ScrollView { self.content }
|
||||||
.onChange(of: self.modelCatalogPath) { _, _ in
|
.onChange(of: self.modelCatalogPath) { _, _ in
|
||||||
@@ -44,6 +51,7 @@ struct ConfigSettings: View {
|
|||||||
self.hasLoaded = true
|
self.hasLoaded = true
|
||||||
self.loadConfig()
|
self.loadConfig()
|
||||||
await self.loadModels()
|
await self.loadModels()
|
||||||
|
await self.refreshGatewayTalkApiKey()
|
||||||
self.allowAutosave = true
|
self.allowAutosave = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,8 +60,13 @@ struct ConfigSettings: View {
|
|||||||
VStack(alignment: .leading, spacing: 14) {
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
self.header
|
self.header
|
||||||
self.agentSection
|
self.agentSection
|
||||||
|
.disabled(self.isNixMode)
|
||||||
self.heartbeatSection
|
self.heartbeatSection
|
||||||
|
.disabled(self.isNixMode)
|
||||||
|
self.talkSection
|
||||||
|
.disabled(self.isNixMode)
|
||||||
self.browserSection
|
self.browserSection
|
||||||
|
.disabled(self.isNixMode)
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
@@ -66,7 +79,9 @@ struct ConfigSettings: View {
|
|||||||
private var header: some View {
|
private var header: some View {
|
||||||
Text("Clawdis CLI config")
|
Text("Clawdis CLI config")
|
||||||
.font(.title3.weight(.semibold))
|
.font(.title3.weight(.semibold))
|
||||||
Text("Edit ~/.clawdis/clawdis.json (inbound.agent / inbound.session).")
|
Text(self.isNixMode
|
||||||
|
? "This tab is read-only in Nix mode. Edit config via Nix and rebuild."
|
||||||
|
: "Edit ~/.clawdis/clawdis.json (agent / session / routing / messages).")
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -266,20 +281,101 @@ struct ConfigSettings: View {
|
|||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var talkSection: some View {
|
||||||
|
GroupBox("Talk Mode") {
|
||||||
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Voice ID")
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
TextField("ElevenLabs voice ID", text: self.$talkVoiceId)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.onChange(of: self.talkVoiceId) { _, _ in self.autosaveConfig() }
|
||||||
|
if !self.talkVoiceSuggestions.isEmpty {
|
||||||
|
Menu {
|
||||||
|
ForEach(self.talkVoiceSuggestions, id: \.self) { value in
|
||||||
|
Button(value) {
|
||||||
|
self.talkVoiceId = value
|
||||||
|
self.autosaveConfig()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Suggestions", systemImage: "chevron.up.chevron.down")
|
||||||
|
}
|
||||||
|
.fixedSize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text("Defaults to ELEVENLABS_VOICE_ID / SAG_VOICE_ID if unset.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("API key")
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
SecureField("ELEVENLABS_API_KEY", text: self.$talkApiKey)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.disabled(self.hasEnvApiKey)
|
||||||
|
.onChange(of: self.talkApiKey) { _, _ in self.autosaveConfig() }
|
||||||
|
if !self.hasEnvApiKey && !self.talkApiKey.isEmpty {
|
||||||
|
Button("Clear") {
|
||||||
|
self.talkApiKey = ""
|
||||||
|
self.autosaveConfig()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.statusLine(label: self.apiKeyStatusLabel, color: self.apiKeyStatusColor)
|
||||||
|
if self.hasEnvApiKey {
|
||||||
|
Text("Using ELEVENLABS_API_KEY from the environment.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else if self.gatewayApiKeyFound && self.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
Text("Using API key from the gateway profile.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Interrupt")
|
||||||
|
Toggle("Stop speaking when you start talking", isOn: self.$talkInterruptOnSpeech)
|
||||||
|
.labelsHidden()
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
.onChange(of: self.talkInterruptOnSpeech) { _, _ in self.autosaveConfig() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
private func gridLabel(_ text: String) -> some View {
|
private func gridLabel(_ text: String) -> some View {
|
||||||
Text(text)
|
Text(text)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.frame(width: self.labelColumnWidth, alignment: .leading)
|
.frame(width: self.labelColumnWidth, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func statusLine(label: String, color: Color) -> some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Circle()
|
||||||
|
.fill(color)
|
||||||
|
.frame(width: 6, height: 6)
|
||||||
|
Text(label)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.top, 2)
|
||||||
|
}
|
||||||
|
|
||||||
private func loadConfig() {
|
private func loadConfig() {
|
||||||
let parsed = self.loadConfigDict()
|
let parsed = self.loadConfigDict()
|
||||||
let inbound = parsed["inbound"] as? [String: Any]
|
let agent = parsed["agent"] as? [String: Any]
|
||||||
let reply = inbound?["reply"] as? [String: Any]
|
let heartbeatMinutes = agent?["heartbeatMinutes"] as? Int
|
||||||
let agent = reply?["agent"] as? [String: Any]
|
let heartbeatBody = agent?["heartbeatBody"] as? String
|
||||||
let heartbeatMinutes = reply?["heartbeatMinutes"] as? Int
|
|
||||||
let heartbeatBody = reply?["heartbeatBody"] as? String
|
|
||||||
let browser = parsed["browser"] as? [String: Any]
|
let browser = parsed["browser"] as? [String: Any]
|
||||||
|
let talk = parsed["talk"] as? [String: Any]
|
||||||
|
|
||||||
let loadedModel = (agent?["model"] as? String) ?? ""
|
let loadedModel = (agent?["model"] as? String) ?? ""
|
||||||
if !loadedModel.isEmpty {
|
if !loadedModel.isEmpty {
|
||||||
@@ -299,10 +395,32 @@ struct ConfigSettings: View {
|
|||||||
if let color = browser["color"] as? String, !color.isEmpty { self.browserColorHex = color }
|
if let color = browser["color"] as? String, !color.isEmpty { self.browserColorHex = color }
|
||||||
if let attachOnly = browser["attachOnly"] as? Bool { self.browserAttachOnly = attachOnly }
|
if let attachOnly = browser["attachOnly"] as? Bool { self.browserAttachOnly = attachOnly }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let talk {
|
||||||
|
if let voice = talk["voiceId"] as? String { self.talkVoiceId = voice }
|
||||||
|
if let apiKey = talk["apiKey"] as? String { self.talkApiKey = apiKey }
|
||||||
|
if let interrupt = talk["interruptOnSpeech"] as? Bool {
|
||||||
|
self.talkInterruptOnSpeech = interrupt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshGatewayTalkApiKey() async {
|
||||||
|
do {
|
||||||
|
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
|
||||||
|
method: .configGet,
|
||||||
|
params: nil,
|
||||||
|
timeoutMs: 8000)
|
||||||
|
let talk = snap.config?["talk"]?.dictionaryValue
|
||||||
|
let apiKey = talk?["apiKey"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
self.gatewayApiKeyFound = !(apiKey ?? "").isEmpty
|
||||||
|
} catch {
|
||||||
|
self.gatewayApiKeyFound = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func autosaveConfig() {
|
private func autosaveConfig() {
|
||||||
guard self.allowAutosave else { return }
|
guard self.allowAutosave, !self.isNixMode else { return }
|
||||||
Task { await self.saveConfig() }
|
Task { await self.saveConfig() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,29 +430,25 @@ struct ConfigSettings: View {
|
|||||||
defer { self.configSaving = false }
|
defer { self.configSaving = false }
|
||||||
|
|
||||||
var root = self.loadConfigDict()
|
var root = self.loadConfigDict()
|
||||||
var inbound = root["inbound"] as? [String: Any] ?? [:]
|
var agent = root["agent"] as? [String: Any] ?? [:]
|
||||||
var reply = inbound["reply"] as? [String: Any] ?? [:]
|
|
||||||
var agent = reply["agent"] as? [String: Any] ?? [:]
|
|
||||||
var browser = root["browser"] as? [String: Any] ?? [:]
|
var browser = root["browser"] as? [String: Any] ?? [:]
|
||||||
|
var talk = root["talk"] as? [String: Any] ?? [:]
|
||||||
|
|
||||||
let chosenModel = (self.configModel == "__custom__" ? self.customModel : self.configModel)
|
let chosenModel = (self.configModel == "__custom__" ? self.customModel : self.configModel)
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let trimmedModel = chosenModel
|
let trimmedModel = chosenModel
|
||||||
if !trimmedModel.isEmpty { agent["model"] = trimmedModel }
|
if !trimmedModel.isEmpty { agent["model"] = trimmedModel }
|
||||||
|
|
||||||
reply["agent"] = agent
|
|
||||||
|
|
||||||
if let heartbeatMinutes {
|
if let heartbeatMinutes {
|
||||||
reply["heartbeatMinutes"] = heartbeatMinutes
|
agent["heartbeatMinutes"] = heartbeatMinutes
|
||||||
}
|
}
|
||||||
|
|
||||||
let trimmedBody = self.heartbeatBody.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmedBody = self.heartbeatBody.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if !trimmedBody.isEmpty {
|
if !trimmedBody.isEmpty {
|
||||||
reply["heartbeatBody"] = trimmedBody
|
agent["heartbeatBody"] = trimmedBody
|
||||||
}
|
}
|
||||||
|
|
||||||
inbound["reply"] = reply
|
root["agent"] = agent
|
||||||
root["inbound"] = inbound
|
|
||||||
|
|
||||||
browser["enabled"] = self.browserEnabled
|
browser["enabled"] = self.browserEnabled
|
||||||
let trimmedUrl = self.browserControlUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmedUrl = self.browserControlUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
@@ -344,6 +458,21 @@ struct ConfigSettings: View {
|
|||||||
browser["attachOnly"] = self.browserAttachOnly
|
browser["attachOnly"] = self.browserAttachOnly
|
||||||
root["browser"] = browser
|
root["browser"] = browser
|
||||||
|
|
||||||
|
let trimmedVoice = self.talkVoiceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmedVoice.isEmpty {
|
||||||
|
talk.removeValue(forKey: "voiceId")
|
||||||
|
} else {
|
||||||
|
talk["voiceId"] = trimmedVoice
|
||||||
|
}
|
||||||
|
let trimmedApiKey = self.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmedApiKey.isEmpty {
|
||||||
|
talk.removeValue(forKey: "apiKey")
|
||||||
|
} else {
|
||||||
|
talk["apiKey"] = trimmedApiKey
|
||||||
|
}
|
||||||
|
talk["interruptOnSpeech"] = self.talkInterruptOnSpeech
|
||||||
|
root["talk"] = talk
|
||||||
|
|
||||||
ClawdisConfigFile.saveDict(root)
|
ClawdisConfigFile.saveDict(root)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,6 +490,41 @@ struct ConfigSettings: View {
|
|||||||
return Color(red: r, green: g, blue: b)
|
return Color(red: r, green: g, blue: b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var talkVoiceSuggestions: [String] {
|
||||||
|
let env = ProcessInfo.processInfo.environment
|
||||||
|
let candidates = [
|
||||||
|
self.talkVoiceId,
|
||||||
|
env["ELEVENLABS_VOICE_ID"] ?? "",
|
||||||
|
env["SAG_VOICE_ID"] ?? "",
|
||||||
|
]
|
||||||
|
var seen = Set<String>()
|
||||||
|
return candidates
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
.filter { seen.insert($0).inserted }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hasEnvApiKey: Bool {
|
||||||
|
let raw = ProcessInfo.processInfo.environment["ELEVENLABS_API_KEY"] ?? ""
|
||||||
|
return !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
private var apiKeyStatusLabel: String {
|
||||||
|
if self.hasEnvApiKey { return "ElevenLabs API key: found (environment)" }
|
||||||
|
if !self.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
return "ElevenLabs API key: stored in config"
|
||||||
|
}
|
||||||
|
if self.gatewayApiKeyFound { return "ElevenLabs API key: found (gateway)" }
|
||||||
|
return "ElevenLabs API key: missing"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var apiKeyStatusColor: Color {
|
||||||
|
if self.hasEnvApiKey { return .green }
|
||||||
|
if !self.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return .green }
|
||||||
|
if self.gatewayApiKeyFound { return .green }
|
||||||
|
return .red
|
||||||
|
}
|
||||||
|
|
||||||
private var browserPathLabel: String? {
|
private var browserPathLabel: String? {
|
||||||
guard self.browserEnabled else { return nil }
|
guard self.browserEnabled else { return nil }
|
||||||
|
|
||||||
|
|||||||
@@ -22,13 +22,6 @@ final class ConnectionModeCoordinator {
|
|||||||
case .local:
|
case .local:
|
||||||
await RemoteTunnelManager.shared.stopAll()
|
await RemoteTunnelManager.shared.stopAll()
|
||||||
WebChatManager.shared.resetTunnels()
|
WebChatManager.shared.resetTunnels()
|
||||||
do {
|
|
||||||
try await ControlChannel.shared.configure(mode: .local)
|
|
||||||
} catch {
|
|
||||||
// Control channel will mark itself degraded; nothing else to do here.
|
|
||||||
self.logger.error(
|
|
||||||
"control channel local configure failed: \(error.localizedDescription, privacy: .public)")
|
|
||||||
}
|
|
||||||
let shouldStart = GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: paused)
|
let shouldStart = GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: paused)
|
||||||
if shouldStart {
|
if shouldStart {
|
||||||
GatewayProcessManager.shared.setActive(true)
|
GatewayProcessManager.shared.setActive(true)
|
||||||
@@ -39,9 +32,17 @@ final class ConnectionModeCoordinator {
|
|||||||
{
|
{
|
||||||
Task { await GatewayProcessManager.shared.ensureLaunchAgentEnabledIfNeeded() }
|
Task { await GatewayProcessManager.shared.ensureLaunchAgentEnabledIfNeeded() }
|
||||||
}
|
}
|
||||||
|
_ = await GatewayProcessManager.shared.waitForGatewayReady()
|
||||||
} else {
|
} else {
|
||||||
GatewayProcessManager.shared.stop()
|
GatewayProcessManager.shared.stop()
|
||||||
}
|
}
|
||||||
|
do {
|
||||||
|
try await ControlChannel.shared.configure(mode: .local)
|
||||||
|
} catch {
|
||||||
|
// Control channel will mark itself degraded; nothing else to do here.
|
||||||
|
self.logger.error(
|
||||||
|
"control channel local configure failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
Task.detached { await PortGuardian.shared.sweep(mode: .local) }
|
Task.detached { await PortGuardian.shared.sweep(mode: .local) }
|
||||||
|
|
||||||
case .remote:
|
case .remote:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import SwiftUI
|
|||||||
struct ConnectionsSettings: View {
|
struct ConnectionsSettings: View {
|
||||||
@Bindable var store: ConnectionsStore
|
@Bindable var store: ConnectionsStore
|
||||||
@State private var showTelegramToken = false
|
@State private var showTelegramToken = false
|
||||||
|
@State private var showDiscordToken = false
|
||||||
|
|
||||||
init(store: ConnectionsStore = .shared) {
|
init(store: ConnectionsStore = .shared) {
|
||||||
self.store = store
|
self.store = store
|
||||||
@@ -15,6 +16,7 @@ struct ConnectionsSettings: View {
|
|||||||
self.header
|
self.header
|
||||||
self.whatsAppSection
|
self.whatsAppSection
|
||||||
self.telegramSection
|
self.telegramSection
|
||||||
|
self.discordSection
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
@@ -29,7 +31,7 @@ struct ConnectionsSettings: View {
|
|||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text("Connections")
|
Text("Connections")
|
||||||
.font(.title3.weight(.semibold))
|
.font(.title3.weight(.semibold))
|
||||||
Text("Link and monitor WhatsApp and Telegram providers.")
|
Text("Link and monitor WhatsApp, Telegram, and Discord providers.")
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -216,6 +218,107 @@ struct ConnectionsSettings: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var discordSection: some View {
|
||||||
|
GroupBox("Discord") {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
self.providerHeader(
|
||||||
|
title: "Discord Bot",
|
||||||
|
color: self.discordTint,
|
||||||
|
subtitle: self.discordSummary)
|
||||||
|
|
||||||
|
if let details = self.discordDetails {
|
||||||
|
Text(details)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let status = self.store.configStatus {
|
||||||
|
Text(status)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider().padding(.vertical, 2)
|
||||||
|
|
||||||
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Bot token")
|
||||||
|
if self.showDiscordToken {
|
||||||
|
TextField("bot token", text: self.$store.discordToken)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.disabled(self.isDiscordTokenLocked)
|
||||||
|
} else {
|
||||||
|
SecureField("bot token", text: self.$store.discordToken)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.disabled(self.isDiscordTokenLocked)
|
||||||
|
}
|
||||||
|
Toggle("Show", isOn: self.$showDiscordToken)
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
.disabled(self.isDiscordTokenLocked)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Require mention")
|
||||||
|
Toggle("", isOn: self.$store.discordRequireMention)
|
||||||
|
.labelsHidden()
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Allow from")
|
||||||
|
TextField("discord:123, user:456", text: self.$store.discordAllowFrom)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Allow guilds")
|
||||||
|
TextField("guildId1, guildId2", text: self.$store.discordGuildAllowFrom)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Allow guild users")
|
||||||
|
TextField("userId1, userId2", text: self.$store.discordGuildUsersAllowFrom)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Media max MB")
|
||||||
|
TextField("8", text: self.$store.discordMediaMaxMb)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.isDiscordTokenLocked {
|
||||||
|
Text("Token set via DISCORD_BOT_TOKEN env; config edits won’t override it.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button {
|
||||||
|
Task { await self.store.saveDiscordConfig() }
|
||||||
|
} label: {
|
||||||
|
if self.store.isSavingConfig {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
} else {
|
||||||
|
Text("Save")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(self.store.isSavingConfig)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button("Refresh") {
|
||||||
|
Task { await self.store.refresh(probe: true) }
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.disabled(self.store.isRefreshing)
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var whatsAppTint: Color {
|
private var whatsAppTint: Color {
|
||||||
guard let status = self.store.snapshot?.whatsapp else { return .secondary }
|
guard let status = self.store.snapshot?.whatsapp else { return .secondary }
|
||||||
if !status.linked { return .red }
|
if !status.linked { return .red }
|
||||||
@@ -232,6 +335,14 @@ struct ConnectionsSettings: View {
|
|||||||
return .secondary
|
return .secondary
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var discordTint: Color {
|
||||||
|
guard let status = self.store.snapshot?.discord else { return .secondary }
|
||||||
|
if !status.configured { return .secondary }
|
||||||
|
if status.running { return .green }
|
||||||
|
if status.lastError != nil { return .orange }
|
||||||
|
return .secondary
|
||||||
|
}
|
||||||
|
|
||||||
private var whatsAppSummary: String {
|
private var whatsAppSummary: String {
|
||||||
guard let status = self.store.snapshot?.whatsapp else { return "Checking…" }
|
guard let status = self.store.snapshot?.whatsapp else { return "Checking…" }
|
||||||
if !status.linked { return "Not linked" }
|
if !status.linked { return "Not linked" }
|
||||||
@@ -247,6 +358,13 @@ struct ConnectionsSettings: View {
|
|||||||
return "Configured"
|
return "Configured"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var discordSummary: String {
|
||||||
|
guard let status = self.store.snapshot?.discord else { return "Checking…" }
|
||||||
|
if !status.configured { return "Not configured" }
|
||||||
|
if status.running { return "Running" }
|
||||||
|
return "Configured"
|
||||||
|
}
|
||||||
|
|
||||||
private var whatsAppDetails: String? {
|
private var whatsAppDetails: String? {
|
||||||
guard let status = self.store.snapshot?.whatsapp else { return nil }
|
guard let status = self.store.snapshot?.whatsapp else { return nil }
|
||||||
var lines: [String] = []
|
var lines: [String] = []
|
||||||
@@ -308,10 +426,42 @@ struct ConnectionsSettings: View {
|
|||||||
return lines.isEmpty ? nil : lines.joined(separator: " · ")
|
return lines.isEmpty ? nil : lines.joined(separator: " · ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var discordDetails: String? {
|
||||||
|
guard let status = self.store.snapshot?.discord else { return nil }
|
||||||
|
var lines: [String] = []
|
||||||
|
if let source = status.tokenSource {
|
||||||
|
lines.append("Token source: \(source)")
|
||||||
|
}
|
||||||
|
if let probe = status.probe {
|
||||||
|
if probe.ok {
|
||||||
|
if let name = probe.bot?.username {
|
||||||
|
lines.append("Bot: @\(name)")
|
||||||
|
}
|
||||||
|
if let elapsed = probe.elapsedMs {
|
||||||
|
lines.append("Probe \(Int(elapsed))ms")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let code = probe.status.map { String($0) } ?? "unknown"
|
||||||
|
lines.append("Probe failed (\(code))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let last = self.date(fromMs: status.lastProbeAt) {
|
||||||
|
lines.append("Last probe \(relativeAge(from: last))")
|
||||||
|
}
|
||||||
|
if let err = status.lastError, !err.isEmpty {
|
||||||
|
lines.append("Error: \(err)")
|
||||||
|
}
|
||||||
|
return lines.isEmpty ? nil : lines.joined(separator: " · ")
|
||||||
|
}
|
||||||
|
|
||||||
private var isTelegramTokenLocked: Bool {
|
private var isTelegramTokenLocked: Bool {
|
||||||
self.store.snapshot?.telegram.tokenSource == "env"
|
self.store.snapshot?.telegram.tokenSource == "env"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var isDiscordTokenLocked: Bool {
|
||||||
|
self.store.snapshot?.discord?.tokenSource == "env"
|
||||||
|
}
|
||||||
|
|
||||||
private func providerHeader(title: String, color: Color, subtitle: String) -> some View {
|
private func providerHeader(title: String, color: Color, subtitle: String) -> some View {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
Circle()
|
Circle()
|
||||||
|
|||||||
@@ -61,9 +61,34 @@ struct ProvidersStatusSnapshot: Codable {
|
|||||||
let lastProbeAt: Double?
|
let lastProbeAt: Double?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct DiscordBot: Codable {
|
||||||
|
let id: String?
|
||||||
|
let username: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DiscordProbe: Codable {
|
||||||
|
let ok: Bool
|
||||||
|
let status: Int?
|
||||||
|
let error: String?
|
||||||
|
let elapsedMs: Double?
|
||||||
|
let bot: DiscordBot?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DiscordStatus: Codable {
|
||||||
|
let configured: Bool
|
||||||
|
let tokenSource: String?
|
||||||
|
let running: Bool
|
||||||
|
let lastStartAt: Double?
|
||||||
|
let lastStopAt: Double?
|
||||||
|
let lastError: String?
|
||||||
|
let probe: DiscordProbe?
|
||||||
|
let lastProbeAt: Double?
|
||||||
|
}
|
||||||
|
|
||||||
let ts: Double
|
let ts: Double
|
||||||
let whatsapp: WhatsAppStatus
|
let whatsapp: WhatsAppStatus
|
||||||
let telegram: TelegramStatus
|
let telegram: TelegramStatus
|
||||||
|
let discord: DiscordStatus?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ConfigSnapshot: Codable {
|
struct ConfigSnapshot: Codable {
|
||||||
@@ -104,6 +129,12 @@ final class ConnectionsStore {
|
|||||||
var telegramWebhookSecret: String = ""
|
var telegramWebhookSecret: String = ""
|
||||||
var telegramWebhookPath: String = ""
|
var telegramWebhookPath: String = ""
|
||||||
var telegramBusy = false
|
var telegramBusy = false
|
||||||
|
var discordToken: String = ""
|
||||||
|
var discordRequireMention = true
|
||||||
|
var discordAllowFrom: String = ""
|
||||||
|
var discordGuildAllowFrom: String = ""
|
||||||
|
var discordGuildUsersAllowFrom: String = ""
|
||||||
|
var discordMediaMaxMb: String = ""
|
||||||
var configStatus: String?
|
var configStatus: String?
|
||||||
var isSavingConfig = false
|
var isSavingConfig = false
|
||||||
|
|
||||||
@@ -263,6 +294,11 @@ final class ConnectionsStore {
|
|||||||
: nil
|
: nil
|
||||||
self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:]
|
self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:]
|
||||||
self.configLoaded = true
|
self.configLoaded = true
|
||||||
|
|
||||||
|
let ui = snap.config?["ui"]?.dictionaryValue
|
||||||
|
let rawSeam = ui?["seamColor"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam
|
||||||
|
|
||||||
let telegram = snap.config?["telegram"]?.dictionaryValue
|
let telegram = snap.config?["telegram"]?.dictionaryValue
|
||||||
self.telegramToken = telegram?["botToken"]?.stringValue ?? ""
|
self.telegramToken = telegram?["botToken"]?.stringValue ?? ""
|
||||||
self.telegramRequireMention = telegram?["requireMention"]?.boolValue ?? true
|
self.telegramRequireMention = telegram?["requireMention"]?.boolValue ?? true
|
||||||
@@ -281,6 +317,53 @@ final class ConnectionsStore {
|
|||||||
self.telegramWebhookUrl = telegram?["webhookUrl"]?.stringValue ?? ""
|
self.telegramWebhookUrl = telegram?["webhookUrl"]?.stringValue ?? ""
|
||||||
self.telegramWebhookSecret = telegram?["webhookSecret"]?.stringValue ?? ""
|
self.telegramWebhookSecret = telegram?["webhookSecret"]?.stringValue ?? ""
|
||||||
self.telegramWebhookPath = telegram?["webhookPath"]?.stringValue ?? ""
|
self.telegramWebhookPath = telegram?["webhookPath"]?.stringValue ?? ""
|
||||||
|
|
||||||
|
let discord = snap.config?["discord"]?.dictionaryValue
|
||||||
|
self.discordToken = discord?["token"]?.stringValue ?? ""
|
||||||
|
self.discordRequireMention = discord?["requireMention"]?.boolValue ?? true
|
||||||
|
if let allow = discord?["allowFrom"]?.arrayValue {
|
||||||
|
let strings = allow.compactMap { entry -> String? in
|
||||||
|
if let str = entry.stringValue { return str }
|
||||||
|
if let intVal = entry.intValue { return String(intVal) }
|
||||||
|
if let doubleVal = entry.doubleValue { return String(Int(doubleVal)) }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.discordAllowFrom = strings.joined(separator: ", ")
|
||||||
|
} else {
|
||||||
|
self.discordAllowFrom = ""
|
||||||
|
}
|
||||||
|
if let guildAllow = discord?["guildAllowFrom"]?.dictionaryValue {
|
||||||
|
if let guilds = guildAllow["guilds"]?.arrayValue {
|
||||||
|
let strings = guilds.compactMap { entry -> String? in
|
||||||
|
if let str = entry.stringValue { return str }
|
||||||
|
if let intVal = entry.intValue { return String(intVal) }
|
||||||
|
if let doubleVal = entry.doubleValue { return String(Int(doubleVal)) }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.discordGuildAllowFrom = strings.joined(separator: ", ")
|
||||||
|
} else {
|
||||||
|
self.discordGuildAllowFrom = ""
|
||||||
|
}
|
||||||
|
if let users = guildAllow["users"]?.arrayValue {
|
||||||
|
let strings = users.compactMap { entry -> String? in
|
||||||
|
if let str = entry.stringValue { return str }
|
||||||
|
if let intVal = entry.intValue { return String(intVal) }
|
||||||
|
if let doubleVal = entry.doubleValue { return String(Int(doubleVal)) }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.discordGuildUsersAllowFrom = strings.joined(separator: ", ")
|
||||||
|
} else {
|
||||||
|
self.discordGuildUsersAllowFrom = ""
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.discordGuildAllowFrom = ""
|
||||||
|
self.discordGuildUsersAllowFrom = ""
|
||||||
|
}
|
||||||
|
if let media = discord?["mediaMaxMb"]?.doubleValue ?? discord?["mediaMaxMb"]?.intValue.map(Double.init) {
|
||||||
|
self.discordMediaMaxMb = String(Int(media))
|
||||||
|
} else {
|
||||||
|
self.discordMediaMaxMb = ""
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
self.configStatus = error.localizedDescription
|
self.configStatus = error.localizedDescription
|
||||||
}
|
}
|
||||||
@@ -371,6 +454,94 @@ final class ConnectionsStore {
|
|||||||
self.configStatus = error.localizedDescription
|
self.configStatus = error.localizedDescription
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func saveDiscordConfig() async {
|
||||||
|
guard !self.isSavingConfig else { return }
|
||||||
|
self.isSavingConfig = true
|
||||||
|
defer { self.isSavingConfig = false }
|
||||||
|
if !self.configLoaded {
|
||||||
|
await self.loadConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
var discord: [String: Any] = (self.configRoot["discord"] as? [String: Any]) ?? [:]
|
||||||
|
let token = self.discordToken.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if token.isEmpty {
|
||||||
|
discord.removeValue(forKey: "token")
|
||||||
|
} else {
|
||||||
|
discord["token"] = token
|
||||||
|
}
|
||||||
|
|
||||||
|
discord["requireMention"] = self.discordRequireMention
|
||||||
|
|
||||||
|
let allow = self.discordAllowFrom
|
||||||
|
.split(separator: ",")
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
if allow.isEmpty {
|
||||||
|
discord.removeValue(forKey: "allowFrom")
|
||||||
|
} else {
|
||||||
|
discord["allowFrom"] = allow
|
||||||
|
}
|
||||||
|
|
||||||
|
var guildAllow: [String: Any] = (discord["guildAllowFrom"] as? [String: Any]) ?? [:]
|
||||||
|
let guilds = self.discordGuildAllowFrom
|
||||||
|
.split(separator: ",")
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
if guilds.isEmpty {
|
||||||
|
guildAllow.removeValue(forKey: "guilds")
|
||||||
|
} else {
|
||||||
|
guildAllow["guilds"] = guilds
|
||||||
|
}
|
||||||
|
|
||||||
|
let users = self.discordGuildUsersAllowFrom
|
||||||
|
.split(separator: ",")
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
if users.isEmpty {
|
||||||
|
guildAllow.removeValue(forKey: "users")
|
||||||
|
} else {
|
||||||
|
guildAllow["users"] = users
|
||||||
|
}
|
||||||
|
|
||||||
|
if guildAllow.isEmpty {
|
||||||
|
discord.removeValue(forKey: "guildAllowFrom")
|
||||||
|
} else {
|
||||||
|
discord["guildAllowFrom"] = guildAllow
|
||||||
|
}
|
||||||
|
|
||||||
|
let media = self.discordMediaMaxMb.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if media.isEmpty {
|
||||||
|
discord.removeValue(forKey: "mediaMaxMb")
|
||||||
|
} else if let value = Double(media) {
|
||||||
|
discord["mediaMaxMb"] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
if discord.isEmpty {
|
||||||
|
self.configRoot.removeValue(forKey: "discord")
|
||||||
|
} else {
|
||||||
|
self.configRoot["discord"] = discord
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let data = try JSONSerialization.data(
|
||||||
|
withJSONObject: self.configRoot,
|
||||||
|
options: [.prettyPrinted, .sortedKeys])
|
||||||
|
guard let raw = String(data: data, encoding: .utf8) else {
|
||||||
|
self.configStatus = "Failed to encode config."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
|
||||||
|
_ = try await GatewayConnection.shared.requestRaw(
|
||||||
|
method: .configSet,
|
||||||
|
params: params,
|
||||||
|
timeoutMs: 10000)
|
||||||
|
self.configStatus = "Saved to ~/.clawdis/clawdis.json."
|
||||||
|
await self.refresh(probe: true)
|
||||||
|
} catch {
|
||||||
|
self.configStatus = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct WhatsAppLoginStartResult: Codable {
|
private struct WhatsAppLoginStartResult: Codable {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ let voiceWakeMicKey = "clawdis.voiceWakeMicID"
|
|||||||
let voiceWakeLocaleKey = "clawdis.voiceWakeLocaleID"
|
let voiceWakeLocaleKey = "clawdis.voiceWakeLocaleID"
|
||||||
let voiceWakeAdditionalLocalesKey = "clawdis.voiceWakeAdditionalLocaleIDs"
|
let voiceWakeAdditionalLocalesKey = "clawdis.voiceWakeAdditionalLocaleIDs"
|
||||||
let voicePushToTalkEnabledKey = "clawdis.voicePushToTalkEnabled"
|
let voicePushToTalkEnabledKey = "clawdis.voicePushToTalkEnabled"
|
||||||
|
let talkEnabledKey = "clawdis.talkEnabled"
|
||||||
let iconOverrideKey = "clawdis.iconOverride"
|
let iconOverrideKey = "clawdis.iconOverride"
|
||||||
let connectionModeKey = "clawdis.connectionMode"
|
let connectionModeKey = "clawdis.connectionMode"
|
||||||
let remoteTargetKey = "clawdis.remoteTarget"
|
let remoteTargetKey = "clawdis.remoteTarget"
|
||||||
@@ -31,5 +32,6 @@ let modelCatalogReloadKey = "clawdis.modelCatalogReload"
|
|||||||
let attachExistingGatewayOnlyKey = "clawdis.gateway.attachExistingOnly"
|
let attachExistingGatewayOnlyKey = "clawdis.gateway.attachExistingOnly"
|
||||||
let heartbeatsEnabledKey = "clawdis.heartbeatsEnabled"
|
let heartbeatsEnabledKey = "clawdis.heartbeatsEnabled"
|
||||||
let debugFileLogEnabledKey = "clawdis.debug.fileLogEnabled"
|
let debugFileLogEnabledKey = "clawdis.debug.fileLogEnabled"
|
||||||
|
let appLogLevelKey = "clawdis.debug.appLogLevel"
|
||||||
let voiceWakeSupported: Bool = ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26
|
let voiceWakeSupported: Bool = ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26
|
||||||
let cliHelperSearchPaths = ["/usr/local/bin", "/opt/homebrew/bin"]
|
let cliHelperSearchPaths = ["/usr/local/bin", "/opt/homebrew/bin"]
|
||||||
@@ -12,6 +12,16 @@ struct ContextUsageBar: View {
|
|||||||
if match == .darkAqua { return base }
|
if match == .darkAqua { return base }
|
||||||
return base.blended(withFraction: 0.24, of: .black) ?? base
|
return base.blended(withFraction: 0.24, of: .black) ?? base
|
||||||
}
|
}
|
||||||
|
private static let trackFill: NSColor = .init(name: nil) { appearance in
|
||||||
|
let match = appearance.bestMatch(from: [.aqua, .darkAqua])
|
||||||
|
if match == .darkAqua { return NSColor.white.withAlphaComponent(0.14) }
|
||||||
|
return NSColor.black.withAlphaComponent(0.12)
|
||||||
|
}
|
||||||
|
private static let trackStroke: NSColor = .init(name: nil) { appearance in
|
||||||
|
let match = appearance.bestMatch(from: [.aqua, .darkAqua])
|
||||||
|
if match == .darkAqua { return NSColor.white.withAlphaComponent(0.22) }
|
||||||
|
return NSColor.black.withAlphaComponent(0.2)
|
||||||
|
}
|
||||||
|
|
||||||
private var clampedFractionUsed: Double {
|
private var clampedFractionUsed: Double {
|
||||||
guard self.contextTokens > 0 else { return 0 }
|
guard self.contextTokens > 0 else { return 0 }
|
||||||
@@ -58,8 +68,8 @@ struct ContextUsageBar: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func barBody(width: CGFloat, fraction: Double) -> some View {
|
private func barBody(width: CGFloat, fraction: Double) -> some View {
|
||||||
let radius = self.height / 2
|
let radius = self.height / 2
|
||||||
let trackFill = Color.white.opacity(0.12)
|
let trackFill = Color(nsColor: Self.trackFill)
|
||||||
let trackStroke = Color.white.opacity(0.18)
|
let trackStroke = Color(nsColor: Self.trackStroke)
|
||||||
let fillWidth = max(1, floor(width * CGFloat(fraction)))
|
let fillWidth = max(1, floor(width * CGFloat(fraction)))
|
||||||
|
|
||||||
ZStack(alignment: .leading) {
|
ZStack(alignment: .leading) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import ClawdisProtocol
|
import ClawdisProtocol
|
||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
import OSLog
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ControlHeartbeatEvent: Codable {
|
struct ControlHeartbeatEvent: Codable {
|
||||||
|
|||||||
@@ -0,0 +1,387 @@
|
|||||||
|
import AppKit
|
||||||
|
|
||||||
|
enum CritterIconRenderer {
|
||||||
|
private static let size = NSSize(width: 18, height: 18)
|
||||||
|
|
||||||
|
struct Badge {
|
||||||
|
let symbolName: String
|
||||||
|
let prominence: IconState.BadgeProminence
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Canvas {
|
||||||
|
let w: CGFloat
|
||||||
|
let h: CGFloat
|
||||||
|
let stepX: CGFloat
|
||||||
|
let stepY: CGFloat
|
||||||
|
let snapX: (CGFloat) -> CGFloat
|
||||||
|
let snapY: (CGFloat) -> CGFloat
|
||||||
|
let context: CGContext
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Geometry {
|
||||||
|
let bodyRect: CGRect
|
||||||
|
let bodyCorner: CGFloat
|
||||||
|
let leftEarRect: CGRect
|
||||||
|
let rightEarRect: CGRect
|
||||||
|
let earCorner: CGFloat
|
||||||
|
let earW: CGFloat
|
||||||
|
let earH: CGFloat
|
||||||
|
let legW: CGFloat
|
||||||
|
let legH: CGFloat
|
||||||
|
let legSpacing: CGFloat
|
||||||
|
let legStartX: CGFloat
|
||||||
|
let legYBase: CGFloat
|
||||||
|
let legLift: CGFloat
|
||||||
|
let legHeightScale: CGFloat
|
||||||
|
let eyeW: CGFloat
|
||||||
|
let eyeY: CGFloat
|
||||||
|
let eyeOffset: CGFloat
|
||||||
|
|
||||||
|
init(canvas: Canvas, legWiggle: CGFloat, earWiggle: CGFloat, earScale: CGFloat) {
|
||||||
|
let w = canvas.w
|
||||||
|
let h = canvas.h
|
||||||
|
let snapX = canvas.snapX
|
||||||
|
let snapY = canvas.snapY
|
||||||
|
|
||||||
|
let bodyW = snapX(w * 0.78)
|
||||||
|
let bodyH = snapY(h * 0.58)
|
||||||
|
let bodyX = snapX((w - bodyW) / 2)
|
||||||
|
let bodyY = snapY(h * 0.36)
|
||||||
|
let bodyCorner = snapX(w * 0.09)
|
||||||
|
|
||||||
|
let earW = snapX(w * 0.22)
|
||||||
|
let earH = snapY(bodyH * 0.54 * earScale * (1 - 0.08 * abs(earWiggle)))
|
||||||
|
let earCorner = snapX(earW * 0.24)
|
||||||
|
let leftEarRect = CGRect(
|
||||||
|
x: snapX(bodyX - earW * 0.55 + earWiggle),
|
||||||
|
y: snapY(bodyY + bodyH * 0.08 + earWiggle * 0.4),
|
||||||
|
width: earW,
|
||||||
|
height: earH)
|
||||||
|
let rightEarRect = CGRect(
|
||||||
|
x: snapX(bodyX + bodyW - earW * 0.45 - earWiggle),
|
||||||
|
y: snapY(bodyY + bodyH * 0.08 - earWiggle * 0.4),
|
||||||
|
width: earW,
|
||||||
|
height: earH)
|
||||||
|
|
||||||
|
let legW = snapX(w * 0.11)
|
||||||
|
let legH = snapY(h * 0.26)
|
||||||
|
let legSpacing = snapX(w * 0.085)
|
||||||
|
let legsWidth = snapX(4 * legW + 3 * legSpacing)
|
||||||
|
let legStartX = snapX((w - legsWidth) / 2)
|
||||||
|
let legLift = snapY(legH * 0.35 * legWiggle)
|
||||||
|
let legYBase = snapY(bodyY - legH + h * 0.05)
|
||||||
|
let legHeightScale = 1 - 0.12 * legWiggle
|
||||||
|
|
||||||
|
let eyeW = snapX(bodyW * 0.2)
|
||||||
|
let eyeY = snapY(bodyY + bodyH * 0.56)
|
||||||
|
let eyeOffset = snapX(bodyW * 0.24)
|
||||||
|
|
||||||
|
self.bodyRect = CGRect(x: bodyX, y: bodyY, width: bodyW, height: bodyH)
|
||||||
|
self.bodyCorner = bodyCorner
|
||||||
|
self.leftEarRect = leftEarRect
|
||||||
|
self.rightEarRect = rightEarRect
|
||||||
|
self.earCorner = earCorner
|
||||||
|
self.earW = earW
|
||||||
|
self.earH = earH
|
||||||
|
self.legW = legW
|
||||||
|
self.legH = legH
|
||||||
|
self.legSpacing = legSpacing
|
||||||
|
self.legStartX = legStartX
|
||||||
|
self.legYBase = legYBase
|
||||||
|
self.legLift = legLift
|
||||||
|
self.legHeightScale = legHeightScale
|
||||||
|
self.eyeW = eyeW
|
||||||
|
self.eyeY = eyeY
|
||||||
|
self.eyeOffset = eyeOffset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct FaceOptions {
|
||||||
|
let blink: CGFloat
|
||||||
|
let earHoles: Bool
|
||||||
|
let earScale: CGFloat
|
||||||
|
let eyesClosedLines: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
static func makeIcon(
|
||||||
|
blink: CGFloat,
|
||||||
|
legWiggle: CGFloat = 0,
|
||||||
|
earWiggle: CGFloat = 0,
|
||||||
|
earScale: CGFloat = 1,
|
||||||
|
earHoles: Bool = false,
|
||||||
|
eyesClosedLines: Bool = false,
|
||||||
|
badge: Badge? = nil) -> NSImage
|
||||||
|
{
|
||||||
|
guard let rep = self.makeBitmapRep() else {
|
||||||
|
return NSImage(size: self.size)
|
||||||
|
}
|
||||||
|
rep.size = self.size
|
||||||
|
|
||||||
|
NSGraphicsContext.saveGraphicsState()
|
||||||
|
defer { NSGraphicsContext.restoreGraphicsState() }
|
||||||
|
|
||||||
|
guard let context = NSGraphicsContext(bitmapImageRep: rep) else {
|
||||||
|
return NSImage(size: self.size)
|
||||||
|
}
|
||||||
|
NSGraphicsContext.current = context
|
||||||
|
context.imageInterpolation = .none
|
||||||
|
context.cgContext.setShouldAntialias(false)
|
||||||
|
|
||||||
|
let canvas = self.makeCanvas(for: rep, context: context)
|
||||||
|
let geometry = Geometry(canvas: canvas, legWiggle: legWiggle, earWiggle: earWiggle, earScale: earScale)
|
||||||
|
|
||||||
|
self.drawBody(in: canvas, geometry: geometry)
|
||||||
|
let face = FaceOptions(
|
||||||
|
blink: blink,
|
||||||
|
earHoles: earHoles,
|
||||||
|
earScale: earScale,
|
||||||
|
eyesClosedLines: eyesClosedLines)
|
||||||
|
self.drawFace(in: canvas, geometry: geometry, options: face)
|
||||||
|
|
||||||
|
if let badge {
|
||||||
|
self.drawBadge(badge, canvas: canvas)
|
||||||
|
}
|
||||||
|
|
||||||
|
let image = NSImage(size: size)
|
||||||
|
image.addRepresentation(rep)
|
||||||
|
image.isTemplate = true
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeBitmapRep() -> NSBitmapImageRep? {
|
||||||
|
// Force a 36×36px backing store (2× for the 18pt logical canvas) so the menu bar icon stays crisp on Retina.
|
||||||
|
let pixelsWide = 36
|
||||||
|
let pixelsHigh = 36
|
||||||
|
return NSBitmapImageRep(
|
||||||
|
bitmapDataPlanes: nil,
|
||||||
|
pixelsWide: pixelsWide,
|
||||||
|
pixelsHigh: pixelsHigh,
|
||||||
|
bitsPerSample: 8,
|
||||||
|
samplesPerPixel: 4,
|
||||||
|
hasAlpha: true,
|
||||||
|
isPlanar: false,
|
||||||
|
colorSpaceName: .deviceRGB,
|
||||||
|
bitmapFormat: [],
|
||||||
|
bytesPerRow: 0,
|
||||||
|
bitsPerPixel: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeCanvas(for rep: NSBitmapImageRep, context: NSGraphicsContext) -> Canvas {
|
||||||
|
let stepX = self.size.width / max(CGFloat(rep.pixelsWide), 1)
|
||||||
|
let stepY = self.size.height / max(CGFloat(rep.pixelsHigh), 1)
|
||||||
|
let snapX: (CGFloat) -> CGFloat = { ($0 / stepX).rounded() * stepX }
|
||||||
|
let snapY: (CGFloat) -> CGFloat = { ($0 / stepY).rounded() * stepY }
|
||||||
|
|
||||||
|
let w = snapX(size.width)
|
||||||
|
let h = snapY(size.height)
|
||||||
|
|
||||||
|
return Canvas(
|
||||||
|
w: w,
|
||||||
|
h: h,
|
||||||
|
stepX: stepX,
|
||||||
|
stepY: stepY,
|
||||||
|
snapX: snapX,
|
||||||
|
snapY: snapY,
|
||||||
|
context: context.cgContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func drawBody(in canvas: Canvas, geometry: Geometry) {
|
||||||
|
canvas.context.setFillColor(NSColor.labelColor.cgColor)
|
||||||
|
|
||||||
|
canvas.context.addPath(CGPath(
|
||||||
|
roundedRect: geometry.bodyRect,
|
||||||
|
cornerWidth: geometry.bodyCorner,
|
||||||
|
cornerHeight: geometry.bodyCorner,
|
||||||
|
transform: nil))
|
||||||
|
canvas.context.addPath(CGPath(
|
||||||
|
roundedRect: geometry.leftEarRect,
|
||||||
|
cornerWidth: geometry.earCorner,
|
||||||
|
cornerHeight: geometry.earCorner,
|
||||||
|
transform: nil))
|
||||||
|
canvas.context.addPath(CGPath(
|
||||||
|
roundedRect: geometry.rightEarRect,
|
||||||
|
cornerWidth: geometry.earCorner,
|
||||||
|
cornerHeight: geometry.earCorner,
|
||||||
|
transform: nil))
|
||||||
|
|
||||||
|
for i in 0..<4 {
|
||||||
|
let x = geometry.legStartX + CGFloat(i) * (geometry.legW + geometry.legSpacing)
|
||||||
|
let lift = i % 2 == 0 ? geometry.legLift : -geometry.legLift
|
||||||
|
let rect = CGRect(
|
||||||
|
x: x,
|
||||||
|
y: geometry.legYBase + lift,
|
||||||
|
width: geometry.legW,
|
||||||
|
height: geometry.legH * geometry.legHeightScale)
|
||||||
|
canvas.context.addPath(CGPath(
|
||||||
|
roundedRect: rect,
|
||||||
|
cornerWidth: geometry.legW * 0.34,
|
||||||
|
cornerHeight: geometry.legW * 0.34,
|
||||||
|
transform: nil))
|
||||||
|
}
|
||||||
|
canvas.context.fillPath()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func drawFace(
|
||||||
|
in canvas: Canvas,
|
||||||
|
geometry: Geometry,
|
||||||
|
options: FaceOptions)
|
||||||
|
{
|
||||||
|
canvas.context.saveGState()
|
||||||
|
canvas.context.setBlendMode(.clear)
|
||||||
|
|
||||||
|
let leftCenter = CGPoint(
|
||||||
|
x: canvas.snapX(canvas.w / 2 - geometry.eyeOffset),
|
||||||
|
y: canvas.snapY(geometry.eyeY))
|
||||||
|
let rightCenter = CGPoint(
|
||||||
|
x: canvas.snapX(canvas.w / 2 + geometry.eyeOffset),
|
||||||
|
y: canvas.snapY(geometry.eyeY))
|
||||||
|
|
||||||
|
if options.earHoles || options.earScale > 1.05 {
|
||||||
|
let holeW = canvas.snapX(geometry.earW * 0.6)
|
||||||
|
let holeH = canvas.snapY(geometry.earH * 0.46)
|
||||||
|
let holeCorner = canvas.snapX(holeW * 0.34)
|
||||||
|
let leftHoleRect = CGRect(
|
||||||
|
x: canvas.snapX(geometry.leftEarRect.midX - holeW / 2),
|
||||||
|
y: canvas.snapY(geometry.leftEarRect.midY - holeH / 2 + geometry.earH * 0.04),
|
||||||
|
width: holeW,
|
||||||
|
height: holeH)
|
||||||
|
let rightHoleRect = CGRect(
|
||||||
|
x: canvas.snapX(geometry.rightEarRect.midX - holeW / 2),
|
||||||
|
y: canvas.snapY(geometry.rightEarRect.midY - holeH / 2 + geometry.earH * 0.04),
|
||||||
|
width: holeW,
|
||||||
|
height: holeH)
|
||||||
|
|
||||||
|
canvas.context.addPath(CGPath(
|
||||||
|
roundedRect: leftHoleRect,
|
||||||
|
cornerWidth: holeCorner,
|
||||||
|
cornerHeight: holeCorner,
|
||||||
|
transform: nil))
|
||||||
|
canvas.context.addPath(CGPath(
|
||||||
|
roundedRect: rightHoleRect,
|
||||||
|
cornerWidth: holeCorner,
|
||||||
|
cornerHeight: holeCorner,
|
||||||
|
transform: nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
if options.eyesClosedLines {
|
||||||
|
let lineW = canvas.snapX(geometry.eyeW * 0.95)
|
||||||
|
let lineH = canvas.snapY(max(canvas.stepY * 2, geometry.bodyRect.height * 0.06))
|
||||||
|
let corner = canvas.snapX(lineH * 0.6)
|
||||||
|
let leftRect = CGRect(
|
||||||
|
x: canvas.snapX(leftCenter.x - lineW / 2),
|
||||||
|
y: canvas.snapY(leftCenter.y - lineH / 2),
|
||||||
|
width: lineW,
|
||||||
|
height: lineH)
|
||||||
|
let rightRect = CGRect(
|
||||||
|
x: canvas.snapX(rightCenter.x - lineW / 2),
|
||||||
|
y: canvas.snapY(rightCenter.y - lineH / 2),
|
||||||
|
width: lineW,
|
||||||
|
height: lineH)
|
||||||
|
canvas.context.addPath(CGPath(
|
||||||
|
roundedRect: leftRect,
|
||||||
|
cornerWidth: corner,
|
||||||
|
cornerHeight: corner,
|
||||||
|
transform: nil))
|
||||||
|
canvas.context.addPath(CGPath(
|
||||||
|
roundedRect: rightRect,
|
||||||
|
cornerWidth: corner,
|
||||||
|
cornerHeight: corner,
|
||||||
|
transform: nil))
|
||||||
|
} else {
|
||||||
|
let eyeOpen = max(0.05, 1 - options.blink)
|
||||||
|
let eyeH = canvas.snapY(geometry.bodyRect.height * 0.26 * eyeOpen)
|
||||||
|
|
||||||
|
let left = CGMutablePath()
|
||||||
|
left.move(to: CGPoint(
|
||||||
|
x: canvas.snapX(leftCenter.x - geometry.eyeW / 2),
|
||||||
|
y: canvas.snapY(leftCenter.y - eyeH)))
|
||||||
|
left.addLine(to: CGPoint(
|
||||||
|
x: canvas.snapX(leftCenter.x + geometry.eyeW / 2),
|
||||||
|
y: canvas.snapY(leftCenter.y)))
|
||||||
|
left.addLine(to: CGPoint(
|
||||||
|
x: canvas.snapX(leftCenter.x - geometry.eyeW / 2),
|
||||||
|
y: canvas.snapY(leftCenter.y + eyeH)))
|
||||||
|
left.closeSubpath()
|
||||||
|
|
||||||
|
let right = CGMutablePath()
|
||||||
|
right.move(to: CGPoint(
|
||||||
|
x: canvas.snapX(rightCenter.x + geometry.eyeW / 2),
|
||||||
|
y: canvas.snapY(rightCenter.y - eyeH)))
|
||||||
|
right.addLine(to: CGPoint(
|
||||||
|
x: canvas.snapX(rightCenter.x - geometry.eyeW / 2),
|
||||||
|
y: canvas.snapY(rightCenter.y)))
|
||||||
|
right.addLine(to: CGPoint(
|
||||||
|
x: canvas.snapX(rightCenter.x + geometry.eyeW / 2),
|
||||||
|
y: canvas.snapY(rightCenter.y + eyeH)))
|
||||||
|
right.closeSubpath()
|
||||||
|
|
||||||
|
canvas.context.addPath(left)
|
||||||
|
canvas.context.addPath(right)
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.context.fillPath()
|
||||||
|
canvas.context.restoreGState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func drawBadge(_ badge: Badge, canvas: Canvas) {
|
||||||
|
let strength: CGFloat = switch badge.prominence {
|
||||||
|
case .primary: 1.0
|
||||||
|
case .secondary: 0.58
|
||||||
|
case .overridden: 0.85
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bigger, higher-contrast badge:
|
||||||
|
// - Increase diameter so tool activity is noticeable.
|
||||||
|
// - Draw a filled "puck", then knock out the symbol shape (transparent hole).
|
||||||
|
// This reads better in template-rendered menu bar icons than tiny monochrome glyphs.
|
||||||
|
let diameter = canvas.snapX(canvas.w * 0.52 * (0.92 + 0.08 * strength)) // ~9–10pt on an 18pt canvas
|
||||||
|
let margin = canvas.snapX(max(0.45, canvas.w * 0.03))
|
||||||
|
let rect = CGRect(
|
||||||
|
x: canvas.snapX(canvas.w - diameter - margin),
|
||||||
|
y: canvas.snapY(margin),
|
||||||
|
width: diameter,
|
||||||
|
height: diameter)
|
||||||
|
|
||||||
|
canvas.context.saveGState()
|
||||||
|
canvas.context.setShouldAntialias(true)
|
||||||
|
|
||||||
|
// Clear the underlying pixels so the badge stays readable over the critter.
|
||||||
|
canvas.context.saveGState()
|
||||||
|
canvas.context.setBlendMode(.clear)
|
||||||
|
canvas.context.addEllipse(in: rect.insetBy(dx: -1.0, dy: -1.0))
|
||||||
|
canvas.context.fillPath()
|
||||||
|
canvas.context.restoreGState()
|
||||||
|
|
||||||
|
let fillAlpha: CGFloat = min(1.0, 0.36 + 0.24 * strength)
|
||||||
|
let strokeAlpha: CGFloat = min(1.0, 0.78 + 0.22 * strength)
|
||||||
|
|
||||||
|
canvas.context.setFillColor(NSColor.labelColor.withAlphaComponent(fillAlpha).cgColor)
|
||||||
|
canvas.context.addEllipse(in: rect)
|
||||||
|
canvas.context.fillPath()
|
||||||
|
|
||||||
|
canvas.context.setStrokeColor(NSColor.labelColor.withAlphaComponent(strokeAlpha).cgColor)
|
||||||
|
canvas.context.setLineWidth(max(1.25, canvas.snapX(canvas.w * 0.075)))
|
||||||
|
canvas.context.strokeEllipse(in: rect.insetBy(dx: 0.45, dy: 0.45))
|
||||||
|
|
||||||
|
if let base = NSImage(systemSymbolName: badge.symbolName, accessibilityDescription: nil) {
|
||||||
|
let pointSize = max(7.0, diameter * 0.82)
|
||||||
|
let config = NSImage.SymbolConfiguration(pointSize: pointSize, weight: .black)
|
||||||
|
let symbol = base.withSymbolConfiguration(config) ?? base
|
||||||
|
symbol.isTemplate = true
|
||||||
|
|
||||||
|
let symbolRect = rect.insetBy(dx: diameter * 0.17, dy: diameter * 0.17)
|
||||||
|
canvas.context.saveGState()
|
||||||
|
canvas.context.setBlendMode(.clear)
|
||||||
|
symbol.draw(
|
||||||
|
in: symbolRect,
|
||||||
|
from: .zero,
|
||||||
|
operation: .sourceOver,
|
||||||
|
fraction: 1,
|
||||||
|
respectFlipped: true,
|
||||||
|
hints: nil)
|
||||||
|
canvas.context.restoreGState()
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.context.restoreGState()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension CritterStatusLabel {
|
||||||
|
private var isWorkingNow: Bool {
|
||||||
|
self.iconState.isWorking || self.isWorking
|
||||||
|
}
|
||||||
|
|
||||||
|
private var effectiveAnimationsEnabled: Bool {
|
||||||
|
self.animationsEnabled && !self.isSleeping
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .topTrailing) {
|
||||||
|
self.iconImage
|
||||||
|
.frame(width: 18, height: 18)
|
||||||
|
.rotationEffect(.degrees(self.wiggleAngle), anchor: .center)
|
||||||
|
.offset(x: self.wiggleOffset)
|
||||||
|
// Avoid Combine's TimerPublisher here: on macOS 26.2 we've seen crashes inside executor checks
|
||||||
|
// triggered by its callbacks. Drive periodic updates via a Swift-concurrency task instead.
|
||||||
|
.task(id: self.tickTaskID) {
|
||||||
|
guard self.effectiveAnimationsEnabled, !self.earBoostActive else {
|
||||||
|
await MainActor.run { self.resetMotion() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
while !Task.isCancelled {
|
||||||
|
let now = Date()
|
||||||
|
await MainActor.run { self.tick(now) }
|
||||||
|
try? await Task.sleep(nanoseconds: 350_000_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: self.isPaused) { _, _ in self.resetMotion() }
|
||||||
|
.onChange(of: self.blinkTick) { _, _ in
|
||||||
|
guard self.effectiveAnimationsEnabled, !self.earBoostActive else { return }
|
||||||
|
self.blink()
|
||||||
|
}
|
||||||
|
.onChange(of: self.sendCelebrationTick) { _, _ in
|
||||||
|
guard self.effectiveAnimationsEnabled, !self.earBoostActive else { return }
|
||||||
|
self.wiggleLegs()
|
||||||
|
}
|
||||||
|
.onChange(of: self.animationsEnabled) { _, enabled in
|
||||||
|
if enabled, !self.isSleeping {
|
||||||
|
self.scheduleRandomTimers(from: Date())
|
||||||
|
} else {
|
||||||
|
self.resetMotion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: self.isSleeping) { _, _ in
|
||||||
|
self.resetMotion()
|
||||||
|
}
|
||||||
|
.onChange(of: self.earBoostActive) { _, active in
|
||||||
|
if active {
|
||||||
|
self.resetMotion()
|
||||||
|
} else if self.effectiveAnimationsEnabled {
|
||||||
|
self.scheduleRandomTimers(from: Date())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.gatewayNeedsAttention {
|
||||||
|
Circle()
|
||||||
|
.fill(self.gatewayBadgeColor)
|
||||||
|
.frame(width: 6, height: 6)
|
||||||
|
.padding(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 18, height: 18)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var tickTaskID: Int {
|
||||||
|
// Ensure SwiftUI restarts (and cancels) the task when these change.
|
||||||
|
(self.effectiveAnimationsEnabled ? 1 : 0) | (self.earBoostActive ? 2 : 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tick(_ now: Date) {
|
||||||
|
guard self.effectiveAnimationsEnabled, !self.earBoostActive else {
|
||||||
|
self.resetMotion()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if now >= self.nextBlink {
|
||||||
|
self.blink()
|
||||||
|
self.nextBlink = now.addingTimeInterval(Double.random(in: 3.5...8.5))
|
||||||
|
}
|
||||||
|
|
||||||
|
if now >= self.nextWiggle {
|
||||||
|
self.wiggle()
|
||||||
|
self.nextWiggle = now.addingTimeInterval(Double.random(in: 6.5...14))
|
||||||
|
}
|
||||||
|
|
||||||
|
if now >= self.nextLegWiggle {
|
||||||
|
self.wiggleLegs()
|
||||||
|
self.nextLegWiggle = now.addingTimeInterval(Double.random(in: 5.0...11.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
if now >= self.nextEarWiggle {
|
||||||
|
self.wiggleEars()
|
||||||
|
self.nextEarWiggle = now.addingTimeInterval(Double.random(in: 7.0...14.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.isWorkingNow {
|
||||||
|
self.scurry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var iconImage: Image {
|
||||||
|
let badge: CritterIconRenderer.Badge? = if let prominence = self.iconState.badgeProminence, !self.isPaused {
|
||||||
|
CritterIconRenderer.Badge(
|
||||||
|
symbolName: self.iconState.badgeSymbolName,
|
||||||
|
prominence: prominence)
|
||||||
|
} else {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.isPaused {
|
||||||
|
return Image(nsImage: CritterIconRenderer.makeIcon(blink: 0, badge: nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.isSleeping {
|
||||||
|
return Image(nsImage: CritterIconRenderer.makeIcon(blink: 1, eyesClosedLines: true, badge: nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
return Image(nsImage: CritterIconRenderer.makeIcon(
|
||||||
|
blink: self.blinkAmount,
|
||||||
|
legWiggle: max(self.legWiggle, self.isWorkingNow ? 0.6 : 0),
|
||||||
|
earWiggle: self.earWiggle,
|
||||||
|
earScale: self.earBoostActive ? 1.9 : 1.0,
|
||||||
|
earHoles: self.earBoostActive,
|
||||||
|
badge: badge))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resetMotion() {
|
||||||
|
self.blinkAmount = 0
|
||||||
|
self.wiggleAngle = 0
|
||||||
|
self.wiggleOffset = 0
|
||||||
|
self.legWiggle = 0
|
||||||
|
self.earWiggle = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func blink() {
|
||||||
|
withAnimation(.easeInOut(duration: 0.08)) { self.blinkAmount = 1 }
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await Task.sleep(nanoseconds: 160_000_000)
|
||||||
|
withAnimation(.easeOut(duration: 0.12)) { self.blinkAmount = 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func wiggle() {
|
||||||
|
let targetAngle = Double.random(in: -4.5...4.5)
|
||||||
|
let targetOffset = CGFloat.random(in: -0.5...0.5)
|
||||||
|
withAnimation(.interpolatingSpring(stiffness: 220, damping: 18)) {
|
||||||
|
self.wiggleAngle = targetAngle
|
||||||
|
self.wiggleOffset = targetOffset
|
||||||
|
}
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await Task.sleep(nanoseconds: 360_000_000)
|
||||||
|
withAnimation(.interpolatingSpring(stiffness: 220, damping: 18)) {
|
||||||
|
self.wiggleAngle = 0
|
||||||
|
self.wiggleOffset = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func wiggleLegs() {
|
||||||
|
let target = CGFloat.random(in: 0.35...0.9)
|
||||||
|
withAnimation(.easeInOut(duration: 0.14)) {
|
||||||
|
self.legWiggle = target
|
||||||
|
}
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await Task.sleep(nanoseconds: 220_000_000)
|
||||||
|
withAnimation(.easeOut(duration: 0.18)) { self.legWiggle = 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scurry() {
|
||||||
|
let target = CGFloat.random(in: 0.7...1.0)
|
||||||
|
withAnimation(.easeInOut(duration: 0.12)) {
|
||||||
|
self.legWiggle = target
|
||||||
|
self.wiggleOffset = CGFloat.random(in: -0.6...0.6)
|
||||||
|
}
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await Task.sleep(nanoseconds: 180_000_000)
|
||||||
|
withAnimation(.easeOut(duration: 0.16)) {
|
||||||
|
self.legWiggle = 0.25
|
||||||
|
self.wiggleOffset = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func wiggleEars() {
|
||||||
|
let target = CGFloat.random(in: -1.2...1.2)
|
||||||
|
withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) {
|
||||||
|
self.earWiggle = target
|
||||||
|
}
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await Task.sleep(nanoseconds: 320_000_000)
|
||||||
|
withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) {
|
||||||
|
self.earWiggle = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleRandomTimers(from date: Date) {
|
||||||
|
self.nextBlink = date.addingTimeInterval(Double.random(in: 3.5...8.5))
|
||||||
|
self.nextWiggle = date.addingTimeInterval(Double.random(in: 6.5...14))
|
||||||
|
self.nextLegWiggle = date.addingTimeInterval(Double.random(in: 5.0...11.0))
|
||||||
|
self.nextEarWiggle = date.addingTimeInterval(Double.random(in: 7.0...14.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var gatewayNeedsAttention: Bool {
|
||||||
|
if self.isSleeping { return false }
|
||||||
|
switch self.gatewayStatus {
|
||||||
|
case .failed, .stopped:
|
||||||
|
return !self.isPaused
|
||||||
|
case .starting, .running, .attachedExisting:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var gatewayBadgeColor: Color {
|
||||||
|
switch self.gatewayStatus {
|
||||||
|
case .failed: .red
|
||||||
|
case .stopped: .orange
|
||||||
|
default: .clear
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
@MainActor
|
||||||
|
extension CritterStatusLabel {
|
||||||
|
static func exerciseForTesting() async {
|
||||||
|
var label = CritterStatusLabel(
|
||||||
|
isPaused: false,
|
||||||
|
isSleeping: false,
|
||||||
|
isWorking: true,
|
||||||
|
earBoostActive: false,
|
||||||
|
blinkTick: 1,
|
||||||
|
sendCelebrationTick: 1,
|
||||||
|
gatewayStatus: .running(details: nil),
|
||||||
|
animationsEnabled: true,
|
||||||
|
iconState: .workingMain(.tool(.bash)))
|
||||||
|
|
||||||
|
_ = label.body
|
||||||
|
_ = label.iconImage
|
||||||
|
_ = label.tickTaskID
|
||||||
|
label.tick(Date())
|
||||||
|
label.resetMotion()
|
||||||
|
label.blink()
|
||||||
|
label.wiggle()
|
||||||
|
label.wiggleLegs()
|
||||||
|
label.wiggleEars()
|
||||||
|
label.scurry()
|
||||||
|
label.scheduleRandomTimers(from: Date())
|
||||||
|
_ = label.gatewayNeedsAttention
|
||||||
|
_ = label.gatewayBadgeColor
|
||||||
|
|
||||||
|
label.isPaused = true
|
||||||
|
_ = label.iconImage
|
||||||
|
|
||||||
|
label.isPaused = false
|
||||||
|
label.isSleeping = true
|
||||||
|
_ = label.iconImage
|
||||||
|
|
||||||
|
label.isSleeping = false
|
||||||
|
label.iconState = .idle
|
||||||
|
_ = label.iconImage
|
||||||
|
|
||||||
|
let failed = CritterStatusLabel(
|
||||||
|
isPaused: false,
|
||||||
|
isSleeping: false,
|
||||||
|
isWorking: false,
|
||||||
|
earBoostActive: false,
|
||||||
|
blinkTick: 0,
|
||||||
|
sendCelebrationTick: 0,
|
||||||
|
gatewayStatus: .failed("boom"),
|
||||||
|
animationsEnabled: false,
|
||||||
|
iconState: .idle)
|
||||||
|
_ = failed.gatewayNeedsAttention
|
||||||
|
_ = failed.gatewayBadgeColor
|
||||||
|
|
||||||
|
let stopped = CritterStatusLabel(
|
||||||
|
isPaused: false,
|
||||||
|
isSleeping: false,
|
||||||
|
isWorking: false,
|
||||||
|
earBoostActive: false,
|
||||||
|
blinkTick: 0,
|
||||||
|
sendCelebrationTick: 0,
|
||||||
|
gatewayStatus: .stopped,
|
||||||
|
animationsEnabled: false,
|
||||||
|
iconState: .idle)
|
||||||
|
_ = stopped.gatewayNeedsAttention
|
||||||
|
_ = stopped.gatewayBadgeColor
|
||||||
|
|
||||||
|
_ = CritterIconRenderer.makeIcon(
|
||||||
|
blink: 0.6,
|
||||||
|
legWiggle: 0.8,
|
||||||
|
earWiggle: 0.4,
|
||||||
|
earScale: 1.4,
|
||||||
|
earHoles: true,
|
||||||
|
eyesClosedLines: true,
|
||||||
|
badge: .init(symbolName: "gearshape.fill", prominence: .secondary))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import AppKit
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct CritterStatusLabel: View {
|
struct CritterStatusLabel: View {
|
||||||
@@ -12,520 +11,13 @@ struct CritterStatusLabel: View {
|
|||||||
var animationsEnabled: Bool
|
var animationsEnabled: Bool
|
||||||
var iconState: IconState
|
var iconState: IconState
|
||||||
|
|
||||||
@State private var blinkAmount: CGFloat = 0
|
@State var blinkAmount: CGFloat = 0
|
||||||
@State private var nextBlink = Date().addingTimeInterval(Double.random(in: 3.5...8.5))
|
@State var nextBlink = Date().addingTimeInterval(Double.random(in: 3.5...8.5))
|
||||||
@State private var wiggleAngle: Double = 0
|
@State var wiggleAngle: Double = 0
|
||||||
@State private var wiggleOffset: CGFloat = 0
|
@State var wiggleOffset: CGFloat = 0
|
||||||
@State private var nextWiggle = Date().addingTimeInterval(Double.random(in: 6.5...14))
|
@State var nextWiggle = Date().addingTimeInterval(Double.random(in: 6.5...14))
|
||||||
@State private var legWiggle: CGFloat = 0
|
@State var legWiggle: CGFloat = 0
|
||||||
@State private var nextLegWiggle = Date().addingTimeInterval(Double.random(in: 5.0...11.0))
|
@State var nextLegWiggle = Date().addingTimeInterval(Double.random(in: 5.0...11.0))
|
||||||
@State private var earWiggle: CGFloat = 0
|
@State var earWiggle: CGFloat = 0
|
||||||
@State private var nextEarWiggle = Date().addingTimeInterval(Double.random(in: 7.0...14.0))
|
@State var nextEarWiggle = Date().addingTimeInterval(Double.random(in: 7.0...14.0))
|
||||||
|
|
||||||
private var isWorkingNow: Bool {
|
|
||||||
self.iconState.isWorking || self.isWorking
|
|
||||||
}
|
|
||||||
|
|
||||||
private var effectiveAnimationsEnabled: Bool {
|
|
||||||
self.animationsEnabled && !self.isSleeping
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack(alignment: .topTrailing) {
|
|
||||||
self.iconImage
|
|
||||||
.frame(width: 18, height: 18)
|
|
||||||
.rotationEffect(.degrees(self.wiggleAngle), anchor: .center)
|
|
||||||
.offset(x: self.wiggleOffset)
|
|
||||||
// Avoid Combine's TimerPublisher here: on macOS 26.2 we've seen crashes inside executor checks
|
|
||||||
// triggered by its callbacks. Drive periodic updates via a Swift-concurrency task instead.
|
|
||||||
.task(id: self.tickTaskID) {
|
|
||||||
guard self.effectiveAnimationsEnabled, !self.earBoostActive else {
|
|
||||||
await MainActor.run { self.resetMotion() }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
while !Task.isCancelled {
|
|
||||||
let now = Date()
|
|
||||||
await MainActor.run { self.tick(now) }
|
|
||||||
try? await Task.sleep(nanoseconds: 350_000_000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onChange(of: self.isPaused) { _, _ in self.resetMotion() }
|
|
||||||
.onChange(of: self.blinkTick) { _, _ in
|
|
||||||
guard self.effectiveAnimationsEnabled, !self.earBoostActive else { return }
|
|
||||||
self.blink()
|
|
||||||
}
|
|
||||||
.onChange(of: self.sendCelebrationTick) { _, _ in
|
|
||||||
guard self.effectiveAnimationsEnabled, !self.earBoostActive else { return }
|
|
||||||
self.wiggleLegs()
|
|
||||||
}
|
|
||||||
.onChange(of: self.animationsEnabled) { _, enabled in
|
|
||||||
if enabled, !self.isSleeping {
|
|
||||||
self.scheduleRandomTimers(from: Date())
|
|
||||||
} else {
|
|
||||||
self.resetMotion()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onChange(of: self.isSleeping) { _, _ in
|
|
||||||
self.resetMotion()
|
|
||||||
}
|
|
||||||
.onChange(of: self.earBoostActive) { _, active in
|
|
||||||
if active {
|
|
||||||
self.resetMotion()
|
|
||||||
} else if self.effectiveAnimationsEnabled {
|
|
||||||
self.scheduleRandomTimers(from: Date())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.gatewayNeedsAttention {
|
|
||||||
Circle()
|
|
||||||
.fill(self.gatewayBadgeColor)
|
|
||||||
.frame(width: 6, height: 6)
|
|
||||||
.padding(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(width: 18, height: 18)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var tickTaskID: Int {
|
|
||||||
// Ensure SwiftUI restarts (and cancels) the task when these change.
|
|
||||||
(self.effectiveAnimationsEnabled ? 1 : 0) | (self.earBoostActive ? 2 : 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func tick(_ now: Date) {
|
|
||||||
guard self.effectiveAnimationsEnabled, !self.earBoostActive else {
|
|
||||||
self.resetMotion()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if now >= self.nextBlink {
|
|
||||||
self.blink()
|
|
||||||
self.nextBlink = now.addingTimeInterval(Double.random(in: 3.5...8.5))
|
|
||||||
}
|
|
||||||
|
|
||||||
if now >= self.nextWiggle {
|
|
||||||
self.wiggle()
|
|
||||||
self.nextWiggle = now.addingTimeInterval(Double.random(in: 6.5...14))
|
|
||||||
}
|
|
||||||
|
|
||||||
if now >= self.nextLegWiggle {
|
|
||||||
self.wiggleLegs()
|
|
||||||
self.nextLegWiggle = now.addingTimeInterval(Double.random(in: 5.0...11.0))
|
|
||||||
}
|
|
||||||
|
|
||||||
if now >= self.nextEarWiggle {
|
|
||||||
self.wiggleEars()
|
|
||||||
self.nextEarWiggle = now.addingTimeInterval(Double.random(in: 7.0...14.0))
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.isWorkingNow {
|
|
||||||
self.scurry()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var iconImage: Image {
|
|
||||||
let badge: CritterIconRenderer.Badge? = if let prominence = self.iconState.badgeProminence, !self.isPaused {
|
|
||||||
CritterIconRenderer.Badge(
|
|
||||||
symbolName: self.iconState.badgeSymbolName,
|
|
||||||
prominence: prominence)
|
|
||||||
} else {
|
|
||||||
nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.isPaused {
|
|
||||||
return Image(nsImage: CritterIconRenderer.makeIcon(blink: 0, badge: nil))
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.isSleeping {
|
|
||||||
return Image(nsImage: CritterIconRenderer.makeIcon(blink: 1, eyesClosedLines: true, badge: nil))
|
|
||||||
}
|
|
||||||
|
|
||||||
return Image(nsImage: CritterIconRenderer.makeIcon(
|
|
||||||
blink: self.blinkAmount,
|
|
||||||
legWiggle: max(self.legWiggle, self.isWorkingNow ? 0.6 : 0),
|
|
||||||
earWiggle: self.earWiggle,
|
|
||||||
earScale: self.earBoostActive ? 1.9 : 1.0,
|
|
||||||
earHoles: self.earBoostActive,
|
|
||||||
badge: badge))
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resetMotion() {
|
|
||||||
self.blinkAmount = 0
|
|
||||||
self.wiggleAngle = 0
|
|
||||||
self.wiggleOffset = 0
|
|
||||||
self.legWiggle = 0
|
|
||||||
self.earWiggle = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
private func blink() {
|
|
||||||
withAnimation(.easeInOut(duration: 0.08)) { self.blinkAmount = 1 }
|
|
||||||
Task { @MainActor in
|
|
||||||
try? await Task.sleep(nanoseconds: 160_000_000)
|
|
||||||
withAnimation(.easeOut(duration: 0.12)) { self.blinkAmount = 0 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func wiggle() {
|
|
||||||
let targetAngle = Double.random(in: -4.5...4.5)
|
|
||||||
let targetOffset = CGFloat.random(in: -0.5...0.5)
|
|
||||||
withAnimation(.interpolatingSpring(stiffness: 220, damping: 18)) {
|
|
||||||
self.wiggleAngle = targetAngle
|
|
||||||
self.wiggleOffset = targetOffset
|
|
||||||
}
|
|
||||||
Task { @MainActor in
|
|
||||||
try? await Task.sleep(nanoseconds: 360_000_000)
|
|
||||||
withAnimation(.interpolatingSpring(stiffness: 220, damping: 18)) {
|
|
||||||
self.wiggleAngle = 0
|
|
||||||
self.wiggleOffset = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func wiggleLegs() {
|
|
||||||
let target = CGFloat.random(in: 0.35...0.9)
|
|
||||||
withAnimation(.easeInOut(duration: 0.14)) {
|
|
||||||
self.legWiggle = target
|
|
||||||
}
|
|
||||||
Task { @MainActor in
|
|
||||||
try? await Task.sleep(nanoseconds: 220_000_000)
|
|
||||||
withAnimation(.easeOut(duration: 0.18)) { self.legWiggle = 0 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func scurry() {
|
|
||||||
let target = CGFloat.random(in: 0.7...1.0)
|
|
||||||
withAnimation(.easeInOut(duration: 0.12)) {
|
|
||||||
self.legWiggle = target
|
|
||||||
self.wiggleOffset = CGFloat.random(in: -0.6...0.6)
|
|
||||||
}
|
|
||||||
Task { @MainActor in
|
|
||||||
try? await Task.sleep(nanoseconds: 180_000_000)
|
|
||||||
withAnimation(.easeOut(duration: 0.16)) {
|
|
||||||
self.legWiggle = 0.25
|
|
||||||
self.wiggleOffset = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func wiggleEars() {
|
|
||||||
let target = CGFloat.random(in: -1.2...1.2)
|
|
||||||
withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) {
|
|
||||||
self.earWiggle = target
|
|
||||||
}
|
|
||||||
Task { @MainActor in
|
|
||||||
try? await Task.sleep(nanoseconds: 320_000_000)
|
|
||||||
withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) {
|
|
||||||
self.earWiggle = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func scheduleRandomTimers(from date: Date) {
|
|
||||||
self.nextBlink = date.addingTimeInterval(Double.random(in: 3.5...8.5))
|
|
||||||
self.nextWiggle = date.addingTimeInterval(Double.random(in: 6.5...14))
|
|
||||||
self.nextLegWiggle = date.addingTimeInterval(Double.random(in: 5.0...11.0))
|
|
||||||
self.nextEarWiggle = date.addingTimeInterval(Double.random(in: 7.0...14.0))
|
|
||||||
}
|
|
||||||
|
|
||||||
private var gatewayNeedsAttention: Bool {
|
|
||||||
if self.isSleeping { return false }
|
|
||||||
switch self.gatewayStatus {
|
|
||||||
case .failed, .stopped:
|
|
||||||
return !self.isPaused
|
|
||||||
case .starting, .running, .attachedExisting:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var gatewayBadgeColor: Color {
|
|
||||||
switch self.gatewayStatus {
|
|
||||||
case .failed: .red
|
|
||||||
case .stopped: .orange
|
|
||||||
default: .clear
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum CritterIconRenderer {
|
|
||||||
private static let size = NSSize(width: 18, height: 18)
|
|
||||||
|
|
||||||
struct Badge {
|
|
||||||
let symbolName: String
|
|
||||||
let prominence: IconState.BadgeProminence
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct Canvas {
|
|
||||||
let w: CGFloat
|
|
||||||
let h: CGFloat
|
|
||||||
let snapX: (CGFloat) -> CGFloat
|
|
||||||
let snapY: (CGFloat) -> CGFloat
|
|
||||||
let context: CGContext
|
|
||||||
}
|
|
||||||
|
|
||||||
static func makeIcon(
|
|
||||||
blink: CGFloat,
|
|
||||||
legWiggle: CGFloat = 0,
|
|
||||||
earWiggle: CGFloat = 0,
|
|
||||||
earScale: CGFloat = 1,
|
|
||||||
earHoles: Bool = false,
|
|
||||||
eyesClosedLines: Bool = false,
|
|
||||||
badge: Badge? = nil) -> NSImage
|
|
||||||
{
|
|
||||||
// Force a 36×36px backing store (2× for the 18pt logical canvas) so the menu bar icon stays crisp on Retina.
|
|
||||||
let pixelsWide = 36
|
|
||||||
let pixelsHigh = 36
|
|
||||||
guard let rep = NSBitmapImageRep(
|
|
||||||
bitmapDataPlanes: nil,
|
|
||||||
pixelsWide: pixelsWide,
|
|
||||||
pixelsHigh: pixelsHigh,
|
|
||||||
bitsPerSample: 8,
|
|
||||||
samplesPerPixel: 4,
|
|
||||||
hasAlpha: true,
|
|
||||||
isPlanar: false,
|
|
||||||
colorSpaceName: .deviceRGB,
|
|
||||||
bitmapFormat: [],
|
|
||||||
bytesPerRow: 0,
|
|
||||||
bitsPerPixel: 0)
|
|
||||||
else {
|
|
||||||
return NSImage(size: self.size)
|
|
||||||
}
|
|
||||||
rep.size = self.size
|
|
||||||
|
|
||||||
NSGraphicsContext.saveGraphicsState()
|
|
||||||
if let context = NSGraphicsContext(bitmapImageRep: rep) {
|
|
||||||
NSGraphicsContext.current = context
|
|
||||||
context.imageInterpolation = .none
|
|
||||||
context.cgContext.setShouldAntialias(false)
|
|
||||||
defer { NSGraphicsContext.restoreGraphicsState() }
|
|
||||||
|
|
||||||
let stepX = self.size.width / max(CGFloat(rep.pixelsWide), 1)
|
|
||||||
let stepY = self.size.height / max(CGFloat(rep.pixelsHigh), 1)
|
|
||||||
let snapX: (CGFloat) -> CGFloat = { ($0 / stepX).rounded() * stepX }
|
|
||||||
let snapY: (CGFloat) -> CGFloat = { ($0 / stepY).rounded() * stepY }
|
|
||||||
|
|
||||||
let w = snapX(size.width)
|
|
||||||
let h = snapY(size.height)
|
|
||||||
|
|
||||||
let bodyW = snapX(w * 0.78)
|
|
||||||
let bodyH = snapY(h * 0.58)
|
|
||||||
let bodyX = snapX((w - bodyW) / 2)
|
|
||||||
let bodyY = snapY(h * 0.36)
|
|
||||||
let bodyCorner = snapX(w * 0.09)
|
|
||||||
|
|
||||||
let earW = snapX(w * 0.22)
|
|
||||||
let earH = snapY(bodyH * 0.54 * earScale * (1 - 0.08 * abs(earWiggle)))
|
|
||||||
let earCorner = snapX(earW * 0.24)
|
|
||||||
let leftEarRect = CGRect(
|
|
||||||
x: snapX(bodyX - earW * 0.55 + earWiggle),
|
|
||||||
y: snapY(bodyY + bodyH * 0.08 + earWiggle * 0.4),
|
|
||||||
width: earW,
|
|
||||||
height: earH)
|
|
||||||
let rightEarRect = CGRect(
|
|
||||||
x: snapX(bodyX + bodyW - earW * 0.45 - earWiggle),
|
|
||||||
y: snapY(bodyY + bodyH * 0.08 - earWiggle * 0.4),
|
|
||||||
width: earW,
|
|
||||||
height: earH)
|
|
||||||
|
|
||||||
let legW = snapX(w * 0.11)
|
|
||||||
let legH = snapY(h * 0.26)
|
|
||||||
let legSpacing = snapX(w * 0.085)
|
|
||||||
let legsWidth = snapX(4 * legW + 3 * legSpacing)
|
|
||||||
let legStartX = snapX((w - legsWidth) / 2)
|
|
||||||
let legLift = snapY(legH * 0.35 * legWiggle)
|
|
||||||
let legYBase = snapY(bodyY - legH + h * 0.05)
|
|
||||||
|
|
||||||
let eyeW = snapX(bodyW * 0.2)
|
|
||||||
let eyeY = snapY(bodyY + bodyH * 0.56)
|
|
||||||
let eyeOffset = snapX(bodyW * 0.24)
|
|
||||||
|
|
||||||
context.cgContext.setFillColor(NSColor.labelColor.cgColor)
|
|
||||||
|
|
||||||
context.cgContext.addPath(CGPath(
|
|
||||||
roundedRect: CGRect(x: bodyX, y: bodyY, width: bodyW, height: bodyH),
|
|
||||||
cornerWidth: bodyCorner,
|
|
||||||
cornerHeight: bodyCorner,
|
|
||||||
transform: nil))
|
|
||||||
context.cgContext.addPath(CGPath(
|
|
||||||
roundedRect: leftEarRect,
|
|
||||||
cornerWidth: earCorner,
|
|
||||||
cornerHeight: earCorner,
|
|
||||||
transform: nil))
|
|
||||||
context.cgContext.addPath(CGPath(
|
|
||||||
roundedRect: rightEarRect,
|
|
||||||
cornerWidth: earCorner,
|
|
||||||
cornerHeight: earCorner,
|
|
||||||
transform: nil))
|
|
||||||
for i in 0..<4 {
|
|
||||||
let x = legStartX + CGFloat(i) * (legW + legSpacing)
|
|
||||||
let lift = (i % 2 == 0 ? legLift : -legLift)
|
|
||||||
let rect = CGRect(
|
|
||||||
x: x,
|
|
||||||
y: legYBase + lift,
|
|
||||||
width: legW,
|
|
||||||
height: legH * (1 - 0.12 * legWiggle))
|
|
||||||
context.cgContext.addPath(CGPath(
|
|
||||||
roundedRect: rect,
|
|
||||||
cornerWidth: legW * 0.34,
|
|
||||||
cornerHeight: legW * 0.34,
|
|
||||||
transform: nil))
|
|
||||||
}
|
|
||||||
context.cgContext.fillPath()
|
|
||||||
|
|
||||||
context.cgContext.saveGState()
|
|
||||||
context.cgContext.setBlendMode(CGBlendMode.clear)
|
|
||||||
|
|
||||||
let leftCenter = CGPoint(x: snapX(w / 2 - eyeOffset), y: snapY(eyeY))
|
|
||||||
let rightCenter = CGPoint(x: snapX(w / 2 + eyeOffset), y: snapY(eyeY))
|
|
||||||
|
|
||||||
if earHoles || earScale > 1.05 {
|
|
||||||
let holeW = snapX(earW * 0.6)
|
|
||||||
let holeH = snapY(earH * 0.46)
|
|
||||||
let holeCorner = snapX(holeW * 0.34)
|
|
||||||
let leftHoleRect = CGRect(
|
|
||||||
x: snapX(leftEarRect.midX - holeW / 2),
|
|
||||||
y: snapY(leftEarRect.midY - holeH / 2 + earH * 0.04),
|
|
||||||
width: holeW,
|
|
||||||
height: holeH)
|
|
||||||
let rightHoleRect = CGRect(
|
|
||||||
x: snapX(rightEarRect.midX - holeW / 2),
|
|
||||||
y: snapY(rightEarRect.midY - holeH / 2 + earH * 0.04),
|
|
||||||
width: holeW,
|
|
||||||
height: holeH)
|
|
||||||
|
|
||||||
context.cgContext.addPath(CGPath(
|
|
||||||
roundedRect: leftHoleRect,
|
|
||||||
cornerWidth: holeCorner,
|
|
||||||
cornerHeight: holeCorner,
|
|
||||||
transform: nil))
|
|
||||||
context.cgContext.addPath(CGPath(
|
|
||||||
roundedRect: rightHoleRect,
|
|
||||||
cornerWidth: holeCorner,
|
|
||||||
cornerHeight: holeCorner,
|
|
||||||
transform: nil))
|
|
||||||
}
|
|
||||||
|
|
||||||
if eyesClosedLines {
|
|
||||||
let lineW = snapX(eyeW * 0.95)
|
|
||||||
let lineH = snapY(max(stepY * 2, bodyH * 0.06))
|
|
||||||
let corner = snapX(lineH * 0.6)
|
|
||||||
let leftRect = CGRect(
|
|
||||||
x: snapX(leftCenter.x - lineW / 2),
|
|
||||||
y: snapY(leftCenter.y - lineH / 2),
|
|
||||||
width: lineW,
|
|
||||||
height: lineH)
|
|
||||||
let rightRect = CGRect(
|
|
||||||
x: snapX(rightCenter.x - lineW / 2),
|
|
||||||
y: snapY(rightCenter.y - lineH / 2),
|
|
||||||
width: lineW,
|
|
||||||
height: lineH)
|
|
||||||
context.cgContext.addPath(CGPath(
|
|
||||||
roundedRect: leftRect,
|
|
||||||
cornerWidth: corner,
|
|
||||||
cornerHeight: corner,
|
|
||||||
transform: nil))
|
|
||||||
context.cgContext.addPath(CGPath(
|
|
||||||
roundedRect: rightRect,
|
|
||||||
cornerWidth: corner,
|
|
||||||
cornerHeight: corner,
|
|
||||||
transform: nil))
|
|
||||||
} else {
|
|
||||||
let eyeOpen = max(0.05, 1 - blink)
|
|
||||||
let eyeH = snapY(bodyH * 0.26 * eyeOpen)
|
|
||||||
|
|
||||||
let left = CGMutablePath()
|
|
||||||
left.move(to: CGPoint(x: snapX(leftCenter.x - eyeW / 2), y: snapY(leftCenter.y - eyeH)))
|
|
||||||
left.addLine(to: CGPoint(x: snapX(leftCenter.x + eyeW / 2), y: snapY(leftCenter.y)))
|
|
||||||
left.addLine(to: CGPoint(x: snapX(leftCenter.x - eyeW / 2), y: snapY(leftCenter.y + eyeH)))
|
|
||||||
left.closeSubpath()
|
|
||||||
|
|
||||||
let right = CGMutablePath()
|
|
||||||
right.move(to: CGPoint(x: snapX(rightCenter.x + eyeW / 2), y: snapY(rightCenter.y - eyeH)))
|
|
||||||
right.addLine(to: CGPoint(x: snapX(rightCenter.x - eyeW / 2), y: snapY(rightCenter.y)))
|
|
||||||
right.addLine(to: CGPoint(x: snapX(rightCenter.x + eyeW / 2), y: snapY(rightCenter.y + eyeH)))
|
|
||||||
right.closeSubpath()
|
|
||||||
|
|
||||||
context.cgContext.addPath(left)
|
|
||||||
context.cgContext.addPath(right)
|
|
||||||
}
|
|
||||||
|
|
||||||
context.cgContext.fillPath()
|
|
||||||
context.cgContext.restoreGState()
|
|
||||||
|
|
||||||
if let badge {
|
|
||||||
self.drawBadge(
|
|
||||||
badge,
|
|
||||||
canvas: Canvas(w: w, h: h, snapX: snapX, snapY: snapY, context: context.cgContext))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
NSGraphicsContext.restoreGraphicsState()
|
|
||||||
return NSImage(size: self.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
let image = NSImage(size: size)
|
|
||||||
image.addRepresentation(rep)
|
|
||||||
image.isTemplate = true
|
|
||||||
return image
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func drawBadge(_ badge: Badge, canvas: Canvas) {
|
|
||||||
let strength: CGFloat = switch badge.prominence {
|
|
||||||
case .primary: 1.0
|
|
||||||
case .secondary: 0.58
|
|
||||||
case .overridden: 0.85
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bigger, higher-contrast badge:
|
|
||||||
// - Increase diameter so tool activity is noticeable.
|
|
||||||
// - Draw a filled "puck", then knock out the symbol shape (transparent hole).
|
|
||||||
// This reads better in template-rendered menu bar icons than tiny monochrome glyphs.
|
|
||||||
let diameter = canvas.snapX(canvas.w * 0.52 * (0.92 + 0.08 * strength)) // ~9–10pt on an 18pt canvas
|
|
||||||
let margin = canvas.snapX(max(0.45, canvas.w * 0.03))
|
|
||||||
let rect = CGRect(
|
|
||||||
x: canvas.snapX(canvas.w - diameter - margin),
|
|
||||||
y: canvas.snapY(margin),
|
|
||||||
width: diameter,
|
|
||||||
height: diameter)
|
|
||||||
|
|
||||||
canvas.context.saveGState()
|
|
||||||
canvas.context.setShouldAntialias(true)
|
|
||||||
|
|
||||||
// Clear the underlying pixels so the badge stays readable over the critter.
|
|
||||||
canvas.context.saveGState()
|
|
||||||
canvas.context.setBlendMode(.clear)
|
|
||||||
canvas.context.addEllipse(in: rect.insetBy(dx: -1.0, dy: -1.0))
|
|
||||||
canvas.context.fillPath()
|
|
||||||
canvas.context.restoreGState()
|
|
||||||
|
|
||||||
let fillAlpha: CGFloat = min(1.0, 0.36 + 0.24 * strength)
|
|
||||||
let strokeAlpha: CGFloat = min(1.0, 0.78 + 0.22 * strength)
|
|
||||||
|
|
||||||
canvas.context.setFillColor(NSColor.labelColor.withAlphaComponent(fillAlpha).cgColor)
|
|
||||||
canvas.context.addEllipse(in: rect)
|
|
||||||
canvas.context.fillPath()
|
|
||||||
|
|
||||||
canvas.context.setStrokeColor(NSColor.labelColor.withAlphaComponent(strokeAlpha).cgColor)
|
|
||||||
canvas.context.setLineWidth(max(1.25, canvas.snapX(canvas.w * 0.075)))
|
|
||||||
canvas.context.strokeEllipse(in: rect.insetBy(dx: 0.45, dy: 0.45))
|
|
||||||
|
|
||||||
if let base = NSImage(systemSymbolName: badge.symbolName, accessibilityDescription: nil) {
|
|
||||||
let pointSize = max(7.0, diameter * 0.82)
|
|
||||||
let config = NSImage.SymbolConfiguration(pointSize: pointSize, weight: .black)
|
|
||||||
let symbol = base.withSymbolConfiguration(config) ?? base
|
|
||||||
symbol.isTemplate = true
|
|
||||||
|
|
||||||
let symbolRect = rect.insetBy(dx: diameter * 0.17, dy: diameter * 0.17)
|
|
||||||
canvas.context.saveGState()
|
|
||||||
canvas.context.setBlendMode(.clear)
|
|
||||||
symbol.draw(
|
|
||||||
in: symbolRect,
|
|
||||||
from: .zero,
|
|
||||||
operation: .sourceOver,
|
|
||||||
fraction: 1,
|
|
||||||
respectFlipped: true,
|
|
||||||
hints: nil)
|
|
||||||
canvas.context.restoreGState()
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas.context.restoreGState()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension CronJobEditor {
|
||||||
|
func gridLabel(_ text: String) -> some View {
|
||||||
|
Text(text)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: self.labelColumnWidth, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hydrateFromJob() {
|
||||||
|
guard let job else { return }
|
||||||
|
self.name = job.name
|
||||||
|
self.description = job.description ?? ""
|
||||||
|
self.enabled = job.enabled
|
||||||
|
self.sessionTarget = job.sessionTarget
|
||||||
|
self.wakeMode = job.wakeMode
|
||||||
|
|
||||||
|
switch job.schedule {
|
||||||
|
case let .at(atMs):
|
||||||
|
self.scheduleKind = .at
|
||||||
|
self.atDate = Date(timeIntervalSince1970: TimeInterval(atMs) / 1000)
|
||||||
|
case let .every(everyMs, _):
|
||||||
|
self.scheduleKind = .every
|
||||||
|
self.everyText = self.formatDuration(ms: everyMs)
|
||||||
|
case let .cron(expr, tz):
|
||||||
|
self.scheduleKind = .cron
|
||||||
|
self.cronExpr = expr
|
||||||
|
self.cronTz = tz ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
switch job.payload {
|
||||||
|
case let .systemEvent(text):
|
||||||
|
self.payloadKind = .systemEvent
|
||||||
|
self.systemEventText = text
|
||||||
|
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver):
|
||||||
|
self.payloadKind = .agentTurn
|
||||||
|
self.agentMessage = message
|
||||||
|
self.thinking = thinking ?? ""
|
||||||
|
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
|
||||||
|
self.deliver = deliver ?? false
|
||||||
|
self.channel = GatewayAgentChannel(raw: channel)
|
||||||
|
self.to = to ?? ""
|
||||||
|
self.bestEffortDeliver = bestEffortDeliver ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
self.postPrefix = job.isolation?.postToMainPrefix ?? "Cron"
|
||||||
|
}
|
||||||
|
|
||||||
|
func save() {
|
||||||
|
do {
|
||||||
|
self.error = nil
|
||||||
|
let payload = try self.buildPayload()
|
||||||
|
self.onSave(payload)
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPayload() throws -> [String: AnyCodable] {
|
||||||
|
let name = self.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if name.isEmpty {
|
||||||
|
throw NSError(
|
||||||
|
domain: "Cron",
|
||||||
|
code: 0,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Name is required."])
|
||||||
|
}
|
||||||
|
let description = self.description.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let schedule: [String: Any]
|
||||||
|
switch self.scheduleKind {
|
||||||
|
case .at:
|
||||||
|
schedule = ["kind": "at", "atMs": Int(self.atDate.timeIntervalSince1970 * 1000)]
|
||||||
|
case .every:
|
||||||
|
guard let ms = Self.parseDurationMs(self.everyText) else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "Cron",
|
||||||
|
code: 0,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Invalid every duration (use 10m, 1h, 1d)."])
|
||||||
|
}
|
||||||
|
schedule = ["kind": "every", "everyMs": ms]
|
||||||
|
case .cron:
|
||||||
|
let expr = self.cronExpr.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if expr.isEmpty {
|
||||||
|
throw NSError(
|
||||||
|
domain: "Cron",
|
||||||
|
code: 0,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Cron expression is required."])
|
||||||
|
}
|
||||||
|
let tz = self.cronTz.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if tz.isEmpty {
|
||||||
|
schedule = ["kind": "cron", "expr": expr]
|
||||||
|
} else {
|
||||||
|
schedule = ["kind": "cron", "expr": expr, "tz": tz]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: [String: Any] = {
|
||||||
|
if self.sessionTarget == .isolated { return self.buildAgentTurnPayload() }
|
||||||
|
switch self.payloadKind {
|
||||||
|
case .systemEvent:
|
||||||
|
let text = self.systemEventText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return ["kind": "systemEvent", "text": text]
|
||||||
|
case .agentTurn:
|
||||||
|
return self.buildAgentTurnPayload()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if self.sessionTarget == .main, payload["kind"] as? String == "agentTurn" {
|
||||||
|
throw NSError(
|
||||||
|
domain: "Cron",
|
||||||
|
code: 0,
|
||||||
|
userInfo: [
|
||||||
|
NSLocalizedDescriptionKey:
|
||||||
|
"Main session jobs require systemEvent payloads (switch Session target to isolated).",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.sessionTarget == .isolated, payload["kind"] as? String == "systemEvent" {
|
||||||
|
throw NSError(
|
||||||
|
domain: "Cron",
|
||||||
|
code: 0,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Isolated jobs require agentTurn payloads."])
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload["kind"] as? String == "systemEvent" {
|
||||||
|
if (payload["text"] as? String ?? "").isEmpty {
|
||||||
|
throw NSError(
|
||||||
|
domain: "Cron",
|
||||||
|
code: 0,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "System event text is required."])
|
||||||
|
}
|
||||||
|
} else if payload["kind"] as? String == "agentTurn" {
|
||||||
|
if (payload["message"] as? String ?? "").isEmpty {
|
||||||
|
throw NSError(
|
||||||
|
domain: "Cron",
|
||||||
|
code: 0,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Agent message is required."])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var root: [String: Any] = [
|
||||||
|
"name": name,
|
||||||
|
"enabled": self.enabled,
|
||||||
|
"schedule": schedule,
|
||||||
|
"sessionTarget": self.sessionTarget.rawValue,
|
||||||
|
"wakeMode": self.wakeMode.rawValue,
|
||||||
|
"payload": payload,
|
||||||
|
]
|
||||||
|
if !description.isEmpty { root["description"] = description }
|
||||||
|
|
||||||
|
if self.sessionTarget == .isolated {
|
||||||
|
let trimmed = self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
root["isolation"] = [
|
||||||
|
"postToMainPrefix": trimmed.isEmpty ? "Cron" : trimmed,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return root.mapValues { AnyCodable($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAgentTurnPayload() -> [String: Any] {
|
||||||
|
let msg = self.agentMessage.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
var payload: [String: Any] = ["kind": "agentTurn", "message": msg]
|
||||||
|
let thinking = self.thinking.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !thinking.isEmpty { payload["thinking"] = thinking }
|
||||||
|
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
|
||||||
|
payload["deliver"] = self.deliver
|
||||||
|
if self.deliver {
|
||||||
|
payload["channel"] = self.channel.rawValue
|
||||||
|
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !to.isEmpty { payload["to"] = to }
|
||||||
|
payload["bestEffortDeliver"] = self.bestEffortDeliver
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
static func parseDurationMs(_ input: String) -> Int? {
|
||||||
|
let raw = input.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if raw.isEmpty { return nil }
|
||||||
|
|
||||||
|
let rx = try? NSRegularExpression(pattern: "^(\\d+(?:\\.\\d+)?)(ms|s|m|h|d)$", options: [.caseInsensitive])
|
||||||
|
guard let match = rx?.firstMatch(in: raw, range: NSRange(location: 0, length: raw.utf16.count)) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func group(_ idx: Int) -> String {
|
||||||
|
let range = match.range(at: idx)
|
||||||
|
guard let r = Range(range, in: raw) else { return "" }
|
||||||
|
return String(raw[r])
|
||||||
|
}
|
||||||
|
let n = Double(group(1)) ?? 0
|
||||||
|
if !n.isFinite || n <= 0 { return nil }
|
||||||
|
let unit = group(2).lowercased()
|
||||||
|
let factor: Double = switch unit {
|
||||||
|
case "ms": 1
|
||||||
|
case "s": 1000
|
||||||
|
case "m": 60000
|
||||||
|
case "h": 3_600_000
|
||||||
|
default: 86_400_000
|
||||||
|
}
|
||||||
|
return Int(floor(n * factor))
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDuration(ms: Int) -> String {
|
||||||
|
if ms < 1000 { return "\(ms)ms" }
|
||||||
|
let s = Double(ms) / 1000.0
|
||||||
|
if s < 60 { return "\(Int(round(s)))s" }
|
||||||
|
let m = s / 60.0
|
||||||
|
if m < 60 { return "\(Int(round(m)))m" }
|
||||||
|
let h = m / 60.0
|
||||||
|
if h < 48 { return "\(Int(round(h)))h" }
|
||||||
|
let d = h / 24.0
|
||||||
|
return "\(Int(round(d)))d"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#if DEBUG
|
||||||
|
extension CronJobEditor {
|
||||||
|
mutating func exerciseForTesting() {
|
||||||
|
self.name = "Test job"
|
||||||
|
self.description = "Test description"
|
||||||
|
self.enabled = true
|
||||||
|
self.sessionTarget = .isolated
|
||||||
|
self.wakeMode = .now
|
||||||
|
|
||||||
|
self.scheduleKind = .every
|
||||||
|
self.everyText = "15m"
|
||||||
|
|
||||||
|
self.payloadKind = .agentTurn
|
||||||
|
self.agentMessage = "Run diagnostic"
|
||||||
|
self.deliver = true
|
||||||
|
self.channel = .last
|
||||||
|
self.to = "+15551230000"
|
||||||
|
self.thinking = "low"
|
||||||
|
self.timeoutSeconds = "90"
|
||||||
|
self.bestEffortDeliver = true
|
||||||
|
self.postPrefix = "Cron"
|
||||||
|
|
||||||
|
_ = self.buildAgentTurnPayload()
|
||||||
|
_ = try? self.buildPayload()
|
||||||
|
_ = self.formatDuration(ms: 45000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct CronJobEditor: View {
|
||||||
|
let job: CronJob?
|
||||||
|
@Binding var isSaving: Bool
|
||||||
|
@Binding var error: String?
|
||||||
|
let onCancel: () -> Void
|
||||||
|
let onSave: ([String: AnyCodable]) -> Void
|
||||||
|
|
||||||
|
let labelColumnWidth: CGFloat = 160
|
||||||
|
static let introText =
|
||||||
|
"Create a schedule that wakes clawd via the Gateway. "
|
||||||
|
+ "Use an isolated session for agent turns so your main chat stays clean."
|
||||||
|
static let sessionTargetNote =
|
||||||
|
"Main jobs post a system event into the current main session. "
|
||||||
|
+ "Isolated jobs run clawd in a dedicated session and can deliver results (WhatsApp/Telegram/Discord/etc)."
|
||||||
|
static let scheduleKindNote =
|
||||||
|
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
|
||||||
|
static let isolatedPayloadNote =
|
||||||
|
"Isolated jobs always run an agent turn. The result can be delivered to a surface, "
|
||||||
|
+ "and a short summary is posted back to your main chat."
|
||||||
|
static let mainPayloadNote =
|
||||||
|
"System events are injected into the current main session. Agent turns require an isolated session target."
|
||||||
|
static let mainSummaryNote =
|
||||||
|
"Controls the label used when posting the completion summary back to the main session."
|
||||||
|
|
||||||
|
@State var name: String = ""
|
||||||
|
@State var description: String = ""
|
||||||
|
@State var enabled: Bool = true
|
||||||
|
@State var sessionTarget: CronSessionTarget = .main
|
||||||
|
@State var wakeMode: CronWakeMode = .nextHeartbeat
|
||||||
|
|
||||||
|
enum ScheduleKind: String, CaseIterable, Identifiable { case at, every, cron; var id: String { rawValue } }
|
||||||
|
@State var scheduleKind: ScheduleKind = .every
|
||||||
|
@State var atDate: Date = .init().addingTimeInterval(60 * 5)
|
||||||
|
@State var everyText: String = "1h"
|
||||||
|
@State var cronExpr: String = "0 9 * * 3"
|
||||||
|
@State var cronTz: String = ""
|
||||||
|
|
||||||
|
enum PayloadKind: String, CaseIterable, Identifiable { case systemEvent, agentTurn; var id: String { rawValue } }
|
||||||
|
@State var payloadKind: PayloadKind = .systemEvent
|
||||||
|
@State var systemEventText: String = ""
|
||||||
|
@State var agentMessage: String = ""
|
||||||
|
@State var deliver: Bool = false
|
||||||
|
@State var channel: GatewayAgentChannel = .last
|
||||||
|
@State var to: String = ""
|
||||||
|
@State var thinking: String = ""
|
||||||
|
@State var timeoutSeconds: String = ""
|
||||||
|
@State var bestEffortDeliver: Bool = false
|
||||||
|
@State var postPrefix: String = "Cron"
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(self.job == nil ? "New cron job" : "Edit cron job")
|
||||||
|
.font(.title3.weight(.semibold))
|
||||||
|
Text(Self.introText)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
ScrollView(.vertical) {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
GroupBox("Basics") {
|
||||||
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Name")
|
||||||
|
TextField("Required (e.g. “Daily summary”)", text: self.$name)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Description")
|
||||||
|
TextField("Optional notes", text: self.$description)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Enabled")
|
||||||
|
Toggle("", isOn: self.$enabled)
|
||||||
|
.labelsHidden()
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Session target")
|
||||||
|
Picker("", selection: self.$sessionTarget) {
|
||||||
|
Text("main").tag(CronSessionTarget.main)
|
||||||
|
Text("isolated").tag(CronSessionTarget.isolated)
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Wake mode")
|
||||||
|
Picker("", selection: self.$wakeMode) {
|
||||||
|
Text("next-heartbeat").tag(CronWakeMode.nextHeartbeat)
|
||||||
|
Text("now").tag(CronWakeMode.now)
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
Color.clear
|
||||||
|
.frame(width: self.labelColumnWidth, height: 1)
|
||||||
|
Text(
|
||||||
|
Self.sessionTargetNote)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GroupBox("Schedule") {
|
||||||
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Kind")
|
||||||
|
Picker("", selection: self.$scheduleKind) {
|
||||||
|
Text("at").tag(ScheduleKind.at)
|
||||||
|
Text("every").tag(ScheduleKind.every)
|
||||||
|
Text("cron").tag(ScheduleKind.cron)
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
Color.clear
|
||||||
|
.frame(width: self.labelColumnWidth, height: 1)
|
||||||
|
Text(
|
||||||
|
Self.scheduleKindNote)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch self.scheduleKind {
|
||||||
|
case .at:
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("At")
|
||||||
|
DatePicker(
|
||||||
|
"",
|
||||||
|
selection: self.$atDate,
|
||||||
|
displayedComponents: [.date, .hourAndMinute])
|
||||||
|
.labelsHidden()
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
case .every:
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Every")
|
||||||
|
TextField("10m, 1h, 1d", text: self.$everyText)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
case .cron:
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Expression")
|
||||||
|
TextField("e.g. 0 9 * * 3", text: self.$cronExpr)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Timezone")
|
||||||
|
TextField("Optional (e.g. America/Los_Angeles)", text: self.$cronTz)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GroupBox("Payload") {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
if self.sessionTarget == .isolated {
|
||||||
|
Text(Self.isolatedPayloadNote)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
self.agentTurnEditor
|
||||||
|
} else {
|
||||||
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Kind")
|
||||||
|
Picker("", selection: self.$payloadKind) {
|
||||||
|
Text("systemEvent").tag(PayloadKind.systemEvent)
|
||||||
|
Text("agentTurn").tag(PayloadKind.agentTurn)
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
Color.clear
|
||||||
|
.frame(width: self.labelColumnWidth, height: 1)
|
||||||
|
Text(
|
||||||
|
Self.mainPayloadNote)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch self.payloadKind {
|
||||||
|
case .systemEvent:
|
||||||
|
TextField("System event text", text: self.$systemEventText, axis: .vertical)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.lineLimit(3...7)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
case .agentTurn:
|
||||||
|
self.agentTurnEditor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.sessionTarget == .isolated {
|
||||||
|
GroupBox("Main session summary") {
|
||||||
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Prefix")
|
||||||
|
TextField("Cron", text: self.$postPrefix)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
Color.clear
|
||||||
|
.frame(width: self.labelColumnWidth, height: 1)
|
||||||
|
Text(
|
||||||
|
Self.mainSummaryNote)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error, !error.isEmpty {
|
||||||
|
Text(error)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Button("Cancel") { self.onCancel() }
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
self.save()
|
||||||
|
} label: {
|
||||||
|
if self.isSaving {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
} else {
|
||||||
|
Text("Save")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(self.isSaving)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
.frame(minWidth: 720, minHeight: 640)
|
||||||
|
.onAppear { self.hydrateFromJob() }
|
||||||
|
.onChange(of: self.payloadKind) { _, newValue in
|
||||||
|
if newValue == .agentTurn, self.sessionTarget == .main {
|
||||||
|
self.sessionTarget = .isolated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: self.sessionTarget) { _, newValue in
|
||||||
|
if newValue == .isolated {
|
||||||
|
self.payloadKind = .agentTurn
|
||||||
|
} else if newValue == .main, self.payloadKind == .agentTurn {
|
||||||
|
self.payloadKind = .systemEvent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var agentTurnEditor: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Message")
|
||||||
|
TextField("What should clawd do?", text: self.$agentMessage, axis: .vertical)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.lineLimit(3...7)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Thinking")
|
||||||
|
TextField("Optional (e.g. low)", text: self.$thinking)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Timeout")
|
||||||
|
TextField("Seconds (optional)", text: self.$timeoutSeconds)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: 180, alignment: .leading)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Deliver")
|
||||||
|
Toggle("Deliver result to a surface", isOn: self.$deliver)
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.deliver {
|
||||||
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Channel")
|
||||||
|
Picker("", selection: self.$channel) {
|
||||||
|
Text("last").tag(GatewayAgentChannel.last)
|
||||||
|
Text("whatsapp").tag(GatewayAgentChannel.whatsapp)
|
||||||
|
Text("telegram").tag(GatewayAgentChannel.telegram)
|
||||||
|
Text("discord").tag(GatewayAgentChannel.discord)
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("To")
|
||||||
|
TextField("Optional override (phone number / chat id / Discord channel)", text: self.$to)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Best-effort")
|
||||||
|
Toggle("Do not fail the job if delivery fails", isOn: self.$bestEffortDeliver)
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension CronSettings {
|
||||||
|
func save(payload: [String: AnyCodable]) async {
|
||||||
|
guard !self.isSaving else { return }
|
||||||
|
self.isSaving = true
|
||||||
|
self.editorError = nil
|
||||||
|
do {
|
||||||
|
try await self.store.upsertJob(id: self.editingJob?.id, payload: payload)
|
||||||
|
await MainActor.run {
|
||||||
|
self.isSaving = false
|
||||||
|
self.showEditor = false
|
||||||
|
self.editingJob = nil
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
self.isSaving = false
|
||||||
|
self.editorError = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension CronSettings {
|
||||||
|
var selectedJob: CronJob? {
|
||||||
|
guard let id = self.store.selectedJobId else { return nil }
|
||||||
|
return self.store.jobs.first(where: { $0.id == id })
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusTint(_ status: String?) -> Color {
|
||||||
|
switch (status ?? "").lowercased() {
|
||||||
|
case "ok": .green
|
||||||
|
case "error": .red
|
||||||
|
case "skipped": .orange
|
||||||
|
default: .secondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scheduleSummary(_ schedule: CronSchedule) -> String {
|
||||||
|
switch schedule {
|
||||||
|
case let .at(atMs):
|
||||||
|
let date = Date(timeIntervalSince1970: TimeInterval(atMs) / 1000)
|
||||||
|
return "at \(date.formatted(date: .abbreviated, time: .standard))"
|
||||||
|
case let .every(everyMs, _):
|
||||||
|
return "every \(self.formatDuration(ms: everyMs))"
|
||||||
|
case let .cron(expr, tz):
|
||||||
|
if let tz, !tz.isEmpty { return "cron \(expr) (\(tz))" }
|
||||||
|
return "cron \(expr)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDuration(ms: Int) -> String {
|
||||||
|
if ms < 1000 { return "\(ms)ms" }
|
||||||
|
let s = Double(ms) / 1000.0
|
||||||
|
if s < 60 { return "\(Int(round(s)))s" }
|
||||||
|
let m = s / 60.0
|
||||||
|
if m < 60 { return "\(Int(round(m)))m" }
|
||||||
|
let h = m / 60.0
|
||||||
|
if h < 48 { return "\(Int(round(h)))h" }
|
||||||
|
let d = h / 24.0
|
||||||
|
return "\(Int(round(d)))d"
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextRunLabel(_ date: Date, now: Date = .init()) -> String {
|
||||||
|
let delta = date.timeIntervalSince(now)
|
||||||
|
if delta <= 0 { return "due" }
|
||||||
|
if delta < 60 { return "in <1m" }
|
||||||
|
let minutes = Int(round(delta / 60))
|
||||||
|
if minutes < 60 { return "in \(minutes)m" }
|
||||||
|
let hours = Int(round(Double(minutes) / 60))
|
||||||
|
if hours < 48 { return "in \(hours)h" }
|
||||||
|
let days = Int(round(Double(hours) / 24))
|
||||||
|
return "in \(days)d"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension CronSettings {
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
self.header
|
||||||
|
self.schedulerBanner
|
||||||
|
self.content
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.onAppear { self.store.start() }
|
||||||
|
.onDisappear { self.store.stop() }
|
||||||
|
.sheet(isPresented: self.$showEditor) {
|
||||||
|
CronJobEditor(
|
||||||
|
job: self.editingJob,
|
||||||
|
isSaving: self.$isSaving,
|
||||||
|
error: self.$editorError,
|
||||||
|
onCancel: {
|
||||||
|
self.showEditor = false
|
||||||
|
self.editingJob = nil
|
||||||
|
},
|
||||||
|
onSave: { payload in
|
||||||
|
Task {
|
||||||
|
await self.save(payload: payload)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.alert("Delete cron job?", isPresented: Binding(
|
||||||
|
get: { self.confirmDelete != nil },
|
||||||
|
set: { if !$0 { self.confirmDelete = nil } }))
|
||||||
|
{
|
||||||
|
Button("Cancel", role: .cancel) { self.confirmDelete = nil }
|
||||||
|
Button("Delete", role: .destructive) {
|
||||||
|
if let job = self.confirmDelete {
|
||||||
|
Task { await self.store.removeJob(id: job.id) }
|
||||||
|
}
|
||||||
|
self.confirmDelete = nil
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
if let job = self.confirmDelete {
|
||||||
|
Text(job.displayName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: self.store.selectedJobId) { _, newValue in
|
||||||
|
guard let newValue else { return }
|
||||||
|
Task { await self.store.refreshRuns(jobId: newValue) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var schedulerBanner: some View {
|
||||||
|
Group {
|
||||||
|
if self.store.schedulerEnabled == false {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
Text("Cron scheduler is disabled")
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"Jobs are saved, but they will not run automatically until `cron.enabled` is set to `true` " +
|
||||||
|
"and the Gateway restarts.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
if let storePath = self.store.schedulerStorePath, !storePath.isEmpty {
|
||||||
|
Text(storePath)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(10)
|
||||||
|
.background(Color.orange.opacity(0.10))
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var header: some View {
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Cron")
|
||||||
|
.font(.headline)
|
||||||
|
Text("Manage Gateway cron jobs (main session vs isolated runs) and inspect run history.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button {
|
||||||
|
Task { await self.store.refreshJobs() }
|
||||||
|
} label: {
|
||||||
|
Label("Refresh", systemImage: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.disabled(self.store.isLoadingJobs)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
self.editorError = nil
|
||||||
|
self.editingJob = nil
|
||||||
|
self.showEditor = true
|
||||||
|
} label: {
|
||||||
|
Label("New Job", systemImage: "plus")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var content: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
if let err = self.store.lastError {
|
||||||
|
Text("Error: \(err)")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
} else if let msg = self.store.statusMessage {
|
||||||
|
Text(msg)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
List(selection: self.$store.selectedJobId) {
|
||||||
|
ForEach(self.store.jobs) { job in
|
||||||
|
self.jobRow(job)
|
||||||
|
.tag(job.id)
|
||||||
|
.contextMenu { self.jobContextMenu(job) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.inset)
|
||||||
|
}
|
||||||
|
.frame(width: 250)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
self.detail
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
var detail: some View {
|
||||||
|
if let selected = self.selectedJob {
|
||||||
|
ScrollView(.vertical) {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
self.detailHeader(selected)
|
||||||
|
self.detailCard(selected)
|
||||||
|
self.runHistoryCard(selected)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.top, 2)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Select a job to inspect details and run history.")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Tip: use ‘New Job’ to add one, or enable cron in your gateway config.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension CronSettings {
|
||||||
|
func jobRow(_ job: CronJob) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(job.displayName)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
Spacer()
|
||||||
|
if !job.enabled {
|
||||||
|
StatusPill(text: "disabled", tint: .secondary)
|
||||||
|
} else if let next = job.nextRunDate {
|
||||||
|
StatusPill(text: self.nextRunLabel(next), tint: .secondary)
|
||||||
|
} else {
|
||||||
|
StatusPill(text: "no next run", tint: .secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
StatusPill(text: job.sessionTarget.rawValue, tint: .secondary)
|
||||||
|
StatusPill(text: job.wakeMode.rawValue, tint: .secondary)
|
||||||
|
if let status = job.state.lastStatus {
|
||||||
|
StatusPill(text: status, tint: status == "ok" ? .green : .orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
func jobContextMenu(_ job: CronJob) -> some View {
|
||||||
|
Button("Run now") { Task { await self.store.runJob(id: job.id, force: true) } }
|
||||||
|
if job.sessionTarget == .isolated {
|
||||||
|
Button("Open transcript") {
|
||||||
|
WebChatManager.shared.show(sessionKey: "cron:\(job.id)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
Button(job.enabled ? "Disable" : "Enable") {
|
||||||
|
Task { await self.store.setJobEnabled(id: job.id, enabled: !job.enabled) }
|
||||||
|
}
|
||||||
|
Button("Edit…") {
|
||||||
|
self.editingJob = job
|
||||||
|
self.editorError = nil
|
||||||
|
self.showEditor = true
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
Button("Delete…", role: .destructive) {
|
||||||
|
self.confirmDelete = job
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func detailHeader(_ job: CronJob) -> some View {
|
||||||
|
HStack(alignment: .center) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(job.displayName)
|
||||||
|
.font(.title3.weight(.semibold))
|
||||||
|
Text(job.id)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Toggle("Enabled", isOn: Binding(
|
||||||
|
get: { job.enabled },
|
||||||
|
set: { enabled in Task { await self.store.setJobEnabled(id: job.id, enabled: enabled) } }))
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
.labelsHidden()
|
||||||
|
Button("Run") { Task { await self.store.runJob(id: job.id, force: true) } }
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
if job.sessionTarget == .isolated {
|
||||||
|
Button("Transcript") {
|
||||||
|
WebChatManager.shared.show(sessionKey: "cron:\(job.id)")
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
Button("Edit") {
|
||||||
|
self.editingJob = job
|
||||||
|
self.editorError = nil
|
||||||
|
self.showEditor = true
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func detailCard(_ job: CronJob) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
LabeledContent("Schedule") { Text(self.scheduleSummary(job.schedule)).font(.callout) }
|
||||||
|
if let desc = job.description, !desc.isEmpty {
|
||||||
|
LabeledContent("Description") { Text(desc).font(.callout) }
|
||||||
|
}
|
||||||
|
LabeledContent("Session") { Text(job.sessionTarget.rawValue) }
|
||||||
|
LabeledContent("Wake") { Text(job.wakeMode.rawValue) }
|
||||||
|
LabeledContent("Next run") {
|
||||||
|
if let date = job.nextRunDate {
|
||||||
|
Text(date.formatted(date: .abbreviated, time: .standard))
|
||||||
|
} else {
|
||||||
|
Text("—").foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LabeledContent("Last run") {
|
||||||
|
if let date = job.lastRunDate {
|
||||||
|
Text("\(date.formatted(date: .abbreviated, time: .standard)) · \(relativeAge(from: date))")
|
||||||
|
} else {
|
||||||
|
Text("—").foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let status = job.state.lastStatus {
|
||||||
|
LabeledContent("Last status") { Text(status) }
|
||||||
|
}
|
||||||
|
if let err = job.state.lastError, !err.isEmpty {
|
||||||
|
Text(err)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
self.payloadSummary(job.payload)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(10)
|
||||||
|
.background(Color.secondary.opacity(0.06))
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runHistoryCard(_ job: CronJob) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("Run history")
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
Task { await self.store.refreshRuns(jobId: job.id) }
|
||||||
|
} label: {
|
||||||
|
Label("Refresh", systemImage: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.disabled(self.store.isLoadingRuns)
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.store.isLoadingRuns {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.store.runEntries.isEmpty {
|
||||||
|
Text("No run log entries yet.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
ForEach(self.store.runEntries) { entry in
|
||||||
|
self.runRow(entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(10)
|
||||||
|
.background(Color.secondary.opacity(0.06))
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runRow(_ entry: CronRunLogEntry) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
StatusPill(text: entry.status ?? "unknown", tint: self.statusTint(entry.status))
|
||||||
|
Text(entry.date.formatted(date: .abbreviated, time: .standard))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
if let ms = entry.durationMs {
|
||||||
|
Text("\(ms)ms")
|
||||||
|
.font(.caption2.monospacedDigit())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let summary = entry.summary, !summary.isEmpty {
|
||||||
|
Text(summary)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
if let error = entry.error, !error.isEmpty {
|
||||||
|
Text(error)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
func payloadSummary(_ payload: CronPayload) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Payload")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
switch payload {
|
||||||
|
case let .systemEvent(text):
|
||||||
|
Text(text)
|
||||||
|
.font(.callout)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, _):
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(message)
|
||||||
|
.font(.callout)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if let thinking, !thinking.isEmpty { StatusPill(text: "think \(thinking)", tint: .secondary) }
|
||||||
|
if let timeoutSeconds { StatusPill(text: "\(timeoutSeconds)s", tint: .secondary) }
|
||||||
|
if deliver ?? false {
|
||||||
|
StatusPill(text: "deliver", tint: .secondary)
|
||||||
|
if let channel, !channel.isEmpty { StatusPill(text: channel, tint: .secondary) }
|
||||||
|
if let to, !to.isEmpty { StatusPill(text: to, tint: .secondary) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
struct CronSettings_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
let store = CronJobsStore(isPreview: true)
|
||||||
|
store.jobs = [
|
||||||
|
CronJob(
|
||||||
|
id: "job-1",
|
||||||
|
name: "Daily summary",
|
||||||
|
description: nil,
|
||||||
|
enabled: true,
|
||||||
|
createdAtMs: 0,
|
||||||
|
updatedAtMs: 0,
|
||||||
|
schedule: .every(everyMs: 86_400_000, anchorMs: nil),
|
||||||
|
sessionTarget: .isolated,
|
||||||
|
wakeMode: .now,
|
||||||
|
payload: .agentTurn(
|
||||||
|
message: "Summarize inbox",
|
||||||
|
thinking: "low",
|
||||||
|
timeoutSeconds: 600,
|
||||||
|
deliver: true,
|
||||||
|
channel: "last",
|
||||||
|
to: nil,
|
||||||
|
bestEffortDeliver: true),
|
||||||
|
isolation: CronIsolation(postToMainPrefix: "Cron"),
|
||||||
|
state: CronJobState(
|
||||||
|
nextRunAtMs: Int(Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000),
|
||||||
|
runningAtMs: nil,
|
||||||
|
lastRunAtMs: nil,
|
||||||
|
lastStatus: nil,
|
||||||
|
lastError: nil,
|
||||||
|
lastDurationMs: nil)),
|
||||||
|
]
|
||||||
|
store.selectedJobId = "job-1"
|
||||||
|
store.runEntries = [
|
||||||
|
CronRunLogEntry(
|
||||||
|
ts: Int(Date().timeIntervalSince1970 * 1000),
|
||||||
|
jobId: "job-1",
|
||||||
|
action: "finished",
|
||||||
|
status: "ok",
|
||||||
|
error: nil,
|
||||||
|
summary: "All good.",
|
||||||
|
runAtMs: nil,
|
||||||
|
durationMs: 1234,
|
||||||
|
nextRunAtMs: nil),
|
||||||
|
]
|
||||||
|
return CronSettings(store: store)
|
||||||
|
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
extension CronSettings {
|
||||||
|
static func exerciseForTesting() {
|
||||||
|
let store = CronJobsStore(isPreview: true)
|
||||||
|
store.schedulerEnabled = false
|
||||||
|
store.schedulerStorePath = "/tmp/clawdis-cron-store.json"
|
||||||
|
|
||||||
|
let job = CronJob(
|
||||||
|
id: "job-1",
|
||||||
|
name: "Daily summary",
|
||||||
|
description: "Summary job",
|
||||||
|
enabled: true,
|
||||||
|
createdAtMs: 1_700_000_000_000,
|
||||||
|
updatedAtMs: 1_700_000_100_000,
|
||||||
|
schedule: .cron(expr: "0 8 * * *", tz: "UTC"),
|
||||||
|
sessionTarget: .isolated,
|
||||||
|
wakeMode: .nextHeartbeat,
|
||||||
|
payload: .agentTurn(
|
||||||
|
message: "Summarize",
|
||||||
|
thinking: "low",
|
||||||
|
timeoutSeconds: 120,
|
||||||
|
deliver: true,
|
||||||
|
channel: "whatsapp",
|
||||||
|
to: "+15551234567",
|
||||||
|
bestEffortDeliver: true),
|
||||||
|
isolation: CronIsolation(postToMainPrefix: "[cron] "),
|
||||||
|
state: CronJobState(
|
||||||
|
nextRunAtMs: 1_700_000_200_000,
|
||||||
|
runningAtMs: nil,
|
||||||
|
lastRunAtMs: 1_700_000_050_000,
|
||||||
|
lastStatus: "ok",
|
||||||
|
lastError: nil,
|
||||||
|
lastDurationMs: 1200))
|
||||||
|
|
||||||
|
let run = CronRunLogEntry(
|
||||||
|
ts: 1_700_000_050_000,
|
||||||
|
jobId: job.id,
|
||||||
|
action: "finished",
|
||||||
|
status: "ok",
|
||||||
|
error: nil,
|
||||||
|
summary: "done",
|
||||||
|
runAtMs: 1_700_000_050_000,
|
||||||
|
durationMs: 1200,
|
||||||
|
nextRunAtMs: 1_700_000_200_000)
|
||||||
|
|
||||||
|
store.jobs = [job]
|
||||||
|
store.selectedJobId = job.id
|
||||||
|
store.runEntries = [run]
|
||||||
|
|
||||||
|
let view = CronSettings(store: store)
|
||||||
|
_ = view.body
|
||||||
|
_ = view.jobRow(job)
|
||||||
|
_ = view.jobContextMenu(job)
|
||||||
|
_ = view.detailHeader(job)
|
||||||
|
_ = view.detailCard(job)
|
||||||
|
_ = view.runHistoryCard(job)
|
||||||
|
_ = view.runRow(run)
|
||||||
|
_ = view.payloadSummary(job.payload)
|
||||||
|
_ = view.scheduleSummary(job.schedule)
|
||||||
|
_ = view.statusTint(job.state.lastStatus)
|
||||||
|
_ = view.nextRunLabel(Date())
|
||||||
|
_ = view.formatDuration(ms: 1234)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
Loaded 100 of 449 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user