amical/apps/desktop/forge.config.ts
2025-11-12 14:39:47 +05:30

567 lines
20 KiB
TypeScript

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<T> = {
[P in keyof T]: T[P];
};
type MyPublicWalker = MyPublicClass<Walker> & {
modules: Module[];
walkDependenciesForModule: (
moduleRoot: string,
depType: DepType,
) => Promise<void>;
};
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;