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",
"vad-rs",
"windows 0.61.3",
"winreg 0.55.0",
]
[[package]]

View file

@ -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" }

View file

@ -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> {

View file

@ -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(())

View file

@ -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

View file

@ -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 **/

View file

@ -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>
);