- Remove @electron-forge/maker-squirrel dependency - Squirrel.Windows fails on modern Windows GitHub Actions runners with Win32Exception during zip creation - We already use MakerWix which produces a much better .msi installer for Windows users - Add MakerZIP support for win32 to provide a portable alternative alongside the MSI
522 lines
15 KiB
TypeScript
522 lines
15 KiB
TypeScript
import { execSync } from 'child_process';
|
|
import crypto from 'crypto';
|
|
import { FuseV1Options, FuseVersion } from '@electron/fuses';
|
|
import type {
|
|
HookFunction,
|
|
HookFunctionErrorCallback,
|
|
TargetArch,
|
|
TargetPlatform,
|
|
} from '@electron/packager';
|
|
import { MakerDeb } from '@electron-forge/maker-deb';
|
|
import { MakerDMG } from '@electron-forge/maker-dmg';
|
|
import { MakerRpm } from '@electron-forge/maker-rpm';
|
|
import { MakerWix } from '@electron-forge/maker-wix';
|
|
import { MakerZIP } from '@electron-forge/maker-zip';
|
|
import { AutoUnpackNativesPlugin } from '@electron-forge/plugin-auto-unpack-natives';
|
|
import { FusesPlugin } from '@electron-forge/plugin-fuses';
|
|
import { VitePlugin } from '@electron-forge/plugin-vite';
|
|
import type { ForgeConfig } from '@electron-forge/shared-types';
|
|
import MakerAppImage from '@pengx17/electron-forge-maker-appimage';
|
|
// @ts-ignore
|
|
import setLanguages from 'electron-packager-languages';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { stringify as yamlStringify } from 'yaml';
|
|
|
|
const nativeModules = ['better-sqlite3', 'keytar', 'bindings', 'file-uri-to-path'];
|
|
const ResolvedMakerAppImage: any = (MakerAppImage as any).default || MakerAppImage;
|
|
const keepLanguages = new Set(['en', 'en-US', 'zh-CN', 'ru']);
|
|
const windowsExecutableName = 'gemini-nexus';
|
|
|
|
function isWixAvailable(): boolean {
|
|
try {
|
|
execSync('candle -?', { stdio: 'ignore' });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const isStartCommand = process.argv.some((arg) => arg.includes('start'));
|
|
|
|
const artifactRegex = /.*\.(?:exe|dmg|AppImage|zip|deb|rpm|msi)$/;
|
|
const platformNamesMap: Record<string, string> = {
|
|
darwin: 'macos',
|
|
linux: 'linux',
|
|
win32: 'windows',
|
|
};
|
|
const ymlBaseNameMap: Record<string, string> = {
|
|
darwin: 'latest-mac',
|
|
linux: 'latest-linux',
|
|
win32: 'latest',
|
|
};
|
|
const ignorePatterns = [
|
|
/^\/\.git/,
|
|
/^\/\.github/,
|
|
/^\/\.vscode/,
|
|
/^\/\.idea/,
|
|
/^\/openspec/,
|
|
/^\/docs?/,
|
|
/^\/scripts?/,
|
|
/^\/tests?/,
|
|
/^\/mocks?/,
|
|
/^\/src/,
|
|
/^\/node_modules\/\.cache/,
|
|
];
|
|
const setLanguagesHook = setLanguages([...keepLanguages.values()]);
|
|
const packagerAfterCopy: HookFunction[] = [
|
|
(
|
|
buildPath: string,
|
|
electronVersion: string,
|
|
platform: TargetPlatform,
|
|
arch: TargetArch,
|
|
callback: HookFunctionErrorCallback,
|
|
) => {
|
|
if (platform !== 'win32') {
|
|
callback();
|
|
return;
|
|
}
|
|
|
|
setLanguagesHook(buildPath, electronVersion, platform, arch, callback);
|
|
},
|
|
];
|
|
|
|
function normalizeArtifactName(value?: string) {
|
|
if (!value) {
|
|
return 'app';
|
|
}
|
|
|
|
return value
|
|
.trim()
|
|
.replace(/\s+/g, '.')
|
|
.replace(/[^a-zA-Z0-9.]/g, '')
|
|
.replace(/\.+/g, '.');
|
|
}
|
|
|
|
function isSquirrelArtifact(artifactPath: string) {
|
|
const fileName = path.basename(artifactPath);
|
|
if (fileName === 'RELEASES') {
|
|
return true;
|
|
}
|
|
|
|
return artifactPath.endsWith('.nupkg');
|
|
}
|
|
|
|
function mapArchName(arch: string, mapping: Record<string, string>) {
|
|
return mapping[arch] || arch;
|
|
}
|
|
|
|
function getArtifactFileName({
|
|
baseName,
|
|
version,
|
|
arch,
|
|
extension,
|
|
}: {
|
|
baseName: string;
|
|
version: string;
|
|
arch: string;
|
|
extension: string;
|
|
}) {
|
|
if (extension === '.rpm') {
|
|
return `${baseName}-${version}-1.${arch}${extension}`;
|
|
}
|
|
|
|
if (extension === '.deb') {
|
|
return `${baseName}_${version}_${arch}${extension}`;
|
|
}
|
|
|
|
if (extension === '.AppImage') {
|
|
return `${baseName}_${version}_${arch}${extension}`;
|
|
}
|
|
|
|
if (extension === '.dmg') {
|
|
return `${baseName}_${version}_${arch}${extension}`;
|
|
}
|
|
|
|
if (extension === '.exe') {
|
|
return `${baseName}_${version}_${arch}-setup${extension}`;
|
|
}
|
|
|
|
if (extension === '.msi') {
|
|
return `${baseName}_${version}_${arch}_en-US${extension}`;
|
|
}
|
|
|
|
if (extension === '.zip') {
|
|
return `${baseName}_${version}_${arch}${extension}`;
|
|
}
|
|
|
|
return `${baseName}_${version}_${arch}${extension}`;
|
|
}
|
|
|
|
function getUpdateYmlFileName(platform: string, arch: string) {
|
|
const baseName = ymlBaseNameMap[platform];
|
|
if (!baseName) {
|
|
return null;
|
|
}
|
|
|
|
if (platform === 'darwin') {
|
|
return arch === 'universal' ? `${baseName}.yml` : `${baseName}-${arch}.yml`;
|
|
}
|
|
|
|
if (platform === 'linux') {
|
|
return arch === 'x64' ? `${baseName}.yml` : `${baseName}-${arch}.yml`;
|
|
}
|
|
|
|
if (platform === 'win32') {
|
|
return arch === 'x64' ? `${baseName}.yml` : `${baseName}-${arch}.yml`;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function getChecksumArchLabel(platform: string, arch: string) {
|
|
if (platform === 'linux') {
|
|
return mapArchName(arch, { x64: 'amd64', arm64: 'aarch64' });
|
|
}
|
|
|
|
if (platform === 'darwin') {
|
|
return mapArchName(arch, { x64: 'x64', arm64: 'arm64', universal: 'universal' });
|
|
}
|
|
|
|
if (platform === 'win32') {
|
|
return mapArchName(arch, { x64: 'x64', arm64: 'arm64' });
|
|
}
|
|
|
|
return arch;
|
|
}
|
|
|
|
const appImageMaker = new ResolvedMakerAppImage({
|
|
options: {
|
|
icon: 'images/128x128.png',
|
|
},
|
|
});
|
|
appImageMaker.name = '@pengx17/electron-forge-maker-appimage';
|
|
|
|
const config: ForgeConfig = {
|
|
packagerConfig: {
|
|
asar: {
|
|
unpack: '**/{better-sqlite3,keytar}/**/*',
|
|
},
|
|
name: 'Gemini Nexus',
|
|
executableName: windowsExecutableName,
|
|
icon: 'images/icon', // Electron Forge automatically adds .icns/.ico
|
|
extraResource: ['src/assets'], // Copy assets folder to resources/assets
|
|
afterCopy: packagerAfterCopy,
|
|
ignore: ignorePatterns,
|
|
prune: true,
|
|
},
|
|
rebuildConfig: {},
|
|
hooks: {
|
|
packageAfterCopy: async (_config, buildPath) => {
|
|
// Copy native modules to the packaged app
|
|
const nodeModulesPath = path.join(buildPath, 'node_modules');
|
|
if (!fs.existsSync(nodeModulesPath)) {
|
|
fs.mkdirSync(nodeModulesPath, { recursive: true });
|
|
}
|
|
|
|
const copyModuleRecursive = (moduleName: string) => {
|
|
const srcPath = path.join(process.cwd(), 'node_modules', moduleName);
|
|
const destPath = path.join(nodeModulesPath, moduleName);
|
|
|
|
if (fs.existsSync(srcPath)) {
|
|
fs.cpSync(srcPath, destPath, { recursive: true });
|
|
console.log(`Copied native module: ${moduleName}`);
|
|
} else {
|
|
console.warn(`Native module not found: ${moduleName}`);
|
|
}
|
|
};
|
|
|
|
for (const moduleName of nativeModules) {
|
|
copyModuleRecursive(moduleName);
|
|
}
|
|
|
|
// Copy assets to resources folder
|
|
const assetsSrc = path.join(process.cwd(), 'src', 'assets');
|
|
const assetsDest = path.join(buildPath, 'resources', 'assets');
|
|
|
|
if (fs.existsSync(assetsSrc)) {
|
|
if (!fs.existsSync(assetsDest)) {
|
|
fs.mkdirSync(assetsDest, { recursive: true });
|
|
}
|
|
fs.cpSync(assetsSrc, assetsDest, { recursive: true });
|
|
console.log(`Copied assets from ${assetsSrc} to ${assetsDest}`);
|
|
} else {
|
|
console.warn(`Assets directory not found: ${assetsSrc}`);
|
|
}
|
|
},
|
|
postMake: async (_config, makeResults) => {
|
|
if (!makeResults?.length) {
|
|
return makeResults;
|
|
}
|
|
|
|
const ymlByTarget = new Map<
|
|
string,
|
|
{
|
|
basePath: string;
|
|
fileName: string;
|
|
yml: {
|
|
version?: string;
|
|
files: {
|
|
url: string;
|
|
sha512: string;
|
|
size: number;
|
|
}[];
|
|
releaseDate?: string;
|
|
};
|
|
}
|
|
>();
|
|
const checksumByTarget = new Map<
|
|
string,
|
|
{
|
|
basePath: string;
|
|
fileName: string;
|
|
lines: string[];
|
|
}
|
|
>();
|
|
|
|
makeResults = makeResults.map((result) => {
|
|
const productName = normalizeArtifactName(
|
|
result.packageJSON.productName || result.packageJSON.name,
|
|
);
|
|
const platformName = platformNamesMap[result.platform] || result.platform;
|
|
const version = result.packageJSON.version;
|
|
const platformKey = result.platform;
|
|
const archKey = result.arch;
|
|
const updateFileName = getUpdateYmlFileName(platformKey, archKey);
|
|
const updateKey = updateFileName ? `${platformKey}-${archKey}` : null;
|
|
const checksumKey = `${platformKey}-${archKey}`;
|
|
const checksumArchLabel = getChecksumArchLabel(platformKey, archKey);
|
|
const checksumFileName = `sha256sums-${platformName}-${checksumArchLabel}.txt`;
|
|
|
|
if (!checksumByTarget.has(checksumKey)) {
|
|
checksumByTarget.set(checksumKey, {
|
|
basePath: '',
|
|
fileName: checksumFileName,
|
|
lines: [],
|
|
});
|
|
}
|
|
|
|
if (updateFileName && updateKey && !ymlByTarget.has(updateKey)) {
|
|
ymlByTarget.set(updateKey, {
|
|
basePath: '',
|
|
fileName: updateFileName,
|
|
yml: {
|
|
version,
|
|
files: [],
|
|
},
|
|
});
|
|
}
|
|
|
|
const updateState = updateKey ? ymlByTarget.get(updateKey)! : null;
|
|
const checksumState = checksumByTarget.get(checksumKey)!;
|
|
|
|
result.artifacts = result.artifacts
|
|
.map((artifact) => {
|
|
if (!artifact) {
|
|
return null;
|
|
}
|
|
|
|
if (isSquirrelArtifact(artifact)) {
|
|
return artifact;
|
|
}
|
|
|
|
if (!artifactRegex.test(artifact)) {
|
|
return artifact;
|
|
}
|
|
|
|
if (!checksumState.basePath) {
|
|
checksumState.basePath = path.dirname(artifact);
|
|
}
|
|
|
|
if (updateState && !updateState.basePath) {
|
|
updateState.basePath = path.dirname(artifact);
|
|
}
|
|
|
|
const extension = path.extname(artifact);
|
|
let archLabel = archKey;
|
|
if (platformKey === 'linux' && extension === '.rpm') {
|
|
archLabel = mapArchName(archKey, { x64: 'x86_64', arm64: 'aarch64' });
|
|
} else if (platformKey === 'linux' && extension === '.deb') {
|
|
archLabel = mapArchName(archKey, { x64: 'amd64', arm64: 'arm64' });
|
|
} else if (platformKey === 'linux' && extension === '.AppImage') {
|
|
archLabel = mapArchName(archKey, { x64: 'amd64', arm64: 'aarch64' });
|
|
} else if (platformKey === 'darwin') {
|
|
archLabel = mapArchName(archKey, {
|
|
x64: 'x64',
|
|
arm64: 'arm64',
|
|
universal: 'universal',
|
|
});
|
|
} else if (platformKey === 'win32') {
|
|
archLabel = mapArchName(archKey, { x64: 'x64', arm64: 'arm64' });
|
|
}
|
|
|
|
const newArtifact = `${path.dirname(artifact)}/${getArtifactFileName({
|
|
baseName: productName,
|
|
version,
|
|
arch: archLabel,
|
|
extension,
|
|
})}`;
|
|
if (newArtifact !== artifact) {
|
|
fs.renameSync(artifact, newArtifact);
|
|
}
|
|
|
|
try {
|
|
const fileData = fs.readFileSync(newArtifact);
|
|
const hash = crypto.createHash('sha512').update(fileData).digest('base64');
|
|
const sha256 = crypto.createHash('sha256').update(fileData).digest('hex');
|
|
const { size } = fs.statSync(newArtifact);
|
|
|
|
if (updateState) {
|
|
updateState.yml.files.push({
|
|
url: path.basename(newArtifact),
|
|
sha512: hash,
|
|
size,
|
|
});
|
|
}
|
|
|
|
checksumState.lines.push(`${sha256} ${path.basename(newArtifact)}`);
|
|
} catch {
|
|
console.error(`Failed to hash ${newArtifact}`);
|
|
}
|
|
|
|
return newArtifact;
|
|
})
|
|
.filter((artifact) => artifact !== null);
|
|
|
|
return result;
|
|
});
|
|
|
|
const releaseDate = new Date().toISOString();
|
|
for (const [updateKey, updateState] of ymlByTarget.entries()) {
|
|
if (!updateState.basePath) {
|
|
continue;
|
|
}
|
|
|
|
updateState.yml.releaseDate = releaseDate;
|
|
const ymlPath = path.join(updateState.basePath, updateState.fileName);
|
|
fs.writeFileSync(ymlPath, yamlStringify(updateState.yml));
|
|
|
|
const [platform, arch] = updateKey.split('-');
|
|
const sampleResult = makeResults.find(
|
|
(result) => result.platform === platform && result.arch === arch,
|
|
);
|
|
if (!sampleResult) {
|
|
continue;
|
|
}
|
|
|
|
makeResults.push({
|
|
artifacts: [ymlPath],
|
|
platform: sampleResult.platform,
|
|
arch: sampleResult.arch,
|
|
packageJSON: sampleResult.packageJSON,
|
|
});
|
|
}
|
|
|
|
for (const [checksumKey, checksumState] of checksumByTarget.entries()) {
|
|
if (!checksumState.basePath || checksumState.lines.length === 0) {
|
|
continue;
|
|
}
|
|
|
|
const checksumPath = path.join(checksumState.basePath, checksumState.fileName);
|
|
fs.writeFileSync(checksumPath, `${checksumState.lines.join('\n')}\n`);
|
|
|
|
const [platform, arch] = checksumKey.split('-');
|
|
const sampleResult = makeResults.find(
|
|
(result) => result.platform === platform && result.arch === arch,
|
|
);
|
|
if (!sampleResult) {
|
|
continue;
|
|
}
|
|
|
|
makeResults.push({
|
|
artifacts: [checksumPath],
|
|
platform: sampleResult.platform,
|
|
arch: sampleResult.arch,
|
|
packageJSON: sampleResult.packageJSON,
|
|
});
|
|
}
|
|
|
|
return makeResults;
|
|
},
|
|
},
|
|
makers: [
|
|
...(process.platform === 'win32' && process.arch === 'x64' && isWixAvailable()
|
|
? [
|
|
new MakerWix({
|
|
language: 1033,
|
|
icon: path.join(process.cwd(), 'images', 'icon.ico'),
|
|
exe: `${windowsExecutableName}.exe`,
|
|
ui: { chooseDirectory: true },
|
|
}),
|
|
]
|
|
: []),
|
|
new MakerDMG(
|
|
{
|
|
overwrite: true,
|
|
icon: 'images/icon.icns',
|
|
iconSize: 160,
|
|
},
|
|
['darwin'],
|
|
),
|
|
new MakerZIP({}, ['darwin', 'win32']),
|
|
appImageMaker,
|
|
new MakerRpm({}),
|
|
new MakerDeb({}),
|
|
],
|
|
publishers: [
|
|
{
|
|
/*
|
|
* Publish release on GitHub as draft.
|
|
* Remember to manually publish it on GitHub website after verifying everything is correct.
|
|
*/
|
|
name: '@electron-forge/publisher-github',
|
|
config: {
|
|
repository: {
|
|
owner: 'evandrodevbr',
|
|
name: 'GeminiNexus',
|
|
},
|
|
draft: true,
|
|
prerelease: false,
|
|
},
|
|
},
|
|
],
|
|
plugins: [
|
|
new VitePlugin({
|
|
build: [
|
|
{
|
|
entry: 'src/main.ts',
|
|
config: 'vite.main.config.mts',
|
|
target: 'main',
|
|
},
|
|
{
|
|
entry: 'src/preload.ts',
|
|
config: 'vite.preload.config.mts',
|
|
target: 'preload',
|
|
},
|
|
],
|
|
renderer: [
|
|
{
|
|
name: 'main_window',
|
|
config: 'vite.renderer.config.mts',
|
|
},
|
|
],
|
|
}),
|
|
...(!isStartCommand
|
|
? [
|
|
new AutoUnpackNativesPlugin({}),
|
|
new FusesPlugin({
|
|
version: FuseVersion.V1,
|
|
[FuseV1Options.RunAsNode]: false,
|
|
[FuseV1Options.EnableCookieEncryption]: true,
|
|
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
|
|
[FuseV1Options.EnableNodeCliInspectArguments]: false,
|
|
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
|
|
[FuseV1Options.OnlyLoadAppFromAsar]: true,
|
|
}),
|
|
]
|
|
: []),
|
|
],
|
|
};
|
|
|
|
export default config;
|