🔧 chore(desktop): exclude node_modules from electron-builder packaging (#11397)

* 🔧 chore(desktop): exclude node_modules from electron-builder packaging

- Add !node_modules to files config to prevent bundling node_modules
- Remove unused asarUnpack config for sharp and @img (not used in electron main process)

Fixes LOBE-3008

* 🔧 chore(file-loaders): move @napi-rs/canvas to devDependencies

@napi-rs/canvas is only used in test/setup.ts for DOMMatrix polyfill,
not required at runtime. Moving to devDependencies allows Vite to
bundle all runtime dependencies as pure JS.

* 🔧 chore(desktop): remove pdfjs-dist from dependencies

Removed the pdfjs-dist package from the dependencies in package.json as it is no longer needed.

Signed-off-by: Innei <tukon479@gmail.com>

* 🔧 chore(desktop): refactor electron-builder configuration and remove unused files

* 🔧 chore(desktop): refactor electron-builder configuration and remove unused files

---------

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2026-01-10 23:15:42 +08:00
committed by GitHub
parent 9d687368b5
commit 07dc919496
15 changed files with 627 additions and 395 deletions

View File

@@ -1,11 +1,18 @@
const dotenv = require('dotenv');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
import dotenv from 'dotenv';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { getAsarUnpackPatterns, getFilesPatterns } from './native-deps.config.mjs';
dotenv.config();
const packageJSON = require('./package.json');
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const packageJSON = JSON.parse(
await fs.readFile(path.join(__dirname, 'package.json'), 'utf8')
);
const channel = process.env.UPDATE_CHANNEL;
const arch = os.arch();
@@ -121,22 +128,24 @@ const config = {
artifactName: '${productName}-${version}.${ext}',
},
asar: true,
asarUnpack: [
// https://github.com/electron-userland/electron-builder/issues/9001#issuecomment-2778802044
'**/node_modules/sharp/**/*',
'**/node_modules/@img/**/*',
],
// Native modules must be unpacked from asar to work correctly
asarUnpack: getAsarUnpackPatterns(),
detectUpdateChannel: true,
directories: {
buildResources: 'build',
output: 'release',
},
dmg: {
artifactName: '${productName}-${version}-${arch}.${ext}',
},
electronDownload: {
mirror: 'https://npmmirror.com/mirrors/electron/',
},
files: [
'dist',
'resources',
@@ -147,6 +156,10 @@ const config = {
'!dist/next/packages',
'!dist/next/.next/server/app/sitemap',
'!dist/next/.next/static/media',
// Exclude node_modules from packaging (except native modules)
'!node_modules',
// Include native modules (defined in native-deps.config.mjs)
...getFilesPatterns(),
],
generateUpdatesFilesForAllChannels: true,
linux: {
@@ -220,4 +233,4 @@ const config = {
},
};
module.exports = config;
export default config;

View File

@@ -1,7 +1,9 @@
import dotenv from 'dotenv';
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
import { defineConfig } from 'electron-vite';
import { resolve } from 'node:path';
import { getExternalDependencies } from './native-deps.config.mjs';
dotenv.config();
const isDev = process.env.NODE_ENV === 'development';
@@ -13,6 +15,10 @@ export default defineConfig({
build: {
minify: !isDev,
outDir: 'dist/main',
rollupOptions: {
// Native modules must be externalized to work correctly
external: getExternalDependencies(),
},
sourcemap: isDev ? 'inline' : false,
},
// 这里是关键:在构建时进行文本替换
@@ -21,7 +27,7 @@ export default defineConfig({
'process.env.OFFICIAL_CLOUD_SERVER': JSON.stringify(process.env.OFFICIAL_CLOUD_SERVER),
'process.env.UPDATE_CHANNEL': JSON.stringify(process.env.UPDATE_CHANNEL),
},
plugins: [externalizeDepsPlugin({})],
resolve: {
alias: {
'@': resolve(__dirname, 'src/main'),
@@ -35,11 +41,11 @@ export default defineConfig({
outDir: 'dist/preload',
sourcemap: isDev ? 'inline' : false,
},
plugins: [externalizeDepsPlugin({})],
resolve: {
alias: {
'~common': resolve(__dirname, 'src/common'),
'@': resolve(__dirname, 'src/main'),
'~common': resolve(__dirname, 'src/common'),
},
},
},

View File

@@ -0,0 +1,102 @@
/**
* Native dependencies configuration for Electron build
*
* Native modules (containing .node bindings) require special handling:
* 1. Must be externalized in Vite/Rollup to prevent bundling
* 2. Must be included in electron-builder files
* 3. Must be unpacked from asar archive
*
* This module automatically resolves the full dependency tree.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/**
* List of native modules that need special handling
* Only add the top-level native modules here - dependencies are resolved automatically
*/
export const nativeModules = [
'node-mac-permissions',
// Add more native modules here as needed
// e.g., 'better-sqlite3', 'sharp', etc.
];
/**
* Recursively resolve all dependencies of a module
* @param {string} moduleName - The module to resolve
* @param {Set<string>} visited - Set of already visited modules (to avoid cycles)
* @param {string} nodeModulesPath - Path to node_modules directory
* @returns {Set<string>} Set of all dependencies
*/
function resolveDependencies(moduleName, visited = new Set(), nodeModulesPath = path.join(__dirname, 'node_modules')) {
if (visited.has(moduleName)) {
return visited;
}
const packageJsonPath = path.join(nodeModulesPath, moduleName, 'package.json');
// Check if module exists
if (!fs.existsSync(packageJsonPath)) {
return visited;
}
visited.add(moduleName);
try {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
const dependencies = packageJson.dependencies || {};
for (const dep of Object.keys(dependencies)) {
resolveDependencies(dep, visited, nodeModulesPath);
}
} catch {
// Ignore errors reading package.json
}
return visited;
}
/**
* Get all dependencies for all native modules (including transitive dependencies)
* @returns {string[]} Array of all dependency names
*/
export function getAllDependencies() {
const allDeps = new Set();
for (const nativeModule of nativeModules) {
const deps = resolveDependencies(nativeModule);
for (const dep of deps) {
allDeps.add(dep);
}
}
return [...allDeps];
}
/**
* Generate glob patterns for electron-builder files config
* @returns {string[]} Array of glob patterns
*/
export function getFilesPatterns() {
return getAllDependencies().map((dep) => `node_modules/${dep}/**/*`);
}
/**
* Generate glob patterns for electron-builder asarUnpack config
* @returns {string[]} Array of glob patterns
*/
export function getAsarUnpackPatterns() {
return getAllDependencies().map((dep) => `node_modules/${dep}/**/*`);
}
/**
* Get the list of native dependencies for Vite external config
* @returns {string[]} Array of dependency names
*/
export function getExternalDependencies() {
return getAllDependencies();
}

View File

@@ -12,11 +12,11 @@
"main": "./dist/main/index.js",
"scripts": {
"build": "electron-vite build",
"build-local": "npm run build && electron-builder --dir --config electron-builder.js --c.mac.notarize=false -c.mac.identity=null --c.asar=false",
"build:linux": "npm run build && electron-builder --linux --config electron-builder.js --publish never",
"build:mac": "npm run build && electron-builder --mac --config electron-builder.js --publish never",
"build:mac:local": "npm run build && UPDATE_CHANNEL=nightly electron-builder --mac --config electron-builder.js --publish never",
"build:win": "npm run build && electron-builder --win --config electron-builder.js --publish never",
"build-local": "npm run build && electron-builder --dir --config electron-builder.mjs --c.mac.notarize=false -c.mac.identity=null --c.asar=false",
"build:linux": "npm run build && electron-builder --linux --config electron-builder.mjs --publish never",
"build:mac": "npm run build && electron-builder --mac --config electron-builder.mjs --publish never",
"build:mac:local": "npm run build && UPDATE_CHANNEL=nightly electron-builder --mac --config electron-builder.mjs --publish never",
"build:win": "npm run build && electron-builder --win --config electron-builder.mjs --publish never",
"dev": "electron-vite dev",
"dev:static": "cross-env DESKTOP_RENDERER_STATIC=1 npm run electron:dev",
"electron:dev": "electron-vite dev",
@@ -43,7 +43,7 @@
"electron-window-state": "^5.0.3",
"fetch-socks": "^1.3.2",
"get-port-please": "^3.2.0",
"pdfjs-dist": "4.10.38",
"node-mac-permissions": "^2.5.0",
"superjson": "^2.2.6"
},
"devDependencies": {
@@ -102,7 +102,8 @@
"pnpm": {
"onlyBuiltDependencies": [
"electron",
"electron-builder"
"electron-builder",
"node-mac-permissions"
]
}
}

View File

@@ -0,0 +1,21 @@
/**
* Mock for node-mac-permissions native module
* Used in tests since the native module only works on macOS
*/
import { vi } from 'vitest';
export const askForAccessibilityAccess = vi.fn(() => undefined);
export const askForCalendarAccess = vi.fn(() => Promise.resolve('authorized'));
export const askForCameraAccess = vi.fn(() => Promise.resolve('authorized'));
export const askForContactsAccess = vi.fn(() => Promise.resolve('authorized'));
export const askForFoldersAccess = vi.fn(() => Promise.resolve('authorized'));
export const askForFullDiskAccess = vi.fn(() => undefined);
export const askForInputMonitoringAccess = vi.fn(() => Promise.resolve('authorized'));
export const askForLocationAccess = vi.fn(() => Promise.resolve('authorized'));
export const askForMicrophoneAccess = vi.fn(() => Promise.resolve('authorized'));
export const askForPhotosAccess = vi.fn(() => Promise.resolve('authorized'));
export const askForRemindersAccess = vi.fn(() => Promise.resolve('authorized'));
export const askForSpeechRecognitionAccess = vi.fn(() => Promise.resolve('authorized'));
export const askForScreenCaptureAccess = vi.fn(() => undefined);
export const getAuthStatus = vi.fn(() => 'authorized');

View File

@@ -0,0 +1,8 @@
/**
* Vitest setup file for mocking native modules
*/
import { vi } from 'vitest';
// Mock node-mac-permissions before any imports
vi.mock('node-mac-permissions', () => import('./node-mac-permissions'));

View File

@@ -1,10 +1,18 @@
import { ElectronAppState, ThemeMode } from '@lobechat/electron-client-ipc';
import { app, desktopCapturer, dialog, nativeTheme, shell, systemPreferences } from 'electron';
import { app, dialog, nativeTheme, shell } from 'electron';
import { macOS } from 'electron-is';
import process from 'node:process';
import { checkFullDiskAccess, openFullDiskAccessSettings } from '@/utils/fullDiskAccess';
import { createLogger } from '@/utils/logger';
import {
getAccessibilityStatus,
getFullDiskAccessStatus,
getMediaAccessStatus,
openFullDiskAccessSettings,
requestAccessibilityAccess,
requestMicrophoneAccess,
requestScreenCaptureAccess,
} from '@/utils/permissions';
import { ControllerModule, IpcMethod } from './index';
@@ -55,43 +63,23 @@ export default class SystemController extends ControllerModule {
@IpcMethod()
requestAccessibilityAccess() {
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;
return requestAccessibilityAccess();
}
@IpcMethod()
getAccessibilityStatus() {
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;
const status = getAccessibilityStatus();
return status === 'granted';
}
/**
* 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();
const status = getFullDiskAccessStatus();
return status === 'granted';
}
/**
* 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,
@@ -105,9 +93,9 @@ export default class SystemController extends ControllerModule {
title?: string;
}): Promise<'cancelled' | 'granted' | 'opened_settings' | 'skipped'> {
// Check if already granted
if (checkFullDiskAccess()) {
const status = getFullDiskAccessStatus();
if (status === 'granted') {
logger.info('[FullDiskAccess] Already granted, skipping prompt');
return 'granted';
}
@@ -151,132 +139,19 @@ export default class SystemController extends ControllerModule {
@IpcMethod()
async getMediaAccessStatus(mediaType: 'microphone' | 'screen'): Promise<string> {
if (!macOS()) return 'granted';
return systemPreferences.getMediaAccessStatus(mediaType);
return getMediaAccessStatus(mediaType);
}
@IpcMethod()
async requestMicrophoneAccess(): Promise<boolean> {
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;
return requestMicrophoneAccess();
}
@IpcMethod()
async requestScreenAccess(): Promise<boolean> {
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).
// 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 {
// 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('[Screen] desktopCapturer.getSources failed:', error);
}
// 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 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();
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('[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',
);
const finalStatus = systemPreferences.getMediaAccessStatus('screen');
logger.info(`[Screen] Final status: ${finalStatus}`);
return finalStatus === 'granted';
return requestScreenCaptureAccess();
}
/**
* Open Full Disk Access settings page
*/
@IpcMethod()
async openFullDiskAccessSettings() {
return openFullDiskAccessSettings();
@@ -287,9 +162,6 @@ export default class SystemController extends ControllerModule {
return shell.openExternal(url);
}
/**
* Open native folder picker dialog
*/
@IpcMethod()
async selectFolder(payload?: {
defaultPath?: string;
@@ -310,23 +182,15 @@ export default class SystemController extends ControllerModule {
return result.filePaths[0];
}
/**
* Get the OS system locale
*/
@IpcMethod()
getSystemLocale(): string {
return app.getLocale();
}
/**
* 更新应用语言设置
*/
@IpcMethod()
async updateLocale(locale: string) {
// 保存语言设置
this.app.storeManager.set('locale', locale);
// 更新i18n实例的语言
await this.app.i18n.changeLanguage(locale === 'auto' ? app.getLocale() : locale);
this.app.browserManager.broadcastToAllWindows('localeChanged', { locale });
@@ -354,9 +218,6 @@ export default class SystemController extends ControllerModule {
nativeTheme.themeSource = themeMode;
}
/**
* Initialize system theme listener to monitor OS theme changes
*/
private initializeSystemThemeListener() {
if (this.systemThemeListenerInitialized) {
logger.debug('System theme listener already initialized');

View File

@@ -7,13 +7,20 @@ import { IpcHandler } from '@/utils/ipc/base';
import SystemController from '../SystemCtr';
const { ipcHandlers, ipcMainHandleMock, readdirSyncMock } = vi.hoisted(() => {
const { ipcHandlers, ipcMainHandleMock, permissionsMock } = vi.hoisted(() => {
const handlers = new Map<string, (event: any, ...args: any[]) => any>();
const handle = vi.fn((channel: string, handler: any) => {
handlers.set(channel, handler);
});
const readdirSync = vi.fn();
return { ipcHandlers: handlers, ipcMainHandleMock: handle, readdirSyncMock: readdirSync };
const permissions = {
askForAccessibilityAccess: vi.fn(() => undefined),
askForCameraAccess: vi.fn(() => Promise.resolve('authorized')),
askForFullDiskAccess: vi.fn(() => undefined),
askForMicrophoneAccess: vi.fn(() => Promise.resolve('authorized')),
askForScreenCaptureAccess: vi.fn(() => undefined),
getAuthStatus: vi.fn(() => 'authorized'),
};
return { ipcHandlers: handlers, ipcMainHandleMock: handle, permissionsMock: permissions };
});
const invokeIpc = async <T = any>(
@@ -80,31 +87,8 @@ 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 node-mac-permissions
vi.mock('node-mac-permissions', () => permissionsMock);
// Mock browserManager
const mockBrowserManager = {
@@ -173,22 +157,23 @@ describe('SystemController', () => {
describe('accessibility', () => {
it('should request accessibility access on macOS', async () => {
const { systemPreferences } = await import('electron');
permissionsMock.getAuthStatus.mockReturnValue('authorized');
await invokeIpc('system.requestAccessibilityAccess');
const result = await invokeIpc('system.requestAccessibilityAccess');
expect(systemPreferences.isTrustedAccessibilityClient).toHaveBeenCalledWith(true);
expect(permissionsMock.askForAccessibilityAccess).toHaveBeenCalled();
expect(permissionsMock.getAuthStatus).toHaveBeenCalledWith('accessibility');
expect(result).toBe(true);
});
it('should return true on non-macOS when requesting accessibility access', async () => {
const { macOS } = await import('electron-is');
const { systemPreferences } = await import('electron');
vi.mocked(macOS).mockReturnValue(false);
const result = await invokeIpc('system.requestAccessibilityAccess');
expect(result).toBe(true);
expect(systemPreferences.isTrustedAccessibilityClient).not.toHaveBeenCalled();
expect(permissionsMock.askForAccessibilityAccess).not.toHaveBeenCalled();
// Reset
vi.mocked(macOS).mockReturnValue(true);
@@ -197,57 +182,55 @@ describe('SystemController', () => {
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');
permissionsMock.getAuthStatus.mockReturnValue('not determined');
permissionsMock.askForMicrophoneAccess.mockResolvedValue('authorized');
await invokeIpc('system.requestMicrophoneAccess');
const result = await invokeIpc('system.requestMicrophoneAccess');
expect(systemPreferences.getMediaAccessStatus).toHaveBeenCalledWith('microphone');
expect(systemPreferences.askForMediaAccess).toHaveBeenCalledWith('microphone');
expect(permissionsMock.getAuthStatus).toHaveBeenCalledWith('microphone');
expect(permissionsMock.askForMicrophoneAccess).toHaveBeenCalled();
expect(result).toBe(true);
// Reset
vi.mocked(systemPreferences.getMediaAccessStatus).mockReturnValue('not-determined');
permissionsMock.getAuthStatus.mockReturnValue('authorized');
});
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 { shell } = await import('electron');
permissionsMock.getAuthStatus.mockReturnValue('authorized');
const result = await invokeIpc('system.requestMicrophoneAccess');
expect(result).toBe(true);
expect(systemPreferences.askForMediaAccess).not.toHaveBeenCalled();
expect(permissionsMock.askForMicrophoneAccess).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 { shell } = await import('electron');
permissionsMock.getAuthStatus.mockReturnValue('denied');
const result = await invokeIpc('system.requestMicrophoneAccess');
expect(result).toBe(false);
expect(systemPreferences.askForMediaAccess).not.toHaveBeenCalled();
expect(permissionsMock.askForMicrophoneAccess).not.toHaveBeenCalled();
expect(shell.openExternal).toHaveBeenCalledWith(
'x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone',
);
// Reset
vi.mocked(systemPreferences.getMediaAccessStatus).mockReturnValue('not-determined');
permissionsMock.getAuthStatus.mockReturnValue('authorized');
});
it('should return true on non-macOS', async () => {
const { macOS } = await import('electron-is');
const { shell, systemPreferences } = await import('electron');
const { shell } = await import('electron');
vi.mocked(macOS).mockReturnValue(false);
const result = await invokeIpc('system.requestMicrophoneAccess');
expect(result).toBe(true);
expect(systemPreferences.getMediaAccessStatus).not.toHaveBeenCalled();
expect(permissionsMock.getAuthStatus).not.toHaveBeenCalled();
expect(shell.openExternal).not.toHaveBeenCalled();
// Reset
@@ -256,48 +239,33 @@ describe('SystemController', () => {
});
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');
it('should request screen capture access on macOS', async () => {
permissionsMock.getAuthStatus.mockReturnValue('not determined');
const result = await invokeIpc('system.requestScreenAccess');
expect(systemPreferences.getMediaAccessStatus).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',
);
expect(permissionsMock.getAuthStatus).toHaveBeenCalledWith('screen');
expect(permissionsMock.askForScreenCaptureAccess).toHaveBeenCalled();
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');
permissionsMock.getAuthStatus.mockReturnValue('authorized');
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');
expect(permissionsMock.askForScreenCaptureAccess).not.toHaveBeenCalled();
});
it('should return true on non-macOS and not open settings', async () => {
const { macOS } = await import('electron-is');
const { desktopCapturer, shell } = await import('electron');
vi.mocked(macOS).mockReturnValue(false);
const result = await invokeIpc('system.requestScreenAccess');
expect(result).toBe(true);
expect(desktopCapturer.getSources).not.toHaveBeenCalled();
expect(shell.openExternal).not.toHaveBeenCalled();
expect(permissionsMock.askForScreenCaptureAccess).not.toHaveBeenCalled();
// Reset
vi.mocked(macOS).mockReturnValue(true);
@@ -305,26 +273,24 @@ 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']);
it('should return true when Full Disk Access is granted', async () => {
permissionsMock.getAuthStatus.mockReturnValue('authorized');
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',
);
expect(permissionsMock.getAuthStatus).toHaveBeenCalledWith('full-disk-access');
});
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');
});
it('should return false when Full Disk Access is not granted', async () => {
permissionsMock.getAuthStatus.mockReturnValue('denied');
const result = await invokeIpc('system.getFullDiskAccessStatus');
expect(result).toBe(false);
// Reset
permissionsMock.getAuthStatus.mockReturnValue('authorized');
});
it('should return true on non-macOS', async () => {
@@ -370,7 +336,7 @@ describe('SystemController', () => {
});
it('should return granted if Full Disk Access is already granted', async () => {
readdirSyncMock.mockReturnValue(['file1', 'file2']);
permissionsMock.getAuthStatus.mockReturnValue('authorized');
const result = await invokeIpc('system.promptFullDiskAccessIfNotGranted');
@@ -379,9 +345,7 @@ describe('SystemController', () => {
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');
});
permissionsMock.getAuthStatus.mockReturnValue('denied');
vi.mocked(dialog.showMessageBox).mockResolvedValue({ response: 0 } as any);
const result = await invokeIpc('system.promptFullDiskAccessIfNotGranted');
@@ -389,13 +353,14 @@ describe('SystemController', () => {
expect(result).toBe('opened_settings');
expect(dialog.showMessageBox).toHaveBeenCalled();
expect(shell.openExternal).toHaveBeenCalled();
// Reset
permissionsMock.getAuthStatus.mockReturnValue('authorized');
});
it('should return skipped when user clicks Later', async () => {
const { dialog, shell } = await import('electron');
readdirSyncMock.mockImplementation(() => {
throw new Error('EPERM: operation not permitted');
});
permissionsMock.getAuthStatus.mockReturnValue('denied');
vi.mocked(dialog.showMessageBox).mockResolvedValue({ response: 1 } as any);
vi.mocked(shell.openExternal).mockClear();
@@ -405,6 +370,9 @@ describe('SystemController', () => {
expect(dialog.showMessageBox).toHaveBeenCalled();
// Should not open settings when user skips
expect(shell.openExternal).not.toHaveBeenCalled();
// Reset
permissionsMock.getAuthStatus.mockReturnValue('authorized');
});
});

View File

@@ -1,121 +0,0 @@
/**
* 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

@@ -0,0 +1,307 @@
/**
* Unified macOS Permission Management using node-mac-permissions
* @see https://github.com/codebytere/node-mac-permissions
*/
import { shell } from 'electron';
import { macOS } from 'electron-is';
import {
askForAccessibilityAccess,
askForCameraAccess,
askForFullDiskAccess,
askForMicrophoneAccess,
askForScreenCaptureAccess,
getAuthStatus,
type AuthType,
type PermissionType,
} from 'node-mac-permissions';
import { createLogger } from './logger';
const logger = createLogger('utils:permissions');
/**
* Permission status mapping between node-mac-permissions and our internal representation
*/
export type PermissionStatus =
| 'authorized'
| 'denied'
| 'not-determined'
| 'restricted'
| 'granted'; // alias for authorized
/**
* Normalize permission status to a consistent format
*/
function normalizeStatus(status: PermissionType | 'not determined'): PermissionStatus {
if (status === 'not determined') return 'not-determined';
if (status === 'authorized') return 'granted';
return status;
}
/**
* Get the authorization status for a specific permission type
*/
export function getPermissionStatus(type: AuthType): PermissionStatus {
if (!macOS()) {
logger.debug(`[Permission] Not macOS, returning granted for ${type}`);
return 'granted';
}
const status = getAuthStatus(type);
const normalized = normalizeStatus(status);
logger.info(`[Permission] ${type} status: ${normalized}`);
return normalized;
}
/**
* Check if Accessibility permission is granted
*/
export function getAccessibilityStatus(): PermissionStatus {
return getPermissionStatus('accessibility');
}
/**
* Request Accessibility permission
* Opens System Preferences to the Accessibility pane
*/
export function requestAccessibilityAccess(): boolean {
if (!macOS()) {
logger.info('[Accessibility] Not macOS, returning true');
return true;
}
logger.info('[Accessibility] Requesting accessibility access...');
askForAccessibilityAccess();
// Check the status after requesting
const status = getPermissionStatus('accessibility');
return status === 'granted';
}
/**
* Check if Microphone permission is granted
*/
export function getMicrophoneStatus(): PermissionStatus {
return getPermissionStatus('microphone');
}
/**
* Request Microphone permission
* Shows the system permission dialog if not determined
*/
export async function requestMicrophoneAccess(): Promise<boolean> {
if (!macOS()) {
logger.info('[Microphone] Not macOS, returning true');
return true;
}
const currentStatus = getPermissionStatus('microphone');
logger.info(`[Microphone] Current status: ${currentStatus}`);
if (currentStatus === 'granted') {
logger.info('[Microphone] Already granted');
return true;
}
if (currentStatus === 'not-determined') {
logger.info('[Microphone] Status is not-determined, requesting access...');
try {
const result = await askForMicrophoneAccess();
logger.info(`[Microphone] askForMicrophoneAccess result: ${result}`);
return result === 'authorized';
} catch (error) {
logger.error('[Microphone] askForMicrophoneAccess failed:', error);
return false;
}
}
// If denied or restricted, open System Settings for manual enable
logger.info(`[Microphone] Status is ${currentStatus}, opening System Settings...`);
await shell.openExternal(
'x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone',
);
return false;
}
/**
* Check if Camera permission is granted
*/
export function getCameraStatus(): PermissionStatus {
return getPermissionStatus('camera');
}
/**
* Request Camera permission
* Shows the system permission dialog if not determined
*/
export async function requestCameraAccess(): Promise<boolean> {
if (!macOS()) {
logger.info('[Camera] Not macOS, returning true');
return true;
}
const currentStatus = getPermissionStatus('camera');
logger.info(`[Camera] Current status: ${currentStatus}`);
if (currentStatus === 'granted') {
logger.info('[Camera] Already granted');
return true;
}
if (currentStatus === 'not-determined') {
logger.info('[Camera] Status is not-determined, requesting access...');
try {
const result = await askForCameraAccess();
logger.info(`[Camera] askForCameraAccess result: ${result}`);
return result === 'authorized';
} catch (error) {
logger.error('[Camera] askForCameraAccess failed:', error);
return false;
}
}
// If denied or restricted, open System Settings for manual enable
logger.info(`[Camera] Status is ${currentStatus}, opening System Settings...`);
await shell.openExternal(
'x-apple.systempreferences:com.apple.preference.security?Privacy_Camera',
);
return false;
}
/**
* Check if Screen Recording permission is granted
*/
export function getScreenCaptureStatus(): PermissionStatus {
return getPermissionStatus('screen');
}
/**
* Request Screen Recording permission
* Opens System Preferences if access not granted
* @param openPreferences - Whether to open System Preferences (default: true)
*/
export async function requestScreenCaptureAccess(openPreferences = true): Promise<boolean> {
if (!macOS()) {
logger.info('[Screen] Not macOS, returning true');
return true;
}
const currentStatus = getPermissionStatus('screen');
logger.info(`[Screen] Current status: ${currentStatus}`);
if (currentStatus === 'granted') {
logger.info('[Screen] Already granted');
return true;
}
// Request screen capture access - this will prompt the user or open settings
logger.info('[Screen] Requesting screen capture access...');
askForScreenCaptureAccess(openPreferences);
// Check the status after requesting
const newStatus = getPermissionStatus('screen');
logger.info(`[Screen] Status after request: ${newStatus}`);
return newStatus === 'granted';
}
/**
* Check if Full Disk Access permission is granted
*/
export function getFullDiskAccessStatus(): PermissionStatus {
return getPermissionStatus('full-disk-access');
}
/**
* Request Full Disk Access permission
* Opens System Preferences to the Full Disk Access pane
* Note: Full Disk Access cannot be granted programmatically,
* user must manually add the app in System Settings
*/
export function requestFullDiskAccess(): void {
if (!macOS()) {
logger.info('[FullDiskAccess] Not macOS, skipping');
return;
}
logger.info('[FullDiskAccess] Opening Full Disk Access settings...');
askForFullDiskAccess();
}
/**
* Open Full Disk Access settings page in System Settings
* Alternative method using shell.openExternal
*/
export async function openFullDiskAccessSettings(): Promise<void> {
if (!macOS()) {
logger.info('[FullDiskAccess] Not macOS, skipping');
return;
}
logger.info('[FullDiskAccess] Opening Full Disk Access settings via shell...');
// 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);
}
}
/**
* Check if Input Monitoring permission is granted
*/
export function getInputMonitoringStatus(): PermissionStatus {
return getPermissionStatus('input-monitoring');
}
/**
* Get media access status (compatibility wrapper for Electron API)
* Maps 'microphone' and 'screen' to corresponding permission checks
*/
export function getMediaAccessStatus(mediaType: 'microphone' | 'screen'): string {
if (!macOS()) return 'granted';
const status = getPermissionStatus(mediaType === 'microphone' ? 'microphone' : 'screen');
// Map our status back to Electron's expected format
switch (status) {
case 'granted': {
return 'granted';
}
case 'not-determined': {
return 'not-determined';
}
case 'denied': {
return 'denied';
}
case 'restricted': {
return 'restricted';
}
default: {
return 'unknown';
}
}
}

View File

@@ -30,6 +30,7 @@
"src/main/**/*",
"src/preload/**/*",
"src/common/**/*",
"electron-builder.js"
"electron-builder.js",
"native-deps.config.js"
]
}

View File

@@ -14,5 +14,6 @@ export default defineConfig({
reportsDirectory: './coverage/app',
},
environment: 'node',
setupFiles: ['./src/main/__mocks__/setup.ts'],
},
});

View File

@@ -25,7 +25,6 @@
"test:coverage": "vitest --coverage --silent='passed-only'"
},
"dependencies": {
"@napi-rs/canvas": "^0.1.70",
"@xmldom/xmldom": "^0.9.8",
"concat-stream": "^2.0.0",
"debug": "^4.4.3",
@@ -37,6 +36,7 @@
"yauzl": "^3.2.0"
},
"devDependencies": {
"@napi-rs/canvas": "^0.1.70",
"@types/concat-stream": "^2.0.3",
"@types/yauzl": "^2.10.3",
"typescript": "^5.9.3"

View File

@@ -20,6 +20,31 @@ const foldersToSymlink = [
const foldersToCopy = ['src', 'scripts'];
// Assets to remove from desktop build output (not needed for Electron app)
const assetsToRemove = [
// Icons & favicons
'apple-touch-icon.png',
'favicon.ico',
'favicon-32x32.ico',
'favicon-16x16.png',
'favicon-32x32.png',
// SEO & sitemap
'sitemap.xml',
'sitemap-index.xml',
'sitemap',
'robots.txt',
// Incompatible pages
'not-compatible.html',
'not-compatible',
// Large media assets
'videos',
'screenshots',
'og',
];
const filesToCopy = [
'package.json',
'tsconfig.json',
@@ -85,10 +110,13 @@ const build = async () => {
console.log('🏗 Running next build in shadow workspace...');
try {
execSync('next build --webpack', {
execSync('next build', {
cwd: TEMP_DIR,
env: {
...process.env,
// Pass PROJECT_ROOT to next.config.ts for outputFileTracingRoot
// This fixes Turbopack symlink resolution when building in shadow workspace
ELECTRON_BUILD_PROJECT_ROOT: PROJECT_ROOT,
NODE_OPTIONS: process.env.NODE_OPTIONS || '--max-old-space-size=8192',
},
stdio: 'inherit',
@@ -106,6 +134,16 @@ const build = async () => {
if (fs.existsSync(sourceOutDir)) {
console.log('📦 Moving "out" directory...');
await fs.move(sourceOutDir, targetOutDir);
// Remove unnecessary assets from desktop build
console.log('🗑️ Removing unnecessary assets...');
for (const asset of assetsToRemove) {
const assetPath = path.join(targetOutDir, asset);
if (fs.existsSync(assetPath)) {
await fs.remove(assetPath);
console.log(` Removed: ${asset}`);
}
}
} else {
console.warn("⚠️ 'out' directory not found. Using '.next' instead (fallback)?");
const sourceNextDir = path.join(TEMP_DIR, '.next');

View File

@@ -144,6 +144,32 @@ export const modifyNextConfig = async (TEMP_DIR: string) => {
}
}
// 6. Inject outputFileTracingRoot to fix symlink resolution for Turbopack
// When building in shadow workspace (TEMP_DIR), symlinks (e.g., node_modules) point to PROJECT_ROOT
// Turbopack's root defaults to TEMP_DIR, causing strip_prefix to fail for paths outside TEMP_DIR
// Setting outputFileTracingRoot to PROJECT_ROOT allows Turbopack to correctly resolve these symlinks
// We use ELECTRON_BUILD_PROJECT_ROOT env var which is set by buildNextApp.mts
const outputFileTracingRootPair = nextConfigDecl.find({
rule: {
pattern: 'outputFileTracingRoot: $A',
},
});
if (!outputFileTracingRootPair) {
const objectNode = nextConfigDecl.find({
rule: { kind: 'object' },
});
if (objectNode) {
const range = objectNode.range();
// Insert outputFileTracingRoot that reads from env var at build time
// Falls back to current directory if not in electron build context
edits.push({
end: range.start.index + 1,
start: range.start.index + 1,
text: "\n outputFileTracingRoot: process.env.ELECTRON_BUILD_PROJECT_ROOT || process.cwd(),",
});
}
}
// Remove withPWA wrapper
const withPWA = root.find({
rule: {