mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
af9b0c7616
* fix(macos): keep the node channel alive and visible when the node-host worker cannot start A node-host worker that exited before its ready manifest never notified the retry policy, so the coordinator respawned the broken CLI forever and the whole node channel silently never dialed the gateway. Startup exits now consume the crash retry budget and carry the worker's stderr into the start error; every non-transient worker failure degrades the connect to native capabilities instead of aborting it; and the menu bar surfaces the recorded node-channel state with the concrete reason. * fix(macos): render node-channel status as a top-level menu view The native-menu extra style flattens multi-view Toggle labels to their first Text, so status sublines inside the label never rendered — the original 'zero indication' report. Top-level menu views render (exec-approval error pattern).
682 lines
26 KiB
Swift
682 lines
26 KiB
Swift
import AppKit
|
|
import AVFoundation
|
|
import Foundation
|
|
import KeyboardShortcuts
|
|
import Observation
|
|
import OpenClawKit
|
|
import SwiftUI
|
|
|
|
/// Menu contents for the OpenClaw menu bar extra.
|
|
struct MenuContent: View {
|
|
@Bindable var state: AppState
|
|
let updater: UpdaterProviding?
|
|
@Bindable private var updateStatus: UpdateStatus
|
|
private let healthStore = HealthStore.shared
|
|
private let heartbeatStore = HeartbeatStore.shared
|
|
private let controlChannel = ControlChannel.shared
|
|
private let dashboardManager = DashboardManager.shared
|
|
private let activityStore = WorkActivityStore.shared
|
|
private let nodesStore = NodesStore.shared
|
|
private let nodeChannelStatus = MacNodeChannelStatusStore.shared
|
|
@Bindable private var pairingPrompter = NodePairingApprovalPrompter.shared
|
|
@Bindable private var devicePairingPrompter = DevicePairingApprovalPrompter.shared
|
|
@State private var availableMics: [AudioInputDevice] = []
|
|
@State private var loadingMics = false
|
|
@State private var micObserver = AudioInputDeviceObserver()
|
|
@State private var micRefreshTask: Task<Void, Never>?
|
|
@State private var browserControlEnabled = true
|
|
@State private var testNotificationPending = false
|
|
@AppStorage(cameraEnabledKey, store: AppDefaults.standard) private var cameraEnabled: Bool = false
|
|
@AppStorage(appLogLevelKey, store: AppDefaults.standard)
|
|
private var appLogLevelRaw: String = Logger.Level.info.rawValue
|
|
@AppStorage(debugFileLogEnabledKey, store: AppDefaults.standard)
|
|
private var appFileLoggingEnabled: Bool = false
|
|
|
|
init(state: AppState, updater: UpdaterProviding?) {
|
|
self._state = Bindable(wrappedValue: state)
|
|
self.updater = updater
|
|
self._updateStatus = Bindable(wrappedValue: updater?.updateStatus ?? UpdateStatus.disabled)
|
|
}
|
|
|
|
private var execApprovalModeBinding: Binding<ExecApprovalQuickMode> {
|
|
Binding(
|
|
get: { self.state.execApprovalMode },
|
|
set: { self.state.updateExecApprovalMode($0) })
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Toggle(isOn: self.activeBinding) {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(self.connectionLabel)
|
|
self.statusLine(label: self.healthStatus.label, color: self.healthStatus.color)
|
|
if self.pairingPrompter.pendingCount > 0 {
|
|
self.pairingStatusLine(
|
|
label: "Pairing approval pending (\(self.pairingPrompter.pendingCount))")
|
|
}
|
|
if self.devicePairingPrompter.pendingCount > 0 {
|
|
let repairCount = self.devicePairingPrompter.pendingRepairCount
|
|
let repairSuffix = repairCount > 0 ? " · \(repairCount) repair" : ""
|
|
self.pairingStatusLine(
|
|
label: "Device pairing pending (\(self.devicePairingPrompter.pendingCount))\(repairSuffix)")
|
|
}
|
|
}
|
|
}
|
|
.disabled(self.state.connectionMode == .unconfigured)
|
|
// The native-menu extra style flattens multi-view Toggle labels to
|
|
// their first Text, so status sublines inside the label above never
|
|
// render. Node-channel state must be a top-level menu view to stay
|
|
// operator-visible (same pattern as the exec-approval error lines).
|
|
if let macNodeStatus = self.macNodeStatus {
|
|
Text(macNodeStatus.label)
|
|
.font(.caption)
|
|
.foregroundStyle(macNodeStatus.color)
|
|
}
|
|
|
|
Divider()
|
|
Toggle(isOn: self.heartbeatsBinding) {
|
|
HStack(spacing: 8) {
|
|
Label("Send Heartbeats", systemImage: "waveform.path.ecg")
|
|
Spacer(minLength: 0)
|
|
self.statusLine(label: self.heartbeatStatus.label, color: self.heartbeatStatus.color)
|
|
}
|
|
}
|
|
Toggle(
|
|
isOn: Binding(
|
|
get: { self.browserControlEnabled },
|
|
set: { enabled in
|
|
self.browserControlEnabled = enabled
|
|
Task { await self.saveBrowserControlEnabled(enabled) }
|
|
})) {
|
|
Label("Browser Control", systemImage: "globe")
|
|
}
|
|
Toggle(isOn: self.$cameraEnabled) {
|
|
Label("Allow Camera", systemImage: "camera")
|
|
}
|
|
switch self.state.execApprovalPolicyLoadState {
|
|
case .available:
|
|
Picker(selection: self.execApprovalModeBinding) {
|
|
ForEach(ExecApprovalQuickMode.allCases) { mode in
|
|
Text(mode.title).tag(mode)
|
|
}
|
|
} label: {
|
|
Label("Exec Approvals", systemImage: "terminal")
|
|
}
|
|
case .loading:
|
|
Label("Loading Exec Approvals…", systemImage: "terminal")
|
|
.foregroundStyle(.secondary)
|
|
case .unavailable:
|
|
Button {
|
|
self.state.retryExecApprovalModeRead()
|
|
} label: {
|
|
Label("Retry Exec Approvals", systemImage: "arrow.clockwise")
|
|
}
|
|
}
|
|
if let error = self.state.execApprovalLoadError {
|
|
Text(error)
|
|
.font(.caption)
|
|
.foregroundStyle(.orange)
|
|
}
|
|
if let error = self.state.execApprovalMutationError {
|
|
Text(error)
|
|
.font(.caption)
|
|
.foregroundStyle(.orange)
|
|
}
|
|
Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) {
|
|
Label("Allow Canvas", systemImage: "rectangle.and.pencil.and.ellipsis")
|
|
}
|
|
.onChange(of: self.state.canvasEnabled) { _, enabled in
|
|
if !enabled {
|
|
CanvasManager.shared.hideAll()
|
|
}
|
|
}
|
|
Toggle(isOn: self.voiceWakeBinding) {
|
|
Label("Voice Wake", systemImage: "mic.fill")
|
|
}
|
|
.disabled(!voiceWakeSupported)
|
|
.opacity(voiceWakeSupported ? 1 : 0.5)
|
|
if self.showVoiceWakeMicPicker {
|
|
self.voiceWakeMicMenu
|
|
}
|
|
Button {
|
|
self.open(tab: .voiceWake)
|
|
} label: {
|
|
Label("Voice & Talk Settings…", systemImage: "slider.horizontal.3")
|
|
}
|
|
Divider()
|
|
Button {
|
|
AppNavigationActions.openDashboard()
|
|
} label: {
|
|
Label("Open Dashboard", systemImage: "gauge")
|
|
}
|
|
Button {
|
|
AppNavigationActions.openChat()
|
|
} label: {
|
|
Label("Open Chat", systemImage: "bubble.left.and.bubble.right")
|
|
}
|
|
if self.state.quickChatEnabled {
|
|
Button {
|
|
QuickChatController.shared.toggle()
|
|
} label: {
|
|
Label("Quick Chat", systemImage: "text.bubble")
|
|
}
|
|
.globalKeyboardShortcut(.toggleQuickChat)
|
|
}
|
|
if self.state.canvasEnabled {
|
|
Button {
|
|
AppNavigationActions.toggleCanvas()
|
|
} label: {
|
|
Label(
|
|
self.state.canvasPanelVisible ? "Close Canvas" : "Open Canvas",
|
|
systemImage: "rectangle.inset.filled.on.rectangle")
|
|
}
|
|
}
|
|
Button {
|
|
Task { await self.state.setTalkEnabled(!self.state.talkEnabled) }
|
|
} label: {
|
|
Label(self.state.talkEnabled ? "Stop Talk Mode" : "Talk Mode", systemImage: "waveform.circle.fill")
|
|
}
|
|
.disabled(!voiceWakeSupported)
|
|
.opacity(voiceWakeSupported ? 1 : 0.5)
|
|
Divider()
|
|
Button("Settings…") { self.open(tab: .general) }
|
|
.keyboardShortcut(",", modifiers: [.command])
|
|
self.debugMenu
|
|
Button("About OpenClaw") { self.open(tab: .about) }
|
|
if let updater, updater.isAvailable, self.updateStatus.isUpdateReady {
|
|
Button("Update ready, restart now?") { updater.checkForUpdates(nil) }
|
|
}
|
|
Button("Quit") { NSApplication.shared.terminate(nil) }
|
|
}
|
|
.task(id: self.state.swabbleEnabled) {
|
|
if self.state.swabbleEnabled {
|
|
await self.loadMicrophones(force: true)
|
|
}
|
|
}
|
|
.task {
|
|
VoicePushToTalkHotkey.shared.setEnabled(voiceWakeSupported && self.state.voicePushToTalkEnabled)
|
|
}
|
|
.task {
|
|
await self.nodesStore.prepareLocalNodeIdentity()
|
|
}
|
|
.onChange(of: self.state.voicePushToTalkEnabled) { _, enabled in
|
|
VoicePushToTalkHotkey.shared.setEnabled(voiceWakeSupported && enabled)
|
|
}
|
|
.task(id: self.state.connectionMode) {
|
|
await self.loadBrowserControlEnabled()
|
|
}
|
|
.onAppear {
|
|
MicRefreshSupport.startObserver(self.micObserver) {
|
|
MicRefreshSupport.schedule(refreshTask: &self.micRefreshTask) {
|
|
await self.loadMicrophones(force: true)
|
|
}
|
|
}
|
|
}
|
|
.onDisappear {
|
|
self.micRefreshTask?.cancel()
|
|
self.micRefreshTask = nil
|
|
self.micObserver.stop()
|
|
}
|
|
}
|
|
|
|
private var connectionLabel: String {
|
|
DashboardGatewayMenuModel.connectionLabel(
|
|
mode: self.state.connectionMode,
|
|
controlState: self.controlChannel.state,
|
|
entries: self.dashboardManager.gatewayEntries)
|
|
}
|
|
|
|
private func loadBrowserControlEnabled() async {
|
|
let root = await ConfigStore.load()
|
|
let browser = root["browser"] as? [String: Any]
|
|
let enabled = browser?["enabled"] as? Bool ?? true
|
|
await MainActor.run { self.browserControlEnabled = enabled }
|
|
}
|
|
|
|
private func saveBrowserControlEnabled(_ enabled: Bool) async {
|
|
let (success, _) = await MenuContent.buildAndSaveBrowserEnabled(enabled)
|
|
|
|
if !success {
|
|
await self.loadBrowserControlEnabled()
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private static func buildAndSaveBrowserEnabled(_ enabled: Bool) async -> (Bool, ()) {
|
|
var root = await ConfigStore.load()
|
|
var browser = root["browser"] as? [String: Any] ?? [:]
|
|
browser["enabled"] = enabled
|
|
root["browser"] = browser
|
|
do {
|
|
try await ConfigStore.save(root)
|
|
return (true, ())
|
|
} catch {
|
|
return (false, ())
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var debugMenu: some View {
|
|
if self.state.debugPaneEnabled {
|
|
Menu("Debug") {
|
|
Button {
|
|
DebugActions.openConfigFolder()
|
|
} label: {
|
|
Label("Open Config Folder", systemImage: "folder")
|
|
}
|
|
Button {
|
|
Task { await DebugActions.runHealthCheckNow() }
|
|
} label: {
|
|
Label("Run Health Check Now", systemImage: "stethoscope")
|
|
}
|
|
Button {
|
|
Task { _ = await DebugActions.sendTestHeartbeat() }
|
|
} label: {
|
|
Label("Send Test Heartbeat", systemImage: "waveform.path.ecg")
|
|
}
|
|
#if DEBUG
|
|
Button {
|
|
DebugActions.showPairingPanelDemo()
|
|
} label: {
|
|
Label("Show Pairing Panel (Demo)", systemImage: "checkmark.shield")
|
|
}
|
|
#endif
|
|
if self.state.connectionMode == .remote {
|
|
Button {
|
|
Task { @MainActor in
|
|
let result = await DebugActions.resetGatewayTunnel()
|
|
self.presentDebugResult(result, title: "Remote Tunnel")
|
|
}
|
|
} label: {
|
|
Label("Reset Remote Tunnel", systemImage: "arrow.triangle.2.circlepath")
|
|
}
|
|
}
|
|
Button {
|
|
Task { _ = await DebugActions.toggleVerboseLoggingMain() }
|
|
} label: {
|
|
Label(
|
|
DebugActions.verboseLoggingEnabledMain
|
|
? "Verbose Logging (Main): On"
|
|
: "Verbose Logging (Main): Off",
|
|
systemImage: "text.alignleft")
|
|
}
|
|
Menu {
|
|
Picker("Verbosity", selection: self.$appLogLevelRaw) {
|
|
ForEach(Logger.Level.allCases, id: \.rawValue) { level in
|
|
Text(level.title).tag(level.rawValue)
|
|
}
|
|
}
|
|
Toggle(isOn: self.$appFileLoggingEnabled) {
|
|
Label(
|
|
self.appFileLoggingEnabled
|
|
? "File Logging: On"
|
|
: "File Logging: Off",
|
|
systemImage: "doc.text.magnifyingglass")
|
|
}
|
|
} label: {
|
|
Label("App Logging", systemImage: "doc.text")
|
|
}
|
|
Button {
|
|
DebugActions.openSessionStore()
|
|
} label: {
|
|
Label("Open Session Store", systemImage: "externaldrive")
|
|
}
|
|
Divider()
|
|
Button {
|
|
DebugActions.openAgentEventsWindow()
|
|
} label: {
|
|
Label("Open Agent Events…", systemImage: "bolt.horizontal.circle")
|
|
}
|
|
Button {
|
|
DebugActions.openLog()
|
|
} label: {
|
|
Label("Open Log", systemImage: "doc.text.magnifyingglass")
|
|
}
|
|
Button {
|
|
Task { _ = await DebugActions.sendDebugVoice() }
|
|
} label: {
|
|
Label("Send Debug Voice Text", systemImage: "waveform.circle")
|
|
}
|
|
Button {
|
|
Task { await self.sendTestNotification() }
|
|
} label: {
|
|
Label("Send Test Notification", systemImage: "bell")
|
|
}
|
|
.disabled(self.testNotificationPending)
|
|
Divider()
|
|
if self.state.connectionMode == .local {
|
|
Button {
|
|
DebugActions.restartGateway()
|
|
} label: {
|
|
Label("Restart Gateway", systemImage: "arrow.clockwise")
|
|
}
|
|
}
|
|
Button {
|
|
DebugActions.restartOnboarding()
|
|
} label: {
|
|
Label("Restart Onboarding", systemImage: "arrow.counterclockwise")
|
|
}
|
|
Button {
|
|
DebugActions.restartApp()
|
|
} label: {
|
|
Label("Restart App", systemImage: "arrow.triangle.2.circlepath")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func open(tab: SettingsTab) {
|
|
AppNavigationActions.openSettings(tab: tab)
|
|
}
|
|
|
|
private var macNodeStatus: (label: String, color: Color)? {
|
|
guard self.state.connectionMode != .unconfigured else { return nil }
|
|
guard case .connected = self.controlChannel.state else { return nil }
|
|
|
|
// The coordinator records why the node channel is down at the connect
|
|
// boundary; prefer that recorded fact over inferring from node listings.
|
|
if let line = self.nodeChannelStatus.state.operatorStatusLine {
|
|
return (line.label, line.isDegraded ? .orange : .red)
|
|
}
|
|
|
|
let deviceId: String
|
|
switch self.nodesStore.localNodeIdentityState {
|
|
case .loading:
|
|
return nil
|
|
case let .available(id):
|
|
deviceId = id
|
|
case .unavailable:
|
|
return ("Mac identity unavailable", .red)
|
|
}
|
|
if let entry = self.nodesStore.nodes.first(where: { $0.nodeId == deviceId }) {
|
|
guard entry.isConnected else {
|
|
return ("Mac capabilities offline", .orange)
|
|
}
|
|
let commands = Set(entry.commands ?? [])
|
|
let missingRequiredCommands = [
|
|
OpenClawSystemCommand.notify.rawValue,
|
|
OpenClawSystemCommand.run.rawValue,
|
|
OpenClawSystemCommand.which.rawValue,
|
|
].filter { !commands.contains($0) }
|
|
if !missingRequiredCommands.isEmpty {
|
|
return ("Mac capabilities incomplete", .orange)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
guard !self.nodesStore.isLoading, !self.nodesStore.nodes.isEmpty else { return nil }
|
|
return ("Mac capabilities offline", .orange)
|
|
}
|
|
|
|
private var healthStatus: (label: String, color: Color) {
|
|
if self.state.connectionMode == .local,
|
|
let failure = GatewayProcessManager.shared.lastFailureReason
|
|
{
|
|
return (failure, .red)
|
|
}
|
|
if self.state.connectionMode == .remote {
|
|
let live = GatewayConnectionPresentation(state: self.controlChannel.state)
|
|
switch live.tone {
|
|
case .healthy:
|
|
break
|
|
case .transient:
|
|
return (live.generalSubtitle, .orange)
|
|
case .attention:
|
|
return (live.generalSubtitle, .red)
|
|
}
|
|
}
|
|
|
|
if let activity = self.activityStore.current {
|
|
let color: Color = activity.role == .main ? .accentColor : .gray
|
|
let roleLabel = activity.role == .main ? "Main" : "Other"
|
|
let text = "\(roleLabel) · \(activity.label)"
|
|
return (text, color)
|
|
}
|
|
|
|
let health = self.healthStore.state
|
|
let isRefreshing = self.healthStore.isRefreshing
|
|
let lastAge = self.healthStore.lastSuccess.map { age(from: $0) }
|
|
|
|
if isRefreshing {
|
|
return ("Health check running…", health.tint)
|
|
}
|
|
|
|
switch health {
|
|
case .ok:
|
|
let ageText = lastAge.map { " · checked \($0)" } ?? ""
|
|
return ("Health ok\(ageText)", .green)
|
|
case .linkingNeeded:
|
|
return ("Health: login required", .red)
|
|
case let .degraded(reason):
|
|
let detail = HealthStore.shared.degradedSummary ?? reason
|
|
let ageText = lastAge.map { " · checked \($0)" } ?? ""
|
|
return ("\(detail)\(ageText)", .orange)
|
|
case .unknown:
|
|
return ("Health pending", .secondary)
|
|
}
|
|
}
|
|
|
|
private var heartbeatStatus: (label: String, color: Color) {
|
|
if case .degraded = self.controlChannel.state {
|
|
return ("Control channel disconnected", .red)
|
|
} else if let evt = self.heartbeatStore.lastEvent {
|
|
let ageText = age(from: Date(timeIntervalSince1970: evt.ts / 1000))
|
|
switch evt.status {
|
|
case "sent":
|
|
return ("Last heartbeat sent · \(ageText)", .blue)
|
|
case "ok-empty", "ok-token":
|
|
return ("Heartbeat ok · \(ageText)", .green)
|
|
case "skipped":
|
|
return ("Heartbeat skipped · \(ageText)", .secondary)
|
|
case "failed":
|
|
return ("Heartbeat failed · \(ageText)", .red)
|
|
default:
|
|
return ("Heartbeat · \(ageText)", .secondary)
|
|
}
|
|
} else {
|
|
return ("No heartbeat yet", .secondary)
|
|
}
|
|
}
|
|
|
|
private func statusLine(label: String, color: Color) -> some View {
|
|
HStack(spacing: 6) {
|
|
Circle()
|
|
.fill(color)
|
|
.frame(width: 6, height: 6)
|
|
Text(label)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.leading)
|
|
.lineLimit(nil)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.layoutPriority(1)
|
|
}
|
|
.padding(.top, 2)
|
|
}
|
|
|
|
/// Pending-pairing status lines reopen the approval panel (e.g. after "Not Now").
|
|
private func pairingStatusLine(label: String) -> some View {
|
|
Button {
|
|
PairingApprovalCenter.shared.showPanel()
|
|
} label: {
|
|
self.statusLine(label: label, color: .orange)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help("Show pairing requests")
|
|
}
|
|
|
|
private var activeBinding: Binding<Bool> {
|
|
Binding(get: { !self.state.isPaused }, set: { self.state.isPaused = !$0 })
|
|
}
|
|
|
|
private var heartbeatsBinding: Binding<Bool> {
|
|
Binding(get: { self.state.heartbeatsEnabled }, set: { self.state.heartbeatsEnabled = $0 })
|
|
}
|
|
|
|
private var voiceWakeBinding: Binding<Bool> {
|
|
MicRefreshSupport.voiceWakeBinding(for: self.state)
|
|
}
|
|
|
|
private var showVoiceWakeMicPicker: Bool {
|
|
voiceWakeSupported && self.state.swabbleEnabled
|
|
}
|
|
|
|
private var voiceWakeMicMenu: some View {
|
|
Menu {
|
|
self.microphoneMenuItems
|
|
|
|
if self.loadingMics {
|
|
Divider()
|
|
Label("Refreshing microphones…", systemImage: "arrow.triangle.2.circlepath")
|
|
.labelStyle(.titleOnly)
|
|
.foregroundStyle(.secondary)
|
|
.disabled(true)
|
|
}
|
|
} label: {
|
|
HStack {
|
|
Text("Microphone")
|
|
Spacer()
|
|
Text(self.selectedMicLabel)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.task { await self.loadMicrophones() }
|
|
}
|
|
|
|
private var selectedMicLabel: String {
|
|
if self.state.voiceWakeMicID.isEmpty {
|
|
return self.defaultMicLabel
|
|
}
|
|
if let match = self.availableMics.first(where: { $0.uid == self.state.voiceWakeMicID }) {
|
|
return match.name
|
|
}
|
|
if !self.state.voiceWakeMicName.isEmpty {
|
|
return self.state.voiceWakeMicName
|
|
}
|
|
return "Unavailable"
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var microphoneMenuItems: some View {
|
|
if self.isSelectedMicUnavailable {
|
|
Label("Disconnected (using System default)", systemImage: "exclamationmark.triangle")
|
|
.labelStyle(.titleAndIcon)
|
|
.foregroundStyle(.secondary)
|
|
.disabled(true)
|
|
Divider()
|
|
}
|
|
Button {
|
|
self.state.voiceWakeMicID = ""
|
|
self.state.voiceWakeMicName = ""
|
|
} label: {
|
|
Label(self.defaultMicLabel, systemImage: self.state.voiceWakeMicID.isEmpty ? "checkmark" : "")
|
|
.labelStyle(.titleAndIcon)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
ForEach(self.availableMics) { mic in
|
|
Button {
|
|
self.state.voiceWakeMicID = mic.uid
|
|
self.state.voiceWakeMicName = mic.name
|
|
} label: {
|
|
Label(mic.name, systemImage: self.state.voiceWakeMicID == mic.uid ? "checkmark" : "")
|
|
.labelStyle(.titleAndIcon)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
private var isSelectedMicUnavailable: Bool {
|
|
let selected = self.state.voiceWakeMicID
|
|
guard !selected.isEmpty else { return false }
|
|
return !self.availableMics.contains(where: { $0.uid == selected })
|
|
}
|
|
|
|
private var defaultMicLabel: String {
|
|
if let host = Host.current().localizedName, !host.isEmpty {
|
|
return "Auto-detect (\(host))"
|
|
}
|
|
return "System default"
|
|
}
|
|
|
|
@MainActor
|
|
private func presentDebugResult(_ result: Result<String, DebugActionError>, title: String) {
|
|
let alert = NSAlert()
|
|
alert.messageText = title
|
|
switch result {
|
|
case let .success(message):
|
|
alert.informativeText = message
|
|
alert.alertStyle = .informational
|
|
case let .failure(error):
|
|
alert.informativeText = error.localizedDescription
|
|
alert.alertStyle = .warning
|
|
}
|
|
alert.runModal()
|
|
}
|
|
|
|
@MainActor
|
|
private func sendTestNotification() async {
|
|
guard !self.testNotificationPending else { return }
|
|
self.testNotificationPending = true
|
|
let outcome = await DebugActions.sendTestNotification()
|
|
self.testNotificationPending = false
|
|
let alert = NSAlert()
|
|
alert.messageText = "Test Notification"
|
|
switch outcome {
|
|
case .pending:
|
|
return
|
|
case .sent:
|
|
alert.informativeText = "The notification request was queued."
|
|
alert.alertStyle = .informational
|
|
case let .error(message):
|
|
alert.informativeText = message
|
|
alert.alertStyle = .warning
|
|
}
|
|
alert.runModal()
|
|
}
|
|
|
|
@MainActor
|
|
private func loadMicrophones(force: Bool = false) async {
|
|
guard self.showVoiceWakeMicPicker else {
|
|
self.availableMics = []
|
|
self.loadingMics = false
|
|
return
|
|
}
|
|
if !force, !self.availableMics.isEmpty {
|
|
return
|
|
}
|
|
self.loadingMics = true
|
|
let discovery = AVCaptureDevice.DiscoverySession(
|
|
deviceTypes: [.external, .microphone],
|
|
mediaType: .audio,
|
|
position: .unspecified)
|
|
let connectedDevices = discovery.devices.filter(\.isConnected)
|
|
self.availableMics = connectedDevices
|
|
.sorted { lhs, rhs in
|
|
lhs.localizedName.localizedCaseInsensitiveCompare(rhs.localizedName) == .orderedAscending
|
|
}
|
|
.map { AudioInputDevice(uid: $0.uniqueID, name: $0.localizedName) }
|
|
self.availableMics = self.filterAliveInputs(self.availableMics)
|
|
self.state.voiceWakeMicName = MicRefreshSupport.selectedMicName(
|
|
selectedID: self.state.voiceWakeMicID,
|
|
in: self.availableMics,
|
|
uid: \.uid,
|
|
name: \.name)
|
|
self.loadingMics = false
|
|
}
|
|
|
|
private func filterAliveInputs(_ inputs: [AudioInputDevice]) -> [AudioInputDevice] {
|
|
let aliveUIDs = AudioInputDeviceObserver.aliveInputDeviceUIDs()
|
|
guard !aliveUIDs.isEmpty else { return inputs }
|
|
return inputs.filter { aliveUIDs.contains($0.uid) }
|
|
}
|
|
|
|
private struct AudioInputDevice: Identifiable, Equatable {
|
|
let uid: String
|
|
let name: String
|
|
var id: String {
|
|
self.uid
|
|
}
|
|
}
|
|
}
|