Nativefier/src/build/buildNativefierApp.ts

375 lines
12 KiB
TypeScript

import * as path from 'path';
import * as electronGet from '@electron/get';
import electronPackager from 'electron-packager';
import * as fs from 'fs-extra';
import * as log from 'loglevel';
import { convertIconIfNecessary } from './buildIcon';
import {
getTempDir,
hasWine,
isWindows,
isWindowsAdmin,
} from '../helpers/helpers';
import { useOldAppOptions, findUpgradeApp } from '../helpers/upgrade/upgrade';
import {
AppOptions,
OutputOptions,
RawOptions,
} from '../../shared/src/options/model';
import { getOptions, normalizePlatform } from '../options/optionsMain';
import { prepareElectronApp } from './prepareElectronApp';
import { makeUniversalApp } from '@electron/universal';
const OPTIONS_REQUIRING_WINDOWS_FOR_WINDOWS_BUILD = [
'icon',
'appCopyright',
'appVersion',
'buildVersion',
'versionString',
'win32metadata',
];
/**
* For Windows & Linux, we have to copy over the icon to the resources/app
* folder, which the BrowserWindow is hard-coded to read the icon from
*/
async function copyIconsIfNecessary(
options: AppOptions,
appPath: string,
): Promise<void> {
log.debug('Copying icons if necessary');
if (!options.packager.icon) {
log.debug('No icon specified in options; aborting');
return;
}
if (
options.packager.platform === 'darwin' ||
options.packager.platform === 'mas'
) {
if (options.nativefier.tray !== 'false') {
//tray icon needs to be .png
log.debug('Copying icon for tray application');
const trayIconFileName = `tray-icon.png`;
const destIconPath = path.join(appPath, 'icon.png');
await fs.copy(
`${path.dirname(options.packager.icon)}/${trayIconFileName}`,
destIconPath,
);
} else {
log.debug('No copying necessary on macOS; aborting');
}
return;
}
// windows & linux: put the icon file into the app
const destFileName = `icon${path.extname(options.packager.icon)}`;
const destIconPath = path.join(appPath, destFileName);
log.debug(`Copying icon ${options.packager.icon} to`, destIconPath);
await fs.copy(options.packager.icon, destIconPath);
}
/**
* Checks the app path array to determine if packaging completed successfully
*/
function getAppPath(appPath: string | string[]): string | undefined {
if (!Array.isArray(appPath)) {
return appPath;
}
if (appPath.length === 0) {
return undefined; // directory already exists and `--overwrite` not set
}
if (appPath.length > 1) {
log.warn(
'Warning: This should not be happening, packaged app path contains more than one element:',
appPath,
);
}
return appPath[0];
}
function isUpgrade(rawOptions: RawOptions): boolean {
if (
rawOptions.upgrade !== undefined &&
typeof rawOptions.upgrade === 'string' &&
rawOptions.upgrade !== ''
) {
rawOptions.upgradeFrom = rawOptions.upgrade;
rawOptions.upgrade = true;
return true;
}
return false;
}
function trimUnprocessableOptions(options: AppOptions): void {
if (options.packager.platform === 'win32' && !isWindows() && !hasWine()) {
const optionsPresent = Object.entries(options)
.filter(
([key, value]) =>
OPTIONS_REQUIRING_WINDOWS_FOR_WINDOWS_BUILD.includes(key) && !!value,
)
.map(([key]) => key);
if (optionsPresent.length === 0) {
return;
}
log.warn(
`*Not* setting [${optionsPresent.join(', ')}], as couldn't find Wine.`,
'Wine is required when packaging a Windows app under on non-Windows platforms.',
'Also, note that Windows apps built under non-Windows platforms without Wine *will lack* certain',
'features, like a correct icon and process name. Do yourself a favor and install Wine, please.',
);
for (const keyToUnset of optionsPresent) {
(options as unknown as Record<string, undefined>)[keyToUnset] = undefined;
}
}
}
function isInvalidUniversal(options: RawOptions): boolean {
const platform = normalizePlatform(options.platform);
if (
(options.arch ?? '').toLowerCase() === 'universal' &&
platform !== 'darwin' &&
platform !== 'mas'
) {
return true;
}
return false;
}
function getOSRunHelp(platform?: string): string {
if (platform === 'win32') {
return `the contained .exe file.`;
} else if (platform === 'linux') {
return `the contained executable file (prefixing with ./ if necessary)\nMenu/desktop shortcuts are up to you, because Nativefier cannot know where you're going to move the app. Search for "linux .desktop file" for help, or see https://wiki.archlinux.org/index.php/Desktop_entries`;
} else if (platform === 'darwin') {
return `the app bundle.`;
}
return '';
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export async function buildNativefierApp(
rawOptions: RawOptions,
): Promise<string> {
// early-suppress potential logging before full options handling
if (rawOptions.quiet) {
log.setLevel('silent');
}
log.warn(
'\n\n Hi! Nativefier is minimally maintained these days, and needs more hands.\n' +
' If you have the time & motivation, help with bugfixes and maintenance is VERY welcome.\n' +
' Please go to https://github.com/nativefier/nativefier and help how you can. Thanks.\n\n',
);
log.info('\nProcessing options...');
let finalOutDirectory = rawOptions.out ?? process.cwd();
if (isUpgrade(rawOptions)) {
log.debug('Attempting to upgrade from', rawOptions.upgradeFrom);
const oldApp = findUpgradeApp(rawOptions.upgradeFrom as string);
if (!oldApp) {
throw new Error(
`Could not find an old Nativfier app in "${
rawOptions.upgradeFrom as string
}"`,
);
}
rawOptions = useOldAppOptions(rawOptions, oldApp);
if (rawOptions.out === undefined && rawOptions.overwrite) {
finalOutDirectory = oldApp.appRoot;
rawOptions.out = getTempDir('appUpgrade', 0o755);
}
}
log.debug('rawOptions', rawOptions);
const options = await getOptions(rawOptions);
log.debug('options', options);
if (options.packager.platform === 'darwin' && isWindows()) {
// electron-packager has to extract the desired electron package for the target platform.
// For a target platform of Mac, this zip file contains symlinks. And on Windows, extracting
// files that are symlinks need Admin permissions. So we'll check if the user is an admin, and
// fail early if not.
// For reference
// https://github.com/electron/electron-packager/issues/933
// https://github.com/electron/electron-packager/issues/1194
// https://github.com/electron/electron/issues/11094
if (!isWindowsAdmin()) {
throw new Error(
'Building an app with a target platform of Mac on a Windows machine requires admin priveleges to perform. Please rerun this command in an admin command prompt.',
);
}
}
log.info('\nPreparing Electron app...');
const tmpPath = getTempDir('app', 0o755);
await prepareElectronApp(options.packager.dir, tmpPath, options);
log.info('\nConverting icons...');
options.packager.dir = tmpPath;
convertIconIfNecessary(options);
await copyIconsIfNecessary(options, tmpPath);
log.info(
"\nPackaging... This will take a few seconds, maybe minutes if the requested Electron isn't cached yet...",
);
trimUnprocessableOptions(options);
electronGet.initializeProxy(); // https://github.com/electron/get#proxies
const appPathArray = await electronPackager(options.packager);
log.info('\nFinalizing build...');
let appPath = getAppPath(appPathArray);
if (!appPath) {
throw new Error('App Path could not be determined.');
}
if (
options.packager.upgrade &&
options.packager.upgradeFrom &&
options.packager.overwrite
) {
if (options.packager.platform === 'darwin') {
try {
// This is needed due to a funky thing that happens when copying Squirrel.framework
// over where it gets into a circular file reference somehow.
await fs.remove(
path.join(
finalOutDirectory,
`${options.packager.name ?? ''}.app`,
'Contents',
'Frameworks',
),
);
} catch (err: unknown) {
log.warn(
'Encountered an error when attempting to pre-delete old frameworks:',
err,
);
}
await fs.copy(
path.join(appPath, `${options.packager.name ?? ''}.app`),
path.join(finalOutDirectory, `${options.packager.name ?? ''}.app`),
{
overwrite: options.packager.overwrite,
preserveTimestamps: true,
},
);
} else {
await fs.copy(appPath, finalOutDirectory, {
overwrite: options.packager.overwrite,
preserveTimestamps: true,
});
}
await fs.remove(appPath);
appPath = finalOutDirectory;
}
const osRunHelp = getOSRunHelp(options.packager.platform);
log.info(
`App built to ${appPath}, move to wherever it makes sense for you and run ${osRunHelp}`,
);
return appPath;
}
function modifyOptionsForUniversal(appPath: string, buildDate: number): void {
const nativefierJSONPath = path.join(
appPath,
'Contents',
'Resources',
'app',
'nativefier.json',
);
const options = JSON.parse(
fs.readFileSync(nativefierJSONPath, 'utf8'),
) as OutputOptions;
options.arch = 'universal';
options.buildDate = buildDate;
fs.writeFileSync(nativefierJSONPath, JSON.stringify(options, null, 2));
}
export async function buildUniversalApp(options: RawOptions): Promise<string> {
if (isInvalidUniversal(options)) {
throw new Error(
'arch of "universal" can only be used with Mac OS app types.',
);
}
const platform = normalizePlatform(options.platform);
const x64Options = { ...options, arch: 'x64' };
const arm64Options = { ...options, arch: 'arm64' };
log.info('Creating universal Mac binary...');
let x64Path: string | undefined;
let arm64Path: string | undefined;
try {
x64Path = await buildNativefierApp(x64Options);
arm64Path = await buildNativefierApp(arm64Options);
const universalAppPath = path
.join(
x64Path,
`${path.parse(x64Path).base.replace(`-${platform}-x64`, '')}.app`,
)
.replace('x64', 'universal');
const x64AppPath = path.join(
x64Path,
`${path.parse(x64Path).base.replace(`-${platform}-x64`, '')}.app`,
);
const arm64AppPath = path.join(
arm64Path,
`${path.parse(arm64Path).base.replace(`-${platform}-arm64`, '')}.app`,
);
// We're going to change the nativefier.json on these to match otherwise we'll see:
// Expected all non-binary files to have identical SHAs when creating a universal build but "Google.app/Contents/Resources/app/nativefier.json" did not
const buildDate = new Date().getTime();
modifyOptionsForUniversal(x64AppPath, buildDate);
modifyOptionsForUniversal(arm64AppPath, buildDate);
await makeUniversalApp({
x64AppPath,
arm64AppPath,
outAppPath: universalAppPath,
force: !!options.overwrite,
});
await fs.copyFile(
path.join(x64Path, 'LICENSE'),
path.join(universalAppPath, '..', 'LICENSE'),
);
await fs.copyFile(
path.join(x64Path, 'LICENSES.chromium.html'),
path.join(universalAppPath, '..', 'LICENSES.chromium.html'),
);
await fs.copyFile(
path.join(x64Path, 'version'),
path.join(universalAppPath, '..', 'version'),
);
const osRunHelp = getOSRunHelp(platform);
log.info(
`App built to ${universalAppPath}, move to wherever it makes sense for you and run ${osRunHelp}`,
);
return universalAppPath;
} finally {
if (x64Path) {
fs.removeSync(x64Path);
}
if (arm64Path) {
fs.removeSync(arm64Path);
}
}
}