mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
* fix(workspace): keep the macOS header clear of the lights when zoomed out App zoom scales CSS pixels but not the native traffic lights. At zoom -3/-4 the header column starts near 29 window pixels, so Back and the playlist switcher slid under the lights, and the 27px header band let the lights overlap the context panel below. A shell-level TrafficLightsClearanceDirective now publishes the lights' clearance in CSS pixels (84 x 48 window pixels, from the page zoom factor) on macOS. The header band grows to the vertical clearance (the rail starts its first link at the same band, replacing the rail's own zoom listener), and the header's leading padding grows to the horizontal clearance less the rail column. Both equal the default layout at 100 %; Windows/Linux and the phone layout are unchanged. The native position is shared with the main process as MACOS_TRAFFIC_LIGHTS_POSITION. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(workspace): pass the header clearance poll labels as options Equivalent to the string form, which Playwright 1.62 also accepts, but explicit in every version (Greptile review). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(workspace): check the header switcher before history exists Since the header's history fallback, a list reached by navigation leads with Back. The macOS check now takes the switcher on the first page, which has nothing to go back to, and Back on a detail page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: 4gray <fourgray@proton.me> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
728 lines
26 KiB
TypeScript
728 lines
26 KiB
TypeScript
import { app, BrowserWindow, Menu, screen, session, shell } from 'electron';
|
|
import {
|
|
ElectronBridgeWindowState,
|
|
MACOS_TRAFFIC_LIGHTS_POSITION,
|
|
WINDOW_STATE_CHANGED,
|
|
} from '@iptvnator/shared/interfaces';
|
|
import { join, resolve } from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import { rendererAppName, rendererAppPort } from './constants';
|
|
import {
|
|
isPerformanceCaptureEnabled,
|
|
isRendererConsoleTraceEnabled,
|
|
isSqlStatementCountEnabled,
|
|
isWindowTraceEnabled,
|
|
performanceCounters,
|
|
trace,
|
|
traceStartupPhase,
|
|
} from './services/debug-trace';
|
|
import {
|
|
MAIN_WINDOW_BACKGROUND_COLOR,
|
|
showMainWindowWhenLoaded,
|
|
} from './services/main-window-first-show';
|
|
import { attachMainWindowPerformanceCounters } from './services/performance-counters';
|
|
import {
|
|
STARTUP_WINDOW_MODE,
|
|
store,
|
|
WINDOW_BOUNDS,
|
|
} from './services/store.service';
|
|
import { isFrameCopyRuntimeUsable } from './services/embedded-mpv-frame-copy-platform.util';
|
|
import {
|
|
attachZoomLevelPersistence,
|
|
persistZoomLevel,
|
|
} from './services/window-zoom-level';
|
|
import {
|
|
attachRendererReloadFallback,
|
|
resolveRoutedRendererUrl,
|
|
restoreRendererRoute,
|
|
} from './services/renderer-reload-fallback';
|
|
import { isEmbeddedMpvFeatureEnabled } from './services/embedded-mpv-runtime-policy.util';
|
|
import {
|
|
FULLSCREEN_LAUNCH_SWITCH,
|
|
resolveStartupWindowMode,
|
|
} from './services/startup-window-mode';
|
|
import {
|
|
requestFullScreen,
|
|
trackNativeFullScreen,
|
|
} from './services/native-fullscreen-transitions';
|
|
|
|
const externalBrowserProtocols = new Set(['http:', 'https:']);
|
|
const trustedDevRendererHosts = new Set([
|
|
'localhost',
|
|
'127.0.0.1',
|
|
'[::1]',
|
|
'::1',
|
|
]);
|
|
|
|
function parseUrl(url: string): URL | null {
|
|
try {
|
|
return new URL(url);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getPackagedRendererIndexPath(): string {
|
|
return resolve(__dirname, '..', rendererAppName, 'index.html');
|
|
}
|
|
|
|
function getFilePathFromUrl(url: URL): string | null {
|
|
try {
|
|
return fileURLToPath(url);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function isExternalBrowserUrl(url: string): boolean {
|
|
const parsedUrl = parseUrl(url);
|
|
return Boolean(
|
|
parsedUrl && externalBrowserProtocols.has(parsedUrl.protocol)
|
|
);
|
|
}
|
|
|
|
export function isTrustedRendererNavigationUrl(
|
|
url: string,
|
|
isDevelopmentMode: boolean,
|
|
packagedRendererIndexPath = getPackagedRendererIndexPath()
|
|
): boolean {
|
|
const parsedUrl = parseUrl(url);
|
|
|
|
if (!parsedUrl) {
|
|
return false;
|
|
}
|
|
|
|
if (parsedUrl.protocol === 'file:') {
|
|
const filePath = getFilePathFromUrl(parsedUrl);
|
|
|
|
if (isDevelopmentMode || !filePath) {
|
|
return false;
|
|
}
|
|
|
|
return resolve(filePath) === resolve(packagedRendererIndexPath);
|
|
}
|
|
|
|
if (!isDevelopmentMode) {
|
|
return false;
|
|
}
|
|
|
|
return (
|
|
parsedUrl.protocol === 'http:' &&
|
|
trustedDevRendererHosts.has(parsedUrl.hostname) &&
|
|
parsedUrl.port === String(rendererAppPort)
|
|
);
|
|
}
|
|
|
|
export function getMainWindowWebPreferences(): Electron.BrowserWindowConstructorOptions['webPreferences'] {
|
|
// The frame-copy embedded MPV experiment needs the preload script to
|
|
// load the shm frame-reader native addon, which the renderer sandbox
|
|
// forbids. Only that opt-in flag relaxes the sandbox; context isolation
|
|
// and nodeIntegration:false stay on either way, so page code never
|
|
// gains Node access. Revisit before the engine can become a default.
|
|
const frameCopyExperiment =
|
|
isEmbeddedMpvFeatureEnabled() &&
|
|
['1', 'true', 'yes', 'on'].includes(
|
|
(process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY ?? '')
|
|
.trim()
|
|
.toLowerCase()
|
|
) && isFrameCopyRuntimeUsable();
|
|
return {
|
|
contextIsolation: true,
|
|
nodeIntegration: false,
|
|
sandbox: !frameCopyExperiment,
|
|
webSecurity: true,
|
|
// Chromium's default. A hidden or minimized window must report
|
|
// `document.hidden` so idle timers can pause and the playback
|
|
// keep-awake gate can release the display; Chromium itself keeps
|
|
// audible media and picture-in-picture running at full rate.
|
|
backgroundThrottling: true,
|
|
preload: join(__dirname, 'main.preload.js'),
|
|
};
|
|
}
|
|
|
|
export async function clearElectronServiceWorkerStorage(
|
|
electronSession: Pick<Electron.Session, 'clearStorageData'> = session.defaultSession
|
|
): Promise<void> {
|
|
try {
|
|
await electronSession.clearStorageData({
|
|
storages: ['serviceworkers', 'cachestorage'],
|
|
});
|
|
|
|
traceStartupPhase('electron-service-worker-storage:cleared');
|
|
} catch (error) {
|
|
console.warn('Failed to clear Electron service worker storage:', error);
|
|
|
|
traceStartupPhase(
|
|
'electron-service-worker-storage:clear-failed',
|
|
() => error
|
|
);
|
|
}
|
|
}
|
|
|
|
function attachWindowTrace(mainWindow: Electron.BrowserWindow): void {
|
|
if (!isWindowTraceEnabled()) {
|
|
return;
|
|
}
|
|
|
|
const webContents = mainWindow.webContents;
|
|
|
|
trace('window', 'created', {
|
|
id: mainWindow.id,
|
|
});
|
|
|
|
mainWindow.on('unresponsive', () => {
|
|
trace('window', 'unresponsive', {
|
|
id: mainWindow.id,
|
|
url: webContents.getURL(),
|
|
});
|
|
});
|
|
mainWindow.on('responsive', () => {
|
|
trace('window', 'responsive', {
|
|
id: mainWindow.id,
|
|
url: webContents.getURL(),
|
|
});
|
|
});
|
|
|
|
webContents.on('did-start-loading', () => {
|
|
trace('window', 'did-start-loading', {
|
|
id: mainWindow.id,
|
|
url: webContents.getURL(),
|
|
});
|
|
});
|
|
webContents.on('dom-ready', () => {
|
|
trace('window', 'dom-ready', {
|
|
id: mainWindow.id,
|
|
url: webContents.getURL(),
|
|
});
|
|
});
|
|
webContents.on('did-finish-load', () => {
|
|
trace('window', 'did-finish-load', {
|
|
id: mainWindow.id,
|
|
url: webContents.getURL(),
|
|
});
|
|
});
|
|
webContents.on(
|
|
'did-fail-load',
|
|
(_event, errorCode, errorDescription, validatedURL) => {
|
|
trace('window', 'did-fail-load', {
|
|
errorCode,
|
|
errorDescription,
|
|
id: mainWindow.id,
|
|
validatedURL,
|
|
});
|
|
}
|
|
);
|
|
webContents.on('did-navigate', (_event, url) => {
|
|
trace('window', 'did-navigate', {
|
|
id: mainWindow.id,
|
|
url,
|
|
});
|
|
});
|
|
webContents.on('render-process-gone', (_event, details) => {
|
|
trace('window', 'render-process-gone', {
|
|
details,
|
|
id: mainWindow.id,
|
|
url: webContents.getURL(),
|
|
});
|
|
});
|
|
|
|
if (isRendererConsoleTraceEnabled()) {
|
|
webContents.on(
|
|
'console-message',
|
|
(_event, level, message, line, sourceId) => {
|
|
trace('renderer-console', 'message', {
|
|
level,
|
|
line,
|
|
message,
|
|
sourceId,
|
|
});
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
export default class App {
|
|
// Keep a global reference of the window object, if you don't, the window will
|
|
// be closed automatically when the JavaScript object is garbage collected.
|
|
static mainWindow: Electron.BrowserWindow | null = null;
|
|
private static readonly mainWindowListeners: Array<
|
|
(mainWindow: Electron.BrowserWindow) => void
|
|
> = [];
|
|
static application: Electron.App;
|
|
static BrowserWindow;
|
|
private static loadedMainWindow: Electron.BrowserWindow | null = null;
|
|
private static mainWindowLoadPromise: Promise<void> | null = null;
|
|
/**
|
|
* Whether the `--fullscreen` launch switch has already shaped a window.
|
|
* See initMainWindow: the switch is consumed by the first window so a
|
|
* window re-created later in the same process (macOS Dock) follows the
|
|
* stored setting instead.
|
|
*/
|
|
private static launchFullscreenSwitchConsumed = false;
|
|
private static rendererLoadingEnabled = false;
|
|
|
|
private static shouldOpenDevTools() {
|
|
return process.env.ELECTRON_OPEN_DEVTOOLS === '1';
|
|
}
|
|
|
|
public static isDevelopmentMode() {
|
|
// First check ELECTRON_IS_DEV environment variable (used by E2E tests)
|
|
// This allows E2E tests to run in production mode without packaging
|
|
if ('ELECTRON_IS_DEV' in process.env) {
|
|
return parseInt(process.env.ELECTRON_IS_DEV, 10) === 1;
|
|
}
|
|
// Fall back to Electron's built-in app.isPackaged
|
|
// This is the most reliable way to detect if the app is packaged
|
|
return !app.isPackaged;
|
|
}
|
|
|
|
private static onWindowAllClosed() {
|
|
if (process.platform !== 'darwin') {
|
|
App.application.quit();
|
|
}
|
|
}
|
|
|
|
private static onClose() {
|
|
// Dereference the window object, usually you would store windows
|
|
// in an array if your app supports multi windows, this is the time
|
|
// when you should delete the corresponding element.
|
|
App.mainWindow = null;
|
|
}
|
|
|
|
private static startMainWindowLoad(): void {
|
|
void App.loadMainWindow().catch((error) => {
|
|
console.error('Failed to load main window:', error);
|
|
});
|
|
}
|
|
|
|
private static onReady() {
|
|
// This method will be called when Electron has finished
|
|
// initialization and is ready to create browser windows.
|
|
// Some APIs can only be used after this event occurs.
|
|
if (rendererAppName) {
|
|
App.initMainWindow();
|
|
if (App.rendererLoadingEnabled) {
|
|
App.startMainWindowLoad();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Registers a listener that needs the current main window, and re-runs it
|
|
* whenever a new one is created.
|
|
*
|
|
* The main window is not created once per process: on macOS the window can
|
|
* be closed and rebuilt (dock `activate`, or a second launch handed over by
|
|
* the single-instance guard) while the process lives on. Anything that
|
|
* caches the window — `download-broadcast`'s module-level reference, for
|
|
* one — would otherwise keep pointing at a destroyed window and silently
|
|
* stop delivering to the renderer. Fires immediately when a window already
|
|
* exists, so callers registering after startup do not miss the first one.
|
|
*/
|
|
static onMainWindowCreated(
|
|
listener: (mainWindow: Electron.BrowserWindow) => void
|
|
): void {
|
|
App.mainWindowListeners.push(listener);
|
|
|
|
if (App.mainWindow && !App.mainWindow.isDestroyed()) {
|
|
listener(App.mainWindow);
|
|
}
|
|
}
|
|
|
|
private static notifyMainWindowCreated(
|
|
mainWindow: Electron.BrowserWindow
|
|
): void {
|
|
for (const listener of App.mainWindowListeners) {
|
|
listener(mainWindow);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Brings the app back to a windowed state, re-creating the main window if
|
|
* it is gone.
|
|
*
|
|
* On macOS closing the last window deliberately keeps the process alive
|
|
* (`onWindowAllClosed`), so this is the recovery path for both the dock
|
|
* `activate` event and a second launch that the single-instance guard
|
|
* hands over to this process.
|
|
*/
|
|
static ensureMainWindow() {
|
|
if (App.mainWindow === null) {
|
|
App.onReady();
|
|
}
|
|
if (App.rendererLoadingEnabled) {
|
|
App.startMainWindowLoad();
|
|
}
|
|
}
|
|
|
|
private static onActivate() {
|
|
// On macOS it's common to re-create a window in the app when the
|
|
// dock icon is clicked and there are no other windows open.
|
|
App.ensureMainWindow();
|
|
}
|
|
|
|
private static handleRendererNavigation(
|
|
event: Electron.Event,
|
|
url: string
|
|
): void {
|
|
if (isTrustedRendererNavigationUrl(url, App.isDevelopmentMode())) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
|
|
// A renderer-initiated reload (`location.reload()`, e.g. the
|
|
// settings unsaved-changes guard) on an in-app route arrives here as
|
|
// a routed file:// URL with no file behind it. Send it straight to
|
|
// the packaged index with that route instead of cancelling it.
|
|
if (!App.isDevelopmentMode() && App.mainWindow) {
|
|
const rendererIndexPath = getPackagedRendererIndexPath();
|
|
const route = resolveRoutedRendererUrl(url, rendererIndexPath);
|
|
|
|
if (route !== null) {
|
|
restoreRendererRoute(App.mainWindow, rendererIndexPath, route);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (isExternalBrowserUrl(url)) {
|
|
shell.openExternal(url);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hide the native title bar on every desktop platform. macOS keeps the
|
|
* system traffic lights (overlay), while Windows/Linux rely on the
|
|
* renderer-drawn window controls (`app-window-controls`) wired up via the
|
|
* WINDOW:* IPC channels. `frame` stays untouched so native resize borders
|
|
* and snapping keep working.
|
|
*/
|
|
private static getPlatformTitleBarOptions(): Electron.BrowserWindowConstructorOptions {
|
|
if (process.platform === 'darwin') {
|
|
return {
|
|
titleBarStyle: 'hidden',
|
|
titleBarOverlay: true,
|
|
trafficLightPosition: { ...MACOS_TRAFFIC_LIGHTS_POSITION },
|
|
};
|
|
}
|
|
|
|
return { titleBarStyle: 'hidden' };
|
|
}
|
|
|
|
private static attachWindowStateEvents(win: Electron.BrowserWindow): void {
|
|
// Only Windows/Linux render custom window controls that subscribe
|
|
// to these pushes; macOS keeps the native traffic lights, so
|
|
// sending state updates there would be dead IPC traffic.
|
|
if (process.platform === 'darwin') {
|
|
return;
|
|
}
|
|
|
|
// Window state is never re-read at event time: on Windows both
|
|
// isFullScreen() and isMaximized() can still report the
|
|
// pre-transition value while the matching event fires (notably for
|
|
// HTML-element fullscreen, i.e. the video player). Since the
|
|
// renderer replaces both flags on every push and no later event
|
|
// corrects a stale one, polling left the controls hidden forever
|
|
// after leaving fullscreen — and, for the companion flag, the
|
|
// maximize/restore glyph stuck on the wrong icon.
|
|
//
|
|
// Instead the state is seeded once here (window creation, so no
|
|
// transition is in flight) and each event patches only the flag it
|
|
// names.
|
|
const state: ElectronBridgeWindowState = {
|
|
isMaximized: win.isMaximized(),
|
|
isFullScreen: win.isFullScreen(),
|
|
};
|
|
|
|
// Native (OS-level) and HTML-element fullscreen are tracked apart
|
|
// and OR-ed into the pushed flag. Electron remembers when the window
|
|
// was already natively fullscreen before the player entered HTML
|
|
// fullscreen and then leaves ONLY the HTML state on exit — no
|
|
// 'leave-full-screen' fires and the window stays fullscreen. A single
|
|
// flag cleared by 'leave-html-full-screen' would un-hide the window
|
|
// controls over a window that is still fullscreen, which a fullscreen
|
|
// launch or F11 followed by the player's F → Esc makes routine.
|
|
const fullscreen = { native: state.isFullScreen, html: false };
|
|
|
|
const push = (patch: Partial<ElectronBridgeWindowState>) => {
|
|
Object.assign(state, patch);
|
|
|
|
if (win.isDestroyed()) {
|
|
return;
|
|
}
|
|
|
|
// A copy per push: the renderer must not observe later
|
|
// mutations of the tracked state.
|
|
win.webContents.send(WINDOW_STATE_CHANGED, { ...state });
|
|
};
|
|
const pushFullScreen = () =>
|
|
push({ isFullScreen: fullscreen.native || fullscreen.html });
|
|
|
|
win.on('maximize', () => push({ isMaximized: true }));
|
|
win.on('unmaximize', () => push({ isMaximized: false }));
|
|
// The html variants cover HTML-element fullscreen; not every
|
|
// platform/trigger emits both pairs, and duplicate pushes with the
|
|
// same payload are harmless.
|
|
win.on('enter-full-screen', () => {
|
|
fullscreen.native = true;
|
|
pushFullScreen();
|
|
});
|
|
win.on('leave-full-screen', () => {
|
|
fullscreen.native = false;
|
|
pushFullScreen();
|
|
});
|
|
win.on('enter-html-full-screen', () => {
|
|
fullscreen.html = true;
|
|
pushFullScreen();
|
|
});
|
|
win.on('leave-html-full-screen', () => {
|
|
fullscreen.html = false;
|
|
pushFullScreen();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Persist the state restored on the next launch: window bounds and the
|
|
* zoom level. Runs from both the window 'close' and app 'before-quit'
|
|
* handlers, since either can be the last to run before the process ends.
|
|
* The zoom half is a no-op until the window's preload took ownership of
|
|
* the level (`services/window-zoom-level.ts`).
|
|
*/
|
|
private static persistWindowState(win: Electron.BrowserWindow): void {
|
|
store.set(WINDOW_BOUNDS, win.getNormalBounds());
|
|
persistZoomLevel(win);
|
|
}
|
|
|
|
private static initMainWindow() {
|
|
const workAreaSize = screen.getPrimaryDisplay().workAreaSize;
|
|
const width = Math.min(1280, workAreaSize.width || 1280);
|
|
const height = Math.min(720, workAreaSize.height || 720);
|
|
|
|
const savedWindowBounds = store.get(WINDOW_BOUNDS);
|
|
const startupWindowMode = resolveStartupWindowMode({
|
|
// One-shot: the switch describes the launch, not every window
|
|
// this process ever opens. On macOS the process outlives its
|
|
// last window and the Dock re-creates it through this same
|
|
// path, which must then follow the stored setting only.
|
|
cliHasFullscreenSwitch:
|
|
!App.launchFullscreenSwitchConsumed &&
|
|
app.commandLine.hasSwitch(FULLSCREEN_LAUNCH_SWITCH),
|
|
storedMode: store.get(STARTUP_WINDOW_MODE),
|
|
});
|
|
App.launchFullscreenSwitchConsumed = true;
|
|
|
|
// Create the browser window.
|
|
App.mainWindow = new BrowserWindow({
|
|
title: 'IPTVnator',
|
|
width: width,
|
|
height: height,
|
|
show: false,
|
|
// The splash colour: the window can be shown before its first
|
|
// paint (main-window-first-show.ts).
|
|
backgroundColor: MAIN_WINDOW_BACKGROUND_COLOR,
|
|
webPreferences: getMainWindowWebPreferences(),
|
|
...savedWindowBounds,
|
|
// Fullscreen is a constructor option: the window is created
|
|
// hidden and enters fullscreen before its first paint. The saved
|
|
// bounds stay spread in above — they are the normal bounds the
|
|
// window returns to, and the close handler keeps persisting
|
|
// getNormalBounds(), so a fullscreen session never corrupts them.
|
|
...(startupWindowMode === 'fullscreen' ? { fullscreen: true } : {}),
|
|
minHeight: 600,
|
|
minWidth: 900,
|
|
...App.getPlatformTitleBarOptions(),
|
|
});
|
|
App.mainWindow.setMenu(null);
|
|
attachMainWindowPerformanceCounters(
|
|
App.mainWindow,
|
|
performanceCounters,
|
|
{
|
|
capture: isPerformanceCaptureEnabled(),
|
|
sqlStatements: isSqlStatementCountEnabled(),
|
|
}
|
|
);
|
|
attachWindowTrace(App.mainWindow);
|
|
App.attachWindowStateEvents(App.mainWindow);
|
|
// Seeds the F11 tracker's fullscreen state now, while no transition
|
|
// can be in flight; from here on it follows the window's events.
|
|
trackNativeFullScreen(App.mainWindow);
|
|
App.notifyMainWindowCreated(App.mainWindow);
|
|
if (!savedWindowBounds) {
|
|
App.mainWindow.center();
|
|
}
|
|
|
|
// Shown at ready-to-show or did-finish-load, whichever comes first
|
|
// (see main-window-first-show.ts).
|
|
const mainWindow = App.mainWindow;
|
|
showMainWindowWhenLoaded(mainWindow, () => {
|
|
// maximize() on a hidden window shows it (Electron docs), so it
|
|
// waits for the document like show() does — any earlier and a
|
|
// blank window flashes before the splash is there.
|
|
if (startupWindowMode === 'maximized') {
|
|
mainWindow.maximize();
|
|
}
|
|
mainWindow.show();
|
|
// macOS ignores the constructor's `fullscreen` while the window
|
|
// is hidden — an NSWindow can only toggle fullscreen once it is
|
|
// on screen — so the request is repeated after show() wherever
|
|
// it has not taken yet. Windows/Linux honoured it at creation
|
|
// (before the first paint) and are left alone. It goes through
|
|
// the transition tracker so an F11 pressed during the animation
|
|
// reads the pending target and leaves fullscreen instead of
|
|
// asking for it again.
|
|
if (
|
|
startupWindowMode === 'fullscreen' &&
|
|
!mainWindow.isFullScreen()
|
|
) {
|
|
requestFullScreen(mainWindow, true);
|
|
}
|
|
});
|
|
|
|
// Route target="_blank" / window.open() to the OS default browser
|
|
App.mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
|
if (isExternalBrowserUrl(url)) {
|
|
shell.openExternal(url);
|
|
}
|
|
return { action: 'deny' };
|
|
});
|
|
|
|
App.mainWindow.webContents.on(
|
|
'will-navigate',
|
|
App.handleRendererNavigation
|
|
);
|
|
App.mainWindow.webContents.on(
|
|
'will-redirect',
|
|
App.handleRendererNavigation
|
|
);
|
|
|
|
// The preload restores the zoom level on every document load; the
|
|
// main process only saves it back before a reload drops it.
|
|
attachZoomLevelPersistence(App.mainWindow);
|
|
|
|
// A reload on an in-app route asks file:// for a path that does not
|
|
// exist; re-load the packaged index with that route instead of
|
|
// leaving Chromium's error page (see renderer-reload-fallback.ts).
|
|
attachRendererReloadFallback(
|
|
App.mainWindow,
|
|
getPackagedRendererIndexPath()
|
|
);
|
|
|
|
// Emitted when the window is closed.
|
|
App.mainWindow.on('closed', () => {
|
|
// Dereference the window object, usually you would store windows
|
|
// in an array if your app supports multi windows, this is the time
|
|
// when you should delete the corresponding element.
|
|
App.mainWindow = null;
|
|
App.loadedMainWindow = null;
|
|
App.mainWindowLoadPromise = null;
|
|
});
|
|
|
|
App.mainWindow.on('close', () => {
|
|
if (App.mainWindow) {
|
|
App.persistWindowState(App.mainWindow);
|
|
}
|
|
});
|
|
|
|
// Enable context menu for input fields only
|
|
App.mainWindow.webContents.on('context-menu', (event, params) => {
|
|
const { isEditable, editFlags } = params;
|
|
|
|
// Check if this is an editable field (input, textarea, contenteditable)
|
|
// editFlags.canPaste is a good indicator of an input field
|
|
if (isEditable && editFlags.canPaste) {
|
|
const menu = Menu.buildFromTemplate([
|
|
{
|
|
label: 'Cut',
|
|
role: 'cut',
|
|
enabled: editFlags.canCut,
|
|
},
|
|
{
|
|
label: 'Copy',
|
|
role: 'copy',
|
|
enabled: editFlags.canCopy,
|
|
},
|
|
{
|
|
label: 'Paste',
|
|
role: 'paste',
|
|
enabled: editFlags.canPaste,
|
|
},
|
|
{
|
|
type: 'separator',
|
|
},
|
|
{
|
|
label: 'Select All',
|
|
role: 'selectAll',
|
|
enabled: editFlags.canSelectAll,
|
|
},
|
|
]);
|
|
|
|
menu.popup();
|
|
}
|
|
});
|
|
}
|
|
|
|
private static async loadMainWindowContent(
|
|
mainWindow: Electron.BrowserWindow
|
|
): Promise<void> {
|
|
// load the index.html of the app.
|
|
if (App.isDevelopmentMode()) {
|
|
const loadPromise = mainWindow.loadURL(
|
|
`http://localhost:${rendererAppPort}`
|
|
);
|
|
if (App.shouldOpenDevTools()) {
|
|
mainWindow.webContents.openDevTools();
|
|
}
|
|
await loadPromise;
|
|
} else {
|
|
await clearElectronServiceWorkerStorage();
|
|
await mainWindow.loadFile(getPackagedRendererIndexPath());
|
|
}
|
|
}
|
|
|
|
static async loadMainWindow(): Promise<void> {
|
|
App.rendererLoadingEnabled = true;
|
|
|
|
if (!rendererAppName || !App.mainWindow) {
|
|
return;
|
|
}
|
|
|
|
if (App.loadedMainWindow === App.mainWindow) {
|
|
return;
|
|
}
|
|
|
|
if (!App.mainWindowLoadPromise) {
|
|
const mainWindow = App.mainWindow;
|
|
App.mainWindowLoadPromise = App.loadMainWindowContent(mainWindow)
|
|
.then(() => {
|
|
App.loadedMainWindow = mainWindow;
|
|
})
|
|
.finally(() => {
|
|
App.mainWindowLoadPromise = null;
|
|
});
|
|
}
|
|
|
|
await App.mainWindowLoadPromise;
|
|
}
|
|
|
|
static main(app: Electron.App, browserWindow: typeof BrowserWindow) {
|
|
// we pass the Electron.App object and the
|
|
// Electron.BrowserWindow into this function
|
|
// so this class has no dependencies. This
|
|
// makes the code easier to write tests for
|
|
|
|
App.BrowserWindow = browserWindow;
|
|
App.application = app;
|
|
|
|
App.application.on('window-all-closed', App.onWindowAllClosed); // Quit when all windows are closed.
|
|
if (App.application.isReady()) {
|
|
App.onReady();
|
|
} else {
|
|
App.application.on('ready', App.onReady); // App is ready to load data
|
|
}
|
|
App.application.on('activate', App.onActivate); // App is activated
|
|
App.application.on('before-quit', () => {
|
|
if (App.mainWindow) App.persistWindowState(App.mainWindow);
|
|
});
|
|
}
|
|
}
|