Skip to content

Calls: real full screen, screen sharing, both at once; and multiple live accounts - #2015

Closed
damienheiser wants to merge 1 commit into
session-foundation:devfrom
damienheiser:feature/call-fullscreen-and-screenshare
Closed

damienheiser wants to merge 1 commit into
session-foundation:devfrom
damienheiser:feature/call-fullscreen-and-screenshare

Conversation

@damienheiser

Copy link
Copy Markdown

Opening this as a single branch because the pieces were built and tested together, but it is three independent changes and I am happy to split it into three PRs (full screen + discoverability / screen sharing / multi-account) if that is easier to review — CONTRIBUTING asks for small PRs and this is not one. Say the word and I'll break it up.

Everything below was observed in the running app, not asserted; §5 of each design document lists what was not verified as well.


Three pieces of work, each with a design document and results observed in the running app rather than asserted.

1. Call video: real full screen

"Full screen" was an absolutely-positioned overlay inside the renderer window — it filled the app, never the display. There was no call path to requestFullscreen() anywhere in the renderer.

  • The overlay now requests real OS full screen, and reconciles Redux with fullscreenchange so the browser's own exit paths (Escape, the OS control, another app taking the display) cannot leave the app believing it is still full screen.
  • The main process no longer persists window.fullscreen while a page is what put the window in full screen. Without that guard, quitting during a full-screen call made Session reopen full screen forever after.

2. Making it obvious

The control was a bare glyph at opacity: 0.4, with no tooltip, no label — and it only rendered when the remote peer happened to be sending video.

  • Always rendered during a call, labelled and captioned ("Full Screen" / "Exit Full Screen (Esc)"), with title and aria-label, at a legible resting opacity, plus a persistent exit pill inside the overlay.

3. Screen sharing, usable with full screen

There was no screen sharing at all. Two things in the old code each independently made "share while full screen" impossible: a container-level onClick that exited full screen on any click (including on the call buttons), and an effect that exited full screen whenever the peer's video muted. Both are gone.

  • Sharing reuses the call's single video sender via replaceTrack. Deliberately not a second video m-line: Session's call signalling has no mid-call renegotiation, so an extra m-line would break calls with unmodified clients. The trade-off, stated rather than hidden: while sharing, your camera is off, and the camera selected before the share is restored when it stops — including when the OS "Stop sharing" affordance ends the track.
  • Electron ships no source picker, so Session has its own (ScreenSharePicker.tsx), backed by desktopCapturer and setDisplayMediaRequestHandler. One portable path, no platform branch.
  • display-capture is gated on the same setting calls already require.

4. Multiple accounts, all of them live

Session holds one account per data directory — database, keys, settings, attachments, poller and store are all rooted there. So each account gets its own directory and its own process, which is what makes "all active" true rather than a claim: every account polls and notifies whether or not its window is the one on screen.

  • Switching is launching. Electron's single-instance lock is per data directory, so starting an account that is already running hands its argv to the live process, which focuses its window, and exits. One code path covers "start it" and "bring it to the front".
  • The first account resolves to the directory Session already used, so an existing install keeps its account with no migration step.
  • Removing an account from the switcher never deletes its data; the path is reported instead.

Not a unified inbox — that needs Session's per-process singletons to become per-account, which is a rewrite of the core rather than a feature on top of it.

Bugs found and fixed along the way

  • removeVideoEventsListener called splice(index) with one argument, removing every listener from that index onward — unmounting one call component silently detached the listeners of every component registered after it.
  • useVideoEventListener evaluated useMountedState() during render, which is false on the first render, so the listener it registered discarded every update it was ever handed. The in-conversation view got away with it because Redux re-renders it; the full-screen overlay mounts once and stayed frozen at its initial defaults — no srcObject, and no idea a share was running.
  • The single-instance check only quit on a failed lock when NODE_APP_INSTANCE === 0, so two processes could open one account's SQLCipher database. Found by testing, not by reading.

How this was verified

Two and then three real instances, real Session accounts, a real 1:1 call over the Session network. The app was driven through the Chrome DevTools Protocol and the main process through the Node inspector, so the numbers below are readings.

  • Main process reported BrowserWindow.isFullScreen(): true, getSize(): [1920, 1200] — the whole display.
  • The peer's remote <video> became 1920×1200 with real pixel content while sharing; after stopping, a flat frame (min = max = 1), i.e. the peer genuinely stops seeing the screen.
  • Share → full screen and full screen → share both work; clicking a control, or the video, inside the overlay no longer exits.
  • Zero "fullscreen":true persisted across the session despite the window really being full screen several times.
  • Multi-account: an existing install kept its account; a second account got its own directory and Session ID; a duplicate launch exited cleanly; clicking a background account flipped its window from isVisible: false to true; and with one account on screen and another's window hidden, a message sent to the hidden one arrived (unreadCount: 1).
  • pnpm build exit 0, pnpm lint clean, pnpm test 961 passing / 0 failing (953 before).

Full results, including what was not verified, are in docs/TDD-call-fullscreen-and-screenshare.md §5 and docs/TDD-multi-account.md §5.

Known gaps, stated plainly

  • macOS only. Nothing platform-specific is used, but Windows and Linux have not been run.
  • Interop with stock Session clients (Android/iOS/unmodified desktop) is by design — no signalling change, one additive data-channel field — but the tested call was between two copies of this build.
  • Multi-account was exercised on a development build; the packaged path (where NODE_APP_INSTANCE is forced empty) is handled explicitly in the design but unverified.
  • New user-facing strings are English-only constants marked TODO(l10n), because session-localization is a separate repository.

Things a reviewer will want to decide

  • Strings. The new user-facing strings are English-only constants marked TODO(l10n) because session-localization is a separate repository. Happy to open the matching PR there and swap them for tr() tokens — I just did not want to guess token names.
  • Screen share and the single video sender. Sharing replaces the camera track rather than adding a second m-line, so sharing means the camera is off. That was chosen to keep calls with unmodified Android/iOS/desktop clients working, since the call signalling has no mid-call renegotiation. If the project would rather have both tracks, that is a protocol change and a different PR.
  • sendVideoStatusViaDataChannel gained a screenShare field. It is additive and clients that don't know it simply read video as before, but it is a wire-format change and worth a look.
  • The single-instance exemption. NODE_APP_INSTANCE !== 0 no longer skips the quit-on-failed-lock path. This was the only way two processes could open one account's database once accounts have their own directories, but it does mean developers can no longer run two copies of the same instance name.
  • Multi-account shape. One process per account was chosen because Session's store, database handle, identity and poller are per-process. A unified inbox would need those to become per-account; that is a core rewrite, not this PR.

Three changes that were built and verified together. They are independent
and can be split into separate PRs on request.

1. Full screen is actually full screen
--------------------------------------
The call overlay was absolutely positioned inside the renderer window, so
it filled the app, never the display; nothing in the renderer ever called
requestFullscreen(). It now does, and reconciles Redux with the
'fullscreenchange' event so the browser's own exit paths - Escape, the OS
control, another app taking the display - cannot leave the app believing
it is still full screen.

The main process no longer persists window.fullscreen while a *page* is
what put the window in full screen. Without that guard, quitting during a
full screen call made Session reopen full screen forever after.

2. Making it discoverable
-------------------------
The control was an unlabelled glyph at opacity 0.4 which only rendered
when the remote peer happened to be sending video. It is now always
rendered during a call, labelled and captioned, with title and aria-label,
at a legible resting opacity, plus a persistent "Exit Full Screen (Esc)"
pill inside the overlay.

3. Screen sharing, usable *with* full screen
--------------------------------------------
Two things each independently made "share while full screen" impossible: a
container-level onClick that exited full screen on any click - including
on the call buttons - and an effect that exited full screen whenever the
peer's video muted. Both are gone.

Sharing reuses the call's single video sender via replaceTrack rather than
adding a second video m-line: Session's call signalling has no mid-call
renegotiation, so an extra m-line would break calls with unmodified
clients. The trade-off, stated rather than hidden: while sharing, the
camera is off, and the camera selected before the share is restored when
it stops - including when the OS "Stop sharing" affordance ends the track.

Electron ships no source picker, so Session has its own, backed by
desktopCapturer and setDisplayMediaRequestHandler. display-capture is
gated on the same setting calls already require.

4. Multiple accounts, all of them live
--------------------------------------
Session holds one account per data directory - database, keys, settings,
attachments, poller and store are all rooted there - so each account gets
its own directory and its own process. Every account polls and notifies
whether or not its window is the one on screen.

Switching is launching: Electron's single-instance lock is per data
directory, so starting an account that is already running hands its argv
to the live process, which focuses its window, and exits. One code path
covers "start it" and "bring it to the front".

The first account resolves to the directory Session already used, so an
existing install keeps its account with no migration step. Removing an
account from the switcher never deletes its data; the path is reported
instead. This is not a unified inbox - that needs Session's per-process
singletons to become per-account, which is a core rewrite.

Bugs found and fixed along the way
----------------------------------
- removeVideoEventsListener called splice(index) with one argument,
  removing every listener from that index onward, so unmounting one call
  component silently detached the listeners of every component registered
  after it.
- useVideoEventListener evaluated useMountedState() during render, which
  is false on the first render, so the listener it registered discarded
  every update it was handed. The in-conversation view got away with it
  because Redux re-renders it; the full screen overlay mounts once and
  stayed frozen at its initial defaults - no srcObject, and no idea a
  share was running.
- The single-instance check only quit on a failed lock when
  NODE_APP_INSTANCE was 0, so two processes could open one account's
  SQLCipher database.
- The account registry was cached per process, so a process worked from
  its start-up snapshot: a stale switcher, and - because every mutation
  writes the whole object - an account added in one window erased by an
  older process's next write. Reads now go to disk.

Design and observed verification, including what was not verified, are in
docs/TDD-call-fullscreen-and-screenshare.md and docs/TDD-multi-account.md.
@damienheiser
damienheiser force-pushed the feature/call-fullscreen-and-screenshare branch from 3979253 to dc6b01d Compare September 21, 2026 02:46
@damienheiser
damienheiser deleted the feature/call-fullscreen-and-screenshare branch September 21, 2026 08:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants