mirror of
https://github.com/clawdbot/clawdbot.git
synced 2026-01-31 19:37:45 +01:00
Mac: finish Moltbot rename
This commit is contained in:
@@ -13,7 +13,7 @@ android {
|
|||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
getByName("main") {
|
getByName("main") {
|
||||||
assets.srcDir(file("../../shared/ClawdbotKit/Sources/ClawdbotKit/Resources"))
|
assets.srcDir(file("../../shared/MoltbotKit/Sources/MoltbotKit/Resources"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ open Clawdbot.xcodeproj
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Shared packages
|
## Shared packages
|
||||||
- `../shared/ClawdbotKit` — shared types/constants used by iOS (and later macOS bridge + gateway routing).
|
- `../shared/MoltbotKit` — shared types/constants used by iOS (and later macOS bridge + gateway routing).
|
||||||
|
|
||||||
## fastlane
|
## fastlane
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -24,37 +24,37 @@ Sources/Status/VoiceWakeToast.swift
|
|||||||
Sources/Voice/VoiceTab.swift
|
Sources/Voice/VoiceTab.swift
|
||||||
Sources/Voice/VoiceWakeManager.swift
|
Sources/Voice/VoiceWakeManager.swift
|
||||||
Sources/Voice/VoiceWakePreferences.swift
|
Sources/Voice/VoiceWakePreferences.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatComposer.swift
|
../shared/MoltbotKit/Sources/MoltbotChatUI/ChatComposer.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownRenderer.swift
|
../shared/MoltbotKit/Sources/MoltbotChatUI/ChatMarkdownRenderer.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownPreprocessor.swift
|
../shared/MoltbotKit/Sources/MoltbotChatUI/ChatMarkdownPreprocessor.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMessageViews.swift
|
../shared/MoltbotKit/Sources/MoltbotChatUI/ChatMessageViews.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatModels.swift
|
../shared/MoltbotKit/Sources/MoltbotChatUI/ChatModels.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatPayloadDecoding.swift
|
../shared/MoltbotKit/Sources/MoltbotChatUI/ChatPayloadDecoding.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatSessions.swift
|
../shared/MoltbotKit/Sources/MoltbotChatUI/ChatSessions.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatSheets.swift
|
../shared/MoltbotKit/Sources/MoltbotChatUI/ChatSheets.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatTheme.swift
|
../shared/MoltbotKit/Sources/MoltbotChatUI/ChatTheme.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatTransport.swift
|
../shared/MoltbotKit/Sources/MoltbotChatUI/ChatTransport.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatView.swift
|
../shared/MoltbotKit/Sources/MoltbotChatUI/ChatView.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatViewModel.swift
|
../shared/MoltbotKit/Sources/MoltbotChatUI/ChatViewModel.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotKit/AnyCodable.swift
|
../shared/MoltbotKit/Sources/MoltbotKit/AnyCodable.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotKit/BonjourEscapes.swift
|
../shared/MoltbotKit/Sources/MoltbotKit/BonjourEscapes.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotKit/BonjourTypes.swift
|
../shared/MoltbotKit/Sources/MoltbotKit/BonjourTypes.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotKit/BridgeFrames.swift
|
../shared/MoltbotKit/Sources/MoltbotKit/BridgeFrames.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotKit/CameraCommands.swift
|
../shared/MoltbotKit/Sources/MoltbotKit/CameraCommands.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotKit/CanvasA2UIAction.swift
|
../shared/MoltbotKit/Sources/MoltbotKit/CanvasA2UIAction.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotKit/CanvasA2UICommands.swift
|
../shared/MoltbotKit/Sources/MoltbotKit/CanvasA2UICommands.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotKit/CanvasA2UIJSONL.swift
|
../shared/MoltbotKit/Sources/MoltbotKit/CanvasA2UIJSONL.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotKit/CanvasCommandParams.swift
|
../shared/MoltbotKit/Sources/MoltbotKit/CanvasCommandParams.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotKit/CanvasCommands.swift
|
../shared/MoltbotKit/Sources/MoltbotKit/CanvasCommands.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotKit/Capabilities.swift
|
../shared/MoltbotKit/Sources/MoltbotKit/Capabilities.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotKit/ClawdbotKitResources.swift
|
../shared/MoltbotKit/Sources/MoltbotKit/ClawdbotKitResources.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotKit/DeepLinks.swift
|
../shared/MoltbotKit/Sources/MoltbotKit/DeepLinks.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotKit/JPEGTranscoder.swift
|
../shared/MoltbotKit/Sources/MoltbotKit/JPEGTranscoder.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotKit/NodeError.swift
|
../shared/MoltbotKit/Sources/MoltbotKit/NodeError.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotKit/ScreenCommands.swift
|
../shared/MoltbotKit/Sources/MoltbotKit/ScreenCommands.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotKit/StoragePaths.swift
|
../shared/MoltbotKit/Sources/MoltbotKit/StoragePaths.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotKit/SystemCommands.swift
|
../shared/MoltbotKit/Sources/MoltbotKit/SystemCommands.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotKit/TalkDirective.swift
|
../shared/MoltbotKit/Sources/MoltbotKit/TalkDirective.swift
|
||||||
../../Swabble/Sources/SwabbleKit/WakeWordGate.swift
|
../../Swabble/Sources/SwabbleKit/WakeWordGate.swift
|
||||||
Sources/Voice/TalkModeManager.swift
|
Sources/Voice/TalkModeManager.swift
|
||||||
Sources/Voice/TalkOrbOverlay.swift
|
Sources/Voice/TalkOrbOverlay.swift
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ settings:
|
|||||||
|
|
||||||
packages:
|
packages:
|
||||||
MoltbotKit:
|
MoltbotKit:
|
||||||
path: ../shared/ClawdbotKit
|
path: ../shared/MoltbotKit
|
||||||
Swabble:
|
Swabble:
|
||||||
path: ../../Swabble
|
path: ../../Swabble
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ let package = Package(
|
|||||||
.package(url: "https://github.com/apple/swift-log.git", from: "1.8.0"),
|
.package(url: "https://github.com/apple/swift-log.git", from: "1.8.0"),
|
||||||
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.8.1"),
|
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.8.1"),
|
||||||
.package(url: "https://github.com/steipete/Peekaboo.git", branch: "main"),
|
.package(url: "https://github.com/steipete/Peekaboo.git", branch: "main"),
|
||||||
.package(path: "../shared/ClawdbotKit"),
|
.package(path: "../shared/MoltbotKit"),
|
||||||
.package(path: "../../Swabble"),
|
.package(path: "../../Swabble"),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Clawdbot macOS app (dev + signing)
|
# Moltbot macOS app (dev + signing)
|
||||||
|
|
||||||
## Quick dev run
|
## Quick dev run
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ scripts/restart-mac.sh --sign # force code signing (requires cert)
|
|||||||
scripts/package-mac-app.sh
|
scripts/package-mac-app.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Creates `dist/Clawdbot.app` and signs it via `scripts/codesign-mac-app.sh`.
|
Creates `dist/Moltbot.app` and signs it via `scripts/codesign-mac-app.sh`.
|
||||||
|
|
||||||
## Signing behavior
|
## Signing behavior
|
||||||
|
|
||||||
|
|||||||
340
apps/macos/Sources/Moltbot/AgentWorkspace.swift
Normal file
340
apps/macos/Sources/Moltbot/AgentWorkspace.swift
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
enum AgentWorkspace {
|
||||||
|
private static let logger = Logger(subsystem: "bot.molt", category: "workspace")
|
||||||
|
static let agentsFilename = "AGENTS.md"
|
||||||
|
static let soulFilename = "SOUL.md"
|
||||||
|
static let identityFilename = "IDENTITY.md"
|
||||||
|
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 func displayPath(for url: URL) -> String {
|
||||||
|
let home = FileManager().homeDirectoryForCurrentUser.path
|
||||||
|
let path = url.path
|
||||||
|
if path == home { return "~" }
|
||||||
|
if path.hasPrefix(home + "/") {
|
||||||
|
return "~/" + String(path.dropFirst(home.count + 1))
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
static func resolveWorkspaceURL(from userInput: String?) -> URL {
|
||||||
|
let trimmed = userInput?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if trimmed.isEmpty { return MoltbotConfigFile.defaultWorkspaceURL() }
|
||||||
|
let expanded = (trimmed as NSString).expandingTildeInPath
|
||||||
|
return URL(fileURLWithPath: expanded, isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func agentsURL(workspaceURL: URL) -> URL {
|
||||||
|
workspaceURL.appendingPathComponent(self.agentsFilename)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func workspaceEntries(workspaceURL: URL) throws -> [String] {
|
||||||
|
let contents = try FileManager().contentsOfDirectory(atPath: workspaceURL.path)
|
||||||
|
return contents.filter { !self.ignoredEntries.contains($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isWorkspaceEmpty(workspaceURL: URL) -> Bool {
|
||||||
|
let fm = FileManager()
|
||||||
|
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()
|
||||||
|
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().createDirectory(at: workspaceURL, withIntermediateDirectories: true)
|
||||||
|
let agentsURL = self.agentsURL(workspaceURL: workspaceURL)
|
||||||
|
if !FileManager().fileExists(atPath: agentsURL.path) {
|
||||||
|
try self.defaultTemplate().write(to: agentsURL, atomically: true, encoding: .utf8)
|
||||||
|
self.logger.info("Created AGENTS.md at \(agentsURL.path, privacy: .public)")
|
||||||
|
}
|
||||||
|
let soulURL = workspaceURL.appendingPathComponent(self.soulFilename)
|
||||||
|
if !FileManager().fileExists(atPath: soulURL.path) {
|
||||||
|
try self.defaultSoulTemplate().write(to: soulURL, atomically: true, encoding: .utf8)
|
||||||
|
self.logger.info("Created SOUL.md at \(soulURL.path, privacy: .public)")
|
||||||
|
}
|
||||||
|
let identityURL = workspaceURL.appendingPathComponent(self.identityFilename)
|
||||||
|
if !FileManager().fileExists(atPath: identityURL.path) {
|
||||||
|
try self.defaultIdentityTemplate().write(to: identityURL, atomically: true, encoding: .utf8)
|
||||||
|
self.logger.info("Created IDENTITY.md at \(identityURL.path, privacy: .public)")
|
||||||
|
}
|
||||||
|
let userURL = workspaceURL.appendingPathComponent(self.userFilename)
|
||||||
|
if !FileManager().fileExists(atPath: userURL.path) {
|
||||||
|
try self.defaultUserTemplate().write(to: userURL, atomically: true, encoding: .utf8)
|
||||||
|
self.logger.info("Created USER.md at \(userURL.path, privacy: .public)")
|
||||||
|
}
|
||||||
|
let bootstrapURL = workspaceURL.appendingPathComponent(self.bootstrapFilename)
|
||||||
|
if shouldSeedBootstrap, !FileManager().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()
|
||||||
|
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 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
static func defaultTemplate() -> String {
|
||||||
|
let fallback = """
|
||||||
|
# AGENTS.md - Moltbot Workspace
|
||||||
|
|
||||||
|
This folder is the assistant's working directory.
|
||||||
|
|
||||||
|
## First run (one-time)
|
||||||
|
- If BOOTSTRAP.md exists, follow its ritual and delete it once complete.
|
||||||
|
- Your agent identity lives in IDENTITY.md.
|
||||||
|
- Your profile lives in USER.md.
|
||||||
|
|
||||||
|
## Backup tip (recommended)
|
||||||
|
If you treat this workspace as the agent's "memory", make it a git repo (ideally private) so identity
|
||||||
|
and notes are backed up.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git init
|
||||||
|
git add AGENTS.md
|
||||||
|
git commit -m "Add agent workspace"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Safety defaults
|
||||||
|
- Don't exfiltrate secrets or private data.
|
||||||
|
- Don't run destructive commands unless explicitly asked.
|
||||||
|
- Be concise in chat; write longer output to files in this workspace.
|
||||||
|
|
||||||
|
## Daily memory (recommended)
|
||||||
|
- Keep a short daily log at memory/YYYY-MM-DD.md (create memory/ if needed).
|
||||||
|
- On session start, read today + yesterday if present.
|
||||||
|
- Capture durable facts, preferences, and decisions; avoid secrets.
|
||||||
|
|
||||||
|
## Customize
|
||||||
|
- Add your preferred style, rules, and "memory" here.
|
||||||
|
"""
|
||||||
|
return self.loadTemplate(named: self.agentsFilename, fallback: fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func defaultSoulTemplate() -> String {
|
||||||
|
let fallback = """
|
||||||
|
# SOUL.md - Persona & Boundaries
|
||||||
|
|
||||||
|
Describe who the assistant is, tone, and boundaries.
|
||||||
|
|
||||||
|
- Keep replies concise and direct.
|
||||||
|
- Ask clarifying questions when needed.
|
||||||
|
- Never send streaming/partial replies to external messaging surfaces.
|
||||||
|
"""
|
||||||
|
return self.loadTemplate(named: self.soulFilename, fallback: fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func defaultIdentityTemplate() -> String {
|
||||||
|
let fallback = """
|
||||||
|
# IDENTITY.md - Agent Identity
|
||||||
|
|
||||||
|
- Name:
|
||||||
|
- Creature:
|
||||||
|
- Vibe:
|
||||||
|
- Emoji:
|
||||||
|
"""
|
||||||
|
return self.loadTemplate(named: self.identityFilename, fallback: fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func defaultUserTemplate() -> String {
|
||||||
|
let fallback = """
|
||||||
|
# USER.md - User Profile
|
||||||
|
|
||||||
|
- Name:
|
||||||
|
- Preferred address:
|
||||||
|
- Pronouns (optional):
|
||||||
|
- Timezone (optional):
|
||||||
|
- Notes:
|
||||||
|
"""
|
||||||
|
return self.loadTemplate(named: self.userFilename, fallback: fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func defaultBootstrapTemplate() -> String {
|
||||||
|
let fallback = """
|
||||||
|
# BOOTSTRAP.md - First Run Ritual (delete after)
|
||||||
|
|
||||||
|
Hello. I was just born.
|
||||||
|
|
||||||
|
## Your mission
|
||||||
|
Start a short, playful conversation and learn:
|
||||||
|
- Who am I?
|
||||||
|
- What am I?
|
||||||
|
- Who are you?
|
||||||
|
- How should I call you?
|
||||||
|
|
||||||
|
## How to ask (cute + helpful)
|
||||||
|
Say:
|
||||||
|
"Hello! I was just born. Who am I? What am I? Who are you? How should I call you?"
|
||||||
|
|
||||||
|
Then offer suggestions:
|
||||||
|
- 3-5 name ideas.
|
||||||
|
- 3-5 creature/vibe combos.
|
||||||
|
- 5 emoji ideas.
|
||||||
|
|
||||||
|
## Write these files
|
||||||
|
After the user chooses, update:
|
||||||
|
|
||||||
|
1) IDENTITY.md
|
||||||
|
- Name
|
||||||
|
- Creature
|
||||||
|
- Vibe
|
||||||
|
- Emoji
|
||||||
|
|
||||||
|
2) USER.md
|
||||||
|
- Name
|
||||||
|
- Preferred address
|
||||||
|
- Pronouns (optional)
|
||||||
|
- Timezone (optional)
|
||||||
|
- Notes
|
||||||
|
|
||||||
|
3) ~/.clawdbot/moltbot.json
|
||||||
|
Set identity.name, identity.theme, identity.emoji to match IDENTITY.md.
|
||||||
|
|
||||||
|
## Cleanup
|
||||||
|
Delete BOOTSTRAP.md once this is complete.
|
||||||
|
"""
|
||||||
|
return self.loadTemplate(named: self.bootstrapFilename, fallback: fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func loadTemplate(named: String, fallback: String) -> String {
|
||||||
|
for url in self.templateURLs(named: named) {
|
||||||
|
if let content = try? String(contentsOf: url, encoding: .utf8) {
|
||||||
|
let stripped = self.stripFrontMatter(content)
|
||||||
|
if !stripped.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
return stripped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func templateURLs(named: String) -> [URL] {
|
||||||
|
var urls: [URL] = []
|
||||||
|
if let resource = Bundle.main.url(
|
||||||
|
forResource: named.replacingOccurrences(of: ".md", with: ""),
|
||||||
|
withExtension: "md",
|
||||||
|
subdirectory: self.templateDirname)
|
||||||
|
{
|
||||||
|
urls.append(resource)
|
||||||
|
}
|
||||||
|
if let resource = Bundle.main.url(
|
||||||
|
forResource: named,
|
||||||
|
withExtension: nil,
|
||||||
|
subdirectory: self.templateDirname)
|
||||||
|
{
|
||||||
|
urls.append(resource)
|
||||||
|
}
|
||||||
|
if let dev = self.devTemplateURL(named: named) {
|
||||||
|
urls.append(dev)
|
||||||
|
}
|
||||||
|
let cwd = URL(fileURLWithPath: FileManager().currentDirectoryPath)
|
||||||
|
urls.append(cwd.appendingPathComponent("docs")
|
||||||
|
.appendingPathComponent(self.templateDirname)
|
||||||
|
.appendingPathComponent(named))
|
||||||
|
return urls
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func devTemplateURL(named: String) -> URL? {
|
||||||
|
let sourceURL = URL(fileURLWithPath: #filePath)
|
||||||
|
let repoRoot = sourceURL
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
return repoRoot.appendingPathComponent("docs")
|
||||||
|
.appendingPathComponent(self.templateDirname)
|
||||||
|
.appendingPathComponent(named)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func stripFrontMatter(_ content: String) -> String {
|
||||||
|
guard content.hasPrefix("---") else { return content }
|
||||||
|
let start = content.index(content.startIndex, offsetBy: 3)
|
||||||
|
guard let range = content.range(of: "\n---", range: start..<content.endIndex) else {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
let remainder = content[range.upperBound...]
|
||||||
|
let trimmed = remainder.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return trimmed + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identity is written by the agent during the bootstrap ritual.
|
||||||
|
}
|
||||||
384
apps/macos/Sources/Moltbot/AnthropicOAuth.swift
Normal file
384
apps/macos/Sources/Moltbot/AnthropicOAuth.swift
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
import Security
|
||||||
|
|
||||||
|
struct AnthropicOAuthCredentials: Codable {
|
||||||
|
let type: String
|
||||||
|
let refresh: String
|
||||||
|
let access: String
|
||||||
|
let expires: Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AnthropicAuthMode: Equatable {
|
||||||
|
case oauthFile
|
||||||
|
case oauthEnv
|
||||||
|
case apiKeyEnv
|
||||||
|
case missing
|
||||||
|
|
||||||
|
var shortLabel: String {
|
||||||
|
switch self {
|
||||||
|
case .oauthFile: "OAuth (Moltbot token file)"
|
||||||
|
case .oauthEnv: "OAuth (env var)"
|
||||||
|
case .apiKeyEnv: "API key (env var)"
|
||||||
|
case .missing: "Missing credentials"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var isConfigured: Bool {
|
||||||
|
switch self {
|
||||||
|
case .missing: false
|
||||||
|
case .oauthFile, .oauthEnv, .apiKeyEnv: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AnthropicAuthResolver {
|
||||||
|
static func resolve(
|
||||||
|
environment: [String: String] = ProcessInfo.processInfo.environment,
|
||||||
|
oauthStatus: MoltbotOAuthStore.AnthropicOAuthStatus = MoltbotOAuthStore
|
||||||
|
.anthropicOAuthStatus()) -> AnthropicAuthMode
|
||||||
|
{
|
||||||
|
if oauthStatus.isConnected { return .oauthFile }
|
||||||
|
|
||||||
|
if let token = environment["ANTHROPIC_OAUTH_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!token.isEmpty
|
||||||
|
{
|
||||||
|
return .oauthEnv
|
||||||
|
}
|
||||||
|
|
||||||
|
if let key = environment["ANTHROPIC_API_KEY"]?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!key.isEmpty
|
||||||
|
{
|
||||||
|
return .apiKeyEnv
|
||||||
|
}
|
||||||
|
|
||||||
|
return .missing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AnthropicOAuth {
|
||||||
|
private static let logger = Logger(subsystem: "bot.molt", category: "anthropic-oauth")
|
||||||
|
|
||||||
|
private static let clientId = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||||
|
private static let authorizeURL = URL(string: "https://claude.ai/oauth/authorize")!
|
||||||
|
private static let tokenURL = URL(string: "https://console.anthropic.com/v1/oauth/token")!
|
||||||
|
private static let redirectURI = "https://console.anthropic.com/oauth/code/callback"
|
||||||
|
private static let scopes = "org:create_api_key user:profile user:inference"
|
||||||
|
|
||||||
|
struct PKCE {
|
||||||
|
let verifier: String
|
||||||
|
let challenge: String
|
||||||
|
}
|
||||||
|
|
||||||
|
static func generatePKCE() throws -> PKCE {
|
||||||
|
var bytes = [UInt8](repeating: 0, count: 32)
|
||||||
|
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||||
|
guard status == errSecSuccess else {
|
||||||
|
throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
|
||||||
|
}
|
||||||
|
let verifier = Data(bytes).base64URLEncodedString()
|
||||||
|
let hash = SHA256.hash(data: Data(verifier.utf8))
|
||||||
|
let challenge = Data(hash).base64URLEncodedString()
|
||||||
|
return PKCE(verifier: verifier, challenge: challenge)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func buildAuthorizeURL(pkce: PKCE) -> URL {
|
||||||
|
var components = URLComponents(url: self.authorizeURL, resolvingAgainstBaseURL: false)!
|
||||||
|
components.queryItems = [
|
||||||
|
URLQueryItem(name: "code", value: "true"),
|
||||||
|
URLQueryItem(name: "client_id", value: self.clientId),
|
||||||
|
URLQueryItem(name: "response_type", value: "code"),
|
||||||
|
URLQueryItem(name: "redirect_uri", value: self.redirectURI),
|
||||||
|
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.
|
||||||
|
URLQueryItem(name: "state", value: pkce.verifier),
|
||||||
|
]
|
||||||
|
return components.url!
|
||||||
|
}
|
||||||
|
|
||||||
|
static func exchangeCode(
|
||||||
|
code: String,
|
||||||
|
state: String,
|
||||||
|
verifier: String) async throws -> AnthropicOAuthCredentials
|
||||||
|
{
|
||||||
|
let payload: [String: Any] = [
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"client_id": self.clientId,
|
||||||
|
"code": code,
|
||||||
|
"state": state,
|
||||||
|
"redirect_uri": self.redirectURI,
|
||||||
|
"code_verifier": verifier,
|
||||||
|
]
|
||||||
|
let body = try JSONSerialization.data(withJSONObject: payload, options: [])
|
||||||
|
|
||||||
|
var request = URLRequest(url: self.tokenURL)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.httpBody = body
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
guard let http = response as? HTTPURLResponse else {
|
||||||
|
throw URLError(.badServerResponse)
|
||||||
|
}
|
||||||
|
guard (200..<300).contains(http.statusCode) else {
|
||||||
|
let text = String(data: data, encoding: .utf8) ?? "<non-utf8>"
|
||||||
|
throw NSError(
|
||||||
|
domain: "AnthropicOAuth",
|
||||||
|
code: http.statusCode,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Token exchange failed: \(text)"])
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||||
|
let access = decoded?["access_token"] as? String
|
||||||
|
let refresh = decoded?["refresh_token"] as? String
|
||||||
|
let expiresIn = decoded?["expires_in"] as? Double
|
||||||
|
guard let access, let refresh, let expiresIn else {
|
||||||
|
throw NSError(domain: "AnthropicOAuth", code: 0, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "Unexpected token response.",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match legacy flow: expiresAt = now + expires_in - 5 minutes.
|
||||||
|
let expiresAtMs = Int64(Date().timeIntervalSince1970 * 1000)
|
||||||
|
+ Int64(expiresIn * 1000)
|
||||||
|
- Int64(5 * 60 * 1000)
|
||||||
|
|
||||||
|
self.logger.info("Anthropic OAuth exchange ok; expiresAtMs=\(expiresAtMs, privacy: .public)")
|
||||||
|
return AnthropicOAuthCredentials(type: "oauth", refresh: refresh, access: access, expires: expiresAtMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func refresh(refreshToken: String) async throws -> AnthropicOAuthCredentials {
|
||||||
|
let payload: [String: Any] = [
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"client_id": self.clientId,
|
||||||
|
"refresh_token": refreshToken,
|
||||||
|
]
|
||||||
|
let body = try JSONSerialization.data(withJSONObject: payload, options: [])
|
||||||
|
|
||||||
|
var request = URLRequest(url: self.tokenURL)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.httpBody = body
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
guard let http = response as? HTTPURLResponse else {
|
||||||
|
throw URLError(.badServerResponse)
|
||||||
|
}
|
||||||
|
guard (200..<300).contains(http.statusCode) else {
|
||||||
|
let text = String(data: data, encoding: .utf8) ?? "<non-utf8>"
|
||||||
|
throw NSError(
|
||||||
|
domain: "AnthropicOAuth",
|
||||||
|
code: http.statusCode,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Token refresh failed: \(text)"])
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||||
|
let access = decoded?["access_token"] as? String
|
||||||
|
let refresh = (decoded?["refresh_token"] as? String) ?? refreshToken
|
||||||
|
let expiresIn = decoded?["expires_in"] as? Double
|
||||||
|
guard let access, let expiresIn else {
|
||||||
|
throw NSError(domain: "AnthropicOAuth", code: 0, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "Unexpected token response.",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
let expiresAtMs = Int64(Date().timeIntervalSince1970 * 1000)
|
||||||
|
+ Int64(expiresIn * 1000)
|
||||||
|
- Int64(5 * 60 * 1000)
|
||||||
|
|
||||||
|
self.logger.info("Anthropic OAuth refresh ok; expiresAtMs=\(expiresAtMs, privacy: .public)")
|
||||||
|
return AnthropicOAuthCredentials(type: "oauth", refresh: refresh, access: access, expires: expiresAtMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MoltbotOAuthStore {
|
||||||
|
static let oauthFilename = "oauth.json"
|
||||||
|
private static let providerKey = "anthropic"
|
||||||
|
private static let moltbotOAuthDirEnv = "CLAWDBOT_OAUTH_DIR"
|
||||||
|
private static let legacyPiDirEnv = "PI_CODING_AGENT_DIR"
|
||||||
|
|
||||||
|
enum AnthropicOAuthStatus: Equatable {
|
||||||
|
case missingFile
|
||||||
|
case unreadableFile
|
||||||
|
case invalidJSON
|
||||||
|
case missingProviderEntry
|
||||||
|
case missingTokens
|
||||||
|
case connected(expiresAtMs: Int64?)
|
||||||
|
|
||||||
|
var isConnected: Bool {
|
||||||
|
if case .connected = self { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var shortDescription: String {
|
||||||
|
switch self {
|
||||||
|
case .missingFile: "Moltbot OAuth token file not found"
|
||||||
|
case .unreadableFile: "Moltbot OAuth token file not readable"
|
||||||
|
case .invalidJSON: "Moltbot OAuth token file invalid"
|
||||||
|
case .missingProviderEntry: "No Anthropic entry in Moltbot OAuth token file"
|
||||||
|
case .missingTokens: "Anthropic entry missing tokens"
|
||||||
|
case .connected: "Moltbot OAuth credentials found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func oauthDir() -> URL {
|
||||||
|
if let override = ProcessInfo.processInfo.environment[self.clawdbotOAuthDirEnv]?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!override.isEmpty
|
||||||
|
{
|
||||||
|
let expanded = NSString(string: override).expandingTildeInPath
|
||||||
|
return URL(fileURLWithPath: expanded, isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return FileManager().homeDirectoryForCurrentUser
|
||||||
|
.appendingPathComponent(".clawdbot", isDirectory: true)
|
||||||
|
.appendingPathComponent("credentials", 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().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().fileExists(atPath: dest.path) else { return nil }
|
||||||
|
|
||||||
|
for url in self.legacyOAuthURLs() {
|
||||||
|
guard FileManager().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())
|
||||||
|
}
|
||||||
|
|
||||||
|
static func hasAnthropicOAuth() -> Bool {
|
||||||
|
self.anthropicOAuthStatus().isConnected
|
||||||
|
}
|
||||||
|
|
||||||
|
static func anthropicOAuthStatus(at url: URL) -> AnthropicOAuthStatus {
|
||||||
|
guard FileManager().fileExists(atPath: url.path) else { return .missingFile }
|
||||||
|
|
||||||
|
guard let data = try? Data(contentsOf: url) else { return .unreadableFile }
|
||||||
|
guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return .invalidJSON }
|
||||||
|
guard let storage = json as? [String: Any] else { return .invalidJSON }
|
||||||
|
guard let rawEntry = storage[self.providerKey] else { return .missingProviderEntry }
|
||||||
|
guard let entry = rawEntry as? [String: Any] else { return .invalidJSON }
|
||||||
|
|
||||||
|
let refresh = self.firstString(in: entry, keys: ["refresh", "refresh_token", "refreshToken"])
|
||||||
|
let access = self.firstString(in: entry, keys: ["access", "access_token", "accessToken"])
|
||||||
|
guard refresh?.isEmpty == false, access?.isEmpty == false else { return .missingTokens }
|
||||||
|
|
||||||
|
let expiresAny = entry["expires"] ?? entry["expires_at"] ?? entry["expiresAt"]
|
||||||
|
let expiresAtMs: Int64? = if let ms = expiresAny as? Int64 {
|
||||||
|
ms
|
||||||
|
} else if let number = expiresAny as? NSNumber {
|
||||||
|
number.int64Value
|
||||||
|
} else if let ms = expiresAny as? Double {
|
||||||
|
Int64(ms)
|
||||||
|
} else {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return .connected(expiresAtMs: expiresAtMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadAnthropicOAuthRefreshToken() -> String? {
|
||||||
|
let url = self.oauthURL()
|
||||||
|
guard let storage = self.loadStorage(at: url) else { return nil }
|
||||||
|
guard let rawEntry = storage[self.providerKey] as? [String: Any] else { return nil }
|
||||||
|
let refresh = self.firstString(in: rawEntry, keys: ["refresh", "refresh_token", "refreshToken"])
|
||||||
|
return refresh?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func firstString(in dict: [String: Any], keys: [String]) -> String? {
|
||||||
|
for key in keys {
|
||||||
|
if let value = dict[key] as? String { return value }
|
||||||
|
}
|
||||||
|
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) ?? [:]
|
||||||
|
|
||||||
|
var updated = existing
|
||||||
|
updated[self.providerKey] = [
|
||||||
|
"type": creds.type,
|
||||||
|
"refresh": creds.refresh,
|
||||||
|
"access": creds.access,
|
||||||
|
"expires": creds.expires,
|
||||||
|
]
|
||||||
|
|
||||||
|
try self.saveStorage(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func saveStorage(_ storage: [String: Any]) throws {
|
||||||
|
let dir = self.oauthDir()
|
||||||
|
try FileManager().createDirectory(
|
||||||
|
at: dir,
|
||||||
|
withIntermediateDirectories: true,
|
||||||
|
attributes: [.posixPermissions: 0o700])
|
||||||
|
|
||||||
|
let url = self.oauthURL()
|
||||||
|
let data = try JSONSerialization.data(
|
||||||
|
withJSONObject: storage,
|
||||||
|
options: [.prettyPrinted, .sortedKeys])
|
||||||
|
try data.write(to: url, options: [.atomic])
|
||||||
|
try FileManager().setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Data {
|
||||||
|
fileprivate func base64URLEncodedString() -> String {
|
||||||
|
self.base64EncodedString()
|
||||||
|
.replacingOccurrences(of: "+", with: "-")
|
||||||
|
.replacingOccurrences(of: "/", with: "_")
|
||||||
|
.replacingOccurrences(of: "=", with: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
216
apps/macos/Sources/Moltbot/AudioInputDeviceObserver.swift
Normal file
216
apps/macos/Sources/Moltbot/AudioInputDeviceObserver.swift
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import CoreAudio
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
final class AudioInputDeviceObserver {
|
||||||
|
private let logger = Logger(subsystem: "bot.molt", category: "audio.devices")
|
||||||
|
private var isActive = false
|
||||||
|
private var devicesListener: AudioObjectPropertyListenerBlock?
|
||||||
|
private var defaultInputListener: AudioObjectPropertyListenerBlock?
|
||||||
|
|
||||||
|
static func defaultInputDeviceUID() -> String? {
|
||||||
|
let systemObject = AudioObjectID(kAudioObjectSystemObject)
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwarePropertyDefaultInputDevice,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var deviceID = AudioObjectID(0)
|
||||||
|
var size = UInt32(MemoryLayout<AudioObjectID>.size)
|
||||||
|
let status = AudioObjectGetPropertyData(
|
||||||
|
systemObject,
|
||||||
|
&address,
|
||||||
|
0,
|
||||||
|
nil,
|
||||||
|
&size,
|
||||||
|
&deviceID)
|
||||||
|
guard status == noErr, deviceID != 0 else { return nil }
|
||||||
|
return self.deviceUID(for: deviceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func aliveInputDeviceUIDs() -> Set<String> {
|
||||||
|
let systemObject = AudioObjectID(kAudioObjectSystemObject)
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwarePropertyDevices,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var size: UInt32 = 0
|
||||||
|
var status = AudioObjectGetPropertyDataSize(systemObject, &address, 0, nil, &size)
|
||||||
|
guard status == noErr, size > 0 else { return [] }
|
||||||
|
|
||||||
|
let count = Int(size) / MemoryLayout<AudioObjectID>.size
|
||||||
|
var deviceIDs = [AudioObjectID](repeating: 0, count: count)
|
||||||
|
status = AudioObjectGetPropertyData(systemObject, &address, 0, nil, &size, &deviceIDs)
|
||||||
|
guard status == noErr else { return [] }
|
||||||
|
|
||||||
|
var output = Set<String>()
|
||||||
|
for deviceID in deviceIDs {
|
||||||
|
guard self.deviceIsAlive(deviceID) else { continue }
|
||||||
|
guard self.deviceHasInput(deviceID) else { continue }
|
||||||
|
if let uid = self.deviceUID(for: deviceID) {
|
||||||
|
output.insert(uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
static func defaultInputDeviceSummary() -> String {
|
||||||
|
let systemObject = AudioObjectID(kAudioObjectSystemObject)
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwarePropertyDefaultInputDevice,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var deviceID = AudioObjectID(0)
|
||||||
|
var size = UInt32(MemoryLayout<AudioObjectID>.size)
|
||||||
|
let status = AudioObjectGetPropertyData(
|
||||||
|
systemObject,
|
||||||
|
&address,
|
||||||
|
0,
|
||||||
|
nil,
|
||||||
|
&size,
|
||||||
|
&deviceID)
|
||||||
|
guard status == noErr, deviceID != 0 else {
|
||||||
|
return "defaultInput=unknown"
|
||||||
|
}
|
||||||
|
let uid = self.deviceUID(for: deviceID) ?? "unknown"
|
||||||
|
let name = self.deviceName(for: deviceID) ?? "unknown"
|
||||||
|
return "defaultInput=\(name) (\(uid))"
|
||||||
|
}
|
||||||
|
|
||||||
|
func start(onChange: @escaping @Sendable () -> Void) {
|
||||||
|
guard !self.isActive else { return }
|
||||||
|
self.isActive = true
|
||||||
|
|
||||||
|
let systemObject = AudioObjectID(kAudioObjectSystemObject)
|
||||||
|
let queue = DispatchQueue.main
|
||||||
|
|
||||||
|
var devicesAddress = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwarePropertyDevices,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
let devicesListener: AudioObjectPropertyListenerBlock = { _, _ in
|
||||||
|
self.logDefaultInputChange(reason: "devices")
|
||||||
|
onChange()
|
||||||
|
}
|
||||||
|
let devicesStatus = AudioObjectAddPropertyListenerBlock(
|
||||||
|
systemObject,
|
||||||
|
&devicesAddress,
|
||||||
|
queue,
|
||||||
|
devicesListener)
|
||||||
|
|
||||||
|
var defaultInputAddress = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwarePropertyDefaultInputDevice,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
let defaultInputListener: AudioObjectPropertyListenerBlock = { _, _ in
|
||||||
|
self.logDefaultInputChange(reason: "default")
|
||||||
|
onChange()
|
||||||
|
}
|
||||||
|
let defaultStatus = AudioObjectAddPropertyListenerBlock(
|
||||||
|
systemObject,
|
||||||
|
&defaultInputAddress,
|
||||||
|
queue,
|
||||||
|
defaultInputListener)
|
||||||
|
|
||||||
|
if devicesStatus != noErr || defaultStatus != noErr {
|
||||||
|
self.logger.error("audio device observer install failed devices=\(devicesStatus) default=\(defaultStatus)")
|
||||||
|
}
|
||||||
|
|
||||||
|
self.logger.info("audio device observer started (\(Self.defaultInputDeviceSummary(), privacy: .public))")
|
||||||
|
|
||||||
|
self.devicesListener = devicesListener
|
||||||
|
self.defaultInputListener = defaultInputListener
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
guard self.isActive else { return }
|
||||||
|
self.isActive = false
|
||||||
|
let systemObject = AudioObjectID(kAudioObjectSystemObject)
|
||||||
|
|
||||||
|
if let devicesListener {
|
||||||
|
var devicesAddress = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwarePropertyDevices,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
_ = AudioObjectRemovePropertyListenerBlock(
|
||||||
|
systemObject,
|
||||||
|
&devicesAddress,
|
||||||
|
DispatchQueue.main,
|
||||||
|
devicesListener)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let defaultInputListener {
|
||||||
|
var defaultInputAddress = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwarePropertyDefaultInputDevice,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
_ = AudioObjectRemovePropertyListenerBlock(
|
||||||
|
systemObject,
|
||||||
|
&defaultInputAddress,
|
||||||
|
DispatchQueue.main,
|
||||||
|
defaultInputListener)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.devicesListener = nil
|
||||||
|
self.defaultInputListener = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func deviceUID(for deviceID: AudioObjectID) -> String? {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioDevicePropertyDeviceUID,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var uid: Unmanaged<CFString>?
|
||||||
|
var size = UInt32(MemoryLayout<Unmanaged<CFString>?>.size)
|
||||||
|
let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &uid)
|
||||||
|
guard status == noErr, let uid else { return nil }
|
||||||
|
return uid.takeUnretainedValue() as String
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func deviceName(for deviceID: AudioObjectID) -> String? {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioObjectPropertyName,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var name: Unmanaged<CFString>?
|
||||||
|
var size = UInt32(MemoryLayout<Unmanaged<CFString>?>.size)
|
||||||
|
let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &name)
|
||||||
|
guard status == noErr, let name else { return nil }
|
||||||
|
return name.takeUnretainedValue() as String
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func deviceIsAlive(_ deviceID: AudioObjectID) -> Bool {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioDevicePropertyDeviceIsAlive,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var alive: UInt32 = 0
|
||||||
|
var size = UInt32(MemoryLayout<UInt32>.size)
|
||||||
|
let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &alive)
|
||||||
|
return status == noErr && alive != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func deviceHasInput(_ deviceID: AudioObjectID) -> Bool {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioDevicePropertyStreamConfiguration,
|
||||||
|
mScope: kAudioDevicePropertyScopeInput,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var size: UInt32 = 0
|
||||||
|
var status = AudioObjectGetPropertyDataSize(deviceID, &address, 0, nil, &size)
|
||||||
|
guard status == noErr, size > 0 else { return false }
|
||||||
|
|
||||||
|
let raw = UnsafeMutableRawPointer.allocate(
|
||||||
|
byteCount: Int(size),
|
||||||
|
alignment: MemoryLayout<AudioBufferList>.alignment)
|
||||||
|
defer { raw.deallocate() }
|
||||||
|
let bufferList = raw.bindMemory(to: AudioBufferList.self, capacity: 1)
|
||||||
|
status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, bufferList)
|
||||||
|
guard status == noErr else { return false }
|
||||||
|
|
||||||
|
let buffers = UnsafeMutableAudioBufferListPointer(bufferList)
|
||||||
|
return buffers.contains(where: { $0.mNumberChannels > 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
private func logDefaultInputChange(reason: StaticString) {
|
||||||
|
self.logger.info("audio input changed (\(reason)) (\(Self.defaultInputDeviceSummary(), privacy: .public))")
|
||||||
|
}
|
||||||
|
}
|
||||||
84
apps/macos/Sources/Moltbot/CLIInstallPrompter.swift
Normal file
84
apps/macos/Sources/Moltbot/CLIInstallPrompter.swift
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class CLIInstallPrompter {
|
||||||
|
static let shared = CLIInstallPrompter()
|
||||||
|
private let logger = Logger(subsystem: "bot.molt", category: "cli.prompt")
|
||||||
|
private var isPrompting = false
|
||||||
|
|
||||||
|
func checkAndPromptIfNeeded(reason: String) {
|
||||||
|
guard self.shouldPrompt() else { return }
|
||||||
|
guard let version = Self.appVersion() else { return }
|
||||||
|
self.isPrompting = true
|
||||||
|
UserDefaults.standard.set(version, forKey: cliInstallPromptedVersionKey)
|
||||||
|
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.messageText = "Install Moltbot CLI?"
|
||||||
|
alert.informativeText = "Local mode needs the CLI so launchd can run the gateway."
|
||||||
|
alert.addButton(withTitle: "Install CLI")
|
||||||
|
alert.addButton(withTitle: "Not now")
|
||||||
|
alert.addButton(withTitle: "Open Settings")
|
||||||
|
let response = alert.runModal()
|
||||||
|
|
||||||
|
switch response {
|
||||||
|
case .alertFirstButtonReturn:
|
||||||
|
Task { await self.installCLI() }
|
||||||
|
case .alertThirdButtonReturn:
|
||||||
|
self.openSettings(tab: .general)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
self.logger.debug("cli install prompt handled reason=\(reason, privacy: .public)")
|
||||||
|
self.isPrompting = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shouldPrompt() -> Bool {
|
||||||
|
guard !self.isPrompting else { return false }
|
||||||
|
guard AppStateStore.shared.onboardingSeen else { return false }
|
||||||
|
guard AppStateStore.shared.connectionMode == .local else { return false }
|
||||||
|
guard CLIInstaller.installedLocation() == nil else { return false }
|
||||||
|
guard let version = Self.appVersion() else { return false }
|
||||||
|
let lastPrompt = UserDefaults.standard.string(forKey: cliInstallPromptedVersionKey)
|
||||||
|
return lastPrompt != version
|
||||||
|
}
|
||||||
|
|
||||||
|
private func installCLI() async {
|
||||||
|
let status = StatusBox()
|
||||||
|
await CLIInstaller.install { message in
|
||||||
|
await status.set(message)
|
||||||
|
}
|
||||||
|
if let message = await status.get() {
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.messageText = "CLI install finished"
|
||||||
|
alert.informativeText = message
|
||||||
|
alert.runModal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openSettings(tab: SettingsTab) {
|
||||||
|
SettingsTabRouter.request(tab)
|
||||||
|
SettingsWindowOpener.shared.open()
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
NotificationCenter.default.post(name: .clawdbotSelectSettingsTab, object: tab)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func appVersion() -> String? {
|
||||||
|
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private actor StatusBox {
|
||||||
|
private var value: String?
|
||||||
|
|
||||||
|
func set(_ value: String) {
|
||||||
|
self.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
func get() -> String? {
|
||||||
|
self.value
|
||||||
|
}
|
||||||
|
}
|
||||||
425
apps/macos/Sources/Moltbot/CameraCaptureService.swift
Normal file
425
apps/macos/Sources/Moltbot/CameraCaptureService.swift
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import MoltbotIPC
|
||||||
|
import MoltbotKit
|
||||||
|
import CoreGraphics
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
actor CameraCaptureService {
|
||||||
|
struct CameraDeviceInfo: Encodable, Sendable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let position: String
|
||||||
|
let deviceType: String
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CameraError: LocalizedError, Sendable {
|
||||||
|
case cameraUnavailable
|
||||||
|
case microphoneUnavailable
|
||||||
|
case permissionDenied(kind: String)
|
||||||
|
case captureFailed(String)
|
||||||
|
case exportFailed(String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .cameraUnavailable:
|
||||||
|
"Camera unavailable"
|
||||||
|
case .microphoneUnavailable:
|
||||||
|
"Microphone unavailable"
|
||||||
|
case let .permissionDenied(kind):
|
||||||
|
"\(kind) permission denied"
|
||||||
|
case let .captureFailed(msg):
|
||||||
|
msg
|
||||||
|
case let .exportFailed(msg):
|
||||||
|
msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "bot.molt", category: "camera")
|
||||||
|
|
||||||
|
func listDevices() -> [CameraDeviceInfo] {
|
||||||
|
Self.availableCameras().map { device in
|
||||||
|
CameraDeviceInfo(
|
||||||
|
id: device.uniqueID,
|
||||||
|
name: device.localizedName,
|
||||||
|
position: Self.positionLabel(device.position),
|
||||||
|
deviceType: device.deviceType.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func snap(
|
||||||
|
facing: CameraFacing?,
|
||||||
|
maxWidth: Int?,
|
||||||
|
quality: Double?,
|
||||||
|
deviceId: String?,
|
||||||
|
delayMs: Int) async throws -> (data: Data, size: CGSize)
|
||||||
|
{
|
||||||
|
let facing = facing ?? .front
|
||||||
|
let normalized = Self.normalizeSnap(maxWidth: maxWidth, quality: quality)
|
||||||
|
let maxWidth = normalized.maxWidth
|
||||||
|
let quality = normalized.quality
|
||||||
|
let delayMs = max(0, delayMs)
|
||||||
|
let deviceId = deviceId?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
|
try await self.ensureAccess(for: .video)
|
||||||
|
|
||||||
|
let session = AVCaptureSession()
|
||||||
|
session.sessionPreset = .photo
|
||||||
|
|
||||||
|
guard let device = Self.pickCamera(facing: facing, deviceId: deviceId) else {
|
||||||
|
throw CameraError.cameraUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
let input = try AVCaptureDeviceInput(device: device)
|
||||||
|
guard session.canAddInput(input) else {
|
||||||
|
throw CameraError.captureFailed("Failed to add camera input")
|
||||||
|
}
|
||||||
|
session.addInput(input)
|
||||||
|
|
||||||
|
let output = AVCapturePhotoOutput()
|
||||||
|
guard session.canAddOutput(output) else {
|
||||||
|
throw CameraError.captureFailed("Failed to add photo output")
|
||||||
|
}
|
||||||
|
session.addOutput(output)
|
||||||
|
output.maxPhotoQualityPrioritization = .quality
|
||||||
|
|
||||||
|
session.startRunning()
|
||||||
|
defer { session.stopRunning() }
|
||||||
|
await Self.warmUpCaptureSession()
|
||||||
|
await self.waitForExposureAndWhiteBalance(device: device)
|
||||||
|
await self.sleepDelayMs(delayMs)
|
||||||
|
|
||||||
|
let settings: AVCapturePhotoSettings = {
|
||||||
|
if output.availablePhotoCodecTypes.contains(.jpeg) {
|
||||||
|
return AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.jpeg])
|
||||||
|
}
|
||||||
|
return AVCapturePhotoSettings()
|
||||||
|
}()
|
||||||
|
settings.photoQualityPrioritization = .quality
|
||||||
|
|
||||||
|
var delegate: PhotoCaptureDelegate?
|
||||||
|
let rawData: Data = try await withCheckedThrowingContinuation { cont in
|
||||||
|
let d = PhotoCaptureDelegate(cont)
|
||||||
|
delegate = d
|
||||||
|
output.capturePhoto(with: settings, delegate: d)
|
||||||
|
}
|
||||||
|
withExtendedLifetime(delegate) {}
|
||||||
|
|
||||||
|
let maxPayloadBytes = 5 * 1024 * 1024
|
||||||
|
// Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under 5MB (API limit).
|
||||||
|
let maxEncodedBytes = (maxPayloadBytes / 4) * 3
|
||||||
|
let res = try JPEGTranscoder.transcodeToJPEG(
|
||||||
|
imageData: rawData,
|
||||||
|
maxWidthPx: maxWidth,
|
||||||
|
quality: quality,
|
||||||
|
maxBytes: maxEncodedBytes)
|
||||||
|
return (data: res.data, size: CGSize(width: res.widthPx, height: res.heightPx))
|
||||||
|
}
|
||||||
|
|
||||||
|
func clip(
|
||||||
|
facing: CameraFacing?,
|
||||||
|
durationMs: Int?,
|
||||||
|
includeAudio: Bool,
|
||||||
|
deviceId: String?,
|
||||||
|
outPath: String?) async throws -> (path: String, durationMs: Int, hasAudio: Bool)
|
||||||
|
{
|
||||||
|
let facing = facing ?? .front
|
||||||
|
let durationMs = Self.clampDurationMs(durationMs)
|
||||||
|
let deviceId = deviceId?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
|
try await self.ensureAccess(for: .video)
|
||||||
|
if includeAudio {
|
||||||
|
try await self.ensureAccess(for: .audio)
|
||||||
|
}
|
||||||
|
|
||||||
|
let session = AVCaptureSession()
|
||||||
|
session.sessionPreset = .high
|
||||||
|
|
||||||
|
guard let camera = Self.pickCamera(facing: facing, deviceId: deviceId) else {
|
||||||
|
throw CameraError.cameraUnavailable
|
||||||
|
}
|
||||||
|
let cameraInput = try AVCaptureDeviceInput(device: camera)
|
||||||
|
guard session.canAddInput(cameraInput) else {
|
||||||
|
throw CameraError.captureFailed("Failed to add camera input")
|
||||||
|
}
|
||||||
|
session.addInput(cameraInput)
|
||||||
|
|
||||||
|
if includeAudio {
|
||||||
|
guard let mic = AVCaptureDevice.default(for: .audio) else {
|
||||||
|
throw CameraError.microphoneUnavailable
|
||||||
|
}
|
||||||
|
let micInput = try AVCaptureDeviceInput(device: mic)
|
||||||
|
guard session.canAddInput(micInput) else {
|
||||||
|
throw CameraError.captureFailed("Failed to add microphone input")
|
||||||
|
}
|
||||||
|
session.addInput(micInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = AVCaptureMovieFileOutput()
|
||||||
|
guard session.canAddOutput(output) else {
|
||||||
|
throw CameraError.captureFailed("Failed to add movie output")
|
||||||
|
}
|
||||||
|
session.addOutput(output)
|
||||||
|
output.maxRecordedDuration = CMTime(value: Int64(durationMs), timescale: 1000)
|
||||||
|
|
||||||
|
session.startRunning()
|
||||||
|
defer { session.stopRunning() }
|
||||||
|
await Self.warmUpCaptureSession()
|
||||||
|
|
||||||
|
let tmpMovURL = FileManager().temporaryDirectory
|
||||||
|
.appendingPathComponent("moltbot-camera-\(UUID().uuidString).mov")
|
||||||
|
defer { try? FileManager().removeItem(at: tmpMovURL) }
|
||||||
|
|
||||||
|
let outputURL: URL = {
|
||||||
|
if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
return URL(fileURLWithPath: outPath)
|
||||||
|
}
|
||||||
|
return FileManager().temporaryDirectory
|
||||||
|
.appendingPathComponent("moltbot-camera-\(UUID().uuidString).mp4")
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Ensure we don't fail exporting due to an existing file.
|
||||||
|
try? FileManager().removeItem(at: outputURL)
|
||||||
|
|
||||||
|
let logger = self.logger
|
||||||
|
var delegate: MovieFileDelegate?
|
||||||
|
let recordedURL: URL = try await withCheckedThrowingContinuation { cont in
|
||||||
|
let d = MovieFileDelegate(cont, logger: logger)
|
||||||
|
delegate = d
|
||||||
|
output.startRecording(to: tmpMovURL, recordingDelegate: d)
|
||||||
|
}
|
||||||
|
withExtendedLifetime(delegate) {}
|
||||||
|
|
||||||
|
try await Self.exportToMP4(inputURL: recordedURL, outputURL: outputURL)
|
||||||
|
return (path: outputURL.path, durationMs: durationMs, hasAudio: includeAudio)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensureAccess(for mediaType: AVMediaType) async throws {
|
||||||
|
let status = AVCaptureDevice.authorizationStatus(for: mediaType)
|
||||||
|
switch status {
|
||||||
|
case .authorized:
|
||||||
|
return
|
||||||
|
case .notDetermined:
|
||||||
|
let ok = await withCheckedContinuation(isolation: nil) { cont in
|
||||||
|
AVCaptureDevice.requestAccess(for: mediaType) { granted in
|
||||||
|
cont.resume(returning: granted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
|
||||||
|
}
|
||||||
|
case .denied, .restricted:
|
||||||
|
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
|
||||||
|
@unknown default:
|
||||||
|
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func availableCameras() -> [AVCaptureDevice] {
|
||||||
|
var types: [AVCaptureDevice.DeviceType] = [
|
||||||
|
.builtInWideAngleCamera,
|
||||||
|
.continuityCamera,
|
||||||
|
]
|
||||||
|
if let external = externalDeviceType() {
|
||||||
|
types.append(external)
|
||||||
|
}
|
||||||
|
let session = AVCaptureDevice.DiscoverySession(
|
||||||
|
deviceTypes: types,
|
||||||
|
mediaType: .video,
|
||||||
|
position: .unspecified)
|
||||||
|
return session.devices
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func externalDeviceType() -> AVCaptureDevice.DeviceType? {
|
||||||
|
if #available(macOS 14.0, *) {
|
||||||
|
return .external
|
||||||
|
}
|
||||||
|
// Use raw value to avoid deprecated symbol in the SDK.
|
||||||
|
return AVCaptureDevice.DeviceType(rawValue: "AVCaptureDeviceTypeExternalUnknown")
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func pickCamera(
|
||||||
|
facing: CameraFacing,
|
||||||
|
deviceId: String?) -> AVCaptureDevice?
|
||||||
|
{
|
||||||
|
if let deviceId, !deviceId.isEmpty {
|
||||||
|
if let match = availableCameras().first(where: { $0.uniqueID == deviceId }) {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let position: AVCaptureDevice.Position = (facing == .front) ? .front : .back
|
||||||
|
|
||||||
|
if let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position) {
|
||||||
|
return device
|
||||||
|
}
|
||||||
|
|
||||||
|
// Many macOS cameras report `unspecified` position; fall back to any default.
|
||||||
|
return AVCaptureDevice.default(for: .video)
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func clampQuality(_ quality: Double?) -> Double {
|
||||||
|
let q = quality ?? 0.9
|
||||||
|
return min(1.0, max(0.05, q))
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func normalizeSnap(maxWidth: Int?, quality: Double?) -> (maxWidth: Int, quality: Double) {
|
||||||
|
// Default to a reasonable max width to keep downstream payload sizes manageable.
|
||||||
|
// If you need full-res, explicitly request a larger maxWidth.
|
||||||
|
let maxWidth = maxWidth.flatMap { $0 > 0 ? $0 : nil } ?? 1600
|
||||||
|
let quality = Self.clampQuality(quality)
|
||||||
|
return (maxWidth: maxWidth, quality: quality)
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func clampDurationMs(_ ms: Int?) -> Int {
|
||||||
|
let v = ms ?? 3000
|
||||||
|
return min(60000, max(250, v))
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func exportToMP4(inputURL: URL, outputURL: URL) async throws {
|
||||||
|
let asset = AVURLAsset(url: inputURL)
|
||||||
|
guard let export = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetMediumQuality) else {
|
||||||
|
throw CameraError.exportFailed("Failed to create export session")
|
||||||
|
}
|
||||||
|
export.shouldOptimizeForNetworkUse = true
|
||||||
|
|
||||||
|
if #available(macOS 15.0, *) {
|
||||||
|
do {
|
||||||
|
try await export.export(to: outputURL, as: .mp4)
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
throw CameraError.exportFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
export.outputURL = outputURL
|
||||||
|
export.outputFileType = .mp4
|
||||||
|
|
||||||
|
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Void, Error>) in
|
||||||
|
export.exportAsynchronously {
|
||||||
|
cont.resume(returning: ())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch export.status {
|
||||||
|
case .completed:
|
||||||
|
return
|
||||||
|
case .failed:
|
||||||
|
throw CameraError.exportFailed(export.error?.localizedDescription ?? "export failed")
|
||||||
|
case .cancelled:
|
||||||
|
throw CameraError.exportFailed("export cancelled")
|
||||||
|
default:
|
||||||
|
throw CameraError.exportFailed("export did not complete (\(export.status.rawValue))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func warmUpCaptureSession() async {
|
||||||
|
// A short delay after `startRunning()` significantly reduces "blank first frame" captures on some devices.
|
||||||
|
try? await Task.sleep(nanoseconds: 150_000_000) // 150ms
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitForExposureAndWhiteBalance(device: AVCaptureDevice) async {
|
||||||
|
let stepNs: UInt64 = 50_000_000
|
||||||
|
let maxSteps = 30 // ~1.5s
|
||||||
|
for _ in 0..<maxSteps {
|
||||||
|
if !(device.isAdjustingExposure || device.isAdjustingWhiteBalance) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try? await Task.sleep(nanoseconds: stepNs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sleepDelayMs(_ delayMs: Int) async {
|
||||||
|
guard delayMs > 0 else { return }
|
||||||
|
let ns = UInt64(min(delayMs, 10000)) * 1_000_000
|
||||||
|
try? await Task.sleep(nanoseconds: ns)
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func positionLabel(_ position: AVCaptureDevice.Position) -> String {
|
||||||
|
switch position {
|
||||||
|
case .front: "front"
|
||||||
|
case .back: "back"
|
||||||
|
default: "unspecified"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate {
|
||||||
|
private var cont: CheckedContinuation<Data, Error>?
|
||||||
|
private var didResume = false
|
||||||
|
|
||||||
|
init(_ cont: CheckedContinuation<Data, Error>) {
|
||||||
|
self.cont = cont
|
||||||
|
}
|
||||||
|
|
||||||
|
func photoOutput(
|
||||||
|
_ output: AVCapturePhotoOutput,
|
||||||
|
didFinishProcessingPhoto photo: AVCapturePhoto,
|
||||||
|
error: Error?)
|
||||||
|
{
|
||||||
|
guard !self.didResume, let cont else { return }
|
||||||
|
self.didResume = true
|
||||||
|
self.cont = nil
|
||||||
|
if let error {
|
||||||
|
cont.resume(throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let data = photo.fileDataRepresentation() else {
|
||||||
|
cont.resume(throwing: CameraCaptureService.CameraError.captureFailed("No photo data"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if data.isEmpty {
|
||||||
|
cont.resume(throwing: CameraCaptureService.CameraError.captureFailed("Photo data empty"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cont.resume(returning: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func photoOutput(
|
||||||
|
_ output: AVCapturePhotoOutput,
|
||||||
|
didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings,
|
||||||
|
error: Error?)
|
||||||
|
{
|
||||||
|
guard let error else { return }
|
||||||
|
guard !self.didResume, let cont else { return }
|
||||||
|
self.didResume = true
|
||||||
|
self.cont = nil
|
||||||
|
cont.resume(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class MovieFileDelegate: NSObject, AVCaptureFileOutputRecordingDelegate {
|
||||||
|
private var cont: CheckedContinuation<URL, Error>?
|
||||||
|
private let logger: Logger
|
||||||
|
|
||||||
|
init(_ cont: CheckedContinuation<URL, Error>, logger: Logger) {
|
||||||
|
self.cont = cont
|
||||||
|
self.logger = logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileOutput(
|
||||||
|
_ output: AVCaptureFileOutput,
|
||||||
|
didFinishRecordingTo outputFileURL: URL,
|
||||||
|
from connections: [AVCaptureConnection],
|
||||||
|
error: Error?)
|
||||||
|
{
|
||||||
|
guard let cont else { return }
|
||||||
|
self.cont = nil
|
||||||
|
|
||||||
|
if let error {
|
||||||
|
let ns = error as NSError
|
||||||
|
if ns.domain == AVFoundationErrorDomain,
|
||||||
|
ns.code == AVError.maximumDurationReached.rawValue
|
||||||
|
{
|
||||||
|
cont.resume(returning: outputFileURL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.logger.error("camera record failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
cont.resume(throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cont.resume(returning: outputFileURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
94
apps/macos/Sources/Moltbot/CanvasFileWatcher.swift
Normal file
94
apps/macos/Sources/Moltbot/CanvasFileWatcher.swift
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import CoreServices
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class CanvasFileWatcher: @unchecked Sendable {
|
||||||
|
private let url: URL
|
||||||
|
private let queue: DispatchQueue
|
||||||
|
private var stream: FSEventStreamRef?
|
||||||
|
private var pending = false
|
||||||
|
private let onChange: () -> Void
|
||||||
|
|
||||||
|
init(url: URL, onChange: @escaping () -> Void) {
|
||||||
|
self.url = url
|
||||||
|
self.queue = DispatchQueue(label: "bot.molt.canvaswatcher")
|
||||||
|
self.onChange = onChange
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
self.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard self.stream == nil else { return }
|
||||||
|
|
||||||
|
let retainedSelf = Unmanaged.passRetained(self)
|
||||||
|
var context = FSEventStreamContext(
|
||||||
|
version: 0,
|
||||||
|
info: retainedSelf.toOpaque(),
|
||||||
|
retain: nil,
|
||||||
|
release: { pointer in
|
||||||
|
guard let pointer else { return }
|
||||||
|
Unmanaged<CanvasFileWatcher>.fromOpaque(pointer).release()
|
||||||
|
},
|
||||||
|
copyDescription: nil)
|
||||||
|
|
||||||
|
let paths = [self.url.path] as CFArray
|
||||||
|
let flags = FSEventStreamCreateFlags(
|
||||||
|
kFSEventStreamCreateFlagFileEvents |
|
||||||
|
kFSEventStreamCreateFlagUseCFTypes |
|
||||||
|
kFSEventStreamCreateFlagNoDefer)
|
||||||
|
|
||||||
|
guard let stream = FSEventStreamCreate(
|
||||||
|
kCFAllocatorDefault,
|
||||||
|
Self.callback,
|
||||||
|
&context,
|
||||||
|
paths,
|
||||||
|
FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
|
||||||
|
0.05,
|
||||||
|
flags)
|
||||||
|
else {
|
||||||
|
retainedSelf.release()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.stream = stream
|
||||||
|
FSEventStreamSetDispatchQueue(stream, self.queue)
|
||||||
|
if FSEventStreamStart(stream) == false {
|
||||||
|
self.stream = nil
|
||||||
|
FSEventStreamSetDispatchQueue(stream, nil)
|
||||||
|
FSEventStreamInvalidate(stream)
|
||||||
|
FSEventStreamRelease(stream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
guard let stream = self.stream else { return }
|
||||||
|
self.stream = nil
|
||||||
|
FSEventStreamStop(stream)
|
||||||
|
FSEventStreamSetDispatchQueue(stream, nil)
|
||||||
|
FSEventStreamInvalidate(stream)
|
||||||
|
FSEventStreamRelease(stream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CanvasFileWatcher {
|
||||||
|
private static let callback: FSEventStreamCallback = { _, info, numEvents, _, eventFlags, _ in
|
||||||
|
guard let info else { return }
|
||||||
|
let watcher = Unmanaged<CanvasFileWatcher>.fromOpaque(info).takeUnretainedValue()
|
||||||
|
watcher.handleEvents(numEvents: numEvents, eventFlags: eventFlags)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleEvents(numEvents: Int, eventFlags: UnsafePointer<FSEventStreamEventFlags>?) {
|
||||||
|
guard numEvents > 0 else { return }
|
||||||
|
guard eventFlags != nil else { return }
|
||||||
|
|
||||||
|
// Coalesce rapid changes (common during builds/atomic saves).
|
||||||
|
if self.pending { return }
|
||||||
|
self.pending = true
|
||||||
|
self.queue.asyncAfter(deadline: .now() + 0.12) { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.pending = false
|
||||||
|
self.onChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
342
apps/macos/Sources/Moltbot/CanvasManager.swift
Normal file
342
apps/macos/Sources/Moltbot/CanvasManager.swift
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
import AppKit
|
||||||
|
import MoltbotIPC
|
||||||
|
import MoltbotKit
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class CanvasManager {
|
||||||
|
static let shared = CanvasManager()
|
||||||
|
|
||||||
|
private static let logger = Logger(subsystem: "bot.molt", category: "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)?
|
||||||
|
|
||||||
|
/// Optional anchor provider (e.g. menu bar status item). If nil, Canvas anchors to the mouse cursor.
|
||||||
|
var defaultAnchorProvider: (() -> NSRect?)?
|
||||||
|
|
||||||
|
private nonisolated static let canvasRoot: URL = {
|
||||||
|
let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||||
|
return base.appendingPathComponent("Moltbot/canvas", isDirectory: true)
|
||||||
|
}()
|
||||||
|
|
||||||
|
func show(sessionKey: String, path: String? = nil, placement: CanvasPlacement? = nil) throws -> String {
|
||||||
|
try self.showDetailed(sessionKey: sessionKey, target: path, placement: placement).directory
|
||||||
|
}
|
||||||
|
|
||||||
|
func showDetailed(
|
||||||
|
sessionKey: String,
|
||||||
|
target: String? = nil,
|
||||||
|
placement: CanvasPlacement? = nil) throws -> CanvasShowResult
|
||||||
|
{
|
||||||
|
Self.logger.debug(
|
||||||
|
"""
|
||||||
|
showDetailed start session=\(sessionKey, privacy: .public) \
|
||||||
|
target=\(target ?? "", privacy: .public) \
|
||||||
|
placement=\(placement != nil)
|
||||||
|
""")
|
||||||
|
let anchorProvider = self.defaultAnchorProvider ?? Self.mouseAnchorProvider
|
||||||
|
let session = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let normalizedTarget = target?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.nonEmpty
|
||||||
|
|
||||||
|
if let controller = self.panelController, self.panelSessionKey == session {
|
||||||
|
Self.logger.debug("showDetailed reuse existing session=\(session, privacy: .public)")
|
||||||
|
controller.onVisibilityChanged = { [weak self] visible in
|
||||||
|
self?.onPanelVisibilityChanged?(visible)
|
||||||
|
}
|
||||||
|
controller.presentAnchoredPanel(anchorProvider: anchorProvider)
|
||||||
|
controller.applyPreferredPlacement(placement)
|
||||||
|
self.refreshDebugStatus()
|
||||||
|
|
||||||
|
// Existing session: only navigate when an explicit target was provided.
|
||||||
|
if let normalizedTarget {
|
||||||
|
controller.load(target: normalizedTarget)
|
||||||
|
return self.makeShowResult(
|
||||||
|
directory: controller.directoryPath,
|
||||||
|
target: target,
|
||||||
|
effectiveTarget: normalizedTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.maybeAutoNavigateToA2UIAsync(controller: controller)
|
||||||
|
return CanvasShowResult(
|
||||||
|
directory: controller.directoryPath,
|
||||||
|
target: target,
|
||||||
|
effectiveTarget: nil,
|
||||||
|
status: .shown,
|
||||||
|
url: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
Self.logger.debug("showDetailed creating new session=\(session, privacy: .public)")
|
||||||
|
self.panelController?.close()
|
||||||
|
self.panelController = nil
|
||||||
|
self.panelSessionKey = nil
|
||||||
|
|
||||||
|
Self.logger.debug("showDetailed ensure canvas root dir")
|
||||||
|
try FileManager().createDirectory(at: Self.canvasRoot, withIntermediateDirectories: true)
|
||||||
|
Self.logger.debug("showDetailed init CanvasWindowController")
|
||||||
|
let controller = try CanvasWindowController(
|
||||||
|
sessionKey: session,
|
||||||
|
root: Self.canvasRoot,
|
||||||
|
presentation: .panel(anchorProvider: anchorProvider))
|
||||||
|
Self.logger.debug("showDetailed CanvasWindowController init done")
|
||||||
|
controller.onVisibilityChanged = { [weak self] visible in
|
||||||
|
self?.onPanelVisibilityChanged?(visible)
|
||||||
|
}
|
||||||
|
self.panelController = controller
|
||||||
|
self.panelSessionKey = session
|
||||||
|
controller.applyPreferredPlacement(placement)
|
||||||
|
|
||||||
|
// New session: default to "/" so the user sees either the welcome page or `index.html`.
|
||||||
|
let effectiveTarget = normalizedTarget ?? "/"
|
||||||
|
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,
|
||||||
|
target: target,
|
||||||
|
effectiveTarget: effectiveTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hide(sessionKey: String) {
|
||||||
|
let session = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard self.panelSessionKey == session else { return }
|
||||||
|
self.panelController?.hideCanvas()
|
||||||
|
}
|
||||||
|
|
||||||
|
func hideAll() {
|
||||||
|
self.panelController?.hideCanvas()
|
||||||
|
}
|
||||||
|
|
||||||
|
func eval(sessionKey: String, javaScript: String) async throws -> String {
|
||||||
|
_ = try self.show(sessionKey: sessionKey, path: nil)
|
||||||
|
guard let controller = self.panelController else { return "" }
|
||||||
|
return try await controller.eval(javaScript: javaScript)
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshot(sessionKey: String, outPath: String?) async throws -> String {
|
||||||
|
_ = try self.show(sessionKey: sessionKey, path: nil)
|
||||||
|
guard let controller = self.panelController else {
|
||||||
|
throw NSError(domain: "Canvas", code: 21, userInfo: [NSLocalizedDescriptionKey: "canvas not available"])
|
||||||
|
}
|
||||||
|
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 raw = snapshot.canvashosturl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if raw.isEmpty {
|
||||||
|
Self.logger.debug("canvas host url missing in gateway snapshot")
|
||||||
|
} else {
|
||||||
|
Self.logger.debug("canvas host url snapshot=\(raw, privacy: .public)")
|
||||||
|
}
|
||||||
|
let a2uiUrl = Self.resolveA2UIHostUrl(from: raw)
|
||||||
|
if a2uiUrl == nil, !raw.isEmpty {
|
||||||
|
Self.logger.debug("canvas host url invalid; cannot resolve A2UI")
|
||||||
|
}
|
||||||
|
guard let controller = self.panelController else {
|
||||||
|
if a2uiUrl != nil {
|
||||||
|
Self.logger.debug("canvas panel not visible; skipping auto-nav")
|
||||||
|
}
|
||||||
|
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 }
|
||||||
|
let shouldNavigate = controller.shouldAutoNavigateToA2UI(lastAutoTarget: self.lastAutoA2UIUrl)
|
||||||
|
guard shouldNavigate else {
|
||||||
|
Self.logger.debug("canvas auto-nav skipped; target unchanged")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Self.logger.debug("canvas auto-nav -> \(a2uiUrl, privacy: .public)")
|
||||||
|
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 mode = AppStateStore.shared.connectionMode
|
||||||
|
let title: String?
|
||||||
|
let subtitle: String?
|
||||||
|
switch mode {
|
||||||
|
case .remote:
|
||||||
|
title = "Remote control"
|
||||||
|
switch ControlChannel.shared.state {
|
||||||
|
case .connected:
|
||||||
|
subtitle = "Connected"
|
||||||
|
case .connecting:
|
||||||
|
subtitle = "Connecting…"
|
||||||
|
case .disconnected:
|
||||||
|
subtitle = "Disconnected"
|
||||||
|
case let .degraded(message):
|
||||||
|
subtitle = message.isEmpty ? "Degraded" : message
|
||||||
|
}
|
||||||
|
case .local:
|
||||||
|
title = GatewayProcessManager.shared.status.label
|
||||||
|
subtitle = mode.rawValue
|
||||||
|
case .unconfigured:
|
||||||
|
title = "Unconfigured"
|
||||||
|
subtitle = mode.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("__moltbot__/a2ui/").absoluteString + "?platform=macos"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Anchoring
|
||||||
|
|
||||||
|
private static func mouseAnchorProvider() -> NSRect? {
|
||||||
|
let pt = NSEvent.mouseLocation
|
||||||
|
return NSRect(x: pt.x, y: pt.y, width: 1, height: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// placement interpretation is handled by the window controller.
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private static func directURL(for target: String?) -> URL? {
|
||||||
|
guard let target else { return nil }
|
||||||
|
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
|
||||||
|
if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased() {
|
||||||
|
if scheme == "https" || scheme == "http" || scheme == "file" { return url }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience: existing absolute *file* paths resolve as local files.
|
||||||
|
// (Avoid treating Canvas routes like "/" as filesystem paths.)
|
||||||
|
if trimmed.hasPrefix("/") {
|
||||||
|
var isDir: ObjCBool = false
|
||||||
|
if FileManager().fileExists(atPath: trimmed, isDirectory: &isDir), !isDir.boolValue {
|
||||||
|
return URL(fileURLWithPath: trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeShowResult(
|
||||||
|
directory: String,
|
||||||
|
target: String?,
|
||||||
|
effectiveTarget: String) -> CanvasShowResult
|
||||||
|
{
|
||||||
|
if let url = Self.directURL(for: effectiveTarget) {
|
||||||
|
return CanvasShowResult(
|
||||||
|
directory: directory,
|
||||||
|
target: target,
|
||||||
|
effectiveTarget: effectiveTarget,
|
||||||
|
status: .web,
|
||||||
|
url: url.absoluteString)
|
||||||
|
}
|
||||||
|
|
||||||
|
let sessionDir = URL(fileURLWithPath: directory)
|
||||||
|
let status = Self.localStatus(sessionDir: sessionDir, target: effectiveTarget)
|
||||||
|
let host = sessionDir.lastPathComponent
|
||||||
|
let canvasURL = CanvasScheme.makeURL(session: host, path: effectiveTarget)?.absoluteString
|
||||||
|
return CanvasShowResult(
|
||||||
|
directory: directory,
|
||||||
|
target: target,
|
||||||
|
effectiveTarget: effectiveTarget,
|
||||||
|
status: status,
|
||||||
|
url: canvasURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func localStatus(sessionDir: URL, target: String) -> CanvasShowStatus {
|
||||||
|
let fm = FileManager()
|
||||||
|
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let withoutQuery = trimmed.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false).first
|
||||||
|
.map(String.init) ?? trimmed
|
||||||
|
var path = withoutQuery
|
||||||
|
if path.hasPrefix("/") { path.removeFirst() }
|
||||||
|
path = path.removingPercentEncoding ?? path
|
||||||
|
|
||||||
|
// Root special-case: built-in scaffold page when no index exists.
|
||||||
|
if path.isEmpty {
|
||||||
|
let a = sessionDir.appendingPathComponent("index.html", isDirectory: false)
|
||||||
|
let b = sessionDir.appendingPathComponent("index.htm", isDirectory: false)
|
||||||
|
if fm.fileExists(atPath: a.path) || fm.fileExists(atPath: b.path) { return .ok }
|
||||||
|
return .welcome
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct file or directory.
|
||||||
|
var candidate = sessionDir.appendingPathComponent(path, isDirectory: false)
|
||||||
|
var isDir: ObjCBool = false
|
||||||
|
if fm.fileExists(atPath: candidate.path, isDirectory: &isDir) {
|
||||||
|
if isDir.boolValue {
|
||||||
|
return Self.indexExists(in: candidate) ? .ok : .notFound
|
||||||
|
}
|
||||||
|
return .ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Directory index behavior ("/yolo" -> "yolo/index.html") if directory exists.
|
||||||
|
if !path.isEmpty, !path.hasSuffix("/") {
|
||||||
|
candidate = sessionDir.appendingPathComponent(path, isDirectory: true)
|
||||||
|
if fm.fileExists(atPath: candidate.path, isDirectory: &isDir), isDir.boolValue {
|
||||||
|
return Self.indexExists(in: candidate) ? .ok : .notFound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return .notFound
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func indexExists(in dir: URL) -> Bool {
|
||||||
|
let fm = FileManager()
|
||||||
|
let a = dir.appendingPathComponent("index.html", isDirectory: false)
|
||||||
|
if fm.fileExists(atPath: a.path) { return true }
|
||||||
|
let b = dir.appendingPathComponent("index.htm", isDirectory: false)
|
||||||
|
return fm.fileExists(atPath: b.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// no bundled A2UI shell; scaffold fallback is purely visual
|
||||||
|
}
|
||||||
259
apps/macos/Sources/Moltbot/CanvasSchemeHandler.swift
Normal file
259
apps/macos/Sources/Moltbot/CanvasSchemeHandler.swift
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import MoltbotKit
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
import WebKit
|
||||||
|
|
||||||
|
private let canvasLogger = Logger(subsystem: "bot.molt", category: "Canvas")
|
||||||
|
|
||||||
|
final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||||
|
private let root: URL
|
||||||
|
|
||||||
|
init(root: URL) {
|
||||||
|
self.root = root
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
|
||||||
|
guard let url = urlSchemeTask.request.url else {
|
||||||
|
urlSchemeTask.didFailWithError(NSError(domain: "Canvas", code: 1, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "missing url",
|
||||||
|
]))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = self.response(for: url)
|
||||||
|
let mime = response.mime
|
||||||
|
let data = response.data
|
||||||
|
let encoding = self.textEncodingName(forMimeType: mime)
|
||||||
|
|
||||||
|
let urlResponse = URLResponse(
|
||||||
|
url: url,
|
||||||
|
mimeType: mime,
|
||||||
|
expectedContentLength: data.count,
|
||||||
|
textEncodingName: encoding)
|
||||||
|
urlSchemeTask.didReceive(urlResponse)
|
||||||
|
urlSchemeTask.didReceive(data)
|
||||||
|
urlSchemeTask.didFinish()
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_: WKWebView, stop _: WKURLSchemeTask) {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CanvasResponse {
|
||||||
|
let mime: String
|
||||||
|
let data: Data
|
||||||
|
}
|
||||||
|
|
||||||
|
private func response(for url: URL) -> CanvasResponse {
|
||||||
|
guard url.scheme == CanvasScheme.scheme else {
|
||||||
|
return self.html("Invalid scheme.")
|
||||||
|
}
|
||||||
|
guard let session = url.host, !session.isEmpty else {
|
||||||
|
return self.html("Missing session.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep session component safe; don't allow slashes or traversal.
|
||||||
|
if session.contains("/") || session.contains("..") {
|
||||||
|
return self.html("Invalid session.")
|
||||||
|
}
|
||||||
|
|
||||||
|
let sessionRoot = self.root.appendingPathComponent(session, isDirectory: true)
|
||||||
|
|
||||||
|
// Path mapping: request path maps directly into the session dir.
|
||||||
|
var path = url.path
|
||||||
|
if let qIdx = path.firstIndex(of: "?") { path = String(path[..<qIdx]) }
|
||||||
|
if path.hasPrefix("/") { path.removeFirst() }
|
||||||
|
path = path.removingPercentEncoding ?? path
|
||||||
|
|
||||||
|
// Special-case: welcome page when root index is missing.
|
||||||
|
if path.isEmpty {
|
||||||
|
let indexA = sessionRoot.appendingPathComponent("index.html", isDirectory: false)
|
||||||
|
let indexB = sessionRoot.appendingPathComponent("index.htm", isDirectory: false)
|
||||||
|
if !FileManager().fileExists(atPath: indexA.path),
|
||||||
|
!FileManager().fileExists(atPath: indexB.path)
|
||||||
|
{
|
||||||
|
return self.scaffoldPage(sessionRoot: sessionRoot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolved = self.resolveFileURL(sessionRoot: sessionRoot, requestPath: path)
|
||||||
|
guard let fileURL = resolved else {
|
||||||
|
return self.html("Not Found", title: "Canvas: 404")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Directory traversal guard: served files must live under the session root.
|
||||||
|
let standardizedRoot = sessionRoot.standardizedFileURL
|
||||||
|
let standardizedFile = fileURL.standardizedFileURL
|
||||||
|
guard standardizedFile.path.hasPrefix(standardizedRoot.path) else {
|
||||||
|
return self.html("Forbidden", title: "Canvas: 403")
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let data = try Data(contentsOf: standardizedFile)
|
||||||
|
let mime = CanvasScheme.mimeType(forExtension: standardizedFile.pathExtension)
|
||||||
|
let servedPath = standardizedFile.path
|
||||||
|
canvasLogger.debug(
|
||||||
|
"served \(session, privacy: .public)/\(path, privacy: .public) -> \(servedPath, privacy: .public)")
|
||||||
|
return CanvasResponse(mime: mime, data: data)
|
||||||
|
} catch {
|
||||||
|
let failedPath = standardizedFile.path
|
||||||
|
let errorText = error.localizedDescription
|
||||||
|
canvasLogger
|
||||||
|
.error(
|
||||||
|
"failed reading \(failedPath, privacy: .public): \(errorText, privacy: .public)")
|
||||||
|
return self.html("Failed to read file.", title: "Canvas error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveFileURL(sessionRoot: URL, requestPath: String) -> URL? {
|
||||||
|
let fm = FileManager()
|
||||||
|
var candidate = sessionRoot.appendingPathComponent(requestPath, isDirectory: false)
|
||||||
|
|
||||||
|
var isDir: ObjCBool = false
|
||||||
|
if fm.fileExists(atPath: candidate.path, isDirectory: &isDir) {
|
||||||
|
if isDir.boolValue {
|
||||||
|
if let idx = self.resolveIndex(in: candidate) { return idx }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
// Directory index behavior:
|
||||||
|
// - "/yolo" serves "<yolo>/index.html" if that directory exists.
|
||||||
|
if !requestPath.isEmpty, !requestPath.hasSuffix("/") {
|
||||||
|
candidate = sessionRoot.appendingPathComponent(requestPath, isDirectory: true)
|
||||||
|
if fm.fileExists(atPath: candidate.path, isDirectory: &isDir), isDir.boolValue {
|
||||||
|
if let idx = self.resolveIndex(in: candidate) { return idx }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Root fallback:
|
||||||
|
// - "/" serves "<sessionRoot>/index.html" if present.
|
||||||
|
if requestPath.isEmpty {
|
||||||
|
return self.resolveIndex(in: sessionRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveIndex(in dir: URL) -> URL? {
|
||||||
|
let fm = FileManager()
|
||||||
|
let a = dir.appendingPathComponent("index.html", isDirectory: false)
|
||||||
|
if fm.fileExists(atPath: a.path) { return a }
|
||||||
|
let b = dir.appendingPathComponent("index.htm", isDirectory: false)
|
||||||
|
if fm.fileExists(atPath: b.path) { return b }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func html(_ body: String, title: String = "Canvas") -> CanvasResponse {
|
||||||
|
let html = """
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>\(title)</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light; }
|
||||||
|
html,body { height:100%; margin:0; }
|
||||||
|
body {
|
||||||
|
font: 13px -apple-system, system-ui;
|
||||||
|
display:flex;
|
||||||
|
align-items:center;
|
||||||
|
justify-content:center;
|
||||||
|
background: #fff;
|
||||||
|
color:#111827;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
max-width: 520px;
|
||||||
|
padding: 18px 18px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(0,0,0,.08);
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,.08);
|
||||||
|
}
|
||||||
|
.muted { color:#6b7280; margin-top:8px; }
|
||||||
|
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div>\(body)</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
return CanvasResponse(mime: "text/html", data: Data(html.utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func welcomePage(sessionRoot: URL) -> CanvasResponse {
|
||||||
|
let escaped = sessionRoot.path
|
||||||
|
.replacingOccurrences(of: "&", with: "&")
|
||||||
|
.replacingOccurrences(of: "<", with: "<")
|
||||||
|
.replacingOccurrences(of: ">", with: ">")
|
||||||
|
let body = """
|
||||||
|
<div style="font-weight:600; font-size:14px;">Canvas is ready.</div>
|
||||||
|
<div class="muted">Create <code>index.html</code> in:</div>
|
||||||
|
<div style="margin-top:10px;"><code>\(escaped)</code></div>
|
||||||
|
"""
|
||||||
|
return self.html(body, title: "Canvas")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scaffoldPage(sessionRoot: URL) -> CanvasResponse {
|
||||||
|
// Default Canvas UX: when no index exists, show the built-in scaffold page.
|
||||||
|
if let data = self.loadBundledResourceData(relativePath: "CanvasScaffold/scaffold.html") {
|
||||||
|
return CanvasResponse(mime: "text/html", data: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for dev misconfiguration: show the classic welcome page.
|
||||||
|
return self.welcomePage(sessionRoot: sessionRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadBundledResourceData(relativePath: String) -> Data? {
|
||||||
|
let trimmed = relativePath.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
if trimmed.contains("..") || trimmed.contains("\\") { return nil }
|
||||||
|
|
||||||
|
let parts = trimmed.split(separator: "/")
|
||||||
|
guard let filename = parts.last else { return nil }
|
||||||
|
let subdirectory =
|
||||||
|
parts.count > 1 ? parts.dropLast().joined(separator: "/") : nil
|
||||||
|
let fileURL = URL(fileURLWithPath: String(filename))
|
||||||
|
let ext = fileURL.pathExtension
|
||||||
|
let name = fileURL.deletingPathExtension().lastPathComponent
|
||||||
|
guard !name.isEmpty, !ext.isEmpty else { return nil }
|
||||||
|
|
||||||
|
let bundle = MoltbotKitResources.bundle
|
||||||
|
let resourceURL =
|
||||||
|
bundle.url(forResource: name, withExtension: ext, subdirectory: subdirectory)
|
||||||
|
?? bundle.url(forResource: name, withExtension: ext)
|
||||||
|
guard let resourceURL else { return nil }
|
||||||
|
return try? Data(contentsOf: resourceURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func textEncodingName(forMimeType mimeType: String) -> String? {
|
||||||
|
if mimeType.hasPrefix("text/") { return "utf-8" }
|
||||||
|
switch mimeType {
|
||||||
|
case "application/javascript", "application/json", "image/svg+xml":
|
||||||
|
return "utf-8"
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
extension CanvasSchemeHandler {
|
||||||
|
func _testResponse(for url: URL) -> (mime: String, data: Data) {
|
||||||
|
let response = self.response(for: url)
|
||||||
|
return (response.mime, response.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _testResolveFileURL(sessionRoot: URL, requestPath: String) -> URL? {
|
||||||
|
self.resolveFileURL(sessionRoot: sessionRoot, requestPath: requestPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _testTextEncodingName(for mimeType: String) -> String? {
|
||||||
|
self.textEncodingName(forMimeType: mimeType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
26
apps/macos/Sources/Moltbot/CanvasWindow.swift
Normal file
26
apps/macos/Sources/Moltbot/CanvasWindow.swift
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import AppKit
|
||||||
|
|
||||||
|
let canvasWindowLogger = Logger(subsystem: "bot.molt", category: "Canvas")
|
||||||
|
|
||||||
|
enum CanvasLayout {
|
||||||
|
static let panelSize = NSSize(width: 520, height: 680)
|
||||||
|
static let windowSize = NSSize(width: 1120, height: 840)
|
||||||
|
static let anchorPadding: CGFloat = 8
|
||||||
|
static let defaultPadding: CGFloat = 10
|
||||||
|
static let minPanelSize = NSSize(width: 360, height: 360)
|
||||||
|
}
|
||||||
|
|
||||||
|
final class CanvasPanel: NSPanel {
|
||||||
|
override var canBecomeKey: Bool { true }
|
||||||
|
override var canBecomeMain: Bool { true }
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CanvasPresentation {
|
||||||
|
case window
|
||||||
|
case panel(anchorProvider: () -> NSRect?)
|
||||||
|
|
||||||
|
var isPanel: Bool {
|
||||||
|
if case .panel = self { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
217
apps/macos/Sources/Moltbot/ClawdbotConfigFile.swift
Normal file
217
apps/macos/Sources/Moltbot/ClawdbotConfigFile.swift
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import MoltbotProtocol
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum MoltbotConfigFile {
|
||||||
|
private static let logger = Logger(subsystem: "bot.molt", category: "config")
|
||||||
|
|
||||||
|
static func url() -> URL {
|
||||||
|
MoltbotPaths.configURL
|
||||||
|
}
|
||||||
|
|
||||||
|
static func stateDirURL() -> URL {
|
||||||
|
MoltbotPaths.stateDirURL
|
||||||
|
}
|
||||||
|
|
||||||
|
static func defaultWorkspaceURL() -> URL {
|
||||||
|
MoltbotPaths.workspaceURL
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadDict() -> [String: Any] {
|
||||||
|
let url = self.url()
|
||||||
|
guard FileManager().fileExists(atPath: url.path) else { return [:] }
|
||||||
|
do {
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
|
guard let root = self.parseConfigData(data) else {
|
||||||
|
self.logger.warning("config JSON root invalid")
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
return root
|
||||||
|
} catch {
|
||||||
|
self.logger.warning("config read failed: \(error.localizedDescription)")
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveDict(_ dict: [String: Any]) {
|
||||||
|
// Nix mode disables config writes in production, but tests rely on saving temp configs.
|
||||||
|
if ProcessInfo.processInfo.isNixMode, !ProcessInfo.processInfo.isRunningTests { return }
|
||||||
|
do {
|
||||||
|
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
|
||||||
|
let url = self.url()
|
||||||
|
try FileManager().createDirectory(
|
||||||
|
at: url.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true)
|
||||||
|
try data.write(to: url, options: [.atomic])
|
||||||
|
} catch {
|
||||||
|
self.logger.error("config save failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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]
|
||||||
|
return browser?["enabled"] as? Bool ?? defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
static func setBrowserControlEnabled(_ enabled: Bool) {
|
||||||
|
var root = self.loadDict()
|
||||||
|
var browser = root["browser"] as? [String: Any] ?? [:]
|
||||||
|
browser["enabled"] = enabled
|
||||||
|
root["browser"] = browser
|
||||||
|
self.saveDict(root)
|
||||||
|
self.logger.debug("browser control updated enabled=\(enabled)")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func agentWorkspace() -> String? {
|
||||||
|
let root = self.loadDict()
|
||||||
|
let agents = root["agents"] as? [String: Any]
|
||||||
|
let defaults = agents?["defaults"] as? [String: Any]
|
||||||
|
return defaults?["workspace"] as? String
|
||||||
|
}
|
||||||
|
|
||||||
|
static func setAgentWorkspace(_ workspace: String?) {
|
||||||
|
var root = self.loadDict()
|
||||||
|
var agents = root["agents"] as? [String: Any] ?? [:]
|
||||||
|
var defaults = agents["defaults"] as? [String: Any] ?? [:]
|
||||||
|
let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if trimmed.isEmpty {
|
||||||
|
defaults.removeValue(forKey: "workspace")
|
||||||
|
} else {
|
||||||
|
defaults["workspace"] = trimmed
|
||||||
|
}
|
||||||
|
if defaults.isEmpty {
|
||||||
|
agents.removeValue(forKey: "defaults")
|
||||||
|
} else {
|
||||||
|
agents["defaults"] = defaults
|
||||||
|
}
|
||||||
|
if agents.isEmpty {
|
||||||
|
root.removeValue(forKey: "agents")
|
||||||
|
} else {
|
||||||
|
root["agents"] = agents
|
||||||
|
}
|
||||||
|
self.saveDict(root)
|
||||||
|
self.logger.debug("agents.defaults.workspace updated set=\(!trimmed.isEmpty)")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func gatewayPassword() -> String? {
|
||||||
|
let root = self.loadDict()
|
||||||
|
guard let gateway = root["gateway"] as? [String: Any],
|
||||||
|
let remote = gateway["remote"] as? [String: Any]
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return remote["password"] as? String
|
||||||
|
}
|
||||||
|
|
||||||
|
static func gatewayPort() -> Int? {
|
||||||
|
let root = self.loadDict()
|
||||||
|
guard let gateway = root["gateway"] as? [String: Any] else { return nil }
|
||||||
|
if let port = gateway["port"] as? Int, port > 0 { return port }
|
||||||
|
if let number = gateway["port"] as? NSNumber, number.intValue > 0 {
|
||||||
|
return number.intValue
|
||||||
|
}
|
||||||
|
if let raw = gateway["port"] as? String,
|
||||||
|
let parsed = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines)),
|
||||||
|
parsed > 0
|
||||||
|
{
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func remoteGatewayPort() -> Int? {
|
||||||
|
guard let url = self.remoteGatewayUrl(),
|
||||||
|
let port = url.port,
|
||||||
|
port > 0
|
||||||
|
else { return nil }
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
|
||||||
|
static func remoteGatewayPort(matchingHost sshHost: String) -> Int? {
|
||||||
|
let trimmedSshHost = sshHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmedSshHost.isEmpty,
|
||||||
|
let url = self.remoteGatewayUrl(),
|
||||||
|
let port = url.port,
|
||||||
|
port > 0,
|
||||||
|
let urlHost = url.host?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!urlHost.isEmpty
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let sshKey = Self.hostKey(trimmedSshHost)
|
||||||
|
let urlKey = Self.hostKey(urlHost)
|
||||||
|
guard !sshKey.isEmpty, !urlKey.isEmpty, sshKey == urlKey else { return nil }
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
|
||||||
|
static func setRemoteGatewayUrl(host: String, port: Int?) {
|
||||||
|
guard let port, port > 0 else { return }
|
||||||
|
let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmedHost.isEmpty else { return }
|
||||||
|
self.updateGatewayDict { gateway in
|
||||||
|
var remote = gateway["remote"] as? [String: Any] ?? [:]
|
||||||
|
let existingUrl = (remote["url"] as? String)?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let scheme = URL(string: existingUrl)?.scheme ?? "ws"
|
||||||
|
remote["url"] = "\(scheme)://\(trimmedHost):\(port)"
|
||||||
|
gateway["remote"] = remote
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func remoteGatewayUrl() -> URL? {
|
||||||
|
let root = self.loadDict()
|
||||||
|
guard let gateway = root["gateway"] as? [String: Any],
|
||||||
|
let remote = gateway["remote"] as? [String: Any],
|
||||||
|
let raw = remote["url"] as? String
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty, let url = URL(string: trimmed) else { return nil }
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func hostKey(_ host: String) -> String {
|
||||||
|
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
guard !trimmed.isEmpty else { return "" }
|
||||||
|
if trimmed.contains(":") { return trimmed }
|
||||||
|
let digits = CharacterSet(charactersIn: "0123456789.")
|
||||||
|
if trimmed.rangeOfCharacter(from: digits.inverted) == nil {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
return trimmed.split(separator: ".").first.map(String.init) ?? trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseConfigData(_ data: Data) -> [String: Any]? {
|
||||||
|
if let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
if #available(macOS 12.0, *) {
|
||||||
|
decoder.allowsJSON5 = true
|
||||||
|
}
|
||||||
|
if let decoded = try? decoder.decode([String: AnyCodable].self, from: data) {
|
||||||
|
self.logger.notice("config parsed with JSON5 decoder")
|
||||||
|
return decoded.mapValues { $0.foundationValue }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
118
apps/macos/Sources/Moltbot/ConfigFileWatcher.swift
Normal file
118
apps/macos/Sources/Moltbot/ConfigFileWatcher.swift
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import CoreServices
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class ConfigFileWatcher: @unchecked Sendable {
|
||||||
|
private let url: URL
|
||||||
|
private let queue: DispatchQueue
|
||||||
|
private var stream: FSEventStreamRef?
|
||||||
|
private var pending = false
|
||||||
|
private let onChange: () -> Void
|
||||||
|
private let watchedDir: URL
|
||||||
|
private let targetPath: String
|
||||||
|
private let targetName: String
|
||||||
|
|
||||||
|
init(url: URL, onChange: @escaping () -> Void) {
|
||||||
|
self.url = url
|
||||||
|
self.queue = DispatchQueue(label: "bot.molt.configwatcher")
|
||||||
|
self.onChange = onChange
|
||||||
|
self.watchedDir = url.deletingLastPathComponent()
|
||||||
|
self.targetPath = url.path
|
||||||
|
self.targetName = url.lastPathComponent
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
self.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard self.stream == nil else { return }
|
||||||
|
|
||||||
|
let retainedSelf = Unmanaged.passRetained(self)
|
||||||
|
var context = FSEventStreamContext(
|
||||||
|
version: 0,
|
||||||
|
info: retainedSelf.toOpaque(),
|
||||||
|
retain: nil,
|
||||||
|
release: { pointer in
|
||||||
|
guard let pointer else { return }
|
||||||
|
Unmanaged<ConfigFileWatcher>.fromOpaque(pointer).release()
|
||||||
|
},
|
||||||
|
copyDescription: nil)
|
||||||
|
|
||||||
|
let paths = [self.watchedDir.path] as CFArray
|
||||||
|
let flags = FSEventStreamCreateFlags(
|
||||||
|
kFSEventStreamCreateFlagFileEvents |
|
||||||
|
kFSEventStreamCreateFlagUseCFTypes |
|
||||||
|
kFSEventStreamCreateFlagNoDefer)
|
||||||
|
|
||||||
|
guard let stream = FSEventStreamCreate(
|
||||||
|
kCFAllocatorDefault,
|
||||||
|
Self.callback,
|
||||||
|
&context,
|
||||||
|
paths,
|
||||||
|
FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
|
||||||
|
0.05,
|
||||||
|
flags)
|
||||||
|
else {
|
||||||
|
retainedSelf.release()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.stream = stream
|
||||||
|
FSEventStreamSetDispatchQueue(stream, self.queue)
|
||||||
|
if FSEventStreamStart(stream) == false {
|
||||||
|
self.stream = nil
|
||||||
|
FSEventStreamSetDispatchQueue(stream, nil)
|
||||||
|
FSEventStreamInvalidate(stream)
|
||||||
|
FSEventStreamRelease(stream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
guard let stream = self.stream else { return }
|
||||||
|
self.stream = nil
|
||||||
|
FSEventStreamStop(stream)
|
||||||
|
FSEventStreamSetDispatchQueue(stream, nil)
|
||||||
|
FSEventStreamInvalidate(stream)
|
||||||
|
FSEventStreamRelease(stream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ConfigFileWatcher {
|
||||||
|
private static let callback: FSEventStreamCallback = { _, info, numEvents, eventPaths, eventFlags, _ in
|
||||||
|
guard let info else { return }
|
||||||
|
let watcher = Unmanaged<ConfigFileWatcher>.fromOpaque(info).takeUnretainedValue()
|
||||||
|
watcher.handleEvents(
|
||||||
|
numEvents: numEvents,
|
||||||
|
eventPaths: eventPaths,
|
||||||
|
eventFlags: eventFlags)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleEvents(
|
||||||
|
numEvents: Int,
|
||||||
|
eventPaths: UnsafeMutableRawPointer?,
|
||||||
|
eventFlags: UnsafePointer<FSEventStreamEventFlags>?)
|
||||||
|
{
|
||||||
|
guard numEvents > 0 else { return }
|
||||||
|
guard eventFlags != nil else { return }
|
||||||
|
guard self.matchesTarget(eventPaths: eventPaths) else { return }
|
||||||
|
|
||||||
|
if self.pending { return }
|
||||||
|
self.pending = true
|
||||||
|
self.queue.asyncAfter(deadline: .now() + 0.12) { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.pending = false
|
||||||
|
self.onChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func matchesTarget(eventPaths: UnsafeMutableRawPointer?) -> Bool {
|
||||||
|
guard let eventPaths else { return true }
|
||||||
|
let paths = unsafeBitCast(eventPaths, to: NSArray.self)
|
||||||
|
for case let path as String in paths {
|
||||||
|
if path == self.targetPath { return true }
|
||||||
|
if path.hasSuffix("/\(self.targetName)") { return true }
|
||||||
|
if path == self.watchedDir.path { return true }
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
79
apps/macos/Sources/Moltbot/ConnectionModeCoordinator.swift
Normal file
79
apps/macos/Sources/Moltbot/ConnectionModeCoordinator.swift
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ConnectionModeCoordinator {
|
||||||
|
static let shared = ConnectionModeCoordinator()
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "bot.molt", category: "connection")
|
||||||
|
private var lastMode: AppState.ConnectionMode?
|
||||||
|
|
||||||
|
/// Apply the requested connection mode by starting/stopping local gateway,
|
||||||
|
/// managing the control-channel SSH tunnel, and cleaning up chat windows/panels.
|
||||||
|
func apply(mode: AppState.ConnectionMode, paused: Bool) async {
|
||||||
|
if let lastMode = self.lastMode, lastMode != mode {
|
||||||
|
GatewayProcessManager.shared.clearLastFailure()
|
||||||
|
NodesStore.shared.lastError = nil
|
||||||
|
}
|
||||||
|
self.lastMode = mode
|
||||||
|
switch mode {
|
||||||
|
case .unconfigured:
|
||||||
|
_ = await NodeServiceManager.stop()
|
||||||
|
NodesStore.shared.lastError = nil
|
||||||
|
await RemoteTunnelManager.shared.stopAll()
|
||||||
|
WebChatManager.shared.resetTunnels()
|
||||||
|
GatewayProcessManager.shared.stop()
|
||||||
|
await GatewayConnection.shared.shutdown()
|
||||||
|
await ControlChannel.shared.disconnect()
|
||||||
|
Task.detached { await PortGuardian.shared.sweep(mode: .unconfigured) }
|
||||||
|
|
||||||
|
case .local:
|
||||||
|
_ = await NodeServiceManager.stop()
|
||||||
|
NodesStore.shared.lastError = nil
|
||||||
|
await RemoteTunnelManager.shared.stopAll()
|
||||||
|
WebChatManager.shared.resetTunnels()
|
||||||
|
let shouldStart = GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: paused)
|
||||||
|
if shouldStart {
|
||||||
|
GatewayProcessManager.shared.setActive(true)
|
||||||
|
if GatewayAutostartPolicy.shouldEnsureLaunchAgent(
|
||||||
|
mode: .local,
|
||||||
|
paused: paused)
|
||||||
|
{
|
||||||
|
Task { await GatewayProcessManager.shared.ensureLaunchAgentEnabledIfNeeded() }
|
||||||
|
}
|
||||||
|
_ = await GatewayProcessManager.shared.waitForGatewayReady()
|
||||||
|
} else {
|
||||||
|
GatewayProcessManager.shared.stop()
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try await ControlChannel.shared.configure(mode: .local)
|
||||||
|
} catch {
|
||||||
|
// Control channel will mark itself degraded; nothing else to do here.
|
||||||
|
self.logger.error(
|
||||||
|
"control channel local configure failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
Task.detached { await PortGuardian.shared.sweep(mode: .local) }
|
||||||
|
|
||||||
|
case .remote:
|
||||||
|
// Never run a local gateway in remote mode.
|
||||||
|
GatewayProcessManager.shared.stop()
|
||||||
|
WebChatManager.shared.resetTunnels()
|
||||||
|
|
||||||
|
do {
|
||||||
|
NodesStore.shared.lastError = nil
|
||||||
|
if let error = await NodeServiceManager.start() {
|
||||||
|
NodesStore.shared.lastError = "Node service start failed: \(error)"
|
||||||
|
}
|
||||||
|
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
|
||||||
|
let settings = CommandResolver.connectionSettings()
|
||||||
|
try await ControlChannel.shared.configure(mode: .remote(
|
||||||
|
target: settings.target,
|
||||||
|
identity: settings.identity))
|
||||||
|
} catch {
|
||||||
|
self.logger.error("remote tunnel/configure failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
|
Task.detached { await PortGuardian.shared.sweep(mode: .remote) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
apps/macos/Sources/Moltbot/Constants.swift
Normal file
44
apps/macos/Sources/Moltbot/Constants.swift
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
let launchdLabel = "bot.molt.mac"
|
||||||
|
let gatewayLaunchdLabel = "bot.molt.gateway"
|
||||||
|
let onboardingVersionKey = "moltbot.onboardingVersion"
|
||||||
|
let currentOnboardingVersion = 7
|
||||||
|
let pauseDefaultsKey = "moltbot.pauseEnabled"
|
||||||
|
let iconAnimationsEnabledKey = "moltbot.iconAnimationsEnabled"
|
||||||
|
let swabbleEnabledKey = "moltbot.swabbleEnabled"
|
||||||
|
let swabbleTriggersKey = "moltbot.swabbleTriggers"
|
||||||
|
let voiceWakeTriggerChimeKey = "moltbot.voiceWakeTriggerChime"
|
||||||
|
let voiceWakeSendChimeKey = "moltbot.voiceWakeSendChime"
|
||||||
|
let showDockIconKey = "moltbot.showDockIcon"
|
||||||
|
let defaultVoiceWakeTriggers = ["clawd", "claude"]
|
||||||
|
let voiceWakeMaxWords = 32
|
||||||
|
let voiceWakeMaxWordLength = 64
|
||||||
|
let voiceWakeMicKey = "moltbot.voiceWakeMicID"
|
||||||
|
let voiceWakeMicNameKey = "moltbot.voiceWakeMicName"
|
||||||
|
let voiceWakeLocaleKey = "moltbot.voiceWakeLocaleID"
|
||||||
|
let voiceWakeAdditionalLocalesKey = "moltbot.voiceWakeAdditionalLocaleIDs"
|
||||||
|
let voicePushToTalkEnabledKey = "moltbot.voicePushToTalkEnabled"
|
||||||
|
let talkEnabledKey = "moltbot.talkEnabled"
|
||||||
|
let iconOverrideKey = "moltbot.iconOverride"
|
||||||
|
let connectionModeKey = "moltbot.connectionMode"
|
||||||
|
let remoteTargetKey = "moltbot.remoteTarget"
|
||||||
|
let remoteIdentityKey = "moltbot.remoteIdentity"
|
||||||
|
let remoteProjectRootKey = "moltbot.remoteProjectRoot"
|
||||||
|
let remoteCliPathKey = "moltbot.remoteCliPath"
|
||||||
|
let canvasEnabledKey = "moltbot.canvasEnabled"
|
||||||
|
let cameraEnabledKey = "moltbot.cameraEnabled"
|
||||||
|
let systemRunPolicyKey = "moltbot.systemRunPolicy"
|
||||||
|
let systemRunAllowlistKey = "moltbot.systemRunAllowlist"
|
||||||
|
let systemRunEnabledKey = "moltbot.systemRunEnabled"
|
||||||
|
let locationModeKey = "moltbot.locationMode"
|
||||||
|
let locationPreciseKey = "moltbot.locationPreciseEnabled"
|
||||||
|
let peekabooBridgeEnabledKey = "moltbot.peekabooBridgeEnabled"
|
||||||
|
let deepLinkKeyKey = "moltbot.deepLinkKey"
|
||||||
|
let modelCatalogPathKey = "moltbot.modelCatalogPath"
|
||||||
|
let modelCatalogReloadKey = "moltbot.modelCatalogReload"
|
||||||
|
let cliInstallPromptedVersionKey = "moltbot.cliInstallPromptedVersion"
|
||||||
|
let heartbeatsEnabledKey = "moltbot.heartbeatsEnabled"
|
||||||
|
let debugFileLogEnabledKey = "moltbot.debug.fileLogEnabled"
|
||||||
|
let appLogLevelKey = "moltbot.debug.appLogLevel"
|
||||||
|
let voiceWakeSupported: Bool = ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26
|
||||||
427
apps/macos/Sources/Moltbot/ControlChannel.swift
Normal file
427
apps/macos/Sources/Moltbot/ControlChannel.swift
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
import MoltbotKit
|
||||||
|
import MoltbotProtocol
|
||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ControlHeartbeatEvent: Codable {
|
||||||
|
let ts: Double
|
||||||
|
let status: String
|
||||||
|
let to: String?
|
||||||
|
let preview: String?
|
||||||
|
let durationMs: Double?
|
||||||
|
let hasMedia: Bool?
|
||||||
|
let reason: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ControlAgentEvent: Codable, Sendable, Identifiable {
|
||||||
|
var id: String { "\(self.runId)-\(self.seq)" }
|
||||||
|
let runId: String
|
||||||
|
let seq: Int
|
||||||
|
let stream: String
|
||||||
|
let ts: Double
|
||||||
|
let data: [String: MoltbotProtocol.AnyCodable]
|
||||||
|
let summary: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ControlChannelError: Error, LocalizedError {
|
||||||
|
case disconnected
|
||||||
|
case badResponse(String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .disconnected: "Control channel disconnected"
|
||||||
|
case let .badResponse(msg): msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class ControlChannel {
|
||||||
|
static let shared = ControlChannel()
|
||||||
|
|
||||||
|
enum Mode {
|
||||||
|
case local
|
||||||
|
case remote(target: String, identity: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ConnectionState: Equatable {
|
||||||
|
case disconnected
|
||||||
|
case connecting
|
||||||
|
case connected
|
||||||
|
case degraded(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
private(set) var state: ConnectionState = .disconnected {
|
||||||
|
didSet {
|
||||||
|
CanvasManager.shared.refreshDebugStatus()
|
||||||
|
guard oldValue != self.state else { return }
|
||||||
|
switch self.state {
|
||||||
|
case .connected:
|
||||||
|
self.logger.info("control channel state -> connected")
|
||||||
|
case .connecting:
|
||||||
|
self.logger.info("control channel state -> connecting")
|
||||||
|
case .disconnected:
|
||||||
|
self.logger.info("control channel state -> disconnected")
|
||||||
|
self.scheduleRecovery(reason: "disconnected")
|
||||||
|
case let .degraded(message):
|
||||||
|
let detail = message.isEmpty ? "degraded" : "degraded: \(message)"
|
||||||
|
self.logger.info("control channel state -> \(detail, privacy: .public)")
|
||||||
|
self.scheduleRecovery(reason: message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private(set) var lastPingMs: Double?
|
||||||
|
private(set) var authSourceLabel: String?
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "bot.molt", category: "control")
|
||||||
|
|
||||||
|
private var eventTask: Task<Void, Never>?
|
||||||
|
private var recoveryTask: Task<Void, Never>?
|
||||||
|
private var lastRecoveryAt: Date?
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
self.startEventStream()
|
||||||
|
}
|
||||||
|
|
||||||
|
func configure() async {
|
||||||
|
self.logger.info("control channel configure mode=local")
|
||||||
|
await self.refreshEndpoint(reason: "configure")
|
||||||
|
}
|
||||||
|
|
||||||
|
func configure(mode: Mode = .local) async throws {
|
||||||
|
switch mode {
|
||||||
|
case .local:
|
||||||
|
await self.configure()
|
||||||
|
case let .remote(target, identity):
|
||||||
|
do {
|
||||||
|
_ = (target, identity)
|
||||||
|
let idSet = !identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
self.logger.info(
|
||||||
|
"control channel configure mode=remote " +
|
||||||
|
"target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)")
|
||||||
|
self.state = .connecting
|
||||||
|
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
|
||||||
|
await self.refreshEndpoint(reason: "configure")
|
||||||
|
} catch {
|
||||||
|
self.state = .degraded(error.localizedDescription)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshEndpoint(reason: String) async {
|
||||||
|
self.logger.info("control channel refresh endpoint reason=\(reason, privacy: .public)")
|
||||||
|
self.state = .connecting
|
||||||
|
do {
|
||||||
|
try await self.establishGatewayConnection()
|
||||||
|
self.state = .connected
|
||||||
|
PresenceReporter.shared.sendImmediate(reason: "connect")
|
||||||
|
} catch {
|
||||||
|
let message = self.friendlyGatewayMessage(error)
|
||||||
|
self.state = .degraded(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func disconnect() async {
|
||||||
|
await GatewayConnection.shared.shutdown()
|
||||||
|
self.state = .disconnected
|
||||||
|
self.lastPingMs = nil
|
||||||
|
self.authSourceLabel = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func health(timeout: TimeInterval? = nil) async throws -> Data {
|
||||||
|
do {
|
||||||
|
let start = Date()
|
||||||
|
var params: [String: AnyHashable]?
|
||||||
|
if let timeout {
|
||||||
|
params = ["timeout": AnyHashable(Int(timeout * 1000))]
|
||||||
|
}
|
||||||
|
let timeoutMs = (timeout ?? 15) * 1000
|
||||||
|
let payload = try await self.request(method: "health", params: params, timeoutMs: timeoutMs)
|
||||||
|
let ms = Date().timeIntervalSince(start) * 1000
|
||||||
|
self.lastPingMs = ms
|
||||||
|
self.state = .connected
|
||||||
|
return payload
|
||||||
|
} catch {
|
||||||
|
let message = self.friendlyGatewayMessage(error)
|
||||||
|
self.state = .degraded(message)
|
||||||
|
throw ControlChannelError.badResponse(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func lastHeartbeat() async throws -> ControlHeartbeatEvent? {
|
||||||
|
let data = try await self.request(method: "last-heartbeat")
|
||||||
|
return try JSONDecoder().decode(ControlHeartbeatEvent?.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func request(
|
||||||
|
method: String,
|
||||||
|
params: [String: AnyHashable]? = nil,
|
||||||
|
timeoutMs: Double? = nil) async throws -> Data
|
||||||
|
{
|
||||||
|
do {
|
||||||
|
let rawParams = params?.reduce(into: [String: MoltbotKit.AnyCodable]()) {
|
||||||
|
$0[$1.key] = MoltbotKit.AnyCodable($1.value.base)
|
||||||
|
}
|
||||||
|
let data = try await GatewayConnection.shared.request(
|
||||||
|
method: method,
|
||||||
|
params: rawParams,
|
||||||
|
timeoutMs: timeoutMs)
|
||||||
|
self.state = .connected
|
||||||
|
return data
|
||||||
|
} catch {
|
||||||
|
let message = self.friendlyGatewayMessage(error)
|
||||||
|
self.state = .degraded(message)
|
||||||
|
throw ControlChannelError.badResponse(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func friendlyGatewayMessage(_ error: Error) -> String {
|
||||||
|
// Map URLSession/WS errors into user-facing, actionable text.
|
||||||
|
if let ctrlErr = error as? ControlChannelError, let desc = ctrlErr.errorDescription {
|
||||||
|
return desc
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the gateway explicitly rejects the hello (e.g., auth/token mismatch), surface it.
|
||||||
|
if let urlErr = error as? URLError,
|
||||||
|
urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures
|
||||||
|
{
|
||||||
|
let reason = urlErr.failureURLString ?? urlErr.localizedDescription
|
||||||
|
let tokenKey = CommandResolver.connectionModeIsRemote()
|
||||||
|
? "gateway.remote.token"
|
||||||
|
: "gateway.auth.token"
|
||||||
|
return
|
||||||
|
"Gateway rejected token; set \(tokenKey) (or CLAWDBOT_GATEWAY_TOKEN) " +
|
||||||
|
"or clear it on the gateway. " +
|
||||||
|
"Reason: \(reason)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common misfire: we connected to the configured localhost port but it is occupied
|
||||||
|
// by some other process (e.g. a local dev gateway or a stuck SSH forward).
|
||||||
|
// The gateway handshake returns something we can't parse, which currently
|
||||||
|
// surfaces as "hello failed (unexpected response)". Give the user a pointer
|
||||||
|
// to free the port instead of a vague message.
|
||||||
|
let nsError = error as NSError
|
||||||
|
if nsError.domain == "Gateway",
|
||||||
|
nsError.localizedDescription.contains("hello failed (unexpected response)")
|
||||||
|
{
|
||||||
|
let port = GatewayEnvironment.gatewayPort()
|
||||||
|
return """
|
||||||
|
Gateway handshake got non-gateway data on localhost:\(port).
|
||||||
|
Another process is using that port or the SSH forward failed.
|
||||||
|
Stop the local gateway/port-forward on \(port) and retry Remote mode.
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
if let urlError = error as? URLError {
|
||||||
|
let port = GatewayEnvironment.gatewayPort()
|
||||||
|
switch urlError.code {
|
||||||
|
case .cancelled:
|
||||||
|
return "Gateway connection was closed; start the gateway (localhost:\(port)) and retry."
|
||||||
|
case .cannotFindHost, .cannotConnectToHost:
|
||||||
|
let isRemote = CommandResolver.connectionModeIsRemote()
|
||||||
|
if isRemote {
|
||||||
|
return """
|
||||||
|
Cannot reach gateway at localhost:\(port).
|
||||||
|
Remote mode uses an SSH tunnel—check the SSH target and that the tunnel is running.
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
return "Cannot reach gateway at localhost:\(port); ensure the gateway is running."
|
||||||
|
case .networkConnectionLost:
|
||||||
|
return "Gateway connection dropped; gateway likely restarted—retry."
|
||||||
|
case .timedOut:
|
||||||
|
return "Gateway request timed out; check gateway on localhost:\(port)."
|
||||||
|
case .notConnectedToInternet:
|
||||||
|
return "No network connectivity; cannot reach gateway."
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if nsError.domain == "Gateway", nsError.code == 5 {
|
||||||
|
let port = GatewayEnvironment.gatewayPort()
|
||||||
|
return "Gateway request timed out; check the gateway process on localhost:\(port)."
|
||||||
|
}
|
||||||
|
|
||||||
|
let detail = nsError.localizedDescription.isEmpty ? "unknown gateway error" : nsError.localizedDescription
|
||||||
|
let trimmed = detail.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmed.lowercased().hasPrefix("gateway error:") { return trimmed }
|
||||||
|
return "Gateway error: \(trimmed)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleRecovery(reason: String) {
|
||||||
|
let now = Date()
|
||||||
|
if let last = self.lastRecoveryAt, now.timeIntervalSince(last) < 10 { return }
|
||||||
|
guard self.recoveryTask == nil else { return }
|
||||||
|
self.lastRecoveryAt = now
|
||||||
|
|
||||||
|
self.recoveryTask = Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
let mode = await MainActor.run { AppStateStore.shared.connectionMode }
|
||||||
|
guard mode != .unconfigured else {
|
||||||
|
self.recoveryTask = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let trimmedReason = reason.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let reasonText = trimmedReason.isEmpty ? "unknown" : trimmedReason
|
||||||
|
self.logger.info(
|
||||||
|
"control channel recovery starting " +
|
||||||
|
"mode=\(String(describing: mode), privacy: .public) " +
|
||||||
|
"reason=\(reasonText, privacy: .public)")
|
||||||
|
if mode == .local {
|
||||||
|
GatewayProcessManager.shared.setActive(true)
|
||||||
|
}
|
||||||
|
if mode == .remote {
|
||||||
|
do {
|
||||||
|
let port = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
|
||||||
|
self.logger.info("control channel recovery ensured SSH tunnel port=\(port, privacy: .public)")
|
||||||
|
} catch {
|
||||||
|
self.logger.error(
|
||||||
|
"control channel recovery tunnel failed \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await self.refreshEndpoint(reason: "recovery:\(reasonText)")
|
||||||
|
if case .connected = self.state {
|
||||||
|
self.logger.info("control channel recovery finished")
|
||||||
|
} else if case let .degraded(message) = self.state {
|
||||||
|
self.logger.error("control channel recovery failed \(message, privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
|
self.recoveryTask = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func establishGatewayConnection(timeoutMs: Int = 5000) async throws {
|
||||||
|
try await GatewayConnection.shared.refresh()
|
||||||
|
let ok = try await GatewayConnection.shared.healthOK(timeoutMs: timeoutMs)
|
||||||
|
if ok == false {
|
||||||
|
throw NSError(
|
||||||
|
domain: "Gateway",
|
||||||
|
code: 0,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "gateway health not ok"])
|
||||||
|
}
|
||||||
|
await self.refreshAuthSourceLabel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshAuthSourceLabel() async {
|
||||||
|
let isRemote = CommandResolver.connectionModeIsRemote()
|
||||||
|
let authSource = await GatewayConnection.shared.authSource()
|
||||||
|
self.authSourceLabel = Self.formatAuthSource(authSource, isRemote: isRemote)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func formatAuthSource(_ source: GatewayAuthSource?, isRemote: Bool) -> String? {
|
||||||
|
guard let source else { return nil }
|
||||||
|
switch source {
|
||||||
|
case .deviceToken:
|
||||||
|
return "Auth: device token (paired device)"
|
||||||
|
case .sharedToken:
|
||||||
|
return "Auth: shared token (\(isRemote ? "gateway.remote.token" : "gateway.auth.token"))"
|
||||||
|
case .password:
|
||||||
|
return "Auth: password (\(isRemote ? "gateway.remote.password" : "gateway.auth.password"))"
|
||||||
|
case .none:
|
||||||
|
return "Auth: none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws {
|
||||||
|
var merged = params
|
||||||
|
merged["text"] = AnyHashable(text)
|
||||||
|
_ = try await self.request(method: "system-event", params: merged)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startEventStream() {
|
||||||
|
self.eventTask?.cancel()
|
||||||
|
self.eventTask = Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
let stream = await GatewayConnection.shared.subscribe()
|
||||||
|
for await push in stream {
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.handle(push: push)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handle(push: GatewayPush) {
|
||||||
|
switch push {
|
||||||
|
case let .event(evt) where evt.event == "agent":
|
||||||
|
if let payload = evt.payload,
|
||||||
|
let agent = try? GatewayPayloadDecoding.decode(payload, as: ControlAgentEvent.self)
|
||||||
|
{
|
||||||
|
AgentEventStore.shared.append(agent)
|
||||||
|
self.routeWorkActivity(from: agent)
|
||||||
|
}
|
||||||
|
case let .event(evt) where evt.event == "heartbeat":
|
||||||
|
if let payload = evt.payload,
|
||||||
|
let heartbeat = try? GatewayPayloadDecoding.decode(payload, as: ControlHeartbeatEvent.self),
|
||||||
|
let data = try? JSONEncoder().encode(heartbeat)
|
||||||
|
{
|
||||||
|
NotificationCenter.default.post(name: .controlHeartbeat, object: data)
|
||||||
|
}
|
||||||
|
case let .event(evt) where evt.event == "shutdown":
|
||||||
|
self.state = .degraded("gateway shutdown")
|
||||||
|
case .snapshot:
|
||||||
|
self.state = .connected
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func routeWorkActivity(from event: ControlAgentEvent) {
|
||||||
|
// We currently treat VoiceWake as the "main" session for UI purposes.
|
||||||
|
// In the future, the gateway can include a sessionKey to distinguish runs.
|
||||||
|
let sessionKey = (event.data["sessionKey"]?.value as? String) ?? "main"
|
||||||
|
|
||||||
|
switch event.stream.lowercased() {
|
||||||
|
case "job":
|
||||||
|
if let state = event.data["state"]?.value as? String {
|
||||||
|
WorkActivityStore.shared.handleJob(sessionKey: sessionKey, state: state)
|
||||||
|
}
|
||||||
|
case "tool":
|
||||||
|
let phase = event.data["phase"]?.value as? String ?? ""
|
||||||
|
let name = event.data["name"]?.value as? String
|
||||||
|
let meta = event.data["meta"]?.value as? String
|
||||||
|
let args = Self.bridgeToProtocolArgs(event.data["args"])
|
||||||
|
WorkActivityStore.shared.handleTool(
|
||||||
|
sessionKey: sessionKey,
|
||||||
|
phase: phase,
|
||||||
|
name: name,
|
||||||
|
meta: meta,
|
||||||
|
args: args)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func bridgeToProtocolArgs(
|
||||||
|
_ value: MoltbotProtocol.AnyCodable?) -> [String: MoltbotProtocol.AnyCodable]?
|
||||||
|
{
|
||||||
|
guard let value else { return nil }
|
||||||
|
if let dict = value.value as? [String: MoltbotProtocol.AnyCodable] {
|
||||||
|
return dict
|
||||||
|
}
|
||||||
|
if let dict = value.value as? [String: MoltbotKit.AnyCodable],
|
||||||
|
let data = try? JSONEncoder().encode(dict),
|
||||||
|
let decoded = try? JSONDecoder().decode([String: MoltbotProtocol.AnyCodable].self, from: data)
|
||||||
|
{
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
if let data = try? JSONEncoder().encode(value),
|
||||||
|
let decoded = try? JSONDecoder().decode([String: MoltbotProtocol.AnyCodable].self, from: data)
|
||||||
|
{
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Notification.Name {
|
||||||
|
static let controlHeartbeat = Notification.Name("moltbot.control.heartbeat")
|
||||||
|
static let controlAgentEvent = Notification.Name("moltbot.control.agent")
|
||||||
|
}
|
||||||
200
apps/macos/Sources/Moltbot/CronJobsStore.swift
Normal file
200
apps/macos/Sources/Moltbot/CronJobsStore.swift
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import MoltbotKit
|
||||||
|
import MoltbotProtocol
|
||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class CronJobsStore {
|
||||||
|
static let shared = CronJobsStore()
|
||||||
|
|
||||||
|
var jobs: [CronJob] = []
|
||||||
|
var selectedJobId: String?
|
||||||
|
var runEntries: [CronRunLogEntry] = []
|
||||||
|
|
||||||
|
var schedulerEnabled: Bool?
|
||||||
|
var schedulerStorePath: String?
|
||||||
|
var schedulerNextWakeAtMs: Int?
|
||||||
|
|
||||||
|
var isLoadingJobs = false
|
||||||
|
var isLoadingRuns = false
|
||||||
|
var lastError: String?
|
||||||
|
var statusMessage: String?
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "bot.molt", category: "cron.ui")
|
||||||
|
private var refreshTask: Task<Void, Never>?
|
||||||
|
private var runsTask: Task<Void, Never>?
|
||||||
|
private var eventTask: Task<Void, Never>?
|
||||||
|
private var pollTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
private let interval: TimeInterval = 30
|
||||||
|
private let isPreview: Bool
|
||||||
|
|
||||||
|
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
|
||||||
|
self.isPreview = isPreview
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard !self.isPreview else { return }
|
||||||
|
guard self.eventTask == nil else { return }
|
||||||
|
self.startGatewaySubscription()
|
||||||
|
self.pollTask = Task.detached { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
await self.refreshJobs()
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000))
|
||||||
|
await self.refreshJobs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
self.refreshTask?.cancel()
|
||||||
|
self.refreshTask = nil
|
||||||
|
self.runsTask?.cancel()
|
||||||
|
self.runsTask = nil
|
||||||
|
self.eventTask?.cancel()
|
||||||
|
self.eventTask = nil
|
||||||
|
self.pollTask?.cancel()
|
||||||
|
self.pollTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshJobs() async {
|
||||||
|
guard !self.isLoadingJobs else { return }
|
||||||
|
self.isLoadingJobs = true
|
||||||
|
self.lastError = nil
|
||||||
|
self.statusMessage = nil
|
||||||
|
defer { self.isLoadingJobs = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
if let status = try? await GatewayConnection.shared.cronStatus() {
|
||||||
|
self.schedulerEnabled = status.enabled
|
||||||
|
self.schedulerStorePath = status.storePath
|
||||||
|
self.schedulerNextWakeAtMs = status.nextWakeAtMs
|
||||||
|
}
|
||||||
|
self.jobs = try await GatewayConnection.shared.cronList(includeDisabled: true)
|
||||||
|
if self.jobs.isEmpty {
|
||||||
|
self.statusMessage = "No cron jobs yet."
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
self.logger.error("cron.list failed \(error.localizedDescription, privacy: .public)")
|
||||||
|
self.lastError = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshRuns(jobId: String, limit: Int = 200) async {
|
||||||
|
guard !self.isLoadingRuns else { return }
|
||||||
|
self.isLoadingRuns = true
|
||||||
|
defer { self.isLoadingRuns = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
self.runEntries = try await GatewayConnection.shared.cronRuns(jobId: jobId, limit: limit)
|
||||||
|
} catch {
|
||||||
|
self.logger.error("cron.runs failed \(error.localizedDescription, privacy: .public)")
|
||||||
|
self.lastError = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runJob(id: String, force: Bool = true) async {
|
||||||
|
do {
|
||||||
|
try await GatewayConnection.shared.cronRun(jobId: id, force: force)
|
||||||
|
} catch {
|
||||||
|
self.lastError = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeJob(id: String) async {
|
||||||
|
do {
|
||||||
|
try await GatewayConnection.shared.cronRemove(jobId: id)
|
||||||
|
await self.refreshJobs()
|
||||||
|
if self.selectedJobId == id {
|
||||||
|
self.selectedJobId = nil
|
||||||
|
self.runEntries = []
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
self.lastError = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setJobEnabled(id: String, enabled: Bool) async {
|
||||||
|
do {
|
||||||
|
try await GatewayConnection.shared.cronUpdate(
|
||||||
|
jobId: id,
|
||||||
|
patch: ["enabled": AnyCodable(enabled)])
|
||||||
|
await self.refreshJobs()
|
||||||
|
} catch {
|
||||||
|
self.lastError = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func upsertJob(
|
||||||
|
id: String?,
|
||||||
|
payload: [String: AnyCodable]) async throws
|
||||||
|
{
|
||||||
|
if let id {
|
||||||
|
try await GatewayConnection.shared.cronUpdate(jobId: id, patch: payload)
|
||||||
|
} else {
|
||||||
|
try await GatewayConnection.shared.cronAdd(payload: payload)
|
||||||
|
}
|
||||||
|
await self.refreshJobs()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Gateway events
|
||||||
|
|
||||||
|
private func startGatewaySubscription() {
|
||||||
|
self.eventTask?.cancel()
|
||||||
|
self.eventTask = Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
let stream = await GatewayConnection.shared.subscribe()
|
||||||
|
for await push in stream {
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.handle(push: push)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handle(push: GatewayPush) {
|
||||||
|
switch push {
|
||||||
|
case let .event(evt) where evt.event == "cron":
|
||||||
|
guard let payload = evt.payload else { return }
|
||||||
|
if let cronEvt = try? GatewayPayloadDecoding.decode(payload, as: CronEvent.self) {
|
||||||
|
self.handle(cronEvent: cronEvt)
|
||||||
|
}
|
||||||
|
case .seqGap:
|
||||||
|
self.scheduleRefresh()
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handle(cronEvent evt: CronEvent) {
|
||||||
|
// Keep UI in sync with the gateway scheduler.
|
||||||
|
self.scheduleRefresh(delayMs: 250)
|
||||||
|
if evt.action == "finished", let selected = self.selectedJobId, selected == evt.jobId {
|
||||||
|
self.scheduleRunsRefresh(jobId: selected, delayMs: 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleRefresh(delayMs: Int = 250) {
|
||||||
|
self.refreshTask?.cancel()
|
||||||
|
self.refreshTask = Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
|
||||||
|
await self.refreshJobs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleRunsRefresh(jobId: String, delayMs: Int = 200) {
|
||||||
|
self.runsTask?.cancel()
|
||||||
|
self.runsTask = Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
|
||||||
|
await self.refreshRuns(jobId: jobId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - (no additional RPC helpers)
|
||||||
|
}
|
||||||
151
apps/macos/Sources/Moltbot/DeepLinks.swift
Normal file
151
apps/macos/Sources/Moltbot/DeepLinks.swift
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import AppKit
|
||||||
|
import MoltbotKit
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
import Security
|
||||||
|
|
||||||
|
private let deepLinkLogger = Logger(subsystem: "bot.molt", category: "DeepLink")
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class DeepLinkHandler {
|
||||||
|
static let shared = DeepLinkHandler()
|
||||||
|
|
||||||
|
private var lastPromptAt: Date = .distantPast
|
||||||
|
|
||||||
|
// Ephemeral, in-memory key used for unattended deep links originating from the in-app Canvas.
|
||||||
|
// This avoids blocking Canvas init on UserDefaults and doesn't weaken the external deep-link prompt:
|
||||||
|
// outside callers can't know this randomly generated key.
|
||||||
|
private nonisolated static let canvasUnattendedKey: String = DeepLinkHandler.generateRandomKey()
|
||||||
|
|
||||||
|
func handle(url: URL) async {
|
||||||
|
guard let route = DeepLinkParser.parse(url) else {
|
||||||
|
deepLinkLogger.debug("ignored url \(url.absoluteString, privacy: .public)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard !AppStateStore.shared.isPaused else {
|
||||||
|
self.presentAlert(title: "Moltbot is paused", message: "Unpause Moltbot to run agent actions.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch route {
|
||||||
|
case let .agent(link):
|
||||||
|
await self.handleAgent(link: link, originalURL: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleAgent(link: AgentDeepLink, originalURL: URL) async {
|
||||||
|
let messagePreview = link.message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if messagePreview.count > 20000 {
|
||||||
|
self.presentAlert(title: "Deep link too large", message: "Message exceeds 20,000 characters.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let allowUnattended = link.key == Self.canvasUnattendedKey || link.key == Self.expectedKey()
|
||||||
|
if !allowUnattended {
|
||||||
|
if Date().timeIntervalSince(self.lastPromptAt) < 1.0 {
|
||||||
|
deepLinkLogger.debug("throttling deep link prompt")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.lastPromptAt = Date()
|
||||||
|
|
||||||
|
let trimmed = messagePreview.count > 240 ? "\(messagePreview.prefix(240))…" : messagePreview
|
||||||
|
let body =
|
||||||
|
"Run the agent with this message?\n\n\(trimmed)\n\nURL:\n\(originalURL.absoluteString)"
|
||||||
|
guard self.confirm(title: "Run Moltbot agent?", message: body) else { return }
|
||||||
|
}
|
||||||
|
|
||||||
|
if AppStateStore.shared.connectionMode == .local {
|
||||||
|
GatewayProcessManager.shared.setActive(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let channel = GatewayAgentChannel(raw: link.channel)
|
||||||
|
let explicitSessionKey = link.sessionKey?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.nonEmpty
|
||||||
|
let resolvedSessionKey: String = if let explicitSessionKey {
|
||||||
|
explicitSessionKey
|
||||||
|
} else {
|
||||||
|
await GatewayConnection.shared.mainSessionKey()
|
||||||
|
}
|
||||||
|
let invocation = GatewayAgentInvocation(
|
||||||
|
message: messagePreview,
|
||||||
|
sessionKey: resolvedSessionKey,
|
||||||
|
thinking: link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
|
||||||
|
deliver: channel.shouldDeliver(link.deliver),
|
||||||
|
to: link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
|
||||||
|
channel: channel,
|
||||||
|
timeoutSeconds: link.timeoutSeconds,
|
||||||
|
idempotencyKey: UUID().uuidString)
|
||||||
|
|
||||||
|
let res = await GatewayConnection.shared.sendAgent(invocation)
|
||||||
|
if !res.ok {
|
||||||
|
throw NSError(
|
||||||
|
domain: "DeepLink",
|
||||||
|
code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: res.error ?? "agent request failed"])
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
self.presentAlert(title: "Agent request failed", message: error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Auth
|
||||||
|
|
||||||
|
static func currentKey() -> String {
|
||||||
|
self.expectedKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func currentCanvasKey() -> String {
|
||||||
|
self.canvasUnattendedKey
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func expectedKey() -> String {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
if let key = defaults.string(forKey: deepLinkKeyKey), !key.isEmpty {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
var bytes = [UInt8](repeating: 0, count: 32)
|
||||||
|
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||||
|
let data = Data(bytes)
|
||||||
|
let key = data
|
||||||
|
.base64EncodedString()
|
||||||
|
.replacingOccurrences(of: "+", with: "-")
|
||||||
|
.replacingOccurrences(of: "/", with: "_")
|
||||||
|
.replacingOccurrences(of: "=", with: "")
|
||||||
|
defaults.set(key, forKey: deepLinkKeyKey)
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func generateRandomKey() -> String {
|
||||||
|
var bytes = [UInt8](repeating: 0, count: 32)
|
||||||
|
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||||
|
let data = Data(bytes)
|
||||||
|
return data
|
||||||
|
.base64EncodedString()
|
||||||
|
.replacingOccurrences(of: "+", with: "-")
|
||||||
|
.replacingOccurrences(of: "/", with: "_")
|
||||||
|
.replacingOccurrences(of: "=", with: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UI
|
||||||
|
|
||||||
|
private func confirm(title: String, message: String) -> Bool {
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.messageText = title
|
||||||
|
alert.informativeText = message
|
||||||
|
alert.addButton(withTitle: "Run")
|
||||||
|
alert.addButton(withTitle: "Cancel")
|
||||||
|
alert.alertStyle = .warning
|
||||||
|
return alert.runModal() == .alertFirstButtonReturn
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentAlert(title: String, message: String) {
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.messageText = title
|
||||||
|
alert.informativeText = message
|
||||||
|
alert.addButton(withTitle: "OK")
|
||||||
|
alert.alertStyle = .informational
|
||||||
|
alert.runModal()
|
||||||
|
}
|
||||||
|
}
|
||||||
334
apps/macos/Sources/Moltbot/DevicePairingApprovalPrompter.swift
Normal file
334
apps/macos/Sources/Moltbot/DevicePairingApprovalPrompter.swift
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
import AppKit
|
||||||
|
import MoltbotKit
|
||||||
|
import MoltbotProtocol
|
||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class DevicePairingApprovalPrompter {
|
||||||
|
static let shared = DevicePairingApprovalPrompter()
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "bot.molt", category: "device-pairing")
|
||||||
|
private var task: Task<Void, Never>?
|
||||||
|
private var isStopping = false
|
||||||
|
private var isPresenting = false
|
||||||
|
private var queue: [PendingRequest] = []
|
||||||
|
var pendingCount: Int = 0
|
||||||
|
var pendingRepairCount: Int = 0
|
||||||
|
private var activeAlert: NSAlert?
|
||||||
|
private var activeRequestId: String?
|
||||||
|
private var alertHostWindow: NSWindow?
|
||||||
|
private var resolvedByRequestId: Set<String> = []
|
||||||
|
|
||||||
|
private final class AlertHostWindow: NSWindow {
|
||||||
|
override var canBecomeKey: Bool { true }
|
||||||
|
override var canBecomeMain: Bool { true }
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PairingList: Codable {
|
||||||
|
let pending: [PendingRequest]
|
||||||
|
let paired: [PairedDevice]?
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PairedDevice: Codable, Equatable {
|
||||||
|
let deviceId: String
|
||||||
|
let approvedAtMs: Double?
|
||||||
|
let displayName: String?
|
||||||
|
let platform: String?
|
||||||
|
let remoteIp: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PendingRequest: Codable, Equatable, Identifiable {
|
||||||
|
let requestId: String
|
||||||
|
let deviceId: String
|
||||||
|
let publicKey: String
|
||||||
|
let displayName: String?
|
||||||
|
let platform: String?
|
||||||
|
let clientId: String?
|
||||||
|
let clientMode: String?
|
||||||
|
let role: String?
|
||||||
|
let scopes: [String]?
|
||||||
|
let remoteIp: String?
|
||||||
|
let silent: Bool?
|
||||||
|
let isRepair: Bool?
|
||||||
|
let ts: Double
|
||||||
|
|
||||||
|
var id: String { self.requestId }
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PairingResolvedEvent: Codable {
|
||||||
|
let requestId: String
|
||||||
|
let deviceId: String
|
||||||
|
let decision: String
|
||||||
|
let ts: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum PairingResolution: String {
|
||||||
|
case approved
|
||||||
|
case rejected
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard self.task == nil else { return }
|
||||||
|
self.isStopping = false
|
||||||
|
self.task = Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
_ = try? await GatewayConnection.shared.refresh()
|
||||||
|
await self.loadPendingRequestsFromGateway()
|
||||||
|
let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200)
|
||||||
|
for await push in stream {
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
await MainActor.run { [weak self] in self?.handle(push: push) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
self.isStopping = true
|
||||||
|
self.endActiveAlert()
|
||||||
|
self.task?.cancel()
|
||||||
|
self.task = nil
|
||||||
|
self.queue.removeAll(keepingCapacity: false)
|
||||||
|
self.updatePendingCounts()
|
||||||
|
self.isPresenting = false
|
||||||
|
self.activeRequestId = nil
|
||||||
|
self.alertHostWindow?.orderOut(nil)
|
||||||
|
self.alertHostWindow?.close()
|
||||||
|
self.alertHostWindow = nil
|
||||||
|
self.resolvedByRequestId.removeAll(keepingCapacity: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadPendingRequestsFromGateway() async {
|
||||||
|
do {
|
||||||
|
let list: PairingList = try await GatewayConnection.shared.requestDecoded(method: .devicePairList)
|
||||||
|
await self.apply(list: list)
|
||||||
|
} catch {
|
||||||
|
self.logger.error("failed to load device pairing requests: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func apply(list: PairingList) async {
|
||||||
|
self.queue = list.pending.sorted(by: { $0.ts > $1.ts })
|
||||||
|
self.updatePendingCounts()
|
||||||
|
self.presentNextIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updatePendingCounts() {
|
||||||
|
self.pendingCount = self.queue.count
|
||||||
|
self.pendingRepairCount = self.queue.count(where: { $0.isRepair == true })
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentNextIfNeeded() {
|
||||||
|
guard !self.isStopping else { return }
|
||||||
|
guard !self.isPresenting else { return }
|
||||||
|
guard let next = self.queue.first else { return }
|
||||||
|
self.isPresenting = true
|
||||||
|
self.presentAlert(for: next)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentAlert(for req: PendingRequest) {
|
||||||
|
self.logger.info("presenting device pairing alert requestId=\(req.requestId, privacy: .public)")
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.alertStyle = .warning
|
||||||
|
alert.messageText = "Allow device to connect?"
|
||||||
|
alert.informativeText = Self.describe(req)
|
||||||
|
alert.addButton(withTitle: "Later")
|
||||||
|
alert.addButton(withTitle: "Approve")
|
||||||
|
alert.addButton(withTitle: "Reject")
|
||||||
|
if #available(macOS 11.0, *), alert.buttons.indices.contains(2) {
|
||||||
|
alert.buttons[2].hasDestructiveAction = true
|
||||||
|
}
|
||||||
|
|
||||||
|
self.activeAlert = alert
|
||||||
|
self.activeRequestId = req.requestId
|
||||||
|
let hostWindow = self.requireAlertHostWindow()
|
||||||
|
|
||||||
|
let sheetSize = alert.window.frame.size
|
||||||
|
if let screen = hostWindow.screen ?? NSScreen.main {
|
||||||
|
let bounds = screen.visibleFrame
|
||||||
|
let x = bounds.midX - (sheetSize.width / 2)
|
||||||
|
let sheetOriginY = bounds.midY - (sheetSize.height / 2)
|
||||||
|
let hostY = sheetOriginY + sheetSize.height - hostWindow.frame.height
|
||||||
|
hostWindow.setFrameOrigin(NSPoint(x: x, y: hostY))
|
||||||
|
} else {
|
||||||
|
hostWindow.center()
|
||||||
|
}
|
||||||
|
|
||||||
|
hostWindow.makeKeyAndOrderFront(nil)
|
||||||
|
alert.beginSheetModal(for: hostWindow) { [weak self] response in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.activeRequestId = nil
|
||||||
|
self.activeAlert = nil
|
||||||
|
await self.handleAlertResponse(response, request: req)
|
||||||
|
hostWindow.orderOut(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleAlertResponse(_ response: NSApplication.ModalResponse, request: PendingRequest) async {
|
||||||
|
var shouldRemove = response != .alertFirstButtonReturn
|
||||||
|
defer {
|
||||||
|
if shouldRemove {
|
||||||
|
if self.queue.first == request {
|
||||||
|
self.queue.removeFirst()
|
||||||
|
} else {
|
||||||
|
self.queue.removeAll { $0 == request }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.updatePendingCounts()
|
||||||
|
self.isPresenting = false
|
||||||
|
self.presentNextIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !self.isStopping else { return }
|
||||||
|
|
||||||
|
if self.resolvedByRequestId.remove(request.requestId) != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch response {
|
||||||
|
case .alertFirstButtonReturn:
|
||||||
|
shouldRemove = false
|
||||||
|
if let idx = self.queue.firstIndex(of: request) {
|
||||||
|
self.queue.remove(at: idx)
|
||||||
|
}
|
||||||
|
self.queue.append(request)
|
||||||
|
return
|
||||||
|
case .alertSecondButtonReturn:
|
||||||
|
_ = await self.approve(requestId: request.requestId)
|
||||||
|
case .alertThirdButtonReturn:
|
||||||
|
await self.reject(requestId: request.requestId)
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func approve(requestId: String) async -> Bool {
|
||||||
|
do {
|
||||||
|
try await GatewayConnection.shared.devicePairApprove(requestId: requestId)
|
||||||
|
self.logger.info("approved device pairing requestId=\(requestId, privacy: .public)")
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
self.logger.error("approve failed requestId=\(requestId, privacy: .public)")
|
||||||
|
self.logger.error("approve failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reject(requestId: String) async {
|
||||||
|
do {
|
||||||
|
try await GatewayConnection.shared.devicePairReject(requestId: requestId)
|
||||||
|
self.logger.info("rejected device pairing requestId=\(requestId, privacy: .public)")
|
||||||
|
} catch {
|
||||||
|
self.logger.error("reject failed requestId=\(requestId, privacy: .public)")
|
||||||
|
self.logger.error("reject failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func endActiveAlert() {
|
||||||
|
guard let alert = self.activeAlert else { return }
|
||||||
|
if let parent = alert.window.sheetParent {
|
||||||
|
parent.endSheet(alert.window, returnCode: .abort)
|
||||||
|
}
|
||||||
|
self.activeAlert = nil
|
||||||
|
self.activeRequestId = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requireAlertHostWindow() -> NSWindow {
|
||||||
|
if let alertHostWindow {
|
||||||
|
return alertHostWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
let window = AlertHostWindow(
|
||||||
|
contentRect: NSRect(x: 0, y: 0, width: 520, height: 1),
|
||||||
|
styleMask: [.borderless],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false)
|
||||||
|
window.title = ""
|
||||||
|
window.isReleasedWhenClosed = false
|
||||||
|
window.level = .floating
|
||||||
|
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||||||
|
window.isOpaque = false
|
||||||
|
window.hasShadow = false
|
||||||
|
window.backgroundColor = .clear
|
||||||
|
window.ignoresMouseEvents = true
|
||||||
|
|
||||||
|
self.alertHostWindow = window
|
||||||
|
return window
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handle(push: GatewayPush) {
|
||||||
|
switch push {
|
||||||
|
case let .event(evt) where evt.event == "device.pair.requested":
|
||||||
|
guard let payload = evt.payload else { return }
|
||||||
|
do {
|
||||||
|
let req = try GatewayPayloadDecoding.decode(payload, as: PendingRequest.self)
|
||||||
|
self.enqueue(req)
|
||||||
|
} catch {
|
||||||
|
self.logger
|
||||||
|
.error("failed to decode device pairing request: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
case let .event(evt) where evt.event == "device.pair.resolved":
|
||||||
|
guard let payload = evt.payload else { return }
|
||||||
|
do {
|
||||||
|
let resolved = try GatewayPayloadDecoding.decode(payload, as: PairingResolvedEvent.self)
|
||||||
|
self.handleResolved(resolved)
|
||||||
|
} catch {
|
||||||
|
self.logger
|
||||||
|
.error(
|
||||||
|
"failed to decode device pairing resolution: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func enqueue(_ req: PendingRequest) {
|
||||||
|
guard !self.queue.contains(req) else { return }
|
||||||
|
self.queue.append(req)
|
||||||
|
self.updatePendingCounts()
|
||||||
|
self.presentNextIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleResolved(_ resolved: PairingResolvedEvent) {
|
||||||
|
let resolution = resolved.decision == PairingResolution.approved.rawValue ? PairingResolution
|
||||||
|
.approved : .rejected
|
||||||
|
if let activeRequestId, activeRequestId == resolved.requestId {
|
||||||
|
self.resolvedByRequestId.insert(resolved.requestId)
|
||||||
|
self.endActiveAlert()
|
||||||
|
let decision = resolution.rawValue
|
||||||
|
self.logger.info(
|
||||||
|
"device pairing resolved while active requestId=\(resolved.requestId, privacy: .public) " +
|
||||||
|
"decision=\(decision, privacy: .public)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.queue.removeAll { $0.requestId == resolved.requestId }
|
||||||
|
self.updatePendingCounts()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func describe(_ req: PendingRequest) -> String {
|
||||||
|
var lines: [String] = []
|
||||||
|
lines.append("Device: \(req.displayName ?? req.deviceId)")
|
||||||
|
if let platform = req.platform {
|
||||||
|
lines.append("Platform: \(platform)")
|
||||||
|
}
|
||||||
|
if let role = req.role {
|
||||||
|
lines.append("Role: \(role)")
|
||||||
|
}
|
||||||
|
if let scopes = req.scopes, !scopes.isEmpty {
|
||||||
|
lines.append("Scopes: \(scopes.joined(separator: ", "))")
|
||||||
|
}
|
||||||
|
if let remoteIp = req.remoteIp {
|
||||||
|
lines.append("IP: \(remoteIp)")
|
||||||
|
}
|
||||||
|
if req.isRepair == true {
|
||||||
|
lines.append("Repair: yes")
|
||||||
|
}
|
||||||
|
return lines.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
116
apps/macos/Sources/Moltbot/DockIconManager.swift
Normal file
116
apps/macos/Sources/Moltbot/DockIconManager.swift
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import AppKit
|
||||||
|
|
||||||
|
/// Central manager for Dock icon visibility.
|
||||||
|
/// Shows the Dock icon while any windows are visible, regardless of user preference.
|
||||||
|
final class DockIconManager: NSObject, @unchecked Sendable {
|
||||||
|
static let shared = DockIconManager()
|
||||||
|
|
||||||
|
private var windowsObservation: NSKeyValueObservation?
|
||||||
|
private let logger = Logger(subsystem: "bot.molt", category: "DockIconManager")
|
||||||
|
|
||||||
|
override private init() {
|
||||||
|
super.init()
|
||||||
|
self.setupObservers()
|
||||||
|
Task { @MainActor in
|
||||||
|
self.updateDockVisibility()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
self.windowsObservation?.invalidate()
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateDockVisibility() {
|
||||||
|
Task { @MainActor in
|
||||||
|
guard NSApp != nil else {
|
||||||
|
self.logger.warning("NSApp not ready, skipping Dock visibility update")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let userWantsDockHidden = !UserDefaults.standard.bool(forKey: showDockIconKey)
|
||||||
|
let visibleWindows = NSApp?.windows.filter { window in
|
||||||
|
window.isVisible &&
|
||||||
|
window.frame.width > 1 &&
|
||||||
|
window.frame.height > 1 &&
|
||||||
|
!window.isKind(of: NSPanel.self) &&
|
||||||
|
"\(type(of: window))" != "NSPopupMenuWindow" &&
|
||||||
|
window.contentViewController != nil
|
||||||
|
} ?? []
|
||||||
|
|
||||||
|
let hasVisibleWindows = !visibleWindows.isEmpty
|
||||||
|
if !userWantsDockHidden || hasVisibleWindows {
|
||||||
|
NSApp?.setActivationPolicy(.regular)
|
||||||
|
} else {
|
||||||
|
NSApp?.setActivationPolicy(.accessory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func temporarilyShowDock() {
|
||||||
|
Task { @MainActor in
|
||||||
|
guard NSApp != nil else {
|
||||||
|
self.logger.warning("NSApp not ready, cannot show Dock icon")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
NSApp.setActivationPolicy(.regular)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupObservers() {
|
||||||
|
Task { @MainActor in
|
||||||
|
guard let app = NSApp else {
|
||||||
|
self.logger.warning("NSApp not ready, delaying Dock observers")
|
||||||
|
try? await Task.sleep(for: .milliseconds(200))
|
||||||
|
self.setupObservers()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.windowsObservation = app.observe(\.windows, options: [.new]) { [weak self] _, _ in
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await Task.sleep(for: .milliseconds(50))
|
||||||
|
self?.updateDockVisibility()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(self.windowVisibilityChanged),
|
||||||
|
name: NSWindow.didBecomeKeyNotification,
|
||||||
|
object: nil)
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(self.windowVisibilityChanged),
|
||||||
|
name: NSWindow.didResignKeyNotification,
|
||||||
|
object: nil)
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(self.windowVisibilityChanged),
|
||||||
|
name: NSWindow.willCloseNotification,
|
||||||
|
object: nil)
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(self.dockPreferenceChanged),
|
||||||
|
name: UserDefaults.didChangeNotification,
|
||||||
|
object: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc
|
||||||
|
private func windowVisibilityChanged(_: Notification) {
|
||||||
|
Task { @MainActor in
|
||||||
|
self.updateDockVisibility()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc
|
||||||
|
private func dockPreferenceChanged(_ notification: Notification) {
|
||||||
|
guard let userDefaults = notification.object as? UserDefaults,
|
||||||
|
userDefaults == UserDefaults.standard
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
Task { @MainActor in
|
||||||
|
self.updateDockVisibility()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
790
apps/macos/Sources/Moltbot/ExecApprovals.swift
Normal file
790
apps/macos/Sources/Moltbot/ExecApprovals.swift
Normal file
@@ -0,0 +1,790 @@
|
|||||||
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
import Security
|
||||||
|
|
||||||
|
enum ExecSecurity: String, CaseIterable, Codable, Identifiable {
|
||||||
|
case deny
|
||||||
|
case allowlist
|
||||||
|
case full
|
||||||
|
|
||||||
|
var id: String { self.rawValue }
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .deny: "Deny"
|
||||||
|
case .allowlist: "Allowlist"
|
||||||
|
case .full: "Always Allow"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ExecApprovalQuickMode: String, CaseIterable, Identifiable {
|
||||||
|
case deny
|
||||||
|
case ask
|
||||||
|
case allow
|
||||||
|
|
||||||
|
var id: String { self.rawValue }
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .deny: "Deny"
|
||||||
|
case .ask: "Always Ask"
|
||||||
|
case .allow: "Always Allow"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var security: ExecSecurity {
|
||||||
|
switch self {
|
||||||
|
case .deny: .deny
|
||||||
|
case .ask: .allowlist
|
||||||
|
case .allow: .full
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var ask: ExecAsk {
|
||||||
|
switch self {
|
||||||
|
case .deny: .off
|
||||||
|
case .ask: .onMiss
|
||||||
|
case .allow: .off
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func from(security: ExecSecurity, ask: ExecAsk) -> ExecApprovalQuickMode {
|
||||||
|
switch security {
|
||||||
|
case .deny:
|
||||||
|
.deny
|
||||||
|
case .full:
|
||||||
|
.allow
|
||||||
|
case .allowlist:
|
||||||
|
.ask
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ExecAsk: String, CaseIterable, Codable, Identifiable {
|
||||||
|
case off
|
||||||
|
case onMiss = "on-miss"
|
||||||
|
case always
|
||||||
|
|
||||||
|
var id: String { self.rawValue }
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .off: "Never Ask"
|
||||||
|
case .onMiss: "Ask on Allowlist Miss"
|
||||||
|
case .always: "Always Ask"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ExecApprovalDecision: String, Codable, Sendable {
|
||||||
|
case allowOnce = "allow-once"
|
||||||
|
case allowAlways = "allow-always"
|
||||||
|
case deny
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExecAllowlistEntry: Codable, Hashable, Identifiable {
|
||||||
|
var id: UUID
|
||||||
|
var pattern: String
|
||||||
|
var lastUsedAt: Double?
|
||||||
|
var lastUsedCommand: String?
|
||||||
|
var lastResolvedPath: String?
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
pattern: String,
|
||||||
|
lastUsedAt: Double? = nil,
|
||||||
|
lastUsedCommand: String? = nil,
|
||||||
|
lastResolvedPath: String? = nil)
|
||||||
|
{
|
||||||
|
self.id = id
|
||||||
|
self.pattern = pattern
|
||||||
|
self.lastUsedAt = lastUsedAt
|
||||||
|
self.lastUsedCommand = lastUsedCommand
|
||||||
|
self.lastResolvedPath = lastResolvedPath
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case pattern
|
||||||
|
case lastUsedAt
|
||||||
|
case lastUsedCommand
|
||||||
|
case lastResolvedPath
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
self.id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
|
||||||
|
self.pattern = try container.decode(String.self, forKey: .pattern)
|
||||||
|
self.lastUsedAt = try container.decodeIfPresent(Double.self, forKey: .lastUsedAt)
|
||||||
|
self.lastUsedCommand = try container.decodeIfPresent(String.self, forKey: .lastUsedCommand)
|
||||||
|
self.lastResolvedPath = try container.decodeIfPresent(String.self, forKey: .lastResolvedPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(self.id, forKey: .id)
|
||||||
|
try container.encode(self.pattern, forKey: .pattern)
|
||||||
|
try container.encodeIfPresent(self.lastUsedAt, forKey: .lastUsedAt)
|
||||||
|
try container.encodeIfPresent(self.lastUsedCommand, forKey: .lastUsedCommand)
|
||||||
|
try container.encodeIfPresent(self.lastResolvedPath, forKey: .lastResolvedPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExecApprovalsDefaults: Codable {
|
||||||
|
var security: ExecSecurity?
|
||||||
|
var ask: ExecAsk?
|
||||||
|
var askFallback: ExecSecurity?
|
||||||
|
var autoAllowSkills: Bool?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExecApprovalsAgent: Codable {
|
||||||
|
var security: ExecSecurity?
|
||||||
|
var ask: ExecAsk?
|
||||||
|
var askFallback: ExecSecurity?
|
||||||
|
var autoAllowSkills: Bool?
|
||||||
|
var allowlist: [ExecAllowlistEntry]?
|
||||||
|
|
||||||
|
var isEmpty: Bool {
|
||||||
|
self.security == nil && self.ask == nil && self.askFallback == nil && self
|
||||||
|
.autoAllowSkills == nil && (self.allowlist?.isEmpty ?? true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExecApprovalsSocketConfig: Codable {
|
||||||
|
var path: String?
|
||||||
|
var token: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExecApprovalsFile: Codable {
|
||||||
|
var version: Int
|
||||||
|
var socket: ExecApprovalsSocketConfig?
|
||||||
|
var defaults: ExecApprovalsDefaults?
|
||||||
|
var agents: [String: ExecApprovalsAgent]?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExecApprovalsSnapshot: Codable {
|
||||||
|
var path: String
|
||||||
|
var exists: Bool
|
||||||
|
var hash: String
|
||||||
|
var file: ExecApprovalsFile
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExecApprovalsResolved {
|
||||||
|
let url: URL
|
||||||
|
let socketPath: String
|
||||||
|
let token: String
|
||||||
|
let defaults: ExecApprovalsResolvedDefaults
|
||||||
|
let agent: ExecApprovalsResolvedDefaults
|
||||||
|
let allowlist: [ExecAllowlistEntry]
|
||||||
|
var file: ExecApprovalsFile
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExecApprovalsResolvedDefaults {
|
||||||
|
var security: ExecSecurity
|
||||||
|
var ask: ExecAsk
|
||||||
|
var askFallback: ExecSecurity
|
||||||
|
var autoAllowSkills: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ExecApprovalsStore {
|
||||||
|
private static let logger = Logger(subsystem: "bot.molt", category: "exec-approvals")
|
||||||
|
private static let defaultAgentId = "main"
|
||||||
|
private static let defaultSecurity: ExecSecurity = .deny
|
||||||
|
private static let defaultAsk: ExecAsk = .onMiss
|
||||||
|
private static let defaultAskFallback: ExecSecurity = .deny
|
||||||
|
private static let defaultAutoAllowSkills = false
|
||||||
|
|
||||||
|
static func fileURL() -> URL {
|
||||||
|
MoltbotPaths.stateDirURL.appendingPathComponent("exec-approvals.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func socketPath() -> String {
|
||||||
|
MoltbotPaths.stateDirURL.appendingPathComponent("exec-approvals.sock").path
|
||||||
|
}
|
||||||
|
|
||||||
|
static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
|
||||||
|
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
var agents = file.agents ?? [:]
|
||||||
|
if let legacyDefault = agents["default"] {
|
||||||
|
if let main = agents[self.defaultAgentId] {
|
||||||
|
agents[self.defaultAgentId] = self.mergeAgents(current: main, legacy: legacyDefault)
|
||||||
|
} else {
|
||||||
|
agents[self.defaultAgentId] = legacyDefault
|
||||||
|
}
|
||||||
|
agents.removeValue(forKey: "default")
|
||||||
|
}
|
||||||
|
return ExecApprovalsFile(
|
||||||
|
version: 1,
|
||||||
|
socket: ExecApprovalsSocketConfig(
|
||||||
|
path: socketPath.isEmpty ? nil : socketPath,
|
||||||
|
token: token.isEmpty ? nil : token),
|
||||||
|
defaults: file.defaults,
|
||||||
|
agents: agents)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func readSnapshot() -> ExecApprovalsSnapshot {
|
||||||
|
let url = self.fileURL()
|
||||||
|
guard FileManager().fileExists(atPath: url.path) else {
|
||||||
|
return ExecApprovalsSnapshot(
|
||||||
|
path: url.path,
|
||||||
|
exists: false,
|
||||||
|
hash: self.hashRaw(nil),
|
||||||
|
file: ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]))
|
||||||
|
}
|
||||||
|
let raw = try? String(contentsOf: url, encoding: .utf8)
|
||||||
|
let data = raw.flatMap { $0.data(using: .utf8) }
|
||||||
|
let decoded: ExecApprovalsFile = {
|
||||||
|
if let data, let file = try? JSONDecoder().decode(ExecApprovalsFile.self, from: data), file.version == 1 {
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
|
||||||
|
}()
|
||||||
|
return ExecApprovalsSnapshot(
|
||||||
|
path: url.path,
|
||||||
|
exists: true,
|
||||||
|
hash: self.hashRaw(raw),
|
||||||
|
file: decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func redactForSnapshot(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
|
||||||
|
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if socketPath.isEmpty {
|
||||||
|
return ExecApprovalsFile(
|
||||||
|
version: file.version,
|
||||||
|
socket: nil,
|
||||||
|
defaults: file.defaults,
|
||||||
|
agents: file.agents)
|
||||||
|
}
|
||||||
|
return ExecApprovalsFile(
|
||||||
|
version: file.version,
|
||||||
|
socket: ExecApprovalsSocketConfig(path: socketPath, token: nil),
|
||||||
|
defaults: file.defaults,
|
||||||
|
agents: file.agents)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadFile() -> ExecApprovalsFile {
|
||||||
|
let url = self.fileURL()
|
||||||
|
guard FileManager().fileExists(atPath: url.path) else {
|
||||||
|
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
|
let decoded = try JSONDecoder().decode(ExecApprovalsFile.self, from: data)
|
||||||
|
if decoded.version != 1 {
|
||||||
|
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
|
||||||
|
}
|
||||||
|
return decoded
|
||||||
|
} catch {
|
||||||
|
self.logger.warning("exec approvals load failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveFile(_ file: ExecApprovalsFile) {
|
||||||
|
do {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
let data = try encoder.encode(file)
|
||||||
|
let url = self.fileURL()
|
||||||
|
try FileManager().createDirectory(
|
||||||
|
at: url.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true)
|
||||||
|
try data.write(to: url, options: [.atomic])
|
||||||
|
try? FileManager().setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
||||||
|
} catch {
|
||||||
|
self.logger.error("exec approvals save failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func ensureFile() -> ExecApprovalsFile {
|
||||||
|
var file = self.loadFile()
|
||||||
|
if file.socket == nil { file.socket = ExecApprovalsSocketConfig(path: nil, token: nil) }
|
||||||
|
let path = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if path.isEmpty {
|
||||||
|
file.socket?.path = self.socketPath()
|
||||||
|
}
|
||||||
|
let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if token.isEmpty {
|
||||||
|
file.socket?.token = self.generateToken()
|
||||||
|
}
|
||||||
|
if file.agents == nil { file.agents = [:] }
|
||||||
|
self.saveFile(file)
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
static func resolve(agentId: String?) -> ExecApprovalsResolved {
|
||||||
|
let file = self.ensureFile()
|
||||||
|
let defaults = file.defaults ?? ExecApprovalsDefaults()
|
||||||
|
let resolvedDefaults = ExecApprovalsResolvedDefaults(
|
||||||
|
security: defaults.security ?? self.defaultSecurity,
|
||||||
|
ask: defaults.ask ?? self.defaultAsk,
|
||||||
|
askFallback: defaults.askFallback ?? self.defaultAskFallback,
|
||||||
|
autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills)
|
||||||
|
let key = self.agentKey(agentId)
|
||||||
|
let agentEntry = file.agents?[key] ?? ExecApprovalsAgent()
|
||||||
|
let wildcardEntry = file.agents?["*"] ?? ExecApprovalsAgent()
|
||||||
|
let resolvedAgent = ExecApprovalsResolvedDefaults(
|
||||||
|
security: agentEntry.security ?? wildcardEntry.security ?? resolvedDefaults.security,
|
||||||
|
ask: agentEntry.ask ?? wildcardEntry.ask ?? resolvedDefaults.ask,
|
||||||
|
askFallback: agentEntry.askFallback ?? wildcardEntry.askFallback
|
||||||
|
?? resolvedDefaults.askFallback,
|
||||||
|
autoAllowSkills: agentEntry.autoAllowSkills ?? wildcardEntry.autoAllowSkills
|
||||||
|
?? resolvedDefaults.autoAllowSkills)
|
||||||
|
let allowlist = ((wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? []))
|
||||||
|
.map { entry in
|
||||||
|
ExecAllowlistEntry(
|
||||||
|
id: entry.id,
|
||||||
|
pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
lastUsedAt: entry.lastUsedAt,
|
||||||
|
lastUsedCommand: entry.lastUsedCommand,
|
||||||
|
lastResolvedPath: entry.lastResolvedPath)
|
||||||
|
}
|
||||||
|
.filter { !$0.pattern.isEmpty }
|
||||||
|
let socketPath = self.expandPath(file.socket?.path ?? self.socketPath())
|
||||||
|
let token = file.socket?.token ?? ""
|
||||||
|
return ExecApprovalsResolved(
|
||||||
|
url: self.fileURL(),
|
||||||
|
socketPath: socketPath,
|
||||||
|
token: token,
|
||||||
|
defaults: resolvedDefaults,
|
||||||
|
agent: resolvedAgent,
|
||||||
|
allowlist: allowlist,
|
||||||
|
file: file)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func resolveDefaults() -> ExecApprovalsResolvedDefaults {
|
||||||
|
let file = self.ensureFile()
|
||||||
|
let defaults = file.defaults ?? ExecApprovalsDefaults()
|
||||||
|
return ExecApprovalsResolvedDefaults(
|
||||||
|
security: defaults.security ?? self.defaultSecurity,
|
||||||
|
ask: defaults.ask ?? self.defaultAsk,
|
||||||
|
askFallback: defaults.askFallback ?? self.defaultAskFallback,
|
||||||
|
autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveDefaults(_ defaults: ExecApprovalsDefaults) {
|
||||||
|
self.updateFile { file in
|
||||||
|
file.defaults = defaults
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func updateDefaults(_ mutate: (inout ExecApprovalsDefaults) -> Void) {
|
||||||
|
self.updateFile { file in
|
||||||
|
var defaults = file.defaults ?? ExecApprovalsDefaults()
|
||||||
|
mutate(&defaults)
|
||||||
|
file.defaults = defaults
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveAgent(_ agent: ExecApprovalsAgent, agentId: String?) {
|
||||||
|
self.updateFile { file in
|
||||||
|
var agents = file.agents ?? [:]
|
||||||
|
let key = self.agentKey(agentId)
|
||||||
|
if agent.isEmpty {
|
||||||
|
agents.removeValue(forKey: key)
|
||||||
|
} else {
|
||||||
|
agents[key] = agent
|
||||||
|
}
|
||||||
|
file.agents = agents.isEmpty ? nil : agents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func addAllowlistEntry(agentId: String?, pattern: String) {
|
||||||
|
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return }
|
||||||
|
self.updateFile { file in
|
||||||
|
let key = self.agentKey(agentId)
|
||||||
|
var agents = file.agents ?? [:]
|
||||||
|
var entry = agents[key] ?? ExecApprovalsAgent()
|
||||||
|
var allowlist = entry.allowlist ?? []
|
||||||
|
if allowlist.contains(where: { $0.pattern == trimmed }) { return }
|
||||||
|
allowlist.append(ExecAllowlistEntry(pattern: trimmed, lastUsedAt: Date().timeIntervalSince1970 * 1000))
|
||||||
|
entry.allowlist = allowlist
|
||||||
|
agents[key] = entry
|
||||||
|
file.agents = agents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func recordAllowlistUse(
|
||||||
|
agentId: String?,
|
||||||
|
pattern: String,
|
||||||
|
command: String,
|
||||||
|
resolvedPath: String?)
|
||||||
|
{
|
||||||
|
self.updateFile { file in
|
||||||
|
let key = self.agentKey(agentId)
|
||||||
|
var agents = file.agents ?? [:]
|
||||||
|
var entry = agents[key] ?? ExecApprovalsAgent()
|
||||||
|
let allowlist = (entry.allowlist ?? []).map { item -> ExecAllowlistEntry in
|
||||||
|
guard item.pattern == pattern else { return item }
|
||||||
|
return ExecAllowlistEntry(
|
||||||
|
id: item.id,
|
||||||
|
pattern: item.pattern,
|
||||||
|
lastUsedAt: Date().timeIntervalSince1970 * 1000,
|
||||||
|
lastUsedCommand: command,
|
||||||
|
lastResolvedPath: resolvedPath)
|
||||||
|
}
|
||||||
|
entry.allowlist = allowlist
|
||||||
|
agents[key] = entry
|
||||||
|
file.agents = agents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func updateAllowlist(agentId: String?, allowlist: [ExecAllowlistEntry]) {
|
||||||
|
self.updateFile { file in
|
||||||
|
let key = self.agentKey(agentId)
|
||||||
|
var agents = file.agents ?? [:]
|
||||||
|
var entry = agents[key] ?? ExecApprovalsAgent()
|
||||||
|
let cleaned = allowlist
|
||||||
|
.map { item in
|
||||||
|
ExecAllowlistEntry(
|
||||||
|
id: item.id,
|
||||||
|
pattern: item.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
lastUsedAt: item.lastUsedAt,
|
||||||
|
lastUsedCommand: item.lastUsedCommand,
|
||||||
|
lastResolvedPath: item.lastResolvedPath)
|
||||||
|
}
|
||||||
|
.filter { !$0.pattern.isEmpty }
|
||||||
|
entry.allowlist = cleaned
|
||||||
|
agents[key] = entry
|
||||||
|
file.agents = agents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func updateAgentSettings(agentId: String?, mutate: (inout ExecApprovalsAgent) -> Void) {
|
||||||
|
self.updateFile { file in
|
||||||
|
let key = self.agentKey(agentId)
|
||||||
|
var agents = file.agents ?? [:]
|
||||||
|
var entry = agents[key] ?? ExecApprovalsAgent()
|
||||||
|
mutate(&entry)
|
||||||
|
if entry.isEmpty {
|
||||||
|
agents.removeValue(forKey: key)
|
||||||
|
} else {
|
||||||
|
agents[key] = entry
|
||||||
|
}
|
||||||
|
file.agents = agents.isEmpty ? nil : agents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func updateFile(_ mutate: (inout ExecApprovalsFile) -> Void) {
|
||||||
|
var file = self.ensureFile()
|
||||||
|
mutate(&file)
|
||||||
|
self.saveFile(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func generateToken() -> String {
|
||||||
|
var bytes = [UInt8](repeating: 0, count: 24)
|
||||||
|
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||||
|
if status == errSecSuccess {
|
||||||
|
return Data(bytes)
|
||||||
|
.base64EncodedString()
|
||||||
|
.replacingOccurrences(of: "+", with: "-")
|
||||||
|
.replacingOccurrences(of: "/", with: "_")
|
||||||
|
.replacingOccurrences(of: "=", with: "")
|
||||||
|
}
|
||||||
|
return UUID().uuidString
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func hashRaw(_ raw: String?) -> String {
|
||||||
|
let data = Data((raw ?? "").utf8)
|
||||||
|
let digest = SHA256.hash(data: data)
|
||||||
|
return digest.map { String(format: "%02x", $0) }.joined()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func expandPath(_ raw: String) -> String {
|
||||||
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmed == "~" {
|
||||||
|
return FileManager().homeDirectoryForCurrentUser.path
|
||||||
|
}
|
||||||
|
if trimmed.hasPrefix("~/") {
|
||||||
|
let suffix = trimmed.dropFirst(2)
|
||||||
|
return FileManager().homeDirectoryForCurrentUser
|
||||||
|
.appendingPathComponent(String(suffix)).path
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func agentKey(_ agentId: String?) -> String {
|
||||||
|
let trimmed = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
return trimmed.isEmpty ? self.defaultAgentId : trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizedPattern(_ pattern: String?) -> String? {
|
||||||
|
let trimmed = pattern?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
return trimmed.isEmpty ? nil : trimmed.lowercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func mergeAgents(
|
||||||
|
current: ExecApprovalsAgent,
|
||||||
|
legacy: ExecApprovalsAgent) -> ExecApprovalsAgent
|
||||||
|
{
|
||||||
|
var seen = Set<String>()
|
||||||
|
var allowlist: [ExecAllowlistEntry] = []
|
||||||
|
func append(_ entry: ExecAllowlistEntry) {
|
||||||
|
guard let key = self.normalizedPattern(entry.pattern), !seen.contains(key) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen.insert(key)
|
||||||
|
allowlist.append(entry)
|
||||||
|
}
|
||||||
|
for entry in current.allowlist ?? [] {
|
||||||
|
append(entry)
|
||||||
|
}
|
||||||
|
for entry in legacy.allowlist ?? [] {
|
||||||
|
append(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ExecApprovalsAgent(
|
||||||
|
security: current.security ?? legacy.security,
|
||||||
|
ask: current.ask ?? legacy.ask,
|
||||||
|
askFallback: current.askFallback ?? legacy.askFallback,
|
||||||
|
autoAllowSkills: current.autoAllowSkills ?? legacy.autoAllowSkills,
|
||||||
|
allowlist: allowlist.isEmpty ? nil : allowlist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExecCommandResolution: Sendable {
|
||||||
|
let rawExecutable: String
|
||||||
|
let resolvedPath: String?
|
||||||
|
let executableName: String
|
||||||
|
let cwd: String?
|
||||||
|
|
||||||
|
static func resolve(
|
||||||
|
command: [String],
|
||||||
|
rawCommand: String?,
|
||||||
|
cwd: String?,
|
||||||
|
env: [String: String]?) -> ExecCommandResolution?
|
||||||
|
{
|
||||||
|
let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmedRaw.isEmpty, let token = self.parseFirstToken(trimmedRaw) {
|
||||||
|
return self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env)
|
||||||
|
}
|
||||||
|
return self.resolve(command: command, cwd: cwd, env: env)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? {
|
||||||
|
guard let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func resolveExecutable(
|
||||||
|
rawExecutable: String,
|
||||||
|
cwd: String?,
|
||||||
|
env: [String: String]?) -> ExecCommandResolution?
|
||||||
|
{
|
||||||
|
let expanded = rawExecutable.hasPrefix("~") ? (rawExecutable as NSString).expandingTildeInPath : rawExecutable
|
||||||
|
let hasPathSeparator = expanded.contains("/") || expanded.contains("\\")
|
||||||
|
let resolvedPath: String? = {
|
||||||
|
if hasPathSeparator {
|
||||||
|
if expanded.hasPrefix("/") {
|
||||||
|
return expanded
|
||||||
|
}
|
||||||
|
let base = cwd?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let root = (base?.isEmpty == false) ? base! : FileManager().currentDirectoryPath
|
||||||
|
return URL(fileURLWithPath: root).appendingPathComponent(expanded).path
|
||||||
|
}
|
||||||
|
let searchPaths = self.searchPaths(from: env)
|
||||||
|
return CommandResolver.findExecutable(named: expanded, searchPaths: searchPaths)
|
||||||
|
}()
|
||||||
|
let name = resolvedPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? expanded
|
||||||
|
return ExecCommandResolution(
|
||||||
|
rawExecutable: expanded,
|
||||||
|
resolvedPath: resolvedPath,
|
||||||
|
executableName: name,
|
||||||
|
cwd: cwd)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseFirstToken(_ command: String) -> String? {
|
||||||
|
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
guard let first = trimmed.first else { return nil }
|
||||||
|
if first == "\"" || first == "'" {
|
||||||
|
let rest = trimmed.dropFirst()
|
||||||
|
if let end = rest.firstIndex(of: first) {
|
||||||
|
return String(rest[..<end])
|
||||||
|
}
|
||||||
|
return String(rest)
|
||||||
|
}
|
||||||
|
return trimmed.split(whereSeparator: { $0.isWhitespace }).first.map(String.init)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func searchPaths(from env: [String: String]?) -> [String] {
|
||||||
|
let raw = env?["PATH"]
|
||||||
|
if let raw, !raw.isEmpty {
|
||||||
|
return raw.split(separator: ":").map(String.init)
|
||||||
|
}
|
||||||
|
return CommandResolver.preferredPaths()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ExecCommandFormatter {
|
||||||
|
static func displayString(for argv: [String]) -> String {
|
||||||
|
argv.map { arg in
|
||||||
|
let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return "\"\"" }
|
||||||
|
let needsQuotes = trimmed.contains { $0.isWhitespace || $0 == "\"" }
|
||||||
|
if !needsQuotes { return trimmed }
|
||||||
|
let escaped = trimmed.replacingOccurrences(of: "\"", with: "\\\"")
|
||||||
|
return "\"\(escaped)\""
|
||||||
|
}.joined(separator: " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func displayString(for argv: [String], rawCommand: String?) -> String {
|
||||||
|
let trimmed = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmed.isEmpty { return trimmed }
|
||||||
|
return self.displayString(for: argv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ExecApprovalHelpers {
|
||||||
|
static func parseDecision(_ raw: String?) -> ExecApprovalDecision? {
|
||||||
|
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
return ExecApprovalDecision(rawValue: trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func requiresAsk(
|
||||||
|
ask: ExecAsk,
|
||||||
|
security: ExecSecurity,
|
||||||
|
allowlistMatch: ExecAllowlistEntry?,
|
||||||
|
skillAllow: Bool) -> Bool
|
||||||
|
{
|
||||||
|
if ask == .always { return true }
|
||||||
|
if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
static func allowlistPattern(command: [String], resolution: ExecCommandResolution?) -> String? {
|
||||||
|
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
|
||||||
|
return pattern.isEmpty ? nil : pattern
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ExecAllowlistMatcher {
|
||||||
|
static func match(entries: [ExecAllowlistEntry], resolution: ExecCommandResolution?) -> ExecAllowlistEntry? {
|
||||||
|
guard let resolution, !entries.isEmpty else { return nil }
|
||||||
|
let rawExecutable = resolution.rawExecutable
|
||||||
|
let resolvedPath = resolution.resolvedPath
|
||||||
|
let executableName = resolution.executableName
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let pattern = entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if pattern.isEmpty { continue }
|
||||||
|
let hasPath = pattern.contains("/") || pattern.contains("~") || pattern.contains("\\")
|
||||||
|
if hasPath {
|
||||||
|
let target = resolvedPath ?? rawExecutable
|
||||||
|
if self.matches(pattern: pattern, target: target) { return entry }
|
||||||
|
} else if self.matches(pattern: pattern, target: executableName) {
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func matches(pattern: String, target: String) -> Bool {
|
||||||
|
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return false }
|
||||||
|
let expanded = trimmed.hasPrefix("~") ? (trimmed as NSString).expandingTildeInPath : trimmed
|
||||||
|
let normalizedPattern = self.normalizeMatchTarget(expanded)
|
||||||
|
let normalizedTarget = self.normalizeMatchTarget(target)
|
||||||
|
guard let regex = self.regex(for: normalizedPattern) else { return false }
|
||||||
|
let range = NSRange(location: 0, length: normalizedTarget.utf16.count)
|
||||||
|
return regex.firstMatch(in: normalizedTarget, options: [], range: range) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizeMatchTarget(_ value: String) -> String {
|
||||||
|
value.replacingOccurrences(of: "\\\\", with: "/").lowercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func regex(for pattern: String) -> NSRegularExpression? {
|
||||||
|
var regex = "^"
|
||||||
|
var idx = pattern.startIndex
|
||||||
|
while idx < pattern.endIndex {
|
||||||
|
let ch = pattern[idx]
|
||||||
|
if ch == "*" {
|
||||||
|
let next = pattern.index(after: idx)
|
||||||
|
if next < pattern.endIndex, pattern[next] == "*" {
|
||||||
|
regex += ".*"
|
||||||
|
idx = pattern.index(after: next)
|
||||||
|
} else {
|
||||||
|
regex += "[^/]*"
|
||||||
|
idx = next
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ch == "?" {
|
||||||
|
regex += "."
|
||||||
|
idx = pattern.index(after: idx)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
regex += NSRegularExpression.escapedPattern(for: String(ch))
|
||||||
|
idx = pattern.index(after: idx)
|
||||||
|
}
|
||||||
|
regex += "$"
|
||||||
|
return try? NSRegularExpression(pattern: regex, options: [.caseInsensitive])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExecEventPayload: Codable, Sendable {
|
||||||
|
var sessionKey: String
|
||||||
|
var runId: String
|
||||||
|
var host: String
|
||||||
|
var command: String?
|
||||||
|
var exitCode: Int?
|
||||||
|
var timedOut: Bool?
|
||||||
|
var success: Bool?
|
||||||
|
var output: String?
|
||||||
|
var reason: String?
|
||||||
|
|
||||||
|
static func truncateOutput(_ raw: String, maxChars: Int = 20000) -> String? {
|
||||||
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
if trimmed.count <= maxChars { return trimmed }
|
||||||
|
let suffix = trimmed.suffix(maxChars)
|
||||||
|
return "... (truncated) \(suffix)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actor SkillBinsCache {
|
||||||
|
static let shared = SkillBinsCache()
|
||||||
|
|
||||||
|
private var bins: Set<String> = []
|
||||||
|
private var lastRefresh: Date?
|
||||||
|
private let refreshInterval: TimeInterval = 90
|
||||||
|
|
||||||
|
func currentBins(force: Bool = false) async -> Set<String> {
|
||||||
|
if force || self.isStale() {
|
||||||
|
await self.refresh()
|
||||||
|
}
|
||||||
|
return self.bins
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh() async {
|
||||||
|
do {
|
||||||
|
let report = try await GatewayConnection.shared.skillsStatus()
|
||||||
|
var next = Set<String>()
|
||||||
|
for skill in report.skills {
|
||||||
|
for bin in skill.requirements.bins {
|
||||||
|
let trimmed = bin.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !trimmed.isEmpty { next.insert(trimmed) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.bins = next
|
||||||
|
self.lastRefresh = Date()
|
||||||
|
} catch {
|
||||||
|
if self.lastRefresh == nil {
|
||||||
|
self.bins = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isStale() -> Bool {
|
||||||
|
guard let lastRefresh else { return true }
|
||||||
|
return Date().timeIntervalSince(lastRefresh) > self.refreshInterval
|
||||||
|
}
|
||||||
|
}
|
||||||
123
apps/macos/Sources/Moltbot/ExecApprovalsGatewayPrompter.swift
Normal file
123
apps/macos/Sources/Moltbot/ExecApprovalsGatewayPrompter.swift
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import MoltbotKit
|
||||||
|
import MoltbotProtocol
|
||||||
|
import CoreGraphics
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ExecApprovalsGatewayPrompter {
|
||||||
|
static let shared = ExecApprovalsGatewayPrompter()
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "bot.molt", category: "exec-approvals.gateway")
|
||||||
|
private var task: Task<Void, Never>?
|
||||||
|
|
||||||
|
struct GatewayApprovalRequest: Codable, Sendable {
|
||||||
|
var id: String
|
||||||
|
var request: ExecApprovalPromptRequest
|
||||||
|
var createdAtMs: Int
|
||||||
|
var expiresAtMs: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard self.task == nil else { return }
|
||||||
|
self.task = Task { [weak self] in
|
||||||
|
await self?.run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
self.task?.cancel()
|
||||||
|
self.task = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func run() async {
|
||||||
|
let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200)
|
||||||
|
for await push in stream {
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
await self.handle(push: push)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handle(push: GatewayPush) async {
|
||||||
|
guard case let .event(evt) = push else { return }
|
||||||
|
guard evt.event == "exec.approval.requested" else { return }
|
||||||
|
guard let payload = evt.payload else { return }
|
||||||
|
do {
|
||||||
|
let data = try JSONEncoder().encode(payload)
|
||||||
|
let request = try JSONDecoder().decode(GatewayApprovalRequest.self, from: data)
|
||||||
|
guard self.shouldPresent(request: request) else { return }
|
||||||
|
let decision = ExecApprovalsPromptPresenter.prompt(request.request)
|
||||||
|
try await GatewayConnection.shared.requestVoid(
|
||||||
|
method: .execApprovalResolve,
|
||||||
|
params: [
|
||||||
|
"id": AnyCodable(request.id),
|
||||||
|
"decision": AnyCodable(decision.rawValue),
|
||||||
|
],
|
||||||
|
timeoutMs: 10000)
|
||||||
|
} catch {
|
||||||
|
self.logger.error("exec approval handling failed \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shouldPresent(request: GatewayApprovalRequest) -> Bool {
|
||||||
|
let mode = AppStateStore.shared.connectionMode
|
||||||
|
let activeSession = WebChatManager.shared.activeSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let requestSession = request.request.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return Self.shouldPresent(
|
||||||
|
mode: mode,
|
||||||
|
activeSession: activeSession,
|
||||||
|
requestSession: requestSession,
|
||||||
|
lastInputSeconds: Self.lastInputSeconds(),
|
||||||
|
thresholdSeconds: 120)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func shouldPresent(
|
||||||
|
mode: AppState.ConnectionMode,
|
||||||
|
activeSession: String?,
|
||||||
|
requestSession: String?,
|
||||||
|
lastInputSeconds: Int?,
|
||||||
|
thresholdSeconds: Int) -> Bool
|
||||||
|
{
|
||||||
|
let active = activeSession?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let requested = requestSession?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let recentlyActive = lastInputSeconds.map { $0 <= thresholdSeconds } ?? (mode == .local)
|
||||||
|
|
||||||
|
if let session = requested, !session.isEmpty {
|
||||||
|
if let active, !active.isEmpty {
|
||||||
|
return active == session
|
||||||
|
}
|
||||||
|
return recentlyActive
|
||||||
|
}
|
||||||
|
|
||||||
|
if let active, !active.isEmpty {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return mode == .local
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func lastInputSeconds() -> Int? {
|
||||||
|
let anyEvent = CGEventType(rawValue: UInt32.max) ?? .null
|
||||||
|
let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyEvent)
|
||||||
|
if seconds.isNaN || seconds.isInfinite || seconds < 0 { return nil }
|
||||||
|
return Int(seconds.rounded())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
extension ExecApprovalsGatewayPrompter {
|
||||||
|
static func _testShouldPresent(
|
||||||
|
mode: AppState.ConnectionMode,
|
||||||
|
activeSession: String?,
|
||||||
|
requestSession: String?,
|
||||||
|
lastInputSeconds: Int?,
|
||||||
|
thresholdSeconds: Int = 120) -> Bool
|
||||||
|
{
|
||||||
|
self.shouldPresent(
|
||||||
|
mode: mode,
|
||||||
|
activeSession: activeSession,
|
||||||
|
requestSession: requestSession,
|
||||||
|
lastInputSeconds: lastInputSeconds,
|
||||||
|
thresholdSeconds: thresholdSeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
831
apps/macos/Sources/Moltbot/ExecApprovalsSocket.swift
Normal file
831
apps/macos/Sources/Moltbot/ExecApprovalsSocket.swift
Normal file
@@ -0,0 +1,831 @@
|
|||||||
|
import AppKit
|
||||||
|
import MoltbotKit
|
||||||
|
import CryptoKit
|
||||||
|
import Darwin
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
struct ExecApprovalPromptRequest: Codable, Sendable {
|
||||||
|
var command: String
|
||||||
|
var cwd: String?
|
||||||
|
var host: String?
|
||||||
|
var security: String?
|
||||||
|
var ask: String?
|
||||||
|
var agentId: String?
|
||||||
|
var resolvedPath: String?
|
||||||
|
var sessionKey: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ExecApprovalSocketRequest: Codable {
|
||||||
|
var type: String
|
||||||
|
var token: String
|
||||||
|
var id: String
|
||||||
|
var request: ExecApprovalPromptRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ExecApprovalSocketDecision: Codable {
|
||||||
|
var type: String
|
||||||
|
var id: String
|
||||||
|
var decision: ExecApprovalDecision
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ExecHostSocketRequest: Codable {
|
||||||
|
var type: String
|
||||||
|
var id: String
|
||||||
|
var nonce: String
|
||||||
|
var ts: Int
|
||||||
|
var hmac: String
|
||||||
|
var requestJson: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ExecHostRequest: Codable {
|
||||||
|
var command: [String]
|
||||||
|
var rawCommand: String?
|
||||||
|
var cwd: String?
|
||||||
|
var env: [String: String]?
|
||||||
|
var timeoutMs: Int?
|
||||||
|
var needsScreenRecording: Bool?
|
||||||
|
var agentId: String?
|
||||||
|
var sessionKey: String?
|
||||||
|
var approvalDecision: ExecApprovalDecision?
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ExecHostRunResult: Codable {
|
||||||
|
var exitCode: Int?
|
||||||
|
var timedOut: Bool
|
||||||
|
var success: Bool
|
||||||
|
var stdout: String
|
||||||
|
var stderr: String
|
||||||
|
var error: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ExecHostError: Codable {
|
||||||
|
var code: String
|
||||||
|
var message: String
|
||||||
|
var reason: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ExecHostResponse: Codable {
|
||||||
|
var type: String
|
||||||
|
var id: String
|
||||||
|
var ok: Bool
|
||||||
|
var payload: ExecHostRunResult?
|
||||||
|
var error: ExecHostError?
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ExecApprovalsSocketClient {
|
||||||
|
private struct TimeoutError: LocalizedError {
|
||||||
|
var message: String
|
||||||
|
var errorDescription: String? { self.message }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func requestDecision(
|
||||||
|
socketPath: String,
|
||||||
|
token: String,
|
||||||
|
request: ExecApprovalPromptRequest,
|
||||||
|
timeoutMs: Int = 15000) async -> ExecApprovalDecision?
|
||||||
|
{
|
||||||
|
let trimmedPath = socketPath.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmedPath.isEmpty, !trimmedToken.isEmpty else { return nil }
|
||||||
|
do {
|
||||||
|
return try await AsyncTimeout.withTimeoutMs(
|
||||||
|
timeoutMs: timeoutMs,
|
||||||
|
onTimeout: {
|
||||||
|
TimeoutError(message: "exec approvals socket timeout")
|
||||||
|
},
|
||||||
|
operation: {
|
||||||
|
try await Task.detached {
|
||||||
|
try self.requestDecisionSync(
|
||||||
|
socketPath: trimmedPath,
|
||||||
|
token: trimmedToken,
|
||||||
|
request: request)
|
||||||
|
}.value
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func requestDecisionSync(
|
||||||
|
socketPath: String,
|
||||||
|
token: String,
|
||||||
|
request: ExecApprovalPromptRequest) throws -> ExecApprovalDecision?
|
||||||
|
{
|
||||||
|
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||||
|
guard fd >= 0 else {
|
||||||
|
throw NSError(domain: "ExecApprovals", code: 1, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "socket create failed",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
var addr = sockaddr_un()
|
||||||
|
addr.sun_family = sa_family_t(AF_UNIX)
|
||||||
|
let maxLen = MemoryLayout.size(ofValue: addr.sun_path)
|
||||||
|
if socketPath.utf8.count >= maxLen {
|
||||||
|
throw NSError(domain: "ExecApprovals", code: 2, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "socket path too long",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
socketPath.withCString { cstr in
|
||||||
|
withUnsafeMutablePointer(to: &addr.sun_path) { ptr in
|
||||||
|
let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: Int8.self)
|
||||||
|
strncpy(raw, cstr, maxLen - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let size = socklen_t(MemoryLayout.size(ofValue: addr))
|
||||||
|
let result = withUnsafePointer(to: &addr) { ptr in
|
||||||
|
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in
|
||||||
|
connect(fd, rebound, size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if result != 0 {
|
||||||
|
throw NSError(domain: "ExecApprovals", code: 3, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "socket connect failed",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
|
||||||
|
|
||||||
|
let message = ExecApprovalSocketRequest(
|
||||||
|
type: "request",
|
||||||
|
token: token,
|
||||||
|
id: UUID().uuidString,
|
||||||
|
request: request)
|
||||||
|
let data = try JSONEncoder().encode(message)
|
||||||
|
var payload = data
|
||||||
|
payload.append(0x0A)
|
||||||
|
try handle.write(contentsOf: payload)
|
||||||
|
|
||||||
|
guard let line = try self.readLine(from: handle, maxBytes: 256_000),
|
||||||
|
let lineData = line.data(using: .utf8)
|
||||||
|
else { return nil }
|
||||||
|
let response = try JSONDecoder().decode(ExecApprovalSocketDecision.self, from: lineData)
|
||||||
|
return response.decision
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func readLine(from handle: FileHandle, maxBytes: Int) throws -> String? {
|
||||||
|
var buffer = Data()
|
||||||
|
while buffer.count < maxBytes {
|
||||||
|
let chunk = try handle.read(upToCount: 4096) ?? Data()
|
||||||
|
if chunk.isEmpty { break }
|
||||||
|
buffer.append(chunk)
|
||||||
|
if buffer.contains(0x0A) { break }
|
||||||
|
}
|
||||||
|
guard let newlineIndex = buffer.firstIndex(of: 0x0A) else {
|
||||||
|
guard !buffer.isEmpty else { return nil }
|
||||||
|
return String(data: buffer, encoding: .utf8)
|
||||||
|
}
|
||||||
|
let lineData = buffer.subdata(in: 0..<newlineIndex)
|
||||||
|
return String(data: lineData, encoding: .utf8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ExecApprovalsPromptServer {
|
||||||
|
static let shared = ExecApprovalsPromptServer()
|
||||||
|
|
||||||
|
private var server: ExecApprovalsSocketServer?
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard self.server == nil else { return }
|
||||||
|
let approvals = ExecApprovalsStore.resolve(agentId: nil)
|
||||||
|
let server = ExecApprovalsSocketServer(
|
||||||
|
socketPath: approvals.socketPath,
|
||||||
|
token: approvals.token,
|
||||||
|
onPrompt: { request in
|
||||||
|
await ExecApprovalsPromptPresenter.prompt(request)
|
||||||
|
},
|
||||||
|
onExec: { request in
|
||||||
|
await ExecHostExecutor.handle(request)
|
||||||
|
})
|
||||||
|
server.start()
|
||||||
|
self.server = server
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
self.server?.stop()
|
||||||
|
self.server = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ExecApprovalsPromptPresenter {
|
||||||
|
@MainActor
|
||||||
|
static func prompt(_ request: ExecApprovalPromptRequest) -> ExecApprovalDecision {
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.alertStyle = .warning
|
||||||
|
alert.messageText = "Allow this command?"
|
||||||
|
alert.informativeText = "Review the command details before allowing."
|
||||||
|
alert.accessoryView = self.buildAccessoryView(request)
|
||||||
|
|
||||||
|
alert.addButton(withTitle: "Allow Once")
|
||||||
|
alert.addButton(withTitle: "Always Allow")
|
||||||
|
alert.addButton(withTitle: "Don't Allow")
|
||||||
|
if #available(macOS 11.0, *), alert.buttons.indices.contains(2) {
|
||||||
|
alert.buttons[2].hasDestructiveAction = true
|
||||||
|
}
|
||||||
|
|
||||||
|
switch alert.runModal() {
|
||||||
|
case .alertFirstButtonReturn:
|
||||||
|
return .allowOnce
|
||||||
|
case .alertSecondButtonReturn:
|
||||||
|
return .allowAlways
|
||||||
|
default:
|
||||||
|
return .deny
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private static func buildAccessoryView(_ request: ExecApprovalPromptRequest) -> NSView {
|
||||||
|
let stack = NSStackView()
|
||||||
|
stack.orientation = .vertical
|
||||||
|
stack.spacing = 8
|
||||||
|
stack.alignment = .leading
|
||||||
|
|
||||||
|
let commandTitle = NSTextField(labelWithString: "Command")
|
||||||
|
commandTitle.font = NSFont.boldSystemFont(ofSize: NSFont.systemFontSize)
|
||||||
|
stack.addArrangedSubview(commandTitle)
|
||||||
|
|
||||||
|
let commandText = NSTextView()
|
||||||
|
commandText.isEditable = false
|
||||||
|
commandText.isSelectable = true
|
||||||
|
commandText.drawsBackground = true
|
||||||
|
commandText.backgroundColor = NSColor.textBackgroundColor
|
||||||
|
commandText.font = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular)
|
||||||
|
commandText.string = request.command
|
||||||
|
commandText.textContainerInset = NSSize(width: 6, height: 6)
|
||||||
|
commandText.textContainer?.lineFragmentPadding = 0
|
||||||
|
commandText.textContainer?.widthTracksTextView = true
|
||||||
|
commandText.isHorizontallyResizable = false
|
||||||
|
commandText.isVerticallyResizable = false
|
||||||
|
|
||||||
|
let commandScroll = NSScrollView()
|
||||||
|
commandScroll.borderType = .lineBorder
|
||||||
|
commandScroll.hasVerticalScroller = false
|
||||||
|
commandScroll.hasHorizontalScroller = false
|
||||||
|
commandScroll.documentView = commandText
|
||||||
|
commandScroll.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
commandScroll.widthAnchor.constraint(lessThanOrEqualToConstant: 440).isActive = true
|
||||||
|
commandScroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 56).isActive = true
|
||||||
|
stack.addArrangedSubview(commandScroll)
|
||||||
|
|
||||||
|
let contextTitle = NSTextField(labelWithString: "Context")
|
||||||
|
contextTitle.font = NSFont.boldSystemFont(ofSize: NSFont.systemFontSize)
|
||||||
|
stack.addArrangedSubview(contextTitle)
|
||||||
|
|
||||||
|
let contextStack = NSStackView()
|
||||||
|
contextStack.orientation = .vertical
|
||||||
|
contextStack.spacing = 4
|
||||||
|
contextStack.alignment = .leading
|
||||||
|
|
||||||
|
let trimmedCwd = request.cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmedCwd.isEmpty {
|
||||||
|
self.addDetailRow(title: "Working directory", value: trimmedCwd, to: contextStack)
|
||||||
|
}
|
||||||
|
let trimmedAgent = request.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmedAgent.isEmpty {
|
||||||
|
self.addDetailRow(title: "Agent", value: trimmedAgent, to: contextStack)
|
||||||
|
}
|
||||||
|
let trimmedPath = request.resolvedPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmedPath.isEmpty {
|
||||||
|
self.addDetailRow(title: "Executable", value: trimmedPath, to: contextStack)
|
||||||
|
}
|
||||||
|
let trimmedHost = request.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmedHost.isEmpty {
|
||||||
|
self.addDetailRow(title: "Host", value: trimmedHost, to: contextStack)
|
||||||
|
}
|
||||||
|
if let security = request.security?.trimmingCharacters(in: .whitespacesAndNewlines), !security.isEmpty {
|
||||||
|
self.addDetailRow(title: "Security", value: security, to: contextStack)
|
||||||
|
}
|
||||||
|
if let ask = request.ask?.trimmingCharacters(in: .whitespacesAndNewlines), !ask.isEmpty {
|
||||||
|
self.addDetailRow(title: "Ask mode", value: ask, to: contextStack)
|
||||||
|
}
|
||||||
|
|
||||||
|
if contextStack.arrangedSubviews.isEmpty {
|
||||||
|
let empty = NSTextField(labelWithString: "No additional context provided.")
|
||||||
|
empty.textColor = NSColor.secondaryLabelColor
|
||||||
|
empty.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)
|
||||||
|
contextStack.addArrangedSubview(empty)
|
||||||
|
}
|
||||||
|
|
||||||
|
stack.addArrangedSubview(contextStack)
|
||||||
|
|
||||||
|
let footer = NSTextField(labelWithString: "This runs on this machine.")
|
||||||
|
footer.textColor = NSColor.secondaryLabelColor
|
||||||
|
footer.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)
|
||||||
|
stack.addArrangedSubview(footer)
|
||||||
|
|
||||||
|
return stack
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private static func addDetailRow(title: String, value: String, to stack: NSStackView) {
|
||||||
|
let row = NSStackView()
|
||||||
|
row.orientation = .horizontal
|
||||||
|
row.spacing = 6
|
||||||
|
row.alignment = .firstBaseline
|
||||||
|
|
||||||
|
let titleLabel = NSTextField(labelWithString: "\(title):")
|
||||||
|
titleLabel.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize, weight: .semibold)
|
||||||
|
titleLabel.textColor = NSColor.secondaryLabelColor
|
||||||
|
|
||||||
|
let valueLabel = NSTextField(labelWithString: value)
|
||||||
|
valueLabel.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)
|
||||||
|
valueLabel.lineBreakMode = .byTruncatingMiddle
|
||||||
|
valueLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||||
|
|
||||||
|
row.addArrangedSubview(titleLabel)
|
||||||
|
row.addArrangedSubview(valueLabel)
|
||||||
|
stack.addArrangedSubview(row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private enum ExecHostExecutor {
|
||||||
|
private struct ExecApprovalContext {
|
||||||
|
let command: [String]
|
||||||
|
let displayCommand: String
|
||||||
|
let trimmedAgent: String?
|
||||||
|
let approvals: ExecApprovalsResolved
|
||||||
|
let security: ExecSecurity
|
||||||
|
let ask: ExecAsk
|
||||||
|
let autoAllowSkills: Bool
|
||||||
|
let env: [String: String]?
|
||||||
|
let resolution: ExecCommandResolution?
|
||||||
|
let allowlistMatch: ExecAllowlistEntry?
|
||||||
|
let skillAllow: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let blockedEnvKeys: Set<String> = [
|
||||||
|
"PATH",
|
||||||
|
"NODE_OPTIONS",
|
||||||
|
"PYTHONHOME",
|
||||||
|
"PYTHONPATH",
|
||||||
|
"PERL5LIB",
|
||||||
|
"PERL5OPT",
|
||||||
|
"RUBYOPT",
|
||||||
|
]
|
||||||
|
|
||||||
|
private static let blockedEnvPrefixes: [String] = [
|
||||||
|
"DYLD_",
|
||||||
|
"LD_",
|
||||||
|
]
|
||||||
|
|
||||||
|
static func handle(_ request: ExecHostRequest) async -> ExecHostResponse {
|
||||||
|
let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
guard !command.isEmpty else {
|
||||||
|
return self.errorResponse(
|
||||||
|
code: "INVALID_REQUEST",
|
||||||
|
message: "command required",
|
||||||
|
reason: "invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
let context = await self.buildContext(request: request, command: command)
|
||||||
|
if context.security == .deny {
|
||||||
|
return self.errorResponse(
|
||||||
|
code: "UNAVAILABLE",
|
||||||
|
message: "SYSTEM_RUN_DISABLED: security=deny",
|
||||||
|
reason: "security=deny")
|
||||||
|
}
|
||||||
|
|
||||||
|
let approvalDecision = request.approvalDecision
|
||||||
|
if approvalDecision == .deny {
|
||||||
|
return self.errorResponse(
|
||||||
|
code: "UNAVAILABLE",
|
||||||
|
message: "SYSTEM_RUN_DENIED: user denied",
|
||||||
|
reason: "user-denied")
|
||||||
|
}
|
||||||
|
|
||||||
|
var approvedByAsk = approvalDecision != nil
|
||||||
|
if ExecApprovalHelpers.requiresAsk(
|
||||||
|
ask: context.ask,
|
||||||
|
security: context.security,
|
||||||
|
allowlistMatch: context.allowlistMatch,
|
||||||
|
skillAllow: context.skillAllow),
|
||||||
|
approvalDecision == nil
|
||||||
|
{
|
||||||
|
let decision = ExecApprovalsPromptPresenter.prompt(
|
||||||
|
ExecApprovalPromptRequest(
|
||||||
|
command: context.displayCommand,
|
||||||
|
cwd: request.cwd,
|
||||||
|
host: "node",
|
||||||
|
security: context.security.rawValue,
|
||||||
|
ask: context.ask.rawValue,
|
||||||
|
agentId: context.trimmedAgent,
|
||||||
|
resolvedPath: context.resolution?.resolvedPath,
|
||||||
|
sessionKey: request.sessionKey))
|
||||||
|
|
||||||
|
switch decision {
|
||||||
|
case .deny:
|
||||||
|
return self.errorResponse(
|
||||||
|
code: "UNAVAILABLE",
|
||||||
|
message: "SYSTEM_RUN_DENIED: user denied",
|
||||||
|
reason: "user-denied")
|
||||||
|
case .allowAlways:
|
||||||
|
approvedByAsk = true
|
||||||
|
self.persistAllowlistEntry(decision: decision, context: context)
|
||||||
|
case .allowOnce:
|
||||||
|
approvedByAsk = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.persistAllowlistEntry(decision: approvalDecision, context: context)
|
||||||
|
|
||||||
|
if context.security == .allowlist,
|
||||||
|
context.allowlistMatch == nil,
|
||||||
|
!context.skillAllow,
|
||||||
|
!approvedByAsk
|
||||||
|
{
|
||||||
|
return self.errorResponse(
|
||||||
|
code: "UNAVAILABLE",
|
||||||
|
message: "SYSTEM_RUN_DENIED: allowlist miss",
|
||||||
|
reason: "allowlist-miss")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let match = context.allowlistMatch {
|
||||||
|
ExecApprovalsStore.recordAllowlistUse(
|
||||||
|
agentId: context.trimmedAgent,
|
||||||
|
pattern: match.pattern,
|
||||||
|
command: context.displayCommand,
|
||||||
|
resolvedPath: context.resolution?.resolvedPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let errorResponse = await self.ensureScreenRecordingAccess(request.needsScreenRecording) {
|
||||||
|
return errorResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
return await self.runCommand(
|
||||||
|
command: command,
|
||||||
|
cwd: request.cwd,
|
||||||
|
env: context.env,
|
||||||
|
timeoutMs: request.timeoutMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func buildContext(request: ExecHostRequest, command: [String]) async -> ExecApprovalContext {
|
||||||
|
let displayCommand = ExecCommandFormatter.displayString(
|
||||||
|
for: command,
|
||||||
|
rawCommand: request.rawCommand)
|
||||||
|
let agentId = request.agentId?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let trimmedAgent = (agentId?.isEmpty == false) ? agentId : nil
|
||||||
|
let approvals = ExecApprovalsStore.resolve(agentId: trimmedAgent)
|
||||||
|
let security = approvals.agent.security
|
||||||
|
let ask = approvals.agent.ask
|
||||||
|
let autoAllowSkills = approvals.agent.autoAllowSkills
|
||||||
|
let env = self.sanitizedEnv(request.env)
|
||||||
|
let resolution = ExecCommandResolution.resolve(
|
||||||
|
command: command,
|
||||||
|
rawCommand: request.rawCommand,
|
||||||
|
cwd: request.cwd,
|
||||||
|
env: env)
|
||||||
|
let allowlistMatch = security == .allowlist
|
||||||
|
? ExecAllowlistMatcher.match(entries: approvals.allowlist, resolution: resolution)
|
||||||
|
: nil
|
||||||
|
let skillAllow: Bool
|
||||||
|
if autoAllowSkills, let name = resolution?.executableName {
|
||||||
|
let bins = await SkillBinsCache.shared.currentBins()
|
||||||
|
skillAllow = bins.contains(name)
|
||||||
|
} else {
|
||||||
|
skillAllow = false
|
||||||
|
}
|
||||||
|
return ExecApprovalContext(
|
||||||
|
command: command,
|
||||||
|
displayCommand: displayCommand,
|
||||||
|
trimmedAgent: trimmedAgent,
|
||||||
|
approvals: approvals,
|
||||||
|
security: security,
|
||||||
|
ask: ask,
|
||||||
|
autoAllowSkills: autoAllowSkills,
|
||||||
|
env: env,
|
||||||
|
resolution: resolution,
|
||||||
|
allowlistMatch: allowlistMatch,
|
||||||
|
skillAllow: skillAllow)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func persistAllowlistEntry(
|
||||||
|
decision: ExecApprovalDecision?,
|
||||||
|
context: ExecApprovalContext)
|
||||||
|
{
|
||||||
|
guard decision == .allowAlways, context.security == .allowlist else { return }
|
||||||
|
guard let pattern = ExecApprovalHelpers.allowlistPattern(
|
||||||
|
command: context.command,
|
||||||
|
resolution: context.resolution)
|
||||||
|
else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ExecApprovalsStore.addAllowlistEntry(agentId: context.trimmedAgent, pattern: pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func ensureScreenRecordingAccess(_ needsScreenRecording: Bool?) async -> ExecHostResponse? {
|
||||||
|
guard needsScreenRecording == true else { return nil }
|
||||||
|
let authorized = await PermissionManager
|
||||||
|
.status([.screenRecording])[.screenRecording] ?? false
|
||||||
|
if authorized { return nil }
|
||||||
|
return self.errorResponse(
|
||||||
|
code: "UNAVAILABLE",
|
||||||
|
message: "PERMISSION_MISSING: screenRecording",
|
||||||
|
reason: "permission:screenRecording")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func runCommand(
|
||||||
|
command: [String],
|
||||||
|
cwd: String?,
|
||||||
|
env: [String: String]?,
|
||||||
|
timeoutMs: Int?) async -> ExecHostResponse
|
||||||
|
{
|
||||||
|
let timeoutSec = timeoutMs.flatMap { Double($0) / 1000.0 }
|
||||||
|
let result = await Task.detached { () -> ShellExecutor.ShellResult in
|
||||||
|
await ShellExecutor.runDetailed(
|
||||||
|
command: command,
|
||||||
|
cwd: cwd,
|
||||||
|
env: env,
|
||||||
|
timeout: timeoutSec)
|
||||||
|
}.value
|
||||||
|
let payload = ExecHostRunResult(
|
||||||
|
exitCode: result.exitCode,
|
||||||
|
timedOut: result.timedOut,
|
||||||
|
success: result.success,
|
||||||
|
stdout: result.stdout,
|
||||||
|
stderr: result.stderr,
|
||||||
|
error: result.errorMessage)
|
||||||
|
return self.successResponse(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func errorResponse(
|
||||||
|
code: String,
|
||||||
|
message: String,
|
||||||
|
reason: String?) -> ExecHostResponse
|
||||||
|
{
|
||||||
|
ExecHostResponse(
|
||||||
|
type: "exec-res",
|
||||||
|
id: UUID().uuidString,
|
||||||
|
ok: false,
|
||||||
|
payload: nil,
|
||||||
|
error: ExecHostError(code: code, message: message, reason: reason))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func successResponse(_ payload: ExecHostRunResult) -> ExecHostResponse {
|
||||||
|
ExecHostResponse(
|
||||||
|
type: "exec-res",
|
||||||
|
id: UUID().uuidString,
|
||||||
|
ok: true,
|
||||||
|
payload: payload,
|
||||||
|
error: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func sanitizedEnv(_ overrides: [String: String]?) -> [String: String]? {
|
||||||
|
guard let overrides else { return nil }
|
||||||
|
var merged = ProcessInfo.processInfo.environment
|
||||||
|
for (rawKey, value) in overrides {
|
||||||
|
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !key.isEmpty else { continue }
|
||||||
|
let upper = key.uppercased()
|
||||||
|
if self.blockedEnvKeys.contains(upper) { continue }
|
||||||
|
if self.blockedEnvPrefixes.contains(where: { upper.hasPrefix($0) }) { continue }
|
||||||
|
merged[key] = value
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
||||||
|
private let logger = Logger(subsystem: "bot.molt", category: "exec-approvals.socket")
|
||||||
|
private let socketPath: String
|
||||||
|
private let token: String
|
||||||
|
private let onPrompt: @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision
|
||||||
|
private let onExec: @Sendable (ExecHostRequest) async -> ExecHostResponse
|
||||||
|
private var socketFD: Int32 = -1
|
||||||
|
private var acceptTask: Task<Void, Never>?
|
||||||
|
private var isRunning = false
|
||||||
|
|
||||||
|
init(
|
||||||
|
socketPath: String,
|
||||||
|
token: String,
|
||||||
|
onPrompt: @escaping @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision,
|
||||||
|
onExec: @escaping @Sendable (ExecHostRequest) async -> ExecHostResponse)
|
||||||
|
{
|
||||||
|
self.socketPath = socketPath
|
||||||
|
self.token = token
|
||||||
|
self.onPrompt = onPrompt
|
||||||
|
self.onExec = onExec
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard !self.isRunning else { return }
|
||||||
|
self.isRunning = true
|
||||||
|
self.acceptTask = Task.detached { [weak self] in
|
||||||
|
await self?.runAcceptLoop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
self.isRunning = false
|
||||||
|
self.acceptTask?.cancel()
|
||||||
|
self.acceptTask = nil
|
||||||
|
if self.socketFD >= 0 {
|
||||||
|
close(self.socketFD)
|
||||||
|
self.socketFD = -1
|
||||||
|
}
|
||||||
|
if !self.socketPath.isEmpty {
|
||||||
|
unlink(self.socketPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func runAcceptLoop() async {
|
||||||
|
let fd = self.openSocket()
|
||||||
|
guard fd >= 0 else {
|
||||||
|
self.isRunning = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.socketFD = fd
|
||||||
|
while self.isRunning {
|
||||||
|
var addr = sockaddr_un()
|
||||||
|
var len = socklen_t(MemoryLayout.size(ofValue: addr))
|
||||||
|
let client = withUnsafeMutablePointer(to: &addr) { ptr in
|
||||||
|
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in
|
||||||
|
accept(fd, rebound, &len)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if client < 0 {
|
||||||
|
if errno == EINTR { continue }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
await self?.handleClient(fd: client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openSocket() -> Int32 {
|
||||||
|
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||||
|
guard fd >= 0 else {
|
||||||
|
self.logger.error("exec approvals socket create failed")
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
unlink(self.socketPath)
|
||||||
|
var addr = sockaddr_un()
|
||||||
|
addr.sun_family = sa_family_t(AF_UNIX)
|
||||||
|
let maxLen = MemoryLayout.size(ofValue: addr.sun_path)
|
||||||
|
if self.socketPath.utf8.count >= maxLen {
|
||||||
|
self.logger.error("exec approvals socket path too long")
|
||||||
|
close(fd)
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
self.socketPath.withCString { cstr in
|
||||||
|
withUnsafeMutablePointer(to: &addr.sun_path) { ptr in
|
||||||
|
let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: Int8.self)
|
||||||
|
memset(raw, 0, maxLen)
|
||||||
|
strncpy(raw, cstr, maxLen - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let size = socklen_t(MemoryLayout.size(ofValue: addr))
|
||||||
|
let result = withUnsafePointer(to: &addr) { ptr in
|
||||||
|
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in
|
||||||
|
bind(fd, rebound, size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if result != 0 {
|
||||||
|
self.logger.error("exec approvals socket bind failed")
|
||||||
|
close(fd)
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if listen(fd, 16) != 0 {
|
||||||
|
self.logger.error("exec approvals socket listen failed")
|
||||||
|
close(fd)
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
chmod(self.socketPath, 0o600)
|
||||||
|
self.logger.info("exec approvals socket listening at \(self.socketPath, privacy: .public)")
|
||||||
|
return fd
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleClient(fd: Int32) async {
|
||||||
|
let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
|
||||||
|
do {
|
||||||
|
guard self.isAllowedPeer(fd: fd) else {
|
||||||
|
try self.sendApprovalResponse(handle: handle, id: UUID().uuidString, decision: .deny)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let line = try self.readLine(from: handle, maxBytes: 256_000),
|
||||||
|
let data = line.data(using: .utf8)
|
||||||
|
else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard
|
||||||
|
let envelope = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let type = envelope["type"] as? String
|
||||||
|
else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if type == "request" {
|
||||||
|
let request = try JSONDecoder().decode(ExecApprovalSocketRequest.self, from: data)
|
||||||
|
guard request.token == self.token else {
|
||||||
|
try self.sendApprovalResponse(handle: handle, id: request.id, decision: .deny)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let decision = await self.onPrompt(request.request)
|
||||||
|
try self.sendApprovalResponse(handle: handle, id: request.id, decision: decision)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if type == "exec" {
|
||||||
|
let request = try JSONDecoder().decode(ExecHostSocketRequest.self, from: data)
|
||||||
|
let response = await self.handleExecRequest(request)
|
||||||
|
try self.sendExecResponse(handle: handle, response: response)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
self.logger.error("exec approvals socket handling failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func readLine(from handle: FileHandle, maxBytes: Int) throws -> String? {
|
||||||
|
var buffer = Data()
|
||||||
|
while buffer.count < maxBytes {
|
||||||
|
let chunk = try handle.read(upToCount: 4096) ?? Data()
|
||||||
|
if chunk.isEmpty { break }
|
||||||
|
buffer.append(chunk)
|
||||||
|
if buffer.contains(0x0A) { break }
|
||||||
|
}
|
||||||
|
guard let newlineIndex = buffer.firstIndex(of: 0x0A) else {
|
||||||
|
guard !buffer.isEmpty else { return nil }
|
||||||
|
return String(data: buffer, encoding: .utf8)
|
||||||
|
}
|
||||||
|
let lineData = buffer.subdata(in: 0..<newlineIndex)
|
||||||
|
return String(data: lineData, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendApprovalResponse(
|
||||||
|
handle: FileHandle,
|
||||||
|
id: String,
|
||||||
|
decision: ExecApprovalDecision) throws
|
||||||
|
{
|
||||||
|
let response = ExecApprovalSocketDecision(type: "decision", id: id, decision: decision)
|
||||||
|
let data = try JSONEncoder().encode(response)
|
||||||
|
var payload = data
|
||||||
|
payload.append(0x0A)
|
||||||
|
try handle.write(contentsOf: payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendExecResponse(handle: FileHandle, response: ExecHostResponse) throws {
|
||||||
|
let data = try JSONEncoder().encode(response)
|
||||||
|
var payload = data
|
||||||
|
payload.append(0x0A)
|
||||||
|
try handle.write(contentsOf: payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isAllowedPeer(fd: Int32) -> Bool {
|
||||||
|
var uid = uid_t(0)
|
||||||
|
var gid = gid_t(0)
|
||||||
|
if getpeereid(fd, &uid, &gid) != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return uid == geteuid()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleExecRequest(_ request: ExecHostSocketRequest) async -> ExecHostResponse {
|
||||||
|
let nowMs = Int(Date().timeIntervalSince1970 * 1000)
|
||||||
|
if abs(nowMs - request.ts) > 10000 {
|
||||||
|
return ExecHostResponse(
|
||||||
|
type: "exec-res",
|
||||||
|
id: request.id,
|
||||||
|
ok: false,
|
||||||
|
payload: nil,
|
||||||
|
error: ExecHostError(code: "INVALID_REQUEST", message: "expired request", reason: "ttl"))
|
||||||
|
}
|
||||||
|
let expected = self.hmacHex(nonce: request.nonce, ts: request.ts, requestJson: request.requestJson)
|
||||||
|
if expected != request.hmac {
|
||||||
|
return ExecHostResponse(
|
||||||
|
type: "exec-res",
|
||||||
|
id: request.id,
|
||||||
|
ok: false,
|
||||||
|
payload: nil,
|
||||||
|
error: ExecHostError(code: "INVALID_REQUEST", message: "invalid auth", reason: "hmac"))
|
||||||
|
}
|
||||||
|
guard let requestData = request.requestJson.data(using: .utf8),
|
||||||
|
let payload = try? JSONDecoder().decode(ExecHostRequest.self, from: requestData)
|
||||||
|
else {
|
||||||
|
return ExecHostResponse(
|
||||||
|
type: "exec-res",
|
||||||
|
id: request.id,
|
||||||
|
ok: false,
|
||||||
|
payload: nil,
|
||||||
|
error: ExecHostError(code: "INVALID_REQUEST", message: "invalid payload", reason: "json"))
|
||||||
|
}
|
||||||
|
let response = await self.onExec(payload)
|
||||||
|
return ExecHostResponse(
|
||||||
|
type: "exec-res",
|
||||||
|
id: request.id,
|
||||||
|
ok: response.ok,
|
||||||
|
payload: response.payload,
|
||||||
|
error: response.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hmacHex(nonce: String, ts: Int, requestJson: String) -> String {
|
||||||
|
let key = SymmetricKey(data: Data(self.token.utf8))
|
||||||
|
let message = "\(nonce):\(ts):\(requestJson)"
|
||||||
|
let mac = HMAC<SHA256>.authenticationCode(for: Data(message.utf8), using: key)
|
||||||
|
return mac.map { String(format: "%02x", $0) }.joined()
|
||||||
|
}
|
||||||
|
}
|
||||||
737
apps/macos/Sources/Moltbot/GatewayConnection.swift
Normal file
737
apps/macos/Sources/Moltbot/GatewayConnection.swift
Normal file
@@ -0,0 +1,737 @@
|
|||||||
|
import MoltbotChatUI
|
||||||
|
import MoltbotKit
|
||||||
|
import MoltbotProtocol
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
private let gatewayConnectionLogger = Logger(subsystem: "bot.molt", category: "gateway.connection")
|
||||||
|
|
||||||
|
enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
|
||||||
|
case last
|
||||||
|
case whatsapp
|
||||||
|
case telegram
|
||||||
|
case discord
|
||||||
|
case googlechat
|
||||||
|
case slack
|
||||||
|
case signal
|
||||||
|
case imessage
|
||||||
|
case msteams
|
||||||
|
case bluebubbles
|
||||||
|
case webchat
|
||||||
|
|
||||||
|
init(raw: String?) {
|
||||||
|
let normalized = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
self = GatewayAgentChannel(rawValue: normalized) ?? .last
|
||||||
|
}
|
||||||
|
|
||||||
|
var isDeliverable: Bool { self != .webchat }
|
||||||
|
|
||||||
|
func shouldDeliver(_ deliver: Bool) -> Bool { deliver && self.isDeliverable }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GatewayAgentInvocation: Sendable {
|
||||||
|
var message: String
|
||||||
|
var sessionKey: String = "main"
|
||||||
|
var thinking: String?
|
||||||
|
var deliver: Bool = false
|
||||||
|
var to: String?
|
||||||
|
var channel: GatewayAgentChannel = .last
|
||||||
|
var timeoutSeconds: Int?
|
||||||
|
var idempotencyKey: String = UUID().uuidString
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single, shared Gateway websocket connection for the whole app.
|
||||||
|
///
|
||||||
|
/// This owns exactly one `GatewayChannelActor` and reuses it across all callers
|
||||||
|
/// (ControlChannel, debug actions, SwiftUI WebChat, etc.).
|
||||||
|
actor GatewayConnection {
|
||||||
|
static let shared = GatewayConnection()
|
||||||
|
|
||||||
|
typealias Config = (url: URL, token: String?, password: String?)
|
||||||
|
|
||||||
|
enum Method: String, Sendable {
|
||||||
|
case agent
|
||||||
|
case status
|
||||||
|
case setHeartbeats = "set-heartbeats"
|
||||||
|
case systemEvent = "system-event"
|
||||||
|
case health
|
||||||
|
case channelsStatus = "channels.status"
|
||||||
|
case configGet = "config.get"
|
||||||
|
case configSet = "config.set"
|
||||||
|
case configPatch = "config.patch"
|
||||||
|
case configSchema = "config.schema"
|
||||||
|
case wizardStart = "wizard.start"
|
||||||
|
case wizardNext = "wizard.next"
|
||||||
|
case wizardCancel = "wizard.cancel"
|
||||||
|
case wizardStatus = "wizard.status"
|
||||||
|
case talkMode = "talk.mode"
|
||||||
|
case webLoginStart = "web.login.start"
|
||||||
|
case webLoginWait = "web.login.wait"
|
||||||
|
case channelsLogout = "channels.logout"
|
||||||
|
case modelsList = "models.list"
|
||||||
|
case chatHistory = "chat.history"
|
||||||
|
case sessionsPreview = "sessions.preview"
|
||||||
|
case chatSend = "chat.send"
|
||||||
|
case chatAbort = "chat.abort"
|
||||||
|
case skillsStatus = "skills.status"
|
||||||
|
case skillsInstall = "skills.install"
|
||||||
|
case skillsUpdate = "skills.update"
|
||||||
|
case voicewakeGet = "voicewake.get"
|
||||||
|
case voicewakeSet = "voicewake.set"
|
||||||
|
case nodePairApprove = "node.pair.approve"
|
||||||
|
case nodePairReject = "node.pair.reject"
|
||||||
|
case devicePairList = "device.pair.list"
|
||||||
|
case devicePairApprove = "device.pair.approve"
|
||||||
|
case devicePairReject = "device.pair.reject"
|
||||||
|
case execApprovalResolve = "exec.approval.resolve"
|
||||||
|
case cronList = "cron.list"
|
||||||
|
case cronRuns = "cron.runs"
|
||||||
|
case cronRun = "cron.run"
|
||||||
|
case cronRemove = "cron.remove"
|
||||||
|
case cronUpdate = "cron.update"
|
||||||
|
case cronAdd = "cron.add"
|
||||||
|
case cronStatus = "cron.status"
|
||||||
|
}
|
||||||
|
|
||||||
|
private let configProvider: @Sendable () async throws -> Config
|
||||||
|
private let sessionBox: WebSocketSessionBox?
|
||||||
|
private let decoder = JSONDecoder()
|
||||||
|
|
||||||
|
private var client: GatewayChannelActor?
|
||||||
|
private var configuredURL: URL?
|
||||||
|
private var configuredToken: String?
|
||||||
|
private var configuredPassword: String?
|
||||||
|
|
||||||
|
private var subscribers: [UUID: AsyncStream<GatewayPush>.Continuation] = [:]
|
||||||
|
private var lastSnapshot: HelloOk?
|
||||||
|
|
||||||
|
init(
|
||||||
|
configProvider: @escaping @Sendable () async throws -> Config = GatewayConnection.defaultConfigProvider,
|
||||||
|
sessionBox: WebSocketSessionBox? = nil)
|
||||||
|
{
|
||||||
|
self.configProvider = configProvider
|
||||||
|
self.sessionBox = sessionBox
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Low-level request
|
||||||
|
|
||||||
|
func request(
|
||||||
|
method: String,
|
||||||
|
params: [String: AnyCodable]?,
|
||||||
|
timeoutMs: Double? = nil) async throws -> Data
|
||||||
|
{
|
||||||
|
let cfg = try await self.configProvider()
|
||||||
|
await self.configure(url: cfg.url, token: cfg.token, password: cfg.password)
|
||||||
|
guard let client else {
|
||||||
|
throw NSError(domain: "Gateway", code: 0, userInfo: [NSLocalizedDescriptionKey: "gateway not configured"])
|
||||||
|
}
|
||||||
|
|
||||||
|
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) }
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let nsError = lastError as NSError
|
||||||
|
if nsError.domain == URLError.errorDomain,
|
||||||
|
let fallback = await GatewayEndpointStore.shared.maybeFallbackToTailnet(from: cfg.url)
|
||||||
|
{
|
||||||
|
await self.configure(url: fallback.url, token: fallback.token, password: fallback.password)
|
||||||
|
for delayMs in [150, 400, 900] {
|
||||||
|
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
|
||||||
|
do {
|
||||||
|
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 .remote:
|
||||||
|
let nsError = error as NSError
|
||||||
|
guard nsError.domain == URLError.errorDomain else { throw error }
|
||||||
|
|
||||||
|
var lastError: Error = error
|
||||||
|
await RemoteTunnelManager.shared.stopAll()
|
||||||
|
do {
|
||||||
|
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
|
||||||
|
} 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, password: cfg.password)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestRaw(
|
||||||
|
method: Method,
|
||||||
|
params: [String: AnyCodable]? = nil,
|
||||||
|
timeoutMs: Double? = nil) async throws -> Data
|
||||||
|
{
|
||||||
|
try await self.request(method: method.rawValue, params: params, timeoutMs: timeoutMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestRaw(
|
||||||
|
method: String,
|
||||||
|
params: [String: AnyCodable]? = nil,
|
||||||
|
timeoutMs: Double? = nil) async throws -> Data
|
||||||
|
{
|
||||||
|
try await self.request(method: method, params: params, timeoutMs: timeoutMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestDecoded<T: Decodable>(
|
||||||
|
method: Method,
|
||||||
|
params: [String: AnyCodable]? = nil,
|
||||||
|
timeoutMs: Double? = nil) async throws -> T
|
||||||
|
{
|
||||||
|
let data = try await self.requestRaw(method: method, params: params, timeoutMs: timeoutMs)
|
||||||
|
do {
|
||||||
|
return try self.decoder.decode(T.self, from: data)
|
||||||
|
} catch {
|
||||||
|
throw GatewayDecodingError(method: method.rawValue, message: error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestVoid(
|
||||||
|
method: Method,
|
||||||
|
params: [String: AnyCodable]? = nil,
|
||||||
|
timeoutMs: Double? = nil) async throws
|
||||||
|
{
|
||||||
|
_ = try await self.requestRaw(method: method, params: params, timeoutMs: timeoutMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure the underlying socket is configured (and replaced if config changed).
|
||||||
|
func refresh() async throws {
|
||||||
|
let cfg = try await self.configProvider()
|
||||||
|
await self.configure(url: cfg.url, token: cfg.token, password: cfg.password)
|
||||||
|
}
|
||||||
|
|
||||||
|
func authSource() async -> GatewayAuthSource? {
|
||||||
|
guard let client else { return nil }
|
||||||
|
return await client.authSource()
|
||||||
|
}
|
||||||
|
|
||||||
|
func shutdown() async {
|
||||||
|
if let client {
|
||||||
|
await client.shutdown()
|
||||||
|
}
|
||||||
|
self.client = nil
|
||||||
|
self.configuredURL = nil
|
||||||
|
self.configuredToken = nil
|
||||||
|
self.lastSnapshot = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func canvasHostUrl() async -> String? {
|
||||||
|
guard let snapshot = self.lastSnapshot else { return nil }
|
||||||
|
let trimmed = snapshot.canvashosturl?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) ?? ""
|
||||||
|
return trimmed.isEmpty ? nil : trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sessionDefaultString(_ defaults: [String: MoltbotProtocol.AnyCodable]?, key: String) -> String {
|
||||||
|
let raw = defaults?[key]?.value as? String
|
||||||
|
return (raw ?? "").trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cachedMainSessionKey() -> String? {
|
||||||
|
guard let snapshot = self.lastSnapshot else { return nil }
|
||||||
|
let trimmed = self.sessionDefaultString(snapshot.snapshot.sessiondefaults, key: "mainSessionKey")
|
||||||
|
return trimmed.isEmpty ? nil : trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
func cachedGatewayVersion() -> String? {
|
||||||
|
guard let snapshot = self.lastSnapshot else { return nil }
|
||||||
|
let raw = snapshot.server["version"]?.value as? String
|
||||||
|
let trimmed = raw?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) ?? ""
|
||||||
|
return trimmed.isEmpty ? nil : trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshotPaths() -> (configPath: String?, stateDir: String?) {
|
||||||
|
guard let snapshot = self.lastSnapshot else { return (nil, nil) }
|
||||||
|
let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let stateDir = snapshot.snapshot.statedir?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return (
|
||||||
|
configPath?.isEmpty == false ? configPath : nil,
|
||||||
|
stateDir?.isEmpty == false ? stateDir : nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func subscribe(bufferingNewest: Int = 100) -> AsyncStream<GatewayPush> {
|
||||||
|
let id = UUID()
|
||||||
|
let snapshot = self.lastSnapshot
|
||||||
|
let connection = self
|
||||||
|
return AsyncStream(bufferingPolicy: .bufferingNewest(bufferingNewest)) { continuation in
|
||||||
|
if let snapshot {
|
||||||
|
continuation.yield(.snapshot(snapshot))
|
||||||
|
}
|
||||||
|
self.subscribers[id] = continuation
|
||||||
|
continuation.onTermination = { @Sendable _ in
|
||||||
|
Task { await connection.removeSubscriber(id) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeSubscriber(_ id: UUID) {
|
||||||
|
self.subscribers[id] = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func broadcast(_ push: GatewayPush) {
|
||||||
|
if case let .snapshot(snapshot) = push {
|
||||||
|
self.lastSnapshot = snapshot
|
||||||
|
if let mainSessionKey = self.cachedMainSessionKey() {
|
||||||
|
Task { @MainActor in
|
||||||
|
WorkActivityStore.shared.setMainSessionKey(mainSessionKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (_, continuation) in self.subscribers {
|
||||||
|
continuation.yield(push)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func canonicalizeSessionKey(_ raw: String) -> String {
|
||||||
|
let trimmed = raw.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return trimmed }
|
||||||
|
guard let defaults = self.lastSnapshot?.snapshot.sessiondefaults else { return trimmed }
|
||||||
|
let mainSessionKey = self.sessionDefaultString(defaults, key: "mainSessionKey")
|
||||||
|
guard !mainSessionKey.isEmpty else { return trimmed }
|
||||||
|
let mainKey = self.sessionDefaultString(defaults, key: "mainKey")
|
||||||
|
let defaultAgentId = self.sessionDefaultString(defaults, key: "defaultAgentId")
|
||||||
|
let isMainAlias =
|
||||||
|
trimmed == "main" ||
|
||||||
|
(!mainKey.isEmpty && trimmed == mainKey) ||
|
||||||
|
trimmed == mainSessionKey ||
|
||||||
|
(!defaultAgentId.isEmpty &&
|
||||||
|
(trimmed == "agent:\(defaultAgentId):main" ||
|
||||||
|
(mainKey.isEmpty == false && trimmed == "agent:\(defaultAgentId):\(mainKey)")))
|
||||||
|
return isMainAlias ? mainSessionKey : trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configure(url: URL, token: String?, password: String?) async {
|
||||||
|
if self.client != nil, self.configuredURL == url, self.configuredToken == token,
|
||||||
|
self.configuredPassword == password
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let client {
|
||||||
|
await client.shutdown()
|
||||||
|
}
|
||||||
|
self.lastSnapshot = nil
|
||||||
|
self.client = GatewayChannelActor(
|
||||||
|
url: url,
|
||||||
|
token: token,
|
||||||
|
password: password,
|
||||||
|
session: self.sessionBox,
|
||||||
|
pushHandler: { [weak self] push in
|
||||||
|
await self?.handle(push: push)
|
||||||
|
})
|
||||||
|
self.configuredURL = url
|
||||||
|
self.configuredToken = token
|
||||||
|
self.configuredPassword = password
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handle(push: GatewayPush) {
|
||||||
|
self.broadcast(push)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func defaultConfigProvider() async throws -> Config {
|
||||||
|
try await GatewayEndpointStore.shared.requireConfig()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Typed gateway API
|
||||||
|
|
||||||
|
extension GatewayConnection {
|
||||||
|
struct ConfigGetSnapshot: Decodable, Sendable {
|
||||||
|
struct SnapshotConfig: Decodable, Sendable {
|
||||||
|
struct Session: Decodable, Sendable {
|
||||||
|
let mainKey: String?
|
||||||
|
let scope: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
let session: Session?
|
||||||
|
}
|
||||||
|
|
||||||
|
let config: SnapshotConfig?
|
||||||
|
}
|
||||||
|
|
||||||
|
static func mainSessionKey(fromConfigGetData data: Data) throws -> String {
|
||||||
|
let snapshot = try JSONDecoder().decode(ConfigGetSnapshot.self, from: data)
|
||||||
|
let scope = snapshot.config?.session?.scope?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if scope == "global" {
|
||||||
|
return "global"
|
||||||
|
}
|
||||||
|
return "main"
|
||||||
|
}
|
||||||
|
|
||||||
|
func mainSessionKey(timeoutMs: Double = 15000) async -> String {
|
||||||
|
if let cached = self.cachedMainSessionKey() {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let data = try await self.requestRaw(method: "config.get", params: nil, timeoutMs: timeoutMs)
|
||||||
|
return try Self.mainSessionKey(fromConfigGetData: data)
|
||||||
|
} catch {
|
||||||
|
return "main"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func status() async -> (ok: Bool, error: String?) {
|
||||||
|
do {
|
||||||
|
_ = try await self.requestRaw(method: .status)
|
||||||
|
return (true, nil)
|
||||||
|
} catch {
|
||||||
|
return (false, error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setHeartbeatsEnabled(_ enabled: Bool) async -> Bool {
|
||||||
|
do {
|
||||||
|
try await self.requestVoid(method: .setHeartbeats, params: ["enabled": AnyCodable(enabled)])
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
gatewayConnectionLogger.error("setHeartbeatsEnabled failed \(error.localizedDescription, privacy: .public)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendAgent(_ invocation: GatewayAgentInvocation) async -> (ok: Bool, error: String?) {
|
||||||
|
let trimmed = invocation.message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return (false, "message empty") }
|
||||||
|
let sessionKey = self.canonicalizeSessionKey(invocation.sessionKey)
|
||||||
|
|
||||||
|
var params: [String: AnyCodable] = [
|
||||||
|
"message": AnyCodable(trimmed),
|
||||||
|
"sessionKey": AnyCodable(sessionKey),
|
||||||
|
"thinking": AnyCodable(invocation.thinking ?? "default"),
|
||||||
|
"deliver": AnyCodable(invocation.deliver),
|
||||||
|
"to": AnyCodable(invocation.to ?? ""),
|
||||||
|
"channel": AnyCodable(invocation.channel.rawValue),
|
||||||
|
"idempotencyKey": AnyCodable(invocation.idempotencyKey),
|
||||||
|
]
|
||||||
|
if let timeout = invocation.timeoutSeconds {
|
||||||
|
params["timeout"] = AnyCodable(timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await self.requestVoid(method: .agent, params: params)
|
||||||
|
return (true, nil)
|
||||||
|
} catch {
|
||||||
|
return (false, error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendAgent(
|
||||||
|
message: String,
|
||||||
|
thinking: String?,
|
||||||
|
sessionKey: String,
|
||||||
|
deliver: Bool,
|
||||||
|
to: String?,
|
||||||
|
channel: GatewayAgentChannel = .last,
|
||||||
|
timeoutSeconds: Int? = nil,
|
||||||
|
idempotencyKey: String = UUID().uuidString) async -> (ok: Bool, error: String?)
|
||||||
|
{
|
||||||
|
await self.sendAgent(GatewayAgentInvocation(
|
||||||
|
message: message,
|
||||||
|
sessionKey: sessionKey,
|
||||||
|
thinking: thinking,
|
||||||
|
deliver: deliver,
|
||||||
|
to: to,
|
||||||
|
channel: channel,
|
||||||
|
timeoutSeconds: timeoutSeconds,
|
||||||
|
idempotencyKey: idempotencyKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendSystemEvent(_ params: [String: AnyCodable]) async {
|
||||||
|
do {
|
||||||
|
try await self.requestVoid(method: .systemEvent, params: params)
|
||||||
|
} catch {
|
||||||
|
// Best-effort only.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Health
|
||||||
|
|
||||||
|
func healthSnapshot(timeoutMs: Double? = nil) async throws -> HealthSnapshot {
|
||||||
|
let data = try await self.requestRaw(method: .health, timeoutMs: timeoutMs)
|
||||||
|
if let snap = decodeHealthSnapshot(from: data) { return snap }
|
||||||
|
throw GatewayDecodingError(method: Method.health.rawValue, message: "failed to decode health snapshot")
|
||||||
|
}
|
||||||
|
|
||||||
|
func healthOK(timeoutMs: Int = 8000) async throws -> Bool {
|
||||||
|
let data = try await self.requestRaw(method: .health, timeoutMs: Double(timeoutMs))
|
||||||
|
return (try? self.decoder.decode(MoltbotGatewayHealthOK.self, from: data))?.ok ?? true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Skills
|
||||||
|
|
||||||
|
func skillsStatus() async throws -> SkillsStatusReport {
|
||||||
|
try await self.requestDecoded(method: .skillsStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
func skillsInstall(
|
||||||
|
name: String,
|
||||||
|
installId: String,
|
||||||
|
timeoutMs: Int? = nil) async throws -> SkillInstallResult
|
||||||
|
{
|
||||||
|
var params: [String: AnyCodable] = [
|
||||||
|
"name": AnyCodable(name),
|
||||||
|
"installId": AnyCodable(installId),
|
||||||
|
]
|
||||||
|
if let timeoutMs {
|
||||||
|
params["timeoutMs"] = AnyCodable(timeoutMs)
|
||||||
|
}
|
||||||
|
return try await self.requestDecoded(method: .skillsInstall, params: params)
|
||||||
|
}
|
||||||
|
|
||||||
|
func skillsUpdate(
|
||||||
|
skillKey: String,
|
||||||
|
enabled: Bool? = nil,
|
||||||
|
apiKey: String? = nil,
|
||||||
|
env: [String: String]? = nil) async throws -> SkillUpdateResult
|
||||||
|
{
|
||||||
|
var params: [String: AnyCodable] = [
|
||||||
|
"skillKey": AnyCodable(skillKey),
|
||||||
|
]
|
||||||
|
if let enabled { params["enabled"] = AnyCodable(enabled) }
|
||||||
|
if let apiKey { params["apiKey"] = AnyCodable(apiKey) }
|
||||||
|
if let env, !env.isEmpty { params["env"] = AnyCodable(env) }
|
||||||
|
return try await self.requestDecoded(method: .skillsUpdate, params: params)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sessions
|
||||||
|
|
||||||
|
func sessionsPreview(
|
||||||
|
keys: [String],
|
||||||
|
limit: Int? = nil,
|
||||||
|
maxChars: Int? = nil,
|
||||||
|
timeoutMs: Int? = nil) async throws -> MoltbotSessionsPreviewPayload
|
||||||
|
{
|
||||||
|
let resolvedKeys = keys
|
||||||
|
.map { self.canonicalizeSessionKey($0) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
if resolvedKeys.isEmpty {
|
||||||
|
return MoltbotSessionsPreviewPayload(ts: 0, previews: [])
|
||||||
|
}
|
||||||
|
var params: [String: AnyCodable] = ["keys": AnyCodable(resolvedKeys)]
|
||||||
|
if let limit { params["limit"] = AnyCodable(limit) }
|
||||||
|
if let maxChars { params["maxChars"] = AnyCodable(maxChars) }
|
||||||
|
let timeout = timeoutMs.map { Double($0) }
|
||||||
|
return try await self.requestDecoded(
|
||||||
|
method: .sessionsPreview,
|
||||||
|
params: params,
|
||||||
|
timeoutMs: timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Chat
|
||||||
|
|
||||||
|
func chatHistory(
|
||||||
|
sessionKey: String,
|
||||||
|
limit: Int? = nil,
|
||||||
|
timeoutMs: Int? = nil) async throws -> MoltbotChatHistoryPayload
|
||||||
|
{
|
||||||
|
let resolvedKey = self.canonicalizeSessionKey(sessionKey)
|
||||||
|
var params: [String: AnyCodable] = ["sessionKey": AnyCodable(resolvedKey)]
|
||||||
|
if let limit { params["limit"] = AnyCodable(limit) }
|
||||||
|
let timeout = timeoutMs.map { Double($0) }
|
||||||
|
return try await self.requestDecoded(
|
||||||
|
method: .chatHistory,
|
||||||
|
params: params,
|
||||||
|
timeoutMs: timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func chatSend(
|
||||||
|
sessionKey: String,
|
||||||
|
message: String,
|
||||||
|
thinking: String,
|
||||||
|
idempotencyKey: String,
|
||||||
|
attachments: [MoltbotChatAttachmentPayload],
|
||||||
|
timeoutMs: Int = 30000) async throws -> MoltbotChatSendResponse
|
||||||
|
{
|
||||||
|
let resolvedKey = self.canonicalizeSessionKey(sessionKey)
|
||||||
|
var params: [String: AnyCodable] = [
|
||||||
|
"sessionKey": AnyCodable(resolvedKey),
|
||||||
|
"message": AnyCodable(message),
|
||||||
|
"thinking": AnyCodable(thinking),
|
||||||
|
"idempotencyKey": AnyCodable(idempotencyKey),
|
||||||
|
"timeoutMs": AnyCodable(timeoutMs),
|
||||||
|
]
|
||||||
|
|
||||||
|
if !attachments.isEmpty {
|
||||||
|
let encoded = attachments.map { att in
|
||||||
|
[
|
||||||
|
"type": att.type,
|
||||||
|
"mimeType": att.mimeType,
|
||||||
|
"fileName": att.fileName,
|
||||||
|
"content": att.content,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
params["attachments"] = AnyCodable(encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await self.requestDecoded(
|
||||||
|
method: .chatSend,
|
||||||
|
params: params,
|
||||||
|
timeoutMs: Double(timeoutMs))
|
||||||
|
}
|
||||||
|
|
||||||
|
func chatAbort(sessionKey: String, runId: String) async throws -> Bool {
|
||||||
|
let resolvedKey = self.canonicalizeSessionKey(sessionKey)
|
||||||
|
struct AbortResponse: Decodable { let ok: Bool?; let aborted: Bool? }
|
||||||
|
let res: AbortResponse = try await self.requestDecoded(
|
||||||
|
method: .chatAbort,
|
||||||
|
params: ["sessionKey": AnyCodable(resolvedKey), "runId": AnyCodable(runId)])
|
||||||
|
return res.aborted ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
func talkMode(enabled: Bool, phase: String? = nil) async {
|
||||||
|
var params: [String: AnyCodable] = ["enabled": AnyCodable(enabled)]
|
||||||
|
if let phase { params["phase"] = AnyCodable(phase) }
|
||||||
|
try? await self.requestVoid(method: .talkMode, params: params)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - VoiceWake
|
||||||
|
|
||||||
|
func voiceWakeGetTriggers() async throws -> [String] {
|
||||||
|
struct VoiceWakePayload: Decodable { let triggers: [String] }
|
||||||
|
let payload: VoiceWakePayload = try await self.requestDecoded(method: .voicewakeGet)
|
||||||
|
return payload.triggers
|
||||||
|
}
|
||||||
|
|
||||||
|
func voiceWakeSetTriggers(_ triggers: [String]) async {
|
||||||
|
do {
|
||||||
|
try await self.requestVoid(
|
||||||
|
method: .voicewakeSet,
|
||||||
|
params: ["triggers": AnyCodable(triggers)],
|
||||||
|
timeoutMs: 10000)
|
||||||
|
} catch {
|
||||||
|
// Best-effort only.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Node pairing
|
||||||
|
|
||||||
|
func nodePairApprove(requestId: String) async throws {
|
||||||
|
try await self.requestVoid(
|
||||||
|
method: .nodePairApprove,
|
||||||
|
params: ["requestId": AnyCodable(requestId)],
|
||||||
|
timeoutMs: 10000)
|
||||||
|
}
|
||||||
|
|
||||||
|
func nodePairReject(requestId: String) async throws {
|
||||||
|
try await self.requestVoid(
|
||||||
|
method: .nodePairReject,
|
||||||
|
params: ["requestId": AnyCodable(requestId)],
|
||||||
|
timeoutMs: 10000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Device pairing
|
||||||
|
|
||||||
|
func devicePairApprove(requestId: String) async throws {
|
||||||
|
try await self.requestVoid(
|
||||||
|
method: .devicePairApprove,
|
||||||
|
params: ["requestId": AnyCodable(requestId)],
|
||||||
|
timeoutMs: 10000)
|
||||||
|
}
|
||||||
|
|
||||||
|
func devicePairReject(requestId: String) async throws {
|
||||||
|
try await self.requestVoid(
|
||||||
|
method: .devicePairReject,
|
||||||
|
params: ["requestId": AnyCodable(requestId)],
|
||||||
|
timeoutMs: 10000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cron
|
||||||
|
|
||||||
|
struct CronSchedulerStatus: Decodable, Sendable {
|
||||||
|
let enabled: Bool
|
||||||
|
let storePath: String
|
||||||
|
let jobs: Int
|
||||||
|
let nextWakeAtMs: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
func cronStatus() async throws -> CronSchedulerStatus {
|
||||||
|
try await self.requestDecoded(method: .cronStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cronList(includeDisabled: Bool = true) async throws -> [CronJob] {
|
||||||
|
let res: CronListResponse = try await self.requestDecoded(
|
||||||
|
method: .cronList,
|
||||||
|
params: ["includeDisabled": AnyCodable(includeDisabled)])
|
||||||
|
return res.jobs
|
||||||
|
}
|
||||||
|
|
||||||
|
func cronRuns(jobId: String, limit: Int = 200) async throws -> [CronRunLogEntry] {
|
||||||
|
let res: CronRunsResponse = try await self.requestDecoded(
|
||||||
|
method: .cronRuns,
|
||||||
|
params: ["id": AnyCodable(jobId), "limit": AnyCodable(limit)])
|
||||||
|
return res.entries
|
||||||
|
}
|
||||||
|
|
||||||
|
func cronRun(jobId: String, force: Bool = true) async throws {
|
||||||
|
try await self.requestVoid(
|
||||||
|
method: .cronRun,
|
||||||
|
params: [
|
||||||
|
"id": AnyCodable(jobId),
|
||||||
|
"mode": AnyCodable(force ? "force" : "due"),
|
||||||
|
],
|
||||||
|
timeoutMs: 20000)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cronRemove(jobId: String) async throws {
|
||||||
|
try await self.requestVoid(method: .cronRemove, params: ["id": AnyCodable(jobId)])
|
||||||
|
}
|
||||||
|
|
||||||
|
func cronUpdate(jobId: String, patch: [String: AnyCodable]) async throws {
|
||||||
|
try await self.requestVoid(
|
||||||
|
method: .cronUpdate,
|
||||||
|
params: ["id": AnyCodable(jobId), "patch": AnyCodable(patch)])
|
||||||
|
}
|
||||||
|
|
||||||
|
func cronAdd(payload: [String: AnyCodable]) async throws {
|
||||||
|
try await self.requestVoid(method: .cronAdd, params: payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class GatewayConnectivityCoordinator {
|
||||||
|
static let shared = GatewayConnectivityCoordinator()
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "bot.molt", category: "gateway.connectivity")
|
||||||
|
private var endpointTask: Task<Void, Never>?
|
||||||
|
private var lastResolvedURL: URL?
|
||||||
|
|
||||||
|
private(set) var endpointState: GatewayEndpointState?
|
||||||
|
private(set) var resolvedURL: URL?
|
||||||
|
private(set) var resolvedMode: AppState.ConnectionMode?
|
||||||
|
private(set) var resolvedHostLabel: String?
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
self.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard self.endpointTask == nil else { return }
|
||||||
|
self.endpointTask = Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
let stream = await GatewayEndpointStore.shared.subscribe()
|
||||||
|
for await state in stream {
|
||||||
|
await MainActor.run { self.handleEndpointState(state) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var localEndpointHostLabel: String? {
|
||||||
|
guard self.resolvedMode == .local, let url = self.resolvedURL else { return nil }
|
||||||
|
return Self.hostLabel(for: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleEndpointState(_ state: GatewayEndpointState) {
|
||||||
|
self.endpointState = state
|
||||||
|
switch state {
|
||||||
|
case let .ready(mode, url, _, _):
|
||||||
|
self.resolvedMode = mode
|
||||||
|
self.resolvedURL = url
|
||||||
|
self.resolvedHostLabel = Self.hostLabel(for: url)
|
||||||
|
let urlChanged = self.lastResolvedURL?.absoluteString != url.absoluteString
|
||||||
|
if urlChanged {
|
||||||
|
self.lastResolvedURL = url
|
||||||
|
Task { await ControlChannel.shared.refreshEndpoint(reason: "endpoint changed") }
|
||||||
|
}
|
||||||
|
case let .connecting(mode, _):
|
||||||
|
self.resolvedMode = mode
|
||||||
|
case let .unavailable(mode, _):
|
||||||
|
self.resolvedMode = mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func hostLabel(for url: URL) -> String {
|
||||||
|
let host = url.host ?? url.absoluteString
|
||||||
|
if let port = url.port { return "\(host):\(port)" }
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
}
|
||||||
696
apps/macos/Sources/Moltbot/GatewayEndpointStore.swift
Normal file
696
apps/macos/Sources/Moltbot/GatewayEndpointStore.swift
Normal file
@@ -0,0 +1,696 @@
|
|||||||
|
import ConcurrencyExtras
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
enum GatewayEndpointState: Sendable, Equatable {
|
||||||
|
case ready(mode: AppState.ConnectionMode, url: URL, token: String?, password: String?)
|
||||||
|
case connecting(mode: AppState.ConnectionMode, detail: String)
|
||||||
|
case unavailable(mode: AppState.ConnectionMode, reason: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single place to resolve (and publish) the effective gateway control endpoint.
|
||||||
|
///
|
||||||
|
/// This is intentionally separate from `GatewayConnection`:
|
||||||
|
/// - `GatewayConnection` consumes the resolved endpoint (no tunnel side-effects).
|
||||||
|
/// - The endpoint store owns observation + explicit "ensure tunnel" actions.
|
||||||
|
actor GatewayEndpointStore {
|
||||||
|
static let shared = GatewayEndpointStore()
|
||||||
|
private static let supportedBindModes: Set<String> = [
|
||||||
|
"loopback",
|
||||||
|
"tailnet",
|
||||||
|
"lan",
|
||||||
|
"auto",
|
||||||
|
"custom",
|
||||||
|
]
|
||||||
|
private static let remoteConnectingDetail = "Connecting to remote gateway…"
|
||||||
|
private static let staticLogger = Logger(subsystem: "bot.molt", category: "gateway-endpoint")
|
||||||
|
private enum EnvOverrideWarningKind: Sendable {
|
||||||
|
case token
|
||||||
|
case password
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let envOverrideWarnings = LockIsolated((token: false, password: false))
|
||||||
|
|
||||||
|
struct Deps: Sendable {
|
||||||
|
let mode: @Sendable () async -> AppState.ConnectionMode
|
||||||
|
let token: @Sendable () -> String?
|
||||||
|
let password: @Sendable () -> String?
|
||||||
|
let localPort: @Sendable () -> Int
|
||||||
|
let localHost: @Sendable () async -> String
|
||||||
|
let remotePortIfRunning: @Sendable () async -> UInt16?
|
||||||
|
let ensureRemoteTunnel: @Sendable () async throws -> UInt16
|
||||||
|
|
||||||
|
static let live = Deps(
|
||||||
|
mode: { await MainActor.run { AppStateStore.shared.connectionMode } },
|
||||||
|
token: {
|
||||||
|
let root = MoltbotConfigFile.loadDict()
|
||||||
|
let isRemote = ConnectionModeResolver.resolve(root: root).mode == .remote
|
||||||
|
return GatewayEndpointStore.resolveGatewayToken(
|
||||||
|
isRemote: isRemote,
|
||||||
|
root: root,
|
||||||
|
env: ProcessInfo.processInfo.environment,
|
||||||
|
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
let root = MoltbotConfigFile.loadDict()
|
||||||
|
let isRemote = ConnectionModeResolver.resolve(root: root).mode == .remote
|
||||||
|
return GatewayEndpointStore.resolveGatewayPassword(
|
||||||
|
isRemote: isRemote,
|
||||||
|
root: root,
|
||||||
|
env: ProcessInfo.processInfo.environment,
|
||||||
|
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
|
||||||
|
},
|
||||||
|
localPort: { GatewayEnvironment.gatewayPort() },
|
||||||
|
localHost: {
|
||||||
|
let root = MoltbotConfigFile.loadDict()
|
||||||
|
let bind = GatewayEndpointStore.resolveGatewayBindMode(
|
||||||
|
root: root,
|
||||||
|
env: ProcessInfo.processInfo.environment)
|
||||||
|
let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: root)
|
||||||
|
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
|
||||||
|
?? TailscaleService.fallbackTailnetIPv4()
|
||||||
|
return GatewayEndpointStore.resolveLocalGatewayHost(
|
||||||
|
bindMode: bind,
|
||||||
|
customBindHost: customBindHost,
|
||||||
|
tailscaleIP: tailscaleIP)
|
||||||
|
},
|
||||||
|
remotePortIfRunning: { await RemoteTunnelManager.shared.controlTunnelPortIfRunning() },
|
||||||
|
ensureRemoteTunnel: { try await RemoteTunnelManager.shared.ensureControlTunnel() })
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func resolveGatewayPassword(
|
||||||
|
isRemote: Bool,
|
||||||
|
root: [String: Any],
|
||||||
|
env: [String: String],
|
||||||
|
launchdSnapshot: LaunchAgentPlistSnapshot?) -> String?
|
||||||
|
{
|
||||||
|
let raw = env["CLAWDBOT_GATEWAY_PASSWORD"] ?? ""
|
||||||
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !trimmed.isEmpty {
|
||||||
|
if let configPassword = self.resolveConfigPassword(isRemote: isRemote, root: root),
|
||||||
|
!configPassword.isEmpty
|
||||||
|
{
|
||||||
|
self.warnEnvOverrideOnce(
|
||||||
|
kind: .password,
|
||||||
|
envVar: "CLAWDBOT_GATEWAY_PASSWORD",
|
||||||
|
configKey: isRemote ? "gateway.remote.password" : "gateway.auth.password")
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
if isRemote {
|
||||||
|
if let gateway = root["gateway"] as? [String: Any],
|
||||||
|
let remote = gateway["remote"] as? [String: Any],
|
||||||
|
let password = remote["password"] as? String
|
||||||
|
{
|
||||||
|
let pw = password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !pw.isEmpty {
|
||||||
|
return pw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if let gateway = root["gateway"] as? [String: Any],
|
||||||
|
let auth = gateway["auth"] as? [String: Any],
|
||||||
|
let password = auth["password"] as? String
|
||||||
|
{
|
||||||
|
let pw = password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !pw.isEmpty {
|
||||||
|
return pw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let password = launchdSnapshot?.password?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!password.isEmpty
|
||||||
|
{
|
||||||
|
return password
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func resolveConfigPassword(isRemote: Bool, root: [String: Any]) -> String? {
|
||||||
|
if isRemote {
|
||||||
|
if let gateway = root["gateway"] as? [String: Any],
|
||||||
|
let remote = gateway["remote"] as? [String: Any],
|
||||||
|
let password = remote["password"] as? String
|
||||||
|
{
|
||||||
|
return password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if let gateway = root["gateway"] as? [String: Any],
|
||||||
|
let auth = gateway["auth"] as? [String: Any],
|
||||||
|
let password = auth["password"] as? String
|
||||||
|
{
|
||||||
|
return password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func resolveGatewayToken(
|
||||||
|
isRemote: Bool,
|
||||||
|
root: [String: Any],
|
||||||
|
env: [String: String],
|
||||||
|
launchdSnapshot: LaunchAgentPlistSnapshot?) -> String?
|
||||||
|
{
|
||||||
|
let raw = env["CLAWDBOT_GATEWAY_TOKEN"] ?? ""
|
||||||
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !trimmed.isEmpty {
|
||||||
|
if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root),
|
||||||
|
!configToken.isEmpty,
|
||||||
|
configToken != trimmed
|
||||||
|
{
|
||||||
|
self.warnEnvOverrideOnce(
|
||||||
|
kind: .token,
|
||||||
|
envVar: "CLAWDBOT_GATEWAY_TOKEN",
|
||||||
|
configKey: isRemote ? "gateway.remote.token" : "gateway.auth.token")
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root),
|
||||||
|
!configToken.isEmpty
|
||||||
|
{
|
||||||
|
return configToken
|
||||||
|
}
|
||||||
|
|
||||||
|
if isRemote {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!token.isEmpty
|
||||||
|
{
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func resolveConfigToken(isRemote: Bool, root: [String: Any]) -> String? {
|
||||||
|
if isRemote {
|
||||||
|
if let gateway = root["gateway"] as? [String: Any],
|
||||||
|
let remote = gateway["remote"] as? [String: Any],
|
||||||
|
let token = remote["token"] as? String
|
||||||
|
{
|
||||||
|
return token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if let gateway = root["gateway"] as? [String: Any],
|
||||||
|
let auth = gateway["auth"] as? [String: Any],
|
||||||
|
let token = auth["token"] as? String
|
||||||
|
{
|
||||||
|
return token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func warnEnvOverrideOnce(
|
||||||
|
kind: EnvOverrideWarningKind,
|
||||||
|
envVar: String,
|
||||||
|
configKey: String)
|
||||||
|
{
|
||||||
|
let shouldWarn = Self.envOverrideWarnings.withValue { state in
|
||||||
|
switch kind {
|
||||||
|
case .token:
|
||||||
|
guard !state.token else { return false }
|
||||||
|
state.token = true
|
||||||
|
return true
|
||||||
|
case .password:
|
||||||
|
guard !state.password else { return false }
|
||||||
|
state.password = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard shouldWarn else { return }
|
||||||
|
Self.staticLogger.warning(
|
||||||
|
"\(envVar, privacy: .public) is set and overrides \(configKey, privacy: .public). " +
|
||||||
|
"If this is unintentional, clear it with: launchctl unsetenv \(envVar, privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private let deps: Deps
|
||||||
|
private let logger = Logger(subsystem: "bot.molt", category: "gateway-endpoint")
|
||||||
|
|
||||||
|
private var state: GatewayEndpointState
|
||||||
|
private var subscribers: [UUID: AsyncStream<GatewayEndpointState>.Continuation] = [:]
|
||||||
|
private var remoteEnsure: (token: UUID, task: Task<UInt16, Error>)?
|
||||||
|
|
||||||
|
init(deps: Deps = .live) {
|
||||||
|
self.deps = deps
|
||||||
|
let modeRaw = UserDefaults.standard.string(forKey: connectionModeKey)
|
||||||
|
let initialMode: AppState.ConnectionMode
|
||||||
|
if let modeRaw {
|
||||||
|
initialMode = AppState.ConnectionMode(rawValue: modeRaw) ?? .local
|
||||||
|
} else {
|
||||||
|
let seen = UserDefaults.standard.bool(forKey: "moltbot.onboardingSeen")
|
||||||
|
initialMode = seen ? .local : .unconfigured
|
||||||
|
}
|
||||||
|
|
||||||
|
let port = deps.localPort()
|
||||||
|
let bind = GatewayEndpointStore.resolveGatewayBindMode(
|
||||||
|
root: MoltbotConfigFile.loadDict(),
|
||||||
|
env: ProcessInfo.processInfo.environment)
|
||||||
|
let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: MoltbotConfigFile.loadDict())
|
||||||
|
let scheme = GatewayEndpointStore.resolveGatewayScheme(
|
||||||
|
root: MoltbotConfigFile.loadDict(),
|
||||||
|
env: ProcessInfo.processInfo.environment)
|
||||||
|
let host = GatewayEndpointStore.resolveLocalGatewayHost(
|
||||||
|
bindMode: bind,
|
||||||
|
customBindHost: customBindHost,
|
||||||
|
tailscaleIP: nil)
|
||||||
|
let token = deps.token()
|
||||||
|
let password = deps.password()
|
||||||
|
switch initialMode {
|
||||||
|
case .local:
|
||||||
|
self.state = .ready(
|
||||||
|
mode: .local,
|
||||||
|
url: URL(string: "\(scheme)://\(host):\(port)")!,
|
||||||
|
token: token,
|
||||||
|
password: password)
|
||||||
|
case .remote:
|
||||||
|
self.state = .connecting(mode: .remote, detail: Self.remoteConnectingDetail)
|
||||||
|
Task { await self.setMode(.remote) }
|
||||||
|
case .unconfigured:
|
||||||
|
self.state = .unavailable(mode: .unconfigured, reason: "Gateway not configured")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func subscribe(bufferingNewest: Int = 1) -> AsyncStream<GatewayEndpointState> {
|
||||||
|
let id = UUID()
|
||||||
|
let initial = self.state
|
||||||
|
let store = self
|
||||||
|
return AsyncStream(bufferingPolicy: .bufferingNewest(bufferingNewest)) { continuation in
|
||||||
|
continuation.yield(initial)
|
||||||
|
self.subscribers[id] = continuation
|
||||||
|
continuation.onTermination = { @Sendable _ in
|
||||||
|
Task { await store.removeSubscriber(id) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh() async {
|
||||||
|
let mode = await self.deps.mode()
|
||||||
|
await self.setMode(mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setMode(_ mode: AppState.ConnectionMode) async {
|
||||||
|
let token = self.deps.token()
|
||||||
|
let password = self.deps.password()
|
||||||
|
switch mode {
|
||||||
|
case .local:
|
||||||
|
self.cancelRemoteEnsure()
|
||||||
|
let port = self.deps.localPort()
|
||||||
|
let host = await self.deps.localHost()
|
||||||
|
let scheme = GatewayEndpointStore.resolveGatewayScheme(
|
||||||
|
root: MoltbotConfigFile.loadDict(),
|
||||||
|
env: ProcessInfo.processInfo.environment)
|
||||||
|
self.setState(.ready(
|
||||||
|
mode: .local,
|
||||||
|
url: URL(string: "\(scheme)://\(host):\(port)")!,
|
||||||
|
token: token,
|
||||||
|
password: password))
|
||||||
|
case .remote:
|
||||||
|
let root = MoltbotConfigFile.loadDict()
|
||||||
|
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
|
||||||
|
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
|
||||||
|
self.cancelRemoteEnsure()
|
||||||
|
self.setState(.unavailable(
|
||||||
|
mode: .remote,
|
||||||
|
reason: "gateway.remote.url missing or invalid for direct transport"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.cancelRemoteEnsure()
|
||||||
|
self.setState(.ready(mode: .remote, url: url, token: token, password: password))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let port = await self.deps.remotePortIfRunning()
|
||||||
|
guard let port else {
|
||||||
|
self.setState(.connecting(mode: .remote, detail: Self.remoteConnectingDetail))
|
||||||
|
self.kickRemoteEnsureIfNeeded(detail: Self.remoteConnectingDetail)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.cancelRemoteEnsure()
|
||||||
|
let scheme = GatewayEndpointStore.resolveGatewayScheme(
|
||||||
|
root: MoltbotConfigFile.loadDict(),
|
||||||
|
env: ProcessInfo.processInfo.environment)
|
||||||
|
self.setState(.ready(
|
||||||
|
mode: .remote,
|
||||||
|
url: URL(string: "\(scheme)://127.0.0.1:\(Int(port))")!,
|
||||||
|
token: token,
|
||||||
|
password: password))
|
||||||
|
case .unconfigured:
|
||||||
|
self.cancelRemoteEnsure()
|
||||||
|
self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicit action: ensure the remote control tunnel is established and publish the resolved endpoint.
|
||||||
|
func ensureRemoteControlTunnel() async throws -> UInt16 {
|
||||||
|
let mode = await self.deps.mode()
|
||||||
|
guard mode == .remote else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "RemoteTunnel",
|
||||||
|
code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
||||||
|
}
|
||||||
|
let root = MoltbotConfigFile.loadDict()
|
||||||
|
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
|
||||||
|
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "GatewayEndpoint",
|
||||||
|
code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"])
|
||||||
|
}
|
||||||
|
guard let port = GatewayRemoteConfig.defaultPort(for: url),
|
||||||
|
let portInt = UInt16(exactly: port)
|
||||||
|
else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "GatewayEndpoint",
|
||||||
|
code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Invalid gateway.remote.url port"])
|
||||||
|
}
|
||||||
|
self.logger.info("remote transport direct; skipping SSH tunnel")
|
||||||
|
return portInt
|
||||||
|
}
|
||||||
|
let config = try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail)
|
||||||
|
guard let portInt = config.0.port, let port = UInt16(exactly: portInt) else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "GatewayEndpoint",
|
||||||
|
code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Missing tunnel port"])
|
||||||
|
}
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireConfig() async throws -> GatewayConnection.Config {
|
||||||
|
await self.refresh()
|
||||||
|
switch self.state {
|
||||||
|
case let .ready(_, url, token, password):
|
||||||
|
return (url, token, password)
|
||||||
|
case let .connecting(mode, _):
|
||||||
|
guard mode == .remote else {
|
||||||
|
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"])
|
||||||
|
}
|
||||||
|
return try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail)
|
||||||
|
case let .unavailable(mode, reason):
|
||||||
|
guard mode == .remote else {
|
||||||
|
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: reason])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-recover for remote mode: if the SSH control tunnel died (or hasn't been created yet),
|
||||||
|
// recreate it on demand so callers can recover without a manual reconnect.
|
||||||
|
self.logger.info(
|
||||||
|
"endpoint unavailable; ensuring remote control tunnel reason=\(reason, privacy: .public)")
|
||||||
|
return try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancelRemoteEnsure() {
|
||||||
|
self.remoteEnsure?.task.cancel()
|
||||||
|
self.remoteEnsure = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func kickRemoteEnsureIfNeeded(detail: String) {
|
||||||
|
if self.remoteEnsure != nil {
|
||||||
|
self.setState(.connecting(mode: .remote, detail: detail))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let deps = self.deps
|
||||||
|
let token = UUID()
|
||||||
|
let task = Task.detached(priority: .utility) { try await deps.ensureRemoteTunnel() }
|
||||||
|
self.remoteEnsure = (token: token, task: task)
|
||||||
|
self.setState(.connecting(mode: .remote, detail: detail))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensureRemoteConfig(detail: String) async throws -> GatewayConnection.Config {
|
||||||
|
let mode = await self.deps.mode()
|
||||||
|
guard mode == .remote else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "RemoteTunnel",
|
||||||
|
code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
||||||
|
}
|
||||||
|
|
||||||
|
let root = MoltbotConfigFile.loadDict()
|
||||||
|
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
|
||||||
|
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "GatewayEndpoint",
|
||||||
|
code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"])
|
||||||
|
}
|
||||||
|
let token = self.deps.token()
|
||||||
|
let password = self.deps.password()
|
||||||
|
self.cancelRemoteEnsure()
|
||||||
|
self.setState(.ready(mode: .remote, url: url, token: token, password: password))
|
||||||
|
return (url, token, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.kickRemoteEnsureIfNeeded(detail: detail)
|
||||||
|
guard let ensure = self.remoteEnsure else {
|
||||||
|
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"])
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let forwarded = try await ensure.task.value
|
||||||
|
let stillRemote = await self.deps.mode() == .remote
|
||||||
|
guard stillRemote else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "RemoteTunnel",
|
||||||
|
code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.remoteEnsure?.token == ensure.token {
|
||||||
|
self.remoteEnsure = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = self.deps.token()
|
||||||
|
let password = self.deps.password()
|
||||||
|
let scheme = GatewayEndpointStore.resolveGatewayScheme(
|
||||||
|
root: MoltbotConfigFile.loadDict(),
|
||||||
|
env: ProcessInfo.processInfo.environment)
|
||||||
|
let url = URL(string: "\(scheme)://127.0.0.1:\(Int(forwarded))")!
|
||||||
|
self.setState(.ready(mode: .remote, url: url, token: token, password: password))
|
||||||
|
return (url, token, password)
|
||||||
|
} catch let err as CancellationError {
|
||||||
|
if self.remoteEnsure?.token == ensure.token {
|
||||||
|
self.remoteEnsure = nil
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
} catch {
|
||||||
|
if self.remoteEnsure?.token == ensure.token {
|
||||||
|
self.remoteEnsure = nil
|
||||||
|
}
|
||||||
|
let msg = "Remote control tunnel failed (\(error.localizedDescription))"
|
||||||
|
self.setState(.unavailable(mode: .remote, reason: msg))
|
||||||
|
self.logger.error("remote control tunnel ensure failed \(msg, privacy: .public)")
|
||||||
|
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: msg])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeSubscriber(_ id: UUID) {
|
||||||
|
self.subscribers[id] = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setState(_ next: GatewayEndpointState) {
|
||||||
|
guard next != self.state else { return }
|
||||||
|
self.state = next
|
||||||
|
for (_, continuation) in self.subscribers {
|
||||||
|
continuation.yield(next)
|
||||||
|
}
|
||||||
|
switch next {
|
||||||
|
case let .ready(mode, url, _, _):
|
||||||
|
let modeDesc = String(describing: mode)
|
||||||
|
let urlDesc = url.absoluteString
|
||||||
|
self.logger
|
||||||
|
.debug(
|
||||||
|
"resolved endpoint mode=\(modeDesc, privacy: .public) url=\(urlDesc, privacy: .public)")
|
||||||
|
case let .connecting(mode, detail):
|
||||||
|
let modeDesc = String(describing: mode)
|
||||||
|
self.logger
|
||||||
|
.debug(
|
||||||
|
"endpoint connecting mode=\(modeDesc, privacy: .public) detail=\(detail, privacy: .public)")
|
||||||
|
case let .unavailable(mode, reason):
|
||||||
|
let modeDesc = String(describing: mode)
|
||||||
|
self.logger
|
||||||
|
.debug(
|
||||||
|
"endpoint unavailable mode=\(modeDesc, privacy: .public) reason=\(reason, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func maybeFallbackToTailnet(from currentURL: URL) async -> GatewayConnection.Config? {
|
||||||
|
let mode = await self.deps.mode()
|
||||||
|
guard mode == .local else { return nil }
|
||||||
|
|
||||||
|
let root = MoltbotConfigFile.loadDict()
|
||||||
|
let bind = GatewayEndpointStore.resolveGatewayBindMode(
|
||||||
|
root: root,
|
||||||
|
env: ProcessInfo.processInfo.environment)
|
||||||
|
guard bind == "tailnet" else { return nil }
|
||||||
|
|
||||||
|
let currentHost = currentURL.host?.lowercased() ?? ""
|
||||||
|
guard currentHost == "127.0.0.1" || currentHost == "localhost" else { return nil }
|
||||||
|
|
||||||
|
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
|
||||||
|
?? TailscaleService.fallbackTailnetIPv4()
|
||||||
|
guard let tailscaleIP, !tailscaleIP.isEmpty else { return nil }
|
||||||
|
|
||||||
|
let scheme = GatewayEndpointStore.resolveGatewayScheme(
|
||||||
|
root: root,
|
||||||
|
env: ProcessInfo.processInfo.environment)
|
||||||
|
let port = self.deps.localPort()
|
||||||
|
let token = self.deps.token()
|
||||||
|
let password = self.deps.password()
|
||||||
|
let url = URL(string: "\(scheme)://\(tailscaleIP):\(port)")!
|
||||||
|
|
||||||
|
self.logger.info("auto bind fallback to tailnet host=\(tailscaleIP, privacy: .public)")
|
||||||
|
self.setState(.ready(mode: .local, url: url, token: token, password: password))
|
||||||
|
return (url, token, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func resolveGatewayBindMode(
|
||||||
|
root: [String: Any],
|
||||||
|
env: [String: String]) -> String?
|
||||||
|
{
|
||||||
|
if let envBind = env["CLAWDBOT_GATEWAY_BIND"] {
|
||||||
|
let trimmed = envBind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
if self.supportedBindModes.contains(trimmed) {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let gateway = root["gateway"] as? [String: Any],
|
||||||
|
let bind = gateway["bind"] as? String
|
||||||
|
{
|
||||||
|
let trimmed = bind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
if self.supportedBindModes.contains(trimmed) {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func resolveGatewayCustomBindHost(root: [String: Any]) -> String? {
|
||||||
|
if let gateway = root["gateway"] as? [String: Any],
|
||||||
|
let customBindHost = gateway["customBindHost"] as? String
|
||||||
|
{
|
||||||
|
let trimmed = customBindHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return trimmed.isEmpty ? nil : trimmed
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func resolveGatewayScheme(
|
||||||
|
root: [String: Any],
|
||||||
|
env: [String: String]) -> String
|
||||||
|
{
|
||||||
|
if let envValue = env["CLAWDBOT_GATEWAY_TLS"]?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!envValue.isEmpty
|
||||||
|
{
|
||||||
|
return (envValue == "1" || envValue.lowercased() == "true") ? "wss" : "ws"
|
||||||
|
}
|
||||||
|
if let gateway = root["gateway"] as? [String: Any],
|
||||||
|
let tls = gateway["tls"] as? [String: Any],
|
||||||
|
let enabled = tls["enabled"] as? Bool
|
||||||
|
{
|
||||||
|
return enabled ? "wss" : "ws"
|
||||||
|
}
|
||||||
|
return "ws"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func resolveLocalGatewayHost(
|
||||||
|
bindMode: String?,
|
||||||
|
customBindHost: String?,
|
||||||
|
tailscaleIP: String?) -> String
|
||||||
|
{
|
||||||
|
switch bindMode {
|
||||||
|
case "tailnet":
|
||||||
|
tailscaleIP ?? "127.0.0.1"
|
||||||
|
case "auto":
|
||||||
|
"127.0.0.1"
|
||||||
|
case "custom":
|
||||||
|
customBindHost ?? "127.0.0.1"
|
||||||
|
default:
|
||||||
|
"127.0.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension GatewayEndpointStore {
|
||||||
|
static func dashboardURL(for config: GatewayConnection.Config) throws -> URL {
|
||||||
|
guard var components = URLComponents(url: config.url, resolvingAgainstBaseURL: false) else {
|
||||||
|
throw NSError(domain: "Dashboard", code: 1, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "Invalid gateway URL",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
switch components.scheme?.lowercased() {
|
||||||
|
case "ws":
|
||||||
|
components.scheme = "http"
|
||||||
|
case "wss":
|
||||||
|
components.scheme = "https"
|
||||||
|
default:
|
||||||
|
components.scheme = "http"
|
||||||
|
}
|
||||||
|
components.path = "/"
|
||||||
|
var queryItems: [URLQueryItem] = []
|
||||||
|
if let token = config.token?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!token.isEmpty
|
||||||
|
{
|
||||||
|
queryItems.append(URLQueryItem(name: "token", value: token))
|
||||||
|
}
|
||||||
|
if let password = config.password?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!password.isEmpty
|
||||||
|
{
|
||||||
|
queryItems.append(URLQueryItem(name: "password", value: password))
|
||||||
|
}
|
||||||
|
components.queryItems = queryItems.isEmpty ? nil : queryItems
|
||||||
|
guard let url = components.url else {
|
||||||
|
throw NSError(domain: "Dashboard", code: 2, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "Failed to build dashboard URL",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
extension GatewayEndpointStore {
|
||||||
|
static func _testResolveGatewayPassword(
|
||||||
|
isRemote: Bool,
|
||||||
|
root: [String: Any],
|
||||||
|
env: [String: String],
|
||||||
|
launchdSnapshot: LaunchAgentPlistSnapshot? = nil) -> String?
|
||||||
|
{
|
||||||
|
self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env, launchdSnapshot: launchdSnapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func _testResolveGatewayToken(
|
||||||
|
isRemote: Bool,
|
||||||
|
root: [String: Any],
|
||||||
|
env: [String: String],
|
||||||
|
launchdSnapshot: LaunchAgentPlistSnapshot? = nil) -> String?
|
||||||
|
{
|
||||||
|
self.resolveGatewayToken(isRemote: isRemote, root: root, env: env, launchdSnapshot: launchdSnapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func _testResolveGatewayBindMode(
|
||||||
|
root: [String: Any],
|
||||||
|
env: [String: String]) -> String?
|
||||||
|
{
|
||||||
|
self.resolveGatewayBindMode(root: root, env: env)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func _testResolveLocalGatewayHost(
|
||||||
|
bindMode: String?,
|
||||||
|
tailscaleIP: String?,
|
||||||
|
customBindHost: String? = nil) -> String
|
||||||
|
{
|
||||||
|
self.resolveLocalGatewayHost(
|
||||||
|
bindMode: bindMode,
|
||||||
|
customBindHost: customBindHost,
|
||||||
|
tailscaleIP: tailscaleIP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
342
apps/macos/Sources/Moltbot/GatewayEnvironment.swift
Normal file
342
apps/macos/Sources/Moltbot/GatewayEnvironment.swift
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
import MoltbotIPC
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
// Lightweight SemVer helper (major.minor.patch only) for gateway compatibility checks.
|
||||||
|
struct Semver: Comparable, CustomStringConvertible, Sendable {
|
||||||
|
let major: Int
|
||||||
|
let minor: Int
|
||||||
|
let patch: Int
|
||||||
|
|
||||||
|
var description: String { "\(self.major).\(self.minor).\(self.patch)" }
|
||||||
|
|
||||||
|
static func < (lhs: Semver, rhs: Semver) -> Bool {
|
||||||
|
if lhs.major != rhs.major { return lhs.major < rhs.major }
|
||||||
|
if lhs.minor != rhs.minor { return lhs.minor < rhs.minor }
|
||||||
|
return lhs.patch < rhs.patch
|
||||||
|
}
|
||||||
|
|
||||||
|
static func parse(_ raw: String?) -> Semver? {
|
||||||
|
guard let raw, !raw.isEmpty else { return nil }
|
||||||
|
let cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.replacingOccurrences(of: "^v", with: "", options: .regularExpression)
|
||||||
|
let parts = cleaned.split(separator: ".")
|
||||||
|
guard parts.count >= 3,
|
||||||
|
let major = Int(parts[0]),
|
||||||
|
let minor = Int(parts[1])
|
||||||
|
else { return nil }
|
||||||
|
// Strip prerelease suffix (e.g., "11-4" → "11", "5-beta.1" → "5")
|
||||||
|
let patchRaw = String(parts[2])
|
||||||
|
guard let patchToken = patchRaw.split(whereSeparator: { $0 == "-" || $0 == "+" }).first,
|
||||||
|
let patchNumeric = Int(patchToken)
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return Semver(major: major, minor: minor, patch: patchNumeric)
|
||||||
|
}
|
||||||
|
|
||||||
|
func compatible(with required: Semver) -> Bool {
|
||||||
|
// Same major and not older than required.
|
||||||
|
self.major == required.major && self >= required
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum GatewayEnvironmentKind: Equatable {
|
||||||
|
case checking
|
||||||
|
case ok
|
||||||
|
case missingNode
|
||||||
|
case missingGateway
|
||||||
|
case incompatible(found: String, required: String)
|
||||||
|
case error(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GatewayEnvironmentStatus: Equatable {
|
||||||
|
let kind: GatewayEnvironmentKind
|
||||||
|
let nodeVersion: String?
|
||||||
|
let gatewayVersion: String?
|
||||||
|
let requiredGateway: String?
|
||||||
|
let message: String
|
||||||
|
|
||||||
|
static var checking: Self {
|
||||||
|
.init(kind: .checking, nodeVersion: nil, gatewayVersion: nil, requiredGateway: nil, message: "Checking…")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GatewayCommandResolution {
|
||||||
|
let status: GatewayEnvironmentStatus
|
||||||
|
let command: [String]?
|
||||||
|
}
|
||||||
|
|
||||||
|
enum GatewayEnvironment {
|
||||||
|
private static let logger = Logger(subsystem: "bot.molt", category: "gateway.env")
|
||||||
|
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
|
||||||
|
|
||||||
|
static func gatewayPort() -> Int {
|
||||||
|
if let raw = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_PORT"] {
|
||||||
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if let parsed = Int(trimmed), parsed > 0 { return parsed }
|
||||||
|
}
|
||||||
|
if let configPort = MoltbotConfigFile.gatewayPort(), configPort > 0 {
|
||||||
|
return configPort
|
||||||
|
}
|
||||||
|
let stored = UserDefaults.standard.integer(forKey: "gatewayPort")
|
||||||
|
return stored > 0 ? stored : 18789
|
||||||
|
}
|
||||||
|
|
||||||
|
static func expectedGatewayVersion() -> Semver? {
|
||||||
|
Semver.parse(self.expectedGatewayVersionString())
|
||||||
|
}
|
||||||
|
|
||||||
|
static func expectedGatewayVersionString() -> String? {
|
||||||
|
let bundleVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
||||||
|
let trimmed = bundleVersion?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return (trimmed?.isEmpty == false) ? trimmed : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exposed for tests so we can inject fake version checks without rewriting bundle metadata.
|
||||||
|
static func expectedGatewayVersion(from versionString: String?) -> Semver? {
|
||||||
|
Semver.parse(versionString)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func check() -> GatewayEnvironmentStatus {
|
||||||
|
let start = Date()
|
||||||
|
defer {
|
||||||
|
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
|
||||||
|
if elapsedMs > 500 {
|
||||||
|
self.logger.warning("gateway env check slow (\(elapsedMs, privacy: .public)ms)")
|
||||||
|
} else {
|
||||||
|
self.logger.debug("gateway env check ok (\(elapsedMs, privacy: .public)ms)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let expected = self.expectedGatewayVersion()
|
||||||
|
let expectedString = self.expectedGatewayVersionString()
|
||||||
|
|
||||||
|
let projectRoot = CommandResolver.projectRoot()
|
||||||
|
let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot)
|
||||||
|
|
||||||
|
switch RuntimeLocator.resolve(searchPaths: CommandResolver.preferredPaths()) {
|
||||||
|
case let .failure(err):
|
||||||
|
return GatewayEnvironmentStatus(
|
||||||
|
kind: .missingNode,
|
||||||
|
nodeVersion: nil,
|
||||||
|
gatewayVersion: nil,
|
||||||
|
requiredGateway: expectedString,
|
||||||
|
message: RuntimeLocator.describeFailure(err))
|
||||||
|
case let .success(runtime):
|
||||||
|
let gatewayBin = CommandResolver.clawdbotExecutable()
|
||||||
|
|
||||||
|
if gatewayBin == nil, projectEntrypoint == nil {
|
||||||
|
return GatewayEnvironmentStatus(
|
||||||
|
kind: .missingGateway,
|
||||||
|
nodeVersion: runtime.version.description,
|
||||||
|
gatewayVersion: nil,
|
||||||
|
requiredGateway: expectedString,
|
||||||
|
message: "moltbot CLI not found in PATH; install the CLI.")
|
||||||
|
}
|
||||||
|
|
||||||
|
let installed = gatewayBin.flatMap { self.readGatewayVersion(binary: $0) }
|
||||||
|
?? self.readLocalGatewayVersion(projectRoot: projectRoot)
|
||||||
|
|
||||||
|
if let expected, let installed, !installed.compatible(with: expected) {
|
||||||
|
let expectedText = expectedString ?? expected.description
|
||||||
|
return GatewayEnvironmentStatus(
|
||||||
|
kind: .incompatible(found: installed.description, required: expectedText),
|
||||||
|
nodeVersion: runtime.version.description,
|
||||||
|
gatewayVersion: installed.description,
|
||||||
|
requiredGateway: expectedText,
|
||||||
|
message: """
|
||||||
|
Gateway version \(installed.description) is incompatible with app \(expectedText);
|
||||||
|
install or update the global package.
|
||||||
|
""")
|
||||||
|
}
|
||||||
|
|
||||||
|
let gatewayLabel = gatewayBin != nil ? "global" : "local"
|
||||||
|
let gatewayVersionText = installed?.description ?? "unknown"
|
||||||
|
// Avoid repeating "(local)" twice; if using the local entrypoint, show the path once.
|
||||||
|
let localPathHint = gatewayBin == nil && projectEntrypoint != nil
|
||||||
|
? " (local: \(projectEntrypoint ?? "unknown"))"
|
||||||
|
: ""
|
||||||
|
let gatewayLabelText = gatewayBin != nil
|
||||||
|
? "(\(gatewayLabel))"
|
||||||
|
: localPathHint.isEmpty ? "(\(gatewayLabel))" : localPathHint
|
||||||
|
return GatewayEnvironmentStatus(
|
||||||
|
kind: .ok,
|
||||||
|
nodeVersion: runtime.version.description,
|
||||||
|
gatewayVersion: gatewayVersionText,
|
||||||
|
requiredGateway: expectedString,
|
||||||
|
message: "Node \(runtime.version.description); gateway \(gatewayVersionText) \(gatewayLabelText)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func resolveGatewayCommand() -> GatewayCommandResolution {
|
||||||
|
let start = Date()
|
||||||
|
defer {
|
||||||
|
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
|
||||||
|
if elapsedMs > 500 {
|
||||||
|
self.logger.warning("gateway command resolve slow (\(elapsedMs, privacy: .public)ms)")
|
||||||
|
} else {
|
||||||
|
self.logger.debug("gateway command resolve ok (\(elapsedMs, privacy: .public)ms)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let projectRoot = CommandResolver.projectRoot()
|
||||||
|
let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot)
|
||||||
|
let status = self.check()
|
||||||
|
let gatewayBin = CommandResolver.clawdbotExecutable()
|
||||||
|
let runtime = RuntimeLocator.resolve(searchPaths: CommandResolver.preferredPaths())
|
||||||
|
|
||||||
|
guard case .ok = status.kind else {
|
||||||
|
return GatewayCommandResolution(status: status, command: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
let port = self.gatewayPort()
|
||||||
|
if let gatewayBin {
|
||||||
|
let bind = self.preferredGatewayBind() ?? "loopback"
|
||||||
|
let cmd = [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind]
|
||||||
|
return GatewayCommandResolution(status: status, command: cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let entry = projectEntrypoint,
|
||||||
|
case let .success(resolvedRuntime) = runtime
|
||||||
|
{
|
||||||
|
let bind = self.preferredGatewayBind() ?? "loopback"
|
||||||
|
let cmd = [resolvedRuntime.path, entry, "gateway-daemon", "--port", "\(port)", "--bind", bind]
|
||||||
|
return GatewayCommandResolution(status: status, command: cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
return GatewayCommandResolution(status: status, command: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func preferredGatewayBind() -> String? {
|
||||||
|
if CommandResolver.connectionModeIsRemote() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if let env = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_BIND"] {
|
||||||
|
let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
if self.supportedBindModes.contains(trimmed) {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let root = MoltbotConfigFile.loadDict()
|
||||||
|
if let gateway = root["gateway"] as? [String: Any],
|
||||||
|
let bind = gateway["bind"] as? String
|
||||||
|
{
|
||||||
|
let trimmed = bind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
if self.supportedBindModes.contains(trimmed) {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func installGlobal(version: Semver?, statusHandler: @escaping @Sendable (String) -> Void) async {
|
||||||
|
await self.installGlobal(versionString: version?.description, statusHandler: statusHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func installGlobal(versionString: String?, statusHandler: @escaping @Sendable (String) -> Void) async {
|
||||||
|
let preferred = CommandResolver.preferredPaths().joined(separator: ":")
|
||||||
|
let trimmed = versionString?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let target: String = if let trimmed, !trimmed.isEmpty {
|
||||||
|
trimmed
|
||||||
|
} else {
|
||||||
|
"latest"
|
||||||
|
}
|
||||||
|
let npm = CommandResolver.findExecutable(named: "npm")
|
||||||
|
let pnpm = CommandResolver.findExecutable(named: "pnpm")
|
||||||
|
let bun = CommandResolver.findExecutable(named: "bun")
|
||||||
|
let (label, cmd): (String, [String]) =
|
||||||
|
if let npm {
|
||||||
|
("npm", [npm, "install", "-g", "moltbot@\(target)"])
|
||||||
|
} else if let pnpm {
|
||||||
|
("pnpm", [pnpm, "add", "-g", "moltbot@\(target)"])
|
||||||
|
} else if let bun {
|
||||||
|
("bun", [bun, "add", "-g", "moltbot@\(target)"])
|
||||||
|
} else {
|
||||||
|
("npm", ["npm", "install", "-g", "moltbot@\(target)"])
|
||||||
|
}
|
||||||
|
|
||||||
|
statusHandler("Installing moltbot@\(target) via \(label)…")
|
||||||
|
|
||||||
|
func summarize(_ text: String) -> String? {
|
||||||
|
let lines = text
|
||||||
|
.split(whereSeparator: \.isNewline)
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
guard let last = lines.last else { return nil }
|
||||||
|
let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
||||||
|
return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = await ShellExecutor.runDetailed(command: cmd, cwd: nil, env: ["PATH": preferred], timeout: 300)
|
||||||
|
if response.success {
|
||||||
|
statusHandler("Installed moltbot@\(target)")
|
||||||
|
} else {
|
||||||
|
if response.timedOut {
|
||||||
|
statusHandler("Install failed: timed out. Check your internet connection and try again.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let exit = response.exitCode.map { "exit \($0)" } ?? (response.errorMessage ?? "failed")
|
||||||
|
let detail = summarize(response.stderr) ?? summarize(response.stdout)
|
||||||
|
if let detail {
|
||||||
|
statusHandler("Install failed (\(exit)): \(detail)")
|
||||||
|
} else {
|
||||||
|
statusHandler("Install failed (\(exit))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internals
|
||||||
|
|
||||||
|
private static func readGatewayVersion(binary: String) -> Semver? {
|
||||||
|
let start = Date()
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = URL(fileURLWithPath: binary)
|
||||||
|
process.arguments = ["--version"]
|
||||||
|
process.environment = ["PATH": CommandResolver.preferredPaths().joined(separator: ":")]
|
||||||
|
|
||||||
|
let pipe = Pipe()
|
||||||
|
process.standardOutput = pipe
|
||||||
|
process.standardError = pipe
|
||||||
|
do {
|
||||||
|
let data = try process.runAndReadToEnd(from: pipe)
|
||||||
|
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
|
||||||
|
if elapsedMs > 500 {
|
||||||
|
self.logger.warning(
|
||||||
|
"""
|
||||||
|
gateway --version slow (\(elapsedMs, privacy: .public)ms) \
|
||||||
|
bin=\(binary, privacy: .public)
|
||||||
|
""")
|
||||||
|
} else {
|
||||||
|
self.logger.debug(
|
||||||
|
"""
|
||||||
|
gateway --version ok (\(elapsedMs, privacy: .public)ms) \
|
||||||
|
bin=\(binary, privacy: .public)
|
||||||
|
""")
|
||||||
|
}
|
||||||
|
let raw = String(data: data, encoding: .utf8)?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return Semver.parse(raw)
|
||||||
|
} catch {
|
||||||
|
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
|
||||||
|
self.logger.error(
|
||||||
|
"""
|
||||||
|
gateway --version failed (\(elapsedMs, privacy: .public)ms) \
|
||||||
|
bin=\(binary, privacy: .public) \
|
||||||
|
err=\(error.localizedDescription, privacy: .public)
|
||||||
|
""")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func readLocalGatewayVersion(projectRoot: URL) -> Semver? {
|
||||||
|
let pkg = projectRoot.appendingPathComponent("package.json")
|
||||||
|
guard let data = try? Data(contentsOf: pkg) else { return nil }
|
||||||
|
guard
|
||||||
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let version = json["version"] as? String
|
||||||
|
else { return nil }
|
||||||
|
return Semver.parse(version)
|
||||||
|
}
|
||||||
|
}
|
||||||
203
apps/macos/Sources/Moltbot/GatewayLaunchAgentManager.swift
Normal file
203
apps/macos/Sources/Moltbot/GatewayLaunchAgentManager.swift
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum GatewayLaunchAgentManager {
|
||||||
|
private static let logger = Logger(subsystem: "bot.molt", category: "gateway.launchd")
|
||||||
|
private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent"
|
||||||
|
|
||||||
|
private static var disableLaunchAgentMarkerURL: URL {
|
||||||
|
FileManager().homeDirectoryForCurrentUser
|
||||||
|
.appendingPathComponent(self.disableLaunchAgentMarker)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static var plistURL: URL {
|
||||||
|
FileManager().homeDirectoryForCurrentUser
|
||||||
|
.appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isLaunchAgentWriteDisabled() -> Bool {
|
||||||
|
FileManager().fileExists(atPath: self.disableLaunchAgentMarkerURL.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func setLaunchAgentWriteDisabled(_ disabled: Bool) -> String? {
|
||||||
|
let marker = self.disableLaunchAgentMarkerURL
|
||||||
|
if disabled {
|
||||||
|
do {
|
||||||
|
try FileManager().createDirectory(
|
||||||
|
at: marker.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true)
|
||||||
|
if !FileManager().fileExists(atPath: marker.path) {
|
||||||
|
FileManager().createFile(atPath: marker.path, contents: nil)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return error.localizedDescription
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if FileManager().fileExists(atPath: marker.path) {
|
||||||
|
do {
|
||||||
|
try FileManager().removeItem(at: marker)
|
||||||
|
} catch {
|
||||||
|
return error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isLoaded() async -> Bool {
|
||||||
|
guard let loaded = await self.readDaemonLoaded() else { return false }
|
||||||
|
return loaded
|
||||||
|
}
|
||||||
|
|
||||||
|
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
|
||||||
|
_ = bundlePath
|
||||||
|
guard !CommandResolver.connectionModeIsRemote() else {
|
||||||
|
self.logger.info("launchd change skipped (remote mode)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if enabled, self.isLaunchAgentWriteDisabled() {
|
||||||
|
self.logger.info("launchd enable skipped (disable marker set)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if enabled {
|
||||||
|
self.logger.info("launchd enable requested via CLI port=\(port)")
|
||||||
|
return await self.runDaemonCommand([
|
||||||
|
"install",
|
||||||
|
"--force",
|
||||||
|
"--port",
|
||||||
|
"\(port)",
|
||||||
|
"--runtime",
|
||||||
|
"node",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
self.logger.info("launchd disable requested via CLI")
|
||||||
|
return await self.runDaemonCommand(["uninstall"])
|
||||||
|
}
|
||||||
|
|
||||||
|
static func kickstart() async {
|
||||||
|
_ = await self.runDaemonCommand(["restart"], timeout: 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func launchdConfigSnapshot() -> LaunchAgentPlistSnapshot? {
|
||||||
|
LaunchAgentPlist.snapshot(url: self.plistURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func launchdGatewayLogPath() -> String {
|
||||||
|
let snapshot = self.launchdConfigSnapshot()
|
||||||
|
if let stdout = snapshot?.stdoutPath?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!stdout.isEmpty
|
||||||
|
{
|
||||||
|
return stdout
|
||||||
|
}
|
||||||
|
if let stderr = snapshot?.stderrPath?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!stderr.isEmpty
|
||||||
|
{
|
||||||
|
return stderr
|
||||||
|
}
|
||||||
|
return LogLocator.launchdGatewayLogPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension GatewayLaunchAgentManager {
|
||||||
|
private static func readDaemonLoaded() async -> Bool? {
|
||||||
|
let result = await self.runDaemonCommandResult(
|
||||||
|
["status", "--json", "--no-probe"],
|
||||||
|
timeout: 15,
|
||||||
|
quiet: true)
|
||||||
|
guard result.success, let payload = result.payload else { return nil }
|
||||||
|
guard
|
||||||
|
let json = try? JSONSerialization.jsonObject(with: payload) as? [String: Any],
|
||||||
|
let service = json["service"] as? [String: Any],
|
||||||
|
let loaded = service["loaded"] as? Bool
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return loaded
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CommandResult {
|
||||||
|
let success: Bool
|
||||||
|
let payload: Data?
|
||||||
|
let message: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ParsedDaemonJson {
|
||||||
|
let text: String
|
||||||
|
let object: [String: Any]
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func runDaemonCommand(
|
||||||
|
_ args: [String],
|
||||||
|
timeout: Double = 15,
|
||||||
|
quiet: Bool = false) async -> String?
|
||||||
|
{
|
||||||
|
let result = await self.runDaemonCommandResult(args, timeout: timeout, quiet: quiet)
|
||||||
|
if result.success { return nil }
|
||||||
|
return result.message ?? "Gateway daemon command failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func runDaemonCommandResult(
|
||||||
|
_ args: [String],
|
||||||
|
timeout: Double,
|
||||||
|
quiet: Bool) async -> CommandResult
|
||||||
|
{
|
||||||
|
let command = CommandResolver.clawdbotCommand(
|
||||||
|
subcommand: "gateway",
|
||||||
|
extraArgs: self.withJsonFlag(args),
|
||||||
|
// Launchd management must always run locally, even if remote mode is configured.
|
||||||
|
configRoot: ["gateway": ["mode": "local"]])
|
||||||
|
var env = ProcessInfo.processInfo.environment
|
||||||
|
env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":")
|
||||||
|
let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout)
|
||||||
|
let parsed = self.parseDaemonJson(from: response.stdout) ?? self.parseDaemonJson(from: response.stderr)
|
||||||
|
let ok = parsed?.object["ok"] as? Bool
|
||||||
|
let message = (parsed?.object["error"] as? String) ?? (parsed?.object["message"] as? String)
|
||||||
|
let payload = parsed?.text.data(using: .utf8)
|
||||||
|
?? (response.stdout.isEmpty ? response.stderr : response.stdout).data(using: .utf8)
|
||||||
|
let success = ok ?? response.success
|
||||||
|
if success {
|
||||||
|
return CommandResult(success: true, payload: payload, message: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
if quiet {
|
||||||
|
return CommandResult(success: false, payload: payload, message: message)
|
||||||
|
}
|
||||||
|
|
||||||
|
let detail = message ?? self.summarize(response.stderr) ?? self.summarize(response.stdout)
|
||||||
|
let exit = response.exitCode.map { "exit \($0)" } ?? (response.errorMessage ?? "failed")
|
||||||
|
let fullMessage = detail.map { "Gateway daemon command failed (\(exit)): \($0)" }
|
||||||
|
?? "Gateway daemon command failed (\(exit))"
|
||||||
|
self.logger.error("\(fullMessage, privacy: .public)")
|
||||||
|
return CommandResult(success: false, payload: payload, message: detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func withJsonFlag(_ args: [String]) -> [String] {
|
||||||
|
if args.contains("--json") { return args }
|
||||||
|
return args + ["--json"]
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseDaemonJson(from raw: String) -> ParsedDaemonJson? {
|
||||||
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard let start = trimmed.firstIndex(of: "{"),
|
||||||
|
let end = trimmed.lastIndex(of: "}")
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let jsonText = String(trimmed[start...end])
|
||||||
|
guard let data = jsonText.data(using: .utf8) else { return nil }
|
||||||
|
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
|
||||||
|
return ParsedDaemonJson(text: jsonText, object: object)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func summarize(_ text: String) -> String? {
|
||||||
|
let lines = text
|
||||||
|
.split(whereSeparator: \.isNewline)
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
guard let last = lines.last else { return nil }
|
||||||
|
let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
||||||
|
return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
432
apps/macos/Sources/Moltbot/GatewayProcessManager.swift
Normal file
432
apps/macos/Sources/Moltbot/GatewayProcessManager.swift
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class GatewayProcessManager {
|
||||||
|
static let shared = GatewayProcessManager()
|
||||||
|
|
||||||
|
enum Status: Equatable {
|
||||||
|
case stopped
|
||||||
|
case starting
|
||||||
|
case running(details: String?)
|
||||||
|
case attachedExisting(details: String?)
|
||||||
|
case failed(String)
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .stopped: return "Stopped"
|
||||||
|
case .starting: return "Starting…"
|
||||||
|
case let .running(details):
|
||||||
|
if let details, !details.isEmpty { return "Running (\(details))" }
|
||||||
|
return "Running"
|
||||||
|
case let .attachedExisting(details):
|
||||||
|
if let details, !details.isEmpty {
|
||||||
|
return "Using existing gateway (\(details))"
|
||||||
|
}
|
||||||
|
return "Using existing gateway"
|
||||||
|
case let .failed(reason): return "Failed: \(reason)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private(set) var status: Status = .stopped {
|
||||||
|
didSet { CanvasManager.shared.refreshDebugStatus() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private(set) var log: String = ""
|
||||||
|
private(set) var environmentStatus: GatewayEnvironmentStatus = .checking
|
||||||
|
private(set) var existingGatewayDetails: String?
|
||||||
|
private(set) var lastFailureReason: String?
|
||||||
|
private var desiredActive = false
|
||||||
|
private var environmentRefreshTask: Task<Void, Never>?
|
||||||
|
private var lastEnvironmentRefresh: Date?
|
||||||
|
private var logRefreshTask: Task<Void, Never>?
|
||||||
|
#if DEBUG
|
||||||
|
private var testingConnection: GatewayConnection?
|
||||||
|
#endif
|
||||||
|
private let logger = Logger(subsystem: "bot.molt", category: "gateway.process")
|
||||||
|
|
||||||
|
private let logLimit = 20000 // characters to keep in-memory
|
||||||
|
private let environmentRefreshMinInterval: TimeInterval = 30
|
||||||
|
private var connection: GatewayConnection {
|
||||||
|
#if DEBUG
|
||||||
|
return self.testingConnection ?? .shared
|
||||||
|
#else
|
||||||
|
return .shared
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
func setActive(_ active: Bool) {
|
||||||
|
// Remote mode should never spawn a local gateway; treat as stopped.
|
||||||
|
if CommandResolver.connectionModeIsRemote() {
|
||||||
|
self.desiredActive = false
|
||||||
|
self.stop()
|
||||||
|
self.status = .stopped
|
||||||
|
self.appendLog("[gateway] remote mode active; skipping local gateway\n")
|
||||||
|
self.logger.info("gateway process skipped: remote mode active")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.logger.debug("gateway active requested active=\(active)")
|
||||||
|
self.desiredActive = active
|
||||||
|
self.refreshEnvironmentStatus()
|
||||||
|
if active {
|
||||||
|
self.startIfNeeded()
|
||||||
|
} else {
|
||||||
|
self.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureLaunchAgentEnabledIfNeeded() async {
|
||||||
|
guard !CommandResolver.connectionModeIsRemote() else { return }
|
||||||
|
if GatewayLaunchAgentManager.isLaunchAgentWriteDisabled() {
|
||||||
|
self.appendLog("[gateway] launchd auto-enable skipped (attach-only)\n")
|
||||||
|
self.logger.info("gateway launchd auto-enable skipped (disable marker set)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let enabled = await GatewayLaunchAgentManager.isLoaded()
|
||||||
|
guard !enabled else { return }
|
||||||
|
let bundlePath = Bundle.main.bundleURL.path
|
||||||
|
let port = GatewayEnvironment.gatewayPort()
|
||||||
|
self.appendLog("[gateway] auto-enabling launchd job (\(gatewayLaunchdLabel)) on port \(port)\n")
|
||||||
|
let err = await GatewayLaunchAgentManager.set(enabled: true, bundlePath: bundlePath, port: port)
|
||||||
|
if let err {
|
||||||
|
self.appendLog("[gateway] launchd auto-enable failed: \(err)\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startIfNeeded() {
|
||||||
|
guard self.desiredActive else { return }
|
||||||
|
// Do not spawn in remote mode (the gateway should run on the remote host).
|
||||||
|
guard !CommandResolver.connectionModeIsRemote() else {
|
||||||
|
self.status = .stopped
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Many surfaces can call `setActive(true)` in quick succession (startup, Canvas, health checks).
|
||||||
|
// Avoid spawning multiple concurrent "start" tasks that can thrash launchd and flap the port.
|
||||||
|
switch self.status {
|
||||||
|
case .starting, .running, .attachedExisting:
|
||||||
|
return
|
||||||
|
case .stopped, .failed:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
self.status = .starting
|
||||||
|
self.logger.debug("gateway start requested")
|
||||||
|
|
||||||
|
// First try to latch onto an already-running gateway to avoid spawning a duplicate.
|
||||||
|
Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
if await self.attachExistingGatewayIfAvailable() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await self.enableLaunchdGateway()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
self.desiredActive = false
|
||||||
|
self.existingGatewayDetails = nil
|
||||||
|
self.lastFailureReason = nil
|
||||||
|
self.status = .stopped
|
||||||
|
self.logger.info("gateway stop requested")
|
||||||
|
if CommandResolver.connectionModeIsRemote() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let bundlePath = Bundle.main.bundleURL.path
|
||||||
|
Task {
|
||||||
|
_ = await GatewayLaunchAgentManager.set(
|
||||||
|
enabled: false,
|
||||||
|
bundlePath: bundlePath,
|
||||||
|
port: GatewayEnvironment.gatewayPort())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearLastFailure() {
|
||||||
|
self.lastFailureReason = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshEnvironmentStatus(force: Bool = false) {
|
||||||
|
let now = Date()
|
||||||
|
if !force {
|
||||||
|
if self.environmentRefreshTask != nil { return }
|
||||||
|
if let last = self.lastEnvironmentRefresh,
|
||||||
|
now.timeIntervalSince(last) < self.environmentRefreshMinInterval
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.lastEnvironmentRefresh = now
|
||||||
|
self.environmentRefreshTask = Task { [weak self] in
|
||||||
|
let status = await Task.detached(priority: .utility) {
|
||||||
|
GatewayEnvironment.check()
|
||||||
|
}.value
|
||||||
|
await MainActor.run {
|
||||||
|
guard let self else { return }
|
||||||
|
self.environmentStatus = status
|
||||||
|
self.environmentRefreshTask = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshLog() {
|
||||||
|
guard self.logRefreshTask == nil else { return }
|
||||||
|
let path = GatewayLaunchAgentManager.launchdGatewayLogPath()
|
||||||
|
let limit = self.logLimit
|
||||||
|
self.logRefreshTask = Task { [weak self] in
|
||||||
|
let log = await Task.detached(priority: .utility) {
|
||||||
|
Self.readGatewayLog(path: path, limit: limit)
|
||||||
|
}.value
|
||||||
|
await MainActor.run {
|
||||||
|
guard let self else { return }
|
||||||
|
if !log.isEmpty {
|
||||||
|
self.log = log
|
||||||
|
}
|
||||||
|
self.logRefreshTask = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internals
|
||||||
|
|
||||||
|
/// Attempt to connect to an already-running gateway on the configured port.
|
||||||
|
/// If successful, mark status as attached and skip spawning a new process.
|
||||||
|
private func attachExistingGatewayIfAvailable() async -> Bool {
|
||||||
|
let port = GatewayEnvironment.gatewayPort()
|
||||||
|
let instance = await PortGuardian.shared.describe(port: port)
|
||||||
|
let instanceText = instance.map { self.describe(instance: $0) }
|
||||||
|
let hasListener = instance != nil
|
||||||
|
|
||||||
|
let attemptAttach = {
|
||||||
|
try await self.connection.requestRaw(method: .health, timeoutMs: 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
for attempt in 0..<(hasListener ? 3 : 1) {
|
||||||
|
do {
|
||||||
|
let data = try await attemptAttach()
|
||||||
|
let snap = decodeHealthSnapshot(from: data)
|
||||||
|
let details = self.describe(details: instanceText, port: port, snap: snap)
|
||||||
|
self.existingGatewayDetails = details
|
||||||
|
self.clearLastFailure()
|
||||||
|
self.status = .attachedExisting(details: details)
|
||||||
|
self.appendLog("[gateway] using existing instance: \(details)\n")
|
||||||
|
self.logger.info("gateway using existing instance details=\(details)")
|
||||||
|
self.refreshControlChannelIfNeeded(reason: "attach existing")
|
||||||
|
self.refreshLog()
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
if attempt < 2, hasListener {
|
||||||
|
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasListener {
|
||||||
|
let reason = self.describeAttachFailure(error, port: port, instance: instance)
|
||||||
|
self.existingGatewayDetails = instanceText
|
||||||
|
self.status = .failed(reason)
|
||||||
|
self.lastFailureReason = reason
|
||||||
|
self.appendLog("[gateway] existing listener on port \(port) but attach failed: \(reason)\n")
|
||||||
|
self.logger.warning("gateway attach failed reason=\(reason)")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// No reachable gateway (and no listener) — fall through to spawn.
|
||||||
|
self.existingGatewayDetails = nil
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.existingGatewayDetails = nil
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func describe(details instance: String?, port: Int, snap: HealthSnapshot?) -> String {
|
||||||
|
let instanceText = instance ?? "pid unknown"
|
||||||
|
if let snap {
|
||||||
|
let order = snap.channelOrder ?? Array(snap.channels.keys)
|
||||||
|
let linkId = order.first(where: { snap.channels[$0]?.linked == true })
|
||||||
|
?? order.first(where: { snap.channels[$0]?.linked != nil })
|
||||||
|
guard let linkId else {
|
||||||
|
return "port \(port), health probe succeeded, \(instanceText)"
|
||||||
|
}
|
||||||
|
let linked = snap.channels[linkId]?.linked ?? false
|
||||||
|
let authAge = snap.channels[linkId]?.authAgeMs.flatMap(msToAge) ?? "unknown age"
|
||||||
|
let label =
|
||||||
|
snap.channelLabels?[linkId] ??
|
||||||
|
linkId.capitalized
|
||||||
|
let linkText = linked ? "linked" : "not linked"
|
||||||
|
return "port \(port), \(label) \(linkText), auth \(authAge), \(instanceText)"
|
||||||
|
}
|
||||||
|
return "port \(port), health probe succeeded, \(instanceText)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func describe(instance: PortGuardian.Descriptor) -> String {
|
||||||
|
let path = instance.executablePath ?? "path unknown"
|
||||||
|
return "pid \(instance.pid) \(instance.command) @ \(path)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func describeAttachFailure(_ error: Error, port: Int, instance: PortGuardian.Descriptor?) -> String {
|
||||||
|
let ns = error as NSError
|
||||||
|
let message = ns.localizedDescription.isEmpty ? "unknown error" : ns.localizedDescription
|
||||||
|
let lower = message.lowercased()
|
||||||
|
if self.isGatewayAuthFailure(error) {
|
||||||
|
return """
|
||||||
|
Gateway on port \(port) rejected auth. Set gateway.auth.token (or CLAWDBOT_GATEWAY_TOKEN) \
|
||||||
|
to match the running gateway (or clear it on the gateway) and retry.
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
if lower.contains("protocol mismatch") {
|
||||||
|
return "Gateway on port \(port) is incompatible (protocol mismatch). Update the app/gateway."
|
||||||
|
}
|
||||||
|
if lower.contains("unexpected response") || lower.contains("invalid response") {
|
||||||
|
return "Port \(port) returned non-gateway data; another process is using it."
|
||||||
|
}
|
||||||
|
if let instance {
|
||||||
|
let instanceText = self.describe(instance: instance)
|
||||||
|
return "Gateway listener found on port \(port) (\(instanceText)) but health check failed: \(message)"
|
||||||
|
}
|
||||||
|
return "Gateway listener found on port \(port) but health check failed: \(message)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isGatewayAuthFailure(_ error: Error) -> Bool {
|
||||||
|
if let urlError = error as? URLError, urlError.code == .dataNotAllowed {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
let ns = error as NSError
|
||||||
|
if ns.domain == "Gateway", ns.code == 1008 { return true }
|
||||||
|
let lower = ns.localizedDescription.lowercased()
|
||||||
|
return lower.contains("unauthorized") || lower.contains("auth")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func enableLaunchdGateway() async {
|
||||||
|
self.existingGatewayDetails = nil
|
||||||
|
let resolution = await Task.detached(priority: .utility) {
|
||||||
|
GatewayEnvironment.resolveGatewayCommand()
|
||||||
|
}.value
|
||||||
|
await MainActor.run { self.environmentStatus = resolution.status }
|
||||||
|
guard resolution.command != nil else {
|
||||||
|
await MainActor.run {
|
||||||
|
self.status = .failed(resolution.status.message)
|
||||||
|
}
|
||||||
|
self.logger.error("gateway command resolve failed: \(resolution.status.message)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if GatewayLaunchAgentManager.isLaunchAgentWriteDisabled() {
|
||||||
|
let message = "Launchd disabled; start the Gateway manually or disable attach-only."
|
||||||
|
self.status = .failed(message)
|
||||||
|
self.lastFailureReason = "launchd disabled"
|
||||||
|
self.appendLog("[gateway] launchd disabled; skipping auto-start\n")
|
||||||
|
self.logger.info("gateway launchd enable skipped (disable marker set)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let bundlePath = Bundle.main.bundleURL.path
|
||||||
|
let port = GatewayEnvironment.gatewayPort()
|
||||||
|
self.appendLog("[gateway] enabling launchd job (\(gatewayLaunchdLabel)) on port \(port)\n")
|
||||||
|
self.logger.info("gateway enabling launchd port=\(port)")
|
||||||
|
let err = await GatewayLaunchAgentManager.set(enabled: true, bundlePath: bundlePath, port: port)
|
||||||
|
if let err {
|
||||||
|
self.status = .failed(err)
|
||||||
|
self.lastFailureReason = err
|
||||||
|
self.logger.error("gateway launchd enable failed: \(err)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort: wait for the gateway to accept connections.
|
||||||
|
let deadline = Date().addingTimeInterval(6)
|
||||||
|
while Date() < deadline {
|
||||||
|
if !self.desiredActive { return }
|
||||||
|
do {
|
||||||
|
_ = try await self.connection.requestRaw(method: .health, timeoutMs: 1500)
|
||||||
|
let instance = await PortGuardian.shared.describe(port: port)
|
||||||
|
let details = instance.map { "pid \($0.pid)" }
|
||||||
|
self.clearLastFailure()
|
||||||
|
self.status = .running(details: details)
|
||||||
|
self.logger.info("gateway started details=\(details ?? "ok")")
|
||||||
|
self.refreshControlChannelIfNeeded(reason: "gateway started")
|
||||||
|
self.refreshLog()
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
try? await Task.sleep(nanoseconds: 400_000_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.status = .failed("Gateway did not start in time")
|
||||||
|
self.lastFailureReason = "launchd start timeout"
|
||||||
|
self.logger.warning("gateway start timed out")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func appendLog(_ chunk: String) {
|
||||||
|
self.log.append(chunk)
|
||||||
|
if self.log.count > self.logLimit {
|
||||||
|
self.log = String(self.log.suffix(self.logLimit))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshControlChannelIfNeeded(reason: String) {
|
||||||
|
switch ControlChannel.shared.state {
|
||||||
|
case .connected, .connecting:
|
||||||
|
return
|
||||||
|
case .disconnected, .degraded:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
self.appendLog("[gateway] refreshing control channel (\(reason))\n")
|
||||||
|
self.logger.debug("gateway control channel refresh reason=\(reason)")
|
||||||
|
Task { await ControlChannel.shared.configure() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForGatewayReady(timeout: TimeInterval = 6) async -> Bool {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
if !self.desiredActive { return false }
|
||||||
|
do {
|
||||||
|
_ = try await self.connection.requestRaw(method: .health, timeoutMs: 1500)
|
||||||
|
self.clearLastFailure()
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.appendLog("[gateway] readiness wait timed out\n")
|
||||||
|
self.logger.warning("gateway readiness wait timed out")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearLog() {
|
||||||
|
self.log = ""
|
||||||
|
try? FileManager().removeItem(atPath: GatewayLaunchAgentManager.launchdGatewayLogPath())
|
||||||
|
self.logger.debug("gateway log cleared")
|
||||||
|
}
|
||||||
|
|
||||||
|
func setProjectRoot(path: String) {
|
||||||
|
CommandResolver.setProjectRoot(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func projectRootPath() -> String {
|
||||||
|
CommandResolver.projectRootPath()
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func readGatewayLog(path: String, limit: Int) -> String {
|
||||||
|
guard FileManager().fileExists(atPath: path) else { return "" }
|
||||||
|
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return "" }
|
||||||
|
let text = String(data: data, encoding: .utf8) ?? ""
|
||||||
|
if text.count <= limit { return text }
|
||||||
|
return String(text.suffix(limit))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
extension GatewayProcessManager {
|
||||||
|
func setTestingConnection(_ connection: GatewayConnection?) {
|
||||||
|
self.testingConnection = connection
|
||||||
|
}
|
||||||
|
|
||||||
|
func setTestingDesiredActive(_ active: Bool) {
|
||||||
|
self.desiredActive = active
|
||||||
|
}
|
||||||
|
|
||||||
|
func setTestingLastFailureReason(_ reason: String?) {
|
||||||
|
self.lastFailureReason = reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
301
apps/macos/Sources/Moltbot/HealthStore.swift
Normal file
301
apps/macos/Sources/Moltbot/HealthStore.swift
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
import Observation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct HealthSnapshot: Codable, Sendable {
|
||||||
|
struct ChannelSummary: Codable, Sendable {
|
||||||
|
struct Probe: Codable, Sendable {
|
||||||
|
struct Bot: Codable, Sendable {
|
||||||
|
let username: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Webhook: Codable, Sendable {
|
||||||
|
let url: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
let ok: Bool?
|
||||||
|
let status: Int?
|
||||||
|
let error: String?
|
||||||
|
let elapsedMs: Double?
|
||||||
|
let bot: Bot?
|
||||||
|
let webhook: Webhook?
|
||||||
|
}
|
||||||
|
|
||||||
|
let configured: Bool?
|
||||||
|
let linked: Bool?
|
||||||
|
let authAgeMs: Double?
|
||||||
|
let probe: Probe?
|
||||||
|
let lastProbeAt: Double?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SessionInfo: Codable, Sendable {
|
||||||
|
let key: String
|
||||||
|
let updatedAt: Double?
|
||||||
|
let age: Double?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Sessions: Codable, Sendable {
|
||||||
|
let path: String
|
||||||
|
let count: Int
|
||||||
|
let recent: [SessionInfo]
|
||||||
|
}
|
||||||
|
|
||||||
|
let ok: Bool?
|
||||||
|
let ts: Double
|
||||||
|
let durationMs: Double
|
||||||
|
let channels: [String: ChannelSummary]
|
||||||
|
let channelOrder: [String]?
|
||||||
|
let channelLabels: [String: String]?
|
||||||
|
let heartbeatSeconds: Int?
|
||||||
|
let sessions: Sessions
|
||||||
|
}
|
||||||
|
|
||||||
|
enum HealthState: Equatable {
|
||||||
|
case unknown
|
||||||
|
case ok
|
||||||
|
case linkingNeeded
|
||||||
|
case degraded(String)
|
||||||
|
|
||||||
|
var tint: Color {
|
||||||
|
switch self {
|
||||||
|
case .ok: .green
|
||||||
|
case .linkingNeeded: .red
|
||||||
|
case .degraded: .orange
|
||||||
|
case .unknown: .secondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class HealthStore {
|
||||||
|
static let shared = HealthStore()
|
||||||
|
|
||||||
|
private static let logger = Logger(subsystem: "bot.molt", category: "health")
|
||||||
|
|
||||||
|
private(set) var snapshot: HealthSnapshot?
|
||||||
|
private(set) var lastSuccess: Date?
|
||||||
|
private(set) var lastError: String?
|
||||||
|
private(set) var isRefreshing = false
|
||||||
|
|
||||||
|
private var loopTask: Task<Void, Never>?
|
||||||
|
private let refreshInterval: TimeInterval = 60
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
// Avoid background health polling in SwiftUI previews and tests.
|
||||||
|
if !ProcessInfo.processInfo.isPreview, !ProcessInfo.processInfo.isRunningTests {
|
||||||
|
self.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test-only escape hatch: the HealthStore is a process-wide singleton but
|
||||||
|
// state derivation is pure from `snapshot` + `lastError`.
|
||||||
|
func __setSnapshotForTest(_ snapshot: HealthSnapshot?, lastError: String? = nil) {
|
||||||
|
self.snapshot = snapshot
|
||||||
|
self.lastError = lastError
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard self.loopTask == nil else { return }
|
||||||
|
self.loopTask = Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
while !Task.isCancelled {
|
||||||
|
await self.refresh()
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(self.refreshInterval * 1_000_000_000))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
self.loopTask?.cancel()
|
||||||
|
self.loopTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh(onDemand: Bool = false) async {
|
||||||
|
guard !self.isRefreshing else { return }
|
||||||
|
self.isRefreshing = true
|
||||||
|
defer { self.isRefreshing = false }
|
||||||
|
let previousError = self.lastError
|
||||||
|
|
||||||
|
do {
|
||||||
|
let data = try await ControlChannel.shared.health(timeout: 15)
|
||||||
|
if let decoded = decodeHealthSnapshot(from: data) {
|
||||||
|
self.snapshot = decoded
|
||||||
|
self.lastSuccess = Date()
|
||||||
|
self.lastError = nil
|
||||||
|
if previousError != nil {
|
||||||
|
Self.logger.info("health refresh recovered")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.lastError = "health output not JSON"
|
||||||
|
if onDemand { self.snapshot = nil }
|
||||||
|
if previousError != self.lastError {
|
||||||
|
Self.logger.warning("health refresh failed: output not JSON")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
let desc = error.localizedDescription
|
||||||
|
self.lastError = desc
|
||||||
|
if onDemand { self.snapshot = nil }
|
||||||
|
if previousError != desc {
|
||||||
|
Self.logger.error("health refresh failed \(desc, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isChannelHealthy(_ summary: HealthSnapshot.ChannelSummary) -> Bool {
|
||||||
|
guard summary.configured == true else { return false }
|
||||||
|
// If probe is missing, treat it as "configured but unknown health" (not a hard fail).
|
||||||
|
return summary.probe?.ok ?? true
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func describeProbeFailure(_ probe: HealthSnapshot.ChannelSummary.Probe) -> String {
|
||||||
|
let elapsed = probe.elapsedMs.map { "\(Int($0))ms" }
|
||||||
|
if let error = probe.error, error.lowercased().contains("timeout") || probe.status == nil {
|
||||||
|
if let elapsed { return "Health check timed out (\(elapsed))" }
|
||||||
|
return "Health check timed out"
|
||||||
|
}
|
||||||
|
let code = probe.status.map { "status \($0)" } ?? "status unknown"
|
||||||
|
let reason = probe.error?.isEmpty == false ? probe.error! : "health probe failed"
|
||||||
|
if let elapsed { return "\(reason) (\(code), \(elapsed))" }
|
||||||
|
return "\(reason) (\(code))"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveLinkChannel(
|
||||||
|
_ snap: HealthSnapshot) -> (id: String, summary: HealthSnapshot.ChannelSummary)?
|
||||||
|
{
|
||||||
|
let order = snap.channelOrder ?? Array(snap.channels.keys)
|
||||||
|
for id in order {
|
||||||
|
if let summary = snap.channels[id], summary.linked == true {
|
||||||
|
return (id: id, summary: summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id in order {
|
||||||
|
if let summary = snap.channels[id], summary.linked != nil {
|
||||||
|
return (id: id, summary: summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveFallbackChannel(
|
||||||
|
_ snap: HealthSnapshot,
|
||||||
|
excluding id: String?) -> (id: String, summary: HealthSnapshot.ChannelSummary)?
|
||||||
|
{
|
||||||
|
let order = snap.channelOrder ?? Array(snap.channels.keys)
|
||||||
|
for channelId in order {
|
||||||
|
if channelId == id { continue }
|
||||||
|
guard let summary = snap.channels[channelId] else { continue }
|
||||||
|
if Self.isChannelHealthy(summary) {
|
||||||
|
return (id: channelId, summary: summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var state: HealthState {
|
||||||
|
if let error = self.lastError, !error.isEmpty {
|
||||||
|
return .degraded(error)
|
||||||
|
}
|
||||||
|
guard let snap = self.snapshot else { return .unknown }
|
||||||
|
guard let link = self.resolveLinkChannel(snap) else { return .unknown }
|
||||||
|
if link.summary.linked != true {
|
||||||
|
// Linking is optional if any other channel is healthy; don't paint the whole app red.
|
||||||
|
let fallback = self.resolveFallbackChannel(snap, excluding: link.id)
|
||||||
|
return fallback != nil ? .degraded("Not linked") : .linkingNeeded
|
||||||
|
}
|
||||||
|
// A channel can be "linked" but still unhealthy (failed probe / cannot connect).
|
||||||
|
if let probe = link.summary.probe, probe.ok == false {
|
||||||
|
return .degraded(Self.describeProbeFailure(probe))
|
||||||
|
}
|
||||||
|
return .ok
|
||||||
|
}
|
||||||
|
|
||||||
|
var summaryLine: String {
|
||||||
|
if self.isRefreshing { return "Health check running…" }
|
||||||
|
if let error = self.lastError { return "Health check failed: \(error)" }
|
||||||
|
guard let snap = self.snapshot else { return "Health check pending" }
|
||||||
|
guard let link = self.resolveLinkChannel(snap) else { return "Health check pending" }
|
||||||
|
if link.summary.linked != true {
|
||||||
|
if let fallback = self.resolveFallbackChannel(snap, excluding: link.id) {
|
||||||
|
let fallbackLabel = snap.channelLabels?[fallback.id] ?? fallback.id.capitalized
|
||||||
|
let fallbackState = (fallback.summary.probe?.ok ?? true) ? "ok" : "degraded"
|
||||||
|
return "\(fallbackLabel) \(fallbackState) · Not linked — run moltbot login"
|
||||||
|
}
|
||||||
|
return "Not linked — run moltbot login"
|
||||||
|
}
|
||||||
|
let auth = link.summary.authAgeMs.map { msToAge($0) } ?? "unknown"
|
||||||
|
if let probe = link.summary.probe, probe.ok == false {
|
||||||
|
let status = probe.status.map(String.init) ?? "?"
|
||||||
|
let suffix = probe.status == nil ? "probe degraded" : "probe degraded · status \(status)"
|
||||||
|
return "linked · auth \(auth) · \(suffix)"
|
||||||
|
}
|
||||||
|
return "linked · auth \(auth)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Short, human-friendly detail for the last failure, used in the UI.
|
||||||
|
var detailLine: String? {
|
||||||
|
if let error = self.lastError, !error.isEmpty {
|
||||||
|
let lower = error.lowercased()
|
||||||
|
if lower.contains("connection refused") {
|
||||||
|
let port = GatewayEnvironment.gatewayPort()
|
||||||
|
let host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)"
|
||||||
|
return "The gateway control port (\(host)) isn’t listening — restart Moltbot to bring it back."
|
||||||
|
}
|
||||||
|
if lower.contains("timeout") {
|
||||||
|
return "Timed out waiting for the control server; the gateway may be crashed or still starting."
|
||||||
|
}
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func describeFailure(from snap: HealthSnapshot, fallback: String?) -> String {
|
||||||
|
if let link = self.resolveLinkChannel(snap), link.summary.linked != true {
|
||||||
|
return "Not linked — run moltbot login"
|
||||||
|
}
|
||||||
|
if let link = self.resolveLinkChannel(snap), let probe = link.summary.probe, probe.ok == false {
|
||||||
|
return Self.describeProbeFailure(probe)
|
||||||
|
}
|
||||||
|
if let fallback, !fallback.isEmpty {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return "health probe failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
var degradedSummary: String? {
|
||||||
|
guard case let .degraded(reason) = self.state else { return nil }
|
||||||
|
if reason == "[object Object]" || reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||||
|
let snap = self.snapshot
|
||||||
|
{
|
||||||
|
return self.describeFailure(from: snap, fallback: reason)
|
||||||
|
}
|
||||||
|
return reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func msToAge(_ ms: Double) -> String {
|
||||||
|
let minutes = Int(round(ms / 60000))
|
||||||
|
if minutes < 1 { return "just now" }
|
||||||
|
if minutes < 60 { return "\(minutes)m" }
|
||||||
|
let hours = Int(round(Double(minutes) / 60))
|
||||||
|
if hours < 48 { return "\(hours)h" }
|
||||||
|
let days = Int(round(Double(hours) / 24))
|
||||||
|
return "\(days)d"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode a health snapshot, tolerating stray log lines before/after the JSON blob.
|
||||||
|
func decodeHealthSnapshot(from data: Data) -> HealthSnapshot? {
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
if let snap = try? decoder.decode(HealthSnapshot.self, from: data) {
|
||||||
|
return snap
|
||||||
|
}
|
||||||
|
guard let text = String(data: data, encoding: .utf8) else { return nil }
|
||||||
|
guard let firstBrace = text.firstIndex(of: "{"), let lastBrace = text.lastIndex(of: "}") else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let slice = text[firstBrace...lastBrace]
|
||||||
|
let cleaned = Data(slice.utf8)
|
||||||
|
return try? decoder.decode(HealthSnapshot.self, from: cleaned)
|
||||||
|
}
|
||||||
394
apps/macos/Sources/Moltbot/InstancesStore.swift
Normal file
394
apps/macos/Sources/Moltbot/InstancesStore.swift
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
import MoltbotKit
|
||||||
|
import MoltbotProtocol
|
||||||
|
import Cocoa
|
||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
struct InstanceInfo: Identifiable, Codable {
|
||||||
|
let id: String
|
||||||
|
let host: String?
|
||||||
|
let ip: String?
|
||||||
|
let version: String?
|
||||||
|
let platform: String?
|
||||||
|
let deviceFamily: String?
|
||||||
|
let modelIdentifier: String?
|
||||||
|
let lastInputSeconds: Int?
|
||||||
|
let mode: String?
|
||||||
|
let reason: String?
|
||||||
|
let text: String
|
||||||
|
let ts: Double
|
||||||
|
|
||||||
|
var ageDescription: String {
|
||||||
|
let date = Date(timeIntervalSince1970: ts / 1000)
|
||||||
|
return age(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastInputDescription: String {
|
||||||
|
guard let secs = lastInputSeconds else { return "unknown" }
|
||||||
|
return "\(secs)s ago"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class InstancesStore {
|
||||||
|
static let shared = InstancesStore()
|
||||||
|
let isPreview: Bool
|
||||||
|
|
||||||
|
var instances: [InstanceInfo] = []
|
||||||
|
var lastError: String?
|
||||||
|
var statusMessage: String?
|
||||||
|
var isLoading = false
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "bot.molt", category: "instances")
|
||||||
|
private var task: Task<Void, Never>?
|
||||||
|
private let interval: TimeInterval = 30
|
||||||
|
private var eventTask: Task<Void, Never>?
|
||||||
|
private var startCount = 0
|
||||||
|
private var lastPresenceById: [String: InstanceInfo] = [:]
|
||||||
|
private var lastLoginNotifiedAtMs: [String: Double] = [:]
|
||||||
|
|
||||||
|
private struct PresenceEventPayload: Codable {
|
||||||
|
let presence: [PresenceEntry]
|
||||||
|
}
|
||||||
|
|
||||||
|
init(isPreview: Bool = false) {
|
||||||
|
self.isPreview = isPreview
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard !self.isPreview else { return }
|
||||||
|
self.startCount += 1
|
||||||
|
guard self.startCount == 1 else { return }
|
||||||
|
guard self.task == nil else { return }
|
||||||
|
self.startGatewaySubscription()
|
||||||
|
self.task = Task.detached { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
await self.refresh()
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000))
|
||||||
|
await self.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
guard !self.isPreview else { return }
|
||||||
|
guard self.startCount > 0 else { return }
|
||||||
|
self.startCount -= 1
|
||||||
|
guard self.startCount == 0 else { return }
|
||||||
|
self.task?.cancel()
|
||||||
|
self.task = nil
|
||||||
|
self.eventTask?.cancel()
|
||||||
|
self.eventTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startGatewaySubscription() {
|
||||||
|
self.eventTask?.cancel()
|
||||||
|
self.eventTask = Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
let stream = await GatewayConnection.shared.subscribe()
|
||||||
|
for await push in stream {
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.handle(push: push)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handle(push: GatewayPush) {
|
||||||
|
switch push {
|
||||||
|
case let .event(evt) where evt.event == "presence":
|
||||||
|
if let payload = evt.payload {
|
||||||
|
self.handlePresenceEventPayload(payload)
|
||||||
|
}
|
||||||
|
case .seqGap:
|
||||||
|
Task { await self.refresh() }
|
||||||
|
case let .snapshot(hello):
|
||||||
|
self.applyPresence(hello.snapshot.presence)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh() async {
|
||||||
|
if self.isLoading { return }
|
||||||
|
self.statusMessage = nil
|
||||||
|
self.isLoading = true
|
||||||
|
defer { self.isLoading = false }
|
||||||
|
do {
|
||||||
|
PresenceReporter.shared.sendImmediate(reason: "instances-refresh")
|
||||||
|
let data = try await ControlChannel.shared.request(method: "system-presence")
|
||||||
|
self.lastPayload = data
|
||||||
|
if data.isEmpty {
|
||||||
|
self.logger.error("instances fetch returned empty payload")
|
||||||
|
self.instances = [self.localFallbackInstance(reason: "no presence payload")]
|
||||||
|
self.lastError = nil
|
||||||
|
self.statusMessage = "No presence payload from gateway; showing local fallback + health probe."
|
||||||
|
await self.probeHealthIfNeeded(reason: "no payload")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let decoded = try JSONDecoder().decode([PresenceEntry].self, from: data)
|
||||||
|
let withIDs = self.normalizePresence(decoded)
|
||||||
|
if withIDs.isEmpty {
|
||||||
|
self.instances = [self.localFallbackInstance(reason: "no presence entries")]
|
||||||
|
self.lastError = nil
|
||||||
|
self.statusMessage = "Presence list was empty; showing local fallback + health probe."
|
||||||
|
await self.probeHealthIfNeeded(reason: "empty list")
|
||||||
|
} else {
|
||||||
|
self.instances = withIDs
|
||||||
|
self.lastError = nil
|
||||||
|
self.statusMessage = nil
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
self.logger.error(
|
||||||
|
"""
|
||||||
|
instances fetch failed: \(error.localizedDescription, privacy: .public) \
|
||||||
|
len=\(self.lastPayload?.count ?? 0, privacy: .public) \
|
||||||
|
utf8=\(self.snippet(self.lastPayload), privacy: .public)
|
||||||
|
""")
|
||||||
|
self.instances = [self.localFallbackInstance(reason: "presence decode failed")]
|
||||||
|
self.lastError = nil
|
||||||
|
self.statusMessage = "Presence data invalid; showing local fallback + health probe."
|
||||||
|
await self.probeHealthIfNeeded(reason: "decode failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func localFallbackInstance(reason: String) -> InstanceInfo {
|
||||||
|
let host = Host.current().localizedName ?? "this-mac"
|
||||||
|
let ip = Self.primaryIPv4Address()
|
||||||
|
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
|
||||||
|
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
|
||||||
|
let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
|
||||||
|
let text = "Local node: \(host)\(ip.map { " (\($0))" } ?? "") · app \(version ?? "dev")"
|
||||||
|
let ts = Date().timeIntervalSince1970 * 1000
|
||||||
|
return InstanceInfo(
|
||||||
|
id: "local-\(host)",
|
||||||
|
host: host,
|
||||||
|
ip: ip,
|
||||||
|
version: version,
|
||||||
|
platform: platform,
|
||||||
|
deviceFamily: "Mac",
|
||||||
|
modelIdentifier: InstanceIdentity.modelIdentifier,
|
||||||
|
lastInputSeconds: Self.lastInputSeconds(),
|
||||||
|
mode: "local",
|
||||||
|
reason: reason,
|
||||||
|
text: text,
|
||||||
|
ts: ts)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func lastInputSeconds() -> Int? {
|
||||||
|
let anyEvent = CGEventType(rawValue: UInt32.max) ?? .null
|
||||||
|
let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyEvent)
|
||||||
|
if seconds.isNaN || seconds.isInfinite || seconds < 0 { return nil }
|
||||||
|
return Int(seconds.rounded())
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func primaryIPv4Address() -> String? {
|
||||||
|
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||||
|
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||||
|
defer { freeifaddrs(addrList) }
|
||||||
|
|
||||||
|
var fallback: String?
|
||||||
|
var en0: String?
|
||||||
|
|
||||||
|
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
|
||||||
|
let flags = Int32(ptr.pointee.ifa_flags)
|
||||||
|
let isUp = (flags & IFF_UP) != 0
|
||||||
|
let isLoopback = (flags & IFF_LOOPBACK) != 0
|
||||||
|
let name = String(cString: ptr.pointee.ifa_name)
|
||||||
|
let family = ptr.pointee.ifa_addr.pointee.sa_family
|
||||||
|
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
|
||||||
|
|
||||||
|
var addr = ptr.pointee.ifa_addr.pointee
|
||||||
|
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||||
|
let result = getnameinfo(
|
||||||
|
&addr,
|
||||||
|
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
|
||||||
|
&buffer,
|
||||||
|
socklen_t(buffer.count),
|
||||||
|
nil,
|
||||||
|
0,
|
||||||
|
NI_NUMERICHOST)
|
||||||
|
guard result == 0 else { continue }
|
||||||
|
let len = buffer.prefix { $0 != 0 }
|
||||||
|
let bytes = len.map { UInt8(bitPattern: $0) }
|
||||||
|
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
|
||||||
|
|
||||||
|
if name == "en0" { en0 = ip; break }
|
||||||
|
if fallback == nil { fallback = ip }
|
||||||
|
}
|
||||||
|
|
||||||
|
return en0 ?? fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
/// Keep the last raw payload for logging.
|
||||||
|
private var lastPayload: Data?
|
||||||
|
|
||||||
|
private func snippet(_ data: Data?, limit: Int = 256) -> String {
|
||||||
|
guard let data else { return "<none>" }
|
||||||
|
if data.isEmpty { return "<empty>" }
|
||||||
|
let prefix = data.prefix(limit)
|
||||||
|
if let asString = String(data: prefix, encoding: .utf8) {
|
||||||
|
return asString.replacingOccurrences(of: "\n", with: " ")
|
||||||
|
}
|
||||||
|
return "<\(data.count) bytes non-utf8>"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func probeHealthIfNeeded(reason: String? = nil) async {
|
||||||
|
do {
|
||||||
|
let data = try await ControlChannel.shared.health(timeout: 8)
|
||||||
|
guard let snap = decodeHealthSnapshot(from: data) else { return }
|
||||||
|
let linkId = snap.channelOrder?.first(where: {
|
||||||
|
if let summary = snap.channels[$0] { return summary.linked != nil }
|
||||||
|
return false
|
||||||
|
}) ?? snap.channels.keys.first(where: {
|
||||||
|
if let summary = snap.channels[$0] { return summary.linked != nil }
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
let linked = linkId.flatMap { snap.channels[$0]?.linked } ?? false
|
||||||
|
let linkLabel =
|
||||||
|
linkId.flatMap { snap.channelLabels?[$0] } ??
|
||||||
|
linkId?.capitalized ??
|
||||||
|
"channel"
|
||||||
|
let entry = InstanceInfo(
|
||||||
|
id: "health-\(snap.ts)",
|
||||||
|
host: "gateway (health)",
|
||||||
|
ip: nil,
|
||||||
|
version: nil,
|
||||||
|
platform: nil,
|
||||||
|
deviceFamily: nil,
|
||||||
|
modelIdentifier: nil,
|
||||||
|
lastInputSeconds: nil,
|
||||||
|
mode: "health",
|
||||||
|
reason: "health probe",
|
||||||
|
text: "Health ok · \(linkLabel) linked=\(linked)",
|
||||||
|
ts: snap.ts)
|
||||||
|
if !self.instances.contains(where: { $0.id == entry.id }) {
|
||||||
|
self.instances.insert(entry, at: 0)
|
||||||
|
}
|
||||||
|
self.lastError = nil
|
||||||
|
self.statusMessage =
|
||||||
|
"Presence unavailable (\(reason ?? "refresh")); showing health probe + local fallback."
|
||||||
|
} catch {
|
||||||
|
self.logger.error("instances health probe failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
if let reason {
|
||||||
|
self.statusMessage =
|
||||||
|
"Presence unavailable (\(reason)), health probe failed: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decodeAndApplyPresenceData(_ data: Data) {
|
||||||
|
do {
|
||||||
|
let decoded = try JSONDecoder().decode([PresenceEntry].self, from: data)
|
||||||
|
self.applyPresence(decoded)
|
||||||
|
} catch {
|
||||||
|
self.logger.error("presence decode from event failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
self.lastError = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handlePresenceEventPayload(_ payload: MoltbotProtocol.AnyCodable) {
|
||||||
|
do {
|
||||||
|
let wrapper = try GatewayPayloadDecoding.decode(payload, as: PresenceEventPayload.self)
|
||||||
|
self.applyPresence(wrapper.presence)
|
||||||
|
} catch {
|
||||||
|
self.logger.error("presence event decode failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
self.lastError = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func normalizePresence(_ entries: [PresenceEntry]) -> [InstanceInfo] {
|
||||||
|
entries.map { entry -> InstanceInfo in
|
||||||
|
let key = entry.instanceid ?? entry.host ?? entry.ip ?? entry.text ?? "entry-\(entry.ts)"
|
||||||
|
return InstanceInfo(
|
||||||
|
id: key,
|
||||||
|
host: entry.host,
|
||||||
|
ip: entry.ip,
|
||||||
|
version: entry.version,
|
||||||
|
platform: entry.platform,
|
||||||
|
deviceFamily: entry.devicefamily,
|
||||||
|
modelIdentifier: entry.modelidentifier,
|
||||||
|
lastInputSeconds: entry.lastinputseconds,
|
||||||
|
mode: entry.mode,
|
||||||
|
reason: entry.reason,
|
||||||
|
text: entry.text ?? "Unnamed node",
|
||||||
|
ts: Double(entry.ts))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyPresence(_ entries: [PresenceEntry]) {
|
||||||
|
let withIDs = self.normalizePresence(entries)
|
||||||
|
self.notifyOnNodeLogin(withIDs)
|
||||||
|
self.lastPresenceById = Dictionary(uniqueKeysWithValues: withIDs.map { ($0.id, $0) })
|
||||||
|
self.instances = withIDs
|
||||||
|
self.statusMessage = nil
|
||||||
|
self.lastError = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func notifyOnNodeLogin(_ instances: [InstanceInfo]) {
|
||||||
|
for inst in instances {
|
||||||
|
guard let reason = inst.reason?.trimmingCharacters(in: .whitespacesAndNewlines) else { continue }
|
||||||
|
guard reason == "node-connected" else { continue }
|
||||||
|
if let mode = inst.mode?.lowercased(), mode == "local" { continue }
|
||||||
|
|
||||||
|
let previous = self.lastPresenceById[inst.id]
|
||||||
|
if previous?.reason == "node-connected", previous?.ts == inst.ts { continue }
|
||||||
|
|
||||||
|
let lastNotified = self.lastLoginNotifiedAtMs[inst.id] ?? 0
|
||||||
|
if inst.ts <= lastNotified { continue }
|
||||||
|
self.lastLoginNotifiedAtMs[inst.id] = inst.ts
|
||||||
|
|
||||||
|
let name = inst.host?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let device = name?.isEmpty == false ? name! : inst.id
|
||||||
|
Task { @MainActor in
|
||||||
|
_ = await NotificationManager().send(
|
||||||
|
title: "Node connected",
|
||||||
|
body: device,
|
||||||
|
sound: nil,
|
||||||
|
priority: .active)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension InstancesStore {
|
||||||
|
static func preview(instances: [InstanceInfo] = [
|
||||||
|
InstanceInfo(
|
||||||
|
id: "local",
|
||||||
|
host: "steipete-mac",
|
||||||
|
ip: "10.0.0.12",
|
||||||
|
version: "1.2.3",
|
||||||
|
platform: "macos 26.2.0",
|
||||||
|
deviceFamily: "Mac",
|
||||||
|
modelIdentifier: "Mac16,6",
|
||||||
|
lastInputSeconds: 12,
|
||||||
|
mode: "local",
|
||||||
|
reason: "preview",
|
||||||
|
text: "Local node: steipete-mac (10.0.0.12) · app 1.2.3",
|
||||||
|
ts: Date().timeIntervalSince1970 * 1000),
|
||||||
|
InstanceInfo(
|
||||||
|
id: "gateway",
|
||||||
|
host: "gateway",
|
||||||
|
ip: "100.64.0.2",
|
||||||
|
version: "1.2.3",
|
||||||
|
platform: "linux 6.6.0",
|
||||||
|
deviceFamily: "Linux",
|
||||||
|
modelIdentifier: "x86_64",
|
||||||
|
lastInputSeconds: 45,
|
||||||
|
mode: "remote",
|
||||||
|
reason: "preview",
|
||||||
|
text: "Gateway node · tunnel ok",
|
||||||
|
ts: Date().timeIntervalSince1970 * 1000 - 45000),
|
||||||
|
]) -> InstancesStore {
|
||||||
|
let store = InstancesStore(isPreview: true)
|
||||||
|
store.instances = instances
|
||||||
|
store.statusMessage = "Preview data"
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
}
|
||||||
95
apps/macos/Sources/Moltbot/LaunchAgentManager.swift
Normal file
95
apps/macos/Sources/Moltbot/LaunchAgentManager.swift
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum LaunchAgentManager {
|
||||||
|
private static let legacyLaunchdLabels = [
|
||||||
|
"com.steipete.clawdbot",
|
||||||
|
"com.clawdbot.mac",
|
||||||
|
]
|
||||||
|
private static var plistURL: URL {
|
||||||
|
FileManager().homeDirectoryForCurrentUser
|
||||||
|
.appendingPathComponent("Library/LaunchAgents/bot.molt.mac.plist")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static var legacyPlistURLs: [URL] {
|
||||||
|
self.legacyLaunchdLabels.map { label in
|
||||||
|
FileManager().homeDirectoryForCurrentUser
|
||||||
|
.appendingPathComponent("Library/LaunchAgents/\(label).plist")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func status() async -> Bool {
|
||||||
|
guard FileManager().fileExists(atPath: self.plistURL.path) else { return false }
|
||||||
|
let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(launchdLabel)"])
|
||||||
|
return result == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
static func set(enabled: Bool, bundlePath: String) async {
|
||||||
|
if enabled {
|
||||||
|
for legacyLabel in self.legacyLaunchdLabels {
|
||||||
|
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(legacyLabel)"])
|
||||||
|
}
|
||||||
|
for legacyURL in self.legacyPlistURLs {
|
||||||
|
try? FileManager().removeItem(at: legacyURL)
|
||||||
|
}
|
||||||
|
self.writePlist(bundlePath: bundlePath)
|
||||||
|
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"])
|
||||||
|
_ = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path])
|
||||||
|
_ = await self.runLaunchctl(["kickstart", "-k", "gui/\(getuid())/\(launchdLabel)"])
|
||||||
|
} else {
|
||||||
|
// Disable autostart going forward but leave the current app running.
|
||||||
|
// bootout would terminate the launchd job immediately (and crash the app if launched via agent).
|
||||||
|
try? FileManager().removeItem(at: self.plistURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func writePlist(bundlePath: String) {
|
||||||
|
let plist = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>bot.molt.mac</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>\(bundlePath)/Contents/MacOS/Moltbot</string>
|
||||||
|
</array>
|
||||||
|
<key>WorkingDirectory</key>
|
||||||
|
<string>\(FileManager().homeDirectoryForCurrentUser.path)</string>
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<true/>
|
||||||
|
<key>EnvironmentVariables</key>
|
||||||
|
<dict>
|
||||||
|
<key>PATH</key>
|
||||||
|
<string>\(CommandResolver.preferredPaths().joined(separator: ":"))</string>
|
||||||
|
</dict>
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>\(LogLocator.launchdLogPath)</string>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>\(LogLocator.launchdLogPath)</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
"""
|
||||||
|
try? plist.write(to: self.plistURL, atomically: true, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
private static func runLaunchctl(_ args: [String]) async -> Int32 {
|
||||||
|
await Task.detached(priority: .utility) { () -> Int32 in
|
||||||
|
let process = Process()
|
||||||
|
process.launchPath = "/bin/launchctl"
|
||||||
|
process.arguments = args
|
||||||
|
let pipe = Pipe()
|
||||||
|
process.standardOutput = pipe
|
||||||
|
process.standardError = pipe
|
||||||
|
do {
|
||||||
|
_ = try process.runAndReadToEnd(from: pipe)
|
||||||
|
return process.terminationStatus
|
||||||
|
} catch {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
}.value
|
||||||
|
}
|
||||||
|
}
|
||||||
230
apps/macos/Sources/Moltbot/Logging/ClawdbotLogging.swift
Normal file
230
apps/macos/Sources/Moltbot/Logging/ClawdbotLogging.swift
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import Foundation
|
||||||
|
@_exported import Logging
|
||||||
|
import os
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
typealias Logger = Logging.Logger
|
||||||
|
|
||||||
|
enum AppLogSettings {
|
||||||
|
static let logLevelKey = appLogLevelKey
|
||||||
|
|
||||||
|
static func logLevel() -> Logger.Level {
|
||||||
|
if let raw = UserDefaults.standard.string(forKey: self.logLevelKey),
|
||||||
|
let level = Logger.Level(rawValue: raw)
|
||||||
|
{
|
||||||
|
return level
|
||||||
|
}
|
||||||
|
return .info
|
||||||
|
}
|
||||||
|
|
||||||
|
static func setLogLevel(_ level: Logger.Level) {
|
||||||
|
UserDefaults.standard.set(level.rawValue, forKey: self.logLevelKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fileLoggingEnabled() -> Bool {
|
||||||
|
UserDefaults.standard.bool(forKey: debugFileLogEnabledKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AppLogLevel: String, CaseIterable, Identifiable {
|
||||||
|
case trace
|
||||||
|
case debug
|
||||||
|
case info
|
||||||
|
case notice
|
||||||
|
case warning
|
||||||
|
case error
|
||||||
|
case critical
|
||||||
|
|
||||||
|
static let `default`: AppLogLevel = .info
|
||||||
|
|
||||||
|
var id: String { self.rawValue }
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .trace: "Trace"
|
||||||
|
case .debug: "Debug"
|
||||||
|
case .info: "Info"
|
||||||
|
case .notice: "Notice"
|
||||||
|
case .warning: "Warning"
|
||||||
|
case .error: "Error"
|
||||||
|
case .critical: "Critical"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MoltbotLogging {
|
||||||
|
private static let labelSeparator = "::"
|
||||||
|
|
||||||
|
private static let didBootstrap: Void = {
|
||||||
|
LoggingSystem.bootstrap { label in
|
||||||
|
let (subsystem, category) = Self.parseLabel(label)
|
||||||
|
let osHandler = MoltbotOSLogHandler(subsystem: subsystem, category: category)
|
||||||
|
let fileHandler = MoltbotFileLogHandler(label: label)
|
||||||
|
return MultiplexLogHandler([osHandler, fileHandler])
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
static func bootstrapIfNeeded() {
|
||||||
|
_ = self.didBootstrap
|
||||||
|
}
|
||||||
|
|
||||||
|
static func makeLabel(subsystem: String, category: String) -> String {
|
||||||
|
"\(subsystem)\(self.labelSeparator)\(category)"
|
||||||
|
}
|
||||||
|
|
||||||
|
static func parseLabel(_ label: String) -> (String, String) {
|
||||||
|
guard let range = label.range(of: labelSeparator) else {
|
||||||
|
return ("bot.molt", label)
|
||||||
|
}
|
||||||
|
let subsystem = String(label[..<range.lowerBound])
|
||||||
|
let category = String(label[range.upperBound...])
|
||||||
|
return (subsystem, category)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Logging.Logger {
|
||||||
|
init(subsystem: String, category: String) {
|
||||||
|
MoltbotLogging.bootstrapIfNeeded()
|
||||||
|
let label = MoltbotLogging.makeLabel(subsystem: subsystem, category: category)
|
||||||
|
self.init(label: label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Logger.Message.StringInterpolation {
|
||||||
|
mutating func appendInterpolation(_ value: some Any, privacy: OSLogPrivacy) {
|
||||||
|
self.appendInterpolation(String(describing: value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MoltbotOSLogHandler: LogHandler {
|
||||||
|
private let osLogger: os.Logger
|
||||||
|
var metadata: Logger.Metadata = [:]
|
||||||
|
|
||||||
|
var logLevel: Logger.Level {
|
||||||
|
get { AppLogSettings.logLevel() }
|
||||||
|
set { AppLogSettings.setLogLevel(newValue) }
|
||||||
|
}
|
||||||
|
|
||||||
|
init(subsystem: String, category: String) {
|
||||||
|
self.osLogger = os.Logger(subsystem: subsystem, category: category)
|
||||||
|
}
|
||||||
|
|
||||||
|
subscript(metadataKey key: String) -> Logger.Metadata.Value? {
|
||||||
|
get { self.metadata[key] }
|
||||||
|
set { self.metadata[key] = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
func log(
|
||||||
|
level: Logger.Level,
|
||||||
|
message: Logger.Message,
|
||||||
|
metadata: Logger.Metadata?,
|
||||||
|
source: String,
|
||||||
|
file: String,
|
||||||
|
function: String,
|
||||||
|
line: UInt)
|
||||||
|
{
|
||||||
|
let merged = Self.mergeMetadata(self.metadata, metadata)
|
||||||
|
let rendered = Self.renderMessage(message, metadata: merged)
|
||||||
|
self.osLogger.log(level: Self.osLogType(for: level), "\(rendered, privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func osLogType(for level: Logger.Level) -> OSLogType {
|
||||||
|
switch level {
|
||||||
|
case .trace, .debug:
|
||||||
|
.debug
|
||||||
|
case .info, .notice:
|
||||||
|
.info
|
||||||
|
case .warning:
|
||||||
|
.default
|
||||||
|
case .error:
|
||||||
|
.error
|
||||||
|
case .critical:
|
||||||
|
.fault
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func mergeMetadata(
|
||||||
|
_ base: Logger.Metadata,
|
||||||
|
_ extra: Logger.Metadata?) -> Logger.Metadata
|
||||||
|
{
|
||||||
|
guard let extra else { return base }
|
||||||
|
return base.merging(extra, uniquingKeysWith: { _, new in new })
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func renderMessage(_ message: Logger.Message, metadata: Logger.Metadata) -> String {
|
||||||
|
guard !metadata.isEmpty else { return message.description }
|
||||||
|
let meta = metadata
|
||||||
|
.sorted(by: { $0.key < $1.key })
|
||||||
|
.map { "\($0.key)=\(self.stringify($0.value))" }
|
||||||
|
.joined(separator: " ")
|
||||||
|
return "\(message.description) [\(meta)]"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func stringify(_ value: Logger.Metadata.Value) -> String {
|
||||||
|
switch value {
|
||||||
|
case let .string(text):
|
||||||
|
text
|
||||||
|
case let .stringConvertible(value):
|
||||||
|
String(describing: value)
|
||||||
|
case let .array(values):
|
||||||
|
"[" + values.map { self.stringify($0) }.joined(separator: ",") + "]"
|
||||||
|
case let .dictionary(entries):
|
||||||
|
"{" + entries.map { "\($0.key)=\(self.stringify($0.value))" }.joined(separator: ",") + "}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MoltbotFileLogHandler: LogHandler {
|
||||||
|
let label: String
|
||||||
|
var metadata: Logger.Metadata = [:]
|
||||||
|
|
||||||
|
var logLevel: Logger.Level {
|
||||||
|
get { AppLogSettings.logLevel() }
|
||||||
|
set { AppLogSettings.setLogLevel(newValue) }
|
||||||
|
}
|
||||||
|
|
||||||
|
subscript(metadataKey key: String) -> Logger.Metadata.Value? {
|
||||||
|
get { self.metadata[key] }
|
||||||
|
set { self.metadata[key] = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
func log(
|
||||||
|
level: Logger.Level,
|
||||||
|
message: Logger.Message,
|
||||||
|
metadata: Logger.Metadata?,
|
||||||
|
source: String,
|
||||||
|
file: String,
|
||||||
|
function: String,
|
||||||
|
line: UInt)
|
||||||
|
{
|
||||||
|
guard AppLogSettings.fileLoggingEnabled() else { return }
|
||||||
|
let (subsystem, category) = MoltbotLogging.parseLabel(self.label)
|
||||||
|
var fields: [String: String] = [
|
||||||
|
"subsystem": subsystem,
|
||||||
|
"category": category,
|
||||||
|
"level": level.rawValue,
|
||||||
|
"source": source,
|
||||||
|
"file": file,
|
||||||
|
"function": function,
|
||||||
|
"line": "\(line)",
|
||||||
|
]
|
||||||
|
let merged = self.metadata.merging(metadata ?? [:], uniquingKeysWith: { _, new in new })
|
||||||
|
for (key, value) in merged {
|
||||||
|
fields["meta.\(key)"] = Self.stringify(value)
|
||||||
|
}
|
||||||
|
DiagnosticsFileLog.shared.log(category: category, event: message.description, fields: fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func stringify(_ value: Logger.Metadata.Value) -> String {
|
||||||
|
switch value {
|
||||||
|
case let .string(text):
|
||||||
|
text
|
||||||
|
case let .stringConvertible(value):
|
||||||
|
String(describing: value)
|
||||||
|
case let .array(values):
|
||||||
|
"[" + values.map { self.stringify($0) }.joined(separator: ",") + "]"
|
||||||
|
case let .dictionary(entries):
|
||||||
|
"{" + entries.map { "\($0.key)=\(self.stringify($0.value))" }.joined(separator: ",") + "}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
471
apps/macos/Sources/Moltbot/MenuBar.swift
Normal file
471
apps/macos/Sources/Moltbot/MenuBar.swift
Normal file
@@ -0,0 +1,471 @@
|
|||||||
|
import AppKit
|
||||||
|
import Darwin
|
||||||
|
import Foundation
|
||||||
|
import MenuBarExtraAccess
|
||||||
|
import Observation
|
||||||
|
import OSLog
|
||||||
|
import Security
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct MoltbotApp: App {
|
||||||
|
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
|
||||||
|
@State private var state: AppState
|
||||||
|
private static let logger = Logger(subsystem: "bot.molt", category: "app")
|
||||||
|
private let gatewayManager = GatewayProcessManager.shared
|
||||||
|
private let controlChannel = ControlChannel.shared
|
||||||
|
private let activityStore = WorkActivityStore.shared
|
||||||
|
private let connectivityCoordinator = GatewayConnectivityCoordinator.shared
|
||||||
|
@State private var statusItem: NSStatusItem?
|
||||||
|
@State private var isMenuPresented = false
|
||||||
|
@State private var isPanelVisible = false
|
||||||
|
@State private var tailscaleService = TailscaleService.shared
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func updateStatusHighlight() {
|
||||||
|
self.statusItem?.button?.highlight(self.isPanelVisible)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func updateHoverHUDSuppression() {
|
||||||
|
HoverHUDController.shared.setSuppressed(self.isMenuPresented || self.isPanelVisible)
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
MoltbotLogging.bootstrapIfNeeded()
|
||||||
|
Self.applyAttachOnlyOverrideIfNeeded()
|
||||||
|
_state = State(initialValue: AppStateStore.shared)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
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,
|
||||||
|
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.installStatusItemMouseHandler(for: item)
|
||||||
|
self.updateHoverHUDSuppression()
|
||||||
|
}
|
||||||
|
.onChange(of: self.state.isPaused) { _, paused in
|
||||||
|
self.applyStatusItemAppearance(paused: paused, sleeping: self.isGatewaySleeping)
|
||||||
|
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) }
|
||||||
|
CLIInstallPrompter.shared.checkAndPromptIfNeeded(reason: "connection-mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
.onChange(of: self.isMenuPresented) { _, _ in
|
||||||
|
self.updateStatusHighlight()
|
||||||
|
self.updateHoverHUDSuppression()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyStatusItemAppearance(paused: Bool, sleeping: Bool) {
|
||||||
|
self.statusItem?.button?.appearsDisabled = paused || sleeping
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func applyAttachOnlyOverrideIfNeeded() {
|
||||||
|
let args = CommandLine.arguments
|
||||||
|
guard args.contains("--attach-only") || args.contains("--no-launchd") else { return }
|
||||||
|
if let error = GatewayLaunchAgentManager.setLaunchAgentWriteDisabled(true) {
|
||||||
|
Self.logger.error("attach-only flag failed: \(error, privacy: .public)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
_ = await GatewayLaunchAgentManager.set(
|
||||||
|
enabled: false,
|
||||||
|
bundlePath: Bundle.main.bundlePath,
|
||||||
|
port: GatewayEnvironment.gatewayPort())
|
||||||
|
}
|
||||||
|
Self.logger.info("attach-only flag enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isGatewaySleeping: Bool {
|
||||||
|
if self.state.isPaused { return false }
|
||||||
|
switch self.state.connectionMode {
|
||||||
|
case .unconfigured:
|
||||||
|
return true
|
||||||
|
case .remote:
|
||||||
|
if case .connected = self.controlChannel.state { return false }
|
||||||
|
return true
|
||||||
|
case .local:
|
||||||
|
switch self.gatewayManager.status {
|
||||||
|
case .running, .starting, .attachedExisting:
|
||||||
|
if case .connected = self.controlChannel.state { return false }
|
||||||
|
return true
|
||||||
|
case .failed, .stopped:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func installStatusItemMouseHandler(for item: NSStatusItem) {
|
||||||
|
guard let button = item.button else { return }
|
||||||
|
if button.subviews.contains(where: { $0 is StatusItemMouseHandlerView }) { return }
|
||||||
|
|
||||||
|
WebChatManager.shared.onPanelVisibilityChanged = { [self] visible in
|
||||||
|
self.isPanelVisible = visible
|
||||||
|
self.updateStatusHighlight()
|
||||||
|
self.updateHoverHUDSuppression()
|
||||||
|
}
|
||||||
|
CanvasManager.shared.onPanelVisibilityChanged = { [self] visible in
|
||||||
|
self.state.canvasPanelVisible = visible
|
||||||
|
}
|
||||||
|
CanvasManager.shared.defaultAnchorProvider = { [self] in self.statusButtonScreenFrame() }
|
||||||
|
|
||||||
|
let handler = StatusItemMouseHandlerView()
|
||||||
|
handler.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
handler.onLeftClick = { [self] in
|
||||||
|
HoverHUDController.shared.dismiss(reason: "statusItemClick")
|
||||||
|
self.toggleWebChatPanel()
|
||||||
|
}
|
||||||
|
handler.onRightClick = { [self] in
|
||||||
|
HoverHUDController.shared.dismiss(reason: "statusItemRightClick")
|
||||||
|
WebChatManager.shared.closePanel()
|
||||||
|
self.isMenuPresented = true
|
||||||
|
self.updateStatusHighlight()
|
||||||
|
}
|
||||||
|
handler.onHoverChanged = { [self] inside in
|
||||||
|
HoverHUDController.shared.statusItemHoverChanged(
|
||||||
|
inside: inside,
|
||||||
|
anchorProvider: { [self] in self.statusButtonScreenFrame() })
|
||||||
|
}
|
||||||
|
|
||||||
|
button.addSubview(handler)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
handler.leadingAnchor.constraint(equalTo: button.leadingAnchor),
|
||||||
|
handler.trailingAnchor.constraint(equalTo: button.trailingAnchor),
|
||||||
|
handler.topAnchor.constraint(equalTo: button.topAnchor),
|
||||||
|
handler.bottomAnchor.constraint(equalTo: button.bottomAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func toggleWebChatPanel() {
|
||||||
|
HoverHUDController.shared.setSuppressed(true)
|
||||||
|
self.isMenuPresented = false
|
||||||
|
Task { @MainActor in
|
||||||
|
let sessionKey = await WebChatManager.shared.preferredSessionKey()
|
||||||
|
WebChatManager.shared.togglePanel(
|
||||||
|
sessionKey: sessionKey,
|
||||||
|
anchorProvider: { [self] in self.statusButtonScreenFrame() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func statusButtonScreenFrame() -> NSRect? {
|
||||||
|
guard let button = self.statusItem?.button, let window = button.window else { return nil }
|
||||||
|
let inWindow = button.convert(button.bounds, to: nil)
|
||||||
|
return window.convertToScreen(inWindow)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var effectiveIconState: IconState {
|
||||||
|
let selection = self.state.iconOverride
|
||||||
|
if selection == .system {
|
||||||
|
return self.activityStore.iconState
|
||||||
|
}
|
||||||
|
let overrideState = selection.toIconState()
|
||||||
|
switch overrideState {
|
||||||
|
case let .workingMain(kind): return .overridden(kind)
|
||||||
|
case let .workingOther(kind): return .overridden(kind)
|
||||||
|
case .idle: return .idle
|
||||||
|
case let .overridden(kind): return .overridden(kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transparent overlay that intercepts clicks without stealing MenuBarExtra ownership.
|
||||||
|
private final class StatusItemMouseHandlerView: NSView {
|
||||||
|
var onLeftClick: (() -> Void)?
|
||||||
|
var onRightClick: (() -> Void)?
|
||||||
|
var onHoverChanged: ((Bool) -> Void)?
|
||||||
|
private var tracking: NSTrackingArea?
|
||||||
|
|
||||||
|
override func mouseDown(with event: NSEvent) {
|
||||||
|
if let onLeftClick {
|
||||||
|
onLeftClick()
|
||||||
|
} else {
|
||||||
|
super.mouseDown(with: event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func rightMouseDown(with event: NSEvent) {
|
||||||
|
self.onRightClick?()
|
||||||
|
// Do not call super; menu will be driven by isMenuPresented binding.
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
self.onHoverChanged?(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func mouseExited(with event: NSEvent) {
|
||||||
|
self.onHoverChanged?(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
private var state: AppState?
|
||||||
|
private let webChatAutoLogger = Logger(subsystem: "bot.molt", category: "Chat")
|
||||||
|
let updaterController: UpdaterProviding = makeUpdaterController()
|
||||||
|
|
||||||
|
func application(_: NSApplication, open urls: [URL]) {
|
||||||
|
Task { @MainActor in
|
||||||
|
for url in urls {
|
||||||
|
await DeepLinkHandler.shared.handle(url: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
|
if self.isDuplicateInstance() {
|
||||||
|
NSApp.terminate(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.state = AppStateStore.shared
|
||||||
|
AppActivationPolicy.apply(showDockIcon: self.state?.showDockIcon ?? false)
|
||||||
|
if let state {
|
||||||
|
Task { await ConnectionModeCoordinator.shared.apply(mode: state.connectionMode, paused: state.isPaused) }
|
||||||
|
}
|
||||||
|
TerminationSignalWatcher.shared.start()
|
||||||
|
NodePairingApprovalPrompter.shared.start()
|
||||||
|
DevicePairingApprovalPrompter.shared.start()
|
||||||
|
ExecApprovalsPromptServer.shared.start()
|
||||||
|
ExecApprovalsGatewayPrompter.shared.start()
|
||||||
|
MacNodeModeCoordinator.shared.start()
|
||||||
|
VoiceWakeGlobalSettingsSync.shared.start()
|
||||||
|
Task { PresenceReporter.shared.start() }
|
||||||
|
Task { await HealthStore.shared.refresh(onDemand: true) }
|
||||||
|
Task { await PortGuardian.shared.sweep(mode: AppStateStore.shared.connectionMode) }
|
||||||
|
Task { await PeekabooBridgeHostCoordinator.shared.setEnabled(AppStateStore.shared.peekabooBridgeEnabled) }
|
||||||
|
self.scheduleFirstRunOnboardingIfNeeded()
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||||
|
CLIInstallPrompter.shared.checkAndPromptIfNeeded(reason: "launch")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Developer/testing helper: auto-open chat when launched with --chat (or legacy --webchat).
|
||||||
|
if CommandLine.arguments.contains("--chat") || CommandLine.arguments.contains("--webchat") {
|
||||||
|
self.webChatAutoLogger.debug("Auto-opening chat via CLI flag")
|
||||||
|
Task { @MainActor in
|
||||||
|
let sessionKey = await WebChatManager.shared.preferredSessionKey()
|
||||||
|
WebChatManager.shared.show(sessionKey: sessionKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationWillTerminate(_ notification: Notification) {
|
||||||
|
PresenceReporter.shared.stop()
|
||||||
|
NodePairingApprovalPrompter.shared.stop()
|
||||||
|
DevicePairingApprovalPrompter.shared.stop()
|
||||||
|
ExecApprovalsPromptServer.shared.stop()
|
||||||
|
ExecApprovalsGatewayPrompter.shared.stop()
|
||||||
|
MacNodeModeCoordinator.shared.stop()
|
||||||
|
TerminationSignalWatcher.shared.stop()
|
||||||
|
VoiceWakeGlobalSettingsSync.shared.stop()
|
||||||
|
WebChatManager.shared.close()
|
||||||
|
WebChatManager.shared.resetTunnels()
|
||||||
|
Task { await RemoteTunnelManager.shared.stopAll() }
|
||||||
|
Task { await GatewayConnection.shared.shutdown() }
|
||||||
|
Task { await PeekabooBridgeHostCoordinator.shared.stop() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func scheduleFirstRunOnboardingIfNeeded() {
|
||||||
|
let seenVersion = UserDefaults.standard.integer(forKey: onboardingVersionKey)
|
||||||
|
let shouldShow = seenVersion < currentOnboardingVersion || !AppStateStore.shared.onboardingSeen
|
||||||
|
guard shouldShow else { return }
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
|
||||||
|
OnboardingController.shared.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isDuplicateInstance() -> Bool {
|
||||||
|
guard let bundleID = Bundle.main.bundleIdentifier else { return false }
|
||||||
|
let running = NSWorkspace.shared.runningApplications.filter { $0.bundleIdentifier == bundleID }
|
||||||
|
return running.count > 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sparkle updater (disabled for unsigned/dev builds)
|
||||||
|
|
||||||
|
@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 {
|
||||||
|
private lazy var controller = SPUStandardUpdaterController(
|
||||||
|
startingUpdater: false,
|
||||||
|
updaterDelegate: self,
|
||||||
|
userDriverDelegate: nil)
|
||||||
|
let updateStatus = UpdateStatus()
|
||||||
|
|
||||||
|
init(savedAutoUpdate: Bool) {
|
||||||
|
super.init()
|
||||||
|
let updater = self.controller.updater
|
||||||
|
updater.automaticallyChecksForUpdates = savedAutoUpdate
|
||||||
|
updater.automaticallyDownloadsUpdates = savedAutoUpdate
|
||||||
|
self.controller.startUpdater()
|
||||||
|
}
|
||||||
|
|
||||||
|
var automaticallyChecksForUpdates: Bool {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SparkleUpdaterController: @preconcurrency SPUUpdaterDelegate {}
|
||||||
|
|
||||||
|
private func isDeveloperIDSigned(bundleURL: URL) -> Bool {
|
||||||
|
var staticCode: SecStaticCode?
|
||||||
|
guard SecStaticCodeCreateWithPath(bundleURL as CFURL, SecCSFlags(), &staticCode) == errSecSuccess,
|
||||||
|
let code = staticCode
|
||||||
|
else { return false }
|
||||||
|
|
||||||
|
var infoCF: CFDictionary?
|
||||||
|
guard SecCodeCopySigningInformation(code, SecCSFlags(rawValue: kSecCSSigningInformation), &infoCF) == errSecSuccess,
|
||||||
|
let info = infoCF as? [String: Any],
|
||||||
|
let certs = info[kSecCodeInfoCertificates as String] as? [SecCertificate],
|
||||||
|
let leaf = certs.first
|
||||||
|
else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if let summary = SecCertificateCopySubjectSummary(leaf) as String? {
|
||||||
|
return summary.hasPrefix("Developer ID Application:")
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func makeUpdaterController() -> UpdaterProviding {
|
||||||
|
let bundleURL = Bundle.main.bundleURL
|
||||||
|
let isBundledApp = bundleURL.pathExtension == "app"
|
||||||
|
guard isBundledApp, isDeveloperIDSigned(bundleURL: bundleURL) else { return DisabledUpdaterController() }
|
||||||
|
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
@MainActor
|
||||||
|
private func makeUpdaterController() -> UpdaterProviding {
|
||||||
|
DisabledUpdaterController()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
97
apps/macos/Sources/Moltbot/MicLevelMonitor.swift
Normal file
97
apps/macos/Sources/Moltbot/MicLevelMonitor.swift
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import OSLog
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
actor MicLevelMonitor {
|
||||||
|
private let logger = Logger(subsystem: "bot.molt", category: "voicewake.meter")
|
||||||
|
private var engine: AVAudioEngine?
|
||||||
|
private var update: (@Sendable (Double) -> Void)?
|
||||||
|
private var running = false
|
||||||
|
private var smoothedLevel: Double = 0
|
||||||
|
|
||||||
|
func start(onLevel: @Sendable @escaping (Double) -> Void) async throws {
|
||||||
|
self.update = onLevel
|
||||||
|
if self.running { return }
|
||||||
|
self.logger.info(
|
||||||
|
"mic level monitor start (\(AudioInputDeviceObserver.defaultInputDeviceSummary(), privacy: .public))")
|
||||||
|
let engine = AVAudioEngine()
|
||||||
|
self.engine = engine
|
||||||
|
let input = engine.inputNode
|
||||||
|
let format = input.outputFormat(forBus: 0)
|
||||||
|
guard format.channelCount > 0, format.sampleRate > 0 else {
|
||||||
|
self.engine = nil
|
||||||
|
throw NSError(
|
||||||
|
domain: "MicLevelMonitor",
|
||||||
|
code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "No audio input available"])
|
||||||
|
}
|
||||||
|
input.removeTap(onBus: 0)
|
||||||
|
input.installTap(onBus: 0, bufferSize: 512, format: format) { [weak self] buffer, _ in
|
||||||
|
guard let self else { return }
|
||||||
|
let level = Self.normalizedLevel(from: buffer)
|
||||||
|
Task { await self.push(level: level) }
|
||||||
|
}
|
||||||
|
engine.prepare()
|
||||||
|
try engine.start()
|
||||||
|
self.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
guard self.running else { return }
|
||||||
|
if let engine {
|
||||||
|
engine.inputNode.removeTap(onBus: 0)
|
||||||
|
engine.stop()
|
||||||
|
}
|
||||||
|
self.engine = nil
|
||||||
|
self.running = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func push(level: Double) {
|
||||||
|
self.smoothedLevel = (self.smoothedLevel * 0.45) + (level * 0.55)
|
||||||
|
guard let update else { return }
|
||||||
|
let value = self.smoothedLevel
|
||||||
|
Task { @MainActor in update(value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizedLevel(from buffer: AVAudioPCMBuffer) -> Double {
|
||||||
|
guard let channel = buffer.floatChannelData?[0] else { return 0 }
|
||||||
|
let frameCount = Int(buffer.frameLength)
|
||||||
|
guard frameCount > 0 else { return 0 }
|
||||||
|
var sum: Float = 0
|
||||||
|
for i in 0..<frameCount {
|
||||||
|
let s = channel[i]
|
||||||
|
sum += s * s
|
||||||
|
}
|
||||||
|
let rms = sqrt(sum / Float(frameCount) + 1e-12)
|
||||||
|
let db = 20 * log10(Double(rms))
|
||||||
|
let normalized = max(0, min(1, (db + 50) / 50))
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MicLevelBar: View {
|
||||||
|
let level: Double
|
||||||
|
let segments: Int = 12
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 3) {
|
||||||
|
ForEach(0..<self.segments, id: \.self) { idx in
|
||||||
|
let fill = self.level * Double(self.segments) > Double(idx)
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(fill ? self.segmentColor(for: idx) : Color.gray.opacity(0.35))
|
||||||
|
.frame(width: 14, height: 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(4)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.stroke(Color.gray.opacity(0.25), lineWidth: 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func segmentColor(for idx: Int) -> Color {
|
||||||
|
let fraction = Double(idx + 1) / Double(self.segments)
|
||||||
|
if fraction < 0.65 { return .green }
|
||||||
|
if fraction < 0.85 { return .yellow }
|
||||||
|
return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
156
apps/macos/Sources/Moltbot/ModelCatalogLoader.swift
Normal file
156
apps/macos/Sources/Moltbot/ModelCatalogLoader.swift
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import Foundation
|
||||||
|
import JavaScriptCore
|
||||||
|
|
||||||
|
enum ModelCatalogLoader {
|
||||||
|
static var defaultPath: String { self.resolveDefaultPath() }
|
||||||
|
private static let logger = Logger(subsystem: "bot.molt", category: "models")
|
||||||
|
private nonisolated static let appSupportDir: URL = {
|
||||||
|
let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||||
|
return base.appendingPathComponent("Moltbot", isDirectory: true)
|
||||||
|
}()
|
||||||
|
|
||||||
|
private static var cachePath: URL {
|
||||||
|
self.appSupportDir.appendingPathComponent("model-catalog/models.generated.js", isDirectory: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func load(from path: String) async throws -> [ModelChoice] {
|
||||||
|
let expanded = (path as NSString).expandingTildeInPath
|
||||||
|
guard let resolved = self.resolvePath(preferred: expanded) else {
|
||||||
|
self.logger.error("model catalog load failed: file not found")
|
||||||
|
throw NSError(
|
||||||
|
domain: "ModelCatalogLoader",
|
||||||
|
code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Model catalog file not found"])
|
||||||
|
}
|
||||||
|
self.logger.debug("model catalog load start file=\(URL(fileURLWithPath: resolved.path).lastPathComponent)")
|
||||||
|
let source = try String(contentsOfFile: resolved.path, encoding: .utf8)
|
||||||
|
let sanitized = self.sanitize(source: source)
|
||||||
|
|
||||||
|
let ctx = JSContext()
|
||||||
|
ctx?.exceptionHandler = { _, exception in
|
||||||
|
if let exception {
|
||||||
|
self.logger.warning("model catalog JS exception: \(exception)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx?.evaluateScript(sanitized)
|
||||||
|
guard let rawModels = ctx?.objectForKeyedSubscript("MODELS")?.toDictionary() as? [String: Any] else {
|
||||||
|
self.logger.error("model catalog parse failed: MODELS missing")
|
||||||
|
throw NSError(
|
||||||
|
domain: "ModelCatalogLoader",
|
||||||
|
code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Failed to parse models.generated.ts"])
|
||||||
|
}
|
||||||
|
|
||||||
|
var choices: [ModelChoice] = []
|
||||||
|
for (provider, value) in rawModels {
|
||||||
|
guard let models = value as? [String: Any] else { continue }
|
||||||
|
for (id, payload) in models {
|
||||||
|
guard let dict = payload as? [String: Any] else { continue }
|
||||||
|
let name = dict["name"] as? String ?? id
|
||||||
|
let ctxWindow = dict["contextWindow"] as? Int
|
||||||
|
choices.append(ModelChoice(id: id, name: name, provider: provider, contextWindow: ctxWindow))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let sorted = choices.sorted { lhs, rhs in
|
||||||
|
if lhs.provider == rhs.provider {
|
||||||
|
return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
|
||||||
|
}
|
||||||
|
return lhs.provider.localizedCaseInsensitiveCompare(rhs.provider) == .orderedAscending
|
||||||
|
}
|
||||||
|
self.logger.debug("model catalog loaded providers=\(rawModels.count) models=\(sorted.count)")
|
||||||
|
if resolved.shouldCache {
|
||||||
|
self.cacheCatalog(sourcePath: resolved.path)
|
||||||
|
}
|
||||||
|
return sorted
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func resolveDefaultPath() -> String {
|
||||||
|
let cache = self.cachePath.path
|
||||||
|
if FileManager().isReadableFile(atPath: cache) { return cache }
|
||||||
|
if let bundlePath = self.bundleCatalogPath() { return bundlePath }
|
||||||
|
if let nodePath = self.nodeModulesCatalogPath() { return nodePath }
|
||||||
|
return cache
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func resolvePath(preferred: String) -> (path: String, shouldCache: Bool)? {
|
||||||
|
if FileManager().isReadableFile(atPath: preferred) {
|
||||||
|
return (preferred, preferred != self.cachePath.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let bundlePath = self.bundleCatalogPath(), bundlePath != preferred {
|
||||||
|
self.logger.warning("model catalog path missing; falling back to bundled catalog")
|
||||||
|
return (bundlePath, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
let cache = self.cachePath.path
|
||||||
|
if cache != preferred, FileManager().isReadableFile(atPath: cache) {
|
||||||
|
self.logger.warning("model catalog path missing; falling back to cached catalog")
|
||||||
|
return (cache, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let nodePath = self.nodeModulesCatalogPath(), nodePath != preferred {
|
||||||
|
self.logger.warning("model catalog path missing; falling back to node_modules catalog")
|
||||||
|
return (nodePath, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func bundleCatalogPath() -> String? {
|
||||||
|
guard let url = Bundle.main.url(forResource: "models.generated", withExtension: "js") else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return url.path
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func nodeModulesCatalogPath() -> String? {
|
||||||
|
let roots = [
|
||||||
|
URL(fileURLWithPath: CommandResolver.projectRootPath()),
|
||||||
|
URL(fileURLWithPath: FileManager().currentDirectoryPath),
|
||||||
|
]
|
||||||
|
for root in roots {
|
||||||
|
let candidate = root
|
||||||
|
.appendingPathComponent("node_modules/@mariozechner/pi-ai/dist/models.generated.js")
|
||||||
|
if FileManager().isReadableFile(atPath: candidate.path) {
|
||||||
|
return candidate.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func cacheCatalog(sourcePath: String) {
|
||||||
|
let destination = self.cachePath
|
||||||
|
do {
|
||||||
|
try FileManager().createDirectory(
|
||||||
|
at: destination.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true)
|
||||||
|
if FileManager().fileExists(atPath: destination.path) {
|
||||||
|
try FileManager().removeItem(at: destination)
|
||||||
|
}
|
||||||
|
try FileManager().copyItem(atPath: sourcePath, toPath: destination.path)
|
||||||
|
self.logger.debug("model catalog cached file=\(destination.lastPathComponent)")
|
||||||
|
} catch {
|
||||||
|
self.logger.warning("model catalog cache failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func sanitize(source: String) -> String {
|
||||||
|
guard let exportRange = source.range(of: "export const MODELS"),
|
||||||
|
let firstBrace = source[exportRange.upperBound...].firstIndex(of: "{"),
|
||||||
|
let lastBrace = source.lastIndex(of: "}")
|
||||||
|
else {
|
||||||
|
return "var MODELS = {}"
|
||||||
|
}
|
||||||
|
var body = String(source[firstBrace...lastBrace])
|
||||||
|
body = body.replacingOccurrences(
|
||||||
|
of: #"(?m)\bsatisfies\s+[^,}\n]+"#,
|
||||||
|
with: "",
|
||||||
|
options: .regularExpression)
|
||||||
|
body = body.replacingOccurrences(
|
||||||
|
of: #"(?m)\bas\s+[^;,\n]+"#,
|
||||||
|
with: "",
|
||||||
|
options: .regularExpression)
|
||||||
|
return "var MODELS = \(body);"
|
||||||
|
}
|
||||||
|
}
|
||||||
171
apps/macos/Sources/Moltbot/NodeMode/MacNodeModeCoordinator.swift
Normal file
171
apps/macos/Sources/Moltbot/NodeMode/MacNodeModeCoordinator.swift
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import MoltbotKit
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class MacNodeModeCoordinator {
|
||||||
|
static let shared = MacNodeModeCoordinator()
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "bot.molt", category: "mac-node")
|
||||||
|
private var task: Task<Void, Never>?
|
||||||
|
private let runtime = MacNodeRuntime()
|
||||||
|
private let session = GatewayNodeSession()
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard self.task == nil else { return }
|
||||||
|
self.task = Task { [weak self] in
|
||||||
|
await self?.run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
self.task?.cancel()
|
||||||
|
self.task = nil
|
||||||
|
Task { await self.session.disconnect() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func setPreferredGatewayStableID(_ stableID: String?) {
|
||||||
|
GatewayDiscoveryPreferences.setPreferredStableID(stableID)
|
||||||
|
Task { await self.session.disconnect() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func run() async {
|
||||||
|
var retryDelay: UInt64 = 1_000_000_000
|
||||||
|
var lastCameraEnabled: Bool?
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
|
||||||
|
while !Task.isCancelled {
|
||||||
|
if await MainActor.run(body: { AppStateStore.shared.isPaused }) {
|
||||||
|
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let cameraEnabled = defaults.object(forKey: cameraEnabledKey) as? Bool ?? false
|
||||||
|
if lastCameraEnabled == nil {
|
||||||
|
lastCameraEnabled = cameraEnabled
|
||||||
|
} else if lastCameraEnabled != cameraEnabled {
|
||||||
|
lastCameraEnabled = cameraEnabled
|
||||||
|
await self.session.disconnect()
|
||||||
|
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let config = try await GatewayEndpointStore.shared.requireConfig()
|
||||||
|
let caps = self.currentCaps()
|
||||||
|
let commands = self.currentCommands(caps: caps)
|
||||||
|
let permissions = await self.currentPermissions()
|
||||||
|
let connectOptions = GatewayConnectOptions(
|
||||||
|
role: "node",
|
||||||
|
scopes: [],
|
||||||
|
caps: caps,
|
||||||
|
commands: commands,
|
||||||
|
permissions: permissions,
|
||||||
|
clientId: "moltbot-macos",
|
||||||
|
clientMode: "node",
|
||||||
|
clientDisplayName: InstanceIdentity.displayName)
|
||||||
|
let sessionBox = self.buildSessionBox(url: config.url)
|
||||||
|
|
||||||
|
try await self.session.connect(
|
||||||
|
url: config.url,
|
||||||
|
token: config.token,
|
||||||
|
password: config.password,
|
||||||
|
connectOptions: connectOptions,
|
||||||
|
sessionBox: sessionBox,
|
||||||
|
onConnected: { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.logger.info("mac node connected to gateway")
|
||||||
|
let mainSessionKey = await GatewayConnection.shared.mainSessionKey()
|
||||||
|
await self.runtime.updateMainSessionKey(mainSessionKey)
|
||||||
|
await self.runtime.setEventSender { [weak self] event, payload in
|
||||||
|
guard let self else { return }
|
||||||
|
await self.session.sendEvent(event: event, payloadJSON: payload)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDisconnected: { [weak self] reason in
|
||||||
|
guard let self else { return }
|
||||||
|
await self.runtime.setEventSender(nil)
|
||||||
|
self.logger.error("mac node disconnected: \(reason, privacy: .public)")
|
||||||
|
},
|
||||||
|
onInvoke: { [weak self] req in
|
||||||
|
guard let self else {
|
||||||
|
return BridgeInvokeResponse(
|
||||||
|
id: req.id,
|
||||||
|
ok: false,
|
||||||
|
error: MoltbotNodeError(code: .unavailable, message: "UNAVAILABLE: node not ready"))
|
||||||
|
}
|
||||||
|
return await self.runtime.handleInvoke(req)
|
||||||
|
})
|
||||||
|
|
||||||
|
retryDelay = 1_000_000_000
|
||||||
|
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||||
|
} catch {
|
||||||
|
self.logger.error("mac node gateway connect failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
try? await Task.sleep(nanoseconds: min(retryDelay, 10_000_000_000))
|
||||||
|
retryDelay = min(retryDelay * 2, 10_000_000_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func currentCaps() -> [String] {
|
||||||
|
var caps: [String] = [MoltbotCapability.canvas.rawValue, MoltbotCapability.screen.rawValue]
|
||||||
|
if UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false {
|
||||||
|
caps.append(MoltbotCapability.camera.rawValue)
|
||||||
|
}
|
||||||
|
let rawLocationMode = UserDefaults.standard.string(forKey: locationModeKey) ?? "off"
|
||||||
|
if MoltbotLocationMode(rawValue: rawLocationMode) != .off {
|
||||||
|
caps.append(MoltbotCapability.location.rawValue)
|
||||||
|
}
|
||||||
|
return caps
|
||||||
|
}
|
||||||
|
|
||||||
|
private func currentPermissions() async -> [String: Bool] {
|
||||||
|
let statuses = await PermissionManager.status()
|
||||||
|
return Dictionary(uniqueKeysWithValues: statuses.map { ($0.key.rawValue, $0.value) })
|
||||||
|
}
|
||||||
|
|
||||||
|
private func currentCommands(caps: [String]) -> [String] {
|
||||||
|
var commands: [String] = [
|
||||||
|
MoltbotCanvasCommand.present.rawValue,
|
||||||
|
MoltbotCanvasCommand.hide.rawValue,
|
||||||
|
MoltbotCanvasCommand.navigate.rawValue,
|
||||||
|
MoltbotCanvasCommand.evalJS.rawValue,
|
||||||
|
MoltbotCanvasCommand.snapshot.rawValue,
|
||||||
|
MoltbotCanvasA2UICommand.push.rawValue,
|
||||||
|
MoltbotCanvasA2UICommand.pushJSONL.rawValue,
|
||||||
|
MoltbotCanvasA2UICommand.reset.rawValue,
|
||||||
|
MacNodeScreenCommand.record.rawValue,
|
||||||
|
MoltbotSystemCommand.notify.rawValue,
|
||||||
|
MoltbotSystemCommand.which.rawValue,
|
||||||
|
MoltbotSystemCommand.run.rawValue,
|
||||||
|
MoltbotSystemCommand.execApprovalsGet.rawValue,
|
||||||
|
MoltbotSystemCommand.execApprovalsSet.rawValue,
|
||||||
|
]
|
||||||
|
|
||||||
|
let capsSet = Set(caps)
|
||||||
|
if capsSet.contains(MoltbotCapability.camera.rawValue) {
|
||||||
|
commands.append(MoltbotCameraCommand.list.rawValue)
|
||||||
|
commands.append(MoltbotCameraCommand.snap.rawValue)
|
||||||
|
commands.append(MoltbotCameraCommand.clip.rawValue)
|
||||||
|
}
|
||||||
|
if capsSet.contains(MoltbotCapability.location.rawValue) {
|
||||||
|
commands.append(MoltbotLocationCommand.get.rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
return commands
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildSessionBox(url: URL) -> WebSocketSessionBox? {
|
||||||
|
guard url.scheme?.lowercased() == "wss" else { return nil }
|
||||||
|
let host = url.host ?? "gateway"
|
||||||
|
let port = url.port ?? 443
|
||||||
|
let stableID = "\(host):\(port)"
|
||||||
|
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
||||||
|
let params = GatewayTLSParams(
|
||||||
|
required: true,
|
||||||
|
expectedFingerprint: stored,
|
||||||
|
allowTOFU: stored == nil,
|
||||||
|
storeKey: stableID)
|
||||||
|
let session = GatewayTLSPinningSession(params: params)
|
||||||
|
return WebSocketSessionBox(session: session)
|
||||||
|
}
|
||||||
|
}
|
||||||
708
apps/macos/Sources/Moltbot/NodePairingApprovalPrompter.swift
Normal file
708
apps/macos/Sources/Moltbot/NodePairingApprovalPrompter.swift
Normal file
@@ -0,0 +1,708 @@
|
|||||||
|
import AppKit
|
||||||
|
import MoltbotDiscovery
|
||||||
|
import MoltbotIPC
|
||||||
|
import MoltbotKit
|
||||||
|
import MoltbotProtocol
|
||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import OSLog
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
enum NodePairingReconcilePolicy {
|
||||||
|
static let activeIntervalMs: UInt64 = 15000
|
||||||
|
static let resyncDelayMs: UInt64 = 250
|
||||||
|
|
||||||
|
static func shouldPoll(pendingCount: Int, isPresenting: Bool) -> Bool {
|
||||||
|
pendingCount > 0 || isPresenting
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class NodePairingApprovalPrompter {
|
||||||
|
static let shared = NodePairingApprovalPrompter()
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "bot.molt", category: "node-pairing")
|
||||||
|
private var task: Task<Void, Never>?
|
||||||
|
private var reconcileTask: Task<Void, Never>?
|
||||||
|
private var reconcileOnceTask: Task<Void, Never>?
|
||||||
|
private var reconcileInFlight = false
|
||||||
|
private var isStopping = false
|
||||||
|
private var isPresenting = false
|
||||||
|
private var queue: [PendingRequest] = []
|
||||||
|
var pendingCount: Int = 0
|
||||||
|
var pendingRepairCount: Int = 0
|
||||||
|
private var activeAlert: NSAlert?
|
||||||
|
private var activeRequestId: String?
|
||||||
|
private var alertHostWindow: NSWindow?
|
||||||
|
private var remoteResolutionsByRequestId: [String: PairingResolution] = [:]
|
||||||
|
private var autoApproveAttempts: Set<String> = []
|
||||||
|
|
||||||
|
private final class AlertHostWindow: NSWindow {
|
||||||
|
override var canBecomeKey: Bool { true }
|
||||||
|
override var canBecomeMain: Bool { true }
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PairingList: Codable {
|
||||||
|
let pending: [PendingRequest]
|
||||||
|
let paired: [PairedNode]?
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PairedNode: Codable, Equatable {
|
||||||
|
let nodeId: String
|
||||||
|
let approvedAtMs: Double?
|
||||||
|
let displayName: String?
|
||||||
|
let platform: String?
|
||||||
|
let version: String?
|
||||||
|
let remoteIp: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PendingRequest: Codable, Equatable, Identifiable {
|
||||||
|
let requestId: String
|
||||||
|
let nodeId: String
|
||||||
|
let displayName: String?
|
||||||
|
let platform: String?
|
||||||
|
let version: String?
|
||||||
|
let remoteIp: String?
|
||||||
|
let isRepair: Bool?
|
||||||
|
let silent: Bool?
|
||||||
|
let ts: Double
|
||||||
|
|
||||||
|
var id: String { self.requestId }
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PairingResolvedEvent: Codable {
|
||||||
|
let requestId: String
|
||||||
|
let nodeId: String
|
||||||
|
let decision: String
|
||||||
|
let ts: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum PairingResolution: String {
|
||||||
|
case approved
|
||||||
|
case rejected
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard self.task == nil else { return }
|
||||||
|
self.isStopping = false
|
||||||
|
self.reconcileTask?.cancel()
|
||||||
|
self.reconcileTask = nil
|
||||||
|
self.task = Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
_ = try? await GatewayConnection.shared.refresh()
|
||||||
|
await self.loadPendingRequestsFromGateway()
|
||||||
|
let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200)
|
||||||
|
for await push in stream {
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
await MainActor.run { [weak self] in self?.handle(push: push) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
self.isStopping = true
|
||||||
|
self.endActiveAlert()
|
||||||
|
self.task?.cancel()
|
||||||
|
self.task = nil
|
||||||
|
self.reconcileTask?.cancel()
|
||||||
|
self.reconcileTask = nil
|
||||||
|
self.reconcileOnceTask?.cancel()
|
||||||
|
self.reconcileOnceTask = nil
|
||||||
|
self.queue.removeAll(keepingCapacity: false)
|
||||||
|
self.updatePendingCounts()
|
||||||
|
self.isPresenting = false
|
||||||
|
self.activeRequestId = nil
|
||||||
|
self.alertHostWindow?.orderOut(nil)
|
||||||
|
self.alertHostWindow?.close()
|
||||||
|
self.alertHostWindow = nil
|
||||||
|
self.remoteResolutionsByRequestId.removeAll(keepingCapacity: false)
|
||||||
|
self.autoApproveAttempts.removeAll(keepingCapacity: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadPendingRequestsFromGateway() async {
|
||||||
|
// The gateway process may start slightly after the app. Retry a bit so
|
||||||
|
// pending pairing prompts are still shown on launch.
|
||||||
|
var delayMs: UInt64 = 200
|
||||||
|
for attempt in 1...8 {
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
do {
|
||||||
|
let data = try await GatewayConnection.shared.request(
|
||||||
|
method: "node.pair.list",
|
||||||
|
params: nil,
|
||||||
|
timeoutMs: 6000)
|
||||||
|
guard !data.isEmpty else { return }
|
||||||
|
let list = try JSONDecoder().decode(PairingList.self, from: data)
|
||||||
|
let pendingCount = list.pending.count
|
||||||
|
guard pendingCount > 0 else { return }
|
||||||
|
self.logger.info(
|
||||||
|
"loaded \(pendingCount, privacy: .public) pending node pairing request(s) on startup")
|
||||||
|
await self.apply(list: list)
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
if attempt == 8 {
|
||||||
|
self.logger
|
||||||
|
.error(
|
||||||
|
"failed to load pending pairing requests: \(error.localizedDescription, privacy: .public)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try? await Task.sleep(nanoseconds: delayMs * 1_000_000)
|
||||||
|
delayMs = min(delayMs * 2, 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reconcileLoop() async {
|
||||||
|
// Reconcile requests periodically so multiple running apps stay in sync
|
||||||
|
// (e.g. close dialogs + notify if another machine approves/rejects via app or CLI).
|
||||||
|
while !Task.isCancelled {
|
||||||
|
if self.isStopping { break }
|
||||||
|
if !self.shouldPoll {
|
||||||
|
self.reconcileTask = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await self.reconcileOnce(timeoutMs: 2500)
|
||||||
|
try? await Task.sleep(
|
||||||
|
nanoseconds: NodePairingReconcilePolicy.activeIntervalMs * 1_000_000)
|
||||||
|
}
|
||||||
|
self.reconcileTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchPairingList(timeoutMs: Double) async throws -> PairingList {
|
||||||
|
let data = try await GatewayConnection.shared.request(
|
||||||
|
method: "node.pair.list",
|
||||||
|
params: nil,
|
||||||
|
timeoutMs: timeoutMs)
|
||||||
|
return try JSONDecoder().decode(PairingList.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func apply(list: PairingList) async {
|
||||||
|
if self.isStopping { return }
|
||||||
|
|
||||||
|
let pendingById = Dictionary(
|
||||||
|
uniqueKeysWithValues: list.pending.map { ($0.requestId, $0) })
|
||||||
|
|
||||||
|
// Enqueue any missing requests (covers missed pushes while reconnecting).
|
||||||
|
for req in list.pending.sorted(by: { $0.ts < $1.ts }) {
|
||||||
|
self.enqueue(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect resolved requests (approved/rejected elsewhere).
|
||||||
|
let queued = self.queue
|
||||||
|
for req in queued {
|
||||||
|
if pendingById[req.requestId] != nil { continue }
|
||||||
|
let resolution = self.inferResolution(for: req, list: list)
|
||||||
|
|
||||||
|
if self.activeRequestId == req.requestId, self.activeAlert != nil {
|
||||||
|
self.remoteResolutionsByRequestId[req.requestId] = resolution
|
||||||
|
self.logger.info(
|
||||||
|
"""
|
||||||
|
pairing request resolved elsewhere; closing dialog \
|
||||||
|
requestId=\(req.requestId, privacy: .public) \
|
||||||
|
resolution=\(resolution.rawValue, privacy: .public)
|
||||||
|
""")
|
||||||
|
self.endActiveAlert()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
"""
|
||||||
|
pairing request resolved elsewhere requestId=\(req.requestId, privacy: .public) \
|
||||||
|
resolution=\(resolution.rawValue, privacy: .public)
|
||||||
|
""")
|
||||||
|
self.queue.removeAll { $0 == req }
|
||||||
|
Task { @MainActor in
|
||||||
|
await self.notify(resolution: resolution, request: req, via: "remote")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.queue.isEmpty {
|
||||||
|
self.isPresenting = false
|
||||||
|
}
|
||||||
|
self.presentNextIfNeeded()
|
||||||
|
self.updateReconcileLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func inferResolution(for request: PendingRequest, list: PairingList) -> PairingResolution {
|
||||||
|
let paired = list.paired ?? []
|
||||||
|
guard let node = paired.first(where: { $0.nodeId == request.nodeId }) else {
|
||||||
|
return .rejected
|
||||||
|
}
|
||||||
|
if request.isRepair == true, let approvedAtMs = node.approvedAtMs {
|
||||||
|
return approvedAtMs >= request.ts ? .approved : .rejected
|
||||||
|
}
|
||||||
|
return .approved
|
||||||
|
}
|
||||||
|
|
||||||
|
private func endActiveAlert() {
|
||||||
|
guard let alert = self.activeAlert else { return }
|
||||||
|
if let parent = alert.window.sheetParent {
|
||||||
|
parent.endSheet(alert.window, returnCode: .abort)
|
||||||
|
}
|
||||||
|
self.activeAlert = nil
|
||||||
|
self.activeRequestId = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requireAlertHostWindow() -> NSWindow {
|
||||||
|
if let alertHostWindow {
|
||||||
|
return alertHostWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
let window = AlertHostWindow(
|
||||||
|
contentRect: NSRect(x: 0, y: 0, width: 520, height: 1),
|
||||||
|
styleMask: [.borderless],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false)
|
||||||
|
window.title = ""
|
||||||
|
window.isReleasedWhenClosed = false
|
||||||
|
window.level = .floating
|
||||||
|
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||||||
|
window.isOpaque = false
|
||||||
|
window.hasShadow = false
|
||||||
|
window.backgroundColor = .clear
|
||||||
|
window.ignoresMouseEvents = true
|
||||||
|
|
||||||
|
self.alertHostWindow = window
|
||||||
|
return window
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handle(push: GatewayPush) {
|
||||||
|
switch push {
|
||||||
|
case let .event(evt) where evt.event == "node.pair.requested":
|
||||||
|
guard let payload = evt.payload else { return }
|
||||||
|
do {
|
||||||
|
let req = try GatewayPayloadDecoding.decode(payload, as: PendingRequest.self)
|
||||||
|
self.enqueue(req)
|
||||||
|
} catch {
|
||||||
|
self.logger
|
||||||
|
.error("failed to decode pairing request: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
case let .event(evt) where evt.event == "node.pair.resolved":
|
||||||
|
guard let payload = evt.payload else { return }
|
||||||
|
do {
|
||||||
|
let resolved = try GatewayPayloadDecoding.decode(payload, as: PairingResolvedEvent.self)
|
||||||
|
self.handleResolved(resolved)
|
||||||
|
} catch {
|
||||||
|
self.logger
|
||||||
|
.error(
|
||||||
|
"failed to decode pairing resolution: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
case .snapshot:
|
||||||
|
self.scheduleReconcileOnce(delayMs: 0)
|
||||||
|
case .seqGap:
|
||||||
|
self.scheduleReconcileOnce()
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func enqueue(_ req: PendingRequest) {
|
||||||
|
if self.queue.contains(req) { return }
|
||||||
|
self.queue.append(req)
|
||||||
|
self.updatePendingCounts()
|
||||||
|
self.presentNextIfNeeded()
|
||||||
|
self.updateReconcileLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentNextIfNeeded() {
|
||||||
|
guard !self.isStopping else { return }
|
||||||
|
guard !self.isPresenting else { return }
|
||||||
|
guard let next = self.queue.first else { return }
|
||||||
|
self.isPresenting = true
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
if await self.trySilentApproveIfPossible(next) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.presentAlert(for: next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentAlert(for req: PendingRequest) {
|
||||||
|
self.logger.info("presenting node pairing alert requestId=\(req.requestId, privacy: .public)")
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.alertStyle = .warning
|
||||||
|
alert.messageText = "Allow node to connect?"
|
||||||
|
alert.informativeText = Self.describe(req)
|
||||||
|
// Fail-safe ordering: if the dialog can't be presented, default to "Later".
|
||||||
|
alert.addButton(withTitle: "Later")
|
||||||
|
alert.addButton(withTitle: "Approve")
|
||||||
|
alert.addButton(withTitle: "Reject")
|
||||||
|
if #available(macOS 11.0, *), alert.buttons.indices.contains(2) {
|
||||||
|
alert.buttons[2].hasDestructiveAction = true
|
||||||
|
}
|
||||||
|
|
||||||
|
self.activeAlert = alert
|
||||||
|
self.activeRequestId = req.requestId
|
||||||
|
let hostWindow = self.requireAlertHostWindow()
|
||||||
|
|
||||||
|
// Position the hidden host window so the sheet appears centered on screen.
|
||||||
|
// (Sheets attach to the top edge of their parent window; if the parent is tiny, it looks "anchored".)
|
||||||
|
let sheetSize = alert.window.frame.size
|
||||||
|
if let screen = hostWindow.screen ?? NSScreen.main {
|
||||||
|
let bounds = screen.visibleFrame
|
||||||
|
let x = bounds.midX - (sheetSize.width / 2)
|
||||||
|
let sheetOriginY = bounds.midY - (sheetSize.height / 2)
|
||||||
|
let hostY = sheetOriginY + sheetSize.height - hostWindow.frame.height
|
||||||
|
hostWindow.setFrameOrigin(NSPoint(x: x, y: hostY))
|
||||||
|
} else {
|
||||||
|
hostWindow.center()
|
||||||
|
}
|
||||||
|
|
||||||
|
hostWindow.makeKeyAndOrderFront(nil)
|
||||||
|
alert.beginSheetModal(for: hostWindow) { [weak self] response in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.activeRequestId = nil
|
||||||
|
self.activeAlert = nil
|
||||||
|
await self.handleAlertResponse(response, request: req)
|
||||||
|
hostWindow.orderOut(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleAlertResponse(_ response: NSApplication.ModalResponse, request: PendingRequest) async {
|
||||||
|
defer {
|
||||||
|
if self.queue.first == request {
|
||||||
|
self.queue.removeFirst()
|
||||||
|
} else {
|
||||||
|
self.queue.removeAll { $0 == request }
|
||||||
|
}
|
||||||
|
self.updatePendingCounts()
|
||||||
|
self.isPresenting = false
|
||||||
|
self.presentNextIfNeeded()
|
||||||
|
self.updateReconcileLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never approve/reject while shutting down (alerts can get dismissed during app termination).
|
||||||
|
guard !self.isStopping else { return }
|
||||||
|
|
||||||
|
if let resolved = self.remoteResolutionsByRequestId.removeValue(forKey: request.requestId) {
|
||||||
|
await self.notify(resolution: resolved, request: request, via: "remote")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch response {
|
||||||
|
case .alertFirstButtonReturn:
|
||||||
|
// Later: leave as pending (CLI can approve/reject). Request will expire on the gateway TTL.
|
||||||
|
return
|
||||||
|
case .alertSecondButtonReturn:
|
||||||
|
_ = await self.approve(requestId: request.requestId)
|
||||||
|
await self.notify(resolution: .approved, request: request, via: "local")
|
||||||
|
case .alertThirdButtonReturn:
|
||||||
|
await self.reject(requestId: request.requestId)
|
||||||
|
await self.notify(resolution: .rejected, request: request, via: "local")
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func approve(requestId: String) async -> Bool {
|
||||||
|
do {
|
||||||
|
try await GatewayConnection.shared.nodePairApprove(requestId: requestId)
|
||||||
|
self.logger.info("approved node pairing requestId=\(requestId, privacy: .public)")
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
self.logger.error("approve failed requestId=\(requestId, privacy: .public)")
|
||||||
|
self.logger.error("approve failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reject(requestId: String) async {
|
||||||
|
do {
|
||||||
|
try await GatewayConnection.shared.nodePairReject(requestId: requestId)
|
||||||
|
self.logger.info("rejected node pairing requestId=\(requestId, privacy: .public)")
|
||||||
|
} catch {
|
||||||
|
self.logger.error("reject failed requestId=\(requestId, privacy: .public)")
|
||||||
|
self.logger.error("reject failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func describe(_ req: PendingRequest) -> String {
|
||||||
|
let name = req.displayName?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let platform = self.prettyPlatform(req.platform)
|
||||||
|
let version = req.version?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let ip = self.prettyIP(req.remoteIp)
|
||||||
|
|
||||||
|
var lines: [String] = []
|
||||||
|
lines.append("Name: \(name?.isEmpty == false ? name! : "Unknown")")
|
||||||
|
lines.append("Node ID: \(req.nodeId)")
|
||||||
|
if let platform, !platform.isEmpty { lines.append("Platform: \(platform)") }
|
||||||
|
if let version, !version.isEmpty { lines.append("App: \(version)") }
|
||||||
|
if let ip, !ip.isEmpty { lines.append("IP: \(ip)") }
|
||||||
|
if req.isRepair == true { lines.append("Note: Repair request (token will rotate).") }
|
||||||
|
return lines.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func prettyIP(_ ip: String?) -> String? {
|
||||||
|
let trimmed = ip?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard let trimmed, !trimmed.isEmpty else { return nil }
|
||||||
|
return trimmed.replacingOccurrences(of: "::ffff:", with: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func prettyPlatform(_ platform: String?) -> String? {
|
||||||
|
let raw = platform?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard let raw, !raw.isEmpty else { return nil }
|
||||||
|
if raw.lowercased() == "ios" { return "iOS" }
|
||||||
|
if raw.lowercased() == "macos" { return "macOS" }
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
private func notify(resolution: PairingResolution, request: PendingRequest, via: String) async {
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
let settings = await center.notificationSettings()
|
||||||
|
guard settings.authorizationStatus == .authorized ||
|
||||||
|
settings.authorizationStatus == .provisional
|
||||||
|
else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let title = resolution == .approved ? "Node pairing approved" : "Node pairing rejected"
|
||||||
|
let name = request.displayName?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let device = name?.isEmpty == false ? name! : request.nodeId
|
||||||
|
let body = "\(device)\n(via \(via))"
|
||||||
|
|
||||||
|
_ = await NotificationManager().send(
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
sound: nil,
|
||||||
|
priority: .active)
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SSHTarget {
|
||||||
|
let host: String
|
||||||
|
let port: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
private func trySilentApproveIfPossible(_ req: PendingRequest) async -> Bool {
|
||||||
|
guard req.silent == true else { return false }
|
||||||
|
if self.autoApproveAttempts.contains(req.requestId) { return false }
|
||||||
|
self.autoApproveAttempts.insert(req.requestId)
|
||||||
|
|
||||||
|
guard let target = await self.resolveSSHTarget() else {
|
||||||
|
self.logger.info("silent pairing skipped (no ssh target) requestId=\(req.requestId, privacy: .public)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = NSUserName().trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !user.isEmpty else {
|
||||||
|
self.logger.info("silent pairing skipped (missing local user) requestId=\(req.requestId, privacy: .public)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let ok = await Self.probeSSH(user: user, host: target.host, port: target.port)
|
||||||
|
if !ok {
|
||||||
|
self.logger.info("silent pairing probe failed requestId=\(req.requestId, privacy: .public)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
guard await self.approve(requestId: req.requestId) else {
|
||||||
|
self.logger.info("silent pairing approve failed requestId=\(req.requestId, privacy: .public)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
await self.notify(resolution: .approved, request: req, via: "silent-ssh")
|
||||||
|
if self.queue.first == req {
|
||||||
|
self.queue.removeFirst()
|
||||||
|
} else {
|
||||||
|
self.queue.removeAll { $0 == req }
|
||||||
|
}
|
||||||
|
|
||||||
|
self.updatePendingCounts()
|
||||||
|
self.isPresenting = false
|
||||||
|
self.presentNextIfNeeded()
|
||||||
|
self.updateReconcileLoop()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveSSHTarget() async -> SSHTarget? {
|
||||||
|
let settings = CommandResolver.connectionSettings()
|
||||||
|
if !settings.target.isEmpty, let parsed = CommandResolver.parseSSHTarget(settings.target) {
|
||||||
|
let user = NSUserName().trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if let targetUser = parsed.user,
|
||||||
|
!targetUser.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||||
|
targetUser != user
|
||||||
|
{
|
||||||
|
self.logger.info("silent pairing skipped (ssh user mismatch)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let host = parsed.host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !host.isEmpty else { return nil }
|
||||||
|
let port = parsed.port > 0 ? parsed.port : 22
|
||||||
|
return SSHTarget(host: host, port: port)
|
||||||
|
}
|
||||||
|
|
||||||
|
let model = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)
|
||||||
|
model.start()
|
||||||
|
defer { model.stop() }
|
||||||
|
|
||||||
|
let deadline = Date().addingTimeInterval(5.0)
|
||||||
|
while model.gateways.isEmpty, Date() < deadline {
|
||||||
|
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
let preferred = GatewayDiscoveryPreferences.preferredStableID()
|
||||||
|
let gateway = model.gateways.first { $0.stableID == preferred } ?? model.gateways.first
|
||||||
|
guard let gateway else { return nil }
|
||||||
|
let host = (gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ??
|
||||||
|
gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty)
|
||||||
|
guard let host, !host.isEmpty else { return nil }
|
||||||
|
let port = gateway.sshPort > 0 ? gateway.sshPort : 22
|
||||||
|
return SSHTarget(host: host, port: port)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func probeSSH(user: String, host: String, port: Int) async -> Bool {
|
||||||
|
await Task.detached(priority: .utility) {
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
|
||||||
|
|
||||||
|
let options = [
|
||||||
|
"-o", "BatchMode=yes",
|
||||||
|
"-o", "ConnectTimeout=5",
|
||||||
|
"-o", "NumberOfPasswordPrompts=0",
|
||||||
|
"-o", "PreferredAuthentications=publickey",
|
||||||
|
"-o", "StrictHostKeyChecking=accept-new",
|
||||||
|
]
|
||||||
|
guard let target = CommandResolver.makeSSHTarget(user: user, host: host, port: port) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let args = CommandResolver.sshArguments(
|
||||||
|
target: target,
|
||||||
|
identity: "",
|
||||||
|
options: options,
|
||||||
|
remoteCommand: ["/usr/bin/true"])
|
||||||
|
process.arguments = args
|
||||||
|
let pipe = Pipe()
|
||||||
|
process.standardOutput = pipe
|
||||||
|
process.standardError = pipe
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try process.runAndReadToEnd(from: pipe)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return process.terminationStatus == 0
|
||||||
|
}.value
|
||||||
|
}
|
||||||
|
|
||||||
|
private var shouldPoll: Bool {
|
||||||
|
NodePairingReconcilePolicy.shouldPoll(
|
||||||
|
pendingCount: self.queue.count,
|
||||||
|
isPresenting: self.isPresenting)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateReconcileLoop() {
|
||||||
|
guard !self.isStopping else { return }
|
||||||
|
if self.shouldPoll {
|
||||||
|
if self.reconcileTask == nil {
|
||||||
|
self.reconcileTask = Task { [weak self] in
|
||||||
|
await self?.reconcileLoop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.reconcileTask?.cancel()
|
||||||
|
self.reconcileTask = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updatePendingCounts() {
|
||||||
|
// Keep a cheap observable summary for the menu bar status line.
|
||||||
|
self.pendingCount = self.queue.count
|
||||||
|
self.pendingRepairCount = self.queue.count(where: { $0.isRepair == true })
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reconcileOnce(timeoutMs: Double) async {
|
||||||
|
if self.isStopping { return }
|
||||||
|
if self.reconcileInFlight { return }
|
||||||
|
self.reconcileInFlight = true
|
||||||
|
defer { self.reconcileInFlight = false }
|
||||||
|
do {
|
||||||
|
let list = try await self.fetchPairingList(timeoutMs: timeoutMs)
|
||||||
|
await self.apply(list: list)
|
||||||
|
} catch {
|
||||||
|
// best effort: ignore transient connectivity failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleReconcileOnce(delayMs: UInt64 = NodePairingReconcilePolicy.resyncDelayMs) {
|
||||||
|
self.reconcileOnceTask?.cancel()
|
||||||
|
self.reconcileOnceTask = Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
if delayMs > 0 {
|
||||||
|
try? await Task.sleep(nanoseconds: delayMs * 1_000_000)
|
||||||
|
}
|
||||||
|
await self.reconcileOnce(timeoutMs: 2500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleResolved(_ resolved: PairingResolvedEvent) {
|
||||||
|
let resolution: PairingResolution =
|
||||||
|
resolved.decision == PairingResolution.approved.rawValue ? .approved : .rejected
|
||||||
|
|
||||||
|
if self.activeRequestId == resolved.requestId, self.activeAlert != nil {
|
||||||
|
self.remoteResolutionsByRequestId[resolved.requestId] = resolution
|
||||||
|
self.logger.info(
|
||||||
|
"""
|
||||||
|
pairing request resolved elsewhere; closing dialog \
|
||||||
|
requestId=\(resolved.requestId, privacy: .public) \
|
||||||
|
resolution=\(resolution.rawValue, privacy: .public)
|
||||||
|
""")
|
||||||
|
self.endActiveAlert()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let request = self.queue.first(where: { $0.requestId == resolved.requestId }) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.queue.removeAll { $0.requestId == resolved.requestId }
|
||||||
|
self.updatePendingCounts()
|
||||||
|
Task { @MainActor in
|
||||||
|
await self.notify(resolution: resolution, request: request, via: "remote")
|
||||||
|
}
|
||||||
|
if self.queue.isEmpty {
|
||||||
|
self.isPresenting = false
|
||||||
|
}
|
||||||
|
self.presentNextIfNeeded()
|
||||||
|
self.updateReconcileLoop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
@MainActor
|
||||||
|
extension NodePairingApprovalPrompter {
|
||||||
|
static func exerciseForTesting() async {
|
||||||
|
let prompter = NodePairingApprovalPrompter()
|
||||||
|
let pending = PendingRequest(
|
||||||
|
requestId: "req-1",
|
||||||
|
nodeId: "node-1",
|
||||||
|
displayName: "Node One",
|
||||||
|
platform: "macos",
|
||||||
|
version: "1.0.0",
|
||||||
|
remoteIp: "127.0.0.1",
|
||||||
|
isRepair: false,
|
||||||
|
silent: true,
|
||||||
|
ts: 1_700_000_000_000)
|
||||||
|
let paired = PairedNode(
|
||||||
|
nodeId: "node-1",
|
||||||
|
approvedAtMs: 1_700_000_000_000,
|
||||||
|
displayName: "Node One",
|
||||||
|
platform: "macOS",
|
||||||
|
version: "1.0.0",
|
||||||
|
remoteIp: "127.0.0.1")
|
||||||
|
let list = PairingList(pending: [pending], paired: [paired])
|
||||||
|
|
||||||
|
_ = Self.describe(pending)
|
||||||
|
_ = Self.prettyIP(pending.remoteIp)
|
||||||
|
_ = Self.prettyPlatform(pending.platform)
|
||||||
|
_ = prompter.inferResolution(for: pending, list: list)
|
||||||
|
|
||||||
|
prompter.queue = [pending]
|
||||||
|
_ = prompter.shouldPoll
|
||||||
|
_ = await prompter.trySilentApproveIfPossible(pending)
|
||||||
|
prompter.queue.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
150
apps/macos/Sources/Moltbot/NodeServiceManager.swift
Normal file
150
apps/macos/Sources/Moltbot/NodeServiceManager.swift
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
enum NodeServiceManager {
|
||||||
|
private static let logger = Logger(subsystem: "bot.molt", category: "node.service")
|
||||||
|
|
||||||
|
static func start() async -> String? {
|
||||||
|
let result = await self.runServiceCommandResult(
|
||||||
|
["node", "start"],
|
||||||
|
timeout: 20,
|
||||||
|
quiet: false)
|
||||||
|
if let error = self.errorMessage(from: result, treatNotLoadedAsError: true) {
|
||||||
|
self.logger.error("node service start failed: \(error, privacy: .public)")
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func stop() async -> String? {
|
||||||
|
let result = await self.runServiceCommandResult(
|
||||||
|
["node", "stop"],
|
||||||
|
timeout: 15,
|
||||||
|
quiet: false)
|
||||||
|
if let error = self.errorMessage(from: result, treatNotLoadedAsError: false) {
|
||||||
|
self.logger.error("node service stop failed: \(error, privacy: .public)")
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NodeServiceManager {
|
||||||
|
private struct CommandResult {
|
||||||
|
let success: Bool
|
||||||
|
let payload: Data?
|
||||||
|
let message: String?
|
||||||
|
let parsed: ParsedServiceJson?
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ParsedServiceJson {
|
||||||
|
let text: String
|
||||||
|
let object: [String: Any]
|
||||||
|
let ok: Bool?
|
||||||
|
let result: String?
|
||||||
|
let message: String?
|
||||||
|
let error: String?
|
||||||
|
let hints: [String]
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func runServiceCommandResult(
|
||||||
|
_ args: [String],
|
||||||
|
timeout: Double,
|
||||||
|
quiet: Bool) async -> CommandResult
|
||||||
|
{
|
||||||
|
let command = CommandResolver.clawdbotCommand(
|
||||||
|
subcommand: "service",
|
||||||
|
extraArgs: self.withJsonFlag(args),
|
||||||
|
// Service management must always run locally, even if remote mode is configured.
|
||||||
|
configRoot: ["gateway": ["mode": "local"]])
|
||||||
|
var env = ProcessInfo.processInfo.environment
|
||||||
|
env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":")
|
||||||
|
let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout)
|
||||||
|
let parsed = self.parseServiceJson(from: response.stdout) ?? self.parseServiceJson(from: response.stderr)
|
||||||
|
let ok = parsed?.ok
|
||||||
|
let message = parsed?.error ?? parsed?.message
|
||||||
|
let payload = parsed?.text.data(using: .utf8)
|
||||||
|
?? (response.stdout.isEmpty ? response.stderr : response.stdout).data(using: .utf8)
|
||||||
|
let success = ok ?? response.success
|
||||||
|
if success {
|
||||||
|
return CommandResult(success: true, payload: payload, message: nil, parsed: parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if quiet {
|
||||||
|
return CommandResult(success: false, payload: payload, message: message, parsed: parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
let detail = message ?? self.summarize(response.stderr) ?? self.summarize(response.stdout)
|
||||||
|
let exit = response.exitCode.map { "exit \($0)" } ?? (response.errorMessage ?? "failed")
|
||||||
|
let fullMessage = detail.map { "Node service command failed (\(exit)): \($0)" }
|
||||||
|
?? "Node service command failed (\(exit))"
|
||||||
|
self.logger.error("\(fullMessage, privacy: .public)")
|
||||||
|
return CommandResult(success: false, payload: payload, message: detail, parsed: parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func errorMessage(from result: CommandResult, treatNotLoadedAsError: Bool) -> String? {
|
||||||
|
if !result.success {
|
||||||
|
return result.message ?? "Node service command failed"
|
||||||
|
}
|
||||||
|
guard let parsed = result.parsed else { return nil }
|
||||||
|
if parsed.ok == false {
|
||||||
|
return self.mergeHints(message: parsed.error ?? parsed.message, hints: parsed.hints)
|
||||||
|
}
|
||||||
|
if treatNotLoadedAsError, parsed.result == "not-loaded" {
|
||||||
|
let base = parsed.message ?? "Node service not loaded."
|
||||||
|
return self.mergeHints(message: base, hints: parsed.hints)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func withJsonFlag(_ args: [String]) -> [String] {
|
||||||
|
if args.contains("--json") { return args }
|
||||||
|
return args + ["--json"]
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseServiceJson(from raw: String) -> ParsedServiceJson? {
|
||||||
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard let start = trimmed.firstIndex(of: "{"),
|
||||||
|
let end = trimmed.lastIndex(of: "}")
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let jsonText = String(trimmed[start...end])
|
||||||
|
guard let data = jsonText.data(using: .utf8) else { return nil }
|
||||||
|
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
|
||||||
|
let ok = object["ok"] as? Bool
|
||||||
|
let result = object["result"] as? String
|
||||||
|
let message = object["message"] as? String
|
||||||
|
let error = object["error"] as? String
|
||||||
|
let hints = (object["hints"] as? [String]) ?? []
|
||||||
|
return ParsedServiceJson(
|
||||||
|
text: jsonText,
|
||||||
|
object: object,
|
||||||
|
ok: ok,
|
||||||
|
result: result,
|
||||||
|
message: message,
|
||||||
|
error: error,
|
||||||
|
hints: hints)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func mergeHints(message: String?, hints: [String]) -> String? {
|
||||||
|
let trimmed = message?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let nonEmpty = trimmed?.isEmpty == false ? trimmed : nil
|
||||||
|
guard !hints.isEmpty else { return nonEmpty }
|
||||||
|
let hintText = hints.prefix(2).joined(separator: " · ")
|
||||||
|
if let nonEmpty {
|
||||||
|
return "\(nonEmpty) (\(hintText))"
|
||||||
|
}
|
||||||
|
return hintText
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func summarize(_ text: String) -> String? {
|
||||||
|
let lines = text
|
||||||
|
.split(whereSeparator: \.isNewline)
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
guard let last = lines.last else { return nil }
|
||||||
|
let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
||||||
|
return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
102
apps/macos/Sources/Moltbot/NodesStore.swift
Normal file
102
apps/macos/Sources/Moltbot/NodesStore.swift
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
struct NodeInfo: Identifiable, Codable {
|
||||||
|
let nodeId: String
|
||||||
|
let displayName: String?
|
||||||
|
let platform: String?
|
||||||
|
let version: String?
|
||||||
|
let coreVersion: String?
|
||||||
|
let uiVersion: String?
|
||||||
|
let deviceFamily: String?
|
||||||
|
let modelIdentifier: String?
|
||||||
|
let remoteIp: String?
|
||||||
|
let caps: [String]?
|
||||||
|
let commands: [String]?
|
||||||
|
let permissions: [String: Bool]?
|
||||||
|
let paired: Bool?
|
||||||
|
let connected: Bool?
|
||||||
|
|
||||||
|
var id: String { self.nodeId }
|
||||||
|
var isConnected: Bool { self.connected ?? false }
|
||||||
|
var isPaired: Bool { self.paired ?? false }
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct NodeListResponse: Codable {
|
||||||
|
let ts: Double?
|
||||||
|
let nodes: [NodeInfo]
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class NodesStore {
|
||||||
|
static let shared = NodesStore()
|
||||||
|
|
||||||
|
var nodes: [NodeInfo] = []
|
||||||
|
var lastError: String?
|
||||||
|
var statusMessage: String?
|
||||||
|
var isLoading = false
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "bot.molt", category: "nodes")
|
||||||
|
private var task: Task<Void, Never>?
|
||||||
|
private let interval: TimeInterval = 30
|
||||||
|
private var startCount = 0
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
self.startCount += 1
|
||||||
|
guard self.startCount == 1 else { return }
|
||||||
|
guard self.task == nil else { return }
|
||||||
|
self.task = Task.detached { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
await self.refresh()
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000))
|
||||||
|
await self.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
guard self.startCount > 0 else { return }
|
||||||
|
self.startCount -= 1
|
||||||
|
guard self.startCount == 0 else { return }
|
||||||
|
self.task?.cancel()
|
||||||
|
self.task = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh() async {
|
||||||
|
if self.isLoading { return }
|
||||||
|
self.statusMessage = nil
|
||||||
|
self.isLoading = true
|
||||||
|
defer { self.isLoading = false }
|
||||||
|
do {
|
||||||
|
let data = try await GatewayConnection.shared.requestRaw(method: "node.list", params: nil, timeoutMs: 8000)
|
||||||
|
let decoded = try JSONDecoder().decode(NodeListResponse.self, from: data)
|
||||||
|
self.nodes = decoded.nodes
|
||||||
|
self.lastError = nil
|
||||||
|
self.statusMessage = nil
|
||||||
|
} catch {
|
||||||
|
if Self.isCancelled(error) {
|
||||||
|
self.logger.debug("node.list cancelled; keeping last nodes")
|
||||||
|
if self.nodes.isEmpty {
|
||||||
|
self.statusMessage = "Refreshing devices…"
|
||||||
|
}
|
||||||
|
self.lastError = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.logger.error("node.list failed \(error.localizedDescription, privacy: .public)")
|
||||||
|
self.nodes = []
|
||||||
|
self.lastError = error.localizedDescription
|
||||||
|
self.statusMessage = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isCancelled(_ error: Error) -> Bool {
|
||||||
|
if error is CancellationError { return true }
|
||||||
|
if let urlError = error as? URLError, urlError.code == .cancelled { return true }
|
||||||
|
let nsError = error as NSError
|
||||||
|
if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
66
apps/macos/Sources/Moltbot/NotificationManager.swift
Normal file
66
apps/macos/Sources/Moltbot/NotificationManager.swift
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import MoltbotIPC
|
||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct NotificationManager {
|
||||||
|
private let logger = Logger(subsystem: "bot.molt", category: "notifications")
|
||||||
|
|
||||||
|
private static let hasTimeSensitiveEntitlement: Bool = {
|
||||||
|
guard let task = SecTaskCreateFromSelf(nil) else { return false }
|
||||||
|
let key = "com.apple.developer.usernotifications.time-sensitive" as CFString
|
||||||
|
guard let val = SecTaskCopyValueForEntitlement(task, key, nil) else { return false }
|
||||||
|
return (val as? Bool) == true
|
||||||
|
}()
|
||||||
|
|
||||||
|
func send(title: String, body: String, sound: String?, priority: NotificationPriority? = nil) async -> Bool {
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
let status = await center.notificationSettings()
|
||||||
|
if status.authorizationStatus == .notDetermined {
|
||||||
|
let granted = try? await center.requestAuthorization(options: [.alert, .sound, .badge])
|
||||||
|
if granted != true {
|
||||||
|
self.logger.warning("notification permission denied (request)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else if status.authorizationStatus != .authorized {
|
||||||
|
self.logger.warning("notification permission denied status=\(status.authorizationStatus.rawValue)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = title
|
||||||
|
content.body = body
|
||||||
|
if let soundName = sound, !soundName.isEmpty {
|
||||||
|
content.sound = UNNotificationSound(named: UNNotificationSoundName(soundName))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set interruption level based on priority
|
||||||
|
if let priority {
|
||||||
|
switch priority {
|
||||||
|
case .passive:
|
||||||
|
content.interruptionLevel = .passive
|
||||||
|
case .active:
|
||||||
|
content.interruptionLevel = .active
|
||||||
|
case .timeSensitive:
|
||||||
|
if Self.hasTimeSensitiveEntitlement {
|
||||||
|
content.interruptionLevel = .timeSensitive
|
||||||
|
} else {
|
||||||
|
self.logger.debug(
|
||||||
|
"time-sensitive notification requested without entitlement; falling back to active")
|
||||||
|
content.interruptionLevel = .active
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let req = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
||||||
|
do {
|
||||||
|
try await center.add(req)
|
||||||
|
self.logger.debug("notification queued")
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
self.logger.error("notification send failed: \(error.localizedDescription)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
412
apps/macos/Sources/Moltbot/OnboardingWizard.swift
Normal file
412
apps/macos/Sources/Moltbot/OnboardingWizard.swift
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
import MoltbotKit
|
||||||
|
import MoltbotProtocol
|
||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import OSLog
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
private let onboardingWizardLogger = Logger(subsystem: "bot.molt", category: "onboarding.wizard")
|
||||||
|
|
||||||
|
// MARK: - Swift 6 AnyCodable Bridging Helpers
|
||||||
|
|
||||||
|
// Bridge between MoltbotProtocol.AnyCodable and the local module to avoid
|
||||||
|
// Swift 6 strict concurrency type conflicts.
|
||||||
|
|
||||||
|
private typealias ProtocolAnyCodable = MoltbotProtocol.AnyCodable
|
||||||
|
|
||||||
|
private func bridgeToLocal(_ value: ProtocolAnyCodable) -> AnyCodable {
|
||||||
|
if let data = try? JSONEncoder().encode(value),
|
||||||
|
let decoded = try? JSONDecoder().decode(AnyCodable.self, from: data)
|
||||||
|
{
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
return AnyCodable(value.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bridgeToLocal(_ value: ProtocolAnyCodable?) -> AnyCodable? {
|
||||||
|
value.map(bridgeToLocal)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class OnboardingWizardModel {
|
||||||
|
private(set) var sessionId: String?
|
||||||
|
private(set) var currentStep: WizardStep?
|
||||||
|
private(set) var status: String?
|
||||||
|
private(set) var errorMessage: String?
|
||||||
|
var isStarting = false
|
||||||
|
var isSubmitting = false
|
||||||
|
private var lastStartMode: AppState.ConnectionMode?
|
||||||
|
private var lastStartWorkspace: String?
|
||||||
|
private var restartAttempts = 0
|
||||||
|
private let maxRestartAttempts = 1
|
||||||
|
|
||||||
|
var isComplete: Bool { self.status == "done" }
|
||||||
|
var isRunning: Bool { self.status == "running" }
|
||||||
|
|
||||||
|
func reset() {
|
||||||
|
self.sessionId = nil
|
||||||
|
self.currentStep = nil
|
||||||
|
self.status = nil
|
||||||
|
self.errorMessage = nil
|
||||||
|
self.isStarting = false
|
||||||
|
self.isSubmitting = false
|
||||||
|
self.restartAttempts = 0
|
||||||
|
self.lastStartMode = nil
|
||||||
|
self.lastStartWorkspace = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func startIfNeeded(mode: AppState.ConnectionMode, workspace: String? = nil) async {
|
||||||
|
guard self.sessionId == nil, !self.isStarting else { return }
|
||||||
|
guard mode == .local else { return }
|
||||||
|
if self.shouldSkipWizard() {
|
||||||
|
self.sessionId = nil
|
||||||
|
self.currentStep = nil
|
||||||
|
self.status = "done"
|
||||||
|
self.errorMessage = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.isStarting = true
|
||||||
|
self.errorMessage = nil
|
||||||
|
self.lastStartMode = mode
|
||||||
|
self.lastStartWorkspace = workspace
|
||||||
|
defer { self.isStarting = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
GatewayProcessManager.shared.setActive(true)
|
||||||
|
if await GatewayProcessManager.shared.waitForGatewayReady(timeout: 12) == false {
|
||||||
|
throw NSError(
|
||||||
|
domain: "Gateway",
|
||||||
|
code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Gateway did not become ready. Check that it is running."])
|
||||||
|
}
|
||||||
|
var params: [String: AnyCodable] = ["mode": AnyCodable("local")]
|
||||||
|
if let workspace, !workspace.isEmpty {
|
||||||
|
params["workspace"] = AnyCodable(workspace)
|
||||||
|
}
|
||||||
|
let res: WizardStartResult = try await GatewayConnection.shared.requestDecoded(
|
||||||
|
method: .wizardStart,
|
||||||
|
params: params)
|
||||||
|
self.applyStartResult(res)
|
||||||
|
} catch {
|
||||||
|
self.status = "error"
|
||||||
|
self.errorMessage = error.localizedDescription
|
||||||
|
onboardingWizardLogger.error("start failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func submit(step: WizardStep, value: AnyCodable?) async {
|
||||||
|
guard let sessionId, !self.isSubmitting else { return }
|
||||||
|
self.isSubmitting = true
|
||||||
|
self.errorMessage = nil
|
||||||
|
defer { self.isSubmitting = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
var params: [String: AnyCodable] = ["sessionId": AnyCodable(sessionId)]
|
||||||
|
var answer: [String: AnyCodable] = ["stepId": AnyCodable(step.id)]
|
||||||
|
if let value {
|
||||||
|
answer["value"] = value
|
||||||
|
}
|
||||||
|
params["answer"] = AnyCodable(answer)
|
||||||
|
let res: WizardNextResult = try await GatewayConnection.shared.requestDecoded(
|
||||||
|
method: .wizardNext,
|
||||||
|
params: params)
|
||||||
|
self.applyNextResult(res)
|
||||||
|
} catch {
|
||||||
|
if self.restartIfSessionLost(error: error) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.status = "error"
|
||||||
|
self.errorMessage = error.localizedDescription
|
||||||
|
onboardingWizardLogger.error("submit failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelIfRunning() async {
|
||||||
|
guard let sessionId, self.isRunning else { return }
|
||||||
|
do {
|
||||||
|
let res: WizardStatusResult = try await GatewayConnection.shared.requestDecoded(
|
||||||
|
method: .wizardCancel,
|
||||||
|
params: ["sessionId": AnyCodable(sessionId)])
|
||||||
|
self.applyStatusResult(res)
|
||||||
|
} catch {
|
||||||
|
self.status = "error"
|
||||||
|
self.errorMessage = error.localizedDescription
|
||||||
|
onboardingWizardLogger.error("cancel failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyStartResult(_ res: WizardStartResult) {
|
||||||
|
self.sessionId = res.sessionid
|
||||||
|
self.status = wizardStatusString(res.status) ?? (res.done ? "done" : "running")
|
||||||
|
self.errorMessage = res.error
|
||||||
|
self.currentStep = decodeWizardStep(res.step)
|
||||||
|
if self.currentStep == nil, res.step != nil {
|
||||||
|
onboardingWizardLogger.error("wizard step decode failed")
|
||||||
|
}
|
||||||
|
if res.done { self.currentStep = nil }
|
||||||
|
self.restartAttempts = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyNextResult(_ res: WizardNextResult) {
|
||||||
|
let status = wizardStatusString(res.status)
|
||||||
|
self.status = status ?? self.status
|
||||||
|
self.errorMessage = res.error
|
||||||
|
self.currentStep = decodeWizardStep(res.step)
|
||||||
|
if self.currentStep == nil, res.step != nil {
|
||||||
|
onboardingWizardLogger.error("wizard step decode failed")
|
||||||
|
}
|
||||||
|
if res.done { self.currentStep = nil }
|
||||||
|
if res.done || status == "done" || status == "cancelled" || status == "error" {
|
||||||
|
self.sessionId = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyStatusResult(_ res: WizardStatusResult) {
|
||||||
|
self.status = wizardStatusString(res.status) ?? "unknown"
|
||||||
|
self.errorMessage = res.error
|
||||||
|
self.currentStep = nil
|
||||||
|
self.sessionId = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func restartIfSessionLost(error: Error) -> Bool {
|
||||||
|
guard let gatewayError = error as? GatewayResponseError else { return false }
|
||||||
|
guard gatewayError.code == ErrorCode.invalidRequest.rawValue else { return false }
|
||||||
|
let message = gatewayError.message.lowercased()
|
||||||
|
guard message.contains("wizard not found") || message.contains("wizard not running") else { return false }
|
||||||
|
guard let mode = self.lastStartMode, self.restartAttempts < self.maxRestartAttempts else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
self.restartAttempts += 1
|
||||||
|
self.sessionId = nil
|
||||||
|
self.currentStep = nil
|
||||||
|
self.status = nil
|
||||||
|
self.errorMessage = "Wizard session lost. Restarting…"
|
||||||
|
Task { await self.startIfNeeded(mode: mode, workspace: self.lastStartWorkspace) }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shouldSkipWizard() -> Bool {
|
||||||
|
let root = MoltbotConfigFile.loadDict()
|
||||||
|
if let wizard = root["wizard"] as? [String: Any], !wizard.isEmpty {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if let gateway = root["gateway"] as? [String: Any],
|
||||||
|
let auth = gateway["auth"] as? [String: Any]
|
||||||
|
{
|
||||||
|
if let mode = auth["mode"] as? String,
|
||||||
|
!mode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
{
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if let token = auth["token"] as? String,
|
||||||
|
!token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
{
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if let password = auth["password"] as? String,
|
||||||
|
!password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
{
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct OnboardingWizardStepView: View {
|
||||||
|
let step: WizardStep
|
||||||
|
let isSubmitting: Bool
|
||||||
|
let onStepSubmit: (AnyCodable?) -> Void
|
||||||
|
|
||||||
|
@State private var textValue: String
|
||||||
|
@State private var confirmValue: Bool
|
||||||
|
@State private var selectedIndex: Int
|
||||||
|
@State private var selectedIndices: Set<Int>
|
||||||
|
|
||||||
|
private let optionItems: [WizardOptionItem]
|
||||||
|
|
||||||
|
init(step: WizardStep, isSubmitting: Bool, onSubmit: @escaping (AnyCodable?) -> Void) {
|
||||||
|
self.step = step
|
||||||
|
self.isSubmitting = isSubmitting
|
||||||
|
self.onStepSubmit = onSubmit
|
||||||
|
let options = parseWizardOptions(step.options).enumerated().map { index, option in
|
||||||
|
WizardOptionItem(index: index, option: option)
|
||||||
|
}
|
||||||
|
self.optionItems = options
|
||||||
|
let initialText = anyCodableString(step.initialvalue)
|
||||||
|
let initialConfirm = anyCodableBool(step.initialvalue)
|
||||||
|
let initialIndex = options.firstIndex(where: { anyCodableEqual($0.option.value, step.initialvalue) }) ?? 0
|
||||||
|
let initialMulti = Set(
|
||||||
|
options.filter { option in
|
||||||
|
anyCodableArray(step.initialvalue).contains { anyCodableEqual($0, option.option.value) }
|
||||||
|
}.map(\.index))
|
||||||
|
|
||||||
|
_textValue = State(initialValue: initialText)
|
||||||
|
_confirmValue = State(initialValue: initialConfirm)
|
||||||
|
_selectedIndex = State(initialValue: initialIndex)
|
||||||
|
_selectedIndices = State(initialValue: initialMulti)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
if let title = step.title, !title.isEmpty {
|
||||||
|
Text(title)
|
||||||
|
.font(.title2.weight(.semibold))
|
||||||
|
}
|
||||||
|
if let message = step.message, !message.isEmpty {
|
||||||
|
Text(message)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch wizardStepType(self.step) {
|
||||||
|
case "note":
|
||||||
|
EmptyView()
|
||||||
|
case "text":
|
||||||
|
self.textField
|
||||||
|
case "confirm":
|
||||||
|
Toggle("", isOn: self.$confirmValue)
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
case "select":
|
||||||
|
self.selectOptions
|
||||||
|
case "multiselect":
|
||||||
|
self.multiselectOptions
|
||||||
|
case "progress":
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
case "action":
|
||||||
|
EmptyView()
|
||||||
|
default:
|
||||||
|
Text("Unsupported step type")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: self.submit) {
|
||||||
|
Text(wizardStepType(self.step) == "action" ? "Run" : "Continue")
|
||||||
|
.frame(minWidth: 120)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(self.isSubmitting || self.isBlocked)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var textField: some View {
|
||||||
|
let isSensitive = self.step.sensitive == true
|
||||||
|
if isSensitive {
|
||||||
|
SecureField(self.step.placeholder ?? "", text: self.$textValue)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(maxWidth: 360)
|
||||||
|
} else {
|
||||||
|
TextField(self.step.placeholder ?? "", text: self.$textValue)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(maxWidth: 360)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var selectOptions: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
ForEach(self.optionItems, id: \.index) { item in
|
||||||
|
self.selectOptionRow(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var multiselectOptions: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
ForEach(self.optionItems, id: \.index) { item in
|
||||||
|
self.multiselectOptionRow(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func selectOptionRow(_ item: WizardOptionItem) -> some View {
|
||||||
|
Button {
|
||||||
|
self.selectedIndex = item.index
|
||||||
|
} label: {
|
||||||
|
HStack(alignment: .top, spacing: 8) {
|
||||||
|
Image(systemName: self.selectedIndex == item.index ? "largecircle.fill.circle" : "circle")
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(item.option.label)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
if let hint = item.option.hint, !hint.isEmpty {
|
||||||
|
Text(hint)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func multiselectOptionRow(_ item: WizardOptionItem) -> some View {
|
||||||
|
Toggle(isOn: self.bindingForOption(item)) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(item.option.label)
|
||||||
|
if let hint = item.option.hint, !hint.isEmpty {
|
||||||
|
Text(hint)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bindingForOption(_ item: WizardOptionItem) -> Binding<Bool> {
|
||||||
|
Binding(get: {
|
||||||
|
self.selectedIndices.contains(item.index)
|
||||||
|
}, set: { newValue in
|
||||||
|
if newValue {
|
||||||
|
self.selectedIndices.insert(item.index)
|
||||||
|
} else {
|
||||||
|
self.selectedIndices.remove(item.index)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isBlocked: Bool {
|
||||||
|
let type = wizardStepType(step)
|
||||||
|
if type == "select" { return self.optionItems.isEmpty }
|
||||||
|
if type == "multiselect" { return self.optionItems.isEmpty }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func submit() {
|
||||||
|
switch wizardStepType(self.step) {
|
||||||
|
case "note", "progress":
|
||||||
|
self.onStepSubmit(nil)
|
||||||
|
case "text":
|
||||||
|
self.onStepSubmit(AnyCodable(self.textValue))
|
||||||
|
case "confirm":
|
||||||
|
self.onStepSubmit(AnyCodable(self.confirmValue))
|
||||||
|
case "select":
|
||||||
|
guard self.optionItems.indices.contains(self.selectedIndex) else {
|
||||||
|
self.onStepSubmit(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let option = self.optionItems[self.selectedIndex].option
|
||||||
|
self.onStepSubmit(bridgeToLocal(option.value) ?? AnyCodable(option.label))
|
||||||
|
case "multiselect":
|
||||||
|
let values = self.optionItems
|
||||||
|
.filter { self.selectedIndices.contains($0.index) }
|
||||||
|
.map { bridgeToLocal($0.option.value) ?? AnyCodable($0.option.label) }
|
||||||
|
self.onStepSubmit(AnyCodable(values))
|
||||||
|
case "action":
|
||||||
|
self.onStepSubmit(AnyCodable(true))
|
||||||
|
default:
|
||||||
|
self.onStepSubmit(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct WizardOptionItem: Identifiable {
|
||||||
|
let index: Int
|
||||||
|
let option: WizardOption
|
||||||
|
|
||||||
|
var id: Int { self.index }
|
||||||
|
}
|
||||||
130
apps/macos/Sources/Moltbot/PeekabooBridgeHostCoordinator.swift
Normal file
130
apps/macos/Sources/Moltbot/PeekabooBridgeHostCoordinator.swift
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
import PeekabooAutomationKit
|
||||||
|
import PeekabooBridge
|
||||||
|
import PeekabooFoundation
|
||||||
|
import Security
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class PeekabooBridgeHostCoordinator {
|
||||||
|
static let shared = PeekabooBridgeHostCoordinator()
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "bot.molt", category: "PeekabooBridge")
|
||||||
|
|
||||||
|
private var host: PeekabooBridgeHost?
|
||||||
|
private var services: MoltbotPeekabooBridgeServices?
|
||||||
|
|
||||||
|
func setEnabled(_ enabled: Bool) async {
|
||||||
|
if enabled {
|
||||||
|
await self.startIfNeeded()
|
||||||
|
} else {
|
||||||
|
await self.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() async {
|
||||||
|
guard let host else { return }
|
||||||
|
await host.stop()
|
||||||
|
self.host = nil
|
||||||
|
self.services = nil
|
||||||
|
self.logger.info("PeekabooBridge host stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startIfNeeded() async {
|
||||||
|
guard self.host == nil else { return }
|
||||||
|
|
||||||
|
var allowlistedTeamIDs: Set<String> = ["Y5PE65HELJ"]
|
||||||
|
if let teamID = Self.currentTeamID() {
|
||||||
|
allowlistedTeamIDs.insert(teamID)
|
||||||
|
}
|
||||||
|
let allowlistedBundles: Set<String> = []
|
||||||
|
|
||||||
|
let services = MoltbotPeekabooBridgeServices()
|
||||||
|
let server = PeekabooBridgeServer(
|
||||||
|
services: services,
|
||||||
|
hostKind: .gui,
|
||||||
|
allowlistedTeams: allowlistedTeamIDs,
|
||||||
|
allowlistedBundles: allowlistedBundles)
|
||||||
|
|
||||||
|
let host = PeekabooBridgeHost(
|
||||||
|
socketPath: PeekabooBridgeConstants.clawdbotSocketPath,
|
||||||
|
server: server,
|
||||||
|
allowedTeamIDs: allowlistedTeamIDs,
|
||||||
|
requestTimeoutSec: 10)
|
||||||
|
|
||||||
|
self.services = services
|
||||||
|
self.host = host
|
||||||
|
|
||||||
|
await host.start()
|
||||||
|
self.logger
|
||||||
|
.info("PeekabooBridge host started at \(PeekabooBridgeConstants.clawdbotSocketPath, privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func currentTeamID() -> String? {
|
||||||
|
var code: SecCode?
|
||||||
|
guard SecCodeCopySelf(SecCSFlags(), &code) == errSecSuccess,
|
||||||
|
let code
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var staticCode: SecStaticCode?
|
||||||
|
guard SecCodeCopyStaticCode(code, SecCSFlags(), &staticCode) == errSecSuccess,
|
||||||
|
let staticCode
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var infoCF: CFDictionary?
|
||||||
|
guard SecCodeCopySigningInformation(
|
||||||
|
staticCode,
|
||||||
|
SecCSFlags(rawValue: kSecCSSigningInformation),
|
||||||
|
&infoCF) == errSecSuccess,
|
||||||
|
let info = infoCF as? [String: Any]
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return info[kSecCodeInfoTeamIdentifier as String] as? String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class MoltbotPeekabooBridgeServices: PeekabooBridgeServiceProviding {
|
||||||
|
let permissions: PermissionsService
|
||||||
|
let screenCapture: any ScreenCaptureServiceProtocol
|
||||||
|
let automation: any UIAutomationServiceProtocol
|
||||||
|
let windows: any WindowManagementServiceProtocol
|
||||||
|
let applications: any ApplicationServiceProtocol
|
||||||
|
let menu: any MenuServiceProtocol
|
||||||
|
let dock: any DockServiceProtocol
|
||||||
|
let dialogs: any DialogServiceProtocol
|
||||||
|
let snapshots: any SnapshotManagerProtocol
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let logging = LoggingService(subsystem: "bot.molt.peekaboo")
|
||||||
|
let feedbackClient: any AutomationFeedbackClient = NoopAutomationFeedbackClient()
|
||||||
|
|
||||||
|
let snapshots = InMemorySnapshotManager(options: .init(
|
||||||
|
snapshotValidityWindow: 600,
|
||||||
|
maxSnapshots: 50,
|
||||||
|
deleteArtifactsOnCleanup: false))
|
||||||
|
let applications = ApplicationService(feedbackClient: feedbackClient)
|
||||||
|
|
||||||
|
let screenCapture = ScreenCaptureService(loggingService: logging)
|
||||||
|
|
||||||
|
self.permissions = PermissionsService()
|
||||||
|
self.snapshots = snapshots
|
||||||
|
self.applications = applications
|
||||||
|
self.screenCapture = screenCapture
|
||||||
|
self.automation = UIAutomationService(
|
||||||
|
snapshotManager: snapshots,
|
||||||
|
loggingService: logging,
|
||||||
|
searchPolicy: .balanced,
|
||||||
|
feedbackClient: feedbackClient)
|
||||||
|
self.windows = WindowManagementService(applicationService: applications, feedbackClient: feedbackClient)
|
||||||
|
self.menu = MenuService(applicationService: applications, feedbackClient: feedbackClient)
|
||||||
|
self.dock = DockService(feedbackClient: feedbackClient)
|
||||||
|
self.dialogs = DialogService(feedbackClient: feedbackClient)
|
||||||
|
}
|
||||||
|
}
|
||||||
506
apps/macos/Sources/Moltbot/PermissionManager.swift
Normal file
506
apps/macos/Sources/Moltbot/PermissionManager.swift
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
import AppKit
|
||||||
|
import ApplicationServices
|
||||||
|
import AVFoundation
|
||||||
|
import MoltbotIPC
|
||||||
|
import CoreGraphics
|
||||||
|
import CoreLocation
|
||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import Speech
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
enum PermissionManager {
|
||||||
|
static func isLocationAuthorized(status: CLAuthorizationStatus, requireAlways: Bool) -> Bool {
|
||||||
|
if requireAlways { return status == .authorizedAlways }
|
||||||
|
switch status {
|
||||||
|
case .authorizedAlways, .authorizedWhenInUse:
|
||||||
|
return true
|
||||||
|
case .authorized: // deprecated, but still shows up on some macOS versions
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func ensure(_ caps: [Capability], interactive: Bool) async -> [Capability: Bool] {
|
||||||
|
var results: [Capability: Bool] = [:]
|
||||||
|
for cap in caps {
|
||||||
|
results[cap] = await self.ensureCapability(cap, interactive: interactive)
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func ensureCapability(_ cap: Capability, interactive: Bool) async -> Bool {
|
||||||
|
switch cap {
|
||||||
|
case .notifications:
|
||||||
|
await self.ensureNotifications(interactive: interactive)
|
||||||
|
case .appleScript:
|
||||||
|
await self.ensureAppleScript(interactive: interactive)
|
||||||
|
case .accessibility:
|
||||||
|
await self.ensureAccessibility(interactive: interactive)
|
||||||
|
case .screenRecording:
|
||||||
|
await self.ensureScreenRecording(interactive: interactive)
|
||||||
|
case .microphone:
|
||||||
|
await self.ensureMicrophone(interactive: interactive)
|
||||||
|
case .speechRecognition:
|
||||||
|
await self.ensureSpeechRecognition(interactive: interactive)
|
||||||
|
case .camera:
|
||||||
|
await self.ensureCamera(interactive: interactive)
|
||||||
|
case .location:
|
||||||
|
await self.ensureLocation(interactive: interactive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func ensureNotifications(interactive: Bool) async -> Bool {
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
let settings = await center.notificationSettings()
|
||||||
|
|
||||||
|
switch settings.authorizationStatus {
|
||||||
|
case .authorized, .provisional, .ephemeral:
|
||||||
|
return true
|
||||||
|
case .notDetermined:
|
||||||
|
guard interactive else { return false }
|
||||||
|
let granted = await (try? center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
|
||||||
|
let updated = await center.notificationSettings()
|
||||||
|
return granted &&
|
||||||
|
(updated.authorizationStatus == .authorized || updated.authorizationStatus == .provisional)
|
||||||
|
case .denied:
|
||||||
|
if interactive {
|
||||||
|
NotificationPermissionHelper.openSettings()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
@unknown default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func ensureAppleScript(interactive: Bool) async -> Bool {
|
||||||
|
let granted = await MainActor.run { AppleScriptPermission.isAuthorized() }
|
||||||
|
if interactive, !granted {
|
||||||
|
await AppleScriptPermission.requestAuthorization()
|
||||||
|
}
|
||||||
|
return await MainActor.run { AppleScriptPermission.isAuthorized() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func ensureAccessibility(interactive: Bool) async -> Bool {
|
||||||
|
let trusted = await MainActor.run { AXIsProcessTrusted() }
|
||||||
|
if interactive, !trusted {
|
||||||
|
await MainActor.run {
|
||||||
|
let opts: NSDictionary = ["AXTrustedCheckOptionPrompt": true]
|
||||||
|
_ = AXIsProcessTrustedWithOptions(opts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return await MainActor.run { AXIsProcessTrusted() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func ensureScreenRecording(interactive: Bool) async -> Bool {
|
||||||
|
let granted = ScreenRecordingProbe.isAuthorized()
|
||||||
|
if interactive, !granted {
|
||||||
|
await ScreenRecordingProbe.requestAuthorization()
|
||||||
|
}
|
||||||
|
return ScreenRecordingProbe.isAuthorized()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func ensureMicrophone(interactive: Bool) async -> Bool {
|
||||||
|
let status = AVCaptureDevice.authorizationStatus(for: .audio)
|
||||||
|
switch status {
|
||||||
|
case .authorized:
|
||||||
|
return true
|
||||||
|
case .notDetermined:
|
||||||
|
guard interactive else { return false }
|
||||||
|
return await AVCaptureDevice.requestAccess(for: .audio)
|
||||||
|
case .denied, .restricted:
|
||||||
|
if interactive {
|
||||||
|
MicrophonePermissionHelper.openSettings()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
@unknown default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func ensureSpeechRecognition(interactive: Bool) async -> Bool {
|
||||||
|
let status = SFSpeechRecognizer.authorizationStatus()
|
||||||
|
if status == .notDetermined, interactive {
|
||||||
|
await withUnsafeContinuation { (cont: UnsafeContinuation<Void, Never>) in
|
||||||
|
SFSpeechRecognizer.requestAuthorization { _ in
|
||||||
|
DispatchQueue.main.async { cont.resume() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SFSpeechRecognizer.authorizationStatus() == .authorized
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func ensureCamera(interactive: Bool) async -> Bool {
|
||||||
|
let status = AVCaptureDevice.authorizationStatus(for: .video)
|
||||||
|
switch status {
|
||||||
|
case .authorized:
|
||||||
|
return true
|
||||||
|
case .notDetermined:
|
||||||
|
guard interactive else { return false }
|
||||||
|
return await AVCaptureDevice.requestAccess(for: .video)
|
||||||
|
case .denied, .restricted:
|
||||||
|
if interactive {
|
||||||
|
CameraPermissionHelper.openSettings()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
@unknown default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func ensureLocation(interactive: Bool) async -> Bool {
|
||||||
|
guard CLLocationManager.locationServicesEnabled() else {
|
||||||
|
if interactive {
|
||||||
|
await MainActor.run { LocationPermissionHelper.openSettings() }
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let status = CLLocationManager().authorizationStatus
|
||||||
|
switch status {
|
||||||
|
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
||||||
|
return true
|
||||||
|
case .notDetermined:
|
||||||
|
guard interactive else { return false }
|
||||||
|
let updated = await LocationPermissionRequester.shared.request(always: false)
|
||||||
|
return self.isLocationAuthorized(status: updated, requireAlways: false)
|
||||||
|
case .denied, .restricted:
|
||||||
|
if interactive {
|
||||||
|
await MainActor.run { LocationPermissionHelper.openSettings() }
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
@unknown default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func voiceWakePermissionsGranted() -> Bool {
|
||||||
|
let mic = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
|
||||||
|
let speech = SFSpeechRecognizer.authorizationStatus() == .authorized
|
||||||
|
return mic && speech
|
||||||
|
}
|
||||||
|
|
||||||
|
static func ensureVoiceWakePermissions(interactive: Bool) async -> Bool {
|
||||||
|
let results = await self.ensure([.microphone, .speechRecognition], interactive: interactive)
|
||||||
|
return results[.microphone] == true && results[.speechRecognition] == true
|
||||||
|
}
|
||||||
|
|
||||||
|
static func status(_ caps: [Capability] = Capability.allCases) async -> [Capability: Bool] {
|
||||||
|
var results: [Capability: Bool] = [:]
|
||||||
|
for cap in caps {
|
||||||
|
switch cap {
|
||||||
|
case .notifications:
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
let settings = await center.notificationSettings()
|
||||||
|
results[cap] = settings.authorizationStatus == .authorized
|
||||||
|
|| settings.authorizationStatus == .provisional
|
||||||
|
|
||||||
|
case .appleScript:
|
||||||
|
results[cap] = await MainActor.run { AppleScriptPermission.isAuthorized() }
|
||||||
|
|
||||||
|
case .accessibility:
|
||||||
|
results[cap] = await MainActor.run { AXIsProcessTrusted() }
|
||||||
|
|
||||||
|
case .screenRecording:
|
||||||
|
if #available(macOS 10.15, *) {
|
||||||
|
results[cap] = CGPreflightScreenCaptureAccess()
|
||||||
|
} else {
|
||||||
|
results[cap] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
case .microphone:
|
||||||
|
results[cap] = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
|
||||||
|
|
||||||
|
case .speechRecognition:
|
||||||
|
results[cap] = SFSpeechRecognizer.authorizationStatus() == .authorized
|
||||||
|
|
||||||
|
case .camera:
|
||||||
|
results[cap] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
|
||||||
|
|
||||||
|
case .location:
|
||||||
|
let status = CLLocationManager().authorizationStatus
|
||||||
|
results[cap] = CLLocationManager.locationServicesEnabled()
|
||||||
|
&& self.isLocationAuthorized(status: status, requireAlways: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum NotificationPermissionHelper {
|
||||||
|
static func openSettings() {
|
||||||
|
let candidates = [
|
||||||
|
"x-apple.systempreferences:com.apple.Notifications-Settings.extension",
|
||||||
|
"x-apple.systempreferences:com.apple.preference.notifications",
|
||||||
|
]
|
||||||
|
|
||||||
|
for candidate in candidates {
|
||||||
|
if let url = URL(string: candidate), NSWorkspace.shared.open(url) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MicrophonePermissionHelper {
|
||||||
|
static func openSettings() {
|
||||||
|
let candidates = [
|
||||||
|
"x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone",
|
||||||
|
"x-apple.systempreferences:com.apple.preference.security",
|
||||||
|
]
|
||||||
|
|
||||||
|
for candidate in candidates {
|
||||||
|
if let url = URL(string: candidate), NSWorkspace.shared.open(url) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CameraPermissionHelper {
|
||||||
|
static func openSettings() {
|
||||||
|
let candidates = [
|
||||||
|
"x-apple.systempreferences:com.apple.preference.security?Privacy_Camera",
|
||||||
|
"x-apple.systempreferences:com.apple.preference.security",
|
||||||
|
]
|
||||||
|
|
||||||
|
for candidate in candidates {
|
||||||
|
if let url = URL(string: candidate), NSWorkspace.shared.open(url) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LocationPermissionHelper {
|
||||||
|
static func openSettings() {
|
||||||
|
let candidates = [
|
||||||
|
"x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices",
|
||||||
|
"x-apple.systempreferences:com.apple.preference.security",
|
||||||
|
]
|
||||||
|
|
||||||
|
for candidate in candidates {
|
||||||
|
if let url = URL(string: candidate), NSWorkspace.shared.open(url) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class LocationPermissionRequester: NSObject, CLLocationManagerDelegate {
|
||||||
|
static let shared = LocationPermissionRequester()
|
||||||
|
private let manager = CLLocationManager()
|
||||||
|
private var continuation: CheckedContinuation<CLAuthorizationStatus, Never>?
|
||||||
|
private var timeoutTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
self.manager.delegate = self
|
||||||
|
}
|
||||||
|
|
||||||
|
func request(always: Bool) async -> CLAuthorizationStatus {
|
||||||
|
let current = self.manager.authorizationStatus
|
||||||
|
if PermissionManager.isLocationAuthorized(status: current, requireAlways: always) {
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
return await withCheckedContinuation { cont in
|
||||||
|
self.continuation = cont
|
||||||
|
self.timeoutTask?.cancel()
|
||||||
|
self.timeoutTask = Task { [weak self] in
|
||||||
|
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
guard self.continuation != nil else { return }
|
||||||
|
LocationPermissionHelper.openSettings()
|
||||||
|
self.finish(status: self.manager.authorizationStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if always {
|
||||||
|
self.manager.requestAlwaysAuthorization()
|
||||||
|
} else {
|
||||||
|
self.manager.requestWhenInUseAuthorization()
|
||||||
|
}
|
||||||
|
|
||||||
|
// On macOS, requesting an actual fix makes the prompt more reliable.
|
||||||
|
self.manager.requestLocation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finish(status: CLAuthorizationStatus) {
|
||||||
|
self.timeoutTask?.cancel()
|
||||||
|
self.timeoutTask = nil
|
||||||
|
guard let cont = self.continuation else { return }
|
||||||
|
self.continuation = nil
|
||||||
|
cont.resume(returning: status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// nonisolated for Swift 6 strict concurrency compatibility
|
||||||
|
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||||
|
let status = manager.authorizationStatus
|
||||||
|
Task { @MainActor in
|
||||||
|
self.finish(status: status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy callback (still used on some macOS versions / configurations).
|
||||||
|
nonisolated func locationManager(
|
||||||
|
_ manager: CLLocationManager,
|
||||||
|
didChangeAuthorization status: CLAuthorizationStatus)
|
||||||
|
{
|
||||||
|
Task { @MainActor in
|
||||||
|
self.finish(status: status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||||
|
let status = manager.authorizationStatus
|
||||||
|
Task { @MainActor in
|
||||||
|
if status == .denied || status == .restricted {
|
||||||
|
LocationPermissionHelper.openSettings()
|
||||||
|
}
|
||||||
|
self.finish(status: status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||||
|
let status = manager.authorizationStatus
|
||||||
|
Task { @MainActor in
|
||||||
|
self.finish(status: status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AppleScriptPermission {
|
||||||
|
private static let logger = Logger(subsystem: "bot.molt", category: "AppleScriptPermission")
|
||||||
|
|
||||||
|
/// Sends a benign AppleScript to Terminal to verify Automation permission.
|
||||||
|
@MainActor
|
||||||
|
static func isAuthorized() -> Bool {
|
||||||
|
let script = """
|
||||||
|
tell application "Terminal"
|
||||||
|
return "moltbot-ok"
|
||||||
|
end tell
|
||||||
|
"""
|
||||||
|
|
||||||
|
var error: NSDictionary?
|
||||||
|
let appleScript = NSAppleScript(source: script)
|
||||||
|
let result = appleScript?.executeAndReturnError(&error)
|
||||||
|
|
||||||
|
if let error, let code = error["NSAppleScriptErrorNumber"] as? Int {
|
||||||
|
if code == -1743 { // errAEEventWouldRequireUserConsent
|
||||||
|
Self.logger.debug("AppleScript permission denied (-1743)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
Self.logger.debug("AppleScript check failed with code \(code)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return result != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Triggers the TCC prompt and opens System Settings → Privacy & Security → Automation.
|
||||||
|
@MainActor
|
||||||
|
static func requestAuthorization() async {
|
||||||
|
_ = self.isAuthorized() // first attempt triggers the dialog if not granted
|
||||||
|
|
||||||
|
// Open the Automation pane to help the user if the prompt was dismissed.
|
||||||
|
let urlStrings = [
|
||||||
|
"x-apple.systempreferences:com.apple.preference.security?Privacy_Automation",
|
||||||
|
"x-apple.systempreferences:com.apple.preference.security",
|
||||||
|
]
|
||||||
|
|
||||||
|
for candidate in urlStrings {
|
||||||
|
if let url = URL(string: candidate), NSWorkspace.shared.open(url) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class PermissionMonitor {
|
||||||
|
static let shared = PermissionMonitor()
|
||||||
|
|
||||||
|
private(set) var status: [Capability: Bool] = [:]
|
||||||
|
|
||||||
|
private var monitorTimer: Timer?
|
||||||
|
private var isChecking = false
|
||||||
|
private var registrations = 0
|
||||||
|
private var lastCheck: Date?
|
||||||
|
private let minimumCheckInterval: TimeInterval = 0.5
|
||||||
|
|
||||||
|
func register() {
|
||||||
|
self.registrations += 1
|
||||||
|
if self.registrations == 1 {
|
||||||
|
self.startMonitoring()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func unregister() {
|
||||||
|
guard self.registrations > 0 else { return }
|
||||||
|
self.registrations -= 1
|
||||||
|
if self.registrations == 0 {
|
||||||
|
self.stopMonitoring()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshNow() async {
|
||||||
|
await self.checkStatus(force: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startMonitoring() {
|
||||||
|
Task { await self.checkStatus(force: true) }
|
||||||
|
|
||||||
|
if ProcessInfo.processInfo.isRunningTests {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.monitorTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
await self.checkStatus(force: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopMonitoring() {
|
||||||
|
self.monitorTimer?.invalidate()
|
||||||
|
self.monitorTimer = nil
|
||||||
|
self.lastCheck = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func checkStatus(force: Bool) async {
|
||||||
|
if self.isChecking { return }
|
||||||
|
let now = Date()
|
||||||
|
if !force, let lastCheck, now.timeIntervalSince(lastCheck) < self.minimumCheckInterval {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.isChecking = true
|
||||||
|
|
||||||
|
let latest = await PermissionManager.status()
|
||||||
|
if latest != self.status {
|
||||||
|
self.status = latest
|
||||||
|
}
|
||||||
|
self.lastCheck = Date()
|
||||||
|
|
||||||
|
self.isChecking = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ScreenRecordingProbe {
|
||||||
|
static func isAuthorized() -> Bool {
|
||||||
|
if #available(macOS 10.15, *) {
|
||||||
|
return CGPreflightScreenCaptureAccess()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
static func requestAuthorization() async {
|
||||||
|
if #available(macOS 10.15, *) {
|
||||||
|
_ = CGRequestScreenCaptureAccess()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
418
apps/macos/Sources/Moltbot/PortGuardian.swift
Normal file
418
apps/macos/Sources/Moltbot/PortGuardian.swift
Normal file
@@ -0,0 +1,418 @@
|
|||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
#if canImport(Darwin)
|
||||||
|
import Darwin
|
||||||
|
#endif
|
||||||
|
|
||||||
|
actor PortGuardian {
|
||||||
|
static let shared = PortGuardian()
|
||||||
|
|
||||||
|
struct Record: Codable {
|
||||||
|
let port: Int
|
||||||
|
let pid: Int32
|
||||||
|
let command: String
|
||||||
|
let mode: String
|
||||||
|
let timestamp: TimeInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Descriptor: Sendable {
|
||||||
|
let pid: Int32
|
||||||
|
let command: String
|
||||||
|
let executablePath: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private var records: [Record] = []
|
||||||
|
private let logger = Logger(subsystem: "bot.molt", category: "portguard")
|
||||||
|
private nonisolated static let appSupportDir: URL = {
|
||||||
|
let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||||
|
return base.appendingPathComponent("Moltbot", isDirectory: true)
|
||||||
|
}()
|
||||||
|
|
||||||
|
private nonisolated static var recordPath: URL {
|
||||||
|
self.appSupportDir.appendingPathComponent("port-guard.json", isDirectory: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
self.records = Self.loadRecords(from: Self.recordPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sweep(mode: AppState.ConnectionMode) async {
|
||||||
|
self.logger.info("port sweep starting (mode=\(mode.rawValue, privacy: .public))")
|
||||||
|
guard mode != .unconfigured else {
|
||||||
|
self.logger.info("port sweep skipped (mode=unconfigured)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let ports = [GatewayEnvironment.gatewayPort()]
|
||||||
|
for port in ports {
|
||||||
|
let listeners = await self.listeners(on: port)
|
||||||
|
guard !listeners.isEmpty else { continue }
|
||||||
|
for listener in listeners {
|
||||||
|
if self.isExpected(listener, port: port, mode: mode) {
|
||||||
|
let message = """
|
||||||
|
port \(port) already served by expected \(listener.command)
|
||||||
|
(pid \(listener.pid)) — keeping
|
||||||
|
"""
|
||||||
|
self.logger.info("\(message, privacy: .public)")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let killed = await self.kill(listener.pid)
|
||||||
|
if killed {
|
||||||
|
let message = """
|
||||||
|
port \(port) was held by \(listener.command)
|
||||||
|
(pid \(listener.pid)); terminated
|
||||||
|
"""
|
||||||
|
self.logger.error("\(message, privacy: .public)")
|
||||||
|
} else {
|
||||||
|
self.logger.error("failed to terminate pid \(listener.pid) on port \(port, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.logger.info("port sweep done")
|
||||||
|
}
|
||||||
|
|
||||||
|
func record(port: Int, pid: Int32, command: String, mode: AppState.ConnectionMode) async {
|
||||||
|
try? FileManager().createDirectory(at: Self.appSupportDir, withIntermediateDirectories: true)
|
||||||
|
self.records.removeAll { $0.pid == pid }
|
||||||
|
self.records.append(
|
||||||
|
Record(
|
||||||
|
port: port,
|
||||||
|
pid: pid,
|
||||||
|
command: command,
|
||||||
|
mode: mode.rawValue,
|
||||||
|
timestamp: Date().timeIntervalSince1970))
|
||||||
|
self.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeRecord(pid: Int32) {
|
||||||
|
let before = self.records.count
|
||||||
|
self.records.removeAll { $0.pid == pid }
|
||||||
|
if self.records.count != before {
|
||||||
|
self.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PortReport: Identifiable {
|
||||||
|
enum Status {
|
||||||
|
case ok(String)
|
||||||
|
case missing(String)
|
||||||
|
case interference(String, offenders: [ReportListener])
|
||||||
|
}
|
||||||
|
|
||||||
|
let port: Int
|
||||||
|
let expected: String
|
||||||
|
let status: Status
|
||||||
|
let listeners: [ReportListener]
|
||||||
|
|
||||||
|
var id: Int { self.port }
|
||||||
|
|
||||||
|
var offenders: [ReportListener] {
|
||||||
|
if case let .interference(_, offenders) = self.status { return offenders }
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
var summary: String {
|
||||||
|
switch self.status {
|
||||||
|
case let .ok(text): text
|
||||||
|
case let .missing(text): text
|
||||||
|
case let .interference(text, _): text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func describe(port: Int) async -> Descriptor? {
|
||||||
|
guard let listener = await self.listeners(on: port).first else { return nil }
|
||||||
|
let path = Self.executablePath(for: listener.pid)
|
||||||
|
return Descriptor(pid: listener.pid, command: listener.command, executablePath: path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internals
|
||||||
|
|
||||||
|
private struct Listener {
|
||||||
|
let pid: Int32
|
||||||
|
let command: String
|
||||||
|
let fullCommand: String
|
||||||
|
let user: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ReportListener: Identifiable {
|
||||||
|
let pid: Int32
|
||||||
|
let command: String
|
||||||
|
let fullCommand: String
|
||||||
|
let user: String?
|
||||||
|
let expected: Bool
|
||||||
|
|
||||||
|
var id: Int32 { self.pid }
|
||||||
|
}
|
||||||
|
|
||||||
|
func diagnose(mode: AppState.ConnectionMode) async -> [PortReport] {
|
||||||
|
if mode == .unconfigured {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
let ports = [GatewayEnvironment.gatewayPort()]
|
||||||
|
var reports: [PortReport] = []
|
||||||
|
|
||||||
|
for port in ports {
|
||||||
|
let listeners = await self.listeners(on: port)
|
||||||
|
let tunnelHealthy = await self.probeGatewayHealthIfNeeded(
|
||||||
|
port: port,
|
||||||
|
mode: mode,
|
||||||
|
listeners: listeners)
|
||||||
|
reports.append(Self.buildReport(
|
||||||
|
port: port,
|
||||||
|
listeners: listeners,
|
||||||
|
mode: mode,
|
||||||
|
tunnelHealthy: tunnelHealthy))
|
||||||
|
}
|
||||||
|
|
||||||
|
return reports
|
||||||
|
}
|
||||||
|
|
||||||
|
func probeGatewayHealth(port: Int, timeout: TimeInterval = 2.0) async -> Bool {
|
||||||
|
let url = URL(string: "http://127.0.0.1:\(port)/")!
|
||||||
|
let config = URLSessionConfiguration.ephemeral
|
||||||
|
config.timeoutIntervalForRequest = timeout
|
||||||
|
config.timeoutIntervalForResource = timeout
|
||||||
|
let session = URLSession(configuration: config)
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.cachePolicy = .reloadIgnoringLocalCacheData
|
||||||
|
request.timeoutInterval = timeout
|
||||||
|
do {
|
||||||
|
let (_, response) = try await session.data(for: request)
|
||||||
|
return response is HTTPURLResponse
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isListening(port: Int, pid: Int32? = nil) async -> Bool {
|
||||||
|
let listeners = await self.listeners(on: port)
|
||||||
|
if let pid {
|
||||||
|
return listeners.contains(where: { $0.pid == pid })
|
||||||
|
}
|
||||||
|
return !listeners.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
private func listeners(on port: Int) async -> [Listener] {
|
||||||
|
let res = await ShellExecutor.run(
|
||||||
|
command: ["lsof", "-nP", "-iTCP:\(port)", "-sTCP:LISTEN", "-Fpcn"],
|
||||||
|
cwd: nil,
|
||||||
|
env: nil,
|
||||||
|
timeout: 5)
|
||||||
|
guard res.ok, let data = res.payload, !data.isEmpty else { return [] }
|
||||||
|
let text = String(data: data, encoding: .utf8) ?? ""
|
||||||
|
return Self.parseListeners(from: text)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func readFullCommand(pid: Int32) -> String? {
|
||||||
|
let proc = Process()
|
||||||
|
proc.executableURL = URL(fileURLWithPath: "/bin/ps")
|
||||||
|
proc.arguments = ["-p", "\(pid)", "-o", "command="]
|
||||||
|
let pipe = Pipe()
|
||||||
|
proc.standardOutput = pipe
|
||||||
|
proc.standardError = Pipe()
|
||||||
|
do {
|
||||||
|
let data = try proc.runAndReadToEnd(from: pipe)
|
||||||
|
guard !data.isEmpty else { return nil }
|
||||||
|
return String(data: data, encoding: .utf8)?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseListeners(from text: String) -> [Listener] {
|
||||||
|
var listeners: [Listener] = []
|
||||||
|
var currentPid: Int32?
|
||||||
|
var currentCmd: String?
|
||||||
|
var currentUser: String?
|
||||||
|
|
||||||
|
func flush() {
|
||||||
|
if let pid = currentPid, let cmd = currentCmd {
|
||||||
|
let full = Self.readFullCommand(pid: pid) ?? cmd
|
||||||
|
listeners.append(Listener(pid: pid, command: cmd, fullCommand: full, user: currentUser))
|
||||||
|
}
|
||||||
|
currentPid = nil
|
||||||
|
currentCmd = nil
|
||||||
|
currentUser = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for line in text.split(separator: "\n") {
|
||||||
|
guard let prefix = line.first else { continue }
|
||||||
|
let value = String(line.dropFirst())
|
||||||
|
switch prefix {
|
||||||
|
case "p":
|
||||||
|
flush()
|
||||||
|
currentPid = Int32(value) ?? 0
|
||||||
|
case "c":
|
||||||
|
currentCmd = value
|
||||||
|
case "u":
|
||||||
|
currentUser = value
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
return listeners
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func buildReport(
|
||||||
|
port: Int,
|
||||||
|
listeners: [Listener],
|
||||||
|
mode: AppState.ConnectionMode,
|
||||||
|
tunnelHealthy: Bool?) -> PortReport
|
||||||
|
{
|
||||||
|
let expectedDesc: String
|
||||||
|
let okPredicate: (Listener) -> Bool
|
||||||
|
let expectedCommands = ["node", "moltbot", "tsx", "pnpm", "bun"]
|
||||||
|
|
||||||
|
switch mode {
|
||||||
|
case .remote:
|
||||||
|
expectedDesc = "SSH tunnel to remote gateway"
|
||||||
|
okPredicate = { $0.command.lowercased().contains("ssh") }
|
||||||
|
case .local:
|
||||||
|
expectedDesc = "Gateway websocket (node/tsx)"
|
||||||
|
okPredicate = { listener in
|
||||||
|
let c = listener.command.lowercased()
|
||||||
|
return expectedCommands.contains { c.contains($0) }
|
||||||
|
}
|
||||||
|
case .unconfigured:
|
||||||
|
expectedDesc = "Gateway not configured"
|
||||||
|
okPredicate = { _ in false }
|
||||||
|
}
|
||||||
|
|
||||||
|
if listeners.isEmpty {
|
||||||
|
let text = "Nothing is listening on \(port) (\(expectedDesc))."
|
||||||
|
return .init(port: port, expected: expectedDesc, status: .missing(text), listeners: [])
|
||||||
|
}
|
||||||
|
|
||||||
|
let tunnelUnhealthy =
|
||||||
|
mode == .remote && port == GatewayEnvironment.gatewayPort() && tunnelHealthy == false
|
||||||
|
let reportListeners = listeners.map { listener in
|
||||||
|
var expected = okPredicate(listener)
|
||||||
|
if tunnelUnhealthy, expected { expected = false }
|
||||||
|
return ReportListener(
|
||||||
|
pid: listener.pid,
|
||||||
|
command: listener.command,
|
||||||
|
fullCommand: listener.fullCommand,
|
||||||
|
user: listener.user,
|
||||||
|
expected: expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
let offenders = reportListeners.filter { !$0.expected }
|
||||||
|
if tunnelUnhealthy {
|
||||||
|
let list = listeners.map { "\($0.command) (\($0.pid))" }.joined(separator: ", ")
|
||||||
|
let reason = "Port \(port) is served by \(list), but the SSH tunnel is unhealthy."
|
||||||
|
return .init(
|
||||||
|
port: port,
|
||||||
|
expected: expectedDesc,
|
||||||
|
status: .interference(reason, offenders: offenders),
|
||||||
|
listeners: reportListeners)
|
||||||
|
}
|
||||||
|
if offenders.isEmpty {
|
||||||
|
let list = listeners.map { "\($0.command) (\($0.pid))" }.joined(separator: ", ")
|
||||||
|
let okText = "Port \(port) is served by \(list)."
|
||||||
|
return .init(
|
||||||
|
port: port,
|
||||||
|
expected: expectedDesc,
|
||||||
|
status: .ok(okText),
|
||||||
|
listeners: reportListeners)
|
||||||
|
}
|
||||||
|
|
||||||
|
let list = offenders.map { "\($0.command) (\($0.pid))" }.joined(separator: ", ")
|
||||||
|
let reason = "Port \(port) is held by \(list), expected \(expectedDesc)."
|
||||||
|
return .init(
|
||||||
|
port: port,
|
||||||
|
expected: expectedDesc,
|
||||||
|
status: .interference(reason, offenders: offenders),
|
||||||
|
listeners: reportListeners)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func executablePath(for pid: Int32) -> String? {
|
||||||
|
#if canImport(Darwin)
|
||||||
|
var buffer = [CChar](repeating: 0, count: Int(PATH_MAX))
|
||||||
|
let length = proc_pidpath(pid, &buffer, UInt32(buffer.count))
|
||||||
|
guard length > 0 else { return nil }
|
||||||
|
// Drop trailing null and decode as UTF-8.
|
||||||
|
let trimmed = buffer.prefix { $0 != 0 }
|
||||||
|
let bytes = trimmed.map { UInt8(bitPattern: $0) }
|
||||||
|
return String(bytes: bytes, encoding: .utf8)
|
||||||
|
#else
|
||||||
|
return nil
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func kill(_ pid: Int32) async -> Bool {
|
||||||
|
let term = await ShellExecutor.run(command: ["kill", "-TERM", "\(pid)"], cwd: nil, env: nil, timeout: 2)
|
||||||
|
if term.ok { return true }
|
||||||
|
let sigkill = await ShellExecutor.run(command: ["kill", "-KILL", "\(pid)"], cwd: nil, env: nil, timeout: 2)
|
||||||
|
return sigkill.ok
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isExpected(_ listener: Listener, port: Int, mode: AppState.ConnectionMode) -> Bool {
|
||||||
|
let cmd = listener.command.lowercased()
|
||||||
|
let full = listener.fullCommand.lowercased()
|
||||||
|
switch mode {
|
||||||
|
case .remote:
|
||||||
|
// Remote mode expects an SSH tunnel for the gateway WebSocket port.
|
||||||
|
if port == GatewayEnvironment.gatewayPort() { return cmd.contains("ssh") }
|
||||||
|
return false
|
||||||
|
case .local:
|
||||||
|
// The gateway daemon may listen as `moltbot` or as its runtime (`node`, `bun`, etc).
|
||||||
|
if full.contains("gateway-daemon") { return true }
|
||||||
|
// If args are unavailable, treat a moltbot listener as expected.
|
||||||
|
if cmd.contains("moltbot"), full == cmd { return true }
|
||||||
|
return false
|
||||||
|
case .unconfigured:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func probeGatewayHealthIfNeeded(
|
||||||
|
port: Int,
|
||||||
|
mode: AppState.ConnectionMode,
|
||||||
|
listeners: [Listener]) async -> Bool?
|
||||||
|
{
|
||||||
|
guard mode == .remote, port == GatewayEnvironment.gatewayPort(), !listeners.isEmpty else { return nil }
|
||||||
|
let hasSsh = listeners.contains { $0.command.lowercased().contains("ssh") }
|
||||||
|
guard hasSsh else { return nil }
|
||||||
|
return await self.probeGatewayHealth(port: port)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func loadRecords(from url: URL) -> [Record] {
|
||||||
|
guard let data = try? Data(contentsOf: url),
|
||||||
|
let decoded = try? JSONDecoder().decode([Record].self, from: data)
|
||||||
|
else { return [] }
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
guard let data = try? JSONEncoder().encode(self.records) else { return }
|
||||||
|
try? data.write(to: Self.recordPath, options: [.atomic])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
extension PortGuardian {
|
||||||
|
static func _testParseListeners(_ text: String) -> [(
|
||||||
|
pid: Int32,
|
||||||
|
command: String,
|
||||||
|
fullCommand: String,
|
||||||
|
user: String?)]
|
||||||
|
{
|
||||||
|
self.parseListeners(from: text).map { ($0.pid, $0.command, $0.fullCommand, $0.user) }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func _testBuildReport(
|
||||||
|
port: Int,
|
||||||
|
mode: AppState.ConnectionMode,
|
||||||
|
listeners: [(pid: Int32, command: String, fullCommand: String, user: String?)]) -> PortReport
|
||||||
|
{
|
||||||
|
let mapped = listeners.map { Listener(
|
||||||
|
pid: $0.pid,
|
||||||
|
command: $0.command,
|
||||||
|
fullCommand: $0.fullCommand,
|
||||||
|
user: $0.user) }
|
||||||
|
return Self.buildReport(port: port, listeners: mapped, mode: mode, tunnelHealthy: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
158
apps/macos/Sources/Moltbot/PresenceReporter.swift
Normal file
158
apps/macos/Sources/Moltbot/PresenceReporter.swift
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import Cocoa
|
||||||
|
import Darwin
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class PresenceReporter {
|
||||||
|
static let shared = PresenceReporter()
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "bot.molt", category: "presence")
|
||||||
|
private var task: Task<Void, Never>?
|
||||||
|
private let interval: TimeInterval = 180 // a few minutes
|
||||||
|
private let instanceId: String = InstanceIdentity.instanceId
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard self.task == nil else { return }
|
||||||
|
self.task = Task.detached { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
await self.push(reason: "launch")
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000))
|
||||||
|
await self.push(reason: "periodic")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
self.task?.cancel()
|
||||||
|
self.task = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
@Sendable
|
||||||
|
private func push(reason: String) async {
|
||||||
|
let mode = await MainActor.run { AppStateStore.shared.connectionMode.rawValue }
|
||||||
|
let host = InstanceIdentity.displayName
|
||||||
|
let ip = Self.primaryIPv4Address() ?? "ip-unknown"
|
||||||
|
let version = Self.appVersionString()
|
||||||
|
let platform = Self.platformString()
|
||||||
|
let lastInput = Self.lastInputSeconds()
|
||||||
|
let text = Self.composePresenceSummary(mode: mode, reason: reason)
|
||||||
|
var params: [String: AnyHashable] = [
|
||||||
|
"instanceId": AnyHashable(self.instanceId),
|
||||||
|
"host": AnyHashable(host),
|
||||||
|
"ip": AnyHashable(ip),
|
||||||
|
"mode": AnyHashable(mode),
|
||||||
|
"version": AnyHashable(version),
|
||||||
|
"platform": AnyHashable(platform),
|
||||||
|
"deviceFamily": AnyHashable("Mac"),
|
||||||
|
"reason": AnyHashable(reason),
|
||||||
|
]
|
||||||
|
if let model = InstanceIdentity.modelIdentifier { params["modelIdentifier"] = AnyHashable(model) }
|
||||||
|
if let lastInput { params["lastInputSeconds"] = AnyHashable(lastInput) }
|
||||||
|
do {
|
||||||
|
try await ControlChannel.shared.sendSystemEvent(text, params: params)
|
||||||
|
} catch {
|
||||||
|
self.logger.error("presence send failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fire an immediate presence beacon (e.g., right after connecting).
|
||||||
|
func sendImmediate(reason: String = "connect") {
|
||||||
|
Task { await self.push(reason: reason) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func composePresenceSummary(mode: String, reason: String) -> String {
|
||||||
|
let host = InstanceIdentity.displayName
|
||||||
|
let ip = Self.primaryIPv4Address() ?? "ip-unknown"
|
||||||
|
let version = Self.appVersionString()
|
||||||
|
let lastInput = Self.lastInputSeconds()
|
||||||
|
let lastLabel = lastInput.map { "last input \($0)s ago" } ?? "last input unknown"
|
||||||
|
return "Node: \(host) (\(ip)) · app \(version) · \(lastLabel) · mode \(mode) · reason \(reason)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func appVersionString() -> String {
|
||||||
|
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "dev"
|
||||||
|
if let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String {
|
||||||
|
let trimmed = build.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !trimmed.isEmpty, trimmed != version {
|
||||||
|
return "\(version) (\(trimmed))"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return version
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func platformString() -> String {
|
||||||
|
let v = ProcessInfo.processInfo.operatingSystemVersion
|
||||||
|
return "macos \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func lastInputSeconds() -> Int? {
|
||||||
|
let anyEvent = CGEventType(rawValue: UInt32.max) ?? .null
|
||||||
|
let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyEvent)
|
||||||
|
if seconds.isNaN || seconds.isInfinite || seconds < 0 { return nil }
|
||||||
|
return Int(seconds.rounded())
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func primaryIPv4Address() -> String? {
|
||||||
|
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||||
|
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||||
|
defer { freeifaddrs(addrList) }
|
||||||
|
|
||||||
|
var fallback: String?
|
||||||
|
var en0: String?
|
||||||
|
|
||||||
|
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
|
||||||
|
let flags = Int32(ptr.pointee.ifa_flags)
|
||||||
|
let isUp = (flags & IFF_UP) != 0
|
||||||
|
let isLoopback = (flags & IFF_LOOPBACK) != 0
|
||||||
|
let name = String(cString: ptr.pointee.ifa_name)
|
||||||
|
let family = ptr.pointee.ifa_addr.pointee.sa_family
|
||||||
|
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
|
||||||
|
|
||||||
|
var addr = ptr.pointee.ifa_addr.pointee
|
||||||
|
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||||
|
let result = getnameinfo(
|
||||||
|
&addr,
|
||||||
|
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
|
||||||
|
&buffer,
|
||||||
|
socklen_t(buffer.count),
|
||||||
|
nil,
|
||||||
|
0,
|
||||||
|
NI_NUMERICHOST)
|
||||||
|
guard result == 0 else { continue }
|
||||||
|
let len = buffer.prefix { $0 != 0 }
|
||||||
|
let bytes = len.map { UInt8(bitPattern: $0) }
|
||||||
|
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
|
||||||
|
|
||||||
|
if name == "en0" { en0 = ip; break }
|
||||||
|
if fallback == nil { fallback = ip }
|
||||||
|
}
|
||||||
|
|
||||||
|
return en0 ?? fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
extension PresenceReporter {
|
||||||
|
static func _testComposePresenceSummary(mode: String, reason: String) -> String {
|
||||||
|
self.composePresenceSummary(mode: mode, reason: reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func _testAppVersionString() -> String {
|
||||||
|
self.appVersionString()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func _testPlatformString() -> String {
|
||||||
|
self.platformString()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func _testLastInputSeconds() -> Int? {
|
||||||
|
self.lastInputSeconds()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func _testPrimaryIPv4Address() -> String? {
|
||||||
|
self.primaryIPv4Address()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
317
apps/macos/Sources/Moltbot/RemotePortTunnel.swift
Normal file
317
apps/macos/Sources/Moltbot/RemotePortTunnel.swift
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
import OSLog
|
||||||
|
#if canImport(Darwin)
|
||||||
|
import Darwin
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// Port forwarding tunnel for remote mode.
|
||||||
|
///
|
||||||
|
/// Uses `ssh -N -L` to forward the remote gateway ports to localhost.
|
||||||
|
final class RemotePortTunnel {
|
||||||
|
private static let logger = Logger(subsystem: "bot.molt", category: "remote.tunnel")
|
||||||
|
|
||||||
|
let process: Process
|
||||||
|
let localPort: UInt16?
|
||||||
|
private let stderrHandle: FileHandle?
|
||||||
|
|
||||||
|
private init(process: Process, localPort: UInt16?, stderrHandle: FileHandle?) {
|
||||||
|
self.process = process
|
||||||
|
self.localPort = localPort
|
||||||
|
self.stderrHandle = stderrHandle
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
Self.cleanupStderr(self.stderrHandle)
|
||||||
|
let pid = self.process.processIdentifier
|
||||||
|
self.process.terminate()
|
||||||
|
Task { await PortGuardian.shared.removeRecord(pid: pid) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func terminate() {
|
||||||
|
Self.cleanupStderr(self.stderrHandle)
|
||||||
|
let pid = self.process.processIdentifier
|
||||||
|
if self.process.isRunning {
|
||||||
|
self.process.terminate()
|
||||||
|
self.process.waitUntilExit()
|
||||||
|
}
|
||||||
|
Task { await PortGuardian.shared.removeRecord(pid: pid) }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func create(
|
||||||
|
remotePort: Int,
|
||||||
|
preferredLocalPort: UInt16? = nil,
|
||||||
|
allowRemoteUrlOverride: Bool = true,
|
||||||
|
allowRandomLocalPort: Bool = true) async throws -> RemotePortTunnel
|
||||||
|
{
|
||||||
|
let settings = CommandResolver.connectionSettings()
|
||||||
|
guard settings.mode == .remote, let parsed = CommandResolver.parseSSHTarget(settings.target) else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "RemotePortTunnel",
|
||||||
|
code: 3,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not configured"])
|
||||||
|
}
|
||||||
|
|
||||||
|
let localPort = try await Self.findPort(
|
||||||
|
preferred: preferredLocalPort,
|
||||||
|
allowRandom: allowRandomLocalPort)
|
||||||
|
let sshHost = parsed.host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let remotePortOverride =
|
||||||
|
allowRemoteUrlOverride && remotePort == GatewayEnvironment.gatewayPort()
|
||||||
|
? Self.resolveRemotePortOverride(for: sshHost)
|
||||||
|
: nil
|
||||||
|
let resolvedRemotePort = remotePortOverride ?? remotePort
|
||||||
|
if let override = remotePortOverride {
|
||||||
|
Self.logger.info(
|
||||||
|
"ssh tunnel remote port override " +
|
||||||
|
"host=\(sshHost, privacy: .public) port=\(override, privacy: .public)")
|
||||||
|
} else {
|
||||||
|
Self.logger.debug(
|
||||||
|
"ssh tunnel using default remote port " +
|
||||||
|
"host=\(sshHost, privacy: .public) port=\(remotePort, privacy: .public)")
|
||||||
|
}
|
||||||
|
let options: [String] = [
|
||||||
|
"-o", "BatchMode=yes",
|
||||||
|
"-o", "ExitOnForwardFailure=yes",
|
||||||
|
"-o", "StrictHostKeyChecking=accept-new",
|
||||||
|
"-o", "UpdateHostKeys=yes",
|
||||||
|
"-o", "ServerAliveInterval=15",
|
||||||
|
"-o", "ServerAliveCountMax=3",
|
||||||
|
"-o", "TCPKeepAlive=yes",
|
||||||
|
"-N",
|
||||||
|
"-L", "\(localPort):127.0.0.1:\(resolvedRemotePort)",
|
||||||
|
]
|
||||||
|
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let args = CommandResolver.sshArguments(
|
||||||
|
target: parsed,
|
||||||
|
identity: identity,
|
||||||
|
options: options)
|
||||||
|
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
|
||||||
|
process.arguments = args
|
||||||
|
|
||||||
|
let pipe = Pipe()
|
||||||
|
process.standardError = pipe
|
||||||
|
let stderrHandle = pipe.fileHandleForReading
|
||||||
|
|
||||||
|
// Consume stderr so ssh cannot block if it logs.
|
||||||
|
stderrHandle.readabilityHandler = { handle in
|
||||||
|
let data = handle.readSafely(upToCount: 64 * 1024)
|
||||||
|
guard !data.isEmpty else {
|
||||||
|
// EOF (or read failure): stop monitoring to avoid spinning on a closed pipe.
|
||||||
|
Self.cleanupStderr(handle)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let line = String(data: data, encoding: .utf8)?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!line.isEmpty
|
||||||
|
else { return }
|
||||||
|
Self.logger.error("ssh tunnel stderr: \(line, privacy: .public)")
|
||||||
|
}
|
||||||
|
process.terminationHandler = { _ in
|
||||||
|
Self.cleanupStderr(stderrHandle)
|
||||||
|
}
|
||||||
|
|
||||||
|
try process.run()
|
||||||
|
|
||||||
|
// If ssh exits immediately (e.g. local port already in use), surface stderr and ensure we stop monitoring.
|
||||||
|
try? await Task.sleep(nanoseconds: 150_000_000) // 150ms
|
||||||
|
if !process.isRunning {
|
||||||
|
let stderr = Self.drainStderr(stderrHandle)
|
||||||
|
let msg = stderr.isEmpty ? "ssh tunnel exited immediately" : "ssh tunnel failed: \(stderr)"
|
||||||
|
throw NSError(domain: "RemotePortTunnel", code: 4, userInfo: [NSLocalizedDescriptionKey: msg])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track tunnel so we can clean up stale listeners on restart.
|
||||||
|
Task {
|
||||||
|
await PortGuardian.shared.record(
|
||||||
|
port: Int(localPort),
|
||||||
|
pid: process.processIdentifier,
|
||||||
|
command: process.executableURL?.path ?? "ssh",
|
||||||
|
mode: CommandResolver.connectionSettings().mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RemotePortTunnel(process: process, localPort: localPort, stderrHandle: stderrHandle)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func resolveRemotePortOverride(for sshHost: String) -> Int? {
|
||||||
|
let root = MoltbotConfigFile.loadDict()
|
||||||
|
guard let gateway = root["gateway"] as? [String: Any],
|
||||||
|
let remote = gateway["remote"] as? [String: Any],
|
||||||
|
let urlRaw = remote["url"] as? String
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let trimmed = urlRaw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty, let url = URL(string: trimmed), let port = url.port else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!host.isEmpty
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let sshKey = Self.hostKey(sshHost)
|
||||||
|
let urlKey = Self.hostKey(host)
|
||||||
|
guard !sshKey.isEmpty, !urlKey.isEmpty else { return nil }
|
||||||
|
guard sshKey == urlKey else {
|
||||||
|
Self.logger.debug(
|
||||||
|
"remote url host mismatch sshHost=\(sshHost, privacy: .public) urlHost=\(host, privacy: .public)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func hostKey(_ host: String) -> String {
|
||||||
|
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
guard !trimmed.isEmpty else { return "" }
|
||||||
|
if trimmed.contains(":") { return trimmed }
|
||||||
|
let digits = CharacterSet(charactersIn: "0123456789.")
|
||||||
|
if trimmed.rangeOfCharacter(from: digits.inverted) == nil {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
return trimmed.split(separator: ".").first.map(String.init) ?? trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func findPort(preferred: UInt16?, allowRandom: Bool) async throws -> UInt16 {
|
||||||
|
if let preferred, self.portIsFree(preferred) { return preferred }
|
||||||
|
if let preferred, !allowRandom {
|
||||||
|
throw NSError(
|
||||||
|
domain: "RemotePortTunnel",
|
||||||
|
code: 5,
|
||||||
|
userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "Local port \(preferred) is unavailable",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await withCheckedThrowingContinuation { cont in
|
||||||
|
let queue = DispatchQueue(label: "bot.molt.remote.tunnel.port", qos: .utility)
|
||||||
|
do {
|
||||||
|
let listener = try NWListener(using: .tcp, on: .any)
|
||||||
|
listener.newConnectionHandler = { connection in connection.cancel() }
|
||||||
|
listener.stateUpdateHandler = { state in
|
||||||
|
switch state {
|
||||||
|
case .ready:
|
||||||
|
if let port = listener.port?.rawValue {
|
||||||
|
listener.stateUpdateHandler = nil
|
||||||
|
listener.cancel()
|
||||||
|
cont.resume(returning: port)
|
||||||
|
}
|
||||||
|
case let .failed(error):
|
||||||
|
listener.stateUpdateHandler = nil
|
||||||
|
listener.cancel()
|
||||||
|
cont.resume(throwing: error)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listener.start(queue: queue)
|
||||||
|
} catch {
|
||||||
|
cont.resume(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func portIsFree(_ port: UInt16) -> Bool {
|
||||||
|
#if canImport(Darwin)
|
||||||
|
// NWListener can succeed even when only one address family is held. Mirror what ssh needs by checking
|
||||||
|
// both 127.0.0.1 and ::1 for availability.
|
||||||
|
return self.canBindIPv4(port) && self.canBindIPv6(port)
|
||||||
|
#else
|
||||||
|
do {
|
||||||
|
let listener = try NWListener(using: .tcp, on: NWEndpoint.Port(rawValue: port)!)
|
||||||
|
listener.cancel()
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
#if canImport(Darwin)
|
||||||
|
private static func canBindIPv4(_ port: UInt16) -> Bool {
|
||||||
|
let fd = socket(AF_INET, SOCK_STREAM, 0)
|
||||||
|
guard fd >= 0 else { return false }
|
||||||
|
defer { _ = Darwin.close(fd) }
|
||||||
|
|
||||||
|
var one: Int32 = 1
|
||||||
|
_ = setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, socklen_t(MemoryLayout.size(ofValue: one)))
|
||||||
|
|
||||||
|
var addr = sockaddr_in()
|
||||||
|
addr.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
|
||||||
|
addr.sin_family = sa_family_t(AF_INET)
|
||||||
|
addr.sin_port = port.bigEndian
|
||||||
|
addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1"))
|
||||||
|
|
||||||
|
let result = withUnsafePointer(to: &addr) { ptr in
|
||||||
|
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||||
|
Darwin.bind(fd, sa, socklen_t(MemoryLayout<sockaddr_in>.size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func canBindIPv6(_ port: UInt16) -> Bool {
|
||||||
|
let fd = socket(AF_INET6, SOCK_STREAM, 0)
|
||||||
|
guard fd >= 0 else { return false }
|
||||||
|
defer { _ = Darwin.close(fd) }
|
||||||
|
|
||||||
|
var one: Int32 = 1
|
||||||
|
_ = setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, socklen_t(MemoryLayout.size(ofValue: one)))
|
||||||
|
|
||||||
|
var addr = sockaddr_in6()
|
||||||
|
addr.sin6_len = UInt8(MemoryLayout<sockaddr_in6>.size)
|
||||||
|
addr.sin6_family = sa_family_t(AF_INET6)
|
||||||
|
addr.sin6_port = port.bigEndian
|
||||||
|
var loopback = in6_addr()
|
||||||
|
_ = withUnsafeMutablePointer(to: &loopback) { ptr in
|
||||||
|
inet_pton(AF_INET6, "::1", ptr)
|
||||||
|
}
|
||||||
|
addr.sin6_addr = loopback
|
||||||
|
|
||||||
|
let result = withUnsafePointer(to: &addr) { ptr in
|
||||||
|
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||||
|
Darwin.bind(fd, sa, socklen_t(MemoryLayout<sockaddr_in6>.size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result == 0
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private static func cleanupStderr(_ handle: FileHandle?) {
|
||||||
|
guard let handle else { return }
|
||||||
|
Self.cleanupStderr(handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func cleanupStderr(_ handle: FileHandle) {
|
||||||
|
if handle.readabilityHandler != nil {
|
||||||
|
handle.readabilityHandler = nil
|
||||||
|
}
|
||||||
|
try? handle.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func drainStderr(_ handle: FileHandle) -> String {
|
||||||
|
handle.readabilityHandler = nil
|
||||||
|
defer { try? handle.close() }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let data = try handle.readToEnd() ?? Data()
|
||||||
|
return String(data: data, encoding: .utf8)?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
} catch {
|
||||||
|
self.logger.debug("Failed to drain ssh stderr: \(error, privacy: .public)")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if SWIFT_PACKAGE
|
||||||
|
static func _testPortIsFree(_ port: UInt16) -> Bool {
|
||||||
|
self.portIsFree(port)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func _testDrainStderr(_ handle: FileHandle) -> String {
|
||||||
|
self.drainStderr(handle)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
122
apps/macos/Sources/Moltbot/RemoteTunnelManager.swift
Normal file
122
apps/macos/Sources/Moltbot/RemoteTunnelManager.swift
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
/// Manages the SSH tunnel that forwards the remote gateway/control port to localhost.
|
||||||
|
actor RemoteTunnelManager {
|
||||||
|
static let shared = RemoteTunnelManager()
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "bot.molt", category: "remote-tunnel")
|
||||||
|
private var controlTunnel: RemotePortTunnel?
|
||||||
|
private var restartInFlight = false
|
||||||
|
private var lastRestartAt: Date?
|
||||||
|
private let restartBackoffSeconds: TimeInterval = 2.0
|
||||||
|
|
||||||
|
func controlTunnelPortIfRunning() async -> UInt16? {
|
||||||
|
if self.restartInFlight {
|
||||||
|
self.logger.info("control tunnel restart in flight; skipping reuse check")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if let tunnel = self.controlTunnel,
|
||||||
|
tunnel.process.isRunning,
|
||||||
|
let local = tunnel.localPort
|
||||||
|
{
|
||||||
|
let pid = tunnel.process.processIdentifier
|
||||||
|
if await PortGuardian.shared.isListening(port: Int(local), pid: pid) {
|
||||||
|
self.logger.info("reusing active SSH tunnel localPort=\(local, privacy: .public)")
|
||||||
|
return local
|
||||||
|
}
|
||||||
|
self.logger.error(
|
||||||
|
"active SSH tunnel on port \(local, privacy: .public) is not listening; restarting")
|
||||||
|
await self.beginRestart()
|
||||||
|
tunnel.terminate()
|
||||||
|
self.controlTunnel = nil
|
||||||
|
}
|
||||||
|
// If a previous Moltbot run already has an SSH listener on the expected port (common after restarts),
|
||||||
|
// reuse it instead of spawning new ssh processes that immediately fail with "Address already in use".
|
||||||
|
let desiredPort = UInt16(GatewayEnvironment.gatewayPort())
|
||||||
|
if let desc = await PortGuardian.shared.describe(port: Int(desiredPort)),
|
||||||
|
self.isSshProcess(desc)
|
||||||
|
{
|
||||||
|
self.logger.info(
|
||||||
|
"reusing existing SSH tunnel listener " +
|
||||||
|
"localPort=\(desiredPort, privacy: .public) " +
|
||||||
|
"pid=\(desc.pid, privacy: .public)")
|
||||||
|
return desiredPort
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure an SSH tunnel is running for the gateway control port.
|
||||||
|
/// Returns the local forwarded port (usually the configured gateway port).
|
||||||
|
func ensureControlTunnel() async throws -> UInt16 {
|
||||||
|
let settings = CommandResolver.connectionSettings()
|
||||||
|
guard settings.mode == .remote else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "RemoteTunnel",
|
||||||
|
code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
||||||
|
}
|
||||||
|
|
||||||
|
let identitySet = !settings.identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
self.logger.info(
|
||||||
|
"ensure SSH tunnel target=\(settings.target, privacy: .public) " +
|
||||||
|
"identitySet=\(identitySet, privacy: .public)")
|
||||||
|
|
||||||
|
if let local = await self.controlTunnelPortIfRunning() { return local }
|
||||||
|
await self.waitForRestartBackoffIfNeeded()
|
||||||
|
|
||||||
|
let desiredPort = UInt16(GatewayEnvironment.gatewayPort())
|
||||||
|
let tunnel = try await RemotePortTunnel.create(
|
||||||
|
remotePort: GatewayEnvironment.gatewayPort(),
|
||||||
|
preferredLocalPort: desiredPort,
|
||||||
|
allowRandomLocalPort: false)
|
||||||
|
self.controlTunnel = tunnel
|
||||||
|
self.endRestart()
|
||||||
|
let resolvedPort = tunnel.localPort ?? desiredPort
|
||||||
|
self.logger.info("ssh tunnel ready localPort=\(resolvedPort, privacy: .public)")
|
||||||
|
return tunnel.localPort ?? desiredPort
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopAll() {
|
||||||
|
self.controlTunnel?.terminate()
|
||||||
|
self.controlTunnel = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isSshProcess(_ desc: PortGuardian.Descriptor) -> Bool {
|
||||||
|
let cmd = desc.command.lowercased()
|
||||||
|
if cmd.contains("ssh") { return true }
|
||||||
|
if let path = desc.executablePath?.lowercased(), path.contains("/ssh") { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func beginRestart() async {
|
||||||
|
guard !self.restartInFlight else { return }
|
||||||
|
self.restartInFlight = true
|
||||||
|
self.lastRestartAt = Date()
|
||||||
|
self.logger.info("control tunnel restart started")
|
||||||
|
Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(self.restartBackoffSeconds * 1_000_000_000))
|
||||||
|
await self.endRestart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func endRestart() {
|
||||||
|
if self.restartInFlight {
|
||||||
|
self.restartInFlight = false
|
||||||
|
self.logger.info("control tunnel restart finished")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitForRestartBackoffIfNeeded() async {
|
||||||
|
guard let last = self.lastRestartAt else { return }
|
||||||
|
let elapsed = Date().timeIntervalSince(last)
|
||||||
|
let remaining = self.restartBackoffSeconds - elapsed
|
||||||
|
guard remaining > 0 else { return }
|
||||||
|
self.logger.info(
|
||||||
|
"control tunnel restart backoff \(remaining, privacy: .public)s")
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep tunnel reuse lightweight; restart only when the listener disappears.
|
||||||
|
}
|
||||||
79
apps/macos/Sources/Moltbot/Resources/Info.plist
Normal file
79
apps/macos/Sources/Moltbot/Resources/Info.plist
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>en</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>Moltbot</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>bot.molt.mac</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>Moltbot</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>2026.1.26</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>202601260</string>
|
||||||
|
<key>CFBundleIconFile</key>
|
||||||
|
<string>Moltbot</string>
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>bot.molt.mac.deeplink</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>moltbot</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>15.0</string>
|
||||||
|
<key>LSUIElement</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<key>MoltbotBuildTimestamp</key>
|
||||||
|
<string></string>
|
||||||
|
<key>MoltbotGitCommit</key>
|
||||||
|
<string></string>
|
||||||
|
|
||||||
|
<key>NSUserNotificationUsageDescription</key>
|
||||||
|
<string>Moltbot needs notification permission to show alerts for agent actions.</string>
|
||||||
|
<key>NSScreenCaptureDescription</key>
|
||||||
|
<string>Moltbot captures the screen when the agent needs screenshots for context.</string>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>Moltbot can capture photos or short video clips when requested by the agent.</string>
|
||||||
|
<key>NSLocationUsageDescription</key>
|
||||||
|
<string>Moltbot can share your location when requested by the agent.</string>
|
||||||
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
|
<string>Moltbot can share your location when requested by the agent.</string>
|
||||||
|
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||||
|
<string>Moltbot can share your location when requested by the agent.</string>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>Moltbot needs the mic for Voice Wake tests and agent audio capture.</string>
|
||||||
|
<key>NSSpeechRecognitionUsageDescription</key>
|
||||||
|
<string>Moltbot uses speech recognition to detect your Voice Wake trigger phrase.</string>
|
||||||
|
<key>NSAppleEventsUsageDescription</key>
|
||||||
|
<string>Moltbot needs Automation (AppleScript) permission to drive Terminal and other apps for agent actions.</string>
|
||||||
|
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSExceptionDomains</key>
|
||||||
|
<dict>
|
||||||
|
<key>100.100.100.100</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSIncludesSubdomains</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
167
apps/macos/Sources/Moltbot/RuntimeLocator.swift
Normal file
167
apps/macos/Sources/Moltbot/RuntimeLocator.swift
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
enum RuntimeKind: String {
|
||||||
|
case node
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RuntimeVersion: Comparable, CustomStringConvertible {
|
||||||
|
let major: Int
|
||||||
|
let minor: Int
|
||||||
|
let patch: Int
|
||||||
|
|
||||||
|
var description: String { "\(self.major).\(self.minor).\(self.patch)" }
|
||||||
|
|
||||||
|
static func < (lhs: RuntimeVersion, rhs: RuntimeVersion) -> Bool {
|
||||||
|
if lhs.major != rhs.major { return lhs.major < rhs.major }
|
||||||
|
if lhs.minor != rhs.minor { return lhs.minor < rhs.minor }
|
||||||
|
return lhs.patch < rhs.patch
|
||||||
|
}
|
||||||
|
|
||||||
|
static func from(string: String) -> RuntimeVersion? {
|
||||||
|
// Accept optional leading "v" and ignore trailing metadata.
|
||||||
|
let pattern = #"(\d+)\.(\d+)\.(\d+)"#
|
||||||
|
guard let match = string.range(of: pattern, options: .regularExpression) else { return nil }
|
||||||
|
let versionString = String(string[match])
|
||||||
|
let parts = versionString.split(separator: ".")
|
||||||
|
guard parts.count == 3,
|
||||||
|
let major = Int(parts[0]),
|
||||||
|
let minor = Int(parts[1]),
|
||||||
|
let patch = Int(parts[2])
|
||||||
|
else { return nil }
|
||||||
|
return RuntimeVersion(major: major, minor: minor, patch: patch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RuntimeResolution {
|
||||||
|
let kind: RuntimeKind
|
||||||
|
let path: String
|
||||||
|
let version: RuntimeVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RuntimeResolutionError: Error {
|
||||||
|
case notFound(searchPaths: [String])
|
||||||
|
case unsupported(
|
||||||
|
kind: RuntimeKind,
|
||||||
|
found: RuntimeVersion,
|
||||||
|
required: RuntimeVersion,
|
||||||
|
path: String,
|
||||||
|
searchPaths: [String])
|
||||||
|
case versionParse(kind: RuntimeKind, raw: String, path: String, searchPaths: [String])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RuntimeLocator {
|
||||||
|
private static let logger = Logger(subsystem: "bot.molt", category: "runtime")
|
||||||
|
private static let minNode = RuntimeVersion(major: 22, minor: 0, patch: 0)
|
||||||
|
|
||||||
|
static func resolve(
|
||||||
|
searchPaths: [String] = CommandResolver.preferredPaths()) -> Result<RuntimeResolution, RuntimeResolutionError>
|
||||||
|
{
|
||||||
|
let pathEnv = searchPaths.joined(separator: ":")
|
||||||
|
let runtime: RuntimeKind = .node
|
||||||
|
|
||||||
|
guard let binary = findExecutable(named: runtime.binaryName, searchPaths: searchPaths) else {
|
||||||
|
return .failure(.notFound(searchPaths: searchPaths))
|
||||||
|
}
|
||||||
|
guard let rawVersion = readVersion(of: binary, pathEnv: pathEnv) else {
|
||||||
|
return .failure(.versionParse(
|
||||||
|
kind: runtime,
|
||||||
|
raw: "(unreadable)",
|
||||||
|
path: binary,
|
||||||
|
searchPaths: searchPaths))
|
||||||
|
}
|
||||||
|
guard let parsed = RuntimeVersion.from(string: rawVersion) else {
|
||||||
|
return .failure(.versionParse(kind: runtime, raw: rawVersion, path: binary, searchPaths: searchPaths))
|
||||||
|
}
|
||||||
|
guard parsed >= self.minNode else {
|
||||||
|
return .failure(.unsupported(
|
||||||
|
kind: runtime,
|
||||||
|
found: parsed,
|
||||||
|
required: self.minNode,
|
||||||
|
path: binary,
|
||||||
|
searchPaths: searchPaths))
|
||||||
|
}
|
||||||
|
|
||||||
|
return .success(RuntimeResolution(kind: runtime, path: binary, version: parsed))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func describeFailure(_ error: RuntimeResolutionError) -> String {
|
||||||
|
switch error {
|
||||||
|
case let .notFound(searchPaths):
|
||||||
|
[
|
||||||
|
"moltbot needs Node >=22.0.0 but found no runtime.",
|
||||||
|
"PATH searched: \(searchPaths.joined(separator: ":"))",
|
||||||
|
"Install Node: https://nodejs.org/en/download",
|
||||||
|
].joined(separator: "\n")
|
||||||
|
case let .unsupported(kind, found, required, path, searchPaths):
|
||||||
|
[
|
||||||
|
"Found \(kind.rawValue) \(found) at \(path) but need >= \(required).",
|
||||||
|
"PATH searched: \(searchPaths.joined(separator: ":"))",
|
||||||
|
"Upgrade Node and rerun moltbot.",
|
||||||
|
].joined(separator: "\n")
|
||||||
|
case let .versionParse(kind, raw, path, searchPaths):
|
||||||
|
[
|
||||||
|
"Could not parse \(kind.rawValue) version output \"\(raw)\" from \(path).",
|
||||||
|
"PATH searched: \(searchPaths.joined(separator: ":"))",
|
||||||
|
"Try reinstalling or pinning a supported version (Node >=22.0.0).",
|
||||||
|
].joined(separator: "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internals
|
||||||
|
|
||||||
|
private static func findExecutable(named name: String, searchPaths: [String]) -> String? {
|
||||||
|
let fm = FileManager()
|
||||||
|
for dir in searchPaths {
|
||||||
|
let candidate = (dir as NSString).appendingPathComponent(name)
|
||||||
|
if fm.isExecutableFile(atPath: candidate) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func readVersion(of binary: String, pathEnv: String) -> String? {
|
||||||
|
let start = Date()
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = URL(fileURLWithPath: binary)
|
||||||
|
process.arguments = ["--version"]
|
||||||
|
process.environment = ["PATH": pathEnv]
|
||||||
|
|
||||||
|
let pipe = Pipe()
|
||||||
|
process.standardOutput = pipe
|
||||||
|
process.standardError = pipe
|
||||||
|
|
||||||
|
do {
|
||||||
|
let data = try process.runAndReadToEnd(from: pipe)
|
||||||
|
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
|
||||||
|
if elapsedMs > 500 {
|
||||||
|
self.logger.warning(
|
||||||
|
"""
|
||||||
|
runtime --version slow (\(elapsedMs, privacy: .public)ms) \
|
||||||
|
bin=\(binary, privacy: .public)
|
||||||
|
""")
|
||||||
|
} else {
|
||||||
|
self.logger.debug(
|
||||||
|
"""
|
||||||
|
runtime --version ok (\(elapsedMs, privacy: .public)ms) \
|
||||||
|
bin=\(binary, privacy: .public)
|
||||||
|
""")
|
||||||
|
}
|
||||||
|
return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
} catch {
|
||||||
|
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
|
||||||
|
self.logger.error(
|
||||||
|
"""
|
||||||
|
runtime --version failed (\(elapsedMs, privacy: .public)ms) \
|
||||||
|
bin=\(binary, privacy: .public) \
|
||||||
|
err=\(error.localizedDescription, privacy: .public)
|
||||||
|
""")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension RuntimeKind {
|
||||||
|
fileprivate var binaryName: String { "node" }
|
||||||
|
}
|
||||||
266
apps/macos/Sources/Moltbot/ScreenRecordService.swift
Normal file
266
apps/macos/Sources/Moltbot/ScreenRecordService.swift
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
@preconcurrency import ScreenCaptureKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ScreenRecordService {
|
||||||
|
enum ScreenRecordError: LocalizedError {
|
||||||
|
case noDisplays
|
||||||
|
case invalidScreenIndex(Int)
|
||||||
|
case noFramesCaptured
|
||||||
|
case writeFailed(String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .noDisplays:
|
||||||
|
"No displays available for screen recording"
|
||||||
|
case let .invalidScreenIndex(idx):
|
||||||
|
"Invalid screen index \(idx)"
|
||||||
|
case .noFramesCaptured:
|
||||||
|
"No frames captured"
|
||||||
|
case let .writeFailed(msg):
|
||||||
|
msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "bot.molt", category: "screenRecord")
|
||||||
|
|
||||||
|
func record(
|
||||||
|
screenIndex: Int?,
|
||||||
|
durationMs: Int?,
|
||||||
|
fps: Double?,
|
||||||
|
includeAudio: Bool?,
|
||||||
|
outPath: String?) async throws -> (path: String, hasAudio: Bool)
|
||||||
|
{
|
||||||
|
let durationMs = Self.clampDurationMs(durationMs)
|
||||||
|
let fps = Self.clampFps(fps)
|
||||||
|
let includeAudio = includeAudio ?? false
|
||||||
|
|
||||||
|
let outURL: URL = {
|
||||||
|
if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
return URL(fileURLWithPath: outPath)
|
||||||
|
}
|
||||||
|
return FileManager().temporaryDirectory
|
||||||
|
.appendingPathComponent("moltbot-screen-record-\(UUID().uuidString).mp4")
|
||||||
|
}()
|
||||||
|
try? FileManager().removeItem(at: outURL)
|
||||||
|
|
||||||
|
let content = try await SCShareableContent.current
|
||||||
|
let displays = content.displays.sorted { $0.displayID < $1.displayID }
|
||||||
|
guard !displays.isEmpty else { throw ScreenRecordError.noDisplays }
|
||||||
|
|
||||||
|
let idx = screenIndex ?? 0
|
||||||
|
guard idx >= 0, idx < displays.count else { throw ScreenRecordError.invalidScreenIndex(idx) }
|
||||||
|
let display = displays[idx]
|
||||||
|
|
||||||
|
let filter = SCContentFilter(display: display, excludingWindows: [])
|
||||||
|
let config = SCStreamConfiguration()
|
||||||
|
config.width = display.width
|
||||||
|
config.height = display.height
|
||||||
|
config.queueDepth = 8
|
||||||
|
config.showsCursor = true
|
||||||
|
config.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(max(1, Int32(fps.rounded()))))
|
||||||
|
if includeAudio {
|
||||||
|
config.capturesAudio = true
|
||||||
|
}
|
||||||
|
|
||||||
|
let recorder = try StreamRecorder(
|
||||||
|
outputURL: outURL,
|
||||||
|
width: display.width,
|
||||||
|
height: display.height,
|
||||||
|
includeAudio: includeAudio,
|
||||||
|
logger: self.logger)
|
||||||
|
|
||||||
|
let stream = SCStream(filter: filter, configuration: config, delegate: recorder)
|
||||||
|
try stream.addStreamOutput(recorder, type: .screen, sampleHandlerQueue: recorder.queue)
|
||||||
|
if includeAudio {
|
||||||
|
try stream.addStreamOutput(recorder, type: .audio, sampleHandlerQueue: recorder.queue)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
"screen record start idx=\(idx) durationMs=\(durationMs) fps=\(fps) out=\(outURL.path, privacy: .public)")
|
||||||
|
|
||||||
|
var started = false
|
||||||
|
do {
|
||||||
|
try await stream.startCapture()
|
||||||
|
started = true
|
||||||
|
try await Task.sleep(nanoseconds: UInt64(durationMs) * 1_000_000)
|
||||||
|
try await stream.stopCapture()
|
||||||
|
} catch {
|
||||||
|
if started { try? await stream.stopCapture() }
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
try await recorder.finish()
|
||||||
|
return (path: outURL.path, hasAudio: recorder.hasAudio)
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func clampDurationMs(_ ms: Int?) -> Int {
|
||||||
|
let v = ms ?? 10000
|
||||||
|
return min(60000, max(250, v))
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func clampFps(_ fps: Double?) -> Double {
|
||||||
|
let v = fps ?? 10
|
||||||
|
if !v.isFinite { return 10 }
|
||||||
|
return min(60, max(1, v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class StreamRecorder: NSObject, SCStreamOutput, SCStreamDelegate, @unchecked Sendable {
|
||||||
|
let queue = DispatchQueue(label: "bot.molt.screenRecord.writer")
|
||||||
|
|
||||||
|
private let logger: Logger
|
||||||
|
private let writer: AVAssetWriter
|
||||||
|
private let input: AVAssetWriterInput
|
||||||
|
private let audioInput: AVAssetWriterInput?
|
||||||
|
let hasAudio: Bool
|
||||||
|
|
||||||
|
private var started = false
|
||||||
|
private var sawFrame = false
|
||||||
|
private var didFinish = false
|
||||||
|
private var pendingErrorMessage: String?
|
||||||
|
|
||||||
|
init(outputURL: URL, width: Int, height: Int, includeAudio: Bool, logger: Logger) throws {
|
||||||
|
self.logger = logger
|
||||||
|
self.writer = try AVAssetWriter(outputURL: outputURL, fileType: .mp4)
|
||||||
|
|
||||||
|
let settings: [String: Any] = [
|
||||||
|
AVVideoCodecKey: AVVideoCodecType.h264,
|
||||||
|
AVVideoWidthKey: width,
|
||||||
|
AVVideoHeightKey: height,
|
||||||
|
]
|
||||||
|
self.input = AVAssetWriterInput(mediaType: .video, outputSettings: settings)
|
||||||
|
self.input.expectsMediaDataInRealTime = true
|
||||||
|
|
||||||
|
guard self.writer.canAdd(self.input) else {
|
||||||
|
throw ScreenRecordService.ScreenRecordError.writeFailed("Cannot add video input")
|
||||||
|
}
|
||||||
|
self.writer.add(self.input)
|
||||||
|
|
||||||
|
if includeAudio {
|
||||||
|
let audioSettings: [String: Any] = [
|
||||||
|
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||||
|
AVNumberOfChannelsKey: 1,
|
||||||
|
AVSampleRateKey: 44100,
|
||||||
|
AVEncoderBitRateKey: 96000,
|
||||||
|
]
|
||||||
|
let audioInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings)
|
||||||
|
audioInput.expectsMediaDataInRealTime = true
|
||||||
|
if self.writer.canAdd(audioInput) {
|
||||||
|
self.writer.add(audioInput)
|
||||||
|
self.audioInput = audioInput
|
||||||
|
self.hasAudio = true
|
||||||
|
} else {
|
||||||
|
self.audioInput = nil
|
||||||
|
self.hasAudio = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.audioInput = nil
|
||||||
|
self.hasAudio = false
|
||||||
|
}
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stream(_ stream: SCStream, didStopWithError error: any Error) {
|
||||||
|
self.queue.async {
|
||||||
|
let msg = String(describing: error)
|
||||||
|
self.pendingErrorMessage = msg
|
||||||
|
self.logger.error("screen record stream stopped with error: \(msg, privacy: .public)")
|
||||||
|
_ = stream
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stream(
|
||||||
|
_ stream: SCStream,
|
||||||
|
didOutputSampleBuffer sampleBuffer: CMSampleBuffer,
|
||||||
|
of type: SCStreamOutputType)
|
||||||
|
{
|
||||||
|
guard CMSampleBufferDataIsReady(sampleBuffer) else { return }
|
||||||
|
// Callback runs on `sampleHandlerQueue` (`self.queue`).
|
||||||
|
switch type {
|
||||||
|
case .screen:
|
||||||
|
self.handleVideo(sampleBuffer: sampleBuffer)
|
||||||
|
case .audio:
|
||||||
|
self.handleAudio(sampleBuffer: sampleBuffer)
|
||||||
|
case .microphone:
|
||||||
|
break
|
||||||
|
@unknown default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
_ = stream
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleVideo(sampleBuffer: CMSampleBuffer) {
|
||||||
|
if let msg = self.pendingErrorMessage {
|
||||||
|
self.logger.error("screen record aborting due to prior error: \(msg, privacy: .public)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if self.didFinish { return }
|
||||||
|
|
||||||
|
if !self.started {
|
||||||
|
guard self.writer.startWriting() else {
|
||||||
|
self.pendingErrorMessage = self.writer.error?.localizedDescription ?? "Failed to start writer"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
|
||||||
|
self.writer.startSession(atSourceTime: pts)
|
||||||
|
self.started = true
|
||||||
|
}
|
||||||
|
|
||||||
|
self.sawFrame = true
|
||||||
|
if self.input.isReadyForMoreMediaData {
|
||||||
|
_ = self.input.append(sampleBuffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleAudio(sampleBuffer: CMSampleBuffer) {
|
||||||
|
guard let audioInput else { return }
|
||||||
|
if let msg = self.pendingErrorMessage {
|
||||||
|
self.logger.error("screen record audio aborting due to prior error: \(msg, privacy: .public)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if self.didFinish || !self.started { return }
|
||||||
|
if audioInput.isReadyForMoreMediaData {
|
||||||
|
_ = audioInput.append(sampleBuffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func finish() async throws {
|
||||||
|
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
||||||
|
self.queue.async {
|
||||||
|
if let msg = self.pendingErrorMessage {
|
||||||
|
cont.resume(throwing: ScreenRecordService.ScreenRecordError.writeFailed(msg))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard self.started, self.sawFrame else {
|
||||||
|
cont.resume(throwing: ScreenRecordService.ScreenRecordError.noFramesCaptured)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if self.didFinish {
|
||||||
|
cont.resume()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.didFinish = true
|
||||||
|
|
||||||
|
self.input.markAsFinished()
|
||||||
|
self.audioInput?.markAsFinished()
|
||||||
|
self.writer.finishWriting {
|
||||||
|
if let err = self.writer.error {
|
||||||
|
cont
|
||||||
|
.resume(throwing: ScreenRecordService.ScreenRecordError
|
||||||
|
.writeFailed(err.localizedDescription))
|
||||||
|
} else if self.writer.status != .completed {
|
||||||
|
cont
|
||||||
|
.resume(throwing: ScreenRecordService.ScreenRecordError
|
||||||
|
.writeFailed("Failed to finalize video"))
|
||||||
|
} else {
|
||||||
|
cont.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
495
apps/macos/Sources/Moltbot/SessionMenuPreviewView.swift
Normal file
495
apps/macos/Sources/Moltbot/SessionMenuPreviewView.swift
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
import MoltbotChatUI
|
||||||
|
import MoltbotKit
|
||||||
|
import MoltbotProtocol
|
||||||
|
import OSLog
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SessionPreviewItem: Identifiable, Sendable {
|
||||||
|
let id: String
|
||||||
|
let role: PreviewRole
|
||||||
|
let text: String
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PreviewRole: String, Sendable {
|
||||||
|
case user
|
||||||
|
case assistant
|
||||||
|
case tool
|
||||||
|
case system
|
||||||
|
case other
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .user: "User"
|
||||||
|
case .assistant: "Agent"
|
||||||
|
case .tool: "Tool"
|
||||||
|
case .system: "System"
|
||||||
|
case .other: "Other"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actor SessionPreviewCache {
|
||||||
|
static let shared = SessionPreviewCache()
|
||||||
|
|
||||||
|
private struct CacheEntry {
|
||||||
|
let snapshot: SessionMenuPreviewSnapshot
|
||||||
|
let updatedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
private var entries: [String: CacheEntry] = [:]
|
||||||
|
|
||||||
|
func cachedSnapshot(for sessionKey: String, maxAge: TimeInterval) -> SessionMenuPreviewSnapshot? {
|
||||||
|
guard let entry = self.entries[sessionKey] else { return nil }
|
||||||
|
guard Date().timeIntervalSince(entry.updatedAt) < maxAge else { return nil }
|
||||||
|
return entry.snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
func store(snapshot: SessionMenuPreviewSnapshot, for sessionKey: String) {
|
||||||
|
self.entries[sessionKey] = CacheEntry(snapshot: snapshot, updatedAt: Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
func lastSnapshot(for sessionKey: String) -> SessionMenuPreviewSnapshot? {
|
||||||
|
self.entries[sessionKey]?.snapshot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actor SessionPreviewLimiter {
|
||||||
|
static let shared = SessionPreviewLimiter(maxConcurrent: 2)
|
||||||
|
|
||||||
|
private let maxConcurrent: Int
|
||||||
|
private var available: Int
|
||||||
|
private var waitQueue: [UUID] = []
|
||||||
|
private var waiters: [UUID: CheckedContinuation<Void, Never>] = [:]
|
||||||
|
|
||||||
|
init(maxConcurrent: Int) {
|
||||||
|
let normalized = max(1, maxConcurrent)
|
||||||
|
self.maxConcurrent = normalized
|
||||||
|
self.available = normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
func withPermit<T>(_ operation: () async throws -> T) async throws -> T {
|
||||||
|
await self.acquire()
|
||||||
|
defer { self.release() }
|
||||||
|
if Task.isCancelled { throw CancellationError() }
|
||||||
|
return try await operation()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func acquire() async {
|
||||||
|
if self.available > 0 {
|
||||||
|
self.available -= 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let id = UUID()
|
||||||
|
await withCheckedContinuation { cont in
|
||||||
|
self.waitQueue.append(id)
|
||||||
|
self.waiters[id] = cont
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func release() {
|
||||||
|
if let id = self.waitQueue.first {
|
||||||
|
self.waitQueue.removeFirst()
|
||||||
|
if let cont = self.waiters.removeValue(forKey: id) {
|
||||||
|
cont.resume()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.available = min(self.available + 1, self.maxConcurrent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
extension SessionPreviewCache {
|
||||||
|
func _testSet(
|
||||||
|
snapshot: SessionMenuPreviewSnapshot,
|
||||||
|
for sessionKey: String,
|
||||||
|
updatedAt: Date = Date())
|
||||||
|
{
|
||||||
|
self.entries[sessionKey] = CacheEntry(snapshot: snapshot, updatedAt: updatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _testReset() {
|
||||||
|
self.entries = [:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
struct SessionMenuPreviewSnapshot: Sendable {
|
||||||
|
let items: [SessionPreviewItem]
|
||||||
|
let status: SessionMenuPreviewView.LoadStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SessionMenuPreviewView: View {
|
||||||
|
let width: CGFloat
|
||||||
|
let maxLines: Int
|
||||||
|
let title: String
|
||||||
|
let items: [SessionPreviewItem]
|
||||||
|
let status: LoadStatus
|
||||||
|
|
||||||
|
@Environment(\.menuItemHighlighted) private var isHighlighted
|
||||||
|
|
||||||
|
enum LoadStatus: Equatable {
|
||||||
|
case loading
|
||||||
|
case ready
|
||||||
|
case empty
|
||||||
|
case error(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var primaryColor: Color {
|
||||||
|
if self.isHighlighted {
|
||||||
|
return Color(nsColor: .selectedMenuItemTextColor)
|
||||||
|
}
|
||||||
|
return Color(nsColor: .labelColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var secondaryColor: Color {
|
||||||
|
if self.isHighlighted {
|
||||||
|
return Color(nsColor: .selectedMenuItemTextColor).opacity(0.85)
|
||||||
|
}
|
||||||
|
return Color(nsColor: .secondaryLabelColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||||
|
Text(self.title)
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(self.secondaryColor)
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch self.status {
|
||||||
|
case .loading:
|
||||||
|
self.placeholder("Loading preview…")
|
||||||
|
case .empty:
|
||||||
|
self.placeholder("No recent messages")
|
||||||
|
case let .error(message):
|
||||||
|
self.placeholder(message)
|
||||||
|
case .ready:
|
||||||
|
if self.items.isEmpty {
|
||||||
|
self.placeholder("No recent messages")
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
ForEach(self.items) { item in
|
||||||
|
self.previewRow(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.padding(.leading, 16)
|
||||||
|
.padding(.trailing, 11)
|
||||||
|
.frame(width: max(1, self.width), alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func previewRow(_ item: SessionPreviewItem) -> some View {
|
||||||
|
HStack(alignment: .top, spacing: 4) {
|
||||||
|
Text(item.role.label)
|
||||||
|
.font(.caption2.monospacedDigit())
|
||||||
|
.foregroundStyle(self.roleColor(item.role))
|
||||||
|
.frame(width: 50, alignment: .leading)
|
||||||
|
|
||||||
|
Text(item.text)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(self.primaryColor)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.lineLimit(self.maxLines)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func roleColor(_ role: PreviewRole) -> Color {
|
||||||
|
if self.isHighlighted { return Color(nsColor: .selectedMenuItemTextColor).opacity(0.9) }
|
||||||
|
switch role {
|
||||||
|
case .user: return .accentColor
|
||||||
|
case .assistant: return .secondary
|
||||||
|
case .tool: return .orange
|
||||||
|
case .system: return .gray
|
||||||
|
case .other: return .secondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func placeholder(_ text: String) -> some View {
|
||||||
|
Text(text)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(self.primaryColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SessionMenuPreviewLoader {
|
||||||
|
private static let logger = Logger(subsystem: "bot.molt", category: "SessionPreview")
|
||||||
|
private static let previewTimeoutSeconds: Double = 4
|
||||||
|
private static let cacheMaxAgeSeconds: TimeInterval = 30
|
||||||
|
private static let previewMaxChars = 240
|
||||||
|
|
||||||
|
private struct PreviewTimeoutError: LocalizedError {
|
||||||
|
var errorDescription: String? { "preview timeout" }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func prewarm(sessionKeys: [String], maxItems: Int) async {
|
||||||
|
let keys = self.uniqueKeys(sessionKeys)
|
||||||
|
guard !keys.isEmpty else { return }
|
||||||
|
do {
|
||||||
|
let payload = try await self.requestPreview(keys: keys, maxItems: maxItems)
|
||||||
|
await self.cache(payload: payload, maxItems: maxItems)
|
||||||
|
} catch {
|
||||||
|
if self.isUnknownMethodError(error) { return }
|
||||||
|
let errorDescription = String(describing: error)
|
||||||
|
Self.logger.debug(
|
||||||
|
"Session preview prewarm failed count=\(keys.count, privacy: .public) " +
|
||||||
|
"error=\(errorDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func load(sessionKey: String, maxItems: Int) async -> SessionMenuPreviewSnapshot {
|
||||||
|
if let cached = await SessionPreviewCache.shared.cachedSnapshot(
|
||||||
|
for: sessionKey,
|
||||||
|
maxAge: cacheMaxAgeSeconds)
|
||||||
|
{
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let snapshot = try await self.fetchSnapshot(sessionKey: sessionKey, maxItems: maxItems)
|
||||||
|
await SessionPreviewCache.shared.store(snapshot: snapshot, for: sessionKey)
|
||||||
|
return snapshot
|
||||||
|
} catch is CancellationError {
|
||||||
|
return SessionMenuPreviewSnapshot(items: [], status: .loading)
|
||||||
|
} catch {
|
||||||
|
if let fallback = await SessionPreviewCache.shared.lastSnapshot(for: sessionKey) {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
let errorDescription = String(describing: error)
|
||||||
|
Self.logger.warning(
|
||||||
|
"Session preview failed session=\(sessionKey, privacy: .public) " +
|
||||||
|
"error=\(errorDescription, privacy: .public)")
|
||||||
|
return SessionMenuPreviewSnapshot(items: [], status: .error("Preview unavailable"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func fetchSnapshot(sessionKey: String, maxItems: Int) async throws -> SessionMenuPreviewSnapshot {
|
||||||
|
do {
|
||||||
|
let payload = try await self.requestPreview(keys: [sessionKey], maxItems: maxItems)
|
||||||
|
if let entry = payload.previews.first(where: { $0.key == sessionKey }) ?? payload.previews.first {
|
||||||
|
return self.snapshot(from: entry, maxItems: maxItems)
|
||||||
|
}
|
||||||
|
return SessionMenuPreviewSnapshot(items: [], status: .error("Preview unavailable"))
|
||||||
|
} catch {
|
||||||
|
if self.isUnknownMethodError(error) {
|
||||||
|
return try await self.fetchHistorySnapshot(sessionKey: sessionKey, maxItems: maxItems)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func requestPreview(
|
||||||
|
keys: [String],
|
||||||
|
maxItems: Int) async throws -> MoltbotSessionsPreviewPayload
|
||||||
|
{
|
||||||
|
let boundedItems = self.normalizeMaxItems(maxItems)
|
||||||
|
let timeoutMs = Int(self.previewTimeoutSeconds * 1000)
|
||||||
|
return try await SessionPreviewLimiter.shared.withPermit {
|
||||||
|
try await AsyncTimeout.withTimeout(
|
||||||
|
seconds: self.previewTimeoutSeconds,
|
||||||
|
onTimeout: { PreviewTimeoutError() },
|
||||||
|
operation: {
|
||||||
|
try await GatewayConnection.shared.sessionsPreview(
|
||||||
|
keys: keys,
|
||||||
|
limit: boundedItems,
|
||||||
|
maxChars: self.previewMaxChars,
|
||||||
|
timeoutMs: timeoutMs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func fetchHistorySnapshot(
|
||||||
|
sessionKey: String,
|
||||||
|
maxItems: Int) async throws -> SessionMenuPreviewSnapshot
|
||||||
|
{
|
||||||
|
let timeoutMs = Int(self.previewTimeoutSeconds * 1000)
|
||||||
|
let payload = try await SessionPreviewLimiter.shared.withPermit {
|
||||||
|
try await AsyncTimeout.withTimeout(
|
||||||
|
seconds: self.previewTimeoutSeconds,
|
||||||
|
onTimeout: { PreviewTimeoutError() },
|
||||||
|
operation: {
|
||||||
|
try await GatewayConnection.shared.chatHistory(
|
||||||
|
sessionKey: sessionKey,
|
||||||
|
limit: self.previewLimit(for: maxItems),
|
||||||
|
timeoutMs: timeoutMs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
let built = Self.previewItems(from: payload, maxItems: maxItems)
|
||||||
|
return Self.snapshot(from: built)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func snapshot(from items: [SessionPreviewItem]) -> SessionMenuPreviewSnapshot {
|
||||||
|
SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func snapshot(
|
||||||
|
from entry: MoltbotSessionPreviewEntry,
|
||||||
|
maxItems: Int) -> SessionMenuPreviewSnapshot
|
||||||
|
{
|
||||||
|
let items = self.previewItems(from: entry, maxItems: maxItems)
|
||||||
|
let normalized = entry.status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
switch normalized {
|
||||||
|
case "ok":
|
||||||
|
return SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready)
|
||||||
|
case "empty":
|
||||||
|
return SessionMenuPreviewSnapshot(items: items, status: .empty)
|
||||||
|
case "missing":
|
||||||
|
return SessionMenuPreviewSnapshot(items: items, status: .error("Session missing"))
|
||||||
|
default:
|
||||||
|
return SessionMenuPreviewSnapshot(items: items, status: .error("Preview unavailable"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func cache(payload: MoltbotSessionsPreviewPayload, maxItems: Int) async {
|
||||||
|
for entry in payload.previews {
|
||||||
|
let snapshot = self.snapshot(from: entry, maxItems: maxItems)
|
||||||
|
await SessionPreviewCache.shared.store(snapshot: snapshot, for: entry.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func previewLimit(for maxItems: Int) -> Int {
|
||||||
|
let boundedItems = self.normalizeMaxItems(maxItems)
|
||||||
|
return min(max(boundedItems * 3, 20), 120)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizeMaxItems(_ maxItems: Int) -> Int {
|
||||||
|
max(1, min(maxItems, 50))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func previewItems(
|
||||||
|
from entry: MoltbotSessionPreviewEntry,
|
||||||
|
maxItems: Int) -> [SessionPreviewItem]
|
||||||
|
{
|
||||||
|
let boundedItems = self.normalizeMaxItems(maxItems)
|
||||||
|
let built: [SessionPreviewItem] = entry.items.enumerated().compactMap { index, item in
|
||||||
|
let text = item.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !text.isEmpty else { return nil }
|
||||||
|
let role = self.previewRoleFromRaw(item.role)
|
||||||
|
return SessionPreviewItem(id: "\(entry.key)-\(index)", role: role, text: text)
|
||||||
|
}
|
||||||
|
|
||||||
|
let trimmed = built.suffix(boundedItems)
|
||||||
|
return Array(trimmed.reversed())
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func previewItems(
|
||||||
|
from payload: MoltbotChatHistoryPayload,
|
||||||
|
maxItems: Int) -> [SessionPreviewItem]
|
||||||
|
{
|
||||||
|
let boundedItems = self.normalizeMaxItems(maxItems)
|
||||||
|
let raw: [MoltbotKit.AnyCodable] = payload.messages ?? []
|
||||||
|
let messages = self.decodeMessages(raw)
|
||||||
|
let built = messages.compactMap { message -> SessionPreviewItem? in
|
||||||
|
guard let text = self.previewText(for: message) else { return nil }
|
||||||
|
let isTool = self.isToolCall(message)
|
||||||
|
let role = self.previewRole(message.role, isTool: isTool)
|
||||||
|
let id = "\(message.timestamp ?? 0)-\(UUID().uuidString)"
|
||||||
|
return SessionPreviewItem(id: id, role: role, text: text)
|
||||||
|
}
|
||||||
|
|
||||||
|
let trimmed = built.suffix(boundedItems)
|
||||||
|
return Array(trimmed.reversed())
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func decodeMessages(_ raw: [MoltbotKit.AnyCodable]) -> [MoltbotChatMessage] {
|
||||||
|
raw.compactMap { item in
|
||||||
|
guard let data = try? JSONEncoder().encode(item) else { return nil }
|
||||||
|
return try? JSONDecoder().decode(MoltbotChatMessage.self, from: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func previewRole(_ raw: String, isTool: Bool) -> PreviewRole {
|
||||||
|
if isTool { return .tool }
|
||||||
|
return self.previewRoleFromRaw(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func previewRoleFromRaw(_ raw: String) -> PreviewRole {
|
||||||
|
switch raw.lowercased() {
|
||||||
|
case "user": .user
|
||||||
|
case "assistant": .assistant
|
||||||
|
case "system": .system
|
||||||
|
case "tool": .tool
|
||||||
|
default: .other
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func previewText(for message: MoltbotChatMessage) -> String? {
|
||||||
|
let text = message.content.compactMap(\.text).joined(separator: "\n")
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !text.isEmpty { return text }
|
||||||
|
|
||||||
|
let toolNames = self.toolNames(for: message)
|
||||||
|
if !toolNames.isEmpty {
|
||||||
|
let shown = toolNames.prefix(2)
|
||||||
|
let overflow = toolNames.count - shown.count
|
||||||
|
var label = "call \(shown.joined(separator: ", "))"
|
||||||
|
if overflow > 0 { label += " +\(overflow)" }
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
|
||||||
|
if let media = self.mediaSummary(for: message) {
|
||||||
|
return media
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isToolCall(_ message: MoltbotChatMessage) -> Bool {
|
||||||
|
if message.toolName?.nonEmpty != nil { return true }
|
||||||
|
return message.content.contains { $0.name?.nonEmpty != nil || $0.type?.lowercased() == "toolcall" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func toolNames(for message: MoltbotChatMessage) -> [String] {
|
||||||
|
var names: [String] = []
|
||||||
|
for content in message.content {
|
||||||
|
if let name = content.name?.nonEmpty {
|
||||||
|
names.append(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let toolName = message.toolName?.nonEmpty {
|
||||||
|
names.append(toolName)
|
||||||
|
}
|
||||||
|
return Self.dedupePreservingOrder(names)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func mediaSummary(for message: MoltbotChatMessage) -> String? {
|
||||||
|
let types = message.content.compactMap { content -> String? in
|
||||||
|
let raw = content.type?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
guard let raw, !raw.isEmpty else { return nil }
|
||||||
|
if raw == "text" || raw == "toolcall" { return nil }
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
guard let first = types.first else { return nil }
|
||||||
|
return "[\(first)]"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func dedupePreservingOrder(_ values: [String]) -> [String] {
|
||||||
|
var seen = Set<String>()
|
||||||
|
var result: [String] = []
|
||||||
|
for value in values where !seen.contains(value) {
|
||||||
|
seen.insert(value)
|
||||||
|
result.append(value)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func uniqueKeys(_ keys: [String]) -> [String] {
|
||||||
|
let trimmed = keys.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
return self.dedupePreservingOrder(trimmed.filter { !$0.isEmpty })
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isUnknownMethodError(_ error: Error) -> Bool {
|
||||||
|
guard let response = error as? GatewayResponseError else { return false }
|
||||||
|
guard response.code == ErrorCode.invalidRequest.rawValue else { return false }
|
||||||
|
let message = response.message.lowercased()
|
||||||
|
return message.contains("unknown method")
|
||||||
|
}
|
||||||
|
}
|
||||||
226
apps/macos/Sources/Moltbot/TailscaleService.swift
Normal file
226
apps/macos/Sources/Moltbot/TailscaleService.swift
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import os
|
||||||
|
#if canImport(Darwin)
|
||||||
|
import Darwin
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// 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: "bot.molt", 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() }
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
init(
|
||||||
|
isInstalled: Bool,
|
||||||
|
isRunning: Bool,
|
||||||
|
tailscaleHostname: String? = nil,
|
||||||
|
tailscaleIP: String? = nil,
|
||||||
|
statusError: String? = nil)
|
||||||
|
{
|
||||||
|
self.isInstalled = isInstalled
|
||||||
|
self.isRunning = isRunning
|
||||||
|
self.tailscaleHostname = tailscaleHostname
|
||||||
|
self.tailscaleIP = tailscaleIP
|
||||||
|
self.statusError = statusError
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
func checkAppInstallation() -> Bool {
|
||||||
|
let installed = FileManager().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 {
|
||||||
|
let previousIP = self.tailscaleIP
|
||||||
|
self.isInstalled = self.checkAppInstallation()
|
||||||
|
if !self.isInstalled {
|
||||||
|
self.isRunning = false
|
||||||
|
self.tailscaleHostname = nil
|
||||||
|
self.tailscaleIP = nil
|
||||||
|
self.statusError = "Tailscale is not installed"
|
||||||
|
} else 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.tailscaleIP == nil, let fallback = Self.detectTailnetIPv4() {
|
||||||
|
self.tailscaleIP = fallback
|
||||||
|
if !self.isRunning {
|
||||||
|
self.isRunning = true
|
||||||
|
}
|
||||||
|
self.statusError = nil
|
||||||
|
self.logger.info("Tailscale interface IP detected (fallback) ip=\(fallback, privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if previousIP != self.tailscaleIP {
|
||||||
|
await GatewayEndpointStore.shared.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func isTailnetIPv4(_ address: String) -> Bool {
|
||||||
|
let parts = address.split(separator: ".")
|
||||||
|
guard parts.count == 4 else { return false }
|
||||||
|
let octets = parts.compactMap { Int($0) }
|
||||||
|
guard octets.count == 4 else { return false }
|
||||||
|
let a = octets[0]
|
||||||
|
let b = octets[1]
|
||||||
|
return a == 100 && b >= 64 && b <= 127
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func detectTailnetIPv4() -> String? {
|
||||||
|
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||||
|
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||||
|
defer { freeifaddrs(addrList) }
|
||||||
|
|
||||||
|
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
|
||||||
|
let flags = Int32(ptr.pointee.ifa_flags)
|
||||||
|
let isUp = (flags & IFF_UP) != 0
|
||||||
|
let isLoopback = (flags & IFF_LOOPBACK) != 0
|
||||||
|
let family = ptr.pointee.ifa_addr.pointee.sa_family
|
||||||
|
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
|
||||||
|
|
||||||
|
var addr = ptr.pointee.ifa_addr.pointee
|
||||||
|
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||||
|
let result = getnameinfo(
|
||||||
|
&addr,
|
||||||
|
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
|
||||||
|
&buffer,
|
||||||
|
socklen_t(buffer.count),
|
||||||
|
nil,
|
||||||
|
0,
|
||||||
|
NI_NUMERICHOST)
|
||||||
|
guard result == 0 else { continue }
|
||||||
|
let len = buffer.prefix { $0 != 0 }
|
||||||
|
let bytes = len.map { UInt8(bitPattern: $0) }
|
||||||
|
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
|
||||||
|
if Self.isTailnetIPv4(ip) { return ip }
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func fallbackTailnetIPv4() -> String? {
|
||||||
|
self.detectTailnetIPv4()
|
||||||
|
}
|
||||||
|
}
|
||||||
158
apps/macos/Sources/Moltbot/TalkAudioPlayer.swift
Normal file
158
apps/macos/Sources/Moltbot/TalkAudioPlayer.swift
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class TalkAudioPlayer: NSObject, @preconcurrency AVAudioPlayerDelegate {
|
||||||
|
static let shared = TalkAudioPlayer()
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "bot.molt", category: "talk.tts")
|
||||||
|
private var player: AVAudioPlayer?
|
||||||
|
private var playback: Playback?
|
||||||
|
|
||||||
|
private final class Playback: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var finished = false
|
||||||
|
private var continuation: CheckedContinuation<TalkPlaybackResult, Never>?
|
||||||
|
private var watchdog: Task<Void, Never>?
|
||||||
|
|
||||||
|
func setContinuation(_ continuation: CheckedContinuation<TalkPlaybackResult, Never>) {
|
||||||
|
self.lock.lock()
|
||||||
|
defer { self.lock.unlock() }
|
||||||
|
self.continuation = continuation
|
||||||
|
}
|
||||||
|
|
||||||
|
func setWatchdog(_ task: Task<Void, Never>?) {
|
||||||
|
self.lock.lock()
|
||||||
|
let old = self.watchdog
|
||||||
|
self.watchdog = task
|
||||||
|
self.lock.unlock()
|
||||||
|
old?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelWatchdog() {
|
||||||
|
self.setWatchdog(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func finish(_ result: TalkPlaybackResult) {
|
||||||
|
let continuation: CheckedContinuation<TalkPlaybackResult, Never>?
|
||||||
|
self.lock.lock()
|
||||||
|
if self.finished {
|
||||||
|
continuation = nil
|
||||||
|
} else {
|
||||||
|
self.finished = true
|
||||||
|
continuation = self.continuation
|
||||||
|
self.continuation = nil
|
||||||
|
}
|
||||||
|
self.lock.unlock()
|
||||||
|
continuation?.resume(returning: result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func play(data: Data) async -> TalkPlaybackResult {
|
||||||
|
self.stopInternal()
|
||||||
|
|
||||||
|
let playback = Playback()
|
||||||
|
self.playback = playback
|
||||||
|
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
|
playback.setContinuation(continuation)
|
||||||
|
do {
|
||||||
|
let player = try AVAudioPlayer(data: data)
|
||||||
|
self.player = player
|
||||||
|
|
||||||
|
player.delegate = self
|
||||||
|
player.prepareToPlay()
|
||||||
|
|
||||||
|
self.armWatchdog(playback: playback)
|
||||||
|
|
||||||
|
let ok = player.play()
|
||||||
|
if !ok {
|
||||||
|
self.logger.error("talk audio player refused to play")
|
||||||
|
self.finish(playback: playback, result: TalkPlaybackResult(finished: false, interruptedAt: nil))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
self.logger.error("talk audio player failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
self.finish(playback: playback, result: TalkPlaybackResult(finished: false, interruptedAt: nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() -> Double? {
|
||||||
|
guard let player else { return nil }
|
||||||
|
let time = player.currentTime
|
||||||
|
self.stopInternal(interruptedAt: time)
|
||||||
|
return time
|
||||||
|
}
|
||||||
|
|
||||||
|
func audioPlayerDidFinishPlaying(_: AVAudioPlayer, successfully flag: Bool) {
|
||||||
|
self.stopInternal(finished: flag)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopInternal(finished: Bool = false, interruptedAt: Double? = nil) {
|
||||||
|
guard let playback else { return }
|
||||||
|
let result = TalkPlaybackResult(finished: finished, interruptedAt: interruptedAt)
|
||||||
|
self.finish(playback: playback, result: result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finish(playback: Playback, result: TalkPlaybackResult) {
|
||||||
|
playback.cancelWatchdog()
|
||||||
|
playback.finish(result)
|
||||||
|
|
||||||
|
guard self.playback === playback else { return }
|
||||||
|
self.playback = nil
|
||||||
|
self.player?.stop()
|
||||||
|
self.player = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopInternal() {
|
||||||
|
if let playback = self.playback {
|
||||||
|
let interruptedAt = self.player?.currentTime
|
||||||
|
self.finish(
|
||||||
|
playback: playback,
|
||||||
|
result: TalkPlaybackResult(finished: false, interruptedAt: interruptedAt))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.player?.stop()
|
||||||
|
self.player = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func armWatchdog(playback: Playback) {
|
||||||
|
playback.setWatchdog(Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await Task.sleep(nanoseconds: 650_000_000)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
|
||||||
|
guard self.playback === playback else { return }
|
||||||
|
if self.player?.isPlaying != true {
|
||||||
|
self.logger.error("talk audio player did not start playing")
|
||||||
|
self.finish(playback: playback, result: TalkPlaybackResult(finished: false, interruptedAt: nil))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let duration = self.player?.duration ?? 0
|
||||||
|
let timeoutSeconds = min(max(2.0, duration + 2.0), 5 * 60.0)
|
||||||
|
do {
|
||||||
|
try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
|
||||||
|
guard self.playback === playback else { return }
|
||||||
|
guard self.player?.isPlaying == true else { return }
|
||||||
|
self.logger.error("talk audio player watchdog fired")
|
||||||
|
self.finish(playback: playback, result: TalkPlaybackResult(finished: false, interruptedAt: nil))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TalkPlaybackResult: Sendable {
|
||||||
|
let finished: Bool
|
||||||
|
let interruptedAt: Double?
|
||||||
|
}
|
||||||
69
apps/macos/Sources/Moltbot/TalkModeController.swift
Normal file
69
apps/macos/Sources/Moltbot/TalkModeController.swift
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import Observation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class TalkModeController {
|
||||||
|
static let shared = TalkModeController()
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "bot.molt", category: "talk.controller")
|
||||||
|
|
||||||
|
private(set) var phase: TalkModePhase = .idle
|
||||||
|
private(set) var isPaused: Bool = false
|
||||||
|
|
||||||
|
func setEnabled(_ enabled: Bool) async {
|
||||||
|
self.logger.info("talk enabled=\(enabled)")
|
||||||
|
if enabled {
|
||||||
|
TalkOverlayController.shared.present()
|
||||||
|
} else {
|
||||||
|
TalkOverlayController.shared.dismiss()
|
||||||
|
}
|
||||||
|
await TalkModeRuntime.shared.setEnabled(enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updatePhase(_ phase: TalkModePhase) {
|
||||||
|
self.phase = phase
|
||||||
|
TalkOverlayController.shared.updatePhase(phase)
|
||||||
|
let effectivePhase = self.isPaused ? "paused" : phase.rawValue
|
||||||
|
Task {
|
||||||
|
await GatewayConnection.shared.talkMode(
|
||||||
|
enabled: AppStateStore.shared.talkEnabled,
|
||||||
|
phase: effectivePhase)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateLevel(_ level: Double) {
|
||||||
|
TalkOverlayController.shared.updateLevel(level)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setPaused(_ paused: Bool) {
|
||||||
|
guard self.isPaused != paused else { return }
|
||||||
|
self.logger.info("talk paused=\(paused)")
|
||||||
|
self.isPaused = paused
|
||||||
|
TalkOverlayController.shared.updatePaused(paused)
|
||||||
|
let effectivePhase = paused ? "paused" : self.phase.rawValue
|
||||||
|
Task {
|
||||||
|
await GatewayConnection.shared.talkMode(
|
||||||
|
enabled: AppStateStore.shared.talkEnabled,
|
||||||
|
phase: effectivePhase)
|
||||||
|
}
|
||||||
|
Task { await TalkModeRuntime.shared.setPaused(paused) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func togglePaused() {
|
||||||
|
self.setPaused(!self.isPaused)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopSpeaking(reason: TalkStopReason = .userTap) {
|
||||||
|
Task { await TalkModeRuntime.shared.stopSpeaking(reason: reason) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func exitTalkMode() {
|
||||||
|
Task { await AppStateStore.shared.setTalkEnabled(false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TalkStopReason {
|
||||||
|
case userTap
|
||||||
|
case speech
|
||||||
|
case manual
|
||||||
|
}
|
||||||
953
apps/macos/Sources/Moltbot/TalkModeRuntime.swift
Normal file
953
apps/macos/Sources/Moltbot/TalkModeRuntime.swift
Normal file
@@ -0,0 +1,953 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import MoltbotChatUI
|
||||||
|
import MoltbotKit
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
import Speech
|
||||||
|
|
||||||
|
actor TalkModeRuntime {
|
||||||
|
static let shared = TalkModeRuntime()
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "bot.molt", category: "talk.runtime")
|
||||||
|
private let ttsLogger = Logger(subsystem: "bot.molt", category: "talk.tts")
|
||||||
|
private static let defaultModelIdFallback = "eleven_v3"
|
||||||
|
|
||||||
|
private final class RMSMeter: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var latestRMS: Double = 0
|
||||||
|
|
||||||
|
func set(_ rms: Double) {
|
||||||
|
self.lock.lock()
|
||||||
|
self.latestRMS = rms
|
||||||
|
self.lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func get() -> Double {
|
||||||
|
self.lock.lock()
|
||||||
|
let value = self.latestRMS
|
||||||
|
self.lock.unlock()
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var recognizer: SFSpeechRecognizer?
|
||||||
|
private var audioEngine: AVAudioEngine?
|
||||||
|
private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
|
||||||
|
private var recognitionTask: SFSpeechRecognitionTask?
|
||||||
|
private var recognitionGeneration: Int = 0
|
||||||
|
private var rmsTask: Task<Void, Never>?
|
||||||
|
private let rmsMeter = RMSMeter()
|
||||||
|
|
||||||
|
private var captureTask: Task<Void, Never>?
|
||||||
|
private var silenceTask: Task<Void, Never>?
|
||||||
|
private var phase: TalkModePhase = .idle
|
||||||
|
private var isEnabled = false
|
||||||
|
private var isPaused = false
|
||||||
|
private var lifecycleGeneration: Int = 0
|
||||||
|
|
||||||
|
private var lastHeard: Date?
|
||||||
|
private var noiseFloorRMS: Double = 1e-4
|
||||||
|
private var lastTranscript: String = ""
|
||||||
|
private var lastSpeechEnergyAt: Date?
|
||||||
|
|
||||||
|
private var defaultVoiceId: String?
|
||||||
|
private var currentVoiceId: String?
|
||||||
|
private var defaultModelId: String?
|
||||||
|
private var currentModelId: String?
|
||||||
|
private var voiceOverrideActive = false
|
||||||
|
private var modelOverrideActive = false
|
||||||
|
private var defaultOutputFormat: String?
|
||||||
|
private var interruptOnSpeech: Bool = true
|
||||||
|
private var lastInterruptedAtSeconds: Double?
|
||||||
|
private var voiceAliases: [String: String] = [:]
|
||||||
|
private var lastSpokenText: String?
|
||||||
|
private var apiKey: String?
|
||||||
|
private var fallbackVoiceId: String?
|
||||||
|
private var lastPlaybackWasPCM: Bool = false
|
||||||
|
|
||||||
|
private let silenceWindow: TimeInterval = 0.7
|
||||||
|
private let minSpeechRMS: Double = 1e-3
|
||||||
|
private let speechBoostFactor: Double = 6.0
|
||||||
|
|
||||||
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
|
func setEnabled(_ enabled: Bool) async {
|
||||||
|
guard enabled != self.isEnabled else { return }
|
||||||
|
self.isEnabled = enabled
|
||||||
|
self.lifecycleGeneration &+= 1
|
||||||
|
if enabled {
|
||||||
|
await self.start()
|
||||||
|
} else {
|
||||||
|
await self.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setPaused(_ paused: Bool) async {
|
||||||
|
guard paused != self.isPaused else { return }
|
||||||
|
self.isPaused = paused
|
||||||
|
await MainActor.run { TalkModeController.shared.updateLevel(0) }
|
||||||
|
|
||||||
|
guard self.isEnabled else { return }
|
||||||
|
|
||||||
|
if paused {
|
||||||
|
self.lastTranscript = ""
|
||||||
|
self.lastHeard = nil
|
||||||
|
self.lastSpeechEnergyAt = nil
|
||||||
|
await self.stopRecognition()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.phase == .idle || self.phase == .listening {
|
||||||
|
await self.startRecognition()
|
||||||
|
self.phase = .listening
|
||||||
|
await MainActor.run { TalkModeController.shared.updatePhase(.listening) }
|
||||||
|
self.startSilenceMonitor()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isCurrent(_ generation: Int) -> Bool {
|
||||||
|
generation == self.lifecycleGeneration && self.isEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
private func start() async {
|
||||||
|
let gen = self.lifecycleGeneration
|
||||||
|
guard voiceWakeSupported else { return }
|
||||||
|
guard PermissionManager.voiceWakePermissionsGranted() else {
|
||||||
|
self.logger.debug("talk runtime not starting: permissions missing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await self.reloadConfig()
|
||||||
|
guard self.isCurrent(gen) else { return }
|
||||||
|
if self.isPaused {
|
||||||
|
self.phase = .idle
|
||||||
|
await MainActor.run {
|
||||||
|
TalkModeController.shared.updateLevel(0)
|
||||||
|
TalkModeController.shared.updatePhase(.idle)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await self.startRecognition()
|
||||||
|
guard self.isCurrent(gen) else { return }
|
||||||
|
self.phase = .listening
|
||||||
|
await MainActor.run { TalkModeController.shared.updatePhase(.listening) }
|
||||||
|
self.startSilenceMonitor()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stop() async {
|
||||||
|
self.captureTask?.cancel()
|
||||||
|
self.captureTask = nil
|
||||||
|
self.silenceTask?.cancel()
|
||||||
|
self.silenceTask = nil
|
||||||
|
|
||||||
|
// Stop audio before changing phase (stopSpeaking is gated on .speaking).
|
||||||
|
await self.stopSpeaking(reason: .manual)
|
||||||
|
|
||||||
|
self.lastTranscript = ""
|
||||||
|
self.lastHeard = nil
|
||||||
|
self.lastSpeechEnergyAt = nil
|
||||||
|
self.phase = .idle
|
||||||
|
await self.stopRecognition()
|
||||||
|
await MainActor.run {
|
||||||
|
TalkModeController.shared.updateLevel(0)
|
||||||
|
TalkModeController.shared.updatePhase(.idle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Speech recognition
|
||||||
|
|
||||||
|
private struct RecognitionUpdate {
|
||||||
|
let transcript: String?
|
||||||
|
let hasConfidence: Bool
|
||||||
|
let isFinal: Bool
|
||||||
|
let errorDescription: String?
|
||||||
|
let generation: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startRecognition() async {
|
||||||
|
await self.stopRecognition()
|
||||||
|
self.recognitionGeneration &+= 1
|
||||||
|
let generation = self.recognitionGeneration
|
||||||
|
|
||||||
|
let locale = await MainActor.run { AppStateStore.shared.voiceWakeLocaleID }
|
||||||
|
self.recognizer = SFSpeechRecognizer(locale: Locale(identifier: locale))
|
||||||
|
guard let recognizer, recognizer.isAvailable else {
|
||||||
|
self.logger.error("talk recognizer unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest()
|
||||||
|
self.recognitionRequest?.shouldReportPartialResults = true
|
||||||
|
guard let request = self.recognitionRequest else { return }
|
||||||
|
|
||||||
|
if self.audioEngine == nil {
|
||||||
|
self.audioEngine = AVAudioEngine()
|
||||||
|
}
|
||||||
|
guard let audioEngine = self.audioEngine else { return }
|
||||||
|
|
||||||
|
let input = audioEngine.inputNode
|
||||||
|
let format = input.outputFormat(forBus: 0)
|
||||||
|
input.removeTap(onBus: 0)
|
||||||
|
let meter = self.rmsMeter
|
||||||
|
input.installTap(onBus: 0, bufferSize: 2048, format: format) { [weak request, meter] buffer, _ in
|
||||||
|
request?.append(buffer)
|
||||||
|
if let rms = Self.rmsLevel(buffer: buffer) {
|
||||||
|
meter.set(rms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
audioEngine.prepare()
|
||||||
|
do {
|
||||||
|
try audioEngine.start()
|
||||||
|
} catch {
|
||||||
|
self.logger.error("talk audio engine start failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.startRMSTicker(meter: meter)
|
||||||
|
|
||||||
|
self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self, generation] result, error in
|
||||||
|
guard let self else { return }
|
||||||
|
let segments = result?.bestTranscription.segments ?? []
|
||||||
|
let transcript = result?.bestTranscription.formattedString
|
||||||
|
let update = RecognitionUpdate(
|
||||||
|
transcript: transcript,
|
||||||
|
hasConfidence: segments.contains { $0.confidence > 0.6 },
|
||||||
|
isFinal: result?.isFinal ?? false,
|
||||||
|
errorDescription: error?.localizedDescription,
|
||||||
|
generation: generation)
|
||||||
|
Task { await self.handleRecognition(update) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopRecognition() async {
|
||||||
|
self.recognitionGeneration &+= 1
|
||||||
|
self.recognitionTask?.cancel()
|
||||||
|
self.recognitionTask = nil
|
||||||
|
self.recognitionRequest?.endAudio()
|
||||||
|
self.recognitionRequest = nil
|
||||||
|
self.audioEngine?.inputNode.removeTap(onBus: 0)
|
||||||
|
self.audioEngine?.stop()
|
||||||
|
self.audioEngine = nil
|
||||||
|
self.recognizer = nil
|
||||||
|
self.rmsTask?.cancel()
|
||||||
|
self.rmsTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startRMSTicker(meter: RMSMeter) {
|
||||||
|
self.rmsTask?.cancel()
|
||||||
|
self.rmsTask = Task { [weak self, meter] in
|
||||||
|
while let self {
|
||||||
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
await self.noteAudioLevel(rms: meter.get())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleRecognition(_ update: RecognitionUpdate) async {
|
||||||
|
guard update.generation == self.recognitionGeneration else { return }
|
||||||
|
guard !self.isPaused else { return }
|
||||||
|
if let errorDescription = update.errorDescription {
|
||||||
|
self.logger.debug("talk recognition error: \(errorDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
guard let transcript = update.transcript else { return }
|
||||||
|
|
||||||
|
let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if self.phase == .speaking, self.interruptOnSpeech {
|
||||||
|
if await self.shouldInterrupt(transcript: trimmed, hasConfidence: update.hasConfidence) {
|
||||||
|
await self.stopSpeaking(reason: .speech)
|
||||||
|
self.lastTranscript = ""
|
||||||
|
self.lastHeard = nil
|
||||||
|
await self.startListening()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard self.phase == .listening else { return }
|
||||||
|
|
||||||
|
if !trimmed.isEmpty {
|
||||||
|
self.lastTranscript = trimmed
|
||||||
|
self.lastHeard = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
if update.isFinal {
|
||||||
|
self.lastTranscript = trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Silence handling
|
||||||
|
|
||||||
|
private func startSilenceMonitor() {
|
||||||
|
self.silenceTask?.cancel()
|
||||||
|
self.silenceTask = Task { [weak self] in
|
||||||
|
await self?.silenceLoop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func silenceLoop() async {
|
||||||
|
while self.isEnabled {
|
||||||
|
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||||
|
await self.checkSilence()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func checkSilence() async {
|
||||||
|
guard !self.isPaused else { return }
|
||||||
|
guard self.phase == .listening else { return }
|
||||||
|
let transcript = self.lastTranscript.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !transcript.isEmpty else { return }
|
||||||
|
guard let lastHeard else { return }
|
||||||
|
let elapsed = Date().timeIntervalSince(lastHeard)
|
||||||
|
guard elapsed >= self.silenceWindow else { return }
|
||||||
|
await self.finalizeTranscript(transcript)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startListening() async {
|
||||||
|
self.phase = .listening
|
||||||
|
self.lastTranscript = ""
|
||||||
|
self.lastHeard = nil
|
||||||
|
await MainActor.run {
|
||||||
|
TalkModeController.shared.updatePhase(.listening)
|
||||||
|
TalkModeController.shared.updateLevel(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finalizeTranscript(_ text: String) async {
|
||||||
|
self.lastTranscript = ""
|
||||||
|
self.lastHeard = nil
|
||||||
|
self.phase = .thinking
|
||||||
|
await MainActor.run { TalkModeController.shared.updatePhase(.thinking) }
|
||||||
|
await self.stopRecognition()
|
||||||
|
await self.sendAndSpeak(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Gateway + TTS
|
||||||
|
|
||||||
|
private func sendAndSpeak(_ transcript: String) async {
|
||||||
|
let gen = self.lifecycleGeneration
|
||||||
|
await self.reloadConfig()
|
||||||
|
guard self.isCurrent(gen) else { return }
|
||||||
|
let prompt = self.buildPrompt(transcript: transcript)
|
||||||
|
let activeSessionKey = await MainActor.run { WebChatManager.shared.activeSessionKey }
|
||||||
|
let sessionKey: String = if let activeSessionKey {
|
||||||
|
activeSessionKey
|
||||||
|
} else {
|
||||||
|
await GatewayConnection.shared.mainSessionKey()
|
||||||
|
}
|
||||||
|
let runId = UUID().uuidString
|
||||||
|
let startedAt = Date().timeIntervalSince1970
|
||||||
|
self.logger.info(
|
||||||
|
"talk send start runId=\(runId, privacy: .public) " +
|
||||||
|
"session=\(sessionKey, privacy: .public) " +
|
||||||
|
"chars=\(prompt.count, privacy: .public)")
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response = try await GatewayConnection.shared.chatSend(
|
||||||
|
sessionKey: sessionKey,
|
||||||
|
message: prompt,
|
||||||
|
thinking: "low",
|
||||||
|
idempotencyKey: runId,
|
||||||
|
attachments: [])
|
||||||
|
guard self.isCurrent(gen) else { return }
|
||||||
|
self.logger.info(
|
||||||
|
"talk chat.send ok runId=\(response.runId, privacy: .public) " +
|
||||||
|
"session=\(sessionKey, privacy: .public)")
|
||||||
|
|
||||||
|
guard let assistantText = await self.waitForAssistantText(
|
||||||
|
sessionKey: sessionKey,
|
||||||
|
since: startedAt,
|
||||||
|
timeoutSeconds: 45)
|
||||||
|
else {
|
||||||
|
self.logger.warning("talk assistant text missing after timeout")
|
||||||
|
await self.startListening()
|
||||||
|
await self.startRecognition()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard self.isCurrent(gen) else { return }
|
||||||
|
|
||||||
|
self.logger.info("talk assistant text len=\(assistantText.count, privacy: .public)")
|
||||||
|
await self.playAssistant(text: assistantText)
|
||||||
|
guard self.isCurrent(gen) else { return }
|
||||||
|
await self.resumeListeningIfNeeded()
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
self.logger.error("talk chat.send failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
await self.resumeListeningIfNeeded()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resumeListeningIfNeeded() async {
|
||||||
|
if self.isPaused {
|
||||||
|
self.lastTranscript = ""
|
||||||
|
self.lastHeard = nil
|
||||||
|
self.lastSpeechEnergyAt = nil
|
||||||
|
await MainActor.run {
|
||||||
|
TalkModeController.shared.updateLevel(0)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await self.startListening()
|
||||||
|
await self.startRecognition()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildPrompt(transcript: String) -> String {
|
||||||
|
let interrupted = self.lastInterruptedAtSeconds
|
||||||
|
self.lastInterruptedAtSeconds = nil
|
||||||
|
return TalkPromptBuilder.build(transcript: transcript, interruptedAtSeconds: interrupted)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitForAssistantText(
|
||||||
|
sessionKey: String,
|
||||||
|
since: Double,
|
||||||
|
timeoutSeconds: Int) async -> String?
|
||||||
|
{
|
||||||
|
let deadline = Date().addingTimeInterval(TimeInterval(timeoutSeconds))
|
||||||
|
while Date() < deadline {
|
||||||
|
if let text = await self.latestAssistantText(sessionKey: sessionKey, since: since) {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func latestAssistantText(sessionKey: String, since: Double? = nil) async -> String? {
|
||||||
|
do {
|
||||||
|
let history = try await GatewayConnection.shared.chatHistory(sessionKey: sessionKey)
|
||||||
|
let messages = history.messages ?? []
|
||||||
|
let decoded: [MoltbotChatMessage] = messages.compactMap { item in
|
||||||
|
guard let data = try? JSONEncoder().encode(item) else { return nil }
|
||||||
|
return try? JSONDecoder().decode(MoltbotChatMessage.self, from: data)
|
||||||
|
}
|
||||||
|
let assistant = decoded.last { message in
|
||||||
|
guard message.role == "assistant" else { return false }
|
||||||
|
guard let since else { return true }
|
||||||
|
guard let timestamp = message.timestamp else { return false }
|
||||||
|
return TalkHistoryTimestamp.isAfter(timestamp, sinceSeconds: since)
|
||||||
|
}
|
||||||
|
guard let assistant else { return nil }
|
||||||
|
let text = assistant.content.compactMap(\.text).joined(separator: "\n")
|
||||||
|
let trimmed = text.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
||||||
|
return trimmed.isEmpty ? nil : trimmed
|
||||||
|
} catch {
|
||||||
|
self.logger.error("talk history fetch failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func playAssistant(text: String) async {
|
||||||
|
guard let input = await self.preparePlaybackInput(text: text) else { return }
|
||||||
|
do {
|
||||||
|
if let apiKey = input.apiKey, !apiKey.isEmpty, let voiceId = input.voiceId {
|
||||||
|
try await self.playElevenLabs(input: input, apiKey: apiKey, voiceId: voiceId)
|
||||||
|
} else {
|
||||||
|
try await self.playSystemVoice(input: input)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
self.ttsLogger
|
||||||
|
.error(
|
||||||
|
"talk TTS failed: \(error.localizedDescription, privacy: .public); " +
|
||||||
|
"falling back to system voice")
|
||||||
|
do {
|
||||||
|
try await self.playSystemVoice(input: input)
|
||||||
|
} catch {
|
||||||
|
self.ttsLogger.error("talk system voice failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.phase == .speaking {
|
||||||
|
self.phase = .thinking
|
||||||
|
await MainActor.run { TalkModeController.shared.updatePhase(.thinking) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TalkPlaybackInput {
|
||||||
|
let generation: Int
|
||||||
|
let cleanedText: String
|
||||||
|
let directive: TalkDirective?
|
||||||
|
let apiKey: String?
|
||||||
|
let voiceId: String?
|
||||||
|
let language: String?
|
||||||
|
let synthTimeoutSeconds: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
private func preparePlaybackInput(text: String) async -> TalkPlaybackInput? {
|
||||||
|
let gen = self.lifecycleGeneration
|
||||||
|
let parse = TalkDirectiveParser.parse(text)
|
||||||
|
let directive = parse.directive
|
||||||
|
let cleaned = parse.stripped.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !cleaned.isEmpty else { return nil }
|
||||||
|
guard self.isCurrent(gen) else { return nil }
|
||||||
|
|
||||||
|
if !parse.unknownKeys.isEmpty {
|
||||||
|
self.logger
|
||||||
|
.warning(
|
||||||
|
"talk directive ignored keys: " +
|
||||||
|
"\(parse.unknownKeys.joined(separator: ","), privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let requestedVoice = directive?.voiceId?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let resolvedVoice = self.resolveVoiceAlias(requestedVoice)
|
||||||
|
if let requestedVoice, !requestedVoice.isEmpty, resolvedVoice == nil {
|
||||||
|
self.logger.warning("talk unknown voice alias \(requestedVoice, privacy: .public)")
|
||||||
|
}
|
||||||
|
if let voice = resolvedVoice {
|
||||||
|
if directive?.once == true {
|
||||||
|
self.logger.info("talk voice override (once) voiceId=\(voice, privacy: .public)")
|
||||||
|
} else {
|
||||||
|
self.currentVoiceId = voice
|
||||||
|
self.voiceOverrideActive = true
|
||||||
|
self.logger.info("talk voice override voiceId=\(voice, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let model = directive?.modelId {
|
||||||
|
if directive?.once == true {
|
||||||
|
self.logger.info("talk model override (once) modelId=\(model, privacy: .public)")
|
||||||
|
} else {
|
||||||
|
self.currentModelId = model
|
||||||
|
self.modelOverrideActive = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let apiKey = self.apiKey?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let preferredVoice =
|
||||||
|
resolvedVoice ??
|
||||||
|
self.currentVoiceId ??
|
||||||
|
self.defaultVoiceId
|
||||||
|
|
||||||
|
let language = ElevenLabsTTSClient.validatedLanguage(directive?.language)
|
||||||
|
|
||||||
|
let voiceId: String? = if let apiKey, !apiKey.isEmpty {
|
||||||
|
await self.resolveVoiceId(preferred: preferredVoice, apiKey: apiKey)
|
||||||
|
} else {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if apiKey?.isEmpty != false {
|
||||||
|
self.ttsLogger.warning("talk missing ELEVENLABS_API_KEY; falling back to system voice")
|
||||||
|
} else if voiceId == nil {
|
||||||
|
self.ttsLogger.warning("talk missing voiceId; falling back to system voice")
|
||||||
|
} else if let voiceId {
|
||||||
|
self.ttsLogger
|
||||||
|
.info(
|
||||||
|
"talk TTS request voiceId=\(voiceId, privacy: .public) " +
|
||||||
|
"chars=\(cleaned.count, privacy: .public)")
|
||||||
|
}
|
||||||
|
self.lastSpokenText = cleaned
|
||||||
|
|
||||||
|
let synthTimeoutSeconds = max(20.0, min(90.0, Double(cleaned.count) * 0.12))
|
||||||
|
|
||||||
|
guard self.isCurrent(gen) else { return nil }
|
||||||
|
|
||||||
|
return TalkPlaybackInput(
|
||||||
|
generation: gen,
|
||||||
|
cleanedText: cleaned,
|
||||||
|
directive: directive,
|
||||||
|
apiKey: apiKey,
|
||||||
|
voiceId: voiceId,
|
||||||
|
language: language,
|
||||||
|
synthTimeoutSeconds: synthTimeoutSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func playElevenLabs(input: TalkPlaybackInput, apiKey: String, voiceId: String) async throws {
|
||||||
|
let desiredOutputFormat = input.directive?.outputFormat ?? self.defaultOutputFormat ?? "pcm_44100"
|
||||||
|
let outputFormat = ElevenLabsTTSClient.validatedOutputFormat(desiredOutputFormat)
|
||||||
|
if outputFormat == nil, !desiredOutputFormat.isEmpty {
|
||||||
|
self.logger
|
||||||
|
.warning(
|
||||||
|
"talk output_format unsupported for local playback: " +
|
||||||
|
"\(desiredOutputFormat, privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let modelId = input.directive?.modelId ?? self.currentModelId ?? self.defaultModelId
|
||||||
|
func makeRequest(outputFormat: String?) -> ElevenLabsTTSRequest {
|
||||||
|
ElevenLabsTTSRequest(
|
||||||
|
text: input.cleanedText,
|
||||||
|
modelId: modelId,
|
||||||
|
outputFormat: outputFormat,
|
||||||
|
speed: TalkTTSValidation.resolveSpeed(
|
||||||
|
speed: input.directive?.speed,
|
||||||
|
rateWPM: input.directive?.rateWPM),
|
||||||
|
stability: TalkTTSValidation.validatedStability(
|
||||||
|
input.directive?.stability,
|
||||||
|
modelId: modelId),
|
||||||
|
similarity: TalkTTSValidation.validatedUnit(input.directive?.similarity),
|
||||||
|
style: TalkTTSValidation.validatedUnit(input.directive?.style),
|
||||||
|
speakerBoost: input.directive?.speakerBoost,
|
||||||
|
seed: TalkTTSValidation.validatedSeed(input.directive?.seed),
|
||||||
|
normalize: ElevenLabsTTSClient.validatedNormalize(input.directive?.normalize),
|
||||||
|
language: input.language,
|
||||||
|
latencyTier: TalkTTSValidation.validatedLatencyTier(input.directive?.latencyTier))
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = makeRequest(outputFormat: outputFormat)
|
||||||
|
self.ttsLogger.info("talk TTS synth timeout=\(input.synthTimeoutSeconds, privacy: .public)s")
|
||||||
|
let client = ElevenLabsTTSClient(apiKey: apiKey)
|
||||||
|
let stream = client.streamSynthesize(voiceId: voiceId, request: request)
|
||||||
|
guard self.isCurrent(input.generation) else { return }
|
||||||
|
|
||||||
|
if self.interruptOnSpeech {
|
||||||
|
guard await self.prepareForPlayback(generation: input.generation) else { return }
|
||||||
|
}
|
||||||
|
|
||||||
|
await MainActor.run { TalkModeController.shared.updatePhase(.speaking) }
|
||||||
|
self.phase = .speaking
|
||||||
|
|
||||||
|
let result = await self.playRemoteStream(
|
||||||
|
client: client,
|
||||||
|
voiceId: voiceId,
|
||||||
|
outputFormat: outputFormat,
|
||||||
|
makeRequest: makeRequest,
|
||||||
|
stream: stream)
|
||||||
|
self.ttsLogger
|
||||||
|
.info(
|
||||||
|
"talk audio result finished=\(result.finished, privacy: .public) " +
|
||||||
|
"interruptedAt=\(String(describing: result.interruptedAt), privacy: .public)")
|
||||||
|
if !result.finished, result.interruptedAt == nil {
|
||||||
|
throw NSError(domain: "StreamingAudioPlayer", code: 1, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "audio playback failed",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
if !result.finished, let interruptedAt = result.interruptedAt, self.phase == .speaking {
|
||||||
|
if self.interruptOnSpeech {
|
||||||
|
self.lastInterruptedAtSeconds = interruptedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func playRemoteStream(
|
||||||
|
client: ElevenLabsTTSClient,
|
||||||
|
voiceId: String,
|
||||||
|
outputFormat: String?,
|
||||||
|
makeRequest: (String?) -> ElevenLabsTTSRequest,
|
||||||
|
stream: AsyncThrowingStream<Data, Error>) async -> StreamingPlaybackResult
|
||||||
|
{
|
||||||
|
let sampleRate = TalkTTSValidation.pcmSampleRate(from: outputFormat)
|
||||||
|
if let sampleRate {
|
||||||
|
self.lastPlaybackWasPCM = true
|
||||||
|
let result = await self.playPCM(stream: stream, sampleRate: sampleRate)
|
||||||
|
if result.finished || result.interruptedAt != nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
let mp3Format = ElevenLabsTTSClient.validatedOutputFormat("mp3_44100")
|
||||||
|
self.ttsLogger.warning("talk pcm playback failed; retrying mp3")
|
||||||
|
self.lastPlaybackWasPCM = false
|
||||||
|
let mp3Stream = client.streamSynthesize(
|
||||||
|
voiceId: voiceId,
|
||||||
|
request: makeRequest(mp3Format))
|
||||||
|
return await self.playMP3(stream: mp3Stream)
|
||||||
|
}
|
||||||
|
self.lastPlaybackWasPCM = false
|
||||||
|
return await self.playMP3(stream: stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func playSystemVoice(input: TalkPlaybackInput) async throws {
|
||||||
|
self.ttsLogger.info("talk system voice start chars=\(input.cleanedText.count, privacy: .public)")
|
||||||
|
if self.interruptOnSpeech {
|
||||||
|
guard await self.prepareForPlayback(generation: input.generation) else { return }
|
||||||
|
}
|
||||||
|
await MainActor.run { TalkModeController.shared.updatePhase(.speaking) }
|
||||||
|
self.phase = .speaking
|
||||||
|
await TalkSystemSpeechSynthesizer.shared.stop()
|
||||||
|
try await TalkSystemSpeechSynthesizer.shared.speak(
|
||||||
|
text: input.cleanedText,
|
||||||
|
language: input.language)
|
||||||
|
self.ttsLogger.info("talk system voice done")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func prepareForPlayback(generation: Int) async -> Bool {
|
||||||
|
await self.startRecognition()
|
||||||
|
return self.isCurrent(generation)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveVoiceId(preferred: String?, apiKey: String) async -> String? {
|
||||||
|
let trimmed = preferred?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmed.isEmpty {
|
||||||
|
if let resolved = self.resolveVoiceAlias(trimmed) { return resolved }
|
||||||
|
self.ttsLogger.warning("talk unknown voice alias \(trimmed, privacy: .public)")
|
||||||
|
}
|
||||||
|
if let fallbackVoiceId { return fallbackVoiceId }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let voices = try await ElevenLabsTTSClient(apiKey: apiKey).listVoices()
|
||||||
|
guard let first = voices.first else {
|
||||||
|
self.ttsLogger.error("elevenlabs voices list empty")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.fallbackVoiceId = first.voiceId
|
||||||
|
if self.defaultVoiceId == nil {
|
||||||
|
self.defaultVoiceId = first.voiceId
|
||||||
|
}
|
||||||
|
if !self.voiceOverrideActive {
|
||||||
|
self.currentVoiceId = first.voiceId
|
||||||
|
}
|
||||||
|
let name = first.name ?? "unknown"
|
||||||
|
self.ttsLogger
|
||||||
|
.info("talk default voice selected \(name, privacy: .public) (\(first.voiceId, privacy: .public))")
|
||||||
|
return first.voiceId
|
||||||
|
} catch {
|
||||||
|
self.ttsLogger.error("elevenlabs list voices failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveVoiceAlias(_ value: String?) -> String? {
|
||||||
|
let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
let normalized = trimmed.lowercased()
|
||||||
|
if let mapped = self.voiceAliases[normalized] { return mapped }
|
||||||
|
if self.voiceAliases.values.contains(where: { $0.caseInsensitiveCompare(trimmed) == .orderedSame }) {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
return Self.isLikelyVoiceId(trimmed) ? trimmed : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isLikelyVoiceId(_ value: String) -> Bool {
|
||||||
|
guard value.count >= 10 else { return false }
|
||||||
|
return value.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" }
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopSpeaking(reason: TalkStopReason) async {
|
||||||
|
let usePCM = self.lastPlaybackWasPCM
|
||||||
|
let interruptedAt = usePCM ? await self.stopPCM() : await self.stopMP3()
|
||||||
|
_ = usePCM ? await self.stopMP3() : await self.stopPCM()
|
||||||
|
await TalkSystemSpeechSynthesizer.shared.stop()
|
||||||
|
guard self.phase == .speaking else { return }
|
||||||
|
if reason == .speech, let interruptedAt {
|
||||||
|
self.lastInterruptedAtSeconds = interruptedAt
|
||||||
|
}
|
||||||
|
if reason == .manual {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if reason == .speech || reason == .userTap {
|
||||||
|
await self.startListening()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.phase = .thinking
|
||||||
|
await MainActor.run { TalkModeController.shared.updatePhase(.thinking) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TalkModeRuntime {
|
||||||
|
// MARK: - Audio playback (MainActor helpers)
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func playPCM(
|
||||||
|
stream: AsyncThrowingStream<Data, Error>,
|
||||||
|
sampleRate: Double) async -> StreamingPlaybackResult
|
||||||
|
{
|
||||||
|
await PCMStreamingAudioPlayer.shared.play(stream: stream, sampleRate: sampleRate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func playMP3(stream: AsyncThrowingStream<Data, Error>) async -> StreamingPlaybackResult {
|
||||||
|
await StreamingAudioPlayer.shared.play(stream: stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func stopPCM() -> Double? {
|
||||||
|
PCMStreamingAudioPlayer.shared.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func stopMP3() -> Double? {
|
||||||
|
StreamingAudioPlayer.shared.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Config
|
||||||
|
|
||||||
|
private func reloadConfig() async {
|
||||||
|
let cfg = await self.fetchTalkConfig()
|
||||||
|
self.defaultVoiceId = cfg.voiceId
|
||||||
|
self.voiceAliases = cfg.voiceAliases
|
||||||
|
if !self.voiceOverrideActive {
|
||||||
|
self.currentVoiceId = cfg.voiceId
|
||||||
|
}
|
||||||
|
self.defaultModelId = cfg.modelId
|
||||||
|
if !self.modelOverrideActive {
|
||||||
|
self.currentModelId = cfg.modelId
|
||||||
|
}
|
||||||
|
self.defaultOutputFormat = cfg.outputFormat
|
||||||
|
self.interruptOnSpeech = cfg.interruptOnSpeech
|
||||||
|
self.apiKey = cfg.apiKey
|
||||||
|
let hasApiKey = (cfg.apiKey?.isEmpty == false)
|
||||||
|
let voiceLabel = (cfg.voiceId?.isEmpty == false) ? cfg.voiceId! : "none"
|
||||||
|
let modelLabel = (cfg.modelId?.isEmpty == false) ? cfg.modelId! : "none"
|
||||||
|
self.logger
|
||||||
|
.info(
|
||||||
|
"talk config voiceId=\(voiceLabel, privacy: .public) " +
|
||||||
|
"modelId=\(modelLabel, privacy: .public) " +
|
||||||
|
"apiKey=\(hasApiKey, privacy: .public) " +
|
||||||
|
"interrupt=\(cfg.interruptOnSpeech, privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TalkRuntimeConfig {
|
||||||
|
let voiceId: String?
|
||||||
|
let voiceAliases: [String: String]
|
||||||
|
let modelId: String?
|
||||||
|
let outputFormat: String?
|
||||||
|
let interruptOnSpeech: Bool
|
||||||
|
let apiKey: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchTalkConfig() async -> TalkRuntimeConfig {
|
||||||
|
let env = ProcessInfo.processInfo.environment
|
||||||
|
let envVoice = env["ELEVENLABS_VOICE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let sagVoice = env["SAG_VOICE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let envApiKey = env["ELEVENLABS_API_KEY"]?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
|
||||||
|
method: .configGet,
|
||||||
|
params: nil,
|
||||||
|
timeoutMs: 8000)
|
||||||
|
let talk = snap.config?["talk"]?.dictionaryValue
|
||||||
|
let ui = snap.config?["ui"]?.dictionaryValue
|
||||||
|
let rawSeam = ui?["seamColor"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
await MainActor.run {
|
||||||
|
AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam
|
||||||
|
}
|
||||||
|
let voice = talk?["voiceId"]?.stringValue
|
||||||
|
let rawAliases = talk?["voiceAliases"]?.dictionaryValue
|
||||||
|
let resolvedAliases: [String: String] =
|
||||||
|
rawAliases?.reduce(into: [:]) { acc, entry in
|
||||||
|
let key = entry.key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
let value = entry.value.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
guard !key.isEmpty, !value.isEmpty else { return }
|
||||||
|
acc[key] = value
|
||||||
|
} ?? [:]
|
||||||
|
let model = talk?["modelId"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let resolvedModel = (model?.isEmpty == false) ? model! : Self.defaultModelIdFallback
|
||||||
|
let outputFormat = talk?["outputFormat"]?.stringValue
|
||||||
|
let interrupt = talk?["interruptOnSpeech"]?.boolValue
|
||||||
|
let apiKey = talk?["apiKey"]?.stringValue
|
||||||
|
let resolvedVoice =
|
||||||
|
(voice?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? voice : nil) ??
|
||||||
|
(envVoice?.isEmpty == false ? envVoice : nil) ??
|
||||||
|
(sagVoice?.isEmpty == false ? sagVoice : nil)
|
||||||
|
let resolvedApiKey =
|
||||||
|
(envApiKey?.isEmpty == false ? envApiKey : nil) ??
|
||||||
|
(apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? apiKey : nil)
|
||||||
|
return TalkRuntimeConfig(
|
||||||
|
voiceId: resolvedVoice,
|
||||||
|
voiceAliases: resolvedAliases,
|
||||||
|
modelId: resolvedModel,
|
||||||
|
outputFormat: outputFormat,
|
||||||
|
interruptOnSpeech: interrupt ?? true,
|
||||||
|
apiKey: resolvedApiKey)
|
||||||
|
} catch {
|
||||||
|
let resolvedVoice =
|
||||||
|
(envVoice?.isEmpty == false ? envVoice : nil) ??
|
||||||
|
(sagVoice?.isEmpty == false ? sagVoice : nil)
|
||||||
|
let resolvedApiKey = envApiKey?.isEmpty == false ? envApiKey : nil
|
||||||
|
return TalkRuntimeConfig(
|
||||||
|
voiceId: resolvedVoice,
|
||||||
|
voiceAliases: [:],
|
||||||
|
modelId: Self.defaultModelIdFallback,
|
||||||
|
outputFormat: nil,
|
||||||
|
interruptOnSpeech: true,
|
||||||
|
apiKey: resolvedApiKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Audio level handling
|
||||||
|
|
||||||
|
private func noteAudioLevel(rms: Double) async {
|
||||||
|
if self.phase != .listening, self.phase != .speaking { return }
|
||||||
|
let alpha: Double = rms < self.noiseFloorRMS ? 0.08 : 0.01
|
||||||
|
self.noiseFloorRMS = max(1e-7, self.noiseFloorRMS + (rms - self.noiseFloorRMS) * alpha)
|
||||||
|
|
||||||
|
let threshold = max(self.minSpeechRMS, self.noiseFloorRMS * self.speechBoostFactor)
|
||||||
|
if rms >= threshold {
|
||||||
|
let now = Date()
|
||||||
|
self.lastHeard = now
|
||||||
|
self.lastSpeechEnergyAt = now
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.phase == .listening {
|
||||||
|
let clamped = min(1.0, max(0.0, rms / max(self.minSpeechRMS, threshold)))
|
||||||
|
await MainActor.run { TalkModeController.shared.updateLevel(clamped) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func rmsLevel(buffer: AVAudioPCMBuffer) -> Double? {
|
||||||
|
guard let channelData = buffer.floatChannelData?.pointee else { return nil }
|
||||||
|
let frameCount = Int(buffer.frameLength)
|
||||||
|
guard frameCount > 0 else { return nil }
|
||||||
|
var sum: Double = 0
|
||||||
|
for i in 0..<frameCount {
|
||||||
|
let sample = Double(channelData[i])
|
||||||
|
sum += sample * sample
|
||||||
|
}
|
||||||
|
return sqrt(sum / Double(frameCount))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shouldInterrupt(transcript: String, hasConfidence: Bool) async -> Bool {
|
||||||
|
let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard trimmed.count >= 3 else { return false }
|
||||||
|
if self.isLikelyEcho(of: trimmed) { return false }
|
||||||
|
let now = Date()
|
||||||
|
if let lastSpeechEnergyAt, now.timeIntervalSince(lastSpeechEnergyAt) > 0.35 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return hasConfidence
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isLikelyEcho(of transcript: String) -> Bool {
|
||||||
|
guard let spoken = self.lastSpokenText?.lowercased(), !spoken.isEmpty else { return false }
|
||||||
|
let probe = transcript.lowercased()
|
||||||
|
if probe.count < 6 {
|
||||||
|
return spoken.contains(probe)
|
||||||
|
}
|
||||||
|
return spoken.contains(probe)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func resolveSpeed(speed: Double?, rateWPM: Int?, logger: Logger) -> Double? {
|
||||||
|
if let rateWPM, rateWPM > 0 {
|
||||||
|
let resolved = Double(rateWPM) / 175.0
|
||||||
|
if resolved <= 0.5 || resolved >= 2.0 {
|
||||||
|
logger.warning("talk rateWPM out of range: \(rateWPM, privacy: .public)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
if let speed {
|
||||||
|
if speed <= 0.5 || speed >= 2.0 {
|
||||||
|
logger.warning("talk speed out of range: \(speed, privacy: .public)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return speed
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func validatedUnit(_ value: Double?, name: String, logger: Logger) -> Double? {
|
||||||
|
guard let value else { return nil }
|
||||||
|
if value < 0 || value > 1 {
|
||||||
|
logger.warning("talk \(name, privacy: .public) out of range: \(value, privacy: .public)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func validatedSeed(_ value: Int?, logger: Logger) -> UInt32? {
|
||||||
|
guard let value else { return nil }
|
||||||
|
if value < 0 || value > 4_294_967_295 {
|
||||||
|
logger.warning("talk seed out of range: \(value, privacy: .public)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return UInt32(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func validatedNormalize(_ value: String?, logger: Logger) -> String? {
|
||||||
|
guard let value else { return nil }
|
||||||
|
let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
guard ["auto", "on", "off"].contains(normalized) else {
|
||||||
|
logger.warning("talk normalize invalid: \(normalized, privacy: .public)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
146
apps/macos/Sources/Moltbot/TalkOverlay.swift
Normal file
146
apps/macos/Sources/Moltbot/TalkOverlay.swift
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import AppKit
|
||||||
|
import Observation
|
||||||
|
import OSLog
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class TalkOverlayController {
|
||||||
|
static let shared = TalkOverlayController()
|
||||||
|
static let overlaySize: CGFloat = 440
|
||||||
|
static let orbSize: CGFloat = 96
|
||||||
|
static let orbPadding: CGFloat = 12
|
||||||
|
static let orbHitSlop: CGFloat = 10
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "bot.molt", category: "talk.overlay")
|
||||||
|
|
||||||
|
struct Model {
|
||||||
|
var isVisible: Bool = false
|
||||||
|
var phase: TalkModePhase = .idle
|
||||||
|
var isPaused: Bool = false
|
||||||
|
var level: Double = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var model = Model()
|
||||||
|
private var window: NSPanel?
|
||||||
|
private var hostingView: NSHostingView<TalkOverlayView>?
|
||||||
|
private let screenInset: CGFloat = 0
|
||||||
|
|
||||||
|
func present() {
|
||||||
|
self.ensureWindow()
|
||||||
|
self.hostingView?.rootView = TalkOverlayView(controller: self)
|
||||||
|
let target = self.targetFrame()
|
||||||
|
|
||||||
|
guard let window else { return }
|
||||||
|
if !self.model.isVisible {
|
||||||
|
self.model.isVisible = true
|
||||||
|
let start = target.offsetBy(dx: 0, dy: -6)
|
||||||
|
window.setFrame(start, display: true)
|
||||||
|
window.alphaValue = 0
|
||||||
|
window.orderFrontRegardless()
|
||||||
|
NSAnimationContext.runAnimationGroup { context in
|
||||||
|
context.duration = 0.18
|
||||||
|
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||||
|
window.animator().setFrame(target, display: true)
|
||||||
|
window.animator().alphaValue = 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
window.setFrame(target, display: true)
|
||||||
|
window.orderFrontRegardless()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dismiss() {
|
||||||
|
guard let window else {
|
||||||
|
self.model.isVisible = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let target = window.frame.offsetBy(dx: 6, dy: 6)
|
||||||
|
NSAnimationContext.runAnimationGroup { context in
|
||||||
|
context.duration = 0.16
|
||||||
|
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||||
|
window.animator().setFrame(target, display: true)
|
||||||
|
window.animator().alphaValue = 0
|
||||||
|
} completionHandler: {
|
||||||
|
Task { @MainActor in
|
||||||
|
window.orderOut(nil)
|
||||||
|
self.model.isVisible = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updatePhase(_ phase: TalkModePhase) {
|
||||||
|
guard self.model.phase != phase else { return }
|
||||||
|
self.logger.info("talk overlay phase=\(phase.rawValue, privacy: .public)")
|
||||||
|
self.model.phase = phase
|
||||||
|
}
|
||||||
|
|
||||||
|
func updatePaused(_ paused: Bool) {
|
||||||
|
guard self.model.isPaused != paused else { return }
|
||||||
|
self.logger.info("talk overlay paused=\(paused)")
|
||||||
|
self.model.isPaused = paused
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateLevel(_ level: Double) {
|
||||||
|
guard self.model.isVisible else { return }
|
||||||
|
self.model.level = max(0, min(1, level))
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentWindowOrigin() -> CGPoint? {
|
||||||
|
self.window?.frame.origin
|
||||||
|
}
|
||||||
|
|
||||||
|
func setWindowOrigin(_ origin: CGPoint) {
|
||||||
|
guard let window else { return }
|
||||||
|
window.setFrameOrigin(origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
|
private func ensureWindow() {
|
||||||
|
if self.window != nil { return }
|
||||||
|
let panel = NSPanel(
|
||||||
|
contentRect: NSRect(x: 0, y: 0, width: Self.overlaySize, height: Self.overlaySize),
|
||||||
|
styleMask: [.nonactivatingPanel, .borderless],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false)
|
||||||
|
panel.isOpaque = false
|
||||||
|
panel.backgroundColor = .clear
|
||||||
|
panel.hasShadow = false
|
||||||
|
panel.level = NSWindow.Level(rawValue: NSWindow.Level.popUpMenu.rawValue - 4)
|
||||||
|
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient]
|
||||||
|
panel.hidesOnDeactivate = false
|
||||||
|
panel.isMovable = false
|
||||||
|
panel.acceptsMouseMovedEvents = true
|
||||||
|
panel.isFloatingPanel = true
|
||||||
|
panel.becomesKeyOnlyIfNeeded = true
|
||||||
|
panel.titleVisibility = .hidden
|
||||||
|
panel.titlebarAppearsTransparent = true
|
||||||
|
|
||||||
|
let host = TalkOverlayHostingView(rootView: TalkOverlayView(controller: self))
|
||||||
|
host.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
panel.contentView = host
|
||||||
|
self.hostingView = host
|
||||||
|
self.window = panel
|
||||||
|
}
|
||||||
|
|
||||||
|
private func targetFrame() -> NSRect {
|
||||||
|
let screen = self.window?.screen
|
||||||
|
?? NSScreen.main
|
||||||
|
?? NSScreen.screens.first
|
||||||
|
guard let screen else { return .zero }
|
||||||
|
let size = NSSize(width: Self.overlaySize, height: Self.overlaySize)
|
||||||
|
let visible = screen.visibleFrame
|
||||||
|
let origin = CGPoint(
|
||||||
|
x: visible.maxX - size.width - self.screenInset,
|
||||||
|
y: visible.maxY - size.height - self.screenInset)
|
||||||
|
return NSRect(origin: origin, size: size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class TalkOverlayHostingView: NSHostingView<TalkOverlayView> {
|
||||||
|
override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
53
apps/macos/Sources/Moltbot/TerminationSignalWatcher.swift
Normal file
53
apps/macos/Sources/Moltbot/TerminationSignalWatcher.swift
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class TerminationSignalWatcher {
|
||||||
|
static let shared = TerminationSignalWatcher()
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "bot.molt", category: "lifecycle")
|
||||||
|
private var sources: [DispatchSourceSignal] = []
|
||||||
|
private var terminationRequested = false
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard self.sources.isEmpty else { return }
|
||||||
|
self.install(SIGTERM)
|
||||||
|
self.install(SIGINT)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
for s in self.sources {
|
||||||
|
s.cancel()
|
||||||
|
}
|
||||||
|
self.sources.removeAll(keepingCapacity: false)
|
||||||
|
self.terminationRequested = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func install(_ sig: Int32) {
|
||||||
|
// Make sure the default action doesn't kill the process before we can gracefully shut down.
|
||||||
|
signal(sig, SIG_IGN)
|
||||||
|
let source = DispatchSource.makeSignalSource(signal: sig, queue: .main)
|
||||||
|
source.setEventHandler { [weak self] in
|
||||||
|
self?.handle(sig)
|
||||||
|
}
|
||||||
|
source.resume()
|
||||||
|
self.sources.append(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handle(_ sig: Int32) {
|
||||||
|
guard !self.terminationRequested else { return }
|
||||||
|
self.terminationRequested = true
|
||||||
|
|
||||||
|
self.logger.info("received signal \(sig, privacy: .public); terminating")
|
||||||
|
// Ensure any pairing prompt can't accidentally approve during shutdown.
|
||||||
|
NodePairingApprovalPrompter.shared.stop()
|
||||||
|
DevicePairingApprovalPrompter.shared.stop()
|
||||||
|
NSApp.terminate(nil)
|
||||||
|
|
||||||
|
// Safety net: don't hang forever if something blocks termination.
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
|
||||||
|
exit(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
421
apps/macos/Sources/Moltbot/VoicePushToTalk.swift
Normal file
421
apps/macos/Sources/Moltbot/VoicePushToTalk.swift
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
import AppKit
|
||||||
|
import AVFoundation
|
||||||
|
import Dispatch
|
||||||
|
import OSLog
|
||||||
|
import Speech
|
||||||
|
|
||||||
|
/// Observes right Option and starts a push-to-talk capture while it is held.
|
||||||
|
final class VoicePushToTalkHotkey: @unchecked Sendable {
|
||||||
|
static let shared = VoicePushToTalkHotkey()
|
||||||
|
|
||||||
|
private var globalMonitor: Any?
|
||||||
|
private var localMonitor: Any?
|
||||||
|
private var optionDown = false // right option only
|
||||||
|
private var active = false
|
||||||
|
|
||||||
|
private let beginAction: @Sendable () async -> Void
|
||||||
|
private let endAction: @Sendable () async -> Void
|
||||||
|
|
||||||
|
init(
|
||||||
|
beginAction: @escaping @Sendable () async -> Void = { await VoicePushToTalk.shared.begin() },
|
||||||
|
endAction: @escaping @Sendable () async -> Void = { await VoicePushToTalk.shared.end() })
|
||||||
|
{
|
||||||
|
self.beginAction = beginAction
|
||||||
|
self.endAction = endAction
|
||||||
|
}
|
||||||
|
|
||||||
|
func setEnabled(_ enabled: Bool) {
|
||||||
|
if ProcessInfo.processInfo.isRunningTests { return }
|
||||||
|
self.withMainThread { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
if enabled {
|
||||||
|
self.startMonitoring()
|
||||||
|
} else {
|
||||||
|
self.stopMonitoring()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startMonitoring() {
|
||||||
|
// assert(Thread.isMainThread) - Removed for Swift 6
|
||||||
|
guard self.globalMonitor == nil, self.localMonitor == nil else { return }
|
||||||
|
// Listen-only global monitor; we rely on Input Monitoring permission to receive events.
|
||||||
|
self.globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
|
||||||
|
let keyCode = event.keyCode
|
||||||
|
let flags = event.modifierFlags
|
||||||
|
self?.handleFlagsChanged(keyCode: keyCode, modifierFlags: flags)
|
||||||
|
}
|
||||||
|
// Also listen locally so we still catch events when the app is active/focused.
|
||||||
|
self.localMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
|
||||||
|
let keyCode = event.keyCode
|
||||||
|
let flags = event.modifierFlags
|
||||||
|
self?.handleFlagsChanged(keyCode: keyCode, modifierFlags: flags)
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopMonitoring() {
|
||||||
|
// assert(Thread.isMainThread) - Removed for Swift 6
|
||||||
|
if let globalMonitor {
|
||||||
|
NSEvent.removeMonitor(globalMonitor)
|
||||||
|
self.globalMonitor = nil
|
||||||
|
}
|
||||||
|
if let localMonitor {
|
||||||
|
NSEvent.removeMonitor(localMonitor)
|
||||||
|
self.localMonitor = nil
|
||||||
|
}
|
||||||
|
self.optionDown = false
|
||||||
|
self.active = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleFlagsChanged(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) {
|
||||||
|
self.withMainThread { [weak self] in
|
||||||
|
self?.updateModifierState(keyCode: keyCode, modifierFlags: modifierFlags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func withMainThread(_ block: @escaping @Sendable () -> Void) {
|
||||||
|
DispatchQueue.main.async(execute: block)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateModifierState(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) {
|
||||||
|
// assert(Thread.isMainThread) - Removed for Swift 6
|
||||||
|
// Right Option (keyCode 61) acts as a hold-to-talk modifier.
|
||||||
|
if keyCode == 61 {
|
||||||
|
self.optionDown = modifierFlags.contains(.option)
|
||||||
|
}
|
||||||
|
|
||||||
|
let chordActive = self.optionDown
|
||||||
|
if chordActive, !self.active {
|
||||||
|
self.active = true
|
||||||
|
Task {
|
||||||
|
Logger(subsystem: "bot.molt", category: "voicewake.ptt")
|
||||||
|
.info("ptt hotkey down")
|
||||||
|
await self.beginAction()
|
||||||
|
}
|
||||||
|
} else if !chordActive, self.active {
|
||||||
|
self.active = false
|
||||||
|
Task {
|
||||||
|
Logger(subsystem: "bot.molt", category: "voicewake.ptt")
|
||||||
|
.info("ptt hotkey up")
|
||||||
|
await self.endAction()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func _testUpdateModifierState(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) {
|
||||||
|
self.updateModifierState(keyCode: keyCode, modifierFlags: modifierFlags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Short-lived speech recognizer that records while the hotkey is held.
|
||||||
|
actor VoicePushToTalk {
|
||||||
|
static let shared = VoicePushToTalk()
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "bot.molt", category: "voicewake.ptt")
|
||||||
|
|
||||||
|
private var recognizer: SFSpeechRecognizer?
|
||||||
|
// Lazily created on begin() to avoid creating an AVAudioEngine at app launch, which can switch Bluetooth
|
||||||
|
// headphones into the low-quality headset profile even if push-to-talk is never used.
|
||||||
|
private var audioEngine: AVAudioEngine?
|
||||||
|
private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
|
||||||
|
private var recognitionTask: SFSpeechRecognitionTask?
|
||||||
|
private var tapInstalled = false
|
||||||
|
|
||||||
|
// Session token used to drop stale callbacks when a new capture starts.
|
||||||
|
private var sessionID = UUID()
|
||||||
|
|
||||||
|
private var committed: String = ""
|
||||||
|
private var volatile: String = ""
|
||||||
|
private var activeConfig: Config?
|
||||||
|
private var isCapturing = false
|
||||||
|
private var triggerChimePlayed = false
|
||||||
|
private var finalized = false
|
||||||
|
private var timeoutTask: Task<Void, Never>?
|
||||||
|
private var overlayToken: UUID?
|
||||||
|
private var adoptedPrefix: String = ""
|
||||||
|
|
||||||
|
private struct Config {
|
||||||
|
let micID: String?
|
||||||
|
let localeID: String?
|
||||||
|
let triggerChime: VoiceWakeChime
|
||||||
|
let sendChime: VoiceWakeChime
|
||||||
|
}
|
||||||
|
|
||||||
|
func begin() async {
|
||||||
|
guard voiceWakeSupported else { return }
|
||||||
|
guard !self.isCapturing else { return }
|
||||||
|
|
||||||
|
// Start a fresh session and invalidate any in-flight callbacks tied to an older one.
|
||||||
|
let sessionID = UUID()
|
||||||
|
self.sessionID = sessionID
|
||||||
|
|
||||||
|
// Ensure permissions up front.
|
||||||
|
let granted = await PermissionManager.ensureVoiceWakePermissions(interactive: true)
|
||||||
|
guard granted else { return }
|
||||||
|
|
||||||
|
let config = await MainActor.run { self.makeConfig() }
|
||||||
|
self.activeConfig = config
|
||||||
|
self.isCapturing = true
|
||||||
|
self.triggerChimePlayed = false
|
||||||
|
self.finalized = false
|
||||||
|
self.timeoutTask?.cancel(); self.timeoutTask = nil
|
||||||
|
let snapshot = await MainActor.run { VoiceSessionCoordinator.shared.snapshot() }
|
||||||
|
self.adoptedPrefix = snapshot.visible ? snapshot.text.trimmingCharacters(in: .whitespacesAndNewlines) : ""
|
||||||
|
self.logger.info("ptt begin adopted_prefix_len=\(self.adoptedPrefix.count, privacy: .public)")
|
||||||
|
if config.triggerChime != .none {
|
||||||
|
self.triggerChimePlayed = true
|
||||||
|
await MainActor.run { VoiceWakeChimePlayer.play(config.triggerChime, reason: "ptt.trigger") }
|
||||||
|
}
|
||||||
|
// Pause the always-on wake word recognizer so both pipelines don't fight over the mic tap.
|
||||||
|
await VoiceWakeRuntime.shared.pauseForPushToTalk()
|
||||||
|
let adoptedPrefix = self.adoptedPrefix
|
||||||
|
let adoptedAttributed: NSAttributedString? = adoptedPrefix.isEmpty ? nil : Self.makeAttributed(
|
||||||
|
committed: adoptedPrefix,
|
||||||
|
volatile: "",
|
||||||
|
isFinal: false)
|
||||||
|
self.overlayToken = await MainActor.run {
|
||||||
|
VoiceSessionCoordinator.shared.startSession(
|
||||||
|
source: .pushToTalk,
|
||||||
|
text: adoptedPrefix,
|
||||||
|
attributed: adoptedAttributed,
|
||||||
|
forwardEnabled: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await self.startRecognition(localeID: config.localeID, sessionID: sessionID)
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
VoiceWakeOverlayController.shared.dismiss()
|
||||||
|
}
|
||||||
|
self.isCapturing = false
|
||||||
|
// If push-to-talk fails to start after pausing wake-word, ensure we resume listening.
|
||||||
|
await VoiceWakeRuntime.shared.applyPushToTalkCooldown()
|
||||||
|
await VoiceWakeRuntime.shared.refresh(state: AppStateStore.shared)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func end() async {
|
||||||
|
guard self.isCapturing else { return }
|
||||||
|
self.isCapturing = false
|
||||||
|
let sessionID = self.sessionID
|
||||||
|
|
||||||
|
// Stop feeding Speech buffers first, then end the request. Stopping the engine here can race with
|
||||||
|
// Speech draining its converter chain (and we already stop/cancel in finalize).
|
||||||
|
if self.tapInstalled {
|
||||||
|
self.audioEngine?.inputNode.removeTap(onBus: 0)
|
||||||
|
self.tapInstalled = false
|
||||||
|
}
|
||||||
|
self.recognitionRequest?.endAudio()
|
||||||
|
|
||||||
|
// If we captured nothing, dismiss immediately when the user lets go.
|
||||||
|
if self.committed.isEmpty, self.volatile.isEmpty, self.adoptedPrefix.isEmpty {
|
||||||
|
await self.finalize(transcriptOverride: "", reason: "emptyOnRelease", sessionID: sessionID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, give Speech a brief window to deliver the final result; then fall back.
|
||||||
|
self.timeoutTask?.cancel()
|
||||||
|
self.timeoutTask = Task { [weak self] in
|
||||||
|
try? await Task.sleep(nanoseconds: 1_500_000_000) // 1.5s grace period to await final result
|
||||||
|
await self?.finalize(transcriptOverride: nil, reason: "timeout", sessionID: sessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
|
private func startRecognition(localeID: String?, sessionID: UUID) async throws {
|
||||||
|
let locale = localeID.flatMap { Locale(identifier: $0) } ?? Locale(identifier: Locale.current.identifier)
|
||||||
|
self.recognizer = SFSpeechRecognizer(locale: locale)
|
||||||
|
guard let recognizer, recognizer.isAvailable else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "VoicePushToTalk",
|
||||||
|
code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Recognizer unavailable"])
|
||||||
|
}
|
||||||
|
|
||||||
|
self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest()
|
||||||
|
self.recognitionRequest?.shouldReportPartialResults = true
|
||||||
|
guard let request = self.recognitionRequest else { return }
|
||||||
|
|
||||||
|
// Lazily create the engine here so app launch doesn't grab audio resources / trigger Bluetooth HFP.
|
||||||
|
if self.audioEngine == nil {
|
||||||
|
self.audioEngine = AVAudioEngine()
|
||||||
|
}
|
||||||
|
guard let audioEngine = self.audioEngine else { return }
|
||||||
|
|
||||||
|
let input = audioEngine.inputNode
|
||||||
|
let format = input.outputFormat(forBus: 0)
|
||||||
|
if self.tapInstalled {
|
||||||
|
input.removeTap(onBus: 0)
|
||||||
|
self.tapInstalled = false
|
||||||
|
}
|
||||||
|
// Pipe raw mic buffers into the Speech request while the chord is held.
|
||||||
|
input.installTap(onBus: 0, bufferSize: 2048, format: format) { [weak request] buffer, _ in
|
||||||
|
request?.append(buffer)
|
||||||
|
}
|
||||||
|
self.tapInstalled = true
|
||||||
|
|
||||||
|
audioEngine.prepare()
|
||||||
|
try audioEngine.start()
|
||||||
|
|
||||||
|
self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in
|
||||||
|
guard let self else { return }
|
||||||
|
if let error {
|
||||||
|
self.logger.debug("push-to-talk error: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
let transcript = result?.bestTranscription.formattedString
|
||||||
|
let isFinal = result?.isFinal ?? false
|
||||||
|
// Hop to a Task so UI updates stay off the Speech callback thread.
|
||||||
|
Task.detached { [weak self, transcript, isFinal, sessionID] in
|
||||||
|
guard let self else { return }
|
||||||
|
await self.handle(transcript: transcript, isFinal: isFinal, sessionID: sessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handle(transcript: String?, isFinal: Bool, sessionID: UUID) async {
|
||||||
|
guard sessionID == self.sessionID else {
|
||||||
|
self.logger.debug("push-to-talk drop transcript for stale session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let transcript else { return }
|
||||||
|
if isFinal {
|
||||||
|
self.committed = transcript
|
||||||
|
self.volatile = ""
|
||||||
|
} else {
|
||||||
|
self.volatile = Self.delta(after: self.committed, current: transcript)
|
||||||
|
}
|
||||||
|
|
||||||
|
let committedWithPrefix = Self.join(self.adoptedPrefix, self.committed)
|
||||||
|
let snapshot = Self.join(committedWithPrefix, self.volatile)
|
||||||
|
let attributed = Self.makeAttributed(committed: committedWithPrefix, volatile: self.volatile, isFinal: isFinal)
|
||||||
|
if let token = self.overlayToken {
|
||||||
|
await MainActor.run {
|
||||||
|
VoiceSessionCoordinator.shared.updatePartial(
|
||||||
|
token: token,
|
||||||
|
text: snapshot,
|
||||||
|
attributed: attributed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finalize(transcriptOverride: String?, reason: String, sessionID: UUID?) async {
|
||||||
|
if self.finalized { return }
|
||||||
|
if let sessionID, sessionID != self.sessionID {
|
||||||
|
self.logger.debug("push-to-talk drop finalize for stale session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.finalized = true
|
||||||
|
self.isCapturing = false
|
||||||
|
self.timeoutTask?.cancel(); self.timeoutTask = nil
|
||||||
|
|
||||||
|
let finalRecognized: String = {
|
||||||
|
if let override = transcriptOverride?.trimmingCharacters(in: .whitespacesAndNewlines) {
|
||||||
|
return override
|
||||||
|
}
|
||||||
|
return (self.committed + self.volatile).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}()
|
||||||
|
let finalText = Self.join(self.adoptedPrefix, finalRecognized)
|
||||||
|
let chime = finalText.isEmpty ? .none : (self.activeConfig?.sendChime ?? .none)
|
||||||
|
|
||||||
|
let token = self.overlayToken
|
||||||
|
let logger = self.logger
|
||||||
|
await MainActor.run {
|
||||||
|
logger.info("ptt finalize reason=\(reason, privacy: .public) len=\(finalText.count, privacy: .public)")
|
||||||
|
if let token {
|
||||||
|
VoiceSessionCoordinator.shared.finalize(
|
||||||
|
token: token,
|
||||||
|
text: finalText,
|
||||||
|
sendChime: chime,
|
||||||
|
autoSendAfter: nil)
|
||||||
|
VoiceSessionCoordinator.shared.sendNow(token: token, reason: reason)
|
||||||
|
} else if !finalText.isEmpty {
|
||||||
|
if chime != .none {
|
||||||
|
VoiceWakeChimePlayer.play(chime, reason: "ptt.fallback_send")
|
||||||
|
}
|
||||||
|
Task.detached {
|
||||||
|
await VoiceWakeForwarder.forward(transcript: finalText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.recognitionTask?.cancel()
|
||||||
|
self.recognitionRequest = nil
|
||||||
|
self.recognitionTask = nil
|
||||||
|
if self.tapInstalled {
|
||||||
|
self.audioEngine?.inputNode.removeTap(onBus: 0)
|
||||||
|
self.tapInstalled = false
|
||||||
|
}
|
||||||
|
if self.audioEngine?.isRunning == true {
|
||||||
|
self.audioEngine?.stop()
|
||||||
|
self.audioEngine?.reset()
|
||||||
|
}
|
||||||
|
// Release the engine so we also release any audio session/resources when push-to-talk ends.
|
||||||
|
self.audioEngine = nil
|
||||||
|
|
||||||
|
self.committed = ""
|
||||||
|
self.volatile = ""
|
||||||
|
self.activeConfig = nil
|
||||||
|
self.triggerChimePlayed = false
|
||||||
|
self.overlayToken = nil
|
||||||
|
self.adoptedPrefix = ""
|
||||||
|
|
||||||
|
// Resume the wake-word runtime after push-to-talk finishes.
|
||||||
|
await VoiceWakeRuntime.shared.applyPushToTalkCooldown()
|
||||||
|
_ = await MainActor.run { Task { await VoiceWakeRuntime.shared.refresh(state: AppStateStore.shared) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func makeConfig() -> Config {
|
||||||
|
let state = AppStateStore.shared
|
||||||
|
return Config(
|
||||||
|
micID: state.voiceWakeMicID.isEmpty ? nil : state.voiceWakeMicID,
|
||||||
|
localeID: state.voiceWakeLocaleID,
|
||||||
|
triggerChime: state.voiceWakeTriggerChime,
|
||||||
|
sendChime: state.voiceWakeSendChime)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Test helpers
|
||||||
|
|
||||||
|
static func _testDelta(committed: String, current: String) -> String {
|
||||||
|
self.delta(after: committed, current: current)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func _testAttributedColors(isFinal: Bool) -> (NSColor, NSColor) {
|
||||||
|
let sample = self.makeAttributed(committed: "a", volatile: "b", isFinal: isFinal)
|
||||||
|
let committedColor = sample.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor ?? .clear
|
||||||
|
let volatileColor = sample.attribute(.foregroundColor, at: 1, effectiveRange: nil) as? NSColor ?? .clear
|
||||||
|
return (committedColor, volatileColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func join(_ prefix: String, _ suffix: String) -> String {
|
||||||
|
if prefix.isEmpty { return suffix }
|
||||||
|
if suffix.isEmpty { return prefix }
|
||||||
|
return "\(prefix) \(suffix)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func delta(after committed: String, current: String) -> String {
|
||||||
|
if current.hasPrefix(committed) {
|
||||||
|
let start = current.index(current.startIndex, offsetBy: committed.count)
|
||||||
|
return String(current[start...])
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeAttributed(committed: String, volatile: String, isFinal: Bool) -> NSAttributedString {
|
||||||
|
let full = NSMutableAttributedString()
|
||||||
|
let committedAttr: [NSAttributedString.Key: Any] = [
|
||||||
|
.foregroundColor: NSColor.labelColor,
|
||||||
|
.font: NSFont.systemFont(ofSize: 13, weight: .regular),
|
||||||
|
]
|
||||||
|
full.append(NSAttributedString(string: committed, attributes: committedAttr))
|
||||||
|
let volatileColor: NSColor = isFinal ? .labelColor : NSColor.tertiaryLabelColor
|
||||||
|
let volatileAttr: [NSAttributedString.Key: Any] = [
|
||||||
|
.foregroundColor: volatileColor,
|
||||||
|
.font: NSFont.systemFont(ofSize: 13, weight: .regular),
|
||||||
|
]
|
||||||
|
full.append(NSAttributedString(string: volatile, attributes: volatileAttr))
|
||||||
|
return full
|
||||||
|
}
|
||||||
|
}
|
||||||
134
apps/macos/Sources/Moltbot/VoiceSessionCoordinator.swift
Normal file
134
apps/macos/Sources/Moltbot/VoiceSessionCoordinator.swift
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class VoiceSessionCoordinator {
|
||||||
|
static let shared = VoiceSessionCoordinator()
|
||||||
|
|
||||||
|
enum Source: String { case wakeWord, pushToTalk }
|
||||||
|
|
||||||
|
struct Session {
|
||||||
|
let token: UUID
|
||||||
|
let source: Source
|
||||||
|
var text: String
|
||||||
|
var attributed: NSAttributedString?
|
||||||
|
var isFinal: Bool
|
||||||
|
var sendChime: VoiceWakeChime
|
||||||
|
var autoSendDelay: TimeInterval?
|
||||||
|
}
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "bot.molt", category: "voicewake.coordinator")
|
||||||
|
private var session: Session?
|
||||||
|
|
||||||
|
// MARK: - API
|
||||||
|
|
||||||
|
func startSession(
|
||||||
|
source: Source,
|
||||||
|
text: String,
|
||||||
|
attributed: NSAttributedString? = nil,
|
||||||
|
forwardEnabled: Bool = false) -> UUID
|
||||||
|
{
|
||||||
|
let token = UUID()
|
||||||
|
self.logger.info("coordinator start token=\(token.uuidString) source=\(source.rawValue) len=\(text.count)")
|
||||||
|
let attributedText = attributed ?? VoiceWakeOverlayController.shared.makeAttributed(from: text)
|
||||||
|
let session = Session(
|
||||||
|
token: token,
|
||||||
|
source: source,
|
||||||
|
text: text,
|
||||||
|
attributed: attributedText,
|
||||||
|
isFinal: false,
|
||||||
|
sendChime: .none,
|
||||||
|
autoSendDelay: nil)
|
||||||
|
self.session = session
|
||||||
|
VoiceWakeOverlayController.shared.startSession(
|
||||||
|
token: token,
|
||||||
|
source: VoiceWakeOverlayController.Source(rawValue: source.rawValue) ?? .wakeWord,
|
||||||
|
transcript: text,
|
||||||
|
attributed: attributedText,
|
||||||
|
forwardEnabled: forwardEnabled,
|
||||||
|
isFinal: false)
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
func updatePartial(token: UUID, text: String, attributed: NSAttributedString? = nil) {
|
||||||
|
guard let session, session.token == token else { return }
|
||||||
|
self.session?.text = text
|
||||||
|
self.session?.attributed = attributed
|
||||||
|
VoiceWakeOverlayController.shared.updatePartial(token: token, transcript: text, attributed: attributed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalize(
|
||||||
|
token: UUID,
|
||||||
|
text: String,
|
||||||
|
sendChime: VoiceWakeChime,
|
||||||
|
autoSendAfter: TimeInterval?)
|
||||||
|
{
|
||||||
|
guard let session, session.token == token else { return }
|
||||||
|
self.logger
|
||||||
|
.info(
|
||||||
|
"coordinator finalize token=\(token.uuidString) len=\(text.count) autoSendAfter=\(autoSendAfter ?? -1)")
|
||||||
|
self.session?.text = text
|
||||||
|
self.session?.isFinal = true
|
||||||
|
self.session?.sendChime = sendChime
|
||||||
|
self.session?.autoSendDelay = autoSendAfter
|
||||||
|
|
||||||
|
let attributed = VoiceWakeOverlayController.shared.makeAttributed(from: text)
|
||||||
|
VoiceWakeOverlayController.shared.presentFinal(
|
||||||
|
token: token,
|
||||||
|
transcript: text,
|
||||||
|
autoSendAfter: autoSendAfter,
|
||||||
|
sendChime: sendChime,
|
||||||
|
attributed: attributed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendNow(token: UUID, reason: String = "explicit") {
|
||||||
|
guard let session, session.token == token else { return }
|
||||||
|
let text = session.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !text.isEmpty else {
|
||||||
|
self.logger.info("coordinator sendNow \(reason) empty -> dismiss")
|
||||||
|
VoiceWakeOverlayController.shared.dismiss(token: token, reason: .empty, outcome: .empty)
|
||||||
|
self.clearSession()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
VoiceWakeOverlayController.shared.beginSendUI(token: token, sendChime: session.sendChime)
|
||||||
|
Task.detached {
|
||||||
|
_ = await VoiceWakeForwarder.forward(transcript: text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dismiss(
|
||||||
|
token: UUID,
|
||||||
|
reason: VoiceWakeOverlayController.DismissReason,
|
||||||
|
outcome: VoiceWakeOverlayController.SendOutcome)
|
||||||
|
{
|
||||||
|
guard let session, session.token == token else { return }
|
||||||
|
VoiceWakeOverlayController.shared.dismiss(token: token, reason: reason, outcome: outcome)
|
||||||
|
self.clearSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateLevel(token: UUID, _ level: Double) {
|
||||||
|
guard let session, session.token == token else { return }
|
||||||
|
VoiceWakeOverlayController.shared.updateLevel(token: token, level)
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshot() -> (token: UUID?, text: String, visible: Bool) {
|
||||||
|
(self.session?.token, self.session?.text ?? "", VoiceWakeOverlayController.shared.isVisible)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
|
private func clearSession() {
|
||||||
|
self.session = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Overlay dismiss completion callback (manual X, empty, auto-dismiss after send).
|
||||||
|
/// Ensures the wake-word recognizer is resumed if Voice Wake is enabled.
|
||||||
|
func overlayDidDismiss(token: UUID?) {
|
||||||
|
if let token, self.session?.token == token {
|
||||||
|
self.clearSession()
|
||||||
|
}
|
||||||
|
Task { await VoiceWakeRuntime.shared.refresh(state: AppStateStore.shared) }
|
||||||
|
}
|
||||||
|
}
|
||||||
74
apps/macos/Sources/Moltbot/VoiceWakeChime.swift
Normal file
74
apps/macos/Sources/Moltbot/VoiceWakeChime.swift
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
enum VoiceWakeChime: Codable, Equatable, Sendable {
|
||||||
|
case none
|
||||||
|
case system(name: String)
|
||||||
|
case custom(displayName: String, bookmark: Data)
|
||||||
|
|
||||||
|
var systemName: String? {
|
||||||
|
if case let .system(name) = self {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayLabel: String {
|
||||||
|
switch self {
|
||||||
|
case .none:
|
||||||
|
"No Sound"
|
||||||
|
case let .system(name):
|
||||||
|
VoiceWakeChimeCatalog.displayName(for: name)
|
||||||
|
case let .custom(displayName, _):
|
||||||
|
displayName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum VoiceWakeChimeCatalog {
|
||||||
|
/// Options shown in the picker.
|
||||||
|
static var systemOptions: [String] { SoundEffectCatalog.systemOptions }
|
||||||
|
|
||||||
|
static func displayName(for raw: String) -> String {
|
||||||
|
SoundEffectCatalog.displayName(for: raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func url(for name: String) -> URL? {
|
||||||
|
SoundEffectCatalog.url(for: name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
enum VoiceWakeChimePlayer {
|
||||||
|
private static let logger = Logger(subsystem: "bot.molt", category: "voicewake.chime")
|
||||||
|
private static var lastSound: NSSound?
|
||||||
|
|
||||||
|
static func play(_ chime: VoiceWakeChime, reason: String? = nil) {
|
||||||
|
guard let sound = self.sound(for: chime) else { return }
|
||||||
|
if let reason {
|
||||||
|
self.logger.log(level: .info, "chime play reason=\(reason, privacy: .public)")
|
||||||
|
} else {
|
||||||
|
self.logger.log(level: .info, "chime play")
|
||||||
|
}
|
||||||
|
DiagnosticsFileLog.shared.log(category: "voicewake.chime", event: "play", fields: [
|
||||||
|
"reason": reason ?? "",
|
||||||
|
"chime": chime.displayLabel,
|
||||||
|
"systemName": chime.systemName ?? "",
|
||||||
|
])
|
||||||
|
SoundEffectPlayer.play(sound)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func sound(for chime: VoiceWakeChime) -> NSSound? {
|
||||||
|
switch chime {
|
||||||
|
case .none:
|
||||||
|
nil
|
||||||
|
|
||||||
|
case let .system(name):
|
||||||
|
SoundEffectPlayer.sound(named: name)
|
||||||
|
|
||||||
|
case let .custom(_, bookmark):
|
||||||
|
SoundEffectPlayer.sound(from: bookmark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
73
apps/macos/Sources/Moltbot/VoiceWakeForwarder.swift
Normal file
73
apps/macos/Sources/Moltbot/VoiceWakeForwarder.swift
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
enum VoiceWakeForwarder {
|
||||||
|
private static let logger = Logger(subsystem: "bot.molt", category: "voicewake.forward")
|
||||||
|
|
||||||
|
static func prefixedTranscript(_ transcript: String, machineName: String? = nil) -> String {
|
||||||
|
let resolvedMachine = machineName
|
||||||
|
.flatMap { name -> String? in
|
||||||
|
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return trimmed.isEmpty ? nil : trimmed
|
||||||
|
}
|
||||||
|
?? Host.current().localizedName
|
||||||
|
?? ProcessInfo.processInfo.hostName
|
||||||
|
|
||||||
|
let safeMachine = resolvedMachine.isEmpty ? "this Mac" : resolvedMachine
|
||||||
|
return """
|
||||||
|
User talked via voice recognition on \(safeMachine) - repeat prompt first \
|
||||||
|
+ remember some words might be incorrectly transcribed.
|
||||||
|
|
||||||
|
\(transcript)
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
enum VoiceWakeForwardError: LocalizedError, Equatable {
|
||||||
|
case rpcFailed(String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case let .rpcFailed(message): message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ForwardOptions: Sendable {
|
||||||
|
var sessionKey: String = "main"
|
||||||
|
var thinking: String = "low"
|
||||||
|
var deliver: Bool = true
|
||||||
|
var to: String?
|
||||||
|
var channel: GatewayAgentChannel = .last
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
static func forward(
|
||||||
|
transcript: String,
|
||||||
|
options: ForwardOptions = ForwardOptions()) async -> Result<Void, VoiceWakeForwardError>
|
||||||
|
{
|
||||||
|
let payload = Self.prefixedTranscript(transcript)
|
||||||
|
let deliver = options.channel.shouldDeliver(options.deliver)
|
||||||
|
let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
|
||||||
|
message: payload,
|
||||||
|
sessionKey: options.sessionKey,
|
||||||
|
thinking: options.thinking,
|
||||||
|
deliver: deliver,
|
||||||
|
to: options.to,
|
||||||
|
channel: options.channel))
|
||||||
|
|
||||||
|
if result.ok {
|
||||||
|
self.logger.info("voice wake forward ok")
|
||||||
|
return .success(())
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = result.error ?? "agent rpc unavailable"
|
||||||
|
self.logger.error("voice wake forward failed: \(message, privacy: .public)")
|
||||||
|
return .failure(.rpcFailed(message))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func checkConnection() async -> Result<Void, VoiceWakeForwardError> {
|
||||||
|
let status = await GatewayConnection.shared.status()
|
||||||
|
if status.ok { return .success(()) }
|
||||||
|
return .failure(.rpcFailed(status.error ?? "agent rpc unreachable"))
|
||||||
|
}
|
||||||
|
}
|
||||||
66
apps/macos/Sources/Moltbot/VoiceWakeGlobalSettingsSync.swift
Normal file
66
apps/macos/Sources/Moltbot/VoiceWakeGlobalSettingsSync.swift
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import MoltbotKit
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class VoiceWakeGlobalSettingsSync {
|
||||||
|
static let shared = VoiceWakeGlobalSettingsSync()
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "bot.molt", category: "voicewake.sync")
|
||||||
|
private var task: Task<Void, Never>?
|
||||||
|
|
||||||
|
private struct VoiceWakePayload: Codable, Equatable {
|
||||||
|
let triggers: [String]
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard self.task == nil else { return }
|
||||||
|
self.task = Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
while !Task.isCancelled {
|
||||||
|
do {
|
||||||
|
try await GatewayConnection.shared.refresh()
|
||||||
|
} catch {
|
||||||
|
// Not configured / not reachable yet.
|
||||||
|
}
|
||||||
|
|
||||||
|
await self.refreshFromGateway()
|
||||||
|
|
||||||
|
let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200)
|
||||||
|
for await push in stream {
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
await self.handle(push: push)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the stream finishes (gateway shutdown / reconnect), loop and resubscribe.
|
||||||
|
try? await Task.sleep(nanoseconds: 600_000_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
self.task?.cancel()
|
||||||
|
self.task = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshFromGateway() async {
|
||||||
|
do {
|
||||||
|
let triggers = try await GatewayConnection.shared.voiceWakeGetTriggers()
|
||||||
|
AppStateStore.shared.applyGlobalVoiceWakeTriggers(triggers)
|
||||||
|
} catch {
|
||||||
|
// Best-effort only.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handle(push: GatewayPush) async {
|
||||||
|
guard case let .event(evt) = push else { return }
|
||||||
|
guard evt.event == "voicewake.changed" else { return }
|
||||||
|
guard let payload = evt.payload else { return }
|
||||||
|
do {
|
||||||
|
let decoded = try GatewayPayloadDecoding.decode(payload, as: VoiceWakePayload.self)
|
||||||
|
AppStateStore.shared.applyGlobalVoiceWakeTriggers(decoded.triggers)
|
||||||
|
} catch {
|
||||||
|
self.logger.error("failed to decode voicewake.changed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
60
apps/macos/Sources/Moltbot/VoiceWakeOverlay.swift
Normal file
60
apps/macos/Sources/Moltbot/VoiceWakeOverlay.swift
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import AppKit
|
||||||
|
import Observation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Lightweight, borderless panel that shows the current voice wake transcript near the menu bar.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class VoiceWakeOverlayController {
|
||||||
|
static let shared = VoiceWakeOverlayController()
|
||||||
|
|
||||||
|
let logger = Logger(subsystem: "bot.molt", category: "voicewake.overlay")
|
||||||
|
let enableUI: Bool
|
||||||
|
|
||||||
|
/// Keep the voice wake overlay above any other Moltbot windows, but below the system’s pop-up menus.
|
||||||
|
/// (Menu bar menus typically live at `.popUpMenu`.)
|
||||||
|
static let preferredWindowLevel = NSWindow.Level(rawValue: NSWindow.Level.popUpMenu.rawValue - 4)
|
||||||
|
|
||||||
|
enum Source: String { case wakeWord, pushToTalk }
|
||||||
|
|
||||||
|
var model = Model()
|
||||||
|
var isVisible: Bool { self.model.isVisible }
|
||||||
|
|
||||||
|
struct Model {
|
||||||
|
var text: String = ""
|
||||||
|
var isFinal: Bool = false
|
||||||
|
var isVisible: Bool = false
|
||||||
|
var forwardEnabled: Bool = false
|
||||||
|
var isSending: Bool = false
|
||||||
|
var attributed: NSAttributedString = .init(string: "")
|
||||||
|
var isOverflowing: Bool = false
|
||||||
|
var isEditing: Bool = false
|
||||||
|
var level: Double = 0 // normalized 0...1 speech level for UI
|
||||||
|
}
|
||||||
|
|
||||||
|
var window: NSPanel?
|
||||||
|
var hostingView: NSHostingView<VoiceWakeOverlayView>?
|
||||||
|
var autoSendTask: Task<Void, Never>?
|
||||||
|
var autoSendToken: UUID?
|
||||||
|
var activeToken: UUID?
|
||||||
|
var activeSource: Source?
|
||||||
|
var lastLevelUpdate: TimeInterval = 0
|
||||||
|
|
||||||
|
let width: CGFloat = 360
|
||||||
|
let padding: CGFloat = 10
|
||||||
|
let buttonWidth: CGFloat = 36
|
||||||
|
let spacing: CGFloat = 8
|
||||||
|
let verticalPadding: CGFloat = 8
|
||||||
|
let maxHeight: CGFloat = 400
|
||||||
|
let minHeight: CGFloat = 48
|
||||||
|
let closeOverflow: CGFloat = 10
|
||||||
|
let levelUpdateInterval: TimeInterval = 1.0 / 12.0
|
||||||
|
|
||||||
|
enum DismissReason { case explicit, empty }
|
||||||
|
enum SendOutcome { case sent, empty }
|
||||||
|
enum GuardOutcome { case accept, dropMismatch, dropNoActive }
|
||||||
|
|
||||||
|
init(enableUI: Bool = true) {
|
||||||
|
self.enableUI = enableUI
|
||||||
|
}
|
||||||
|
}
|
||||||
804
apps/macos/Sources/Moltbot/VoiceWakeRuntime.swift
Normal file
804
apps/macos/Sources/Moltbot/VoiceWakeRuntime.swift
Normal file
@@ -0,0 +1,804 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
import Speech
|
||||||
|
import SwabbleKit
|
||||||
|
#if canImport(AppKit)
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// Background listener that keeps the voice-wake pipeline alive outside the settings test view.
|
||||||
|
actor VoiceWakeRuntime {
|
||||||
|
static let shared = VoiceWakeRuntime()
|
||||||
|
|
||||||
|
enum ListeningState { case idle, voiceWake, pushToTalk }
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "bot.molt", category: "voicewake.runtime")
|
||||||
|
|
||||||
|
private var recognizer: SFSpeechRecognizer?
|
||||||
|
// Lazily created on start to avoid creating an AVAudioEngine at app launch, which can switch Bluetooth
|
||||||
|
// headphones into the low-quality headset profile even if Voice Wake is disabled.
|
||||||
|
private var audioEngine: AVAudioEngine?
|
||||||
|
private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
|
||||||
|
private var recognitionTask: SFSpeechRecognitionTask?
|
||||||
|
private var recognitionGeneration: Int = 0 // drop stale callbacks after restarts
|
||||||
|
private var lastHeard: Date?
|
||||||
|
private var noiseFloorRMS: Double = 1e-4
|
||||||
|
private var captureStartedAt: Date?
|
||||||
|
private var captureTask: Task<Void, Never>?
|
||||||
|
private var capturedTranscript: String = ""
|
||||||
|
private var isCapturing: Bool = false
|
||||||
|
private var heardBeyondTrigger: Bool = false
|
||||||
|
private var triggerChimePlayed: Bool = false
|
||||||
|
private var committedTranscript: String = ""
|
||||||
|
private var volatileTranscript: String = ""
|
||||||
|
private var cooldownUntil: Date?
|
||||||
|
private var currentConfig: RuntimeConfig?
|
||||||
|
private var listeningState: ListeningState = .idle
|
||||||
|
private var overlayToken: UUID?
|
||||||
|
private var activeTriggerEndTime: TimeInterval?
|
||||||
|
private var scheduledRestartTask: Task<Void, Never>?
|
||||||
|
private var lastLoggedText: String?
|
||||||
|
private var lastLoggedAt: Date?
|
||||||
|
private var lastTapLogAt: Date?
|
||||||
|
private var lastCallbackLogAt: Date?
|
||||||
|
private var lastTranscript: String?
|
||||||
|
private var lastTranscriptAt: Date?
|
||||||
|
private var preDetectTask: Task<Void, Never>?
|
||||||
|
private var isStarting: Bool = false
|
||||||
|
private var triggerOnlyTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
// Tunables
|
||||||
|
// Silence threshold once we've captured user speech (post-trigger).
|
||||||
|
private let silenceWindow: TimeInterval = 2.0
|
||||||
|
// Silence threshold when we only heard the trigger but no post-trigger speech yet.
|
||||||
|
private let triggerOnlySilenceWindow: TimeInterval = 5.0
|
||||||
|
// Maximum capture duration from trigger until we force-send, to avoid runaway sessions.
|
||||||
|
private let captureHardStop: TimeInterval = 120.0
|
||||||
|
private let debounceAfterSend: TimeInterval = 0.35
|
||||||
|
// Voice activity detection parameters (RMS-based).
|
||||||
|
private let minSpeechRMS: Double = 1e-3
|
||||||
|
private let speechBoostFactor: Double = 6.0 // how far above noise floor we require to mark speech
|
||||||
|
private let preDetectSilenceWindow: TimeInterval = 1.0
|
||||||
|
private let triggerPauseWindow: TimeInterval = 0.55
|
||||||
|
|
||||||
|
/// Stops the active Speech pipeline without clearing the stored config, so we can restart cleanly.
|
||||||
|
private func haltRecognitionPipeline() {
|
||||||
|
// Bump generation first so any in-flight callbacks from the cancelled task get dropped.
|
||||||
|
self.recognitionGeneration &+= 1
|
||||||
|
self.recognitionTask?.cancel()
|
||||||
|
self.recognitionTask = nil
|
||||||
|
self.recognitionRequest?.endAudio()
|
||||||
|
self.recognitionRequest = nil
|
||||||
|
self.audioEngine?.inputNode.removeTap(onBus: 0)
|
||||||
|
self.audioEngine?.stop()
|
||||||
|
// Release the engine so we also release any audio session/resources when Voice Wake is idle.
|
||||||
|
self.audioEngine = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RuntimeConfig: Equatable {
|
||||||
|
let triggers: [String]
|
||||||
|
let micID: String?
|
||||||
|
let localeID: String?
|
||||||
|
let triggerChime: VoiceWakeChime
|
||||||
|
let sendChime: VoiceWakeChime
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct RecognitionUpdate {
|
||||||
|
let transcript: String?
|
||||||
|
let segments: [WakeWordSegment]
|
||||||
|
let isFinal: Bool
|
||||||
|
let error: Error?
|
||||||
|
let generation: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh(state: AppState) async {
|
||||||
|
let snapshot = await MainActor.run { () -> (Bool, RuntimeConfig) in
|
||||||
|
let enabled = state.swabbleEnabled
|
||||||
|
let config = RuntimeConfig(
|
||||||
|
triggers: sanitizeVoiceWakeTriggers(state.swabbleTriggerWords),
|
||||||
|
micID: state.voiceWakeMicID.isEmpty ? nil : state.voiceWakeMicID,
|
||||||
|
localeID: state.voiceWakeLocaleID.isEmpty ? nil : state.voiceWakeLocaleID,
|
||||||
|
triggerChime: state.voiceWakeTriggerChime,
|
||||||
|
sendChime: state.voiceWakeSendChime)
|
||||||
|
return (enabled, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard voiceWakeSupported, snapshot.0 else {
|
||||||
|
self.stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard PermissionManager.voiceWakePermissionsGranted() else {
|
||||||
|
self.logger.debug("voicewake runtime not starting: permissions missing")
|
||||||
|
self.stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = snapshot.1
|
||||||
|
|
||||||
|
if self.isStarting {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.scheduledRestartTask != nil, config == self.currentConfig, self.recognitionTask == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.scheduledRestartTask != nil {
|
||||||
|
self.scheduledRestartTask?.cancel()
|
||||||
|
self.scheduledRestartTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if config == self.currentConfig, self.recognitionTask != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.stop()
|
||||||
|
await self.start(with: config)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func start(with config: RuntimeConfig) async {
|
||||||
|
if self.isStarting {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.isStarting = true
|
||||||
|
defer { self.isStarting = false }
|
||||||
|
do {
|
||||||
|
self.recognitionGeneration &+= 1
|
||||||
|
let generation = self.recognitionGeneration
|
||||||
|
|
||||||
|
self.configureSession(localeID: config.localeID)
|
||||||
|
|
||||||
|
guard let recognizer, recognizer.isAvailable else {
|
||||||
|
self.logger.error("voicewake runtime: speech recognizer unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest()
|
||||||
|
self.recognitionRequest?.shouldReportPartialResults = true
|
||||||
|
self.recognitionRequest?.taskHint = .dictation
|
||||||
|
guard let request = self.recognitionRequest else { return }
|
||||||
|
|
||||||
|
// Lazily create the engine here so app launch doesn't grab audio resources / trigger Bluetooth HFP.
|
||||||
|
if self.audioEngine == nil {
|
||||||
|
self.audioEngine = AVAudioEngine()
|
||||||
|
}
|
||||||
|
guard let audioEngine = self.audioEngine else { return }
|
||||||
|
|
||||||
|
let input = audioEngine.inputNode
|
||||||
|
let format = input.outputFormat(forBus: 0)
|
||||||
|
guard format.channelCount > 0, format.sampleRate > 0 else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "VoiceWakeRuntime",
|
||||||
|
code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "No audio input available"])
|
||||||
|
}
|
||||||
|
input.removeTap(onBus: 0)
|
||||||
|
input.installTap(onBus: 0, bufferSize: 2048, format: format) { [weak self, weak request] buffer, _ in
|
||||||
|
request?.append(buffer)
|
||||||
|
guard let rms = Self.rmsLevel(buffer: buffer) else { return }
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
await self?.noteAudioLevel(rms: rms)
|
||||||
|
await self?.noteAudioTap(rms: rms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
audioEngine.prepare()
|
||||||
|
try audioEngine.start()
|
||||||
|
|
||||||
|
self.currentConfig = config
|
||||||
|
self.lastHeard = Date()
|
||||||
|
// Preserve any existing cooldownUntil so the debounce after send isn't wiped by a restart.
|
||||||
|
|
||||||
|
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.noteRecognitionCallback(transcript: transcript, isFinal: isFinal, error: error) }
|
||||||
|
let update = RecognitionUpdate(
|
||||||
|
transcript: transcript,
|
||||||
|
segments: segments,
|
||||||
|
isFinal: isFinal,
|
||||||
|
error: error,
|
||||||
|
generation: generation)
|
||||||
|
Task { await self.handleRecognition(update, config: config) }
|
||||||
|
}
|
||||||
|
|
||||||
|
let preferred = config.micID?.isEmpty == false ? config.micID! : "system-default"
|
||||||
|
self.logger.info(
|
||||||
|
"voicewake runtime input preferred=\(preferred, privacy: .public) " +
|
||||||
|
"\(AudioInputDeviceObserver.defaultInputDeviceSummary(), privacy: .public)")
|
||||||
|
self.logger.info("voicewake runtime started")
|
||||||
|
DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "started", fields: [
|
||||||
|
"locale": config.localeID ?? "",
|
||||||
|
"micID": config.micID ?? "",
|
||||||
|
])
|
||||||
|
} catch {
|
||||||
|
self.logger.error("voicewake runtime failed to start: \(error.localizedDescription, privacy: .public)")
|
||||||
|
self.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stop(dismissOverlay: Bool = true, cancelScheduledRestart: Bool = true) {
|
||||||
|
if cancelScheduledRestart {
|
||||||
|
self.scheduledRestartTask?.cancel()
|
||||||
|
self.scheduledRestartTask = nil
|
||||||
|
}
|
||||||
|
self.captureTask?.cancel()
|
||||||
|
self.captureTask = nil
|
||||||
|
self.isCapturing = false
|
||||||
|
self.capturedTranscript = ""
|
||||||
|
self.captureStartedAt = nil
|
||||||
|
self.triggerChimePlayed = false
|
||||||
|
self.lastTranscript = nil
|
||||||
|
self.lastTranscriptAt = nil
|
||||||
|
self.preDetectTask?.cancel()
|
||||||
|
self.preDetectTask = nil
|
||||||
|
self.triggerOnlyTask?.cancel()
|
||||||
|
self.triggerOnlyTask = nil
|
||||||
|
self.haltRecognitionPipeline()
|
||||||
|
self.recognizer = nil
|
||||||
|
self.currentConfig = nil
|
||||||
|
self.listeningState = .idle
|
||||||
|
self.activeTriggerEndTime = nil
|
||||||
|
self.logger.debug("voicewake runtime stopped")
|
||||||
|
DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "stopped")
|
||||||
|
|
||||||
|
let token = self.overlayToken
|
||||||
|
self.overlayToken = nil
|
||||||
|
guard dismissOverlay else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
if let token {
|
||||||
|
VoiceSessionCoordinator.shared.dismiss(token: token, reason: .explicit, outcome: .empty)
|
||||||
|
} else {
|
||||||
|
VoiceWakeOverlayController.shared.dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureSession(localeID: String?) {
|
||||||
|
let locale = localeID.flatMap { Locale(identifier: $0) } ?? Locale(identifier: Locale.current.identifier)
|
||||||
|
self.recognizer = SFSpeechRecognizer(locale: locale)
|
||||||
|
self.recognizer?.defaultTaskHint = .dictation
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleRecognition(_ update: RecognitionUpdate, config: RuntimeConfig) async {
|
||||||
|
if update.generation != self.recognitionGeneration {
|
||||||
|
return // stale callback from a superseded recognizer session
|
||||||
|
}
|
||||||
|
if let error = update.error {
|
||||||
|
self.logger.debug("voicewake recognition error: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let transcript = update.transcript else { return }
|
||||||
|
|
||||||
|
let now = Date()
|
||||||
|
if !transcript.isEmpty {
|
||||||
|
self.lastHeard = now
|
||||||
|
if !self.isCapturing {
|
||||||
|
self.lastTranscript = transcript
|
||||||
|
self.lastTranscriptAt = now
|
||||||
|
}
|
||||||
|
if self.isCapturing {
|
||||||
|
self.maybeLogRecognition(
|
||||||
|
transcript: transcript,
|
||||||
|
segments: update.segments,
|
||||||
|
triggers: config.triggers,
|
||||||
|
isFinal: update.isFinal,
|
||||||
|
match: nil,
|
||||||
|
usedFallback: false,
|
||||||
|
capturing: true)
|
||||||
|
let trimmed = Self.commandAfterTrigger(
|
||||||
|
transcript: transcript,
|
||||||
|
segments: update.segments,
|
||||||
|
triggerEndTime: self.activeTriggerEndTime,
|
||||||
|
triggers: config.triggers)
|
||||||
|
self.capturedTranscript = trimmed
|
||||||
|
self.updateHeardBeyondTrigger(withTrimmed: trimmed)
|
||||||
|
if update.isFinal {
|
||||||
|
self.committedTranscript = trimmed
|
||||||
|
self.volatileTranscript = ""
|
||||||
|
} else {
|
||||||
|
self.volatileTranscript = Self.delta(after: self.committedTranscript, current: trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
let attributed = Self.makeAttributed(
|
||||||
|
committed: self.committedTranscript,
|
||||||
|
volatile: self.volatileTranscript,
|
||||||
|
isFinal: update.isFinal)
|
||||||
|
let snapshot = self.committedTranscript + self.volatileTranscript
|
||||||
|
if let token = self.overlayToken {
|
||||||
|
await MainActor.run {
|
||||||
|
VoiceSessionCoordinator.shared.updatePartial(
|
||||||
|
token: token,
|
||||||
|
text: snapshot,
|
||||||
|
attributed: attributed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.isCapturing { return }
|
||||||
|
|
||||||
|
let gateConfig = WakeWordGateConfig(triggers: config.triggers)
|
||||||
|
var usedFallback = false
|
||||||
|
var match = WakeWordGate.match(transcript: transcript, segments: update.segments, config: gateConfig)
|
||||||
|
if match == nil, update.isFinal {
|
||||||
|
match = self.textOnlyFallbackMatch(
|
||||||
|
transcript: transcript,
|
||||||
|
triggers: config.triggers,
|
||||||
|
config: gateConfig)
|
||||||
|
usedFallback = match != nil
|
||||||
|
}
|
||||||
|
self.maybeLogRecognition(
|
||||||
|
transcript: transcript,
|
||||||
|
segments: update.segments,
|
||||||
|
triggers: config.triggers,
|
||||||
|
isFinal: update.isFinal,
|
||||||
|
match: match,
|
||||||
|
usedFallback: usedFallback,
|
||||||
|
capturing: false)
|
||||||
|
|
||||||
|
if let match {
|
||||||
|
if let cooldown = cooldownUntil, now < cooldown {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if usedFallback {
|
||||||
|
self.logger.info("voicewake runtime detected (text-only fallback) len=\(match.command.count)")
|
||||||
|
} else {
|
||||||
|
self.logger.info("voicewake runtime detected len=\(match.command.count)")
|
||||||
|
}
|
||||||
|
await self.beginCapture(command: match.command, triggerEndTime: match.triggerEndTime, config: config)
|
||||||
|
} else if !transcript.isEmpty, update.error == nil {
|
||||||
|
if self.isTriggerOnly(transcript: transcript, triggers: config.triggers) {
|
||||||
|
self.preDetectTask?.cancel()
|
||||||
|
self.preDetectTask = nil
|
||||||
|
self.scheduleTriggerOnlyPauseCheck(triggers: config.triggers, config: config)
|
||||||
|
} else {
|
||||||
|
self.triggerOnlyTask?.cancel()
|
||||||
|
self.triggerOnlyTask = nil
|
||||||
|
self.schedulePreDetectSilenceCheck(
|
||||||
|
triggers: config.triggers,
|
||||||
|
gateConfig: gateConfig,
|
||||||
|
config: config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func maybeLogRecognition(
|
||||||
|
transcript: String,
|
||||||
|
segments: [WakeWordSegment],
|
||||||
|
triggers: [String],
|
||||||
|
isFinal: Bool,
|
||||||
|
match: WakeWordGateMatch?,
|
||||||
|
usedFallback: Bool,
|
||||||
|
capturing: Bool)
|
||||||
|
{
|
||||||
|
guard !transcript.isEmpty else { return }
|
||||||
|
let level = self.logger.logLevel
|
||||||
|
guard level == .debug || level == .trace else { return }
|
||||||
|
if transcript == self.lastLoggedText, !isFinal {
|
||||||
|
if let last = self.lastLoggedAt, Date().timeIntervalSince(last) < 0.25 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.lastLoggedText = transcript
|
||||||
|
self.lastLoggedAt = Date()
|
||||||
|
|
||||||
|
let textOnly = WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers)
|
||||||
|
let timingCount = segments.count(where: { $0.start > 0 || $0.duration > 0 })
|
||||||
|
let matchSummary = match.map {
|
||||||
|
"match=true gap=\(String(format: "%.2f", $0.postGap))s cmdLen=\($0.command.count)"
|
||||||
|
} ?? "match=false"
|
||||||
|
let segmentSummary = segments.map { seg in
|
||||||
|
let start = String(format: "%.2f", seg.start)
|
||||||
|
let end = String(format: "%.2f", seg.end)
|
||||||
|
return "\(seg.text)@\(start)-\(end)"
|
||||||
|
}.joined(separator: ", ")
|
||||||
|
|
||||||
|
self.logger.debug(
|
||||||
|
"voicewake runtime transcript='\(transcript, privacy: .private)' textOnly=\(textOnly) " +
|
||||||
|
"isFinal=\(isFinal) timing=\(timingCount)/\(segments.count) " +
|
||||||
|
"capturing=\(capturing) fallback=\(usedFallback) " +
|
||||||
|
"\(matchSummary) segments=[\(segmentSummary, privacy: .private)]")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func noteAudioTap(rms: Double) {
|
||||||
|
let now = Date()
|
||||||
|
if let last = self.lastTapLogAt, now.timeIntervalSince(last) < 1.0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.lastTapLogAt = now
|
||||||
|
let db = 20 * log10(max(rms, 1e-7))
|
||||||
|
self.logger.debug(
|
||||||
|
"voicewake runtime audio tap rms=\(String(format: "%.6f", rms)) " +
|
||||||
|
"db=\(String(format: "%.1f", db)) capturing=\(self.isCapturing)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func noteRecognitionCallback(transcript: String?, isFinal: Bool, error: Error?) {
|
||||||
|
guard transcript?.isEmpty ?? true else { return }
|
||||||
|
let now = Date()
|
||||||
|
if let last = self.lastCallbackLogAt, now.timeIntervalSince(last) < 1.0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.lastCallbackLogAt = now
|
||||||
|
let errorSummary = error?.localizedDescription ?? "none"
|
||||||
|
self.logger.debug(
|
||||||
|
"voicewake runtime callback empty transcript isFinal=\(isFinal) error=\(errorSummary, privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleTriggerOnlyPauseCheck(triggers: [String], config: RuntimeConfig) {
|
||||||
|
self.triggerOnlyTask?.cancel()
|
||||||
|
let lastSeenAt = self.lastTranscriptAt
|
||||||
|
let lastText = self.lastTranscript
|
||||||
|
let windowNanos = UInt64(self.triggerPauseWindow * 1_000_000_000)
|
||||||
|
self.triggerOnlyTask = Task { [weak self, lastSeenAt, lastText] in
|
||||||
|
try? await Task.sleep(nanoseconds: windowNanos)
|
||||||
|
guard let self else { return }
|
||||||
|
await self.triggerOnlyPauseCheck(
|
||||||
|
lastSeenAt: lastSeenAt,
|
||||||
|
lastText: lastText,
|
||||||
|
triggers: triggers,
|
||||||
|
config: config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func schedulePreDetectSilenceCheck(
|
||||||
|
triggers: [String],
|
||||||
|
gateConfig: WakeWordGateConfig,
|
||||||
|
config: RuntimeConfig)
|
||||||
|
{
|
||||||
|
self.preDetectTask?.cancel()
|
||||||
|
let lastSeenAt = self.lastTranscriptAt
|
||||||
|
let lastText = self.lastTranscript
|
||||||
|
let windowNanos = UInt64(self.preDetectSilenceWindow * 1_000_000_000)
|
||||||
|
self.preDetectTask = Task { [weak self, lastSeenAt, lastText] in
|
||||||
|
try? await Task.sleep(nanoseconds: windowNanos)
|
||||||
|
guard let self else { return }
|
||||||
|
await self.preDetectSilenceCheck(
|
||||||
|
lastSeenAt: lastSeenAt,
|
||||||
|
lastText: lastText,
|
||||||
|
triggers: triggers,
|
||||||
|
gateConfig: gateConfig,
|
||||||
|
config: config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func triggerOnlyPauseCheck(
|
||||||
|
lastSeenAt: Date?,
|
||||||
|
lastText: String?,
|
||||||
|
triggers: [String],
|
||||||
|
config: RuntimeConfig) async
|
||||||
|
{
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
guard !self.isCapturing else { return }
|
||||||
|
guard let lastSeenAt, let lastText else { return }
|
||||||
|
guard self.lastTranscriptAt == lastSeenAt, self.lastTranscript == lastText else { return }
|
||||||
|
guard self.isTriggerOnly(transcript: lastText, triggers: triggers) else { return }
|
||||||
|
if let cooldown = self.cooldownUntil, Date() < cooldown {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.logger.info("voicewake runtime detected (trigger-only pause)")
|
||||||
|
await self.beginCapture(command: "", triggerEndTime: nil, config: config)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func textOnlyFallbackMatch(
|
||||||
|
transcript: String,
|
||||||
|
triggers: [String],
|
||||||
|
config: WakeWordGateConfig) -> WakeWordGateMatch?
|
||||||
|
{
|
||||||
|
guard let command = VoiceWakeTextUtils.textOnlyCommand(
|
||||||
|
transcript: transcript,
|
||||||
|
triggers: triggers,
|
||||||
|
minCommandLength: config.minCommandLength,
|
||||||
|
trimWake: Self.trimmedAfterTrigger)
|
||||||
|
else { return nil }
|
||||||
|
return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: command)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isTriggerOnly(transcript: String, triggers: [String]) -> Bool {
|
||||||
|
guard WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers) else { return false }
|
||||||
|
guard VoiceWakeTextUtils.startsWithTrigger(transcript: transcript, triggers: triggers) else { return false }
|
||||||
|
return Self.trimmedAfterTrigger(transcript, triggers: triggers).isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
private func preDetectSilenceCheck(
|
||||||
|
lastSeenAt: Date?,
|
||||||
|
lastText: String?,
|
||||||
|
triggers: [String],
|
||||||
|
gateConfig: WakeWordGateConfig,
|
||||||
|
config: RuntimeConfig) async
|
||||||
|
{
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
guard !self.isCapturing else { return }
|
||||||
|
guard let lastSeenAt, let lastText else { return }
|
||||||
|
guard self.lastTranscriptAt == lastSeenAt, self.lastTranscript == lastText else { return }
|
||||||
|
guard let match = self.textOnlyFallbackMatch(
|
||||||
|
transcript: lastText,
|
||||||
|
triggers: triggers,
|
||||||
|
config: gateConfig)
|
||||||
|
else { return }
|
||||||
|
if let cooldown = self.cooldownUntil, Date() < cooldown {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.logger.info("voicewake runtime detected (silence fallback) len=\(match.command.count)")
|
||||||
|
await self.beginCapture(
|
||||||
|
command: match.command,
|
||||||
|
triggerEndTime: match.triggerEndTime,
|
||||||
|
config: config)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func beginCapture(command: String, triggerEndTime: TimeInterval?, config: RuntimeConfig) async {
|
||||||
|
self.listeningState = .voiceWake
|
||||||
|
self.isCapturing = true
|
||||||
|
DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "beginCapture")
|
||||||
|
self.capturedTranscript = command
|
||||||
|
self.committedTranscript = ""
|
||||||
|
self.volatileTranscript = command
|
||||||
|
self.captureStartedAt = Date()
|
||||||
|
self.cooldownUntil = nil
|
||||||
|
self.heardBeyondTrigger = !command.isEmpty
|
||||||
|
self.triggerChimePlayed = false
|
||||||
|
self.activeTriggerEndTime = triggerEndTime
|
||||||
|
self.preDetectTask?.cancel()
|
||||||
|
self.preDetectTask = nil
|
||||||
|
self.triggerOnlyTask?.cancel()
|
||||||
|
self.triggerOnlyTask = nil
|
||||||
|
|
||||||
|
if config.triggerChime != .none, !self.triggerChimePlayed {
|
||||||
|
self.triggerChimePlayed = true
|
||||||
|
await MainActor.run { VoiceWakeChimePlayer.play(config.triggerChime, reason: "voicewake.trigger") }
|
||||||
|
}
|
||||||
|
|
||||||
|
let snapshot = self.committedTranscript + self.volatileTranscript
|
||||||
|
let attributed = Self.makeAttributed(
|
||||||
|
committed: self.committedTranscript,
|
||||||
|
volatile: self.volatileTranscript,
|
||||||
|
isFinal: false)
|
||||||
|
self.overlayToken = await MainActor.run {
|
||||||
|
VoiceSessionCoordinator.shared.startSession(
|
||||||
|
source: .wakeWord,
|
||||||
|
text: snapshot,
|
||||||
|
attributed: attributed,
|
||||||
|
forwardEnabled: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the "ears" boosted for the capture window so the status icon animates while recording.
|
||||||
|
await MainActor.run { AppStateStore.shared.triggerVoiceEars(ttl: nil) }
|
||||||
|
|
||||||
|
self.captureTask?.cancel()
|
||||||
|
self.captureTask = Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
await self.monitorCapture(config: config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func monitorCapture(config: RuntimeConfig) async {
|
||||||
|
let start = self.captureStartedAt ?? Date()
|
||||||
|
let hardStop = start.addingTimeInterval(self.captureHardStop)
|
||||||
|
|
||||||
|
while self.isCapturing {
|
||||||
|
let now = Date()
|
||||||
|
if now >= hardStop {
|
||||||
|
// Hard-stop after a maximum duration so we never leave the recognizer pinned open.
|
||||||
|
await self.finalizeCapture(config: config)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let silenceThreshold = self.heardBeyondTrigger ? self.silenceWindow : self.triggerOnlySilenceWindow
|
||||||
|
if let last = self.lastHeard, now.timeIntervalSince(last) >= silenceThreshold {
|
||||||
|
await self.finalizeCapture(config: config)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finalizeCapture(config: RuntimeConfig) async {
|
||||||
|
guard self.isCapturing else { return }
|
||||||
|
self.isCapturing = false
|
||||||
|
// Disarm trigger matching immediately (before halting recognition) to avoid double-trigger
|
||||||
|
// races from late callbacks that arrive after isCapturing is cleared.
|
||||||
|
self.cooldownUntil = Date().addingTimeInterval(self.debounceAfterSend)
|
||||||
|
self.captureTask?.cancel()
|
||||||
|
self.captureTask = nil
|
||||||
|
|
||||||
|
let finalTranscript = self.capturedTranscript.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "finalizeCapture", fields: [
|
||||||
|
"finalLen": "\(finalTranscript.count)",
|
||||||
|
])
|
||||||
|
// Stop further recognition events so we don't retrigger immediately with buffered audio.
|
||||||
|
self.haltRecognitionPipeline()
|
||||||
|
self.capturedTranscript = ""
|
||||||
|
self.captureStartedAt = nil
|
||||||
|
self.lastHeard = nil
|
||||||
|
self.heardBeyondTrigger = false
|
||||||
|
self.triggerChimePlayed = false
|
||||||
|
self.activeTriggerEndTime = nil
|
||||||
|
self.lastTranscript = nil
|
||||||
|
self.lastTranscriptAt = nil
|
||||||
|
self.preDetectTask?.cancel()
|
||||||
|
self.preDetectTask = nil
|
||||||
|
self.triggerOnlyTask?.cancel()
|
||||||
|
self.triggerOnlyTask = nil
|
||||||
|
|
||||||
|
await MainActor.run { AppStateStore.shared.stopVoiceEars() }
|
||||||
|
if let token = self.overlayToken {
|
||||||
|
await MainActor.run { VoiceSessionCoordinator.shared.updateLevel(token: token, 0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
let delay: TimeInterval = 0.0
|
||||||
|
let sendChime = finalTranscript.isEmpty ? .none : config.sendChime
|
||||||
|
if let token = self.overlayToken {
|
||||||
|
await MainActor.run {
|
||||||
|
VoiceSessionCoordinator.shared.finalize(
|
||||||
|
token: token,
|
||||||
|
text: finalTranscript,
|
||||||
|
sendChime: sendChime,
|
||||||
|
autoSendAfter: delay)
|
||||||
|
}
|
||||||
|
} else if !finalTranscript.isEmpty {
|
||||||
|
if sendChime != .none {
|
||||||
|
await MainActor.run { VoiceWakeChimePlayer.play(sendChime, reason: "voicewake.send") }
|
||||||
|
}
|
||||||
|
Task.detached {
|
||||||
|
await VoiceWakeForwarder.forward(transcript: finalTranscript)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.overlayToken = nil
|
||||||
|
self.scheduleRestartRecognizer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Audio level handling
|
||||||
|
|
||||||
|
private func noteAudioLevel(rms: Double) {
|
||||||
|
guard self.isCapturing else { return }
|
||||||
|
|
||||||
|
// Update adaptive noise floor: faster when lower energy (quiet), slower when loud.
|
||||||
|
let alpha: Double = rms < self.noiseFloorRMS ? 0.08 : 0.01
|
||||||
|
self.noiseFloorRMS = max(1e-7, self.noiseFloorRMS + (rms - self.noiseFloorRMS) * alpha)
|
||||||
|
|
||||||
|
let threshold = max(self.minSpeechRMS, self.noiseFloorRMS * self.speechBoostFactor)
|
||||||
|
if rms >= threshold {
|
||||||
|
self.lastHeard = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize against the adaptive threshold so the UI meter stays roughly 0...1 across devices.
|
||||||
|
let clamped = min(1.0, max(0.0, rms / max(self.minSpeechRMS, threshold)))
|
||||||
|
if let token = self.overlayToken {
|
||||||
|
Task { @MainActor in
|
||||||
|
VoiceSessionCoordinator.shared.updateLevel(token: token, clamped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func rmsLevel(buffer: AVAudioPCMBuffer) -> Double? {
|
||||||
|
guard let channelData = buffer.floatChannelData?.pointee else { return nil }
|
||||||
|
let frameCount = Int(buffer.frameLength)
|
||||||
|
guard frameCount > 0 else { return nil }
|
||||||
|
var sum: Double = 0
|
||||||
|
for i in 0..<frameCount {
|
||||||
|
let sample = Double(channelData[i])
|
||||||
|
sum += sample * sample
|
||||||
|
}
|
||||||
|
return sqrt(sum / Double(frameCount))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func restartRecognizer() {
|
||||||
|
// Restart the recognizer so we listen for the next trigger with a clean buffer.
|
||||||
|
let current = self.currentConfig
|
||||||
|
self.stop(dismissOverlay: false, cancelScheduledRestart: false)
|
||||||
|
if let current {
|
||||||
|
Task { await self.start(with: current) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func restartRecognizerIfIdleAndOverlayHidden() async {
|
||||||
|
if self.isCapturing { return }
|
||||||
|
self.restartRecognizer()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleRestartRecognizer(delay: TimeInterval = 0.7) {
|
||||||
|
self.scheduledRestartTask?.cancel()
|
||||||
|
self.scheduledRestartTask = Task { [weak self] in
|
||||||
|
let nanos = UInt64(max(0, delay) * 1_000_000_000)
|
||||||
|
try? await Task.sleep(nanoseconds: nanos)
|
||||||
|
guard let self else { return }
|
||||||
|
await self.consumeScheduledRestart()
|
||||||
|
await self.restartRecognizerIfIdleAndOverlayHidden()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func consumeScheduledRestart() {
|
||||||
|
self.scheduledRestartTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyPushToTalkCooldown() {
|
||||||
|
self.cooldownUntil = Date().addingTimeInterval(self.debounceAfterSend)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pauseForPushToTalk() {
|
||||||
|
self.listeningState = .pushToTalk
|
||||||
|
self.stop(dismissOverlay: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateHeardBeyondTrigger(withTrimmed trimmed: String) {
|
||||||
|
if !self.heardBeyondTrigger, !trimmed.isEmpty {
|
||||||
|
self.heardBeyondTrigger = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func trimmedAfterTrigger(_ text: String, triggers: [String]) -> String {
|
||||||
|
let lower = text.lowercased()
|
||||||
|
for trigger in triggers {
|
||||||
|
let token = trigger.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !token.isEmpty, let range = lower.range(of: token) else { continue }
|
||||||
|
let after = range.upperBound
|
||||||
|
let trimmed = text[after...].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return String(trimmed)
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func commandAfterTrigger(
|
||||||
|
transcript: String,
|
||||||
|
segments: [WakeWordSegment],
|
||||||
|
triggerEndTime: TimeInterval?,
|
||||||
|
triggers: [String]) -> String
|
||||||
|
{
|
||||||
|
guard let triggerEndTime else {
|
||||||
|
return self.trimmedAfterTrigger(transcript, triggers: triggers)
|
||||||
|
}
|
||||||
|
let trimmed = WakeWordGate.commandText(
|
||||||
|
transcript: transcript,
|
||||||
|
segments: segments,
|
||||||
|
triggerEndTime: triggerEndTime)
|
||||||
|
return trimmed.isEmpty ? self.trimmedAfterTrigger(transcript, triggers: triggers) : trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
static func _testTrimmedAfterTrigger(_ text: String, triggers: [String]) -> String {
|
||||||
|
self.trimmedAfterTrigger(text, triggers: triggers)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func _testHasContentAfterTrigger(_ text: String, triggers: [String]) -> Bool {
|
||||||
|
!self.trimmedAfterTrigger(text, triggers: triggers).isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
static func _testAttributedColor(isFinal: Bool) -> NSColor {
|
||||||
|
self.makeAttributed(committed: "sample", volatile: "", isFinal: isFinal)
|
||||||
|
.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor ?? .clear
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private static func delta(after committed: String, current: String) -> String {
|
||||||
|
if current.hasPrefix(committed) {
|
||||||
|
let start = current.index(current.startIndex, offsetBy: committed.count)
|
||||||
|
return String(current[start...])
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeAttributed(committed: String, volatile: String, isFinal: Bool) -> NSAttributedString {
|
||||||
|
let full = NSMutableAttributedString()
|
||||||
|
let committedAttr: [NSAttributedString.Key: Any] = [
|
||||||
|
.foregroundColor: NSColor.labelColor,
|
||||||
|
.font: NSFont.systemFont(ofSize: 13, weight: .regular),
|
||||||
|
]
|
||||||
|
full.append(NSAttributedString(string: committed, attributes: committedAttr))
|
||||||
|
let volatileColor: NSColor = isFinal ? .labelColor : NSColor.tertiaryLabelColor
|
||||||
|
let volatileAttr: [NSAttributedString.Key: Any] = [
|
||||||
|
.foregroundColor: volatileColor,
|
||||||
|
.font: NSFont.systemFont(ofSize: 13, weight: .regular),
|
||||||
|
]
|
||||||
|
full.append(NSAttributedString(string: volatile, attributes: volatileAttr))
|
||||||
|
return full
|
||||||
|
}
|
||||||
|
}
|
||||||
473
apps/macos/Sources/Moltbot/VoiceWakeTester.swift
Normal file
473
apps/macos/Sources/Moltbot/VoiceWakeTester.swift
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import Foundation
|
||||||
|
import Speech
|
||||||
|
import SwabbleKit
|
||||||
|
|
||||||
|
enum VoiceWakeTestState: Equatable {
|
||||||
|
case idle
|
||||||
|
case requesting
|
||||||
|
case listening
|
||||||
|
case hearing(String)
|
||||||
|
case finalizing
|
||||||
|
case detected(String)
|
||||||
|
case failed(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
final class VoiceWakeTester {
|
||||||
|
private let recognizer: SFSpeechRecognizer?
|
||||||
|
private var audioEngine: AVAudioEngine?
|
||||||
|
private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
|
||||||
|
private var recognitionTask: SFSpeechRecognitionTask?
|
||||||
|
private var isStopping = false
|
||||||
|
private var isFinalizing = false
|
||||||
|
private var detectionStart: Date?
|
||||||
|
private var lastHeard: Date?
|
||||||
|
private var lastLoggedText: String?
|
||||||
|
private var lastLoggedAt: Date?
|
||||||
|
private var lastTranscript: String?
|
||||||
|
private var lastTranscriptAt: Date?
|
||||||
|
private var silenceTask: Task<Void, Never>?
|
||||||
|
private var currentTriggers: [String] = []
|
||||||
|
private var holdingAfterDetect = false
|
||||||
|
private var detectedText: String?
|
||||||
|
private let logger = Logger(subsystem: "bot.molt", category: "voicewake")
|
||||||
|
private let silenceWindow: TimeInterval = 1.0
|
||||||
|
|
||||||
|
init(locale: Locale = .current) {
|
||||||
|
self.recognizer = SFSpeechRecognizer(locale: locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
func start(
|
||||||
|
triggers: [String],
|
||||||
|
micID: String?,
|
||||||
|
localeID: String?,
|
||||||
|
onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) async throws
|
||||||
|
{
|
||||||
|
guard self.recognitionTask == nil else { return }
|
||||||
|
self.isStopping = false
|
||||||
|
self.isFinalizing = false
|
||||||
|
self.holdingAfterDetect = false
|
||||||
|
self.detectedText = nil
|
||||||
|
self.lastHeard = nil
|
||||||
|
self.lastLoggedText = nil
|
||||||
|
self.lastLoggedAt = nil
|
||||||
|
self.lastTranscript = nil
|
||||||
|
self.lastTranscriptAt = nil
|
||||||
|
self.silenceTask?.cancel()
|
||||||
|
self.silenceTask = nil
|
||||||
|
self.currentTriggers = triggers
|
||||||
|
let chosenLocale = localeID.flatMap { Locale(identifier: $0) } ?? Locale.current
|
||||||
|
let recognizer = SFSpeechRecognizer(locale: chosenLocale)
|
||||||
|
guard let recognizer, recognizer.isAvailable else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "VoiceWakeTester",
|
||||||
|
code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Speech recognition unavailable"])
|
||||||
|
}
|
||||||
|
recognizer.defaultTaskHint = .dictation
|
||||||
|
|
||||||
|
guard Self.hasPrivacyStrings else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "VoiceWakeTester",
|
||||||
|
code: 3,
|
||||||
|
userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: """
|
||||||
|
Missing mic/speech privacy strings. Rebuild the mac app (scripts/restart-mac.sh) \
|
||||||
|
to include usage descriptions.
|
||||||
|
""",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
let granted = try await Self.ensurePermissions()
|
||||||
|
guard granted else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "VoiceWakeTester",
|
||||||
|
code: 2,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Microphone or speech permission denied"])
|
||||||
|
}
|
||||||
|
|
||||||
|
self.logInputSelection(preferredMicID: micID)
|
||||||
|
self.configureSession(preferredMicID: micID)
|
||||||
|
|
||||||
|
let engine = AVAudioEngine()
|
||||||
|
self.audioEngine = engine
|
||||||
|
|
||||||
|
self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest()
|
||||||
|
self.recognitionRequest?.shouldReportPartialResults = true
|
||||||
|
self.recognitionRequest?.taskHint = .dictation
|
||||||
|
let request = self.recognitionRequest
|
||||||
|
|
||||||
|
let inputNode = engine.inputNode
|
||||||
|
let format = inputNode.outputFormat(forBus: 0)
|
||||||
|
guard format.channelCount > 0, format.sampleRate > 0 else {
|
||||||
|
self.audioEngine = nil
|
||||||
|
throw NSError(
|
||||||
|
domain: "VoiceWakeTester",
|
||||||
|
code: 4,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "No audio input available"])
|
||||||
|
}
|
||||||
|
inputNode.removeTap(onBus: 0)
|
||||||
|
inputNode.installTap(onBus: 0, bufferSize: 2048, format: format) { [weak request] buffer, _ in
|
||||||
|
request?.append(buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
engine.prepare()
|
||||||
|
try engine.start()
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
onUpdate(.listening)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.detectionStart = Date()
|
||||||
|
self.lastHeard = self.detectionStart
|
||||||
|
|
||||||
|
guard let request = recognitionRequest else { return }
|
||||||
|
|
||||||
|
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 isFinal = result?.isFinal ?? false
|
||||||
|
let gateConfig = WakeWordGateConfig(triggers: triggers)
|
||||||
|
var match = WakeWordGate.match(transcript: text, segments: segments, config: gateConfig)
|
||||||
|
if match == nil, isFinal {
|
||||||
|
match = self.textOnlyFallbackMatch(
|
||||||
|
transcript: text,
|
||||||
|
triggers: triggers,
|
||||||
|
config: gateConfig)
|
||||||
|
}
|
||||||
|
self.maybeLogDebug(
|
||||||
|
transcript: text,
|
||||||
|
segments: segments,
|
||||||
|
triggers: triggers,
|
||||||
|
match: match,
|
||||||
|
isFinal: isFinal)
|
||||||
|
let errorMessage = error?.localizedDescription
|
||||||
|
|
||||||
|
Task { [weak self] in
|
||||||
|
guard let self, !self.isStopping else { return }
|
||||||
|
await self.handleResult(
|
||||||
|
match: match,
|
||||||
|
text: text,
|
||||||
|
isFinal: isFinal,
|
||||||
|
errorMessage: errorMessage,
|
||||||
|
onUpdate: onUpdate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
self.stop(force: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalize(timeout: TimeInterval = 1.5) {
|
||||||
|
guard self.recognitionTask != nil else {
|
||||||
|
self.stop(force: true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.isFinalizing = true
|
||||||
|
self.recognitionRequest?.endAudio()
|
||||||
|
if let engine = self.audioEngine {
|
||||||
|
engine.inputNode.removeTap(onBus: 0)
|
||||||
|
engine.stop()
|
||||||
|
}
|
||||||
|
Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
|
||||||
|
if !self.isStopping {
|
||||||
|
self.stop(force: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stop(force: Bool) {
|
||||||
|
if force { self.isStopping = true }
|
||||||
|
self.isFinalizing = false
|
||||||
|
self.recognitionRequest?.endAudio()
|
||||||
|
self.recognitionTask?.cancel()
|
||||||
|
self.recognitionTask = nil
|
||||||
|
self.recognitionRequest = nil
|
||||||
|
if let engine = self.audioEngine {
|
||||||
|
engine.inputNode.removeTap(onBus: 0)
|
||||||
|
engine.stop()
|
||||||
|
}
|
||||||
|
self.audioEngine = nil
|
||||||
|
self.holdingAfterDetect = false
|
||||||
|
self.detectedText = nil
|
||||||
|
self.lastHeard = nil
|
||||||
|
self.detectionStart = nil
|
||||||
|
self.lastLoggedText = nil
|
||||||
|
self.lastLoggedAt = nil
|
||||||
|
self.lastTranscript = nil
|
||||||
|
self.lastTranscriptAt = nil
|
||||||
|
self.silenceTask?.cancel()
|
||||||
|
self.silenceTask = nil
|
||||||
|
self.currentTriggers = []
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleResult(
|
||||||
|
match: WakeWordGateMatch?,
|
||||||
|
text: String,
|
||||||
|
isFinal: Bool,
|
||||||
|
errorMessage: String?,
|
||||||
|
onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) async
|
||||||
|
{
|
||||||
|
if !text.isEmpty {
|
||||||
|
self.lastHeard = Date()
|
||||||
|
self.lastTranscript = text
|
||||||
|
self.lastTranscriptAt = Date()
|
||||||
|
}
|
||||||
|
if self.holdingAfterDetect {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let match, !match.command.isEmpty {
|
||||||
|
self.holdingAfterDetect = true
|
||||||
|
self.detectedText = match.command
|
||||||
|
self.logger.info("voice wake detected (test) (len=\(match.command.count))")
|
||||||
|
await MainActor.run { AppStateStore.shared.triggerVoiceEars(ttl: nil) }
|
||||||
|
self.stop()
|
||||||
|
await MainActor.run {
|
||||||
|
AppStateStore.shared.stopVoiceEars()
|
||||||
|
onUpdate(.detected(match.command))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !isFinal, !text.isEmpty {
|
||||||
|
self.scheduleSilenceCheck(
|
||||||
|
triggers: self.currentTriggers,
|
||||||
|
onUpdate: onUpdate)
|
||||||
|
}
|
||||||
|
if self.isFinalizing {
|
||||||
|
Task { @MainActor in onUpdate(.finalizing) }
|
||||||
|
}
|
||||||
|
if let errorMessage {
|
||||||
|
self.stop(force: true)
|
||||||
|
Task { @MainActor in onUpdate(.failed(errorMessage)) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isFinal {
|
||||||
|
self.stop(force: true)
|
||||||
|
let state: VoiceWakeTestState = text.isEmpty
|
||||||
|
? .failed("No speech detected")
|
||||||
|
: .failed("No trigger heard: “\(text)”")
|
||||||
|
Task { @MainActor in onUpdate(state) }
|
||||||
|
} else {
|
||||||
|
let state: VoiceWakeTestState = text.isEmpty ? .listening : .hearing(text)
|
||||||
|
Task { @MainActor in onUpdate(state) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func maybeLogDebug(
|
||||||
|
transcript: String,
|
||||||
|
segments: [WakeWordSegment],
|
||||||
|
triggers: [String],
|
||||||
|
match: WakeWordGateMatch?,
|
||||||
|
isFinal: Bool)
|
||||||
|
{
|
||||||
|
guard !transcript.isEmpty else { return }
|
||||||
|
let level = self.logger.logLevel
|
||||||
|
guard level == .debug || level == .trace else { return }
|
||||||
|
if transcript == self.lastLoggedText, !isFinal {
|
||||||
|
if let last = self.lastLoggedAt, Date().timeIntervalSince(last) < 0.25 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.lastLoggedText = transcript
|
||||||
|
self.lastLoggedAt = Date()
|
||||||
|
|
||||||
|
let textOnly = WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers)
|
||||||
|
let gaps = Self.debugCandidateGaps(triggers: triggers, segments: segments)
|
||||||
|
let segmentSummary = Self.debugSegments(segments)
|
||||||
|
let timingCount = segments.count(where: { $0.start > 0 || $0.duration > 0 })
|
||||||
|
let matchSummary = match.map {
|
||||||
|
"match=true gap=\(String(format: "%.2f", $0.postGap))s cmdLen=\($0.command.count)"
|
||||||
|
} ?? "match=false"
|
||||||
|
|
||||||
|
self.logger.debug(
|
||||||
|
"voicewake test transcript='\(transcript, privacy: .private)' textOnly=\(textOnly) " +
|
||||||
|
"isFinal=\(isFinal) timing=\(timingCount)/\(segments.count) " +
|
||||||
|
"\(matchSummary) gaps=[\(gaps, privacy: .private)] segments=[\(segmentSummary, privacy: .private)]")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func debugSegments(_ segments: [WakeWordSegment]) -> String {
|
||||||
|
segments.map { seg in
|
||||||
|
let start = String(format: "%.2f", seg.start)
|
||||||
|
let end = String(format: "%.2f", seg.end)
|
||||||
|
return "\(seg.text)@\(start)-\(end)"
|
||||||
|
}.joined(separator: ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func debugCandidateGaps(triggers: [String], segments: [WakeWordSegment]) -> String {
|
||||||
|
let tokens = self.normalizeSegments(segments)
|
||||||
|
guard !tokens.isEmpty else { return "" }
|
||||||
|
let triggerTokens = self.normalizeTriggers(triggers)
|
||||||
|
var gaps: [String] = []
|
||||||
|
|
||||||
|
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) {
|
||||||
|
let matched = (0..<count).allSatisfy { tokens[i + $0].normalized == trigger.tokens[$0] }
|
||||||
|
if !matched { continue }
|
||||||
|
let triggerEnd = tokens[i + count - 1].end
|
||||||
|
let nextToken = tokens[i + count]
|
||||||
|
let gap = nextToken.start - triggerEnd
|
||||||
|
let formatted = String(format: "%.2f", gap)
|
||||||
|
gaps.append("\(trigger.tokens.joined(separator: " ")):\(formatted)s")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return gaps.joined(separator: ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DebugToken {
|
||||||
|
let normalized: String
|
||||||
|
let start: TimeInterval
|
||||||
|
let end: TimeInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DebugTriggerTokens {
|
||||||
|
let tokens: [String]
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizeTriggers(_ triggers: [String]) -> [DebugTriggerTokens] {
|
||||||
|
var output: [DebugTriggerTokens] = []
|
||||||
|
for trigger in triggers {
|
||||||
|
let tokens = trigger
|
||||||
|
.split(whereSeparator: { $0.isWhitespace })
|
||||||
|
.map { VoiceWakeTextUtils.normalizeToken(String($0)) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
if tokens.isEmpty { continue }
|
||||||
|
output.append(DebugTriggerTokens(tokens: tokens))
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizeSegments(_ segments: [WakeWordSegment]) -> [DebugToken] {
|
||||||
|
segments.compactMap { segment in
|
||||||
|
let normalized = VoiceWakeTextUtils.normalizeToken(segment.text)
|
||||||
|
guard !normalized.isEmpty else { return nil }
|
||||||
|
return DebugToken(
|
||||||
|
normalized: normalized,
|
||||||
|
start: segment.start,
|
||||||
|
end: segment.end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func textOnlyFallbackMatch(
|
||||||
|
transcript: String,
|
||||||
|
triggers: [String],
|
||||||
|
config: WakeWordGateConfig) -> WakeWordGateMatch?
|
||||||
|
{
|
||||||
|
guard let command = VoiceWakeTextUtils.textOnlyCommand(
|
||||||
|
transcript: transcript,
|
||||||
|
triggers: triggers,
|
||||||
|
minCommandLength: config.minCommandLength,
|
||||||
|
trimWake: { WakeWordGate.stripWake(text: $0, triggers: $1) })
|
||||||
|
else { return nil }
|
||||||
|
return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: command)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func holdUntilSilence(onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) {
|
||||||
|
Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
let detectedAt = Date()
|
||||||
|
let hardStop = detectedAt.addingTimeInterval(6) // cap overall listen after trigger
|
||||||
|
|
||||||
|
while !self.isStopping {
|
||||||
|
let now = Date()
|
||||||
|
if now >= hardStop { break }
|
||||||
|
if let last = self.lastHeard, now.timeIntervalSince(last) >= silenceWindow {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||||
|
}
|
||||||
|
if !self.isStopping {
|
||||||
|
self.stop()
|
||||||
|
await MainActor.run { AppStateStore.shared.stopVoiceEars() }
|
||||||
|
if let detectedText {
|
||||||
|
self.logger.info("voice wake hold finished; len=\(detectedText.count)")
|
||||||
|
Task { @MainActor in onUpdate(.detected(detectedText)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleSilenceCheck(
|
||||||
|
triggers: [String],
|
||||||
|
onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void)
|
||||||
|
{
|
||||||
|
self.silenceTask?.cancel()
|
||||||
|
let lastSeenAt = self.lastTranscriptAt
|
||||||
|
let lastText = self.lastTranscript
|
||||||
|
self.silenceTask = Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(self.silenceWindow * 1_000_000_000))
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
guard !self.isStopping, !self.holdingAfterDetect else { return }
|
||||||
|
guard let lastSeenAt, let lastText else { return }
|
||||||
|
guard self.lastTranscriptAt == lastSeenAt, self.lastTranscript == lastText else { return }
|
||||||
|
guard let match = self.textOnlyFallbackMatch(
|
||||||
|
transcript: lastText,
|
||||||
|
triggers: triggers,
|
||||||
|
config: WakeWordGateConfig(triggers: triggers)) else { return }
|
||||||
|
self.holdingAfterDetect = true
|
||||||
|
self.detectedText = match.command
|
||||||
|
self.logger.info("voice wake detected (test, silence) (len=\(match.command.count))")
|
||||||
|
await MainActor.run { AppStateStore.shared.triggerVoiceEars(ttl: nil) }
|
||||||
|
self.stop()
|
||||||
|
await MainActor.run {
|
||||||
|
AppStateStore.shared.stopVoiceEars()
|
||||||
|
onUpdate(.detected(match.command))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureSession(preferredMicID: String?) {
|
||||||
|
_ = preferredMicID
|
||||||
|
}
|
||||||
|
|
||||||
|
private func logInputSelection(preferredMicID: String?) {
|
||||||
|
let preferred = (preferredMicID?.isEmpty == false) ? preferredMicID! : "system-default"
|
||||||
|
self.logger.info(
|
||||||
|
"voicewake test input preferred=\(preferred, privacy: .public) " +
|
||||||
|
"\(AudioInputDeviceObserver.defaultInputDeviceSummary(), privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func ensurePermissions() async throws -> Bool {
|
||||||
|
let speechStatus = SFSpeechRecognizer.authorizationStatus()
|
||||||
|
if speechStatus == .notDetermined {
|
||||||
|
let granted = await withCheckedContinuation { continuation in
|
||||||
|
SFSpeechRecognizer.requestAuthorization { status in
|
||||||
|
continuation.resume(returning: status == .authorized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard granted else { return false }
|
||||||
|
} else if speechStatus != .authorized {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let micStatus = AVCaptureDevice.authorizationStatus(for: .audio)
|
||||||
|
switch micStatus {
|
||||||
|
case .authorized: return true
|
||||||
|
|
||||||
|
case .notDetermined:
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
|
AVCaptureDevice.requestAccess(for: .audio) { granted in
|
||||||
|
continuation.resume(returning: granted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static var hasPrivacyStrings: Bool {
|
||||||
|
let speech = Bundle.main.object(forInfoDictionaryKey: "NSSpeechRecognitionUsageDescription") as? String
|
||||||
|
let mic = Bundle.main.object(forInfoDictionaryKey: "NSMicrophoneUsageDescription") as? String
|
||||||
|
return speech?.isEmpty == false && mic?.isEmpty == false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension VoiceWakeTester: @unchecked Sendable {}
|
||||||
374
apps/macos/Sources/Moltbot/WebChatSwiftUI.swift
Normal file
374
apps/macos/Sources/Moltbot/WebChatSwiftUI.swift
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
import AppKit
|
||||||
|
import MoltbotChatUI
|
||||||
|
import MoltbotKit
|
||||||
|
import MoltbotProtocol
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
import QuartzCore
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
private let webChatSwiftLogger = Logger(subsystem: "bot.molt", category: "WebChatSwiftUI")
|
||||||
|
|
||||||
|
private enum WebChatSwiftUILayout {
|
||||||
|
static let windowSize = NSSize(width: 500, height: 840)
|
||||||
|
static let panelSize = NSSize(width: 480, height: 640)
|
||||||
|
static let windowMinSize = NSSize(width: 480, height: 360)
|
||||||
|
static let anchorPadding: CGFloat = 8
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MacGatewayChatTransport: MoltbotChatTransport, Sendable {
|
||||||
|
func requestHistory(sessionKey: String) async throws -> MoltbotChatHistoryPayload {
|
||||||
|
try await GatewayConnection.shared.chatHistory(sessionKey: sessionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func abortRun(sessionKey: String, runId: String) async throws {
|
||||||
|
_ = try await GatewayConnection.shared.request(
|
||||||
|
method: "chat.abort",
|
||||||
|
params: [
|
||||||
|
"sessionKey": AnyCodable(sessionKey),
|
||||||
|
"runId": AnyCodable(runId),
|
||||||
|
],
|
||||||
|
timeoutMs: 10000)
|
||||||
|
}
|
||||||
|
|
||||||
|
func listSessions(limit: Int?) async throws -> MoltbotChatSessionsListResponse {
|
||||||
|
var params: [String: AnyCodable] = [
|
||||||
|
"includeGlobal": AnyCodable(true),
|
||||||
|
"includeUnknown": AnyCodable(false),
|
||||||
|
]
|
||||||
|
if let limit {
|
||||||
|
params["limit"] = AnyCodable(limit)
|
||||||
|
}
|
||||||
|
let data = try await GatewayConnection.shared.request(
|
||||||
|
method: "sessions.list",
|
||||||
|
params: params,
|
||||||
|
timeoutMs: 15000)
|
||||||
|
return try JSONDecoder().decode(MoltbotChatSessionsListResponse.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendMessage(
|
||||||
|
sessionKey: String,
|
||||||
|
message: String,
|
||||||
|
thinking: String,
|
||||||
|
idempotencyKey: String,
|
||||||
|
attachments: [MoltbotChatAttachmentPayload]) async throws -> MoltbotChatSendResponse
|
||||||
|
{
|
||||||
|
try await GatewayConnection.shared.chatSend(
|
||||||
|
sessionKey: sessionKey,
|
||||||
|
message: message,
|
||||||
|
thinking: thinking,
|
||||||
|
idempotencyKey: idempotencyKey,
|
||||||
|
attachments: attachments)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestHealth(timeoutMs: Int) async throws -> Bool {
|
||||||
|
try await GatewayConnection.shared.healthOK(timeoutMs: timeoutMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func events() -> AsyncStream<MoltbotChatTransportEvent> {
|
||||||
|
AsyncStream { continuation in
|
||||||
|
let task = Task {
|
||||||
|
do {
|
||||||
|
try await GatewayConnection.shared.refresh()
|
||||||
|
} catch {
|
||||||
|
webChatSwiftLogger.error("gateway refresh failed \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let stream = await GatewayConnection.shared.subscribe()
|
||||||
|
for await push in stream {
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
if let evt = Self.mapPushToTransportEvent(push) {
|
||||||
|
continuation.yield(evt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
continuation.onTermination = { @Sendable _ in
|
||||||
|
task.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func mapPushToTransportEvent(_ push: GatewayPush) -> MoltbotChatTransportEvent? {
|
||||||
|
switch push {
|
||||||
|
case let .snapshot(hello):
|
||||||
|
let ok = (try? JSONDecoder().decode(
|
||||||
|
MoltbotGatewayHealthOK.self,
|
||||||
|
from: JSONEncoder().encode(hello.snapshot.health)))?.ok ?? true
|
||||||
|
return .health(ok: ok)
|
||||||
|
|
||||||
|
case let .event(evt):
|
||||||
|
switch evt.event {
|
||||||
|
case "health":
|
||||||
|
guard let payload = evt.payload else { return nil }
|
||||||
|
let ok = (try? JSONDecoder().decode(
|
||||||
|
MoltbotGatewayHealthOK.self,
|
||||||
|
from: JSONEncoder().encode(payload)))?.ok ?? true
|
||||||
|
return .health(ok: ok)
|
||||||
|
case "tick":
|
||||||
|
return .tick
|
||||||
|
case "chat":
|
||||||
|
guard let payload = evt.payload else { return nil }
|
||||||
|
guard let chat = try? JSONDecoder().decode(
|
||||||
|
MoltbotChatEventPayload.self,
|
||||||
|
from: JSONEncoder().encode(payload))
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return .chat(chat)
|
||||||
|
case "agent":
|
||||||
|
guard let payload = evt.payload else { return nil }
|
||||||
|
guard let agent = try? JSONDecoder().decode(
|
||||||
|
MoltbotAgentEventPayload.self,
|
||||||
|
from: JSONEncoder().encode(payload))
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return .agent(agent)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
case .seqGap:
|
||||||
|
return .seqGap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Window controller
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class WebChatSwiftUIWindowController {
|
||||||
|
private let presentation: WebChatPresentation
|
||||||
|
private let sessionKey: String
|
||||||
|
private let hosting: NSHostingController<MoltbotChatView>
|
||||||
|
private let contentController: NSViewController
|
||||||
|
private var window: NSWindow?
|
||||||
|
private var dismissMonitor: Any?
|
||||||
|
var onClosed: (() -> Void)?
|
||||||
|
var onVisibilityChanged: ((Bool) -> Void)?
|
||||||
|
|
||||||
|
convenience init(sessionKey: String, presentation: WebChatPresentation) {
|
||||||
|
self.init(sessionKey: sessionKey, presentation: presentation, transport: MacGatewayChatTransport())
|
||||||
|
}
|
||||||
|
|
||||||
|
init(sessionKey: String, presentation: WebChatPresentation, transport: any MoltbotChatTransport) {
|
||||||
|
self.sessionKey = sessionKey
|
||||||
|
self.presentation = presentation
|
||||||
|
let vm = MoltbotChatViewModel(sessionKey: sessionKey, transport: transport)
|
||||||
|
let accent = Self.color(fromHex: AppStateStore.shared.seamColorHex)
|
||||||
|
self.hosting = NSHostingController(rootView: MoltbotChatView(
|
||||||
|
viewModel: vm,
|
||||||
|
showsSessionSwitcher: true,
|
||||||
|
userAccent: accent))
|
||||||
|
self.contentController = Self.makeContentController(for: presentation, hosting: self.hosting)
|
||||||
|
self.window = Self.makeWindow(for: presentation, contentViewController: self.contentController)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {}
|
||||||
|
|
||||||
|
var isVisible: Bool {
|
||||||
|
self.window?.isVisible ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
func show() {
|
||||||
|
guard let window else { return }
|
||||||
|
self.ensureWindowSize()
|
||||||
|
window.makeKeyAndOrderFront(nil)
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
self.onVisibilityChanged?(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func presentAnchored(anchorProvider: () -> NSRect?) {
|
||||||
|
guard case .panel = self.presentation, let window else { return }
|
||||||
|
self.installDismissMonitor()
|
||||||
|
let target = self.reposition(using: anchorProvider)
|
||||||
|
|
||||||
|
if !self.isVisible {
|
||||||
|
let start = target.offsetBy(dx: 0, dy: 8)
|
||||||
|
window.setFrame(start, display: true)
|
||||||
|
window.alphaValue = 0
|
||||||
|
window.makeKeyAndOrderFront(nil)
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
NSAnimationContext.runAnimationGroup { context in
|
||||||
|
context.duration = 0.18
|
||||||
|
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||||
|
window.animator().setFrame(target, display: true)
|
||||||
|
window.animator().alphaValue = 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
window.makeKeyAndOrderFront(nil)
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.onVisibilityChanged?(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func close() {
|
||||||
|
self.window?.orderOut(nil)
|
||||||
|
self.onVisibilityChanged?(false)
|
||||||
|
self.onClosed?()
|
||||||
|
self.removeDismissMonitor()
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
private func reposition(using anchorProvider: () -> NSRect?) -> NSRect {
|
||||||
|
guard let window else { return .zero }
|
||||||
|
guard let anchor = anchorProvider() else {
|
||||||
|
let frame = WindowPlacement.topRightFrame(
|
||||||
|
size: WebChatSwiftUILayout.panelSize,
|
||||||
|
padding: WebChatSwiftUILayout.anchorPadding)
|
||||||
|
window.setFrame(frame, display: false)
|
||||||
|
return frame
|
||||||
|
}
|
||||||
|
let screen = NSScreen.screens.first { screen in
|
||||||
|
screen.frame.contains(anchor.origin) || screen.frame.contains(NSPoint(x: anchor.midX, y: anchor.midY))
|
||||||
|
} ?? NSScreen.main
|
||||||
|
let bounds = (screen?.visibleFrame ?? .zero).insetBy(
|
||||||
|
dx: WebChatSwiftUILayout.anchorPadding,
|
||||||
|
dy: WebChatSwiftUILayout.anchorPadding)
|
||||||
|
let frame = WindowPlacement.anchoredBelowFrame(
|
||||||
|
size: WebChatSwiftUILayout.panelSize,
|
||||||
|
anchor: anchor,
|
||||||
|
padding: WebChatSwiftUILayout.anchorPadding,
|
||||||
|
in: bounds)
|
||||||
|
window.setFrame(frame, display: false)
|
||||||
|
return frame
|
||||||
|
}
|
||||||
|
|
||||||
|
private func installDismissMonitor() {
|
||||||
|
if ProcessInfo.processInfo.isRunningTests { return }
|
||||||
|
guard self.dismissMonitor == nil, self.window != nil else { return }
|
||||||
|
self.dismissMonitor = NSEvent.addGlobalMonitorForEvents(
|
||||||
|
matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown])
|
||||||
|
{ [weak self] _ in
|
||||||
|
guard let self, let win = self.window else { return }
|
||||||
|
let pt = NSEvent.mouseLocation
|
||||||
|
if !win.frame.contains(pt) {
|
||||||
|
self.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeDismissMonitor() {
|
||||||
|
if let monitor = self.dismissMonitor {
|
||||||
|
NSEvent.removeMonitor(monitor)
|
||||||
|
self.dismissMonitor = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeWindow(
|
||||||
|
for presentation: WebChatPresentation,
|
||||||
|
contentViewController: NSViewController) -> NSWindow
|
||||||
|
{
|
||||||
|
switch presentation {
|
||||||
|
case .window:
|
||||||
|
let window = NSWindow(
|
||||||
|
contentRect: NSRect(origin: .zero, size: WebChatSwiftUILayout.windowSize),
|
||||||
|
styleMask: [.titled, .closable, .resizable, .miniaturizable],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false)
|
||||||
|
window.title = "Moltbot Chat"
|
||||||
|
window.contentViewController = contentViewController
|
||||||
|
window.isReleasedWhenClosed = false
|
||||||
|
window.titleVisibility = .visible
|
||||||
|
window.titlebarAppearsTransparent = false
|
||||||
|
window.backgroundColor = .clear
|
||||||
|
window.isOpaque = false
|
||||||
|
window.center()
|
||||||
|
WindowPlacement.ensureOnScreen(window: window, defaultSize: WebChatSwiftUILayout.windowSize)
|
||||||
|
window.minSize = WebChatSwiftUILayout.windowMinSize
|
||||||
|
window.contentView?.wantsLayer = true
|
||||||
|
window.contentView?.layer?.backgroundColor = NSColor.clear.cgColor
|
||||||
|
return window
|
||||||
|
case .panel:
|
||||||
|
let panel = WebChatPanel(
|
||||||
|
contentRect: NSRect(origin: .zero, size: WebChatSwiftUILayout.panelSize),
|
||||||
|
styleMask: [.borderless],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false)
|
||||||
|
panel.level = .statusBar
|
||||||
|
panel.hidesOnDeactivate = true
|
||||||
|
panel.hasShadow = true
|
||||||
|
panel.isMovable = false
|
||||||
|
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||||||
|
panel.titleVisibility = .hidden
|
||||||
|
panel.titlebarAppearsTransparent = true
|
||||||
|
panel.backgroundColor = .clear
|
||||||
|
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,
|
||||||
|
padding: WebChatSwiftUILayout.anchorPadding),
|
||||||
|
display: false)
|
||||||
|
return panel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeContentController(
|
||||||
|
for presentation: WebChatPresentation,
|
||||||
|
hosting: NSHostingController<MoltbotChatView>) -> NSViewController
|
||||||
|
{
|
||||||
|
let controller = NSViewController()
|
||||||
|
let effectView = NSVisualEffectView()
|
||||||
|
effectView.material = .sidebar
|
||||||
|
effectView.blendingMode = .behindWindow
|
||||||
|
effectView.state = .active
|
||||||
|
effectView.wantsLayer = true
|
||||||
|
effectView.layer?.cornerCurve = .continuous
|
||||||
|
let cornerRadius: CGFloat = switch presentation {
|
||||||
|
case .panel:
|
||||||
|
16
|
||||||
|
case .window:
|
||||||
|
0
|
||||||
|
}
|
||||||
|
effectView.layer?.cornerRadius = cornerRadius
|
||||||
|
effectView.layer?.masksToBounds = true
|
||||||
|
|
||||||
|
effectView.translatesAutoresizingMaskIntoConstraints = true
|
||||||
|
effectView.autoresizingMask = [.width, .height]
|
||||||
|
let rootView = effectView
|
||||||
|
|
||||||
|
hosting.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
hosting.view.wantsLayer = true
|
||||||
|
hosting.view.layer?.backgroundColor = NSColor.clear.cgColor
|
||||||
|
|
||||||
|
controller.addChild(hosting)
|
||||||
|
effectView.addSubview(hosting.view)
|
||||||
|
controller.view = rootView
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensureWindowSize() {
|
||||||
|
guard case .window = self.presentation, let window else { return }
|
||||||
|
let current = window.frame.size
|
||||||
|
let min = WebChatSwiftUILayout.windowMinSize
|
||||||
|
if current.width < min.width || current.height < min.height {
|
||||||
|
let frame = WindowPlacement.centeredFrame(size: WebChatSwiftUILayout.windowSize)
|
||||||
|
window.setFrame(frame, display: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func color(fromHex raw: String?) -> Color? {
|
||||||
|
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed
|
||||||
|
guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil }
|
||||||
|
let r = Double((value >> 16) & 0xFF) / 255.0
|
||||||
|
let g = Double((value >> 8) & 0xFF) / 255.0
|
||||||
|
let b = Double(value & 0xFF) / 255.0
|
||||||
|
return Color(red: r, green: g, blue: b)
|
||||||
|
}
|
||||||
|
}
|
||||||
683
apps/macos/Sources/MoltbotDiscovery/GatewayDiscoveryModel.swift
Normal file
683
apps/macos/Sources/MoltbotDiscovery/GatewayDiscoveryModel.swift
Normal file
@@ -0,0 +1,683 @@
|
|||||||
|
import MoltbotKit
|
||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
import Observation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
public final class GatewayDiscoveryModel {
|
||||||
|
public struct LocalIdentity: Equatable, Sendable {
|
||||||
|
public var hostTokens: Set<String>
|
||||||
|
public var displayTokens: Set<String>
|
||||||
|
|
||||||
|
public init(hostTokens: Set<String>, displayTokens: Set<String>) {
|
||||||
|
self.hostTokens = hostTokens
|
||||||
|
self.displayTokens = displayTokens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct DiscoveredGateway: Identifiable, Equatable, Sendable {
|
||||||
|
public var id: String { self.stableID }
|
||||||
|
public var displayName: String
|
||||||
|
public var lanHost: String?
|
||||||
|
public var tailnetDns: String?
|
||||||
|
public var sshPort: Int
|
||||||
|
public var gatewayPort: Int?
|
||||||
|
public var cliPath: String?
|
||||||
|
public var stableID: String
|
||||||
|
public var debugID: String
|
||||||
|
public var isLocal: Bool
|
||||||
|
|
||||||
|
public init(
|
||||||
|
displayName: String,
|
||||||
|
lanHost: String? = nil,
|
||||||
|
tailnetDns: String? = nil,
|
||||||
|
sshPort: Int,
|
||||||
|
gatewayPort: Int? = nil,
|
||||||
|
cliPath: String? = nil,
|
||||||
|
stableID: String,
|
||||||
|
debugID: String,
|
||||||
|
isLocal: Bool)
|
||||||
|
{
|
||||||
|
self.displayName = displayName
|
||||||
|
self.lanHost = lanHost
|
||||||
|
self.tailnetDns = tailnetDns
|
||||||
|
self.sshPort = sshPort
|
||||||
|
self.gatewayPort = gatewayPort
|
||||||
|
self.cliPath = cliPath
|
||||||
|
self.stableID = stableID
|
||||||
|
self.debugID = debugID
|
||||||
|
self.isLocal = isLocal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var gateways: [DiscoveredGateway] = []
|
||||||
|
public var statusText: String = "Idle"
|
||||||
|
|
||||||
|
private var browsers: [String: NWBrowser] = [:]
|
||||||
|
private var resultsByDomain: [String: Set<NWBrowser.Result>] = [:]
|
||||||
|
private var gatewaysByDomain: [String: [DiscoveredGateway]] = [:]
|
||||||
|
private var statesByDomain: [String: NWBrowser.State] = [:]
|
||||||
|
private var localIdentity: LocalIdentity
|
||||||
|
private let localDisplayName: String?
|
||||||
|
private let filterLocalGateways: Bool
|
||||||
|
private var resolvedTXTByID: [String: [String: String]] = [:]
|
||||||
|
private var pendingTXTResolvers: [String: GatewayTXTResolver] = [:]
|
||||||
|
private var wideAreaFallbackTask: Task<Void, Never>?
|
||||||
|
private var wideAreaFallbackGateways: [DiscoveredGateway] = []
|
||||||
|
private let logger = Logger(subsystem: "bot.molt", category: "gateway-discovery")
|
||||||
|
|
||||||
|
public init(
|
||||||
|
localDisplayName: String? = nil,
|
||||||
|
filterLocalGateways: Bool = true)
|
||||||
|
{
|
||||||
|
self.localDisplayName = localDisplayName
|
||||||
|
self.filterLocalGateways = filterLocalGateways
|
||||||
|
self.localIdentity = Self.buildLocalIdentityFast(displayName: localDisplayName)
|
||||||
|
self.refreshLocalIdentity()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func start() {
|
||||||
|
if !self.browsers.isEmpty { return }
|
||||||
|
|
||||||
|
for domain in MoltbotBonjour.gatewayServiceDomains {
|
||||||
|
let params = NWParameters.tcp
|
||||||
|
params.includePeerToPeer = true
|
||||||
|
let browser = NWBrowser(
|
||||||
|
for: .bonjour(type: MoltbotBonjour.gatewayServiceType, domain: domain),
|
||||||
|
using: params)
|
||||||
|
|
||||||
|
browser.stateUpdateHandler = { [weak self] state in
|
||||||
|
Task { @MainActor in
|
||||||
|
guard let self else { return }
|
||||||
|
self.statesByDomain[domain] = state
|
||||||
|
self.updateStatusText()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
browser.browseResultsChangedHandler = { [weak self] results, _ in
|
||||||
|
Task { @MainActor in
|
||||||
|
guard let self else { return }
|
||||||
|
self.resultsByDomain[domain] = results
|
||||||
|
self.updateGateways(for: domain)
|
||||||
|
self.recomputeGateways()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.browsers[domain] = browser
|
||||||
|
browser.start(queue: DispatchQueue(label: "bot.molt.macos.gateway-discovery.\(domain)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
self.scheduleWideAreaFallback()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func refreshWideAreaFallbackNow(timeoutSeconds: TimeInterval = 5.0) {
|
||||||
|
let domain = MoltbotBonjour.wideAreaGatewayServiceDomain
|
||||||
|
Task.detached(priority: .utility) { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
let beacons = WideAreaGatewayDiscovery.discover(timeoutSeconds: timeoutSeconds)
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.wideAreaFallbackGateways = self.mapWideAreaBeacons(beacons, domain: domain)
|
||||||
|
self.recomputeGateways()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func stop() {
|
||||||
|
for browser in self.browsers.values {
|
||||||
|
browser.cancel()
|
||||||
|
}
|
||||||
|
self.browsers = [:]
|
||||||
|
self.resultsByDomain = [:]
|
||||||
|
self.gatewaysByDomain = [:]
|
||||||
|
self.statesByDomain = [:]
|
||||||
|
self.resolvedTXTByID = [:]
|
||||||
|
self.pendingTXTResolvers.values.forEach { $0.cancel() }
|
||||||
|
self.pendingTXTResolvers = [:]
|
||||||
|
self.wideAreaFallbackTask?.cancel()
|
||||||
|
self.wideAreaFallbackTask = nil
|
||||||
|
self.wideAreaFallbackGateways = []
|
||||||
|
self.gateways = []
|
||||||
|
self.statusText = "Stopped"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func mapWideAreaBeacons(_ beacons: [WideAreaGatewayBeacon], domain: String) -> [DiscoveredGateway] {
|
||||||
|
beacons.map { beacon in
|
||||||
|
let stableID = "wide-area|\(domain)|\(beacon.instanceName)"
|
||||||
|
let isLocal = Self.isLocalGateway(
|
||||||
|
lanHost: beacon.lanHost,
|
||||||
|
tailnetDns: beacon.tailnetDns,
|
||||||
|
displayName: beacon.displayName,
|
||||||
|
serviceName: beacon.instanceName,
|
||||||
|
local: self.localIdentity)
|
||||||
|
return DiscoveredGateway(
|
||||||
|
displayName: beacon.displayName,
|
||||||
|
lanHost: beacon.lanHost,
|
||||||
|
tailnetDns: beacon.tailnetDns,
|
||||||
|
sshPort: beacon.sshPort ?? 22,
|
||||||
|
gatewayPort: beacon.gatewayPort,
|
||||||
|
cliPath: beacon.cliPath,
|
||||||
|
stableID: stableID,
|
||||||
|
debugID: "\(beacon.instanceName)@\(beacon.host):\(beacon.port)",
|
||||||
|
isLocal: isLocal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func recomputeGateways() {
|
||||||
|
let primary = self.sortedDeduped(gateways: self.gatewaysByDomain.values.flatMap(\.self))
|
||||||
|
let primaryFiltered = self.filterLocalGateways ? primary.filter { !$0.isLocal } : primary
|
||||||
|
if !primaryFiltered.isEmpty {
|
||||||
|
self.gateways = primaryFiltered
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bonjour can return only "local" results for the wide-area domain (or no results at all),
|
||||||
|
// which makes onboarding look empty even though Tailscale DNS-SD can already see gateways.
|
||||||
|
guard !self.wideAreaFallbackGateways.isEmpty else {
|
||||||
|
self.gateways = primaryFiltered
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let combined = self.sortedDeduped(gateways: primary + self.wideAreaFallbackGateways)
|
||||||
|
self.gateways = self.filterLocalGateways ? combined.filter { !$0.isLocal } : combined
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateGateways(for domain: String) {
|
||||||
|
guard let results = self.resultsByDomain[domain] else {
|
||||||
|
self.gatewaysByDomain[domain] = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.gatewaysByDomain[domain] = results.compactMap { result -> DiscoveredGateway? in
|
||||||
|
guard case let .service(name, type, resultDomain, _) = result.endpoint else { return nil }
|
||||||
|
|
||||||
|
let decodedName = BonjourEscapes.decode(name)
|
||||||
|
let stableID = GatewayEndpointID.stableID(result.endpoint)
|
||||||
|
let resolvedTXT = self.resolvedTXTByID[stableID] ?? [:]
|
||||||
|
let txt = Self.txtDictionary(from: result).merging(
|
||||||
|
resolvedTXT,
|
||||||
|
uniquingKeysWith: { _, new in new })
|
||||||
|
|
||||||
|
let advertisedName = txt["displayName"]
|
||||||
|
.map(Self.prettifyInstanceName)
|
||||||
|
.flatMap { $0.isEmpty ? nil : $0 }
|
||||||
|
let prettyName =
|
||||||
|
advertisedName ?? Self.prettifyServiceName(decodedName)
|
||||||
|
|
||||||
|
let parsedTXT = Self.parseGatewayTXT(txt)
|
||||||
|
|
||||||
|
if parsedTXT.lanHost == nil || parsedTXT.tailnetDns == nil {
|
||||||
|
self.ensureTXTResolution(
|
||||||
|
stableID: stableID,
|
||||||
|
serviceName: name,
|
||||||
|
type: type,
|
||||||
|
domain: resultDomain)
|
||||||
|
}
|
||||||
|
|
||||||
|
let isLocal = Self.isLocalGateway(
|
||||||
|
lanHost: parsedTXT.lanHost,
|
||||||
|
tailnetDns: parsedTXT.tailnetDns,
|
||||||
|
displayName: prettyName,
|
||||||
|
serviceName: decodedName,
|
||||||
|
local: self.localIdentity)
|
||||||
|
return DiscoveredGateway(
|
||||||
|
displayName: prettyName,
|
||||||
|
lanHost: parsedTXT.lanHost,
|
||||||
|
tailnetDns: parsedTXT.tailnetDns,
|
||||||
|
sshPort: parsedTXT.sshPort,
|
||||||
|
gatewayPort: parsedTXT.gatewayPort,
|
||||||
|
cliPath: parsedTXT.cliPath,
|
||||||
|
stableID: stableID,
|
||||||
|
debugID: GatewayEndpointID.prettyDescription(result.endpoint),
|
||||||
|
isLocal: isLocal)
|
||||||
|
}
|
||||||
|
.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
|
||||||
|
|
||||||
|
if domain == MoltbotBonjour.wideAreaGatewayServiceDomain,
|
||||||
|
self.hasUsableWideAreaResults
|
||||||
|
{
|
||||||
|
self.wideAreaFallbackGateways = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleWideAreaFallback() {
|
||||||
|
let domain = MoltbotBonjour.wideAreaGatewayServiceDomain
|
||||||
|
if Self.isRunningTests { return }
|
||||||
|
guard self.wideAreaFallbackTask == nil else { return }
|
||||||
|
self.wideAreaFallbackTask = Task.detached(priority: .utility) { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
var attempt = 0
|
||||||
|
let startedAt = Date()
|
||||||
|
while !Task.isCancelled, Date().timeIntervalSince(startedAt) < 35.0 {
|
||||||
|
let hasResults = await MainActor.run {
|
||||||
|
self.hasUsableWideAreaResults
|
||||||
|
}
|
||||||
|
if hasResults { return }
|
||||||
|
|
||||||
|
// Wide-area discovery can be racy (Tailscale not yet up, DNS zone not
|
||||||
|
// published yet). Retry with a short backoff while onboarding is open.
|
||||||
|
let beacons = WideAreaGatewayDiscovery.discover(timeoutSeconds: 2.0)
|
||||||
|
if !beacons.isEmpty {
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.wideAreaFallbackGateways = self.mapWideAreaBeacons(beacons, domain: domain)
|
||||||
|
self.recomputeGateways()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt += 1
|
||||||
|
let backoff = min(8.0, 0.6 + (Double(attempt) * 0.7))
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(backoff * 1_000_000_000))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hasUsableWideAreaResults: Bool {
|
||||||
|
let domain = MoltbotBonjour.wideAreaGatewayServiceDomain
|
||||||
|
guard let gateways = self.gatewaysByDomain[domain], !gateways.isEmpty else { return false }
|
||||||
|
if !self.filterLocalGateways { return true }
|
||||||
|
return gateways.contains(where: { !$0.isLocal })
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sortedDeduped(gateways: [DiscoveredGateway]) -> [DiscoveredGateway] {
|
||||||
|
var seen = Set<String>()
|
||||||
|
let deduped = gateways.filter { gateway in
|
||||||
|
if seen.contains(gateway.stableID) { return false }
|
||||||
|
seen.insert(gateway.stableID)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return deduped.sorted {
|
||||||
|
$0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static var isRunningTests: Bool {
|
||||||
|
// Keep discovery background work from running forever during SwiftPM test runs.
|
||||||
|
if Bundle.allBundles.contains(where: { $0.bundleURL.pathExtension == "xctest" }) { return true }
|
||||||
|
|
||||||
|
let env = ProcessInfo.processInfo.environment
|
||||||
|
return env["XCTestConfigurationFilePath"] != nil
|
||||||
|
|| env["XCTestBundlePath"] != nil
|
||||||
|
|| env["XCTestSessionIdentifier"] != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateGatewaysForAllDomains() {
|
||||||
|
for domain in self.resultsByDomain.keys {
|
||||||
|
self.updateGateways(for: domain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateStatusText() {
|
||||||
|
let states = Array(self.statesByDomain.values)
|
||||||
|
if states.isEmpty {
|
||||||
|
self.statusText = self.browsers.isEmpty ? "Idle" : "Setup"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let failed = states.first(where: { state in
|
||||||
|
if case .failed = state { return true }
|
||||||
|
return false
|
||||||
|
}) {
|
||||||
|
if case let .failed(err) = failed {
|
||||||
|
self.statusText = "Failed: \(err)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let waiting = states.first(where: { state in
|
||||||
|
if case .waiting = state { return true }
|
||||||
|
return false
|
||||||
|
}) {
|
||||||
|
if case let .waiting(err) = waiting {
|
||||||
|
self.statusText = "Waiting: \(err)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if states.contains(where: { if case .ready = $0 { true } else { false } }) {
|
||||||
|
self.statusText = "Searching…"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if states.contains(where: { if case .setup = $0 { true } else { false } }) {
|
||||||
|
self.statusText = "Setup"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.statusText = "Searching…"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func txtDictionary(from result: NWBrowser.Result) -> [String: String] {
|
||||||
|
var merged: [String: String] = [:]
|
||||||
|
|
||||||
|
if case let .bonjour(txt) = result.metadata {
|
||||||
|
merged.merge(txt.dictionary, uniquingKeysWith: { _, new in new })
|
||||||
|
}
|
||||||
|
|
||||||
|
if let endpointTxt = result.endpoint.txtRecord?.dictionary {
|
||||||
|
merged.merge(endpointTxt, uniquingKeysWith: { _, new in new })
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct GatewayTXT: Equatable {
|
||||||
|
public var lanHost: String?
|
||||||
|
public var tailnetDns: String?
|
||||||
|
public var sshPort: Int
|
||||||
|
public var gatewayPort: Int?
|
||||||
|
public var cliPath: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func parseGatewayTXT(_ txt: [String: String]) -> GatewayTXT {
|
||||||
|
var lanHost: String?
|
||||||
|
var tailnetDns: String?
|
||||||
|
var sshPort = 22
|
||||||
|
var gatewayPort: Int?
|
||||||
|
var cliPath: String?
|
||||||
|
|
||||||
|
if let value = txt["lanHost"] {
|
||||||
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
lanHost = trimmed.isEmpty ? nil : trimmed
|
||||||
|
}
|
||||||
|
if let value = txt["tailnetDns"] {
|
||||||
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
tailnetDns = trimmed.isEmpty ? nil : trimmed
|
||||||
|
}
|
||||||
|
if let value = txt["sshPort"],
|
||||||
|
let parsed = Int(value.trimmingCharacters(in: .whitespacesAndNewlines)),
|
||||||
|
parsed > 0
|
||||||
|
{
|
||||||
|
sshPort = parsed
|
||||||
|
}
|
||||||
|
if let value = txt["gatewayPort"],
|
||||||
|
let parsed = Int(value.trimmingCharacters(in: .whitespacesAndNewlines)),
|
||||||
|
parsed > 0
|
||||||
|
{
|
||||||
|
gatewayPort = parsed
|
||||||
|
}
|
||||||
|
if let value = txt["cliPath"] {
|
||||||
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
cliPath = trimmed.isEmpty ? nil : trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
return GatewayTXT(
|
||||||
|
lanHost: lanHost,
|
||||||
|
tailnetDns: tailnetDns,
|
||||||
|
sshPort: sshPort,
|
||||||
|
gatewayPort: gatewayPort,
|
||||||
|
cliPath: cliPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func buildSSHTarget(user: String, host: String, port: Int) -> String {
|
||||||
|
var target = "\(user)@\(host)"
|
||||||
|
if port != 22 {
|
||||||
|
target += ":\(port)"
|
||||||
|
}
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensureTXTResolution(
|
||||||
|
stableID: String,
|
||||||
|
serviceName: String,
|
||||||
|
type: String,
|
||||||
|
domain: String)
|
||||||
|
{
|
||||||
|
guard self.resolvedTXTByID[stableID] == nil else { return }
|
||||||
|
guard self.pendingTXTResolvers[stableID] == nil else { return }
|
||||||
|
|
||||||
|
let resolver = GatewayTXTResolver(
|
||||||
|
name: serviceName,
|
||||||
|
type: type,
|
||||||
|
domain: domain,
|
||||||
|
logger: self.logger)
|
||||||
|
{ [weak self] result in
|
||||||
|
Task { @MainActor in
|
||||||
|
guard let self else { return }
|
||||||
|
self.pendingTXTResolvers[stableID] = nil
|
||||||
|
switch result {
|
||||||
|
case let .success(txt):
|
||||||
|
self.resolvedTXTByID[stableID] = txt
|
||||||
|
self.updateGatewaysForAllDomains()
|
||||||
|
self.recomputeGateways()
|
||||||
|
case .failure:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.pendingTXTResolvers[stableID] = resolver
|
||||||
|
resolver.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func prettifyInstanceName(_ decodedName: String) -> String {
|
||||||
|
let normalized = decodedName.split(whereSeparator: \.isWhitespace).joined(separator: " ")
|
||||||
|
let stripped = normalized.replacingOccurrences(of: " (Moltbot)", with: "")
|
||||||
|
.replacingOccurrences(of: #"\s+\(\d+\)$"#, with: "", options: .regularExpression)
|
||||||
|
return stripped.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func prettifyServiceName(_ decodedName: String) -> String {
|
||||||
|
let normalized = Self.prettifyInstanceName(decodedName)
|
||||||
|
var cleaned = normalized.replacingOccurrences(of: #"\s*-?gateway$"#, with: "", options: .regularExpression)
|
||||||
|
cleaned = cleaned
|
||||||
|
.replacingOccurrences(of: "_", with: " ")
|
||||||
|
.replacingOccurrences(of: "-", with: " ")
|
||||||
|
.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if cleaned.isEmpty {
|
||||||
|
cleaned = normalized
|
||||||
|
}
|
||||||
|
let words = cleaned.split(separator: " ")
|
||||||
|
let titled = words.map { word -> String in
|
||||||
|
let lower = word.lowercased()
|
||||||
|
guard let first = lower.first else { return "" }
|
||||||
|
return String(first).uppercased() + lower.dropFirst()
|
||||||
|
}.joined(separator: " ")
|
||||||
|
return titled.isEmpty ? normalized : titled
|
||||||
|
}
|
||||||
|
|
||||||
|
public nonisolated static func isLocalGateway(
|
||||||
|
lanHost: String?,
|
||||||
|
tailnetDns: String?,
|
||||||
|
displayName: String?,
|
||||||
|
serviceName: String?,
|
||||||
|
local: LocalIdentity) -> Bool
|
||||||
|
{
|
||||||
|
if let host = normalizeHostToken(lanHost),
|
||||||
|
local.hostTokens.contains(host)
|
||||||
|
{
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if let host = normalizeHostToken(tailnetDns),
|
||||||
|
local.hostTokens.contains(host)
|
||||||
|
{
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if let name = normalizeDisplayToken(displayName),
|
||||||
|
local.displayTokens.contains(name)
|
||||||
|
{
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if let serviceHost = normalizeServiceHostToken(serviceName),
|
||||||
|
local.hostTokens.contains(serviceHost)
|
||||||
|
{
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshLocalIdentity() {
|
||||||
|
let fastIdentity = self.localIdentity
|
||||||
|
let displayName = self.localDisplayName
|
||||||
|
Task.detached(priority: .utility) {
|
||||||
|
let slowIdentity = Self.buildLocalIdentitySlow(displayName: displayName)
|
||||||
|
let merged = Self.mergeLocalIdentity(fast: fastIdentity, slow: slowIdentity)
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
guard self.localIdentity != merged else { return }
|
||||||
|
self.localIdentity = merged
|
||||||
|
self.recomputeGateways()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func mergeLocalIdentity(
|
||||||
|
fast: LocalIdentity,
|
||||||
|
slow: LocalIdentity) -> LocalIdentity
|
||||||
|
{
|
||||||
|
LocalIdentity(
|
||||||
|
hostTokens: fast.hostTokens.union(slow.hostTokens),
|
||||||
|
displayTokens: fast.displayTokens.union(slow.displayTokens))
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func buildLocalIdentityFast(displayName: String?) -> LocalIdentity {
|
||||||
|
var hostTokens: Set<String> = []
|
||||||
|
var displayTokens: Set<String> = []
|
||||||
|
|
||||||
|
let hostName = ProcessInfo.processInfo.hostName
|
||||||
|
if let token = normalizeHostToken(hostName) {
|
||||||
|
hostTokens.insert(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let token = normalizeDisplayToken(displayName) {
|
||||||
|
displayTokens.insert(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
return LocalIdentity(hostTokens: hostTokens, displayTokens: displayTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func buildLocalIdentitySlow(displayName: String?) -> LocalIdentity {
|
||||||
|
var hostTokens: Set<String> = []
|
||||||
|
var displayTokens: Set<String> = []
|
||||||
|
|
||||||
|
if let host = Host.current().name,
|
||||||
|
let token = normalizeHostToken(host)
|
||||||
|
{
|
||||||
|
hostTokens.insert(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let token = normalizeDisplayToken(displayName) {
|
||||||
|
displayTokens.insert(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let token = normalizeDisplayToken(Host.current().localizedName) {
|
||||||
|
displayTokens.insert(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
return LocalIdentity(hostTokens: hostTokens, displayTokens: displayTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func normalizeHostToken(_ raw: String?) -> String? {
|
||||||
|
guard let raw else { return nil }
|
||||||
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmed.isEmpty { return nil }
|
||||||
|
let lower = trimmed.lowercased()
|
||||||
|
let strippedTrailingDot = lower.hasSuffix(".")
|
||||||
|
? String(lower.dropLast())
|
||||||
|
: lower
|
||||||
|
let withoutLocal = strippedTrailingDot.hasSuffix(".local")
|
||||||
|
? String(strippedTrailingDot.dropLast(6))
|
||||||
|
: strippedTrailingDot
|
||||||
|
let firstLabel = withoutLocal.split(separator: ".").first.map(String.init)
|
||||||
|
let token = (firstLabel ?? withoutLocal).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return token.isEmpty ? nil : token
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func normalizeDisplayToken(_ raw: String?) -> String? {
|
||||||
|
guard let raw else { return nil }
|
||||||
|
let prettified = Self.prettifyInstanceName(raw)
|
||||||
|
let trimmed = prettified.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmed.isEmpty { return nil }
|
||||||
|
return trimmed.lowercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func normalizeServiceHostToken(_ raw: String?) -> String? {
|
||||||
|
guard let raw else { return nil }
|
||||||
|
let prettified = Self.prettifyInstanceName(raw)
|
||||||
|
let strippedGateway = prettified.replacingOccurrences(
|
||||||
|
of: #"\s*-?\s*gateway$"#,
|
||||||
|
with: "",
|
||||||
|
options: .regularExpression)
|
||||||
|
return self.normalizeHostToken(strippedGateway)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class GatewayTXTResolver: NSObject, NetServiceDelegate {
|
||||||
|
private let service: NetService
|
||||||
|
private let completion: (Result<[String: String], Error>) -> Void
|
||||||
|
private let logger: Logger
|
||||||
|
private var didFinish = false
|
||||||
|
|
||||||
|
init(
|
||||||
|
name: String,
|
||||||
|
type: String,
|
||||||
|
domain: String,
|
||||||
|
logger: Logger,
|
||||||
|
completion: @escaping (Result<[String: String], Error>) -> Void)
|
||||||
|
{
|
||||||
|
self.service = NetService(domain: domain, type: type, name: name)
|
||||||
|
self.completion = completion
|
||||||
|
self.logger = logger
|
||||||
|
super.init()
|
||||||
|
self.service.delegate = self
|
||||||
|
}
|
||||||
|
|
||||||
|
func start(timeout: TimeInterval = 2.0) {
|
||||||
|
self.service.schedule(in: .main, forMode: .common)
|
||||||
|
self.service.resolve(withTimeout: timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel() {
|
||||||
|
self.finish(result: .failure(GatewayTXTResolverError.cancelled))
|
||||||
|
}
|
||||||
|
|
||||||
|
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)")
|
||||||
|
}
|
||||||
|
self.finish(result: .success(txt))
|
||||||
|
}
|
||||||
|
|
||||||
|
func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {
|
||||||
|
self.finish(result: .failure(GatewayTXTResolverError.resolveFailed(errorDict)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finish(result: Result<[String: String], Error>) {
|
||||||
|
guard !self.didFinish else { return }
|
||||||
|
self.didFinish = true
|
||||||
|
self.service.stop()
|
||||||
|
self.service.remove(from: .main, forMode: .common)
|
||||||
|
self.completion(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func decodeTXT(_ data: Data?) -> [String: String] {
|
||||||
|
guard let data else { return [:] }
|
||||||
|
let dict = NetService.dictionary(fromTXTRecord: data)
|
||||||
|
var out: [String: String] = [:]
|
||||||
|
out.reserveCapacity(dict.count)
|
||||||
|
for (key, value) in dict {
|
||||||
|
if let str = String(data: value, encoding: .utf8) {
|
||||||
|
out[key] = str
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatTXT(_ txt: [String: String]) -> String {
|
||||||
|
txt.sorted(by: { $0.key < $1.key })
|
||||||
|
.map { "\($0.key)=\($0.value)" }
|
||||||
|
.joined(separator: " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum GatewayTXTResolverError: Error {
|
||||||
|
case cancelled
|
||||||
|
case resolveFailed([String: NSNumber])
|
||||||
|
}
|
||||||
61
apps/shared/MoltbotKit/Package.swift
Normal file
61
apps/shared/MoltbotKit/Package.swift
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// swift-tools-version: 6.2
|
||||||
|
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "MoltbotKit",
|
||||||
|
platforms: [
|
||||||
|
.iOS(.v18),
|
||||||
|
.macOS(.v15),
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
.library(name: "MoltbotProtocol", targets: ["MoltbotProtocol"]),
|
||||||
|
.library(name: "MoltbotKit", targets: ["MoltbotKit"]),
|
||||||
|
.library(name: "MoltbotChatUI", targets: ["MoltbotChatUI"]),
|
||||||
|
],
|
||||||
|
dependencies: [
|
||||||
|
.package(url: "https://github.com/steipete/ElevenLabsKit", exact: "0.1.0"),
|
||||||
|
.package(url: "https://github.com/gonzalezreal/textual", exact: "0.3.1"),
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.target(
|
||||||
|
name: "MoltbotProtocol",
|
||||||
|
path: "Sources/MoltbotProtocol",
|
||||||
|
swiftSettings: [
|
||||||
|
.enableUpcomingFeature("StrictConcurrency"),
|
||||||
|
]),
|
||||||
|
.target(
|
||||||
|
name: "MoltbotKit",
|
||||||
|
path: "Sources/MoltbotKit",
|
||||||
|
dependencies: [
|
||||||
|
"MoltbotProtocol",
|
||||||
|
.product(name: "ElevenLabsKit", package: "ElevenLabsKit"),
|
||||||
|
],
|
||||||
|
resources: [
|
||||||
|
.process("Resources"),
|
||||||
|
],
|
||||||
|
swiftSettings: [
|
||||||
|
.enableUpcomingFeature("StrictConcurrency"),
|
||||||
|
]),
|
||||||
|
.target(
|
||||||
|
name: "MoltbotChatUI",
|
||||||
|
path: "Sources/MoltbotChatUI",
|
||||||
|
dependencies: [
|
||||||
|
"MoltbotKit",
|
||||||
|
.product(
|
||||||
|
name: "Textual",
|
||||||
|
package: "textual",
|
||||||
|
condition: .when(platforms: [.macOS, .iOS])),
|
||||||
|
],
|
||||||
|
swiftSettings: [
|
||||||
|
.enableUpcomingFeature("StrictConcurrency"),
|
||||||
|
]),
|
||||||
|
.testTarget(
|
||||||
|
name: "MoltbotKitTests",
|
||||||
|
dependencies: ["MoltbotKit", "MoltbotChatUI"],
|
||||||
|
path: "Tests/MoltbotKitTests",
|
||||||
|
swiftSettings: [
|
||||||
|
.enableUpcomingFeature("StrictConcurrency"),
|
||||||
|
.enableExperimentalFeature("SwiftTesting"),
|
||||||
|
]),
|
||||||
|
])
|
||||||
@@ -58,7 +58,7 @@ Authoritative list lives in `src/gateway/server.ts` (`METHODS`, `EVENTS`).
|
|||||||
- Server handshake + method dispatch: `src/gateway/server.ts`
|
- Server handshake + method dispatch: `src/gateway/server.ts`
|
||||||
- Node client: `src/gateway/client.ts`
|
- Node client: `src/gateway/client.ts`
|
||||||
- Generated JSON Schema: `dist/protocol.schema.json`
|
- Generated JSON Schema: `dist/protocol.schema.json`
|
||||||
- Generated Swift models: `apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift`
|
- Generated Swift models: `apps/macos/Sources/MoltbotProtocol/GatewayModels.swift`
|
||||||
|
|
||||||
## Current pipeline
|
## Current pipeline
|
||||||
|
|
||||||
|
|||||||
@@ -115,7 +115,7 @@
|
|||||||
"lint:all": "pnpm lint && pnpm lint:swift",
|
"lint:all": "pnpm lint && pnpm lint:swift",
|
||||||
"lint:fix": "pnpm format:fix && oxlint --type-aware --fix src test",
|
"lint:fix": "pnpm format:fix && oxlint --type-aware --fix src test",
|
||||||
"format": "oxfmt --check src test",
|
"format": "oxfmt --check src test",
|
||||||
"format:swift": "swiftformat --lint --config .swiftformat apps/macos/Sources apps/ios/Sources apps/shared/ClawdbotKit/Sources",
|
"format:swift": "swiftformat --lint --config .swiftformat apps/macos/Sources apps/ios/Sources apps/shared/MoltbotKit/Sources",
|
||||||
"format:all": "pnpm format && pnpm format:swift",
|
"format:all": "pnpm format && pnpm format:swift",
|
||||||
"format:fix": "oxfmt --write src test",
|
"format:fix": "oxfmt --write src test",
|
||||||
"test": "node scripts/test-parallel.mjs",
|
"test": "node scripts/test-parallel.mjs",
|
||||||
@@ -141,7 +141,7 @@
|
|||||||
"test:install:e2e:anthropic": "CLAWDBOT_E2E_MODELS=anthropic bash scripts/test-install-sh-e2e-docker.sh",
|
"test:install:e2e:anthropic": "CLAWDBOT_E2E_MODELS=anthropic bash scripts/test-install-sh-e2e-docker.sh",
|
||||||
"protocol:gen": "node --import tsx scripts/protocol-gen.ts",
|
"protocol:gen": "node --import tsx scripts/protocol-gen.ts",
|
||||||
"protocol:gen:swift": "node --import tsx scripts/protocol-gen-swift.ts",
|
"protocol:gen:swift": "node --import tsx scripts/protocol-gen-swift.ts",
|
||||||
"protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift",
|
"protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/MoltbotProtocol/GatewayModels.swift",
|
||||||
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
|
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
|
||||||
"check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500"
|
"check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|||||||
HASH_FILE="$ROOT_DIR/src/canvas-host/a2ui/.bundle.hash"
|
HASH_FILE="$ROOT_DIR/src/canvas-host/a2ui/.bundle.hash"
|
||||||
OUTPUT_FILE="$ROOT_DIR/src/canvas-host/a2ui/a2ui.bundle.js"
|
OUTPUT_FILE="$ROOT_DIR/src/canvas-host/a2ui/a2ui.bundle.js"
|
||||||
A2UI_RENDERER_DIR="$ROOT_DIR/vendor/a2ui/renderers/lit"
|
A2UI_RENDERER_DIR="$ROOT_DIR/vendor/a2ui/renderers/lit"
|
||||||
A2UI_APP_DIR="$ROOT_DIR/apps/shared/ClawdbotKit/Tools/CanvasA2UI"
|
A2UI_APP_DIR="$ROOT_DIR/apps/shared/MoltbotKit/Tools/CanvasA2UI"
|
||||||
|
|
||||||
# Docker builds exclude vendor/apps via .dockerignore.
|
# Docker builds exclude vendor/apps via .dockerignore.
|
||||||
# In that environment we must keep the prebuilt bundle.
|
# In that environment we must keep the prebuilt bundle.
|
||||||
|
|||||||
@@ -24,16 +24,16 @@ const outPaths = [
|
|||||||
"apps",
|
"apps",
|
||||||
"macos",
|
"macos",
|
||||||
"Sources",
|
"Sources",
|
||||||
"ClawdbotProtocol",
|
"MoltbotProtocol",
|
||||||
"GatewayModels.swift",
|
"GatewayModels.swift",
|
||||||
),
|
),
|
||||||
path.join(
|
path.join(
|
||||||
repoRoot,
|
repoRoot,
|
||||||
"apps",
|
"apps",
|
||||||
"shared",
|
"shared",
|
||||||
"ClawdbotKit",
|
"MoltbotKit",
|
||||||
"Sources",
|
"Sources",
|
||||||
"ClawdbotProtocol",
|
"MoltbotProtocol",
|
||||||
"GatewayModels.swift",
|
"GatewayModels.swift",
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user