mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-26 13:19:34 +07:00
🔧 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:
@@ -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;
|
||||
@@ -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'),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
102
apps/desktop/native-deps.config.mjs
Normal file
102
apps/desktop/native-deps.config.mjs
Normal 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();
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
21
apps/desktop/src/main/__mocks__/node-mac-permissions.ts
Normal file
21
apps/desktop/src/main/__mocks__/node-mac-permissions.ts
Normal 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');
|
||||
8
apps/desktop/src/main/__mocks__/setup.ts
Normal file
8
apps/desktop/src/main/__mocks__/setup.ts
Normal 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'));
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
307
apps/desktop/src/main/utils/permissions.ts
Normal file
307
apps/desktop/src/main/utils/permissions.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@
|
||||
"src/main/**/*",
|
||||
"src/preload/**/*",
|
||||
"src/common/**/*",
|
||||
"electron-builder.js"
|
||||
"electron-builder.js",
|
||||
"native-deps.config.js"
|
||||
]
|
||||
}
|
||||
@@ -14,5 +14,6 @@ export default defineConfig({
|
||||
reportsDirectory: './coverage/app',
|
||||
},
|
||||
environment: 'node',
|
||||
setupFiles: ['./src/main/__mocks__/setup.ts'],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user