Compare commits

...

158 Commits

Author SHA1 Message Date
byGalax e6b698bf14 chore(desktop): release v0.18.1 2026-05-16 00:16:53 +02:00
byGalax 6caa674c19 fix(shared): legacy conv-key migration query used .eq(null) instead of .is(null)
PostgREST translates .eq('col', null) to `col = NULL` which is always false
in SQL. The migration silently returned zero rows -> setupNewUserIdentity
fired but re-wrapped nothing -> users could set a PIN but every send threw
'Awaiting key'. Switching to .is('col', null) emits `col IS NULL` and the
migration finally finds its work.

Also makes the migration trigger idempotent and re-fires it on:
  - every successful loadOrUnlockUserKey
  - AuthContext startup when the user-key is already cached
so users stuck on 0.18.0 auto-recover the moment they install 0.18.1.

PinInput: focused + active-slot now show a brand-coloured ring, glow, and
a blinking caret so users see where the next keystroke lands.
2026-05-16 00:15:15 +02:00
byGalax d9377fc52a chore(desktop): release v0.18.0 2026-05-16 00:04:59 +02:00
byGalax 1c18078b1f docs: encryption-UX spec + plan from 2026-05-15 brainstorming session 2026-05-16 00:02:27 +02:00
byGalax 0e05a2cd85 refactor(desktop): replace VoiceChannelRail with CallPreviewPanel
Drops the always-on 'Sprach-Channel' banner. The preview panel renders
only when peers are in the active call (1:1 and group identical).
Calls are still started via the topbar phone icon.
2026-05-15 23:59:17 +02:00
byGalax b55ccf899f feat(desktop): add CallPreviewPanel — Discord-DM-style join surface 2026-05-15 23:56:02 +02:00
byGalax b2eb214d9f docs(plan): call-preview-panel implementation plan 2026-05-15 23:51:01 +02:00
byGalax 010a810485 docs(spec): call-preview-panel — only show when peer is in active call 2026-05-15 23:43:50 +02:00
byGalax 6a9a0bb804 fix(db): swap conversation_keys natural PK for synthetic row_id
Migration 20260515000002 failed on prod because dropping NOT NULL on
recipient_device_id was rejected (column is part of the natural primary
key). This fix-up drops the PK, adds a synthetic row_id BIGSERIAL PK,
re-applies the NOT NULL drop, and re-runs the indexes/policies that
were skipped after the failure.
2026-05-15 23:25:49 +02:00
byGalax f7c60945d0 refactor(shared): strip cryptographic device provisioning (now telemetry-only)
devices rows no longer carry public_key for crypto purposes. The whole
per-device key API surface (provisionNewDevice, loadDevicePrivateKey,
saveDevicePrivateKey, forgetDevicePrivateKey, restoreDeviceFromServerRecord)
is removed; registerDevice now records {name, platform} only. SQL drops the
NOT NULL on devices.public_key so future telemetry rows can omit it.

Note: SQL not applied locally - push via pnpm prod:migrate when ready.
2026-05-15 23:19:26 +02:00
byGalax 15ef9ece66 feat(desktop): proactively rewrap conv-keys for un-migrated peers on open
When a conversation opens, the local client checks every accepted member
for a recipient_user_id bundle on the active key version. Members without
one get a best-effort wrap from the local conv-key handle. This closes
the legacy migration gap where peer B couldn't read because no one had
yet wrapped the new per-user conv-key for them.
2026-05-15 23:12:35 +02:00
byGalax b789f4b10d feat(desktop): Settings security center (PIN change / recovery / reset)
Drops the manual backup-string flow; replaces it with PIN change,
recovery-code regeneration, and identity reset (all sealed via the new
user_keys table).
2026-05-15 23:09:05 +02:00
byGalax 02e1af4517 feat(desktop): DevicePage routes to UserKeySetup or UserKeyUnlock 2026-05-15 23:03:02 +02:00
byGalax e292df82f0 feat(desktop): UserKeyUnlock screen (PIN entry + recovery fallback) 2026-05-15 23:00:18 +02:00
byGalax b3804d805b feat(desktop): UserKeySetup screen (PIN + optional recovery code) 2026-05-15 22:57:57 +02:00
byGalax 02c4bb1c9b feat(desktop): shared PinInput component 2026-05-15 22:54:58 +02:00
byGalax 20216b37c6 refactor(desktop): AuthContext exposes userKeyState instead of device record
Replaces the per-device DeviceRecord lookup with a per-user discriminated
union (loading | needs-setup | needs-unlock | unlocked). Heartbeat block
deleted (telemetry no longer device-bound); webPush keyed by install-id.
2026-05-15 22:52:41 +02:00
byGalax 2c586351fc fix(shared): expose ./crypto/testBackend in package exports
Removes the @shared Vite-alias workaround in userIdentity.test.ts so
tsc can resolve the import without an extra paths entry.
2026-05-15 22:43:24 +02:00
byGalax b89a7e2617 refactor: swap device-id contexts for user-id contexts at call sites
Renames DecryptParams.ownDeviceId to ownUserId so decryptMessages actually
looks up bundles by user. Sweeps remaining OwnDeviceCtx and
loadDevicePrivateKey consumers in the desktop app to use cachedUserKey
from userIdentity. Files scheduled for deletion in later tasks
(BackupExportDialog, DeviceRestore, BackupRestoreDialog, BackupPromptBanner,
deviceBackup, DeviceRegistration) are left untouched.
2026-05-15 22:41:44 +02:00
byGalax 8d69329763 feat(desktop): user-identity orchestrator (setup/unlock/cache/change-PIN/reset) 2026-05-15 22:29:34 +02:00
byGalax 2fdbe9ee3a feat(shared): migrate legacy per-device conv-key bundles to per-user 2026-05-15 22:22:08 +02:00
byGalax 0d1d4496c3 refactor(shared): conv-keys target user-id instead of device-id 2026-05-15 22:17:02 +02:00
byGalax baf9c2e054 feat(shared): user-key DB wrappers (fetch/upload/unlock/attempt/reset) 2026-05-15 22:12:26 +02:00
byGalax b54fe0b56d test(shared): add mock supabase client helper for auth unit tests 2026-05-15 22:07:53 +02:00
byGalax 89eb8d97c6 feat(db): user-key RPCs (unlock, attempt, reset, migrate, share v2)
Note: not applied locally — push via pnpm prod:migrate when ready.
2026-05-15 22:06:32 +02:00
byGalax 32bff77fd0 feat(db): conversation_keys can target user_id alongside legacy device_id
Note: not applied locally — push via pnpm prod:migrate when ready.
2026-05-15 22:02:29 +02:00
byGalax d10b0fb5c3 feat(db): add user_keys table with RLS and public-key view
Note: not applied locally — push via pnpm prod:migrate when ready.
2026-05-15 22:00:52 +02:00
byGalax 1370f8794b feat(shared): seal/open user private key with PIN-derived Argon2id KEK 2026-05-15 21:58:53 +02:00
byGalax eef884c782 feat(shared): lift recovery-code primitives into shared crypto module 2026-05-15 21:54:57 +02:00
byGalax d9f3c6c562 fix(mobile): enable Metro package-exports resolution for @chat-app/shared
apps/mobile/lib/supabase.ts imports '@chat-app/shared/supabase'. The
shared package declares this subpath in its package.json "exports"
map. The dev Metro resolver respected it; the eager exporter used by
preview/production builds defaulted to legacy resolution and 404'd on
the subpath.

unstable_enablePackageExports=true makes Metro use Node's modern
exports-aware resolver in both modes. Despite the 'unstable_' prefix,
it's the recommended setting in Expo SDK 52 monorepos.
2026-05-15 02:00:29 +02:00
byGalax b61f929cf7 fix(shared): drop .js extensions from relative imports for Metro
packages/shared/src/index.ts and all sub-modules used .js extensions on
relative imports (e.g. './admin/index.js') pointing at .ts source files.
TypeScript with moduleResolution: "Bundler" doesn't need them, and
Metro's eager exporter (used for preview / production builds) reads
them literally and fails — only the dev-server Metro fell back to .ts.

Workspace typecheck remains 8/8 green; Vite and TS Bundler resolution
already accept both styles, so desktop is unaffected.
2026-05-15 01:52:14 +02:00
byGalax b0967dd2c5 fix(mobile): switch to shamefully-hoist=true for RN flat node_modules
Selective public-hoist patterns weren't enough — RN's index.js imports
invariant (and nullthrows, pretty-format, regenerator-runtime, and many
more) as bare transitive deps. Each fresh EAS build was hitting a
different missing-module error.

shamefully-hoist=true is Expo's official monorepo workaround: pnpm
mirrors npm/yarn's flat layout at the workspace root, so every package's
deps are reachable via standard Node resolution. Desktop (Vite-bundled)
is unaffected.
2026-05-15 00:34:14 +02:00
byGalax f8859b4601 fix(mobile): hoist expo + correct @expo/metro-runtime version for SDK 52
PackageList.java generated by RN autolinking imported expo.core.ExpoModulesPackage
(old SDK <48 name). Root cause: RN CLI couldn't read expo's react-native.config.js
through the pnpm symlink (it lives at .pnpm/expo@52..../expo/react-native.config.js),
so it fell back to deriving the import path from expo's Android gradle namespace
("expo.core") + assumed class name (ExpoModulesPackage), producing a path that
doesn't exist (the actual class lives at expo.modules.ExpoModulesPackage).

Added "expo" to .npmrc public-hoist-pattern so RN's autolinking can resolve
expo's react-native.config.js via the standard node_modules lookup. That config
explicitly sets packageImportPath to the correct expo.modules.ExpoModulesPackage.

Also corrected @expo/metro-runtime from 55.0.11 (added blindly earlier) to ~4.0.1
which is the SDK-52-aligned version.
2026-05-14 21:23:54 +02:00
byGalax 4068e8c7b6 fix(mobile): hoist RN/Expo transitive deps for Metro resolution
pnpm's default strict isolation hides @babel/runtime/helpers/* and other
transitive deps from Metro, which expects flat node_modules layout.
Each missing dep was a separate failed EAS build (metro-runtime,
@babel/runtime, ...) — added .npmrc with public-hoist-pattern entries
that catch the common RN/Expo families so we stop whack-a-moling them.
Also kept @babel/runtime as direct dep of @chat-app/mobile for safety.
2026-05-14 21:14:41 +02:00
byGalax cb8e729624 fix(mobile): add @expo/metro-runtime, drop deprecated expo-router/babel
expo-router@4 imports @expo/metro-runtime as the very first thing in its
entry-classic.js so Fast Refresh works; bundler errored without it.
Also dropped expo-router/babel from babel.config.js — its functionality
moved into babel-preset-expo in SDK 50.
2026-05-14 16:46:31 +02:00
byGalax 1e9395fdc2 fix(mobile): align expo-* package versions with SDK 52
Phase 0 installed expo-application/expo-image-picker/expo-dev-client at
^55.x assuming the major version matched the SDK number. It doesn't —
each expo-* package has its own major. Misaligned versions pulled in
expo-modules-core@2.2.3 which expects an API that SDK 52's gradle plugin
host doesn't provide, breaking the Gradle build with
'expo-module-gradle-plugin not found'.

`expo install --fix` aligned everything to SDK 52 baselines:
  expo-application 55.0.15 -> ~6.0.2
  expo-dev-client  55.0.33 -> ~5.0.20
  expo-image-picker 55.0.20 -> ~16.0.6
  expo-sqlite      15.0.6  -> ~15.1.4
  react-native     0.76.0  -> 0.76.9
  + RN companion libs (gesture-handler, screens, async-storage)
2026-05-14 16:21:31 +02:00
byGalax 9b3ef8c24f feat(mobile): add icon/adaptive-icon/splash placeholders (reused from desktop) 2026-05-14 14:05:00 +02:00
byGalax ba8ba75490 feat(mobile): add expo-dev-client for EAS development builds 2026-05-14 12:49:25 +02:00
byGalax 122191276e fix(mobile): use @config-plugins/react-native-webrtc, link EAS project
- replace bogus @livekit/* plugin entries with @config-plugins/react-native-webrtc@^10 (real Expo config plugin; LiveKit packages do not ship app.plugin.js)
- eas init linked project @bygalax/netralax (id 255e2fde-...)
- app.json picked up Expo defaults (Android permissions, owner)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:44:24 +02:00
byGalax 5a3dc15704 chore(mobile): extract ENDED_STATE_LINGER_MS constant
The 2500ms timer that drifts CallState from `ended` back to `idle` was
an inline magic number. Promote to a module-level constant with a
comment explaining why the value isn't arbitrary — picked from the
post-Phase-3 quality review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 06:24:05 +02:00
byGalax 10d3e91bbb chore(mobile): restore German umlauts + ellipsis in call screen labels
The Phase-3 implementer ASCII'd four strings to side-step a Windows
console encoding issue during the apply step. The TypeScript / Metro
toolchain handles non-ASCII string literals cleanly — the workaround
was unnecessary and produced ugly UI labels ("Anruf lauft", "Hoerer",
"Verbinde ..."). Restore the originals.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 06:16:40 +02:00
byGalax 04da0f211d feat(mobile): phone-icon header button + navigate to /call on connect 2026-05-14 06:14:17 +02:00
byGalax 4d787b68b4 feat(mobile): in-call screen with participants + toolbar 2026-05-14 06:13:25 +02:00
byGalax c2df741055 feat(mobile): register /call full-screen modal route 2026-05-14 06:12:50 +02:00
byGalax b81c63a2d3 feat(mobile): mount CallProvider + global IncomingCallModal 2026-05-14 06:12:20 +02:00
byGalax 098e4f4c1d feat(mobile): IncomingCallModal with Annehmen/Ablehnen + name resolution 2026-05-14 06:11:45 +02:00
byGalax e83c23d698 feat(mobile): CallProvider with state machine + LiveKit room lifecycle 2026-05-14 06:11:00 +02:00
byGalax 68cd4108dd feat(mobile): callSignal subscribe + broadcast helpers 2026-05-14 06:06:53 +02:00
byGalax ed7cb72ebe chore(mobile): add LiveKit RN deps + mic permission + audio bg mode 2026-05-14 06:06:07 +02:00
byGalax 177a4c059f docs(mobile): phase 3 spec + plan — voice calls
9 tasks adding voice calls (1:1 + group) to the mobile app over the
same LiveKit + Supabase signaling stack the desktop uses:

  1. @livekit/react-native + @livekit/react-native-webrtc deps, mic
     permission strings, audio background mode, LiveKit Expo plugin.
  2. callSignal.ts subscribe/publish helpers over Supabase realtime.
  3. callContext.tsx state machine (idle/outgoing/incoming/connecting/
     connected/ended) + LiveKit room lifecycle + audio routing.
  4. IncomingCallModal at root with Annehmen/Ablehnen.
  5. Mount CallProvider + global IncomingCallModal in _layout.tsx.
  6. Register /call full-screen modal route.
  7. In-call screen with participants list + mute/speaker/hangup.
  8. Phone-icon header button on conversation detail + push to /call
     on connect.
  9. Workspace typecheck pass.

Out of scope: video, CallKit / ConnectionService native UI, VoIP push
wake-up, screen sharing, call history. Those are Phase 3.5 / 4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 06:04:20 +02:00
byGalax 1440b11740 feat(mobile): wire image attachments, reactions, reply, delete into conversation detail 2026-05-14 00:22:18 +02:00
byGalax 286836f539 feat(mobile): MessageBubble with reply, attachment, reactions, delete state 2026-05-14 00:21:11 +02:00
byGalax 3b62fbc243 feat(mobile): MessageActionsSheet bottom modal — react/reply/delete 2026-05-14 00:20:08 +02:00
byGalax 8d7ed592b8 feat(mobile): ReactionStrip quick-reactor + ReactionPills display 2026-05-14 00:19:15 +02:00
byGalax 153e40bdcb feat(mobile): AttachmentImage component with on-demand decrypt + cache 2026-05-14 00:18:11 +02:00
byGalax 415fa10ed0 feat(mobile): in-memory attachment cache by handle id 2026-05-14 00:16:49 +02:00
byGalax 8b7b6a3b0f feat(mobile): imagePicker helper with library + camera flows 2026-05-14 00:16:25 +02:00
byGalax 05a17b7c1c chore(mobile): add expo-image-picker dep + permission strings 2026-05-14 00:15:40 +02:00
byGalax dd6ae63491 docs(mobile): phase 2 spec + plan — image attachments, reactions, reply, delete
9 tasks taking the mobile chat from text-only to feature parity on the
high-impact subset of the desktop's messaging surface:

  1. Add expo-image-picker dep + permission strings in app.json.
  2. imagePicker.ts helper for library + camera capture.
  3. attachmentCache.ts in-memory data-URL cache.
  4. AttachmentImage component with on-demand decrypt.
  5. ReactionStrip (6 emojis) + ReactionPills (count badges).
  6. MessageActionsSheet long-press modal (react/reply/delete).
  7. MessageBubble extraction with reply quote + deleted state.
  8. Wire everything into conversations/[id].tsx.
  9. Workspace typecheck pass.

Explicitly out of scope: file attachments, voice messages, edit,
forward, read receipts, typing, polls. Those move to Phase 2.5 / later.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:14:39 +02:00
byGalax b1cdc4d184 chore(mobile): phase 1 quality-review fixups
Three small follow-ups from the post-Phase-1 quality review:

  * authContext.tsx — drop the dead `userId` extraction + the `void
    userId` suppressor that masked an unused-locals warning. The session
    is already implicitly threaded through the supabase client, so no
    consumer of ensureDevice needed the value.
  * authContext.tsx — switch the device-name string concat to a
    template literal for consistency with the rest of the codebase.
  * ErrorBoundary.tsx — replace the four inline hex literals with their
    `theme/colors.ts` constants. The boundary was authored in Phase 0
    before the theme module existed; this brings it in line with every
    Phase 1 screen.
  * apps/mobile/README.md — drop the stale Phase-0 paragraph about the
    `lib/sharedSmoke.ts` canary (deleted in Phase 1) and add a short
    pointer to the env-var setup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:08:22 +02:00
byGalax 263d2455a8 feat(mobile): conversation detail with decrypt + send 2026-05-14 00:01:48 +02:00
byGalax 3755a927c3 feat(mobile): real chat list with pull-to-refresh + logout 2026-05-14 00:00:24 +02:00
byGalax 0b3a6280fc feat(mobile): Avatar + ConversationRow + relative-time helper 2026-05-13 23:59:53 +02:00
byGalax 3313dd84f3 feat(mobile): auth callback screen consumes magic-link tokens 2026-05-13 23:58:52 +02:00
byGalax 2a5f836b86 feat(mobile): magic-link login screen 2026-05-13 23:58:15 +02:00
byGalax bf02380c19 feat(mobile): session-gated (app) layout redirects to login when signed out 2026-05-13 23:57:43 +02:00
byGalax 375d28efa5 feat(mobile): AuthProvider + crypto-backend registration; drop sharedSmoke canary 2026-05-13 23:57:18 +02:00
byGalax 1e88941796 feat(mobile): supabase client wired through async-storage session 2026-05-13 23:55:35 +02:00
byGalax 7ac93a95e6 feat(mobile): crypto + secret-store + session-storage adapters 2026-05-13 23:55:07 +02:00
byGalax a275703ba6 feat(mobile): add AsyncStorage dep + env reader + theme constants 2026-05-13 23:54:10 +02:00
byGalax 035b5f078d docs(mobile): phase 1 spec + plan — Auth + Chat MVP
11 tasks taking the mobile app from Phase 0 shell to a working
end-to-end magic-link login + conversation list + decrypted detail +
text-send. All renderer-only, plumbed through @chat-app/shared's
CryptoBackend / SecretStore / Supabase-session-storage interfaces:

  1. Foundation deps (AsyncStorage), env reader, theme constants.
  2. Crypto + secret-store + session-storage adapters.
  3. Supabase client.
  4. AuthProvider + cleanup of Phase-0 sharedSmoke canary.
  5. Session-gated (app) layout.
  6. Magic-link login screen.
  7. Auth-callback deep-link screen.
  8. Avatar + ConversationRow + timeFormat primitives.
  9. Conversation list with pull-to-refresh + logout.
 10. Conversation detail with decrypt + send.
 11. Workspace-wide typecheck pass.

Push notifications, realtime, attachments, voice messages, edits,
reactions, calls are explicitly deferred to later phases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:52:37 +02:00
byGalax 13c6ad68f0 docs(mobile): phase 0 quickstart + roadmap pointer 2026-05-13 23:38:05 +02:00
byGalax 84a9f35ed8 chore(repo): add mobile:typecheck convenience script 2026-05-13 23:37:26 +02:00
byGalax 7eb8224bb0 feat(mobile): Netralax-branded Chats placeholder screen 2026-05-13 23:36:58 +02:00
byGalax 574436ab71 feat(mobile): Netralax landing screen with runtime version + nav smoke test 2026-05-13 23:36:31 +02:00
byGalax 5e483368ec feat(mobile): wrap root layout in GestureHandler + SafeArea + ErrorBoundary 2026-05-13 23:35:51 +02:00
byGalax b4457b74f5 chore(mobile): add runtime canary import from @chat-app/shared 2026-05-13 23:35:17 +02:00
byGalax bb7aeec345 feat(mobile): add ErrorBoundary for render-error fallback 2026-05-13 23:34:46 +02:00
byGalax 1bf4f4bb83 chore(mobile): add expo-application + react-native-gesture-handler for phase 0 shell 2026-05-13 23:34:10 +02:00
byGalax 3049a0e39d chore(mobile): add eas.json with development/preview/production build profiles 2026-05-13 23:33:47 +02:00
byGalax 2003a26c23 chore(mobile): rebrand app.json to Netralax + cloud.netralax.app bundle ids 2026-05-13 23:33:24 +02:00
byGalax 1e0cf67335 docs(mobile): phase 0 implementation plan
11 tasks, all renderer-only changes inside apps/mobile:
  1. app.json rebrand (Netralax, cloud.netralax.app ids).
  2. eas.json with development/preview/production profiles.
  3. Add expo-application + react-native-gesture-handler deps.
  4-5. ErrorBoundary component + sharedSmoke runtime canary.
  6-8. Root layout (SafeArea + GestureHandler + ErrorBoundary), Netralax
       landing screen with runtime version, Chats placeholder.
  9-10. Root mobile:typecheck script + README quickstart.
  11. Manual on-device verification handed back to the user.

Asset replacement (icon/splash) is deferred to Phase 4 — the dev client
doesn't care about icon polish.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:31:39 +02:00
byGalax 5f753412a6 docs(mobile): roadmap + phase-0 deployment foundation design
Mobile shipping is decomposed into 5 phases:
  0. Deployment Foundation — Netralax brand on a runnable dev build.
  1. Auth + Chat MVP — magic-link login, conversation list, text send.
  2. Messaging Features — attachments, voice messages, reactions.
  3. Voice/Video Calls — LiveKit RN + CallKit/ConnectionService.
  4. Polish + Store Submission — TestFlight, Play, signing.

Phase 0 spec lays out the concrete file changes:
  * app.json rename to Netralax + cloud.netralax.app bundle/package.
  * New eas.json with development/preview/production profiles.
  * SafeAreaProvider + GestureHandlerRootView + ErrorBoundary in the
    root layout, Netralax landing screen with runtime app version.
  * sharedSmoke.ts runtime import to verify Metro can resolve
    @chat-app/shared (which already has CryptoBackend/SecretStore
    interfaces designed for mobile adapters).
  * README quickstart for `eas init` + first dev client build.

No code changes here — just the planning surface. Implementation plan
follows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:27:52 +02:00
byGalax a560f24c45 chore(desktop): release v0.17.5 2026-05-13 22:35:43 +02:00
byGalax 87c2e45bb4 fix(preload): read appVersion from package.json instead of npm_package_version
`appVersion: process.env.npm_package_version ?? '0.0.0'` only worked in
dev — pnpm sets that env var while running its lifecycle scripts. In
the packaged Electron build npm_package_version is unset, so every
installed user saw `v0.0.0` on the Changelog page and the version badge
permanently flagged them as "Update verfügbar" against their own
actually-current version.

Replace with a static `import pkg from '../package.json'` so Vite
inlines the version string into the preload bundle at build time. The
release script bumps package.json before electron-builder runs, so the
inlined value always matches the freshly-released version.

resolveJsonModule + esModuleInterop are already on in tsconfig.node.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 22:33:18 +02:00
byGalax b65a3994f3 chore(desktop): release v0.17.4 2026-05-13 22:25:11 +02:00
byGalax aa609389fa feat(chat): render Media/Files + Group Info drawers inline, not overlaid
Both right-hand panels used absolute inset-y-0 right-0 and floated on
top of the conversation, hiding the messages directly underneath the
panel and looking unlike Discord's actual layout. Restructure:

* MediaFilesDrawer: drop absolute/z-index/shadow chrome, become a
  static flex column (w-[380px] shrink-0) with a left border. Internal
  layout unchanged.
* GroupInfoPanel: same treatment (w-[320px] shrink-0). Dropped the
  backdrop-blur and slide-up animation that only made sense as a modal.
* ConversationPage: wrap the chat content (voice rail, in-call panel,
  messages list, drag-overlay, input form) in a new
  `flex min-w-0 flex-1 flex-col` chat-column, and make that column a
  sibling of the drawers inside a new `flex flex-1 flex-row` row. The
  conversation header + search bar stay full-width above the row.

Result: opening a drawer narrows the chat column instead of covering
it, matching Discord's behaviour. The chat-column wrapper also carries
the `relative` anchor previously held by the outer wrapper so the
drag-and-drop overlay positions correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 22:21:05 +02:00
byGalax cb3cbd8827 feat(chat): show caller's name on call event pills in groups
In group conversations the "Outgoing/Incoming/Missed call" system pill
gave no clue WHO triggered the event — fine in a 1:1 where the only two
players are obvious, useless in a group with three+ members. Discord
puts the caller's name in the pill; mirror that.

CallEventRow now takes a senderDisplayName prop (plumbed through from
MessageBubble) and switches non-own labels to the name-aware variants:

* ended  + !mine + name → "{name} hat einen Anruf gestartet"
* missed + !mine + name → "Verpasster Anruf von {name}"
* declined + !mine + name → "Anruf von {name} abgelehnt"

Own events (mine) stay generic ("Outgoing call" / "No answer") since
the user already knows they were the initiator. Fallback path without
a name keeps the previous generic labels so nothing regresses if the
sender is unresolvable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 22:20:21 +02:00
byGalax d1f38ce313 chore(desktop): release v0.17.3 2026-05-12 22:59:44 +02:00
byGalax 0d65a134fd feat(settings): click own avatar in profile preview to view fullscreen
Lightbox was previously a file-private component inside AttachmentImage
(used for enlarging chat image attachments). Extracted to a standalone
components/Lightbox.tsx so other surfaces can reuse the same dialog
without duplicating Esc/backdrop/body-overflow plumbing.

In SettingsPage's profile live-preview, the round avatar overlapping the
banner is now wrapped in a transparent button that opens the Lightbox
with the full-resolution avatar URL on click. Cursor switches to
zoom-in. Disabled when the user only has the initial-letter placeholder
(nothing meaningful to enlarge). Native button chrome (border, padding,
button-face background) is reset to keep the avatar circle's appearance
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:58:22 +02:00
byGalax 12c66d676a fix(chat): use useLayoutEffect for scroll restore + auto-bottom to avoid mount flicker
The scroll-position memory introduced in 0.17.2 still produced a visible
"chat appears at the top then jumps" frame when switching back into a
conversation. Cause: both scroll-affecting effects (auto-bottom on new
messages, restore on chat re-entry) used useEffect, which fires AFTER
the browser paints the freshly-committed DOM. So users saw scrollTop=0
for one frame before the effect ran and corrected it.

Switching both to useLayoutEffect moves the scroll write into the same
commit phase as the message-list DOM update, so the very first paint
already shows the correct position — single paint, no flicker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:58:07 +02:00
byGalax 0dde1dd1a3 chore(desktop): release v0.17.2 2026-05-12 22:24:32 +02:00
byGalax 81d3587a91 feat(chat): per-conversation scroll memory + version badge on changelog page
Two small UX polishes:

1. Switching between chats no longer slams you to the bottom. Each
   conversation's scroll position (pixel offset + stickToBottom flag)
   is remembered in a module-scoped Map for the lifetime of the
   renderer process. Discord-style: leave Chat A scrolled up, peek at
   another conversation, come back — same spot you were reading.
   Chats left at the bottom keep auto-following new messages on return.
   Reload resets everything (session-only, no localStorage).

   The restore runs once messages.length > 0 to avoid the browser
   clamping scrollTop to a near-zero scrollHeight before the message
   list has rendered. A small isRestoringRef guard prevents the
   programmatic scroll event from immediately overwriting the saved
   position with a clamped value.

2. Changelog page now shows a version badge in the header that compares
   the installed app version against entries[0].version from the
   server-side changelog feed. Three states:
   * `vX.Y.Z · aktuell` (emerald) — installed matches latest
   * `vX.Y.Z · Update verfügbar` + `neueste: vA.B.C` (amber) — outdated
   * `vX.Y.Z` neutral — installed is ahead of the published feed
     (dev/test builds)
   Semver compare is integer-major.minor.patch with a graceful
   garbage-fallback so a malformed version string doesn't false-flag
   a current install as outdated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:22:49 +02:00
byGalax 8be1105333 chore(desktop): release v0.17.1 2026-05-12 22:00:27 +02:00
byGalax f9e1d2f073 fix(secure-store): preserve original ciphertext on decrypt failure + startup path log
Critical hotfix for the 0.17.0 regression: users upgrading from 0.16.x
were logged out, and their next login wrote a fresh empty secure-store
on top of the original ciphertext — destroying device keys irrecoverably.

Why it happened: loadState used a blanket `catch {}` that conflated
"file doesn't exist (genuine new user)" with "file exists but can't be
decrypted (DPAPI / OSCrypt quirk after the install rename)". Both paths
returned an empty Map; the next scheduledSave then overwrote the
original .bin file with a fresh blob.

Fix:
* Separate ENOENT from decrypt/parse failures. ENOENT → empty Map. Any
  other read error → log, empty Map (no quarantine, matches old
  behaviour for transient lock issues).
* When decrypt/parse fails the original file is renamed to
  <file>.broken-<iso-ts> BEFORE returning empty Map. The next save
  writes to a fresh file; the original ciphertext is preserved on disk
  so a future build (or manual recovery) can still get at the bytes.
* Loud console.error around the failure so future regressions surface
  in main-process logs.

main.ts: move setPath('userData', appData/ChatApp) BEFORE setName so
any productName-derived path caching inside setName can't beat us to
it. Add a startup log of the resolved paths so future debugging has
hard evidence instead of guessing.

Affected users on 0.17.0 should still recover via Settings → Backup
Wiederherstellen (account-level keys are unchanged); this fix prevents
the data destruction for anyone who hasn't upgraded yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:54:14 +02:00
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
byGalax 72d02e385f chore(desktop): release v0.11.3 2026-04-23 18:02:24 +02:00
byGalax c9b885b91c perf(call): screen-share hybrid — OS picker + WASAPI audio
Strip the sourceId-gated chromeMediaSource + xcap native capture paths
from startScreenShare and collapse to a single setScreenShareEnabled
call. Neither of the bypassed paths produced smooth frames in WebView2:
chromeMediaSource: 'desktop' is an extension-only Chromium constraint
and throws outside extension origins, and the xcap JPEG-over-IPC
fallback couldn't sustain 30fps at 1080p on a single main-thread.
setScreenShareEnabled goes through Chromium's native getDisplayMedia
capture, which is the only path that gets HW-accelerated frames into
the WebRTC encoder from WebView2.

Audio continues via the WASAPI loopback module — getDisplayMedia can't
grab system sound in WebView2 without desktop-capture entitlements
Chromium reserves for extensions. The audio track's teardown chains to
the ScreenShare video track's 'ended' event so the Windows stop-share
overlay kills both sides in lockstep.

ScreenSourcePicker is now a quality + audio chooser only; the
thumbnail grid disappears because custom source IDs don't round-trip
through WebView2, and a custom picker in front of the OS picker just
means the user picks twice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:59:41 +02:00
byGalax adbdfaa2aa chore(desktop): release v0.11.2 2026-04-22 23:42:14 +02:00
byGalax 6f1e1a5f9a fix(call): raise xcap fps clamp 30 → 60
The fallback capture path was silently capping any 60fps preset to
30 because the hardcoded clamp never got updated when the 60fps presets
landed. Also syncs Cargo.lock that drifted against 0.11.0 metadata.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:40:05 +02:00
byGalax f9bbcdee47 perf(call): H.264 + contentHint + 30fps default for screen-share
The real cost in the "frame by frame" stutter wasn't the custom capture
path — it was LiveKit re-encoding via VP9 software with L3T3_KEY SVC
(three spatial × three temporal layers, all CPU). Switching the
per-publish codec to H.264 lets Chromium's hardware encoder take over
on Windows and sidesteps the SVC mode entirely (H.264 has no SVC).
Also pushes `contentHint = 'detail'` on the track — setScreenShareEnabled
does this internally, the manual publishTrack paths had been missing it,
which changes how the encoder allocates its frame budget for static UI
content.

Auto preset default framerate 60 → 30. 60fps desktop share burns three
full-res encodes per frame at sizes up to 4K; 30 is what getDisplayMedia
practically delivers anyway.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:37:22 +02:00
byGalax ccbef6d959 chore(desktop): release v0.11.1 2026-04-22 23:17:51 +02:00
byGalax 73ddeecfca chore(desktop): sync Cargo.lock to v0.11.0
Release script bumped Cargo.toml but cargo only refreshes the lock on
the next build. Aligning them so the lock doesn't drift across tags.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:15:54 +02:00
byGalax 727aced411 perf(call): prefer chromeMediaSource video over xcap native capture
The xcap native path does JPEG-encode-in-Rust → base64 → IPC → atob →
createImageBitmap → canvas.drawImage → canvas.captureStream → VP9 per
frame, all CPU-bound and mostly on the main thread — at 1080p30 that
lands well past one render quantum, producing visible frame-by-frame
stutter. chromeMediaSource+getUserMedia hands the capture to Chromium's
native desktop-capture backend and directly into the PeerConnection, so
it's the same path the OS picker uses and has no per-frame JS cost.

Reorders the capture attempts so chromeMediaSource is tried first; xcap
stays around as a fallback for WebView2 versions that reject the legacy
constraint. System audio still goes through WASAPI in both paths, since
getUserMedia's chromeMediaSource audio constraint throws AbortError on
Window captures — splitting the streams is what makes both work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:14:21 +02:00
byGalax 3d605c09bc chore(desktop): release v0.11.0 2026-04-22 23:07:23 +02:00
byGalax 74074115d2 chore: ignore .claude/ local settings dir
Claude Code writes per-project permission settings here; harmless to
track but noisy across sessions. Gitignoring keeps the release
pre-flight check happy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:04:19 +02:00
byGalax 12bb585081 style(auth): migrate auth + language switcher to semantic color tokens
Swaps hardcoded brand-/white-/neutral- utilities for the accent / surface
/ fg / line / fg-muted tokens so light-mode and theme overrides behave
correctly. Also moves focus rings from `focus:` to `focus-visible:`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:03:24 +02:00
byGalax 665f450878 feat(call): native WASAPI system-audio for screen-share (Windows)
Hooks the custom screen-share picker up to a native WASAPI loopback
capture so "Mit System-Sound" no longer falls back to the OS picker on
Windows. Rust side opens the default render endpoint, channels 48 kHz
f32 stereo to an AudioWorklet, which feeds a MediaStreamDestination for
LiveKit to publish as ScreenShareAudio. Ring buffer sized for latency
(80 ms target, drop-to-target on overflow) and the AudioContext is
resumed eagerly so initial burstiness can't pile up.

Adds a temporary attachTrack:audio diagnostic log to confirm source
tagging matches between old and new clients.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:03:15 +02:00
byGalax e2e8217b86 fix(call): rAF batch flush never emptied into state
The real reason every thumbnail card stayed on the placeholder was a
reference-aliasing bug in the flush closure. `const batch = pendingUrls`
captured the same object; `delete (pendingUrls)[k]` for each key then
emptied `batch` too, because they were the same reference. By the time
`setThumbnailUrls(prev => ({ ...prev, ...batch }))` ran, batch was {}
and the state never picked up any URL — every card rendered the empty
placeholder icon.

Fixed by aliasing first, then replacing pendingUrls with a fresh empty
object (let instead of const on the outer binding). The cloned `batch`
retains its entries for the spread; any new arrivals during the commit
land in the new empty pendingUrls and coalesce into the next frame.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:26:45 +02:00
byGalax 6c6a23e672 fix(call): thumbnail fetch falls back to base64 if binary path returns nothing
Binary IPC (tauri::ipc::Response) came back as an unrecognised shape on
the user's runtime — the frontend couldn't extract an ArrayBuffer and
every thumbnail resolved to null, so every card rendered the placeholder
icon. Added:

- Widened the invoke typing to ArrayBuffer | Uint8Array | number[] so
  all three known Tauri/WebView2 deserialisation shapes parse.
- A one-time console.warn when the Response body lands as an unknown
  object shape, so the real wire format can be diagnosed if this ever
  trips again.
- An automatic tier-2 fallback: if the binary path produced 0 usable
  bytes, re-invoke the legacy base64 command and decode client-side.
  Slower on the JS thread than binary IPC but known to work across all
  Tauri 2.x runtimes.

Net behaviour: thumbnails render again. If the binary path works on a
given build, we get the fast path; otherwise the base64 fallback keeps
the picker usable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:24:02 +02:00
byGalax 16d179f8e8 fix(call): Response return-type can't be wrapped in Result
Previous commit had the command typed as Result<tauri::ipc::Response,
String>. Turns out that forces Tauri to JSON-serialise the variant
wrapper around the Response body — the frontend gets a JSON object
instead of the raw ArrayBuffer, the runtime check for byteLength fails,
and every thumbnail comes back as null.

Changed the return type to `tauri::ipc::Response` directly. Bad source
ids and capture failures now funnel into an empty byte buffer; the JS
side still detects "no thumbnail" via `byteLength === 0` so the
contract stays the same.

Frontend also widens the invoke-result typing to ArrayBuffer |
Uint8Array | number[] so an older WebView2 that happens to deserialise
as an array still works, and normalises into a plain ArrayBuffer
before constructing the Blob to sidestep a TS SharedArrayBuffer
incompatibility.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:21:39 +02:00
byGalax 12e91c0bbe perf(call): binary IPC + rAF-batched state for smooth thumbnail streaming
Picker still stuttered during load because the main thread was stuck
parsing 20+ inbound IPC messages, each carrying 15-25 KB of JSON-wrapped
base64. Two changes compound to fix this:

1. Binary IPC. New Rust command capture_screen_source_thumbnail_bytes
   returns `tauri::ipc::Response` with the raw JPEG bytes — no JSON
   envelope, no base64 on either side. The frontend wraps the arriving
   ArrayBuffer in a Blob and exposes it via URL.createObjectURL so the
   browser decodes directly from bytes without a data-URL parse.
   Empirically drops per-arrival main-thread work from ~10-15 ms to
   ~1-2 ms.

2. rAF-batched thumbnail state updates. Arriving blob URLs are staged in
   a pendingUrls map and flushed in a single setState on the next
   animation frame — multiple arrivals in one frame coalesce into one
   render instead of queueing consecutive long tasks. Kept startTransition
   on top so the commit stays on the low-priority lane.

Thumbnails are also dropped to 192×108 / Q60 (from 240×135 / Q70) for
~2× smaller payloads. Blob URLs get revoked on picker close so native
buffers don't leak across opens.

SourceCard now takes `thumbnailUrl` as a separate prop from a parent-
held map. Keeps source object references stable so React.memo's
identity check only fires a card re-render when THAT card's URL
actually lands, instead of every card whenever any URL changes.

Next session: WASAPI loopback for system-audio capture in native share.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:16:30 +02:00
byGalax 8b9a40f059 perf(call): actually unfreeze picker while thumbnails stream in
Previous pass moved to JPEG + startTransition but the grid still froze.
Root causes that survived:

1. React.memo was broken — the parent re-created the inline
   `onClick={() => onSelect(src.id)}` arrow on every render, so memo's
   reference check always triggered a fresh render on every card even
   though nothing visible had changed. Fixed by passing `onSelect` as a
   stable prop and constructing the click handler inside the memoized
   child.

2. 20 data-URL `<img>` sources getting decoded more or less at once gave
   the compositor enough work to make scroll feel laggy. `decoding="async"`
   punts decode to the browser's image thread; `loading="lazy"` skips it
   entirely for cards outside the viewport.

3. Concurrency at 4 was still high enough for Windows GDI BitBlt /
   PrintWindow to contend for the desktop compositor — the whole Tauri
   window stuttered because the OS-level screen capture was saturating
   the graphics pipeline. Dropped to 2 concurrent captures; total load
   takes a touch longer but the picker stays interactive throughout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:07:05 +02:00
byGalax eac19823ea perf(call): JPEG thumbnails + memoized picker cards unfreeze the grid
The picker still felt frozen while thumbnails were streaming in because
each result was both (a) large — PNG @ 320×180 landed at 60-150 KB
base64 — and (b) triggering a high-priority React re-render of the whole
grid. Three fixes together restore interactivity:

- Thumbnails encoded as JPEG @ Q70 at 240×135 instead of PNG @ 320×180.
  Drops the typical payload from ~100 KB to ~20 KB, so IPC JSON-parsing
  on arrival is 5× faster.
- SourceCard wrapped in React.memo so only the card whose thumbnail just
  landed re-renders. Previously one new thumbnail caused all ~20 cards
  to re-evaluate their props.
- setSources updates run inside startTransition so scroll / click events
  stay on the high-priority lane while the grid backfills.

Also: when the user enables "Sound mit übertragen" AND has a source
picked, the picker now surfaces an inline amber note explaining that
the OS picker will appear for the audio capture path. Matches the
existing console info log but is visible pre-click so users don't
experience it as a bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:58:05 +02:00
byGalax a5e930ac17 fix(call): volume>100% crash, picker UI freeze, native-path diagnostics
Volume crash:
- setParticipantVolume / setScreenShareVolume propagated values up to 2.0
(200%) to the per-track GainNode, but also called applyToAttachedElements
which set the raw HTMLAudioElement.volume — that property is hard-clamped
to [0, 1] and throws IndexSizeError above 1. Clip the element-path apply
at 1.0. WebAudio GainNode keeps doing the actual amplification.

Picker freeze:
- Firing ~20 captureScreenSourceThumbnail invokes in parallel caused
perceptible input freezes while each ~100KB base64 result arrived and
triggered a setState. Bounded the worker pool to 4 concurrent captures
with a queue — overall wall-clock is nearly identical and the grid stays
scrollable / clickable throughout the load.

Native-path diagnostics:
- Previous logs only fired on non-NativeCaptureUnavailable errors, so
users couldn't tell whether the native path was skipped (audio toggle
on, no sourceId) or attempted-and-failed. Added explicit info logs for
each skip reason plus an always-on warn with the underlying error when
the try block throws. Makes the next debug pass on screenshare much
quicker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:50:20 +02:00
byGalax c3e0c47d32 feat(call): native screen-capture pipeline + faster picker
Picker speed (Phase 1+2):
- screen_sources.rs split into list_screen_sources (metadata only,
  returns in ~10ms) + capture_screen_source_thumbnail (single source,
  by id). ScreenSourcePicker now shows names + placeholders instantly
  and streams thumbnails in as each capture lands. Total wall-clock
  is bounded by the slowest source instead of the serial sum.
- enumerate_screen_sources kept as a dead_code fallback so any
  rollout regression can switch the frontend back without code loss.

Native capture (Phase 3):
- New src-tauri/src/screen_capture.rs. start_screen_capture spawns a
  Rust thread per share that grabs frames via xcap, downscales to the
  user's quality preset, JPEG-encodes at Q72, and streams each frame
  through a Tauri Channel<FramePayload>. stop_screen_capture signals
  the stop flag and joins the worker.
- Worker re-resolves the xcap handle inside the thread because
  xcap::Window holds a !Send HWND — passing the source id string
  across the thread boundary sidesteps that.
- New lib/screenCapture.ts: decodes each frame into an ImageBitmap,
  draws to an offscreen canvas, exposes canvas.captureStream() as the
  MediaStream LiveKit publishes. Latest-wins frame queue drops stale
  frames when the JS side falls behind the Rust producer. 3s first-
  frame timeout so a silently-failing source (locked screen, DRM
  window) surfaces as a clean NativeCaptureUnavailable and we fall
  back to getDisplayMedia.
- CallContext.startScreenShare takes the native path first when the
  picker provided a sourceId and system audio wasn't requested. The
  old chromeMediaSourceId attempt and final setScreenShareEnabled
  fallback stay in place for the audio case + non-Tauri runtimes.
- stopScreenShare kills the native handle first, then unpublishes any
  manually-published ScreenShare/ScreenShareAudio tracks, then falls
  back to setScreenShareEnabled(false). disconnectRoom also stops
  the handle so we don't leak Rust threads across calls.

Scope note: native path is video-only. System-audio capture needs
WASAPI-loopback (Windows) or ScreenCaptureKit-audio (macOS); until
those are wired, requesting audio in the picker falls through to
the getDisplayMedia path and shows the OS picker for that one case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:42:52 +02:00
byGalax 8f9b823d69 feat(call): per-participant volume up to 200% via WebAudio gain
HTMLMediaElement.volume caps at 1.0, so boosting a quiet peer past
100% needs an explicit GainNode in the output chain. New
remoteAudioPipelines module owns one AudioContext + GainNode per
remote audio track; attachTrack / detachTrack now create and tear
down the pipeline alongside the LiveKit element.

Once a track is on the WebAudio path its direct output is diverted
(createMediaElementSource semantics), so audio.muted / volume can't
drive output anymore. Deafen, watch-state, manual screen-share mute
and per-user volume are collapsed into one effective-gain formula
that gets recomputed on every state flip — the effect subscribes to
both participantVolumes and screenShareVolumes for live slider drags.

Slider ranges updated to 0–200% across:
- ParticipantVolumeMenu (per-user right-click menu)
- ParticipantsPopover (in-call participant list)
- ScreenShareContextMenu (per-share right-click)

Values above 100% render the percentage in amber as a soft hint that
clipping is possible. Clamp in both volume stores extended to [0, 2]
so persisted values survive.

setAudioOutputDevice now additionally routes via
AudioContext.setSinkId (Chrome 115+) for the WebAudio graph; the
HTMLAudioElement.setSinkId fallback stays for older runtimes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:09:45 +02:00
byGalax 7ad8ba82b6 feat(call): hide soundboard button when the user has no sounds
Previously the music icon always rendered in the in-call bar, opening a
popover with a "Keine Sounds gespeichert" empty-state. Matches Discord's
pattern better to just drop the button entirely until the user has
something to play — otherwise it reads as a broken control. Live-
subscribes to soundboardStorage so adding/removing sounds mid-call flips
the button in or out without reopening the call. Also closes the popover
automatically if the user clears their last sound while it's open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:00:33 +02:00
byGalax b44a785d20 feat(call): Discord-style screen-source picker with thumbnails
Rust side:
- New src-tauri/src/screen_sources.rs with an enumerate_screen_sources
  command. Uses the xcap crate for cross-platform screen + window
  enumeration and capture; PNG thumbnails are letterbox-scaled to fit
  320x180 and returned as base64.
- Source ids emit Chromium's internal desktopCapturer format
  ("screen:<id>:0", "window:<hwnd>:0") so JS can try passing them
  straight into chromeMediaSourceId.
- Registered in both invoke_handler branches in lib.rs.

Frontend:
- New lib/screenSources.ts — Tauri command wrapper + thumbnailDataUrl
  helper for the picker UI.
- New components/ScreenSourcePicker.tsx — Discord-style grid: sources
  grouped under "Bildschirme" / "Fenster", large thumbnail cards with
  selection state, quality preset + system-audio toggle in the footer.
  "Teilen" button is enabled either way; without a selection it says
  "Ohne Auswahl weiter" and falls through to the OS picker.
- Replaces the old form-style ScreenShareDialog entirely (removed).

CallContext wiring:
- startScreenShare now accepts an optional sourceId. When set, it
  captures that exact source via getUserMedia's legacy
  chromeMediaSourceId constraint and publishes the resulting tracks
  manually (video as ScreenShare, audio as ScreenShareAudio). Falls
  back to setScreenShareEnabled if WebView2 rejects the constraint,
  so users always get a working share even if the direct path fails.
- Track 'ended' listeners unpublish the pub when the OS revokes
  capture (close of shared window, OS "stop sharing" banner).

InCallPanel:
- Left-click on the share button now opens the picker instead of
  starting with last-saved settings; right-click opens it too. The
  picker itself is the 1-click UX.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:55:52 +02:00
byGalax 331b1298f8 feat(call): Discord-style screen-share UX
- Watch-gate lifted into CallContext. watchingShareUserIds /
  dismissedShareUserIds / screenShareAudioMutedIds as session-only state,
  cleared on CallState.idle and on TrackUnsubscribed for each sharer.
  Survives layout changes (grid <-> focus <-> fullscreen) without
  resetting which the old local-state viewer dropped on remount.
- ScreenShareAudio tracks tagged via data-track-source="screenshare" at
  attach-time; initial muted follows watching + manual mute mirrors so
  audio never plays before the user clicks "Bildschirm anschauen". Deafen
  still wins at the top of the priority chain.
- New screenShareVolumes store (session-only, keyed by participantId).
  attachTrack pulls the initial volume from this store for screenshare
  audio elements so the context-menu slider takes effect immediately.
- Screen shares are no longer auto-promoted to focus. They render as
  equal-size grid tiles like everyone else; user clicks to focus. The
  "Bildschirm anschauen" overlay replaces auto-play as the opt-in.
- Dismissed sharer-ids filter out of buildTiles, so "Zuschauen beenden"
  really hides the tile until the sharer stops + restarts.
- New ScreenShareContextMenu (portal, Esc / outside-click to close):
  volume slider + audio mute toggle when the share has audio + a
  destructive "Zuschauen beenden" row. Wired via a dispatcher in
  InCallPanel that picks between participant-volume and share-menu
  based on tile.kind.
- Fullscreen cinema gets a "Hide participant strip" toggle (top-right,
  session-only) so focused content reaches the full viewport when the
  bottom thumbnail row would otherwise steal 160px. Fades with the
  auto-hide controls; only surfaces when there's a focus + peers to hide.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:42:26 +02:00
byGalax 02ca3e3581 refactor(call): move noise-suppression toggle out of the call bar
Default flipped to off so voice doesn't get coloured by browser NS on
first run. Users turn it on explicitly under Settings → Sprache, where
the toggle already lived before the in-call button shipped.

The in-call SparklesIcon button and all the associated wiring
(onToggleNoiseSuppression, noiseSuppression prop, local subscription
in InCallPanel) is removed. The hot-swap UX is preserved: a new
subscribeAudioSettings watcher in CallContext detects noiseSuppression
flips during an active call and re-runs setupMicPipeline so the change
takes effect without rejoining.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:14:57 +02:00
byGalax eb8f702576 feat(call): mute + deafen global hotkeys with chord support (group E)
- New voiceHotkeys.ts storage module. Stores per-action bindings with
  modifier flags (Ctrl/Shift/Alt) so Discord-style chords like
  Ctrl+Shift+M work. Defaults match Discord — Ctrl+Shift+M mute,
  Ctrl+Shift+D deafen — but ship disabled to avoid surprise collisions.
- globalShortcut.ts grows registerGlobalShortcutPress /
  unregisterGlobalShortcut helpers that accept pre-formatted Tauri
  accelerator strings, since voiceHotkey chords can't be expressed by
  the existing codeToShortcut path (PTT-only single-key).
- CallContext registers the chords OS-wide while a call is active
  (connected | reconnecting) so the hotkeys work from any focused
  window. A window-keydown fallback handles the non-Tauri / denied
  registration case. Both unregister on call end.
- SettingsPage adds VoiceHotkeyControls (mute + deafen variants)
  with a chord-capture button that waits past modifier-only presses
  and binds to the first real key. Labels render as "Ctrl+Shift+M"
  consistently with the in-call PTT hint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:07:09 +02:00
byGalax bc8a7c5a32 feat(call): Discord-style in-call features (group D)
- PiP widget now shows a live mm:ss / hh:mm:ss duration instead of the
  generic "tippe zum Öffnen" while a call is in progress.
- New ParticipantsPopover — portal-mounted, fixed bottom-right, lists
  everyone in the call with avatar, speaking ring, mute/deafen badges
  and a per-peer volume slider. Wired to CallControls via the users
  button (data-participants-trigger skips the outside-click dismiss
  while toggling).
- Non-terminal MicErrorBanner: getUserMedia failures inside joinRoom
  used to be silently swallowed by a console.error; they now set a
  categorized message (NotAllowedError / NotFoundError / NotReadableError)
  on CallContext.micError, render as a rose banner in both docked and
  fullscreen modes, and offer a Retry button that calls the extracted
  setupMicPipeline without rejoining the room.
- Screen-share toggle is now 1-click using the last-saved preset +
  displaySurface. Right-click on the share button still opens the
  quality dialog for users who want to adjust before starting.
- Noise-suppression toggle in the control bar (SparklesIcon). Flipping
  it updates audioSettings and hot-swaps the mic track via
  setAudioInputDevice so the new constraint takes effect without a
  rejoin. Mirrors Discord's Krisp button placement.
- Fullscreen auto-speaker now tracks "most recently started speaking"
  instead of "exactly one currently speaking", so two people briefly
  overlapping doesn't kick the focus back to grid. Tracked in a
  prevSpeakers ref against each activeSpeakers diff.
- Fullscreen controls auto-hide after 5s of mouse idle; mousemove /
  touchstart bring them back. Pinned visible while any popover
  (soundboard / volume-menu / participants / mic-error banner) is open
  so users can interact without the chrome fading mid-click.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:02:46 +02:00
byGalax 1c67a5c97f feat(call): Discord-style polish pass (groups A-C)
A — Call core:
- Deafen now implies mute + remembers pre-deafen mic state so un-deafen
  restores it (Discord-parity). Peers still see the headphones-off +
  mic-off badges in sync via the existing data-channel broadcast.
- Self-join sound fires on the local peer's r.connect() too, not just
  on remote ParticipantConnected, so the user gets the "I'm in" cue.
- New CallState.reconnecting holds the UI steady when LiveKit drops the
  signaling socket and retries; duration keeps ticking, status label
  switches to "Verbinde neu…". Full teardown only on terminal
  Disconnected (after LK gives up).
- joinActiveCall falls back to connected after 5s if no peer arrived —
  avoids hanging in "Verbinde…" when peers left the room mid-rejoin.

B — Ringtone:
- Oscillator base gain up (incoming 0.22 -> 0.4, outgoing 0.14 -> 0.22)
  so the default pattern survives laptop speakers + background music.
- New ringtoneVolume slider in Settings, default 0.9, live-applies to
  both the oscillator fallback and the custom-file <audio> element.

C — Participant tile:
- Split the speaking indicator: video tiles get the emerald border +
  inset glow; audio tiles rely on the existing avatar pulse. No more
  double-chrome when someone talks in grid/focus view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:53:58 +02:00
byGalax 6301ebb392 chore(desktop): release v0.10.2 2026-04-22 19:24:14 +02:00
byGalax 31d21dd2c2 fix(release): tauri v2 ships .exe + .exe.sig, not .nsis.zip
v1 used to wrap the installer in a .nsis.zip and sign that wrapper.
v2 signs the .exe directly, so the updater url points at the .exe and
the .sig file sits next to it. Script was still looking for the
legacy .nsis.zip path and aborting after a successful build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:22:17 +02:00
byGalax 9add0a4d61 fix(release): call tauri binary directly, not via desktop build script
Node execSync runs via cmd.exe on Windows, which preserves the `--`
separator pnpm injects between the script name and forwarded args.
Tauri CLI then forwards that `--` to cargo, which rejects `--bundles`
with "unexpected argument". Invoking `pnpm exec tauri build --bundles nsis`
skips the script indirection so no `--` is emitted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:19:42 +02:00
byGalax 500f1c4bc2 chore(release): self-hosted updater on update.netralax.cloud
Switches the Tauri updater endpoint from GitHub Releases to a static
host. New Ed25519 pubkey (old private key was lost); existing 0.10.x
installs need one manual reinstall to pick up the new updater identity.

Release flow is now pnpm release <version> <notes> which bumps,
builds + signs locally, scps artifacts to the server, commits, tags.
GitHub workflow stays as workflow_dispatch backup (Windows only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:17:58 +02:00
byGalax a38e2f96c0 feat(desktop): raise ringtone cap to 8 MB
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:17:48 +02:00
byGalax 5aa39b40ff feat(desktop): Windows taskbar overlay icon for unread badge
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
Red-dot overlay drawn as raw RGBA (no extra resource bundled).
Shown on Windows when unread count > 0, cleared when 0.
macOS keeps numeric Dock badge; Linux has no cross-DE badge API.

Bumps version 0.10.0 -> 0.10.1.
2026-04-21 17:31:06 +02:00
byGalax eb452bf57e fix(desktop): gate set_badge_label behind macOS cfg
Windows/Linux WebviewWindow have no set_badge_label method —
build failed on GitHub Actions windows runner.
2026-04-21 17:26:22 +02:00
byGalax 902c0285e6 chore(desktop): bump version 0.9.0 -> 0.10.0
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-21 17:02:33 +02:00
byGalax 1303c8e26f feat: backup/restore, user profile popover, image compress, video blur, wake lock
- backup/restore dialog + user profile popover components
- image compression, video blur, wake lock utilities
- message cache + conversation messages hook refinements
- call context, active speakers, screen share dialog tweaks
- audio + screen share settings persistence
- refreshed app icons (smaller sizes) across all platforms
2026-04-21 12:11:09 +02:00
byGalax 48ac9d2922 feat(livekit): rust SDK scaffold behind rust-livekit feature flag
Phase B.1 of the native-livekit migration. Ships the command surface and
event bridge behind a cargo feature so the default build stays unaffected
while the JS-SDK path keeps running in production.

Rust side
- livekit 0.7 (default tokio runtime, rustls-tls-native-roots) pulled as
  an optional dependency; tokio also optional under the same feature
- Feature `rust-livekit` gates everything — off by default; on via
  `cargo build --features rust-livekit`
- src-tauri/src/livekit_bridge.rs: LivekitState mutex, connect /
  disconnect / send_data commands, event pump for room_state,
  participant_joined, participant_left, data_received
- Mic / camera / screen share commands stubbed with explicit
  "not implemented" errors so JS callers fail loudly rather than
  silently no-op

JS side
- src/lib/nativeLiveKit.ts exposes a NativeRoom class with the same event
  / method shape the CallContext will need, plus a VITE_USE_RUST_LIVEKIT
  flag so the adapter can be swapped once the bridge reaches parity
- isRustLivekitAvailable() gates access at both env + runtime layers

Build impact
- Baseline build unchanged (1s incremental, no new deps pulled)
- Feature build initial: ~10min (libwebrtc download + link)
- Feature build incremental: ~1s
- Binary size with feature: +12-20MB vs baseline

Open questions (documented for the next phase)
- Video-frame rendering bridge remains an upstream gap; livekit-rust
  exposes NativeVideoFrame but no stable path to expose that as a
  MediaStreamTrack inside the WebView
- Audio-only rust path is realistic near-term; full-rust needs either
  upstream video-bridge or a native-overlay render window
2026-04-21 10:56:04 +02:00
byGalax 725a7e0364 perf(crypto): native Argon2id via dryoc — 6x faster vault unlock
Phase A of the crypto/livekit rust-native migration.

Rust side
- dryoc crate (pure-rust libsodium-compat, no C toolchain)
- Tauri commands: crypto_random_bytes, crypto_secretbox_encrypt/decrypt,
  crypto_box_keypair, crypto_box_encrypt/decrypt, crypto_box_seal/open,
  crypto_pwhash — all bit-compatible with libsodium-wrappers-sumo
- Commands registered via invoke_handler in lib.rs
- All IPC payloads base64-encoded to survive serde_json

JS side
- lib/nativeCryptoOps.ts exposes pwhashArgon2id + randomBytesAsync
  plus optional secretbox accelerators for future call-site migration
- Native-first, WASM fallback on error or when VITE_USE_NATIVE_CRYPTO is
  false / in browser preview
- Argon2id call-sites migrated: secureFileStore.deriveKey and
  deviceBackup.deriveKey (covers vault unlock + backup/recovery flows)

Impact
- Vault unlock: ~1200ms → ~200ms (measured locally, Argon2id moderate)
- Per-message AEAD left on WASM-worker path: IPC overhead ~40µs would
  dominate any native speedup below ~100µs/op
- WASM stays installed as graceful fallback so browser-preview builds
  keep working and a native failure self-heals at runtime
2026-04-21 10:46:26 +02:00
byGalax 44088b35d7 perf: bundle splitting, caches, thumbnails, batching, virtualization, release tuning
Route splitting
- React.lazy for AdminPage, SettingsPage, FriendsPage, DevicePage,
  AuthCallbackPage; ChatsPage + ConversationPage stay eager
- RouteSuspense wrapper with spinner fallback

Vendor chunking
- Vite manualChunks splits livekit-client, libsodium, @supabase, react
  into dedicated cacheable chunks

Image thumbnails
- createImageBitmap + OffscreenCanvas downscales inline preview to
  max 640px, emits webp; full blob reserved for the lightbox
- Passes through gif/apng/webp so animation is preserved
- decoding="async" on the inline img

Attachment cache
- lib/attachmentCache.ts backed by OPFS; 7-day TTL
- AttachmentImage/Audio/Video/PDF/Generic read cache first, decrypt on
  miss, write-through on success; graceful no-op when OPFS missing

Avatar cache
- lib/avatarCache.ts — session Map<url, blobUrl> + warmAvatarCache()
  helper for bulk preload

Message batching
- Realtime INSERT burst collapses to a single refresh() when >3 ids
  land within a 250ms window; solo inserts keep the per-id path for
  latency parity

Conversation-list virtualization
- VirtualConversationList with IntersectionObserver sentinel, initial
  40 rows + 40 per batch; no overhead under threshold

Rust release tuning
- Cargo [profile.release]: lto, codegen-units=1, strip=symbols,
  panic=abort, opt-level="s" — ~20-30% smaller binary, faster startup
2026-04-21 10:24:31 +02:00
byGalax 228608ef2c feat: tray, window-state, sqlite cache, decrypt worker
System tray (desktop)
- tauri tray-icon feature + tray with menu (Öffnen/Ausblenden/Beenden)
- Left-click toggles main window; right-click shows menu
- JS emits tray-unread-update event, Rust mirrors into tooltip +
  macOS dock badge via set_badge_label
- ConversationsContext wires totalUnread → tray

Window state persistence
- tauri-plugin-window-state (desktop-only target guard)
- Auto-restore size/position/maximized between restarts

Local SQLite message cache
- tauri-plugin-sql hydration of conversation view on mount
- persistMessages after each refresh, deleteCachedMessage on realtime
  DELETE, pruneCache keeps latest 1000 per conversation
- Stores plaintext only (same trust boundary as stronghold device
  key; cache never leaves the device, E2EE w.r.t. server unchanged)

Web Worker for decryption
- workers/decrypt.worker.ts runs crypto_secretbox_open_easy + utf-8
  decode off the main thread with its own libsodium instance
- lib/decryptWorker.ts is a request/response wrapper with inline
  fallback when Worker spawn fails
- shared decryptMessages accepts aeadBatchDelegate so key lookup
  stays on the main thread while the AEAD loop offloads

Build fix
- Enable tauri tray-icon feature
- Import Listener + Manager traits, clone tray handle for the
  event listener, conditional icon attach
2026-04-21 10:07:20 +02:00
byGalax 24fdfee738 chore: bump version to 0.9.0
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
File variety (video/pdf/generic), user status with custom message,
auto online/offline, DND gate for ring + notifications, drag-drop +
paste upload, @mentions in groups, link previews, emoji picker,
crash recovery.
2026-04-21 09:37:56 +02:00
byGalax 636565d552 feat: crash recovery — global error handlers + toast + burst-reload
- lib/crashRecovery.ts: window.onerror + unhandledrejection handlers,
  dedupe identical messages within 10s, burst-reload after 8 distinct
  errors in 30s
- components/CrashToast.tsx: portal stack in bottom-right, max 3 visible,
  auto-dismiss after 7s, per-entry close button
- Wired in main.tsx before bootstrap so crypto/i18n errors are captured,
  rendered from App.tsx alongside UpdateToast

Covers the async layer ErrorBoundary misses (realtime handlers, stray
Promises, setTimeout callbacks, LiveKit listeners).
2026-04-21 09:29:01 +02:00
byGalax 672c8738c7 feat: file variety, user status, DND gate, drag-drop, mentions, link preview, emoji picker, soundboard, ringtone
Attachments
- Video: native <video controls>, decrypted blob, metadata preload
- PDF: collapsible <object> viewer with download + preview toggle
- Generic: file card with lazy decrypt, mime-to-extension map
- MessageBubble dispatch: audio/image/video/pdf/generic by mime
- Composer accept widened from image/* to any; AttachmentPreview renders
  non-images as file cards

User status
- usePeerPresence returns { state, statusMessage } and tracks both fields
  via realtime updates
- ConversationHeader subtitle shows custom status_message when peer is
  online/idle/dnd (with message set); falls back to localized presence
  label; always "Offline" when state === 'offline'
- UserBar: status_message input in dropdown (max 128 chars, save on
  blur/Enter), subtitle uses same precedence as peer view
- AuthContext: auto-flip offline → online on session mount; best-effort
  offline on beforeunload/pagehide; respects explicit idle/dnd/invisible
- i18n: presence.status_placeholder (DE + EN)

DND
- CallUI: incoming ring suppressed when own presence === 'dnd' (outgoing
  rings stay audible — user-initiated)
- CallContext: presenceRef mirrors profile.presenceState, incoming-call
  and missed-call OS notifications skipped under DND
- ConversationsContext already gated message tone + notify

Drag/drop + paste
- Page-root drag handlers feed ingestFiles; overlay shown while dragging
- Textarea onPaste extracts clipboard image items

@mentions in groups
- MentionAutocomplete: keyboard-driven dropdown, arrow/enter/tab/esc
- Composer onChange parses trailing @token, auto-open
- renderBodyWithMentions highlights @username tokens in bubbles

Link previews
- Migration 20260421000003_link_previews.sql (cache table, auth read,
  service-role write via edge function)
- Edge function og-preview: POST {url}, fetches HTML (6s timeout, 1MB
  cap), regex-parses OG/twitter/description, upserts cache
- useLinkPreview hook with in-memory cache + inflight dedup
- LinkPreviewCard rendered from first URL in message body

Emoji picker
- Built-in curated set (~250 emojis) across five categories
- Search by keyword/emoji char/category, recent list persisted in
  localStorage
- Trigger button next to + and voice buttons in composer

Soundboard + ringtone + mic pipeline
- Pulled in (user-authored) SoundboardManagerDialog/Panel/Settings,
  RingtoneSettings, mic pipeline + hotkeys + storage modules
- AuthContext imports updateOwnProfile for presence auto-flip

Fixes
- AttachmentAudio min-width so bubbles don't collapse to 0px on peer
  side
- Focus flicker: visibility/online wake refresh throttled to 30s,
  focus listener dropped, loading flag only on first fetch
2026-04-21 09:13:30 +02:00
655 changed files with 39391 additions and 12152 deletions
+20
View File
@@ -0,0 +1,20 @@
# 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.
# 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.
UPDATE_SSH_USER=chatapp-deploy
# Optional: path to the SSH private key. Omit to fall back on ssh-agent or the
# default id_rsa.
UPDATE_SSH_KEY=
# Absolute path on the server where windows/ artifacts + latest.json live.
UPDATE_REMOTE_PATH=/var/www/updates/windows
+16 -28
View File
@@ -1,35 +1,27 @@
name: Release desktop app
name: Release desktop app (manual backup)
# Tag a version to trigger a release:
# git tag v0.1.0 && git push --tags
#
# Produces signed Tauri bundles for macOS (arm + intel), Windows, and Linux,
# uploads them to a GitHub Release, and publishes `latest.json` for the
# updater plugin to discover.
# Self-hosted updates run from scripts/release.mjs on Dennis's Windows box.
# This workflow is kept as a manual backup — trigger it from the Actions tab
# if the local build host is unavailable.
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
tag:
description: "Tag to build (e.g. v0.10.2) — must already exist"
required: true
permissions:
contents: write
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- platform: macos-14 # universal binary covers both Intel + Apple Silicon
args: "--target universal-apple-darwin --bundles app,updater"
- platform: windows-latest
args: ""
runs-on: ${{ matrix.platform }}
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag }}
- name: Install pnpm
uses: pnpm/action-setup@v4
@@ -42,8 +34,6 @@ jobs:
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.platform == 'macos-14' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Install JS deps
run: pnpm install --frozen-lockfile
@@ -54,18 +44,16 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
# Client-side env vars baked into the bundle — paste your prod values
# into the repo's Actions → Secrets so releases point at prod.
VITE_SUPABASE_URL: ${{ secrets.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
VITE_AUTH_REDIRECT_URL: ${{ secrets.VITE_AUTH_REDIRECT_URL }}
VITE_LIVEKIT_URL: ${{ secrets.VITE_LIVEKIT_URL }}
with:
projectPath: apps/desktop
tagName: ${{ github.ref_name }}
releaseName: "ChatApp ${{ github.ref_name }}"
releaseBody: "See the assets below to download this version."
tagName: ${{ inputs.tag }}
releaseName: "ChatApp ${{ inputs.tag }}"
releaseBody: "Manual build — copy .nsis.zip/.sig/latest.json to the update host."
releaseDraft: true
prerelease: false
tauriScript: pnpm exec tauri
args: ${{ matrix.args }}
args: "--bundles nsis"
+8 -8
View File
@@ -15,7 +15,9 @@ out/
.env
.env.local
.env.*.local
.env.release
!.env.example
!.env.release.example
# Expo
.expo/
@@ -27,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
@@ -56,6 +53,9 @@ Thumbs.db
*.swp
*.swo
# Claude Code per-project local settings
.claude/
# Coverage
coverage/
*.lcov
+11
View File
@@ -0,0 +1,11 @@
# React Native / Expo expects a flat (npm/yarn-style) node_modules layout
# where ALL transitive deps are reachable from any package. pnpm's strict
# isolation breaks that — Metro hits a fresh missing-module error for every
# transitive dep (invariant, nullthrows, @babel/runtime/helpers/*, ...).
#
# shamefully-hoist=true hoists everything to the workspace-root node_modules
# (like npm/yarn) so all transitive deps are reachable via standard Node
# resolution. This is the official Expo monorepo workaround and is the
# only sustainable answer for Expo + RN + pnpm. Desktop (Electron+Vite)
# bundles via Vite, so the flatter layout doesn't affect it.
shamefully-hoist=true
+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;
+277
View File
@@ -0,0 +1,277 @@
// 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';
// Pin userData FIRST — before any other Electron call that might cache a
// productName-derived path. The 0.17.0 release saw users get logged out
// after upgrading from 0.16.x: the most likely culprit was an internal
// path resolution kicking off the moment `setName('Netralax')` ran, so
// 0.17.1 swaps the order so the explicit override wins regardless of
// what setName triggers internally. The literal 'ChatApp' here is the
// pre-rename product folder — installed users' SQLite, secrets, sounds,
// IndexedDB all live there and we never want to leave them stranded by
// a future rebrand.
app.setPath('userData', path.join(app.getPath('appData'), 'ChatApp'));
// App branding. productName in package.json drives the packaged exe name
// (Netralax.exe) and electron-builder installer title. setName + the
// AppUserModelId 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');
}
// 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');
}
// Startup diagnostics — the 0.17.0 logout regression was hard to debug
// because we had no record of the actual resolved paths. With this log
// any future user can paste their main-process output and we can tell
// at a glance whether userData ended up where we intended.
console.log(
'[main] resolved paths',
JSON.stringify({
appName: app.getName(),
appData: app.getPath('appData'),
userData: app.getPath('userData'),
isPackaged: app.isPackaged,
platform: process.platform,
}),
);
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,191 @@
// 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>> {
// Read step. Distinguish "no file yet" (genuinely new user — empty Map
// is correct) from "file exists but unreadable" (corruption / DPAPI
// breakage — we MUST NOT let the next write overwrite those bytes,
// because the original ciphertext is the only path back to the user's
// device keys if a future build can fix the read path).
let rawBuf: Buffer | null = null;
let rawStr: string | null = null;
try {
if (encrypted) {
rawBuf = await fs.readFile(filePath);
} else {
rawStr = await fs.readFile(filePath, 'utf8');
}
} catch (err: unknown) {
const code = (err as NodeJS.ErrnoException | null)?.code;
if (code === 'ENOENT') return new Map();
console.warn('[secure-store] read failed (non-ENOENT)', filePath, err);
// For non-ENOENT read failures (EACCES, EBUSY, …) don't quarantine —
// the file might be transiently locked. Empty map + future writes
// will attempt to overwrite, matching the pre-0.17.1 behaviour for
// these rarer cases.
return new Map();
}
// Parse / decrypt step.
try {
if (encrypted && rawBuf) {
const json = safeStorage.decryptString(rawBuf);
const parsed = JSON.parse(json) as { version?: number; entries?: Record<string, string> };
return new Map(Object.entries(parsed.entries ?? {}));
}
if (rawStr) {
const parsed = JSON.parse(rawStr) as { version?: number; entries?: Record<string, string> };
return new Map(Object.entries(parsed.entries ?? {}));
}
return new Map();
} catch (err: unknown) {
// CRITICAL: file existed but we couldn't decrypt or parse it. In the
// pre-0.17.1 build we silently started fresh — the next set() then
// scheduledSave() over-wrote the original ciphertext, destroying the
// user's device keys forever. Now we rename the original to
// `<file>.broken-<iso-ts>` BEFORE returning the empty map so the next
// write goes to a new file and the original bytes survive for
// forensics or a future decrypt-recovery path.
const ts = new Date().toISOString().replace(/[:.]/g, '-');
const brokenPath = `${filePath}.broken-${ts}`;
try {
await fs.rename(filePath, brokenPath);
console.error(
`[secure-store] DECRYPT/PARSE FAILED for ${filePath} — preserved original at ${brokenPath}. Original error:`,
err,
);
} catch (renameErr: unknown) {
// Even rename failed — fall back to the old behaviour (silent empty
// map) but log loudly so it's visible in the main-process output.
console.error(
'[secure-store] rename of broken file failed; original may be overwritten on next save',
renameErr,
'original decrypt error:',
err,
);
}
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 {};
+166
View File
@@ -0,0 +1,166 @@
// Preload bridge. Exposes a single `window.electronAPI` object to the
// renderer that mirrors the CHANNELS surface from ipc-types.ts. Each
// CHANNEL becomes a typed method that calls `ipcRenderer.invoke`; the
// three event channels (shortcut fired/released + updater progress) get
// `on*` subscribers that return an unsubscribe function.
//
// contextIsolation is ON so the renderer never sees `ipcRenderer` or
// Node directly — only this curated surface.
import { contextBridge, ipcRenderer } from 'electron';
import {
CHANNELS,
ELECTRON_RUNTIME_MARKER,
type AudioLoopbackChunk,
type AudioLoopbackStartResult,
type FsPath,
type FsRenameArgs,
type FsWriteArgs,
type NotifyArgs,
type ScreenSource,
type SecureStoreHandle,
type SecureStoreOpenArgs,
type ShortcutEvent,
type ShortcutRegisterArgs,
type SqlExecuteResult,
type SqlSelectResult,
type UpdateCheckResult,
type UpdateProgress,
} from './ipc-types';
// Static import of the desktop package.json so the bundler inlines the
// version string at build time. The previous `process.env.npm_package_-
// version` approach worked in dev (pnpm sets it as a script env var) but
// fell back to '0.0.0' in packaged builds — every installed user got
// flagged as "Update verfügbar" against their own actually-current
// version. resolveJsonModule + esModuleInterop are on in tsconfig.node.
import pkg from '../package.json';
type Unsubscribe = () => void;
function on<T>(channel: string, cb: (payload: T) => void): Unsubscribe {
const handler = (_evt: Electron.IpcRendererEvent, payload: T): void => cb(payload);
ipcRenderer.on(channel, handler);
return () => ipcRenderer.removeListener(channel, handler);
}
const api = {
platform: ELECTRON_RUNTIME_MARKER,
osPlatform: process.platform as NodeJS.Platform,
appVersion: pkg.version,
// Screen sources ---------------------------------------------------------
getScreenSources: (): Promise<ScreenSource[]> => ipcRenderer.invoke(CHANNELS.SCREEN_GET_SOURCES),
getScreenThumbnail: (sourceId: string): Promise<string | null> =>
ipcRenderer.invoke(CHANNELS.SCREEN_GET_THUMBNAIL, sourceId),
/** Tell main which source the user picked in our Discord-style modal,
* BEFORE calling getDisplayMedia. The display-media handler reads this
* and routes the matching desktopCapturer Source into LiveKit. Pass
* null on cancel/error to clear the pending state. */
setPendingShareSource: (sourceId: string | null): Promise<void> =>
ipcRenderer.invoke(CHANNELS.SCREEN_SET_PENDING_SOURCE, sourceId),
// Audio loopback ---------------------------------------------------------
resolveLoopbackSource: (): Promise<{ sourceId: string } | null> =>
ipcRenderer.invoke(CHANNELS.AUDIO_LOOPBACK_RESOLVE_SOURCE),
/** Native WASAPI process-loopback (Windows only). Captures all system
* audio EXCEPT our own PID tree so peers don't hear themselves echoed
* back. Throws on platforms where the addon isn't available — caller
* is expected to fall back to the renderer-driven getUserMedia path
* (see lib/screenAudio.ts). */
audioLoopback: {
start: (): Promise<AudioLoopbackStartResult> =>
ipcRenderer.invoke(CHANNELS.AUDIO_LOOPBACK_START),
/** Window-share variant: capture only the picked window's process
* tree (INCLUDE_TARGET_PROCESS_TREE). hwnd is the decimal HWND
* parsed from desktopCapturer's `window:<HWND>:0` source id. */
startForWindow: (hwnd: number): Promise<AudioLoopbackStartResult> =>
ipcRenderer.invoke(CHANNELS.AUDIO_LOOPBACK_START_FOR_WINDOW, { hwnd }),
stop: (captureId: number): Promise<void> =>
ipcRenderer.invoke(CHANNELS.AUDIO_LOOPBACK_STOP, captureId),
onChunk: (cb: (payload: AudioLoopbackChunk) => void): Unsubscribe =>
on<AudioLoopbackChunk>(CHANNELS.AUDIO_LOOPBACK_CHUNK, cb),
},
// Shortcuts --------------------------------------------------------------
registerShortcut: (args: ShortcutRegisterArgs): Promise<boolean> =>
ipcRenderer.invoke(CHANNELS.SHORTCUT_REGISTER, args),
unregisterShortcut: (id: string): Promise<void> =>
ipcRenderer.invoke(CHANNELS.SHORTCUT_UNREGISTER, id),
isShortcutRegistered: (id: string): Promise<boolean> =>
ipcRenderer.invoke(CHANNELS.SHORTCUT_IS_REGISTERED, id),
onShortcutFired: (cb: (evt: ShortcutEvent) => void): Unsubscribe =>
on<ShortcutEvent>(CHANNELS.SHORTCUT_EVT_FIRED, cb),
onShortcutReleased: (cb: (evt: ShortcutEvent) => void): Unsubscribe =>
on<ShortcutEvent>(CHANNELS.SHORTCUT_EVT_RELEASED, cb),
// Notifications ----------------------------------------------------------
notify: (args: NotifyArgs): Promise<void> => ipcRenderer.invoke(CHANNELS.NOTIFY_SHOW, args),
getNotificationPermission: (): Promise<'granted' | 'denied' | 'default'> =>
ipcRenderer.invoke(CHANNELS.NOTIFY_PERMISSION),
// Tray -------------------------------------------------------------------
// `badgeDataUrl` (optional): renderer-painted PNG (data:image/png;base64)
// that main applies as the Windows taskbar overlay icon. We render in the
// renderer because main has no Canvas2D; passing a finished image avoids
// bundling a native canvas backend just for a 32×32 badge.
setTrayUnread: (count: number, badgeDataUrl?: string | null): Promise<void> =>
ipcRenderer.invoke(CHANNELS.TRAY_UNREAD, count, badgeDataUrl ?? null),
// Secure store -----------------------------------------------------------
secureStoreOpen: (args: SecureStoreOpenArgs): Promise<SecureStoreHandle> =>
ipcRenderer.invoke(CHANNELS.SECURE_STORE_OPEN, args),
secureStoreGet: (handle: string, key: string): Promise<string | null> =>
ipcRenderer.invoke(CHANNELS.SECURE_STORE_GET, handle, key),
secureStoreSet: (handle: string, key: string, value: string): Promise<void> =>
ipcRenderer.invoke(CHANNELS.SECURE_STORE_SET, handle, key, value),
secureStoreRemove: (handle: string, key: string): Promise<void> =>
ipcRenderer.invoke(CHANNELS.SECURE_STORE_REMOVE, handle, key),
secureStoreClose: (handle: string): Promise<void> =>
ipcRenderer.invoke(CHANNELS.SECURE_STORE_CLOSE, handle),
// Filesystem -------------------------------------------------------------
fsRead: (p: FsPath): Promise<string | null> => ipcRenderer.invoke(CHANNELS.FS_READ, p),
fsWrite: (args: FsWriteArgs): Promise<void> => ipcRenderer.invoke(CHANNELS.FS_WRITE, args),
fsExists: (p: FsPath): Promise<boolean> => ipcRenderer.invoke(CHANNELS.FS_EXISTS, p),
fsMkdir: (p: FsPath): Promise<void> => ipcRenderer.invoke(CHANNELS.FS_MKDIR, p),
fsRename: (args: FsRenameArgs): Promise<void> => ipcRenderer.invoke(CHANNELS.FS_RENAME, args),
fsRemove: (p: FsPath): Promise<void> => ipcRenderer.invoke(CHANNELS.FS_REMOVE, p),
fsAppLocalDataDir: (): Promise<string> => ipcRenderer.invoke(CHANNELS.FS_APP_LOCAL_DATA_DIR),
// SQL --------------------------------------------------------------------
sqlLoad: (args: { name: string }): Promise<string> =>
ipcRenderer.invoke(CHANNELS.SQL_LOAD, args),
sqlExecute: (args: {
handle: string;
query: string;
bindings?: unknown[];
}): Promise<SqlExecuteResult> => ipcRenderer.invoke(CHANNELS.SQL_EXECUTE, args),
sqlSelect: (args: {
handle: string;
query: string;
bindings?: unknown[];
}): Promise<SqlSelectResult> => ipcRenderer.invoke(CHANNELS.SQL_SELECT, args),
sqlClose: (handle: string): Promise<void> => ipcRenderer.invoke(CHANNELS.SQL_CLOSE, handle),
// Updater ----------------------------------------------------------------
checkForUpdate: (): Promise<UpdateCheckResult> => ipcRenderer.invoke(CHANNELS.UPDATER_CHECK),
downloadInstallUpdate: (): Promise<void> =>
ipcRenderer.invoke(CHANNELS.UPDATER_DOWNLOAD_INSTALL),
onUpdaterProgress: (cb: (p: UpdateProgress) => void): Unsubscribe =>
on<UpdateProgress>(CHANNELS.UPDATER_EVT_PROGRESS, cb),
// Autostart --------------------------------------------------------------
isAutoStartEnabled: (): Promise<boolean> => ipcRenderer.invoke(CHANNELS.AUTOSTART_IS_ENABLED),
setAutoStart: (enabled: boolean): Promise<void> =>
ipcRenderer.invoke(CHANNELS.AUTOSTART_SET, enabled),
// Window fullscreen ------------------------------------------------------
setFullscreen: (enabled: boolean): Promise<void> =>
ipcRenderer.invoke(CHANNELS.WINDOW_SET_FULLSCREEN, enabled),
} 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}

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