import "dotenv/config"; import type { ForgeConfig } from "@electron-forge/shared-types"; import { MakerSquirrel } from "@electron-forge/maker-squirrel"; import { MakerZIP } from "@electron-forge/maker-zip"; import { MakerDMG } from "@electron-forge/maker-dmg"; import { MakerDeb } from "@electron-forge/maker-deb"; import { MakerRpm } from "@electron-forge/maker-rpm"; import { VitePlugin } from "@electron-forge/plugin-vite"; import { FusesPlugin } from "@electron-forge/plugin-fuses"; import { FuseV1Options, FuseVersion } from "@electron/fuses"; import { PublisherGithub } from "@electron-forge/publisher-github"; import { readdirSync, rmdirSync, statSync, existsSync, mkdirSync, cpSync, rmSync, lstatSync, readlinkSync, } from "node:fs"; import { join, normalize } from "node:path"; // Use flora-colossus for finding all dependencies of EXTERNAL_DEPENDENCIES // flora-colossus is maintained by MarshallOfSound (a top electron-forge contributor) // already included as a dependency of electron-packager/galactus (so we do NOT have to add it to package.json) // grabs nested dependencies from tree import { Walker, DepType, type Module } from "flora-colossus"; let nativeModuleDependenciesToPackage: string[] = []; export const EXTERNAL_DEPENDENCIES = [ "electron-squirrel-startup", "@libsql/client", "@libsql/darwin-arm64", "@libsql/darwin-x64", "@libsql/linux-x64-gnu", "@libsql/linux-x64-musl", "@libsql/win32-x64-msvc", "libsql", "onnxruntime-node", "@amical/whisper-wrapper", // Add any other native modules you need here ]; const config: ForgeConfig = { hooks: { prePackage: async (_forgeConfig, platform, arch) => { const projectRoot = normalize(__dirname); // In a monorepo, node_modules are typically at the root level const monorepoRoot = join(projectRoot, "../../"); // Go up to monorepo root // Copy platform-specific Node.js binary console.log(`Copying Node.js binary for ${platform}-${arch}...`); const nodeBinarySource = join( projectRoot, "node-binaries", `${platform}-${arch}`, platform === "win32" ? "node.exe" : "node", ); // Check if the binary exists if (existsSync(nodeBinarySource)) { console.log(`✓ Node.js binary found for ${platform}-${arch}`); } else { console.error( `✗ Node.js binary not found for ${platform}-${arch} at ${nodeBinarySource}`, ); console.error( ` Please run 'pnpm download-node' or 'pnpm download-node:all' first`, ); throw new Error(`Missing Node.js binary for ${platform}-${arch}`); } const getExternalNestedDependencies = async ( nodeModuleNames: string[], includeNestedDeps = true, ) => { const foundModules = new Set(nodeModuleNames); if (includeNestedDeps) { for (const external of nodeModuleNames) { type MyPublicClass = { [P in keyof T]: T[P]; }; type MyPublicWalker = MyPublicClass & { modules: Module[]; walkDependenciesForModule: ( moduleRoot: string, depType: DepType, ) => Promise; }; const moduleRoot = join(monorepoRoot, "node_modules", external); console.log("moduleRoot", moduleRoot); // Initialize Walker with monorepo root as base path const walker = new Walker( monorepoRoot, ) as unknown as MyPublicWalker; walker.modules = []; await walker.walkDependenciesForModule(moduleRoot, DepType.PROD); walker.modules .filter( (dep) => (dep.nativeModuleType as number) === DepType.PROD, ) // Remove the problematic name splitting that breaks scoped packages .map((dep) => dep.name) .forEach((name) => foundModules.add(name)); } } return foundModules; }; const nativeModuleDependencies = await getExternalNestedDependencies( EXTERNAL_DEPENDENCIES, ); nativeModuleDependenciesToPackage = Array.from(nativeModuleDependencies); // Copy external dependencies to local node_modules console.error("Copying external dependencies to local node_modules"); const localNodeModules = join(projectRoot, "node_modules"); const rootNodeModules = join(monorepoRoot, "node_modules"); // Ensure local node_modules directory exists if (!existsSync(localNodeModules)) { mkdirSync(localNodeModules, { recursive: true }); } console.log( `Found ${nativeModuleDependenciesToPackage.length} dependencies to copy`, ); // Copy all required dependencies for (const dep of nativeModuleDependenciesToPackage) { const rootDepPath = join(rootNodeModules, dep); const localDepPath = join(localNodeModules, dep); try { // Skip if source doesn't exist if (!existsSync(rootDepPath)) { console.log(`Skipping ${dep}: not found in root node_modules`); continue; } // Skip if target already exists (don't override) if (existsSync(localDepPath)) { console.log(`Skipping ${dep}: already exists locally`); continue; } // Copy the package console.log(`Copying ${dep}...`); cpSync(rootDepPath, localDepPath, { recursive: true, dereference: true, force: true, }); console.log(`✓ Successfully copied ${dep}`); } catch (error) { console.error(`Failed to copy ${dep}:`, error); } } // Prune heavy native sources that trigger MAX_PATH on Windows packages const whisperWrapperPath = join( localNodeModules, "@amical", "whisper-wrapper", ); const whisperPruneTargets = [ join(whisperWrapperPath, "whisper.cpp"), join(whisperWrapperPath, "build"), join(whisperWrapperPath, ".cmake-js"), ]; for (const target of whisperPruneTargets) { if (existsSync(target)) { console.log(`Pruning ${target} from packaged output`); rmSync(target, { recursive: true, force: true }); } } // Second pass: Replace any symlinks with dereferenced copies console.log("Checking for symlinks in copied dependencies..."); for (const dep of nativeModuleDependenciesToPackage) { const localDepPath = join(localNodeModules, dep); try { if (existsSync(localDepPath)) { const stats = lstatSync(localDepPath); if (stats.isSymbolicLink()) { console.log( `Found symlink for ${dep}, replacing with dereferenced copy...`, ); // Read where the symlink points to const symlinkTarget = readlinkSync(localDepPath); let absoluteTarget = symlinkTarget; if (process.platform !== "win32") { absoluteTarget = join(localDepPath, "..", symlinkTarget); } const sourcePath = normalize(absoluteTarget); console.log(` Symlink points to: ${sourcePath}`); // Remove the symlink rmSync(localDepPath, { recursive: true, force: true }); // Copy with dereference to get actual content cpSync(sourcePath, localDepPath, { recursive: true, force: true, dereference: true, // Follow symlinks and copy actual content }); console.log( `✓ Successfully replaced symlink for ${dep} with actual content`, ); } } } catch (error) { console.error(`Failed to check/replace symlink for ${dep}:`, error); } } // Prune onnxruntime-node to keep only the required binary const targetPlatform = platform; const targetArch = arch; console.log( `Pruning onnxruntime-node binaries for ${targetPlatform}/${targetArch}...`, ); const onnxBinRoot = join(localNodeModules, "onnxruntime-node", "bin"); if (existsSync(onnxBinRoot)) { const napiVersionDirs = readdirSync(onnxBinRoot); for (const napiVersionDir of napiVersionDirs) { const napiVersionPath = join(onnxBinRoot, napiVersionDir); if (!statSync(napiVersionPath).isDirectory()) continue; const platformDirs = readdirSync(napiVersionPath); for (const platformDir of platformDirs) { const platformPath = join(napiVersionPath, platformDir); if (!statSync(platformPath).isDirectory()) continue; // Delete unused platforms except Linux (keep for compatibility) if (platformDir !== targetPlatform && platformDir !== "linux") { console.log(`- Deleting unused platform: ${platformPath}`); rmSync(platformPath, { recursive: true, force: true }); } else if (platformDir === targetPlatform) { // Now in the correct platform dir, prune architectures const archDirs = readdirSync(platformPath); for (const archDir of archDirs) { const archPath = join(platformPath, archDir); if (!statSync(archPath).isDirectory()) continue; if (archDir !== targetArch) { console.log(`- Deleting unused arch: ${archPath}`); rmSync(archPath, { recursive: true, force: true }); } } } } } console.log("✓ Finished pruning onnxruntime-node."); } else { console.log( "Skipping onnxruntime-node pruning, bin directory not found.", ); } }, packageAfterPrune: async (_forgeConfig, buildPath) => { console.error("PRE PACKAGE"); try { function getItemsFromFolder( path: string, totalCollection: { path: string; type: "directory" | "file"; empty: boolean; }[] = [], ) { try { const normalizedPath = normalize(path); const childItems = readdirSync(normalizedPath); const getItemStats = statSync(normalizedPath); if (getItemStats.isDirectory()) { totalCollection.push({ path: normalizedPath, type: "directory", empty: childItems.length === 0, }); } childItems.forEach((childItem) => { const childItemNormalizedPath = join(normalizedPath, childItem); const childItemStats = statSync(childItemNormalizedPath); if (childItemStats.isDirectory()) { getItemsFromFolder(childItemNormalizedPath, totalCollection); } else { totalCollection.push({ path: childItemNormalizedPath, type: "file", empty: false, }); } }); } catch { return; } return totalCollection; } const getItems = getItemsFromFolder(buildPath) ?? []; for (const item of getItems) { const DELETE_EMPTY_DIRECTORIES = true; if (item.empty === true) { if (DELETE_EMPTY_DIRECTORIES) { const pathToDelete = normalize(item.path); // one last check to make sure it is a directory and is empty const stats = statSync(pathToDelete); if (!stats.isDirectory()) { // SKIPPING DELETION: pathToDelete is not a directory return; } const childItems = readdirSync(pathToDelete); if (childItems.length !== 0) { // SKIPPING DELETION: pathToDelete is not empty return; } rmdirSync(pathToDelete); } } } } catch (error) { console.error("Error in packageAfterPrune:", error); throw error; } }, }, packagerConfig: { asar: { unpack: "{*.node,*.dylib,*.so,*.dll,*.metal,**/node_modules/@amical/whisper-wrapper/**,**/whisper.cpp/**,**/.vite/build/whisper-worker-fork.js,**/node_modules/jest-worker/**,**/onnxruntime-node/bin/**}", }, name: "Amical", executableName: "Amical", icon: "./assets/logo", // Path to your icon file appBundleId: "com.amical.desktop", // Proper bundle ID extraResource: [ `${process.platform === "win32" ? "../../packages/native-helpers/windows-helper/bin" : "../../packages/native-helpers/swift-helper/bin"}`, "./src/db/migrations", // Only include the platform-specific node binary `./node-binaries/${process.platform}-${process.arch}/node${ process.platform === "win32" ? ".exe" : "" }`, "./models", "./assets", ], extendInfo: { NSMicrophoneUsageDescription: "This app needs access to your microphone to record audio for transcription.", CFBundleURLTypes: [ { CFBundleURLSchemes: ["amical"], CFBundleURLName: "com.amical.desktop", }, ], }, protocols: [ { name: "Amical", schemes: ["amical"], }, ], // Code signing configuration for macOS ...(process.env.SKIP_CODESIGNING === "true" ? {} : { osxSign: { identity: process.env.CODESIGNING_IDENTITY, // Apply different entitlements based on file path optionsForFile: (filePath: string) => { // Apply minimal entitlements to Node binary if (filePath.includes("node-binaries")) { return { entitlements: "./entitlements.node.plist", hardenedRuntime: true, }; } // Use default entitlements for everything else // https://www.npmjs.com/package/@electron/osx-sign#opts // !still need to do any return null as any; }, }, // Notarization for macOS ...(process.env.SKIP_NOTARIZATION === "true" ? {} : { osxNotarize: { appleId: process.env.APPLE_ID!, appleIdPassword: process.env.APPLE_APP_PASSWORD!, teamId: process.env.APPLE_TEAM_ID!, }, }), }), //! issues with monorepo setup and module resolutions //! when forge walks paths via flora-colossus prune: false, ignore: (file: string) => { try { const filePath = file.toLowerCase(); const KEEP_FILE = { keep: false, log: true, }; // NOTE: must return false for empty string or nothing will be packaged if (filePath === "") KEEP_FILE.keep = true; if (!KEEP_FILE.keep && filePath === "/package.json") KEEP_FILE.keep = true; if (!KEEP_FILE.keep && filePath === "/node_modules") KEEP_FILE.keep = true; if (!KEEP_FILE.keep && filePath === "/.vite") KEEP_FILE.keep = true; if (!KEEP_FILE.keep && filePath.startsWith("/.vite/")) KEEP_FILE.keep = true; if (!KEEP_FILE.keep && filePath.startsWith("/node_modules/")) { // check if matches any of the external dependencies for (const dep of nativeModuleDependenciesToPackage) { if ( filePath === `/node_modules/${dep}/` || filePath === `/node_modules/${dep}` ) { KEEP_FILE.keep = true; break; } if (filePath === `/node_modules/${dep}/package.json`) { KEEP_FILE.keep = true; break; } if (filePath.startsWith(`/node_modules/${dep}/`)) { KEEP_FILE.keep = true; KEEP_FILE.log = false; break; } // Handle scoped packages: if dep is @scope/package, also keep @scope/ directory // But not for our workspace packages if (dep.includes("/") && dep.startsWith("@")) { const scopeDir = dep.split("/")[0]; // @libsql/client -> @libsql // for workspace packages only keep the actual package if (scopeDir === "@amical") { if ( filePath.startsWith(`/node_modules/${dep}`) || filePath === `/node_modules/${scopeDir}` ) { KEEP_FILE.keep = true; KEEP_FILE.log = true; } continue; } if ( filePath === `/node_modules/${scopeDir}/` || filePath === `/node_modules/${scopeDir}` || filePath.startsWith(`/node_modules/${scopeDir}/`) ) { KEEP_FILE.keep = true; KEEP_FILE.log = filePath === `/node_modules/${scopeDir}/` || filePath === `/node_modules/${scopeDir}`; break; } } } } if (KEEP_FILE.keep) { if (KEEP_FILE.log) console.log("Keeping:", file); return false; } return true; } catch (error) { console.error("Error in ignore:", error); throw error; } }, }, rebuildConfig: {}, makers: [ new MakerSquirrel({ name: "Amical", setupIcon: "./assets/logo.ico", }), new MakerZIP( { // macOS ZIP files will be named like: Amical-darwin-arm64-1.0.0.zip // The default naming includes platform and arch, which is good for auto-updates }, ["darwin"], ), // Required for macOS auto-updates new MakerDMG( { //! @see https://github.com/electron/forge/issues/3517#issuecomment-2428129194 // macOS DMG files will be named like: Amical-0.0.1-arm64.dmg icon: "./assets/logo.icns", background: "./assets/dmg_bg.tiff", }, ["darwin"], ), new MakerRpm({}), new MakerDeb({}), ], plugins: [ new VitePlugin({ // `build` can specify multiple entry builds, which can be Main process, Preload scripts, Worker process, etc. // If you are familiar with Vite configuration, it will look really familiar. build: [ { // `entry` is just an alias for `build.lib.entry` in the corresponding file of `config`. entry: "src/main/main.ts", config: "vite.main.config.mts", target: "main", }, { entry: "src/main/preload.ts", config: "vite.preload.config.mts", target: "preload", }, { entry: "src/main/onboarding-preload.ts", config: "vite.onboarding-preload.config.mts", target: "preload", }, ], renderer: [ { name: "main_window", config: "vite.renderer.config.mts", }, { name: "widget_window", config: "vite.widget.config.mts", }, { name: "onboarding_window", config: "vite.onboarding.config.mts", }, ], }), // Fuses are used to enable/disable various Electron functionality // at package time, before code signing the application new FusesPlugin({ version: FuseVersion.V1, [FuseV1Options.RunAsNode]: false, [FuseV1Options.EnableCookieEncryption]: true, [FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false, [FuseV1Options.EnableNodeCliInspectArguments]: false, [FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true, [FuseV1Options.OnlyLoadAppFromAsar]: true, }), ], publishers: [ new PublisherGithub({ repository: { owner: "amicalhq", name: "amical", }, prerelease: true, draft: true, // Create draft releases first for review }), ], }; export default config;