diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 43ca763..015c60b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -164,3 +164,28 @@ pub fn initialize_enigo(app: AppHandle) -> Result<(), String> { } } } + +/// Marker state to track if shortcuts have been initialized. +pub struct ShortcutsInitialized; + +/// Initialize keyboard shortcuts. +/// On macOS, this should be called after accessibility permissions are granted. +/// This is idempotent - calling it multiple times is safe. +#[specta::specta] +#[tauri::command] +pub fn initialize_shortcuts(app: AppHandle) -> Result<(), String> { + // Check if already initialized + if app.try_state::().is_some() { + log::debug!("Shortcuts already initialized"); + return Ok(()); + } + + // Initialize shortcuts + crate::shortcut::init_shortcuts(&app); + + // Mark as initialized + app.manage(ShortcutsInitialized); + + log::info!("Shortcuts initialized successfully"); + Ok(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 556dd3a..c80a8b9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -134,8 +134,10 @@ fn initialize_core_logic(app_handle: &AppHandle) { app_handle.manage(transcription_manager.clone()); app_handle.manage(history_manager.clone()); - // Initialize the shortcuts - shortcut::init_shortcuts(app_handle); + // Note: Shortcuts are NOT initialized here. + // The frontend is responsible for calling the `initialize_shortcuts` command + // after permissions are confirmed (on macOS) or after onboarding completes. + // This matches the pattern used for Enigo initialization. #[cfg(unix)] let signals = Signals::new(&[SIGUSR2]).unwrap(); @@ -285,6 +287,7 @@ pub fn run() { commands::open_app_data_dir, commands::check_apple_intelligence_available, commands::initialize_enigo, + commands::initialize_shortcuts, 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 bfdf080..56e4328 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,10 @@ import { useEffect, useState, useRef } from "react"; import { Toaster } from "sonner"; +import { platform } from "@tauri-apps/plugin-os"; +import { + checkAccessibilityPermission, + checkMicrophonePermission, +} from "tauri-plugin-macos-permissions-api"; import "./App.css"; import AccessibilityPermissions from "./components/AccessibilityPermissions"; import Footer from "./components/footer"; @@ -21,6 +26,9 @@ function App() { const [onboardingStep, setOnboardingStep] = useState( null, ); + // Track if this is a returning user who just needs to grant permissions + // (vs a new user who needs full onboarding including model selection) + const [isReturningUser, setIsReturningUser] = useState(false); const [currentSection, setCurrentSection] = useState("general"); const { settings, updateSetting } = useSettings(); @@ -36,12 +44,15 @@ function App() { checkOnboardingStatus(); }, []); - // Initialize Enigo and refresh audio devices when main app loads + // Initialize Enigo, shortcuts, 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); + Promise.all([ + commands.initializeEnigo(), + commands.initializeShortcuts(), + ]).catch((e) => { + console.warn("Failed to initialize:", e); }); refreshAudioDevices(); refreshOutputDevices(); @@ -77,10 +88,31 @@ function App() { try { // Check if they have any models available const result = await commands.hasAnyModelsAvailable(); - if (result.status === "ok") { - // If they have models/downloads, they're done. Otherwise start permissions step. - setOnboardingStep(result.data ? "done" : "accessibility"); + const hasModels = result.status === "ok" && result.data; + + if (hasModels) { + // Returning user - but check if they need to grant permissions on macOS + setIsReturningUser(true); + if (platform() === "macos") { + try { + const [hasAccessibility, hasMicrophone] = await Promise.all([ + checkAccessibilityPermission(), + checkMicrophonePermission(), + ]); + if (!hasAccessibility || !hasMicrophone) { + // Missing permissions - show accessibility onboarding + setOnboardingStep("accessibility"); + return; + } + } catch (e) { + console.warn("Failed to check permissions:", e); + // If we can't check, proceed to main app and let them fix it there + } + } + setOnboardingStep("done"); } else { + // New user - start full onboarding + setIsReturningUser(false); setOnboardingStep("accessibility"); } } catch (error) { @@ -90,7 +122,9 @@ function App() { }; const handleAccessibilityComplete = () => { - setOnboardingStep("model"); + // Returning users already have models, skip to main app + // New users need to select a model + setOnboardingStep(isReturningUser ? "done" : "model"); }; const handleModelSelected = () => { diff --git a/src/bindings.ts b/src/bindings.ts index 6b50126..e382486 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -412,6 +412,19 @@ async initializeEnigo() : Promise> { else return { status: "error", error: e as any }; } }, +/** + * Initialize keyboard shortcuts. + * On macOS, this should be called after accessibility permissions are granted. + * This is idempotent - calling it multiple times is safe. + */ +async initializeShortcuts() : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("initialize_shortcuts") }; +} 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 index 056dd1c..e4048fd 100644 --- a/src/components/onboarding/AccessibilityOnboarding.tsx +++ b/src/components/onboarding/AccessibilityOnboarding.tsx @@ -68,12 +68,15 @@ const AccessibilityOnboarding: React.FC = ({ checkMicrophonePermission(), ]); - // If accessibility is granted, initialize Enigo + // If accessibility is granted, initialize Enigo and shortcuts if (accessibilityGranted) { try { - await commands.initializeEnigo(); + await Promise.all([ + commands.initializeEnigo(), + commands.initializeShortcuts(), + ]); } catch (e) { - console.warn("Failed to initialize Enigo:", e); + console.warn("Failed to initialize after permission grant:", e); } } @@ -118,9 +121,12 @@ const AccessibilityOnboarding: React.FC = ({ 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); + // 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); }); }