feat: windows install guidance (#63)

* build: add dmg/appimage and artifact metadata

* feat: enhance packaging outputs and add Windows install-path notice
This commit is contained in:
vin authored and GitHub committed 2026-02-04 18:10:26 +08:00
1 parent 080fb67974
commit ce7147025d
7 files changed
+2280 -11

No files matched your search

+233 -7
View File
@@ -1,19 +1,115 @@
import type { ForgeConfig } from '@electron-forge/shared-types';
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 { MakerSquirrel } from '@electron-forge/maker-squirrel';
import { MakerZIP } from '@electron-forge/maker-zip';
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 { 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';
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 = MakerAppImage;
const keepLanguages = new Set(['en', 'en-US', 'zh-CN', 'ru']);
const isStartCommand = process.argv.some((arg) => arg.includes('start'));
const artifactRegex = /.*\.(?:exe|dmg|AppImage|zip|deb|rpm)$/;
const platformNamesMap: Record<string, string> = {
darwin: 'macos',
linux: 'linux',
win32: 'windows',
};
const ymlMapsMap: Record<string, string> = {
darwin: 'latest-mac.yml',
linux: 'latest-linux.yml',
win32: 'latest.yml',
};
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, '-');
}
function isSquirrelArtifact(artifactPath: string) {
const fileName = path.basename(artifactPath);
if (fileName === 'RELEASES') {
return true;
}
return artifactPath.endsWith('.nupkg');
}
const appImageMaker = new ResolvedMakerAppImage({
config: {
icons: [
{
file: 'images/32x32.png',
size: 32,
},
{
file: 'images/64x64.png',
size: 64,
},
{
file: 'images/128x128.png',
size: 128,
},
{
file: 'images/[email protected]',
size: 256,
},
],
},
});
appImageMaker.name = '@pengx17/electron-forge-maker-appimage';
const config: ForgeConfig = {
packagerConfig: {
asar: {
@@ -23,6 +119,9 @@ const config: ForgeConfig = {
executableName: 'antigravity-manager',
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: {
@@ -63,8 +162,135 @@ const config: ForgeConfig = {
console.warn(`Assets directory not found: ${assetsSrc}`);
}
},
postMake: async (_config, makeResults) => {
if (!makeResults?.length) {
return makeResults;
}
const ymlByPlatform = new Map<
string,
{
basePath: string;
yml: {
version?: string;
files: {
url: string;
sha512: string;
size: number;
}[];
releaseDate?: string;
};
}
>();
makeResults = makeResults.map((result) => {
const productName = normalizeArtifactName(result.packageJSON.productName);
const platformName = platformNamesMap[result.platform] || result.platform;
const version = result.packageJSON.version;
const platformKey = result.platform;
if (!ymlByPlatform.has(platformKey)) {
ymlByPlatform.set(platformKey, {
basePath: '',
yml: {
version,
files: [],
},
});
}
const platformState = ymlByPlatform.get(platformKey)!;
result.artifacts = result.artifacts
.map((artifact) => {
if (!artifact) {
return null;
}
if (isSquirrelArtifact(artifact)) {
return artifact;
}
if (!artifactRegex.test(artifact)) {
return artifact;
}
if (!platformState.basePath) {
platformState.basePath = path.dirname(artifact);
}
const newArtifact = `${path.dirname(artifact)}/${productName}-${version}-${platformName}-${result.arch}${path.extname(artifact)}`;
if (newArtifact !== artifact) {
fs.renameSync(artifact, newArtifact);
}
try {
const fileData = fs.readFileSync(newArtifact);
const hash = crypto.createHash('sha512').update(fileData).digest('base64');
const { size } = fs.statSync(newArtifact);
platformState.yml.files.push({
url: path.basename(newArtifact),
sha512: hash,
size,
});
} catch {
console.error(`Failed to hash ${newArtifact}`);
}
return newArtifact;
})
.filter((artifact) => artifact !== null);
return result;
});
const releaseDate = new Date().toISOString();
for (const [platform, platformState] of ymlByPlatform.entries()) {
const ymlFileName = ymlMapsMap[platform];
if (!ymlFileName || !platformState.basePath) {
continue;
}
platformState.yml.releaseDate = releaseDate;
const ymlPath = path.join(platformState.basePath, ymlFileName);
fs.writeFileSync(ymlPath, yamlStringify(platformState.yml));
const sampleResult = makeResults.find((result) => result.platform === platform);
if (!sampleResult) {
continue;
}
makeResults.push({
artifacts: [ymlPath],
platform: sampleResult.platform,
arch: sampleResult.arch,
packageJSON: sampleResult.packageJSON,
});
}
return makeResults;
},
},
makers: [new MakerSquirrel({}), new MakerZIP({}, ['darwin']), new MakerRpm({}), new MakerDeb({})],
makers: [
new MakerSquirrel({
setupIcon: 'images/icon.ico',
iconUrl:
'https://raw.githubusercontent.com/Draculabo/AntigravityManager/main/images/icon.ico',
}),
new MakerDMG(
{
overwrite: true,
icon: 'images/icon.icns',
iconSize: 160,
},
['darwin'],
),
new MakerZIP({}, ['darwin']),
appImageMaker,
new MakerRpm({}),
new MakerDeb({}),
],
publishers: [
{
/*
+1814 -3
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -25,6 +25,7 @@
"devDependencies": {
"@electron-forge/cli": "^7.10.2",
"@electron-forge/maker-deb": "^7.10.2",
"@electron-forge/maker-dmg": "^7.10.2",
"@electron-forge/maker-rpm": "^7.10.2",
"@electron-forge/maker-squirrel": "^7.10.2",
"@electron-forge/maker-zip": "^7.10.2",
@@ -33,6 +34,7 @@
"@electron-forge/plugin-vite": "^7.10.2",
"@electron-forge/publisher-github": "^7.10.2",
"@electron-forge/shared-types": "^7.8.1",
"@pengx17/electron-forge-maker-appimage": "^1.2.1",
"@electron/fuses": "~1.8.0",
"@electron/rebuild": "^3.7.1",
"@eslint/compat": "^1.4.1",
@@ -61,6 +63,7 @@
"electron": "^37.3.1",
"electron-devtools-installer": "^4.0.0",
"electron-playwright-helpers": "^1.8.2",
"electron-packager-languages": "^0.6.0",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
+71 -1
View File
@@ -1,13 +1,21 @@
import './instrument'; // MUST be the first import to ensure Sentry initializes before app ready
import { app, BrowserWindow } from 'electron';
import { app, BrowserWindow, dialog, shell } from 'electron';
import type { MessageBoxOptions } from 'electron';
import path from 'path';
import fs from 'fs';
import squirrelStartup from 'electron-squirrel-startup';
import { ipcMain } from 'electron/main';
import { ipcContext } from '@/ipc/context';
import { IPC_CHANNELS } from './constants';
import { updateElectronApp, UpdateSourceType } from 'update-electron-app';
import { logger } from './utils/logger';
import {
getExpectedInstallRoot,
getInstallNoticeText,
isRunningFromExpectedInstallDir as isRunningFromExpectedInstallDirUtil,
resolveInstallNoticeLanguage,
} from './utils/installNotice';
import { CloudAccountRepo } from './ipc/database/cloudHandler';
import { initDatabase } from './ipc/database/handler';
import { CloudMonitorService } from './services/CloudMonitorService';
@@ -58,6 +66,11 @@ ipcMain.on(IPC_CHANNELS.CHANGE_LANGUAGE, (event, lang) => {
app.disableHardwareAcceleration();
if (squirrelStartup) {
app.quit();
process.exit(0);
}
const inDevelopment = process.env.NODE_ENV === 'development';
// Keep a global reference of the window object, if you don't, the window will
@@ -67,6 +80,62 @@ let globalMainWindow: BrowserWindow | null = null;
let isQuitting = false;
let startupConfig: AppConfig | null = null;
let shouldStartHidden = false;
let hasShownInstallNotice = false;
function isRunningFromExpectedInstallDir() {
return isRunningFromExpectedInstallDirUtil({
platform: process.platform,
isPackaged: app.isPackaged,
localAppData: process.env.LOCALAPPDATA,
appName: app.getName(),
execPath: process.execPath,
});
}
function showWindowsInstallNoticeIfNeeded() {
if (hasShownInstallNotice) {
return;
}
if (isRunningFromExpectedInstallDir()) {
return;
}
const expectedRoot = getExpectedInstallRoot({
platform: process.platform,
localAppData: process.env.LOCALAPPDATA,
appName: app.getName(),
});
if (!expectedRoot) {
return;
}
hasShownInstallNotice = true;
const language = resolveInstallNoticeLanguage({
configLanguage: startupConfig?.language,
locale: app.getLocale(),
});
const text = getInstallNoticeText(language);
const options: MessageBoxOptions = {
type: 'info',
title: text.title,
message: text.message,
detail: `${text.detailPrefix}${expectedRoot}`,
buttons: [...text.buttons],
defaultId: 1,
};
const showPromise = globalMainWindow
? dialog.showMessageBox(globalMainWindow, options)
: dialog.showMessageBox(options);
showPromise.then(({ response }) => {
if (response === 0) {
shell.openPath(expectedRoot);
}
});
}
const gotSingleInstanceLock = app.requestSingleInstanceLock();
@@ -154,6 +223,7 @@ function createWindow({ startHidden }: { startHidden: boolean }) {
}
logger.info('Window created');
showWindowsInstallNoticeIfNeeded();
mainWindow.on('close', (event) => {
if (!isQuitting) {
+120
View File
@@ -0,0 +1,120 @@
import path from 'path';
export type InstallNoticeLanguage = 'zh-CN' | 'en' | 'ru';
const installNoticeText: Record<
InstallNoticeLanguage,
{
title: string;
message: string;
detailPrefix: string;
buttons: [string, string];
}
> = {
'zh-CN': {
title: '建议从开始菜单启动',
message:
'检测到你正在从非安装路径启动应用。为了确保自动更新生效,请从开始菜单或桌面快捷方式启动。如果没有快捷方式,请重新运行安装包。',
detailPrefix: '安装目录:',
buttons: ['打开安装目录', '知道了'],
},
en: {
title: 'Please launch from the Start menu',
message:
'We detected the app is running from a non-install location. To ensure auto-updates work, launch it from the Start menu or desktop shortcut. If no shortcut exists, run the installer again.',
detailPrefix: 'Install location: ',
buttons: ['Open install folder', 'OK'],
},
ru: {
title: 'Запускайте приложение из меню «Пуск»',
message:
'Обнаружен запуск из неустановленного пути. Чтобы автообновления работали, запускайте приложение из меню «Пуск» или ярлыка. Если ярлыка нет, запустите установщик ещё раз.',
detailPrefix: 'Папка установки: ',
buttons: ['Открыть папку', 'Понятно'],
},
};
export function resolveInstallNoticeLanguage({
configLanguage,
locale,
}: {
configLanguage?: string | null;
locale?: string | null;
}): InstallNoticeLanguage {
const rawLanguage = configLanguage || locale;
if (!rawLanguage) {
return 'en';
}
const normalized = rawLanguage.toLowerCase();
if (normalized.startsWith('zh')) {
return 'zh-CN';
}
if (normalized.startsWith('ru')) {
return 'ru';
}
return 'en';
}
export function getInstallNoticeText(language: InstallNoticeLanguage) {
return installNoticeText[language] || installNoticeText.en;
}
function getPathApi(platform: string) {
if (platform === 'win32') {
return path.win32;
}
return path;
}
export function getExpectedInstallRoot({
platform,
localAppData,
appName,
}: {
platform: string;
localAppData?: string | null;
appName: string;
}) {
if (platform !== 'win32') {
return null;
}
if (!localAppData) {
return null;
}
const pathApi = getPathApi(platform);
return pathApi.resolve(pathApi.join(localAppData, appName));
}
export function isRunningFromExpectedInstallDir({
platform,
isPackaged,
localAppData,
appName,
execPath,
}: {
platform: string;
isPackaged: boolean;
localAppData?: string | null;
appName: string;
execPath: string;
}) {
if (platform !== 'win32' || !isPackaged) {
return true;
}
const expectedRoot = getExpectedInstallRoot({ platform, localAppData, appName });
if (!expectedRoot) {
return true;
}
const pathApi = getPathApi(platform);
const normalizedExecPath = pathApi.resolve(execPath);
return normalizedExecPath.toLowerCase().startsWith(expectedRoot.toLowerCase() + pathApi.sep);
}
+1
View File
@@ -26,6 +26,7 @@
},
"include": [
"src/**/*",
"types/**/*.d.ts",
"./package.json",
"./forge.config.ts",
"*.mts",
+38
View File
@@ -0,0 +1,38 @@
declare module 'electron-packager-languages' {
type AfterCopyHook = (
buildPath: string,
electronVersion: string,
platform: string,
arch: string,
callback: () => void,
) => void;
interface SetLanguagesOptions {
allowRemovingAll?: boolean;
}
function setLanguages(languages: string[], options?: SetLanguagesOptions): AfterCopyHook;
export = setLanguages;
}
declare module '@pengx17/electron-forge-maker-appimage' {
import type MakerBase from '@electron-forge/maker-base';
import type { ForgePlatform } from '@electron-forge/shared-types';
export interface AppImageForgeConfig {
template?: string;
chmodChromeSandbox?: string;
icons?: { file: string; size: number }[];
}
export interface MakerAppImageConfig {
config?: AppImageForgeConfig;
}
export default class MakerAppImage extends MakerBase<MakerAppImageConfig> {
name: string;
defaultPlatforms: ForgePlatform[];
isSupportedOnCurrentPlatform(): boolean;
}
}