Compare commits

...

19 Commits

Author SHA1 Message Date
byGalax 341f5f227d chore(desktop): release v0.17.0 2026-05-12 21:35:29 +02:00
byGalax 8baac2fd1e fix(call): tile sizing polish + cinema-mode chrome suppression + spec/plan docs
Equal-grid cells no longer set aspect-video — on wide chat panels this
forced cell height = width × 9/16 (~400px on a 700px panel) which pushed
the row past the section's max-h and ate the controls bar below. n>=2
cells now fill grid tracks normally via auto-rows-fr; the solo case
(n=1) keeps a 16:9 silhouette via aspect-video + max-w + justify-self-
center so a single-user-alone-calling view doesn't stretch into a
full-width slab. Same change applied to the fullscreen-grid path plus
+16px bottom-padding (pb-28) so audio-only avatars' name chip clears
the floating controls bar.

Docked stage strip thumbs (focus + bento) switch from aspect-video
shrink-0 to flex-1 min-w-[200px] max-w-[460px] so 2-3 thumbs share the
row width evenly under the share above, instead of clinging to the left
edge with dead space to the right. Fullscreen-cinema strip keeps the
small aspect-video thumbs the user explicitly approved.

ScreenShareViewer gains a hideFullscreenToggle prop; cinema mode passes
it via a new `cinema` prop on TileRender so the in-share fullscreen icon
doesn't visually collide with FullscreenCall's strip-hidden toggle at
the same top-right corner.

docs/superpowers/specs + plans for the Discord-style tile handling
workstream are committed alongside the implementation that completed it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:31:26 +02:00
byGalax 05870ef8fa feat(call): split resolution/fps in share picker + restore window state after fullscreen
ScreenSharePickerModal now exposes Auflösung (Auto · 720p · 1080p · 1440p
· 4K) and FPS (30 · 60) as separate pill rows instead of bundled quality
presets — users can pick "1440p · 30 fps" or "4K · 30 fps" which the old
preset list didn't surface. The underlying screenShareSettings framerateOverride
slot already existed; the modal just stopped resetting it to null on every
start and now plumbs the chosen FPS through to startScreenShare.

Cinema-mode fullscreen on Windows had two defects:

1. Maximized → fullscreen left the taskbar drawn on top of the window
   because DWM kept the maximized work-area constraints. We now unmaximize
   first so DWM recomposes cleanly and setFullScreen actually covers the
   whole monitor including the taskbar strip.

2. Esc out of cinema came back as a small floating window even when the
   user had been maximized before clicking the Vollbild button — the
   unmaximize from (1) was never undone. We now memo the pre-fullscreen
   maximized flag per window-id and call win.maximize() once the
   leave-full-screen event has fired.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:31:10 +02:00
byGalax f9e340dbec feat(branding): Netralax rebrand + Discord-style taskbar unread badge
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>
2026-05-12 21:30:54 +02:00
byGalax 91eedc0e8e fix(call): aspect-video for fullscreen grid+strip, switch section sizing to stageLayout 2026-05-12 19:03:23 +02:00
byGalax e56533918e feat(call): multi-share bento layout in stage + fullscreen
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 18:54:23 +02:00
byGalax c87d4e82d3 feat(call): doubleclick on focused tile clears pin
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 18:50:29 +02:00
byGalax 187f8dc95a feat(call): left-click toggles pin, drop manual focus mode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 18:47:10 +02:00
byGalax f612c1bb50 feat(call): introduce StageLayout discriminated union
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 18:43:55 +02:00
byGalax a9d5e2d430 feat(call): uniform 16:9 grid cells, drop grid-rows constraint 2026-05-12 18:40:54 +02:00
byGalax a065cc0a2c feat(call): drop hardcoded 16:9 on screen-share preview button 2026-05-12 18:39:06 +02:00
byGalax 005aebd60b feat(call): VideoStub accepts fit prop, contain when focused 2026-05-12 18:36:32 +02:00
byGalax 68bc1f76f6 chore(desktop): release v0.16.3 2026-05-07 17:25:56 +02:00
byGalax f1c7501807 fix(crypto): suppress approval banner for devices with existing wraps
After 0.16.2 some users saw the approval banner stack up to 6+ entries
on first launch — every old device they ever registered (Tauri-era,
test installs, dev builds) showed up because the only "already legit"
filter was `created_at <= ownDevice.created_at`. That fails when own
device is restored from Backup (older than every other entry) or when
the user accumulated installs around the migration window.

Add a semantic check: if a device already has at least one row in
`conversation_keys` (recipient_device_id), it has been wrapped before
and is by definition not awaiting approval. Treat as approved silently.
Bulk query against the candidate IDs, no N+1.

Plus UX: when more than one request is pending, render a sticky header
with a count and "Alle ablehnen" button so users with stale piles can
clear them in one click.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:24:17 +02:00
byGalax e16b248366 chore(desktop): release v0.16.2 2026-05-07 17:07:28 +02:00
byGalax 9b764053c4 feat(crypto): explicit device-approval flow + dev userData isolation
Disable the previous auto-share of conversation keys to newly-registered
devices: a stolen password / new device registered by an attacker no
longer automatically grants history access. Backup-Restore (which
restores the old device-id) still opens existing wraps as before.

Phase 1 of the approval replacement:
- New `lib/deviceApproval.ts`: realtime listener for `devices` INSERT,
  surfaces a pending list, persists approve/deny decisions in
  `chatapp.approvedDeviceIds` / `chatapp.dismissedDeviceIds`. Filters the
  initial fetch by created_at > own-device's created_at so a freshly
  installed client doesn't try to "approve" pre-existing devices.
- New `components/DeviceApprovalBanner.tsx`: bottom-right Discord-style
  banner per pending request with Genehmigen / Ablehnen actions; reuses
  `wrapForOneDevice` from conversationKeySync to fan out conv-keys.
- AppShell mounts both the listener and the banner.

Plus dev userData isolation in main.ts: when running unpackaged, append
`-Dev` to the userData path so `pnpm dev` runs side-by-side with the
installed packaged build instead of colliding on the single-instance
lock. Window title also distinguished as "ChatApp (Dev)".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:03:04 +02:00
byGalax 950ef5b706 chore(release): inject releaseNotes into latest.yml
electron-builder doesn't write CLI-supplied --notes into the
auto-update manifest, so clients saw an empty body in UpdateToast
even when the release script logged notes. After the build but
before scp, patch latest.yml in place: append a block scalar
(`releaseNotes: |-`) so multi-line notes survive intact.

Idempotent — skips if a releaseNotes entry is already present
(reruns / hand-edited manifests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 23:45:10 +02:00
byGalax cc84bb7ff6 chore(desktop): release v0.16.1 2026-05-06 23:37:34 +02:00
byGalax 825160ee46 feat(desktop): port v0.11.4-v0.15.2 from Tauri to Electron + Discord-parity audio (v0.16.0)
Chronological port of every Tauri release commit (v0.11.4 -> v0.15.2)
into the Electron rebuild, plus Discord-style audio handling that goes
beyond the Tauri original.

Highlights:
  - All 17 Tauri release commits ported (audio fixes, custom notification
    sound, Discord-style chat UX, profile banner, changelog page,
    Discord-parity call UX, screen-share echo + re-watch UX, audio-loop
    fix).
  - Native napi-rs audio-loopback addon with WASAPI process-loopback:
      * EXCLUDE_TARGET_PROCESS_TREE for full-screen shares -> peers
        never hear themselves echoed back through the capture.
      * INCLUDE_TARGET_PROCESS_TREE for window shares -> only the
        picked window's audio is captured, not the whole OS mixer
        (Discord parity).
      * HWND -> PID resolution via Win32 GetWindowThreadProcessId.
  - Discord-style screen-source picker (thumbnail grid, screens vs
    apps tabs, live-refreshing thumbnails).
  - Hash routing fix for packaged builds (file:// can't resolve
    BrowserRouter paths).
  - Tauri sources removed (apps/desktop/src-tauri).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 23:35:01 +02:00
509 changed files with 16341 additions and 14508 deletions
+6 -7
View File
@@ -1,13 +1,12 @@
# Copy to .env.release (gitignored) and fill in.
# Consumed by scripts/release.mjs.
#
# electron-updater hash-verifies via SHA-512 embedded in latest.yml, so no
# minisign / Tauri signing key is needed any more — the legacy
# TAURI_SIGNING_* vars from the Tauri build can be removed once you've
# stopped publishing Tauri releases to this host.
# Absolute path to the private key file produced by `tauri signer generate`.
TAURI_SIGNING_PRIVATE_KEY_PATH=C:/Users/denni/.tauri/chatapp.key
# Password set when generating the key. Leave empty if none.
TAURI_SIGNING_PRIVATE_KEY_PASSWORD=
# Host serving latest.json + installer artifacts over HTTPS.
# Host serving latest.yml + installer artifacts over HTTPS.
UPDATE_HOST=update.netralax.cloud
# SSH user on UPDATE_HOST with write access to UPDATE_REMOTE_PATH.
+3 -8
View File
@@ -29,14 +29,9 @@ web-build/
*.key
*.mobileprovision
# Tauri updater signing key (private — NEVER commit)
.tauri-updater.key
# .tauri-updater.key.pub is public, may be committed
# Tauri
apps/desktop/src-tauri/target/
apps/desktop/src-tauri/gen/
apps/desktop/src-tauri/WixTools/
# Electron desktop build artifacts
apps/desktop/out/
apps/desktop/release/
# Logs
*.log
+90
View File
@@ -0,0 +1,90 @@
// electron-vite config. Three targets:
// - main: the Electron main-process entry (electron/main.ts)
// - preload: the contextBridge script (electron/preload.ts)
// - renderer: the existing React app at apps/desktop/ (unchanged root)
//
// Manual chunks + optimizeDeps config is ported from the pre-migration
// vite.config.ts so LiveKit/libsodium/supabase/react vendor bundles keep
// caching independently across app updates.
import react from '@vitejs/plugin-react';
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
import path from 'node:path';
const rendererAliases = {
'@': path.resolve(__dirname, './src'),
'@shared': path.resolve(__dirname, '../../packages/shared/src'),
'@db-types': path.resolve(__dirname, '../../packages/db-types/src'),
'@ui-web': path.resolve(__dirname, '../../packages/ui-web/src'),
};
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
build: {
outDir: 'out/main',
rollupOptions: {
input: path.resolve(__dirname, 'electron/main.ts'),
},
},
},
preload: {
plugins: [externalizeDepsPlugin()],
build: {
outDir: 'out/preload',
rollupOptions: {
input: path.resolve(__dirname, 'electron/preload.ts'),
},
},
},
renderer: {
root: '.',
// file:// loading from inside app.asar can't resolve root-absolute
// asset URLs (`/assets/...`) — they hit the OS root instead of the
// bundle. Relative base produces `./assets/...` which works in both
// dev (served from /) and packaged builds.
base: './',
plugins: [react()],
resolve: {
alias: rendererAliases,
},
optimizeDeps: {
// libsodium-wrappers-sumo (and the compact variant) ship broken
// "import" conditions in package exports — the ESM bundle
// references a sibling ./libsodium.mjs that isn't in the
// published artefact. Force esbuild to pick the "require"
// condition so the self-contained CJS build is used.
include: ['libsodium-wrappers-sumo'],
esbuildOptions: {
conditions: ['require', 'node', 'default'],
},
},
build: {
outDir: 'out/renderer',
rollupOptions: {
input: path.resolve(__dirname, 'index.html'),
output: {
manualChunks: (id: string): string | undefined => {
if (id.includes('node_modules/livekit-client')) return 'vendor-livekit';
if (id.includes('node_modules/libsodium-wrappers-sumo'))
return 'vendor-sodium';
if (id.includes('node_modules/@supabase')) return 'vendor-supabase';
if (
id.includes('node_modules/react-router') ||
id.includes('node_modules/react-dom') ||
id.includes('node_modules/react/')
) {
return 'vendor-react';
}
return undefined;
},
},
},
},
server: {
port: 1420,
strictPort: true,
},
clearScreen: false,
},
});
+266
View File
@@ -0,0 +1,266 @@
// Single source of truth for the Electron IPC surface. Main process
// registers handlers keyed by the CHANNEL constants; preload exposes a
// typed `window.electronAPI` that mirrors the same shape. Renderer
// imports the type-only declarations via a d.ts published from preload.
//
// Rule: anything that the Rust side of Tauri used to do goes through
// here. If a call is not in this file it should not exist in the
// renderer.
export const CHANNELS = {
// Screen-share — replaces Rust screen_sources + browser getDisplayMedia
// coordination. Video still flows through Chromium's native pipeline
// (LiveKit JS SDK calls getDisplayMedia); we only feed it source ids.
SCREEN_GET_SOURCES: 'screen:get-sources',
SCREEN_GET_THUMBNAIL: 'screen:get-thumbnail',
/** Renderer-driven source selection: the picker modal calls this with
* the chosen sourceId BEFORE invoking getDisplayMedia. The main-process
* display-media handler then routes that source into the LiveKit track
* (instead of silently granting request.frame). Pass null to clear. */
SCREEN_SET_PENDING_SOURCE: 'screen:set-pending-source',
// System audio loopback — Implementation is renderer-driven under Electron.
// Main provides the screen source id (AUDIO_LOOPBACK_RESOLVE_SOURCE); the
// renderer then calls navigator.mediaDevices.getUserMedia with a
// chromeMediaSourceId constraint to obtain the loopback MediaStream
// directly. The legacy START/STOP channels are kept as named constants
// for now to avoid breaking type imports but have no handlers — see
// the renderer's lib/screenAudio.ts for the new flow.
AUDIO_LOOPBACK_RESOLVE_SOURCE: 'audio:loopback:resolve-source',
// Native WASAPI process-loopback path (Windows only). Implemented by
// the @chatapp/audio-loopback-native napi-rs addon; main owns the
// session and forwards PCM chunks to the renderer via the CHUNK event.
// Captures every render session on the box *except* our own PID tree,
// so peers in a video call don't hear themselves echoed back when the
// sharer ticks "include system audio". Falls back to the renderer-
// driven getUserMedia path on macOS/Linux or when the addon isn't
// available (dev builds without `pnpm build:native`, packaged builds
// missing the .node binary, etc.).
AUDIO_LOOPBACK_START: 'audio:loopback:start',
/** Window-share variant: capture audio of ONLY the picked window's
* process tree (INCLUDE_TARGET_PROCESS_TREE). Renderer derives the
* HWND from desktopCapturer's `window:<HWND>:0` source ids. */
AUDIO_LOOPBACK_START_FOR_WINDOW: 'audio:loopback:start-for-window',
AUDIO_LOOPBACK_STOP: 'audio:loopback:stop',
AUDIO_LOOPBACK_CHUNK: 'audio:loopback:chunk',
// Global hotkeys — replaces @tauri-apps/plugin-global-shortcut. Main
// owns the registration; renderer listens to `shortcut:fired` events
// for press and `shortcut:released` for PTT-style release.
SHORTCUT_REGISTER: 'shortcut:register',
SHORTCUT_UNREGISTER: 'shortcut:unregister',
SHORTCUT_IS_REGISTERED: 'shortcut:is-registered',
SHORTCUT_EVT_FIRED: 'shortcut:fired',
SHORTCUT_EVT_RELEASED: 'shortcut:released',
// OS notifications — replaces @tauri-apps/plugin-notification. Permission
// is always granted on desktop Electron; we keep the surface symmetric
// with the Tauri version so renderer code doesn't need to branch.
NOTIFY_SHOW: 'notify:show',
NOTIFY_PERMISSION: 'notify:permission',
// Tray badge — replaces tauri emit('tray-unread-update'). Main owns the
// Tray instance and overlays an unread count; renderer just pushes
// the number.
TRAY_UNREAD: 'tray:unread',
// Secure store — replaces plugin-stronghold + custom file vault. Uses
// Electron safeStorage (DPAPI/Keychain/libsecret) to seal per-user
// blobs on disk. `secure-store:open` establishes a per-user handle;
// subsequent calls use the stringified handle.
SECURE_STORE_OPEN: 'secure-store:open',
SECURE_STORE_GET: 'secure-store:get',
SECURE_STORE_SET: 'secure-store:set',
SECURE_STORE_REMOVE: 'secure-store:remove',
SECURE_STORE_CLOSE: 'secure-store:close',
// Filesystem (scoped to app local data dir) — replaces plugin-fs.
// All paths are relative to appLocalDataDir; main rejects traversal.
FS_READ: 'fs:read',
FS_WRITE: 'fs:write',
FS_EXISTS: 'fs:exists',
FS_MKDIR: 'fs:mkdir',
FS_RENAME: 'fs:rename',
FS_REMOVE: 'fs:remove',
FS_APP_LOCAL_DATA_DIR: 'fs:app-local-data-dir',
// SQLite — replaces plugin-sql. The renderer gets a handle per DB file.
SQL_LOAD: 'sql:load',
SQL_EXECUTE: 'sql:execute',
SQL_SELECT: 'sql:select',
SQL_CLOSE: 'sql:close',
// Auto-updater — replaces plugin-updater. electron-updater feed.
UPDATER_CHECK: 'updater:check',
UPDATER_DOWNLOAD_INSTALL: 'updater:download-install',
UPDATER_EVT_PROGRESS: 'updater:progress',
// Autostart — replaces @tauri-apps/plugin-autostart. Uses Electron's
// built-in app.setLoginItemSettings() to manage OS login items (Windows
// registry Run key, macOS LaunchAgent, Linux .desktop entry).
AUTOSTART_IS_ENABLED: 'autostart:is-enabled',
AUTOSTART_SET: 'autostart:set',
// Window fullscreen — replaces Tauri's `appWindow.setFullscreen(...)`.
// Used by the call cinema mode to flip the host BrowserWindow into real
// OS fullscreen so the Windows taskbar / macOS menubar gets covered.
WINDOW_SET_FULLSCREEN: 'window:set-fullscreen',
} as const;
// ---- Screen sources ------------------------------------------------------
export interface ScreenSource {
/** Opaque source id. Feed unchanged into getDisplayMedia via the
* chromeMediaSourceId constraint when we want Chromium to capture it. */
id: string;
name: string;
kind: 'screen' | 'window';
/** Display index for monitors (0-based). null for windows. */
displayId: number | null;
/** Pre-fetched thumbnail (data URL). Cheap to get from desktopCapturer
* so we return it inline with the listing — avoids a second round-trip
* per source. */
thumbnailDataUrl: string | null;
/** App icon for windows (data URL); null when unavailable. */
iconDataUrl: string | null;
}
// ---- Audio loopback ------------------------------------------------------
export interface AudioFrame {
captureId: number;
sampleRate: number;
channels: number;
/** Base64 of interleaved f32 LE stereo. Matches the old Tauri
* payload shape so the existing AudioWorklet doesn't change. */
samplesBase64: string;
}
/** Payload shape of `AUDIO_LOOPBACK_CHUNK` events delivered from main
* to the renderer. `samples` is a Float32Array of interleaved f32
* stereo PCM at 48kHz — Electron's structured-clone marshals
* TypedArrays directly, so no base64 hop is needed (unlike the legacy
* Tauri channel). */
export interface AudioLoopbackChunk {
captureId: number;
samples: Float32Array;
}
/** Result of `AUDIO_LOOPBACK_START`. */
export interface AudioLoopbackStartResult {
captureId: number;
}
// ---- Shortcuts -----------------------------------------------------------
export type ShortcutKind = 'press' | 'ptt';
export interface ShortcutRegisterArgs {
/** Accelerator in Electron syntax (e.g. `CommandOrControl+Shift+M`,
* or a bare key code like `F13`). PTT uses a single raw key. */
accelerator: string;
/** Opaque id the renderer picked. Used in `shortcut:fired` events and
* for unregister. Scoping to a logical id (not the accelerator)
* means the same key can be re-bound without double-register. */
id: string;
kind: ShortcutKind;
}
export interface ShortcutEvent {
id: string;
/** Monotonic timestamp (ms since epoch) — useful for PTT to detect
* held-key repeats at the OS level. */
ts: number;
}
// ---- Notifications -------------------------------------------------------
export interface NotifyArgs {
title: string;
body: string;
/** Silent = no system sound. Renderer already owns its own ringtone
* layer, so most notifications are silent. */
silent?: boolean;
}
// ---- Secure store --------------------------------------------------------
export interface SecureStoreOpenArgs {
userId: string;
}
export interface SecureStoreHandle {
handle: string;
/** True if the platform's safeStorage is available (encrypted). False
* means the store falls back to plaintext-on-disk — the renderer
* should warn the user and avoid storing long-lived secrets. */
encrypted: boolean;
}
// ---- Filesystem ----------------------------------------------------------
export type FsPath = string;
export interface FsWriteArgs {
path: FsPath;
/** Base64 of raw bytes. JSON IPC can't carry binary cleanly. */
dataBase64: string;
}
export interface FsRenameArgs {
from: FsPath;
to: FsPath;
}
// ---- SQL -----------------------------------------------------------------
export interface SqlLoadArgs {
/** DB filename (resolved under appLocalDataDir). Tauri's plugin-sql
* calls these `sqlite:<name>`; we strip the prefix on the main side. */
name: string;
}
export interface SqlExecuteArgs {
handle: string;
query: string;
bindings?: unknown[];
}
export interface SqlExecuteResult {
rowsAffected: number;
lastInsertId: number | null;
}
export interface SqlSelectArgs {
handle: string;
query: string;
bindings?: unknown[];
}
export type SqlSelectResult = Record<string, unknown>[];
// ---- Updater -------------------------------------------------------------
export interface UpdateInfo {
version: string;
releaseNotes: string | null;
}
export interface UpdateCheckResult {
available: boolean;
info: UpdateInfo | null;
}
export interface UpdateProgress {
percent: number;
transferred: number;
total: number;
}
// ---- Runtime marker ------------------------------------------------------
/** Value exposed on `window.electronAPI.platform`. Used by the renderer
* to keep the existing `isTauriRuntime()`-style branches but pointed at
* the new runtime. */
export const ELECTRON_RUNTIME_MARKER = 'electron-chatapp-v1' as const;
+259
View File
@@ -0,0 +1,259 @@
// Electron entry point. Creates the single BrowserWindow, wires all IPC
// module registrars, and keeps a single running instance (second launch
// focuses the existing window instead of opening a new one).
//
// Loads the renderer from the Vite dev server in dev (port 1420, matches
// the old Tauri devUrl so nothing in the renderer code needs to change)
// and from the built dist in packaged mode.
import { app, BrowserWindow, desktopCapturer, Menu, session } from 'electron';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { register as registerAudioLoopback } from './modules/audio-loopback';
import { register as registerAutostart } from './modules/autostart';
import { register as registerFsScoped } from './modules/fs-scoped';
import { register as registerNotifications } from './modules/notifications';
import { register as registerScreenAudio } from './modules/screen-audio';
import {
consumePendingShareSourceId,
register as registerScreenSources,
} from './modules/screen-sources';
import { register as registerSecureStore } from './modules/secure-store';
import { register as registerShortcuts } from './modules/shortcuts';
import { register as registerSql } from './modules/sql';
import { register as registerTray } from './modules/tray';
import { register as registerUpdater } from './modules/updater';
import { register as registerWindowFullscreen } from './modules/window-fullscreen';
import { attach as attachWindowState, loadState } from './window-state';
// __dirname in an ESM main-process bundle resolves to the out/main dir
// after electron-vite builds. Use a safe helper that works in both CJS
// (electron-vite main target defaults to CJS) and ESM contexts.
const __filenameSafe =
typeof __filename === 'string' ? __filename : fileURLToPath(import.meta.url);
const __dirnameSafe = path.dirname(__filenameSafe);
const DEV_URL = 'http://localhost:1420';
const WINDOW_STATE_FILE = 'window-state.json';
// App branding. productName in package.json drives the packaged exe name
// (Netralax.exe) and electron-builder installer title. setName + the
// AppUserModelId below cover the live process: window title fallback,
// Windows taskbar grouping, notification source attribution.
app.setName('Netralax');
if (process.platform === 'win32') {
app.setAppUserModelId('cloud.netralax.desktop');
}
// Pin userData to %APPDATA%\ChatApp regardless of productName so existing
// installs keep their profile, sounds, secrets, SQLite. Electron's default
// is %APPDATA%\<productName>, which after the Netralax rename would point
// at an empty fresh dir — same painful migration as the Tauri → Electron
// cut. Anchored to `appData` (the platform-AppData root) so productName
// changes can't drag it.
app.setPath('userData', path.join(app.getPath('appData'), 'ChatApp'));
// Run dev side-by-side with the installed packaged build by isolating the
// renderer profile / secret-store / SQLite / IndexedDB / localStorage in
// a separate userData dir. Without this both share `%APPDATA%\ChatApp`,
// the single-instance lock fires, and `pnpm dev` exits immediately while
// the installed prod app holds the lock. Must run BEFORE the lock check
// below + before any other module reads `app.getPath('userData')`.
if (!app.isPackaged) {
app.setPath('userData', app.getPath('userData') + '-Dev');
}
let mainWindow: BrowserWindow | null = null;
function resolvePreloadPath(): string {
// electron-vite names the preload bundle after the entry filename, so
// electron/preload.ts → out/preload/preload.mjs. ESM preload works in
// modern Electron when sandbox: false (which we have — preload needs
// Node APIs for libsodium init etc.). The previous `index.cjs` lookup
// was a stale leftover that silently never loaded — every IPC call
// (secret store, autostart, native notifications, screen sources,
// pending-share-source) returned undefined and any .x access threw.
return path.join(__dirnameSafe, '..', 'preload', 'preload.mjs');
}
function resolveRendererIndex(): string {
return path.join(__dirnameSafe, '..', 'renderer', 'index.html');
}
function resolveIconPath(): string {
const packaged = path.join(process.resourcesPath || '', 'icon.ico');
const dev = path.join(app.getAppPath(), 'resources', 'icon.ico');
return app.isPackaged ? packaged : dev;
}
async function createWindow(): Promise<BrowserWindow> {
const state = await loadState(WINDOW_STATE_FILE);
const win = new BrowserWindow({
title: app.isPackaged ? 'Netralax' : 'Netralax (Dev)',
width: state.width,
height: state.height,
...(state.x !== undefined ? { x: state.x } : {}),
...(state.y !== undefined ? { y: state.y } : {}),
minWidth: 800,
minHeight: 600,
resizable: true,
icon: resolveIconPath(),
show: false,
// Discord-style: no menu bar at all (no File/Edit/View/Window/Help).
// Belt: autoHideMenuBar hides it visually; suspenders: the global
// Menu.setApplicationMenu(null) below removes it entirely so Alt
// can't reveal it either.
autoHideMenuBar: true,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
// sandbox: false — preload needs Node APIs (libsodium init etc.)
sandbox: false,
preload: resolvePreloadPath(),
// Strip DevTools from release builds. Disables openDevTools(),
// F12, Ctrl+Shift+I, and the right-click Inspect entry for end
// users. Packaged = release; unpackaged = `electron-vite dev`,
// where we still want the inspector for local debugging.
devTools: !app.isPackaged,
},
});
if (state.maximized) win.maximize();
win.once('ready-to-show', () => win.show());
attachWindowState(win, WINDOW_STATE_FILE);
if (!app.isPackaged) {
await win.loadURL(DEV_URL);
} else {
await win.loadFile(resolveRendererIndex());
}
win.on('closed', () => {
if (mainWindow === win) mainWindow = null;
});
return win;
}
function configureDisplayMediaHandler(): void {
// Our custom picker runs in the renderer; when LiveKit (or anything
// else) calls navigator.mediaDevices.getDisplayMedia with a specific
// chromeMediaSourceId constraint, Chromium defers to the handler we
// install here.
//
// Two paths:
// 1. Discord-style picker case — the renderer set a pending source id
// via SCREEN_SET_PENDING_SOURCE before calling getDisplayMedia. We
// resolve that id back to a desktopCapturer Source and pass it as
// the video grant. This is what makes the user's tile choice
// actually take effect.
// 2. Fallback — no pending id (e.g. a stray getDisplayMedia from
// another code path). Grant whatever the renderer's constraint
// selected by passing the frame back, matching the legacy v0.11.x
// behavior so we don't break anything off the picker code path.
//
// Audio: 'loopback' captures all system audio. We tried
// 'loopbackWithMute' (which excludes this app's own audio output from
// the capture) but it ALSO mutes the renderer's audio playback
// locally — so the user couldn't hear remote peers during a share
// and the stream had no audio either when the only thing playing was
// the muted call audio. There is no clean Electron equivalent of
// Tauri's EXCLUDE_TARGET_PROCESS_TREE that affects only the capture
// and not local playback. Trade-off chosen here: keep local audio
// working ('loopback'), let the user opt into the JS-level auto-duck
// (`screenShareSettings.duckRemoteAudioWhileSharing`) when they
// actually need the echo prevention. Default: off.
// https://www.electronjs.org/docs/latest/api/session#sessetdisplaymediarequesthandlerhandler-opts
session.defaultSession.setDisplayMediaRequestHandler(
(request, callback) => {
const pendingId = consumePendingShareSourceId();
if (pendingId) {
void desktopCapturer
.getSources({ types: ['screen', 'window'] })
.then((sources) => {
const source = sources.find((s) => s.id === pendingId);
if (source) {
callback({ video: source, audio: 'loopback' });
} else {
// Source vanished between picker confirm and grant (window
// closed, monitor unplugged). Fall back to the frame grant
// so getDisplayMedia doesn't hang the renderer.
callback({
video: request.frame as unknown as Electron.WebFrameMain,
audio: 'loopback',
});
}
})
.catch(() => {
callback({
video: request.frame as unknown as Electron.WebFrameMain,
audio: 'loopback',
});
});
return;
}
callback({
video: request.frame as unknown as Electron.WebFrameMain,
audio: 'loopback',
});
},
{ useSystemPicker: false },
);
}
// Single-instance lock — second launch focuses existing window instead
// of spawning a duplicate process.
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
app.quit();
} else {
app.on('second-instance', () => {
if (!mainWindow) return;
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
});
void app.whenReady().then(async () => {
// Strip the default application menu globally — we don't ship any
// File/Edit/View/Window/Help entries (Discord-style chrome). Must run
// BEFORE the first BrowserWindow is created; setting it later causes
// the menu to flash for a frame on Windows.
Menu.setApplicationMenu(null);
configureDisplayMediaHandler();
mainWindow = await createWindow();
// Stateless registrars first.
registerScreenSources();
registerScreenAudio();
registerNotifications();
registerSecureStore();
registerFsScoped();
registerSql();
registerAutostart();
// Window-dependent registrars — only call once mainWindow exists so
// event emitters have somewhere to send.
registerShortcuts(mainWindow);
registerTray(mainWindow);
registerUpdater(mainWindow);
registerWindowFullscreen(mainWindow);
registerAudioLoopback(mainWindow);
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
app.on('activate', () => {
if (!mainWindow) {
void createWindow().then((w) => {
mainWindow = w;
});
}
});
}
@@ -0,0 +1,213 @@
// Native WASAPI process-loopback bridge. Wraps the
// @chatapp/audio-loopback-native napi-rs addon and forwards PCM chunks
// to the renderer over IPC.
//
// Why the addon over Chromium's built-in 'loopback' source: the OS
// process-loopback API supports EXCLUDE_TARGET_PROCESS_TREE, which lets
// us capture every render session *except* our own PID tree. That keeps
// LiveKit's call playback out of the outgoing share so peers don't hear
// themselves echoed back. Chromium's 'loopback' has no such filter.
//
// Windows-only. The addon's start_capture call rejects on macOS/Linux
// with a clear error string and the renderer falls through to the
// existing Chromium getUserMedia path (lib/screenAudio.ts).
import { app, BrowserWindow, ipcMain } from 'electron';
import { createRequire } from 'node:module';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
CHANNELS,
type AudioLoopbackChunk,
type AudioLoopbackStartResult,
} from '../ipc-types';
interface NativeAudioLoopback {
startCapture: (callback: (samples: Float32Array) => void) => number;
/** INCLUDE_TARGET_PROCESS_TREE variant — capture only `pid`'s tree.
* Used for window-shares so we get just the picked app's audio. */
startCaptureForPid?: (
pid: number,
callback: (samples: Float32Array) => void,
) => number;
/** Look up the owning process id of a top-level window handle. */
resolveWindowPid?: (hwnd: number) => number;
stopCapture: (captureId: number) => void;
}
// __dirname in an ESM main bundle resolves to out/main after
// electron-vite builds. We need a CJS-style require to load the native
// addon — `import` would trigger ESM resolution which doesn't handle
// .node files cleanly across electron-vite's transform.
const __filenameSafe =
typeof __filename === 'string' ? __filename : fileURLToPath(import.meta.url);
const __dirnameSafe = path.dirname(__filenameSafe);
const requireCjs = createRequire(__filenameSafe);
let cachedAddon: NativeAudioLoopback | null | undefined;
function resolveAddon(): NativeAudioLoopback | null {
if (cachedAddon !== undefined) return cachedAddon;
// Production: electron-builder copies the .node into
// resources/native/audio-loopback.node (see extraResources in
// package.json).
// Dev: napi build emits the binary alongside the addon's package.json
// at apps/desktop/native/audio-loopback/audio-loopback.<triple>.node,
// and writes an index.js shim that auto-selects the right triple.
// Loading the shim works in both layouts when the binary sits
// adjacent to it; for the packaged single-file layout we require the
// .node directly.
const candidates: string[] = [];
if (app.isPackaged && process.resourcesPath) {
candidates.push(path.join(process.resourcesPath, 'native', 'audio-loopback.node'));
}
// Dev workspace layout — main bundle lives at out/main/main.js,
// addon lives at native/audio-loopback/. Walk up two levels from the
// bundle dir (out/main → out → apps/desktop) and into native/.
candidates.push(
path.join(__dirnameSafe, '..', '..', 'native', 'audio-loopback', 'index.js'),
// Direct .node fallback in case the JS shim is missing (e.g. user
// ran `cargo build` manually instead of `napi build`).
path.join(
__dirnameSafe,
'..',
'..',
'native',
'audio-loopback',
`audio-loopback.${process.platform}-${process.arch}-msvc.node`,
),
);
for (const candidate of candidates) {
try {
const mod = requireCjs(candidate) as NativeAudioLoopback;
if (
typeof mod.startCapture === 'function' &&
typeof mod.stopCapture === 'function'
) {
cachedAddon = mod;
return mod;
}
} catch {
/* try next candidate */
}
}
cachedAddon = null;
return null;
}
/** Track active capture ids per BrowserWindow so we can tear them down
* if the renderer is destroyed mid-capture (renderer crash, window
* close during share). Without this the WASAPI thread would leak. */
const activeCaptures = new Map<number, Set<number>>();
function registerWindowCleanup(win: BrowserWindow, addon: NativeAudioLoopback): void {
const wcId = win.webContents.id;
if (activeCaptures.has(wcId)) return;
activeCaptures.set(wcId, new Set());
const cleanup = (): void => {
const ids = activeCaptures.get(wcId);
if (!ids) return;
for (const id of ids) {
try {
addon.stopCapture(id);
} catch {
/* best effort */
}
}
activeCaptures.delete(wcId);
};
win.webContents.once('destroyed', cleanup);
win.once('closed', cleanup);
}
export function register(mainWindow: BrowserWindow): void {
ipcMain.handle(
CHANNELS.AUDIO_LOOPBACK_START,
async (event): Promise<AudioLoopbackStartResult> => {
const addon = resolveAddon();
if (!addon) {
throw new Error('audio-loopback native addon unavailable on this platform');
}
const wc = event.sender;
// captureId is assigned synchronously by addon.startCapture below,
// but the chunk callback needs to reference it — we forward-declare
// via a closure-shared holder. The first chunk can only fire after
// startCapture returns (the worker thread spawn happens inside it).
let assignedId = 0;
const cb = makeChunkCallback(wc, () => assignedId);
assignedId = addon.startCapture(cb);
registerWindowCleanup(mainWindow, addon);
const set = activeCaptures.get(mainWindow.webContents.id);
if (set) set.add(assignedId);
return { captureId: assignedId };
},
);
ipcMain.handle(
CHANNELS.AUDIO_LOOPBACK_START_FOR_WINDOW,
async (
event,
args: { hwnd: number },
): Promise<AudioLoopbackStartResult> => {
const addon = resolveAddon();
if (!addon) {
throw new Error('audio-loopback native addon unavailable on this platform');
}
if (!addon.startCaptureForPid || !addon.resolveWindowPid) {
throw new Error(
'audio-loopback native addon is too old: missing startCaptureForPid / resolveWindowPid (rebuild with `pnpm build:native`)',
);
}
if (!args || typeof args.hwnd !== 'number' || !Number.isFinite(args.hwnd)) {
throw new Error('AUDIO_LOOPBACK_START_FOR_WINDOW: hwnd must be a finite number');
}
const pid = addon.resolveWindowPid(args.hwnd);
const wc = event.sender;
let assignedId = 0;
const cb = makeChunkCallback(wc, () => assignedId);
assignedId = addon.startCaptureForPid(pid, cb);
registerWindowCleanup(mainWindow, addon);
const set = activeCaptures.get(mainWindow.webContents.id);
if (set) set.add(assignedId);
return { captureId: assignedId };
},
);
ipcMain.handle(
CHANNELS.AUDIO_LOOPBACK_STOP,
async (_event, captureId: number): Promise<void> => {
const addon = resolveAddon();
if (!addon) return;
try {
addon.stopCapture(captureId);
} finally {
const set = activeCaptures.get(mainWindow.webContents.id);
if (set) set.delete(captureId);
}
},
);
}
/** Shared chunk-forwarding callback. Both start variants produce the
* same wire format (interleaved f32 stereo @ 48kHz) so the renderer
* doesn't need to know which start path was used — the captureId
* routes the chunks. */
function makeChunkCallback(
wc: Electron.WebContents,
getCaptureId: () => number,
): (samples: Float32Array) => void {
return (samples: Float32Array): void => {
// The addon delivers each chunk on its WASAPI capture thread —
// marshal to the renderer's webContents from the main loop. If the
// webContents has been destroyed (window closed during a share)
// silently drop; the cleanup hook will stop the capture.
if (wc.isDestroyed()) return;
const captureId = getCaptureId();
const payload: AudioLoopbackChunk = { captureId, samples };
wc.send(CHANNELS.AUDIO_LOOPBACK_CHUNK, payload);
};
}
@@ -0,0 +1,29 @@
// Autostart adapter — replaces Tauri's `tauri-plugin-autostart`. Uses
// Electron's built-in `app.setLoginItemSettings()` / `app.getLoginItemSettings()`,
// which manages the OS login-items mechanism on Windows (HKCU registry
// Run key), macOS (LaunchAgent), and Linux (.desktop entry).
import { app, ipcMain } from 'electron';
import { CHANNELS } from '../ipc-types';
export function register(): void {
ipcMain.handle(CHANNELS.AUTOSTART_IS_ENABLED, () => {
try {
const settings = app.getLoginItemSettings();
return settings.openAtLogin;
} catch (err: unknown) {
console.warn('autostart get failed', err);
return false;
}
});
ipcMain.handle(CHANNELS.AUTOSTART_SET, (_evt, enabled: boolean) => {
try {
app.setLoginItemSettings({ openAtLogin: !!enabled });
} catch (err: unknown) {
console.warn('autostart set failed', err);
throw err;
}
});
}
@@ -0,0 +1,81 @@
// Scoped filesystem. Every renderer-supplied path is resolved under
// `app.getPath('userData')`. Post-normalisation we re-check the resolved
// absolute path is still contained in the root; anything that breaks out
// (via .., symlink, absolute path) is rejected. Binary payloads are
// base64 on the wire because JSON IPC can't carry raw bytes cleanly.
import { app, ipcMain } from 'electron';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { CHANNELS, type FsPath, type FsRenameArgs, type FsWriteArgs } from '../ipc-types';
function rootDir(): string {
return app.getPath('userData');
}
function resolveScoped(rel: FsPath): string {
const root = rootDir();
if (path.isAbsolute(rel)) {
throw new Error('fs-scoped: absolute path rejected');
}
const normalised = path.normalize(rel);
if (normalised.split(/[\\/]/).includes('..')) {
throw new Error('fs-scoped: path traversal rejected');
}
const abs = path.resolve(root, normalised);
const withSep = root.endsWith(path.sep) ? root : root + path.sep;
if (abs !== root && !abs.startsWith(withSep)) {
throw new Error('fs-scoped: escaped scope');
}
return abs;
}
export function register(): void {
ipcMain.handle(CHANNELS.FS_APP_LOCAL_DATA_DIR, async (): Promise<string> => rootDir());
ipcMain.handle(CHANNELS.FS_READ, async (_evt, rel: FsPath): Promise<string | null> => {
const abs = resolveScoped(rel);
try {
const buf = await fs.readFile(abs);
return buf.toString('base64');
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw err;
}
});
ipcMain.handle(CHANNELS.FS_WRITE, async (_evt, args: FsWriteArgs): Promise<void> => {
const abs = resolveScoped(args.path);
await fs.mkdir(path.dirname(abs), { recursive: true });
const buf = Buffer.from(args.dataBase64, 'base64');
await fs.writeFile(abs, buf);
});
ipcMain.handle(CHANNELS.FS_EXISTS, async (_evt, rel: FsPath): Promise<boolean> => {
const abs = resolveScoped(rel);
try {
await fs.access(abs);
return true;
} catch {
return false;
}
});
ipcMain.handle(CHANNELS.FS_MKDIR, async (_evt, rel: FsPath): Promise<void> => {
const abs = resolveScoped(rel);
await fs.mkdir(abs, { recursive: true });
});
ipcMain.handle(CHANNELS.FS_RENAME, async (_evt, args: FsRenameArgs): Promise<void> => {
const from = resolveScoped(args.from);
const to = resolveScoped(args.to);
await fs.mkdir(path.dirname(to), { recursive: true });
await fs.rename(from, to);
});
ipcMain.handle(CHANNELS.FS_REMOVE, async (_evt, rel: FsPath): Promise<void> => {
const abs = resolveScoped(rel);
await fs.rm(abs, { recursive: true, force: true });
});
}
@@ -0,0 +1,26 @@
// OS notifications. Electron's Notification API doesn't have a separate
// permission prompt on desktop — permission is implicit and always
// granted — so `notify:permission` is a compatibility shim that keeps
// the renderer's existing plugin-notification call-sites working
// without branching.
import { ipcMain, Notification } from 'electron';
import { CHANNELS, type NotifyArgs } from '../ipc-types';
export function register(): void {
ipcMain.handle(CHANNELS.NOTIFY_SHOW, async (_evt, args: NotifyArgs): Promise<void> => {
if (!Notification.isSupported()) return;
const n = new Notification({
title: args.title,
body: args.body,
silent: args.silent ?? true,
});
n.show();
});
ipcMain.handle(
CHANNELS.NOTIFY_PERMISSION,
async (): Promise<'granted' | 'denied' | 'default'> => 'granted',
);
}
@@ -0,0 +1,38 @@
// System-audio loopback — renderer-driven. Chromium's
// `chromeMediaSource: 'desktop'` constraint on getUserMedia accepts a
// source id and returns a MediaStream that contains the OS mixer output.
// Main's only job is to resolve which source id the renderer should feed
// to getUserMedia (typically the primary screen). See the display-media
// handler in main.ts which grants the request automatically.
import { desktopCapturer, ipcMain } from 'electron';
import { CHANNELS } from '../ipc-types';
export interface ResolveLoopbackSourceResult {
sourceId: string;
}
export function register(): void {
ipcMain.handle(
CHANNELS.AUDIO_LOOPBACK_RESOLVE_SOURCE,
async (): Promise<ResolveLoopbackSourceResult | null> => {
// Enumerate only screens; windows don't expose audio loopback on
// Windows and there's no meaningful "system audio" tied to a
// single window anyway. Primary screen is the first entry — the
// id is stable across calls as long as the display config doesn't
// change mid-session.
const sources = await desktopCapturer.getSources({
types: ['screen'],
fetchWindowIcons: false,
});
const first = sources[0];
if (!first) return null;
return { sourceId: first.id };
},
);
// The legacy start/stop channels are left unregistered on purpose —
// their constants still exist in ipc-types.ts for backwards compatible
// import paths, but there is no corresponding handler.
}
@@ -0,0 +1,96 @@
// Screen / window source enumeration + on-demand high-res thumbnail fetch.
// The picker in the renderer calls `SCREEN_GET_SOURCES` once to populate
// the grid (thumbnails come back inline as data URLs from desktopCapturer,
// so no second round-trip is needed for the initial paint). When the user
// hovers a tile we can optionally refresh the thumb at a higher resolution
// via `SCREEN_GET_THUMBNAIL` — same API but a single id and 640x360 size.
import { desktopCapturer, ipcMain } from 'electron';
import { CHANNELS, type ScreenSource } from '../ipc-types';
// Renderer-driven pending source: the picker modal sets this BEFORE calling
// getDisplayMedia so our display-media handler can route the chosen source
// to LiveKit. Stored module-level (single capture in flight at a time —
// the renderer enforces this since only one picker can be open). Cleared
// on consume or on explicit null-set (cancel/error path).
let pendingShareSourceId: string | null = null;
/** Read-and-clear: returns the pending id and resets it to null in one
* step so the main-process display-media handler can't accidentally apply
* the same id twice (e.g. if a stray getDisplayMedia call fires while
* the picker is closed). */
export function consumePendingShareSourceId(): string | null {
const id = pendingShareSourceId;
pendingShareSourceId = null;
return id;
}
/** Non-destructive read for callers that just want to know if a pending
* selection exists. */
export function getPendingShareSourceId(): string | null {
return pendingShareSourceId;
}
function parseDisplayId(raw: string): number | null {
// desktopCapturer ids for screens look like "screen:<display-id>:0". We
// index monitors 0-based in the UI so reduce the opaque id to a small
// integer per primary-display order. For windows there is no display
// association, return null.
if (!raw.startsWith('screen:')) return null;
const parts = raw.split(':');
const n = Number(parts[1]);
return Number.isFinite(n) ? n : null;
}
async function enumerate(thumbWidth: number, thumbHeight: number): Promise<ScreenSource[]> {
const sources = await desktopCapturer.getSources({
types: ['screen', 'window'],
thumbnailSize: { width: thumbWidth, height: thumbHeight },
fetchWindowIcons: true,
});
return sources.map((src): ScreenSource => {
const kind: 'screen' | 'window' = src.id.startsWith('screen:') ? 'screen' : 'window';
const thumbnailDataUrl =
src.thumbnail && !src.thumbnail.isEmpty() ? src.thumbnail.toDataURL() : null;
const iconDataUrl =
src.appIcon && !src.appIcon.isEmpty() ? src.appIcon.toDataURL() : null;
return {
id: src.id,
name: src.name,
kind,
displayId: kind === 'screen' ? parseDisplayId(src.id) : null,
thumbnailDataUrl,
iconDataUrl,
};
});
}
export function register(): void {
ipcMain.handle(CHANNELS.SCREEN_GET_SOURCES, async (): Promise<ScreenSource[]> => {
return enumerate(320, 180);
});
ipcMain.handle(
CHANNELS.SCREEN_GET_THUMBNAIL,
async (_evt, sourceId: string): Promise<string | null> => {
// Re-enumerate — desktopCapturer has no "fetch one by id" API. Done
// at 640x360 so the detail view looks crisp without paying the full
// enumeration cost more than once per hover-debounce.
const list = await enumerate(640, 360);
const found = list.find((s) => s.id === sourceId);
return found?.thumbnailDataUrl ?? null;
},
);
ipcMain.handle(
CHANNELS.SCREEN_SET_PENDING_SOURCE,
(_evt, sourceId: string | null): void => {
// Renderer signals the chosen source id (or null to clear on cancel/
// error). Stored until the next getDisplayMedia request comes in via
// the display-media handler, which calls consumePendingShareSourceId
// to read-and-clear it.
pendingShareSourceId = sourceId;
},
);
}
@@ -0,0 +1,143 @@
// Per-user secure key/value store. Replaces Tauri's plugin-stronghold +
// custom file vault with Electron's safeStorage (DPAPI on Windows,
// Keychain on macOS, libsecret on Linux). Encryption is at the file
// level — the whole entries map is a single encrypted blob — so there's
// no per-set ciphertext rotation to track.
//
// Pre-encrypt JSON: {version:1, entries: { <key>: <utf8-string-value> }}.
// When safeStorage is unavailable we degrade to a `.plaintext` JSON file
// and flag `encrypted: false` back to the renderer so the renderer can
// warn the user and avoid long-lived secrets.
import { app, ipcMain, safeStorage } from 'electron';
import { createHash } from 'node:crypto';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import {
CHANNELS,
type SecureStoreHandle,
type SecureStoreOpenArgs,
} from '../ipc-types';
interface HandleState {
filePath: string;
encrypted: boolean;
entries: Map<string, string>;
saveTimer: NodeJS.Timeout | null;
}
const handles = new Map<string, HandleState>();
function hashUserId(userId: string): string {
return createHash('sha256').update(userId).digest('hex').slice(0, 16);
}
function filePathFor(userId: string, encrypted: boolean): string {
const suffix = hashUserId(userId);
const ext = encrypted ? 'bin' : 'plaintext';
return path.join(app.getPath('userData'), `chatapp-secure-${suffix}.${ext}`);
}
async function loadState(filePath: string, encrypted: boolean): Promise<Map<string, string>> {
try {
if (encrypted) {
const buf = await fs.readFile(filePath);
const json = safeStorage.decryptString(buf);
const parsed = JSON.parse(json) as { version?: number; entries?: Record<string, string> };
return new Map(Object.entries(parsed.entries ?? {}));
} else {
const raw = await fs.readFile(filePath, 'utf8');
const parsed = JSON.parse(raw) as { version?: number; entries?: Record<string, string> };
return new Map(Object.entries(parsed.entries ?? {}));
}
} catch {
// Missing file or malformed contents — start fresh. The next write
// will overwrite with a fresh blob.
return new Map();
}
}
async function writeStateNow(state: HandleState): Promise<void> {
const obj: Record<string, string> = {};
for (const [k, v] of state.entries) obj[k] = v;
const serialised = JSON.stringify({ version: 1, entries: obj });
await fs.mkdir(path.dirname(state.filePath), { recursive: true });
if (state.encrypted) {
const buf = safeStorage.encryptString(serialised);
await fs.writeFile(state.filePath, buf);
} else {
await fs.writeFile(state.filePath, serialised, 'utf8');
}
}
function scheduleSave(state: HandleState): void {
if (state.saveTimer) clearTimeout(state.saveTimer);
state.saveTimer = setTimeout(() => {
state.saveTimer = null;
void writeStateNow(state).catch((err: unknown) => {
console.warn('[secure-store] persist failed', err);
});
}, 100);
}
function requireState(handle: string): HandleState {
const s = handles.get(handle);
if (!s) throw new Error('secure-store: unknown handle');
return s;
}
export function register(): void {
ipcMain.handle(
CHANNELS.SECURE_STORE_OPEN,
async (_evt, args: SecureStoreOpenArgs): Promise<SecureStoreHandle> => {
const encrypted = safeStorage.isEncryptionAvailable();
const filePath = filePathFor(args.userId, encrypted);
const entries = await loadState(filePath, encrypted);
const handle = hashUserId(args.userId);
handles.set(handle, { filePath, encrypted, entries, saveTimer: null });
return { handle, encrypted };
},
);
ipcMain.handle(
CHANNELS.SECURE_STORE_GET,
async (_evt, handle: string, key: string): Promise<string | null> => {
const state = requireState(handle);
return state.entries.get(key) ?? null;
},
);
ipcMain.handle(
CHANNELS.SECURE_STORE_SET,
async (_evt, handle: string, key: string, value: string): Promise<void> => {
const state = requireState(handle);
state.entries.set(key, value);
scheduleSave(state);
},
);
ipcMain.handle(
CHANNELS.SECURE_STORE_REMOVE,
async (_evt, handle: string, key: string): Promise<void> => {
const state = requireState(handle);
state.entries.delete(key);
scheduleSave(state);
},
);
ipcMain.handle(CHANNELS.SECURE_STORE_CLOSE, async (_evt, handle: string): Promise<void> => {
const state = handles.get(handle);
if (!state) return;
if (state.saveTimer) {
clearTimeout(state.saveTimer);
state.saveTimer = null;
try {
await writeStateNow(state);
} catch (err: unknown) {
console.warn('[secure-store] close-flush failed', err);
}
}
handles.delete(handle);
});
}
@@ -0,0 +1,86 @@
// Global shortcuts — wraps Electron's globalShortcut, tracks registrations
// by an opaque renderer-supplied id so the same accelerator can be
// re-bound without the caller juggling state.
//
// PTT semantics are simulated: Electron's globalShortcut API only delivers
// a "pressed" callback — it has no keyup / release event. We fire
// SHORTCUT_EVT_FIRED on press, then after a 200ms timer fire
// SHORTCUT_EVT_RELEASED. Known limitation; TODO: revisit with
// `uiohook-napi` or `node-global-key-listener` if the press-and-release
// UX is too loose.
import { app, BrowserWindow, globalShortcut, ipcMain } from 'electron';
import {
CHANNELS,
type ShortcutEvent,
type ShortcutKind,
type ShortcutRegisterArgs,
} from '../ipc-types';
interface Entry {
accelerator: string;
kind: ShortcutKind;
}
const registry = new Map<string, Entry>();
export function register(mainWindow: BrowserWindow): void {
const send = (channel: string, payload: ShortcutEvent): void => {
if (mainWindow.isDestroyed()) return;
mainWindow.webContents.send(channel, payload);
};
ipcMain.handle(
CHANNELS.SHORTCUT_REGISTER,
async (_evt, args: ShortcutRegisterArgs): Promise<boolean> => {
const { id, accelerator, kind } = args;
const prev = registry.get(id);
if (prev) {
try {
globalShortcut.unregister(prev.accelerator);
} catch {
/* ignore */
}
registry.delete(id);
}
if (globalShortcut.isRegistered(accelerator)) {
return false;
}
const ok = globalShortcut.register(accelerator, () => {
const ts = Date.now();
send(CHANNELS.SHORTCUT_EVT_FIRED, { id, ts });
if (kind === 'ptt') {
setTimeout(() => {
send(CHANNELS.SHORTCUT_EVT_RELEASED, { id, ts: Date.now() });
}, 200);
}
});
if (!ok) return false;
registry.set(id, { accelerator, kind });
return true;
},
);
ipcMain.handle(CHANNELS.SHORTCUT_UNREGISTER, async (_evt, id: string): Promise<void> => {
const entry = registry.get(id);
if (!entry) return;
try {
globalShortcut.unregister(entry.accelerator);
} catch {
/* already gone */
}
registry.delete(id);
});
ipcMain.handle(CHANNELS.SHORTCUT_IS_REGISTERED, async (_evt, id: string): Promise<boolean> => {
const entry = registry.get(id);
if (!entry) return false;
return globalShortcut.isRegistered(entry.accelerator);
});
app.on('will-quit', () => {
globalShortcut.unregisterAll();
registry.clear();
});
}
+93
View File
@@ -0,0 +1,93 @@
// SQLite via better-sqlite3. One Database instance per renderer-tracked
// handle; handles are keyed by the normalised db name (Tauri's plugin-sql
// uses `sqlite:<name>` — we strip the prefix). Sync API is fine here
// because the main process has its own event loop; better-sqlite3's
// prepare/run/all are blocking but fast for typical chat-cache queries
// (<1ms per op for the current workload).
//
// Binding param style: Tauri's plugin-sql used $1, $2... positionals with
// bindings as an array. SQLite natively accepts $N so existing queries
// keep working unmodified.
import { app, ipcMain } from 'electron';
import Database from 'better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import {
CHANNELS,
type SqlExecuteArgs,
type SqlExecuteResult,
type SqlLoadArgs,
type SqlSelectArgs,
type SqlSelectResult,
} from '../ipc-types';
interface Handle {
db: Database.Database;
filePath: string;
}
const handles = new Map<string, Handle>();
function stripPrefix(name: string): string {
return name.startsWith('sqlite:') ? name.slice('sqlite:'.length) : name;
}
function requireHandle(h: string): Handle {
const entry = handles.get(h);
if (!entry) throw new Error(`sql: unknown handle ${h}`);
return entry;
}
export function register(): void {
ipcMain.handle(CHANNELS.SQL_LOAD, async (_evt, args: SqlLoadArgs): Promise<string> => {
const rawName = stripPrefix(args.name);
const fileName = rawName.endsWith('.db') ? rawName : rawName + '.db';
const filePath = path.join(app.getPath('userData'), fileName);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const existing = handles.get(rawName);
if (existing) return rawName;
const db = new Database(filePath);
db.pragma('journal_mode = WAL');
handles.set(rawName, { db, filePath });
return rawName;
});
ipcMain.handle(
CHANNELS.SQL_EXECUTE,
async (_evt, args: SqlExecuteArgs): Promise<SqlExecuteResult> => {
const entry = requireHandle(args.handle);
const stmt = entry.db.prepare(args.query);
const info = stmt.run(...((args.bindings ?? []) as unknown[]));
return {
rowsAffected: info.changes,
lastInsertId:
typeof info.lastInsertRowid === 'bigint'
? Number(info.lastInsertRowid)
: (info.lastInsertRowid ?? null),
};
},
);
ipcMain.handle(
CHANNELS.SQL_SELECT,
async (_evt, args: SqlSelectArgs): Promise<SqlSelectResult> => {
const entry = requireHandle(args.handle);
const stmt = entry.db.prepare(args.query);
const rows = stmt.all(...((args.bindings ?? []) as unknown[])) as Record<string, unknown>[];
return rows;
},
);
ipcMain.handle(CHANNELS.SQL_CLOSE, async (_evt, handle: string): Promise<void> => {
const entry = handles.get(handle);
if (!entry) return;
try {
entry.db.close();
} catch (err: unknown) {
console.warn('[sql] close failed', err);
}
handles.delete(handle);
});
}
+120
View File
@@ -0,0 +1,120 @@
// System tray + Windows taskbar overlay badge. Renderer pushes the
// current aggregate unread count via CHANNELS.TRAY_UNREAD; we update the
// tooltip and (on Windows) set an overlay icon on the main window's
// taskbar button.
//
// Overlay image is a pre-rendered 16x16 red dot embedded as base64 so
// the module is self-contained — no runtime canvas dependency.
import {
app,
BrowserWindow,
ipcMain,
Menu,
nativeImage,
type NativeImage,
Tray,
} from 'electron';
import path from 'node:path';
import { CHANNELS } from '../ipc-types';
let trayRef: Tray | null = null;
let overlayImage: NativeImage | null = null;
function resolveIconPath(): string {
return path.join(process.resourcesPath || app.getAppPath(), 'icon.ico');
}
function resolveIconPathDev(): string {
return path.join(app.getAppPath(), 'resources', 'icon.ico');
}
function loadTrayIcon(): NativeImage {
for (const p of [resolveIconPath(), resolveIconPathDev()]) {
try {
const img = nativeImage.createFromPath(p);
if (!img.isEmpty()) return img;
} catch {
/* try next */
}
}
return nativeImage.createEmpty();
}
function buildOverlay(): NativeImage {
if (overlayImage) return overlayImage;
// 16x16 PNG, solid red circle. Inline base64 so no external file lookup.
const base64 =
'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAPElEQVR42mNkYGD4z0AGYBxVSF2F//' +
'//Z2BgYGD4/58kBYz4FDAxMDAwMDIwMDD8//+foArGUYWjCoc1AABTgwUBf3lZtAAAAABJRU5ErkJggg==';
overlayImage = nativeImage.createFromBuffer(Buffer.from(base64, 'base64'));
return overlayImage;
}
export function register(mainWindow: BrowserWindow): void {
const icon = loadTrayIcon();
trayRef = new Tray(icon.isEmpty() ? nativeImage.createEmpty() : icon);
trayRef.setToolTip('Netralax');
const menu = Menu.buildFromTemplate([
{
label: 'Open',
click: (): void => {
if (mainWindow.isDestroyed()) return;
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
},
},
{ type: 'separator' },
{
label: 'Quit',
click: (): void => {
app.quit();
},
},
]);
trayRef.setContextMenu(menu);
trayRef.on('click', (): void => {
if (mainWindow.isDestroyed()) return;
if (mainWindow.isMinimized()) mainWindow.restore();
if (mainWindow.isVisible()) mainWindow.focus();
else mainWindow.show();
});
ipcMain.handle(
CHANNELS.TRAY_UNREAD,
async (_evt, count: number, badgeDataUrl?: string | null): Promise<void> => {
const n = Math.max(0, Math.floor(Number(count) || 0));
if (trayRef && !trayRef.isDestroyed()) {
trayRef.setToolTip(n > 0 ? `Netralax — ${n} ungelesen` : 'Netralax');
}
if (mainWindow.isDestroyed()) return;
if (process.platform !== 'win32') return;
if (n <= 0) {
mainWindow.setOverlayIcon(null, '');
return;
}
// Discord-style: prefer the renderer-painted badge (red circle with
// the actual unread number). Fall back to the static red dot only if
// the renderer didn't supply one or decoding failed — keeps the
// visual indicator alive even when the canvas pipeline is unavailable.
let overlay: NativeImage | null = null;
if (typeof badgeDataUrl === 'string' && badgeDataUrl.startsWith('data:image/')) {
const decoded = nativeImage.createFromDataURL(badgeDataUrl);
if (!decoded.isEmpty()) overlay = decoded;
}
mainWindow.setOverlayIcon(overlay ?? buildOverlay(), `${n} ungelesen`);
},
);
app.on('before-quit', () => {
try {
trayRef?.destroy();
} catch {
/* already gone */
}
trayRef = null;
});
}
+84
View File
@@ -0,0 +1,84 @@
// Auto-updater. Wraps electron-updater; configuration (feed URL) lives in
// package.json's `build.publish`. In dev (`app.isPackaged === false`) we
// short-circuit everything — the feed server is production-only and
// hitting it every launch from a dev machine just adds noise.
import { app, BrowserWindow, ipcMain } from 'electron';
import electronUpdater, {
type ProgressInfo,
type UpdateInfo as BuilderUpdateInfo,
} from 'electron-updater';
// electron-updater is a CJS module; named ESM imports don't work. Pull
// autoUpdater off the default export instead.
const { autoUpdater } = electronUpdater;
import {
CHANNELS,
type UpdateCheckResult,
type UpdateInfo,
type UpdateProgress,
} from '../ipc-types';
let cached: BuilderUpdateInfo | null = null;
function toPublicInfo(info: BuilderUpdateInfo | null): UpdateInfo | null {
if (!info) return null;
const notes = info.releaseNotes;
let releaseNotes: string | null = null;
if (typeof notes === 'string') releaseNotes = notes;
else if (Array.isArray(notes)) releaseNotes = notes.map((r) => r.note).join('\n\n');
return {
version: info.version ?? '',
releaseNotes,
};
}
export function register(mainWindow: BrowserWindow): void {
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = false;
autoUpdater.on('download-progress', (p: ProgressInfo) => {
if (mainWindow.isDestroyed()) return;
const payload: UpdateProgress = {
percent: p.percent ?? 0,
transferred: p.transferred ?? 0,
total: p.total ?? 0,
};
mainWindow.webContents.send(CHANNELS.UPDATER_EVT_PROGRESS, payload);
});
ipcMain.handle(CHANNELS.UPDATER_CHECK, async (): Promise<UpdateCheckResult> => {
if (!app.isPackaged) {
return { available: false, info: null };
}
try {
const result = await autoUpdater.checkForUpdates();
if (!result || !result.updateInfo) {
cached = null;
return { available: false, info: null };
}
const current = app.getVersion();
const remote = result.updateInfo.version;
if (!remote || remote === current) {
cached = null;
return { available: false, info: null };
}
cached = result.updateInfo;
return { available: true, info: toPublicInfo(cached) };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
if (!/ENOTFOUND|ETIMEDOUT|ECONNRESET|404/i.test(msg)) {
console.warn('[updater] check failed', err);
}
return { available: false, info: null };
}
});
ipcMain.handle(CHANNELS.UPDATER_DOWNLOAD_INSTALL, async (): Promise<void> => {
if (!app.isPackaged) return;
if (!cached) throw new Error('no pending update — call check first');
await autoUpdater.downloadUpdate();
autoUpdater.quitAndInstall();
});
}
@@ -0,0 +1,64 @@
// Window fullscreen adapter — replaces Tauri's
// `getCurrentWindow().setFullscreen(...)` from `@tauri-apps/api/window`.
// The renderer asks main to flip the OS-level fullscreen flag on the host
// BrowserWindow so cinema mode covers the Windows taskbar / macOS menubar
// the way Tauri's appWindow.setFullscreen used to.
import { BrowserWindow, ipcMain } from 'electron';
import { CHANNELS } from '../ipc-types';
export function register(mainWindow: BrowserWindow): void {
// Per-window maximize-before-fullscreen memo. We have to drop the
// maximized flag on Windows before setFullScreen so DWM recomposes
// cleanly (taskbar quirk), but Electron doesn't remember that the
// window WAS maximized — exiting fullscreen would leave it as a small
// floating window. Track it ourselves keyed by window-id so a future
// multi-window setup doesn't cross-pollute state.
const wasMaximized = new Map<number, boolean>();
ipcMain.handle(CHANNELS.WINDOW_SET_FULLSCREEN, (evt, enabled: boolean) => {
try {
// Prefer the BrowserWindow that issued the IPC so multi-window setups
// affect the right host; fall back to the main window we were
// registered against (matches autostart.ts's app-singleton shape).
const win = BrowserWindow.fromWebContents(evt.sender) ?? mainWindow;
if (!win || win.isDestroyed()) return;
const id = win.id;
if (enabled) {
// Windows DWM quirk: maximized → fullscreen sometimes leaves the
// taskbar drawn on top of the window because DWM keeps the
// maximized work-area constraints. Drop the maximize flag first
// so setFullScreen covers the whole monitor cleanly. Remember the
// pre-fullscreen state so the exit path can restore it.
if (process.platform === 'win32') {
const was = win.isMaximized();
wasMaximized.set(id, was);
if (was) win.unmaximize();
}
win.setFullScreen(true);
} else {
win.setFullScreen(false);
// Restore maximize if we dropped it on entry. setFullScreen(false)
// emits 'leave-full-screen' asynchronously; maximize() needs to
// wait until the window is back in normal mode or it silently
// no-ops. The event fires same-tick in Electron 33, but we listen
// for it once just to be safe across versions.
if (process.platform === 'win32' && wasMaximized.get(id)) {
wasMaximized.delete(id);
const restore = (): void => {
if (!win.isDestroyed()) win.maximize();
};
if (win.isFullScreen()) {
win.once('leave-full-screen', restore);
} else {
restore();
}
}
}
} catch (err: unknown) {
console.warn('window setFullscreen failed', err);
throw err;
}
});
}
+106
View File
@@ -0,0 +1,106 @@
// Renderer-visible typing for the preload bridge. Referenced via tsconfig
// `include` so TS sees `window.electronAPI` without needing to import
// from `electron` or drag Node types into the renderer build. The
// ElectronAPI type mirrors the `api` object exported from preload.ts —
// keep them in sync when adding new methods.
import type {
AudioLoopbackChunk,
AudioLoopbackStartResult,
FsPath,
FsRenameArgs,
FsWriteArgs,
NotifyArgs,
ScreenSource,
SecureStoreHandle,
SecureStoreOpenArgs,
ShortcutEvent,
ShortcutRegisterArgs,
SqlExecuteResult,
SqlSelectResult,
UpdateCheckResult,
UpdateProgress,
} from './ipc-types';
type Unsubscribe = () => void;
export interface ElectronAPI {
/** Opaque runtime marker (value: 'electron-chatapp-v1'). */
platform: string;
osPlatform: NodeJS.Platform;
appVersion: string;
getScreenSources: () => Promise<ScreenSource[]>;
getScreenThumbnail: (sourceId: string) => Promise<string | null>;
setPendingShareSource: (sourceId: string | null) => Promise<void>;
resolveLoopbackSource: () => Promise<{ sourceId: string } | null>;
/** Native WASAPI process-loopback bridge — Windows only. `start`
* rejects on macOS/Linux or when the .node binary isn't shipped, so
* callers should catch and fall back to the getUserMedia path. */
audioLoopback: {
start: () => Promise<AudioLoopbackStartResult>;
/** 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>;
stop: (captureId: number) => Promise<void>;
onChunk: (cb: (payload: AudioLoopbackChunk) => void) => Unsubscribe;
};
registerShortcut: (args: ShortcutRegisterArgs) => Promise<boolean>;
unregisterShortcut: (id: string) => Promise<void>;
isShortcutRegistered: (id: string) => Promise<boolean>;
onShortcutFired: (cb: (evt: ShortcutEvent) => void) => Unsubscribe;
onShortcutReleased: (cb: (evt: ShortcutEvent) => void) => Unsubscribe;
notify: (args: NotifyArgs) => Promise<void>;
getNotificationPermission: () => Promise<'granted' | 'denied' | 'default'>;
setTrayUnread: (count: number, badgeDataUrl?: string | null) => Promise<void>;
secureStoreOpen: (args: SecureStoreOpenArgs) => Promise<SecureStoreHandle>;
secureStoreGet: (handle: string, key: string) => Promise<string | null>;
secureStoreSet: (handle: string, key: string, value: string) => Promise<void>;
secureStoreRemove: (handle: string, key: string) => Promise<void>;
secureStoreClose: (handle: string) => Promise<void>;
fsRead: (p: FsPath) => Promise<string | null>;
fsWrite: (args: FsWriteArgs) => Promise<void>;
fsExists: (p: FsPath) => Promise<boolean>;
fsMkdir: (p: FsPath) => Promise<void>;
fsRename: (args: FsRenameArgs) => Promise<void>;
fsRemove: (p: FsPath) => Promise<void>;
fsAppLocalDataDir: () => Promise<string>;
sqlLoad: (args: { name: string }) => Promise<string>;
sqlExecute: (args: {
handle: string;
query: string;
bindings?: unknown[];
}) => Promise<SqlExecuteResult>;
sqlSelect: (args: {
handle: string;
query: string;
bindings?: unknown[];
}) => Promise<SqlSelectResult>;
sqlClose: (handle: string) => Promise<void>;
checkForUpdate: () => Promise<UpdateCheckResult>;
downloadInstallUpdate: () => Promise<void>;
onUpdaterProgress: (cb: (p: UpdateProgress) => void) => Unsubscribe;
isAutoStartEnabled: () => Promise<boolean>;
setAutoStart: (enabled: boolean) => Promise<void>;
setFullscreen: (enabled: boolean) => Promise<void>;
}
declare global {
interface Window {
electronAPI: ElectronAPI;
}
}
export {};
+159
View File
@@ -0,0 +1,159 @@
// 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);
+91
View File
@@ -0,0 +1,91 @@
// Persists BrowserWindow bounds + maximized state to userData/<filename>.
// Saves are debounced 500ms on move/resize, and fired synchronously on close.
// Validates loaded bounds against the current display layout to avoid
// restoring a window onto a display that no longer exists.
import { app, BrowserWindow, screen } from 'electron';
import { promises as fs } from 'node:fs';
import path from 'node:path';
interface State {
x?: number;
y?: number;
width: number;
height: number;
maximized?: boolean;
}
const DEFAULT: State = { width: 1200, height: 800 };
function isOnAnyDisplay(bounds: { x: number; y: number; width: number; height: number }): boolean {
for (const d of screen.getAllDisplays()) {
const wa = d.workArea;
if (
bounds.x >= wa.x &&
bounds.y >= wa.y &&
bounds.x + bounds.width <= wa.x + wa.width + 8 &&
bounds.y + bounds.height <= wa.y + wa.height + 8
) {
return true;
}
}
return false;
}
export async function loadState(filename: string): Promise<State> {
const filePath = path.join(app.getPath('userData'), filename);
try {
const raw = await fs.readFile(filePath, 'utf8');
const parsed = JSON.parse(raw) as Partial<State>;
const width = typeof parsed.width === 'number' ? parsed.width : DEFAULT.width;
const height = typeof parsed.height === 'number' ? parsed.height : DEFAULT.height;
const state: State = { width, height };
if (typeof parsed.x === 'number' && typeof parsed.y === 'number') {
if (isOnAnyDisplay({ x: parsed.x, y: parsed.y, width, height })) {
state.x = parsed.x;
state.y = parsed.y;
}
}
if (parsed.maximized) state.maximized = true;
return state;
} catch {
return { ...DEFAULT };
}
}
export function attach(win: BrowserWindow, filename: string): void {
const filePath = path.join(app.getPath('userData'), filename);
let saveTimer: NodeJS.Timeout | null = null;
const writeNow = (): void => {
if (win.isDestroyed()) return;
const bounds = win.getNormalBounds();
const state: State = {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
maximized: win.isMaximized(),
};
// Fire-and-forget; exceptions are logged but non-fatal.
void fs
.writeFile(filePath, JSON.stringify(state), 'utf8')
.catch((err: unknown) => {
console.warn('[window-state] write failed', err);
});
};
const schedule = (): void => {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(writeNow, 500);
};
win.on('move', schedule);
win.on('resize', schedule);
win.on('maximize', schedule);
win.on('unmaximize', schedule);
win.on('close', () => {
if (saveTimer) clearTimeout(saveTimer);
writeNow();
});
}
+380
View File
@@ -0,0 +1,380 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "audio-loopback"
version = "0.1.0"
dependencies = [
"napi",
"napi-build",
"napi-derive",
"wasapi",
]
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "bitflags"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "convert_case"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "ctor"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501"
dependencies = [
"quote",
"syn",
]
[[package]]
name = "libloading"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
dependencies = [
"cfg-if",
"windows-link",
]
[[package]]
name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "napi"
version = "2.16.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3"
dependencies = [
"bitflags",
"ctor",
"napi-derive",
"napi-sys",
"once_cell",
]
[[package]]
name = "napi-build"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d376940fd5b723c6893cd1ee3f33abbfd86acb1cd1ec079f3ab04a2a3bc4d3b1"
[[package]]
name = "napi-derive"
version = "2.16.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cbe2585d8ac223f7d34f13701434b9d5f4eb9c332cccce8dee57ea18ab8ab0c"
dependencies = [
"cfg-if",
"convert_case",
"napi-derive-backend",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "napi-derive-backend"
version = "1.0.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1639aaa9eeb76e91c6ae66da8ce3e89e921cd3885e99ec85f4abacae72fc91bf"
dependencies = [
"convert_case",
"once_cell",
"proc-macro2",
"quote",
"regex",
"semver",
"syn",
]
[[package]]
name = "napi-sys"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3"
dependencies = [
"libloading",
]
[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "regex"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-segmentation"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
[[package]]
name = "wasapi"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f6b03b82e419f186fcdc06ac6068621bdadc88b89b2612067f1c021ad2c9449"
dependencies = [
"log",
"num-integer",
"widestring",
"windows",
"windows-core",
]
[[package]]
name = "widestring"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
[[package]]
name = "windows"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143"
dependencies = [
"windows-core",
"windows-targets",
]
[[package]]
name = "windows-core"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d"
dependencies = [
"windows-implement",
"windows-interface",
"windows-result",
"windows-targets",
]
[[package]]
name = "windows-implement"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
@@ -0,0 +1,31 @@
[package]
name = "audio-loopback"
version = "0.1.0"
edition = "2021"
license = "MIT OR Apache-2.0"
publish = false
description = "Native Windows process-loopback audio capture for ChatApp Electron"
[lib]
crate-type = ["cdylib"]
# Reduce binary size on release builds and avoid the default-features
# panic-handler that bloats node addons. We don't strip — keeping symbols
# helps debug crashes from a packaged build.
[profile.release]
lto = true
codegen-units = 1
panic = "abort"
[dependencies]
napi = { version = "2", default-features = false, features = ["napi6"] }
napi-derive = "2"
[target.'cfg(target_os = "windows")'.dependencies]
# Match the Tauri reference exactly. Cargo.lock there pins 0.15.0; the
# 0.15 family exposes AudioClient::new_application_loopback_client(pid,
# include_tree) which is the load-bearing API for EXCLUDE_TARGET_PROCESS_TREE.
wasapi = "0.15"
[build-dependencies]
napi-build = "2"
@@ -0,0 +1,10 @@
// napi-rs build helper. Generates the platform-specific binding glue
// (e.g. typedefs, init symbols) that `napi build` consumes. Required for
// every napi-rs crate; the build will silently produce a half-wired
// binding without it.
extern crate napi_build;
fn main() {
napi_build::setup();
}
+35
View File
@@ -0,0 +1,35 @@
/* tslint:disable */
/* eslint-disable */
/* auto-generated by NAPI-RS */
/**
* Start a process-loopback capture that excludes the current process
* tree. The supplied JS callback is invoked from a background thread
* with one argument: a Float32Array of interleaved f32 stereo samples
* at 48kHz. Returns a numeric capture id that must be passed to
* `stopCapture` when the share ends.
*
* Always excludes `std::process::id()` (whole-OS-mixer-minus-self).
* For "include only this app" use `start_capture_for_pid` instead.
*/
export declare function startCapture(callback: (...args: any[]) => any): number
/**
* Start a process-loopback capture that INCLUDES the target PID's
* process tree (and only that tree) — the WASAPI
* INCLUDE_TARGET_PROCESS_TREE mode. Used for window-shares where we
* want only the picked app's audio (Discord parity).
*/
export declare function startCaptureForPid(pid: number, callback: (...args: any[]) => any): number
/**
* Resolve the owning process id of a top-level window handle. The
* renderer derives `hwnd` from desktopCapturer's `window:<HWND>:0`
* source ids and we hand that to `start_capture_for_pid`.
*/
export declare function resolveWindowPid(hwnd: number): number
/**
* Tear down the capture for the given id. Safe to call on a missing id
* (no-op) so the JS side doesn't have to track whether the stop has
* already been issued by the screen-share teardown path.
*/
export declare function stopCapture(captureId: number): void
+318
View File
@@ -0,0 +1,318 @@
/* tslint:disable */
/* eslint-disable */
/* prettier-ignore */
/* auto-generated by NAPI-RS */
const { existsSync, readFileSync } = require('fs')
const { join } = require('path')
const { platform, arch } = process
let nativeBinding = null
let localFileExisted = false
let loadError = null
function isMusl() {
// For Node 10
if (!process.report || typeof process.report.getReport !== 'function') {
try {
const lddPath = require('child_process').execSync('which ldd').toString().trim()
return readFileSync(lddPath, 'utf8').includes('musl')
} catch (e) {
return true
}
} else {
const { glibcVersionRuntime } = process.report.getReport().header
return !glibcVersionRuntime
}
}
switch (platform) {
case 'android':
switch (arch) {
case 'arm64':
localFileExisted = existsSync(join(__dirname, 'audio-loopback.android-arm64.node'))
try {
if (localFileExisted) {
nativeBinding = require('./audio-loopback.android-arm64.node')
} else {
nativeBinding = require('@chatapp/audio-loopback-native-android-arm64')
}
} catch (e) {
loadError = e
}
break
case 'arm':
localFileExisted = existsSync(join(__dirname, 'audio-loopback.android-arm-eabi.node'))
try {
if (localFileExisted) {
nativeBinding = require('./audio-loopback.android-arm-eabi.node')
} else {
nativeBinding = require('@chatapp/audio-loopback-native-android-arm-eabi')
}
} catch (e) {
loadError = e
}
break
default:
throw new Error(`Unsupported architecture on Android ${arch}`)
}
break
case 'win32':
switch (arch) {
case 'x64':
localFileExisted = existsSync(
join(__dirname, 'audio-loopback.win32-x64-msvc.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./audio-loopback.win32-x64-msvc.node')
} else {
nativeBinding = require('@chatapp/audio-loopback-native-win32-x64-msvc')
}
} catch (e) {
loadError = e
}
break
case 'ia32':
localFileExisted = existsSync(
join(__dirname, 'audio-loopback.win32-ia32-msvc.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./audio-loopback.win32-ia32-msvc.node')
} else {
nativeBinding = require('@chatapp/audio-loopback-native-win32-ia32-msvc')
}
} catch (e) {
loadError = e
}
break
case 'arm64':
localFileExisted = existsSync(
join(__dirname, 'audio-loopback.win32-arm64-msvc.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./audio-loopback.win32-arm64-msvc.node')
} else {
nativeBinding = require('@chatapp/audio-loopback-native-win32-arm64-msvc')
}
} catch (e) {
loadError = e
}
break
default:
throw new Error(`Unsupported architecture on Windows: ${arch}`)
}
break
case 'darwin':
localFileExisted = existsSync(join(__dirname, 'audio-loopback.darwin-universal.node'))
try {
if (localFileExisted) {
nativeBinding = require('./audio-loopback.darwin-universal.node')
} else {
nativeBinding = require('@chatapp/audio-loopback-native-darwin-universal')
}
break
} catch {}
switch (arch) {
case 'x64':
localFileExisted = existsSync(join(__dirname, 'audio-loopback.darwin-x64.node'))
try {
if (localFileExisted) {
nativeBinding = require('./audio-loopback.darwin-x64.node')
} else {
nativeBinding = require('@chatapp/audio-loopback-native-darwin-x64')
}
} catch (e) {
loadError = e
}
break
case 'arm64':
localFileExisted = existsSync(
join(__dirname, 'audio-loopback.darwin-arm64.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./audio-loopback.darwin-arm64.node')
} else {
nativeBinding = require('@chatapp/audio-loopback-native-darwin-arm64')
}
} catch (e) {
loadError = e
}
break
default:
throw new Error(`Unsupported architecture on macOS: ${arch}`)
}
break
case 'freebsd':
if (arch !== 'x64') {
throw new Error(`Unsupported architecture on FreeBSD: ${arch}`)
}
localFileExisted = existsSync(join(__dirname, 'audio-loopback.freebsd-x64.node'))
try {
if (localFileExisted) {
nativeBinding = require('./audio-loopback.freebsd-x64.node')
} else {
nativeBinding = require('@chatapp/audio-loopback-native-freebsd-x64')
}
} catch (e) {
loadError = e
}
break
case 'linux':
switch (arch) {
case 'x64':
if (isMusl()) {
localFileExisted = existsSync(
join(__dirname, 'audio-loopback.linux-x64-musl.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./audio-loopback.linux-x64-musl.node')
} else {
nativeBinding = require('@chatapp/audio-loopback-native-linux-x64-musl')
}
} catch (e) {
loadError = e
}
} else {
localFileExisted = existsSync(
join(__dirname, 'audio-loopback.linux-x64-gnu.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./audio-loopback.linux-x64-gnu.node')
} else {
nativeBinding = require('@chatapp/audio-loopback-native-linux-x64-gnu')
}
} catch (e) {
loadError = e
}
}
break
case 'arm64':
if (isMusl()) {
localFileExisted = existsSync(
join(__dirname, 'audio-loopback.linux-arm64-musl.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./audio-loopback.linux-arm64-musl.node')
} else {
nativeBinding = require('@chatapp/audio-loopback-native-linux-arm64-musl')
}
} catch (e) {
loadError = e
}
} else {
localFileExisted = existsSync(
join(__dirname, 'audio-loopback.linux-arm64-gnu.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./audio-loopback.linux-arm64-gnu.node')
} else {
nativeBinding = require('@chatapp/audio-loopback-native-linux-arm64-gnu')
}
} catch (e) {
loadError = e
}
}
break
case 'arm':
if (isMusl()) {
localFileExisted = existsSync(
join(__dirname, 'audio-loopback.linux-arm-musleabihf.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./audio-loopback.linux-arm-musleabihf.node')
} else {
nativeBinding = require('@chatapp/audio-loopback-native-linux-arm-musleabihf')
}
} catch (e) {
loadError = e
}
} else {
localFileExisted = existsSync(
join(__dirname, 'audio-loopback.linux-arm-gnueabihf.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./audio-loopback.linux-arm-gnueabihf.node')
} else {
nativeBinding = require('@chatapp/audio-loopback-native-linux-arm-gnueabihf')
}
} catch (e) {
loadError = e
}
}
break
case 'riscv64':
if (isMusl()) {
localFileExisted = existsSync(
join(__dirname, 'audio-loopback.linux-riscv64-musl.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./audio-loopback.linux-riscv64-musl.node')
} else {
nativeBinding = require('@chatapp/audio-loopback-native-linux-riscv64-musl')
}
} catch (e) {
loadError = e
}
} else {
localFileExisted = existsSync(
join(__dirname, 'audio-loopback.linux-riscv64-gnu.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./audio-loopback.linux-riscv64-gnu.node')
} else {
nativeBinding = require('@chatapp/audio-loopback-native-linux-riscv64-gnu')
}
} catch (e) {
loadError = e
}
}
break
case 's390x':
localFileExisted = existsSync(
join(__dirname, 'audio-loopback.linux-s390x-gnu.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./audio-loopback.linux-s390x-gnu.node')
} else {
nativeBinding = require('@chatapp/audio-loopback-native-linux-s390x-gnu')
}
} catch (e) {
loadError = e
}
break
default:
throw new Error(`Unsupported architecture on Linux: ${arch}`)
}
break
default:
throw new Error(`Unsupported OS: ${platform}, architecture: ${arch}`)
}
if (!nativeBinding) {
if (loadError) {
throw loadError
}
throw new Error(`Failed to load native binding`)
}
const { startCapture, startCaptureForPid, resolveWindowPid, stopCapture } = nativeBinding
module.exports.startCapture = startCapture
module.exports.startCaptureForPid = startCaptureForPid
module.exports.resolveWindowPid = resolveWindowPid
module.exports.stopCapture = stopCapture
@@ -0,0 +1,32 @@
{
"name": "@chatapp/audio-loopback-native",
"version": "0.1.0",
"private": true,
"description": "Native Windows process-loopback audio capture addon (excludes our own PID tree)",
"main": "index.js",
"types": "index.d.ts",
"files": [
"index.js",
"index.d.ts",
"*.node"
],
"napi": {
"name": "audio-loopback",
"triples": {
"defaults": false,
"additional": [
"x86_64-pc-windows-msvc"
]
}
},
"scripts": {
"build": "napi build --platform --release",
"build:debug": "napi build --platform"
},
"devDependencies": {
"@napi-rs/cli": "^2.18.0"
},
"engines": {
"node": ">= 18"
}
}
@@ -0,0 +1,339 @@
// Native system-audio capture addon for the ChatApp Electron desktop
// client. Mirrors the proven Tauri implementation
// (apps/desktop/src-tauri/src/screen_audio.rs in the legacy repo).
//
// Why a native addon at all when Electron already exposes a 'loopback'
// audio source through setDisplayMediaRequestHandler? Because Chromium's
// loopback captures the entire OS mixer including our own renderer's
// playback — peers in a video call hear themselves echoed back when the
// sharer ticks "system audio". Windows ships an EXCLUDE_TARGET_PROCESS_TREE
// process-loopback mode that captures every render session except the
// targeted PID's tree. We pass our own PID so the LiveKit playback never
// re-enters the outgoing share.
//
// Wire format is fixed at 48kHz interleaved f32 stereo. The renderer-
// side AudioWorklet (lib/loopbackAudio.ts) assumes that layout and feeds
// samples into a MediaStreamDestination so LiveKit publishes a plain
// ScreenShareAudio track.
//
// Windows-only for v1. macOS/Linux stubs return a clear napi::Error so
// the renderer can fall through to the existing getUserMedia path.
#![allow(clippy::needless_return)]
use napi::bindgen_prelude::{Float32Array, Result};
use napi::threadsafe_function::{
ErrorStrategy, ThreadsafeFunction, ThreadsafeFunctionCallMode,
};
use napi::JsFunction;
use napi_derive::napi;
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::thread;
// Output format we always deliver to the frontend. Pinning a single fixed
// format means the AudioWorklet never has to renegotiate — it just
// assumes interleaved f32 stereo at 48kHz. WASAPI mix format is usually
// already this on Windows 10+, so the resample/upmix branch inside
// process-loopback's autoconvert is rarely hit.
const OUTPUT_SAMPLE_RATE: u32 = 48_000;
const OUTPUT_CHANNELS: u16 = 2;
static NEXT_ID: AtomicU32 = AtomicU32::new(1);
static SESSIONS: OnceLock<Mutex<HashMap<u32, Session>>> = OnceLock::new();
fn sessions() -> &'static Mutex<HashMap<u32, Session>> {
SESSIONS.get_or_init(|| Mutex::new(HashMap::new()))
}
struct Session {
stop: Arc<AtomicBool>,
handle: Option<thread::JoinHandle<()>>,
}
/// Start a process-loopback capture that excludes the current process
/// tree. The supplied JS callback is invoked from a background thread
/// with one argument: a Float32Array of interleaved f32 stereo samples
/// at 48kHz. Returns a numeric capture id that must be passed to
/// `stopCapture` when the share ends.
///
/// Always excludes `std::process::id()` (whole-OS-mixer-minus-self).
/// For "include only this app" use `start_capture_for_pid` instead.
#[napi]
pub fn start_capture(callback: JsFunction) -> Result<u32> {
spawn_capture_session(callback, std::process::id(), false)
}
/// Start a process-loopback capture that INCLUDES the target PID's
/// process tree (and only that tree) — the WASAPI
/// INCLUDE_TARGET_PROCESS_TREE mode. Used for window-shares where we
/// want only the picked app's audio (Discord parity).
#[napi]
pub fn start_capture_for_pid(pid: u32, callback: JsFunction) -> Result<u32> {
spawn_capture_session(callback, pid, true)
}
/// Resolve the owning process id of a top-level window handle. The
/// renderer derives `hwnd` from desktopCapturer's `window:<HWND>:0`
/// source ids and we hand that to `start_capture_for_pid`.
#[napi]
pub fn resolve_window_pid(hwnd: u32) -> Result<u32> {
#[cfg(target_os = "windows")]
{
// Minimal FFI to user32!GetWindowThreadProcessId — pulling in a
// full windows crate just for one call would balloon build
// times. The function returns the thread id (we ignore it) and
// writes the process id through the pointer.
#[allow(non_snake_case)]
extern "system" {
fn GetWindowThreadProcessId(hWnd: usize, lpdwProcessId: *mut u32) -> u32;
}
let mut pid: u32 = 0;
// SAFETY: GetWindowThreadProcessId tolerates an invalid HWND
// (returns 0 thread id and leaves *lpdwProcessId untouched). We
// detect the failure case by checking for pid == 0 below.
let thread_id = unsafe { GetWindowThreadProcessId(hwnd as usize, &mut pid) };
if thread_id == 0 || pid == 0 {
return Err(napi::Error::from_reason(format!(
"GetWindowThreadProcessId({hwnd}) failed — window may have closed"
)));
}
Ok(pid)
}
#[cfg(not(target_os = "windows"))]
{
let _ = hwnd;
Err(napi::Error::from_reason(
"resolve_window_pid only supported on Windows",
))
}
}
#[cfg(target_os = "windows")]
fn spawn_capture_session(
callback: JsFunction,
pid: u32,
include_tree: bool,
) -> Result<u32> {
// ErrorStrategy::Fatal — the JS callback signature is `(samples)`
// not `(err, samples)`, so we don't want napi to inject an
// error slot. If anything goes wrong on the Rust side we tear
// the session down and stop calling the callback.
let tsfn: ThreadsafeFunction<Float32Array, ErrorStrategy::Fatal> =
callback.create_threadsafe_function(0, |ctx| Ok(vec![ctx.value]))?;
let capture_id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
let stop = Arc::new(AtomicBool::new(false));
let stop_clone = Arc::clone(&stop);
let handle = thread::Builder::new()
.name(format!("audio-loopback-{capture_id}"))
.spawn(move || {
if let Err(err) = windows_loopback::capture_loop(
capture_id,
tsfn,
stop_clone,
pid,
include_tree,
) {
eprintln!("audio-loopback {capture_id}: {err}");
}
})
.map_err(|e| {
napi::Error::from_reason(format!("failed to spawn audio thread: {e}"))
})?;
sessions().lock().unwrap().insert(
capture_id,
Session {
stop,
handle: Some(handle),
},
);
Ok(capture_id)
}
#[cfg(not(target_os = "windows"))]
fn spawn_capture_session(
callback: JsFunction,
_pid: u32,
_include_tree: bool,
) -> Result<u32> {
let _ = callback;
Err(napi::Error::from_reason(
"system audio capture only supported on Windows",
))
}
/// Tear down the capture for the given id. Safe to call on a missing id
/// (no-op) so the JS side doesn't have to track whether the stop has
/// already been issued by the screen-share teardown path.
#[napi]
pub fn stop_capture(capture_id: u32) -> Result<()> {
let session = sessions().lock().unwrap().remove(&capture_id);
let Some(mut session) = session else {
return Ok(());
};
session.stop.store(true, Ordering::Relaxed);
if let Some(handle) = session.handle.take() {
// Best-effort join — the capture loop polls `stop` every event
// cycle (≤100ms) so this usually returns promptly. If a WASAPI
// call is wedged we'd rather drop the handle than hang the JS
// teardown path.
let _ = handle.join();
}
Ok(())
}
// ---------------------------------------------------------------------------
// Windows loopback implementation
// ---------------------------------------------------------------------------
#[cfg(target_os = "windows")]
mod windows_loopback {
use super::*;
use wasapi::{initialize_mta, AudioClient, Direction, SampleType, ShareMode, WaveFormat};
// 200ms request buffer in 100ns units. Process-loopback clients
// ignore the period for shared-mode but the API still requires a
// non-zero value — picking 200ms keeps wakeups infrequent enough
// that we don't spin the capture thread on idle audio.
const REQUESTED_BUFFER_HNS: i64 = 2_000_000;
pub fn capture_loop(
capture_id: u32,
tsfn: ThreadsafeFunction<Float32Array, ErrorStrategy::Fatal>,
stop: Arc<AtomicBool>,
pid: u32,
include_tree: bool,
) -> std::result::Result<(), String> {
// COM must be initialised on every thread that touches WASAPI.
// MTA is the right model for a background capture thread —
// STA would require message pumping we don't want to add.
initialize_mta()
.ok()
.map_err(|e| format!("initialize_mta: {e:?}"))?;
// Process-loopback. Two modes — selected by `include_tree`:
// false → EXCLUDE_TARGET_PROCESS_TREE: every render session on
// the box *except* the given PID's tree. Default for
// full-screen shares so LiveKit playback stays out of
// the outgoing audio (we pass our own PID).
// true → INCLUDE_TARGET_PROCESS_TREE: only the given PID's
// tree. Used for window-shares so the captured audio
// is exactly the picked app (Discord parity).
// The `include_tree` flag is the load-bearing arg to
// `new_application_loopback_client`; do not flip without
// re-reading the wasapi crate's docs.
let mut audio_client =
AudioClient::new_application_loopback_client(pid, include_tree)
.map_err(|e| format!("new_application_loopback_client: {e:?}"))?;
// Process-loopback only accepts caller-specified formats —
// `get_mixformat` is documented as broken on this client. We
// pin the wire format we already deliver downstream: 48kHz,
// 32-bit float, stereo. `autoconvert=true` (the trailing `true`
// arg to initialize_client) lets WASAPI mix arbitrary session
// formats into ours so games at 44.1k or mono notification
// sounds don't blow up the capture.
let wave_format = WaveFormat::new(
32,
32,
&SampleType::Float,
OUTPUT_SAMPLE_RATE as usize,
OUTPUT_CHANNELS as usize,
None,
);
audio_client
.initialize_client(
&wave_format,
REQUESTED_BUFFER_HNS,
&Direction::Capture,
&ShareMode::Shared,
true,
)
.map_err(|e| format!("initialize_client (process-loopback): {e:?}"))?;
let h_event = audio_client
.set_get_eventhandle()
.map_err(|e| format!("set_get_eventhandle: {e:?}"))?;
let capture_client = audio_client
.get_audiocaptureclient()
.map_err(|e| format!("get_audiocaptureclient: {e:?}"))?;
audio_client
.start_stream()
.map_err(|e| format!("start_stream: {e:?}"))?;
let block_align = wave_format.get_blockalign() as usize;
while !stop.load(Ordering::Relaxed) {
// 100ms timeout lets the loop check the stop flag even when
// every excluded session is silent — there's nothing to
// render so the event handle never fires.
if h_event.wait_for_event(100).is_err() {
continue;
}
// Drain all packets available since the last wake — there
// can be several queued if we were preempted.
loop {
if stop.load(Ordering::Relaxed) {
break;
}
let frames_available = match capture_client.get_next_nbr_frames() {
Ok(Some(n)) if n > 0 => n,
Ok(_) => break,
Err(e) => {
eprintln!(
"audio-loopback {capture_id}: get_next_nbr_frames: {e:?}"
);
break;
}
};
let bytes_needed = frames_available as usize * block_align;
let mut raw = vec![0u8; bytes_needed];
if let Err(e) = capture_client.read_from_device(&mut raw) {
eprintln!(
"audio-loopback {capture_id}: read_from_device: {e:?}"
);
break;
}
// Reinterpret bytes as f32 little-endian samples. The
// buffer is already 48kHz f32 stereo because process-
// loopback autoconverted to our requested format. We
// copy out into a Vec<f32> so napi can hand ownership
// of a JS-owned ArrayBuffer to the renderer.
let mut samples = Vec::<f32>::with_capacity(raw.len() / 4);
let mut idx = 0;
while idx + 4 <= raw.len() {
let bytes = [raw[idx], raw[idx + 1], raw[idx + 2], raw[idx + 3]];
samples.push(f32::from_le_bytes(bytes));
idx += 4;
}
let _ = (OUTPUT_SAMPLE_RATE, OUTPUT_CHANNELS);
let payload = Float32Array::new(samples);
// NonBlocking: never block the WASAPI capture thread on
// a slow JS event loop — at 48kHz stereo a stalled
// renderer would otherwise back-pressure the WASAPI
// event handle and underrun every other consumer.
let status = tsfn.call(payload, ThreadsafeFunctionCallMode::NonBlocking);
if status != napi::Status::Ok {
// Renderer went away or the threadsafe function was
// released — stop cleanly.
stop.store(true, Ordering::Relaxed);
break;
}
}
}
let _ = audio_client.stop_stream();
Ok(())
}
}
@@ -0,0 +1 @@
{"rustc_fingerprint":2812790340253412174,"outputs":{"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___.exe\nlib___.rlib\n___.dll\n___.dll\n___.lib\n___.dll\nC:\\Users\\denni\\.rustup\\toolchains\\stable-x86_64-pc-windows-msvc\npacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"msvc\"\ntarget_family=\"windows\"\ntarget_feature=\"cmpxchg16b\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_feature=\"sse3\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"windows\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"pc\"\nwindows\n","stderr":""},"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.95.0 (59807616e 2026-04-14)\nbinary: rustc\ncommit-hash: 59807616e1fa2540724bfbac14d7976d7e4a3860\ncommit-date: 2026-04-14\nhost: x86_64-pc-windows-msvc\nrelease: 1.95.0\nLLVM version: 22.1.2\n","stderr":""}},"successes":{}}
@@ -0,0 +1,3 @@
Signature: 8a477f597d28d172789f06886806bc55
# This file is a cache directory tag created by cargo.
# For information about cache directory tags see https://bford.info/cachedir/
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
{"rustc":8891013984288978370,"features":"[\"perf-literal\", \"std\"]","declared_features":"[\"default\", \"logging\", \"perf-literal\", \"std\"]","target":7534583537114156500,"profile":17257705230225558938,"path":12213129009505393428,"deps":[[1363051979936526615,"memchr",false,10675094612715872077]],"local":[{"CheckDepInfo":{"dep_info":"release\\.fingerprint\\aho-corasick-9f0713aa5a0615e8\\dep-lib-aho_corasick","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
{"rustc":8891013984288978370,"features":"[]","declared_features":"[]","target":5408242616063297496,"profile":17257705230225558938,"path":13767053534773805487,"deps":[[1035178698636953719,"napi_build",false,13969733624275323243]],"local":[{"CheckDepInfo":{"dep_info":"release\\.fingerprint\\audio-loopback-12f5458902d8391e\\dep-build-script-build-script-build","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
{"rustc":8891013984288978370,"features":"","declared_features":"","target":0,"profile":0,"path":0,"deps":[[2572457258515766174,"build_script_build",false,4865569503500372180]],"local":[{"RerunIfEnvChanged":{"var":"DEBUG_GENERATED_CODE","val":null}},{"RerunIfEnvChanged":{"var":"TYPE_DEF_TMP_PATH","val":"C:\\Users\\denni\\AppData\\Local\\Temp\\audio_loopback-a3c3b6f6.napi_type_def.tmp"}},{"RerunIfEnvChanged":{"var":"CARGO_CFG_NAPI_RS_CLI_VERSION","val":"2.18.4"}},{"RerunIfEnvChanged":{"var":"NAPI_DEBUG_GENERATED_CODE","val":null}},{"RerunIfEnvChanged":{"var":"NAPI_TYPE_DEF_TMP_FOLDER","val":null}},{"RerunIfEnvChanged":{"var":"NAPI_FORCE_BUILD_AUDIO_LOOPBACK","val":null}}],"rustflags":[],"config":0,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
{"rustc":8891013984288978370,"features":"[]","declared_features":"[]","target":11760611160289367125,"profile":5353520049763563864,"path":10763286916239946207,"deps":[[2572457258515766174,"build_script_build",false,10195717413952832446],[13045677537521422049,"wasapi",false,17410940550348078650],[13423243174795060362,"napi_derive",false,2979073294737158417],[16099943211762415786,"napi",false,7346211637381484829]],"local":[{"CheckDepInfo":{"dep_info":"release\\.fingerprint\\audio-loopback-6c6fd3fda356bdba\\dep-lib-audio_loopback","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
{"rustc":8891013984288978370,"features":"[]","declared_features":"[]","target":6962977057026645649,"profile":17257705230225558938,"path":18420280761196579094,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"release\\.fingerprint\\autocfg-ef41a80ef2a4f8c4\\dep-lib-autocfg","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
{"rustc":8891013984288978370,"features":"[]","declared_features":"[\"arbitrary\", \"bytemuck\", \"example_generated\", \"serde\", \"serde_core\", \"std\"]","target":7691312148208718491,"profile":16503403049695105087,"path":12664969178542693245,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"release\\.fingerprint\\bitflags-56bc72c2ee6a11d2\\dep-lib-bitflags","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
{"rustc":8891013984288978370,"features":"[]","declared_features":"[\"core\", \"rustc-dep-of-std\"]","target":13840298032947503755,"profile":17257705230225558938,"path":7789178187138679712,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"release\\.fingerprint\\cfg-if-2de0fde34c98fcea\\dep-lib-cfg_if","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
{"rustc":8891013984288978370,"features":"[]","declared_features":"[\"rand\", \"random\"]","target":13517390075341535229,"profile":17257705230225558938,"path":13073641468317742375,"deps":[[4341528441765018781,"unicode_segmentation",false,4009714942342982812]],"local":[{"CheckDepInfo":{"dep_info":"release\\.fingerprint\\convert_case-cb245ff7b0d5094c\\dep-lib-convert_case","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
{"rustc":8891013984288978370,"features":"[]","declared_features":"[\"used_linker\"]","target":16767752466166802488,"profile":17257705230225558938,"path":8223028202004655627,"deps":[[10420560437213941093,"syn",false,667617596471125619],[13111758008314797071,"quote",false,15488645298819577813]],"local":[{"CheckDepInfo":{"dep_info":"release\\.fingerprint\\ctor-c44e8ff1492b4966\\dep-lib-ctor","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
{"rustc":8891013984288978370,"features":"[]","declared_features":"[]","target":9378127968640496523,"profile":2301124911398833726,"path":15222717544052245301,"deps":[[6959378045035346538,"windows_link",false,12153921703720002459]],"local":[{"CheckDepInfo":{"dep_info":"release\\.fingerprint\\libloading-c73fed9c19177dcc\\dep-lib-libloading","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
{"rustc":8891013984288978370,"features":"[]","declared_features":"[\"kv\", \"kv_serde\", \"kv_std\", \"kv_sval\", \"kv_unstable\", \"kv_unstable_serde\", \"kv_unstable_std\", \"kv_unstable_sval\", \"max_level_debug\", \"max_level_error\", \"max_level_info\", \"max_level_off\", \"max_level_trace\", \"max_level_warn\", \"release_max_level_debug\", \"release_max_level_error\", \"release_max_level_info\", \"release_max_level_off\", \"release_max_level_trace\", \"release_max_level_warn\", \"serde\", \"serde_core\", \"std\", \"sval\", \"sval_ref\", \"value-bag\"]","target":6550155848337067049,"profile":16503403049695105087,"path":2935339677018414979,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"release\\.fingerprint\\log-503ba197615b4899\\dep-lib-log","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
{"rustc":8891013984288978370,"features":"[\"alloc\", \"std\"]","declared_features":"[\"alloc\", \"core\", \"default\", \"libc\", \"logging\", \"rustc-dep-of-std\", \"std\", \"use_std\"]","target":11745930252914242013,"profile":17257705230225558938,"path":10267584396946875985,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"release\\.fingerprint\\memchr-0b6ded161300fb6d\\dep-lib-memchr","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
{"rustc":8891013984288978370,"features":"[]","declared_features":"[]","target":9388202626367339685,"profile":17257705230225558938,"path":6355682921637151155,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"release\\.fingerprint\\napi-build-547d402dbe5275e3\\dep-lib-napi_build","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
{"rustc":8891013984288978370,"features":"[\"napi1\", \"napi2\", \"napi3\", \"napi4\", \"napi5\", \"napi6\"]","declared_features":"[\"anyhow\", \"async\", \"chrono\", \"chrono_date\", \"compat-mode\", \"default\", \"deferred_trace\", \"dyn-symbols\", \"encoding_rs\", \"error_anyhow\", \"experimental\", \"full\", \"indexmap\", \"latin1\", \"napi1\", \"napi2\", \"napi3\", \"napi4\", \"napi5\", \"napi6\", \"napi7\", \"napi8\", \"napi9\", \"noop\", \"object_indexmap\", \"serde\", \"serde-json\", \"serde-json-ordered\", \"serde_json\", \"tokio\", \"tokio_fs\", \"tokio_full\", \"tokio_io_std\", \"tokio_io_util\", \"tokio_macros\", \"tokio_net\", \"tokio_process\", \"tokio_rt\", \"tokio_signal\", \"tokio_sync\", \"tokio_test_util\", \"tokio_time\"]","target":6604924358859142166,"profile":16503403049695105087,"path":12768161688960962680,"deps":[[900613073546913600,"napi_sys",false,1702221090865446476],[2571033484697105782,"bitflags",false,15853756778772315199],[5855319743879205494,"once_cell",false,4803485267941743741],[6606131838865521726,"ctor",false,11060023290689421308]],"local":[{"CheckDepInfo":{"dep_info":"release\\.fingerprint\\napi-c71c5da813d1757f\\dep-lib-napi","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
{"rustc":8891013984288978370,"features":"[\"compat-mode\", \"default\", \"full\", \"strict\", \"type-def\"]","declared_features":"[\"compat-mode\", \"default\", \"full\", \"noop\", \"strict\", \"type-def\"]","target":2065430088197001673,"profile":17257705230225558938,"path":14139299789826547350,"deps":[[4289358735036141001,"proc_macro2",false,4386134274409224043],[5241157436998822951,"napi_derive_backend",false,14640003306665018262],[7667230146095136825,"cfg_if",false,17743514191689890556],[10420560437213941093,"syn",false,667617596471125619],[13111758008314797071,"quote",false,15488645298819577813],[13475460906694513802,"convert_case",false,7806480704826143177]],"local":[{"CheckDepInfo":{"dep_info":"release\\.fingerprint\\napi-derive-8a06c7ca101ebb69\\dep-lib-napi_derive","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
{"rustc":8891013984288978370,"features":"[\"regex\", \"semver\", \"strict\", \"type-def\"]","declared_features":"[\"noop\", \"regex\", \"semver\", \"strict\", \"type-def\"]","target":7459870077939534063,"profile":17257705230225558938,"path":18206335121874424249,"deps":[[4289358735036141001,"proc_macro2",false,4386134274409224043],[5855319743879205494,"once_cell",false,3458902346603695405],[9680020106200215617,"semver",false,3158118117038451264],[10420560437213941093,"syn",false,667617596471125619],[13111758008314797071,"quote",false,15488645298819577813],[13475460906694513802,"convert_case",false,7806480704826143177],[17109794424245468765,"regex",false,15429713168089850008]],"local":[{"CheckDepInfo":{"dep_info":"release\\.fingerprint\\napi-derive-backend-b741fd0ea2b72c1a\\dep-lib-napi_derive_backend","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
{"rustc":8891013984288978370,"features":"[\"napi1\", \"napi2\", \"napi3\", \"napi4\", \"napi5\", \"napi6\"]","declared_features":"[\"dyn-symbols\", \"experimental\", \"libloading\", \"napi1\", \"napi2\", \"napi3\", \"napi4\", \"napi5\", \"napi6\", \"napi7\", \"napi8\", \"napi9\"]","target":7475771664120104103,"profile":16503403049695105087,"path":17503114133641832535,"deps":[[7883780462905440460,"libloading",false,18267044209476020296]],"local":[{"CheckDepInfo":{"dep_info":"release\\.fingerprint\\napi-sys-9a4a05b3b03a98e6\\dep-lib-napi_sys","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.

Some files were not shown because too many files have changed in this diff Show More