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:
10 files changed
+100
-22
No files matched your search
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PermissionStatus>('notDetermined');
|
||||
const [microphone, setMicrophone] = useState<PermissionStatus>('notDetermined');
|
||||
const [busy, setBusy] = useState(false);
|
||||
// 区分「尚未尝试弹 TCC 对话框」和「TCC 已拒绝」两种 denied 状态。
|
||||
const [tccPromptShown, setTccPromptShown] = useState(false);
|
||||
const refreshTimeoutRef = useRef<number | null>(null);
|
||||
const { capability } = useHotkeySettings();
|
||||
|
||||
@@ -322,12 +325,16 @@ function DesktopOnboarding({
|
||||
const onGrantAccessibility = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await requestAccessibilityPermission();
|
||||
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,6 +375,7 @@ function DesktopOnboarding({
|
||||
<BrandHeader title={t('onboarding.welcome')} desc={t('onboarding.intro')} />
|
||||
|
||||
{(requiresAccessibility || accessibility === 'denied') && (
|
||||
<>
|
||||
<PermissionStep
|
||||
index={1}
|
||||
title={capability?.requiresAccessibilityPermission ? t('onboarding.accessibilityTitle') : t('onboarding.hotkeyTitle')}
|
||||
@@ -380,7 +388,7 @@ function DesktopOnboarding({
|
||||
? t('onboarding.actionNotApplicable')
|
||||
: accessibility === 'granted'
|
||||
? t('onboarding.actionGranted')
|
||||
: accessibility === 'denied'
|
||||
: accessibility === 'denied' && tccPromptShown
|
||||
? t('onboarding.actionOpenSystem')
|
||||
: t('onboarding.actionGrant')
|
||||
}
|
||||
@@ -388,6 +396,29 @@ function DesktopOnboarding({
|
||||
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
|
||||
|
||||
@@ -225,6 +225,7 @@ export const en: typeof zhCN = {
|
||||
actionNotApplicable: 'Not required',
|
||||
actionGranted: 'Granted',
|
||||
actionOpenSystem: 'Open System Settings',
|
||||
actionRestart: 'Reset Accessibility and Restart OpenLess',
|
||||
actionGrant: 'Grant',
|
||||
actionRequestMic: 'Request access',
|
||||
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',
|
||||
indeterminate: 'Undetermined',
|
||||
openSystem: 'Open System Settings',
|
||||
restart: 'Reset and Restart',
|
||||
grant: 'Grant',
|
||||
rerunAndroidSetup: 'Run setup again',
|
||||
hotkeyInstalled: 'Installed',
|
||||
|
||||
@@ -227,6 +227,7 @@ export const ja: typeof zhCN = {
|
||||
actionNotApplicable: '権限不要',
|
||||
actionGranted: '許可済み',
|
||||
actionOpenSystem: 'システム設定を開く',
|
||||
actionRestart: 'アクセシビリティをリセットして OpenLess を再起動',
|
||||
actionGrant: '許可する',
|
||||
actionRequestMic: '許可ダイアログを表示',
|
||||
accessibilityHint: '許可後は **OpenLess を完全に終了** してから再起動してください(macOS TCC の仕様)。',
|
||||
@@ -845,6 +846,7 @@ export const ja: typeof zhCN = {
|
||||
denied: '未許可',
|
||||
indeterminate: '未確定',
|
||||
openSystem: 'システム設定を開く',
|
||||
restart: 'リセットして再起動',
|
||||
grant: '許可する',
|
||||
rerunAndroidSetup: 'セットアップを再実行',
|
||||
hotkeyInstalled: 'インストール済み',
|
||||
|
||||
@@ -227,6 +227,7 @@ export const ko: typeof zhCN = {
|
||||
actionNotApplicable: '권한 불필요',
|
||||
actionGranted: '허용됨',
|
||||
actionOpenSystem: '시스템 설정 열기',
|
||||
actionRestart: '접근성 권한 재설정 후 OpenLess 재시작',
|
||||
actionGrant: '허용',
|
||||
actionRequestMic: '권한 대화상자 표시',
|
||||
accessibilityHint: '허용 후에는 **OpenLess 를 완전히 종료** 한 다음 다시 실행해야 합니다(macOS TCC 규칙).',
|
||||
@@ -845,6 +846,7 @@ export const ko: typeof zhCN = {
|
||||
denied: '허용되지 않음',
|
||||
indeterminate: '미결정',
|
||||
openSystem: '시스템 설정 열기',
|
||||
restart: '재설정 후 재시작',
|
||||
grant: '허용',
|
||||
rerunAndroidSetup: '설정 마법사 다시 실행',
|
||||
hotkeyInstalled: '설치됨',
|
||||
|
||||
@@ -223,6 +223,7 @@ export const zhCN = {
|
||||
actionNotApplicable: '无需授权',
|
||||
actionGranted: '已授权',
|
||||
actionOpenSystem: '打开系统设置',
|
||||
actionRestart: '重置授权并重启 OpenLess',
|
||||
actionGrant: '授权',
|
||||
actionRequestMic: '弹出授权',
|
||||
accessibilityHint: '授权后必须**完全退出 OpenLess** 再重新打开(macOS TCC 规则)。',
|
||||
@@ -841,6 +842,7 @@ export const zhCN = {
|
||||
denied: '未授权',
|
||||
indeterminate: '未确定',
|
||||
openSystem: '打开系统设置',
|
||||
restart: '重置授权并重启',
|
||||
grant: '授权',
|
||||
rerunAndroidSetup: '重新运行设置向导',
|
||||
hotkeyInstalled: '已安装',
|
||||
|
||||
@@ -225,6 +225,7 @@ export const zhTW: typeof zhCN = {
|
||||
actionNotApplicable: '無需授權',
|
||||
actionGranted: '已授權',
|
||||
actionOpenSystem: '打開系統設置',
|
||||
actionRestart: '重置授權並重新啟動 OpenLess',
|
||||
actionGrant: '授權',
|
||||
actionRequestMic: '彈出授權',
|
||||
accessibilityHint: '授權後必須**完全退出 OpenLess** 再重新打開(macOS TCC 規則)。',
|
||||
@@ -843,6 +844,7 @@ export const zhTW: typeof zhCN = {
|
||||
denied: '未授權',
|
||||
indeterminate: '未確定',
|
||||
openSystem: '打開系統設置',
|
||||
restart: '重置授權並重新啟動',
|
||||
grant: '授權',
|
||||
rerunAndroidSetup: '重新執行設定向導',
|
||||
hotkeyInstalled: '已安裝',
|
||||
|
||||
@@ -89,6 +89,7 @@ export {
|
||||
openSystemSettings,
|
||||
triggerMicrophonePrompt,
|
||||
restartApp,
|
||||
resetAccessibilityPermissionAndRestartApp,
|
||||
} from "./permissions"
|
||||
|
||||
// hotkeys
|
||||
|
||||
@@ -46,3 +46,11 @@ export function triggerMicrophonePrompt(): Promise<void> {
|
||||
export function restartApp(): Promise<void> {
|
||||
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,
|
||||
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() {
|
||||
<SettingRow label={t('settings.permissions.accLabel')}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'flex-end', width: '100%', flexWrap: 'wrap', minWidth: 0 }}>
|
||||
<PermissionPill status={accessibility} />
|
||||
{accessibility !== 'granted' && accessibility !== 'notApplicable' && (
|
||||
{accessibility !== 'granted' && accessibility !== 'notApplicable' && accessibility !== 'loading' && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user