Add permission step to onboarding (#545)

* Add permission step to onboarding

* translations

* minor ui

* Update AccessibilityOnboarding.tsx

* claude suggestions

---------

Co-authored-by: CJ Pais <cj@cjpais.com>
This commit is contained in:
Andre Popovitch 2026-01-13 17:53:49 -08:00 committed by GitHub
parent dfb72993b1
commit e1c6007f71
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 617 additions and 23 deletions

View file

@ -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::<EnigoState>().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))
}
}
}

View file

@ -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,

View file

@ -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<boolean | null>(null);
const [onboardingStep, setOnboardingStep] = useState<OnboardingStep | null>(
null,
);
const [currentSection, setCurrentSection] =
useState<SidebarSection>("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 <AccessibilityOnboarding onComplete={handleAccessibilityComplete} />;
}
if (onboardingStep === "model") {
return <Onboarding onModelSelected={handleModelSelected} />;
}

View file

@ -350,6 +350,18 @@ async openAppDataDir() : Promise<Result<null, string>> {
async checkAppleIntelligenceAvailable() : Promise<boolean> {
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<Result<null, string>> {
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<Result<ModelInfo[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_available_models") };

View file

@ -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<AccessibilityOnboardingProps> = ({
onComplete,
}) => {
const { t } = useTranslation();
const refreshAudioDevices = useSettingsStore(
(state) => state.refreshAudioDevices,
);
const refreshOutputDevices = useSettingsStore(
(state) => state.refreshOutputDevices,
);
const [isMacOS, setIsMacOS] = useState<boolean | null>(null);
const [permissions, setPermissions] = useState<PermissionsState>({
accessibility: "checking",
microphone: "checking",
});
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const errorCountRef = useRef<number>(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 (
<div className="h-screen w-screen flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-text/50" />
</div>
);
}
// All permissions granted - show success briefly
if (allGranted) {
return (
<div className="h-screen w-screen flex flex-col items-center justify-center gap-4">
<div className="p-4 rounded-full bg-emerald-500/20">
<Check className="w-12 h-12 text-emerald-400" />
</div>
<p className="text-lg font-medium text-text">
{t("onboarding.permissions.allGranted")}
</p>
</div>
);
}
// Show permissions request screen
return (
<div className="h-screen w-screen flex flex-col p-6 gap-6 items-center justify-center">
<div className="flex flex-col items-center gap-2">
<HandyTextLogo width={200} />
</div>
<div className="max-w-md w-full flex flex-col items-center gap-4">
<div className="text-center mb-2">
<h2 className="text-xl font-semibold text-text mb-2">
{t("onboarding.permissions.title")}
</h2>
<p className="text-text/70">
{t("onboarding.permissions.description")}
</p>
</div>
{/* Microphone Permission Card */}
<div className="w-full p-4 rounded-lg bg-white/5 border border-mid-gray/20">
<div className="flex items-center gap-4">
<div className="p-3 rounded-full bg-logo-primary/20 shrink-0">
<Mic className="w-6 h-6 text-logo-primary" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-text">
{t("onboarding.permissions.microphone.title")}
</h3>
<p className="text-sm text-text/60 mb-3">
{t("onboarding.permissions.microphone.description")}
</p>
{permissions.microphone === "granted" ? (
<div className="flex items-center gap-2 text-emerald-400 text-sm">
<Check className="w-4 h-4" />
{t("onboarding.permissions.granted")}
</div>
) : permissions.microphone === "waiting" ? (
<div className="flex items-center gap-2 text-text/50 text-sm">
<Loader2 className="w-4 h-4 animate-spin" />
{t("onboarding.permissions.waiting")}
</div>
) : (
<button
onClick={handleGrantMicrophone}
className="px-4 py-2 rounded-lg bg-logo-primary hover:bg-logo-primary/90 text-white text-sm font-medium transition-colors"
>
{t("onboarding.permissions.grant")}
</button>
)}
</div>
</div>
</div>
{/* Accessibility Permission Card */}
<div className="w-full p-4 rounded-lg bg-white/5 border border-mid-gray/20">
<div className="flex items-center gap-4">
<div className="p-3 rounded-full bg-logo-primary/20 shrink-0">
<Keyboard className="w-6 h-6 text-logo-primary" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-text">
{t("onboarding.permissions.accessibility.title")}
</h3>
<p className="text-sm text-text/60 mb-3">
{t("onboarding.permissions.accessibility.description")}
</p>
{permissions.accessibility === "granted" ? (
<div className="flex items-center gap-2 text-emerald-400 text-sm">
<Check className="w-4 h-4" />
{t("onboarding.permissions.granted")}
</div>
) : permissions.accessibility === "waiting" ? (
<div className="flex items-center gap-2 text-text/50 text-sm">
<Loader2 className="w-4 h-4 animate-spin" />
{t("onboarding.permissions.waiting")}
</div>
) : (
<button
onClick={handleGrantAccessibility}
className="px-4 py-2 rounded-lg bg-logo-primary hover:bg-logo-primary/90 text-white text-sm font-medium transition-colors"
>
{t("onboarding.permissions.grant")}
</button>
)}
</div>
</div>
</div>
</div>
</div>
);
};
export default AccessibilityOnboarding;

View file

@ -74,8 +74,7 @@ const Onboarding: React.FC<OnboardingProps> = ({ onModelSelected }) => {
</div>
)}
{/*<div className="flex flex-col gap-4 bg-background-dark p-4 py-5 w-full rounded-2xl flex-1 overflow-y-auto min-h-0">*/}
<div className="flex flex-col gap-4 ">
<div className="flex flex-col gap-4 pb-6">
{availableModels
.filter((model) => getRecommendedBadge(model.id))
.map((model) => (

View file

@ -1 +1,2 @@
export { default } from "./Onboarding";
export { default as AccessibilityOnboarding } from "./AccessibilityOnboarding";

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -55,6 +55,22 @@
"errors": {
"loadModels": "利用可能なモデルの読み込みに失敗しました",
"downloadModel": "モデルのダウンロードに失敗しました: {{error}}"
},
"permissions": {
"title": "権限が必要です",
"description": "Handyが正常に動作するにはいくつかの権限が必要です。",
"microphone": {
"title": "マイクへのアクセス",
"description": "文字起こしのためにあなたの声を聞くために必要です。"
},
"accessibility": {
"title": "アクセシビリティへのアクセス",
"description": "アプリケーションに文字起こしテキストを入力するために必要です。"
},
"grant": "権限を許可",
"granted": "許可済み",
"waiting": "待機中...",
"allGranted": "準備完了!"
}
},
"modelSelector": {

View file

@ -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": {

View file

@ -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": {

View file

@ -55,6 +55,22 @@
"errors": {
"loadModels": "Не удалось загрузить доступные модели.",
"downloadModel": "Не удалось загрузить модель: {{error}}."
},
"permissions": {
"title": "Требуются разрешения",
"description": "Handy требуются некоторые разрешения для корректной работы.",
"microphone": {
"title": "Доступ к микрофону",
"description": "Необходим для прослушивания вашего голоса для транскрипции."
},
"accessibility": {
"title": "Доступ к универсальному доступу",
"description": "Необходим для ввода расшифрованного текста в ваши приложения."
},
"grant": "Предоставить разрешение",
"granted": "Предоставлено",
"waiting": "Ожидание...",
"allGranted": "Всё готово!"
}
},
"modelSelector": {

View file

@ -55,6 +55,22 @@
"errors": {
"loadModels": "Не вдалося завантажити доступні моделі",
"downloadModel": "Не вдалося завантажити модель: {{error}}"
},
"permissions": {
"title": "Потрібні дозволи",
"description": "Handy потребує деяких дозволів для коректної роботи.",
"microphone": {
"title": "Доступ до мікрофона",
"description": "Потрібен для прослуховування вашого голосу для транскрипції."
},
"accessibility": {
"title": "Доступ до доступності",
"description": "Потрібен для введення транскрибованого тексту у ваші додатки."
},
"grant": "Надати дозвіл",
"granted": "Надано",
"waiting": "Очікування...",
"allGranted": "Все готово!"
}
},
"modelSelector": {

View file

@ -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": {

View file

@ -55,6 +55,22 @@
"errors": {
"loadModels": "无法加载可用模型",
"downloadModel": "模型下载失败: {{error}}"
},
"permissions": {
"title": "需要权限",
"description": "Handy 需要一些权限才能正常工作。",
"microphone": {
"title": "麦克风访问",
"description": "需要听取您的语音进行转录。"
},
"accessibility": {
"title": "辅助功能访问",
"description": "需要将转录文本输入到您的应用程序中。"
},
"grant": "授予权限",
"granted": "已授予",
"waiting": "等待中...",
"allGranted": "全部完成!"
}
},
"modelSelector": {

View file

@ -493,18 +493,15 @@ export const useSettingsStore = create<SettingsStore>()(
// 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(),
]);
},