import { useEffect, useState, useCallback, useRef } from "react"; 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 HandyTextLogo from "../icons/HandyTextLogo"; import { Keyboard, Mic, Check, Loader2 } from "lucide-react"; interface AccessibilityOnboardingProps { onComplete: () => void; } type PermissionStatus = "checking" | "needed" | "waiting" | "granted"; interface PermissionsState { accessibility: PermissionStatus; microphone: PermissionStatus; } const AccessibilityOnboarding: React.FC = ({ onComplete, }) => { const { t } = useTranslation(); const refreshAudioDevices = useSettingsStore( (state) => state.refreshAudioDevices, ); const refreshOutputDevices = useSettingsStore( (state) => state.refreshOutputDevices, ); const [isMacOS, setIsMacOS] = useState(null); const [permissions, setPermissions] = useState({ accessibility: "checking", microphone: "checking", }); const pollingRef = useRef | null>(null); const timeoutRef = useRef | null>(null); const errorCountRef = useRef(0); const MAX_POLLING_ERRORS = 3; const allGranted = permissions.accessibility === "granted" && permissions.microphone === "granted"; // Check platform and permission status on mount useEffect(() => { const currentPlatform = platform(); const isMac = currentPlatform === "macos"; setIsMacOS(isMac); // Skip immediately on non-macOS - no permissions needed if (!isMac) { onComplete(); return; } // On macOS, check both permissions const checkInitial = async () => { try { const [accessibilityGranted, microphoneGranted] = await Promise.all([ checkAccessibilityPermission(), checkMicrophonePermission(), ]); // If accessibility is granted, initialize Enigo and shortcuts if (accessibilityGranted) { try { await Promise.all([ commands.initializeEnigo(), commands.initializeShortcuts(), ]); } catch (e) { console.warn("Failed to initialize after permission grant:", e); } } const newState: PermissionsState = { accessibility: accessibilityGranted ? "granted" : "needed", microphone: microphoneGranted ? "granted" : "needed", }; setPermissions(newState); // If both already granted, refresh audio devices and skip ahead if (accessibilityGranted && microphoneGranted) { await Promise.all([refreshAudioDevices(), refreshOutputDevices()]); timeoutRef.current = setTimeout(() => onComplete(), 300); } } catch (error) { console.error("Failed to check permissions:", error); toast.error(t("onboarding.permissions.errors.checkFailed")); setPermissions({ accessibility: "needed", microphone: "needed", }); } }; checkInitial(); }, [onComplete, refreshAudioDevices, refreshOutputDevices, t]); // Polling for permissions after user clicks a button const startPolling = useCallback(() => { if (pollingRef.current) return; pollingRef.current = setInterval(async () => { try { const [accessibilityGranted, microphoneGranted] = await Promise.all([ checkAccessibilityPermission(), checkMicrophonePermission(), ]); setPermissions((prev) => { const newState = { ...prev }; if (accessibilityGranted && prev.accessibility !== "granted") { newState.accessibility = "granted"; // Initialize Enigo and shortcuts when accessibility is granted Promise.all([ commands.initializeEnigo(), commands.initializeShortcuts(), ]).catch((e) => { console.warn("Failed to initialize after permission grant:", e); }); } if (microphoneGranted && prev.microphone !== "granted") { newState.microphone = "granted"; } return newState; }); // If both granted, stop polling, refresh audio devices, and proceed if (accessibilityGranted && microphoneGranted) { if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; } // Now that we have mic permission, refresh audio devices await Promise.all([refreshAudioDevices(), refreshOutputDevices()]); timeoutRef.current = setTimeout(() => onComplete(), 500); } // Reset error count on success errorCountRef.current = 0; } catch (error) { console.error("Error checking permissions:", error); errorCountRef.current += 1; if (errorCountRef.current >= MAX_POLLING_ERRORS) { // Stop polling after too many consecutive errors if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; } toast.error(t("onboarding.permissions.errors.checkFailed")); } } }, 1000); }, [onComplete, refreshAudioDevices, refreshOutputDevices, t]); // Cleanup polling and timeouts on unmount useEffect(() => { return () => { if (pollingRef.current) { clearInterval(pollingRef.current); } if (timeoutRef.current) { clearTimeout(timeoutRef.current); } }; }, []); const handleGrantAccessibility = async () => { try { await requestAccessibilityPermission(); setPermissions((prev) => ({ ...prev, accessibility: "waiting" })); startPolling(); } catch (error) { console.error("Failed to request accessibility permission:", error); toast.error(t("onboarding.permissions.errors.requestFailed")); } }; const handleGrantMicrophone = async () => { try { await requestMicrophonePermission(); setPermissions((prev) => ({ ...prev, microphone: "waiting" })); startPolling(); } catch (error) { console.error("Failed to request microphone permission:", error); toast.error(t("onboarding.permissions.errors.requestFailed")); } }; // Still checking platform/initial permissions if ( isMacOS === null || (permissions.accessibility === "checking" && permissions.microphone === "checking") ) { return (
); } // All permissions granted - show success briefly if (allGranted) { return (

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

); } // Show permissions request screen return (

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

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

{/* Microphone Permission Card */}

{t("onboarding.permissions.microphone.title")}

{t("onboarding.permissions.microphone.description")}

{permissions.microphone === "granted" ? (
{t("onboarding.permissions.granted")}
) : permissions.microphone === "waiting" ? (
{t("onboarding.permissions.waiting")}
) : ( )}
{/* Accessibility Permission Card */}

{t("onboarding.permissions.accessibility.title")}

{t("onboarding.permissions.accessibility.description")}

{permissions.accessibility === "granted" ? (
{t("onboarding.permissions.granted")}
) : permissions.accessibility === "waiting" ? (
{t("onboarding.permissions.waiting")}
) : ( )}
); }; export default AccessibilityOnboarding;