From a9f058e2863ecca4f691649a5c641ba3dcfc4fae Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 24 Jun 2026 18:05:34 +0800 Subject: [PATCH] fix(macos): onboarding Accessibility authorization page stuck (#703 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: permissions::request_accessibility() was called in setup(), which runs BEFORE the AppKit event loop is live. On some macOS versions, AXIsProcessTrustedWithOptions depends on the run loop for XPC to tccd. When the event loop isn't ready, TCC may not show the dialog but still consumes the one-time prompt. Later calls from the onboarding page's 'grant accessibility' button silently return Denied, trapping users on the onboarding page. Recovery requires System Settings grant + app restart, but users had no clear restart action — only a text hint. Changes: - lib.rs: Remove setup() TCC prompt. The prompt is now exclusively triggered by user action on the onboarding page, aligning with Apple HIG (explain why → then prompt). - Onboarding.tsx: * onGrantAccessibility now checks the TCC result: skip openSystemSettings when already granted (previously always opened) * New tccPromptShown state distinguishes 'never attempted' vs 'already denied' for correct button labels * Denied state now shows a prominent 'Restart OpenLess' button calling restartApp() - PermissionsSection.tsx: Same fix for settings page accessibility row — check TCC result, guide to System Settings when denied, show 'Restart App' button - i18n: Add onboarding.actionRestart + settings.permissions.restart for zh-CN / zh-TW / en / ja / ko Closes: macos-onboarding-accessibility-stuck --- openless-all/app/src-tauri/src/lib.rs | 50 ++++++++---- .../app/src/components/Onboarding.tsx | 77 +++++++++++++------ openless-all/app/src/i18n/en.ts | 2 + openless-all/app/src/i18n/ja.ts | 2 + openless-all/app/src/i18n/ko.ts | 2 + openless-all/app/src/i18n/zh-CN.ts | 2 + openless-all/app/src/i18n/zh-TW.ts | 2 + openless-all/app/src/lib/ipc/index.ts | 1 + openless-all/app/src/lib/ipc/permissions.ts | 8 ++ .../src/pages/settings/PermissionsSection.tsx | 16 +++- 10 files changed, 120 insertions(+), 42 deletions(-) diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 2c2335dd..cef4e2e9 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -289,6 +289,7 @@ macro_rules! app_invoke_handler_desktop { commands::sherpa_onnx_asr_reveal_model_dir, commands::export_error_log, restart_app, + reset_accessibility_permission_and_restart_app, log_client_error, set_windows_caption_theme, ] @@ -380,6 +381,7 @@ macro_rules! app_invoke_handler_mobile { $crate::commands::app_check_update_with_channel, $crate::commands::app_download_and_install_android_update, $crate::restart_app, + $crate::reset_accessibility_permission_and_restart_app, $crate::log_client_error, ] }; @@ -592,13 +594,17 @@ fn run_desktop() { } } - // 启动时主动弹 Accessibility 授权框(与 Swift `AppDelegate` 行为一致)。 - // 用户首次必看到系统提示;已授权则静默返回。 - #[cfg(target_os = "macos")] - { - let status = permissions::request_accessibility(); - log::info!("[startup] Accessibility status = {:?}", status); - } + // Accessibility 授权不再在 setup() 中触发。 + // + // 原因:setup() 在 AppKit event loop 就绪之前执行,此时 + // AXIsProcessTrustedWithOptions 调用的 XPC 通信依赖 run loop, + // 在部分 macOS 版本上可能导致 TCC 弹窗不弹出但已被 TCC 标记为 + // "已展示"——之后前端 onboarding 再次调用时不再弹窗,只能走系统设置+ + // 重启恢复,用户就会卡死在 onboarding 页。 + // + // 现在由前端 onboarding 页面在用户点击「授权辅助功能」按钮时触发, + // 符合 Apple HIG:"在解释用途之后再弹出权限请求"。 + // 已授权用户不受影响(AXIsProcessTrusted 返回 true,引导页直接跳过)。 // AppImage / 便携版:fcitx5 插件缺了就从 bundled resources 自动安装 // 到 ~/.local/ 下面。不会覆盖系统已有的插件。 @@ -1118,6 +1124,21 @@ fn set_windows_caption_theme(app: AppHandle, dark: bool) { #[tauri::command] fn restart_app(app: AppHandle) { + prepare_for_restart(); + #[cfg(target_os = "macos")] + reset_tcc_for_beta_restart(); + app.restart(); +} + +#[tauri::command] +fn reset_accessibility_permission_and_restart_app(app: AppHandle) { + prepare_for_restart(); + #[cfg(target_os = "macos")] + reset_tcc_service_for_restart("Accessibility", "accessibility recovery"); + app.restart(); +} + +fn prepare_for_restart() { // macOS:自动更新会让新装的 .app 带 com.apple.quarantine(无论 Tauri updater // 怎么解包,下载流由 LaunchServices 接管,输出物可能仍带 xattr)。如果不 // strip,重启后 Gatekeeper 会拦着说"OpenLess 已损坏 / 来自未识别开发者", @@ -1138,9 +1159,6 @@ fn restart_app(app: AppHandle) { log::info!("[updater] stripped xattr on {:?} before restart", bundle); } } - #[cfg(target_os = "macos")] - reset_tcc_for_beta_restart(); - app.restart(); } /// 把前端的关键错误(如自动更新 install 失败)转发到 Rust 文件日志(openless.log)。 @@ -1172,8 +1190,8 @@ fn reset_tcc_for_beta_restart() { // Beta builds are currently ad-hoc signed. Their code hash changes across builds, so // old TCC rows can leave System Settings checked while AXIsProcessTrusted() is false. - reset_tcc_service_for_beta_restart("Accessibility"); - reset_tcc_service_for_beta_restart("Microphone"); + reset_tcc_service_for_restart("Accessibility", "beta ad-hoc identity refresh"); + reset_tcc_service_for_restart("Microphone", "beta ad-hoc identity refresh"); } #[cfg(target_os = "macos")] @@ -1182,19 +1200,19 @@ fn is_beta_build() -> bool { } #[cfg(target_os = "macos")] -fn reset_tcc_service_for_beta_restart(service: &str) { +fn reset_tcc_service_for_restart(service: &str, reason: &str) { match std::process::Command::new("/usr/bin/tccutil") .args(["reset", service, OPENLESS_BUNDLE_ID]) .status() { Ok(status) if status.success() => { - log::info!("[updater] reset TCC {service} before beta restart"); + log::info!("[tcc] reset {service} before restart ({reason})"); } Ok(status) => { - log::warn!("[updater] reset TCC {service} before beta restart exited with {status}"); + log::warn!("[tcc] reset {service} before restart ({reason}) exited with {status}"); } Err(e) => { - log::warn!("[updater] reset TCC {service} before beta restart failed: {e}"); + log::warn!("[tcc] reset {service} before restart ({reason}) failed: {e}"); } } } diff --git a/openless-all/app/src/components/Onboarding.tsx b/openless-all/app/src/components/Onboarding.tsx index 90ee702a..19568230 100644 --- a/openless-all/app/src/components/Onboarding.tsx +++ b/openless-all/app/src/components/Onboarding.tsx @@ -11,6 +11,7 @@ import { openSystemSettings, requestAccessibilityPermission, requestMicrophonePermission, + resetAccessibilityPermissionAndRestartApp, } from '../lib/ipc'; import { getHotkeyTriggerLabel } from '../lib/hotkey'; import type { PermissionStatus, PlatformCapabilities } from '../lib/types'; @@ -281,6 +282,8 @@ function DesktopOnboarding({ const [accessibility, setAccessibility] = useState('notDetermined'); const [microphone, setMicrophone] = useState('notDetermined'); const [busy, setBusy] = useState(false); + // 区分「尚未尝试弹 TCC 对话框」和「TCC 已拒绝」两种 denied 状态。 + const [tccPromptShown, setTccPromptShown] = useState(false); const refreshTimeoutRef = useRef(null); const { capability } = useHotkeySettings(); @@ -322,12 +325,16 @@ function DesktopOnboarding({ const onGrantAccessibility = async () => { setBusy(true); try { - await requestAccessibilityPermission(); - await openSystemSettings('accessibility'); + const result = await requestAccessibilityPermission(); + setTccPromptShown(true); + // 如果 TCC 弹窗用户点了「允许」,权限已授予,不需要再打开系统设置。 + // 仅在 TCC 拒绝或之前已拒绝(不会再弹窗)时才打开系统设置引导用户手动开启。 + if (result !== 'granted') { + await openSystemSettings('accessibility'); + } } finally { setBusy(false); } - // issue #470:与麦克风路径对称——授权动作返回后立即刷新,并挂一次 800ms 兜底覆盖 app 内按钮发起的授予。 void refresh(); if (refreshTimeoutRef.current) clearTimeout(refreshTimeoutRef.current); refreshTimeoutRef.current = window.setTimeout(refresh, 800); @@ -368,26 +375,50 @@ function DesktopOnboarding({ {(requiresAccessibility || accessibility === 'denied') && ( - + <> + + {accessibility === 'denied' && tccPromptShown && ( +
+ +
+ )} + )} { export function restartApp(): Promise { return invokeOrMock("restart_app", undefined, () => undefined) } + +export function resetAccessibilityPermissionAndRestartApp(): Promise { + return invokeOrMock( + "reset_accessibility_permission_and_restart_app", + undefined, + () => undefined, + ) +} diff --git a/openless-all/app/src/pages/settings/PermissionsSection.tsx b/openless-all/app/src/pages/settings/PermissionsSection.tsx index dfc0a463..f2d3e355 100644 --- a/openless-all/app/src/pages/settings/PermissionsSection.tsx +++ b/openless-all/app/src/pages/settings/PermissionsSection.tsx @@ -14,6 +14,7 @@ import { openSystemSettings, requestAccessibilityPermission, requestMicrophonePermission, + resetAccessibilityPermissionAndRestartApp, } from '../../lib/ipc'; import type { NetworkCheckResult } from '../../lib/ipc'; import { getPlatformCapabilities } from '../../lib/platform'; @@ -107,7 +108,11 @@ export function PermissionsSection() { }, [platformCaps?.platform, platformCaps?.supportsDesktopHotkey]); const reRequestAccessibility = async () => { - await requestAccessibilityPermission(); + const result = await requestAccessibilityPermission(); + // 如果 TCC 弹窗已拒绝(或之前已拒绝不再弹),引导用户到系统设置 + if (result !== 'granted') { + await openSystemSettings('accessibility'); + } refreshPermissions(); }; @@ -159,9 +164,14 @@ export function PermissionsSection() {
- {accessibility !== 'granted' && accessibility !== 'notApplicable' && ( + {accessibility !== 'granted' && accessibility !== 'notApplicable' && accessibility !== 'loading' && ( - {t('settings.permissions.grant')} + {accessibility === 'denied' ? t('settings.permissions.openSystem') : t('settings.permissions.grant')} + + )} + {accessibility === 'denied' && ( + { resetAccessibilityPermissionAndRestartApp().catch(console.error); }}> + {t('settings.permissions.restart') ?? '重启'} )}