From c88e4b8553da2f2a3fd3bd044d536f1a412b2755 Mon Sep 17 00:00:00 2001 From: Sanjays2402 <51058514+Sanjays2402@users.noreply.github.com> Date: Sat, 9 May 2026 17:07:16 -0700 Subject: [PATCH] fix(remap): wire Accessibility (TCC) gate so button remap actually fires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The button-remap engine in #8 dispatched actions with CGEvent.post(), which silently drops every event when the process isn't trusted under System Settings → Privacy & Security → Accessibility. There was no AX check and no UI signal — the toggle in the remap menu would persist, the firmware would correctly divert the button, but pressing it did nothing because macOS dropped the synthesized keystroke into the void. Worse: WelcomeWindow.swift literally said "Optune does not require accessibility" — a leftover from before the remap engine landed. Worst: macOS keys Accessibility grants on the binary's signature/path. Every brew upgrade or rebuild invalidates the prior grant while the toggle in the System Settings list visibly stays on, leaving the user thinking they've granted permission when they haven't. Changes: - Add AccessibilityChecker singleton (@MainActor ObservableObject) that polls AXIsProcessTrusted() on a 2s timer, stops polling once trusted, and exposes requestPrompt() / openSettingsPane() helpers. - Gate every CGEvent-dispatching action in RemapEngine on AXIsProcessTrusted(); if denied, refresh the checker and pop the system prompt instead of silently no-op'ing. - Replace the lying Welcome 'permissions' page: now lists Input Monitoring AND Accessibility as separate rows, with live trust status, a Grant button that triggers the prompt, and a tip about re-adding after updates. - Add an Accessibility nag banner at the top of ButtonsPane when remap bindings exist but the trust grant is missing, so the user sees the exact recovery path right where they're about to be confused. - Bump CFBundleVersion to 0.6.0 in bundle-app.sh. --- Scripts/bundle-app.sh | 4 +- Sources/OptuneApp/AccessibilityChecker.swift | 109 +++++++++++++++++++ Sources/OptuneApp/RemapEngine.swift | 22 ++++ Sources/OptuneApp/SettingsWindow.swift | 61 +++++++++++ Sources/OptuneApp/WelcomeWindow.swift | 37 ++++++- 5 files changed, 226 insertions(+), 7 deletions(-) create mode 100644 Sources/OptuneApp/AccessibilityChecker.swift diff --git a/Scripts/bundle-app.sh b/Scripts/bundle-app.sh index 4957086..e60ad97 100755 --- a/Scripts/bundle-app.sh +++ b/Scripts/bundle-app.sh @@ -46,8 +46,8 @@ cat > "$APP_DIR/Contents/Info.plist" <CFBundleDisplayNameOptune CFBundleExecutableOptuneApp CFBundleIdentifiercom.sanjays2402.optune - CFBundleVersion0.5.0 - CFBundleShortVersionString0.5.0 + CFBundleVersion0.6.0 + CFBundleShortVersionString0.6.0 CFBundleInfoDictionaryVersion6.0 CFBundlePackageTypeAPPL LSMinimumSystemVersion15.0 diff --git a/Sources/OptuneApp/AccessibilityChecker.swift b/Sources/OptuneApp/AccessibilityChecker.swift new file mode 100644 index 0000000..8272bcc --- /dev/null +++ b/Sources/OptuneApp/AccessibilityChecker.swift @@ -0,0 +1,109 @@ +import AppKit +import ApplicationServices +import Combine +import Foundation + +/// Live observer of the macOS Accessibility (TCC) trust state. +/// +/// **Why this exists**: `CGEvent.post` is the only API available to a non-signed +/// menu-bar agent for synthesizing keystrokes / mouse events. macOS silently +/// drops every event a process tries to post unless that process has been +/// explicitly granted Accessibility under System Settings → Privacy & Security +/// → Accessibility. There's no error, no return code, no log — events just +/// vanish into the void. So the only way to surface "your remap isn't working" +/// is to poll `AXIsProcessTrusted()` ourselves and gate the UI on it. +/// +/// **The TCC reset trap**: Apple's permission database keys grants on +/// `(bundleID, binary signature, binary path)`. The moment we ship a new +/// release the binary hash changes, the prior grant is invalidated, but the +/// row stays in the Accessibility list with the toggle visually *on*. The user +/// flips it off and back on — fine — but if they don't, the app appears +/// permitted and silently broken. We detect that case here and show a +/// "Re-grant required" banner. +/// +/// Owns a 2 s timer that keeps `isTrusted` in sync without blocking the main +/// actor. UI binds to `@Published` properties. +@MainActor +final class AccessibilityChecker: ObservableObject { + static let shared = AccessibilityChecker() + + /// Whether the OS currently trusts this process to post synthetic events. + @Published private(set) var isTrusted: Bool = AXIsProcessTrusted() + + /// Number of times the user has consciously dismissed the permission + /// nudge. Used to suppress repeat banners — once is enough until the next + /// time they actually try to fire a remap. + @Published var bannerDismissed: Bool = false + + private var timer: Timer? + + private init() { + startPolling() + } + + /// Begin a 2-second poll. Cheap — `AXIsProcessTrusted()` is a single + /// XPC round trip. Stops as soon as we go from `false → true` to avoid + /// burning a wakeup forever once permission is granted. + private func startPolling() { + timer?.invalidate() + timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in + Task { @MainActor in + guard let self else { return } + let trusted = AXIsProcessTrusted() + if trusted != self.isTrusted { + self.isTrusted = trusted + if trusted { + // Stop polling once granted — we'll resume only if the + // user revokes (the next refresh from any UI surface). + self.timer?.invalidate() + self.timer = nil + } + } + } + } + } + + /// Force an immediate re-check. Cheap, safe to call from any UI surface. + func refresh() { + isTrusted = AXIsProcessTrusted() + if !isTrusted && timer == nil { + startPolling() + } + } + + /// Trigger the system Accessibility prompt. macOS only shows the prompt + /// once per process lifetime — subsequent calls are no-ops. After the + /// user clicks "Open System Settings" in the prompt we open the pane + /// ourselves as a fallback. + func requestPrompt() { + // Use the literal key string instead of `kAXTrustedCheckOptionPrompt` + // — Swift 6 strict concurrency flags the global as non-Sendable, but + // the value is documented and stable: kAXTrustedCheckOptionPrompt = + // "AXTrustedCheckOptionPrompt". + let opts: NSDictionary = ["AXTrustedCheckOptionPrompt" as NSString: true] + _ = AXIsProcessTrustedWithOptions(opts) + // Fire-and-forget: regardless of whether the prompt sheet appears + // (it doesn't on subsequent calls), open the pane in 600 ms so the + // user always lands somewhere actionable. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { + self.openSettingsPane() + } + } + + /// Open the Accessibility list in System Settings. Works on macOS 13+ + /// via the `x-apple.systempreferences:` URL scheme. + func openSettingsPane() { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") { + NSWorkspace.shared.open(url) + } + } + + /// **TCC reset helper** — surface the "I granted it but it still doesn't + /// work" case. The fix is to remove and re-add Optune from the + /// Accessibility list (rebuilds the signature key). We can't do that from + /// in-process, but we can deep-link the user there and tell them what + /// to click. + var resetGuidanceText: String { + "If Optune is already listed in Accessibility but remap still doesn't fire, remove it (–) and add it back (+). macOS invalidates the grant whenever the app is updated." + } +} diff --git a/Sources/OptuneApp/RemapEngine.swift b/Sources/OptuneApp/RemapEngine.swift index dbb395e..fc651f7 100644 --- a/Sources/OptuneApp/RemapEngine.swift +++ b/Sources/OptuneApp/RemapEngine.swift @@ -1,6 +1,7 @@ import Foundation import CoreGraphics import AppKit +import ApplicationServices import OptuneCore /// What action to fire when a diverted CID is pressed. @@ -139,6 +140,27 @@ final class RemapEngine { } private func fire(action: RemapAction) { + // Gate every CGEvent action on Accessibility trust. Without it the + // call is a silent no-op and the user is left wondering why nothing + // happened. We don't block — we still attempt the post and the OS + // will drop it — but we flip a published flag so the UI can surface + // a banner. (`AXIsProcessTrusted()` is a fast XPC ping.) + let needsAX: Bool = { + switch action { + case .keystroke, .systemSwipe: return true + default: return false + } + }() + if needsAX, !AXIsProcessTrusted() { + // Bump the checker so any open settings/welcome surfaces refresh, + // and pop the system prompt (only fires once per process lifetime). + Task { @MainActor in + AccessibilityChecker.shared.refresh() + AccessibilityChecker.shared.requestPrompt() + } + return + } + switch action { case .none: return diff --git a/Sources/OptuneApp/SettingsWindow.swift b/Sources/OptuneApp/SettingsWindow.swift index ac7471e..29bd4e3 100644 --- a/Sources/OptuneApp/SettingsWindow.swift +++ b/Sources/OptuneApp/SettingsWindow.swift @@ -779,6 +779,7 @@ private struct SmartShiftControl: View { private struct ButtonsPane: View { @EnvironmentObject private var model: DeviceModel + @ObservedObject private var accessibility = AccessibilityChecker.shared var body: some View { VStack(alignment: .leading, spacing: OptuneDesign.Spacing.xl) { @@ -787,6 +788,16 @@ private struct ButtonsPane: View { subtitle: "Reprogrammable controls (HID++ 0x1B04). Pick a host action per button — the firmware diverts events to Optune over HID++ and we synthesize the action with CGEvent." ) + // Accessibility nag — only when remap actions exist and the trust + // grant is missing. macOS silently drops every CGEvent.post() the + // process tries to make until this is granted, so the engine + // appears to do nothing. Without this banner the user has no + // signal — the toggles in the menu still flip, the binding + // persists, but the button literally does nothing when pressed. + if !accessibility.isTrusted && hasRemaps { + accessibilityBanner + } + if case .ok(let controls) = model.telemetry.buttons { InsetGroup { ForEach(Array(controls.enumerated()), id: \.element.id) { idx, control in @@ -798,6 +809,56 @@ private struct ButtonsPane: View { emptyState } } + .onAppear { accessibility.refresh() } + } + + private var hasRemaps: Bool { + model.remapBindings.values.contains { $0 != .none } + } + + private var accessibilityBanner: some View { + HStack(alignment: .top, spacing: OptuneDesign.Spacing.md) { + ZStack { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.orange.opacity(0.18)) + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.orange) + } + .frame(width: 32, height: 32) + VStack(alignment: .leading, spacing: 4) { + Text("Accessibility permission needed") + .font(OptuneDesign.Typography.body.weight(.semibold)) + Text("Optune needs Accessibility to synthesize keystrokes and gestures. Without it your remap toggles save but pressing the button does nothing — macOS silently drops the events.") + .font(OptuneDesign.Typography.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Text("If Optune is already in the list but it still doesn't fire, remove (–) and re-add (+) it. macOS invalidates the grant whenever the app is updated.") + .font(OptuneDesign.Typography.caption) + .foregroundStyle(.tertiary) + .padding(.top, 2) + .fixedSize(horizontal: false, vertical: true) + HStack(spacing: 8) { + Button("Grant Accessibility") { accessibility.requestPrompt() } + .buttonStyle(.borderedProminent) + .controlSize(.small) + Button("Open Settings") { accessibility.openSettingsPane() } + .buttonStyle(.bordered) + .controlSize(.small) + } + .padding(.top, 4) + } + Spacer(minLength: 0) + } + .padding(OptuneDesign.Spacing.md) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.orange.opacity(0.06)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(Color.orange.opacity(0.30), lineWidth: 0.5) + ) } private var emptyState: some View { diff --git a/Sources/OptuneApp/WelcomeWindow.swift b/Sources/OptuneApp/WelcomeWindow.swift index b579604..55cf8b1 100644 --- a/Sources/OptuneApp/WelcomeWindow.swift +++ b/Sources/OptuneApp/WelcomeWindow.swift @@ -7,6 +7,7 @@ import OptuneUI /// Sets `welcomeCompleted = true` on finish so it never reappears. struct WelcomeWindow: View { @EnvironmentObject private var model: DeviceModel + @ObservedObject private var accessibility = AccessibilityChecker.shared @State private var page: Int = 0 let onClose: () -> Void @@ -71,8 +72,8 @@ struct WelcomeWindow: View { } .frame(width: 48, height: 48) VStack(alignment: .leading, spacing: 2) { - Text("One permission to grant").font(.system(size: 15, weight: .semibold)) - Text("Required to talk to your mice and keyboards.") + Text("Two permissions to grant").font(.system(size: 15, weight: .semibold)) + Text("Required for full functionality. Without them, button remap silently no-ops.") .font(.system(size: 12)) .foregroundStyle(.secondary) } @@ -82,7 +83,7 @@ struct WelcomeWindow: View { InsetGroup { InsetRow( title: "Input Monitoring", - subtitle: "Lets Optune send HID++ commands to read battery and apply settings." + subtitle: "Talk to your mice/keyboards over HID++ — battery, DPI, gestures." ) { ZStack { RoundedRectangle(cornerRadius: 6, style: .continuous) @@ -93,7 +94,7 @@ struct WelcomeWindow: View { } .frame(width: 22, height: 22) } trailing: { - Button("Open Settings") { + Button("Open") { if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent") { NSWorkspace.shared.open(url) } @@ -101,9 +102,35 @@ struct WelcomeWindow: View { .buttonStyle(.bordered) .controlSize(.small) } + + GroupDivider() + + InsetRow( + title: "Accessibility", + subtitle: "Synthesize keystrokes / gestures for custom button remaps. macOS silently drops events without it." + ) { + ZStack { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(accessibility.isTrusted ? Color.green.opacity(0.14) : Color.orange.opacity(0.14)) + Image(systemName: accessibility.isTrusted ? "checkmark" : "hand.tap.fill") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(accessibility.isTrusted ? .green : .orange) + } + .frame(width: 22, height: 22) + } trailing: { + if accessibility.isTrusted { + Text("Granted") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.green) + } else { + Button("Grant") { accessibility.requestPrompt() } + .buttonStyle(.bordered) + .controlSize(.small) + } + } } - Text("Optune does not require accessibility, screen recording, or any other privileged scope. We never connect to the network except to look for new releases on github.com.") + Text("Tip: if button remap stops working after an update, remove **Optune** from System Settings → Privacy & Security → Accessibility and add it back. macOS invalidates Accessibility grants on every code-signature change.") .font(.system(size: 11)) .foregroundStyle(.secondary) .padding(.top, 4)