initial commit

This commit is contained in:
Hampus Kraft
2026-01-01 20:42:59 +00:00
commit 2f557eda8c
9029 changed files with 1490197 additions and 0 deletions

View File

@@ -0,0 +1,290 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import http from 'node:http';
import https from 'node:https';
import log from 'electron-log';
import {BUILD_CHANNEL} from '../common/build-channel.js';
import {CANARY_APP_URL, STABLE_APP_URL} from '../common/constants.js';
export const API_PROXY_PORT = BUILD_CHANNEL === 'canary' ? 21862 : 21861;
const PROXY_PATH = '/proxy';
const PROXY_INITIATOR_HEADER = 'x-fluxer-proxy-initiator';
const ALLOWED_ORIGINS = [STABLE_APP_URL, CANARY_APP_URL];
const isAllowedOrigin = (origin?: string): boolean => {
if (!origin) return false;
return ALLOWED_ORIGINS.includes(origin);
};
const refererMatchesAllowedOrigin = (referer?: string): boolean => {
if (!referer) return false;
return ALLOWED_ORIGINS.some((allowed) => referer.startsWith(allowed));
};
const parseOrigin = (value?: string): string | null => {
if (!value) {
return null;
}
try {
return new URL(value).origin;
} catch {
return null;
}
};
const sanitizeUpstreamHeaders = (headers: http.IncomingHttpHeaders): Record<string, string> => {
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(headers)) {
if (!key) continue;
if (typeof value === 'string') {
out[key.toLowerCase()] = value;
} else if (Array.isArray(value)) {
out[key.toLowerCase()] = value.join(', ');
}
}
return out;
};
const buildForwardHeaders = (req: http.IncomingMessage, targetUrl: URL): Record<string, string> => {
const headers = sanitizeUpstreamHeaders(req.headers);
delete headers.host;
delete headers.connection;
delete headers.origin;
delete headers.referer;
delete headers['sec-fetch-site'];
delete headers['sec-fetch-mode'];
delete headers['sec-fetch-dest'];
delete headers['proxy-connection'];
delete headers[PROXY_INITIATOR_HEADER];
headers.host = targetUrl.host;
headers.referer = targetUrl.origin;
return headers;
};
const setCorsHeaders = (res: http.ServerResponse, origin?: string) => {
if (origin) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Vary', 'Origin');
}
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader(
'Access-Control-Allow-Headers',
'Authorization,Content-Type,Accept,Origin,X-Requested-With,X-Fluxer-Proxy-Initiator',
);
res.setHeader('Access-Control-Expose-Headers', 'x-fluxer-sudo-mode-jwt,x-request-id,content-type');
};
const rejectIfDisallowedPage = (
req: http.IncomingMessage,
res: http.ServerResponse,
targetUrl?: URL,
initiator?: string,
): boolean => {
const origin = req.headers.origin;
const referer = req.headers.referer;
const initiatorHeader = initiator ? initiator : undefined;
const targetOrigin = targetUrl?.origin;
if (!origin && !referer && !initiatorHeader) {
res.writeHead(403);
res.end();
return true;
}
const originCandidate = origin ?? parseOrigin(initiatorHeader ?? undefined);
if (originCandidate && !isAllowedOrigin(originCandidate) && originCandidate !== targetOrigin) {
res.writeHead(403);
res.end();
return true;
}
const refererCandidate = referer ?? initiatorHeader;
if (
refererCandidate &&
!refererMatchesAllowedOrigin(refererCandidate) &&
!(targetOrigin && refererCandidate.startsWith(targetOrigin))
) {
res.writeHead(403);
res.end();
return true;
}
if (originCandidate && refererCandidate && !refererCandidate.startsWith(originCandidate)) {
res.writeHead(403);
res.end();
return true;
}
return false;
};
const isValidTargetUrl = (raw: string | null): raw is string => {
if (!raw) return false;
try {
const parsed = new URL(raw);
return parsed.protocol === 'https:' || parsed.protocol === 'http:';
} catch {
return false;
}
};
const pipeRequest = (
req: http.IncomingMessage,
res: http.ServerResponse,
targetUrl: string,
_retriesRemaining: number,
): void => {
const parsedTarget = new URL(targetUrl);
const agent = parsedTarget.protocol === 'https:' ? https : http;
const method = (req.method ?? 'GET').toUpperCase();
const requestOptions: http.RequestOptions = {
hostname: parsedTarget.hostname,
port: parsedTarget.port || (parsedTarget.protocol === 'https:' ? 443 : 80),
path: parsedTarget.pathname + parsedTarget.search,
method,
headers: buildForwardHeaders(req, parsedTarget),
};
const upstreamReq = agent.request(requestOptions, (upstreamRes) => {
const status = upstreamRes.statusCode ?? 502;
const headers = sanitizeUpstreamHeaders(upstreamRes.headers);
delete headers.connection;
delete headers['proxy-connection'];
delete headers['transfer-encoding'];
headers['Access-Control-Allow-Origin'] = req.headers.origin ?? req.headers.referer ?? 'https://web.fluxer.app';
headers['Access-Control-Allow-Credentials'] = 'true';
headers['Access-Control-Expose-Headers'] = 'x-fluxer-sudo-mode-jwt,x-request-id,content-type';
headers.Vary = 'Origin';
res.writeHead(status, headers);
upstreamRes.pipe(res);
});
upstreamReq.on('error', (error: Error) => {
log.error('[API Proxy] Upstream request error:', error);
if (!res.headersSent) {
setCorsHeaders(res, req.headers.origin ?? req.headers.referer);
res.writeHead(502, {'Content-Type': 'text/plain'});
}
res.end('Bad Gateway');
});
res.on('close', () => {
try {
upstreamReq.destroy();
} catch {}
});
req.pipe(upstreamReq);
};
let server: http.Server | null = null;
export const startApiProxyServer = (): Promise<void> => {
return new Promise((resolve, reject) => {
if (server) {
resolve();
return;
}
server = http.createServer((req, res) => {
const remoteAddress = req.socket.remoteAddress;
if (remoteAddress !== '127.0.0.1' && remoteAddress !== '::1' && remoteAddress !== '::ffff:127.0.0.1') {
res.writeHead(403);
res.end();
return;
}
const requestUrl = new URL(req.url ?? '/', `http://${req.headers.host}`);
if (requestUrl.pathname !== PROXY_PATH) {
res.writeHead(404);
res.end();
return;
}
const target = requestUrl.searchParams.get('target');
if (!isValidTargetUrl(target)) {
res.writeHead(400, {'Content-Type': 'text/plain'});
res.end('Missing or invalid target');
return;
}
const targetUrl = new URL(target);
const initiator = req.headers[PROXY_INITIATOR_HEADER];
const initiatorHeader = Array.isArray(initiator) ? initiator[0] : initiator;
if (rejectIfDisallowedPage(req, res, targetUrl, initiatorHeader ?? undefined)) {
return;
}
setCorsHeaders(res, req.headers.origin ?? initiatorHeader ?? req.headers.referer ?? undefined);
if (req.method?.toUpperCase() === 'OPTIONS') {
res.setHeader('Access-Control-Allow-Methods', 'GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS');
res.writeHead(204);
res.end();
return;
}
pipeRequest(req, res, target, 5);
});
server.on('error', (error: NodeJS.ErrnoException) => {
if (error.code === 'EADDRINUSE') {
log.warn(`[API Proxy] Port ${API_PROXY_PORT} already in use, API proxy disabled`);
server = null;
resolve();
} else {
log.error('[API Proxy] Server error:', error);
reject(error);
}
});
server.listen(API_PROXY_PORT, '127.0.0.1', () => {
log.info(`[API Proxy] Server listening on http://127.0.0.1:${API_PROXY_PORT}${PROXY_PATH}`);
resolve();
});
});
};
export const stopApiProxyServer = (): Promise<void> => {
return new Promise((resolve) => {
if (!server) {
resolve();
return;
}
server.close((err) => {
if (err) {
log.error('[API Proxy] Error closing server:', err);
}
server = null;
resolve();
});
});
};

View File

@@ -0,0 +1,122 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import fs from 'node:fs';
import path from 'node:path';
import {app, ipcMain} from 'electron';
import {BUILD_CHANNEL} from '../common/build-channel.js';
const AUTOSTART_INITIALIZED_FILE = 'autostart-initialized';
function getInitializedFilePath(): string {
return path.join(app.getPath('userData'), AUTOSTART_INITIALIZED_FILE);
}
function isInitialized(): boolean {
try {
return fs.existsSync(getInitializedFilePath());
} catch {
return false;
}
}
function markInitialized(): void {
try {
fs.writeFileSync(getInitializedFilePath(), '1', 'utf8');
} catch {}
}
const isMac = process.platform === 'darwin';
interface AutoLaunchConfig {
name: string;
path: string;
isHidden: boolean;
args?: Array<string>;
}
function getAutoLaunchConfig(): AutoLaunchConfig {
const isCanary = BUILD_CHANNEL === 'canary';
const appName = isCanary ? 'Fluxer Canary' : 'Fluxer';
return {
name: appName,
path: process.execPath,
isHidden: true,
args: [],
};
}
async function enableAutostart(): Promise<void> {
if (!isMac) return;
const config = getAutoLaunchConfig();
app.setLoginItemSettings({
openAtLogin: true,
openAsHidden: config.isHidden,
name: config.name,
});
}
async function disableAutostart(): Promise<void> {
if (!isMac) return;
const config = getAutoLaunchConfig();
app.setLoginItemSettings({
openAtLogin: false,
name: config.name,
path: config.path,
args: config.args,
});
}
async function isAutostartEnabled(): Promise<boolean> {
if (!isMac) return false;
const config = getAutoLaunchConfig();
const settings = app.getLoginItemSettings({
path: config.path,
args: config.args,
});
return settings.openAtLogin;
}
export function registerAutostartHandlers(): void {
if (!isMac) return;
ipcMain.handle('autostart-enable', async (): Promise<void> => {
await enableAutostart();
});
ipcMain.handle('autostart-disable', async (): Promise<void> => {
await disableAutostart();
});
ipcMain.handle('autostart-is-enabled', async (): Promise<boolean> => {
return isAutostartEnabled();
});
ipcMain.handle('autostart-is-initialized', (): boolean => {
return isInitialized();
});
ipcMain.handle('autostart-mark-initialized', (): void => {
markInitialized();
});
}

View File

@@ -0,0 +1,72 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {app, ipcMain} from 'electron';
import {APP_PROTOCOL} from '../common/constants.js';
import {getMainWindow, showWindow} from './window.js';
let initialDeepLink: string | null = null;
export function initializeDeepLinks(): void {
if (process.defaultApp) {
if (process.argv.length >= 2) {
app.setAsDefaultProtocolClient(APP_PROTOCOL, process.execPath, [process.argv[1]]);
}
} else {
app.setAsDefaultProtocolClient(APP_PROTOCOL);
}
const deepLinkArg = process.argv.find((arg) => arg.startsWith(`${APP_PROTOCOL}://`));
if (deepLinkArg) {
initialDeepLink = deepLinkArg;
}
ipcMain.handle('get-initial-deep-link', (): string | null => {
const url = initialDeepLink;
initialDeepLink = null;
return url;
});
}
export function handleOpenUrl(url: string): void {
const mainWindow = getMainWindow();
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('deep-link', url);
showWindow();
} else {
initialDeepLink = url;
}
}
export function handleSecondInstance(argv: Array<string>): void {
const url = argv.find((arg) => arg.startsWith(`${APP_PROTOCOL}://`));
if (url) {
const mainWindow = getMainWindow();
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('deep-link', url);
showWindow();
} else {
initialDeepLink = url;
}
} else {
showWindow();
}
}

View File

@@ -0,0 +1,337 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {createRequire} from 'node:module';
import {ipcMain} from 'electron';
import type {UiohookKeyboardEvent, UiohookMouseEvent} from 'uiohook-napi';
import {getMainWindow} from './window.js';
let uIOhook: typeof import('uiohook-napi').uIOhook | null = null;
let UiohookKey: typeof import('uiohook-napi').UiohookKey | null = null;
interface KeybindRegistration {
id: string;
keycode: number;
mouseButton?: number;
modifiers: {
ctrl: boolean;
alt: boolean;
shift: boolean;
meta: boolean;
};
}
const registeredKeybinds = new Map<string, KeybindRegistration>();
const activeKeys = new Set<number>();
let hookStarted = false;
const requireModule = createRequire(import.meta.url);
function keycodeToKeyName(keycode: number): string {
if (!UiohookKey) return `Key${keycode}`;
const keyMap: Record<number, string> = {
[UiohookKey.Escape]: 'Escape',
[UiohookKey.F1]: 'F1',
[UiohookKey.F2]: 'F2',
[UiohookKey.F3]: 'F3',
[UiohookKey.F4]: 'F4',
[UiohookKey.F5]: 'F5',
[UiohookKey.F6]: 'F6',
[UiohookKey.F7]: 'F7',
[UiohookKey.F8]: 'F8',
[UiohookKey.F9]: 'F9',
[UiohookKey.F10]: 'F10',
[UiohookKey.F11]: 'F11',
[UiohookKey.F12]: 'F12',
[UiohookKey.Backquote]: 'Backquote',
[UiohookKey['1']]: '1',
[UiohookKey['2']]: '2',
[UiohookKey['3']]: '3',
[UiohookKey['4']]: '4',
[UiohookKey['5']]: '5',
[UiohookKey['6']]: '6',
[UiohookKey['7']]: '7',
[UiohookKey['8']]: '8',
[UiohookKey['9']]: '9',
[UiohookKey['0']]: '0',
[UiohookKey.Minus]: 'Minus',
[UiohookKey.Equal]: 'Equal',
[UiohookKey.Backspace]: 'Backspace',
[UiohookKey.Tab]: 'Tab',
[UiohookKey.Q]: 'Q',
[UiohookKey.W]: 'W',
[UiohookKey.E]: 'E',
[UiohookKey.R]: 'R',
[UiohookKey.T]: 'T',
[UiohookKey.Y]: 'Y',
[UiohookKey.U]: 'U',
[UiohookKey.I]: 'I',
[UiohookKey.O]: 'O',
[UiohookKey.P]: 'P',
[UiohookKey.BracketLeft]: 'BracketLeft',
[UiohookKey.BracketRight]: 'BracketRight',
[UiohookKey.Backslash]: 'Backslash',
[UiohookKey.CapsLock]: 'CapsLock',
[UiohookKey.A]: 'A',
[UiohookKey.S]: 'S',
[UiohookKey.D]: 'D',
[UiohookKey.F]: 'F',
[UiohookKey.G]: 'G',
[UiohookKey.H]: 'H',
[UiohookKey.J]: 'J',
[UiohookKey.K]: 'K',
[UiohookKey.L]: 'L',
[UiohookKey.Semicolon]: 'Semicolon',
[UiohookKey.Quote]: 'Quote',
[UiohookKey.Enter]: 'Enter',
[UiohookKey.Shift]: 'ShiftLeft',
[UiohookKey.Z]: 'Z',
[UiohookKey.X]: 'X',
[UiohookKey.C]: 'C',
[UiohookKey.V]: 'V',
[UiohookKey.B]: 'B',
[UiohookKey.N]: 'N',
[UiohookKey.M]: 'M',
[UiohookKey.Comma]: 'Comma',
[UiohookKey.Period]: 'Period',
[UiohookKey.Slash]: 'Slash',
[UiohookKey.ShiftRight]: 'ShiftRight',
[UiohookKey.Ctrl]: 'ControlLeft',
[UiohookKey.Meta]: 'MetaLeft',
[UiohookKey.Alt]: 'AltLeft',
[UiohookKey.Space]: 'Space',
[UiohookKey.AltRight]: 'AltRight',
[UiohookKey.MetaRight]: 'MetaRight',
[UiohookKey.CtrlRight]: 'ControlRight',
[UiohookKey.ArrowLeft]: 'ArrowLeft',
[UiohookKey.ArrowUp]: 'ArrowUp',
[UiohookKey.ArrowRight]: 'ArrowRight',
[UiohookKey.ArrowDown]: 'ArrowDown',
[UiohookKey.Insert]: 'Insert',
[UiohookKey.Delete]: 'Delete',
[UiohookKey.Home]: 'Home',
[UiohookKey.End]: 'End',
[UiohookKey.PageUp]: 'PageUp',
[UiohookKey.PageDown]: 'PageDown',
};
return keyMap[keycode] ?? `Key${keycode}`;
}
function handleKeyEvent(event: UiohookKeyboardEvent, type: 'keydown' | 'keyup') {
const mainWindow = getMainWindow();
if (!mainWindow) return;
const keycode = event.keycode;
const keyName = keycodeToKeyName(keycode);
if (type === 'keydown') {
activeKeys.add(keycode);
} else {
activeKeys.delete(keycode);
}
mainWindow.webContents.send('global-key-event', {
type,
keycode,
keyName,
altKey: event.altKey,
ctrlKey: event.ctrlKey,
shiftKey: event.shiftKey,
metaKey: event.metaKey,
});
for (const [id, keybind] of registeredKeybinds) {
if (keybind.keycode === keycode) {
const modifiersMatch =
keybind.modifiers.ctrl === event.ctrlKey &&
keybind.modifiers.alt === event.altKey &&
keybind.modifiers.shift === event.shiftKey &&
keybind.modifiers.meta === event.metaKey;
if (modifiersMatch || !Object.values(keybind.modifiers).some(Boolean)) {
mainWindow.webContents.send('global-keybind-triggered', {
id,
type,
});
}
}
}
}
function handleMouseEvent(event: UiohookMouseEvent, type: 'mousedown' | 'mouseup') {
const mainWindow = getMainWindow();
if (!mainWindow) return;
const button = event.button;
mainWindow.webContents.send('global-mouse-event', {
type,
button,
});
for (const [id, keybind] of registeredKeybinds) {
if (keybind.mouseButton === button) {
mainWindow.webContents.send('global-keybind-triggered', {
id,
type: type === 'mousedown' ? 'keydown' : 'keyup',
});
}
}
}
async function startHook(): Promise<boolean> {
if (hookStarted) return true;
try {
const uiohookModule = await import('uiohook-napi');
uIOhook = uiohookModule.uIOhook;
UiohookKey = uiohookModule.UiohookKey;
uIOhook.on('keydown', (event) => handleKeyEvent(event, 'keydown'));
uIOhook.on('keyup', (event) => handleKeyEvent(event, 'keyup'));
uIOhook.on('mousedown', (event) => handleMouseEvent(event, 'mousedown'));
uIOhook.on('mouseup', (event) => handleMouseEvent(event, 'mouseup'));
uIOhook.start();
hookStarted = true;
return true;
} catch (error) {
console.error('[GlobalKeyHook] Failed to start:', error);
return false;
}
}
const INPUT_MONITORING_PERMISSION = 'input-monitoring';
const INPUT_MONITORING_STATUS_ALLOWLIST = new Set(['authorized', 'not-determined']);
function getInputMonitoringStatus(): string | null {
const permissionsModule = (() => {
try {
return requireModule('node-mac-permissions');
} catch (error) {
console.error('[GlobalKeyHook] Failed to load node-mac-permissions:', error);
return null;
}
})();
if (!permissionsModule || typeof permissionsModule.getAuthStatus !== 'function') {
return null;
}
try {
return permissionsModule.getAuthStatus(INPUT_MONITORING_PERMISSION);
} catch (error) {
console.error('[GlobalKeyHook] Failed to query Input Monitoring auth status:', error);
return null;
}
}
async function checkInputMonitoringAccess(): Promise<boolean> {
if (process.platform !== 'darwin') {
return true;
}
if (hookStarted) {
return true;
}
const status = getInputMonitoringStatus();
if (status === null) {
return true;
}
if (INPUT_MONITORING_STATUS_ALLOWLIST.has(status)) {
return true;
}
console.warn('[GlobalKeyHook] Input Monitoring access denied, status:', status);
return false;
}
function stopHook(): void {
if (!hookStarted || !uIOhook) return;
try {
uIOhook.stop();
hookStarted = false;
} catch (error) {
console.error('[GlobalKeyHook] Failed to stop:', error);
}
}
export function registerGlobalKeyHookHandlers(): void {
ipcMain.handle('global-key-hook-start', async (): Promise<boolean> => {
return startHook();
});
ipcMain.handle('global-key-hook-stop', (): void => {
stopHook();
});
ipcMain.handle('global-key-hook-is-running', (): boolean => {
return hookStarted;
});
ipcMain.handle('check-input-monitoring-access', async (): Promise<boolean> => {
return checkInputMonitoringAccess();
});
ipcMain.handle(
'global-key-hook-register',
(
_event,
options: {
id: string;
keycode?: number;
mouseButton?: number;
ctrl?: boolean;
alt?: boolean;
shift?: boolean;
meta?: boolean;
},
): void => {
registeredKeybinds.set(options.id, {
id: options.id,
keycode: options.keycode ?? 0,
mouseButton: options.mouseButton,
modifiers: {
ctrl: options.ctrl ?? false,
alt: options.alt ?? false,
shift: options.shift ?? false,
meta: options.meta ?? false,
},
});
},
);
ipcMain.handle('global-key-hook-unregister', (_event, id: string): void => {
registeredKeybinds.delete(id);
});
ipcMain.handle('global-key-hook-unregister-all', (): void => {
registeredKeybinds.clear();
});
}
export function cleanupGlobalKeyHook(): void {
stopHook();
registeredKeybinds.clear();
activeKeys.clear();
}

View File

@@ -0,0 +1,188 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {createRequire} from 'node:module';
import {app, globalShortcut} from 'electron';
import log from 'electron-log';
import {BUILD_CHANNEL} from '../common/build-channel.js';
import {configureUserDataPath} from '../common/user-data-path.js';
import {startApiProxyServer, stopApiProxyServer} from './api-proxy-server.js';
import {registerAutostartHandlers} from './autostart.js';
import {handleOpenUrl, handleSecondInstance, initializeDeepLinks} from './deep-links.js';
import {cleanupGlobalKeyHook, registerGlobalKeyHookHandlers} from './global-key-hook.js';
import {cleanupIpcHandlers, registerIpcHandlers} from './ipc-handlers.js';
import {startMediaProxyServer, stopMediaProxyServer} from './media-proxy-server.js';
import {createApplicationMenu} from './menu.js';
import {startRpcServer, stopRpcServer} from './rpc-server.js';
import {registerUpdater} from './updater.js';
import {createWindow, getMainWindow, registerDisplayMediaHandlers, setQuitting, showWindow} from './window.js';
import {startWsProxyServer, stopWsProxyServer} from './ws-proxy-server.js';
log.transports.file.level = 'info';
log.transports.console.level = 'debug';
const requireModule = createRequire(import.meta.url);
const userDataConfig = configureUserDataPath();
log.info('Configured user data storage', {
channel: userDataConfig.channel,
directory: userDataConfig.directoryName,
path: userDataConfig.base,
});
const isCanary = BUILD_CHANNEL === 'canary';
if (process.platform === 'win32') {
const handledSquirrelEvent = requireModule('electron-squirrel-startup');
if (handledSquirrelEvent) {
app.quit();
}
}
app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required');
if (process.platform === 'win32') {
app.commandLine.appendSwitch('disable-background-timer-throttling');
app.commandLine.appendSwitch('disable-renderer-backgrounding');
}
if (process.env.NODE_ENV === 'development') {
log.error('Electron desktop does not support development mode; exiting.');
app.quit();
process.exit(1);
}
if (process.platform === 'win32') {
const appId = isCanary ? 'app.fluxer.canary' : 'app.fluxer';
app.setAppUserModelId(appId);
}
if (process.platform === 'linux') {
const linuxName = isCanary ? 'Fluxer Canary' : 'Fluxer';
app.setName(linuxName);
}
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
} else {
app.on('second-instance', (_event, argv, _workingDirectory) => {
handleSecondInstance(argv);
});
app.on('open-url', (event, url) => {
event.preventDefault();
handleOpenUrl(url);
});
app.whenReady().then(async () => {
log.info('App ready, initializing...');
try {
initializeDeepLinks();
} catch (error) {
log.error('[Init] Failed to initialize deep links:', error);
}
try {
registerIpcHandlers();
} catch (error) {
log.error('[Init] Failed to register IPC handlers:', error);
}
try {
registerAutostartHandlers();
} catch (error) {
log.error('[Init] Failed to register autostart handlers:', error);
}
try {
registerGlobalKeyHookHandlers();
} catch (error) {
log.error('[Init] Failed to register global key hook handlers:', error);
}
try {
registerDisplayMediaHandlers();
} catch (error: unknown) {
log.error('[Init] Failed to register display media handlers:', error);
}
try {
createApplicationMenu();
} catch (error: unknown) {
log.error('[Init] Failed to create application menu:', error);
}
createWindow();
registerUpdater(getMainWindow);
app.on('activate', () => {
const mainWindow = getMainWindow();
if (mainWindow === null || mainWindow.isDestroyed()) {
createWindow();
} else {
showWindow();
}
});
void startRpcServer().catch((error: unknown) => {
log.error('[RPC] Failed to start RPC server:', error);
});
void startWsProxyServer().catch((error: unknown) => {
log.error('[WS Proxy] Failed to start WS proxy server:', error);
});
void startApiProxyServer().catch((error: unknown) => {
log.error('[API Proxy] Failed to start API proxy server:', error);
});
void startMediaProxyServer().catch((error: unknown) => {
log.error('[Media Proxy] Failed to start media proxy server:', error);
});
log.info('App initialized successfully');
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('before-quit', () => {
setQuitting(true);
});
app.on('will-quit', () => {
cleanupIpcHandlers();
cleanupGlobalKeyHook();
globalShortcut.unregisterAll();
void stopRpcServer();
void stopWsProxyServer();
void stopApiProxyServer();
void stopMediaProxyServer();
});
process.on('uncaughtException', (error: unknown) => {
log.error('Uncaught exception:', error);
});
process.on('unhandledRejection', (reason: unknown, promise: Promise<unknown>) => {
log.error('Unhandled rejection at:', promise, 'reason:', reason);
});
}

View File

@@ -0,0 +1,777 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import fs from 'node:fs';
import http from 'node:http';
import https from 'node:https';
import {createRequire} from 'node:module';
import os from 'node:os';
import type {
PublicKeyCredential,
PublicKeyCredentialCreationOptions,
PublicKeyCredentialDescriptor,
PublicKeyCredentialRequestOptions,
} from '@electron-webauthn/native';
import {create as nativeCreate, get as nativeGet, isSupported as nativeIsSupported} from '@electron-webauthn/native';
import type {
AuthenticationExtensionsClientOutputs,
AuthenticationResponseJSON,
AuthenticatorAssertionResponseJSON,
AuthenticatorAttestationResponseJSON,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialDescriptorJSON,
PublicKeyCredentialRequestOptionsJSON,
RegistrationResponseJSON,
} from '@simplewebauthn/browser';
import {
app,
BrowserWindow,
clipboard,
dialog,
globalShortcut,
ipcMain,
Notification,
nativeImage,
shell,
systemPreferences,
} from 'electron';
import type {
AssertionCredential,
AuthenticatorType,
CreateCredentialOptions,
CredentialDescriptor,
GetCredentialOptions,
RegistrationCredential,
WebAuthnMacAddon,
} from 'electron-webauthn-mac';
import {BUILD_CHANNEL} from '../common/build-channel.js';
import type {
DesktopInfo,
DownloadFileResult,
GlobalShortcutOptions,
MediaAccessType,
NotificationOptions,
} from '../common/types.js';
import {getMediaProxyToken} from './media-proxy-server.js';
import {getMainWindow} from './window.js';
import {setWindowsBadgeOverlay} from './windows-badge.js';
const registeredShortcuts = new Map<string, string>();
interface ActiveNotification {
notification: Notification;
url?: string;
}
const activeNotifications = new Map<string, ActiveNotification>();
const requireModule = createRequire(import.meta.url);
let notificationIdCounter = 0;
const base64UrlToBuffer = (value: string): Buffer => {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
const padLength = (4 - (normalized.length % 4)) % 4;
return Buffer.from(`${normalized}${'='.repeat(padLength)}`, 'base64');
};
const bufferToBase64Url = (value: Buffer): string =>
value.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const convertDescriptorList = (
list?: Array<PublicKeyCredentialDescriptorJSON>,
): Array<PublicKeyCredentialDescriptor> | undefined =>
list?.map((descriptor) => ({
id: base64UrlToBuffer(descriptor.id),
type: descriptor.type,
transports: descriptor.transports,
}));
const convertRequestOptions = (options: PublicKeyCredentialRequestOptionsJSON): PublicKeyCredentialRequestOptions => ({
...options,
challenge: base64UrlToBuffer(options.challenge),
allowCredentials: convertDescriptorList(options.allowCredentials),
});
const convertCreationOptions = (
options: PublicKeyCredentialCreationOptionsJSON,
): PublicKeyCredentialCreationOptions => ({
attestation: options.attestation,
authenticatorSelection: options.authenticatorSelection,
challenge: base64UrlToBuffer(options.challenge),
excludeCredentials: convertDescriptorList(options.excludeCredentials),
extensions: options.extensions,
pubKeyCredParams: options.pubKeyCredParams,
rp: options.rp,
timeout: options.timeout,
user: {...options.user, id: base64UrlToBuffer(options.user.id)},
});
const emptyExtensions: AuthenticationExtensionsClientOutputs = {};
const parseCredentialResponse = <T>(credential: PublicKeyCredential): T => {
const payload = credential.response.toString('utf-8');
if (!payload) {
throw new Error('Passkey response payload is empty');
}
try {
return JSON.parse(payload) as T;
} catch (error) {
throw new Error(
`Failed to parse passkey response payload: ${error instanceof Error ? error.message : 'unknown error'}`,
);
}
};
const normalizeAuthenticatorAttachment = (
attachment: string | null | undefined,
): AuthenticatorAttachment | undefined => (attachment == null ? undefined : (attachment as AuthenticatorAttachment));
const buildAuthenticationResponse = (credential: PublicKeyCredential): AuthenticationResponseJSON => ({
id: bufferToBase64Url(credential.rawId),
rawId: bufferToBase64Url(credential.rawId),
response: parseCredentialResponse<AuthenticatorAssertionResponseJSON>(credential),
clientExtensionResults: emptyExtensions,
type: 'public-key',
authenticatorAttachment: normalizeAuthenticatorAttachment(credential.authenticatorAttachment),
});
const buildRegistrationResponse = (credential: PublicKeyCredential): RegistrationResponseJSON => ({
id: bufferToBase64Url(credential.rawId),
rawId: bufferToBase64Url(credential.rawId),
response: parseCredentialResponse<AuthenticatorAttestationResponseJSON>(credential),
clientExtensionResults: emptyExtensions,
type: 'public-key',
authenticatorAttachment: normalizeAuthenticatorAttachment(credential.authenticatorAttachment),
});
const base64ToBase64Url = (value: string): string => value.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const base64UrlToBase64 = (value: string): string => base64UrlToBuffer(value).toString('base64');
const convertDescriptorForMac = (descriptor: PublicKeyCredentialDescriptorJSON): CredentialDescriptor => ({
id: base64UrlToBase64(descriptor.id),
transports: descriptor.transports,
});
const deriveAuthenticatorTypesFromSelection = (
selection?: PublicKeyCredentialCreationOptionsJSON['authenticatorSelection'],
): Array<AuthenticatorType> | undefined => {
const attachment = selection?.authenticatorAttachment;
if (!attachment) {
return undefined;
}
if (attachment === 'platform') {
return ['platform'];
}
if (attachment === 'cross-platform') {
return ['securityKey'];
}
return undefined;
};
const ensureRpId = (value: string | undefined, context: string): string => {
if (!value) {
throw new Error(`Passkey ${context} operation requires rpId`);
}
return value;
};
const convertMacCreationOptions = (options: PublicKeyCredentialCreationOptionsJSON): CreateCredentialOptions => ({
rpId: ensureRpId(options.rp.id, 'registration'),
userId: base64UrlToBuffer(options.user.id).toString('base64'),
name: options.user.name,
displayName: options.user.displayName,
authenticators: deriveAuthenticatorTypesFromSelection(options.authenticatorSelection),
excludeCredentials: options.excludeCredentials?.map(convertDescriptorForMac),
userVerification: options.authenticatorSelection?.userVerification,
attestation: options.attestation,
});
const convertMacRequestOptions = (options: PublicKeyCredentialRequestOptionsJSON): GetCredentialOptions => ({
rpId: ensureRpId(options.rpId, 'assertion'),
allowCredentials: options.allowCredentials?.map(convertDescriptorForMac),
userVerification: options.userVerification,
});
const MAC_APP_IDENTIFIER_SEARCH = 'application identifier';
const hasMacApplicationIdentifier = (): boolean => process.platform === 'darwin' && app.isPackaged;
const isMissingApplicationIdentifierError = (error: unknown): boolean => {
const message =
error instanceof Error
? error.message
: typeof error === 'object' && error !== null && 'message' in error
? String((error as {message?: unknown}).message ?? '')
: '';
return message.toLowerCase().includes(MAC_APP_IDENTIFIER_SEARCH);
};
const buildRegistrationResponseFromMac = (credential: RegistrationCredential): RegistrationResponseJSON => {
const id = base64ToBase64Url(credential.credentialID);
return {
id,
rawId: id,
response: {
clientDataJSON: base64ToBase64Url(credential.clientDataJSON),
attestationObject: base64ToBase64Url(credential.attestationObject),
transports:
'transports' in credential
? (credential.transports as RegistrationResponseJSON['response']['transports'])
: undefined,
},
clientExtensionResults: emptyExtensions,
type: 'public-key',
authenticatorAttachment:
'attachment' in credential ? (credential.attachment as AuthenticatorAttachment) : undefined,
};
};
const buildAuthenticationResponseFromMac = (credential: AssertionCredential): AuthenticationResponseJSON => {
const id = base64ToBase64Url(credential.credentialID);
return {
id,
rawId: id,
response: {
clientDataJSON: base64ToBase64Url(credential.clientDataJSON),
authenticatorData: base64ToBase64Url(credential.authenticatorData),
signature: base64ToBase64Url(credential.signature),
userHandle: credential.userID ? base64ToBase64Url(credential.userID) : undefined,
},
clientExtensionResults: emptyExtensions,
type: 'public-key',
authenticatorAttachment:
'attachment' in credential ? (credential.attachment as AuthenticatorAttachment) : undefined,
};
};
interface PasskeyProvider {
isSupported: () => Promise<boolean>;
authenticate: (options: PublicKeyCredentialRequestOptionsJSON) => Promise<AuthenticationResponseJSON>;
register: (options: PublicKeyCredentialCreationOptionsJSON) => Promise<RegistrationResponseJSON>;
}
const passkeyProvider = createPasskeyProvider();
function createPasskeyProvider(): PasskeyProvider {
const macAddon = loadMacWebAuthnAddon();
if (macAddon) {
return createMacPasskeyProvider(macAddon);
}
return createNativePasskeyProvider();
}
function createNativePasskeyProvider(): PasskeyProvider {
return {
isSupported: nativeIsSupported,
authenticate: async (options) => {
const requestOptions = convertRequestOptions(options);
const credential = await nativeGet(requestOptions);
return buildAuthenticationResponse(credential);
},
register: async (options) => {
const creationOptions = convertCreationOptions(options);
const credential = await nativeCreate(creationOptions);
return buildRegistrationResponse(credential);
},
};
}
function createMacPasskeyProvider(addon: WebAuthnMacAddon): PasskeyProvider {
const fallbackProvider = createNativePasskeyProvider();
let useAddon = true;
const disableAddon = (): void => {
useAddon = false;
};
const callWithFallback = async <T>(
addonOperation: () => Promise<T>,
nativeOperation: () => Promise<T>,
): Promise<T> => {
if (!useAddon) {
return nativeOperation();
}
try {
return await addonOperation();
} catch (error) {
if (isMissingApplicationIdentifierError(error)) {
console.warn('electron-webauthn-mac disabled: missing application identifier', error);
disableAddon();
return nativeOperation();
}
throw error;
}
};
return {
isSupported: async () => {
if (!useAddon) {
return fallbackProvider.isSupported();
}
return true;
},
authenticate: async (options) =>
callWithFallback(
async () => {
const requestOptions = convertMacRequestOptions(options);
const credential = await addon.getCredential(requestOptions);
return buildAuthenticationResponseFromMac(credential);
},
() => fallbackProvider.authenticate(options),
),
register: async (options) =>
callWithFallback(
async () => {
const creationOptions = convertMacCreationOptions(options);
const credential = await addon.createCredential(creationOptions);
return buildRegistrationResponseFromMac(credential);
},
() => fallbackProvider.register(options),
),
};
}
function loadMacWebAuthnAddon(): WebAuthnMacAddon | null {
if (process.platform !== 'darwin' || !hasMacApplicationIdentifier()) {
if (process.platform === 'darwin') {
console.info(
'electron-webauthn-mac disabled: macOS build lacks an application identifier (likely unsigned dev bundle).',
);
}
return null;
}
try {
return requireModule('electron-webauthn-mac') as WebAuthnMacAddon;
} catch (error) {
console.warn('Failed to initialize electron-webauthn-mac:', error);
return null;
}
}
export function registerIpcHandlers(): void {
ipcMain.handle(
'get-desktop-info',
(): DesktopInfo => ({
version: app.getVersion(),
channel: BUILD_CHANNEL,
arch: process.arch,
os: process.platform,
osVersion: os.release(),
}),
);
ipcMain.handle('get-media-proxy-token', (): string => {
return getMediaProxyToken();
});
ipcMain.on('get-media-proxy-token', (event) => {
event.returnValue = getMediaProxyToken();
});
ipcMain.on('window-minimize', (event) => {
BrowserWindow.fromWebContents(event.sender)?.minimize();
});
ipcMain.on('window-maximize', (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (win) {
if (win.isMaximized()) {
win.unmaximize();
} else {
win.maximize();
}
}
});
ipcMain.on('window-close', (event) => {
BrowserWindow.fromWebContents(event.sender)?.close();
});
ipcMain.handle('window-is-maximized', (event): boolean => {
return BrowserWindow.fromWebContents(event.sender)?.isMaximized() ?? false;
});
ipcMain.handle('open-external', async (_event, url: string): Promise<void> => {
const allowedProtocols = ['http:', 'https:', 'mailto:'];
if (process.platform === 'darwin') {
allowedProtocols.push('x-apple.systempreferences:');
}
try {
const parsed = new URL(url);
if (allowedProtocols.includes(parsed.protocol)) {
await shell.openExternal(url);
} else {
throw new Error('Invalid URL protocol');
}
} catch (error) {
if (error instanceof TypeError) {
throw new Error('Invalid URL');
}
throw error;
}
});
ipcMain.handle('clipboard-write-text', (_event, text: string): void => {
clipboard.writeText(text);
});
ipcMain.handle('clipboard-read-text', (): string => {
return clipboard.readText();
});
ipcMain.handle('app-set-badge', (_event, payload: {count: number; text?: string}) => {
const count = Math.max(0, Math.floor(payload?.count ?? 0));
const label = payload?.text ?? String(count);
app.setBadgeCount(count);
if (process.platform === 'darwin' && app.dock) {
app.dock.setBadge(count > 0 ? label : '');
}
if (process.platform === 'win32') {
setWindowsBadgeOverlay(getMainWindow(), count);
}
});
ipcMain.handle(
'download-file',
async (event, options: {url: string; defaultPath: string}): Promise<DownloadFileResult> => {
const win = BrowserWindow.fromWebContents(event.sender);
if (!win) {
return {success: false, error: 'No window found'};
}
try {
const result = await dialog.showSaveDialog(win, {
defaultPath: options.defaultPath,
});
if (result.canceled || !result.filePath) {
return {success: false};
}
await downloadFile(options.url, result.filePath);
return {success: true, path: result.filePath};
} catch (error) {
return {success: false, error: error instanceof Error ? error.message : 'Unknown error'};
}
},
);
ipcMain.on('toggle-devtools', (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (win) {
if (win.webContents.isDevToolsOpened()) {
win.webContents.closeDevTools();
} else {
win.webContents.openDevTools();
}
}
});
ipcMain.handle('register-global-shortcut', (_event, options: GlobalShortcutOptions): boolean => {
const {accelerator, id} = options;
if (registeredShortcuts.has(accelerator)) {
globalShortcut.unregister(accelerator);
}
const success = globalShortcut.register(accelerator, () => {
const mainWindow = getMainWindow();
if (mainWindow) {
mainWindow.webContents.send('global-shortcut-triggered', id);
}
});
if (success) {
registeredShortcuts.set(accelerator, id);
}
return success;
});
ipcMain.handle('unregister-global-shortcut', (_event, accelerator: string): void => {
if (registeredShortcuts.has(accelerator)) {
globalShortcut.unregister(accelerator);
registeredShortcuts.delete(accelerator);
}
});
ipcMain.handle('unregister-all-global-shortcuts', (): void => {
globalShortcut.unregisterAll();
registeredShortcuts.clear();
});
ipcMain.handle('check-media-access', (_event, type: MediaAccessType): string => {
if (process.platform !== 'darwin') {
return 'granted';
}
return systemPreferences.getMediaAccessStatus(type);
});
ipcMain.handle('request-media-access', async (_event, type: MediaAccessType): Promise<boolean> => {
if (process.platform !== 'darwin') {
return true;
}
if (type === 'screen') {
return systemPreferences.getMediaAccessStatus('screen') === 'granted';
}
return systemPreferences.askForMediaAccess(type);
});
ipcMain.handle('open-media-access-settings', async (_event, type: MediaAccessType): Promise<void> => {
if (process.platform !== 'darwin') {
return;
}
const privacyKeys: Record<MediaAccessType, string> = {
microphone: 'Privacy_Microphone',
camera: 'Privacy_Camera',
screen: 'Privacy_ScreenCapture',
};
await shell.openExternal(`x-apple.systempreferences:com.apple.preference.security?${privacyKeys[type]}`);
});
ipcMain.handle('check-accessibility', (_event, prompt: boolean): boolean => {
if (process.platform !== 'darwin') {
return true;
}
return systemPreferences.isTrustedAccessibilityClient(prompt);
});
ipcMain.handle('open-accessibility-settings', async (): Promise<void> => {
if (process.platform !== 'darwin') {
return;
}
await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility');
});
ipcMain.handle('open-input-monitoring-settings', async (): Promise<void> => {
if (process.platform !== 'darwin') {
return;
}
await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent');
});
ipcMain.handle('show-notification', async (_event, options: NotificationOptions): Promise<{id: string}> => {
const id = `notification-${++notificationIdCounter}`;
if (!Notification.isSupported()) {
return {id};
}
const notificationOpts: Electron.NotificationConstructorOptions = {
title: options.title,
body: options.body,
};
if (options.icon) {
try {
if (options.icon.startsWith('http://') || options.icon.startsWith('https://')) {
const iconBuffer = await downloadToBuffer(options.icon);
notificationOpts.icon = nativeImage.createFromBuffer(iconBuffer);
} else if (options.icon.startsWith('data:')) {
const base64Data = options.icon.split(',')[1];
if (base64Data) {
const iconBuffer = Buffer.from(base64Data, 'base64');
notificationOpts.icon = nativeImage.createFromBuffer(iconBuffer);
}
} else {
notificationOpts.icon = options.icon;
}
} catch (error) {
console.warn('[Notification] Failed to load icon:', error);
}
}
const notification = new Notification(notificationOpts);
activeNotifications.set(id, {notification, url: options.url});
notification.on('click', () => {
const mainWindow = getMainWindow();
if (mainWindow) {
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
mainWindow.show();
mainWindow.focus();
mainWindow.webContents.send('notification-click', id, options.url);
}
activeNotifications.delete(id);
});
notification.on('close', () => {
activeNotifications.delete(id);
});
notification.show();
return {id};
});
ipcMain.on('close-notification', (_event, id: string) => {
const active = activeNotifications.get(id);
if (active) {
active.notification.close();
activeNotifications.delete(id);
}
});
ipcMain.on('close-notifications', (_event, ids: Array<string>) => {
for (const id of ids) {
const active = activeNotifications.get(id);
if (active) {
active.notification.close();
activeNotifications.delete(id);
}
}
});
ipcMain.on('set-badge-count', (_event, count: number) => {
if (process.platform === 'darwin') {
app.setBadgeCount(count);
} else if (process.platform === 'win32') {
setWindowsBadgeOverlay(getMainWindow(), count);
} else {
app.setBadgeCount(count);
}
});
ipcMain.handle('get-badge-count', (): number => {
return app.getBadgeCount();
});
ipcMain.on('bounce-dock', (event, type: 'critical' | 'informational') => {
if (process.platform === 'darwin' && app.dock) {
const id = app.dock.bounce(type);
event.returnValue = id;
} else {
event.returnValue = -1;
}
});
ipcMain.on('cancel-bounce-dock', (_event, id: number) => {
if (process.platform === 'darwin' && app.dock && id >= 0) {
app.dock.cancelBounce(id);
}
});
ipcMain.on('set-zoom-factor', (event, factor: number) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (win && factor > 0) {
win.webContents.setZoomFactor(factor);
}
});
ipcMain.handle('get-zoom-factor', (event): number => {
const win = BrowserWindow.fromWebContents(event.sender);
return win?.webContents.getZoomFactor() ?? 1;
});
ipcMain.handle('passkey-is-supported', (): Promise<boolean> => {
return passkeyProvider.isSupported();
});
ipcMain.handle(
'passkey-authenticate',
async (_event, options: PublicKeyCredentialRequestOptionsJSON): Promise<AuthenticationResponseJSON> => {
return passkeyProvider.authenticate(options);
},
);
ipcMain.handle(
'passkey-register',
async (_event, options: PublicKeyCredentialCreationOptionsJSON): Promise<RegistrationResponseJSON> => {
return passkeyProvider.register(options);
},
);
}
function downloadToBuffer(url: string): Promise<Buffer> {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https://') ? https : http;
protocol
.get(url, (response) => {
if (response.statusCode === 301 || response.statusCode === 302) {
const redirectUrl = response.headers.location;
if (redirectUrl) {
downloadToBuffer(redirectUrl).then(resolve).catch(reject);
return;
}
}
if (response.statusCode !== 200) {
reject(new Error(`HTTP ${response.statusCode}`));
return;
}
const chunks: Array<Buffer> = [];
response.on('data', (chunk: Buffer) => chunks.push(chunk));
response.on('end', () => resolve(Buffer.concat(chunks)));
response.on('error', reject);
})
.on('error', reject);
});
}
function downloadFile(url: string, destPath: string): Promise<void> {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https://') ? https : http;
const file = fs.createWriteStream(destPath);
protocol
.get(url, (response) => {
if (response.statusCode === 301 || response.statusCode === 302) {
const redirectUrl = response.headers.location;
if (redirectUrl) {
file.close();
fs.unlinkSync(destPath);
downloadFile(redirectUrl, destPath).then(resolve).catch(reject);
return;
}
}
if (response.statusCode !== 200) {
file.close();
fs.unlinkSync(destPath);
reject(new Error(`HTTP ${response.statusCode}`));
return;
}
response.pipe(file);
file.on('finish', () => {
file.close();
resolve();
});
})
.on('error', (err) => {
file.close();
fs.unlink(destPath, () => {});
reject(err);
});
});
}
export function cleanupIpcHandlers(): void {
globalShortcut.unregisterAll();
registeredShortcuts.clear();
}

View File

@@ -0,0 +1,278 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {randomUUID} from 'node:crypto';
import http from 'node:http';
import https from 'node:https';
import log from 'electron-log';
import {BUILD_CHANNEL} from '../common/build-channel.js';
import {CANARY_APP_URL, STABLE_APP_URL} from '../common/constants.js';
export const MEDIA_PROXY_PORT = BUILD_CHANNEL === 'canary' ? 21868 : 21867;
const MEDIA_PROXY_TOKEN_PARAM = 'token';
const MEDIA_PROXY_TOKEN = randomUUID();
export const getMediaProxyToken = (): string => MEDIA_PROXY_TOKEN;
const ALLOWED_ORIGINS = [STABLE_APP_URL, CANARY_APP_URL];
const isAllowedOrigin = (origin?: string): boolean => {
if (!origin) return false;
return ALLOWED_ORIGINS.includes(origin);
};
const refererMatchesAllowedOrigin = (referer?: string): boolean => {
if (!referer) return false;
return ALLOWED_ORIGINS.some((allowed) => referer.startsWith(allowed));
};
const rejectIfDisallowedPage = (
req: http.IncomingMessage,
res: http.ServerResponse,
targetUrl?: URL,
tokenValid?: boolean,
): boolean => {
const origin = req.headers.origin;
const referer = req.headers.referer;
const targetOrigin = targetUrl?.origin;
if (tokenValid) {
return false;
}
if (!origin && !referer) {
res.writeHead(403);
res.end();
return true;
}
if (origin && !isAllowedOrigin(origin) && origin !== targetOrigin) {
res.writeHead(403);
res.end();
return true;
}
if (referer && !refererMatchesAllowedOrigin(referer) && !(targetOrigin && referer.startsWith(targetOrigin))) {
res.writeHead(403);
res.end();
return true;
}
if (origin && referer && !referer.startsWith(origin)) {
res.writeHead(403);
res.end();
return true;
}
return false;
};
const isValidTargetUrl = (raw: string | null): raw is string => {
if (!raw) return false;
try {
const parsed = new URL(raw);
return parsed.protocol === 'https:' || parsed.protocol === 'http:';
} catch {
return false;
}
};
const sanitizeUpstreamHeaders = (headers: http.IncomingHttpHeaders): Record<string, string> => {
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(headers)) {
if (!key) continue;
if (typeof value === 'string') {
out[key.toLowerCase()] = value;
} else if (Array.isArray(value)) {
out[key.toLowerCase()] = value.join(', ');
}
}
return out;
};
const buildForwardHeaders = (req: http.IncomingMessage, targetUrl: URL): Record<string, string> => {
const headers = sanitizeUpstreamHeaders(req.headers);
delete headers.host;
delete headers.origin;
delete headers.referer;
delete headers.connection;
delete headers['proxy-connection'];
delete headers['sec-fetch-site'];
delete headers['sec-fetch-mode'];
delete headers['sec-fetch-dest'];
headers.host = targetUrl.host;
return headers;
};
const pipeRequest = (
req: http.IncomingMessage,
res: http.ServerResponse,
targetUrl: string,
redirectsRemaining: number,
): void => {
const parsedTarget = new URL(targetUrl);
const agent = parsedTarget.protocol === 'https:' ? https : http;
const method = (req.method ?? 'GET').toUpperCase();
const requestOptions: http.RequestOptions = {
hostname: parsedTarget.hostname,
port: parsedTarget.port || (parsedTarget.protocol === 'https:' ? 443 : 80),
path: parsedTarget.pathname + parsedTarget.search,
method,
headers: buildForwardHeaders(req, parsedTarget),
};
const upstreamReq = agent.request(requestOptions, (upstreamRes) => {
const status = upstreamRes.statusCode ?? 502;
if (
redirectsRemaining > 0 &&
(status === 301 || status === 302 || status === 303 || status === 307 || status === 308) &&
typeof upstreamRes.headers.location === 'string'
) {
const location = upstreamRes.headers.location;
const nextTarget = new URL(location, parsedTarget).toString();
upstreamRes.resume();
pipeRequest(req, res, nextTarget, redirectsRemaining - 1);
return;
}
const headers = sanitizeUpstreamHeaders(upstreamRes.headers);
delete headers.connection;
delete headers['proxy-connection'];
delete headers['transfer-encoding'];
headers['cross-origin-resource-policy'] = 'cross-origin';
res.writeHead(status, headers);
upstreamRes.pipe(res);
});
upstreamReq.on('error', (error: Error) => {
log.error('[Media Proxy] Upstream request error:', error);
if (!res.headersSent) {
res.writeHead(502, {'Content-Type': 'text/plain'});
}
res.end('Bad Gateway');
});
res.on('close', () => {
try {
upstreamReq.destroy();
} catch {}
});
upstreamReq.end();
};
let server: http.Server | null = null;
const hasValidMediaProxyToken = (url: URL): boolean => {
const value = url.searchParams.get(MEDIA_PROXY_TOKEN_PARAM);
return value === MEDIA_PROXY_TOKEN;
};
export const startMediaProxyServer = (): Promise<void> => {
return new Promise((resolve, reject) => {
if (server) {
resolve();
return;
}
server = http.createServer((req, res) => {
const remoteAddress = req.socket.remoteAddress;
if (remoteAddress !== '127.0.0.1' && remoteAddress !== '::1' && remoteAddress !== '::ffff:127.0.0.1') {
res.writeHead(403);
res.end();
return;
}
const requestUrl = new URL(req.url ?? '/', `http://${req.headers.host}`);
const tokenValid = hasValidMediaProxyToken(requestUrl);
if (!tokenValid) {
res.writeHead(403);
res.end();
return;
}
requestUrl.searchParams.delete(MEDIA_PROXY_TOKEN_PARAM);
const method = (req.method ?? 'GET').toUpperCase();
if (method !== 'GET' && method !== 'HEAD') {
res.writeHead(405, {'Content-Type': 'text/plain'});
res.end('Method Not Allowed');
return;
}
if (requestUrl.pathname !== '/media') {
res.writeHead(404);
res.end();
return;
}
const target = requestUrl.searchParams.get('target');
if (!isValidTargetUrl(target)) {
res.writeHead(400, {'Content-Type': 'text/plain'});
res.end('Missing or invalid target');
return;
}
const targetUrl = new URL(target);
if (rejectIfDisallowedPage(req, res, targetUrl, tokenValid)) {
return;
}
pipeRequest(req, res, target, 5);
});
server.on('error', (error: NodeJS.ErrnoException) => {
if (error.code === 'EADDRINUSE') {
log.warn(`[Media Proxy] Port ${MEDIA_PROXY_PORT} already in use, media proxy disabled`);
server = null;
resolve();
} else {
log.error('[Media Proxy] Server error:', error);
reject(error);
}
});
server.listen(MEDIA_PROXY_PORT, '127.0.0.1', () => {
log.info(`[Media Proxy] Server listening on http://127.0.0.1:${MEDIA_PROXY_PORT}/media`);
resolve();
});
});
};
export const stopMediaProxyServer = (): Promise<void> => {
return new Promise((resolve) => {
if (!server) {
resolve();
return;
}
server.close((err) => {
if (err) {
log.error('[Media Proxy] Error closing server:', err);
}
server = null;
resolve();
});
});
};

View File

@@ -0,0 +1,207 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {app, Menu, type MenuItemConstructorOptions, shell} from 'electron';
import {BUILD_CHANNEL} from '../common/build-channel.js';
import {getMainWindow} from './window.js';
export function createApplicationMenu(): void {
const isCanary = BUILD_CHANNEL === 'canary';
const appName = isCanary ? 'Fluxer Canary' : 'Fluxer';
const isMac = process.platform === 'darwin';
const template: Array<MenuItemConstructorOptions> = [];
if (isMac) {
app.setName(appName);
template.push({
label: appName,
submenu: [
{
role: 'about',
label: `About ${appName}`,
},
{type: 'separator'},
{
label: 'Preferences...',
accelerator: 'Cmd+,',
click: () => {
const mainWindow = getMainWindow();
if (mainWindow) {
mainWindow.webContents.send('open-settings');
}
},
},
{type: 'separator'},
{role: 'services'},
{type: 'separator'},
{
role: 'hide',
label: `Hide ${appName}`,
},
{role: 'hideOthers'},
{role: 'unhide'},
{type: 'separator'},
{
role: 'quit',
label: `Quit ${appName}`,
},
],
});
}
template.push({
label: 'File',
submenu: isMac
? [{role: 'close'}]
: [
{
label: 'Preferences',
accelerator: 'Ctrl+,',
click: () => {
const mainWindow = getMainWindow();
if (mainWindow) {
mainWindow.webContents.send('open-settings');
}
},
},
{type: 'separator'},
{role: 'quit'},
],
});
template.push({
label: 'Edit',
submenu: [
{role: 'undo'},
{role: 'redo'},
{type: 'separator'},
{role: 'cut'},
{role: 'copy'},
{role: 'paste'},
...(isMac
? [
{role: 'pasteAndMatchStyle' as const},
{role: 'delete' as const},
{role: 'selectAll' as const},
{type: 'separator' as const},
{
label: 'Speech',
submenu: [{role: 'startSpeaking' as const}, {role: 'stopSpeaking' as const}],
},
]
: [{role: 'delete' as const}, {type: 'separator' as const}, {role: 'selectAll' as const}]),
],
});
const zoomInHandler = () => {
const mainWindow = getMainWindow();
if (mainWindow) {
mainWindow.webContents.send('zoom-in');
}
};
template.push({
label: 'View',
submenu: [
{role: 'reload'},
{role: 'forceReload'},
{role: 'toggleDevTools'},
{type: 'separator'},
{
label: 'Actual Size',
accelerator: 'CmdOrCtrl+0',
click: () => {
const mainWindow = getMainWindow();
if (mainWindow) {
mainWindow.webContents.send('zoom-reset');
}
},
},
{
label: 'Zoom In',
accelerator: 'CmdOrCtrl+Plus',
click: zoomInHandler,
},
{
label: 'Zoom In',
accelerator: 'CmdOrCtrl+=',
visible: false,
click: zoomInHandler,
},
{
label: 'Zoom Out',
accelerator: 'CmdOrCtrl+-',
click: () => {
const mainWindow = getMainWindow();
if (mainWindow) {
mainWindow.webContents.send('zoom-out');
}
},
},
{type: 'separator'},
{role: 'togglefullscreen'},
],
});
template.push({
label: 'Window',
submenu: [
{role: 'minimize'},
{role: 'zoom'},
...(isMac
? [
{type: 'separator' as const},
{role: 'front' as const},
{type: 'separator' as const},
{role: 'window' as const},
]
: [{role: 'close' as const}]),
],
});
template.push({
label: 'Help',
submenu: [
{
label: 'Website',
click: async () => {
await shell.openExternal('https://fluxer.app');
},
},
{
label: 'GitHub',
click: async () => {
await shell.openExternal('https://github.com/fluxerapp/fluxer');
},
},
{type: 'separator'},
{
label: 'Report Issue',
click: async () => {
await shell.openExternal('https://github.com/fluxerapp/fluxer/issues');
},
},
],
});
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
}

View File

@@ -0,0 +1,266 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import http from 'node:http';
import {app} from 'electron';
import log from 'electron-log';
import {BUILD_CHANNEL} from '../common/build-channel.js';
import {CANARY_APP_URL, STABLE_APP_URL} from '../common/constants.js';
import {getMainWindow, showWindow} from './window.js';
export const RPC_PORT = BUILD_CHANNEL === 'canary' ? 21864 : 21863;
const ALLOWED_ORIGINS = [STABLE_APP_URL, CANARY_APP_URL];
const isAllowedOrigin = (origin?: string): boolean => {
if (!origin) return false;
return ALLOWED_ORIGINS.includes(origin);
};
const refererMatchesAllowedOrigin = (referer?: string): boolean => {
if (!referer) return false;
return ALLOWED_ORIGINS.some((allowed) => referer.startsWith(allowed));
};
let server: http.Server | null = null;
interface RpcRequest {
method: string;
params?: Record<string, unknown>;
}
interface RpcResponse {
success: boolean;
data?: unknown;
error?: string;
}
const sendJson = (res: http.ServerResponse, status: number, data: RpcResponse) => {
res.writeHead(status, {'Content-Type': 'application/json'});
res.end(JSON.stringify(data));
};
const rejectIfDisallowedPage = (req: http.IncomingMessage, res: http.ServerResponse): boolean => {
const origin = req.headers.origin;
const referer = req.headers.referer;
if (!origin && !referer) {
res.writeHead(403);
res.end();
return true;
}
if (origin && !isAllowedOrigin(origin)) {
res.writeHead(403);
res.end();
return true;
}
if (referer && !refererMatchesAllowedOrigin(referer)) {
res.writeHead(403);
res.end();
return true;
}
if (origin && referer && !referer.startsWith(origin)) {
res.writeHead(403);
res.end();
return true;
}
return false;
};
const handleCors = (req: http.IncomingMessage, res: http.ServerResponse): boolean => {
const origin = req.headers.origin;
if (origin && !isAllowedOrigin(origin)) {
res.writeHead(403);
res.end();
return true;
}
if (origin && isAllowedOrigin(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
res.setHeader('Access-Control-Max-Age', '86400');
}
if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return true;
}
return false;
};
const parseBody = (req: http.IncomingMessage): Promise<RpcRequest | null> => {
return new Promise((resolve) => {
if (req.method !== 'POST') {
resolve(null);
return;
}
let body = '';
req.on('data', (chunk) => {
body += chunk;
if (body.length > 1024 * 1024) {
resolve(null);
}
});
req.on('end', () => {
try {
resolve(JSON.parse(body) as RpcRequest);
} catch {
resolve(null);
}
});
req.on('error', () => resolve(null));
});
};
const handleHealth = (_req: http.IncomingMessage, res: http.ServerResponse) => {
sendJson(res, 200, {
success: true,
data: {
status: 'ok',
channel: BUILD_CHANNEL,
version: app.getVersion(),
platform: process.platform,
},
});
};
const handleNavigate = async (req: http.IncomingMessage, res: http.ServerResponse) => {
const body = await parseBody(req);
if (!body?.params?.path || typeof body.params.path !== 'string') {
sendJson(res, 400, {success: false, error: 'Missing or invalid path parameter'});
return;
}
const path = body.params.path;
const mainWindow = getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) {
sendJson(res, 503, {success: false, error: 'Main window not available'});
return;
}
mainWindow.webContents.send('rpc-navigate', path);
showWindow();
sendJson(res, 200, {success: true, data: {navigated: true, path}});
};
const handleFocus = (_req: http.IncomingMessage, res: http.ServerResponse) => {
const mainWindow = getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) {
sendJson(res, 503, {success: false, error: 'Main window not available'});
return;
}
showWindow();
sendJson(res, 200, {success: true, data: {focused: true}});
};
const requestHandler = async (req: http.IncomingMessage, res: http.ServerResponse) => {
const remoteAddress = req.socket.remoteAddress;
if (remoteAddress !== '127.0.0.1' && remoteAddress !== '::1' && remoteAddress !== '::ffff:127.0.0.1') {
res.writeHead(403);
res.end();
return;
}
if (rejectIfDisallowedPage(req, res)) {
return;
}
if (handleCors(req, res)) {
return;
}
const url = req.url ?? '/';
try {
switch (url) {
case '/health':
handleHealth(req, res);
break;
case '/navigate':
await handleNavigate(req, res);
break;
case '/focus':
handleFocus(req, res);
break;
default:
sendJson(res, 404, {success: false, error: 'Not found'});
}
} catch (error) {
log.error('[RPC] Request handler error:', error);
sendJson(res, 500, {success: false, error: 'Internal server error'});
}
};
export const startRpcServer = (): Promise<void> => {
return new Promise((resolve, reject) => {
if (server) {
resolve();
return;
}
server = http.createServer(requestHandler);
server.on('error', (error: NodeJS.ErrnoException) => {
if (error.code === 'EADDRINUSE') {
log.warn(`[RPC] Port ${RPC_PORT} already in use, RPC server disabled`);
server = null;
resolve();
} else {
log.error('[RPC] Server error:', error);
reject(error);
}
});
server.listen(RPC_PORT, '127.0.0.1', () => {
log.info(`[RPC] Server listening on http://127.0.0.1:${RPC_PORT}`);
resolve();
});
});
};
export const stopRpcServer = (): Promise<void> => {
return new Promise((resolve) => {
if (!server) {
resolve();
return;
}
server.close((err) => {
if (err) {
log.error('[RPC] Error closing server:', err);
}
server = null;
resolve();
});
});
};

View File

@@ -0,0 +1,217 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {app, ipcMain, type Session, shell, type WebContents} from 'electron';
import log from 'electron-log';
type LanguageCode = string;
interface SpellcheckState {
enabled: boolean;
languages: Array<LanguageCode>;
}
interface RendererSpellcheckState {
enabled?: boolean;
languages?: Array<LanguageCode>;
}
const defaultState: SpellcheckState = {
enabled: true,
languages: [],
};
const isMac = process.platform === 'darwin';
const isWindows = process.platform === 'win32';
const showSpellMenu = isMac || isWindows;
const normalizeLanguage = (code: string): string => code.toLowerCase();
const contextSourceByWebContents = new WeakMap<WebContents, {isTextarea: boolean; ts: number}>();
let contextIpcRegistered = false;
const ensureContextIpc = () => {
if (contextIpcRegistered) return;
contextIpcRegistered = true;
ipcMain.on('spellcheck-context-target', (event, payload: {isTextarea?: boolean}) => {
contextSourceByWebContents.set(event.sender, {
isTextarea: Boolean(payload?.isTextarea),
ts: Date.now(),
});
});
};
const pickSystemLanguages = (session: Session): Array<LanguageCode> => {
const available = session.availableSpellCheckerLanguages ?? [];
const availableMap = new Map(available.map((code) => [normalizeLanguage(code), code]));
const preferred = (typeof app.getPreferredSystemLanguages === 'function' && app.getPreferredSystemLanguages()) || [
app.getLocale(),
];
const selected: Array<LanguageCode> = [];
for (const lang of preferred) {
const normalized = normalizeLanguage(lang);
const exact = availableMap.get(normalized);
if (exact) {
selected.push(exact);
}
}
if (selected.length > 0) return selected;
if (available.length > 0) return [available[0]];
return [];
};
const applyStateToSession = (session: Session, state: SpellcheckState): void => {
session.setSpellCheckerEnabled(state.enabled);
if (!isMac) {
const languages =
state.languages.length > 0
? state.languages
: pickSystemLanguages(session) || session.availableSpellCheckerLanguages;
if (languages && languages.length > 0) {
session.setSpellCheckerLanguages(languages);
}
}
};
const shouldHandleContextMenu = (webContents: WebContents, params: Electron.ContextMenuParams): boolean => {
if (!params.isEditable) return false;
const inputFieldType = (params as {inputFieldType?: string}).inputFieldType;
const isPassword =
(params as {isPassword?: boolean}).isPassword === true ||
inputFieldType === 'password' ||
(params as {formControlType?: string}).formControlType === 'password';
if (isPassword) return false;
const target = contextSourceByWebContents.get(webContents);
const targetRecent = target && Date.now() - target.ts < 5000;
const isTextLike = inputFieldType === 'plainText' || inputFieldType === 'textarea' || inputFieldType === undefined;
return Boolean((targetRecent && target.isTextarea) || isTextLike);
};
export const registerSpellcheck = (webContents: WebContents): void => {
ensureContextIpc();
const session = webContents.session;
let state: SpellcheckState = {...defaultState};
const pickLanguages = (langs: Array<string>, electronSession: Electron.Session): Array<string> => {
if (langs.length > 0) {
return langs;
}
if (!isMac) {
return pickSystemLanguages(electronSession);
}
return [];
};
const normalizeState = (incoming: RendererSpellcheckState | SpellcheckState): SpellcheckState => {
const available = session.availableSpellCheckerLanguages ?? [];
const availableSet = new Set(available.map(normalizeLanguage));
const langs = (incoming.languages ?? state.languages ?? []).filter((lang) =>
availableSet.has(normalizeLanguage(lang)),
);
const pickedLanguages = pickLanguages(langs, session);
return {
enabled: incoming.enabled ?? state.enabled ?? defaultState.enabled,
languages: pickedLanguages,
};
};
const broadcastState = () => {
webContents.send('spellcheck-state-changed', state);
};
const setState = (next: RendererSpellcheckState | SpellcheckState, opts?: {broadcast?: boolean}) => {
state = normalizeState(next);
applyStateToSession(session, state);
if (opts?.broadcast !== false) {
broadcastState();
}
};
setState(state, {broadcast: false});
ipcMain.handle('spellcheck-get-state', () => state);
ipcMain.handle('spellcheck-set-state', (_event, next: RendererSpellcheckState) => {
setState(next);
return state;
});
const openLanguageSettings = async () => {
if (!showSpellMenu) return false;
try {
if (isMac) {
await shell.openExternal('x-apple.systempreferences:com.apple.preference.keyboard');
return true;
}
if (isWindows) {
await shell.openExternal('ms-settings:regionlanguage');
return true;
}
return false;
} catch (error) {
log.warn('[Spellcheck] Failed to open language settings', error);
return false;
}
};
ipcMain.handle('spellcheck-get-available-languages', () => session.availableSpellCheckerLanguages ?? []);
ipcMain.handle('spellcheck-open-language-settings', () => openLanguageSettings());
ipcMain.handle('spellcheck-replace-misspelling', (_event, replacement: string) => {
webContents.replaceMisspelling(replacement);
});
ipcMain.handle('spellcheck-add-word-to-dictionary', (_event, word: string) => {
session.addWordToSpellCheckerDictionary(word);
});
webContents.on('context-menu', (event, params) => {
if (!shouldHandleContextMenu(webContents, params)) {
return;
}
event.preventDefault();
const spellcheckEnabled = session.isSpellCheckerEnabled();
const misspelledWord = params.misspelledWord;
const suggestions = params.dictionarySuggestions || [];
webContents.send('textarea-context-menu', {
misspelledWord: spellcheckEnabled ? misspelledWord : undefined,
suggestions: spellcheckEnabled && misspelledWord ? suggestions : [],
editFlags: {
canUndo: params.editFlags.canUndo,
canRedo: params.editFlags.canRedo,
canCut: params.editFlags.canCut,
canCopy: params.editFlags.canCopy,
canPaste: params.editFlags.canPaste,
canSelectAll: params.editFlags.canSelectAll,
},
x: params.x,
y: params.y,
});
});
};

View File

@@ -0,0 +1,81 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {autoUpdater, type BrowserWindow, ipcMain} from 'electron';
import log from 'electron-log';
import {UpdateSourceType, updateElectronApp} from 'update-electron-app';
import {BUILD_CHANNEL} from '../common/build-channel.js';
import {setQuitting} from './window.js';
export type UpdaterContext = 'user' | 'background' | 'focus';
export type UpdaterEvent =
| {type: 'checking'; context: UpdaterContext}
| {type: 'available'; context: UpdaterContext; version?: string | null}
| {type: 'not-available'; context: UpdaterContext}
| {type: 'downloaded'; context: UpdaterContext; version?: string | null}
| {type: 'error'; context: UpdaterContext; message: string};
let lastContext: UpdaterContext = 'background';
function send(win: BrowserWindow | null, event: UpdaterEvent) {
win?.webContents.send('updater-event', event);
}
export function registerUpdater(getMainWindow: () => BrowserWindow | null) {
updateElectronApp({
updateSource: {
type: UpdateSourceType.StaticStorage,
baseUrl: `https://api.fluxer.app/dl/desktop/${BUILD_CHANNEL}/${process.platform}/${process.arch}`,
},
updateInterval: '12 hours',
logger: log,
notifyUser: false,
});
autoUpdater.on('checking-for-update', () => {
send(getMainWindow(), {type: 'checking', context: lastContext});
});
autoUpdater.on('update-available', () => {
send(getMainWindow(), {type: 'available', context: lastContext, version: null});
});
autoUpdater.on('update-not-available', () => {
send(getMainWindow(), {type: 'not-available', context: lastContext});
});
autoUpdater.on('update-downloaded', (_event, _releaseNotes, releaseName) => {
send(getMainWindow(), {type: 'downloaded', context: lastContext, version: releaseName ?? null});
});
autoUpdater.on('error', (err: Error) => {
send(getMainWindow(), {type: 'error', context: lastContext, message: err?.message ?? String(err)});
});
ipcMain.handle('updater-check', async (_e, context: UpdaterContext) => {
lastContext = context;
autoUpdater.checkForUpdates();
});
ipcMain.handle('updater-install', async () => {
setQuitting(true);
autoUpdater.quitAndInstall();
});
}

View File

@@ -0,0 +1,610 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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<string>,
);
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<string, PendingDisplayMediaRequest>();
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<Electron.Display>, 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<WindowBounds> | 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;
}

View File

@@ -0,0 +1,115 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import fs from 'node:fs';
import path from 'node:path';
import type {BrowserWindow, NativeImage} from 'electron';
import {nativeImage} from 'electron';
const badgeIcons: Array<NativeImage | null> = [];
let hasInit = false;
let lastIndex: number | null = null;
let lastCount: number | null = null;
function isSupported(): boolean {
return process.platform === 'win32';
}
function ensureInitialized(): void {
if (hasInit || !isSupported()) {
return;
}
hasInit = true;
const badgeDir = path.join(process.resourcesPath, 'badges');
for (let i = 1; i <= 11; i++) {
const iconPath = path.join(badgeDir, `badge-${i}.ico`);
if (!fs.existsSync(iconPath)) {
badgeIcons.push(null);
continue;
}
const icon = nativeImage.createFromPath(iconPath);
badgeIcons.push(icon.isEmpty() ? null : icon);
}
}
function getOverlayIconData(count: number): {index: number | null; description: string} {
if (count === -1) {
return {
index: 10,
description: 'Unread messages',
};
}
if (count === 0) {
return {
index: null,
description: 'No Notifications',
};
}
const index = Math.max(1, Math.min(count, 10)) - 1;
return {
index,
description: `${index} notifications`,
};
}
function applyOverlay(win: BrowserWindow | null, count: number, force: boolean): void {
if (!isSupported()) {
return;
}
if (!win || win.isDestroyed()) {
return;
}
const {index, description} = getOverlayIconData(count);
if (force || lastIndex !== index) {
if (index == null) {
win.setOverlayIcon(null, description);
} else {
const icon = badgeIcons[index];
win.setOverlayIcon(icon ?? null, description);
}
lastIndex = index;
}
lastCount = count;
}
export function setWindowsBadgeOverlay(win: BrowserWindow | null, count: number): void {
if (!isSupported()) {
return;
}
ensureInitialized();
applyOverlay(win, count, false);
}
export function refreshWindowsBadgeOverlay(win: BrowserWindow | null): void {
if (!isSupported() || lastCount == null) {
return;
}
ensureInitialized();
applyOverlay(win, lastCount, true);
}

View File

@@ -0,0 +1,252 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import http from 'node:http';
import log from 'electron-log';
import WebSocket, {WebSocketServer} from 'ws';
import {BUILD_CHANNEL} from '../common/build-channel.js';
import {CANARY_APP_URL, STABLE_APP_URL} from '../common/constants.js';
export const WS_PROXY_PORT = BUILD_CHANNEL === 'canary' ? 21866 : 21865;
const ALLOWED_ORIGINS = new Set([STABLE_APP_URL, CANARY_APP_URL]);
let server: http.Server | null = null;
let wss: WebSocketServer | null = null;
const isAllowedOrigin = (origin: string | undefined): boolean => {
if (!origin) return false;
return ALLOWED_ORIGINS.has(origin);
};
const isValidTargetUrl = (url: string | null): boolean => {
if (!url) return false;
try {
const parsed = new URL(url);
return parsed.protocol === 'wss:' || parsed.protocol === 'ws:';
} catch {
return false;
}
};
interface ProxyConnection {
clientSocket: WebSocket;
targetSocket: WebSocket | null;
targetUrl: string;
}
const activeConnections = new Map<WebSocket, ProxyConnection>();
const handleUpgrade = (request: http.IncomingMessage, socket: import('stream').Duplex, head: Buffer): void => {
const remoteAddress = request.socket.remoteAddress;
if (remoteAddress !== '127.0.0.1' && remoteAddress !== '::1' && remoteAddress !== '::ffff:127.0.0.1') {
log.warn('[WS Proxy] Rejected connection from non-localhost:', remoteAddress);
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
socket.destroy();
return;
}
const origin = request.headers.origin;
if (!isAllowedOrigin(origin)) {
log.warn('[WS Proxy] Rejected connection from disallowed origin:', origin);
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
socket.destroy();
return;
}
const url = new URL(request.url ?? '/', `http://${request.headers.host}`);
const targetUrl = url.searchParams.get('target');
if (!isValidTargetUrl(targetUrl)) {
log.warn('[WS Proxy] Invalid or missing target URL:', targetUrl);
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
socket.destroy();
return;
}
wss?.handleUpgrade(request, socket, head, (clientSocket: WebSocket) => {
wss?.emit('connection', clientSocket, request, targetUrl);
});
};
const handleConnection = (clientSocket: WebSocket, _request: http.IncomingMessage, targetUrl: string): void => {
log.info('[WS Proxy] New connection, proxying to:', targetUrl);
const connection: ProxyConnection = {
clientSocket,
targetSocket: null,
targetUrl,
};
activeConnections.set(clientSocket, connection);
const targetSocket = new WebSocket(targetUrl);
connection.targetSocket = targetSocket;
targetSocket.binaryType = 'arraybuffer';
targetSocket.on('open', () => {
log.debug('[WS Proxy] Target connection established:', targetUrl);
});
targetSocket.on('message', (data: Buffer | ArrayBuffer | Array<Buffer>, isBinary: boolean) => {
if (clientSocket.readyState === WebSocket.OPEN) {
try {
if (isBinary) {
if (data instanceof ArrayBuffer) {
clientSocket.send(Buffer.from(data));
} else if (Array.isArray(data)) {
clientSocket.send(Buffer.concat(data));
} else {
clientSocket.send(data);
}
} else {
clientSocket.send(data.toString());
}
} catch (error) {
log.error('[WS Proxy] Error sending to client:', error);
}
}
});
targetSocket.on('close', (code: number, reason: Buffer) => {
log.debug('[WS Proxy] Target closed:', code, reason.toString());
if (clientSocket.readyState === WebSocket.OPEN) {
clientSocket.close(code, reason.toString());
}
activeConnections.delete(clientSocket);
});
targetSocket.on('error', (error: Error) => {
log.error('[WS Proxy] Target socket error:', error);
if (clientSocket.readyState === WebSocket.OPEN) {
clientSocket.close(1011, 'Target connection error');
}
activeConnections.delete(clientSocket);
});
clientSocket.on('message', (data: Buffer | ArrayBuffer | Array<Buffer>, isBinary: boolean) => {
if (targetSocket.readyState === WebSocket.OPEN) {
try {
if (isBinary) {
if (data instanceof ArrayBuffer) {
targetSocket.send(Buffer.from(data));
} else if (Array.isArray(data)) {
targetSocket.send(Buffer.concat(data));
} else {
targetSocket.send(data);
}
} else {
targetSocket.send(data.toString());
}
} catch (error) {
log.error('[WS Proxy] Error sending to target:', error);
}
}
});
clientSocket.on('close', (code: number, reason: Buffer) => {
log.debug('[WS Proxy] Client closed:', code, reason.toString());
if (targetSocket.readyState === WebSocket.OPEN || targetSocket.readyState === WebSocket.CONNECTING) {
targetSocket.close(code, reason.toString());
}
activeConnections.delete(clientSocket);
});
clientSocket.on('error', (error: Error) => {
log.error('[WS Proxy] Client socket error:', error);
if (targetSocket.readyState === WebSocket.OPEN || targetSocket.readyState === WebSocket.CONNECTING) {
targetSocket.close(1011, 'Client connection error');
}
activeConnections.delete(clientSocket);
});
};
export const startWsProxyServer = (): Promise<void> => {
return new Promise((resolve, reject) => {
if (server) {
resolve();
return;
}
server = http.createServer((_req, res) => {
res.writeHead(426, {'Content-Type': 'text/plain'});
res.end('WebSocket connections only');
});
wss = new WebSocketServer({noServer: true});
wss.on('connection', handleConnection);
server.on('upgrade', handleUpgrade);
server.on('error', (error: NodeJS.ErrnoException) => {
if (error.code === 'EADDRINUSE') {
log.warn(`[WS Proxy] Port ${WS_PROXY_PORT} already in use, WS proxy server disabled`);
server = null;
wss = null;
resolve();
} else {
log.error('[WS Proxy] Server error:', error);
reject(error);
}
});
server.listen(WS_PROXY_PORT, '127.0.0.1', () => {
log.info(`[WS Proxy] Server listening on ws://127.0.0.1:${WS_PROXY_PORT}`);
resolve();
});
});
};
export const stopWsProxyServer = (): Promise<void> => {
return new Promise((resolve) => {
for (const connection of activeConnections.values()) {
try {
connection.targetSocket?.close(1001, 'Server shutting down');
connection.clientSocket.close(1001, 'Server shutting down');
} catch {}
}
activeConnections.clear();
if (wss) {
wss.close();
wss = null;
}
if (!server) {
resolve();
return;
}
server.close((err) => {
if (err) {
log.error('[WS Proxy] Error closing server:', err);
}
server = null;
resolve();
});
});
};
export const getWsProxyUrl = (): string | null => {
if (!server) return null;
return `ws://127.0.0.1:${WS_PROXY_PORT}`;
};