Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .github/workflows/electron-build-ci.yml
Original file line number Diff line number Diff line change
@@ -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
24 changes: 24 additions & 0 deletions electron/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
130 changes: 130 additions & 0 deletions electron/SplashWindow.test.ts
Original file line number Diff line number Diff line change
@@ -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; // <img src=x onerror=alert(1)>\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();
});
});
101 changes: 101 additions & 0 deletions electron/SplashWindow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { BrowserWindow } from 'electron';
import { getAppIconPath } from './defaultPaths';

const SPLASH_HTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'">
<title>StoryCore Creative Studio</title>
<style>
body { margin: 0; padding: 32px; background: #1a1a2e; color: white;
font-family: system-ui, sans-serif; text-align: center; }
h1 { font-size: 22px; margin: 16px 0 24px; }
progress { width: 100%; accent-color: #667eea; }
#status { font-size: 14px; min-height: 36px; }
</style>
</head>
<body>
<h1>StoryCore Creative Studio</h1>
<p id="status" role="status">Starting...</p>
<progress id="progress" max="100" value="0" aria-label="Startup progress"></progress>
</body>
</html>`;

/** 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);
});
}
}
11 changes: 11 additions & 0 deletions electron/jest.splash.config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
module.exports = {
rootDir: '..',
roots: ['<rootDir>/electron'],
testEnvironment: 'node',
testMatch: ['<rootDir>/electron/SplashWindow.test.ts'],
transform: {
'^.+\\.tsx?$': ['ts-jest', {
tsconfig: '<rootDir>/electron/tsconfig.splash-test.json',
}],
},
};
9 changes: 9 additions & 0 deletions electron/tsconfig.splash-test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["node", "jest"],
"noEmit": true
},
"include": ["SplashWindow.ts", "SplashWindow.test.ts"],
"exclude": []
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
},
"scripts": {
"test": "jest",
"test:electron:splash": "jest --config electron/jest.splash.config.cjs --runInBand",
"test:ui": "jest \"./src/ui/**/*.test.tsx\"",
"cli:build": "tsc -p cli/tsconfig.json",
"cli:dev": "tsc -p cli/tsconfig.json --watch",
Expand Down
50 changes: 50 additions & 0 deletions scripts/test-electron-splash.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Run after electron:build, with Electron and a display (Xvfb on Linux CI).
const assert = require('node:assert/strict');
const { once } = require('node:events');
const { app, BrowserWindow } = require('electron');
const { SplashWindow } = require('../dist/electron/SplashWindow.js');

const timeout = setTimeout(() => {
console.error('Splash smoke test timed out');
app.exit(1);
}, 15000);

app.whenReady().then(async () => {
const splash = new SplashWindow();
const [window] = BrowserWindow.getAllWindows();
assert.ok(window, 'Splash creates a native BrowserWindow');
const loaded = once(window.webContents, 'did-finish-load');
splash.update('Initializing services...', 10);
splash.update('Connecting to backend engine...', 30);
await loaded;

const readStatus = () => window.webContents.executeJavaScript(`({
message: document.getElementById('status').textContent,
progress: document.getElementById('progress').value,
hasNode: typeof require !== 'undefined'
})`);
assert.deepEqual(await readStatus(), {
message: 'Connecting to backend engine...', progress: 30, hasNode: false,
});
const message = `Loading interface... É " <img src=x onerror=alert(1)>`;
splash.update(message, 90);
assert.deepEqual(await readStatus(), { message, progress: 90, hasNode: false });

const closed = once(window, 'closed');
splash.close();
await closed;
splash.close();
splash.update('Too late', 100);
assert.equal(BrowserWindow.getAllWindows().length, 0);
console.log('STORYCORE_SPLASH_SMOKE_PASS', JSON.stringify({
electron: process.versions.electron,
node: process.versions.node,
scope: 'splash document, progress and close lifecycle',
}));
clearTimeout(timeout);
app.exit(0);
}).catch((error) => {
console.error(error);
clearTimeout(timeout);
app.exit(1);
});
Loading