Skip to content
Open
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
48 changes: 48 additions & 0 deletions src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
30 changes: 30 additions & 0 deletions src/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,36 @@ async initializeShortcuts() : Promise<Result<null, string>> {
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<Result<null, string>> {
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<Result<null, string>> {
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<Result<ModelInfo[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_available_models") };
Expand Down
85 changes: 67 additions & 18 deletions src/components/AccessibilityPermissions.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -21,6 +24,10 @@ const AccessibilityPermissions: React.FC = () => {
const [hasAccessibility, setHasAccessibility] = useState<boolean>(false);
const [permissionState, setPermissionState] =
useState<PermissionState>("request");
const [showStaleHint, setShowStaleHint] = useState(false);
const staleHintTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);

// Accessibility permissions are only required on macOS
const isMacOS = type() === "macos";
Expand Down Expand Up @@ -59,20 +66,48 @@ const AccessibilityPermissions: React.FC = () => {
}, []);

// Handle the unified button action based on current state
const handleButtonClick = async (): Promise<void> => {
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<void> => {
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<void> => {
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)
Expand Down Expand Up @@ -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 (
<div className="p-4 w-full rounded-lg border border-mid-gray">
Expand All @@ -139,6 +176,18 @@ const AccessibilityPermissions: React.FC = () => {
<p className="text-sm font-medium">
{t("accessibility.permissionsDescription")}
</p>
{!hasAccessibility && showStaleHint && (
<p className="mt-1 text-xs text-text/60">
{t("accessibility.staleHint")}{" "}
<button
type="button"
onClick={handleResetClick}
className="underline hover:text-text cursor-pointer"
>
{t("accessibility.reset")}
</button>
</p>
)}
<div className="mt-2">
<span
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${statusClassName}`}
Expand Down
71 changes: 67 additions & 4 deletions src/components/onboarding/AccessibilityOnboarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@ import { useTranslation } from "react-i18next";
import { platform } from "@tauri-apps/plugin-os";
import {
checkAccessibilityPermission,
requestAccessibilityPermission,
checkMicrophonePermission,
requestMicrophonePermission,
} from "tauri-plugin-macos-permissions-api";
import { toast } from "sonner";
import { commands } from "@/bindings";
import { useSettingsStore } from "@/stores/settingsStore";
import {
STALE_GRANT_HINT_DELAY_MS,
openAccessibilityGrant,
resetAndRegrantAccessibility,
} from "@/utils/accessibilityPermission";
import DictxTextLogo from "../icons/DictxTextLogo";
import { Keyboard, Mic, Check, Loader2, RefreshCw } from "lucide-react";

Expand Down Expand Up @@ -45,6 +49,9 @@ const AccessibilityOnboarding: React.FC<AccessibilityOnboardingProps> = ({
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const errorCountRef = useRef<number>(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" &&
Expand Down Expand Up @@ -173,6 +180,21 @@ const AccessibilityOnboarding: React.FC<AccessibilityOnboardingProps> = ({
}, 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 () => {
Expand All @@ -187,7 +209,7 @@ const AccessibilityOnboarding: React.FC<AccessibilityOnboardingProps> = ({

const handleGrantAccessibility = async () => {
try {
await requestAccessibilityPermission();
await openAccessibilityGrant();
setPermissions((prev) => ({ ...prev, accessibility: "waiting" }));
startPolling();
} catch (error) {
Expand All @@ -196,6 +218,23 @@ const AccessibilityOnboarding: React.FC<AccessibilityOnboardingProps> = ({
}
};

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();
Expand Down Expand Up @@ -372,9 +411,33 @@ const AccessibilityOnboarding: React.FC<AccessibilityOnboardingProps> = ({
</div>
</div>

{/* Stale accessibility entry recovery */}
{showStaleHint && permissions.accessibility === "waiting" && (
<div className="w-full p-4 rounded-lg bg-amber-400/10 border border-amber-400/30 flex flex-col gap-2">
<h3 className="text-sm font-medium text-text">
{t("onboarding.permissions.accessibility.staleTitle")}
</h3>
<p className="text-xs text-text/70">
{t("onboarding.permissions.accessibility.staleDescription")}
</p>
<button
type="button"
onClick={handleResetAccessibility}
disabled={isResetting}
className="self-start flex items-center gap-1 px-3 py-1.5 rounded-lg bg-white/10 hover:bg-white/15 disabled:opacity-50 text-text text-xs font-medium transition-colors"
>
{isResetting ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<RefreshCw className="w-3 h-3" />
)}
{t("onboarding.permissions.accessibility.reset")}
</button>
</div>
)}

{/* Restart hint */}
{(permissions.accessibility === "waiting" ||
permissions.microphone === "waiting") && (
{permissions.microphone === "waiting" && (
<p className="text-text/40 text-xs text-center">
{t("onboarding.permissions.restartHint")}
</p>
Expand Down
10 changes: 8 additions & 2 deletions src/i18n/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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}}"
Expand Down
Loading
Loading