Compare commits
83
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68ecee84d1 | ||
|
|
d1ebc4489a | ||
|
|
a71efbe7de | ||
|
|
a7b8c0d963 | ||
|
|
55a07a0ef0 | ||
|
|
9899ba53a3 | ||
|
|
52458a5628 | ||
|
|
7abd6713c8 | ||
|
|
451174ca10 | ||
|
|
cdfbd6e7eb | ||
|
|
350e007a5c | ||
|
|
5e156135a1 | ||
|
|
b7ec9ae475 | ||
|
|
8a18af409d | ||
|
|
6a125b554b | ||
|
|
ce92fac983 | ||
|
|
341a224301 | ||
|
|
95cd153f33 | ||
|
|
0af89022ff | ||
|
|
27a8f3d061 | ||
|
|
72b34f7d03 | ||
|
|
73fa2e10bc | ||
|
|
4a6b33d799 | ||
|
|
145964c85e | ||
|
|
217b84f2ac | ||
|
|
1d6de24ab3 | ||
|
|
822def84d2 | ||
|
|
f313af75e9 | ||
|
|
591773715e | ||
|
|
dd6b9b510b | ||
|
|
6ae51ae3de | ||
|
|
00c3e98431 | ||
|
|
dd561f58d1 | ||
|
|
200dd634fb | ||
|
|
3bbdcaf87f | ||
|
|
abff5e3b1f | ||
|
|
40ee0f0672 | ||
|
|
9f8eeceae7 | ||
|
|
53baba71fa | ||
|
|
0f85080d81 | ||
|
|
72f8148080 | ||
|
|
be3da5b856 | ||
|
|
9a9b429f74 | ||
|
|
733e86516e | ||
|
|
1a00175eb7 | ||
|
|
77c76ca52f | ||
|
|
5de3395204 | ||
|
|
4e4655f607 | ||
|
|
48731f494b | ||
|
|
4fcd89c3d9 | ||
|
|
a4f433a1b1 | ||
|
|
84a7ee491b | ||
|
|
3043dd3a0c | ||
|
|
81f4a7cdb7 | ||
|
|
c2a74d6d2a | ||
|
|
861e1b33f5 | ||
|
|
0647d56555 | ||
|
|
ea6aea8532 | ||
|
|
6eca2edd79 | ||
|
|
d31dfbc565 | ||
|
|
1e0f776824 | ||
|
|
db36f0105d | ||
|
|
5377e2400a | ||
|
|
72c0aa63fb | ||
|
|
933bee220f | ||
|
|
bd2dabfa8f | ||
|
|
f11b352089 | ||
|
|
bb54e60179 | ||
|
|
e52bdaa2a2 | ||
|
|
b6301c719b | ||
|
|
6e16c0699a | ||
|
|
bf4ad295af | ||
|
|
7a80e8fe77 | ||
|
|
1ec3512925 | ||
|
|
772ada4308 | ||
|
|
7165c8a7e5 | ||
|
|
daa1460502 | ||
|
|
f47c7ac369 | ||
|
|
7199813969 | ||
|
|
87d5fa516d | ||
|
|
10340d2a3f | ||
|
|
508c4d362f | ||
|
|
f73b008251 |
No files matched your search
+44
-1
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.steipete.clawdis.node
|
package com.clawdis.android
|
||||||
|
|
||||||
enum class CameraHudKind {
|
enum class CameraHudKind {
|
||||||
Photo,
|
Photo,
|
||||||
+1
-1
@@ -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
|
||||||
+3
-3
@@ -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() {
|
||||||
+6
-6
@@ -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
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.steipete.clawdis.node
|
package com.clawdis.android
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
|
||||||
+2
-2
@@ -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)
|
||||||
+22
-22
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
|
||||||
+2
-2
@@ -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
|
||||||
+4
-2
@@ -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,
|
||||||
)
|
)
|
||||||
+2
-1
@@ -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,
|
||||||
)
|
)
|
||||||
+2
-2
@@ -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
|
||||||
+2
-2
@@ -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
-1
@@ -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
|
||||||
+4
-4
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
|
||||||
+3
-3
@@ -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
-1
@@ -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
|
||||||
+3
-3
@@ -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
|
||||||
+6
-6
@@ -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
-1
@@ -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
-1
@@ -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
|
||||||
+2
-2
@@ -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
-1
@@ -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
|
||||||
+3
-3
@@ -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(
|
||||||
+34
-8
@@ -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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2
-2
@@ -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(
|
||||||
+3
-3
@@ -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
|
||||||
+2
-2
@@ -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
-1
@@ -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
-1
@@ -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
|
||||||
+2
-2
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
|
||||||
+2
-2
@@ -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
-1
@@ -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
@@ -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
|
||||||
@@ -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)"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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,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:
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
@@ -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: []),
|
||||||
|
|||||||
@@ -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
Reference in new issue
Block a user