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
- 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 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.
- 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).

View File

@@ -16,6 +16,10 @@ enum CommandResolver {
RuntimeLocator.resolve(searchPaths: self.preferredPaths())
}
static func runtimeResolution(searchPaths: [String]?) -> Result<RuntimeResolution, RuntimeResolutionError> {
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

View File

@@ -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"

View File

@@ -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()

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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