From 08c8407b0a679f941c529d8206381a138aa36ec4 Mon Sep 17 00:00:00 2001 From: 0xNyk <0xnykcd@googlemail.com> Date: Thu, 17 Sep 2026 16:56:18 +0700 Subject: [PATCH] fix(macos): recover from stale Accessibility grants Ad-hoc signed builds tie the Accessibility grant to the binary hash, so after a rebuild or update System Settings shows Dictx enabled while AXIsProcessTrusted reports false. Toggling the switch does not fix it. The trust prompt also appears only once, so repeat Grant clicks did nothing visible. - Grant buttons always open the Accessibility pane of System Settings. - If a grant has not registered after 8 seconds, onboarding and the settings card offer to reset the stale entry (tccutil reset for the app's own bundle id) and grant again. - New commands: open_accessibility_settings, reset_accessibility_permission. --- src-tauri/src/commands/mod.rs | 48 +++++++++++ src-tauri/src/lib.rs | 2 + src/bindings.ts | 30 +++++++ src/components/AccessibilityPermissions.tsx | 85 +++++++++++++++---- .../onboarding/AccessibilityOnboarding.tsx | 71 +++++++++++++++- src/i18n/locales/en/translation.json | 10 ++- src/utils/accessibilityPermission.ts | 39 +++++++++ 7 files changed, 261 insertions(+), 24 deletions(-) create mode 100644 src/utils/accessibilityPermission.ts diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index f23c35f..aac7277 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -216,6 +216,54 @@ pub fn initialize_enigo(app: AppHandle) -> Result<(), String> { } } +/// Open the Accessibility pane of System Settings (macOS only). +/// The system trust prompt only appears once per app, so the frontend opens +/// the pane directly whenever the user asks to grant access. +#[specta::specta] +#[tauri::command] +pub fn open_accessibility_settings() -> Result<(), String> { + #[cfg(target_os = "macos")] + { + std::process::Command::new("open") + .arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") + .status() + .map_err(|e| format!("Failed to open System Settings: {}", e))?; + } + Ok(()) +} + +/// Remove this app's Accessibility entry from the macOS privacy database. +/// +/// Builds that are not signed with a stable identity get a designated +/// requirement tied to the binary hash. After an update or rebuild, System +/// Settings still shows Dictx as enabled, but the grant belongs to the old +/// binary and the new one is not trusted. Toggling the switch does not fix +/// that; removing the stale entry and granting again does. +#[specta::specta] +#[tauri::command] +pub fn reset_accessibility_permission(app: AppHandle) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + let identifier = app.config().identifier.clone(); + let output = std::process::Command::new("/usr/bin/tccutil") + .args(["reset", "Accessibility", &identifier]) + .output() + .map_err(|e| format!("Failed to run tccutil: {}", e))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + log::warn!("tccutil reset Accessibility failed: {}", stderr.trim()); + return Err(format!( + "Failed to reset accessibility permission: {}", + stderr.trim() + )); + } + log::info!("Reset accessibility permission for {}", identifier); + } + #[cfg(not(target_os = "macos"))] + let _ = app; + Ok(()) +} + /// Marker state to track if shortcuts have been initialized. pub struct ShortcutsInitialized; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2efb459..89e8f58 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -354,6 +354,8 @@ pub fn run(cli_args: CliArgs) { commands::check_apple_intelligence_available, commands::initialize_enigo, commands::initialize_shortcuts, + commands::open_accessibility_settings, + commands::reset_accessibility_permission, commands::models::get_available_models, commands::models::get_model_info, commands::models::download_model, diff --git a/src/bindings.ts b/src/bindings.ts index 9a4cc62..a6d5588 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -537,6 +537,36 @@ async initializeShortcuts() : Promise> { else return { status: "error", error: e as any }; } }, +/** + * Open the Accessibility pane of System Settings (macOS only). + * The system trust prompt only appears once per app, so the frontend opens + * the pane directly whenever the user asks to grant access. + */ +async openAccessibilitySettings() : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("open_accessibility_settings") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +/** + * Remove this app's Accessibility entry from the macOS privacy database. + * + * Builds that are not signed with a stable identity get a designated + * requirement tied to the binary hash. After an update or rebuild, System + * Settings still shows Dictx as enabled, but the grant belongs to the old + * binary and the new one is not trusted. Toggling the switch does not fix + * that; removing the stale entry and granting again does. + */ +async resetAccessibilityPermission() : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("reset_accessibility_permission") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, async getAvailableModels() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("get_available_models") }; diff --git a/src/components/AccessibilityPermissions.tsx b/src/components/AccessibilityPermissions.tsx index ae9180c..957ff21 100644 --- a/src/components/AccessibilityPermissions.tsx +++ b/src/components/AccessibilityPermissions.tsx @@ -1,11 +1,14 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { type } from "@tauri-apps/plugin-os"; -import { - checkAccessibilityPermission, - requestAccessibilityPermission, -} from "tauri-plugin-macos-permissions-api"; +import { checkAccessibilityPermission } from "tauri-plugin-macos-permissions-api"; +import { toast } from "sonner"; import { commands } from "@/bindings"; +import { + STALE_GRANT_HINT_DELAY_MS, + openAccessibilityGrant, + resetAndRegrantAccessibility, +} from "@/utils/accessibilityPermission"; // Define permission state type type PermissionState = "request" | "verify" | "granted"; @@ -21,6 +24,10 @@ const AccessibilityPermissions: React.FC = () => { const [hasAccessibility, setHasAccessibility] = useState(false); const [permissionState, setPermissionState] = useState("request"); + const [showStaleHint, setShowStaleHint] = useState(false); + const staleHintTimeoutRef = useRef | null>( + null, + ); // Accessibility permissions are only required on macOS const isMacOS = type() === "macos"; @@ -59,20 +66,48 @@ const AccessibilityPermissions: React.FC = () => { }, []); // Handle the unified button action based on current state - const handleButtonClick = async (): Promise => { - if (permissionState === "request") { - try { - await requestAccessibilityPermission(); - // After system prompt, transition to verification state - setPermissionState("verify"); - } catch (error) { - console.error("Error requesting permissions:", error); - setPermissionState("verify"); + // Offer a reset only if a grant attempt has not registered after a while + const scheduleStaleHint = () => { + setShowStaleHint(false); + if (staleHintTimeoutRef.current) { + clearTimeout(staleHintTimeoutRef.current); + } + staleHintTimeoutRef.current = setTimeout( + () => setShowStaleHint(true), + STALE_GRANT_HINT_DELAY_MS, + ); + }; + + useEffect(() => { + return () => { + if (staleHintTimeoutRef.current) { + clearTimeout(staleHintTimeoutRef.current); } - } else if (permissionState === "verify") { - // State is "verify" - check if permission was granted - await checkPermissions(); + }; + }, []); + + const handleButtonClick = async (): Promise => { + scheduleStaleHint(); + try { + // The system prompt only appears once, so always open System Settings + await openAccessibilityGrant(); + } catch (error) { + console.error("Error requesting permissions:", error); + } + setPermissionState("verify"); + await checkPermissions(); + }; + + // Clear a stale entry left by an older build, then grant again + const handleResetClick = async (): Promise => { + scheduleStaleHint(); + try { + await resetAndRegrantAccessibility(); + } catch (error) { + console.error("Error resetting accessibility permission:", error); + toast.error(t("onboarding.permissions.accessibility.resetFailed")); } + await checkPermissions(); }; // On app boot - check permissions (only on macOS) @@ -130,7 +165,9 @@ const AccessibilityPermissions: React.FC = () => { const statusClassName = hasAccessibility ? "bg-emerald-400/20 text-emerald-300 border-emerald-400/30" : "bg-amber-400/20 text-amber-200 border-amber-400/30"; - const statusText = hasAccessibility ? t("common.enabled") : t("common.disabled"); + const statusText = hasAccessibility + ? t("common.enabled") + : t("common.disabled"); return (
@@ -139,6 +176,18 @@ const AccessibilityPermissions: React.FC = () => {

{t("accessibility.permissionsDescription")}

+ {!hasAccessibility && showStaleHint && ( +

+ {t("accessibility.staleHint")}{" "} + +

+ )}
= ({ const timeoutRef = useRef | null>(null); const errorCountRef = useRef(0); const MAX_POLLING_ERRORS = 3; + const [showStaleHint, setShowStaleHint] = useState(false); + const [isResetting, setIsResetting] = useState(false); + const [grantAttempt, setGrantAttempt] = useState(0); const allGranted = permissions.accessibility === "granted" && @@ -173,6 +180,21 @@ const AccessibilityOnboarding: React.FC = ({ }, 1000); }, [onComplete, refreshAudioDevices, refreshOutputDevices, t]); + // An entry left by an older build keeps System Settings showing Dictx as + // enabled while macOS reports it as untrusted. If a grant attempt does not + // register within a few seconds, offer to reset that entry. + useEffect(() => { + if (permissions.accessibility !== "waiting") { + setShowStaleHint(false); + return; + } + const hintTimeout = setTimeout( + () => setShowStaleHint(true), + STALE_GRANT_HINT_DELAY_MS, + ); + return () => clearTimeout(hintTimeout); + }, [permissions.accessibility, grantAttempt]); + // Cleanup polling and timeouts on unmount useEffect(() => { return () => { @@ -187,7 +209,7 @@ const AccessibilityOnboarding: React.FC = ({ const handleGrantAccessibility = async () => { try { - await requestAccessibilityPermission(); + await openAccessibilityGrant(); setPermissions((prev) => ({ ...prev, accessibility: "waiting" })); startPolling(); } catch (error) { @@ -196,6 +218,23 @@ const AccessibilityOnboarding: React.FC = ({ } }; + const handleResetAccessibility = async () => { + setIsResetting(true); + try { + await resetAndRegrantAccessibility(); + // Restart the waiting period so the hint does not reappear immediately + setShowStaleHint(false); + setGrantAttempt((attempt) => attempt + 1); + setPermissions((prev) => ({ ...prev, accessibility: "waiting" })); + startPolling(); + } catch (error) { + console.error("Failed to reset accessibility permission:", error); + toast.error(t("onboarding.permissions.accessibility.resetFailed")); + } finally { + setIsResetting(false); + } + }; + const handleGrantMicrophone = async () => { try { await requestMicrophonePermission(); @@ -372,9 +411,33 @@ const AccessibilityOnboarding: React.FC = ({
+ {/* Stale accessibility entry recovery */} + {showStaleHint && permissions.accessibility === "waiting" && ( +
+

+ {t("onboarding.permissions.accessibility.staleTitle")} +

+

+ {t("onboarding.permissions.accessibility.staleDescription")} +

+ +
+ )} + {/* Restart hint */} - {(permissions.accessibility === "waiting" || - permissions.microphone === "waiting") && ( + {permissions.microphone === "waiting" && (

{t("onboarding.permissions.restartHint")}

diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 6513c75..ecfb556 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -112,7 +112,11 @@ }, "accessibility": { "title": "Accessibility Access", - "description": "Required to type transcribed text into your applications." + "description": "Required to type transcribed text into your applications.", + "staleTitle": "Still not detected?", + "staleDescription": "If Dictx is already on in System Settings, that entry belongs to an older build of the app. Reset it, then turn Dictx on again.", + "reset": "Reset and grant again", + "resetFailed": "Could not reset the permission. In System Settings, select Dictx, remove it with the minus button, then add it again." }, "grant": "Grant Permission", "granted": "Granted", @@ -604,7 +608,9 @@ "permissionsRequired": "Accessibility Permissions Required", "permissionsDescription": "Dictx needs accessibility permissions to type transcribed text.", "openSettings": "Open System Settings", - "dismiss": "Dismiss" + "dismiss": "Dismiss", + "staleHint": "Already on in System Settings? That entry may be from an older build.", + "reset": "Reset permission" }, "errors": { "loadDirectory": "Error loading directory: {{error}}" diff --git a/src/utils/accessibilityPermission.ts b/src/utils/accessibilityPermission.ts new file mode 100644 index 0000000..2b08c83 --- /dev/null +++ b/src/utils/accessibilityPermission.ts @@ -0,0 +1,39 @@ +import { requestAccessibilityPermission } from "tauri-plugin-macos-permissions-api"; +import { commands } from "@/bindings"; + +/** + * How long to wait after the user starts granting access before suggesting + * that the System Settings entry may be stale. + */ +export const STALE_GRANT_HINT_DELAY_MS = 8000; + +/** + * Ask macOS for Accessibility access and open the matching System Settings + * pane. The system prompt is shown only once per app, so opening the pane is + * what makes repeat clicks do something visible. + */ +export async function openAccessibilityGrant(): Promise { + try { + await requestAccessibilityPermission(); + } catch (error) { + // Still open System Settings so the user has a way forward + console.warn("Accessibility prompt failed:", error); + } + const result = await commands.openAccessibilitySettings(); + if (result.status === "error") { + throw new Error(result.error); + } +} + +/** + * Remove a stale Accessibility entry (left by an older build of Dictx) and + * start the grant flow again. + */ +export async function resetAndRegrantAccessibility(): Promise { + const result = await commands.resetAccessibilityPermission(); + // Open System Settings either way; on failure the user removes the entry there + await openAccessibilityGrant(); + if (result.status === "error") { + throw new Error(result.error); + } +}