Add Windows microphone permission onboarding (#991)

This commit is contained in:
Fero 2026-03-13 15:06:46 +01:00 committed by GitHub
parent 3795bc2417
commit dfd445d422
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 418 additions and 133 deletions

1
src-tauri/Cargo.lock generated
View file

@ -2471,6 +2471,7 @@ dependencies = [
"transcribe-rs", "transcribe-rs",
"vad-rs", "vad-rs",
"windows 0.61.3", "windows 0.61.3",
"winreg 0.55.0",
] ]
[[package]] [[package]]

View file

@ -94,6 +94,7 @@ windows = { version = "0.61.3", features = [
"Win32_Foundation", "Win32_Foundation",
"Win32_UI_WindowsAndMessaging", "Win32_UI_WindowsAndMessaging",
] } ] }
winreg = "0.55"
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2.1" } tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2.1" }

View file

@ -5,9 +5,15 @@ use crate::settings::{get_settings, write_settings};
use log::warn; use log::warn;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use specta::Type; use specta::Type;
use std::sync::Arc; use std::{process::Command, sync::Arc};
use tauri::{AppHandle, Manager}; use tauri::{AppHandle, Manager};
#[cfg(target_os = "windows")]
use winreg::{
enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE},
RegKey, HKEY,
};
#[derive(Serialize, Type)] #[derive(Serialize, Type)]
pub struct CustomSounds { pub struct CustomSounds {
start: bool, start: bool,
@ -35,6 +41,113 @@ pub struct AudioDevice {
pub is_default: bool, 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::<String, _>("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] #[tauri::command]
#[specta::specta] #[specta::specta]
pub fn update_microphone_mode(app: AppHandle, always_on: bool) -> Result<(), String> { pub fn update_microphone_mode(app: AppHandle, always_on: bool) -> Result<(), String> {

View file

@ -85,24 +85,54 @@ fn build_console_filter() -> env_filter::Filter {
fn show_main_window(app: &AppHandle) { fn show_main_window(app: &AppHandle) {
if let Some(main_window) = app.get_webview_window("main") { 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() { 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() { 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")] #[cfg(target_os = "macos")]
{ {
if let Err(e) = app.set_activation_policy(tauri::ActivationPolicy::Regular) { if let Err(e) = app.set_activation_policy(tauri::ActivationPolicy::Regular) {
log::error!("Failed to set activation policy to Regular: {}", e); log::error!("Failed to set activation policy to Regular: {}", e);
} }
} }
} else { return;
log::error!("Main window not found.");
} }
let webview_labels = app.webview_windows().keys().cloned().collect::<Vec<_>>();
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::<Arc<ModelManager>>();
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) { fn initialize_core_logic(app_handle: &AppHandle) {
@ -251,6 +281,13 @@ fn trigger_update_check(app: AppHandle) -> Result<(), String> {
Ok(()) 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)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run(cli_args: CliArgs) { pub fn run(cli_args: CliArgs) {
// Detect portable mode before anything else // 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::start_handy_keys_recording,
shortcut::handy_keys::stop_handy_keys_recording, shortcut::handy_keys::stop_handy_keys_recording,
trigger_update_check, trigger_update_check,
show_main_window_command,
commands::cancel_operation, commands::cancel_operation,
commands::get_app_dir_path, commands::get_app_dir_path,
commands::get_app_settings, commands::get_app_settings,
@ -330,6 +368,8 @@ pub fn run(cli_args: CliArgs) {
commands::models::has_any_models_or_downloads, commands::models::has_any_models_or_downloads,
commands::audio::update_microphone_mode, commands::audio::update_microphone_mode,
commands::audio::get_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::get_available_microphones,
commands::audio::set_selected_microphone, commands::audio::set_selected_microphone,
commands::audio::get_selected_microphone, commands::audio::get_selected_microphone,
@ -466,18 +506,17 @@ pub fn run(cli_args: CliArgs) {
tray::set_tray_visibility(&app_handle, false); tray::set_tray_visibility(&app_handle, false);
} }
// Show main window only if not starting hidden // Show main window only if not starting hidden.
// CLI --start-hidden flag overrides the setting // 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_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 // 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. // anyway. Without a tray icon, the dock is the only way back in.
let tray_available = settings.show_tray_icon && !cli_args.no_tray; let tray_available = settings.show_tray_icon && !cli_args.no_tray;
if !should_hide || !tray_available { if should_force_show || !should_hide || !tray_available {
if let Some(main_window) = app_handle.get_webview_window("main") { show_main_window(&app_handle);
main_window.show().unwrap();
main_window.set_focus().unwrap();
}
} }
Ok(()) Ok(())

View file

@ -125,31 +125,60 @@ function App() {
}; };
}, [t]); }, [t]);
const revealMainWindowForPermissions = async () => {
try {
await commands.showMainWindowCommand();
} catch (e) {
console.warn("Failed to show main window for permission onboarding:", e);
}
};
const checkOnboardingStatus = async () => { const checkOnboardingStatus = async () => {
try { try {
// Check if they have any models available // Check if they have any models available
const result = await commands.hasAnyModelsAvailable(); const result = await commands.hasAnyModelsAvailable();
const hasModels = result.status === "ok" && result.data; const hasModels = result.status === "ok" && result.data;
const currentPlatform = platform();
if (hasModels) { 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); setIsReturningUser(true);
if (platform() === "macos") {
if (currentPlatform === "macos") {
try { try {
const [hasAccessibility, hasMicrophone] = await Promise.all([ const [hasAccessibility, hasMicrophone] = await Promise.all([
checkAccessibilityPermission(), checkAccessibilityPermission(),
checkMicrophonePermission(), checkMicrophonePermission(),
]); ]);
if (!hasAccessibility || !hasMicrophone) { if (!hasAccessibility || !hasMicrophone) {
// Missing permissions - show accessibility onboarding await revealMainWindowForPermissions();
setOnboardingStep("accessibility"); setOnboardingStep("accessibility");
return; return;
} }
} catch (e) { } 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 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"); setOnboardingStep("done");
} else { } else {
// New user - start full onboarding // New user - start full onboarding

View file

@ -369,6 +369,14 @@ async triggerUpdateCheck() : Promise<Result<null, string>> {
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async showMainWindowCommand() : Promise<Result<null, string>> {
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<void> { async cancelOperation() : Promise<void> {
await TAURI_INVOKE("cancel_operation"); await TAURI_INVOKE("cancel_operation");
}, },
@ -572,6 +580,17 @@ async getMicrophoneMode() : Promise<Result<boolean, string>> {
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async getWindowsMicrophonePermissionStatus() : Promise<WindowsMicrophonePermissionStatus> {
return await TAURI_INVOKE("get_windows_microphone_permission_status");
},
async openMicrophonePrivacySettings() : Promise<Result<null, string>> {
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<Result<AudioDevice[], string>> { async getAvailableMicrophones() : Promise<Result<AudioDevice[], string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("get_available_microphones") }; return { status: "ok", data: await TAURI_INVOKE("get_available_microphones") };
@ -713,10 +732,8 @@ async updateRecordingRetentionPeriod(period: string) : Promise<Result<null, stri
} }
}, },
/** /**
* Checks if the Mac is a laptop by detecting battery presence * Stub implementation for non-macOS platforms
* * Always returns false since laptop detection is macOS-specific
* This uses pmset to check for battery information.
* Returns true if a battery is detected (laptop), false otherwise (desktop)
*/ */
async isLaptop() : Promise<Result<boolean, string>> { async isLaptop() : Promise<Result<boolean, string>> {
try { 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 ModelUnloadTimeout = "never" | "immediately" | "min_2" | "min_5" | "min_10" | "min_15" | "hour_1" | "sec_5"
export type OverlayPosition = "none" | "top" | "bottom" export type OverlayPosition = "none" | "top" | "bottom"
export type PasteMethod = "ctrl_v" | "direct" | "none" | "shift_insert" | "ctrl_shift_v" | "external_script" 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 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 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 ShortcutBinding = { id: string; name: string; description: string; default_binding: string; current_binding: string }
export type SoundTheme = "marimba" | "pop" | "custom" export type SoundTheme = "marimba" | "pop" | "custom"
export type TypingTool = "auto" | "wtype" | "kwtype" | "dotool" | "ydotool" | "xdotool" 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 **/ /** tauri-specta globals **/

View file

@ -18,6 +18,7 @@ interface AccessibilityOnboardingProps {
} }
type PermissionStatus = "checking" | "needed" | "waiting" | "granted"; type PermissionStatus = "checking" | "needed" | "waiting" | "granted";
type PermissionPlatform = "macos" | "windows" | "other";
interface PermissionsState { interface PermissionsState {
accessibility: PermissionStatus; accessibility: PermissionStatus;
@ -34,7 +35,8 @@ const AccessibilityOnboarding: React.FC<AccessibilityOnboardingProps> = ({
const refreshOutputDevices = useSettingsStore( const refreshOutputDevices = useSettingsStore(
(state) => state.refreshOutputDevices, (state) => state.refreshOutputDevices,
); );
const [isMacOS, setIsMacOS] = useState<boolean | null>(null); const [permissionPlatform, setPermissionPlatform] =
useState<PermissionPlatform | null>(null);
const [permissions, setPermissions] = useState<PermissionsState>({ const [permissions, setPermissions] = useState<PermissionsState>({
accessibility: "checking", accessibility: "checking",
microphone: "checking", microphone: "checking",
@ -44,73 +46,142 @@ const AccessibilityOnboarding: React.FC<AccessibilityOnboardingProps> = ({
const errorCountRef = useRef<number>(0); const errorCountRef = useRef<number>(0);
const MAX_POLLING_ERRORS = 3; const MAX_POLLING_ERRORS = 3;
const allGranted = const isMacOS = permissionPlatform === "macos";
permissions.accessibility === "granted" && const isWindows = permissionPlatform === "windows";
permissions.microphone === "granted"; 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<boolean> => {
const microphoneStatus =
await commands.getWindowsMicrophonePermissionStatus();
if (!microphoneStatus.supported) {
return true;
}
return microphoneStatus.overall_access !== "denied";
}, []);
// Check platform and permission status on mount // Check platform and permission status on mount
useEffect(() => { useEffect(() => {
const currentPlatform = platform(); const currentPlatform = platform();
const isMac = currentPlatform === "macos"; const nextPlatform: PermissionPlatform =
setIsMacOS(isMac); currentPlatform === "macos"
? "macos"
: currentPlatform === "windows"
? "windows"
: "other";
// Skip immediately on non-macOS - no permissions needed setPermissionPlatform(nextPlatform);
if (!isMac) {
// Skip immediately on unsupported platforms
if (nextPlatform === "other") {
onComplete(); onComplete();
return; return;
} }
// On macOS, check both permissions
const checkInitial = async () => { const checkInitial = async () => {
try { if (nextPlatform === "macos") {
const [accessibilityGranted, microphoneGranted] = await Promise.all([ try {
checkAccessibilityPermission(), const [accessibilityGranted, microphoneGranted] = await Promise.all([
checkMicrophonePermission(), checkAccessibilityPermission(),
]); checkMicrophonePermission(),
]);
// If accessibility is granted, initialize Enigo and shortcuts // If accessibility is granted, initialize Enigo and shortcuts
if (accessibilityGranted) { if (accessibilityGranted) {
try { try {
await Promise.all([ await Promise.all([
commands.initializeEnigo(), commands.initializeEnigo(),
commands.initializeShortcuts(), commands.initializeShortcuts(),
]); ]);
} catch (e) { } catch (e) {
console.warn("Failed to initialize after permission grant:", 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 = { return;
accessibility: accessibilityGranted ? "granted" : "needed", }
try {
const microphoneGranted = await hasWindowsMicrophoneAccess();
setPermissions({
accessibility: "granted",
microphone: microphoneGranted ? "granted" : "needed", microphone: microphoneGranted ? "granted" : "needed",
}; });
setPermissions(newState); if (microphoneGranted) {
await completeOnboarding();
// 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) { } catch (error) {
console.error("Failed to check permissions:", error); console.warn("Failed to check Windows microphone permissions:", error);
toast.error(t("onboarding.permissions.errors.checkFailed"));
setPermissions({ setPermissions({
accessibility: "needed", accessibility: "granted",
microphone: "needed", microphone: "granted",
}); });
await completeOnboarding();
} }
}; };
checkInitial(); checkInitial();
}, [onComplete, refreshAudioDevices, refreshOutputDevices, t]); }, [completeOnboarding, hasWindowsMicrophoneAccess, onComplete, t]);
// Polling for permissions after user clicks a button // Polling for permissions after user clicks a button
const startPolling = useCallback(() => { const startPolling = useCallback(() => {
if (pollingRef.current) return; if (pollingRef.current || permissionPlatform === null) return;
pollingRef.current = setInterval(async () => { pollingRef.current = setInterval(async () => {
try { 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([ const [accessibilityGranted, microphoneGranted] = await Promise.all([
checkAccessibilityPermission(), checkAccessibilityPermission(),
checkMicrophonePermission(), checkMicrophonePermission(),
@ -143,9 +214,7 @@ const AccessibilityOnboarding: React.FC<AccessibilityOnboardingProps> = ({
clearInterval(pollingRef.current); clearInterval(pollingRef.current);
pollingRef.current = null; pollingRef.current = null;
} }
// Now that we have mic permission, refresh audio devices await completeOnboarding();
await Promise.all([refreshAudioDevices(), refreshOutputDevices()]);
timeoutRef.current = setTimeout(() => onComplete(), 500);
} }
// Reset error count on success // Reset error count on success
@ -164,7 +233,7 @@ const AccessibilityOnboarding: React.FC<AccessibilityOnboardingProps> = ({
} }
} }
}, 1000); }, 1000);
}, [onComplete, refreshAudioDevices, refreshOutputDevices, t]); }, [completeOnboarding, hasWindowsMicrophoneAccess, permissionPlatform, t]);
// Cleanup polling and timeouts on unmount // Cleanup polling and timeouts on unmount
useEffect(() => { useEffect(() => {
@ -191,7 +260,12 @@ const AccessibilityOnboarding: React.FC<AccessibilityOnboardingProps> = ({
const handleGrantMicrophone = async () => { const handleGrantMicrophone = async () => {
try { try {
await requestMicrophonePermission(); if (isWindows) {
await commands.openMicrophonePrivacySettings();
} else {
await requestMicrophonePermission();
}
setPermissions((prev) => ({ ...prev, microphone: "waiting" })); setPermissions((prev) => ({ ...prev, microphone: "waiting" }));
startPolling(); startPolling();
} catch (error) { } catch (error) {
@ -200,12 +274,15 @@ const AccessibilityOnboarding: React.FC<AccessibilityOnboardingProps> = ({
} }
}; };
const isChecking =
permissionPlatform === null ||
(isMacOS &&
permissions.accessibility === "checking" &&
permissions.microphone === "checking") ||
(isWindows && permissions.microphone === "checking");
// Still checking platform/initial permissions // Still checking platform/initial permissions
if ( if (isChecking) {
isMacOS === null ||
(permissions.accessibility === "checking" &&
permissions.microphone === "checking")
) {
return ( return (
<div className="h-screen w-screen flex items-center justify-center"> <div className="h-screen w-screen flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-text/50" /> <Loader2 className="w-8 h-8 animate-spin text-text/50" />
@ -245,74 +322,80 @@ const AccessibilityOnboarding: React.FC<AccessibilityOnboardingProps> = ({
</div> </div>
{/* Microphone Permission Card */} {/* Microphone Permission Card */}
<div className="w-full p-4 rounded-lg bg-white/5 border border-mid-gray/20"> {showMicrophonePermission && (
<div className="flex items-center gap-4"> <div className="w-full p-4 rounded-lg bg-white/5 border border-mid-gray/20">
<div className="p-3 rounded-full bg-logo-primary/20 shrink-0"> <div className="flex items-center gap-4">
<Mic className="w-6 h-6 text-logo-primary" /> <div className="p-3 rounded-full bg-logo-primary/20 shrink-0">
</div> <Mic className="w-6 h-6 text-logo-primary" />
<div className="flex-1 min-w-0"> </div>
<h3 className="font-medium text-text"> <div className="flex-1 min-w-0">
{t("onboarding.permissions.microphone.title")} <h3 className="font-medium text-text">
</h3> {t("onboarding.permissions.microphone.title")}
<p className="text-sm text-text/60 mb-3"> </h3>
{t("onboarding.permissions.microphone.description")} <p className="text-sm text-text/60 mb-3">
</p> {t("onboarding.permissions.microphone.description")}
{permissions.microphone === "granted" ? ( </p>
<div className="flex items-center gap-2 text-emerald-400 text-sm"> {permissions.microphone === "granted" ? (
<Check className="w-4 h-4" /> <div className="flex items-center gap-2 text-emerald-400 text-sm">
{t("onboarding.permissions.granted")} <Check className="w-4 h-4" />
</div> {t("onboarding.permissions.granted")}
) : permissions.microphone === "waiting" ? ( </div>
<div className="flex items-center gap-2 text-text/50 text-sm"> ) : permissions.microphone === "waiting" ? (
<Loader2 className="w-4 h-4 animate-spin" /> <div className="flex items-center gap-2 text-text/50 text-sm">
{t("onboarding.permissions.waiting")} <Loader2 className="w-4 h-4 animate-spin" />
</div> {t("onboarding.permissions.waiting")}
) : ( </div>
<button ) : (
onClick={handleGrantMicrophone} <button
className="px-4 py-2 rounded-lg bg-logo-primary hover:bg-logo-primary/90 text-white text-sm font-medium transition-colors" 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> {isWindows
)} ? t("accessibility.openSettings")
: t("onboarding.permissions.grant")}
</button>
)}
</div>
</div> </div>
</div> </div>
</div> )}
{/* Accessibility Permission Card */} {/* Accessibility Permission Card */}
<div className="w-full p-4 rounded-lg bg-white/5 border border-mid-gray/20"> {showAccessibilityPermission && (
<div className="flex items-center gap-4"> <div className="w-full p-4 rounded-lg bg-white/5 border border-mid-gray/20">
<div className="p-3 rounded-full bg-logo-primary/20 shrink-0"> <div className="flex items-center gap-4">
<Keyboard className="w-6 h-6 text-logo-primary" /> <div className="p-3 rounded-full bg-logo-primary/20 shrink-0">
</div> <Keyboard className="w-6 h-6 text-logo-primary" />
<div className="flex-1 min-w-0"> </div>
<h3 className="font-medium text-text"> <div className="flex-1 min-w-0">
{t("onboarding.permissions.accessibility.title")} <h3 className="font-medium text-text">
</h3> {t("onboarding.permissions.accessibility.title")}
<p className="text-sm text-text/60 mb-3"> </h3>
{t("onboarding.permissions.accessibility.description")} <p className="text-sm text-text/60 mb-3">
</p> {t("onboarding.permissions.accessibility.description")}
{permissions.accessibility === "granted" ? ( </p>
<div className="flex items-center gap-2 text-emerald-400 text-sm"> {permissions.accessibility === "granted" ? (
<Check className="w-4 h-4" /> <div className="flex items-center gap-2 text-emerald-400 text-sm">
{t("onboarding.permissions.granted")} <Check className="w-4 h-4" />
</div> {t("onboarding.permissions.granted")}
) : permissions.accessibility === "waiting" ? ( </div>
<div className="flex items-center gap-2 text-text/50 text-sm"> ) : permissions.accessibility === "waiting" ? (
<Loader2 className="w-4 h-4 animate-spin" /> <div className="flex items-center gap-2 text-text/50 text-sm">
{t("onboarding.permissions.waiting")} <Loader2 className="w-4 h-4 animate-spin" />
</div> {t("onboarding.permissions.waiting")}
) : ( </div>
<button ) : (
onClick={handleGrantAccessibility} <button
className="px-4 py-2 rounded-lg bg-logo-primary hover:bg-logo-primary/90 text-white text-sm font-medium transition-colors" 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> {t("onboarding.permissions.grant")}
)} </button>
)}
</div>
</div> </div>
</div> </div>
</div> )}
</div> </div>
</div> </div>
); );