192 lines
9.5 KiB
TypeScript
192 lines
9.5 KiB
TypeScript
// Preload bridge. Exposes a single `window.electronAPI` object to the
|
||
// renderer that mirrors the CHANNELS surface from ipc-types.ts. Each
|
||
// CHANNEL becomes a typed method that calls `ipcRenderer.invoke`; the
|
||
// three event channels (shortcut fired/released + updater progress) get
|
||
// `on*` subscribers that return an unsubscribe function.
|
||
//
|
||
// contextIsolation is ON so the renderer never sees `ipcRenderer` or
|
||
// Node directly — only this curated surface.
|
||
|
||
import { contextBridge, ipcRenderer } from 'electron';
|
||
|
||
import {
|
||
CHANNELS,
|
||
ELECTRON_RUNTIME_MARKER,
|
||
type AudioLoopbackChunk,
|
||
type AudioLoopbackStartResult,
|
||
type FsPath,
|
||
type FsRenameArgs,
|
||
type FsWriteArgs,
|
||
type NotifyArgs,
|
||
type ScreenSource,
|
||
type SecureStoreHandle,
|
||
type SecureStoreOpenArgs,
|
||
type ShortcutEvent,
|
||
type ShortcutRegisterArgs,
|
||
type SqlExecuteResult,
|
||
type SqlSelectResult,
|
||
type UpdateCheckResult,
|
||
type UpdateProgress,
|
||
} from './ipc-types';
|
||
// Static import of the desktop package.json so the bundler inlines the
|
||
// version string at build time. The previous `process.env.npm_package_-
|
||
// version` approach worked in dev (pnpm sets it as a script env var) but
|
||
// fell back to '0.0.0' in packaged builds — every installed user got
|
||
// flagged as "Update verfügbar" against their own actually-current
|
||
// version. resolveJsonModule + esModuleInterop are on in tsconfig.node.
|
||
import pkg from '../package.json';
|
||
|
||
type Unsubscribe = () => void;
|
||
|
||
function on<T>(channel: string, cb: (payload: T) => void): Unsubscribe {
|
||
const handler = (_evt: Electron.IpcRendererEvent, payload: T): void => cb(payload);
|
||
ipcRenderer.on(channel, handler);
|
||
return () => ipcRenderer.removeListener(channel, handler);
|
||
}
|
||
|
||
const api = {
|
||
platform: ELECTRON_RUNTIME_MARKER,
|
||
osPlatform: process.platform as NodeJS.Platform,
|
||
appVersion: pkg.version,
|
||
|
||
// Screen sources ---------------------------------------------------------
|
||
getScreenSources: (): Promise<ScreenSource[]> => ipcRenderer.invoke(CHANNELS.SCREEN_GET_SOURCES),
|
||
getScreenThumbnail: (sourceId: string): Promise<string | null> =>
|
||
ipcRenderer.invoke(CHANNELS.SCREEN_GET_THUMBNAIL, sourceId),
|
||
/** Tell main which source the user picked in our Discord-style modal,
|
||
* BEFORE calling getDisplayMedia. The display-media handler reads this
|
||
* and routes the matching desktopCapturer Source into LiveKit. Pass
|
||
* null on cancel/error to clear the pending state. */
|
||
setPendingShareSource: (sourceId: string | null): Promise<void> =>
|
||
ipcRenderer.invoke(CHANNELS.SCREEN_SET_PENDING_SOURCE, sourceId),
|
||
|
||
// Audio loopback ---------------------------------------------------------
|
||
resolveLoopbackSource: (): Promise<{ sourceId: string } | null> =>
|
||
ipcRenderer.invoke(CHANNELS.AUDIO_LOOPBACK_RESOLVE_SOURCE),
|
||
|
||
/** Native WASAPI process-loopback (Windows only). Captures all system
|
||
* audio EXCEPT our own PID tree so peers don't hear themselves echoed
|
||
* back. Throws on platforms where the addon isn't available — caller
|
||
* is expected to fall back to the renderer-driven getUserMedia path
|
||
* (see lib/screenAudio.ts). */
|
||
audioLoopback: {
|
||
start: (): Promise<AudioLoopbackStartResult> =>
|
||
ipcRenderer.invoke(CHANNELS.AUDIO_LOOPBACK_START),
|
||
/** Window-share variant: capture only the picked window's process
|
||
* tree (INCLUDE_TARGET_PROCESS_TREE). hwnd is the decimal HWND
|
||
* parsed from desktopCapturer's `window:<HWND>:0` source id. */
|
||
startForWindow: (hwnd: number): Promise<AudioLoopbackStartResult> =>
|
||
ipcRenderer.invoke(CHANNELS.AUDIO_LOOPBACK_START_FOR_WINDOW, { hwnd }),
|
||
stop: (captureId: number): Promise<void> =>
|
||
ipcRenderer.invoke(CHANNELS.AUDIO_LOOPBACK_STOP, captureId),
|
||
onChunk: (cb: (payload: AudioLoopbackChunk) => void): Unsubscribe =>
|
||
on<AudioLoopbackChunk>(CHANNELS.AUDIO_LOOPBACK_CHUNK, cb),
|
||
},
|
||
|
||
// Shortcuts --------------------------------------------------------------
|
||
registerShortcut: (args: ShortcutRegisterArgs): Promise<boolean> =>
|
||
ipcRenderer.invoke(CHANNELS.SHORTCUT_REGISTER, args),
|
||
unregisterShortcut: (id: string): Promise<void> =>
|
||
ipcRenderer.invoke(CHANNELS.SHORTCUT_UNREGISTER, id),
|
||
isShortcutRegistered: (id: string): Promise<boolean> =>
|
||
ipcRenderer.invoke(CHANNELS.SHORTCUT_IS_REGISTERED, id),
|
||
onShortcutFired: (cb: (evt: ShortcutEvent) => void): Unsubscribe =>
|
||
on<ShortcutEvent>(CHANNELS.SHORTCUT_EVT_FIRED, cb),
|
||
onShortcutReleased: (cb: (evt: ShortcutEvent) => void): Unsubscribe =>
|
||
on<ShortcutEvent>(CHANNELS.SHORTCUT_EVT_RELEASED, cb),
|
||
|
||
// Notifications ----------------------------------------------------------
|
||
notify: (args: NotifyArgs): Promise<void> => ipcRenderer.invoke(CHANNELS.NOTIFY_SHOW, args),
|
||
getNotificationPermission: (): Promise<'granted' | 'denied' | 'default'> =>
|
||
ipcRenderer.invoke(CHANNELS.NOTIFY_PERMISSION),
|
||
|
||
// Tray -------------------------------------------------------------------
|
||
// `badgeDataUrl` (optional): renderer-painted PNG (data:image/png;base64)
|
||
// that main applies as the Windows taskbar overlay icon. We render in the
|
||
// renderer because main has no Canvas2D; passing a finished image avoids
|
||
// bundling a native canvas backend just for a 32×32 badge.
|
||
setTrayUnread: (count: number, badgeDataUrl?: string | null): Promise<void> =>
|
||
ipcRenderer.invoke(CHANNELS.TRAY_UNREAD, count, badgeDataUrl ?? null),
|
||
|
||
// Secure store -----------------------------------------------------------
|
||
secureStoreOpen: (args: SecureStoreOpenArgs): Promise<SecureStoreHandle> =>
|
||
ipcRenderer.invoke(CHANNELS.SECURE_STORE_OPEN, args),
|
||
secureStoreGet: (handle: string, key: string): Promise<string | null> =>
|
||
ipcRenderer.invoke(CHANNELS.SECURE_STORE_GET, handle, key),
|
||
secureStoreSet: (handle: string, key: string, value: string): Promise<void> =>
|
||
ipcRenderer.invoke(CHANNELS.SECURE_STORE_SET, handle, key, value),
|
||
secureStoreRemove: (handle: string, key: string): Promise<void> =>
|
||
ipcRenderer.invoke(CHANNELS.SECURE_STORE_REMOVE, handle, key),
|
||
secureStoreClose: (handle: string): Promise<void> =>
|
||
ipcRenderer.invoke(CHANNELS.SECURE_STORE_CLOSE, handle),
|
||
|
||
// Filesystem -------------------------------------------------------------
|
||
fsRead: (p: FsPath): Promise<string | null> => ipcRenderer.invoke(CHANNELS.FS_READ, p),
|
||
fsWrite: (args: FsWriteArgs): Promise<void> => ipcRenderer.invoke(CHANNELS.FS_WRITE, args),
|
||
fsExists: (p: FsPath): Promise<boolean> => ipcRenderer.invoke(CHANNELS.FS_EXISTS, p),
|
||
fsMkdir: (p: FsPath): Promise<void> => ipcRenderer.invoke(CHANNELS.FS_MKDIR, p),
|
||
fsRename: (args: FsRenameArgs): Promise<void> => ipcRenderer.invoke(CHANNELS.FS_RENAME, args),
|
||
fsRemove: (p: FsPath): Promise<void> => ipcRenderer.invoke(CHANNELS.FS_REMOVE, p),
|
||
fsAppLocalDataDir: (): Promise<string> => ipcRenderer.invoke(CHANNELS.FS_APP_LOCAL_DATA_DIR),
|
||
|
||
// SQL --------------------------------------------------------------------
|
||
sqlLoad: (args: { name: string }): Promise<string> =>
|
||
ipcRenderer.invoke(CHANNELS.SQL_LOAD, args),
|
||
sqlExecute: (args: {
|
||
handle: string;
|
||
query: string;
|
||
bindings?: unknown[];
|
||
}): Promise<SqlExecuteResult> => ipcRenderer.invoke(CHANNELS.SQL_EXECUTE, args),
|
||
sqlSelect: (args: {
|
||
handle: string;
|
||
query: string;
|
||
bindings?: unknown[];
|
||
}): Promise<SqlSelectResult> => ipcRenderer.invoke(CHANNELS.SQL_SELECT, args),
|
||
sqlClose: (handle: string): Promise<void> => ipcRenderer.invoke(CHANNELS.SQL_CLOSE, handle),
|
||
|
||
// Updater ----------------------------------------------------------------
|
||
checkForUpdate: (): Promise<UpdateCheckResult> => ipcRenderer.invoke(CHANNELS.UPDATER_CHECK),
|
||
downloadInstallUpdate: (): Promise<void> =>
|
||
ipcRenderer.invoke(CHANNELS.UPDATER_DOWNLOAD_INSTALL),
|
||
onUpdaterProgress: (cb: (p: UpdateProgress) => void): Unsubscribe =>
|
||
on<UpdateProgress>(CHANNELS.UPDATER_EVT_PROGRESS, cb),
|
||
|
||
// Autostart --------------------------------------------------------------
|
||
isAutoStartEnabled: (): Promise<boolean> => ipcRenderer.invoke(CHANNELS.AUTOSTART_IS_ENABLED),
|
||
setAutoStart: (enabled: boolean): Promise<void> =>
|
||
ipcRenderer.invoke(CHANNELS.AUTOSTART_SET, enabled),
|
||
|
||
// Window fullscreen ------------------------------------------------------
|
||
setFullscreen: (enabled: boolean): Promise<void> =>
|
||
ipcRenderer.invoke(CHANNELS.WINDOW_SET_FULLSCREEN, enabled),
|
||
|
||
// Window content protection ----------------------------------------------
|
||
setContentProtection: (enabled: boolean): Promise<void> =>
|
||
ipcRenderer.invoke(CHANNELS.WINDOW_SET_CONTENT_PROTECTION, { enabled }),
|
||
|
||
// OS hostname ------------------------------------------------------------
|
||
getHostname: (): Promise<string | null> => ipcRenderer.invoke(CHANNELS.APP_HOSTNAME),
|
||
|
||
// Wipe-on-close ----------------------------------------------------------
|
||
// Subscribe to the main-process pre-quit notification. The renderer's
|
||
// callback does the actual wipe (memoryWipe.ts) and resolves; we ack
|
||
// unconditionally so main can finish quitting — better to lose the wipe
|
||
// than to hang the app shutdown if the callback throws.
|
||
onWipeBeforeQuit: (cb: () => Promise<void>): Unsubscribe => {
|
||
const handler = async (_evt: Electron.IpcRendererEvent): Promise<void> => {
|
||
try {
|
||
await cb();
|
||
} catch (err) {
|
||
console.warn('[wipe] renderer cb failed', err);
|
||
}
|
||
ipcRenderer.send(CHANNELS.APP_WIPE_BEFORE_QUIT + ':done');
|
||
};
|
||
ipcRenderer.on(CHANNELS.APP_WIPE_BEFORE_QUIT, handler);
|
||
return () => ipcRenderer.removeListener(CHANNELS.APP_WIPE_BEFORE_QUIT, handler);
|
||
},
|
||
} as const;
|
||
|
||
export type ElectronAPI = typeof api;
|
||
|
||
contextBridge.exposeInMainWorld('electronAPI', api);
|