Files
Toju/electron/window/create-window.ts
T
myxelium e49b3ec112 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.
2026-08-14 03:19:29 +02:00

422 lines
11 KiB
TypeScript

import {
app,
BrowserWindow,
desktopCapturer,
Menu,
session,
shell,
Tray
} from 'electron';
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';
let mainWindow: BrowserWindow | null = null;
let tray: Tray | null = null;
let closeToTrayEnabled = true;
let appQuitting = false;
let youtubeRequestHeadersConfigured = false;
let displayMediaHandlerConfigured = false;
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
const YOUTUBE_EMBED_REFERRER = 'https://toju.app/';
function ensureYoutubeEmbedRequestHeaders(): void {
if (youtubeRequestHeadersConfigured || !app.isPackaged) {
return;
}
youtubeRequestHeadersConfigured = true;
session.defaultSession.webRequest.onBeforeSendHeaders(
{
urls: [
'https://www.youtube-nocookie.com/*',
'https://www.youtube.com/*',
'https://*.youtube.com/*',
'https://*.googlevideo.com/*',
'https://*.ytimg.com/*'
]
},
(details, callback) => {
const requestHeaders = { ...details.requestHeaders };
requestHeaders['Referer'] ??= YOUTUBE_EMBED_REFERRER;
callback({ requestHeaders });
}
);
}
function getAssetPath(...segments: string[]): string {
const basePath = app.isPackaged
? path.join(process.resourcesPath, 'images')
: path.join(__dirname, '..', '..', '..', 'images');
return path.join(basePath, ...segments);
}
function getExistingAssetPath(...segments: string[]): string | undefined {
const assetPath = getAssetPath(...segments);
return fs.existsSync(assetPath) ? assetPath : undefined;
}
function getWindowIconPath(): string | undefined {
if (process.platform === 'win32')
return getExistingAssetPath('windows', 'icon.ico');
if (process.platform === 'linux')
return getExistingAssetPath('icon.png');
return undefined;
}
export function getDockIconPath(): string | undefined {
return getExistingAssetPath('macos', '1024x1024.png');
}
function getTrayIconPath(): string | undefined {
if (process.platform === 'win32')
return getExistingAssetPath('windows', 'icon.ico');
return getExistingAssetPath('icon.png');
}
export { getWindowIconPath };
export function getMainWindow(): BrowserWindow | null {
return mainWindow;
}
function destroyTray(): void {
if (!tray) {
return;
}
tray.destroy();
tray = null;
}
function requestAppQuit(): void {
prepareWindowForAppQuit();
app.quit();
}
function ensureTray(): void {
if (tray) {
return;
}
const trayIconPath = getTrayIconPath();
if (!trayIconPath) {
return;
}
tray = new Tray(trayIconPath);
tray.setToolTip('Toju');
tray.setContextMenu(
Menu.buildFromTemplate([
{
label: 'Open Toju',
click: () => {
void showMainWindow();
}
},
{
type: 'separator'
},
{
label: 'Close Toju',
click: () => {
requestAppQuit();
}
}
])
);
tray.on('click', () => {
void showMainWindow();
});
}
function hideWindowToTray(): void {
if (!mainWindow || mainWindow.isDestroyed()) {
return;
}
mainWindow.hide();
emitWindowState();
}
export function updateCloseToTraySetting(enabled: boolean): void {
closeToTrayEnabled = enabled;
}
export function prepareWindowForAppQuit(): void {
appQuitting = true;
destroyTray();
}
export async function showMainWindow(): Promise<void> {
if (!mainWindow || mainWindow.isDestroyed()) {
await createWindow();
return;
}
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
if (!mainWindow.isVisible()) {
mainWindow.show();
}
mainWindow.focus();
emitWindowState();
}
function emitWindowState(): void {
if (!mainWindow || mainWindow.isDestroyed()) {
return;
}
mainWindow.webContents.send(WINDOW_STATE_CHANGED_CHANNEL, {
isFocused: mainWindow.isFocused(),
isMinimized: mainWindow.isMinimized()
});
}
function ensureDisplayMediaRequestHandler(): void {
if (!shouldRegisterDisplayMediaHandler(process.platform, displayMediaHandlerConfigured)) {
return;
}
displayMediaHandlerConfigured = true;
if (process.platform === 'linux') {
session.defaultSession.setDisplayMediaRequestHandler(
async (_request, respond) => {
// On Linux/Wayland the system picker (useSystemPicker: true) handles
// the portal. This handler is only reached if the system picker is
// unavailable (e.g. X11 without a portal). Fall back to
// desktopCapturer so the user still gets something.
try {
const sources = await desktopCapturer.getSources({
types: ['window', 'screen'],
thumbnailSize: { width: 150, height: 150 }
});
const firstSource = sources[0];
if (firstSource) {
respond({ video: firstSource });
return;
}
} catch {
// desktopCapturer also unavailable
}
respond({});
},
{ useSystemPicker: true }
);
return;
}
session.defaultSession.setDisplayMediaRequestHandler(
async (request, respond) => {
// On Windows the system picker (useSystemPicker: true) is preferred.
// This handler is only reached when the system picker is unavailable.
// Include loopback audio when the renderer requested it so that
// getDisplayMedia receives an audio track and the renderer-side
// restrictOwnAudio constraint can keep the app's own voice playback
// out of the captured stream.
try {
const sources = await desktopCapturer.getSources({
types: ['window', 'screen'],
thumbnailSize: { width: 150, height: 150 }
});
const firstSource = sources[0];
if (firstSource) {
respond({
video: firstSource,
...(request.audioRequested ? { audio: 'loopback' } : {})
});
return;
}
} catch {
// desktopCapturer also unavailable
}
respond({});
},
{ useSystemPicker: true }
);
}
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, '&amp;').replace(/</g, '&lt;');
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();
closeToTrayEnabled = readDesktopSettings().closeToTray;
ensureTray();
ensureYoutubeEmbedRequestHeaders();
mainWindow = new BrowserWindow({
width: 1400,
height: 900,
minWidth: 800,
minHeight: 600,
frame: false,
title: DESKTOP_APP_DISPLAY_NAME,
titleBarStyle: 'hidden',
backgroundColor: '#0a0a0f',
...(windowIconPath ? { icon: windowIconPath } : {}),
webPreferences: {
backgroundThrottling: false,
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, '..', 'preload.js'),
webSecurity: true
}
});
ensureDisplayMediaRequestHandler();
if (process.env['NODE_ENV'] === 'development') {
await loadDevelopmentClient(mainWindow, resolveDevelopmentClientUrl(process.env['SSL'] === 'true'));
if (process.env['DEBUG_DEVTOOLS'] === '1') {
mainWindow.webContents.openDevTools();
}
} else {
await mainWindow.loadFile(path.join(__dirname, '..', '..', 'client', 'browser', 'index.html'));
}
mainWindow.on('close', (event) => {
if (appQuitting || !closeToTrayEnabled) {
return;
}
event.preventDefault();
hideWindowToTray();
});
mainWindow.on('closed', () => {
mainWindow = null;
});
mainWindow.on('focus', () => {
mainWindow?.flashFrame(false);
emitWindowState();
});
mainWindow.on('blur', () => {
emitWindowState();
});
mainWindow.on('minimize', () => {
emitWindowState();
});
mainWindow.on('restore', () => {
emitWindowState();
});
mainWindow.on('show', () => {
emitWindowState();
});
mainWindow.on('hide', () => {
emitWindowState();
});
emitWindowState();
mainWindow.webContents.on('context-menu', (_event, params) => {
mainWindow?.webContents.send('show-context-menu', {
posX: params.x,
posY: params.y,
isEditable: params.isEditable,
selectionText: params.selectionText,
linkURL: params.linkURL,
mediaType: params.mediaType,
srcURL: params.srcURL,
editFlags: {
canCut: params.editFlags.canCut,
canCopy: params.editFlags.canCopy,
canPaste: params.editFlags.canPaste,
canSelectAll: params.editFlags.canSelectAll
}
});
});
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };
});
mainWindow.webContents.on('will-navigate', (event, url) => {
const currentUrl = mainWindow?.webContents.getURL();
const isSameOrigin = new URL(url).origin === new URL(currentUrl || '').origin;
if (!isSameOrigin) {
event.preventDefault();
shell.openExternal(url);
}
});
}