diff --git a/apps/desktop/electron-builder.mjs b/apps/desktop/electron-builder.mjs index eb382b4d3b..424d28c2e5 100644 --- a/apps/desktop/electron-builder.mjs +++ b/apps/desktop/electron-builder.mjs @@ -4,7 +4,11 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { getAsarUnpackPatterns, getFilesPatterns } from './native-deps.config.mjs'; +import { + copyNativeModules, + getAsarUnpackPatterns, + getFilesPatterns, +} from './native-deps.config.mjs'; dotenv.config(); @@ -86,30 +90,46 @@ const getIconFileName = () => { */ const config = { /** - * AfterPack hook to copy pre-generated Liquid Glass Assets.car for macOS 26+ + * AfterPack hook for post-processing: + * 1. Copy native modules to asar.unpacked (resolving pnpm symlinks) + * 2. Copy Liquid Glass Assets.car for macOS 26+ + * 3. Remove unused Electron Framework localizations + * * @see https://github.com/electron-userland/electron-builder/issues/9254 * @see https://github.com/MultiboxLabs/flow-browser/pull/159 * @see https://github.com/electron/packager/pull/1806 */ afterPack: async (context) => { - // Only process macOS builds - if (!['darwin', 'mas'].includes(context.electronPlatformName)) { + const isMac = ['darwin', 'mas'].includes(context.electronPlatformName); + + // Determine resources path based on platform + let resourcesPath; + if (isMac) { + resourcesPath = path.join( + context.appOutDir, + `${context.packager.appInfo.productFilename}.app`, + 'Contents', + 'Resources', + ); + } else { + // Windows and Linux: resources is directly in appOutDir + resourcesPath = path.join(context.appOutDir, 'resources'); + } + + // Copy native modules to asar.unpacked, resolving pnpm symlinks + const unpackedNodeModules = path.join(resourcesPath, 'app.asar.unpacked', 'node_modules'); + await copyNativeModules(unpackedNodeModules); + + // macOS-specific post-processing + if (!isMac) { return; } const iconFileName = getIconFileName(); const assetsCarSource = path.join(__dirname, 'build', `${iconFileName}.Assets.car`); - const resourcesPath = path.join( - context.appOutDir, - `${context.packager.appInfo.productFilename}.app`, - 'Contents', - 'Resources', - ); const assetsCarDest = path.join(resourcesPath, 'Assets.car'); // Remove unused Electron Framework localizations to reduce app size - // Equivalent to: - // ../../Frameworks/Electron Framework.framework/Versions/A/Resources/*.lproj const frameworkResourcePath = path.join( context.appOutDir, `${context.packager.appInfo.productFilename}.app`, @@ -155,7 +175,7 @@ const config = { appImage: { artifactName: '${productName}-${version}.${ext}', }, - asar: true, + // Native modules must be unpacked from asar to work correctly asarUnpack: getAsarUnpackPatterns(), diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index 0a15c402b3..e6c4ff66a2 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -18,6 +18,14 @@ export default defineConfig({ rollupOptions: { // Native modules must be externalized to work correctly external: getExternalDependencies(), + output: { + // Prevent debug package from being bundled into index.js to avoid side-effect pollution + manualChunks(id) { + if (id.includes('node_modules/debug')) { + return 'vendor-debug'; + } + }, + }, }, sourcemap: isDev ? 'inline' : false, }, @@ -25,7 +33,6 @@ export default defineConfig({ 'process.env.UPDATE_CHANNEL': JSON.stringify(process.env.UPDATE_CHANNEL), 'process.env.UPDATE_SERVER_URL': JSON.stringify(process.env.UPDATE_SERVER_URL), }, - resolve: { alias: { '@': resolve(__dirname, 'src/main'), diff --git a/apps/desktop/native-deps.config.mjs b/apps/desktop/native-deps.config.mjs index ffc05a3baa..d88dcdf844 100644 --- a/apps/desktop/native-deps.config.mjs +++ b/apps/desktop/native-deps.config.mjs @@ -24,6 +24,7 @@ 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 @@ -33,8 +34,8 @@ const isDarwin = getTargetPlatform() === 'darwin'; export const nativeModules = [ // macOS-only native modules ...(isDarwin ? ['node-mac-permissions'] : []), + '@napi-rs/canvas', // Add more native modules here as needed - // e.g., 'better-sqlite3', 'sharp', etc. ]; /** @@ -53,22 +54,32 @@ function resolveDependencies( return visited; } + // Always add the module name first (important for workspace dependencies + // that may not be in local node_modules but are declared in nativeModules) + visited.add(moduleName); + const packageJsonPath = path.join(nodeModulesPath, moduleName, 'package.json'); - // Check if module exists + // If module doesn't exist locally, still keep it in visited but skip dependency resolution if (!fs.existsSync(packageJsonPath)) { return visited; } - visited.add(moduleName); - try { const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); const dependencies = packageJson.dependencies || {}; + const optionalDependencies = packageJson.optionalDependencies || {}; + // Resolve regular dependencies for (const dep of Object.keys(dependencies)) { resolveDependencies(dep, visited, nodeModulesPath); } + + // Also resolve optional dependencies (important for native modules like @napi-rs/canvas + // which have platform-specific binaries in optional deps) + for (const dep of Object.keys(optionalDependencies)) { + resolveDependencies(dep, visited, nodeModulesPath); + } } catch { // Ignore errors reading package.json } @@ -116,3 +127,79 @@ export function getAsarUnpackPatterns() { export function getExternalDependencies() { return getAllDependencies(); } + +/** + * Copy native modules to destination, resolving symlinks + * This is used in afterPack hook to handle pnpm symlinks correctly + * @param {string} destNodeModules - Destination node_modules path + */ +export async function copyNativeModules(destNodeModules) { + const fsPromises = await import('node:fs/promises'); + const deps = getAllDependencies(); + const sourceNodeModules = path.join(__dirname, 'node_modules'); + + console.log(`📦 Copying ${deps.length} native modules to unpacked directory...`); + + for (const dep of deps) { + const sourcePath = path.join(sourceNodeModules, dep); + const destPath = path.join(destNodeModules, dep); + + try { + // Check if source exists (might be a symlink) + const stat = await fsPromises.lstat(sourcePath); + + if (stat.isSymbolicLink()) { + // Resolve the symlink to get the real path + const realPath = await fsPromises.realpath(sourcePath); + console.log(` 📎 ${dep} (symlink -> ${path.relative(sourceNodeModules, realPath)})`); + + // Create destination directory + await fsPromises.mkdir(path.dirname(destPath), { recursive: true }); + + // Copy the actual directory content (not the symlink) + await copyDir(realPath, destPath); + } else if (stat.isDirectory()) { + console.log(` 📁 ${dep}`); + await fsPromises.mkdir(path.dirname(destPath), { recursive: true }); + await copyDir(sourcePath, destPath); + } + } catch (err) { + // Module might not exist (optional dependency for different platform) + console.log(` ⏭️ ${dep} (skipped: ${err.code || err.message})`); + } + } + + console.log(`✅ Native modules copied successfully`); +} + +/** + * Recursively copy a directory + * @param {string} src - Source directory + * @param {string} dest - Destination directory + */ +async function copyDir(src, dest) { + const fsPromises = await import('node:fs/promises'); + + await fsPromises.mkdir(dest, { recursive: true }); + const entries = await fsPromises.readdir(src, { withFileTypes: true }); + + for (const entry of entries) { + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + + if (entry.isDirectory()) { + await copyDir(srcPath, destPath); + } else if (entry.isSymbolicLink()) { + // For symlinks within the module, resolve and copy the actual file + const realPath = await fsPromises.realpath(srcPath); + const realStat = await fsPromises.stat(realPath); + if (realStat.isDirectory()) { + await copyDir(realPath, destPath); + } else { + await fsPromises.copyFile(realPath, destPath); + } + } else { + await fsPromises.copyFile(srcPath, destPath); + } + } +} diff --git a/packages/file-loaders/package.json b/packages/file-loaders/package.json index 48b701de7a..e78716f74d 100644 --- a/packages/file-loaders/package.json +++ b/packages/file-loaders/package.json @@ -33,10 +33,10 @@ "pdfjs-dist": "5.4.530", "word-extractor": "^1.0.4", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", - "yauzl": "^3.2.0" + "yauzl": "^3.2.0", + "@napi-rs/canvas": "^0.1.70" }, "devDependencies": { - "@napi-rs/canvas": "^0.1.70", "@types/concat-stream": "^2.0.3", "@types/yauzl": "^2.10.3", "typescript": "^5.9.3" @@ -44,4 +44,4 @@ "peerDependencies": { "typescript": ">=5" } -} +} \ No newline at end of file