Add Windows microphone permission onboarding (#991)
This commit is contained in:
parent
3795bc2417
commit
dfd445d422
7 changed files with 418 additions and 133 deletions
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
|
|
@ -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]]
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
|
|
|
||||||
37
src/App.tsx
37
src/App.tsx
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 **/
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue