Compare commits

...

370 Commits

Author SHA1 Message Date
byGalax f438018400 chore(desktop): release v0.21.11 2026-06-02 23:07:34 +02:00
byGalax 89003f71a4 fix(desktop): remove chat-switch reveal flicker (decouple from reactions)
The residual flicker on chat switch was a loading/reveal artifact, not scroll.
listReady gated the MessageList reveal on reactionsReady OR a 300ms timeout, so
on a cache-hit switch (messages already present from the first render) the list
sat at opacity:0 for up to 300ms and then popped in. Drop the reactions/timeout
gate: reveal as soon as messages exist. Reaction chips stream in a beat later;
because the list is pinned to the bottom their height growth re-pins with no
visible jump, and MessageList still defers its own reveal a few frames until the
row-height measurement settles so it appears already at the final bottom.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:02:07 +02:00
byGalax b364c53c61 fix(desktop): degrade missing avatar image to letter circle
Avatar rendered a bare <img> with no error handling, so an avatar_url whose
storage object is unreachable (e.g. a 404 after the server move) showed a
broken image instead of the coloured letter-circle fallback. Track an onError
flag and fall back to the circle; reset it when the URL changes so a fresh
valid avatar is retried. This is client-side resilience only — it does not
restore a genuinely missing storage object.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:02:07 +02:00
byGalax 9c5456b492 chore(desktop): release v0.21.10 2026-06-02 22:32:52 +02:00
byGalax b057795735 fix(desktop): stop chat opening at top + flicker on switch
Make stick-to-bottom intent the single source of truth in MessageList and
drive onAtBottomChange from intent, not raw scroll position. A measurement
reflow can no longer flip the intent off (RC1), the second scrollPositions
writer no longer persists a drifting topmost index while stuck (RC2), and a
pin-on-rows layout effect re-pins through the two-phase data swap (RC3).

- scrollController: add tested nextStickIntent() state machine
- MessageList: input-event-based unstick (wheel/key/touch + scrollbar drag),
  reveal after 2 stable frames, tabIndex for keyboard nav, remove debug overlay
- ConversationPage: reuse resolveInitialAnchor; harden handleRangeChanged

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 22:31:39 +02:00
byGalax c81a036c4e chore(desktop): release v0.21.9 2026-06-02 21:48:43 +02:00
byGalax bdc017e609 fix(desktop): MessageList sticks to bottom via ResizeObserver + scroll guard
Data from the on-screen overlay showed SCROLLABLE=YES but scrollTop=84/0 and atBottom=false: the initial pin happened before rows finished measuring, then a measurement reflow fired onScroll with the stale (top) scrollTop, flipping atBottom=false and disabling re-pinning, so the list never reached the bottom. Fix: a ResizeObserver re-pins to the true bottom as the content measures/grows; a programmatic-scroll guard makes onScroll ignore the scrolls we cause (so measurement reflows no longer flip the stick intent); overflow-anchor:none so the browser doesn't fight us; reveal waits for the height to settle. Overlay kept for one more verification pass.
2026-06-02 21:47:15 +02:00
byGalax 27160145f9 chore(desktop): release v0.21.8 2026-06-02 21:37:29 +02:00
byGalax 8ea2cb48e9 debug(desktop): on-screen scroll-metrics overlay (temporary) 2026-06-02 21:34:04 +02:00
byGalax c3ef995404 chore(desktop): release v0.21.7 2026-06-02 21:04:37 +02:00
byGalax b4ed3aced0 fix(desktop): MessageList anchors via direct scrollTop + flex-1 height
scrollToIndex raced the virtualizer's own layout effect and depended on size estimates, leaving the list pinned at the top on open (and flickering as it settled). Drive scrollTop = scrollHeight directly for the bottom case (order-independent, true bottom) and re-pin on measure; switch the scroll root from h-full to flex-1 min-h-0 so it always has a bounded, scrollable height.
2026-06-02 21:00:19 +02:00
byGalax 30d00194be chore(desktop): release v0.21.6 2026-06-02 20:46:53 +02:00
byGalax 31b394a6e0 chore(desktop): drop react-virtuoso + scroll debug instrumentation 2026-06-02 20:44:56 +02:00
byGalax 271d6fff5c feat(desktop): use MessageList in ConversationPage (replace react-virtuoso) 2026-06-02 20:32:08 +02:00
byGalax 8b8d71bc4d feat(desktop): TanStack-Virtual MessageList with deferred reveal 2026-06-02 20:27:31 +02:00
byGalax 40f36cb182 refactor(desktop): export VirtuosoRow type for MessageList 2026-06-02 20:26:21 +02:00
byGalax 2372731504 feat(desktop): expose reactions reveal-gate flag (ready) 2026-06-02 20:26:00 +02:00
byGalax 43a99a8d6d feat(desktop): pure scroll-decision logic for new message list 2026-06-02 20:25:06 +02:00
byGalax f73abbd860 build(desktop): add @tanstack/react-virtual 2026-06-02 20:24:15 +02:00
byGalax e822f6f58f docs(plan): message-list / scroll rewrite implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 20:22:28 +02:00
byGalax 255dbdc712 docs(spec): message-list / scroll rewrite design (TanStack Virtual + deferred reveal)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 20:16:42 +02:00
byGalax 51a8630114 chore(desktop): release v0.21.5 2026-06-02 19:41:46 +02:00
byGalax d539656535 fix(conversation): anchor message list to bottom on chat switch
initialTopMostItemIndex was a plain index (top-aligned), so react-virtuoso painted with estimated row heights then corrected scrollTop after measuring the real (taller) dynamic bubbles — a visible jump on every chat switch. Use { index: 'LAST', align: 'end' } to pin the bottom edge instead, matching react-virtuoso's canonical chat pattern; the restore-to-saved-row path stays align: 'start'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 19:39:04 +02:00
byGalax 5bc30c950c feat(infra): migrate self-hosted backend to netralax.de
Move Supabase + LiveKit from the netralax.cloud VPS to a new netralax.de server. Adds the migration runbook (docs/), one-time move scripts (scripts/migrate/), and prod Caddy/LiveKit config templates (infra/). Repoints the desktop publish/changelog URLs and prod ops config to .de. JWT_SECRET + VAPID copied identically so already-installed clients keep working; the new server also serves the legacy .cloud hostnames.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 19:39:04 +02:00
byGalax 588b843904 chore(desktop): release v0.21.4 2026-05-21 23:12:38 +02:00
byGalax dbf8030e93 fix(conv-key): rotate-instead-of-share + cache invalidation; fire-first realtime inserts (no sound-vs-text gap)
Friend-DM "Nachricht nicht lesbar" recurred even after v0.21.3 because the
proactive sweep called shareConvKeyToUser, which reads the module-level
conv-key cache first. After a server-side cleanup the cache still held the
stale locally-bootstrapped key, so each side wrapped its own different key
for the peer and the bundles diverged anew.

Switch the sweep to rotate_conv_key when any peer's user-id bundle is
missing at the active version: a fresh symmetric key is generated, wrapped
for every member at their CURRENT pubkey, and the active version is bumped
under a row-level FOR UPDATE lock. Concurrent rotations are race-safe — the
loser sees "new version must be greater" and bails; the winner's bundles
propagate via realtime.

Realtime conversation_keys subscription now invalidates the cache for the
affected (conversationId, key_version) on any INSERT/UPDATE/DELETE — so
admin cleanups, peer rotations, or device wraps can no longer leave a
stale entry in this client's session cache.

queueInsert now fires the first event of a quiet period immediately and
only collapses follow-up bursts. BATCH_WINDOW_MS dropped 250 → 80 ms.
This closes the ~250 ms gap between the notification sound and the
message body appearing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:10:36 +02:00
byGalax 615770722e chore(desktop): release v0.21.3 2026-05-21 22:52:50 +02:00
byGalax f1cba99b9e fix(conv-key): bootstrap re-fetches canonical key after share to handle concurrent race 2026-05-21 22:51:17 +02:00
byGalax f60c5c676a chore(desktop): release v0.21.2 2026-05-18 15:40:56 +02:00
byGalax 8e6be3256d fix(conv-key): rotate on unwrap failure (post-reset_user_key recovery) 2026-05-18 15:38:31 +02:00
byGalax 508c53b451 chore(desktop): release v0.21.1 2026-05-18 14:40:29 +02:00
byGalax e2f86bc377 fix(chat): snap to bottom after send to mask composer-shrink layout shift 2026-05-18 14:29:31 +02:00
byGalax 92a6e01a26 fix(chat): instant scroll + larger at-bottom threshold + footer spacer (Discord-clean) 2026-05-18 14:21:04 +02:00
byGalax 3d959aaadf fix(chat): auto-rotate stuck conv-keys on chat open (receive-side recovery) 2026-05-18 14:03:35 +02:00
byGalax 787437c3f1 chore(desktop): release v0.21.0 2026-05-17 20:04:00 +02:00
byGalax be5647281d chore(mobile): track expo-generated .gitignore 2026-05-17 20:00:18 +02:00
byGalax fc8fc275cb fix(soundboard): mount-once hotkey registration to avoid call-state churn 2026-05-17 17:49:08 +02:00
byGalax e19a71e892 feat(profile): preserve animation on GIF/APNG/animated-WebP avatar uploads
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 17:38:07 +02:00
byGalax 11869be443 feat(soundboard): hotkeys fire outside calls with local-only playback
Lifts the `state.kind === 'connected'` guard from the soundboard hotkey
useEffect so OS-level shortcuts are always registered. Inside a call the
existing `playSoundboard` path routes audio into the LiveKit pipeline so
peers hear; outside a call the new `playSoundboardLocal` helper fetches
the blob via `getSoundBlob`, creates a short-lived object URL, and plays
through a fresh HTMLAudioElement on the system default output.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 17:33:50 +02:00
byGalax 3fa8b6dbc1 feat(call): shared annotation overlay on screen share 2026-05-17 17:30:05 +02:00
byGalax f9f8e3fdb8 feat(whiteboard): live cursors via broadcast channel 2026-05-17 17:24:34 +02:00
byGalax 4ed3f04300 fix(view-once): hold-to-view pattern + content-protection during reveal 2026-05-17 17:21:22 +02:00
byGalax a7ffcbff83 feat(voice): playback-speed toggle (1x/1.5x/2x) with per-user default 2026-05-17 17:09:01 +02:00
byGalax 82600915f1 feat(composer): persist text + reply target per chat across restarts 2026-05-17 17:06:43 +02:00
byGalax 93098a74ca docs(phase8): feature batch implementation plan 2026-05-17 16:54:56 +02:00
byGalax c8f0e8efd5 chore(desktop): release v0.20.1 2026-05-17 15:11:11 +02:00
byGalax fd9b8a88d6 docs(chat-switch): implementation plan for chat-switch flicker fix 2026-05-17 15:09:43 +02:00
byGalax ab2f7130fe fix(chat-switch): seed lastPendingCountRef from outbox to suppress mount scroll 2026-05-17 15:02:40 +02:00
byGalax b9a3dde1aa refactor(chat-switch): drop redundant id-change reset effect 2026-05-17 14:54:31 +02:00
byGalax 65d2446804 fix(chat-switch): remount ConversationPage per conversation id 2026-05-17 14:51:22 +02:00
byGalax faa12a4ebb feat(chat-switch): hydrate useConversationMessages from in-memory cache
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 14:48:48 +02:00
byGalax 37dd1b4f23 feat(chat-switch): in-memory message cache helper 2026-05-17 14:39:01 +02:00
byGalax d803773261 chore(desktop): release v0.20.0 2026-05-17 13:21:50 +02:00
byGalax 49855c5d3f fix(console-noise): pre-warm via auth.getSession + demote stuck crypto-migration logs to debug 2026-05-17 01:43:53 +02:00
byGalax c9a64bf898 feat(call): remove live-captions feature (privacy-inconsistent with E2E, unused) 2026-05-17 01:39:42 +02:00
byGalax 7f704e80f6 feat(P7.T4): per-attachment view-once toggle on AttachmentPreview 2026-05-17 01:25:08 +02:00
byGalax 940432d287 feat(P7.T3): composer toolbar — 5 inline buttons + popover for Bild/Poll/Aktivitäten 2026-05-17 01:18:32 +02:00
byGalax 28b6d64936 feat(P7.T2): ComposerActionsMenu popover (Bild/Datei + Umfrage + Aktivitäten group)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 01:14:34 +02:00
byGalax 92baa626d6 docs(P7): Phase 7 composer redesign plan (Hybrid: + menu + per-attachment view-once) 2026-05-17 01:11:04 +02:00
byGalax cd7ef8dccc fix(P6): AttachmentImage Lightbox blob URL — split unmount-only revoke, lock deps to handle.id 2026-05-17 01:09:09 +02:00
byGalax 58efc66ca7 fix(P4A): guard ImageAnnotator load callbacks with cancelled flag (React strict double-mount) 2026-05-17 01:03:37 +02:00
byGalax 1e139fb86e fix(P4A): ImageAnnotator effect-race — stabilize onCancel via ref so URL.revoke doesn't fire mid-decode 2026-05-17 01:01:27 +02:00
byGalax eeb713f03d fix(P6): convert positional bindings to named-params object for better-sqlite3 2026-05-17 00:59:39 +02:00
byGalax 837b5a326e fix(P6): hoist savedPositionRef above initialTopMostIndex (TDZ) + auto-open DevTools in dev 2026-05-17 00:56:34 +02:00
byGalax b1f37752d6 perf(P6C.T12): optimistic UI for mute / mentions-only / archive / pin / device-revoke
Audit of write-actions revealed that send (and edit via realtime UPDATE) are
already optimistic via local state insertion in `useConversationMessages`,
and friend nicknames are pure-local localStorage. Five user-write actions
were waiting on the ~100-200 ms server roundtrip + realtime echo:

* Toggle mute (`setConversationMutedUntil`)
* Toggle mentions-only (`setConversationMentionsOnly`)
* Toggle archive (`setConversationArchived`)
* Pin / unpin message (`pinMessage` / `unpinMessage`)
* Revoke device (`revokeDevice` RPC)

All five now flip local state synchronously and roll back on failure. The
existing realtime subscriptions reconcile canonically (no-op when the
optimistic patch already matches the server row), so this is purely a UX
latency improvement — no protocol or persistence changes.

Reactions (`toggleReaction` / `voteExclusive`) were intentionally skipped
this round: rollback semantics for the exclusive-vote path with multiple
sequential awaits are messy enough to warrant a dedicated pass.
2026-05-17 00:44:28 +02:00
byGalax 854c4b91a8 perf(P6C.T11): bundle audit + opt-in visualizer for future audits
Audited the renderer bundle with rollup-plugin-visualizer. Top offenders
(libsodium-sumo 292KB gz, livekit-client 177KB gz, @supabase 149KB gz)
all have justified usage and no viable swap. Mediapipe + track-processors
are already lazy-loaded into a separate chunk on first BackgroundBlur
activation. Bundle has zero duplicate packages, no moment/lodash/dayjs,
no syntax-highlight libs, no polyfills — already lean from T1+T6.

Added rollup-plugin-visualizer as a dev-dep, gated behind ANALYZE=true
so production builds pay no cost. Run with:
  ANALYZE=true pnpm --filter @chat-app/desktop build
to regenerate stats.html (treemap) + stats.json (raw) for future audits.
2026-05-17 00:35:41 +02:00
byGalax d3b708636f perf(P6B.T8): virtualize message list with react-virtuoso
Switches the ConversationPage chat list from a full O(N) render to
windowed rendering via react-virtuoso. On long histories only the
visible rows (plus a 400px overscan buffer) live in the DOM, ending the
scroll jank and layout thrashing that hit conversations with >500
messages.

Preserved behaviors:
- Newest message visible on open via initialTopMostItemIndex.
- Auto-scroll on send via a pending-count-based effect (the old
  setStickToBottom + useLayoutEffect pattern doesn't apply now that
  Virtuoso owns the scroll element).
- Realtime auto-follow only when scrolled to bottom (followOutput).
- 'New messages while away' counter + 'jump to newest' pill via
  atBottomStateChange.
- Pinned-message / reply / search jumps via virtuosoRef.scrollToIndex;
  expands displayCount on the fly if the target is outside the
  rendered slice. Flash highlight unchanged.
- Load-older infinite scroll via Virtuoso startReached (replaces the
  IntersectionObserver-on-sentinel pattern).
- Per-conversation position memory now keys on row index instead of
  pixel scrollTop (the latter isn't meaningful under virtualization).

Also wires the PinnedMessagesPanel onJump callback (previously a TODO
that just closed the panel) into jumpToMessage, since virtualization
made the smooth-scroll-from-pinned UX easy to deliver as a side
benefit.
2026-05-17 00:20:41 +02:00
byGalax c449943b52 perf(P6B.T7): WebP thumbnails for image attachments (320px max, thumb-first render) 2026-05-17 00:12:03 +02:00
byGalax db59e3f658 perf(P6B.T6): offload Argon2 + userKey unseal to Web Worker
PIN-unlock used to freeze the renderer for ~1-2 s on mid-hardware while
the moderate-preset Argon2id KDF + sealed-key secretbox open ran on the
main thread. Push that work into a Vite-bundled ESM Web Worker so the
unlock screen stays responsive.

The worker (apps/desktop/src/workers/crypto.worker.ts) bundles its own
libsodium-wrappers-sumo instance and registers a fresh CryptoBackend
inside the worker realm. Client wrapper (apps/desktop/src/lib/cryptoWorker.ts)
spawns a one-shot worker per unlock — workers are cheap, PIN-unlock is
once-per-session, and one-shot avoids the request-id bookkeeping that the
existing decrypt.worker needs for high-volume per-message decrypts.

Falls back to inline main-thread openUserKey when the Worker constructor
is unavailable (vitest's jsdom) or when worker spawn / round-trip fails
(strict CSP). All 14 desktop + 71 shared tests still pass — the existing
loadOrUnlockUserKey test exercises the inline-fallback branch.

Private key bytes are transferred (zero-copy) back to the main thread,
detaching the worker-side ArrayBuffer view on transfer.

Build emits crypto.worker-<hash>.js (~2.5 MB, mostly libsodium WASM glue
duplicated from the main bundle). Acceptable trade-off for the unblocked
UI; a future change could lazy-load libsodium on the main thread to drop
the duplication.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 00:05:00 +02:00
byGalax 6c6828006b feat(P6B.T9): PIN-Idle-Auto-Lock setting + idle watcher
Adds opt-in (default OFF) auto-lock: after X minutes of no user input
the app calls signOut() (full memory wipe + PIN re-entry on next open).
Settings dropdown (Aus / 5 / 15 / 30 / 60 min) lives in SecurityCenter
below the existing wipe-on-close toggle. The idle timer is mounted in
AppShell via useIdleAutoLock; activity events are throttled to 1 Hz to
avoid timer thrash on rapid mouse movement. The localStorage key is
added to PRESERVE_LOCAL_STORAGE so a wipe never silently disables the
feature.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 23:59:27 +02:00
byGalax 21376daf39 perf(P6B.T10): i18next tree-shake — prune dead locale keys
Audited all four i18next namespaces (common, auth, errors, app) against
static t() call-site grep across the entire desktop source tree.
Pruned 40 dead keys per locale, 80 total lines removed across 6 files.

Dead keys removed:
- app: call.{e2ee_active_hint,still_live,voice_connected},
       chats.{show_archived,show_active}, admin.nav,
       friends.confirm_unfriend, settings.{danger_zone,this_device,
       presence,section_ringtone}
- auth: signed_in.{title,session_active,user_id,admin,yes,no,sign_out,
        device_active,device_platform,device_registered_at},
        entire device.* section (old device-registration UI)
- common: loading, cancel, retry, online, offline, idle, dnd, invisible

Dynamic-key patterns were found and respected: t('errors:'+code)
keeps all errors keys; t('app:presence.'+val) keeps all presence keys;
t('app:annotator.tool.'+id) uses defaultValue so its locale entries
were not required.
2026-05-16 23:55:04 +02:00
byGalax 6d0e4fb1f0 perf(P6A.T5): pre-warm Supabase + avatar loading hints
Fire a no-await profiles query in AuthContext on session establish to absorb
cold-connection latency before the first user-triggered request. Add
loading='lazy' default to the central Avatar component so all off-screen
avatars (chat list, friends list, popovers, message senders) skip eager
Supabase Storage fetches; set loading='eager' on ConversationHeader (active
conv header) and CallParticipantTile inline imgs (both AudioContent and
VideoStub) which are always above-the-fold when visible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 23:43:04 +02:00
byGalax 36ab7eca8a perf(P6A.T4): wrap all icon components in React.memo
All 57 exported SVG icon components in icons.tsx are now memoised via
React.memo, giving React permission to skip re-renders when props are
referentially equal.  Consumer icon-prop types updated from the legacy
SVGProps (includes string refs) to ComponentPropsWithoutRef<'svg'> so
the MemoExoticComponent return type satisfies TypeScript without casts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:39:47 +02:00
byGalax ddd696a790 perf(P6A.T3): memoize MessageBubble + stabilize parent callbacks 2026-05-16 23:30:50 +02:00
byGalax 53b3b5e1fc perf(P6A.T2): respect prefers-reduced-motion globally + gate confetti
- Update globals.css reduced-motion block: 0.01ms → 0.001ms durations
  and add scroll-behavior: auto to suppress all transitions/animations
  when OS reduced-motion preference is active.
- Gate canvas-confetti burst in GameModal behind matchMedia check so
  the particle effect is skipped entirely for users who opt out of motion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 23:25:01 +02:00
byGalax a4573b315d perf(P6A.T1): lazy-load ImageAnnotator/Whiteboard/WatchTogether/GameModal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 23:22:52 +02:00
byGalax c271e95100 docs(P6): Phase 6 performance pack plan (Groups A/B/C + deferred channel pooling) 2026-05-16 23:16:42 +02:00
byGalax 888ed1b217 feat(P5C.T4): confetti burst on game win
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 22:26:25 +02:00
byGalax a974e5b8ab feat(P5C.T3): recompute message_mentions on edit
After the ciphertext UPDATE succeeds in editEncryptedMessage, delete the
existing message_mentions rows for that message then re-resolve and
re-insert from the new plaintext — matching the best-effort pattern used
by insertMessage. No signature change needed; conversationId and
newPlaintext were already present in EditMessageParams.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 22:24:17 +02:00
byGalax 7ebc5f6c9d feat(P5C.T2): per-conv 'mentions only' toggle + notification gate 2026-05-16 22:22:04 +02:00
byGalax b8b451ef4f feat(P5C.T2-sql): conversation_members.mentions_only column 2026-05-16 22:17:04 +02:00
byGalax d7c0c3d0a2 feat(P5C.T1): empty-state for empty chat-list search results 2026-05-16 22:14:33 +02:00
byGalax 70ce824120 docs(P5C): Phase 5C polish plan (4 spec sub-items) 2026-05-16 22:12:27 +02:00
byGalax d10840e0b2 feat(P5B.T6): composer Spielen button + bubble dispatch + game-picker
Wires mini-game entry points: createGamePayload helper in conversationFeatures.ts (GamePayload import added), game state/handler/picker-dialog/modal-mount/open-event-listener in ConversationPage.tsx (DM-only guard uses conversation.members[].userId camelCase as confirmed), and parsed.kind==='game' bubble branch in MessageBubble.tsx dispatching chatapp:open-game CustomEvent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:58:10 +02:00
byGalax cd59ee30d6 feat(P5B.T5): GameModal — picks board + turn/winner status 2026-05-16 21:53:42 +02:00
byGalax e56e22631b feat(P5B.T4): TicTacToeBoard + ConnectFourBoard render-only components 2026-05-16 21:51:06 +02:00
byGalax dbf90504b4 feat(P5B.T3): useGame hook with realtime + makeMove 2026-05-16 21:49:00 +02:00
byGalax 256a613134 feat(P5B.T2): GamePayload + games wrappers + winner-detect helpers + tests
Adds conversation_games table type + game_make_move RPC to db-types, GamePayload variant and parseMessagePayload branch to shared/attachments, games.ts with createGame/getGame/makeGameMove wrappers plus pure tttWinningLine/c4WinningCells/c4DropRow/isBoardFull helpers, 11 tests covering all winner helpers, and re-export from chat/index.ts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:47:36 +02:00
byGalax e1423dba32 feat(P5B.T1): conversation_games table + game_make_move RPC + RLS + realtime 2026-05-16 21:42:47 +02:00
byGalax 7730828403 docs(P5B): Phase 5B implementation plan (Mini-Games) 2026-05-16 21:39:12 +02:00
byGalax ecbd11e369 feat(P5A.T5): composer Watch-Together button + bubble dispatch 2026-05-16 21:30:22 +02:00
byGalax 7d5f3b37cd feat(P5A.T4): WatchTogetherModal — YouTube IFrame + drift reconcile
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:26:59 +02:00
byGalax 7ce6b1c1d4 feat(P5A.T3): useWatchSession hook with realtime + throttled push
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:23:39 +02:00
byGalax 4399d39f08 feat(P5A.T2): WatchTogetherPayload + wrappers + parseYouTubeUrl + tests 2026-05-16 21:21:30 +02:00
byGalax ad239ec549 feat(P5A.T1): conversation_watch_sessions table with RLS + realtime 2026-05-16 21:16:38 +02:00
byGalax d1193142ae docs(P5A): Phase 5A implementation plan (Watch-Together / YouTube) 2026-05-16 21:14:38 +02:00
byGalax 997252c4cb chore(desktop): release v0.19.1 2026-05-16 21:07:11 +02:00
byGalax 890d5dc2b7 feat(P4C.T4): SoundboardSettings — sync hook mount + per-row badges + remote-delete
Mount useSoundboardSync in SoundboardManagerDialog, thread badges map through
SoundboardCategoryGroup/SoundboardRow, render a cloud-state glyph badge inline
with each row's size/mime metadata, and wrap handleDelete to attempt a
best-effort remote delete via deleteRemoteSound before the local deleteSound call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:02:26 +02:00
byGalax 759113b9ce feat(P4C.T3): useSoundboardSync — initial diff + realtime + debounced push 2026-05-16 20:58:48 +02:00
byGalax f981904e7f feat(P4C.T2): shared soundboards wrappers + sealed-blob crypto + tests 2026-05-16 20:53:48 +02:00
byGalax 1fe196b839 feat(P4C.T1): user_soundboards table + soundboards storage bucket + RLS 2026-05-16 20:49:10 +02:00
byGalax 1b4cf63e07 docs(P4C): Phase 4C implementation plan (Soundboard Cloud-Sync, E2E encrypted) 2026-05-16 20:46:57 +02:00
byGalax 18197a9f95 feat(P4B.T7): MessageBubble renders whiteboard kind with Öffnen button
Adds a whiteboard branch to the MessageBubble ternary chain that renders
a card with an icon, label, and Öffnen button dispatching the
chatapp:open-whiteboard CustomEvent. ConversationPage now listens for
that event via a useEffect and calls setOpenWhiteboardId to open the
modal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 20:38:03 +02:00
byGalax 383245ec90 feat(P4B.T6): composer 'Whiteboard' button creates board + sends bubble 2026-05-16 20:36:03 +02:00
byGalax 5e59ee22f6 feat(P4B.T5): WhiteboardModal — fullscreen container + toolbar + clear-confirm 2026-05-16 20:29:24 +02:00
byGalax be6f5b9c6f feat(P4B.T4): WhiteboardCanvas — pen/eraser drawing engine 2026-05-16 20:27:09 +02:00
byGalax 2243c646ac feat(P4B.T3): useWhiteboardStrokes hook with INSERT + bulk DELETE realtime
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 20:25:22 +02:00
byGalax 3df7cc01ea feat(P4B.T2): WhiteboardPayload + shared whiteboards CRUD wrappers + tests
Adds WhiteboardPayload wire-format variant to MessagePayload/ParsedMessagePayload,
creates the whiteboards.ts CRUD wrapper (createWhiteboard, listWhiteboardStrokes,
insertWhiteboardStroke, clearWhiteboardStrokes), writes 5 unit tests (38/38 pass),
re-exports from chat/index.ts, and registers conversation_whiteboards +
whiteboard_strokes in packages/db-types so typecheck passes cleanly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 20:23:45 +02:00
byGalax f95858c703 feat(P4B.T1): whiteboards + whiteboard_strokes tables with RLS + realtime 2026-05-16 20:01:28 +02:00
byGalax d29e2c1174 docs(P4B): Phase 4B implementation plan (Whiteboard / Snapshot-Sync) 2026-05-16 19:59:10 +02:00
byGalax 11a9c8b173 feat(P4A.T4): wire ImageAnnotator into composer attachment preview
Hover an image attachment in the composer to reveal a pencil-edit button;
clicking it opens ImageAnnotator and Save replaces the File in attachments[].

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 19:48:57 +02:00
byGalax d623a59c87 feat(P4A.T3): annotator toolbar — tool/color/width/undo/redo controls 2026-05-16 19:46:00 +02:00
byGalax 2a2e334f51 feat(P4A.T2): annotator drawing engine — pen/arrow/rect/circle/text/highlighter
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 19:44:04 +02:00
byGalax 9228cc635c feat(P4A.T1): ImageAnnotator modal skeleton with canvas mount + op-stack types 2026-05-16 19:41:36 +02:00
byGalax 7056bfdd1c docs(P4A): Phase 4A implementation plan (Image Annotation vor Send) 2026-05-16 19:38:26 +02:00
byGalax e8a5d6dc5e chore(desktop): release v0.19.0 2026-05-16 19:25:55 +02:00
byGalax c5851195c7 fix(call): strip-toggle icon no longer touches its frame edges
Shrink the StripToggleIcon SVG from 18x18 to 14x14 px so the 2-people
icon sits centred with ~11 px of breathing room inside its 36x36 button,
matching the h-4/w-4 small-icon convention used elsewhere in InCallPanel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 19:21:26 +02:00
byGalax 258ad3511d fix(screen-share): preset dims no longer crop non-16:9 monitors
Pass width/height as { ideal: N } constraints (not exact values) so
Chromium preserves the source's aspect ratio instead of cropping
non-matching monitors. Previously a 1920x1200 monitor under a 1080p
preset lost the bottom 120px including the Windows taskbar.

The auto-preset fallback now omits width/height entirely (both set to
undefined) so getDisplayMedia uses the screen's native resolution.
Cast through `unknown` to satisfy LiveKit's VideoResolution type, which
declares width/height as plain number but Chromium accepts the full
MediaTrackConstraints shape at runtime.
2026-05-16 19:19:30 +02:00
byGalax 2583613d7f feat(P3.T7): RemoteRevokedScreen overlay for own-device revocation 2026-05-16 19:15:15 +02:00
byGalax 04d9909e92 feat(P3.T6): Settings 'Geräte' tab with revoke + this-device badge
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 19:13:31 +02:00
byGalax e05f5445e7 feat(P3.T5): useOwnDevices hook with realtime refresh
Adds useOwnDevices React hook that fetches the user device list via
listOwnDevices and re-fetches on any postgres_changes event on the
devices table, matching the channel/filter convention in AuthContext.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:10:48 +02:00
byGalax a4f7a16c90 feat(P3.T4): ensure device row + revoke-realtime + revokedRemotely flag 2026-05-16 19:08:17 +02:00
byGalax f6e0e4dd09 feat(P3.T3): app:hostname IPC for device-name default 2026-05-16 19:04:03 +02:00
byGalax d449e128a8 docs(hotfix): screen-share taskbar crop + strip-toggle icon padding plan 2026-05-16 18:59:59 +02:00
byGalax 5b0aa24a8d feat(P3.T2): DeviceRecord.revokedAt + revokeDevice wrapper
Extend DeviceRecord with revokedAt field, update registerDevice and
listOwnDevices selects to include revoked_at, add revokeDevice RPC
helper, update db-types to reflect T1 migration schema, and add
vitest coverage for all new behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 18:52:34 +02:00
byGalax 540b506f91 feat(P3.T1): devices.revoked_at + revoke_device RPC + realtime publication
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 18:48:22 +02:00
byGalax ede0d00f5b docs(P3): Phase 3 implementation plan (Session/Device List + Revoke) 2026-05-16 18:45:46 +02:00
byGalax ef1f9f45d8 fix(desktop): swap GIF provider from Tenor (closed Jan 2026) to GIPHY
Google closed Tenor v2 to new API clients in Jan 2026, so the only
people who could use the picker were those with a pre-existing Google
Cloud Console key. Swapped to GIPHY's Developer API (still open, free
keys at https://developers.giphy.com/dashboard/).

- Env var renamed VITE_TENOR_API_KEY → VITE_GIPHY_API_KEY
- Endpoint, response mapping, error sentinel updated
- File still named tenor.ts for import-path stability — renaming
  later if it bothers anyone
- Public GifResult interface unchanged so the picker UI didn't need
  edits beyond the error-message switch
2026-05-16 18:25:18 +02:00
byGalax 95156a65eb feat(desktop): view-once image attachments (sender toggle + recipient lightbox + tombstone)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:18:39 +02:00
byGalax 915569db39 feat(shared): mark_attachment_viewed + view_once on AttachmentHandle 2026-05-16 18:12:15 +02:00
byGalax 526f9d7bcc feat(db): message_attachments.view_once + mark_attachment_viewed RPC 2026-05-16 18:09:36 +02:00
byGalax cd79b77e6f feat(desktop): GIF button + picker wired into composer 2026-05-16 18:07:40 +02:00
byGalax 7726498218 feat(desktop): GIF picker popover (Tenor + trending/search/recent) 2026-05-16 18:05:45 +02:00
byGalax f1d9602a76 fix(desktop): read Tenor API key from VITE_TENOR_API_KEY env, no hardcoded credential 2026-05-16 18:03:20 +02:00
byGalax 45bcbb449c feat(desktop): Tenor v2 GIF search/featured/recent client 2026-05-16 18:02:01 +02:00
byGalax ef98efb938 feat(desktop): mention notifications via realtime + osNotify 2026-05-16 17:59:57 +02:00
byGalax 7355b343c8 feat(shared): sendEncryptedMessage inserts mention rows after the message row 2026-05-16 17:57:52 +02:00
byGalax 1a39c1bb33 feat(shared): parseMentionUsernames + insertMentions helpers 2026-05-16 17:55:20 +02:00
byGalax 70209be1de feat(db): message_mentions table (RLS: mentioned user + author can SELECT) 2026-05-16 17:52:40 +02:00
byGalax 3dbeabd268 feat(desktop): pin/unpin from message context menu + side panel 2026-05-16 17:51:01 +02:00
byGalax 6bff163f09 feat(desktop): pinned-messages side panel 2026-05-16 17:47:39 +02:00
byGalax ddeedeb71d feat(desktop): pinned-messages pill in conv header 2026-05-16 17:45:43 +02:00
byGalax 7e85ffe548 feat(desktop): usePinnedMessages live hook 2026-05-16 17:43:45 +02:00
byGalax 0d30a462b3 feat(shared): pinned-messages list/pin/unpin helpers 2026-05-16 17:42:09 +02:00
byGalax 745b8cd69d feat(db): pinned_messages table (≤5 per conv via trigger) 2026-05-16 17:39:42 +02:00
byGalax 5ace6735d8 docs(plan): phase 2 — messaging (pinned / mentions / GIF / view-once) 2026-05-16 17:36:21 +02:00
byGalax ffaa6ceb70 feat(mobile): SecurityCenter — PIN change, recovery, reset, migration retry 2026-05-16 17:23:04 +02:00
byGalax ccac822cb9 feat(mobile): settings hub with security section + signout 2026-05-16 17:21:57 +02:00
byGalax 74a0ef6c32 refactor(mobile): chats header opens settings instead of inline logout 2026-05-16 17:18:56 +02:00
byGalax 9de2c368bf feat(desktop): optional wipe-on-close (Settings -> Sicherheit) 2026-05-16 17:17:59 +02:00
byGalax 88345420e0 refactor(mobile): AttachmentImage drops device-keyed props 2026-05-16 17:17:37 +02:00
byGalax 0354676d2f refactor(mobile): MessageBubble drops ownDeviceId prop 2026-05-16 17:17:30 +02:00
byGalax cb7bd99fb4 refactor(mobile): conversation detail uses ownUserId + drops senderDeviceId 2026-05-16 17:13:47 +02:00
byGalax 3b55fcaf2c feat(mobile): (app) layout routes by userKeyState 2026-05-16 17:11:05 +02:00
byGalax 7ae1d5ba8c feat(mobile): UserKey unlock screen (PIN entry + recovery tab + reset) 2026-05-16 17:09:50 +02:00
byGalax 50bfb5b137 feat(desktop): wipe local crypto + caches on sign-out 2026-05-16 17:09:01 +02:00
byGalax 41359816f1 feat(mobile): UserKey setup screen (PIN + optional recovery code) 2026-05-16 17:08:47 +02:00
byGalax 8ad212291a feat(desktop): apply friend nicknames in header / bubble / mentions / call tile 2026-05-16 17:06:18 +02:00
byGalax 18a6365586 feat(mobile): PinInput component — 6-digit numeric pad 2026-05-16 17:04:25 +02:00
byGalax 898b7469bb feat(desktop): set-nickname dialog + right-click trigger in friends list 2026-05-16 17:01:38 +02:00
byGalax a5eadef663 feat(desktop): friendNicknames local store + useNickname hook 2026-05-16 16:58:14 +02:00
byGalax e8074e7da0 feat(desktop): empty-state for empty conversation 2026-05-16 16:56:27 +02:00
byGalax 88409aff5d feat(desktop): empty-state for friends list 2026-05-16 16:54:12 +02:00
byGalax b7d7a85253 feat(desktop): empty-state for chat list
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:51:37 +02:00
byGalax 502c6af1b8 feat(desktop): EmptyState primitive component 2026-05-16 16:49:16 +02:00
byGalax adc9686035 refactor(mobile): AuthProvider exposes userKeyState instead of device record 2026-05-16 16:47:55 +02:00
byGalax 472665b980 fix(desktop): tray badge — IPC handler died when icon.ico missing from packaged resources 2026-05-16 16:47:19 +02:00
byGalax c52c65faa9 feat(mobile): userIdentity orchestrator (setup/unlock/cache/change-PIN/reset) 2026-05-16 16:43:50 +02:00
byGalax 79f1786ac6 feat(desktop): Settings — 🌐 Global toggle per voice hotkey 2026-05-16 16:43:00 +02:00
byGalax ca77214e2d fix(desktop): voice hotkeys are window-scoped unless Global is toggled
The old code registered every enabled hotkey through Electron globalShortcut
API, which captures system-wide. Setting M as mute meant m could not be
typed in any other app. Now the OS-level registration only happens when
binding.global === true; otherwise the existing window-keydown listener
handles it.
2026-05-16 16:41:20 +02:00
byGalax 8c27e8eafa feat(mobile): legacyDeviceVault helper for per-device key probing 2026-05-16 16:41:18 +02:00
byGalax 69733168ed feat(desktop): add 'global' flag to voice hotkey binding (default false) 2026-05-16 16:38:51 +02:00
byGalax bf87add18d feat(mobile): extend cryptoBackend with pwhash + scalarMultBase 2026-05-16 16:37:09 +02:00
byGalax c871687fd2 refactor(desktop): derivePublicKey via CryptoBackend.scalarMultBase 2026-05-16 16:35:36 +02:00
byGalax 61a1eb37ea refactor(shared): route userKey through CryptoBackend (no libsodium-wrappers-sumo) 2026-05-16 16:33:50 +02:00
byGalax 9b9802ff6e docs(plan): phase 1 — quality & fixes (hotkey-bug, tray-audit, empty-states, nicknames, memory-wipe) 2026-05-16 16:33:38 +02:00
byGalax 2463081949 feat(shared): extend CryptoBackend with pwhash + scalarMultBase 2026-05-16 16:30:52 +02:00
byGalax 1eea80c529 chore(mobile): check:env script lints .env.local against .env.example 2026-05-16 16:27:21 +02:00
byGalax 830dac4cdd fix(mobile): defer crypto backend init into AppBootstrap (prevents white-screen) 2026-05-16 16:25:10 +02:00
byGalax cb46483f5d feat(mobile): AppBootstrap boundary — defers crypto init, catches global throws 2026-05-16 16:24:34 +02:00
byGalax 87fed820dc feat(mobile): BootSplash + BootError fallback views for AppBootstrap 2026-05-16 16:23:26 +02:00
byGalax fca5211008 docs(spec): fifteen-features initiative (0.19.0) — 5 phases, single end-of-batch release 2026-05-16 16:21:32 +02:00
byGalax c430a590fc fix(mobile): lazy env proxy so missing EXPO_PUBLIC vars throw inside React 2026-05-16 16:21:02 +02:00
byGalax 3b884b0415 docs(mobile): document EAS Secrets contract for EXPO_PUBLIC_* 2026-05-16 16:14:23 +02:00
byGalax affff0b433 docs(plan): mobile encryption port + Android white-screen RCA plans
Two implementation plans for the 2026-05-16 specs.

- Android white-screen: 12 tasks across 7 phases. Phase 0 wires EAS Secrets,
  Phase 1-2 ship the lazy env proxy + AppBootstrap boundary + global JS
  error handler, Phase 3-4 validate against a real APK, Phase 5 has
  conditional hypothesis-specific fixes, Phase 6-7 close out.

- Mobile encryption port: 24 tasks across 8 phases. Extends shared
  CryptoBackend with pwhash + scalarMultBase (the change that lets mobile
  stop loading libsodium-wrappers-sumo in Hermes), refactors desktop
  derivePublicKey through the same backend, mirrors the desktop
  userIdentity orchestrator and Auth flow on RN with new PinInput, setup,
  unlock, and security-settings screens, updates every device-keyed call
  site, and ends with a manual Android smoke list.

Each plan ships with a spec-coverage checklist and explicit out-of-scope
list. White-screen plan must land first; mobile-encryption plan depends
on AppBootstrap deferring crypto init.
2026-05-16 16:01:51 +02:00
byGalax a5c0889b0a docs: mobile encryption-UX port + Android white-screen RCA specs (2026-05-16)
Two specs from the 2026-05-16 brainstorming session:

- mobile encryption-UX port: bring apps/mobile to feature parity with
  desktop v0.18.x user-key/PIN identity. Includes the shared CryptoBackend
  extension (pwhash + scalarMultBase) that removes the libsodium-wrappers-sumo
  Hermes blocker.

- Android white-screen RCA: five ranked hypotheses, ordered diagnostic
  playbook (env-missing, newArch, module-eval crypto init, shared sodium
  side-effects, asset paths), plus defense-in-depth (lazy env proxy,
  AppBootstrap boundary, global JS error handler) that ships regardless
  of which hypothesis confirms.

User decisions resolved at the review gate:
- EAS Secrets for EXPO_PUBLIC_* (not eas.json env block).
- newArchEnabled: false acceptable as a temporary rollback if H2 confirms.
2026-05-16 15:46:40 +02:00
byGalax 90fa0a7e95 chore(desktop): release v0.18.8 2026-05-16 15:35:24 +02:00
byGalax 767db72847 fix(desktop): drop bright border on Settings SubSection cards
The inner cards (Nachrichten-Ton / Klingelton / Audio-Gerät / Hotkeys /
…) looked like they had a stark white outline. The cause: --color-line
is a semi-transparent white token, and applying the `/60` alpha modifier
in `border-line/60` overrides the original alpha — so the inner border
ended up brighter than the outer Section's normal `border-line`.

Drop the border entirely. Background tint + caps heading is enough
grouping signal in a tab-pattern panel.
2026-05-16 15:33:36 +02:00
byGalax 0288d7a476 chore(desktop): release v0.18.7 2026-05-16 15:30:14 +02:00
byGalax 34b972ec2a refactor(desktop): Settings = tab pattern instead of scroll + observer
The previous build used IntersectionObserver to highlight whichever
section was in view. With nine sections of unequal heights and smooth-
scroll firing observer callbacks mid-scroll, the active highlight
drifted (clicking 'Konto' showed 'Soundboard' as active because the
last section never crossed the observer's 20%-30% band).

Switched to a tab pattern (macOS System Settings / Discord / GitHub
style): the sidebar selects ONE section, only that section renders.
`useState<TabId>` is the single source of truth; no observer, no
scrolling between sections, no anchor links to clash with HashRouter.
Mobile fallback (<lg) gets a `<select>` dropdown above the panel.

Drops the now-unused `id` prop from Section and removes the
IntersectionObserver effect.
2026-05-16 15:28:03 +02:00
byGalax c55173800b chore(desktop): release v0.18.6 2026-05-16 01:13:54 +02:00
byGalax fc9f1ec143 fix(desktop): Settings sidebar redirected to /chats instead of scrolling
App uses HashRouter, so an <a href="#profile"> changes the routing
hash and the router can't find a match — it falls back to /chats.
Replace anchors with buttons that scroll the target section via
scrollIntoView and update the active highlight optimistically.
2026-05-16 01:12:50 +02:00
byGalax 12e4b597a0 chore(desktop): release v0.18.5 2026-05-16 01:09:54 +02:00
byGalax 5c05afb009 feat(desktop): redesign Settings + fix unreadable bubble text
Two UI fixes:

1. MessageBubble: 'Nachricht nicht lesbar' was rendered with
   text-fg-muted on the blue 'mine' bubble — invisible. Now uses
   text-accent-fg/80 on mine, text-fg-muted on peer (still
   ≥4.5:1 contrast in both modes).

2. SettingsPage: redesigned from a long single-column scroll into a
   sticky-sidebar + content layout (lg+) with:
   - 9 anchor-linked sections with icons in the sidebar
   - IntersectionObserver highlights the active section
   - Each section has a description subtitle for context
   - Voice (the densest section) is now sub-grouped into Audio-Gerät /
     Qualität / PTT / Hotkeys / E2EE via SubSection cards
   - Notifications consolidates message-sound + ringtone
   - Danger-toned account section visually separated
   - Mobile fallback is the original single-column scroll
2026-05-16 01:08:28 +02:00
byGalax a636a3c1c1 chore(desktop): release v0.18.4 2026-05-16 01:00:34 +02:00
byGalax c9fe4879e0 fix(shared): stop sending fake install-id as messages.sender_device_id
Task 12 (the AuthContext userKeyState refactor) replaced the per-device
DeviceRecord lookup with a localStorage UUID via ensureInstallId(). That
UUID was then passed straight through to messages.sender_device_id on
INSERT.

The messages_insert_member RLS policy requires sender_device_id to be
NULL OR to match a row in `devices` owned by the caller. The localStorage
UUID matches neither -> 403 -> outbox endlessly retries with "Wiederhole".

Fix: SendMessageParams.senderDeviceId becomes optional, and the message
INSERT coerces undefined to NULL. The column is pure telemetry post-conv-
keys so passing NULL is correct. Existing call sites that hand in
ensureInstallId() still typecheck (string is assignable to string|null|undefined)
but the row is written with NULL until those callers stop passing it.
2026-05-16 00:59:28 +02:00
byGalax 61462516d2 chore(desktop): release v0.18.3 2026-05-16 00:49:32 +02:00
byGalax d39a0fb6dc fix: stop reset_user_key from wiping conv-key bundles + auto-rotate stuck convs
Root cause of "alle Nachrichten verschlüsselt + kann nicht schreiben":
uploadUserKeyBlob (called by setupNewUserIdentity, changePin and
regenerateRecoveryCode) routed through reset_user_key, which DELETES
every conversation_keys row addressed to the user or one of their
devices. So setting a PIN destroyed every legacy bundle BEFORE the
migration could re-wrap them. The user ended up with user_keys set,
zero un-migrated bundles, no decryption, no send.

Fixes shipped:

  * supabase/migrations/20260516000001_user_key_rpcs_v2.sql
    - upsert_user_key: same UPSERT, NO delete. Used everywhere except
      "Identität zurücksetzen" (which keeps reset_user_key on purpose).
    - rotate_conv_key: bumps active_key_version atomically and inserts
      a fresh batch of bundles (per-user + per-device fallback).
  * shared/auth/userKey.ts: uploadUserKeyBlob now calls upsert_user_key.
  * shared/chat/convKeys.ts: new rotateConvKey() that wraps the fresh
    conv-key for every member's user_keys (preferred) and falls back to
    each member's per-device public_key for peers still on 0.17.x.
  * shared/chat/convKeys.ts: getOrCreateConvKey auto-triggers rotate
    when the user has no recipient_user_id row at the active version
    but rows exist (the deadlock case). Existing outbox retries drain
    on their own once the rotate completes — no manual button.
  * desktop/MessageBubble.tsx: "...cannot decrypt" is now a softer,
    German "Nachricht nicht lesbar" so users don't think the app
    crashed when historical messages can't be unwrapped.
2026-05-16 00:48:28 +02:00
byGalax 9207f473cd chore(desktop): release v0.18.2 2026-05-16 00:27:54 +02:00
byGalax 5367544b59 feat(desktop): diagnostic + manual retry for legacy key migration
The 0.18.1 fix relied on an existing-device + present-stronghold-key match.
That fails for users who:
  - had multiple device registrations and only retain the latest device's
    private key in the local vault
  - had a vault wipe / fresh OS install at some point
  - have device rows that vanished server-side but keys still locally

Migration now scans conversation_keys for distinct un-migrated
recipient_device_ids visible to the user (RLS-filtered) and probes the
stronghold for each, regardless of whether the server still lists that
device. Result struct surfaces attempted/migrated/noKey/decryptFail/rpcFail
counters; SecurityCenter shows them via a new "Migration erneut ausführen"
button so users can self-diagnose without DevTools.

Also adds [crypto-migration] console.info breadcrumbs at every decision
point so a single F12 shows what happened.
2026-05-16 00:25:48 +02:00
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
byGalax b89ec90813 chore: bump version to 0.8.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
Voice messages, offline queue, delivery receipts, group call scaling,
recovery code, push notifications scaffolding, admin panel, search v2,
focus-flicker fix.
2026-04-21 01:15:57 +02:00
byGalax a04ecf7a19 feat: voice messages, offline queue, delivery ticks, volume slider, admin + scaling
- Voice messages: MediaRecorder → encrypted attachment, custom waveform
  player via OfflineAudioContext, 60s limit + live mic-level meter
- Offline message queue: localStorage outbox, exponential backoff retries,
  optimistic pending bubble with retry/discard
- Delivery indicator: message_deliveries table + RLS (reciprocal receipts),
  ✓ / ✓✓ / ✓✓-blue tick states, group-aware (all members must ack)
- Per-participant volume slider in calls via right-click tile menu,
  persisted to localStorage, applied to attached audio elements
- Group call scaling: grid up to 12 tiles with pagination,
  active-speaker auto-promotion in fullscreen
- Push notifications scaffolding: service worker, VAPID subscription
  registration, notify-push edge function skeleton
- Backup recovery code: 24-char base32 code (~120 bits entropy) as
  alternative decrypt path, restore UI with mode toggle
- Admin panel: conversations list, audit log (admin_audit_log table +
  admin_log_action RPC), audit entry on user flag toggle
- Search v2: sender filter, attachment-only toggle, date range
- Reactions pop animation (scale 0.4→1.15→1 on count change)
- Message list windowing (150 default, expand via IntersectionObserver)
- Stub cleanup: removed dead ScreenshareStub from CallParticipantTile

Fixes:
- Focus-triggered flicker: dropped window.focus listeners in three spots,
  throttled visibilitychange/online wake-refreshes to 30s, keep existing
  data visible during background re-syncs (no more spinner on every click)
- Voice attachment audio element collapsed to 0px on peer side — now
  forces 280px min-width on bubble

Migrations (push required):
  20260421000001_message_deliveries.sql
  20260421000002_admin_audit_log.sql

Server TODO:
  VAPID keys + notify-push edge function deploy
2026-04-21 01:14:16 +02:00
byGalax da85f0ba54 feat: call UX overhaul — deafen sync, share dialog, fullscreen redesign
Speaking ring:
- Switch useActiveSpeakers from LiveKit's smoothed isSpeaking / server-
  batched ActiveSpeakersChanged to Web Audio API AnalyserNode on each
  participant's raw audio MediaStreamTrack. Poll 50ms, RMS threshold 0.03,
  250ms hold. Feels real-time vs the old ~500ms lag
- Defensive syncProbe on every tick so probes catch up if TrackPublished
  missed (local mic publish race on join)
- Universal speaking overlay on tile (3px emerald border + inset glow,
  z-10) so video mode shows the ring too, not just audio mode

Screen sharing:
- Separate "screen" tile per sharer so the sharer's avatar tile stays
  intact with its speaking ring. Tile.id is kind-prefixed (user:xxx /
  screen:xxx) so focus tracking distinguishes them
- New ScreenShareDialog (quality preset + fps override + displaySurface
  hint) opens on the share button. startScreenShare / stopScreenShare
  actions in CallContext replace the one-shot toggle
- ScreenShareViewer: plain CSS-only fullscreen overlay (Tauri WKWebView
  doesn't implement requestFullscreen), always `h-full w-full
  object-contain`, Esc exits

Camera:
- toggleCamera action in CallContext tracks isCameraEnabled
- VideoStub renders real <video> srcObject for the participant's camera
  MediaStreamTrack; local preview is mirrored
- Tile video track resolves to Track.Source.Camera publications of the
  LocalParticipant / each RemoteParticipant
- Room listens for TrackMuted / TrackUnmuted and re-publishes remote
  state so peers switch to avatar placeholder when a camera is disabled

Deafen:
- New isDeafened state + toggleDeafen action. Sets `muted = true` on all
  attached `<audio[data-livekit-track]>` plus mutes fresh ones on attach
  via module-level flag
- Broadcast state over the LiveKit data channel
  ({type:'presence', deafened}) so peers can render the headphones-off
  badge. Attributes API not used because the self-hosted server may run
  older LiveKit versions
- remoteDeafen: Record<identity, bool> exposed via context, bumped on
  DataReceived and re-broadcast on ParticipantConnected

Incoming video call:
- acceptIncoming takes an optional CallKind override so the receiver can
  answer a video invite with audio only or promote an audio invite to
  video on accept
- IncomingCallPanel shows two accept buttons (audio + video) when the
  invite is a video call

Audio devices:
- audioSettings adds inputDeviceId + outputDeviceId, persisted
- CallContext uses them on setMicrophoneEnabled, plus new
  setAudioInputDevice / setAudioOutputDevice hot-swap actions.
  Output swap applies setSinkId to every attached remote-audio element
  since LiveKit's own switchActiveDevice only tracks elements it
  attached itself
- SettingsPage "Mikrofon" + "Ausgabegerät" selects with devicechange
  listener and a permission-probe button

Fullscreen mode:
- Replaced absolute-positioned speaker + floating thumbnails with a real
  flex layout. Default = even grid of all tiles. Clicking a tile flips
  to big-speaker + horizontal thumbnail strip. Click focused tile =
  back to grid
- Controls overlay pinned bottom; content wrapper has pb-24 so tiles
  never sit behind the toolbar
- Grid now uses explicit grid-rows-* so cells get a defined 1fr height
  (without it, video intrinsic dimensions blew tiles past the container
  bounds on Windows)

UI chips:
- Mic-off badge combines isMuted flag AND
  localParticipant.isMicrophoneEnabled, so a user with no mic / denied
  permission sees the badge + the toolbar button red even though they
  never pressed mute
- Deafen badge on tile chips for local + remote (remote driven by the
  data-channel broadcast)
2026-04-21 00:02:43 +02:00
byGalax a4c9b959a9 fix: editable decrypt + windows realtime wake (v0.7.1)
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
Edit message decrypt:
- handleUpdate in useConversationMessages now refetches the canonical row
  via REST after a realtime UPDATE instead of trusting the realtime
  payload's bytea encoding. Same pattern as handleInsert — base64 vs
  `\x…` hex serialisation varies across supabase/postgrest versions and
  was silently producing undecryptable ciphertext for edited messages
  on the receiver side

Windows WebView2 background throttling:
- ConversationsContext, useFriendships and useConversationMessages now
  listen for visibilitychange / focus / online events and trigger both a
  fresh REST refresh and a best-effort channel.subscribe() on wake.
  WebView2 aggressively throttles background WebSockets and was dropping
  realtime events entirely while the window was minimised, so new
  messages and friend acceptances only surfaced after a manual reload

Bump tauri version 0.7.0 -> 0.7.1
2026-04-20 22:15:49 +02:00
byGalax 37becba7e2 feat: audio devices + fullscreen + banner cleanup (v0.7.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
Audio device selection:
- audioSettings: persisted inputDeviceId + outputDeviceId
- CallContext: uses stored input deviceId on mic enable, new
  setAudioInputDevice / setAudioOutputDevice actions that hot-swap
  without reconnect. Output swap applies HTMLMediaElement.setSinkId
  to every attached remote-audio element (LiveKit's switchActiveDevice
  only tracks elements it attached itself)
- SettingsPage: new "Mikrofon" + "Ausgabegerät" selects with
  enumerateDevices, devicechange listener, permission-probe button.
  setSinkId-unsupported fallback is messaged but non-blocking

Fullscreen:
- FullscreenCall was absolute inset-0 z-40 which trapped it inside the
  <main> pane — sidebar + chat-list stayed visible. Switched to
  fixed inset-0 z-[60] so the call overlays the whole window
  Discord-style
- ScreenShareViewer fullscreen: CSS-only toggle (native Fullscreen API
  unreliable under Tauri WKWebView), portalled to document.body when
  active so no ancestor stacking context can clip it. Esc exits

ActiveCallBanner:
- cleanup effect returned early when presence was entirely empty,
  leaving the "1 im Raum" fallback stuck after both peers left. Now
  schedules dismissLastCall as soon as othersIn.length === 0, with a
  3s grace window to absorb presence re-sync flicker

Bump tauri version 0.6.0 -> 0.7.0
2026-04-20 21:26:10 +02:00
byGalax eb8f9857ff feat: device backup/restore + quick wins + username casing
Backup / restore flow:
- deviceBackup.ts: v2 format wrapping userId + deviceId + privateKey in
  an encrypted JSON payload so restore can re-seed localStorage, vault,
  and reattach to the existing server-side device row without provisioning
  a new one (conv-key bundles stay valid, no "awaiting key" state)
- shared/auth: restoreDeviceFromServerRecord — verifies session.user.id
  matches the backup's userId, confirms the server device row still
  exists, then writes the private key into the local secret store
- BackupExportDialog — passphrase + confirm, generates portable string,
  copy + download .txt
- DeviceRestore — textarea + passphrase → seeds vault + writes
  deviceId cache, treats this install as the original device
- DevicePage now has tabs "Neu einrichten" / "Backup wiederherstellen"
- BackupPromptBanner — post-registration nudge, reads sessionStorage
  signal from fresh provisions and persists "never-ask-again" in
  localStorage so it stops nagging
- SettingsPage backup section: uses the new dialog; removes the
  dangerous in-place key import (restore now lives in the device flow)

Username casing:
- Migration 20260420000002 drops lower() from the handle_new_user trigger
  and widens the regex to [A-Za-z0-9_]. profiles.username is citext so
  uniqueness + lookups stay case-insensitive regardless of stored casing
- Shared auth: trim() only, no toLowerCase on signup/lookups/search.
  ilike handles CI anyway and citext makes client normalisation redundant
- AuthPage regex + input preserve case, FriendsPage search preserves case
- i18n (de+en): updated username_hint / username_invalid / ERR_USERNAME_INVALID
  to reflect the new rule

Quick wins:
- React Router v7 future flags (v7_startTransition + v7_relativeSplatPath)
  set on BrowserRouter — silences the upgrade warning
- appUpdates.checkForUpdate: swallow benign network/fetch/"could not
  fetch valid release JSON" cases silently instead of console spam
- osNotify: persist an "asked" marker in localStorage so the permission
  prompt only fires once per install (OS already persists the answer,
  but the plugin re-queries loudly otherwise)
2026-04-20 19:07:31 +02:00
byGalax de431386ea feat: reply + search + forward + archive/mute + error boundary
Messages:
- Reply-to: hover action, composer chip with cancel, quote bubble inside
  the replying message with tap-to-jump + amber highlight ring
- Search: header search button toggles in-conversation search bar with
  prev/next + match counter, auto-jump to active match
- Forward: multi-select conversation picker. Attachments are now carried
  over: download + decrypt source, re-encrypt under each target conv-key,
  re-upload with fresh per-attachment keys, insert new attachment rows

Conversations:
- Archive + mute per member. New migration 20260420000001 adds `archived`
  + `muted_until` on conversation_members. Shared helpers:
  setConversationArchived / setConversationMutedUntil / isConversationMuted
- ChatsPage: archive toggle in header with unread badge for archived
  bucket, split active/archived lists, muted indicator (BellOff icon,
  dimmed unread badge)
- ConversationRowMenu via createPortal (escapes sidebar overflow clip),
  forwardRef-based MenuItem so submenu positioning refs survive React 18
- ConversationsContext: suppresses notification sound + OS notif when
  target conversation is muted
- Refresh on `profiles UPDATE` realtime so peer avatar / displayName
  changes flow to conversation.members without manual refresh

Resilience:
- ErrorBoundary (Discord-style): centred spinner + escalating copy, no
  manual reload button. Backoff retry schedule [2s, 4s, 8s, 15s, 30s].
  Uses a keyed Fragment (not a div wrapper) so flex h-full chains survive
- App wrapped root + per-route RouteBoundary, conversation-level boundary
- AuthContext: flip `ready` immediately on cached session read; validate
  getUser in background so a stalled/offline Supabase doesn't freeze the
  app on the loading spinner

Crypto:
- Swap libsodium-wrappers -> libsodium-wrappers-sumo (compact build was
  missing crypto_pwhash so Argon2id vault KDF threw, falling back to
  plaintext localStorage on every launch)
- Shim d.ts for sumo types (sumo is API superset, no official types ship)
- vite optimizeDeps includes sumo with the "require" condition
- secureFileStore: exists(dir) check before mkdir; surface genuine
  permission errors instead of silent catch

Tauri:
- fs scope adds `$APPLOCALDATA` direct entry (not just /**) so the app
  data directory itself can be mkdir'd on first launch

Chat layout:
- Skip call_event messages when computing avatar run boundaries so a
  regular bubble followed by a call event from the same sender still
  shows its avatar
2026-04-20 15:42:49 +02:00
byGalax 1fab2edc57 fix(messages): peer avatar visibility in chat bubbles
- ConversationsContext: subscribe to profiles UPDATE realtime so
  conversation members[].profile picks up peer avatar / displayName changes
  without a manual refresh
- ConversationPage: skip call_event messages when computing run boundaries.
  Previously a peer bubble followed by a call event from the same sender
  was treated as mid-run -> avatar slot collapsed to a placeholder
- DM peer-profile fallback already added in previous commit covers transient
  member-lookup misses
2026-04-20 00:49:21 +02:00
byGalax 4db65993d5 feat: redesign + avatars + theme + call presence fixes (v0.6.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
Visual:
- Light/dark theme via ThemeContext + CSS vars
- New design tokens (surface/fg/accent/line/etc.) across all pages
- Reusable Avatar component (img + letter fallback) wired into UserBar,
  ConversationHeader, ChatsPage, FriendsPage, GroupInfoPanel, IncomingCallPanel

Calls:
- Split call UI: IncomingCallPanel, InCallPanel, CallControls,
  CallParticipantTile, ScreenShareViewer
- Active speaker hook (useActiveSpeakers)
- Fix: ActiveCallBanner stayed hidden after hangup while peers in room.
  - useCallPresence: bind presence callbacks only when we own subscribe
    (Supabase forbids .on() after .subscribe() on shared dedup'd channels)
  - useCallPresence: never removeChannel — channel is shared with CallContext
    so tearing it down on ConversationHeader unmount killed live tracking
  - ActiveCallBanner: lastCallConversationId fallback so banner shows
    instantly after hangup, auto-dismiss when room confirmed empty
- Drop unused useAnyActiveCall

Bump tauri version 0.5.0 -> 0.6.0
2026-04-20 00:26:19 +02:00
byGalax 0ca29952ba feat: profile avatar upload + share_conv_keys rpc + favicon + smtp tweaks
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-19 23:04:03 +02:00
byGalax ff5ea274b9 fix(keysync): swallow expected supabase errors by code/status, not just message
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-19 22:08:54 +02:00
byGalax b0f9f1dada fix(keysync): silence expected RLS rejections during best-effort backfill
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-19 22:03:49 +02:00
830 changed files with 82419 additions and 11855 deletions
+24
View File
@@ -0,0 +1,24 @@
# 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.
# NOTE: during the .cloud→.de transition the new VPS must ALSO serve the same
# artifacts under update.netralax.cloud (point its DNS at the new IP) so that
# already-installed clients — which have update.netralax.cloud baked in — can
# still pull the release that switches them over to .de.
UPDATE_HOST=update.netralax.de
# 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"
+11 -7
View File
@@ -15,7 +15,9 @@ out/
.env
.env.local
.env.*.local
.env.release
!.env.example
!.env.release.example
# Expo
.expo/
@@ -27,14 +29,13 @@ web-build/
*.key
*.mobileprovision
# Tauri updater signing key (private — NEVER commit)
.tauri-updater.key
# .tauri-updater.key.pub is public, may be committed
# Electron desktop build artifacts
apps/desktop/out/
apps/desktop/release/
# Tauri
apps/desktop/src-tauri/target/
apps/desktop/src-tauri/gen/
apps/desktop/src-tauri/WixTools/
# Bundle visualizer reports
apps/desktop/stats.html
apps/desktop/stats.json
# Logs
*.log
@@ -56,6 +57,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
+116
View File
@@ -0,0 +1,116 @@
// 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';
import { visualizer } from 'rollup-plugin-visualizer';
const ANALYZE = process.env.ANALYZE === 'true';
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: './',
// Visualizer plugins are gated behind ANALYZE=true so the production
// build never pays the analysis cost. Re-enable with:
// ANALYZE=true pnpm --filter @chat-app/desktop build
// which writes apps/desktop/stats.html (treemap) + stats.json (raw).
plugins: [
react(),
...(ANALYZE
? [
visualizer({
filename: 'stats.html',
template: 'treemap',
gzipSize: true,
brotliSize: true,
open: false,
}),
visualizer({
filename: 'stats.json',
template: 'raw-data',
gzipSize: true,
brotliSize: true,
}),
]
: []),
],
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,
},
});
+290
View File
@@ -0,0 +1,290 @@
// 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',
// Content-protection toggle. Enables/disables OS-level screenshot/screen-
// recording block (WDA_MONITOR on Windows, NSWindowSharingNone on macOS)
// while a view-once image is being revealed. No-op on Linux X11.
WINDOW_SET_CONTENT_PROTECTION: 'window:set-content-protection',
// Wipe-on-close — main process pushes this to the renderer right before
// exiting if the user has enabled the Settings → Sicherheit toggle. The
// renderer clears its sensitive caches (memoryWipe.ts) and acks via
// `app:wipe-before-quit:done`; main quits after the ack (or a 2s safety
// timeout, whichever comes first).
/** Main → renderer: about to quit. Renderer wipes, then resolves. */
APP_WIPE_BEFORE_QUIT: 'app:wipe-before-quit',
// OS hostname — returns the machine hostname via Node's `os.hostname()`.
// Used by AuthContext on first launch to populate the `devices` row with
// a human-readable default device name. Returns null on failure.
APP_HOSTNAME: 'app:hostname',
} 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;
}
// ---- Window content protection -------------------------------------------
export interface WindowSetContentProtectionArgs {
enabled: boolean;
}
// ---- 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;
+319
View File
@@ -0,0 +1,319 @@
// 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, ipcMain, Menu, session } from 'electron';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { CHANNELS } from './ipc-types';
import { register as registerAudioLoopback } from './modules/audio-loopback';
import { register as registerAppHostname } from './modules/app-hostname';
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 registerWindowContentProtection } from './modules/window-content-protection';
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) {
// Auto-open DevTools in dev — the menu bar is stripped (Discord-style)
// so F12 / Ctrl+Shift+I have no chord; opening detached gives a
// separate inspector window for easy debugging.
win.webContents.openDevTools({ mode: 'detach' });
// Forward renderer console messages to the main-process stdout so
// errors during local dev are visible in the terminal too (helps when
// the inspector isn't focused).
win.webContents.on('console-message', (_event, level, message, line, sourceId) => {
const tag = level === 3 ? 'error' : level === 2 ? 'warn' : level === 1 ? 'log' : 'info';
console.log('[renderer ' + tag + ']', message, '(' + sourceId + ':' + line + ')');
});
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();
registerAppHostname();
// Window-dependent registrars — only call once mainWindow exists so
// event emitters have somewhere to send.
registerShortcuts(mainWindow);
registerTray(mainWindow);
registerUpdater(mainWindow);
registerWindowFullscreen(mainWindow);
registerWindowContentProtection(mainWindow);
registerAudioLoopback(mainWindow);
});
// Wipe-on-close: when the user enables it in Settings, the renderer is given
// a chance to clear all sensitive caches before the app process exits. If
// the renderer doesn't ack within 2 seconds we force-quit anyway — better
// to lose the wipe than to hang the app shutdown.
//
// Note: there's a separate `before-quit` listener in modules/tray.ts that
// tears down the Tray instance. Electron fires both; the tray listener is
// synchronous and doesn't touch event.preventDefault, so it doesn't fight
// our deferred-quit dance here. The `wipeRequested` flag guards re-entry
// when our own `app.quit()` below fires `before-quit` a second time.
let wipeRequested = false;
app.on('before-quit', (event) => {
if (wipeRequested) return;
if (!mainWindow || mainWindow.isDestroyed()) return;
wipeRequested = true;
event.preventDefault();
mainWindow.webContents.send(CHANNELS.APP_WIPE_BEFORE_QUIT);
const done = new Promise<void>((resolve) => {
ipcMain.once(CHANNELS.APP_WIPE_BEFORE_QUIT + ':done', () => resolve());
});
void Promise.race([done, new Promise<void>((r) => setTimeout(r, 2000))]).finally(() => {
app.quit();
});
});
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,21 @@
// app:hostname adapter — returns the OS hostname via Node's `os.hostname()`.
// The renderer has no Node access (contextIsolation is ON), so it must ask
// main for this value. Used by AuthContext on first launch to populate the
// `devices` row with a human-readable default device name.
import os from 'node:os';
import { ipcMain } from 'electron';
import { CHANNELS } from '../ipc-types';
export function register(): void {
ipcMain.handle(CHANNELS.APP_HOSTNAME, (): string | null => {
try {
const h = os.hostname();
return typeof h === 'string' && h.length > 0 ? h : null;
} catch {
return null;
}
});
}
@@ -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();
});
}
+114
View File
@@ -0,0 +1,114 @@
// 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 parses `$NAME` as a NAMED parameter
// (NAME = `1`, `2`, …), not as positional, so better-sqlite3 wants the
// bindings as `{ '1': v1, '2': v2 }` not `[v1, v2]`. We accept the old
// array-shape from callers and convert to the named-object on the way in.
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;
}
// Convert a positional bindings array `[v1, v2]` to the named-params object
// `{ '1': v1, '2': v2 }` that better-sqlite3 needs when the SQL uses
// `$1`/`$2` named placeholders. Returns the original array (spread later)
// when it's empty.
function bindParams(bindings: unknown[] | undefined): Record<string, unknown> | [] {
const arr = bindings ?? [];
if (arr.length === 0) return [];
const obj: Record<string, unknown> = {};
for (let i = 0; i < arr.length; i++) {
obj[String(i + 1)] = arr[i];
}
return obj;
}
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 params = bindParams(args.bindings);
const info = Array.isArray(params) ? stmt.run() : stmt.run(params);
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 params = bindParams(args.bindings);
const rows = (Array.isArray(params) ? stmt.all() : stmt.all(params)) 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);
});
}
+145
View File
@@ -0,0 +1,145 @@
// 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');
}
// 16×16 grey square — last-ditch fallback when neither the packaged
// nor dev icon file resolves. Tray constructor on Windows throws when
// handed an empty NativeImage, which would tear down the whole
// registrar before `ipcMain.handle(TRAY_UNREAD)` runs — leaving the
// taskbar overlay badge wired but the renderer's invoke rejecting
// with "No handler registered". A non-empty placeholder keeps the
// constructor happy so the IPC handler always gets registered.
const FALLBACK_TRAY_PNG_BASE64 =
'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAH0lEQVR42mNk' +
'YGD4z0ABYBxVOKpwVOGowlGFwwoBAEnYAR9XlIldAAAAAElFTkSuQmCC';
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.createFromBuffer(Buffer.from(FALLBACK_TRAY_PNG_BASE64, 'base64'));
}
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 {
// Tray icon is best-effort: if neither the packaged resource nor the
// dev path resolves (e.g. icon.ico isn't shipped under resourcesPath
// in packaged builds — only the app icon goes into the .exe metadata),
// we still want the taskbar overlay badge to work. setOverlayIcon is
// a BrowserWindow method, so it functions even when the systray icon
// creation fails.
try {
const icon = loadTrayIcon();
if (!icon.isEmpty()) {
trayRef = new Tray(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();
});
}
} catch (err: unknown) {
// Swallow: the overlay badge below is the user-visible bit. A missing
// systray icon is cosmetic and shouldn't take the unread handler with it.
console.warn('[tray] systray init failed, overlay badge still active', err);
}
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,36 @@
// Window content-protection adapter. Enables / disables OS-level
// screenshot and screen-recording blocking on the host BrowserWindow.
//
// Windows: WDA_MONITOR (SetWindowDisplayAffinity) — the window surface
// appears black in any screen capture tool (OBS, Snipping Tool,
// Win+PrtScr, etc.) while protection is enabled.
// macOS: NSWindowSharingNone — equivalent coverage for QuickTime,
// Cmd+Shift+3/4, and external recorders.
// Linux: No-op. Electron exposes the API on all platforms but the
// X11/Wayland compositors don't honour it in Electron 33.
//
// Called by the renderer during view-once image reveals so the image
// cannot be captured by an OS-level screenshot while it is on screen.
import { BrowserWindow, ipcMain } from 'electron';
import { CHANNELS, type WindowSetContentProtectionArgs } from '../ipc-types';
export function register(mainWindow: BrowserWindow): void {
ipcMain.handle(
CHANNELS.WINDOW_SET_CONTENT_PROTECTION,
(_evt, args: WindowSetContentProtectionArgs) => {
// Electron's setContentProtection covers Windows (WDA_MONITOR) and
// macOS (NSWindowSharingNone) in one call. No-op on Linux X11.
// Wrapped in try/catch because the window can already be destroyed
// by the time this fires during a teardown.
try {
const win = BrowserWindow.fromWebContents(_evt.sender) ?? mainWindow;
if (!win || win.isDestroyed()) return;
win.setContentProtection(args.enabled);
} catch (err) {
console.warn('setContentProtection failed', err);
}
},
);
}
@@ -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;
}
});
}
+123
View File
@@ -0,0 +1,123 @@
// 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>;
/** Block OS-level screen capture (Win+PrtScr, OBS, etc.) while a
* view-once image is being revealed. Covers Windows (WDA_MONITOR) and
* macOS (NSWindowSharingNone). No-op on Linux X11. Optional: always
* feature-check because the web build has no preload bridge. */
setContentProtection?: (enabled: boolean) => Promise<void>;
/** Subscribe to the main-process pre-quit notification. Used by the
* "Cache beim Schließen leeren" Settings toggle. */
onWipeBeforeQuit: (cb: () => Promise<void>) => () => void;
/** Returns the OS hostname (via Node's `os.hostname()`). Used by
* AuthContext on first launch to populate the `devices` row with a
* human-readable default device name. Optional: always feature-check
* via `typeof window.electronAPI?.getHostname === 'function'` because
* the web build has no preload bridge. */
getHostname?: () => Promise<string | null>;
}
declare global {
interface Window {
electronAPI: ElectronAPI;
}
}
export {};
+191
View File
@@ -0,0 +1,191 @@
// Preload bridge. Exposes a single `window.electronAPI` object to the
// renderer that mirrors the CHANNELS surface from ipc-types.ts. Each
// CHANNEL becomes a typed method that calls `ipcRenderer.invoke`; the
// three event channels (shortcut fired/released + updater progress) get
// `on*` subscribers that return an unsubscribe function.
//
// contextIsolation is ON so the renderer never sees `ipcRenderer` or
// Node directly — only this curated surface.
import { contextBridge, ipcRenderer } from 'electron';
import {
CHANNELS,
ELECTRON_RUNTIME_MARKER,
type AudioLoopbackChunk,
type AudioLoopbackStartResult,
type FsPath,
type FsRenameArgs,
type FsWriteArgs,
type NotifyArgs,
type ScreenSource,
type SecureStoreHandle,
type SecureStoreOpenArgs,
type ShortcutEvent,
type ShortcutRegisterArgs,
type SqlExecuteResult,
type SqlSelectResult,
type UpdateCheckResult,
type UpdateProgress,
} from './ipc-types';
// Static import of the desktop package.json so the bundler inlines the
// version string at build time. The previous `process.env.npm_package_-
// version` approach worked in dev (pnpm sets it as a script env var) but
// fell back to '0.0.0' in packaged builds — every installed user got
// flagged as "Update verfügbar" against their own actually-current
// version. resolveJsonModule + esModuleInterop are on in tsconfig.node.
import pkg from '../package.json';
type Unsubscribe = () => void;
function on<T>(channel: string, cb: (payload: T) => void): Unsubscribe {
const handler = (_evt: Electron.IpcRendererEvent, payload: T): void => cb(payload);
ipcRenderer.on(channel, handler);
return () => ipcRenderer.removeListener(channel, handler);
}
const api = {
platform: ELECTRON_RUNTIME_MARKER,
osPlatform: process.platform as NodeJS.Platform,
appVersion: pkg.version,
// Screen sources ---------------------------------------------------------
getScreenSources: (): Promise<ScreenSource[]> => ipcRenderer.invoke(CHANNELS.SCREEN_GET_SOURCES),
getScreenThumbnail: (sourceId: string): Promise<string | null> =>
ipcRenderer.invoke(CHANNELS.SCREEN_GET_THUMBNAIL, sourceId),
/** Tell main which source the user picked in our Discord-style modal,
* BEFORE calling getDisplayMedia. The display-media handler reads this
* and routes the matching desktopCapturer Source into LiveKit. Pass
* null on cancel/error to clear the pending state. */
setPendingShareSource: (sourceId: string | null): Promise<void> =>
ipcRenderer.invoke(CHANNELS.SCREEN_SET_PENDING_SOURCE, sourceId),
// Audio loopback ---------------------------------------------------------
resolveLoopbackSource: (): Promise<{ sourceId: string } | null> =>
ipcRenderer.invoke(CHANNELS.AUDIO_LOOPBACK_RESOLVE_SOURCE),
/** Native WASAPI process-loopback (Windows only). Captures all system
* audio EXCEPT our own PID tree so peers don't hear themselves echoed
* back. Throws on platforms where the addon isn't available — caller
* is expected to fall back to the renderer-driven getUserMedia path
* (see lib/screenAudio.ts). */
audioLoopback: {
start: (): Promise<AudioLoopbackStartResult> =>
ipcRenderer.invoke(CHANNELS.AUDIO_LOOPBACK_START),
/** Window-share variant: capture only the picked window's process
* tree (INCLUDE_TARGET_PROCESS_TREE). hwnd is the decimal HWND
* parsed from desktopCapturer's `window:<HWND>:0` source id. */
startForWindow: (hwnd: number): Promise<AudioLoopbackStartResult> =>
ipcRenderer.invoke(CHANNELS.AUDIO_LOOPBACK_START_FOR_WINDOW, { hwnd }),
stop: (captureId: number): Promise<void> =>
ipcRenderer.invoke(CHANNELS.AUDIO_LOOPBACK_STOP, captureId),
onChunk: (cb: (payload: AudioLoopbackChunk) => void): Unsubscribe =>
on<AudioLoopbackChunk>(CHANNELS.AUDIO_LOOPBACK_CHUNK, cb),
},
// Shortcuts --------------------------------------------------------------
registerShortcut: (args: ShortcutRegisterArgs): Promise<boolean> =>
ipcRenderer.invoke(CHANNELS.SHORTCUT_REGISTER, args),
unregisterShortcut: (id: string): Promise<void> =>
ipcRenderer.invoke(CHANNELS.SHORTCUT_UNREGISTER, id),
isShortcutRegistered: (id: string): Promise<boolean> =>
ipcRenderer.invoke(CHANNELS.SHORTCUT_IS_REGISTERED, id),
onShortcutFired: (cb: (evt: ShortcutEvent) => void): Unsubscribe =>
on<ShortcutEvent>(CHANNELS.SHORTCUT_EVT_FIRED, cb),
onShortcutReleased: (cb: (evt: ShortcutEvent) => void): Unsubscribe =>
on<ShortcutEvent>(CHANNELS.SHORTCUT_EVT_RELEASED, cb),
// Notifications ----------------------------------------------------------
notify: (args: NotifyArgs): Promise<void> => ipcRenderer.invoke(CHANNELS.NOTIFY_SHOW, args),
getNotificationPermission: (): Promise<'granted' | 'denied' | 'default'> =>
ipcRenderer.invoke(CHANNELS.NOTIFY_PERMISSION),
// Tray -------------------------------------------------------------------
// `badgeDataUrl` (optional): renderer-painted PNG (data:image/png;base64)
// that main applies as the Windows taskbar overlay icon. We render in the
// renderer because main has no Canvas2D; passing a finished image avoids
// bundling a native canvas backend just for a 32×32 badge.
setTrayUnread: (count: number, badgeDataUrl?: string | null): Promise<void> =>
ipcRenderer.invoke(CHANNELS.TRAY_UNREAD, count, badgeDataUrl ?? null),
// Secure store -----------------------------------------------------------
secureStoreOpen: (args: SecureStoreOpenArgs): Promise<SecureStoreHandle> =>
ipcRenderer.invoke(CHANNELS.SECURE_STORE_OPEN, args),
secureStoreGet: (handle: string, key: string): Promise<string | null> =>
ipcRenderer.invoke(CHANNELS.SECURE_STORE_GET, handle, key),
secureStoreSet: (handle: string, key: string, value: string): Promise<void> =>
ipcRenderer.invoke(CHANNELS.SECURE_STORE_SET, handle, key, value),
secureStoreRemove: (handle: string, key: string): Promise<void> =>
ipcRenderer.invoke(CHANNELS.SECURE_STORE_REMOVE, handle, key),
secureStoreClose: (handle: string): Promise<void> =>
ipcRenderer.invoke(CHANNELS.SECURE_STORE_CLOSE, handle),
// Filesystem -------------------------------------------------------------
fsRead: (p: FsPath): Promise<string | null> => ipcRenderer.invoke(CHANNELS.FS_READ, p),
fsWrite: (args: FsWriteArgs): Promise<void> => ipcRenderer.invoke(CHANNELS.FS_WRITE, args),
fsExists: (p: FsPath): Promise<boolean> => ipcRenderer.invoke(CHANNELS.FS_EXISTS, p),
fsMkdir: (p: FsPath): Promise<void> => ipcRenderer.invoke(CHANNELS.FS_MKDIR, p),
fsRename: (args: FsRenameArgs): Promise<void> => ipcRenderer.invoke(CHANNELS.FS_RENAME, args),
fsRemove: (p: FsPath): Promise<void> => ipcRenderer.invoke(CHANNELS.FS_REMOVE, p),
fsAppLocalDataDir: (): Promise<string> => ipcRenderer.invoke(CHANNELS.FS_APP_LOCAL_DATA_DIR),
// SQL --------------------------------------------------------------------
sqlLoad: (args: { name: string }): Promise<string> =>
ipcRenderer.invoke(CHANNELS.SQL_LOAD, args),
sqlExecute: (args: {
handle: string;
query: string;
bindings?: unknown[];
}): Promise<SqlExecuteResult> => ipcRenderer.invoke(CHANNELS.SQL_EXECUTE, args),
sqlSelect: (args: {
handle: string;
query: string;
bindings?: unknown[];
}): Promise<SqlSelectResult> => ipcRenderer.invoke(CHANNELS.SQL_SELECT, args),
sqlClose: (handle: string): Promise<void> => ipcRenderer.invoke(CHANNELS.SQL_CLOSE, handle),
// Updater ----------------------------------------------------------------
checkForUpdate: (): Promise<UpdateCheckResult> => ipcRenderer.invoke(CHANNELS.UPDATER_CHECK),
downloadInstallUpdate: (): Promise<void> =>
ipcRenderer.invoke(CHANNELS.UPDATER_DOWNLOAD_INSTALL),
onUpdaterProgress: (cb: (p: UpdateProgress) => void): Unsubscribe =>
on<UpdateProgress>(CHANNELS.UPDATER_EVT_PROGRESS, cb),
// Autostart --------------------------------------------------------------
isAutoStartEnabled: (): Promise<boolean> => ipcRenderer.invoke(CHANNELS.AUTOSTART_IS_ENABLED),
setAutoStart: (enabled: boolean): Promise<void> =>
ipcRenderer.invoke(CHANNELS.AUTOSTART_SET, enabled),
// Window fullscreen ------------------------------------------------------
setFullscreen: (enabled: boolean): Promise<void> =>
ipcRenderer.invoke(CHANNELS.WINDOW_SET_FULLSCREEN, enabled),
// Window content protection ----------------------------------------------
setContentProtection: (enabled: boolean): Promise<void> =>
ipcRenderer.invoke(CHANNELS.WINDOW_SET_CONTENT_PROTECTION, { enabled }),
// OS hostname ------------------------------------------------------------
getHostname: (): Promise<string | null> => ipcRenderer.invoke(CHANNELS.APP_HOSTNAME),
// Wipe-on-close ----------------------------------------------------------
// Subscribe to the main-process pre-quit notification. The renderer's
// callback does the actual wipe (memoryWipe.ts) and resolves; we ack
// unconditionally so main can finish quitting — better to lose the wipe
// than to hang the app shutdown if the callback throws.
onWipeBeforeQuit: (cb: () => Promise<void>): Unsubscribe => {
const handler = async (_evt: Electron.IpcRendererEvent): Promise<void> => {
try {
await cb();
} catch (err) {
console.warn('[wipe] renderer cb failed', err);
}
ipcRenderer.send(CHANNELS.APP_WIPE_BEFORE_QUIT + ':done');
};
ipcRenderer.on(CHANNELS.APP_WIPE_BEFORE_QUIT, handler);
return () => ipcRenderer.removeListener(CHANNELS.APP_WIPE_BEFORE_QUIT, handler);
},
} as const;
export type ElectronAPI = typeof api;
contextBridge.exposeInMainWorld('electronAPI', api);
+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();
});
}
+16 -2
View File
@@ -4,9 +4,23 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>ChatApp</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Netralax</title>
<script>
// Apply persisted / system theme before paint to avoid FOUC.
(function () {
try {
var stored = localStorage.getItem('netralax.theme');
var dark =
stored === 'dark' ||
(stored !== 'light' &&
window.matchMedia('(prefers-color-scheme: dark)').matches);
if (dark) document.documentElement.classList.add('dark');
} catch (_) {}
})();
</script>
</head>
<body class="bg-[#0b0b0f] text-white antialiased">
<body class="bg-surface text-fg antialiased">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
+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.

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