Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3762e876b3 | ||
|
|
9b4d06002a | ||
|
|
d2d336fc3a |
No files matched your search
@@ -170,43 +170,6 @@ jobs:
|
|||||||
sleep $((attempt * 20))
|
sleep $((attempt * 20))
|
||||||
done
|
done
|
||||||
exit 1
|
exit 1
|
||||||
ios:
|
|
||||||
runs-on: macos-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: false
|
|
||||||
|
|
||||||
- name: Checkout submodules (retry)
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
git submodule sync --recursive
|
|
||||||
for attempt in 1 2 3 4 5; do
|
|
||||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
|
||||||
sleep $((attempt * 10))
|
|
||||||
done
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
- name: Select Xcode 26.1
|
|
||||||
run: |
|
|
||||||
sudo xcode-select -s /Applications/Xcode_26.1.app
|
|
||||||
xcodebuild -version
|
|
||||||
|
|
||||||
- name: Install XcodeGen
|
|
||||||
run: brew install xcodegen
|
|
||||||
|
|
||||||
- name: Install SwiftLint / SwiftFormat
|
|
||||||
run: brew install swiftlint swiftformat
|
|
||||||
|
|
||||||
- name: Show toolchain
|
|
||||||
run: |
|
|
||||||
sw_vers
|
|
||||||
xcodebuild -version
|
|
||||||
swift --version
|
|
||||||
|
|
||||||
- name: Generate iOS project
|
- name: Generate iOS project
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ bin/clawdis-mac
|
|||||||
bin/docs-list
|
bin/docs-list
|
||||||
apps/macos/.build-local/
|
apps/macos/.build-local/
|
||||||
apps/macos/.swiftpm/
|
apps/macos/.swiftpm/
|
||||||
apps/shared/ClawdisKit/.swiftpm/
|
|
||||||
Core/
|
Core/
|
||||||
apps/ios/*.xcodeproj/
|
apps/ios/*.xcodeproj/
|
||||||
apps/ios/*.xcworkspace/
|
apps/ios/*.xcworkspace/
|
||||||
|
|||||||
@@ -40,13 +40,10 @@
|
|||||||
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for subsystem `com.steipete.clawdis`; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
|
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for subsystem `com.steipete.clawdis`; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
|
||||||
- Also read the shared guardrails at `~/Projects/oracle/AGENTS.md` and `~/Projects/agent-scripts/AGENTS.MD` before making changes; align with any cross-repo rules noted there.
|
- Also read the shared guardrails at `~/Projects/oracle/AGENTS.md` and `~/Projects/agent-scripts/AGENTS.MD` before making changes; align with any cross-repo rules noted there.
|
||||||
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
|
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
|
||||||
- **Restart apps:** “restart iOS/Android apps” means rebuild (recompile/install) and relaunch, not just kill/launch.
|
|
||||||
- Notary key file lives at `~/Library/CloudStorage/Dropbox/Backup/AppStore/AuthKey_NJF3NFGTS3.p8` (Sparkle keys live under `~/Library/CloudStorage/Dropbox/Backup/Sparkle`).
|
|
||||||
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless Peter explicitly asks (this includes `git pull --rebase --autostash`). Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
|
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless Peter explicitly asks (this includes `git pull --rebase --autostash`). Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
|
||||||
- **Multi-agent safety:** when Peter says "push", you may `git pull --rebase` to integrate latest changes (never discard other agents' work). When Peter says "commit", scope to your changes only. When Peter says "commit all", commit everything in grouped chunks.
|
|
||||||
- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless Peter explicitly asks.
|
- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless Peter explicitly asks.
|
||||||
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless Peter explicitly asks.
|
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless Peter explicitly asks.
|
||||||
- When asked to open a “session” file, open the Pi session logs under `~/.clawdis/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from Mac Studio, SSH via Tailscale and read the same path there.
|
- When asked to open a “session” file, open the Pi session logs under `~/.clawdis/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`.
|
||||||
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdis variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
|
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdis variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
|
||||||
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
|
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
|
||||||
- Voice wake forwarding tips:
|
- Voice wake forwarding tips:
|
||||||
|
|||||||
@@ -1,41 +1,5 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## Unreleased — 2025-12-23
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Telegram/WhatsApp: native replies now target the original inbound message; reply context is captured in `ReplyTo*` fields for templates. (Thanks @joshp123 for the PR and follow-up question.)
|
|
||||||
|
|
||||||
## 2.0.0-beta2 — 2025-12-21
|
|
||||||
|
|
||||||
Second beta focused on bundled gateway packaging, skills management, onboarding polish, and provider reliability.
|
|
||||||
|
|
||||||
### Highlights
|
|
||||||
- Bundled gateway packaging: bun-compiled embedded gateway, new `gateway-daemon` command, launchd support, DMG packaging (zip+DMG).
|
|
||||||
- Skills platform: managed/bundled skills, install metadata + installers (uv), skill search + website, media/transcription helpers.
|
|
||||||
- macOS app: new Connections settings w/ provider status + QR login, skills settings redesign w/ install targets, models list loaded from the Gateway, clearer local/remote gateway choices.
|
|
||||||
- Web/agent UX: tool summary streaming + runtime toggle, WhatsApp QR login tool, agent steering queue, voice wake routes to main session, workspace bootstrap ritual.
|
|
||||||
|
|
||||||
### Gateway & providers
|
|
||||||
- Gateway: `models.list`, provider status events + RPC coverage, tailscale auth + PAM, bind-mode config, enriched agent WS logs, safer upgrade socket handling, fixed handshake auth crash.
|
|
||||||
- WhatsApp Web: QR login flow improvements (logged-out clearing, wait flow), self-chat mode handling, removed batching delay, web inbox made non-blocking.
|
|
||||||
- Telegram: normalized chat IDs with clearer error reporting.
|
|
||||||
|
|
||||||
### Canvas & browser control
|
|
||||||
- Canvas host served on Gateway port; removed standalone canvasHost port config; restored action bridge; refreshed A2UI bundle + message context; bridge canvas host for nodes.
|
|
||||||
- A2UI full-screen gutters + status clearance after successful load to avoid overlay collisions.
|
|
||||||
- Browser control API simplified; added MCP tool dispatch + native actions; control server can start without Playwright; hook timeouts extended.
|
|
||||||
|
|
||||||
### macOS UI polish
|
|
||||||
- Onboarding chat UI: kickoff flow, bubble tails, spacing + bottom bar refinements, window sizing tweaks, show Dock icon during onboarding.
|
|
||||||
- Skills UI: stabilized action column, fixed install target access, refined list layout and sizing, always show CLI installer.
|
|
||||||
- Remote/local gateway: auto-enable local gateway, clearer labels, re-ensure remote tunnel, hide local bridge discovery in remote mode.
|
|
||||||
|
|
||||||
### Build, CI, deps
|
|
||||||
- Bundled playwright-core + chromium-bidi/long; bun gateway bytecode builds; swiftformat/biome CI fixes; iOS lint script updates; Android icon/compiler updates; ignored new ClawdisKit `.swiftpm` path.
|
|
||||||
|
|
||||||
### Docs
|
|
||||||
- README architecture refresh + npm header image fix; onboarding/bootstrap steps; skills install guidance + new skills; browser/canvas control docs; bundled gateway + DMG packaging notes.
|
|
||||||
|
|
||||||
## 2.0.0-beta1 — 2025-12-19
|
## 2.0.0-beta1 — 2025-12-19
|
||||||
|
|
||||||
First Clawdis release post rebrand. This is a semver-major because we dropped legacy providers/agents and moved defaults to new paths while adding a full macOS companion app, a WebSocket Gateway, and an iOS node.
|
First Clawdis release post rebrand. This is a semver-major because we dropped legacy providers/agents and moved defaults to new paths while adding a full macOS companion app, a WebSocket Gateway, and an iOS node.
|
||||||
|
|||||||
+1
-1
Submodule Peekaboo updated: 9db365b73c...be03d6e3df.
@@ -1,7 +1,7 @@
|
|||||||
# 🦞 CLAWDIS — Personal AI Assistant
|
# 🦞 CLAWDIS — Personal AI Assistant
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://raw.githubusercontent.com/steipete/clawdis/main/docs/whatsapp-clawd.jpg" alt="CLAWDIS" width="400">
|
<img src="docs/whatsapp-clawd.jpg" alt="CLAWDIS" width="400">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -43,7 +43,6 @@ Your surfaces
|
|||||||
- **Canvas** — a live visual workspace you can drive from the agent.
|
- **Canvas** — a live visual workspace you can drive from the agent.
|
||||||
- **Automation-ready** — browser control, media handling, and tool streaming.
|
- **Automation-ready** — browser control, media handling, and tool streaming.
|
||||||
- **Local-first control plane** — the Gateway owns state, everything else connects.
|
- **Local-first control plane** — the Gateway owns state, everything else connects.
|
||||||
- **Group chats** — mention-based by default, `/activation always|mention` per group (owner-only).
|
|
||||||
|
|
||||||
## How it works (short)
|
## How it works (short)
|
||||||
|
|
||||||
@@ -76,17 +75,6 @@ pnpm clawdis agent --message "Ship checklist" --thinking high
|
|||||||
|
|
||||||
If you run from source, prefer `pnpm clawdis …` (not global `clawdis`).
|
If you run from source, prefer `pnpm clawdis …` (not global `clawdis`).
|
||||||
|
|
||||||
## Chat commands
|
|
||||||
|
|
||||||
Send these in WhatsApp/Telegram/WebChat (group commands are owner-only):
|
|
||||||
|
|
||||||
- `/status` — health + session info (group shows activation mode)
|
|
||||||
- `/new` or `/reset` — reset the session
|
|
||||||
- `/think <level>` — off|minimal|low|medium|high
|
|
||||||
- `/verbose on|off`
|
|
||||||
- `/restart` — restart the gateway (owner-only in groups)
|
|
||||||
- `/activation mention|always` — group activation toggle (groups only)
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### TypeScript Gateway (src/gateway/server.ts)
|
### TypeScript Gateway (src/gateway/server.ts)
|
||||||
@@ -183,7 +171,6 @@ Browser control (optional):
|
|||||||
|
|
||||||
- [`docs/index.md`](docs/index.md) (overview)
|
- [`docs/index.md`](docs/index.md) (overview)
|
||||||
- [`docs/configuration.md`](docs/configuration.md)
|
- [`docs/configuration.md`](docs/configuration.md)
|
||||||
- [`docs/group-messages.md`](docs/group-messages.md)
|
|
||||||
- [`docs/gateway.md`](docs/gateway.md)
|
- [`docs/gateway.md`](docs/gateway.md)
|
||||||
- [`docs/web.md`](docs/web.md)
|
- [`docs/web.md`](docs/web.md)
|
||||||
- [`docs/discovery.md`](docs/discovery.md)
|
- [`docs/discovery.md`](docs/discovery.md)
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
# Changelog
|
|
||||||
|
|
||||||
## 0.2.0 — 2025-12-23
|
|
||||||
|
|
||||||
### Highlights
|
|
||||||
- Added `SwabbleKit` (multi-platform wake-word gate utilities with segment-aware gap detection).
|
|
||||||
- Swabble package now supports iOS + macOS consumers; CLI remains macOS 26-only.
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- CLI wake-word matching/stripping routed through `SwabbleKit` helpers.
|
|
||||||
- Speech pipeline types now explicitly gated to macOS 26 / iOS 26 availability.
|
|
||||||
+1
-20
@@ -4,12 +4,10 @@ import PackageDescription
|
|||||||
let package = Package(
|
let package = Package(
|
||||||
name: "swabble",
|
name: "swabble",
|
||||||
platforms: [
|
platforms: [
|
||||||
.macOS(.v15),
|
.macOS(.v26),
|
||||||
.iOS(.v17),
|
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
.library(name: "Swabble", targets: ["Swabble"]),
|
.library(name: "Swabble", targets: ["Swabble"]),
|
||||||
.library(name: "SwabbleKit", targets: ["SwabbleKit"]),
|
|
||||||
.executable(name: "swabble", targets: ["SwabbleCLI"]),
|
.executable(name: "swabble", targets: ["SwabbleCLI"]),
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
@@ -21,30 +19,13 @@ let package = Package(
|
|||||||
name: "Swabble",
|
name: "Swabble",
|
||||||
path: "Sources/SwabbleCore",
|
path: "Sources/SwabbleCore",
|
||||||
swiftSettings: []),
|
swiftSettings: []),
|
||||||
.target(
|
|
||||||
name: "SwabbleKit",
|
|
||||||
path: "Sources/SwabbleKit",
|
|
||||||
swiftSettings: [
|
|
||||||
.enableUpcomingFeature("StrictConcurrency"),
|
|
||||||
]),
|
|
||||||
.executableTarget(
|
.executableTarget(
|
||||||
name: "SwabbleCLI",
|
name: "SwabbleCLI",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
"Swabble",
|
"Swabble",
|
||||||
"SwabbleKit",
|
|
||||||
.product(name: "Commander", package: "Commander"),
|
.product(name: "Commander", package: "Commander"),
|
||||||
],
|
],
|
||||||
path: "Sources/swabble"),
|
path: "Sources/swabble"),
|
||||||
.testTarget(
|
|
||||||
name: "SwabbleKitTests",
|
|
||||||
dependencies: [
|
|
||||||
"SwabbleKit",
|
|
||||||
.product(name: "Testing", package: "swift-testing"),
|
|
||||||
],
|
|
||||||
swiftSettings: [
|
|
||||||
.enableUpcomingFeature("StrictConcurrency"),
|
|
||||||
.enableExperimentalFeature("SwiftTesting"),
|
|
||||||
]),
|
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "swabbleTests",
|
name: "swabbleTests",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
|
|||||||
+4
-8
@@ -1,10 +1,9 @@
|
|||||||
# 🎙️ swabble — Speech.framework wake-word hook daemon (macOS 26)
|
# 🎙️ swabble — Speech.framework wake-word hook daemon (macOS 26)
|
||||||
|
|
||||||
swabble is a Swift 6.2 wake-word hook daemon. The CLI targets macOS 26 (SpeechAnalyzer + SpeechTranscriber). The shared `SwabbleKit` target is multi-platform and exposes wake-word gating utilities for iOS/macOS apps.
|
swabble is a Swift 6.2, macOS 26-only rewrite of the brabble voice daemon. It listens on your mic, gates on a wake word, transcribes locally using Apple's new SpeechAnalyzer + SpeechTranscriber, then fires a shell hook with the transcript. No cloud calls, no Whisper binaries.
|
||||||
|
|
||||||
- **Local-only**: Speech.framework on-device models; zero network usage.
|
- **Local-only**: Speech.framework on-device models; zero network usage.
|
||||||
- **Wake word**: Default `clawd` (aliases `claude`), optional `--no-wake` bypass.
|
- **Wake word**: Default `clawd` (aliases `claude`), optional `--no-wake` bypass.
|
||||||
- **SwabbleKit**: Shared wake gate utilities (gap-based gating when you provide speech segments).
|
|
||||||
- **Hooks**: Run any command with prefix/env, cooldown, min_chars, timeout.
|
- **Hooks**: Run any command with prefix/env, cooldown, min_chars, timeout.
|
||||||
- **Services**: launchd helper stubs for start/stop/install.
|
- **Services**: launchd helper stubs for start/stop/install.
|
||||||
- **File transcribe**: TXT or SRT with time ranges (using AttributedString splits).
|
- **File transcribe**: TXT or SRT with time ranges (using AttributedString splits).
|
||||||
@@ -31,7 +30,7 @@ swift run swabble transcribe /path/to/audio.m4a --format srt --output out.srt
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Use as a library
|
## Use as a library
|
||||||
Add swabble as a SwiftPM dependency and import the `Swabble` or `SwabbleKit` product:
|
Add swabble as a SwiftPM dependency and import the `Swabble` product to reuse the Speech pipeline, config loader, hook executor, and transcript store in your own app:
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
// Package.swift
|
// Package.swift
|
||||||
@@ -39,10 +38,7 @@ dependencies: [
|
|||||||
.package(url: "https://github.com/steipete/swabble.git", branch: "main"),
|
.package(url: "https://github.com/steipete/swabble.git", branch: "main"),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(name: "MyApp", dependencies: [
|
.target(name: "MyApp", dependencies: [.product(name: "Swabble", package: "swabble")]),
|
||||||
.product(name: "Swabble", package: "swabble"), // Speech pipeline (macOS 26+ / iOS 26+)
|
|
||||||
.product(name: "SwabbleKit", package: "swabble"), // Wake-word gate utilities (iOS 17+ / macOS 15+)
|
|
||||||
]),
|
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -97,7 +93,7 @@ Environment variables:
|
|||||||
|
|
||||||
## Speech pipeline
|
## Speech pipeline
|
||||||
- `AVAudioEngine` tap → `BufferConverter` → `AnalyzerInput` → `SpeechAnalyzer` with a `SpeechTranscriber` module.
|
- `AVAudioEngine` tap → `BufferConverter` → `AnalyzerInput` → `SpeechAnalyzer` with a `SpeechTranscriber` module.
|
||||||
- Requests volatile + final results; the CLI uses text-only wake gating today.
|
- Requests volatile + final results; wake gating is string match on partial/final.
|
||||||
- Authorization requested at first start; requires macOS 26 + new Speech.framework APIs.
|
- Authorization requested at first start; requires macOS 26 + new Speech.framework APIs.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|||||||
@@ -2,13 +2,11 @@ import AVFoundation
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Speech
|
import Speech
|
||||||
|
|
||||||
@available(macOS 26.0, iOS 26.0, *)
|
|
||||||
public struct SpeechSegment: Sendable {
|
public struct SpeechSegment: Sendable {
|
||||||
public let text: String
|
public let text: String
|
||||||
public let isFinal: Bool
|
public let isFinal: Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(macOS 26.0, iOS 26.0, *)
|
|
||||||
public enum SpeechPipelineError: Error {
|
public enum SpeechPipelineError: Error {
|
||||||
case authorizationDenied
|
case authorizationDenied
|
||||||
case analyzerFormatUnavailable
|
case analyzerFormatUnavailable
|
||||||
@@ -16,7 +14,6 @@ public enum SpeechPipelineError: Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Live microphone → SpeechAnalyzer → SpeechTranscriber pipeline.
|
/// Live microphone → SpeechAnalyzer → SpeechTranscriber pipeline.
|
||||||
@available(macOS 26.0, iOS 26.0, *)
|
|
||||||
public actor SpeechPipeline {
|
public actor SpeechPipeline {
|
||||||
private struct UnsafeBuffer: @unchecked Sendable { let buffer: AVAudioPCMBuffer }
|
private struct UnsafeBuffer: @unchecked Sendable { let buffer: AVAudioPCMBuffer }
|
||||||
|
|
||||||
|
|||||||
@@ -1,202 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
public struct WakeWordSegment: Sendable, Equatable {
|
|
||||||
public let text: String
|
|
||||||
public let start: TimeInterval
|
|
||||||
public let duration: TimeInterval
|
|
||||||
public let range: Range<String.Index>?
|
|
||||||
|
|
||||||
public init(text: String, start: TimeInterval, duration: TimeInterval, range: Range<String.Index>? = nil) {
|
|
||||||
self.text = text
|
|
||||||
self.start = start
|
|
||||||
self.duration = duration
|
|
||||||
self.range = range
|
|
||||||
}
|
|
||||||
|
|
||||||
public var end: TimeInterval { self.start + self.duration }
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct WakeWordGateConfig: Sendable, Equatable {
|
|
||||||
public var triggers: [String]
|
|
||||||
public var minPostTriggerGap: TimeInterval
|
|
||||||
public var minCommandLength: Int
|
|
||||||
|
|
||||||
public init(
|
|
||||||
triggers: [String],
|
|
||||||
minPostTriggerGap: TimeInterval = 0.45,
|
|
||||||
minCommandLength: Int = 1)
|
|
||||||
{
|
|
||||||
self.triggers = triggers
|
|
||||||
self.minPostTriggerGap = minPostTriggerGap
|
|
||||||
self.minCommandLength = minCommandLength
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct WakeWordGateMatch: Sendable, Equatable {
|
|
||||||
public let triggerEndTime: TimeInterval
|
|
||||||
public let postGap: TimeInterval
|
|
||||||
public let command: String
|
|
||||||
|
|
||||||
public init(triggerEndTime: TimeInterval, postGap: TimeInterval, command: String) {
|
|
||||||
self.triggerEndTime = triggerEndTime
|
|
||||||
self.postGap = postGap
|
|
||||||
self.command = command
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum WakeWordGate {
|
|
||||||
private struct Token {
|
|
||||||
let normalized: String
|
|
||||||
let start: TimeInterval
|
|
||||||
let end: TimeInterval
|
|
||||||
let range: Range<String.Index>?
|
|
||||||
let text: String
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct TriggerTokens {
|
|
||||||
let tokens: [String]
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func match(
|
|
||||||
transcript: String,
|
|
||||||
segments: [WakeWordSegment],
|
|
||||||
config: WakeWordGateConfig)
|
|
||||||
-> WakeWordGateMatch? {
|
|
||||||
let triggerTokens = self.normalizeTriggers(config.triggers)
|
|
||||||
guard !triggerTokens.isEmpty else { return nil }
|
|
||||||
|
|
||||||
let tokens = self.normalizeSegments(segments)
|
|
||||||
guard !tokens.isEmpty else { return nil }
|
|
||||||
|
|
||||||
var bestIndex: Int?
|
|
||||||
var bestTriggerEnd: TimeInterval = 0
|
|
||||||
var bestGap: TimeInterval = 0
|
|
||||||
|
|
||||||
for trigger in triggerTokens {
|
|
||||||
let count = trigger.tokens.count
|
|
||||||
guard count > 0, tokens.count > count else { continue }
|
|
||||||
for i in 0...(tokens.count - count - 1) {
|
|
||||||
var matched = true
|
|
||||||
for t in 0..<count {
|
|
||||||
if tokens[i + t].normalized != trigger.tokens[t] {
|
|
||||||
matched = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !matched { continue }
|
|
||||||
|
|
||||||
let triggerEnd = tokens[i + count - 1].end
|
|
||||||
let nextToken = tokens[i + count]
|
|
||||||
let gap = nextToken.start - triggerEnd
|
|
||||||
if gap < config.minPostTriggerGap { continue }
|
|
||||||
|
|
||||||
if let bestIndex, i <= bestIndex { continue }
|
|
||||||
|
|
||||||
bestIndex = i
|
|
||||||
bestTriggerEnd = triggerEnd
|
|
||||||
bestGap = gap
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let bestIndex else { return nil }
|
|
||||||
let command = self.commandText(transcript: transcript, segments: segments, triggerEndTime: bestTriggerEnd)
|
|
||||||
.trimmingCharacters(in: Self.whitespaceAndPunctuation)
|
|
||||||
guard command.count >= config.minCommandLength else { return nil }
|
|
||||||
return WakeWordGateMatch(triggerEndTime: bestTriggerEnd, postGap: bestGap, command: command)
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func commandText(
|
|
||||||
transcript: String,
|
|
||||||
segments: [WakeWordSegment],
|
|
||||||
triggerEndTime: TimeInterval)
|
|
||||||
-> String {
|
|
||||||
let threshold = triggerEndTime + 0.001
|
|
||||||
for segment in segments where segment.start >= threshold {
|
|
||||||
if normalizeToken(segment.text).isEmpty { continue }
|
|
||||||
if let range = segment.range {
|
|
||||||
let slice = transcript[range.lowerBound...]
|
|
||||||
return String(slice).trimmingCharacters(in: Self.whitespaceAndPunctuation)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
let text = segments
|
|
||||||
.filter { $0.start >= threshold && !self.normalizeToken($0.text).isEmpty }
|
|
||||||
.map(\.text)
|
|
||||||
.joined(separator: " ")
|
|
||||||
return text.trimmingCharacters(in: Self.whitespaceAndPunctuation)
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func matchesTextOnly(text: String, triggers: [String]) -> Bool {
|
|
||||||
guard !text.isEmpty else { return false }
|
|
||||||
let normalized = text.lowercased()
|
|
||||||
for trigger in triggers {
|
|
||||||
let token = trigger.trimmingCharacters(in: self.whitespaceAndPunctuation).lowercased()
|
|
||||||
if token.isEmpty { continue }
|
|
||||||
if normalized.contains(token) { return true }
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func stripWake(text: String, triggers: [String]) -> String {
|
|
||||||
var out = text
|
|
||||||
for trigger in triggers {
|
|
||||||
let token = trigger.trimmingCharacters(in: self.whitespaceAndPunctuation)
|
|
||||||
guard !token.isEmpty else { continue }
|
|
||||||
out = out.replacingOccurrences(of: token, with: "", options: [.caseInsensitive])
|
|
||||||
}
|
|
||||||
return out.trimmingCharacters(in: self.whitespaceAndPunctuation)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func normalizeTriggers(_ triggers: [String]) -> [TriggerTokens] {
|
|
||||||
var output: [TriggerTokens] = []
|
|
||||||
for trigger in triggers {
|
|
||||||
let tokens = trigger
|
|
||||||
.split(whereSeparator: { $0.isWhitespace })
|
|
||||||
.map { self.normalizeToken(String($0)) }
|
|
||||||
.filter { !$0.isEmpty }
|
|
||||||
if tokens.isEmpty { continue }
|
|
||||||
output.append(TriggerTokens(tokens: tokens))
|
|
||||||
}
|
|
||||||
return output
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func normalizeSegments(_ segments: [WakeWordSegment]) -> [Token] {
|
|
||||||
segments.compactMap { segment in
|
|
||||||
let normalized = self.normalizeToken(segment.text)
|
|
||||||
guard !normalized.isEmpty else { return nil }
|
|
||||||
return Token(
|
|
||||||
normalized: normalized,
|
|
||||||
start: segment.start,
|
|
||||||
end: segment.end,
|
|
||||||
range: segment.range,
|
|
||||||
text: segment.text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func normalizeToken(_ token: String) -> String {
|
|
||||||
token
|
|
||||||
.trimmingCharacters(in: self.whitespaceAndPunctuation)
|
|
||||||
.lowercased()
|
|
||||||
}
|
|
||||||
|
|
||||||
private static let whitespaceAndPunctuation = CharacterSet.whitespacesAndNewlines
|
|
||||||
.union(.punctuationCharacters)
|
|
||||||
}
|
|
||||||
|
|
||||||
#if canImport(Speech)
|
|
||||||
import Speech
|
|
||||||
|
|
||||||
public enum WakeWordSpeechSegments {
|
|
||||||
public static func from(transcription: SFTranscription, transcript: String) -> [WakeWordSegment] {
|
|
||||||
transcription.segments.map { segment in
|
|
||||||
let range = Range(segment.substringRange, in: transcript)
|
|
||||||
return WakeWordSegment(
|
|
||||||
text: segment.substring,
|
|
||||||
start: segment.timestamp,
|
|
||||||
duration: segment.duration,
|
|
||||||
range: range)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import Commander
|
import Commander
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@available(macOS 26.0, *)
|
|
||||||
@MainActor
|
@MainActor
|
||||||
enum CLIRegistry {
|
enum CLIRegistry {
|
||||||
static var descriptors: [CommandDescriptor] {
|
static var descriptors: [CommandDescriptor] {
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import Commander
|
import Commander
|
||||||
import Foundation
|
import Foundation
|
||||||
import Swabble
|
import Swabble
|
||||||
import SwabbleKit
|
|
||||||
|
|
||||||
@available(macOS 26.0, *)
|
|
||||||
@MainActor
|
@MainActor
|
||||||
struct ServeCommand: ParsableCommand {
|
struct ServeCommand: ParsableCommand {
|
||||||
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
|
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
|
||||||
@@ -70,12 +68,17 @@ struct ServeCommand: ParsableCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func matchesWake(text: String, cfg: SwabbleConfig) -> Bool {
|
private static func matchesWake(text: String, cfg: SwabbleConfig) -> Bool {
|
||||||
let triggers = [cfg.wake.word] + cfg.wake.aliases
|
let lowered = text.lowercased()
|
||||||
return WakeWordGate.matchesTextOnly(text: text, triggers: triggers)
|
if lowered.contains(cfg.wake.word.lowercased()) { return true }
|
||||||
|
return cfg.wake.aliases.contains(where: { lowered.contains($0.lowercased()) })
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func stripWake(text: String, cfg: SwabbleConfig) -> String {
|
private static func stripWake(text: String, cfg: SwabbleConfig) -> String {
|
||||||
let triggers = [cfg.wake.word] + cfg.wake.aliases
|
var out = text
|
||||||
return WakeWordGate.stripWake(text: text, triggers: triggers)
|
out = out.replacingOccurrences(of: cfg.wake.word, with: "", options: [.caseInsensitive])
|
||||||
|
for alias in cfg.wake.aliases {
|
||||||
|
out = out.replacingOccurrences(of: alias, with: "", options: [.caseInsensitive])
|
||||||
|
}
|
||||||
|
return out.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import Commander
|
import Commander
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@available(macOS 26.0, *)
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func runCLI() async -> Int32 {
|
private func runCLI() async -> Int32 {
|
||||||
do {
|
do {
|
||||||
@@ -16,7 +15,6 @@ private func runCLI() async -> Int32 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(macOS 26.0, *)
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func dispatch(invocation: CommandInvocation) async throws {
|
private func dispatch(invocation: CommandInvocation) async throws {
|
||||||
let parsed = invocation.parsedValues
|
let parsed = invocation.parsedValues
|
||||||
@@ -97,10 +95,5 @@ private func dispatch(invocation: CommandInvocation) async throws {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if #available(macOS 26.0, *) {
|
let exitCode = await runCLI()
|
||||||
let exitCode = await runCLI()
|
exit(exitCode)
|
||||||
exit(exitCode)
|
|
||||||
} else {
|
|
||||||
fputs("error: swabble requires macOS 26 or newer\n", stderr)
|
|
||||||
exit(1)
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Testing
|
|
||||||
import SwabbleKit
|
|
||||||
|
|
||||||
@Suite struct WakeWordGateTests {
|
|
||||||
@Test func matchRequiresGapAfterTrigger() {
|
|
||||||
let transcript = "hey clawd do thing"
|
|
||||||
let segments = makeSegments(
|
|
||||||
transcript: transcript,
|
|
||||||
words: [
|
|
||||||
("hey", 0.0, 0.1),
|
|
||||||
("clawd", 0.2, 0.1),
|
|
||||||
("do", 0.35, 0.1),
|
|
||||||
("thing", 0.5, 0.1),
|
|
||||||
])
|
|
||||||
let config = WakeWordGateConfig(triggers: ["clawd"], minPostTriggerGap: 0.3)
|
|
||||||
#expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config) == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func matchAllowsGapAndExtractsCommand() {
|
|
||||||
let transcript = "hey clawd do thing"
|
|
||||||
let segments = makeSegments(
|
|
||||||
transcript: transcript,
|
|
||||||
words: [
|
|
||||||
("hey", 0.0, 0.1),
|
|
||||||
("clawd", 0.2, 0.1),
|
|
||||||
("do", 0.9, 0.1),
|
|
||||||
("thing", 1.1, 0.1),
|
|
||||||
])
|
|
||||||
let config = WakeWordGateConfig(triggers: ["clawd"], minPostTriggerGap: 0.3)
|
|
||||||
let match = WakeWordGate.match(transcript: transcript, segments: segments, config: config)
|
|
||||||
#expect(match?.command == "do thing")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func matchHandlesMultiWordTriggers() {
|
|
||||||
let transcript = "hey clawd do it"
|
|
||||||
let segments = makeSegments(
|
|
||||||
transcript: transcript,
|
|
||||||
words: [
|
|
||||||
("hey", 0.0, 0.1),
|
|
||||||
("clawd", 0.2, 0.1),
|
|
||||||
("do", 0.8, 0.1),
|
|
||||||
("it", 1.0, 0.1),
|
|
||||||
])
|
|
||||||
let config = WakeWordGateConfig(triggers: ["hey clawd"], minPostTriggerGap: 0.3)
|
|
||||||
let match = WakeWordGate.match(transcript: transcript, segments: segments, config: config)
|
|
||||||
#expect(match?.command == "do it")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makeSegments(
|
|
||||||
transcript: String,
|
|
||||||
words: [(String, TimeInterval, TimeInterval)])
|
|
||||||
-> [WakeWordSegment] {
|
|
||||||
var searchStart = transcript.startIndex
|
|
||||||
var output: [WakeWordSegment] = []
|
|
||||||
for (word, start, duration) in words {
|
|
||||||
let range = transcript.range(of: word, range: searchStart..<transcript.endIndex)
|
|
||||||
output.append(WakeWordSegment(text: word, start: start, duration: duration, range: range))
|
|
||||||
if let range { searchStart = range.upperBound }
|
|
||||||
}
|
|
||||||
return output
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
# swabble — macOS 26 speech hook daemon (Swift 6.2)
|
# swabble — macOS 26 speech hook daemon (Swift 6.2)
|
||||||
|
|
||||||
Goal: brabble-style always-on voice hook for macOS 26 using Apple Speech.framework (SpeechAnalyzer + SpeechTranscriber) instead of whisper.cpp. Local-only, wake word gated, dispatches a shell hook with the transcript. Shared wake-gate utilities live in `SwabbleKit` for reuse by other apps (iOS/macOS).
|
Goal: brabble-style always-on voice hook for macOS 26 using Apple Speech.framework (SpeechAnalyzer + SpeechTranscriber) instead of whisper.cpp. Local-only, wake word gated, dispatches a shell hook with the transcript.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
- macOS 26+, Swift 6.2, Speech.framework with on-device assets.
|
- macOS 26+, Swift 6.2, Speech.framework with on-device assets.
|
||||||
- Local only; no network calls during transcription.
|
- Local only; no network calls during transcription.
|
||||||
- Wake word gating (default "clawd" plus aliases) with bypass flag `--no-wake`.
|
- Wake word gating (default "clawd" plus aliases) with bypass flag `--no-wake`.
|
||||||
- `SwabbleKit` target (multi-platform) providing wake-word gating helpers that can use speech segment timing to require a post-trigger gap.
|
|
||||||
- Hook execution with cooldown, min_chars, timeout, prefix, env vars.
|
- Hook execution with cooldown, min_chars, timeout, prefix, env vars.
|
||||||
- Simple config at `~/.config/swabble/config.json` (JSON, Codable) — no TOML.
|
- Simple config at `~/.config/swabble/config.json` (JSON, Codable) — no TOML.
|
||||||
- CLI implemented with Commander (SwiftPM package `steipete/Commander`); core types are available via the SwiftPM library product `Swabble` for embedding.
|
- CLI implemented with Commander (SwiftPM package `steipete/Commander`); core types are available via the SwiftPM library product `Swabble` for embedding.
|
||||||
@@ -18,7 +17,7 @@ Goal: brabble-style always-on voice hook for macOS 26 using Apple Speech.framewo
|
|||||||
- **CLI layer (Commander)**: Root command `swabble` with subcommands `serve`, `transcribe`, `test-hook`, `mic list|set`, `doctor`, `health`, `tail-log`. Runtime flags from Commander (`-v/--verbose`, `--json-output`, `--log-level`). Custom `--config` path applies everywhere.
|
- **CLI layer (Commander)**: Root command `swabble` with subcommands `serve`, `transcribe`, `test-hook`, `mic list|set`, `doctor`, `health`, `tail-log`. Runtime flags from Commander (`-v/--verbose`, `--json-output`, `--log-level`). Custom `--config` path applies everywhere.
|
||||||
- **Config**: `SwabbleConfig` Codable. Fields: audio device name/index, wake (enabled/word/aliases/sensitivity placeholder), hook (command/args/prefix/cooldown/min_chars/timeout/env), logging (level, format), transcripts (enabled, max kept), speech (locale, enableEtiquetteReplacements flag). Stored JSON; default written by `setup`.
|
- **Config**: `SwabbleConfig` Codable. Fields: audio device name/index, wake (enabled/word/aliases/sensitivity placeholder), hook (command/args/prefix/cooldown/min_chars/timeout/env), logging (level, format), transcripts (enabled, max kept), speech (locale, enableEtiquetteReplacements flag). Stored JSON; default written by `setup`.
|
||||||
- **Audio + Speech pipeline**: `SpeechPipeline` wraps `AVAudioEngine` input → `SpeechAnalyzer` with `SpeechTranscriber` module. Emits partial/final transcripts via async stream. Requests `.audioTimeRange` when transcripts enabled. Handles Speech permission and asset download prompts ahead of capture.
|
- **Audio + Speech pipeline**: `SpeechPipeline` wraps `AVAudioEngine` input → `SpeechAnalyzer` with `SpeechTranscriber` module. Emits partial/final transcripts via async stream. Requests `.audioTimeRange` when transcripts enabled. Handles Speech permission and asset download prompts ahead of capture.
|
||||||
- **Wake gate**: CLI currently uses text-only keyword match; shared `SwabbleKit` gate can enforce a minimum pause between the wake word and the next token when speech segments are available. `--no-wake` disables gating.
|
- **Wake gate**: text-based keyword match against latest partial/final; strips wake term before hook dispatch. `--no-wake` disables.
|
||||||
- **Hook executor**: async `HookExecutor` spawns `Process` with configured args, prefix substitution `${hostname}`. Enforces cooldown + timeout; injects env `SWABBLE_TEXT`, `SWABBLE_PREFIX` plus user env map.
|
- **Hook executor**: async `HookExecutor` spawns `Process` with configured args, prefix substitution `${hostname}`. Enforces cooldown + timeout; injects env `SWABBLE_TEXT`, `SWABBLE_PREFIX` plus user env map.
|
||||||
- **Transcripts store**: in-memory ring buffer; optional persisted JSON lines under `~/Library/Application Support/swabble/transcripts.log`.
|
- **Transcripts store**: in-memory ring buffer; optional persisted JSON lines under `~/Library/Application Support/swabble/transcripts.log`.
|
||||||
- **Logging**: simple structured logger to stderr; respects log level.
|
- **Logging**: simple structured logger to stderr; respects log level.
|
||||||
@@ -26,7 +25,7 @@ Goal: brabble-style always-on voice hook for macOS 26 using Apple Speech.framewo
|
|||||||
## Out of scope (initial cut)
|
## Out of scope (initial cut)
|
||||||
- Model management (Speech handles assets).
|
- Model management (Speech handles assets).
|
||||||
- Launchd helper (planned follow-up).
|
- Launchd helper (planned follow-up).
|
||||||
- Advanced wake-word detector (segment-aware gate now lives in `SwabbleKit`; CLI still text-only until segment timing is plumbed through).
|
- Advanced wake-word detector (text match only for now).
|
||||||
|
|
||||||
## Open decisions
|
## Open decisions
|
||||||
- Whether to expose a UNIX control socket for `status`/`health` (currently planned as stdin/out direct calls).
|
- Whether to expose a UNIX control socket for `status`/`health` (currently planned as stdin/out direct calls).
|
||||||
|
|||||||
-11
@@ -6,17 +6,6 @@
|
|||||||
<title>Clawdis Updates</title>
|
<title>Clawdis Updates</title>
|
||||||
<link>https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml</link>
|
<link>https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml</link>
|
||||||
<description>Signed update feed for the Clawdis macOS companion app.</description>
|
<description>Signed update feed for the Clawdis macOS companion app.</description>
|
||||||
<item>
|
|
||||||
<title>Clawdis 2.0.0-beta2</title>
|
|
||||||
<sparkle:releaseNotesLink>https://github.com/steipete/clawdis/releases/tag/v2.0.0-beta2</sparkle:releaseNotesLink>
|
|
||||||
<pubDate>Sun, 21 Dec 2025 02:25:39 +0000</pubDate>
|
|
||||||
<enclosure url="https://github.com/steipete/clawdis/releases/download/v2.0.0-beta2/Clawdis-2.0.0-beta2.zip"
|
|
||||||
sparkle:edSignature="voRWLh2Cbg/i2KtUV6ci/MW3b7hK/u1ZPoiryKs+S36ua3xnc51R97JGwmIaToCfTHg2mgFWF7M6qppfe7YsAw=="
|
|
||||||
sparkle:version="2.0.0-beta2"
|
|
||||||
sparkle:shortVersionString="2.0.0-beta2"
|
|
||||||
length="67435891"
|
|
||||||
type="application/octet-stream" />
|
|
||||||
</item>
|
|
||||||
<item>
|
<item>
|
||||||
<title>Clawdis 2.0.0-beta1</title>
|
<title>Clawdis 2.0.0-beta1</title>
|
||||||
<sparkle:releaseNotesLink>https://github.com/steipete/clawdis/releases/tag/v2.0.0-beta1</sparkle:releaseNotesLink>
|
<sparkle:releaseNotesLink>https://github.com/steipete/clawdis/releases/tag/v2.0.0-beta1</sparkle:releaseNotesLink>
|
||||||
|
|||||||
@@ -38,21 +38,15 @@ android {
|
|||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
|
||||||
packaging {
|
packaging {
|
||||||
resources {
|
resources {
|
||||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lint {
|
|
||||||
disable += setOf("IconLauncherShape")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlin {
|
|
||||||
compilerOptions {
|
|
||||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
@@ -62,7 +56,7 @@ dependencies {
|
|||||||
|
|
||||||
implementation("androidx.core:core-ktx:1.17.0")
|
implementation("androidx.core:core-ktx:1.17.0")
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
||||||
implementation("androidx.activity:activity-compose:1.12.2")
|
implementation("androidx.activity:activity-compose:1.12.1")
|
||||||
|
|
||||||
implementation("androidx.compose.ui:ui")
|
implementation("androidx.compose.ui:ui")
|
||||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||||
|
|||||||
@@ -12,20 +12,12 @@
|
|||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.ACCESS_FINE_LOCATION"
|
android:name="android.permission.ACCESS_FINE_LOCATION"
|
||||||
android:maxSdkVersion="32" />
|
android:maxSdkVersion="32" />
|
||||||
<uses-permission
|
|
||||||
android:name="android.permission.ACCESS_COARSE_LOCATION"
|
|
||||||
android:maxSdkVersion="32" />
|
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-feature
|
|
||||||
android:name="android.hardware.camera"
|
|
||||||
android:required="false" />
|
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".NodeApp"
|
android:name=".NodeApp"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
val manualEnabled: StateFlow<Boolean> = runtime.manualEnabled
|
val manualEnabled: StateFlow<Boolean> = runtime.manualEnabled
|
||||||
val manualHost: StateFlow<String> = runtime.manualHost
|
val manualHost: StateFlow<String> = runtime.manualHost
|
||||||
val manualPort: StateFlow<Int> = runtime.manualPort
|
val manualPort: StateFlow<Int> = runtime.manualPort
|
||||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = runtime.canvasDebugStatusEnabled
|
|
||||||
|
|
||||||
val chatSessionKey: StateFlow<String> = runtime.chatSessionKey
|
val chatSessionKey: StateFlow<String> = runtime.chatSessionKey
|
||||||
val chatSessionId: StateFlow<String?> = runtime.chatSessionId
|
val chatSessionId: StateFlow<String?> = runtime.chatSessionId
|
||||||
@@ -79,10 +78,6 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
runtime.setManualPort(value)
|
runtime.setManualPort(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
|
||||||
runtime.setCanvasDebugStatusEnabled(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setWakeWords(words: List<String>) {
|
fun setWakeWords(words: List<String>) {
|
||||||
runtime.setWakeWords(words)
|
runtime.setWakeWords(words)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.content.pm.ServiceInfo
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.os.Build
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -84,6 +85,7 @@ class NodeForegroundService : Service() {
|
|||||||
override fun onBind(intent: Intent?) = null
|
override fun onBind(intent: Intent?) = null
|
||||||
|
|
||||||
private fun ensureChannel() {
|
private fun ensureChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
val mgr = getSystemService(NotificationManager::class.java)
|
val mgr = getSystemService(NotificationManager::class.java)
|
||||||
val channel =
|
val channel =
|
||||||
NotificationChannel(
|
NotificationChannel(
|
||||||
@@ -99,7 +101,12 @@ class NodeForegroundService : Service() {
|
|||||||
|
|
||||||
private fun buildNotification(title: String, text: String): Notification {
|
private fun buildNotification(title: String, text: String): Notification {
|
||||||
val stopIntent = Intent(this, NodeForegroundService::class.java).setAction(ACTION_STOP)
|
val stopIntent = Intent(this, NodeForegroundService::class.java).setAction(ACTION_STOP)
|
||||||
val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
val flags =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
} else {
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
}
|
||||||
val stopPending = PendingIntent.getService(this, 2, stopIntent, flags)
|
val stopPending = PendingIntent.getService(this, 2, stopIntent, flags)
|
||||||
|
|
||||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
@@ -119,6 +126,11 @@ class NodeForegroundService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun startForegroundWithTypes(notification: Notification, requiresMic: Boolean) {
|
private fun startForegroundWithTypes(notification: Notification, requiresMic: Boolean) {
|
||||||
|
if (Build.VERSION.SDK_INT < 29) {
|
||||||
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (didStartForeground && requiresMic == lastRequiresMic) {
|
if (didStartForeground && requiresMic == lastRequiresMic) {
|
||||||
updateNotification(notification)
|
updateNotification(notification)
|
||||||
return
|
return
|
||||||
@@ -150,7 +162,11 @@ class NodeForegroundService : Service() {
|
|||||||
|
|
||||||
fun start(context: Context) {
|
fun start(context: Context) {
|
||||||
val intent = Intent(context, NodeForegroundService::class.java)
|
val intent = Intent(context, NodeForegroundService::class.java)
|
||||||
context.startForegroundService(intent)
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
context.startForegroundService(intent)
|
||||||
|
} else {
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stop(context: Context) {
|
fun stop(context: Context) {
|
||||||
|
|||||||
@@ -109,8 +109,6 @@ class NodeRuntime(context: Context) {
|
|||||||
private val _isForeground = MutableStateFlow(true)
|
private val _isForeground = MutableStateFlow(true)
|
||||||
val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
|
val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
|
||||||
|
|
||||||
private var lastAutoA2uiUrl: String? = null
|
|
||||||
|
|
||||||
private val session =
|
private val session =
|
||||||
BridgeSession(
|
BridgeSession(
|
||||||
scope = scope,
|
scope = scope,
|
||||||
@@ -120,7 +118,6 @@ class NodeRuntime(context: Context) {
|
|||||||
_remoteAddress.value = remote
|
_remoteAddress.value = remote
|
||||||
_isConnected.value = true
|
_isConnected.value = true
|
||||||
scope.launch { refreshWakeWordsFromGateway() }
|
scope.launch { refreshWakeWordsFromGateway() }
|
||||||
maybeNavigateToA2uiOnConnect()
|
|
||||||
},
|
},
|
||||||
onDisconnected = { message -> handleSessionDisconnected(message) },
|
onDisconnected = { message -> handleSessionDisconnected(message) },
|
||||||
onEvent = { event, payloadJson ->
|
onEvent = { event, payloadJson ->
|
||||||
@@ -139,21 +136,6 @@ class NodeRuntime(context: Context) {
|
|||||||
_remoteAddress.value = null
|
_remoteAddress.value = null
|
||||||
_isConnected.value = false
|
_isConnected.value = false
|
||||||
chat.onDisconnected(message)
|
chat.onDisconnected(message)
|
||||||
showLocalCanvasOnDisconnect()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun maybeNavigateToA2uiOnConnect() {
|
|
||||||
val a2uiUrl = resolveA2uiHostUrl() ?: return
|
|
||||||
val current = canvas.currentUrl()?.trim().orEmpty()
|
|
||||||
if (current.isEmpty() || current == lastAutoA2uiUrl) {
|
|
||||||
lastAutoA2uiUrl = a2uiUrl
|
|
||||||
canvas.navigate(a2uiUrl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showLocalCanvasOnDisconnect() {
|
|
||||||
lastAutoA2uiUrl = null
|
|
||||||
canvas.navigate("")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val instanceId: StateFlow<String> = prefs.instanceId
|
val instanceId: StateFlow<String> = prefs.instanceId
|
||||||
@@ -166,7 +148,6 @@ class NodeRuntime(context: Context) {
|
|||||||
val manualHost: StateFlow<String> = prefs.manualHost
|
val manualHost: StateFlow<String> = prefs.manualHost
|
||||||
val manualPort: StateFlow<Int> = prefs.manualPort
|
val manualPort: StateFlow<Int> = prefs.manualPort
|
||||||
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
|
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
|
||||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
|
|
||||||
|
|
||||||
private var didAutoConnect = false
|
private var didAutoConnect = false
|
||||||
private var suppressWakeWordsSync = false
|
private var suppressWakeWordsSync = false
|
||||||
@@ -247,22 +228,6 @@ class NodeRuntime(context: Context) {
|
|||||||
connect(target)
|
connect(target)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
scope.launch {
|
|
||||||
combine(
|
|
||||||
canvasDebugStatusEnabled,
|
|
||||||
statusText,
|
|
||||||
serverName,
|
|
||||||
remoteAddress,
|
|
||||||
) { debugEnabled, status, server, remote ->
|
|
||||||
Quad(debugEnabled, status, server, remote)
|
|
||||||
}.distinctUntilChanged()
|
|
||||||
.collect { (debugEnabled, status, server, remote) ->
|
|
||||||
canvas.setDebugStatusEnabled(debugEnabled)
|
|
||||||
if (!debugEnabled) return@collect
|
|
||||||
canvas.setDebugStatus(status, server ?: remote)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setForeground(value: Boolean) {
|
fun setForeground(value: Boolean) {
|
||||||
@@ -293,10 +258,6 @@ class NodeRuntime(context: Context) {
|
|||||||
prefs.setManualPort(value)
|
prefs.setManualPort(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
|
||||||
prefs.setCanvasDebugStatusEnabled(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setWakeWords(words: List<String>) {
|
fun setWakeWords(words: List<String>) {
|
||||||
prefs.setWakeWords(words)
|
prefs.setWakeWords(words)
|
||||||
scheduleWakeWordsSyncIfNeeded()
|
scheduleWakeWordsSyncIfNeeded()
|
||||||
@@ -435,7 +396,8 @@ class NodeRuntime(context: Context) {
|
|||||||
val actionId = (userActionObj["id"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty {
|
val actionId = (userActionObj["id"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty {
|
||||||
java.util.UUID.randomUUID().toString()
|
java.util.UUID.randomUUID().toString()
|
||||||
}
|
}
|
||||||
val name = ClawdisCanvasA2UIAction.extractActionName(userActionObj) ?: return@launch
|
val name = (userActionObj["name"] as? JsonPrimitive)?.content?.trim().orEmpty()
|
||||||
|
if (name.isEmpty()) return@launch
|
||||||
|
|
||||||
val surfaceId =
|
val surfaceId =
|
||||||
(userActionObj["surfaceId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "main" }
|
(userActionObj["surfaceId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "main" }
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
package com.steipete.clawdis.node
|
package com.steipete.clawdis.node
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.core.content.edit
|
|
||||||
import androidx.security.crypto.EncryptedSharedPreferences
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
import androidx.security.crypto.MasterKey
|
import androidx.security.crypto.MasterKey
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -63,10 +62,6 @@ class SecurePrefs(context: Context) {
|
|||||||
MutableStateFlow(prefs.getString("bridge.lastDiscoveredStableId", "")!!)
|
MutableStateFlow(prefs.getString("bridge.lastDiscoveredStableId", "")!!)
|
||||||
val lastDiscoveredStableId: StateFlow<String> = _lastDiscoveredStableId
|
val lastDiscoveredStableId: StateFlow<String> = _lastDiscoveredStableId
|
||||||
|
|
||||||
private val _canvasDebugStatusEnabled =
|
|
||||||
MutableStateFlow(prefs.getBoolean("canvas.debugStatusEnabled", false))
|
|
||||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = _canvasDebugStatusEnabled
|
|
||||||
|
|
||||||
private val _wakeWords = MutableStateFlow(loadWakeWords())
|
private val _wakeWords = MutableStateFlow(loadWakeWords())
|
||||||
val wakeWords: StateFlow<List<String>> = _wakeWords
|
val wakeWords: StateFlow<List<String>> = _wakeWords
|
||||||
|
|
||||||
@@ -75,47 +70,42 @@ class SecurePrefs(context: Context) {
|
|||||||
|
|
||||||
fun setLastDiscoveredStableId(value: String) {
|
fun setLastDiscoveredStableId(value: String) {
|
||||||
val trimmed = value.trim()
|
val trimmed = value.trim()
|
||||||
prefs.edit { putString("bridge.lastDiscoveredStableId", trimmed) }
|
prefs.edit().putString("bridge.lastDiscoveredStableId", trimmed).apply()
|
||||||
_lastDiscoveredStableId.value = trimmed
|
_lastDiscoveredStableId.value = trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setDisplayName(value: String) {
|
fun setDisplayName(value: String) {
|
||||||
val trimmed = value.trim()
|
val trimmed = value.trim()
|
||||||
prefs.edit { putString(displayNameKey, trimmed) }
|
prefs.edit().putString(displayNameKey, trimmed).apply()
|
||||||
_displayName.value = trimmed
|
_displayName.value = trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setCameraEnabled(value: Boolean) {
|
fun setCameraEnabled(value: Boolean) {
|
||||||
prefs.edit { putBoolean("camera.enabled", value) }
|
prefs.edit().putBoolean("camera.enabled", value).apply()
|
||||||
_cameraEnabled.value = value
|
_cameraEnabled.value = value
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setPreventSleep(value: Boolean) {
|
fun setPreventSleep(value: Boolean) {
|
||||||
prefs.edit { putBoolean("screen.preventSleep", value) }
|
prefs.edit().putBoolean("screen.preventSleep", value).apply()
|
||||||
_preventSleep.value = value
|
_preventSleep.value = value
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setManualEnabled(value: Boolean) {
|
fun setManualEnabled(value: Boolean) {
|
||||||
prefs.edit { putBoolean("bridge.manual.enabled", value) }
|
prefs.edit().putBoolean("bridge.manual.enabled", value).apply()
|
||||||
_manualEnabled.value = value
|
_manualEnabled.value = value
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setManualHost(value: String) {
|
fun setManualHost(value: String) {
|
||||||
val trimmed = value.trim()
|
val trimmed = value.trim()
|
||||||
prefs.edit { putString("bridge.manual.host", trimmed) }
|
prefs.edit().putString("bridge.manual.host", trimmed).apply()
|
||||||
_manualHost.value = trimmed
|
_manualHost.value = trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setManualPort(value: Int) {
|
fun setManualPort(value: Int) {
|
||||||
prefs.edit { putInt("bridge.manual.port", value) }
|
prefs.edit().putInt("bridge.manual.port", value).apply()
|
||||||
_manualPort.value = value
|
_manualPort.value = value
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
|
||||||
prefs.edit { putBoolean("canvas.debugStatusEnabled", value) }
|
|
||||||
_canvasDebugStatusEnabled.value = value
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadBridgeToken(): String? {
|
fun loadBridgeToken(): String? {
|
||||||
val key = "bridge.token.${_instanceId.value}"
|
val key = "bridge.token.${_instanceId.value}"
|
||||||
return prefs.getString(key, null)
|
return prefs.getString(key, null)
|
||||||
@@ -123,14 +113,14 @@ class SecurePrefs(context: Context) {
|
|||||||
|
|
||||||
fun saveBridgeToken(token: String) {
|
fun saveBridgeToken(token: String) {
|
||||||
val key = "bridge.token.${_instanceId.value}"
|
val key = "bridge.token.${_instanceId.value}"
|
||||||
prefs.edit { putString(key, token.trim()) }
|
prefs.edit().putString(key, token.trim()).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadOrCreateInstanceId(): String {
|
private fun loadOrCreateInstanceId(): String {
|
||||||
val existing = prefs.getString("node.instanceId", null)?.trim()
|
val existing = prefs.getString("node.instanceId", null)?.trim()
|
||||||
if (!existing.isNullOrBlank()) return existing
|
if (!existing.isNullOrBlank()) return existing
|
||||||
val fresh = UUID.randomUUID().toString()
|
val fresh = UUID.randomUUID().toString()
|
||||||
prefs.edit { putString("node.instanceId", fresh) }
|
prefs.edit().putString("node.instanceId", fresh).apply()
|
||||||
return fresh
|
return fresh
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,7 +131,7 @@ class SecurePrefs(context: Context) {
|
|||||||
val candidate = DeviceNames.bestDefaultNodeName(context).trim()
|
val candidate = DeviceNames.bestDefaultNodeName(context).trim()
|
||||||
val resolved = candidate.ifEmpty { "Android Node" }
|
val resolved = candidate.ifEmpty { "Android Node" }
|
||||||
|
|
||||||
prefs.edit { putString(displayNameKey, resolved) }
|
prefs.edit().putString(displayNameKey, resolved).apply()
|
||||||
return resolved
|
return resolved
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,12 +139,12 @@ class SecurePrefs(context: Context) {
|
|||||||
val sanitized = WakeWords.sanitize(words, defaultWakeWords)
|
val sanitized = WakeWords.sanitize(words, defaultWakeWords)
|
||||||
val encoded =
|
val encoded =
|
||||||
JsonArray(sanitized.map { JsonPrimitive(it) }).toString()
|
JsonArray(sanitized.map { JsonPrimitive(it) }).toString()
|
||||||
prefs.edit { putString("voiceWake.triggerWords", encoded) }
|
prefs.edit().putString("voiceWake.triggerWords", encoded).apply()
|
||||||
_wakeWords.value = sanitized
|
_wakeWords.value = sanitized
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setVoiceWakeMode(mode: VoiceWakeMode) {
|
fun setVoiceWakeMode(mode: VoiceWakeMode) {
|
||||||
prefs.edit { putString(voiceWakeModeKey, mode.rawValue) }
|
prefs.edit().putString(voiceWakeModeKey, mode.rawValue).apply()
|
||||||
_voiceWakeMode.value = mode
|
_voiceWakeMode.value = mode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +154,7 @@ class SecurePrefs(context: Context) {
|
|||||||
|
|
||||||
// Default ON (foreground) when unset.
|
// Default ON (foreground) when unset.
|
||||||
if (raw.isNullOrBlank()) {
|
if (raw.isNullOrBlank()) {
|
||||||
prefs.edit { putString(voiceWakeModeKey, resolved.rawValue) }
|
prefs.edit().putString(voiceWakeModeKey, resolved.rawValue).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
return resolved
|
return resolved
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import android.net.DnsResolver
|
|||||||
import android.net.NetworkCapabilities
|
import android.net.NetworkCapabilities
|
||||||
import android.net.nsd.NsdManager
|
import android.net.nsd.NsdManager
|
||||||
import android.net.nsd.NsdServiceInfo
|
import android.net.nsd.NsdServiceInfo
|
||||||
|
import android.os.Build
|
||||||
import android.os.CancellationSignal
|
import android.os.CancellationSignal
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
@@ -43,7 +44,6 @@ import org.xbill.DNS.Type
|
|||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
import kotlin.coroutines.resumeWithException
|
import kotlin.coroutines.resumeWithException
|
||||||
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
class BridgeDiscovery(
|
class BridgeDiscovery(
|
||||||
context: Context,
|
context: Context,
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
@@ -181,6 +181,7 @@ class BridgeDiscovery(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun txt(info: NsdServiceInfo, key: String): String? {
|
private fun txt(info: NsdServiceInfo, key: String): String? {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) return null
|
||||||
val bytes = info.attributes[key] ?: return null
|
val bytes = info.attributes[key] ?: return null
|
||||||
return try {
|
return try {
|
||||||
String(bytes, Charsets.UTF_8).trim().ifEmpty { null }
|
String(bytes, Charsets.UTF_8).trim().ifEmpty { null }
|
||||||
@@ -400,7 +401,7 @@ class BridgeDiscovery(
|
|||||||
dns.rawQuery(
|
dns.rawQuery(
|
||||||
network,
|
network,
|
||||||
wireQuery,
|
wireQuery,
|
||||||
DnsResolver.FLAG_EMPTY,
|
0,
|
||||||
dnsExecutor,
|
dnsExecutor,
|
||||||
signal,
|
signal,
|
||||||
object : DnsResolver.Callback<ByteArray> {
|
object : DnsResolver.Callback<ByteArray> {
|
||||||
|
|||||||
+1
-4
@@ -2,7 +2,6 @@ package com.steipete.clawdis.node.node
|
|||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
@@ -19,7 +18,6 @@ import androidx.camera.video.VideoCapture
|
|||||||
import androidx.camera.video.VideoRecordEvent
|
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 com.steipete.clawdis.node.PermissionRequester
|
import com.steipete.clawdis.node.PermissionRequester
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
@@ -94,7 +92,7 @@ class CameraCaptureManager(private val context: Context) {
|
|||||||
(decoded.height.toDouble() * (maxWidth.toDouble() / decoded.width.toDouble()))
|
(decoded.height.toDouble() * (maxWidth.toDouble() / decoded.width.toDouble()))
|
||||||
.toInt()
|
.toInt()
|
||||||
.coerceAtLeast(1)
|
.coerceAtLeast(1)
|
||||||
decoded.scale(maxWidth, h)
|
Bitmap.createScaledBitmap(decoded, maxWidth, h, true)
|
||||||
} else {
|
} else {
|
||||||
decoded
|
decoded
|
||||||
}
|
}
|
||||||
@@ -110,7 +108,6 @@ class CameraCaptureManager(private val context: Context) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
|
||||||
suspend fun clip(paramsJson: String?): Payload =
|
suspend fun clip(paramsJson: String?): Payload =
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
ensureCameraPermission()
|
ensureCameraPermission()
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
package com.steipete.clawdis.node.node
|
package com.steipete.clawdis.node.node
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
|
import android.os.Build
|
||||||
import android.graphics.Canvas
|
import android.graphics.Canvas
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import androidx.core.graphics.createBitmap
|
|
||||||
import androidx.core.graphics.scale
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import org.json.JSONObject
|
|
||||||
import kotlinx.serialization.json.Json
|
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
|
||||||
@@ -26,9 +24,6 @@ class CanvasController {
|
|||||||
|
|
||||||
@Volatile private var webView: WebView? = null
|
@Volatile private var webView: WebView? = null
|
||||||
@Volatile private var url: String? = null
|
@Volatile private var url: String? = null
|
||||||
@Volatile private var debugStatusEnabled: Boolean = false
|
|
||||||
@Volatile private var debugStatusTitle: String? = null
|
|
||||||
@Volatile private var debugStatusSubtitle: String? = null
|
|
||||||
|
|
||||||
private val scaffoldAssetUrl = "file:///android_asset/CanvasScaffold/scaffold.html"
|
private val scaffoldAssetUrl = "file:///android_asset/CanvasScaffold/scaffold.html"
|
||||||
|
|
||||||
@@ -40,7 +35,6 @@ class CanvasController {
|
|||||||
fun attach(webView: WebView) {
|
fun attach(webView: WebView) {
|
||||||
this.webView = webView
|
this.webView = webView
|
||||||
reload()
|
reload()
|
||||||
applyDebugStatus()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun navigate(url: String) {
|
fun navigate(url: String) {
|
||||||
@@ -49,25 +43,6 @@ class CanvasController {
|
|||||||
reload()
|
reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun currentUrl(): String? = url
|
|
||||||
|
|
||||||
fun isDefaultCanvas(): Boolean = url == null
|
|
||||||
|
|
||||||
fun setDebugStatusEnabled(enabled: Boolean) {
|
|
||||||
debugStatusEnabled = enabled
|
|
||||||
applyDebugStatus()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setDebugStatus(title: String?, subtitle: String?) {
|
|
||||||
debugStatusTitle = title
|
|
||||||
debugStatusSubtitle = subtitle
|
|
||||||
applyDebugStatus()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onPageFinished() {
|
|
||||||
applyDebugStatus()
|
|
||||||
}
|
|
||||||
|
|
||||||
private inline fun withWebViewOnMain(crossinline block: (WebView) -> Unit) {
|
private inline fun withWebViewOnMain(crossinline block: (WebView) -> Unit) {
|
||||||
val wv = webView ?: return
|
val wv = webView ?: return
|
||||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||||
@@ -88,32 +63,6 @@ class CanvasController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun applyDebugStatus() {
|
|
||||||
val enabled = debugStatusEnabled
|
|
||||||
val title = debugStatusTitle
|
|
||||||
val subtitle = debugStatusSubtitle
|
|
||||||
withWebViewOnMain { wv ->
|
|
||||||
val titleJs = title?.let { JSONObject.quote(it) } ?: "null"
|
|
||||||
val subtitleJs = subtitle?.let { JSONObject.quote(it) } ?: "null"
|
|
||||||
val js = """
|
|
||||||
(() => {
|
|
||||||
try {
|
|
||||||
const api = globalThis.__clawdis;
|
|
||||||
if (!api) return;
|
|
||||||
if (typeof api.setDebugStatusEnabled === 'function') {
|
|
||||||
api.setDebugStatusEnabled(${if (enabled) "true" else "false"});
|
|
||||||
}
|
|
||||||
if (!${if (enabled) "true" else "false"}) return;
|
|
||||||
if (typeof api.setStatus === 'function') {
|
|
||||||
api.setStatus($titleJs, $subtitleJs);
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
})();
|
|
||||||
""".trimIndent()
|
|
||||||
wv.evaluateJavascript(js, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun eval(javaScript: String): String =
|
suspend fun eval(javaScript: String): String =
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
val wv = webView ?: throw IllegalStateException("no webview")
|
val wv = webView ?: throw IllegalStateException("no webview")
|
||||||
@@ -131,7 +80,7 @@ class CanvasController {
|
|||||||
val scaled =
|
val scaled =
|
||||||
if (maxWidth != null && maxWidth > 0 && bmp.width > maxWidth) {
|
if (maxWidth != null && maxWidth > 0 && bmp.width > maxWidth) {
|
||||||
val h = (bmp.height.toDouble() * (maxWidth.toDouble() / bmp.width.toDouble())).toInt().coerceAtLeast(1)
|
val h = (bmp.height.toDouble() * (maxWidth.toDouble() / bmp.width.toDouble())).toInt().coerceAtLeast(1)
|
||||||
bmp.scale(maxWidth, h)
|
Bitmap.createScaledBitmap(bmp, maxWidth, h, true)
|
||||||
} else {
|
} else {
|
||||||
bmp
|
bmp
|
||||||
}
|
}
|
||||||
@@ -148,7 +97,7 @@ class CanvasController {
|
|||||||
val scaled =
|
val scaled =
|
||||||
if (maxWidth != null && maxWidth > 0 && bmp.width > maxWidth) {
|
if (maxWidth != null && maxWidth > 0 && bmp.width > maxWidth) {
|
||||||
val h = (bmp.height.toDouble() * (maxWidth.toDouble() / bmp.width.toDouble())).toInt().coerceAtLeast(1)
|
val h = (bmp.height.toDouble() * (maxWidth.toDouble() / bmp.width.toDouble())).toInt().coerceAtLeast(1)
|
||||||
bmp.scale(maxWidth, h)
|
Bitmap.createScaledBitmap(bmp, maxWidth, h, true)
|
||||||
} else {
|
} else {
|
||||||
bmp
|
bmp
|
||||||
}
|
}
|
||||||
@@ -167,7 +116,7 @@ class CanvasController {
|
|||||||
suspendCancellableCoroutine { cont ->
|
suspendCancellableCoroutine { cont ->
|
||||||
val width = width.coerceAtLeast(1)
|
val width = width.coerceAtLeast(1)
|
||||||
val height = height.coerceAtLeast(1)
|
val height = height.coerceAtLeast(1)
|
||||||
val bitmap = createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||||
|
|
||||||
// WebView isn't supported by PixelCopy.request(...) directly; draw() is the most reliable
|
// WebView isn't supported by PixelCopy.request(...) directly; draw() is the most reliable
|
||||||
// cross-version snapshot for this lightweight "canvas" use-case.
|
// cross-version snapshot for this lightweight "canvas" use-case.
|
||||||
|
|||||||
+1
-18
@@ -1,24 +1,6 @@
|
|||||||
package com.steipete.clawdis.node.protocol
|
package com.steipete.clawdis.node.protocol
|
||||||
|
|
||||||
import kotlinx.serialization.json.JsonObject
|
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
|
||||||
|
|
||||||
object ClawdisCanvasA2UIAction {
|
object ClawdisCanvasA2UIAction {
|
||||||
fun extractActionName(userAction: JsonObject): String? {
|
|
||||||
val name =
|
|
||||||
(userAction["name"] as? JsonPrimitive)
|
|
||||||
?.content
|
|
||||||
?.trim()
|
|
||||||
.orEmpty()
|
|
||||||
if (name.isNotEmpty()) return name
|
|
||||||
val action =
|
|
||||||
(userAction["action"] as? JsonPrimitive)
|
|
||||||
?.content
|
|
||||||
?.trim()
|
|
||||||
.orEmpty()
|
|
||||||
return action.ifEmpty { null }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sanitizeTagValue(value: String): String {
|
fun sanitizeTagValue(value: String): String {
|
||||||
val trimmed = value.trim().ifEmpty { "-" }
|
val trimmed = value.trim().ifEmpty { "-" }
|
||||||
val normalized = trimmed.replace(" ", "_")
|
val normalized = trimmed.replace(" ", "_")
|
||||||
@@ -64,3 +46,4 @@ object ClawdisCanvasA2UIAction {
|
|||||||
return "window.dispatchEvent(new CustomEvent('clawdis:a2ui-action-status', { detail: { id: \"${idEscaped}\", ok: ${okLiteral}, error: \"${err}\" } }));"
|
return "window.dispatchEvent(new CustomEvent('clawdis:a2ui-action-status', { detail: { id: \"${idEscaped}\", ok: ${okLiteral}, error: \"${err}\" } }));"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,22 +187,15 @@ private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier)
|
|||||||
"onReceivedHttpError: ${errorResponse.statusCode} ${errorResponse.reasonPhrase} ${request.url}",
|
"onReceivedHttpError: ${errorResponse.statusCode} ${errorResponse.reasonPhrase} ${request.url}",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPageFinished(view: WebView, url: String?) {
|
|
||||||
viewModel.canvas.onPageFinished()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setBackgroundColor(Color.BLACK)
|
setBackgroundColor(Color.BLACK)
|
||||||
setLayerType(View.LAYER_TYPE_HARDWARE, null)
|
setLayerType(View.LAYER_TYPE_HARDWARE, null)
|
||||||
|
|
||||||
val a2uiBridge =
|
addJavascriptInterface(
|
||||||
CanvasA2UIActionBridge { payload ->
|
CanvasA2UIActionBridge { payload ->
|
||||||
viewModel.handleCanvasA2UIActionFromWebView(payload)
|
viewModel.handleCanvasA2UIActionFromWebView(payload)
|
||||||
}
|
},
|
||||||
addJavascriptInterface(a2uiBridge, CanvasA2UIActionBridge.interfaceName)
|
CanvasA2UIActionBridge.interfaceName,
|
||||||
addJavascriptInterface(
|
|
||||||
CanvasA2UIActionLegacyBridge(a2uiBridge),
|
|
||||||
CanvasA2UIActionLegacyBridge.interfaceName,
|
|
||||||
)
|
)
|
||||||
viewModel.canvas.attach(this)
|
viewModel.canvas.attach(this)
|
||||||
}
|
}
|
||||||
@@ -222,19 +215,3 @@ private class CanvasA2UIActionBridge(private val onMessage: (String) -> Unit) {
|
|||||||
const val interfaceName: String = "clawdisCanvasA2UIAction"
|
const val interfaceName: String = "clawdisCanvasA2UIAction"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class CanvasA2UIActionLegacyBridge(private val bridge: CanvasA2UIActionBridge) {
|
|
||||||
@JavascriptInterface
|
|
||||||
fun canvasAction(payload: String?) {
|
|
||||||
bridge.postMessage(payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
@JavascriptInterface
|
|
||||||
fun postMessage(payload: String?) {
|
|
||||||
bridge.postMessage(payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val interfaceName: String = "Android"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -64,7 +64,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
val manualEnabled by viewModel.manualEnabled.collectAsState()
|
val manualEnabled by viewModel.manualEnabled.collectAsState()
|
||||||
val manualHost by viewModel.manualHost.collectAsState()
|
val manualHost by viewModel.manualHost.collectAsState()
|
||||||
val manualPort by viewModel.manualPort.collectAsState()
|
val manualPort by viewModel.manualPort.collectAsState()
|
||||||
val canvasDebugStatusEnabled by viewModel.canvasDebugStatusEnabled.collectAsState()
|
|
||||||
val statusText by viewModel.statusText.collectAsState()
|
val statusText by viewModel.statusText.collectAsState()
|
||||||
val serverName by viewModel.serverName.collectAsState()
|
val serverName by viewModel.serverName.collectAsState()
|
||||||
val remoteAddress by viewModel.remoteAddress.collectAsState()
|
val remoteAddress by viewModel.remoteAddress.collectAsState()
|
||||||
@@ -395,23 +394,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
item { HorizontalDivider() }
|
|
||||||
|
|
||||||
// Debug
|
|
||||||
item { Text("Debug", style = MaterialTheme.typography.titleSmall) }
|
|
||||||
item {
|
|
||||||
ListItem(
|
|
||||||
headlineContent = { Text("Debug Canvas Status") },
|
|
||||||
supportingContent = { Text("Show status text in the canvas when debug is enabled.") },
|
|
||||||
trailingContent = {
|
|
||||||
Switch(
|
|
||||||
checked = canvasDebugStatusEnabled,
|
|
||||||
onCheckedChange = viewModel::setCanvasDebugStatusEnabled,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
item { Spacer(modifier = Modifier.height(20.dp)) }
|
item { Spacer(modifier = Modifier.height(20.dp)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -2,5 +2,5 @@
|
|||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@color/ic_launcher_background" />
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
|
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
|
|
||||||
+1
-1
@@ -2,5 +2,5 @@
|
|||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@color/ic_launcher_background" />
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
|
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
|
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
<resources>
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
<style name="Theme.ClawdisNode" parent="Theme.Material3.DayNight.NoActionBar">
|
<style name="Theme.ClawdisNode" parent="Theme.Material3.DayNight.NoActionBar">
|
||||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
<item name="android:windowLightStatusBar">false</item>
|
<item name="android:windowLightStatusBar" tools:targetApi="m">false</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<full-backup-content>
|
|
||||||
<include domain="file" path="." />
|
|
||||||
</full-backup-content>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<data-extraction-rules>
|
|
||||||
<cloud-backup>
|
|
||||||
<include domain="file" path="." />
|
|
||||||
</cloud-backup>
|
|
||||||
<device-transfer>
|
|
||||||
<include domain="file" path="." />
|
|
||||||
</device-transfer>
|
|
||||||
</data-extraction-rules>
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<network-security-config xmlns:tools="http://schemas.android.com/tools">
|
<network-security-config>
|
||||||
<!-- This app is primarily used on a trusted tailnet; allow cleartext for IP-based endpoints too. -->
|
<!-- This app is primarily used on a trusted tailnet; allow cleartext for IP-based endpoints too. -->
|
||||||
<base-config cleartextTrafficPermitted="true" tools:ignore="InsecureBaseConfiguration" />
|
<base-config cleartextTrafficPermitted="true" />
|
||||||
<!-- Allow HTTP for tailnet/local dev endpoints (e.g. canvas/background web). -->
|
<!-- Allow HTTP for tailnet/local dev endpoints (e.g. canvas/background web). -->
|
||||||
<domain-config cleartextTrafficPermitted="true">
|
<domain-config cleartextTrafficPermitted="true">
|
||||||
<domain includeSubdomains="true">clawdis.internal</domain>
|
<domain includeSubdomains="true">clawdis.internal</domain>
|
||||||
|
|||||||
+1
-15
@@ -1,24 +1,9 @@
|
|||||||
package com.steipete.clawdis.node.protocol
|
package com.steipete.clawdis.node.protocol
|
||||||
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.jsonObject
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class ClawdisCanvasA2UIActionTest {
|
class ClawdisCanvasA2UIActionTest {
|
||||||
@Test
|
|
||||||
fun extractActionNameAcceptsNameOrAction() {
|
|
||||||
val nameObj = Json.parseToJsonElement("{\"name\":\"Hello\"}").jsonObject
|
|
||||||
assertEquals("Hello", ClawdisCanvasA2UIAction.extractActionName(nameObj))
|
|
||||||
|
|
||||||
val actionObj = Json.parseToJsonElement("{\"action\":\"Wave\"}").jsonObject
|
|
||||||
assertEquals("Wave", ClawdisCanvasA2UIAction.extractActionName(actionObj))
|
|
||||||
|
|
||||||
val fallbackObj =
|
|
||||||
Json.parseToJsonElement("{\"name\":\" \",\"action\":\"Fallback\"}").jsonObject
|
|
||||||
assertEquals("Fallback", ClawdisCanvasA2UIAction.extractActionName(fallbackObj))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun formatAgentMessageMatchesSharedSpec() {
|
fun formatAgentMessageMatchesSharedSpec() {
|
||||||
val msg =
|
val msg =
|
||||||
@@ -47,3 +32,4 @@ class ClawdisCanvasA2UIActionTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,11 +22,6 @@
|
|||||||
<string>1.0</string>
|
<string>1.0</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1</string>
|
<string>1</string>
|
||||||
<key>NSAppTransportSecurity</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
|
||||||
<key>NSBonjourServices</key>
|
<key>NSBonjourServices</key>
|
||||||
<array>
|
<array>
|
||||||
<string>_clawdis-bridge._tcp</string>
|
<string>_clawdis-bridge._tcp</string>
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ final class NodeAppModel {
|
|||||||
private var voiceWakeSyncTask: Task<Void, Never>?
|
private var voiceWakeSyncTask: Task<Void, Never>?
|
||||||
@ObservationIgnored private var cameraHUDDismissTask: Task<Void, Never>?
|
@ObservationIgnored private var cameraHUDDismissTask: Task<Void, Never>?
|
||||||
let voiceWake = VoiceWakeManager()
|
let voiceWake = VoiceWakeManager()
|
||||||
private var lastAutoA2uiURL: String?
|
|
||||||
|
|
||||||
var bridgeSession: BridgeSession { self.bridge }
|
var bridgeSession: BridgeSession { self.bridge }
|
||||||
|
|
||||||
@@ -81,7 +80,7 @@ final class NodeAppModel {
|
|||||||
}()
|
}()
|
||||||
guard !userAction.isEmpty else { return }
|
guard !userAction.isEmpty else { return }
|
||||||
|
|
||||||
guard let name = ClawdisCanvasA2UIAction.extractActionName(userAction) else { return }
|
guard let name = userAction["name"] as? String, !name.isEmpty else { return }
|
||||||
let actionId: String = {
|
let actionId: String = {
|
||||||
let id = (userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let id = (userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
return id.isEmpty ? UUID().uuidString : id
|
return id.isEmpty ? UUID().uuidString : id
|
||||||
@@ -103,12 +102,14 @@ final class NodeAppModel {
|
|||||||
let contextJSON = ClawdisCanvasA2UIAction.compactJSON(userAction["context"])
|
let contextJSON = ClawdisCanvasA2UIAction.compactJSON(userAction["context"])
|
||||||
let sessionKey = "main"
|
let sessionKey = "main"
|
||||||
|
|
||||||
let messageContext = ClawdisCanvasA2UIAction.AgentMessageContext(
|
let message = ClawdisCanvasA2UIAction.formatAgentMessage(
|
||||||
actionName: name,
|
actionName: name,
|
||||||
session: .init(key: sessionKey, surfaceId: surfaceId),
|
sessionKey: sessionKey,
|
||||||
component: .init(id: sourceComponentId, host: host, instanceId: instanceId),
|
surfaceId: surfaceId,
|
||||||
|
sourceComponentId: sourceComponentId,
|
||||||
|
host: host,
|
||||||
|
instanceId: instanceId,
|
||||||
contextJSON: contextJSON)
|
contextJSON: contextJSON)
|
||||||
let message = ClawdisCanvasA2UIAction.formatAgentMessage(messageContext)
|
|
||||||
|
|
||||||
let ok: Bool
|
let ok: Bool
|
||||||
var errorText: String?
|
var errorText: String?
|
||||||
@@ -148,20 +149,6 @@ final class NodeAppModel {
|
|||||||
return base.appendingPathComponent("__clawdis__/a2ui/").absoluteString
|
return base.appendingPathComponent("__clawdis__/a2ui/").absoluteString
|
||||||
}
|
}
|
||||||
|
|
||||||
private func showA2UIOnConnectIfNeeded() async {
|
|
||||||
guard let a2uiUrl = await self.resolveA2UIHostURL() else { return }
|
|
||||||
let current = self.screen.urlString.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if current.isEmpty || current == self.lastAutoA2uiURL {
|
|
||||||
self.screen.navigate(to: a2uiUrl)
|
|
||||||
self.lastAutoA2uiURL = a2uiUrl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func showLocalCanvasOnDisconnect() {
|
|
||||||
self.lastAutoA2uiURL = nil
|
|
||||||
self.screen.showDefaultCanvas()
|
|
||||||
}
|
|
||||||
|
|
||||||
func setScenePhase(_ phase: ScenePhase) {
|
func setScenePhase(_ phase: ScenePhase) {
|
||||||
switch phase {
|
switch phase {
|
||||||
case .background:
|
case .background:
|
||||||
@@ -217,7 +204,6 @@ final class NodeAppModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
await self.startVoiceWakeSync()
|
await self.startVoiceWakeSync()
|
||||||
await self.showA2UIOnConnectIfNeeded()
|
|
||||||
},
|
},
|
||||||
onInvoke: { [weak self] req in
|
onInvoke: { [weak self] req in
|
||||||
guard let self else {
|
guard let self else {
|
||||||
@@ -230,9 +216,6 @@ final class NodeAppModel {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if Task.isCancelled { break }
|
if Task.isCancelled { break }
|
||||||
await MainActor.run {
|
|
||||||
self.showLocalCanvasOnDisconnect()
|
|
||||||
}
|
|
||||||
attempt += 1
|
attempt += 1
|
||||||
let sleepSeconds = min(6.0, 0.35 * pow(1.7, Double(attempt)))
|
let sleepSeconds = min(6.0, 0.35 * pow(1.7, Double(attempt)))
|
||||||
try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000))
|
try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000))
|
||||||
@@ -243,7 +226,6 @@ final class NodeAppModel {
|
|||||||
self.bridgeStatusText = "Bridge error: \(error.localizedDescription)"
|
self.bridgeStatusText = "Bridge error: \(error.localizedDescription)"
|
||||||
self.bridgeServerName = nil
|
self.bridgeServerName = nil
|
||||||
self.bridgeRemoteAddress = nil
|
self.bridgeRemoteAddress = nil
|
||||||
self.showLocalCanvasOnDisconnect()
|
|
||||||
}
|
}
|
||||||
let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(attempt)))
|
let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(attempt)))
|
||||||
try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000))
|
try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000))
|
||||||
@@ -255,7 +237,6 @@ final class NodeAppModel {
|
|||||||
self.bridgeServerName = nil
|
self.bridgeServerName = nil
|
||||||
self.bridgeRemoteAddress = nil
|
self.bridgeRemoteAddress = nil
|
||||||
self.connectedBridgeID = nil
|
self.connectedBridgeID = nil
|
||||||
self.showLocalCanvasOnDisconnect()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -270,7 +251,6 @@ final class NodeAppModel {
|
|||||||
self.bridgeServerName = nil
|
self.bridgeServerName = nil
|
||||||
self.bridgeRemoteAddress = nil
|
self.bridgeRemoteAddress = nil
|
||||||
self.connectedBridgeID = nil
|
self.connectedBridgeID = nil
|
||||||
self.showLocalCanvasOnDisconnect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func setGlobalWakeWords(_ words: [String]) async {
|
func setGlobalWakeWords(_ words: [String]) async {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ struct RootCanvas: View {
|
|||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false
|
@AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false
|
||||||
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
|
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
|
||||||
@AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false
|
|
||||||
@State private var presentedSheet: PresentedSheet?
|
@State private var presentedSheet: PresentedSheet?
|
||||||
@State private var voiceWakeToastText: String?
|
@State private var voiceWakeToastText: String?
|
||||||
@State private var toastDismissTask: Task<Void, Never>?
|
@State private var toastDismissTask: Task<Void, Never>?
|
||||||
@@ -57,11 +56,6 @@ struct RootCanvas: View {
|
|||||||
.onAppear { self.updateIdleTimer() }
|
.onAppear { self.updateIdleTimer() }
|
||||||
.onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() }
|
.onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() }
|
||||||
.onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() }
|
.onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() }
|
||||||
.onAppear { self.updateCanvasDebugStatus() }
|
|
||||||
.onChange(of: self.canvasDebugStatusEnabled) { _, _ in self.updateCanvasDebugStatus() }
|
|
||||||
.onChange(of: self.appModel.bridgeStatusText) { _, _ in self.updateCanvasDebugStatus() }
|
|
||||||
.onChange(of: self.appModel.bridgeServerName) { _, _ in self.updateCanvasDebugStatus() }
|
|
||||||
.onChange(of: self.appModel.bridgeRemoteAddress) { _, _ in self.updateCanvasDebugStatus() }
|
|
||||||
.onChange(of: self.voiceWake.lastTriggeredCommand) { _, newValue in
|
.onChange(of: self.voiceWake.lastTriggeredCommand) { _, newValue in
|
||||||
guard let newValue else { return }
|
guard let newValue else { return }
|
||||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
@@ -108,14 +102,6 @@ struct RootCanvas: View {
|
|||||||
private func updateIdleTimer() {
|
private func updateIdleTimer() {
|
||||||
UIApplication.shared.isIdleTimerDisabled = (self.scenePhase == .active && self.preventSleep)
|
UIApplication.shared.isIdleTimerDisabled = (self.scenePhase == .active && self.preventSleep)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateCanvasDebugStatus() {
|
|
||||||
self.appModel.screen.setDebugStatusEnabled(self.canvasDebugStatusEnabled)
|
|
||||||
guard self.canvasDebugStatusEnabled else { return }
|
|
||||||
let title = self.appModel.bridgeStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
let subtitle = self.appModel.bridgeServerName ?? self.appModel.bridgeRemoteAddress
|
|
||||||
self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct CanvasContent: View {
|
private struct CanvasContent: View {
|
||||||
|
|||||||
@@ -19,18 +19,12 @@ final class ScreenController {
|
|||||||
/// Callback invoked when the user clicks an A2UI action (e.g. button) inside the canvas web UI.
|
/// Callback invoked when the user clicks an A2UI action (e.g. button) inside the canvas web UI.
|
||||||
var onA2UIAction: (([String: Any]) -> Void)?
|
var onA2UIAction: (([String: Any]) -> Void)?
|
||||||
|
|
||||||
private var debugStatusEnabled: Bool = false
|
|
||||||
private var debugStatusTitle: String?
|
|
||||||
private var debugStatusSubtitle: String?
|
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let config = WKWebViewConfiguration()
|
let config = WKWebViewConfiguration()
|
||||||
config.websiteDataStore = .nonPersistent()
|
config.websiteDataStore = .nonPersistent()
|
||||||
let a2uiActionHandler = CanvasA2UIActionMessageHandler()
|
let a2uiActionHandler = CanvasA2UIActionMessageHandler()
|
||||||
let userContentController = WKUserContentController()
|
let userContentController = WKUserContentController()
|
||||||
for name in CanvasA2UIActionMessageHandler.handlerNames {
|
userContentController.add(a2uiActionHandler, name: CanvasA2UIActionMessageHandler.messageName)
|
||||||
userContentController.add(a2uiActionHandler, name: name)
|
|
||||||
}
|
|
||||||
config.userContentController = userContentController
|
config.userContentController = userContentController
|
||||||
self.navigationDelegate = ScreenNavigationDelegate()
|
self.navigationDelegate = ScreenNavigationDelegate()
|
||||||
self.a2uiActionHandler = a2uiActionHandler
|
self.a2uiActionHandler = a2uiActionHandler
|
||||||
@@ -84,39 +78,6 @@ final class ScreenController {
|
|||||||
self.reload()
|
self.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
func setDebugStatusEnabled(_ enabled: Bool) {
|
|
||||||
self.debugStatusEnabled = enabled
|
|
||||||
self.applyDebugStatusIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateDebugStatus(title: String?, subtitle: String?) {
|
|
||||||
self.debugStatusTitle = title
|
|
||||||
self.debugStatusSubtitle = subtitle
|
|
||||||
self.applyDebugStatusIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate func applyDebugStatusIfNeeded() {
|
|
||||||
let enabled = self.debugStatusEnabled
|
|
||||||
let title = self.debugStatusTitle
|
|
||||||
let subtitle = self.debugStatusSubtitle
|
|
||||||
let js = """
|
|
||||||
(() => {
|
|
||||||
try {
|
|
||||||
const api = globalThis.__clawdis;
|
|
||||||
if (!api) return;
|
|
||||||
if (typeof api.setDebugStatusEnabled === 'function') {
|
|
||||||
api.setDebugStatusEnabled(\(enabled ? "true" : "false"));
|
|
||||||
}
|
|
||||||
if (!\(enabled ? "true" : "false")) return;
|
|
||||||
if (typeof api.setStatus === 'function') {
|
|
||||||
api.setStatus(\(Self.jsValue(title)), \(Self.jsValue(subtitle)));
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
})()
|
|
||||||
"""
|
|
||||||
self.webView.evaluateJavaScript(js) { _, _ in }
|
|
||||||
}
|
|
||||||
|
|
||||||
func waitForA2UIReady(timeoutMs: Int) async -> Bool {
|
func waitForA2UIReady(timeoutMs: Int) async -> Bool {
|
||||||
let clock = ContinuousClock()
|
let clock = ContinuousClock()
|
||||||
let deadline = clock.now.advanced(by: .milliseconds(timeoutMs))
|
let deadline = clock.now.advanced(by: .milliseconds(timeoutMs))
|
||||||
@@ -249,17 +210,6 @@ final class ScreenController {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func jsValue(_ value: String?) -> String {
|
|
||||||
guard let value else { return "null" }
|
|
||||||
if let data = try? JSONSerialization.data(withJSONObject: [value]),
|
|
||||||
let encoded = String(data: data, encoding: .utf8),
|
|
||||||
encoded.count >= 2
|
|
||||||
{
|
|
||||||
return String(encoded.dropFirst().dropLast())
|
|
||||||
}
|
|
||||||
return "null"
|
|
||||||
}
|
|
||||||
|
|
||||||
func isLocalNetworkCanvasURL(_ url: URL) -> Bool {
|
func isLocalNetworkCanvasURL(_ url: URL) -> Bool {
|
||||||
guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else {
|
guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else {
|
||||||
return false
|
return false
|
||||||
@@ -366,11 +316,6 @@ private final class ScreenNavigationDelegate: NSObject, WKNavigationDelegate {
|
|||||||
self.controller?.errorText = error.localizedDescription
|
self.controller?.errorText = error.localizedDescription
|
||||||
}
|
}
|
||||||
|
|
||||||
func webView(_: WKWebView, didFinish _: WKNavigation?) {
|
|
||||||
self.controller?.errorText = nil
|
|
||||||
self.controller?.applyDebugStatusIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
func webView(_: WKWebView, didFail _: WKNavigation?, withError error: any Error) {
|
func webView(_: WKWebView, didFail _: WKNavigation?, withError error: any Error) {
|
||||||
self.controller?.errorText = error.localizedDescription
|
self.controller?.errorText = error.localizedDescription
|
||||||
}
|
}
|
||||||
@@ -378,8 +323,6 @@ private final class ScreenNavigationDelegate: NSObject, WKNavigationDelegate {
|
|||||||
|
|
||||||
private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
|
private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
|
||||||
static let messageName = "clawdisCanvasA2UIAction"
|
static let messageName = "clawdisCanvasA2UIAction"
|
||||||
static let legacyMessageNames = ["canvas", "a2ui", "userAction", "action"]
|
|
||||||
static let handlerNames = [messageName] + legacyMessageNames
|
|
||||||
|
|
||||||
weak var controller: ScreenController?
|
weak var controller: ScreenController?
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ struct SettingsTab: View {
|
|||||||
@AppStorage("bridge.manual.host") private var manualBridgeHost: String = ""
|
@AppStorage("bridge.manual.host") private var manualBridgeHost: String = ""
|
||||||
@AppStorage("bridge.manual.port") private var manualBridgePort: Int = 18790
|
@AppStorage("bridge.manual.port") private var manualBridgePort: Int = 18790
|
||||||
@AppStorage("bridge.discovery.debugLogs") private var discoveryDebugLogsEnabled: Bool = false
|
@AppStorage("bridge.discovery.debugLogs") private var discoveryDebugLogsEnabled: Bool = false
|
||||||
@AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false
|
|
||||||
@State private var connectStatus = ConnectStatusStore()
|
@State private var connectStatus = ConnectStatusStore()
|
||||||
@State private var connectingBridgeID: String?
|
@State private var connectingBridgeID: String?
|
||||||
@State private var localIPAddress: String?
|
@State private var localIPAddress: String?
|
||||||
@@ -143,8 +142,6 @@ struct SettingsTab: View {
|
|||||||
NavigationLink("Discovery Logs") {
|
NavigationLink("Discovery Logs") {
|
||||||
BridgeDiscoveryDebugLogView()
|
BridgeDiscoveryDebugLogView()
|
||||||
}
|
}
|
||||||
|
|
||||||
Toggle("Debug Canvas Status", isOn: self.$canvasDebugStatusEnabled)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import AVFAudio
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
import Speech
|
import Speech
|
||||||
import SwabbleKit
|
|
||||||
|
|
||||||
private func makeAudioTapEnqueueCallback(queue: AudioBufferQueue) -> @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void {
|
private func makeAudioTapEnqueueCallback(queue: AudioBufferQueue) -> @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void {
|
||||||
{ buffer, _ in
|
{ buffer, _ in
|
||||||
@@ -290,18 +289,15 @@ final class VoiceWakeManager: NSObject {
|
|||||||
private nonisolated func makeRecognitionResultHandler() -> @Sendable (SFSpeechRecognitionResult?, Error?) -> Void {
|
private nonisolated func makeRecognitionResultHandler() -> @Sendable (SFSpeechRecognitionResult?, Error?) -> Void {
|
||||||
{ [weak self] result, error in
|
{ [weak self] result, error in
|
||||||
let transcript = result?.bestTranscription.formattedString
|
let transcript = result?.bestTranscription.formattedString
|
||||||
let segments = result.flatMap { result in
|
|
||||||
transcript.map { WakeWordSpeechSegments.from(transcription: result.bestTranscription, transcript: $0) }
|
|
||||||
} ?? []
|
|
||||||
let errorText = error?.localizedDescription
|
let errorText = error?.localizedDescription
|
||||||
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self?.handleRecognitionCallback(transcript: transcript, segments: segments, errorText: errorText)
|
self?.handleRecognitionCallback(transcript: transcript, errorText: errorText)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleRecognitionCallback(transcript: String?, segments: [WakeWordSegment], errorText: String?) {
|
private func handleRecognitionCallback(transcript: String?, errorText: String?) {
|
||||||
if let errorText {
|
if let errorText {
|
||||||
self.statusText = "Recognizer error: \(errorText)"
|
self.statusText = "Recognizer error: \(errorText)"
|
||||||
self.isListening = false
|
self.isListening = false
|
||||||
@@ -317,7 +313,7 @@ final class VoiceWakeManager: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard let transcript else { return }
|
guard let transcript else { return }
|
||||||
guard let cmd = self.extractCommand(from: transcript, segments: segments) else { return }
|
guard let cmd = self.extractCommand(from: transcript) else { return }
|
||||||
|
|
||||||
if cmd == self.lastDispatched { return }
|
if cmd == self.lastDispatched { return }
|
||||||
self.lastDispatched = cmd
|
self.lastDispatched = cmd
|
||||||
@@ -338,18 +334,30 @@ final class VoiceWakeManager: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func extractCommand(from transcript: String, segments: [WakeWordSegment]) -> String? {
|
private func extractCommand(from transcript: String) -> String? {
|
||||||
Self.extractCommand(from: transcript, segments: segments, triggers: self.activeTriggerWords)
|
Self.extractCommand(from: transcript, triggers: self.activeTriggerWords)
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated static func extractCommand(
|
nonisolated static func extractCommand(from transcript: String, triggers: [String]) -> String? {
|
||||||
from transcript: String,
|
var bestRange: Range<String.Index>?
|
||||||
segments: [WakeWordSegment],
|
for trigger in triggers {
|
||||||
triggers: [String],
|
let token = trigger.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
minPostTriggerGap: TimeInterval = 0.45) -> String?
|
guard !token.isEmpty else { continue }
|
||||||
{
|
guard let range = transcript.range(of: token, options: [.caseInsensitive, .backwards]) else { continue }
|
||||||
let config = WakeWordGateConfig(triggers: triggers, minPostTriggerGap: minPostTriggerGap)
|
if let currentBest = bestRange {
|
||||||
return WakeWordGate.match(transcript: transcript, segments: segments, config: config)?.command
|
if range.lowerBound > currentBest.lowerBound {
|
||||||
|
bestRange = range
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
bestRange = range
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let bestRange else { return nil }
|
||||||
|
let after = transcript[bestRange.upperBound...]
|
||||||
|
let trimmed = after.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
return String(trimmed)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func configureAudioSession() throws {
|
private static func configureAudioSession() throws {
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
Sources/Bridge/BridgeClient.swift
|
|
||||||
Sources/Bridge/BridgeConnectionController.swift
|
|
||||||
Sources/Bridge/BridgeDiscoveryDebugLogView.swift
|
|
||||||
Sources/Bridge/BridgeDiscoveryModel.swift
|
|
||||||
Sources/Bridge/BridgeEndpointID.swift
|
|
||||||
Sources/Bridge/BridgeSession.swift
|
|
||||||
Sources/Bridge/BridgeSettingsStore.swift
|
|
||||||
Sources/Bridge/KeychainStore.swift
|
|
||||||
Sources/Camera/CameraController.swift
|
|
||||||
Sources/Chat/ChatSheet.swift
|
|
||||||
Sources/Chat/IOSBridgeChatTransport.swift
|
|
||||||
Sources/ClawdisApp.swift
|
|
||||||
Sources/Model/NodeAppModel.swift
|
|
||||||
Sources/RootCanvas.swift
|
|
||||||
Sources/RootTabs.swift
|
|
||||||
Sources/Screen/ScreenController.swift
|
|
||||||
Sources/Screen/ScreenRecordService.swift
|
|
||||||
Sources/Screen/ScreenTab.swift
|
|
||||||
Sources/Screen/ScreenWebView.swift
|
|
||||||
Sources/Settings/SettingsNetworkingHelpers.swift
|
|
||||||
Sources/Settings/SettingsTab.swift
|
|
||||||
Sources/Settings/VoiceWakeWordsSettingsView.swift
|
|
||||||
Sources/Status/StatusPill.swift
|
|
||||||
Sources/Status/VoiceWakeToast.swift
|
|
||||||
Sources/Voice/VoiceTab.swift
|
|
||||||
Sources/Voice/VoiceWakeManager.swift
|
|
||||||
Sources/Voice/VoiceWakePreferences.swift
|
|
||||||
../shared/ClawdisKit/Sources/ClawdisChatUI/ChatComposer.swift
|
|
||||||
../shared/ClawdisKit/Sources/ClawdisChatUI/ChatMarkdownSplitter.swift
|
|
||||||
../shared/ClawdisKit/Sources/ClawdisChatUI/ChatMessageViews.swift
|
|
||||||
../shared/ClawdisKit/Sources/ClawdisChatUI/ChatModels.swift
|
|
||||||
../shared/ClawdisKit/Sources/ClawdisChatUI/ChatPayloadDecoding.swift
|
|
||||||
../shared/ClawdisKit/Sources/ClawdisChatUI/ChatSessions.swift
|
|
||||||
../shared/ClawdisKit/Sources/ClawdisChatUI/ChatSheets.swift
|
|
||||||
../shared/ClawdisKit/Sources/ClawdisChatUI/ChatTheme.swift
|
|
||||||
../shared/ClawdisKit/Sources/ClawdisChatUI/ChatTransport.swift
|
|
||||||
../shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift
|
|
||||||
../shared/ClawdisKit/Sources/ClawdisChatUI/ChatViewModel.swift
|
|
||||||
../shared/ClawdisKit/Sources/ClawdisKit/AnyCodable.swift
|
|
||||||
../shared/ClawdisKit/Sources/ClawdisKit/BonjourEscapes.swift
|
|
||||||
../shared/ClawdisKit/Sources/ClawdisKit/BonjourTypes.swift
|
|
||||||
../shared/ClawdisKit/Sources/ClawdisKit/BridgeFrames.swift
|
|
||||||
../shared/ClawdisKit/Sources/ClawdisKit/CameraCommands.swift
|
|
||||||
../shared/ClawdisKit/Sources/ClawdisKit/CanvasA2UIAction.swift
|
|
||||||
../shared/ClawdisKit/Sources/ClawdisKit/CanvasA2UICommands.swift
|
|
||||||
../shared/ClawdisKit/Sources/ClawdisKit/CanvasA2UIJSONL.swift
|
|
||||||
../shared/ClawdisKit/Sources/ClawdisKit/CanvasCommandParams.swift
|
|
||||||
../shared/ClawdisKit/Sources/ClawdisKit/CanvasCommands.swift
|
|
||||||
../shared/ClawdisKit/Sources/ClawdisKit/Capabilities.swift
|
|
||||||
../shared/ClawdisKit/Sources/ClawdisKit/ClawdisKitResources.swift
|
|
||||||
../shared/ClawdisKit/Sources/ClawdisKit/DeepLinks.swift
|
|
||||||
../shared/ClawdisKit/Sources/ClawdisKit/JPEGTranscoder.swift
|
|
||||||
../shared/ClawdisKit/Sources/ClawdisKit/NodeError.swift
|
|
||||||
../shared/ClawdisKit/Sources/ClawdisKit/ScreenCommands.swift
|
|
||||||
../shared/ClawdisKit/Sources/ClawdisKit/StoragePaths.swift
|
|
||||||
../shared/ClawdisKit/Sources/ClawdisKit/SystemCommands.swift
|
|
||||||
../../Swabble/Sources/SwabbleKit/WakeWordGate.swift
|
|
||||||
@@ -1,90 +1,33 @@
|
|||||||
import Foundation
|
|
||||||
import Testing
|
import Testing
|
||||||
import SwabbleKit
|
|
||||||
@testable import Clawdis
|
@testable import Clawdis
|
||||||
|
|
||||||
@Suite struct VoiceWakeManagerExtractCommandTests {
|
@Suite struct VoiceWakeManagerExtractCommandTests {
|
||||||
@Test func extractCommandReturnsNilWhenNoTriggerFound() {
|
@Test func extractCommandReturnsNilWhenNoTriggerFound() {
|
||||||
let transcript = "hello world"
|
#expect(VoiceWakeManager.extractCommand(from: "hello world", triggers: ["clawd"]) == nil)
|
||||||
let segments = makeSegments(
|
|
||||||
transcript: transcript,
|
|
||||||
words: [("hello", 0.0, 0.1), ("world", 0.2, 0.1)])
|
|
||||||
#expect(VoiceWakeManager.extractCommand(from: transcript, segments: segments, triggers: ["clawd"]) == nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func extractCommandTrimsTokensAndResult() {
|
@Test func extractCommandTrimsTokensAndResult() {
|
||||||
let transcript = "hey clawd do thing"
|
let cmd = VoiceWakeManager.extractCommand(from: "hey clawd do thing ", triggers: [" clawd "])
|
||||||
let segments = makeSegments(
|
|
||||||
transcript: transcript,
|
|
||||||
words: [
|
|
||||||
("hey", 0.0, 0.1),
|
|
||||||
("clawd", 0.2, 0.1),
|
|
||||||
("do", 0.9, 0.1),
|
|
||||||
("thing", 1.1, 0.1),
|
|
||||||
])
|
|
||||||
let cmd = VoiceWakeManager.extractCommand(
|
|
||||||
from: transcript,
|
|
||||||
segments: segments,
|
|
||||||
triggers: [" clawd "],
|
|
||||||
minPostTriggerGap: 0.3)
|
|
||||||
#expect(cmd == "do thing")
|
#expect(cmd == "do thing")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func extractCommandReturnsNilWhenGapTooShort() {
|
@Test func extractCommandPicksLatestTriggerOccurrence() {
|
||||||
let transcript = "hey clawd do thing"
|
let transcript = "clawd first\nthen something\nclaude second"
|
||||||
let segments = makeSegments(
|
let cmd = VoiceWakeManager.extractCommand(from: transcript, triggers: ["clawd", "claude"])
|
||||||
transcript: transcript,
|
#expect(cmd == "second")
|
||||||
words: [
|
}
|
||||||
("hey", 0.0, 0.1),
|
|
||||||
("clawd", 0.2, 0.1),
|
@Test func extractCommandIsCaseInsensitive() {
|
||||||
("do", 0.35, 0.1),
|
let cmd = VoiceWakeManager.extractCommand(from: "HELLO CLAWD run it", triggers: ["clawd"])
|
||||||
("thing", 0.5, 0.1),
|
#expect(cmd == "run it")
|
||||||
])
|
|
||||||
let cmd = VoiceWakeManager.extractCommand(
|
|
||||||
from: transcript,
|
|
||||||
segments: segments,
|
|
||||||
triggers: ["clawd"],
|
|
||||||
minPostTriggerGap: 0.3)
|
|
||||||
#expect(cmd == nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func extractCommandReturnsNilWhenNothingAfterTrigger() {
|
@Test func extractCommandReturnsNilWhenNothingAfterTrigger() {
|
||||||
let transcript = "hey clawd"
|
#expect(VoiceWakeManager.extractCommand(from: "hey clawd \n", triggers: ["clawd"]) == nil)
|
||||||
let segments = makeSegments(
|
|
||||||
transcript: transcript,
|
|
||||||
words: [("hey", 0.0, 0.1), ("clawd", 0.2, 0.1)])
|
|
||||||
#expect(VoiceWakeManager.extractCommand(from: transcript, segments: segments, triggers: ["clawd"]) == nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func extractCommandIgnoresEmptyTriggers() {
|
@Test func extractCommandIgnoresEmptyTriggers() {
|
||||||
let transcript = "hey clawd do thing"
|
let cmd = VoiceWakeManager.extractCommand(from: "hey clawd do thing", triggers: ["", " ", "clawd"])
|
||||||
let segments = makeSegments(
|
|
||||||
transcript: transcript,
|
|
||||||
words: [
|
|
||||||
("hey", 0.0, 0.1),
|
|
||||||
("clawd", 0.2, 0.1),
|
|
||||||
("do", 0.9, 0.1),
|
|
||||||
("thing", 1.1, 0.1),
|
|
||||||
])
|
|
||||||
let cmd = VoiceWakeManager.extractCommand(
|
|
||||||
from: transcript,
|
|
||||||
segments: segments,
|
|
||||||
triggers: ["", " ", "clawd"],
|
|
||||||
minPostTriggerGap: 0.3)
|
|
||||||
#expect(cmd == "do thing")
|
#expect(cmd == "do thing")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeSegments(
|
|
||||||
transcript: String,
|
|
||||||
words: [(String, TimeInterval, TimeInterval)])
|
|
||||||
-> [WakeWordSegment] {
|
|
||||||
var searchStart = transcript.startIndex
|
|
||||||
var output: [WakeWordSegment] = []
|
|
||||||
for (word, start, duration) in words {
|
|
||||||
let range = transcript.range(of: word, range: searchStart..<transcript.endIndex)
|
|
||||||
output.append(WakeWordSegment(text: word, start: start, duration: duration, range: range))
|
|
||||||
if let range { searchStart = range.upperBound }
|
|
||||||
}
|
|
||||||
return output
|
|
||||||
}
|
|
||||||
+3
-17
@@ -8,8 +8,6 @@ options:
|
|||||||
packages:
|
packages:
|
||||||
ClawdisKit:
|
ClawdisKit:
|
||||||
path: ../shared/ClawdisKit
|
path: ../shared/ClawdisKit
|
||||||
Swabble:
|
|
||||||
path: ../../Swabble
|
|
||||||
|
|
||||||
schemes:
|
schemes:
|
||||||
Clawdis:
|
Clawdis:
|
||||||
@@ -31,35 +29,27 @@ targets:
|
|||||||
- package: ClawdisKit
|
- package: ClawdisKit
|
||||||
- package: ClawdisKit
|
- package: ClawdisKit
|
||||||
product: ClawdisChatUI
|
product: ClawdisChatUI
|
||||||
- package: Swabble
|
|
||||||
product: SwabbleKit
|
|
||||||
- sdk: AppIntents.framework
|
|
||||||
preBuildScripts:
|
preBuildScripts:
|
||||||
- name: SwiftFormat (lint)
|
- name: SwiftFormat (lint)
|
||||||
basedOnDependencyAnalysis: false
|
basedOnDependencyAnalysis: false
|
||||||
inputFileLists:
|
|
||||||
- $(SRCROOT)/SwiftSources.input.xcfilelist
|
|
||||||
script: |
|
script: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
|
|
||||||
if ! command -v swiftformat >/dev/null 2>&1; then
|
if ! command -v swiftformat >/dev/null 2>&1; then
|
||||||
echo "error: swiftformat not found (brew install swiftformat)" >&2
|
echo "error: swiftformat not found (brew install swiftformat)" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
swiftformat --lint --config "$SRCROOT/../../.swiftformat" \
|
swiftformat --lint --config "$SRCROOT/../../.swiftformat" \
|
||||||
--filelist "$SRCROOT/SwiftSources.input.xcfilelist"
|
"$SRCROOT/Sources" \
|
||||||
|
"$SRCROOT/../shared/ClawdisKit/Sources"
|
||||||
- name: SwiftLint
|
- name: SwiftLint
|
||||||
basedOnDependencyAnalysis: false
|
basedOnDependencyAnalysis: false
|
||||||
inputFileLists:
|
|
||||||
- $(SRCROOT)/SwiftSources.input.xcfilelist
|
|
||||||
script: |
|
script: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
|
|
||||||
if ! command -v swiftlint >/dev/null 2>&1; then
|
if ! command -v swiftlint >/dev/null 2>&1; then
|
||||||
echo "error: swiftlint not found (brew install swiftlint)" >&2
|
echo "error: swiftlint not found (brew install swiftlint)" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
swiftlint lint --config "$SRCROOT/.swiftlint.yml" --use-script-input-file-lists
|
swiftlint lint --config "$SRCROOT/.swiftlint.yml"
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: com.steipete.clawdis.ios
|
PRODUCT_BUNDLE_IDENTIFIER: com.steipete.clawdis.ios
|
||||||
@@ -75,8 +65,6 @@ targets:
|
|||||||
UIBackgroundModes:
|
UIBackgroundModes:
|
||||||
- audio
|
- audio
|
||||||
NSLocalNetworkUsageDescription: Clawdis discovers and connects to your Clawdis bridge on the local network.
|
NSLocalNetworkUsageDescription: Clawdis discovers and connects to your Clawdis bridge on the local network.
|
||||||
NSAppTransportSecurity:
|
|
||||||
NSAllowsArbitraryLoadsInWebContent: true
|
|
||||||
NSBonjourServices:
|
NSBonjourServices:
|
||||||
- _clawdis-bridge._tcp
|
- _clawdis-bridge._tcp
|
||||||
NSCameraUsageDescription: Clawdis can capture photos or short video clips when requested via the bridge.
|
NSCameraUsageDescription: Clawdis can capture photos or short video clips when requested via the bridge.
|
||||||
@@ -90,8 +78,6 @@ targets:
|
|||||||
- path: Tests
|
- path: Tests
|
||||||
dependencies:
|
dependencies:
|
||||||
- target: Clawdis
|
- target: Clawdis
|
||||||
- package: Swabble
|
|
||||||
product: SwabbleKit
|
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: com.steipete.clawdis.ios.tests
|
PRODUCT_BUNDLE_IDENTIFIER: com.steipete.clawdis.ios.tests
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ let package = Package(
|
|||||||
.package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.1.0"),
|
.package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.1.0"),
|
||||||
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.8.1"),
|
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.8.1"),
|
||||||
.package(path: "../shared/ClawdisKit"),
|
.package(path: "../shared/ClawdisKit"),
|
||||||
.package(path: "../../Swabble"),
|
|
||||||
.package(path: "../../Peekaboo/Core/PeekabooCore"),
|
.package(path: "../../Peekaboo/Core/PeekabooCore"),
|
||||||
.package(path: "../../Peekaboo/Core/PeekabooAutomationKit"),
|
.package(path: "../../Peekaboo/Core/PeekabooAutomationKit"),
|
||||||
],
|
],
|
||||||
@@ -42,7 +41,6 @@ let package = Package(
|
|||||||
"ClawdisProtocol",
|
"ClawdisProtocol",
|
||||||
.product(name: "ClawdisKit", package: "ClawdisKit"),
|
.product(name: "ClawdisKit", package: "ClawdisKit"),
|
||||||
.product(name: "ClawdisChatUI", package: "ClawdisKit"),
|
.product(name: "ClawdisChatUI", package: "ClawdisKit"),
|
||||||
.product(name: "SwabbleKit", package: "swabble"),
|
|
||||||
.product(name: "MenuBarExtraAccess", package: "MenuBarExtraAccess"),
|
.product(name: "MenuBarExtraAccess", package: "MenuBarExtraAccess"),
|
||||||
.product(name: "Subprocess", package: "swift-subprocess"),
|
.product(name: "Subprocess", package: "swift-subprocess"),
|
||||||
.product(name: "Sparkle", package: "Sparkle"),
|
.product(name: "Sparkle", package: "Sparkle"),
|
||||||
@@ -58,12 +56,7 @@ let package = Package(
|
|||||||
]),
|
]),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "ClawdisIPCTests",
|
name: "ClawdisIPCTests",
|
||||||
dependencies: [
|
dependencies: ["ClawdisIPC", "Clawdis", "ClawdisProtocol"],
|
||||||
"ClawdisIPC",
|
|
||||||
"Clawdis",
|
|
||||||
"ClawdisProtocol",
|
|
||||||
.product(name: "SwabbleKit", package: "swabble"),
|
|
||||||
],
|
|
||||||
swiftSettings: [
|
swiftSettings: [
|
||||||
.enableUpcomingFeature("StrictConcurrency"),
|
.enableUpcomingFeature("StrictConcurrency"),
|
||||||
.enableExperimentalFeature("SwiftTesting"),
|
.enableExperimentalFeature("SwiftTesting"),
|
||||||
|
|||||||
@@ -92,12 +92,10 @@ struct AboutSettings: View {
|
|||||||
guard let updater, !self.didLoadUpdaterState else { return }
|
guard let updater, !self.didLoadUpdaterState else { return }
|
||||||
// Keep Sparkle’s auto-check setting in sync with the persisted toggle.
|
// Keep Sparkle’s auto-check setting in sync with the persisted toggle.
|
||||||
updater.automaticallyChecksForUpdates = self.autoCheckEnabled
|
updater.automaticallyChecksForUpdates = self.autoCheckEnabled
|
||||||
updater.automaticallyDownloadsUpdates = self.autoCheckEnabled
|
|
||||||
self.didLoadUpdaterState = true
|
self.didLoadUpdaterState = true
|
||||||
}
|
}
|
||||||
.onChange(of: self.autoCheckEnabled) { _, newValue in
|
.onChange(of: self.autoCheckEnabled) { _, newValue in
|
||||||
self.updater?.automaticallyChecksForUpdates = newValue
|
self.updater?.automaticallyChecksForUpdates = newValue
|
||||||
self.updater?.automaticallyDownloadsUpdates = newValue
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct AgentIdentity: Codable, Equatable {
|
||||||
|
var name: String
|
||||||
|
var theme: String
|
||||||
|
var emoji: String
|
||||||
|
|
||||||
|
var isEmpty: Bool {
|
||||||
|
self.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
|
||||||
|
self.theme.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
|
||||||
|
self.emoji.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AgentIdentityEmoji {
|
||||||
|
static func suggest(theme: String) -> String {
|
||||||
|
let normalized = theme.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
if normalized.isEmpty { return "🦞" }
|
||||||
|
|
||||||
|
let table: [(needle: String, emoji: String)] = [
|
||||||
|
("lobster", "🦞"),
|
||||||
|
("sloth", "🦥"),
|
||||||
|
("octopus", "🐙"),
|
||||||
|
("crab", "🦀"),
|
||||||
|
("shark", "🦈"),
|
||||||
|
("cat", "🐈"),
|
||||||
|
("dog", "🐕"),
|
||||||
|
("owl", "🦉"),
|
||||||
|
("fox", "🦊"),
|
||||||
|
("otter", "🦦"),
|
||||||
|
("raccoon", "🦝"),
|
||||||
|
("badger", "🦡"),
|
||||||
|
("hedgehog", "🦔"),
|
||||||
|
("koala", "🐨"),
|
||||||
|
("penguin", "🐧"),
|
||||||
|
("frog", "🐸"),
|
||||||
|
("bear", "🐻"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for entry in table where normalized.contains(entry.needle) {
|
||||||
|
return entry.emoji
|
||||||
|
}
|
||||||
|
return "🦞"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,18 +9,8 @@ enum AgentWorkspace {
|
|||||||
static let userFilename = "USER.md"
|
static let userFilename = "USER.md"
|
||||||
static let bootstrapFilename = "BOOTSTRAP.md"
|
static let bootstrapFilename = "BOOTSTRAP.md"
|
||||||
private static let templateDirname = "templates"
|
private static let templateDirname = "templates"
|
||||||
private static let ignoredEntries: Set<String> = [".DS_Store", ".git", ".gitignore"]
|
static let identityStartMarker = "<!-- clawdis:identity:start -->"
|
||||||
private static let templateEntries: Set<String> = [
|
static let identityEndMarker = "<!-- clawdis:identity:end -->"
|
||||||
AgentWorkspace.agentsFilename,
|
|
||||||
AgentWorkspace.soulFilename,
|
|
||||||
AgentWorkspace.identityFilename,
|
|
||||||
AgentWorkspace.userFilename,
|
|
||||||
AgentWorkspace.bootstrapFilename,
|
|
||||||
]
|
|
||||||
enum BootstrapSafety: Equatable {
|
|
||||||
case safe
|
|
||||||
case unsafe(reason: String)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func displayPath(for url: URL) -> String {
|
static func displayPath(for url: URL) -> String {
|
||||||
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
||||||
@@ -43,53 +33,7 @@ enum AgentWorkspace {
|
|||||||
workspaceURL.appendingPathComponent(self.agentsFilename)
|
workspaceURL.appendingPathComponent(self.agentsFilename)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func workspaceEntries(workspaceURL: URL) throws -> [String] {
|
|
||||||
let contents = try FileManager.default.contentsOfDirectory(atPath: workspaceURL.path)
|
|
||||||
return contents.filter { !self.ignoredEntries.contains($0) }
|
|
||||||
}
|
|
||||||
|
|
||||||
static func isWorkspaceEmpty(workspaceURL: URL) -> Bool {
|
|
||||||
let fm = FileManager.default
|
|
||||||
var isDir: ObjCBool = false
|
|
||||||
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
guard isDir.boolValue else { return false }
|
|
||||||
guard let entries = try? self.workspaceEntries(workspaceURL: workspaceURL) else { return false }
|
|
||||||
return entries.isEmpty
|
|
||||||
}
|
|
||||||
|
|
||||||
static func isTemplateOnlyWorkspace(workspaceURL: URL) -> Bool {
|
|
||||||
guard let entries = try? self.workspaceEntries(workspaceURL: workspaceURL) else { return false }
|
|
||||||
guard !entries.isEmpty else { return true }
|
|
||||||
return Set(entries).isSubset(of: self.templateEntries)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func bootstrapSafety(for workspaceURL: URL) -> BootstrapSafety {
|
|
||||||
let fm = FileManager.default
|
|
||||||
var isDir: ObjCBool = false
|
|
||||||
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
|
|
||||||
return .safe
|
|
||||||
}
|
|
||||||
if !isDir.boolValue {
|
|
||||||
return .unsafe(reason: "Workspace path points to a file.")
|
|
||||||
}
|
|
||||||
let agentsURL = self.agentsURL(workspaceURL: workspaceURL)
|
|
||||||
if fm.fileExists(atPath: agentsURL.path) {
|
|
||||||
return .safe
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
let entries = try self.workspaceEntries(workspaceURL: workspaceURL)
|
|
||||||
return entries.isEmpty
|
|
||||||
? .safe
|
|
||||||
: .unsafe(reason: "Folder isn't empty. Choose a new folder or add AGENTS.md first.")
|
|
||||||
} catch {
|
|
||||||
return .unsafe(reason: "Couldn't inspect the workspace folder.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func bootstrap(workspaceURL: URL) throws -> URL {
|
static func bootstrap(workspaceURL: URL) throws -> URL {
|
||||||
let shouldSeedBootstrap = self.isWorkspaceEmpty(workspaceURL: workspaceURL)
|
|
||||||
try FileManager.default.createDirectory(at: workspaceURL, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: workspaceURL, withIntermediateDirectories: true)
|
||||||
let agentsURL = self.agentsURL(workspaceURL: workspaceURL)
|
let agentsURL = self.agentsURL(workspaceURL: workspaceURL)
|
||||||
if !FileManager.default.fileExists(atPath: agentsURL.path) {
|
if !FileManager.default.fileExists(atPath: agentsURL.path) {
|
||||||
@@ -112,44 +56,36 @@ enum AgentWorkspace {
|
|||||||
self.logger.info("Created USER.md at \(userURL.path, privacy: .public)")
|
self.logger.info("Created USER.md at \(userURL.path, privacy: .public)")
|
||||||
}
|
}
|
||||||
let bootstrapURL = workspaceURL.appendingPathComponent(self.bootstrapFilename)
|
let bootstrapURL = workspaceURL.appendingPathComponent(self.bootstrapFilename)
|
||||||
if shouldSeedBootstrap, !FileManager.default.fileExists(atPath: bootstrapURL.path) {
|
if !FileManager.default.fileExists(atPath: bootstrapURL.path) {
|
||||||
try self.defaultBootstrapTemplate().write(to: bootstrapURL, atomically: true, encoding: .utf8)
|
try self.defaultBootstrapTemplate().write(to: bootstrapURL, atomically: true, encoding: .utf8)
|
||||||
self.logger.info("Created BOOTSTRAP.md at \(bootstrapURL.path, privacy: .public)")
|
self.logger.info("Created BOOTSTRAP.md at \(bootstrapURL.path, privacy: .public)")
|
||||||
}
|
}
|
||||||
return agentsURL
|
return agentsURL
|
||||||
}
|
}
|
||||||
|
|
||||||
static func needsBootstrap(workspaceURL: URL) -> Bool {
|
static func upsertIdentity(workspaceURL: URL, identity: AgentIdentity) throws {
|
||||||
let fm = FileManager.default
|
let agentsURL = try self.bootstrap(workspaceURL: workspaceURL)
|
||||||
var isDir: ObjCBool = false
|
var content = (try? String(contentsOf: agentsURL, encoding: .utf8)) ?? ""
|
||||||
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
|
let block = self.identityBlock(identity: identity)
|
||||||
return true
|
|
||||||
}
|
|
||||||
guard isDir.boolValue else { return true }
|
|
||||||
if self.hasIdentity(workspaceURL: workspaceURL) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
let bootstrapURL = workspaceURL.appendingPathComponent(self.bootstrapFilename)
|
|
||||||
guard fm.fileExists(atPath: bootstrapURL.path) else { return false }
|
|
||||||
return self.isTemplateOnlyWorkspace(workspaceURL: workspaceURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func hasIdentity(workspaceURL: URL) -> Bool {
|
if let start = content.range(of: self.identityStartMarker),
|
||||||
let identityURL = workspaceURL.appendingPathComponent(self.identityFilename)
|
let end = content.range(of: self.identityEndMarker),
|
||||||
guard let contents = try? String(contentsOf: identityURL, encoding: .utf8) else { return false }
|
start.lowerBound < end.upperBound
|
||||||
return self.identityLinesHaveValues(contents)
|
{
|
||||||
}
|
content.replaceSubrange(
|
||||||
|
start.lowerBound..<end.upperBound,
|
||||||
private static func identityLinesHaveValues(_ content: String) -> Bool {
|
with: block.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||||
for line in content.split(separator: "\n") {
|
} else if let insert = self.identityInsertRange(in: content) {
|
||||||
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
content.insert(contentsOf: "\n\n## Identity\n\(block)\n", at: insert.upperBound)
|
||||||
guard trimmed.hasPrefix("-"), let colon = trimmed.firstIndex(of: ":") else { continue }
|
} else {
|
||||||
let value = trimmed[trimmed.index(after: colon)...].trimmingCharacters(in: .whitespacesAndNewlines)
|
content = [content.trimmingCharacters(in: .whitespacesAndNewlines), "## Identity\n\(block)"]
|
||||||
if !value.isEmpty {
|
.filter { !$0.isEmpty }
|
||||||
return true
|
.joined(separator: "\n\n")
|
||||||
}
|
.appending("\n")
|
||||||
}
|
}
|
||||||
return false
|
|
||||||
|
try content.write(to: agentsURL, atomically: true, encoding: .utf8)
|
||||||
|
self.logger.info("Updated identity in \(agentsURL.path, privacy: .public)")
|
||||||
}
|
}
|
||||||
|
|
||||||
static func defaultTemplate() -> String {
|
static func defaultTemplate() -> String {
|
||||||
@@ -336,5 +272,25 @@ enum AgentWorkspace {
|
|||||||
return trimmed + "\n"
|
return trimmed + "\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Identity is written by the agent during the bootstrap ritual.
|
private static func identityBlock(identity: AgentIdentity) -> String {
|
||||||
|
let name = identity.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let theme = identity.theme.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let emoji = identity.emoji.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
|
return """
|
||||||
|
\(self.identityStartMarker)
|
||||||
|
- Name: \(name)
|
||||||
|
- Theme: \(theme)
|
||||||
|
- Emoji: \(emoji)
|
||||||
|
\(self.identityEndMarker)
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func identityInsertRange(in content: String) -> Range<String.Index>? {
|
||||||
|
if let firstHeading = content.range(of: "\n") {
|
||||||
|
// Insert after the first line (usually "# AGENTS.md …")
|
||||||
|
return firstHeading
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,7 @@ import SwiftUI
|
|||||||
struct AnthropicAuthControls: View {
|
struct AnthropicAuthControls: View {
|
||||||
let connectionMode: AppState.ConnectionMode
|
let connectionMode: AppState.ConnectionMode
|
||||||
|
|
||||||
@State private var oauthStatus: ClawdisOAuthStore.AnthropicOAuthStatus = ClawdisOAuthStore.anthropicOAuthStatus()
|
@State private var oauthStatus: PiOAuthStore.AnthropicOAuthStatus = PiOAuthStore.anthropicOAuthStatus()
|
||||||
@State private var pkce: AnthropicOAuth.PKCE?
|
@State private var pkce: AnthropicOAuth.PKCE?
|
||||||
@State private var code: String = ""
|
@State private var code: String = ""
|
||||||
@State private var busy = false
|
@State private var busy = false
|
||||||
@@ -20,7 +20,7 @@ struct AnthropicAuthControls: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
if self.connectionMode != .local {
|
if self.connectionMode != .local {
|
||||||
Text("Gateway isn’t running locally; OAuth must be created on the gateway host.")
|
Text("Gateway isn’t running locally; OAuth must be created on the gateway host where Pi runs.")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
@@ -35,10 +35,10 @@ struct AnthropicAuthControls: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("Reveal") {
|
Button("Reveal") {
|
||||||
NSWorkspace.shared.activateFileViewerSelecting([ClawdisOAuthStore.oauthURL()])
|
NSWorkspace.shared.activateFileViewerSelecting([PiOAuthStore.oauthURL()])
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.disabled(!FileManager.default.fileExists(atPath: ClawdisOAuthStore.oauthURL().path))
|
.disabled(!FileManager.default.fileExists(atPath: PiOAuthStore.oauthURL().path))
|
||||||
|
|
||||||
Button("Refresh") {
|
Button("Refresh") {
|
||||||
self.refresh()
|
self.refresh()
|
||||||
@@ -46,7 +46,7 @@ struct AnthropicAuthControls: View {
|
|||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(ClawdisOAuthStore.oauthURL().path)
|
Text(PiOAuthStore.oauthURL().path)
|
||||||
.font(.caption.monospaced())
|
.font(.caption.monospaced())
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@@ -123,11 +123,7 @@ struct AnthropicAuthControls: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func refresh() {
|
private func refresh() {
|
||||||
let imported = ClawdisOAuthStore.importLegacyAnthropicOAuthIfNeeded()
|
self.oauthStatus = PiOAuthStore.anthropicOAuthStatus()
|
||||||
self.oauthStatus = ClawdisOAuthStore.anthropicOAuthStatus()
|
|
||||||
if imported != nil {
|
|
||||||
self.statusText = "Imported existing OAuth credentials."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startOAuth() {
|
private func startOAuth() {
|
||||||
@@ -165,11 +161,11 @@ struct AnthropicAuthControls: View {
|
|||||||
code: parsed.code,
|
code: parsed.code,
|
||||||
state: parsed.state,
|
state: parsed.state,
|
||||||
verifier: pkce.verifier)
|
verifier: pkce.verifier)
|
||||||
try ClawdisOAuthStore.saveAnthropicOAuth(creds)
|
try PiOAuthStore.saveAnthropicOAuth(creds)
|
||||||
self.refresh()
|
self.refresh()
|
||||||
self.pkce = nil
|
self.pkce = nil
|
||||||
self.code = ""
|
self.code = ""
|
||||||
self.statusText = "Connected. Clawdis can now use Claude via OAuth."
|
self.statusText = "Connected. Pi can now use Claude via OAuth."
|
||||||
} catch {
|
} catch {
|
||||||
self.statusText = "OAuth failed: \(error.localizedDescription)"
|
self.statusText = "OAuth failed: \(error.localizedDescription)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ enum AnthropicAuthMode: Equatable {
|
|||||||
|
|
||||||
var shortLabel: String {
|
var shortLabel: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .oauthFile: "OAuth (Clawdis token file)"
|
case .oauthFile: "OAuth (Pi token file)"
|
||||||
case .oauthEnv: "OAuth (env var)"
|
case .oauthEnv: "OAuth (env var)"
|
||||||
case .apiKeyEnv: "API key (env var)"
|
case .apiKeyEnv: "API key (env var)"
|
||||||
case .missing: "Missing credentials"
|
case .missing: "Missing credentials"
|
||||||
@@ -36,8 +36,7 @@ enum AnthropicAuthMode: Equatable {
|
|||||||
enum AnthropicAuthResolver {
|
enum AnthropicAuthResolver {
|
||||||
static func resolve(
|
static func resolve(
|
||||||
environment: [String: String] = ProcessInfo.processInfo.environment,
|
environment: [String: String] = ProcessInfo.processInfo.environment,
|
||||||
oauthStatus: ClawdisOAuthStore.AnthropicOAuthStatus = ClawdisOAuthStore.anthropicOAuthStatus()
|
oauthStatus: PiOAuthStore.AnthropicOAuthStatus = PiOAuthStore.anthropicOAuthStatus()) -> AnthropicAuthMode
|
||||||
) -> AnthropicAuthMode
|
|
||||||
{
|
{
|
||||||
if oauthStatus.isConnected { return .oauthFile }
|
if oauthStatus.isConnected { return .oauthFile }
|
||||||
|
|
||||||
@@ -93,7 +92,7 @@ enum AnthropicOAuth {
|
|||||||
URLQueryItem(name: "scope", value: self.scopes),
|
URLQueryItem(name: "scope", value: self.scopes),
|
||||||
URLQueryItem(name: "code_challenge", value: pkce.challenge),
|
URLQueryItem(name: "code_challenge", value: pkce.challenge),
|
||||||
URLQueryItem(name: "code_challenge_method", value: "S256"),
|
URLQueryItem(name: "code_challenge_method", value: "S256"),
|
||||||
// Match legacy flow: state is the verifier.
|
// Match Pi: state is the verifier.
|
||||||
URLQueryItem(name: "state", value: pkce.verifier),
|
URLQueryItem(name: "state", value: pkce.verifier),
|
||||||
]
|
]
|
||||||
return components.url!
|
return components.url!
|
||||||
@@ -141,7 +140,7 @@ enum AnthropicOAuth {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match legacy flow: expiresAt = now + expires_in - 5 minutes.
|
// Match Pi: expiresAt = now + expires_in - 5 minutes.
|
||||||
let expiresAtMs = Int64(Date().timeIntervalSince1970 * 1000)
|
let expiresAtMs = Int64(Date().timeIntervalSince1970 * 1000)
|
||||||
+ Int64(expiresIn * 1000)
|
+ Int64(expiresIn * 1000)
|
||||||
- Int64(5 * 60 * 1000)
|
- Int64(5 * 60 * 1000)
|
||||||
@@ -151,11 +150,10 @@ enum AnthropicOAuth {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ClawdisOAuthStore {
|
enum PiOAuthStore {
|
||||||
static let oauthFilename = "oauth.json"
|
static let oauthFilename = "oauth.json"
|
||||||
private static let providerKey = "anthropic"
|
private static let providerKey = "anthropic"
|
||||||
private static let clawdisOAuthDirEnv = "CLAWDIS_OAUTH_DIR"
|
private static let piAgentDirEnv = "PI_CODING_AGENT_DIR"
|
||||||
private static let legacyPiDirEnv = "PI_CODING_AGENT_DIR"
|
|
||||||
|
|
||||||
enum AnthropicOAuthStatus: Equatable {
|
enum AnthropicOAuthStatus: Equatable {
|
||||||
case missingFile
|
case missingFile
|
||||||
@@ -172,18 +170,18 @@ enum ClawdisOAuthStore {
|
|||||||
|
|
||||||
var shortDescription: String {
|
var shortDescription: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .missingFile: "Clawdis OAuth token file not found"
|
case .missingFile: "Pi OAuth token file not found"
|
||||||
case .unreadableFile: "Clawdis OAuth token file not readable"
|
case .unreadableFile: "Pi OAuth token file not readable"
|
||||||
case .invalidJSON: "Clawdis OAuth token file invalid"
|
case .invalidJSON: "Pi OAuth token file invalid"
|
||||||
case .missingProviderEntry: "No Anthropic entry in Clawdis OAuth token file"
|
case .missingProviderEntry: "No Anthropic entry in Pi OAuth token file"
|
||||||
case .missingTokens: "Anthropic entry missing tokens"
|
case .missingTokens: "Anthropic entry missing tokens"
|
||||||
case .connected: "Clawdis OAuth credentials found"
|
case .connected: "Pi OAuth credentials found"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func oauthDir() -> URL {
|
static func oauthDir() -> URL {
|
||||||
if let override = ProcessInfo.processInfo.environment[self.clawdisOAuthDirEnv]?
|
if let override = ProcessInfo.processInfo.environment[self.piAgentDirEnv]?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
!override.isEmpty
|
!override.isEmpty
|
||||||
{
|
{
|
||||||
@@ -192,58 +190,14 @@ enum ClawdisOAuthStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return FileManager.default.homeDirectoryForCurrentUser
|
return FileManager.default.homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent(".clawdis", isDirectory: true)
|
.appendingPathComponent(".pi", isDirectory: true)
|
||||||
.appendingPathComponent("credentials", isDirectory: true)
|
.appendingPathComponent("agent", isDirectory: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func oauthURL() -> URL {
|
static func oauthURL() -> URL {
|
||||||
self.oauthDir().appendingPathComponent(self.oauthFilename)
|
self.oauthDir().appendingPathComponent(self.oauthFilename)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func legacyOAuthURLs() -> [URL] {
|
|
||||||
var urls: [URL] = []
|
|
||||||
let env = ProcessInfo.processInfo.environment
|
|
||||||
if let override = env[self.legacyPiDirEnv]?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
||||||
!override.isEmpty
|
|
||||||
{
|
|
||||||
let expanded = NSString(string: override).expandingTildeInPath
|
|
||||||
urls.append(URL(fileURLWithPath: expanded, isDirectory: true).appendingPathComponent(self.oauthFilename))
|
|
||||||
}
|
|
||||||
|
|
||||||
let home = FileManager.default.homeDirectoryForCurrentUser
|
|
||||||
urls.append(home.appendingPathComponent(".pi/agent/\(self.oauthFilename)"))
|
|
||||||
urls.append(home.appendingPathComponent(".claude/\(self.oauthFilename)"))
|
|
||||||
urls.append(home.appendingPathComponent(".config/claude/\(self.oauthFilename)"))
|
|
||||||
urls.append(home.appendingPathComponent(".config/anthropic/\(self.oauthFilename)"))
|
|
||||||
|
|
||||||
var seen = Set<String>()
|
|
||||||
return urls.filter { url in
|
|
||||||
let path = url.standardizedFileURL.path
|
|
||||||
if seen.contains(path) { return false }
|
|
||||||
seen.insert(path)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func importLegacyAnthropicOAuthIfNeeded() -> URL? {
|
|
||||||
let dest = self.oauthURL()
|
|
||||||
guard !FileManager.default.fileExists(atPath: dest.path) else { return nil }
|
|
||||||
|
|
||||||
for url in self.legacyOAuthURLs() {
|
|
||||||
guard FileManager.default.fileExists(atPath: url.path) else { continue }
|
|
||||||
guard self.anthropicOAuthStatus(at: url).isConnected else { continue }
|
|
||||||
guard let storage = self.loadStorage(at: url) else { continue }
|
|
||||||
do {
|
|
||||||
try self.saveStorage(storage)
|
|
||||||
return url
|
|
||||||
} catch {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
static func anthropicOAuthStatus() -> AnthropicOAuthStatus {
|
static func anthropicOAuthStatus() -> AnthropicOAuthStatus {
|
||||||
self.anthropicOAuthStatus(at: self.oauthURL())
|
self.anthropicOAuthStatus(at: self.oauthURL())
|
||||||
}
|
}
|
||||||
@@ -286,15 +240,17 @@ enum ClawdisOAuthStore {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func loadStorage(at url: URL) -> [String: Any]? {
|
|
||||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
|
||||||
guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return nil }
|
|
||||||
return json as? [String: Any]
|
|
||||||
}
|
|
||||||
|
|
||||||
static func saveAnthropicOAuth(_ creds: AnthropicOAuthCredentials) throws {
|
static func saveAnthropicOAuth(_ creds: AnthropicOAuthCredentials) throws {
|
||||||
let url = self.oauthURL()
|
let url = self.oauthURL()
|
||||||
let existing: [String: Any] = self.loadStorage(at: url) ?? [:]
|
let existing: [String: Any] = if FileManager.default.fileExists(atPath: url.path),
|
||||||
|
let data = try? Data(contentsOf: url),
|
||||||
|
let json = try? JSONSerialization.jsonObject(with: data, options: []),
|
||||||
|
let dict = json as? [String: Any]
|
||||||
|
{
|
||||||
|
dict
|
||||||
|
} else {
|
||||||
|
[:]
|
||||||
|
}
|
||||||
|
|
||||||
var updated = existing
|
var updated = existing
|
||||||
updated[self.providerKey] = [
|
updated[self.providerKey] = [
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import ClawdisProtocol
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
extension AnyCodable {
|
|
||||||
var stringValue: String? { self.value as? String }
|
|
||||||
var boolValue: Bool? { self.value as? Bool }
|
|
||||||
var intValue: Int? { self.value as? Int }
|
|
||||||
var doubleValue: Double? { self.value as? Double }
|
|
||||||
var dictionaryValue: [String: AnyCodable]? { self.value as? [String: AnyCodable] }
|
|
||||||
var arrayValue: [AnyCodable]? { self.value as? [AnyCodable] }
|
|
||||||
|
|
||||||
var foundationValue: Any {
|
|
||||||
switch self.value {
|
|
||||||
case let dict as [String: AnyCodable]:
|
|
||||||
dict.mapValues { $0.foundationValue }
|
|
||||||
case let array as [AnyCodable]:
|
|
||||||
array.map(\.foundationValue)
|
|
||||||
default:
|
|
||||||
self.value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -38,7 +38,6 @@ final class AppState {
|
|||||||
var debugPaneEnabled: Bool {
|
var debugPaneEnabled: Bool {
|
||||||
didSet {
|
didSet {
|
||||||
self.ifNotPreview { UserDefaults.standard.set(self.debugPaneEnabled, forKey: "clawdis.debugPaneEnabled") }
|
self.ifNotPreview { UserDefaults.standard.set(self.debugPaneEnabled, forKey: "clawdis.debugPaneEnabled") }
|
||||||
CanvasManager.shared.refreshDebugStatus()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,12 +11,6 @@ final class CanvasManager {
|
|||||||
|
|
||||||
private var panelController: CanvasWindowController?
|
private var panelController: CanvasWindowController?
|
||||||
private var panelSessionKey: String?
|
private var panelSessionKey: String?
|
||||||
private var lastAutoA2UIUrl: String?
|
|
||||||
private var gatewayWatchTask: Task<Void, Never>?
|
|
||||||
|
|
||||||
private init() {
|
|
||||||
self.startGatewayObserver()
|
|
||||||
}
|
|
||||||
|
|
||||||
var onPanelVisibilityChanged: ((Bool) -> Void)?
|
var onPanelVisibilityChanged: ((Bool) -> Void)?
|
||||||
|
|
||||||
@@ -56,7 +50,6 @@ final class CanvasManager {
|
|||||||
}
|
}
|
||||||
controller.presentAnchoredPanel(anchorProvider: anchorProvider)
|
controller.presentAnchoredPanel(anchorProvider: anchorProvider)
|
||||||
controller.applyPreferredPlacement(placement)
|
controller.applyPreferredPlacement(placement)
|
||||||
self.refreshDebugStatus()
|
|
||||||
|
|
||||||
// Existing session: only navigate when an explicit target was provided.
|
// Existing session: only navigate when an explicit target was provided.
|
||||||
if let normalizedTarget {
|
if let normalizedTarget {
|
||||||
@@ -67,7 +60,6 @@ final class CanvasManager {
|
|||||||
effectiveTarget: normalizedTarget)
|
effectiveTarget: normalizedTarget)
|
||||||
}
|
}
|
||||||
|
|
||||||
self.maybeAutoNavigateToA2UIAsync(controller: controller)
|
|
||||||
return CanvasShowResult(
|
return CanvasShowResult(
|
||||||
directory: controller.directoryPath,
|
directory: controller.directoryPath,
|
||||||
target: target,
|
target: target,
|
||||||
@@ -101,10 +93,6 @@ final class CanvasManager {
|
|||||||
Self.logger.debug("showDetailed showCanvas effectiveTarget=\(effectiveTarget, privacy: .public)")
|
Self.logger.debug("showDetailed showCanvas effectiveTarget=\(effectiveTarget, privacy: .public)")
|
||||||
controller.showCanvas(path: effectiveTarget)
|
controller.showCanvas(path: effectiveTarget)
|
||||||
Self.logger.debug("showDetailed showCanvas done")
|
Self.logger.debug("showDetailed showCanvas done")
|
||||||
if normalizedTarget == nil {
|
|
||||||
self.maybeAutoNavigateToA2UIAsync(controller: controller)
|
|
||||||
}
|
|
||||||
self.refreshDebugStatus()
|
|
||||||
|
|
||||||
return self.makeShowResult(
|
return self.makeShowResult(
|
||||||
directory: controller.directoryPath,
|
directory: controller.directoryPath,
|
||||||
@@ -136,63 +124,6 @@ final class CanvasManager {
|
|||||||
return try await controller.snapshot(to: outPath)
|
return try await controller.snapshot(to: outPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Gateway A2UI auto-nav
|
|
||||||
|
|
||||||
private func startGatewayObserver() {
|
|
||||||
self.gatewayWatchTask?.cancel()
|
|
||||||
self.gatewayWatchTask = Task { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 1)
|
|
||||||
for await push in stream {
|
|
||||||
self.handleGatewayPush(push)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleGatewayPush(_ push: GatewayPush) {
|
|
||||||
guard case let .snapshot(snapshot) = push else { return }
|
|
||||||
let a2uiUrl = Self.resolveA2UIHostUrl(from: snapshot.canvashosturl)
|
|
||||||
guard let controller = self.panelController else { return }
|
|
||||||
self.maybeAutoNavigateToA2UI(controller: controller, a2uiUrl: a2uiUrl)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func maybeAutoNavigateToA2UIAsync(controller: CanvasWindowController) {
|
|
||||||
Task { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
let a2uiUrl = await self.resolveA2UIHostUrl()
|
|
||||||
await MainActor.run {
|
|
||||||
guard self.panelController === controller else { return }
|
|
||||||
self.maybeAutoNavigateToA2UI(controller: controller, a2uiUrl: a2uiUrl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func maybeAutoNavigateToA2UI(controller: CanvasWindowController, a2uiUrl: String?) {
|
|
||||||
guard let a2uiUrl else { return }
|
|
||||||
guard controller.shouldAutoNavigateToA2UI(lastAutoTarget: self.lastAutoA2UIUrl) else { return }
|
|
||||||
controller.load(target: a2uiUrl)
|
|
||||||
self.lastAutoA2UIUrl = a2uiUrl
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resolveA2UIHostUrl() async -> String? {
|
|
||||||
let raw = await GatewayConnection.shared.canvasHostUrl()
|
|
||||||
return Self.resolveA2UIHostUrl(from: raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
func refreshDebugStatus() {
|
|
||||||
guard let controller = self.panelController else { return }
|
|
||||||
let enabled = AppStateStore.shared.debugPaneEnabled
|
|
||||||
let title = GatewayProcessManager.shared.status.label
|
|
||||||
let subtitle = AppStateStore.shared.connectionMode.rawValue
|
|
||||||
controller.updateDebugStatus(enabled: enabled, title: title, subtitle: subtitle)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func resolveA2UIHostUrl(from raw: String?) -> String? {
|
|
||||||
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil }
|
|
||||||
return base.appendingPathComponent("__clawdis__/a2ui/").absoluteString
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Anchoring
|
// MARK: - Anchoring
|
||||||
|
|
||||||
private static func mouseAnchorProvider() -> NSRect? {
|
private static func mouseAnchorProvider() -> NSRect? {
|
||||||
|
|||||||
@@ -43,10 +43,6 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
|||||||
private let container: HoverChromeContainerView
|
private let container: HoverChromeContainerView
|
||||||
let presentation: CanvasPresentation
|
let presentation: CanvasPresentation
|
||||||
private var preferredPlacement: CanvasPlacement?
|
private var preferredPlacement: CanvasPlacement?
|
||||||
private(set) var currentTarget: String?
|
|
||||||
private var debugStatusEnabled = false
|
|
||||||
private var debugStatusTitle: String?
|
|
||||||
private var debugStatusSubtitle: String?
|
|
||||||
|
|
||||||
var onVisibilityChanged: ((Bool) -> Void)?
|
var onVisibilityChanged: ((Bool) -> Void)?
|
||||||
|
|
||||||
@@ -241,7 +237,6 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
|||||||
|
|
||||||
func load(target: String) {
|
func load(target: String) {
|
||||||
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
self.currentTarget = trimmed
|
|
||||||
|
|
||||||
if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased() {
|
if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased() {
|
||||||
if scheme == "https" || scheme == "http" {
|
if scheme == "https" || scheme == "http" {
|
||||||
@@ -281,35 +276,6 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
|||||||
self.webView.load(URLRequest(url: url))
|
self.webView.load(URLRequest(url: url))
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateDebugStatus(enabled: Bool, title: String?, subtitle: String?) {
|
|
||||||
self.debugStatusEnabled = enabled
|
|
||||||
self.debugStatusTitle = title
|
|
||||||
self.debugStatusSubtitle = subtitle
|
|
||||||
self.applyDebugStatusIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func applyDebugStatusIfNeeded() {
|
|
||||||
let enabled = self.debugStatusEnabled
|
|
||||||
let title = Self.jsOptionalStringLiteral(self.debugStatusTitle)
|
|
||||||
let subtitle = Self.jsOptionalStringLiteral(self.debugStatusSubtitle)
|
|
||||||
let js = """
|
|
||||||
(() => {
|
|
||||||
try {
|
|
||||||
const api = globalThis.__clawdis;
|
|
||||||
if (!api) return;
|
|
||||||
if (typeof api.setDebugStatusEnabled === 'function') {
|
|
||||||
api.setDebugStatusEnabled(\(enabled ? "true" : "false"));
|
|
||||||
}
|
|
||||||
if (!\(enabled ? "true" : "false")) return;
|
|
||||||
if (typeof api.setStatus === 'function') {
|
|
||||||
api.setStatus(\(title), \(subtitle));
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
})();
|
|
||||||
"""
|
|
||||||
self.webView.evaluateJavaScript(js) { _, _ in }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadFile(_ url: URL) {
|
private func loadFile(_ url: URL) {
|
||||||
let fileURL = url.isFileURL ? url : URL(fileURLWithPath: url.path)
|
let fileURL = url.isFileURL ? url : URL(fileURLWithPath: url.path)
|
||||||
let accessDir = fileURL.deletingLastPathComponent()
|
let accessDir = fileURL.deletingLastPathComponent()
|
||||||
@@ -374,18 +340,6 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
|||||||
self.sessionDir.path
|
self.sessionDir.path
|
||||||
}
|
}
|
||||||
|
|
||||||
func shouldAutoNavigateToA2UI(lastAutoTarget: String?) -> Bool {
|
|
||||||
let trimmed = (self.currentTarget ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if trimmed.isEmpty || trimmed == "/" { return true }
|
|
||||||
if let lastAuto = lastAutoTarget?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
||||||
!lastAuto.isEmpty,
|
|
||||||
trimmed == lastAuto
|
|
||||||
{
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Window
|
// MARK: - Window
|
||||||
|
|
||||||
private static func makeWindow(for presentation: CanvasPresentation, contentView: NSView) -> NSWindow {
|
private static func makeWindow(for presentation: CanvasPresentation, contentView: NSView) -> NSWindow {
|
||||||
@@ -583,10 +537,6 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
|||||||
decisionHandler(.cancel)
|
decisionHandler(.cancel)
|
||||||
}
|
}
|
||||||
|
|
||||||
func webView(_: WKWebView, didFinish _: WKNavigation?) {
|
|
||||||
self.applyDebugStatusIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - NSWindowDelegate
|
// MARK: - NSWindowDelegate
|
||||||
|
|
||||||
func windowWillClose(_: Notification) {
|
func windowWillClose(_: Notification) {
|
||||||
@@ -621,11 +571,6 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
|||||||
return data.flatMap { String(data: $0, encoding: .utf8) } ?? "\"\""
|
return data.flatMap { String(data: $0, encoding: .utf8) } ?? "\"\""
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func jsOptionalStringLiteral(_ value: String?) -> String {
|
|
||||||
guard let value else { return "null" }
|
|
||||||
return Self.jsStringLiteral(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func storedFrameDefaultsKey(sessionKey: String) -> String {
|
private static func storedFrameDefaultsKey(sessionKey: String) -> String {
|
||||||
"clawdis.canvas.frame.\(self.sanitizeSessionKey(sessionKey))"
|
"clawdis.canvas.frame.\(self.sanitizeSessionKey(sessionKey))"
|
||||||
}
|
}
|
||||||
@@ -694,7 +639,7 @@ private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHan
|
|||||||
}()
|
}()
|
||||||
guard !userAction.isEmpty else { return }
|
guard !userAction.isEmpty else { return }
|
||||||
|
|
||||||
guard let name = ClawdisCanvasA2UIAction.extractActionName(userAction) else { return }
|
guard let name = userAction["name"] as? String, !name.isEmpty else { return }
|
||||||
let actionId =
|
let actionId =
|
||||||
(userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
(userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||||
?? UUID().uuidString
|
?? UUID().uuidString
|
||||||
@@ -709,12 +654,14 @@ private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHan
|
|||||||
let contextJSON = ClawdisCanvasA2UIAction.compactJSON(userAction["context"])
|
let contextJSON = ClawdisCanvasA2UIAction.compactJSON(userAction["context"])
|
||||||
|
|
||||||
// Token-efficient and unambiguous. The agent should treat this as a UI event and (by default) update Canvas.
|
// Token-efficient and unambiguous. The agent should treat this as a UI event and (by default) update Canvas.
|
||||||
let messageContext = ClawdisCanvasA2UIAction.AgentMessageContext(
|
let text = ClawdisCanvasA2UIAction.formatAgentMessage(
|
||||||
actionName: name,
|
actionName: name,
|
||||||
session: .init(key: self.sessionKey, surfaceId: surfaceId),
|
sessionKey: self.sessionKey,
|
||||||
component: .init(id: sourceComponentId, host: InstanceIdentity.displayName, instanceId: instanceId),
|
surfaceId: surfaceId,
|
||||||
|
sourceComponentId: sourceComponentId,
|
||||||
|
host: InstanceIdentity.displayName,
|
||||||
|
instanceId: instanceId,
|
||||||
contextJSON: contextJSON)
|
contextJSON: contextJSON)
|
||||||
let text = ClawdisCanvasA2UIAction.formatAgentMessage(messageContext)
|
|
||||||
|
|
||||||
Task { [weak webView] in
|
Task { [weak webView] in
|
||||||
if AppStateStore.shared.connectionMode == .local {
|
if AppStateStore.shared.connectionMode == .local {
|
||||||
|
|||||||
@@ -30,23 +30,6 @@ enum ClawdisConfigFile {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func loadGatewayDict() -> [String: Any] {
|
|
||||||
let root = self.loadDict()
|
|
||||||
return root["gateway"] as? [String: Any] ?? [:]
|
|
||||||
}
|
|
||||||
|
|
||||||
static func updateGatewayDict(_ mutate: (inout [String: Any]) -> Void) {
|
|
||||||
var root = self.loadDict()
|
|
||||||
var gateway = root["gateway"] as? [String: Any] ?? [:]
|
|
||||||
mutate(&gateway)
|
|
||||||
if gateway.isEmpty {
|
|
||||||
root.removeValue(forKey: "gateway")
|
|
||||||
} else {
|
|
||||||
root["gateway"] = gateway
|
|
||||||
}
|
|
||||||
self.saveDict(root)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func browserControlEnabled(defaultValue: Bool = true) -> Bool {
|
static func browserControlEnabled(defaultValue: Bool = true) -> Bool {
|
||||||
let root = self.loadDict()
|
let root = self.loadDict()
|
||||||
let browser = root["browser"] as? [String: Any]
|
let browser = root["browser"] as? [String: Any]
|
||||||
@@ -79,4 +62,28 @@ enum ClawdisConfigFile {
|
|||||||
root["inbound"] = inbound
|
root["inbound"] = inbound
|
||||||
self.saveDict(root)
|
self.saveDict(root)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func loadIdentity() -> AgentIdentity? {
|
||||||
|
let root = self.loadDict()
|
||||||
|
guard let identity = root["identity"] as? [String: Any] else { return nil }
|
||||||
|
let name = identity["name"] as? String ?? ""
|
||||||
|
let theme = identity["theme"] as? String ?? ""
|
||||||
|
let emoji = identity["emoji"] as? String ?? ""
|
||||||
|
let result = AgentIdentity(name: name, theme: theme, emoji: emoji)
|
||||||
|
return result.isEmpty ? nil : result
|
||||||
|
}
|
||||||
|
|
||||||
|
static func setIdentity(_ identity: AgentIdentity?) {
|
||||||
|
var root = self.loadDict()
|
||||||
|
if let identity, !identity.isEmpty {
|
||||||
|
root["identity"] = [
|
||||||
|
"name": identity.name.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
"theme": identity.theme.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
"emoji": identity.emoji.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
root.removeValue(forKey: "identity")
|
||||||
|
}
|
||||||
|
self.saveDict(root)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -152,7 +152,7 @@ struct ConfigSettings: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var anthropicAuthHelpText: String {
|
private var anthropicAuthHelpText: String {
|
||||||
"Determined from Clawdis OAuth token file (~/.clawdis/credentials/oauth.json) " +
|
"Determined from Pi OAuth token file (~/.pi/agent/oauth.json) " +
|
||||||
"or environment variables (ANTHROPIC_OAUTH_TOKEN / ANTHROPIC_API_KEY)."
|
"or environment variables (ANTHROPIC_OAUTH_TOKEN / ANTHROPIC_API_KEY)."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,350 +0,0 @@
|
|||||||
import AppKit
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct ConnectionsSettings: View {
|
|
||||||
@Bindable var store: ConnectionsStore
|
|
||||||
@State private var showTelegramToken = false
|
|
||||||
|
|
||||||
init(store: ConnectionsStore = .shared) {
|
|
||||||
self.store = store
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ScrollView(.vertical) {
|
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
|
||||||
self.header
|
|
||||||
self.whatsAppSection
|
|
||||||
self.telegramSection
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.padding(.horizontal, 24)
|
|
||||||
.padding(.vertical, 18)
|
|
||||||
}
|
|
||||||
.onAppear { self.store.start() }
|
|
||||||
.onDisappear { self.store.stop() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private var header: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
Text("Connections")
|
|
||||||
.font(.title3.weight(.semibold))
|
|
||||||
Text("Link and monitor WhatsApp and Telegram providers.")
|
|
||||||
.font(.callout)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var whatsAppSection: some View {
|
|
||||||
GroupBox("WhatsApp") {
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
|
||||||
self.providerHeader(
|
|
||||||
title: "WhatsApp Web",
|
|
||||||
color: self.whatsAppTint,
|
|
||||||
subtitle: self.whatsAppSummary)
|
|
||||||
|
|
||||||
if let details = self.whatsAppDetails {
|
|
||||||
Text(details)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let message = self.store.whatsappLoginMessage {
|
|
||||||
Text(message)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let qr = self.store.whatsappLoginQrDataUrl, let image = self.qrImage(from: qr) {
|
|
||||||
Image(nsImage: image)
|
|
||||||
.resizable()
|
|
||||||
.interpolation(.none)
|
|
||||||
.frame(width: 180, height: 180)
|
|
||||||
.cornerRadius(8)
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Button {
|
|
||||||
Task { await self.store.startWhatsAppLogin(force: false) }
|
|
||||||
} label: {
|
|
||||||
if self.store.whatsappBusy {
|
|
||||||
ProgressView().controlSize(.small)
|
|
||||||
} else {
|
|
||||||
Text("Show QR")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.disabled(self.store.whatsappBusy)
|
|
||||||
|
|
||||||
Button("Relink") {
|
|
||||||
Task { await self.store.startWhatsAppLogin(force: true) }
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.disabled(self.store.whatsappBusy)
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var telegramSection: some View {
|
|
||||||
GroupBox("Telegram") {
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
|
||||||
self.providerHeader(
|
|
||||||
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 {
|
|
||||||
self.gridLabel("Bot token")
|
|
||||||
if self.showTelegramToken {
|
|
||||||
TextField("123:abc", text: self.$store.telegramToken)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
.disabled(self.isTelegramTokenLocked)
|
|
||||||
} else {
|
|
||||||
SecureField("123:abc", text: self.$store.telegramToken)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
.disabled(self.isTelegramTokenLocked)
|
|
||||||
}
|
|
||||||
Toggle("Show", isOn: self.$showTelegramToken)
|
|
||||||
.toggleStyle(.switch)
|
|
||||||
.disabled(self.isTelegramTokenLocked)
|
|
||||||
}
|
|
||||||
GridRow {
|
|
||||||
self.gridLabel("Require mention")
|
|
||||||
Toggle("", isOn: self.$store.telegramRequireMention)
|
|
||||||
.labelsHidden()
|
|
||||||
.toggleStyle(.checkbox)
|
|
||||||
}
|
|
||||||
GridRow {
|
|
||||||
self.gridLabel("Allow from")
|
|
||||||
TextField("123456789, @team", text: self.$store.telegramAllowFrom)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
}
|
|
||||||
GridRow {
|
|
||||||
self.gridLabel("Proxy")
|
|
||||||
TextField("socks5://localhost:9050", text: self.$store.telegramProxy)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
}
|
|
||||||
GridRow {
|
|
||||||
self.gridLabel("Webhook URL")
|
|
||||||
TextField("https://example.com/telegram-webhook", text: self.$store.telegramWebhookUrl)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
}
|
|
||||||
GridRow {
|
|
||||||
self.gridLabel("Webhook secret")
|
|
||||||
TextField("secret", text: self.$store.telegramWebhookSecret)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
}
|
|
||||||
GridRow {
|
|
||||||
self.gridLabel("Webhook path")
|
|
||||||
TextField("/telegram-webhook", text: self.$store.telegramWebhookPath)
|
|
||||||
.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var whatsAppTint: Color {
|
|
||||||
guard let status = self.store.snapshot?.whatsapp else { return .secondary }
|
|
||||||
if !status.linked { return .red }
|
|
||||||
if status.connected { return .green }
|
|
||||||
if status.lastError != nil { return .orange }
|
|
||||||
return .green
|
|
||||||
}
|
|
||||||
|
|
||||||
private var telegramTint: Color {
|
|
||||||
guard let status = self.store.snapshot?.telegram else { return .secondary }
|
|
||||||
if !status.configured { return .secondary }
|
|
||||||
if status.running { return .green }
|
|
||||||
if status.lastError != nil { return .orange }
|
|
||||||
return .secondary
|
|
||||||
}
|
|
||||||
|
|
||||||
private var whatsAppSummary: String {
|
|
||||||
guard let status = self.store.snapshot?.whatsapp else { return "Checking…" }
|
|
||||||
if !status.linked { return "Not linked" }
|
|
||||||
if status.connected { return "Connected" }
|
|
||||||
if status.running { return "Running" }
|
|
||||||
return "Linked"
|
|
||||||
}
|
|
||||||
|
|
||||||
private var telegramSummary: String {
|
|
||||||
guard let status = self.store.snapshot?.telegram else { return "Checking…" }
|
|
||||||
if !status.configured { return "Not configured" }
|
|
||||||
if status.running { return "Running" }
|
|
||||||
return "Configured"
|
|
||||||
}
|
|
||||||
|
|
||||||
private var whatsAppDetails: String? {
|
|
||||||
guard let status = self.store.snapshot?.whatsapp else { return nil }
|
|
||||||
var lines: [String] = []
|
|
||||||
if let e164 = status.`self`?.e164 ?? status.`self`?.jid {
|
|
||||||
lines.append("Linked as \(e164)")
|
|
||||||
}
|
|
||||||
if let age = status.authAgeMs {
|
|
||||||
lines.append("Auth age \(msToAge(age))")
|
|
||||||
}
|
|
||||||
if let last = self.date(fromMs: status.lastConnectedAt) {
|
|
||||||
lines.append("Last connect \(relativeAge(from: last))")
|
|
||||||
}
|
|
||||||
if let disconnect = status.lastDisconnect {
|
|
||||||
let when = self.date(fromMs: disconnect.at).map { relativeAge(from: $0) } ?? "unknown"
|
|
||||||
let code = disconnect.status.map { "status \($0)" } ?? "status unknown"
|
|
||||||
let err = disconnect.error ?? "disconnect"
|
|
||||||
lines.append("Last disconnect \(code) · \(err) · \(when)")
|
|
||||||
}
|
|
||||||
if status.reconnectAttempts > 0 {
|
|
||||||
lines.append("Reconnect attempts \(status.reconnectAttempts)")
|
|
||||||
}
|
|
||||||
if let msgAt = self.date(fromMs: status.lastMessageAt) {
|
|
||||||
lines.append("Last message \(relativeAge(from: msgAt))")
|
|
||||||
}
|
|
||||||
if let err = status.lastError, !err.isEmpty {
|
|
||||||
lines.append("Error: \(err)")
|
|
||||||
}
|
|
||||||
return lines.isEmpty ? nil : lines.joined(separator: " · ")
|
|
||||||
}
|
|
||||||
|
|
||||||
private var telegramDetails: String? {
|
|
||||||
guard let status = self.store.snapshot?.telegram else { return nil }
|
|
||||||
var lines: [String] = []
|
|
||||||
if let source = status.tokenSource {
|
|
||||||
lines.append("Token source: \(source)")
|
|
||||||
}
|
|
||||||
if let mode = status.mode {
|
|
||||||
lines.append("Mode: \(mode)")
|
|
||||||
}
|
|
||||||
if let probe = status.probe {
|
|
||||||
if probe.ok {
|
|
||||||
if let name = probe.bot?.username {
|
|
||||||
lines.append("Bot: @\(name)")
|
|
||||||
}
|
|
||||||
if let url = probe.webhook?.url, !url.isEmpty {
|
|
||||||
lines.append("Webhook: \(url)")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let code = probe.status.map { String($0) } ?? "unknown"
|
|
||||||
lines.append("Probe failed (\(code))")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let last = self.date(fromMs: status.lastProbeAt) {
|
|
||||||
lines.append("Last probe \(relativeAge(from: last))")
|
|
||||||
}
|
|
||||||
if let err = status.lastError, !err.isEmpty {
|
|
||||||
lines.append("Error: \(err)")
|
|
||||||
}
|
|
||||||
return lines.isEmpty ? nil : lines.joined(separator: " · ")
|
|
||||||
}
|
|
||||||
|
|
||||||
private var isTelegramTokenLocked: Bool {
|
|
||||||
self.store.snapshot?.telegram.tokenSource == "env"
|
|
||||||
}
|
|
||||||
|
|
||||||
private func providerHeader(title: String, color: Color, subtitle: String) -> some View {
|
|
||||||
HStack(spacing: 10) {
|
|
||||||
Circle()
|
|
||||||
.fill(color)
|
|
||||||
.frame(width: 10, height: 10)
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text(title)
|
|
||||||
.font(.headline)
|
|
||||||
Text(subtitle)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func gridLabel(_ text: String) -> some View {
|
|
||||||
Text(text)
|
|
||||||
.font(.callout.weight(.semibold))
|
|
||||||
.frame(width: 120, alignment: .leading)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func date(fromMs ms: Double?) -> Date? {
|
|
||||||
guard let ms else { return nil }
|
|
||||||
return Date(timeIntervalSince1970: ms / 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func qrImage(from dataUrl: String) -> NSImage? {
|
|
||||||
guard let comma = dataUrl.firstIndex(of: ",") else { return nil }
|
|
||||||
let header = dataUrl[..<comma]
|
|
||||||
guard header.contains("base64") else { return nil }
|
|
||||||
let base64 = dataUrl[dataUrl.index(after: comma)...]
|
|
||||||
guard let data = Data(base64Encoded: String(base64)) else { return nil }
|
|
||||||
return NSImage(data: data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,393 +0,0 @@
|
|||||||
import ClawdisProtocol
|
|
||||||
import Foundation
|
|
||||||
import Observation
|
|
||||||
|
|
||||||
struct ProvidersStatusSnapshot: Codable {
|
|
||||||
struct WhatsAppSelf: Codable {
|
|
||||||
let e164: String?
|
|
||||||
let jid: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct WhatsAppDisconnect: Codable {
|
|
||||||
let at: Double
|
|
||||||
let status: Int?
|
|
||||||
let error: String?
|
|
||||||
let loggedOut: Bool?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct WhatsAppStatus: Codable {
|
|
||||||
let configured: Bool
|
|
||||||
let linked: Bool
|
|
||||||
let authAgeMs: Double?
|
|
||||||
let `self`: WhatsAppSelf?
|
|
||||||
let running: Bool
|
|
||||||
let connected: Bool
|
|
||||||
let lastConnectedAt: Double?
|
|
||||||
let lastDisconnect: WhatsAppDisconnect?
|
|
||||||
let reconnectAttempts: Int
|
|
||||||
let lastMessageAt: Double?
|
|
||||||
let lastEventAt: Double?
|
|
||||||
let lastError: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TelegramBot: Codable {
|
|
||||||
let id: Int?
|
|
||||||
let username: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TelegramWebhook: Codable {
|
|
||||||
let url: String?
|
|
||||||
let hasCustomCert: Bool?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TelegramProbe: Codable {
|
|
||||||
let ok: Bool
|
|
||||||
let status: Int?
|
|
||||||
let error: String?
|
|
||||||
let elapsedMs: Double?
|
|
||||||
let bot: TelegramBot?
|
|
||||||
let webhook: TelegramWebhook?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TelegramStatus: Codable {
|
|
||||||
let configured: Bool
|
|
||||||
let tokenSource: String?
|
|
||||||
let running: Bool
|
|
||||||
let mode: String?
|
|
||||||
let lastStartAt: Double?
|
|
||||||
let lastStopAt: Double?
|
|
||||||
let lastError: String?
|
|
||||||
let probe: TelegramProbe?
|
|
||||||
let lastProbeAt: Double?
|
|
||||||
}
|
|
||||||
|
|
||||||
let ts: Double
|
|
||||||
let whatsapp: WhatsAppStatus
|
|
||||||
let telegram: TelegramStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ConfigSnapshot: Codable {
|
|
||||||
struct Issue: Codable {
|
|
||||||
let path: String
|
|
||||||
let message: String
|
|
||||||
}
|
|
||||||
|
|
||||||
let path: String?
|
|
||||||
let exists: Bool?
|
|
||||||
let raw: String?
|
|
||||||
let parsed: AnyCodable?
|
|
||||||
let valid: Bool?
|
|
||||||
let config: [String: AnyCodable]?
|
|
||||||
let issues: [Issue]?
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Observable
|
|
||||||
final class ConnectionsStore {
|
|
||||||
static let shared = ConnectionsStore()
|
|
||||||
|
|
||||||
var snapshot: ProvidersStatusSnapshot?
|
|
||||||
var lastError: String?
|
|
||||||
var lastSuccess: Date?
|
|
||||||
var isRefreshing = false
|
|
||||||
|
|
||||||
var whatsappLoginMessage: String?
|
|
||||||
var whatsappLoginQrDataUrl: String?
|
|
||||||
var whatsappLoginConnected: Bool?
|
|
||||||
var whatsappBusy = false
|
|
||||||
|
|
||||||
var telegramToken: String = ""
|
|
||||||
var telegramRequireMention = true
|
|
||||||
var telegramAllowFrom: String = ""
|
|
||||||
var telegramProxy: String = ""
|
|
||||||
var telegramWebhookUrl: String = ""
|
|
||||||
var telegramWebhookSecret: String = ""
|
|
||||||
var telegramWebhookPath: String = ""
|
|
||||||
var telegramBusy = false
|
|
||||||
var configStatus: String?
|
|
||||||
var isSavingConfig = false
|
|
||||||
|
|
||||||
private let interval: TimeInterval = 45
|
|
||||||
private let isPreview: Bool
|
|
||||||
private var pollTask: Task<Void, Never>?
|
|
||||||
private var configRoot: [String: Any] = [:]
|
|
||||||
private var configLoaded = false
|
|
||||||
|
|
||||||
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
|
|
||||||
self.isPreview = isPreview
|
|
||||||
}
|
|
||||||
|
|
||||||
func start() {
|
|
||||||
guard !self.isPreview else { return }
|
|
||||||
guard self.pollTask == nil else { return }
|
|
||||||
self.pollTask = Task.detached { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
await self.refresh(probe: true)
|
|
||||||
await self.loadConfig()
|
|
||||||
while !Task.isCancelled {
|
|
||||||
try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000))
|
|
||||||
await self.refresh(probe: false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func stop() {
|
|
||||||
self.pollTask?.cancel()
|
|
||||||
self.pollTask = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func refresh(probe: Bool) async {
|
|
||||||
guard !self.isRefreshing else { return }
|
|
||||||
self.isRefreshing = true
|
|
||||||
defer { self.isRefreshing = false }
|
|
||||||
|
|
||||||
do {
|
|
||||||
let params: [String: AnyCodable] = [
|
|
||||||
"probe": AnyCodable(probe),
|
|
||||||
"timeoutMs": AnyCodable(8000),
|
|
||||||
]
|
|
||||||
let snap: ProvidersStatusSnapshot = try await GatewayConnection.shared.requestDecoded(
|
|
||||||
method: .providersStatus,
|
|
||||||
params: params,
|
|
||||||
timeoutMs: 12000)
|
|
||||||
self.snapshot = snap
|
|
||||||
self.lastSuccess = Date()
|
|
||||||
self.lastError = nil
|
|
||||||
} catch {
|
|
||||||
self.lastError = error.localizedDescription
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func startWhatsAppLogin(force: Bool, autoWait: Bool = true) async {
|
|
||||||
guard !self.whatsappBusy else { return }
|
|
||||||
self.whatsappBusy = true
|
|
||||||
defer { self.whatsappBusy = false }
|
|
||||||
var shouldAutoWait = false
|
|
||||||
do {
|
|
||||||
let params: [String: AnyCodable] = [
|
|
||||||
"force": AnyCodable(force),
|
|
||||||
"timeoutMs": AnyCodable(30000),
|
|
||||||
]
|
|
||||||
let result: WhatsAppLoginStartResult = try await GatewayConnection.shared.requestDecoded(
|
|
||||||
method: .webLoginStart,
|
|
||||||
params: params,
|
|
||||||
timeoutMs: 35000)
|
|
||||||
self.whatsappLoginMessage = result.message
|
|
||||||
self.whatsappLoginQrDataUrl = result.qrDataUrl
|
|
||||||
self.whatsappLoginConnected = nil
|
|
||||||
shouldAutoWait = autoWait && result.qrDataUrl != nil
|
|
||||||
} catch {
|
|
||||||
self.whatsappLoginMessage = error.localizedDescription
|
|
||||||
self.whatsappLoginQrDataUrl = nil
|
|
||||||
self.whatsappLoginConnected = nil
|
|
||||||
}
|
|
||||||
await self.refresh(probe: true)
|
|
||||||
if shouldAutoWait {
|
|
||||||
Task { await self.waitWhatsAppLogin() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func waitWhatsAppLogin(timeoutMs: Int = 120_000) async {
|
|
||||||
guard !self.whatsappBusy else { return }
|
|
||||||
self.whatsappBusy = true
|
|
||||||
defer { self.whatsappBusy = false }
|
|
||||||
do {
|
|
||||||
let params: [String: AnyCodable] = [
|
|
||||||
"timeoutMs": AnyCodable(timeoutMs),
|
|
||||||
]
|
|
||||||
let result: WhatsAppLoginWaitResult = try await GatewayConnection.shared.requestDecoded(
|
|
||||||
method: .webLoginWait,
|
|
||||||
params: params,
|
|
||||||
timeoutMs: Double(timeoutMs) + 5000)
|
|
||||||
self.whatsappLoginMessage = result.message
|
|
||||||
self.whatsappLoginConnected = result.connected
|
|
||||||
if result.connected {
|
|
||||||
self.whatsappLoginQrDataUrl = nil
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
self.whatsappLoginMessage = error.localizedDescription
|
|
||||||
}
|
|
||||||
await self.refresh(probe: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func logoutWhatsApp() async {
|
|
||||||
guard !self.whatsappBusy else { return }
|
|
||||||
self.whatsappBusy = true
|
|
||||||
defer { self.whatsappBusy = false }
|
|
||||||
do {
|
|
||||||
let result: WhatsAppLogoutResult = try await GatewayConnection.shared.requestDecoded(
|
|
||||||
method: .webLogout,
|
|
||||||
params: nil,
|
|
||||||
timeoutMs: 15000)
|
|
||||||
self.whatsappLoginMessage = result.cleared
|
|
||||||
? "Logged out and cleared credentials."
|
|
||||||
: "No WhatsApp session found."
|
|
||||||
self.whatsappLoginQrDataUrl = nil
|
|
||||||
} catch {
|
|
||||||
self.whatsappLoginMessage = error.localizedDescription
|
|
||||||
}
|
|
||||||
await self.refresh(probe: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func logoutTelegram() async {
|
|
||||||
guard !self.telegramBusy else { return }
|
|
||||||
self.telegramBusy = true
|
|
||||||
defer { self.telegramBusy = false }
|
|
||||||
do {
|
|
||||||
let result: TelegramLogoutResult = try await GatewayConnection.shared.requestDecoded(
|
|
||||||
method: .telegramLogout,
|
|
||||||
params: nil,
|
|
||||||
timeoutMs: 15000)
|
|
||||||
if result.envToken == true {
|
|
||||||
self.configStatus = "Telegram token still set via env; config cleared."
|
|
||||||
} else {
|
|
||||||
self.configStatus = result.cleared
|
|
||||||
? "Telegram token cleared."
|
|
||||||
: "No Telegram token configured."
|
|
||||||
}
|
|
||||||
await self.loadConfig()
|
|
||||||
} catch {
|
|
||||||
self.configStatus = error.localizedDescription
|
|
||||||
}
|
|
||||||
await self.refresh(probe: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadConfig() async {
|
|
||||||
do {
|
|
||||||
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
|
|
||||||
method: .configGet,
|
|
||||||
params: nil,
|
|
||||||
timeoutMs: 10000)
|
|
||||||
self.configStatus = snap.valid == false
|
|
||||||
? "Config invalid; fix it in ~/.clawdis/clawdis.json."
|
|
||||||
: nil
|
|
||||||
self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:]
|
|
||||||
self.configLoaded = true
|
|
||||||
let telegram = snap.config?["telegram"]?.dictionaryValue
|
|
||||||
self.telegramToken = telegram?["botToken"]?.stringValue ?? ""
|
|
||||||
self.telegramRequireMention = telegram?["requireMention"]?.boolValue ?? true
|
|
||||||
if let allow = telegram?["allowFrom"]?.arrayValue {
|
|
||||||
let strings = allow.compactMap { entry -> String? in
|
|
||||||
if let str = entry.stringValue { return str }
|
|
||||||
if let intVal = entry.intValue { return String(intVal) }
|
|
||||||
if let doubleVal = entry.doubleValue { return String(Int(doubleVal)) }
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
self.telegramAllowFrom = strings.joined(separator: ", ")
|
|
||||||
} else {
|
|
||||||
self.telegramAllowFrom = ""
|
|
||||||
}
|
|
||||||
self.telegramProxy = telegram?["proxy"]?.stringValue ?? ""
|
|
||||||
self.telegramWebhookUrl = telegram?["webhookUrl"]?.stringValue ?? ""
|
|
||||||
self.telegramWebhookSecret = telegram?["webhookSecret"]?.stringValue ?? ""
|
|
||||||
self.telegramWebhookPath = telegram?["webhookPath"]?.stringValue ?? ""
|
|
||||||
} catch {
|
|
||||||
self.configStatus = error.localizedDescription
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func saveTelegramConfig() async {
|
|
||||||
guard !self.isSavingConfig else { return }
|
|
||||||
self.isSavingConfig = true
|
|
||||||
defer { self.isSavingConfig = false }
|
|
||||||
if !self.configLoaded {
|
|
||||||
await self.loadConfig()
|
|
||||||
}
|
|
||||||
|
|
||||||
var telegram: [String: Any] = (self.configRoot["telegram"] as? [String: Any]) ?? [:]
|
|
||||||
let token = self.telegramToken.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if token.isEmpty {
|
|
||||||
telegram.removeValue(forKey: "botToken")
|
|
||||||
} else {
|
|
||||||
telegram["botToken"] = token
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.telegramRequireMention {
|
|
||||||
telegram["requireMention"] = true
|
|
||||||
} else {
|
|
||||||
telegram["requireMention"] = false
|
|
||||||
}
|
|
||||||
|
|
||||||
let allow = self.telegramAllowFrom
|
|
||||||
.split(separator: ",")
|
|
||||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
||||||
.filter { !$0.isEmpty }
|
|
||||||
if allow.isEmpty {
|
|
||||||
telegram.removeValue(forKey: "allowFrom")
|
|
||||||
} else {
|
|
||||||
telegram["allowFrom"] = allow
|
|
||||||
}
|
|
||||||
|
|
||||||
let proxy = self.telegramProxy.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if proxy.isEmpty {
|
|
||||||
telegram.removeValue(forKey: "proxy")
|
|
||||||
} else {
|
|
||||||
telegram["proxy"] = proxy
|
|
||||||
}
|
|
||||||
|
|
||||||
let webhookUrl = self.telegramWebhookUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if webhookUrl.isEmpty {
|
|
||||||
telegram.removeValue(forKey: "webhookUrl")
|
|
||||||
} else {
|
|
||||||
telegram["webhookUrl"] = webhookUrl
|
|
||||||
}
|
|
||||||
|
|
||||||
let webhookSecret = self.telegramWebhookSecret.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if webhookSecret.isEmpty {
|
|
||||||
telegram.removeValue(forKey: "webhookSecret")
|
|
||||||
} else {
|
|
||||||
telegram["webhookSecret"] = webhookSecret
|
|
||||||
}
|
|
||||||
|
|
||||||
let webhookPath = self.telegramWebhookPath.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if webhookPath.isEmpty {
|
|
||||||
telegram.removeValue(forKey: "webhookPath")
|
|
||||||
} else {
|
|
||||||
telegram["webhookPath"] = webhookPath
|
|
||||||
}
|
|
||||||
|
|
||||||
if telegram.isEmpty {
|
|
||||||
self.configRoot.removeValue(forKey: "telegram")
|
|
||||||
} else {
|
|
||||||
self.configRoot["telegram"] = telegram
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
let data = try JSONSerialization.data(
|
|
||||||
withJSONObject: self.configRoot,
|
|
||||||
options: [.prettyPrinted, .sortedKeys])
|
|
||||||
guard let raw = String(data: data, encoding: .utf8) else {
|
|
||||||
self.configStatus = "Failed to encode config."
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
|
|
||||||
_ = try await GatewayConnection.shared.requestRaw(
|
|
||||||
method: .configSet,
|
|
||||||
params: params,
|
|
||||||
timeoutMs: 10000)
|
|
||||||
self.configStatus = "Saved to ~/.clawdis/clawdis.json."
|
|
||||||
await self.refresh(probe: true)
|
|
||||||
} catch {
|
|
||||||
self.configStatus = error.localizedDescription
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct WhatsAppLoginStartResult: Codable {
|
|
||||||
let qrDataUrl: String?
|
|
||||||
let message: String
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct WhatsAppLoginWaitResult: Codable {
|
|
||||||
let connected: Bool
|
|
||||||
let message: String
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct WhatsAppLogoutResult: Codable {
|
|
||||||
let cleared: Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct TelegramLogoutResult: Codable {
|
|
||||||
let cleared: Bool
|
|
||||||
let envToken: Bool?
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ import SwiftUI
|
|||||||
|
|
||||||
struct CritterStatusLabel: View {
|
struct CritterStatusLabel: View {
|
||||||
var isPaused: Bool
|
var isPaused: Bool
|
||||||
var isSleeping: Bool
|
|
||||||
var isWorking: Bool
|
var isWorking: Bool
|
||||||
var earBoostActive: Bool
|
var earBoostActive: Bool
|
||||||
var blinkTick: Int
|
var blinkTick: Int
|
||||||
@@ -26,10 +25,6 @@ struct CritterStatusLabel: View {
|
|||||||
self.iconState.isWorking || self.isWorking
|
self.iconState.isWorking || self.isWorking
|
||||||
}
|
}
|
||||||
|
|
||||||
private var effectiveAnimationsEnabled: Bool {
|
|
||||||
self.animationsEnabled && !self.isSleeping
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .topTrailing) {
|
ZStack(alignment: .topTrailing) {
|
||||||
self.iconImage
|
self.iconImage
|
||||||
@@ -39,7 +34,7 @@ struct CritterStatusLabel: View {
|
|||||||
// Avoid Combine's TimerPublisher here: on macOS 26.2 we've seen crashes inside executor checks
|
// Avoid Combine's TimerPublisher here: on macOS 26.2 we've seen crashes inside executor checks
|
||||||
// triggered by its callbacks. Drive periodic updates via a Swift-concurrency task instead.
|
// triggered by its callbacks. Drive periodic updates via a Swift-concurrency task instead.
|
||||||
.task(id: self.tickTaskID) {
|
.task(id: self.tickTaskID) {
|
||||||
guard self.effectiveAnimationsEnabled, !self.earBoostActive else {
|
guard self.animationsEnabled, !self.earBoostActive else {
|
||||||
await MainActor.run { self.resetMotion() }
|
await MainActor.run { self.resetMotion() }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -52,27 +47,24 @@ struct CritterStatusLabel: View {
|
|||||||
}
|
}
|
||||||
.onChange(of: self.isPaused) { _, _ in self.resetMotion() }
|
.onChange(of: self.isPaused) { _, _ in self.resetMotion() }
|
||||||
.onChange(of: self.blinkTick) { _, _ in
|
.onChange(of: self.blinkTick) { _, _ in
|
||||||
guard self.effectiveAnimationsEnabled, !self.earBoostActive else { return }
|
guard !self.earBoostActive else { return }
|
||||||
self.blink()
|
self.blink()
|
||||||
}
|
}
|
||||||
.onChange(of: self.sendCelebrationTick) { _, _ in
|
.onChange(of: self.sendCelebrationTick) { _, _ in
|
||||||
guard self.effectiveAnimationsEnabled, !self.earBoostActive else { return }
|
guard !self.earBoostActive else { return }
|
||||||
self.wiggleLegs()
|
self.wiggleLegs()
|
||||||
}
|
}
|
||||||
.onChange(of: self.animationsEnabled) { _, enabled in
|
.onChange(of: self.animationsEnabled) { _, enabled in
|
||||||
if enabled, !self.isSleeping {
|
if enabled {
|
||||||
self.scheduleRandomTimers(from: Date())
|
self.scheduleRandomTimers(from: Date())
|
||||||
} else {
|
} else {
|
||||||
self.resetMotion()
|
self.resetMotion()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: self.isSleeping) { _, _ in
|
|
||||||
self.resetMotion()
|
|
||||||
}
|
|
||||||
.onChange(of: self.earBoostActive) { _, active in
|
.onChange(of: self.earBoostActive) { _, active in
|
||||||
if active {
|
if active {
|
||||||
self.resetMotion()
|
self.resetMotion()
|
||||||
} else if self.effectiveAnimationsEnabled {
|
} else if self.animationsEnabled {
|
||||||
self.scheduleRandomTimers(from: Date())
|
self.scheduleRandomTimers(from: Date())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,11 +81,11 @@ struct CritterStatusLabel: View {
|
|||||||
|
|
||||||
private var tickTaskID: Int {
|
private var tickTaskID: Int {
|
||||||
// Ensure SwiftUI restarts (and cancels) the task when these change.
|
// Ensure SwiftUI restarts (and cancels) the task when these change.
|
||||||
(self.effectiveAnimationsEnabled ? 1 : 0) | (self.earBoostActive ? 2 : 0)
|
(self.animationsEnabled ? 1 : 0) | (self.earBoostActive ? 2 : 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func tick(_ now: Date) {
|
private func tick(_ now: Date) {
|
||||||
guard self.effectiveAnimationsEnabled, !self.earBoostActive else {
|
guard self.animationsEnabled, !self.earBoostActive else {
|
||||||
self.resetMotion()
|
self.resetMotion()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -136,10 +128,6 @@ struct CritterStatusLabel: View {
|
|||||||
return Image(nsImage: CritterIconRenderer.makeIcon(blink: 0, badge: nil))
|
return Image(nsImage: CritterIconRenderer.makeIcon(blink: 0, badge: nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.isSleeping {
|
|
||||||
return Image(nsImage: CritterIconRenderer.makeIcon(blink: 1, eyesClosedLines: true, badge: nil))
|
|
||||||
}
|
|
||||||
|
|
||||||
return Image(nsImage: CritterIconRenderer.makeIcon(
|
return Image(nsImage: CritterIconRenderer.makeIcon(
|
||||||
blink: self.blinkAmount,
|
blink: self.blinkAmount,
|
||||||
legWiggle: max(self.legWiggle, self.isWorkingNow ? 0.6 : 0),
|
legWiggle: max(self.legWiggle, self.isWorkingNow ? 0.6 : 0),
|
||||||
@@ -228,12 +216,11 @@ struct CritterStatusLabel: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var gatewayNeedsAttention: Bool {
|
private var gatewayNeedsAttention: Bool {
|
||||||
if self.isSleeping { return false }
|
|
||||||
switch self.gatewayStatus {
|
switch self.gatewayStatus {
|
||||||
case .failed, .stopped:
|
case .failed, .stopped:
|
||||||
return !self.isPaused
|
!self.isPaused
|
||||||
case .starting, .running, .attachedExisting:
|
case .starting, .running, .attachedExisting:
|
||||||
return false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,7 +255,6 @@ enum CritterIconRenderer {
|
|||||||
earWiggle: CGFloat = 0,
|
earWiggle: CGFloat = 0,
|
||||||
earScale: CGFloat = 1,
|
earScale: CGFloat = 1,
|
||||||
earHoles: Bool = false,
|
earHoles: Bool = false,
|
||||||
eyesClosedLines: Bool = false,
|
|
||||||
badge: Badge? = nil) -> NSImage
|
badge: Badge? = nil) -> NSImage
|
||||||
{
|
{
|
||||||
// Force a 36×36px backing store (2× for the 18pt logical canvas) so the menu bar icon stays crisp on Retina.
|
// Force a 36×36px backing store (2× for the 18pt logical canvas) so the menu bar icon stays crisp on Retina.
|
||||||
@@ -334,7 +320,9 @@ enum CritterIconRenderer {
|
|||||||
let legLift = snapY(legH * 0.35 * legWiggle)
|
let legLift = snapY(legH * 0.35 * legWiggle)
|
||||||
let legYBase = snapY(bodyY - legH + h * 0.05)
|
let legYBase = snapY(bodyY - legH + h * 0.05)
|
||||||
|
|
||||||
|
let eyeOpen = max(0.05, 1 - blink)
|
||||||
let eyeW = snapX(bodyW * 0.2)
|
let eyeW = snapX(bodyW * 0.2)
|
||||||
|
let eyeH = snapY(bodyH * 0.26 * eyeOpen)
|
||||||
let eyeY = snapY(bodyY + bodyH * 0.56)
|
let eyeY = snapY(bodyY + bodyH * 0.56)
|
||||||
let eyeOffset = snapX(bodyW * 0.24)
|
let eyeOffset = snapX(bodyW * 0.24)
|
||||||
|
|
||||||
@@ -404,50 +392,20 @@ enum CritterIconRenderer {
|
|||||||
transform: nil))
|
transform: nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
if eyesClosedLines {
|
let left = CGMutablePath()
|
||||||
let lineW = snapX(eyeW * 0.95)
|
left.move(to: CGPoint(x: snapX(leftCenter.x - eyeW / 2), y: snapY(leftCenter.y - eyeH)))
|
||||||
let lineH = snapY(max(stepY * 2, bodyH * 0.06))
|
left.addLine(to: CGPoint(x: snapX(leftCenter.x + eyeW / 2), y: snapY(leftCenter.y)))
|
||||||
let corner = snapX(lineH * 0.6)
|
left.addLine(to: CGPoint(x: snapX(leftCenter.x - eyeW / 2), y: snapY(leftCenter.y + eyeH)))
|
||||||
let leftRect = CGRect(
|
left.closeSubpath()
|
||||||
x: snapX(leftCenter.x - lineW / 2),
|
|
||||||
y: snapY(leftCenter.y - lineH / 2),
|
|
||||||
width: lineW,
|
|
||||||
height: lineH)
|
|
||||||
let rightRect = CGRect(
|
|
||||||
x: snapX(rightCenter.x - lineW / 2),
|
|
||||||
y: snapY(rightCenter.y - lineH / 2),
|
|
||||||
width: lineW,
|
|
||||||
height: lineH)
|
|
||||||
context.cgContext.addPath(CGPath(
|
|
||||||
roundedRect: leftRect,
|
|
||||||
cornerWidth: corner,
|
|
||||||
cornerHeight: corner,
|
|
||||||
transform: nil))
|
|
||||||
context.cgContext.addPath(CGPath(
|
|
||||||
roundedRect: rightRect,
|
|
||||||
cornerWidth: corner,
|
|
||||||
cornerHeight: corner,
|
|
||||||
transform: nil))
|
|
||||||
} else {
|
|
||||||
let eyeOpen = max(0.05, 1 - blink)
|
|
||||||
let eyeH = snapY(bodyH * 0.26 * eyeOpen)
|
|
||||||
|
|
||||||
let left = CGMutablePath()
|
let right = CGMutablePath()
|
||||||
left.move(to: CGPoint(x: snapX(leftCenter.x - eyeW / 2), y: snapY(leftCenter.y - eyeH)))
|
right.move(to: CGPoint(x: snapX(rightCenter.x + eyeW / 2), y: snapY(rightCenter.y - eyeH)))
|
||||||
left.addLine(to: CGPoint(x: snapX(leftCenter.x + eyeW / 2), y: snapY(leftCenter.y)))
|
right.addLine(to: CGPoint(x: snapX(rightCenter.x - eyeW / 2), y: snapY(rightCenter.y)))
|
||||||
left.addLine(to: CGPoint(x: snapX(leftCenter.x - eyeW / 2), y: snapY(leftCenter.y + eyeH)))
|
right.addLine(to: CGPoint(x: snapX(rightCenter.x + eyeW / 2), y: snapY(rightCenter.y + eyeH)))
|
||||||
left.closeSubpath()
|
right.closeSubpath()
|
||||||
|
|
||||||
let right = CGMutablePath()
|
|
||||||
right.move(to: CGPoint(x: snapX(rightCenter.x + eyeW / 2), y: snapY(rightCenter.y - eyeH)))
|
|
||||||
right.addLine(to: CGPoint(x: snapX(rightCenter.x - eyeW / 2), y: snapY(rightCenter.y)))
|
|
||||||
right.addLine(to: CGPoint(x: snapX(rightCenter.x + eyeW / 2), y: snapY(rightCenter.y + eyeH)))
|
|
||||||
right.closeSubpath()
|
|
||||||
|
|
||||||
context.cgContext.addPath(left)
|
|
||||||
context.cgContext.addPath(right)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
context.cgContext.addPath(left)
|
||||||
|
context.cgContext.addPath(right)
|
||||||
context.cgContext.fillPath()
|
context.cgContext.fillPath()
|
||||||
context.cgContext.restoreGState()
|
context.cgContext.restoreGState()
|
||||||
|
|
||||||
|
|||||||
@@ -109,61 +109,24 @@ actor GatewayConnection {
|
|||||||
do {
|
do {
|
||||||
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
|
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
|
||||||
} catch {
|
} catch {
|
||||||
if error is GatewayResponseError || error is GatewayDecodingError {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-recover in local mode by spawning/attaching a gateway and retrying a few times.
|
// Auto-recover in local mode by spawning/attaching a gateway and retrying a few times.
|
||||||
// Canvas interactions should "just work" even if the local gateway isn't running yet.
|
// Canvas interactions should "just work" even if the local gateway isn't running yet.
|
||||||
let mode = await MainActor.run { AppStateStore.shared.connectionMode }
|
let isLocal = await MainActor.run { AppStateStore.shared.connectionMode == .local }
|
||||||
switch mode {
|
guard isLocal else { throw error }
|
||||||
case .local:
|
|
||||||
await MainActor.run { GatewayProcessManager.shared.setActive(true) }
|
|
||||||
|
|
||||||
var lastError: Error = error
|
await MainActor.run { GatewayProcessManager.shared.setActive(true) }
|
||||||
for delayMs in [150, 400, 900] {
|
|
||||||
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
|
|
||||||
do {
|
|
||||||
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
|
|
||||||
} catch {
|
|
||||||
lastError = error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw lastError
|
var lastError: Error = error
|
||||||
case .remote:
|
for delayMs in [150, 400, 900] {
|
||||||
let nsError = error as NSError
|
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
|
||||||
guard nsError.domain == URLError.errorDomain else { throw error }
|
|
||||||
|
|
||||||
var lastError: Error = error
|
|
||||||
await RemoteTunnelManager.shared.stopAll()
|
|
||||||
do {
|
do {
|
||||||
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
|
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
|
||||||
} catch {
|
} catch {
|
||||||
lastError = error
|
lastError = error
|
||||||
}
|
}
|
||||||
|
|
||||||
for delayMs in [150, 400, 900] {
|
|
||||||
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
|
|
||||||
do {
|
|
||||||
let cfg = try await self.configProvider()
|
|
||||||
await self.configure(url: cfg.url, token: cfg.token)
|
|
||||||
guard let client = self.client else {
|
|
||||||
throw NSError(
|
|
||||||
domain: "Gateway",
|
|
||||||
code: 0,
|
|
||||||
userInfo: [NSLocalizedDescriptionKey: "gateway not configured"])
|
|
||||||
}
|
|
||||||
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
|
|
||||||
} catch {
|
|
||||||
lastError = error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw lastError
|
|
||||||
case .unconfigured:
|
|
||||||
throw error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
throw lastError
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -327,8 +327,10 @@ final class GatewayDiscoveryModel {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if let service = normalizeServiceToken(serviceName) {
|
if let service = normalizeServiceToken(serviceName) {
|
||||||
for token in local.hostTokens where service.contains(token) {
|
for token in local.hostTokens {
|
||||||
return true
|
if service.contains(token) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -454,9 +456,8 @@ final class GatewayTXTResolver: NSObject, NetServiceDelegate {
|
|||||||
func netServiceDidResolveAddress(_ sender: NetService) {
|
func netServiceDidResolveAddress(_ sender: NetService) {
|
||||||
let txt = Self.decodeTXT(sender.txtRecordData())
|
let txt = Self.decodeTXT(sender.txtRecordData())
|
||||||
if !txt.isEmpty {
|
if !txt.isEmpty {
|
||||||
let payload = self.formatTXT(txt)
|
|
||||||
self.logger.debug(
|
self.logger.debug(
|
||||||
"discovery: resolved TXT for \(sender.name, privacy: .public): \(payload, privacy: .public)")
|
"discovery: resolved TXT for \(sender.name, privacy: .public): \(self.formatTXT(txt), privacy: .public)")
|
||||||
}
|
}
|
||||||
self.finish(result: .success(txt))
|
self.finish(result: .success(txt))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,9 +36,7 @@ enum GatewayLaunchAgentManager {
|
|||||||
? "Failed to bootstrap gateway launchd job"
|
? "Failed to bootstrap gateway launchd job"
|
||||||
: bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
: bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
}
|
}
|
||||||
// Note: removed redundant `kickstart -k` that caused race condition.
|
_ = await self.runLaunchctl(["kickstart", "-k", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||||
// bootstrap already starts the job; kickstart -k would kill it immediately
|
|
||||||
// and with KeepAlive=true, cause a restart loop with port conflicts.
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,10 +30,7 @@ final class GatewayProcessManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private(set) var status: Status = .stopped {
|
private(set) var status: Status = .stopped
|
||||||
didSet { CanvasManager.shared.refreshDebugStatus() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private(set) var log: String = ""
|
private(set) var log: String = ""
|
||||||
private(set) var environmentStatus: GatewayEnvironmentStatus = .checking
|
private(set) var environmentStatus: GatewayEnvironmentStatus = .checking
|
||||||
private(set) var existingGatewayDetails: String?
|
private(set) var existingGatewayDetails: String?
|
||||||
|
|||||||
@@ -126,17 +126,12 @@ struct GeneralSettings: View {
|
|||||||
|
|
||||||
if self.state.connectionMode == .local {
|
if self.state.connectionMode == .local {
|
||||||
self.gatewayInstallerCard
|
self.gatewayInstallerCard
|
||||||
TailscaleIntegrationSection(
|
|
||||||
connectionMode: self.state.connectionMode,
|
|
||||||
isPaused: self.state.isPaused)
|
|
||||||
self.healthRow
|
self.healthRow
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.state.connectionMode == .remote {
|
if self.state.connectionMode == .remote {
|
||||||
self.remoteCard
|
self.remoteCard
|
||||||
}
|
}
|
||||||
|
|
||||||
self.cliInstaller
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -588,8 +583,7 @@ extension GeneralSettings {
|
|||||||
trimmed.localizedCaseInsensitiveContains("host key verification failed")
|
trimmed.localizedCaseInsensitiveContains("host key verification failed")
|
||||||
{
|
{
|
||||||
let host = CommandResolver.parseSSHTarget(target)?.host ?? target
|
let host = CommandResolver.parseSSHTarget(target)?.host ?? target
|
||||||
return "SSH check failed: Host key verification failed. Remove the old key with " +
|
return "SSH check failed: Host key verification failed. Remove the old key with `ssh-keygen -R \(host)` and try again."
|
||||||
"`ssh-keygen -R \(host)` and try again."
|
|
||||||
}
|
}
|
||||||
if let trimmed, !trimmed.isEmpty {
|
if let trimmed, !trimmed.isEmpty {
|
||||||
if let message = response.message, message.hasPrefix("exit ") {
|
if let message = response.message, message.hasPrefix("exit ") {
|
||||||
@@ -648,7 +642,6 @@ struct GeneralSettings_Previews: PreviewProvider {
|
|||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
GeneralSettings(state: .preview)
|
GeneralSettings(state: .preview)
|
||||||
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
||||||
.environment(TailscaleService.shared)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -2,7 +2,6 @@ import AppKit
|
|||||||
import Darwin
|
import Darwin
|
||||||
import Foundation
|
import Foundation
|
||||||
import MenuBarExtraAccess
|
import MenuBarExtraAccess
|
||||||
import Observation
|
|
||||||
import OSLog
|
import OSLog
|
||||||
import Security
|
import Security
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -12,12 +11,11 @@ struct ClawdisApp: App {
|
|||||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
|
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
|
||||||
@State private var state: AppState
|
@State private var state: AppState
|
||||||
private let gatewayManager = GatewayProcessManager.shared
|
private let gatewayManager = GatewayProcessManager.shared
|
||||||
private let controlChannel = ControlChannel.shared
|
|
||||||
private let activityStore = WorkActivityStore.shared
|
private let activityStore = WorkActivityStore.shared
|
||||||
@State private var statusItem: NSStatusItem?
|
@State private var statusItem: NSStatusItem?
|
||||||
@State private var isMenuPresented = false
|
@State private var isMenuPresented = false
|
||||||
@State private var isPanelVisible = false
|
@State private var isPanelVisible = false
|
||||||
@State private var tailscaleService = TailscaleService.shared
|
@State private var menuInjector = MenuContextCardInjector.shared
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func updateStatusHighlight() {
|
private func updateStatusHighlight() {
|
||||||
@@ -37,37 +35,30 @@ struct ClawdisApp: App {
|
|||||||
MenuBarExtra { MenuContent(state: self.state, updater: self.delegate.updaterController) } label: {
|
MenuBarExtra { MenuContent(state: self.state, updater: self.delegate.updaterController) } label: {
|
||||||
CritterStatusLabel(
|
CritterStatusLabel(
|
||||||
isPaused: self.state.isPaused,
|
isPaused: self.state.isPaused,
|
||||||
isSleeping: self.isGatewaySleeping,
|
|
||||||
isWorking: self.state.isWorking,
|
isWorking: self.state.isWorking,
|
||||||
earBoostActive: self.state.earBoostActive,
|
earBoostActive: self.state.earBoostActive,
|
||||||
blinkTick: self.state.blinkTick,
|
blinkTick: self.state.blinkTick,
|
||||||
sendCelebrationTick: self.state.sendCelebrationTick,
|
sendCelebrationTick: self.state.sendCelebrationTick,
|
||||||
gatewayStatus: self.gatewayManager.status,
|
gatewayStatus: self.gatewayManager.status,
|
||||||
animationsEnabled: self.state.iconAnimationsEnabled && !self.isGatewaySleeping,
|
animationsEnabled: self.state.iconAnimationsEnabled,
|
||||||
iconState: self.effectiveIconState)
|
iconState: self.effectiveIconState)
|
||||||
}
|
}
|
||||||
.menuBarExtraStyle(.menu)
|
.menuBarExtraStyle(.menu)
|
||||||
.menuBarExtraAccess(isPresented: self.$isMenuPresented) { item in
|
.menuBarExtraAccess(isPresented: self.$isMenuPresented) { item in
|
||||||
self.statusItem = item
|
self.statusItem = item
|
||||||
MenuSessionsInjector.shared.install(into: item)
|
self.applyStatusItemAppearance(paused: self.state.isPaused)
|
||||||
self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping)
|
|
||||||
self.installStatusItemMouseHandler(for: item)
|
self.installStatusItemMouseHandler(for: item)
|
||||||
|
self.menuInjector.install(into: item)
|
||||||
self.updateHoverHUDSuppression()
|
self.updateHoverHUDSuppression()
|
||||||
}
|
}
|
||||||
.onChange(of: self.state.isPaused) { _, paused in
|
.onChange(of: self.state.isPaused) { _, paused in
|
||||||
self.applyStatusItemAppearance(paused: paused, sleeping: self.isGatewaySleeping)
|
self.applyStatusItemAppearance(paused: paused)
|
||||||
if self.state.connectionMode == .local {
|
if self.state.connectionMode == .local {
|
||||||
self.gatewayManager.setActive(!paused)
|
self.gatewayManager.setActive(!paused)
|
||||||
} else {
|
} else {
|
||||||
self.gatewayManager.stop()
|
self.gatewayManager.stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: self.controlChannel.state) { _, _ in
|
|
||||||
self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping)
|
|
||||||
}
|
|
||||||
.onChange(of: self.gatewayManager.status) { _, _ in
|
|
||||||
self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping)
|
|
||||||
}
|
|
||||||
.onChange(of: self.state.connectionMode) { _, mode in
|
.onChange(of: self.state.connectionMode) { _, mode in
|
||||||
Task { await ConnectionModeCoordinator.shared.apply(mode: mode, paused: self.state.isPaused) }
|
Task { await ConnectionModeCoordinator.shared.apply(mode: mode, paused: self.state.isPaused) }
|
||||||
}
|
}
|
||||||
@@ -75,7 +66,6 @@ struct ClawdisApp: App {
|
|||||||
Settings {
|
Settings {
|
||||||
SettingsRootView(state: self.state, updater: self.delegate.updaterController)
|
SettingsRootView(state: self.state, updater: self.delegate.updaterController)
|
||||||
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight, alignment: .topLeading)
|
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight, alignment: .topLeading)
|
||||||
.environment(self.tailscaleService)
|
|
||||||
}
|
}
|
||||||
.defaultSize(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
.defaultSize(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
||||||
.windowResizability(.contentSize)
|
.windowResizability(.contentSize)
|
||||||
@@ -85,27 +75,8 @@ struct ClawdisApp: App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyStatusItemAppearance(paused: Bool, sleeping: Bool) {
|
private func applyStatusItemAppearance(paused: Bool) {
|
||||||
self.statusItem?.button?.appearsDisabled = paused || sleeping
|
self.statusItem?.button?.appearsDisabled = paused
|
||||||
}
|
|
||||||
|
|
||||||
private var isGatewaySleeping: Bool {
|
|
||||||
if self.state.isPaused { return false }
|
|
||||||
switch self.state.connectionMode {
|
|
||||||
case .unconfigured:
|
|
||||||
return true
|
|
||||||
case .remote:
|
|
||||||
if case .connected = self.controlChannel.state { return false }
|
|
||||||
return true
|
|
||||||
case .local:
|
|
||||||
switch self.gatewayManager.status {
|
|
||||||
case .running, .starting, .attachedExisting:
|
|
||||||
if case .connected = self.controlChannel.state { return false }
|
|
||||||
return true
|
|
||||||
case .failed, .stopped:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -307,94 +278,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
@MainActor
|
@MainActor
|
||||||
protocol UpdaterProviding: AnyObject {
|
protocol UpdaterProviding: AnyObject {
|
||||||
var automaticallyChecksForUpdates: Bool { get set }
|
var automaticallyChecksForUpdates: Bool { get set }
|
||||||
var automaticallyDownloadsUpdates: Bool { get set }
|
|
||||||
var isAvailable: Bool { get }
|
var isAvailable: Bool { get }
|
||||||
var updateStatus: UpdateStatus { get }
|
|
||||||
func checkForUpdates(_ sender: Any?)
|
func checkForUpdates(_ sender: Any?)
|
||||||
}
|
}
|
||||||
|
|
||||||
// No-op updater used for debug/dev runs to suppress Sparkle dialogs.
|
// No-op updater used for debug/dev runs to suppress Sparkle dialogs.
|
||||||
final class DisabledUpdaterController: UpdaterProviding {
|
final class DisabledUpdaterController: UpdaterProviding {
|
||||||
var automaticallyChecksForUpdates: Bool = false
|
var automaticallyChecksForUpdates: Bool = false
|
||||||
var automaticallyDownloadsUpdates: Bool = false
|
|
||||||
let isAvailable: Bool = false
|
let isAvailable: Bool = false
|
||||||
let updateStatus = UpdateStatus()
|
|
||||||
func checkForUpdates(_: Any?) {}
|
func checkForUpdates(_: Any?) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Observable
|
|
||||||
final class UpdateStatus {
|
|
||||||
static let disabled = UpdateStatus()
|
|
||||||
var isUpdateReady: Bool
|
|
||||||
|
|
||||||
init(isUpdateReady: Bool = false) {
|
|
||||||
self.isUpdateReady = isUpdateReady
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if canImport(Sparkle)
|
#if canImport(Sparkle)
|
||||||
import Sparkle
|
import Sparkle
|
||||||
|
|
||||||
@MainActor
|
extension SPUStandardUpdaterController: UpdaterProviding {
|
||||||
final class SparkleUpdaterController: NSObject, UpdaterProviding, SPUUpdaterDelegate {
|
|
||||||
private lazy var controller = SPUStandardUpdaterController(
|
|
||||||
startingUpdater: false,
|
|
||||||
updaterDelegate: self,
|
|
||||||
userDriverDelegate: nil)
|
|
||||||
let updateStatus = UpdateStatus()
|
|
||||||
|
|
||||||
init(savedAutoUpdate: Bool) {
|
|
||||||
super.init()
|
|
||||||
let updater = self.controller.updater
|
|
||||||
updater.automaticallyChecksForUpdates = savedAutoUpdate
|
|
||||||
updater.automaticallyDownloadsUpdates = savedAutoUpdate
|
|
||||||
self.controller.startUpdater()
|
|
||||||
}
|
|
||||||
|
|
||||||
var automaticallyChecksForUpdates: Bool {
|
var automaticallyChecksForUpdates: Bool {
|
||||||
get { self.controller.updater.automaticallyChecksForUpdates }
|
get { self.updater.automaticallyChecksForUpdates }
|
||||||
set { self.controller.updater.automaticallyChecksForUpdates = newValue }
|
set { self.updater.automaticallyChecksForUpdates = newValue }
|
||||||
}
|
|
||||||
|
|
||||||
var automaticallyDownloadsUpdates: Bool {
|
|
||||||
get { self.controller.updater.automaticallyDownloadsUpdates }
|
|
||||||
set { self.controller.updater.automaticallyDownloadsUpdates = newValue }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var isAvailable: Bool { true }
|
var isAvailable: Bool { true }
|
||||||
|
|
||||||
func checkForUpdates(_ sender: Any?) {
|
|
||||||
self.controller.checkForUpdates(sender)
|
|
||||||
}
|
|
||||||
|
|
||||||
func updater(_ updater: SPUUpdater, didDownloadUpdate item: SUAppcastItem) {
|
|
||||||
self.updateStatus.isUpdateReady = true
|
|
||||||
}
|
|
||||||
|
|
||||||
func updater(_ updater: SPUUpdater, failedToDownloadUpdate item: SUAppcastItem, error: Error) {
|
|
||||||
self.updateStatus.isUpdateReady = false
|
|
||||||
}
|
|
||||||
|
|
||||||
func userDidCancelDownload(_ updater: SPUUpdater) {
|
|
||||||
self.updateStatus.isUpdateReady = false
|
|
||||||
}
|
|
||||||
|
|
||||||
func updater(
|
|
||||||
_ updater: SPUUpdater,
|
|
||||||
userDidMakeChoice choice: SPUUserUpdateChoice,
|
|
||||||
forUpdate updateItem: SUAppcastItem,
|
|
||||||
state: SPUUserUpdateState
|
|
||||||
) {
|
|
||||||
switch choice {
|
|
||||||
case .install, .skip:
|
|
||||||
self.updateStatus.isUpdateReady = false
|
|
||||||
case .dismiss:
|
|
||||||
self.updateStatus.isUpdateReady = (state.stage == .downloaded)
|
|
||||||
@unknown default:
|
|
||||||
self.updateStatus.isUpdateReady = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func isDeveloperIDSigned(bundleURL: URL) -> Bool {
|
private func isDeveloperIDSigned(bundleURL: URL) -> Bool {
|
||||||
@@ -427,7 +331,14 @@ private func makeUpdaterController() -> UpdaterProviding {
|
|||||||
let autoUpdateKey = "autoUpdateEnabled"
|
let autoUpdateKey = "autoUpdateEnabled"
|
||||||
// Default to true; honor the user's last choice otherwise.
|
// Default to true; honor the user's last choice otherwise.
|
||||||
let savedAutoUpdate = (defaults.object(forKey: autoUpdateKey) as? Bool) ?? true
|
let savedAutoUpdate = (defaults.object(forKey: autoUpdateKey) as? Bool) ?? true
|
||||||
return SparkleUpdaterController(savedAutoUpdate: savedAutoUpdate)
|
|
||||||
|
let controller = SPUStandardUpdaterController(
|
||||||
|
startingUpdater: false,
|
||||||
|
updaterDelegate: nil,
|
||||||
|
userDriverDelegate: nil)
|
||||||
|
controller.updater.automaticallyChecksForUpdates = savedAutoUpdate
|
||||||
|
controller.startUpdater()
|
||||||
|
return controller
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
private func makeUpdaterController() -> UpdaterProviding {
|
private func makeUpdaterController() -> UpdaterProviding {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import SwiftUI
|
|||||||
struct MenuContent: View {
|
struct MenuContent: View {
|
||||||
@Bindable var state: AppState
|
@Bindable var state: AppState
|
||||||
let updater: UpdaterProviding?
|
let updater: UpdaterProviding?
|
||||||
@Bindable private var updateStatus: UpdateStatus
|
|
||||||
private let gatewayManager = GatewayProcessManager.shared
|
private let gatewayManager = GatewayProcessManager.shared
|
||||||
private let healthStore = HealthStore.shared
|
private let healthStore = HealthStore.shared
|
||||||
private let heartbeatStore = HeartbeatStore.shared
|
private let heartbeatStore = HeartbeatStore.shared
|
||||||
@@ -17,14 +16,9 @@ struct MenuContent: View {
|
|||||||
@Environment(\.openSettings) private var openSettings
|
@Environment(\.openSettings) private var openSettings
|
||||||
@State private var availableMics: [AudioInputDevice] = []
|
@State private var availableMics: [AudioInputDevice] = []
|
||||||
@State private var loadingMics = false
|
@State private var loadingMics = false
|
||||||
|
@State private var sessionMenu: [SessionRow] = []
|
||||||
@State private var browserControlEnabled = true
|
@State private var browserControlEnabled = true
|
||||||
|
|
||||||
init(state: AppState, updater: UpdaterProviding?) {
|
|
||||||
self._state = Bindable(wrappedValue: state)
|
|
||||||
self.updater = updater
|
|
||||||
self._updateStatus = Bindable(wrappedValue: updater?.updateStatus ?? UpdateStatus.disabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Toggle(isOn: self.activeBinding) {
|
Toggle(isOn: self.activeBinding) {
|
||||||
@@ -34,7 +28,6 @@ struct MenuContent: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.disabled(self.state.connectionMode == .unconfigured)
|
.disabled(self.state.connectionMode == .unconfigured)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
Toggle(isOn: self.heartbeatsBinding) {
|
Toggle(isOn: self.heartbeatsBinding) {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
@@ -48,7 +41,6 @@ struct MenuContent: View {
|
|||||||
if self.showVoiceWakeMicPicker {
|
if self.showVoiceWakeMicPicker {
|
||||||
self.voiceWakeMicMenu
|
self.voiceWakeMicMenu
|
||||||
}
|
}
|
||||||
Divider()
|
|
||||||
Button("Open Chat") {
|
Button("Open Chat") {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
let sessionKey = await WebChatManager.shared.preferredSessionKey()
|
let sessionKey = await WebChatManager.shared.preferredSessionKey()
|
||||||
@@ -60,6 +52,15 @@ struct MenuContent: View {
|
|||||||
await self.openDashboard()
|
await self.openDashboard()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Toggle(
|
||||||
|
isOn: Binding(
|
||||||
|
get: { self.browserControlEnabled },
|
||||||
|
set: { enabled in
|
||||||
|
self.browserControlEnabled = enabled
|
||||||
|
ClawdisConfigFile.setBrowserControlEnabled(enabled)
|
||||||
|
})) {
|
||||||
|
Text("Browser Control")
|
||||||
|
}
|
||||||
Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) {
|
Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) {
|
||||||
Text("Allow Canvas")
|
Text("Allow Canvas")
|
||||||
}
|
}
|
||||||
@@ -79,22 +80,12 @@ struct MenuContent: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Divider()
|
Divider()
|
||||||
Toggle(
|
|
||||||
isOn: Binding(
|
|
||||||
get: { self.browserControlEnabled },
|
|
||||||
set: { enabled in
|
|
||||||
self.browserControlEnabled = enabled
|
|
||||||
ClawdisConfigFile.setBrowserControlEnabled(enabled)
|
|
||||||
})) {
|
|
||||||
Text("Browser Control")
|
|
||||||
}
|
|
||||||
Divider()
|
|
||||||
Button("Settings…") { self.open(tab: .general) }
|
Button("Settings…") { self.open(tab: .general) }
|
||||||
.keyboardShortcut(",", modifiers: [.command])
|
.keyboardShortcut(",", modifiers: [.command])
|
||||||
self.debugMenu
|
self.debugMenu
|
||||||
Button("About Clawdis") { self.open(tab: .about) }
|
Button("About Clawdis") { self.open(tab: .about) }
|
||||||
if let updater, updater.isAvailable, self.updateStatus.isUpdateReady {
|
if let updater, updater.isAvailable {
|
||||||
Button("Update ready, restart now?") { updater.checkForUpdates(nil) }
|
Button("Check for Updates…") { updater.checkForUpdates(nil) }
|
||||||
}
|
}
|
||||||
Button("Quit") { NSApplication.shared.terminate(nil) }
|
Button("Quit") { NSApplication.shared.terminate(nil) }
|
||||||
}
|
}
|
||||||
@@ -103,6 +94,9 @@ struct MenuContent: View {
|
|||||||
await self.loadMicrophones(force: true)
|
await self.loadMicrophones(force: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.task {
|
||||||
|
await self.reloadSessionMenu()
|
||||||
|
}
|
||||||
.task {
|
.task {
|
||||||
VoicePushToTalkHotkey.shared.setEnabled(voiceWakeSupported && self.state.voicePushToTalkEnabled)
|
VoicePushToTalkHotkey.shared.setEnabled(voiceWakeSupported && self.state.voicePushToTalkEnabled)
|
||||||
}
|
}
|
||||||
@@ -129,6 +123,55 @@ struct MenuContent: View {
|
|||||||
private var debugMenu: some View {
|
private var debugMenu: some View {
|
||||||
if self.state.debugPaneEnabled {
|
if self.state.debugPaneEnabled {
|
||||||
Menu("Debug") {
|
Menu("Debug") {
|
||||||
|
Menu {
|
||||||
|
ForEach(self.sessionMenu) { row in
|
||||||
|
Menu(row.key) {
|
||||||
|
Menu("Thinking") {
|
||||||
|
ForEach(["low", "medium", "high", "default"], id: \.self) { level in
|
||||||
|
let normalized = level == "default" ? nil : level
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
try? await DebugActions.updateSession(
|
||||||
|
key: row.key,
|
||||||
|
thinking: normalized,
|
||||||
|
verbose: row.verboseLevel)
|
||||||
|
await self.reloadSessionMenu()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
let checkmark = row.thinkingLevel == normalized ? "checkmark" : ""
|
||||||
|
Label(level.capitalized, systemImage: checkmark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Menu("Verbose") {
|
||||||
|
ForEach(["on", "off", "default"], id: \.self) { level in
|
||||||
|
let normalized = level == "default" ? nil : level
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
try? await DebugActions.updateSession(
|
||||||
|
key: row.key,
|
||||||
|
thinking: row.thinkingLevel,
|
||||||
|
verbose: normalized)
|
||||||
|
await self.reloadSessionMenu()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
let checkmark = row.verboseLevel == normalized ? "checkmark" : ""
|
||||||
|
Label(level.capitalized, systemImage: checkmark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
DebugActions.openSessionStoreInCode()
|
||||||
|
} label: {
|
||||||
|
Label("Open Session Log", systemImage: "doc.text")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
} label: {
|
||||||
|
Label("Sessions", systemImage: "clock.arrow.circlepath")
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
Button {
|
Button {
|
||||||
DebugActions.openConfigFolder()
|
DebugActions.openConfigFolder()
|
||||||
} label: {
|
} label: {
|
||||||
@@ -380,6 +423,11 @@ struct MenuContent: View {
|
|||||||
return "System default"
|
return "System default"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func reloadSessionMenu() async {
|
||||||
|
self.sessionMenu = await DebugActions.recentSessions()
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func loadMicrophones(force: Bool = false) async {
|
private func loadMicrophones(force: Bool = false) async {
|
||||||
guard self.showVoiceWakeMicPicker else {
|
guard self.showVoiceWakeMicPicker else {
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate {
|
|||||||
let initialRows = self.cachedRows
|
let initialRows = self.cachedRows
|
||||||
let initialIsLoading = initialRows.isEmpty
|
let initialIsLoading = initialRows.isEmpty
|
||||||
let initialStatusText = initialIsLoading ? self.cacheErrorText : nil
|
let initialStatusText = initialIsLoading ? self.cacheErrorText : nil
|
||||||
let initialWidth = self.initialCardWidth(for: menu)
|
|
||||||
|
|
||||||
let initial = AnyView(ContextMenuCardView(
|
let initial = AnyView(ContextMenuCardView(
|
||||||
rows: initialRows,
|
rows: initialRows,
|
||||||
@@ -56,8 +55,8 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate {
|
|||||||
isLoading: initialIsLoading))
|
isLoading: initialIsLoading))
|
||||||
|
|
||||||
let hosting = NSHostingView(rootView: initial)
|
let hosting = NSHostingView(rootView: initial)
|
||||||
hosting.frame.size.width = max(1, initialWidth)
|
|
||||||
let size = hosting.fittingSize
|
let size = hosting.fittingSize
|
||||||
|
let initialWidth = self.initialCardWidth(for: menu)
|
||||||
hosting.frame = NSRect(
|
hosting.frame = NSRect(
|
||||||
origin: .zero,
|
origin: .zero,
|
||||||
size: NSSize(width: initialWidth, height: size.height))
|
size: NSSize(width: initialWidth, height: size.height))
|
||||||
@@ -84,7 +83,6 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate {
|
|||||||
hosting.rootView = view
|
hosting.rootView = view
|
||||||
hosting.invalidateIntrinsicContentSize()
|
hosting.invalidateIntrinsicContentSize()
|
||||||
self.captureMenuWidthIfAvailable(for: menu, hosting: hosting)
|
self.captureMenuWidthIfAvailable(for: menu, hosting: hosting)
|
||||||
hosting.frame.size.width = max(1, initialWidth)
|
|
||||||
let size = hosting.fittingSize
|
let size = hosting.fittingSize
|
||||||
hosting.frame.size.height = size.height
|
hosting.frame.size.height = size.height
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct MenuSessionsHeaderView: View {
|
|
||||||
let count: Int
|
|
||||||
let statusText: String?
|
|
||||||
|
|
||||||
private let paddingTop: CGFloat = 8
|
|
||||||
private let paddingBottom: CGFloat = 6
|
|
||||||
private let paddingTrailing: CGFloat = 10
|
|
||||||
private let paddingLeading: CGFloat = 20
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
HStack(alignment: .firstTextBaseline) {
|
|
||||||
Text("Context")
|
|
||||||
.font(.caption.weight(.semibold))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Spacer(minLength: 10)
|
|
||||||
Text(self.subtitle)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let statusText, !statusText.isEmpty {
|
|
||||||
Text(statusText)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
.truncationMode(.tail)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.top, self.paddingTop)
|
|
||||||
.padding(.bottom, self.paddingBottom)
|
|
||||||
.padding(.leading, self.paddingLeading)
|
|
||||||
.padding(.trailing, self.paddingTrailing)
|
|
||||||
.frame(minWidth: 300, maxWidth: .infinity, alignment: .leading)
|
|
||||||
.transaction { txn in txn.animation = nil }
|
|
||||||
}
|
|
||||||
|
|
||||||
private var subtitle: String {
|
|
||||||
if self.count == 1 { return "1 session · 24h" }
|
|
||||||
return "\(self.count) sessions · 24h"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,538 +0,0 @@
|
|||||||
import AppKit
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|
||||||
static let shared = MenuSessionsInjector()
|
|
||||||
|
|
||||||
private let tag = 9_415_557
|
|
||||||
private let fallbackWidth: CGFloat = 320
|
|
||||||
private let activeWindowSeconds: TimeInterval = 24 * 60 * 60
|
|
||||||
|
|
||||||
private weak var originalDelegate: NSMenuDelegate?
|
|
||||||
private weak var statusItem: NSStatusItem?
|
|
||||||
private var loadTask: Task<Void, Never>?
|
|
||||||
private var isMenuOpen = false
|
|
||||||
private var lastKnownMenuWidth: CGFloat?
|
|
||||||
|
|
||||||
private var cachedSnapshot: SessionStoreSnapshot?
|
|
||||||
private var cachedErrorText: String?
|
|
||||||
private var cacheUpdatedAt: Date?
|
|
||||||
private let refreshIntervalSeconds: TimeInterval = 12
|
|
||||||
|
|
||||||
func install(into statusItem: NSStatusItem) {
|
|
||||||
self.statusItem = statusItem
|
|
||||||
guard let menu = statusItem.menu else { return }
|
|
||||||
|
|
||||||
// Preserve SwiftUI's internal NSMenuDelegate, otherwise it may stop populating menu items.
|
|
||||||
if menu.delegate !== self {
|
|
||||||
self.originalDelegate = menu.delegate
|
|
||||||
menu.delegate = self
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.loadTask == nil {
|
|
||||||
self.loadTask = Task { await self.refreshCache(force: true) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func menuWillOpen(_ menu: NSMenu) {
|
|
||||||
self.originalDelegate?.menuWillOpen?(menu)
|
|
||||||
self.isMenuOpen = true
|
|
||||||
|
|
||||||
self.inject(into: menu)
|
|
||||||
|
|
||||||
// Refresh in background for the next open (but only when connected).
|
|
||||||
self.loadTask?.cancel()
|
|
||||||
self.loadTask = Task { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
await self.refreshCache(force: false)
|
|
||||||
await MainActor.run {
|
|
||||||
guard self.isMenuOpen else { return }
|
|
||||||
// SwiftUI might have refreshed menu items; re-inject once.
|
|
||||||
self.inject(into: menu)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func menuDidClose(_ menu: NSMenu) {
|
|
||||||
self.originalDelegate?.menuDidClose?(menu)
|
|
||||||
self.isMenuOpen = false
|
|
||||||
self.loadTask?.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
func menuNeedsUpdate(_ menu: NSMenu) {
|
|
||||||
self.originalDelegate?.menuNeedsUpdate?(menu)
|
|
||||||
}
|
|
||||||
|
|
||||||
func confinementRect(for menu: NSMenu, on screen: NSScreen?) -> NSRect {
|
|
||||||
if let rect = self.originalDelegate?.confinementRect?(for: menu, on: screen) {
|
|
||||||
return rect
|
|
||||||
}
|
|
||||||
return NSRect.zero
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Injection
|
|
||||||
|
|
||||||
private func inject(into menu: NSMenu) {
|
|
||||||
// Remove any previous injected items.
|
|
||||||
for item in menu.items where item.tag == self.tag {
|
|
||||||
menu.removeItem(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let insertIndex = self.findInsertIndex(in: menu) else { return }
|
|
||||||
let width = self.initialWidth(for: menu)
|
|
||||||
|
|
||||||
guard self.isControlChannelConnected else {
|
|
||||||
menu.insertItem(self.makeMessageItem(
|
|
||||||
text: "No connection to gateway",
|
|
||||||
symbolName: "wifi.slash",
|
|
||||||
width: width), at: insertIndex)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let snapshot = self.cachedSnapshot else {
|
|
||||||
let headerItem = NSMenuItem()
|
|
||||||
headerItem.tag = self.tag
|
|
||||||
headerItem.isEnabled = false
|
|
||||||
headerItem.view = self.makeHostedView(
|
|
||||||
rootView: AnyView(MenuSessionsHeaderView(
|
|
||||||
count: 0,
|
|
||||||
statusText: self.cachedErrorText ?? "Loading sessions…")),
|
|
||||||
width: width,
|
|
||||||
highlighted: false)
|
|
||||||
menu.insertItem(headerItem, at: insertIndex)
|
|
||||||
DispatchQueue.main.async { [weak self, weak view = headerItem.view] in
|
|
||||||
guard let self, let view else { return }
|
|
||||||
self.captureMenuWidthIfAvailable(from: view)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let now = Date()
|
|
||||||
let rows = snapshot.rows.filter { row in
|
|
||||||
if row.key == "main" { return true }
|
|
||||||
guard let updatedAt = row.updatedAt else { return false }
|
|
||||||
return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds
|
|
||||||
}.sorted { lhs, rhs in
|
|
||||||
if lhs.key == "main" { return true }
|
|
||||||
if rhs.key == "main" { return false }
|
|
||||||
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
|
|
||||||
}
|
|
||||||
|
|
||||||
let headerItem = NSMenuItem()
|
|
||||||
headerItem.tag = self.tag
|
|
||||||
headerItem.isEnabled = false
|
|
||||||
let headerView = self.makeHostedView(
|
|
||||||
rootView: AnyView(MenuSessionsHeaderView(count: rows.count, statusText: nil)),
|
|
||||||
width: width,
|
|
||||||
highlighted: false)
|
|
||||||
headerItem.view = headerView
|
|
||||||
menu.insertItem(headerItem, at: insertIndex)
|
|
||||||
|
|
||||||
var cursor = insertIndex + 1
|
|
||||||
if rows.isEmpty {
|
|
||||||
menu.insertItem(self.makeMessageItem(text: "No active sessions", symbolName: "minus", width: width), at: cursor)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for row in rows {
|
|
||||||
let item = NSMenuItem()
|
|
||||||
item.tag = self.tag
|
|
||||||
item.isEnabled = true
|
|
||||||
item.submenu = self.buildSubmenu(for: row, storePath: snapshot.storePath)
|
|
||||||
item.view = self.makeHostedView(
|
|
||||||
rootView: AnyView(SessionMenuLabelView(row: row, width: width)),
|
|
||||||
width: width,
|
|
||||||
highlighted: true)
|
|
||||||
menu.insertItem(item, at: cursor)
|
|
||||||
cursor += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
DispatchQueue.main.async { [weak self, weak headerView] in
|
|
||||||
guard let self, let headerView else { return }
|
|
||||||
self.captureMenuWidthIfAvailable(from: headerView)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var isControlChannelConnected: Bool {
|
|
||||||
if case .connected = ControlChannel.shared.state { return true }
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makeMessageItem(text: String, symbolName: String, width: CGFloat) -> NSMenuItem {
|
|
||||||
let view = AnyView(
|
|
||||||
Label(text, systemImage: symbolName)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
.truncationMode(.tail)
|
|
||||||
.padding(.leading, 18)
|
|
||||||
.padding(.trailing, 12)
|
|
||||||
.padding(.vertical, 6)
|
|
||||||
.frame(minWidth: 300, alignment: .leading))
|
|
||||||
|
|
||||||
let item = NSMenuItem()
|
|
||||||
item.tag = self.tag
|
|
||||||
item.isEnabled = false
|
|
||||||
item.view = self.makeHostedView(rootView: view, width: width, highlighted: false)
|
|
||||||
return item
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Cache
|
|
||||||
|
|
||||||
private func refreshCache(force: Bool) async {
|
|
||||||
if !force, let updated = self.cacheUpdatedAt, Date().timeIntervalSince(updated) < self.refreshIntervalSeconds {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard self.isControlChannelConnected else {
|
|
||||||
self.cachedSnapshot = nil
|
|
||||||
self.cachedErrorText = nil
|
|
||||||
self.cacheUpdatedAt = Date()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
self.cachedSnapshot = try await SessionLoader.loadSnapshot(limit: 32)
|
|
||||||
self.cachedErrorText = nil
|
|
||||||
self.cacheUpdatedAt = Date()
|
|
||||||
} catch {
|
|
||||||
self.cachedSnapshot = nil
|
|
||||||
self.cachedErrorText = self.compactError(error)
|
|
||||||
self.cacheUpdatedAt = Date()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func compactError(_ error: Error) -> String {
|
|
||||||
if let loadError = error as? SessionLoadError {
|
|
||||||
switch loadError {
|
|
||||||
case .gatewayUnavailable:
|
|
||||||
return "No connection to gateway"
|
|
||||||
case .decodeFailed:
|
|
||||||
return "Sessions unavailable"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "Sessions unavailable"
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Submenus
|
|
||||||
|
|
||||||
private func buildSubmenu(for row: SessionRow, storePath: String) -> NSMenu {
|
|
||||||
let menu = NSMenu()
|
|
||||||
|
|
||||||
let thinking = NSMenuItem(title: "Thinking", action: nil, keyEquivalent: "")
|
|
||||||
thinking.submenu = self.buildThinkingMenu(for: row)
|
|
||||||
menu.addItem(thinking)
|
|
||||||
|
|
||||||
let verbose = NSMenuItem(title: "Verbose", action: nil, keyEquivalent: "")
|
|
||||||
verbose.submenu = self.buildVerboseMenu(for: row)
|
|
||||||
menu.addItem(verbose)
|
|
||||||
|
|
||||||
if AppStateStore.shared.debugPaneEnabled,
|
|
||||||
AppStateStore.shared.connectionMode == .local,
|
|
||||||
let sessionId = row.sessionId,
|
|
||||||
!sessionId.isEmpty
|
|
||||||
{
|
|
||||||
menu.addItem(NSMenuItem.separator())
|
|
||||||
let openLog = NSMenuItem(title: "Open Session Log", action: #selector(self.openSessionLog(_:)), keyEquivalent: "")
|
|
||||||
openLog.target = self
|
|
||||||
openLog.representedObject = [
|
|
||||||
"sessionId": sessionId,
|
|
||||||
"storePath": storePath,
|
|
||||||
]
|
|
||||||
menu.addItem(openLog)
|
|
||||||
}
|
|
||||||
|
|
||||||
menu.addItem(NSMenuItem.separator())
|
|
||||||
|
|
||||||
let reset = NSMenuItem(title: "Reset Session", action: #selector(self.resetSession(_:)), keyEquivalent: "")
|
|
||||||
reset.target = self
|
|
||||||
reset.representedObject = row.key
|
|
||||||
menu.addItem(reset)
|
|
||||||
|
|
||||||
let compact = NSMenuItem(title: "Compact Session Log", action: #selector(self.compactSession(_:)), keyEquivalent: "")
|
|
||||||
compact.target = self
|
|
||||||
compact.representedObject = row.key
|
|
||||||
menu.addItem(compact)
|
|
||||||
|
|
||||||
if row.key != "main" {
|
|
||||||
let del = NSMenuItem(title: "Delete Session", action: #selector(self.deleteSession(_:)), keyEquivalent: "")
|
|
||||||
del.target = self
|
|
||||||
del.representedObject = row.key
|
|
||||||
del.isAlternate = false
|
|
||||||
del.keyEquivalentModifierMask = []
|
|
||||||
menu.addItem(del)
|
|
||||||
}
|
|
||||||
|
|
||||||
return menu
|
|
||||||
}
|
|
||||||
|
|
||||||
private func buildThinkingMenu(for row: SessionRow) -> NSMenu {
|
|
||||||
let menu = NSMenu()
|
|
||||||
menu.autoenablesItems = false
|
|
||||||
menu.showsStateColumn = true
|
|
||||||
let levels: [String] = ["off", "minimal", "low", "medium", "high"]
|
|
||||||
let current = levels.contains(row.thinkingLevel ?? "") ? row.thinkingLevel ?? "off" : "off"
|
|
||||||
for level in levels {
|
|
||||||
let title = level.capitalized
|
|
||||||
let item = NSMenuItem(title: title, action: #selector(self.patchThinking(_:)), keyEquivalent: "")
|
|
||||||
item.target = self
|
|
||||||
item.representedObject = [
|
|
||||||
"key": row.key,
|
|
||||||
"value": level as Any,
|
|
||||||
]
|
|
||||||
item.state = (current == level) ? .on : .off
|
|
||||||
menu.addItem(item)
|
|
||||||
}
|
|
||||||
return menu
|
|
||||||
}
|
|
||||||
|
|
||||||
private func buildVerboseMenu(for row: SessionRow) -> NSMenu {
|
|
||||||
let menu = NSMenu()
|
|
||||||
menu.autoenablesItems = false
|
|
||||||
menu.showsStateColumn = true
|
|
||||||
let levels: [String] = ["on", "off"]
|
|
||||||
let current = levels.contains(row.verboseLevel ?? "") ? row.verboseLevel ?? "off" : "off"
|
|
||||||
for level in levels {
|
|
||||||
let title = level.capitalized
|
|
||||||
let item = NSMenuItem(title: title, action: #selector(self.patchVerbose(_:)), keyEquivalent: "")
|
|
||||||
item.target = self
|
|
||||||
item.representedObject = [
|
|
||||||
"key": row.key,
|
|
||||||
"value": level as Any,
|
|
||||||
]
|
|
||||||
item.state = (current == level) ? .on : .off
|
|
||||||
menu.addItem(item)
|
|
||||||
}
|
|
||||||
return menu
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc
|
|
||||||
private func patchThinking(_ sender: NSMenuItem) {
|
|
||||||
guard let dict = sender.representedObject as? [String: Any],
|
|
||||||
let key = dict["key"] as? String
|
|
||||||
else { return }
|
|
||||||
let value = dict["value"] as? String
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
try await SessionActions.patchSession(key: key, thinking: .some(value))
|
|
||||||
await self.refreshCache(force: true)
|
|
||||||
} catch {
|
|
||||||
await MainActor.run {
|
|
||||||
SessionActions.presentError(title: "Update thinking failed", error: error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc
|
|
||||||
private func patchVerbose(_ sender: NSMenuItem) {
|
|
||||||
guard let dict = sender.representedObject as? [String: Any],
|
|
||||||
let key = dict["key"] as? String
|
|
||||||
else { return }
|
|
||||||
let value = dict["value"] as? String
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
try await SessionActions.patchSession(key: key, verbose: .some(value))
|
|
||||||
await self.refreshCache(force: true)
|
|
||||||
} catch {
|
|
||||||
await MainActor.run {
|
|
||||||
SessionActions.presentError(title: "Update verbose failed", error: error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc
|
|
||||||
private func openSessionLog(_ sender: NSMenuItem) {
|
|
||||||
guard let dict = sender.representedObject as? [String: String],
|
|
||||||
let sessionId = dict["sessionId"],
|
|
||||||
let storePath = dict["storePath"]
|
|
||||||
else { return }
|
|
||||||
SessionActions.openSessionLogInCode(sessionId: sessionId, storePath: storePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc
|
|
||||||
private func resetSession(_ sender: NSMenuItem) {
|
|
||||||
guard let key = sender.representedObject as? String else { return }
|
|
||||||
Task { @MainActor in
|
|
||||||
guard SessionActions.confirmDestructiveAction(
|
|
||||||
title: "Reset session?",
|
|
||||||
message: "Starts a new session id for “\(key)”.",
|
|
||||||
action: "Reset")
|
|
||||||
else { return }
|
|
||||||
|
|
||||||
do {
|
|
||||||
try await SessionActions.resetSession(key: key)
|
|
||||||
await self.refreshCache(force: true)
|
|
||||||
} catch {
|
|
||||||
SessionActions.presentError(title: "Reset failed", error: error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc
|
|
||||||
private func compactSession(_ sender: NSMenuItem) {
|
|
||||||
guard let key = sender.representedObject as? String else { return }
|
|
||||||
Task { @MainActor in
|
|
||||||
guard SessionActions.confirmDestructiveAction(
|
|
||||||
title: "Compact session log?",
|
|
||||||
message: "Keeps the last 400 lines; archives the old file.",
|
|
||||||
action: "Compact")
|
|
||||||
else { return }
|
|
||||||
|
|
||||||
do {
|
|
||||||
try await SessionActions.compactSession(key: key, maxLines: 400)
|
|
||||||
await self.refreshCache(force: true)
|
|
||||||
} catch {
|
|
||||||
SessionActions.presentError(title: "Compact failed", error: error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc
|
|
||||||
private func deleteSession(_ sender: NSMenuItem) {
|
|
||||||
guard let key = sender.representedObject as? String else { return }
|
|
||||||
Task { @MainActor in
|
|
||||||
guard SessionActions.confirmDestructiveAction(
|
|
||||||
title: "Delete session?",
|
|
||||||
message: "Deletes the “\(key)” entry and archives its transcript.",
|
|
||||||
action: "Delete")
|
|
||||||
else { return }
|
|
||||||
|
|
||||||
do {
|
|
||||||
try await SessionActions.deleteSession(key: key)
|
|
||||||
await self.refreshCache(force: true)
|
|
||||||
} catch {
|
|
||||||
SessionActions.presentError(title: "Delete failed", error: error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Width + placement
|
|
||||||
|
|
||||||
private func findInsertIndex(in menu: NSMenu) -> Int? {
|
|
||||||
// Insert right before the separator above "Send Heartbeats".
|
|
||||||
if let idx = menu.items.firstIndex(where: { $0.title == "Send Heartbeats" }) {
|
|
||||||
if let sepIdx = menu.items[..<idx].lastIndex(where: { $0.isSeparatorItem }) {
|
|
||||||
return sepIdx
|
|
||||||
}
|
|
||||||
return idx
|
|
||||||
}
|
|
||||||
|
|
||||||
if let sepIdx = menu.items.firstIndex(where: { $0.isSeparatorItem }) {
|
|
||||||
return sepIdx
|
|
||||||
}
|
|
||||||
|
|
||||||
if menu.items.count >= 1 { return 1 }
|
|
||||||
return menu.items.count
|
|
||||||
}
|
|
||||||
|
|
||||||
private func initialWidth(for menu: NSMenu) -> CGFloat {
|
|
||||||
let candidates: [CGFloat] = [
|
|
||||||
menu.minimumWidth,
|
|
||||||
self.lastKnownMenuWidth ?? 0,
|
|
||||||
self.fallbackWidth,
|
|
||||||
]
|
|
||||||
let resolved = candidates.max() ?? self.fallbackWidth
|
|
||||||
return max(300, resolved)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Views
|
|
||||||
|
|
||||||
private func makeHostedView(rootView: AnyView, width: CGFloat, highlighted: Bool) -> NSView {
|
|
||||||
if highlighted {
|
|
||||||
let container = HighlightedMenuItemHostView(rootView: rootView, width: width)
|
|
||||||
return container
|
|
||||||
}
|
|
||||||
|
|
||||||
let hosting = NSHostingView(rootView: rootView)
|
|
||||||
hosting.frame.size.width = max(1, width)
|
|
||||||
let size = hosting.fittingSize
|
|
||||||
hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height))
|
|
||||||
return hosting
|
|
||||||
}
|
|
||||||
|
|
||||||
private func captureMenuWidthIfAvailable(from view: NSView) {
|
|
||||||
guard let width = view.window?.contentView?.bounds.width, width > 0 else { return }
|
|
||||||
self.lastKnownMenuWidth = max(300, width)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private final class HighlightedMenuItemHostView: NSView {
|
|
||||||
private let baseView: AnyView
|
|
||||||
private let hosting: NSHostingView<AnyView>
|
|
||||||
private var targetWidth: CGFloat
|
|
||||||
private var tracking: NSTrackingArea?
|
|
||||||
private var hovered = false {
|
|
||||||
didSet { self.updateHighlight() }
|
|
||||||
}
|
|
||||||
|
|
||||||
init(rootView: AnyView, width: CGFloat) {
|
|
||||||
self.baseView = rootView
|
|
||||||
self.hosting = NSHostingView(rootView: AnyView(rootView.environment(\.menuItemHighlighted, false)))
|
|
||||||
self.targetWidth = max(1, width)
|
|
||||||
super.init(frame: .zero)
|
|
||||||
|
|
||||||
self.addSubview(self.hosting)
|
|
||||||
self.hosting.autoresizingMask = [.width, .height]
|
|
||||||
self.updateSizing()
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(*, unavailable)
|
|
||||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
||||||
|
|
||||||
override var intrinsicContentSize: NSSize {
|
|
||||||
self.hosting.fittingSize
|
|
||||||
}
|
|
||||||
|
|
||||||
override func updateTrackingAreas() {
|
|
||||||
super.updateTrackingAreas()
|
|
||||||
if let tracking {
|
|
||||||
self.removeTrackingArea(tracking)
|
|
||||||
}
|
|
||||||
let options: NSTrackingArea.Options = [
|
|
||||||
.mouseEnteredAndExited,
|
|
||||||
.activeAlways,
|
|
||||||
.inVisibleRect,
|
|
||||||
]
|
|
||||||
let area = NSTrackingArea(rect: self.bounds, options: options, owner: self, userInfo: nil)
|
|
||||||
self.addTrackingArea(area)
|
|
||||||
self.tracking = area
|
|
||||||
}
|
|
||||||
|
|
||||||
override func mouseEntered(with event: NSEvent) {
|
|
||||||
_ = event
|
|
||||||
self.hovered = true
|
|
||||||
}
|
|
||||||
|
|
||||||
override func mouseExited(with event: NSEvent) {
|
|
||||||
_ = event
|
|
||||||
self.hovered = false
|
|
||||||
}
|
|
||||||
|
|
||||||
override func layout() {
|
|
||||||
super.layout()
|
|
||||||
self.hosting.frame = self.bounds
|
|
||||||
}
|
|
||||||
|
|
||||||
override func draw(_ dirtyRect: NSRect) {
|
|
||||||
if self.hovered {
|
|
||||||
NSColor.selectedContentBackgroundColor.setFill()
|
|
||||||
self.bounds.fill()
|
|
||||||
}
|
|
||||||
super.draw(dirtyRect)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateHighlight() {
|
|
||||||
self.hosting.rootView = AnyView(self.baseView.environment(\.menuItemHighlighted, self.hovered))
|
|
||||||
self.updateSizing()
|
|
||||||
self.needsDisplay = true
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateSizing() {
|
|
||||||
self.hosting.frame.size.width = self.targetWidth
|
|
||||||
let size = self.hosting.fittingSize
|
|
||||||
self.frame = NSRect(origin: .zero, size: NSSize(width: self.targetWidth, height: size.height))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,7 @@ actor MacNodeRuntime {
|
|||||||
private let cameraCapture = CameraCaptureService()
|
private let cameraCapture = CameraCaptureService()
|
||||||
@MainActor private let screenRecorder = ScreenRecordService()
|
@MainActor private let screenRecorder = ScreenRecordService()
|
||||||
|
|
||||||
// swiftlint:disable:next function_body_length cyclomatic_complexity
|
// swiftlint:disable:next function_body_length
|
||||||
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
|
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
|
||||||
let command = req.command
|
let command = req.command
|
||||||
if command.hasPrefix("canvas.") || command.hasPrefix("canvas.a2ui."), !Self.canvasEnabled() {
|
if command.hasPrefix("canvas.") || command.hasPrefix("canvas.a2ui."), !Self.canvasEnabled() {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ final class OnboardingController {
|
|||||||
let hosting = NSHostingController(rootView: OnboardingView())
|
let hosting = NSHostingController(rootView: OnboardingView())
|
||||||
let window = NSWindow(contentViewController: hosting)
|
let window = NSWindow(contentViewController: hosting)
|
||||||
window.title = UIStrings.welcomeTitle
|
window.title = UIStrings.welcomeTitle
|
||||||
window.setContentSize(NSSize(width: 630, height: 644))
|
window.setContentSize(NSSize(width: 680, height: 840))
|
||||||
window.styleMask = [.titled, .closable, .fullSizeContentView]
|
window.styleMask = [.titled, .closable, .fullSizeContentView]
|
||||||
window.titlebarAppearsTransparent = true
|
window.titlebarAppearsTransparent = true
|
||||||
window.titleVisibility = .hidden
|
window.titleVisibility = .hidden
|
||||||
@@ -62,13 +62,18 @@ struct OnboardingView: View {
|
|||||||
@State private var anthropicAuthStatus: String?
|
@State private var anthropicAuthStatus: String?
|
||||||
@State private var anthropicAuthBusy = false
|
@State private var anthropicAuthBusy = false
|
||||||
@State private var anthropicAuthConnected = false
|
@State private var anthropicAuthConnected = false
|
||||||
@State private var anthropicAuthDetectedStatus: ClawdisOAuthStore.AnthropicOAuthStatus = .missingFile
|
@State private var anthropicAuthDetectedStatus: PiOAuthStore.AnthropicOAuthStatus = .missingFile
|
||||||
@State private var anthropicAuthAutoDetectClipboard = true
|
@State private var anthropicAuthAutoDetectClipboard = true
|
||||||
@State private var anthropicAuthAutoConnectClipboard = true
|
@State private var anthropicAuthAutoConnectClipboard = true
|
||||||
@State private var anthropicAuthLastPasteboardChangeCount = NSPasteboard.general.changeCount
|
@State private var anthropicAuthLastPasteboardChangeCount = NSPasteboard.general.changeCount
|
||||||
@State private var monitoringAuth = false
|
@State private var monitoringAuth = false
|
||||||
@State private var authMonitorTask: Task<Void, Never>?
|
@State private var authMonitorTask: Task<Void, Never>?
|
||||||
@State private var needsBootstrap = false
|
@State private var identityName: String = ""
|
||||||
|
@State private var identityTheme: String = ""
|
||||||
|
@State private var identityEmoji: String = ""
|
||||||
|
@State private var identityStatus: String?
|
||||||
|
@State private var identityApplying = false
|
||||||
|
@State private var hasIdentity = false
|
||||||
@State private var didAutoKickoff = false
|
@State private var didAutoKickoff = false
|
||||||
@State private var showAdvancedConnection = false
|
@State private var showAdvancedConnection = false
|
||||||
@State private var preferredGatewayID: String?
|
@State private var preferredGatewayID: String?
|
||||||
@@ -78,8 +83,8 @@ struct OnboardingView: View {
|
|||||||
@Bindable private var state: AppState
|
@Bindable private var state: AppState
|
||||||
private var permissionMonitor: PermissionMonitor
|
private var permissionMonitor: PermissionMonitor
|
||||||
|
|
||||||
private let pageWidth: CGFloat = 630
|
private let pageWidth: CGFloat = 680
|
||||||
private let contentHeight: CGFloat = 420
|
private let contentHeight: CGFloat = 520
|
||||||
private let connectionPageIndex = 1
|
private let connectionPageIndex = 1
|
||||||
private let anthropicAuthPageIndex = 2
|
private let anthropicAuthPageIndex = 2
|
||||||
private let onboardingChatPageIndex = 8
|
private let onboardingChatPageIndex = 8
|
||||||
@@ -88,22 +93,22 @@ struct OnboardingView: View {
|
|||||||
private let permissionsPageIndex = 5
|
private let permissionsPageIndex = 5
|
||||||
static func pageOrder(
|
static func pageOrder(
|
||||||
for mode: AppState.ConnectionMode,
|
for mode: AppState.ConnectionMode,
|
||||||
needsBootstrap: Bool) -> [Int]
|
hasIdentity: Bool) -> [Int]
|
||||||
{
|
{
|
||||||
switch mode {
|
switch mode {
|
||||||
case .remote:
|
case .remote:
|
||||||
// Remote setup doesn't need local gateway/CLI/workspace setup pages,
|
// Remote setup doesn't need local gateway/CLI/workspace setup pages,
|
||||||
// and WhatsApp/Telegram setup is optional.
|
// and WhatsApp/Telegram setup is optional.
|
||||||
needsBootstrap ? [0, 1, 5, 8, 9] : [0, 1, 5, 9]
|
hasIdentity ? [0, 1, 5, 9] : [0, 1, 5, 8, 9]
|
||||||
case .unconfigured:
|
case .unconfigured:
|
||||||
needsBootstrap ? [0, 1, 8, 9] : [0, 1, 9]
|
hasIdentity ? [0, 1, 9] : [0, 1, 8, 9]
|
||||||
case .local:
|
case .local:
|
||||||
needsBootstrap ? [0, 1, 2, 5, 6, 8, 9] : [0, 1, 2, 5, 6, 9]
|
hasIdentity ? [0, 1, 2, 5, 6, 9] : [0, 1, 2, 5, 6, 8, 9]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var pageOrder: [Int] {
|
private var pageOrder: [Int] {
|
||||||
Self.pageOrder(for: self.state.connectionMode, needsBootstrap: self.needsBootstrap)
|
Self.pageOrder(for: self.state.connectionMode, hasIdentity: self.hasIdentity)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var pageCount: Int { self.pageOrder.count }
|
private var pageCount: Int { self.pageOrder.count }
|
||||||
@@ -140,9 +145,9 @@ struct OnboardingView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
GlowingClawdisIcon(size: 130, glowIntensity: 0.28)
|
GlowingClawdisIcon(size: 156, glowIntensity: 0.28)
|
||||||
.offset(y: 10)
|
.offset(y: 13)
|
||||||
.frame(height: 145)
|
.frame(height: 181)
|
||||||
|
|
||||||
GeometryReader { _ in
|
GeometryReader { _ in
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
@@ -160,10 +165,9 @@ struct OnboardingView: View {
|
|||||||
}
|
}
|
||||||
.frame(height: self.contentHeight)
|
.frame(height: self.contentHeight)
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
self.navigationBar
|
self.navigationBar
|
||||||
}
|
}
|
||||||
.frame(width: self.pageWidth, height: 644)
|
.frame(width: self.pageWidth, height: 840)
|
||||||
.background(Color(NSColor.windowBackgroundColor))
|
.background(Color(NSColor.windowBackgroundColor))
|
||||||
.onAppear {
|
.onAppear {
|
||||||
self.currentPage = 0
|
self.currentPage = 0
|
||||||
@@ -177,7 +181,7 @@ struct OnboardingView: View {
|
|||||||
self.reconcilePageForModeChange(previousActivePageIndex: oldActive)
|
self.reconcilePageForModeChange(previousActivePageIndex: oldActive)
|
||||||
self.updateDiscoveryMonitoring(for: self.activePageIndex)
|
self.updateDiscoveryMonitoring(for: self.activePageIndex)
|
||||||
}
|
}
|
||||||
.onChange(of: self.needsBootstrap) { _, _ in
|
.onChange(of: self.hasIdentity) { _, _ in
|
||||||
if self.currentPage >= self.pageOrder.count {
|
if self.currentPage >= self.pageOrder.count {
|
||||||
self.currentPage = max(0, self.pageOrder.count - 1)
|
self.currentPage = max(0, self.pageOrder.count - 1)
|
||||||
}
|
}
|
||||||
@@ -193,7 +197,7 @@ struct OnboardingView: View {
|
|||||||
self.loadWorkspaceDefaults()
|
self.loadWorkspaceDefaults()
|
||||||
self.ensureDefaultWorkspace()
|
self.ensureDefaultWorkspace()
|
||||||
self.refreshAnthropicOAuthStatus()
|
self.refreshAnthropicOAuthStatus()
|
||||||
self.refreshBootstrapStatus()
|
self.loadIdentityDefaults()
|
||||||
self.preferredGatewayID = BridgeDiscoveryPreferences.preferredStableID()
|
self.preferredGatewayID = BridgeDiscoveryPreferences.preferredStableID()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,6 +229,8 @@ struct OnboardingView: View {
|
|||||||
self.connectionPage()
|
self.connectionPage()
|
||||||
case 2:
|
case 2:
|
||||||
self.anthropicAuthPage()
|
self.anthropicAuthPage()
|
||||||
|
case 3:
|
||||||
|
self.identityPage()
|
||||||
case 5:
|
case 5:
|
||||||
self.permissionsPage()
|
self.permissionsPage()
|
||||||
case 6:
|
case 6:
|
||||||
@@ -243,7 +249,9 @@ struct OnboardingView: View {
|
|||||||
VStack(spacing: 22) {
|
VStack(spacing: 22) {
|
||||||
Text("Welcome to Clawdis")
|
Text("Welcome to Clawdis")
|
||||||
.font(.largeTitle.weight(.semibold))
|
.font(.largeTitle.weight(.semibold))
|
||||||
Text("Clawdis is a powerful personal AI assistant that can connect to WhatsApp or Telegram.")
|
Text(
|
||||||
|
"Your macOS menu bar companion for notifications, screenshots, and agent automation. " +
|
||||||
|
"Setup takes a few minutes.")
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
@@ -530,7 +538,7 @@ struct OnboardingView: View {
|
|||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.frame(maxWidth: 540)
|
.frame(maxWidth: 540)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
Text("Clawdis supports any model — we strongly recommend Opus 4.5 for the best experience.")
|
Text("Pi supports any model — we strongly recommend Opus 4.5 for the best experience.")
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
@@ -555,14 +563,14 @@ struct OnboardingView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
"This lets Clawdis use Claude immediately. Credentials are stored at " +
|
"This lets Pi use Claude immediately. Credentials are stored at " +
|
||||||
"`~/.clawdis/credentials/oauth.json` (owner-only). You can redo this anytime.")
|
"`~/.pi/agent/oauth.json` (owner-only). You can redo this anytime.")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Text(ClawdisOAuthStore.oauthURL().path)
|
Text(PiOAuthStore.oauthURL().path)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@@ -571,7 +579,7 @@ struct OnboardingView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Button("Reveal") {
|
Button("Reveal") {
|
||||||
NSWorkspace.shared.activateFileViewerSelecting([ClawdisOAuthStore.oauthURL()])
|
NSWorkspace.shared.activateFileViewerSelecting([PiOAuthStore.oauthURL()])
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
|
|
||||||
@@ -683,9 +691,9 @@ struct OnboardingView: View {
|
|||||||
code: parsed.code,
|
code: parsed.code,
|
||||||
state: parsed.state,
|
state: parsed.state,
|
||||||
verifier: pkce.verifier)
|
verifier: pkce.verifier)
|
||||||
try ClawdisOAuthStore.saveAnthropicOAuth(creds)
|
try PiOAuthStore.saveAnthropicOAuth(creds)
|
||||||
self.refreshAnthropicOAuthStatus()
|
self.refreshAnthropicOAuthStatus()
|
||||||
self.anthropicAuthStatus = "Connected. Clawdis can now use Claude."
|
self.anthropicAuthStatus = "Connected. Pi can now use Claude."
|
||||||
} catch {
|
} catch {
|
||||||
self.anthropicAuthStatus = "OAuth failed: \(error.localizedDescription)"
|
self.anthropicAuthStatus = "OAuth failed: \(error.localizedDescription)"
|
||||||
}
|
}
|
||||||
@@ -717,12 +725,104 @@ struct OnboardingView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func refreshAnthropicOAuthStatus() {
|
private func refreshAnthropicOAuthStatus() {
|
||||||
_ = ClawdisOAuthStore.importLegacyAnthropicOAuthIfNeeded()
|
let status = PiOAuthStore.anthropicOAuthStatus()
|
||||||
let status = ClawdisOAuthStore.anthropicOAuthStatus()
|
|
||||||
self.anthropicAuthDetectedStatus = status
|
self.anthropicAuthDetectedStatus = status
|
||||||
self.anthropicAuthConnected = status.isConnected
|
self.anthropicAuthConnected = status.isConnected
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func identityPage() -> some View {
|
||||||
|
self.onboardingPage {
|
||||||
|
Text("Identity")
|
||||||
|
.font(.largeTitle.weight(.semibold))
|
||||||
|
Text("Name your agent, pick a vibe, and choose an emoji.")
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.frame(maxWidth: 520)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
|
self.onboardingCard(spacing: 12, padding: 16) {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("Agent name")
|
||||||
|
.font(.headline)
|
||||||
|
TextField("Clawd", text: self.$identityName)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("Theme")
|
||||||
|
.font(.headline)
|
||||||
|
TextField("helpful space lobster", text: self.$identityTheme)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("Emoji")
|
||||||
|
.font(.headline)
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
TextField("🦞", text: self.$identityEmoji)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: 120)
|
||||||
|
|
||||||
|
Button("Suggest") {
|
||||||
|
let suggested = AgentIdentityEmoji.suggest(theme: self.identityTheme)
|
||||||
|
self.identityEmoji = suggested
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider().padding(.vertical, 2)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Workspace")
|
||||||
|
.font(.headline)
|
||||||
|
Text(self.workspacePath.isEmpty ? AgentWorkspace
|
||||||
|
.displayPath(for: ClawdisConfigFile.defaultWorkspaceURL()) : self.workspacePath)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button {
|
||||||
|
Task { await self.applyIdentity() }
|
||||||
|
} label: {
|
||||||
|
if self.identityApplying {
|
||||||
|
ProgressView()
|
||||||
|
} else {
|
||||||
|
Text("Save identity")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(self.identityApplying || self.identityName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.isEmpty)
|
||||||
|
|
||||||
|
Button("Open workspace") {
|
||||||
|
let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath)
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.disabled(self.identityApplying)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"This writes your identity to `~/.clawdis/clawdis.json` and into `AGENTS.md` " +
|
||||||
|
"inside the workspace. " +
|
||||||
|
"Treat that workspace as the agent’s “memory” and consider making it a private git repo.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
|
if let status = self.identityStatus {
|
||||||
|
Text(status)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func permissionsPage() -> some View {
|
private func permissionsPage() -> some View {
|
||||||
self.onboardingPage {
|
self.onboardingPage {
|
||||||
Text("Grant permissions")
|
Text("Grant permissions")
|
||||||
@@ -734,13 +834,9 @@ struct OnboardingView: View {
|
|||||||
.frame(maxWidth: 520)
|
.frame(maxWidth: 520)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
self.onboardingCard(spacing: 8, padding: 12) {
|
self.onboardingCard {
|
||||||
ForEach(Capability.allCases, id: \.self) { cap in
|
ForEach(Capability.allCases, id: \.self) { cap in
|
||||||
PermissionRow(
|
PermissionRow(capability: cap, status: self.permissionMonitor.status[cap] ?? false) {
|
||||||
capability: cap,
|
|
||||||
status: self.permissionMonitor.status[cap] ?? false,
|
|
||||||
compact: true)
|
|
||||||
{
|
|
||||||
Task { await self.request(cap) }
|
Task { await self.request(cap) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -909,7 +1005,7 @@ struct OnboardingView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func onboardingChatPage() -> some View {
|
private func onboardingChatPage() -> some View {
|
||||||
VStack(spacing: 16) {
|
self.onboardingPage {
|
||||||
Text("Meet your agent")
|
Text("Meet your agent")
|
||||||
.font(.largeTitle.weight(.semibold))
|
.font(.largeTitle.weight(.semibold))
|
||||||
Text(
|
Text(
|
||||||
@@ -923,12 +1019,9 @@ struct OnboardingView: View {
|
|||||||
|
|
||||||
self.onboardingCard(padding: 8) {
|
self.onboardingCard(padding: 8) {
|
||||||
ClawdisChatView(viewModel: self.onboardingChatModel, style: .onboarding)
|
ClawdisChatView(viewModel: self.onboardingChatModel, style: .onboarding)
|
||||||
.frame(maxHeight: .infinity)
|
.frame(height: 420)
|
||||||
}
|
}
|
||||||
.frame(maxHeight: .infinity)
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 28)
|
|
||||||
.frame(width: self.pageWidth, height: self.contentHeight, alignment: .top)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func readyPage() -> some View {
|
private func readyPage() -> some View {
|
||||||
@@ -948,8 +1041,8 @@ struct OnboardingView: View {
|
|||||||
self.featureRow(
|
self.featureRow(
|
||||||
title: "Remote gateway checklist",
|
title: "Remote gateway checklist",
|
||||||
subtitle: """
|
subtitle: """
|
||||||
On your gateway host: install/update the `clawdis` package and make sure credentials exist
|
On your gateway host: install/update the `clawdis` package and make sure Pi has credentials
|
||||||
(typically `~/.clawdis/credentials/oauth.json`). Then connect again if needed.
|
(typically `~/.pi/agent/oauth.json`). Then connect again if needed.
|
||||||
""",
|
""",
|
||||||
systemImage: "network")
|
systemImage: "network")
|
||||||
Divider()
|
Divider()
|
||||||
@@ -959,13 +1052,6 @@ struct OnboardingView: View {
|
|||||||
title: "Open the menu bar panel",
|
title: "Open the menu bar panel",
|
||||||
subtitle: "Click the Clawdis menu bar icon for quick chat and status.",
|
subtitle: "Click the Clawdis menu bar icon for quick chat and status.",
|
||||||
systemImage: "bubble.left.and.bubble.right")
|
systemImage: "bubble.left.and.bubble.right")
|
||||||
self.featureActionRow(
|
|
||||||
title: "Connect WhatsApp or Telegram",
|
|
||||||
subtitle: "Open Settings → Connections to link providers and monitor status.",
|
|
||||||
systemImage: "link")
|
|
||||||
{
|
|
||||||
self.openSettings(tab: .connections)
|
|
||||||
}
|
|
||||||
self.featureRow(
|
self.featureRow(
|
||||||
title: "Try Voice Wake",
|
title: "Try Voice Wake",
|
||||||
subtitle: "Enable Voice Wake in Settings for hands-free commands with a live transcript overlay.",
|
subtitle: "Enable Voice Wake in Settings for hands-free commands with a live transcript overlay.",
|
||||||
@@ -975,6 +1061,10 @@ struct OnboardingView: View {
|
|||||||
subtitle: "Open the menu bar panel for quick chat; the agent can show previews " +
|
subtitle: "Open the menu bar panel for quick chat; the agent can show previews " +
|
||||||
"and richer visuals in Canvas.",
|
"and richer visuals in Canvas.",
|
||||||
systemImage: "rectangle.inset.filled.and.person.filled")
|
systemImage: "rectangle.inset.filled.and.person.filled")
|
||||||
|
self.featureRow(
|
||||||
|
title: "Test a notification",
|
||||||
|
subtitle: "Send a quick notify via the menu bar to confirm sounds and permissions.",
|
||||||
|
systemImage: "bell.badge")
|
||||||
self.featureActionRow(
|
self.featureActionRow(
|
||||||
title: "Give your agent more powers",
|
title: "Give your agent more powers",
|
||||||
subtitle: "Enable optional skills (Peekaboo, oracle, camsnap, …) from Settings → Skills.",
|
subtitle: "Enable optional skills (Peekaboo, oracle, camsnap, …) from Settings → Skills.",
|
||||||
@@ -1044,12 +1134,12 @@ struct OnboardingView: View {
|
|||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 28)
|
.padding(.horizontal, 28)
|
||||||
.padding(.bottom, 13)
|
.padding(.bottom, 36)
|
||||||
.frame(minHeight: 60, alignment: .bottom)
|
.frame(minHeight: 60)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func onboardingPage(@ViewBuilder _ content: () -> some View) -> some View {
|
private func onboardingPage(@ViewBuilder _ content: () -> some View) -> some View {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 22) {
|
||||||
content()
|
content()
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
@@ -1086,7 +1176,6 @@ struct OnboardingView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 4)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func featureActionRow(
|
private func featureActionRow(
|
||||||
@@ -1111,7 +1200,6 @@ struct OnboardingView: View {
|
|||||||
}
|
}
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
.padding(.vertical, 4)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleBack() {
|
private func handleBack() {
|
||||||
@@ -1271,34 +1359,40 @@ struct OnboardingView: View {
|
|||||||
let configured = ClawdisConfigFile.inboundWorkspace()
|
let configured = ClawdisConfigFile.inboundWorkspace()
|
||||||
let url = AgentWorkspace.resolveWorkspaceURL(from: configured)
|
let url = AgentWorkspace.resolveWorkspaceURL(from: configured)
|
||||||
self.workspacePath = AgentWorkspace.displayPath(for: url)
|
self.workspacePath = AgentWorkspace.displayPath(for: url)
|
||||||
self.refreshBootstrapStatus()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func ensureDefaultWorkspace() {
|
private func ensureDefaultWorkspace() {
|
||||||
guard self.state.connectionMode == .local else { return }
|
guard self.state.connectionMode == .local else { return }
|
||||||
let configured = ClawdisConfigFile.inboundWorkspace()
|
let configured = ClawdisConfigFile.inboundWorkspace()
|
||||||
let url = AgentWorkspace.resolveWorkspaceURL(from: configured)
|
let url = AgentWorkspace.resolveWorkspaceURL(from: configured)
|
||||||
switch AgentWorkspace.bootstrapSafety(for: url) {
|
do {
|
||||||
case .safe:
|
_ = try AgentWorkspace.bootstrap(workspaceURL: url)
|
||||||
do {
|
if (configured ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
_ = try AgentWorkspace.bootstrap(workspaceURL: url)
|
ClawdisConfigFile.setInboundWorkspace(AgentWorkspace.displayPath(for: url))
|
||||||
if (configured ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
||||||
ClawdisConfigFile.setInboundWorkspace(AgentWorkspace.displayPath(for: url))
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)"
|
|
||||||
}
|
}
|
||||||
case let .unsafe(reason):
|
} catch {
|
||||||
self.workspaceStatus = "Workspace not touched: \(reason)"
|
self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)"
|
||||||
}
|
}
|
||||||
self.refreshBootstrapStatus()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func refreshBootstrapStatus() {
|
private func loadIdentityDefaults() {
|
||||||
let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath)
|
if let identity = ClawdisConfigFile.loadIdentity() {
|
||||||
self.needsBootstrap = AgentWorkspace.needsBootstrap(workspaceURL: url)
|
self.identityName = identity.name
|
||||||
if self.needsBootstrap {
|
self.identityTheme = identity.theme
|
||||||
self.didAutoKickoff = false
|
self.identityEmoji = identity.emoji
|
||||||
|
self.hasIdentity = !identity.isEmpty
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.hasIdentity = false
|
||||||
|
if self.identityName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
self.identityName = "Clawd"
|
||||||
|
}
|
||||||
|
if self.identityTheme.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
self.identityTheme = "helpful space lobster"
|
||||||
|
}
|
||||||
|
if self.identityEmoji.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
self.identityEmoji = AgentIdentityEmoji.suggest(theme: self.identityTheme)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1319,22 +1413,48 @@ struct OnboardingView: View {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath)
|
let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath)
|
||||||
if case let .unsafe(reason) = AgentWorkspace.bootstrapSafety(for: url) {
|
|
||||||
self.workspaceStatus = "Workspace not created: \(reason)"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = try AgentWorkspace.bootstrap(workspaceURL: url)
|
_ = try AgentWorkspace.bootstrap(workspaceURL: url)
|
||||||
self.workspacePath = AgentWorkspace.displayPath(for: url)
|
self.workspacePath = AgentWorkspace.displayPath(for: url)
|
||||||
self.workspaceStatus = "Workspace ready at \(self.workspacePath)"
|
self.workspaceStatus = "Workspace ready at \(self.workspacePath)"
|
||||||
self.refreshBootstrapStatus()
|
|
||||||
} catch {
|
} catch {
|
||||||
self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)"
|
self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func applyIdentity() async {
|
||||||
|
guard !self.identityApplying else { return }
|
||||||
|
self.identityApplying = true
|
||||||
|
defer { self.identityApplying = false }
|
||||||
|
|
||||||
|
if self.identityName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
self.identityStatus = "Please enter a name first."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var identity = AgentIdentity(
|
||||||
|
name: self.identityName,
|
||||||
|
theme: self.identityTheme,
|
||||||
|
emoji: self.identityEmoji)
|
||||||
|
|
||||||
|
if identity.emoji.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
identity.emoji = AgentIdentityEmoji.suggest(theme: identity.theme)
|
||||||
|
self.identityEmoji = identity.emoji
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let workspaceURL = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath)
|
||||||
|
try AgentWorkspace.upsertIdentity(workspaceURL: workspaceURL, identity: identity)
|
||||||
|
ClawdisConfigFile.setInboundWorkspace(AgentWorkspace.displayPath(for: workspaceURL))
|
||||||
|
ClawdisConfigFile.setIdentity(identity)
|
||||||
|
self.identityStatus = "Saved identity to AGENTS.md and ~/.clawdis/clawdis.json"
|
||||||
|
} catch {
|
||||||
|
self.identityStatus = "Failed to save identity: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func maybeKickoffOnboardingChat(for pageIndex: Int) {
|
private func maybeKickoffOnboardingChat(for pageIndex: Int) {
|
||||||
guard pageIndex == self.onboardingChatPageIndex else { return }
|
guard pageIndex == self.onboardingChatPageIndex else { return }
|
||||||
guard self.needsBootstrap else { return }
|
guard !self.hasIdentity else { return }
|
||||||
guard !self.didAutoKickoff else { return }
|
guard !self.didAutoKickoff else { return }
|
||||||
self.didAutoKickoff = true
|
self.didAutoKickoff = true
|
||||||
|
|
||||||
|
|||||||
@@ -58,21 +58,13 @@ struct PermissionStatusList: View {
|
|||||||
struct PermissionRow: View {
|
struct PermissionRow: View {
|
||||||
let capability: Capability
|
let capability: Capability
|
||||||
let status: Bool
|
let status: Bool
|
||||||
let compact: Bool
|
|
||||||
let action: () -> Void
|
let action: () -> Void
|
||||||
|
|
||||||
init(capability: Capability, status: Bool, compact: Bool = false, action: @escaping () -> Void) {
|
|
||||||
self.capability = capability
|
|
||||||
self.status = status
|
|
||||||
self.compact = compact
|
|
||||||
self.action = action
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: self.compact ? 10 : 12) {
|
HStack(spacing: 12) {
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle().fill(self.status ? Color.green.opacity(0.2) : Color.gray.opacity(0.15))
|
Circle().fill(self.status ? Color.green.opacity(0.2) : Color.gray.opacity(0.15))
|
||||||
.frame(width: self.iconSize, height: self.iconSize)
|
.frame(width: 32, height: 32)
|
||||||
Image(systemName: self.icon)
|
Image(systemName: self.icon)
|
||||||
.foregroundStyle(self.status ? Color.green : Color.secondary)
|
.foregroundStyle(self.status ? Color.green : Color.secondary)
|
||||||
}
|
}
|
||||||
@@ -89,11 +81,9 @@ struct PermissionRow: View {
|
|||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, self.compact ? 4 : 6)
|
.padding(.vertical, 6)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var iconSize: CGFloat { self.compact ? 28 : 32 }
|
|
||||||
|
|
||||||
private var title: String {
|
private var title: String {
|
||||||
switch self.capability {
|
switch self.capability {
|
||||||
case .appleScript: "Automation (AppleScript)"
|
case .appleScript: "Automation (AppleScript)"
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
import AppKit
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
enum SessionActions {
|
|
||||||
static func patchSession(
|
|
||||||
key: String,
|
|
||||||
thinking: String?? = nil,
|
|
||||||
verbose: String?? = nil) async throws
|
|
||||||
{
|
|
||||||
var params: [String: AnyHashable] = ["key": AnyHashable(key)]
|
|
||||||
|
|
||||||
if let thinking {
|
|
||||||
params["thinkingLevel"] = thinking.map(AnyHashable.init) ?? AnyHashable(NSNull())
|
|
||||||
}
|
|
||||||
if let verbose {
|
|
||||||
params["verboseLevel"] = verbose.map(AnyHashable.init) ?? AnyHashable(NSNull())
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = try await ControlChannel.shared.request(method: "sessions.patch", params: params)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func resetSession(key: String) async throws {
|
|
||||||
_ = try await ControlChannel.shared.request(
|
|
||||||
method: "sessions.reset",
|
|
||||||
params: ["key": AnyHashable(key)])
|
|
||||||
}
|
|
||||||
|
|
||||||
static func deleteSession(key: String) async throws {
|
|
||||||
_ = try await ControlChannel.shared.request(
|
|
||||||
method: "sessions.delete",
|
|
||||||
params: ["key": AnyHashable(key), "deleteTranscript": AnyHashable(true)])
|
|
||||||
}
|
|
||||||
|
|
||||||
static func compactSession(key: String, maxLines: Int = 400) async throws {
|
|
||||||
_ = try await ControlChannel.shared.request(
|
|
||||||
method: "sessions.compact",
|
|
||||||
params: ["key": AnyHashable(key), "maxLines": AnyHashable(maxLines)])
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
static func confirmDestructiveAction(title: String, message: String, action: String) -> Bool {
|
|
||||||
let alert = NSAlert()
|
|
||||||
alert.messageText = title
|
|
||||||
alert.informativeText = message
|
|
||||||
alert.addButton(withTitle: action)
|
|
||||||
alert.addButton(withTitle: "Cancel")
|
|
||||||
alert.alertStyle = .warning
|
|
||||||
return alert.runModal() == .alertFirstButtonReturn
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
static func presentError(title: String, error: Error) {
|
|
||||||
let alert = NSAlert()
|
|
||||||
alert.messageText = title
|
|
||||||
alert.informativeText = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
|
|
||||||
alert.addButton(withTitle: "OK")
|
|
||||||
alert.alertStyle = .warning
|
|
||||||
alert.runModal()
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
static func openSessionLogInCode(sessionId: String, storePath: String?) {
|
|
||||||
let candidates: [URL] = {
|
|
||||||
var urls: [URL] = []
|
|
||||||
if let storePath, !storePath.isEmpty {
|
|
||||||
let dir = URL(fileURLWithPath: storePath).deletingLastPathComponent()
|
|
||||||
urls.append(dir.appendingPathComponent("\(sessionId).jsonl"))
|
|
||||||
}
|
|
||||||
let home = FileManager.default.homeDirectoryForCurrentUser
|
|
||||||
urls.append(home.appendingPathComponent(".clawdis/sessions/\(sessionId).jsonl"))
|
|
||||||
return urls
|
|
||||||
}()
|
|
||||||
|
|
||||||
let existing = candidates.first(where: { FileManager.default.fileExists(atPath: $0.path) })
|
|
||||||
guard let url = existing else {
|
|
||||||
let alert = NSAlert()
|
|
||||||
alert.messageText = "Session log not found"
|
|
||||||
alert.informativeText = sessionId
|
|
||||||
alert.runModal()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let proc = Process()
|
|
||||||
proc.launchPath = "/usr/bin/env"
|
|
||||||
proc.arguments = ["code", url.path]
|
|
||||||
if (try? proc.run()) != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
NSWorkspace.shared.activateFileViewerSelecting([url])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
private struct MenuItemHighlightedKey: EnvironmentKey {
|
|
||||||
static let defaultValue = false
|
|
||||||
}
|
|
||||||
|
|
||||||
extension EnvironmentValues {
|
|
||||||
var menuItemHighlighted: Bool {
|
|
||||||
get { self[MenuItemHighlightedKey.self] }
|
|
||||||
set { self[MenuItemHighlightedKey.self] = newValue }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SessionMenuLabelView: View {
|
|
||||||
let row: SessionRow
|
|
||||||
let width: CGFloat
|
|
||||||
@Environment(\.menuItemHighlighted) private var isHighlighted
|
|
||||||
private let paddingLeading: CGFloat = 26
|
|
||||||
private let paddingTrailing: CGFloat = 18
|
|
||||||
private let barHeight: CGFloat = 6
|
|
||||||
|
|
||||||
private var primaryTextColor: Color {
|
|
||||||
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary
|
|
||||||
}
|
|
||||||
|
|
||||||
private var secondaryTextColor: Color {
|
|
||||||
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
ContextUsageBar(
|
|
||||||
usedTokens: row.tokens.total,
|
|
||||||
contextTokens: row.tokens.contextTokens,
|
|
||||||
width: max(1, self.width - (self.paddingLeading + self.paddingTrailing)),
|
|
||||||
height: self.barHeight)
|
|
||||||
|
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
|
||||||
Text(row.key)
|
|
||||||
.font(.caption.weight(row.key == "main" ? .semibold : .regular))
|
|
||||||
.foregroundStyle(self.primaryTextColor)
|
|
||||||
.lineLimit(1)
|
|
||||||
.truncationMode(.middle)
|
|
||||||
.layoutPriority(1)
|
|
||||||
|
|
||||||
Spacer(minLength: 8)
|
|
||||||
|
|
||||||
Text(row.tokens.contextSummaryShort)
|
|
||||||
.font(.caption.monospacedDigit())
|
|
||||||
.foregroundStyle(self.secondaryTextColor)
|
|
||||||
.lineLimit(1)
|
|
||||||
.fixedSize(horizontal: true, vertical: false)
|
|
||||||
.layoutPriority(2)
|
|
||||||
|
|
||||||
Image(systemName: "chevron.right")
|
|
||||||
.font(.caption.weight(.semibold))
|
|
||||||
.foregroundStyle(self.secondaryTextColor)
|
|
||||||
.padding(.leading, 2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.padding(.leading, self.paddingLeading)
|
|
||||||
.padding(.trailing, self.paddingTrailing)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -21,10 +21,6 @@ struct SettingsRootView: View {
|
|||||||
.tabItem { Label("General", systemImage: "gearshape") }
|
.tabItem { Label("General", systemImage: "gearshape") }
|
||||||
.tag(SettingsTab.general)
|
.tag(SettingsTab.general)
|
||||||
|
|
||||||
ConnectionsSettings()
|
|
||||||
.tabItem { Label("Connections", systemImage: "link") }
|
|
||||||
.tag(SettingsTab.connections)
|
|
||||||
|
|
||||||
VoiceWakeSettings(state: self.state)
|
VoiceWakeSettings(state: self.state)
|
||||||
.tabItem { Label("Voice Wake", systemImage: "waveform.circle") }
|
.tabItem { Label("Voice Wake", systemImage: "waveform.circle") }
|
||||||
.tag(SettingsTab.voiceWake)
|
.tag(SettingsTab.voiceWake)
|
||||||
@@ -129,13 +125,12 @@ struct SettingsRootView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum SettingsTab: CaseIterable {
|
enum SettingsTab: CaseIterable {
|
||||||
case general, connections, skills, sessions, cron, config, instances, voiceWake, permissions, debug, about
|
case general, skills, sessions, cron, config, instances, voiceWake, permissions, debug, about
|
||||||
static let windowWidth: CGFloat = 824 // wider
|
static let windowWidth: CGFloat = 724 // +10% (tabs fit better)
|
||||||
static let windowHeight: CGFloat = 790 // +10% (more room)
|
static let windowHeight: CGFloat = 790 // +10% (more room)
|
||||||
var title: String {
|
var title: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .general: "General"
|
case .general: "General"
|
||||||
case .connections: "Connections"
|
|
||||||
case .skills: "Skills"
|
case .skills: "Skills"
|
||||||
case .sessions: "Sessions"
|
case .sessions: "Sessions"
|
||||||
case .cron: "Cron"
|
case .cron: "Cron"
|
||||||
|
|||||||
@@ -56,8 +56,8 @@ enum ShellExecutor {
|
|||||||
let err = stderrPipe.fileHandleForReading.readToEndSafely()
|
let err = stderrPipe.fileHandleForReading.readToEndSafely()
|
||||||
let status = Int(process.terminationStatus)
|
let status = Int(process.terminationStatus)
|
||||||
return ShellResult(
|
return ShellResult(
|
||||||
stdout: String(bytes: out, encoding: .utf8) ?? "",
|
stdout: String(decoding: out, as: UTF8.self),
|
||||||
stderr: String(bytes: err, encoding: .utf8) ?? "",
|
stderr: String(decoding: err, as: UTF8.self),
|
||||||
exitCode: status,
|
exitCode: status,
|
||||||
timedOut: false,
|
timedOut: false,
|
||||||
success: status == 0,
|
success: status == 0,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ struct SkillsSettings: View {
|
|||||||
@Bindable var state: AppState
|
@Bindable var state: AppState
|
||||||
@State private var model = SkillsSettingsModel()
|
@State private var model = SkillsSettingsModel()
|
||||||
@State private var envEditor: EnvEditorState?
|
@State private var envEditor: EnvEditorState?
|
||||||
|
@State private var searchQuery = ""
|
||||||
@State private var filter: SkillsFilter = .all
|
@State private var filter: SkillsFilter = .all
|
||||||
|
|
||||||
init(state: AppState = AppStateStore.shared) {
|
init(state: AppState = AppStateStore.shared) {
|
||||||
@@ -82,7 +83,8 @@ struct SkillsSettings: View {
|
|||||||
SkillRow(
|
SkillRow(
|
||||||
skill: skill,
|
skill: skill,
|
||||||
isBusy: self.model.isBusy(skill: skill),
|
isBusy: self.model.isBusy(skill: skill),
|
||||||
connectionMode: self.state.connectionMode,
|
canInstallLocally: self.state.connectionMode == .local,
|
||||||
|
defaultInstallTarget: self.defaultInstallTarget(for: skill),
|
||||||
onToggleEnabled: { enabled in
|
onToggleEnabled: { enabled in
|
||||||
Task { await self.model.setEnabled(skillKey: skill.skillKey, enabled: enabled) }
|
Task { await self.model.setEnabled(skillKey: skill.skillKey, enabled: enabled) }
|
||||||
},
|
},
|
||||||
@@ -104,6 +106,7 @@ struct SkillsSettings: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.inset)
|
.listStyle(.inset)
|
||||||
|
.searchable(text: self.$searchQuery, placement: .automatic, prompt: "Search skills")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,19 +123,31 @@ struct SkillsSettings: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var filteredSkills: [SkillStatus] {
|
private var filteredSkills: [SkillStatus] {
|
||||||
self.model.skills.filter { skill in
|
let trimmed = self.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let query = trimmed.lowercased()
|
||||||
|
return self.model.skills.filter { skill in
|
||||||
|
if !query.isEmpty {
|
||||||
|
let matchesName = skill.name.lowercased().contains(query)
|
||||||
|
let matchesDescription = skill.description.lowercased().contains(query)
|
||||||
|
if !(matchesName || matchesDescription) { return false }
|
||||||
|
}
|
||||||
switch self.filter {
|
switch self.filter {
|
||||||
case .all:
|
case .all:
|
||||||
true
|
return true
|
||||||
case .ready:
|
case .ready:
|
||||||
!skill.disabled && skill.eligible
|
return !skill.disabled && skill.eligible
|
||||||
case .needsSetup:
|
case .needsSetup:
|
||||||
!skill.disabled && !skill.eligible
|
return !skill.disabled && !skill.eligible
|
||||||
case .disabled:
|
case .disabled:
|
||||||
skill.disabled
|
return skill.disabled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func defaultInstallTarget(for skill: SkillStatus) -> InstallTarget {
|
||||||
|
let localPreferred = ["imsg", "peekaboo", "spotify-player"]
|
||||||
|
return localPreferred.contains(skill.skillKey) ? .local : .gateway
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum SkillsFilter: String, CaseIterable, Identifiable {
|
private enum SkillsFilter: String, CaseIterable, Identifiable {
|
||||||
@@ -165,10 +180,11 @@ private enum InstallTarget: String, CaseIterable {
|
|||||||
private struct SkillRow: View {
|
private struct SkillRow: View {
|
||||||
let skill: SkillStatus
|
let skill: SkillStatus
|
||||||
let isBusy: Bool
|
let isBusy: Bool
|
||||||
let connectionMode: AppState.ConnectionMode
|
let canInstallLocally: Bool
|
||||||
let onToggleEnabled: (Bool) -> Void
|
let onToggleEnabled: (Bool) -> Void
|
||||||
let onInstall: (SkillInstallOption, InstallTarget) -> Void
|
let onInstall: (SkillInstallOption, InstallTarget) -> Void
|
||||||
let onSetEnv: (String, Bool) -> Void
|
let onSetEnv: (String, Bool) -> Void
|
||||||
|
@State private var installTarget: InstallTarget
|
||||||
|
|
||||||
private var missingBins: [String] { self.skill.missing.bins }
|
private var missingBins: [String] { self.skill.missing.bins }
|
||||||
private var missingEnv: [String] { self.skill.missing.env }
|
private var missingEnv: [String] { self.skill.missing.env }
|
||||||
@@ -177,17 +193,22 @@ private struct SkillRow: View {
|
|||||||
init(
|
init(
|
||||||
skill: SkillStatus,
|
skill: SkillStatus,
|
||||||
isBusy: Bool,
|
isBusy: Bool,
|
||||||
connectionMode: AppState.ConnectionMode,
|
canInstallLocally: Bool,
|
||||||
|
defaultInstallTarget: InstallTarget,
|
||||||
onToggleEnabled: @escaping (Bool) -> Void,
|
onToggleEnabled: @escaping (Bool) -> Void,
|
||||||
onInstall: @escaping (SkillInstallOption, InstallTarget) -> Void,
|
onInstall: @escaping (SkillInstallOption, InstallTarget) -> Void,
|
||||||
onSetEnv: @escaping (String, Bool) -> Void)
|
onSetEnv: @escaping (String, Bool) -> Void)
|
||||||
{
|
{
|
||||||
self.skill = skill
|
self.skill = skill
|
||||||
self.isBusy = isBusy
|
self.isBusy = isBusy
|
||||||
self.connectionMode = connectionMode
|
self.canInstallLocally = canInstallLocally
|
||||||
self.onToggleEnabled = onToggleEnabled
|
self.onToggleEnabled = onToggleEnabled
|
||||||
self.onInstall = onInstall
|
self.onInstall = onInstall
|
||||||
self.onSetEnv = onSetEnv
|
self.onSetEnv = onSetEnv
|
||||||
|
let initialTarget: InstallTarget = (defaultInstallTarget == .local && !canInstallLocally)
|
||||||
|
? .gateway
|
||||||
|
: defaultInstallTarget
|
||||||
|
self._installTarget = State(initialValue: initialTarget)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -327,31 +348,28 @@ private struct SkillRow: View {
|
|||||||
private var trailingActions: some View {
|
private var trailingActions: some View {
|
||||||
VStack(alignment: .trailing, spacing: 8) {
|
VStack(alignment: .trailing, spacing: 8) {
|
||||||
if !self.installOptions.isEmpty {
|
if !self.installOptions.isEmpty {
|
||||||
ForEach(self.installOptions, id: \.id) { (option: SkillInstallOption) in
|
HStack(spacing: 6) {
|
||||||
HStack(spacing: 6) {
|
Text("Install on")
|
||||||
if self.showGatewayInstall {
|
.font(.caption)
|
||||||
Button("Install on Gateway") { self.onInstall(option, .gateway) }
|
.foregroundStyle(.secondary)
|
||||||
.buttonStyle(.borderedProminent)
|
Picker("Install on", selection: self.$installTarget) {
|
||||||
.disabled(self.isBusy)
|
Text("Gateway")
|
||||||
}
|
.tag(InstallTarget.gateway)
|
||||||
if self.showGatewayInstall {
|
Text("This Mac")
|
||||||
Button("Install on This Mac") { self.onInstall(option, .local) }
|
.tag(InstallTarget.local)
|
||||||
.buttonStyle(.bordered)
|
.disabled(!self.canInstallLocally)
|
||||||
.disabled(self.isBusy)
|
|
||||||
.help(
|
|
||||||
self.localInstallNeedsSwitch
|
|
||||||
? "Switches to Local mode to install on this Mac."
|
|
||||||
: "")
|
|
||||||
} else {
|
|
||||||
Button("Install on This Mac") { self.onInstall(option, .local) }
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.disabled(self.isBusy)
|
|
||||||
.help(
|
|
||||||
self.localInstallNeedsSwitch
|
|
||||||
? "Switches to Local mode to install on this Mac."
|
|
||||||
: "")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.frame(width: 160)
|
||||||
|
.controlSize(.small)
|
||||||
|
.help(self.canInstallLocally ? "" : "Local install requires a local gateway connection.")
|
||||||
|
}
|
||||||
|
ForEach(self.installOptions) { option in
|
||||||
|
Button("Install") { self.onInstall(option, self.installTarget) }
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(self.isBusy || self.installBlocked)
|
||||||
|
.help(self.installBlocked ? "Local install requires a local gateway connection." : "")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Toggle("", isOn: self.enabledBinding)
|
Toggle("", isOn: self.enabledBinding)
|
||||||
@@ -390,12 +408,8 @@ private struct SkillRow: View {
|
|||||||
!self.missingConfig.isEmpty
|
!self.missingConfig.isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
private var showGatewayInstall: Bool {
|
private var installBlocked: Bool {
|
||||||
self.connectionMode == .remote
|
self.installTarget == .local && !self.canInstallLocally
|
||||||
}
|
|
||||||
|
|
||||||
private var localInstallNeedsSwitch: Bool {
|
|
||||||
self.connectionMode != .local
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func formatConfigValue(_ value: AnyCodable?) -> String {
|
private func formatConfigValue(_ value: AnyCodable?) -> String {
|
||||||
@@ -507,8 +521,8 @@ final class SkillsSettingsModel {
|
|||||||
await self.withBusy(skill.skillKey) {
|
await self.withBusy(skill.skillKey) {
|
||||||
do {
|
do {
|
||||||
if target == .local, AppStateStore.shared.connectionMode != .local {
|
if target == .local, AppStateStore.shared.connectionMode != .local {
|
||||||
AppStateStore.shared.connectionMode = .local
|
self.statusMessage = "Local install requires a local gateway connection"
|
||||||
self.statusMessage = "Switched to Local mode to install on this Mac"
|
return
|
||||||
}
|
}
|
||||||
let result = try await GatewayConnection.shared.skillsInstall(
|
let result = try await GatewayConnection.shared.skillsInstall(
|
||||||
name: skill.name,
|
name: skill.name,
|
||||||
|
|||||||
@@ -1,372 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
private enum GatewayTailscaleMode: String, CaseIterable, Identifiable {
|
|
||||||
case off
|
|
||||||
case serve
|
|
||||||
case funnel
|
|
||||||
|
|
||||||
var id: String { self.rawValue }
|
|
||||||
|
|
||||||
var label: String {
|
|
||||||
switch self {
|
|
||||||
case .off: "Off"
|
|
||||||
case .serve: "Tailnet (Serve)"
|
|
||||||
case .funnel: "Public (Funnel)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var description: String {
|
|
||||||
switch self {
|
|
||||||
case .off:
|
|
||||||
"No automatic Tailscale configuration."
|
|
||||||
case .serve:
|
|
||||||
"Tailnet-only HTTPS via Tailscale Serve."
|
|
||||||
case .funnel:
|
|
||||||
"Public HTTPS via Tailscale Funnel (requires auth)."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum GatewayAuthMode: String, CaseIterable, Identifiable {
|
|
||||||
case system
|
|
||||||
case password
|
|
||||||
|
|
||||||
var id: String { self.rawValue }
|
|
||||||
|
|
||||||
var label: String {
|
|
||||||
switch self {
|
|
||||||
case .system: "System password"
|
|
||||||
case .password: "Shared password"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TailscaleIntegrationSection: View {
|
|
||||||
let connectionMode: AppState.ConnectionMode
|
|
||||||
let isPaused: Bool
|
|
||||||
|
|
||||||
@Environment(TailscaleService.self) private var tailscaleService
|
|
||||||
|
|
||||||
@State private var hasLoaded = false
|
|
||||||
@State private var tailscaleMode: GatewayTailscaleMode = .off
|
|
||||||
@State private var requireCredentialsForServe = false
|
|
||||||
@State private var authMode: GatewayAuthMode = .system
|
|
||||||
@State private var username: String = ""
|
|
||||||
@State private var password: String = ""
|
|
||||||
@State private var statusMessage: String?
|
|
||||||
@State private var validationMessage: String?
|
|
||||||
@State private var statusTimer: Timer?
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
|
||||||
Text("Tailscale (dashboard access)")
|
|
||||||
.font(.callout.weight(.semibold))
|
|
||||||
|
|
||||||
self.statusRow
|
|
||||||
|
|
||||||
if !self.tailscaleService.isInstalled {
|
|
||||||
self.installButtons
|
|
||||||
} else {
|
|
||||||
self.modePicker
|
|
||||||
if self.tailscaleMode != .off {
|
|
||||||
self.accessURLRow
|
|
||||||
}
|
|
||||||
if self.tailscaleMode == .serve {
|
|
||||||
self.serveAuthSection
|
|
||||||
}
|
|
||||||
if self.tailscaleMode == .funnel {
|
|
||||||
self.funnelAuthSection
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.connectionMode != .local {
|
|
||||||
Text("Local mode required. Update settings on the gateway host.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let validationMessage {
|
|
||||||
Text(validationMessage)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.orange)
|
|
||||||
} else if let statusMessage {
|
|
||||||
Text(statusMessage)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(12)
|
|
||||||
.background(Color.gray.opacity(0.08))
|
|
||||||
.cornerRadius(10)
|
|
||||||
.disabled(self.connectionMode != .local)
|
|
||||||
.task {
|
|
||||||
guard !self.hasLoaded else { return }
|
|
||||||
self.hasLoaded = true
|
|
||||||
self.loadConfig()
|
|
||||||
await self.tailscaleService.checkTailscaleStatus()
|
|
||||||
self.startStatusTimer()
|
|
||||||
}
|
|
||||||
.onDisappear {
|
|
||||||
self.stopStatusTimer()
|
|
||||||
}
|
|
||||||
.onChange(of: self.tailscaleMode) { _, _ in
|
|
||||||
self.applySettings()
|
|
||||||
}
|
|
||||||
.onChange(of: self.requireCredentialsForServe) { _, _ in
|
|
||||||
self.applySettings()
|
|
||||||
}
|
|
||||||
.onChange(of: self.authMode) { _, _ in
|
|
||||||
self.applySettings()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var statusRow: some View {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Circle()
|
|
||||||
.fill(self.statusColor)
|
|
||||||
.frame(width: 10, height: 10)
|
|
||||||
Text(self.statusText)
|
|
||||||
.font(.callout)
|
|
||||||
Spacer()
|
|
||||||
Button("Refresh") {
|
|
||||||
Task { await self.tailscaleService.checkTailscaleStatus() }
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.controlSize(.small)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var statusColor: Color {
|
|
||||||
if !self.tailscaleService.isInstalled { return .yellow }
|
|
||||||
if self.tailscaleService.isRunning { return .green }
|
|
||||||
return .orange
|
|
||||||
}
|
|
||||||
|
|
||||||
private var statusText: String {
|
|
||||||
if !self.tailscaleService.isInstalled { return "Tailscale is not installed" }
|
|
||||||
if self.tailscaleService.isRunning { return "Tailscale is installed and running" }
|
|
||||||
return "Tailscale is installed but not running"
|
|
||||||
}
|
|
||||||
|
|
||||||
private var installButtons: some View {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Button("App Store") { self.tailscaleService.openAppStore() }
|
|
||||||
.buttonStyle(.link)
|
|
||||||
Button("Direct Download") { self.tailscaleService.openDownloadPage() }
|
|
||||||
.buttonStyle(.link)
|
|
||||||
Button("Setup Guide") { self.tailscaleService.openSetupGuide() }
|
|
||||||
.buttonStyle(.link)
|
|
||||||
}
|
|
||||||
.controlSize(.small)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var modePicker: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
Text("Exposure mode")
|
|
||||||
.font(.callout.weight(.semibold))
|
|
||||||
Picker("Exposure", selection: self.$tailscaleMode) {
|
|
||||||
ForEach(GatewayTailscaleMode.allCases) { mode in
|
|
||||||
Text(mode.label).tag(mode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.pickerStyle(.segmented)
|
|
||||||
Text(self.tailscaleMode.description)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var accessURLRow: some View {
|
|
||||||
if let host = self.tailscaleService.tailscaleHostname {
|
|
||||||
let url = "https://\(host)/ui/"
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Text("Dashboard URL:")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
if let link = URL(string: url) {
|
|
||||||
Link(url, destination: link)
|
|
||||||
.font(.system(.caption, design: .monospaced))
|
|
||||||
} else {
|
|
||||||
Text(url)
|
|
||||||
.font(.system(.caption, design: .monospaced))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if !self.tailscaleService.isRunning {
|
|
||||||
Text("Start Tailscale to get your tailnet hostname.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.tailscaleService.isInstalled, !self.tailscaleService.isRunning {
|
|
||||||
Button("Start Tailscale") { self.tailscaleService.openTailscaleApp() }
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.controlSize(.small)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var serveAuthSection: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Toggle("Require credentials", isOn: self.$requireCredentialsForServe)
|
|
||||||
.toggleStyle(.checkbox)
|
|
||||||
if self.requireCredentialsForServe {
|
|
||||||
self.authModePicker
|
|
||||||
self.authFields
|
|
||||||
} else {
|
|
||||||
Text("Serve uses Tailscale identity headers; no password required.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var funnelAuthSection: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text("Funnel requires authentication.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
self.authModePicker
|
|
||||||
self.authFields
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var authModePicker: some View {
|
|
||||||
Picker("Auth", selection: self.$authMode) {
|
|
||||||
ForEach(GatewayAuthMode.allCases) { mode in
|
|
||||||
Text(mode.label).tag(mode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.pickerStyle(.segmented)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var authFields: some View {
|
|
||||||
if self.authMode == .system {
|
|
||||||
TextField("Username (optional)", text: self.$username)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
.frame(maxWidth: 240)
|
|
||||||
.onSubmit { self.applySettings() }
|
|
||||||
} else {
|
|
||||||
SecureField("Password", text: self.$password)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
.frame(maxWidth: 240)
|
|
||||||
.onSubmit { self.applySettings() }
|
|
||||||
Text("Stored in ~/.clawdis/clawdis.json. Prefer CLAWDIS_GATEWAY_PASSWORD for production.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Button("Update password") { self.applySettings() }
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.controlSize(.small)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadConfig() {
|
|
||||||
let gateway = ClawdisConfigFile.loadGatewayDict()
|
|
||||||
let tailscale = gateway["tailscale"] as? [String: Any] ?? [:]
|
|
||||||
let modeRaw = (tailscale["mode"] as? String) ?? "off"
|
|
||||||
self.tailscaleMode = GatewayTailscaleMode(rawValue: modeRaw) ?? .off
|
|
||||||
|
|
||||||
let auth = gateway["auth"] as? [String: Any] ?? [:]
|
|
||||||
let authModeRaw = auth["mode"] as? String
|
|
||||||
let allowTailscale = auth["allowTailscale"] as? Bool
|
|
||||||
|
|
||||||
if let authModeRaw, authModeRaw == "password" {
|
|
||||||
self.authMode = .password
|
|
||||||
} else {
|
|
||||||
self.authMode = .system
|
|
||||||
}
|
|
||||||
|
|
||||||
self.username = auth["username"] as? String ?? ""
|
|
||||||
self.password = auth["password"] as? String ?? ""
|
|
||||||
|
|
||||||
if self.tailscaleMode == .serve {
|
|
||||||
let usesExplicitAuth = authModeRaw == "password" || authModeRaw == "system"
|
|
||||||
if let allowTailscale, allowTailscale == false {
|
|
||||||
self.requireCredentialsForServe = true
|
|
||||||
} else {
|
|
||||||
self.requireCredentialsForServe = usesExplicitAuth
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.requireCredentialsForServe = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func applySettings() {
|
|
||||||
guard self.hasLoaded else { return }
|
|
||||||
self.validationMessage = nil
|
|
||||||
self.statusMessage = nil
|
|
||||||
|
|
||||||
let trimmedPassword = self.password.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
let requiresPassword = self.tailscaleMode == .funnel
|
|
||||||
|| (self.tailscaleMode == .serve && self.requireCredentialsForServe)
|
|
||||||
if requiresPassword, self.authMode == .password, trimmedPassword.isEmpty {
|
|
||||||
self.validationMessage = "Password required for this mode."
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ClawdisConfigFile.updateGatewayDict { gateway in
|
|
||||||
var tailscale = gateway["tailscale"] as? [String: Any] ?? [:]
|
|
||||||
tailscale["mode"] = self.tailscaleMode.rawValue
|
|
||||||
gateway["tailscale"] = tailscale
|
|
||||||
|
|
||||||
if self.tailscaleMode != .off {
|
|
||||||
gateway["bind"] = "loopback"
|
|
||||||
}
|
|
||||||
|
|
||||||
guard self.tailscaleMode != .off else { return }
|
|
||||||
var auth = gateway["auth"] as? [String: Any] ?? [:]
|
|
||||||
|
|
||||||
if self.tailscaleMode == .serve, !self.requireCredentialsForServe {
|
|
||||||
auth["allowTailscale"] = true
|
|
||||||
auth.removeValue(forKey: "mode")
|
|
||||||
auth.removeValue(forKey: "password")
|
|
||||||
auth.removeValue(forKey: "username")
|
|
||||||
} else {
|
|
||||||
auth["allowTailscale"] = false
|
|
||||||
auth["mode"] = self.authMode.rawValue
|
|
||||||
if self.authMode == .password {
|
|
||||||
auth["password"] = trimmedPassword
|
|
||||||
auth.removeValue(forKey: "username")
|
|
||||||
} else {
|
|
||||||
let trimmedUsername = self.username.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if trimmedUsername.isEmpty {
|
|
||||||
auth.removeValue(forKey: "username")
|
|
||||||
} else {
|
|
||||||
auth["username"] = trimmedUsername
|
|
||||||
}
|
|
||||||
auth.removeValue(forKey: "password")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if auth.isEmpty {
|
|
||||||
gateway.removeValue(forKey: "auth")
|
|
||||||
} else {
|
|
||||||
gateway["auth"] = auth
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.connectionMode == .local, !self.isPaused {
|
|
||||||
self.statusMessage = "Saved to ~/.clawdis/clawdis.json. Restarting gateway…"
|
|
||||||
} else {
|
|
||||||
self.statusMessage = "Saved to ~/.clawdis/clawdis.json. Restart the gateway to apply."
|
|
||||||
}
|
|
||||||
self.restartGatewayIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func restartGatewayIfNeeded() {
|
|
||||||
guard self.connectionMode == .local, !self.isPaused else { return }
|
|
||||||
Task { await GatewayLaunchAgentManager.kickstart() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startStatusTimer() {
|
|
||||||
self.stopStatusTimer()
|
|
||||||
self.statusTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { _ in
|
|
||||||
Task { await self.tailscaleService.checkTailscaleStatus() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func stopStatusTimer() {
|
|
||||||
self.statusTimer?.invalidate()
|
|
||||||
self.statusTimer = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
import AppKit
|
|
||||||
import Foundation
|
|
||||||
import Observation
|
|
||||||
import os
|
|
||||||
|
|
||||||
/// Manages Tailscale integration and status checking.
|
|
||||||
@Observable
|
|
||||||
@MainActor
|
|
||||||
final class TailscaleService {
|
|
||||||
static let shared = TailscaleService()
|
|
||||||
|
|
||||||
/// Tailscale local API endpoint.
|
|
||||||
private static let tailscaleAPIEndpoint = "http://100.100.100.100/api/data"
|
|
||||||
|
|
||||||
/// API request timeout in seconds.
|
|
||||||
private static let apiTimeoutInterval: TimeInterval = 5.0
|
|
||||||
|
|
||||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "tailscale")
|
|
||||||
|
|
||||||
/// Indicates if the Tailscale app is installed on the system.
|
|
||||||
private(set) var isInstalled = false
|
|
||||||
|
|
||||||
/// Indicates if Tailscale is currently running.
|
|
||||||
private(set) var isRunning = false
|
|
||||||
|
|
||||||
/// The Tailscale hostname for this device (e.g., "my-mac.tailnet.ts.net").
|
|
||||||
private(set) var tailscaleHostname: String?
|
|
||||||
|
|
||||||
/// The Tailscale IPv4 address for this device.
|
|
||||||
private(set) var tailscaleIP: String?
|
|
||||||
|
|
||||||
/// Error message if status check fails.
|
|
||||||
private(set) var statusError: String?
|
|
||||||
|
|
||||||
private init() {
|
|
||||||
Task { await self.checkTailscaleStatus() }
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkAppInstallation() -> Bool {
|
|
||||||
let installed = FileManager.default.fileExists(atPath: "/Applications/Tailscale.app")
|
|
||||||
self.logger.info("Tailscale app installed: \(installed)")
|
|
||||||
return installed
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct TailscaleAPIResponse: Codable {
|
|
||||||
let status: String
|
|
||||||
let deviceName: String
|
|
||||||
let tailnetName: String
|
|
||||||
let iPv4: String?
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case status = "Status"
|
|
||||||
case deviceName = "DeviceName"
|
|
||||||
case tailnetName = "TailnetName"
|
|
||||||
case iPv4 = "IPv4"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func fetchTailscaleStatus() async -> TailscaleAPIResponse? {
|
|
||||||
guard let url = URL(string: Self.tailscaleAPIEndpoint) else {
|
|
||||||
self.logger.error("Invalid Tailscale API URL")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
let configuration = URLSessionConfiguration.default
|
|
||||||
configuration.timeoutIntervalForRequest = Self.apiTimeoutInterval
|
|
||||||
let session = URLSession(configuration: configuration)
|
|
||||||
|
|
||||||
let (data, response) = try await session.data(from: url)
|
|
||||||
guard let httpResponse = response as? HTTPURLResponse,
|
|
||||||
httpResponse.statusCode == 200
|
|
||||||
else {
|
|
||||||
self.logger.warning("Tailscale API returned non-200 status")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let decoder = JSONDecoder()
|
|
||||||
return try decoder.decode(TailscaleAPIResponse.self, from: data)
|
|
||||||
} catch {
|
|
||||||
self.logger.debug("Failed to fetch Tailscale status: \(String(describing: error))")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkTailscaleStatus() async {
|
|
||||||
self.isInstalled = self.checkAppInstallation()
|
|
||||||
guard self.isInstalled else {
|
|
||||||
self.isRunning = false
|
|
||||||
self.tailscaleHostname = nil
|
|
||||||
self.tailscaleIP = nil
|
|
||||||
self.statusError = "Tailscale is not installed"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if let apiResponse = await fetchTailscaleStatus() {
|
|
||||||
self.isRunning = apiResponse.status.lowercased() == "running"
|
|
||||||
|
|
||||||
if self.isRunning {
|
|
||||||
let deviceName = apiResponse.deviceName
|
|
||||||
.lowercased()
|
|
||||||
.replacingOccurrences(of: " ", with: "-")
|
|
||||||
let tailnetName = apiResponse.tailnetName
|
|
||||||
.replacingOccurrences(of: ".ts.net", with: "")
|
|
||||||
.replacingOccurrences(of: ".tailscale.net", with: "")
|
|
||||||
|
|
||||||
self.tailscaleHostname = "\(deviceName).\(tailnetName).ts.net"
|
|
||||||
self.tailscaleIP = apiResponse.iPv4
|
|
||||||
self.statusError = nil
|
|
||||||
|
|
||||||
self.logger.info(
|
|
||||||
"Tailscale running host=\(self.tailscaleHostname ?? "nil") ip=\(self.tailscaleIP ?? "nil")")
|
|
||||||
} else {
|
|
||||||
self.tailscaleHostname = nil
|
|
||||||
self.tailscaleIP = nil
|
|
||||||
self.statusError = "Tailscale is not running"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.isRunning = false
|
|
||||||
self.tailscaleHostname = nil
|
|
||||||
self.tailscaleIP = nil
|
|
||||||
self.statusError = "Please start the Tailscale app"
|
|
||||||
self.logger.info("Tailscale API not responding; app likely not running")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func openTailscaleApp() {
|
|
||||||
if let url = URL(string: "file:///Applications/Tailscale.app") {
|
|
||||||
NSWorkspace.shared.open(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func openAppStore() {
|
|
||||||
if let url = URL(string: "https://apps.apple.com/us/app/tailscale/id1475387142") {
|
|
||||||
NSWorkspace.shared.open(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func openDownloadPage() {
|
|
||||||
if let url = URL(string: "https://tailscale.com/download/macos") {
|
|
||||||
NSWorkspace.shared.open(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func openSetupGuide() {
|
|
||||||
if let url = URL(string: "https://tailscale.com/kb/1017/install/") {
|
|
||||||
NSWorkspace.shared.open(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ import AVFoundation
|
|||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
import Speech
|
import Speech
|
||||||
import SwabbleKit
|
|
||||||
#if canImport(AppKit)
|
#if canImport(AppKit)
|
||||||
import AppKit
|
import AppKit
|
||||||
#endif
|
#endif
|
||||||
@@ -36,7 +35,6 @@ actor VoiceWakeRuntime {
|
|||||||
private var currentConfig: RuntimeConfig?
|
private var currentConfig: RuntimeConfig?
|
||||||
private var listeningState: ListeningState = .idle
|
private var listeningState: ListeningState = .idle
|
||||||
private var overlayToken: UUID?
|
private var overlayToken: UUID?
|
||||||
private var activeTriggerEndTime: TimeInterval?
|
|
||||||
|
|
||||||
// Tunables
|
// Tunables
|
||||||
// Silence threshold once we've captured user speech (post-trigger).
|
// Silence threshold once we've captured user speech (post-trigger).
|
||||||
@@ -149,13 +147,9 @@ actor VoiceWakeRuntime {
|
|||||||
self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self, generation] result, error in
|
self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self, generation] result, error in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
let transcript = result?.bestTranscription.formattedString
|
let transcript = result?.bestTranscription.formattedString
|
||||||
let segments = result.flatMap { result in
|
|
||||||
transcript.map { WakeWordSpeechSegments.from(transcription: result.bestTranscription, transcript: $0) }
|
|
||||||
} ?? []
|
|
||||||
let isFinal = result?.isFinal ?? false
|
let isFinal = result?.isFinal ?? false
|
||||||
Task { await self.handleRecognition(
|
Task { await self.handleRecognition(
|
||||||
transcript: transcript,
|
transcript: transcript,
|
||||||
segments: segments,
|
|
||||||
isFinal: isFinal,
|
isFinal: isFinal,
|
||||||
error: error,
|
error: error,
|
||||||
config: config,
|
config: config,
|
||||||
@@ -190,7 +184,6 @@ actor VoiceWakeRuntime {
|
|||||||
self.audioEngine = nil
|
self.audioEngine = nil
|
||||||
self.currentConfig = nil
|
self.currentConfig = nil
|
||||||
self.listeningState = .idle
|
self.listeningState = .idle
|
||||||
self.activeTriggerEndTime = nil
|
|
||||||
self.logger.debug("voicewake runtime stopped")
|
self.logger.debug("voicewake runtime stopped")
|
||||||
DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "stopped")
|
DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "stopped")
|
||||||
|
|
||||||
@@ -213,7 +206,6 @@ actor VoiceWakeRuntime {
|
|||||||
|
|
||||||
private func handleRecognition(
|
private func handleRecognition(
|
||||||
transcript: String?,
|
transcript: String?,
|
||||||
segments: [WakeWordSegment],
|
|
||||||
isFinal: Bool,
|
isFinal: Bool,
|
||||||
error: Error?,
|
error: Error?,
|
||||||
config: RuntimeConfig,
|
config: RuntimeConfig,
|
||||||
@@ -232,11 +224,7 @@ actor VoiceWakeRuntime {
|
|||||||
if !transcript.isEmpty {
|
if !transcript.isEmpty {
|
||||||
self.lastHeard = now
|
self.lastHeard = now
|
||||||
if self.isCapturing {
|
if self.isCapturing {
|
||||||
let trimmed = Self.commandAfterTrigger(
|
let trimmed = Self.trimmedAfterTrigger(transcript, triggers: config.triggers)
|
||||||
transcript: transcript,
|
|
||||||
segments: segments,
|
|
||||||
triggerEndTime: self.activeTriggerEndTime,
|
|
||||||
triggers: config.triggers)
|
|
||||||
self.capturedTranscript = trimmed
|
self.capturedTranscript = trimmed
|
||||||
self.updateHeardBeyondTrigger(withTrimmed: trimmed)
|
self.updateHeardBeyondTrigger(withTrimmed: trimmed)
|
||||||
if isFinal {
|
if isFinal {
|
||||||
@@ -264,27 +252,37 @@ actor VoiceWakeRuntime {
|
|||||||
|
|
||||||
if self.isCapturing { return }
|
if self.isCapturing { return }
|
||||||
|
|
||||||
let gateConfig = WakeWordGateConfig(triggers: config.triggers)
|
if Self.matches(text: transcript, triggers: config.triggers) {
|
||||||
if let match = WakeWordGate.match(transcript: transcript, segments: segments, config: gateConfig) {
|
|
||||||
if let cooldown = cooldownUntil, now < cooldown {
|
if let cooldown = cooldownUntil, now < cooldown {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await self.beginCapture(command: match.command, triggerEndTime: match.triggerEndTime, config: config)
|
await self.beginCapture(transcript: transcript, config: config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func beginCapture(command: String, triggerEndTime: TimeInterval, config: RuntimeConfig) async {
|
private static func matches(text: String, triggers: [String]) -> Bool {
|
||||||
|
guard !text.isEmpty else { return false }
|
||||||
|
let normalized = text.lowercased()
|
||||||
|
for trigger in triggers {
|
||||||
|
let t = trigger.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if t.isEmpty { continue }
|
||||||
|
if normalized.contains(t) { return true }
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func beginCapture(transcript: String, config: RuntimeConfig) async {
|
||||||
self.listeningState = .voiceWake
|
self.listeningState = .voiceWake
|
||||||
self.isCapturing = true
|
self.isCapturing = true
|
||||||
DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "beginCapture")
|
DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "beginCapture")
|
||||||
self.capturedTranscript = command
|
let trimmed = Self.trimmedAfterTrigger(transcript, triggers: config.triggers)
|
||||||
|
self.capturedTranscript = trimmed
|
||||||
self.committedTranscript = ""
|
self.committedTranscript = ""
|
||||||
self.volatileTranscript = command
|
self.volatileTranscript = trimmed
|
||||||
self.captureStartedAt = Date()
|
self.captureStartedAt = Date()
|
||||||
self.cooldownUntil = nil
|
self.cooldownUntil = nil
|
||||||
self.heardBeyondTrigger = !command.isEmpty
|
self.heardBeyondTrigger = !trimmed.isEmpty
|
||||||
self.triggerChimePlayed = false
|
self.triggerChimePlayed = false
|
||||||
self.activeTriggerEndTime = triggerEndTime
|
|
||||||
|
|
||||||
if config.triggerChime != .none, !self.triggerChimePlayed {
|
if config.triggerChime != .none, !self.triggerChimePlayed {
|
||||||
self.triggerChimePlayed = true
|
self.triggerChimePlayed = true
|
||||||
@@ -356,7 +354,6 @@ actor VoiceWakeRuntime {
|
|||||||
self.lastHeard = nil
|
self.lastHeard = nil
|
||||||
self.heardBeyondTrigger = false
|
self.heardBeyondTrigger = false
|
||||||
self.triggerChimePlayed = false
|
self.triggerChimePlayed = false
|
||||||
self.activeTriggerEndTime = nil
|
|
||||||
|
|
||||||
await MainActor.run { AppStateStore.shared.stopVoiceEars() }
|
await MainActor.run { AppStateStore.shared.stopVoiceEars() }
|
||||||
if let token = self.overlayToken {
|
if let token = self.overlayToken {
|
||||||
@@ -470,22 +467,6 @@ actor VoiceWakeRuntime {
|
|||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func commandAfterTrigger(
|
|
||||||
transcript: String,
|
|
||||||
segments: [WakeWordSegment],
|
|
||||||
triggerEndTime: TimeInterval?,
|
|
||||||
triggers: [String]) -> String
|
|
||||||
{
|
|
||||||
guard let triggerEndTime else {
|
|
||||||
return trimmedAfterTrigger(transcript, triggers: triggers)
|
|
||||||
}
|
|
||||||
let trimmed = WakeWordGate.commandText(
|
|
||||||
transcript: transcript,
|
|
||||||
segments: segments,
|
|
||||||
triggerEndTime: triggerEndTime)
|
|
||||||
return trimmed.isEmpty ? trimmedAfterTrigger(transcript, triggers: triggers) : trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
static func _testTrimmedAfterTrigger(_ text: String, triggers: [String]) -> String {
|
static func _testTrimmedAfterTrigger(_ text: String, triggers: [String]) -> String {
|
||||||
self.trimmedAfterTrigger(text, triggers: triggers)
|
self.trimmedAfterTrigger(text, triggers: triggers)
|
||||||
@@ -500,6 +481,9 @@ actor VoiceWakeRuntime {
|
|||||||
.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor ?? .clear
|
.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor ?? .clear
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func _testMatches(text: String, triggers: [String]) -> Bool {
|
||||||
|
self.matches(text: text, triggers: triggers)
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
private static func delta(after committed: String, current: String) -> String {
|
private static func delta(after committed: String, current: String) -> String {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import AVFoundation
|
|||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
import Speech
|
import Speech
|
||||||
import SwabbleKit
|
|
||||||
|
|
||||||
enum VoiceWakeTestState: Equatable {
|
enum VoiceWakeTestState: Equatable {
|
||||||
case idle
|
case idle
|
||||||
@@ -94,16 +93,14 @@ final class VoiceWakeTester {
|
|||||||
self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in
|
self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in
|
||||||
guard let self, !self.isStopping else { return }
|
guard let self, !self.isStopping else { return }
|
||||||
let text = result?.bestTranscription.formattedString ?? ""
|
let text = result?.bestTranscription.formattedString ?? ""
|
||||||
let segments = result.map { WakeWordSpeechSegments.from(transcription: $0.bestTranscription, transcript: text) } ?? []
|
let matched = Self.matches(text: text, triggers: triggers)
|
||||||
let gateConfig = WakeWordGateConfig(triggers: triggers)
|
|
||||||
let match = WakeWordGate.match(transcript: text, segments: segments, config: gateConfig)
|
|
||||||
let isFinal = result?.isFinal ?? false
|
let isFinal = result?.isFinal ?? false
|
||||||
let errorMessage = error?.localizedDescription
|
let errorMessage = error?.localizedDescription
|
||||||
|
|
||||||
Task { [weak self] in
|
Task { [weak self] in
|
||||||
guard let self, !self.isStopping else { return }
|
guard let self, !self.isStopping else { return }
|
||||||
await self.handleResult(
|
await self.handleResult(
|
||||||
match: match,
|
matched: matched,
|
||||||
text: text,
|
text: text,
|
||||||
isFinal: isFinal,
|
isFinal: isFinal,
|
||||||
errorMessage: errorMessage,
|
errorMessage: errorMessage,
|
||||||
@@ -123,7 +120,7 @@ final class VoiceWakeTester {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleResult(
|
private func handleResult(
|
||||||
match: WakeWordGateMatch?,
|
matched: Bool,
|
||||||
text: String,
|
text: String,
|
||||||
isFinal: Bool,
|
isFinal: Bool,
|
||||||
errorMessage: String?,
|
errorMessage: String?,
|
||||||
@@ -132,15 +129,15 @@ final class VoiceWakeTester {
|
|||||||
if !text.isEmpty {
|
if !text.isEmpty {
|
||||||
self.lastHeard = Date()
|
self.lastHeard = Date()
|
||||||
}
|
}
|
||||||
if let match, !match.command.isEmpty {
|
if matched, !text.isEmpty {
|
||||||
self.holdingAfterDetect = true
|
self.holdingAfterDetect = true
|
||||||
self.detectedText = match.command
|
self.detectedText = text
|
||||||
self.logger.info("voice wake detected; forwarding (len=\(match.command.count))")
|
self.logger.info("voice wake detected; forwarding (len=\(text.count))")
|
||||||
await MainActor.run { AppStateStore.shared.triggerVoiceEars(ttl: nil) }
|
await MainActor.run { AppStateStore.shared.triggerVoiceEars(ttl: nil) }
|
||||||
Task.detached {
|
Task.detached {
|
||||||
await VoiceWakeForwarder.forward(transcript: match.command)
|
await VoiceWakeForwarder.forward(transcript: text)
|
||||||
}
|
}
|
||||||
Task { @MainActor in onUpdate(.detected(match.command)) }
|
Task { @MainActor in onUpdate(.detected(text)) }
|
||||||
self.holdUntilSilence(onUpdate: onUpdate)
|
self.holdUntilSilence(onUpdate: onUpdate)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -190,6 +187,15 @@ final class VoiceWakeTester {
|
|||||||
_ = preferredMicID
|
_ = preferredMicID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func matches(text: String, triggers: [String]) -> Bool {
|
||||||
|
let lowered = text.lowercased()
|
||||||
|
return triggers.contains { lowered.contains($0.lowercased()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func _testMatches(text: String, triggers: [String]) -> Bool {
|
||||||
|
self.matches(text: text, triggers: triggers)
|
||||||
|
}
|
||||||
|
|
||||||
private nonisolated static func ensurePermissions() async throws -> Bool {
|
private nonisolated static func ensurePermissions() async throws -> Bool {
|
||||||
let speechStatus = SFSpeechRecognizer.authorizationStatus()
|
let speechStatus = SFSpeechRecognizer.authorizationStatus()
|
||||||
if speechStatus == .notDetermined {
|
if speechStatus == .notDetermined {
|
||||||
|
|||||||
@@ -140,7 +140,6 @@ final class WebChatSwiftUIWindowController {
|
|||||||
private let presentation: WebChatPresentation
|
private let presentation: WebChatPresentation
|
||||||
private let sessionKey: String
|
private let sessionKey: String
|
||||||
private let hosting: NSHostingController<ClawdisChatView>
|
private let hosting: NSHostingController<ClawdisChatView>
|
||||||
private let contentController: NSViewController
|
|
||||||
private var window: NSWindow?
|
private var window: NSWindow?
|
||||||
private var dismissMonitor: Any?
|
private var dismissMonitor: Any?
|
||||||
var onClosed: (() -> Void)?
|
var onClosed: (() -> Void)?
|
||||||
@@ -155,8 +154,11 @@ final class WebChatSwiftUIWindowController {
|
|||||||
self.presentation = presentation
|
self.presentation = presentation
|
||||||
let vm = ClawdisChatViewModel(sessionKey: sessionKey, transport: transport)
|
let vm = ClawdisChatViewModel(sessionKey: sessionKey, transport: transport)
|
||||||
self.hosting = NSHostingController(rootView: ClawdisChatView(viewModel: vm))
|
self.hosting = NSHostingController(rootView: ClawdisChatView(viewModel: vm))
|
||||||
self.contentController = Self.makeContentController(for: presentation, hosting: self.hosting)
|
self.hosting.view.wantsLayer = true
|
||||||
self.window = Self.makeWindow(for: presentation, contentViewController: self.contentController)
|
self.hosting.view.layer?.cornerCurve = .continuous
|
||||||
|
self.hosting.view.layer?.cornerRadius = 16
|
||||||
|
self.hosting.view.layer?.masksToBounds = true
|
||||||
|
self.window = Self.makeWindow(for: presentation, contentViewController: self.hosting)
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {}
|
deinit {}
|
||||||
@@ -265,13 +267,11 @@ final class WebChatSwiftUIWindowController {
|
|||||||
window.isReleasedWhenClosed = false
|
window.isReleasedWhenClosed = false
|
||||||
window.titleVisibility = .visible
|
window.titleVisibility = .visible
|
||||||
window.titlebarAppearsTransparent = false
|
window.titlebarAppearsTransparent = false
|
||||||
window.backgroundColor = .clear
|
window.backgroundColor = .windowBackgroundColor
|
||||||
window.isOpaque = false
|
window.isOpaque = true
|
||||||
window.center()
|
window.center()
|
||||||
WindowPlacement.ensureOnScreen(window: window, defaultSize: WebChatSwiftUILayout.windowSize)
|
WindowPlacement.ensureOnScreen(window: window, defaultSize: WebChatSwiftUILayout.windowSize)
|
||||||
window.minSize = NSSize(width: 880, height: 680)
|
window.minSize = NSSize(width: 880, height: 680)
|
||||||
window.contentView?.wantsLayer = true
|
|
||||||
window.contentView?.layer?.backgroundColor = NSColor.clear.cgColor
|
|
||||||
return window
|
return window
|
||||||
case .panel:
|
case .panel:
|
||||||
let panel = WebChatPanel(
|
let panel = WebChatPanel(
|
||||||
@@ -290,8 +290,6 @@ final class WebChatSwiftUIWindowController {
|
|||||||
panel.isOpaque = false
|
panel.isOpaque = false
|
||||||
panel.contentViewController = contentViewController
|
panel.contentViewController = contentViewController
|
||||||
panel.becomesKeyOnlyIfNeeded = true
|
panel.becomesKeyOnlyIfNeeded = true
|
||||||
panel.contentView?.wantsLayer = true
|
|
||||||
panel.contentView?.layer?.backgroundColor = NSColor.clear.cgColor
|
|
||||||
panel.setFrame(
|
panel.setFrame(
|
||||||
WindowPlacement.topRightFrame(
|
WindowPlacement.topRightFrame(
|
||||||
size: WebChatSwiftUILayout.panelSize,
|
size: WebChatSwiftUILayout.panelSize,
|
||||||
@@ -300,45 +298,4 @@ final class WebChatSwiftUIWindowController {
|
|||||||
return panel
|
return panel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func makeContentController(
|
|
||||||
for presentation: WebChatPresentation,
|
|
||||||
hosting: NSHostingController<ClawdisChatView>) -> NSViewController
|
|
||||||
{
|
|
||||||
let controller = NSViewController()
|
|
||||||
let effectView = NSVisualEffectView()
|
|
||||||
effectView.material = .sidebar
|
|
||||||
effectView.blendingMode = .behindWindow
|
|
||||||
effectView.state = .active
|
|
||||||
effectView.translatesAutoresizingMaskIntoConstraints = true
|
|
||||||
effectView.autoresizingMask = [.width, .height]
|
|
||||||
effectView.wantsLayer = true
|
|
||||||
effectView.layer?.cornerCurve = .continuous
|
|
||||||
let cornerRadius: CGFloat
|
|
||||||
switch presentation {
|
|
||||||
case .panel:
|
|
||||||
cornerRadius = 16
|
|
||||||
case .window:
|
|
||||||
cornerRadius = 12
|
|
||||||
}
|
|
||||||
effectView.layer?.cornerRadius = cornerRadius
|
|
||||||
effectView.layer?.masksToBounds = true
|
|
||||||
|
|
||||||
hosting.view.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
hosting.view.wantsLayer = true
|
|
||||||
hosting.view.layer?.backgroundColor = NSColor.clear.cgColor
|
|
||||||
|
|
||||||
controller.addChild(hosting)
|
|
||||||
effectView.addSubview(hosting.view)
|
|
||||||
controller.view = effectView
|
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
hosting.view.leadingAnchor.constraint(equalTo: effectView.leadingAnchor),
|
|
||||||
hosting.view.trailingAnchor.constraint(equalTo: effectView.trailingAnchor),
|
|
||||||
hosting.view.topAnchor.constraint(equalTo: effectView.topAnchor),
|
|
||||||
hosting.view.bottomAnchor.constraint(equalTo: effectView.bottomAnchor)
|
|
||||||
])
|
|
||||||
|
|
||||||
return controller
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Clawdis
|
||||||
|
|
||||||
|
@Suite
|
||||||
|
struct AgentIdentityTests {
|
||||||
|
@Test
|
||||||
|
func isEmptyTreatsWhitespaceAsEmpty() {
|
||||||
|
#expect(AgentIdentity(name: " ", theme: "\n", emoji: "\t").isEmpty == true)
|
||||||
|
#expect(AgentIdentity(name: "Pi", theme: "", emoji: "").isEmpty == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func emojiSuggestMatchesKnownThemes() {
|
||||||
|
#expect(AgentIdentityEmoji.suggest(theme: "") == "🦞")
|
||||||
|
#expect(AgentIdentityEmoji.suggest(theme: "shark") == "🦈")
|
||||||
|
#expect(AgentIdentityEmoji.suggest(theme: " Octopus helper ") == "🐙")
|
||||||
|
#expect(AgentIdentityEmoji.suggest(theme: "unknown") == "🦞")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -48,76 +48,4 @@ struct AgentWorkspaceTests {
|
|||||||
let second = try AgentWorkspace.bootstrap(workspaceURL: tmp)
|
let second = try AgentWorkspace.bootstrap(workspaceURL: tmp)
|
||||||
#expect(second == agentsURL)
|
#expect(second == agentsURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
func bootstrapSafetyRejectsNonEmptyFolderWithoutAgents() throws {
|
|
||||||
let tmp = FileManager.default.temporaryDirectory
|
|
||||||
.appendingPathComponent("clawdis-ws-\(UUID().uuidString)", isDirectory: true)
|
|
||||||
defer { try? FileManager.default.removeItem(at: tmp) }
|
|
||||||
try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
|
|
||||||
let marker = tmp.appendingPathComponent("notes.txt")
|
|
||||||
try "hello".write(to: marker, atomically: true, encoding: .utf8)
|
|
||||||
|
|
||||||
let result = AgentWorkspace.bootstrapSafety(for: tmp)
|
|
||||||
switch result {
|
|
||||||
case .unsafe:
|
|
||||||
break
|
|
||||||
case .safe:
|
|
||||||
#expect(false, "Expected unsafe bootstrap safety result.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func bootstrapSafetyAllowsExistingAgentsFile() throws {
|
|
||||||
let tmp = FileManager.default.temporaryDirectory
|
|
||||||
.appendingPathComponent("clawdis-ws-\(UUID().uuidString)", isDirectory: true)
|
|
||||||
defer { try? FileManager.default.removeItem(at: tmp) }
|
|
||||||
try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
|
|
||||||
let agents = tmp.appendingPathComponent(AgentWorkspace.agentsFilename)
|
|
||||||
try "# AGENTS.md".write(to: agents, atomically: true, encoding: .utf8)
|
|
||||||
|
|
||||||
let result = AgentWorkspace.bootstrapSafety(for: tmp)
|
|
||||||
switch result {
|
|
||||||
case .safe:
|
|
||||||
break
|
|
||||||
case .unsafe:
|
|
||||||
#expect(false, "Expected safe bootstrap safety result.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func bootstrapSkipsBootstrapFileWhenWorkspaceHasContent() throws {
|
|
||||||
let tmp = FileManager.default.temporaryDirectory
|
|
||||||
.appendingPathComponent("clawdis-ws-\(UUID().uuidString)", isDirectory: true)
|
|
||||||
defer { try? FileManager.default.removeItem(at: tmp) }
|
|
||||||
try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
|
|
||||||
let marker = tmp.appendingPathComponent("notes.txt")
|
|
||||||
try "hello".write(to: marker, atomically: true, encoding: .utf8)
|
|
||||||
|
|
||||||
_ = try AgentWorkspace.bootstrap(workspaceURL: tmp)
|
|
||||||
|
|
||||||
let bootstrapURL = tmp.appendingPathComponent(AgentWorkspace.bootstrapFilename)
|
|
||||||
#expect(!FileManager.default.fileExists(atPath: bootstrapURL.path))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func needsBootstrapFalseWhenIdentityAlreadySet() throws {
|
|
||||||
let tmp = FileManager.default.temporaryDirectory
|
|
||||||
.appendingPathComponent("clawdis-ws-\(UUID().uuidString)", isDirectory: true)
|
|
||||||
defer { try? FileManager.default.removeItem(at: tmp) }
|
|
||||||
try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
|
|
||||||
let identityURL = tmp.appendingPathComponent(AgentWorkspace.identityFilename)
|
|
||||||
try """
|
|
||||||
# IDENTITY.md - Agent Identity
|
|
||||||
|
|
||||||
- Name: Clawd
|
|
||||||
- Creature: Space Lobster
|
|
||||||
- Vibe: Helpful
|
|
||||||
- Emoji: lobster
|
|
||||||
""".write(to: identityURL, atomically: true, encoding: .utf8)
|
|
||||||
let bootstrapURL = tmp.appendingPathComponent(AgentWorkspace.bootstrapFilename)
|
|
||||||
try "bootstrap".write(to: bootstrapURL, atomically: true, encoding: .utf8)
|
|
||||||
|
|
||||||
#expect(!AgentWorkspace.needsBootstrap(workspaceURL: tmp))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,7 @@ import Testing
|
|||||||
struct AnthropicAuthResolverTests {
|
struct AnthropicAuthResolverTests {
|
||||||
@Test
|
@Test
|
||||||
func prefersOAuthFileOverEnv() throws {
|
func prefersOAuthFileOverEnv() throws {
|
||||||
let key = "CLAWDIS_OAUTH_DIR"
|
let key = "PI_CODING_AGENT_DIR"
|
||||||
let previous = ProcessInfo.processInfo.environment[key]
|
let previous = ProcessInfo.processInfo.environment[key]
|
||||||
defer {
|
defer {
|
||||||
if let previous {
|
if let previous {
|
||||||
@@ -61,3 +61,4 @@ struct AnthropicAuthResolverTests {
|
|||||||
#expect(mode == .missing)
|
#expect(mode == .missing)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
+10
-10
@@ -3,18 +3,18 @@ import Testing
|
|||||||
@testable import Clawdis
|
@testable import Clawdis
|
||||||
|
|
||||||
@Suite
|
@Suite
|
||||||
struct ClawdisOAuthStoreTests {
|
struct PiOAuthStoreTests {
|
||||||
@Test
|
@Test
|
||||||
func returnsMissingWhenFileAbsent() {
|
func returnsMissingWhenFileAbsent() {
|
||||||
let url = FileManager.default.temporaryDirectory
|
let url = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("clawdis-oauth-\(UUID().uuidString)")
|
.appendingPathComponent("clawdis-oauth-\(UUID().uuidString)")
|
||||||
.appendingPathComponent("oauth.json")
|
.appendingPathComponent("oauth.json")
|
||||||
#expect(ClawdisOAuthStore.anthropicOAuthStatus(at: url) == .missingFile)
|
#expect(PiOAuthStore.anthropicOAuthStatus(at: url) == .missingFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func usesEnvOverrideForClawdisOAuthDir() throws {
|
func usesEnvOverrideForPiAgentDir() throws {
|
||||||
let key = "CLAWDIS_OAUTH_DIR"
|
let key = "PI_CODING_AGENT_DIR"
|
||||||
let previous = ProcessInfo.processInfo.environment[key]
|
let previous = ProcessInfo.processInfo.environment[key]
|
||||||
defer {
|
defer {
|
||||||
if let previous {
|
if let previous {
|
||||||
@@ -25,10 +25,10 @@ struct ClawdisOAuthStoreTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let dir = FileManager.default.temporaryDirectory
|
let dir = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("clawdis-oauth-\(UUID().uuidString)", isDirectory: true)
|
.appendingPathComponent("clawdis-pi-agent-\(UUID().uuidString)", isDirectory: true)
|
||||||
setenv(key, dir.path, 1)
|
setenv(key, dir.path, 1)
|
||||||
|
|
||||||
#expect(ClawdisOAuthStore.oauthDir().standardizedFileURL == dir.standardizedFileURL)
|
#expect(PiOAuthStore.oauthDir().standardizedFileURL == dir.standardizedFileURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -42,7 +42,7 @@ struct ClawdisOAuthStoreTests {
|
|||||||
],
|
],
|
||||||
])
|
])
|
||||||
|
|
||||||
#expect(ClawdisOAuthStore.anthropicOAuthStatus(at: url).isConnected)
|
#expect(PiOAuthStore.anthropicOAuthStatus(at: url).isConnected)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -55,7 +55,7 @@ struct ClawdisOAuthStoreTests {
|
|||||||
],
|
],
|
||||||
])
|
])
|
||||||
|
|
||||||
#expect(ClawdisOAuthStore.anthropicOAuthStatus(at: url).isConnected)
|
#expect(PiOAuthStore.anthropicOAuthStatus(at: url).isConnected)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -68,7 +68,7 @@ struct ClawdisOAuthStoreTests {
|
|||||||
],
|
],
|
||||||
])
|
])
|
||||||
|
|
||||||
#expect(ClawdisOAuthStore.anthropicOAuthStatus(at: url) == .missingProviderEntry)
|
#expect(PiOAuthStore.anthropicOAuthStatus(at: url) == .missingProviderEntry)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -81,7 +81,7 @@ struct ClawdisOAuthStoreTests {
|
|||||||
],
|
],
|
||||||
])
|
])
|
||||||
|
|
||||||
#expect(ClawdisOAuthStore.anthropicOAuthStatus(at: url) == .missingTokens)
|
#expect(PiOAuthStore.anthropicOAuthStatus(at: url) == .missingTokens)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func writeOAuthFile(_ json: [String: Any]) throws -> URL {
|
private func writeOAuthFile(_ json: [String: Any]) throws -> URL {
|
||||||
@@ -1,9 +1,23 @@
|
|||||||
import Foundation
|
|
||||||
import Testing
|
import Testing
|
||||||
import SwabbleKit
|
|
||||||
@testable import Clawdis
|
@testable import Clawdis
|
||||||
|
|
||||||
@Suite struct VoiceWakeRuntimeTests {
|
@Suite struct VoiceWakeRuntimeTests {
|
||||||
|
@Test func matchesIsCaseInsensitive() {
|
||||||
|
let triggers = ["ClAwD", "buddy"]
|
||||||
|
#expect(VoiceWakeRuntime._testMatches(text: "hey clawd are you there", triggers: triggers))
|
||||||
|
#expect(!VoiceWakeRuntime._testMatches(text: "nothing to see", triggers: triggers))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func matchesIgnoresWhitespace() {
|
||||||
|
let triggers = [" claude "]
|
||||||
|
#expect(VoiceWakeRuntime._testMatches(text: "hello claude!", triggers: triggers))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func matchesSkipsEmptyTriggers() {
|
||||||
|
let triggers = [" ", ""]
|
||||||
|
#expect(!VoiceWakeRuntime._testMatches(text: "hello", triggers: triggers))
|
||||||
|
}
|
||||||
|
|
||||||
@Test func trimsAfterTriggerKeepsPostSpeech() {
|
@Test func trimsAfterTriggerKeepsPostSpeech() {
|
||||||
let triggers = ["claude", "clawd"]
|
let triggers = ["claude", "clawd"]
|
||||||
let text = "hey Claude how are you"
|
let text = "hey Claude how are you"
|
||||||
@@ -34,46 +48,4 @@ import SwabbleKit
|
|||||||
let text = "claude write a note"
|
let text = "claude write a note"
|
||||||
#expect(VoiceWakeRuntime._testHasContentAfterTrigger(text, triggers: triggers))
|
#expect(VoiceWakeRuntime._testHasContentAfterTrigger(text, triggers: triggers))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func gateRequiresGapBetweenTriggerAndCommand() {
|
|
||||||
let transcript = "hey clawd do thing"
|
|
||||||
let segments = makeSegments(
|
|
||||||
transcript: transcript,
|
|
||||||
words: [
|
|
||||||
("hey", 0.0, 0.1),
|
|
||||||
("clawd", 0.2, 0.1),
|
|
||||||
("do", 0.35, 0.1),
|
|
||||||
("thing", 0.5, 0.1),
|
|
||||||
])
|
|
||||||
let config = WakeWordGateConfig(triggers: ["clawd"], minPostTriggerGap: 0.3)
|
|
||||||
#expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config) == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func gateAcceptsGapAndExtractsCommand() {
|
|
||||||
let transcript = "hey clawd do thing"
|
|
||||||
let segments = makeSegments(
|
|
||||||
transcript: transcript,
|
|
||||||
words: [
|
|
||||||
("hey", 0.0, 0.1),
|
|
||||||
("clawd", 0.2, 0.1),
|
|
||||||
("do", 0.9, 0.1),
|
|
||||||
("thing", 1.1, 0.1),
|
|
||||||
])
|
|
||||||
let config = WakeWordGateConfig(triggers: ["clawd"], minPostTriggerGap: 0.3)
|
|
||||||
#expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config)?.command == "do thing")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makeSegments(
|
|
||||||
transcript: String,
|
|
||||||
words: [(String, TimeInterval, TimeInterval)])
|
|
||||||
-> [WakeWordSegment] {
|
|
||||||
var searchStart = transcript.startIndex
|
|
||||||
var output: [WakeWordSegment] = []
|
|
||||||
for (word, start, duration) in words {
|
|
||||||
let range = transcript.range(of: word, range: searchStart..<transcript.endIndex)
|
|
||||||
output.append(WakeWordSegment(text: word, start: start, duration: duration, range: range))
|
|
||||||
if let range { searchStart = range.upperBound }
|
|
||||||
}
|
|
||||||
return output
|
|
||||||
}
|
}
|
||||||
@@ -1,47 +1,15 @@
|
|||||||
import Foundation
|
|
||||||
import Testing
|
import Testing
|
||||||
import SwabbleKit
|
@testable import Clawdis
|
||||||
|
|
||||||
struct VoiceWakeTesterTests {
|
struct VoiceWakeTesterTests {
|
||||||
@Test func matchRespectsGapRequirement() {
|
@Test func matchesIsCaseInsensitiveAndSubstring() {
|
||||||
let transcript = "hey claude do thing"
|
let triggers = ["Claude", "wake word"]
|
||||||
let segments = makeSegments(
|
#expect(VoiceWakeTester._testMatches(text: "hey claude are you there", triggers: triggers))
|
||||||
transcript: transcript,
|
#expect(VoiceWakeTester._testMatches(text: "this has wake word inside", triggers: triggers))
|
||||||
words: [
|
|
||||||
("hey", 0.0, 0.1),
|
|
||||||
("claude", 0.2, 0.1),
|
|
||||||
("do", 0.35, 0.1),
|
|
||||||
("thing", 0.5, 0.1),
|
|
||||||
])
|
|
||||||
let config = WakeWordGateConfig(triggers: ["claude"], minPostTriggerGap: 0.3)
|
|
||||||
#expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config) == nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func matchReturnsCommandAfterGap() {
|
@Test func matchesReturnsFalseWhenNoTrigger() {
|
||||||
let transcript = "hey claude do thing"
|
let triggers = ["claude"]
|
||||||
let segments = makeSegments(
|
#expect(!VoiceWakeTester._testMatches(text: "random text", triggers: triggers))
|
||||||
transcript: transcript,
|
|
||||||
words: [
|
|
||||||
("hey", 0.0, 0.1),
|
|
||||||
("claude", 0.2, 0.1),
|
|
||||||
("do", 0.8, 0.1),
|
|
||||||
("thing", 1.0, 0.1),
|
|
||||||
])
|
|
||||||
let config = WakeWordGateConfig(triggers: ["claude"], minPostTriggerGap: 0.3)
|
|
||||||
#expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config)?.command == "do thing")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeSegments(
|
|
||||||
transcript: String,
|
|
||||||
words: [(String, TimeInterval, TimeInterval)])
|
|
||||||
-> [WakeWordSegment] {
|
|
||||||
var searchStart = transcript.startIndex
|
|
||||||
var output: [WakeWordSegment] = []
|
|
||||||
for (word, start, duration) in words {
|
|
||||||
let range = transcript.range(of: word, range: searchStart..<transcript.endIndex)
|
|
||||||
output.append(WakeWordSegment(text: word, start: start, duration: duration, range: range))
|
|
||||||
if let range { searchStart = range.upperBound }
|
|
||||||
}
|
|
||||||
return output
|
|
||||||
}
|
|
||||||
@@ -18,9 +18,9 @@ struct ClawdisChatComposer: View {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
if self.showsToolbar {
|
if self.showsToolbar {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 8) {
|
||||||
self.thinkingPicker
|
self.thinkingPicker
|
||||||
Spacer()
|
Spacer()
|
||||||
self.refreshButton
|
self.refreshButton
|
||||||
@@ -41,14 +41,11 @@ struct ClawdisChatComposer: View {
|
|||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(self.composerPadding)
|
.padding(8)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||||
.fill(ClawdisChatTheme.composerBackground)
|
.fill(ClawdisChatTheme.composerBackground)
|
||||||
.overlay(
|
.shadow(color: .black.opacity(0.08), radius: 10, y: 4))
|
||||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
|
||||||
.strokeBorder(ClawdisChatTheme.composerBorder, lineWidth: 1))
|
|
||||||
.shadow(color: .black.opacity(0.12), radius: 12, y: 6))
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.onDrop(of: [.fileURL], isTargeted: nil) { providers in
|
.onDrop(of: [.fileURL], isTargeted: nil) { providers in
|
||||||
self.handleDrop(providers)
|
self.handleDrop(providers)
|
||||||
@@ -137,9 +134,9 @@ struct ClawdisChatComposer: View {
|
|||||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
.fill(ClawdisChatTheme.composerField))
|
.fill(ClawdisChatTheme.composerField))
|
||||||
.overlay {
|
.overlay {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
self.editorOverlay
|
self.editorOverlay
|
||||||
HStack(alignment: .bottom, spacing: 6) {
|
HStack(alignment: .bottom, spacing: 8) {
|
||||||
if self.showsConnectionPill {
|
if self.showsConnectionPill {
|
||||||
self.connectionPill
|
self.connectionPill
|
||||||
}
|
}
|
||||||
@@ -147,12 +144,9 @@ struct ClawdisChatComposer: View {
|
|||||||
self.sendButton
|
self.sendButton
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(8)
|
||||||
}
|
}
|
||||||
.padding(self.editorPadding)
|
.frame(minHeight: 44, idealHeight: 44, maxHeight: 96)
|
||||||
.frame(
|
|
||||||
minHeight: self.editorMinHeight,
|
|
||||||
idealHeight: self.editorMinHeight,
|
|
||||||
maxHeight: self.editorMaxHeight)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var connectionPill: some View {
|
private var connectionPill: some View {
|
||||||
@@ -166,8 +160,8 @@ struct ClawdisChatComposer: View {
|
|||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 10)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 6)
|
||||||
.background(ClawdisChatTheme.subtleCard)
|
.background(ClawdisChatTheme.subtleCard)
|
||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
}
|
}
|
||||||
@@ -177,23 +171,23 @@ struct ClawdisChatComposer: View {
|
|||||||
if self.viewModel.input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
if self.viewModel.input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
Text("Message Clawd…")
|
Text("Message Clawd…")
|
||||||
.foregroundStyle(.tertiary)
|
.foregroundStyle(.tertiary)
|
||||||
.padding(.horizontal, 4)
|
.padding(.horizontal, 6)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 5)
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
ChatComposerTextView(text: self.$viewModel.input) {
|
ChatComposerTextView(text: self.$viewModel.input) {
|
||||||
self.viewModel.send()
|
self.viewModel.send()
|
||||||
}
|
}
|
||||||
.frame(minHeight: self.textMinHeight, idealHeight: self.textMinHeight, maxHeight: self.textMaxHeight)
|
.frame(minHeight: 32, idealHeight: 32, maxHeight: 72)
|
||||||
.padding(.horizontal, 4)
|
.padding(.horizontal, 6)
|
||||||
.padding(.vertical, 3)
|
.padding(.vertical, 4)
|
||||||
#else
|
#else
|
||||||
TextEditor(text: self.$viewModel.input)
|
TextEditor(text: self.$viewModel.input)
|
||||||
.font(.system(size: 15))
|
.font(.system(size: 15))
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.padding(.horizontal, 4)
|
.padding(.horizontal, 6)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 6)
|
||||||
.focused(self.$isFocused)
|
.focused(self.$isFocused)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
@@ -214,7 +208,7 @@ struct ClawdisChatComposer: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.padding(6)
|
.padding(8)
|
||||||
.background(Circle().fill(Color.red))
|
.background(Circle().fill(Color.red))
|
||||||
.disabled(self.viewModel.isAborting)
|
.disabled(self.viewModel.isAborting)
|
||||||
} else {
|
} else {
|
||||||
@@ -230,7 +224,7 @@ struct ClawdisChatComposer: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.padding(6)
|
.padding(8)
|
||||||
.background(Circle().fill(Color.accentColor))
|
.background(Circle().fill(Color.accentColor))
|
||||||
.disabled(!self.viewModel.canSend)
|
.disabled(!self.viewModel.canSend)
|
||||||
}
|
}
|
||||||
@@ -260,30 +254,6 @@ struct ClawdisChatComposer: View {
|
|||||||
self.style == .standard
|
self.style == .standard
|
||||||
}
|
}
|
||||||
|
|
||||||
private var composerPadding: CGFloat {
|
|
||||||
self.style == .onboarding ? 5 : 6
|
|
||||||
}
|
|
||||||
|
|
||||||
private var editorPadding: CGFloat {
|
|
||||||
self.style == .onboarding ? 5 : 6
|
|
||||||
}
|
|
||||||
|
|
||||||
private var editorMinHeight: CGFloat {
|
|
||||||
self.style == .onboarding ? 34 : 40
|
|
||||||
}
|
|
||||||
|
|
||||||
private var editorMaxHeight: CGFloat {
|
|
||||||
self.style == .onboarding ? 60 : 84
|
|
||||||
}
|
|
||||||
|
|
||||||
private var textMinHeight: CGFloat {
|
|
||||||
self.style == .onboarding ? 24 : 28
|
|
||||||
}
|
|
||||||
|
|
||||||
private var textMaxHeight: CGFloat {
|
|
||||||
self.style == .onboarding ? 52 : 64
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
private func pickFilesMac() {
|
private func pickFilesMac() {
|
||||||
let panel = NSOpenPanel()
|
let panel = NSOpenPanel()
|
||||||
@@ -353,7 +323,7 @@ private struct ChatComposerTextView: NSViewRepresentable {
|
|||||||
textView.font = .systemFont(ofSize: 14, weight: .regular)
|
textView.font = .systemFont(ofSize: 14, weight: .regular)
|
||||||
textView.textContainer?.lineBreakMode = .byWordWrapping
|
textView.textContainer?.lineBreakMode = .byWordWrapping
|
||||||
textView.textContainer?.lineFragmentPadding = 0
|
textView.textContainer?.lineFragmentPadding = 0
|
||||||
textView.textContainerInset = NSSize(width: 2, height: 4)
|
textView.textContainerInset = NSSize(width: 2, height: 6)
|
||||||
textView.focusRingType = .none
|
textView.focusRingType = .none
|
||||||
|
|
||||||
textView.minSize = .zero
|
textView.minSize = .zero
|
||||||
|
|||||||
@@ -6,139 +6,12 @@ private enum ChatUIConstants {
|
|||||||
static let bubbleCorner: CGFloat = 18
|
static let bubbleCorner: CGFloat = 18
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ChatBubbleShape: InsettableShape {
|
|
||||||
enum Tail {
|
|
||||||
case left
|
|
||||||
case right
|
|
||||||
case none
|
|
||||||
}
|
|
||||||
|
|
||||||
let cornerRadius: CGFloat
|
|
||||||
let tail: Tail
|
|
||||||
var insetAmount: CGFloat = 0
|
|
||||||
|
|
||||||
private let tailWidth: CGFloat = 7
|
|
||||||
private let tailBaseHeight: CGFloat = 9
|
|
||||||
|
|
||||||
func inset(by amount: CGFloat) -> ChatBubbleShape {
|
|
||||||
var copy = self
|
|
||||||
copy.insetAmount += amount
|
|
||||||
return copy
|
|
||||||
}
|
|
||||||
|
|
||||||
func path(in rect: CGRect) -> Path {
|
|
||||||
let rect = rect.insetBy(dx: self.insetAmount, dy: self.insetAmount)
|
|
||||||
switch self.tail {
|
|
||||||
case .left:
|
|
||||||
return self.leftTailPath(in: rect, radius: self.cornerRadius)
|
|
||||||
case .right:
|
|
||||||
return self.rightTailPath(in: rect, radius: self.cornerRadius)
|
|
||||||
case .none:
|
|
||||||
return Path(roundedRect: rect, cornerRadius: self.cornerRadius)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func rightTailPath(in rect: CGRect, radius r: CGFloat) -> Path {
|
|
||||||
var path = Path()
|
|
||||||
let bubbleMinX = rect.minX
|
|
||||||
let bubbleMaxX = rect.maxX - self.tailWidth
|
|
||||||
let bubbleMinY = rect.minY
|
|
||||||
let bubbleMaxY = rect.maxY
|
|
||||||
|
|
||||||
let available = max(4, bubbleMaxY - bubbleMinY - 2 * r)
|
|
||||||
let baseH = min(tailBaseHeight, available)
|
|
||||||
let baseBottomY = bubbleMaxY - max(r * 0.45, 6)
|
|
||||||
let baseTopY = baseBottomY - baseH
|
|
||||||
let midY = (baseTopY + baseBottomY) / 2
|
|
||||||
|
|
||||||
let baseTop = CGPoint(x: bubbleMaxX, y: baseTopY)
|
|
||||||
let baseBottom = CGPoint(x: bubbleMaxX, y: baseBottomY)
|
|
||||||
let tip = CGPoint(x: bubbleMaxX + self.tailWidth, y: midY)
|
|
||||||
|
|
||||||
path.move(to: CGPoint(x: bubbleMinX + r, y: bubbleMinY))
|
|
||||||
path.addLine(to: CGPoint(x: bubbleMaxX - r, y: bubbleMinY))
|
|
||||||
path.addQuadCurve(
|
|
||||||
to: CGPoint(x: bubbleMaxX, y: bubbleMinY + r),
|
|
||||||
control: CGPoint(x: bubbleMaxX, y: bubbleMinY))
|
|
||||||
path.addLine(to: baseTop)
|
|
||||||
path.addCurve(
|
|
||||||
to: tip,
|
|
||||||
control1: CGPoint(x: bubbleMaxX + self.tailWidth * 0.2, y: baseTopY + baseH * 0.05),
|
|
||||||
control2: CGPoint(x: bubbleMaxX + self.tailWidth * 0.95, y: midY - baseH * 0.15))
|
|
||||||
path.addCurve(
|
|
||||||
to: baseBottom,
|
|
||||||
control1: CGPoint(x: bubbleMaxX + self.tailWidth * 0.95, y: midY + baseH * 0.15),
|
|
||||||
control2: CGPoint(x: bubbleMaxX + self.tailWidth * 0.2, y: baseBottomY - baseH * 0.05))
|
|
||||||
path.addQuadCurve(
|
|
||||||
to: CGPoint(x: bubbleMaxX - r, y: bubbleMaxY),
|
|
||||||
control: CGPoint(x: bubbleMaxX, y: bubbleMaxY))
|
|
||||||
path.addLine(to: CGPoint(x: bubbleMinX + r, y: bubbleMaxY))
|
|
||||||
path.addQuadCurve(
|
|
||||||
to: CGPoint(x: bubbleMinX, y: bubbleMaxY - r),
|
|
||||||
control: CGPoint(x: bubbleMinX, y: bubbleMaxY))
|
|
||||||
path.addLine(to: CGPoint(x: bubbleMinX, y: bubbleMinY + r))
|
|
||||||
path.addQuadCurve(
|
|
||||||
to: CGPoint(x: bubbleMinX + r, y: bubbleMinY),
|
|
||||||
control: CGPoint(x: bubbleMinX, y: bubbleMinY))
|
|
||||||
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
private func leftTailPath(in rect: CGRect, radius r: CGFloat) -> Path {
|
|
||||||
var path = Path()
|
|
||||||
let bubbleMinX = rect.minX + self.tailWidth
|
|
||||||
let bubbleMaxX = rect.maxX
|
|
||||||
let bubbleMinY = rect.minY
|
|
||||||
let bubbleMaxY = rect.maxY
|
|
||||||
|
|
||||||
let available = max(4, bubbleMaxY - bubbleMinY - 2 * r)
|
|
||||||
let baseH = min(tailBaseHeight, available)
|
|
||||||
let baseBottomY = bubbleMaxY - max(r * 0.45, 6)
|
|
||||||
let baseTopY = baseBottomY - baseH
|
|
||||||
let midY = (baseTopY + baseBottomY) / 2
|
|
||||||
|
|
||||||
let baseTop = CGPoint(x: bubbleMinX, y: baseTopY)
|
|
||||||
let baseBottom = CGPoint(x: bubbleMinX, y: baseBottomY)
|
|
||||||
let tip = CGPoint(x: bubbleMinX - self.tailWidth, y: midY)
|
|
||||||
|
|
||||||
path.move(to: CGPoint(x: bubbleMinX + r, y: bubbleMinY))
|
|
||||||
path.addLine(to: CGPoint(x: bubbleMaxX - r, y: bubbleMinY))
|
|
||||||
path.addQuadCurve(
|
|
||||||
to: CGPoint(x: bubbleMaxX, y: bubbleMinY + r),
|
|
||||||
control: CGPoint(x: bubbleMaxX, y: bubbleMinY))
|
|
||||||
path.addLine(to: CGPoint(x: bubbleMaxX, y: bubbleMaxY - r))
|
|
||||||
path.addQuadCurve(
|
|
||||||
to: CGPoint(x: bubbleMaxX - r, y: bubbleMaxY),
|
|
||||||
control: CGPoint(x: bubbleMaxX, y: bubbleMaxY))
|
|
||||||
path.addLine(to: CGPoint(x: bubbleMinX + r, y: bubbleMaxY))
|
|
||||||
path.addQuadCurve(
|
|
||||||
to: CGPoint(x: bubbleMinX, y: bubbleMaxY - r),
|
|
||||||
control: CGPoint(x: bubbleMinX, y: bubbleMaxY))
|
|
||||||
path.addLine(to: baseBottom)
|
|
||||||
path.addCurve(
|
|
||||||
to: tip,
|
|
||||||
control1: CGPoint(x: bubbleMinX - self.tailWidth * 0.2, y: baseBottomY - baseH * 0.05),
|
|
||||||
control2: CGPoint(x: bubbleMinX - self.tailWidth * 0.95, y: midY + baseH * 0.15))
|
|
||||||
path.addCurve(
|
|
||||||
to: baseTop,
|
|
||||||
control1: CGPoint(x: bubbleMinX - self.tailWidth * 0.95, y: midY - baseH * 0.15),
|
|
||||||
control2: CGPoint(x: bubbleMinX - self.tailWidth * 0.2, y: baseTopY + baseH * 0.05))
|
|
||||||
path.addLine(to: CGPoint(x: bubbleMinX, y: bubbleMinY + r))
|
|
||||||
path.addQuadCurve(
|
|
||||||
to: CGPoint(x: bubbleMinX + r, y: bubbleMinY),
|
|
||||||
control: CGPoint(x: bubbleMinX, y: bubbleMinY))
|
|
||||||
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
struct ChatMessageBubble: View {
|
struct ChatMessageBubble: View {
|
||||||
let message: ClawdisChatMessage
|
let message: ClawdisChatMessage
|
||||||
let style: ClawdisChatView.Style
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ChatMessageBody(message: self.message, isUser: self.isUser, style: self.style)
|
ChatMessageBody(message: self.message, isUser: self.isUser)
|
||||||
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: self.isUser ? .trailing : .leading)
|
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: self.isUser ? .trailing : .leading)
|
||||||
.frame(maxWidth: .infinity, alignment: self.isUser ? .trailing : .leading)
|
.frame(maxWidth: .infinity, alignment: self.isUser ? .trailing : .leading)
|
||||||
.padding(.horizontal, 2)
|
.padding(.horizontal, 2)
|
||||||
@@ -151,7 +24,6 @@ struct ChatMessageBubble: View {
|
|||||||
private struct ChatMessageBody: View {
|
private struct ChatMessageBody: View {
|
||||||
let message: ClawdisChatMessage
|
let message: ClawdisChatMessage
|
||||||
let isUser: Bool
|
let isUser: Bool
|
||||||
let style: ClawdisChatView.Style
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let text = self.primaryText
|
let text = self.primaryText
|
||||||
@@ -201,11 +73,8 @@ private struct ChatMessageBody: View {
|
|||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.foregroundStyle(textColor)
|
.foregroundStyle(textColor)
|
||||||
.background(self.bubbleBackground)
|
.background(self.bubbleBackground)
|
||||||
.clipShape(self.bubbleShape)
|
|
||||||
.overlay(self.bubbleBorder)
|
.overlay(self.bubbleBorder)
|
||||||
.shadow(color: self.bubbleShadowColor, radius: self.bubbleShadowRadius, y: self.bubbleShadowYOffset)
|
.clipShape(RoundedRectangle(cornerRadius: ChatUIConstants.bubbleCorner, style: .continuous))
|
||||||
.padding(.leading, self.tailPaddingLeading)
|
|
||||||
.padding(.trailing, self.tailPaddingTrailing)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var primaryText: String {
|
private var primaryText: String {
|
||||||
@@ -217,67 +86,16 @@ private struct ChatMessageBody: View {
|
|||||||
self.message.content.filter { ($0.type ?? "text") != "text" }
|
self.message.content.filter { ($0.type ?? "text") != "text" }
|
||||||
}
|
}
|
||||||
|
|
||||||
private var bubbleFillColor: Color {
|
|
||||||
if self.isUser {
|
|
||||||
return ClawdisChatTheme.userBubble
|
|
||||||
}
|
|
||||||
if self.style == .onboarding {
|
|
||||||
return ClawdisChatTheme.onboardingAssistantBubble
|
|
||||||
}
|
|
||||||
return ClawdisChatTheme.assistantBubble
|
|
||||||
}
|
|
||||||
|
|
||||||
private var bubbleBackground: AnyShapeStyle {
|
private var bubbleBackground: AnyShapeStyle {
|
||||||
AnyShapeStyle(self.bubbleFillColor)
|
let fill = self.isUser ? ClawdisChatTheme.userBubble : ClawdisChatTheme.assistantBubble
|
||||||
}
|
return AnyShapeStyle(fill)
|
||||||
|
|
||||||
private var bubbleBorderColor: Color {
|
|
||||||
if self.isUser {
|
|
||||||
return Color.white.opacity(0.12)
|
|
||||||
}
|
|
||||||
if self.style == .onboarding {
|
|
||||||
return ClawdisChatTheme.onboardingAssistantBorder
|
|
||||||
}
|
|
||||||
return Color.white.opacity(0.08)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var bubbleBorderWidth: CGFloat {
|
|
||||||
if self.isUser { return 0.5 }
|
|
||||||
if self.style == .onboarding { return 0.8 }
|
|
||||||
return 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var bubbleBorder: some View {
|
private var bubbleBorder: some View {
|
||||||
self.bubbleShape.strokeBorder(self.bubbleBorderColor, lineWidth: self.bubbleBorderWidth)
|
RoundedRectangle(cornerRadius: ChatUIConstants.bubbleCorner, style: .continuous)
|
||||||
}
|
.strokeBorder(
|
||||||
|
self.isUser ? Color.white.opacity(0.12) : Color.black.opacity(0.08),
|
||||||
private var bubbleShape: ChatBubbleShape {
|
lineWidth: self.isUser ? 0.5 : 1)
|
||||||
ChatBubbleShape(cornerRadius: ChatUIConstants.bubbleCorner, tail: self.bubbleTail)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var bubbleTail: ChatBubbleShape.Tail {
|
|
||||||
guard self.style == .onboarding else { return .none }
|
|
||||||
return self.isUser ? .right : .left
|
|
||||||
}
|
|
||||||
|
|
||||||
private var tailPaddingLeading: CGFloat {
|
|
||||||
self.style == .onboarding && !self.isUser ? 8 : 0
|
|
||||||
}
|
|
||||||
|
|
||||||
private var tailPaddingTrailing: CGFloat {
|
|
||||||
self.style == .onboarding && self.isUser ? 8 : 0
|
|
||||||
}
|
|
||||||
|
|
||||||
private var bubbleShadowColor: Color {
|
|
||||||
self.style == .onboarding && !self.isUser ? Color.black.opacity(0.28) : .clear
|
|
||||||
}
|
|
||||||
|
|
||||||
private var bubbleShadowRadius: CGFloat {
|
|
||||||
self.style == .onboarding && !self.isUser ? 6 : 0
|
|
||||||
}
|
|
||||||
|
|
||||||
private var bubbleShadowYOffset: CGFloat {
|
|
||||||
self.style == .onboarding && !self.isUser ? 2 : 0
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,7 +139,7 @@ struct ChatTypingIndicatorBubble: View {
|
|||||||
.fill(ClawdisChatTheme.assistantBubble))
|
.fill(ClawdisChatTheme.assistantBubble))
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||||
.strokeBorder(Color.white.opacity(0.08), lineWidth: 1))
|
.strokeBorder(Color.black.opacity(0.08), lineWidth: 1))
|
||||||
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading)
|
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -340,7 +158,7 @@ struct ChatStreamingAssistantBubble: View {
|
|||||||
.fill(ClawdisChatTheme.assistantBubble))
|
.fill(ClawdisChatTheme.assistantBubble))
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||||
.strokeBorder(Color.white.opacity(0.08), lineWidth: 1))
|
.strokeBorder(Color.black.opacity(0.08), lineWidth: 1))
|
||||||
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading)
|
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -374,7 +192,7 @@ struct ChatPendingToolsBubble: View {
|
|||||||
.fill(ClawdisChatTheme.assistantBubble))
|
.fill(ClawdisChatTheme.assistantBubble))
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||||
.strokeBorder(Color.white.opacity(0.08), lineWidth: 1))
|
.strokeBorder(Color.black.opacity(0.08), lineWidth: 1))
|
||||||
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading)
|
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -412,18 +230,12 @@ private struct MarkdownTextView: View {
|
|||||||
let textColor: Color
|
let textColor: Color
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let normalized = self.text.replacingOccurrences(
|
if let attributed = try? AttributedString(markdown: self.text) {
|
||||||
of: "(?<!\\n)\\n(?!\\n)",
|
|
||||||
with: " ",
|
|
||||||
options: .regularExpression)
|
|
||||||
let options = AttributedString.MarkdownParsingOptions(
|
|
||||||
interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
|
||||||
if let attributed = try? AttributedString(markdown: normalized, options: options) {
|
|
||||||
Text(attributed)
|
Text(attributed)
|
||||||
.font(.system(size: 14))
|
.font(.system(size: 14))
|
||||||
.foregroundStyle(self.textColor)
|
.foregroundStyle(self.textColor)
|
||||||
} else {
|
} else {
|
||||||
Text(normalized)
|
Text(self.text)
|
||||||
.font(.system(size: 14))
|
.font(.system(size: 14))
|
||||||
.foregroundStyle(self.textColor)
|
.foregroundStyle(self.textColor)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,38 @@ public struct ClawdisChatSessionsDefaults: Codable, Sendable {
|
|||||||
public let contextTokens: Int?
|
public let contextTokens: Int?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum ClawdisChatSessionSyncing: Codable, Hashable, Sendable {
|
||||||
|
case bool(Bool)
|
||||||
|
case string(String)
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
if let b = try? container.decode(Bool.self) {
|
||||||
|
self = .bool(b)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let s = try? container.decode(String.self) {
|
||||||
|
self = .string(s)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw DecodingError.typeMismatch(
|
||||||
|
ClawdisChatSessionSyncing.self,
|
||||||
|
DecodingError.Context(
|
||||||
|
codingPath: decoder.codingPath,
|
||||||
|
debugDescription: "Expected Bool or String"))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.singleValueContainer()
|
||||||
|
switch self {
|
||||||
|
case let .bool(b):
|
||||||
|
try container.encode(b)
|
||||||
|
case let .string(s):
|
||||||
|
try container.encode(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public struct ClawdisChatSessionEntry: Codable, Identifiable, Sendable, Hashable {
|
public struct ClawdisChatSessionEntry: Codable, Identifiable, Sendable, Hashable {
|
||||||
public var id: String { self.key }
|
public var id: String { self.key }
|
||||||
|
|
||||||
@@ -24,6 +56,7 @@ public struct ClawdisChatSessionEntry: Codable, Identifiable, Sendable, Hashable
|
|||||||
|
|
||||||
public let model: String?
|
public let model: String?
|
||||||
public let contextTokens: Int?
|
public let contextTokens: Int?
|
||||||
|
public let syncing: ClawdisChatSessionSyncing?
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ClawdisChatSessionsListResponse: Codable, Sendable {
|
public struct ClawdisChatSessionsListResponse: Codable, Sendable {
|
||||||
|
|||||||
@@ -15,40 +15,6 @@ enum ClawdisChatTheme {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
static var background: some View {
|
|
||||||
#if os(macOS)
|
|
||||||
ZStack {
|
|
||||||
LinearGradient(
|
|
||||||
colors: [
|
|
||||||
Color(nsColor: .windowBackgroundColor).opacity(0.85),
|
|
||||||
Color.black.opacity(0.92),
|
|
||||||
],
|
|
||||||
startPoint: .topLeading,
|
|
||||||
endPoint: .bottomTrailing)
|
|
||||||
RadialGradient(
|
|
||||||
colors: [
|
|
||||||
Color(nsColor: .systemOrange).opacity(0.18),
|
|
||||||
.clear,
|
|
||||||
],
|
|
||||||
center: .topLeading,
|
|
||||||
startRadius: 40,
|
|
||||||
endRadius: 320)
|
|
||||||
RadialGradient(
|
|
||||||
colors: [
|
|
||||||
Color(nsColor: .systemTeal).opacity(0.16),
|
|
||||||
.clear,
|
|
||||||
],
|
|
||||||
center: .topTrailing,
|
|
||||||
startRadius: 40,
|
|
||||||
endRadius: 280)
|
|
||||||
Color.black.opacity(0.12)
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
Color(uiColor: .systemBackground)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
static var card: Color {
|
static var card: Color {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
Color(nsColor: .textBackgroundColor)
|
Color(nsColor: .textBackgroundColor)
|
||||||
@@ -57,11 +23,11 @@ enum ClawdisChatTheme {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
static var subtleCard: AnyShapeStyle {
|
static var subtleCard: Color {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
AnyShapeStyle(.ultraThinMaterial)
|
Color(nsColor: .textBackgroundColor).opacity(0.55)
|
||||||
#else
|
#else
|
||||||
AnyShapeStyle(Color(uiColor: .secondarySystemBackground).opacity(0.9))
|
Color(uiColor: .secondarySystemBackground).opacity(0.9)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,32 +41,12 @@ enum ClawdisChatTheme {
|
|||||||
|
|
||||||
static var assistantBubble: Color {
|
static var assistantBubble: Color {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
let base = NSColor.controlBackgroundColor
|
Color(nsColor: .controlBackgroundColor)
|
||||||
let blended = base.blended(withFraction: 0.18, of: .white) ?? base
|
|
||||||
return Color(nsColor: blended).opacity(0.88)
|
|
||||||
#else
|
#else
|
||||||
Color(uiColor: .secondarySystemBackground)
|
Color(uiColor: .secondarySystemBackground)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
static var onboardingAssistantBubble: Color {
|
|
||||||
#if os(macOS)
|
|
||||||
let base = NSColor.controlBackgroundColor
|
|
||||||
let blended = base.blended(withFraction: 0.22, of: .white) ?? base
|
|
||||||
return Color(nsColor: blended)
|
|
||||||
#else
|
|
||||||
Color(uiColor: .secondarySystemBackground)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
static var onboardingAssistantBorder: Color {
|
|
||||||
#if os(macOS)
|
|
||||||
Color.white.opacity(0.12)
|
|
||||||
#else
|
|
||||||
Color.white.opacity(0.12)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
static var userText: Color { .white }
|
static var userText: Color { .white }
|
||||||
|
|
||||||
static var assistantText: Color {
|
static var assistantText: Color {
|
||||||
@@ -111,24 +57,24 @@ enum ClawdisChatTheme {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
static var composerBackground: AnyShapeStyle {
|
static var composerBackground: Color {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
AnyShapeStyle(.ultraThinMaterial)
|
Color(nsColor: .windowBackgroundColor)
|
||||||
#else
|
#else
|
||||||
AnyShapeStyle(Color(uiColor: .systemBackground))
|
Color(uiColor: .systemBackground)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
static var composerField: AnyShapeStyle {
|
static var composerField: Color {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
AnyShapeStyle(.thinMaterial)
|
Color(nsColor: .textBackgroundColor)
|
||||||
#else
|
#else
|
||||||
AnyShapeStyle(Color(uiColor: .secondarySystemBackground))
|
Color(uiColor: .secondarySystemBackground)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
static var composerBorder: Color {
|
static var composerBorder: Color {
|
||||||
Color.white.opacity(0.12)
|
Color.secondary.opacity(0.2)
|
||||||
}
|
}
|
||||||
|
|
||||||
static var divider: Color {
|
static var divider: Color {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ public struct ClawdisChatView: View {
|
|||||||
@State private var viewModel: ClawdisChatViewModel
|
@State private var viewModel: ClawdisChatViewModel
|
||||||
@State private var scrollerBottomID = UUID()
|
@State private var scrollerBottomID = UUID()
|
||||||
@State private var showSessions = false
|
@State private var showSessions = false
|
||||||
@State private var hasPerformedInitialScroll = false
|
|
||||||
private let showsSessionSwitcher: Bool
|
private let showsSessionSwitcher: Bool
|
||||||
private let style: Style
|
private let style: Style
|
||||||
|
|
||||||
@@ -44,7 +43,7 @@ public struct ClawdisChatView: View {
|
|||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
ClawdisChatTheme.background
|
ClawdisChatTheme.surface
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
|
|
||||||
VStack(spacing: Layout.stackSpacing) {
|
VStack(spacing: Layout.stackSpacing) {
|
||||||
@@ -68,59 +67,44 @@ public struct ClawdisChatView: View {
|
|||||||
|
|
||||||
private var messageList: some View {
|
private var messageList: some View {
|
||||||
ScrollViewReader { proxy in
|
ScrollViewReader { proxy in
|
||||||
ZStack {
|
ScrollView {
|
||||||
ScrollView {
|
LazyVStack(spacing: Layout.messageSpacing) {
|
||||||
LazyVStack(spacing: Layout.messageSpacing) {
|
ForEach(self.visibleMessages) { msg in
|
||||||
ForEach(self.visibleMessages) { msg in
|
ChatMessageBubble(message: msg)
|
||||||
ChatMessageBubble(message: msg, style: self.style)
|
.frame(
|
||||||
.frame(
|
maxWidth: .infinity,
|
||||||
maxWidth: .infinity,
|
alignment: msg.role.lowercased() == "user" ? .trailing : .leading)
|
||||||
alignment: msg.role.lowercased() == "user" ? .trailing : .leading)
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.viewModel.pendingRunCount > 0 {
|
|
||||||
ChatTypingIndicatorBubble(style: self.style)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !self.viewModel.pendingToolCalls.isEmpty {
|
|
||||||
ChatPendingToolsBubble(toolCalls: self.viewModel.pendingToolCalls)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let text = self.viewModel.streamingAssistantText, !text.isEmpty {
|
|
||||||
ChatStreamingAssistantBubble(text: text)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
|
||||||
|
|
||||||
Color.clear
|
|
||||||
.frame(height: 1)
|
|
||||||
.id(self.scrollerBottomID)
|
|
||||||
}
|
}
|
||||||
.padding(.top, Layout.messageListPaddingTop)
|
|
||||||
.padding(.bottom, Layout.messageListPaddingBottom)
|
|
||||||
.padding(.horizontal, Layout.messageListPaddingHorizontal)
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.viewModel.isLoading {
|
if self.viewModel.pendingRunCount > 0 {
|
||||||
ProgressView()
|
ChatTypingIndicatorBubble(style: self.style)
|
||||||
.controlSize(.large)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
}
|
||||||
|
|
||||||
|
if !self.viewModel.pendingToolCalls.isEmpty {
|
||||||
|
ChatPendingToolsBubble(toolCalls: self.viewModel.pendingToolCalls)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let text = self.viewModel.streamingAssistantText, !text.isEmpty {
|
||||||
|
ChatStreamingAssistantBubble(text: text)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
Color.clear
|
||||||
|
.frame(height: 1)
|
||||||
|
.id(self.scrollerBottomID)
|
||||||
}
|
}
|
||||||
}
|
.padding(.top, Layout.messageListPaddingTop)
|
||||||
.onChange(of: self.viewModel.isLoading) { _, isLoading in
|
.padding(.bottom, Layout.messageListPaddingBottom)
|
||||||
guard !isLoading, !self.hasPerformedInitialScroll else { return }
|
.padding(.horizontal, Layout.messageListPaddingHorizontal)
|
||||||
proxy.scrollTo(self.scrollerBottomID, anchor: .bottom)
|
|
||||||
self.hasPerformedInitialScroll = true
|
|
||||||
}
|
}
|
||||||
.onChange(of: self.viewModel.messages.count) { _, _ in
|
.onChange(of: self.viewModel.messages.count) { _, _ in
|
||||||
guard self.hasPerformedInitialScroll else { return }
|
|
||||||
withAnimation(.snappy(duration: 0.22)) {
|
withAnimation(.snappy(duration: 0.22)) {
|
||||||
proxy.scrollTo(self.scrollerBottomID, anchor: .bottom)
|
proxy.scrollTo(self.scrollerBottomID, anchor: .bottom)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: self.viewModel.pendingRunCount) { _, _ in
|
.onChange(of: self.viewModel.pendingRunCount) { _, _ in
|
||||||
guard self.hasPerformedInitialScroll else { return }
|
|
||||||
withAnimation(.snappy(duration: 0.22)) {
|
withAnimation(.snappy(duration: 0.22)) {
|
||||||
proxy.scrollTo(self.scrollerBottomID, anchor: .bottom)
|
proxy.scrollTo(self.scrollerBottomID, anchor: .bottom)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public enum ClawdisCanvasA2UIAction: Sendable {
|
public enum ClawdisCanvasA2UIAction: Sendable {
|
||||||
public struct AgentMessageContext: Sendable {
|
|
||||||
public struct Session: Sendable {
|
|
||||||
public var key: String
|
|
||||||
public var surfaceId: String
|
|
||||||
|
|
||||||
public init(key: String, surfaceId: String) {
|
|
||||||
self.key = key
|
|
||||||
self.surfaceId = surfaceId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct Component: Sendable {
|
|
||||||
public var id: String
|
|
||||||
public var host: String
|
|
||||||
public var instanceId: String
|
|
||||||
|
|
||||||
public init(id: String, host: String, instanceId: String) {
|
|
||||||
self.id = id
|
|
||||||
self.host = host
|
|
||||||
self.instanceId = instanceId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public var actionName: String
|
|
||||||
public var session: Session
|
|
||||||
public var component: Component
|
|
||||||
public var contextJSON: String?
|
|
||||||
|
|
||||||
public init(actionName: String, session: Session, component: Component, contextJSON: String?) {
|
|
||||||
self.actionName = actionName
|
|
||||||
self.session = session
|
|
||||||
self.component = component
|
|
||||||
self.contextJSON = contextJSON
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func extractActionName(_ userAction: [String: Any]) -> String? {
|
|
||||||
let keys = ["name", "action"]
|
|
||||||
for key in keys {
|
|
||||||
if let raw = userAction[key] as? String {
|
|
||||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if !trimmed.isEmpty { return trimmed }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func sanitizeTagValue(_ value: String) -> String {
|
public static func sanitizeTagValue(_ value: String) -> String {
|
||||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let nonEmpty = trimmed.isEmpty ? "-" : trimmed
|
let nonEmpty = trimmed.isEmpty ? "-" : trimmed
|
||||||
@@ -66,16 +19,25 @@ public enum ClawdisCanvasA2UIAction: Sendable {
|
|||||||
return str
|
return str
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func formatAgentMessage(_ context: AgentMessageContext) -> String {
|
public static func formatAgentMessage(
|
||||||
let ctxSuffix = context.contextJSON.flatMap { $0.isEmpty ? nil : " ctx=\($0)" } ?? ""
|
actionName: String,
|
||||||
|
sessionKey: String,
|
||||||
|
surfaceId: String,
|
||||||
|
sourceComponentId: String,
|
||||||
|
host: String,
|
||||||
|
instanceId: String,
|
||||||
|
contextJSON: String?)
|
||||||
|
-> String
|
||||||
|
{
|
||||||
|
let ctxSuffix = contextJSON.flatMap { $0.isEmpty ? nil : " ctx=\($0)" } ?? ""
|
||||||
return [
|
return [
|
||||||
"CANVAS_A2UI",
|
"CANVAS_A2UI",
|
||||||
"action=\(self.sanitizeTagValue(context.actionName))",
|
"action=\(self.sanitizeTagValue(actionName))",
|
||||||
"session=\(self.sanitizeTagValue(context.session.key))",
|
"session=\(self.sanitizeTagValue(sessionKey))",
|
||||||
"surface=\(self.sanitizeTagValue(context.session.surfaceId))",
|
"surface=\(self.sanitizeTagValue(surfaceId))",
|
||||||
"component=\(self.sanitizeTagValue(context.component.id))",
|
"component=\(self.sanitizeTagValue(sourceComponentId))",
|
||||||
"host=\(self.sanitizeTagValue(context.component.host))",
|
"host=\(self.sanitizeTagValue(host))",
|
||||||
"instance=\(self.sanitizeTagValue(context.component.instanceId))\(ctxSuffix)",
|
"instance=\(self.sanitizeTagValue(instanceId))\(ctxSuffix)",
|
||||||
"default=update_canvas",
|
"default=update_canvas",
|
||||||
].joined(separator: " ")
|
].joined(separator: " ")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,12 +27,7 @@ public enum ClawdisCanvasA2UIJSONL: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static func validateV0_8(_ items: [ParsedItem]) throws {
|
public static func validateV0_8(_ items: [ParsedItem]) throws {
|
||||||
let allowed = Set([
|
let allowed = Set(["beginRendering", "surfaceUpdate", "dataModelUpdate", "deleteSurface"])
|
||||||
"beginRendering",
|
|
||||||
"surfaceUpdate",
|
|
||||||
"dataModelUpdate",
|
|
||||||
"deleteSurface",
|
|
||||||
])
|
|
||||||
for item in items {
|
for item in items {
|
||||||
guard let dict = item.message.value as? [String: AnyCodable] else {
|
guard let dict = item.message.value as? [String: AnyCodable] else {
|
||||||
throw NSError(domain: "A2UI", code: 1, userInfo: [
|
throw NSError(domain: "A2UI", code: 1, userInfo: [
|
||||||
@@ -44,8 +39,7 @@ public enum ClawdisCanvasA2UIJSONL: Sendable {
|
|||||||
throw NSError(domain: "A2UI", code: 2, userInfo: [
|
throw NSError(domain: "A2UI", code: 2, userInfo: [
|
||||||
NSLocalizedDescriptionKey: """
|
NSLocalizedDescriptionKey: """
|
||||||
A2UI JSONL line \(item.lineNumber): looks like A2UI v0.9 (`createSurface`).
|
A2UI JSONL line \(item.lineNumber): looks like A2UI v0.9 (`createSurface`).
|
||||||
Canvas currently supports A2UI v0.8 server→client messages
|
Canvas currently supports A2UI v0.8 server→client messages (`beginRendering`, `surfaceUpdate`, `dataModelUpdate`, `deleteSurface`).
|
||||||
(`beginRendering`, `surfaceUpdate`, `dataModelUpdate`, `deleteSurface`).
|
|
||||||
""",
|
""",
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,9 +80,8 @@
|
|||||||
#clawdis-status {
|
#clawdis-status {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
display: none;
|
display: grid;
|
||||||
place-items: start center;
|
place-items: center;
|
||||||
padding-top: calc(20px + env(safe-area-inset-top, 0px));
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
}
|
}
|
||||||
@@ -124,18 +123,6 @@
|
|||||||
const statusEl = document.getElementById('clawdis-status');
|
const statusEl = document.getElementById('clawdis-status');
|
||||||
const titleEl = document.getElementById('clawdis-status-title');
|
const titleEl = document.getElementById('clawdis-status-title');
|
||||||
const subtitleEl = document.getElementById('clawdis-status-subtitle');
|
const subtitleEl = document.getElementById('clawdis-status-subtitle');
|
||||||
const debugStatusEnabledByQuery = (() => {
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
const raw = params.get('debugStatus') ?? params.get('debug');
|
|
||||||
if (!raw) return false;
|
|
||||||
const normalized = String(raw).trim().toLowerCase();
|
|
||||||
return normalized === '1' || normalized === 'true' || normalized === 'yes';
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
let debugStatusEnabled = debugStatusEnabledByQuery;
|
|
||||||
|
|
||||||
function resize() {
|
function resize() {
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
@@ -149,24 +136,11 @@
|
|||||||
window.addEventListener('resize', resize);
|
window.addEventListener('resize', resize);
|
||||||
resize();
|
resize();
|
||||||
|
|
||||||
const setDebugStatusEnabled = (enabled) => {
|
|
||||||
debugStatusEnabled = !!enabled;
|
|
||||||
if (!statusEl) return;
|
|
||||||
if (!debugStatusEnabled) {
|
|
||||||
statusEl.style.display = 'none';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (statusEl && !debugStatusEnabled) {
|
|
||||||
statusEl.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
window.__clawdis = {
|
window.__clawdis = {
|
||||||
canvas,
|
canvas,
|
||||||
ctx,
|
ctx,
|
||||||
setDebugStatusEnabled,
|
|
||||||
setStatus: (title, subtitle) => {
|
setStatus: (title, subtitle) => {
|
||||||
if (!statusEl || !debugStatusEnabled) return;
|
if (!statusEl) return;
|
||||||
if (!title && !subtitle) {
|
if (!title && !subtitle) {
|
||||||
statusEl.style.display = 'none';
|
statusEl.style.display = 'none';
|
||||||
return;
|
return;
|
||||||
@@ -174,14 +148,11 @@
|
|||||||
statusEl.style.display = 'grid';
|
statusEl.style.display = 'grid';
|
||||||
if (titleEl && typeof title === 'string') titleEl.textContent = title;
|
if (titleEl && typeof title === 'string') titleEl.textContent = title;
|
||||||
if (subtitleEl && typeof subtitle === 'string') subtitleEl.textContent = subtitle;
|
if (subtitleEl && typeof subtitle === 'string') subtitleEl.textContent = subtitle;
|
||||||
if (!debugStatusEnabled) {
|
// Auto-hide after 3 seconds.
|
||||||
clearTimeout(window.__statusTimeout);
|
clearTimeout(window.__statusTimeout);
|
||||||
window.__statusTimeout = setTimeout(() => {
|
window.__statusTimeout = setTimeout(() => {
|
||||||
statusEl.style.display = 'none';
|
statusEl.style.display = 'none';
|
||||||
}, 3000);
|
}, 3000);
|
||||||
} else {
|
|
||||||
clearTimeout(window.__statusTimeout);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -9,20 +9,15 @@ import Testing
|
|||||||
#expect(ClawdisCanvasA2UIAction.sanitizeTagValue("macOS 26.2") == "macOS_26.2")
|
#expect(ClawdisCanvasA2UIAction.sanitizeTagValue("macOS 26.2") == "macOS_26.2")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func extractActionNameAcceptsNameOrAction() {
|
|
||||||
#expect(ClawdisCanvasA2UIAction.extractActionName(["name": "Hello"]) == "Hello")
|
|
||||||
#expect(ClawdisCanvasA2UIAction.extractActionName(["action": "Wave"]) == "Wave")
|
|
||||||
#expect(ClawdisCanvasA2UIAction.extractActionName(["name": " ", "action": "Fallback"]) == "Fallback")
|
|
||||||
#expect(ClawdisCanvasA2UIAction.extractActionName(["action": " "]) == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func formatAgentMessageIsTokenEfficientAndUnambiguous() {
|
@Test func formatAgentMessageIsTokenEfficientAndUnambiguous() {
|
||||||
let messageContext = ClawdisCanvasA2UIAction.AgentMessageContext(
|
let msg = ClawdisCanvasA2UIAction.formatAgentMessage(
|
||||||
actionName: "Get Weather",
|
actionName: "Get Weather",
|
||||||
session: .init(key: "main", surfaceId: "main"),
|
sessionKey: "main",
|
||||||
component: .init(id: "btnWeather", host: "Peter’s iPad", instanceId: "ipad16,6"),
|
surfaceId: "main",
|
||||||
|
sourceComponentId: "btnWeather",
|
||||||
|
host: "Peter’s iPad",
|
||||||
|
instanceId: "ipad16,6",
|
||||||
contextJSON: "{\"city\":\"Vienna\"}")
|
contextJSON: "{\"city\":\"Vienna\"}")
|
||||||
let msg = ClawdisCanvasA2UIAction.formatAgentMessage(messageContext)
|
|
||||||
|
|
||||||
#expect(msg.contains("CANVAS_A2UI "))
|
#expect(msg.contains("CANVAS_A2UI "))
|
||||||
#expect(msg.contains("action=Get_Weather"))
|
#expect(msg.contains("action=Get_Weather"))
|
||||||
|
|||||||
+4
-8
@@ -176,11 +176,7 @@ class ClawdisA2UIHost extends LitElement {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding:
|
padding: 12px;
|
||||||
var(--clawdis-a2ui-inset-top, 0px)
|
|
||||||
var(--clawdis-a2ui-inset-right, 0px)
|
|
||||||
var(--clawdis-a2ui-inset-bottom, 0px)
|
|
||||||
var(--clawdis-a2ui-inset-left, 0px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#surfaces {
|
#surfaces {
|
||||||
@@ -189,14 +185,14 @@ class ClawdisA2UIHost extends LitElement {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding-bottom: var(--clawdis-a2ui-scroll-pad-bottom, 0px);
|
padding-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status {
|
.status {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
top: var(--clawdis-a2ui-status-top, 12px);
|
top: 12px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -217,7 +213,7 @@ class ClawdisA2UIHost extends LitElement {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
bottom: var(--clawdis-a2ui-toast-bottom, 12px);
|
bottom: 12px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
+1
-2
@@ -7,8 +7,7 @@
|
|||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"includes": [
|
"includes": [
|
||||||
"src/**/*.ts",
|
"src/**/*.ts"
|
||||||
"test/**/*.ts"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"linter": {
|
"linter": {
|
||||||
|
|||||||
Loaded 100 of 275 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user