Calls, in the window that can actually make them

The media is here because this is Chromium: a peer connection, a microphone
with echo cancellation, a camera, and hardware decode for whatever comes
back. The daemon carries the offer, the answer and the network paths, and
knows nothing about how any of it works.

Two buttons on a Matrix conversation, a stage that floats over whatever you
are reading - a call outlives navigating away, and one that vanished when
you looked elsewhere would be one you could not hang up - and a ringing
panel with two answers, because answering a video call with audio only is
a thing people do on purpose.

Screen sharing is here too, and is the half Discord will share: the picker
is the desktop's own list of screens and windows with a still of each,
since two browser windows are named almost the same and showing the wrong
one to a room is the mistake worth preventing. The screen goes in as its
own track, so somebody sharing is still in the call.

Three things this needed that were not obvious. Chromium will not hand over
a microphone unasked, and an unanswered permission request does not fail,
it hangs - which is what a call that never started looked like. A call
whose first phase was its initial value announced nothing, so no call ever
appeared on screen. And a machine with no camera should still make video
calls: the camera is dropped and it says so, rather than failing the whole
request, microphone included.

Verified between two accounts on different homeservers: rang, answered,
connected with live audio both ways, then a screen shared and drawn at the
far end.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-05 22:11:09 -04:00
co-authored by Claude Opus 5
parent 295d464301
commit 5d99b42b08
12 changed files with 1137 additions and 1 deletions
+1 -1
Submodule nobilis updated: 72a881e5dd...c9d06bcb44
+29
View File
@@ -6,11 +6,13 @@ import {
app,
BrowserWindow,
clipboard,
desktopCapturer,
dialog,
globalShortcut,
ipcMain,
nativeImage,
net,
session,
Menu,
screen,
shell,
@@ -550,6 +552,20 @@ function wireIpc(): void {
// far. Separate from pickFile because the two dialogs ask opposite
// questions, and a save dialog that cannot name a default file is a save
// dialog people cancel.
// What could be shared into a call. Thumbnails at a size worth looking at
// but not worth waiting for - this is a picker, not a preview.
ipcMain.handle(IPC.screenSources, async () => {
const sources = await desktopCapturer.getSources({
types: ['screen', 'window'],
thumbnailSize: { width: 320, height: 180 }
})
return sources.map((s) => ({
id: s.id,
name: s.name,
thumbnail: s.thumbnail.toDataURL()
}))
})
ipcMain.handle(IPC.pickSavePath, async (e, suggested?: string) => {
const parent = callerWindow(e) ?? mainWindow
if (!parent) return null
@@ -696,6 +712,19 @@ app.whenReady().then(() => {
electronApp.setAppUserModelId('com.salastil.moho')
app.on('browser-window-created', (_, window) => optimizer.watchWindowShortcuts(window))
// A call needs a microphone, and Chromium will not hand one over without
// being asked. Unanswered, the request does not fail - it hangs, which is
// what a call that never started looked like: no error, no ringing, nothing.
//
// Granted only to this application's own windows, on the default session.
// A sign-in window loads somebody else's page and runs in a partition of
// its own, which this handler never sees.
const allowed = new Set(['media', 'display-capture', 'audioCapture', 'videoCapture'])
session.defaultSession.setPermissionRequestHandler((_contents, permission, callback) => {
callback(allowed.has(permission))
})
session.defaultSession.setPermissionCheckHandler((_contents, permission) => allowed.has(permission))
// Someone tried to launch a second copy: treat it as "show me moho", which
// is almost always what they meant - especially when the window is hidden
// to the tray and looks like nothing is running.
+10
View File
@@ -105,6 +105,16 @@ const api = {
pathForFile: (file: File): string => webUtils.getPathForFile(file),
pickFile: (): Promise<string | null> => ipcRenderer.invoke(IPC.pickFile),
/**
* What could be shared into a call: every screen and window, each with a
* still of what is on it.
*
* Asked of the desktop rather than of the browser. Electron refuses
* getDisplayMedia's own picker, and a person choosing what to show a room
* needs to see which window they are choosing.
*/
screenSources: (): Promise<{ id: string; name: string; thumbnail: string }[]> =>
ipcRenderer.invoke(IPC.screenSources),
pickSavePath: (suggested?: string): Promise<string | null> =>
ipcRenderer.invoke(IPC.pickSavePath, suggested),
pickDirectory: (): Promise<string | null> => ipcRenderer.invoke(IPC.pickDirectory),
+10
View File
@@ -20,6 +20,7 @@ import { DownloadsPanel } from './components/DownloadsPanel'
import { JoinPanel } from './components/JoinPanel'
import { Toasts } from './components/Toasts'
import { IncomingCallPanel } from './components/IncomingCallPanel'
import { CallStage, IncomingMatrixCall, ScreenPicker } from './components/CallStage'
import { FileDrop } from './components/FileDrop'
import { TransferPanel } from './components/TransferPanel'
import { Icon, IconButton } from './components/Icon'
@@ -203,6 +204,15 @@ export default function App(): JSX.Element {
{/* Over everything: a call arrives while you are looking elsewhere. */}
<IncomingCallPanel />
{/* A Matrix call rings here too, but is answered by this window rather
than by the daemon - the media is the window's. */}
<IncomingMatrixCall />
<ScreenPicker />
{/* The call this window is holding, wherever the window has wandered
to. It does not live under the conversation: walking away from a
conversation does not hang up, and a call that vanished when you
looked at something else would be a call you could not hang up. */}
<CallStage />
{/* Over everything for the same reason: a file is offered while you are
reading something else, and often in a conversation you are not. */}
<TransferPanel />
+7
View File
@@ -8,6 +8,7 @@ import { ConversationTools } from './components/ConversationTools'
import { BufferFace } from './components/BufferFace'
import { FileDrop } from './components/FileDrop'
import { Toasts } from './components/Toasts'
import { CallStage, IncomingMatrixCall, ScreenPicker } from './components/CallStage'
import { Icon, IconButton } from './components/Icon'
import { useActiveBuffer, useChat, usePref, usePrefsReady, useStore } from './state/hooks'
import { bufferDisplayName } from './lib/util'
@@ -129,6 +130,12 @@ export function Popout({ bufferId }: { bufferId: string }): JSX.Element {
</div>
<FileDrop />
{/* A call rings wherever the client is, including here: this window has
its own connection to the daemon and its own media, so a call
answered here is answered here. */}
<IncomingMatrixCall />
<ScreenPicker />
<CallStage />
<Toasts />
</div>
)
+139
View File
@@ -846,6 +846,145 @@
margin-left: auto;
}
/*
* A call with pictures, over the conversation it belongs to.
*
* Sized so it can be looked at without swallowing the log: a call is watched
* and the conversation is read, and the two happen at once.
*/
.call-stage {
/* Over everything, because a call is not part of the conversation it
started in: it follows the window rather than the buffer, and has to be
hangable-up from wherever somebody has wandered to. */
position: absolute;
right: var(--spacing-m);
bottom: 90px;
z-index: 40;
width: 380px;
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
padding: var(--spacing-xs);
border: 1px solid var(--outline-medium);
border-radius: var(--corner-radius);
background: var(--surface-container);
}
.call-stage-video {
position: relative;
height: 214px;
border-radius: var(--corner-radius-small);
overflow: hidden;
background: #000;
}
.call-remote {
width: 100%;
height: 100%;
object-fit: contain;
}
/* Nothing to look at yet, so the element is not what is on screen. */
.call-remote.audio-only {
opacity: 0;
}
.call-stage-placeholder {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-s);
}
/* Yourself, small and out of the way: it is there to check you are in frame,
not to be watched. */
.call-local {
position: absolute;
right: var(--spacing-s);
bottom: var(--spacing-s);
width: 140px;
max-height: 100px;
border-radius: var(--corner-radius-small);
border: 1px solid var(--outline-medium);
background: #000;
object-fit: cover;
}
.call-stage-controls {
display: flex;
align-items: center;
gap: var(--spacing-xs);
padding: 0 var(--spacing-xs);
}
.call-stage-controls .small {
margin-right: auto;
}
/*
* Which window to show a room.
*
* Pictures rather than a list of names: two browser windows are called almost
* the same thing, and showing the wrong one to a room is the mistake this
* exists to prevent.
*/
.screen-picker {
display: flex;
flex-direction: column;
gap: var(--spacing-s);
max-width: 760px;
max-height: 70vh;
padding: var(--spacing-m);
border-radius: var(--corner-radius);
background: var(--surface-container);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
.screen-picker-head {
display: flex;
align-items: center;
gap: var(--spacing-s);
font-weight: 500;
}
.screen-picker-head .icon-button {
margin-left: auto;
}
.screen-picker-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: var(--spacing-s);
overflow-y: auto;
}
.screen-source {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
padding: var(--spacing-xs);
border: 1px solid var(--outline-medium);
border-radius: var(--corner-radius-small);
background: var(--surface-container-low);
color: var(--surface-text);
cursor: pointer;
}
.screen-source:hover {
border-color: var(--primary);
}
.screen-source img {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: contain;
background: #000;
border-radius: var(--corner-radius-small);
}
/*
* The controls a service put on a message.
*
+173
View File
@@ -0,0 +1,173 @@
import { useEffect, useRef, useState } from 'react'
import { Icon, IconButton } from './Icon'
import { useChat, useStore } from '../state/hooks'
import { bufferDisplayName, classes } from '../lib/util'
/**
* A call with pictures in it, over the conversation it belongs to.
*
* Two tiles: the other end large, and this end small in the corner - the
* arrangement every video call has settled on, because the person you are
* looking at is the one you are talking to and the picture of yourself is
* only there to check you are in frame.
*
* Above the log rather than instead of it, for the reason the voice call view
* gives: a call and the conversation it is in are the same conversation, and
* people type links into a channel while talking in it.
*
* Deliberately not Matrix-shaped. It draws two streams and a row of buttons,
* which is what a call is on any service - the screen-share button here is the
* same button Discord's will be, and the tiles are the same tiles.
*/
export function CallStage(): JSX.Element | null {
const store = useStore()
const call = useChat((s) => s.activeCall)
const buffers = useChat((s) => s.buffers)
const localVideo = useRef<HTMLVideoElement>(null)
const remoteVideo = useRef<HTMLVideoElement>(null)
const [hasRemoteVideo, setHasRemoteVideo] = useState(false)
// The streams are not state: they are live objects that change tracks under
// a call, so the elements are pointed at them rather than re-rendered from
// them.
useEffect(() => {
if (!call) return
const attach = (): void => {
const { local, remote } = store.callStreams()
if (localVideo.current && local && localVideo.current.srcObject !== local) {
localVideo.current.srcObject = local
}
if (remoteVideo.current && remote && remoteVideo.current.srcObject !== remote) {
remoteVideo.current.srcObject = remote
}
setHasRemoteVideo(!!remote?.getVideoTracks().some((t) => t.readyState === 'live'))
}
attach()
// Tracks arrive after the call connects, and again when somebody starts
// sharing a screen - so this keeps looking rather than attaching once.
const timer = setInterval(attach, 700)
return () => clearInterval(timer)
}, [call, store])
if (!call) return null
const where = buffers.find((b) => b.id === call.bufferId)?.name ?? ''
const phase =
call.phase === 'ringing' ? 'Ringing…' : call.phase === 'connecting' ? 'Connecting…' : 'Connected'
return (
<div className="call-stage">
<div className="call-stage-video">
{/* Muted, because this is our own microphone coming back: playing it
would be the oldest mistake in video calling. */}
<video ref={remoteVideo} className={classes('call-remote', !hasRemoteVideo && 'audio-only')} autoPlay playsInline />
{!hasRemoteVideo && (
<div className="call-stage-placeholder muted">
<Icon name="call" size={28} />
<span>{phase}</span>
</div>
)}
<video ref={localVideo} className="call-local" autoPlay playsInline muted />
</div>
<div className="call-stage-controls">
{/* Which conversation this call is in, because the stage no longer
sits under it: a call outlives looking at something else. */}
<span className="small muted ellipsis">
{phase}
{where ? ` · ${bufferDisplayName(where)}` : ''}
</span>
<IconButton
name={call.muted ? 'mic_off' : 'mic'}
title={call.muted ? 'Unmute' : 'Mute'}
className={call.muted ? 'calling' : undefined}
onClick={() => store.toggleCallMute()}
/>
<IconButton
name={call.sharingScreen ? 'stop_screen_share' : 'screen_share'}
title={call.sharingScreen ? 'Stop sharing' : 'Share a screen or window'}
className={call.sharingScreen ? 'active' : undefined}
onClick={() => void store.toggleScreenShare()}
/>
<IconButton name="call_end" title="Hang up" className="calling" onClick={() => store.hangUpMatrixCall()} />
</div>
</div>
)
}
/**
* Which screen or window to show the room.
*
* A grid of what is actually open, with a still of each, because the names
* alone do not distinguish two browser windows - and showing the wrong window
* to a room is the mistake this dialog exists to prevent.
*/
export function ScreenPicker(): JSX.Element | null {
const store = useStore()
const sources = useChat((s) => s.screenSources)
if (!sources) return null
return (
<div className="lightbox-backdrop" onClick={() => store.chooseScreenSource(null)}>
<div className="screen-picker" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true">
<div className="screen-picker-head">
<span>Share a screen or window</span>
<IconButton name="close" size={16} title="Cancel" onClick={() => store.chooseScreenSource(null)} />
</div>
<div className="screen-picker-grid">
{sources.map((source) => (
<button
key={source.id}
type="button"
className="screen-source"
onClick={() => store.chooseScreenSource({ id: source.id, name: source.name })}
>
<img src={source.thumbnail} alt="" />
<span className="small ellipsis">{source.name}</span>
</button>
))}
</div>
</div>
</div>
)
}
/**
* Somebody ringing, and the two answers.
*
* Its own panel rather than a line in the log, because a call is answered in
* the seconds it is offered and a message that scrolls is not an offer. Two
* ways in, because an offer with a camera and one without are different
* things to accept - and answering a video call with audio only is a thing
* people do on purpose.
*/
export function IncomingMatrixCall(): JSX.Element | null {
const store = useStore()
const ringing = useChat((s) => s.ringingCall)
const buffers = useChat((s) => s.buffers)
if (!ringing) return null
const where = buffers.find((b) => b.id === ringing.bufferId)?.name ?? ''
return (
<div className="incoming-calls">
<div className="incoming-call">
<Icon name={ringing.video ? 'videocam' : 'call'} size={18} />
<span className="ellipsis">
{ringing.from} is calling{where ? ` in ${where}` : ''}
</span>
<button type="button" className="button" onClick={() => void store.answerMatrixCall(false)}>
Answer
</button>
{ringing.video && (
<button type="button" className="button" onClick={() => void store.answerMatrixCall(true)}>
With video
</button>
)}
<button type="button" className="button danger" onClick={() => store.declineMatrixCall()}>
Decline
</button>
</div>
</div>
)
}
@@ -492,6 +492,7 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem
const filterMenu = suggestions(query)
const isMatrix = buffer.accountId.startsWith('matrix:')
const stream = useChat((s) => s.kickStreams)[buffer.id]
const matrixCall = useChat((s) => s.activeCall)
const isDm = buffer.kind === 'dm'
// This conversation's call, not merely a call on the same account: one
// account can only be in one, but the button has to be right about which.
@@ -718,6 +719,25 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem
)}
</span>
)}
{/* Matrix's own calls, which this window makes rather than the daemon:
two buttons because a video call is a different thing to start, and
on a service where either is normal the choice belongs on the
header rather than inside the call. */}
{isMatrix && buffer.kind !== 'server' && !matrixCall && (
<>
<IconButton name="call" title="Voice call" onClick={() => void store.callMatrix(buffer.id, false)} />
<IconButton name="videocam" title="Video call" onClick={() => void store.callMatrix(buffer.id, true)} />
</>
)}
{isMatrix && matrixCall?.bufferId === buffer.id && (
<IconButton
name="call_end"
title="Hang up"
className="calling"
onClick={() => store.hangUpMatrixCall()}
/>
)}
{isDiscord && isDm && (
<IconButton
name={inCall ? 'call_end' : 'call'}
+273
View File
@@ -0,0 +1,273 @@
/**
* A Matrix call, which is a WebRTC call with a room for a telephone exchange.
*
* Matrix says the whole of it in events: `m.call.invite` carries the offer,
* `m.call.answer` the answer, `m.call.candidates` the network paths, and
* `m.call.hangup` the end. This turns those into the `Call` in webrtc.ts and
* back again, and knows nothing about how the media works - which is the
* point of the split.
*
* One call at a time per account, which is what the protocol assumes: every
* event after the invite carries the call id it belongs to, and a second call
* arriving while one is up is a busy signal rather than a second window.
*/
import { Call, type CallPhase } from './webrtc'
/** What the daemon sends up when a call event arrives in a room. */
export interface MatrixCallEvent {
accountId: string
bufferId: string
/** `m.call.invite`, `m.call.answer`, … */
kind: string
callId: string
from: string
/** Whether this is our own signalling coming back off the server. */
own: boolean
partyId?: string | null
content: Record<string, any>
}
/** A call being offered to this account, waiting to be answered or refused. */
export interface RingingCall {
accountId: string
bufferId: string
callId: string
from: string
/** Whether the offer carries video, so the answer can match it. */
video: boolean
offerSdp: string
/** When the offer stops being good, in epoch milliseconds. */
expires: number
}
export interface CallSurface {
send: (bufferId: string, type: string, content: Record<string, unknown>) => void
iceServers: (accountId: string) => Promise<RTCIceServer[]>
onRinging: (call: RingingCall | null) => void
onPhase: (phase: CallPhase, call: ActiveCall | null) => void
onError: (message: string) => void
}
export interface ActiveCall {
accountId: string
bufferId: string
call: Call
/** The other end's stream, once it arrives. */
remote: MediaStream | null
/** Whether this end offered video at the start. */
video: boolean
}
/**
* Whether an offer is for a call with pictures.
*
* Read from the SDP rather than from a field, because the SDP is the thing
* that decides: an offer with a video line wants a camera answered with a
* camera, and Matrix's own hint for it has changed spelling twice.
*/
export function offerHasVideo(sdp: string): boolean {
return /^m=video/m.test(sdp)
}
export class MatrixCalls {
private surface: CallSurface
private active: ActiveCall | null = null
private ringing: RingingCall | null = null
constructor(surface: CallSurface) {
this.surface = surface
}
get current(): ActiveCall | null {
return this.active
}
/** Rings whoever is in this conversation. */
async place(accountId: string, bufferId: string, video: boolean): Promise<void> {
if (this.active) {
this.surface.onError('Already in a call')
return
}
const callId = `moho-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const iceServers = await this.surface.iceServers(accountId)
const call = this.build(accountId, bufferId, callId, iceServers, true, video)
this.active = { accountId, bufferId, call, remote: null, video }
try {
await call.invite(video)
} catch (e) {
this.surface.onError(`Couldn't start the call: ${(e as Error).message}`)
this.end(false)
}
}
/** Answers the call that is ringing. */
async answer(video: boolean): Promise<void> {
const ringing = this.ringing
if (!ringing) return
this.ringing = null
this.surface.onRinging(null)
const iceServers = await this.surface.iceServers(ringing.accountId)
const call = this.build(ringing.accountId, ringing.bufferId, ringing.callId, iceServers, false, video)
this.active = { accountId: ringing.accountId, bufferId: ringing.bufferId, call, remote: null, video }
try {
await call.accept(ringing.offerSdp, video)
} catch (e) {
this.surface.onError(`Couldn't answer: ${(e as Error).message}`)
this.end(true)
}
}
/** Refuses it, which is a hangup with a reason. */
decline(): void {
const ringing = this.ringing
if (!ringing) return
this.ringing = null
this.surface.onRinging(null)
this.surface.send(ringing.bufferId, 'm.call.hangup', {
call_id: ringing.callId,
version: '1',
party_id: 'moho',
reason: 'user_hangup'
})
}
/** Ends whatever is up. */
end(tell = true): void {
const active = this.active
this.active = null
if (active) void active.call.hangUp('user_hangup', tell)
this.surface.onPhase('ended', null)
}
private build(
accountId: string,
bufferId: string,
callId: string,
iceServers: RTCIceServer[],
outgoing: boolean,
video: boolean
): Call {
const call: Call = new Call({
id: callId,
outgoing,
iceServers,
handlers: {
send: (signal) => {
const base = { call_id: callId, version: '1', party_id: call.partyId }
// Each signal is its own event type; the shapes differ enough that
// naming them here is clearer than a table.
if (signal.kind === 'invite') {
this.surface.send(bufferId, 'm.call.invite', { ...base, ...signal.content })
} else if (signal.kind === 'answer') {
this.surface.send(bufferId, 'm.call.answer', { ...base, ...signal.content })
} else if (signal.kind === 'candidates') {
this.surface.send(bufferId, 'm.call.candidates', { ...base, ...signal.content })
} else if (signal.kind === 'negotiate') {
// Adding a screen to a call in progress. Its own event type, so a
// client that does not understand it ignores it rather than
// treating it as a second call.
this.surface.send(bufferId, 'm.call.negotiate', {
...base,
lifetime: 30000,
...signal.content
})
} else if (signal.kind === 'hangup') {
this.surface.send(bufferId, 'm.call.hangup', { ...base, ...signal.content })
}
},
onRemoteStream: (stream) => {
if (this.active) this.active.remote = stream
this.surface.onPhase('connected', this.active)
},
onPhase: (phase) => {
if (phase === 'ended') this.active = null
this.surface.onPhase(phase, this.active)
},
onError: (message) => this.surface.onError(message)
}
})
void video
return call
}
/**
* One call event out of a room.
*
* Our own signalling coming back is mostly ignored - the server echoes
* everything - except for the one case where it means something: this
* account answering somewhere else, which stops the ringing here.
*/
handle(event: MatrixCallEvent): void {
const { kind, callId, content } = event
if (event.own) {
const answeredElsewhere =
kind === 'm.call.answer' && this.ringing?.callId === callId && content.party_id !== 'moho'
if (answeredElsewhere) {
this.ringing = null
this.surface.onRinging(null)
}
return
}
if (kind === 'm.call.invite') {
const sdp = content.offer?.sdp as string | undefined
if (!sdp) return
// Our own call, arriving at another of this client's accounts. Somebody
// signed in twice here and calling themselves is a real thing to do -
// it is how this was tested - and without this the same window would
// both ring and answer itself busy, killing the call it had just made.
if (this.active?.call.id === callId) return
// Busy: one call at a time, and the other end is told rather than left
// ringing at nothing.
if (this.active) {
this.surface.send(event.bufferId, 'm.call.hangup', {
call_id: callId,
version: '1',
party_id: 'moho',
reason: 'user_busy'
})
return
}
const lifetime = typeof content.lifetime === 'number' ? content.lifetime : 60000
this.ringing = {
accountId: event.accountId,
bufferId: event.bufferId,
callId,
from: event.from,
video: offerHasVideo(sdp),
offerSdp: sdp,
expires: Date.now() + lifetime
}
this.surface.onRinging(this.ringing)
return
}
if (kind === 'm.call.answer' && this.active?.call.id === callId) {
const sdp = content.answer?.sdp as string | undefined
if (sdp) void this.active.call.takeAnswer(sdp)
return
}
if (kind === 'm.call.candidates' && this.active?.call.id === callId) {
const candidates = (content.candidates as RTCIceCandidateInit[] | undefined) ?? []
void this.active.call.addCandidates(candidates)
return
}
if (kind === 'm.call.negotiate' && this.active?.call.id === callId) {
const description = content.description as RTCSessionDescriptionInit | undefined
if (description) void this.active.call.takeNegotiation(description)
return
}
if (kind === 'm.call.hangup' || kind === 'm.call.reject') {
if (this.ringing?.callId === callId) {
this.ringing = null
this.surface.onRinging(null)
}
if (this.active?.call.id === callId) this.end(false)
}
}
}
+290
View File
@@ -0,0 +1,290 @@
/**
* One end of a call, in the window rather than in the daemon.
*
* A call is WebRTC: two peers agreeing codecs and network paths, then sending
* each other encrypted RTP with echo cancellation, jitter buffers and hardware
* decode in between. This window is Chromium, which has all of that; the
* daemon has none of it, and writing it there in Rust would be writing a
* browser badly. So the media lives here and the daemon carries the
* signalling, which genuinely is its job - on Matrix the offer, the answer and
* the network candidates are events in a room, and rooms are what it knows.
*
* That is the same division Discord's voice already draws from the other side:
* there the daemon holds the audio because the connection is a bespoke
* protocol no browser speaks. Where the protocol is the browser's, the browser
* holds it.
*
* Deliberately protocol-agnostic. Nothing below knows what Matrix is: it is
* given a way to send a signal and told when one arrives, which is all a call
* needs from whatever is carrying it.
*/
/** What a call is doing, in the words a person would use. */
export type CallPhase = 'ringing' | 'connecting' | 'connected' | 'ended'
export interface CallSignal {
/** `offer`, `answer`, `candidates`, `hangup` - what happened. */
kind: string
content: Record<string, unknown>
}
export interface CallHandlers {
/** Sends one signal to the other end. */
send: (signal: CallSignal) => void
/** The other end's audio and video, once there is any. */
onRemoteStream: (stream: MediaStream) => void
onPhase: (phase: CallPhase) => void
/** Something went wrong that the person should hear about. */
onError: (message: string) => void
}
/** How long to wait for an answer before giving up on an unanswered call. */
const RING_TIMEOUT_MS = 60_000
/**
* A call in progress.
*
* Holds the peer connection, the local tracks and the small amount of state
* that decides what an arriving signal means. One per call; a second call is a
* second instance, and the caller decides whether that is allowed.
*/
export class Call {
readonly id: string
readonly outgoing: boolean
/** The party id both ends put on every signal, so a call answered on
* another device can be told apart from this one. */
readonly partyId: string
private pc: RTCPeerConnection
private local: MediaStream | null = null
private remote = new MediaStream()
private handlers: CallHandlers
// Starts as nothing rather than as ringing: the first real phase has to be
// announced, and a call whose initial value already said "ringing" told the
// window nothing when it started ringing - so no call ever appeared.
private phase: CallPhase | 'new' = 'new'
private ringTimer: ReturnType<typeof setTimeout> | null = null
/** Candidates found before there was anywhere to send them. */
private pending: RTCIceCandidateInit[] = []
private answered = false
/** The screen track while one is being shared, so it can be stopped. */
private screen: MediaStreamTrack | null = null
constructor(opts: {
id: string
outgoing: boolean
iceServers: RTCIceServer[]
handlers: CallHandlers
}) {
this.id = opts.id
this.outgoing = opts.outgoing
this.handlers = opts.handlers
this.partyId = `moho-${Math.random().toString(36).slice(2, 10)}`
this.pc = new RTCPeerConnection({ iceServers: opts.iceServers })
// Candidates are found for as long as a call lasts, not only at the
// start: a network changing under a call is exactly when the later ones
// matter, and a client that stopped listening after connecting would drop
// the call rather than move it.
this.pc.onicecandidate = (e) => {
if (!e.candidate) return
const candidate = {
candidate: e.candidate.candidate,
sdpMid: e.candidate.sdpMid,
sdpMLineIndex: e.candidate.sdpMLineIndex
}
// Nowhere to send them until the other end knows about the call.
if (!this.answered && this.outgoing) this.pending.push(candidate)
else this.handlers.send({ kind: 'candidates', content: { candidates: [candidate] } })
}
this.pc.ontrack = (e) => {
for (const track of e.streams[0]?.getTracks() ?? [e.track]) {
if (!this.remote.getTracks().some((t) => t.id === track.id)) this.remote.addTrack(track)
}
this.handlers.onRemoteStream(this.remote)
}
this.pc.onconnectionstatechange = () => {
const state = this.pc.connectionState
if (state === 'connected') this.setPhase('connected')
// "failed" is over; "disconnected" is not, and often comes back on its
// own when a network hiccups. Treating the second as the first is how
// a call ends itself over a lift ride.
if (state === 'failed' || state === 'closed') this.setPhase('ended')
}
}
get stream(): MediaStream | null {
return this.local
}
get remoteStream(): MediaStream {
return this.remote
}
get sharingScreen(): boolean {
return !!this.screen
}
private setPhase(phase: CallPhase): void {
if (this.phase === phase || this.phase === 'ended') return
this.phase = phase
this.handlers.onPhase(phase)
}
/**
* Microphone, and camera when this is a video call.
*
* A machine with no camera still makes calls. Asking for one it does not
* have fails the whole request - audio included - so a refused camera falls
* back to voice rather than to nothing, which is what somebody pressing
* "video call" on a desktop with no webcam actually wants.
*/
private async openLocal(video: boolean): Promise<void> {
const audio = { echoCancellation: true, noiseSuppression: true }
try {
this.local = await navigator.mediaDevices.getUserMedia({ audio, video })
} catch (e) {
const name = (e as Error).name
const noCamera = name === 'NotFoundError' || name === 'NotAllowedError' || name === 'OverconstrainedError'
if (!video || !noCamera) throw e
this.handlers.onError('No camera here - calling with audio only')
this.local = await navigator.mediaDevices.getUserMedia({ audio, video: false })
}
for (const track of this.local.getTracks()) this.pc.addTrack(track, this.local)
}
/** Rings somebody: opens the local media and sends the offer. */
async invite(video: boolean): Promise<void> {
await this.openLocal(video)
const offer = await this.pc.createOffer()
await this.pc.setLocalDescription(offer)
this.setPhase('ringing')
this.handlers.send({
kind: 'invite',
content: {
offer: { type: 'offer', sdp: offer.sdp },
lifetime: RING_TIMEOUT_MS
}
})
this.ringTimer = setTimeout(() => {
if (this.phase === 'ringing') {
this.handlers.onError('No answer')
void this.hangUp('invite_timeout')
}
}, RING_TIMEOUT_MS)
}
/** Answers a call that is ringing here. */
async accept(offerSdp: string, video: boolean): Promise<void> {
this.setPhase('connecting')
await this.pc.setRemoteDescription({ type: 'offer', sdp: offerSdp })
await this.openLocal(video)
const answer = await this.pc.createAnswer()
await this.pc.setLocalDescription(answer)
this.answered = true
this.handlers.send({ kind: 'answer', content: { answer: { type: 'answer', sdp: answer.sdp } } })
this.flushCandidates()
}
/** The other end answered a call this window made. */
async takeAnswer(answerSdp: string): Promise<void> {
if (this.answered) return
this.answered = true
if (this.ringTimer) clearTimeout(this.ringTimer)
this.setPhase('connecting')
await this.pc.setRemoteDescription({ type: 'answer', sdp: answerSdp })
this.flushCandidates()
}
/** Network paths from the other end. */
async addCandidates(candidates: RTCIceCandidateInit[]): Promise<void> {
for (const candidate of candidates) {
// A candidate for a call whose description has not arrived yet is not
// an error worth showing anybody - it is a race the specification
// expects, and the browser keeps them once there is a description.
try {
await this.pc.addIceCandidate(candidate)
} catch {
/* not usable yet, or already known */
}
}
}
private flushCandidates(): void {
if (this.pending.length === 0) return
this.handlers.send({ kind: 'candidates', content: { candidates: this.pending } })
this.pending = []
}
/** Silences the microphone, or lets it speak again. Returns muted. */
toggleMute(): boolean {
const track = this.local?.getAudioTracks()[0]
if (!track) return false
track.enabled = !track.enabled
return !track.enabled
}
/** Turns the camera off or on. Returns whether it is now on. */
toggleCamera(): boolean {
const track = this.local?.getVideoTracks()[0]
if (!track) return false
track.enabled = !track.enabled
return track.enabled
}
/**
* Shares a screen or a window, or stops.
*
* Added as its own track rather than replacing the camera: a person sharing
* their screen is still in the call, and every other client shows both.
* Renegotiation follows, because a new track is a new description.
*/
async shareScreen(source?: MediaStream): Promise<boolean> {
if (this.screen) {
this.screen.stop()
const sender = this.pc.getSenders().find((s) => s.track === this.screen)
if (sender) this.pc.removeTrack(sender)
this.screen = null
await this.renegotiate()
return false
}
const stream = source ?? (await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false }))
const track = stream.getVideoTracks()[0]
if (!track) return false
this.screen = track
// Stopping from the browser's own "stop sharing" control has to reach the
// call too, or the other end keeps a frozen last frame.
track.onended = () => void this.shareScreen()
this.pc.addTrack(track, stream)
await this.renegotiate()
return true
}
private async renegotiate(): Promise<void> {
const offer = await this.pc.createOffer()
await this.pc.setLocalDescription(offer)
this.handlers.send({ kind: 'negotiate', content: { description: { type: 'offer', sdp: offer.sdp } } })
}
/** A renegotiation from the other end - somebody starting to share. */
async takeNegotiation(description: RTCSessionDescriptionInit): Promise<void> {
await this.pc.setRemoteDescription(description)
if (description.type !== 'offer') return
const answer = await this.pc.createAnswer()
await this.pc.setLocalDescription(answer)
this.handlers.send({ kind: 'negotiate', content: { description: { type: 'answer', sdp: answer.sdp } } })
}
/** Ends the call, telling the other end unless it was them who ended it. */
async hangUp(reason?: string, tell = true): Promise<void> {
if (this.ringTimer) clearTimeout(this.ringTimer)
if (tell) this.handlers.send({ kind: 'hangup', content: reason ? { reason } : {} })
for (const track of this.local?.getTracks() ?? []) track.stop()
this.screen?.stop()
this.screen = null
this.pc.close()
this.setPhase('ended')
}
}
+183
View File
@@ -26,6 +26,8 @@ import { bufferDisplayName, isImageFile, resolveMediaUrl } from '../lib/util'
import { DM_GROUP_ID, isDirectMessage } from '../lib/groups'
import { ircNetworkFor } from '../lib/networks'
import { parseDeepLink, type IrcLink } from '../../../shared/deeplink'
import { MatrixCalls, type MatrixCallEvent, type RingingCall } from '../lib/matrixcall'
import type { CallPhase } from '../lib/webrtc'
import type { PopoutState } from '../../../shared/ipc'
/**
@@ -391,6 +393,25 @@ export interface ChatState {
pinnedMessages: Record<string, string[]>
/** Friends and pending requests per Discord account, as they change. */
discordFriends: Record<string, DiscordFriend[]>
/**
* A Matrix call: the one ringing, and the one that is up.
*
* Held here rather than in a component because a call outlives whatever is
* on screen - navigating away from the conversation does not end it, and
* the panel that shows it has to be able to find it again.
*/
ringingCall: RingingCall | null
/** The screens and windows on offer, while somebody is choosing one. */
screenSources: { id: string; name: string; thumbnail: string }[] | null
activeCall: {
accountId: string
bufferId: string
phase: CallPhase
/** Whether this end offered a camera. */
video: boolean
muted: boolean
sharingScreen: boolean
} | null
/** Who each Matrix account has asked never to hear from. */
matrixIgnored: Record<string, string[]>
/** Rooms each Matrix account has been invited to and not answered. */
@@ -473,6 +494,9 @@ const INITIAL: ChatState = {
reviewCard: null,
pinnedMessages: {},
discordFriends: {},
ringingCall: null,
screenSources: null,
activeCall: null,
matrixIgnored: {},
matrixInvites: {},
profile: null,
@@ -680,6 +704,55 @@ export class ChatStore {
/** clientId -> the optimistic message awaiting its real echo. */
private pendingSends = new Map<string, { bufferId: string; ts: number }>()
private toastSeq = 0
/**
* The Matrix call engine, one per window.
*
* Built here rather than per component so a call survives the panel that
* started it: walking away from a conversation does not hang up, and the
* bar that follows a call around has to find the same call.
*/
private matrixCalls = new MatrixCalls({
send: (bufferId, type, content) => {
void window.moho
.rpc('sendMatrixCallEvent', { bufferId, type, content })
.catch((e: Error) => this.toast('error', `Call signalling failed: ${e.message}`))
},
iceServers: async (accountId) => {
try {
const answer = await window.moho.rpc<{ uris?: string[]; username?: string; password?: string }>(
'matrixTurnServers',
{ accountId }
)
const uris = answer.uris ?? []
if (uris.length === 0) return []
// One entry carrying every uri, which is how a browser wants them:
// the credentials are the same for all of a homeserver's relays.
return [{ urls: uris, username: answer.username, credential: answer.password }]
} catch {
// No relay is a working configuration for two people on the same
// network, and a call worth trying is better than a refusal.
return []
}
},
onRinging: (call) => this.set({ ringingCall: call }),
onPhase: (phase, call) => {
if (!call || phase === 'ended') {
this.set({ activeCall: null })
return
}
this.set({
activeCall: {
accountId: call.accountId,
bufferId: call.bufferId,
phase,
video: call.video,
muted: this.state.activeCall?.muted ?? false,
sharingScreen: call.call.sharingScreen
}
})
},
onError: (message) => this.toast('error', message)
})
private sweepTimer: ReturnType<typeof setInterval> | null = null
private mentionsTimer: ReturnType<typeof setTimeout> | null = null
@@ -1586,6 +1659,110 @@ export class ChatStore {
.catch((e: Error) => this.toast('error', e.message))
}
/**
* Rings whoever is in this Matrix conversation.
*
* Its own method rather than part of callBuffer, which is Discord's: the two
* do genuinely different things - Discord's call is a channel the daemon
* joins, and this one is a connection this window makes.
*/
async callMatrix(bufferId: string, video: boolean): Promise<void> {
const account = this.accountFor(bufferId)
if (!account) return
await this.matrixCalls.place(account.id, bufferId, video)
}
async answerMatrixCall(video: boolean): Promise<void> {
await this.matrixCalls.answer(video)
}
declineMatrixCall(): void {
this.matrixCalls.decline()
}
hangUpMatrixCall(): void {
this.matrixCalls.end()
}
/**
* Puts the screen picker on screen and waits for a choice.
*
* A promise held here because the choosing is a component's job and the
* sharing is this one's - the alternative is the picker knowing how to
* start a screen share, which would put a call's mechanics in a dialog.
*/
private screenChoice: ((source: { id: string; name: string } | null) => void) | null = null
private askForScreenSource(
sources: { id: string; name: string; thumbnail: string }[]
): Promise<{ id: string; name: string } | null> {
return new Promise((resolve) => {
this.screenChoice = resolve
this.set({ screenSources: sources })
})
}
/** The picker answering, with a source or with nothing. */
chooseScreenSource(source: { id: string; name: string } | null): void {
const resolve = this.screenChoice
this.screenChoice = null
this.set({ screenSources: null })
resolve?.(source)
}
/** The two streams a call view draws: ours and theirs. */
callStreams(): { local: MediaStream | null; remote: MediaStream | null } {
const call = this.matrixCalls.current
return { local: call?.call.stream ?? null, remote: call?.remote ?? null }
}
toggleCallMute(): void {
const call = this.matrixCalls.current
if (!call || !this.state.activeCall) return
const muted = call.call.toggleMute()
this.set({ activeCall: { ...this.state.activeCall, muted } })
}
/**
* Shares a screen or a window into the call.
*
* The picker is the desktop's rather than the browser's: Electron will not
* answer getDisplayMedia without being told which source, and a list of the
* windows somebody actually has open is the only way to choose one.
*/
async toggleScreenShare(): Promise<void> {
const call = this.matrixCalls.current
if (!call || !this.state.activeCall) return
try {
if (call.call.sharingScreen) {
await call.call.shareScreen()
this.set({ activeCall: { ...this.state.activeCall, sharingScreen: false } })
return
}
const sources = await window.moho.screenSources()
if (sources.length === 0) {
this.toast('info', 'Nothing to share')
return
}
// Chosen in the window rather than here: the picker is a component, and
// this waits for it to answer.
const source = await this.askForScreenSource(sources)
if (!source) return
const stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
// Chromium's own desktop-capture constraint, which is how an
// Electron window turns a source id into a stream.
mandatory: { chromeMediaSource: 'desktop', chromeMediaSourceId: source.id }
}
} as unknown as MediaStreamConstraints)
await call.call.shareScreen(stream)
this.set({ activeCall: { ...this.state.activeCall, sharingScreen: true } })
} catch (e) {
this.toast('error', `Couldn't share the screen: ${(e as Error).message}`)
}
}
/** Follows a Kick channel, or stops. The header reads the answer back. */
setFollowing(bufferId: string, follow: boolean): void {
void window.moho
@@ -1602,6 +1779,12 @@ export class ChatStore {
if (!data.isOwn || !this.reconcileOwnEcho(data)) this.appendMessage(data.bufferId, data)
break
// One end of a Matrix call: an offer, an answer, the network paths, or
// a hangup. The media is this window's; the daemon only carries these.
case 'matrixCall':
this.matrixCalls.handle(data as unknown as MatrixCallEvent)
break
// Friends, and requests either way, as they change. Discord announces
// these rather than expecting a client to poll - which is what makes a
// request that arrives while the panel is open appear in it.
+2
View File
@@ -19,6 +19,8 @@ export const IPC = {
restartDaemon: 'moho:restartDaemon',
daemonStatus: 'moho:daemonStatus',
smiliesDir: 'moho:smiliesDir',
/** The screens and windows that could be shared into a call. */
screenSources: 'moho:screenSources',
markBufferRead: 'moho:markBufferRead',
browserLogin: 'moho:browserLogin',
popoutOpen: 'moho:popout:open',