fix(macos): hide Restart Gateway when remote

This commit is contained in:
Peter Steinberger
2025-12-30 01:40:51 +01:00
parent 10e1e7fd44
commit 02db68aa67
7 changed files with 67 additions and 29 deletions

View File

@@ -8,6 +8,7 @@
### Fixes ### 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: 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 menu: add a Talk Mode action alongside the Open Dashboard/Chat/Canvas entries.
- macOS Debug: hide “Restart Gateway” when the app wont 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. - 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). - 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). - macOS menu: device list now uses `node.list` (devices only; no agent/tool presence entries).

View File

@@ -16,6 +16,10 @@ enum CommandResolver {
RuntimeLocator.resolve(searchPaths: self.preferredPaths()) RuntimeLocator.resolve(searchPaths: self.preferredPaths())
} }
static func runtimeResolution(searchPaths: [String]?) -> Result<RuntimeResolution, RuntimeResolutionError> {
RuntimeLocator.resolve(searchPaths: searchPaths ?? self.preferredPaths())
}
static func makeRuntimeCommand( static func makeRuntimeCommand(
runtime: RuntimeResolution, runtime: RuntimeResolution,
entrypoint: String, entrypoint: String,
@@ -152,8 +156,8 @@ enum CommandResolver {
return paths return paths
} }
static func findExecutable(named name: String) -> String? { static func findExecutable(named name: String, searchPaths: [String]? = nil) -> String? {
for dir in self.preferredPaths() { for dir in (searchPaths ?? self.preferredPaths()) {
let candidate = (dir as NSString).appendingPathComponent(name) let candidate = (dir as NSString).appendingPathComponent(name)
if FileManager.default.isExecutableFile(atPath: candidate) { if FileManager.default.isExecutableFile(atPath: candidate) {
return candidate return candidate
@@ -162,8 +166,14 @@ enum CommandResolver {
return nil return nil
} }
static func clawdisExecutable() -> String? { static func clawdisExecutable(searchPaths: [String]? = nil) -> String? {
self.findExecutable(named: self.helperName) 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? { static func nodeCliPath() -> String? {
@@ -171,17 +181,18 @@ enum CommandResolver {
return FileManager.default.isReadableFile(atPath: candidate) ? candidate : nil return FileManager.default.isReadableFile(atPath: candidate) ? candidate : nil
} }
static func hasAnyClawdisInvoker() -> Bool { static func hasAnyClawdisInvoker(searchPaths: [String]? = nil) -> Bool {
if self.clawdisExecutable() != nil { return true } if self.clawdisExecutable(searchPaths: searchPaths) != nil { return true }
if self.findExecutable(named: "pnpm") != nil { return true } if self.findExecutable(named: "pnpm", searchPaths: searchPaths) != nil { return true }
if self.findExecutable(named: "node") != nil, self.nodeCliPath() != nil { return true } if self.findExecutable(named: "node", searchPaths: searchPaths) != nil, self.nodeCliPath() != nil { return true }
return false return false
} }
static func clawdisNodeCommand( static func clawdisNodeCommand(
subcommand: String, subcommand: String,
extraArgs: [String] = [], extraArgs: [String] = [],
defaults: UserDefaults = .standard) -> [String] defaults: UserDefaults = .standard,
searchPaths: [String]? = nil) -> [String]
{ {
let settings = self.connectionSettings(defaults: defaults) let settings = self.connectionSettings(defaults: defaults)
if settings.mode == .remote, let ssh = self.sshNodeCommand( if settings.mode == .remote, let ssh = self.sshNodeCommand(
@@ -192,25 +203,29 @@ enum CommandResolver {
return ssh return ssh
} }
let runtimeResult = self.runtimeResolution() let runtimeResult = self.runtimeResolution(searchPaths: searchPaths)
switch runtimeResult { switch runtimeResult {
case let .success(runtime): case let .success(runtime):
if let clawdisPath = self.clawdisExecutable() { let root = self.projectRoot()
if let clawdisPath = self.projectClawdisExecutable(projectRoot: root) {
return [clawdisPath, subcommand] + extraArgs return [clawdisPath, subcommand] + extraArgs
} }
if let entry = self.gatewayEntrypoint(in: self.projectRoot()) { if let entry = self.gatewayEntrypoint(in: root) {
return self.makeRuntimeCommand( return self.makeRuntimeCommand(
runtime: runtime, runtime: runtime,
entrypoint: entry, entrypoint: entry,
subcommand: subcommand, subcommand: subcommand,
extraArgs: extraArgs) 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. // Use --silent to avoid pnpm lifecycle banners that would corrupt JSON outputs.
return [pnpm, "--silent", "clawdis", subcommand] + extraArgs return [pnpm, "--silent", "clawdis", subcommand] + extraArgs
} }
if let clawdisPath = self.clawdisExecutable(searchPaths: searchPaths) {
return [clawdisPath, subcommand] + extraArgs
}
let missingEntry = """ let missingEntry = """
clawdis entrypoint missing (looked for dist/index.js or bin/clawdis.js); run pnpm build. clawdis entrypoint missing (looked for dist/index.js or bin/clawdis.js); run pnpm build.
@@ -226,9 +241,10 @@ enum CommandResolver {
static func clawdisCommand( static func clawdisCommand(
subcommand: String, subcommand: String,
extraArgs: [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 // MARK: - SSH helpers

View File

@@ -1,8 +1,10 @@
import AppKit import AppKit
import Observation
import SwiftUI import SwiftUI
import UniformTypeIdentifiers import UniformTypeIdentifiers
struct DebugSettings: View { struct DebugSettings: View {
@Bindable var state: AppState
private let isPreview = ProcessInfo.processInfo.isPreview private let isPreview = ProcessInfo.processInfo.isPreview
private let labelColumnWidth: CGFloat = 140 private let labelColumnWidth: CGFloat = 140
@AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath @AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath
@@ -36,6 +38,10 @@ struct DebugSettings: View {
@State private var canvasEvalResult: String? @State private var canvasEvalResult: String?
@State private var canvasSnapshotPath: String? @State private var canvasSnapshotPath: String?
init(state: AppState = AppStateStore.shared) {
self.state = state
}
var body: some View { var body: some View {
ScrollView(.vertical) { ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 14) {
@@ -194,7 +200,9 @@ struct DebugSettings: View {
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.2))) .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.2)))
HStack(spacing: 8) { HStack(spacing: 8) {
Button("Restart Gateway") { DebugActions.restartGateway() } if self.canRestartGateway {
Button("Restart Gateway") { DebugActions.restartGateway() }
}
Button("Clear log") { GatewayProcessManager.shared.clearLog() } Button("Clear log") { GatewayProcessManager.shared.clearLog() }
Spacer(minLength: 0) Spacer(minLength: 0)
} }
@@ -762,6 +770,10 @@ struct DebugSettings: View {
CommandResolver.connectionSettings().mode == .remote CommandResolver.connectionSettings().mode == .remote
} }
private var canRestartGateway: Bool {
self.state.connectionMode == .local && !self.attachExistingGatewayOnly
}
private func configURL() -> URL { private func configURL() -> URL {
FileManager.default.homeDirectoryForCurrentUser FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".clawdis") .appendingPathComponent(".clawdis")
@@ -902,7 +914,7 @@ private struct PlainSettingsGroupBoxStyle: GroupBoxStyle {
#if DEBUG #if DEBUG
struct DebugSettings_Previews: PreviewProvider { struct DebugSettings_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
DebugSettings() DebugSettings(state: .preview)
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
} }
} }
@@ -910,7 +922,7 @@ struct DebugSettings_Previews: PreviewProvider {
@MainActor @MainActor
extension DebugSettings { extension DebugSettings {
static func exerciseForTesting() async { static func exerciseForTesting() async {
let view = DebugSettings() let view = DebugSettings(state: .preview)
view.modelsCount = 3 view.modelsCount = 3
view.modelsLoading = false view.modelsLoading = false
view.modelsError = "Failed to load models" view.modelsError = "Failed to load models"

View File

@@ -209,10 +209,12 @@ struct MenuContent: View {
Label("Send Test Notification", systemImage: "bell") Label("Send Test Notification", systemImage: "bell")
} }
Divider() Divider()
Button { if self.state.connectionMode == .local, !AppStateStore.attachExistingGatewayOnly {
DebugActions.restartGateway() Button {
} label: { DebugActions.restartGateway()
Label("Restart Gateway", systemImage: "arrow.clockwise") } label: {
Label("Restart Gateway", systemImage: "arrow.clockwise")
}
} }
Button { Button {
DebugActions.restartApp() DebugActions.restartApp()

View File

@@ -57,7 +57,7 @@ struct SettingsRootView: View {
.tag(SettingsTab.permissions) .tag(SettingsTab.permissions)
if self.state.debugPaneEnabled { if self.state.debugPaneEnabled {
DebugSettings() DebugSettings(state: self.state)
.tabItem { Label("Debug", systemImage: "ant") } .tabItem { Label("Debug", systemImage: "ant") }
.tag(SettingsTab.debug) .tag(SettingsTab.debug)
} }

View File

@@ -52,12 +52,17 @@ import Testing
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path)
try self.makeExec(at: scriptPath) 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.count >= 3)
#expect(cmd[0] == nodePath.path) if cmd.count >= 3 {
#expect(cmd[1] == scriptPath.path) #expect(cmd[0] == nodePath.path)
#expect(cmd[2] == "rpc") #expect(cmd[1] == scriptPath.path)
#expect(cmd[2] == "rpc")
}
} }
@Test func fallsBackToPnpm() async throws { @Test func fallsBackToPnpm() async throws {

View File

@@ -43,7 +43,8 @@ struct ConnectionsSettingsSmokeTests {
elapsedMs: 120, elapsedMs: 120,
bot: ProvidersStatusSnapshot.TelegramBot(id: 123, username: "clawdisbot"), bot: ProvidersStatusSnapshot.TelegramBot(id: 123, username: "clawdisbot"),
webhook: ProvidersStatusSnapshot.TelegramWebhook(url: "https://example.com/hook", hasCustomCert: false)), 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.whatsappLoginMessage = "Scan QR"
store.whatsappLoginQrDataUrl = store.whatsappLoginQrDataUrl =
@@ -92,7 +93,8 @@ struct ConnectionsSettingsSmokeTests {
elapsedMs: 120, elapsedMs: 120,
bot: nil, bot: nil,
webhook: nil), webhook: nil),
lastProbeAt: 1_700_000_100_000)) lastProbeAt: 1_700_000_100_000),
discord: nil)
let view = ConnectionsSettings(store: store) let view = ConnectionsSettings(store: store)
_ = view.body _ = view.body