From 9330c8434f4f6bd9e12b4553726a5f9a33b41148 Mon Sep 17 00:00:00 2001 From: Adam Weeden Date: Wed, 28 Apr 2021 22:00:31 -0400 Subject: [PATCH] Add an option to upgrade an existing app (fix #1131) (PR #1138) This adds a `--upgrade` option to upgrade-in-place an old app, re-using its options it can. Should help fix #1131 Co-authored-by: Ronan Jouchet --- docs/api.md | 23 ++- src/build/buildNativefierApp.ts | 80 ++++++--- src/build/prepareElectronApp.ts | 26 ++- src/cli.ts | 20 ++- src/helpers/fsHelpers.ts | 19 ++ src/helpers/upgrade/executableHelpers.ts | 186 +++++++++++++++++++ src/helpers/upgrade/plistInfoXMLHelpers.ts | 39 ++++ src/helpers/upgrade/rceditGet.ts | 42 +++++ src/helpers/upgrade/upgrade.ts | 199 +++++++++++++++++++++ src/integration-test.ts | 91 +++++++++- src/options/model.ts | 4 + src/options/optionsMain.ts | 11 +- 12 files changed, 701 insertions(+), 39 deletions(-) create mode 100644 src/helpers/fsHelpers.ts create mode 100644 src/helpers/upgrade/executableHelpers.ts create mode 100644 src/helpers/upgrade/plistInfoXMLHelpers.ts create mode 100644 src/helpers/upgrade/rceditGet.ts create mode 100644 src/helpers/upgrade/upgrade.ts diff --git a/docs/api.md b/docs/api.md index e81b699..7aa9965 100644 --- a/docs/api.md +++ b/docs/api.md @@ -9,6 +9,7 @@ - [[dest]](#dest) - [Help](#help) - [Version](#version) + - [[upgrade]](#upgrade) - [[name]](#name) - [[platform]](#platform) - [[arch]](#arch) @@ -92,9 +93,13 @@ See [PR #744 - Support packaging nativefier applications into Squirrel-based ins ## Command Line ```bash -nativefier [options] [dest] +nativefier [options] [targetUrl] [dest] ``` +You must provide: +- Either a `targetUrl` to generate a new app from it. +- Or option `--upgrade ` to upgrade an existing app. + Command line options are listed below. #### Target Url @@ -121,6 +126,22 @@ Prints the usage information. Prints the version of your `nativefier` install. +#### [upgrade] + +``` +--upgrade +``` + +*NEW IN 43.1.0* + +This option will attempt to extract all existing options from the old app, and upgrade it using the current Nativefier CLI. + +**IMPORTANT NOTE** + +**This action is an in-place upgrade, and will REPLACE the current application. In case this feature does not work as intended or as the user may wish, it is advised to make a backup of the app to be upgraded before using, or specify an alternate directory as you would when creating a new file.** + +The provided path must be the "executable" of an application packaged with a previous version of Nativefier, and to be upgraded to the latest version of Nativefier. "Executable" means: the `.exe` file on Windows, the executable on Linux, or the `.app` on macOS. The executable must be living in the original context where it was generated (i.e., on Windows and Linux, the exe file must still be in the folder containing the generated `resources` directory). + #### [name] ``` diff --git a/src/build/buildNativefierApp.ts b/src/build/buildNativefierApp.ts index fe863cc..a531d58 100644 --- a/src/build/buildNativefierApp.ts +++ b/src/build/buildNativefierApp.ts @@ -12,7 +12,8 @@ import { isWindows, isWindowsAdmin, } from '../helpers/helpers'; -import { AppOptions, NativefierOptions } from '../options/model'; +import { useOldAppOptions, findUpgradeApp } from '../helpers/upgrade/upgrade'; +import { AppOptions } from '../options/model'; import { getOptions } from '../options/optionsMain'; import { prepareElectronApp } from './prepareElectronApp'; @@ -25,28 +26,6 @@ const OPTIONS_REQUIRING_WINDOWS_FOR_WINDOWS_BUILD = [ 'win32metadata', ]; -/** - * Checks the app path array to determine if packaging completed successfully - */ -function getAppPath(appPath: string | string[]): string { - if (!Array.isArray(appPath)) { - return appPath; - } - - if (appPath.length === 0) { - return null; // 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]; -} - /** * 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 @@ -88,6 +67,36 @@ async function copyIconsIfNecessary( await copyFileOrDir(options.packager.icon, destIconPath); } +/** + * Checks the app path array to determine if packaging completed successfully + */ +function getAppPath(appPath: string | string[]): string { + if (!Array.isArray(appPath)) { + return appPath; + } + + if (appPath.length === 0) { + return null; // 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) { + return ( + rawOptions.upgrade !== undefined && + (rawOptions.upgrade === true || + (typeof rawOptions.upgrade === 'string' && rawOptions.upgrade !== '')) + ); +} + function trimUnprocessableOptions(options: AppOptions): void { if ( options.packager.platform === 'win32' && @@ -116,11 +125,28 @@ function trimUnprocessableOptions(options: AppOptions): void { } // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -export async function buildNativefierApp( - rawOptions: NativefierOptions, -): Promise { - log.info('Processing options...'); +export async function buildNativefierApp(rawOptions): Promise { + log.info('\nProcessing options...'); + + if (isUpgrade(rawOptions)) { + log.debug('Attempting to upgrade from', rawOptions.upgrade); + const oldApp = findUpgradeApp(rawOptions.upgrade.toString()); + if (oldApp === null) { + throw new Error( + `Could not find an old Nativfier app in "${ + rawOptions.upgrade as string + }"`, + ); + } + rawOptions = useOldAppOptions(rawOptions, oldApp); + if (rawOptions.out === undefined && rawOptions.overwrite) { + rawOptions.out = path.dirname(rawOptions.upgrade); + } + } + 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. diff --git a/src/build/prepareElectronApp.ts b/src/build/prepareElectronApp.ts index 4ebad6f..5105322 100644 --- a/src/build/prepareElectronApp.ts +++ b/src/build/prepareElectronApp.ts @@ -17,58 +17,76 @@ function pickElectronAppArgs(options: AppOptions): any { return { accessibilityPrompt: options.nativefier.accessibilityPrompt, alwaysOnTop: options.nativefier.alwaysOnTop, + appBundleId: options.packager.appBundleId, + appCategoryType: options.packager.appCategoryType, appCopyright: options.packager.appCopyright, appVersion: options.packager.appVersion, + arch: options.packager.arch, + asar: options.packager.asar, backgroundColor: options.nativefier.backgroundColor, basicAuthPassword: options.nativefier.basicAuthPassword, basicAuthUsername: options.nativefier.basicAuthUsername, + blockExternalUrls: options.nativefier.blockExternalUrls, bounce: options.nativefier.bounce, browserwindowOptions: options.nativefier.browserwindowOptions, + buildDate: new Date().getTime(), buildVersion: options.packager.buildVersion, clearCache: options.nativefier.clearCache, counter: options.nativefier.counter, crashReporter: options.nativefier.crashReporter, darwinDarkModeSupport: options.packager.darwinDarkModeSupport, + derefSymlinks: options.packager.derefSymlinks, disableContextMenu: options.nativefier.disableContextMenu, disableDevTools: options.nativefier.disableDevTools, disableGpu: options.nativefier.disableGpu, + disableOldBuildWarning: options.nativefier.disableOldBuildWarning, diskCacheSize: options.nativefier.diskCacheSize, + download: options.packager.download, + electronVersionUsed: options.packager.electronVersion, enableEs3Apis: options.nativefier.enableEs3Apis, + executableName: options.packager.executableName, fastQuit: options.nativefier.fastQuit, fileDownloadOptions: options.nativefier.fileDownloadOptions, flashPluginDir: options.nativefier.flashPluginDir, fullScreen: options.nativefier.fullScreen, globalShortcuts: options.nativefier.globalShortcuts, height: options.nativefier.height, + helperBundleId: options.packager.helperBundleId, hideWindowFrame: options.nativefier.hideWindowFrame, ignoreCertificate: options.nativefier.ignoreCertificate, ignoreGpuBlacklist: options.nativefier.ignoreGpuBlacklist, insecure: options.nativefier.insecure, internalUrls: options.nativefier.internalUrls, - blockExternalUrls: options.nativefier.blockExternalUrls, - maxHeight: options.nativefier.maxHeight, + isUpgrade: options.packager.upgrade, + junk: options.packager.junk, maximize: options.nativefier.maximize, + maxHeight: options.nativefier.maxHeight, maxWidth: options.nativefier.maxWidth, minHeight: options.nativefier.minHeight, minWidth: options.nativefier.minWidth, name: options.packager.name, nativefierVersion: options.nativefier.nativefierVersion, + osxNotarize: options.packager.osxNotarize, + osxSign: options.packager.osxSign, processEnvs: options.nativefier.processEnvs, + protocols: options.packager.protocols, proxyRules: options.nativefier.proxyRules, + prune: options.packager.prune, + quiet: options.packager.quiet, showMenuBar: options.nativefier.showMenuBar, singleInstance: options.nativefier.singleInstance, targetUrl: options.packager.targetUrl, titleBarStyle: options.nativefier.titleBarStyle, tray: options.nativefier.tray, + usageDescription: options.packager.usageDescription, userAgent: options.nativefier.userAgent, + userAgentOverriden: options.nativefier.userAgentOverriden, versionString: options.nativefier.versionString, width: options.nativefier.width, win32metadata: options.packager.win32metadata, - disableOldBuildWarning: options.nativefier.disableOldBuildWarning, x: options.nativefier.x, y: options.nativefier.y, zoom: options.nativefier.zoom, - buildDate: new Date().getTime(), // OLD_BUILD_WARNING_TEXT is an undocumented env. var to let *packagers* // tweak the message shown on warning about an old build, to something // more tailored to their audience (who might not even know Nativefier). diff --git a/src/cli.ts b/src/cli.ts index 1dcf600..273149b 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -9,6 +9,7 @@ import * as log from 'loglevel'; import { isArgFormatInvalid } from './helpers/helpers'; import { supportedArchs, supportedPlatforms } from './infer/inferOs'; import { buildNativefierApp } from './main'; +import { NativefierOptions } from './options/model'; import { parseBooleanOrString, parseJson } from './utils/parseUtils'; // package.json is `require`d to let tsc strip the `src` folder by determining @@ -67,12 +68,16 @@ if (require.main === module) { const args = commander .name('nativefier') .version(packageJson.version, '-v, --version') - .arguments(' [dest]') + .arguments('[targetUrl] [dest]') .action((url, outputDirectory) => { positionalOptions.targetUrl = url; positionalOptions.out = outputDirectory; }) .option('-n, --name ', 'app name') + .option( + '--upgrade ', + 'Upgrade an app built by an older Nativefier. You must pass the full path to the existing app executable (app will be overwritten with upgraded version by default)', + ) .addOption( new commander.Option('-p, --platform ').choices( supportedPlatforms, @@ -291,7 +296,18 @@ if (require.main === module) { commander.help(); } checkInternet(); - const options = { ...positionalOptions, ...commander.opts() }; + const options: NativefierOptions = { + ...positionalOptions, + ...commander.opts(), + }; + + if (!options.targetUrl && !options.upgrade) { + console.error( + 'Nativefier must be called with either a targetUrl or the --upgrade option.', + ); + commander.help(); + } + buildNativefierApp(options).catch((error) => { log.error('Error during build. Run with --verbose for details.', error); }); diff --git a/src/helpers/fsHelpers.ts b/src/helpers/fsHelpers.ts new file mode 100644 index 0000000..eb9397c --- /dev/null +++ b/src/helpers/fsHelpers.ts @@ -0,0 +1,19 @@ +import * as fs from 'fs'; + +export function dirExists(dirName: string): boolean { + try { + const dirStat = fs.statSync(dirName); + return dirStat.isDirectory(); + } catch { + return false; + } +} + +export function fileExists(fileName: string): boolean { + try { + const fileStat = fs.statSync(fileName); + return fileStat.isFile(); + } catch { + return false; + } +} diff --git a/src/helpers/upgrade/executableHelpers.ts b/src/helpers/upgrade/executableHelpers.ts new file mode 100644 index 0000000..eaabb5f --- /dev/null +++ b/src/helpers/upgrade/executableHelpers.ts @@ -0,0 +1,186 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import * as log from 'loglevel'; + +import { NativefierOptions } from '../../options/model'; +import { getVersionString } from './rceditGet'; +import { fileExists } from '../fsHelpers'; +type ExecutableInfo = { + arch?: string; +}; + +function getExecutableBytes(executablePath: string): Uint8Array { + return fs.readFileSync(executablePath); +} + +function getExecutableArch( + exeBytes: Uint8Array, + platform: string, +): string | undefined { + switch (platform) { + case 'linux': + // https://en.wikipedia.org/wiki/Executable_and_Linkable_Format#File_header + switch (exeBytes[0x12]) { + case 0x03: + return 'ia32'; + case 0x28: + return 'armv7l'; + case 0x3e: + return 'x64'; + case 0xb7: + return 'arm64'; + default: + return undefined; + } + case 'darwin': + case 'mas': + // https://opensource.apple.com/source/xnu/xnu-2050.18.24/EXTERNAL_HEADERS/mach-o/loader.h + + switch ((exeBytes[0x04] << 8) + exeBytes[0x05]) { + case 0x0700: + return 'x64'; + case 0x0c00: + return 'arm64'; + default: + return undefined; + } + case 'windows': + // https://en.wikibooks.org/wiki/X86_Disassembly/Windows_Executable_Files#COFF_Header + switch ((exeBytes[0x7d] << 8) + exeBytes[0x7c]) { + case 0x014c: + return 'ia32'; + case 0x8664: + return 'x64'; + case 0xaa64: + return 'arm64'; + default: + return undefined; + } + default: + return undefined; + } +} + +function getExecutableInfo( + executablePath: string, + platform: string, +): ExecutableInfo { + if (!fileExists(executablePath)) { + return {}; + } + + const exeBytes = getExecutableBytes(executablePath); + return { + arch: getExecutableArch(exeBytes, platform), + }; +} + +export function getOptionsFromExecutable( + appResourcesDir: string, + priorOptions: NativefierOptions, +): NativefierOptions { + const newOptions: NativefierOptions = { ...priorOptions }; + let executablePath: string | undefined = undefined; + + const appRoot = path.resolve(path.join(appResourcesDir, '..', '..')); + const children = fs.readdirSync(appRoot, { withFileTypes: true }); + const looksLikeMacOS = + children.filter((c) => c.name === 'MacOS' && c.isDirectory()).length > 0; + const looksLikeWindows = + children.filter((c) => c.name.toLowerCase().endsWith('.exe') && c.isFile()) + .length > 0; + const looksLikeLinux = + children.filter((c) => c.name.toLowerCase().endsWith('.so') && c.isFile()) + .length > 0; + + if (looksLikeMacOS) { + log.debug('This looks like a MacOS app...'); + if (newOptions.platform === undefined) { + newOptions.platform = + children.filter((c) => c.name === 'Library' && c.isDirectory()).length > + 0 + ? 'mas' + : 'darwin'; + } + executablePath = path.join( + appRoot, + 'MacOS', + fs.readdirSync(path.join(appRoot, 'MacOS'))[0], + ); + } else if (looksLikeWindows) { + log.debug('This looks like a Windows app...'); + + if (newOptions.platform === undefined) { + newOptions.platform = 'windows'; + } + executablePath = path.join( + appRoot, + children.filter( + (c) => + c.name.toLowerCase() === `${newOptions.name.toLowerCase()}.exe` && + c.isFile(), + )[0].name, + ); + + if (newOptions.appVersion === undefined) { + // https://github.com/electron/electron-packager/blob/f1c159f4c844d807968078ea504fba40ca7d9c73/src/win32.js#L46-L48 + newOptions.appVersion = getVersionString( + executablePath, + 'ProductVersion', + ); + log.debug( + `Extracted app version from executable: ${newOptions.appVersion}`, + ); + } + + if (newOptions.buildVersion === undefined) { + //https://github.com/electron/electron-packager/blob/f1c159f4c844d807968078ea504fba40ca7d9c73/src/win32.js#L50-L52 + newOptions.buildVersion = getVersionString(executablePath, 'FileVersion'); + + if (newOptions.appVersion == newOptions.buildVersion) { + newOptions.buildVersion = undefined; + } else { + log.debug( + `Extracted build version from executable: ${newOptions.buildVersion}`, + ); + } + } + + if (newOptions.appCopyright === undefined) { + // https://github.com/electron/electron-packager/blob/f1c159f4c844d807968078ea504fba40ca7d9c73/src/win32.js#L54-L56 + newOptions.appCopyright = getVersionString( + executablePath, + 'LegalCopyright', + ); + log.debug( + `Extracted app copyright from executable: ${newOptions.appCopyright}`, + ); + } + } else if (looksLikeLinux) { + log.debug('This looks like a Linux app...'); + if (newOptions.platform === undefined) { + newOptions.platform = 'linux'; + } + executablePath = path.join( + appRoot, + children.filter((c) => c.name == newOptions.name && c.isFile())[0].name, + ); + } + + log.debug(`Executable path: ${executablePath}`); + + if (newOptions.arch === undefined) { + const executableInfo = getExecutableInfo( + executablePath, + newOptions.platform, + ); + newOptions.arch = executableInfo.arch; + log.debug(`Extracted arch from executable: ${newOptions.arch}`); + } + if (newOptions.platform === undefined || newOptions.arch == undefined) { + throw Error(`Could not determine platform / arch of app in ${appRoot}`); + } + + return newOptions; +} diff --git a/src/helpers/upgrade/plistInfoXMLHelpers.ts b/src/helpers/upgrade/plistInfoXMLHelpers.ts new file mode 100644 index 0000000..40af710 --- /dev/null +++ b/src/helpers/upgrade/plistInfoXMLHelpers.ts @@ -0,0 +1,39 @@ +export function extractBoolean( + infoPlistXML: string, + plistKey: string, +): boolean | undefined { + const plistValue = extractRaw(infoPlistXML, plistKey); + + return plistValue === undefined + ? undefined + : plistValue.split('<')[1].split('/>')[0].toLowerCase() === 'true'; +} + +export function extractString( + infoPlistXML: string, + plistKey: string, +): string | undefined { + const plistValue = extractRaw(infoPlistXML, plistKey); + + return plistValue === undefined + ? undefined + : plistValue.split('')[1].split('')[0]; +} + +function extractRaw( + infoPlistXML: string, + plistKey: string, +): string | undefined { + // This would be easier with xml2js, but let's not add a dependency for something this minor. + const fullKey = `\n ${plistKey}`; + + if (infoPlistXML.indexOf(fullKey) === -1) { + // This value wasn't set, so we'll stay agnostic to it + return undefined; + } + + return infoPlistXML + .split(fullKey)[1] + .split('\n ')[0] // Get everything between here and the end of the main plist dict + .split('\n ')[0]; // Get everything before the next key (if it exists) +} diff --git a/src/helpers/upgrade/rceditGet.ts b/src/helpers/upgrade/rceditGet.ts new file mode 100644 index 0000000..3d2b79f --- /dev/null +++ b/src/helpers/upgrade/rceditGet.ts @@ -0,0 +1,42 @@ +import * as os from 'os'; +import * as path from 'path'; +import { spawnSync } from 'child_process'; + +// A modification of https://github.com/electron/node-rcedit to support the retrieval +// of information. + +export function getVersionString( + executablePath: string, + versionString: string, +): string { + let rcedit = path.resolve( + __dirname, + '..', + '..', + '..', + 'node_modules', + 'rcedit', + 'bin', + process.arch === 'x64' ? 'rcedit-x64.exe' : 'rcedit.exe', + ); + const args = [executablePath, `--get-version-string`, versionString]; + + const spawnOptions = { + env: { ...process.env }, + }; + + // Use Wine on non-Windows platforms except for WSL, which doesn't need it + if (process.platform !== 'win32' && !os.release().endsWith('Microsoft')) { + args.unshift(rcedit); + rcedit = process.arch === 'x64' ? 'wine64' : 'wine'; + // Suppress "fixme:" stderr log messages + spawnOptions.env.WINEDEBUG = '-all'; + } + try { + const child = spawnSync(rcedit, args, spawnOptions); + const result = child.output?.toString().split(',wine: ')[0]; + return result.startsWith(',') ? result.substr(1) : result; + } catch { + return undefined; + } +} diff --git a/src/helpers/upgrade/upgrade.ts b/src/helpers/upgrade/upgrade.ts new file mode 100644 index 0000000..21d43b6 --- /dev/null +++ b/src/helpers/upgrade/upgrade.ts @@ -0,0 +1,199 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import * as log from 'loglevel'; + +import { NativefierOptions } from '../../options/model'; +import { dirExists, fileExists } from '../fsHelpers'; +import { extractBoolean, extractString } from './plistInfoXMLHelpers'; +import { getOptionsFromExecutable } from './executableHelpers'; + +export type UpgradeAppInfo = { + appResourcesDir: string; + options: NativefierOptions; +}; + +function findUpgradeAppResourcesDir(searchDir: string): string | null { + searchDir = dirExists(searchDir) ? searchDir : path.dirname(searchDir); + log.debug(`Searching for nativfier.json in ${searchDir}`); + const children = fs.readdirSync(searchDir, { withFileTypes: true }); + if (fileExists(path.join(searchDir, 'nativefier.json'))) { + // Found 'nativefier.json', so this must be it! + return path.resolve(searchDir); + } + const childDirectories = children.filter((c) => c.isDirectory()); + for (const childDir of childDirectories) { + // We must go deeper! + const result = findUpgradeAppResourcesDir( + path.join(searchDir, childDir.name, 'nativefier.json'), + ); + if (result !== null) { + return path.resolve(result); + } + } + + // Didn't find it down here + return null; +} + +function getIconPath(appResourcesDir: string): string | undefined { + const icnsPath = path.join(appResourcesDir, '..', 'electron.icns'); + if (fileExists(icnsPath)) { + log.debug(`Found icon at: ${icnsPath}`); + return path.resolve(icnsPath); + } + const icoPath = path.join(appResourcesDir, 'icon.ico'); + if (fileExists(icoPath)) { + log.debug(`Found icon at: ${icoPath}`); + return path.resolve(icoPath); + } + const pngPath = path.join(appResourcesDir, 'icon.png'); + if (fileExists(pngPath)) { + log.debug(`Found icon at: ${pngPath}`); + return path.resolve(pngPath); + } + + log.debug('Could not find icon file.'); + return undefined; +} + +function getInfoPListOptions( + appResourcesDir: string, + priorOptions: NativefierOptions, +): NativefierOptions { + if (!fileExists(path.join(appResourcesDir, '..', '..', 'Info.plist'))) { + // Not a darwin/mas app, so this is irrelevant + return priorOptions; + } + + const newOptions = { ...priorOptions }; + + const infoPlistXML: string = fs + .readFileSync(path.join(appResourcesDir, '..', '..', 'Info.plist')) + .toString(); + + if (newOptions.appCopyright === undefined) { + // https://github.com/electron/electron-packager/blob/0d3f84374e9ab3741b171610735ebc6be3e5e75f/src/mac.js#L230-L232 + newOptions.appCopyright = extractString( + infoPlistXML, + 'NSHumanReadableCopyright', + ); + log.debug( + `Extracted app copyright from Info.plist: ${newOptions.appCopyright}`, + ); + } + + if (newOptions.appVersion === undefined) { + // https://github.com/electron/electron-packager/blob/0d3f84374e9ab3741b171610735ebc6be3e5e75f/src/mac.js#L214-L216 + // This could also be the buildVersion, but since they end up in the same place, that SHOULDN'T matter + const bundleVersion = extractString(infoPlistXML, 'CFBundleVersion'); + newOptions.appVersion = + bundleVersion === undefined || bundleVersion === '1.0.0' // If it's 1.0.0, that's just the default + ? undefined + : bundleVersion; + (newOptions.darwinDarkModeSupport = + newOptions.darwinDarkModeSupport === undefined + ? undefined + : newOptions.darwinDarkModeSupport === false), + log.debug( + `Extracted app version from Info.plist: ${newOptions.appVersion}`, + ); + } + + if (newOptions.darwinDarkModeSupport === undefined) { + // https://github.com/electron/electron-packager/blob/0d3f84374e9ab3741b171610735ebc6be3e5e75f/src/mac.js#L234-L236 + newOptions.darwinDarkModeSupport = extractBoolean( + infoPlistXML, + 'NSRequiresAquaSystemAppearance', + ); + log.debug( + `Extracted Darwin dark mode support from Info.plist: ${ + newOptions.darwinDarkModeSupport ? 'Yes' : 'No' + }`, + ); + } + + return newOptions; +} + +function getInjectPaths(appResourcesDir: string): string[] | undefined { + const injectDir = path.join(appResourcesDir, 'inject'); + if (!dirExists(injectDir)) { + return undefined; + } + + const injectPaths = fs + .readdirSync(injectDir, { withFileTypes: true }) + .filter( + (fd) => + fd.isFile() && + (fd.name.toLowerCase().endsWith('.css') || + fd.name.toLowerCase().endsWith('.js')), + ) + .map((fd) => path.resolve(path.join(injectDir, fd.name))); + log.debug(`CSS/JS Inject paths: ${injectPaths.join(', ')}`); + return injectPaths; +} + +function isAsar(appResourcesDir: string): boolean { + const asar = fileExists(path.join(appResourcesDir, '..', 'electron.asar')); + log.debug(`Is this app an ASAR? ${asar ? 'Yes' : 'No'}`); + return asar; +} + +export function findUpgradeApp(upgradeFrom: string): UpgradeAppInfo | null { + const searchDir = dirExists(upgradeFrom) + ? upgradeFrom + : path.dirname(upgradeFrom); + log.debug(`Looking for old options file in ${searchDir}`); + const appResourcesDir = findUpgradeAppResourcesDir(searchDir); + if (appResourcesDir === null) { + log.debug(`No nativefier.json file found in ${searchDir}`); + return null; + } + + log.debug(`Loading ${path.join(appResourcesDir, 'nativefier.json')}`); + const options: NativefierOptions = JSON.parse( + fs.readFileSync(path.join(appResourcesDir, 'nativefier.json'), 'utf8'), + ); + + options.electronVersion = undefined; + + return { + appResourcesDir, + options: { + ...options, + ...getOptionsFromExecutable(appResourcesDir, options), + ...getInfoPListOptions(appResourcesDir, options), + asar: options.asar !== undefined ? options.asar : isAsar(appResourcesDir), + icon: getIconPath(appResourcesDir), + inject: getInjectPaths(appResourcesDir), + }, + }; +} + +export function useOldAppOptions( + rawOptions: NativefierOptions, + oldApp: UpgradeAppInfo, +): NativefierOptions { + if (rawOptions.targetUrl !== undefined && dirExists(rawOptions.targetUrl)) { + // You got your ouput dir in my targetUrl! + rawOptions.out = rawOptions.targetUrl; + } + + log.debug('rawOptions', rawOptions); + log.debug('oldApp', oldApp); + + if ( + oldApp.options.userAgentOverriden === undefined || + oldApp.options.userAgentOverriden === false + ) { + oldApp.options.userAgent = undefined; + } + + const combinedOptions = { ...rawOptions, ...oldApp.options }; + + log.debug('Combined options', combinedOptions); + + return combinedOptions; +} diff --git a/src/integration-test.ts b/src/integration-test.ts index 200f22d..dc63d73 100644 --- a/src/integration-test.ts +++ b/src/integration-test.ts @@ -1,10 +1,24 @@ import * as fs from 'fs'; +import * as os from 'os'; import * as path from 'path'; +import { DEFAULT_ELECTRON_VERSION } from './constants'; import { getTempDir } from './helpers/helpers'; +import { inferArch } from './infer/inferOs'; +import { inferUserAgent } from './infer/inferUserAgent'; import { buildNativefierApp } from './main'; -function checkApp(appRoot: string, inputOptions: any): void { +async function checkApp(appRoot: string, inputOptions: any): Promise { + const arch = (inputOptions.arch as string) || inferArch(); + if (inputOptions.out !== undefined) { + expect( + path.join( + inputOptions.out, + `Google-${inputOptions.platform as string}-${arch}`, + ), + ).toBe(appRoot); + } + let relativeAppFolder: string; switch (inputOptions.platform) { @@ -18,7 +32,9 @@ function checkApp(appRoot: string, inputOptions: any): void { relativeAppFolder = 'resources/app'; break; default: - throw new Error('Unknown app platform'); + throw new Error( + `Unknown app platform: ${new String(inputOptions.platform).toString()}`, + ); } const appPath = path.join(appRoot, relativeAppFolder); @@ -36,6 +52,28 @@ function checkApp(appRoot: string, inputOptions: any): void { const iconPath = path.join(appPath, iconFile); expect(fs.existsSync(iconPath)).toBe(true); expect(fs.statSync(iconPath).size).toBeGreaterThan(1000); + + // Test arch + if (inputOptions.arch !== undefined) { + expect(inputOptions.arch).toBe(nativefierConfig.arch); + } else { + expect(os.arch()).toBe(nativefierConfig.arch); + } + + // Test electron version + expect(nativefierConfig.electronVersionUsed).toBe( + inputOptions.electronVersion || DEFAULT_ELECTRON_VERSION, + ); + + // Test user agent + expect(nativefierConfig.userAgent).toBe( + inputOptions.userAgent !== undefined + ? inputOptions.userAgent + : await inferUserAgent( + inputOptions.electronVersion || DEFAULT_ELECTRON_VERSION, + inputOptions.platform, + ), + ); } describe('Nativefier', () => { @@ -52,7 +90,54 @@ describe('Nativefier', () => { platform, }; const appPath = await buildNativefierApp(options); - checkApp(appPath, options); + await checkApp(appPath, options); + }, + ); +}); + +describe('Nativefier upgrade', () => { + jest.setTimeout(300000); + + test.each([ + { platform: 'darwin', arch: 'x64' }, + { platform: 'linux', arch: 'arm64', userAgent: 'FIREFOX' }, + // Exhaustive integration testing here would be neat, but takes too long. + // -> For now, only testing a subset of platforms/archs + // { platform: 'win32', arch: 'x64' }, + // { platform: 'win32', arch: 'ia32' }, + // { platform: 'darwin', arch: 'arm64' }, + // { platform: 'linux', arch: 'x64' }, + // { platform: 'linux', arch: 'armv7l' }, + // { platform: 'linux', arch: 'ia32' }, + ])( + 'can upgrade a Nativefier app for platform/arch: %s', + async (baseAppOptions) => { + const tempDirectory = getTempDir('integtestUpgrade1'); + const options = { + targetUrl: 'https://google.com/', + out: tempDirectory, + overwrite: true, + electronVersion: '11.2.3', + ...baseAppOptions, + }; + const appPath = await buildNativefierApp(options); + await checkApp(appPath, options); + + const upgradeOptions = { + upgrade: appPath, + overwrite: true, + }; + + const upgradeAppPath = await buildNativefierApp(upgradeOptions); + options.electronVersion = DEFAULT_ELECTRON_VERSION; + options.userAgent = + baseAppOptions.userAgent !== undefined + ? baseAppOptions.userAgent + : await inferUserAgent( + DEFAULT_ELECTRON_VERSION, + baseAppOptions.platform, + ); + await checkApp(upgradeAppPath, options); }, ); }); diff --git a/src/options/model.ts b/src/options/model.ts index 67b6b4b..6380624 100644 --- a/src/options/model.ts +++ b/src/options/model.ts @@ -3,6 +3,8 @@ import * as electronPackager from 'electron-packager'; export interface ElectronPackagerOptions extends electronPackager.Options { targetUrl: string; platform: string; + upgrade: boolean; + upgradeFrom?: string; } export interface AppOptions { @@ -24,6 +26,7 @@ export interface AppOptions { disableGpu: boolean; disableOldBuildWarning: boolean; diskCacheSize: number; + electronVersionUsed?: string; enableEs3Apis: boolean; fastQuit: boolean; fileDownloadOptions: any; @@ -46,6 +49,7 @@ export interface AppOptions { titleBarStyle: string; tray: string | boolean; userAgent: string; + userAgentOverriden: boolean; verbose: boolean; versionString: string; width: number; diff --git a/src/options/optionsMain.ts b/src/options/optionsMain.ts index 0284797..12e92c2 100644 --- a/src/options/optionsMain.ts +++ b/src/options/optionsMain.ts @@ -28,7 +28,7 @@ export async function getOptions(rawOptions: any): Promise { appCopyright: rawOptions.appCopyright, appVersion: rawOptions.appVersion, arch: rawOptions.arch || inferArch(), - asar: rawOptions.conceal || false, + asar: rawOptions.asar || rawOptions.conceal || false, buildVersion: rawOptions.buildVersion, darwinDarkModeSupport: rawOptions.darwinDarkModeSupport || false, dir: PLACEHOLDER_APP_DIR, @@ -38,8 +38,13 @@ export async function getOptions(rawOptions: any): Promise { out: rawOptions.out || process.cwd(), overwrite: rawOptions.overwrite, platform: rawOptions.platform || inferPlatform(), - targetUrl: normalizeUrl(rawOptions.targetUrl), + targetUrl: + rawOptions.targetUrl === undefined + ? '' // We'll plug this in later via upgrade + : normalizeUrl(rawOptions.targetUrl), tmpdir: false, // workaround for electron-packager#375 + upgrade: rawOptions.upgrade !== undefined ? true : false, + upgradeFrom: rawOptions.upgrade, win32metadata: rawOptions.win32metadata || { ProductName: rawOptions.name, InternalName: rawOptions.name, @@ -86,6 +91,8 @@ export async function getOptions(rawOptions: any): Promise { titleBarStyle: rawOptions.titleBarStyle || null, tray: rawOptions.tray || false, userAgent: rawOptions.userAgent, + userAgentOverriden: + rawOptions.userAgent !== undefined && rawOptions.userAgent !== null, verbose: rawOptions.verbose, versionString: rawOptions.versionString, width: rawOptions.width || 1280,