Mac: finish Moltbot rename (paths)
This commit is contained in:
374 files changed
-18903
No files matched your search
@@ -1,340 +0,0 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
enum AgentWorkspace {
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", 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.
|
||||
}
|
||||
@@ -1,384 +0,0 @@
|
||||
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: "com.clawdbot", 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: "")
|
||||
}
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
import CoreAudio
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
final class AudioInputDeviceObserver {
|
||||
private let logger = Logger(subsystem: "com.clawdbot", 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))")
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
@MainActor
|
||||
final class CLIInstallPrompter {
|
||||
static let shared = CLIInstallPrompter()
|
||||
private let logger = Logger(subsystem: "com.clawdbot", 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
|
||||
}
|
||||
}
|
||||
@@ -1,425 +0,0 @@
|
||||
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: "com.clawdbot", 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)
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
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: "com.clawdbot.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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,342 +0,0 @@
|
||||
import AppKit
|
||||
import MoltbotIPC
|
||||
import MoltbotKit
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
@MainActor
|
||||
final class CanvasManager {
|
||||
static let shared = CanvasManager()
|
||||
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", 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
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
import MoltbotKit
|
||||
import Foundation
|
||||
import OSLog
|
||||
import WebKit
|
||||
|
||||
private let canvasLogger = Logger(subsystem: "com.clawdbot", 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
|
||||
@@ -1,26 +0,0 @@
|
||||
import AppKit
|
||||
|
||||
let canvasWindowLogger = Logger(subsystem: "com.clawdbot", 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
|
||||
}
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
import MoltbotProtocol
|
||||
import Foundation
|
||||
|
||||
enum MoltbotConfigFile {
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", 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
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
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: "com.clawdbot.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
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
@MainActor
|
||||
final class ConnectionModeCoordinator {
|
||||
static let shared = ConnectionModeCoordinator()
|
||||
|
||||
private let logger = Logger(subsystem: "com.clawdbot", 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
let launchdLabel = "com.clawdbot.mac"
|
||||
let gatewayLaunchdLabel = "com.clawdbot.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
|
||||
@@ -1,427 +0,0 @@
|
||||
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: "com.clawdbot", 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")
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
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: "com.clawdbot", 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)
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import AppKit
|
||||
import MoltbotKit
|
||||
import Foundation
|
||||
import OSLog
|
||||
import Security
|
||||
|
||||
private let deepLinkLogger = Logger(subsystem: "com.clawdbot", 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()
|
||||
}
|
||||
}
|
||||
@@ -1,334 +0,0 @@
|
||||
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: "com.clawdbot", 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")
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
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: "com.clawdbot", 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,790 +0,0 @@
|
||||
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: "com.clawdbot", 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
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
import MoltbotKit
|
||||
import MoltbotProtocol
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
@MainActor
|
||||
final class ExecApprovalsGatewayPrompter {
|
||||
static let shared = ExecApprovalsGatewayPrompter()
|
||||
|
||||
private let logger = Logger(subsystem: "com.clawdbot", 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
|
||||
@@ -1,831 +0,0 @@
|
||||
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: "com.clawdbot", 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()
|
||||
}
|
||||
}
|
||||
@@ -1,737 +0,0 @@
|
||||
import MoltbotChatUI
|
||||
import MoltbotKit
|
||||
import MoltbotProtocol
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
private let gatewayConnectionLogger = Logger(subsystem: "com.clawdbot", 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)
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import OSLog
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class GatewayConnectivityCoordinator {
|
||||
static let shared = GatewayConnectivityCoordinator()
|
||||
|
||||
private let logger = Logger(subsystem: "com.clawdbot", 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
|
||||
}
|
||||
}
|
||||
@@ -1,696 +0,0 @@
|
||||
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: "com.clawdbot", 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: "com.clawdbot", 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
|
||||
@@ -1,342 +0,0 @@
|
||||
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: "com.clawdbot", 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)
|
||||
}
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum GatewayLaunchAgentManager {
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", 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
|
||||
}
|
||||
}
|
||||
@@ -1,432 +0,0 @@
|
||||
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: "com.clawdbot", 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
|
||||
@@ -1,301 +0,0 @@
|
||||
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: "com.clawdbot", 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)
|
||||
}
|
||||
@@ -1,394 +0,0 @@
|
||||
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: "com.clawdbot", 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
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum LaunchAgentManager {
|
||||
private static let legacyLaunchdLabel = "com.steipete.clawdbot"
|
||||
private static var plistURL: URL {
|
||||
FileManager().homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/LaunchAgents/com.clawdbot.mac.plist")
|
||||
}
|
||||
|
||||
private static var legacyPlistURL: URL {
|
||||
FileManager().homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/LaunchAgents/\(legacyLaunchdLabel).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 {
|
||||
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(self.legacyLaunchdLabel)"])
|
||||
try? FileManager().removeItem(at: self.legacyPlistURL)
|
||||
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>com.clawdbot.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
|
||||
}
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
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 ("com.clawdbot", 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: ",") + "}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,471 +0,0 @@
|
||||
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: "com.clawdbot", 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: "com.clawdbot", 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
|
||||
@@ -1,97 +0,0 @@
|
||||
import AVFoundation
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
actor MicLevelMonitor {
|
||||
private let logger = Logger(subsystem: "com.clawdbot", 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
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import Foundation
|
||||
import JavaScriptCore
|
||||
|
||||
enum ModelCatalogLoader {
|
||||
static var defaultPath: String { self.resolveDefaultPath() }
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", 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);"
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
import MoltbotKit
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
@MainActor
|
||||
final class MacNodeModeCoordinator {
|
||||
static let shared = MacNodeModeCoordinator()
|
||||
|
||||
private let logger = Logger(subsystem: "com.clawdbot", 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)
|
||||
}
|
||||
}
|
||||
@@ -1,708 +0,0 @@
|
||||
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: "com.clawdbot", 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
|
||||
@@ -1,150 +0,0 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
enum NodeServiceManager {
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", 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
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
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: "com.clawdbot", 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
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import MoltbotIPC
|
||||
import Foundation
|
||||
import Security
|
||||
import UserNotifications
|
||||
|
||||
@MainActor
|
||||
struct NotificationManager {
|
||||
private let logger = Logger(subsystem: "com.clawdbot", 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,412 +0,0 @@
|
||||
import MoltbotKit
|
||||
import MoltbotProtocol
|
||||
import Foundation
|
||||
import Observation
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
private let onboardingWizardLogger = Logger(subsystem: "com.clawdbot", 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 }
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
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: "com.clawdbot", 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: "com.clawdbot.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)
|
||||
}
|
||||
}
|
||||
@@ -1,506 +0,0 @@
|
||||
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: "com.clawdbot", 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,418 +0,0 @@
|
||||
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: "com.clawdbot", 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
|
||||
@@ -1,158 +0,0 @@
|
||||
import Cocoa
|
||||
import Darwin
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
@MainActor
|
||||
final class PresenceReporter {
|
||||
static let shared = PresenceReporter()
|
||||
|
||||
private let logger = Logger(subsystem: "com.clawdbot", 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
|
||||
@@ -1,317 +0,0 @@
|
||||
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: "com.clawdbot", 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: "com.clawdbot.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
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
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: "com.clawdbot", 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.
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
<?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>com.clawdbot.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>com.clawdbot.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>
|
||||
@@ -1,167 +0,0 @@
|
||||
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: "com.clawdbot", 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" }
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
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: "com.clawdbot", 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: "com.clawdbot.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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,495 +0,0 @@
|
||||
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: "com.clawdbot", 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")
|
||||
}
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
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: "com.clawdbot", 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()
|
||||
}
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
@MainActor
|
||||
final class TalkAudioPlayer: NSObject, @preconcurrency AVAudioPlayerDelegate {
|
||||
static let shared = TalkAudioPlayer()
|
||||
|
||||
private let logger = Logger(subsystem: "com.clawdbot", 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?
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class TalkModeController {
|
||||
static let shared = TalkModeController()
|
||||
|
||||
private let logger = Logger(subsystem: "com.clawdbot", 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
|
||||
}
|
||||
@@ -1,953 +0,0 @@
|
||||
import AVFoundation
|
||||
import MoltbotChatUI
|
||||
import MoltbotKit
|
||||
import Foundation
|
||||
import OSLog
|
||||
import Speech
|
||||
|
||||
actor TalkModeRuntime {
|
||||
static let shared = TalkModeRuntime()
|
||||
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "talk.runtime")
|
||||
private let ttsLogger = Logger(subsystem: "com.clawdbot", 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
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
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: "com.clawdbot", 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
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
@MainActor
|
||||
final class TerminationSignalWatcher {
|
||||
static let shared = TerminationSignalWatcher()
|
||||
|
||||
private let logger = Logger(subsystem: "com.clawdbot", 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,421 +0,0 @@
|
||||
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: "com.clawdbot", category: "voicewake.ptt")
|
||||
.info("ptt hotkey down")
|
||||
await self.beginAction()
|
||||
}
|
||||
} else if !chordActive, self.active {
|
||||
self.active = false
|
||||
Task {
|
||||
Logger(subsystem: "com.clawdbot", 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: "com.clawdbot", 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
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
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: "com.clawdbot", 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) }
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
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: "com.clawdbot", 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
enum VoiceWakeForwarder {
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", 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"))
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import MoltbotKit
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
@MainActor
|
||||
final class VoiceWakeGlobalSettingsSync {
|
||||
static let shared = VoiceWakeGlobalSettingsSync()
|
||||
|
||||
private let logger = Logger(subsystem: "com.clawdbot", 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
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: "com.clawdbot", 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
|
||||
}
|
||||
}
|
||||
@@ -1,804 +0,0 @@
|
||||
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: "com.clawdbot", 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
|
||||
}
|
||||
}
|
||||
@@ -1,473 +0,0 @@
|
||||
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: "com.clawdbot", 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 {}
|
||||
@@ -1,374 +0,0 @@
|
||||
import AppKit
|
||||
import MoltbotChatUI
|
||||
import MoltbotKit
|
||||
import MoltbotProtocol
|
||||
import Foundation
|
||||
import OSLog
|
||||
import QuartzCore
|
||||
import SwiftUI
|
||||
|
||||
private let webChatSwiftLogger = Logger(subsystem: "com.clawdbot", 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)
|
||||
}
|
||||
}
|
||||
@@ -1,683 +0,0 @@
|
||||
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: "com.clawdbot", 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: "com.clawdbot.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])
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Loaded 100 of 374 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user