From cc72498b46f8a07cba1dd6709112417b22aef93e Mon Sep 17 00:00:00 2001 From: Shadow Date: Tue, 27 Jan 2026 14:12:17 -0600 Subject: [PATCH 1/8] Mac: finish Moltbot rename --- apps/android/app/build.gradle.kts | 2 +- apps/ios/README.md | 2 +- apps/ios/SwiftSources.input.xcfilelist | 62 +- apps/ios/project.yml | 2 +- apps/macos/Package.swift | 2 +- apps/macos/README.md | 4 +- .../Sources/Moltbot/AgentWorkspace.swift | 340 +++++++ .../Sources/Moltbot/AnthropicOAuth.swift | 384 +++++++ .../Moltbot/AudioInputDeviceObserver.swift | 216 ++++ .../Sources/Moltbot/CLIInstallPrompter.swift | 84 ++ .../Moltbot/CameraCaptureService.swift | 425 ++++++++ .../Sources/Moltbot/CanvasFileWatcher.swift | 94 ++ .../macos/Sources/Moltbot/CanvasManager.swift | 342 +++++++ .../Sources/Moltbot/CanvasSchemeHandler.swift | 259 +++++ apps/macos/Sources/Moltbot/CanvasWindow.swift | 26 + .../Sources/Moltbot/ClawdbotConfigFile.swift | 217 ++++ .../Sources/Moltbot/ConfigFileWatcher.swift | 118 +++ .../Moltbot/ConnectionModeCoordinator.swift | 79 ++ apps/macos/Sources/Moltbot/Constants.swift | 44 + .../Sources/Moltbot/ControlChannel.swift | 427 ++++++++ .../macos/Sources/Moltbot/CronJobsStore.swift | 200 ++++ apps/macos/Sources/Moltbot/DeepLinks.swift | 151 +++ .../DevicePairingApprovalPrompter.swift | 334 ++++++ .../Sources/Moltbot/DockIconManager.swift | 116 +++ .../macos/Sources/Moltbot/ExecApprovals.swift | 790 +++++++++++++++ .../ExecApprovalsGatewayPrompter.swift | 123 +++ .../Sources/Moltbot/ExecApprovalsSocket.swift | 831 +++++++++++++++ .../Sources/Moltbot/GatewayConnection.swift | 737 ++++++++++++++ .../GatewayConnectivityCoordinator.swift | 63 ++ .../Moltbot/GatewayEndpointStore.swift | 696 +++++++++++++ .../Sources/Moltbot/GatewayEnvironment.swift | 342 +++++++ .../Moltbot/GatewayLaunchAgentManager.swift | 203 ++++ .../Moltbot/GatewayProcessManager.swift | 432 ++++++++ apps/macos/Sources/Moltbot/HealthStore.swift | 301 ++++++ .../Sources/Moltbot/InstancesStore.swift | 394 ++++++++ .../Sources/Moltbot/LaunchAgentManager.swift | 95 ++ .../Moltbot/Logging/ClawdbotLogging.swift | 230 +++++ apps/macos/Sources/Moltbot/MenuBar.swift | 471 +++++++++ .../Sources/Moltbot/MicLevelMonitor.swift | 97 ++ .../Sources/Moltbot/ModelCatalogLoader.swift | 156 +++ .../NodeMode/MacNodeModeCoordinator.swift | 171 ++++ .../Moltbot/NodePairingApprovalPrompter.swift | 708 +++++++++++++ .../Sources/Moltbot/NodeServiceManager.swift | 150 +++ apps/macos/Sources/Moltbot/NodesStore.swift | 102 ++ .../Sources/Moltbot/NotificationManager.swift | 66 ++ .../Sources/Moltbot/OnboardingWizard.swift | 412 ++++++++ .../PeekabooBridgeHostCoordinator.swift | 130 +++ .../Sources/Moltbot/PermissionManager.swift | 506 ++++++++++ apps/macos/Sources/Moltbot/PortGuardian.swift | 418 ++++++++ .../Sources/Moltbot/PresenceReporter.swift | 158 +++ .../Sources/Moltbot/RemotePortTunnel.swift | 317 ++++++ .../Sources/Moltbot/RemoteTunnelManager.swift | 122 +++ .../Sources/Moltbot/Resources/Info.plist | 79 ++ .../Sources/Moltbot/RuntimeLocator.swift | 167 +++ .../Sources/Moltbot/ScreenRecordService.swift | 266 +++++ .../Moltbot/SessionMenuPreviewView.swift | 495 +++++++++ .../Sources/Moltbot/TailscaleService.swift | 226 +++++ .../Sources/Moltbot/TalkAudioPlayer.swift | 158 +++ .../Sources/Moltbot/TalkModeController.swift | 69 ++ .../Sources/Moltbot/TalkModeRuntime.swift | 953 ++++++++++++++++++ apps/macos/Sources/Moltbot/TalkOverlay.swift | 146 +++ .../Moltbot/TerminationSignalWatcher.swift | 53 + .../Sources/Moltbot/VoicePushToTalk.swift | 421 ++++++++ .../Moltbot/VoiceSessionCoordinator.swift | 134 +++ .../Sources/Moltbot/VoiceWakeChime.swift | 74 ++ .../Sources/Moltbot/VoiceWakeForwarder.swift | 73 ++ .../Moltbot/VoiceWakeGlobalSettingsSync.swift | 66 ++ .../Sources/Moltbot/VoiceWakeOverlay.swift | 60 ++ .../Sources/Moltbot/VoiceWakeRuntime.swift | 804 +++++++++++++++ .../Sources/Moltbot/VoiceWakeTester.swift | 473 +++++++++ .../Sources/Moltbot/WebChatSwiftUI.swift | 374 +++++++ .../GatewayDiscoveryModel.swift | 683 +++++++++++++ apps/shared/MoltbotKit/Package.swift | 61 ++ docs/concepts/typebox.md | 2 +- package.json | 4 +- scripts/bundle-a2ui.sh | 2 +- scripts/protocol-gen-swift.ts | 6 +- 77 files changed, 18956 insertions(+), 44 deletions(-) create mode 100644 apps/macos/Sources/Moltbot/AgentWorkspace.swift create mode 100644 apps/macos/Sources/Moltbot/AnthropicOAuth.swift create mode 100644 apps/macos/Sources/Moltbot/AudioInputDeviceObserver.swift create mode 100644 apps/macos/Sources/Moltbot/CLIInstallPrompter.swift create mode 100644 apps/macos/Sources/Moltbot/CameraCaptureService.swift create mode 100644 apps/macos/Sources/Moltbot/CanvasFileWatcher.swift create mode 100644 apps/macos/Sources/Moltbot/CanvasManager.swift create mode 100644 apps/macos/Sources/Moltbot/CanvasSchemeHandler.swift create mode 100644 apps/macos/Sources/Moltbot/CanvasWindow.swift create mode 100644 apps/macos/Sources/Moltbot/ClawdbotConfigFile.swift create mode 100644 apps/macos/Sources/Moltbot/ConfigFileWatcher.swift create mode 100644 apps/macos/Sources/Moltbot/ConnectionModeCoordinator.swift create mode 100644 apps/macos/Sources/Moltbot/Constants.swift create mode 100644 apps/macos/Sources/Moltbot/ControlChannel.swift create mode 100644 apps/macos/Sources/Moltbot/CronJobsStore.swift create mode 100644 apps/macos/Sources/Moltbot/DeepLinks.swift create mode 100644 apps/macos/Sources/Moltbot/DevicePairingApprovalPrompter.swift create mode 100644 apps/macos/Sources/Moltbot/DockIconManager.swift create mode 100644 apps/macos/Sources/Moltbot/ExecApprovals.swift create mode 100644 apps/macos/Sources/Moltbot/ExecApprovalsGatewayPrompter.swift create mode 100644 apps/macos/Sources/Moltbot/ExecApprovalsSocket.swift create mode 100644 apps/macos/Sources/Moltbot/GatewayConnection.swift create mode 100644 apps/macos/Sources/Moltbot/GatewayConnectivityCoordinator.swift create mode 100644 apps/macos/Sources/Moltbot/GatewayEndpointStore.swift create mode 100644 apps/macos/Sources/Moltbot/GatewayEnvironment.swift create mode 100644 apps/macos/Sources/Moltbot/GatewayLaunchAgentManager.swift create mode 100644 apps/macos/Sources/Moltbot/GatewayProcessManager.swift create mode 100644 apps/macos/Sources/Moltbot/HealthStore.swift create mode 100644 apps/macos/Sources/Moltbot/InstancesStore.swift create mode 100644 apps/macos/Sources/Moltbot/LaunchAgentManager.swift create mode 100644 apps/macos/Sources/Moltbot/Logging/ClawdbotLogging.swift create mode 100644 apps/macos/Sources/Moltbot/MenuBar.swift create mode 100644 apps/macos/Sources/Moltbot/MicLevelMonitor.swift create mode 100644 apps/macos/Sources/Moltbot/ModelCatalogLoader.swift create mode 100644 apps/macos/Sources/Moltbot/NodeMode/MacNodeModeCoordinator.swift create mode 100644 apps/macos/Sources/Moltbot/NodePairingApprovalPrompter.swift create mode 100644 apps/macos/Sources/Moltbot/NodeServiceManager.swift create mode 100644 apps/macos/Sources/Moltbot/NodesStore.swift create mode 100644 apps/macos/Sources/Moltbot/NotificationManager.swift create mode 100644 apps/macos/Sources/Moltbot/OnboardingWizard.swift create mode 100644 apps/macos/Sources/Moltbot/PeekabooBridgeHostCoordinator.swift create mode 100644 apps/macos/Sources/Moltbot/PermissionManager.swift create mode 100644 apps/macos/Sources/Moltbot/PortGuardian.swift create mode 100644 apps/macos/Sources/Moltbot/PresenceReporter.swift create mode 100644 apps/macos/Sources/Moltbot/RemotePortTunnel.swift create mode 100644 apps/macos/Sources/Moltbot/RemoteTunnelManager.swift create mode 100644 apps/macos/Sources/Moltbot/Resources/Info.plist create mode 100644 apps/macos/Sources/Moltbot/RuntimeLocator.swift create mode 100644 apps/macos/Sources/Moltbot/ScreenRecordService.swift create mode 100644 apps/macos/Sources/Moltbot/SessionMenuPreviewView.swift create mode 100644 apps/macos/Sources/Moltbot/TailscaleService.swift create mode 100644 apps/macos/Sources/Moltbot/TalkAudioPlayer.swift create mode 100644 apps/macos/Sources/Moltbot/TalkModeController.swift create mode 100644 apps/macos/Sources/Moltbot/TalkModeRuntime.swift create mode 100644 apps/macos/Sources/Moltbot/TalkOverlay.swift create mode 100644 apps/macos/Sources/Moltbot/TerminationSignalWatcher.swift create mode 100644 apps/macos/Sources/Moltbot/VoicePushToTalk.swift create mode 100644 apps/macos/Sources/Moltbot/VoiceSessionCoordinator.swift create mode 100644 apps/macos/Sources/Moltbot/VoiceWakeChime.swift create mode 100644 apps/macos/Sources/Moltbot/VoiceWakeForwarder.swift create mode 100644 apps/macos/Sources/Moltbot/VoiceWakeGlobalSettingsSync.swift create mode 100644 apps/macos/Sources/Moltbot/VoiceWakeOverlay.swift create mode 100644 apps/macos/Sources/Moltbot/VoiceWakeRuntime.swift create mode 100644 apps/macos/Sources/Moltbot/VoiceWakeTester.swift create mode 100644 apps/macos/Sources/Moltbot/WebChatSwiftUI.swift create mode 100644 apps/macos/Sources/MoltbotDiscovery/GatewayDiscoveryModel.swift create mode 100644 apps/shared/MoltbotKit/Package.swift diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index b9f7d7682..ef2fb8dd2 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -13,7 +13,7 @@ android { sourceSets { getByName("main") { - assets.srcDir(file("../../shared/ClawdbotKit/Sources/ClawdbotKit/Resources")) + assets.srcDir(file("../../shared/MoltbotKit/Sources/MoltbotKit/Resources")) } } diff --git a/apps/ios/README.md b/apps/ios/README.md index 72eb5f7e2..58aceff8b 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -15,7 +15,7 @@ open Clawdbot.xcodeproj ``` ## Shared packages -- `../shared/ClawdbotKit` — shared types/constants used by iOS (and later macOS bridge + gateway routing). +- `../shared/MoltbotKit` — shared types/constants used by iOS (and later macOS bridge + gateway routing). ## fastlane ```bash diff --git a/apps/ios/SwiftSources.input.xcfilelist b/apps/ios/SwiftSources.input.xcfilelist index 70d0f39d6..c9d7ff46c 100644 --- a/apps/ios/SwiftSources.input.xcfilelist +++ b/apps/ios/SwiftSources.input.xcfilelist @@ -24,37 +24,37 @@ Sources/Status/VoiceWakeToast.swift Sources/Voice/VoiceTab.swift Sources/Voice/VoiceWakeManager.swift Sources/Voice/VoiceWakePreferences.swift -../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatComposer.swift -../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownRenderer.swift -../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownPreprocessor.swift -../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMessageViews.swift -../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatModels.swift -../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatPayloadDecoding.swift -../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatSessions.swift -../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatSheets.swift -../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatTheme.swift -../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatTransport.swift -../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatView.swift -../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatViewModel.swift -../shared/ClawdbotKit/Sources/ClawdbotKit/AnyCodable.swift -../shared/ClawdbotKit/Sources/ClawdbotKit/BonjourEscapes.swift -../shared/ClawdbotKit/Sources/ClawdbotKit/BonjourTypes.swift -../shared/ClawdbotKit/Sources/ClawdbotKit/BridgeFrames.swift -../shared/ClawdbotKit/Sources/ClawdbotKit/CameraCommands.swift -../shared/ClawdbotKit/Sources/ClawdbotKit/CanvasA2UIAction.swift -../shared/ClawdbotKit/Sources/ClawdbotKit/CanvasA2UICommands.swift -../shared/ClawdbotKit/Sources/ClawdbotKit/CanvasA2UIJSONL.swift -../shared/ClawdbotKit/Sources/ClawdbotKit/CanvasCommandParams.swift -../shared/ClawdbotKit/Sources/ClawdbotKit/CanvasCommands.swift -../shared/ClawdbotKit/Sources/ClawdbotKit/Capabilities.swift -../shared/ClawdbotKit/Sources/ClawdbotKit/ClawdbotKitResources.swift -../shared/ClawdbotKit/Sources/ClawdbotKit/DeepLinks.swift -../shared/ClawdbotKit/Sources/ClawdbotKit/JPEGTranscoder.swift -../shared/ClawdbotKit/Sources/ClawdbotKit/NodeError.swift -../shared/ClawdbotKit/Sources/ClawdbotKit/ScreenCommands.swift -../shared/ClawdbotKit/Sources/ClawdbotKit/StoragePaths.swift -../shared/ClawdbotKit/Sources/ClawdbotKit/SystemCommands.swift -../shared/ClawdbotKit/Sources/ClawdbotKit/TalkDirective.swift +../shared/MoltbotKit/Sources/MoltbotChatUI/ChatComposer.swift +../shared/MoltbotKit/Sources/MoltbotChatUI/ChatMarkdownRenderer.swift +../shared/MoltbotKit/Sources/MoltbotChatUI/ChatMarkdownPreprocessor.swift +../shared/MoltbotKit/Sources/MoltbotChatUI/ChatMessageViews.swift +../shared/MoltbotKit/Sources/MoltbotChatUI/ChatModels.swift +../shared/MoltbotKit/Sources/MoltbotChatUI/ChatPayloadDecoding.swift +../shared/MoltbotKit/Sources/MoltbotChatUI/ChatSessions.swift +../shared/MoltbotKit/Sources/MoltbotChatUI/ChatSheets.swift +../shared/MoltbotKit/Sources/MoltbotChatUI/ChatTheme.swift +../shared/MoltbotKit/Sources/MoltbotChatUI/ChatTransport.swift +../shared/MoltbotKit/Sources/MoltbotChatUI/ChatView.swift +../shared/MoltbotKit/Sources/MoltbotChatUI/ChatViewModel.swift +../shared/MoltbotKit/Sources/MoltbotKit/AnyCodable.swift +../shared/MoltbotKit/Sources/MoltbotKit/BonjourEscapes.swift +../shared/MoltbotKit/Sources/MoltbotKit/BonjourTypes.swift +../shared/MoltbotKit/Sources/MoltbotKit/BridgeFrames.swift +../shared/MoltbotKit/Sources/MoltbotKit/CameraCommands.swift +../shared/MoltbotKit/Sources/MoltbotKit/CanvasA2UIAction.swift +../shared/MoltbotKit/Sources/MoltbotKit/CanvasA2UICommands.swift +../shared/MoltbotKit/Sources/MoltbotKit/CanvasA2UIJSONL.swift +../shared/MoltbotKit/Sources/MoltbotKit/CanvasCommandParams.swift +../shared/MoltbotKit/Sources/MoltbotKit/CanvasCommands.swift +../shared/MoltbotKit/Sources/MoltbotKit/Capabilities.swift +../shared/MoltbotKit/Sources/MoltbotKit/ClawdbotKitResources.swift +../shared/MoltbotKit/Sources/MoltbotKit/DeepLinks.swift +../shared/MoltbotKit/Sources/MoltbotKit/JPEGTranscoder.swift +../shared/MoltbotKit/Sources/MoltbotKit/NodeError.swift +../shared/MoltbotKit/Sources/MoltbotKit/ScreenCommands.swift +../shared/MoltbotKit/Sources/MoltbotKit/StoragePaths.swift +../shared/MoltbotKit/Sources/MoltbotKit/SystemCommands.swift +../shared/MoltbotKit/Sources/MoltbotKit/TalkDirective.swift ../../Swabble/Sources/SwabbleKit/WakeWordGate.swift Sources/Voice/TalkModeManager.swift Sources/Voice/TalkOrbOverlay.swift diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 2f6b0ec47..cdd16d4d1 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -11,7 +11,7 @@ settings: packages: MoltbotKit: - path: ../shared/ClawdbotKit + path: ../shared/MoltbotKit Swabble: path: ../../Swabble diff --git a/apps/macos/Package.swift b/apps/macos/Package.swift index ac6691493..b3cae1184 100644 --- a/apps/macos/Package.swift +++ b/apps/macos/Package.swift @@ -20,7 +20,7 @@ let package = Package( .package(url: "https://github.com/apple/swift-log.git", from: "1.8.0"), .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.8.1"), .package(url: "https://github.com/steipete/Peekaboo.git", branch: "main"), - .package(path: "../shared/ClawdbotKit"), + .package(path: "../shared/MoltbotKit"), .package(path: "../../Swabble"), ], targets: [ diff --git a/apps/macos/README.md b/apps/macos/README.md index ae35b772e..4a460d275 100644 --- a/apps/macos/README.md +++ b/apps/macos/README.md @@ -1,4 +1,4 @@ -# Clawdbot macOS app (dev + signing) +# Moltbot macOS app (dev + signing) ## Quick dev run @@ -20,7 +20,7 @@ scripts/restart-mac.sh --sign # force code signing (requires cert) scripts/package-mac-app.sh ``` -Creates `dist/Clawdbot.app` and signs it via `scripts/codesign-mac-app.sh`. +Creates `dist/Moltbot.app` and signs it via `scripts/codesign-mac-app.sh`. ## Signing behavior diff --git a/apps/macos/Sources/Moltbot/AgentWorkspace.swift b/apps/macos/Sources/Moltbot/AgentWorkspace.swift new file mode 100644 index 000000000..02e725a83 --- /dev/null +++ b/apps/macos/Sources/Moltbot/AgentWorkspace.swift @@ -0,0 +1,340 @@ +import Foundation +import OSLog + +enum AgentWorkspace { + private static let logger = Logger(subsystem: "bot.molt", category: "workspace") + static let agentsFilename = "AGENTS.md" + static let soulFilename = "SOUL.md" + static let identityFilename = "IDENTITY.md" + static let userFilename = "USER.md" + static let bootstrapFilename = "BOOTSTRAP.md" + private static let templateDirname = "templates" + private static let ignoredEntries: Set = [".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: "bot.molt", category: "anthropic-oauth") + + private static let clientId = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" + private static let authorizeURL = URL(string: "https://claude.ai/oauth/authorize")! + private static let tokenURL = URL(string: "https://console.anthropic.com/v1/oauth/token")! + private static let redirectURI = "https://console.anthropic.com/oauth/code/callback" + private static let scopes = "org:create_api_key user:profile user:inference" + + struct PKCE { + let verifier: String + let challenge: String + } + + static func generatePKCE() throws -> PKCE { + var bytes = [UInt8](repeating: 0, count: 32) + let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + guard status == errSecSuccess else { + throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) + } + let verifier = Data(bytes).base64URLEncodedString() + let hash = SHA256.hash(data: Data(verifier.utf8)) + let challenge = Data(hash).base64URLEncodedString() + return PKCE(verifier: verifier, challenge: challenge) + } + + static func buildAuthorizeURL(pkce: PKCE) -> URL { + var components = URLComponents(url: self.authorizeURL, resolvingAgainstBaseURL: false)! + components.queryItems = [ + URLQueryItem(name: "code", value: "true"), + URLQueryItem(name: "client_id", value: self.clientId), + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "redirect_uri", value: self.redirectURI), + URLQueryItem(name: "scope", value: self.scopes), + URLQueryItem(name: "code_challenge", value: pkce.challenge), + URLQueryItem(name: "code_challenge_method", value: "S256"), + // Match legacy flow: state is the verifier. + URLQueryItem(name: "state", value: pkce.verifier), + ] + return components.url! + } + + static func exchangeCode( + code: String, + state: String, + verifier: String) async throws -> AnthropicOAuthCredentials + { + let payload: [String: Any] = [ + "grant_type": "authorization_code", + "client_id": self.clientId, + "code": code, + "state": state, + "redirect_uri": self.redirectURI, + "code_verifier": verifier, + ] + let body = try JSONSerialization.data(withJSONObject: payload, options: []) + + var request = URLRequest(url: self.tokenURL) + request.httpMethod = "POST" + request.httpBody = body + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + guard (200..<300).contains(http.statusCode) else { + let text = String(data: data, encoding: .utf8) ?? "" + 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/Moltbot/AudioInputDeviceObserver.swift b/apps/macos/Sources/Moltbot/AudioInputDeviceObserver.swift new file mode 100644 index 000000000..4411016f5 --- /dev/null +++ b/apps/macos/Sources/Moltbot/AudioInputDeviceObserver.swift @@ -0,0 +1,216 @@ +import CoreAudio +import Foundation +import OSLog + +final class AudioInputDeviceObserver { + private let logger = Logger(subsystem: "bot.molt", category: "audio.devices") + private var isActive = false + private var devicesListener: AudioObjectPropertyListenerBlock? + private var defaultInputListener: AudioObjectPropertyListenerBlock? + + static func defaultInputDeviceUID() -> String? { + let systemObject = AudioObjectID(kAudioObjectSystemObject) + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultInputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + var deviceID = AudioObjectID(0) + var size = UInt32(MemoryLayout.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/Moltbot/CLIInstallPrompter.swift b/apps/macos/Sources/Moltbot/CLIInstallPrompter.swift new file mode 100644 index 000000000..b091fc8b5 --- /dev/null +++ b/apps/macos/Sources/Moltbot/CLIInstallPrompter.swift @@ -0,0 +1,84 @@ +import AppKit +import Foundation +import OSLog + +@MainActor +final class CLIInstallPrompter { + static let shared = CLIInstallPrompter() + private let logger = Logger(subsystem: "bot.molt", category: "cli.prompt") + private var isPrompting = false + + func checkAndPromptIfNeeded(reason: String) { + guard self.shouldPrompt() else { return } + guard let version = Self.appVersion() else { return } + self.isPrompting = true + UserDefaults.standard.set(version, forKey: cliInstallPromptedVersionKey) + + let alert = NSAlert() + alert.messageText = "Install Moltbot CLI?" + alert.informativeText = "Local mode needs the CLI so launchd can run the gateway." + alert.addButton(withTitle: "Install CLI") + alert.addButton(withTitle: "Not now") + alert.addButton(withTitle: "Open Settings") + let response = alert.runModal() + + switch response { + case .alertFirstButtonReturn: + Task { await self.installCLI() } + case .alertThirdButtonReturn: + self.openSettings(tab: .general) + default: + break + } + + self.logger.debug("cli install prompt handled reason=\(reason, privacy: .public)") + self.isPrompting = false + } + + private func shouldPrompt() -> Bool { + guard !self.isPrompting else { return false } + guard AppStateStore.shared.onboardingSeen else { return false } + guard AppStateStore.shared.connectionMode == .local else { return false } + guard CLIInstaller.installedLocation() == nil else { return false } + guard let version = Self.appVersion() else { return false } + let lastPrompt = UserDefaults.standard.string(forKey: cliInstallPromptedVersionKey) + return lastPrompt != version + } + + private func installCLI() async { + let status = StatusBox() + await CLIInstaller.install { message in + await status.set(message) + } + if let message = await status.get() { + let alert = NSAlert() + alert.messageText = "CLI install finished" + alert.informativeText = message + alert.runModal() + } + } + + private func openSettings(tab: SettingsTab) { + SettingsTabRouter.request(tab) + SettingsWindowOpener.shared.open() + DispatchQueue.main.async { + NotificationCenter.default.post(name: .clawdbotSelectSettingsTab, object: tab) + } + } + + private static func appVersion() -> String? { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String + } +} + +private actor StatusBox { + private var value: String? + + func set(_ value: String) { + self.value = value + } + + func get() -> String? { + self.value + } +} diff --git a/apps/macos/Sources/Moltbot/CameraCaptureService.swift b/apps/macos/Sources/Moltbot/CameraCaptureService.swift new file mode 100644 index 000000000..ee70a3006 --- /dev/null +++ b/apps/macos/Sources/Moltbot/CameraCaptureService.swift @@ -0,0 +1,425 @@ +import AVFoundation +import MoltbotIPC +import MoltbotKit +import CoreGraphics +import Foundation +import OSLog + +actor CameraCaptureService { + struct CameraDeviceInfo: Encodable, Sendable { + let id: String + let name: String + let position: String + let deviceType: String + } + + enum CameraError: LocalizedError, Sendable { + case cameraUnavailable + case microphoneUnavailable + case permissionDenied(kind: String) + case captureFailed(String) + case exportFailed(String) + + var errorDescription: String? { + switch self { + case .cameraUnavailable: + "Camera unavailable" + case .microphoneUnavailable: + "Microphone unavailable" + case let .permissionDenied(kind): + "\(kind) permission denied" + case let .captureFailed(msg): + msg + case let .exportFailed(msg): + msg + } + } + } + + private let logger = Logger(subsystem: "bot.molt", category: "camera") + + func listDevices() -> [CameraDeviceInfo] { + Self.availableCameras().map { device in + CameraDeviceInfo( + id: device.uniqueID, + name: device.localizedName, + position: Self.positionLabel(device.position), + deviceType: device.deviceType.rawValue) + } + } + + func snap( + facing: CameraFacing?, + maxWidth: Int?, + quality: Double?, + deviceId: String?, + delayMs: Int) async throws -> (data: Data, size: CGSize) + { + let facing = facing ?? .front + let normalized = Self.normalizeSnap(maxWidth: maxWidth, quality: quality) + let maxWidth = normalized.maxWidth + let quality = normalized.quality + let delayMs = max(0, delayMs) + let deviceId = deviceId?.trimmingCharacters(in: .whitespacesAndNewlines) + + try await self.ensureAccess(for: .video) + + let session = AVCaptureSession() + session.sessionPreset = .photo + + guard let device = Self.pickCamera(facing: facing, deviceId: deviceId) else { + throw CameraError.cameraUnavailable + } + + let input = try AVCaptureDeviceInput(device: device) + guard session.canAddInput(input) else { + throw CameraError.captureFailed("Failed to add camera input") + } + session.addInput(input) + + let output = AVCapturePhotoOutput() + guard session.canAddOutput(output) else { + throw CameraError.captureFailed("Failed to add photo output") + } + session.addOutput(output) + output.maxPhotoQualityPrioritization = .quality + + session.startRunning() + defer { session.stopRunning() } + await Self.warmUpCaptureSession() + await self.waitForExposureAndWhiteBalance(device: device) + await self.sleepDelayMs(delayMs) + + let settings: AVCapturePhotoSettings = { + if output.availablePhotoCodecTypes.contains(.jpeg) { + return AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.jpeg]) + } + return AVCapturePhotoSettings() + }() + settings.photoQualityPrioritization = .quality + + var delegate: PhotoCaptureDelegate? + let rawData: Data = try await withCheckedThrowingContinuation { cont in + let d = PhotoCaptureDelegate(cont) + delegate = d + output.capturePhoto(with: settings, delegate: d) + } + withExtendedLifetime(delegate) {} + + let maxPayloadBytes = 5 * 1024 * 1024 + // Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under 5MB (API limit). + let maxEncodedBytes = (maxPayloadBytes / 4) * 3 + let res = try JPEGTranscoder.transcodeToJPEG( + imageData: rawData, + maxWidthPx: maxWidth, + quality: quality, + maxBytes: maxEncodedBytes) + return (data: res.data, size: CGSize(width: res.widthPx, height: res.heightPx)) + } + + func clip( + facing: CameraFacing?, + durationMs: Int?, + includeAudio: Bool, + deviceId: String?, + outPath: String?) async throws -> (path: String, durationMs: Int, hasAudio: Bool) + { + let facing = facing ?? .front + let durationMs = Self.clampDurationMs(durationMs) + let deviceId = deviceId?.trimmingCharacters(in: .whitespacesAndNewlines) + + try await self.ensureAccess(for: .video) + if includeAudio { + try await self.ensureAccess(for: .audio) + } + + let session = AVCaptureSession() + session.sessionPreset = .high + + guard let camera = Self.pickCamera(facing: facing, deviceId: deviceId) else { + throw CameraError.cameraUnavailable + } + let cameraInput = try AVCaptureDeviceInput(device: camera) + guard session.canAddInput(cameraInput) else { + throw CameraError.captureFailed("Failed to add camera input") + } + session.addInput(cameraInput) + + if includeAudio { + guard let mic = AVCaptureDevice.default(for: .audio) else { + throw CameraError.microphoneUnavailable + } + let micInput = try AVCaptureDeviceInput(device: mic) + guard session.canAddInput(micInput) else { + throw CameraError.captureFailed("Failed to add microphone input") + } + session.addInput(micInput) + } + + let output = AVCaptureMovieFileOutput() + guard session.canAddOutput(output) else { + throw CameraError.captureFailed("Failed to add movie output") + } + session.addOutput(output) + output.maxRecordedDuration = CMTime(value: Int64(durationMs), timescale: 1000) + + session.startRunning() + defer { session.stopRunning() } + await Self.warmUpCaptureSession() + + let tmpMovURL = FileManager().temporaryDirectory + .appendingPathComponent("moltbot-camera-\(UUID().uuidString).mov") + defer { try? FileManager().removeItem(at: tmpMovURL) } + + let outputURL: URL = { + if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return URL(fileURLWithPath: outPath) + } + return FileManager().temporaryDirectory + .appendingPathComponent("moltbot-camera-\(UUID().uuidString).mp4") + }() + + // Ensure we don't fail exporting due to an existing file. + try? FileManager().removeItem(at: outputURL) + + let logger = self.logger + var delegate: MovieFileDelegate? + let recordedURL: URL = try await withCheckedThrowingContinuation { cont in + let d = MovieFileDelegate(cont, logger: logger) + delegate = d + output.startRecording(to: tmpMovURL, recordingDelegate: d) + } + withExtendedLifetime(delegate) {} + + try await Self.exportToMP4(inputURL: recordedURL, outputURL: outputURL) + return (path: outputURL.path, durationMs: durationMs, hasAudio: includeAudio) + } + + private func ensureAccess(for mediaType: AVMediaType) async throws { + let status = AVCaptureDevice.authorizationStatus(for: mediaType) + switch status { + case .authorized: + return + case .notDetermined: + let ok = await withCheckedContinuation(isolation: nil) { cont in + AVCaptureDevice.requestAccess(for: mediaType) { granted in + cont.resume(returning: granted) + } + } + if !ok { + throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone") + } + case .denied, .restricted: + throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone") + @unknown default: + throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone") + } + } + + private nonisolated static func availableCameras() -> [AVCaptureDevice] { + var types: [AVCaptureDevice.DeviceType] = [ + .builtInWideAngleCamera, + .continuityCamera, + ] + if let external = externalDeviceType() { + types.append(external) + } + let session = AVCaptureDevice.DiscoverySession( + deviceTypes: types, + mediaType: .video, + position: .unspecified) + return session.devices + } + + private nonisolated static func externalDeviceType() -> AVCaptureDevice.DeviceType? { + if #available(macOS 14.0, *) { + return .external + } + // Use raw value to avoid deprecated symbol in the SDK. + return AVCaptureDevice.DeviceType(rawValue: "AVCaptureDeviceTypeExternalUnknown") + } + + private nonisolated static func pickCamera( + facing: CameraFacing, + deviceId: String?) -> AVCaptureDevice? + { + if let deviceId, !deviceId.isEmpty { + if let match = availableCameras().first(where: { $0.uniqueID == deviceId }) { + return match + } + } + let position: AVCaptureDevice.Position = (facing == .front) ? .front : .back + + if let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position) { + return device + } + + // Many macOS cameras report `unspecified` position; fall back to any default. + return AVCaptureDevice.default(for: .video) + } + + private nonisolated static func clampQuality(_ quality: Double?) -> Double { + let q = quality ?? 0.9 + return min(1.0, max(0.05, q)) + } + + nonisolated static func normalizeSnap(maxWidth: Int?, quality: Double?) -> (maxWidth: Int, quality: Double) { + // Default to a reasonable max width to keep downstream payload sizes manageable. + // If you need full-res, explicitly request a larger maxWidth. + let maxWidth = maxWidth.flatMap { $0 > 0 ? $0 : nil } ?? 1600 + let quality = Self.clampQuality(quality) + return (maxWidth: maxWidth, quality: quality) + } + + private nonisolated static func clampDurationMs(_ ms: Int?) -> Int { + let v = ms ?? 3000 + return min(60000, max(250, v)) + } + + private nonisolated static func exportToMP4(inputURL: URL, outputURL: URL) async throws { + let asset = AVURLAsset(url: inputURL) + guard let export = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetMediumQuality) else { + throw CameraError.exportFailed("Failed to create export session") + } + export.shouldOptimizeForNetworkUse = true + + if #available(macOS 15.0, *) { + do { + try await export.export(to: outputURL, as: .mp4) + return + } catch { + throw CameraError.exportFailed(error.localizedDescription) + } + } else { + export.outputURL = outputURL + export.outputFileType = .mp4 + + try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation) 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/Moltbot/CanvasFileWatcher.swift b/apps/macos/Sources/Moltbot/CanvasFileWatcher.swift new file mode 100644 index 000000000..bef341fdc --- /dev/null +++ b/apps/macos/Sources/Moltbot/CanvasFileWatcher.swift @@ -0,0 +1,94 @@ +import CoreServices +import Foundation + +final class CanvasFileWatcher: @unchecked Sendable { + private let url: URL + private let queue: DispatchQueue + private var stream: FSEventStreamRef? + private var pending = false + private let onChange: () -> Void + + init(url: URL, onChange: @escaping () -> Void) { + self.url = url + self.queue = DispatchQueue(label: "bot.molt.canvaswatcher") + self.onChange = onChange + } + + deinit { + self.stop() + } + + func start() { + guard self.stream == nil else { return } + + let retainedSelf = Unmanaged.passRetained(self) + var context = FSEventStreamContext( + version: 0, + info: retainedSelf.toOpaque(), + retain: nil, + release: { pointer in + guard let pointer else { return } + Unmanaged.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/Moltbot/CanvasManager.swift b/apps/macos/Sources/Moltbot/CanvasManager.swift new file mode 100644 index 000000000..8100934ab --- /dev/null +++ b/apps/macos/Sources/Moltbot/CanvasManager.swift @@ -0,0 +1,342 @@ +import AppKit +import MoltbotIPC +import MoltbotKit +import Foundation +import OSLog + +@MainActor +final class CanvasManager { + static let shared = CanvasManager() + + private static let logger = Logger(subsystem: "bot.molt", category: "CanvasManager") + + private var panelController: CanvasWindowController? + private var panelSessionKey: String? + private var lastAutoA2UIUrl: String? + private var gatewayWatchTask: Task? + + 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/Moltbot/CanvasSchemeHandler.swift b/apps/macos/Sources/Moltbot/CanvasSchemeHandler.swift new file mode 100644 index 000000000..3e47026a2 --- /dev/null +++ b/apps/macos/Sources/Moltbot/CanvasSchemeHandler.swift @@ -0,0 +1,259 @@ +import MoltbotKit +import Foundation +import OSLog +import WebKit + +private let canvasLogger = Logger(subsystem: "bot.molt", category: "Canvas") + +final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler { + private let root: URL + + init(root: URL) { + self.root = root + } + + func webView(_: WKWebView, start urlSchemeTask: WKURLSchemeTask) { + guard let url = urlSchemeTask.request.url else { + urlSchemeTask.didFailWithError(NSError(domain: "Canvas", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "missing url", + ])) + return + } + + let response = self.response(for: url) + let mime = response.mime + let data = response.data + let encoding = self.textEncodingName(forMimeType: mime) + + let urlResponse = URLResponse( + url: url, + mimeType: mime, + expectedContentLength: data.count, + textEncodingName: encoding) + urlSchemeTask.didReceive(urlResponse) + urlSchemeTask.didReceive(data) + urlSchemeTask.didFinish() + } + + func webView(_: WKWebView, stop _: WKURLSchemeTask) { + // no-op + } + + private struct CanvasResponse { + let mime: String + let data: Data + } + + private func response(for url: URL) -> CanvasResponse { + guard url.scheme == CanvasScheme.scheme else { + return self.html("Invalid scheme.") + } + guard let session = url.host, !session.isEmpty else { + return self.html("Missing session.") + } + + // Keep session component safe; don't allow slashes or traversal. + if session.contains("/") || session.contains("..") { + return self.html("Invalid session.") + } + + let sessionRoot = self.root.appendingPathComponent(session, isDirectory: true) + + // Path mapping: request path maps directly into the session dir. + var path = url.path + if let qIdx = path.firstIndex(of: "?") { path = String(path[.. \(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/Moltbot/CanvasWindow.swift b/apps/macos/Sources/Moltbot/CanvasWindow.swift new file mode 100644 index 000000000..27306f88a --- /dev/null +++ b/apps/macos/Sources/Moltbot/CanvasWindow.swift @@ -0,0 +1,26 @@ +import AppKit + +let canvasWindowLogger = Logger(subsystem: "bot.molt", category: "Canvas") + +enum CanvasLayout { + static let panelSize = NSSize(width: 520, height: 680) + static let windowSize = NSSize(width: 1120, height: 840) + static let anchorPadding: CGFloat = 8 + static let defaultPadding: CGFloat = 10 + static let minPanelSize = NSSize(width: 360, height: 360) +} + +final class CanvasPanel: NSPanel { + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { true } +} + +enum CanvasPresentation { + case window + case panel(anchorProvider: () -> NSRect?) + + var isPanel: Bool { + if case .panel = self { return true } + return false + } +} diff --git a/apps/macos/Sources/Moltbot/ClawdbotConfigFile.swift b/apps/macos/Sources/Moltbot/ClawdbotConfigFile.swift new file mode 100644 index 000000000..2c796d4ea --- /dev/null +++ b/apps/macos/Sources/Moltbot/ClawdbotConfigFile.swift @@ -0,0 +1,217 @@ +import MoltbotProtocol +import Foundation + +enum MoltbotConfigFile { + private static let logger = Logger(subsystem: "bot.molt", category: "config") + + static func url() -> URL { + MoltbotPaths.configURL + } + + static func stateDirURL() -> URL { + MoltbotPaths.stateDirURL + } + + static func defaultWorkspaceURL() -> URL { + MoltbotPaths.workspaceURL + } + + static func loadDict() -> [String: Any] { + let url = self.url() + guard FileManager().fileExists(atPath: url.path) else { return [:] } + do { + let data = try Data(contentsOf: url) + guard let root = self.parseConfigData(data) else { + self.logger.warning("config JSON root invalid") + return [:] + } + return root + } catch { + self.logger.warning("config read failed: \(error.localizedDescription)") + return [:] + } + } + + static func saveDict(_ dict: [String: Any]) { + // Nix mode disables config writes in production, but tests rely on saving temp configs. + if ProcessInfo.processInfo.isNixMode, !ProcessInfo.processInfo.isRunningTests { return } + do { + let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys]) + let url = self.url() + try FileManager().createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + try data.write(to: url, options: [.atomic]) + } catch { + self.logger.error("config save failed: \(error.localizedDescription)") + } + } + + static func loadGatewayDict() -> [String: Any] { + let root = self.loadDict() + return root["gateway"] as? [String: Any] ?? [:] + } + + static func updateGatewayDict(_ mutate: (inout [String: Any]) -> Void) { + var root = self.loadDict() + var gateway = root["gateway"] as? [String: Any] ?? [:] + mutate(&gateway) + if gateway.isEmpty { + root.removeValue(forKey: "gateway") + } else { + root["gateway"] = gateway + } + self.saveDict(root) + } + + static func browserControlEnabled(defaultValue: Bool = true) -> Bool { + let root = self.loadDict() + let browser = root["browser"] as? [String: Any] + return browser?["enabled"] as? Bool ?? defaultValue + } + + static func setBrowserControlEnabled(_ enabled: Bool) { + var root = self.loadDict() + var browser = root["browser"] as? [String: Any] ?? [:] + browser["enabled"] = enabled + root["browser"] = browser + self.saveDict(root) + self.logger.debug("browser control updated enabled=\(enabled)") + } + + static func agentWorkspace() -> String? { + let root = self.loadDict() + let agents = root["agents"] as? [String: Any] + let defaults = agents?["defaults"] as? [String: Any] + return defaults?["workspace"] as? String + } + + static func setAgentWorkspace(_ workspace: String?) { + var root = self.loadDict() + var agents = root["agents"] as? [String: Any] ?? [:] + var defaults = agents["defaults"] as? [String: Any] ?? [:] + let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if trimmed.isEmpty { + defaults.removeValue(forKey: "workspace") + } else { + defaults["workspace"] = trimmed + } + if defaults.isEmpty { + agents.removeValue(forKey: "defaults") + } else { + agents["defaults"] = defaults + } + if agents.isEmpty { + root.removeValue(forKey: "agents") + } else { + root["agents"] = agents + } + self.saveDict(root) + self.logger.debug("agents.defaults.workspace updated set=\(!trimmed.isEmpty)") + } + + static func gatewayPassword() -> String? { + let root = self.loadDict() + guard let gateway = root["gateway"] as? [String: Any], + let remote = gateway["remote"] as? [String: Any] + else { + return nil + } + return remote["password"] as? String + } + + static func gatewayPort() -> Int? { + let root = self.loadDict() + guard let gateway = root["gateway"] as? [String: Any] else { return nil } + if let port = gateway["port"] as? Int, port > 0 { return port } + if let number = gateway["port"] as? NSNumber, number.intValue > 0 { + return number.intValue + } + if let raw = gateway["port"] as? String, + let parsed = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines)), + parsed > 0 + { + return parsed + } + return nil + } + + static func remoteGatewayPort() -> Int? { + guard let url = self.remoteGatewayUrl(), + let port = url.port, + port > 0 + else { return nil } + return port + } + + static func remoteGatewayPort(matchingHost sshHost: String) -> Int? { + let trimmedSshHost = sshHost.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedSshHost.isEmpty, + let url = self.remoteGatewayUrl(), + let port = url.port, + port > 0, + let urlHost = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), + !urlHost.isEmpty + else { + return nil + } + + let sshKey = Self.hostKey(trimmedSshHost) + let urlKey = Self.hostKey(urlHost) + guard !sshKey.isEmpty, !urlKey.isEmpty, sshKey == urlKey else { return nil } + return port + } + + static func setRemoteGatewayUrl(host: String, port: Int?) { + guard let port, port > 0 else { return } + let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedHost.isEmpty else { return } + self.updateGatewayDict { gateway in + var remote = gateway["remote"] as? [String: Any] ?? [:] + let existingUrl = (remote["url"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let scheme = URL(string: existingUrl)?.scheme ?? "ws" + remote["url"] = "\(scheme)://\(trimmedHost):\(port)" + gateway["remote"] = remote + } + } + + private static func remoteGatewayUrl() -> URL? { + let root = self.loadDict() + guard let gateway = root["gateway"] as? [String: Any], + let remote = gateway["remote"] as? [String: Any], + let raw = remote["url"] as? String + else { + return nil + } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let url = URL(string: trimmed) else { return nil } + return url + } + + private static func hostKey(_ host: String) -> String { + let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !trimmed.isEmpty else { return "" } + if trimmed.contains(":") { return trimmed } + let digits = CharacterSet(charactersIn: "0123456789.") + if trimmed.rangeOfCharacter(from: digits.inverted) == nil { + return trimmed + } + return trimmed.split(separator: ".").first.map(String.init) ?? trimmed + } + + private static func parseConfigData(_ data: Data) -> [String: Any]? { + if let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + return root + } + let decoder = JSONDecoder() + if #available(macOS 12.0, *) { + decoder.allowsJSON5 = true + } + if let decoded = try? decoder.decode([String: AnyCodable].self, from: data) { + self.logger.notice("config parsed with JSON5 decoder") + return decoded.mapValues { $0.foundationValue } + } + return nil + } +} diff --git a/apps/macos/Sources/Moltbot/ConfigFileWatcher.swift b/apps/macos/Sources/Moltbot/ConfigFileWatcher.swift new file mode 100644 index 000000000..b7904f73f --- /dev/null +++ b/apps/macos/Sources/Moltbot/ConfigFileWatcher.swift @@ -0,0 +1,118 @@ +import CoreServices +import Foundation + +final class ConfigFileWatcher: @unchecked Sendable { + private let url: URL + private let queue: DispatchQueue + private var stream: FSEventStreamRef? + private var pending = false + private let onChange: () -> Void + private let watchedDir: URL + private let targetPath: String + private let targetName: String + + init(url: URL, onChange: @escaping () -> Void) { + self.url = url + self.queue = DispatchQueue(label: "bot.molt.configwatcher") + self.onChange = onChange + self.watchedDir = url.deletingLastPathComponent() + self.targetPath = url.path + self.targetName = url.lastPathComponent + } + + deinit { + self.stop() + } + + func start() { + guard self.stream == nil else { return } + + let retainedSelf = Unmanaged.passRetained(self) + var context = FSEventStreamContext( + version: 0, + info: retainedSelf.toOpaque(), + retain: nil, + release: { pointer in + guard let pointer else { return } + Unmanaged.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/Moltbot/ConnectionModeCoordinator.swift b/apps/macos/Sources/Moltbot/ConnectionModeCoordinator.swift new file mode 100644 index 000000000..28bb5795b --- /dev/null +++ b/apps/macos/Sources/Moltbot/ConnectionModeCoordinator.swift @@ -0,0 +1,79 @@ +import Foundation +import OSLog + +@MainActor +final class ConnectionModeCoordinator { + static let shared = ConnectionModeCoordinator() + + private let logger = Logger(subsystem: "bot.molt", category: "connection") + private var lastMode: AppState.ConnectionMode? + + /// Apply the requested connection mode by starting/stopping local gateway, + /// managing the control-channel SSH tunnel, and cleaning up chat windows/panels. + func apply(mode: AppState.ConnectionMode, paused: Bool) async { + if let lastMode = self.lastMode, lastMode != mode { + GatewayProcessManager.shared.clearLastFailure() + NodesStore.shared.lastError = nil + } + self.lastMode = mode + switch mode { + case .unconfigured: + _ = await NodeServiceManager.stop() + NodesStore.shared.lastError = nil + await RemoteTunnelManager.shared.stopAll() + WebChatManager.shared.resetTunnels() + GatewayProcessManager.shared.stop() + await GatewayConnection.shared.shutdown() + await ControlChannel.shared.disconnect() + Task.detached { await PortGuardian.shared.sweep(mode: .unconfigured) } + + case .local: + _ = await NodeServiceManager.stop() + NodesStore.shared.lastError = nil + await RemoteTunnelManager.shared.stopAll() + WebChatManager.shared.resetTunnels() + let shouldStart = GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: paused) + if shouldStart { + GatewayProcessManager.shared.setActive(true) + if GatewayAutostartPolicy.shouldEnsureLaunchAgent( + mode: .local, + paused: paused) + { + Task { await GatewayProcessManager.shared.ensureLaunchAgentEnabledIfNeeded() } + } + _ = await GatewayProcessManager.shared.waitForGatewayReady() + } else { + GatewayProcessManager.shared.stop() + } + do { + try await ControlChannel.shared.configure(mode: .local) + } catch { + // Control channel will mark itself degraded; nothing else to do here. + self.logger.error( + "control channel local configure failed: \(error.localizedDescription, privacy: .public)") + } + Task.detached { await PortGuardian.shared.sweep(mode: .local) } + + case .remote: + // Never run a local gateway in remote mode. + GatewayProcessManager.shared.stop() + WebChatManager.shared.resetTunnels() + + do { + NodesStore.shared.lastError = nil + if let error = await NodeServiceManager.start() { + NodesStore.shared.lastError = "Node service start failed: \(error)" + } + _ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel() + let settings = CommandResolver.connectionSettings() + try await ControlChannel.shared.configure(mode: .remote( + target: settings.target, + identity: settings.identity)) + } catch { + self.logger.error("remote tunnel/configure failed: \(error.localizedDescription, privacy: .public)") + } + + Task.detached { await PortGuardian.shared.sweep(mode: .remote) } + } + } +} diff --git a/apps/macos/Sources/Moltbot/Constants.swift b/apps/macos/Sources/Moltbot/Constants.swift new file mode 100644 index 000000000..5905d3f1b --- /dev/null +++ b/apps/macos/Sources/Moltbot/Constants.swift @@ -0,0 +1,44 @@ +import Foundation + +let launchdLabel = "bot.molt.mac" +let gatewayLaunchdLabel = "bot.molt.gateway" +let onboardingVersionKey = "moltbot.onboardingVersion" +let currentOnboardingVersion = 7 +let pauseDefaultsKey = "moltbot.pauseEnabled" +let iconAnimationsEnabledKey = "moltbot.iconAnimationsEnabled" +let swabbleEnabledKey = "moltbot.swabbleEnabled" +let swabbleTriggersKey = "moltbot.swabbleTriggers" +let voiceWakeTriggerChimeKey = "moltbot.voiceWakeTriggerChime" +let voiceWakeSendChimeKey = "moltbot.voiceWakeSendChime" +let showDockIconKey = "moltbot.showDockIcon" +let defaultVoiceWakeTriggers = ["clawd", "claude"] +let voiceWakeMaxWords = 32 +let voiceWakeMaxWordLength = 64 +let voiceWakeMicKey = "moltbot.voiceWakeMicID" +let voiceWakeMicNameKey = "moltbot.voiceWakeMicName" +let voiceWakeLocaleKey = "moltbot.voiceWakeLocaleID" +let voiceWakeAdditionalLocalesKey = "moltbot.voiceWakeAdditionalLocaleIDs" +let voicePushToTalkEnabledKey = "moltbot.voicePushToTalkEnabled" +let talkEnabledKey = "moltbot.talkEnabled" +let iconOverrideKey = "moltbot.iconOverride" +let connectionModeKey = "moltbot.connectionMode" +let remoteTargetKey = "moltbot.remoteTarget" +let remoteIdentityKey = "moltbot.remoteIdentity" +let remoteProjectRootKey = "moltbot.remoteProjectRoot" +let remoteCliPathKey = "moltbot.remoteCliPath" +let canvasEnabledKey = "moltbot.canvasEnabled" +let cameraEnabledKey = "moltbot.cameraEnabled" +let systemRunPolicyKey = "moltbot.systemRunPolicy" +let systemRunAllowlistKey = "moltbot.systemRunAllowlist" +let systemRunEnabledKey = "moltbot.systemRunEnabled" +let locationModeKey = "moltbot.locationMode" +let locationPreciseKey = "moltbot.locationPreciseEnabled" +let peekabooBridgeEnabledKey = "moltbot.peekabooBridgeEnabled" +let deepLinkKeyKey = "moltbot.deepLinkKey" +let modelCatalogPathKey = "moltbot.modelCatalogPath" +let modelCatalogReloadKey = "moltbot.modelCatalogReload" +let cliInstallPromptedVersionKey = "moltbot.cliInstallPromptedVersion" +let heartbeatsEnabledKey = "moltbot.heartbeatsEnabled" +let debugFileLogEnabledKey = "moltbot.debug.fileLogEnabled" +let appLogLevelKey = "moltbot.debug.appLogLevel" +let voiceWakeSupported: Bool = ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26 diff --git a/apps/macos/Sources/Moltbot/ControlChannel.swift b/apps/macos/Sources/Moltbot/ControlChannel.swift new file mode 100644 index 000000000..2af7c721d --- /dev/null +++ b/apps/macos/Sources/Moltbot/ControlChannel.swift @@ -0,0 +1,427 @@ +import MoltbotKit +import MoltbotProtocol +import Foundation +import Observation +import SwiftUI + +struct ControlHeartbeatEvent: Codable { + let ts: Double + let status: String + let to: String? + let preview: String? + let durationMs: Double? + let hasMedia: Bool? + let reason: String? +} + +struct ControlAgentEvent: Codable, Sendable, Identifiable { + var id: String { "\(self.runId)-\(self.seq)" } + let runId: String + let seq: Int + let stream: String + let ts: Double + let data: [String: MoltbotProtocol.AnyCodable] + let summary: String? +} + +enum ControlChannelError: Error, LocalizedError { + case disconnected + case badResponse(String) + + var errorDescription: String? { + switch self { + case .disconnected: "Control channel disconnected" + case let .badResponse(msg): msg + } + } +} + +@MainActor +@Observable +final class ControlChannel { + static let shared = ControlChannel() + + enum Mode { + case local + case remote(target: String, identity: String) + } + + enum ConnectionState: Equatable { + case disconnected + case connecting + case connected + case degraded(String) + } + + private(set) var state: ConnectionState = .disconnected { + didSet { + CanvasManager.shared.refreshDebugStatus() + guard oldValue != self.state else { return } + switch self.state { + case .connected: + self.logger.info("control channel state -> connected") + case .connecting: + self.logger.info("control channel state -> connecting") + case .disconnected: + self.logger.info("control channel state -> disconnected") + self.scheduleRecovery(reason: "disconnected") + case let .degraded(message): + let detail = message.isEmpty ? "degraded" : "degraded: \(message)" + self.logger.info("control channel state -> \(detail, privacy: .public)") + self.scheduleRecovery(reason: message) + } + } + } + + private(set) var lastPingMs: Double? + private(set) var authSourceLabel: String? + + private let logger = Logger(subsystem: "bot.molt", category: "control") + + private var eventTask: Task? + 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/Moltbot/CronJobsStore.swift b/apps/macos/Sources/Moltbot/CronJobsStore.swift new file mode 100644 index 000000000..81503921b --- /dev/null +++ b/apps/macos/Sources/Moltbot/CronJobsStore.swift @@ -0,0 +1,200 @@ +import MoltbotKit +import MoltbotProtocol +import Foundation +import Observation +import OSLog + +@MainActor +@Observable +final class CronJobsStore { + static let shared = CronJobsStore() + + var jobs: [CronJob] = [] + var selectedJobId: String? + var runEntries: [CronRunLogEntry] = [] + + var schedulerEnabled: Bool? + var schedulerStorePath: String? + var schedulerNextWakeAtMs: Int? + + var isLoadingJobs = false + var isLoadingRuns = false + var lastError: String? + var statusMessage: String? + + private let logger = Logger(subsystem: "bot.molt", category: "cron.ui") + private var refreshTask: Task? + 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/Moltbot/DeepLinks.swift b/apps/macos/Sources/Moltbot/DeepLinks.swift new file mode 100644 index 000000000..1d8b42d96 --- /dev/null +++ b/apps/macos/Sources/Moltbot/DeepLinks.swift @@ -0,0 +1,151 @@ +import AppKit +import MoltbotKit +import Foundation +import OSLog +import Security + +private let deepLinkLogger = Logger(subsystem: "bot.molt", category: "DeepLink") + +@MainActor +final class DeepLinkHandler { + static let shared = DeepLinkHandler() + + private var lastPromptAt: Date = .distantPast + + // Ephemeral, in-memory key used for unattended deep links originating from the in-app Canvas. + // This avoids blocking Canvas init on UserDefaults and doesn't weaken the external deep-link prompt: + // outside callers can't know this randomly generated key. + private nonisolated static let canvasUnattendedKey: String = DeepLinkHandler.generateRandomKey() + + func handle(url: URL) async { + guard let route = DeepLinkParser.parse(url) else { + deepLinkLogger.debug("ignored url \(url.absoluteString, privacy: .public)") + return + } + guard !AppStateStore.shared.isPaused else { + self.presentAlert(title: "Moltbot is paused", message: "Unpause Moltbot to run agent actions.") + return + } + + switch route { + case let .agent(link): + await self.handleAgent(link: link, originalURL: url) + } + } + + private func handleAgent(link: AgentDeepLink, originalURL: URL) async { + let messagePreview = link.message.trimmingCharacters(in: .whitespacesAndNewlines) + if messagePreview.count > 20000 { + self.presentAlert(title: "Deep link too large", message: "Message exceeds 20,000 characters.") + return + } + + let allowUnattended = link.key == Self.canvasUnattendedKey || link.key == Self.expectedKey() + if !allowUnattended { + if Date().timeIntervalSince(self.lastPromptAt) < 1.0 { + deepLinkLogger.debug("throttling deep link prompt") + return + } + self.lastPromptAt = Date() + + let trimmed = messagePreview.count > 240 ? "\(messagePreview.prefix(240))…" : messagePreview + let body = + "Run the agent with this message?\n\n\(trimmed)\n\nURL:\n\(originalURL.absoluteString)" + guard self.confirm(title: "Run Moltbot agent?", message: body) else { return } + } + + if AppStateStore.shared.connectionMode == .local { + GatewayProcessManager.shared.setActive(true) + } + + do { + let channel = GatewayAgentChannel(raw: link.channel) + let explicitSessionKey = link.sessionKey? + .trimmingCharacters(in: .whitespacesAndNewlines) + .nonEmpty + let resolvedSessionKey: String = if let explicitSessionKey { + explicitSessionKey + } else { + await GatewayConnection.shared.mainSessionKey() + } + let invocation = GatewayAgentInvocation( + message: messagePreview, + sessionKey: resolvedSessionKey, + thinking: link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty, + deliver: channel.shouldDeliver(link.deliver), + to: link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty, + channel: channel, + timeoutSeconds: link.timeoutSeconds, + idempotencyKey: UUID().uuidString) + + let res = await GatewayConnection.shared.sendAgent(invocation) + if !res.ok { + throw NSError( + domain: "DeepLink", + code: 1, + userInfo: [NSLocalizedDescriptionKey: res.error ?? "agent request failed"]) + } + } catch { + self.presentAlert(title: "Agent request failed", message: error.localizedDescription) + } + } + + // MARK: - Auth + + static func currentKey() -> String { + self.expectedKey() + } + + static func currentCanvasKey() -> String { + self.canvasUnattendedKey + } + + private static func expectedKey() -> String { + let defaults = UserDefaults.standard + if let key = defaults.string(forKey: deepLinkKeyKey), !key.isEmpty { + return key + } + var bytes = [UInt8](repeating: 0, count: 32) + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + let data = Data(bytes) + let key = data + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + defaults.set(key, forKey: deepLinkKeyKey) + return key + } + + private nonisolated static func generateRandomKey() -> String { + var bytes = [UInt8](repeating: 0, count: 32) + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + let data = Data(bytes) + return data + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + // MARK: - UI + + private func confirm(title: String, message: String) -> Bool { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.addButton(withTitle: "Run") + alert.addButton(withTitle: "Cancel") + alert.alertStyle = .warning + return alert.runModal() == .alertFirstButtonReturn + } + + private func presentAlert(title: String, message: String) { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.addButton(withTitle: "OK") + alert.alertStyle = .informational + alert.runModal() + } +} diff --git a/apps/macos/Sources/Moltbot/DevicePairingApprovalPrompter.swift b/apps/macos/Sources/Moltbot/DevicePairingApprovalPrompter.swift new file mode 100644 index 000000000..39ec6d8ac --- /dev/null +++ b/apps/macos/Sources/Moltbot/DevicePairingApprovalPrompter.swift @@ -0,0 +1,334 @@ +import AppKit +import MoltbotKit +import MoltbotProtocol +import Foundation +import Observation +import OSLog + +@MainActor +@Observable +final class DevicePairingApprovalPrompter { + static let shared = DevicePairingApprovalPrompter() + + private let logger = Logger(subsystem: "bot.molt", category: "device-pairing") + private var task: Task? + 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/Moltbot/DockIconManager.swift b/apps/macos/Sources/Moltbot/DockIconManager.swift new file mode 100644 index 000000000..b00cfe953 --- /dev/null +++ b/apps/macos/Sources/Moltbot/DockIconManager.swift @@ -0,0 +1,116 @@ +import AppKit + +/// Central manager for Dock icon visibility. +/// Shows the Dock icon while any windows are visible, regardless of user preference. +final class DockIconManager: NSObject, @unchecked Sendable { + static let shared = DockIconManager() + + private var windowsObservation: NSKeyValueObservation? + private let logger = Logger(subsystem: "bot.molt", category: "DockIconManager") + + override private init() { + super.init() + self.setupObservers() + Task { @MainActor in + self.updateDockVisibility() + } + } + + deinit { + self.windowsObservation?.invalidate() + NotificationCenter.default.removeObserver(self) + } + + func updateDockVisibility() { + Task { @MainActor in + guard NSApp != nil else { + self.logger.warning("NSApp not ready, skipping Dock visibility update") + return + } + + let userWantsDockHidden = !UserDefaults.standard.bool(forKey: showDockIconKey) + let visibleWindows = NSApp?.windows.filter { window in + window.isVisible && + window.frame.width > 1 && + window.frame.height > 1 && + !window.isKind(of: NSPanel.self) && + "\(type(of: window))" != "NSPopupMenuWindow" && + window.contentViewController != nil + } ?? [] + + let hasVisibleWindows = !visibleWindows.isEmpty + if !userWantsDockHidden || hasVisibleWindows { + NSApp?.setActivationPolicy(.regular) + } else { + NSApp?.setActivationPolicy(.accessory) + } + } + } + + func temporarilyShowDock() { + Task { @MainActor in + guard NSApp != nil else { + self.logger.warning("NSApp not ready, cannot show Dock icon") + return + } + NSApp.setActivationPolicy(.regular) + } + } + + private func setupObservers() { + Task { @MainActor in + guard let app = NSApp else { + self.logger.warning("NSApp not ready, delaying Dock observers") + try? await Task.sleep(for: .milliseconds(200)) + self.setupObservers() + return + } + + self.windowsObservation = app.observe(\.windows, options: [.new]) { [weak self] _, _ in + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(50)) + self?.updateDockVisibility() + } + } + + NotificationCenter.default.addObserver( + self, + selector: #selector(self.windowVisibilityChanged), + name: NSWindow.didBecomeKeyNotification, + object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(self.windowVisibilityChanged), + name: NSWindow.didResignKeyNotification, + object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(self.windowVisibilityChanged), + name: NSWindow.willCloseNotification, + object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(self.dockPreferenceChanged), + name: UserDefaults.didChangeNotification, + object: nil) + } + } + + @objc + private func windowVisibilityChanged(_: Notification) { + Task { @MainActor in + self.updateDockVisibility() + } + } + + @objc + private func dockPreferenceChanged(_ notification: Notification) { + guard let userDefaults = notification.object as? UserDefaults, + userDefaults == UserDefaults.standard + else { return } + + Task { @MainActor in + self.updateDockVisibility() + } + } +} diff --git a/apps/macos/Sources/Moltbot/ExecApprovals.swift b/apps/macos/Sources/Moltbot/ExecApprovals.swift new file mode 100644 index 000000000..6fe92626c --- /dev/null +++ b/apps/macos/Sources/Moltbot/ExecApprovals.swift @@ -0,0 +1,790 @@ +import CryptoKit +import Foundation +import OSLog +import Security + +enum ExecSecurity: String, CaseIterable, Codable, Identifiable { + case deny + case allowlist + case full + + var id: String { self.rawValue } + + var title: String { + switch self { + case .deny: "Deny" + case .allowlist: "Allowlist" + case .full: "Always Allow" + } + } +} + +enum ExecApprovalQuickMode: String, CaseIterable, Identifiable { + case deny + case ask + case allow + + var id: String { self.rawValue } + + var title: String { + switch self { + case .deny: "Deny" + case .ask: "Always Ask" + case .allow: "Always Allow" + } + } + + var security: ExecSecurity { + switch self { + case .deny: .deny + case .ask: .allowlist + case .allow: .full + } + } + + var ask: ExecAsk { + switch self { + case .deny: .off + case .ask: .onMiss + case .allow: .off + } + } + + static func from(security: ExecSecurity, ask: ExecAsk) -> ExecApprovalQuickMode { + switch security { + case .deny: + .deny + case .full: + .allow + case .allowlist: + .ask + } + } +} + +enum ExecAsk: String, CaseIterable, Codable, Identifiable { + case off + case onMiss = "on-miss" + case always + + var id: String { self.rawValue } + + var title: String { + switch self { + case .off: "Never Ask" + case .onMiss: "Ask on Allowlist Miss" + case .always: "Always Ask" + } + } +} + +enum ExecApprovalDecision: String, Codable, Sendable { + case allowOnce = "allow-once" + case allowAlways = "allow-always" + case deny +} + +struct ExecAllowlistEntry: Codable, Hashable, Identifiable { + var id: UUID + var pattern: String + var lastUsedAt: Double? + var lastUsedCommand: String? + var lastResolvedPath: String? + + init( + id: UUID = UUID(), + pattern: String, + lastUsedAt: Double? = nil, + lastUsedCommand: String? = nil, + lastResolvedPath: String? = nil) + { + self.id = id + self.pattern = pattern + self.lastUsedAt = lastUsedAt + self.lastUsedCommand = lastUsedCommand + self.lastResolvedPath = lastResolvedPath + } + + private enum CodingKeys: String, CodingKey { + case id + case pattern + case lastUsedAt + case lastUsedCommand + case lastResolvedPath + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + self.pattern = try container.decode(String.self, forKey: .pattern) + self.lastUsedAt = try container.decodeIfPresent(Double.self, forKey: .lastUsedAt) + self.lastUsedCommand = try container.decodeIfPresent(String.self, forKey: .lastUsedCommand) + self.lastResolvedPath = try container.decodeIfPresent(String.self, forKey: .lastResolvedPath) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.id, forKey: .id) + try container.encode(self.pattern, forKey: .pattern) + try container.encodeIfPresent(self.lastUsedAt, forKey: .lastUsedAt) + try container.encodeIfPresent(self.lastUsedCommand, forKey: .lastUsedCommand) + try container.encodeIfPresent(self.lastResolvedPath, forKey: .lastResolvedPath) + } +} + +struct ExecApprovalsDefaults: Codable { + var security: ExecSecurity? + var ask: ExecAsk? + var askFallback: ExecSecurity? + var autoAllowSkills: Bool? +} + +struct ExecApprovalsAgent: Codable { + var security: ExecSecurity? + var ask: ExecAsk? + var askFallback: ExecSecurity? + var autoAllowSkills: Bool? + var allowlist: [ExecAllowlistEntry]? + + var isEmpty: Bool { + self.security == nil && self.ask == nil && self.askFallback == nil && self + .autoAllowSkills == nil && (self.allowlist?.isEmpty ?? true) + } +} + +struct ExecApprovalsSocketConfig: Codable { + var path: String? + var token: String? +} + +struct ExecApprovalsFile: Codable { + var version: Int + var socket: ExecApprovalsSocketConfig? + var defaults: ExecApprovalsDefaults? + var agents: [String: ExecApprovalsAgent]? +} + +struct ExecApprovalsSnapshot: Codable { + var path: String + var exists: Bool + var hash: String + var file: ExecApprovalsFile +} + +struct ExecApprovalsResolved { + let url: URL + let socketPath: String + let token: String + let defaults: ExecApprovalsResolvedDefaults + let agent: ExecApprovalsResolvedDefaults + let allowlist: [ExecAllowlistEntry] + var file: ExecApprovalsFile +} + +struct ExecApprovalsResolvedDefaults { + var security: ExecSecurity + var ask: ExecAsk + var askFallback: ExecSecurity + var autoAllowSkills: Bool +} + +enum ExecApprovalsStore { + private static let logger = Logger(subsystem: "bot.molt", category: "exec-approvals") + private static let defaultAgentId = "main" + private static let defaultSecurity: ExecSecurity = .deny + private static let defaultAsk: ExecAsk = .onMiss + private static let defaultAskFallback: ExecSecurity = .deny + private static let defaultAutoAllowSkills = false + + static func fileURL() -> URL { + MoltbotPaths.stateDirURL.appendingPathComponent("exec-approvals.json") + } + + static func socketPath() -> String { + MoltbotPaths.stateDirURL.appendingPathComponent("exec-approvals.sock").path + } + + static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile { + let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + var agents = file.agents ?? [:] + if let legacyDefault = agents["default"] { + if let main = agents[self.defaultAgentId] { + agents[self.defaultAgentId] = self.mergeAgents(current: main, legacy: legacyDefault) + } else { + agents[self.defaultAgentId] = legacyDefault + } + agents.removeValue(forKey: "default") + } + return ExecApprovalsFile( + version: 1, + socket: ExecApprovalsSocketConfig( + path: socketPath.isEmpty ? nil : socketPath, + token: token.isEmpty ? nil : token), + defaults: file.defaults, + agents: agents) + } + + static func readSnapshot() -> ExecApprovalsSnapshot { + let url = self.fileURL() + guard FileManager().fileExists(atPath: url.path) else { + return ExecApprovalsSnapshot( + path: url.path, + exists: false, + hash: self.hashRaw(nil), + file: ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])) + } + let raw = try? String(contentsOf: url, encoding: .utf8) + let data = raw.flatMap { $0.data(using: .utf8) } + let decoded: ExecApprovalsFile = { + if let data, let file = try? JSONDecoder().decode(ExecApprovalsFile.self, from: data), file.version == 1 { + return file + } + return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]) + }() + return ExecApprovalsSnapshot( + path: url.path, + exists: true, + hash: self.hashRaw(raw), + file: decoded) + } + + static func redactForSnapshot(_ file: ExecApprovalsFile) -> ExecApprovalsFile { + let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if socketPath.isEmpty { + return ExecApprovalsFile( + version: file.version, + socket: nil, + defaults: file.defaults, + agents: file.agents) + } + return ExecApprovalsFile( + version: file.version, + socket: ExecApprovalsSocketConfig(path: socketPath, token: nil), + defaults: file.defaults, + agents: file.agents) + } + + static func loadFile() -> ExecApprovalsFile { + let url = self.fileURL() + guard FileManager().fileExists(atPath: url.path) else { + return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]) + } + do { + let data = try Data(contentsOf: url) + let decoded = try JSONDecoder().decode(ExecApprovalsFile.self, from: data) + if decoded.version != 1 { + return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]) + } + return decoded + } catch { + self.logger.warning("exec approvals load failed: \(error.localizedDescription, privacy: .public)") + return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]) + } + } + + static func saveFile(_ file: ExecApprovalsFile) { + do { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(file) + let url = self.fileURL() + try FileManager().createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + try data.write(to: url, options: [.atomic]) + try? FileManager().setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + } catch { + self.logger.error("exec approvals save failed: \(error.localizedDescription, privacy: .public)") + } + } + + static func ensureFile() -> ExecApprovalsFile { + var file = self.loadFile() + if file.socket == nil { file.socket = ExecApprovalsSocketConfig(path: nil, token: nil) } + let path = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if path.isEmpty { + file.socket?.path = self.socketPath() + } + let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if token.isEmpty { + file.socket?.token = self.generateToken() + } + if file.agents == nil { file.agents = [:] } + self.saveFile(file) + return file + } + + static func resolve(agentId: String?) -> ExecApprovalsResolved { + let file = self.ensureFile() + let defaults = file.defaults ?? ExecApprovalsDefaults() + let resolvedDefaults = ExecApprovalsResolvedDefaults( + security: defaults.security ?? self.defaultSecurity, + ask: defaults.ask ?? self.defaultAsk, + askFallback: defaults.askFallback ?? self.defaultAskFallback, + autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills) + let key = self.agentKey(agentId) + let agentEntry = file.agents?[key] ?? ExecApprovalsAgent() + let wildcardEntry = file.agents?["*"] ?? ExecApprovalsAgent() + let resolvedAgent = ExecApprovalsResolvedDefaults( + security: agentEntry.security ?? wildcardEntry.security ?? resolvedDefaults.security, + ask: agentEntry.ask ?? wildcardEntry.ask ?? resolvedDefaults.ask, + askFallback: agentEntry.askFallback ?? wildcardEntry.askFallback + ?? resolvedDefaults.askFallback, + autoAllowSkills: agentEntry.autoAllowSkills ?? wildcardEntry.autoAllowSkills + ?? resolvedDefaults.autoAllowSkills) + let allowlist = ((wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? [])) + .map { entry in + ExecAllowlistEntry( + id: entry.id, + pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines), + lastUsedAt: entry.lastUsedAt, + lastUsedCommand: entry.lastUsedCommand, + lastResolvedPath: entry.lastResolvedPath) + } + .filter { !$0.pattern.isEmpty } + let socketPath = self.expandPath(file.socket?.path ?? self.socketPath()) + let token = file.socket?.token ?? "" + return ExecApprovalsResolved( + url: self.fileURL(), + socketPath: socketPath, + token: token, + defaults: resolvedDefaults, + agent: resolvedAgent, + allowlist: allowlist, + file: file) + } + + static func resolveDefaults() -> ExecApprovalsResolvedDefaults { + let file = self.ensureFile() + let defaults = file.defaults ?? ExecApprovalsDefaults() + return ExecApprovalsResolvedDefaults( + security: defaults.security ?? self.defaultSecurity, + ask: defaults.ask ?? self.defaultAsk, + askFallback: defaults.askFallback ?? self.defaultAskFallback, + autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills) + } + + static func saveDefaults(_ defaults: ExecApprovalsDefaults) { + self.updateFile { file in + file.defaults = defaults + } + } + + static func updateDefaults(_ mutate: (inout ExecApprovalsDefaults) -> Void) { + self.updateFile { file in + var defaults = file.defaults ?? ExecApprovalsDefaults() + mutate(&defaults) + file.defaults = defaults + } + } + + static func saveAgent(_ agent: ExecApprovalsAgent, agentId: String?) { + self.updateFile { file in + var agents = file.agents ?? [:] + let key = self.agentKey(agentId) + if agent.isEmpty { + agents.removeValue(forKey: key) + } else { + agents[key] = agent + } + file.agents = agents.isEmpty ? nil : agents + } + } + + static func addAllowlistEntry(agentId: String?, pattern: String) { + let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + self.updateFile { file in + let key = self.agentKey(agentId) + var agents = file.agents ?? [:] + var entry = agents[key] ?? ExecApprovalsAgent() + var allowlist = entry.allowlist ?? [] + if allowlist.contains(where: { $0.pattern == trimmed }) { return } + allowlist.append(ExecAllowlistEntry(pattern: trimmed, lastUsedAt: Date().timeIntervalSince1970 * 1000)) + entry.allowlist = allowlist + agents[key] = entry + file.agents = agents + } + } + + static func recordAllowlistUse( + agentId: String?, + pattern: String, + command: String, + resolvedPath: String?) + { + self.updateFile { file in + let key = self.agentKey(agentId) + var agents = file.agents ?? [:] + var entry = agents[key] ?? ExecApprovalsAgent() + let allowlist = (entry.allowlist ?? []).map { item -> ExecAllowlistEntry in + guard item.pattern == pattern else { return item } + return ExecAllowlistEntry( + id: item.id, + pattern: item.pattern, + lastUsedAt: Date().timeIntervalSince1970 * 1000, + lastUsedCommand: command, + lastResolvedPath: resolvedPath) + } + entry.allowlist = allowlist + agents[key] = entry + file.agents = agents + } + } + + static func updateAllowlist(agentId: String?, allowlist: [ExecAllowlistEntry]) { + self.updateFile { file in + let key = self.agentKey(agentId) + var agents = file.agents ?? [:] + var entry = agents[key] ?? ExecApprovalsAgent() + let cleaned = allowlist + .map { item in + ExecAllowlistEntry( + id: item.id, + pattern: item.pattern.trimmingCharacters(in: .whitespacesAndNewlines), + lastUsedAt: item.lastUsedAt, + lastUsedCommand: item.lastUsedCommand, + lastResolvedPath: item.lastResolvedPath) + } + .filter { !$0.pattern.isEmpty } + entry.allowlist = cleaned + agents[key] = entry + file.agents = agents + } + } + + static func updateAgentSettings(agentId: String?, mutate: (inout ExecApprovalsAgent) -> Void) { + self.updateFile { file in + let key = self.agentKey(agentId) + var agents = file.agents ?? [:] + var entry = agents[key] ?? ExecApprovalsAgent() + mutate(&entry) + if entry.isEmpty { + agents.removeValue(forKey: key) + } else { + agents[key] = entry + } + file.agents = agents.isEmpty ? nil : agents + } + } + + private static func updateFile(_ mutate: (inout ExecApprovalsFile) -> Void) { + var file = self.ensureFile() + mutate(&file) + self.saveFile(file) + } + + private static func generateToken() -> String { + var bytes = [UInt8](repeating: 0, count: 24) + let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + if status == errSecSuccess { + return Data(bytes) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + return UUID().uuidString + } + + private static func hashRaw(_ raw: String?) -> String { + let data = Data((raw ?? "").utf8) + let digest = SHA256.hash(data: data) + return digest.map { String(format: "%02x", $0) }.joined() + } + + private static func expandPath(_ raw: String) -> String { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed == "~" { + return FileManager().homeDirectoryForCurrentUser.path + } + if trimmed.hasPrefix("~/") { + let suffix = trimmed.dropFirst(2) + return FileManager().homeDirectoryForCurrentUser + .appendingPathComponent(String(suffix)).path + } + return trimmed + } + + private static func agentKey(_ agentId: String?) -> String { + let trimmed = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? self.defaultAgentId : trimmed + } + + private static func normalizedPattern(_ pattern: String?) -> String? { + let trimmed = pattern?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed.lowercased() + } + + private static func mergeAgents( + current: ExecApprovalsAgent, + legacy: ExecApprovalsAgent) -> ExecApprovalsAgent + { + var seen = Set() + 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/Moltbot/ExecApprovalsGatewayPrompter.swift b/apps/macos/Sources/Moltbot/ExecApprovalsGatewayPrompter.swift new file mode 100644 index 000000000..02b344b58 --- /dev/null +++ b/apps/macos/Sources/Moltbot/ExecApprovalsGatewayPrompter.swift @@ -0,0 +1,123 @@ +import MoltbotKit +import MoltbotProtocol +import CoreGraphics +import Foundation +import OSLog + +@MainActor +final class ExecApprovalsGatewayPrompter { + static let shared = ExecApprovalsGatewayPrompter() + + private let logger = Logger(subsystem: "bot.molt", category: "exec-approvals.gateway") + private var task: Task? + + 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/Moltbot/ExecApprovalsSocket.swift b/apps/macos/Sources/Moltbot/ExecApprovalsSocket.swift new file mode 100644 index 000000000..dea2bd5df --- /dev/null +++ b/apps/macos/Sources/Moltbot/ExecApprovalsSocket.swift @@ -0,0 +1,831 @@ +import AppKit +import MoltbotKit +import CryptoKit +import Darwin +import Foundation +import OSLog + +struct ExecApprovalPromptRequest: Codable, Sendable { + var command: String + var cwd: String? + var host: String? + var security: String? + var ask: String? + var agentId: String? + var resolvedPath: String? + var sessionKey: String? +} + +private struct ExecApprovalSocketRequest: Codable { + var type: String + var token: String + var id: String + var request: ExecApprovalPromptRequest +} + +private struct ExecApprovalSocketDecision: Codable { + var type: String + var id: String + var decision: ExecApprovalDecision +} + +private struct ExecHostSocketRequest: Codable { + var type: String + var id: String + var nonce: String + var ts: Int + var hmac: String + var requestJson: String +} + +private struct ExecHostRequest: Codable { + var command: [String] + var rawCommand: String? + var cwd: String? + var env: [String: String]? + var timeoutMs: Int? + var needsScreenRecording: Bool? + var agentId: String? + var sessionKey: String? + var approvalDecision: ExecApprovalDecision? +} + +private struct ExecHostRunResult: Codable { + var exitCode: Int? + var timedOut: Bool + var success: Bool + var stdout: String + var stderr: String + var error: String? +} + +private struct ExecHostError: Codable { + var code: String + var message: String + var reason: String? +} + +private struct ExecHostResponse: Codable { + var type: String + var id: String + var ok: Bool + var payload: ExecHostRunResult? + var error: ExecHostError? +} + +enum ExecApprovalsSocketClient { + private struct TimeoutError: LocalizedError { + var message: String + var errorDescription: String? { self.message } + } + + static func requestDecision( + socketPath: String, + token: String, + request: ExecApprovalPromptRequest, + timeoutMs: Int = 15000) async -> ExecApprovalDecision? + { + let trimmedPath = socketPath.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedPath.isEmpty, !trimmedToken.isEmpty else { return nil } + do { + return try await AsyncTimeout.withTimeoutMs( + timeoutMs: timeoutMs, + onTimeout: { + TimeoutError(message: "exec approvals socket timeout") + }, + operation: { + try await Task.detached { + try self.requestDecisionSync( + socketPath: trimmedPath, + token: trimmedToken, + request: request) + }.value + }) + } catch { + return nil + } + } + + private static func requestDecisionSync( + socketPath: String, + token: String, + request: ExecApprovalPromptRequest) throws -> ExecApprovalDecision? + { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + throw NSError(domain: "ExecApprovals", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "socket create failed", + ]) + } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let maxLen = MemoryLayout.size(ofValue: addr.sun_path) + if socketPath.utf8.count >= maxLen { + throw NSError(domain: "ExecApprovals", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "socket path too long", + ]) + } + socketPath.withCString { cstr in + withUnsafeMutablePointer(to: &addr.sun_path) { ptr in + let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: Int8.self) + strncpy(raw, cstr, maxLen - 1) + } + } + let size = socklen_t(MemoryLayout.size(ofValue: addr)) + let result = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in + connect(fd, rebound, size) + } + } + if result != 0 { + throw NSError(domain: "ExecApprovals", code: 3, userInfo: [ + NSLocalizedDescriptionKey: "socket connect failed", + ]) + } + + let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true) + + let message = ExecApprovalSocketRequest( + type: "request", + token: token, + id: UUID().uuidString, + request: request) + let data = try JSONEncoder().encode(message) + var payload = data + payload.append(0x0A) + try handle.write(contentsOf: payload) + + guard let line = try self.readLine(from: handle, maxBytes: 256_000), + let lineData = line.data(using: .utf8) + else { return nil } + let response = try JSONDecoder().decode(ExecApprovalSocketDecision.self, from: lineData) + return response.decision + } + + private static func readLine(from handle: FileHandle, maxBytes: Int) throws -> String? { + var buffer = Data() + while buffer.count < maxBytes { + let chunk = try handle.read(upToCount: 4096) ?? Data() + if chunk.isEmpty { break } + buffer.append(chunk) + if buffer.contains(0x0A) { break } + } + guard let newlineIndex = buffer.firstIndex(of: 0x0A) else { + guard !buffer.isEmpty else { return nil } + return String(data: buffer, encoding: .utf8) + } + let lineData = buffer.subdata(in: 0.. 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: "bot.molt", category: "exec-approvals.socket") + private let socketPath: String + private let token: String + private let onPrompt: @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision + private let onExec: @Sendable (ExecHostRequest) async -> ExecHostResponse + private var socketFD: Int32 = -1 + private var acceptTask: Task? + 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/Moltbot/GatewayConnection.swift b/apps/macos/Sources/Moltbot/GatewayConnection.swift new file mode 100644 index 000000000..d733c9c86 --- /dev/null +++ b/apps/macos/Sources/Moltbot/GatewayConnection.swift @@ -0,0 +1,737 @@ +import MoltbotChatUI +import MoltbotKit +import MoltbotProtocol +import Foundation +import OSLog + +private let gatewayConnectionLogger = Logger(subsystem: "bot.molt", category: "gateway.connection") + +enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable { + case last + case whatsapp + case telegram + case discord + case googlechat + case slack + case signal + case imessage + case msteams + case bluebubbles + case webchat + + init(raw: String?) { + let normalized = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + self = GatewayAgentChannel(rawValue: normalized) ?? .last + } + + var isDeliverable: Bool { self != .webchat } + + func shouldDeliver(_ deliver: Bool) -> Bool { deliver && self.isDeliverable } +} + +struct GatewayAgentInvocation: Sendable { + var message: String + var sessionKey: String = "main" + var thinking: String? + var deliver: Bool = false + var to: String? + var channel: GatewayAgentChannel = .last + var timeoutSeconds: Int? + var idempotencyKey: String = UUID().uuidString +} + +/// Single, shared Gateway websocket connection for the whole app. +/// +/// This owns exactly one `GatewayChannelActor` and reuses it across all callers +/// (ControlChannel, debug actions, SwiftUI WebChat, etc.). +actor GatewayConnection { + static let shared = GatewayConnection() + + typealias Config = (url: URL, token: String?, password: String?) + + enum Method: String, Sendable { + case agent + case status + case setHeartbeats = "set-heartbeats" + case systemEvent = "system-event" + case health + case channelsStatus = "channels.status" + case configGet = "config.get" + case configSet = "config.set" + case configPatch = "config.patch" + case configSchema = "config.schema" + case wizardStart = "wizard.start" + case wizardNext = "wizard.next" + case wizardCancel = "wizard.cancel" + case wizardStatus = "wizard.status" + case talkMode = "talk.mode" + case webLoginStart = "web.login.start" + case webLoginWait = "web.login.wait" + case channelsLogout = "channels.logout" + case modelsList = "models.list" + case chatHistory = "chat.history" + case sessionsPreview = "sessions.preview" + case chatSend = "chat.send" + case chatAbort = "chat.abort" + case skillsStatus = "skills.status" + case skillsInstall = "skills.install" + case skillsUpdate = "skills.update" + case voicewakeGet = "voicewake.get" + case voicewakeSet = "voicewake.set" + case nodePairApprove = "node.pair.approve" + case nodePairReject = "node.pair.reject" + case devicePairList = "device.pair.list" + case devicePairApprove = "device.pair.approve" + case devicePairReject = "device.pair.reject" + case execApprovalResolve = "exec.approval.resolve" + case cronList = "cron.list" + case cronRuns = "cron.runs" + case cronRun = "cron.run" + case cronRemove = "cron.remove" + case cronUpdate = "cron.update" + case cronAdd = "cron.add" + case cronStatus = "cron.status" + } + + private let configProvider: @Sendable () async throws -> Config + private let sessionBox: WebSocketSessionBox? + private let decoder = JSONDecoder() + + private var client: GatewayChannelActor? + private var configuredURL: URL? + private var configuredToken: String? + private var configuredPassword: String? + + private var subscribers: [UUID: AsyncStream.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/Moltbot/GatewayConnectivityCoordinator.swift b/apps/macos/Sources/Moltbot/GatewayConnectivityCoordinator.swift new file mode 100644 index 000000000..8a5f15aa0 --- /dev/null +++ b/apps/macos/Sources/Moltbot/GatewayConnectivityCoordinator.swift @@ -0,0 +1,63 @@ +import Foundation +import Observation +import OSLog + +@MainActor +@Observable +final class GatewayConnectivityCoordinator { + static let shared = GatewayConnectivityCoordinator() + + private let logger = Logger(subsystem: "bot.molt", category: "gateway.connectivity") + private var endpointTask: Task? + 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/Moltbot/GatewayEndpointStore.swift b/apps/macos/Sources/Moltbot/GatewayEndpointStore.swift new file mode 100644 index 000000000..08c4249b0 --- /dev/null +++ b/apps/macos/Sources/Moltbot/GatewayEndpointStore.swift @@ -0,0 +1,696 @@ +import ConcurrencyExtras +import Foundation +import OSLog + +enum GatewayEndpointState: Sendable, Equatable { + case ready(mode: AppState.ConnectionMode, url: URL, token: String?, password: String?) + case connecting(mode: AppState.ConnectionMode, detail: String) + case unavailable(mode: AppState.ConnectionMode, reason: String) +} + +/// Single place to resolve (and publish) the effective gateway control endpoint. +/// +/// This is intentionally separate from `GatewayConnection`: +/// - `GatewayConnection` consumes the resolved endpoint (no tunnel side-effects). +/// - The endpoint store owns observation + explicit "ensure tunnel" actions. +actor GatewayEndpointStore { + static let shared = GatewayEndpointStore() + private static let supportedBindModes: Set = [ + "loopback", + "tailnet", + "lan", + "auto", + "custom", + ] + private static let remoteConnectingDetail = "Connecting to remote gateway…" + private static let staticLogger = Logger(subsystem: "bot.molt", category: "gateway-endpoint") + private enum EnvOverrideWarningKind: Sendable { + case token + case password + } + + private static let envOverrideWarnings = LockIsolated((token: false, password: false)) + + struct Deps: Sendable { + let mode: @Sendable () async -> AppState.ConnectionMode + let token: @Sendable () -> String? + let password: @Sendable () -> String? + let localPort: @Sendable () -> Int + let localHost: @Sendable () async -> String + let remotePortIfRunning: @Sendable () async -> UInt16? + let ensureRemoteTunnel: @Sendable () async throws -> UInt16 + + static let live = Deps( + mode: { await MainActor.run { AppStateStore.shared.connectionMode } }, + token: { + let root = MoltbotConfigFile.loadDict() + let isRemote = ConnectionModeResolver.resolve(root: root).mode == .remote + return GatewayEndpointStore.resolveGatewayToken( + isRemote: isRemote, + root: root, + env: ProcessInfo.processInfo.environment, + launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot()) + }, + password: { + let root = MoltbotConfigFile.loadDict() + let isRemote = ConnectionModeResolver.resolve(root: root).mode == .remote + return GatewayEndpointStore.resolveGatewayPassword( + isRemote: isRemote, + root: root, + env: ProcessInfo.processInfo.environment, + launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot()) + }, + localPort: { GatewayEnvironment.gatewayPort() }, + localHost: { + let root = MoltbotConfigFile.loadDict() + let bind = GatewayEndpointStore.resolveGatewayBindMode( + root: root, + env: ProcessInfo.processInfo.environment) + let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: root) + let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP } + ?? TailscaleService.fallbackTailnetIPv4() + return GatewayEndpointStore.resolveLocalGatewayHost( + bindMode: bind, + customBindHost: customBindHost, + tailscaleIP: tailscaleIP) + }, + remotePortIfRunning: { await RemoteTunnelManager.shared.controlTunnelPortIfRunning() }, + ensureRemoteTunnel: { try await RemoteTunnelManager.shared.ensureControlTunnel() }) + } + + private static func resolveGatewayPassword( + isRemote: Bool, + root: [String: Any], + env: [String: String], + launchdSnapshot: LaunchAgentPlistSnapshot?) -> String? + { + let raw = env["CLAWDBOT_GATEWAY_PASSWORD"] ?? "" + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + if let configPassword = self.resolveConfigPassword(isRemote: isRemote, root: root), + !configPassword.isEmpty + { + self.warnEnvOverrideOnce( + kind: .password, + envVar: "CLAWDBOT_GATEWAY_PASSWORD", + configKey: isRemote ? "gateway.remote.password" : "gateway.auth.password") + } + return trimmed + } + if isRemote { + if let gateway = root["gateway"] as? [String: Any], + let remote = gateway["remote"] as? [String: Any], + let password = remote["password"] as? String + { + let pw = password.trimmingCharacters(in: .whitespacesAndNewlines) + if !pw.isEmpty { + return pw + } + } + return nil + } + if let gateway = root["gateway"] as? [String: Any], + let auth = gateway["auth"] as? [String: Any], + let password = auth["password"] as? String + { + let pw = password.trimmingCharacters(in: .whitespacesAndNewlines) + if !pw.isEmpty { + return pw + } + } + if let password = launchdSnapshot?.password?.trimmingCharacters(in: .whitespacesAndNewlines), + !password.isEmpty + { + return password + } + return nil + } + + private static func resolveConfigPassword(isRemote: Bool, root: [String: Any]) -> String? { + if isRemote { + if let gateway = root["gateway"] as? [String: Any], + let remote = gateway["remote"] as? [String: Any], + let password = remote["password"] as? String + { + return password.trimmingCharacters(in: .whitespacesAndNewlines) + } + return nil + } + + if let gateway = root["gateway"] as? [String: Any], + let auth = gateway["auth"] as? [String: Any], + let password = auth["password"] as? String + { + return password.trimmingCharacters(in: .whitespacesAndNewlines) + } + return nil + } + + private static func resolveGatewayToken( + isRemote: Bool, + root: [String: Any], + env: [String: String], + launchdSnapshot: LaunchAgentPlistSnapshot?) -> String? + { + let raw = env["CLAWDBOT_GATEWAY_TOKEN"] ?? "" + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root), + !configToken.isEmpty, + configToken != trimmed + { + self.warnEnvOverrideOnce( + kind: .token, + envVar: "CLAWDBOT_GATEWAY_TOKEN", + configKey: isRemote ? "gateway.remote.token" : "gateway.auth.token") + } + return trimmed + } + + if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root), + !configToken.isEmpty + { + return configToken + } + + if isRemote { + return nil + } + + if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines), + !token.isEmpty + { + return token + } + + return nil + } + + private static func resolveConfigToken(isRemote: Bool, root: [String: Any]) -> String? { + if isRemote { + if let gateway = root["gateway"] as? [String: Any], + let remote = gateway["remote"] as? [String: Any], + let token = remote["token"] as? String + { + return token.trimmingCharacters(in: .whitespacesAndNewlines) + } + return nil + } + + if let gateway = root["gateway"] as? [String: Any], + let auth = gateway["auth"] as? [String: Any], + let token = auth["token"] as? String + { + return token.trimmingCharacters(in: .whitespacesAndNewlines) + } + return nil + } + + private static func warnEnvOverrideOnce( + kind: EnvOverrideWarningKind, + envVar: String, + configKey: String) + { + let shouldWarn = Self.envOverrideWarnings.withValue { state in + switch kind { + case .token: + guard !state.token else { return false } + state.token = true + return true + case .password: + guard !state.password else { return false } + state.password = true + return true + } + } + guard shouldWarn else { return } + Self.staticLogger.warning( + "\(envVar, privacy: .public) is set and overrides \(configKey, privacy: .public). " + + "If this is unintentional, clear it with: launchctl unsetenv \(envVar, privacy: .public)") + } + + private let deps: Deps + private let logger = Logger(subsystem: "bot.molt", category: "gateway-endpoint") + + private var state: GatewayEndpointState + private var subscribers: [UUID: AsyncStream.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/Moltbot/GatewayEnvironment.swift b/apps/macos/Sources/Moltbot/GatewayEnvironment.swift new file mode 100644 index 000000000..2689d8604 --- /dev/null +++ b/apps/macos/Sources/Moltbot/GatewayEnvironment.swift @@ -0,0 +1,342 @@ +import MoltbotIPC +import Foundation +import OSLog + +// Lightweight SemVer helper (major.minor.patch only) for gateway compatibility checks. +struct Semver: Comparable, CustomStringConvertible, Sendable { + let major: Int + let minor: Int + let patch: Int + + var description: String { "\(self.major).\(self.minor).\(self.patch)" } + + static func < (lhs: Semver, rhs: Semver) -> Bool { + if lhs.major != rhs.major { return lhs.major < rhs.major } + if lhs.minor != rhs.minor { return lhs.minor < rhs.minor } + return lhs.patch < rhs.patch + } + + static func parse(_ raw: String?) -> Semver? { + guard let raw, !raw.isEmpty else { return nil } + let cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "^v", with: "", options: .regularExpression) + let parts = cleaned.split(separator: ".") + guard parts.count >= 3, + let major = Int(parts[0]), + let minor = Int(parts[1]) + else { return nil } + // Strip prerelease suffix (e.g., "11-4" → "11", "5-beta.1" → "5") + let patchRaw = String(parts[2]) + guard let patchToken = patchRaw.split(whereSeparator: { $0 == "-" || $0 == "+" }).first, + let patchNumeric = Int(patchToken) + else { + return nil + } + return Semver(major: major, minor: minor, patch: patchNumeric) + } + + func compatible(with required: Semver) -> Bool { + // Same major and not older than required. + self.major == required.major && self >= required + } +} + +enum GatewayEnvironmentKind: Equatable { + case checking + case ok + case missingNode + case missingGateway + case incompatible(found: String, required: String) + case error(String) +} + +struct GatewayEnvironmentStatus: Equatable { + let kind: GatewayEnvironmentKind + let nodeVersion: String? + let gatewayVersion: String? + let requiredGateway: String? + let message: String + + static var checking: Self { + .init(kind: .checking, nodeVersion: nil, gatewayVersion: nil, requiredGateway: nil, message: "Checking…") + } +} + +struct GatewayCommandResolution { + let status: GatewayEnvironmentStatus + let command: [String]? +} + +enum GatewayEnvironment { + private static let logger = Logger(subsystem: "bot.molt", category: "gateway.env") + private static let supportedBindModes: Set = ["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/Moltbot/GatewayLaunchAgentManager.swift b/apps/macos/Sources/Moltbot/GatewayLaunchAgentManager.swift new file mode 100644 index 000000000..70c5a5eec --- /dev/null +++ b/apps/macos/Sources/Moltbot/GatewayLaunchAgentManager.swift @@ -0,0 +1,203 @@ +import Foundation + +enum GatewayLaunchAgentManager { + private static let logger = Logger(subsystem: "bot.molt", category: "gateway.launchd") + private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent" + + private static var disableLaunchAgentMarkerURL: URL { + FileManager().homeDirectoryForCurrentUser + .appendingPathComponent(self.disableLaunchAgentMarker) + } + + private static var plistURL: URL { + FileManager().homeDirectoryForCurrentUser + .appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist") + } + + static func isLaunchAgentWriteDisabled() -> Bool { + FileManager().fileExists(atPath: self.disableLaunchAgentMarkerURL.path) + } + + static func setLaunchAgentWriteDisabled(_ disabled: Bool) -> String? { + let marker = self.disableLaunchAgentMarkerURL + if disabled { + do { + try FileManager().createDirectory( + at: marker.deletingLastPathComponent(), + withIntermediateDirectories: true) + if !FileManager().fileExists(atPath: marker.path) { + FileManager().createFile(atPath: marker.path, contents: nil) + } + } catch { + return error.localizedDescription + } + return nil + } + + if FileManager().fileExists(atPath: marker.path) { + do { + try FileManager().removeItem(at: marker) + } catch { + return error.localizedDescription + } + } + return nil + } + + static func isLoaded() async -> Bool { + guard let loaded = await self.readDaemonLoaded() else { return false } + return loaded + } + + static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? { + _ = bundlePath + guard !CommandResolver.connectionModeIsRemote() else { + self.logger.info("launchd change skipped (remote mode)") + return nil + } + if enabled, self.isLaunchAgentWriteDisabled() { + self.logger.info("launchd enable skipped (disable marker set)") + return nil + } + + if enabled { + self.logger.info("launchd enable requested via CLI port=\(port)") + return await self.runDaemonCommand([ + "install", + "--force", + "--port", + "\(port)", + "--runtime", + "node", + ]) + } + + self.logger.info("launchd disable requested via CLI") + return await self.runDaemonCommand(["uninstall"]) + } + + static func kickstart() async { + _ = await self.runDaemonCommand(["restart"], timeout: 20) + } + + static func launchdConfigSnapshot() -> LaunchAgentPlistSnapshot? { + LaunchAgentPlist.snapshot(url: self.plistURL) + } + + static func launchdGatewayLogPath() -> String { + let snapshot = self.launchdConfigSnapshot() + if let stdout = snapshot?.stdoutPath?.trimmingCharacters(in: .whitespacesAndNewlines), + !stdout.isEmpty + { + return stdout + } + if let stderr = snapshot?.stderrPath?.trimmingCharacters(in: .whitespacesAndNewlines), + !stderr.isEmpty + { + return stderr + } + return LogLocator.launchdGatewayLogPath + } +} + +extension GatewayLaunchAgentManager { + private static func readDaemonLoaded() async -> Bool? { + let result = await self.runDaemonCommandResult( + ["status", "--json", "--no-probe"], + timeout: 15, + quiet: true) + guard result.success, let payload = result.payload else { return nil } + guard + let json = try? JSONSerialization.jsonObject(with: payload) as? [String: Any], + let service = json["service"] as? [String: Any], + let loaded = service["loaded"] as? Bool + else { + return nil + } + return loaded + } + + private struct CommandResult { + let success: Bool + let payload: Data? + let message: String? + } + + private struct ParsedDaemonJson { + let text: String + let object: [String: Any] + } + + private static func runDaemonCommand( + _ args: [String], + timeout: Double = 15, + quiet: Bool = false) async -> String? + { + let result = await self.runDaemonCommandResult(args, timeout: timeout, quiet: quiet) + if result.success { return nil } + return result.message ?? "Gateway daemon command failed" + } + + private static func runDaemonCommandResult( + _ args: [String], + timeout: Double, + quiet: Bool) async -> CommandResult + { + let command = CommandResolver.clawdbotCommand( + subcommand: "gateway", + extraArgs: self.withJsonFlag(args), + // Launchd management must always run locally, even if remote mode is configured. + configRoot: ["gateway": ["mode": "local"]]) + var env = ProcessInfo.processInfo.environment + env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":") + let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout) + let parsed = self.parseDaemonJson(from: response.stdout) ?? self.parseDaemonJson(from: response.stderr) + let ok = parsed?.object["ok"] as? Bool + let message = (parsed?.object["error"] as? String) ?? (parsed?.object["message"] as? String) + let payload = parsed?.text.data(using: .utf8) + ?? (response.stdout.isEmpty ? response.stderr : response.stdout).data(using: .utf8) + let success = ok ?? response.success + if success { + return CommandResult(success: true, payload: payload, message: nil) + } + + if quiet { + return CommandResult(success: false, payload: payload, message: message) + } + + let detail = message ?? self.summarize(response.stderr) ?? self.summarize(response.stdout) + let exit = response.exitCode.map { "exit \($0)" } ?? (response.errorMessage ?? "failed") + let fullMessage = detail.map { "Gateway daemon command failed (\(exit)): \($0)" } + ?? "Gateway daemon command failed (\(exit))" + self.logger.error("\(fullMessage, privacy: .public)") + return CommandResult(success: false, payload: payload, message: detail) + } + + private static func withJsonFlag(_ args: [String]) -> [String] { + if args.contains("--json") { return args } + return args + ["--json"] + } + + private static func parseDaemonJson(from raw: String) -> ParsedDaemonJson? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard let start = trimmed.firstIndex(of: "{"), + let end = trimmed.lastIndex(of: "}") + else { + return nil + } + let jsonText = String(trimmed[start...end]) + guard let data = jsonText.data(using: .utf8) else { return nil } + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + return ParsedDaemonJson(text: jsonText, object: object) + } + + private static func summarize(_ text: String) -> String? { + let lines = text + .split(whereSeparator: \.isNewline) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + guard let last = lines.last else { return nil } + let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) + return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized + } +} diff --git a/apps/macos/Sources/Moltbot/GatewayProcessManager.swift b/apps/macos/Sources/Moltbot/GatewayProcessManager.swift new file mode 100644 index 000000000..86dfc851f --- /dev/null +++ b/apps/macos/Sources/Moltbot/GatewayProcessManager.swift @@ -0,0 +1,432 @@ +import Foundation +import Observation + +@MainActor +@Observable +final class GatewayProcessManager { + static let shared = GatewayProcessManager() + + enum Status: Equatable { + case stopped + case starting + case running(details: String?) + case attachedExisting(details: String?) + case failed(String) + + var label: String { + switch self { + case .stopped: return "Stopped" + case .starting: return "Starting…" + case let .running(details): + if let details, !details.isEmpty { return "Running (\(details))" } + return "Running" + case let .attachedExisting(details): + if let details, !details.isEmpty { + return "Using existing gateway (\(details))" + } + return "Using existing gateway" + case let .failed(reason): return "Failed: \(reason)" + } + } + } + + private(set) var status: Status = .stopped { + didSet { CanvasManager.shared.refreshDebugStatus() } + } + + private(set) var log: String = "" + private(set) var environmentStatus: GatewayEnvironmentStatus = .checking + private(set) var existingGatewayDetails: String? + private(set) var lastFailureReason: String? + private var desiredActive = false + private var environmentRefreshTask: Task? + private var lastEnvironmentRefresh: Date? + private var logRefreshTask: Task? + #if DEBUG + private var testingConnection: GatewayConnection? + #endif + private let logger = Logger(subsystem: "bot.molt", category: "gateway.process") + + private let logLimit = 20000 // characters to keep in-memory + private let environmentRefreshMinInterval: TimeInterval = 30 + private var connection: GatewayConnection { + #if DEBUG + return self.testingConnection ?? .shared + #else + return .shared + #endif + } + + func setActive(_ active: Bool) { + // Remote mode should never spawn a local gateway; treat as stopped. + if CommandResolver.connectionModeIsRemote() { + self.desiredActive = false + self.stop() + self.status = .stopped + self.appendLog("[gateway] remote mode active; skipping local gateway\n") + self.logger.info("gateway process skipped: remote mode active") + return + } + self.logger.debug("gateway active requested active=\(active)") + self.desiredActive = active + self.refreshEnvironmentStatus() + if active { + self.startIfNeeded() + } else { + self.stop() + } + } + + func ensureLaunchAgentEnabledIfNeeded() async { + guard !CommandResolver.connectionModeIsRemote() else { return } + if GatewayLaunchAgentManager.isLaunchAgentWriteDisabled() { + self.appendLog("[gateway] launchd auto-enable skipped (attach-only)\n") + self.logger.info("gateway launchd auto-enable skipped (disable marker set)") + return + } + let enabled = await GatewayLaunchAgentManager.isLoaded() + guard !enabled else { return } + let bundlePath = Bundle.main.bundleURL.path + let port = GatewayEnvironment.gatewayPort() + self.appendLog("[gateway] auto-enabling launchd job (\(gatewayLaunchdLabel)) on port \(port)\n") + let err = await GatewayLaunchAgentManager.set(enabled: true, bundlePath: bundlePath, port: port) + if let err { + self.appendLog("[gateway] launchd auto-enable failed: \(err)\n") + } + } + + func startIfNeeded() { + guard self.desiredActive else { return } + // Do not spawn in remote mode (the gateway should run on the remote host). + guard !CommandResolver.connectionModeIsRemote() else { + self.status = .stopped + return + } + // Many surfaces can call `setActive(true)` in quick succession (startup, Canvas, health checks). + // Avoid spawning multiple concurrent "start" tasks that can thrash launchd and flap the port. + switch self.status { + case .starting, .running, .attachedExisting: + return + case .stopped, .failed: + break + } + self.status = .starting + self.logger.debug("gateway start requested") + + // First try to latch onto an already-running gateway to avoid spawning a duplicate. + Task { [weak self] in + guard let self else { return } + if await self.attachExistingGatewayIfAvailable() { + return + } + await self.enableLaunchdGateway() + } + } + + func stop() { + self.desiredActive = false + self.existingGatewayDetails = nil + self.lastFailureReason = nil + self.status = .stopped + self.logger.info("gateway stop requested") + if CommandResolver.connectionModeIsRemote() { + return + } + let bundlePath = Bundle.main.bundleURL.path + Task { + _ = await GatewayLaunchAgentManager.set( + enabled: false, + bundlePath: bundlePath, + port: GatewayEnvironment.gatewayPort()) + } + } + + func clearLastFailure() { + self.lastFailureReason = nil + } + + func refreshEnvironmentStatus(force: Bool = false) { + let now = Date() + if !force { + if self.environmentRefreshTask != nil { return } + if let last = self.lastEnvironmentRefresh, + now.timeIntervalSince(last) < self.environmentRefreshMinInterval + { + return + } + } + self.lastEnvironmentRefresh = now + self.environmentRefreshTask = Task { [weak self] in + let status = await Task.detached(priority: .utility) { + GatewayEnvironment.check() + }.value + await MainActor.run { + guard let self else { return } + self.environmentStatus = status + self.environmentRefreshTask = nil + } + } + } + + func refreshLog() { + guard self.logRefreshTask == nil else { return } + let path = GatewayLaunchAgentManager.launchdGatewayLogPath() + let limit = self.logLimit + self.logRefreshTask = Task { [weak self] in + let log = await Task.detached(priority: .utility) { + Self.readGatewayLog(path: path, limit: limit) + }.value + await MainActor.run { + guard let self else { return } + if !log.isEmpty { + self.log = log + } + self.logRefreshTask = nil + } + } + } + + // MARK: - Internals + + /// Attempt to connect to an already-running gateway on the configured port. + /// If successful, mark status as attached and skip spawning a new process. + private func attachExistingGatewayIfAvailable() async -> Bool { + let port = GatewayEnvironment.gatewayPort() + let instance = await PortGuardian.shared.describe(port: port) + let instanceText = instance.map { self.describe(instance: $0) } + let hasListener = instance != nil + + let attemptAttach = { + try await self.connection.requestRaw(method: .health, timeoutMs: 2000) + } + + for attempt in 0..<(hasListener ? 3 : 1) { + do { + let data = try await attemptAttach() + let snap = decodeHealthSnapshot(from: data) + let details = self.describe(details: instanceText, port: port, snap: snap) + self.existingGatewayDetails = details + self.clearLastFailure() + self.status = .attachedExisting(details: details) + self.appendLog("[gateway] using existing instance: \(details)\n") + self.logger.info("gateway using existing instance details=\(details)") + self.refreshControlChannelIfNeeded(reason: "attach existing") + self.refreshLog() + return true + } catch { + if attempt < 2, hasListener { + try? await Task.sleep(nanoseconds: 250_000_000) + continue + } + + if hasListener { + let reason = self.describeAttachFailure(error, port: port, instance: instance) + self.existingGatewayDetails = instanceText + self.status = .failed(reason) + self.lastFailureReason = reason + self.appendLog("[gateway] existing listener on port \(port) but attach failed: \(reason)\n") + self.logger.warning("gateway attach failed reason=\(reason)") + return true + } + + // No reachable gateway (and no listener) — fall through to spawn. + self.existingGatewayDetails = nil + return false + } + } + + self.existingGatewayDetails = nil + return false + } + + private func describe(details instance: String?, port: Int, snap: HealthSnapshot?) -> String { + let instanceText = instance ?? "pid unknown" + if let snap { + let order = snap.channelOrder ?? Array(snap.channels.keys) + let linkId = order.first(where: { snap.channels[$0]?.linked == true }) + ?? order.first(where: { snap.channels[$0]?.linked != nil }) + guard let linkId else { + return "port \(port), health probe succeeded, \(instanceText)" + } + let linked = snap.channels[linkId]?.linked ?? false + let authAge = snap.channels[linkId]?.authAgeMs.flatMap(msToAge) ?? "unknown age" + let label = + snap.channelLabels?[linkId] ?? + linkId.capitalized + let linkText = linked ? "linked" : "not linked" + return "port \(port), \(label) \(linkText), auth \(authAge), \(instanceText)" + } + return "port \(port), health probe succeeded, \(instanceText)" + } + + private func describe(instance: PortGuardian.Descriptor) -> String { + let path = instance.executablePath ?? "path unknown" + return "pid \(instance.pid) \(instance.command) @ \(path)" + } + + private func describeAttachFailure(_ error: Error, port: Int, instance: PortGuardian.Descriptor?) -> String { + let ns = error as NSError + let message = ns.localizedDescription.isEmpty ? "unknown error" : ns.localizedDescription + let lower = message.lowercased() + if self.isGatewayAuthFailure(error) { + return """ + Gateway on port \(port) rejected auth. Set gateway.auth.token (or CLAWDBOT_GATEWAY_TOKEN) \ + to match the running gateway (or clear it on the gateway) and retry. + """ + } + if lower.contains("protocol mismatch") { + return "Gateway on port \(port) is incompatible (protocol mismatch). Update the app/gateway." + } + if lower.contains("unexpected response") || lower.contains("invalid response") { + return "Port \(port) returned non-gateway data; another process is using it." + } + if let instance { + let instanceText = self.describe(instance: instance) + return "Gateway listener found on port \(port) (\(instanceText)) but health check failed: \(message)" + } + return "Gateway listener found on port \(port) but health check failed: \(message)" + } + + private func isGatewayAuthFailure(_ error: Error) -> Bool { + if let urlError = error as? URLError, urlError.code == .dataNotAllowed { + return true + } + let ns = error as NSError + if ns.domain == "Gateway", ns.code == 1008 { return true } + let lower = ns.localizedDescription.lowercased() + return lower.contains("unauthorized") || lower.contains("auth") + } + + private func enableLaunchdGateway() async { + self.existingGatewayDetails = nil + let resolution = await Task.detached(priority: .utility) { + GatewayEnvironment.resolveGatewayCommand() + }.value + await MainActor.run { self.environmentStatus = resolution.status } + guard resolution.command != nil else { + await MainActor.run { + self.status = .failed(resolution.status.message) + } + self.logger.error("gateway command resolve failed: \(resolution.status.message)") + return + } + + if GatewayLaunchAgentManager.isLaunchAgentWriteDisabled() { + let message = "Launchd disabled; start the Gateway manually or disable attach-only." + self.status = .failed(message) + self.lastFailureReason = "launchd disabled" + self.appendLog("[gateway] launchd disabled; skipping auto-start\n") + self.logger.info("gateway launchd enable skipped (disable marker set)") + return + } + + let bundlePath = Bundle.main.bundleURL.path + let port = GatewayEnvironment.gatewayPort() + self.appendLog("[gateway] enabling launchd job (\(gatewayLaunchdLabel)) on port \(port)\n") + self.logger.info("gateway enabling launchd port=\(port)") + let err = await GatewayLaunchAgentManager.set(enabled: true, bundlePath: bundlePath, port: port) + if let err { + self.status = .failed(err) + self.lastFailureReason = err + self.logger.error("gateway launchd enable failed: \(err)") + return + } + + // Best-effort: wait for the gateway to accept connections. + let deadline = Date().addingTimeInterval(6) + while Date() < deadline { + if !self.desiredActive { return } + do { + _ = try await self.connection.requestRaw(method: .health, timeoutMs: 1500) + let instance = await PortGuardian.shared.describe(port: port) + let details = instance.map { "pid \($0.pid)" } + self.clearLastFailure() + self.status = .running(details: details) + self.logger.info("gateway started details=\(details ?? "ok")") + self.refreshControlChannelIfNeeded(reason: "gateway started") + self.refreshLog() + return + } catch { + try? await Task.sleep(nanoseconds: 400_000_000) + } + } + + self.status = .failed("Gateway did not start in time") + self.lastFailureReason = "launchd start timeout" + self.logger.warning("gateway start timed out") + } + + private func appendLog(_ chunk: String) { + self.log.append(chunk) + if self.log.count > self.logLimit { + self.log = String(self.log.suffix(self.logLimit)) + } + } + + private func refreshControlChannelIfNeeded(reason: String) { + switch ControlChannel.shared.state { + case .connected, .connecting: + return + case .disconnected, .degraded: + break + } + self.appendLog("[gateway] refreshing control channel (\(reason))\n") + self.logger.debug("gateway control channel refresh reason=\(reason)") + Task { await ControlChannel.shared.configure() } + } + + func waitForGatewayReady(timeout: TimeInterval = 6) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if !self.desiredActive { return false } + do { + _ = try await self.connection.requestRaw(method: .health, timeoutMs: 1500) + self.clearLastFailure() + return true + } catch { + try? await Task.sleep(nanoseconds: 300_000_000) + } + } + self.appendLog("[gateway] readiness wait timed out\n") + self.logger.warning("gateway readiness wait timed out") + return false + } + + func clearLog() { + self.log = "" + try? FileManager().removeItem(atPath: GatewayLaunchAgentManager.launchdGatewayLogPath()) + self.logger.debug("gateway log cleared") + } + + func setProjectRoot(path: String) { + CommandResolver.setProjectRoot(path) + } + + func projectRootPath() -> String { + CommandResolver.projectRootPath() + } + + private nonisolated static func readGatewayLog(path: String, limit: Int) -> String { + guard FileManager().fileExists(atPath: path) else { return "" } + guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return "" } + let text = String(data: data, encoding: .utf8) ?? "" + if text.count <= limit { return text } + return String(text.suffix(limit)) + } +} + +#if DEBUG +extension GatewayProcessManager { + func setTestingConnection(_ connection: GatewayConnection?) { + self.testingConnection = connection + } + + func setTestingDesiredActive(_ active: Bool) { + self.desiredActive = active + } + + func setTestingLastFailureReason(_ reason: String?) { + self.lastFailureReason = reason + } +} +#endif diff --git a/apps/macos/Sources/Moltbot/HealthStore.swift b/apps/macos/Sources/Moltbot/HealthStore.swift new file mode 100644 index 000000000..6e4c2437b --- /dev/null +++ b/apps/macos/Sources/Moltbot/HealthStore.swift @@ -0,0 +1,301 @@ +import Foundation +import Network +import Observation +import SwiftUI + +struct HealthSnapshot: Codable, Sendable { + struct ChannelSummary: Codable, Sendable { + struct Probe: Codable, Sendable { + struct Bot: Codable, Sendable { + let username: String? + } + + struct Webhook: Codable, Sendable { + let url: String? + } + + let ok: Bool? + let status: Int? + let error: String? + let elapsedMs: Double? + let bot: Bot? + let webhook: Webhook? + } + + let configured: Bool? + let linked: Bool? + let authAgeMs: Double? + let probe: Probe? + let lastProbeAt: Double? + } + + struct SessionInfo: Codable, Sendable { + let key: String + let updatedAt: Double? + let age: Double? + } + + struct Sessions: Codable, Sendable { + let path: String + let count: Int + let recent: [SessionInfo] + } + + let ok: Bool? + let ts: Double + let durationMs: Double + let channels: [String: ChannelSummary] + let channelOrder: [String]? + let channelLabels: [String: String]? + let heartbeatSeconds: Int? + let sessions: Sessions +} + +enum HealthState: Equatable { + case unknown + case ok + case linkingNeeded + case degraded(String) + + var tint: Color { + switch self { + case .ok: .green + case .linkingNeeded: .red + case .degraded: .orange + case .unknown: .secondary + } + } +} + +@MainActor +@Observable +final class HealthStore { + static let shared = HealthStore() + + private static let logger = Logger(subsystem: "bot.molt", category: "health") + + private(set) var snapshot: HealthSnapshot? + private(set) var lastSuccess: Date? + private(set) var lastError: String? + private(set) var isRefreshing = false + + private var loopTask: Task? + 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/Moltbot/InstancesStore.swift b/apps/macos/Sources/Moltbot/InstancesStore.swift new file mode 100644 index 000000000..65b20df29 --- /dev/null +++ b/apps/macos/Sources/Moltbot/InstancesStore.swift @@ -0,0 +1,394 @@ +import MoltbotKit +import MoltbotProtocol +import Cocoa +import Foundation +import Observation +import OSLog + +struct InstanceInfo: Identifiable, Codable { + let id: String + let host: String? + let ip: String? + let version: String? + let platform: String? + let deviceFamily: String? + let modelIdentifier: String? + let lastInputSeconds: Int? + let mode: String? + let reason: String? + let text: String + let ts: Double + + var ageDescription: String { + let date = Date(timeIntervalSince1970: ts / 1000) + return age(from: date) + } + + var lastInputDescription: String { + guard let secs = lastInputSeconds else { return "unknown" } + return "\(secs)s ago" + } +} + +@MainActor +@Observable +final class InstancesStore { + static let shared = InstancesStore() + let isPreview: Bool + + var instances: [InstanceInfo] = [] + var lastError: String? + var statusMessage: String? + var isLoading = false + + private let logger = Logger(subsystem: "bot.molt", category: "instances") + private var task: Task? + 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/Moltbot/LaunchAgentManager.swift b/apps/macos/Sources/Moltbot/LaunchAgentManager.swift new file mode 100644 index 000000000..fdc1785ba --- /dev/null +++ b/apps/macos/Sources/Moltbot/LaunchAgentManager.swift @@ -0,0 +1,95 @@ +import Foundation + +enum LaunchAgentManager { + private static let legacyLaunchdLabels = [ + "com.steipete.clawdbot", + "com.clawdbot.mac", + ] + private static var plistURL: URL { + FileManager().homeDirectoryForCurrentUser + .appendingPathComponent("Library/LaunchAgents/bot.molt.mac.plist") + } + + private static var legacyPlistURLs: [URL] { + self.legacyLaunchdLabels.map { label in + FileManager().homeDirectoryForCurrentUser + .appendingPathComponent("Library/LaunchAgents/\(label).plist") + } + } + + static func status() async -> Bool { + guard FileManager().fileExists(atPath: self.plistURL.path) else { return false } + let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(launchdLabel)"]) + return result == 0 + } + + static func set(enabled: Bool, bundlePath: String) async { + if enabled { + for legacyLabel in self.legacyLaunchdLabels { + _ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(legacyLabel)"]) + } + for legacyURL in self.legacyPlistURLs { + try? FileManager().removeItem(at: legacyURL) + } + self.writePlist(bundlePath: bundlePath) + _ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"]) + _ = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path]) + _ = await self.runLaunchctl(["kickstart", "-k", "gui/\(getuid())/\(launchdLabel)"]) + } else { + // Disable autostart going forward but leave the current app running. + // bootout would terminate the launchd job immediately (and crash the app if launched via agent). + try? FileManager().removeItem(at: self.plistURL) + } + } + + private static func writePlist(bundlePath: String) { + let plist = """ + + + + + Label + bot.molt.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/Moltbot/Logging/ClawdbotLogging.swift b/apps/macos/Sources/Moltbot/Logging/ClawdbotLogging.swift new file mode 100644 index 000000000..2ac8e8003 --- /dev/null +++ b/apps/macos/Sources/Moltbot/Logging/ClawdbotLogging.swift @@ -0,0 +1,230 @@ +import Foundation +@_exported import Logging +import os +import OSLog + +typealias Logger = Logging.Logger + +enum AppLogSettings { + static let logLevelKey = appLogLevelKey + + static func logLevel() -> Logger.Level { + if let raw = UserDefaults.standard.string(forKey: self.logLevelKey), + let level = Logger.Level(rawValue: raw) + { + return level + } + return .info + } + + static func setLogLevel(_ level: Logger.Level) { + UserDefaults.standard.set(level.rawValue, forKey: self.logLevelKey) + } + + static func fileLoggingEnabled() -> Bool { + UserDefaults.standard.bool(forKey: debugFileLogEnabledKey) + } +} + +enum AppLogLevel: String, CaseIterable, Identifiable { + case trace + case debug + case info + case notice + case warning + case error + case critical + + static let `default`: AppLogLevel = .info + + var id: String { self.rawValue } + + var title: String { + switch self { + case .trace: "Trace" + case .debug: "Debug" + case .info: "Info" + case .notice: "Notice" + case .warning: "Warning" + case .error: "Error" + case .critical: "Critical" + } + } +} + +enum MoltbotLogging { + private static let labelSeparator = "::" + + private static let didBootstrap: Void = { + LoggingSystem.bootstrap { label in + let (subsystem, category) = Self.parseLabel(label) + let osHandler = MoltbotOSLogHandler(subsystem: subsystem, category: category) + let fileHandler = MoltbotFileLogHandler(label: label) + return MultiplexLogHandler([osHandler, fileHandler]) + } + }() + + static func bootstrapIfNeeded() { + _ = self.didBootstrap + } + + static func makeLabel(subsystem: String, category: String) -> String { + "\(subsystem)\(self.labelSeparator)\(category)" + } + + static func parseLabel(_ label: String) -> (String, String) { + guard let range = label.range(of: labelSeparator) else { + return ("bot.molt", label) + } + let subsystem = String(label[.. 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/Moltbot/MenuBar.swift b/apps/macos/Sources/Moltbot/MenuBar.swift new file mode 100644 index 000000000..63cce602c --- /dev/null +++ b/apps/macos/Sources/Moltbot/MenuBar.swift @@ -0,0 +1,471 @@ +import AppKit +import Darwin +import Foundation +import MenuBarExtraAccess +import Observation +import OSLog +import Security +import SwiftUI + +@main +struct MoltbotApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate + @State private var state: AppState + private static let logger = Logger(subsystem: "bot.molt", category: "app") + private let gatewayManager = GatewayProcessManager.shared + private let controlChannel = ControlChannel.shared + private let activityStore = WorkActivityStore.shared + private let connectivityCoordinator = GatewayConnectivityCoordinator.shared + @State private var statusItem: NSStatusItem? + @State private var isMenuPresented = false + @State private var isPanelVisible = false + @State private var tailscaleService = TailscaleService.shared + + @MainActor + private func updateStatusHighlight() { + self.statusItem?.button?.highlight(self.isPanelVisible) + } + + @MainActor + private func updateHoverHUDSuppression() { + HoverHUDController.shared.setSuppressed(self.isMenuPresented || self.isPanelVisible) + } + + init() { + MoltbotLogging.bootstrapIfNeeded() + Self.applyAttachOnlyOverrideIfNeeded() + _state = State(initialValue: AppStateStore.shared) + } + + var body: some Scene { + MenuBarExtra { MenuContent(state: self.state, updater: self.delegate.updaterController) } label: { + CritterStatusLabel( + isPaused: self.state.isPaused, + isSleeping: self.isGatewaySleeping, + isWorking: self.state.isWorking, + earBoostActive: self.state.earBoostActive, + blinkTick: self.state.blinkTick, + sendCelebrationTick: self.state.sendCelebrationTick, + gatewayStatus: self.gatewayManager.status, + animationsEnabled: self.state.iconAnimationsEnabled && !self.isGatewaySleeping, + iconState: self.effectiveIconState) + } + .menuBarExtraStyle(.menu) + .menuBarExtraAccess(isPresented: self.$isMenuPresented) { item in + self.statusItem = item + MenuSessionsInjector.shared.install(into: item) + self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping) + self.installStatusItemMouseHandler(for: item) + self.updateHoverHUDSuppression() + } + .onChange(of: self.state.isPaused) { _, paused in + self.applyStatusItemAppearance(paused: paused, sleeping: self.isGatewaySleeping) + if self.state.connectionMode == .local { + self.gatewayManager.setActive(!paused) + } else { + self.gatewayManager.stop() + } + } + .onChange(of: self.controlChannel.state) { _, _ in + self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping) + } + .onChange(of: self.gatewayManager.status) { _, _ in + self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping) + } + .onChange(of: self.state.connectionMode) { _, mode in + Task { await ConnectionModeCoordinator.shared.apply(mode: mode, paused: self.state.isPaused) } + CLIInstallPrompter.shared.checkAndPromptIfNeeded(reason: "connection-mode") + } + + Settings { + SettingsRootView(state: self.state, updater: self.delegate.updaterController) + .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight, alignment: .topLeading) + .environment(self.tailscaleService) + } + .defaultSize(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) + .windowResizability(.contentSize) + .onChange(of: self.isMenuPresented) { _, _ in + self.updateStatusHighlight() + self.updateHoverHUDSuppression() + } + } + + private func applyStatusItemAppearance(paused: Bool, sleeping: Bool) { + self.statusItem?.button?.appearsDisabled = paused || sleeping + } + + private static func applyAttachOnlyOverrideIfNeeded() { + let args = CommandLine.arguments + guard args.contains("--attach-only") || args.contains("--no-launchd") else { return } + if let error = GatewayLaunchAgentManager.setLaunchAgentWriteDisabled(true) { + Self.logger.error("attach-only flag failed: \(error, privacy: .public)") + return + } + Task { + _ = await GatewayLaunchAgentManager.set( + enabled: false, + bundlePath: Bundle.main.bundlePath, + port: GatewayEnvironment.gatewayPort()) + } + Self.logger.info("attach-only flag enabled") + } + + private var isGatewaySleeping: Bool { + if self.state.isPaused { return false } + switch self.state.connectionMode { + case .unconfigured: + return true + case .remote: + if case .connected = self.controlChannel.state { return false } + return true + case .local: + switch self.gatewayManager.status { + case .running, .starting, .attachedExisting: + if case .connected = self.controlChannel.state { return false } + return true + case .failed, .stopped: + return true + } + } + } + + @MainActor + private func installStatusItemMouseHandler(for item: NSStatusItem) { + guard let button = item.button else { return } + if button.subviews.contains(where: { $0 is StatusItemMouseHandlerView }) { return } + + WebChatManager.shared.onPanelVisibilityChanged = { [self] visible in + self.isPanelVisible = visible + self.updateStatusHighlight() + self.updateHoverHUDSuppression() + } + CanvasManager.shared.onPanelVisibilityChanged = { [self] visible in + self.state.canvasPanelVisible = visible + } + CanvasManager.shared.defaultAnchorProvider = { [self] in self.statusButtonScreenFrame() } + + let handler = StatusItemMouseHandlerView() + handler.translatesAutoresizingMaskIntoConstraints = false + handler.onLeftClick = { [self] in + HoverHUDController.shared.dismiss(reason: "statusItemClick") + self.toggleWebChatPanel() + } + handler.onRightClick = { [self] in + HoverHUDController.shared.dismiss(reason: "statusItemRightClick") + WebChatManager.shared.closePanel() + self.isMenuPresented = true + self.updateStatusHighlight() + } + handler.onHoverChanged = { [self] inside in + HoverHUDController.shared.statusItemHoverChanged( + inside: inside, + anchorProvider: { [self] in self.statusButtonScreenFrame() }) + } + + button.addSubview(handler) + NSLayoutConstraint.activate([ + handler.leadingAnchor.constraint(equalTo: button.leadingAnchor), + handler.trailingAnchor.constraint(equalTo: button.trailingAnchor), + handler.topAnchor.constraint(equalTo: button.topAnchor), + handler.bottomAnchor.constraint(equalTo: button.bottomAnchor), + ]) + } + + @MainActor + private func toggleWebChatPanel() { + HoverHUDController.shared.setSuppressed(true) + self.isMenuPresented = false + Task { @MainActor in + let sessionKey = await WebChatManager.shared.preferredSessionKey() + WebChatManager.shared.togglePanel( + sessionKey: sessionKey, + anchorProvider: { [self] in self.statusButtonScreenFrame() }) + } + } + + @MainActor + private func statusButtonScreenFrame() -> NSRect? { + guard let button = self.statusItem?.button, let window = button.window else { return nil } + let inWindow = button.convert(button.bounds, to: nil) + return window.convertToScreen(inWindow) + } + + private var effectiveIconState: IconState { + let selection = self.state.iconOverride + if selection == .system { + return self.activityStore.iconState + } + let overrideState = selection.toIconState() + switch overrideState { + case let .workingMain(kind): return .overridden(kind) + case let .workingOther(kind): return .overridden(kind) + case .idle: return .idle + case let .overridden(kind): return .overridden(kind) + } + } +} + +/// Transparent overlay that intercepts clicks without stealing MenuBarExtra ownership. +private final class StatusItemMouseHandlerView: NSView { + var onLeftClick: (() -> Void)? + var onRightClick: (() -> Void)? + var onHoverChanged: ((Bool) -> Void)? + private var tracking: NSTrackingArea? + + override func mouseDown(with event: NSEvent) { + if let onLeftClick { + onLeftClick() + } else { + super.mouseDown(with: event) + } + } + + override func rightMouseDown(with event: NSEvent) { + self.onRightClick?() + // Do not call super; menu will be driven by isMenuPresented binding. + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let tracking { + self.removeTrackingArea(tracking) + } + let options: NSTrackingArea.Options = [ + .mouseEnteredAndExited, + .activeAlways, + .inVisibleRect, + ] + let area = NSTrackingArea(rect: self.bounds, options: options, owner: self, userInfo: nil) + self.addTrackingArea(area) + self.tracking = area + } + + override func mouseEntered(with event: NSEvent) { + self.onHoverChanged?(true) + } + + override func mouseExited(with event: NSEvent) { + self.onHoverChanged?(false) + } +} + +@MainActor +final class AppDelegate: NSObject, NSApplicationDelegate { + private var state: AppState? + private let webChatAutoLogger = Logger(subsystem: "bot.molt", category: "Chat") + let updaterController: UpdaterProviding = makeUpdaterController() + + func application(_: NSApplication, open urls: [URL]) { + Task { @MainActor in + for url in urls { + await DeepLinkHandler.shared.handle(url: url) + } + } + } + + @MainActor + func applicationDidFinishLaunching(_ notification: Notification) { + if self.isDuplicateInstance() { + NSApp.terminate(nil) + return + } + self.state = AppStateStore.shared + AppActivationPolicy.apply(showDockIcon: self.state?.showDockIcon ?? false) + if let state { + Task { await ConnectionModeCoordinator.shared.apply(mode: state.connectionMode, paused: state.isPaused) } + } + TerminationSignalWatcher.shared.start() + NodePairingApprovalPrompter.shared.start() + DevicePairingApprovalPrompter.shared.start() + ExecApprovalsPromptServer.shared.start() + ExecApprovalsGatewayPrompter.shared.start() + MacNodeModeCoordinator.shared.start() + VoiceWakeGlobalSettingsSync.shared.start() + Task { PresenceReporter.shared.start() } + Task { await HealthStore.shared.refresh(onDemand: true) } + Task { await PortGuardian.shared.sweep(mode: AppStateStore.shared.connectionMode) } + Task { await PeekabooBridgeHostCoordinator.shared.setEnabled(AppStateStore.shared.peekabooBridgeEnabled) } + self.scheduleFirstRunOnboardingIfNeeded() + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + CLIInstallPrompter.shared.checkAndPromptIfNeeded(reason: "launch") + } + + // Developer/testing helper: auto-open chat when launched with --chat (or legacy --webchat). + if CommandLine.arguments.contains("--chat") || CommandLine.arguments.contains("--webchat") { + self.webChatAutoLogger.debug("Auto-opening chat via CLI flag") + Task { @MainActor in + let sessionKey = await WebChatManager.shared.preferredSessionKey() + WebChatManager.shared.show(sessionKey: sessionKey) + } + } + } + + func applicationWillTerminate(_ notification: Notification) { + PresenceReporter.shared.stop() + NodePairingApprovalPrompter.shared.stop() + DevicePairingApprovalPrompter.shared.stop() + ExecApprovalsPromptServer.shared.stop() + ExecApprovalsGatewayPrompter.shared.stop() + MacNodeModeCoordinator.shared.stop() + TerminationSignalWatcher.shared.stop() + VoiceWakeGlobalSettingsSync.shared.stop() + WebChatManager.shared.close() + WebChatManager.shared.resetTunnels() + Task { await RemoteTunnelManager.shared.stopAll() } + Task { await GatewayConnection.shared.shutdown() } + Task { await PeekabooBridgeHostCoordinator.shared.stop() } + } + + @MainActor + private func scheduleFirstRunOnboardingIfNeeded() { + let seenVersion = UserDefaults.standard.integer(forKey: onboardingVersionKey) + let shouldShow = seenVersion < currentOnboardingVersion || !AppStateStore.shared.onboardingSeen + guard shouldShow else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { + OnboardingController.shared.show() + } + } + + private func isDuplicateInstance() -> Bool { + guard let bundleID = Bundle.main.bundleIdentifier else { return false } + let running = NSWorkspace.shared.runningApplications.filter { $0.bundleIdentifier == bundleID } + return running.count > 1 + } +} + +// MARK: - Sparkle updater (disabled for unsigned/dev builds) + +@MainActor +protocol UpdaterProviding: AnyObject { + var automaticallyChecksForUpdates: Bool { get set } + var automaticallyDownloadsUpdates: Bool { get set } + var isAvailable: Bool { get } + var updateStatus: UpdateStatus { get } + func checkForUpdates(_ sender: Any?) +} + +// No-op updater used for debug/dev runs to suppress Sparkle dialogs. +final class DisabledUpdaterController: UpdaterProviding { + var automaticallyChecksForUpdates: Bool = false + var automaticallyDownloadsUpdates: Bool = false + let isAvailable: Bool = false + let updateStatus = UpdateStatus() + func checkForUpdates(_: Any?) {} +} + +@MainActor +@Observable +final class UpdateStatus { + static let disabled = UpdateStatus() + var isUpdateReady: Bool + + init(isUpdateReady: Bool = false) { + self.isUpdateReady = isUpdateReady + } +} + +#if canImport(Sparkle) +import Sparkle + +@MainActor +final class SparkleUpdaterController: NSObject, UpdaterProviding { + private lazy var controller = SPUStandardUpdaterController( + startingUpdater: false, + updaterDelegate: self, + userDriverDelegate: nil) + let updateStatus = UpdateStatus() + + init(savedAutoUpdate: Bool) { + super.init() + let updater = self.controller.updater + updater.automaticallyChecksForUpdates = savedAutoUpdate + updater.automaticallyDownloadsUpdates = savedAutoUpdate + self.controller.startUpdater() + } + + var automaticallyChecksForUpdates: Bool { + get { self.controller.updater.automaticallyChecksForUpdates } + set { self.controller.updater.automaticallyChecksForUpdates = newValue } + } + + var automaticallyDownloadsUpdates: Bool { + get { self.controller.updater.automaticallyDownloadsUpdates } + set { self.controller.updater.automaticallyDownloadsUpdates = newValue } + } + + var isAvailable: Bool { true } + + func checkForUpdates(_ sender: Any?) { + self.controller.checkForUpdates(sender) + } + + func updater(_ updater: SPUUpdater, didDownloadUpdate item: SUAppcastItem) { + self.updateStatus.isUpdateReady = true + } + + func updater(_ updater: SPUUpdater, failedToDownloadUpdate item: SUAppcastItem, error: Error) { + self.updateStatus.isUpdateReady = false + } + + func userDidCancelDownload(_ updater: SPUUpdater) { + self.updateStatus.isUpdateReady = false + } + + func updater( + _ updater: SPUUpdater, + userDidMakeChoice choice: SPUUserUpdateChoice, + forUpdate updateItem: SUAppcastItem, + state: SPUUserUpdateState) + { + switch choice { + case .install, .skip: + self.updateStatus.isUpdateReady = false + case .dismiss: + self.updateStatus.isUpdateReady = (state.stage == .downloaded) + @unknown default: + self.updateStatus.isUpdateReady = false + } + } +} + +extension SparkleUpdaterController: @preconcurrency SPUUpdaterDelegate {} + +private func isDeveloperIDSigned(bundleURL: URL) -> Bool { + var staticCode: SecStaticCode? + guard SecStaticCodeCreateWithPath(bundleURL as CFURL, SecCSFlags(), &staticCode) == errSecSuccess, + let code = staticCode + else { return false } + + var infoCF: CFDictionary? + guard SecCodeCopySigningInformation(code, SecCSFlags(rawValue: kSecCSSigningInformation), &infoCF) == errSecSuccess, + let info = infoCF as? [String: Any], + let certs = info[kSecCodeInfoCertificates as String] as? [SecCertificate], + let leaf = certs.first + else { + return false + } + + if let summary = SecCertificateCopySubjectSummary(leaf) as String? { + return summary.hasPrefix("Developer ID Application:") + } + return false +} + +@MainActor +private func makeUpdaterController() -> UpdaterProviding { + let bundleURL = Bundle.main.bundleURL + let isBundledApp = bundleURL.pathExtension == "app" + guard isBundledApp, isDeveloperIDSigned(bundleURL: bundleURL) else { return DisabledUpdaterController() } + + let defaults = UserDefaults.standard + let autoUpdateKey = "autoUpdateEnabled" + // Default to true; honor the user's last choice otherwise. + let savedAutoUpdate = (defaults.object(forKey: autoUpdateKey) as? Bool) ?? true + return SparkleUpdaterController(savedAutoUpdate: savedAutoUpdate) +} +#else +@MainActor +private func makeUpdaterController() -> UpdaterProviding { + DisabledUpdaterController() +} +#endif diff --git a/apps/macos/Sources/Moltbot/MicLevelMonitor.swift b/apps/macos/Sources/Moltbot/MicLevelMonitor.swift new file mode 100644 index 000000000..654f9052a --- /dev/null +++ b/apps/macos/Sources/Moltbot/MicLevelMonitor.swift @@ -0,0 +1,97 @@ +import AVFoundation +import OSLog +import SwiftUI + +actor MicLevelMonitor { + private let logger = Logger(subsystem: "bot.molt", category: "voicewake.meter") + private var engine: AVAudioEngine? + private var update: (@Sendable (Double) -> Void)? + private var running = false + private var smoothedLevel: Double = 0 + + func start(onLevel: @Sendable @escaping (Double) -> Void) async throws { + self.update = onLevel + if self.running { return } + self.logger.info( + "mic level monitor start (\(AudioInputDeviceObserver.defaultInputDeviceSummary(), privacy: .public))") + let engine = AVAudioEngine() + self.engine = engine + let input = engine.inputNode + let format = input.outputFormat(forBus: 0) + guard format.channelCount > 0, format.sampleRate > 0 else { + self.engine = nil + throw NSError( + domain: "MicLevelMonitor", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "No audio input available"]) + } + input.removeTap(onBus: 0) + input.installTap(onBus: 0, bufferSize: 512, format: format) { [weak self] buffer, _ in + guard let self else { return } + let level = Self.normalizedLevel(from: buffer) + Task { await self.push(level: level) } + } + engine.prepare() + try engine.start() + self.running = true + } + + func stop() { + guard self.running else { return } + if let engine { + engine.inputNode.removeTap(onBus: 0) + engine.stop() + } + self.engine = nil + self.running = false + } + + private func push(level: Double) { + self.smoothedLevel = (self.smoothedLevel * 0.45) + (level * 0.55) + guard let update else { return } + let value = self.smoothedLevel + Task { @MainActor in update(value) } + } + + private static func normalizedLevel(from buffer: AVAudioPCMBuffer) -> Double { + guard let channel = buffer.floatChannelData?[0] else { return 0 } + let frameCount = Int(buffer.frameLength) + guard frameCount > 0 else { return 0 } + var sum: Float = 0 + for i in 0.. 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/Moltbot/ModelCatalogLoader.swift b/apps/macos/Sources/Moltbot/ModelCatalogLoader.swift new file mode 100644 index 000000000..1ef60104e --- /dev/null +++ b/apps/macos/Sources/Moltbot/ModelCatalogLoader.swift @@ -0,0 +1,156 @@ +import Foundation +import JavaScriptCore + +enum ModelCatalogLoader { + static var defaultPath: String { self.resolveDefaultPath() } + private static let logger = Logger(subsystem: "bot.molt", category: "models") + private nonisolated static let appSupportDir: URL = { + let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + return base.appendingPathComponent("Moltbot", isDirectory: true) + }() + + private static var cachePath: URL { + self.appSupportDir.appendingPathComponent("model-catalog/models.generated.js", isDirectory: false) + } + + static func load(from path: String) async throws -> [ModelChoice] { + let expanded = (path as NSString).expandingTildeInPath + guard let resolved = self.resolvePath(preferred: expanded) else { + self.logger.error("model catalog load failed: file not found") + throw NSError( + domain: "ModelCatalogLoader", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Model catalog file not found"]) + } + self.logger.debug("model catalog load start file=\(URL(fileURLWithPath: resolved.path).lastPathComponent)") + let source = try String(contentsOfFile: resolved.path, encoding: .utf8) + let sanitized = self.sanitize(source: source) + + let ctx = JSContext() + ctx?.exceptionHandler = { _, exception in + if let exception { + self.logger.warning("model catalog JS exception: \(exception)") + } + } + ctx?.evaluateScript(sanitized) + guard let rawModels = ctx?.objectForKeyedSubscript("MODELS")?.toDictionary() as? [String: Any] else { + self.logger.error("model catalog parse failed: MODELS missing") + throw NSError( + domain: "ModelCatalogLoader", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Failed to parse models.generated.ts"]) + } + + var choices: [ModelChoice] = [] + for (provider, value) in rawModels { + guard let models = value as? [String: Any] else { continue } + for (id, payload) in models { + guard let dict = payload as? [String: Any] else { continue } + let name = dict["name"] as? String ?? id + let ctxWindow = dict["contextWindow"] as? Int + choices.append(ModelChoice(id: id, name: name, provider: provider, contextWindow: ctxWindow)) + } + } + + let sorted = choices.sorted { lhs, rhs in + if lhs.provider == rhs.provider { + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + return lhs.provider.localizedCaseInsensitiveCompare(rhs.provider) == .orderedAscending + } + self.logger.debug("model catalog loaded providers=\(rawModels.count) models=\(sorted.count)") + if resolved.shouldCache { + self.cacheCatalog(sourcePath: resolved.path) + } + return sorted + } + + private static func resolveDefaultPath() -> String { + let cache = self.cachePath.path + if FileManager().isReadableFile(atPath: cache) { return cache } + if let bundlePath = self.bundleCatalogPath() { return bundlePath } + if let nodePath = self.nodeModulesCatalogPath() { return nodePath } + return cache + } + + private static func resolvePath(preferred: String) -> (path: String, shouldCache: Bool)? { + if FileManager().isReadableFile(atPath: preferred) { + return (preferred, preferred != self.cachePath.path) + } + + if let bundlePath = self.bundleCatalogPath(), bundlePath != preferred { + self.logger.warning("model catalog path missing; falling back to bundled catalog") + return (bundlePath, true) + } + + let cache = self.cachePath.path + if cache != preferred, FileManager().isReadableFile(atPath: cache) { + self.logger.warning("model catalog path missing; falling back to cached catalog") + return (cache, false) + } + + if let nodePath = self.nodeModulesCatalogPath(), nodePath != preferred { + self.logger.warning("model catalog path missing; falling back to node_modules catalog") + return (nodePath, true) + } + + return nil + } + + private static func bundleCatalogPath() -> String? { + guard let url = Bundle.main.url(forResource: "models.generated", withExtension: "js") else { + return nil + } + return url.path + } + + private static func nodeModulesCatalogPath() -> String? { + let roots = [ + URL(fileURLWithPath: CommandResolver.projectRootPath()), + URL(fileURLWithPath: FileManager().currentDirectoryPath), + ] + for root in roots { + let candidate = root + .appendingPathComponent("node_modules/@mariozechner/pi-ai/dist/models.generated.js") + if FileManager().isReadableFile(atPath: candidate.path) { + return candidate.path + } + } + return nil + } + + private static func cacheCatalog(sourcePath: String) { + let destination = self.cachePath + do { + try FileManager().createDirectory( + at: destination.deletingLastPathComponent(), + withIntermediateDirectories: true) + if FileManager().fileExists(atPath: destination.path) { + try FileManager().removeItem(at: destination) + } + try FileManager().copyItem(atPath: sourcePath, toPath: destination.path) + self.logger.debug("model catalog cached file=\(destination.lastPathComponent)") + } catch { + self.logger.warning("model catalog cache failed: \(error.localizedDescription)") + } + } + + private static func sanitize(source: String) -> String { + guard let exportRange = source.range(of: "export const MODELS"), + let firstBrace = source[exportRange.upperBound...].firstIndex(of: "{"), + let lastBrace = source.lastIndex(of: "}") + else { + return "var MODELS = {}" + } + var body = String(source[firstBrace...lastBrace]) + body = body.replacingOccurrences( + of: #"(?m)\bsatisfies\s+[^,}\n]+"#, + with: "", + options: .regularExpression) + body = body.replacingOccurrences( + of: #"(?m)\bas\s+[^;,\n]+"#, + with: "", + options: .regularExpression) + return "var MODELS = \(body);" + } +} diff --git a/apps/macos/Sources/Moltbot/NodeMode/MacNodeModeCoordinator.swift b/apps/macos/Sources/Moltbot/NodeMode/MacNodeModeCoordinator.swift new file mode 100644 index 000000000..3d619f53b --- /dev/null +++ b/apps/macos/Sources/Moltbot/NodeMode/MacNodeModeCoordinator.swift @@ -0,0 +1,171 @@ +import MoltbotKit +import Foundation +import OSLog + +@MainActor +final class MacNodeModeCoordinator { + static let shared = MacNodeModeCoordinator() + + private let logger = Logger(subsystem: "bot.molt", category: "mac-node") + private var task: Task? + 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/Moltbot/NodePairingApprovalPrompter.swift b/apps/macos/Sources/Moltbot/NodePairingApprovalPrompter.swift new file mode 100644 index 000000000..3f2aff19d --- /dev/null +++ b/apps/macos/Sources/Moltbot/NodePairingApprovalPrompter.swift @@ -0,0 +1,708 @@ +import AppKit +import MoltbotDiscovery +import MoltbotIPC +import MoltbotKit +import MoltbotProtocol +import Foundation +import Observation +import OSLog +import UserNotifications + +enum NodePairingReconcilePolicy { + static let activeIntervalMs: UInt64 = 15000 + static let resyncDelayMs: UInt64 = 250 + + static func shouldPoll(pendingCount: Int, isPresenting: Bool) -> Bool { + pendingCount > 0 || isPresenting + } +} + +@MainActor +@Observable +final class NodePairingApprovalPrompter { + static let shared = NodePairingApprovalPrompter() + + private let logger = Logger(subsystem: "bot.molt", category: "node-pairing") + private var task: Task? + 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/Moltbot/NodeServiceManager.swift b/apps/macos/Sources/Moltbot/NodeServiceManager.swift new file mode 100644 index 000000000..bceba7c39 --- /dev/null +++ b/apps/macos/Sources/Moltbot/NodeServiceManager.swift @@ -0,0 +1,150 @@ +import Foundation +import OSLog + +enum NodeServiceManager { + private static let logger = Logger(subsystem: "bot.molt", category: "node.service") + + static func start() async -> String? { + let result = await self.runServiceCommandResult( + ["node", "start"], + timeout: 20, + quiet: false) + if let error = self.errorMessage(from: result, treatNotLoadedAsError: true) { + self.logger.error("node service start failed: \(error, privacy: .public)") + return error + } + return nil + } + + static func stop() async -> String? { + let result = await self.runServiceCommandResult( + ["node", "stop"], + timeout: 15, + quiet: false) + if let error = self.errorMessage(from: result, treatNotLoadedAsError: false) { + self.logger.error("node service stop failed: \(error, privacy: .public)") + return error + } + return nil + } +} + +extension NodeServiceManager { + private struct CommandResult { + let success: Bool + let payload: Data? + let message: String? + let parsed: ParsedServiceJson? + } + + private struct ParsedServiceJson { + let text: String + let object: [String: Any] + let ok: Bool? + let result: String? + let message: String? + let error: String? + let hints: [String] + } + + private static func runServiceCommandResult( + _ args: [String], + timeout: Double, + quiet: Bool) async -> CommandResult + { + let command = CommandResolver.clawdbotCommand( + subcommand: "service", + extraArgs: self.withJsonFlag(args), + // Service management must always run locally, even if remote mode is configured. + configRoot: ["gateway": ["mode": "local"]]) + var env = ProcessInfo.processInfo.environment + env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":") + let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout) + let parsed = self.parseServiceJson(from: response.stdout) ?? self.parseServiceJson(from: response.stderr) + let ok = parsed?.ok + let message = parsed?.error ?? parsed?.message + let payload = parsed?.text.data(using: .utf8) + ?? (response.stdout.isEmpty ? response.stderr : response.stdout).data(using: .utf8) + let success = ok ?? response.success + if success { + return CommandResult(success: true, payload: payload, message: nil, parsed: parsed) + } + + if quiet { + return CommandResult(success: false, payload: payload, message: message, parsed: parsed) + } + + let detail = message ?? self.summarize(response.stderr) ?? self.summarize(response.stdout) + let exit = response.exitCode.map { "exit \($0)" } ?? (response.errorMessage ?? "failed") + let fullMessage = detail.map { "Node service command failed (\(exit)): \($0)" } + ?? "Node service command failed (\(exit))" + self.logger.error("\(fullMessage, privacy: .public)") + return CommandResult(success: false, payload: payload, message: detail, parsed: parsed) + } + + private static func errorMessage(from result: CommandResult, treatNotLoadedAsError: Bool) -> String? { + if !result.success { + return result.message ?? "Node service command failed" + } + guard let parsed = result.parsed else { return nil } + if parsed.ok == false { + return self.mergeHints(message: parsed.error ?? parsed.message, hints: parsed.hints) + } + if treatNotLoadedAsError, parsed.result == "not-loaded" { + let base = parsed.message ?? "Node service not loaded." + return self.mergeHints(message: base, hints: parsed.hints) + } + return nil + } + + private static func withJsonFlag(_ args: [String]) -> [String] { + if args.contains("--json") { return args } + return args + ["--json"] + } + + private static func parseServiceJson(from raw: String) -> ParsedServiceJson? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard let start = trimmed.firstIndex(of: "{"), + let end = trimmed.lastIndex(of: "}") + else { + return nil + } + let jsonText = String(trimmed[start...end]) + guard let data = jsonText.data(using: .utf8) else { return nil } + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + let ok = object["ok"] as? Bool + let result = object["result"] as? String + let message = object["message"] as? String + let error = object["error"] as? String + let hints = (object["hints"] as? [String]) ?? [] + return ParsedServiceJson( + text: jsonText, + object: object, + ok: ok, + result: result, + message: message, + error: error, + hints: hints) + } + + private static func mergeHints(message: String?, hints: [String]) -> String? { + let trimmed = message?.trimmingCharacters(in: .whitespacesAndNewlines) + let nonEmpty = trimmed?.isEmpty == false ? trimmed : nil + guard !hints.isEmpty else { return nonEmpty } + let hintText = hints.prefix(2).joined(separator: " · ") + if let nonEmpty { + return "\(nonEmpty) (\(hintText))" + } + return hintText + } + + private static func summarize(_ text: String) -> String? { + let lines = text + .split(whereSeparator: \.isNewline) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + guard let last = lines.last else { return nil } + let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) + return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized + } +} diff --git a/apps/macos/Sources/Moltbot/NodesStore.swift b/apps/macos/Sources/Moltbot/NodesStore.swift new file mode 100644 index 000000000..ae21a902c --- /dev/null +++ b/apps/macos/Sources/Moltbot/NodesStore.swift @@ -0,0 +1,102 @@ +import Foundation +import Observation +import OSLog + +struct NodeInfo: Identifiable, Codable { + let nodeId: String + let displayName: String? + let platform: String? + let version: String? + let coreVersion: String? + let uiVersion: String? + let deviceFamily: String? + let modelIdentifier: String? + let remoteIp: String? + let caps: [String]? + let commands: [String]? + let permissions: [String: Bool]? + let paired: Bool? + let connected: Bool? + + var id: String { self.nodeId } + var isConnected: Bool { self.connected ?? false } + var isPaired: Bool { self.paired ?? false } +} + +private struct NodeListResponse: Codable { + let ts: Double? + let nodes: [NodeInfo] +} + +@MainActor +@Observable +final class NodesStore { + static let shared = NodesStore() + + var nodes: [NodeInfo] = [] + var lastError: String? + var statusMessage: String? + var isLoading = false + + private let logger = Logger(subsystem: "bot.molt", category: "nodes") + private var task: Task? + 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/Moltbot/NotificationManager.swift b/apps/macos/Sources/Moltbot/NotificationManager.swift new file mode 100644 index 000000000..53659e15d --- /dev/null +++ b/apps/macos/Sources/Moltbot/NotificationManager.swift @@ -0,0 +1,66 @@ +import MoltbotIPC +import Foundation +import Security +import UserNotifications + +@MainActor +struct NotificationManager { + private let logger = Logger(subsystem: "bot.molt", category: "notifications") + + private static let hasTimeSensitiveEntitlement: Bool = { + guard let task = SecTaskCreateFromSelf(nil) else { return false } + let key = "com.apple.developer.usernotifications.time-sensitive" as CFString + guard let val = SecTaskCopyValueForEntitlement(task, key, nil) else { return false } + return (val as? Bool) == true + }() + + func send(title: String, body: String, sound: String?, priority: NotificationPriority? = nil) async -> Bool { + let center = UNUserNotificationCenter.current() + let status = await center.notificationSettings() + if status.authorizationStatus == .notDetermined { + let granted = try? await center.requestAuthorization(options: [.alert, .sound, .badge]) + if granted != true { + self.logger.warning("notification permission denied (request)") + return false + } + } else if status.authorizationStatus != .authorized { + self.logger.warning("notification permission denied status=\(status.authorizationStatus.rawValue)") + return false + } + + let content = UNMutableNotificationContent() + content.title = title + content.body = body + if let soundName = sound, !soundName.isEmpty { + content.sound = UNNotificationSound(named: UNNotificationSoundName(soundName)) + } + + // Set interruption level based on priority + if let priority { + switch priority { + case .passive: + content.interruptionLevel = .passive + case .active: + content.interruptionLevel = .active + case .timeSensitive: + if Self.hasTimeSensitiveEntitlement { + content.interruptionLevel = .timeSensitive + } else { + self.logger.debug( + "time-sensitive notification requested without entitlement; falling back to active") + content.interruptionLevel = .active + } + } + } + + let req = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) + do { + try await center.add(req) + self.logger.debug("notification queued") + return true + } catch { + self.logger.error("notification send failed: \(error.localizedDescription)") + return false + } + } +} diff --git a/apps/macos/Sources/Moltbot/OnboardingWizard.swift b/apps/macos/Sources/Moltbot/OnboardingWizard.swift new file mode 100644 index 000000000..f06636071 --- /dev/null +++ b/apps/macos/Sources/Moltbot/OnboardingWizard.swift @@ -0,0 +1,412 @@ +import MoltbotKit +import MoltbotProtocol +import Foundation +import Observation +import OSLog +import SwiftUI + +private let onboardingWizardLogger = Logger(subsystem: "bot.molt", category: "onboarding.wizard") + +// MARK: - Swift 6 AnyCodable Bridging Helpers + +// Bridge between MoltbotProtocol.AnyCodable and the local module to avoid +// Swift 6 strict concurrency type conflicts. + +private typealias ProtocolAnyCodable = MoltbotProtocol.AnyCodable + +private func bridgeToLocal(_ value: ProtocolAnyCodable) -> AnyCodable { + if let data = try? JSONEncoder().encode(value), + let decoded = try? JSONDecoder().decode(AnyCodable.self, from: data) + { + return decoded + } + return AnyCodable(value.value) +} + +private func bridgeToLocal(_ value: ProtocolAnyCodable?) -> AnyCodable? { + value.map(bridgeToLocal) +} + +@MainActor +@Observable +final class OnboardingWizardModel { + private(set) var sessionId: String? + private(set) var currentStep: WizardStep? + private(set) var status: String? + private(set) var errorMessage: String? + var isStarting = false + var isSubmitting = false + private var lastStartMode: AppState.ConnectionMode? + private var lastStartWorkspace: String? + private var restartAttempts = 0 + private let maxRestartAttempts = 1 + + var isComplete: Bool { self.status == "done" } + var isRunning: Bool { self.status == "running" } + + func reset() { + self.sessionId = nil + self.currentStep = nil + self.status = nil + self.errorMessage = nil + self.isStarting = false + self.isSubmitting = false + self.restartAttempts = 0 + self.lastStartMode = nil + self.lastStartWorkspace = nil + } + + func startIfNeeded(mode: AppState.ConnectionMode, workspace: String? = nil) async { + guard self.sessionId == nil, !self.isStarting else { return } + guard mode == .local else { return } + if self.shouldSkipWizard() { + self.sessionId = nil + self.currentStep = nil + self.status = "done" + self.errorMessage = nil + return + } + self.isStarting = true + self.errorMessage = nil + self.lastStartMode = mode + self.lastStartWorkspace = workspace + defer { self.isStarting = false } + + do { + GatewayProcessManager.shared.setActive(true) + if await GatewayProcessManager.shared.waitForGatewayReady(timeout: 12) == false { + throw NSError( + domain: "Gateway", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Gateway did not become ready. Check that it is running."]) + } + var params: [String: AnyCodable] = ["mode": AnyCodable("local")] + if let workspace, !workspace.isEmpty { + params["workspace"] = AnyCodable(workspace) + } + let res: WizardStartResult = try await GatewayConnection.shared.requestDecoded( + method: .wizardStart, + params: params) + self.applyStartResult(res) + } catch { + self.status = "error" + self.errorMessage = error.localizedDescription + onboardingWizardLogger.error("start failed: \(error.localizedDescription, privacy: .public)") + } + } + + func submit(step: WizardStep, value: AnyCodable?) async { + guard let sessionId, !self.isSubmitting else { return } + self.isSubmitting = true + self.errorMessage = nil + defer { self.isSubmitting = false } + + do { + var params: [String: AnyCodable] = ["sessionId": AnyCodable(sessionId)] + var answer: [String: AnyCodable] = ["stepId": AnyCodable(step.id)] + if let value { + answer["value"] = value + } + params["answer"] = AnyCodable(answer) + let res: WizardNextResult = try await GatewayConnection.shared.requestDecoded( + method: .wizardNext, + params: params) + self.applyNextResult(res) + } catch { + if self.restartIfSessionLost(error: error) { + return + } + self.status = "error" + self.errorMessage = error.localizedDescription + onboardingWizardLogger.error("submit failed: \(error.localizedDescription, privacy: .public)") + } + } + + func cancelIfRunning() async { + guard let sessionId, self.isRunning else { return } + do { + let res: WizardStatusResult = try await GatewayConnection.shared.requestDecoded( + method: .wizardCancel, + params: ["sessionId": AnyCodable(sessionId)]) + self.applyStatusResult(res) + } catch { + self.status = "error" + self.errorMessage = error.localizedDescription + onboardingWizardLogger.error("cancel failed: \(error.localizedDescription, privacy: .public)") + } + } + + private func applyStartResult(_ res: WizardStartResult) { + self.sessionId = res.sessionid + self.status = wizardStatusString(res.status) ?? (res.done ? "done" : "running") + self.errorMessage = res.error + self.currentStep = decodeWizardStep(res.step) + if self.currentStep == nil, res.step != nil { + onboardingWizardLogger.error("wizard step decode failed") + } + if res.done { self.currentStep = nil } + self.restartAttempts = 0 + } + + private func applyNextResult(_ res: WizardNextResult) { + let status = wizardStatusString(res.status) + self.status = status ?? self.status + self.errorMessage = res.error + self.currentStep = decodeWizardStep(res.step) + if self.currentStep == nil, res.step != nil { + onboardingWizardLogger.error("wizard step decode failed") + } + if res.done { self.currentStep = nil } + if res.done || status == "done" || status == "cancelled" || status == "error" { + self.sessionId = nil + } + } + + private func applyStatusResult(_ res: WizardStatusResult) { + self.status = wizardStatusString(res.status) ?? "unknown" + self.errorMessage = res.error + self.currentStep = nil + self.sessionId = nil + } + + private func restartIfSessionLost(error: Error) -> Bool { + guard let gatewayError = error as? GatewayResponseError else { return false } + guard gatewayError.code == ErrorCode.invalidRequest.rawValue else { return false } + let message = gatewayError.message.lowercased() + guard message.contains("wizard not found") || message.contains("wizard not running") else { return false } + guard let mode = self.lastStartMode, self.restartAttempts < self.maxRestartAttempts else { + return false + } + self.restartAttempts += 1 + self.sessionId = nil + self.currentStep = nil + self.status = nil + self.errorMessage = "Wizard session lost. Restarting…" + Task { await self.startIfNeeded(mode: mode, workspace: self.lastStartWorkspace) } + return true + } + + private func shouldSkipWizard() -> Bool { + let root = MoltbotConfigFile.loadDict() + if let wizard = root["wizard"] as? [String: Any], !wizard.isEmpty { + return true + } + if let gateway = root["gateway"] as? [String: Any], + let auth = gateway["auth"] as? [String: Any] + { + if let mode = auth["mode"] as? String, + !mode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + return true + } + if let token = auth["token"] as? String, + !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + return true + } + if let password = auth["password"] as? String, + !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + return true + } + } + return false + } +} + +struct OnboardingWizardStepView: View { + let step: WizardStep + let isSubmitting: Bool + let onStepSubmit: (AnyCodable?) -> Void + + @State private var textValue: String + @State private var confirmValue: Bool + @State private var selectedIndex: Int + @State private var selectedIndices: Set + + 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/Moltbot/PeekabooBridgeHostCoordinator.swift b/apps/macos/Sources/Moltbot/PeekabooBridgeHostCoordinator.swift new file mode 100644 index 000000000..16f5f554e --- /dev/null +++ b/apps/macos/Sources/Moltbot/PeekabooBridgeHostCoordinator.swift @@ -0,0 +1,130 @@ +import Foundation +import os +import PeekabooAutomationKit +import PeekabooBridge +import PeekabooFoundation +import Security + +@MainActor +final class PeekabooBridgeHostCoordinator { + static let shared = PeekabooBridgeHostCoordinator() + + private let logger = Logger(subsystem: "bot.molt", category: "PeekabooBridge") + + private var host: PeekabooBridgeHost? + private var services: MoltbotPeekabooBridgeServices? + + func setEnabled(_ enabled: Bool) async { + if enabled { + await self.startIfNeeded() + } else { + await self.stop() + } + } + + func stop() async { + guard let host else { return } + await host.stop() + self.host = nil + self.services = nil + self.logger.info("PeekabooBridge host stopped") + } + + private func startIfNeeded() async { + guard self.host == nil else { return } + + var allowlistedTeamIDs: Set = ["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: "bot.molt.peekaboo") + let feedbackClient: any AutomationFeedbackClient = NoopAutomationFeedbackClient() + + let snapshots = InMemorySnapshotManager(options: .init( + snapshotValidityWindow: 600, + maxSnapshots: 50, + deleteArtifactsOnCleanup: false)) + let applications = ApplicationService(feedbackClient: feedbackClient) + + let screenCapture = ScreenCaptureService(loggingService: logging) + + self.permissions = PermissionsService() + self.snapshots = snapshots + self.applications = applications + self.screenCapture = screenCapture + self.automation = UIAutomationService( + snapshotManager: snapshots, + loggingService: logging, + searchPolicy: .balanced, + feedbackClient: feedbackClient) + self.windows = WindowManagementService(applicationService: applications, feedbackClient: feedbackClient) + self.menu = MenuService(applicationService: applications, feedbackClient: feedbackClient) + self.dock = DockService(feedbackClient: feedbackClient) + self.dialogs = DialogService(feedbackClient: feedbackClient) + } +} diff --git a/apps/macos/Sources/Moltbot/PermissionManager.swift b/apps/macos/Sources/Moltbot/PermissionManager.swift new file mode 100644 index 000000000..f001827a0 --- /dev/null +++ b/apps/macos/Sources/Moltbot/PermissionManager.swift @@ -0,0 +1,506 @@ +import AppKit +import ApplicationServices +import AVFoundation +import MoltbotIPC +import CoreGraphics +import CoreLocation +import Foundation +import Observation +import Speech +import UserNotifications + +enum PermissionManager { + static func isLocationAuthorized(status: CLAuthorizationStatus, requireAlways: Bool) -> Bool { + if requireAlways { return status == .authorizedAlways } + switch status { + case .authorizedAlways, .authorizedWhenInUse: + return true + case .authorized: // deprecated, but still shows up on some macOS versions + return true + default: + return false + } + } + + static func ensure(_ caps: [Capability], interactive: Bool) async -> [Capability: Bool] { + var results: [Capability: Bool] = [:] + for cap in caps { + results[cap] = await self.ensureCapability(cap, interactive: interactive) + } + return results + } + + private static func ensureCapability(_ cap: Capability, interactive: Bool) async -> Bool { + switch cap { + case .notifications: + await self.ensureNotifications(interactive: interactive) + case .appleScript: + await self.ensureAppleScript(interactive: interactive) + case .accessibility: + await self.ensureAccessibility(interactive: interactive) + case .screenRecording: + await self.ensureScreenRecording(interactive: interactive) + case .microphone: + await self.ensureMicrophone(interactive: interactive) + case .speechRecognition: + await self.ensureSpeechRecognition(interactive: interactive) + case .camera: + await self.ensureCamera(interactive: interactive) + case .location: + await self.ensureLocation(interactive: interactive) + } + } + + private static func ensureNotifications(interactive: Bool) async -> Bool { + let center = UNUserNotificationCenter.current() + let settings = await center.notificationSettings() + + switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: + return true + case .notDetermined: + guard interactive else { return false } + let granted = await (try? center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false + let updated = await center.notificationSettings() + return granted && + (updated.authorizationStatus == .authorized || updated.authorizationStatus == .provisional) + case .denied: + if interactive { + NotificationPermissionHelper.openSettings() + } + return false + @unknown default: + return false + } + } + + private static func ensureAppleScript(interactive: Bool) async -> Bool { + let granted = await MainActor.run { AppleScriptPermission.isAuthorized() } + if interactive, !granted { + await AppleScriptPermission.requestAuthorization() + } + return await MainActor.run { AppleScriptPermission.isAuthorized() } + } + + private static func ensureAccessibility(interactive: Bool) async -> Bool { + let trusted = await MainActor.run { AXIsProcessTrusted() } + if interactive, !trusted { + await MainActor.run { + let opts: NSDictionary = ["AXTrustedCheckOptionPrompt": true] + _ = AXIsProcessTrustedWithOptions(opts) + } + } + return await MainActor.run { AXIsProcessTrusted() } + } + + private static func ensureScreenRecording(interactive: Bool) async -> Bool { + let granted = ScreenRecordingProbe.isAuthorized() + if interactive, !granted { + await ScreenRecordingProbe.requestAuthorization() + } + return ScreenRecordingProbe.isAuthorized() + } + + private static func ensureMicrophone(interactive: Bool) async -> Bool { + let status = AVCaptureDevice.authorizationStatus(for: .audio) + switch status { + case .authorized: + return true + case .notDetermined: + guard interactive else { return false } + return await AVCaptureDevice.requestAccess(for: .audio) + case .denied, .restricted: + if interactive { + MicrophonePermissionHelper.openSettings() + } + return false + @unknown default: + return false + } + } + + private static func ensureSpeechRecognition(interactive: Bool) async -> Bool { + let status = SFSpeechRecognizer.authorizationStatus() + if status == .notDetermined, interactive { + await withUnsafeContinuation { (cont: UnsafeContinuation) 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: "bot.molt", category: "AppleScriptPermission") + + /// Sends a benign AppleScript to Terminal to verify Automation permission. + @MainActor + static func isAuthorized() -> Bool { + let script = """ + tell application "Terminal" + return "moltbot-ok" + end tell + """ + + var error: NSDictionary? + let appleScript = NSAppleScript(source: script) + let result = appleScript?.executeAndReturnError(&error) + + if let error, let code = error["NSAppleScriptErrorNumber"] as? Int { + if code == -1743 { // errAEEventWouldRequireUserConsent + Self.logger.debug("AppleScript permission denied (-1743)") + return false + } + Self.logger.debug("AppleScript check failed with code \(code)") + } + + return result != nil + } + + /// Triggers the TCC prompt and opens System Settings → Privacy & Security → Automation. + @MainActor + static func requestAuthorization() async { + _ = self.isAuthorized() // first attempt triggers the dialog if not granted + + // Open the Automation pane to help the user if the prompt was dismissed. + let urlStrings = [ + "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation", + "x-apple.systempreferences:com.apple.preference.security", + ] + + for candidate in urlStrings { + if let url = URL(string: candidate), NSWorkspace.shared.open(url) { + break + } + } + } +} + +@MainActor +@Observable +final class PermissionMonitor { + static let shared = PermissionMonitor() + + private(set) var status: [Capability: Bool] = [:] + + private var monitorTimer: Timer? + private var isChecking = false + private var registrations = 0 + private var lastCheck: Date? + private let minimumCheckInterval: TimeInterval = 0.5 + + func register() { + self.registrations += 1 + if self.registrations == 1 { + self.startMonitoring() + } + } + + func unregister() { + guard self.registrations > 0 else { return } + self.registrations -= 1 + if self.registrations == 0 { + self.stopMonitoring() + } + } + + func refreshNow() async { + await self.checkStatus(force: true) + } + + private func startMonitoring() { + Task { await self.checkStatus(force: true) } + + if ProcessInfo.processInfo.isRunningTests { + return + } + self.monitorTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + guard let self else { return } + Task { @MainActor in + await self.checkStatus(force: false) + } + } + } + + private func stopMonitoring() { + self.monitorTimer?.invalidate() + self.monitorTimer = nil + self.lastCheck = nil + } + + private func checkStatus(force: Bool) async { + if self.isChecking { return } + let now = Date() + if !force, let lastCheck, now.timeIntervalSince(lastCheck) < self.minimumCheckInterval { + return + } + + self.isChecking = true + + let latest = await PermissionManager.status() + if latest != self.status { + self.status = latest + } + self.lastCheck = Date() + + self.isChecking = false + } +} + +enum ScreenRecordingProbe { + static func isAuthorized() -> Bool { + if #available(macOS 10.15, *) { + return CGPreflightScreenCaptureAccess() + } + return true + } + + @MainActor + static func requestAuthorization() async { + if #available(macOS 10.15, *) { + _ = CGRequestScreenCaptureAccess() + } + } +} diff --git a/apps/macos/Sources/Moltbot/PortGuardian.swift b/apps/macos/Sources/Moltbot/PortGuardian.swift new file mode 100644 index 000000000..c96b66802 --- /dev/null +++ b/apps/macos/Sources/Moltbot/PortGuardian.swift @@ -0,0 +1,418 @@ +import Foundation +import OSLog +#if canImport(Darwin) +import Darwin +#endif + +actor PortGuardian { + static let shared = PortGuardian() + + struct Record: Codable { + let port: Int + let pid: Int32 + let command: String + let mode: String + let timestamp: TimeInterval + } + + struct Descriptor: Sendable { + let pid: Int32 + let command: String + let executablePath: String? + } + + private var records: [Record] = [] + private let logger = Logger(subsystem: "bot.molt", category: "portguard") + private nonisolated static let appSupportDir: URL = { + let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + return base.appendingPathComponent("Moltbot", isDirectory: true) + }() + + private nonisolated static var recordPath: URL { + self.appSupportDir.appendingPathComponent("port-guard.json", isDirectory: false) + } + + init() { + self.records = Self.loadRecords(from: Self.recordPath) + } + + func sweep(mode: AppState.ConnectionMode) async { + self.logger.info("port sweep starting (mode=\(mode.rawValue, privacy: .public))") + guard mode != .unconfigured else { + self.logger.info("port sweep skipped (mode=unconfigured)") + return + } + let ports = [GatewayEnvironment.gatewayPort()] + for port in ports { + let listeners = await self.listeners(on: port) + guard !listeners.isEmpty else { continue } + for listener in listeners { + if self.isExpected(listener, port: port, mode: mode) { + let message = """ + port \(port) already served by expected \(listener.command) + (pid \(listener.pid)) — keeping + """ + self.logger.info("\(message, privacy: .public)") + continue + } + let killed = await self.kill(listener.pid) + if killed { + let message = """ + port \(port) was held by \(listener.command) + (pid \(listener.pid)); terminated + """ + self.logger.error("\(message, privacy: .public)") + } else { + self.logger.error("failed to terminate pid \(listener.pid) on port \(port, privacy: .public)") + } + } + } + self.logger.info("port sweep done") + } + + func record(port: Int, pid: Int32, command: String, mode: AppState.ConnectionMode) async { + try? FileManager().createDirectory(at: Self.appSupportDir, withIntermediateDirectories: true) + self.records.removeAll { $0.pid == pid } + self.records.append( + Record( + port: port, + pid: pid, + command: command, + mode: mode.rawValue, + timestamp: Date().timeIntervalSince1970)) + self.save() + } + + func removeRecord(pid: Int32) { + let before = self.records.count + self.records.removeAll { $0.pid == pid } + if self.records.count != before { + self.save() + } + } + + struct PortReport: Identifiable { + enum Status { + case ok(String) + case missing(String) + case interference(String, offenders: [ReportListener]) + } + + let port: Int + let expected: String + let status: Status + let listeners: [ReportListener] + + var id: Int { self.port } + + var offenders: [ReportListener] { + if case let .interference(_, offenders) = self.status { return offenders } + return [] + } + + var summary: String { + switch self.status { + case let .ok(text): text + case let .missing(text): text + case let .interference(text, _): text + } + } + } + + func describe(port: Int) async -> Descriptor? { + guard let listener = await self.listeners(on: port).first else { return nil } + let path = Self.executablePath(for: listener.pid) + return Descriptor(pid: listener.pid, command: listener.command, executablePath: path) + } + + // MARK: - Internals + + private struct Listener { + let pid: Int32 + let command: String + let fullCommand: String + let user: String? + } + + struct ReportListener: Identifiable { + let pid: Int32 + let command: String + let fullCommand: String + let user: String? + let expected: Bool + + var id: Int32 { self.pid } + } + + func diagnose(mode: AppState.ConnectionMode) async -> [PortReport] { + if mode == .unconfigured { + return [] + } + let ports = [GatewayEnvironment.gatewayPort()] + var reports: [PortReport] = [] + + for port in ports { + let listeners = await self.listeners(on: port) + let tunnelHealthy = await self.probeGatewayHealthIfNeeded( + port: port, + mode: mode, + listeners: listeners) + reports.append(Self.buildReport( + port: port, + listeners: listeners, + mode: mode, + tunnelHealthy: tunnelHealthy)) + } + + return reports + } + + func probeGatewayHealth(port: Int, timeout: TimeInterval = 2.0) async -> Bool { + let url = URL(string: "http://127.0.0.1:\(port)/")! + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = timeout + config.timeoutIntervalForResource = timeout + let session = URLSession(configuration: config) + var request = URLRequest(url: url) + request.cachePolicy = .reloadIgnoringLocalCacheData + request.timeoutInterval = timeout + do { + let (_, response) = try await session.data(for: request) + return response is HTTPURLResponse + } catch { + return false + } + } + + func isListening(port: Int, pid: Int32? = nil) async -> Bool { + let listeners = await self.listeners(on: port) + if let pid { + return listeners.contains(where: { $0.pid == pid }) + } + return !listeners.isEmpty + } + + private func listeners(on port: Int) async -> [Listener] { + let res = await ShellExecutor.run( + command: ["lsof", "-nP", "-iTCP:\(port)", "-sTCP:LISTEN", "-Fpcn"], + cwd: nil, + env: nil, + timeout: 5) + guard res.ok, let data = res.payload, !data.isEmpty else { return [] } + let text = String(data: data, encoding: .utf8) ?? "" + return Self.parseListeners(from: text) + } + + private static func readFullCommand(pid: Int32) -> String? { + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/bin/ps") + proc.arguments = ["-p", "\(pid)", "-o", "command="] + let pipe = Pipe() + proc.standardOutput = pipe + proc.standardError = Pipe() + do { + let data = try proc.runAndReadToEnd(from: pipe) + guard !data.isEmpty else { return nil } + return String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + } catch { + return nil + } + } + + private static func parseListeners(from text: String) -> [Listener] { + var listeners: [Listener] = [] + var currentPid: Int32? + var currentCmd: String? + var currentUser: String? + + func flush() { + if let pid = currentPid, let cmd = currentCmd { + let full = Self.readFullCommand(pid: pid) ?? cmd + listeners.append(Listener(pid: pid, command: cmd, fullCommand: full, user: currentUser)) + } + currentPid = nil + currentCmd = nil + currentUser = nil + } + + for line in text.split(separator: "\n") { + guard let prefix = line.first else { continue } + let value = String(line.dropFirst()) + switch prefix { + case "p": + flush() + currentPid = Int32(value) ?? 0 + case "c": + currentCmd = value + case "u": + currentUser = value + default: + continue + } + } + flush() + return listeners + } + + private static func buildReport( + port: Int, + listeners: [Listener], + mode: AppState.ConnectionMode, + tunnelHealthy: Bool?) -> PortReport + { + let expectedDesc: String + let okPredicate: (Listener) -> Bool + let expectedCommands = ["node", "moltbot", "tsx", "pnpm", "bun"] + + switch mode { + case .remote: + expectedDesc = "SSH tunnel to remote gateway" + okPredicate = { $0.command.lowercased().contains("ssh") } + case .local: + expectedDesc = "Gateway websocket (node/tsx)" + okPredicate = { listener in + let c = listener.command.lowercased() + return expectedCommands.contains { c.contains($0) } + } + case .unconfigured: + expectedDesc = "Gateway not configured" + okPredicate = { _ in false } + } + + if listeners.isEmpty { + let text = "Nothing is listening on \(port) (\(expectedDesc))." + return .init(port: port, expected: expectedDesc, status: .missing(text), listeners: []) + } + + let tunnelUnhealthy = + mode == .remote && port == GatewayEnvironment.gatewayPort() && tunnelHealthy == false + let reportListeners = listeners.map { listener in + var expected = okPredicate(listener) + if tunnelUnhealthy, expected { expected = false } + return ReportListener( + pid: listener.pid, + command: listener.command, + fullCommand: listener.fullCommand, + user: listener.user, + expected: expected) + } + + let offenders = reportListeners.filter { !$0.expected } + if tunnelUnhealthy { + let list = listeners.map { "\($0.command) (\($0.pid))" }.joined(separator: ", ") + let reason = "Port \(port) is served by \(list), but the SSH tunnel is unhealthy." + return .init( + port: port, + expected: expectedDesc, + status: .interference(reason, offenders: offenders), + listeners: reportListeners) + } + if offenders.isEmpty { + let list = listeners.map { "\($0.command) (\($0.pid))" }.joined(separator: ", ") + let okText = "Port \(port) is served by \(list)." + return .init( + port: port, + expected: expectedDesc, + status: .ok(okText), + listeners: reportListeners) + } + + let list = offenders.map { "\($0.command) (\($0.pid))" }.joined(separator: ", ") + let reason = "Port \(port) is held by \(list), expected \(expectedDesc)." + return .init( + port: port, + expected: expectedDesc, + status: .interference(reason, offenders: offenders), + listeners: reportListeners) + } + + private static func executablePath(for pid: Int32) -> String? { + #if canImport(Darwin) + var buffer = [CChar](repeating: 0, count: Int(PATH_MAX)) + let length = proc_pidpath(pid, &buffer, UInt32(buffer.count)) + guard length > 0 else { return nil } + // Drop trailing null and decode as UTF-8. + let trimmed = buffer.prefix { $0 != 0 } + let bytes = trimmed.map { UInt8(bitPattern: $0) } + return String(bytes: bytes, encoding: .utf8) + #else + return nil + #endif + } + + private func kill(_ pid: Int32) async -> Bool { + let term = await ShellExecutor.run(command: ["kill", "-TERM", "\(pid)"], cwd: nil, env: nil, timeout: 2) + if term.ok { return true } + let sigkill = await ShellExecutor.run(command: ["kill", "-KILL", "\(pid)"], cwd: nil, env: nil, timeout: 2) + return sigkill.ok + } + + private func isExpected(_ listener: Listener, port: Int, mode: AppState.ConnectionMode) -> Bool { + let cmd = listener.command.lowercased() + let full = listener.fullCommand.lowercased() + switch mode { + case .remote: + // Remote mode expects an SSH tunnel for the gateway WebSocket port. + if port == GatewayEnvironment.gatewayPort() { return cmd.contains("ssh") } + return false + case .local: + // The gateway daemon may listen as `moltbot` or as its runtime (`node`, `bun`, etc). + if full.contains("gateway-daemon") { return true } + // If args are unavailable, treat a moltbot listener as expected. + if cmd.contains("moltbot"), full == cmd { return true } + return false + case .unconfigured: + return false + } + } + + private func probeGatewayHealthIfNeeded( + port: Int, + mode: AppState.ConnectionMode, + listeners: [Listener]) async -> Bool? + { + guard mode == .remote, port == GatewayEnvironment.gatewayPort(), !listeners.isEmpty else { return nil } + let hasSsh = listeners.contains { $0.command.lowercased().contains("ssh") } + guard hasSsh else { return nil } + return await self.probeGatewayHealth(port: port) + } + + private static func loadRecords(from url: URL) -> [Record] { + guard let data = try? Data(contentsOf: url), + let decoded = try? JSONDecoder().decode([Record].self, from: data) + else { return [] } + return decoded + } + + private func save() { + guard let data = try? JSONEncoder().encode(self.records) else { return } + try? data.write(to: Self.recordPath, options: [.atomic]) + } +} + +#if DEBUG +extension PortGuardian { + static func _testParseListeners(_ text: String) -> [( + pid: Int32, + command: String, + fullCommand: String, + user: String?)] + { + self.parseListeners(from: text).map { ($0.pid, $0.command, $0.fullCommand, $0.user) } + } + + static func _testBuildReport( + port: Int, + mode: AppState.ConnectionMode, + listeners: [(pid: Int32, command: String, fullCommand: String, user: String?)]) -> PortReport + { + let mapped = listeners.map { Listener( + pid: $0.pid, + command: $0.command, + fullCommand: $0.fullCommand, + user: $0.user) } + return Self.buildReport(port: port, listeners: mapped, mode: mode, tunnelHealthy: nil) + } +} +#endif diff --git a/apps/macos/Sources/Moltbot/PresenceReporter.swift b/apps/macos/Sources/Moltbot/PresenceReporter.swift new file mode 100644 index 000000000..369e277d6 --- /dev/null +++ b/apps/macos/Sources/Moltbot/PresenceReporter.swift @@ -0,0 +1,158 @@ +import Cocoa +import Darwin +import Foundation +import OSLog + +@MainActor +final class PresenceReporter { + static let shared = PresenceReporter() + + private let logger = Logger(subsystem: "bot.molt", category: "presence") + private var task: Task? + 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/Moltbot/RemotePortTunnel.swift b/apps/macos/Sources/Moltbot/RemotePortTunnel.swift new file mode 100644 index 000000000..8c6db89a3 --- /dev/null +++ b/apps/macos/Sources/Moltbot/RemotePortTunnel.swift @@ -0,0 +1,317 @@ +import Foundation +import Network +import OSLog +#if canImport(Darwin) +import Darwin +#endif + +/// Port forwarding tunnel for remote mode. +/// +/// Uses `ssh -N -L` to forward the remote gateway ports to localhost. +final class RemotePortTunnel { + private static let logger = Logger(subsystem: "bot.molt", category: "remote.tunnel") + + let process: Process + let localPort: UInt16? + private let stderrHandle: FileHandle? + + private init(process: Process, localPort: UInt16?, stderrHandle: FileHandle?) { + self.process = process + self.localPort = localPort + self.stderrHandle = stderrHandle + } + + deinit { + Self.cleanupStderr(self.stderrHandle) + let pid = self.process.processIdentifier + self.process.terminate() + Task { await PortGuardian.shared.removeRecord(pid: pid) } + } + + func terminate() { + Self.cleanupStderr(self.stderrHandle) + let pid = self.process.processIdentifier + if self.process.isRunning { + self.process.terminate() + self.process.waitUntilExit() + } + Task { await PortGuardian.shared.removeRecord(pid: pid) } + } + + static func create( + remotePort: Int, + preferredLocalPort: UInt16? = nil, + allowRemoteUrlOverride: Bool = true, + allowRandomLocalPort: Bool = true) async throws -> RemotePortTunnel + { + let settings = CommandResolver.connectionSettings() + guard settings.mode == .remote, let parsed = CommandResolver.parseSSHTarget(settings.target) else { + throw NSError( + domain: "RemotePortTunnel", + code: 3, + userInfo: [NSLocalizedDescriptionKey: "Remote mode is not configured"]) + } + + let localPort = try await Self.findPort( + preferred: preferredLocalPort, + allowRandom: allowRandomLocalPort) + let sshHost = parsed.host.trimmingCharacters(in: .whitespacesAndNewlines) + let remotePortOverride = + allowRemoteUrlOverride && remotePort == GatewayEnvironment.gatewayPort() + ? Self.resolveRemotePortOverride(for: sshHost) + : nil + let resolvedRemotePort = remotePortOverride ?? remotePort + if let override = remotePortOverride { + Self.logger.info( + "ssh tunnel remote port override " + + "host=\(sshHost, privacy: .public) port=\(override, privacy: .public)") + } else { + Self.logger.debug( + "ssh tunnel using default remote port " + + "host=\(sshHost, privacy: .public) port=\(remotePort, privacy: .public)") + } + let options: [String] = [ + "-o", "BatchMode=yes", + "-o", "ExitOnForwardFailure=yes", + "-o", "StrictHostKeyChecking=accept-new", + "-o", "UpdateHostKeys=yes", + "-o", "ServerAliveInterval=15", + "-o", "ServerAliveCountMax=3", + "-o", "TCPKeepAlive=yes", + "-N", + "-L", "\(localPort):127.0.0.1:\(resolvedRemotePort)", + ] + let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines) + let args = CommandResolver.sshArguments( + target: parsed, + identity: identity, + options: options) + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + process.arguments = args + + let pipe = Pipe() + process.standardError = pipe + let stderrHandle = pipe.fileHandleForReading + + // Consume stderr so ssh cannot block if it logs. + stderrHandle.readabilityHandler = { handle in + let data = handle.readSafely(upToCount: 64 * 1024) + guard !data.isEmpty else { + // EOF (or read failure): stop monitoring to avoid spinning on a closed pipe. + Self.cleanupStderr(handle) + return + } + guard let line = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !line.isEmpty + else { return } + Self.logger.error("ssh tunnel stderr: \(line, privacy: .public)") + } + process.terminationHandler = { _ in + Self.cleanupStderr(stderrHandle) + } + + try process.run() + + // If ssh exits immediately (e.g. local port already in use), surface stderr and ensure we stop monitoring. + try? await Task.sleep(nanoseconds: 150_000_000) // 150ms + if !process.isRunning { + let stderr = Self.drainStderr(stderrHandle) + let msg = stderr.isEmpty ? "ssh tunnel exited immediately" : "ssh tunnel failed: \(stderr)" + throw NSError(domain: "RemotePortTunnel", code: 4, userInfo: [NSLocalizedDescriptionKey: msg]) + } + + // Track tunnel so we can clean up stale listeners on restart. + Task { + await PortGuardian.shared.record( + port: Int(localPort), + pid: process.processIdentifier, + command: process.executableURL?.path ?? "ssh", + mode: CommandResolver.connectionSettings().mode) + } + + return RemotePortTunnel(process: process, localPort: localPort, stderrHandle: stderrHandle) + } + + private static func resolveRemotePortOverride(for sshHost: String) -> Int? { + let root = MoltbotConfigFile.loadDict() + guard let gateway = root["gateway"] as? [String: Any], + let remote = gateway["remote"] as? [String: Any], + let urlRaw = remote["url"] as? String + else { + return nil + } + let trimmed = urlRaw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let url = URL(string: trimmed), let port = url.port else { + return nil + } + guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), + !host.isEmpty + else { + return nil + } + let sshKey = Self.hostKey(sshHost) + let urlKey = Self.hostKey(host) + guard !sshKey.isEmpty, !urlKey.isEmpty else { return nil } + guard sshKey == urlKey else { + Self.logger.debug( + "remote url host mismatch sshHost=\(sshHost, privacy: .public) urlHost=\(host, privacy: .public)") + return nil + } + return port + } + + private static func hostKey(_ host: String) -> String { + let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !trimmed.isEmpty else { return "" } + if trimmed.contains(":") { return trimmed } + let digits = CharacterSet(charactersIn: "0123456789.") + if trimmed.rangeOfCharacter(from: digits.inverted) == nil { + return trimmed + } + return trimmed.split(separator: ".").first.map(String.init) ?? trimmed + } + + private static func findPort(preferred: UInt16?, allowRandom: Bool) async throws -> UInt16 { + if let preferred, self.portIsFree(preferred) { return preferred } + if let preferred, !allowRandom { + throw NSError( + domain: "RemotePortTunnel", + code: 5, + userInfo: [ + NSLocalizedDescriptionKey: "Local port \(preferred) is unavailable", + ]) + } + + return try await withCheckedThrowingContinuation { cont in + let queue = DispatchQueue(label: "bot.molt.remote.tunnel.port", qos: .utility) + do { + let listener = try NWListener(using: .tcp, on: .any) + listener.newConnectionHandler = { connection in connection.cancel() } + listener.stateUpdateHandler = { state in + switch state { + case .ready: + if let port = listener.port?.rawValue { + listener.stateUpdateHandler = nil + listener.cancel() + cont.resume(returning: port) + } + case let .failed(error): + listener.stateUpdateHandler = nil + listener.cancel() + cont.resume(throwing: error) + default: + break + } + } + listener.start(queue: queue) + } catch { + cont.resume(throwing: error) + } + } + } + + private static func portIsFree(_ port: UInt16) -> Bool { + #if canImport(Darwin) + // NWListener can succeed even when only one address family is held. Mirror what ssh needs by checking + // both 127.0.0.1 and ::1 for availability. + return self.canBindIPv4(port) && self.canBindIPv6(port) + #else + do { + let listener = try NWListener(using: .tcp, on: NWEndpoint.Port(rawValue: port)!) + listener.cancel() + return true + } catch { + return false + } + #endif + } + + #if canImport(Darwin) + private static func canBindIPv4(_ port: UInt16) -> Bool { + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { return false } + defer { _ = Darwin.close(fd) } + + var one: Int32 = 1 + _ = setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, socklen_t(MemoryLayout.size(ofValue: one))) + + var addr = sockaddr_in() + addr.sin_len = UInt8(MemoryLayout.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/Moltbot/RemoteTunnelManager.swift b/apps/macos/Sources/Moltbot/RemoteTunnelManager.swift new file mode 100644 index 000000000..f199ff9fe --- /dev/null +++ b/apps/macos/Sources/Moltbot/RemoteTunnelManager.swift @@ -0,0 +1,122 @@ +import Foundation +import OSLog + +/// Manages the SSH tunnel that forwards the remote gateway/control port to localhost. +actor RemoteTunnelManager { + static let shared = RemoteTunnelManager() + + private let logger = Logger(subsystem: "bot.molt", category: "remote-tunnel") + private var controlTunnel: RemotePortTunnel? + private var restartInFlight = false + private var lastRestartAt: Date? + private let restartBackoffSeconds: TimeInterval = 2.0 + + func controlTunnelPortIfRunning() async -> UInt16? { + if self.restartInFlight { + self.logger.info("control tunnel restart in flight; skipping reuse check") + return nil + } + if let tunnel = self.controlTunnel, + tunnel.process.isRunning, + let local = tunnel.localPort + { + let pid = tunnel.process.processIdentifier + if await PortGuardian.shared.isListening(port: Int(local), pid: pid) { + self.logger.info("reusing active SSH tunnel localPort=\(local, privacy: .public)") + return local + } + self.logger.error( + "active SSH tunnel on port \(local, privacy: .public) is not listening; restarting") + await self.beginRestart() + tunnel.terminate() + self.controlTunnel = nil + } + // If a previous Moltbot run already has an SSH listener on the expected port (common after restarts), + // reuse it instead of spawning new ssh processes that immediately fail with "Address already in use". + let desiredPort = UInt16(GatewayEnvironment.gatewayPort()) + if let desc = await PortGuardian.shared.describe(port: Int(desiredPort)), + self.isSshProcess(desc) + { + self.logger.info( + "reusing existing SSH tunnel listener " + + "localPort=\(desiredPort, privacy: .public) " + + "pid=\(desc.pid, privacy: .public)") + return desiredPort + } + return nil + } + + /// Ensure an SSH tunnel is running for the gateway control port. + /// Returns the local forwarded port (usually the configured gateway port). + func ensureControlTunnel() async throws -> UInt16 { + let settings = CommandResolver.connectionSettings() + guard settings.mode == .remote else { + throw NSError( + domain: "RemoteTunnel", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"]) + } + + let identitySet = !settings.identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + self.logger.info( + "ensure SSH tunnel target=\(settings.target, privacy: .public) " + + "identitySet=\(identitySet, privacy: .public)") + + if let local = await self.controlTunnelPortIfRunning() { return local } + await self.waitForRestartBackoffIfNeeded() + + let desiredPort = UInt16(GatewayEnvironment.gatewayPort()) + let tunnel = try await RemotePortTunnel.create( + remotePort: GatewayEnvironment.gatewayPort(), + preferredLocalPort: desiredPort, + allowRandomLocalPort: false) + self.controlTunnel = tunnel + self.endRestart() + let resolvedPort = tunnel.localPort ?? desiredPort + self.logger.info("ssh tunnel ready localPort=\(resolvedPort, privacy: .public)") + return tunnel.localPort ?? desiredPort + } + + func stopAll() { + self.controlTunnel?.terminate() + self.controlTunnel = nil + } + + private func isSshProcess(_ desc: PortGuardian.Descriptor) -> Bool { + let cmd = desc.command.lowercased() + if cmd.contains("ssh") { return true } + if let path = desc.executablePath?.lowercased(), path.contains("/ssh") { return true } + return false + } + + private func beginRestart() async { + guard !self.restartInFlight else { return } + self.restartInFlight = true + self.lastRestartAt = Date() + self.logger.info("control tunnel restart started") + Task { [weak self] in + guard let self else { return } + try? await Task.sleep(nanoseconds: UInt64(self.restartBackoffSeconds * 1_000_000_000)) + await self.endRestart() + } + } + + private func endRestart() { + if self.restartInFlight { + self.restartInFlight = false + self.logger.info("control tunnel restart finished") + } + } + + private func waitForRestartBackoffIfNeeded() async { + guard let last = self.lastRestartAt else { return } + let elapsed = Date().timeIntervalSince(last) + let remaining = self.restartBackoffSeconds - elapsed + guard remaining > 0 else { return } + self.logger.info( + "control tunnel restart backoff \(remaining, privacy: .public)s") + try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000)) + } + + // Keep tunnel reuse lightweight; restart only when the listener disappears. +} diff --git a/apps/macos/Sources/Moltbot/Resources/Info.plist b/apps/macos/Sources/Moltbot/Resources/Info.plist new file mode 100644 index 000000000..89c5a2d9e --- /dev/null +++ b/apps/macos/Sources/Moltbot/Resources/Info.plist @@ -0,0 +1,79 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + Moltbot + CFBundleIdentifier + bot.molt.mac + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Moltbot + CFBundlePackageType + APPL + CFBundleShortVersionString + 2026.1.26 + CFBundleVersion + 202601260 + CFBundleIconFile + Moltbot + CFBundleURLTypes + + + CFBundleURLName + bot.molt.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/Moltbot/RuntimeLocator.swift b/apps/macos/Sources/Moltbot/RuntimeLocator.swift new file mode 100644 index 000000000..270e209d3 --- /dev/null +++ b/apps/macos/Sources/Moltbot/RuntimeLocator.swift @@ -0,0 +1,167 @@ +import Foundation +import OSLog + +enum RuntimeKind: String { + case node +} + +struct RuntimeVersion: Comparable, CustomStringConvertible { + let major: Int + let minor: Int + let patch: Int + + var description: String { "\(self.major).\(self.minor).\(self.patch)" } + + static func < (lhs: RuntimeVersion, rhs: RuntimeVersion) -> Bool { + if lhs.major != rhs.major { return lhs.major < rhs.major } + if lhs.minor != rhs.minor { return lhs.minor < rhs.minor } + return lhs.patch < rhs.patch + } + + static func from(string: String) -> RuntimeVersion? { + // Accept optional leading "v" and ignore trailing metadata. + let pattern = #"(\d+)\.(\d+)\.(\d+)"# + guard let match = string.range(of: pattern, options: .regularExpression) else { return nil } + let versionString = String(string[match]) + let parts = versionString.split(separator: ".") + guard parts.count == 3, + let major = Int(parts[0]), + let minor = Int(parts[1]), + let patch = Int(parts[2]) + else { return nil } + return RuntimeVersion(major: major, minor: minor, patch: patch) + } +} + +struct RuntimeResolution { + let kind: RuntimeKind + let path: String + let version: RuntimeVersion +} + +enum RuntimeResolutionError: Error { + case notFound(searchPaths: [String]) + case unsupported( + kind: RuntimeKind, + found: RuntimeVersion, + required: RuntimeVersion, + path: String, + searchPaths: [String]) + case versionParse(kind: RuntimeKind, raw: String, path: String, searchPaths: [String]) +} + +enum RuntimeLocator { + private static let logger = Logger(subsystem: "bot.molt", category: "runtime") + private static let minNode = RuntimeVersion(major: 22, minor: 0, patch: 0) + + static func resolve( + searchPaths: [String] = CommandResolver.preferredPaths()) -> Result + { + 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/Moltbot/ScreenRecordService.swift b/apps/macos/Sources/Moltbot/ScreenRecordService.swift new file mode 100644 index 000000000..a46f00780 --- /dev/null +++ b/apps/macos/Sources/Moltbot/ScreenRecordService.swift @@ -0,0 +1,266 @@ +import AVFoundation +import Foundation +import OSLog +@preconcurrency import ScreenCaptureKit + +@MainActor +final class ScreenRecordService { + enum ScreenRecordError: LocalizedError { + case noDisplays + case invalidScreenIndex(Int) + case noFramesCaptured + case writeFailed(String) + + var errorDescription: String? { + switch self { + case .noDisplays: + "No displays available for screen recording" + case let .invalidScreenIndex(idx): + "Invalid screen index \(idx)" + case .noFramesCaptured: + "No frames captured" + case let .writeFailed(msg): + msg + } + } + } + + private let logger = Logger(subsystem: "bot.molt", category: "screenRecord") + + func record( + screenIndex: Int?, + durationMs: Int?, + fps: Double?, + includeAudio: Bool?, + outPath: String?) async throws -> (path: String, hasAudio: Bool) + { + let durationMs = Self.clampDurationMs(durationMs) + let fps = Self.clampFps(fps) + let includeAudio = includeAudio ?? false + + let outURL: URL = { + if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return URL(fileURLWithPath: outPath) + } + return FileManager().temporaryDirectory + .appendingPathComponent("moltbot-screen-record-\(UUID().uuidString).mp4") + }() + try? FileManager().removeItem(at: outURL) + + let content = try await SCShareableContent.current + let displays = content.displays.sorted { $0.displayID < $1.displayID } + guard !displays.isEmpty else { throw ScreenRecordError.noDisplays } + + let idx = screenIndex ?? 0 + guard idx >= 0, idx < displays.count else { throw ScreenRecordError.invalidScreenIndex(idx) } + let display = displays[idx] + + let filter = SCContentFilter(display: display, excludingWindows: []) + let config = SCStreamConfiguration() + config.width = display.width + config.height = display.height + config.queueDepth = 8 + config.showsCursor = true + config.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(max(1, Int32(fps.rounded())))) + if includeAudio { + config.capturesAudio = true + } + + let recorder = try StreamRecorder( + outputURL: outURL, + width: display.width, + height: display.height, + includeAudio: includeAudio, + logger: self.logger) + + let stream = SCStream(filter: filter, configuration: config, delegate: recorder) + try stream.addStreamOutput(recorder, type: .screen, sampleHandlerQueue: recorder.queue) + if includeAudio { + try stream.addStreamOutput(recorder, type: .audio, sampleHandlerQueue: recorder.queue) + } + + self.logger.info( + "screen record start idx=\(idx) durationMs=\(durationMs) fps=\(fps) out=\(outURL.path, privacy: .public)") + + var started = false + do { + try await stream.startCapture() + started = true + try await Task.sleep(nanoseconds: UInt64(durationMs) * 1_000_000) + try await stream.stopCapture() + } catch { + if started { try? await stream.stopCapture() } + throw error + } + + try await recorder.finish() + return (path: outURL.path, hasAudio: recorder.hasAudio) + } + + private nonisolated static func clampDurationMs(_ ms: Int?) -> Int { + let v = ms ?? 10000 + return min(60000, max(250, v)) + } + + private nonisolated static func clampFps(_ fps: Double?) -> Double { + let v = fps ?? 10 + if !v.isFinite { return 10 } + return min(60, max(1, v)) + } +} + +private final class StreamRecorder: NSObject, SCStreamOutput, SCStreamDelegate, @unchecked Sendable { + let queue = DispatchQueue(label: "bot.molt.screenRecord.writer") + + private let logger: Logger + private let writer: AVAssetWriter + private let input: AVAssetWriterInput + private let audioInput: AVAssetWriterInput? + let hasAudio: Bool + + private var started = false + private var sawFrame = false + private var didFinish = false + private var pendingErrorMessage: String? + + init(outputURL: URL, width: Int, height: Int, includeAudio: Bool, logger: Logger) throws { + self.logger = logger + self.writer = try AVAssetWriter(outputURL: outputURL, fileType: .mp4) + + let settings: [String: Any] = [ + AVVideoCodecKey: AVVideoCodecType.h264, + AVVideoWidthKey: width, + AVVideoHeightKey: height, + ] + self.input = AVAssetWriterInput(mediaType: .video, outputSettings: settings) + self.input.expectsMediaDataInRealTime = true + + guard self.writer.canAdd(self.input) else { + throw ScreenRecordService.ScreenRecordError.writeFailed("Cannot add video input") + } + self.writer.add(self.input) + + if includeAudio { + let audioSettings: [String: Any] = [ + AVFormatIDKey: kAudioFormatMPEG4AAC, + AVNumberOfChannelsKey: 1, + AVSampleRateKey: 44100, + AVEncoderBitRateKey: 96000, + ] + let audioInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings) + audioInput.expectsMediaDataInRealTime = true + if self.writer.canAdd(audioInput) { + self.writer.add(audioInput) + self.audioInput = audioInput + self.hasAudio = true + } else { + self.audioInput = nil + self.hasAudio = false + } + } else { + self.audioInput = nil + self.hasAudio = false + } + super.init() + } + + func stream(_ stream: SCStream, didStopWithError error: any Error) { + self.queue.async { + let msg = String(describing: error) + self.pendingErrorMessage = msg + self.logger.error("screen record stream stopped with error: \(msg, privacy: .public)") + _ = stream + } + } + + func stream( + _ stream: SCStream, + didOutputSampleBuffer sampleBuffer: CMSampleBuffer, + of type: SCStreamOutputType) + { + guard CMSampleBufferDataIsReady(sampleBuffer) else { return } + // Callback runs on `sampleHandlerQueue` (`self.queue`). + switch type { + case .screen: + self.handleVideo(sampleBuffer: sampleBuffer) + case .audio: + self.handleAudio(sampleBuffer: sampleBuffer) + case .microphone: + break + @unknown default: + break + } + _ = stream + } + + private func handleVideo(sampleBuffer: CMSampleBuffer) { + if let msg = self.pendingErrorMessage { + self.logger.error("screen record aborting due to prior error: \(msg, privacy: .public)") + return + } + if self.didFinish { return } + + if !self.started { + guard self.writer.startWriting() else { + self.pendingErrorMessage = self.writer.error?.localizedDescription ?? "Failed to start writer" + return + } + let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) + self.writer.startSession(atSourceTime: pts) + self.started = true + } + + self.sawFrame = true + if self.input.isReadyForMoreMediaData { + _ = self.input.append(sampleBuffer) + } + } + + private func handleAudio(sampleBuffer: CMSampleBuffer) { + guard let audioInput else { return } + if let msg = self.pendingErrorMessage { + self.logger.error("screen record audio aborting due to prior error: \(msg, privacy: .public)") + return + } + if self.didFinish || !self.started { return } + if audioInput.isReadyForMoreMediaData { + _ = audioInput.append(sampleBuffer) + } + } + + func finish() async throws { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) 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/Moltbot/SessionMenuPreviewView.swift b/apps/macos/Sources/Moltbot/SessionMenuPreviewView.swift new file mode 100644 index 000000000..a60a9616c --- /dev/null +++ b/apps/macos/Sources/Moltbot/SessionMenuPreviewView.swift @@ -0,0 +1,495 @@ +import MoltbotChatUI +import MoltbotKit +import MoltbotProtocol +import OSLog +import SwiftUI + +struct SessionPreviewItem: Identifiable, Sendable { + let id: String + let role: PreviewRole + let text: String +} + +enum PreviewRole: String, Sendable { + case user + case assistant + case tool + case system + case other + + var label: String { + switch self { + case .user: "User" + case .assistant: "Agent" + case .tool: "Tool" + case .system: "System" + case .other: "Other" + } + } +} + +actor SessionPreviewCache { + static let shared = SessionPreviewCache() + + private struct CacheEntry { + let snapshot: SessionMenuPreviewSnapshot + let updatedAt: Date + } + + private var entries: [String: CacheEntry] = [:] + + func cachedSnapshot(for sessionKey: String, maxAge: TimeInterval) -> SessionMenuPreviewSnapshot? { + guard let entry = self.entries[sessionKey] else { return nil } + guard Date().timeIntervalSince(entry.updatedAt) < maxAge else { return nil } + return entry.snapshot + } + + func store(snapshot: SessionMenuPreviewSnapshot, for sessionKey: String) { + self.entries[sessionKey] = CacheEntry(snapshot: snapshot, updatedAt: Date()) + } + + func lastSnapshot(for sessionKey: String) -> SessionMenuPreviewSnapshot? { + self.entries[sessionKey]?.snapshot + } +} + +actor SessionPreviewLimiter { + static let shared = SessionPreviewLimiter(maxConcurrent: 2) + + private let maxConcurrent: Int + private var available: Int + private var waitQueue: [UUID] = [] + private var waiters: [UUID: CheckedContinuation] = [:] + + 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: "bot.molt", category: "SessionPreview") + private static let previewTimeoutSeconds: Double = 4 + private static let cacheMaxAgeSeconds: TimeInterval = 30 + private static let previewMaxChars = 240 + + private struct PreviewTimeoutError: LocalizedError { + var errorDescription: String? { "preview timeout" } + } + + static func prewarm(sessionKeys: [String], maxItems: Int) async { + let keys = self.uniqueKeys(sessionKeys) + guard !keys.isEmpty else { return } + do { + let payload = try await self.requestPreview(keys: keys, maxItems: maxItems) + await self.cache(payload: payload, maxItems: maxItems) + } catch { + if self.isUnknownMethodError(error) { return } + let errorDescription = String(describing: error) + Self.logger.debug( + "Session preview prewarm failed count=\(keys.count, privacy: .public) " + + "error=\(errorDescription, privacy: .public)") + } + } + + static func load(sessionKey: String, maxItems: Int) async -> SessionMenuPreviewSnapshot { + if let cached = await SessionPreviewCache.shared.cachedSnapshot( + for: sessionKey, + maxAge: cacheMaxAgeSeconds) + { + return cached + } + + do { + let snapshot = try await self.fetchSnapshot(sessionKey: sessionKey, maxItems: maxItems) + await SessionPreviewCache.shared.store(snapshot: snapshot, for: sessionKey) + return snapshot + } catch is CancellationError { + return SessionMenuPreviewSnapshot(items: [], status: .loading) + } catch { + if let fallback = await SessionPreviewCache.shared.lastSnapshot(for: sessionKey) { + return fallback + } + let errorDescription = String(describing: error) + Self.logger.warning( + "Session preview failed session=\(sessionKey, privacy: .public) " + + "error=\(errorDescription, privacy: .public)") + return SessionMenuPreviewSnapshot(items: [], status: .error("Preview unavailable")) + } + } + + private static func fetchSnapshot(sessionKey: String, maxItems: Int) async throws -> SessionMenuPreviewSnapshot { + do { + let payload = try await self.requestPreview(keys: [sessionKey], maxItems: maxItems) + if let entry = payload.previews.first(where: { $0.key == sessionKey }) ?? payload.previews.first { + return self.snapshot(from: entry, maxItems: maxItems) + } + return SessionMenuPreviewSnapshot(items: [], status: .error("Preview unavailable")) + } catch { + if self.isUnknownMethodError(error) { + return try await self.fetchHistorySnapshot(sessionKey: sessionKey, maxItems: maxItems) + } + throw error + } + } + + private static func requestPreview( + keys: [String], + maxItems: Int) async throws -> MoltbotSessionsPreviewPayload + { + let boundedItems = self.normalizeMaxItems(maxItems) + let timeoutMs = Int(self.previewTimeoutSeconds * 1000) + return try await SessionPreviewLimiter.shared.withPermit { + try await AsyncTimeout.withTimeout( + seconds: self.previewTimeoutSeconds, + onTimeout: { PreviewTimeoutError() }, + operation: { + try await GatewayConnection.shared.sessionsPreview( + keys: keys, + limit: boundedItems, + maxChars: self.previewMaxChars, + timeoutMs: timeoutMs) + }) + } + } + + private static func fetchHistorySnapshot( + sessionKey: String, + maxItems: Int) async throws -> SessionMenuPreviewSnapshot + { + let timeoutMs = Int(self.previewTimeoutSeconds * 1000) + let payload = try await SessionPreviewLimiter.shared.withPermit { + try await AsyncTimeout.withTimeout( + seconds: self.previewTimeoutSeconds, + onTimeout: { PreviewTimeoutError() }, + operation: { + try await GatewayConnection.shared.chatHistory( + sessionKey: sessionKey, + limit: self.previewLimit(for: maxItems), + timeoutMs: timeoutMs) + }) + } + let built = Self.previewItems(from: payload, maxItems: maxItems) + return Self.snapshot(from: built) + } + + private static func snapshot(from items: [SessionPreviewItem]) -> SessionMenuPreviewSnapshot { + SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready) + } + + private static func snapshot( + from entry: MoltbotSessionPreviewEntry, + maxItems: Int) -> SessionMenuPreviewSnapshot + { + let items = self.previewItems(from: entry, maxItems: maxItems) + let normalized = entry.status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + switch normalized { + case "ok": + return SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready) + case "empty": + return SessionMenuPreviewSnapshot(items: items, status: .empty) + case "missing": + return SessionMenuPreviewSnapshot(items: items, status: .error("Session missing")) + default: + return SessionMenuPreviewSnapshot(items: items, status: .error("Preview unavailable")) + } + } + + private static func cache(payload: MoltbotSessionsPreviewPayload, maxItems: Int) async { + for entry in payload.previews { + let snapshot = self.snapshot(from: entry, maxItems: maxItems) + await SessionPreviewCache.shared.store(snapshot: snapshot, for: entry.key) + } + } + + private static func previewLimit(for maxItems: Int) -> Int { + let boundedItems = self.normalizeMaxItems(maxItems) + return min(max(boundedItems * 3, 20), 120) + } + + private static func normalizeMaxItems(_ maxItems: Int) -> Int { + max(1, min(maxItems, 50)) + } + + private static func previewItems( + from entry: MoltbotSessionPreviewEntry, + maxItems: Int) -> [SessionPreviewItem] + { + let boundedItems = self.normalizeMaxItems(maxItems) + let built: [SessionPreviewItem] = entry.items.enumerated().compactMap { index, item in + let text = item.text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return nil } + let role = self.previewRoleFromRaw(item.role) + return SessionPreviewItem(id: "\(entry.key)-\(index)", role: role, text: text) + } + + let trimmed = built.suffix(boundedItems) + return Array(trimmed.reversed()) + } + + private static func previewItems( + from payload: MoltbotChatHistoryPayload, + maxItems: Int) -> [SessionPreviewItem] + { + let boundedItems = self.normalizeMaxItems(maxItems) + let raw: [MoltbotKit.AnyCodable] = payload.messages ?? [] + let messages = self.decodeMessages(raw) + let built = messages.compactMap { message -> SessionPreviewItem? in + guard let text = self.previewText(for: message) else { return nil } + let isTool = self.isToolCall(message) + let role = self.previewRole(message.role, isTool: isTool) + let id = "\(message.timestamp ?? 0)-\(UUID().uuidString)" + return SessionPreviewItem(id: id, role: role, text: text) + } + + let trimmed = built.suffix(boundedItems) + return Array(trimmed.reversed()) + } + + private static func decodeMessages(_ raw: [MoltbotKit.AnyCodable]) -> [MoltbotChatMessage] { + raw.compactMap { item in + guard let data = try? JSONEncoder().encode(item) else { return nil } + return try? JSONDecoder().decode(MoltbotChatMessage.self, from: data) + } + } + + private static func previewRole(_ raw: String, isTool: Bool) -> PreviewRole { + if isTool { return .tool } + return self.previewRoleFromRaw(raw) + } + + private static func previewRoleFromRaw(_ raw: String) -> PreviewRole { + switch raw.lowercased() { + case "user": .user + case "assistant": .assistant + case "system": .system + case "tool": .tool + default: .other + } + } + + private static func previewText(for message: MoltbotChatMessage) -> String? { + let text = message.content.compactMap(\.text).joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + if !text.isEmpty { return text } + + let toolNames = self.toolNames(for: message) + if !toolNames.isEmpty { + let shown = toolNames.prefix(2) + let overflow = toolNames.count - shown.count + var label = "call \(shown.joined(separator: ", "))" + if overflow > 0 { label += " +\(overflow)" } + return label + } + + if let media = self.mediaSummary(for: message) { + return media + } + + return nil + } + + private static func isToolCall(_ message: MoltbotChatMessage) -> Bool { + if message.toolName?.nonEmpty != nil { return true } + return message.content.contains { $0.name?.nonEmpty != nil || $0.type?.lowercased() == "toolcall" } + } + + private static func toolNames(for message: MoltbotChatMessage) -> [String] { + var names: [String] = [] + for content in message.content { + if let name = content.name?.nonEmpty { + names.append(name) + } + } + if let toolName = message.toolName?.nonEmpty { + names.append(toolName) + } + return Self.dedupePreservingOrder(names) + } + + private static func mediaSummary(for message: MoltbotChatMessage) -> String? { + let types = message.content.compactMap { content -> String? in + let raw = content.type?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard let raw, !raw.isEmpty else { return nil } + if raw == "text" || raw == "toolcall" { return nil } + return raw + } + guard let first = types.first else { return nil } + return "[\(first)]" + } + + private static func dedupePreservingOrder(_ values: [String]) -> [String] { + var seen = Set() + 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/Moltbot/TailscaleService.swift b/apps/macos/Sources/Moltbot/TailscaleService.swift new file mode 100644 index 000000000..299045e5a --- /dev/null +++ b/apps/macos/Sources/Moltbot/TailscaleService.swift @@ -0,0 +1,226 @@ +import AppKit +import Foundation +import Observation +import os +#if canImport(Darwin) +import Darwin +#endif + +/// Manages Tailscale integration and status checking. +@Observable +@MainActor +final class TailscaleService { + static let shared = TailscaleService() + + /// Tailscale local API endpoint. + private static let tailscaleAPIEndpoint = "http://100.100.100.100/api/data" + + /// API request timeout in seconds. + private static let apiTimeoutInterval: TimeInterval = 5.0 + + private let logger = Logger(subsystem: "bot.molt", category: "tailscale") + + /// Indicates if the Tailscale app is installed on the system. + private(set) var isInstalled = false + + /// Indicates if Tailscale is currently running. + private(set) var isRunning = false + + /// The Tailscale hostname for this device (e.g., "my-mac.tailnet.ts.net"). + private(set) var tailscaleHostname: String? + + /// The Tailscale IPv4 address for this device. + private(set) var tailscaleIP: String? + + /// Error message if status check fails. + private(set) var statusError: String? + + private init() { + Task { await self.checkTailscaleStatus() } + } + + #if DEBUG + init( + isInstalled: Bool, + isRunning: Bool, + tailscaleHostname: String? = nil, + tailscaleIP: String? = nil, + statusError: String? = nil) + { + self.isInstalled = isInstalled + self.isRunning = isRunning + self.tailscaleHostname = tailscaleHostname + self.tailscaleIP = tailscaleIP + self.statusError = statusError + } + #endif + + func checkAppInstallation() -> Bool { + let installed = FileManager().fileExists(atPath: "/Applications/Tailscale.app") + self.logger.info("Tailscale app installed: \(installed)") + return installed + } + + private struct TailscaleAPIResponse: Codable { + let status: String + let deviceName: String + let tailnetName: String + let iPv4: String? + + private enum CodingKeys: String, CodingKey { + case status = "Status" + case deviceName = "DeviceName" + case tailnetName = "TailnetName" + case iPv4 = "IPv4" + } + } + + private func fetchTailscaleStatus() async -> TailscaleAPIResponse? { + guard let url = URL(string: Self.tailscaleAPIEndpoint) else { + self.logger.error("Invalid Tailscale API URL") + return nil + } + + do { + let configuration = URLSessionConfiguration.default + configuration.timeoutIntervalForRequest = Self.apiTimeoutInterval + let session = URLSession(configuration: configuration) + + let (data, response) = try await session.data(from: url) + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 + else { + self.logger.warning("Tailscale API returned non-200 status") + return nil + } + + let decoder = JSONDecoder() + return try decoder.decode(TailscaleAPIResponse.self, from: data) + } catch { + self.logger.debug("Failed to fetch Tailscale status: \(String(describing: error))") + return nil + } + } + + func checkTailscaleStatus() async { + let previousIP = self.tailscaleIP + self.isInstalled = self.checkAppInstallation() + if !self.isInstalled { + self.isRunning = false + self.tailscaleHostname = nil + self.tailscaleIP = nil + self.statusError = "Tailscale is not installed" + } else if let apiResponse = await fetchTailscaleStatus() { + self.isRunning = apiResponse.status.lowercased() == "running" + + if self.isRunning { + let deviceName = apiResponse.deviceName + .lowercased() + .replacingOccurrences(of: " ", with: "-") + let tailnetName = apiResponse.tailnetName + .replacingOccurrences(of: ".ts.net", with: "") + .replacingOccurrences(of: ".tailscale.net", with: "") + + self.tailscaleHostname = "\(deviceName).\(tailnetName).ts.net" + self.tailscaleIP = apiResponse.iPv4 + self.statusError = nil + + self.logger.info( + "Tailscale running host=\(self.tailscaleHostname ?? "nil") ip=\(self.tailscaleIP ?? "nil")") + } else { + self.tailscaleHostname = nil + self.tailscaleIP = nil + self.statusError = "Tailscale is not running" + } + } else { + self.isRunning = false + self.tailscaleHostname = nil + self.tailscaleIP = nil + self.statusError = "Please start the Tailscale app" + self.logger.info("Tailscale API not responding; app likely not running") + } + + if self.tailscaleIP == nil, let fallback = Self.detectTailnetIPv4() { + self.tailscaleIP = fallback + if !self.isRunning { + self.isRunning = true + } + self.statusError = nil + self.logger.info("Tailscale interface IP detected (fallback) ip=\(fallback, privacy: .public)") + } + + if previousIP != self.tailscaleIP { + await GatewayEndpointStore.shared.refresh() + } + } + + func openTailscaleApp() { + if let url = URL(string: "file:///Applications/Tailscale.app") { + NSWorkspace.shared.open(url) + } + } + + func openAppStore() { + if let url = URL(string: "https://apps.apple.com/us/app/tailscale/id1475387142") { + NSWorkspace.shared.open(url) + } + } + + func openDownloadPage() { + if let url = URL(string: "https://tailscale.com/download/macos") { + NSWorkspace.shared.open(url) + } + } + + func openSetupGuide() { + if let url = URL(string: "https://tailscale.com/kb/1017/install/") { + NSWorkspace.shared.open(url) + } + } + + private nonisolated static func isTailnetIPv4(_ address: String) -> Bool { + let parts = address.split(separator: ".") + guard parts.count == 4 else { return false } + let octets = parts.compactMap { Int($0) } + guard octets.count == 4 else { return false } + let a = octets[0] + let b = octets[1] + return a == 100 && b >= 64 && b <= 127 + } + + private nonisolated static func detectTailnetIPv4() -> String? { + var addrList: UnsafeMutablePointer? + 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/Moltbot/TalkAudioPlayer.swift b/apps/macos/Sources/Moltbot/TalkAudioPlayer.swift new file mode 100644 index 000000000..b137994a3 --- /dev/null +++ b/apps/macos/Sources/Moltbot/TalkAudioPlayer.swift @@ -0,0 +1,158 @@ +import AVFoundation +import Foundation +import OSLog + +@MainActor +final class TalkAudioPlayer: NSObject, @preconcurrency AVAudioPlayerDelegate { + static let shared = TalkAudioPlayer() + + private let logger = Logger(subsystem: "bot.molt", category: "talk.tts") + private var player: AVAudioPlayer? + private var playback: Playback? + + private final class Playback: @unchecked Sendable { + private let lock = NSLock() + private var finished = false + private var continuation: CheckedContinuation? + 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/Moltbot/TalkModeController.swift b/apps/macos/Sources/Moltbot/TalkModeController.swift new file mode 100644 index 000000000..89eac593b --- /dev/null +++ b/apps/macos/Sources/Moltbot/TalkModeController.swift @@ -0,0 +1,69 @@ +import Observation + +@MainActor +@Observable +final class TalkModeController { + static let shared = TalkModeController() + + private let logger = Logger(subsystem: "bot.molt", category: "talk.controller") + + private(set) var phase: TalkModePhase = .idle + private(set) var isPaused: Bool = false + + func setEnabled(_ enabled: Bool) async { + self.logger.info("talk enabled=\(enabled)") + if enabled { + TalkOverlayController.shared.present() + } else { + TalkOverlayController.shared.dismiss() + } + await TalkModeRuntime.shared.setEnabled(enabled) + } + + func updatePhase(_ phase: TalkModePhase) { + self.phase = phase + TalkOverlayController.shared.updatePhase(phase) + let effectivePhase = self.isPaused ? "paused" : phase.rawValue + Task { + await GatewayConnection.shared.talkMode( + enabled: AppStateStore.shared.talkEnabled, + phase: effectivePhase) + } + } + + func updateLevel(_ level: Double) { + TalkOverlayController.shared.updateLevel(level) + } + + func setPaused(_ paused: Bool) { + guard self.isPaused != paused else { return } + self.logger.info("talk paused=\(paused)") + self.isPaused = paused + TalkOverlayController.shared.updatePaused(paused) + let effectivePhase = paused ? "paused" : self.phase.rawValue + Task { + await GatewayConnection.shared.talkMode( + enabled: AppStateStore.shared.talkEnabled, + phase: effectivePhase) + } + Task { await TalkModeRuntime.shared.setPaused(paused) } + } + + func togglePaused() { + self.setPaused(!self.isPaused) + } + + func stopSpeaking(reason: TalkStopReason = .userTap) { + Task { await TalkModeRuntime.shared.stopSpeaking(reason: reason) } + } + + func exitTalkMode() { + Task { await AppStateStore.shared.setTalkEnabled(false) } + } +} + +enum TalkStopReason { + case userTap + case speech + case manual +} diff --git a/apps/macos/Sources/Moltbot/TalkModeRuntime.swift b/apps/macos/Sources/Moltbot/TalkModeRuntime.swift new file mode 100644 index 000000000..5c33cdb34 --- /dev/null +++ b/apps/macos/Sources/Moltbot/TalkModeRuntime.swift @@ -0,0 +1,953 @@ +import AVFoundation +import MoltbotChatUI +import MoltbotKit +import Foundation +import OSLog +import Speech + +actor TalkModeRuntime { + static let shared = TalkModeRuntime() + + private let logger = Logger(subsystem: "bot.molt", category: "talk.runtime") + private let ttsLogger = Logger(subsystem: "bot.molt", category: "talk.tts") + private static let defaultModelIdFallback = "eleven_v3" + + private final class RMSMeter: @unchecked Sendable { + private let lock = NSLock() + private var latestRMS: Double = 0 + + func set(_ rms: Double) { + self.lock.lock() + self.latestRMS = rms + self.lock.unlock() + } + + func get() -> Double { + self.lock.lock() + let value = self.latestRMS + self.lock.unlock() + return value + } + } + + private var recognizer: SFSpeechRecognizer? + private var audioEngine: AVAudioEngine? + private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? + private var recognitionTask: SFSpeechRecognitionTask? + private var recognitionGeneration: Int = 0 + private var rmsTask: Task? + 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/Moltbot/TalkOverlay.swift b/apps/macos/Sources/Moltbot/TalkOverlay.swift new file mode 100644 index 000000000..b9d2f6a24 --- /dev/null +++ b/apps/macos/Sources/Moltbot/TalkOverlay.swift @@ -0,0 +1,146 @@ +import AppKit +import Observation +import OSLog +import SwiftUI + +@MainActor +@Observable +final class TalkOverlayController { + static let shared = TalkOverlayController() + static let overlaySize: CGFloat = 440 + static let orbSize: CGFloat = 96 + static let orbPadding: CGFloat = 12 + static let orbHitSlop: CGFloat = 10 + + private let logger = Logger(subsystem: "bot.molt", category: "talk.overlay") + + struct Model { + var isVisible: Bool = false + var phase: TalkModePhase = .idle + var isPaused: Bool = false + var level: Double = 0 + } + + var model = Model() + private var window: NSPanel? + private var hostingView: NSHostingView? + 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/Moltbot/TerminationSignalWatcher.swift b/apps/macos/Sources/Moltbot/TerminationSignalWatcher.swift new file mode 100644 index 000000000..dca6916ac --- /dev/null +++ b/apps/macos/Sources/Moltbot/TerminationSignalWatcher.swift @@ -0,0 +1,53 @@ +import AppKit +import Foundation +import OSLog + +@MainActor +final class TerminationSignalWatcher { + static let shared = TerminationSignalWatcher() + + private let logger = Logger(subsystem: "bot.molt", category: "lifecycle") + private var sources: [DispatchSourceSignal] = [] + private var terminationRequested = false + + func start() { + guard self.sources.isEmpty else { return } + self.install(SIGTERM) + self.install(SIGINT) + } + + func stop() { + for s in self.sources { + s.cancel() + } + self.sources.removeAll(keepingCapacity: false) + self.terminationRequested = false + } + + private func install(_ sig: Int32) { + // Make sure the default action doesn't kill the process before we can gracefully shut down. + signal(sig, SIG_IGN) + let source = DispatchSource.makeSignalSource(signal: sig, queue: .main) + source.setEventHandler { [weak self] in + self?.handle(sig) + } + source.resume() + self.sources.append(source) + } + + private func handle(_ sig: Int32) { + guard !self.terminationRequested else { return } + self.terminationRequested = true + + self.logger.info("received signal \(sig, privacy: .public); terminating") + // Ensure any pairing prompt can't accidentally approve during shutdown. + NodePairingApprovalPrompter.shared.stop() + DevicePairingApprovalPrompter.shared.stop() + NSApp.terminate(nil) + + // Safety net: don't hang forever if something blocks termination. + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + exit(0) + } + } +} diff --git a/apps/macos/Sources/Moltbot/VoicePushToTalk.swift b/apps/macos/Sources/Moltbot/VoicePushToTalk.swift new file mode 100644 index 000000000..fb454a5fe --- /dev/null +++ b/apps/macos/Sources/Moltbot/VoicePushToTalk.swift @@ -0,0 +1,421 @@ +import AppKit +import AVFoundation +import Dispatch +import OSLog +import Speech + +/// Observes right Option and starts a push-to-talk capture while it is held. +final class VoicePushToTalkHotkey: @unchecked Sendable { + static let shared = VoicePushToTalkHotkey() + + private var globalMonitor: Any? + private var localMonitor: Any? + private var optionDown = false // right option only + private var active = false + + private let beginAction: @Sendable () async -> Void + private let endAction: @Sendable () async -> Void + + init( + beginAction: @escaping @Sendable () async -> Void = { await VoicePushToTalk.shared.begin() }, + endAction: @escaping @Sendable () async -> Void = { await VoicePushToTalk.shared.end() }) + { + self.beginAction = beginAction + self.endAction = endAction + } + + func setEnabled(_ enabled: Bool) { + if ProcessInfo.processInfo.isRunningTests { return } + self.withMainThread { [weak self] in + guard let self else { return } + if enabled { + self.startMonitoring() + } else { + self.stopMonitoring() + } + } + } + + private func startMonitoring() { + // assert(Thread.isMainThread) - Removed for Swift 6 + guard self.globalMonitor == nil, self.localMonitor == nil else { return } + // Listen-only global monitor; we rely on Input Monitoring permission to receive events. + self.globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in + let keyCode = event.keyCode + let flags = event.modifierFlags + self?.handleFlagsChanged(keyCode: keyCode, modifierFlags: flags) + } + // Also listen locally so we still catch events when the app is active/focused. + self.localMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in + let keyCode = event.keyCode + let flags = event.modifierFlags + self?.handleFlagsChanged(keyCode: keyCode, modifierFlags: flags) + return event + } + } + + private func stopMonitoring() { + // assert(Thread.isMainThread) - Removed for Swift 6 + if let globalMonitor { + NSEvent.removeMonitor(globalMonitor) + self.globalMonitor = nil + } + if let localMonitor { + NSEvent.removeMonitor(localMonitor) + self.localMonitor = nil + } + self.optionDown = false + self.active = false + } + + private func handleFlagsChanged(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) { + self.withMainThread { [weak self] in + self?.updateModifierState(keyCode: keyCode, modifierFlags: modifierFlags) + } + } + + private func withMainThread(_ block: @escaping @Sendable () -> Void) { + DispatchQueue.main.async(execute: block) + } + + private func updateModifierState(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) { + // assert(Thread.isMainThread) - Removed for Swift 6 + // Right Option (keyCode 61) acts as a hold-to-talk modifier. + if keyCode == 61 { + self.optionDown = modifierFlags.contains(.option) + } + + let chordActive = self.optionDown + if chordActive, !self.active { + self.active = true + Task { + Logger(subsystem: "bot.molt", category: "voicewake.ptt") + .info("ptt hotkey down") + await self.beginAction() + } + } else if !chordActive, self.active { + self.active = false + Task { + Logger(subsystem: "bot.molt", category: "voicewake.ptt") + .info("ptt hotkey up") + await self.endAction() + } + } + } + + func _testUpdateModifierState(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) { + self.updateModifierState(keyCode: keyCode, modifierFlags: modifierFlags) + } +} + +/// Short-lived speech recognizer that records while the hotkey is held. +actor VoicePushToTalk { + static let shared = VoicePushToTalk() + + private let logger = Logger(subsystem: "bot.molt", category: "voicewake.ptt") + + private var recognizer: SFSpeechRecognizer? + // Lazily created on begin() to avoid creating an AVAudioEngine at app launch, which can switch Bluetooth + // headphones into the low-quality headset profile even if push-to-talk is never used. + private var audioEngine: AVAudioEngine? + private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? + private var recognitionTask: SFSpeechRecognitionTask? + private var tapInstalled = false + + // Session token used to drop stale callbacks when a new capture starts. + private var sessionID = UUID() + + private var committed: String = "" + private var volatile: String = "" + private var activeConfig: Config? + private var isCapturing = false + private var triggerChimePlayed = false + private var finalized = false + private var timeoutTask: Task? + 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/Moltbot/VoiceSessionCoordinator.swift b/apps/macos/Sources/Moltbot/VoiceSessionCoordinator.swift new file mode 100644 index 000000000..244d1da28 --- /dev/null +++ b/apps/macos/Sources/Moltbot/VoiceSessionCoordinator.swift @@ -0,0 +1,134 @@ +import AppKit +import Foundation +import Observation + +@MainActor +@Observable +final class VoiceSessionCoordinator { + static let shared = VoiceSessionCoordinator() + + enum Source: String { case wakeWord, pushToTalk } + + struct Session { + let token: UUID + let source: Source + var text: String + var attributed: NSAttributedString? + var isFinal: Bool + var sendChime: VoiceWakeChime + var autoSendDelay: TimeInterval? + } + + private let logger = Logger(subsystem: "bot.molt", category: "voicewake.coordinator") + private var session: Session? + + // MARK: - API + + func startSession( + source: Source, + text: String, + attributed: NSAttributedString? = nil, + forwardEnabled: Bool = false) -> UUID + { + let token = UUID() + self.logger.info("coordinator start token=\(token.uuidString) source=\(source.rawValue) len=\(text.count)") + let attributedText = attributed ?? VoiceWakeOverlayController.shared.makeAttributed(from: text) + let session = Session( + token: token, + source: source, + text: text, + attributed: attributedText, + isFinal: false, + sendChime: .none, + autoSendDelay: nil) + self.session = session + VoiceWakeOverlayController.shared.startSession( + token: token, + source: VoiceWakeOverlayController.Source(rawValue: source.rawValue) ?? .wakeWord, + transcript: text, + attributed: attributedText, + forwardEnabled: forwardEnabled, + isFinal: false) + return token + } + + func updatePartial(token: UUID, text: String, attributed: NSAttributedString? = nil) { + guard let session, session.token == token else { return } + self.session?.text = text + self.session?.attributed = attributed + VoiceWakeOverlayController.shared.updatePartial(token: token, transcript: text, attributed: attributed) + } + + func finalize( + token: UUID, + text: String, + sendChime: VoiceWakeChime, + autoSendAfter: TimeInterval?) + { + guard let session, session.token == token else { return } + self.logger + .info( + "coordinator finalize token=\(token.uuidString) len=\(text.count) autoSendAfter=\(autoSendAfter ?? -1)") + self.session?.text = text + self.session?.isFinal = true + self.session?.sendChime = sendChime + self.session?.autoSendDelay = autoSendAfter + + let attributed = VoiceWakeOverlayController.shared.makeAttributed(from: text) + VoiceWakeOverlayController.shared.presentFinal( + token: token, + transcript: text, + autoSendAfter: autoSendAfter, + sendChime: sendChime, + attributed: attributed) + } + + func sendNow(token: UUID, reason: String = "explicit") { + guard let session, session.token == token else { return } + let text = session.text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { + self.logger.info("coordinator sendNow \(reason) empty -> dismiss") + VoiceWakeOverlayController.shared.dismiss(token: token, reason: .empty, outcome: .empty) + self.clearSession() + return + } + VoiceWakeOverlayController.shared.beginSendUI(token: token, sendChime: session.sendChime) + Task.detached { + _ = await VoiceWakeForwarder.forward(transcript: text) + } + } + + func dismiss( + token: UUID, + reason: VoiceWakeOverlayController.DismissReason, + outcome: VoiceWakeOverlayController.SendOutcome) + { + guard let session, session.token == token else { return } + VoiceWakeOverlayController.shared.dismiss(token: token, reason: reason, outcome: outcome) + self.clearSession() + } + + func updateLevel(token: UUID, _ level: Double) { + guard let session, session.token == token else { return } + VoiceWakeOverlayController.shared.updateLevel(token: token, level) + } + + func snapshot() -> (token: UUID?, text: String, visible: Bool) { + (self.session?.token, self.session?.text ?? "", VoiceWakeOverlayController.shared.isVisible) + } + + // MARK: - Private + + private func clearSession() { + self.session = nil + } + + /// Overlay dismiss completion callback (manual X, empty, auto-dismiss after send). + /// Ensures the wake-word recognizer is resumed if Voice Wake is enabled. + func overlayDidDismiss(token: UUID?) { + if let token, self.session?.token == token { + self.clearSession() + } + Task { await VoiceWakeRuntime.shared.refresh(state: AppStateStore.shared) } + } +} diff --git a/apps/macos/Sources/Moltbot/VoiceWakeChime.swift b/apps/macos/Sources/Moltbot/VoiceWakeChime.swift new file mode 100644 index 000000000..ca74d22dd --- /dev/null +++ b/apps/macos/Sources/Moltbot/VoiceWakeChime.swift @@ -0,0 +1,74 @@ +import AppKit +import Foundation +import OSLog + +enum VoiceWakeChime: Codable, Equatable, Sendable { + case none + case system(name: String) + case custom(displayName: String, bookmark: Data) + + var systemName: String? { + if case let .system(name) = self { + return name + } + return nil + } + + var displayLabel: String { + switch self { + case .none: + "No Sound" + case let .system(name): + VoiceWakeChimeCatalog.displayName(for: name) + case let .custom(displayName, _): + displayName + } + } +} + +enum VoiceWakeChimeCatalog { + /// Options shown in the picker. + static var systemOptions: [String] { SoundEffectCatalog.systemOptions } + + static func displayName(for raw: String) -> String { + SoundEffectCatalog.displayName(for: raw) + } + + static func url(for name: String) -> URL? { + SoundEffectCatalog.url(for: name) + } +} + +@MainActor +enum VoiceWakeChimePlayer { + private static let logger = Logger(subsystem: "bot.molt", category: "voicewake.chime") + private static var lastSound: NSSound? + + static func play(_ chime: VoiceWakeChime, reason: String? = nil) { + guard let sound = self.sound(for: chime) else { return } + if let reason { + self.logger.log(level: .info, "chime play reason=\(reason, privacy: .public)") + } else { + self.logger.log(level: .info, "chime play") + } + DiagnosticsFileLog.shared.log(category: "voicewake.chime", event: "play", fields: [ + "reason": reason ?? "", + "chime": chime.displayLabel, + "systemName": chime.systemName ?? "", + ]) + SoundEffectPlayer.play(sound) + } + + private static func sound(for chime: VoiceWakeChime) -> NSSound? { + switch chime { + case .none: + nil + + case let .system(name): + SoundEffectPlayer.sound(named: name) + + case let .custom(_, bookmark): + SoundEffectPlayer.sound(from: bookmark) + } + } +} diff --git a/apps/macos/Sources/Moltbot/VoiceWakeForwarder.swift b/apps/macos/Sources/Moltbot/VoiceWakeForwarder.swift new file mode 100644 index 000000000..7192f2bf4 --- /dev/null +++ b/apps/macos/Sources/Moltbot/VoiceWakeForwarder.swift @@ -0,0 +1,73 @@ +import Foundation +import OSLog + +enum VoiceWakeForwarder { + private static let logger = Logger(subsystem: "bot.molt", category: "voicewake.forward") + + static func prefixedTranscript(_ transcript: String, machineName: String? = nil) -> String { + let resolvedMachine = machineName + .flatMap { name -> String? in + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + ?? Host.current().localizedName + ?? ProcessInfo.processInfo.hostName + + let safeMachine = resolvedMachine.isEmpty ? "this Mac" : resolvedMachine + return """ + User talked via voice recognition on \(safeMachine) - repeat prompt first \ + + remember some words might be incorrectly transcribed. + + \(transcript) + """ + } + + enum VoiceWakeForwardError: LocalizedError, Equatable { + case rpcFailed(String) + + var errorDescription: String? { + switch self { + case let .rpcFailed(message): message + } + } + } + + struct ForwardOptions: Sendable { + var sessionKey: String = "main" + var thinking: String = "low" + var deliver: Bool = true + var to: String? + var channel: GatewayAgentChannel = .last + } + + @discardableResult + static func forward( + transcript: String, + options: ForwardOptions = ForwardOptions()) async -> Result + { + 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/Moltbot/VoiceWakeGlobalSettingsSync.swift b/apps/macos/Sources/Moltbot/VoiceWakeGlobalSettingsSync.swift new file mode 100644 index 000000000..b60d07597 --- /dev/null +++ b/apps/macos/Sources/Moltbot/VoiceWakeGlobalSettingsSync.swift @@ -0,0 +1,66 @@ +import MoltbotKit +import Foundation +import OSLog + +@MainActor +final class VoiceWakeGlobalSettingsSync { + static let shared = VoiceWakeGlobalSettingsSync() + + private let logger = Logger(subsystem: "bot.molt", category: "voicewake.sync") + private var task: Task? + + 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/Moltbot/VoiceWakeOverlay.swift b/apps/macos/Sources/Moltbot/VoiceWakeOverlay.swift new file mode 100644 index 000000000..dcbd25621 --- /dev/null +++ b/apps/macos/Sources/Moltbot/VoiceWakeOverlay.swift @@ -0,0 +1,60 @@ +import AppKit +import Observation +import SwiftUI + +/// Lightweight, borderless panel that shows the current voice wake transcript near the menu bar. +@MainActor +@Observable +final class VoiceWakeOverlayController { + static let shared = VoiceWakeOverlayController() + + let logger = Logger(subsystem: "bot.molt", category: "voicewake.overlay") + let enableUI: Bool + + /// Keep the voice wake overlay above any other Moltbot windows, but below the system’s pop-up menus. + /// (Menu bar menus typically live at `.popUpMenu`.) + static let preferredWindowLevel = NSWindow.Level(rawValue: NSWindow.Level.popUpMenu.rawValue - 4) + + enum Source: String { case wakeWord, pushToTalk } + + var model = Model() + var isVisible: Bool { self.model.isVisible } + + struct Model { + var text: String = "" + var isFinal: Bool = false + var isVisible: Bool = false + var forwardEnabled: Bool = false + var isSending: Bool = false + var attributed: NSAttributedString = .init(string: "") + var isOverflowing: Bool = false + var isEditing: Bool = false + var level: Double = 0 // normalized 0...1 speech level for UI + } + + var window: NSPanel? + var hostingView: NSHostingView? + 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/Moltbot/VoiceWakeRuntime.swift b/apps/macos/Sources/Moltbot/VoiceWakeRuntime.swift new file mode 100644 index 000000000..805211122 --- /dev/null +++ b/apps/macos/Sources/Moltbot/VoiceWakeRuntime.swift @@ -0,0 +1,804 @@ +import AVFoundation +import Foundation +import OSLog +import Speech +import SwabbleKit +#if canImport(AppKit) +import AppKit +#endif + +/// Background listener that keeps the voice-wake pipeline alive outside the settings test view. +actor VoiceWakeRuntime { + static let shared = VoiceWakeRuntime() + + enum ListeningState { case idle, voiceWake, pushToTalk } + + private let logger = Logger(subsystem: "bot.molt", category: "voicewake.runtime") + + private var recognizer: SFSpeechRecognizer? + // Lazily created on start to avoid creating an AVAudioEngine at app launch, which can switch Bluetooth + // headphones into the low-quality headset profile even if Voice Wake is disabled. + private var audioEngine: AVAudioEngine? + private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? + private var recognitionTask: SFSpeechRecognitionTask? + private var recognitionGeneration: Int = 0 // drop stale callbacks after restarts + private var lastHeard: Date? + private var noiseFloorRMS: Double = 1e-4 + private var captureStartedAt: Date? + private var captureTask: Task? + 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/Moltbot/VoiceWakeTester.swift b/apps/macos/Sources/Moltbot/VoiceWakeTester.swift new file mode 100644 index 000000000..05c8148b6 --- /dev/null +++ b/apps/macos/Sources/Moltbot/VoiceWakeTester.swift @@ -0,0 +1,473 @@ +import AVFoundation +import Foundation +import Speech +import SwabbleKit + +enum VoiceWakeTestState: Equatable { + case idle + case requesting + case listening + case hearing(String) + case finalizing + case detected(String) + case failed(String) +} + +final class VoiceWakeTester { + private let recognizer: SFSpeechRecognizer? + private var audioEngine: AVAudioEngine? + private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? + private var recognitionTask: SFSpeechRecognitionTask? + private var isStopping = false + private var isFinalizing = false + private var detectionStart: Date? + private var lastHeard: Date? + private var lastLoggedText: String? + private var lastLoggedAt: Date? + private var lastTranscript: String? + private var lastTranscriptAt: Date? + private var silenceTask: Task? + private var currentTriggers: [String] = [] + private var holdingAfterDetect = false + private var detectedText: String? + private let logger = Logger(subsystem: "bot.molt", category: "voicewake") + private let silenceWindow: TimeInterval = 1.0 + + init(locale: Locale = .current) { + self.recognizer = SFSpeechRecognizer(locale: locale) + } + + func start( + triggers: [String], + micID: String?, + localeID: String?, + onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) async throws + { + guard self.recognitionTask == nil else { return } + self.isStopping = false + self.isFinalizing = false + self.holdingAfterDetect = false + self.detectedText = nil + self.lastHeard = nil + self.lastLoggedText = nil + self.lastLoggedAt = nil + self.lastTranscript = nil + self.lastTranscriptAt = nil + self.silenceTask?.cancel() + self.silenceTask = nil + self.currentTriggers = triggers + let chosenLocale = localeID.flatMap { Locale(identifier: $0) } ?? Locale.current + let recognizer = SFSpeechRecognizer(locale: chosenLocale) + guard let recognizer, recognizer.isAvailable else { + throw NSError( + domain: "VoiceWakeTester", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Speech recognition unavailable"]) + } + recognizer.defaultTaskHint = .dictation + + guard Self.hasPrivacyStrings else { + throw NSError( + domain: "VoiceWakeTester", + code: 3, + userInfo: [ + NSLocalizedDescriptionKey: """ + Missing mic/speech privacy strings. Rebuild the mac app (scripts/restart-mac.sh) \ + to include usage descriptions. + """, + ]) + } + + let granted = try await Self.ensurePermissions() + guard granted else { + throw NSError( + domain: "VoiceWakeTester", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "Microphone or speech permission denied"]) + } + + self.logInputSelection(preferredMicID: micID) + self.configureSession(preferredMicID: micID) + + let engine = AVAudioEngine() + self.audioEngine = engine + + self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest() + self.recognitionRequest?.shouldReportPartialResults = true + self.recognitionRequest?.taskHint = .dictation + let request = self.recognitionRequest + + let inputNode = engine.inputNode + let format = inputNode.outputFormat(forBus: 0) + guard format.channelCount > 0, format.sampleRate > 0 else { + self.audioEngine = nil + throw NSError( + domain: "VoiceWakeTester", + code: 4, + userInfo: [NSLocalizedDescriptionKey: "No audio input available"]) + } + inputNode.removeTap(onBus: 0) + inputNode.installTap(onBus: 0, bufferSize: 2048, format: format) { [weak request] buffer, _ in + request?.append(buffer) + } + + engine.prepare() + try engine.start() + DispatchQueue.main.async { + onUpdate(.listening) + } + + self.detectionStart = Date() + self.lastHeard = self.detectionStart + + guard let request = recognitionRequest else { return } + + self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in + guard let self, !self.isStopping else { return } + let text = result?.bestTranscription.formattedString ?? "" + let segments = result.map { WakeWordSpeechSegments.from( + transcription: $0.bestTranscription, + transcript: text) } ?? [] + let isFinal = result?.isFinal ?? false + let gateConfig = WakeWordGateConfig(triggers: triggers) + var match = WakeWordGate.match(transcript: text, segments: segments, config: gateConfig) + if match == nil, isFinal { + match = self.textOnlyFallbackMatch( + transcript: text, + triggers: triggers, + config: gateConfig) + } + self.maybeLogDebug( + transcript: text, + segments: segments, + triggers: triggers, + match: match, + isFinal: isFinal) + let errorMessage = error?.localizedDescription + + Task { [weak self] in + guard let self, !self.isStopping else { return } + await self.handleResult( + match: match, + text: text, + isFinal: isFinal, + errorMessage: errorMessage, + onUpdate: onUpdate) + } + } + } + + func stop() { + self.stop(force: true) + } + + func finalize(timeout: TimeInterval = 1.5) { + guard self.recognitionTask != nil else { + self.stop(force: true) + return + } + self.isFinalizing = true + self.recognitionRequest?.endAudio() + if let engine = self.audioEngine { + engine.inputNode.removeTap(onBus: 0) + engine.stop() + } + Task { [weak self] in + guard let self else { return } + try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + if !self.isStopping { + self.stop(force: true) + } + } + } + + private func stop(force: Bool) { + if force { self.isStopping = true } + self.isFinalizing = false + self.recognitionRequest?.endAudio() + self.recognitionTask?.cancel() + self.recognitionTask = nil + self.recognitionRequest = nil + if let engine = self.audioEngine { + engine.inputNode.removeTap(onBus: 0) + engine.stop() + } + self.audioEngine = nil + self.holdingAfterDetect = false + self.detectedText = nil + self.lastHeard = nil + self.detectionStart = nil + self.lastLoggedText = nil + self.lastLoggedAt = nil + self.lastTranscript = nil + self.lastTranscriptAt = nil + self.silenceTask?.cancel() + self.silenceTask = nil + self.currentTriggers = [] + } + + private func handleResult( + match: WakeWordGateMatch?, + text: String, + isFinal: Bool, + errorMessage: String?, + onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) async + { + if !text.isEmpty { + self.lastHeard = Date() + self.lastTranscript = text + self.lastTranscriptAt = Date() + } + if self.holdingAfterDetect { + return + } + if let match, !match.command.isEmpty { + self.holdingAfterDetect = true + self.detectedText = match.command + self.logger.info("voice wake detected (test) (len=\(match.command.count))") + await MainActor.run { AppStateStore.shared.triggerVoiceEars(ttl: nil) } + self.stop() + await MainActor.run { + AppStateStore.shared.stopVoiceEars() + onUpdate(.detected(match.command)) + } + return + } + if !isFinal, !text.isEmpty { + self.scheduleSilenceCheck( + triggers: self.currentTriggers, + onUpdate: onUpdate) + } + if self.isFinalizing { + Task { @MainActor in onUpdate(.finalizing) } + } + if let errorMessage { + self.stop(force: true) + Task { @MainActor in onUpdate(.failed(errorMessage)) } + return + } + if isFinal { + self.stop(force: true) + let state: VoiceWakeTestState = text.isEmpty + ? .failed("No speech detected") + : .failed("No trigger heard: “\(text)”") + Task { @MainActor in onUpdate(state) } + } else { + let state: VoiceWakeTestState = text.isEmpty ? .listening : .hearing(text) + Task { @MainActor in onUpdate(state) } + } + } + + private func maybeLogDebug( + transcript: String, + segments: [WakeWordSegment], + triggers: [String], + match: WakeWordGateMatch?, + isFinal: Bool) + { + guard !transcript.isEmpty else { return } + let level = self.logger.logLevel + guard level == .debug || level == .trace else { return } + if transcript == self.lastLoggedText, !isFinal { + if let last = self.lastLoggedAt, Date().timeIntervalSince(last) < 0.25 { + return + } + } + self.lastLoggedText = transcript + self.lastLoggedAt = Date() + + let textOnly = WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers) + let gaps = Self.debugCandidateGaps(triggers: triggers, segments: segments) + let segmentSummary = Self.debugSegments(segments) + let timingCount = segments.count(where: { $0.start > 0 || $0.duration > 0 }) + let matchSummary = match.map { + "match=true gap=\(String(format: "%.2f", $0.postGap))s cmdLen=\($0.command.count)" + } ?? "match=false" + + self.logger.debug( + "voicewake test transcript='\(transcript, privacy: .private)' textOnly=\(textOnly) " + + "isFinal=\(isFinal) timing=\(timingCount)/\(segments.count) " + + "\(matchSummary) gaps=[\(gaps, privacy: .private)] segments=[\(segmentSummary, privacy: .private)]") + } + + private static func debugSegments(_ segments: [WakeWordSegment]) -> String { + segments.map { seg in + let start = String(format: "%.2f", seg.start) + let end = String(format: "%.2f", seg.end) + return "\(seg.text)@\(start)-\(end)" + }.joined(separator: ", ") + } + + private static func debugCandidateGaps(triggers: [String], segments: [WakeWordSegment]) -> String { + let tokens = self.normalizeSegments(segments) + guard !tokens.isEmpty else { return "" } + let triggerTokens = self.normalizeTriggers(triggers) + var gaps: [String] = [] + + for trigger in triggerTokens { + let count = trigger.tokens.count + guard count > 0, tokens.count > count else { continue } + for i in 0...(tokens.count - count - 1) { + let matched = (0.. [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/Moltbot/WebChatSwiftUI.swift b/apps/macos/Sources/Moltbot/WebChatSwiftUI.swift new file mode 100644 index 000000000..c457ceb2a --- /dev/null +++ b/apps/macos/Sources/Moltbot/WebChatSwiftUI.swift @@ -0,0 +1,374 @@ +import AppKit +import MoltbotChatUI +import MoltbotKit +import MoltbotProtocol +import Foundation +import OSLog +import QuartzCore +import SwiftUI + +private let webChatSwiftLogger = Logger(subsystem: "bot.molt", category: "WebChatSwiftUI") + +private enum WebChatSwiftUILayout { + static let windowSize = NSSize(width: 500, height: 840) + static let panelSize = NSSize(width: 480, height: 640) + static let windowMinSize = NSSize(width: 480, height: 360) + static let anchorPadding: CGFloat = 8 +} + +struct MacGatewayChatTransport: MoltbotChatTransport, Sendable { + func requestHistory(sessionKey: String) async throws -> MoltbotChatHistoryPayload { + try await GatewayConnection.shared.chatHistory(sessionKey: sessionKey) + } + + func abortRun(sessionKey: String, runId: String) async throws { + _ = try await GatewayConnection.shared.request( + method: "chat.abort", + params: [ + "sessionKey": AnyCodable(sessionKey), + "runId": AnyCodable(runId), + ], + timeoutMs: 10000) + } + + func listSessions(limit: Int?) async throws -> MoltbotChatSessionsListResponse { + var params: [String: AnyCodable] = [ + "includeGlobal": AnyCodable(true), + "includeUnknown": AnyCodable(false), + ] + if let limit { + params["limit"] = AnyCodable(limit) + } + let data = try await GatewayConnection.shared.request( + method: "sessions.list", + params: params, + timeoutMs: 15000) + return try JSONDecoder().decode(MoltbotChatSessionsListResponse.self, from: data) + } + + func sendMessage( + sessionKey: String, + message: String, + thinking: String, + idempotencyKey: String, + attachments: [MoltbotChatAttachmentPayload]) async throws -> MoltbotChatSendResponse + { + try await GatewayConnection.shared.chatSend( + sessionKey: sessionKey, + message: message, + thinking: thinking, + idempotencyKey: idempotencyKey, + attachments: attachments) + } + + func requestHealth(timeoutMs: Int) async throws -> Bool { + try await GatewayConnection.shared.healthOK(timeoutMs: timeoutMs) + } + + func events() -> AsyncStream { + 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/MoltbotDiscovery/GatewayDiscoveryModel.swift b/apps/macos/Sources/MoltbotDiscovery/GatewayDiscoveryModel.swift new file mode 100644 index 000000000..69d8978ec --- /dev/null +++ b/apps/macos/Sources/MoltbotDiscovery/GatewayDiscoveryModel.swift @@ -0,0 +1,683 @@ +import MoltbotKit +import Foundation +import Network +import Observation +import OSLog + +@MainActor +@Observable +public final class GatewayDiscoveryModel { + public struct LocalIdentity: Equatable, Sendable { + public var hostTokens: Set + 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: "bot.molt", category: "gateway-discovery") + + public init( + localDisplayName: String? = nil, + filterLocalGateways: Bool = true) + { + self.localDisplayName = localDisplayName + self.filterLocalGateways = filterLocalGateways + self.localIdentity = Self.buildLocalIdentityFast(displayName: localDisplayName) + self.refreshLocalIdentity() + } + + public func start() { + if !self.browsers.isEmpty { return } + + for domain in MoltbotBonjour.gatewayServiceDomains { + let params = NWParameters.tcp + params.includePeerToPeer = true + let browser = NWBrowser( + for: .bonjour(type: MoltbotBonjour.gatewayServiceType, domain: domain), + using: params) + + browser.stateUpdateHandler = { [weak self] state in + Task { @MainActor in + guard let self else { return } + self.statesByDomain[domain] = state + self.updateStatusText() + } + } + + browser.browseResultsChangedHandler = { [weak self] results, _ in + Task { @MainActor in + guard let self else { return } + self.resultsByDomain[domain] = results + self.updateGateways(for: domain) + self.recomputeGateways() + } + } + + self.browsers[domain] = browser + browser.start(queue: DispatchQueue(label: "bot.molt.macos.gateway-discovery.\(domain)")) + } + + self.scheduleWideAreaFallback() + } + + public func refreshWideAreaFallbackNow(timeoutSeconds: TimeInterval = 5.0) { + let domain = MoltbotBonjour.wideAreaGatewayServiceDomain + Task.detached(priority: .utility) { [weak self] in + guard let self else { return } + let beacons = WideAreaGatewayDiscovery.discover(timeoutSeconds: timeoutSeconds) + await MainActor.run { [weak self] in + guard let self else { return } + self.wideAreaFallbackGateways = self.mapWideAreaBeacons(beacons, domain: domain) + self.recomputeGateways() + } + } + } + + public func stop() { + for browser in self.browsers.values { + browser.cancel() + } + self.browsers = [:] + self.resultsByDomain = [:] + self.gatewaysByDomain = [:] + self.statesByDomain = [:] + self.resolvedTXTByID = [:] + self.pendingTXTResolvers.values.forEach { $0.cancel() } + self.pendingTXTResolvers = [:] + self.wideAreaFallbackTask?.cancel() + self.wideAreaFallbackTask = nil + self.wideAreaFallbackGateways = [] + self.gateways = [] + self.statusText = "Stopped" + } + + private func mapWideAreaBeacons(_ beacons: [WideAreaGatewayBeacon], domain: String) -> [DiscoveredGateway] { + beacons.map { beacon in + let stableID = "wide-area|\(domain)|\(beacon.instanceName)" + let isLocal = Self.isLocalGateway( + lanHost: beacon.lanHost, + tailnetDns: beacon.tailnetDns, + displayName: beacon.displayName, + serviceName: beacon.instanceName, + local: self.localIdentity) + return DiscoveredGateway( + displayName: beacon.displayName, + lanHost: beacon.lanHost, + tailnetDns: beacon.tailnetDns, + sshPort: beacon.sshPort ?? 22, + gatewayPort: beacon.gatewayPort, + cliPath: beacon.cliPath, + stableID: stableID, + debugID: "\(beacon.instanceName)@\(beacon.host):\(beacon.port)", + isLocal: isLocal) + } + } + + private func recomputeGateways() { + let primary = self.sortedDeduped(gateways: self.gatewaysByDomain.values.flatMap(\.self)) + let primaryFiltered = self.filterLocalGateways ? primary.filter { !$0.isLocal } : primary + if !primaryFiltered.isEmpty { + self.gateways = primaryFiltered + return + } + + // Bonjour can return only "local" results for the wide-area domain (or no results at all), + // which makes onboarding look empty even though Tailscale DNS-SD can already see gateways. + guard !self.wideAreaFallbackGateways.isEmpty else { + self.gateways = primaryFiltered + return + } + + let combined = self.sortedDeduped(gateways: primary + self.wideAreaFallbackGateways) + self.gateways = self.filterLocalGateways ? combined.filter { !$0.isLocal } : combined + } + + private func updateGateways(for domain: String) { + guard let results = self.resultsByDomain[domain] else { + self.gatewaysByDomain[domain] = [] + return + } + + self.gatewaysByDomain[domain] = results.compactMap { result -> DiscoveredGateway? in + guard case let .service(name, type, resultDomain, _) = result.endpoint else { return nil } + + let decodedName = BonjourEscapes.decode(name) + let stableID = GatewayEndpointID.stableID(result.endpoint) + let resolvedTXT = self.resolvedTXTByID[stableID] ?? [:] + let txt = Self.txtDictionary(from: result).merging( + resolvedTXT, + uniquingKeysWith: { _, new in new }) + + let advertisedName = txt["displayName"] + .map(Self.prettifyInstanceName) + .flatMap { $0.isEmpty ? nil : $0 } + let prettyName = + advertisedName ?? Self.prettifyServiceName(decodedName) + + let parsedTXT = Self.parseGatewayTXT(txt) + + if parsedTXT.lanHost == nil || parsedTXT.tailnetDns == nil { + self.ensureTXTResolution( + stableID: stableID, + serviceName: name, + type: type, + domain: resultDomain) + } + + let isLocal = Self.isLocalGateway( + lanHost: parsedTXT.lanHost, + tailnetDns: parsedTXT.tailnetDns, + displayName: prettyName, + serviceName: decodedName, + local: self.localIdentity) + return DiscoveredGateway( + displayName: prettyName, + lanHost: parsedTXT.lanHost, + tailnetDns: parsedTXT.tailnetDns, + sshPort: parsedTXT.sshPort, + gatewayPort: parsedTXT.gatewayPort, + cliPath: parsedTXT.cliPath, + stableID: stableID, + debugID: GatewayEndpointID.prettyDescription(result.endpoint), + isLocal: isLocal) + } + .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } + + if domain == MoltbotBonjour.wideAreaGatewayServiceDomain, + self.hasUsableWideAreaResults + { + self.wideAreaFallbackGateways = [] + } + } + + private func scheduleWideAreaFallback() { + let domain = MoltbotBonjour.wideAreaGatewayServiceDomain + if Self.isRunningTests { return } + guard self.wideAreaFallbackTask == nil else { return } + self.wideAreaFallbackTask = Task.detached(priority: .utility) { [weak self] in + guard let self else { return } + var attempt = 0 + let startedAt = Date() + while !Task.isCancelled, Date().timeIntervalSince(startedAt) < 35.0 { + let hasResults = await MainActor.run { + self.hasUsableWideAreaResults + } + if hasResults { return } + + // Wide-area discovery can be racy (Tailscale not yet up, DNS zone not + // published yet). Retry with a short backoff while onboarding is open. + let beacons = WideAreaGatewayDiscovery.discover(timeoutSeconds: 2.0) + if !beacons.isEmpty { + await MainActor.run { [weak self] in + guard let self else { return } + self.wideAreaFallbackGateways = self.mapWideAreaBeacons(beacons, domain: domain) + self.recomputeGateways() + } + return + } + + attempt += 1 + let backoff = min(8.0, 0.6 + (Double(attempt) * 0.7)) + try? await Task.sleep(nanoseconds: UInt64(backoff * 1_000_000_000)) + } + } + } + + private var hasUsableWideAreaResults: Bool { + let domain = MoltbotBonjour.wideAreaGatewayServiceDomain + guard let gateways = self.gatewaysByDomain[domain], !gateways.isEmpty else { return false } + if !self.filterLocalGateways { return true } + return gateways.contains(where: { !$0.isLocal }) + } + + private func sortedDeduped(gateways: [DiscoveredGateway]) -> [DiscoveredGateway] { + var seen = Set() + 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/shared/MoltbotKit/Package.swift b/apps/shared/MoltbotKit/Package.swift new file mode 100644 index 000000000..b821755a6 --- /dev/null +++ b/apps/shared/MoltbotKit/Package.swift @@ -0,0 +1,61 @@ +// swift-tools-version: 6.2 + +import PackageDescription + +let package = Package( + name: "MoltbotKit", + platforms: [ + .iOS(.v18), + .macOS(.v15), + ], + products: [ + .library(name: "MoltbotProtocol", targets: ["MoltbotProtocol"]), + .library(name: "MoltbotKit", targets: ["MoltbotKit"]), + .library(name: "MoltbotChatUI", targets: ["MoltbotChatUI"]), + ], + dependencies: [ + .package(url: "https://github.com/steipete/ElevenLabsKit", exact: "0.1.0"), + .package(url: "https://github.com/gonzalezreal/textual", exact: "0.3.1"), + ], + targets: [ + .target( + name: "MoltbotProtocol", + path: "Sources/MoltbotProtocol", + swiftSettings: [ + .enableUpcomingFeature("StrictConcurrency"), + ]), + .target( + name: "MoltbotKit", + path: "Sources/MoltbotKit", + dependencies: [ + "MoltbotProtocol", + .product(name: "ElevenLabsKit", package: "ElevenLabsKit"), + ], + resources: [ + .process("Resources"), + ], + swiftSettings: [ + .enableUpcomingFeature("StrictConcurrency"), + ]), + .target( + name: "MoltbotChatUI", + path: "Sources/MoltbotChatUI", + dependencies: [ + "MoltbotKit", + .product( + name: "Textual", + package: "textual", + condition: .when(platforms: [.macOS, .iOS])), + ], + swiftSettings: [ + .enableUpcomingFeature("StrictConcurrency"), + ]), + .testTarget( + name: "MoltbotKitTests", + dependencies: ["MoltbotKit", "MoltbotChatUI"], + path: "Tests/MoltbotKitTests", + swiftSettings: [ + .enableUpcomingFeature("StrictConcurrency"), + .enableExperimentalFeature("SwiftTesting"), + ]), + ]) diff --git a/docs/concepts/typebox.md b/docs/concepts/typebox.md index 5ee4346cd..892ba1b2d 100644 --- a/docs/concepts/typebox.md +++ b/docs/concepts/typebox.md @@ -58,7 +58,7 @@ Authoritative list lives in `src/gateway/server.ts` (`METHODS`, `EVENTS`). - Server handshake + method dispatch: `src/gateway/server.ts` - Node client: `src/gateway/client.ts` - Generated JSON Schema: `dist/protocol.schema.json` -- Generated Swift models: `apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift` +- Generated Swift models: `apps/macos/Sources/MoltbotProtocol/GatewayModels.swift` ## Current pipeline diff --git a/package.json b/package.json index f91af8199..7c05bd9b4 100644 --- a/package.json +++ b/package.json @@ -115,7 +115,7 @@ "lint:all": "pnpm lint && pnpm lint:swift", "lint:fix": "pnpm format:fix && oxlint --type-aware --fix src test", "format": "oxfmt --check src test", - "format:swift": "swiftformat --lint --config .swiftformat apps/macos/Sources apps/ios/Sources apps/shared/ClawdbotKit/Sources", + "format:swift": "swiftformat --lint --config .swiftformat apps/macos/Sources apps/ios/Sources apps/shared/MoltbotKit/Sources", "format:all": "pnpm format && pnpm format:swift", "format:fix": "oxfmt --write src test", "test": "node scripts/test-parallel.mjs", @@ -141,7 +141,7 @@ "test:install:e2e:anthropic": "CLAWDBOT_E2E_MODELS=anthropic bash scripts/test-install-sh-e2e-docker.sh", "protocol:gen": "node --import tsx scripts/protocol-gen.ts", "protocol:gen:swift": "node --import tsx scripts/protocol-gen-swift.ts", - "protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift", + "protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/MoltbotProtocol/GatewayModels.swift", "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh", "check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500" }, diff --git a/scripts/bundle-a2ui.sh b/scripts/bundle-a2ui.sh index 75844ec6d..e04648090 100755 --- a/scripts/bundle-a2ui.sh +++ b/scripts/bundle-a2ui.sh @@ -11,7 +11,7 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" HASH_FILE="$ROOT_DIR/src/canvas-host/a2ui/.bundle.hash" OUTPUT_FILE="$ROOT_DIR/src/canvas-host/a2ui/a2ui.bundle.js" A2UI_RENDERER_DIR="$ROOT_DIR/vendor/a2ui/renderers/lit" -A2UI_APP_DIR="$ROOT_DIR/apps/shared/ClawdbotKit/Tools/CanvasA2UI" +A2UI_APP_DIR="$ROOT_DIR/apps/shared/MoltbotKit/Tools/CanvasA2UI" # Docker builds exclude vendor/apps via .dockerignore. # In that environment we must keep the prebuilt bundle. diff --git a/scripts/protocol-gen-swift.ts b/scripts/protocol-gen-swift.ts index b4310d9b8..0c2ca5066 100644 --- a/scripts/protocol-gen-swift.ts +++ b/scripts/protocol-gen-swift.ts @@ -24,16 +24,16 @@ const outPaths = [ "apps", "macos", "Sources", - "ClawdbotProtocol", + "MoltbotProtocol", "GatewayModels.swift", ), path.join( repoRoot, "apps", "shared", - "ClawdbotKit", + "MoltbotKit", "Sources", - "ClawdbotProtocol", + "MoltbotProtocol", "GatewayModels.swift", ), ]; From c1a7917de73c001330b04e3e51d3ac7a8b8ed87f Mon Sep 17 00:00:00 2001 From: Shadow Date: Tue, 27 Jan 2026 14:12:47 -0600 Subject: [PATCH 2/8] Mac: finish Moltbot rename (paths) --- .../Sources/Clawdbot/AgentWorkspace.swift | 340 ------- .../Sources/Clawdbot/AnthropicOAuth.swift | 384 ------- .../Clawdbot/AudioInputDeviceObserver.swift | 216 ---- .../Sources/Clawdbot/CLIInstallPrompter.swift | 84 -- .../Clawdbot/CameraCaptureService.swift | 425 -------- .../Sources/Clawdbot/CanvasFileWatcher.swift | 94 -- .../Sources/Clawdbot/CanvasManager.swift | 342 ------- .../Clawdbot/CanvasSchemeHandler.swift | 259 ----- .../macos/Sources/Clawdbot/CanvasWindow.swift | 26 - .../Sources/Clawdbot/ClawdbotConfigFile.swift | 217 ---- .../Sources/Clawdbot/ConfigFileWatcher.swift | 118 --- .../Clawdbot/ConnectionModeCoordinator.swift | 79 -- apps/macos/Sources/Clawdbot/Constants.swift | 44 - .../Sources/Clawdbot/ControlChannel.swift | 427 -------- .../Sources/Clawdbot/CronJobsStore.swift | 200 ---- apps/macos/Sources/Clawdbot/DeepLinks.swift | 151 --- .../DevicePairingApprovalPrompter.swift | 334 ------ .../Sources/Clawdbot/DockIconManager.swift | 116 --- .../Sources/Clawdbot/ExecApprovals.swift | 790 --------------- .../ExecApprovalsGatewayPrompter.swift | 123 --- .../Clawdbot/ExecApprovalsSocket.swift | 831 --------------- .../Sources/Clawdbot/GatewayConnection.swift | 737 -------------- .../GatewayConnectivityCoordinator.swift | 63 -- .../Clawdbot/GatewayEndpointStore.swift | 696 ------------- .../Sources/Clawdbot/GatewayEnvironment.swift | 342 ------- .../Clawdbot/GatewayLaunchAgentManager.swift | 203 ---- .../Clawdbot/GatewayProcessManager.swift | 432 -------- apps/macos/Sources/Clawdbot/HealthStore.swift | 301 ------ .../Sources/Clawdbot/InstancesStore.swift | 394 -------- .../Sources/Clawdbot/LaunchAgentManager.swift | 86 -- .../Clawdbot/Logging/ClawdbotLogging.swift | 230 ----- apps/macos/Sources/Clawdbot/MenuBar.swift | 471 --------- .../Sources/Clawdbot/MicLevelMonitor.swift | 97 -- .../Sources/Clawdbot/ModelCatalogLoader.swift | 156 --- .../NodeMode/MacNodeModeCoordinator.swift | 171 ---- .../NodePairingApprovalPrompter.swift | 708 ------------- .../Sources/Clawdbot/NodeServiceManager.swift | 150 --- apps/macos/Sources/Clawdbot/NodesStore.swift | 102 -- .../Clawdbot/NotificationManager.swift | 66 -- .../Sources/Clawdbot/OnboardingWizard.swift | 412 -------- .../PeekabooBridgeHostCoordinator.swift | 130 --- .../Sources/Clawdbot/PermissionManager.swift | 506 ---------- .../macos/Sources/Clawdbot/PortGuardian.swift | 418 -------- .../Sources/Clawdbot/PresenceReporter.swift | 158 --- .../Sources/Clawdbot/RemotePortTunnel.swift | 317 ------ .../Clawdbot/RemoteTunnelManager.swift | 122 --- .../Sources/Clawdbot/Resources/Info.plist | 79 -- .../Sources/Clawdbot/RuntimeLocator.swift | 167 --- .../Clawdbot/ScreenRecordService.swift | 266 ----- .../Clawdbot/SessionMenuPreviewView.swift | 495 --------- .../Sources/Clawdbot/TailscaleService.swift | 226 ----- .../Sources/Clawdbot/TalkAudioPlayer.swift | 158 --- .../Sources/Clawdbot/TalkModeController.swift | 69 -- .../Sources/Clawdbot/TalkModeRuntime.swift | 953 ------------------ apps/macos/Sources/Clawdbot/TalkOverlay.swift | 146 --- .../Clawdbot/TerminationSignalWatcher.swift | 53 - .../Sources/Clawdbot/VoicePushToTalk.swift | 421 -------- .../Clawdbot/VoiceSessionCoordinator.swift | 134 --- .../Sources/Clawdbot/VoiceWakeChime.swift | 74 -- .../Sources/Clawdbot/VoiceWakeForwarder.swift | 73 -- .../VoiceWakeGlobalSettingsSync.swift | 66 -- .../Sources/Clawdbot/VoiceWakeOverlay.swift | 60 -- .../Sources/Clawdbot/VoiceWakeRuntime.swift | 804 --------------- .../Sources/Clawdbot/VoiceWakeTester.swift | 473 --------- .../Sources/Clawdbot/WebChatSwiftUI.swift | 374 ------- .../GatewayDiscoveryModel.swift | 683 ------------- .../{Clawdbot => Moltbot}/AboutSettings.swift | 0 .../{Clawdbot => Moltbot}/AgeFormatting.swift | 0 .../AgentEventStore.swift | 0 .../AgentEventsWindow.swift | 0 .../AnthropicAuthControls.swift | 0 .../AnthropicOAuthCodeState.swift | 0 .../AnyCodable+Helpers.swift | 0 .../{Clawdbot => Moltbot}/AppState.swift | 0 .../{Clawdbot => Moltbot}/CLIInstaller.swift | 0 .../CanvasA2UIActionMessageHandler.swift | 0 .../CanvasChromeContainerView.swift | 0 .../{Clawdbot => Moltbot}/CanvasScheme.swift | 0 .../CanvasWindowController+Helpers.swift | 0 .../CanvasWindowController+Navigation.swift | 0 .../CanvasWindowController+Testing.swift | 0 .../CanvasWindowController+Window.swift | 0 .../CanvasWindowController.swift | 0 .../ChannelConfigForm.swift | 0 .../ChannelsSettings+ChannelSections.swift | 0 .../ChannelsSettings+ChannelState.swift | 0 .../ChannelsSettings+Helpers.swift | 0 .../ChannelsSettings+View.swift | 0 .../ChannelsSettings.swift | 0 .../ChannelsStore+Config.swift | 0 .../ChannelsStore+Lifecycle.swift | 0 .../{Clawdbot => Moltbot}/ChannelsStore.swift | 0 .../{Clawdbot => Moltbot}/ClawdbotPaths.swift | 0 .../CommandResolver.swift | 0 .../ConfigSchemaSupport.swift | 0 .../ConfigSettings.swift | 0 .../{Clawdbot => Moltbot}/ConfigStore.swift | 0 .../ConnectionModeResolver.swift | 0 .../ContextMenuCardView.swift | 0 .../ContextUsageBar.swift | 0 .../CostUsageMenuView.swift | 0 .../CritterIconRenderer.swift | 0 .../CritterStatusLabel+Behavior.swift | 0 .../CritterStatusLabel.swift | 0 .../CronJobEditor+Helpers.swift | 0 .../CronJobEditor+Testing.swift | 0 .../{Clawdbot => Moltbot}/CronJobEditor.swift | 0 .../{Clawdbot => Moltbot}/CronModels.swift | 0 .../CronSettings+Actions.swift | 0 .../CronSettings+Helpers.swift | 0 .../CronSettings+Layout.swift | 0 .../CronSettings+Rows.swift | 0 .../CronSettings+Testing.swift | 0 .../{Clawdbot => Moltbot}/CronSettings.swift | 0 .../{Clawdbot => Moltbot}/DebugActions.swift | 0 .../{Clawdbot => Moltbot}/DebugSettings.swift | 0 .../DeviceModelCatalog.swift | 0 .../DiagnosticsFileLog.swift | 0 .../FileHandle+SafeRead.swift | 0 .../GatewayAutostartPolicy.swift | 0 .../GatewayDiscoveryHelpers.swift | 0 .../GatewayDiscoveryMenu.swift | 0 .../GatewayDiscoveryPreferences.swift | 0 .../GatewayRemoteConfig.swift | 0 .../GeneralSettings.swift | 0 .../HeartbeatStore.swift | 0 .../{Clawdbot => Moltbot}/HoverHUD.swift | 0 .../{Clawdbot => Moltbot}/IconState.swift | 0 .../InstancesSettings.swift | 0 .../{Clawdbot => Moltbot}/Launchctl.swift | 0 .../LaunchdManager.swift | 0 .../{Clawdbot => Moltbot}/LogLocator.swift | 0 .../MenuContentView.swift | 0 .../MenuContextCardInjector.swift | 0 .../MenuHighlightedHostView.swift | 0 .../MenuHostedItem.swift | 0 .../MenuSessionsHeaderView.swift | 0 .../MenuSessionsInjector.swift | 0 .../MenuUsageHeaderView.swift | 0 .../NSAttributedString+VoiceWake.swift | 0 .../NodeMode/MacNodeLocationService.swift | 0 .../NodeMode/MacNodeRuntime.swift | 0 .../MacNodeRuntimeMainActorServices.swift | 0 .../NodeMode/MacNodeScreenCommands.swift | 0 .../{Clawdbot => Moltbot}/NodesMenu.swift | 0 .../{Clawdbot => Moltbot}/NotifyOverlay.swift | 0 .../{Clawdbot => Moltbot}/Onboarding.swift | 0 .../OnboardingView+Actions.swift | 0 .../OnboardingView+Chat.swift | 0 .../OnboardingView+Layout.swift | 0 .../OnboardingView+Monitoring.swift | 0 .../OnboardingView+Pages.swift | 0 .../OnboardingView+Testing.swift | 0 .../OnboardingView+Wizard.swift | 0 .../OnboardingView+Workspace.swift | 0 .../OnboardingWidgets.swift | 0 .../PermissionsSettings.swift | 0 .../PointingHandCursor.swift | 0 .../Process+PipeRead.swift | 0 .../ProcessInfo+Clawdbot.swift | 0 .../Resources/Clawdbot.icns | Bin .../LICENSE.apple-device-identifiers.txt | 0 .../Resources/DeviceModels/NOTICE.md | 0 .../DeviceModels/ios-device-identifiers.json | 0 .../DeviceModels/mac-device-identifiers.json | 0 .../ScreenshotSize.swift | 0 .../SessionActions.swift | 0 .../{Clawdbot => Moltbot}/SessionData.swift | 0 .../SessionMenuLabelView.swift | 0 .../SessionsSettings.swift | 0 .../SettingsComponents.swift | 0 .../SettingsRootView.swift | 0 .../SettingsWindowOpener.swift | 0 .../{Clawdbot => Moltbot}/ShellExecutor.swift | 0 .../{Clawdbot => Moltbot}/SkillsModels.swift | 0 .../SkillsSettings.swift | 0 .../{Clawdbot => Moltbot}/SoundEffects.swift | 0 .../{Clawdbot => Moltbot}/StatusPill.swift | 0 .../String+NonEmpty.swift | 0 .../SystemRunSettingsView.swift | 0 .../TailscaleIntegrationSection.swift | 0 .../{Clawdbot => Moltbot}/TalkModeTypes.swift | 0 .../TalkOverlayView.swift | 0 .../{Clawdbot => Moltbot}/UsageCostData.swift | 0 .../{Clawdbot => Moltbot}/UsageData.swift | 0 .../UsageMenuLabelView.swift | 0 .../{Clawdbot => Moltbot}/ViewMetrics.swift | 0 .../VisualEffectView.swift | 0 .../VoiceWakeHelpers.swift | 0 .../VoiceWakeOverlayController+Session.swift | 0 .../VoiceWakeOverlayController+Testing.swift | 0 .../VoiceWakeOverlayController+Window.swift | 0 .../VoiceWakeOverlayTextViews.swift | 0 .../VoiceWakeOverlayView.swift | 0 .../VoiceWakeSettings.swift | 0 .../VoiceWakeTestCard.swift | 0 .../VoiceWakeTextUtils.swift | 0 .../WebChatManager.swift | 0 .../WindowPlacement.swift | 0 .../WorkActivityStore.swift | 0 .../WideAreaGatewayDiscovery.swift | 0 .../{ClawdbotIPC => MoltbotIPC}/IPC.swift | 0 .../ConnectCommand.swift | 0 .../DiscoverCommand.swift | 0 .../EntryPoint.swift | 0 .../GatewayConfig.swift | 0 .../TypeAliases.swift | 0 .../WizardCommand.swift | 0 .../GatewayModels.swift | 0 .../AgentEventStoreTests.swift | 0 .../AgentWorkspaceTests.swift | 0 .../AnthropicAuthControlsSmokeTests.swift | 0 .../AnthropicAuthResolverTests.swift | 0 .../AnthropicOAuthCodeStateTests.swift | 0 .../AnyCodableEncodingTests.swift | 0 .../CLIInstallerTests.swift | 0 .../CameraCaptureServiceTests.swift | 0 .../CameraIPCTests.swift | 0 .../CanvasFileWatcherTests.swift | 0 .../CanvasIPCTests.swift | 0 .../CanvasWindowSmokeTests.swift | 0 .../ChannelsSettingsSmokeTests.swift | 0 .../ClawdbotConfigFileTests.swift | 0 .../ClawdbotOAuthStoreTests.swift | 0 .../CommandResolverTests.swift | 0 .../ConfigStoreTests.swift | 0 .../CoverageDumpTests.swift | 0 .../CritterIconRendererTests.swift | 0 .../CronJobEditorSmokeTests.swift | 0 .../CronModelsTests.swift | 0 .../DeviceModelCatalogTests.swift | 0 .../ExecAllowlistTests.swift | 0 .../ExecApprovalHelpersTests.swift | 0 .../ExecApprovalsGatewayPrompterTests.swift | 0 .../FileHandleLegacyAPIGuardTests.swift | 0 .../FileHandleSafeReadTests.swift | 0 .../GatewayAgentChannelTests.swift | 0 .../GatewayAutostartPolicyTests.swift | 0 .../GatewayChannelConfigureTests.swift | 0 .../GatewayChannelConnectTests.swift | 0 .../GatewayChannelRequestTests.swift | 0 .../GatewayChannelShutdownTests.swift | 0 .../GatewayConnectionControlTests.swift | 0 .../GatewayDiscoveryModelTests.swift | 0 .../GatewayEndpointStoreTests.swift | 0 .../GatewayEnvironmentTests.swift | 0 .../GatewayFrameDecodeTests.swift | 0 .../GatewayLaunchAgentManagerTests.swift | 0 .../GatewayProcessManagerTests.swift | 0 .../HealthDecodeTests.swift | 0 .../HealthStoreStateTests.swift | 0 .../HoverHUDControllerTests.swift | 0 .../InstancesSettingsSmokeTests.swift | 0 .../InstancesStoreTests.swift | 0 .../LogLocatorTests.swift | 0 .../LowCoverageHelperTests.swift | 0 .../LowCoverageViewSmokeTests.swift | 0 .../MacGatewayChatTransportMappingTests.swift | 0 .../MacNodeRuntimeTests.swift | 0 .../MasterDiscoveryMenuSmokeTests.swift | 0 .../MenuContentSmokeTests.swift | 0 .../MenuSessionsInjectorTests.swift | 0 .../ModelCatalogLoaderTests.swift | 0 .../NodeManagerPathsTests.swift | 0 .../NodePairingApprovalPrompterTests.swift | 0 .../NodePairingReconcilePolicyTests.swift | 0 .../OnboardingCoverageTests.swift | 0 .../OnboardingViewSmokeTests.swift | 0 .../OnboardingWizardStepViewTests.swift | 0 .../PermissionManagerLocationTests.swift | 0 .../PermissionManagerTests.swift | 0 .../Placeholder.swift | 0 .../RemotePortTunnelTests.swift | 0 .../RuntimeLocatorTests.swift | 0 .../ScreenshotSizeTests.swift | 0 .../SemverTests.swift | 0 .../SessionDataTests.swift | 0 .../SessionMenuPreviewTests.swift | 0 .../SettingsViewSmokeTests.swift | 0 .../SkillsSettingsSmokeTests.swift | 0 .../TailscaleIntegrationSectionTests.swift | 0 .../TalkAudioPlayerTests.swift | 0 .../TestIsolation.swift | 0 .../UtilitiesTests.swift | 0 .../VoicePushToTalkHotkeyTests.swift | 0 .../VoicePushToTalkTests.swift | 0 .../VoiceWakeForwarderTests.swift | 0 .../VoiceWakeGlobalSettingsSyncTests.swift | 0 .../VoiceWakeHelpersTests.swift | 0 .../VoiceWakeOverlayControllerTests.swift | 0 .../VoiceWakeOverlayTests.swift | 0 .../VoiceWakeOverlayViewSmokeTests.swift | 0 .../VoiceWakeRuntimeTests.swift | 0 .../VoiceWakeTesterTests.swift | 0 .../WebChatMainSessionKeyTests.swift | 0 .../WebChatManagerTests.swift | 0 .../WebChatSwiftUISmokeTests.swift | 0 .../WideAreaGatewayDiscoveryTests.swift | 0 .../WindowPlacementTests.swift | 0 .../WorkActivityStoreTests.swift | 0 apps/shared/ClawdbotKit/Package.swift | 61 -- .../MoltbotChatUI}/AssistantTextParser.swift | 0 .../Sources/MoltbotChatUI}/ChatComposer.swift | 0 .../ChatMarkdownPreprocessor.swift | 0 .../MoltbotChatUI}/ChatMarkdownRenderer.swift | 0 .../MoltbotChatUI}/ChatMessageViews.swift | 0 .../Sources/MoltbotChatUI}/ChatModels.swift | 0 .../MoltbotChatUI}/ChatPayloadDecoding.swift | 0 .../Sources/MoltbotChatUI}/ChatSessions.swift | 0 .../Sources/MoltbotChatUI}/ChatSheets.swift | 0 .../Sources/MoltbotChatUI}/ChatTheme.swift | 0 .../MoltbotChatUI}/ChatTransport.swift | 0 .../Sources/MoltbotChatUI}/ChatView.swift | 0 .../MoltbotChatUI}/ChatViewModel.swift | 0 .../Sources/MoltbotKit}/AnyCodable.swift | 0 .../Sources/MoltbotKit}/AsyncTimeout.swift | 0 .../MoltbotKit}/AudioStreamingProtocols.swift | 0 .../Sources/MoltbotKit}/BonjourEscapes.swift | 0 .../Sources/MoltbotKit}/BonjourTypes.swift | 0 .../Sources/MoltbotKit}/BridgeFrames.swift | 0 .../Sources/MoltbotKit}/CameraCommands.swift | 0 .../MoltbotKit}/CanvasA2UIAction.swift | 0 .../MoltbotKit}/CanvasA2UICommands.swift | 0 .../Sources/MoltbotKit}/CanvasA2UIJSONL.swift | 0 .../MoltbotKit}/CanvasCommandParams.swift | 0 .../Sources/MoltbotKit}/CanvasCommands.swift | 0 .../Sources/MoltbotKit}/Capabilities.swift | 0 .../MoltbotKit}/ClawdbotKitResources.swift | 0 .../Sources/MoltbotKit}/DeepLinks.swift | 0 .../Sources/MoltbotKit}/DeviceAuthStore.swift | 0 .../Sources/MoltbotKit}/DeviceIdentity.swift | 0 .../MoltbotKit}/ElevenLabsKitShim.swift | 0 .../Sources/MoltbotKit}/GatewayChannel.swift | 0 .../MoltbotKit}/GatewayEndpointID.swift | 0 .../Sources/MoltbotKit}/GatewayErrors.swift | 0 .../MoltbotKit}/GatewayNodeSession.swift | 0 .../MoltbotKit}/GatewayPayloadDecoding.swift | 0 .../Sources/MoltbotKit}/GatewayPush.swift | 0 .../MoltbotKit}/GatewayTLSPinning.swift | 0 .../MoltbotKit}/InstanceIdentity.swift | 0 .../Sources/MoltbotKit}/JPEGTranscoder.swift | 0 .../MoltbotKit}/LocationCommands.swift | 0 .../MoltbotKit}/LocationSettings.swift | 0 .../Sources/MoltbotKit}/NodeError.swift | 0 .../Resources/CanvasScaffold/scaffold.html | 0 .../MoltbotKit}/Resources/tool-display.json | 0 .../Sources/MoltbotKit}/ScreenCommands.swift | 0 .../Sources/MoltbotKit}/StoragePaths.swift | 0 .../Sources/MoltbotKit}/SystemCommands.swift | 0 .../Sources/MoltbotKit}/TalkDirective.swift | 0 .../MoltbotKit}/TalkHistoryTimestamp.swift | 0 .../MoltbotKit}/TalkPromptBuilder.swift | 0 .../TalkSystemSpeechSynthesizer.swift | 0 .../Sources/MoltbotKit}/ToolDisplay.swift | 0 .../Sources/MoltbotProtocol}/AnyCodable.swift | 0 .../MoltbotProtocol}/GatewayModels.swift | 0 .../MoltbotProtocol}/WizardHelpers.swift | 0 .../AssistantTextParserTests.swift | 0 .../BonjourEscapesTests.swift | 0 .../CanvasA2UIActionTests.swift | 0 .../MoltbotKitTests}/CanvasA2UITests.swift | 0 .../CanvasSnapshotFormatTests.swift | 0 .../ChatMarkdownPreprocessorTests.swift | 0 .../MoltbotKitTests}/ChatThemeTests.swift | 0 .../MoltbotKitTests}/ChatViewModelTests.swift | 0 .../ElevenLabsTTSValidationTests.swift | 0 .../GatewayNodeSessionTests.swift | 0 .../JPEGTranscoderTests.swift | 0 .../MoltbotKitTests}/TalkDirectiveTests.swift | 0 .../TalkHistoryTimestampTests.swift | 0 .../TalkPromptBuilderTests.swift | 0 .../ToolDisplayRegistryTests.swift | 0 .../Tools/CanvasA2UI/bootstrap.js | 0 .../Tools/CanvasA2UI/rolldown.config.mjs | 0 374 files changed, 18903 deletions(-) delete mode 100644 apps/macos/Sources/Clawdbot/AgentWorkspace.swift delete mode 100644 apps/macos/Sources/Clawdbot/AnthropicOAuth.swift delete mode 100644 apps/macos/Sources/Clawdbot/AudioInputDeviceObserver.swift delete mode 100644 apps/macos/Sources/Clawdbot/CLIInstallPrompter.swift delete mode 100644 apps/macos/Sources/Clawdbot/CameraCaptureService.swift delete mode 100644 apps/macos/Sources/Clawdbot/CanvasFileWatcher.swift delete mode 100644 apps/macos/Sources/Clawdbot/CanvasManager.swift delete mode 100644 apps/macos/Sources/Clawdbot/CanvasSchemeHandler.swift delete mode 100644 apps/macos/Sources/Clawdbot/CanvasWindow.swift delete mode 100644 apps/macos/Sources/Clawdbot/ClawdbotConfigFile.swift delete mode 100644 apps/macos/Sources/Clawdbot/ConfigFileWatcher.swift delete mode 100644 apps/macos/Sources/Clawdbot/ConnectionModeCoordinator.swift delete mode 100644 apps/macos/Sources/Clawdbot/Constants.swift delete mode 100644 apps/macos/Sources/Clawdbot/ControlChannel.swift delete mode 100644 apps/macos/Sources/Clawdbot/CronJobsStore.swift delete mode 100644 apps/macos/Sources/Clawdbot/DeepLinks.swift delete mode 100644 apps/macos/Sources/Clawdbot/DevicePairingApprovalPrompter.swift delete mode 100644 apps/macos/Sources/Clawdbot/DockIconManager.swift delete mode 100644 apps/macos/Sources/Clawdbot/ExecApprovals.swift delete mode 100644 apps/macos/Sources/Clawdbot/ExecApprovalsGatewayPrompter.swift delete mode 100644 apps/macos/Sources/Clawdbot/ExecApprovalsSocket.swift delete mode 100644 apps/macos/Sources/Clawdbot/GatewayConnection.swift delete mode 100644 apps/macos/Sources/Clawdbot/GatewayConnectivityCoordinator.swift delete mode 100644 apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift delete mode 100644 apps/macos/Sources/Clawdbot/GatewayEnvironment.swift delete mode 100644 apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift delete mode 100644 apps/macos/Sources/Clawdbot/GatewayProcessManager.swift delete mode 100644 apps/macos/Sources/Clawdbot/HealthStore.swift delete mode 100644 apps/macos/Sources/Clawdbot/InstancesStore.swift delete mode 100644 apps/macos/Sources/Clawdbot/LaunchAgentManager.swift delete mode 100644 apps/macos/Sources/Clawdbot/Logging/ClawdbotLogging.swift delete mode 100644 apps/macos/Sources/Clawdbot/MenuBar.swift delete mode 100644 apps/macos/Sources/Clawdbot/MicLevelMonitor.swift delete mode 100644 apps/macos/Sources/Clawdbot/ModelCatalogLoader.swift delete mode 100644 apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift delete mode 100644 apps/macos/Sources/Clawdbot/NodePairingApprovalPrompter.swift delete mode 100644 apps/macos/Sources/Clawdbot/NodeServiceManager.swift delete mode 100644 apps/macos/Sources/Clawdbot/NodesStore.swift delete mode 100644 apps/macos/Sources/Clawdbot/NotificationManager.swift delete mode 100644 apps/macos/Sources/Clawdbot/OnboardingWizard.swift delete mode 100644 apps/macos/Sources/Clawdbot/PeekabooBridgeHostCoordinator.swift delete mode 100644 apps/macos/Sources/Clawdbot/PermissionManager.swift delete mode 100644 apps/macos/Sources/Clawdbot/PortGuardian.swift delete mode 100644 apps/macos/Sources/Clawdbot/PresenceReporter.swift delete mode 100644 apps/macos/Sources/Clawdbot/RemotePortTunnel.swift delete mode 100644 apps/macos/Sources/Clawdbot/RemoteTunnelManager.swift delete mode 100644 apps/macos/Sources/Clawdbot/Resources/Info.plist delete mode 100644 apps/macos/Sources/Clawdbot/RuntimeLocator.swift delete mode 100644 apps/macos/Sources/Clawdbot/ScreenRecordService.swift delete mode 100644 apps/macos/Sources/Clawdbot/SessionMenuPreviewView.swift delete mode 100644 apps/macos/Sources/Clawdbot/TailscaleService.swift delete mode 100644 apps/macos/Sources/Clawdbot/TalkAudioPlayer.swift delete mode 100644 apps/macos/Sources/Clawdbot/TalkModeController.swift delete mode 100644 apps/macos/Sources/Clawdbot/TalkModeRuntime.swift delete mode 100644 apps/macos/Sources/Clawdbot/TalkOverlay.swift delete mode 100644 apps/macos/Sources/Clawdbot/TerminationSignalWatcher.swift delete mode 100644 apps/macos/Sources/Clawdbot/VoicePushToTalk.swift delete mode 100644 apps/macos/Sources/Clawdbot/VoiceSessionCoordinator.swift delete mode 100644 apps/macos/Sources/Clawdbot/VoiceWakeChime.swift delete mode 100644 apps/macos/Sources/Clawdbot/VoiceWakeForwarder.swift delete mode 100644 apps/macos/Sources/Clawdbot/VoiceWakeGlobalSettingsSync.swift delete mode 100644 apps/macos/Sources/Clawdbot/VoiceWakeOverlay.swift delete mode 100644 apps/macos/Sources/Clawdbot/VoiceWakeRuntime.swift delete mode 100644 apps/macos/Sources/Clawdbot/VoiceWakeTester.swift delete mode 100644 apps/macos/Sources/Clawdbot/WebChatSwiftUI.swift delete mode 100644 apps/macos/Sources/ClawdbotDiscovery/GatewayDiscoveryModel.swift rename apps/macos/Sources/{Clawdbot => Moltbot}/AboutSettings.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/AgeFormatting.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/AgentEventStore.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/AgentEventsWindow.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/AnthropicAuthControls.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/AnthropicOAuthCodeState.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/AnyCodable+Helpers.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/AppState.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CLIInstaller.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CanvasA2UIActionMessageHandler.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CanvasChromeContainerView.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CanvasScheme.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CanvasWindowController+Helpers.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CanvasWindowController+Navigation.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CanvasWindowController+Testing.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CanvasWindowController+Window.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CanvasWindowController.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/ChannelConfigForm.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/ChannelsSettings+ChannelSections.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/ChannelsSettings+ChannelState.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/ChannelsSettings+Helpers.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/ChannelsSettings+View.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/ChannelsSettings.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/ChannelsStore+Config.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/ChannelsStore+Lifecycle.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/ChannelsStore.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/ClawdbotPaths.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CommandResolver.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/ConfigSchemaSupport.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/ConfigSettings.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/ConfigStore.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/ConnectionModeResolver.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/ContextMenuCardView.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/ContextUsageBar.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CostUsageMenuView.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CritterIconRenderer.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CritterStatusLabel+Behavior.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CritterStatusLabel.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CronJobEditor+Helpers.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CronJobEditor+Testing.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CronJobEditor.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CronModels.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CronSettings+Actions.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CronSettings+Helpers.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CronSettings+Layout.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CronSettings+Rows.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CronSettings+Testing.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/CronSettings.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/DebugActions.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/DebugSettings.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/DeviceModelCatalog.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/DiagnosticsFileLog.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/FileHandle+SafeRead.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/GatewayAutostartPolicy.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/GatewayDiscoveryHelpers.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/GatewayDiscoveryMenu.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/GatewayDiscoveryPreferences.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/GatewayRemoteConfig.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/GeneralSettings.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/HeartbeatStore.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/HoverHUD.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/IconState.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/InstancesSettings.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/Launchctl.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/LaunchdManager.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/LogLocator.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/MenuContentView.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/MenuContextCardInjector.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/MenuHighlightedHostView.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/MenuHostedItem.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/MenuSessionsHeaderView.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/MenuSessionsInjector.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/MenuUsageHeaderView.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/NSAttributedString+VoiceWake.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/NodeMode/MacNodeLocationService.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/NodeMode/MacNodeRuntime.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/NodeMode/MacNodeRuntimeMainActorServices.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/NodeMode/MacNodeScreenCommands.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/NodesMenu.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/NotifyOverlay.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/Onboarding.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/OnboardingView+Actions.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/OnboardingView+Chat.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/OnboardingView+Layout.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/OnboardingView+Monitoring.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/OnboardingView+Pages.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/OnboardingView+Testing.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/OnboardingView+Wizard.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/OnboardingView+Workspace.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/OnboardingWidgets.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/PermissionsSettings.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/PointingHandCursor.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/Process+PipeRead.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/ProcessInfo+Clawdbot.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/Resources/Clawdbot.icns (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/Resources/DeviceModels/LICENSE.apple-device-identifiers.txt (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/Resources/DeviceModels/NOTICE.md (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/Resources/DeviceModels/ios-device-identifiers.json (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/Resources/DeviceModels/mac-device-identifiers.json (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/ScreenshotSize.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/SessionActions.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/SessionData.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/SessionMenuLabelView.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/SessionsSettings.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/SettingsComponents.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/SettingsRootView.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/SettingsWindowOpener.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/ShellExecutor.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/SkillsModels.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/SkillsSettings.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/SoundEffects.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/StatusPill.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/String+NonEmpty.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/SystemRunSettingsView.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/TailscaleIntegrationSection.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/TalkModeTypes.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/TalkOverlayView.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/UsageCostData.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/UsageData.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/UsageMenuLabelView.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/ViewMetrics.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/VisualEffectView.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/VoiceWakeHelpers.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/VoiceWakeOverlayController+Session.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/VoiceWakeOverlayController+Testing.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/VoiceWakeOverlayController+Window.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/VoiceWakeOverlayTextViews.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/VoiceWakeOverlayView.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/VoiceWakeSettings.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/VoiceWakeTestCard.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/VoiceWakeTextUtils.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/WebChatManager.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/WindowPlacement.swift (100%) rename apps/macos/Sources/{Clawdbot => Moltbot}/WorkActivityStore.swift (100%) rename apps/macos/Sources/{ClawdbotDiscovery => MoltbotDiscovery}/WideAreaGatewayDiscovery.swift (100%) rename apps/macos/Sources/{ClawdbotIPC => MoltbotIPC}/IPC.swift (100%) rename apps/macos/Sources/{ClawdbotMacCLI => MoltbotMacCLI}/ConnectCommand.swift (100%) rename apps/macos/Sources/{ClawdbotMacCLI => MoltbotMacCLI}/DiscoverCommand.swift (100%) rename apps/macos/Sources/{ClawdbotMacCLI => MoltbotMacCLI}/EntryPoint.swift (100%) rename apps/macos/Sources/{ClawdbotMacCLI => MoltbotMacCLI}/GatewayConfig.swift (100%) rename apps/macos/Sources/{ClawdbotMacCLI => MoltbotMacCLI}/TypeAliases.swift (100%) rename apps/macos/Sources/{ClawdbotMacCLI => MoltbotMacCLI}/WizardCommand.swift (100%) rename apps/macos/Sources/{ClawdbotProtocol => MoltbotProtocol}/GatewayModels.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/AgentEventStoreTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/AgentWorkspaceTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/AnthropicAuthControlsSmokeTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/AnthropicAuthResolverTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/AnthropicOAuthCodeStateTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/AnyCodableEncodingTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/CLIInstallerTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/CameraCaptureServiceTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/CameraIPCTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/CanvasFileWatcherTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/CanvasIPCTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/CanvasWindowSmokeTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/ChannelsSettingsSmokeTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/ClawdbotConfigFileTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/ClawdbotOAuthStoreTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/CommandResolverTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/ConfigStoreTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/CoverageDumpTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/CritterIconRendererTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/CronJobEditorSmokeTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/CronModelsTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/DeviceModelCatalogTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/ExecAllowlistTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/ExecApprovalHelpersTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/ExecApprovalsGatewayPrompterTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/FileHandleLegacyAPIGuardTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/FileHandleSafeReadTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/GatewayAgentChannelTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/GatewayAutostartPolicyTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/GatewayChannelConfigureTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/GatewayChannelConnectTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/GatewayChannelRequestTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/GatewayChannelShutdownTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/GatewayConnectionControlTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/GatewayDiscoveryModelTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/GatewayEndpointStoreTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/GatewayEnvironmentTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/GatewayFrameDecodeTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/GatewayLaunchAgentManagerTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/GatewayProcessManagerTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/HealthDecodeTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/HealthStoreStateTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/HoverHUDControllerTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/InstancesSettingsSmokeTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/InstancesStoreTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/LogLocatorTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/LowCoverageHelperTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/LowCoverageViewSmokeTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/MacGatewayChatTransportMappingTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/MacNodeRuntimeTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/MasterDiscoveryMenuSmokeTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/MenuContentSmokeTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/MenuSessionsInjectorTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/ModelCatalogLoaderTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/NodeManagerPathsTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/NodePairingApprovalPrompterTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/NodePairingReconcilePolicyTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/OnboardingCoverageTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/OnboardingViewSmokeTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/OnboardingWizardStepViewTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/PermissionManagerLocationTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/PermissionManagerTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/Placeholder.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/RemotePortTunnelTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/RuntimeLocatorTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/ScreenshotSizeTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/SemverTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/SessionDataTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/SessionMenuPreviewTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/SettingsViewSmokeTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/SkillsSettingsSmokeTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/TailscaleIntegrationSectionTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/TalkAudioPlayerTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/TestIsolation.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/UtilitiesTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/VoicePushToTalkHotkeyTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/VoicePushToTalkTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/VoiceWakeForwarderTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/VoiceWakeGlobalSettingsSyncTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/VoiceWakeHelpersTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/VoiceWakeOverlayControllerTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/VoiceWakeOverlayTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/VoiceWakeOverlayViewSmokeTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/VoiceWakeRuntimeTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/VoiceWakeTesterTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/WebChatMainSessionKeyTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/WebChatManagerTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/WebChatSwiftUISmokeTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/WideAreaGatewayDiscoveryTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/WindowPlacementTests.swift (100%) rename apps/macos/Tests/{ClawdbotIPCTests => MoltbotIPCTests}/WorkActivityStoreTests.swift (100%) delete mode 100644 apps/shared/ClawdbotKit/Package.swift rename apps/shared/{ClawdbotKit/Sources/ClawdbotChatUI => MoltbotKit/Sources/MoltbotChatUI}/AssistantTextParser.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotChatUI => MoltbotKit/Sources/MoltbotChatUI}/ChatComposer.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotChatUI => MoltbotKit/Sources/MoltbotChatUI}/ChatMarkdownPreprocessor.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotChatUI => MoltbotKit/Sources/MoltbotChatUI}/ChatMarkdownRenderer.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotChatUI => MoltbotKit/Sources/MoltbotChatUI}/ChatMessageViews.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotChatUI => MoltbotKit/Sources/MoltbotChatUI}/ChatModels.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotChatUI => MoltbotKit/Sources/MoltbotChatUI}/ChatPayloadDecoding.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotChatUI => MoltbotKit/Sources/MoltbotChatUI}/ChatSessions.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotChatUI => MoltbotKit/Sources/MoltbotChatUI}/ChatSheets.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotChatUI => MoltbotKit/Sources/MoltbotChatUI}/ChatTheme.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotChatUI => MoltbotKit/Sources/MoltbotChatUI}/ChatTransport.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotChatUI => MoltbotKit/Sources/MoltbotChatUI}/ChatView.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotChatUI => MoltbotKit/Sources/MoltbotChatUI}/ChatViewModel.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/AnyCodable.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/AsyncTimeout.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/AudioStreamingProtocols.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/BonjourEscapes.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/BonjourTypes.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/BridgeFrames.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/CameraCommands.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/CanvasA2UIAction.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/CanvasA2UICommands.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/CanvasA2UIJSONL.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/CanvasCommandParams.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/CanvasCommands.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/Capabilities.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/ClawdbotKitResources.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/DeepLinks.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/DeviceAuthStore.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/DeviceIdentity.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/ElevenLabsKitShim.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/GatewayChannel.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/GatewayEndpointID.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/GatewayErrors.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/GatewayNodeSession.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/GatewayPayloadDecoding.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/GatewayPush.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/GatewayTLSPinning.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/InstanceIdentity.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/JPEGTranscoder.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/LocationCommands.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/LocationSettings.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/NodeError.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/Resources/CanvasScaffold/scaffold.html (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/Resources/tool-display.json (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/ScreenCommands.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/StoragePaths.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/SystemCommands.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/TalkDirective.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/TalkHistoryTimestamp.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/TalkPromptBuilder.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/TalkSystemSpeechSynthesizer.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotKit => MoltbotKit/Sources/MoltbotKit}/ToolDisplay.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotProtocol => MoltbotKit/Sources/MoltbotProtocol}/AnyCodable.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotProtocol => MoltbotKit/Sources/MoltbotProtocol}/GatewayModels.swift (100%) rename apps/shared/{ClawdbotKit/Sources/ClawdbotProtocol => MoltbotKit/Sources/MoltbotProtocol}/WizardHelpers.swift (100%) rename apps/shared/{ClawdbotKit/Tests/ClawdbotKitTests => MoltbotKit/Tests/MoltbotKitTests}/AssistantTextParserTests.swift (100%) rename apps/shared/{ClawdbotKit/Tests/ClawdbotKitTests => MoltbotKit/Tests/MoltbotKitTests}/BonjourEscapesTests.swift (100%) rename apps/shared/{ClawdbotKit/Tests/ClawdbotKitTests => MoltbotKit/Tests/MoltbotKitTests}/CanvasA2UIActionTests.swift (100%) rename apps/shared/{ClawdbotKit/Tests/ClawdbotKitTests => MoltbotKit/Tests/MoltbotKitTests}/CanvasA2UITests.swift (100%) rename apps/shared/{ClawdbotKit/Tests/ClawdbotKitTests => MoltbotKit/Tests/MoltbotKitTests}/CanvasSnapshotFormatTests.swift (100%) rename apps/shared/{ClawdbotKit/Tests/ClawdbotKitTests => MoltbotKit/Tests/MoltbotKitTests}/ChatMarkdownPreprocessorTests.swift (100%) rename apps/shared/{ClawdbotKit/Tests/ClawdbotKitTests => MoltbotKit/Tests/MoltbotKitTests}/ChatThemeTests.swift (100%) rename apps/shared/{ClawdbotKit/Tests/ClawdbotKitTests => MoltbotKit/Tests/MoltbotKitTests}/ChatViewModelTests.swift (100%) rename apps/shared/{ClawdbotKit/Tests/ClawdbotKitTests => MoltbotKit/Tests/MoltbotKitTests}/ElevenLabsTTSValidationTests.swift (100%) rename apps/shared/{ClawdbotKit/Tests/ClawdbotKitTests => MoltbotKit/Tests/MoltbotKitTests}/GatewayNodeSessionTests.swift (100%) rename apps/shared/{ClawdbotKit/Tests/ClawdbotKitTests => MoltbotKit/Tests/MoltbotKitTests}/JPEGTranscoderTests.swift (100%) rename apps/shared/{ClawdbotKit/Tests/ClawdbotKitTests => MoltbotKit/Tests/MoltbotKitTests}/TalkDirectiveTests.swift (100%) rename apps/shared/{ClawdbotKit/Tests/ClawdbotKitTests => MoltbotKit/Tests/MoltbotKitTests}/TalkHistoryTimestampTests.swift (100%) rename apps/shared/{ClawdbotKit/Tests/ClawdbotKitTests => MoltbotKit/Tests/MoltbotKitTests}/TalkPromptBuilderTests.swift (100%) rename apps/shared/{ClawdbotKit/Tests/ClawdbotKitTests => MoltbotKit/Tests/MoltbotKitTests}/ToolDisplayRegistryTests.swift (100%) rename apps/shared/{ClawdbotKit => MoltbotKit}/Tools/CanvasA2UI/bootstrap.js (100%) rename apps/shared/{ClawdbotKit => MoltbotKit}/Tools/CanvasA2UI/rolldown.config.mjs (100%) 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 From d33cd4506158b8294c69ed382ac189f91678a130 Mon Sep 17 00:00:00 2001 From: Alex Fallah Date: Tue, 27 Jan 2026 11:04:39 -0500 Subject: [PATCH 3/8] fix(macOS): rename Clawdbot directories to Moltbot for naming consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Directory renames: - apps/macos/Sources/Clawdbot → Moltbot - apps/macos/Sources/ClawdbotDiscovery → MoltbotDiscovery - apps/macos/Sources/ClawdbotIPC → MoltbotIPC - apps/macos/Sources/ClawdbotMacCLI → MoltbotMacCLI - apps/macos/Sources/ClawdbotProtocol → MoltbotProtocol - apps/macos/Tests/ClawdbotIPCTests → MoltbotIPCTests - apps/shared/ClawdbotKit → MoltbotKit - apps/shared/MoltbotKit/Sources/Clawdbot* → Moltbot* - apps/shared/MoltbotKit/Tests/ClawdbotKitTests → MoltbotKitTests Resource renames: - Clawdbot.icns → Moltbot.icns Code fixes: - Update Package.swift paths to reference Moltbot* directories - Fix clawdbot* → moltbot* symbol references in Swift code: - clawdbotManagedPaths → moltbotManagedPaths - clawdbotExecutable → moltbotExecutable - clawdbotCommand → moltbotCommand - clawdbotNodeCommand → moltbotNodeCommand - clawdbotOAuthDirEnv → moltbotOAuthDirEnv - clawdbotSelectSettingsTab → moltbotSelectSettingsTab --- apps/macos/Sources/Moltbot/AnthropicOAuth.swift | 2 +- apps/macos/Sources/Moltbot/CLIInstallPrompter.swift | 2 +- apps/macos/Sources/Moltbot/CommandResolver.swift | 8 ++++---- apps/macos/Sources/Moltbot/GatewayEnvironment.swift | 4 ++-- .../Sources/Moltbot/GatewayLaunchAgentManager.swift | 2 +- apps/macos/Sources/Moltbot/MenuContentView.swift | 2 +- apps/macos/Sources/Moltbot/NodeServiceManager.swift | 2 +- .../Sources/Moltbot/OnboardingView+Actions.swift | 2 +- .../Resources/{Clawdbot.icns => Moltbot.icns} | Bin apps/macos/Sources/Moltbot/SettingsRootView.swift | 2 +- .../MoltbotIPCTests/CommandResolverTests.swift | 12 ++++++------ 11 files changed, 19 insertions(+), 19 deletions(-) rename apps/macos/Sources/Moltbot/Resources/{Clawdbot.icns => Moltbot.icns} (100%) diff --git a/apps/macos/Sources/Moltbot/AnthropicOAuth.swift b/apps/macos/Sources/Moltbot/AnthropicOAuth.swift index 447ad5bb3..a13275d7a 100644 --- a/apps/macos/Sources/Moltbot/AnthropicOAuth.swift +++ b/apps/macos/Sources/Moltbot/AnthropicOAuth.swift @@ -226,7 +226,7 @@ enum MoltbotOAuthStore { } static func oauthDir() -> URL { - if let override = ProcessInfo.processInfo.environment[self.clawdbotOAuthDirEnv]? + if let override = ProcessInfo.processInfo.environment[self.moltbotOAuthDirEnv]? .trimmingCharacters(in: .whitespacesAndNewlines), !override.isEmpty { diff --git a/apps/macos/Sources/Moltbot/CLIInstallPrompter.swift b/apps/macos/Sources/Moltbot/CLIInstallPrompter.swift index b091fc8b5..80cd695fd 100644 --- a/apps/macos/Sources/Moltbot/CLIInstallPrompter.swift +++ b/apps/macos/Sources/Moltbot/CLIInstallPrompter.swift @@ -62,7 +62,7 @@ final class CLIInstallPrompter { SettingsTabRouter.request(tab) SettingsWindowOpener.shared.open() DispatchQueue.main.async { - NotificationCenter.default.post(name: .clawdbotSelectSettingsTab, object: tab) + NotificationCenter.default.post(name: .moltbotSelectSettingsTab, object: tab) } } diff --git a/apps/macos/Sources/Moltbot/CommandResolver.swift b/apps/macos/Sources/Moltbot/CommandResolver.swift index 99a738541..427accbbf 100644 --- a/apps/macos/Sources/Moltbot/CommandResolver.swift +++ b/apps/macos/Sources/Moltbot/CommandResolver.swift @@ -87,7 +87,7 @@ enum CommandResolver { // Dev-only convenience. Avoid project-local PATH hijacking in release builds. extras.insert(projectRoot.appendingPathComponent("node_modules/.bin").path, at: 0) #endif - let moltbotPaths = self.clawdbotManagedPaths(home: home) + let moltbotPaths = self.moltbotManagedPaths(home: home) if !moltbotPaths.isEmpty { extras.insert(contentsOf: moltbotPaths, at: 1) } @@ -207,7 +207,7 @@ enum CommandResolver { } static func hasAnyMoltbotInvoker(searchPaths: [String]? = nil) -> Bool { - if self.clawdbotExecutable(searchPaths: searchPaths) != nil { return true } + if self.moltbotExecutable(searchPaths: searchPaths) != nil { return true } if self.findExecutable(named: "pnpm", searchPaths: searchPaths) != nil { return true } if self.findExecutable(named: "node", searchPaths: searchPaths) != nil, self.nodeCliPath() != nil @@ -253,7 +253,7 @@ enum CommandResolver { // Use --silent to avoid pnpm lifecycle banners that would corrupt JSON outputs. return [pnpm, "--silent", "moltbot", subcommand] + extraArgs } - if let moltbotPath = self.clawdbotExecutable(searchPaths: searchPaths) { + if let moltbotPath = self.moltbotExecutable(searchPaths: searchPaths) { return [moltbotPath, subcommand] + extraArgs } @@ -275,7 +275,7 @@ enum CommandResolver { configRoot: [String: Any]? = nil, searchPaths: [String]? = nil) -> [String] { - self.clawdbotNodeCommand( + self.moltbotNodeCommand( subcommand: subcommand, extraArgs: extraArgs, defaults: defaults, diff --git a/apps/macos/Sources/Moltbot/GatewayEnvironment.swift b/apps/macos/Sources/Moltbot/GatewayEnvironment.swift index 2689d8604..0dbcf9780 100644 --- a/apps/macos/Sources/Moltbot/GatewayEnvironment.swift +++ b/apps/macos/Sources/Moltbot/GatewayEnvironment.swift @@ -123,7 +123,7 @@ enum GatewayEnvironment { requiredGateway: expectedString, message: RuntimeLocator.describeFailure(err)) case let .success(runtime): - let gatewayBin = CommandResolver.clawdbotExecutable() + let gatewayBin = CommandResolver.moltbotExecutable() if gatewayBin == nil, projectEntrypoint == nil { return GatewayEnvironmentStatus( @@ -181,7 +181,7 @@ enum GatewayEnvironment { let projectRoot = CommandResolver.projectRoot() let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot) let status = self.check() - let gatewayBin = CommandResolver.clawdbotExecutable() + let gatewayBin = CommandResolver.moltbotExecutable() let runtime = RuntimeLocator.resolve(searchPaths: CommandResolver.preferredPaths()) guard case .ok = status.kind else { diff --git a/apps/macos/Sources/Moltbot/GatewayLaunchAgentManager.swift b/apps/macos/Sources/Moltbot/GatewayLaunchAgentManager.swift index 70c5a5eec..cc78b7e10 100644 --- a/apps/macos/Sources/Moltbot/GatewayLaunchAgentManager.swift +++ b/apps/macos/Sources/Moltbot/GatewayLaunchAgentManager.swift @@ -143,7 +143,7 @@ extension GatewayLaunchAgentManager { timeout: Double, quiet: Bool) async -> CommandResult { - let command = CommandResolver.clawdbotCommand( + let command = CommandResolver.moltbotCommand( subcommand: "gateway", extraArgs: self.withJsonFlag(args), // Launchd management must always run locally, even if remote mode is configured. diff --git a/apps/macos/Sources/Moltbot/MenuContentView.swift b/apps/macos/Sources/Moltbot/MenuContentView.swift index 3f22eda63..118b78dc7 100644 --- a/apps/macos/Sources/Moltbot/MenuContentView.swift +++ b/apps/macos/Sources/Moltbot/MenuContentView.swift @@ -329,7 +329,7 @@ struct MenuContent: View { NSApp.activate(ignoringOtherApps: true) self.openSettings() DispatchQueue.main.async { - NotificationCenter.default.post(name: .clawdbotSelectSettingsTab, object: tab) + NotificationCenter.default.post(name: .moltbotSelectSettingsTab, object: tab) } } diff --git a/apps/macos/Sources/Moltbot/NodeServiceManager.swift b/apps/macos/Sources/Moltbot/NodeServiceManager.swift index bceba7c39..bcf17d972 100644 --- a/apps/macos/Sources/Moltbot/NodeServiceManager.swift +++ b/apps/macos/Sources/Moltbot/NodeServiceManager.swift @@ -52,7 +52,7 @@ extension NodeServiceManager { timeout: Double, quiet: Bool) async -> CommandResult { - let command = CommandResolver.clawdbotCommand( + let command = CommandResolver.moltbotCommand( subcommand: "service", extraArgs: self.withJsonFlag(args), // Service management must always run locally, even if remote mode is configured. diff --git a/apps/macos/Sources/Moltbot/OnboardingView+Actions.swift b/apps/macos/Sources/Moltbot/OnboardingView+Actions.swift index 80dadcf94..79e7d4d48 100644 --- a/apps/macos/Sources/Moltbot/OnboardingView+Actions.swift +++ b/apps/macos/Sources/Moltbot/OnboardingView+Actions.swift @@ -47,7 +47,7 @@ extension OnboardingView { SettingsTabRouter.request(tab) self.openSettings() DispatchQueue.main.async { - NotificationCenter.default.post(name: .clawdbotSelectSettingsTab, object: tab) + NotificationCenter.default.post(name: .moltbotSelectSettingsTab, object: tab) } } diff --git a/apps/macos/Sources/Moltbot/Resources/Clawdbot.icns b/apps/macos/Sources/Moltbot/Resources/Moltbot.icns similarity index 100% rename from apps/macos/Sources/Moltbot/Resources/Clawdbot.icns rename to apps/macos/Sources/Moltbot/Resources/Moltbot.icns diff --git a/apps/macos/Sources/Moltbot/SettingsRootView.swift b/apps/macos/Sources/Moltbot/SettingsRootView.swift index 004f15827..97520a31b 100644 --- a/apps/macos/Sources/Moltbot/SettingsRootView.swift +++ b/apps/macos/Sources/Moltbot/SettingsRootView.swift @@ -77,7 +77,7 @@ struct SettingsRootView: View { .padding(.vertical, 22) .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight, alignment: .topLeading) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .onReceive(NotificationCenter.default.publisher(for: .clawdbotSelectSettingsTab)) { note in + .onReceive(NotificationCenter.default.publisher(for: .moltbotSelectSettingsTab)) { note in if let tab = note.object as? SettingsTab { withAnimation(.spring(response: 0.32, dampingFraction: 0.85)) { self.selectedTab = tab diff --git a/apps/macos/Tests/MoltbotIPCTests/CommandResolverTests.swift b/apps/macos/Tests/MoltbotIPCTests/CommandResolverTests.swift index 8bc84e51f..d6abe45af 100644 --- a/apps/macos/Tests/MoltbotIPCTests/CommandResolverTests.swift +++ b/apps/macos/Tests/MoltbotIPCTests/CommandResolverTests.swift @@ -34,7 +34,7 @@ import Testing let moltbotPath = tmp.appendingPathComponent("node_modules/.bin/moltbot") try self.makeExec(at: moltbotPath) - let cmd = CommandResolver.clawdbotCommand(subcommand: "gateway", defaults: defaults, configRoot: [:]) + let cmd = CommandResolver.moltbotCommand(subcommand: "gateway", defaults: defaults, configRoot: [:]) #expect(cmd.prefix(2).elementsEqual([moltbotPath.path, "gateway"])) } @@ -52,7 +52,7 @@ import Testing try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path) try self.makeExec(at: scriptPath) - let cmd = CommandResolver.clawdbotCommand( + let cmd = CommandResolver.moltbotCommand( subcommand: "rpc", defaults: defaults, configRoot: [:], @@ -76,7 +76,7 @@ import Testing let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm") try self.makeExec(at: pnpmPath) - let cmd = CommandResolver.clawdbotCommand(subcommand: "rpc", defaults: defaults, configRoot: [:]) + let cmd = CommandResolver.moltbotCommand(subcommand: "rpc", defaults: defaults, configRoot: [:]) #expect(cmd.prefix(4).elementsEqual([pnpmPath.path, "--silent", "moltbot", "rpc"])) } @@ -91,7 +91,7 @@ import Testing let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm") try self.makeExec(at: pnpmPath) - let cmd = CommandResolver.clawdbotCommand( + let cmd = CommandResolver.moltbotCommand( subcommand: "health", extraArgs: ["--json", "--timeout", "5"], defaults: defaults, @@ -116,7 +116,7 @@ import Testing defaults.set("/tmp/id_ed25519", forKey: remoteIdentityKey) defaults.set("/srv/moltbot", forKey: remoteProjectRootKey) - let cmd = CommandResolver.clawdbotCommand( + let cmd = CommandResolver.moltbotCommand( subcommand: "status", extraArgs: ["--json"], defaults: defaults, @@ -157,7 +157,7 @@ import Testing let moltbotPath = tmp.appendingPathComponent("node_modules/.bin/moltbot") try self.makeExec(at: moltbotPath) - let cmd = CommandResolver.clawdbotCommand( + let cmd = CommandResolver.moltbotCommand( subcommand: "daemon", defaults: defaults, configRoot: ["gateway": ["mode": "local"]]) From 289440256b326ce8699ca769324894e03cd0b632 Mon Sep 17 00:00:00 2001 From: Alex Fallah Date: Tue, 27 Jan 2026 11:23:02 -0500 Subject: [PATCH 4/8] fix: update remaining ClawdbotKit path references to MoltbotKit - scripts/bundle-a2ui.sh: A2UI_APP_DIR path - package.json: format:swift and protocol:check paths - scripts/protocol-gen-swift.ts: output paths - .github/dependabot.yml: directory path and comment - .gitignore: build cache paths - .swiftformat: exclusion paths - .swiftlint.yml: exclusion path - apps/android/app/build.gradle.kts: assets.srcDir path - apps/ios/project.yml: package path - apps/ios/README.md: documentation reference - docs/concepts/typebox.md: documentation reference - apps/shared/MoltbotKit/Package.swift: fix argument order --- .github/dependabot.yml | 4 ++-- .gitignore | 4 ++-- .swiftformat | 2 +- .swiftlint.yml | 2 +- apps/shared/MoltbotKit/Package.swift | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c0e1d465b..829604b4c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -64,9 +64,9 @@ updates: - patch open-pull-requests-limit: 5 - # Swift Package Manager - shared ClawdbotKit + # Swift Package Manager - shared MoltbotKit - package-ecosystem: swift - directory: /apps/shared/ClawdbotKit + directory: /apps/shared/MoltbotKit schedule: interval: weekly cooldown: diff --git a/.gitignore b/.gitignore index d3fdee6b5..9dc547c9c 100644 --- a/.gitignore +++ b/.gitignore @@ -19,14 +19,14 @@ ui/test-results/ # Bun build artifacts *.bun-build apps/macos/.build/ -apps/shared/ClawdbotKit/.build/ +apps/shared/MoltbotKit/.build/ **/ModuleCache/ bin/ bin/clawdbot-mac bin/docs-list apps/macos/.build-local/ apps/macos/.swiftpm/ -apps/shared/ClawdbotKit/.swiftpm/ +apps/shared/MoltbotKit/.swiftpm/ Core/ apps/ios/*.xcodeproj/ apps/ios/*.xcworkspace/ diff --git a/.swiftformat b/.swiftformat index 6622d0b01..fd8c0e631 100644 --- a/.swiftformat +++ b/.swiftformat @@ -48,4 +48,4 @@ --allman false # Exclusions ---exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,Peekaboo,Swabble,apps/android,apps/ios,apps/shared,apps/macos/Sources/ClawdisProtocol,apps/macos/Sources/ClawdbotProtocol +--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,Peekaboo,Swabble,apps/android,apps/ios,apps/shared,apps/macos/Sources/MoltbotProtocol diff --git a/.swiftlint.yml b/.swiftlint.yml index 12500f4c7..b56228801 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -18,7 +18,7 @@ excluded: - coverage - "*.playground" # Generated (protocol-gen-swift.ts) - - apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift + - apps/macos/Sources/MoltbotProtocol/GatewayModels.swift analyzer_rules: - unused_declaration diff --git a/apps/shared/MoltbotKit/Package.swift b/apps/shared/MoltbotKit/Package.swift index b821755a6..78ced7f0b 100644 --- a/apps/shared/MoltbotKit/Package.swift +++ b/apps/shared/MoltbotKit/Package.swift @@ -26,11 +26,11 @@ let package = Package( ]), .target( name: "MoltbotKit", - path: "Sources/MoltbotKit", dependencies: [ "MoltbotProtocol", .product(name: "ElevenLabsKit", package: "ElevenLabsKit"), ], + path: "Sources/MoltbotKit", resources: [ .process("Resources"), ], @@ -39,7 +39,6 @@ let package = Package( ]), .target( name: "MoltbotChatUI", - path: "Sources/MoltbotChatUI", dependencies: [ "MoltbotKit", .product( @@ -47,6 +46,7 @@ let package = Package( package: "textual", condition: .when(platforms: [.macOS, .iOS])), ], + path: "Sources/MoltbotChatUI", swiftSettings: [ .enableUpcomingFeature("StrictConcurrency"), ]), From cf5ed4b5a41d6d1c434de19297d6fa8df04c510a Mon Sep 17 00:00:00 2001 From: Alex Fallah Date: Tue, 27 Jan 2026 11:23:11 -0500 Subject: [PATCH 5/8] chore: update Package.resolved after dependency resolution --- apps/macos/Package.resolved | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/macos/Package.resolved b/apps/macos/Package.resolved index ef9609649..302a4c78a 100644 --- a/apps/macos/Package.resolved +++ b/apps/macos/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "f847d54db16b371dbb1a79271d50436cdec572179b0f0cf14cfe1b75df8dfbc2", + "originHash" : "c86f22da7772193c6f161fc9db81747cc00c8b8c96b45f9479de1e65c2c4b17e", "pins" : [ { "identity" : "axorcist", @@ -24,7 +24,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/steipete/ElevenLabsKit", "state" : { - "revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d", + "revision" : "7e3c948d8340abe3977014f3de020edf221e9269", "version" : "0.1.0" } }, @@ -78,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "bc386b95f2a16ccd0150a8235e7c69eab2b866ca", - "version" : "1.8.0" + "revision" : "2778fd4e5a12a8aaa30a3ee8285f4ce54c5f3181", + "version" : "1.9.1" } }, { @@ -96,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-subprocess.git", "state" : { - "revision" : "44922dfe46380cd354ca4b0208e717a3e92b13dd", - "version" : "0.2.1" + "revision" : "ba5888ad7758cbcbe7abebac37860b1652af2d9c", + "version" : "0.3.0" } }, { @@ -105,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-system", "state" : { - "revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db", - "version" : "1.6.3" + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" } }, { From 4a3102117b7a356e7d289d63722b655f16167851 Mon Sep 17 00:00:00 2001 From: Alex Fallah Date: Tue, 27 Jan 2026 11:38:27 -0500 Subject: [PATCH 6/8] fix: add MACOS_APP_SOURCES_DIR constant and update test to use new path The cron-protocol-conformance test was using LEGACY_MACOS_APP_SOURCES_DIR which points to the old Clawdbot path. Added a new MACOS_APP_SOURCES_DIR constant for the current Moltbot path and updated the test to use it. --- src/compat/legacy-names.ts | 2 ++ src/cron/cron-protocol-conformance.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/compat/legacy-names.ts b/src/compat/legacy-names.ts index f2ff6993d..e57b6b688 100644 --- a/src/compat/legacy-names.ts +++ b/src/compat/legacy-names.ts @@ -7,3 +7,5 @@ export const LEGACY_PLUGIN_MANIFEST_FILENAME = `${LEGACY_PROJECT_NAME}.plugin.js export const LEGACY_CANVAS_HANDLER_NAME = `${LEGACY_PROJECT_NAME}CanvasA2UIAction` as const; export const LEGACY_MACOS_APP_SOURCES_DIR = "apps/macos/Sources/Clawdbot" as const; + +export const MACOS_APP_SOURCES_DIR = "apps/macos/Sources/Moltbot" as const; diff --git a/src/cron/cron-protocol-conformance.test.ts b/src/cron/cron-protocol-conformance.test.ts index 9cdf07c31..3da74c874 100644 --- a/src/cron/cron-protocol-conformance.test.ts +++ b/src/cron/cron-protocol-conformance.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { LEGACY_MACOS_APP_SOURCES_DIR } from "../compat/legacy-names.js"; +import { MACOS_APP_SOURCES_DIR } from "../compat/legacy-names.js"; import { CronPayloadSchema } from "../gateway/protocol/schema.js"; type SchemaLike = { @@ -30,7 +30,7 @@ function extractCronChannels(schema: SchemaLike): string[] { const UI_FILES = ["ui/src/ui/types.ts", "ui/src/ui/ui-types.ts", "ui/src/ui/views/cron.ts"]; -const SWIFT_FILES = [`${LEGACY_MACOS_APP_SOURCES_DIR}/GatewayConnection.swift`]; +const SWIFT_FILES = [`${MACOS_APP_SOURCES_DIR}/GatewayConnection.swift`]; describe("cron protocol conformance", () => { it("ui + swift include all cron providers from gateway schema", async () => { From e6186ee3dba495e5be9f16f549b0aefeac1705b0 Mon Sep 17 00:00:00 2001 From: Shadow Date: Tue, 27 Jan 2026 14:16:24 -0600 Subject: [PATCH 7/8] fix: finish Moltbot macOS rename (#2844) (thanks @fal3) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce4114902..cc26b0796 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Status: unreleased. - Rebrand: rename the npm package/CLI to `moltbot`, add a `moltbot` compatibility shim, and move extensions to the `@moltbot/*` scope. - Commands: group /help and /commands output with Telegram paging. (#2504) Thanks @hougangdev. - macOS: limit project-local `node_modules/.bin` PATH preference to debug builds (reduce PATH hijacking risk). +- macOS: finish Moltbot app rename for macOS sources, bundle identifiers, and shared kit paths. (#2844) Thanks @fal3. - Tools: add per-sender group tool policies and fix precedence. (#1757) Thanks @adam91holt. - Agents: summarize dropped messages during compaction safeguard pruning. (#2509) Thanks @jogi47. - Skills: add multi-image input support to Nano Banana Pro skill. (#1958) Thanks @tyler6204. From 9883d5d8974cecbe585df80b96ee042d4f4a1419 Mon Sep 17 00:00:00 2001 From: Josh Palmer Date: Tue, 27 Jan 2026 21:19:14 +0100 Subject: [PATCH 8/8] Extensions: use workspace moltbot in memory-core --- extensions/memory-core/package.json | 3 +++ pnpm-lock.yaml | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/extensions/memory-core/package.json b/extensions/memory-core/package.json index fc2bb36e1..7c4ae5b01 100644 --- a/extensions/memory-core/package.json +++ b/extensions/memory-core/package.json @@ -10,5 +10,8 @@ }, "peerDependencies": { "moltbot": ">=2026.1.26" + }, + "devDependencies": { + "moltbot": "workspace:*" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f35c2612..9c0f99928 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -355,9 +355,9 @@ importers: extensions/mattermost: {} extensions/memory-core: - dependencies: + devDependencies: moltbot: - specifier: '>=2026.1.26' + specifier: workspace:* version: link:../.. extensions/memory-lancedb: