fix(macos): recover stuck Accessibility onboarding

Fix macOS Accessibility onboarding getting stuck after TCC prompt/identity drift. Request permission only from onboarding, add targeted Accessibility TCC reset + restart recovery, and mirror the flow in settings.
This commit is contained in:
TRIP authored and GitHub committed 2026-06-25 13:38:52 +08:00
1 parent 8563e707ba
commit 124a49ca05
10 files changed
+120 -42

No files matched your search

+34 -16
View File
@@ -289,6 +289,7 @@ macro_rules! app_invoke_handler_desktop {
commands::sherpa_onnx_asr_reveal_model_dir, commands::sherpa_onnx_asr_reveal_model_dir,
commands::export_error_log, commands::export_error_log,
restart_app, restart_app,
reset_accessibility_permission_and_restart_app,
log_client_error, log_client_error,
set_windows_caption_theme, set_windows_caption_theme,
] ]
@@ -380,6 +381,7 @@ macro_rules! app_invoke_handler_mobile {
$crate::commands::app_check_update_with_channel, $crate::commands::app_check_update_with_channel,
$crate::commands::app_download_and_install_android_update, $crate::commands::app_download_and_install_android_update,
$crate::restart_app, $crate::restart_app,
$crate::reset_accessibility_permission_and_restart_app,
$crate::log_client_error, $crate::log_client_error,
] ]
}; };
@@ -592,13 +594,17 @@ fn run_desktop() {
} }
} }
// 启动时主动弹 Accessibility 授权框(与 Swift `AppDelegate` 行为一致)。 // Accessibility 授权不再在 setup() 中触发。
// 用户首次必看到系统提示;已授权则静默返回。 //
#[cfg(target_os = "macos")] // 原因:setup() 在 AppKit event loop 就绪之前执行,此时
{ // AXIsProcessTrustedWithOptions 调用的 XPC 通信依赖 run loop,
let status = permissions::request_accessibility(); // 在部分 macOS 版本上可能导致 TCC 弹窗不弹出但已被 TCC 标记为
log::info!("[startup] Accessibility status = {:?}", status); // "已展示"——之后前端 onboarding 再次调用时不再弹窗,只能走系统设置+
} // 重启恢复,用户就会卡死在 onboarding 页。
//
// 现在由前端 onboarding 页面在用户点击「授权辅助功能」按钮时触发,
// 符合 Apple HIG:"在解释用途之后再弹出权限请求"。
// 已授权用户不受影响(AXIsProcessTrusted 返回 true,引导页直接跳过)。
// AppImage / 便携版:fcitx5 插件缺了就从 bundled resources 自动安装 // AppImage / 便携版:fcitx5 插件缺了就从 bundled resources 自动安装
// 到 ~/.local/ 下面。不会覆盖系统已有的插件。 // 到 ~/.local/ 下面。不会覆盖系统已有的插件。
@@ -1118,6 +1124,21 @@ fn set_windows_caption_theme(app: AppHandle, dark: bool) {
#[tauri::command] #[tauri::command]
fn restart_app(app: AppHandle) { 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 // macOS:自动更新会让新装的 .app 带 com.apple.quarantine(无论 Tauri updater
// 怎么解包,下载流由 LaunchServices 接管,输出物可能仍带 xattr)。如果不 // 怎么解包,下载流由 LaunchServices 接管,输出物可能仍带 xattr)。如果不
// strip,重启后 Gatekeeper 会拦着说"OpenLess 已损坏 / 来自未识别开发者", // strip,重启后 Gatekeeper 会拦着说"OpenLess 已损坏 / 来自未识别开发者",
@@ -1138,9 +1159,6 @@ fn restart_app(app: AppHandle) {
log::info!("[updater] stripped xattr on {:?} before restart", bundle); log::info!("[updater] stripped xattr on {:?} before restart", bundle);
} }
} }
#[cfg(target_os = "macos")]
reset_tcc_for_beta_restart();
app.restart();
} }
/// 把前端的关键错误(如自动更新 install 失败)转发到 Rust 文件日志(openless.log)。 /// 把前端的关键错误(如自动更新 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 // 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. // old TCC rows can leave System Settings checked while AXIsProcessTrusted() is false.
reset_tcc_service_for_beta_restart("Accessibility"); reset_tcc_service_for_restart("Accessibility", "beta ad-hoc identity refresh");
reset_tcc_service_for_beta_restart("Microphone"); reset_tcc_service_for_restart("Microphone", "beta ad-hoc identity refresh");
} }
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -1182,19 +1200,19 @@ fn is_beta_build() -> bool {
} }
#[cfg(target_os = "macos")] #[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") match std::process::Command::new("/usr/bin/tccutil")
.args(["reset", service, OPENLESS_BUNDLE_ID]) .args(["reset", service, OPENLESS_BUNDLE_ID])
.status() .status()
{ {
Ok(status) if status.success() => { Ok(status) if status.success() => {
log::info!("[updater] reset TCC {service} before beta restart"); log::info!("[tcc] reset {service} before restart ({reason})");
} }
Ok(status) => { 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) => { Err(e) => {
log::warn!("[updater] reset TCC {service} before beta restart failed: {e}"); log::warn!("[tcc] reset {service} before restart ({reason}) failed: {e}");
} }
} }
} }
+54 -23
View File
@@ -11,6 +11,7 @@ import {
openSystemSettings, openSystemSettings,
requestAccessibilityPermission, requestAccessibilityPermission,
requestMicrophonePermission, requestMicrophonePermission,
resetAccessibilityPermissionAndRestartApp,
} from '../lib/ipc'; } from '../lib/ipc';
import { getHotkeyTriggerLabel } from '../lib/hotkey'; import { getHotkeyTriggerLabel } from '../lib/hotkey';
import type { PermissionStatus, PlatformCapabilities } from '../lib/types'; import type { PermissionStatus, PlatformCapabilities } from '../lib/types';
@@ -281,6 +282,8 @@ function DesktopOnboarding({
const [accessibility, setAccessibility] = useState<PermissionStatus>('notDetermined'); const [accessibility, setAccessibility] = useState<PermissionStatus>('notDetermined');
const [microphone, setMicrophone] = useState<PermissionStatus>('notDetermined'); const [microphone, setMicrophone] = useState<PermissionStatus>('notDetermined');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
// 区分「尚未尝试弹 TCC 对话框」和「TCC 已拒绝」两种 denied 状态。
const [tccPromptShown, setTccPromptShown] = useState(false);
const refreshTimeoutRef = useRef<number | null>(null); const refreshTimeoutRef = useRef<number | null>(null);
const { capability } = useHotkeySettings(); const { capability } = useHotkeySettings();
@@ -322,12 +325,16 @@ function DesktopOnboarding({
const onGrantAccessibility = async () => { const onGrantAccessibility = async () => {
setBusy(true); setBusy(true);
try { try {
await requestAccessibilityPermission(); const result = await requestAccessibilityPermission();
await openSystemSettings('accessibility'); setTccPromptShown(true);
// 如果 TCC 弹窗用户点了「允许」,权限已授予,不需要再打开系统设置。
// 仅在 TCC 拒绝或之前已拒绝(不会再弹窗)时才打开系统设置引导用户手动开启。
if (result !== 'granted') {
await openSystemSettings('accessibility');
}
} finally { } finally {
setBusy(false); setBusy(false);
} }
// issue #470:与麦克风路径对称——授权动作返回后立即刷新,并挂一次 800ms 兜底覆盖 app 内按钮发起的授予。
void refresh(); void refresh();
if (refreshTimeoutRef.current) clearTimeout(refreshTimeoutRef.current); if (refreshTimeoutRef.current) clearTimeout(refreshTimeoutRef.current);
refreshTimeoutRef.current = window.setTimeout(refresh, 800); refreshTimeoutRef.current = window.setTimeout(refresh, 800);
@@ -368,26 +375,50 @@ function DesktopOnboarding({
<BrandHeader title={t('onboarding.welcome')} desc={t('onboarding.intro')} /> <BrandHeader title={t('onboarding.welcome')} desc={t('onboarding.intro')} />
{(requiresAccessibility || accessibility === 'denied') && ( {(requiresAccessibility || accessibility === 'denied') && (
<PermissionStep <>
index={1} <PermissionStep
title={capability?.requiresAccessibilityPermission ? t('onboarding.accessibilityTitle') : t('onboarding.hotkeyTitle')} index={1}
desc={capability?.requiresAccessibilityPermission title={capability?.requiresAccessibilityPermission ? t('onboarding.accessibilityTitle') : t('onboarding.hotkeyTitle')}
? t('onboarding.accessibilityDesc', { trigger: getHotkeyTriggerLabel(capability.availableTriggers[0]) }) desc={capability?.requiresAccessibilityPermission
: capability?.statusHint ?? t('onboarding.hotkeyDesc')} ? t('onboarding.accessibilityDesc', { trigger: getHotkeyTriggerLabel(capability.availableTriggers[0]) })
status={accessibility} : capability?.statusHint ?? t('onboarding.hotkeyDesc')}
actionLabel={ status={accessibility}
!capability?.requiresAccessibilityPermission || accessibility === 'notApplicable' actionLabel={
? t('onboarding.actionNotApplicable') !capability?.requiresAccessibilityPermission || accessibility === 'notApplicable'
: accessibility === 'granted' ? t('onboarding.actionNotApplicable')
? t('onboarding.actionGranted') : accessibility === 'granted'
: accessibility === 'denied' ? t('onboarding.actionGranted')
? t('onboarding.actionOpenSystem') : accessibility === 'denied' && tccPromptShown
: t('onboarding.actionGrant') ? t('onboarding.actionOpenSystem')
} : t('onboarding.actionGrant')
onAction={onGrantAccessibility} }
disabled={busy || accessibility === 'granted' || accessibility === 'notApplicable'} onAction={onGrantAccessibility}
hint={capability?.requiresAccessibilityPermission ? t('onboarding.accessibilityHint') : undefined} disabled={busy || accessibility === 'granted' || accessibility === 'notApplicable'}
/> hint={capability?.requiresAccessibilityPermission ? t('onboarding.accessibilityHint') : undefined}
/>
{accessibility === 'denied' && tccPromptShown && (
<div style={{ marginTop: 10, paddingTop: 10, borderTop: '0.5px solid var(--ol-line-soft)' }}>
<button
type="button"
onClick={() => { resetAccessibilityPermissionAndRestartApp().catch(console.error); }}
style={{
width: '100%',
padding: '8px 14px',
fontSize: 12.5,
fontWeight: 500,
fontFamily: 'inherit',
border: '0.5px solid var(--ol-line-strong)',
borderRadius: 8,
background: 'var(--ol-surface)',
color: 'var(--ol-ink-2)',
cursor: 'default',
}}
>
{t('onboarding.actionRestart')}
</button>
</div>
)}
</>
)} )}
<PermissionStep <PermissionStep
+2
View File
@@ -225,6 +225,7 @@ export const en: typeof zhCN = {
actionNotApplicable: 'Not required', actionNotApplicable: 'Not required',
actionGranted: 'Granted', actionGranted: 'Granted',
actionOpenSystem: 'Open System Settings', actionOpenSystem: 'Open System Settings',
actionRestart: 'Reset Accessibility and Restart OpenLess',
actionGrant: 'Grant', actionGrant: 'Grant',
actionRequestMic: 'Request access', actionRequestMic: 'Request access',
accessibilityHint: 'After granting, you must **fully quit OpenLess** and reopen it (a macOS TCC requirement).', accessibilityHint: 'After granting, you must **fully quit OpenLess** and reopen it (a macOS TCC requirement).',
@@ -843,6 +844,7 @@ export const en: typeof zhCN = {
denied: 'Not granted', denied: 'Not granted',
indeterminate: 'Undetermined', indeterminate: 'Undetermined',
openSystem: 'Open System Settings', openSystem: 'Open System Settings',
restart: 'Reset and Restart',
grant: 'Grant', grant: 'Grant',
rerunAndroidSetup: 'Run setup again', rerunAndroidSetup: 'Run setup again',
hotkeyInstalled: 'Installed', hotkeyInstalled: 'Installed',
+2
View File
@@ -227,6 +227,7 @@ export const ja: typeof zhCN = {
actionNotApplicable: '権限不要', actionNotApplicable: '権限不要',
actionGranted: '許可済み', actionGranted: '許可済み',
actionOpenSystem: 'システム設定を開く', actionOpenSystem: 'システム設定を開く',
actionRestart: 'アクセシビリティをリセットして OpenLess を再起動',
actionGrant: '許可する', actionGrant: '許可する',
actionRequestMic: '許可ダイアログを表示', actionRequestMic: '許可ダイアログを表示',
accessibilityHint: '許可後は **OpenLess を完全に終了** してから再起動してください(macOS TCC の仕様)。', accessibilityHint: '許可後は **OpenLess を完全に終了** してから再起動してください(macOS TCC の仕様)。',
@@ -845,6 +846,7 @@ export const ja: typeof zhCN = {
denied: '未許可', denied: '未許可',
indeterminate: '未確定', indeterminate: '未確定',
openSystem: 'システム設定を開く', openSystem: 'システム設定を開く',
restart: 'リセットして再起動',
grant: '許可する', grant: '許可する',
rerunAndroidSetup: 'セットアップを再実行', rerunAndroidSetup: 'セットアップを再実行',
hotkeyInstalled: 'インストール済み', hotkeyInstalled: 'インストール済み',
+2
View File
@@ -227,6 +227,7 @@ export const ko: typeof zhCN = {
actionNotApplicable: '권한 불필요', actionNotApplicable: '권한 불필요',
actionGranted: '허용됨', actionGranted: '허용됨',
actionOpenSystem: '시스템 설정 열기', actionOpenSystem: '시스템 설정 열기',
actionRestart: '접근성 권한 재설정 후 OpenLess 재시작',
actionGrant: '허용', actionGrant: '허용',
actionRequestMic: '권한 대화상자 표시', actionRequestMic: '권한 대화상자 표시',
accessibilityHint: '허용 후에는 **OpenLess 를 완전히 종료** 한 다음 다시 실행해야 합니다(macOS TCC 규칙).', accessibilityHint: '허용 후에는 **OpenLess 를 완전히 종료** 한 다음 다시 실행해야 합니다(macOS TCC 규칙).',
@@ -845,6 +846,7 @@ export const ko: typeof zhCN = {
denied: '허용되지 않음', denied: '허용되지 않음',
indeterminate: '미결정', indeterminate: '미결정',
openSystem: '시스템 설정 열기', openSystem: '시스템 설정 열기',
restart: '재설정 후 재시작',
grant: '허용', grant: '허용',
rerunAndroidSetup: '설정 마법사 다시 실행', rerunAndroidSetup: '설정 마법사 다시 실행',
hotkeyInstalled: '설치됨', hotkeyInstalled: '설치됨',
+2
View File
@@ -223,6 +223,7 @@ export const zhCN = {
actionNotApplicable: '无需授权', actionNotApplicable: '无需授权',
actionGranted: '已授权', actionGranted: '已授权',
actionOpenSystem: '打开系统设置', actionOpenSystem: '打开系统设置',
actionRestart: '重置授权并重启 OpenLess',
actionGrant: '授权', actionGrant: '授权',
actionRequestMic: '弹出授权', actionRequestMic: '弹出授权',
accessibilityHint: '授权后必须**完全退出 OpenLess** 再重新打开(macOS TCC 规则)。', accessibilityHint: '授权后必须**完全退出 OpenLess** 再重新打开(macOS TCC 规则)。',
@@ -841,6 +842,7 @@ export const zhCN = {
denied: '未授权', denied: '未授权',
indeterminate: '未确定', indeterminate: '未确定',
openSystem: '打开系统设置', openSystem: '打开系统设置',
restart: '重置授权并重启',
grant: '授权', grant: '授权',
rerunAndroidSetup: '重新运行设置向导', rerunAndroidSetup: '重新运行设置向导',
hotkeyInstalled: '已安装', hotkeyInstalled: '已安装',
+2
View File
@@ -225,6 +225,7 @@ export const zhTW: typeof zhCN = {
actionNotApplicable: '無需授權', actionNotApplicable: '無需授權',
actionGranted: '已授權', actionGranted: '已授權',
actionOpenSystem: '打開系統設置', actionOpenSystem: '打開系統設置',
actionRestart: '重置授權並重新啟動 OpenLess',
actionGrant: '授權', actionGrant: '授權',
actionRequestMic: '彈出授權', actionRequestMic: '彈出授權',
accessibilityHint: '授權後必須**完全退出 OpenLess** 再重新打開(macOS TCC 規則)。', accessibilityHint: '授權後必須**完全退出 OpenLess** 再重新打開(macOS TCC 規則)。',
@@ -843,6 +844,7 @@ export const zhTW: typeof zhCN = {
denied: '未授權', denied: '未授權',
indeterminate: '未確定', indeterminate: '未確定',
openSystem: '打開系統設置', openSystem: '打開系統設置',
restart: '重置授權並重新啟動',
grant: '授權', grant: '授權',
rerunAndroidSetup: '重新執行設定向導', rerunAndroidSetup: '重新執行設定向導',
hotkeyInstalled: '已安裝', hotkeyInstalled: '已安裝',
+1
View File
@@ -89,6 +89,7 @@ export {
openSystemSettings, openSystemSettings,
triggerMicrophonePrompt, triggerMicrophonePrompt,
restartApp, restartApp,
resetAccessibilityPermissionAndRestartApp,
} from "./permissions" } from "./permissions"
// hotkeys // hotkeys
@@ -46,3 +46,11 @@ export function triggerMicrophonePrompt(): Promise<void> {
export function restartApp(): Promise<void> { export function restartApp(): Promise<void> {
return invokeOrMock("restart_app", undefined, () => undefined) return invokeOrMock("restart_app", undefined, () => undefined)
} }
export function resetAccessibilityPermissionAndRestartApp(): Promise<void> {
return invokeOrMock(
"reset_accessibility_permission_and_restart_app",
undefined,
() => undefined,
)
}
@@ -14,6 +14,7 @@ import {
openSystemSettings, openSystemSettings,
requestAccessibilityPermission, requestAccessibilityPermission,
requestMicrophonePermission, requestMicrophonePermission,
resetAccessibilityPermissionAndRestartApp,
} from '../../lib/ipc'; } from '../../lib/ipc';
import type { NetworkCheckResult } from '../../lib/ipc'; import type { NetworkCheckResult } from '../../lib/ipc';
import { getPlatformCapabilities } from '../../lib/platform'; import { getPlatformCapabilities } from '../../lib/platform';
@@ -107,7 +108,11 @@ export function PermissionsSection() {
}, [platformCaps?.platform, platformCaps?.supportsDesktopHotkey]); }, [platformCaps?.platform, platformCaps?.supportsDesktopHotkey]);
const reRequestAccessibility = async () => { const reRequestAccessibility = async () => {
await requestAccessibilityPermission(); const result = await requestAccessibilityPermission();
// 如果 TCC 弹窗已拒绝(或之前已拒绝不再弹),引导用户到系统设置
if (result !== 'granted') {
await openSystemSettings('accessibility');
}
refreshPermissions(); refreshPermissions();
}; };
@@ -159,9 +164,14 @@ export function PermissionsSection() {
<SettingRow label={t('settings.permissions.accLabel')}> <SettingRow label={t('settings.permissions.accLabel')}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'flex-end', width: '100%', flexWrap: 'wrap', minWidth: 0 }}> <div style={{ display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'flex-end', width: '100%', flexWrap: 'wrap', minWidth: 0 }}>
<PermissionPill status={accessibility} /> <PermissionPill status={accessibility} />
{accessibility !== 'granted' && accessibility !== 'notApplicable' && ( {accessibility !== 'granted' && accessibility !== 'notApplicable' && accessibility !== 'loading' && (
<Btn variant="ghost" size="sm" onClick={reRequestAccessibility}> <Btn variant="ghost" size="sm" onClick={reRequestAccessibility}>
{t('settings.permissions.grant')} {accessibility === 'denied' ? t('settings.permissions.openSystem') : t('settings.permissions.grant')}
</Btn>
)}
{accessibility === 'denied' && (
<Btn variant="ghost" size="sm" onClick={() => { resetAccessibilityPermissionAndRestartApp().catch(console.error); }}>
{t('settings.permissions.restart') ?? '重启'}
</Btn> </Btn>
)} )}
</div> </div>