initial commit
This commit is contained in:
290
fluxer_app/src-electron/main/api-proxy-server.ts
Normal file
290
fluxer_app/src-electron/main/api-proxy-server.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
};
|
||||
122
fluxer_app/src-electron/main/autostart.ts
Normal file
122
fluxer_app/src-electron/main/autostart.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
72
fluxer_app/src-electron/main/deep-links.ts
Normal file
72
fluxer_app/src-electron/main/deep-links.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
337
fluxer_app/src-electron/main/global-key-hook.ts
Normal file
337
fluxer_app/src-electron/main/global-key-hook.ts
Normal 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();
|
||||
}
|
||||
188
fluxer_app/src-electron/main/index.ts
Normal file
188
fluxer_app/src-electron/main/index.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
777
fluxer_app/src-electron/main/ipc-handlers.ts
Normal file
777
fluxer_app/src-electron/main/ipc-handlers.ts
Normal 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();
|
||||
}
|
||||
278
fluxer_app/src-electron/main/media-proxy-server.ts
Normal file
278
fluxer_app/src-electron/main/media-proxy-server.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
};
|
||||
207
fluxer_app/src-electron/main/menu.ts
Normal file
207
fluxer_app/src-electron/main/menu.ts
Normal 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);
|
||||
}
|
||||
266
fluxer_app/src-electron/main/rpc-server.ts
Normal file
266
fluxer_app/src-electron/main/rpc-server.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
};
|
||||
217
fluxer_app/src-electron/main/spellcheck.ts
Normal file
217
fluxer_app/src-electron/main/spellcheck.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
};
|
||||
81
fluxer_app/src-electron/main/updater.ts
Normal file
81
fluxer_app/src-electron/main/updater.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
610
fluxer_app/src-electron/main/window.ts
Normal file
610
fluxer_app/src-electron/main/window.ts
Normal 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;
|
||||
}
|
||||
115
fluxer_app/src-electron/main/windows-badge.ts
Normal file
115
fluxer_app/src-electron/main/windows-badge.ts
Normal 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);
|
||||
}
|
||||
252
fluxer_app/src-electron/main/ws-proxy-server.ts
Normal file
252
fluxer_app/src-electron/main/ws-proxy-server.ts
Normal 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}`;
|
||||
};
|
||||
Reference in New Issue
Block a user