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); + } +}