diff --git a/apps/macos/Sources/Clawdbot/AgentWorkspace.swift b/apps/macos/Sources/Clawdbot/AgentWorkspace.swift deleted file mode 100644 index bad27d3b7..000000000 --- a/apps/macos/Sources/Clawdbot/AgentWorkspace.swift +++ /dev/null @@ -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 = [".DS_Store", ".git", ".gitignore"] - private static let templateEntries: Set = [ - 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.. 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) ?? "" - 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) ?? "" - 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() - 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: "") - } -} diff --git a/apps/macos/Sources/Clawdbot/AudioInputDeviceObserver.swift b/apps/macos/Sources/Clawdbot/AudioInputDeviceObserver.swift deleted file mode 100644 index bc296972c..000000000 --- a/apps/macos/Sources/Clawdbot/AudioInputDeviceObserver.swift +++ /dev/null @@ -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.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 { - 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.size - var deviceIDs = [AudioObjectID](repeating: 0, count: count) - status = AudioObjectGetPropertyData(systemObject, &address, 0, nil, &size, &deviceIDs) - guard status == noErr else { return [] } - - var output = Set() - 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.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? - var size = UInt32(MemoryLayout?>.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? - var size = UInt32(MemoryLayout?>.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.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.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))") - } -} diff --git a/apps/macos/Sources/Clawdbot/CLIInstallPrompter.swift b/apps/macos/Sources/Clawdbot/CLIInstallPrompter.swift deleted file mode 100644 index 75c0b04d4..000000000 --- a/apps/macos/Sources/Clawdbot/CLIInstallPrompter.swift +++ /dev/null @@ -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 - } -} diff --git a/apps/macos/Sources/Clawdbot/CameraCaptureService.swift b/apps/macos/Sources/Clawdbot/CameraCaptureService.swift deleted file mode 100644 index 49a15262e..000000000 --- a/apps/macos/Sources/Clawdbot/CameraCaptureService.swift +++ /dev/null @@ -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) 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.. 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? - private var didResume = false - - init(_ cont: CheckedContinuation) { - 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? - private let logger: Logger - - init(_ cont: CheckedContinuation, 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) - } -} diff --git a/apps/macos/Sources/Clawdbot/CanvasFileWatcher.swift b/apps/macos/Sources/Clawdbot/CanvasFileWatcher.swift deleted file mode 100644 index 131e68748..000000000 --- a/apps/macos/Sources/Clawdbot/CanvasFileWatcher.swift +++ /dev/null @@ -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.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.fromOpaque(info).takeUnretainedValue() - watcher.handleEvents(numEvents: numEvents, eventFlags: eventFlags) - } - - private func handleEvents(numEvents: Int, eventFlags: UnsafePointer?) { - 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() - } - } -} diff --git a/apps/macos/Sources/Clawdbot/CanvasManager.swift b/apps/macos/Sources/Clawdbot/CanvasManager.swift deleted file mode 100644 index 9a0f32d61..000000000 --- a/apps/macos/Sources/Clawdbot/CanvasManager.swift +++ /dev/null @@ -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? - - 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 -} diff --git a/apps/macos/Sources/Clawdbot/CanvasSchemeHandler.swift b/apps/macos/Sources/Clawdbot/CanvasSchemeHandler.swift deleted file mode 100644 index 92bc8e71b..000000000 --- a/apps/macos/Sources/Clawdbot/CanvasSchemeHandler.swift +++ /dev/null @@ -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[.. \(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 "/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 "/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 = """ - - - - - - \(title) - - - -
-
\(body)
-
- - - """ - 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 = """ -
Canvas is ready.
-
Create index.html in:
-
\(escaped)
- """ - 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 diff --git a/apps/macos/Sources/Clawdbot/CanvasWindow.swift b/apps/macos/Sources/Clawdbot/CanvasWindow.swift deleted file mode 100644 index 47e0a4128..000000000 --- a/apps/macos/Sources/Clawdbot/CanvasWindow.swift +++ /dev/null @@ -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 - } -} diff --git a/apps/macos/Sources/Clawdbot/ClawdbotConfigFile.swift b/apps/macos/Sources/Clawdbot/ClawdbotConfigFile.swift deleted file mode 100644 index 0ca77af30..000000000 --- a/apps/macos/Sources/Clawdbot/ClawdbotConfigFile.swift +++ /dev/null @@ -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 - } -} diff --git a/apps/macos/Sources/Clawdbot/ConfigFileWatcher.swift b/apps/macos/Sources/Clawdbot/ConfigFileWatcher.swift deleted file mode 100644 index c21b002a7..000000000 --- a/apps/macos/Sources/Clawdbot/ConfigFileWatcher.swift +++ /dev/null @@ -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.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.fromOpaque(info).takeUnretainedValue() - watcher.handleEvents( - numEvents: numEvents, - eventPaths: eventPaths, - eventFlags: eventFlags) - } - - private func handleEvents( - numEvents: Int, - eventPaths: UnsafeMutableRawPointer?, - eventFlags: UnsafePointer?) - { - 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 - } -} diff --git a/apps/macos/Sources/Clawdbot/ConnectionModeCoordinator.swift b/apps/macos/Sources/Clawdbot/ConnectionModeCoordinator.swift deleted file mode 100644 index 00f93bd85..000000000 --- a/apps/macos/Sources/Clawdbot/ConnectionModeCoordinator.swift +++ /dev/null @@ -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) } - } - } -} diff --git a/apps/macos/Sources/Clawdbot/Constants.swift b/apps/macos/Sources/Clawdbot/Constants.swift deleted file mode 100644 index dcb36d4a9..000000000 --- a/apps/macos/Sources/Clawdbot/Constants.swift +++ /dev/null @@ -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 diff --git a/apps/macos/Sources/Clawdbot/ControlChannel.swift b/apps/macos/Sources/Clawdbot/ControlChannel.swift deleted file mode 100644 index 02f7e7686..000000000 --- a/apps/macos/Sources/Clawdbot/ControlChannel.swift +++ /dev/null @@ -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? - private var recoveryTask: Task? - 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") -} diff --git a/apps/macos/Sources/Clawdbot/CronJobsStore.swift b/apps/macos/Sources/Clawdbot/CronJobsStore.swift deleted file mode 100644 index 36a8b95a3..000000000 --- a/apps/macos/Sources/Clawdbot/CronJobsStore.swift +++ /dev/null @@ -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? - private var runsTask: Task? - private var eventTask: Task? - private var pollTask: Task? - - 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) -} diff --git a/apps/macos/Sources/Clawdbot/DeepLinks.swift b/apps/macos/Sources/Clawdbot/DeepLinks.swift deleted file mode 100644 index 4308cf47f..000000000 --- a/apps/macos/Sources/Clawdbot/DeepLinks.swift +++ /dev/null @@ -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() - } -} diff --git a/apps/macos/Sources/Clawdbot/DevicePairingApprovalPrompter.swift b/apps/macos/Sources/Clawdbot/DevicePairingApprovalPrompter.swift deleted file mode 100644 index b282a394b..000000000 --- a/apps/macos/Sources/Clawdbot/DevicePairingApprovalPrompter.swift +++ /dev/null @@ -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? - 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 = [] - - 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") - } -} diff --git a/apps/macos/Sources/Clawdbot/DockIconManager.swift b/apps/macos/Sources/Clawdbot/DockIconManager.swift deleted file mode 100644 index 59eacee29..000000000 --- a/apps/macos/Sources/Clawdbot/DockIconManager.swift +++ /dev/null @@ -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() - } - } -} diff --git a/apps/macos/Sources/Clawdbot/ExecApprovals.swift b/apps/macos/Sources/Clawdbot/ExecApprovals.swift deleted file mode 100644 index c79c96e84..000000000 --- a/apps/macos/Sources/Clawdbot/ExecApprovals.swift +++ /dev/null @@ -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() - 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[.. [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 = [] - private var lastRefresh: Date? - private let refreshInterval: TimeInterval = 90 - - func currentBins(force: Bool = false) async -> Set { - if force || self.isStale() { - await self.refresh() - } - return self.bins - } - - func refresh() async { - do { - let report = try await GatewayConnection.shared.skillsStatus() - var next = Set() - 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 - } -} diff --git a/apps/macos/Sources/Clawdbot/ExecApprovalsGatewayPrompter.swift b/apps/macos/Sources/Clawdbot/ExecApprovalsGatewayPrompter.swift deleted file mode 100644 index 29d1be50b..000000000 --- a/apps/macos/Sources/Clawdbot/ExecApprovalsGatewayPrompter.swift +++ /dev/null @@ -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? - - 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 diff --git a/apps/macos/Sources/Clawdbot/ExecApprovalsSocket.swift b/apps/macos/Sources/Clawdbot/ExecApprovalsSocket.swift deleted file mode 100644 index b5591dbd6..000000000 --- a/apps/macos/Sources/Clawdbot/ExecApprovalsSocket.swift +++ /dev/null @@ -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.. 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 = [ - "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? - 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.. 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.authenticationCode(for: Data(message.utf8), using: key) - return mac.map { String(format: "%02x", $0) }.joined() - } -} diff --git a/apps/macos/Sources/Clawdbot/GatewayConnection.swift b/apps/macos/Sources/Clawdbot/GatewayConnection.swift deleted file mode 100644 index 5b655d3ac..000000000 --- a/apps/macos/Sources/Clawdbot/GatewayConnection.swift +++ /dev/null @@ -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.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( - 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 { - 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) - } -} diff --git a/apps/macos/Sources/Clawdbot/GatewayConnectivityCoordinator.swift b/apps/macos/Sources/Clawdbot/GatewayConnectivityCoordinator.swift deleted file mode 100644 index ac65ec0ac..000000000 --- a/apps/macos/Sources/Clawdbot/GatewayConnectivityCoordinator.swift +++ /dev/null @@ -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? - 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 - } -} diff --git a/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift b/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift deleted file mode 100644 index a5c3a756e..000000000 --- a/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift +++ /dev/null @@ -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 = [ - "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.Continuation] = [:] - private var remoteEnsure: (token: UUID, task: Task)? - - 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 { - 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 diff --git a/apps/macos/Sources/Clawdbot/GatewayEnvironment.swift b/apps/macos/Sources/Clawdbot/GatewayEnvironment.swift deleted file mode 100644 index ff92f308c..000000000 --- a/apps/macos/Sources/Clawdbot/GatewayEnvironment.swift +++ /dev/null @@ -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 = ["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) - } -} diff --git a/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift b/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift deleted file mode 100644 index f0896e691..000000000 --- a/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift +++ /dev/null @@ -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 - } -} diff --git a/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift b/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift deleted file mode 100644 index 60964fa39..000000000 --- a/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift +++ /dev/null @@ -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? - private var lastEnvironmentRefresh: Date? - private var logRefreshTask: Task? - #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 diff --git a/apps/macos/Sources/Clawdbot/HealthStore.swift b/apps/macos/Sources/Clawdbot/HealthStore.swift deleted file mode 100644 index 0410dcb4c..000000000 --- a/apps/macos/Sources/Clawdbot/HealthStore.swift +++ /dev/null @@ -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? - 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) -} diff --git a/apps/macos/Sources/Clawdbot/InstancesStore.swift b/apps/macos/Sources/Clawdbot/InstancesStore.swift deleted file mode 100644 index 41685b463..000000000 --- a/apps/macos/Sources/Clawdbot/InstancesStore.swift +++ /dev/null @@ -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? - private let interval: TimeInterval = 30 - private var eventTask: Task? - 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? - 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 "" } - if data.isEmpty { return "" } - 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 - } -} diff --git a/apps/macos/Sources/Clawdbot/LaunchAgentManager.swift b/apps/macos/Sources/Clawdbot/LaunchAgentManager.swift deleted file mode 100644 index 6b0225a65..000000000 --- a/apps/macos/Sources/Clawdbot/LaunchAgentManager.swift +++ /dev/null @@ -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 = """ - - - - - Label - com.clawdbot.mac - ProgramArguments - - \(bundlePath)/Contents/MacOS/Moltbot - - WorkingDirectory - \(FileManager().homeDirectoryForCurrentUser.path) - RunAtLoad - - KeepAlive - - EnvironmentVariables - - PATH - \(CommandResolver.preferredPaths().joined(separator: ":")) - - StandardOutPath - \(LogLocator.launchdLogPath) - StandardErrorPath - \(LogLocator.launchdLogPath) - - - """ - 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 - } -} diff --git a/apps/macos/Sources/Clawdbot/Logging/ClawdbotLogging.swift b/apps/macos/Sources/Clawdbot/Logging/ClawdbotLogging.swift deleted file mode 100644 index c966aaa05..000000000 --- a/apps/macos/Sources/Clawdbot/Logging/ClawdbotLogging.swift +++ /dev/null @@ -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[.. 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: ",") + "}" - } - } -} diff --git a/apps/macos/Sources/Clawdbot/MenuBar.swift b/apps/macos/Sources/Clawdbot/MenuBar.swift deleted file mode 100644 index a1e64c279..000000000 --- a/apps/macos/Sources/Clawdbot/MenuBar.swift +++ /dev/null @@ -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 diff --git a/apps/macos/Sources/Clawdbot/MicLevelMonitor.swift b/apps/macos/Sources/Clawdbot/MicLevelMonitor.swift deleted file mode 100644 index 2b2b4c99b..000000000 --- a/apps/macos/Sources/Clawdbot/MicLevelMonitor.swift +++ /dev/null @@ -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.. 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 - } -} diff --git a/apps/macos/Sources/Clawdbot/ModelCatalogLoader.swift b/apps/macos/Sources/Clawdbot/ModelCatalogLoader.swift deleted file mode 100644 index 4fc652b11..000000000 --- a/apps/macos/Sources/Clawdbot/ModelCatalogLoader.swift +++ /dev/null @@ -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);" - } -} diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift deleted file mode 100644 index 818a329ad..000000000 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift +++ /dev/null @@ -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? - 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) - } -} diff --git a/apps/macos/Sources/Clawdbot/NodePairingApprovalPrompter.swift b/apps/macos/Sources/Clawdbot/NodePairingApprovalPrompter.swift deleted file mode 100644 index ef0735ca2..000000000 --- a/apps/macos/Sources/Clawdbot/NodePairingApprovalPrompter.swift +++ /dev/null @@ -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? - private var reconcileTask: Task? - private var reconcileOnceTask: Task? - 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 = [] - - 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 diff --git a/apps/macos/Sources/Clawdbot/NodeServiceManager.swift b/apps/macos/Sources/Clawdbot/NodeServiceManager.swift deleted file mode 100644 index 2dd62d1e6..000000000 --- a/apps/macos/Sources/Clawdbot/NodeServiceManager.swift +++ /dev/null @@ -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 - } -} diff --git a/apps/macos/Sources/Clawdbot/NodesStore.swift b/apps/macos/Sources/Clawdbot/NodesStore.swift deleted file mode 100644 index 51d43336d..000000000 --- a/apps/macos/Sources/Clawdbot/NodesStore.swift +++ /dev/null @@ -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? - 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 - } -} diff --git a/apps/macos/Sources/Clawdbot/NotificationManager.swift b/apps/macos/Sources/Clawdbot/NotificationManager.swift deleted file mode 100644 index 20d7a35b3..000000000 --- a/apps/macos/Sources/Clawdbot/NotificationManager.swift +++ /dev/null @@ -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 - } - } -} diff --git a/apps/macos/Sources/Clawdbot/OnboardingWizard.swift b/apps/macos/Sources/Clawdbot/OnboardingWizard.swift deleted file mode 100644 index 4c0ce8de4..000000000 --- a/apps/macos/Sources/Clawdbot/OnboardingWizard.swift +++ /dev/null @@ -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 - - 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 { - 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 } -} diff --git a/apps/macos/Sources/Clawdbot/PeekabooBridgeHostCoordinator.swift b/apps/macos/Sources/Clawdbot/PeekabooBridgeHostCoordinator.swift deleted file mode 100644 index 76777b57f..000000000 --- a/apps/macos/Sources/Clawdbot/PeekabooBridgeHostCoordinator.swift +++ /dev/null @@ -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 = ["Y5PE65HELJ"] - if let teamID = Self.currentTeamID() { - allowlistedTeamIDs.insert(teamID) - } - let allowlistedBundles: Set = [] - - 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) - } -} diff --git a/apps/macos/Sources/Clawdbot/PermissionManager.swift b/apps/macos/Sources/Clawdbot/PermissionManager.swift deleted file mode 100644 index e0d7b2404..000000000 --- a/apps/macos/Sources/Clawdbot/PermissionManager.swift +++ /dev/null @@ -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) 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? - private var timeoutTask: Task? - - 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() - } - } -} diff --git a/apps/macos/Sources/Clawdbot/PortGuardian.swift b/apps/macos/Sources/Clawdbot/PortGuardian.swift deleted file mode 100644 index c28e3eda0..000000000 --- a/apps/macos/Sources/Clawdbot/PortGuardian.swift +++ /dev/null @@ -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 diff --git a/apps/macos/Sources/Clawdbot/PresenceReporter.swift b/apps/macos/Sources/Clawdbot/PresenceReporter.swift deleted file mode 100644 index 8bffaefa0..000000000 --- a/apps/macos/Sources/Clawdbot/PresenceReporter.swift +++ /dev/null @@ -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? - 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? - 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 diff --git a/apps/macos/Sources/Clawdbot/RemotePortTunnel.swift b/apps/macos/Sources/Clawdbot/RemotePortTunnel.swift deleted file mode 100644 index e95f3f50d..000000000 --- a/apps/macos/Sources/Clawdbot/RemotePortTunnel.swift +++ /dev/null @@ -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.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.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.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.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 -} diff --git a/apps/macos/Sources/Clawdbot/RemoteTunnelManager.swift b/apps/macos/Sources/Clawdbot/RemoteTunnelManager.swift deleted file mode 100644 index 78a5154a9..000000000 --- a/apps/macos/Sources/Clawdbot/RemoteTunnelManager.swift +++ /dev/null @@ -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. -} diff --git a/apps/macos/Sources/Clawdbot/Resources/Info.plist b/apps/macos/Sources/Clawdbot/Resources/Info.plist deleted file mode 100644 index 83a81468b..000000000 --- a/apps/macos/Sources/Clawdbot/Resources/Info.plist +++ /dev/null @@ -1,79 +0,0 @@ - - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - Moltbot - CFBundleIdentifier - com.clawdbot.mac - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - Moltbot - CFBundlePackageType - APPL - CFBundleShortVersionString - 2026.1.26 - CFBundleVersion - 202601260 - CFBundleIconFile - Moltbot - CFBundleURLTypes - - - CFBundleURLName - com.clawdbot.mac.deeplink - CFBundleURLSchemes - - moltbot - - - - LSMinimumSystemVersion - 15.0 - LSUIElement - - - MoltbotBuildTimestamp - - MoltbotGitCommit - - - NSUserNotificationUsageDescription - Moltbot needs notification permission to show alerts for agent actions. - NSScreenCaptureDescription - Moltbot captures the screen when the agent needs screenshots for context. - NSCameraUsageDescription - Moltbot can capture photos or short video clips when requested by the agent. - NSLocationUsageDescription - Moltbot can share your location when requested by the agent. - NSLocationWhenInUseUsageDescription - Moltbot can share your location when requested by the agent. - NSLocationAlwaysAndWhenInUseUsageDescription - Moltbot can share your location when requested by the agent. - NSMicrophoneUsageDescription - Moltbot needs the mic for Voice Wake tests and agent audio capture. - NSSpeechRecognitionUsageDescription - Moltbot uses speech recognition to detect your Voice Wake trigger phrase. - NSAppleEventsUsageDescription - Moltbot needs Automation (AppleScript) permission to drive Terminal and other apps for agent actions. - - NSAppTransportSecurity - - NSAllowsArbitraryLoadsInWebContent - - NSExceptionDomains - - 100.100.100.100 - - NSExceptionAllowsInsecureHTTPLoads - - NSIncludesSubdomains - - - - - - diff --git a/apps/macos/Sources/Clawdbot/RuntimeLocator.swift b/apps/macos/Sources/Clawdbot/RuntimeLocator.swift deleted file mode 100644 index 775613457..000000000 --- a/apps/macos/Sources/Clawdbot/RuntimeLocator.swift +++ /dev/null @@ -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 - { - 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" } -} diff --git a/apps/macos/Sources/Clawdbot/ScreenRecordService.swift b/apps/macos/Sources/Clawdbot/ScreenRecordService.swift deleted file mode 100644 index ecbe99692..000000000 --- a/apps/macos/Sources/Clawdbot/ScreenRecordService.swift +++ /dev/null @@ -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) 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() - } - } - } - } - } -} diff --git a/apps/macos/Sources/Clawdbot/SessionMenuPreviewView.swift b/apps/macos/Sources/Clawdbot/SessionMenuPreviewView.swift deleted file mode 100644 index dd8222a48..000000000 --- a/apps/macos/Sources/Clawdbot/SessionMenuPreviewView.swift +++ /dev/null @@ -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] = [:] - - init(maxConcurrent: Int) { - let normalized = max(1, maxConcurrent) - self.maxConcurrent = normalized - self.available = normalized - } - - func withPermit(_ 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() - 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") - } -} diff --git a/apps/macos/Sources/Clawdbot/TailscaleService.swift b/apps/macos/Sources/Clawdbot/TailscaleService.swift deleted file mode 100644 index 413e8d0c8..000000000 --- a/apps/macos/Sources/Clawdbot/TailscaleService.swift +++ /dev/null @@ -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? - 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() - } -} diff --git a/apps/macos/Sources/Clawdbot/TalkAudioPlayer.swift b/apps/macos/Sources/Clawdbot/TalkAudioPlayer.swift deleted file mode 100644 index af5fdeffb..000000000 --- a/apps/macos/Sources/Clawdbot/TalkAudioPlayer.swift +++ /dev/null @@ -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? - private var watchdog: Task? - - func setContinuation(_ continuation: CheckedContinuation) { - self.lock.lock() - defer { self.lock.unlock() } - self.continuation = continuation - } - - func setWatchdog(_ task: Task?) { - 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? - 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? -} diff --git a/apps/macos/Sources/Clawdbot/TalkModeController.swift b/apps/macos/Sources/Clawdbot/TalkModeController.swift deleted file mode 100644 index a92c0fda0..000000000 --- a/apps/macos/Sources/Clawdbot/TalkModeController.swift +++ /dev/null @@ -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 -} diff --git a/apps/macos/Sources/Clawdbot/TalkModeRuntime.swift b/apps/macos/Sources/Clawdbot/TalkModeRuntime.swift deleted file mode 100644 index a25a8d7ed..000000000 --- a/apps/macos/Sources/Clawdbot/TalkModeRuntime.swift +++ /dev/null @@ -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? - private let rmsMeter = RMSMeter() - - private var captureTask: Task? - private var silenceTask: Task? - 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) 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, - sampleRate: Double) async -> StreamingPlaybackResult - { - await PCMStreamingAudioPlayer.shared.play(stream: stream, sampleRate: sampleRate) - } - - @MainActor - private func playMP3(stream: AsyncThrowingStream) 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.. 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 - } -} diff --git a/apps/macos/Sources/Clawdbot/TalkOverlay.swift b/apps/macos/Sources/Clawdbot/TalkOverlay.swift deleted file mode 100644 index 387b6db76..000000000 --- a/apps/macos/Sources/Clawdbot/TalkOverlay.swift +++ /dev/null @@ -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? - 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 { - override func acceptsFirstMouse(for event: NSEvent?) -> Bool { - true - } -} diff --git a/apps/macos/Sources/Clawdbot/TerminationSignalWatcher.swift b/apps/macos/Sources/Clawdbot/TerminationSignalWatcher.swift deleted file mode 100644 index 7994016ef..000000000 --- a/apps/macos/Sources/Clawdbot/TerminationSignalWatcher.swift +++ /dev/null @@ -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) - } - } -} diff --git a/apps/macos/Sources/Clawdbot/VoicePushToTalk.swift b/apps/macos/Sources/Clawdbot/VoicePushToTalk.swift deleted file mode 100644 index 2bb1ec1f5..000000000 --- a/apps/macos/Sources/Clawdbot/VoicePushToTalk.swift +++ /dev/null @@ -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? - 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 - } -} diff --git a/apps/macos/Sources/Clawdbot/VoiceSessionCoordinator.swift b/apps/macos/Sources/Clawdbot/VoiceSessionCoordinator.swift deleted file mode 100644 index d7ee38a53..000000000 --- a/apps/macos/Sources/Clawdbot/VoiceSessionCoordinator.swift +++ /dev/null @@ -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) } - } -} diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeChime.swift b/apps/macos/Sources/Clawdbot/VoiceWakeChime.swift deleted file mode 100644 index 8d0cc8b28..000000000 --- a/apps/macos/Sources/Clawdbot/VoiceWakeChime.swift +++ /dev/null @@ -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) - } - } -} diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeForwarder.swift b/apps/macos/Sources/Clawdbot/VoiceWakeForwarder.swift deleted file mode 100644 index 3fd9f827b..000000000 --- a/apps/macos/Sources/Clawdbot/VoiceWakeForwarder.swift +++ /dev/null @@ -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 - { - 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 { - let status = await GatewayConnection.shared.status() - if status.ok { return .success(()) } - return .failure(.rpcFailed(status.error ?? "agent rpc unreachable")) - } -} diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeGlobalSettingsSync.swift b/apps/macos/Sources/Clawdbot/VoiceWakeGlobalSettingsSync.swift deleted file mode 100644 index d08b79d84..000000000 --- a/apps/macos/Sources/Clawdbot/VoiceWakeGlobalSettingsSync.swift +++ /dev/null @@ -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? - - 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)") - } - } -} diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeOverlay.swift b/apps/macos/Sources/Clawdbot/VoiceWakeOverlay.swift deleted file mode 100644 index 278ca1389..000000000 --- a/apps/macos/Sources/Clawdbot/VoiceWakeOverlay.swift +++ /dev/null @@ -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? - var autoSendTask: Task? - 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 - } -} diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeRuntime.swift b/apps/macos/Sources/Clawdbot/VoiceWakeRuntime.swift deleted file mode 100644 index 06ebfb7ae..000000000 --- a/apps/macos/Sources/Clawdbot/VoiceWakeRuntime.swift +++ /dev/null @@ -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? - 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? - 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? - private var isStarting: Bool = false - private var triggerOnlyTask: Task? - - // 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.. 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 - } -} diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeTester.swift b/apps/macos/Sources/Clawdbot/VoiceWakeTester.swift deleted file mode 100644 index bf6a883ab..000000000 --- a/apps/macos/Sources/Clawdbot/VoiceWakeTester.swift +++ /dev/null @@ -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? - 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.. [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 {} diff --git a/apps/macos/Sources/Clawdbot/WebChatSwiftUI.swift b/apps/macos/Sources/Clawdbot/WebChatSwiftUI.swift deleted file mode 100644 index 18d3c46c8..000000000 --- a/apps/macos/Sources/Clawdbot/WebChatSwiftUI.swift +++ /dev/null @@ -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 { - 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 - 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) -> 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) - } -} diff --git a/apps/macos/Sources/ClawdbotDiscovery/GatewayDiscoveryModel.swift b/apps/macos/Sources/ClawdbotDiscovery/GatewayDiscoveryModel.swift deleted file mode 100644 index 6ce854c74..000000000 --- a/apps/macos/Sources/ClawdbotDiscovery/GatewayDiscoveryModel.swift +++ /dev/null @@ -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 - public var displayTokens: Set - - public init(hostTokens: Set, displayTokens: Set) { - 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] = [:] - 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? - 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() - 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 = [] - var displayTokens: Set = [] - - 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 = [] - var displayTokens: Set = [] - - 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]) -} diff --git a/apps/macos/Sources/Clawdbot/AboutSettings.swift b/apps/macos/Sources/Moltbot/AboutSettings.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/AboutSettings.swift rename to apps/macos/Sources/Moltbot/AboutSettings.swift diff --git a/apps/macos/Sources/Clawdbot/AgeFormatting.swift b/apps/macos/Sources/Moltbot/AgeFormatting.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/AgeFormatting.swift rename to apps/macos/Sources/Moltbot/AgeFormatting.swift diff --git a/apps/macos/Sources/Clawdbot/AgentEventStore.swift b/apps/macos/Sources/Moltbot/AgentEventStore.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/AgentEventStore.swift rename to apps/macos/Sources/Moltbot/AgentEventStore.swift diff --git a/apps/macos/Sources/Clawdbot/AgentEventsWindow.swift b/apps/macos/Sources/Moltbot/AgentEventsWindow.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/AgentEventsWindow.swift rename to apps/macos/Sources/Moltbot/AgentEventsWindow.swift diff --git a/apps/macos/Sources/Clawdbot/AnthropicAuthControls.swift b/apps/macos/Sources/Moltbot/AnthropicAuthControls.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/AnthropicAuthControls.swift rename to apps/macos/Sources/Moltbot/AnthropicAuthControls.swift diff --git a/apps/macos/Sources/Clawdbot/AnthropicOAuthCodeState.swift b/apps/macos/Sources/Moltbot/AnthropicOAuthCodeState.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/AnthropicOAuthCodeState.swift rename to apps/macos/Sources/Moltbot/AnthropicOAuthCodeState.swift diff --git a/apps/macos/Sources/Clawdbot/AnyCodable+Helpers.swift b/apps/macos/Sources/Moltbot/AnyCodable+Helpers.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/AnyCodable+Helpers.swift rename to apps/macos/Sources/Moltbot/AnyCodable+Helpers.swift diff --git a/apps/macos/Sources/Clawdbot/AppState.swift b/apps/macos/Sources/Moltbot/AppState.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/AppState.swift rename to apps/macos/Sources/Moltbot/AppState.swift diff --git a/apps/macos/Sources/Clawdbot/CLIInstaller.swift b/apps/macos/Sources/Moltbot/CLIInstaller.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CLIInstaller.swift rename to apps/macos/Sources/Moltbot/CLIInstaller.swift diff --git a/apps/macos/Sources/Clawdbot/CanvasA2UIActionMessageHandler.swift b/apps/macos/Sources/Moltbot/CanvasA2UIActionMessageHandler.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CanvasA2UIActionMessageHandler.swift rename to apps/macos/Sources/Moltbot/CanvasA2UIActionMessageHandler.swift diff --git a/apps/macos/Sources/Clawdbot/CanvasChromeContainerView.swift b/apps/macos/Sources/Moltbot/CanvasChromeContainerView.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CanvasChromeContainerView.swift rename to apps/macos/Sources/Moltbot/CanvasChromeContainerView.swift diff --git a/apps/macos/Sources/Clawdbot/CanvasScheme.swift b/apps/macos/Sources/Moltbot/CanvasScheme.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CanvasScheme.swift rename to apps/macos/Sources/Moltbot/CanvasScheme.swift diff --git a/apps/macos/Sources/Clawdbot/CanvasWindowController+Helpers.swift b/apps/macos/Sources/Moltbot/CanvasWindowController+Helpers.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CanvasWindowController+Helpers.swift rename to apps/macos/Sources/Moltbot/CanvasWindowController+Helpers.swift diff --git a/apps/macos/Sources/Clawdbot/CanvasWindowController+Navigation.swift b/apps/macos/Sources/Moltbot/CanvasWindowController+Navigation.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CanvasWindowController+Navigation.swift rename to apps/macos/Sources/Moltbot/CanvasWindowController+Navigation.swift diff --git a/apps/macos/Sources/Clawdbot/CanvasWindowController+Testing.swift b/apps/macos/Sources/Moltbot/CanvasWindowController+Testing.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CanvasWindowController+Testing.swift rename to apps/macos/Sources/Moltbot/CanvasWindowController+Testing.swift diff --git a/apps/macos/Sources/Clawdbot/CanvasWindowController+Window.swift b/apps/macos/Sources/Moltbot/CanvasWindowController+Window.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CanvasWindowController+Window.swift rename to apps/macos/Sources/Moltbot/CanvasWindowController+Window.swift diff --git a/apps/macos/Sources/Clawdbot/CanvasWindowController.swift b/apps/macos/Sources/Moltbot/CanvasWindowController.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CanvasWindowController.swift rename to apps/macos/Sources/Moltbot/CanvasWindowController.swift diff --git a/apps/macos/Sources/Clawdbot/ChannelConfigForm.swift b/apps/macos/Sources/Moltbot/ChannelConfigForm.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/ChannelConfigForm.swift rename to apps/macos/Sources/Moltbot/ChannelConfigForm.swift diff --git a/apps/macos/Sources/Clawdbot/ChannelsSettings+ChannelSections.swift b/apps/macos/Sources/Moltbot/ChannelsSettings+ChannelSections.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/ChannelsSettings+ChannelSections.swift rename to apps/macos/Sources/Moltbot/ChannelsSettings+ChannelSections.swift diff --git a/apps/macos/Sources/Clawdbot/ChannelsSettings+ChannelState.swift b/apps/macos/Sources/Moltbot/ChannelsSettings+ChannelState.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/ChannelsSettings+ChannelState.swift rename to apps/macos/Sources/Moltbot/ChannelsSettings+ChannelState.swift diff --git a/apps/macos/Sources/Clawdbot/ChannelsSettings+Helpers.swift b/apps/macos/Sources/Moltbot/ChannelsSettings+Helpers.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/ChannelsSettings+Helpers.swift rename to apps/macos/Sources/Moltbot/ChannelsSettings+Helpers.swift diff --git a/apps/macos/Sources/Clawdbot/ChannelsSettings+View.swift b/apps/macos/Sources/Moltbot/ChannelsSettings+View.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/ChannelsSettings+View.swift rename to apps/macos/Sources/Moltbot/ChannelsSettings+View.swift diff --git a/apps/macos/Sources/Clawdbot/ChannelsSettings.swift b/apps/macos/Sources/Moltbot/ChannelsSettings.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/ChannelsSettings.swift rename to apps/macos/Sources/Moltbot/ChannelsSettings.swift diff --git a/apps/macos/Sources/Clawdbot/ChannelsStore+Config.swift b/apps/macos/Sources/Moltbot/ChannelsStore+Config.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/ChannelsStore+Config.swift rename to apps/macos/Sources/Moltbot/ChannelsStore+Config.swift diff --git a/apps/macos/Sources/Clawdbot/ChannelsStore+Lifecycle.swift b/apps/macos/Sources/Moltbot/ChannelsStore+Lifecycle.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/ChannelsStore+Lifecycle.swift rename to apps/macos/Sources/Moltbot/ChannelsStore+Lifecycle.swift diff --git a/apps/macos/Sources/Clawdbot/ChannelsStore.swift b/apps/macos/Sources/Moltbot/ChannelsStore.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/ChannelsStore.swift rename to apps/macos/Sources/Moltbot/ChannelsStore.swift diff --git a/apps/macos/Sources/Clawdbot/ClawdbotPaths.swift b/apps/macos/Sources/Moltbot/ClawdbotPaths.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/ClawdbotPaths.swift rename to apps/macos/Sources/Moltbot/ClawdbotPaths.swift diff --git a/apps/macos/Sources/Clawdbot/CommandResolver.swift b/apps/macos/Sources/Moltbot/CommandResolver.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CommandResolver.swift rename to apps/macos/Sources/Moltbot/CommandResolver.swift diff --git a/apps/macos/Sources/Clawdbot/ConfigSchemaSupport.swift b/apps/macos/Sources/Moltbot/ConfigSchemaSupport.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/ConfigSchemaSupport.swift rename to apps/macos/Sources/Moltbot/ConfigSchemaSupport.swift diff --git a/apps/macos/Sources/Clawdbot/ConfigSettings.swift b/apps/macos/Sources/Moltbot/ConfigSettings.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/ConfigSettings.swift rename to apps/macos/Sources/Moltbot/ConfigSettings.swift diff --git a/apps/macos/Sources/Clawdbot/ConfigStore.swift b/apps/macos/Sources/Moltbot/ConfigStore.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/ConfigStore.swift rename to apps/macos/Sources/Moltbot/ConfigStore.swift diff --git a/apps/macos/Sources/Clawdbot/ConnectionModeResolver.swift b/apps/macos/Sources/Moltbot/ConnectionModeResolver.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/ConnectionModeResolver.swift rename to apps/macos/Sources/Moltbot/ConnectionModeResolver.swift diff --git a/apps/macos/Sources/Clawdbot/ContextMenuCardView.swift b/apps/macos/Sources/Moltbot/ContextMenuCardView.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/ContextMenuCardView.swift rename to apps/macos/Sources/Moltbot/ContextMenuCardView.swift diff --git a/apps/macos/Sources/Clawdbot/ContextUsageBar.swift b/apps/macos/Sources/Moltbot/ContextUsageBar.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/ContextUsageBar.swift rename to apps/macos/Sources/Moltbot/ContextUsageBar.swift diff --git a/apps/macos/Sources/Clawdbot/CostUsageMenuView.swift b/apps/macos/Sources/Moltbot/CostUsageMenuView.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CostUsageMenuView.swift rename to apps/macos/Sources/Moltbot/CostUsageMenuView.swift diff --git a/apps/macos/Sources/Clawdbot/CritterIconRenderer.swift b/apps/macos/Sources/Moltbot/CritterIconRenderer.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CritterIconRenderer.swift rename to apps/macos/Sources/Moltbot/CritterIconRenderer.swift diff --git a/apps/macos/Sources/Clawdbot/CritterStatusLabel+Behavior.swift b/apps/macos/Sources/Moltbot/CritterStatusLabel+Behavior.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CritterStatusLabel+Behavior.swift rename to apps/macos/Sources/Moltbot/CritterStatusLabel+Behavior.swift diff --git a/apps/macos/Sources/Clawdbot/CritterStatusLabel.swift b/apps/macos/Sources/Moltbot/CritterStatusLabel.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CritterStatusLabel.swift rename to apps/macos/Sources/Moltbot/CritterStatusLabel.swift diff --git a/apps/macos/Sources/Clawdbot/CronJobEditor+Helpers.swift b/apps/macos/Sources/Moltbot/CronJobEditor+Helpers.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CronJobEditor+Helpers.swift rename to apps/macos/Sources/Moltbot/CronJobEditor+Helpers.swift diff --git a/apps/macos/Sources/Clawdbot/CronJobEditor+Testing.swift b/apps/macos/Sources/Moltbot/CronJobEditor+Testing.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CronJobEditor+Testing.swift rename to apps/macos/Sources/Moltbot/CronJobEditor+Testing.swift diff --git a/apps/macos/Sources/Clawdbot/CronJobEditor.swift b/apps/macos/Sources/Moltbot/CronJobEditor.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CronJobEditor.swift rename to apps/macos/Sources/Moltbot/CronJobEditor.swift diff --git a/apps/macos/Sources/Clawdbot/CronModels.swift b/apps/macos/Sources/Moltbot/CronModels.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CronModels.swift rename to apps/macos/Sources/Moltbot/CronModels.swift diff --git a/apps/macos/Sources/Clawdbot/CronSettings+Actions.swift b/apps/macos/Sources/Moltbot/CronSettings+Actions.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CronSettings+Actions.swift rename to apps/macos/Sources/Moltbot/CronSettings+Actions.swift diff --git a/apps/macos/Sources/Clawdbot/CronSettings+Helpers.swift b/apps/macos/Sources/Moltbot/CronSettings+Helpers.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CronSettings+Helpers.swift rename to apps/macos/Sources/Moltbot/CronSettings+Helpers.swift diff --git a/apps/macos/Sources/Clawdbot/CronSettings+Layout.swift b/apps/macos/Sources/Moltbot/CronSettings+Layout.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CronSettings+Layout.swift rename to apps/macos/Sources/Moltbot/CronSettings+Layout.swift diff --git a/apps/macos/Sources/Clawdbot/CronSettings+Rows.swift b/apps/macos/Sources/Moltbot/CronSettings+Rows.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CronSettings+Rows.swift rename to apps/macos/Sources/Moltbot/CronSettings+Rows.swift diff --git a/apps/macos/Sources/Clawdbot/CronSettings+Testing.swift b/apps/macos/Sources/Moltbot/CronSettings+Testing.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CronSettings+Testing.swift rename to apps/macos/Sources/Moltbot/CronSettings+Testing.swift diff --git a/apps/macos/Sources/Clawdbot/CronSettings.swift b/apps/macos/Sources/Moltbot/CronSettings.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/CronSettings.swift rename to apps/macos/Sources/Moltbot/CronSettings.swift diff --git a/apps/macos/Sources/Clawdbot/DebugActions.swift b/apps/macos/Sources/Moltbot/DebugActions.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/DebugActions.swift rename to apps/macos/Sources/Moltbot/DebugActions.swift diff --git a/apps/macos/Sources/Clawdbot/DebugSettings.swift b/apps/macos/Sources/Moltbot/DebugSettings.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/DebugSettings.swift rename to apps/macos/Sources/Moltbot/DebugSettings.swift diff --git a/apps/macos/Sources/Clawdbot/DeviceModelCatalog.swift b/apps/macos/Sources/Moltbot/DeviceModelCatalog.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/DeviceModelCatalog.swift rename to apps/macos/Sources/Moltbot/DeviceModelCatalog.swift diff --git a/apps/macos/Sources/Clawdbot/DiagnosticsFileLog.swift b/apps/macos/Sources/Moltbot/DiagnosticsFileLog.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/DiagnosticsFileLog.swift rename to apps/macos/Sources/Moltbot/DiagnosticsFileLog.swift diff --git a/apps/macos/Sources/Clawdbot/FileHandle+SafeRead.swift b/apps/macos/Sources/Moltbot/FileHandle+SafeRead.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/FileHandle+SafeRead.swift rename to apps/macos/Sources/Moltbot/FileHandle+SafeRead.swift diff --git a/apps/macos/Sources/Clawdbot/GatewayAutostartPolicy.swift b/apps/macos/Sources/Moltbot/GatewayAutostartPolicy.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/GatewayAutostartPolicy.swift rename to apps/macos/Sources/Moltbot/GatewayAutostartPolicy.swift diff --git a/apps/macos/Sources/Clawdbot/GatewayDiscoveryHelpers.swift b/apps/macos/Sources/Moltbot/GatewayDiscoveryHelpers.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/GatewayDiscoveryHelpers.swift rename to apps/macos/Sources/Moltbot/GatewayDiscoveryHelpers.swift diff --git a/apps/macos/Sources/Clawdbot/GatewayDiscoveryMenu.swift b/apps/macos/Sources/Moltbot/GatewayDiscoveryMenu.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/GatewayDiscoveryMenu.swift rename to apps/macos/Sources/Moltbot/GatewayDiscoveryMenu.swift diff --git a/apps/macos/Sources/Clawdbot/GatewayDiscoveryPreferences.swift b/apps/macos/Sources/Moltbot/GatewayDiscoveryPreferences.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/GatewayDiscoveryPreferences.swift rename to apps/macos/Sources/Moltbot/GatewayDiscoveryPreferences.swift diff --git a/apps/macos/Sources/Clawdbot/GatewayRemoteConfig.swift b/apps/macos/Sources/Moltbot/GatewayRemoteConfig.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/GatewayRemoteConfig.swift rename to apps/macos/Sources/Moltbot/GatewayRemoteConfig.swift diff --git a/apps/macos/Sources/Clawdbot/GeneralSettings.swift b/apps/macos/Sources/Moltbot/GeneralSettings.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/GeneralSettings.swift rename to apps/macos/Sources/Moltbot/GeneralSettings.swift diff --git a/apps/macos/Sources/Clawdbot/HeartbeatStore.swift b/apps/macos/Sources/Moltbot/HeartbeatStore.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/HeartbeatStore.swift rename to apps/macos/Sources/Moltbot/HeartbeatStore.swift diff --git a/apps/macos/Sources/Clawdbot/HoverHUD.swift b/apps/macos/Sources/Moltbot/HoverHUD.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/HoverHUD.swift rename to apps/macos/Sources/Moltbot/HoverHUD.swift diff --git a/apps/macos/Sources/Clawdbot/IconState.swift b/apps/macos/Sources/Moltbot/IconState.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/IconState.swift rename to apps/macos/Sources/Moltbot/IconState.swift diff --git a/apps/macos/Sources/Clawdbot/InstancesSettings.swift b/apps/macos/Sources/Moltbot/InstancesSettings.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/InstancesSettings.swift rename to apps/macos/Sources/Moltbot/InstancesSettings.swift diff --git a/apps/macos/Sources/Clawdbot/Launchctl.swift b/apps/macos/Sources/Moltbot/Launchctl.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/Launchctl.swift rename to apps/macos/Sources/Moltbot/Launchctl.swift diff --git a/apps/macos/Sources/Clawdbot/LaunchdManager.swift b/apps/macos/Sources/Moltbot/LaunchdManager.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/LaunchdManager.swift rename to apps/macos/Sources/Moltbot/LaunchdManager.swift diff --git a/apps/macos/Sources/Clawdbot/LogLocator.swift b/apps/macos/Sources/Moltbot/LogLocator.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/LogLocator.swift rename to apps/macos/Sources/Moltbot/LogLocator.swift diff --git a/apps/macos/Sources/Clawdbot/MenuContentView.swift b/apps/macos/Sources/Moltbot/MenuContentView.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/MenuContentView.swift rename to apps/macos/Sources/Moltbot/MenuContentView.swift diff --git a/apps/macos/Sources/Clawdbot/MenuContextCardInjector.swift b/apps/macos/Sources/Moltbot/MenuContextCardInjector.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/MenuContextCardInjector.swift rename to apps/macos/Sources/Moltbot/MenuContextCardInjector.swift diff --git a/apps/macos/Sources/Clawdbot/MenuHighlightedHostView.swift b/apps/macos/Sources/Moltbot/MenuHighlightedHostView.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/MenuHighlightedHostView.swift rename to apps/macos/Sources/Moltbot/MenuHighlightedHostView.swift diff --git a/apps/macos/Sources/Clawdbot/MenuHostedItem.swift b/apps/macos/Sources/Moltbot/MenuHostedItem.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/MenuHostedItem.swift rename to apps/macos/Sources/Moltbot/MenuHostedItem.swift diff --git a/apps/macos/Sources/Clawdbot/MenuSessionsHeaderView.swift b/apps/macos/Sources/Moltbot/MenuSessionsHeaderView.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/MenuSessionsHeaderView.swift rename to apps/macos/Sources/Moltbot/MenuSessionsHeaderView.swift diff --git a/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift b/apps/macos/Sources/Moltbot/MenuSessionsInjector.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift rename to apps/macos/Sources/Moltbot/MenuSessionsInjector.swift diff --git a/apps/macos/Sources/Clawdbot/MenuUsageHeaderView.swift b/apps/macos/Sources/Moltbot/MenuUsageHeaderView.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/MenuUsageHeaderView.swift rename to apps/macos/Sources/Moltbot/MenuUsageHeaderView.swift diff --git a/apps/macos/Sources/Clawdbot/NSAttributedString+VoiceWake.swift b/apps/macos/Sources/Moltbot/NSAttributedString+VoiceWake.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/NSAttributedString+VoiceWake.swift rename to apps/macos/Sources/Moltbot/NSAttributedString+VoiceWake.swift diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeLocationService.swift b/apps/macos/Sources/Moltbot/NodeMode/MacNodeLocationService.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/NodeMode/MacNodeLocationService.swift rename to apps/macos/Sources/Moltbot/NodeMode/MacNodeLocationService.swift diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift b/apps/macos/Sources/Moltbot/NodeMode/MacNodeRuntime.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift rename to apps/macos/Sources/Moltbot/NodeMode/MacNodeRuntime.swift diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntimeMainActorServices.swift b/apps/macos/Sources/Moltbot/NodeMode/MacNodeRuntimeMainActorServices.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntimeMainActorServices.swift rename to apps/macos/Sources/Moltbot/NodeMode/MacNodeRuntimeMainActorServices.swift diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeScreenCommands.swift b/apps/macos/Sources/Moltbot/NodeMode/MacNodeScreenCommands.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/NodeMode/MacNodeScreenCommands.swift rename to apps/macos/Sources/Moltbot/NodeMode/MacNodeScreenCommands.swift diff --git a/apps/macos/Sources/Clawdbot/NodesMenu.swift b/apps/macos/Sources/Moltbot/NodesMenu.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/NodesMenu.swift rename to apps/macos/Sources/Moltbot/NodesMenu.swift diff --git a/apps/macos/Sources/Clawdbot/NotifyOverlay.swift b/apps/macos/Sources/Moltbot/NotifyOverlay.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/NotifyOverlay.swift rename to apps/macos/Sources/Moltbot/NotifyOverlay.swift diff --git a/apps/macos/Sources/Clawdbot/Onboarding.swift b/apps/macos/Sources/Moltbot/Onboarding.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/Onboarding.swift rename to apps/macos/Sources/Moltbot/Onboarding.swift diff --git a/apps/macos/Sources/Clawdbot/OnboardingView+Actions.swift b/apps/macos/Sources/Moltbot/OnboardingView+Actions.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/OnboardingView+Actions.swift rename to apps/macos/Sources/Moltbot/OnboardingView+Actions.swift diff --git a/apps/macos/Sources/Clawdbot/OnboardingView+Chat.swift b/apps/macos/Sources/Moltbot/OnboardingView+Chat.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/OnboardingView+Chat.swift rename to apps/macos/Sources/Moltbot/OnboardingView+Chat.swift diff --git a/apps/macos/Sources/Clawdbot/OnboardingView+Layout.swift b/apps/macos/Sources/Moltbot/OnboardingView+Layout.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/OnboardingView+Layout.swift rename to apps/macos/Sources/Moltbot/OnboardingView+Layout.swift diff --git a/apps/macos/Sources/Clawdbot/OnboardingView+Monitoring.swift b/apps/macos/Sources/Moltbot/OnboardingView+Monitoring.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/OnboardingView+Monitoring.swift rename to apps/macos/Sources/Moltbot/OnboardingView+Monitoring.swift diff --git a/apps/macos/Sources/Clawdbot/OnboardingView+Pages.swift b/apps/macos/Sources/Moltbot/OnboardingView+Pages.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/OnboardingView+Pages.swift rename to apps/macos/Sources/Moltbot/OnboardingView+Pages.swift diff --git a/apps/macos/Sources/Clawdbot/OnboardingView+Testing.swift b/apps/macos/Sources/Moltbot/OnboardingView+Testing.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/OnboardingView+Testing.swift rename to apps/macos/Sources/Moltbot/OnboardingView+Testing.swift diff --git a/apps/macos/Sources/Clawdbot/OnboardingView+Wizard.swift b/apps/macos/Sources/Moltbot/OnboardingView+Wizard.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/OnboardingView+Wizard.swift rename to apps/macos/Sources/Moltbot/OnboardingView+Wizard.swift diff --git a/apps/macos/Sources/Clawdbot/OnboardingView+Workspace.swift b/apps/macos/Sources/Moltbot/OnboardingView+Workspace.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/OnboardingView+Workspace.swift rename to apps/macos/Sources/Moltbot/OnboardingView+Workspace.swift diff --git a/apps/macos/Sources/Clawdbot/OnboardingWidgets.swift b/apps/macos/Sources/Moltbot/OnboardingWidgets.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/OnboardingWidgets.swift rename to apps/macos/Sources/Moltbot/OnboardingWidgets.swift diff --git a/apps/macos/Sources/Clawdbot/PermissionsSettings.swift b/apps/macos/Sources/Moltbot/PermissionsSettings.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/PermissionsSettings.swift rename to apps/macos/Sources/Moltbot/PermissionsSettings.swift diff --git a/apps/macos/Sources/Clawdbot/PointingHandCursor.swift b/apps/macos/Sources/Moltbot/PointingHandCursor.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/PointingHandCursor.swift rename to apps/macos/Sources/Moltbot/PointingHandCursor.swift diff --git a/apps/macos/Sources/Clawdbot/Process+PipeRead.swift b/apps/macos/Sources/Moltbot/Process+PipeRead.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/Process+PipeRead.swift rename to apps/macos/Sources/Moltbot/Process+PipeRead.swift diff --git a/apps/macos/Sources/Clawdbot/ProcessInfo+Clawdbot.swift b/apps/macos/Sources/Moltbot/ProcessInfo+Clawdbot.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/ProcessInfo+Clawdbot.swift rename to apps/macos/Sources/Moltbot/ProcessInfo+Clawdbot.swift diff --git a/apps/macos/Sources/Clawdbot/Resources/Clawdbot.icns b/apps/macos/Sources/Moltbot/Resources/Clawdbot.icns similarity index 100% rename from apps/macos/Sources/Clawdbot/Resources/Clawdbot.icns rename to apps/macos/Sources/Moltbot/Resources/Clawdbot.icns diff --git a/apps/macos/Sources/Clawdbot/Resources/DeviceModels/LICENSE.apple-device-identifiers.txt b/apps/macos/Sources/Moltbot/Resources/DeviceModels/LICENSE.apple-device-identifiers.txt similarity index 100% rename from apps/macos/Sources/Clawdbot/Resources/DeviceModels/LICENSE.apple-device-identifiers.txt rename to apps/macos/Sources/Moltbot/Resources/DeviceModels/LICENSE.apple-device-identifiers.txt diff --git a/apps/macos/Sources/Clawdbot/Resources/DeviceModels/NOTICE.md b/apps/macos/Sources/Moltbot/Resources/DeviceModels/NOTICE.md similarity index 100% rename from apps/macos/Sources/Clawdbot/Resources/DeviceModels/NOTICE.md rename to apps/macos/Sources/Moltbot/Resources/DeviceModels/NOTICE.md diff --git a/apps/macos/Sources/Clawdbot/Resources/DeviceModels/ios-device-identifiers.json b/apps/macos/Sources/Moltbot/Resources/DeviceModels/ios-device-identifiers.json similarity index 100% rename from apps/macos/Sources/Clawdbot/Resources/DeviceModels/ios-device-identifiers.json rename to apps/macos/Sources/Moltbot/Resources/DeviceModels/ios-device-identifiers.json diff --git a/apps/macos/Sources/Clawdbot/Resources/DeviceModels/mac-device-identifiers.json b/apps/macos/Sources/Moltbot/Resources/DeviceModels/mac-device-identifiers.json similarity index 100% rename from apps/macos/Sources/Clawdbot/Resources/DeviceModels/mac-device-identifiers.json rename to apps/macos/Sources/Moltbot/Resources/DeviceModels/mac-device-identifiers.json diff --git a/apps/macos/Sources/Clawdbot/ScreenshotSize.swift b/apps/macos/Sources/Moltbot/ScreenshotSize.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/ScreenshotSize.swift rename to apps/macos/Sources/Moltbot/ScreenshotSize.swift diff --git a/apps/macos/Sources/Clawdbot/SessionActions.swift b/apps/macos/Sources/Moltbot/SessionActions.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/SessionActions.swift rename to apps/macos/Sources/Moltbot/SessionActions.swift diff --git a/apps/macos/Sources/Clawdbot/SessionData.swift b/apps/macos/Sources/Moltbot/SessionData.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/SessionData.swift rename to apps/macos/Sources/Moltbot/SessionData.swift diff --git a/apps/macos/Sources/Clawdbot/SessionMenuLabelView.swift b/apps/macos/Sources/Moltbot/SessionMenuLabelView.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/SessionMenuLabelView.swift rename to apps/macos/Sources/Moltbot/SessionMenuLabelView.swift diff --git a/apps/macos/Sources/Clawdbot/SessionsSettings.swift b/apps/macos/Sources/Moltbot/SessionsSettings.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/SessionsSettings.swift rename to apps/macos/Sources/Moltbot/SessionsSettings.swift diff --git a/apps/macos/Sources/Clawdbot/SettingsComponents.swift b/apps/macos/Sources/Moltbot/SettingsComponents.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/SettingsComponents.swift rename to apps/macos/Sources/Moltbot/SettingsComponents.swift diff --git a/apps/macos/Sources/Clawdbot/SettingsRootView.swift b/apps/macos/Sources/Moltbot/SettingsRootView.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/SettingsRootView.swift rename to apps/macos/Sources/Moltbot/SettingsRootView.swift diff --git a/apps/macos/Sources/Clawdbot/SettingsWindowOpener.swift b/apps/macos/Sources/Moltbot/SettingsWindowOpener.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/SettingsWindowOpener.swift rename to apps/macos/Sources/Moltbot/SettingsWindowOpener.swift diff --git a/apps/macos/Sources/Clawdbot/ShellExecutor.swift b/apps/macos/Sources/Moltbot/ShellExecutor.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/ShellExecutor.swift rename to apps/macos/Sources/Moltbot/ShellExecutor.swift diff --git a/apps/macos/Sources/Clawdbot/SkillsModels.swift b/apps/macos/Sources/Moltbot/SkillsModels.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/SkillsModels.swift rename to apps/macos/Sources/Moltbot/SkillsModels.swift diff --git a/apps/macos/Sources/Clawdbot/SkillsSettings.swift b/apps/macos/Sources/Moltbot/SkillsSettings.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/SkillsSettings.swift rename to apps/macos/Sources/Moltbot/SkillsSettings.swift diff --git a/apps/macos/Sources/Clawdbot/SoundEffects.swift b/apps/macos/Sources/Moltbot/SoundEffects.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/SoundEffects.swift rename to apps/macos/Sources/Moltbot/SoundEffects.swift diff --git a/apps/macos/Sources/Clawdbot/StatusPill.swift b/apps/macos/Sources/Moltbot/StatusPill.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/StatusPill.swift rename to apps/macos/Sources/Moltbot/StatusPill.swift diff --git a/apps/macos/Sources/Clawdbot/String+NonEmpty.swift b/apps/macos/Sources/Moltbot/String+NonEmpty.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/String+NonEmpty.swift rename to apps/macos/Sources/Moltbot/String+NonEmpty.swift diff --git a/apps/macos/Sources/Clawdbot/SystemRunSettingsView.swift b/apps/macos/Sources/Moltbot/SystemRunSettingsView.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/SystemRunSettingsView.swift rename to apps/macos/Sources/Moltbot/SystemRunSettingsView.swift diff --git a/apps/macos/Sources/Clawdbot/TailscaleIntegrationSection.swift b/apps/macos/Sources/Moltbot/TailscaleIntegrationSection.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/TailscaleIntegrationSection.swift rename to apps/macos/Sources/Moltbot/TailscaleIntegrationSection.swift diff --git a/apps/macos/Sources/Clawdbot/TalkModeTypes.swift b/apps/macos/Sources/Moltbot/TalkModeTypes.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/TalkModeTypes.swift rename to apps/macos/Sources/Moltbot/TalkModeTypes.swift diff --git a/apps/macos/Sources/Clawdbot/TalkOverlayView.swift b/apps/macos/Sources/Moltbot/TalkOverlayView.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/TalkOverlayView.swift rename to apps/macos/Sources/Moltbot/TalkOverlayView.swift diff --git a/apps/macos/Sources/Clawdbot/UsageCostData.swift b/apps/macos/Sources/Moltbot/UsageCostData.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/UsageCostData.swift rename to apps/macos/Sources/Moltbot/UsageCostData.swift diff --git a/apps/macos/Sources/Clawdbot/UsageData.swift b/apps/macos/Sources/Moltbot/UsageData.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/UsageData.swift rename to apps/macos/Sources/Moltbot/UsageData.swift diff --git a/apps/macos/Sources/Clawdbot/UsageMenuLabelView.swift b/apps/macos/Sources/Moltbot/UsageMenuLabelView.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/UsageMenuLabelView.swift rename to apps/macos/Sources/Moltbot/UsageMenuLabelView.swift diff --git a/apps/macos/Sources/Clawdbot/ViewMetrics.swift b/apps/macos/Sources/Moltbot/ViewMetrics.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/ViewMetrics.swift rename to apps/macos/Sources/Moltbot/ViewMetrics.swift diff --git a/apps/macos/Sources/Clawdbot/VisualEffectView.swift b/apps/macos/Sources/Moltbot/VisualEffectView.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/VisualEffectView.swift rename to apps/macos/Sources/Moltbot/VisualEffectView.swift diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeHelpers.swift b/apps/macos/Sources/Moltbot/VoiceWakeHelpers.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/VoiceWakeHelpers.swift rename to apps/macos/Sources/Moltbot/VoiceWakeHelpers.swift diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeOverlayController+Session.swift b/apps/macos/Sources/Moltbot/VoiceWakeOverlayController+Session.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/VoiceWakeOverlayController+Session.swift rename to apps/macos/Sources/Moltbot/VoiceWakeOverlayController+Session.swift diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeOverlayController+Testing.swift b/apps/macos/Sources/Moltbot/VoiceWakeOverlayController+Testing.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/VoiceWakeOverlayController+Testing.swift rename to apps/macos/Sources/Moltbot/VoiceWakeOverlayController+Testing.swift diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeOverlayController+Window.swift b/apps/macos/Sources/Moltbot/VoiceWakeOverlayController+Window.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/VoiceWakeOverlayController+Window.swift rename to apps/macos/Sources/Moltbot/VoiceWakeOverlayController+Window.swift diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeOverlayTextViews.swift b/apps/macos/Sources/Moltbot/VoiceWakeOverlayTextViews.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/VoiceWakeOverlayTextViews.swift rename to apps/macos/Sources/Moltbot/VoiceWakeOverlayTextViews.swift diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeOverlayView.swift b/apps/macos/Sources/Moltbot/VoiceWakeOverlayView.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/VoiceWakeOverlayView.swift rename to apps/macos/Sources/Moltbot/VoiceWakeOverlayView.swift diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeSettings.swift b/apps/macos/Sources/Moltbot/VoiceWakeSettings.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/VoiceWakeSettings.swift rename to apps/macos/Sources/Moltbot/VoiceWakeSettings.swift diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeTestCard.swift b/apps/macos/Sources/Moltbot/VoiceWakeTestCard.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/VoiceWakeTestCard.swift rename to apps/macos/Sources/Moltbot/VoiceWakeTestCard.swift diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeTextUtils.swift b/apps/macos/Sources/Moltbot/VoiceWakeTextUtils.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/VoiceWakeTextUtils.swift rename to apps/macos/Sources/Moltbot/VoiceWakeTextUtils.swift diff --git a/apps/macos/Sources/Clawdbot/WebChatManager.swift b/apps/macos/Sources/Moltbot/WebChatManager.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/WebChatManager.swift rename to apps/macos/Sources/Moltbot/WebChatManager.swift diff --git a/apps/macos/Sources/Clawdbot/WindowPlacement.swift b/apps/macos/Sources/Moltbot/WindowPlacement.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/WindowPlacement.swift rename to apps/macos/Sources/Moltbot/WindowPlacement.swift diff --git a/apps/macos/Sources/Clawdbot/WorkActivityStore.swift b/apps/macos/Sources/Moltbot/WorkActivityStore.swift similarity index 100% rename from apps/macos/Sources/Clawdbot/WorkActivityStore.swift rename to apps/macos/Sources/Moltbot/WorkActivityStore.swift diff --git a/apps/macos/Sources/ClawdbotDiscovery/WideAreaGatewayDiscovery.swift b/apps/macos/Sources/MoltbotDiscovery/WideAreaGatewayDiscovery.swift similarity index 100% rename from apps/macos/Sources/ClawdbotDiscovery/WideAreaGatewayDiscovery.swift rename to apps/macos/Sources/MoltbotDiscovery/WideAreaGatewayDiscovery.swift diff --git a/apps/macos/Sources/ClawdbotIPC/IPC.swift b/apps/macos/Sources/MoltbotIPC/IPC.swift similarity index 100% rename from apps/macos/Sources/ClawdbotIPC/IPC.swift rename to apps/macos/Sources/MoltbotIPC/IPC.swift diff --git a/apps/macos/Sources/ClawdbotMacCLI/ConnectCommand.swift b/apps/macos/Sources/MoltbotMacCLI/ConnectCommand.swift similarity index 100% rename from apps/macos/Sources/ClawdbotMacCLI/ConnectCommand.swift rename to apps/macos/Sources/MoltbotMacCLI/ConnectCommand.swift diff --git a/apps/macos/Sources/ClawdbotMacCLI/DiscoverCommand.swift b/apps/macos/Sources/MoltbotMacCLI/DiscoverCommand.swift similarity index 100% rename from apps/macos/Sources/ClawdbotMacCLI/DiscoverCommand.swift rename to apps/macos/Sources/MoltbotMacCLI/DiscoverCommand.swift diff --git a/apps/macos/Sources/ClawdbotMacCLI/EntryPoint.swift b/apps/macos/Sources/MoltbotMacCLI/EntryPoint.swift similarity index 100% rename from apps/macos/Sources/ClawdbotMacCLI/EntryPoint.swift rename to apps/macos/Sources/MoltbotMacCLI/EntryPoint.swift diff --git a/apps/macos/Sources/ClawdbotMacCLI/GatewayConfig.swift b/apps/macos/Sources/MoltbotMacCLI/GatewayConfig.swift similarity index 100% rename from apps/macos/Sources/ClawdbotMacCLI/GatewayConfig.swift rename to apps/macos/Sources/MoltbotMacCLI/GatewayConfig.swift diff --git a/apps/macos/Sources/ClawdbotMacCLI/TypeAliases.swift b/apps/macos/Sources/MoltbotMacCLI/TypeAliases.swift similarity index 100% rename from apps/macos/Sources/ClawdbotMacCLI/TypeAliases.swift rename to apps/macos/Sources/MoltbotMacCLI/TypeAliases.swift diff --git a/apps/macos/Sources/ClawdbotMacCLI/WizardCommand.swift b/apps/macos/Sources/MoltbotMacCLI/WizardCommand.swift similarity index 100% rename from apps/macos/Sources/ClawdbotMacCLI/WizardCommand.swift rename to apps/macos/Sources/MoltbotMacCLI/WizardCommand.swift diff --git a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift b/apps/macos/Sources/MoltbotProtocol/GatewayModels.swift similarity index 100% rename from apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift rename to apps/macos/Sources/MoltbotProtocol/GatewayModels.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/AgentEventStoreTests.swift b/apps/macos/Tests/MoltbotIPCTests/AgentEventStoreTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/AgentEventStoreTests.swift rename to apps/macos/Tests/MoltbotIPCTests/AgentEventStoreTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/AgentWorkspaceTests.swift b/apps/macos/Tests/MoltbotIPCTests/AgentWorkspaceTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/AgentWorkspaceTests.swift rename to apps/macos/Tests/MoltbotIPCTests/AgentWorkspaceTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthControlsSmokeTests.swift b/apps/macos/Tests/MoltbotIPCTests/AnthropicAuthControlsSmokeTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthControlsSmokeTests.swift rename to apps/macos/Tests/MoltbotIPCTests/AnthropicAuthControlsSmokeTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthResolverTests.swift b/apps/macos/Tests/MoltbotIPCTests/AnthropicAuthResolverTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthResolverTests.swift rename to apps/macos/Tests/MoltbotIPCTests/AnthropicAuthResolverTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/AnthropicOAuthCodeStateTests.swift b/apps/macos/Tests/MoltbotIPCTests/AnthropicOAuthCodeStateTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/AnthropicOAuthCodeStateTests.swift rename to apps/macos/Tests/MoltbotIPCTests/AnthropicOAuthCodeStateTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/AnyCodableEncodingTests.swift b/apps/macos/Tests/MoltbotIPCTests/AnyCodableEncodingTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/AnyCodableEncodingTests.swift rename to apps/macos/Tests/MoltbotIPCTests/AnyCodableEncodingTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/CLIInstallerTests.swift b/apps/macos/Tests/MoltbotIPCTests/CLIInstallerTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/CLIInstallerTests.swift rename to apps/macos/Tests/MoltbotIPCTests/CLIInstallerTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/CameraCaptureServiceTests.swift b/apps/macos/Tests/MoltbotIPCTests/CameraCaptureServiceTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/CameraCaptureServiceTests.swift rename to apps/macos/Tests/MoltbotIPCTests/CameraCaptureServiceTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/CameraIPCTests.swift b/apps/macos/Tests/MoltbotIPCTests/CameraIPCTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/CameraIPCTests.swift rename to apps/macos/Tests/MoltbotIPCTests/CameraIPCTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/CanvasFileWatcherTests.swift b/apps/macos/Tests/MoltbotIPCTests/CanvasFileWatcherTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/CanvasFileWatcherTests.swift rename to apps/macos/Tests/MoltbotIPCTests/CanvasFileWatcherTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/CanvasIPCTests.swift b/apps/macos/Tests/MoltbotIPCTests/CanvasIPCTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/CanvasIPCTests.swift rename to apps/macos/Tests/MoltbotIPCTests/CanvasIPCTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/CanvasWindowSmokeTests.swift b/apps/macos/Tests/MoltbotIPCTests/CanvasWindowSmokeTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/CanvasWindowSmokeTests.swift rename to apps/macos/Tests/MoltbotIPCTests/CanvasWindowSmokeTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/ChannelsSettingsSmokeTests.swift b/apps/macos/Tests/MoltbotIPCTests/ChannelsSettingsSmokeTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/ChannelsSettingsSmokeTests.swift rename to apps/macos/Tests/MoltbotIPCTests/ChannelsSettingsSmokeTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/ClawdbotConfigFileTests.swift b/apps/macos/Tests/MoltbotIPCTests/ClawdbotConfigFileTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/ClawdbotConfigFileTests.swift rename to apps/macos/Tests/MoltbotIPCTests/ClawdbotConfigFileTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/ClawdbotOAuthStoreTests.swift b/apps/macos/Tests/MoltbotIPCTests/ClawdbotOAuthStoreTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/ClawdbotOAuthStoreTests.swift rename to apps/macos/Tests/MoltbotIPCTests/ClawdbotOAuthStoreTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/CommandResolverTests.swift b/apps/macos/Tests/MoltbotIPCTests/CommandResolverTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/CommandResolverTests.swift rename to apps/macos/Tests/MoltbotIPCTests/CommandResolverTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/ConfigStoreTests.swift b/apps/macos/Tests/MoltbotIPCTests/ConfigStoreTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/ConfigStoreTests.swift rename to apps/macos/Tests/MoltbotIPCTests/ConfigStoreTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/CoverageDumpTests.swift b/apps/macos/Tests/MoltbotIPCTests/CoverageDumpTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/CoverageDumpTests.swift rename to apps/macos/Tests/MoltbotIPCTests/CoverageDumpTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/CritterIconRendererTests.swift b/apps/macos/Tests/MoltbotIPCTests/CritterIconRendererTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/CritterIconRendererTests.swift rename to apps/macos/Tests/MoltbotIPCTests/CritterIconRendererTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/CronJobEditorSmokeTests.swift b/apps/macos/Tests/MoltbotIPCTests/CronJobEditorSmokeTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/CronJobEditorSmokeTests.swift rename to apps/macos/Tests/MoltbotIPCTests/CronJobEditorSmokeTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/CronModelsTests.swift b/apps/macos/Tests/MoltbotIPCTests/CronModelsTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/CronModelsTests.swift rename to apps/macos/Tests/MoltbotIPCTests/CronModelsTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/DeviceModelCatalogTests.swift b/apps/macos/Tests/MoltbotIPCTests/DeviceModelCatalogTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/DeviceModelCatalogTests.swift rename to apps/macos/Tests/MoltbotIPCTests/DeviceModelCatalogTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/ExecAllowlistTests.swift b/apps/macos/Tests/MoltbotIPCTests/ExecAllowlistTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/ExecAllowlistTests.swift rename to apps/macos/Tests/MoltbotIPCTests/ExecAllowlistTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/ExecApprovalHelpersTests.swift b/apps/macos/Tests/MoltbotIPCTests/ExecApprovalHelpersTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/ExecApprovalHelpersTests.swift rename to apps/macos/Tests/MoltbotIPCTests/ExecApprovalHelpersTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/ExecApprovalsGatewayPrompterTests.swift b/apps/macos/Tests/MoltbotIPCTests/ExecApprovalsGatewayPrompterTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/ExecApprovalsGatewayPrompterTests.swift rename to apps/macos/Tests/MoltbotIPCTests/ExecApprovalsGatewayPrompterTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/FileHandleLegacyAPIGuardTests.swift b/apps/macos/Tests/MoltbotIPCTests/FileHandleLegacyAPIGuardTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/FileHandleLegacyAPIGuardTests.swift rename to apps/macos/Tests/MoltbotIPCTests/FileHandleLegacyAPIGuardTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/FileHandleSafeReadTests.swift b/apps/macos/Tests/MoltbotIPCTests/FileHandleSafeReadTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/FileHandleSafeReadTests.swift rename to apps/macos/Tests/MoltbotIPCTests/FileHandleSafeReadTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayAgentChannelTests.swift b/apps/macos/Tests/MoltbotIPCTests/GatewayAgentChannelTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/GatewayAgentChannelTests.swift rename to apps/macos/Tests/MoltbotIPCTests/GatewayAgentChannelTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayAutostartPolicyTests.swift b/apps/macos/Tests/MoltbotIPCTests/GatewayAutostartPolicyTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/GatewayAutostartPolicyTests.swift rename to apps/macos/Tests/MoltbotIPCTests/GatewayAutostartPolicyTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayChannelConfigureTests.swift b/apps/macos/Tests/MoltbotIPCTests/GatewayChannelConfigureTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/GatewayChannelConfigureTests.swift rename to apps/macos/Tests/MoltbotIPCTests/GatewayChannelConfigureTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayChannelConnectTests.swift b/apps/macos/Tests/MoltbotIPCTests/GatewayChannelConnectTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/GatewayChannelConnectTests.swift rename to apps/macos/Tests/MoltbotIPCTests/GatewayChannelConnectTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayChannelRequestTests.swift b/apps/macos/Tests/MoltbotIPCTests/GatewayChannelRequestTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/GatewayChannelRequestTests.swift rename to apps/macos/Tests/MoltbotIPCTests/GatewayChannelRequestTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayChannelShutdownTests.swift b/apps/macos/Tests/MoltbotIPCTests/GatewayChannelShutdownTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/GatewayChannelShutdownTests.swift rename to apps/macos/Tests/MoltbotIPCTests/GatewayChannelShutdownTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayConnectionControlTests.swift b/apps/macos/Tests/MoltbotIPCTests/GatewayConnectionControlTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/GatewayConnectionControlTests.swift rename to apps/macos/Tests/MoltbotIPCTests/GatewayConnectionControlTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayDiscoveryModelTests.swift b/apps/macos/Tests/MoltbotIPCTests/GatewayDiscoveryModelTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/GatewayDiscoveryModelTests.swift rename to apps/macos/Tests/MoltbotIPCTests/GatewayDiscoveryModelTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayEndpointStoreTests.swift b/apps/macos/Tests/MoltbotIPCTests/GatewayEndpointStoreTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/GatewayEndpointStoreTests.swift rename to apps/macos/Tests/MoltbotIPCTests/GatewayEndpointStoreTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayEnvironmentTests.swift b/apps/macos/Tests/MoltbotIPCTests/GatewayEnvironmentTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/GatewayEnvironmentTests.swift rename to apps/macos/Tests/MoltbotIPCTests/GatewayEnvironmentTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayFrameDecodeTests.swift b/apps/macos/Tests/MoltbotIPCTests/GatewayFrameDecodeTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/GatewayFrameDecodeTests.swift rename to apps/macos/Tests/MoltbotIPCTests/GatewayFrameDecodeTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayLaunchAgentManagerTests.swift b/apps/macos/Tests/MoltbotIPCTests/GatewayLaunchAgentManagerTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/GatewayLaunchAgentManagerTests.swift rename to apps/macos/Tests/MoltbotIPCTests/GatewayLaunchAgentManagerTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayProcessManagerTests.swift b/apps/macos/Tests/MoltbotIPCTests/GatewayProcessManagerTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/GatewayProcessManagerTests.swift rename to apps/macos/Tests/MoltbotIPCTests/GatewayProcessManagerTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/HealthDecodeTests.swift b/apps/macos/Tests/MoltbotIPCTests/HealthDecodeTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/HealthDecodeTests.swift rename to apps/macos/Tests/MoltbotIPCTests/HealthDecodeTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/HealthStoreStateTests.swift b/apps/macos/Tests/MoltbotIPCTests/HealthStoreStateTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/HealthStoreStateTests.swift rename to apps/macos/Tests/MoltbotIPCTests/HealthStoreStateTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/HoverHUDControllerTests.swift b/apps/macos/Tests/MoltbotIPCTests/HoverHUDControllerTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/HoverHUDControllerTests.swift rename to apps/macos/Tests/MoltbotIPCTests/HoverHUDControllerTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/InstancesSettingsSmokeTests.swift b/apps/macos/Tests/MoltbotIPCTests/InstancesSettingsSmokeTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/InstancesSettingsSmokeTests.swift rename to apps/macos/Tests/MoltbotIPCTests/InstancesSettingsSmokeTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/InstancesStoreTests.swift b/apps/macos/Tests/MoltbotIPCTests/InstancesStoreTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/InstancesStoreTests.swift rename to apps/macos/Tests/MoltbotIPCTests/InstancesStoreTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/LogLocatorTests.swift b/apps/macos/Tests/MoltbotIPCTests/LogLocatorTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/LogLocatorTests.swift rename to apps/macos/Tests/MoltbotIPCTests/LogLocatorTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/LowCoverageHelperTests.swift b/apps/macos/Tests/MoltbotIPCTests/LowCoverageHelperTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/LowCoverageHelperTests.swift rename to apps/macos/Tests/MoltbotIPCTests/LowCoverageHelperTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/LowCoverageViewSmokeTests.swift b/apps/macos/Tests/MoltbotIPCTests/LowCoverageViewSmokeTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/LowCoverageViewSmokeTests.swift rename to apps/macos/Tests/MoltbotIPCTests/LowCoverageViewSmokeTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/MacGatewayChatTransportMappingTests.swift b/apps/macos/Tests/MoltbotIPCTests/MacGatewayChatTransportMappingTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/MacGatewayChatTransportMappingTests.swift rename to apps/macos/Tests/MoltbotIPCTests/MacGatewayChatTransportMappingTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/MacNodeRuntimeTests.swift b/apps/macos/Tests/MoltbotIPCTests/MacNodeRuntimeTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/MacNodeRuntimeTests.swift rename to apps/macos/Tests/MoltbotIPCTests/MacNodeRuntimeTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/MasterDiscoveryMenuSmokeTests.swift b/apps/macos/Tests/MoltbotIPCTests/MasterDiscoveryMenuSmokeTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/MasterDiscoveryMenuSmokeTests.swift rename to apps/macos/Tests/MoltbotIPCTests/MasterDiscoveryMenuSmokeTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/MenuContentSmokeTests.swift b/apps/macos/Tests/MoltbotIPCTests/MenuContentSmokeTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/MenuContentSmokeTests.swift rename to apps/macos/Tests/MoltbotIPCTests/MenuContentSmokeTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/MenuSessionsInjectorTests.swift b/apps/macos/Tests/MoltbotIPCTests/MenuSessionsInjectorTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/MenuSessionsInjectorTests.swift rename to apps/macos/Tests/MoltbotIPCTests/MenuSessionsInjectorTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/ModelCatalogLoaderTests.swift b/apps/macos/Tests/MoltbotIPCTests/ModelCatalogLoaderTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/ModelCatalogLoaderTests.swift rename to apps/macos/Tests/MoltbotIPCTests/ModelCatalogLoaderTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/NodeManagerPathsTests.swift b/apps/macos/Tests/MoltbotIPCTests/NodeManagerPathsTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/NodeManagerPathsTests.swift rename to apps/macos/Tests/MoltbotIPCTests/NodeManagerPathsTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/NodePairingApprovalPrompterTests.swift b/apps/macos/Tests/MoltbotIPCTests/NodePairingApprovalPrompterTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/NodePairingApprovalPrompterTests.swift rename to apps/macos/Tests/MoltbotIPCTests/NodePairingApprovalPrompterTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/NodePairingReconcilePolicyTests.swift b/apps/macos/Tests/MoltbotIPCTests/NodePairingReconcilePolicyTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/NodePairingReconcilePolicyTests.swift rename to apps/macos/Tests/MoltbotIPCTests/NodePairingReconcilePolicyTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/OnboardingCoverageTests.swift b/apps/macos/Tests/MoltbotIPCTests/OnboardingCoverageTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/OnboardingCoverageTests.swift rename to apps/macos/Tests/MoltbotIPCTests/OnboardingCoverageTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/OnboardingViewSmokeTests.swift b/apps/macos/Tests/MoltbotIPCTests/OnboardingViewSmokeTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/OnboardingViewSmokeTests.swift rename to apps/macos/Tests/MoltbotIPCTests/OnboardingViewSmokeTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/OnboardingWizardStepViewTests.swift b/apps/macos/Tests/MoltbotIPCTests/OnboardingWizardStepViewTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/OnboardingWizardStepViewTests.swift rename to apps/macos/Tests/MoltbotIPCTests/OnboardingWizardStepViewTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/PermissionManagerLocationTests.swift b/apps/macos/Tests/MoltbotIPCTests/PermissionManagerLocationTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/PermissionManagerLocationTests.swift rename to apps/macos/Tests/MoltbotIPCTests/PermissionManagerLocationTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/PermissionManagerTests.swift b/apps/macos/Tests/MoltbotIPCTests/PermissionManagerTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/PermissionManagerTests.swift rename to apps/macos/Tests/MoltbotIPCTests/PermissionManagerTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/Placeholder.swift b/apps/macos/Tests/MoltbotIPCTests/Placeholder.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/Placeholder.swift rename to apps/macos/Tests/MoltbotIPCTests/Placeholder.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/RemotePortTunnelTests.swift b/apps/macos/Tests/MoltbotIPCTests/RemotePortTunnelTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/RemotePortTunnelTests.swift rename to apps/macos/Tests/MoltbotIPCTests/RemotePortTunnelTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/RuntimeLocatorTests.swift b/apps/macos/Tests/MoltbotIPCTests/RuntimeLocatorTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/RuntimeLocatorTests.swift rename to apps/macos/Tests/MoltbotIPCTests/RuntimeLocatorTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/ScreenshotSizeTests.swift b/apps/macos/Tests/MoltbotIPCTests/ScreenshotSizeTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/ScreenshotSizeTests.swift rename to apps/macos/Tests/MoltbotIPCTests/ScreenshotSizeTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/SemverTests.swift b/apps/macos/Tests/MoltbotIPCTests/SemverTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/SemverTests.swift rename to apps/macos/Tests/MoltbotIPCTests/SemverTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/SessionDataTests.swift b/apps/macos/Tests/MoltbotIPCTests/SessionDataTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/SessionDataTests.swift rename to apps/macos/Tests/MoltbotIPCTests/SessionDataTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/SessionMenuPreviewTests.swift b/apps/macos/Tests/MoltbotIPCTests/SessionMenuPreviewTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/SessionMenuPreviewTests.swift rename to apps/macos/Tests/MoltbotIPCTests/SessionMenuPreviewTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/SettingsViewSmokeTests.swift b/apps/macos/Tests/MoltbotIPCTests/SettingsViewSmokeTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/SettingsViewSmokeTests.swift rename to apps/macos/Tests/MoltbotIPCTests/SettingsViewSmokeTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/SkillsSettingsSmokeTests.swift b/apps/macos/Tests/MoltbotIPCTests/SkillsSettingsSmokeTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/SkillsSettingsSmokeTests.swift rename to apps/macos/Tests/MoltbotIPCTests/SkillsSettingsSmokeTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/TailscaleIntegrationSectionTests.swift b/apps/macos/Tests/MoltbotIPCTests/TailscaleIntegrationSectionTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/TailscaleIntegrationSectionTests.swift rename to apps/macos/Tests/MoltbotIPCTests/TailscaleIntegrationSectionTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/TalkAudioPlayerTests.swift b/apps/macos/Tests/MoltbotIPCTests/TalkAudioPlayerTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/TalkAudioPlayerTests.swift rename to apps/macos/Tests/MoltbotIPCTests/TalkAudioPlayerTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/TestIsolation.swift b/apps/macos/Tests/MoltbotIPCTests/TestIsolation.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/TestIsolation.swift rename to apps/macos/Tests/MoltbotIPCTests/TestIsolation.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/UtilitiesTests.swift b/apps/macos/Tests/MoltbotIPCTests/UtilitiesTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/UtilitiesTests.swift rename to apps/macos/Tests/MoltbotIPCTests/UtilitiesTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/VoicePushToTalkHotkeyTests.swift b/apps/macos/Tests/MoltbotIPCTests/VoicePushToTalkHotkeyTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/VoicePushToTalkHotkeyTests.swift rename to apps/macos/Tests/MoltbotIPCTests/VoicePushToTalkHotkeyTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/VoicePushToTalkTests.swift b/apps/macos/Tests/MoltbotIPCTests/VoicePushToTalkTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/VoicePushToTalkTests.swift rename to apps/macos/Tests/MoltbotIPCTests/VoicePushToTalkTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/VoiceWakeForwarderTests.swift b/apps/macos/Tests/MoltbotIPCTests/VoiceWakeForwarderTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/VoiceWakeForwarderTests.swift rename to apps/macos/Tests/MoltbotIPCTests/VoiceWakeForwarderTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/VoiceWakeGlobalSettingsSyncTests.swift b/apps/macos/Tests/MoltbotIPCTests/VoiceWakeGlobalSettingsSyncTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/VoiceWakeGlobalSettingsSyncTests.swift rename to apps/macos/Tests/MoltbotIPCTests/VoiceWakeGlobalSettingsSyncTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/VoiceWakeHelpersTests.swift b/apps/macos/Tests/MoltbotIPCTests/VoiceWakeHelpersTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/VoiceWakeHelpersTests.swift rename to apps/macos/Tests/MoltbotIPCTests/VoiceWakeHelpersTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/VoiceWakeOverlayControllerTests.swift b/apps/macos/Tests/MoltbotIPCTests/VoiceWakeOverlayControllerTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/VoiceWakeOverlayControllerTests.swift rename to apps/macos/Tests/MoltbotIPCTests/VoiceWakeOverlayControllerTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/VoiceWakeOverlayTests.swift b/apps/macos/Tests/MoltbotIPCTests/VoiceWakeOverlayTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/VoiceWakeOverlayTests.swift rename to apps/macos/Tests/MoltbotIPCTests/VoiceWakeOverlayTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/VoiceWakeOverlayViewSmokeTests.swift b/apps/macos/Tests/MoltbotIPCTests/VoiceWakeOverlayViewSmokeTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/VoiceWakeOverlayViewSmokeTests.swift rename to apps/macos/Tests/MoltbotIPCTests/VoiceWakeOverlayViewSmokeTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/VoiceWakeRuntimeTests.swift b/apps/macos/Tests/MoltbotIPCTests/VoiceWakeRuntimeTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/VoiceWakeRuntimeTests.swift rename to apps/macos/Tests/MoltbotIPCTests/VoiceWakeRuntimeTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/VoiceWakeTesterTests.swift b/apps/macos/Tests/MoltbotIPCTests/VoiceWakeTesterTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/VoiceWakeTesterTests.swift rename to apps/macos/Tests/MoltbotIPCTests/VoiceWakeTesterTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/WebChatMainSessionKeyTests.swift b/apps/macos/Tests/MoltbotIPCTests/WebChatMainSessionKeyTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/WebChatMainSessionKeyTests.swift rename to apps/macos/Tests/MoltbotIPCTests/WebChatMainSessionKeyTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/WebChatManagerTests.swift b/apps/macos/Tests/MoltbotIPCTests/WebChatManagerTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/WebChatManagerTests.swift rename to apps/macos/Tests/MoltbotIPCTests/WebChatManagerTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/WebChatSwiftUISmokeTests.swift b/apps/macos/Tests/MoltbotIPCTests/WebChatSwiftUISmokeTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/WebChatSwiftUISmokeTests.swift rename to apps/macos/Tests/MoltbotIPCTests/WebChatSwiftUISmokeTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/WideAreaGatewayDiscoveryTests.swift b/apps/macos/Tests/MoltbotIPCTests/WideAreaGatewayDiscoveryTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/WideAreaGatewayDiscoveryTests.swift rename to apps/macos/Tests/MoltbotIPCTests/WideAreaGatewayDiscoveryTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/WindowPlacementTests.swift b/apps/macos/Tests/MoltbotIPCTests/WindowPlacementTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/WindowPlacementTests.swift rename to apps/macos/Tests/MoltbotIPCTests/WindowPlacementTests.swift diff --git a/apps/macos/Tests/ClawdbotIPCTests/WorkActivityStoreTests.swift b/apps/macos/Tests/MoltbotIPCTests/WorkActivityStoreTests.swift similarity index 100% rename from apps/macos/Tests/ClawdbotIPCTests/WorkActivityStoreTests.swift rename to apps/macos/Tests/MoltbotIPCTests/WorkActivityStoreTests.swift diff --git a/apps/shared/ClawdbotKit/Package.swift b/apps/shared/ClawdbotKit/Package.swift deleted file mode 100644 index f86582a98..000000000 --- a/apps/shared/ClawdbotKit/Package.swift +++ /dev/null @@ -1,61 +0,0 @@ -// swift-tools-version: 6.2 - -import PackageDescription - -let package = Package( - name: "MoltbotKit", - platforms: [ - .iOS(.v18), - .macOS(.v15), - ], - products: [ - .library(name: "MoltbotProtocol", targets: ["MoltbotProtocol"]), - .library(name: "MoltbotKit", targets: ["MoltbotKit"]), - .library(name: "MoltbotChatUI", targets: ["MoltbotChatUI"]), - ], - dependencies: [ - .package(url: "https://github.com/steipete/ElevenLabsKit", exact: "0.1.0"), - .package(url: "https://github.com/gonzalezreal/textual", exact: "0.3.1"), - ], - targets: [ - .target( - name: "MoltbotProtocol", - path: "Sources/ClawdbotProtocol", - swiftSettings: [ - .enableUpcomingFeature("StrictConcurrency"), - ]), - .target( - name: "MoltbotKit", - path: "Sources/ClawdbotKit", - dependencies: [ - "MoltbotProtocol", - .product(name: "ElevenLabsKit", package: "ElevenLabsKit"), - ], - resources: [ - .process("Resources"), - ], - swiftSettings: [ - .enableUpcomingFeature("StrictConcurrency"), - ]), - .target( - name: "MoltbotChatUI", - path: "Sources/ClawdbotChatUI", - dependencies: [ - "MoltbotKit", - .product( - name: "Textual", - package: "textual", - condition: .when(platforms: [.macOS, .iOS])), - ], - swiftSettings: [ - .enableUpcomingFeature("StrictConcurrency"), - ]), - .testTarget( - name: "MoltbotKitTests", - dependencies: ["MoltbotKit", "MoltbotChatUI"], - path: "Tests/ClawdbotKitTests", - swiftSettings: [ - .enableUpcomingFeature("StrictConcurrency"), - .enableExperimentalFeature("SwiftTesting"), - ]), - ]) diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/AssistantTextParser.swift b/apps/shared/MoltbotKit/Sources/MoltbotChatUI/AssistantTextParser.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/AssistantTextParser.swift rename to apps/shared/MoltbotKit/Sources/MoltbotChatUI/AssistantTextParser.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatComposer.swift b/apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatComposer.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatComposer.swift rename to apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatComposer.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownPreprocessor.swift b/apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatMarkdownPreprocessor.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownPreprocessor.swift rename to apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatMarkdownPreprocessor.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownRenderer.swift b/apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatMarkdownRenderer.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownRenderer.swift rename to apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatMarkdownRenderer.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMessageViews.swift b/apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatMessageViews.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMessageViews.swift rename to apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatMessageViews.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatModels.swift b/apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatModels.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatModels.swift rename to apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatModels.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatPayloadDecoding.swift b/apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatPayloadDecoding.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatPayloadDecoding.swift rename to apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatPayloadDecoding.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatSessions.swift b/apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatSessions.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatSessions.swift rename to apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatSessions.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatSheets.swift b/apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatSheets.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatSheets.swift rename to apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatSheets.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatTheme.swift b/apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatTheme.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatTheme.swift rename to apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatTheme.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatTransport.swift b/apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatTransport.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatTransport.swift rename to apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatTransport.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatView.swift b/apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatView.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatView.swift rename to apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatView.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatViewModel.swift b/apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatViewModel.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatViewModel.swift rename to apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatViewModel.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/AnyCodable.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/AnyCodable.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/AnyCodable.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/AnyCodable.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/AsyncTimeout.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/AsyncTimeout.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/AsyncTimeout.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/AsyncTimeout.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/AudioStreamingProtocols.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/AudioStreamingProtocols.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/AudioStreamingProtocols.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/AudioStreamingProtocols.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/BonjourEscapes.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/BonjourEscapes.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/BonjourEscapes.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/BonjourEscapes.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/BonjourTypes.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/BonjourTypes.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/BonjourTypes.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/BonjourTypes.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/BridgeFrames.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/BridgeFrames.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/BridgeFrames.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/BridgeFrames.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/CameraCommands.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/CameraCommands.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/CameraCommands.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/CameraCommands.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/CanvasA2UIAction.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/CanvasA2UIAction.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/CanvasA2UIAction.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/CanvasA2UIAction.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/CanvasA2UICommands.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/CanvasA2UICommands.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/CanvasA2UICommands.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/CanvasA2UICommands.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/CanvasA2UIJSONL.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/CanvasA2UIJSONL.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/CanvasA2UIJSONL.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/CanvasA2UIJSONL.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/CanvasCommandParams.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/CanvasCommandParams.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/CanvasCommandParams.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/CanvasCommandParams.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/CanvasCommands.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/CanvasCommands.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/CanvasCommands.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/CanvasCommands.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/Capabilities.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/Capabilities.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/Capabilities.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/Capabilities.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/ClawdbotKitResources.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/ClawdbotKitResources.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/ClawdbotKitResources.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/ClawdbotKitResources.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/DeepLinks.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/DeepLinks.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/DeepLinks.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/DeepLinks.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/DeviceAuthStore.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/DeviceAuthStore.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/DeviceAuthStore.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/DeviceAuthStore.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/DeviceIdentity.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/DeviceIdentity.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/DeviceIdentity.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/DeviceIdentity.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/ElevenLabsKitShim.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/ElevenLabsKitShim.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/ElevenLabsKitShim.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/ElevenLabsKitShim.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayChannel.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayChannel.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayChannel.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayChannel.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayEndpointID.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayEndpointID.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayEndpointID.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayEndpointID.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayErrors.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayErrors.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayErrors.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayErrors.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayNodeSession.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayNodeSession.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayNodeSession.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayNodeSession.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayPayloadDecoding.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayPayloadDecoding.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayPayloadDecoding.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayPayloadDecoding.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayPush.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayPush.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayPush.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayPush.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayTLSPinning.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayTLSPinning.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayTLSPinning.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayTLSPinning.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/InstanceIdentity.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/InstanceIdentity.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/InstanceIdentity.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/InstanceIdentity.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/JPEGTranscoder.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/JPEGTranscoder.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/JPEGTranscoder.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/JPEGTranscoder.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/LocationCommands.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/LocationCommands.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/LocationCommands.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/LocationCommands.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/LocationSettings.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/LocationSettings.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/LocationSettings.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/LocationSettings.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/NodeError.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/NodeError.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/NodeError.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/NodeError.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/Resources/CanvasScaffold/scaffold.html b/apps/shared/MoltbotKit/Sources/MoltbotKit/Resources/CanvasScaffold/scaffold.html similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/Resources/CanvasScaffold/scaffold.html rename to apps/shared/MoltbotKit/Sources/MoltbotKit/Resources/CanvasScaffold/scaffold.html diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/Resources/tool-display.json b/apps/shared/MoltbotKit/Sources/MoltbotKit/Resources/tool-display.json similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/Resources/tool-display.json rename to apps/shared/MoltbotKit/Sources/MoltbotKit/Resources/tool-display.json diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/ScreenCommands.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/ScreenCommands.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/ScreenCommands.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/ScreenCommands.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/StoragePaths.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/StoragePaths.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/StoragePaths.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/StoragePaths.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/SystemCommands.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/SystemCommands.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/SystemCommands.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/SystemCommands.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/TalkDirective.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/TalkDirective.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/TalkDirective.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/TalkDirective.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/TalkHistoryTimestamp.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/TalkHistoryTimestamp.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/TalkHistoryTimestamp.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/TalkHistoryTimestamp.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/TalkPromptBuilder.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/TalkPromptBuilder.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/TalkPromptBuilder.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/TalkPromptBuilder.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/TalkSystemSpeechSynthesizer.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/TalkSystemSpeechSynthesizer.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/TalkSystemSpeechSynthesizer.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/TalkSystemSpeechSynthesizer.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/ToolDisplay.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/ToolDisplay.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotKit/ToolDisplay.swift rename to apps/shared/MoltbotKit/Sources/MoltbotKit/ToolDisplay.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotProtocol/AnyCodable.swift b/apps/shared/MoltbotKit/Sources/MoltbotProtocol/AnyCodable.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotProtocol/AnyCodable.swift rename to apps/shared/MoltbotKit/Sources/MoltbotProtocol/AnyCodable.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotProtocol/GatewayModels.swift b/apps/shared/MoltbotKit/Sources/MoltbotProtocol/GatewayModels.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotProtocol/GatewayModels.swift rename to apps/shared/MoltbotKit/Sources/MoltbotProtocol/GatewayModels.swift diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotProtocol/WizardHelpers.swift b/apps/shared/MoltbotKit/Sources/MoltbotProtocol/WizardHelpers.swift similarity index 100% rename from apps/shared/ClawdbotKit/Sources/ClawdbotProtocol/WizardHelpers.swift rename to apps/shared/MoltbotKit/Sources/MoltbotProtocol/WizardHelpers.swift diff --git a/apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/AssistantTextParserTests.swift b/apps/shared/MoltbotKit/Tests/MoltbotKitTests/AssistantTextParserTests.swift similarity index 100% rename from apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/AssistantTextParserTests.swift rename to apps/shared/MoltbotKit/Tests/MoltbotKitTests/AssistantTextParserTests.swift diff --git a/apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/BonjourEscapesTests.swift b/apps/shared/MoltbotKit/Tests/MoltbotKitTests/BonjourEscapesTests.swift similarity index 100% rename from apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/BonjourEscapesTests.swift rename to apps/shared/MoltbotKit/Tests/MoltbotKitTests/BonjourEscapesTests.swift diff --git a/apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/CanvasA2UIActionTests.swift b/apps/shared/MoltbotKit/Tests/MoltbotKitTests/CanvasA2UIActionTests.swift similarity index 100% rename from apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/CanvasA2UIActionTests.swift rename to apps/shared/MoltbotKit/Tests/MoltbotKitTests/CanvasA2UIActionTests.swift diff --git a/apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/CanvasA2UITests.swift b/apps/shared/MoltbotKit/Tests/MoltbotKitTests/CanvasA2UITests.swift similarity index 100% rename from apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/CanvasA2UITests.swift rename to apps/shared/MoltbotKit/Tests/MoltbotKitTests/CanvasA2UITests.swift diff --git a/apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/CanvasSnapshotFormatTests.swift b/apps/shared/MoltbotKit/Tests/MoltbotKitTests/CanvasSnapshotFormatTests.swift similarity index 100% rename from apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/CanvasSnapshotFormatTests.swift rename to apps/shared/MoltbotKit/Tests/MoltbotKitTests/CanvasSnapshotFormatTests.swift diff --git a/apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/ChatMarkdownPreprocessorTests.swift b/apps/shared/MoltbotKit/Tests/MoltbotKitTests/ChatMarkdownPreprocessorTests.swift similarity index 100% rename from apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/ChatMarkdownPreprocessorTests.swift rename to apps/shared/MoltbotKit/Tests/MoltbotKitTests/ChatMarkdownPreprocessorTests.swift diff --git a/apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/ChatThemeTests.swift b/apps/shared/MoltbotKit/Tests/MoltbotKitTests/ChatThemeTests.swift similarity index 100% rename from apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/ChatThemeTests.swift rename to apps/shared/MoltbotKit/Tests/MoltbotKitTests/ChatThemeTests.swift diff --git a/apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/ChatViewModelTests.swift b/apps/shared/MoltbotKit/Tests/MoltbotKitTests/ChatViewModelTests.swift similarity index 100% rename from apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/ChatViewModelTests.swift rename to apps/shared/MoltbotKit/Tests/MoltbotKitTests/ChatViewModelTests.swift diff --git a/apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/ElevenLabsTTSValidationTests.swift b/apps/shared/MoltbotKit/Tests/MoltbotKitTests/ElevenLabsTTSValidationTests.swift similarity index 100% rename from apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/ElevenLabsTTSValidationTests.swift rename to apps/shared/MoltbotKit/Tests/MoltbotKitTests/ElevenLabsTTSValidationTests.swift diff --git a/apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/GatewayNodeSessionTests.swift b/apps/shared/MoltbotKit/Tests/MoltbotKitTests/GatewayNodeSessionTests.swift similarity index 100% rename from apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/GatewayNodeSessionTests.swift rename to apps/shared/MoltbotKit/Tests/MoltbotKitTests/GatewayNodeSessionTests.swift diff --git a/apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/JPEGTranscoderTests.swift b/apps/shared/MoltbotKit/Tests/MoltbotKitTests/JPEGTranscoderTests.swift similarity index 100% rename from apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/JPEGTranscoderTests.swift rename to apps/shared/MoltbotKit/Tests/MoltbotKitTests/JPEGTranscoderTests.swift diff --git a/apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/TalkDirectiveTests.swift b/apps/shared/MoltbotKit/Tests/MoltbotKitTests/TalkDirectiveTests.swift similarity index 100% rename from apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/TalkDirectiveTests.swift rename to apps/shared/MoltbotKit/Tests/MoltbotKitTests/TalkDirectiveTests.swift diff --git a/apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/TalkHistoryTimestampTests.swift b/apps/shared/MoltbotKit/Tests/MoltbotKitTests/TalkHistoryTimestampTests.swift similarity index 100% rename from apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/TalkHistoryTimestampTests.swift rename to apps/shared/MoltbotKit/Tests/MoltbotKitTests/TalkHistoryTimestampTests.swift diff --git a/apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/TalkPromptBuilderTests.swift b/apps/shared/MoltbotKit/Tests/MoltbotKitTests/TalkPromptBuilderTests.swift similarity index 100% rename from apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/TalkPromptBuilderTests.swift rename to apps/shared/MoltbotKit/Tests/MoltbotKitTests/TalkPromptBuilderTests.swift diff --git a/apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/ToolDisplayRegistryTests.swift b/apps/shared/MoltbotKit/Tests/MoltbotKitTests/ToolDisplayRegistryTests.swift similarity index 100% rename from apps/shared/ClawdbotKit/Tests/ClawdbotKitTests/ToolDisplayRegistryTests.swift rename to apps/shared/MoltbotKit/Tests/MoltbotKitTests/ToolDisplayRegistryTests.swift diff --git a/apps/shared/ClawdbotKit/Tools/CanvasA2UI/bootstrap.js b/apps/shared/MoltbotKit/Tools/CanvasA2UI/bootstrap.js similarity index 100% rename from apps/shared/ClawdbotKit/Tools/CanvasA2UI/bootstrap.js rename to apps/shared/MoltbotKit/Tools/CanvasA2UI/bootstrap.js diff --git a/apps/shared/ClawdbotKit/Tools/CanvasA2UI/rolldown.config.mjs b/apps/shared/MoltbotKit/Tools/CanvasA2UI/rolldown.config.mjs similarity index 100% rename from apps/shared/ClawdbotKit/Tools/CanvasA2UI/rolldown.config.mjs rename to apps/shared/MoltbotKit/Tools/CanvasA2UI/rolldown.config.mjs