Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3762e876b3 | ||
|
|
9b4d06002a | ||
|
|
d2d336fc3a |
No files matched your search
@@ -170,43 +170,6 @@ jobs:
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
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
|
||||
run: |
|
||||
|
||||
@@ -16,7 +16,6 @@ bin/clawdis-mac
|
||||
bin/docs-list
|
||||
apps/macos/.build-local/
|
||||
apps/macos/.swiftpm/
|
||||
apps/shared/ClawdisKit/.swiftpm/
|
||||
Core/
|
||||
apps/ios/*.xcodeproj/
|
||||
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`.
|
||||
- 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.
|
||||
- **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:** when Peter says "push", you may `git pull --rebase` to integrate latest changes (never discard other agents' work). When Peter says "commit", scope to your changes only. When Peter says "commit all", commit everything in grouped chunks.
|
||||
- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless Peter explicitly asks.
|
||||
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless Peter explicitly asks.
|
||||
- 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.
|
||||
- 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:
|
||||
|
||||
@@ -1,41 +1,5 @@
|
||||
# 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
|
||||
|
||||
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
|
||||
|
||||
<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 align="center">
|
||||
@@ -43,7 +43,6 @@ Your surfaces
|
||||
- **Canvas** — a live visual workspace you can drive from the agent.
|
||||
- **Automation-ready** — browser control, media handling, and tool streaming.
|
||||
- **Local-first control plane** — the Gateway owns state, everything else connects.
|
||||
- **Group chats** — mention-based by default, `/activation always|mention` per group (owner-only).
|
||||
|
||||
## 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`).
|
||||
|
||||
## 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
|
||||
|
||||
### TypeScript Gateway (src/gateway/server.ts)
|
||||
@@ -183,7 +171,6 @@ Browser control (optional):
|
||||
|
||||
- [`docs/index.md`](docs/index.md) (overview)
|
||||
- [`docs/configuration.md`](docs/configuration.md)
|
||||
- [`docs/group-messages.md`](docs/group-messages.md)
|
||||
- [`docs/gateway.md`](docs/gateway.md)
|
||||
- [`docs/web.md`](docs/web.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(
|
||||
name: "swabble",
|
||||
platforms: [
|
||||
.macOS(.v15),
|
||||
.iOS(.v17),
|
||||
.macOS(.v26),
|
||||
],
|
||||
products: [
|
||||
.library(name: "Swabble", targets: ["Swabble"]),
|
||||
.library(name: "SwabbleKit", targets: ["SwabbleKit"]),
|
||||
.executable(name: "swabble", targets: ["SwabbleCLI"]),
|
||||
],
|
||||
dependencies: [
|
||||
@@ -21,30 +19,13 @@ let package = Package(
|
||||
name: "Swabble",
|
||||
path: "Sources/SwabbleCore",
|
||||
swiftSettings: []),
|
||||
.target(
|
||||
name: "SwabbleKit",
|
||||
path: "Sources/SwabbleKit",
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
.executableTarget(
|
||||
name: "SwabbleCLI",
|
||||
dependencies: [
|
||||
"Swabble",
|
||||
"SwabbleKit",
|
||||
.product(name: "Commander", package: "Commander"),
|
||||
],
|
||||
path: "Sources/swabble"),
|
||||
.testTarget(
|
||||
name: "SwabbleKitTests",
|
||||
dependencies: [
|
||||
"SwabbleKit",
|
||||
.product(name: "Testing", package: "swift-testing"),
|
||||
],
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
.enableExperimentalFeature("SwiftTesting"),
|
||||
]),
|
||||
.testTarget(
|
||||
name: "swabbleTests",
|
||||
dependencies: [
|
||||
|
||||
+4
-8
@@ -1,10 +1,9 @@
|
||||
# 🎙️ swabble — Speech.framework wake-word hook daemon (macOS 26)
|
||||
|
||||
swabble is a Swift 6.2 wake-word hook daemon. The CLI targets macOS 26 (SpeechAnalyzer + SpeechTranscriber). The shared `SwabbleKit` target is multi-platform and exposes wake-word gating utilities for iOS/macOS apps.
|
||||
swabble is a Swift 6.2, macOS 26-only rewrite of the brabble voice daemon. It listens on your mic, gates on a wake word, transcribes locally using Apple's new SpeechAnalyzer + SpeechTranscriber, then fires a shell hook with the transcript. No cloud calls, no Whisper binaries.
|
||||
|
||||
- **Local-only**: Speech.framework on-device models; zero network usage.
|
||||
- **Wake word**: Default `clawd` (aliases `claude`), optional `--no-wake` bypass.
|
||||
- **SwabbleKit**: Shared wake gate utilities (gap-based gating when you provide speech segments).
|
||||
- **Hooks**: Run any command with prefix/env, cooldown, min_chars, timeout.
|
||||
- **Services**: launchd helper stubs for start/stop/install.
|
||||
- **File transcribe**: TXT or SRT with time ranges (using AttributedString splits).
|
||||
@@ -31,7 +30,7 @@ swift run swabble transcribe /path/to/audio.m4a --format srt --output out.srt
|
||||
```
|
||||
|
||||
## Use as a library
|
||||
Add swabble as a SwiftPM dependency and import the `Swabble` or `SwabbleKit` product:
|
||||
Add swabble as a SwiftPM dependency and import the `Swabble` product to reuse the Speech pipeline, config loader, hook executor, and transcript store in your own app:
|
||||
|
||||
```swift
|
||||
// Package.swift
|
||||
@@ -39,10 +38,7 @@ dependencies: [
|
||||
.package(url: "https://github.com/steipete/swabble.git", branch: "main"),
|
||||
],
|
||||
targets: [
|
||||
.target(name: "MyApp", dependencies: [
|
||||
.product(name: "Swabble", package: "swabble"), // Speech pipeline (macOS 26+ / iOS 26+)
|
||||
.product(name: "SwabbleKit", package: "swabble"), // Wake-word gate utilities (iOS 17+ / macOS 15+)
|
||||
]),
|
||||
.target(name: "MyApp", dependencies: [.product(name: "Swabble", package: "swabble")]),
|
||||
]
|
||||
```
|
||||
|
||||
@@ -97,7 +93,7 @@ Environment variables:
|
||||
|
||||
## Speech pipeline
|
||||
- `AVAudioEngine` tap → `BufferConverter` → `AnalyzerInput` → `SpeechAnalyzer` with a `SpeechTranscriber` module.
|
||||
- Requests volatile + final results; the CLI uses text-only wake gating today.
|
||||
- Requests volatile + final results; wake gating is string match on partial/final.
|
||||
- Authorization requested at first start; requires macOS 26 + new Speech.framework APIs.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -2,13 +2,11 @@ import AVFoundation
|
||||
import Foundation
|
||||
import Speech
|
||||
|
||||
@available(macOS 26.0, iOS 26.0, *)
|
||||
public struct SpeechSegment: Sendable {
|
||||
public let text: String
|
||||
public let isFinal: Bool
|
||||
}
|
||||
|
||||
@available(macOS 26.0, iOS 26.0, *)
|
||||
public enum SpeechPipelineError: Error {
|
||||
case authorizationDenied
|
||||
case analyzerFormatUnavailable
|
||||
@@ -16,7 +14,6 @@ public enum SpeechPipelineError: Error {
|
||||
}
|
||||
|
||||
/// Live microphone → SpeechAnalyzer → SpeechTranscriber pipeline.
|
||||
@available(macOS 26.0, iOS 26.0, *)
|
||||
public actor SpeechPipeline {
|
||||
private struct UnsafeBuffer: @unchecked Sendable { let buffer: AVAudioPCMBuffer }
|
||||
|
||||
|
||||
@@ -1,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 Foundation
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
@MainActor
|
||||
enum CLIRegistry {
|
||||
static var descriptors: [CommandDescriptor] {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import Swabble
|
||||
import SwabbleKit
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
@MainActor
|
||||
struct ServeCommand: ParsableCommand {
|
||||
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
|
||||
@@ -70,12 +68,17 @@ struct ServeCommand: ParsableCommand {
|
||||
}
|
||||
|
||||
private static func matchesWake(text: String, cfg: SwabbleConfig) -> Bool {
|
||||
let triggers = [cfg.wake.word] + cfg.wake.aliases
|
||||
return WakeWordGate.matchesTextOnly(text: text, triggers: triggers)
|
||||
let lowered = text.lowercased()
|
||||
if lowered.contains(cfg.wake.word.lowercased()) { return true }
|
||||
return cfg.wake.aliases.contains(where: { lowered.contains($0.lowercased()) })
|
||||
}
|
||||
|
||||
private static func stripWake(text: String, cfg: SwabbleConfig) -> String {
|
||||
let triggers = [cfg.wake.word] + cfg.wake.aliases
|
||||
return WakeWordGate.stripWake(text: text, triggers: triggers)
|
||||
var out = text
|
||||
out = out.replacingOccurrences(of: cfg.wake.word, with: "", options: [.caseInsensitive])
|
||||
for alias in cfg.wake.aliases {
|
||||
out = out.replacingOccurrences(of: alias, with: "", options: [.caseInsensitive])
|
||||
}
|
||||
return out.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
@MainActor
|
||||
private func runCLI() async -> Int32 {
|
||||
do {
|
||||
@@ -16,7 +15,6 @@ private func runCLI() async -> Int32 {
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
@MainActor
|
||||
private func dispatch(invocation: CommandInvocation) async throws {
|
||||
let parsed = invocation.parsedValues
|
||||
@@ -97,10 +95,5 @@ private func dispatch(invocation: CommandInvocation) async throws {
|
||||
}
|
||||
}
|
||||
|
||||
if #available(macOS 26.0, *) {
|
||||
let exitCode = await runCLI()
|
||||
exit(exitCode)
|
||||
} else {
|
||||
fputs("error: swabble requires macOS 26 or newer\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
let exitCode = await runCLI()
|
||||
exit(exitCode)
|
||||
@@ -1,63 +0,0 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import SwabbleKit
|
||||
|
||||
@Suite struct WakeWordGateTests {
|
||||
@Test func matchRequiresGapAfterTrigger() {
|
||||
let transcript = "hey clawd do thing"
|
||||
let segments = makeSegments(
|
||||
transcript: transcript,
|
||||
words: [
|
||||
("hey", 0.0, 0.1),
|
||||
("clawd", 0.2, 0.1),
|
||||
("do", 0.35, 0.1),
|
||||
("thing", 0.5, 0.1),
|
||||
])
|
||||
let config = WakeWordGateConfig(triggers: ["clawd"], minPostTriggerGap: 0.3)
|
||||
#expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config) == nil)
|
||||
}
|
||||
|
||||
@Test func matchAllowsGapAndExtractsCommand() {
|
||||
let transcript = "hey clawd do thing"
|
||||
let segments = makeSegments(
|
||||
transcript: transcript,
|
||||
words: [
|
||||
("hey", 0.0, 0.1),
|
||||
("clawd", 0.2, 0.1),
|
||||
("do", 0.9, 0.1),
|
||||
("thing", 1.1, 0.1),
|
||||
])
|
||||
let config = WakeWordGateConfig(triggers: ["clawd"], minPostTriggerGap: 0.3)
|
||||
let match = WakeWordGate.match(transcript: transcript, segments: segments, config: config)
|
||||
#expect(match?.command == "do thing")
|
||||
}
|
||||
|
||||
@Test func matchHandlesMultiWordTriggers() {
|
||||
let transcript = "hey clawd do it"
|
||||
let segments = makeSegments(
|
||||
transcript: transcript,
|
||||
words: [
|
||||
("hey", 0.0, 0.1),
|
||||
("clawd", 0.2, 0.1),
|
||||
("do", 0.8, 0.1),
|
||||
("it", 1.0, 0.1),
|
||||
])
|
||||
let config = WakeWordGateConfig(triggers: ["hey clawd"], minPostTriggerGap: 0.3)
|
||||
let match = WakeWordGate.match(transcript: transcript, segments: segments, config: config)
|
||||
#expect(match?.command == "do it")
|
||||
}
|
||||
}
|
||||
|
||||
private func makeSegments(
|
||||
transcript: String,
|
||||
words: [(String, TimeInterval, TimeInterval)])
|
||||
-> [WakeWordSegment] {
|
||||
var searchStart = transcript.startIndex
|
||||
var output: [WakeWordSegment] = []
|
||||
for (word, start, duration) in words {
|
||||
let range = transcript.range(of: word, range: searchStart..<transcript.endIndex)
|
||||
output.append(WakeWordSegment(text: word, start: start, duration: duration, range: range))
|
||||
if let range { searchStart = range.upperBound }
|
||||
}
|
||||
return output
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
# swabble — macOS 26 speech hook daemon (Swift 6.2)
|
||||
|
||||
Goal: brabble-style always-on voice hook for macOS 26 using Apple Speech.framework (SpeechAnalyzer + SpeechTranscriber) instead of whisper.cpp. Local-only, wake word gated, dispatches a shell hook with the transcript. Shared wake-gate utilities live in `SwabbleKit` for reuse by other apps (iOS/macOS).
|
||||
Goal: brabble-style always-on voice hook for macOS 26 using Apple Speech.framework (SpeechAnalyzer + SpeechTranscriber) instead of whisper.cpp. Local-only, wake word gated, dispatches a shell hook with the transcript.
|
||||
|
||||
## Requirements
|
||||
- macOS 26+, Swift 6.2, Speech.framework with on-device assets.
|
||||
- Local only; no network calls during transcription.
|
||||
- Wake word gating (default "clawd" plus aliases) with bypass flag `--no-wake`.
|
||||
- `SwabbleKit` target (multi-platform) providing wake-word gating helpers that can use speech segment timing to require a post-trigger gap.
|
||||
- Hook execution with cooldown, min_chars, timeout, prefix, env vars.
|
||||
- Simple config at `~/.config/swabble/config.json` (JSON, Codable) — no TOML.
|
||||
- CLI implemented with Commander (SwiftPM package `steipete/Commander`); core types are available via the SwiftPM library product `Swabble` for embedding.
|
||||
@@ -18,7 +17,7 @@ Goal: brabble-style always-on voice hook for macOS 26 using Apple Speech.framewo
|
||||
- **CLI layer (Commander)**: Root command `swabble` with subcommands `serve`, `transcribe`, `test-hook`, `mic list|set`, `doctor`, `health`, `tail-log`. Runtime flags from Commander (`-v/--verbose`, `--json-output`, `--log-level`). Custom `--config` path applies everywhere.
|
||||
- **Config**: `SwabbleConfig` Codable. Fields: audio device name/index, wake (enabled/word/aliases/sensitivity placeholder), hook (command/args/prefix/cooldown/min_chars/timeout/env), logging (level, format), transcripts (enabled, max kept), speech (locale, enableEtiquetteReplacements flag). Stored JSON; default written by `setup`.
|
||||
- **Audio + Speech pipeline**: `SpeechPipeline` wraps `AVAudioEngine` input → `SpeechAnalyzer` with `SpeechTranscriber` module. Emits partial/final transcripts via async stream. Requests `.audioTimeRange` when transcripts enabled. Handles Speech permission and asset download prompts ahead of capture.
|
||||
- **Wake gate**: CLI currently uses text-only keyword match; shared `SwabbleKit` gate can enforce a minimum pause between the wake word and the next token when speech segments are available. `--no-wake` disables gating.
|
||||
- **Wake gate**: text-based keyword match against latest partial/final; strips wake term before hook dispatch. `--no-wake` disables.
|
||||
- **Hook executor**: async `HookExecutor` spawns `Process` with configured args, prefix substitution `${hostname}`. Enforces cooldown + timeout; injects env `SWABBLE_TEXT`, `SWABBLE_PREFIX` plus user env map.
|
||||
- **Transcripts store**: in-memory ring buffer; optional persisted JSON lines under `~/Library/Application Support/swabble/transcripts.log`.
|
||||
- **Logging**: simple structured logger to stderr; respects log level.
|
||||
@@ -26,7 +25,7 @@ Goal: brabble-style always-on voice hook for macOS 26 using Apple Speech.framewo
|
||||
## Out of scope (initial cut)
|
||||
- Model management (Speech handles assets).
|
||||
- Launchd helper (planned follow-up).
|
||||
- Advanced wake-word detector (segment-aware gate now lives in `SwabbleKit`; CLI still text-only until segment timing is plumbed through).
|
||||
- Advanced wake-word detector (text match only for now).
|
||||
|
||||
## Open decisions
|
||||
- Whether to expose a UNIX control socket for `status`/`health` (currently planned as stdin/out direct calls).
|
||||
|
||||
-11
@@ -6,17 +6,6 @@
|
||||
<title>Clawdis Updates</title>
|
||||
<link>https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml</link>
|
||||
<description>Signed update feed for the Clawdis macOS companion app.</description>
|
||||
<item>
|
||||
<title>Clawdis 2.0.0-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>
|
||||
<title>Clawdis 2.0.0-beta1</title>
|
||||
<sparkle:releaseNotesLink>https://github.com/steipete/clawdis/releases/tag/v2.0.0-beta1</sparkle:releaseNotesLink>
|
||||
|
||||
@@ -38,21 +38,15 @@ android {
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
disable += setOf("IconLauncherShape")
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -62,7 +56,7 @@ dependencies {
|
||||
|
||||
implementation("androidx.core:core-ktx:1.17.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
||||
implementation("androidx.activity:activity-compose:1.12.2")
|
||||
implementation("androidx.activity:activity-compose:1.12.1")
|
||||
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
|
||||
@@ -12,20 +12,12 @@
|
||||
<uses-permission
|
||||
android:name="android.permission.ACCESS_FINE_LOCATION"
|
||||
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.RECORD_AUDIO" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".NodeApp"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:label="@string/app_name"
|
||||
|
||||
@@ -38,7 +38,6 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val manualEnabled: StateFlow<Boolean> = runtime.manualEnabled
|
||||
val manualHost: StateFlow<String> = runtime.manualHost
|
||||
val manualPort: StateFlow<Int> = runtime.manualPort
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = runtime.canvasDebugStatusEnabled
|
||||
|
||||
val chatSessionKey: StateFlow<String> = runtime.chatSessionKey
|
||||
val chatSessionId: StateFlow<String?> = runtime.chatSessionId
|
||||
@@ -79,10 +78,6 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
runtime.setManualPort(value)
|
||||
}
|
||||
|
||||
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
||||
runtime.setCanvasDebugStatusEnabled(value)
|
||||
}
|
||||
|
||||
fun setWakeWords(words: List<String>) {
|
||||
runtime.setWakeWords(words)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -84,6 +85,7 @@ class NodeForegroundService : Service() {
|
||||
override fun onBind(intent: Intent?) = null
|
||||
|
||||
private fun ensureChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val mgr = getSystemService(NotificationManager::class.java)
|
||||
val channel =
|
||||
NotificationChannel(
|
||||
@@ -99,7 +101,12 @@ class NodeForegroundService : Service() {
|
||||
|
||||
private fun buildNotification(title: String, text: String): Notification {
|
||||
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)
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
@@ -119,6 +126,11 @@ class NodeForegroundService : Service() {
|
||||
}
|
||||
|
||||
private fun startForegroundWithTypes(notification: Notification, requiresMic: Boolean) {
|
||||
if (Build.VERSION.SDK_INT < 29) {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
return
|
||||
}
|
||||
|
||||
if (didStartForeground && requiresMic == lastRequiresMic) {
|
||||
updateNotification(notification)
|
||||
return
|
||||
@@ -150,7 +162,11 @@ class NodeForegroundService : Service() {
|
||||
|
||||
fun start(context: Context) {
|
||||
val intent = Intent(context, NodeForegroundService::class.java)
|
||||
context.startForegroundService(intent)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
|
||||
@@ -109,8 +109,6 @@ class NodeRuntime(context: Context) {
|
||||
private val _isForeground = MutableStateFlow(true)
|
||||
val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
|
||||
|
||||
private var lastAutoA2uiUrl: String? = null
|
||||
|
||||
private val session =
|
||||
BridgeSession(
|
||||
scope = scope,
|
||||
@@ -120,7 +118,6 @@ class NodeRuntime(context: Context) {
|
||||
_remoteAddress.value = remote
|
||||
_isConnected.value = true
|
||||
scope.launch { refreshWakeWordsFromGateway() }
|
||||
maybeNavigateToA2uiOnConnect()
|
||||
},
|
||||
onDisconnected = { message -> handleSessionDisconnected(message) },
|
||||
onEvent = { event, payloadJson ->
|
||||
@@ -139,21 +136,6 @@ class NodeRuntime(context: Context) {
|
||||
_remoteAddress.value = null
|
||||
_isConnected.value = false
|
||||
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
|
||||
@@ -166,7 +148,6 @@ class NodeRuntime(context: Context) {
|
||||
val manualHost: StateFlow<String> = prefs.manualHost
|
||||
val manualPort: StateFlow<Int> = prefs.manualPort
|
||||
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
|
||||
|
||||
private var didAutoConnect = false
|
||||
private var suppressWakeWordsSync = false
|
||||
@@ -247,22 +228,6 @@ class NodeRuntime(context: Context) {
|
||||
connect(target)
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
combine(
|
||||
canvasDebugStatusEnabled,
|
||||
statusText,
|
||||
serverName,
|
||||
remoteAddress,
|
||||
) { debugEnabled, status, server, remote ->
|
||||
Quad(debugEnabled, status, server, remote)
|
||||
}.distinctUntilChanged()
|
||||
.collect { (debugEnabled, status, server, remote) ->
|
||||
canvas.setDebugStatusEnabled(debugEnabled)
|
||||
if (!debugEnabled) return@collect
|
||||
canvas.setDebugStatus(status, server ?: remote)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setForeground(value: Boolean) {
|
||||
@@ -293,10 +258,6 @@ class NodeRuntime(context: Context) {
|
||||
prefs.setManualPort(value)
|
||||
}
|
||||
|
||||
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
||||
prefs.setCanvasDebugStatusEnabled(value)
|
||||
}
|
||||
|
||||
fun setWakeWords(words: List<String>) {
|
||||
prefs.setWakeWords(words)
|
||||
scheduleWakeWordsSyncIfNeeded()
|
||||
@@ -435,7 +396,8 @@ class NodeRuntime(context: Context) {
|
||||
val actionId = (userActionObj["id"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty {
|
||||
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 =
|
||||
(userActionObj["surfaceId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "main" }
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
package com.steipete.clawdis.node
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.edit
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -63,10 +62,6 @@ class SecurePrefs(context: Context) {
|
||||
MutableStateFlow(prefs.getString("bridge.lastDiscoveredStableId", "")!!)
|
||||
val lastDiscoveredStableId: StateFlow<String> = _lastDiscoveredStableId
|
||||
|
||||
private val _canvasDebugStatusEnabled =
|
||||
MutableStateFlow(prefs.getBoolean("canvas.debugStatusEnabled", false))
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = _canvasDebugStatusEnabled
|
||||
|
||||
private val _wakeWords = MutableStateFlow(loadWakeWords())
|
||||
val wakeWords: StateFlow<List<String>> = _wakeWords
|
||||
|
||||
@@ -75,47 +70,42 @@ class SecurePrefs(context: Context) {
|
||||
|
||||
fun setLastDiscoveredStableId(value: String) {
|
||||
val trimmed = value.trim()
|
||||
prefs.edit { putString("bridge.lastDiscoveredStableId", trimmed) }
|
||||
prefs.edit().putString("bridge.lastDiscoveredStableId", trimmed).apply()
|
||||
_lastDiscoveredStableId.value = trimmed
|
||||
}
|
||||
|
||||
fun setDisplayName(value: String) {
|
||||
val trimmed = value.trim()
|
||||
prefs.edit { putString(displayNameKey, trimmed) }
|
||||
prefs.edit().putString(displayNameKey, trimmed).apply()
|
||||
_displayName.value = trimmed
|
||||
}
|
||||
|
||||
fun setCameraEnabled(value: Boolean) {
|
||||
prefs.edit { putBoolean("camera.enabled", value) }
|
||||
prefs.edit().putBoolean("camera.enabled", value).apply()
|
||||
_cameraEnabled.value = value
|
||||
}
|
||||
|
||||
fun setPreventSleep(value: Boolean) {
|
||||
prefs.edit { putBoolean("screen.preventSleep", value) }
|
||||
prefs.edit().putBoolean("screen.preventSleep", value).apply()
|
||||
_preventSleep.value = value
|
||||
}
|
||||
|
||||
fun setManualEnabled(value: Boolean) {
|
||||
prefs.edit { putBoolean("bridge.manual.enabled", value) }
|
||||
prefs.edit().putBoolean("bridge.manual.enabled", value).apply()
|
||||
_manualEnabled.value = value
|
||||
}
|
||||
|
||||
fun setManualHost(value: String) {
|
||||
val trimmed = value.trim()
|
||||
prefs.edit { putString("bridge.manual.host", trimmed) }
|
||||
prefs.edit().putString("bridge.manual.host", trimmed).apply()
|
||||
_manualHost.value = trimmed
|
||||
}
|
||||
|
||||
fun setManualPort(value: Int) {
|
||||
prefs.edit { putInt("bridge.manual.port", value) }
|
||||
prefs.edit().putInt("bridge.manual.port", value).apply()
|
||||
_manualPort.value = value
|
||||
}
|
||||
|
||||
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
||||
prefs.edit { putBoolean("canvas.debugStatusEnabled", value) }
|
||||
_canvasDebugStatusEnabled.value = value
|
||||
}
|
||||
|
||||
fun loadBridgeToken(): String? {
|
||||
val key = "bridge.token.${_instanceId.value}"
|
||||
return prefs.getString(key, null)
|
||||
@@ -123,14 +113,14 @@ class SecurePrefs(context: Context) {
|
||||
|
||||
fun saveBridgeToken(token: String) {
|
||||
val key = "bridge.token.${_instanceId.value}"
|
||||
prefs.edit { putString(key, token.trim()) }
|
||||
prefs.edit().putString(key, token.trim()).apply()
|
||||
}
|
||||
|
||||
private fun loadOrCreateInstanceId(): String {
|
||||
val existing = prefs.getString("node.instanceId", null)?.trim()
|
||||
if (!existing.isNullOrBlank()) return existing
|
||||
val fresh = UUID.randomUUID().toString()
|
||||
prefs.edit { putString("node.instanceId", fresh) }
|
||||
prefs.edit().putString("node.instanceId", fresh).apply()
|
||||
return fresh
|
||||
}
|
||||
|
||||
@@ -141,7 +131,7 @@ class SecurePrefs(context: Context) {
|
||||
val candidate = DeviceNames.bestDefaultNodeName(context).trim()
|
||||
val resolved = candidate.ifEmpty { "Android Node" }
|
||||
|
||||
prefs.edit { putString(displayNameKey, resolved) }
|
||||
prefs.edit().putString(displayNameKey, resolved).apply()
|
||||
return resolved
|
||||
}
|
||||
|
||||
@@ -149,12 +139,12 @@ class SecurePrefs(context: Context) {
|
||||
val sanitized = WakeWords.sanitize(words, defaultWakeWords)
|
||||
val encoded =
|
||||
JsonArray(sanitized.map { JsonPrimitive(it) }).toString()
|
||||
prefs.edit { putString("voiceWake.triggerWords", encoded) }
|
||||
prefs.edit().putString("voiceWake.triggerWords", encoded).apply()
|
||||
_wakeWords.value = sanitized
|
||||
}
|
||||
|
||||
fun setVoiceWakeMode(mode: VoiceWakeMode) {
|
||||
prefs.edit { putString(voiceWakeModeKey, mode.rawValue) }
|
||||
prefs.edit().putString(voiceWakeModeKey, mode.rawValue).apply()
|
||||
_voiceWakeMode.value = mode
|
||||
}
|
||||
|
||||
@@ -164,7 +154,7 @@ class SecurePrefs(context: Context) {
|
||||
|
||||
// Default ON (foreground) when unset.
|
||||
if (raw.isNullOrBlank()) {
|
||||
prefs.edit { putString(voiceWakeModeKey, resolved.rawValue) }
|
||||
prefs.edit().putString(voiceWakeModeKey, resolved.rawValue).apply()
|
||||
}
|
||||
|
||||
return resolved
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.net.DnsResolver
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.nsd.NsdManager
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.CancellationSignal
|
||||
import android.util.Log
|
||||
import java.io.IOException
|
||||
@@ -43,7 +44,6 @@ import org.xbill.DNS.Type
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
class BridgeDiscovery(
|
||||
context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
@@ -181,6 +181,7 @@ class BridgeDiscovery(
|
||||
}
|
||||
|
||||
private fun txt(info: NsdServiceInfo, key: String): String? {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) return null
|
||||
val bytes = info.attributes[key] ?: return null
|
||||
return try {
|
||||
String(bytes, Charsets.UTF_8).trim().ifEmpty { null }
|
||||
@@ -400,7 +401,7 @@ class BridgeDiscovery(
|
||||
dns.rawQuery(
|
||||
network,
|
||||
wireQuery,
|
||||
DnsResolver.FLAG_EMPTY,
|
||||
0,
|
||||
dnsExecutor,
|
||||
signal,
|
||||
object : DnsResolver.Callback<ByteArray> {
|
||||
|
||||
+1
-4
@@ -2,7 +2,6 @@ package com.steipete.clawdis.node.node
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.annotation.SuppressLint
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
@@ -19,7 +18,6 @@ import androidx.camera.video.VideoCapture
|
||||
import androidx.camera.video.VideoRecordEvent
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.ContextCompat.checkSelfPermission
|
||||
import androidx.core.graphics.scale
|
||||
import com.steipete.clawdis.node.PermissionRequester
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
@@ -94,7 +92,7 @@ class CameraCaptureManager(private val context: Context) {
|
||||
(decoded.height.toDouble() * (maxWidth.toDouble() / decoded.width.toDouble()))
|
||||
.toInt()
|
||||
.coerceAtLeast(1)
|
||||
decoded.scale(maxWidth, h)
|
||||
Bitmap.createScaledBitmap(decoded, maxWidth, h, true)
|
||||
} else {
|
||||
decoded
|
||||
}
|
||||
@@ -110,7 +108,6 @@ class CameraCaptureManager(private val context: Context) {
|
||||
)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
suspend fun clip(paramsJson: String?): Payload =
|
||||
withContext(Dispatchers.Main) {
|
||||
ensureCameraPermission()
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
package com.steipete.clawdis.node.node
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Build
|
||||
import android.graphics.Canvas
|
||||
import android.os.Looper
|
||||
import android.webkit.WebView
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.core.graphics.scale
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.ByteArrayOutputStream
|
||||
import android.util.Base64
|
||||
import org.json.JSONObject
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
@@ -26,9 +24,6 @@ class CanvasController {
|
||||
|
||||
@Volatile private var webView: WebView? = null
|
||||
@Volatile private var url: String? = null
|
||||
@Volatile private var debugStatusEnabled: Boolean = false
|
||||
@Volatile private var debugStatusTitle: String? = null
|
||||
@Volatile private var debugStatusSubtitle: String? = null
|
||||
|
||||
private val scaffoldAssetUrl = "file:///android_asset/CanvasScaffold/scaffold.html"
|
||||
|
||||
@@ -40,7 +35,6 @@ class CanvasController {
|
||||
fun attach(webView: WebView) {
|
||||
this.webView = webView
|
||||
reload()
|
||||
applyDebugStatus()
|
||||
}
|
||||
|
||||
fun navigate(url: String) {
|
||||
@@ -49,25 +43,6 @@ class CanvasController {
|
||||
reload()
|
||||
}
|
||||
|
||||
fun currentUrl(): String? = url
|
||||
|
||||
fun isDefaultCanvas(): Boolean = url == null
|
||||
|
||||
fun setDebugStatusEnabled(enabled: Boolean) {
|
||||
debugStatusEnabled = enabled
|
||||
applyDebugStatus()
|
||||
}
|
||||
|
||||
fun setDebugStatus(title: String?, subtitle: String?) {
|
||||
debugStatusTitle = title
|
||||
debugStatusSubtitle = subtitle
|
||||
applyDebugStatus()
|
||||
}
|
||||
|
||||
fun onPageFinished() {
|
||||
applyDebugStatus()
|
||||
}
|
||||
|
||||
private inline fun withWebViewOnMain(crossinline block: (WebView) -> Unit) {
|
||||
val wv = webView ?: return
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
@@ -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 =
|
||||
withContext(Dispatchers.Main) {
|
||||
val wv = webView ?: throw IllegalStateException("no webview")
|
||||
@@ -131,7 +80,7 @@ class CanvasController {
|
||||
val scaled =
|
||||
if (maxWidth != null && maxWidth > 0 && bmp.width > maxWidth) {
|
||||
val h = (bmp.height.toDouble() * (maxWidth.toDouble() / bmp.width.toDouble())).toInt().coerceAtLeast(1)
|
||||
bmp.scale(maxWidth, h)
|
||||
Bitmap.createScaledBitmap(bmp, maxWidth, h, true)
|
||||
} else {
|
||||
bmp
|
||||
}
|
||||
@@ -148,7 +97,7 @@ class CanvasController {
|
||||
val scaled =
|
||||
if (maxWidth != null && maxWidth > 0 && bmp.width > maxWidth) {
|
||||
val h = (bmp.height.toDouble() * (maxWidth.toDouble() / bmp.width.toDouble())).toInt().coerceAtLeast(1)
|
||||
bmp.scale(maxWidth, h)
|
||||
Bitmap.createScaledBitmap(bmp, maxWidth, h, true)
|
||||
} else {
|
||||
bmp
|
||||
}
|
||||
@@ -167,7 +116,7 @@ class CanvasController {
|
||||
suspendCancellableCoroutine { cont ->
|
||||
val width = width.coerceAtLeast(1)
|
||||
val height = height.coerceAtLeast(1)
|
||||
val bitmap = createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
|
||||
// WebView isn't supported by PixelCopy.request(...) directly; draw() is the most reliable
|
||||
// cross-version snapshot for this lightweight "canvas" use-case.
|
||||
|
||||
+1
-18
@@ -1,24 +1,6 @@
|
||||
package com.steipete.clawdis.node.protocol
|
||||
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
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 {
|
||||
val trimmed = value.trim().ifEmpty { "-" }
|
||||
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}\" } }));"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,22 +187,15 @@ private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier)
|
||||
"onReceivedHttpError: ${errorResponse.statusCode} ${errorResponse.reasonPhrase} ${request.url}",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView, url: String?) {
|
||||
viewModel.canvas.onPageFinished()
|
||||
}
|
||||
}
|
||||
setBackgroundColor(Color.BLACK)
|
||||
setLayerType(View.LAYER_TYPE_HARDWARE, null)
|
||||
|
||||
val a2uiBridge =
|
||||
addJavascriptInterface(
|
||||
CanvasA2UIActionBridge { payload ->
|
||||
viewModel.handleCanvasA2UIActionFromWebView(payload)
|
||||
}
|
||||
addJavascriptInterface(a2uiBridge, CanvasA2UIActionBridge.interfaceName)
|
||||
addJavascriptInterface(
|
||||
CanvasA2UIActionLegacyBridge(a2uiBridge),
|
||||
CanvasA2UIActionLegacyBridge.interfaceName,
|
||||
},
|
||||
CanvasA2UIActionBridge.interfaceName,
|
||||
)
|
||||
viewModel.canvas.attach(this)
|
||||
}
|
||||
@@ -222,19 +215,3 @@ private class CanvasA2UIActionBridge(private val onMessage: (String) -> Unit) {
|
||||
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 manualHost by viewModel.manualHost.collectAsState()
|
||||
val manualPort by viewModel.manualPort.collectAsState()
|
||||
val canvasDebugStatusEnabled by viewModel.canvasDebugStatusEnabled.collectAsState()
|
||||
val statusText by viewModel.statusText.collectAsState()
|
||||
val serverName by viewModel.serverName.collectAsState()
|
||||
val remoteAddress by viewModel.remoteAddress.collectAsState()
|
||||
@@ -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)) }
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -2,5 +2,5 @@
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
||||
+1
-1
@@ -2,5 +2,5 @@
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<resources>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<style name="Theme.ClawdisNode" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
<item name="android:windowLightStatusBar" tools:targetApi="m">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<full-backup-content>
|
||||
<include domain="file" path="." />
|
||||
</full-backup-content>
|
||||
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<include domain="file" path="." />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<include domain="file" path="." />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config xmlns:tools="http://schemas.android.com/tools">
|
||||
<network-security-config>
|
||||
<!-- This app is primarily used on a trusted tailnet; allow cleartext for IP-based endpoints too. -->
|
||||
<base-config cleartextTrafficPermitted="true" tools:ignore="InsecureBaseConfiguration" />
|
||||
<base-config cleartextTrafficPermitted="true" />
|
||||
<!-- Allow HTTP for tailnet/local dev endpoints (e.g. canvas/background web). -->
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="true">clawdis.internal</domain>
|
||||
|
||||
+1
-15
@@ -1,24 +1,9 @@
|
||||
package com.steipete.clawdis.node.protocol
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
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
|
||||
fun formatAgentMessageMatchesSharedSpec() {
|
||||
val msg =
|
||||
@@ -47,3 +32,4 @@ class ClawdisCanvasA2UIActionTest {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,11 +22,6 @@
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_clawdis-bridge._tcp</string>
|
||||
|
||||
@@ -28,7 +28,6 @@ final class NodeAppModel {
|
||||
private var voiceWakeSyncTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var cameraHUDDismissTask: Task<Void, Never>?
|
||||
let voiceWake = VoiceWakeManager()
|
||||
private var lastAutoA2uiURL: String?
|
||||
|
||||
var bridgeSession: BridgeSession { self.bridge }
|
||||
|
||||
@@ -81,7 +80,7 @@ final class NodeAppModel {
|
||||
}()
|
||||
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 id = (userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return id.isEmpty ? UUID().uuidString : id
|
||||
@@ -103,12 +102,14 @@ final class NodeAppModel {
|
||||
let contextJSON = ClawdisCanvasA2UIAction.compactJSON(userAction["context"])
|
||||
let sessionKey = "main"
|
||||
|
||||
let messageContext = ClawdisCanvasA2UIAction.AgentMessageContext(
|
||||
let message = ClawdisCanvasA2UIAction.formatAgentMessage(
|
||||
actionName: name,
|
||||
session: .init(key: sessionKey, surfaceId: surfaceId),
|
||||
component: .init(id: sourceComponentId, host: host, instanceId: instanceId),
|
||||
sessionKey: sessionKey,
|
||||
surfaceId: surfaceId,
|
||||
sourceComponentId: sourceComponentId,
|
||||
host: host,
|
||||
instanceId: instanceId,
|
||||
contextJSON: contextJSON)
|
||||
let message = ClawdisCanvasA2UIAction.formatAgentMessage(messageContext)
|
||||
|
||||
let ok: Bool
|
||||
var errorText: String?
|
||||
@@ -148,20 +149,6 @@ final class NodeAppModel {
|
||||
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) {
|
||||
switch phase {
|
||||
case .background:
|
||||
@@ -217,7 +204,6 @@ final class NodeAppModel {
|
||||
}
|
||||
}
|
||||
await self.startVoiceWakeSync()
|
||||
await self.showA2UIOnConnectIfNeeded()
|
||||
},
|
||||
onInvoke: { [weak self] req in
|
||||
guard let self else {
|
||||
@@ -230,9 +216,6 @@ final class NodeAppModel {
|
||||
})
|
||||
|
||||
if Task.isCancelled { break }
|
||||
await MainActor.run {
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
}
|
||||
attempt += 1
|
||||
let sleepSeconds = min(6.0, 0.35 * pow(1.7, Double(attempt)))
|
||||
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.bridgeServerName = nil
|
||||
self.bridgeRemoteAddress = nil
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
}
|
||||
let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(attempt)))
|
||||
try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000))
|
||||
@@ -255,7 +237,6 @@ final class NodeAppModel {
|
||||
self.bridgeServerName = nil
|
||||
self.bridgeRemoteAddress = nil
|
||||
self.connectedBridgeID = nil
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -270,7 +251,6 @@ final class NodeAppModel {
|
||||
self.bridgeServerName = nil
|
||||
self.bridgeRemoteAddress = nil
|
||||
self.connectedBridgeID = nil
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
}
|
||||
|
||||
func setGlobalWakeWords(_ words: [String]) async {
|
||||
|
||||
@@ -8,7 +8,6 @@ struct RootCanvas: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false
|
||||
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
|
||||
@AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false
|
||||
@State private var presentedSheet: PresentedSheet?
|
||||
@State private var voiceWakeToastText: String?
|
||||
@State private var toastDismissTask: Task<Void, Never>?
|
||||
@@ -57,11 +56,6 @@ struct RootCanvas: View {
|
||||
.onAppear { self.updateIdleTimer() }
|
||||
.onChange(of: self.preventSleep) { _, _ 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
|
||||
guard let newValue else { return }
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -108,14 +102,6 @@ struct RootCanvas: View {
|
||||
private func updateIdleTimer() {
|
||||
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 {
|
||||
|
||||
@@ -19,18 +19,12 @@ final class ScreenController {
|
||||
/// Callback invoked when the user clicks an A2UI action (e.g. button) inside the canvas web UI.
|
||||
var onA2UIAction: (([String: Any]) -> Void)?
|
||||
|
||||
private var debugStatusEnabled: Bool = false
|
||||
private var debugStatusTitle: String?
|
||||
private var debugStatusSubtitle: String?
|
||||
|
||||
init() {
|
||||
let config = WKWebViewConfiguration()
|
||||
config.websiteDataStore = .nonPersistent()
|
||||
let a2uiActionHandler = CanvasA2UIActionMessageHandler()
|
||||
let userContentController = WKUserContentController()
|
||||
for name in CanvasA2UIActionMessageHandler.handlerNames {
|
||||
userContentController.add(a2uiActionHandler, name: name)
|
||||
}
|
||||
userContentController.add(a2uiActionHandler, name: CanvasA2UIActionMessageHandler.messageName)
|
||||
config.userContentController = userContentController
|
||||
self.navigationDelegate = ScreenNavigationDelegate()
|
||||
self.a2uiActionHandler = a2uiActionHandler
|
||||
@@ -84,39 +78,6 @@ final class ScreenController {
|
||||
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 {
|
||||
let clock = ContinuousClock()
|
||||
let deadline = clock.now.advanced(by: .milliseconds(timeoutMs))
|
||||
@@ -249,17 +210,6 @@ final class ScreenController {
|
||||
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 {
|
||||
guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else {
|
||||
return false
|
||||
@@ -366,11 +316,6 @@ private final class ScreenNavigationDelegate: NSObject, WKNavigationDelegate {
|
||||
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) {
|
||||
self.controller?.errorText = error.localizedDescription
|
||||
}
|
||||
@@ -378,8 +323,6 @@ private final class ScreenNavigationDelegate: NSObject, WKNavigationDelegate {
|
||||
|
||||
private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
|
||||
static let messageName = "clawdisCanvasA2UIAction"
|
||||
static let legacyMessageNames = ["canvas", "a2ui", "userAction", "action"]
|
||||
static let handlerNames = [messageName] + legacyMessageNames
|
||||
|
||||
weak var controller: ScreenController?
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ struct SettingsTab: View {
|
||||
@AppStorage("bridge.manual.host") private var manualBridgeHost: String = ""
|
||||
@AppStorage("bridge.manual.port") private var manualBridgePort: Int = 18790
|
||||
@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 connectingBridgeID: String?
|
||||
@State private var localIPAddress: String?
|
||||
@@ -143,8 +142,6 @@ struct SettingsTab: View {
|
||||
NavigationLink("Discovery Logs") {
|
||||
BridgeDiscoveryDebugLogView()
|
||||
}
|
||||
|
||||
Toggle("Debug Canvas Status", isOn: self.$canvasDebugStatusEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import AVFAudio
|
||||
import Foundation
|
||||
import Observation
|
||||
import Speech
|
||||
import SwabbleKit
|
||||
|
||||
private func makeAudioTapEnqueueCallback(queue: AudioBufferQueue) -> @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void {
|
||||
{ buffer, _ in
|
||||
@@ -290,18 +289,15 @@ final class VoiceWakeManager: NSObject {
|
||||
private nonisolated func makeRecognitionResultHandler() -> @Sendable (SFSpeechRecognitionResult?, Error?) -> Void {
|
||||
{ [weak self] result, error in
|
||||
let transcript = result?.bestTranscription.formattedString
|
||||
let segments = result.flatMap { result in
|
||||
transcript.map { WakeWordSpeechSegments.from(transcription: result.bestTranscription, transcript: $0) }
|
||||
} ?? []
|
||||
let errorText = error?.localizedDescription
|
||||
|
||||
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 {
|
||||
self.statusText = "Recognizer error: \(errorText)"
|
||||
self.isListening = false
|
||||
@@ -317,7 +313,7 @@ final class VoiceWakeManager: NSObject {
|
||||
}
|
||||
|
||||
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 }
|
||||
self.lastDispatched = cmd
|
||||
@@ -338,18 +334,30 @@ final class VoiceWakeManager: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func extractCommand(from transcript: String, segments: [WakeWordSegment]) -> String? {
|
||||
Self.extractCommand(from: transcript, segments: segments, triggers: self.activeTriggerWords)
|
||||
private func extractCommand(from transcript: String) -> String? {
|
||||
Self.extractCommand(from: transcript, triggers: self.activeTriggerWords)
|
||||
}
|
||||
|
||||
nonisolated static func extractCommand(
|
||||
from transcript: String,
|
||||
segments: [WakeWordSegment],
|
||||
triggers: [String],
|
||||
minPostTriggerGap: TimeInterval = 0.45) -> String?
|
||||
{
|
||||
let config = WakeWordGateConfig(triggers: triggers, minPostTriggerGap: minPostTriggerGap)
|
||||
return WakeWordGate.match(transcript: transcript, segments: segments, config: config)?.command
|
||||
nonisolated static func extractCommand(from transcript: String, triggers: [String]) -> String? {
|
||||
var bestRange: Range<String.Index>?
|
||||
for trigger in triggers {
|
||||
let token = trigger.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !token.isEmpty else { continue }
|
||||
guard let range = transcript.range(of: token, options: [.caseInsensitive, .backwards]) else { continue }
|
||||
if let currentBest = bestRange {
|
||||
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 {
|
||||
|
||||
@@ -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 SwabbleKit
|
||||
@testable import Clawdis
|
||||
|
||||
@Suite struct VoiceWakeManagerExtractCommandTests {
|
||||
@Test func extractCommandReturnsNilWhenNoTriggerFound() {
|
||||
let transcript = "hello world"
|
||||
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)
|
||||
#expect(VoiceWakeManager.extractCommand(from: "hello world", triggers: ["clawd"]) == nil)
|
||||
}
|
||||
|
||||
@Test func extractCommandTrimsTokensAndResult() {
|
||||
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 cmd = VoiceWakeManager.extractCommand(
|
||||
from: transcript,
|
||||
segments: segments,
|
||||
triggers: [" clawd "],
|
||||
minPostTriggerGap: 0.3)
|
||||
let cmd = VoiceWakeManager.extractCommand(from: "hey clawd do thing ", triggers: [" clawd "])
|
||||
#expect(cmd == "do thing")
|
||||
}
|
||||
|
||||
@Test func extractCommandReturnsNilWhenGapTooShort() {
|
||||
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 cmd = VoiceWakeManager.extractCommand(
|
||||
from: transcript,
|
||||
segments: segments,
|
||||
triggers: ["clawd"],
|
||||
minPostTriggerGap: 0.3)
|
||||
#expect(cmd == nil)
|
||||
@Test func extractCommandPicksLatestTriggerOccurrence() {
|
||||
let transcript = "clawd first\nthen something\nclaude second"
|
||||
let cmd = VoiceWakeManager.extractCommand(from: transcript, triggers: ["clawd", "claude"])
|
||||
#expect(cmd == "second")
|
||||
}
|
||||
|
||||
@Test func extractCommandIsCaseInsensitive() {
|
||||
let cmd = VoiceWakeManager.extractCommand(from: "HELLO CLAWD run it", triggers: ["clawd"])
|
||||
#expect(cmd == "run it")
|
||||
}
|
||||
|
||||
@Test func extractCommandReturnsNilWhenNothingAfterTrigger() {
|
||||
let transcript = "hey clawd"
|
||||
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)
|
||||
#expect(VoiceWakeManager.extractCommand(from: "hey clawd \n", triggers: ["clawd"]) == nil)
|
||||
}
|
||||
|
||||
@Test func extractCommandIgnoresEmptyTriggers() {
|
||||
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 cmd = VoiceWakeManager.extractCommand(
|
||||
from: transcript,
|
||||
segments: segments,
|
||||
triggers: ["", " ", "clawd"],
|
||||
minPostTriggerGap: 0.3)
|
||||
let cmd = VoiceWakeManager.extractCommand(from: "hey clawd do thing", triggers: ["", " ", "clawd"])
|
||||
#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:
|
||||
ClawdisKit:
|
||||
path: ../shared/ClawdisKit
|
||||
Swabble:
|
||||
path: ../../Swabble
|
||||
|
||||
schemes:
|
||||
Clawdis:
|
||||
@@ -31,35 +29,27 @@ targets:
|
||||
- package: ClawdisKit
|
||||
- package: ClawdisKit
|
||||
product: ClawdisChatUI
|
||||
- package: Swabble
|
||||
product: SwabbleKit
|
||||
- sdk: AppIntents.framework
|
||||
preBuildScripts:
|
||||
- name: SwiftFormat (lint)
|
||||
basedOnDependencyAnalysis: false
|
||||
inputFileLists:
|
||||
- $(SRCROOT)/SwiftSources.input.xcfilelist
|
||||
script: |
|
||||
set -euo pipefail
|
||||
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
|
||||
if ! command -v swiftformat >/dev/null 2>&1; then
|
||||
echo "error: swiftformat not found (brew install swiftformat)" >&2
|
||||
exit 1
|
||||
fi
|
||||
swiftformat --lint --config "$SRCROOT/../../.swiftformat" \
|
||||
--filelist "$SRCROOT/SwiftSources.input.xcfilelist"
|
||||
"$SRCROOT/Sources" \
|
||||
"$SRCROOT/../shared/ClawdisKit/Sources"
|
||||
- name: SwiftLint
|
||||
basedOnDependencyAnalysis: false
|
||||
inputFileLists:
|
||||
- $(SRCROOT)/SwiftSources.input.xcfilelist
|
||||
script: |
|
||||
set -euo pipefail
|
||||
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
|
||||
if ! command -v swiftlint >/dev/null 2>&1; then
|
||||
echo "error: swiftlint not found (brew install swiftlint)" >&2
|
||||
exit 1
|
||||
fi
|
||||
swiftlint lint --config "$SRCROOT/.swiftlint.yml" --use-script-input-file-lists
|
||||
swiftlint lint --config "$SRCROOT/.swiftlint.yml"
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.steipete.clawdis.ios
|
||||
@@ -75,8 +65,6 @@ targets:
|
||||
UIBackgroundModes:
|
||||
- audio
|
||||
NSLocalNetworkUsageDescription: Clawdis discovers and connects to your Clawdis bridge on the local network.
|
||||
NSAppTransportSecurity:
|
||||
NSAllowsArbitraryLoadsInWebContent: true
|
||||
NSBonjourServices:
|
||||
- _clawdis-bridge._tcp
|
||||
NSCameraUsageDescription: Clawdis can capture photos or short video clips when requested via the bridge.
|
||||
@@ -90,8 +78,6 @@ targets:
|
||||
- path: Tests
|
||||
dependencies:
|
||||
- target: Clawdis
|
||||
- package: Swabble
|
||||
product: SwabbleKit
|
||||
settings:
|
||||
base:
|
||||
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/sparkle-project/Sparkle", from: "2.8.1"),
|
||||
.package(path: "../shared/ClawdisKit"),
|
||||
.package(path: "../../Swabble"),
|
||||
.package(path: "../../Peekaboo/Core/PeekabooCore"),
|
||||
.package(path: "../../Peekaboo/Core/PeekabooAutomationKit"),
|
||||
],
|
||||
@@ -42,7 +41,6 @@ let package = Package(
|
||||
"ClawdisProtocol",
|
||||
.product(name: "ClawdisKit", package: "ClawdisKit"),
|
||||
.product(name: "ClawdisChatUI", package: "ClawdisKit"),
|
||||
.product(name: "SwabbleKit", package: "swabble"),
|
||||
.product(name: "MenuBarExtraAccess", package: "MenuBarExtraAccess"),
|
||||
.product(name: "Subprocess", package: "swift-subprocess"),
|
||||
.product(name: "Sparkle", package: "Sparkle"),
|
||||
@@ -58,12 +56,7 @@ let package = Package(
|
||||
]),
|
||||
.testTarget(
|
||||
name: "ClawdisIPCTests",
|
||||
dependencies: [
|
||||
"ClawdisIPC",
|
||||
"Clawdis",
|
||||
"ClawdisProtocol",
|
||||
.product(name: "SwabbleKit", package: "swabble"),
|
||||
],
|
||||
dependencies: ["ClawdisIPC", "Clawdis", "ClawdisProtocol"],
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
.enableExperimentalFeature("SwiftTesting"),
|
||||
|
||||
@@ -92,12 +92,10 @@ struct AboutSettings: View {
|
||||
guard let updater, !self.didLoadUpdaterState else { return }
|
||||
// Keep Sparkle’s auto-check setting in sync with the persisted toggle.
|
||||
updater.automaticallyChecksForUpdates = self.autoCheckEnabled
|
||||
updater.automaticallyDownloadsUpdates = self.autoCheckEnabled
|
||||
self.didLoadUpdaterState = true
|
||||
}
|
||||
.onChange(of: self.autoCheckEnabled) { _, newValue in
|
||||
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 bootstrapFilename = "BOOTSTRAP.md"
|
||||
private static let templateDirname = "templates"
|
||||
private static let ignoredEntries: Set<String> = [".DS_Store", ".git", ".gitignore"]
|
||||
private static let templateEntries: Set<String> = [
|
||||
AgentWorkspace.agentsFilename,
|
||||
AgentWorkspace.soulFilename,
|
||||
AgentWorkspace.identityFilename,
|
||||
AgentWorkspace.userFilename,
|
||||
AgentWorkspace.bootstrapFilename,
|
||||
]
|
||||
enum BootstrapSafety: Equatable {
|
||||
case safe
|
||||
case unsafe(reason: String)
|
||||
}
|
||||
static let identityStartMarker = "<!-- clawdis:identity:start -->"
|
||||
static let identityEndMarker = "<!-- clawdis:identity:end -->"
|
||||
|
||||
static func displayPath(for url: URL) -> String {
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
||||
@@ -43,53 +33,7 @@ enum AgentWorkspace {
|
||||
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 {
|
||||
let shouldSeedBootstrap = self.isWorkspaceEmpty(workspaceURL: workspaceURL)
|
||||
try FileManager.default.createDirectory(at: workspaceURL, withIntermediateDirectories: true)
|
||||
let agentsURL = self.agentsURL(workspaceURL: workspaceURL)
|
||||
if !FileManager.default.fileExists(atPath: agentsURL.path) {
|
||||
@@ -112,44 +56,36 @@ enum AgentWorkspace {
|
||||
self.logger.info("Created USER.md at \(userURL.path, privacy: .public)")
|
||||
}
|
||||
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)
|
||||
self.logger.info("Created BOOTSTRAP.md at \(bootstrapURL.path, privacy: .public)")
|
||||
}
|
||||
return agentsURL
|
||||
}
|
||||
|
||||
static func needsBootstrap(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 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 upsertIdentity(workspaceURL: URL, identity: AgentIdentity) throws {
|
||||
let agentsURL = try self.bootstrap(workspaceURL: workspaceURL)
|
||||
var content = (try? String(contentsOf: agentsURL, encoding: .utf8)) ?? ""
|
||||
let block = self.identityBlock(identity: identity)
|
||||
|
||||
static func hasIdentity(workspaceURL: URL) -> Bool {
|
||||
let identityURL = workspaceURL.appendingPathComponent(self.identityFilename)
|
||||
guard let contents = try? String(contentsOf: identityURL, encoding: .utf8) else { return false }
|
||||
return self.identityLinesHaveValues(contents)
|
||||
}
|
||||
|
||||
private static func identityLinesHaveValues(_ content: String) -> Bool {
|
||||
for line in content.split(separator: "\n") {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.hasPrefix("-"), let colon = trimmed.firstIndex(of: ":") else { continue }
|
||||
let value = trimmed[trimmed.index(after: colon)...].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !value.isEmpty {
|
||||
return true
|
||||
}
|
||||
if let start = content.range(of: self.identityStartMarker),
|
||||
let end = content.range(of: self.identityEndMarker),
|
||||
start.lowerBound < end.upperBound
|
||||
{
|
||||
content.replaceSubrange(
|
||||
start.lowerBound..<end.upperBound,
|
||||
with: block.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
} else if let insert = self.identityInsertRange(in: content) {
|
||||
content.insert(contentsOf: "\n\n## Identity\n\(block)\n", at: insert.upperBound)
|
||||
} else {
|
||||
content = [content.trimmingCharacters(in: .whitespacesAndNewlines), "## Identity\n\(block)"]
|
||||
.filter { !$0.isEmpty }
|
||||
.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 {
|
||||
@@ -336,5 +272,25 @@ enum AgentWorkspace {
|
||||
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 {
|
||||
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 code: String = ""
|
||||
@State private var busy = false
|
||||
@@ -20,7 +20,7 @@ struct AnthropicAuthControls: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
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)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
@@ -35,10 +35,10 @@ struct AnthropicAuthControls: View {
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button("Reveal") {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([ClawdisOAuthStore.oauthURL()])
|
||||
NSWorkspace.shared.activateFileViewerSelecting([PiOAuthStore.oauthURL()])
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(!FileManager.default.fileExists(atPath: ClawdisOAuthStore.oauthURL().path))
|
||||
.disabled(!FileManager.default.fileExists(atPath: PiOAuthStore.oauthURL().path))
|
||||
|
||||
Button("Refresh") {
|
||||
self.refresh()
|
||||
@@ -46,7 +46,7 @@ struct AnthropicAuthControls: View {
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
Text(ClawdisOAuthStore.oauthURL().path)
|
||||
Text(PiOAuthStore.oauthURL().path)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
@@ -123,11 +123,7 @@ struct AnthropicAuthControls: View {
|
||||
}
|
||||
|
||||
private func refresh() {
|
||||
let imported = ClawdisOAuthStore.importLegacyAnthropicOAuthIfNeeded()
|
||||
self.oauthStatus = ClawdisOAuthStore.anthropicOAuthStatus()
|
||||
if imported != nil {
|
||||
self.statusText = "Imported existing OAuth credentials."
|
||||
}
|
||||
self.oauthStatus = PiOAuthStore.anthropicOAuthStatus()
|
||||
}
|
||||
|
||||
private func startOAuth() {
|
||||
@@ -165,11 +161,11 @@ struct AnthropicAuthControls: View {
|
||||
code: parsed.code,
|
||||
state: parsed.state,
|
||||
verifier: pkce.verifier)
|
||||
try ClawdisOAuthStore.saveAnthropicOAuth(creds)
|
||||
try PiOAuthStore.saveAnthropicOAuth(creds)
|
||||
self.refresh()
|
||||
self.pkce = nil
|
||||
self.code = ""
|
||||
self.statusText = "Connected. Clawdis can now use Claude via OAuth."
|
||||
self.statusText = "Connected. Pi can now use Claude via OAuth."
|
||||
} catch {
|
||||
self.statusText = "OAuth failed: \(error.localizedDescription)"
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ enum AnthropicAuthMode: Equatable {
|
||||
|
||||
var shortLabel: String {
|
||||
switch self {
|
||||
case .oauthFile: "OAuth (Clawdis token file)"
|
||||
case .oauthFile: "OAuth (Pi token file)"
|
||||
case .oauthEnv: "OAuth (env var)"
|
||||
case .apiKeyEnv: "API key (env var)"
|
||||
case .missing: "Missing credentials"
|
||||
@@ -36,8 +36,7 @@ enum AnthropicAuthMode: Equatable {
|
||||
enum AnthropicAuthResolver {
|
||||
static func resolve(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment,
|
||||
oauthStatus: ClawdisOAuthStore.AnthropicOAuthStatus = ClawdisOAuthStore.anthropicOAuthStatus()
|
||||
) -> AnthropicAuthMode
|
||||
oauthStatus: PiOAuthStore.AnthropicOAuthStatus = PiOAuthStore.anthropicOAuthStatus()) -> AnthropicAuthMode
|
||||
{
|
||||
if oauthStatus.isConnected { return .oauthFile }
|
||||
|
||||
@@ -93,7 +92,7 @@ enum AnthropicOAuth {
|
||||
URLQueryItem(name: "scope", value: self.scopes),
|
||||
URLQueryItem(name: "code_challenge", value: pkce.challenge),
|
||||
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),
|
||||
]
|
||||
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)
|
||||
+ Int64(expiresIn * 1000)
|
||||
- Int64(5 * 60 * 1000)
|
||||
@@ -151,11 +150,10 @@ enum AnthropicOAuth {
|
||||
}
|
||||
}
|
||||
|
||||
enum ClawdisOAuthStore {
|
||||
enum PiOAuthStore {
|
||||
static let oauthFilename = "oauth.json"
|
||||
private static let providerKey = "anthropic"
|
||||
private static let clawdisOAuthDirEnv = "CLAWDIS_OAUTH_DIR"
|
||||
private static let legacyPiDirEnv = "PI_CODING_AGENT_DIR"
|
||||
private static let piAgentDirEnv = "PI_CODING_AGENT_DIR"
|
||||
|
||||
enum AnthropicOAuthStatus: Equatable {
|
||||
case missingFile
|
||||
@@ -172,18 +170,18 @@ enum ClawdisOAuthStore {
|
||||
|
||||
var shortDescription: String {
|
||||
switch self {
|
||||
case .missingFile: "Clawdis OAuth token file not found"
|
||||
case .unreadableFile: "Clawdis OAuth token file not readable"
|
||||
case .invalidJSON: "Clawdis OAuth token file invalid"
|
||||
case .missingProviderEntry: "No Anthropic entry in Clawdis OAuth token file"
|
||||
case .missingFile: "Pi OAuth token file not found"
|
||||
case .unreadableFile: "Pi OAuth token file not readable"
|
||||
case .invalidJSON: "Pi OAuth token file invalid"
|
||||
case .missingProviderEntry: "No Anthropic entry in Pi OAuth token file"
|
||||
case .missingTokens: "Anthropic entry missing tokens"
|
||||
case .connected: "Clawdis OAuth credentials found"
|
||||
case .connected: "Pi OAuth credentials found"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func oauthDir() -> URL {
|
||||
if let override = ProcessInfo.processInfo.environment[self.clawdisOAuthDirEnv]?
|
||||
if let override = ProcessInfo.processInfo.environment[self.piAgentDirEnv]?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!override.isEmpty
|
||||
{
|
||||
@@ -192,58 +190,14 @@ enum ClawdisOAuthStore {
|
||||
}
|
||||
|
||||
return FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".clawdis", isDirectory: true)
|
||||
.appendingPathComponent("credentials", isDirectory: true)
|
||||
.appendingPathComponent(".pi", isDirectory: true)
|
||||
.appendingPathComponent("agent", isDirectory: true)
|
||||
}
|
||||
|
||||
static func oauthURL() -> URL {
|
||||
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 {
|
||||
self.anthropicOAuthStatus(at: self.oauthURL())
|
||||
}
|
||||
@@ -286,15 +240,17 @@ enum ClawdisOAuthStore {
|
||||
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 {
|
||||
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
|
||||
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 {
|
||||
didSet {
|
||||
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 panelSessionKey: String?
|
||||
private var lastAutoA2UIUrl: String?
|
||||
private var gatewayWatchTask: Task<Void, Never>?
|
||||
|
||||
private init() {
|
||||
self.startGatewayObserver()
|
||||
}
|
||||
|
||||
var onPanelVisibilityChanged: ((Bool) -> Void)?
|
||||
|
||||
@@ -56,7 +50,6 @@ final class CanvasManager {
|
||||
}
|
||||
controller.presentAnchoredPanel(anchorProvider: anchorProvider)
|
||||
controller.applyPreferredPlacement(placement)
|
||||
self.refreshDebugStatus()
|
||||
|
||||
// Existing session: only navigate when an explicit target was provided.
|
||||
if let normalizedTarget {
|
||||
@@ -67,7 +60,6 @@ final class CanvasManager {
|
||||
effectiveTarget: normalizedTarget)
|
||||
}
|
||||
|
||||
self.maybeAutoNavigateToA2UIAsync(controller: controller)
|
||||
return CanvasShowResult(
|
||||
directory: controller.directoryPath,
|
||||
target: target,
|
||||
@@ -101,10 +93,6 @@ final class CanvasManager {
|
||||
Self.logger.debug("showDetailed showCanvas effectiveTarget=\(effectiveTarget, privacy: .public)")
|
||||
controller.showCanvas(path: effectiveTarget)
|
||||
Self.logger.debug("showDetailed showCanvas done")
|
||||
if normalizedTarget == nil {
|
||||
self.maybeAutoNavigateToA2UIAsync(controller: controller)
|
||||
}
|
||||
self.refreshDebugStatus()
|
||||
|
||||
return self.makeShowResult(
|
||||
directory: controller.directoryPath,
|
||||
@@ -136,63 +124,6 @@ final class CanvasManager {
|
||||
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
|
||||
|
||||
private static func mouseAnchorProvider() -> NSRect? {
|
||||
|
||||
@@ -43,10 +43,6 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
private let container: HoverChromeContainerView
|
||||
let presentation: CanvasPresentation
|
||||
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)?
|
||||
|
||||
@@ -241,7 +237,6 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
|
||||
func load(target: String) {
|
||||
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.currentTarget = trimmed
|
||||
|
||||
if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased() {
|
||||
if scheme == "https" || scheme == "http" {
|
||||
@@ -281,35 +276,6 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
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) {
|
||||
let fileURL = url.isFileURL ? url : URL(fileURLWithPath: url.path)
|
||||
let accessDir = fileURL.deletingLastPathComponent()
|
||||
@@ -374,18 +340,6 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
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
|
||||
|
||||
private static func makeWindow(for presentation: CanvasPresentation, contentView: NSView) -> NSWindow {
|
||||
@@ -583,10 +537,6 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
decisionHandler(.cancel)
|
||||
}
|
||||
|
||||
func webView(_: WKWebView, didFinish _: WKNavigation?) {
|
||||
self.applyDebugStatusIfNeeded()
|
||||
}
|
||||
|
||||
// MARK: - NSWindowDelegate
|
||||
|
||||
func windowWillClose(_: Notification) {
|
||||
@@ -621,11 +571,6 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
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 {
|
||||
"clawdis.canvas.frame.\(self.sanitizeSessionKey(sessionKey))"
|
||||
}
|
||||
@@ -694,7 +639,7 @@ private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHan
|
||||
}()
|
||||
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 =
|
||||
(userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
?? UUID().uuidString
|
||||
@@ -709,12 +654,14 @@ private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHan
|
||||
let contextJSON = ClawdisCanvasA2UIAction.compactJSON(userAction["context"])
|
||||
|
||||
// Token-efficient and unambiguous. The agent should treat this as a UI event and (by default) update Canvas.
|
||||
let messageContext = ClawdisCanvasA2UIAction.AgentMessageContext(
|
||||
let text = ClawdisCanvasA2UIAction.formatAgentMessage(
|
||||
actionName: name,
|
||||
session: .init(key: self.sessionKey, surfaceId: surfaceId),
|
||||
component: .init(id: sourceComponentId, host: InstanceIdentity.displayName, instanceId: instanceId),
|
||||
sessionKey: self.sessionKey,
|
||||
surfaceId: surfaceId,
|
||||
sourceComponentId: sourceComponentId,
|
||||
host: InstanceIdentity.displayName,
|
||||
instanceId: instanceId,
|
||||
contextJSON: contextJSON)
|
||||
let text = ClawdisCanvasA2UIAction.formatAgentMessage(messageContext)
|
||||
|
||||
Task { [weak webView] in
|
||||
if AppStateStore.shared.connectionMode == .local {
|
||||
|
||||
@@ -30,23 +30,6 @@ enum ClawdisConfigFile {
|
||||
} 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 {
|
||||
let root = self.loadDict()
|
||||
let browser = root["browser"] as? [String: Any]
|
||||
@@ -79,4 +62,28 @@ enum ClawdisConfigFile {
|
||||
root["inbound"] = inbound
|
||||
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 {
|
||||
"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)."
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
var isPaused: Bool
|
||||
var isSleeping: Bool
|
||||
var isWorking: Bool
|
||||
var earBoostActive: Bool
|
||||
var blinkTick: Int
|
||||
@@ -26,10 +25,6 @@ struct CritterStatusLabel: View {
|
||||
self.iconState.isWorking || self.isWorking
|
||||
}
|
||||
|
||||
private var effectiveAnimationsEnabled: Bool {
|
||||
self.animationsEnabled && !self.isSleeping
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
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
|
||||
// triggered by its callbacks. Drive periodic updates via a Swift-concurrency task instead.
|
||||
.task(id: self.tickTaskID) {
|
||||
guard self.effectiveAnimationsEnabled, !self.earBoostActive else {
|
||||
guard self.animationsEnabled, !self.earBoostActive else {
|
||||
await MainActor.run { self.resetMotion() }
|
||||
return
|
||||
}
|
||||
@@ -52,27 +47,24 @@ struct CritterStatusLabel: View {
|
||||
}
|
||||
.onChange(of: self.isPaused) { _, _ in self.resetMotion() }
|
||||
.onChange(of: self.blinkTick) { _, _ in
|
||||
guard self.effectiveAnimationsEnabled, !self.earBoostActive else { return }
|
||||
guard !self.earBoostActive else { return }
|
||||
self.blink()
|
||||
}
|
||||
.onChange(of: self.sendCelebrationTick) { _, _ in
|
||||
guard self.effectiveAnimationsEnabled, !self.earBoostActive else { return }
|
||||
guard !self.earBoostActive else { return }
|
||||
self.wiggleLegs()
|
||||
}
|
||||
.onChange(of: self.animationsEnabled) { _, enabled in
|
||||
if enabled, !self.isSleeping {
|
||||
if enabled {
|
||||
self.scheduleRandomTimers(from: Date())
|
||||
} else {
|
||||
self.resetMotion()
|
||||
}
|
||||
}
|
||||
.onChange(of: self.isSleeping) { _, _ in
|
||||
self.resetMotion()
|
||||
}
|
||||
.onChange(of: self.earBoostActive) { _, active in
|
||||
if active {
|
||||
self.resetMotion()
|
||||
} else if self.effectiveAnimationsEnabled {
|
||||
} else if self.animationsEnabled {
|
||||
self.scheduleRandomTimers(from: Date())
|
||||
}
|
||||
}
|
||||
@@ -89,11 +81,11 @@ struct CritterStatusLabel: View {
|
||||
|
||||
private var tickTaskID: Int {
|
||||
// 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) {
|
||||
guard self.effectiveAnimationsEnabled, !self.earBoostActive else {
|
||||
guard self.animationsEnabled, !self.earBoostActive else {
|
||||
self.resetMotion()
|
||||
return
|
||||
}
|
||||
@@ -136,10 +128,6 @@ struct CritterStatusLabel: View {
|
||||
return Image(nsImage: CritterIconRenderer.makeIcon(blink: 0, badge: nil))
|
||||
}
|
||||
|
||||
if self.isSleeping {
|
||||
return Image(nsImage: CritterIconRenderer.makeIcon(blink: 1, eyesClosedLines: true, badge: nil))
|
||||
}
|
||||
|
||||
return Image(nsImage: CritterIconRenderer.makeIcon(
|
||||
blink: self.blinkAmount,
|
||||
legWiggle: max(self.legWiggle, self.isWorkingNow ? 0.6 : 0),
|
||||
@@ -228,12 +216,11 @@ struct CritterStatusLabel: View {
|
||||
}
|
||||
|
||||
private var gatewayNeedsAttention: Bool {
|
||||
if self.isSleeping { return false }
|
||||
switch self.gatewayStatus {
|
||||
case .failed, .stopped:
|
||||
return !self.isPaused
|
||||
!self.isPaused
|
||||
case .starting, .running, .attachedExisting:
|
||||
return false
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,7 +255,6 @@ enum CritterIconRenderer {
|
||||
earWiggle: CGFloat = 0,
|
||||
earScale: CGFloat = 1,
|
||||
earHoles: Bool = false,
|
||||
eyesClosedLines: Bool = false,
|
||||
badge: Badge? = nil) -> NSImage
|
||||
{
|
||||
// Force a 36×36px backing store (2× for the 18pt logical canvas) so the menu bar icon stays crisp on Retina.
|
||||
@@ -334,7 +320,9 @@ enum CritterIconRenderer {
|
||||
let legLift = snapY(legH * 0.35 * legWiggle)
|
||||
let legYBase = snapY(bodyY - legH + h * 0.05)
|
||||
|
||||
let eyeOpen = max(0.05, 1 - blink)
|
||||
let eyeW = snapX(bodyW * 0.2)
|
||||
let eyeH = snapY(bodyH * 0.26 * eyeOpen)
|
||||
let eyeY = snapY(bodyY + bodyH * 0.56)
|
||||
let eyeOffset = snapX(bodyW * 0.24)
|
||||
|
||||
@@ -404,50 +392,20 @@ enum CritterIconRenderer {
|
||||
transform: nil))
|
||||
}
|
||||
|
||||
if eyesClosedLines {
|
||||
let lineW = snapX(eyeW * 0.95)
|
||||
let lineH = snapY(max(stepY * 2, bodyH * 0.06))
|
||||
let corner = snapX(lineH * 0.6)
|
||||
let leftRect = CGRect(
|
||||
x: snapX(leftCenter.x - lineW / 2),
|
||||
y: snapY(leftCenter.y - lineH / 2),
|
||||
width: lineW,
|
||||
height: lineH)
|
||||
let rightRect = CGRect(
|
||||
x: snapX(rightCenter.x - lineW / 2),
|
||||
y: snapY(rightCenter.y - lineH / 2),
|
||||
width: lineW,
|
||||
height: lineH)
|
||||
context.cgContext.addPath(CGPath(
|
||||
roundedRect: leftRect,
|
||||
cornerWidth: corner,
|
||||
cornerHeight: corner,
|
||||
transform: nil))
|
||||
context.cgContext.addPath(CGPath(
|
||||
roundedRect: rightRect,
|
||||
cornerWidth: corner,
|
||||
cornerHeight: corner,
|
||||
transform: nil))
|
||||
} else {
|
||||
let eyeOpen = max(0.05, 1 - blink)
|
||||
let eyeH = snapY(bodyH * 0.26 * eyeOpen)
|
||||
let left = CGMutablePath()
|
||||
left.move(to: CGPoint(x: snapX(leftCenter.x - eyeW / 2), y: snapY(leftCenter.y - eyeH)))
|
||||
left.addLine(to: CGPoint(x: snapX(leftCenter.x + eyeW / 2), y: snapY(leftCenter.y)))
|
||||
left.addLine(to: CGPoint(x: snapX(leftCenter.x - eyeW / 2), y: snapY(leftCenter.y + eyeH)))
|
||||
left.closeSubpath()
|
||||
|
||||
let left = CGMutablePath()
|
||||
left.move(to: CGPoint(x: snapX(leftCenter.x - eyeW / 2), y: snapY(leftCenter.y - eyeH)))
|
||||
left.addLine(to: CGPoint(x: snapX(leftCenter.x + eyeW / 2), y: snapY(leftCenter.y)))
|
||||
left.addLine(to: CGPoint(x: snapX(leftCenter.x - eyeW / 2), y: snapY(leftCenter.y + eyeH)))
|
||||
left.closeSubpath()
|
||||
|
||||
let right = CGMutablePath()
|
||||
right.move(to: CGPoint(x: snapX(rightCenter.x + eyeW / 2), y: snapY(rightCenter.y - eyeH)))
|
||||
right.addLine(to: CGPoint(x: snapX(rightCenter.x - eyeW / 2), y: snapY(rightCenter.y)))
|
||||
right.addLine(to: CGPoint(x: snapX(rightCenter.x + eyeW / 2), y: snapY(rightCenter.y + eyeH)))
|
||||
right.closeSubpath()
|
||||
|
||||
context.cgContext.addPath(left)
|
||||
context.cgContext.addPath(right)
|
||||
}
|
||||
let right = CGMutablePath()
|
||||
right.move(to: CGPoint(x: snapX(rightCenter.x + eyeW / 2), y: snapY(rightCenter.y - eyeH)))
|
||||
right.addLine(to: CGPoint(x: snapX(rightCenter.x - eyeW / 2), y: snapY(rightCenter.y)))
|
||||
right.addLine(to: CGPoint(x: snapX(rightCenter.x + eyeW / 2), y: snapY(rightCenter.y + eyeH)))
|
||||
right.closeSubpath()
|
||||
|
||||
context.cgContext.addPath(left)
|
||||
context.cgContext.addPath(right)
|
||||
context.cgContext.fillPath()
|
||||
context.cgContext.restoreGState()
|
||||
|
||||
|
||||
@@ -109,61 +109,24 @@ actor GatewayConnection {
|
||||
do {
|
||||
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
|
||||
} 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.
|
||||
// Canvas interactions should "just work" even if the local gateway isn't running yet.
|
||||
let mode = await MainActor.run { AppStateStore.shared.connectionMode }
|
||||
switch mode {
|
||||
case .local:
|
||||
await MainActor.run { GatewayProcessManager.shared.setActive(true) }
|
||||
let isLocal = await MainActor.run { AppStateStore.shared.connectionMode == .local }
|
||||
guard isLocal else { throw error }
|
||||
|
||||
var lastError: Error = error
|
||||
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
|
||||
}
|
||||
}
|
||||
await MainActor.run { GatewayProcessManager.shared.setActive(true) }
|
||||
|
||||
throw lastError
|
||||
case .remote:
|
||||
let nsError = error as NSError
|
||||
guard nsError.domain == URLError.errorDomain else { throw error }
|
||||
|
||||
var lastError: Error = error
|
||||
await RemoteTunnelManager.shared.stopAll()
|
||||
var lastError: Error = error
|
||||
for delayMs in [150, 400, 900] {
|
||||
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
|
||||
do {
|
||||
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
|
||||
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
|
||||
} catch {
|
||||
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
|
||||
}
|
||||
if let service = normalizeServiceToken(serviceName) {
|
||||
for token in local.hostTokens where service.contains(token) {
|
||||
return true
|
||||
for token in local.hostTokens {
|
||||
if service.contains(token) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
@@ -454,9 +456,8 @@ final class GatewayTXTResolver: NSObject, NetServiceDelegate {
|
||||
func netServiceDidResolveAddress(_ sender: NetService) {
|
||||
let txt = Self.decodeTXT(sender.txtRecordData())
|
||||
if !txt.isEmpty {
|
||||
let payload = self.formatTXT(txt)
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -36,9 +36,7 @@ enum GatewayLaunchAgentManager {
|
||||
? "Failed to bootstrap gateway launchd job"
|
||||
: bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
// Note: removed redundant `kickstart -k` that caused race condition.
|
||||
// bootstrap already starts the job; kickstart -k would kill it immediately
|
||||
// and with KeepAlive=true, cause a restart loop with port conflicts.
|
||||
_ = await self.runLaunchctl(["kickstart", "-k", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -30,10 +30,7 @@ final class GatewayProcessManager {
|
||||
}
|
||||
}
|
||||
|
||||
private(set) var status: Status = .stopped {
|
||||
didSet { CanvasManager.shared.refreshDebugStatus() }
|
||||
}
|
||||
|
||||
private(set) var status: Status = .stopped
|
||||
private(set) var log: String = ""
|
||||
private(set) var environmentStatus: GatewayEnvironmentStatus = .checking
|
||||
private(set) var existingGatewayDetails: String?
|
||||
|
||||
@@ -126,17 +126,12 @@ struct GeneralSettings: View {
|
||||
|
||||
if self.state.connectionMode == .local {
|
||||
self.gatewayInstallerCard
|
||||
TailscaleIntegrationSection(
|
||||
connectionMode: self.state.connectionMode,
|
||||
isPaused: self.state.isPaused)
|
||||
self.healthRow
|
||||
}
|
||||
|
||||
if self.state.connectionMode == .remote {
|
||||
self.remoteCard
|
||||
}
|
||||
|
||||
self.cliInstaller
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,8 +583,7 @@ extension GeneralSettings {
|
||||
trimmed.localizedCaseInsensitiveContains("host key verification failed")
|
||||
{
|
||||
let host = CommandResolver.parseSSHTarget(target)?.host ?? target
|
||||
return "SSH check failed: Host key verification failed. Remove the old key with " +
|
||||
"`ssh-keygen -R \(host)` and try again."
|
||||
return "SSH check failed: Host key verification failed. Remove the old key with `ssh-keygen -R \(host)` and try again."
|
||||
}
|
||||
if let trimmed, !trimmed.isEmpty {
|
||||
if let message = response.message, message.hasPrefix("exit ") {
|
||||
@@ -648,7 +642,6 @@ struct GeneralSettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
GeneralSettings(state: .preview)
|
||||
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
||||
.environment(TailscaleService.shared)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -2,7 +2,6 @@ import AppKit
|
||||
import Darwin
|
||||
import Foundation
|
||||
import MenuBarExtraAccess
|
||||
import Observation
|
||||
import OSLog
|
||||
import Security
|
||||
import SwiftUI
|
||||
@@ -12,12 +11,11 @@ struct ClawdisApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
|
||||
@State private var state: AppState
|
||||
private let gatewayManager = GatewayProcessManager.shared
|
||||
private let controlChannel = ControlChannel.shared
|
||||
private let activityStore = WorkActivityStore.shared
|
||||
@State private var statusItem: NSStatusItem?
|
||||
@State private var isMenuPresented = false
|
||||
@State private var isPanelVisible = false
|
||||
@State private var tailscaleService = TailscaleService.shared
|
||||
@State private var menuInjector = MenuContextCardInjector.shared
|
||||
|
||||
@MainActor
|
||||
private func updateStatusHighlight() {
|
||||
@@ -37,37 +35,30 @@ struct ClawdisApp: App {
|
||||
MenuBarExtra { MenuContent(state: self.state, updater: self.delegate.updaterController) } label: {
|
||||
CritterStatusLabel(
|
||||
isPaused: self.state.isPaused,
|
||||
isSleeping: self.isGatewaySleeping,
|
||||
isWorking: self.state.isWorking,
|
||||
earBoostActive: self.state.earBoostActive,
|
||||
blinkTick: self.state.blinkTick,
|
||||
sendCelebrationTick: self.state.sendCelebrationTick,
|
||||
gatewayStatus: self.gatewayManager.status,
|
||||
animationsEnabled: self.state.iconAnimationsEnabled && !self.isGatewaySleeping,
|
||||
animationsEnabled: self.state.iconAnimationsEnabled,
|
||||
iconState: self.effectiveIconState)
|
||||
}
|
||||
.menuBarExtraStyle(.menu)
|
||||
.menuBarExtraAccess(isPresented: self.$isMenuPresented) { item in
|
||||
self.statusItem = item
|
||||
MenuSessionsInjector.shared.install(into: item)
|
||||
self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping)
|
||||
self.applyStatusItemAppearance(paused: self.state.isPaused)
|
||||
self.installStatusItemMouseHandler(for: item)
|
||||
self.menuInjector.install(into: item)
|
||||
self.updateHoverHUDSuppression()
|
||||
}
|
||||
.onChange(of: self.state.isPaused) { _, paused in
|
||||
self.applyStatusItemAppearance(paused: paused, sleeping: self.isGatewaySleeping)
|
||||
self.applyStatusItemAppearance(paused: paused)
|
||||
if self.state.connectionMode == .local {
|
||||
self.gatewayManager.setActive(!paused)
|
||||
} else {
|
||||
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
|
||||
Task { await ConnectionModeCoordinator.shared.apply(mode: mode, paused: self.state.isPaused) }
|
||||
}
|
||||
@@ -75,7 +66,6 @@ struct ClawdisApp: App {
|
||||
Settings {
|
||||
SettingsRootView(state: self.state, updater: self.delegate.updaterController)
|
||||
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight, alignment: .topLeading)
|
||||
.environment(self.tailscaleService)
|
||||
}
|
||||
.defaultSize(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
||||
.windowResizability(.contentSize)
|
||||
@@ -85,27 +75,8 @@ struct ClawdisApp: App {
|
||||
}
|
||||
}
|
||||
|
||||
private func applyStatusItemAppearance(paused: Bool, sleeping: Bool) {
|
||||
self.statusItem?.button?.appearsDisabled = paused || sleeping
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
private func applyStatusItemAppearance(paused: Bool) {
|
||||
self.statusItem?.button?.appearsDisabled = paused
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -307,94 +278,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
@MainActor
|
||||
protocol UpdaterProviding: AnyObject {
|
||||
var automaticallyChecksForUpdates: Bool { get set }
|
||||
var automaticallyDownloadsUpdates: Bool { get set }
|
||||
var isAvailable: Bool { get }
|
||||
var updateStatus: UpdateStatus { get }
|
||||
func checkForUpdates(_ sender: Any?)
|
||||
}
|
||||
|
||||
// No-op updater used for debug/dev runs to suppress Sparkle dialogs.
|
||||
final class DisabledUpdaterController: UpdaterProviding {
|
||||
var automaticallyChecksForUpdates: Bool = false
|
||||
var automaticallyDownloadsUpdates: Bool = false
|
||||
let isAvailable: Bool = false
|
||||
let updateStatus = UpdateStatus()
|
||||
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)
|
||||
import Sparkle
|
||||
|
||||
@MainActor
|
||||
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()
|
||||
}
|
||||
|
||||
extension SPUStandardUpdaterController: UpdaterProviding {
|
||||
var automaticallyChecksForUpdates: Bool {
|
||||
get { self.controller.updater.automaticallyChecksForUpdates }
|
||||
set { self.controller.updater.automaticallyChecksForUpdates = newValue }
|
||||
}
|
||||
|
||||
var automaticallyDownloadsUpdates: Bool {
|
||||
get { self.controller.updater.automaticallyDownloadsUpdates }
|
||||
set { self.controller.updater.automaticallyDownloadsUpdates = newValue }
|
||||
get { self.updater.automaticallyChecksForUpdates }
|
||||
set { self.updater.automaticallyChecksForUpdates = newValue }
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -427,7 +331,14 @@ private func makeUpdaterController() -> UpdaterProviding {
|
||||
let autoUpdateKey = "autoUpdateEnabled"
|
||||
// Default to true; honor the user's last choice otherwise.
|
||||
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
|
||||
private func makeUpdaterController() -> UpdaterProviding {
|
||||
|
||||
@@ -8,7 +8,6 @@ import SwiftUI
|
||||
struct MenuContent: View {
|
||||
@Bindable var state: AppState
|
||||
let updater: UpdaterProviding?
|
||||
@Bindable private var updateStatus: UpdateStatus
|
||||
private let gatewayManager = GatewayProcessManager.shared
|
||||
private let healthStore = HealthStore.shared
|
||||
private let heartbeatStore = HeartbeatStore.shared
|
||||
@@ -17,14 +16,9 @@ struct MenuContent: View {
|
||||
@Environment(\.openSettings) private var openSettings
|
||||
@State private var availableMics: [AudioInputDevice] = []
|
||||
@State private var loadingMics = false
|
||||
@State private var sessionMenu: [SessionRow] = []
|
||||
@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 {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Toggle(isOn: self.activeBinding) {
|
||||
@@ -34,7 +28,6 @@ struct MenuContent: View {
|
||||
}
|
||||
}
|
||||
.disabled(self.state.connectionMode == .unconfigured)
|
||||
|
||||
Divider()
|
||||
Toggle(isOn: self.heartbeatsBinding) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
@@ -48,7 +41,6 @@ struct MenuContent: View {
|
||||
if self.showVoiceWakeMicPicker {
|
||||
self.voiceWakeMicMenu
|
||||
}
|
||||
Divider()
|
||||
Button("Open Chat") {
|
||||
Task { @MainActor in
|
||||
let sessionKey = await WebChatManager.shared.preferredSessionKey()
|
||||
@@ -60,6 +52,15 @@ struct MenuContent: View {
|
||||
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 })) {
|
||||
Text("Allow Canvas")
|
||||
}
|
||||
@@ -79,22 +80,12 @@ struct MenuContent: View {
|
||||
}
|
||||
}
|
||||
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) }
|
||||
.keyboardShortcut(",", modifiers: [.command])
|
||||
self.debugMenu
|
||||
Button("About Clawdis") { self.open(tab: .about) }
|
||||
if let updater, updater.isAvailable, self.updateStatus.isUpdateReady {
|
||||
Button("Update ready, restart now?") { updater.checkForUpdates(nil) }
|
||||
if let updater, updater.isAvailable {
|
||||
Button("Check for Updates…") { updater.checkForUpdates(nil) }
|
||||
}
|
||||
Button("Quit") { NSApplication.shared.terminate(nil) }
|
||||
}
|
||||
@@ -103,6 +94,9 @@ struct MenuContent: View {
|
||||
await self.loadMicrophones(force: true)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await self.reloadSessionMenu()
|
||||
}
|
||||
.task {
|
||||
VoicePushToTalkHotkey.shared.setEnabled(voiceWakeSupported && self.state.voicePushToTalkEnabled)
|
||||
}
|
||||
@@ -129,6 +123,55 @@ struct MenuContent: View {
|
||||
private var debugMenu: some View {
|
||||
if self.state.debugPaneEnabled {
|
||||
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 {
|
||||
DebugActions.openConfigFolder()
|
||||
} label: {
|
||||
@@ -380,6 +423,11 @@ struct MenuContent: View {
|
||||
return "System default"
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func reloadSessionMenu() async {
|
||||
self.sessionMenu = await DebugActions.recentSessions()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadMicrophones(force: Bool = false) async {
|
||||
guard self.showVoiceWakeMicPicker else {
|
||||
|
||||
@@ -48,7 +48,6 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate {
|
||||
let initialRows = self.cachedRows
|
||||
let initialIsLoading = initialRows.isEmpty
|
||||
let initialStatusText = initialIsLoading ? self.cacheErrorText : nil
|
||||
let initialWidth = self.initialCardWidth(for: menu)
|
||||
|
||||
let initial = AnyView(ContextMenuCardView(
|
||||
rows: initialRows,
|
||||
@@ -56,8 +55,8 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate {
|
||||
isLoading: initialIsLoading))
|
||||
|
||||
let hosting = NSHostingView(rootView: initial)
|
||||
hosting.frame.size.width = max(1, initialWidth)
|
||||
let size = hosting.fittingSize
|
||||
let initialWidth = self.initialCardWidth(for: menu)
|
||||
hosting.frame = NSRect(
|
||||
origin: .zero,
|
||||
size: NSSize(width: initialWidth, height: size.height))
|
||||
@@ -84,7 +83,6 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate {
|
||||
hosting.rootView = view
|
||||
hosting.invalidateIntrinsicContentSize()
|
||||
self.captureMenuWidthIfAvailable(for: menu, hosting: hosting)
|
||||
hosting.frame.size.width = max(1, initialWidth)
|
||||
let size = hosting.fittingSize
|
||||
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()
|
||||
@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 {
|
||||
let command = req.command
|
||||
if command.hasPrefix("canvas.") || command.hasPrefix("canvas.a2ui."), !Self.canvasEnabled() {
|
||||
|
||||
@@ -24,7 +24,7 @@ final class OnboardingController {
|
||||
let hosting = NSHostingController(rootView: OnboardingView())
|
||||
let window = NSWindow(contentViewController: hosting)
|
||||
window.title = UIStrings.welcomeTitle
|
||||
window.setContentSize(NSSize(width: 630, height: 644))
|
||||
window.setContentSize(NSSize(width: 680, height: 840))
|
||||
window.styleMask = [.titled, .closable, .fullSizeContentView]
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.titleVisibility = .hidden
|
||||
@@ -62,13 +62,18 @@ struct OnboardingView: View {
|
||||
@State private var anthropicAuthStatus: String?
|
||||
@State private var anthropicAuthBusy = 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 anthropicAuthAutoConnectClipboard = true
|
||||
@State private var anthropicAuthLastPasteboardChangeCount = NSPasteboard.general.changeCount
|
||||
@State private var monitoringAuth = false
|
||||
@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 showAdvancedConnection = false
|
||||
@State private var preferredGatewayID: String?
|
||||
@@ -78,8 +83,8 @@ struct OnboardingView: View {
|
||||
@Bindable private var state: AppState
|
||||
private var permissionMonitor: PermissionMonitor
|
||||
|
||||
private let pageWidth: CGFloat = 630
|
||||
private let contentHeight: CGFloat = 420
|
||||
private let pageWidth: CGFloat = 680
|
||||
private let contentHeight: CGFloat = 520
|
||||
private let connectionPageIndex = 1
|
||||
private let anthropicAuthPageIndex = 2
|
||||
private let onboardingChatPageIndex = 8
|
||||
@@ -88,22 +93,22 @@ struct OnboardingView: View {
|
||||
private let permissionsPageIndex = 5
|
||||
static func pageOrder(
|
||||
for mode: AppState.ConnectionMode,
|
||||
needsBootstrap: Bool) -> [Int]
|
||||
hasIdentity: Bool) -> [Int]
|
||||
{
|
||||
switch mode {
|
||||
case .remote:
|
||||
// Remote setup doesn't need local gateway/CLI/workspace setup pages,
|
||||
// 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:
|
||||
needsBootstrap ? [0, 1, 8, 9] : [0, 1, 9]
|
||||
hasIdentity ? [0, 1, 9] : [0, 1, 8, 9]
|
||||
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] {
|
||||
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 }
|
||||
@@ -140,9 +145,9 @@ struct OnboardingView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
GlowingClawdisIcon(size: 130, glowIntensity: 0.28)
|
||||
.offset(y: 10)
|
||||
.frame(height: 145)
|
||||
GlowingClawdisIcon(size: 156, glowIntensity: 0.28)
|
||||
.offset(y: 13)
|
||||
.frame(height: 181)
|
||||
|
||||
GeometryReader { _ in
|
||||
HStack(spacing: 0) {
|
||||
@@ -160,10 +165,9 @@ struct OnboardingView: View {
|
||||
}
|
||||
.frame(height: self.contentHeight)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
self.navigationBar
|
||||
}
|
||||
.frame(width: self.pageWidth, height: 644)
|
||||
.frame(width: self.pageWidth, height: 840)
|
||||
.background(Color(NSColor.windowBackgroundColor))
|
||||
.onAppear {
|
||||
self.currentPage = 0
|
||||
@@ -177,7 +181,7 @@ struct OnboardingView: View {
|
||||
self.reconcilePageForModeChange(previousActivePageIndex: oldActive)
|
||||
self.updateDiscoveryMonitoring(for: self.activePageIndex)
|
||||
}
|
||||
.onChange(of: self.needsBootstrap) { _, _ in
|
||||
.onChange(of: self.hasIdentity) { _, _ in
|
||||
if self.currentPage >= self.pageOrder.count {
|
||||
self.currentPage = max(0, self.pageOrder.count - 1)
|
||||
}
|
||||
@@ -193,7 +197,7 @@ struct OnboardingView: View {
|
||||
self.loadWorkspaceDefaults()
|
||||
self.ensureDefaultWorkspace()
|
||||
self.refreshAnthropicOAuthStatus()
|
||||
self.refreshBootstrapStatus()
|
||||
self.loadIdentityDefaults()
|
||||
self.preferredGatewayID = BridgeDiscoveryPreferences.preferredStableID()
|
||||
}
|
||||
}
|
||||
@@ -225,6 +229,8 @@ struct OnboardingView: View {
|
||||
self.connectionPage()
|
||||
case 2:
|
||||
self.anthropicAuthPage()
|
||||
case 3:
|
||||
self.identityPage()
|
||||
case 5:
|
||||
self.permissionsPage()
|
||||
case 6:
|
||||
@@ -243,7 +249,9 @@ struct OnboardingView: View {
|
||||
VStack(spacing: 22) {
|
||||
Text("Welcome to Clawdis")
|
||||
.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)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -530,7 +538,7 @@ struct OnboardingView: View {
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 540)
|
||||
.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)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -555,14 +563,14 @@ struct OnboardingView: View {
|
||||
}
|
||||
|
||||
Text(
|
||||
"This lets Clawdis use Claude immediately. Credentials are stored at " +
|
||||
"`~/.clawdis/credentials/oauth.json` (owner-only). You can redo this anytime.")
|
||||
"This lets Pi use Claude immediately. Credentials are stored at " +
|
||||
"`~/.pi/agent/oauth.json` (owner-only). You can redo this anytime.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Text(ClawdisOAuthStore.oauthURL().path)
|
||||
Text(PiOAuthStore.oauthURL().path)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
@@ -571,7 +579,7 @@ struct OnboardingView: View {
|
||||
Spacer()
|
||||
|
||||
Button("Reveal") {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([ClawdisOAuthStore.oauthURL()])
|
||||
NSWorkspace.shared.activateFileViewerSelecting([PiOAuthStore.oauthURL()])
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
@@ -683,9 +691,9 @@ struct OnboardingView: View {
|
||||
code: parsed.code,
|
||||
state: parsed.state,
|
||||
verifier: pkce.verifier)
|
||||
try ClawdisOAuthStore.saveAnthropicOAuth(creds)
|
||||
try PiOAuthStore.saveAnthropicOAuth(creds)
|
||||
self.refreshAnthropicOAuthStatus()
|
||||
self.anthropicAuthStatus = "Connected. Clawdis can now use Claude."
|
||||
self.anthropicAuthStatus = "Connected. Pi can now use Claude."
|
||||
} catch {
|
||||
self.anthropicAuthStatus = "OAuth failed: \(error.localizedDescription)"
|
||||
}
|
||||
@@ -717,12 +725,104 @@ struct OnboardingView: View {
|
||||
}
|
||||
|
||||
private func refreshAnthropicOAuthStatus() {
|
||||
_ = ClawdisOAuthStore.importLegacyAnthropicOAuthIfNeeded()
|
||||
let status = ClawdisOAuthStore.anthropicOAuthStatus()
|
||||
let status = PiOAuthStore.anthropicOAuthStatus()
|
||||
self.anthropicAuthDetectedStatus = status
|
||||
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 {
|
||||
self.onboardingPage {
|
||||
Text("Grant permissions")
|
||||
@@ -734,13 +834,9 @@ struct OnboardingView: View {
|
||||
.frame(maxWidth: 520)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
self.onboardingCard(spacing: 8, padding: 12) {
|
||||
self.onboardingCard {
|
||||
ForEach(Capability.allCases, id: \.self) { cap in
|
||||
PermissionRow(
|
||||
capability: cap,
|
||||
status: self.permissionMonitor.status[cap] ?? false,
|
||||
compact: true)
|
||||
{
|
||||
PermissionRow(capability: cap, status: self.permissionMonitor.status[cap] ?? false) {
|
||||
Task { await self.request(cap) }
|
||||
}
|
||||
}
|
||||
@@ -909,7 +1005,7 @@ struct OnboardingView: View {
|
||||
}
|
||||
|
||||
private func onboardingChatPage() -> some View {
|
||||
VStack(spacing: 16) {
|
||||
self.onboardingPage {
|
||||
Text("Meet your agent")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
Text(
|
||||
@@ -923,12 +1019,9 @@ struct OnboardingView: View {
|
||||
|
||||
self.onboardingCard(padding: 8) {
|
||||
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 {
|
||||
@@ -948,8 +1041,8 @@ struct OnboardingView: View {
|
||||
self.featureRow(
|
||||
title: "Remote gateway checklist",
|
||||
subtitle: """
|
||||
On your gateway host: install/update the `clawdis` package and make sure credentials exist
|
||||
(typically `~/.clawdis/credentials/oauth.json`). Then connect again if needed.
|
||||
On your gateway host: install/update the `clawdis` package and make sure Pi has credentials
|
||||
(typically `~/.pi/agent/oauth.json`). Then connect again if needed.
|
||||
""",
|
||||
systemImage: "network")
|
||||
Divider()
|
||||
@@ -959,13 +1052,6 @@ struct OnboardingView: View {
|
||||
title: "Open the menu bar panel",
|
||||
subtitle: "Click the Clawdis menu bar icon for quick chat and status.",
|
||||
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(
|
||||
title: "Try Voice Wake",
|
||||
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 " +
|
||||
"and richer visuals in Canvas.",
|
||||
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(
|
||||
title: "Give your agent more powers",
|
||||
subtitle: "Enable optional skills (Peekaboo, oracle, camsnap, …) from Settings → Skills.",
|
||||
@@ -1044,12 +1134,12 @@ struct OnboardingView: View {
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding(.horizontal, 28)
|
||||
.padding(.bottom, 13)
|
||||
.frame(minHeight: 60, alignment: .bottom)
|
||||
.padding(.bottom, 36)
|
||||
.frame(minHeight: 60)
|
||||
}
|
||||
|
||||
private func onboardingPage(@ViewBuilder _ content: () -> some View) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
VStack(spacing: 22) {
|
||||
content()
|
||||
Spacer()
|
||||
}
|
||||
@@ -1086,7 +1176,6 @@ struct OnboardingView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func featureActionRow(
|
||||
@@ -1111,7 +1200,6 @@ struct OnboardingView: View {
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func handleBack() {
|
||||
@@ -1271,34 +1359,40 @@ struct OnboardingView: View {
|
||||
let configured = ClawdisConfigFile.inboundWorkspace()
|
||||
let url = AgentWorkspace.resolveWorkspaceURL(from: configured)
|
||||
self.workspacePath = AgentWorkspace.displayPath(for: url)
|
||||
self.refreshBootstrapStatus()
|
||||
}
|
||||
|
||||
private func ensureDefaultWorkspace() {
|
||||
guard self.state.connectionMode == .local else { return }
|
||||
let configured = ClawdisConfigFile.inboundWorkspace()
|
||||
let url = AgentWorkspace.resolveWorkspaceURL(from: configured)
|
||||
switch AgentWorkspace.bootstrapSafety(for: url) {
|
||||
case .safe:
|
||||
do {
|
||||
_ = try AgentWorkspace.bootstrap(workspaceURL: url)
|
||||
if (configured ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
ClawdisConfigFile.setInboundWorkspace(AgentWorkspace.displayPath(for: url))
|
||||
}
|
||||
} catch {
|
||||
self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)"
|
||||
do {
|
||||
_ = try AgentWorkspace.bootstrap(workspaceURL: url)
|
||||
if (configured ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
ClawdisConfigFile.setInboundWorkspace(AgentWorkspace.displayPath(for: url))
|
||||
}
|
||||
case let .unsafe(reason):
|
||||
self.workspaceStatus = "Workspace not touched: \(reason)"
|
||||
} catch {
|
||||
self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)"
|
||||
}
|
||||
self.refreshBootstrapStatus()
|
||||
}
|
||||
|
||||
private func refreshBootstrapStatus() {
|
||||
let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath)
|
||||
self.needsBootstrap = AgentWorkspace.needsBootstrap(workspaceURL: url)
|
||||
if self.needsBootstrap {
|
||||
self.didAutoKickoff = false
|
||||
private func loadIdentityDefaults() {
|
||||
if let identity = ClawdisConfigFile.loadIdentity() {
|
||||
self.identityName = identity.name
|
||||
self.identityTheme = identity.theme
|
||||
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 {
|
||||
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)
|
||||
self.workspacePath = AgentWorkspace.displayPath(for: url)
|
||||
self.workspaceStatus = "Workspace ready at \(self.workspacePath)"
|
||||
self.refreshBootstrapStatus()
|
||||
} catch {
|
||||
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) {
|
||||
guard pageIndex == self.onboardingChatPageIndex else { return }
|
||||
guard self.needsBootstrap else { return }
|
||||
guard !self.hasIdentity else { return }
|
||||
guard !self.didAutoKickoff else { return }
|
||||
self.didAutoKickoff = true
|
||||
|
||||
|
||||
@@ -58,21 +58,13 @@ struct PermissionStatusList: View {
|
||||
struct PermissionRow: View {
|
||||
let capability: Capability
|
||||
let status: Bool
|
||||
let compact: Bool
|
||||
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 {
|
||||
HStack(spacing: self.compact ? 10 : 12) {
|
||||
HStack(spacing: 12) {
|
||||
ZStack {
|
||||
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)
|
||||
.foregroundStyle(self.status ? Color.green : Color.secondary)
|
||||
}
|
||||
@@ -89,11 +81,9 @@ struct PermissionRow: View {
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, self.compact ? 4 : 6)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
private var iconSize: CGFloat { self.compact ? 28 : 32 }
|
||||
|
||||
private var title: String {
|
||||
switch self.capability {
|
||||
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") }
|
||||
.tag(SettingsTab.general)
|
||||
|
||||
ConnectionsSettings()
|
||||
.tabItem { Label("Connections", systemImage: "link") }
|
||||
.tag(SettingsTab.connections)
|
||||
|
||||
VoiceWakeSettings(state: self.state)
|
||||
.tabItem { Label("Voice Wake", systemImage: "waveform.circle") }
|
||||
.tag(SettingsTab.voiceWake)
|
||||
@@ -129,13 +125,12 @@ struct SettingsRootView: View {
|
||||
}
|
||||
|
||||
enum SettingsTab: CaseIterable {
|
||||
case general, connections, skills, sessions, cron, config, instances, voiceWake, permissions, debug, about
|
||||
static let windowWidth: CGFloat = 824 // wider
|
||||
case general, skills, sessions, cron, config, instances, voiceWake, permissions, debug, about
|
||||
static let windowWidth: CGFloat = 724 // +10% (tabs fit better)
|
||||
static let windowHeight: CGFloat = 790 // +10% (more room)
|
||||
var title: String {
|
||||
switch self {
|
||||
case .general: "General"
|
||||
case .connections: "Connections"
|
||||
case .skills: "Skills"
|
||||
case .sessions: "Sessions"
|
||||
case .cron: "Cron"
|
||||
|
||||
@@ -56,8 +56,8 @@ enum ShellExecutor {
|
||||
let err = stderrPipe.fileHandleForReading.readToEndSafely()
|
||||
let status = Int(process.terminationStatus)
|
||||
return ShellResult(
|
||||
stdout: String(bytes: out, encoding: .utf8) ?? "",
|
||||
stderr: String(bytes: err, encoding: .utf8) ?? "",
|
||||
stdout: String(decoding: out, as: UTF8.self),
|
||||
stderr: String(decoding: err, as: UTF8.self),
|
||||
exitCode: status,
|
||||
timedOut: false,
|
||||
success: status == 0,
|
||||
|
||||
@@ -6,6 +6,7 @@ struct SkillsSettings: View {
|
||||
@Bindable var state: AppState
|
||||
@State private var model = SkillsSettingsModel()
|
||||
@State private var envEditor: EnvEditorState?
|
||||
@State private var searchQuery = ""
|
||||
@State private var filter: SkillsFilter = .all
|
||||
|
||||
init(state: AppState = AppStateStore.shared) {
|
||||
@@ -82,7 +83,8 @@ struct SkillsSettings: View {
|
||||
SkillRow(
|
||||
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
|
||||
Task { await self.model.setEnabled(skillKey: skill.skillKey, enabled: enabled) }
|
||||
},
|
||||
@@ -104,6 +106,7 @@ struct SkillsSettings: View {
|
||||
}
|
||||
}
|
||||
.listStyle(.inset)
|
||||
.searchable(text: self.$searchQuery, placement: .automatic, prompt: "Search skills")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,19 +123,31 @@ struct SkillsSettings: View {
|
||||
}
|
||||
|
||||
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 {
|
||||
case .all:
|
||||
true
|
||||
return true
|
||||
case .ready:
|
||||
!skill.disabled && skill.eligible
|
||||
return !skill.disabled && skill.eligible
|
||||
case .needsSetup:
|
||||
!skill.disabled && !skill.eligible
|
||||
return !skill.disabled && !skill.eligible
|
||||
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 {
|
||||
@@ -165,10 +180,11 @@ private enum InstallTarget: String, CaseIterable {
|
||||
private struct SkillRow: View {
|
||||
let skill: SkillStatus
|
||||
let isBusy: Bool
|
||||
let connectionMode: AppState.ConnectionMode
|
||||
let canInstallLocally: Bool
|
||||
let onToggleEnabled: (Bool) -> Void
|
||||
let onInstall: (SkillInstallOption, InstallTarget) -> Void
|
||||
let onSetEnv: (String, Bool) -> Void
|
||||
@State private var installTarget: InstallTarget
|
||||
|
||||
private var missingBins: [String] { self.skill.missing.bins }
|
||||
private var missingEnv: [String] { self.skill.missing.env }
|
||||
@@ -177,17 +193,22 @@ private struct SkillRow: View {
|
||||
init(
|
||||
skill: SkillStatus,
|
||||
isBusy: Bool,
|
||||
connectionMode: AppState.ConnectionMode,
|
||||
canInstallLocally: Bool,
|
||||
defaultInstallTarget: InstallTarget,
|
||||
onToggleEnabled: @escaping (Bool) -> Void,
|
||||
onInstall: @escaping (SkillInstallOption, InstallTarget) -> Void,
|
||||
onSetEnv: @escaping (String, Bool) -> Void)
|
||||
{
|
||||
self.skill = skill
|
||||
self.isBusy = isBusy
|
||||
self.connectionMode = connectionMode
|
||||
self.canInstallLocally = canInstallLocally
|
||||
self.onToggleEnabled = onToggleEnabled
|
||||
self.onInstall = onInstall
|
||||
self.onSetEnv = onSetEnv
|
||||
let initialTarget: InstallTarget = (defaultInstallTarget == .local && !canInstallLocally)
|
||||
? .gateway
|
||||
: defaultInstallTarget
|
||||
self._installTarget = State(initialValue: initialTarget)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -327,31 +348,28 @@ private struct SkillRow: View {
|
||||
private var trailingActions: some View {
|
||||
VStack(alignment: .trailing, spacing: 8) {
|
||||
if !self.installOptions.isEmpty {
|
||||
ForEach(self.installOptions, id: \.id) { (option: SkillInstallOption) in
|
||||
HStack(spacing: 6) {
|
||||
if self.showGatewayInstall {
|
||||
Button("Install on Gateway") { self.onInstall(option, .gateway) }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.isBusy)
|
||||
}
|
||||
if self.showGatewayInstall {
|
||||
Button("Install on This Mac") { self.onInstall(option, .local) }
|
||||
.buttonStyle(.bordered)
|
||||
.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."
|
||||
: "")
|
||||
}
|
||||
HStack(spacing: 6) {
|
||||
Text("Install on")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Picker("Install on", selection: self.$installTarget) {
|
||||
Text("Gateway")
|
||||
.tag(InstallTarget.gateway)
|
||||
Text("This Mac")
|
||||
.tag(InstallTarget.local)
|
||||
.disabled(!self.canInstallLocally)
|
||||
}
|
||||
.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 {
|
||||
Toggle("", isOn: self.enabledBinding)
|
||||
@@ -390,12 +408,8 @@ private struct SkillRow: View {
|
||||
!self.missingConfig.isEmpty
|
||||
}
|
||||
|
||||
private var showGatewayInstall: Bool {
|
||||
self.connectionMode == .remote
|
||||
}
|
||||
|
||||
private var localInstallNeedsSwitch: Bool {
|
||||
self.connectionMode != .local
|
||||
private var installBlocked: Bool {
|
||||
self.installTarget == .local && !self.canInstallLocally
|
||||
}
|
||||
|
||||
private func formatConfigValue(_ value: AnyCodable?) -> String {
|
||||
@@ -507,8 +521,8 @@ final class SkillsSettingsModel {
|
||||
await self.withBusy(skill.skillKey) {
|
||||
do {
|
||||
if target == .local, AppStateStore.shared.connectionMode != .local {
|
||||
AppStateStore.shared.connectionMode = .local
|
||||
self.statusMessage = "Switched to Local mode to install on this Mac"
|
||||
self.statusMessage = "Local install requires a local gateway connection"
|
||||
return
|
||||
}
|
||||
let result = try await GatewayConnection.shared.skillsInstall(
|
||||
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 OSLog
|
||||
import Speech
|
||||
import SwabbleKit
|
||||
#if canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
@@ -36,7 +35,6 @@ actor VoiceWakeRuntime {
|
||||
private var currentConfig: RuntimeConfig?
|
||||
private var listeningState: ListeningState = .idle
|
||||
private var overlayToken: UUID?
|
||||
private var activeTriggerEndTime: TimeInterval?
|
||||
|
||||
// Tunables
|
||||
// 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
|
||||
guard let self else { return }
|
||||
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
|
||||
Task { await self.handleRecognition(
|
||||
transcript: transcript,
|
||||
segments: segments,
|
||||
isFinal: isFinal,
|
||||
error: error,
|
||||
config: config,
|
||||
@@ -190,7 +184,6 @@ actor VoiceWakeRuntime {
|
||||
self.audioEngine = nil
|
||||
self.currentConfig = nil
|
||||
self.listeningState = .idle
|
||||
self.activeTriggerEndTime = nil
|
||||
self.logger.debug("voicewake runtime stopped")
|
||||
DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "stopped")
|
||||
|
||||
@@ -213,7 +206,6 @@ actor VoiceWakeRuntime {
|
||||
|
||||
private func handleRecognition(
|
||||
transcript: String?,
|
||||
segments: [WakeWordSegment],
|
||||
isFinal: Bool,
|
||||
error: Error?,
|
||||
config: RuntimeConfig,
|
||||
@@ -232,11 +224,7 @@ actor VoiceWakeRuntime {
|
||||
if !transcript.isEmpty {
|
||||
self.lastHeard = now
|
||||
if self.isCapturing {
|
||||
let trimmed = Self.commandAfterTrigger(
|
||||
transcript: transcript,
|
||||
segments: segments,
|
||||
triggerEndTime: self.activeTriggerEndTime,
|
||||
triggers: config.triggers)
|
||||
let trimmed = Self.trimmedAfterTrigger(transcript, triggers: config.triggers)
|
||||
self.capturedTranscript = trimmed
|
||||
self.updateHeardBeyondTrigger(withTrimmed: trimmed)
|
||||
if isFinal {
|
||||
@@ -264,27 +252,37 @@ actor VoiceWakeRuntime {
|
||||
|
||||
if self.isCapturing { return }
|
||||
|
||||
let gateConfig = WakeWordGateConfig(triggers: config.triggers)
|
||||
if let match = WakeWordGate.match(transcript: transcript, segments: segments, config: gateConfig) {
|
||||
if Self.matches(text: transcript, triggers: config.triggers) {
|
||||
if let cooldown = cooldownUntil, now < cooldown {
|
||||
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.isCapturing = true
|
||||
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.volatileTranscript = command
|
||||
self.volatileTranscript = trimmed
|
||||
self.captureStartedAt = Date()
|
||||
self.cooldownUntil = nil
|
||||
self.heardBeyondTrigger = !command.isEmpty
|
||||
self.heardBeyondTrigger = !trimmed.isEmpty
|
||||
self.triggerChimePlayed = false
|
||||
self.activeTriggerEndTime = triggerEndTime
|
||||
|
||||
if config.triggerChime != .none, !self.triggerChimePlayed {
|
||||
self.triggerChimePlayed = true
|
||||
@@ -356,7 +354,6 @@ actor VoiceWakeRuntime {
|
||||
self.lastHeard = nil
|
||||
self.heardBeyondTrigger = false
|
||||
self.triggerChimePlayed = false
|
||||
self.activeTriggerEndTime = nil
|
||||
|
||||
await MainActor.run { AppStateStore.shared.stopVoiceEars() }
|
||||
if let token = self.overlayToken {
|
||||
@@ -470,22 +467,6 @@ actor VoiceWakeRuntime {
|
||||
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
|
||||
static func _testTrimmedAfterTrigger(_ text: String, triggers: [String]) -> String {
|
||||
self.trimmedAfterTrigger(text, triggers: triggers)
|
||||
@@ -500,6 +481,9 @@ actor VoiceWakeRuntime {
|
||||
.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor ?? .clear
|
||||
}
|
||||
|
||||
static func _testMatches(text: String, triggers: [String]) -> Bool {
|
||||
self.matches(text: text, triggers: triggers)
|
||||
}
|
||||
#endif
|
||||
|
||||
private static func delta(after committed: String, current: String) -> String {
|
||||
|
||||
@@ -2,7 +2,6 @@ import AVFoundation
|
||||
import Foundation
|
||||
import OSLog
|
||||
import Speech
|
||||
import SwabbleKit
|
||||
|
||||
enum VoiceWakeTestState: Equatable {
|
||||
case idle
|
||||
@@ -94,16 +93,14 @@ final class VoiceWakeTester {
|
||||
self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in
|
||||
guard let self, !self.isStopping else { return }
|
||||
let text = result?.bestTranscription.formattedString ?? ""
|
||||
let segments = result.map { WakeWordSpeechSegments.from(transcription: $0.bestTranscription, transcript: text) } ?? []
|
||||
let gateConfig = WakeWordGateConfig(triggers: triggers)
|
||||
let match = WakeWordGate.match(transcript: text, segments: segments, config: gateConfig)
|
||||
let matched = Self.matches(text: text, triggers: triggers)
|
||||
let isFinal = result?.isFinal ?? false
|
||||
let errorMessage = error?.localizedDescription
|
||||
|
||||
Task { [weak self] in
|
||||
guard let self, !self.isStopping else { return }
|
||||
await self.handleResult(
|
||||
match: match,
|
||||
matched: matched,
|
||||
text: text,
|
||||
isFinal: isFinal,
|
||||
errorMessage: errorMessage,
|
||||
@@ -123,7 +120,7 @@ final class VoiceWakeTester {
|
||||
}
|
||||
|
||||
private func handleResult(
|
||||
match: WakeWordGateMatch?,
|
||||
matched: Bool,
|
||||
text: String,
|
||||
isFinal: Bool,
|
||||
errorMessage: String?,
|
||||
@@ -132,15 +129,15 @@ final class VoiceWakeTester {
|
||||
if !text.isEmpty {
|
||||
self.lastHeard = Date()
|
||||
}
|
||||
if let match, !match.command.isEmpty {
|
||||
if matched, !text.isEmpty {
|
||||
self.holdingAfterDetect = true
|
||||
self.detectedText = match.command
|
||||
self.logger.info("voice wake detected; forwarding (len=\(match.command.count))")
|
||||
self.detectedText = text
|
||||
self.logger.info("voice wake detected; forwarding (len=\(text.count))")
|
||||
await MainActor.run { AppStateStore.shared.triggerVoiceEars(ttl: nil) }
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -190,6 +187,15 @@ final class VoiceWakeTester {
|
||||
_ = 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 {
|
||||
let speechStatus = SFSpeechRecognizer.authorizationStatus()
|
||||
if speechStatus == .notDetermined {
|
||||
|
||||
@@ -140,7 +140,6 @@ final class WebChatSwiftUIWindowController {
|
||||
private let presentation: WebChatPresentation
|
||||
private let sessionKey: String
|
||||
private let hosting: NSHostingController<ClawdisChatView>
|
||||
private let contentController: NSViewController
|
||||
private var window: NSWindow?
|
||||
private var dismissMonitor: Any?
|
||||
var onClosed: (() -> Void)?
|
||||
@@ -155,8 +154,11 @@ final class WebChatSwiftUIWindowController {
|
||||
self.presentation = presentation
|
||||
let vm = ClawdisChatViewModel(sessionKey: sessionKey, transport: transport)
|
||||
self.hosting = NSHostingController(rootView: ClawdisChatView(viewModel: vm))
|
||||
self.contentController = Self.makeContentController(for: presentation, hosting: self.hosting)
|
||||
self.window = Self.makeWindow(for: presentation, contentViewController: self.contentController)
|
||||
self.hosting.view.wantsLayer = true
|
||||
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 {}
|
||||
@@ -265,13 +267,11 @@ final class WebChatSwiftUIWindowController {
|
||||
window.isReleasedWhenClosed = false
|
||||
window.titleVisibility = .visible
|
||||
window.titlebarAppearsTransparent = false
|
||||
window.backgroundColor = .clear
|
||||
window.isOpaque = false
|
||||
window.backgroundColor = .windowBackgroundColor
|
||||
window.isOpaque = true
|
||||
window.center()
|
||||
WindowPlacement.ensureOnScreen(window: window, defaultSize: WebChatSwiftUILayout.windowSize)
|
||||
window.minSize = NSSize(width: 880, height: 680)
|
||||
window.contentView?.wantsLayer = true
|
||||
window.contentView?.layer?.backgroundColor = NSColor.clear.cgColor
|
||||
return window
|
||||
case .panel:
|
||||
let panel = WebChatPanel(
|
||||
@@ -290,8 +290,6 @@ final class WebChatSwiftUIWindowController {
|
||||
panel.isOpaque = false
|
||||
panel.contentViewController = contentViewController
|
||||
panel.becomesKeyOnlyIfNeeded = true
|
||||
panel.contentView?.wantsLayer = true
|
||||
panel.contentView?.layer?.backgroundColor = NSColor.clear.cgColor
|
||||
panel.setFrame(
|
||||
WindowPlacement.topRightFrame(
|
||||
size: WebChatSwiftUILayout.panelSize,
|
||||
@@ -300,45 +298,4 @@ final class WebChatSwiftUIWindowController {
|
||||
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)
|
||||
#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 {
|
||||
@Test
|
||||
func prefersOAuthFileOverEnv() throws {
|
||||
let key = "CLAWDIS_OAUTH_DIR"
|
||||
let key = "PI_CODING_AGENT_DIR"
|
||||
let previous = ProcessInfo.processInfo.environment[key]
|
||||
defer {
|
||||
if let previous {
|
||||
@@ -61,3 +61,4 @@ struct AnthropicAuthResolverTests {
|
||||
#expect(mode == .missing)
|
||||
}
|
||||
}
|
||||
|
||||
+10
-10
@@ -3,18 +3,18 @@ import Testing
|
||||
@testable import Clawdis
|
||||
|
||||
@Suite
|
||||
struct ClawdisOAuthStoreTests {
|
||||
struct PiOAuthStoreTests {
|
||||
@Test
|
||||
func returnsMissingWhenFileAbsent() {
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clawdis-oauth-\(UUID().uuidString)")
|
||||
.appendingPathComponent("oauth.json")
|
||||
#expect(ClawdisOAuthStore.anthropicOAuthStatus(at: url) == .missingFile)
|
||||
#expect(PiOAuthStore.anthropicOAuthStatus(at: url) == .missingFile)
|
||||
}
|
||||
|
||||
@Test
|
||||
func usesEnvOverrideForClawdisOAuthDir() throws {
|
||||
let key = "CLAWDIS_OAUTH_DIR"
|
||||
func usesEnvOverrideForPiAgentDir() throws {
|
||||
let key = "PI_CODING_AGENT_DIR"
|
||||
let previous = ProcessInfo.processInfo.environment[key]
|
||||
defer {
|
||||
if let previous {
|
||||
@@ -25,10 +25,10 @@ struct ClawdisOAuthStoreTests {
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
#expect(ClawdisOAuthStore.oauthDir().standardizedFileURL == dir.standardizedFileURL)
|
||||
#expect(PiOAuthStore.oauthDir().standardizedFileURL == dir.standardizedFileURL)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -42,7 +42,7 @@ struct ClawdisOAuthStoreTests {
|
||||
],
|
||||
])
|
||||
|
||||
#expect(ClawdisOAuthStore.anthropicOAuthStatus(at: url).isConnected)
|
||||
#expect(PiOAuthStore.anthropicOAuthStatus(at: url).isConnected)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -55,7 +55,7 @@ struct ClawdisOAuthStoreTests {
|
||||
],
|
||||
])
|
||||
|
||||
#expect(ClawdisOAuthStore.anthropicOAuthStatus(at: url).isConnected)
|
||||
#expect(PiOAuthStore.anthropicOAuthStatus(at: url).isConnected)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -68,7 +68,7 @@ struct ClawdisOAuthStoreTests {
|
||||
],
|
||||
])
|
||||
|
||||
#expect(ClawdisOAuthStore.anthropicOAuthStatus(at: url) == .missingProviderEntry)
|
||||
#expect(PiOAuthStore.anthropicOAuthStatus(at: url) == .missingProviderEntry)
|
||||
}
|
||||
|
||||
@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 {
|
||||
@@ -1,9 +1,23 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import SwabbleKit
|
||||
@testable import Clawdis
|
||||
|
||||
@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() {
|
||||
let triggers = ["claude", "clawd"]
|
||||
let text = "hey Claude how are you"
|
||||
@@ -34,46 +48,4 @@ import SwabbleKit
|
||||
let text = "claude write a note"
|
||||
#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 SwabbleKit
|
||||
@testable import Clawdis
|
||||
|
||||
struct VoiceWakeTesterTests {
|
||||
@Test func matchRespectsGapRequirement() {
|
||||
let transcript = "hey claude do thing"
|
||||
let segments = makeSegments(
|
||||
transcript: transcript,
|
||||
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 matchesIsCaseInsensitiveAndSubstring() {
|
||||
let triggers = ["Claude", "wake word"]
|
||||
#expect(VoiceWakeTester._testMatches(text: "hey claude are you there", triggers: triggers))
|
||||
#expect(VoiceWakeTester._testMatches(text: "this has wake word inside", triggers: triggers))
|
||||
}
|
||||
|
||||
@Test func matchReturnsCommandAfterGap() {
|
||||
let transcript = "hey claude do thing"
|
||||
let segments = makeSegments(
|
||||
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")
|
||||
@Test func matchesReturnsFalseWhenNoTrigger() {
|
||||
let triggers = ["claude"]
|
||||
#expect(!VoiceWakeTester._testMatches(text: "random text", triggers: triggers))
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if self.showsToolbar {
|
||||
HStack(spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
self.thinkingPicker
|
||||
Spacer()
|
||||
self.refreshButton
|
||||
@@ -41,14 +41,11 @@ struct ClawdisChatComposer: View {
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
.padding(self.composerPadding)
|
||||
.padding(8)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.fill(ClawdisChatTheme.composerBackground)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.strokeBorder(ClawdisChatTheme.composerBorder, lineWidth: 1))
|
||||
.shadow(color: .black.opacity(0.12), radius: 12, y: 6))
|
||||
.shadow(color: .black.opacity(0.08), radius: 10, y: 4))
|
||||
#if os(macOS)
|
||||
.onDrop(of: [.fileURL], isTargeted: nil) { providers in
|
||||
self.handleDrop(providers)
|
||||
@@ -137,9 +134,9 @@ struct ClawdisChatComposer: View {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(ClawdisChatTheme.composerField))
|
||||
.overlay {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
self.editorOverlay
|
||||
HStack(alignment: .bottom, spacing: 6) {
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
if self.showsConnectionPill {
|
||||
self.connectionPill
|
||||
}
|
||||
@@ -147,12 +144,9 @@ struct ClawdisChatComposer: View {
|
||||
self.sendButton
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
}
|
||||
.padding(self.editorPadding)
|
||||
.frame(
|
||||
minHeight: self.editorMinHeight,
|
||||
idealHeight: self.editorMinHeight,
|
||||
maxHeight: self.editorMaxHeight)
|
||||
.frame(minHeight: 44, idealHeight: 44, maxHeight: 96)
|
||||
}
|
||||
|
||||
private var connectionPill: some View {
|
||||
@@ -166,8 +160,8 @@ struct ClawdisChatComposer: View {
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(ClawdisChatTheme.subtleCard)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
@@ -177,23 +171,23 @@ struct ClawdisChatComposer: View {
|
||||
if self.viewModel.input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
Text("Message Clawd…")
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 4)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 5)
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
ChatComposerTextView(text: self.$viewModel.input) {
|
||||
self.viewModel.send()
|
||||
}
|
||||
.frame(minHeight: self.textMinHeight, idealHeight: self.textMinHeight, maxHeight: self.textMaxHeight)
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 3)
|
||||
.frame(minHeight: 32, idealHeight: 32, maxHeight: 72)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 4)
|
||||
#else
|
||||
TextEditor(text: self.$viewModel.input)
|
||||
.font(.system(size: 15))
|
||||
.scrollContentBackground(.hidden)
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 4)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 6)
|
||||
.focused(self.$isFocused)
|
||||
#endif
|
||||
}
|
||||
@@ -214,7 +208,7 @@ struct ClawdisChatComposer: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.white)
|
||||
.padding(6)
|
||||
.padding(8)
|
||||
.background(Circle().fill(Color.red))
|
||||
.disabled(self.viewModel.isAborting)
|
||||
} else {
|
||||
@@ -230,7 +224,7 @@ struct ClawdisChatComposer: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.white)
|
||||
.padding(6)
|
||||
.padding(8)
|
||||
.background(Circle().fill(Color.accentColor))
|
||||
.disabled(!self.viewModel.canSend)
|
||||
}
|
||||
@@ -260,30 +254,6 @@ struct ClawdisChatComposer: View {
|
||||
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)
|
||||
private func pickFilesMac() {
|
||||
let panel = NSOpenPanel()
|
||||
@@ -353,7 +323,7 @@ private struct ChatComposerTextView: NSViewRepresentable {
|
||||
textView.font = .systemFont(ofSize: 14, weight: .regular)
|
||||
textView.textContainer?.lineBreakMode = .byWordWrapping
|
||||
textView.textContainer?.lineFragmentPadding = 0
|
||||
textView.textContainerInset = NSSize(width: 2, height: 4)
|
||||
textView.textContainerInset = NSSize(width: 2, height: 6)
|
||||
textView.focusRingType = .none
|
||||
|
||||
textView.minSize = .zero
|
||||
|
||||
@@ -6,139 +6,12 @@ private enum ChatUIConstants {
|
||||
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
|
||||
struct ChatMessageBubble: View {
|
||||
let message: ClawdisChatMessage
|
||||
let style: ClawdisChatView.Style
|
||||
|
||||
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: .infinity, alignment: self.isUser ? .trailing : .leading)
|
||||
.padding(.horizontal, 2)
|
||||
@@ -151,7 +24,6 @@ struct ChatMessageBubble: View {
|
||||
private struct ChatMessageBody: View {
|
||||
let message: ClawdisChatMessage
|
||||
let isUser: Bool
|
||||
let style: ClawdisChatView.Style
|
||||
|
||||
var body: some View {
|
||||
let text = self.primaryText
|
||||
@@ -201,11 +73,8 @@ private struct ChatMessageBody: View {
|
||||
.padding(.horizontal, 12)
|
||||
.foregroundStyle(textColor)
|
||||
.background(self.bubbleBackground)
|
||||
.clipShape(self.bubbleShape)
|
||||
.overlay(self.bubbleBorder)
|
||||
.shadow(color: self.bubbleShadowColor, radius: self.bubbleShadowRadius, y: self.bubbleShadowYOffset)
|
||||
.padding(.leading, self.tailPaddingLeading)
|
||||
.padding(.trailing, self.tailPaddingTrailing)
|
||||
.clipShape(RoundedRectangle(cornerRadius: ChatUIConstants.bubbleCorner, style: .continuous))
|
||||
}
|
||||
|
||||
private var primaryText: String {
|
||||
@@ -217,67 +86,16 @@ private struct ChatMessageBody: View {
|
||||
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 {
|
||||
AnyShapeStyle(self.bubbleFillColor)
|
||||
}
|
||||
|
||||
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
|
||||
let fill = self.isUser ? ClawdisChatTheme.userBubble : ClawdisChatTheme.assistantBubble
|
||||
return AnyShapeStyle(fill)
|
||||
}
|
||||
|
||||
private var bubbleBorder: some View {
|
||||
self.bubbleShape.strokeBorder(self.bubbleBorderColor, lineWidth: self.bubbleBorderWidth)
|
||||
}
|
||||
|
||||
private var bubbleShape: ChatBubbleShape {
|
||||
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
|
||||
RoundedRectangle(cornerRadius: ChatUIConstants.bubbleCorner, style: .continuous)
|
||||
.strokeBorder(
|
||||
self.isUser ? Color.white.opacity(0.12) : Color.black.opacity(0.08),
|
||||
lineWidth: self.isUser ? 0.5 : 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,7 +139,7 @@ struct ChatTypingIndicatorBubble: View {
|
||||
.fill(ClawdisChatTheme.assistantBubble))
|
||||
.overlay(
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -340,7 +158,7 @@ struct ChatStreamingAssistantBubble: View {
|
||||
.fill(ClawdisChatTheme.assistantBubble))
|
||||
.overlay(
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -374,7 +192,7 @@ struct ChatPendingToolsBubble: View {
|
||||
.fill(ClawdisChatTheme.assistantBubble))
|
||||
.overlay(
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -412,18 +230,12 @@ private struct MarkdownTextView: View {
|
||||
let textColor: Color
|
||||
|
||||
var body: some View {
|
||||
let normalized = self.text.replacingOccurrences(
|
||||
of: "(?<!\\n)\\n(?!\\n)",
|
||||
with: " ",
|
||||
options: .regularExpression)
|
||||
let options = AttributedString.MarkdownParsingOptions(
|
||||
interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
||||
if let attributed = try? AttributedString(markdown: normalized, options: options) {
|
||||
if let attributed = try? AttributedString(markdown: self.text) {
|
||||
Text(attributed)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(self.textColor)
|
||||
} else {
|
||||
Text(normalized)
|
||||
Text(self.text)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(self.textColor)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,38 @@ public struct ClawdisChatSessionsDefaults: Codable, Sendable {
|
||||
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 var id: String { self.key }
|
||||
|
||||
@@ -24,6 +56,7 @@ public struct ClawdisChatSessionEntry: Codable, Identifiable, Sendable, Hashable
|
||||
|
||||
public let model: String?
|
||||
public let contextTokens: Int?
|
||||
public let syncing: ClawdisChatSessionSyncing?
|
||||
}
|
||||
|
||||
public struct ClawdisChatSessionsListResponse: Codable, Sendable {
|
||||
|
||||
@@ -15,40 +15,6 @@ enum ClawdisChatTheme {
|
||||
#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 {
|
||||
#if os(macOS)
|
||||
Color(nsColor: .textBackgroundColor)
|
||||
@@ -57,11 +23,11 @@ enum ClawdisChatTheme {
|
||||
#endif
|
||||
}
|
||||
|
||||
static var subtleCard: AnyShapeStyle {
|
||||
static var subtleCard: Color {
|
||||
#if os(macOS)
|
||||
AnyShapeStyle(.ultraThinMaterial)
|
||||
Color(nsColor: .textBackgroundColor).opacity(0.55)
|
||||
#else
|
||||
AnyShapeStyle(Color(uiColor: .secondarySystemBackground).opacity(0.9))
|
||||
Color(uiColor: .secondarySystemBackground).opacity(0.9)
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -75,32 +41,12 @@ enum ClawdisChatTheme {
|
||||
|
||||
static var assistantBubble: Color {
|
||||
#if os(macOS)
|
||||
let base = NSColor.controlBackgroundColor
|
||||
let blended = base.blended(withFraction: 0.18, of: .white) ?? base
|
||||
return Color(nsColor: blended).opacity(0.88)
|
||||
Color(nsColor: .controlBackgroundColor)
|
||||
#else
|
||||
Color(uiColor: .secondarySystemBackground)
|
||||
#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 assistantText: Color {
|
||||
@@ -111,24 +57,24 @@ enum ClawdisChatTheme {
|
||||
#endif
|
||||
}
|
||||
|
||||
static var composerBackground: AnyShapeStyle {
|
||||
static var composerBackground: Color {
|
||||
#if os(macOS)
|
||||
AnyShapeStyle(.ultraThinMaterial)
|
||||
Color(nsColor: .windowBackgroundColor)
|
||||
#else
|
||||
AnyShapeStyle(Color(uiColor: .systemBackground))
|
||||
Color(uiColor: .systemBackground)
|
||||
#endif
|
||||
}
|
||||
|
||||
static var composerField: AnyShapeStyle {
|
||||
static var composerField: Color {
|
||||
#if os(macOS)
|
||||
AnyShapeStyle(.thinMaterial)
|
||||
Color(nsColor: .textBackgroundColor)
|
||||
#else
|
||||
AnyShapeStyle(Color(uiColor: .secondarySystemBackground))
|
||||
Color(uiColor: .secondarySystemBackground)
|
||||
#endif
|
||||
}
|
||||
|
||||
static var composerBorder: Color {
|
||||
Color.white.opacity(0.12)
|
||||
Color.secondary.opacity(0.2)
|
||||
}
|
||||
|
||||
static var divider: Color {
|
||||
|
||||
@@ -10,7 +10,6 @@ public struct ClawdisChatView: View {
|
||||
@State private var viewModel: ClawdisChatViewModel
|
||||
@State private var scrollerBottomID = UUID()
|
||||
@State private var showSessions = false
|
||||
@State private var hasPerformedInitialScroll = false
|
||||
private let showsSessionSwitcher: Bool
|
||||
private let style: Style
|
||||
|
||||
@@ -44,7 +43,7 @@ public struct ClawdisChatView: View {
|
||||
|
||||
public var body: some View {
|
||||
ZStack {
|
||||
ClawdisChatTheme.background
|
||||
ClawdisChatTheme.surface
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: Layout.stackSpacing) {
|
||||
@@ -68,59 +67,44 @@ public struct ClawdisChatView: View {
|
||||
|
||||
private var messageList: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ZStack {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: Layout.messageSpacing) {
|
||||
ForEach(self.visibleMessages) { msg in
|
||||
ChatMessageBubble(message: msg, style: self.style)
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
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)
|
||||
ScrollView {
|
||||
LazyVStack(spacing: Layout.messageSpacing) {
|
||||
ForEach(self.visibleMessages) { msg in
|
||||
ChatMessageBubble(message: msg)
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
alignment: msg.role.lowercased() == "user" ? .trailing : .leading)
|
||||
}
|
||||
.padding(.top, Layout.messageListPaddingTop)
|
||||
.padding(.bottom, Layout.messageListPaddingBottom)
|
||||
.padding(.horizontal, Layout.messageListPaddingHorizontal)
|
||||
}
|
||||
|
||||
if self.viewModel.isLoading {
|
||||
ProgressView()
|
||||
.controlSize(.large)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
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)
|
||||
}
|
||||
}
|
||||
.onChange(of: self.viewModel.isLoading) { _, isLoading in
|
||||
guard !isLoading, !self.hasPerformedInitialScroll else { return }
|
||||
proxy.scrollTo(self.scrollerBottomID, anchor: .bottom)
|
||||
self.hasPerformedInitialScroll = true
|
||||
.padding(.top, Layout.messageListPaddingTop)
|
||||
.padding(.bottom, Layout.messageListPaddingBottom)
|
||||
.padding(.horizontal, Layout.messageListPaddingHorizontal)
|
||||
}
|
||||
.onChange(of: self.viewModel.messages.count) { _, _ in
|
||||
guard self.hasPerformedInitialScroll else { return }
|
||||
withAnimation(.snappy(duration: 0.22)) {
|
||||
proxy.scrollTo(self.scrollerBottomID, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
.onChange(of: self.viewModel.pendingRunCount) { _, _ in
|
||||
guard self.hasPerformedInitialScroll else { return }
|
||||
withAnimation(.snappy(duration: 0.22)) {
|
||||
proxy.scrollTo(self.scrollerBottomID, anchor: .bottom)
|
||||
}
|
||||
|
||||
@@ -1,53 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
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 {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let nonEmpty = trimmed.isEmpty ? "-" : trimmed
|
||||
@@ -66,16 +19,25 @@ public enum ClawdisCanvasA2UIAction: Sendable {
|
||||
return str
|
||||
}
|
||||
|
||||
public static func formatAgentMessage(_ context: AgentMessageContext) -> String {
|
||||
let ctxSuffix = context.contextJSON.flatMap { $0.isEmpty ? nil : " ctx=\($0)" } ?? ""
|
||||
public static func formatAgentMessage(
|
||||
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 [
|
||||
"CANVAS_A2UI",
|
||||
"action=\(self.sanitizeTagValue(context.actionName))",
|
||||
"session=\(self.sanitizeTagValue(context.session.key))",
|
||||
"surface=\(self.sanitizeTagValue(context.session.surfaceId))",
|
||||
"component=\(self.sanitizeTagValue(context.component.id))",
|
||||
"host=\(self.sanitizeTagValue(context.component.host))",
|
||||
"instance=\(self.sanitizeTagValue(context.component.instanceId))\(ctxSuffix)",
|
||||
"action=\(self.sanitizeTagValue(actionName))",
|
||||
"session=\(self.sanitizeTagValue(sessionKey))",
|
||||
"surface=\(self.sanitizeTagValue(surfaceId))",
|
||||
"component=\(self.sanitizeTagValue(sourceComponentId))",
|
||||
"host=\(self.sanitizeTagValue(host))",
|
||||
"instance=\(self.sanitizeTagValue(instanceId))\(ctxSuffix)",
|
||||
"default=update_canvas",
|
||||
].joined(separator: " ")
|
||||
}
|
||||
|
||||
@@ -27,12 +27,7 @@ public enum ClawdisCanvasA2UIJSONL: Sendable {
|
||||
}
|
||||
|
||||
public static func validateV0_8(_ items: [ParsedItem]) throws {
|
||||
let allowed = Set([
|
||||
"beginRendering",
|
||||
"surfaceUpdate",
|
||||
"dataModelUpdate",
|
||||
"deleteSurface",
|
||||
])
|
||||
let allowed = Set(["beginRendering", "surfaceUpdate", "dataModelUpdate", "deleteSurface"])
|
||||
for item in items {
|
||||
guard let dict = item.message.value as? [String: AnyCodable] else {
|
||||
throw NSError(domain: "A2UI", code: 1, userInfo: [
|
||||
@@ -44,8 +39,7 @@ public enum ClawdisCanvasA2UIJSONL: Sendable {
|
||||
throw NSError(domain: "A2UI", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: """
|
||||
A2UI JSONL line \(item.lineNumber): looks like A2UI v0.9 (`createSurface`).
|
||||
Canvas currently supports A2UI v0.8 server→client messages
|
||||
(`beginRendering`, `surfaceUpdate`, `dataModelUpdate`, `deleteSurface`).
|
||||
Canvas currently supports A2UI v0.8 server→client messages (`beginRendering`, `surfaceUpdate`, `dataModelUpdate`, `deleteSurface`).
|
||||
""",
|
||||
])
|
||||
}
|
||||
|
||||
@@ -80,9 +80,8 @@
|
||||
#clawdis-status {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
place-items: start center;
|
||||
padding-top: calc(20px + env(safe-area-inset-top, 0px));
|
||||
display: grid;
|
||||
place-items: center;
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
}
|
||||
@@ -124,18 +123,6 @@
|
||||
const statusEl = document.getElementById('clawdis-status');
|
||||
const titleEl = document.getElementById('clawdis-status-title');
|
||||
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() {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
@@ -149,24 +136,11 @@
|
||||
window.addEventListener('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 = {
|
||||
canvas,
|
||||
ctx,
|
||||
setDebugStatusEnabled,
|
||||
setStatus: (title, subtitle) => {
|
||||
if (!statusEl || !debugStatusEnabled) return;
|
||||
if (!statusEl) return;
|
||||
if (!title && !subtitle) {
|
||||
statusEl.style.display = 'none';
|
||||
return;
|
||||
@@ -174,14 +148,11 @@
|
||||
statusEl.style.display = 'grid';
|
||||
if (titleEl && typeof title === 'string') titleEl.textContent = title;
|
||||
if (subtitleEl && typeof subtitle === 'string') subtitleEl.textContent = subtitle;
|
||||
if (!debugStatusEnabled) {
|
||||
clearTimeout(window.__statusTimeout);
|
||||
window.__statusTimeout = setTimeout(() => {
|
||||
statusEl.style.display = 'none';
|
||||
}, 3000);
|
||||
} else {
|
||||
clearTimeout(window.__statusTimeout);
|
||||
}
|
||||
// Auto-hide after 3 seconds.
|
||||
clearTimeout(window.__statusTimeout);
|
||||
window.__statusTimeout = setTimeout(() => {
|
||||
statusEl.style.display = 'none';
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -9,20 +9,15 @@ import Testing
|
||||
#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() {
|
||||
let messageContext = ClawdisCanvasA2UIAction.AgentMessageContext(
|
||||
let msg = ClawdisCanvasA2UIAction.formatAgentMessage(
|
||||
actionName: "Get Weather",
|
||||
session: .init(key: "main", surfaceId: "main"),
|
||||
component: .init(id: "btnWeather", host: "Peter’s iPad", instanceId: "ipad16,6"),
|
||||
sessionKey: "main",
|
||||
surfaceId: "main",
|
||||
sourceComponentId: "btnWeather",
|
||||
host: "Peter’s iPad",
|
||||
instanceId: "ipad16,6",
|
||||
contextJSON: "{\"city\":\"Vienna\"}")
|
||||
let msg = ClawdisCanvasA2UIAction.formatAgentMessage(messageContext)
|
||||
|
||||
#expect(msg.contains("CANVAS_A2UI "))
|
||||
#expect(msg.contains("action=Get_Weather"))
|
||||
|
||||
+4
-8
@@ -176,11 +176,7 @@ class ClawdisA2UIHost extends LitElement {
|
||||
height: 100%;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
padding:
|
||||
var(--clawdis-a2ui-inset-top, 0px)
|
||||
var(--clawdis-a2ui-inset-right, 0px)
|
||||
var(--clawdis-a2ui-inset-bottom, 0px)
|
||||
var(--clawdis-a2ui-inset-left, 0px);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
#surfaces {
|
||||
@@ -189,14 +185,14 @@ class ClawdisA2UIHost extends LitElement {
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding-bottom: var(--clawdis-a2ui-scroll-pad-bottom, 0px);
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.status {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
top: var(--clawdis-a2ui-status-top, 12px);
|
||||
top: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@@ -217,7 +213,7 @@ class ClawdisA2UIHost extends LitElement {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: var(--clawdis-a2ui-toast-bottom, 12px);
|
||||
bottom: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
+1
-2
@@ -7,8 +7,7 @@
|
||||
},
|
||||
"files": {
|
||||
"includes": [
|
||||
"src/**/*.ts",
|
||||
"test/**/*.ts"
|
||||
"src/**/*.ts"
|
||||
]
|
||||
},
|
||||
"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