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:
7 files changed
+2280
-11
No files matched your search
+233
-7
@@ -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: [
|
||||
{
|
||||
/*
|
||||
|
||||
Generated
+1814
-3
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -26,6 +26,7 @@
|
||||
},
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"types/**/*.d.ts",
|
||||
"./package.json",
|
||||
"./forge.config.ts",
|
||||
"*.mts",
|
||||
|
||||
Vendored
+38
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user