🐛 fix: Fix Windows desktop build error with macOS native module (#11417)

*  fix: Implement dynamic macOS permissions handling and improve module loading

* 🛠️ chore: Remove test job from manual build workflow to streamline CI process

* 🚀 chore: Optimize dependency installation in manual build workflow by running jobs in parallel
This commit is contained in:
Innei
2026-01-11 17:13:11 +08:00
committed by GitHub
parent 70daf1355d
commit 67a81141df
4 changed files with 134 additions and 62 deletions

View File

@@ -44,28 +44,6 @@ env:
BUN_VERSION: 1.2.23
jobs:
test:
name: Code quality check
runs-on: ubuntu-latest
steps:
- name: Checkout base
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup Node & Bun
uses: ./.github/actions/setup-node-bun
with:
node-version: ${{ env.NODE_VERSION }}
bun-version: ${{ env.BUN_VERSION }}
package-manager-cache: 'false'
- name: Install deps
run: bun i
- name: Lint
run: bun run lint
version:
name: Determine version
runs-on: ubuntu-latest
@@ -106,7 +84,7 @@ jobs:
echo "🚦 Release Version: ${{ steps.set_version.outputs.version }}"
build-macos:
needs: [version, test]
needs: [version]
name: Build Desktop App (macOS)
if: inputs.build_macos
runs-on: ${{ matrix.os }}
@@ -126,10 +104,10 @@ jobs:
# node-linker=hoisted 模式将可以确保 asar 压缩可用
- name: Install dependencies
run: pnpm install --node-linker=hoisted
- name: Install deps on Desktop
run: npm run install-isolated --prefix=./apps/desktop
run: |
pnpm install --node-linker=hoisted &
npm run install-isolated --prefix=./apps/desktop &
wait
- name: Set package version
run: npm run workflow:set-desktop-version ${{ needs.version.outputs.version }} ${{ inputs.channel }}
@@ -187,7 +165,7 @@ jobs:
retention-days: 5
build-windows:
needs: [version, test]
needs: [version]
name: Build Desktop App (Windows)
if: inputs.build_windows
runs-on: windows-2025
@@ -203,10 +181,11 @@ jobs:
package-manager-cache: 'false'
- name: Install dependencies
run: pnpm install --node-linker=hoisted
- name: Install deps on Desktop
run: npm run install-isolated --prefix=./apps/desktop
shell: pwsh
run: |
$job1 = Start-Job -ScriptBlock { pnpm install --node-linker=hoisted }
$job2 = Start-Job -ScriptBlock { npm run install-isolated --prefix=./apps/desktop }
$job1, $job2 | Wait-Job | Receive-Job
- name: Set package version
run: npm run workflow:set-desktop-version ${{ needs.version.outputs.version }} ${{ inputs.channel }}
@@ -240,7 +219,7 @@ jobs:
retention-days: 5
build-linux:
needs: [version, test]
needs: [version]
name: Build Desktop App (Linux)
if: inputs.build_linux
runs-on: ubuntu-latest
@@ -256,10 +235,10 @@ jobs:
package-manager-cache: 'false'
- name: Install dependencies
run: pnpm install --node-linker=hoisted
- name: Install deps on Desktop
run: npm run install-isolated --prefix=./apps/desktop
run: |
pnpm install --node-linker=hoisted &
npm run install-isolated --prefix=./apps/desktop &
wait
- name: Set package version
run: npm run workflow:set-desktop-version ${{ needs.version.outputs.version }} ${{ inputs.channel }}

View File

@@ -8,19 +8,31 @@
*
* This module automatically resolves the full dependency tree.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/**
* Get the current target platform
* During build, electron-builder sets npm_config_platform
* Falls back to os.platform() for development
*/
function getTargetPlatform() {
return process.env.npm_config_platform || os.platform();
}
const isDarwin = getTargetPlatform() === 'darwin';
/**
* List of native modules that need special handling
* Only add the top-level native modules here - dependencies are resolved automatically
*
* Platform-specific modules are only included when building for their target platform
*/
export const nativeModules = [
'node-mac-permissions',
// macOS-only native modules
...(isDarwin ? ['node-mac-permissions'] : []),
// Add more native modules here as needed
// e.g., 'better-sqlite3', 'sharp', etc.
];
@@ -32,7 +44,11 @@ export const nativeModules = [
* @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')) {
function resolveDependencies(
moduleName,
visited = new Set(),
nodeModulesPath = path.join(__dirname, 'node_modules'),
) {
if (visited.has(moduleName)) {
return visited;
}

View File

@@ -4,6 +4,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { App } from '@/core/App';
import type { IpcContext } from '@/utils/ipc';
import { IpcHandler } from '@/utils/ipc/base';
import {
__resetMacPermissionsModuleCache,
__setMacPermissionsModule,
} from '@/utils/permissions';
import SystemController from '../SystemCtr';
@@ -131,6 +135,9 @@ describe('SystemController', () => {
ipcHandlers.clear();
ipcMainHandleMock.mockClear();
(IpcHandler.getInstance() as any).registeredChannels?.clear();
// Reset and inject mock permissions module for testing
__resetMacPermissionsModuleCache();
__setMacPermissionsModule(permissionsMock as any);
controller = new SystemController(mockApp);
});
@@ -169,6 +176,8 @@ describe('SystemController', () => {
it('should return true on non-macOS when requesting accessibility access', async () => {
const { macOS } = await import('electron-is');
vi.mocked(macOS).mockReturnValue(false);
// Clear the injected module to simulate non-macOS behavior
__setMacPermissionsModule(null);
const result = await invokeIpc('system.requestAccessibilityAccess');
@@ -177,6 +186,7 @@ describe('SystemController', () => {
// Reset
vi.mocked(macOS).mockReturnValue(true);
__setMacPermissionsModule(permissionsMock as any);
});
});
@@ -226,6 +236,8 @@ describe('SystemController', () => {
const { macOS } = await import('electron-is');
const { shell } = await import('electron');
vi.mocked(macOS).mockReturnValue(false);
// Clear the injected module to simulate non-macOS behavior
__setMacPermissionsModule(null);
const result = await invokeIpc('system.requestMicrophoneAccess');
@@ -235,6 +247,7 @@ describe('SystemController', () => {
// Reset
vi.mocked(macOS).mockReturnValue(true);
__setMacPermissionsModule(permissionsMock as any);
});
});

View File

@@ -1,22 +1,80 @@
/**
* Unified macOS Permission Management using node-mac-permissions
* @see https://github.com/codebytere/node-mac-permissions
*
* IMPORTANT: node-mac-permissions is a macOS-only native module.
* It must be dynamically imported to prevent loading errors on Windows/Linux.
*/
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';
// Type definitions - use module types when available, fallback to local definition
// Note: We don't import the module statically, so we need local type definitions
type AuthType =
| 'accessibility'
| 'calendar'
| 'camera'
| 'contacts'
| 'full-disk-access'
| 'input-monitoring'
| 'location'
| 'microphone'
| 'reminders'
| 'screen'
| 'speech-recognition';
type PermissionType = 'authorized' | 'denied' | 'not determined' | 'restricted';
// Lazy-loaded module cache
let macPermissionsModule: typeof import('node-mac-permissions') | null = null;
// Test injection override (set via __setMacPermissionsModule for testing)
let testModuleOverride: typeof import('node-mac-permissions') | null = null;
/**
* Lazily load the node-mac-permissions module (macOS only)
* Returns null on non-macOS platforms
*/
function getMacPermissionsModule(): typeof import('node-mac-permissions') | null {
// Allow test injection to override the module
if (testModuleOverride) {
return testModuleOverride;
}
if (!macOS()) {
return null;
}
if (!macPermissionsModule) {
// Dynamic require to prevent module loading on non-macOS platforms
// eslint-disable-next-line @typescript-eslint/no-require-imports
macPermissionsModule = require('node-mac-permissions');
}
return macPermissionsModule;
}
/**
* Reset the module cache (for testing purposes)
* @internal
*/
export function __resetMacPermissionsModuleCache(): void {
macPermissionsModule = null;
testModuleOverride = null;
}
/**
* Set the mac permissions module (for testing purposes)
* @internal
*/
export function __setMacPermissionsModule(
module: typeof import('node-mac-permissions') | null,
): void {
testModuleOverride = module;
}
const logger = createLogger('utils:permissions');
/**
@@ -42,12 +100,13 @@ function normalizeStatus(status: PermissionType | 'not determined'): PermissionS
* Get the authorization status for a specific permission type
*/
export function getPermissionStatus(type: AuthType): PermissionStatus {
if (!macOS()) {
const macPermissions = getMacPermissionsModule();
if (!macPermissions) {
logger.debug(`[Permission] Not macOS, returning granted for ${type}`);
return 'granted';
}
const status = getAuthStatus(type);
const status = macPermissions.getAuthStatus(type);
const normalized = normalizeStatus(status);
logger.info(`[Permission] ${type} status: ${normalized}`);
return normalized;
@@ -65,13 +124,14 @@ export function getAccessibilityStatus(): PermissionStatus {
* Opens System Preferences to the Accessibility pane
*/
export function requestAccessibilityAccess(): boolean {
if (!macOS()) {
const macPermissions = getMacPermissionsModule();
if (!macPermissions) {
logger.info('[Accessibility] Not macOS, returning true');
return true;
}
logger.info('[Accessibility] Requesting accessibility access...');
askForAccessibilityAccess();
macPermissions.askForAccessibilityAccess();
// Check the status after requesting
const status = getPermissionStatus('accessibility');
@@ -90,7 +150,8 @@ export function getMicrophoneStatus(): PermissionStatus {
* Shows the system permission dialog if not determined
*/
export async function requestMicrophoneAccess(): Promise<boolean> {
if (!macOS()) {
const macPermissions = getMacPermissionsModule();
if (!macPermissions) {
logger.info('[Microphone] Not macOS, returning true');
return true;
}
@@ -106,7 +167,7 @@ export async function requestMicrophoneAccess(): Promise<boolean> {
if (currentStatus === 'not-determined') {
logger.info('[Microphone] Status is not-determined, requesting access...');
try {
const result = await askForMicrophoneAccess();
const result = await macPermissions.askForMicrophoneAccess();
logger.info(`[Microphone] askForMicrophoneAccess result: ${result}`);
return result === 'authorized';
} catch (error) {
@@ -135,7 +196,8 @@ export function getCameraStatus(): PermissionStatus {
* Shows the system permission dialog if not determined
*/
export async function requestCameraAccess(): Promise<boolean> {
if (!macOS()) {
const macPermissions = getMacPermissionsModule();
if (!macPermissions) {
logger.info('[Camera] Not macOS, returning true');
return true;
}
@@ -151,7 +213,7 @@ export async function requestCameraAccess(): Promise<boolean> {
if (currentStatus === 'not-determined') {
logger.info('[Camera] Status is not-determined, requesting access...');
try {
const result = await askForCameraAccess();
const result = await macPermissions.askForCameraAccess();
logger.info(`[Camera] askForCameraAccess result: ${result}`);
return result === 'authorized';
} catch (error) {
@@ -181,7 +243,8 @@ export function getScreenCaptureStatus(): PermissionStatus {
* @param openPreferences - Whether to open System Preferences (default: true)
*/
export async function requestScreenCaptureAccess(openPreferences = true): Promise<boolean> {
if (!macOS()) {
const macPermissions = getMacPermissionsModule();
if (!macPermissions) {
logger.info('[Screen] Not macOS, returning true');
return true;
}
@@ -196,7 +259,7 @@ export async function requestScreenCaptureAccess(openPreferences = true): Promis
// Request screen capture access - this will prompt the user or open settings
logger.info('[Screen] Requesting screen capture access...');
askForScreenCaptureAccess(openPreferences);
macPermissions.askForScreenCaptureAccess(openPreferences);
// Check the status after requesting
const newStatus = getPermissionStatus('screen');
@@ -218,13 +281,14 @@ export function getFullDiskAccessStatus(): PermissionStatus {
* user must manually add the app in System Settings
*/
export function requestFullDiskAccess(): void {
if (!macOS()) {
const macPermissions = getMacPermissionsModule();
if (!macPermissions) {
logger.info('[FullDiskAccess] Not macOS, skipping');
return;
}
logger.info('[FullDiskAccess] Opening Full Disk Access settings...');
askForFullDiskAccess();
macPermissions.askForFullDiskAccess();
}
/**