refactor progress
This commit is contained in:
21
fluxer_desktop/src/common/BrandedTypes.tsx
Normal file
21
fluxer_desktop/src/common/BrandedTypes.tsx
Normal 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};
|
||||
23
fluxer_desktop/src/common/BuildChannel.tsx
Normal file
23
fluxer_desktop/src/common/BuildChannel.tsx
Normal 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;
|
||||
26
fluxer_desktop/src/common/Constants.tsx
Normal file
26
fluxer_desktop/src/common/Constants.tsx
Normal 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;
|
||||
81
fluxer_desktop/src/common/DesktopConfig.tsx
Normal file
81
fluxer_desktop/src/common/DesktopConfig.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import 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();
|
||||
}
|
||||
41
fluxer_desktop/src/common/Logger.tsx
Normal file
41
fluxer_desktop/src/common/Logger.tsx
Normal 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),
|
||||
};
|
||||
}
|
||||
258
fluxer_desktop/src/common/Types.tsx
Normal file
258
fluxer_desktop/src/common/Types.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
61
fluxer_desktop/src/common/UserDataPath.tsx
Normal file
61
fluxer_desktop/src/common/UserDataPath.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
125
fluxer_desktop/src/main/Autostart.tsx
Normal file
125
fluxer_desktop/src/main/Autostart.tsx
Normal 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();
|
||||
});
|
||||
}
|
||||
72
fluxer_desktop/src/main/DeepLinks.tsx
Normal file
72
fluxer_desktop/src/main/DeepLinks.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {APP_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();
|
||||
}
|
||||
}
|
||||
332
fluxer_desktop/src/main/GlobalKeyHook.tsx
Normal file
332
fluxer_desktop/src/main/GlobalKeyHook.tsx
Normal 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();
|
||||
}
|
||||
914
fluxer_desktop/src/main/IpcHandlers.tsx
Normal file
914
fluxer_desktop/src/main/IpcHandlers.tsx
Normal 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();
|
||||
}
|
||||
207
fluxer_desktop/src/main/Menu.tsx
Normal file
207
fluxer_desktop/src/main/Menu.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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);
|
||||
}
|
||||
271
fluxer_desktop/src/main/RpcServer.tsx
Normal file
271
fluxer_desktop/src/main/RpcServer.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
};
|
||||
216
fluxer_desktop/src/main/Spellcheck.tsx
Normal file
216
fluxer_desktop/src/main/Spellcheck.tsx
Normal 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'],
|
||||
});
|
||||
});
|
||||
};
|
||||
80
fluxer_desktop/src/main/Updater.tsx
Normal file
80
fluxer_desktop/src/main/Updater.tsx
Normal 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();
|
||||
});
|
||||
}
|
||||
746
fluxer_desktop/src/main/Window.tsx
Normal file
746
fluxer_desktop/src/main/Window.tsx
Normal 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;
|
||||
}
|
||||
115
fluxer_desktop/src/main/WindowsBadge.tsx
Normal file
115
fluxer_desktop/src/main/WindowsBadge.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type {BrowserWindow, NativeImage} from 'electron';
|
||||
import {nativeImage} from 'electron';
|
||||
|
||||
const badgeIcons: Array<NativeImage | null> = [];
|
||||
let hasInit = false;
|
||||
let lastIndex: number | null = null;
|
||||
let lastCount: number | null = null;
|
||||
|
||||
function isSupported(): boolean {
|
||||
return process.platform === 'win32';
|
||||
}
|
||||
|
||||
function ensureInitialized(): void {
|
||||
if (hasInit || !isSupported()) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasInit = true;
|
||||
const badgeDir = path.join(process.resourcesPath, 'badges');
|
||||
|
||||
for (let i = 1; i <= 11; i++) {
|
||||
const iconPath = path.join(badgeDir, `badge-${i}.ico`);
|
||||
if (!fs.existsSync(iconPath)) {
|
||||
badgeIcons.push(null);
|
||||
continue;
|
||||
}
|
||||
|
||||
const icon = nativeImage.createFromPath(iconPath);
|
||||
badgeIcons.push(icon.isEmpty() ? null : icon);
|
||||
}
|
||||
}
|
||||
|
||||
function getOverlayIconData(count: number): {index: number | null; description: string} {
|
||||
if (count === -1) {
|
||||
return {
|
||||
index: 10,
|
||||
description: 'Unread messages',
|
||||
};
|
||||
}
|
||||
|
||||
if (count === 0) {
|
||||
return {
|
||||
index: null,
|
||||
description: 'No Notifications',
|
||||
};
|
||||
}
|
||||
|
||||
const index = Math.max(1, Math.min(count, 10)) - 1;
|
||||
|
||||
return {
|
||||
index,
|
||||
description: `${index} notifications`,
|
||||
};
|
||||
}
|
||||
|
||||
function applyOverlay(win: BrowserWindow | null, count: number, force: boolean): void {
|
||||
if (!isSupported()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!win || win.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {index, description} = getOverlayIconData(count);
|
||||
if (force || lastIndex !== index) {
|
||||
if (index == null) {
|
||||
win.setOverlayIcon(null, description);
|
||||
} else {
|
||||
const icon = badgeIcons[index];
|
||||
win.setOverlayIcon(icon ?? null, description);
|
||||
}
|
||||
lastIndex = index;
|
||||
}
|
||||
lastCount = count;
|
||||
}
|
||||
|
||||
export function setWindowsBadgeOverlay(win: BrowserWindow | null, count: number): void {
|
||||
if (!isSupported()) {
|
||||
return;
|
||||
}
|
||||
|
||||
ensureInitialized();
|
||||
applyOverlay(win, count, false);
|
||||
}
|
||||
|
||||
export function refreshWindowsBadgeOverlay(win: BrowserWindow | null): void {
|
||||
if (!isSupported() || lastCount == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ensureInitialized();
|
||||
applyOverlay(win, lastCount, true);
|
||||
}
|
||||
177
fluxer_desktop/src/main/index.tsx
Normal file
177
fluxer_desktop/src/main/index.tsx
Normal 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);
|
||||
});
|
||||
}
|
||||
300
fluxer_desktop/src/preload/index.tsx
Normal file
300
fluxer_desktop/src/preload/index.tsx
Normal 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);
|
||||
Reference in New Issue
Block a user