From 3a8cac25a11ed207192d443562f899cecb54aede Mon Sep 17 00:00:00 2001
From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com>
Date: Sat, 3 Oct 2026 17:14:28 +0200
Subject: [PATCH] fix(electron): restore tested startup splash window
---
.github/workflows/electron-build-ci.yml | 48 +++++++++
electron/README.md | 24 +++++
electron/SplashWindow.test.ts | 130 ++++++++++++++++++++++++
electron/SplashWindow.ts | 101 ++++++++++++++++++
electron/jest.splash.config.cjs | 11 ++
electron/tsconfig.splash-test.json | 9 ++
package.json | 1 +
scripts/test-electron-splash.cjs | 50 +++++++++
8 files changed, 374 insertions(+)
create mode 100644 .github/workflows/electron-build-ci.yml
create mode 100644 electron/SplashWindow.test.ts
create mode 100644 electron/SplashWindow.ts
create mode 100644 electron/jest.splash.config.cjs
create mode 100644 electron/tsconfig.splash-test.json
create mode 100644 scripts/test-electron-splash.cjs
diff --git a/.github/workflows/electron-build-ci.yml b/.github/workflows/electron-build-ci.yml
new file mode 100644
index 00000000..5b0b38e3
--- /dev/null
+++ b/.github/workflows/electron-build-ci.yml
@@ -0,0 +1,48 @@
+name: Electron Launcher CI
+
+on:
+ pull_request:
+ paths:
+ - 'electron/**'
+ - 'package.json'
+ - 'package-lock.json'
+ - 'scripts/test-electron-splash.cjs'
+ - '.github/workflows/electron-build-ci.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ strategy:
+ fail-fast: false
+ matrix:
+ node: ['22.12.0', '24.19.0']
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: ${{ matrix.node }}
+ cache: npm
+ - run: npm ci --ignore-scripts --no-audit --no-fund
+ - run: npm run test:electron:splash
+ - run: npm run electron:build
+
+ splash-smoke:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '24.19.0'
+ cache: npm
+ - run: npm ci --ignore-scripts --no-audit --no-fund
+ - run: npm run electron:build
+ - run: npm rebuild electron
+ # Xvfb/--no-sandbox apply only to this bounded CI harness, not the launcher.
+ # This check does not establish runtime sandbox enforcement or full-app startup.
+ - run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox scripts/test-electron-splash.cjs
diff --git a/electron/README.md b/electron/README.md
index 4cb251c2..96ac7a79 100644
--- a/electron/README.md
+++ b/electron/README.md
@@ -20,6 +20,30 @@ npm run electron:build
npm run electron:build:watch
```
+Run these commands from the repository root. `config/package.json` is a legacy
+snapshot with paths such as `electron/tsconfig.json` and `src/ui` that are not
+relative to `config/`; it is not a second launcher entry point.
+
+### Startup status checks
+
+`SplashWindow.ts` implements the startup status window used by `main.ts`. It
+buffers updates until its local document loads, renders messages as text, and
+closes when the main window is ready. It exposes no renderer Node bridge.
+
+```bash
+npm run test:electron:splash
+npm run electron:build
+# Requires the installed Electron binary and a graphical display:
+./node_modules/.bin/electron scripts/test-electron-splash.cjs
+```
+
+The focused CI checks compilation on Node 22.12.0 and 24.19.0 plus an actual
+Electron splash-window smoke on Linux/Xvfb. The smoke harness disables the process
+sandbox only for CI; it does not validate sandbox enforcement, complete launcher
+startup, backend/provider integration, the Creative Studio renderer, packaging,
+or macOS/Windows behavior. The launcher's BrowserWindow sandbox settings remain
+enabled.
+
### Running
```bash
diff --git a/electron/SplashWindow.test.ts b/electron/SplashWindow.test.ts
new file mode 100644
index 00000000..17af24b2
--- /dev/null
+++ b/electron/SplashWindow.test.ts
@@ -0,0 +1,130 @@
+import { EventEmitter } from 'events';
+import { runInNewContext } from 'vm';
+import { BrowserWindow } from 'electron';
+import { SplashWindow } from './SplashWindow';
+
+jest.mock('electron', () => ({ BrowserWindow: jest.fn() }));
+jest.mock('./defaultPaths', () => ({ getAppIconPath: () => undefined }));
+
+class FakeWindow extends EventEmitter {
+ destroyed = false;
+ statusElement = { textContent: '' };
+ progressElement = { value: 0 };
+ renderer = {
+ document: {
+ getElementById: (id: string) => id === 'status' ? this.statusElement : this.progressElement,
+ },
+ injected: false,
+ };
+ webContents = Object.assign(new EventEmitter(), {
+ setWindowOpenHandler: jest.fn(),
+ executeJavaScript: jest.fn(async (script: string) => runInNewContext(script, this.renderer)),
+ });
+ loadURL = jest.fn().mockResolvedValue(undefined);
+ show = jest.fn();
+ isDestroyed = (): boolean => this.destroyed;
+ close = jest.fn(() => {
+ this.destroyed = true;
+ this.emit('closed');
+ });
+}
+
+describe('SplashWindow startup lifecycle', () => {
+ let window: FakeWindow;
+ let splash: SplashWindow;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ window = new FakeWindow();
+ (BrowserWindow as unknown as jest.Mock).mockReturnValue(window);
+ splash = new SplashWindow();
+ });
+
+ it('uses a sandboxed local document without a renderer Node bridge', () => {
+ expect(BrowserWindow).toHaveBeenCalledWith(expect.objectContaining({
+ show: false,
+ webPreferences: { nodeIntegration: false, contextIsolation: true, sandbox: true },
+ }));
+ const url = window.loadURL.mock.calls[0][0] as string;
+ expect(url).toMatch(/^data:text\/html;charset=utf-8,/);
+ expect(decodeURIComponent(url)).toContain("default-src 'none'");
+ expect(window.show).not.toHaveBeenCalled();
+ window.emit('ready-to-show');
+ expect(window.show).toHaveBeenCalledTimes(1);
+ });
+
+ it('keeps the latest update sent before the document has loaded', () => {
+ splash.update('Initializing services...', 10);
+ splash.update('Connecting to backend engine...', 30);
+ expect(window.webContents.executeJavaScript).not.toHaveBeenCalled();
+ window.webContents.emit('did-finish-load');
+ expect(window.statusElement.textContent).toBe('Connecting to backend engine...');
+ expect(window.progressElement.value).toBe(30);
+ });
+
+ it('renders quotes, markup and code-like status strings as literal text', () => {
+ window.webContents.emit('did-finish-load');
+ const message = `"; globalThis.injected = true; // \nÉté`;
+ splash.update(message, 70);
+ expect(window.statusElement.textContent).toBe(message);
+ expect(window.renderer.injected).toBe(false);
+ expect(window.progressElement.value).toBe(70);
+ });
+
+ it.each([[-10, 0], [120, 100], [NaN, 0], [Infinity, 0], [-Infinity, 0]])(
+ 'bounds progress %s to %s', (input, expected) => {
+ window.webContents.emit('did-finish-load');
+ splash.update('Loading interface...', input);
+ expect(window.progressElement.value).toBe(expected);
+ },
+ );
+
+ it('ignores late load, show and update events after an early close', () => {
+ splash.close();
+ splash.close();
+ window.webContents.emit('did-finish-load');
+ window.emit('ready-to-show');
+ splash.update('Too late', 100);
+ expect(window.close).toHaveBeenCalledTimes(1);
+ expect(window.show).not.toHaveBeenCalled();
+ expect(window.webContents.executeJavaScript).not.toHaveBeenCalled();
+ });
+
+ it('ignores updates after the native window is closed', () => {
+ window.close();
+ splash.update('Too late', 100);
+ splash.close();
+ expect(window.close).toHaveBeenCalledTimes(1);
+ expect(window.webContents.executeJavaScript).not.toHaveBeenCalled();
+ });
+
+ it('denies new windows and navigation away from the splash document', () => {
+ const openHandler = window.webContents.setWindowOpenHandler.mock.calls[0][0];
+ expect(openHandler({ url: 'https://example.invalid/' })).toEqual({ action: 'deny' });
+ const event = { preventDefault: jest.fn() };
+ window.webContents.emit('will-navigate', event);
+ expect(event.preventDefault).toHaveBeenCalledTimes(1);
+ });
+
+ it('handles a document load failure without leaving the splash window open', async () => {
+ const warning = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
+ splash.close();
+ window = new FakeWindow();
+ window.loadURL.mockRejectedValue(new Error('load failed'));
+ (BrowserWindow as unknown as jest.Mock).mockReturnValue(window);
+ new SplashWindow();
+ await Promise.resolve();
+ expect(window.close).toHaveBeenCalledTimes(1);
+ expect(warning).toHaveBeenCalled();
+ warning.mockRestore();
+ });
+
+ it('handles a renderer update failure without an unhandled rejection', async () => {
+ const warning = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
+ window.webContents.executeJavaScript.mockRejectedValue(new Error('renderer stopped'));
+ window.webContents.emit('did-finish-load');
+ await Promise.resolve();
+ expect(warning).toHaveBeenCalled();
+ warning.mockRestore();
+ });
+});
diff --git a/electron/SplashWindow.ts b/electron/SplashWindow.ts
new file mode 100644
index 00000000..0691b1db
--- /dev/null
+++ b/electron/SplashWindow.ts
@@ -0,0 +1,101 @@
+import { BrowserWindow } from 'electron';
+import { getAppIconPath } from './defaultPaths';
+
+const SPLASH_HTML = `
+
+
Starting...
+ + +`; + +/** Startup status window used by main.ts until the main renderer is ready. */ +export class SplashWindow { + private window: BrowserWindow | null; + private loaded = false; + private status = { message: 'Starting...', progress: 0 }; + + constructor() { + const window = new BrowserWindow({ + width: 420, + height: 240, + frame: false, + resizable: false, + center: true, + alwaysOnTop: true, + show: false, + backgroundColor: '#1a1a2e', + icon: getAppIconPath(), + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + }, + }); + this.window = window; + + window.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); + window.webContents.on('will-navigate', (event) => event.preventDefault()); + window.webContents.once('did-finish-load', () => { + if (this.window === window && !window.isDestroyed()) { + this.loaded = true; + this.renderStatus(); + } + }); + window.once('ready-to-show', () => { + if (this.window === window && !window.isDestroyed()) window.show(); + }); + window.on('closed', () => { + this.window = null; + this.loaded = false; + }); + + void window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(SPLASH_HTML)}`) + .catch((error: unknown) => { + console.warn('Could not load startup status window:', error); + this.close(); + }); + } + + update(message: string, progress: number): void { + this.status = { + message, + progress: Number.isFinite(progress) ? Math.min(100, Math.max(0, progress)) : 0, + }; + this.renderStatus(); + } + + close(): void { + const window = this.window; + this.window = null; + this.loaded = false; + if (window && !window.isDestroyed()) window.close(); + } + + private renderStatus(): void { + const window = this.window; + if (!this.loaded || !window || window.isDestroyed()) return; + + // JSON quoting and textContent keep status messages as text, never markup/code. + const script = `(() => { + document.getElementById('status').textContent = ${JSON.stringify(this.status.message)}; + document.getElementById('progress').value = ${this.status.progress}; + })()`; + void window.webContents.executeJavaScript(script).catch((error: unknown) => { + if (!window.isDestroyed()) console.warn('Could not update startup status:', error); + }); + } +} diff --git a/electron/jest.splash.config.cjs b/electron/jest.splash.config.cjs new file mode 100644 index 00000000..953da1a7 --- /dev/null +++ b/electron/jest.splash.config.cjs @@ -0,0 +1,11 @@ +module.exports = { + rootDir: '..', + roots: ['