diff --git a/.eslintrc.cjs b/.eslintrc.cjs
index d90576f9..12521256 100644
--- a/.eslintrc.cjs
+++ b/.eslintrc.cjs
@@ -21,7 +21,7 @@ module.exports = {
'@vue/typescript/recommended',
],
parserOptions: {
- ecmaVersion: 12, // ECMA 2021
+ ecmaVersion: 2022, // So it allows top-level awaits
/*
Having 'latest' leads to:
```
diff --git a/.github/workflows/checks.desktop-runtime-errors.yaml b/.github/workflows/checks.desktop-runtime-errors.yaml
new file mode 100644
index 00000000..c662152b
--- /dev/null
+++ b/.github/workflows/checks.desktop-runtime-errors.yaml
@@ -0,0 +1,67 @@
+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'
+ shell: bash
+ run: |-
+ sudo apt update
+
+ # Configure AppImage dependencies
+ sudo apt install -y libfuse2
+
+ # Configure DBUS (fixes `Failed to connect to the bus: Could not parse server address: Unknown address type`)
+ if ! command -v 'dbus-launch' &> /dev/null; then
+ echo 'DBUS does not exist, installing...'
+ sudo apt install -y dbus-x11 # Gives both dbus and dbus-launch utility
+ fi
+ sudo systemctl start dbus
+ DBUS_LAUNCH_OUTPUT=$(dbus-launch)
+ if [ $? -eq 0 ]; then
+ echo "${DBUS_LAUNCH_OUTPUT}" >> $GITHUB_ENV
+ else
+ echo 'Error: dbus-launch command did not execute successfully. Exiting.' >&2
+ echo "${DBUS_LAUNCH_OUTPUT}" >&2
+ exit 1
+ fi
+
+ # Configure fake (virtual) display
+ sudo apt install -y xvfb
+ sudo Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &
+ echo "DISPLAY=:99" >> $GITHUB_ENV
+
+ # Install ImageMagick for screenshots
+ sudo apt install -y imagemagick
+
+ # Install xdotool and xprop (from x11-utils) for window title capturing
+ sudo apt install -y xdotool x11-utils
+ -
+ name: Test
+ shell: bash
+ run: node scripts/check-desktop-runtime-errors --screenshot
+ -
+ name: Upload screenshot
+ if: always() # Run even if previous step fails
+ uses: actions/upload-artifact@v3
+ with:
+ name: screenshot-${{ matrix.os }}
+ path: screenshot.png
diff --git a/README.md b/README.md
index 2d6add5f..7851a002 100644
--- a/README.md
+++ b/README.md
@@ -70,6 +70,12 @@
src="https://github.com/undergroundwires/privacy.sexy/workflows/build-checks/badge.svg"
/>
+
+
+
diff --git a/package-lock.json b/package-lock.json
index 3899c609..f0cfad23 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6,7 +6,7 @@
"packages": {
"": {
"name": "privacy.sexy",
- "version": "0.12.0",
+ "version": "0.12.1",
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.4.0",
diff --git a/scripts/check-desktop-runtime-errors/.eslintrc.cjs b/scripts/check-desktop-runtime-errors/.eslintrc.cjs
new file mode 100644
index 00000000..9c0ba7a3
--- /dev/null
+++ b/scripts/check-desktop-runtime-errors/.eslintrc.cjs
@@ -0,0 +1,11 @@
+const { rules: baseStyleRules } = require('eslint-config-airbnb-base/rules/style');
+require('@rushstack/eslint-patch/modern-module-resolution');
+
+module.exports = {
+ env: {
+ node: true,
+ },
+ rules: {
+ "import/extensions": ["error", "always"],
+ },
+};
diff --git a/scripts/check-desktop-runtime-errors/README.md b/scripts/check-desktop-runtime-errors/README.md
new file mode 100644
index 00000000..f167b448
--- /dev/null
+++ b/scripts/check-desktop-runtime-errors/README.md
@@ -0,0 +1,35 @@
+# check-desktop-runtime-errors
+
+This script automates the processes of:
+
+1) Building
+2) Packaging
+3) Installing
+4) Executing
+5) Verifying Electron distributions
+
+It runs the application for a duration and detects runtime errors in the packaged application via:
+
+- **Log verification**: Checking application logs for errors and validating successful application initialization.
+- **`stderr` monitoring**: Continuous listening to the `stderr` stream for unexpected errors.
+- **Window title inspection**: Checking for window titles that indicate crashes before logging becomes possible.
+
+Upon error, the script captures a screenshot (if `--screenshot` is provided) and terminates.
+
+## Usage
+
+```sh
+node ./scripts/check-desktop-runtime-errors
+```
+
+## Options
+
+- `--build`: Clears the electron distribution directory and forces a rebuild of the Electron app.
+- `--screenshot`: Takes a screenshot of the desktop environment after running the application.
+
+This module provides utilities for building, executing, and validating Electron desktop apps.
+It can be used to automate checking for runtime errors during development.
+
+## Configs
+
+Configurations are defined in [`config.js`](./config.js).
diff --git a/scripts/check-desktop-runtime-errors/app/app-logs.js b/scripts/check-desktop-runtime-errors/app/app-logs.js
new file mode 100644
index 00000000..c620a4fa
--- /dev/null
+++ b/scripts/check-desktop-runtime-errors/app/app-logs.js
@@ -0,0 +1,55 @@
+import { unlink, readFile } from 'fs/promises';
+import { join } from 'path';
+import { log, die, LOG_LEVELS } from '../utils/log.js';
+import { exists } from '../utils/io.js';
+import { SUPPORTED_PLATFORMS, CURRENT_PLATFORM } from '../utils/platform.js';
+import { getAppName } from '../utils/npm.js';
+
+export async function clearAppLogFile(projectDir) {
+ if (!projectDir) { throw new Error('missing project directory'); }
+ const logPath = await determineLogPath(projectDir);
+ if (!logPath || !await exists(logPath)) {
+ log(`Skipping clearing logs, log file does not exist: ${logPath}.`);
+ return;
+ }
+ try {
+ await unlink(logPath);
+ log(`Successfully cleared the log file at: ${logPath}.`);
+ } catch (error) {
+ die(`Failed to clear the log file at: ${logPath}. Reason: ${error}`);
+ }
+}
+
+export async function readAppLogFile(projectDir) {
+ if (!projectDir) { throw new Error('missing project directory'); }
+ const logPath = await determineLogPath(projectDir);
+ if (!logPath || !await exists(logPath)) {
+ log(`No log file at: ${logPath}`, LOG_LEVELS.WARN);
+ return undefined;
+ }
+ const logContent = await readLogFile(logPath);
+ return logContent;
+}
+
+async function determineLogPath(projectDir) {
+ if (!projectDir) { throw new Error('missing project directory'); }
+ const appName = await getAppName(projectDir);
+ if (!appName) {
+ die('App name not found.');
+ }
+ const logFilePaths = {
+ [SUPPORTED_PLATFORMS.MAC]: () => join(process.env.HOME, 'Library', 'Logs', appName, 'main.log'),
+ [SUPPORTED_PLATFORMS.LINUX]: () => join(process.env.HOME, '.config', appName, 'logs', 'main.log'),
+ [SUPPORTED_PLATFORMS.WINDOWS]: () => join(process.env.USERPROFILE, 'AppData', 'Roaming', appName, 'logs', 'main.log'),
+ };
+ const logFilePath = logFilePaths[CURRENT_PLATFORM]?.();
+ if (!logFilePath) {
+ log(`Cannot determine log path, unsupported OS: ${CURRENT_PLATFORM}`, LOG_LEVELS.WARN);
+ }
+ return logFilePath;
+}
+
+async function readLogFile(logFilePath) {
+ const content = await readFile(logFilePath, 'utf-8');
+ return content?.trim().length > 0 ? content : undefined;
+}
diff --git a/scripts/check-desktop-runtime-errors/app/check-for-errors.js b/scripts/check-desktop-runtime-errors/app/check-for-errors.js
new file mode 100644
index 00000000..744e0b19
--- /dev/null
+++ b/scripts/check-desktop-runtime-errors/app/check-for-errors.js
@@ -0,0 +1,114 @@
+import { splitTextIntoLines, indentText } from '../utils/text.js';
+import { die } from '../utils/log.js';
+import { readAppLogFile } from './app-logs.js';
+
+const LOG_ERROR_MARKER = '[error]'; // from electron-log
+const ELECTRON_CRASH_TITLE = 'Error'; // Used by electron for early crashes
+const APP_INITIALIZED_MARKER = '[APP_INIT_SUCCESS]'; // Logged by application on successful initialization
+
+export async function checkForErrors(stderr, windowTitles, projectDir) {
+ if (!projectDir) { throw new Error('missing project directory'); }
+ const errors = await gatherErrors(stderr, windowTitles, projectDir);
+ if (errors.length) {
+ die(formatErrors(errors));
+ }
+}
+
+async function gatherErrors(stderr, windowTitles, projectDir) {
+ if (!projectDir) { throw new Error('missing project directory'); }
+ const logContent = await readAppLogFile(projectDir);
+ return [
+ verifyStdErr(stderr),
+ verifyApplicationInitializationLog(logContent),
+ verifyWindowTitle(windowTitles),
+ verifyErrorsInLogs(logContent),
+ ].filter(Boolean);
+}
+
+function formatErrors(errors) {
+ if (!errors || !errors.length) { throw new Error('missing errors'); }
+ return [
+ 'Errors detected during execution:',
+ ...errors.map(
+ (error) => formatError(error),
+ ),
+ ].join('\n---\n');
+}
+
+function formatError(error) {
+ if (!error) { throw new Error('missing error'); }
+ if (!error.reason) { throw new Error(`missing reason, error (${typeof error}): ${JSON.stringify(error)}`); }
+ let message = `Reason: ${indentText(error.reason, 1)}`;
+ if (error.description) {
+ message += `\nDescription:\n${indentText(error.description, 2)}`;
+ }
+ return message;
+}
+
+function verifyApplicationInitializationLog(logContent) {
+ if (!logContent || !logContent.length) {
+ return describeError(
+ 'Missing application logs',
+ 'Application logs are empty not were not found.',
+ );
+ }
+ if (!logContent.includes(APP_INITIALIZED_MARKER)) {
+ return describeError(
+ 'Unexpected application logs',
+ `Missing identifier "${APP_INITIALIZED_MARKER}" in application logs.`,
+ );
+ }
+ return undefined;
+}
+
+function verifyWindowTitle(windowTitles) {
+ const errorTitles = windowTitles.filter(
+ (title) => title.toLowerCase().includes(ELECTRON_CRASH_TITLE),
+ );
+ if (errorTitles.length) {
+ return describeError(
+ 'Unexpected window title',
+ 'One or more window titles suggest an error occurred in the application:'
+ + `\nError Titles: ${errorTitles.join(', ')}`
+ + `\nAll Titles: ${windowTitles.join(', ')}`,
+ );
+ }
+ return undefined;
+}
+
+function verifyStdErr(stderrOutput) {
+ if (stderrOutput && stderrOutput.length > 0) {
+ return describeError(
+ 'Standard error stream (`stderr`) is not empty.',
+ stderrOutput,
+ );
+ }
+ return undefined;
+}
+
+function verifyErrorsInLogs(logContent) {
+ if (!logContent || !logContent.length) {
+ return undefined;
+ }
+ const logLines = getNonEmptyLines(logContent)
+ .filter((line) => line.includes(LOG_ERROR_MARKER));
+ if (!logLines.length) {
+ return undefined;
+ }
+ return describeError(
+ 'Application log file',
+ logLines.join('\n'),
+ );
+}
+
+function describeError(reason, description) {
+ return {
+ reason,
+ description: `${description}\nThis might indicate an early crash or significant runtime issue.`,
+ };
+}
+
+function getNonEmptyLines(text) {
+ return splitTextIntoLines(text)
+ .filter((line) => line?.trim().length > 0);
+}
diff --git a/scripts/check-desktop-runtime-errors/app/extractors/linux.js b/scripts/check-desktop-runtime-errors/app/extractors/linux.js
new file mode 100644
index 00000000..0d4f8e32
--- /dev/null
+++ b/scripts/check-desktop-runtime-errors/app/extractors/linux.js
@@ -0,0 +1,34 @@
+import { access, chmod } from 'fs/promises';
+import { constants } from 'fs';
+import { findSingleFileByExtension } from '../../utils/io.js';
+import { log } from '../../utils/log.js';
+
+export async function prepareLinuxApp(desktopDistPath) {
+ const { absolutePath: appFile } = await findSingleFileByExtension(
+ 'AppImage',
+ desktopDistPath,
+ );
+ await makeExecutable(appFile);
+ return {
+ appExecutablePath: appFile,
+ };
+}
+
+async function makeExecutable(appFile) {
+ if (!appFile) { throw new Error('missing file'); }
+ if (await isExecutable(appFile)) {
+ log('AppImage is already executable.');
+ return;
+ }
+ log('Making it executable...');
+ await chmod(appFile, 0o755);
+}
+
+async function isExecutable(file) {
+ try {
+ await access(file, constants.X_OK);
+ return true;
+ } catch {
+ return false;
+ }
+}
diff --git a/scripts/check-desktop-runtime-errors/app/extractors/macos.js b/scripts/check-desktop-runtime-errors/app/extractors/macos.js
new file mode 100644
index 00000000..4f0730fb
--- /dev/null
+++ b/scripts/check-desktop-runtime-errors/app/extractors/macos.js
@@ -0,0 +1,66 @@
+import { runCommand } from '../../utils/run-command.js';
+import { findSingleFileByExtension, exists } from '../../utils/io.js';
+import { log, die, LOG_LEVELS } from '../../utils/log.js';
+
+export async function prepareMacOsApp(desktopDistPath) {
+ const { absolutePath: dmgPath } = await findSingleFileByExtension('dmg', desktopDistPath);
+ const { mountPath } = await mountDmg(dmgPath);
+ const appPath = await findMacAppExecutablePath(mountPath);
+ return {
+ appExecutablePath: appPath,
+ cleanup: async () => {
+ log('Cleaning up resources...');
+ await detachMount(mountPath);
+ },
+ };
+}
+
+async function mountDmg(dmgFile) {
+ const { stdout: hdiutilOutput, error } = await runCommand(`hdiutil attach '${dmgFile}'`);
+ if (error) {
+ die(`Failed to mount DMG file at ${dmgFile}.\n${error}`);
+ }
+ const mountPathMatch = hdiutilOutput.match(/\/Volumes\/[^\n]+/);
+ const mountPath = mountPathMatch ? mountPathMatch[0] : null;
+ return {
+ mountPath,
+ };
+}
+
+async function findMacAppExecutablePath(mountPath) {
+ const { stdout: findOutput, error } = await runCommand(
+ `find '${mountPath}' -maxdepth 1 -type d -name "*.app"`,
+ );
+ if (error) {
+ die(`Failed to find executable path at mount path ${mountPath}\n${error}`);
+ }
+ const appFolder = findOutput.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;
+}
+
+async function detachMount(mountPath, retries = 5) {
+ const { error } = await runCommand(`hdiutil detach '${mountPath}'`);
+ if (error) {
+ if (retries <= 0) {
+ log(`Failed to detach mount after multiple attempts: ${mountPath}\n${error}`, LOG_LEVELS.WARN);
+ return;
+ }
+ await sleep(500);
+ await detachMount(mountPath, retries - 1);
+ return;
+ }
+ log(`Successfully detached from ${mountPath}`);
+}
+
+function sleep(milliseconds) {
+ return new Promise((resolve) => {
+ setTimeout(resolve, milliseconds);
+ });
+}
diff --git a/scripts/check-desktop-runtime-errors/app/extractors/windows.js b/scripts/check-desktop-runtime-errors/app/extractors/windows.js
new file mode 100644
index 00000000..a03cb947
--- /dev/null
+++ b/scripts/check-desktop-runtime-errors/app/extractors/windows.js
@@ -0,0 +1,38 @@
+import { mkdtemp, rmdir } from 'fs/promises';
+import { join } from 'path';
+import { tmpdir } from 'os';
+import { findSingleFileByExtension, exists } from '../../utils/io.js';
+import { log, die } from '../../utils/log.js';
+import { runCommand } from '../../utils/run-command.js';
+
+export async function prepareWindowsApp(desktopDistPath) {
+ const workdir = await mkdtemp(join(tmpdir(), 'win-nsis-installation-'));
+ if (await exists(workdir)) {
+ log(`Temporary directory ${workdir} already exists, cleaning up...`);
+ await rmdir(workdir, { recursive: true });
+ }
+ const { appExecutablePath } = await installNsis(workdir, desktopDistPath);
+ return {
+ appExecutablePath,
+ cleanup: async () => {
+ log(`Cleaning up working directory ${workdir}...`);
+ await rmdir(workdir, { recursive: true });
+ },
+ };
+}
+
+async function installNsis(installationPath, desktopDistPath) {
+ const { absolutePath: installerPath } = await findSingleFileByExtension('exe', desktopDistPath);
+
+ log(`Silently installing contents of ${installerPath} to ${installationPath}...`);
+ const { error } = await runCommand(`"${installerPath}" /S /D=${installationPath}`);
+ if (error) {
+ die(`Failed to install.\n${error}`);
+ }
+
+ const { absolutePath: appExecutablePath } = await findSingleFileByExtension('exe', installationPath);
+
+ return {
+ appExecutablePath,
+ };
+}
diff --git a/scripts/check-desktop-runtime-errors/app/runner.js b/scripts/check-desktop-runtime-errors/app/runner.js
new file mode 100644
index 00000000..4b76382a
--- /dev/null
+++ b/scripts/check-desktop-runtime-errors/app/runner.js
@@ -0,0 +1,164 @@
+import { spawn } from 'child_process';
+import { log, LOG_LEVELS, die } from '../utils/log.js';
+import { captureScreen } from './system-capture/screen-capture.js';
+import { captureWindowTitles } from './system-capture/window-title-capture.js';
+
+const TERMINATION_GRACE_PERIOD_IN_SECONDS = 60;
+const TERMINATION_CHECK_INTERVAL_IN_MS = 1000;
+const WINDOW_TITLE_CAPTURE_INTERVAL_IN_MS = 100;
+
+export function runApplication(
+ appFile,
+ executionDurationInSeconds,
+ enableScreenshot,
+ screenshotPath,
+) {
+ if (!appFile) {
+ throw new Error('Missing app file');
+ }
+
+ logDetails(appFile, executionDurationInSeconds);
+
+ const processDetails = {
+ stderrData: '',
+ stdoutData: '',
+ explicitlyKilled: false,
+ windowTitles: [],
+ isCrashed: false,
+ isDone: false,
+ process: undefined,
+ resolve: () => { /* NOOP */ },
+ };
+
+ const process = spawn(appFile);
+ processDetails.process = process;
+
+ return new Promise((resolve) => {
+ processDetails.resolve = resolve;
+ handleTitleCapture(process.pid, processDetails);
+ handleProcessEvents(
+ processDetails,
+ enableScreenshot,
+ screenshotPath,
+ executionDurationInSeconds,
+ );
+ });
+}
+
+function logDetails(appFile, executionDurationInSeconds) {
+ log(
+ [
+ 'Executing the app to check for errors...',
+ `Maximum execution time: ${executionDurationInSeconds}`,
+ `Application path: ${appFile}`,
+ ].join('\n\t'),
+ );
+}
+
+function handleTitleCapture(processId, processDetails) {
+ const capture = async () => {
+ const titles = await captureWindowTitles(processId);
+
+ (titles || []).forEach((title) => {
+ if (!title || !title.length) {
+ return;
+ }
+ if (!processDetails.windowTitles.includes(title)) {
+ log(`New window title captured: ${title}`);
+ processDetails.windowTitles.push(title);
+ }
+ });
+
+ if (!processDetails.isDone) {
+ setTimeout(capture, WINDOW_TITLE_CAPTURE_INTERVAL_IN_MS);
+ }
+ };
+
+ capture();
+}
+
+function handleProcessEvents(
+ processDetails,
+ enableScreenshot,
+ screenshotPath,
+ executionDurationInSeconds,
+) {
+ const { process } = processDetails;
+ process.stderr.on('data', (data) => {
+ processDetails.stderrData += data.toString();
+ });
+ process.stdout.on('data', (data) => {
+ processDetails.stdoutData += data.toString();
+ });
+
+ process.on('error', (error) => {
+ die(`An issue spawning the child process: ${error}`, LOG_LEVELS.ERROR);
+ });
+
+ process.on('exit', async (code) => {
+ await onProcessExit(code, processDetails, enableScreenshot, screenshotPath);
+ });
+
+ setTimeout(async () => {
+ await onExecutionLimitReached(process, processDetails, enableScreenshot, screenshotPath);
+ }, executionDurationInSeconds * 1000);
+}
+
+async function onProcessExit(code, processDetails, enableScreenshot, screenshotPath) {
+ log(`Application exited ${code === null || Number.isNaN(code) ? '.' : `with code ${code}`}`);
+
+ if (processDetails.explicitlyKilled) return;
+
+ processDetails.isCrashed = true;
+
+ if (enableScreenshot) {
+ await captureScreen(screenshotPath);
+ }
+
+ finishProcess(processDetails);
+}
+
+async function onExecutionLimitReached(process, processDetails, enableScreenshot, screenshotPath) {
+ if (enableScreenshot) {
+ await captureScreen(screenshotPath);
+ }
+
+ processDetails.explicitlyKilled = true;
+ await terminateGracefully(process);
+ finishProcess(processDetails);
+}
+
+function finishProcess(processDetails) {
+ processDetails.isDone = true;
+ processDetails.resolve({
+ stderr: processDetails.stderrData,
+ stdout: processDetails.stdoutData,
+ windowTitles: [...processDetails.windowTitles],
+ isCrashed: processDetails.isCrashed,
+ });
+}
+
+async function terminateGracefully(process) {
+ let elapsedSeconds = 0;
+ log('Attempting to terminate the process gracefully...');
+ process.kill('SIGTERM');
+
+ return new Promise((resolve) => {
+ const checkInterval = setInterval(() => {
+ elapsedSeconds += TERMINATION_CHECK_INTERVAL_IN_MS / 1000;
+
+ if (!process.killed) {
+ if (elapsedSeconds >= TERMINATION_GRACE_PERIOD_IN_SECONDS) {
+ process.kill('SIGKILL');
+ log('Process did not terminate gracefully within the grace period. Forcing termination.', LOG_LEVELS.WARN);
+ clearInterval(checkInterval);
+ resolve();
+ }
+ } else {
+ log('Process terminated gracefully.');
+ clearInterval(checkInterval);
+ resolve();
+ }
+ }, TERMINATION_CHECK_INTERVAL_IN_MS);
+ });
+}
diff --git a/scripts/check-desktop-runtime-errors/app/system-capture/screen-capture.js b/scripts/check-desktop-runtime-errors/app/system-capture/screen-capture.js
new file mode 100644
index 00000000..fcae87ef
--- /dev/null
+++ b/scripts/check-desktop-runtime-errors/app/system-capture/screen-capture.js
@@ -0,0 +1,59 @@
+import { unlink } from 'fs/promises';
+import { runCommand } from '../../utils/run-command.js';
+import { log, LOG_LEVELS } from '../../utils/log.js';
+import { CURRENT_PLATFORM, SUPPORTED_PLATFORMS } from '../../utils/platform.js';
+import { exists } from '../../utils/io.js';
+
+export async function captureScreen(imagePath) {
+ if (!imagePath) {
+ throw new Error('Path for screenshot not provided');
+ }
+
+ if (await exists(imagePath)) {
+ log(`Screenshot file already exists at ${imagePath}. It will be overwritten.`, LOG_LEVELS.WARN);
+ unlink(imagePath);
+ }
+
+ const platformCommands = {
+ [SUPPORTED_PLATFORMS.MAC]: `screencapture -x ${imagePath}`,
+ [SUPPORTED_PLATFORMS.LINUX]: `import -window root ${imagePath}`,
+ [SUPPORTED_PLATFORMS.WINDOWS]: `powershell -NoProfile -EncodedCommand ${encodeForPowershell(getScreenshotPowershellScript(imagePath))}`,
+ };
+
+ const commandForPlatform = platformCommands[CURRENT_PLATFORM];
+
+ if (!commandForPlatform) {
+ log(`Screenshot capture not supported on: ${CURRENT_PLATFORM}`, LOG_LEVELS.WARN);
+ return;
+ }
+
+ log(`Capturing screenshot to ${imagePath} using command:\n\t> ${commandForPlatform}`);
+
+ const { error } = await runCommand(commandForPlatform);
+ if (error) {
+ log(`Failed to capture screenshot.\n${error}`, LOG_LEVELS.WARN);
+ return;
+ }
+ log(`Captured screenshot to ${imagePath}.`);
+}
+
+function getScreenshotPowershellScript(imagePath) {
+ return `
+ $ProgressPreference = 'SilentlyContinue' # Do not pollute stderr
+ Add-Type -AssemblyName System.Windows.Forms
+ $screenBounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
+
+ $bmp = New-Object System.Drawing.Bitmap $screenBounds.Width, $screenBounds.Height
+ $graphics = [System.Drawing.Graphics]::FromImage($bmp)
+ $graphics.CopyFromScreen([System.Drawing.Point]::Empty, [System.Drawing.Point]::Empty, $screenBounds.Size)
+
+ $bmp.Save('${imagePath}')
+ $graphics.Dispose()
+ $bmp.Dispose()
+ `;
+}
+
+function encodeForPowershell(script) {
+ const buffer = Buffer.from(script, 'utf-16le');
+ return buffer.toString('base64');
+}
diff --git a/scripts/check-desktop-runtime-errors/app/system-capture/window-title-capture.js b/scripts/check-desktop-runtime-errors/app/system-capture/window-title-capture.js
new file mode 100644
index 00000000..ca5b7b69
--- /dev/null
+++ b/scripts/check-desktop-runtime-errors/app/system-capture/window-title-capture.js
@@ -0,0 +1,113 @@
+import { runCommand } from '../../utils/run-command.js';
+import { log, LOG_LEVELS } from '../../utils/log.js';
+import { SUPPORTED_PLATFORMS, CURRENT_PLATFORM } from '../../utils/platform.js';
+
+export async function captureWindowTitles(processId) {
+ if (!processId) { throw new Error('Missing process ID.'); }
+
+ const captureFunction = windowTitleCaptureFunctions[CURRENT_PLATFORM];
+ if (!captureFunction) {
+ log(`Cannot capture window title, unsupported OS: ${CURRENT_PLATFORM}`, LOG_LEVELS.WARN);
+ return undefined;
+ }
+
+ return captureFunction(processId);
+}
+
+const windowTitleCaptureFunctions = {
+ [SUPPORTED_PLATFORMS.MAC]: captureTitlesOnMac,
+ [SUPPORTED_PLATFORMS.LINUX]: captureTitlesOnLinux,
+ [SUPPORTED_PLATFORMS.WINDOWS]: captureTitlesOnWindows,
+};
+
+async function captureTitlesOnWindows(processId) {
+ if (!processId) { throw new Error('Missing process ID.'); }
+
+ const { stdout: tasklistOutput, error } = await runCommand(
+ `tasklist /FI "PID eq ${processId}" /fo list /v`,
+ );
+ if (error) {
+ log(`Failed to retrieve window title.\n${error}`, LOG_LEVELS.WARN);
+ return [];
+ }
+ const match = tasklistOutput.match(/Window Title:\s*(.*)/);
+ if (match && match[1]) {
+ const title = match[1].trim();
+ if (title === 'N/A') {
+ return [];
+ }
+ return [title];
+ }
+ return [];
+}
+
+async function captureTitlesOnLinux(processId) {
+ if (!processId) { throw new Error('Missing process ID.'); }
+
+ const { stdout: windowIdsOutput, error: windowIdError } = await runCommand(
+ `xdotool search --pid '${processId}'`,
+ );
+
+ if (windowIdError || !windowIdsOutput) {
+ return undefined;
+ }
+
+ const windowIds = windowIdsOutput.trim().split('\n');
+
+ const titles = await Promise.all(windowIds.map(async (windowId) => {
+ const { stdout: titleOutput, error: titleError } = await runCommand(
+ `xprop -id ${windowId} | grep "WM_NAME(STRING)" | cut -d '=' -f 2 | sed 's/^[[:space:]]*"\\(.*\\)"[[:space:]]*$/\\1/'`
+ );
+ if (titleError || !titleOutput) {
+ return undefined;
+ }
+ return titleOutput.trim();
+ }));
+
+ return titles.filter(Boolean);
+}
+
+let hasAssistiveAccessOnMac = true;
+
+async function captureTitlesOnMac(processId) {
+ if (!processId) { throw new Error('Missing process ID.'); }
+ if (!hasAssistiveAccessOnMac) {
+ return [];
+ }
+ const script = `
+ tell application "System Events"
+ try
+ set targetProcess to first process whose unix id is ${processId}
+ on error
+ return
+ end try
+ tell targetProcess
+ if (count of windows) > 0 then
+ set window_name to name of front window
+ return window_name
+ end if
+ end tell
+ end tell
+ `;
+ const argument = script.trim()
+ .split(/[\r\n]+/)
+ .map((line) => `-e '${line.trim()}'`)
+ .join(' ');
+
+ const { stdout: titleOutput, error } = await runCommand(`osascript ${argument}`);
+ if (error) {
+ let errorMessage = '';
+ if (error.includes('-25211')) {
+ errorMessage += 'Capturing window title requires assistive access. You do not have it.\n';
+ hasAssistiveAccessOnMac = false;
+ }
+ errorMessage += error;
+ log(errorMessage, LOG_LEVELS.WARN);
+ return [];
+ }
+ const title = titleOutput?.trim();
+ if (!title) {
+ return [];
+ }
+ return [title];
+}
diff --git a/scripts/check-desktop-runtime-errors/cli-args.js b/scripts/check-desktop-runtime-errors/cli-args.js
new file mode 100644
index 00000000..f9557efc
--- /dev/null
+++ b/scripts/check-desktop-runtime-errors/cli-args.js
@@ -0,0 +1,20 @@
+import { log } from './utils/log.js';
+
+const PROCESS_ARGUMENTS = process.argv.slice(2);
+
+export const COMMAND_LINE_FLAGS = Object.freeze({
+ FORCE_REBUILD: '--build',
+ TAKE_SCREENSHOT: '--screenshot',
+});
+
+export function logCurrentArgs() {
+ if (!PROCESS_ARGUMENTS.length) {
+ log('No additional arguments provided.');
+ return;
+ }
+ log(`Arguments: ${PROCESS_ARGUMENTS.join(', ')}`);
+}
+
+export function hasCommandLineFlag(flag) {
+ return PROCESS_ARGUMENTS.includes(flag);
+}
diff --git a/scripts/check-desktop-runtime-errors/config.js b/scripts/check-desktop-runtime-errors/config.js
new file mode 100644
index 00000000..9597e6d5
--- /dev/null
+++ b/scripts/check-desktop-runtime-errors/config.js
@@ -0,0 +1,7 @@
+import { join } from 'path';
+
+export const DESKTOP_BUILD_COMMAND = 'npm run electron:build -- -p never';
+export const PROJECT_DIR = process.cwd();
+export const DESKTOP_DIST_PATH = join(PROJECT_DIR, 'dist_electron');
+export const APP_EXECUTION_DURATION_IN_SECONDS = 60; // Long enough so CI runners have time to bootstrap it
+export const SCREENSHOT_PATH = join(PROJECT_DIR, 'screenshot.png');
diff --git a/scripts/check-desktop-runtime-errors/index.js b/scripts/check-desktop-runtime-errors/index.js
new file mode 100644
index 00000000..904a873a
--- /dev/null
+++ b/scripts/check-desktop-runtime-errors/index.js
@@ -0,0 +1,3 @@
+import { main } from './main.js';
+
+await main();
diff --git a/scripts/check-desktop-runtime-errors/main.js b/scripts/check-desktop-runtime-errors/main.js
new file mode 100644
index 00000000..79912963
--- /dev/null
+++ b/scripts/check-desktop-runtime-errors/main.js
@@ -0,0 +1,68 @@
+import { logCurrentArgs, COMMAND_LINE_FLAGS, hasCommandLineFlag } from './cli-args.js';
+import { log, die } from './utils/log.js';
+import { ensureNpmProjectDir, npmInstall, npmBuild } from './utils/npm.js';
+import { clearAppLogFile } from './app/app-logs.js';
+import { checkForErrors } from './app/check-for-errors.js';
+import { runApplication } from './app/runner.js';
+import { CURRENT_PLATFORM, SUPPORTED_PLATFORMS } from './utils/platform.js';
+import { prepareLinuxApp } from './app/extractors/linux.js';
+import { prepareWindowsApp } from './app/extractors/windows.js';
+import { prepareMacOsApp } from './app/extractors/macos.js';
+import {
+ DESKTOP_BUILD_COMMAND,
+ PROJECT_DIR,
+ DESKTOP_DIST_PATH,
+ APP_EXECUTION_DURATION_IN_SECONDS,
+ SCREENSHOT_PATH,
+} from './config.js';
+
+export async function main() {
+ logCurrentArgs();
+ await ensureNpmProjectDir(PROJECT_DIR);
+ await npmInstall(PROJECT_DIR);
+ await npmBuild(
+ PROJECT_DIR,
+ DESKTOP_BUILD_COMMAND,
+ DESKTOP_DIST_PATH,
+ hasCommandLineFlag(COMMAND_LINE_FLAGS.FORCE_REBUILD),
+ );
+ await clearAppLogFile(PROJECT_DIR);
+ const {
+ stderr, stdout, isCrashed, windowTitles,
+ } = await extractAndRun();
+ if (stdout) {
+ log(`Output (stdout) from application execution:\n${stdout}`);
+ }
+ if (isCrashed) {
+ die('The application encountered an error during its execution.');
+ }
+ await checkForErrors(stderr, windowTitles, PROJECT_DIR);
+ log('🥳🎈 Success! Application completed without any runtime errors.');
+ process.exit(0);
+}
+
+async function extractAndRun() {
+ const extractors = {
+ [SUPPORTED_PLATFORMS.MAC]: () => prepareMacOsApp(DESKTOP_DIST_PATH),
+ [SUPPORTED_PLATFORMS.LINUX]: () => prepareLinuxApp(DESKTOP_DIST_PATH),
+ [SUPPORTED_PLATFORMS.WINDOWS]: () => prepareWindowsApp(DESKTOP_DIST_PATH),
+ };
+ const extractor = extractors[CURRENT_PLATFORM];
+ if (!extractor) {
+ throw new Error(`Platform not supported: ${CURRENT_PLATFORM}`);
+ }
+ const { appExecutablePath, cleanup } = await extractor();
+ try {
+ return await runApplication(
+ appExecutablePath,
+ APP_EXECUTION_DURATION_IN_SECONDS,
+ hasCommandLineFlag(COMMAND_LINE_FLAGS.TAKE_SCREENSHOT),
+ SCREENSHOT_PATH,
+ );
+ } finally {
+ if (cleanup) {
+ log('Cleaning up post-execution resources...');
+ await cleanup();
+ }
+ }
+}
diff --git a/scripts/check-desktop-runtime-errors/utils/io.js b/scripts/check-desktop-runtime-errors/utils/io.js
new file mode 100644
index 00000000..fdebceb9
--- /dev/null
+++ b/scripts/check-desktop-runtime-errors/utils/io.js
@@ -0,0 +1,48 @@
+import { extname, join } from 'path';
+import { readdir, access } from 'fs/promises';
+import { constants } from 'fs';
+import { log, die, LOG_LEVELS } from './log.js';
+
+export async function findSingleFileByExtension(extension, directory) {
+ if (!directory) { throw new Error('Missing directory'); }
+ if (!extension) { throw new Error('Missing file extension'); }
+
+ if (!await exists(directory)) {
+ die(`Directory does not exist: ${directory}`);
+ return [];
+ }
+
+ const directoryContents = await readdir(directory);
+ const foundFileNames = directoryContents.filter((file) => extname(file) === `.${extension}`);
+ const withoutUninstaller = foundFileNames.filter(
+ (fileName) => !fileName.toLowerCase().includes('uninstall'), // NSIS build has `Uninstall {app-name}.exe`
+ );
+ if (!withoutUninstaller.length) {
+ die(`No ${extension} found in ${directory} directory.`);
+ }
+ if (withoutUninstaller.length > 1) {
+ log(`Found multiple ${extension} files: ${withoutUninstaller.join(', ')}. Using first occurrence`, LOG_LEVELS.WARN);
+ }
+ return {
+ absolutePath: join(directory, withoutUninstaller[0]),
+ };
+}
+
+export async function exists(path) {
+ if (!path) { throw new Error('Missing path'); }
+ try {
+ await access(path, constants.F_OK);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+export 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;
+}
diff --git a/scripts/check-desktop-runtime-errors/utils/log.js b/scripts/check-desktop-runtime-errors/utils/log.js
new file mode 100644
index 00000000..66b09c1d
--- /dev/null
+++ b/scripts/check-desktop-runtime-errors/utils/log.js
@@ -0,0 +1,39 @@
+export const LOG_LEVELS = Object.freeze({
+ INFO: 'INFO',
+ WARN: 'WARN',
+ ERROR: 'ERROR',
+});
+
+export function log(message, level = LOG_LEVELS.INFO) {
+ const timestamp = new Date().toISOString();
+ const config = LOG_LEVEL_CONFIG[level] || LOG_LEVEL_CONFIG[LOG_LEVELS.INFO];
+ const formattedMessage = `[${timestamp}][${config.color}${level}${COLOR_CODES.RESET}] ${message}`;
+ config.method(formattedMessage);
+}
+
+export function die(message) {
+ log(message, LOG_LEVELS.ERROR);
+ process.exit(1);
+}
+
+const COLOR_CODES = {
+ RESET: '\x1b[0m',
+ LIGHT_RED: '\x1b[91m',
+ YELLOW: '\x1b[33m',
+ LIGHT_BLUE: '\x1b[94m',
+};
+
+const LOG_LEVEL_CONFIG = {
+ [LOG_LEVELS.INFO]: {
+ color: COLOR_CODES.LIGHT_BLUE,
+ method: console.log,
+ },
+ [LOG_LEVELS.WARN]: {
+ color: COLOR_CODES.YELLOW,
+ method: console.warn,
+ },
+ [LOG_LEVELS.ERROR]: {
+ color: COLOR_CODES.LIGHT_RED,
+ method: console.error,
+ },
+};
diff --git a/scripts/check-desktop-runtime-errors/utils/npm.js b/scripts/check-desktop-runtime-errors/utils/npm.js
new file mode 100644
index 00000000..803515d1
--- /dev/null
+++ b/scripts/check-desktop-runtime-errors/utils/npm.js
@@ -0,0 +1,87 @@
+import { join } from 'path';
+import { rmdir, readFile } from 'fs/promises';
+import { exists, isDirMissingOrEmpty } from './io.js';
+import { runCommand } from './run-command.js';
+import { LOG_LEVELS, die, log } from './log.js';
+
+export async function ensureNpmProjectDir(projectDir) {
+ if (!projectDir) { throw new Error('missing project directory'); }
+ if (!await exists(join(projectDir, 'package.json'))) {
+ die(`'package.json' not found in project directory: ${projectDir}`);
+ }
+}
+
+export async function npmInstall(projectDir) {
+ if (!projectDir) { throw new Error('missing project directory'); }
+ const npmModulesPath = join(projectDir, 'node_modules');
+ if (!await isDirMissingOrEmpty(npmModulesPath)) {
+ log(`Directory "${npmModulesPath}" exists and has content. Skipping 'npm install'.`);
+ return;
+ }
+ log('Starting dependency installation...');
+ const { error } = await runCommand('npm install --loglevel=error', {
+ stdio: 'inherit',
+ cwd: projectDir,
+ });
+ if (error) {
+ die(error);
+ }
+}
+
+export async function npmBuild(projectDir, buildCommand, distDir, forceRebuild) {
+ if (!projectDir) { throw new Error('missing project directory'); }
+ if (!buildCommand) { throw new Error('missing build command'); }
+ if (!distDir) { throw new Error('missing distribution directory'); }
+
+ const isMissingBuild = await isDirMissingOrEmpty(distDir);
+
+ if (!isMissingBuild && !forceRebuild) {
+ log(`Directory "${distDir}" exists and has content. Skipping build: '${buildCommand}'.`);
+ return;
+ }
+
+ if (forceRebuild) {
+ log(`Removing directory "${distDir}" for a clean build (triggered by --build flag).`);
+ await rmdir(distDir, { recursive: true });
+ }
+
+ log('Starting project build...');
+ const { error } = await runCommand(buildCommand, {
+ stdio: 'inherit',
+ cwd: projectDir,
+ });
+ if (error) {
+ log(error, LOG_LEVELS.WARN); // Cannot disable Vue CLI errors, stderr contains false-positives.
+ }
+}
+
+export async function getAppName(projectDir) {
+ if (!projectDir) { throw new Error('missing project directory'); }
+ const packageData = await readPackageJsonContents(projectDir);
+ try {
+ const packageJson = JSON.parse(packageData);
+ if (!packageJson.name) {
+ die(`The 'package.json' file doesn't specify a name: ${packageData}`);
+ }
+ return packageJson.name;
+ } catch (error) {
+ die(`Unable to parse 'package.json'. Error: ${error}\nContent: ${packageData}`, LOG_LEVELS.ERROR);
+ return undefined;
+ }
+}
+
+async function readPackageJsonContents(projectDir) {
+ if (!projectDir) { throw new Error('missing project directory'); }
+ const packagePath = join(projectDir, 'package.json');
+ if (!await exists(packagePath)) {
+ die(`'package.json' file not found at ${packagePath}`);
+ }
+ try {
+ const packageData = await readFile(packagePath, 'utf8');
+ return packageData;
+ } catch (error) {
+ log(`Error reading 'package.json' from ${packagePath}.`, LOG_LEVELS.ERROR);
+ die(`Error detail: ${error}`, LOG_LEVELS.ERROR);
+ throw error;
+ }
+}
diff --git a/scripts/check-desktop-runtime-errors/utils/platform.js b/scripts/check-desktop-runtime-errors/utils/platform.js
new file mode 100644
index 00000000..24acb111
--- /dev/null
+++ b/scripts/check-desktop-runtime-errors/utils/platform.js
@@ -0,0 +1,9 @@
+import { platform } from 'os';
+
+export const SUPPORTED_PLATFORMS = {
+ MAC: 'darwin',
+ LINUX: 'linux',
+ WINDOWS: 'win32',
+};
+
+export const CURRENT_PLATFORM = platform();
diff --git a/scripts/check-desktop-runtime-errors/utils/run-command.js b/scripts/check-desktop-runtime-errors/utils/run-command.js
new file mode 100644
index 00000000..6ea2baf6
--- /dev/null
+++ b/scripts/check-desktop-runtime-errors/utils/run-command.js
@@ -0,0 +1,45 @@
+import { exec } from 'child_process';
+import { LOG_LEVELS, log } from './log.js';
+import { indentText } from './text.js';
+
+const TIMEOUT_IN_SECONDS = 180;
+const MAX_OUTPUT_BUFFER_SIZE = 1024 * 1024; // 1 MB
+
+export function runCommand(commandString, options) {
+ return new Promise((resolve) => {
+ options = {
+ cwd: process.cwd(),
+ timeout: TIMEOUT_IN_SECONDS * 1000,
+ maxBuffer: MAX_OUTPUT_BUFFER_SIZE * 2,
+ ...options,
+ };
+
+ exec(commandString, options, (error, stdout, stderr) => {
+ let errorText;
+ if (error || stderr?.length > 0) {
+ errorText = formatError(commandString, error, stdout, stderr);
+ }
+ resolve({
+ stdout,
+ error: errorText,
+ });
+ });
+ });
+}
+
+function formatError(commandString, error, stdout, stderr) {
+ const errorParts = [
+ 'Error while running command.',
+ `Command:\n${indentText(commandString, 1)}`,
+ ];
+ if (error?.toString().trim()) {
+ errorParts.push(`Error:\n${indentText(error.toString(), 1)}`);
+ }
+ if (stderr?.toString().trim()) {
+ errorParts.push(`stderr:\n${indentText(stderr, 1)}`);
+ }
+ if (stdout?.toString().trim()) {
+ errorParts.push(`stdout:\n${indentText(stdout, 1)}`);
+ }
+ return errorParts.join('\n---\n');
+}
diff --git a/scripts/check-desktop-runtime-errors/utils/text.js b/scripts/check-desktop-runtime-errors/utils/text.js
new file mode 100644
index 00000000..1a21d3ef
--- /dev/null
+++ b/scripts/check-desktop-runtime-errors/utils/text.js
@@ -0,0 +1,19 @@
+export function indentText(text, indentLevel = 1) {
+ validateText(text);
+ const indentation = '\t'.repeat(indentLevel);
+ return splitTextIntoLines(text)
+ .map((line) => (line ? `${indentation}${line}` : line))
+ .join('\n');
+}
+
+export function splitTextIntoLines(text) {
+ validateText(text);
+ return text
+ .split(/[\r\n]+/);
+}
+
+function validateText(text) {
+ if (typeof text !== 'string') {
+ throw new Error(`text is not a string. It is: ${typeof text}\n${text}`);
+ }
+}
diff --git a/src/presentation/electron/main.ts b/src/presentation/electron/main.ts
index cbe045da..e70377a5 100644
--- a/src/presentation/electron/main.ts
+++ b/src/presentation/electron/main.ts
@@ -26,10 +26,7 @@ protocol.registerSchemesAsPrivileged([
{ scheme: 'app', privileges: { secure: true, standard: true } },
]);
-log.transports.file.level = 'silly';
-if (!process.env.IS_TEST) {
- Object.assign(console, log.functions); // override console.log, console.warn etc.
-}
+setupLogger();
function createWindow() {
// Create the browser window.
@@ -131,6 +128,9 @@ function loadApplication(window: BrowserWindow) {
const updater = setupAutoUpdater();
updater.checkForUpdates();
}
+ // Do not remove [APP_INIT_SUCCESS]; it's a marker used in tests to verify
+ // app initialization.
+ log.info('[APP_INIT_SUCCESS] Main window initialized and content loading.');
}
function configureExternalsUrlsOpenBrowser(window: BrowserWindow) {
@@ -155,3 +155,10 @@ function getWindowSize(idealWidth: number, idealHeight: number) {
height = Math.min(height, idealHeight);
return { width, height };
}
+
+function setupLogger(): void {
+ log.transports.file.level = 'silly';
+ if (!process.env.IS_TEST) {
+ Object.assign(console, log.functions); // override console.log, console.warn etc.
+ }
+}