refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View File

@@ -0,0 +1,21 @@
/*
* 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/>.
*/
declare const LanguageCodeBrand: unique symbol;
export type LanguageCode = string & {readonly __brand: typeof LanguageCodeBrand};

View File

@@ -0,0 +1,23 @@
/*
* 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/>.
*/
export type BuildChannel = 'stable' | 'canary';
export const BUILD_CHANNEL = 'stable' as BuildChannel;
export const IS_CANARY = BUILD_CHANNEL === 'canary';
export const CHANNEL_DISPLAY_NAME = BUILD_CHANNEL;

View File

@@ -0,0 +1,26 @@
/*
* 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/>.
*/
export const APP_PROTOCOL = 'fluxer';
export const STABLE_APP_URL = 'https://web.fluxer.app';
export const CANARY_APP_URL = 'https://web.canary.fluxer.app';
export const DEFAULT_WINDOW_WIDTH = 1280;
export const DEFAULT_WINDOW_HEIGHT = 800;
export const MIN_WINDOW_WIDTH = 800;
export const MIN_WINDOW_HEIGHT = 600;

View File

@@ -0,0 +1,81 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import fs from 'node:fs';
import path from 'node:path';
import {BUILD_CHANNEL} from '@electron/common/BuildChannel';
import {CANARY_APP_URL, STABLE_APP_URL} from '@electron/common/Constants';
import log from 'electron-log';
const CONFIG_FILE_NAME = 'settings.json';
interface DesktopConfig {
app_url?: string;
}
let config: DesktopConfig = {};
let configPath: string | null = null;
function saveDesktopConfig(): void {
if (!configPath) {
log.warn('Desktop config path not initialised; cannot save settings');
return;
}
try {
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
log.info('Saved desktop config to', configPath, {app_url: config.app_url ?? '(default)'});
} catch (error) {
log.error('Failed to save desktop config:', error);
}
}
export function loadDesktopConfig(userDataPath: string): void {
configPath = path.join(userDataPath, CONFIG_FILE_NAME);
try {
if (fs.existsSync(configPath)) {
const data = fs.readFileSync(configPath, 'utf-8');
config = JSON.parse(data) as DesktopConfig;
log.info('Loaded desktop config from', configPath, {app_url: config.app_url ?? '(default)'});
}
} catch (error) {
log.error('Failed to load desktop config:', error);
}
}
export function getAppUrl(): string {
if (config.app_url) {
return config.app_url;
}
return BUILD_CHANNEL === 'canary' ? CANARY_APP_URL : STABLE_APP_URL;
}
export function getCustomAppUrl(): string | null {
return config.app_url ?? null;
}
export function setCustomAppUrl(appUrl: string | null): void {
if (appUrl) {
config.app_url = appUrl;
} else {
delete config.app_url;
}
saveDesktopConfig();
}

View File

@@ -0,0 +1,41 @@
/*
* 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 {BUILD_CHANNEL} from '@electron/common/BuildChannel';
import log from 'electron-log';
log.transports.file.level = BUILD_CHANNEL === 'canary' ? 'debug' : 'info';
log.transports.console.level = BUILD_CHANNEL === 'canary' ? 'debug' : 'info';
export const Logger = {
debug: (...args: Array<unknown>) => log.debug(...args),
info: (...args: Array<unknown>) => log.info(...args),
warn: (...args: Array<unknown>) => log.warn(...args),
error: (...args: Array<unknown>) => log.error(...args),
};
export function createChildLogger(componentName: string): typeof Logger {
const prefix = `[${componentName}]`;
return {
debug: (...args: Array<unknown>) => log.debug(prefix, ...args),
info: (...args: Array<unknown>) => log.info(prefix, ...args),
warn: (...args: Array<unknown>) => log.warn(prefix, ...args),
error: (...args: Array<unknown>) => log.error(prefix, ...args),
};
}

View File

@@ -0,0 +1,258 @@
/*
* 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 type {
AuthenticationResponseJSON,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
RegistrationResponseJSON,
} from '@simplewebauthn/browser';
export interface DesktopInfo {
version: string;
channel: 'stable' | 'canary';
arch: string;
hardwareArch: string;
runningUnderRosetta: boolean;
os: NodeJS.Platform;
osVersion: string;
systemVersion?: string;
}
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: 'progress';
context: UpdaterContext;
percent: number;
transferred: number;
total: number;
bytesPerSecond: number;
}
| {type: 'error'; context: UpdaterContext; message: string};
export interface DownloadFileOptions {
url: string;
defaultPath: string;
}
export interface DownloadFileResult {
success: boolean;
path?: string;
error?: string;
}
export interface SwitchInstanceUrlOptions {
instanceUrl: string;
desktopHandoffCode?: string | null;
}
export interface GlobalShortcutOptions {
accelerator: string;
id: string;
}
export type MediaAccessType = 'microphone' | 'camera' | 'screen';
export type MediaAccessStatus = 'granted' | 'denied' | 'not-determined' | 'restricted' | 'unknown';
export interface DesktopSource {
id: string;
name: string;
thumbnailDataUrl: string;
appIconDataUrl?: string;
display_id?: string;
}
export interface DisplayMediaRequestInfo {
audioRequested: boolean;
videoRequested: boolean;
supportsLoopbackAudio: boolean;
supportsSystemAudioCapture: boolean;
}
export interface NotificationOptions {
title: string;
body: string;
icon?: string;
url?: string;
}
export interface NotificationResult {
id: string;
}
export interface ElectronAPI {
platform: NodeJS.Platform;
getDesktopInfo: () => Promise<DesktopInfo>;
onUpdaterEvent: (callback: (event: UpdaterEvent) => void) => () => void;
updaterCheck: (context: UpdaterContext) => Promise<void>;
updaterInstall: () => Promise<void>;
windowMinimize: () => void;
windowMaximize: () => void;
windowClose: () => void;
windowIsMaximized: () => Promise<boolean>;
onWindowMaximizeChange: (callback: (maximized: boolean) => void) => () => void;
openExternal: (url: string) => Promise<void>;
clipboardWriteText: (text: string) => Promise<void>;
clipboardReadText: () => Promise<string>;
onDeepLink: (callback: (url: string) => void) => () => void;
getInitialDeepLink: () => Promise<string | null>;
onRpcNavigate: (callback: (path: string) => void) => () => void;
registerGlobalShortcut: (accelerator: string, id: string) => Promise<boolean>;
unregisterGlobalShortcut: (accelerator: string) => Promise<void>;
unregisterAllGlobalShortcuts: () => Promise<void>;
onGlobalShortcut: (callback: (id: string) => void) => () => void;
autostartEnable: () => Promise<void>;
autostartDisable: () => Promise<void>;
autostartIsEnabled: () => Promise<boolean>;
autostartIsInitialized: () => Promise<boolean>;
autostartMarkInitialized: () => Promise<void>;
checkMediaAccess: (type: MediaAccessType) => Promise<MediaAccessStatus>;
requestMediaAccess: (type: MediaAccessType) => Promise<boolean>;
openMediaAccessSettings: (type: MediaAccessType) => Promise<void>;
checkAccessibility: (prompt: boolean) => Promise<boolean>;
openAccessibilitySettings: () => Promise<void>;
openInputMonitoringSettings: () => Promise<void>;
downloadFile: (url: string, defaultPath: string) => Promise<DownloadFileResult>;
toggleDevTools: () => void;
showNotification: (options: NotificationOptions) => Promise<NotificationResult>;
closeNotification: (id: string) => void;
closeNotifications: (ids: Array<string>) => void;
onNotificationClick: (callback: (id: string, url?: string) => void) => () => void;
setBadgeCount: (count: number) => void;
getBadgeCount: () => Promise<number>;
bounceDock: (type?: 'critical' | 'informational') => number;
cancelBounceDock: (id: number) => void;
setZoomFactor: (factor: number) => void;
getZoomFactor: () => Promise<number>;
onZoomIn: (callback: () => void) => () => void;
onZoomOut: (callback: () => void) => () => void;
onZoomReset: (callback: () => void) => () => void;
onOpenSettings: (callback: () => void) => () => void;
globalKeyHookStart: () => Promise<boolean>;
globalKeyHookStop: () => Promise<void>;
globalKeyHookIsRunning: () => Promise<boolean>;
checkInputMonitoringAccess: () => Promise<boolean>;
globalKeyHookRegister: (options: GlobalKeyHookRegisterOptions) => Promise<void>;
globalKeyHookUnregister: (id: string) => Promise<void>;
globalKeyHookUnregisterAll: () => Promise<void>;
onGlobalKeyEvent: (callback: (event: GlobalKeyEvent) => void) => () => void;
onGlobalMouseEvent: (callback: (event: GlobalMouseEvent) => void) => () => void;
onGlobalKeybindTriggered: (callback: (event: GlobalKeybindTriggeredEvent) => void) => () => void;
spellcheckGetState: () => Promise<SpellcheckState>;
spellcheckSetState: (state: Partial<SpellcheckState>) => Promise<SpellcheckState>;
spellcheckGetAvailableLanguages: () => Promise<Array<string>>;
spellcheckOpenLanguageSettings: () => Promise<boolean>;
onSpellcheckStateChanged: (callback: (state: SpellcheckState) => void) => () => void;
onTextareaContextMenu: (callback: (params: TextareaContextMenuParams) => void) => () => void;
spellcheckReplaceMisspelling: (replacement: string) => Promise<void>;
spellcheckAddWordToDictionary: (word: string) => Promise<void>;
passkeyIsSupported: () => Promise<boolean>;
passkeyAuthenticate: (options: PublicKeyCredentialRequestOptionsJSON) => Promise<AuthenticationResponseJSON>;
passkeyRegister: (options: PublicKeyCredentialCreationOptionsJSON) => Promise<RegistrationResponseJSON>;
switchInstanceUrl: (options: SwitchInstanceUrlOptions) => Promise<void>;
consumeDesktopHandoffCode: () => Promise<string | null>;
getDesktopSources: (types: Array<'screen' | 'window'>, requestId?: string) => Promise<Array<DesktopSource>>;
onDisplayMediaRequested?: (callback: (requestId: string, info: DisplayMediaRequestInfo) => void) => () => void;
selectDisplayMediaSource: (requestId: string, sourceId: string | null, withAudio: boolean) => void;
}
export interface GlobalKeyHookRegisterOptions {
id: string;
keycode?: number;
mouseButton?: number;
ctrl?: boolean;
alt?: boolean;
shift?: boolean;
meta?: boolean;
}
export interface GlobalKeyEvent {
type: 'keydown' | 'keyup';
keycode: number;
keyName: string;
altKey: boolean;
ctrlKey: boolean;
shiftKey: boolean;
metaKey: boolean;
}
export interface GlobalMouseEvent {
type: 'mousedown' | 'mouseup';
button: number;
}
export interface GlobalKeybindTriggeredEvent {
id: string;
type: 'keydown' | 'keyup';
}
export interface SpellcheckState {
enabled: boolean;
languages: Array<string>;
}
export interface TextareaContextMenuParams {
misspelledWord?: string;
suggestions?: Array<string>;
editFlags: {
canUndo: boolean;
canRedo: boolean;
canCut: boolean;
canCopy: boolean;
canPaste: boolean;
canSelectAll: boolean;
};
x: number;
y: number;
}
declare global {
interface Window {
electron?: ElectronAPI;
}
}

View File

@@ -0,0 +1,61 @@
/*
* 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 path from 'node:path';
import {BUILD_CHANNEL, type BuildChannel} from '@electron/common/BuildChannel';
import {app} from 'electron';
interface UserDataPaths {
readonly channel: BuildChannel;
readonly directoryName: string;
readonly base: string;
}
interface ChannelStorageDirectoryMap {
stable: string;
canary: string;
}
const channelStorageDirectoryMap: ChannelStorageDirectoryMap = {
stable: 'fluxer',
canary: 'fluxercanary',
};
function resolveUserDataPaths(channel: BuildChannel): {directoryName: string; base: string} {
const directoryName = channelStorageDirectoryMap[channel];
const appDataPath = app.getPath('appData');
const base = path.join(appDataPath, directoryName);
return {
directoryName,
base,
};
}
export function configureUserDataPath(): UserDataPaths {
const channel = BUILD_CHANNEL;
const {directoryName, base} = resolveUserDataPaths(channel);
app.setPath('userData', base);
return {
channel,
directoryName,
base,
};
}

View File

@@ -0,0 +1,125 @@
/*
* 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 {BUILD_CHANNEL} from '@electron/common/BuildChannel';
import {app, ipcMain} from 'electron';
import log from 'electron-log';
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 (error) {
log.error('[Autostart] Failed to mark initialized:', error);
}
}
const isMac = process.platform === 'darwin';
interface AutoLaunchConfig {
name: string;
path: string;
isHidden: boolean;
args?: Array<string>;
}
function getAutoLaunchConfig(): AutoLaunchConfig {
const isCanary = BUILD_CHANNEL === 'canary';
const appName = isCanary ? 'Fluxer Canary' : 'Fluxer';
return {
name: appName,
path: process.execPath,
isHidden: true,
args: [],
};
}
async function enableAutostart(): Promise<void> {
if (!isMac) return;
const config = getAutoLaunchConfig();
app.setLoginItemSettings({
openAtLogin: true,
openAsHidden: config.isHidden,
name: config.name,
});
}
async function disableAutostart(): Promise<void> {
if (!isMac) return;
const config = getAutoLaunchConfig();
app.setLoginItemSettings({
openAtLogin: false,
name: config.name,
path: config.path,
args: config.args,
});
}
async function isAutostartEnabled(): Promise<boolean> {
if (!isMac) return false;
const config = getAutoLaunchConfig();
const settings = app.getLoginItemSettings({
path: config.path,
args: config.args,
});
return settings.openAtLogin;
}
export function registerAutostartHandlers(): void {
if (!isMac) return;
ipcMain.handle('autostart-enable', async (): Promise<void> => {
await enableAutostart();
});
ipcMain.handle('autostart-disable', async (): Promise<void> => {
await disableAutostart();
});
ipcMain.handle('autostart-is-enabled', async (): Promise<boolean> => {
return isAutostartEnabled();
});
ipcMain.handle('autostart-is-initialized', (): boolean => {
return isInitialized();
});
ipcMain.handle('autostart-mark-initialized', (): void => {
markInitialized();
});
}

View File

@@ -0,0 +1,72 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {APP_PROTOCOL} from '@electron/common/Constants';
import {getMainWindow, showWindow} from '@electron/main/Window';
import {app, ipcMain} from 'electron';
let initialDeepLink: string | null = null;
export function initializeDeepLinks(): void {
if (process.defaultApp) {
if (process.argv.length >= 2) {
app.setAsDefaultProtocolClient(APP_PROTOCOL, process.execPath, [process.argv[1]]);
}
} else {
app.setAsDefaultProtocolClient(APP_PROTOCOL);
}
const deepLinkArg = process.argv.find((arg) => arg.startsWith(`${APP_PROTOCOL}://`));
if (deepLinkArg) {
initialDeepLink = deepLinkArg;
}
ipcMain.handle('get-initial-deep-link', (): string | null => {
const url = initialDeepLink;
initialDeepLink = null;
return url;
});
}
export function handleOpenUrl(url: string): void {
const mainWindow = getMainWindow();
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('deep-link', url);
showWindow();
} else {
initialDeepLink = url;
}
}
export function handleSecondInstance(argv: Array<string>): void {
const url = argv.find((arg) => arg.startsWith(`${APP_PROTOCOL}://`));
if (url) {
const mainWindow = getMainWindow();
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('deep-link', url);
showWindow();
} else {
initialDeepLink = url;
}
} else {
showWindow();
}
}

View File

@@ -0,0 +1,332 @@
/*
* 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 {createChildLogger} from '@electron/common/Logger';
import {getMainWindow} from '@electron/main/Window';
import {ipcMain} from 'electron';
import type {UiohookKeyboardEvent, UiohookMouseEvent} from 'uiohook-napi';
import {UiohookKey, uIOhook} from 'uiohook-napi';
const logger = createChildLogger('GlobalKeyHook');
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 {
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 {
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) {
logger.error('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) {
logger.error('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) {
logger.error('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;
}
logger.warn('Input Monitoring access denied, status:', status);
return false;
}
function stopHook(): void {
if (!hookStarted || !uIOhook) return;
try {
uIOhook.stop();
hookStarted = false;
} catch (error) {
logger.error('Failed to stop:', error);
}
}
export function registerGlobalKeyHookHandlers(): void {
ipcMain.handle('global-key-hook-start', async (): Promise<boolean> => {
return startHook();
});
ipcMain.handle('global-key-hook-stop', (): void => {
stopHook();
});
ipcMain.handle('global-key-hook-is-running', (): boolean => {
return hookStarted;
});
ipcMain.handle('check-input-monitoring-access', async (): Promise<boolean> => {
return checkInputMonitoringAccess();
});
ipcMain.handle(
'global-key-hook-register',
(
_event,
options: {
id: string;
keycode?: number;
mouseButton?: number;
ctrl?: boolean;
alt?: boolean;
shift?: boolean;
meta?: boolean;
},
): void => {
registeredKeybinds.set(options.id, {
id: options.id,
keycode: options.keycode ?? 0,
mouseButton: options.mouseButton,
modifiers: {
ctrl: options.ctrl ?? false,
alt: options.alt ?? false,
shift: options.shift ?? false,
meta: options.meta ?? false,
},
});
},
);
ipcMain.handle('global-key-hook-unregister', (_event, id: string): void => {
registeredKeybinds.delete(id);
});
ipcMain.handle('global-key-hook-unregister-all', (): void => {
registeredKeybinds.clear();
});
}
export function cleanupGlobalKeyHook(): void {
stopHook();
registeredKeybinds.clear();
activeKeys.clear();
}

View File

@@ -0,0 +1,914 @@
/*
* 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 child_process from 'node:child_process';
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 {BUILD_CHANNEL} from '@electron/common/BuildChannel';
import {setCustomAppUrl} from '@electron/common/DesktopConfig';
import {createChildLogger} from '@electron/common/Logger';
import type {
DesktopInfo,
DownloadFileResult,
GlobalShortcutOptions,
MediaAccessType,
NotificationOptions,
SwitchInstanceUrlOptions,
} from '@electron/common/Types';
import {getMainWindow} from '@electron/main/Window';
import {setWindowsBadgeOverlay} from '@electron/main/WindowsBadge';
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';
const logger = createChildLogger('IpcHandlers');
const registeredShortcuts = new Map<string, string>();
let pendingDesktopHandoffCode: string | null = null;
function normalizeInstanceOrigin(rawUrl: string): string {
const trimmed = rawUrl.trim();
if (!trimmed) {
throw new Error('Instance URL is required');
}
let candidate = trimmed;
if (!/^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//.test(candidate)) {
candidate = `https://${candidate}`;
}
let url: URL;
try {
url = new URL(candidate);
} catch {
throw new Error('Invalid instance URL');
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
throw new Error('Instance URL must use http or https');
}
return url.origin;
}
function isValidWellKnownPayload(payload: unknown): boolean {
if (!payload || typeof payload !== 'object') {
return false;
}
if (!('endpoints' in payload)) {
return false;
}
const endpoints = (payload as {endpoints?: unknown}).endpoints;
if (!endpoints || typeof endpoints !== 'object') {
return false;
}
const api = (endpoints as {api?: unknown}).api;
const gateway = (endpoints as {gateway?: unknown}).gateway;
return typeof api === 'string' && typeof gateway === 'string';
}
async function assertValidFluxerInstance(instanceOrigin: string): Promise<void> {
const url = new URL('/.well-known/fluxer', instanceOrigin).toString();
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
},
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = (await response.json()) as unknown;
if (!isValidWellKnownPayload(payload)) {
throw new Error('Malformed discovery document');
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Not a valid Fluxer instance (${message})`);
} finally {
clearTimeout(timeout);
}
}
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 = {};
function 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 runSysctl = (query: string): string | null => {
try {
return child_process
.execSync(query, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
})
.trim();
} catch {
return null;
}
};
const detectRosettaMode = (): boolean => {
if (process.platform !== 'darwin') {
return false;
}
const translated = runSysctl('sysctl -n sysctl.proc_translated');
return translated === '1';
};
const detectHardwareArch = (): string => {
if (process.platform !== 'darwin') {
return os.arch();
}
const optionalArm64 = runSysctl('sysctl -n hw.optional.arm64');
if (optionalArm64 === '1') {
return 'arm64';
}
return os.arch();
};
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;
};
async function callWithFallback<T>(addonOperation: () => Promise<T>, nativeOperation: () => Promise<T>): Promise<T> {
if (!useAddon) {
return nativeOperation();
}
try {
return await addonOperation();
} catch (error) {
if (isMissingApplicationIdentifierError(error)) {
logger.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') {
logger.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) {
logger.warn('Failed to initialize electron-webauthn-mac:', error);
return null;
}
}
export function registerIpcHandlers(): void {
ipcMain.handle('switch-instance-url', async (_event, options: SwitchInstanceUrlOptions): Promise<void> => {
const instanceOrigin = normalizeInstanceOrigin(options.instanceUrl);
await assertValidFluxerInstance(instanceOrigin);
const mainWindow = getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) {
throw new Error('Main window not available');
}
pendingDesktopHandoffCode = options.desktopHandoffCode ?? null;
setCustomAppUrl(instanceOrigin);
try {
await mainWindow.loadURL(instanceOrigin);
} catch (error) {
setCustomAppUrl(null);
pendingDesktopHandoffCode = null;
const detail = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to load instance: ${detail}`);
}
});
ipcMain.handle('consume-desktop-handoff-code', (): string | null => {
const code = pendingDesktopHandoffCode;
pendingDesktopHandoffCode = null;
return code;
});
ipcMain.handle(
'get-desktop-info',
(): DesktopInfo => ({
version: app.getVersion(),
channel: BUILD_CHANNEL,
arch: process.arch,
hardwareArch: detectHardwareArch(),
runningUnderRosetta: detectRosettaMode(),
os: process.platform,
osVersion: os.release(),
systemVersion: process.getSystemVersion(),
}),
);
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,
silent: true,
};
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) {
logger.warn('Failed to load icon:', error);
}
}
const notification = new Notification(notificationOpts);
activeNotifications.set(id, {notification, url: options.url});
notification.on('click', () => {
const mainWindow = getMainWindow();
if (mainWindow) {
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
mainWindow.show();
mainWindow.focus();
mainWindow.webContents.send('notification-click', id, options.url);
}
activeNotifications.delete(id);
});
notification.on('close', () => {
activeNotifications.delete(id);
});
notification.show();
return {id};
});
ipcMain.on('close-notification', (_event, id: string) => {
const active = activeNotifications.get(id);
if (active) {
active.notification.close();
activeNotifications.delete(id);
}
});
ipcMain.on('close-notifications', (_event, ids: Array<string>) => {
for (const id of ids) {
const active = activeNotifications.get(id);
if (active) {
active.notification.close();
activeNotifications.delete(id);
}
}
});
ipcMain.on('set-badge-count', (_event, count: number) => {
if (process.platform === 'darwin') {
app.setBadgeCount(count);
} else if (process.platform === 'win32') {
setWindowsBadgeOverlay(getMainWindow(), count);
} else {
app.setBadgeCount(count);
}
});
ipcMain.handle('get-badge-count', (): number => {
return app.getBadgeCount();
});
ipcMain.on('bounce-dock', (event, type: 'critical' | 'informational') => {
if (process.platform === 'darwin' && app.dock) {
const id = app.dock.bounce(type);
event.returnValue = id;
} else {
event.returnValue = -1;
}
});
ipcMain.on('cancel-bounce-dock', (_event, id: number) => {
if (process.platform === 'darwin' && app.dock && id >= 0) {
app.dock.cancelBounce(id);
}
});
ipcMain.on('set-zoom-factor', (event, factor: number) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (win && factor > 0) {
win.webContents.setZoomFactor(factor);
}
});
ipcMain.handle('get-zoom-factor', (event): number => {
const win = BrowserWindow.fromWebContents(event.sender);
return win?.webContents.getZoomFactor() ?? 1;
});
ipcMain.handle('passkey-is-supported', (): Promise<boolean> => {
return passkeyProvider.isSupported();
});
ipcMain.handle(
'passkey-authenticate',
async (_event, options: PublicKeyCredentialRequestOptionsJSON): Promise<AuthenticationResponseJSON> => {
return passkeyProvider.authenticate(options);
},
);
ipcMain.handle(
'passkey-register',
async (_event, options: PublicKeyCredentialCreationOptionsJSON): Promise<RegistrationResponseJSON> => {
return passkeyProvider.register(options);
},
);
}
function downloadToBuffer(url: string): Promise<Buffer> {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https://') ? https : http;
protocol
.get(url, (response) => {
if (response.statusCode === 301 || response.statusCode === 302) {
const redirectUrl = response.headers.location;
if (redirectUrl) {
downloadToBuffer(redirectUrl).then(resolve).catch(reject);
return;
}
}
if (response.statusCode !== 200) {
reject(new Error(`HTTP ${response.statusCode}`));
return;
}
const chunks: Array<Buffer> = [];
response.on('data', (chunk: Buffer) => chunks.push(chunk));
response.on('end', () => resolve(Buffer.concat(chunks)));
response.on('error', reject);
})
.on('error', reject);
});
}
function downloadFile(url: string, destPath: string): Promise<void> {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https://') ? https : http;
const file = fs.createWriteStream(destPath);
protocol
.get(url, (response) => {
if (response.statusCode === 301 || response.statusCode === 302) {
const redirectUrl = response.headers.location;
if (redirectUrl) {
file.close();
fs.unlinkSync(destPath);
downloadFile(redirectUrl, destPath).then(resolve).catch(reject);
return;
}
}
if (response.statusCode !== 200) {
file.close();
fs.unlinkSync(destPath);
reject(new Error(`HTTP ${response.statusCode}`));
return;
}
response.pipe(file);
file.on('finish', () => {
file.close();
resolve();
});
})
.on('error', (err) => {
file.close();
fs.unlink(destPath, () => {});
reject(err);
});
});
}
export function cleanupIpcHandlers(): void {
globalShortcut.unregisterAll();
registeredShortcuts.clear();
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,746 @@
/*
* 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';
import {
CANARY_APP_URL,
DEFAULT_WINDOW_HEIGHT,
DEFAULT_WINDOW_WIDTH,
MIN_WINDOW_HEIGHT,
MIN_WINDOW_WIDTH,
STABLE_APP_URL,
} from '@electron/common/Constants';
import {getAppUrl, getCustomAppUrl} from '@electron/common/DesktopConfig';
import {createChildLogger} from '@electron/common/Logger';
import {registerSpellcheck} from '@electron/main/Spellcheck';
import {refreshWindowsBadgeOverlay} from '@electron/main/WindowsBadge';
import {app, BrowserWindow, desktopCapturer, ipcMain, screen, shell} from 'electron';
import log from 'electron-log';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const logger = createChildLogger('Window');
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;
if (trustedWebOrigins.has(origin)) return true;
const customUrl = getCustomAppUrl();
if (customUrl) {
try {
return new URL(customUrl).origin === origin;
} catch {
return false;
}
}
return false;
}
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;
cachedSources?: Array<Electron.DesktopCapturerSource>;
}
const pendingDisplayMediaRequests = new Map<string, PendingDisplayMediaRequest>();
let displayMediaRequestCounter = 0;
const DESKTOP_SOURCE_CACHE_TTL_MS = 60_000;
let latestDesktopSources: Array<Electron.DesktopCapturerSource> = [];
let latestDesktopSourcesTimestamp = 0;
function normaliseDesktopSourceId(sourceId: string): string {
const segments = sourceId.split(':');
if (segments.length < 2) {
return sourceId;
}
return `${segments[0]}:${segments[1]}`;
}
function resolveSelectedDesktopSource(
sources: Array<Electron.DesktopCapturerSource>,
requestedSourceId: string,
): Electron.DesktopCapturerSource | null {
const exactMatch = sources.find((source) => source.id === requestedSourceId);
if (exactMatch) {
return exactMatch;
}
if (process.platform !== 'linux') {
return null;
}
const normalisedRequestedId = normaliseDesktopSourceId(requestedSourceId);
const normalisedMatch = sources.find((source) => normaliseDesktopSourceId(source.id) === normalisedRequestedId);
if (normalisedMatch) {
log.warn('[selectDisplayMediaSource] Falling back to normalised Linux source ID match', {
requestedSourceId,
matchedSourceId: normalisedMatch.id,
});
return normalisedMatch;
}
if (sources.length === 1) {
log.warn('[selectDisplayMediaSource] Falling back to only Linux source in cache', {
requestedSourceId,
fallbackSourceId: sources[0].id,
});
return sources[0];
}
return null;
}
function setupDisplayMediaHandler(session: Electron.Session, webContents: Electron.WebContents): void {
session.setDisplayMediaRequestHandler((request, callback) => {
const requestId = `display-media-${++displayMediaRequestCounter}`;
let callbackInvoked = false;
const invokeCallback = (streams: Electron.Streams | null): void => {
if (callbackInvoked) {
log.warn('[DisplayMedia] Callback already invoked for request:', requestId);
return;
}
callbackInvoked = true;
try {
if (streams === null) {
callback({});
} else {
callback(streams);
}
} catch (error) {
log.warn('[DisplayMedia] Callback threw:', {requestId, error});
}
};
const audioRequested = Boolean(request.audioRequested);
const videoRequested = Boolean(request.videoRequested);
if (!videoRequested) {
log.warn('[DisplayMedia] Rejecting request without video stream', {
requestId,
audioRequested,
videoRequested,
});
invokeCallback(null);
return;
}
pendingDisplayMediaRequests.set(requestId, {callback: invokeCallback});
const supportsLoopbackAudio = process.platform === 'win32';
const supportsSystemAudioCapture = false;
webContents.send('display-media-requested', requestId, {
audioRequested,
videoRequested,
supportsLoopbackAudio,
supportsSystemAudioCapture,
});
setTimeout(() => {
if (pendingDisplayMediaRequests.has(requestId)) {
log.warn('[DisplayMedia] Request timed out:', requestId);
pendingDisplayMediaRequests.delete(requestId);
invokeCallback(null);
}
}, 60000);
});
}
export function registerDisplayMediaHandlers(): void {
ipcMain.handle(
'get-desktop-sources',
async (
_event,
types: Array<'screen' | 'window'>,
requestId?: string,
): 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,
});
latestDesktopSources = sources;
latestDesktopSourcesTimestamp = Date.now();
if (requestId) {
const pending = pendingDisplayMediaRequests.get(requestId);
if (pending) {
pending.cachedSources = sources;
log.debug('[getDesktopSources] Cached sources for request:', requestId);
}
}
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 {
let sources = pending.cachedSources;
if (!sources || sources.length === 0) {
const sourceCacheAgeMs = Date.now() - latestDesktopSourcesTimestamp;
const hasFreshGlobalCache =
latestDesktopSources.length > 0 && sourceCacheAgeMs >= 0 && sourceCacheAgeMs <= DESKTOP_SOURCE_CACHE_TTL_MS;
if (hasFreshGlobalCache && process.platform !== 'linux') {
log.debug('[selectDisplayMediaSource] Request cache miss, using fresh global source cache:', requestId);
sources = latestDesktopSources;
} else if (process.platform === 'linux') {
log.warn(
'[selectDisplayMediaSource] Source cache miss on Linux; cancelling to avoid duplicate portal picker:',
requestId,
);
pending.callback(null);
return;
} else {
log.debug(
'[selectDisplayMediaSource] Cache miss, resolving sources from desktopCapturer on non-Linux:',
requestId,
);
sources = await desktopCapturer.getSources({
types: ['screen', 'window'],
});
}
} else {
log.debug('[selectDisplayMediaSource] Cache hit for request:', requestId);
}
const selectedSource = resolveSelectedDesktopSource(sources, 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 streams: Electron.Streams = {
video: selectedSource,
};
if (withAudio && process.platform === 'win32') {
streams.audio = 'loopback';
}
pending.callback(streams);
} 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 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 = getAppUrl();
mainWindow.loadURL(appUrl).catch((error) => {
logger.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 (error) {
log.warn('[Window] Failed to show dock:', error);
}
try {
app.focus({steal: true});
} catch (error) {
log.warn('[Window] Failed to focus app:', error);
}
try {
mainWindow.setVisibleOnAllWorkspaces(true, {visibleOnFullScreen: true});
} catch (error) {
log.warn('[Window] Failed to set visible on all workspaces:', error);
}
mainWindow.show();
mainWindow.focus();
setTimeout(() => {
if (!mainWindow || mainWindow.isDestroyed()) return;
try {
mainWindow.setVisibleOnAllWorkspaces(false);
} catch (error) {
log.warn('[Window] Failed to disable visible on all workspaces:', error);
}
}, 250);
} else {
mainWindow.show();
mainWindow.focus();
}
}
}
export function hideWindow(): void {
if (mainWindow) {
mainWindow.hide();
}
}
export function setQuitting(quitting: boolean): void {
isQuitting = quitting;
}

View File

@@ -0,0 +1,115 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import fs from 'node:fs';
import path from 'node:path';
import type {BrowserWindow, NativeImage} from 'electron';
import {nativeImage} from 'electron';
const badgeIcons: Array<NativeImage | null> = [];
let hasInit = false;
let lastIndex: number | null = null;
let lastCount: number | null = null;
function isSupported(): boolean {
return process.platform === 'win32';
}
function ensureInitialized(): void {
if (hasInit || !isSupported()) {
return;
}
hasInit = true;
const badgeDir = path.join(process.resourcesPath, 'badges');
for (let i = 1; i <= 11; i++) {
const iconPath = path.join(badgeDir, `badge-${i}.ico`);
if (!fs.existsSync(iconPath)) {
badgeIcons.push(null);
continue;
}
const icon = nativeImage.createFromPath(iconPath);
badgeIcons.push(icon.isEmpty() ? null : icon);
}
}
function getOverlayIconData(count: number): {index: number | null; description: string} {
if (count === -1) {
return {
index: 10,
description: 'Unread messages',
};
}
if (count === 0) {
return {
index: null,
description: 'No Notifications',
};
}
const index = Math.max(1, Math.min(count, 10)) - 1;
return {
index,
description: `${index} notifications`,
};
}
function applyOverlay(win: BrowserWindow | null, count: number, force: boolean): void {
if (!isSupported()) {
return;
}
if (!win || win.isDestroyed()) {
return;
}
const {index, description} = getOverlayIconData(count);
if (force || lastIndex !== index) {
if (index == null) {
win.setOverlayIcon(null, description);
} else {
const icon = badgeIcons[index];
win.setOverlayIcon(icon ?? null, description);
}
lastIndex = index;
}
lastCount = count;
}
export function setWindowsBadgeOverlay(win: BrowserWindow | null, count: number): void {
if (!isSupported()) {
return;
}
ensureInitialized();
applyOverlay(win, count, false);
}
export function refreshWindowsBadgeOverlay(win: BrowserWindow | null): void {
if (!isSupported() || lastCount == null) {
return;
}
ensureInitialized();
applyOverlay(win, lastCount, true);
}

View File

@@ -0,0 +1,177 @@
/*
* 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 {BUILD_CHANNEL} from '@electron/common/BuildChannel';
import {loadDesktopConfig} from '@electron/common/DesktopConfig';
import {configureUserDataPath} from '@electron/common/UserDataPath';
import {registerAutostartHandlers} from '@electron/main/Autostart';
import {handleOpenUrl, handleSecondInstance, initializeDeepLinks} from '@electron/main/DeepLinks';
import {cleanupGlobalKeyHook, registerGlobalKeyHookHandlers} from '@electron/main/GlobalKeyHook';
import {cleanupIpcHandlers, registerIpcHandlers} from '@electron/main/IpcHandlers';
import {createApplicationMenu} from '@electron/main/Menu';
import {startRpcServer, stopRpcServer} from '@electron/main/RpcServer';
import {registerUpdater} from '@electron/main/Updater';
import {
createWindow,
getMainWindow,
registerDisplayMediaHandlers,
setQuitting,
showWindow,
} from '@electron/main/Window';
import {app, globalShortcut} from 'electron';
import log from 'electron-log';
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,
});
loadDesktopConfig(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.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);
app.commandLine.appendSwitch('enable-features', 'WebRTCPipeWireCapturer');
}
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);
});
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();
});
process.on('uncaughtException', (error: unknown) => {
log.error('Uncaught exception:', error);
});
process.on('unhandledRejection', (reason: unknown, promise: Promise<unknown>) => {
log.error('Unhandled rejection at:', promise, 'reason:', reason);
});
}

View File

@@ -0,0 +1,300 @@
/*
* 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 type {
DesktopInfo,
DesktopSource,
DisplayMediaRequestInfo,
DownloadFileResult,
ElectronAPI,
GlobalKeybindTriggeredEvent,
GlobalKeyEvent,
GlobalKeyHookRegisterOptions,
GlobalMouseEvent,
MediaAccessStatus,
MediaAccessType,
NotificationOptions,
NotificationResult,
SpellcheckState,
SwitchInstanceUrlOptions,
TextareaContextMenuParams,
UpdaterContext,
UpdaterEvent,
} from '@electron/common/Types';
import type {
AuthenticationResponseJSON,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
RegistrationResponseJSON,
} from '@simplewebauthn/browser';
import {contextBridge, ipcRenderer} from 'electron';
const api: ElectronAPI = {
platform: process.platform,
getDesktopInfo: (): Promise<DesktopInfo> => ipcRenderer.invoke('get-desktop-info'),
onUpdaterEvent: (callback: (event: UpdaterEvent) => void): (() => void) => {
const handler = (_event: Electron.IpcRendererEvent, data: UpdaterEvent) => callback(data);
ipcRenderer.on('updater-event', handler);
return () => ipcRenderer.removeListener('updater-event', handler);
},
updaterCheck: (context: UpdaterContext): Promise<void> => ipcRenderer.invoke('updater-check', context),
updaterInstall: () => ipcRenderer.invoke('updater-install'),
windowMinimize: (): void => {
ipcRenderer.send('window-minimize');
},
windowMaximize: (): void => {
ipcRenderer.send('window-maximize');
},
windowClose: (): void => {
ipcRenderer.send('window-close');
},
windowIsMaximized: (): Promise<boolean> => ipcRenderer.invoke('window-is-maximized'),
onWindowMaximizeChange: (callback: (maximized: boolean) => void): (() => void) => {
const handler = (_event: Electron.IpcRendererEvent, maximized: boolean): void => {
callback(maximized);
};
ipcRenderer.on('window-maximize-change', handler);
return () => {
ipcRenderer.removeListener('window-maximize-change', handler);
};
},
openExternal: (url: string): Promise<void> => ipcRenderer.invoke('open-external', url),
clipboardWriteText: (text: string): Promise<void> => ipcRenderer.invoke('clipboard-write-text', text),
clipboardReadText: (): Promise<string> => ipcRenderer.invoke('clipboard-read-text'),
onDeepLink: (callback: (url: string) => void): (() => void) => {
const handler = (_event: Electron.IpcRendererEvent, url: string): void => {
callback(url);
};
ipcRenderer.on('deep-link', handler);
return () => {
ipcRenderer.removeListener('deep-link', handler);
};
},
getInitialDeepLink: (): Promise<string | null> => ipcRenderer.invoke('get-initial-deep-link'),
onRpcNavigate: (callback: (path: string) => void): (() => void) => {
const handler = (_event: Electron.IpcRendererEvent, path: string): void => {
callback(path);
};
ipcRenderer.on('rpc-navigate', handler);
return () => {
ipcRenderer.removeListener('rpc-navigate', handler);
};
},
registerGlobalShortcut: (accelerator: string, id: string): Promise<boolean> =>
ipcRenderer.invoke('register-global-shortcut', {accelerator, id}),
unregisterGlobalShortcut: (accelerator: string): Promise<void> =>
ipcRenderer.invoke('unregister-global-shortcut', accelerator),
unregisterAllGlobalShortcuts: (): Promise<void> => ipcRenderer.invoke('unregister-all-global-shortcuts'),
onGlobalShortcut: (callback: (id: string) => void): (() => void) => {
const handler = (_event: Electron.IpcRendererEvent, id: string): void => {
callback(id);
};
ipcRenderer.on('global-shortcut-triggered', handler);
return () => {
ipcRenderer.removeListener('global-shortcut-triggered', handler);
};
},
autostartEnable: (): Promise<void> => ipcRenderer.invoke('autostart-enable'),
autostartDisable: (): Promise<void> => ipcRenderer.invoke('autostart-disable'),
autostartIsEnabled: (): Promise<boolean> => ipcRenderer.invoke('autostart-is-enabled'),
autostartIsInitialized: (): Promise<boolean> => ipcRenderer.invoke('autostart-is-initialized'),
autostartMarkInitialized: (): Promise<void> => ipcRenderer.invoke('autostart-mark-initialized'),
checkMediaAccess: (type: MediaAccessType): Promise<MediaAccessStatus> =>
ipcRenderer.invoke('check-media-access', type),
requestMediaAccess: (type: MediaAccessType): Promise<boolean> => ipcRenderer.invoke('request-media-access', type),
openMediaAccessSettings: (type: MediaAccessType): Promise<void> =>
ipcRenderer.invoke('open-media-access-settings', type),
checkAccessibility: (prompt: boolean): Promise<boolean> => ipcRenderer.invoke('check-accessibility', prompt),
openAccessibilitySettings: (): Promise<void> => ipcRenderer.invoke('open-accessibility-settings'),
openInputMonitoringSettings: (): Promise<void> => ipcRenderer.invoke('open-input-monitoring-settings'),
downloadFile: (url: string, defaultPath: string): Promise<DownloadFileResult> =>
ipcRenderer.invoke('download-file', {url, defaultPath}),
passkeyIsSupported: (): Promise<boolean> => ipcRenderer.invoke('passkey-is-supported'),
passkeyAuthenticate: (options: PublicKeyCredentialRequestOptionsJSON): Promise<AuthenticationResponseJSON> =>
ipcRenderer.invoke('passkey-authenticate', options),
passkeyRegister: (options: PublicKeyCredentialCreationOptionsJSON): Promise<RegistrationResponseJSON> =>
ipcRenderer.invoke('passkey-register', options),
switchInstanceUrl: (options: SwitchInstanceUrlOptions): Promise<void> =>
ipcRenderer.invoke('switch-instance-url', options),
consumeDesktopHandoffCode: (): Promise<string | null> => ipcRenderer.invoke('consume-desktop-handoff-code'),
toggleDevTools: (): void => {
ipcRenderer.send('toggle-devtools');
},
getDesktopSources: (types: Array<'screen' | 'window'>, requestId?: string): Promise<Array<DesktopSource>> =>
ipcRenderer.invoke('get-desktop-sources', types, requestId),
onDisplayMediaRequested: (callback: (requestId: string, info: DisplayMediaRequestInfo) => void): (() => void) => {
const handler = (_event: Electron.IpcRendererEvent, requestId: string, info: DisplayMediaRequestInfo): void => {
callback(requestId, info);
};
ipcRenderer.on('display-media-requested', handler);
return () => {
ipcRenderer.removeListener('display-media-requested', handler);
};
},
selectDisplayMediaSource: (requestId: string, sourceId: string | null, withAudio: boolean): void => {
ipcRenderer.send('select-display-media-source', requestId, sourceId, withAudio);
},
showNotification: (options: NotificationOptions): Promise<NotificationResult> =>
ipcRenderer.invoke('show-notification', options),
closeNotification: (id: string): void => {
ipcRenderer.send('close-notification', id);
},
closeNotifications: (ids: Array<string>): void => {
ipcRenderer.send('close-notifications', ids);
},
onNotificationClick: (callback: (id: string, url?: string) => void): (() => void) => {
const handler = (_event: Electron.IpcRendererEvent, id: string, url?: string): void => {
callback(id, url);
};
ipcRenderer.on('notification-click', handler);
return () => {
ipcRenderer.removeListener('notification-click', handler);
};
},
setBadgeCount: (count: number): void => {
ipcRenderer.send('set-badge-count', count);
},
getBadgeCount: (): Promise<number> => ipcRenderer.invoke('get-badge-count'),
bounceDock: (type?: 'critical' | 'informational'): number => {
return ipcRenderer.sendSync('bounce-dock', type ?? 'informational');
},
cancelBounceDock: (id: number): void => {
ipcRenderer.send('cancel-bounce-dock', id);
},
setZoomFactor: (factor: number): void => {
ipcRenderer.send('set-zoom-factor', factor);
},
getZoomFactor: (): Promise<number> => ipcRenderer.invoke('get-zoom-factor'),
onZoomIn: (callback: () => void): (() => void) => {
const handler = (): void => callback();
ipcRenderer.on('zoom-in', handler);
return () => ipcRenderer.removeListener('zoom-in', handler);
},
onZoomOut: (callback: () => void): (() => void) => {
const handler = (): void => callback();
ipcRenderer.on('zoom-out', handler);
return () => ipcRenderer.removeListener('zoom-out', handler);
},
onZoomReset: (callback: () => void): (() => void) => {
const handler = (): void => callback();
ipcRenderer.on('zoom-reset', handler);
return () => ipcRenderer.removeListener('zoom-reset', handler);
},
onOpenSettings: (callback: () => void): (() => void) => {
const handler = (): void => callback();
ipcRenderer.on('open-settings', handler);
return () => ipcRenderer.removeListener('open-settings', handler);
},
globalKeyHookStart: (): Promise<boolean> => ipcRenderer.invoke('global-key-hook-start'),
globalKeyHookStop: (): Promise<void> => ipcRenderer.invoke('global-key-hook-stop'),
globalKeyHookIsRunning: (): Promise<boolean> => ipcRenderer.invoke('global-key-hook-is-running'),
checkInputMonitoringAccess: (): Promise<boolean> => ipcRenderer.invoke('check-input-monitoring-access'),
globalKeyHookRegister: (options: GlobalKeyHookRegisterOptions): Promise<void> =>
ipcRenderer.invoke('global-key-hook-register', options),
globalKeyHookUnregister: (id: string): Promise<void> => ipcRenderer.invoke('global-key-hook-unregister', id),
globalKeyHookUnregisterAll: (): Promise<void> => ipcRenderer.invoke('global-key-hook-unregister-all'),
onGlobalKeyEvent: (callback: (event: GlobalKeyEvent) => void): (() => void) => {
const handler = (_event: Electron.IpcRendererEvent, data: GlobalKeyEvent): void => {
callback(data);
};
ipcRenderer.on('global-key-event', handler);
return () => {
ipcRenderer.removeListener('global-key-event', handler);
};
},
onGlobalMouseEvent: (callback: (event: GlobalMouseEvent) => void): (() => void) => {
const handler = (_event: Electron.IpcRendererEvent, data: GlobalMouseEvent): void => {
callback(data);
};
ipcRenderer.on('global-mouse-event', handler);
return () => {
ipcRenderer.removeListener('global-mouse-event', handler);
};
},
onGlobalKeybindTriggered: (callback: (event: GlobalKeybindTriggeredEvent) => void): (() => void) => {
const handler = (_event: Electron.IpcRendererEvent, data: GlobalKeybindTriggeredEvent): void => {
callback(data);
};
ipcRenderer.on('global-keybind-triggered', handler);
return () => {
ipcRenderer.removeListener('global-keybind-triggered', handler);
};
},
spellcheckGetState: (): Promise<SpellcheckState> => ipcRenderer.invoke('spellcheck-get-state'),
spellcheckSetState: (state: Partial<SpellcheckState>): Promise<SpellcheckState> =>
ipcRenderer.invoke('spellcheck-set-state', state),
spellcheckGetAvailableLanguages: (): Promise<Array<string>> =>
ipcRenderer.invoke('spellcheck-get-available-languages'),
spellcheckOpenLanguageSettings: (): Promise<boolean> => ipcRenderer.invoke('spellcheck-open-language-settings'),
onSpellcheckStateChanged: (callback: (state: SpellcheckState) => void): (() => void) => {
const handler = (_event: Electron.IpcRendererEvent, data: SpellcheckState): void => callback(data);
ipcRenderer.on('spellcheck-state-changed', handler);
return () => {
ipcRenderer.removeListener('spellcheck-state-changed', handler);
};
},
onTextareaContextMenu: (callback: (params: TextareaContextMenuParams) => void): (() => void) => {
const handler = (_event: Electron.IpcRendererEvent, data: TextareaContextMenuParams): void => callback(data);
ipcRenderer.on('textarea-context-menu', handler);
return () => {
ipcRenderer.removeListener('textarea-context-menu', handler);
};
},
spellcheckReplaceMisspelling: (replacement: string): Promise<void> =>
ipcRenderer.invoke('spellcheck-replace-misspelling', replacement),
spellcheckAddWordToDictionary: (word: string): Promise<void> =>
ipcRenderer.invoke('spellcheck-add-word-to-dictionary', word),
};
window.addEventListener(
'contextmenu',
(event) => {
const target = event.target as HTMLElement | null;
const isTextarea = Boolean(target?.closest?.('textarea'));
ipcRenderer.send('spellcheck-context-target', {isTextarea});
},
true,
);
contextBridge.exposeInMainWorld('electron', api);