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:
Innei
2026-01-10 01:06:49 +08:00
committed by GitHub
parent 1e8e656a0c
commit 2d5868f759
15 changed files with 680 additions and 253 deletions

View File

@@ -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>

View File

@@ -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": "等待授权连接"
}
}

View File

@@ -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 下完)",

View File

@@ -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()

View File

@@ -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', () => {

View File

@@ -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

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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)',

View File

@@ -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');

View 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);
}
}

View File

@@ -4,4 +4,5 @@ export interface SystemBroadcastEvents {
localeChanged: (data: { locale: string }) => void;
systemThemeChanged: (data: { themeMode: ThemeAppearance }) => void;
themeChanged: (data: { themeMode: ThemeMode }) => void;
windowFocused: () => void;
}

View File

@@ -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>

View File

@@ -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 })

View File

@@ -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;
}
};