f9e340dbec
App productName becomes Netralax (driving exe name and window title);
existing installs keep their %APPDATA%\ChatApp profile via an explicit
app.setPath('userData', appData/ChatApp) so no Login/Sounds/Secret store
data is lost.
The Windows taskbar overlay now renders a red bubble with the actual
unread count (Discord parity) instead of just a static red dot. Renderer
paints a 64×64 PNG via canvas — full-bleed red circle, white bold count
with a "99+" cap, no outer ring — and passes the data URL through the
existing setTrayUnread IPC. Main decodes via nativeImage and applies it
as the BrowserWindow overlay icon. Falls back to the static dot if the
renderer canvas pipeline is unavailable.
Also: app.setName('Netralax') + setAppUserModelId('cloud.netralax.desktop')
for Windows taskbar grouping and notification source attribution, and
release.mjs now reads productName dynamically from package.json so the
artifact lookup stays correct after the rename.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
160 lines
7.8 KiB
TypeScript
160 lines
7.8 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';
|
||
|
||
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: process.env.npm_package_version ?? '0.0.0',
|
||
|
||
// 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),
|
||
} as const;
|
||
|
||
export type ElectronAPI = typeof api;
|
||
|
||
contextBridge.exposeInMainWorld('electronAPI', api);
|