diff --git a/CHANGELOG.md b/CHANGELOG.md index 62c9357ac..8f8317f04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixes - macOS: Voice Wake now fully tears down the Speech pipeline when disabled (cancel pending restarts, drop stale callbacks) to avoid high CPU in the background. - macOS menu: add a Talk Mode action alongside the Open Dashboard/Chat/Canvas entries. +- macOS Debug: hide “Restart Gateway” when the app won’t start a local gateway (remote mode / attach-only). - macOS Talk Mode: orb overlay refresh, ElevenLabs request logging, API key status in settings, and auto-select first voice when none is configured. - iOS/Android nodes: enable scrolling for loaded web pages in the Canvas WebView (default scaffold stays touch-first). - macOS menu: device list now uses `node.list` (devices only; no agent/tool presence entries). diff --git a/apps/macos/Sources/Clawdis/CommandResolver.swift b/apps/macos/Sources/Clawdis/CommandResolver.swift index d418cd5e0..2e8b5c6ce 100644 --- a/apps/macos/Sources/Clawdis/CommandResolver.swift +++ b/apps/macos/Sources/Clawdis/CommandResolver.swift @@ -16,6 +16,10 @@ enum CommandResolver { RuntimeLocator.resolve(searchPaths: self.preferredPaths()) } + static func runtimeResolution(searchPaths: [String]?) -> Result { + RuntimeLocator.resolve(searchPaths: searchPaths ?? self.preferredPaths()) + } + static func makeRuntimeCommand( runtime: RuntimeResolution, entrypoint: String, @@ -152,8 +156,8 @@ enum CommandResolver { return paths } - static func findExecutable(named name: String) -> String? { - for dir in self.preferredPaths() { + static func findExecutable(named name: String, searchPaths: [String]? = nil) -> String? { + for dir in (searchPaths ?? self.preferredPaths()) { let candidate = (dir as NSString).appendingPathComponent(name) if FileManager.default.isExecutableFile(atPath: candidate) { return candidate @@ -162,8 +166,14 @@ enum CommandResolver { return nil } - static func clawdisExecutable() -> String? { - self.findExecutable(named: self.helperName) + static func clawdisExecutable(searchPaths: [String]? = nil) -> String? { + self.findExecutable(named: self.helperName, searchPaths: searchPaths) + } + + static func projectClawdisExecutable(projectRoot: URL? = nil) -> String? { + let root = projectRoot ?? self.projectRoot() + let candidate = root.appendingPathComponent("node_modules/.bin").appendingPathComponent(self.helperName).path + return FileManager.default.isExecutableFile(atPath: candidate) ? candidate : nil } static func nodeCliPath() -> String? { @@ -171,17 +181,18 @@ enum CommandResolver { return FileManager.default.isReadableFile(atPath: candidate) ? candidate : nil } - static func hasAnyClawdisInvoker() -> Bool { - if self.clawdisExecutable() != nil { return true } - if self.findExecutable(named: "pnpm") != nil { return true } - if self.findExecutable(named: "node") != nil, self.nodeCliPath() != nil { return true } + static func hasAnyClawdisInvoker(searchPaths: [String]? = nil) -> Bool { + if self.clawdisExecutable(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 { return true } return false } static func clawdisNodeCommand( subcommand: String, extraArgs: [String] = [], - defaults: UserDefaults = .standard) -> [String] + defaults: UserDefaults = .standard, + searchPaths: [String]? = nil) -> [String] { let settings = self.connectionSettings(defaults: defaults) if settings.mode == .remote, let ssh = self.sshNodeCommand( @@ -192,25 +203,29 @@ enum CommandResolver { return ssh } - let runtimeResult = self.runtimeResolution() + let runtimeResult = self.runtimeResolution(searchPaths: searchPaths) switch runtimeResult { case let .success(runtime): - if let clawdisPath = self.clawdisExecutable() { + let root = self.projectRoot() + if let clawdisPath = self.projectClawdisExecutable(projectRoot: root) { return [clawdisPath, subcommand] + extraArgs } - if let entry = self.gatewayEntrypoint(in: self.projectRoot()) { + if let entry = self.gatewayEntrypoint(in: root) { return self.makeRuntimeCommand( runtime: runtime, entrypoint: entry, subcommand: subcommand, extraArgs: extraArgs) } - if let pnpm = self.findExecutable(named: "pnpm") { + if let pnpm = self.findExecutable(named: "pnpm", searchPaths: searchPaths) { // Use --silent to avoid pnpm lifecycle banners that would corrupt JSON outputs. return [pnpm, "--silent", "clawdis", subcommand] + extraArgs } + if let clawdisPath = self.clawdisExecutable(searchPaths: searchPaths) { + return [clawdisPath, subcommand] + extraArgs + } let missingEntry = """ clawdis entrypoint missing (looked for dist/index.js or bin/clawdis.js); run pnpm build. @@ -226,9 +241,10 @@ enum CommandResolver { static func clawdisCommand( subcommand: String, extraArgs: [String] = [], - defaults: UserDefaults = .standard) -> [String] + defaults: UserDefaults = .standard, + searchPaths: [String]? = nil) -> [String] { - self.clawdisNodeCommand(subcommand: subcommand, extraArgs: extraArgs, defaults: defaults) + self.clawdisNodeCommand(subcommand: subcommand, extraArgs: extraArgs, defaults: defaults, searchPaths: searchPaths) } // MARK: - SSH helpers diff --git a/apps/macos/Sources/Clawdis/DebugSettings.swift b/apps/macos/Sources/Clawdis/DebugSettings.swift index a730d5ef1..a30cf917a 100644 --- a/apps/macos/Sources/Clawdis/DebugSettings.swift +++ b/apps/macos/Sources/Clawdis/DebugSettings.swift @@ -1,8 +1,10 @@ import AppKit +import Observation import SwiftUI import UniformTypeIdentifiers struct DebugSettings: View { + @Bindable var state: AppState private let isPreview = ProcessInfo.processInfo.isPreview private let labelColumnWidth: CGFloat = 140 @AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath @@ -36,6 +38,10 @@ struct DebugSettings: View { @State private var canvasEvalResult: String? @State private var canvasSnapshotPath: String? + init(state: AppState = AppStateStore.shared) { + self.state = state + } + var body: some View { ScrollView(.vertical) { VStack(alignment: .leading, spacing: 14) { @@ -194,7 +200,9 @@ struct DebugSettings: View { .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.2))) HStack(spacing: 8) { - Button("Restart Gateway") { DebugActions.restartGateway() } + if self.canRestartGateway { + Button("Restart Gateway") { DebugActions.restartGateway() } + } Button("Clear log") { GatewayProcessManager.shared.clearLog() } Spacer(minLength: 0) } @@ -762,6 +770,10 @@ struct DebugSettings: View { CommandResolver.connectionSettings().mode == .remote } + private var canRestartGateway: Bool { + self.state.connectionMode == .local && !self.attachExistingGatewayOnly + } + private func configURL() -> URL { FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".clawdis") @@ -902,7 +914,7 @@ private struct PlainSettingsGroupBoxStyle: GroupBoxStyle { #if DEBUG struct DebugSettings_Previews: PreviewProvider { static var previews: some View { - DebugSettings() + DebugSettings(state: .preview) .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) } } @@ -910,7 +922,7 @@ struct DebugSettings_Previews: PreviewProvider { @MainActor extension DebugSettings { static func exerciseForTesting() async { - let view = DebugSettings() + let view = DebugSettings(state: .preview) view.modelsCount = 3 view.modelsLoading = false view.modelsError = "Failed to load models" diff --git a/apps/macos/Sources/Clawdis/MenuContentView.swift b/apps/macos/Sources/Clawdis/MenuContentView.swift index c43986925..7cb1d420b 100644 --- a/apps/macos/Sources/Clawdis/MenuContentView.swift +++ b/apps/macos/Sources/Clawdis/MenuContentView.swift @@ -209,10 +209,12 @@ struct MenuContent: View { Label("Send Test Notification", systemImage: "bell") } Divider() - Button { - DebugActions.restartGateway() - } label: { - Label("Restart Gateway", systemImage: "arrow.clockwise") + if self.state.connectionMode == .local, !AppStateStore.attachExistingGatewayOnly { + Button { + DebugActions.restartGateway() + } label: { + Label("Restart Gateway", systemImage: "arrow.clockwise") + } } Button { DebugActions.restartApp() diff --git a/apps/macos/Sources/Clawdis/SettingsRootView.swift b/apps/macos/Sources/Clawdis/SettingsRootView.swift index 9ede06efb..8cfd39ae5 100644 --- a/apps/macos/Sources/Clawdis/SettingsRootView.swift +++ b/apps/macos/Sources/Clawdis/SettingsRootView.swift @@ -57,7 +57,7 @@ struct SettingsRootView: View { .tag(SettingsTab.permissions) if self.state.debugPaneEnabled { - DebugSettings() + DebugSettings(state: self.state) .tabItem { Label("Debug", systemImage: "ant") } .tag(SettingsTab.debug) } diff --git a/apps/macos/Tests/ClawdisIPCTests/CommandResolverTests.swift b/apps/macos/Tests/ClawdisIPCTests/CommandResolverTests.swift index f0a543a87..9a4a650aa 100644 --- a/apps/macos/Tests/ClawdisIPCTests/CommandResolverTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/CommandResolverTests.swift @@ -52,12 +52,17 @@ import Testing try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path) try self.makeExec(at: scriptPath) - let cmd = CommandResolver.clawdisCommand(subcommand: "rpc", defaults: defaults) + let cmd = CommandResolver.clawdisCommand( + subcommand: "rpc", + defaults: defaults, + searchPaths: [tmp.appendingPathComponent("node_modules/.bin").path]) #expect(cmd.count >= 3) - #expect(cmd[0] == nodePath.path) - #expect(cmd[1] == scriptPath.path) - #expect(cmd[2] == "rpc") + if cmd.count >= 3 { + #expect(cmd[0] == nodePath.path) + #expect(cmd[1] == scriptPath.path) + #expect(cmd[2] == "rpc") + } } @Test func fallsBackToPnpm() async throws { diff --git a/apps/macos/Tests/ClawdisIPCTests/ConnectionsSettingsSmokeTests.swift b/apps/macos/Tests/ClawdisIPCTests/ConnectionsSettingsSmokeTests.swift index 4941b0524..a9ba93a5f 100644 --- a/apps/macos/Tests/ClawdisIPCTests/ConnectionsSettingsSmokeTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/ConnectionsSettingsSmokeTests.swift @@ -43,7 +43,8 @@ struct ConnectionsSettingsSmokeTests { elapsedMs: 120, bot: ProvidersStatusSnapshot.TelegramBot(id: 123, username: "clawdisbot"), webhook: ProvidersStatusSnapshot.TelegramWebhook(url: "https://example.com/hook", hasCustomCert: false)), - lastProbeAt: 1_700_000_050_000)) + lastProbeAt: 1_700_000_050_000), + discord: nil) store.whatsappLoginMessage = "Scan QR" store.whatsappLoginQrDataUrl = @@ -92,7 +93,8 @@ struct ConnectionsSettingsSmokeTests { elapsedMs: 120, bot: nil, webhook: nil), - lastProbeAt: 1_700_000_100_000)) + lastProbeAt: 1_700_000_100_000), + discord: nil) let view = ConnectionsSettings(store: store) _ = view.body