From d6730f7022b3cd5ef58a1b9740548e1727c577ea Mon Sep 17 00:00:00 2001 From: Adam Weeden Date: Fri, 21 May 2021 23:41:13 -0400 Subject: [PATCH] Improve user agent handling/provide user agent "short" codes (#1198) --- API.md | 75 +++++++++--------- CATALOG.md | 4 +- app/package.json | 2 +- app/src/helpers/helpers.test.ts | 22 +++++- app/src/helpers/helpers.ts | 15 ++++ app/src/main.ts | 10 ++- src/build/prepareElectronApp.ts | 3 +- src/cli.ts | 3 +- src/constants.ts | 14 +++- src/helpers/upgrade/upgrade.ts | 7 -- src/infer/browsers/inferChromeVersion.ts | 58 ++++++++++++++ src/infer/browsers/inferFirefoxVersion.ts | 49 ++++++++++++ src/infer/browsers/inferSafariVersion.ts | 74 ++++++++++++++++++ src/infer/inferUserAgent.test.ts | 30 -------- src/infer/inferUserAgent.ts | 94 ----------------------- src/integration-test.ts | 56 ++++++++++---- src/options/fields/fields.test.ts | 21 ++++- src/options/fields/userAgent.test.ts | 86 ++++++++++++++++++--- src/options/fields/userAgent.ts | 72 +++++++++++++++-- src/options/model.ts | 3 +- src/options/optionsMain.test.ts | 21 ++++- src/options/optionsMain.ts | 35 +++++---- 22 files changed, 526 insertions(+), 228 deletions(-) create mode 100644 src/infer/browsers/inferChromeVersion.ts create mode 100644 src/infer/browsers/inferFirefoxVersion.ts create mode 100644 src/infer/browsers/inferSafariVersion.ts delete mode 100644 src/infer/inferUserAgent.test.ts delete mode 100644 src/infer/inferUserAgent.ts diff --git a/API.md b/API.md index 4aff720..ac2aba9 100644 --- a/API.md +++ b/API.md @@ -52,10 +52,13 @@ - [[zoom]](#zoom) - [Internal Browser Options](#internal-browser-options) - [[file-download-options]](#file-download-options) - - [[honest]](#honest) - [[inject]](#inject) - [[lang]](#lang) - [[user-agent]](#user-agent) + - [[user-agent-honest]](#user-agent-honest) + - [Internal Browser Cache Options](#internal-browser-cache-options) + - [[clear-cache]](#clear-cache) + - [[disk-cache-size]](#disk-cache-size) - [URL Handling Options](#url-handling-options) - [[block-external-urls]](#block-external-urls) - [[internal-urls]](#internal-urls) @@ -66,9 +69,6 @@ - [[disable-gpu]](#disable-gpu) - [[enable-es3-apis]](#enable-es3-apis) - [[ignore-gpu-blacklist]](#ignore-gpu-blacklist) - - [Caching Options](#caching-options) - - [[clear-cache]](#clear-cache) - - [[disk-cache-size]](#disk-cache-size) - [(In)Security Options](#in-security-options) - [[disable-old-build-warning-yesiknowitisinsecure]](#disable-old-build-warning-yesiknowitisinsecure) - [[ignore-certificate]](#ignore-certificate) @@ -672,16 +672,6 @@ Example `shortcuts.json` for `https://deezer.com` & `https://soundcloud.com` to On MacOS 10.14+, if you have set a global shortcut that includes a Media key, the user will need to be prompted for permissions to enable these keys in System Preferences > Security & Privacy > Accessibility. -#### [honest] - -``` ---honest -``` - -By default, Nativefier uses a preset user agent string for your OS and masquerades as a regular Google Chrome browser, so that sites like WhatsApp Web will not say that the current browser is unsupported. - -If this flag is passed, it will not override the user agent. - #### [inject] ``` @@ -712,7 +702,41 @@ Set the language or locale to render the web site as (e.g., "fr", "en-US", "es", -u, --user-agent ``` -Set the user agent to run the created app with. +Set the user agent to run the created app with. Use `--user-agent-honest` to use the true Electron user agent. + +The following short codes are also supported to generate a user agent: `edge`, `firefox`, `safari`. + +- `edge` will generate a Microsoft Edge user agent matching the Chrome version of Electron being used +- `firefox` will generate a Mozilla Firefox user agent matching the latest stable release of that browser +- `safari` will generate an Apple Safari user agent matching the latest stable release of that browser + +#### [user-agent-honest] + +``` +--user-agent-honest, --honest +``` + +By default, Nativefier uses a preset user agent string for your OS and masquerades as a regular Google Chrome browser, so that for some sites, it will not say that the current browser is unsupported. + +If this flag is passed, it will not override the user agent, and use Electron's default generated one for your app. + +### Internal Browser Cache Options + +#### [clear-cache] + +``` +--clear-cache +``` + +Prevents the application from preserving cache between launches. + +#### [disk-cache-size] + +``` +--disk-cache-size +``` + +Forces the maximum disk space to be used by the disk cache. Value is given in bytes. ### URL Handling Options @@ -818,27 +842,6 @@ Passes the enable-es3-apis flag to the Chrome engine, to force the activation of Passes the ignore-gpu-blacklist flag to the Chrome engine, to allow for WebGl apps to work on non supported graphics cards. - - - -### Caching Options - -#### [clear-cache] - -``` ---clear-cache -``` - -Prevents the application from preserving cache between launches. - -#### [disk-cache-size] - -``` ---disk-cache-size -``` - -Forces the maximum disk space to be used by the disk cache. Value is given in bytes. - ### (In)Security Options #### [ignore-certificate] diff --git a/CATALOG.md b/CATALOG.md index c921792..cfe8f11 100644 --- a/CATALOG.md +++ b/CATALOG.md @@ -15,7 +15,7 @@ Below you'll find a list of build commands contributed by the Nativefier communi ```sh nativefier 'https://docs.google.com/spreadsheets' \ - --user-agent 'user agent of current stable Firefox' + --user-agent firefox ``` Note: lying about the User Agent is required, else Google will notice your "Chrome" isn't a real Chrome, and will refuse access. @@ -58,7 +58,7 @@ Note: as for Udemy, `--widevine` + [app signing](https://github.com/nativefier/n ```sh nativefier 'https://open.spotify.com/' --widevine - -u 'useragent of a non-Chrome browser, e.g. the current stable Firefox' + --user-agent firefox --inject spotify.js --inject spotify.css ``` diff --git a/app/package.json b/app/package.json index 1f33f42..fb4a08e 100644 --- a/app/package.json +++ b/app/package.json @@ -20,6 +20,6 @@ "source-map-support": "^0.5.19" }, "devDependencies": { - "electron": "^12.0.1" + "electron": "^12.0.7" } } diff --git a/app/src/helpers/helpers.test.ts b/app/src/helpers/helpers.test.ts index 6821128..8b59a8a 100644 --- a/app/src/helpers/helpers.test.ts +++ b/app/src/helpers/helpers.test.ts @@ -1,4 +1,8 @@ -import { linkIsInternal, getCounterValue } from './helpers'; +import { + linkIsInternal, + getCounterValue, + removeUserAgentSpecifics, +} from './helpers'; const internalUrl = 'https://medium.com/'; const internalUrlWww = 'https://www.medium.com/'; @@ -146,3 +150,19 @@ test('getCounterValue should return a string for small counter numbers in the ti test('getCounterValue should return a string for large counter numbers in the title', () => { expect(getCounterValue(largeCounterTitle)).toEqual('8,756'); }); + +describe('removeUserAgentSpecifics', () => { + test('removes Electron and App specific info', () => { + const userAgentFallback = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) app-nativefier-804458/1.0.0 Chrome/89.0.4389.128 Electron/12.0.7 Safari/537.36'; + expect( + removeUserAgentSpecifics( + userAgentFallback, + 'app-nativefier-804458', + '1.0.0', + ), + ).not.toBe( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.128 Safari/537.36', + ); + }); +}); diff --git a/app/src/helpers/helpers.ts b/app/src/helpers/helpers.ts index 027a085..09bcef3 100644 --- a/app/src/helpers/helpers.ts +++ b/app/src/helpers/helpers.ts @@ -160,3 +160,18 @@ export function getCounterValue(title: string): string { const match = itemCountRegex.exec(title); return match ? match[1] : undefined; } + +export function removeUserAgentSpecifics( + userAgentFallback: string, + appName: string, + appVersion: string, +): string { + // Electron userAgentFallback is the user agent used if none is specified when creating a window. + // For our purposes, it's useful because its format is similar enough to a real Chrome's user agent to not need + // to infer the userAgent. userAgentFallback normally looks like this: + // Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) app-nativefier-804458/1.0.0 Chrome/89.0.4389.128 Electron/12.0.7 Safari/537.36 + // We just need to strip out the appName/1.0.0 and Electron/electronVersion + return userAgentFallback + .replace(`Electron/${process.versions.electron} `, '') + .replace(`${appName}/${appVersion} `, ' '); +} diff --git a/app/src/main.ts b/app/src/main.ts index 6fe337e..f55c2b1 100644 --- a/app/src/main.ts +++ b/app/src/main.ts @@ -21,7 +21,7 @@ import { APP_ARGS_FILE_PATH, } from './components/mainWindow'; import { createTrayIcon } from './components/trayIcon'; -import { isOSX } from './helpers/helpers'; +import { isOSX, removeUserAgentSpecifics } from './helpers/helpers'; import { inferFlashPath } from './helpers/inferFlash'; // Entrypoint for Squirrel, a windows update framework. See https://github.com/nativefier/nativefier/pull/744 @@ -46,6 +46,14 @@ if (appArgs.portable) { app.setPath('userData', path.join(__dirname, '..', 'appData')); } +if (!appArgs.userAgentHonest) { + app.userAgentFallback = removeUserAgentSpecifics( + app.userAgentFallback, + app.getName(), + app.getVersion(), + ); +} + // Take in a URL on the command line as an override if (process.argv.length > 1) { const maybeUrl = process.argv[1]; diff --git a/src/build/prepareElectronApp.ts b/src/build/prepareElectronApp.ts index 3e5d326..eda7274 100644 --- a/src/build/prepareElectronApp.ts +++ b/src/build/prepareElectronApp.ts @@ -53,7 +53,6 @@ function pickElectronAppArgs(options: AppOptions): any { height: options.nativefier.height, helperBundleId: options.packager.helperBundleId, hideWindowFrame: options.nativefier.hideWindowFrame, - honest: options.nativefier.honest, ignoreCertificate: options.nativefier.ignoreCertificate, ignoreGpuBlacklist: options.nativefier.ignoreGpuBlacklist, insecure: options.nativefier.insecure, @@ -83,7 +82,7 @@ function pickElectronAppArgs(options: AppOptions): any { tray: options.nativefier.tray, usageDescription: options.packager.usageDescription, userAgent: options.nativefier.userAgent, - userAgentOverriden: options.nativefier.userAgentOverriden, + userAgentHonest: options.nativefier.userAgentHonest, versionString: options.nativefier.versionString, width: options.nativefier.width, widevine: options.nativefier.widevine, diff --git a/src/cli.ts b/src/cli.ts index 3c03c21..9245bc5 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -300,7 +300,8 @@ export function initArgs(argv: string[]): yargs.Argv { }) .option('u', { alias: 'user-agent', - description: "set the app's user agent string", + description: + "set the app's user agent string; may also use 'edge', 'firefox', or 'safari' to have one auto-generated", type: 'string', }) .option('user-agent-honest', { diff --git a/src/constants.ts b/src/constants.ts index 05ec6ba..fe72af4 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -2,10 +2,22 @@ import * as path from 'path'; export const DEFAULT_APP_NAME = 'APP'; -// Update both together, and update app / package.json / devDeps / electron +// Update both DEFAULT_ELECTRON_VERSION and DEFAULT_CHROME_VERSION together, +// and update app / package.json / devDeps / electron to value of DEFAULT_ELECTRON_VERSION export const DEFAULT_ELECTRON_VERSION = '12.0.7'; export const DEFAULT_CHROME_VERSION = '89.0.4389.128'; +// Update each of these periodically +// https://product-details.mozilla.org/1.0/firefox_versions.json +export const DEFAULT_FIREFOX_VERSION = '88.0.1'; + +// https://en.wikipedia.org/wiki/Safari_version_history +export const DEFAULT_SAFARI_VERSION = { + majorVersion: 14, + version: '14.0.3', + webkitVersion: '610.4.3.1.7', +}; + export const ELECTRON_MAJOR_VERSION = parseInt( DEFAULT_ELECTRON_VERSION.split('.')[0], 10, diff --git a/src/helpers/upgrade/upgrade.ts b/src/helpers/upgrade/upgrade.ts index 21d43b6..4802d50 100644 --- a/src/helpers/upgrade/upgrade.ts +++ b/src/helpers/upgrade/upgrade.ts @@ -184,13 +184,6 @@ export function useOldAppOptions( 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); diff --git a/src/infer/browsers/inferChromeVersion.ts b/src/infer/browsers/inferChromeVersion.ts new file mode 100644 index 0000000..7638262 --- /dev/null +++ b/src/infer/browsers/inferChromeVersion.ts @@ -0,0 +1,58 @@ +import axios from 'axios'; +import * as log from 'loglevel'; +import { + DEFAULT_CHROME_VERSION, + DEFAULT_ELECTRON_VERSION, +} from '../../constants'; + +type ElectronRelease = { + version: string; + date: string; + node: string; + v8: string; + uv: string; + zlib: string; + openssl: string; + modules: string; + chrome: string; + files: string[]; +}; + +const ELECTRON_VERSIONS_URL = 'https://atom.io/download/atom-shell/index.json'; + +export async function getChromeVersionForElectronVersion( + electronVersion: string, + url = ELECTRON_VERSIONS_URL, +): Promise { + if (!electronVersion || electronVersion === DEFAULT_ELECTRON_VERSION) { + // Exit quickly for the scenario that we already know about + return DEFAULT_CHROME_VERSION; + } + + try { + log.debug('Grabbing electron<->chrome versions file from', url); + const response = await axios.get(url, { timeout: 5000 }); + if (response.status !== 200) { + throw new Error(`Bad request: Status code ${response.status}`); + } + const electronReleases: ElectronRelease[] = response.data; + const electronVersionToChromeVersion: { [key: string]: string } = {}; + for (const release of electronReleases) { + electronVersionToChromeVersion[release.version] = release.chrome; + } + if (!(electronVersion in electronVersionToChromeVersion)) { + throw new Error( + `Electron version '${electronVersion}' not found in retrieved version list!`, + ); + } + const chromeVersion = electronVersionToChromeVersion[electronVersion]; + log.debug( + `Associated electron v${electronVersion} to chrome v${chromeVersion}`, + ); + return chromeVersion; + } catch (err) { + log.error('getChromeVersionForElectronVersion ERROR', err); + log.debug('Falling back to default Chrome version', DEFAULT_CHROME_VERSION); + return DEFAULT_CHROME_VERSION; + } +} diff --git a/src/infer/browsers/inferFirefoxVersion.ts b/src/infer/browsers/inferFirefoxVersion.ts new file mode 100644 index 0000000..ea675a7 --- /dev/null +++ b/src/infer/browsers/inferFirefoxVersion.ts @@ -0,0 +1,49 @@ +import axios from 'axios'; +import * as log from 'loglevel'; +import { DEFAULT_FIREFOX_VERSION } from '../../constants'; + +type FirefoxVersions = { + FIREFOX_AURORA: string; + FIREFOX_DEVEDITION: string; + FIREFOX_ESR: string; + FIREFOX_ESR_NEXT: string; + FIREFOX_NIGHTLY: string; + LAST_MERGE_DATE: string; + LAST_RELEASE_DATE: string; + LAST_SOFTFREEZE_DATE: string; + LATEST_FIREFOX_DEVEL_VERSION: string; + LATEST_FIREFOX_OLDER_VERSION: string; + LATEST_FIREFOX_RELEASED_DEVEL_VERSION: string; + LATEST_FIREFOX_VERSION: string; + NEXT_MERGE_DATE: string; + NEXT_RELEASE_DATE: string; + NEXT_SOFTFREEZE_DATE: string; +}; + +const FIREFOX_VERSIONS_URL = + 'https://product-details.mozilla.org/1.0/firefox_versions.json'; + +export async function getLatestFirefoxVersion( + url = FIREFOX_VERSIONS_URL, +): Promise { + try { + log.debug('Grabbing Firefox version data from', url); + const response = await axios.get(url, { timeout: 5000 }); + if (response.status !== 200) { + throw new Error(`Bad request: Status code ${response.status}`); + } + const firefoxVersions: FirefoxVersions = response.data; + + log.debug( + `Got latest Firefox version ${firefoxVersions.LATEST_FIREFOX_VERSION}`, + ); + return firefoxVersions.LATEST_FIREFOX_VERSION; + } catch (err) { + log.error('getLatestFirefoxVersion ERROR', err); + log.debug( + 'Falling back to default Firefox version', + DEFAULT_FIREFOX_VERSION, + ); + return DEFAULT_FIREFOX_VERSION; + } +} diff --git a/src/infer/browsers/inferSafariVersion.ts b/src/infer/browsers/inferSafariVersion.ts new file mode 100644 index 0000000..00ab0da --- /dev/null +++ b/src/infer/browsers/inferSafariVersion.ts @@ -0,0 +1,74 @@ +import axios from 'axios'; +import * as log from 'loglevel'; +import { DEFAULT_SAFARI_VERSION } from '../../constants'; + +export type SafariVersion = { + majorVersion: number; + version: string; + webkitVersion: string; +}; + +const SAFARI_VERSIONS_HISTORY_URL = + 'https://en.wikipedia.org/wiki/Safari_version_history'; + +export async function getLatestSafariVersion( + url = SAFARI_VERSIONS_HISTORY_URL, +): Promise { + try { + log.debug('Grabbing apple version data from', url); + const response = await axios.get(url, { timeout: 5000 }); + if (response.status !== 200) { + throw new Error(`Bad request: Status code ${response.status}`); + } + + // This would be easier with an HTML parser, but we're not going to include an extra dependency for something that dumb + const rawData: string = response.data; + + const majorVersions = [ + ...rawData.matchAll( + /class="mw-headline" id="Safari_[0-9]*">Safari ([0-9]*) match[1]); + + const majorVersion = parseInt(majorVersions[majorVersions.length - 1]); + + const majorVersionTable = rawData + .split('>Release history<')[2] + .split(' table.includes(`Safari ${majorVersion}.x`))[0]; + + const versionRows = majorVersionTable.split('\s*(([0-9]*\.){2}[0-9])\s* 0 && !version) { + version = versionMatch[0][1]; + } + + const webkitVersionMatch = [ + ...versionRow.matchAll(/>\s*(([0-9]*\.){3,4}[0-9])\s* 0 && !webkitVersion) { + webkitVersion = webkitVersionMatch[0][1]; + } + if (version && webkitVersion) { + break; + } + } + + return { + majorVersion, + version, + webkitVersion, + }; + } catch (err) { + log.error('getLatestSafariVersion ERROR', err); + log.debug('Falling back to default Safari version', DEFAULT_SAFARI_VERSION); + return DEFAULT_SAFARI_VERSION; + } +} diff --git a/src/infer/inferUserAgent.test.ts b/src/infer/inferUserAgent.test.ts deleted file mode 100644 index 9bf8ee7..0000000 --- a/src/infer/inferUserAgent.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { inferUserAgent } from './inferUserAgent'; -import { DEFAULT_ELECTRON_VERSION, DEFAULT_CHROME_VERSION } from '../constants'; - -const EXPECTED_USERAGENTS = { - darwin: `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${DEFAULT_CHROME_VERSION} Safari/537.36`, - mas: `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${DEFAULT_CHROME_VERSION} Safari/537.36`, - win32: `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${DEFAULT_CHROME_VERSION} Safari/537.36`, - linux: `Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${DEFAULT_CHROME_VERSION} Safari/537.36`, -}; - -describe('Infer User Agent', () => { - // TODO make fast by mocking timeout. - for (const [platform, archUa] of Object.entries(EXPECTED_USERAGENTS)) { - test(`Can infer userAgent for ${platform}`, async () => { - jest.setTimeout(10000); - const ua = await inferUserAgent(DEFAULT_ELECTRON_VERSION, platform); - expect(ua).toBe(archUa); - }); - } - - // TODO make fast by mocking timeout, and un-skip - test.skip('Connection error will still get a user agent', async () => { - jest.setTimeout(6000); - - const TIMEOUT_URL = 'http://www.google.com:81/'; - await expect(inferUserAgent('1.6.7', 'darwin', TIMEOUT_URL)).resolves.toBe( - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36', - ); - }); -}); diff --git a/src/infer/inferUserAgent.ts b/src/infer/inferUserAgent.ts deleted file mode 100644 index 95968a4..0000000 --- a/src/infer/inferUserAgent.ts +++ /dev/null @@ -1,94 +0,0 @@ -import axios from 'axios'; -import * as log from 'loglevel'; -import { DEFAULT_CHROME_VERSION } from '../constants'; - -const ELECTRON_VERSIONS_URL = 'https://atom.io/download/atom-shell/index.json'; - -type ElectronRelease = { - version: string; - date: string; - node: string; - v8: string; - uv: string; - zlib: string; - openssl: string; - modules: string; - chrome: string; - files: string[]; -}; - -async function getChromeVersionForElectronVersion( - electronVersion: string, - url = ELECTRON_VERSIONS_URL, -): Promise { - log.debug('Grabbing electron<->chrome versions file from', url); - const response = await axios.get(url, { timeout: 5000 }); - if (response.status !== 200) { - throw new Error(`Bad request: Status code ${response.status}`); - } - const electronReleases: ElectronRelease[] = response.data; - const electronVersionToChromeVersion: { [key: string]: string } = {}; - for (const release of electronReleases) { - electronVersionToChromeVersion[release.version] = release.chrome; - } - if (!(electronVersion in electronVersionToChromeVersion)) { - throw new Error( - `Electron version '${electronVersion}' not found in retrieved version list!`, - ); - } - const chromeVersion = electronVersionToChromeVersion[electronVersion]; - log.debug( - `Associated electron v${electronVersion} to chrome v${chromeVersion}`, - ); - return chromeVersion; -} - -export function getUserAgentString( - chromeVersion: string, - platform: string, -): string { - let userAgent: string; - switch (platform) { - case 'darwin': - case 'mas': - userAgent = `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36`; - break; - case 'win32': - userAgent = `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36`; - break; - case 'linux': - userAgent = `Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36`; - break; - default: - throw new Error( - 'Error invalid platform specified to getUserAgentString()', - ); - } - log.debug( - `Given chrome ${chromeVersion} on ${platform},`, - `using user agent: ${userAgent}`, - ); - return userAgent; -} - -export async function inferUserAgent( - electronVersion: string, - platform: string, - url = ELECTRON_VERSIONS_URL, -): Promise { - log.debug( - `Inferring user agent for electron ${electronVersion} / ${platform}`, - ); - try { - const chromeVersion = await getChromeVersionForElectronVersion( - electronVersion, - url, - ); - return getUserAgentString(chromeVersion, platform); - } catch (e) { - log.warn( - `Unable to infer chrome version for user agent, using ${DEFAULT_CHROME_VERSION}`, - ); - return getUserAgentString(DEFAULT_CHROME_VERSION, platform); - } -} diff --git a/src/integration-test.ts b/src/integration-test.ts index f876ed0..13db378 100644 --- a/src/integration-test.ts +++ b/src/integration-test.ts @@ -4,9 +4,12 @@ import * as path from 'path'; import { DEFAULT_ELECTRON_VERSION } from './constants'; import { getTempDir } from './helpers/helpers'; +import { getChromeVersionForElectronVersion } from './infer/browsers/inferChromeVersion'; +import { getLatestFirefoxVersion } from './infer/browsers/inferFirefoxVersion'; +import { getLatestSafariVersion } from './infer/browsers/inferSafariVersion'; import { inferArch } from './infer/inferOs'; -import { inferUserAgent } from './infer/inferUserAgent'; import { buildNativefierApp } from './main'; +import { userAgent } from './options/fields/userAgent'; async function checkApp(appRoot: string, inputOptions: any): Promise { const arch = (inputOptions.arch as string) || inferArch(); @@ -66,14 +69,18 @@ async function checkApp(appRoot: string, inputOptions: any): Promise { ); // Test user agent - expect(nativefierConfig.userAgent).toBe( - inputOptions.userAgent !== undefined - ? inputOptions.userAgent - : await inferUserAgent( + if (inputOptions.userAgent) { + const translatedUserAgent = await userAgent({ + packager: { + platform: inputOptions.platform, + electronVersion: inputOptions.electronVersion || DEFAULT_ELECTRON_VERSION, - inputOptions.platform, - ), - ); + }, + nativefier: { userAgent: inputOptions.userAgent }, + }); + inputOptions.userAgent = translatedUserAgent || inputOptions.userAgent; + } + expect(nativefierConfig.userAgent).toBe(inputOptions.userAgent); // Test lang expect(nativefierConfig.lang).toBe(inputOptions.lang); @@ -87,10 +94,10 @@ describe('Nativefier', () => { async (platform) => { const tempDirectory = getTempDir('integtest'); const options = { + platform, targetUrl: 'https://google.com/', out: tempDirectory, overwrite: true, - platform, lang: 'en-US', }; const appPath = await buildNativefierApp(options); @@ -104,7 +111,7 @@ describe('Nativefier upgrade', () => { test.each([ { platform: 'darwin', arch: 'x64' }, - { platform: 'linux', arch: 'arm64', userAgent: 'FIREFOX' }, + { platform: 'linux', arch: 'arm64', userAgent: 'FIREFOX 60' }, // Exhaustive integration testing here would be neat, but takes too long. // -> For now, only testing a subset of platforms/archs // { platform: 'win32', arch: 'x64' }, @@ -134,14 +141,29 @@ describe('Nativefier upgrade', () => { const upgradeAppPath = await buildNativefierApp(upgradeOptions); options.electronVersion = DEFAULT_ELECTRON_VERSION; - options.userAgent = - baseAppOptions.userAgent !== undefined - ? baseAppOptions.userAgent - : await inferUserAgent( - DEFAULT_ELECTRON_VERSION, - baseAppOptions.platform, - ); + options.userAgent = baseAppOptions.userAgent; await checkApp(upgradeAppPath, options); }, ); }); + +describe('Browser version retrieval', () => { + test('get chrome version with electron version', async () => { + await expect(getChromeVersionForElectronVersion('12.0.0')).resolves.toBe( + '89.0.4389.69', + ); + }); + + test('get latest firefox version', async () => { + const firefoxVersion = await getLatestFirefoxVersion(); + + const majorVersion = parseInt(firefoxVersion.split('.')[0]); + expect(majorVersion).toBeGreaterThanOrEqual(88); + }); + + test('get latest safari version', async () => { + const safariVersion = await getLatestSafariVersion(); + + expect(safariVersion.majorVersion).toBeGreaterThanOrEqual(14); + }); +}); diff --git a/src/options/fields/fields.test.ts b/src/options/fields/fields.test.ts index 7ac9357..1390bf1 100644 --- a/src/options/fields/fields.test.ts +++ b/src/options/fields/fields.test.ts @@ -18,7 +18,7 @@ test('fully-defined async options are returned as-is', async () => { expect(options.nativefier.userAgent).toEqual('random user agent'); }); -test('user agent is inferred if not passed', async () => { +test('user agent is ignored if not provided', async () => { const options = { packager: { icon: '/my/icon.png', @@ -32,5 +32,22 @@ test('user agent is inferred if not passed', async () => { // @ts-ignore await processOptions(options); - expect(options.nativefier.userAgent).toMatch(/Linux.*Chrome/); + expect(options.nativefier.userAgent).toBeUndefined(); +}); + +test('user agent short code is populated', async () => { + const options = { + packager: { + icon: '/my/icon.png', + name: 'my beautiful app ', + targetUrl: 'https://myurl.com', + dir: '/tmp/myapp', + platform: 'linux', + }, + nativefier: { userAgent: 'edge' }, + }; + // @ts-ignore + await processOptions(options); + + expect(options.nativefier.userAgent).not.toBe('edge'); }); diff --git a/src/options/fields/userAgent.test.ts b/src/options/fields/userAgent.test.ts index fdc8b81..e1b5028 100644 --- a/src/options/fields/userAgent.test.ts +++ b/src/options/fields/userAgent.test.ts @@ -1,26 +1,90 @@ +import { getChromeVersionForElectronVersion } from '../../infer/browsers/inferChromeVersion'; +import { getLatestFirefoxVersion } from '../../infer/browsers/inferFirefoxVersion'; +import { getLatestSafariVersion } from '../../infer/browsers/inferSafariVersion'; import { userAgent } from './userAgent'; -import { inferUserAgent } from '../../infer/inferUserAgent'; -jest.mock('./../../infer/inferUserAgent'); +jest.mock('./../../infer/browsers/inferChromeVersion'); +jest.mock('./../../infer/browsers/inferFirefoxVersion'); +jest.mock('./../../infer/browsers/inferSafariVersion'); test('when a userAgent parameter is passed', async () => { - expect(inferUserAgent).toHaveBeenCalledTimes(0); - const params = { packager: {}, nativefier: { userAgent: 'valid user agent' }, }; - await expect(userAgent(params)).resolves.toBe(null); + await expect(userAgent(params)).resolves.toBeNull(); }); test('no userAgent parameter is passed', async () => { const params = { - packager: { electronVersion: '123', platform: 'mac' }, + packager: { platform: 'mac' }, nativefier: {}, }; - await userAgent(params); - expect(inferUserAgent).toHaveBeenCalledWith( - params.packager.electronVersion, - params.packager.platform, - ); + await expect(userAgent(params)).resolves.toBeNull(); +}); + +test('edge userAgent parameter is passed', async () => { + (getChromeVersionForElectronVersion as jest.Mock).mockImplementationOnce(() => + Promise.resolve('99.0.0'), + ); + const params = { + packager: { platform: 'darwin' }, + nativefier: { userAgent: 'edge' }, + }; + + const parsedUserAgent = await userAgent(params); + + expect(parsedUserAgent).not.toBe(params.nativefier.userAgent); + expect(parsedUserAgent).toContain('Edg/99.0.0'); +}); + +test('firefox userAgent parameter is passed', async () => { + (getLatestFirefoxVersion as jest.Mock).mockImplementationOnce(() => + Promise.resolve('100.0.0'), + ); + const params = { + packager: { platform: 'win32' }, + nativefier: { userAgent: 'firefox' }, + }; + + const parsedUserAgent = await userAgent(params); + + expect(parsedUserAgent).not.toBe(params.nativefier.userAgent); + expect(parsedUserAgent).toContain('Firefox/100.0.0'); +}); + +test('safari userAgent parameter is passed', async () => { + (getLatestSafariVersion as jest.Mock).mockImplementationOnce(() => + Promise.resolve({ + majorVersion: 101, + version: '101.0.0', + webkitVersion: '600.0.0.0', + }), + ); + const params = { + packager: { platform: 'linux' }, + nativefier: { userAgent: 'safari' }, + }; + + const parsedUserAgent = await userAgent(params); + + expect(parsedUserAgent).not.toBe(params.nativefier.userAgent); + expect(parsedUserAgent).toContain('Version/101.0.0 Safari'); +}); + +test('short userAgent parameter is passed with an electronVersion', async () => { + (getChromeVersionForElectronVersion as jest.Mock).mockImplementationOnce(() => + Promise.resolve('102.0.0'), + ); + + const params = { + packager: { electronVersion: '12.0.0', platform: 'darwin' }, + nativefier: { userAgent: 'edge' }, + }; + + const parsedUserAgent = await userAgent(params); + + expect(parsedUserAgent).not.toBe(params.nativefier.userAgent); + expect(parsedUserAgent).toContain('102.0.0'); + expect(getChromeVersionForElectronVersion).toHaveBeenCalledWith('12.0.0'); }); diff --git a/src/options/fields/userAgent.ts b/src/options/fields/userAgent.ts index 1fd5f65..cb7e8ba 100644 --- a/src/options/fields/userAgent.ts +++ b/src/options/fields/userAgent.ts @@ -1,4 +1,9 @@ -import { inferUserAgent } from '../../infer/inferUserAgent'; +import * as log from 'loglevel'; + +import { getChromeVersionForElectronVersion } from '../../infer/browsers/inferChromeVersion'; +import { getLatestFirefoxVersion } from '../../infer/browsers/inferFirefoxVersion'; +import { getLatestSafariVersion } from '../../infer/browsers/inferSafariVersion'; +import { normalizePlatform } from '../optionsMain'; type UserAgentOpts = { packager: { @@ -10,13 +15,68 @@ type UserAgentOpts = { }; }; +const USER_AGENT_PLATFORM_MAPS = { + darwin: 'Macintosh; Intel Mac OS X 10_15_7', + linux: 'X11; Linux x86_64', + win32: 'Windows NT 10.0; Win64; x64', +}; + +const USER_AGENT_SHORT_CODE_MAPS = { + edge: edgeUserAgent, + firefox: firefoxUserAgent, + safari: safariUserAgent, +}; + export async function userAgent(options: UserAgentOpts): Promise { - if (options.nativefier.userAgent) { + if (!options.nativefier.userAgent) { + // No user agent got passed. Let's handle it with the app.userAgentFallback return null; } - return inferUserAgent( - options.packager.electronVersion, - options.packager.platform, - ); + if ( + !Object.keys(USER_AGENT_SHORT_CODE_MAPS).includes( + options.nativefier.userAgent.toLowerCase(), + ) + ) { + // Real user agent got passed. No need to translate it. + log.debug( + `${options.nativefier.userAgent.toLowerCase()} not found in`, + Object.keys(USER_AGENT_SHORT_CODE_MAPS), + ); + return null; + } + + options.packager.platform = normalizePlatform(options.packager.platform); + + const userAgentPlatform = + USER_AGENT_PLATFORM_MAPS[ + options.packager.platform === 'mas' ? 'darwin' : options.packager.platform + ]; + + const mapFunction = USER_AGENT_SHORT_CODE_MAPS[options.nativefier.userAgent]; + + return await mapFunction(userAgentPlatform, options.packager.electronVersion); +} + +async function edgeUserAgent( + platform: string, + electronVersion: string, +): Promise { + const chromeVersion = await getChromeVersionForElectronVersion( + electronVersion, + ); + + return `Mozilla/5.0 (${platform}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36 Edg/${chromeVersion}`; +} + +async function firefoxUserAgent(platform: string): Promise { + const firefoxVersion = await getLatestFirefoxVersion(); + + return `Mozilla/5.0 (${platform}; rv:${firefoxVersion}) Gecko/20100101 Firefox/${firefoxVersion}`; +} + +async function safariUserAgent(platform: string): Promise { + const safariVersion = await getLatestSafariVersion(); + + return `Mozilla/5.0 (${platform}) AppleWebKit/${safariVersion.webkitVersion} (KHTML, like Gecko) Version/${safariVersion.version} Safari/${safariVersion.webkitVersion}`; } diff --git a/src/options/model.ts b/src/options/model.ts index 4d784fd..da5993d 100644 --- a/src/options/model.ts +++ b/src/options/model.ts @@ -36,7 +36,6 @@ export interface AppOptions { fullScreen: boolean; globalShortcuts: any; hideWindowFrame: boolean; - honest: boolean; ignoreCertificate: boolean; ignoreGpuBlacklist: boolean; inject: string[]; @@ -52,7 +51,7 @@ export interface AppOptions { titleBarStyle: string; tray: string | boolean; userAgent: string; - userAgentOverriden: boolean; + userAgentHonest: boolean; verbose: boolean; versionString: string; width: number; diff --git a/src/options/optionsMain.test.ts b/src/options/optionsMain.test.ts index eec1d7a..3c103e1 100644 --- a/src/options/optionsMain.test.ts +++ b/src/options/optionsMain.test.ts @@ -1,5 +1,6 @@ -import { getOptions } from './optionsMain'; +import { getOptions, normalizePlatform } from './optionsMain'; import * as asyncConfig from './asyncConfig'; +import { inferPlatform } from '../infer/inferOs'; const mockedAsyncConfig = { some: 'options' }; let asyncConfigMock: jasmine.Spy; @@ -38,3 +39,21 @@ test('it should set the accessibility prompt option to true by default', async ( ); expect(result.nativefier.accessibilityPrompt).toEqual(true); }); + +test.each([ + { platform: 'darwin', expectedPlatform: 'darwin' }, + { platform: 'mAc', expectedPlatform: 'darwin' }, + { platform: 'osx', expectedPlatform: 'darwin' }, + { platform: 'liNux', expectedPlatform: 'linux' }, + { platform: 'mas', expectedPlatform: 'mas' }, + { platform: 'WIN32', expectedPlatform: 'win32' }, + { platform: 'windows', expectedPlatform: 'win32' }, + {}, +])('it should be able to normalize the platform %s', (platformOptions) => { + if (!platformOptions.expectedPlatform) { + platformOptions.expectedPlatform = inferPlatform(); + } + expect(normalizePlatform(platformOptions.platform)).toBe( + platformOptions.expectedPlatform, + ); +}); diff --git a/src/options/optionsMain.ts b/src/options/optionsMain.ts index 3533a22..87844b1 100644 --- a/src/options/optionsMain.ts +++ b/src/options/optionsMain.ts @@ -37,7 +37,7 @@ export async function getOptions(rawOptions: any): Promise { name: typeof rawOptions.name === 'string' ? rawOptions.name : '', out: rawOptions.out || process.cwd(), overwrite: rawOptions.overwrite, - platform: rawOptions.platform || inferPlatform(), + platform: rawOptions.platform, portable: rawOptions.portable || false, targetUrl: rawOptions.targetUrl === undefined @@ -78,7 +78,6 @@ export async function getOptions(rawOptions: any): Promise { fullScreen: rawOptions.fullScreen || false, globalShortcuts: null, hideWindowFrame: rawOptions.hideWindowFrame, - honest: rawOptions.honest || false, ignoreCertificate: rawOptions.ignoreCertificate || false, ignoreGpuBlacklist: rawOptions.ignoreGpuBlacklist || false, inject: rawOptions.inject || [], @@ -94,8 +93,7 @@ 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, + userAgentHonest: rawOptions.userAgentHonest || false, verbose: rawOptions.verbose, versionString: rawOptions.versionString, width: rawOptions.width || 1280, @@ -174,18 +172,14 @@ export async function getOptions(rawOptions: any): Promise { options.nativefier.insecure = true; } - if (options.nativefier.honest) { + if (options.nativefier.userAgentHonest && options.nativefier.userAgent) { options.nativefier.userAgent = null; + log.warn( + `\nATTENTION: user-agent AND user-agent-honest/honest were provided. In this case, honesty wins. user-agent will be ignored`, + ); } - const platform = options.packager.platform.toLowerCase(); - if (platform === 'windows') { - options.packager.platform = 'win32'; - } - - if (['osx', 'mac', 'macos'].includes(platform)) { - options.packager.platform = 'darwin'; - } + options.packager.platform = normalizePlatform(options.packager.platform); if (options.nativefier.width > options.nativefier.maxWidth) { options.nativefier.width = options.nativefier.maxWidth; @@ -218,3 +212,18 @@ export async function getOptions(rawOptions: any): Promise { return options; } + +export function normalizePlatform(platform: string): string { + if (!platform) { + return inferPlatform(); + } + if (platform.toLowerCase() === 'windows') { + return 'win32'; + } + + if (['osx', 'mac', 'macos'].includes(platform.toLowerCase())) { + return 'darwin'; + } + + return platform.toLowerCase(); +}