Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 34 additions & 16 deletions openless-all/app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]
Expand Down Expand Up @@ -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,
]
};
Expand Down Expand Up @@ -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/ 下面。不会覆盖系统已有的插件。
Expand Down Expand Up @@ -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 已损坏 / 来自未识别开发者",
Expand All @@ -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)。
Expand Down Expand Up @@ -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")]
Expand All @@ -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}");
}
}
}
Expand Down
77 changes: 54 additions & 23 deletions openless-all/app/src/components/Onboarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
openSystemSettings,
requestAccessibilityPermission,
requestMicrophonePermission,
resetAccessibilityPermissionAndRestartApp,
} from '../lib/ipc';
import { getHotkeyTriggerLabel } from '../lib/hotkey';
import type { PermissionStatus, PlatformCapabilities } from '../lib/types';
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -368,26 +375,50 @@ 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')}
desc={capability?.requiresAccessibilityPermission
? t('onboarding.accessibilityDesc', { trigger: getHotkeyTriggerLabel(capability.availableTriggers[0]) })
: capability?.statusHint ?? t('onboarding.hotkeyDesc')}
status={accessibility}
actionLabel={
!capability?.requiresAccessibilityPermission || accessibility === 'notApplicable'
? t('onboarding.actionNotApplicable')
: accessibility === 'granted'
? t('onboarding.actionGranted')
: accessibility === 'denied'
? t('onboarding.actionOpenSystem')
: t('onboarding.actionGrant')
}
onAction={onGrantAccessibility}
disabled={busy || accessibility === 'granted' || accessibility === 'notApplicable'}
hint={capability?.requiresAccessibilityPermission ? t('onboarding.accessibilityHint') : undefined}
/>
<>
<PermissionStep
index={1}
title={capability?.requiresAccessibilityPermission ? t('onboarding.accessibilityTitle') : t('onboarding.hotkeyTitle')}
desc={capability?.requiresAccessibilityPermission
? t('onboarding.accessibilityDesc', { trigger: getHotkeyTriggerLabel(capability.availableTriggers[0]) })
: capability?.statusHint ?? t('onboarding.hotkeyDesc')}
status={accessibility}
actionLabel={
!capability?.requiresAccessibilityPermission || accessibility === 'notApplicable'
? t('onboarding.actionNotApplicable')
: accessibility === 'granted'
? t('onboarding.actionGranted')
: accessibility === 'denied' && tccPromptShown
? t('onboarding.actionOpenSystem')
: t('onboarding.actionGrant')
}
onAction={onGrantAccessibility}
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
Expand Down
2 changes: 2 additions & 0 deletions openless-all/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).',
Expand Down Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions openless-all/app/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ export const ja: typeof zhCN = {
actionNotApplicable: '権限不要',
actionGranted: '許可済み',
actionOpenSystem: 'システム設定を開く',
actionRestart: 'アクセシビリティをリセットして OpenLess を再起動',
actionGrant: '許可する',
actionRequestMic: '許可ダイアログを表示',
accessibilityHint: '許可後は **OpenLess を完全に終了** してから再起動してください(macOS TCC の仕様)。',
Expand Down Expand Up @@ -845,6 +846,7 @@ export const ja: typeof zhCN = {
denied: '未許可',
indeterminate: '未確定',
openSystem: 'システム設定を開く',
restart: 'リセットして再起動',
grant: '許可する',
rerunAndroidSetup: 'セットアップを再実行',
hotkeyInstalled: 'インストール済み',
Expand Down
2 changes: 2 additions & 0 deletions openless-all/app/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ export const ko: typeof zhCN = {
actionNotApplicable: '권한 불필요',
actionGranted: '허용됨',
actionOpenSystem: '시스템 설정 열기',
actionRestart: '접근성 권한 재설정 후 OpenLess 재시작',
actionGrant: '허용',
actionRequestMic: '권한 대화상자 표시',
accessibilityHint: '허용 후에는 **OpenLess 를 완전히 종료** 한 다음 다시 실행해야 합니다(macOS TCC 규칙).',
Expand Down Expand Up @@ -845,6 +846,7 @@ export const ko: typeof zhCN = {
denied: '허용되지 않음',
indeterminate: '미결정',
openSystem: '시스템 설정 열기',
restart: '재설정 후 재시작',
grant: '허용',
rerunAndroidSetup: '설정 마법사 다시 실행',
hotkeyInstalled: '설치됨',
Expand Down
2 changes: 2 additions & 0 deletions openless-all/app/src/i18n/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ export const zhCN = {
actionNotApplicable: '无需授权',
actionGranted: '已授权',
actionOpenSystem: '打开系统设置',
actionRestart: '重置授权并重启 OpenLess',
actionGrant: '授权',
actionRequestMic: '弹出授权',
accessibilityHint: '授权后必须**完全退出 OpenLess** 再重新打开(macOS TCC 规则)。',
Expand Down Expand Up @@ -841,6 +842,7 @@ export const zhCN = {
denied: '未授权',
indeterminate: '未确定',
openSystem: '打开系统设置',
restart: '重置授权并重启',
grant: '授权',
rerunAndroidSetup: '重新运行设置向导',
hotkeyInstalled: '已安装',
Expand Down
2 changes: 2 additions & 0 deletions openless-all/app/src/i18n/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ export const zhTW: typeof zhCN = {
actionNotApplicable: '無需授權',
actionGranted: '已授權',
actionOpenSystem: '打開系統設置',
actionRestart: '重置授權並重新啟動 OpenLess',
actionGrant: '授權',
actionRequestMic: '彈出授權',
accessibilityHint: '授權後必須**完全退出 OpenLess** 再重新打開(macOS TCC 規則)。',
Expand Down Expand Up @@ -843,6 +844,7 @@ export const zhTW: typeof zhCN = {
denied: '未授權',
indeterminate: '未確定',
openSystem: '打開系統設置',
restart: '重置授權並重新啟動',
grant: '授權',
rerunAndroidSetup: '重新執行設定向導',
hotkeyInstalled: '已安裝',
Expand Down
1 change: 1 addition & 0 deletions openless-all/app/src/lib/ipc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export {
openSystemSettings,
triggerMicrophonePrompt,
restartApp,
resetAccessibilityPermissionAndRestartApp,
} from "./permissions"

// hotkeys
Expand Down
8 changes: 8 additions & 0 deletions openless-all/app/src/lib/ipc/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
}
16 changes: 13 additions & 3 deletions openless-all/app/src/pages/settings/PermissionsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
openSystemSettings,
requestAccessibilityPermission,
requestMicrophonePermission,
resetAccessibilityPermissionAndRestartApp,
} from '../../lib/ipc';
import type { NetworkCheckResult } from '../../lib/ipc';
import { getPlatformCapabilities } from '../../lib/platform';
Expand Down Expand Up @@ -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();
};

Expand Down Expand Up @@ -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>
Expand Down
Loading