From e1c6007f71e40dc8777da97c559bf2135434e24f Mon Sep 17 00:00:00 2001 From: Andre Popovitch Date: Tue, 13 Jan 2026 17:53:49 -0800 Subject: [PATCH] Add permission step to onboarding (#545) * Add permission step to onboarding * translations * minor ui * Update AccessibilityOnboarding.tsx * claude suggestions --------- Co-authored-by: CJ Pais --- src-tauri/src/commands/mod.rs | 34 ++ src-tauri/src/lib.rs | 8 +- src/App.tsx | 56 +++- src/bindings.ts | 12 + .../onboarding/AccessibilityOnboarding.tsx | 315 ++++++++++++++++++ src/components/onboarding/Onboarding.tsx | 3 +- src/components/onboarding/index.ts | 1 + src/i18n/locales/de/translation.json | 16 + src/i18n/locales/en/translation.json | 20 ++ src/i18n/locales/es/translation.json | 16 + src/i18n/locales/fr/translation.json | 16 + src/i18n/locales/it/translation.json | 16 + src/i18n/locales/ja/translation.json | 16 + src/i18n/locales/pl/translation.json | 16 + src/i18n/locales/pt/translation.json | 16 + src/i18n/locales/ru/translation.json | 16 + src/i18n/locales/uk/translation.json | 16 + src/i18n/locales/vi/translation.json | 16 + src/i18n/locales/zh/translation.json | 16 + src/stores/settingsStore.ts | 15 +- 20 files changed, 617 insertions(+), 23 deletions(-) create mode 100644 src/components/onboarding/AccessibilityOnboarding.tsx diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 803cf6f..43ca763 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -130,3 +130,37 @@ pub fn check_apple_intelligence_available() -> bool { false } } + +/// Try to initialize Enigo (keyboard/mouse simulation). +/// On macOS, this will return an error if accessibility permissions are not granted. +#[specta::specta] +#[tauri::command] +pub fn initialize_enigo(app: AppHandle) -> Result<(), String> { + use crate::input::EnigoState; + + // Check if already initialized + if app.try_state::().is_some() { + log::debug!("Enigo already initialized"); + return Ok(()); + } + + // Try to initialize + match EnigoState::new() { + Ok(enigo_state) => { + app.manage(enigo_state); + log::info!("Enigo initialized successfully after permission grant"); + Ok(()) + } + Err(e) => { + if cfg!(target_os = "macos") { + log::warn!( + "Failed to initialize Enigo: {} (accessibility permissions may not be granted)", + e + ); + } else { + log::warn!("Failed to initialize Enigo: {}", e); + } + Err(format!("Failed to initialize input system: {}", e)) + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6472167..5ecd91a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -110,9 +110,10 @@ fn show_main_window(app: &AppHandle) { } fn initialize_core_logic(app_handle: &AppHandle) { - // Initialize the input state (Enigo singleton for keyboard/mouse simulation) - let enigo_state = input::EnigoState::new().expect("Failed to initialize input state (Enigo)"); - app_handle.manage(enigo_state); + // Note: Enigo (keyboard/mouse simulation) is NOT initialized here. + // The frontend is responsible for calling the `initialize_enigo` command + // after onboarding completes. This avoids triggering permission dialogs + // on macOS before the user is ready. // Initialize the managers let recording_manager = Arc::new( @@ -275,6 +276,7 @@ pub fn run() { commands::open_log_dir, commands::open_app_data_dir, commands::check_apple_intelligence_available, + commands::initialize_enigo, commands::models::get_available_models, commands::models::get_model_info, commands::models::download_model, diff --git a/src/App.tsx b/src/App.tsx index c85db42..bfdf080 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,13 +1,16 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef } from "react"; import { Toaster } from "sonner"; import "./App.css"; import AccessibilityPermissions from "./components/AccessibilityPermissions"; import Footer from "./components/footer"; -import Onboarding from "./components/onboarding"; +import Onboarding, { AccessibilityOnboarding } from "./components/onboarding"; import { Sidebar, SidebarSection, SECTIONS_CONFIG } from "./components/Sidebar"; import { useSettings } from "./hooks/useSettings"; +import { useSettingsStore } from "./stores/settingsStore"; import { commands } from "@/bindings"; +type OnboardingStep = "accessibility" | "model" | "done"; + const renderSettingsContent = (section: SidebarSection) => { const ActiveComponent = SECTIONS_CONFIG[section]?.component || SECTIONS_CONFIG.general.component; @@ -15,15 +18,36 @@ const renderSettingsContent = (section: SidebarSection) => { }; function App() { - const [showOnboarding, setShowOnboarding] = useState(null); + const [onboardingStep, setOnboardingStep] = useState( + null, + ); const [currentSection, setCurrentSection] = useState("general"); const { settings, updateSetting } = useSettings(); + const refreshAudioDevices = useSettingsStore( + (state) => state.refreshAudioDevices, + ); + const refreshOutputDevices = useSettingsStore( + (state) => state.refreshOutputDevices, + ); + const hasCompletedPostOnboardingInit = useRef(false); useEffect(() => { checkOnboardingStatus(); }, []); + // Initialize Enigo and refresh audio devices when main app loads + useEffect(() => { + if (onboardingStep === "done" && !hasCompletedPostOnboardingInit.current) { + hasCompletedPostOnboardingInit.current = true; + commands.initializeEnigo().catch((e) => { + console.warn("Failed to initialize Enigo:", e); + }); + refreshAudioDevices(); + refreshOutputDevices(); + } + }, [onboardingStep, refreshAudioDevices, refreshOutputDevices]); + // Handle keyboard shortcuts for debug mode toggle useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -51,25 +75,39 @@ function App() { const checkOnboardingStatus = async () => { try { - // Always check if they have any models available + // Check if they have any models available const result = await commands.hasAnyModelsAvailable(); if (result.status === "ok") { - setShowOnboarding(!result.data); + // If they have models/downloads, they're done. Otherwise start permissions step. + setOnboardingStep(result.data ? "done" : "accessibility"); } else { - setShowOnboarding(true); + setOnboardingStep("accessibility"); } } catch (error) { console.error("Failed to check onboarding status:", error); - setShowOnboarding(true); + setOnboardingStep("accessibility"); } }; + const handleAccessibilityComplete = () => { + setOnboardingStep("model"); + }; + const handleModelSelected = () => { // Transition to main app - user has started a download - setShowOnboarding(false); + setOnboardingStep("done"); }; - if (showOnboarding) { + // Still checking onboarding status + if (onboardingStep === null) { + return null; + } + + if (onboardingStep === "accessibility") { + return ; + } + + if (onboardingStep === "model") { return ; } diff --git a/src/bindings.ts b/src/bindings.ts index 33ddb27..e7f1640 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -350,6 +350,18 @@ async openAppDataDir() : Promise> { async checkAppleIntelligenceAvailable() : Promise { return await TAURI_INVOKE("check_apple_intelligence_available"); }, +/** + * Try to initialize Enigo (keyboard/mouse simulation). + * On macOS, this will return an error if accessibility permissions are not granted. + */ +async initializeEnigo() : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("initialize_enigo") }; +} 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/onboarding/AccessibilityOnboarding.tsx b/src/components/onboarding/AccessibilityOnboarding.tsx new file mode 100644 index 0000000..056dd1c --- /dev/null +++ b/src/components/onboarding/AccessibilityOnboarding.tsx @@ -0,0 +1,315 @@ +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 + if (accessibilityGranted) { + try { + await commands.initializeEnigo(); + } catch (e) { + console.warn("Failed to initialize Enigo:", 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 when accessibility is granted + commands.initializeEnigo().catch((e) => { + console.warn("Failed to initialize Enigo:", 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; diff --git a/src/components/onboarding/Onboarding.tsx b/src/components/onboarding/Onboarding.tsx index 6222394..f88a9bd 100644 --- a/src/components/onboarding/Onboarding.tsx +++ b/src/components/onboarding/Onboarding.tsx @@ -74,8 +74,7 @@ const Onboarding: React.FC = ({ onModelSelected }) => { )} - {/*
*/} -
+
{availableModels .filter((model) => getRecommendedBadge(model.id)) .map((model) => ( diff --git a/src/components/onboarding/index.ts b/src/components/onboarding/index.ts index adfa58c..739efa5 100644 --- a/src/components/onboarding/index.ts +++ b/src/components/onboarding/index.ts @@ -1 +1,2 @@ export { default } from "./Onboarding"; +export { default as AccessibilityOnboarding } from "./AccessibilityOnboarding"; diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index 35f4226..10d6c2e 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -55,6 +55,22 @@ "errors": { "loadModels": "Verfügbare Modelle konnten nicht geladen werden", "downloadModel": "Modell konnte nicht heruntergeladen werden: {{error}}" + }, + "permissions": { + "title": "Berechtigungen erforderlich", + "description": "Handy benötigt einige Berechtigungen, um richtig zu funktionieren.", + "microphone": { + "title": "Mikrofonzugriff", + "description": "Erforderlich, um Ihre Stimme für die Transkription zu hören." + }, + "accessibility": { + "title": "Bedienungshilfen-Zugriff", + "description": "Erforderlich, um transkribierten Text in Ihre Anwendungen einzugeben." + }, + "grant": "Berechtigung erteilen", + "granted": "Erteilt", + "waiting": "Warten...", + "allGranted": "Alles bereit!" } }, "modelSelector": { diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 3e1fd32..70ecd49 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -55,6 +55,26 @@ "errors": { "loadModels": "Failed to load available models", "downloadModel": "Failed to download model: {{error}}" + }, + "permissions": { + "title": "Permissions Required", + "description": "Handy needs a couple of permissions to work properly.", + "microphone": { + "title": "Microphone Access", + "description": "Required to hear your voice for transcription." + }, + "accessibility": { + "title": "Accessibility Access", + "description": "Required to type transcribed text into your applications." + }, + "grant": "Grant Permission", + "granted": "Granted", + "waiting": "Waiting...", + "allGranted": "All set!", + "errors": { + "checkFailed": "Failed to check permissions. Please try again.", + "requestFailed": "Failed to request permission. Please try again." + } } }, "modelSelector": { diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index a62ce49..ec2d939 100644 --- a/src/i18n/locales/es/translation.json +++ b/src/i18n/locales/es/translation.json @@ -55,6 +55,22 @@ "errors": { "loadModels": "Error al cargar los modelos disponibles", "downloadModel": "Error al descargar el modelo: {{error}}" + }, + "permissions": { + "title": "Permisos Requeridos", + "description": "Handy necesita algunos permisos para funcionar correctamente.", + "microphone": { + "title": "Acceso al Micrófono", + "description": "Necesario para escuchar tu voz para la transcripción." + }, + "accessibility": { + "title": "Acceso de Accesibilidad", + "description": "Necesario para escribir el texto transcrito en tus aplicaciones." + }, + "grant": "Conceder Permiso", + "granted": "Concedido", + "waiting": "Esperando...", + "allGranted": "¡Todo listo!" } }, "modelSelector": { diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index 95bb53b..c3416a0 100644 --- a/src/i18n/locales/fr/translation.json +++ b/src/i18n/locales/fr/translation.json @@ -56,6 +56,22 @@ "errors": { "loadModels": "Échec du chargement des modèles disponibles", "downloadModel": "Échec du téléchargement du modèle : {{error}}" + }, + "permissions": { + "title": "Autorisations requises", + "description": "Handy a besoin de quelques autorisations pour fonctionner correctement.", + "microphone": { + "title": "Accès au microphone", + "description": "Nécessaire pour entendre votre voix pour la transcription." + }, + "accessibility": { + "title": "Accès à l'accessibilité", + "description": "Nécessaire pour taper le texte transcrit dans vos applications." + }, + "grant": "Accorder l'autorisation", + "granted": "Accordé", + "waiting": "En attente...", + "allGranted": "Tout est prêt !" } }, "modelSelector": { diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index 59dbb1b..fae468c 100644 --- a/src/i18n/locales/it/translation.json +++ b/src/i18n/locales/it/translation.json @@ -55,6 +55,22 @@ "errors": { "loadModels": "Errore di caricamento dei modelli disponibili", "downloadModel": "Errore nel download del modello: {{error}}" + }, + "permissions": { + "title": "Permessi Richiesti", + "description": "Handy ha bisogno di alcuni permessi per funzionare correttamente.", + "microphone": { + "title": "Accesso al Microfono", + "description": "Necessario per sentire la tua voce per la trascrizione." + }, + "accessibility": { + "title": "Accesso all'Accessibilità", + "description": "Necessario per digitare il testo trascritto nelle tue applicazioni." + }, + "grant": "Concedi Permesso", + "granted": "Concesso", + "waiting": "In attesa...", + "allGranted": "Tutto pronto!" } }, "modelSelector": { diff --git a/src/i18n/locales/ja/translation.json b/src/i18n/locales/ja/translation.json index c26342d..60b7d6e 100644 --- a/src/i18n/locales/ja/translation.json +++ b/src/i18n/locales/ja/translation.json @@ -55,6 +55,22 @@ "errors": { "loadModels": "利用可能なモデルの読み込みに失敗しました", "downloadModel": "モデルのダウンロードに失敗しました: {{error}}" + }, + "permissions": { + "title": "権限が必要です", + "description": "Handyが正常に動作するにはいくつかの権限が必要です。", + "microphone": { + "title": "マイクへのアクセス", + "description": "文字起こしのためにあなたの声を聞くために必要です。" + }, + "accessibility": { + "title": "アクセシビリティへのアクセス", + "description": "アプリケーションに文字起こしテキストを入力するために必要です。" + }, + "grant": "権限を許可", + "granted": "許可済み", + "waiting": "待機中...", + "allGranted": "準備完了!" } }, "modelSelector": { diff --git a/src/i18n/locales/pl/translation.json b/src/i18n/locales/pl/translation.json index 7127ef9..a5cefd5 100644 --- a/src/i18n/locales/pl/translation.json +++ b/src/i18n/locales/pl/translation.json @@ -55,6 +55,22 @@ "errors": { "loadModels": "Nie udało się wczytać dostępnych modeli", "downloadModel": "Nie udało się pobrać modelu: {{error}}" + }, + "permissions": { + "title": "Wymagane uprawnienia", + "description": "Handy potrzebuje kilku uprawnień, aby działać poprawnie.", + "microphone": { + "title": "Dostęp do mikrofonu", + "description": "Wymagany do słyszenia Twojego głosu w celu transkrypcji." + }, + "accessibility": { + "title": "Dostęp do dostępności", + "description": "Wymagany do wpisywania transkrybowanego tekstu w Twoich aplikacjach." + }, + "grant": "Przyznaj uprawnienie", + "granted": "Przyznano", + "waiting": "Oczekiwanie...", + "allGranted": "Wszystko gotowe!" } }, "modelSelector": { diff --git a/src/i18n/locales/pt/translation.json b/src/i18n/locales/pt/translation.json index d07ed9d..1b893cd 100644 --- a/src/i18n/locales/pt/translation.json +++ b/src/i18n/locales/pt/translation.json @@ -55,6 +55,22 @@ "errors": { "loadModels": "Falha ao carregar modelos disponíveis", "downloadModel": "Falha ao baixar modelo: {{error}}" + }, + "permissions": { + "title": "Permissões Necessárias", + "description": "O Handy precisa de algumas permissões para funcionar corretamente.", + "microphone": { + "title": "Acesso ao Microfone", + "description": "Necessário para ouvir sua voz para transcrição." + }, + "accessibility": { + "title": "Acesso à Acessibilidade", + "description": "Necessário para digitar o texto transcrito em seus aplicativos." + }, + "grant": "Conceder Permissão", + "granted": "Concedido", + "waiting": "Aguardando...", + "allGranted": "Tudo pronto!" } }, "modelSelector": { diff --git a/src/i18n/locales/ru/translation.json b/src/i18n/locales/ru/translation.json index bbf7f5d..ccc865d 100644 --- a/src/i18n/locales/ru/translation.json +++ b/src/i18n/locales/ru/translation.json @@ -55,6 +55,22 @@ "errors": { "loadModels": "Не удалось загрузить доступные модели.", "downloadModel": "Не удалось загрузить модель: {{error}}." + }, + "permissions": { + "title": "Требуются разрешения", + "description": "Handy требуются некоторые разрешения для корректной работы.", + "microphone": { + "title": "Доступ к микрофону", + "description": "Необходим для прослушивания вашего голоса для транскрипции." + }, + "accessibility": { + "title": "Доступ к универсальному доступу", + "description": "Необходим для ввода расшифрованного текста в ваши приложения." + }, + "grant": "Предоставить разрешение", + "granted": "Предоставлено", + "waiting": "Ожидание...", + "allGranted": "Всё готово!" } }, "modelSelector": { diff --git a/src/i18n/locales/uk/translation.json b/src/i18n/locales/uk/translation.json index ab14a8e..62e3d16 100644 --- a/src/i18n/locales/uk/translation.json +++ b/src/i18n/locales/uk/translation.json @@ -55,6 +55,22 @@ "errors": { "loadModels": "Не вдалося завантажити доступні моделі", "downloadModel": "Не вдалося завантажити модель: {{error}}" + }, + "permissions": { + "title": "Потрібні дозволи", + "description": "Handy потребує деяких дозволів для коректної роботи.", + "microphone": { + "title": "Доступ до мікрофона", + "description": "Потрібен для прослуховування вашого голосу для транскрипції." + }, + "accessibility": { + "title": "Доступ до доступності", + "description": "Потрібен для введення транскрибованого тексту у ваші додатки." + }, + "grant": "Надати дозвіл", + "granted": "Надано", + "waiting": "Очікування...", + "allGranted": "Все готово!" } }, "modelSelector": { diff --git a/src/i18n/locales/vi/translation.json b/src/i18n/locales/vi/translation.json index b49aa70..e3dbf2e 100644 --- a/src/i18n/locales/vi/translation.json +++ b/src/i18n/locales/vi/translation.json @@ -56,6 +56,22 @@ "errors": { "loadModels": "Không thể tải các mô hình có sẵn", "downloadModel": "Không thể tải mô hình: {{error}}" + }, + "permissions": { + "title": "Cần cấp quyền", + "description": "Handy cần một số quyền để hoạt động bình thường.", + "microphone": { + "title": "Quyền truy cập Micrô", + "description": "Cần thiết để nghe giọng nói của bạn để chuyển đổi." + }, + "accessibility": { + "title": "Quyền truy cập Trợ năng", + "description": "Cần thiết để nhập văn bản đã chuyển đổi vào các ứng dụng của bạn." + }, + "grant": "Cấp quyền", + "granted": "Đã cấp", + "waiting": "Đang chờ...", + "allGranted": "Hoàn tất!" } }, "modelSelector": { diff --git a/src/i18n/locales/zh/translation.json b/src/i18n/locales/zh/translation.json index 5fd24cd..f02b72f 100644 --- a/src/i18n/locales/zh/translation.json +++ b/src/i18n/locales/zh/translation.json @@ -55,6 +55,22 @@ "errors": { "loadModels": "无法加载可用模型", "downloadModel": "模型下载失败: {{error}}" + }, + "permissions": { + "title": "需要权限", + "description": "Handy 需要一些权限才能正常工作。", + "microphone": { + "title": "麦克风访问", + "description": "需要听取您的语音进行转录。" + }, + "accessibility": { + "title": "辅助功能访问", + "description": "需要将转录文本输入到您的应用程序中。" + }, + "grant": "授予权限", + "granted": "已授予", + "waiting": "等待中...", + "allGranted": "全部完成!" } }, "modelSelector": { diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index 3a7a231..d08135b 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -493,18 +493,15 @@ export const useSettingsStore = create()( // Initialize everything initialize: async () => { - const { - refreshSettings, - refreshAudioDevices, - refreshOutputDevices, - checkCustomSounds, - loadDefaultSettings, - } = get(); + const { refreshSettings, checkCustomSounds, loadDefaultSettings } = get(); + + // Note: Audio devices are NOT refreshed here. The frontend (App.tsx) + // is responsible for calling refreshAudioDevices/refreshOutputDevices + // after onboarding completes. This avoids triggering permission dialogs + // on macOS before the user is ready. await Promise.all([ loadDefaultSettings(), refreshSettings(), - refreshAudioDevices(), - refreshOutputDevices(), checkCustomSounds(), ]); },