mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-27 13:29:15 +07:00
✨ feat(desktop): improve macOS permission requests and Full Disk Access detection (#11380)
* ✨ feat(desktop): improve macOS permission requests and Full Disk Access detection - Add microphone and camera entitlements for hardened runtime - Implement Full Disk Access detection using protected directory check - Add native dialog prompt for Full Disk Access permission - Add window focus broadcast for permission status refresh - Extract Full Disk Access utilities to separate module - Remove macOS-specific permissions from Linux/Windows menus - Update PermissionsStep UI to show checkmark for all granted permissions - Add comprehensive tests for permission methods * ✨ feat(desktop): persist onboarding step for app restart recovery - Add storage functions to persist/restore current onboarding step - Restore step from localStorage on app restart (prioritized over URL params) - Clear persisted step when onboarding completes - Remove unused fullDisk.autoAdd translation key
This commit is contained in:
@@ -2,11 +2,20 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Hardened Runtime exceptions for Electron -->
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
|
||||
<!-- Microphone access for voice interactions -->
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
|
||||
<!-- Camera access (for future video features) -->
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
"error.detail": "操作未完成。你可以重试,或稍后再试。",
|
||||
"error.message": "发生错误",
|
||||
"error.title": "错误",
|
||||
"fullDiskAccess.message": "LobeHub 需要完全磁盘访问权限来读取文件并启用知识库功能。请在系统设置中授予权限。",
|
||||
"fullDiskAccess.openSettings": "打开设置",
|
||||
"fullDiskAccess.skip": "稍后",
|
||||
"fullDiskAccess.title": "需要完全磁盘访问权限",
|
||||
"update.checkingUpdate": "检查新版本",
|
||||
"update.checkingUpdateDesc": "正在获取版本信息…",
|
||||
"update.downloadAndInstall": "下载并安装",
|
||||
@@ -41,4 +45,4 @@
|
||||
"waitingOAuth.helpText": "如果浏览器没有自动打开,请点击取消后重新尝试",
|
||||
"waitingOAuth.retry": "重试",
|
||||
"waitingOAuth.title": "等待授权连接"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,13 @@
|
||||
"dev.openStore": "打开本地数据目录",
|
||||
"dev.openUpdaterCacheDir": "更新缓存目录",
|
||||
"dev.openUserDataDir": "用户配置目录",
|
||||
"dev.permissions.accessibility.request": "请求辅助功能权限",
|
||||
"dev.permissions.fullDisk.open": "打开「完全磁盘访问」设置",
|
||||
"dev.permissions.fullDisk.request": "请求完全磁盘访问权限",
|
||||
"dev.permissions.microphone.request": "请求麦克风权限",
|
||||
"dev.permissions.notification.request": "请求通知权限",
|
||||
"dev.permissions.screen.request": "请求屏幕录制权限",
|
||||
"dev.permissions.title": "权限",
|
||||
"dev.refreshMenu": "刷新菜单",
|
||||
"dev.reload": "重新加载",
|
||||
"dev.simulateAutoDownload": "模拟启动后台自动下载更新(3s 下完)",
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { ElectronAppState, ThemeMode } from '@lobechat/electron-client-ipc';
|
||||
import { app, dialog, nativeTheme, shell, systemPreferences } from 'electron';
|
||||
import { app, desktopCapturer, dialog, nativeTheme, shell, systemPreferences } from 'electron';
|
||||
import { macOS } from 'electron-is';
|
||||
import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
import { checkFullDiskAccess, openFullDiskAccessSettings } from '@/utils/fullDiskAccess';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
import fullDiskAccessAutoAddScript from './scripts/full-disk-access.applescript?raw';
|
||||
|
||||
const logger = createLogger('controllers:SystemCtr');
|
||||
|
||||
@@ -57,14 +55,98 @@ export default class SystemController extends ControllerModule {
|
||||
|
||||
@IpcMethod()
|
||||
requestAccessibilityAccess() {
|
||||
if (!macOS()) return true;
|
||||
return systemPreferences.isTrustedAccessibilityClient(true);
|
||||
if (!macOS()) {
|
||||
logger.info('[Accessibility] Not macOS, returning true');
|
||||
return true;
|
||||
}
|
||||
logger.info('[Accessibility] Requesting accessibility access (will prompt if not granted)...');
|
||||
// Pass true to prompt user if not already trusted
|
||||
const result = systemPreferences.isTrustedAccessibilityClient(true);
|
||||
logger.info(`[Accessibility] isTrustedAccessibilityClient(true) returned: ${result}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
getAccessibilityStatus() {
|
||||
if (!macOS()) return true;
|
||||
return systemPreferences.isTrustedAccessibilityClient(false);
|
||||
if (!macOS()) {
|
||||
logger.info('[Accessibility] Not macOS, returning true');
|
||||
return true;
|
||||
}
|
||||
// Pass false to just check without prompting
|
||||
const status = systemPreferences.isTrustedAccessibilityClient(false);
|
||||
logger.info(`[Accessibility] Current status: ${status}`);
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Full Disk Access is granted.
|
||||
* This works by attempting to read a protected system directory.
|
||||
* Calling this also registers the app in the TCC database, making it appear
|
||||
* in System Settings > Privacy & Security > Full Disk Access.
|
||||
*/
|
||||
@IpcMethod()
|
||||
getFullDiskAccessStatus(): boolean {
|
||||
return checkFullDiskAccess();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt the user with a native dialog if Full Disk Access is not granted.
|
||||
* Based on https://github.com/inket/FullDiskAccess
|
||||
*
|
||||
* @param options - Dialog options
|
||||
* @returns 'granted' if already granted, 'opened_settings' if user chose to open settings,
|
||||
* 'skipped' if user chose to skip, 'cancelled' if dialog was cancelled
|
||||
*/
|
||||
@IpcMethod()
|
||||
async promptFullDiskAccessIfNotGranted(options?: {
|
||||
message?: string;
|
||||
openSettingsButtonText?: string;
|
||||
skipButtonText?: string;
|
||||
title?: string;
|
||||
}): Promise<'cancelled' | 'granted' | 'opened_settings' | 'skipped'> {
|
||||
// Check if already granted
|
||||
if (checkFullDiskAccess()) {
|
||||
logger.info('[FullDiskAccess] Already granted, skipping prompt');
|
||||
|
||||
return 'granted';
|
||||
}
|
||||
|
||||
if (!macOS()) {
|
||||
logger.info('[FullDiskAccess] Not macOS, returning granted');
|
||||
return 'granted';
|
||||
}
|
||||
|
||||
const mainWindow = this.app.browserManager.getMainWindow()?.browserWindow;
|
||||
|
||||
// Get localized strings
|
||||
const t = this.app.i18n.ns('dialog');
|
||||
const title = options?.title || t('fullDiskAccess.title');
|
||||
const message = options?.message || t('fullDiskAccess.message');
|
||||
const openSettingsButtonText =
|
||||
options?.openSettingsButtonText || t('fullDiskAccess.openSettings');
|
||||
const skipButtonText = options?.skipButtonText || t('fullDiskAccess.skip');
|
||||
|
||||
logger.info('[FullDiskAccess] Showing native prompt dialog');
|
||||
|
||||
const result = await dialog.showMessageBox(mainWindow!, {
|
||||
buttons: [openSettingsButtonText, skipButtonText],
|
||||
cancelId: 1,
|
||||
defaultId: 0,
|
||||
message: message,
|
||||
title: title,
|
||||
type: 'info',
|
||||
});
|
||||
|
||||
if (result.response === 0) {
|
||||
// User chose to open settings
|
||||
logger.info('[FullDiskAccess] User chose to open settings');
|
||||
await this.openFullDiskAccessSettings();
|
||||
return 'opened_settings';
|
||||
} else {
|
||||
// User chose to skip or cancelled
|
||||
logger.info('[FullDiskAccess] User chose to skip');
|
||||
return 'skipped';
|
||||
}
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
@@ -75,119 +157,129 @@ export default class SystemController extends ControllerModule {
|
||||
|
||||
@IpcMethod()
|
||||
async requestMicrophoneAccess(): Promise<boolean> {
|
||||
if (!macOS()) return true;
|
||||
return systemPreferences.askForMediaAccess('microphone');
|
||||
if (!macOS()) {
|
||||
logger.info('[Microphone] Not macOS, returning true');
|
||||
return true;
|
||||
}
|
||||
|
||||
const status = systemPreferences.getMediaAccessStatus('microphone');
|
||||
logger.info(`[Microphone] Current status: ${status}`);
|
||||
|
||||
// Only ask for access if status is 'not-determined'
|
||||
// If already denied/restricted, the system won't show a prompt
|
||||
if (status === 'not-determined') {
|
||||
logger.info('[Microphone] Status is not-determined, calling askForMediaAccess...');
|
||||
try {
|
||||
const result = await systemPreferences.askForMediaAccess('microphone');
|
||||
logger.info(`[Microphone] askForMediaAccess result: ${result}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('[Microphone] askForMediaAccess failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'granted') {
|
||||
logger.info('[Microphone] Already granted');
|
||||
return true;
|
||||
}
|
||||
|
||||
// If denied or restricted, open System Settings for manual enable
|
||||
logger.info(`[Microphone] Status is ${status}, opening System Settings...`);
|
||||
await shell.openExternal(
|
||||
'x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone',
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async requestScreenAccess(): Promise<boolean> {
|
||||
if (!macOS()) return true;
|
||||
if (!macOS()) {
|
||||
logger.info('[Screen] Not macOS, returning true');
|
||||
return true;
|
||||
}
|
||||
|
||||
const status = systemPreferences.getMediaAccessStatus('screen');
|
||||
logger.info(`[Screen] Current status: ${status}`);
|
||||
|
||||
// If already granted, no need to do anything
|
||||
if (status === 'granted') {
|
||||
logger.info('[Screen] Already granted');
|
||||
return true;
|
||||
}
|
||||
|
||||
// IMPORTANT:
|
||||
// On macOS, the app may NOT appear in "Screen Recording" list until it actually
|
||||
// requests the permission once (TCC needs to register this app).
|
||||
// So we try to proactively request it first, then open System Settings for manual toggle.
|
||||
// 1) Best-effort: try Electron runtime API if available (not typed in Electron 38).
|
||||
// We use multiple approaches to ensure TCC registration:
|
||||
// 1. desktopCapturer.getSources() in main process
|
||||
// 2. getDisplayMedia() in renderer as fallback
|
||||
|
||||
// Approach 1: Use desktopCapturer in main process
|
||||
logger.info('[Screen] Attempting TCC registration via desktopCapturer.getSources...');
|
||||
try {
|
||||
const status = systemPreferences.getMediaAccessStatus('screen');
|
||||
if (status !== 'granted') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
await (systemPreferences as any).askForMediaAccess?.('screen');
|
||||
}
|
||||
// Using a reasonable thumbnail size and both types to ensure TCC registration
|
||||
const sources = await desktopCapturer.getSources({
|
||||
fetchWindowIcons: true,
|
||||
thumbnailSize: { height: 144, width: 256 },
|
||||
types: ['screen', 'window'],
|
||||
});
|
||||
// Access the sources to ensure the capture actually happens
|
||||
logger.info(`[Screen] desktopCapturer.getSources returned ${sources.length} sources`);
|
||||
} catch (error) {
|
||||
logger.warn('Failed to request screen recording access via systemPreferences', error);
|
||||
logger.warn('[Screen] desktopCapturer.getSources failed:', error);
|
||||
}
|
||||
|
||||
// 2) Reliable trigger: run a one-shot getDisplayMedia in renderer to register TCC entry.
|
||||
// This will show the OS capture picker; once the user selects/cancels, we stop tracks immediately.
|
||||
// Approach 2: Trigger getDisplayMedia in renderer as additional attempt
|
||||
// This shows the OS capture picker which definitely registers with TCC
|
||||
logger.info('[Screen] Attempting TCC registration via getDisplayMedia in renderer...');
|
||||
try {
|
||||
const status = systemPreferences.getMediaAccessStatus('screen');
|
||||
if (status !== 'granted') {
|
||||
const mainWindow = this.app.browserManager.getMainWindow()?.browserWindow;
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
const script = `
|
||||
(() => {
|
||||
const stop = (stream) => {
|
||||
try { stream.getTracks().forEach((t) => t.stop()); } catch {}
|
||||
};
|
||||
return navigator.mediaDevices.getDisplayMedia({ video: true, audio: false })
|
||||
.then((stream) => { stop(stream); return true; })
|
||||
.catch(() => false);
|
||||
const mainWindow = this.app.browserManager.getMainWindow()?.browserWindow;
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
const script = `
|
||||
(async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false });
|
||||
stream.getTracks().forEach(t => t.stop());
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('[Screen] getDisplayMedia error:', e);
|
||||
return false;
|
||||
}
|
||||
})()
|
||||
`.trim();
|
||||
`.trim();
|
||||
|
||||
await mainWindow.webContents.executeJavaScript(script, true);
|
||||
}
|
||||
const result = await mainWindow.webContents.executeJavaScript(script, true);
|
||||
logger.info(`[Screen] getDisplayMedia result: ${result}`);
|
||||
} else {
|
||||
logger.warn('[Screen] Main window not available for getDisplayMedia');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to request screen recording access via getDisplayMedia', error);
|
||||
logger.warn('[Screen] getDisplayMedia failed:', error);
|
||||
}
|
||||
|
||||
// Check status after attempts
|
||||
const newStatus = systemPreferences.getMediaAccessStatus('screen');
|
||||
logger.info(`[Screen] Status after TCC attempts: ${newStatus}`);
|
||||
|
||||
// Open System Settings for user to manually enable screen recording
|
||||
logger.info('[Screen] Opening System Settings for Screen Recording...');
|
||||
await shell.openExternal(
|
||||
'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture',
|
||||
);
|
||||
|
||||
return systemPreferences.getMediaAccessStatus('screen') === 'granted';
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
openFullDiskAccessSettings(payload?: { autoAdd?: boolean }) {
|
||||
if (!macOS()) return;
|
||||
const { autoAdd = false } = payload || {};
|
||||
|
||||
// NOTE:
|
||||
// - Full Disk Access cannot be requested programmatically like microphone/screen.
|
||||
// - On macOS 13+ (Ventura), System Preferences is replaced by System Settings,
|
||||
// and deep links may differ. We try multiple known schemes for compatibility.
|
||||
const candidates = [
|
||||
// macOS 13+ (System Settings)
|
||||
'com.apple.settings:Privacy&path=FullDiskAccess',
|
||||
// Older macOS (System Preferences)
|
||||
'x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles',
|
||||
];
|
||||
if (autoAdd) this.tryAutoAddFullDiskAccess();
|
||||
|
||||
(async () => {
|
||||
for (const url of candidates) {
|
||||
try {
|
||||
await shell.openExternal(url);
|
||||
return;
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to open Full Disk Access settings via ${url}`, error);
|
||||
}
|
||||
}
|
||||
})();
|
||||
const finalStatus = systemPreferences.getMediaAccessStatus('screen');
|
||||
logger.info(`[Screen] Final status: ${finalStatus}`);
|
||||
return finalStatus === 'granted';
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort UI automation to add this app into Full Disk Access list.
|
||||
*
|
||||
* Limitations:
|
||||
* - This uses AppleScript UI scripting (System Events) and may require the user to grant
|
||||
* additional "Automation" permission (to control System Settings).
|
||||
* - UI structure differs across macOS versions/languages; we fall back silently.
|
||||
* Open Full Disk Access settings page
|
||||
*/
|
||||
private tryAutoAddFullDiskAccess() {
|
||||
if (!macOS()) return;
|
||||
|
||||
const exePath = app.getPath('exe');
|
||||
// /Applications/App.app/Contents/MacOS/App -> /Applications/App.app
|
||||
const appBundlePath = path.resolve(path.dirname(exePath), '..', '..');
|
||||
|
||||
// Keep the script minimal and resilient; failure should not break onboarding flow.
|
||||
const script = fullDiskAccessAutoAddScript.trim();
|
||||
|
||||
try {
|
||||
const child = spawn('osascript', ['-e', script, appBundlePath], { env: process.env });
|
||||
child.on('error', (error) => {
|
||||
logger.warn('Full Disk Access auto-add (osascript) failed to start', error);
|
||||
});
|
||||
child.on('exit', (code) => {
|
||||
logger.debug('Full Disk Access auto-add (osascript) exited', { code });
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('Full Disk Access auto-add failed', error);
|
||||
}
|
||||
@IpcMethod()
|
||||
async openFullDiskAccessSettings() {
|
||||
return openFullDiskAccessSettings();
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
|
||||
@@ -7,12 +7,13 @@ import { IpcHandler } from '@/utils/ipc/base';
|
||||
|
||||
import SystemController from '../SystemCtr';
|
||||
|
||||
const { ipcHandlers, ipcMainHandleMock } = vi.hoisted(() => {
|
||||
const { ipcHandlers, ipcMainHandleMock, readdirSyncMock } = vi.hoisted(() => {
|
||||
const handlers = new Map<string, (event: any, ...args: any[]) => any>();
|
||||
const handle = vi.fn((channel: string, handler: any) => {
|
||||
handlers.set(channel, handler);
|
||||
});
|
||||
return { ipcHandlers: handlers, ipcMainHandleMock: handle };
|
||||
const readdirSync = vi.fn();
|
||||
return { ipcHandlers: handlers, ipcMainHandleMock: handle, readdirSyncMock: readdirSync };
|
||||
});
|
||||
|
||||
const invokeIpc = async <T = any>(
|
||||
@@ -44,28 +45,18 @@ vi.mock('@/utils/logger', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const { spawnMock } = vi.hoisted(() => ({
|
||||
spawnMock: vi.fn(() => {
|
||||
const handlers = new Map<string, (...args: any[]) => void>();
|
||||
return {
|
||||
on: vi.fn((event: string, cb: (...args: any[]) => void) => {
|
||||
handlers.set(event, cb);
|
||||
return undefined;
|
||||
}),
|
||||
} as any;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
spawn: (...args: any[]) => spawnMock.call(null, ...args),
|
||||
}));
|
||||
|
||||
// Mock electron
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getLocale: vi.fn(() => 'en-US'),
|
||||
getPath: vi.fn((name: string) => `/mock/path/${name}`),
|
||||
},
|
||||
desktopCapturer: {
|
||||
getSources: vi.fn(async () => []),
|
||||
},
|
||||
dialog: {
|
||||
showMessageBox: vi.fn(async () => ({ response: 0 })),
|
||||
},
|
||||
ipcMain: {
|
||||
handle: ipcMainHandleMock,
|
||||
},
|
||||
@@ -89,6 +80,32 @@ vi.mock('electron-is', () => ({
|
||||
macOS: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
// Mock node:fs for Full Disk Access check
|
||||
vi.mock('node:fs', () => ({
|
||||
default: {
|
||||
readdirSync: readdirSyncMock,
|
||||
},
|
||||
readdirSync: readdirSyncMock,
|
||||
}));
|
||||
|
||||
// Mock node:os for homedir and release
|
||||
vi.mock('node:os', () => ({
|
||||
default: {
|
||||
homedir: vi.fn(() => '/Users/testuser'),
|
||||
release: vi.fn(() => '23.0.0'), // Darwin 23 = macOS 14 (Sonoma)
|
||||
},
|
||||
homedir: vi.fn(() => '/Users/testuser'),
|
||||
release: vi.fn(() => '23.0.0'),
|
||||
}));
|
||||
|
||||
// Mock node:path
|
||||
vi.mock('node:path', () => ({
|
||||
default: {
|
||||
join: vi.fn((...args: string[]) => args.join('/')),
|
||||
},
|
||||
join: vi.fn((...args: string[]) => args.join('/')),
|
||||
}));
|
||||
|
||||
// Mock browserManager
|
||||
const mockBrowserManager = {
|
||||
broadcastToAllWindows: vi.fn(),
|
||||
@@ -112,6 +129,7 @@ const mockStoreManager = {
|
||||
// Mock i18n
|
||||
const mockI18n = {
|
||||
changeLanguage: vi.fn().mockResolvedValue(undefined),
|
||||
ns: vi.fn((namespace: string) => (key: string) => `${namespace}.${key}`),
|
||||
};
|
||||
|
||||
const mockApp = {
|
||||
@@ -177,14 +195,78 @@ describe('SystemController', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('screen recording', () => {
|
||||
it('should request screen recording access and open System Settings on macOS', async () => {
|
||||
describe('microphone access', () => {
|
||||
it('should ask for microphone access when status is not-determined', async () => {
|
||||
const { systemPreferences } = await import('electron');
|
||||
vi.mocked(systemPreferences.getMediaAccessStatus).mockReturnValue('not-determined');
|
||||
|
||||
await invokeIpc('system.requestMicrophoneAccess');
|
||||
|
||||
expect(systemPreferences.getMediaAccessStatus).toHaveBeenCalledWith('microphone');
|
||||
expect(systemPreferences.askForMediaAccess).toHaveBeenCalledWith('microphone');
|
||||
|
||||
// Reset
|
||||
vi.mocked(systemPreferences.getMediaAccessStatus).mockReturnValue('not-determined');
|
||||
});
|
||||
|
||||
it('should return true immediately if microphone access is already granted', async () => {
|
||||
const { shell, systemPreferences } = await import('electron');
|
||||
vi.mocked(systemPreferences.getMediaAccessStatus).mockReturnValue('granted');
|
||||
|
||||
const result = await invokeIpc('system.requestMicrophoneAccess');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(systemPreferences.askForMediaAccess).not.toHaveBeenCalled();
|
||||
expect(shell.openExternal).not.toHaveBeenCalled();
|
||||
|
||||
// Reset
|
||||
vi.mocked(systemPreferences.getMediaAccessStatus).mockReturnValue('not-determined');
|
||||
});
|
||||
|
||||
it('should open System Settings if microphone access is denied', async () => {
|
||||
const { shell, systemPreferences } = await import('electron');
|
||||
vi.mocked(systemPreferences.getMediaAccessStatus).mockReturnValue('denied');
|
||||
|
||||
const result = await invokeIpc('system.requestMicrophoneAccess');
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(systemPreferences.askForMediaAccess).not.toHaveBeenCalled();
|
||||
expect(shell.openExternal).toHaveBeenCalledWith(
|
||||
'x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone',
|
||||
);
|
||||
|
||||
// Reset
|
||||
vi.mocked(systemPreferences.getMediaAccessStatus).mockReturnValue('not-determined');
|
||||
});
|
||||
|
||||
it('should return true on non-macOS', async () => {
|
||||
const { macOS } = await import('electron-is');
|
||||
const { shell, systemPreferences } = await import('electron');
|
||||
vi.mocked(macOS).mockReturnValue(false);
|
||||
|
||||
const result = await invokeIpc('system.requestMicrophoneAccess');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(systemPreferences.getMediaAccessStatus).not.toHaveBeenCalled();
|
||||
expect(shell.openExternal).not.toHaveBeenCalled();
|
||||
|
||||
// Reset
|
||||
vi.mocked(macOS).mockReturnValue(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('screen recording', () => {
|
||||
it('should use desktopCapturer and getDisplayMedia to trigger TCC and open System Settings on macOS', async () => {
|
||||
const { desktopCapturer, shell, systemPreferences } = await import('electron');
|
||||
|
||||
const result = await invokeIpc('system.requestScreenAccess');
|
||||
|
||||
expect(systemPreferences.getMediaAccessStatus).toHaveBeenCalledWith('screen');
|
||||
expect(systemPreferences.askForMediaAccess).toHaveBeenCalledWith('screen');
|
||||
expect(desktopCapturer.getSources).toHaveBeenCalledWith({
|
||||
fetchWindowIcons: true,
|
||||
thumbnailSize: { height: 144, width: 256 },
|
||||
types: ['screen', 'window'],
|
||||
});
|
||||
expect(mockBrowserManager.getMainWindow).toHaveBeenCalled();
|
||||
expect(shell.openExternal).toHaveBeenCalledWith(
|
||||
'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture',
|
||||
@@ -192,15 +274,29 @@ describe('SystemController', () => {
|
||||
expect(typeof result).toBe('boolean');
|
||||
});
|
||||
|
||||
it('should return true immediately if screen access is already granted', async () => {
|
||||
const { desktopCapturer, shell, systemPreferences } = await import('electron');
|
||||
vi.mocked(systemPreferences.getMediaAccessStatus).mockReturnValue('granted');
|
||||
|
||||
const result = await invokeIpc('system.requestScreenAccess');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(desktopCapturer.getSources).not.toHaveBeenCalled();
|
||||
expect(shell.openExternal).not.toHaveBeenCalled();
|
||||
|
||||
// Reset
|
||||
vi.mocked(systemPreferences.getMediaAccessStatus).mockReturnValue('not-determined');
|
||||
});
|
||||
|
||||
it('should return true on non-macOS and not open settings', async () => {
|
||||
const { macOS } = await import('electron-is');
|
||||
const { shell, systemPreferences } = await import('electron');
|
||||
const { desktopCapturer, shell } = await import('electron');
|
||||
vi.mocked(macOS).mockReturnValue(false);
|
||||
|
||||
const result = await invokeIpc('system.requestScreenAccess');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(systemPreferences.askForMediaAccess).not.toHaveBeenCalled();
|
||||
expect(desktopCapturer.getSources).not.toHaveBeenCalled();
|
||||
expect(shell.openExternal).not.toHaveBeenCalled();
|
||||
|
||||
// Reset
|
||||
@@ -209,6 +305,40 @@ describe('SystemController', () => {
|
||||
});
|
||||
|
||||
describe('full disk access', () => {
|
||||
it('should return true when Full Disk Access is granted (can read protected directory)', async () => {
|
||||
readdirSyncMock.mockReturnValue(['file1', 'file2']);
|
||||
|
||||
const result = await invokeIpc('system.getFullDiskAccessStatus');
|
||||
|
||||
expect(result).toBe(true);
|
||||
// On macOS 14 (Darwin 23), should check com.apple.stocks
|
||||
expect(readdirSyncMock).toHaveBeenCalledWith(
|
||||
'/Users/testuser/Library/Containers/com.apple.stocks',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false when Full Disk Access is not granted (cannot read protected directory)', async () => {
|
||||
readdirSyncMock.mockImplementation(() => {
|
||||
throw new Error('EPERM: operation not permitted');
|
||||
});
|
||||
|
||||
const result = await invokeIpc('system.getFullDiskAccessStatus');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true on non-macOS', async () => {
|
||||
const { macOS } = await import('electron-is');
|
||||
vi.mocked(macOS).mockReturnValue(false);
|
||||
|
||||
const result = await invokeIpc('system.getFullDiskAccessStatus');
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Reset
|
||||
vi.mocked(macOS).mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('should try to open Full Disk Access settings with fallbacks', async () => {
|
||||
const { shell } = await import('electron');
|
||||
vi.mocked(shell.openExternal)
|
||||
@@ -218,25 +348,64 @@ describe('SystemController', () => {
|
||||
await invokeIpc('system.openFullDiskAccessSettings');
|
||||
|
||||
expect(shell.openExternal).toHaveBeenCalledWith(
|
||||
'com.apple.settings:Privacy&path=FullDiskAccess',
|
||||
'x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles',
|
||||
);
|
||||
expect(shell.openExternal).toHaveBeenCalledWith(
|
||||
'x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles',
|
||||
);
|
||||
});
|
||||
|
||||
it('should spawn osascript when autoAdd is enabled', async () => {
|
||||
it('should open fallback Privacy settings if all candidates fail', async () => {
|
||||
const { shell } = await import('electron');
|
||||
vi.mocked(shell.openExternal).mockResolvedValueOnce(undefined);
|
||||
vi.mocked(shell.openExternal)
|
||||
.mockRejectedValueOnce(new Error('fail first'))
|
||||
.mockRejectedValueOnce(new Error('fail second'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
await invokeIpc('system.openFullDiskAccessSettings', { autoAdd: true });
|
||||
await invokeIpc('system.openFullDiskAccessSettings');
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'osascript',
|
||||
expect.arrayContaining(['-e', expect.any(String), expect.any(String)]),
|
||||
expect.objectContaining({ env: expect.any(Object) }),
|
||||
expect(shell.openExternal).toHaveBeenCalledWith(
|
||||
'x-apple.systempreferences:com.apple.preference.security?Privacy',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return granted if Full Disk Access is already granted', async () => {
|
||||
readdirSyncMock.mockReturnValue(['file1', 'file2']);
|
||||
|
||||
const result = await invokeIpc('system.promptFullDiskAccessIfNotGranted');
|
||||
|
||||
expect(result).toBe('granted');
|
||||
});
|
||||
|
||||
it('should show dialog and open settings when user clicks Open Settings', async () => {
|
||||
const { dialog, shell } = await import('electron');
|
||||
readdirSyncMock.mockImplementation(() => {
|
||||
throw new Error('EPERM: operation not permitted');
|
||||
});
|
||||
vi.mocked(dialog.showMessageBox).mockResolvedValue({ response: 0 } as any);
|
||||
|
||||
const result = await invokeIpc('system.promptFullDiskAccessIfNotGranted');
|
||||
|
||||
expect(result).toBe('opened_settings');
|
||||
expect(dialog.showMessageBox).toHaveBeenCalled();
|
||||
expect(shell.openExternal).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return skipped when user clicks Later', async () => {
|
||||
const { dialog, shell } = await import('electron');
|
||||
readdirSyncMock.mockImplementation(() => {
|
||||
throw new Error('EPERM: operation not permitted');
|
||||
});
|
||||
vi.mocked(dialog.showMessageBox).mockResolvedValue({ response: 1 } as any);
|
||||
vi.mocked(shell.openExternal).mockClear();
|
||||
|
||||
const result = await invokeIpc('system.promptFullDiskAccessIfNotGranted');
|
||||
|
||||
expect(result).toBe('skipped');
|
||||
expect(dialog.showMessageBox).toHaveBeenCalled();
|
||||
// Should not open settings when user skips
|
||||
expect(shell.openExternal).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('openExternalLink', () => {
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
on run argv
|
||||
set appBundlePath to item 1 of argv
|
||||
|
||||
set settingsBundleIds to {"com.apple.SystemSettings", "com.apple.systempreferences"}
|
||||
|
||||
-- Bring System Settings/Preferences to front (Ventura+ / older). If it doesn't exist, ignore.
|
||||
repeat with bundleId in settingsBundleIds
|
||||
try
|
||||
tell application id bundleId to activate
|
||||
exit repeat
|
||||
end try
|
||||
end repeat
|
||||
|
||||
tell application "System Events"
|
||||
set settingsProcess to missing value
|
||||
repeat 30 times
|
||||
repeat with bundleId in settingsBundleIds
|
||||
try
|
||||
if exists (first process whose bundle identifier is bundleId) then
|
||||
set settingsProcess to first process whose bundle identifier is bundleId
|
||||
exit repeat
|
||||
end if
|
||||
end try
|
||||
end repeat
|
||||
|
||||
if settingsProcess is not missing value then exit repeat
|
||||
delay 0.2
|
||||
end repeat
|
||||
|
||||
if settingsProcess is missing value then return "no-settings-process"
|
||||
|
||||
tell settingsProcess
|
||||
set frontmost to true
|
||||
|
||||
repeat 30 times
|
||||
if exists window 1 then exit repeat
|
||||
delay 0.2
|
||||
end repeat
|
||||
if not (exists window 1) then return "no-window"
|
||||
|
||||
-- Best-effort: find an "add" button in the front window and click it.
|
||||
set clickedAdd to false
|
||||
repeat 30 times
|
||||
try
|
||||
repeat with b in (buttons of window 1)
|
||||
set bDesc to ""
|
||||
set bName to ""
|
||||
set bTitle to ""
|
||||
try set bDesc to description of b end try
|
||||
try set bName to name of b end try
|
||||
try set bTitle to title of b end try
|
||||
|
||||
if (bDesc is "Add") or (bTitle is "Add") or (bName is "+") or (bTitle is "+") then
|
||||
click b
|
||||
set clickedAdd to true
|
||||
exit repeat
|
||||
end if
|
||||
end repeat
|
||||
end try
|
||||
|
||||
if clickedAdd is true then exit repeat
|
||||
delay 0.2
|
||||
end repeat
|
||||
|
||||
if clickedAdd is false then return "no-add-button"
|
||||
|
||||
-- Wait for open panel / sheet
|
||||
repeat 30 times
|
||||
if exists sheet 1 of window 1 then exit repeat
|
||||
delay 0.2
|
||||
end repeat
|
||||
if not (exists sheet 1 of window 1) then return "no-sheet"
|
||||
|
||||
-- Open "Go to the folder" and input the app bundle path, then confirm.
|
||||
keystroke "G" using {command down, shift down}
|
||||
delay 0.3
|
||||
keystroke appBundlePath
|
||||
key code 36
|
||||
delay 0.6
|
||||
-- Confirm "Open" in the panel (Enter usually triggers default)
|
||||
key code 36
|
||||
return "ok"
|
||||
end tell
|
||||
end tell
|
||||
end run
|
||||
@@ -183,6 +183,7 @@ export default class Browser {
|
||||
private setupEventListeners(browserWindow: BrowserWindow): void {
|
||||
this.setupReadyToShowListener(browserWindow);
|
||||
this.setupCloseListener(browserWindow);
|
||||
this.setupFocusListener(browserWindow);
|
||||
}
|
||||
|
||||
private setupReadyToShowListener(browserWindow: BrowserWindow): void {
|
||||
@@ -207,6 +208,14 @@ export default class Browser {
|
||||
browserWindow.on('close', closeHandler);
|
||||
}
|
||||
|
||||
private setupFocusListener(browserWindow: BrowserWindow): void {
|
||||
logger.debug(`[${this.identifier}] Setting up 'focus' event listener.`);
|
||||
browserWindow.on('focus', () => {
|
||||
logger.debug(`[${this.identifier}] Window 'focus' event fired.`);
|
||||
this.broadcast('windowFocused');
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Window Actions ====================
|
||||
|
||||
show(): void {
|
||||
|
||||
@@ -8,9 +8,14 @@ const dialog = {
|
||||
'confirm.title': 'Please confirm',
|
||||
'confirm.yes': 'Continue',
|
||||
'error.button': 'OK',
|
||||
'error.detail': 'Couldn\'t complete the action. Retry or try again later.',
|
||||
'error.detail': "Couldn't complete the action. Retry or try again later.",
|
||||
'error.message': 'An error occurred',
|
||||
'error.title': 'Error',
|
||||
'fullDiskAccess.message':
|
||||
'LobeHub needs Full Disk Access to read files and enable knowledge base features. Please grant access in System Settings.',
|
||||
'fullDiskAccess.openSettings': 'Open Settings',
|
||||
'fullDiskAccess.skip': 'Later',
|
||||
'fullDiskAccess.title': 'Full Disk Access Required',
|
||||
'update.downloadAndInstall': 'Download and Install',
|
||||
'update.downloadComplete': 'Download Complete',
|
||||
'update.downloadCompleteMessage': 'Update downloaded. Install now?',
|
||||
@@ -22,4 +27,4 @@ const dialog = {
|
||||
'update.skipThisVersion': 'Skip This Version',
|
||||
};
|
||||
|
||||
export default dialog;
|
||||
export default dialog;
|
||||
|
||||
@@ -7,6 +7,13 @@ const menu = {
|
||||
'dev.openStore': 'Open Data Folder',
|
||||
'dev.openUpdaterCacheDir': 'Open Updater Cache',
|
||||
'dev.openUserDataDir': 'Open User Data',
|
||||
'dev.permissions.accessibility.request': 'Request Accessibility Permission',
|
||||
'dev.permissions.fullDisk.open': 'Open Full Disk Access Settings',
|
||||
'dev.permissions.fullDisk.request': 'Request Full Disk Access Permission',
|
||||
'dev.permissions.microphone.request': 'Request Microphone Permission',
|
||||
'dev.permissions.notification.request': 'Request Notification Permission',
|
||||
'dev.permissions.screen.request': 'Request Screen Recording Permission',
|
||||
'dev.permissions.title': 'Permissions',
|
||||
'dev.refreshMenu': 'Refresh Menu',
|
||||
'dev.reload': 'Reload',
|
||||
'dev.simulateAutoDownload': 'Simulate Auto Download (3s)',
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Menu, MenuItemConstructorOptions, app, shell } from 'electron';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { isDev } from '@/const/env';
|
||||
import NotificationCtr from '@/controllers/NotificationCtr';
|
||||
import SystemController from '@/controllers/SystemCtr';
|
||||
|
||||
import type { IMenuPlatform, MenuOptions } from '../types';
|
||||
import { BaseMenuPlatform } from './BaseMenuPlatform';
|
||||
@@ -57,7 +59,6 @@ export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
|
||||
private getAppMenuTemplate(options?: MenuOptions): MenuItemConstructorOptions[] {
|
||||
const appName = app.getName();
|
||||
const showDev = isDev || options?.showDevItems;
|
||||
|
||||
// 创建命名空间翻译函数
|
||||
const t = this.app.i18n.ns('menu');
|
||||
|
||||
@@ -269,6 +270,48 @@ export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
|
||||
label: t('dev.refreshMenu'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: t('dev.permissions.title'),
|
||||
submenu: [
|
||||
{
|
||||
click: () => {
|
||||
const notificationCtr = this.app.getController(NotificationCtr);
|
||||
void notificationCtr.requestNotificationPermission();
|
||||
},
|
||||
label: t('dev.permissions.notification.request'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
click: () => {
|
||||
const systemCtr = this.app.getController(SystemController);
|
||||
void systemCtr.requestAccessibilityAccess();
|
||||
},
|
||||
label: t('dev.permissions.accessibility.request'),
|
||||
},
|
||||
{
|
||||
click: () => {
|
||||
const systemCtr = this.app.getController(SystemController);
|
||||
void systemCtr.requestMicrophoneAccess();
|
||||
},
|
||||
label: t('dev.permissions.microphone.request'),
|
||||
},
|
||||
{
|
||||
click: () => {
|
||||
const systemCtr = this.app.getController(SystemController);
|
||||
void systemCtr.requestScreenAccess();
|
||||
},
|
||||
label: t('dev.permissions.screen.request'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
click: () => {
|
||||
const systemCtr = this.app.getController(SystemController);
|
||||
void systemCtr.promptFullDiskAccessIfNotGranted();
|
||||
},
|
||||
label: t('dev.permissions.fullDisk.request'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
click: () => {
|
||||
const userDataPath = app.getPath('userData');
|
||||
|
||||
121
apps/desktop/src/main/utils/fullDiskAccess.ts
Normal file
121
apps/desktop/src/main/utils/fullDiskAccess.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Full Disk Access utilities for macOS
|
||||
* Based on https://github.com/inket/FullDiskAccess
|
||||
*/
|
||||
import { shell } from 'electron';
|
||||
import { macOS } from 'electron-is';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createLogger } from './logger';
|
||||
|
||||
const logger = createLogger('utils:fullDiskAccess');
|
||||
|
||||
/**
|
||||
* Get the macOS major version number
|
||||
* Returns 0 if not macOS or unable to determine
|
||||
*
|
||||
* Darwin version to macOS version mapping:
|
||||
* - Darwin 23.x = macOS 14 (Sonoma)
|
||||
* - Darwin 22.x = macOS 13 (Ventura)
|
||||
* - Darwin 21.x = macOS 12 (Monterey)
|
||||
* - Darwin 20.x = macOS 11 (Big Sur)
|
||||
* - Darwin 19.x = macOS 10.15 (Catalina)
|
||||
* - Darwin 18.x = macOS 10.14 (Mojave)
|
||||
*/
|
||||
export function getMacOSMajorVersion(): number {
|
||||
if (!macOS()) return 0;
|
||||
try {
|
||||
const release = os.release(); // e.g., "23.0.0" for macOS 14 (Sonoma)
|
||||
const darwinMajor = Number.parseInt(release.split('.')[0], 10);
|
||||
if (darwinMajor >= 20) {
|
||||
return darwinMajor - 9; // Darwin 20 = macOS 11, Darwin 21 = macOS 12, etc.
|
||||
}
|
||||
// For older versions, return 10 (covers Mojave and Catalina)
|
||||
return 10;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Full Disk Access is granted by attempting to read a protected directory.
|
||||
*
|
||||
* On macOS 12+ (Monterey, Ventura, Sonoma, Sequoia): checks ~/Library/Containers/com.apple.stocks
|
||||
* On macOS 10.14-11 (Mojave, Catalina, Big Sur): checks ~/Library/Safari
|
||||
*
|
||||
* Reading these directories will also register the app in TCC database,
|
||||
* making it appear in System Settings > Privacy & Security > Full Disk Access
|
||||
*/
|
||||
export function checkFullDiskAccess(): boolean {
|
||||
if (!macOS()) return true;
|
||||
|
||||
const homeDir = os.homedir();
|
||||
const macOSVersion = getMacOSMajorVersion();
|
||||
|
||||
// Determine which protected directory to check based on macOS version
|
||||
let checkPath: string;
|
||||
if (macOSVersion >= 12) {
|
||||
// macOS 12+ (Monterey, Ventura, Sonoma, Sequoia)
|
||||
checkPath = path.join(homeDir, 'Library', 'Containers', 'com.apple.stocks');
|
||||
} else {
|
||||
// macOS 10.14-11 (Mojave, Catalina, Big Sur)
|
||||
checkPath = path.join(homeDir, 'Library', 'Safari');
|
||||
}
|
||||
|
||||
try {
|
||||
fs.readdirSync(checkPath);
|
||||
logger.info(`[FullDiskAccess] Access granted (able to read ${checkPath})`);
|
||||
return true;
|
||||
} catch {
|
||||
logger.info(`[FullDiskAccess] Access not granted (unable to read ${checkPath})`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open Full Disk Access settings page in System Settings
|
||||
*
|
||||
* NOTE: Full Disk Access cannot be requested programmatically.
|
||||
* User must manually add the app in System Settings.
|
||||
* There is NO entitlement for Full Disk Access - it's purely TCC controlled.
|
||||
*/
|
||||
export async function openFullDiskAccessSettings(): Promise<void> {
|
||||
if (!macOS()) {
|
||||
logger.info('[FullDiskAccess] Not macOS, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('[FullDiskAccess] Opening Full Disk Access settings...');
|
||||
|
||||
// On macOS 13+ (Ventura), System Preferences is replaced by System Settings,
|
||||
// and deep links may differ. We try multiple known schemes for compatibility.
|
||||
const candidates = [
|
||||
// macOS 13+ (Ventura and later) - System Settings
|
||||
'x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles',
|
||||
// macOS 13+ alternative format
|
||||
'x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles',
|
||||
];
|
||||
|
||||
for (const url of candidates) {
|
||||
try {
|
||||
logger.info(`[FullDiskAccess] Trying URL: ${url}`);
|
||||
await shell.openExternal(url);
|
||||
logger.info(`[FullDiskAccess] Successfully opened via ${url}`);
|
||||
return;
|
||||
} catch (error) {
|
||||
logger.warn(`[FullDiskAccess] Failed with URL ${url}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: open Privacy & Security pane
|
||||
try {
|
||||
const fallbackUrl = 'x-apple.systempreferences:com.apple.preference.security?Privacy';
|
||||
logger.info(`[FullDiskAccess] Trying fallback URL: ${fallbackUrl}`);
|
||||
await shell.openExternal(fallbackUrl);
|
||||
logger.info('[FullDiskAccess] Opened Privacy & Security settings as fallback');
|
||||
} catch (error) {
|
||||
logger.error('[FullDiskAccess] Failed to open any Privacy settings:', error);
|
||||
}
|
||||
}
|
||||
@@ -4,4 +4,5 @@ export interface SystemBroadcastEvents {
|
||||
localeChanged: (data: { locale: string }) => void;
|
||||
systemThemeChanged: (data: { themeMode: ThemeAppearance }) => void;
|
||||
themeChanged: (data: { themeMode: ThemeMode }) => void;
|
||||
windowFocused: () => void;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useWatchBroadcast } from '@lobechat/electron-client-ipc';
|
||||
import { Block, Button, Flexbox, Icon, Text } from '@lobehub/ui';
|
||||
import { cssVar } from 'antd-style';
|
||||
import {
|
||||
@@ -93,12 +94,15 @@ const PermissionsStep = memo<PermissionsStepProps>(({ onBack, onNext }) => {
|
||||
const micStatus = await ipc.system.getMediaAccessStatus('microphone');
|
||||
const screenStatus = await ipc.system.getMediaAccessStatus('screen');
|
||||
const accessibilityStatus = await ipc.system.getAccessibilityStatus();
|
||||
// Full Disk Access can now be checked by attempting to read protected directories
|
||||
const fullDiskStatus = await ipc.system.getFullDiskAccessStatus();
|
||||
|
||||
setPermissions((prev) =>
|
||||
prev.map((p) => {
|
||||
if (p.id === 1) return { ...p, granted: notifStatus === 'authorized' };
|
||||
// Full Disk Access cannot be checked programmatically, so it remains manual
|
||||
if (p.id === 2) return { ...p, buttonKey: 'screen3.actions.openSettings', granted: false };
|
||||
// Full Disk Access status is detected by reading protected directories
|
||||
if (p.id === 2)
|
||||
return { ...p, buttonKey: 'screen3.actions.openSettings', granted: fullDiskStatus };
|
||||
if (p.id === 3)
|
||||
return { ...p, granted: micStatus === 'granted' && screenStatus === 'granted' };
|
||||
if (p.id === 4) return { ...p, granted: accessibilityStatus };
|
||||
@@ -111,28 +115,11 @@ const PermissionsStep = memo<PermissionsStepProps>(({ onBack, onNext }) => {
|
||||
checkAllPermissions();
|
||||
}, [checkAllPermissions]);
|
||||
|
||||
// When this page regains focus (e.g. back from System Settings), re-check permission states and refresh UI.
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const handleFocus = () => {
|
||||
checkAllPermissions();
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
checkAllPermissions();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('focus', handleFocus);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('focus', handleFocus);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [checkAllPermissions]);
|
||||
// Listen for window focus event from Electron main process
|
||||
// This is more reliable than browser focus events in Electron environment
|
||||
useWatchBroadcast('windowFocused', () => {
|
||||
checkAllPermissions();
|
||||
});
|
||||
|
||||
const handlePermissionRequest = async (permissionId: number) => {
|
||||
const ipc = ensureElectronIpc();
|
||||
@@ -143,7 +130,8 @@ const PermissionsStep = memo<PermissionsStepProps>(({ onBack, onNext }) => {
|
||||
break;
|
||||
}
|
||||
case 2: {
|
||||
await ipc.system.openFullDiskAccessSettings({ autoAdd: true });
|
||||
// Use native prompt dialog for Full Disk Access
|
||||
await ipc.system.promptFullDiskAccessIfNotGranted();
|
||||
break;
|
||||
}
|
||||
case 3: {
|
||||
@@ -175,7 +163,7 @@ const PermissionsStep = memo<PermissionsStepProps>(({ onBack, onNext }) => {
|
||||
{permissions.map((permission) => (
|
||||
<Block
|
||||
align={'center'}
|
||||
clickable={!permission.granted || permission.id === 2}
|
||||
clickable={!permission.granted}
|
||||
gap={16}
|
||||
horizontal
|
||||
key={permission.id}
|
||||
@@ -197,7 +185,7 @@ const PermissionsStep = memo<PermissionsStepProps>(({ onBack, onNext }) => {
|
||||
{t(permission.descriptionKey as any)}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
{permission.granted && permission.id !== 2 ? (
|
||||
{permission.granted ? (
|
||||
<Icon color={cssVar.colorSuccess} icon={Check} size={20} />
|
||||
) : (
|
||||
<Button
|
||||
@@ -213,9 +201,7 @@ const PermissionsStep = memo<PermissionsStepProps>(({ onBack, onNext }) => {
|
||||
}}
|
||||
type={'text'}
|
||||
>
|
||||
{permission.granted && permission.id === 2
|
||||
? t('screen3.actions.granted')
|
||||
: t(permission.buttonKey)}
|
||||
{t(permission.buttonKey)}
|
||||
</Button>
|
||||
)}
|
||||
</Block>
|
||||
|
||||
@@ -2,27 +2,37 @@
|
||||
|
||||
import { Flexbox, Skeleton } from '@lobehub/ui';
|
||||
import { Suspense, memo, useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import Loading from '@/components/Loading/BrandTextLoading';
|
||||
import { electronSystemService } from '@/services/electron/system';
|
||||
import { isDev } from '@/utils/env';
|
||||
|
||||
import OnboardingContainer from './_layout';
|
||||
import DataModeStep from './features/DataModeStep';
|
||||
import LoginStep from './features/LoginStep';
|
||||
import PermissionsStep from './features/PermissionsStep';
|
||||
import WelcomeStep from './features/WelcomeStep';
|
||||
import { getDesktopOnboardingCompleted, setDesktopOnboardingCompleted } from './storage';
|
||||
import {
|
||||
clearDesktopOnboardingStep,
|
||||
getDesktopOnboardingStep,
|
||||
setDesktopOnboardingCompleted,
|
||||
setDesktopOnboardingStep,
|
||||
} from './storage';
|
||||
|
||||
const DesktopOnboardingPage = memo(() => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [isMac, setIsMac] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// 从 URL query 参数获取初始步骤,默认为 1
|
||||
// 从 localStorage 或 URL query 参数获取初始步骤
|
||||
// 优先使用 localStorage 以支持重启后恢复
|
||||
const getInitialStep = useCallback(() => {
|
||||
// First try localStorage (for app restart scenario)
|
||||
const savedStep = getDesktopOnboardingStep();
|
||||
if (savedStep !== null) {
|
||||
return savedStep;
|
||||
}
|
||||
// Then try URL params
|
||||
const stepParam = searchParams.get('step');
|
||||
if (stepParam) {
|
||||
const step = parseInt(stepParam, 10);
|
||||
@@ -33,11 +43,10 @@ const DesktopOnboardingPage = memo(() => {
|
||||
|
||||
const [currentStep, setCurrentStep] = useState(getInitialStep);
|
||||
|
||||
// 检查是否已完成 onboarding
|
||||
// 持久化当前步骤到 localStorage
|
||||
useEffect(() => {
|
||||
if (isDev) return;
|
||||
if (getDesktopOnboardingCompleted()) navigate('/', { replace: true });
|
||||
}, [navigate]);
|
||||
setDesktopOnboardingStep(currentStep);
|
||||
}, [currentStep]);
|
||||
|
||||
// 设置窗口大小和可调整性
|
||||
useEffect(() => {
|
||||
@@ -117,6 +126,7 @@ const DesktopOnboardingPage = memo(() => {
|
||||
case 4: {
|
||||
// 如果是第4步(LoginStep),完成 onboarding
|
||||
setDesktopOnboardingCompleted();
|
||||
clearDesktopOnboardingStep(); // Clear persisted step since onboarding is complete
|
||||
// Restore window resizable before hard reload (cleanup won't run due to hard navigation)
|
||||
electronSystemService
|
||||
.setWindowResizable({ resizable: true })
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const DESKTOP_ONBOARDING_STORAGE_KEY = 'lobechat:desktop:onboarding:completed:v1';
|
||||
export const DESKTOP_ONBOARDING_STEP_KEY = 'lobechat:desktop:onboarding:step:v1';
|
||||
|
||||
export const getDesktopOnboardingCompleted = () => {
|
||||
if (typeof window === 'undefined') return true;
|
||||
@@ -32,3 +33,51 @@ export const clearDesktopOnboardingCompleted = () => {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the persisted onboarding step (for restoring after app restart)
|
||||
*/
|
||||
export const getDesktopOnboardingStep = (): number | null => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
try {
|
||||
const step = window.localStorage.getItem(DESKTOP_ONBOARDING_STEP_KEY);
|
||||
if (step) {
|
||||
const parsedStep = Number.parseInt(step, 10);
|
||||
if (parsedStep >= 1 && parsedStep <= 4) {
|
||||
return parsedStep;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Persist the current onboarding step
|
||||
*/
|
||||
export const setDesktopOnboardingStep = (step: number) => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(DESKTOP_ONBOARDING_STEP_KEY, step.toString());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear the persisted onboarding step (called when onboarding completes)
|
||||
*/
|
||||
export const clearDesktopOnboardingStep = () => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
|
||||
try {
|
||||
window.localStorage.removeItem(DESKTOP_ONBOARDING_STEP_KEY);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user