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:
parent
dfb72993b1
commit
e1c6007f71
20 changed files with 617 additions and 23 deletions
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
56
src/App.tsx
56
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<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} />;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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") };
|
||||
|
|
|
|||
315
src/components/onboarding/AccessibilityOnboarding.tsx
Normal file
315
src/components/onboarding/AccessibilityOnboarding.tsx
Normal 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;
|
||||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
export { default } from "./Onboarding";
|
||||
export { default as AccessibilityOnboarding } from "./AccessibilityOnboarding";
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -55,6 +55,22 @@
|
|||
"errors": {
|
||||
"loadModels": "利用可能なモデルの読み込みに失敗しました",
|
||||
"downloadModel": "モデルのダウンロードに失敗しました: {{error}}"
|
||||
},
|
||||
"permissions": {
|
||||
"title": "権限が必要です",
|
||||
"description": "Handyが正常に動作するにはいくつかの権限が必要です。",
|
||||
"microphone": {
|
||||
"title": "マイクへのアクセス",
|
||||
"description": "文字起こしのためにあなたの声を聞くために必要です。"
|
||||
},
|
||||
"accessibility": {
|
||||
"title": "アクセシビリティへのアクセス",
|
||||
"description": "アプリケーションに文字起こしテキストを入力するために必要です。"
|
||||
},
|
||||
"grant": "権限を許可",
|
||||
"granted": "許可済み",
|
||||
"waiting": "待機中...",
|
||||
"allGranted": "準備完了!"
|
||||
}
|
||||
},
|
||||
"modelSelector": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -55,6 +55,22 @@
|
|||
"errors": {
|
||||
"loadModels": "Не удалось загрузить доступные модели.",
|
||||
"downloadModel": "Не удалось загрузить модель: {{error}}."
|
||||
},
|
||||
"permissions": {
|
||||
"title": "Требуются разрешения",
|
||||
"description": "Handy требуются некоторые разрешения для корректной работы.",
|
||||
"microphone": {
|
||||
"title": "Доступ к микрофону",
|
||||
"description": "Необходим для прослушивания вашего голоса для транскрипции."
|
||||
},
|
||||
"accessibility": {
|
||||
"title": "Доступ к универсальному доступу",
|
||||
"description": "Необходим для ввода расшифрованного текста в ваши приложения."
|
||||
},
|
||||
"grant": "Предоставить разрешение",
|
||||
"granted": "Предоставлено",
|
||||
"waiting": "Ожидание...",
|
||||
"allGranted": "Всё готово!"
|
||||
}
|
||||
},
|
||||
"modelSelector": {
|
||||
|
|
|
|||
|
|
@ -55,6 +55,22 @@
|
|||
"errors": {
|
||||
"loadModels": "Не вдалося завантажити доступні моделі",
|
||||
"downloadModel": "Не вдалося завантажити модель: {{error}}"
|
||||
},
|
||||
"permissions": {
|
||||
"title": "Потрібні дозволи",
|
||||
"description": "Handy потребує деяких дозволів для коректної роботи.",
|
||||
"microphone": {
|
||||
"title": "Доступ до мікрофона",
|
||||
"description": "Потрібен для прослуховування вашого голосу для транскрипції."
|
||||
},
|
||||
"accessibility": {
|
||||
"title": "Доступ до доступності",
|
||||
"description": "Потрібен для введення транскрибованого тексту у ваші додатки."
|
||||
},
|
||||
"grant": "Надати дозвіл",
|
||||
"granted": "Надано",
|
||||
"waiting": "Очікування...",
|
||||
"allGranted": "Все готово!"
|
||||
}
|
||||
},
|
||||
"modelSelector": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -55,6 +55,22 @@
|
|||
"errors": {
|
||||
"loadModels": "无法加载可用模型",
|
||||
"downloadModel": "模型下载失败: {{error}}"
|
||||
},
|
||||
"permissions": {
|
||||
"title": "需要权限",
|
||||
"description": "Handy 需要一些权限才能正常工作。",
|
||||
"microphone": {
|
||||
"title": "麦克风访问",
|
||||
"description": "需要听取您的语音进行转录。"
|
||||
},
|
||||
"accessibility": {
|
||||
"title": "辅助功能访问",
|
||||
"description": "需要将转录文本输入到您的应用程序中。"
|
||||
},
|
||||
"grant": "授予权限",
|
||||
"granted": "已授予",
|
||||
"waiting": "等待中...",
|
||||
"allGranted": "全部完成!"
|
||||
}
|
||||
},
|
||||
"modelSelector": {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue