Skip to content

Commit

Permalink
add checks to ensure desktop app runs
Browse files Browse the repository at this point in the history
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)
```
  • Loading branch information
undergroundwires committed Aug 19, 2023
1 parent 0d15992 commit ea25920
Show file tree
Hide file tree
Showing 3 changed files with 322 additions and 0 deletions.
51 changes: 51 additions & 0 deletions .github/workflows/checks.desktop-runtime-errors.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
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: Take screenshot
if: matrix.os == 'macos'
run: |
screencapture screenshot.jpg
- name: Upload screenshot
if: matrix.os == 'macos'
uses: actions/upload-artifact@v2
with:
name: screenshot
path: screenshot.jpg
-
name: Test
run: |-
node scripts/check-desktop-runtime-errors.js
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@
src="https://github.com/undergroundwires/privacy.sexy/workflows/build-checks/badge.svg"
/>
</a>
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/checks.desktop-runtime-errors.yaml" target="_blank" rel="noopener noreferrer">
<img
alt="Status of runtime error checks for the desktop application"
src="https://github.com/undergroundwires/privacy.sexy/workflows/checks.desktop-runtime-errors/badge.svg"
/>
</a>
<!-- Release -->
<br />
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/release.git.yaml" target="_blank" rel="noopener noreferrer">
Expand Down
265 changes: 265 additions & 0 deletions scripts/check-desktop-runtime-errors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
/**
* 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();
if(stdout) {
log(`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();

0 comments on commit ea25920

Please sign in to comment.