Commit Graph

124 Commits

Author SHA1 Message Date
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 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 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 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 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 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 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 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 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 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 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 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 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