mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-26 13:19:34 +07:00
🐛 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:
53
.github/workflows/manual-build-desktop.yml
vendored
53
.github/workflows/manual-build-desktop.yml
vendored
@@ -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 }}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user