From 9b7a41621465deb84dc2396535060cf5a72cee57 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Wed, 7 Oct 2026 19:13:48 +0545 Subject: [PATCH] fix: stop the Bluetooth permission prompt; stop ewiz-mcp crashing on SIGTERM The Automation tab's live panel read IOBluetooth's paired devices every four seconds. That call is TCC-gated, macOS restarts the app when access is granted, and an ad-hoc signature ties the grant to the exact build, so every update asked again and 'Allow' quit the app. Bluetooth is now read only when a rule depends on it, and the row says so. Top-level globals in main.swift are main-actor isolated under Swift 6; the signal handler read `awake` from a global queue and the isolation assertion trapped, so ending an agent session crashed the server instead of releasing its hold. Bound to a local the handler can use. Verified: SIGTERM mid-hold now exits cleanly, releases the assertion, writes no crash report. --- .changeset/bluetooth-prompt-mcp-crash.md | 11 +++++++++++ Sources/eWiz/TriggerSettings.swift | 4 +++- Sources/eWiz/TriggerStore.swift | 16 ++++++++++++++-- Sources/ewiz-mcp/main.swift | 9 ++++++++- 4 files changed, 36 insertions(+), 4 deletions(-) create mode 100644 .changeset/bluetooth-prompt-mcp-crash.md diff --git a/.changeset/bluetooth-prompt-mcp-crash.md b/.changeset/bluetooth-prompt-mcp-crash.md new file mode 100644 index 0000000..343126e --- /dev/null +++ b/.changeset/bluetooth-prompt-mcp-crash.md @@ -0,0 +1,11 @@ +--- +"ewiz": patch +--- + +**No more Bluetooth permission prompt from the Automation tab.** The tab's live state read +your connected Bluetooth devices every few seconds, which made macOS ask for Bluetooth +access on every update and relaunch eWiz when you allowed it. Bluetooth is now read only +when a rule actually depends on it. + +**The AI-agent server no longer crashes when a session ends.** Ending an agent session +sent a signal that crashed `ewiz-mcp` instead of releasing its keep-awake. diff --git a/Sources/eWiz/TriggerSettings.swift b/Sources/eWiz/TriggerSettings.swift index 8bbe82b..6c576f5 100644 --- a/Sources/eWiz/TriggerSettings.swift +++ b/Sources/eWiz/TriggerSettings.swift @@ -69,7 +69,9 @@ struct TriggerLiveStateView: View { Divider().padding(.leading, 12) row("USB devices", list(s.usbDevices, empty: "None")) Divider().padding(.leading, 12) - row("Bluetooth", list(s.bluetoothDevices, empty: "None connected")) + row("Bluetooth", store.observesBluetooth + ? list(s.bluetoothDevices, empty: "None connected") + : "Read when a rule uses it (needs Bluetooth access)") Divider().padding(.leading, 12) row("Volumes", list(s.externalVolumes, empty: "None mounted")) Divider().padding(.leading, 12) diff --git a/Sources/eWiz/TriggerStore.swift b/Sources/eWiz/TriggerStore.swift index c6c2332..32a0d8c 100644 --- a/Sources/eWiz/TriggerStore.swift +++ b/Sources/eWiz/TriggerStore.swift @@ -114,11 +114,23 @@ final class TriggerStore: ObservableObject { /// Sensors we need this tick: everything while a live view is open, otherwise /// only what the enabled rules actually reference. + /// + /// Bluetooth is the exception: it's read only when a rule asks for it. Listing paired + /// devices goes through IOBluetooth, which macOS gates behind a permission prompt, and + /// on grant it restarts the app. eWiz is ad-hoc signed, so TCC ties that grant to the + /// exact build and asks again after every update. Opening the Automation tab was + /// triggering all of that for a row nobody had set a rule on. private var kindsInUse: Set { - if viewers > 0 { return Set(TriggerKind.allCases) } - return Set(rules.filter(\.enabled).flatMap { $0.conditions.map(\.kind) }) + let ruleKinds = Set(rules.filter(\.enabled).flatMap { $0.conditions.map(\.kind) }) + guard viewers > 0 else { return ruleKinds } + var all = Set(TriggerKind.allCases) + if !ruleKinds.contains(.bluetoothDevice) { all.remove(.bluetoothDevice) } + return all } + /// Whether the live view is reading Bluetooth, so it can say why the row is blank. + var observesBluetooth: Bool { kindsInUse.contains(.bluetoothDevice) } + /// Start, retime, or stop the poll depending on what's needed right now. private func reconfigure() { timer?.invalidate() diff --git a/Sources/ewiz-mcp/main.swift b/Sources/ewiz-mcp/main.swift index c6af8dd..5feb5c4 100644 --- a/Sources/ewiz-mcp/main.swift +++ b/Sources/ewiz-mcp/main.swift @@ -17,12 +17,19 @@ let server = MCPServer(awake: awake, version: version) // A lid hold outlives this process, so it's handed back however the session ends: the // client closing stdin (below) or a signal (here). +// +// `awake` is bound to a local first. Top-level variables in main.swift are main-actor +// isolated under Swift 6, and reading one from the signal source's global queue tripped +// the runtime's isolation assertion: every SIGTERM from a client ending its session +// crashed the server (EXC_BREAKPOINT in dispatch_assert_queue) instead of releasing the +// hold. AgentAwake is Sendable and guards itself with a lock, so the local is safe anywhere. +let awakeForSignals = awake var signalSources: [DispatchSourceSignal] = [] for sig in [SIGTERM, SIGINT, SIGHUP] { signal(sig, SIG_IGN) let source = DispatchSource.makeSignalSource(signal: sig, queue: .global()) source.setEventHandler { - awake.shutdown() + awakeForSignals.shutdown() exit(0) } source.resume()