oficina / meet Public
Calls native apps
The SwiftUI Mac and iPhone apps use the same Calls server and shared Go core.
Build and run
Requires macOS 14+, Xcode, Go 1.26 and network access for pinned dependencies.
make native-mac
open native/build/Calls.appSigned out, the home screen names the Calls server — calls.oficina.works unless you have chosen another — and offers one button, Sign in. It opens your default browser for the existing Home/Calls sign-in on that server. Open the emailed link normally, compare the code in the browser with the app, and confirm Sign in to Calls app. The app continues automatically. There is no address to type.
Join a call as a guest, under it, opens a screen with one field for the invite link someone sent you (/j/… or /m/…, on any server), a Paste control, Join and Cancel. The link is checked before anything opens, and a problem is said beside the field in plain words. Join runs that meeting's own browser sign-in — the pending-join for someone who is not a member — on the server the link names. The clipboard is read only through Paste, so iPhone never asks whether Calls may paste, and only a Calls link is taken from it.
The server is a setting. On a Mac it is Calls server in Calls → Settings…, after the mini-window settings; on iPhone the gear on the home screen (or Change beside the server line) opens a small settings sheet with the same field. The address must be https — plain http is accepted only for localhost, 127.0.0.1 and ::1, the rule callscore's transport enforces — with a host and nothing after it (no path, query or name and password); whitespace and trailing slashes are trimmed and a bare host is taken to be https. Reset to calls.oficina.works puts the default back. It is kept in UserDefaults (the default is kept as no value at all, so a later change of default reaches everyone who never chose). Changing it while signed in asks first, then signs out of the old server exactly as Sign out does — the Keychain copy forgotten, the old server asked to revoke the session — and it cannot be changed during a call. -CallsServer <origin> on the command line overrides it for one run without saving it, which is how the iPhone UI tests point the app at their own server.
Links. A meeting link — oficina-calls://join?url=… from the browser, or one pasted into the signed-in home's field — never changes the server setting. On the server you are signed in to, it opens with the session the app holds if that session may join the meeting (OpenLink asks the meeting's /info); if it may not — the app is signed in as somebody else — the home says so and offers a sign-in for that meeting only. A link from a signed-in browser's Join in the app also carries an offer (2026-10-02): the app takes it up at once, opens no browser, and the page the person came from asks them to confirm the app's code, handing over their own session — member or guest — for that meeting. Otherwise a link arriving through oficina-calls:// is offered, not started: the home names the server the meeting is on with a Sign in button (and Not now), so a browser never opens for a server the person has not seen named. One pasted into the field is the person's own act, so Open link goes straight to that sign-in. A link on another server signs in there for that meeting only, exactly as a guest does: that session is never kept, starts no meetings, and leaving the meeting (or Sign out) drops it — asking that server to revoke it — and comes back to the server's own home, signed in if a member's session is kept, signed out if not. The kept sign-in for the configured server is not replaced by a visit.
Once signed in — however sign-in began — the app is home: Start meeting, Join meeting when there is a meeting in hand (the link you signed in from, or the call you just left), a field to open a link someone sent you, and Sign out. A link on the instance you are signed in to opens with the session the app already holds, pasted or arriving as oficina-calls://join?url=…, and joins straight away; a link on another instance is that instance's sign-in, as before. Leaving a call comes back home.
The session is the person's whole session whether the browser landed on a link or on the meeting list — the link only decides where it lands. Who may start a meeting is requireMember's decision on the server, and a refusal is shown as the server gives it. Starting goes through POST /native/meetings/start — the same store.createMeeting, behind the same requireMember, as the web list's one-click button — and answers only the meeting's identifier. The invitation is not part of the answer: a link is a credential, and the room's own Share is where one is minted.
The sign-in is kept. A member's session goes into the Keychain — a generic password, this device only, readable while unlocked — and the next launch checks it with the instance before offering anything ("Checking your sign-in"): one GET /settings, member-only, read by status alone. A member's page means signed in; the sign-in redirect means the session has ended (thirty days, or revoked), so it is forgotten and the home says so and points at Sign in; anything else — no network, a 5xx — keeps it, uses it, and offers Check sign-in again. Only a member's session on the configured server is kept: a guest's, an invited non-member's, or one on another server is good for the meeting it came in through and is signed in again from the next link. A session kept for a server that is no longer the configured one (the setting changed outside the app) is signed out at launch. A sign-in that fails — no network, a server that does not answer — says what happened and to choose Sign in (or Join a call as a guest) to try again. Sign out forgets the Keychain copy at once and asks the instance to revoke the session with the web's own POST /signout; if that cannot be reached, the session lapses at its thirty days.
On a Mac, the this-device-only keychain needs a keychain-access-groups entitlement that the ad hoc signed make native-mac build does not carry. There the item falls back to the login keychain, protected by the login password and the app's code signature; because an ad hoc signature changes on every rebuild, macOS may ask once per build whether Calls may read it.
Scheduling a meeting for later, ending one, and replacing invitations stay on the web for this milestone.
Devices in the call opens the camera, microphone and speaker panel. The audio readout is behind its ⓘ (Audio details), with the screen-share line while sharing; the panel itself shows one warning line only when something is wrong (a stretched or broken-up signal, echo cancellation off, noise reduction bypassed, processing refused — AudioWarning).
Sound is measured, not inferred (spec §15.20). Since 0.3.7 a tile shows it as the speaking light on the name — since 0.4.3 the warm rim (SpeakingGlow and WarmRim: -42 to -12 dBFS, lit once speech has kept up 0.15 s, 50 ms attack, 300 ms release, a steady 0.6 under Reduce Motion; see DESIGN.md) — in place of the white ring and the meter. On 2026-09-25 Tony, in a browser, could not hear Paul on the Mac app, and Paul's panel looked fine because nothing on it measured sound. Now:
- Your tile's rim and the panel's Microphone level meter what is sent — the capture hook's level after noise reduction (
CaptureLevel, read at 10 Hz). Unmuted and capturing, with nothing reaching the hook above -90 dBFS before the suppressor for five seconds, the tile says "Your microphone isn't picking anything up", and its ⓘ names the device and input being sent ("Scarlett 18i8 USB, input 1"). Only once the hook has had buffers while a connection was up, and only while one still is (MicSample.sending): alone in a call WebRTC never opens the microphone, and 0.4.6 told Paul, alone, that his was dead (2026-10-03). - Everyone else's tile meters what arrives from them: a renderer on their remote audio track (
RemoteAudioTap), which sees each decoded frame before the output volume, read at 5 Hz. Muted, the tile shows the Muted badge and no glow. Reporting themselves unmuted, connected, with audio bytes still rising (inbound-rtp, onestatistics(for:)per receiver a second) and the decoded peak under -85 dBFS for five seconds, it says "No sound from Bea" instead, and the app reports those people asnoSoundFromthrough the bridge. A thin white ring marks whoever is speaking (with hysteresis; no fade under Reduce Motion), and the mini window's tab shows a waveform while anyone is. - Their report about you shows on their tile: "Tony's browser is blocking sound — they need to click to hear you", or, while you are unmuted, "Tony isn't getting any sound from you". Each line is in the tile's accessibility label and announced when it appears. The app itself is never blocked from playing sound, so it never reports
audioBlocked. - Who can hear you yet (spec §15.24): the app reports
hearing, the people whose audio path is established here (connected, packets in and decoded frames at the tap, or connected and muted), read five times a second until it is. From the moment someone joins, their tile says "Samuel is connecting and can't hear you yet" — past a one-second grace, with dots that take turns (steady under Reduce Motion) — until their report lists you, their own blocked or no-sound report replacing it, and never past twenty seconds (ConnectingWatch.swift). The mini window and its tab show a small ear for anyone it is not showing.
The rules (AudioLevelRules.swift) are held by AudioLevelTests on an injected clock; TestNativeMacCallsBrowser checks that the browser measures energy, not only bytes, in what the app sends, and that the app's receive meter hears the browser.
The camera is a picker on both platforms. Switching rests on the capturer feeding the same RTCVideoSource, so the RTCVideoTrack every peer connection already holds does not change and nothing renegotiates — the native counterpart of the browser's replaceTrack. Switches are serialised; the last pick wins.
The camera is remembered and watched (CameraDirector, ManagedCamera, held by CameraRecoveryTests), because of an ATEM Mini that dropped off USB mid-call in 0.3.1 and left the self tile on "Waiting for video":
- Remembered. A camera chosen in the panel is kept in UserDefaults by its
uniqueID, with its name for while it is missing. A call starts on it if it is connected, otherwise on the built-in camera (the front one on iPhone), otherwise on whatever there is, and the panel says so ("ATEM Mini isn't connected; using FaceTime HD Camera"). - Followed. Cameras connecting and disconnecting are observed — AVFoundation's connect and disconnect notifications and, on Mac, the discovery session's device list by KVO — with a look every three seconds as well while there is no working camera. The camera in use disconnecting falls back to another at once ("ATEM Mini disconnected; using FaceTime HD Camera"); the remembered camera coming back, or appearing later, is switched back to.
- Watched. Frames are counted on their way to the video source. No first frame within three seconds of opening, or none for three seconds after they had been arriving, restarts capture on the same device; still nothing tries the device's next format, then another camera. With nothing to fall back to, the camera is named as having no signal and kept, and a frame arriving later is noticed. A camera that fell silent is returned to only when it reconnects.
- Formats. Nearest 640 wide, then nearest 30 frames a second without going over, at a rate the format really supports: 30 where a range covers it, or the format's own rate (an ATEM's 1080p formats each run at exactly one of 23.98, 24, 25, 29.97, 30, 50, 59.94 or 60), set on the device exactly when it is not a whole number. An open that has not finished in six seconds counts as refused.
- Restarted after a capture session runtime error, an interruption ending, or the Mac waking, at most once every five seconds.
- Said. Your own tile says what the camera is doing instead of an endless "Waiting for video": "Starting camera…", "No signal from ATEM Mini — check its input", "Camera disconnected", "No camera found". The panel's Camera row is always there in a call — "No camera found" with Try again when there is none, and the picker with a plain-words line when the camera is not working.
The native/browser gate drives the same code with synthetic cameras, and stalls one mid-call: the watchdog has to restart it and the browser has to decode the picture again on the same connection.
The microphone is a picker on both platforms, and on Mac so is the speaker. On iPhone it is an audio session route. On Mac it is a CoreAudio device that the call's AVAudioEngine (LiveKit's engine module) is put on; switching mid-call rebuilds the engine without renegotiating. A choice there moves this call and leaves the Mac's system setting alone. Before the move to LiveKit's build the Mac could only report what macOS had chosen: that build's macOS slice had no audio device API at all.
The Mac's choices follow these rules (AudioRoutes, held by AudioRoutesTests):
- Remembered across calls and launches, in UserDefaults: the device's CoreAudio UID, and its name for while it is missing. A device back under a new UID (a USB interface on another port) is found by that name when exactly one connected device has it. Calls 0.3.1 forgot them at the end of every call. The panel says "Calls remembers these for next time."
- System default is the first entry of each picker, where someone who has never chosen starts, and a choice like any other (remembered when picked). It follows macOS: a new default device, or the device changing its sample rate, is followed by the module itself, which listens for both and rebuilds its engine.
- An explicit choice keeps its device; a change of system default does not move it.
- A chosen device that is not connected — at join or mid-call — puts the call on the system default for now, and the panel says so ("Scarlett 18i8 isn't connected; using MacBook Pro Microphone"). "Connected" means CoreAudio's own device list, never the engine module's. The note never names the missing device as the one in use. The choice is kept, and the call goes back to the device when it reappears; CoreAudio's device list and default devices are listened to for that, as well as the module's own report. No poller.
- A device the engine will not start on (the module gives up without saying so; the app notices an engine that is not running two seconds after a start, with no stop since) is set aside: "Calls couldn't open Scarlett 18i8 USB; using MacBook Pro Microphone", and the call goes back to the default. Choosing it again, or anything being plugged in or taken out, tries it again.
- Everything above is logged, with device lists, each engine start and the device its unit is actually on:
log show --last 1h --predicate 'subsystem == "net.amadan.oficina.calls" && category == "audio"' --info. - Nothing changes Sound settings. Mute is not remembered: every call starts unmuted.
- Input, for a microphone with more than one input (an audio interface): a picker under Microphone, "Input 1" to "Input 18" with the driver's channel names where it gives them, remembered per device UID. LiveKit's engine module wires the microphone in as mono, and the conversion from a multi-channel interface keeps its first input only, so a microphone on input 2 used to send silence. The module's observer now sets the input unit's channel map (
kAudioOutputUnitProperty_ChannelMap) so every channel it hands the engine is the chosen input, and connects nothing, leaving the module's own mono connection in place (InputChannel.swift). Changing it mid-call takes the module's input away and gives it back (setEngineAvailability), which rewires the microphone with the new map — a moment's gap, as a device switch has. Since 0.3.8 the map is strictly opt-in (InputChannelMap.channel): it is set only for an input other than the first, chosen on a microphone chosen by name that has more than one input. On the system default, or any ordinary microphone, the unit is left exactly as the module wired it — 0.3.3's capture — and the picker is not shown. - A chosen microphone changing rate (a Bluetooth headset moves to its hands-free 16 or 24 kHz the moment its microphone opens) is listened to, and a running engine wired at another rate is rebuilt (
RateRule, at most once every three seconds). On the system default the module follows the device itself, as it always has.
How the engine is put on a device (AudioDevices.swift, 0.3.7). Up to 0.3.6 the app chose through LiveKit's module (inputDevice = …). Paul's Scarlett 18i8 was not picked up at launch and would not stay chosen, and the module's source (webrtc-sdk m150_release, audio_engine_device.mm) and a probe of it show four reasons, any one of them enough:
- The module lists only "default" until the first peer connection initialises it, and raises no device-update callback when it does. At launch the remembered Scarlett was missing from a one-entry list, so the call fell back with "Scarlett 18i8 USB isn't connected; using …", and the speaker's note named the Scarlett as the fallback because it was the default output.
- Its list leaves out every virtual or aggregate device, and any device whose input streams have an unknown terminal type while it also has outputs.
- It wires the microphone in with the previous device's format and only then moves the engine's unit to the chosen device, so a device at another sample rate starts silent or not at all. A start that fails has already released the old engine, so the call is left with no audio.
trySetInputDevice/trySetOutputDeviceanswer YES on failure and NO on success, andinputDevice/outputDevicealways read back nil.
So the module is left on its default and never asked to choose. The pickers list CoreAudio's devices (ready at launch, virtual and aggregate devices included), and the module's "will enable" hook puts the engine's unit on the chosen device before the module reads a format from it. On a Mac, AVAudioEngine's input and output are one audio unit (measured: by default it runs on macOS's private CADefaultDeviceAggregate of the two defaults), so a microphone and speaker that are different devices are joined in a private aggregate of the app's own: microphone first (AVAudioEngine will not start an aggregate whose first device has no input), the speaker's clock leading, the microphone drift-corrected. A microphone with outputs of its own has the engine's output mapped past them. With nothing chosen the engine is left exactly as before, on macOS's own default. A change mid-call rebuilds the engine (both sides taken away and given back), a moment's gap in both directions. The device the unit is actually on is read back as the engine starts, and is what the readout names.
Test audio (home screen, and the device panel in a call) meters the capture hook — before noise reduction and after it — and records from the hook's sink. Before a call it builds a CallAudio as a call does and sends its microphone track between two in-process peer connections that are never connected, then asks the engine module to record (initRecording, startRecording: what WebRTC's first send stream calls). That last step is 0.3.8's. WebRTC starts a send stream, and with it the microphone, only on a connection whose transport has been writable (BaseChannel::IsReadyToSendMedia_w, was_ever_writable_), so in 0.3.6 and 0.3.7 the test never opened the microphone on any Mac: an empty meter and "We can't hear anything from …" for every device (Paul's AirPods, 2026-09-26). The interop harness passed because in manual rendering the module runs its input whenever its output runs; it now also runs the start on the real device module, with 0.3.7's start as a control that must never ask for the microphone. Calls themselves were not affected: their connections are connected.
Nothing in the audio start runs on the main thread (0.3.9). Paul's 0.3.8, on a MacBook Air with AirPods Pro: "after I open the Test window, there's a pause, then a beachball, then it works." A main-thread watchdog put numbers on it: building the factory and its engine module (100–135 ms) and logging the module's first device list (61–68 ms); applying the loopback's answer, which waits for the engine's playout to start (188–229 ms); and initRecording, which waits while the engine starts with the microphone — for a Bluetooth headset, while it moves from A2DP to its hands-free profile, one to two seconds. Every call into WebRTC's objects is a blocking hop to its own threads, and while the worker thread is starting the engine every one of them waits: the module's isRecording, isPlaying and device lists, the factory's processing state, a track's isEnabled (mute) and its processing options, 0.9–1.2 s each with the switch simulated. So all of it runs on one serial queue (AudioWork), in the order asked; the main thread reads the module's state from ModuleState, which waits at most 5 ms and otherwise gives the last answer. The window opens at once and says Starting microphone… beside a spinner — Switching Paul's AirPods Pro to microphone mode… for a Bluetooth headset not yet at its hands-free rate, because that wait is physics, not a fault — and the meter comes alive when the microphone has started. Closing it partway cancels cleanly: the stop is queued behind the start, so what the start opens is closed (MicStart). A call's join builds its media the same way (MediaEngine.make), and its connections' descriptions, candidates, statistics, sender parameters and closes go through the same queue (PeerCalls). Measured by CallsInterop --audio-start-stall on an M1 Max, longest main-thread stall, before → after: Test audio 142–716 ms → 0–5 ms; with a 1.5 s Bluetooth switch simulated 1,536–1,657 ms → 1–7 ms; closed mid-start → 0–8 ms; a call's audio start with the switch 1,322–1,514 ms → 21–43 ms.
The verdict names the device the microphone really is, with its connection and rate ("Paul's AirPods Pro (Bluetooth, 24 kHz)"), and tells apart "no sound is arriving" (no buffers at all: the microphone was asked for and nothing came back) from "sound is arriving, but it is silent". Bluetooth rates, which the suppressor passes through, are metered and recorded like any other.
Copy audio diagnostics, in Test audio (in the panel's ⓘ it is Copy call diagnostics, with the screen's account added), puts text on the clipboard to paste into a message: the devices chosen and what they resolve to, every CoreAudio device with its rate and transport, the engine module's state and last few engine events with their formats, the capture hook's rate and buffer counts, the processing report, the last ten seconds of meter readings in dBFS, and the last 50 lines of the audio log (which is also kept in memory, the last 200 lines). Never any audio.
Calls 0.2.1 (build 3) routed audio differently: its own CoreAudio audio device (CallsAudio, one pair of HAL units per call) behind WebRTC's custom-audio-device constructor. That constructor takes no audio processing module, so it cannot carry the noise suppressor; the rules above are that build's, carried onto LiveKit's module, and CallsAudio was not kept.
The camera list includes external and Continuity cameras (the Mac app declares NSCameraUseContinuityCameraDeviceType) and refreshes every second while the device panel is open. A camera switch that is refused tries to restore the previous camera, and the picker returns to the camera actually in use.
Apple's voice processing is not allowed on Mac (setPlatformVoiceProcessingAllowed(false)). Left allowed, the engine module resolves echo cancellation, noise suppression and gain to Apple's coupled voice-processing unit; with it off, WebRTC's own software processing runs, as it always has on Mac. One consequence: macOS offers its Mic Mode menu (Voice Isolation and the rest) only to apps on voice processing, so it is not offered to Calls on Mac. The iPhone still runs on the voice-processing unit.
The native/browser gate cannot open a microphone (a test process has no permission to), so CallsInterop gives the engine module a synthetic voice through manual rendering mode, as it gives the capturer synthetic frames; no device is opened on either side of it.
Reduce background noise (in the device panel, on by default, remembered) replaces WebRTC's classic noise suppressor with a neural one, for the noises people actually hear: typing, wind, a room's echo. WebRTC's capture processing runs high-pass filter, echo canceller, noise suppressor and gain control, then hands each 10 ms frame to a capture post-processing hook (RTCDefaultAudioProcessingModule); the suppressor runs there, after the echo canceller has removed the far side. ProcessingPlan holds the rules:
- WebRTC's noise suppressor is off while ours runs, and ours never runs beside Apple's Voice Isolation (it stands aside when that is the active mic mode).
- WebRTC's gain control runs before the hook, so it would lift the room with a quiet voice and hand the suppressor more noise to remove. It is off while ours runs;
SpeechGainlifts the voice after the noise is gone instead — only upward, at most 12 dB, learning only from frames the model calls speech, about 1 dB a second, with a peak limit. - The models run at 48 kHz only. At any other capture rate (a Bluetooth headset's 16 kHz) frames pass through untouched and WebRTC's own suppressor and gain come back on the Mac.
- Off means no suppression at all; WebRTC's gain control comes back.
- On iPhone Apple's echo canceller and its noise suppressor are one switch in the voice-processing unit, and the phone has always run both; that pairing stays. Apple's gain control is separate and comes off while ours runs.
The audio readout says which suppressor is running and at what share of a core, and what WebRTC reports as running for echo cancellation, noise suppression and gain — what it reports, not what was asked. WebRTC skips the hook while the microphone is muted, and the readout says so. WebRTC opens the microphone only once a connection is sending, so a person alone in a call has none running yet; until then the processing module reports its construction default (16 kHz, everything off), and on Mac the readout says "The microphone has not started yet" instead of repeating that. Every initialisation of the capture hook is reported to the plan, a repeat of the same rate included, so a microphone really capturing at 16 kHz brings WebRTC's own suppressor back.
On Mac the suppressor is DeepFilterNet 3; on iPhone it is RNNoise.
- DeepFilterNet 3 (Rikorose/DeepFilterNet's libDF, MIT or Apache-2.0; notice in
LICENSE.deepfilternet, bundled in the Mac app) is markedly better on non-stationary noise — typing, wind, voices in the next room. It takes 480-sample hops at 48 kHz in ±1 floats, so samples are scaled by 1/32768 on the way in and back on the way out, and adds about 30 ms of latency.scripts/build-deepfilter.shbuildsnative/build/DeepFilter.xcframework(static;aarch64-apple-darwin,aarch64-apple-ios,aarch64-apple-ios-sim) with a pinned rustup toolchain, cbindgen for the header, and the C API (--features capi), and copies the standard model (DeepFilterNet3_onnx.tar.gz, 8 MB) beside it; the Mac bundle carries the model. Needs rustup (https://rustup.rs). The library is linked on Mac only. The model takes about 0.3 s to load (on an M1 Max; longer on an Air), so one is loaded three seconds after launch, at utility priority, and the first Test audio or call takes it ready-made (DeepFilterPreload). A loaded model is about 88 MB resident, so only one is kept spare: taking it does not load another at once (a call would then hold two), a new spare is loaded when its user is done, and a spare nobody has taken for fifteen minutes is let go. Without a spare a call starts on RNNoise and moves to DeepFilterNet when one has loaded, off the main thread, as before; a model is never handed from one user to the next, because its state holds the last audio it heard and libDF has no way to clear it. A missing model leaves it on RNNoise. (RNNoise itself is made in under a millisecond and is not preloaded.)CALLS_DEEPFILTER_MODELpoints at a model file outside a bundle (the tests and the interop harness). - RNNoise (xiph, BSD-3; notice in
LICENSE.rnnoise, bundled in both apps): 480-sample frames at 48 kHz in WebRTC's own ±32768 scale. Its model weights arrive as a generated C file of 78 MB, so it is not a source target:scripts/build-rnnoise.shfetches the pinned commit and the checksummed model and buildsnative/build/RNNoise.xcframework(static, Mac, iPhone and simulator). - Both are limited to 30 dB of attenuation: unlimited, a room goes to digital silence between words, which is heard as the line dropping. DeepFilterNet has the limit built in; RNNoise gets it by mixing the untouched input back in at -30 dB.
- Measured on an M1 Max (fixture test, isolated): RNNoise 0.69–0.76 ms and DeepFilterNet 3 1.16–1.23 ms per 10 ms frame, i.e. about 7% and 12% of one core. In the native/browser gate, as wall-clock share of the audio thread with Chromium busy on the same machine: RNNoise median 12.9%, DeepFilterNet median 15.1% (13–27%). DeepFilterNet has not been measured on an iPhone, which is why the phone stays on RNNoise; the xcframework already carries the iOS slices, so trying it is a Package.swift condition and a bundled model.
None of these libraries is committed: build-mac.sh and build-iphone.sh run all three scripts (bridge, RNNoise, DeepFilter), each cached against its pins; run them yourself before swift test.
Choosing a Bluetooth microphone on iPhone puts the call on the hands-free profile, which is narrowband in both directions. That used to happen silently to every call the moment any headset connected, because .allowBluetoothHFP was set unconditionally; it is now set only while a Bluetooth input is the chosen one, and the panel says what it costs.
On iPhone and iPad the panel also names Apple's Mic Mode — Standard, Voice Isolation or Wide Spectrum — and Change Mic Mode opens the system picker. Voice Isolation is the best noise suppression the device has (wind, typing, room echo), and it reaches the call because WebRTC's iOS audio device runs on Apple's voice-processing I/O unit. Apps cannot set the mode, only show it and open the picker; when the route cannot honour the chosen mode, the panel names the one actually in use. The Mac app does not offer it: WebRTC's macOS audio device does not use Apple's voice processing, so the mode would not reach the call.
The audio readout separates the two ways received audio goes wrong, which is the distinction a person reporting "it growled" cannot make: concealed samples are loss and sound like dropouts, while inserted and removed samples are the jitter buffer rescaling the signal to track the sender's clock — and a stretched voice is what a growl is. The device pickers let you compare routes while watching those counters.
Shared screens from anyone in the call — a browser or the other app — take the stage as a tile of their own, captioned "Name’s screen" exactly as the browser captions it, while the sharer's camera moves to a thumbnail beside yours. Nothing on the wire says which m-line is a screen; the app works it out with the browser's own rules (spec §5.4, classifyTrack in room.js, ported in ScreenShare.swift with its tests), so the two never disagree about whether they are looking at a face or a document. The tile goes on whichever of §5.4's two endings arrives first: the share {on:false} message, or the stopped transceiver taking its receiver away. Each was checked alone against a browser when this was written (the other one disabled); the native gate checks them together.
Share your screen on the Mac opens the system's own picker (SCContentSharingPicker, macOS 14) for a display or a window. What a person picks there is what they have agreed to share, so Calls asks for no Screen Recording permission, sends nobody to System Settings, and needs no Info.plist key — macOS has no usage string for screen capture — and the same path works in the App Sandbox. Calls' own windows are left out of the picker, and from a shared display too on macOS 14.4 and later, where the display is rebuilt excluding the app so that windows opening later (the floating mini window among them) stay out as well. The screen goes to every connection on its own sendonly transceiver in its own stream, exactly as the browser sends one, announced with share {on:true}; stopping stops the transceiver and renegotiates, so the far side's track ends rather than freezing, and says share {on:false}. The menu bar's stop control and a shared window closing end it the same way as the button, and so does Stop sharing in the "You are sharing your screen" notice itself (0.4.3; the iPhone's and the browser's notices have it too), which stays on screen while the controls fade. Frames are capped at 1920 on the long side and 30 a second.
Optimise shared screen for (in the device panel, remembered, applied live to a share in progress) decides what gives way when the uplink or the encoder cannot carry everything: Balanced (the default) holds the frame rate and sends full size while the link allows; Text and detail holds resolution and lets frames fall; Motion and video holds the frame rate at 1280 on the long side at most. They are sender parameters (ScreenSendPolicy), so nothing renegotiates. While a screen goes out it asks for H.264 first (Apple's hardware encoder), with every other codec still offered, and has high priority; the camera beside it is capped at 500 kbit/s and 360 lines, and given its defaults back when the share stops. While sharing, the panel shows one line on how the screen is actually leaving, e.g. "Screen: 1920×1080 at 8 fps, limited by bandwidth, 1.2 Mbit/s, direct (H264 hardware)", and a second line with the encoded and sent frame rates, keyframes made and asked for, and what the screen watchdog is doing. The Mac capture keeps six surfaces in its queue (three, in 0.4.x, left too few once the encoder and the repeat were holding theirs, and a capture with every surface held stops dead), drops a frame that arrives more than 100 ms late, and repeats the last frame once a second while the screen is still, so a keyframe can be made for a receiver that asks for one. A screen whose source is not a screencast (Motion, or a share started in it) is kept within H.264 level 3.1's 1280×720 worth of blocks: Chromium answers H.264 at that level, and the Mac's encoder refused larger frames outright (spec §15.21). The browser offers the same three choices, as contentHint, degradationPreference and sender parameters.
A screen that stops is put right without anyone sharing again (spec §15.21, after Paul's "my screen froze on her end … I had to stop and re-share", 28 Sep). The sharer's watchdog (ScreenWatchdog) looks once a second: a capture that reports nothing for three seconds is restarted on the same content; a connection that takes frames in and encodes or sends none for five seconds (past its first ten, and not held back by the link) gets a keyframe, then a fresh encoder (the screen a third smaller for thirty seconds), then a new transceiver, three seconds apart. A receiver — app or browser — whose view of someone's screen has decoded nothing for four seconds says so in its media report (screenStalled), the sharer sends that connection keyframes (and the fuller steps only if its own screen has stopped leaving too), and shows "Vicky's view of your screen stalled — recovering" while it does. Each step is in log show --predicate 'subsystem == "net.amadan.oficina.calls" && category == "screen"' and in Copy call diagnostics (the panel's ⓘ; Test audio's is still Copy audio diagnostics). The apps restart ICE only when a relay credential is due for renewal, not on every stream resume as 0.4.x did.
When keyframes are not enough (spec §15.22, after Paul's "still seeing issues with screen sharing after a while", 30 Sep, the notice sitting at "recovering"): a receiver still reporting six seconds after the first keyframe gets its screen rebuilt — a new transceiver for that person alone, so a new encoder here and a new receiver and decoder there, at the same size and mode (a report never shrinks a Text and detail share) — and the notice says "Restarting your share for Samuel…". Still reported ten seconds later, everyone's screen is shared again from the same capture, with no picker: what Stop and Share did by hand. Ten seconds after that the notice says "Couldn't restore Samuel's view — try Stop and Share again". A rebuild at most once a minute per person, a share again at most once every five minutes, and neither unless frames actually left for that person since the last step. The receiver hands a stalled screen's picture to its view again four seconds in, logs what its statistics say is still arriving (packets, frames received, decoded, dropped, keyframes, PLI, the decoder), holds its report for up to three minutes, and counts any change in the decoded count as movement (a rebuilt screen's count starts again from zero, which 0.4.3 read as still stalled). The iPhone's broadcast repeats its last frame once a second while the screen is still, as the Mac's capture does, so a still iPhone screen is no longer reported as stalled. The browser does all of this too (its sharer's rebuild and share again included), and has Copy call diagnostics at the foot of its Devices panel.
"Reconnecting…" is said only for a problem that lasts (spec §15.23): the signalling stream down 4 s, a connection disconnected/failed 3 s, or no audio for 2 s from someone unmuted — that one on their tile, "Reconnecting to Samuel…" — and it stays a second past the recovery. The stream's own renewal every few minutes is never said. ReconnectWatch; the connection log category.
The gate for it wedges a receiver's screen decoder, which keyframes cannot mend: screen-decoder-wedge in CallsInterop (run with CALLS_DECODER_WEDGE=1; a wrapping decoder factory that only the gate turns on), and in Chromium an encoded transform that swallows every frame. wedge, wedge-sticky and reshare in make native-pair, and wedge, wedge-sticky, web-wedge and web-wedge-sticky in make native-browser, hold the ladder to restoring the view within 30 s, or to saying it cannot. soak and web-soak (SCREEN_SOAK_SECONDS) and the pair's soak (SCREEN_PAIR_SOAK) are the long shares, run by hand.
The numbers behind the modes come from TestNativeMacScreenMotion (in make native-browser): the app shares a 1080p page scrolling at 30 fps with a window dragged across it, and Chromium measures what arrives, with the app's uplink estimate open and capped at 1.5 Mbit/s. Before this, a 1.5 Mbit/s uplink delivered 2 fps at full size. Balanced and motion must now arrive at 15 fps or more, and detail must keep 1920×1080.
On iPhone the whole screen is shared through a ReplayKit broadcast extension, which hands its frames to the app over a socket in an App Group container. See ios/README.md for how that works and what signing it needs.
Knocks are announced when someone asks to be let in while the app is not frontmost. The admission queue is already on screen for a member who is looking at the call; this is for the member who is in another Space, another app, or has the phone in a pocket.
A knock also reaches a member whose app is closed, where the server has an Apple push key. The app registers its device token at join (POST /native/devices); the hub calls back when someone begins waiting; the server pushes to every member who was not admitted in that room at that moment, because the ones who were can already see the queue.
Without a key the server sends nothing and says nothing, which is where this app was before push existed: knocks still reach everyone in the room over their own streams. It is configured with CALLS_APNS_KEY (the .p8 as Apple issues it, via carlos secrets set), CALLS_APNS_KEY_ID, CALLS_APNS_TEAM_ID, CALLS_APNS_TOPIC_IOS, CALLS_APNS_TOPIC_MACOS and optionally CALLS_APNS_SANDBOX. All five of the first are required together — a half-configured push is refused at boot rather than discovered on the first knock.
Someone who arrived while the queue was visible is never announced later, so tabbing away does not produce a pile of notifications about people who have been waiting a while. Only an arrival during an absence is news, and leaving the queue and knocking again is heard again.
On Mac, switching to another app during a call puts the call in a small floating window, bottom right by default, and coming back to Calls restores the call window as it was. It is the first setting in Calls → Settings…, on by default, with the corner beside it; dragging the window snaps it to the nearest corner and remembers that one. Mini window placement can instead be Anywhere I put it: the window stays exactly where it is dropped, remembered per display (by display ID, as an offset from that display's visible frame, so rearranging displays does not lose it) and clamped back on screen when a display changes. Holding ⌥ Option while dragging places it freely even when snapping to corners, for the rest of that call only. It resizes from its edges and corners at 16:9, from 200 pt wide up to 40% of the screen's width, and the size is remembered; snapping keeps it (MiniWindowResize, done by the window itself rather than AppKit's .resizable). Clicking the window brings Calls back; hovering shows Collapse, Mute and Leave. Collapse shrinks it to a tab against the nearest screen edge showing your microphone state and who the call is with (the other person's name, or a count); clicking the tab or dragging it away from the edge opens the mini window again, and double-clicking it brings the call window forward. Collapsed lasts for the rest of the call; every call starts expanded. The tab is the same window, so it floats, follows Spaces and stays out of screen captures exactly as the mini window does. There is no speaking light: nothing in the app measures audio level live.
Space toggles the microphone while the call window (or the mini window) is the key window, unless a text field or a control has the keyboard, where Space is typing or the control's own press; a held Space toggles once. Call → Mute Microphone (⌘⇧M) is the same toggle. The browser room does the same (never in an input, textarea, select, contenteditable, button, link or other control, and announced through the polite live region), and on iPhone and iPad a hardware keyboard's Space does it through a key command that yields to text editing and focus. Push-to-talk (holding Space while muted) is not built.
Call → Copy Invite Link (⌘⇧I), beside Mute, puts the meeting's invite link on the clipboard and says "Invite link copied" briefly over the stage (and to VoiceOver), from the call window or the mini window, with the controls shown or not. It asks the server the way the Share button does, which returns the meeting's current sealed invitation and never replaces or revokes one. It is off outside a call; a guest pressing it is told only members can share. An iPad's hardware keyboard has the same shortcut. The browser room does not: ⌘⇧I is Chrome's developer tools. It shows what Speaker view's stage shows (0.4.0), whichever layout the call window is in: whatever you pinned, otherwise the newest shared screen, otherwise whoever is speaking (see DESIGN.md), otherwise the first other participant in your order, with their muted and camera-off state, or your own camera while you are alone. It is not shown for a minimised call window, and ⌘H hides it with the rest of the app. The call window is hidden by transparency rather than ordered out, because SwiftUI treats an ordered-out window as closed, which would leave the call. The mini window is excluded from screen capture: a shared display leaves out the whole Calls application (macOS 14.4 and later), and the window also sets sharingType = .none, so it never appears in a screen this app shares.
Members can use Share in the call to retrieve the current invitation, copy it, or open the system sharing menu. This needs the server's POST /m/{sqid}/share endpoint and the encrypted-invitation migration.
There are no embedded webviews. SwiftUI renders the app and native WebRTC handles media. The shared Go core waits for a five-minute, one-use authorization bound to a proof secret held only by the app. Browser approval creates a separate, ordinary framework session, preserving the existing identity method and Home assertion authentication time. No browser cookie is copied, and no new bearer authentication type is introduced. A member's session is kept in the Keychain, as described above.
The server must include this branch's native authorization endpoints. An older server cannot complete native sign-in; the app reports that limitation. The production handoff endpoints were deployed in 86ed9f3. In-progress handoffs expire on server restart. There are at most 256 pending handoffs per server process.
make native-mac builds for the host Mac and signs the local .app ad hoc. Developer ID signing, notarization and App Store packaging remain follow-up work. The iPhone target, device builds and Safari tests are described in ios/README.md.
Quality reports and Report a problem
The apps send a connection-quality report every 30 seconds in a call and one at leave, and "Report a problem" (the ⋯ menu, the ⓘ panel, and the screen a call ends on) sends the call diagnostics with a note. Spec §15.26 says exactly what is in them and what never is; Settings has the disclosure and the "Send anonymous quality reports" switch. They land in the server's log as calls: quality and calls: problem report JSON lines under random call and peer tags, kept for the platform's seven days. To read them:
# The call at 15:40 today (Europe/Dublin), summarised
go run ./cmd/quality-summary --since 24h --at 15:40
# A reference someone was shown, with its diagnostics
go run ./cmd/quality-summary --problem K7Q2M9PD --since 7d
# The raw lines of one call
carlos logs --app calls --account oficina --since 24h -n 5000 --field call=<tag>docs/operations/quality.md has the line shapes, more queries, and the kill switch (CALLS_QUALITY_REPORTS=off).
Shared code
protocol/: the server and client use the same Go wire types. The hub keeps aliases so authorization and admission behavior remain in the server.callscore/: browser sign-in handoff, bounded SSE parsing, room state, per-target ordered sends, generation fences, retry, resume and ICE credential fetching. No UI, database, or media dependency.native/bridge: a narrow gomobile Objective-C interface consumed by Swift.native/apple/Sources/CallsKit: Swift adapter and native WebRTC media. The platform capture/UI portions are separate from the Go protocol implementation.native/apple/Sources/CallsMac: SwiftUI app and system-browser authentication host.native/apple/Sources/CallsInterop: a separate synthetic-media test executable; it is not bundled with Calls.app.
Go Mobile's cgo requirement is isolated in native/go.mod and the native build script. The existing server continues to build with CGO_ENABLED=0. WebRTC is LiveKit's build of Google WebRTC, livekit/webrtc-xcframework pinned exactly to 150.7871.02 (M150); SwiftPM verifies its published artifact checksum. It replaced stasel/WebRTC 152 because it carries what that build does not: an audio device module on macOS (device lists and selection) and a capture post-processing hook for noise suppression. Its Objective-C symbols are prefixed LK; CallsKit's WebRTCNames.swift aliases the old RTC… names once, so code and open branches keep using them and change only their import (import LiveKitWebRTC). Go Mobile is also pinned; so are RNNoise and DeepFilterNet (see above). The bridge includes Mac, iPhone and iOS Simulator slices. Generated frameworks, Xcode projects and binaries are ignored.
Verification
CGO_ENABLED=0 make ci
make race
CGO_ENABLED=0 make browser
make native-test
make native-browser
make native-pair
CGO_ENABLED=0 go test -tags browser -run TestNativeBrowserAuthentication ./internal/callstestTestNativeMacAudioStartStall (in make native-browser) runs CallsInterop --audio-start-stall: a watchdog posts to the main queue every 5 ms and times the answer while Test audio starts (plainly, with a 1.5 s Bluetooth switch simulated by holding the engine's start on WebRTC's worker thread, and closed partway through) and while a call's audio starts with the same switch, all on the real engine module. Each must hold the main thread under 100 ms; a control that blocks it for 300 ms on purpose must be seen.
swift test needs native/scripts/build-bridge.sh, native/scripts/build-rnnoise.sh and native/scripts/build-deepfilter.sh to have run. Its suppressor tests feed Tests/CallsKitTests/Fixtures/speech-48k.wav (macOS's own speech synthesiser, normalised to -23 dBFS, a second of silence ahead) mixed with seeded hiss and key clicks through each real suppressor, and require the noise-only lead-in to fall by at least 10 dB, the speech level to hold within 6 dB, and 80% of voiced frames to survive. The frame adapter, the gain stage and the processing plan have unit tests of their own.
The native/browser gate requires the same Playwright/Chromium setup as make browser, and working UDP. It creates two real magic-link sessions and a meeting on an isolated server, starts Chromium and the separate native test executable, then checks guest admission, actual incoming/outgoing media, clean departure and continuity through ten-second SSE renewals, bidirectional chat and the native sender's accepted-message history. Test speaker playback is disabled in both clients; audio checks use RTP counters, not audible output. The native side's microphone is a synthetic voice through the audio device module's manual rendering mode, so its capture passes through WebRTC's real processing: the gate asserts the noise suppressor processed frames, and that WebRTC reports its own suppressor and gain off and its echo canceller on. make native-browser builds CallsInterop itself; it used to run whichever binary an earlier build had left. It never needs production credentials or hardware camera access. The Go Home test separately exercises a session minted through Home assertions and explicit identity linking, including resume and meeting end.
The native/browser gate also shares screens both ways: the browser shares (a fake monitor, --auto-accept-this-tab-capture) and the app must caption and decode it beside the camera, then drop it when it stops; the app shares a synthetic screen and the browser must show it under the app's name, then see its received track end — once by the button and once by the capture ending on its own. make native-pair puts two apps in one call with no browser: a screen shared before the other app joined, both endings, and both apps sharing at once. TestNativeMacScreenRecovery (in make native-browser) and TestNativeMacScreenPairRecovery (in make native-pair) share a screen through relay renewals every twenty seconds and stream resumes every ten, a capture that stops, an encoder that stops, and a receiver that reports a stall, and require the watcher's screen to keep decoding or come back without a new share; SCREEN_PAIR_A/SCREEN_PAIR_B put an older build on either side, and screen-display shares this Mac's real display through ScreenCaptureKit and the hardware encoder (Screen Recording permission needed). It is separate because two unsigned apps on one Mac do not reliably connect at all (see TestNativeMacScreenShare), which looks like the machine rather than the feature.
The native chat gate sends a sequence of browser messages and renders the shared native chat view to native/build/chat-incoming.png for visual inspection of the latest message. The window stays off-screen. Native chat follows new messages and keeps its history visible during signaling reconnects.
There is no claim that a simulator or synthetic test proves physical camera, microphone permissions, audio-device switching, sleep/wake or Home passkeys. Those need a real-device acceptance pass. So does screen sharing: the Mac's system picker and the iPhone's broadcast extension cannot be driven by any gate here. Background calling (other than while sharing the iPhone's screen) and long-call TURN renewal parity are follow-up work.
See VERIFICATION.md for the completed gate record and acceptance limits.
App artwork
The iPhone asset catalog and Mac application resource use the approved Oficina Calls icon. Both are generated copies of Estilo's brand/png/calls-1024.png; update them with python3 brand/distribute.py calls /path/to/calls from Estilo. The Mac derives MacIcon.png with rounded corners and transparent padding using native/scripts/generate-mac-icon.swift. Its Finder ICNS and runtime Dock icon both use that Mac variant; the square master remains unchanged. The Lucide notice is retained in native/LICENSE.lucide.
Third-party notices ship in the apps' Resources:
| Notice | What | Mac | iPhone | |---|---|---|---| | LICENSE.lucide | Lucide icons | yes | yes | | LICENSE.webrtc | WebRTC's own BSD licence, as LiveKit ships it in LiveKitWebRTC.xcframework | yes | yes | | PATENTS.webrtc | WebRTC's additional IP rights grant (patents) | yes | yes | | LICENSE.rnnoise | RNNoise | yes | yes | | LICENSE.deepfilternet | DeepFilterNet's libDF | yes | no (not linked) | | LICENSE.deepfilternet-crates | the 90 Rust crates libDF links, generated by scripts/deepfilter-notices.py after build-deepfilter.sh; rerun it when the DeepFilterNet pin changes | yes | no (not linked) |
LICENSE.webrtc is WebRTC's own licence only. LiveKit's framework ships no notices for the third-party code Google's WebRTC compiles in (libvpx, libyuv, Opus, BoringSSL, abseil and others); those have not been gathered.
Native interface
See DESIGN.md for the first Oficina native visual direction, shared views, screenshot workflow and remaining interaction work.