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",
|
||||
"vad-rs",
|
||||
"windows 0.61.3",
|
||||
"winreg 0.55.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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::<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]
|
||||
#[specta::specta]
|
||||
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) {
|
||||
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::<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) {
|
||||
|
|
@ -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(())
|
||||
|
|
|
|||
37
src/App.tsx
37
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
|
||||
|
|
|
|||
|
|
@ -369,6 +369,14 @@ async triggerUpdateCheck() : Promise<Result<null, string>> {
|
|||
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> {
|
||||
await TAURI_INVOKE("cancel_operation");
|
||||
},
|
||||
|
|
@ -572,6 +580,17 @@ async getMicrophoneMode() : Promise<Result<boolean, string>> {
|
|||
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>> {
|
||||
try {
|
||||
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
|
||||
*
|
||||
* This uses pmset to check for battery information.
|
||||
* Returns true if a battery is detected (laptop), false otherwise (desktop)
|
||||
* Stub implementation for non-macOS platforms
|
||||
* Always returns false since laptop detection is macOS-specific
|
||||
*/
|
||||
async isLaptop() : Promise<Result<boolean, string>> {
|
||||
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 **/
|
||||
|
||||
|
|
|
|||
|
|
@ -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<AccessibilityOnboardingProps> = ({
|
|||
const refreshOutputDevices = useSettingsStore(
|
||||
(state) => state.refreshOutputDevices,
|
||||
);
|
||||
const [isMacOS, setIsMacOS] = useState<boolean | null>(null);
|
||||
const [permissionPlatform, setPermissionPlatform] =
|
||||
useState<PermissionPlatform | null>(null);
|
||||
const [permissions, setPermissions] = useState<PermissionsState>({
|
||||
accessibility: "checking",
|
||||
microphone: "checking",
|
||||
|
|
@ -44,73 +46,142 @@ const AccessibilityOnboarding: React.FC<AccessibilityOnboardingProps> = ({
|
|||
const errorCountRef = useRef<number>(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<boolean> => {
|
||||
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<AccessibilityOnboardingProps> = ({
|
|||
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<AccessibilityOnboardingProps> = ({
|
|||
}
|
||||
}
|
||||
}, 1000);
|
||||
}, [onComplete, refreshAudioDevices, refreshOutputDevices, t]);
|
||||
}, [completeOnboarding, hasWindowsMicrophoneAccess, permissionPlatform, t]);
|
||||
|
||||
// Cleanup polling and timeouts on unmount
|
||||
useEffect(() => {
|
||||
|
|
@ -191,7 +260,12 @@ const AccessibilityOnboarding: React.FC<AccessibilityOnboardingProps> = ({
|
|||
|
||||
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<AccessibilityOnboardingProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="h-screen w-screen flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-text/50" />
|
||||
|
|
@ -245,74 +322,80 @@ const AccessibilityOnboarding: React.FC<AccessibilityOnboardingProps> = ({
|
|||
</div>
|
||||
|
||||
{/* Microphone Permission Card */}
|
||||
<div className="w-full p-4 rounded-lg bg-white/5 border border-mid-gray/20">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 rounded-full bg-logo-primary/20 shrink-0">
|
||||
<Mic className="w-6 h-6 text-logo-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-text">
|
||||
{t("onboarding.permissions.microphone.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-text/60 mb-3">
|
||||
{t("onboarding.permissions.microphone.description")}
|
||||
</p>
|
||||
{permissions.microphone === "granted" ? (
|
||||
<div className="flex items-center gap-2 text-emerald-400 text-sm">
|
||||
<Check className="w-4 h-4" />
|
||||
{t("onboarding.permissions.granted")}
|
||||
</div>
|
||||
) : permissions.microphone === "waiting" ? (
|
||||
<div className="flex items-center gap-2 text-text/50 text-sm">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{t("onboarding.permissions.waiting")}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleGrantMicrophone}
|
||||
className="px-4 py-2 rounded-lg bg-logo-primary hover:bg-logo-primary/90 text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
{t("onboarding.permissions.grant")}
|
||||
</button>
|
||||
)}
|
||||
{showMicrophonePermission && (
|
||||
<div className="w-full p-4 rounded-lg bg-white/5 border border-mid-gray/20">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 rounded-full bg-logo-primary/20 shrink-0">
|
||||
<Mic className="w-6 h-6 text-logo-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-text">
|
||||
{t("onboarding.permissions.microphone.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-text/60 mb-3">
|
||||
{t("onboarding.permissions.microphone.description")}
|
||||
</p>
|
||||
{permissions.microphone === "granted" ? (
|
||||
<div className="flex items-center gap-2 text-emerald-400 text-sm">
|
||||
<Check className="w-4 h-4" />
|
||||
{t("onboarding.permissions.granted")}
|
||||
</div>
|
||||
) : permissions.microphone === "waiting" ? (
|
||||
<div className="flex items-center gap-2 text-text/50 text-sm">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{t("onboarding.permissions.waiting")}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleGrantMicrophone}
|
||||
className="px-4 py-2 rounded-lg bg-logo-primary hover:bg-logo-primary/90 text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
{isWindows
|
||||
? t("accessibility.openSettings")
|
||||
: t("onboarding.permissions.grant")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Accessibility Permission Card */}
|
||||
<div className="w-full p-4 rounded-lg bg-white/5 border border-mid-gray/20">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 rounded-full bg-logo-primary/20 shrink-0">
|
||||
<Keyboard className="w-6 h-6 text-logo-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-text">
|
||||
{t("onboarding.permissions.accessibility.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-text/60 mb-3">
|
||||
{t("onboarding.permissions.accessibility.description")}
|
||||
</p>
|
||||
{permissions.accessibility === "granted" ? (
|
||||
<div className="flex items-center gap-2 text-emerald-400 text-sm">
|
||||
<Check className="w-4 h-4" />
|
||||
{t("onboarding.permissions.granted")}
|
||||
</div>
|
||||
) : permissions.accessibility === "waiting" ? (
|
||||
<div className="flex items-center gap-2 text-text/50 text-sm">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{t("onboarding.permissions.waiting")}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleGrantAccessibility}
|
||||
className="px-4 py-2 rounded-lg bg-logo-primary hover:bg-logo-primary/90 text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
{t("onboarding.permissions.grant")}
|
||||
</button>
|
||||
)}
|
||||
{showAccessibilityPermission && (
|
||||
<div className="w-full p-4 rounded-lg bg-white/5 border border-mid-gray/20">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 rounded-full bg-logo-primary/20 shrink-0">
|
||||
<Keyboard className="w-6 h-6 text-logo-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-text">
|
||||
{t("onboarding.permissions.accessibility.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-text/60 mb-3">
|
||||
{t("onboarding.permissions.accessibility.description")}
|
||||
</p>
|
||||
{permissions.accessibility === "granted" ? (
|
||||
<div className="flex items-center gap-2 text-emerald-400 text-sm">
|
||||
<Check className="w-4 h-4" />
|
||||
{t("onboarding.permissions.granted")}
|
||||
</div>
|
||||
) : permissions.accessibility === "waiting" ? (
|
||||
<div className="flex items-center gap-2 text-text/50 text-sm">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{t("onboarding.permissions.waiting")}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleGrantAccessibility}
|
||||
className="px-4 py-2 rounded-lg bg-logo-primary hover:bg-logo-primary/90 text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
{t("onboarding.permissions.grant")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue