From 38cdbbd9b079293675f8829305805a6ba896bfa3 Mon Sep 17 00:00:00 2001 From: undergroundwires Date: Sat, 19 Aug 2023 13:59:14 +0200 Subject: [PATCH] add checks to ensure desktop app runs nklayman/vue-cli-plugin-electron-builder/issues/1622 electron/electron/issues/21457 electron/asar/issues/249 Fix desktop applications failing with following error: ``` A JavaScript error occurred in the main process Uncaught Exception: Error [ERR_REQUIRE_ESM]: require() of ES Module /tmp/.mount_privacSXvQfc/resources/app.asar/index.js not supported. index.js is treated as an ES module file as it is a .js file whose nearest parent package.json contains "type": "module" which declares all .js files in that package scope as ES modules. Instead rename index.js to end in .cjs, change the requiring code to use dynamic import() which is available in all CommonJS modules, or change "type": "module" to "type": "commonjs" in /tmp/.mount_privacSXvQfc/resources/app.asar/package.json to treat all .js files as CommonJS (using .mjs for all ES modules instead). at f._load (node:electron/js2c/asar_bundle:2:13330) at node:electron/js2c/browser_init:2:123492 at node:electron/js2c/browser_init:2:123695 at node:electron/js2c/browser_init:2:123699 at f._load (node:electron/js2c/asar_bundle:2:13330) ``` --- .../checks.desktop-runtime-errors.yaml | 41 +++ README.md | 6 + scripts/check-desktop-runtime-errors.js | 263 ++++++++++++++++++ 3 files changed, 310 insertions(+) create mode 100644 .github/workflows/checks.desktop-runtime-errors.yaml create mode 100644 scripts/check-desktop-runtime-errors.js diff --git a/.github/workflows/checks.desktop-runtime-errors.yaml b/.github/workflows/checks.desktop-runtime-errors.yaml new file mode 100644 index 000000000..39ee40155 --- /dev/null +++ b/.github/workflows/checks.desktop-runtime-errors.yaml @@ -0,0 +1,41 @@ +name: checks.desktop-runtime-errors +# Verifies desktop builds for Electron applications across multiple OS platforms (macOS ,Ubuntu, and Windows). + +on: + push: + pull_request: + +jobs: + build-desktop: + strategy: + matrix: + os: [ macos, ubuntu, windows ] + fail-fast: false # Allows to see results from other combinations + runs-on: ${{ matrix.os }}-latest + steps: + - + name: Checkout + uses: actions/checkout@v2 + - + name: Setup node + uses: ./.github/actions/setup-node + - + name: Configure Ubuntu + if: matrix.os == 'ubuntu' + run: |- + sudo apt update + # Configure AppImage dependencies + sudo apt install -y libfuse2 + # Configure fake (virtual) display + sudo apt install -y xvfb + sudo Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & + echo "DISPLAY=:99" >> $GITHUB_ENV + - + name: Configure macOS + if: matrix.os == 'macos' + # Disable Gatekeeper as Electron app isn't signed and notarized + run: sudo spctl --master-disable + - + name: Test + run: |- + node scripts/check-desktop-runtime-errors.js diff --git a/README.md b/README.md index 2d6add5f6..7851a0022 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,12 @@ src="https://github.com/undergroundwires/privacy.sexy/workflows/build-checks/badge.svg" /> + + Status of runtime error checks for the desktop application +
diff --git a/scripts/check-desktop-runtime-errors.js b/scripts/check-desktop-runtime-errors.js new file mode 100644 index 000000000..3fbff74e1 --- /dev/null +++ b/scripts/check-desktop-runtime-errors.js @@ -0,0 +1,263 @@ +/** + * A script for automating the build, execution, and verification of an Electron distributions. + * It builds and executes the packages application for a specified duration to check for runtime errors. + * + * Usage: + * - --build: Clears the electron distribution directory and forces a rebuild of the Electron app. + */ + +import { execSync, spawn } from 'node:child_process'; +import { platform } from 'node:os'; +import fs, { access, readdir, rmdir } from 'node:fs/promises'; + +const DESKTOP_BUILD_COMMAND = 'npm run electron:build -- -p never'; +const DESKTOP_DIST_PATH = 'dist_electron'; +const NPM_MODULES_PATH = './node_modules'; +const APP_EXECUTION_DURATION_IN_SECONDS = 15; +const FORCE_REBUILD = process.argv.includes('--build'); + +async function main() { + try { + await npmInstall(); + await build(); + const currentPlatform = platform(); + const executor = PLATFORM_EXECUTORS[currentPlatform]; + if (!executor) { + throw new Error(`Unsupported OS: ${currentPlatform}`); + } + const { stderr, stdout, isCrashed } = await executor(); + log(`Execution completed, stdout:\n${stdout}`); + if(isCrashed) { + die(`Application crashed during execution.`); + } + ensureNoErrors(stderr); + log('Application ran without errors.'); + process.exit(0); + } catch (error) { + console.error(error); + die('Unexpected error'); + } +} + +const SUPPORTED_PLATFORMS = { + MAC: 'darwin', + LINUX: 'linux', + WINDOWS: 'win32' +}; + +const PLATFORM_EXECUTORS = { + [SUPPORTED_PLATFORMS.MAC]: executeDmg, + [SUPPORTED_PLATFORMS.LINUX]: executeAppImage, + [SUPPORTED_PLATFORMS.WINDOWS]: executeMsi, +}; + +function executeMsi() { + throw new Error('not yet supported'); +} + +async function isDirMissingOrEmpty(dir) { + if(!dir) { throw new Error('Missing directory'); } + if(!await exists(dir)) { + return true; + } + const contents = await readdir(dir); + + return contents.length === 0; +} + +async function npmInstall() { + if (!await isDirMissingOrEmpty(NPM_MODULES_PATH)) { + log(`"${NPM_MODULES_PATH}" exists and is not empty, skipping desktop build npm install.`); + return; + } + log(`Installing dependencies...`); + execSync('npm install', { stdio: 'inherit' }); +} + +async function build() { + if (!await isDirMissingOrEmpty(DESKTOP_DIST_PATH)) { + if(FORCE_REBUILD) { + log(`Clearing "${DESKTOP_DIST_PATH}" for a fresh build due to --build flag.`); + await rmdir(DESKTOP_DIST_PATH, { recursive: true }); + } else { + log(`"${DESKTOP_DIST_PATH}" exists and is not empty, skipping desktop build (${DESKTOP_BUILD_COMMAND}).`); + return; + } + } + log('Building the project...'); + execSync(DESKTOP_BUILD_COMMAND, { stdio: 'inherit' }); +} + +function findFileByExtension(extension) { + const files = execSync(`find ./${DESKTOP_DIST_PATH} -type f -name '*.${extension}'`) + .toString() + .trim() + .split('\n'); + + if (!files.length) { + die(`No ${extension} found in ${DESKTOP_DIST_PATH} directory.`); + } + if (files.length > 1) { + die(`Found multiple ${extension} files: ${files.join(', ')}`); + } + return files[0]; +} + +function executeAppImage() { + const appFile = findFileByExtension('AppImage'); + makeExecutable(appFile); + return execute(appFile); +} + +function makeExecutable(appFile) { + if(!appFile) { throw new Error('missing file'); } + if (isExecutable(appFile)) { + log('AppImage is already executable.'); + return; + } + log('Making it executable...'); + execSync(`chmod +x ${appFile}`); + + function isExecutable(file) { + try { + execSync(`test -x ${file}`); + return true; + } catch { + return false; + } + } +} + +async function executeDmg() { + const filePath = findFileByExtension('dmg'); + const { mountPath } = mountDmg(filePath); + const appPath = await findMacAppExecutablePath(mountPath); + + try { + return await execute(appPath); + } finally { + tryDetachMount(mountPath); + } +} + +async function findMacAppExecutablePath(mountPath) { + const appFolder = execSync(`find '${mountPath}' -maxdepth 1 -type d -name "*.app"`) + .toString() + .trim(); + const appName = appFolder.split('/').pop().replace('.app', ''); + const appPath = `${appFolder}/Contents/MacOS/${appName}`; + if(await exists(appPath)) { + log(`Application is located at ${appPath}`); + } else { + die(`Application does not exist at ${appPath}`); + } + return appPath; +} + +function mountDmg(dmgFile) { + const hdiutilOutput = execSync(`hdiutil attach '${dmgFile}'`).toString(); + const mountPathMatch = hdiutilOutput.match(/\/Volumes\/[^\n]+/); + const mountPath = mountPathMatch ? mountPathMatch[0] : null; + return { + mountPath, + }; +} + +function tryDetachMount(mountPath, retries = 3) { + while (retries-- > 0) { + try { + execSync(`hdiutil detach '${mountPath}'`); + break; + } catch (error) { + if (retries <= 0) { + console.error(`Failed to detach mount after multiple attempts: ${mountPath}`); + } else { + sleep(500); + } + } + } +} + +function execute(appFile) { + if(!appFile) { throw new Error('missing file'); }; + log(`Executing the AppImage for ${APP_EXECUTION_DURATION_IN_SECONDS} seconds to check for errors...`); + let explicitlyKilled = false; + return new Promise((resolve, reject) => { + let stderrData = ''; + let stdoutData = ''; + + const child = spawn(appFile); + + child.stderr.on('data', (data) => { + stderrData += data.toString(); + }); + + child.stdout.on('data', (data) => { + stdoutData += data.toString(); + }); + + child.on('exit', (code, signal) => { + log(`Application exited with code ${code}`); + if(explicitlyKilled) { + return; + } + resolve({ + stderr: stderrData, + stdout: stdoutData, + isCrashed: true, + }); + }); + + child.on('error', (error) => { + reject(error); + }); + + setTimeout(() => { + explicitlyKilled = true; + child.kill(); + resolve({ + stderr: stderrData, + stdout: stdoutData, + isCrashed: false, + }); + }, APP_EXECUTION_DURATION_IN_SECONDS * 1000); + }); +} + +function sleep(milliseconds) { + const date = Date.now(); + let currentDate = null; + do { + currentDate = Date.now(); + } while (currentDate - date < milliseconds); +} + +function ensureNoErrors(stderr) { + if (stderr && stderr.length > 0) { + die(`Errors detected while running the AppImage:\n${stderr}`); + } +} + +async function exists(path) { + try { + await access(path, fs.constants.F_OK); + return true; + } catch { + return false; + } +} + +function log(message) { + const separator = '======================================'; + const ansiiBold = '\x1b[1m'; + const ansiiReset = '\x1b[0m'; + console.log(`${separator}\n${ansiiBold}${message}${ansiiReset}`); +} + +function die(message) { + const separator = '======================================'; + console.error(`${separator}\n${message}\n${separator}`); + process.exit(1); +} + +await main();