chore: dev-stack switches, shared e2e harness, and desktop shell rules
- `LIVE_RELOAD=false npm run dev` keeps the renderer alive across a machine suspend; the reload client otherwise destroys the session under test. - `dev-peer.sh` plus a separate userdata dir runs a second local peer. - `tools/voice-probe.js` samples peer state and RTP counters from a live window, persisting to localStorage so a renderer reload cannot erase it. - e2e helpers for voice pairs, peer-role election, and a TURN relay. - Electron single-instance and dev-client-load decisions move into rules files with colocated specs.
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
import { app } from 'electron';
|
||||
import * as path from 'path';
|
||||
import { createWindow, getMainWindow } from '../window/create-window';
|
||||
import { resolveSecondInstanceAction } from './second-instance.rules';
|
||||
|
||||
const CUSTOM_PROTOCOL = 'toju';
|
||||
const DEEP_LINK_PREFIX = `${CUSTOM_PROTOCOL}://`;
|
||||
const DEV_SINGLE_INSTANCE_EXIT_CODE_ENV = 'METOYOU_SINGLE_INSTANCE_EXIT_CODE';
|
||||
const DEV_RELOAD_EXISTING_ARG = '--metoyou-dev-reload-existing';
|
||||
|
||||
let pendingDeepLink: string | null = null;
|
||||
|
||||
@@ -42,6 +42,24 @@ function focusMainWindow(): void {
|
||||
mainWindow.focus();
|
||||
}
|
||||
|
||||
function reloadMainWindow(): void {
|
||||
const mainWindow = getMainWindow();
|
||||
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
void createWindow();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
focusMainWindow();
|
||||
|
||||
if (mainWindow.webContents.isLoadingMainFrame()) {
|
||||
return;
|
||||
}
|
||||
|
||||
mainWindow.webContents.reloadIgnoringCache();
|
||||
}
|
||||
|
||||
function forwardDeepLink(url: string): void {
|
||||
const mainWindow = getMainWindow();
|
||||
|
||||
@@ -96,13 +114,16 @@ export function initializeDeepLinkHandling(): boolean {
|
||||
}
|
||||
|
||||
app.on('second-instance', (_event, argv) => {
|
||||
if (resolveDevSingleInstanceExitCode() != null && argv.includes(DEV_RELOAD_EXISTING_ARG)) {
|
||||
app.relaunch();
|
||||
app.exit(0);
|
||||
return;
|
||||
}
|
||||
const action = resolveSecondInstanceAction({
|
||||
argv,
|
||||
devSingleInstanceExitCode: resolveDevSingleInstanceExitCode()
|
||||
});
|
||||
|
||||
focusMainWindow();
|
||||
if (action === 'reload-existing') {
|
||||
reloadMainWindow();
|
||||
} else {
|
||||
focusMainWindow();
|
||||
}
|
||||
|
||||
const deepLink = extractDeepLink(argv);
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { DEV_RELOAD_EXISTING_ARG, resolveSecondInstanceAction } from './second-instance.rules';
|
||||
|
||||
describe('resolveSecondInstanceAction', () => {
|
||||
it('reloads the open window when a dev launch asks to reuse it', () => {
|
||||
const action = resolveSecondInstanceAction({
|
||||
argv: [
|
||||
'electron',
|
||||
'.',
|
||||
DEV_RELOAD_EXISTING_ARG
|
||||
],
|
||||
devSingleInstanceExitCode: 23
|
||||
});
|
||||
|
||||
expect(action).toBe('reload-existing');
|
||||
});
|
||||
|
||||
it('never asks a packaged instance to reload, even with the dev argument', () => {
|
||||
const action = resolveSecondInstanceAction({
|
||||
argv: ['metoyou', DEV_RELOAD_EXISTING_ARG],
|
||||
devSingleInstanceExitCode: null
|
||||
});
|
||||
|
||||
expect(action).toBe('focus');
|
||||
});
|
||||
|
||||
it('focuses the open window for an ordinary second launch', () => {
|
||||
const action = resolveSecondInstanceAction({
|
||||
argv: [
|
||||
'electron',
|
||||
'.',
|
||||
'toju://invite/abc'
|
||||
],
|
||||
devSingleInstanceExitCode: 23
|
||||
});
|
||||
|
||||
expect(action).toBe('focus');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
export const DEV_RELOAD_EXISTING_ARG = '--metoyou-dev-reload-existing';
|
||||
|
||||
export type SecondInstanceAction = 'reload-existing' | 'focus';
|
||||
|
||||
export interface SecondInstanceInput {
|
||||
argv: string[];
|
||||
devSingleInstanceExitCode: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A dev launch always carries `--metoyou-dev-reload-existing`, so the running
|
||||
* instance reloads in place. It must never answer by relaunching itself: the
|
||||
* successor inherits the same argument and asks for the single-instance lock
|
||||
* while the dying parent still holds it, so the refused successor fires
|
||||
* `second-instance` again and the pair respawns forever.
|
||||
*/
|
||||
export function resolveSecondInstanceAction(input: SecondInstanceInput): SecondInstanceAction {
|
||||
const isDevelopmentLaunch = input.devSingleInstanceExitCode != null;
|
||||
|
||||
return isDevelopmentLaunch && input.argv.includes(DEV_RELOAD_EXISTING_ARG)
|
||||
? 'reload-existing'
|
||||
: 'focus';
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { DESKTOP_APP_DISPLAY_NAME } from '../app/desktop-branding.rules';
|
||||
import { readDesktopSettings } from '../desktop-settings';
|
||||
import { DEV_CLIENT_LOAD_ATTEMPTS, loadDevelopmentClientWithRetry } from './dev-client-load.rules';
|
||||
import { resolveDevelopmentClientUrl } from './dev-client-url.rules';
|
||||
import { shouldRegisterDisplayMediaHandler } from './display-media-handler.rules';
|
||||
|
||||
@@ -261,6 +262,52 @@ function ensureDisplayMediaRequestHandler(): void {
|
||||
);
|
||||
}
|
||||
|
||||
function describeError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function buildDevClientFailurePage(url: string, reason: string): string {
|
||||
const escapedReason = reason.replace(/&/g, '&').replace(/</g, '<');
|
||||
|
||||
return `<!doctype html>
|
||||
<html><body style="background:#0a0a0f;color:#e5e7eb;font:14px system-ui;padding:48px">
|
||||
<h1 style="font-size:18px">The dev client did not load</h1>
|
||||
<p>Could not load <code>${url}</code> after ${DEV_CLIENT_LOAD_ATTEMPTS} attempts.</p>
|
||||
<p style="color:#f87171"><code>${escapedReason}</code></p>
|
||||
<p>Check that <code>npm run dev</code> is still running, then reload with Ctrl+R.</p>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
async function loadDevelopmentClient(window: BrowserWindow, url: string): Promise<void> {
|
||||
let lastError: unknown = null;
|
||||
|
||||
const outcome = await loadDevelopmentClientWithRetry({
|
||||
isAborted: () => window.isDestroyed(),
|
||||
load: () => window.loadURL(url),
|
||||
onGiveUp: (attempts, error) => {
|
||||
lastError = error;
|
||||
console.error(`[Window] Dev client at ${url} failed after ${attempts} attempts: ${describeError(error)}`);
|
||||
},
|
||||
onRetry: (attempt, error) => {
|
||||
lastError = error;
|
||||
console.warn(`[Window] Dev client at ${url} not ready (attempt ${attempt}): ${describeError(error)}. Retrying.`);
|
||||
},
|
||||
wait: (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs))
|
||||
});
|
||||
|
||||
if (outcome !== 'failed' || window.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const failurePage = buildDevClientFailurePage(url, describeError(lastError));
|
||||
|
||||
try {
|
||||
await window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(failurePage)}`);
|
||||
} catch (error) {
|
||||
console.error(`[Window] Could not show the dev client failure page: ${describeError(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createWindow(): Promise<void> {
|
||||
const windowIconPath = getWindowIconPath();
|
||||
|
||||
@@ -290,7 +337,7 @@ export async function createWindow(): Promise<void> {
|
||||
ensureDisplayMediaRequestHandler();
|
||||
|
||||
if (process.env['NODE_ENV'] === 'development') {
|
||||
await mainWindow.loadURL(resolveDevelopmentClientUrl(process.env['SSL'] === 'true'));
|
||||
await loadDevelopmentClient(mainWindow, resolveDevelopmentClientUrl(process.env['SSL'] === 'true'));
|
||||
|
||||
if (process.env['DEBUG_DEVTOOLS'] === '1') {
|
||||
mainWindow.webContents.openDevTools();
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
DEV_CLIENT_LOAD_ATTEMPTS,
|
||||
DevClientLoadDeps,
|
||||
loadDevelopmentClientWithRetry
|
||||
} from './dev-client-load.rules';
|
||||
|
||||
function createDeps(overrides: Partial<DevClientLoadDeps> = {}): DevClientLoadDeps {
|
||||
return {
|
||||
isAborted: () => false,
|
||||
load: vi.fn().mockResolvedValue(undefined),
|
||||
onGiveUp: vi.fn(),
|
||||
onRetry: vi.fn(),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('loadDevelopmentClientWithRetry', () => {
|
||||
it('loads once when the dev server answers', async () => {
|
||||
const deps = createDeps();
|
||||
|
||||
await expect(loadDevelopmentClientWithRetry(deps)).resolves.toBe('loaded');
|
||||
expect(deps.load).toHaveBeenCalledTimes(1);
|
||||
expect(deps.onRetry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retries a rebuild gap and reports the recovered load', async () => {
|
||||
const load = vi.fn()
|
||||
.mockRejectedValueOnce(new Error('ERR_CONNECTION_REFUSED (-102)'))
|
||||
.mockResolvedValue(undefined);
|
||||
const deps = createDeps({ load });
|
||||
|
||||
await expect(loadDevelopmentClientWithRetry(deps)).resolves.toBe('loaded');
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
expect(deps.onRetry).toHaveBeenCalledTimes(1);
|
||||
expect(deps.wait).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports failure instead of throwing so the caller still wires the window', async () => {
|
||||
const error = new Error('ERR_FAILED (-2)');
|
||||
const deps = createDeps({ load: vi.fn().mockRejectedValue(error) });
|
||||
|
||||
await expect(loadDevelopmentClientWithRetry(deps)).resolves.toBe('failed');
|
||||
expect(deps.load).toHaveBeenCalledTimes(DEV_CLIENT_LOAD_ATTEMPTS);
|
||||
expect(deps.onGiveUp).toHaveBeenCalledWith(DEV_CLIENT_LOAD_ATTEMPTS, error);
|
||||
});
|
||||
|
||||
it('stops retrying once the window is gone', async () => {
|
||||
let windowAlive = true;
|
||||
|
||||
const load = vi.fn().mockImplementation(() => {
|
||||
windowAlive = false;
|
||||
|
||||
return Promise.reject(new Error('ERR_FAILED (-2)'));
|
||||
});
|
||||
const deps = createDeps({
|
||||
isAborted: () => !windowAlive,
|
||||
load
|
||||
});
|
||||
|
||||
await expect(loadDevelopmentClientWithRetry(deps)).resolves.toBe('aborted');
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
expect(deps.onGiveUp).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
export const DEV_CLIENT_LOAD_ATTEMPTS = 10;
|
||||
export const DEV_CLIENT_RETRY_DELAY_MS = 500;
|
||||
|
||||
export type DevClientLoadOutcome = 'loaded' | 'aborted' | 'failed';
|
||||
|
||||
export interface DevClientLoadDeps {
|
||||
load: () => Promise<void>;
|
||||
isAborted: () => boolean;
|
||||
wait: (delayMs: number) => Promise<void>;
|
||||
onRetry: (attempt: number, error: unknown) => void;
|
||||
onGiveUp: (attempts: number, error: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The dev client is served by a watch-mode build, so a load can fail for
|
||||
* reasons that resolve on their own: a rebuild in flight, or a shutdown that
|
||||
* aborted the navigation. Failing hard skipped every window listener
|
||||
* registered after the load and left a blank window with no message, so this
|
||||
* retries and always reports instead of throwing.
|
||||
*/
|
||||
export async function loadDevelopmentClientWithRetry(deps: DevClientLoadDeps): Promise<DevClientLoadOutcome> {
|
||||
for (let attempt = 1; attempt <= DEV_CLIENT_LOAD_ATTEMPTS; attempt += 1) {
|
||||
if (deps.isAborted()) {
|
||||
return 'aborted';
|
||||
}
|
||||
|
||||
try {
|
||||
await deps.load();
|
||||
|
||||
return 'loaded';
|
||||
} catch (error) {
|
||||
if (deps.isAborted()) {
|
||||
return 'aborted';
|
||||
}
|
||||
|
||||
if (attempt === DEV_CLIENT_LOAD_ATTEMPTS) {
|
||||
deps.onGiveUp(attempt, error);
|
||||
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
deps.onRetry(attempt, error);
|
||||
|
||||
await deps.wait(DEV_CLIENT_RETRY_DELAY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
return 'failed';
|
||||
}
|
||||
Reference in New Issue
Block a user