Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Scripts/bundle-app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ cat > "$APP_DIR/Contents/Info.plist" <<PLIST
<key>CFBundleDisplayName</key><string>Optune</string>
<key>CFBundleExecutable</key><string>OptuneApp</string>
<key>CFBundleIdentifier</key><string>com.sanjays2402.optune</string>
<key>CFBundleVersion</key><string>0.5.0</string>
<key>CFBundleShortVersionString</key><string>0.5.0</string>
<key>CFBundleVersion</key><string>0.6.0</string>
<key>CFBundleShortVersionString</key><string>0.6.0</string>
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>LSMinimumSystemVersion</key><string>15.0</string>
Expand Down
109 changes: 109 additions & 0 deletions Sources/OptuneApp/AccessibilityChecker.swift
Original file line number Diff line number Diff line change
@@ -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."
}
}
22 changes: 22 additions & 0 deletions Sources/OptuneApp/RemapEngine.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Foundation
import CoreGraphics
import AppKit
import ApplicationServices
import OptuneCore

/// What action to fire when a diverted CID is pressed.
Expand Down Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions Sources/OptuneApp/SettingsWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
Expand All @@ -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 {
Expand Down
37 changes: 32 additions & 5 deletions Sources/OptuneApp/WelcomeWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
Expand All @@ -93,17 +94,43 @@ 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)
}
}
.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)
Expand Down