/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see .
*/
import fs from 'node:fs';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
import {app, BrowserWindow, desktopCapturer, ipcMain, screen, shell} from 'electron';
import log from 'electron-log';
import {BUILD_CHANNEL} from '../common/build-channel.js';
import {
CANARY_APP_URL,
DEFAULT_WINDOW_HEIGHT,
DEFAULT_WINDOW_WIDTH,
MIN_WINDOW_HEIGHT,
MIN_WINDOW_WIDTH,
STABLE_APP_URL,
} from '../common/constants.js';
import {registerSpellcheck} from './spellcheck.js';
import {refreshWindowsBadgeOverlay} from './windows-badge.js';
const VISIBILITY_MARGIN = 32;
const trustedWebOrigins = new Set(
[STABLE_APP_URL, CANARY_APP_URL]
.map((url) => {
try {
return new URL(url).origin;
} catch (error) {
log.error('Invalid trusted origin URL', {url, error});
return null;
}
})
.filter(Boolean) as Array,
);
const webAuthnDeviceTypes = new Set(['hid', 'usb', 'serial', 'bluetooth']);
const webAuthnPermissionTypes = new Set(['hid', 'usb', 'serial', 'bluetooth']);
const POPOUT_NAMESPACE = 'fluxer_';
function getOrigin(url?: string): string | null {
if (!url) return null;
try {
return new URL(url).origin;
} catch (error) {
log.warn('Invalid URL for origin check', {url, error});
return null;
}
}
function isTrustedOrigin(url?: string): boolean {
const origin = getOrigin(url);
if (!origin) return false;
return trustedWebOrigins.has(origin);
}
function getSanitizedPath(rawUrl: string): string | null {
try {
return new URL(rawUrl).pathname;
} catch (error) {
log.warn('Invalid URL for path check', {rawUrl, error});
return null;
}
}
interface WindowBounds {
x: number;
y: number;
width: number;
height: number;
isMaximized: boolean;
}
let mainWindow: BrowserWindow | null = null;
let windowStateFile: string;
let isQuitting = false;
interface PendingDisplayMediaRequest {
callback: (streams: Electron.Streams | null) => void;
}
const pendingDisplayMediaRequests = new Map();
let displayMediaRequestCounter = 0;
function setupDisplayMediaHandler(session: Electron.Session, webContents: Electron.WebContents): void {
session.setDisplayMediaRequestHandler((request, callback) => {
const requestId = `display-media-${++displayMediaRequestCounter}`;
const requestCallback = (streams: Electron.Streams | null) => callback(streams as Electron.Streams);
pendingDisplayMediaRequests.set(requestId, {callback: requestCallback});
webContents.send('display-media-requested', requestId, {
audioRequested: Boolean(request.audioRequested),
videoRequested: Boolean(request.videoRequested),
});
setTimeout(() => {
if (pendingDisplayMediaRequests.has(requestId)) {
log.warn('[DisplayMedia] Request timed out:', requestId);
pendingDisplayMediaRequests.delete(requestId);
callback(null as unknown as Electron.Streams);
}
}, 60000);
});
}
export function registerDisplayMediaHandlers(): void {
ipcMain.handle(
'get-desktop-sources',
async (
_event,
types: Array<'screen' | 'window'>,
): Promise<
Array<{
id: string;
name: string;
thumbnailDataUrl: string;
appIconDataUrl?: string;
display_id?: string;
}>
> => {
try {
const sources = await desktopCapturer.getSources({
types,
thumbnailSize: {width: 320, height: 180},
fetchWindowIcons: true,
});
return sources.map((source) => ({
id: source.id,
name: source.name,
thumbnailDataUrl: source.thumbnail.toDataURL(),
appIconDataUrl: source.appIcon?.toDataURL(),
display_id: source.display_id,
}));
} catch (error) {
log.error('[getDesktopSources] Failed:', error);
return [];
}
},
);
ipcMain.on(
'select-display-media-source',
async (_event, requestId: string, sourceId: string | null, withAudio: boolean) => {
const pending = pendingDisplayMediaRequests.get(requestId);
if (!pending) {
log.warn('[selectDisplayMediaSource] No pending request for:', requestId);
return;
}
pendingDisplayMediaRequests.delete(requestId);
if (!sourceId) {
log.info('[selectDisplayMediaSource] User cancelled');
pending.callback(null);
return;
}
try {
const sources = await desktopCapturer.getSources({
types: ['screen', 'window'],
});
const selectedSource = sources.find((s) => s.id === sourceId);
if (!selectedSource) {
log.error('[selectDisplayMediaSource] Source not found:', sourceId);
pending.callback(null);
return;
}
log.info('[selectDisplayMediaSource] Selected source:', {
id: selectedSource.id,
name: selectedSource.name,
withAudio,
});
const audioSource = withAudio && process.platform === 'darwin' ? 'loopback' : undefined;
pending.callback({
video: selectedSource,
audio: audioSource,
});
} catch (error) {
log.error('[selectDisplayMediaSource] Failed:', error);
pending.callback(null);
}
},
);
}
function getWindowStateFile(): string {
if (!windowStateFile) {
const userDataPath = app.getPath('userData');
windowStateFile = path.join(userDataPath, 'window-state.json');
}
return windowStateFile;
}
interface Bounds {
x: number;
y: number;
width: number;
height: number;
}
function boundsIntersect(a: Bounds, b: Bounds): boolean {
const aRight = a.x + a.width;
const bRight = b.x + b.width;
const aBottom = a.y + a.height;
const bBottom = b.y + b.height;
const overlapX = Math.min(aRight, bRight) - Math.max(a.x, b.x);
const overlapY = Math.min(aBottom, bBottom) - Math.max(a.y, b.y);
return overlapX > 0 && overlapY > 0;
}
function findVisibleDisplay(displays: Array, bounds: Bounds): Electron.Display | undefined {
return displays.find((display) => {
const visibleArea = {
x: display.workArea.x + VISIBILITY_MARGIN,
y: display.workArea.y + VISIBILITY_MARGIN,
width: display.workArea.width - 2 * VISIBILITY_MARGIN,
height: display.workArea.height - 2 * VISIBILITY_MARGIN,
};
return boundsIntersect(bounds, visibleArea);
});
}
function ensureWindowOnScreen(window: BrowserWindow): void {
const bounds = window.getBounds();
const displays = screen.getAllDisplays();
const visibleDisplay = findVisibleDisplay(displays, bounds);
if (!visibleDisplay && displays.length > 0) {
const primaryBounds = displays[0].bounds;
const correctedBounds = {
x: primaryBounds.x,
y: primaryBounds.y,
width: Math.min(bounds.width, primaryBounds.width),
height: Math.min(bounds.height, primaryBounds.height),
};
log.warn('Window is off-screen, repositioning to primary display:', correctedBounds);
window.setBounds(correctedBounds);
}
}
function loadWindowBounds(): Partial | null {
try {
const filePath = getWindowStateFile();
if (fs.existsSync(filePath)) {
const data = fs.readFileSync(filePath, 'utf-8');
const bounds = JSON.parse(data) as WindowBounds;
const displays = screen.getAllDisplays();
const display = findVisibleDisplay(displays, bounds);
if (display != null) {
log.info('Restored window bounds:', bounds);
return bounds;
} else {
log.warn('Saved window position is off-screen, using defaults');
}
}
} catch (error) {
log.error('Failed to load window bounds:', error);
}
return null;
}
function saveWindowBounds(): void {
if (!mainWindow) return;
try {
const bounds = mainWindow.getBounds();
const windowState: WindowBounds = {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
isMaximized: mainWindow.isMaximized(),
};
const filePath = getWindowStateFile();
fs.writeFileSync(filePath, JSON.stringify(windowState, null, 2), 'utf-8');
log.debug('Saved window bounds:', windowState);
} catch (error) {
log.error('Failed to save window bounds:', error);
}
}
export function getMainWindow(): BrowserWindow | null {
return mainWindow;
}
export function createWindow(): BrowserWindow {
const isCanary = BUILD_CHANNEL === 'canary';
const primaryDisplay = screen.getPrimaryDisplay();
const {width: screenWidth, height: screenHeight} = primaryDisplay.workAreaSize;
const savedBounds = loadWindowBounds();
const windowWidth = savedBounds?.width ?? Math.min(DEFAULT_WINDOW_WIDTH, screenWidth);
const windowHeight = savedBounds?.height ?? Math.min(DEFAULT_WINDOW_HEIGHT, screenHeight);
const isMac = process.platform === 'darwin';
const isWindows = process.platform === 'win32';
const isLinux = process.platform === 'linux';
const windowOptions: Electron.BrowserWindowConstructorOptions = {
width: windowWidth,
height: windowHeight,
minWidth: MIN_WINDOW_WIDTH,
minHeight: MIN_WINDOW_HEIGHT,
show: false,
backgroundColor: '#1a1a1a',
titleBarStyle: isMac ? 'hidden' : 'hidden',
trafficLightPosition: isMac ? {x: 9, y: 9} : undefined,
titleBarOverlay: isWindows ? false : undefined,
frame: false,
resizable: true,
webPreferences: {
preload: path.join(__dirname, '../preload/index.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
webSecurity: true,
allowRunningInsecureContent: false,
spellcheck: true,
},
};
if (isLinux) {
const baseIconName = '512x512.png';
const resourceIconPath = path.join(process.resourcesPath, baseIconName);
const exeDirIconPath = path.join(path.dirname(app.getPath('exe')), baseIconName);
if (fs.existsSync(resourceIconPath)) {
windowOptions.icon = resourceIconPath;
} else if (fs.existsSync(exeDirIconPath)) {
windowOptions.icon = exeDirIconPath;
}
}
if (savedBounds?.x !== undefined && savedBounds?.y !== undefined) {
windowOptions.x = savedBounds.x;
windowOptions.y = savedBounds.y;
} else {
windowOptions.center = true;
}
mainWindow = new BrowserWindow(windowOptions);
if (savedBounds?.isMaximized) {
mainWindow.maximize();
}
let windowShown = false;
const showWindowOnce = () => {
if (!windowShown && mainWindow) {
windowShown = true;
mainWindow.show();
}
};
mainWindow.once('ready-to-show', showWindowOnce);
setTimeout(() => {
if (!windowShown) {
log.warn('ready-to-show did not fire within 5 seconds, forcing window to show');
showWindowOnce();
}
}, 5000);
let saveTimeout: NodeJS.Timeout | null = null;
const debouncedSave = () => {
if (saveTimeout) clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => {
saveWindowBounds();
}, 500);
};
mainWindow.on('resize', debouncedSave);
mainWindow.on('move', debouncedSave);
mainWindow.on('maximize', () => {
saveWindowBounds();
mainWindow?.webContents.send('window-maximize-change', true);
});
mainWindow.on('unmaximize', () => {
saveWindowBounds();
mainWindow?.webContents.send('window-maximize-change', false);
});
mainWindow.on('close', (event) => {
if (saveTimeout) clearTimeout(saveTimeout);
saveWindowBounds();
if (process.platform === 'darwin' && !isQuitting) {
event.preventDefault();
mainWindow?.hide();
}
});
mainWindow.on('closed', () => {
mainWindow = null;
});
mainWindow.setMenuBarVisibility(false);
if (process.platform === 'win32') {
mainWindow.on('show', () => {
refreshWindowsBadgeOverlay(mainWindow);
});
}
const webContents = mainWindow.webContents;
const session = webContents.session;
registerSpellcheck(webContents);
session.setDevicePermissionHandler(({deviceType, origin}) => {
if (!origin || !isTrustedOrigin(origin)) {
return false;
}
return webAuthnDeviceTypes.has(deviceType);
});
session.on('select-hid-device', (event, details, callback) => {
const origin = details.frame?.url;
if (!isTrustedOrigin(origin)) {
return;
}
event.preventDefault();
const firstDevice = details.deviceList?.[0];
callback(firstDevice?.deviceId ?? '');
});
session.setPermissionRequestHandler((webContents, permission, callback, details) => {
const origin = details.requestingUrl || webContents.getURL();
const trusted = isTrustedOrigin(origin);
if (!trusted) {
callback(false);
return;
}
if (webAuthnPermissionTypes.has(permission)) {
callback(true);
return;
}
if (
permission === 'media' ||
permission === 'notifications' ||
permission === 'fullscreen' ||
permission === 'pointerLock' ||
permission === 'clipboard-sanitized-write'
) {
callback(true);
return;
}
callback(false);
});
session.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => {
const origin = requestingOrigin || details?.requestingUrl || webContents?.getURL();
const embeddingOrigin = details?.embeddingOrigin;
if (!webContents) return false;
if (!isTrustedOrigin(origin)) {
return false;
}
if (embeddingOrigin && !isTrustedOrigin(embeddingOrigin)) {
return false;
}
if (webAuthnPermissionTypes.has(permission)) {
return true;
}
if (
permission === 'media' ||
permission === 'notifications' ||
permission === 'fullscreen' ||
permission === 'pointerLock' ||
permission === 'clipboard-sanitized-write'
) {
return true;
}
return false;
});
setupDisplayMediaHandler(session, webContents);
const appUrl = isCanary ? CANARY_APP_URL : STABLE_APP_URL;
mainWindow.loadURL(appUrl).catch((error) => {
console.error('Failed to load app URL:', error);
});
webContents.on('will-navigate', (event, url) => {
if (!isTrustedOrigin(url)) {
event.preventDefault();
shell.openExternal(url).catch((error) => {
log.warn('Failed to open external URL from will-navigate:', error);
});
}
});
webContents.setWindowOpenHandler(({url, frameName}) => {
const pathname = getSanitizedPath(url);
if (frameName?.startsWith(POPOUT_NAMESPACE) && pathname === '/popout' && isTrustedOrigin(url)) {
const overrideBrowserWindowOptions: Electron.BrowserWindowConstructorOptions = {
titleBarStyle: isMac ? 'hidden' : undefined,
trafficLightPosition: isMac ? {x: 12, y: 5} : undefined,
frame: isLinux,
resizable: true,
backgroundColor: '#1a1a1a',
show: true,
};
return {action: 'allow', overrideBrowserWindowOptions};
}
if (isTrustedOrigin(url)) {
return {action: 'deny'};
}
shell.openExternal(url).catch((error) => {
log.warn('Failed to open external URL from window-open:', error);
});
return {action: 'deny'};
});
return mainWindow;
}
export function showWindow(): void {
if (mainWindow) {
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
ensureWindowOnScreen(mainWindow);
if (process.platform === 'darwin') {
try {
app.dock?.show();
} catch {}
try {
app.focus({steal: true});
} catch {}
try {
mainWindow.setVisibleOnAllWorkspaces(true, {visibleOnFullScreen: true});
} catch {}
mainWindow.show();
mainWindow.focus();
setTimeout(() => {
if (!mainWindow || mainWindow.isDestroyed()) return;
try {
mainWindow.setVisibleOnAllWorkspaces(false);
} catch {}
}, 250);
} else {
mainWindow.show();
mainWindow.focus();
}
}
}
export function hideWindow(): void {
if (mainWindow) {
mainWindow.hide();
}
}
export function setQuitting(quitting: boolean): void {
isQuitting = quitting;
}