Compare commits

..
Author SHA1 Message Date
Peter Steinberger 68ecee84d1 fix: stream tool summaries early and tool output 2026-01-03 20:37:57 +01:00
Peter Steinberger d1ebc4489a fix: document macOS permission requirements 2026-01-03 20:04:27 +01:00
Jake a71efbe7de Scripts: Make ad-hoc fallback opt-in with stronger TCC warnings 2026-01-03 20:04:27 +01:00
Jake a7b8c0d963 Scripts: Fallback to ad-hoc signing in codesign-mac-app.sh 2026-01-03 20:04:26 +01:00
Peter Steinberger 55a07a0ef0 style: fix lint formatting 2026-01-03 18:51:25 +00:00
Peter Steinberger 9899ba53a3 Docs: add PR number for Discord reactions 2026-01-03 18:48:36 +00:00
Peter Steinberger 52458a5628 Discord: default reaction notifications to own 2026-01-03 18:48:36 +00:00
Shadow 7abd6713c8 Docs: clarify discord reaction notifications 2026-01-03 18:48:36 +00:00
Shadow 451174ca10 Discord: add reaction notification allowlist 2026-01-03 18:48:36 +00:00
Peter Steinberger cdfbd6e7eb test(gateway): align config constants in auth test 2026-01-03 19:37:09 +01:00
Peter Steinberger 350e007a5c test(agents): extend text_end coverage 2026-01-03 19:37:09 +01:00
Peter Steinberger 5e156135a1 test(gateway): avoid hoisted export errors 2026-01-03 19:37:09 +01:00
Peter Steinberger b7ec9ae475 fix(gateway): format status/code errors 2026-01-03 19:37:09 +01:00
Peter Steinberger 8a18af409d test(gateway): cover helper registries 2026-01-03 19:37:09 +01:00
Peter Steinberger 6a125b554b refactor(gateway): split server helpers 2026-01-03 19:37:09 +01:00
Shadow ce92fac983 chore: formatting 2026-01-03 12:35:16 -06:00
Peter SteinbergerandMaxim Vovshin 341a224301 docs: credit Hyaxia in changelog and credits
Co-authored-by: Maxim Vovshin <36747317+Hyaxia@users.noreply.github.com>
2026-01-03 18:05:46 +00:00
Peter Steinberger 95cd153f33 feat: add blogwatcher skill 2026-01-03 18:00:08 +00:00
Peter Steinberger 0af89022ff fix: avoid Swift compiler crash in onboarding wizard 2026-01-03 17:59:37 +00:00
Peter Steinberger 27a8f3d061 chore: add inline guidance for block streaming 2026-01-03 18:46:59 +01:00
Peter Steinberger 72b34f7d03 fix: harden block stream dedupe 2026-01-03 18:44:07 +01:00
Peter Steinberger 73fa2e10bc refactor: split gateway server methods 2026-01-03 18:14:07 +01:00
Peter Steinberger 4a6b33d799 refactor: add gateway server helper modules 2026-01-03 18:00:45 +01:00
Peter Steinberger 145964c85e feat: add github skill 2026-01-03 17:57:13 +01:00
Peter Steinberger 217b84f2ac fix: drop final payloads after block streaming 2026-01-03 17:55:31 +01:00
Peter Steinberger 1d6de24ab3 feat: configurable control ui base path 2026-01-03 17:55:31 +01:00
Peter Steinberger 822def84d2 docs(faq): add Tailscale bind conflict + model/thinking compatibility
- Added Tailscale serve requires bind: loopback (not lan)
- Added model + thinking mode issues section (Gemini Flash, Opus, local LLMs)

From Discord #help session learnings 🦞
2026-01-03 16:53:56 +00:00
Peter Steinberger f313af75e9 fix: avoid duplicate block-stream payloads 2026-01-03 16:53:56 +00:00
Peter Steinberger 591773715e fix: honor whatsapp per-group mention overrides 2026-01-03 17:51:10 +01:00
Peter Steinberger dd6b9b510b docs: update changelog for gateway refactor 2026-01-03 17:35:29 +01:00
Peter Steinberger 6ae51ae3de refactor: split gateway server helpers and tests 2026-01-03 17:34:52 +01:00
Peter Steinberger 00c3e98431 docs: add tmux skill guidance 2026-01-03 17:31:26 +01:00
Peter Steinberger dd561f58d1 docs: expand coding-agent Pi usage 2026-01-03 17:21:17 +01:00
Peter Steinberger 200dd634fb fix: preserve block streaming order 2026-01-03 17:14:01 +01:00
Peter Steinberger 3bbdcaf87f fix: avoid duplicate block streaming 2026-01-03 17:10:47 +01:00
Peter Steinberger abff5e3b1f docs: thank @ratulsarna for control UI UUID fallback 2026-01-03 15:56:36 +00:00
Peter Steinberger 40ee0f0672 build: lock x86_64 relay to AVX2 2026-01-03 16:52:06 +01:00
Peter Steinberger 9f8eeceae7 feat: soften block streaming chunking 2026-01-03 16:48:26 +01:00
Peter Steinberger 53baba71fa feat: unify onboarding + config schema 2026-01-03 16:48:08 +01:00
Peter Steinberger 0f85080d81 Merge pull request #133 from ratulsarna/fix/ui-http-uuid
fix(ui): robust UUID generation for HTTP Control UI
2026-01-03 16:16:43 +01:00
Peter Steinberger 72f8148080 fix: clean up embedded lint 2026-01-03 15:09:07 +00:00
Peter Steinberger be3da5b856 fix: update protocol models and android parsing 2026-01-03 15:04:24 +00:00
Peter Steinberger 9a9b429f74 fix: elevate embedded run logs to info 2026-01-03 15:03:03 +00:00
Peter Steinberger 733e86516e fix: address runtime warnings in build 2026-01-03 15:01:38 +00:00
Peter Steinberger 1a00175eb7 chore: fix lint formatting 2026-01-03 14:57:49 +00:00
Peter Steinberger 77c76ca52f test: fix transcription and tool schema assertions 2026-01-03 14:55:05 +00:00
Peter Steinberger 5de3395204 fix: resolve gcloud python path 2026-01-03 14:36:48 +00:00
Peter Steinberger 4e4655f607 docs(faq): use correct codex login --device-auth command 2026-01-03 14:13:18 +00:00
Peter Steinberger 48731f494b fix: add embedded run logs and typing ttl 2026-01-03 14:09:19 +00:00
Peter SteinbergerandClawd 4fcd89c3d9 docs(faq): add stop/cancel task + Codex subscription auth sections
- Added FAQ for /stop and other abort commands
- Added FAQ explaining Codex CLI browser auth vs API key
- Browser OAuth uses ChatGPT Pro subscription, API key is pay-per-token

Co-authored-by: Clawd <clawdbot@gmail.com>
2026-01-03 14:08:24 +00:00
Peter Steinberger a4f433a1b1 docs: update onboarding steps 2026-01-03 14:08:24 +00:00
Ratul Sarna 84a7ee491b fix(ui): robust UUID generation on HTTP
Fixes #131
2026-01-03 13:43:20 +00:00
Peter Steinberger 3043dd3a0c fix: restructure macOS connections settings 2026-01-03 14:25:03 +01:00
Jake 81f4a7cdb7 Agents: Fix Gemini schema compatibility and robust model discovery 2026-01-03 13:57:29 +01:00
Peter Steinberger c2a74d6d2a docs(template): add 'Write It Down' rule to AGENTS.md template
Mental notes don't survive sessions. Files do. Text > Brain 📝
2026-01-03 12:52:11 +00:00
Peter Steinberger 861e1b33f5 docs(skill): add PR review safety rules for coding-agent
- Never checkout branches in live Clawdis repo
- Clone to temp folder or use git worktree for reviews
- Added explicit examples for safe PR review workflow
2026-01-03 12:49:03 +00:00
Peter Steinberger 0647d56555 fix(build): repair tool-meta regex literal 2026-01-03 12:46:33 +00:00
Peter Steinberger ea6aea8532 docs: warn about gmail watcher port conflict 2026-01-03 12:41:44 +00:00
Peter Steinberger 6eca2edd79 chore(swift): update Swabble package lock 2026-01-03 13:38:18 +01:00
Peter Steinberger d31dfbc565 chore(canvas): refresh a2ui bundle hash 2026-01-03 13:38:12 +01:00
Peter Steinberger 1e0f776824 test(gateway): add multi-instance e2e suite 2026-01-03 13:37:46 +01:00
Peter Steinberger db36f0105d fix(gateway): validate event/response frames 2026-01-03 13:37:40 +01:00
Peter Steinberger 5377e2400a fix: avoid red gmail-watcher prefix 2026-01-03 12:36:15 +00:00
Peter Steinberger 72c0aa63fb style: tidy imports and formatting 2026-01-03 12:35:23 +00:00
Peter Steinberger 933bee220f fix(cron): pass resolved channel to agent tools 2026-01-03 12:35:23 +00:00
Peter Steinberger bd2dabfa8f fix(agents): load tool display config from disk 2026-01-03 12:35:23 +00:00
Peter Steinberger f11b352089 fix(macos): expand onboarding window height 2026-01-03 13:34:30 +01:00
Peter Steinberger bb54e60179 fix(logging): decouple file logs from console verbose 2026-01-03 12:32:14 +00:00
Peter Steinberger e52bdaa2a2 fix: repair tool meta regex 2026-01-03 12:30:46 +00:00
Peter Steinberger b6301c719b fix: default low thinking for reasoning models 2026-01-03 12:19:06 +00:00
Peter Steinberger 6e16c0699a feat: centralize tool display metadata 2026-01-03 13:18:27 +01:00
Peter SteinbergerandClawd bf4ad295af docs(faq): add media/vision troubleshooting section
- Added FAQ entry for images/media not being understood
- Covers vision-capable models checklist
- Debugging steps for media pipeline
- Link to summarize.sh for exotic files

Co-authored-by: Clawd <clawdbot@gmail.com>
2026-01-03 11:43:40 +00:00
Peter Steinberger 7a80e8fe77 refactor: centralize home path shortening 2026-01-03 12:42:27 +01:00
Peter Steinberger 1ec3512925 refactor!: drop clawdis_ tool prefix 2026-01-03 12:39:52 +01:00
Peter Steinberger 772ada4308 fix: refine tool summaries and scope discord tool 2026-01-03 12:33:42 +01:00
Peter Steinberger 7165c8a7e5 refactor: rename bundle identifiers to com.clawdis 2026-01-03 12:26:22 +01:00
Peter Steinberger daa1460502 docs(discord): document sendMessage mediaUrl and to format
- Add example for sendMessage with media attachment (file:// and https://)
- Clarify that sendMessage uses 'to: channel:<id>' not 'channelId'
- Document replyTo parameter for replying to specific messages
- Add mediaUrl to inputs section
2026-01-03 11:05:09 +00:00
Peter Steinberger f47c7ac369 feat: support configurable gateway port 2026-01-03 12:00:17 +01:00
Peter Steinberger 7199813969 docs: document gateway port configuration 2026-01-03 11:46:58 +01:00
Peter SteinbergerandTyler Wince 87d5fa516d docs(skills): correct bear-notes instructions
Co-authored-by: Tyler Wince <tylerwince@users.noreply.github.com>
2026-01-03 11:34:31 +01:00
Claude 10340d2a3f feat(skills): add bear-notes skill using grizzly CLI 2026-01-03 11:29:14 +01:00
Peter Steinberger 508c4d362f docs: update changelog for gog skill 2026-01-03 11:20:17 +01:00
Mariano Belinky f73b008251 docs: add Sheets/Docs examples to gog skill 2026-01-03 11:20:17 +01:00
342 changed files with 20999 additions and 12161 deletions

No files matched your search

+44 -1
View File
@@ -2,11 +2,53 @@
## Unreleased ## Unreleased
### Breaking
- Identifiers: rename bundle IDs and internal domains to `com.clawdis.*` (macOS: `com.clawdis.mac`, iOS: `com.clawdis.ios`, Android: `com.clawdis.android`) and update the gateway LaunchAgent label to `com.clawdis.gateway`.
- Agent tools: drop the `clawdis_` prefix (`browser`, `canvas`, `nodes`, `cron`, `gateway`).
### Features
- Gateway: support `gateway.port` + `CLAWDIS_GATEWAY_PORT` across CLI, TUI, and macOS app.
- UI: centralize tool display metadata and show action/detail summaries across Web Chat, SwiftUI, Android, and the TUI.
- Control UI: support configurable base paths (`gateway.controlUi.basePath`, default unchanged) for hosting under URL prefixes.
- Onboarding: shared wizard engine powering CLI + macOS via gateway wizard RPC.
- Config: expose schema + UI hints for generic config forms (Web UI + future clients).
- Skills: add blogwatcher skill for RSS/Atom monitoring — thanks @Hyaxia.
- Discord: emit system events for reaction add/remove with per-guild reaction notifications (off|own|all|allowlist) (#140) — thanks @thewilloftheshadow.
### Fixes ### Fixes
- Auto-reply: drop final payloads when block streaming to avoid duplicate Discord sends.
- Telegram: chunk block-stream replies to avoid “message is too long” errors (#124) — thanks @mukhtharcm. - Telegram: chunk block-stream replies to avoid “message is too long” errors (#124) — thanks @mukhtharcm.
- Block streaming: default to text_end and suppress duplicate block sends while in-flight.
- Block streaming: avoid duplicate block chunks when providers repeat full content on text_end.
- Block streaming: drop final payloads after soft chunking to keep Discord order intact.
- Gmail hooks: resolve gcloud Python to a real executable when PATH uses mise shims — thanks @joargp.
- Control UI: generate UUIDs when `crypto.randomUUID()` is unavailable over HTTP — thanks @ratulsarna.
- Control UI: stream live tool output cards in Chat (agent events include sessionKey).
- Agent: add soft block-stream chunking (800–1200 chars default) with paragraph/newline preference.
- Agent tools: scope the Discord tool to Discord surface runs.
- Agent tools: format verbose tool summaries without brackets, with unique emojis and `tool: detail` style.
- Agent tools: emit verbose tool summaries at tool start (no debounce).
- Gateway: split server helpers/tests into hooks/session-utils/ws-log/net modules for better isolation; add unit coverage for hooks/session utils/ws log.
- Gateway: extract WS method handling + HTTP/provider/constant helpers to shrink server wiring and improve testability.
- Onboarding: fix Control UI basePath usage when showing/opening gateway URLs.
- macOS Connections: move to sidebar + detail layout with structured sections and header actions.
- macOS onboarding: increase window height so the permissions page fits without scrolling.
- Thinking: default to low for reasoning-capable models when no /think or config default is set.
- Logging: decouple file log levels from console verbosity; verbose-only details are captured when `logging.level` is debug/trace.
- Build: fix regex literal in tool-meta path detection (watch build error).
- Build: require AVX2 Bun for x86_64 relay packaging (reject baseline builds).
- Auto-reply: add run-level telemetry + typing TTL guardrails to diagnose stuck replies.
- WhatsApp: honor per-group mention gating overrides when group ids are stored as session keys.
- Dependencies: bump pi-mono packages to 0.32.3.
### Docs ### Docs
- Skills: add Sheets/Docs examples to gog skill (#128) — thanks @mbelinky. - Skills: add Sheets/Docs examples to gog skill (#128) — thanks @mbelinky.
- Skills: clarify bear-notes token + callback usage (#120) — thanks @tylerwince.
- Skills: document Discord `sendMessage` media attachments and `to` format clarification.
- Skills: add tmux skill + interactive coding guidance in coding-agent.
- Gateway: document port configuration + multi-instance isolation.
- Onboarding/Config: add protocol notes for wizard + schema RPC.
- Queue: clarify steer-backlog behavior with inline commands and update examples for streaming surfaces.
## 2.0.0-beta5 — 2026-01-03 ## 2.0.0-beta5 — 2026-01-03
@@ -68,6 +110,7 @@
- Skills: add tmux-first coding-agent skill + `requires.anyBins` gate for multi-CLI setup (thanks @sreekaransrinath). - Skills: add tmux-first coding-agent skill + `requires.anyBins` gate for multi-CLI setup (thanks @sreekaransrinath).
### Fixes ### Fixes
- macOS codesign: make ad-hoc signing opt-in with loud warnings and document TCC permission fragility — thanks @mcinteerj.
- Gog calendar: format date ranges as RFC 3339 with timezone to satisfy Google Calendar API (thanks @jayhickey). - Gog calendar: format date ranges as RFC 3339 with timezone to satisfy Google Calendar API (thanks @jayhickey).
- macOS onboarding: add scrollable page gutter for overflowing content (#105) — thanks @thewilloftheshadow. - macOS onboarding: add scrollable page gutter for overflowing content (#105) — thanks @thewilloftheshadow.
- Chat UI: keep the chat scrolled to the latest message after switching sessions. - Chat UI: keep the chat scrolled to the latest message after switching sessions.
@@ -219,7 +262,7 @@
- Hook mappings + Gmail Pub/Sub helper (`clawdis hooks gmail setup/run`) with auto-renew + Tailscale Funnel support. - 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. - 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. - 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. - Gateway in-process restart: `gateway` tool action triggers a SIGUSR1 restart without needing a supervisor.
### Breaking ### 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. - Config refactor: `inbound.*` removed; use top-level `routing` (allowlists + group rules + transcription), `messages` (prefixes/timestamps), and `session` (scoping/store/mainKey). No legacy keys read.
+1 -1
View File
@@ -109,7 +109,7 @@ Send these in WhatsApp/Telegram/WebChat (group commands are owner-only):
- **Events + snapshot**: handshake returns a snapshot (presence/health) and declares event types; runtime events include `agent`, `chat`, `presence`, `tick`, `health`, `heartbeat`, `cron`, `node.pair.*`, `voicewake.changed`, `shutdown`. - **Events + snapshot**: handshake returns a snapshot (presence/health) and declares event types; runtime events include `agent`, `chat`, `presence`, `tick`, `health`, `heartbeat`, `cron`, `node.pair.*`, `voicewake.changed`, `shutdown`.
- **Idempotency & safety**: `send`/`agent`/`chat.send` require idempotency keys with a TTL cache (5 min, cap 1000) to avoid double‑sends on reconnects; payload sizes are capped per connection. - **Idempotency & safety**: `send`/`agent`/`chat.send` require idempotency keys with a TTL cache (5 min, cap 1000) to avoid double‑sends on reconnects; payload sizes are capped per connection.
- **Bridge for nodes**: optional TCP bridge (`src/infra/bridge/server.ts`) is newline‑delimited JSON frames (`hello`, pairing, RPC, `invoke`); node connect/disconnect is surfaced into presence. - **Bridge for nodes**: optional TCP bridge (`src/infra/bridge/server.ts`) is newline‑delimited JSON frames (`hello`, pairing, RPC, `invoke`); node connect/disconnect is surfaced into presence.
- **Control UI + Canvas Host**: HTTP serves `/ui` assets (if built) and can host a live‑reload Canvas host for nodes (`src/canvas-host/server.ts`), injecting the A2UI postMessage bridge. - **Control UI + Canvas Host**: HTTP serves Control UI assets (default `/`, optional base path) and can host a live‑reload Canvas host for nodes (`src/canvas-host/server.ts`), injecting the A2UI postMessage bridge.
### iOS app (apps/ios) ### iOS app (apps/ios)
- **Discovery + pairing**: Bonjour discovery via `BridgeDiscoveryModel` (NWBrowser). `BridgeConnectionController` auto‑connects using Keychain token or allows manual host/port. - **Discovery + pairing**: Bonjour discovery via `BridgeDiscoveryModel` (NWBrowser). `BridgeConnectionController` auto‑connects using Keychain token or allows manual host/port.
+10 -1
View File
@@ -1,6 +1,15 @@
{ {
"originHash" : "2012d083159d375d07febbc184c592c569d7ab48247045e35a762e3269d4cadc", "originHash" : "5d29ee82825e0764775562242cfa1ff4dc79584797dd638f76c9876545454748",
"pins" : [ "pins" : [
{
"identity" : "elevenlabskit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/ElevenLabsKit",
"state" : {
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
"version" : "0.1.0"
}
},
{ {
"identity" : "swift-syntax", "identity" : "swift-syntax",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
+2 -2
View File
@@ -6,7 +6,7 @@ plugins {
} }
android { android {
namespace = "com.steipete.clawdis.node" namespace = "com.clawdis.android"
compileSdk = 36 compileSdk = 36
sourceSets { sourceSets {
@@ -16,7 +16,7 @@ android {
} }
defaultConfig { defaultConfig {
applicationId = "com.steipete.clawdis.node" applicationId = "com.clawdis.android"
minSdk = 31 minSdk = 31
targetSdk = 36 targetSdk = 36
versionCode = 1 versionCode = 1
@@ -0,0 +1,197 @@
{
"version": 1,
"fallback": {
"emoji": "🧩",
"detailKeys": [
"command",
"path",
"url",
"targetUrl",
"targetId",
"ref",
"element",
"node",
"nodeId",
"jobId",
"requestId",
"to",
"channelId",
"guildId",
"userId",
"name",
"query",
"pattern",
"messageId"
]
},
"tools": {
"bash": {
"emoji": "🛠️",
"title": "Bash",
"detailKeys": ["command"]
},
"process": {
"emoji": "🧰",
"title": "Process",
"detailKeys": ["sessionId"]
},
"read": {
"emoji": "📖",
"title": "Read",
"detailKeys": ["path"]
},
"write": {
"emoji": "✍️",
"title": "Write",
"detailKeys": ["path"]
},
"edit": {
"emoji": "📝",
"title": "Edit",
"detailKeys": ["path"]
},
"attach": {
"emoji": "📎",
"title": "Attach",
"detailKeys": ["path", "url", "fileName"]
},
"browser": {
"emoji": "🌐",
"title": "Browser",
"actions": {
"status": { "label": "status" },
"start": { "label": "start" },
"stop": { "label": "stop" },
"tabs": { "label": "tabs" },
"open": { "label": "open", "detailKeys": ["targetUrl"] },
"focus": { "label": "focus", "detailKeys": ["targetId"] },
"close": { "label": "close", "detailKeys": ["targetId"] },
"snapshot": {
"label": "snapshot",
"detailKeys": ["targetUrl", "targetId", "ref", "element", "format"]
},
"screenshot": {
"label": "screenshot",
"detailKeys": ["targetUrl", "targetId", "ref", "element"]
},
"navigate": {
"label": "navigate",
"detailKeys": ["targetUrl", "targetId"]
},
"console": { "label": "console", "detailKeys": ["level", "targetId"] },
"pdf": { "label": "pdf", "detailKeys": ["targetId"] },
"upload": {
"label": "upload",
"detailKeys": ["paths", "ref", "inputRef", "element", "targetId"]
},
"dialog": {
"label": "dialog",
"detailKeys": ["accept", "promptText", "targetId"]
},
"act": {
"label": "act",
"detailKeys": ["request.kind", "request.ref", "request.selector", "request.text", "request.value"]
}
}
},
"canvas": {
"emoji": "🖼️",
"title": "Canvas",
"actions": {
"present": { "label": "present", "detailKeys": ["target", "node", "nodeId"] },
"hide": { "label": "hide", "detailKeys": ["node", "nodeId"] },
"navigate": { "label": "navigate", "detailKeys": ["url", "node", "nodeId"] },
"eval": { "label": "eval", "detailKeys": ["javaScript", "node", "nodeId"] },
"snapshot": { "label": "snapshot", "detailKeys": ["format", "node", "nodeId"] },
"a2ui_push": { "label": "A2UI push", "detailKeys": ["jsonlPath", "node", "nodeId"] },
"a2ui_reset": { "label": "A2UI reset", "detailKeys": ["node", "nodeId"] }
}
},
"nodes": {
"emoji": "📱",
"title": "Nodes",
"actions": {
"status": { "label": "status" },
"describe": { "label": "describe", "detailKeys": ["node", "nodeId"] },
"pending": { "label": "pending" },
"approve": { "label": "approve", "detailKeys": ["requestId"] },
"reject": { "label": "reject", "detailKeys": ["requestId"] },
"notify": { "label": "notify", "detailKeys": ["node", "nodeId", "title", "body"] },
"camera_snap": { "label": "camera snap", "detailKeys": ["node", "nodeId", "facing", "deviceId"] },
"camera_list": { "label": "camera list", "detailKeys": ["node", "nodeId"] },
"camera_clip": { "label": "camera clip", "detailKeys": ["node", "nodeId", "facing", "duration", "durationMs"] },
"screen_record": {
"label": "screen record",
"detailKeys": ["node", "nodeId", "duration", "durationMs", "fps", "screenIndex"]
}
}
},
"cron": {
"emoji": "⏰",
"title": "Cron",
"actions": {
"status": { "label": "status" },
"list": { "label": "list" },
"add": {
"label": "add",
"detailKeys": ["job.name", "job.id", "job.schedule", "job.cron"]
},
"update": { "label": "update", "detailKeys": ["jobId"] },
"remove": { "label": "remove", "detailKeys": ["jobId"] },
"run": { "label": "run", "detailKeys": ["jobId"] },
"runs": { "label": "runs", "detailKeys": ["jobId"] },
"wake": { "label": "wake", "detailKeys": ["text", "mode"] }
}
},
"gateway": {
"emoji": "🔌",
"title": "Gateway",
"actions": {
"restart": { "label": "restart", "detailKeys": ["reason", "delayMs"] }
}
},
"whatsapp_login": {
"emoji": "🟢",
"title": "WhatsApp Login",
"actions": {
"start": { "label": "start" },
"wait": { "label": "wait" }
}
},
"discord": {
"emoji": "💬",
"title": "Discord",
"actions": {
"react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji"] },
"reactions": { "label": "reactions", "detailKeys": ["channelId", "messageId"] },
"sticker": { "label": "sticker", "detailKeys": ["to", "stickerIds"] },
"poll": { "label": "poll", "detailKeys": ["question", "to"] },
"permissions": { "label": "permissions", "detailKeys": ["channelId"] },
"readMessages": { "label": "read messages", "detailKeys": ["channelId", "limit"] },
"sendMessage": { "label": "send", "detailKeys": ["to", "content"] },
"editMessage": { "label": "edit", "detailKeys": ["channelId", "messageId"] },
"deleteMessage": { "label": "delete", "detailKeys": ["channelId", "messageId"] },
"threadCreate": { "label": "thread create", "detailKeys": ["channelId", "name"] },
"threadList": { "label": "thread list", "detailKeys": ["guildId", "channelId"] },
"threadReply": { "label": "thread reply", "detailKeys": ["channelId", "content"] },
"pinMessage": { "label": "pin", "detailKeys": ["channelId", "messageId"] },
"unpinMessage": { "label": "unpin", "detailKeys": ["channelId", "messageId"] },
"listPins": { "label": "list pins", "detailKeys": ["channelId"] },
"searchMessages": { "label": "search", "detailKeys": ["guildId", "content"] },
"memberInfo": { "label": "member", "detailKeys": ["guildId", "userId"] },
"roleInfo": { "label": "roles", "detailKeys": ["guildId"] },
"emojiList": { "label": "emoji list", "detailKeys": ["guildId"] },
"roleAdd": { "label": "role add", "detailKeys": ["guildId", "userId", "roleId"] },
"roleRemove": { "label": "role remove", "detailKeys": ["guildId", "userId", "roleId"] },
"channelInfo": { "label": "channel", "detailKeys": ["channelId"] },
"channelList": { "label": "channels", "detailKeys": ["guildId"] },
"voiceStatus": { "label": "voice", "detailKeys": ["guildId", "userId"] },
"eventList": { "label": "events", "detailKeys": ["guildId"] },
"eventCreate": { "label": "event create", "detailKeys": ["guildId", "name"] },
"timeout": { "label": "timeout", "detailKeys": ["guildId", "userId"] },
"kick": { "label": "kick", "detailKeys": ["guildId", "userId"] },
"ban": { "label": "ban", "detailKeys": ["guildId", "userId"] }
}
}
}
}
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node package com.clawdis.android
enum class CameraHudKind { enum class CameraHudKind {
Photo, Photo,
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node package com.clawdis.android
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node package com.clawdis.android
import android.Manifest import android.Manifest
import android.content.pm.ApplicationInfo import android.content.pm.ApplicationInfo
@@ -18,8 +18,8 @@ import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import com.steipete.clawdis.node.ui.RootScreen import com.clawdis.android.ui.RootScreen
import com.steipete.clawdis.node.ui.ClawdisTheme import com.clawdis.android.ui.ClawdisTheme
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@@ -1,12 +1,12 @@
package com.steipete.clawdis.node package com.clawdis.android
import android.app.Application import android.app.Application
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import com.steipete.clawdis.node.bridge.BridgeEndpoint import com.clawdis.android.bridge.BridgeEndpoint
import com.steipete.clawdis.node.chat.OutgoingAttachment import com.clawdis.android.chat.OutgoingAttachment
import com.steipete.clawdis.node.node.CameraCaptureManager import com.clawdis.android.node.CameraCaptureManager
import com.steipete.clawdis.node.node.CanvasController import com.clawdis.android.node.CanvasController
import com.steipete.clawdis.node.node.ScreenRecordManager import com.clawdis.android.node.ScreenRecordManager
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
class MainViewModel(app: Application) : AndroidViewModel(app) { class MainViewModel(app: Application) : AndroidViewModel(app) {
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node package com.clawdis.android
import android.app.Application import android.app.Application
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node package com.clawdis.android
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
@@ -146,7 +146,7 @@ class NodeForegroundService : Service() {
private const val CHANNEL_ID = "connection" private const val CHANNEL_ID = "connection"
private const val NOTIFICATION_ID = 1 private const val NOTIFICATION_ID = 1
private const val ACTION_STOP = "com.steipete.clawdis.node.action.STOP" private const val ACTION_STOP = "com.clawdis.android.action.STOP"
fun start(context: Context) { fun start(context: Context) {
val intent = Intent(context, NodeForegroundService::class.java) val intent = Intent(context, NodeForegroundService::class.java)
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node package com.clawdis.android
import android.Manifest import android.Manifest
import android.content.Context import android.content.Context
@@ -6,27 +6,27 @@ import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.SystemClock import android.os.SystemClock
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.steipete.clawdis.node.chat.ChatController import com.clawdis.android.chat.ChatController
import com.steipete.clawdis.node.chat.ChatMessage import com.clawdis.android.chat.ChatMessage
import com.steipete.clawdis.node.chat.ChatPendingToolCall import com.clawdis.android.chat.ChatPendingToolCall
import com.steipete.clawdis.node.chat.ChatSessionEntry import com.clawdis.android.chat.ChatSessionEntry
import com.steipete.clawdis.node.chat.OutgoingAttachment import com.clawdis.android.chat.OutgoingAttachment
import com.steipete.clawdis.node.bridge.BridgeDiscovery import com.clawdis.android.bridge.BridgeDiscovery
import com.steipete.clawdis.node.bridge.BridgeEndpoint import com.clawdis.android.bridge.BridgeEndpoint
import com.steipete.clawdis.node.bridge.BridgePairingClient import com.clawdis.android.bridge.BridgePairingClient
import com.steipete.clawdis.node.bridge.BridgeSession import com.clawdis.android.bridge.BridgeSession
import com.steipete.clawdis.node.node.CameraCaptureManager import com.clawdis.android.node.CameraCaptureManager
import com.steipete.clawdis.node.BuildConfig import com.clawdis.android.BuildConfig
import com.steipete.clawdis.node.node.CanvasController import com.clawdis.android.node.CanvasController
import com.steipete.clawdis.node.node.ScreenRecordManager import com.clawdis.android.node.ScreenRecordManager
import com.steipete.clawdis.node.protocol.ClawdisCapability import com.clawdis.android.protocol.ClawdisCapability
import com.steipete.clawdis.node.protocol.ClawdisCameraCommand import com.clawdis.android.protocol.ClawdisCameraCommand
import com.steipete.clawdis.node.protocol.ClawdisCanvasA2UIAction import com.clawdis.android.protocol.ClawdisCanvasA2UIAction
import com.steipete.clawdis.node.protocol.ClawdisCanvasA2UICommand import com.clawdis.android.protocol.ClawdisCanvasA2UICommand
import com.steipete.clawdis.node.protocol.ClawdisCanvasCommand import com.clawdis.android.protocol.ClawdisCanvasCommand
import com.steipete.clawdis.node.protocol.ClawdisScreenCommand import com.clawdis.android.protocol.ClawdisScreenCommand
import com.steipete.clawdis.node.voice.TalkModeManager import com.clawdis.android.voice.TalkModeManager
import com.steipete.clawdis.node.voice.VoiceWakeManager import com.clawdis.android.voice.VoiceWakeManager
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node package com.clawdis.android
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.Intent import android.content.Intent
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node package com.clawdis.android
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
@@ -1,6 +1,6 @@
@file:Suppress("DEPRECATION") @file:Suppress("DEPRECATION")
package com.steipete.clawdis.node package com.clawdis.android
import android.content.Context import android.content.Context
import androidx.core.content.edit import androidx.core.content.edit
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node package com.clawdis.android
enum class VoiceWakeMode(val rawValue: String) { enum class VoiceWakeMode(val rawValue: String) {
Off("off"), Off("off"),
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node package com.clawdis.android
object WakeWords { object WakeWords {
const val maxWords: Int = 32 const val maxWords: Int = 32
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.bridge package com.clawdis.android.bridge
object BonjourEscapes { object BonjourEscapes {
fun decode(input: String): String { fun decode(input: String): String {
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.bridge package com.clawdis.android.bridge
import android.content.Context import android.content.Context
import android.net.ConnectivityManager import android.net.ConnectivityManager
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.bridge package com.clawdis.android.bridge
data class BridgeEndpoint( data class BridgeEndpoint(
val stableId: String, val stableId: String,
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.bridge package com.clawdis.android.bridge
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.bridge package com.clawdis.android.bridge
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -11,7 +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 com.clawdis.android.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
@@ -1,6 +1,6 @@
package com.steipete.clawdis.node.chat package com.clawdis.android.chat
import com.steipete.clawdis.node.bridge.BridgeSession import com.clawdis.android.bridge.BridgeSession
import java.util.UUID import java.util.UUID
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -361,10 +361,12 @@ class ChatController(
val ts = payload["ts"].asLongOrNull() ?: System.currentTimeMillis() val ts = payload["ts"].asLongOrNull() ?: System.currentTimeMillis()
if (phase == "start") { if (phase == "start") {
val args = data?.get("args").asObjectOrNull()
pendingToolCallsById[toolCallId] = pendingToolCallsById[toolCallId] =
ChatPendingToolCall( ChatPendingToolCall(
toolCallId = toolCallId, toolCallId = toolCallId,
name = name, name = name,
args = args,
startedAtMs = ts, startedAtMs = ts,
isError = null, isError = null,
) )
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.chat package com.clawdis.android.chat
data class ChatMessage( data class ChatMessage(
val id: String, val id: String,
@@ -18,6 +18,7 @@ data class ChatMessageContent(
data class ChatPendingToolCall( data class ChatPendingToolCall(
val toolCallId: String, val toolCallId: String,
val name: String, val name: String,
val args: kotlinx.serialization.json.JsonObject? = null,
val startedAtMs: Long, val startedAtMs: Long,
val isError: Boolean? = null, val isError: Boolean? = null,
) )
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.node package com.clawdis.android.node
import android.Manifest import android.Manifest
import android.content.Context import android.content.Context
@@ -20,7 +20,7 @@ import androidx.camera.video.VideoRecordEvent
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.ContextCompat.checkSelfPermission import androidx.core.content.ContextCompat.checkSelfPermission
import androidx.core.graphics.scale import androidx.core.graphics.scale
import com.steipete.clawdis.node.PermissionRequester import com.clawdis.android.PermissionRequester
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.node package com.clawdis.android.node
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Canvas import android.graphics.Canvas
@@ -17,7 +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 com.clawdis.android.BuildConfig
import kotlin.coroutines.resume import kotlin.coroutines.resume
class CanvasController { class CanvasController {
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.node package com.clawdis.android.node
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
@@ -1,11 +1,11 @@
package com.steipete.clawdis.node.node package com.clawdis.android.node
import android.content.Context import android.content.Context
import android.hardware.display.DisplayManager import android.hardware.display.DisplayManager
import android.media.MediaRecorder import android.media.MediaRecorder
import android.media.projection.MediaProjectionManager import android.media.projection.MediaProjectionManager
import android.util.Base64 import android.util.Base64
import com.steipete.clawdis.node.ScreenCaptureRequester import com.clawdis.android.ScreenCaptureRequester
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -16,13 +16,13 @@ class ScreenRecordManager(private val context: Context) {
data class Payload(val payloadJson: String) data class Payload(val payloadJson: String)
@Volatile private var screenCaptureRequester: ScreenCaptureRequester? = null @Volatile private var screenCaptureRequester: ScreenCaptureRequester? = null
@Volatile private var permissionRequester: com.steipete.clawdis.node.PermissionRequester? = null @Volatile private var permissionRequester: com.clawdis.android.PermissionRequester? = null
fun attachScreenCaptureRequester(requester: ScreenCaptureRequester) { fun attachScreenCaptureRequester(requester: ScreenCaptureRequester) {
screenCaptureRequester = requester screenCaptureRequester = requester
} }
fun attachPermissionRequester(requester: com.steipete.clawdis.node.PermissionRequester) { fun attachPermissionRequester(requester: com.clawdis.android.PermissionRequester) {
permissionRequester = requester permissionRequester = requester
} }
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.protocol package com.clawdis.android.protocol
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.protocol package com.clawdis.android.protocol
enum class ClawdisCapability(val rawValue: String) { enum class ClawdisCapability(val rawValue: String) {
Canvas("canvas"), Canvas("canvas"),
@@ -0,0 +1,222 @@
package com.clawdis.android.tools
import android.content.Context
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
@Serializable
private data class ToolDisplayActionSpec(
val label: String? = null,
val detailKeys: List<String>? = null,
)
@Serializable
private data class ToolDisplaySpec(
val emoji: String? = null,
val title: String? = null,
val label: String? = null,
val detailKeys: List<String>? = null,
val actions: Map<String, ToolDisplayActionSpec>? = null,
)
@Serializable
private data class ToolDisplayConfig(
val version: Int? = null,
val fallback: ToolDisplaySpec? = null,
val tools: Map<String, ToolDisplaySpec>? = null,
)
data class ToolDisplaySummary(
val name: String,
val emoji: String,
val title: String,
val label: String,
val verb: String?,
val detail: String?,
) {
val detailLine: String?
get() {
val parts = mutableListOf<String>()
if (!verb.isNullOrBlank()) parts.add(verb)
if (!detail.isNullOrBlank()) parts.add(detail)
return if (parts.isEmpty()) null else parts.joinToString(" · ")
}
val summaryLine: String
get() = if (detailLine != null) "${emoji} ${label}: ${detailLine}" else "${emoji} ${label}"
}
object ToolDisplayRegistry {
private const val CONFIG_ASSET = "tool-display.json"
private val json = Json { ignoreUnknownKeys = true }
@Volatile private var cachedConfig: ToolDisplayConfig? = null
fun resolve(
context: Context,
name: String?,
args: JsonObject?,
meta: String? = null,
): ToolDisplaySummary {
val trimmedName = name?.trim().orEmpty().ifEmpty { "tool" }
val key = trimmedName.lowercase()
val config = loadConfig(context)
val spec = config.tools?.get(key)
val fallback = config.fallback
val emoji = spec?.emoji ?: fallback?.emoji ?: "🧩"
val title = spec?.title ?: titleFromName(trimmedName)
val label = spec?.label ?: trimmedName
val actionRaw = args?.get("action")?.asStringOrNull()?.trim()
val action = actionRaw?.takeIf { it.isNotEmpty() }
val actionSpec = action?.let { spec?.actions?.get(it) }
val verb = normalizeVerb(actionSpec?.label ?: action)
var detail: String? = null
if (key == "read") {
detail = readDetail(args)
} else if (key == "write" || key == "edit" || key == "attach") {
detail = pathDetail(args)
}
val detailKeys = actionSpec?.detailKeys ?: spec?.detailKeys ?: fallback?.detailKeys ?: emptyList()
if (detail == null) {
detail = firstValue(args, detailKeys)
}
if (detail == null) {
detail = meta
}
if (detail != null) {
detail = shortenHomeInString(detail)
}
return ToolDisplaySummary(
name = trimmedName,
emoji = emoji,
title = title,
label = label,
verb = verb,
detail = detail,
)
}
private fun loadConfig(context: Context): ToolDisplayConfig {
val existing = cachedConfig
if (existing != null) return existing
return try {
val jsonString = context.assets.open(CONFIG_ASSET).bufferedReader().use { it.readText() }
val decoded = json.decodeFromString(ToolDisplayConfig.serializer(), jsonString)
cachedConfig = decoded
decoded
} catch (_: Throwable) {
val fallback = ToolDisplayConfig()
cachedConfig = fallback
fallback
}
}
private fun titleFromName(name: String): String {
val cleaned = name.replace("_", " ").trim()
if (cleaned.isEmpty()) return "Tool"
return cleaned
.split(Regex("\\s+"))
.joinToString(" ") { part ->
val upper = part.uppercase()
if (part.length <= 2 && part == upper) part
else upper.firstOrNull()?.toString().orEmpty() + part.lowercase().drop(1)
}
}
private fun normalizeVerb(value: String?): String? {
val trimmed = value?.trim().orEmpty()
if (trimmed.isEmpty()) return null
return trimmed.replace("_", " ")
}
private fun readDetail(args: JsonObject?): String? {
val path = args?.get("path")?.asStringOrNull() ?: return null
val offset = args["offset"].asNumberOrNull()
val limit = args["limit"].asNumberOrNull()
return if (offset != null && limit != null) {
val end = offset + limit
"${path}:${offset.toInt()}-${end.toInt()}"
} else {
path
}
}
private fun pathDetail(args: JsonObject?): String? {
return args?.get("path")?.asStringOrNull()
}
private fun firstValue(args: JsonObject?, keys: List<String>): String? {
for (key in keys) {
val value = valueForPath(args, key)
val rendered = renderValue(value)
if (!rendered.isNullOrBlank()) return rendered
}
return null
}
private fun valueForPath(args: JsonObject?, path: String): JsonElement? {
var current: JsonElement? = args
for (segment in path.split(".")) {
if (segment.isBlank()) return null
val obj = current as? JsonObject ?: return null
current = obj[segment]
}
return current
}
private fun renderValue(value: JsonElement?): String? {
if (value == null) return null
if (value is JsonPrimitive) {
if (value.isString) {
val trimmed = value.contentOrNull?.trim().orEmpty()
if (trimmed.isEmpty()) return null
val firstLine = trimmed.lineSequence().firstOrNull()?.trim().orEmpty()
if (firstLine.isEmpty()) return null
return if (firstLine.length > 160) "${firstLine.take(157)}…" else firstLine
}
val raw = value.contentOrNull?.trim().orEmpty()
raw.toBooleanStrictOrNull()?.let { return it.toString() }
raw.toLongOrNull()?.let { return it.toString() }
raw.toDoubleOrNull()?.let { return it.toString() }
}
if (value is JsonArray) {
val items = value.mapNotNull { renderValue(it) }
if (items.isEmpty()) return null
val preview = items.take(3).joinToString(", ")
return if (items.size > 3) "${preview}…" else preview
}
return null
}
private fun shortenHomeInString(value: String): String {
val home = System.getProperty("user.home")?.takeIf { it.isNotBlank() }
?: System.getenv("HOME")?.takeIf { it.isNotBlank() }
if (home.isNullOrEmpty()) return value
return value.replace(home, "~")
.replace(Regex("/Users/[^/]+"), "~")
.replace(Regex("/home/[^/]+"), "~")
}
private fun JsonElement?.asStringOrNull(): String? {
val primitive = this as? JsonPrimitive ?: return null
return if (primitive.isString) primitive.contentOrNull else primitive.toString()
}
private fun JsonElement?.asNumberOrNull(): Double? {
val primitive = this as? JsonPrimitive ?: return null
val raw = primitive.contentOrNull ?: return null
return raw.toDoubleOrNull()
}
}
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.ui package com.clawdis.android.ui
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -1,8 +1,8 @@
package com.steipete.clawdis.node.ui package com.clawdis.android.ui
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import com.steipete.clawdis.node.MainViewModel import com.clawdis.android.MainViewModel
import com.steipete.clawdis.node.ui.chat.ChatSheetContent import com.clawdis.android.ui.chat.ChatSheetContent
@Composable @Composable
fun ChatSheet(viewModel: MainViewModel) { fun ChatSheet(viewModel: MainViewModel) {
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.ui package com.clawdis.android.ui
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.ui package com.clawdis.android.ui
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.Manifest import android.Manifest
@@ -65,8 +65,8 @@ 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.clawdis.android.CameraHudKind
import com.steipete.clawdis.node.MainViewModel import com.clawdis.android.MainViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.ui package com.clawdis.android.ui
import android.Manifest import android.Manifest
import android.content.pm.PackageManager import android.content.pm.PackageManager
@@ -47,10 +47,10 @@ 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.clawdis.android.BuildConfig
import com.steipete.clawdis.node.MainViewModel import com.clawdis.android.MainViewModel
import com.steipete.clawdis.node.NodeForegroundService import com.clawdis.android.NodeForegroundService
import com.steipete.clawdis.node.VoiceWakeMode import com.clawdis.android.VoiceWakeMode
@Composable @Composable
fun SettingsSheet(viewModel: MainViewModel) { fun SettingsSheet(viewModel: MainViewModel) {
@@ -381,7 +381,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button( Button(
onClick = { onClick = {
val parsed = com.steipete.clawdis.node.WakeWords.parseCommaSeparated(wakeWordsText) val parsed = com.clawdis.android.WakeWords.parseCommaSeparated(wakeWordsText)
viewModel.setWakeWords(parsed) viewModel.setWakeWords(parsed)
}, },
enabled = isConnected, enabled = isConnected,
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.ui package com.clawdis.android.ui
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.ui package com.clawdis.android.ui
import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.RepeatMode
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.ui.chat package com.clawdis.android.ui.chat
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -38,7 +38,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.steipete.clawdis.node.chat.ChatSessionEntry import com.clawdis.android.chat.ChatSessionEntry
@Composable @Composable
fun ChatComposer( fun ChatComposer(
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.ui.chat package com.clawdis.android.ui.chat
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.util.Base64 import android.util.Base64
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.ui.chat package com.clawdis.android.ui.chat
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -20,8 +20,8 @@ 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.unit.dp import androidx.compose.ui.unit.dp
import com.steipete.clawdis.node.chat.ChatMessage import com.clawdis.android.chat.ChatMessage
import com.steipete.clawdis.node.chat.ChatPendingToolCall import com.clawdis.android.chat.ChatPendingToolCall
@Composable @Composable
fun ChatMessageListCard( fun ChatMessageListCard(
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.ui.chat package com.clawdis.android.ui.chat
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.util.Base64 import android.util.Base64
@@ -31,11 +31,13 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import com.steipete.clawdis.node.chat.ChatMessage import com.clawdis.android.chat.ChatMessage
import com.steipete.clawdis.node.chat.ChatMessageContent import com.clawdis.android.chat.ChatMessageContent
import com.steipete.clawdis.node.chat.ChatPendingToolCall import com.clawdis.android.chat.ChatPendingToolCall
import com.clawdis.android.tools.ToolDisplayRegistry
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import androidx.compose.ui.platform.LocalContext
@Composable @Composable
fun ChatMessageBubble(message: ChatMessage) { fun ChatMessageBubble(message: ChatMessage) {
@@ -104,18 +106,42 @@ fun ChatTypingIndicatorBubble() {
@Composable @Composable
fun ChatPendingToolsBubble(toolCalls: List<ChatPendingToolCall>) { fun ChatPendingToolsBubble(toolCalls: List<ChatPendingToolCall>) {
val context = LocalContext.current
val displays =
remember(toolCalls, context) {
toolCalls.map { ToolDisplayRegistry.resolve(context, it.name, it.args) }
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
Surface( Surface(
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceContainer, color = MaterialTheme.colorScheme.surfaceContainer,
) { ) {
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Tools", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface) Text("Running tools…", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
for (t in toolCalls.take(6)) { for (display in displays.take(6)) {
Text("· ${t.name}", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
"${display.emoji} ${display.label}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
)
display.detailLine?.let { detail ->
Text(
detail,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
)
}
}
} }
if (toolCalls.size > 6) { if (toolCalls.size > 6) {
Text("… +${toolCalls.size - 6} more", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) Text(
"… +${toolCalls.size - 6} more",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} }
} }
} }
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.ui.chat package com.clawdis.android.ui.chat
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -20,7 +20,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.steipete.clawdis.node.chat.ChatSessionEntry import com.clawdis.android.chat.ChatSessionEntry
@Composable @Composable
fun ChatSessionsDialog( fun ChatSessionsDialog(
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.ui.chat package com.clawdis.android.ui.chat
import android.content.ContentResolver import android.content.ContentResolver
import android.net.Uri import android.net.Uri
@@ -19,8 +19,8 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
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 com.steipete.clawdis.node.MainViewModel import com.clawdis.android.MainViewModel
import com.steipete.clawdis.node.chat.OutgoingAttachment import com.clawdis.android.chat.OutgoingAttachment
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -1,6 +1,6 @@
package com.steipete.clawdis.node.ui.chat package com.clawdis.android.ui.chat
import com.steipete.clawdis.node.chat.ChatSessionEntry import com.clawdis.android.chat.ChatSessionEntry
private const val MAIN_SESSION_KEY = "main" private const val MAIN_SESSION_KEY = "main"
private const val RECENT_WINDOW_MS = 24 * 60 * 60 * 1000L private const val RECENT_WINDOW_MS = 24 * 60 * 60 * 1000L
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.voice package com.clawdis.android.voice
import android.media.MediaDataSource import android.media.MediaDataSource
import kotlin.math.min import kotlin.math.min
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.voice package com.clawdis.android.voice
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.voice package com.clawdis.android.voice
import android.Manifest import android.Manifest
import android.content.Context import android.content.Context
@@ -20,7 +20,7 @@ import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener import android.speech.tts.UtteranceProgressListener
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.steipete.clawdis.node.bridge.BridgeSession import com.clawdis.android.bridge.BridgeSession
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import java.util.UUID import java.util.UUID
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.voice package com.clawdis.android.voice
object VoiceWakeCommandExtractor { object VoiceWakeCommandExtractor {
fun extractCommand(text: String, triggerWords: List<String>): String? { fun extractCommand(text: String, triggerWords: List<String>): String? {
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.voice package com.clawdis.android.voice
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node package com.clawdis.android
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.bridge package com.clawdis.android.bridge
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.bridge package com.clawdis.android.bridge
import io.kotest.core.spec.style.StringSpec import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.bridge package com.clawdis.android.bridge
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.bridge package com.clawdis.android.bridge
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.node package com.clawdis.android.node
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.node package com.clawdis.android.node
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.protocol package com.clawdis.android.protocol
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.protocol package com.clawdis.android.protocol
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
@@ -1,6 +1,6 @@
package com.steipete.clawdis.node.ui.chat package com.clawdis.android.ui.chat
import com.steipete.clawdis.node.chat.ChatSessionEntry import com.clawdis.android.chat.ChatSessionEntry
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.voice package com.clawdis.android.voice
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
@@ -1,4 +1,4 @@
package com.steipete.clawdis.node.voice package com.clawdis.android.voice
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
+1 -1
View File
@@ -14,7 +14,7 @@ actor BridgeClient {
{ {
self.lineBuffer = Data() self.lineBuffer = Data()
let connection = NWConnection(to: endpoint, using: .tcp) let connection = NWConnection(to: endpoint, using: .tcp)
let queue = DispatchQueue(label: "com.steipete.clawdis.ios.bridge-client") let queue = DispatchQueue(label: "com.clawdis.ios.bridge-client")
defer { connection.cancel() } defer { connection.cancel() }
try await self.withTimeout(seconds: 8, purpose: "connect") { try await self.withTimeout(seconds: 8, purpose: "connect") {
try await self.startAndWaitForReady(connection, queue: queue) try await self.startAndWaitForReady(connection, queue: queue)
@@ -99,7 +99,7 @@ final class BridgeConnectionController {
guard !instanceId.isEmpty else { return } guard !instanceId.isEmpty else { return }
let token = KeychainStore.loadString( let token = KeychainStore.loadString(
service: "com.steipete.clawdis.bridge", service: "com.clawdis.bridge",
account: self.keychainAccount(instanceId: instanceId))? account: self.keychainAccount(instanceId: instanceId))?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !token.isEmpty else { return } guard !token.isEmpty else { return }
@@ -189,7 +189,7 @@ final class BridgeConnectionController {
if !refreshed.isEmpty, refreshed != token { if !refreshed.isEmpty, refreshed != token {
_ = KeychainStore.saveString( _ = KeychainStore.saveString(
refreshed, refreshed,
service: "com.steipete.clawdis.bridge", service: "com.clawdis.bridge",
account: self.keychainAccount(instanceId: instanceId)) account: self.keychainAccount(instanceId: instanceId))
} }
appModel.connectToBridge(endpoint: endpoint, hello: self.makeHello(token: resolvedToken)) appModel.connectToBridge(endpoint: endpoint, hello: self.makeHello(token: resolvedToken))
@@ -102,7 +102,7 @@ final class BridgeDiscoveryModel {
} }
self.browsers[domain] = browser self.browsers[domain] = browser
browser.start(queue: DispatchQueue(label: "com.steipete.clawdis.ios.bridge-discovery.\(domain)")) browser.start(queue: DispatchQueue(label: "com.clawdis.ios.bridge-discovery.\(domain)"))
} }
} }
+1 -1
View File
@@ -78,7 +78,7 @@ actor BridgeSession {
let params = NWParameters.tcp let params = NWParameters.tcp
params.includePeerToPeer = true params.includePeerToPeer = true
let connection = NWConnection(to: endpoint, using: params) let connection = NWConnection(to: endpoint, using: params)
let queue = DispatchQueue(label: "com.steipete.clawdis.ios.bridge-session") let queue = DispatchQueue(label: "com.clawdis.ios.bridge-session")
self.connection = connection self.connection = connection
self.queue = queue self.queue = queue
@@ -1,8 +1,8 @@
import Foundation import Foundation
enum BridgeSettingsStore { enum BridgeSettingsStore {
private static let bridgeService = "com.steipete.clawdis.bridge" private static let bridgeService = "com.clawdis.bridge"
private static let nodeService = "com.steipete.clawdis.node" private static let nodeService = "com.clawdis.node"
private static let instanceIdDefaultsKey = "node.instanceId" private static let instanceIdDefaultsKey = "node.instanceId"
private static let preferredBridgeStableIDDefaultsKey = "bridge.preferredStableID" private static let preferredBridgeStableIDDefaultsKey = "bridge.preferredStableID"
@@ -68,7 +68,7 @@ final class ScreenRecordService: @unchecked Sendable {
try? FileManager.default.removeItem(at: outURL) try? FileManager.default.removeItem(at: outURL)
let state = CaptureState() let state = CaptureState()
let recordQueue = DispatchQueue(label: "com.steipete.clawdis.screenrecord") let recordQueue = DispatchQueue(label: "com.clawdis.screenrecord")
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
let handler: @Sendable (CMSampleBuffer, RPSampleBufferType, Error?) -> Void = { sample, type, error in let handler: @Sendable (CMSampleBuffer, RPSampleBufferType, Error?) -> Void = { sample, type, error in
+4 -4
View File
@@ -353,7 +353,7 @@ struct SettingsTab: View {
do { do {
let statusStore = self.connectStatus let statusStore = self.connectStatus
let existing = KeychainStore.loadString( let existing = KeychainStore.loadString(
service: "com.steipete.clawdis.bridge", service: "com.clawdis.bridge",
account: self.keychainAccount()) account: self.keychainAccount())
let existingToken = (existing?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? let existingToken = (existing?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ?
existing : existing :
@@ -381,7 +381,7 @@ struct SettingsTab: View {
if !token.isEmpty, token != existingToken { if !token.isEmpty, token != existingToken {
_ = KeychainStore.saveString( _ = KeychainStore.saveString(
token, token,
service: "com.steipete.clawdis.bridge", service: "com.clawdis.bridge",
account: self.keychainAccount()) account: self.keychainAccount())
} }
@@ -427,7 +427,7 @@ struct SettingsTab: View {
do { do {
let statusStore = self.connectStatus let statusStore = self.connectStatus
let existing = KeychainStore.loadString( let existing = KeychainStore.loadString(
service: "com.steipete.clawdis.bridge", service: "com.clawdis.bridge",
account: self.keychainAccount()) account: self.keychainAccount())
let existingToken = (existing?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? let existingToken = (existing?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ?
existing : existing :
@@ -455,7 +455,7 @@ struct SettingsTab: View {
if !token.isEmpty, token != existingToken { if !token.isEmpty, token != existingToken {
_ = KeychainStore.saveString( _ = KeychainStore.saveString(
token, token,
service: "com.steipete.clawdis.bridge", service: "com.clawdis.bridge",
account: self.keychainAccount()) account: self.keychainAccount())
} }
+1 -1
View File
@@ -47,7 +47,7 @@ final class TalkModeManager: NSObject {
private var chatSubscribedSessionKeys = Set<String>() private var chatSubscribedSessionKeys = Set<String>()
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "TalkMode") private let logger = Logger(subsystem: "com.clawdis", category: "TalkMode")
func attachBridge(_ bridge: BridgeSession) { func attachBridge(_ bridge: BridgeSession) {
self.bridge = bridge self.bridge = bridge
+1 -1
View File
@@ -6,7 +6,7 @@ import Testing
@Suite struct BridgeClientTests { @Suite struct BridgeClientTests {
private final class LineServer: @unchecked Sendable { private final class LineServer: @unchecked Sendable {
private let queue = DispatchQueue(label: "com.steipete.clawdis.tests.bridge-client-server") private let queue = DispatchQueue(label: "com.clawdis.tests.bridge-client-server")
private let listener: NWListener private let listener: NWListener
private var connection: NWConnection? private var connection: NWConnection?
private var buffer = Data() private var buffer = Data()
@@ -10,8 +10,8 @@ private struct KeychainEntry: Hashable {
let account: String let account: String
} }
private let bridgeService = "com.steipete.clawdis.bridge" private let bridgeService = "com.clawdis.bridge"
private let nodeService = "com.steipete.clawdis.node" private let nodeService = "com.clawdis.node"
private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId") private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId")
private let preferredBridgeEntry = KeychainEntry(service: bridgeService, account: "preferredStableID") private let preferredBridgeEntry = KeychainEntry(service: bridgeService, account: "preferredStableID")
private let lastBridgeEntry = KeychainEntry(service: bridgeService, account: "lastDiscoveredStableID") private let lastBridgeEntry = KeychainEntry(service: bridgeService, account: "lastDiscoveredStableID")
@@ -7,8 +7,8 @@ private struct KeychainEntry: Hashable {
let account: String let account: String
} }
private let bridgeService = "com.steipete.clawdis.bridge" private let bridgeService = "com.clawdis.bridge"
private let nodeService = "com.steipete.clawdis.node" private let nodeService = "com.clawdis.node"
private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId") private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId")
private let preferredBridgeEntry = KeychainEntry(service: bridgeService, account: "preferredStableID") private let preferredBridgeEntry = KeychainEntry(service: bridgeService, account: "preferredStableID")
private let lastBridgeEntry = KeychainEntry(service: bridgeService, account: "lastDiscoveredStableID") private let lastBridgeEntry = KeychainEntry(service: bridgeService, account: "lastDiscoveredStableID")
+1 -1
View File
@@ -4,7 +4,7 @@ import Testing
@Suite struct KeychainStoreTests { @Suite struct KeychainStoreTests {
@Test func saveLoadUpdateDeleteRoundTrip() { @Test func saveLoadUpdateDeleteRoundTrip() {
let service = "com.steipete.clawdis.tests.\(UUID().uuidString)" let service = "com.clawdis.tests.\(UUID().uuidString)"
let account = "value" let account = "value"
#expect(KeychainStore.delete(service: service, account: account)) #expect(KeychainStore.delete(service: service, account: account))
+1 -1
View File
@@ -1,4 +1,4 @@
app_identifier("com.steipete.clawdis.ios") app_identifier("com.clawdis.ios")
# Auth is expected via App Store Connect API key. # Auth is expected via App Store Connect API key.
# Provide either: # Provide either:
+4 -4
View File
@@ -1,6 +1,6 @@
name: Clawdis name: Clawdis
options: options:
bundleIdPrefix: com.steipete.clawdis bundleIdPrefix: com.clawdis
deploymentTarget: deploymentTarget:
iOS: "17.0" iOS: "17.0"
xcodeVersion: "16.0" xcodeVersion: "16.0"
@@ -65,8 +65,8 @@ targets:
CODE_SIGN_IDENTITY: "Apple Development" CODE_SIGN_IDENTITY: "Apple Development"
CODE_SIGN_STYLE: Manual CODE_SIGN_STYLE: Manual
DEVELOPMENT_TEAM: Y5PE65HELJ DEVELOPMENT_TEAM: Y5PE65HELJ
PRODUCT_BUNDLE_IDENTIFIER: com.steipete.clawdis.ios PRODUCT_BUNDLE_IDENTIFIER: com.clawdis.ios
PROVISIONING_PROFILE_SPECIFIER: "com.steipete.clawdis.ios Development" PROVISIONING_PROFILE_SPECIFIER: "com.clawdis.ios Development"
SWIFT_VERSION: "6.0" SWIFT_VERSION: "6.0"
info: info:
path: Sources/Info.plist path: Sources/Info.plist
@@ -98,7 +98,7 @@ targets:
product: SwabbleKit product: SwabbleKit
settings: settings:
base: base:
PRODUCT_BUNDLE_IDENTIFIER: com.steipete.clawdis.ios.tests PRODUCT_BUNDLE_IDENTIFIER: com.clawdis.ios.tests
SWIFT_VERSION: "6.0" SWIFT_VERSION: "6.0"
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/Clawdis.app/Clawdis" TEST_HOST: "$(BUILT_PRODUCTS_DIR)/Clawdis.app/Clawdis"
BUNDLE_LOADER: "$(TEST_HOST)" BUNDLE_LOADER: "$(TEST_HOST)"
@@ -2,7 +2,7 @@ import Foundation
import OSLog import OSLog
enum AgentWorkspace { enum AgentWorkspace {
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "workspace") private static let logger = Logger(subsystem: "com.clawdis", category: "workspace")
static let agentsFilename = "AGENTS.md" static let agentsFilename = "AGENTS.md"
static let soulFilename = "SOUL.md" static let soulFilename = "SOUL.md"
static let identityFilename = "IDENTITY.md" static let identityFilename = "IDENTITY.md"
@@ -58,7 +58,7 @@ enum AnthropicAuthResolver {
} }
enum AnthropicOAuth { enum AnthropicOAuth {
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "anthropic-oauth") private static let logger = Logger(subsystem: "com.clawdis", category: "anthropic-oauth")
private static let clientId = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" private static let clientId = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
private static let authorizeURL = URL(string: "https://claude.ai/oauth/authorize")! private static let authorizeURL = URL(string: "https://claude.ai/oauth/authorize")!
@@ -19,7 +19,7 @@ actor BridgeConnectionHandler {
private let logger: Logger private let logger: Logger
private let decoder = JSONDecoder() private let decoder = JSONDecoder()
private let encoder = JSONEncoder() private let encoder = JSONEncoder()
private let queue = DispatchQueue(label: "com.steipete.clawdis.bridge.connection") private let queue = DispatchQueue(label: "com.clawdis.bridge.connection")
private var buffer = Data() private var buffer = Data()
private var isAuthenticated = false private var isAuthenticated = false
@@ -8,7 +8,7 @@ import OSLog
actor BridgeServer { actor BridgeServer {
static let shared = BridgeServer() static let shared = BridgeServer()
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "bridge") private let logger = Logger(subsystem: "com.clawdis", category: "bridge")
private var listener: NWListener? private var listener: NWListener?
private var isRunning = false private var isRunning = false
private var store: PairedNodesStore? private var store: PairedNodesStore?
@@ -42,7 +42,7 @@ actor BridgeServer {
Task { await self.handleListenerState(state) } Task { await self.handleListenerState(state) }
} }
listener.start(queue: DispatchQueue(label: "com.steipete.clawdis.bridge")) listener.start(queue: DispatchQueue(label: "com.clawdis.bridge"))
self.listener = listener self.listener = listener
} catch { } catch {
self.logger.error("bridge start failed: \(error.localizedDescription, privacy: .public)") self.logger.error("bridge start failed: \(error.localizedDescription, privacy: .public)")
@@ -36,7 +36,7 @@ actor CameraCaptureService {
} }
} }
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "camera") private let logger = Logger(subsystem: "com.clawdis", category: "camera")
func listDevices() -> [CameraDeviceInfo] { func listDevices() -> [CameraDeviceInfo] {
Self.availableCameras().map { device in Self.availableCameras().map { device in
@@ -10,7 +10,7 @@ final class CanvasFileWatcher: @unchecked Sendable {
init(url: URL, onChange: @escaping () -> Void) { init(url: URL, onChange: @escaping () -> Void) {
self.url = url self.url = url
self.queue = DispatchQueue(label: "com.steipete.clawdis.canvaswatcher") self.queue = DispatchQueue(label: "com.clawdis.canvaswatcher")
self.onChange = onChange self.onChange = onChange
} }
@@ -7,7 +7,7 @@ import OSLog
final class CanvasManager { final class CanvasManager {
static let shared = CanvasManager() static let shared = CanvasManager()
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "CanvasManager") private static let logger = Logger(subsystem: "com.clawdis", category: "CanvasManager")
private var panelController: CanvasWindowController? private var panelController: CanvasWindowController?
private var panelSessionKey: String? private var panelSessionKey: String?
@@ -3,7 +3,7 @@ import Foundation
import OSLog import OSLog
import WebKit import WebKit
private let canvasLogger = Logger(subsystem: "com.steipete.clawdis", category: "Canvas") private let canvasLogger = Logger(subsystem: "com.clawdis", category: "Canvas")
final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler { final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
private let root: URL private let root: URL
@@ -1,6 +1,6 @@
import AppKit import AppKit
let canvasWindowLogger = Logger(subsystem: "com.steipete.clawdis", category: "Canvas") let canvasWindowLogger = Logger(subsystem: "com.clawdis", category: "Canvas")
enum CanvasLayout { enum CanvasLayout {
static let panelSize = NSSize(width: 520, height: 680) static let panelSize = NSSize(width: 520, height: 680)
@@ -1,7 +1,7 @@
import Foundation import Foundation
enum ClawdisConfigFile { enum ClawdisConfigFile {
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "config") private static let logger = Logger(subsystem: "com.clawdis", category: "config")
static func url() -> URL { static func url() -> URL {
ClawdisPaths.configURL ClawdisPaths.configURL
@@ -106,4 +106,20 @@ enum ClawdisConfigFile {
return remote["password"] as? String return remote["password"] as? String
} }
static func gatewayPort() -> Int? {
let root = self.loadDict()
guard let gateway = root["gateway"] as? [String: Any] else { return nil }
if let port = gateway["port"] as? Int, port > 0 { return port }
if let number = gateway["port"] as? NSNumber, number.intValue > 0 {
return number.intValue
}
if let raw = gateway["port"] as? String,
let parsed = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines)),
parsed > 0
{
return parsed
}
return nil
}
} }
@@ -5,7 +5,7 @@ import OSLog
final class ConnectionModeCoordinator { final class ConnectionModeCoordinator {
static let shared = ConnectionModeCoordinator() static let shared = ConnectionModeCoordinator()
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "connection") private let logger = Logger(subsystem: "com.clawdis", category: "connection")
/// Apply the requested connection mode by starting/stopping local gateway, /// Apply the requested connection mode by starting/stopping local gateway,
/// managing the control-channel SSH tunnel, and cleaning up chat windows/panels. /// managing the control-channel SSH tunnel, and cleaning up chat windows/panels.
@@ -2,7 +2,7 @@ import AppKit
import SwiftUI import SwiftUI
struct ConnectionsSettings: View { struct ConnectionsSettings: View {
private enum ConnectionProvider: String, CaseIterable, Identifiable { private enum ConnectionProvider: String, CaseIterable, Identifiable, Hashable {
case whatsapp case whatsapp
case telegram case telegram
case discord case discord
@@ -20,9 +20,40 @@ struct ConnectionsSettings: View {
case .imessage: 4 case .imessage: 4
} }
} }
var title: String {
switch self {
case .whatsapp: "WhatsApp"
case .telegram: "Telegram"
case .discord: "Discord"
case .signal: "Signal"
case .imessage: "iMessage"
}
}
var detailTitle: String {
switch self {
case .whatsapp: "WhatsApp Web"
case .telegram: "Telegram Bot"
case .discord: "Discord Bot"
case .signal: "Signal REST"
case .imessage: "iMessage (imsg)"
}
}
var systemImage: String {
switch self {
case .whatsapp: "message"
case .telegram: "paperplane"
case .discord: "bubble.left.and.bubble.right"
case .signal: "antenna.radiowaves.left.and.right"
case .imessage: "message.fill"
}
}
} }
@Bindable var store: ConnectionsStore @Bindable var store: ConnectionsStore
@State private var selectedProvider: ConnectionProvider? = nil
@State private var showTelegramToken = false @State private var showTelegramToken = false
@State private var showDiscordToken = false @State private var showDiscordToken = false
@@ -31,47 +62,189 @@ struct ConnectionsSettings: View {
} }
var body: some View { var body: some View {
ScrollView(.vertical) { NavigationSplitView {
VStack(alignment: .leading, spacing: 14) { self.sidebar
self.header } detail: {
ForEach(self.orderedProviders) { provider in self.detail
self.providerSection(provider) }
.onAppear {
self.store.start()
self.ensureSelection()
}
.onChange(of: self.orderedProviders) { _, _ in
self.ensureSelection()
}
.onDisappear { self.store.stop() }
}
private var sidebar: some View {
List(selection: self.$selectedProvider) {
if !self.enabledProviders.isEmpty {
Section("Configured") {
ForEach(self.enabledProviders) { provider in
self.sidebarRow(provider)
.tag(provider)
}
} }
}
if !self.availableProviders.isEmpty {
Section("Available") {
ForEach(self.availableProviders) { provider in
self.sidebarRow(provider)
.tag(provider)
}
}
}
}
.listStyle(.sidebar)
.frame(minWidth: 210, idealWidth: 230, maxWidth: 260)
}
private var detail: some View {
Group {
if let provider = self.selectedProvider {
self.providerDetail(provider)
} else {
self.emptyDetail
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private var emptyDetail: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Connections")
.font(.title3.weight(.semibold))
Text("Select a provider to view status and settings.")
.font(.callout)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 24)
.padding(.vertical, 18)
}
private func providerDetail(_ provider: ConnectionProvider) -> some View {
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 16) {
self.detailHeader(for: provider)
Divider()
self.providerSection(provider)
Spacer(minLength: 0) Spacer(minLength: 0)
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 24) .padding(.horizontal, 24)
.padding(.vertical, 18) .padding(.vertical, 18)
} }
.onAppear { self.store.start() }
.onDisappear { self.store.stop() }
} }
private var header: some View { private func sidebarRow(_ provider: ConnectionProvider) -> some View {
VStack(alignment: .leading, spacing: 6) { HStack(spacing: 8) {
Text("Connections") Circle()
.font(.title3.weight(.semibold)) .fill(self.providerTint(provider))
Text("Link and monitor messaging providers.") .frame(width: 8, height: 8)
.font(.callout) VStack(alignment: .leading, spacing: 2) {
.foregroundStyle(.secondary) Text(provider.title)
Text(self.providerSummary(provider))
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
private func detailHeader(for provider: ConnectionProvider) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Label(provider.detailTitle, systemImage: provider.systemImage)
.font(.title3.weight(.semibold))
self.statusBadge(
self.providerSummary(provider),
color: self.providerTint(provider))
Spacer()
self.providerHeaderActions(provider)
}
HStack(spacing: 10) {
Text("Last check \(self.providerLastCheckText(provider))")
.font(.caption)
.foregroundStyle(.secondary)
if self.providerHasError(provider) {
Text("Error")
.font(.caption2.weight(.semibold))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.red.opacity(0.15))
.foregroundStyle(.red)
.clipShape(Capsule())
}
}
if let details = self.providerDetails(provider) {
Text(details)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
} }
} }
private var whatsAppSection: some View { private func statusBadge(_ text: String, color: Color) -> some View {
GroupBox("WhatsApp") { Text(text)
.font(.caption2.weight(.semibold))
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(color.opacity(0.16))
.foregroundStyle(color)
.clipShape(Capsule())
}
private func formSection(_ title: String, @ViewBuilder content: () -> some View) -> some View {
GroupBox(title) {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
self.providerHeader( content()
title: "WhatsApp Web", }
color: self.whatsAppTint, .frame(maxWidth: .infinity, alignment: .leading)
subtitle: self.whatsAppSummary) }
}
if let details = self.whatsAppDetails { @ViewBuilder
Text(details) private func providerHeaderActions(_ provider: ConnectionProvider) -> some View {
.font(.caption) HStack(spacing: 8) {
.foregroundStyle(.secondary) if provider == .whatsapp {
.fixedSize(horizontal: false, vertical: true) Button("Logout") {
Task { await self.store.logoutWhatsApp() }
} }
.buttonStyle(.bordered)
.disabled(self.store.whatsappBusy)
}
if provider == .telegram {
Button("Logout") {
Task { await self.store.logoutTelegram() }
}
.buttonStyle(.bordered)
.disabled(self.store.telegramBusy)
}
Button {
Task { await self.store.refresh(probe: true) }
} label: {
if self.store.isRefreshing {
ProgressView().controlSize(.small)
} else {
Text("Refresh")
}
}
.buttonStyle(.bordered)
.disabled(self.store.isRefreshing)
}
.controlSize(.small)
}
private var whatsAppSection: some View {
VStack(alignment: .leading, spacing: 16) {
self.formSection("Linking") {
if let message = self.store.whatsappLoginMessage { if let message = self.store.whatsappLoginMessage {
Text(message) Text(message)
.font(.caption) .font(.caption)
@@ -105,52 +278,16 @@ struct ConnectionsSettings: View {
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.disabled(self.store.whatsappBusy) .disabled(self.store.whatsappBusy)
Spacer()
Button("Logout") {
Task { await self.store.logoutWhatsApp() }
}
.buttonStyle(.bordered)
.disabled(self.store.whatsappBusy)
Button("Refresh") {
Task { await self.store.refresh(probe: true) }
}
.buttonStyle(.bordered)
.disabled(self.store.isRefreshing)
} }
.font(.caption) .font(.caption)
} }
.frame(maxWidth: .infinity, alignment: .leading)
} }
} }
private var telegramSection: some View { private var telegramSection: some View {
GroupBox("Telegram") { VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 10) { self.formSection("Authentication") {
self.providerHeader( Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
title: "Telegram Bot",
color: self.telegramTint,
subtitle: self.telegramSummary)
if let details = self.telegramDetails {
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 { GridRow {
self.gridLabel("Bot token") self.gridLabel("Bot token")
if self.showTelegramToken { if self.showTelegramToken {
@@ -166,6 +303,11 @@ struct ConnectionsSettings: View {
.toggleStyle(.switch) .toggleStyle(.switch)
.disabled(self.isTelegramTokenLocked) .disabled(self.isTelegramTokenLocked)
} }
}
}
self.formSection("Access") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow { GridRow {
self.gridLabel("Require mention") self.gridLabel("Require mention")
Toggle("", isOn: self.$store.telegramRequireMention) Toggle("", isOn: self.$store.telegramRequireMention)
@@ -177,11 +319,11 @@ struct ConnectionsSettings: View {
TextField("123456789, @team", text: self.$store.telegramAllowFrom) TextField("123456789, @team", text: self.$store.telegramAllowFrom)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
} }
GridRow { }
self.gridLabel("Proxy") }
TextField("socks5://localhost:9050", text: self.$store.telegramProxy)
.textFieldStyle(.roundedBorder) self.formSection("Webhook") {
} Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow { GridRow {
self.gridLabel("Webhook URL") self.gridLabel("Webhook URL")
TextField("https://example.com/telegram-webhook", text: self.$store.telegramWebhookUrl) TextField("https://example.com/telegram-webhook", text: self.$store.telegramWebhookUrl)
@@ -198,71 +340,49 @@ struct ConnectionsSettings: View {
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
} }
} }
if self.isTelegramTokenLocked {
Text("Token set via TELEGRAM_BOT_TOKEN env; config edits won’t override it.")
.font(.caption)
.foregroundStyle(.secondary)
}
HStack(spacing: 12) {
Button {
Task { await self.store.saveTelegramConfig() }
} label: {
if self.store.isSavingConfig {
ProgressView().controlSize(.small)
} else {
Text("Save")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.store.isSavingConfig)
Spacer()
Button("Logout") {
Task { await self.store.logoutTelegram() }
}
.buttonStyle(.bordered)
.disabled(self.store.telegramBusy)
Button("Refresh") {
Task { await self.store.refresh(probe: true) }
}
.buttonStyle(.bordered)
.disabled(self.store.isRefreshing)
}
.font(.caption)
} }
.frame(maxWidth: .infinity, alignment: .leading)
self.formSection("Network") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow {
self.gridLabel("Proxy")
TextField("socks5://localhost:9050", text: self.$store.telegramProxy)
.textFieldStyle(.roundedBorder)
}
}
}
if self.isTelegramTokenLocked {
Text("Token set via TELEGRAM_BOT_TOKEN env; config edits won’t override it.")
.font(.caption)
.foregroundStyle(.secondary)
}
self.configStatusMessage
HStack(spacing: 12) {
Button {
Task { await self.store.saveTelegramConfig() }
} label: {
if self.store.isSavingConfig {
ProgressView().controlSize(.small)
} else {
Text("Save")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.store.isSavingConfig)
Spacer()
}
.font(.caption)
} }
} }
private var discordSection: some View { private var discordSection: some View {
GroupBox("Discord") { VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 10) { self.formSection("Authentication") {
self.providerHeader( Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
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 { GridRow {
self.gridLabel("Enabled") self.gridLabel("Enabled")
Toggle("", isOn: self.$store.discordEnabled) Toggle("", isOn: self.$store.discordEnabled)
@@ -284,6 +404,11 @@ struct ConnectionsSettings: View {
.toggleStyle(.switch) .toggleStyle(.switch)
.disabled(self.isDiscordTokenLocked) .disabled(self.isDiscordTokenLocked)
} }
}
}
self.formSection("Messages") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow { GridRow {
self.gridLabel("Allow DMs from") self.gridLabel("Allow DMs from")
TextField("123456789, username#1234", text: self.$store.discordAllowFrom) TextField("123456789, username#1234", text: self.$store.discordAllowFrom)
@@ -306,6 +431,20 @@ struct ConnectionsSettings: View {
TextField("channelId1, channelId2", text: self.$store.discordGroupChannels) TextField("channelId1, channelId2", text: self.$store.discordGroupChannels)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
} }
GridRow {
self.gridLabel("Reply to mode")
Picker("", selection: self.$store.discordReplyToMode) {
Text("off").tag("off")
Text("first").tag("first")
Text("all").tag("all")
}
.labelsHidden()
}
}
}
self.formSection("Limits") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow { GridRow {
self.gridLabel("Media max MB") self.gridLabel("Media max MB")
TextField("8", text: self.$store.discordMediaMaxMb) TextField("8", text: self.$store.discordMediaMaxMb)
@@ -321,17 +460,13 @@ struct ConnectionsSettings: View {
TextField("2000", text: self.$store.discordTextChunkLimit) TextField("2000", text: self.$store.discordTextChunkLimit)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
} }
}
}
self.formSection("Slash command") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow { GridRow {
self.gridLabel("Reply to mode") self.gridLabel("Enabled")
Picker("", selection: self.$store.discordReplyToMode) {
Text("off").tag("off")
Text("first").tag("first")
Text("all").tag("all")
}
.labelsHidden()
}
GridRow {
self.gridLabel("Slash command")
Toggle("", isOn: self.$store.discordSlashEnabled) Toggle("", isOn: self.$store.discordSlashEnabled)
.labelsHidden() .labelsHidden()
.toggleStyle(.checkbox) .toggleStyle(.checkbox)
@@ -342,24 +477,20 @@ struct ConnectionsSettings: View {
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
} }
GridRow { GridRow {
self.gridLabel("Slash session prefix") self.gridLabel("Session prefix")
TextField("discord:slash", text: self.$store.discordSlashSessionPrefix) TextField("discord:slash", text: self.$store.discordSlashSessionPrefix)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
} }
GridRow { GridRow {
self.gridLabel("Slash ephemeral") self.gridLabel("Ephemeral")
Toggle("", isOn: self.$store.discordSlashEphemeral) Toggle("", isOn: self.$store.discordSlashEphemeral)
.labelsHidden() .labelsHidden()
.toggleStyle(.checkbox) .toggleStyle(.checkbox)
} }
} }
}
Divider().padding(.vertical, 2) GroupBox("Guilds") {
Text("Guilds")
.font(.caption)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
ForEach($store.discordGuilds) { $guild in ForEach($store.discordGuilds) { $guild in
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
@@ -372,7 +503,7 @@ struct ConnectionsSettings: View {
.buttonStyle(.bordered) .buttonStyle(.bordered)
} }
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow { GridRow {
self.gridLabel("Slug") self.gridLabel("Slug")
TextField("optional slug", text: $guild.slug) TextField("optional slug", text: $guild.slug)
@@ -384,6 +515,17 @@ struct ConnectionsSettings: View {
.labelsHidden() .labelsHidden()
.toggleStyle(.checkbox) .toggleStyle(.checkbox)
} }
GridRow {
self.gridLabel("Reaction notifications")
Picker("", selection: $guild.reactionNotifications) {
Text("Off").tag("off")
Text("Own").tag("own")
Text("All").tag("all")
Text("Allowlist").tag("allowlist")
}
.labelsHidden()
.pickerStyle(.segmented)
}
GridRow { GridRow {
self.gridLabel("Users allowlist") self.gridLabel("Users allowlist")
TextField("123456789, username#1234", text: $guild.users) TextField("123456789, username#1234", text: $guild.users)
@@ -426,14 +568,11 @@ struct ConnectionsSettings: View {
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
} }
.frame(maxWidth: .infinity, alignment: .leading)
}
Divider().padding(.vertical, 2) GroupBox("Tool actions") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
Text("Tool actions")
.font(.caption)
.foregroundStyle(.secondary)
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow { GridRow {
self.gridLabel("Reactions") self.gridLabel("Reactions")
Toggle("", isOn: self.$store.discordActionReactions) Toggle("", isOn: self.$store.discordActionReactions)
@@ -525,65 +664,40 @@ struct ConnectionsSettings: View {
.toggleStyle(.checkbox) .toggleStyle(.checkbox)
} }
} }
.frame(maxWidth: .infinity, alignment: .leading)
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)
if self.isDiscordTokenLocked {
Text("Token set via DISCORD_BOT_TOKEN env; config edits won’t override it.")
.font(.caption)
.foregroundStyle(.secondary)
}
self.configStatusMessage
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()
}
.font(.caption)
} }
} }
private var signalSection: some View { private var signalSection: some View {
GroupBox("Signal") { VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 10) { self.formSection("Connection") {
self.providerHeader( Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
title: "Signal REST",
color: self.signalTint,
subtitle: self.signalSummary)
if let details = self.signalDetails {
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 { GridRow {
self.gridLabel("Enabled") self.gridLabel("Enabled")
Toggle("", isOn: self.$store.signalEnabled) Toggle("", isOn: self.$store.signalEnabled)
@@ -615,6 +729,11 @@ struct ConnectionsSettings: View {
TextField("signal-cli", text: self.$store.signalCliPath) TextField("signal-cli", text: self.$store.signalCliPath)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
} }
}
}
self.formSection("Behavior") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow { GridRow {
self.gridLabel("Auto start") self.gridLabel("Auto start")
Toggle("", isOn: self.$store.signalAutoStart) Toggle("", isOn: self.$store.signalAutoStart)
@@ -649,6 +768,11 @@ struct ConnectionsSettings: View {
.labelsHidden() .labelsHidden()
.toggleStyle(.checkbox) .toggleStyle(.checkbox)
} }
}
}
self.formSection("Access & limits") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow { GridRow {
self.gridLabel("Allow from") self.gridLabel("Allow from")
TextField("12345, +1555", text: self.$store.signalAllowFrom) TextField("12345, +1555", text: self.$store.signalAllowFrom)
@@ -660,59 +784,33 @@ struct ConnectionsSettings: View {
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
} }
} }
HStack(spacing: 12) {
Button {
Task { await self.store.saveSignalConfig() }
} 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)
self.configStatusMessage
HStack(spacing: 12) {
Button {
Task { await self.store.saveSignalConfig() }
} label: {
if self.store.isSavingConfig {
ProgressView().controlSize(.small)
} else {
Text("Save")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.store.isSavingConfig)
Spacer()
}
.font(.caption)
} }
} }
private var imessageSection: some View { private var imessageSection: some View {
GroupBox("iMessage") { VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 10) { self.formSection("Connection") {
self.providerHeader( Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
title: "iMessage (imsg)",
color: self.imessageTint,
subtitle: self.imessageSummary)
if let details = self.imessageDetails {
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 { GridRow {
self.gridLabel("Enabled") self.gridLabel("Enabled")
Toggle("", isOn: self.$store.imessageEnabled) Toggle("", isOn: self.$store.imessageEnabled)
@@ -739,6 +837,11 @@ struct ConnectionsSettings: View {
.labelsHidden() .labelsHidden()
.pickerStyle(.menu) .pickerStyle(.menu)
} }
}
}
self.formSection("Behavior") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
GridRow { GridRow {
self.gridLabel("Region") self.gridLabel("Region")
TextField("US", text: self.$store.imessageRegion) TextField("US", text: self.$store.imessageRegion)
@@ -761,31 +864,26 @@ struct ConnectionsSettings: View {
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
} }
} }
HStack(spacing: 12) {
Button {
Task { await self.store.saveIMessageConfig() }
} 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)
self.configStatusMessage
HStack(spacing: 12) {
Button {
Task { await self.store.saveIMessageConfig() }
} label: {
if self.store.isSavingConfig {
ProgressView().controlSize(.small)
} else {
Text("Save")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.store.isSavingConfig)
Spacer()
}
.font(.caption)
} }
} }
@@ -1025,6 +1123,24 @@ struct ConnectionsSettings: View {
} }
} }
private var enabledProviders: [ConnectionProvider] {
self.orderedProviders.filter { self.providerEnabled($0) }
}
private var availableProviders: [ConnectionProvider] {
self.orderedProviders.filter { !self.providerEnabled($0) }
}
private func ensureSelection() {
guard let selected = self.selectedProvider else {
self.selectedProvider = self.orderedProviders.first
return
}
if !self.orderedProviders.contains(selected) {
self.selectedProvider = self.orderedProviders.first
}
}
private func providerEnabled(_ provider: ConnectionProvider) -> Bool { private func providerEnabled(_ provider: ConnectionProvider) -> Bool {
switch provider { switch provider {
case .whatsapp: case .whatsapp:
@@ -1061,26 +1177,106 @@ struct ConnectionsSettings: View {
} }
} }
private func providerHeader(title: String, color: Color, subtitle: String) -> some View { @ViewBuilder
HStack(spacing: 10) { private var configStatusMessage: some View {
Circle() if let status = self.store.configStatus {
.fill(color) Text(status)
.frame(width: 10, height: 10) .font(.caption)
VStack(alignment: .leading, spacing: 2) { .foregroundStyle(.secondary)
Text(title) .fixedSize(horizontal: false, vertical: true)
.font(.headline) }
Text(subtitle) }
.font(.caption)
.foregroundStyle(color) private func providerTint(_ provider: ConnectionProvider) -> Color {
} switch provider {
Spacer() case .whatsapp:
self.whatsAppTint
case .telegram:
self.telegramTint
case .discord:
self.discordTint
case .signal:
self.signalTint
case .imessage:
self.imessageTint
}
}
private func providerSummary(_ provider: ConnectionProvider) -> String {
switch provider {
case .whatsapp:
self.whatsAppSummary
case .telegram:
self.telegramSummary
case .discord:
self.discordSummary
case .signal:
self.signalSummary
case .imessage:
self.imessageSummary
}
}
private func providerDetails(_ provider: ConnectionProvider) -> String? {
switch provider {
case .whatsapp:
self.whatsAppDetails
case .telegram:
self.telegramDetails
case .discord:
self.discordDetails
case .signal:
self.signalDetails
case .imessage:
self.imessageDetails
}
}
private func providerLastCheckText(_ provider: ConnectionProvider) -> String {
guard let date = self.providerLastCheck(provider) else { return "never" }
return relativeAge(from: date)
}
private func providerLastCheck(_ provider: ConnectionProvider) -> Date? {
switch provider {
case .whatsapp:
guard let status = self.store.snapshot?.whatsapp else { return nil }
return self.date(fromMs: status.lastEventAt ?? status.lastMessageAt ?? status.lastConnectedAt)
case .telegram:
return self.date(fromMs: self.store.snapshot?.telegram.lastProbeAt)
case .discord:
return self.date(fromMs: self.store.snapshot?.discord?.lastProbeAt)
case .signal:
return self.date(fromMs: self.store.snapshot?.signal?.lastProbeAt)
case .imessage:
return self.date(fromMs: self.store.snapshot?.imessage?.lastProbeAt)
}
}
private func providerHasError(_ provider: ConnectionProvider) -> Bool {
switch provider {
case .whatsapp:
guard let status = self.store.snapshot?.whatsapp else { return false }
return status.lastError?.isEmpty == false || status.lastDisconnect?.loggedOut == true
case .telegram:
guard let status = self.store.snapshot?.telegram else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false
case .discord:
guard let status = self.store.snapshot?.discord else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false
case .signal:
guard let status = self.store.snapshot?.signal else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false
case .imessage:
guard let status = self.store.snapshot?.imessage else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false
} }
} }
private func gridLabel(_ text: String) -> some View { private func gridLabel(_ text: String) -> some View {
Text(text) Text(text)
.font(.callout.weight(.semibold)) .font(.callout.weight(.semibold))
.frame(width: 120, alignment: .leading) .frame(width: 140, alignment: .leading)
} }
private func date(fromMs ms: Double?) -> Date? { private func date(fromMs ms: Double?) -> Date? {
@@ -162,6 +162,7 @@ struct DiscordGuildForm: Identifiable {
var key: String var key: String
var slug: String var slug: String
var requireMention: Bool var requireMention: Bool
var reactionNotifications: String
var users: String var users: String
var channels: [DiscordGuildChannelForm] var channels: [DiscordGuildChannelForm]
@@ -169,12 +170,14 @@ struct DiscordGuildForm: Identifiable {
key: String = "", key: String = "",
slug: String = "", slug: String = "",
requireMention: Bool = false, requireMention: Bool = false,
reactionNotifications: String = "own",
users: String = "", users: String = "",
channels: [DiscordGuildChannelForm] = [] channels: [DiscordGuildChannelForm] = []
) { ) {
self.key = key self.key = key
self.slug = slug self.slug = slug
self.requireMention = requireMention self.requireMention = requireMention
self.reactionNotifications = reactionNotifications
self.users = users self.users = users
self.channels = channels self.channels = channels
} }
@@ -491,6 +494,10 @@ final class ConnectionsStore {
let entry = value.dictionaryValue ?? [:] let entry = value.dictionaryValue ?? [:]
let slug = entry["slug"]?.stringValue ?? "" let slug = entry["slug"]?.stringValue ?? ""
let requireMention = entry["requireMention"]?.boolValue ?? false let requireMention = entry["requireMention"]?.boolValue ?? false
let reactionModeRaw = entry["reactionNotifications"]?.stringValue ?? ""
let reactionNotifications = ["off", "own", "all", "allowlist"].contains(reactionModeRaw)
? reactionModeRaw
: "own"
let users = entry["users"]?.arrayValue? let users = entry["users"]?.arrayValue?
.compactMap { item -> String? in .compactMap { item -> String? in
if let str = item.stringValue { return str } if let str = item.stringValue { return str }
@@ -518,6 +525,7 @@ final class ConnectionsStore {
key: key, key: key,
slug: slug, slug: slug,
requireMention: requireMention, requireMention: requireMention,
reactionNotifications: reactionNotifications,
users: users, users: users,
channels: channels) channels: channels)
} }
@@ -794,6 +802,9 @@ final class ConnectionsStore {
let slug = entry.slug.trimmingCharacters(in: .whitespacesAndNewlines) let slug = entry.slug.trimmingCharacters(in: .whitespacesAndNewlines)
if !slug.isEmpty { payload["slug"] = slug } if !slug.isEmpty { payload["slug"] = slug }
if entry.requireMention { payload["requireMention"] = true } if entry.requireMention { payload["requireMention"] = true }
if ["off", "own", "all", "allowlist"].contains(entry.reactionNotifications) {
payload["reactionNotifications"] = entry.reactionNotifications
}
let users = entry.users let users = entry.users
.split(separator: ",") .split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+2 -2
View File
@@ -1,7 +1,7 @@
import Foundation import Foundation
let launchdLabel = "com.steipete.clawdis" let launchdLabel = "com.clawdis.mac"
let gatewayLaunchdLabel = "com.steipete.clawdis.gateway" let gatewayLaunchdLabel = "com.clawdis.gateway"
let onboardingVersionKey = "clawdis.onboardingVersion" let onboardingVersionKey = "clawdis.onboardingVersion"
let currentOnboardingVersion = 7 let currentOnboardingVersion = 7
let pauseDefaultsKey = "clawdis.pauseEnabled" let pauseDefaultsKey = "clawdis.pauseEnabled"
@@ -73,7 +73,7 @@ final class ControlChannel {
} }
private(set) var lastPingMs: Double? private(set) var lastPingMs: Double?
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "control") private let logger = Logger(subsystem: "com.clawdis", category: "control")
private var eventTask: Task<Void, Never>? private var eventTask: Task<Void, Never>?
private var recoveryTask: Task<Void, Never>? private var recoveryTask: Task<Void, Never>?
@@ -185,7 +185,7 @@ final class ControlChannel {
"Reason: \(reason)" "Reason: \(reason)"
} }
// Common misfire: we connected to localhost:18789 but the port is occupied // Common misfire: we connected to the configured localhost port but it is occupied
// by some other process (e.g. a local dev gateway or a stuck SSH forward). // by some other process (e.g. a local dev gateway or a stuck SSH forward).
// The gateway handshake returns something we can't parse, which currently // The gateway handshake returns something we can't parse, which currently
// surfaces as "hello failed (unexpected response)". Give the user a pointer // surfaces as "hello failed (unexpected response)". Give the user a pointer
@@ -21,7 +21,7 @@ final class CronJobsStore {
var lastError: String? var lastError: String?
var statusMessage: String? var statusMessage: String?
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "cron.ui") private let logger = Logger(subsystem: "com.clawdis", category: "cron.ui")
private var refreshTask: Task<Void, Never>? private var refreshTask: Task<Void, Never>?
private var runsTask: Task<Void, Never>? private var runsTask: Task<Void, Never>?
private var eventTask: Task<Void, Never>? private var eventTask: Task<Void, Never>?
@@ -306,7 +306,7 @@ struct DebugSettings: View {
} }
if self.portReports.isEmpty, !self.portCheckInFlight { if self.portReports.isEmpty, !self.portCheckInFlight {
Text("Check which process owns 18789 and suggest fixes.") Text("Check which process owns \(GatewayEnvironment.gatewayPort()) and suggest fixes.")
.font(.caption2) .font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else { } else {
@@ -946,7 +946,7 @@ extension DebugSettings {
view.portCheckInFlight = true view.portCheckInFlight = true
view.portReports = [ view.portReports = [
DebugActions.PortReport( DebugActions.PortReport(
port: 18789, port: GatewayEnvironment.gatewayPort(),
expected: "Gateway websocket (node/tsx)", expected: "Gateway websocket (node/tsx)",
status: .missing("Missing"), status: .missing("Missing"),
listeners: []), listeners: []),
+1 -1
View File
@@ -4,7 +4,7 @@ import Foundation
import OSLog import OSLog
import Security import Security
private let deepLinkLogger = Logger(subsystem: "com.steipete.clawdis", category: "DeepLink") private let deepLinkLogger = Logger(subsystem: "com.clawdis", category: "DeepLink")
@MainActor @MainActor
final class DeepLinkHandler { final class DeepLinkHandler {
@@ -6,7 +6,7 @@ final class DockIconManager: NSObject, @unchecked Sendable {
static let shared = DockIconManager() static let shared = DockIconManager()
private var windowsObservation: NSKeyValueObservation? private var windowsObservation: NSKeyValueObservation?
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "DockIconManager") private let logger = Logger(subsystem: "com.clawdis", category: "DockIconManager")
override private init() { override private init() {
super.init() super.init()
@@ -58,7 +58,7 @@ struct WebSocketSessionBox: @unchecked Sendable {
private typealias ProtoAnyCodable = ClawdisProtocol.AnyCodable private typealias ProtoAnyCodable = ClawdisProtocol.AnyCodable
actor GatewayChannelActor { actor GatewayChannelActor {
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "gateway") private let logger = Logger(subsystem: "com.clawdis", category: "gateway")
private var task: WebSocketTaskBox? private var task: WebSocketTaskBox?
private var pending: [String: CheckedContinuation<GatewayFrame, Error>] = [:] private var pending: [String: CheckedContinuation<GatewayFrame, Error>] = [:]
private var connected = false private var connected = false
@@ -3,7 +3,7 @@ import ClawdisProtocol
import Foundation import Foundation
import OSLog import OSLog
private let gatewayConnectionLogger = Logger(subsystem: "com.steipete.clawdis", category: "gateway.connection") private let gatewayConnectionLogger = Logger(subsystem: "com.clawdis", category: "gateway.connection")
enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable { enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
case last case last
@@ -51,6 +51,10 @@ actor GatewayConnection {
case providersStatus = "providers.status" case providersStatus = "providers.status"
case configGet = "config.get" case configGet = "config.get"
case configSet = "config.set" case configSet = "config.set"
case wizardStart = "wizard.start"
case wizardNext = "wizard.next"
case wizardCancel = "wizard.cancel"
case wizardStatus = "wizard.status"
case talkMode = "talk.mode" case talkMode = "talk.mode"
case webLoginStart = "web.login.start" case webLoginStart = "web.login.start"
case webLoginWait = "web.login.wait" case webLoginWait = "web.login.wait"
@@ -34,7 +34,7 @@ final class GatewayDiscoveryModel {
private var localIdentity: LocalIdentity private var localIdentity: LocalIdentity
private var resolvedTXTByID: [String: [String: String]] = [:] private var resolvedTXTByID: [String: [String: String]] = [:]
private var pendingTXTResolvers: [String: GatewayTXTResolver] = [:] private var pendingTXTResolvers: [String: GatewayTXTResolver] = [:]
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "gateway-discovery") private let logger = Logger(subsystem: "com.clawdis", category: "gateway-discovery")
init() { init() {
self.localIdentity = Self.buildLocalIdentityFast() self.localIdentity = Self.buildLocalIdentityFast()
@@ -69,7 +69,7 @@ final class GatewayDiscoveryModel {
} }
self.browsers[domain] = browser self.browsers[domain] = browser
browser.start(queue: DispatchQueue(label: "com.steipete.clawdis.macos.gateway-discovery.\(domain)")) browser.start(queue: DispatchQueue(label: "com.clawdis.macos.gateway-discovery.\(domain)"))
} }
} }
@@ -72,7 +72,7 @@ actor GatewayEndpointStore {
} }
private let deps: Deps private let deps: Deps
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "gateway-endpoint") private let logger = Logger(subsystem: "com.clawdis", category: "gateway-endpoint")
private var state: GatewayEndpointState private var state: GatewayEndpointState
private var subscribers: [UUID: AsyncStream<GatewayEndpointState>.Continuation] = [:] private var subscribers: [UUID: AsyncStream<GatewayEndpointState>.Continuation] = [:]
@@ -62,7 +62,7 @@ struct GatewayCommandResolution {
} }
enum GatewayEnvironment { enum GatewayEnvironment {
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "gateway.env") private static let logger = Logger(subsystem: "com.clawdis", category: "gateway.env")
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"] private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
static func bundledGatewayExecutable() -> String? { static func bundledGatewayExecutable() -> String? {
@@ -72,6 +72,13 @@ enum GatewayEnvironment {
} }
static func gatewayPort() -> Int { static func gatewayPort() -> Int {
if let raw = ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_PORT"] {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if let parsed = Int(trimmed), parsed > 0 { return parsed }
}
if let configPort = ClawdisConfigFile.gatewayPort(), configPort > 0 {
return configPort
}
let stored = UserDefaults.standard.integer(forKey: "gatewayPort") let stored = UserDefaults.standard.integer(forKey: "gatewayPort")
return stored > 0 ? stored : 18789 return stored > 0 ? stored : 18789
} }
Loaded 100 of 342 files, more files were not shown because too many files have changed in this diff. Show more