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') ?? '重启'} )}