From dfd445d4223201bee13fb53115c6a4fc67da2337 Mon Sep 17 00:00:00 2001 From: Fero Date: Fri, 13 Mar 2026 15:06:46 +0100 Subject: [PATCH] Add Windows microphone permission onboarding (#991) --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/commands/audio.rs | 115 ++++++- src-tauri/src/lib.rs | 67 +++- src/App.tsx | 37 ++- src/bindings.ts | 27 +- .../onboarding/AccessibilityOnboarding.tsx | 303 +++++++++++------- 7 files changed, 418 insertions(+), 133 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 77eb978..2b3fea8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2471,6 +2471,7 @@ dependencies = [ "transcribe-rs", "vad-rs", "windows 0.61.3", + "winreg 0.55.0", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 75b1aa4..d24a188 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -94,6 +94,7 @@ windows = { version = "0.61.3", features = [ "Win32_Foundation", "Win32_UI_WindowsAndMessaging", ] } +winreg = "0.55" [target.'cfg(target_os = "macos")'.dependencies] tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2.1" } diff --git a/src-tauri/src/commands/audio.rs b/src-tauri/src/commands/audio.rs index 38a5b7c..d649acb 100644 --- a/src-tauri/src/commands/audio.rs +++ b/src-tauri/src/commands/audio.rs @@ -5,9 +5,15 @@ use crate::settings::{get_settings, write_settings}; use log::warn; use serde::{Deserialize, Serialize}; use specta::Type; -use std::sync::Arc; +use std::{process::Command, sync::Arc}; use tauri::{AppHandle, Manager}; +#[cfg(target_os = "windows")] +use winreg::{ + enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE}, + RegKey, HKEY, +}; + #[derive(Serialize, Type)] pub struct CustomSounds { start: bool, @@ -35,6 +41,113 @@ pub struct AudioDevice { pub is_default: bool, } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Type)] +#[serde(rename_all = "snake_case")] +pub enum PermissionAccess { + Allowed, + Denied, + Unknown, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Type)] +pub struct WindowsMicrophonePermissionStatus { + pub supported: bool, + pub overall_access: PermissionAccess, + pub device_access: PermissionAccess, + pub app_access: PermissionAccess, + pub desktop_app_access: PermissionAccess, +} + +#[cfg(target_os = "windows")] +fn read_registry_permission_access(root_hkey: HKEY, path: &str) -> PermissionAccess { + let root = RegKey::predef(root_hkey); + let Ok(key) = root.open_subkey(path) else { + return PermissionAccess::Unknown; + }; + + let Ok(value) = key.get_value::("Value") else { + return PermissionAccess::Unknown; + }; + + match value.to_ascii_lowercase().as_str() { + "allow" => PermissionAccess::Allowed, + "deny" => PermissionAccess::Denied, + _ => PermissionAccess::Unknown, + } +} + +#[cfg(target_os = "windows")] +fn get_windows_microphone_permission_status_impl() -> WindowsMicrophonePermissionStatus { + const MICROPHONE_PATH: &str = + "Software\\Microsoft\\Windows\\CurrentVersion\\CapabilityAccessManager\\ConsentStore\\microphone"; + const DESKTOP_APPS_PATH: &str = + "Software\\Microsoft\\Windows\\CurrentVersion\\CapabilityAccessManager\\ConsentStore\\microphone\\NonPackaged"; + + let device_access = read_registry_permission_access(HKEY_LOCAL_MACHINE, MICROPHONE_PATH); + let app_access = read_registry_permission_access(HKEY_CURRENT_USER, MICROPHONE_PATH); + let desktop_app_access = read_registry_permission_access(HKEY_CURRENT_USER, DESKTOP_APPS_PATH); + + let overall_access = if [device_access, app_access, desktop_app_access] + .into_iter() + .any(|access| access == PermissionAccess::Denied) + { + PermissionAccess::Denied + } else if [device_access, app_access, desktop_app_access] + .into_iter() + .all(|access| access == PermissionAccess::Allowed) + { + PermissionAccess::Allowed + } else { + PermissionAccess::Unknown + }; + + WindowsMicrophonePermissionStatus { + supported: true, + overall_access, + device_access, + app_access, + desktop_app_access, + } +} + +#[tauri::command] +#[specta::specta] +pub fn get_windows_microphone_permission_status() -> WindowsMicrophonePermissionStatus { + #[cfg(target_os = "windows")] + { + get_windows_microphone_permission_status_impl() + } + + #[cfg(not(target_os = "windows"))] + { + WindowsMicrophonePermissionStatus { + supported: false, + overall_access: PermissionAccess::Unknown, + device_access: PermissionAccess::Unknown, + app_access: PermissionAccess::Unknown, + desktop_app_access: PermissionAccess::Unknown, + } + } +} + +#[tauri::command] +#[specta::specta] +pub fn open_microphone_privacy_settings() -> Result<(), String> { + #[cfg(target_os = "windows")] + { + Command::new("cmd") + .args(["/C", "start", "", "ms-settings:privacy-microphone"]) + .spawn() + .map_err(|e| format!("Failed to open Windows microphone privacy settings: {}", e))?; + return Ok(()); + } + + #[cfg(not(target_os = "windows"))] + { + Err("Opening microphone privacy settings is only supported on Windows".to_string()) + } +} + #[tauri::command] #[specta::specta] pub fn update_microphone_mode(app: AppHandle, always_on: bool) -> Result<(), String> { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a9de79d..8ee0744 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -85,24 +85,54 @@ fn build_console_filter() -> env_filter::Filter { fn show_main_window(app: &AppHandle) { if let Some(main_window) = app.get_webview_window("main") { - // First, ensure the window is visible + if let Err(e) = main_window.unminimize() { + log::error!("Failed to unminimize webview window: {}", e); + } if let Err(e) = main_window.show() { - log::error!("Failed to show window: {}", e); + log::error!("Failed to show webview window: {}", e); } - // Then, bring it to the front and give it focus if let Err(e) = main_window.set_focus() { - log::error!("Failed to focus window: {}", e); + log::error!("Failed to focus webview window: {}", e); } - // Optional: On macOS, ensure the app becomes active if it was an accessory #[cfg(target_os = "macos")] { if let Err(e) = app.set_activation_policy(tauri::ActivationPolicy::Regular) { log::error!("Failed to set activation policy to Regular: {}", e); } } - } else { - log::error!("Main window not found."); + return; } + + let webview_labels = app.webview_windows().keys().cloned().collect::>(); + log::error!( + "Main window not found. Webview labels: {:?}", + webview_labels + ); +} + +fn should_force_show_permissions_window(app: &AppHandle) -> bool { + #[cfg(target_os = "windows")] + { + let model_manager = app.state::>(); + let has_downloaded_models = model_manager + .get_available_models() + .iter() + .any(|model| model.is_downloaded); + + if !has_downloaded_models { + return false; + } + + let status = commands::audio::get_windows_microphone_permission_status(); + if status.supported && status.overall_access == commands::audio::PermissionAccess::Denied { + log::info!( + "Windows microphone permissions are denied; forcing main window visible for onboarding" + ); + return true; + } + } + + false } fn initialize_core_logic(app_handle: &AppHandle) { @@ -251,6 +281,13 @@ fn trigger_update_check(app: AppHandle) -> Result<(), String> { Ok(()) } +#[tauri::command] +#[specta::specta] +fn show_main_window_command(app: AppHandle) -> Result<(), String> { + show_main_window(&app); + Ok(()) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run(cli_args: CliArgs) { // Detect portable mode before anything else @@ -305,6 +342,7 @@ pub fn run(cli_args: CliArgs) { shortcut::handy_keys::start_handy_keys_recording, shortcut::handy_keys::stop_handy_keys_recording, trigger_update_check, + show_main_window_command, commands::cancel_operation, commands::get_app_dir_path, commands::get_app_settings, @@ -330,6 +368,8 @@ pub fn run(cli_args: CliArgs) { commands::models::has_any_models_or_downloads, commands::audio::update_microphone_mode, commands::audio::get_microphone_mode, + commands::audio::get_windows_microphone_permission_status, + commands::audio::open_microphone_privacy_settings, commands::audio::get_available_microphones, commands::audio::set_selected_microphone, commands::audio::get_selected_microphone, @@ -466,18 +506,17 @@ pub fn run(cli_args: CliArgs) { tray::set_tray_visibility(&app_handle, false); } - // Show main window only if not starting hidden - // CLI --start-hidden flag overrides the setting + // Show main window only if not starting hidden. + // CLI --start-hidden flag overrides the setting. + // But if permission onboarding is required, always show the window. let should_hide = settings.start_hidden || cli_args.start_hidden; + let should_force_show = should_force_show_permissions_window(&app_handle); // If start_hidden but tray is disabled, we must show the window // anyway. Without a tray icon, the dock is the only way back in. let tray_available = settings.show_tray_icon && !cli_args.no_tray; - if !should_hide || !tray_available { - if let Some(main_window) = app_handle.get_webview_window("main") { - main_window.show().unwrap(); - main_window.set_focus().unwrap(); - } + if should_force_show || !should_hide || !tray_available { + show_main_window(&app_handle); } Ok(()) diff --git a/src/App.tsx b/src/App.tsx index b805105..9ff3c78 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -125,31 +125,60 @@ function App() { }; }, [t]); + const revealMainWindowForPermissions = async () => { + try { + await commands.showMainWindowCommand(); + } catch (e) { + console.warn("Failed to show main window for permission onboarding:", e); + } + }; + const checkOnboardingStatus = async () => { try { // Check if they have any models available const result = await commands.hasAnyModelsAvailable(); const hasModels = result.status === "ok" && result.data; + const currentPlatform = platform(); if (hasModels) { - // Returning user - but check if they need to grant permissions on macOS + // Returning user - check if they need to grant permissions first setIsReturningUser(true); - if (platform() === "macos") { + + if (currentPlatform === "macos") { try { const [hasAccessibility, hasMicrophone] = await Promise.all([ checkAccessibilityPermission(), checkMicrophonePermission(), ]); if (!hasAccessibility || !hasMicrophone) { - // Missing permissions - show accessibility onboarding + await revealMainWindowForPermissions(); setOnboardingStep("accessibility"); return; } } catch (e) { - console.warn("Failed to check permissions:", e); + console.warn("Failed to check macOS permissions:", e); // If we can't check, proceed to main app and let them fix it there } } + + if (currentPlatform === "windows") { + try { + const microphoneStatus = + await commands.getWindowsMicrophonePermissionStatus(); + if ( + microphoneStatus.supported && + microphoneStatus.overall_access === "denied" + ) { + await revealMainWindowForPermissions(); + setOnboardingStep("accessibility"); + return; + } + } catch (e) { + console.warn("Failed to check Windows microphone 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 diff --git a/src/bindings.ts b/src/bindings.ts index b4d1bbc..da19403 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -369,6 +369,14 @@ async triggerUpdateCheck() : Promise> { else return { status: "error", error: e as any }; } }, +async showMainWindowCommand() : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("show_main_window_command") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, async cancelOperation() : Promise { await TAURI_INVOKE("cancel_operation"); }, @@ -572,6 +580,17 @@ async getMicrophoneMode() : Promise> { else return { status: "error", error: e as any }; } }, +async getWindowsMicrophonePermissionStatus() : Promise { + return await TAURI_INVOKE("get_windows_microphone_permission_status"); +}, +async openMicrophonePrivacySettings() : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("open_microphone_privacy_settings") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, async getAvailableMicrophones() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("get_available_microphones") }; @@ -713,10 +732,8 @@ async updateRecordingRetentionPeriod(period: string) : Promise> { try { @@ -762,11 +779,13 @@ export type ModelLoadStatus = { is_loaded: boolean; current_model: string | null export type ModelUnloadTimeout = "never" | "immediately" | "min_2" | "min_5" | "min_10" | "min_15" | "hour_1" | "sec_5" export type OverlayPosition = "none" | "top" | "bottom" export type PasteMethod = "ctrl_v" | "direct" | "none" | "shift_insert" | "ctrl_shift_v" | "external_script" +export type PermissionAccess = "allowed" | "denied" | "unknown" export type PostProcessProvider = { id: string; label: string; base_url: string; allow_base_url_edit?: boolean; models_endpoint?: string | null; supports_structured_output?: boolean } export type RecordingRetentionPeriod = "never" | "preserve_limit" | "days_3" | "weeks_2" | "months_3" export type ShortcutBinding = { id: string; name: string; description: string; default_binding: string; current_binding: string } export type SoundTheme = "marimba" | "pop" | "custom" export type TypingTool = "auto" | "wtype" | "kwtype" | "dotool" | "ydotool" | "xdotool" +export type WindowsMicrophonePermissionStatus = { supported: boolean; overall_access: PermissionAccess; device_access: PermissionAccess; app_access: PermissionAccess; desktop_app_access: PermissionAccess } /** tauri-specta globals **/ diff --git a/src/components/onboarding/AccessibilityOnboarding.tsx b/src/components/onboarding/AccessibilityOnboarding.tsx index e4048fd..7a35603 100644 --- a/src/components/onboarding/AccessibilityOnboarding.tsx +++ b/src/components/onboarding/AccessibilityOnboarding.tsx @@ -18,6 +18,7 @@ interface AccessibilityOnboardingProps { } type PermissionStatus = "checking" | "needed" | "waiting" | "granted"; +type PermissionPlatform = "macos" | "windows" | "other"; interface PermissionsState { accessibility: PermissionStatus; @@ -34,7 +35,8 @@ const AccessibilityOnboarding: React.FC = ({ const refreshOutputDevices = useSettingsStore( (state) => state.refreshOutputDevices, ); - const [isMacOS, setIsMacOS] = useState(null); + const [permissionPlatform, setPermissionPlatform] = + useState(null); const [permissions, setPermissions] = useState({ accessibility: "checking", microphone: "checking", @@ -44,73 +46,142 @@ const AccessibilityOnboarding: React.FC = ({ const errorCountRef = useRef(0); const MAX_POLLING_ERRORS = 3; - const allGranted = - permissions.accessibility === "granted" && - permissions.microphone === "granted"; + const isMacOS = permissionPlatform === "macos"; + const isWindows = permissionPlatform === "windows"; + const showMicrophonePermission = isMacOS || isWindows; + const showAccessibilityPermission = isMacOS; + + const allGranted = isMacOS + ? permissions.accessibility === "granted" && + permissions.microphone === "granted" + : isWindows + ? permissions.microphone === "granted" + : true; + + const completeOnboarding = useCallback(async () => { + await Promise.all([refreshAudioDevices(), refreshOutputDevices()]); + timeoutRef.current = setTimeout(() => onComplete(), 300); + }, [onComplete, refreshAudioDevices, refreshOutputDevices]); + + const hasWindowsMicrophoneAccess = useCallback(async (): Promise => { + const microphoneStatus = + await commands.getWindowsMicrophonePermissionStatus(); + + if (!microphoneStatus.supported) { + return true; + } + + return microphoneStatus.overall_access !== "denied"; + }, []); // Check platform and permission status on mount useEffect(() => { const currentPlatform = platform(); - const isMac = currentPlatform === "macos"; - setIsMacOS(isMac); + const nextPlatform: PermissionPlatform = + currentPlatform === "macos" + ? "macos" + : currentPlatform === "windows" + ? "windows" + : "other"; - // Skip immediately on non-macOS - no permissions needed - if (!isMac) { + setPermissionPlatform(nextPlatform); + + // Skip immediately on unsupported platforms + if (nextPlatform === "other") { onComplete(); return; } - // On macOS, check both permissions const checkInitial = async () => { - try { - const [accessibilityGranted, microphoneGranted] = await Promise.all([ - checkAccessibilityPermission(), - checkMicrophonePermission(), - ]); + if (nextPlatform === "macos") { + 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); + // 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 (accessibilityGranted && microphoneGranted) { + await completeOnboarding(); + } + } catch (error) { + console.error("Failed to check macOS permissions:", error); + toast.error(t("onboarding.permissions.errors.checkFailed")); + setPermissions({ + accessibility: "needed", + microphone: "needed", + }); } - const newState: PermissionsState = { - accessibility: accessibilityGranted ? "granted" : "needed", + return; + } + + try { + const microphoneGranted = await hasWindowsMicrophoneAccess(); + + setPermissions({ + accessibility: "granted", 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); + if (microphoneGranted) { + await completeOnboarding(); } } catch (error) { - console.error("Failed to check permissions:", error); - toast.error(t("onboarding.permissions.errors.checkFailed")); + console.warn("Failed to check Windows microphone permissions:", error); setPermissions({ - accessibility: "needed", - microphone: "needed", + accessibility: "granted", + microphone: "granted", }); + await completeOnboarding(); } }; checkInitial(); - }, [onComplete, refreshAudioDevices, refreshOutputDevices, t]); + }, [completeOnboarding, hasWindowsMicrophoneAccess, onComplete, t]); // Polling for permissions after user clicks a button const startPolling = useCallback(() => { - if (pollingRef.current) return; + if (pollingRef.current || permissionPlatform === null) return; pollingRef.current = setInterval(async () => { try { + if (permissionPlatform === "windows") { + const microphoneGranted = await hasWindowsMicrophoneAccess(); + + if (microphoneGranted) { + setPermissions((prev) => ({ ...prev, microphone: "granted" })); + + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + + await completeOnboarding(); + } + + errorCountRef.current = 0; + return; + } + const [accessibilityGranted, microphoneGranted] = await Promise.all([ checkAccessibilityPermission(), checkMicrophonePermission(), @@ -143,9 +214,7 @@ const AccessibilityOnboarding: React.FC = ({ 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); + await completeOnboarding(); } // Reset error count on success @@ -164,7 +233,7 @@ const AccessibilityOnboarding: React.FC = ({ } } }, 1000); - }, [onComplete, refreshAudioDevices, refreshOutputDevices, t]); + }, [completeOnboarding, hasWindowsMicrophoneAccess, permissionPlatform, t]); // Cleanup polling and timeouts on unmount useEffect(() => { @@ -191,7 +260,12 @@ const AccessibilityOnboarding: React.FC = ({ const handleGrantMicrophone = async () => { try { - await requestMicrophonePermission(); + if (isWindows) { + await commands.openMicrophonePrivacySettings(); + } else { + await requestMicrophonePermission(); + } + setPermissions((prev) => ({ ...prev, microphone: "waiting" })); startPolling(); } catch (error) { @@ -200,12 +274,15 @@ const AccessibilityOnboarding: React.FC = ({ } }; + const isChecking = + permissionPlatform === null || + (isMacOS && + permissions.accessibility === "checking" && + permissions.microphone === "checking") || + (isWindows && permissions.microphone === "checking"); + // Still checking platform/initial permissions - if ( - isMacOS === null || - (permissions.accessibility === "checking" && - permissions.microphone === "checking") - ) { + if (isChecking) { return (
@@ -245,74 +322,80 @@ const AccessibilityOnboarding: React.FC = ({
{/* 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")} -
- ) : ( - - )} + {showMicrophonePermission && ( +
+
+
+ +
+
+

+ {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")} -
- ) : ( - - )} + {showAccessibilityPermission && ( +
+
+
+ +
+
+

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

+

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

+ {permissions.accessibility === "granted" ? ( +
+ + {t("onboarding.permissions.granted")} +
+ ) : permissions.accessibility === "waiting" ? ( +
+ + {t("onboarding.permissions.waiting")} +
+ ) : ( + + )} +
-
+ )}
);