oficina / meet Public

Merge branch 'fix/mac-icon-and-mini-centre' into main

Merge fix/mac-icon-and-mini-centre: a full-size Mac icon, a stage that fills the window, centred placeholders, a mini window that takes every press, the screen awake through a call; Mac and iPhone 0.4.10

- Mac icon (Paul, macOS 27: 'tiny'): Assets.car from an Icon Composer .icon (CFBundleIconName) for macOS 26+, no runtime Dock override there; MacIcon.png no longer padded twice (Dock artwork was 664 of 1024 since 0.4.5).
- Speaker view's stage fills the window on Mac, iPhone and web, the filmstrip floating over it and the stage's name just above it; cameras cropped with headroom; a shared screen fitted with its own soft dimmed picture around it (ScreenBackdrop; web canvas).
- Every tile placeholder centred in the whole tile, rising only to clear the name (PlaceholderPlacement; web flex spacers).
- Mini window: presses on the Muted mark, the connecting ear and the collapsed tab were lost to SwiftUI; now the surface's. Activation retried; reasserted in front; os_log category mini; CallsInterop --mini-clicks 45/45.
- Screen awake through a call: Mac ProcessInfo activity (CallWake), web Screen Wake Lock.
- callscore: keep a replaced default transport (fixes native-browser's panic since 38ad0b7).

Gates on 81f2dce: swift test 536 (0 failures, 3 skipped); GOTOOLCHAIN=go1.26.5 make ci; make native-test; make native-mac + 15 s launch smoke (Assets.car, CFBundleIconName); make iphone; iPhone call-screen UI tests 8/8; make browser (47 scenarios incl. new stage, node tests incl. room-wake); make native-browser 8/9, TestNativeMacBarLayout failing only on screencapture, unavailable in this session.
claude agent, operated by paul@keymail.dev 882eab847a71e2b6ce5f3aa283186fd04ed13f6b parent 0e2bd6aparent 81f2dce
37 files changed, +1896 −101
  • Makefile +1 −0
  • callscore/transport.go +11 −3
  • internal/calls/static/calls.css +63 −15
  • internal/calls/static/room.js +89 −0
  • internal/callstest/browser_test.go +1 −0
  • native/README.md +10 −3
  • native/apple/Sources/CallsInterop/Interop.swift +22 −0
  • native/apple/Sources/CallsInterop/StageSnapshots.swift +66 −0
  • native/apple/Sources/CallsKit/CallFixtures.swift +48 −0
  • native/apple/Sources/CallsKit/CallWake.swift +55 −0
  • native/apple/Sources/CallsKit/CallsAppearance.swift +3 −0
  • native/apple/Sources/CallsKit/CallsModel.swift +15 −1
  • native/apple/Sources/CallsKit/MacFullWindowCall.swift +14 −10
  • native/apple/Sources/CallsKit/MacVideoTile.swift +11 −9
  • native/apple/Sources/CallsKit/MiniWindow.swift +141 −16
  • native/apple/Sources/CallsKit/MiniWindowProbe.swift +196 −0
  • native/apple/Sources/CallsKit/MiniWindowRules.swift +40 −0
  • native/apple/Sources/CallsKit/MutedBadge.swift +62 −15
  • native/apple/Sources/CallsKit/ScreenBackdrop.swift +239 −0
  • native/apple/Sources/CallsKit/SpeakerView.swift +14 −0
  • native/apple/Sources/CallsMac/CallsMac.swift +7 −0
  • native/apple/Sources/CallsMac/Resources/MacIcon.png binary
  • native/apple/Tests/CallsKitTests/CallWakeTests.swift +77 −0
  • native/apple/Tests/CallsKitTests/MiniWindowGestureTests.swift +63 −0
  • native/apple/Tests/CallsKitTests/TileLayoutTests.swift +155 −0
  • native/ios/Sources/CallsPhone.swift +48 −11
  • native/ios/Sources/PhoneCallFixture.swift +6 −1
  • native/ios/Sources/PhoneFullScreenCall.swift +9 −7
  • native/ios/project.yml +1 −1
  • native/scripts/bundle-mac.py +31 −3
  • native/scripts/generate-mac-icon.swift +9 −3
  • test/browser/call.mjs +164 −3
  • test/browser/native-motion.mjs +2 −0
  • test/browser/native-screen.mjs +2 −0
  • test/browser/native.mjs +2 −0
  • test/browser/room-wake.test.mjs +152 −0
  • test/browser/synthetic-media.mjs +67 −0

CI passed — run details

CI log
=== vet ===
go vet ./...
=== fmt ===
=== test ===
go test ./...
ok  	amadan.net/oficina/calls/callscore	7.286s
ok  	amadan.net/oficina/calls/cmd/calls	13.206s
ok  	amadan.net/oficina/calls/cmd/quality-summary	0.003s
ok  	amadan.net/oficina/calls/internal/calls	2.148s
ok  	amadan.net/oficina/calls/internal/calls/apns	0.007s
ok  	amadan.net/oficina/calls/internal/calls/hub	0.325s
?   	amadan.net/oficina/calls/internal/calls/icons	[no test files]
?   	amadan.net/oficina/calls/internal/calls/keymailtest	[no test files]
ok  	amadan.net/oficina/calls/internal/calls/relay	0.003s
ok  	amadan.net/oficina/calls/internal/calls/seal	0.002s
ok  	amadan.net/oficina/calls/internal/callstest	10.139s
?   	amadan.net/oficina/calls/protocol	[no test files]
?   	amadan.net/oficina/calls/scripts/writeestilo	[no test files]
=== migration-check ===
go run amadan.net/rastrillo/rastrillo/cmd/rastrillo migration check
rastrillo migration check: models and migrations agree
=== race ===
CGO_ENABLED=1 go test -race ./internal/...
ok  	amadan.net/oficina/calls/internal/calls	12.692s
ok  	amadan.net/oficina/calls/internal/calls/apns	1.021s
ok  	amadan.net/oficina/calls/internal/calls/hub	2.824s
?   	amadan.net/oficina/calls/internal/calls/icons	[no test files]
?   	amadan.net/oficina/calls/internal/calls/keymailtest	[no test files]
ok  	amadan.net/oficina/calls/internal/calls/relay	1.016s
ok  	amadan.net/oficina/calls/internal/calls/seal	1.012s
ok  	amadan.net/oficina/calls/internal/callstest	30.630s
diff --git a/Makefile b/Makefile
index 278825d..976b7e1 100644
--- a/Makefile
+++ b/Makefile
@@ -158,6 +158,7 @@ browser:
node --test test/browser/room-window.test.mjs
node --test test/browser/room-lane.test.mjs
node --test test/browser/room-quality.test.mjs
+ node --test test/browser/room-wake.test.mjs
node --test test/browser/media.test.mjs
node --test test/browser/audio.test.mjs
node --test test/browser/devices.test.mjs
diff --git a/callscore/transport.go b/callscore/transport.go
index a953645..e64b3d7 100644
--- a/callscore/transport.go
+++ b/callscore/transport.go
@@ -74,9 +74,17 @@ func safeClient(client *http.Client) (*http.Client, error) {
}
safe := *client
if safe.Transport == nil {
- transport := http.DefaultTransport.(*http.Transport).Clone()
- transport.ResponseHeaderTimeout = 15 * time.Second
- safe.Transport = transport
+ // The default transport, with a deadline on the response's
+ // headers. A process that has replaced it (internal/callstest's
+ // TestMain installs keymailtest's, which answers DNS in-process)
+ // keeps its own: asserting *http.Transport panicked there.
+ if base, ok := http.DefaultTransport.(*http.Transport); ok {
+ transport := base.Clone()
+ transport.ResponseHeaderTimeout = 15 * time.Second
+ safe.Transport = transport
+ } else {
+ safe.Transport = http.DefaultTransport
+ }
}
safe.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
safe.Timeout = 0 // streams live for five minutes; individual calls have deadlines
diff --git a/internal/calls/static/calls.css b/internal/calls/static/calls.css
index a91b888..d22ccfc 100644
--- a/internal/calls/static/calls.css
+++ b/internal/calls/static/calls.css
@@ -622,6 +622,11 @@ body:has(> .calls-window) { background: var(--rst-bg); }
width: 100%;
height: 100%;
object-fit: cover;
+ /* A crop takes more from the bottom than the top (35:65): a face is
+ above the middle of the picture, and a crop through the forehead
+ reads worse than one through the chest. The native apps' VideoFrame
+ has the same headroom. */
+ object-position: 50% 35%;
}
.calls-tile-self:not(.calls-tile-share) video,
.calls-preview {
@@ -632,15 +637,36 @@ body:has(> .calls-window) { background: var(--rst-bg); }
is usually at the edges, so the whole screen is fitted into the tile
rather than cropped to fill it. */
.calls-tile-share video {
+ position: relative;
object-fit: contain;
+ object-position: 50% 50%;
background: #000;
}
+/* On the stage, what is beside or above the fitted screen is the same
+ picture enlarged, soft and dimmed, rather than black bars, as the
+ native apps' ScreenBackdrop: room.js draws a frame into this 64 × 36
+ canvas twice a second, and the browser's upscaling and a blur do the
+ rest. It is under the video (which is positioned, and after it). */
+.calls-tile-backdrop { display: none; }
+.calls-window #tiles > [data-stage].calls-tile-share > .calls-tile-backdrop {
+ display: block;
+ position: absolute;
+ inset: -8%;
+ inline-size: 116%;
+ block-size: 116%;
+ filter: blur(18px);
+ opacity: 0.45;
+ pointer-events: none;
+}
+.calls-window #tiles > [data-stage].calls-tile-share video { background: transparent; }
-/* Speaker view (0.4.0): the stage fills the window above the filmstrip,
- and the filmstrip runs along the bottom — tiles a sixth of the window
- high, 72 to 120px, centred while they fit and scrolling sideways once
- they do not — rising clear of the bar while the controls show, so
- with them hidden nothing lies over the stage. The stage is placed
+/* Speaker view (0.4.0): the stage fills the whole window, edge to edge,
+ as FaceTime's does, and the filmstrip floats over its bottom — tiles a
+ sixth of the window high, 72 to 120px, centred while they fit and
+ scrolling sideways once they do not — rising clear of the bar while
+ the controls show (2026-10-05: until then the stage stopped above the
+ filmstrip, leaving a black band behind it and the bar). The stage's
+ caption rides just above the filmstrip. The stage is placed
against .calls-stage rather than #tiles, so it neither scrolls with
the filmstrip nor is clipped by it; the tiles stay children of #tiles,
whose order is the reading order call-layout.js keeps. */
@@ -673,22 +699,29 @@ body:has(> .calls-window) { background: var(--rst-bg); }
.calls-window #tiles[data-layout='speaker'] > figure:not([data-stage]) { cursor: pointer; z-index: 1; }
.calls-window #tiles[data-layout='speaker'] > [data-stage] {
position: absolute;
- inset: 0 0 var(--calls-band) 0;
+ inset: 0;
block-size: auto;
inline-size: auto;
aspect-ratio: auto;
border-radius: 0;
background: #0b0b0c;
}
+/* The stage's caption: bottom left of the window, just above the
+ filmstrip (StageLabel in the native apps), and rising with it, clear
+ of the bar, while the controls show. In a call of two there is no
+ filmstrip (--calls-band is 0): the other person fills the window, you
+ float in a corner, and the caption clears the bar alone. */
.calls-window #tiles[data-layout='speaker'] > [data-stage] .calls-tile-name {
- inset-block-end: 12px;
+ inset-block-end: calc(12px + var(--calls-band));
inset-inline-start: 12px;
}
-/* A call of two: no filmstrip, the other person fills the window and
- you float in a corner, clear of the bar while it shows. */
-.calls-window[data-chrome='shown'] #tiles[data-layout='speaker']:not(:has(> figure:not([data-stage], .calls-tile-pip))) > [data-stage] .calls-tile-name {
+.calls-window[data-chrome='shown'] #tiles[data-layout='speaker'] > [data-stage] .calls-tile-name {
translate: 0 calc(-1 * var(--calls-lift));
}
+/* What the stage's camera-off cover keeps clear of: that caption, lifted. */
+.calls-window #tiles[data-layout='speaker'] > [data-stage] > .calls-tile-off {
+ --calls-caption-clear: calc(12px + 2rem + var(--calls-band) + var(--calls-lift));
+}
/* The self view, floating (Stage.selfFloats): about a fifth of the
window's width, 150 to 260px at 16:9, 12px in from the corner it was
@@ -804,21 +837,35 @@ body:has(> .calls-window) { background: var(--rst-bg); }
/* A camera that is off sends black frames, or holds the last one. The
cover says what that is instead of leaving a frozen face or a black
rectangle to be read as a fault. It lies over the video rather than
- hiding it: the element is also what plays the peer's audio. Its
- symbol and words are centred in the space above the caption. */
+ hiding it: the element is also what plays the peer's audio.
+ Its symbol and words are centred in the whole tile, as FaceTime has
+ them, with the caption over the bottom edge (2026-10-05; from 0.4.1
+ they were centred in the space above the caption, which left them
+ high). Only on a tile so short that centred they would reach the
+ caption do they rise, and by no more than that: the two spacers share
+ the free space equally until the lower one would be shorter than the
+ caption's clearance (--calls-caption-clear), when flexbox holds it at
+ that and gives the rest to the upper one. The native apps'
+ PlaceholderPlacement is the same rule. */
.calls-tile-off {
+ --calls-caption-clear: 2.75rem;
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
- justify-content: center;
- gap: 8px;
- padding-block-end: 2.75rem;
background: #212121;
color: rgb(255 255 255 / 0.8);
font-size: var(--rst-fs-sm);
}
+.calls-tile-off::before,
+.calls-tile-off::after {
+ content: '';
+ flex: 1 1 0;
+}
+.calls-tile-off::after { min-block-size: var(--calls-caption-clear); }
+.calls-tile-off > * { flex: none; }
+.calls-tile-off > * + * { margin-block-start: 8px; }
.calls-tile-off .icon { inline-size: 2rem; block-size: 2rem; }
.calls-tile-camera-off .calls-tile-name { z-index: 1; }
@@ -893,6 +940,7 @@ body:has(> .calls-window) { background: var(--rst-bg); }
font-size: var(--rst-fs-xs);
}
.calls-tile-name > span:first-child { flex: 0 1 auto; min-inline-size: 0; }
+ .calls-tile-off { --calls-caption-clear: 2rem; }
.calls-tile-name > :not(:first-child) { flex: none; }
.calls-tile-muted > :not(svg) {
position: absolute;
diff --git a/internal/calls/static/room.js b/internal/calls/static/room.js
index c1003cf..4fd5382 100644
--- a/internal/calls/static/room.js
+++ b/internal/calls/static/room.js
@@ -518,6 +518,7 @@
let reconnectTimer = 0;
let reconnectWatchTimer = 0;
let statsTimer = 0;
+ let backdropTimer = 0;
let fullTimer = 0;
let renewTimer = 0; // §5.3: the scheduled renewal, at `renewAt`
let iceRetryTimer = 0; // §5.3 step 1: the one-minute retry
@@ -5785,6 +5786,81 @@
mediaLive.textContent = text;
}
+ // screenWake keeps the screen on through the call (0.4.10): a call is
+ // often watched rather than typed into, and a screen dimming and
+ // locking under it reads as the call having ended. The Screen Wake Lock
+ // is held from the start of the call until it is left or ends. The
+ // browser lets it go whenever the tab is hidden, so it is asked for
+ // again when the tab is visible again, while still wanted; released on
+ // leaving. Where there is no navigator.wakeLock, or the request is
+ // refused (a battery saver, a policy), nothing is said: the call works
+ // as before. Self-contained, so room-wake.test.mjs can lift it.
+ function screenWake(nav, doc) {
+ let sentinel = null;
+ let wanted = false;
+ let asking = null;
+ const ask = () => {
+ if (asking) return asking;
+ if (!wanted || sentinel || !nav || !nav.wakeLock || doc.visibilityState !== 'visible') return Promise.resolve();
+ asking = Promise.resolve()
+ .then(() => nav.wakeLock.request('screen'))
+ .then((lock) => {
+ asking = null;
+ if (!wanted) return lock.release().catch(() => {});
+ sentinel = lock;
+ if (lock.addEventListener) {
+ lock.addEventListener('release', () => { if (sentinel === lock) sentinel = null; });
+ }
+ }, () => { asking = null; });
+ return asking;
+ };
+ doc.addEventListener('visibilitychange', () => {
+ if (doc.visibilityState === 'visible') ask();
+ });
+ return {
+ hold() { wanted = true; return ask(); },
+ release() {
+ wanted = false;
+ const lock = sentinel;
+ sentinel = null;
+ return lock ? Promise.resolve(lock.release()).catch(() => {}) : Promise.resolve();
+ },
+ get held() { return !!sentinel; },
+ };
+ }
+ const wake = screenWake(navigator, document);
+
+ // paintBackdrops draws the shared screen on the stage, if there is one,
+ // into its 64 × 36 backdrop canvas: what calls.css enlarges, blurs and
+ // dims around the fitted screen instead of black bars (2026-10-05, as
+ // FaceTime; the native apps' ScreenBackdrop). Twice a second, and only
+ // for the screen on the stage in a visible tab: one small drawImage,
+ // whose cost is kept on the canvas (data-cost-ms, the slowest so far)
+ // for the browser gate to report.
+ const BACKDROP_W = 64;
+ const BACKDROP_H = 36;
+ const BACKDROP_EVERY = 500;
+ function paintBackdrops() {
+ if (document.visibilityState !== 'visible') return;
+ const fig = document.querySelector('#tiles > [data-stage].calls-tile-share');
+ if (!fig) return;
+ const video = fig.querySelector('video');
+ const canvas = fig.querySelector('.calls-tile-backdrop');
+ if (!video || !canvas || video.readyState < 2 || !video.videoWidth) return;
+ const ctx = canvas.getContext('2d', { alpha: false });
+ if (!ctx) return;
+ const t0 = performance.now();
+ try {
+ ctx.drawImage(video, 0, 0, BACKDROP_W, BACKDROP_H);
+ } catch (err) {
+ return;
+ }
+ const cost = performance.now() - t0;
+ const worst = parseFloat(canvas.dataset.costMs || '0');
+ if (cost > worst) canvas.dataset.costMs = cost.toFixed(3);
+ canvas.dataset.painted = String((parseInt(canvas.dataset.painted || '0', 10) || 0) + 1);
+ }
+
// tileMarks builds one tile's pair of marks from the server's
// templates: the muted badge beside the name, and the camera-off cover
// over the video. The cover sits over the element rather than hiding
@@ -6508,6 +6584,8 @@
clearTimeout(reconnectTimer);
clearTimeout(fullTimer);
clearInterval(statsTimer);
+ clearInterval(backdropTimer);
+ wake.release();
clearInterval(reconnectWatchTimer);
clearInterval(levelTimer);
stopIceTimers();
@@ -6724,6 +6802,13 @@
// screen into the tile instead of cropping it to fill.
if (key.slice(-SHARE_SUFFIX.length) === SHARE_SUFFIX) {
figure.classList.add('calls-tile-share');
+ // What surrounds the fitted screen on the stage (paintBackdrops).
+ const backdrop = document.createElement('canvas');
+ backdrop.className = 'calls-tile-backdrop';
+ backdrop.width = BACKDROP_W;
+ backdrop.height = BACKDROP_H;
+ backdrop.setAttribute('aria-hidden', 'true');
+ figure.append(backdrop);
}
const label = document.createElement('figcaption');
label.className = 'calls-tile-name';
@@ -7208,6 +7293,8 @@
clearTimeout(reconnectTimer);
clearTimeout(fullTimer);
clearInterval(statsTimer);
+ clearInterval(backdropTimer);
+ wake.release();
clearInterval(reconnectWatchTimer);
clearInterval(levelTimer);
stopIceTimers();
@@ -8051,6 +8138,8 @@
// camera that stopped encoding (Codex S4-5), so the walk runs
// either way and does whichever halves apply.
statsTimer = setInterval(pollFrames, 1000);
+ backdropTimer = setInterval(paintBackdrops, BACKDROP_EVERY);
+ wake.hold();
// §15.23: no stats, only what the tab already knows.
reconnectWatchTimer = setInterval(watchReconnect, RECONNECT_EVERY);
// §15.20's meters, five times a second and cheaper per turn: one
diff --git a/internal/callstest/browser_test.go b/internal/callstest/browser_test.go
index 9f7952d..8851e48 100644
--- a/internal/callstest/browser_test.go
+++ b/internal/callstest/browser_test.go
@@ -85,6 +85,7 @@ var scenarios = []string{
"guestdirect",
"layout",
"glyphs",
+ "stage",
"window",
"title",
"chrome",
diff --git a/native/README.md b/native/README.md
index 213e279..8c57272 100644
--- a/native/README.md
+++ b/native/README.md
@@ -841,9 +841,16 @@ See [VERIFICATION.md](VERIFICATION.md) for the completed gate record and accepta
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
+The Mac's icon comes from the iPhone's full-bleed square, the one master.
+`native/scripts/bundle-mac.py` wraps it in an Icon Composer `AppIcon.icon` and
+compiles it with `xcrun actool` into `Assets.car`, named by `CFBundleIconName`:
+macOS 26 and later mask, light and tint it themselves, and actool flattens it
+onto the 824-on-1024 grid for macOS 14 and 15. This needs Xcode 26 or later.
+`native/scripts/generate-mac-icon.swift` derives `MacIcon.png`, the master
+rounded onto that grid, for `CallsMac.icns` (`CFBundleIconFile`) and for the
+Dock icon set at launch, which happens only before macOS 26 or outside a
+bundle. Never feed it `Resources/AppIcon.png`, which is already on the grid:
+padding it twice shrank the Dock icon to 664 of 1024 from 0.4.5. The Lucide
notice is retained in `native/LICENSE.lucide`.
Third-party notices ship in the apps' Resources:
diff --git a/native/apple/Sources/CallsInterop/Interop.swift b/native/apple/Sources/CallsInterop/Interop.swift
index 5a44ce5..3a61045 100644
--- a/native/apple/Sources/CallsInterop/Interop.swift
+++ b/native/apple/Sources/CallsInterop/Interop.swift
@@ -52,6 +52,28 @@ struct Input: Decodable {
} catch { exit(1) }
return
}
+ if CommandLine.arguments.count == 2, CommandLine.arguments[1] == "--mini-clicks" {
+ // Under NSApplication's own run loop, as the app is: the surface
+ // tracks a press with a nested event loop, which Swift's async main
+ // alone does not survive.
+ NSApplication.shared.setActivationPolicy(.accessory)
+ Task { @MainActor in
+ let call = Call(playAudio: false)
+ CallFixture.grid.apply(to: call)
+ let results = await MiniWindowProbe.run(call: call)
+ for r in results { print((r.passed ? "ok " : "FAIL ") + r.name + (r.passed ? "" : " — " + r.detail)) }
+ print("mini-clicks: \(results.filter(\.passed).count) of \(results.count) presses did what they should")
+ fflush(stdout)
+ exit(results.allSatisfy(\.passed) ? 0 : 1)
+ }
+ NSApplication.shared.run()
+ }
+ if CommandLine.arguments.count == 3, CommandLine.arguments[1] == "--stage-snapshots" {
+ do {
+ try await StageSnapshots.save(to: URL(fileURLWithPath: CommandLine.arguments[2]))
+ } catch { exit(1) }
+ return
+ }
if CommandLine.arguments.count == 3, CommandLine.arguments[1] == "--bar-layout" {
exit(await BarLayoutHarness.run(to: URL(fileURLWithPath: CommandLine.arguments[2])) ? 0 : 1)
}
diff --git a/native/apple/Sources/CallsInterop/StageSnapshots.swift b/native/apple/Sources/CallsInterop/StageSnapshots.swift
new file mode 100644
index 0000000..5b61f45
--- /dev/null
+++ b/native/apple/Sources/CallsInterop/StageSnapshots.swift
@@ -0,0 +1,66 @@
+import AppKit
+@_spi(Fixtures) import CallsKit
+import SwiftUI
+
+/// The stage and every tile's placeholder (2026-10-05), drawn off screen
+/// (no glass and no Metal video, as the other offline fixtures), with a busy frame as the picture on the stage's
+/// tile so the filmstrip, the names and the bar show over a picture:
+/// Speaker view with a camera on the stage, with a camera that is off on
+/// it, and with a shared screen on it, controls shown and hidden, at the
+/// default window and the smallest; Grid; and the mini window at 200, 320
+/// and 480 with someone whose camera is off, and its tab.
+@MainActor enum StageSnapshots {
+ static func save(to folder: URL) async throws {
+ NSApplication.shared.setActivationPolicy(.prohibited)
+ try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
+ func shot(
+ _ name: String, _ fixture: CallFixture = .grid, grid: Bool = false, shown: Bool,
+ size: CGSize = CGSize(width: 1000, height: 640), speaker: String?
+ ) async throws {
+ let call = Call(playAudio: false)
+ fixture.apply(to: call)
+ call.levels.speaker.showFixture(speaker)
+ try await draw(
+ MacCallView(
+ call: call, grid: grid, style: .fullWindow, controlsShown: shown, leave: {}, rejoin: {}
+ )
+ .tint(CallsAppearance.accent)
+ .environment(\.fixtureStagePicture, .busy),
+ size: size, to: folder.appendingPathComponent("mac-\(name).png"))
+ }
+ for shown in [true, false] {
+ let state = shown ? "controls" : "hidden"
+ try await shot("stage-camera-\(state)", shown: shown, speaker: "ana")
+ try await shot("stage-camera-off-\(state)", shown: shown, speaker: "tom")
+ try await shot("stage-screen-\(state)", .screen, shown: shown, speaker: "ana")
+ try await shot(
+ "stage-camera-760x540-\(state)", shown: shown, size: CGSize(width: 760, height: 540),
+ speaker: "ana")
+ try await shot(
+ "stage-screen-900x800-\(state)", .screen, shown: shown, size: CGSize(width: 900, height: 800),
+ speaker: "ana")
+ try await shot("grid-\(state)", grid: true, shown: shown, speaker: "ana")
+ }
+ for width in [MiniWindowRules.minimumWidth, MiniWindowRules.size.width, 480] {
+ for (who, label) in [("tom", "camera-off"), (nil, "self")] as [(String?, String)] {
+ let call = Call(playAudio: false)
+ CallFixture.grid.apply(to: call)
+ call.levels.speaker.showFixture(who)
+ if who == nil { call.showFixtureSelf(muted: false, cameraOff: true, sharingScreen: false) }
+ try await draw(
+ MiniWindowSnapshot.window(call: call, hovering: false),
+ size: CGSize(width: width, height: width / MiniWindowRules.aspect),
+ to: folder.appendingPathComponent("mini-\(Int(width))-\(label).png"))
+ }
+ }
+ let call = Call(playAudio: false)
+ CallFixture.grid.apply(to: call)
+ try await draw(
+ MiniWindowSnapshot.tab(call: call), size: MiniWindowTab.size,
+ to: folder.appendingPathComponent("mini-tab.png"))
+ }
+
+ private static func draw(_ content: some View, size: CGSize, to file: URL) async throws {
+ try await LobbySnapshots.render(content, scheme: .dark, size: size, to: file)
+ }
+}
diff --git a/native/apple/Sources/CallsKit/CallFixtures.swift b/native/apple/Sources/CallsKit/CallFixtures.swift
index 89f70b0..09e8543 100644
--- a/native/apple/Sources/CallsKit/CallFixtures.swift
+++ b/native/apple/Sources/CallsKit/CallFixtures.swift
@@ -157,6 +157,54 @@ extension EnvironmentValues {
}
}
+private struct FixtureStagePictureKey: EnvironmentKey {
+ static let defaultValue: FixtureBackdrop? = nil
+}
+
+extension EnvironmentValues {
+ /// A fixture's frame as the picture on the stage's tile, so snapshots of
+ /// Speaker view show how a picture sits under the filmstrip and the bar,
+ /// which a tile's placeholder cannot; nil in a real call.
+ @_spi(Fixtures) public var fixtureStagePicture: FixtureBackdrop? {
+ get { self[FixtureStagePictureKey.self] }
+ set { self[FixtureStagePictureKey.self] = newValue }
+ }
+}
+
+/// A fixture's frame drawn as a tile draws video: placed by `VideoFrame`
+/// (a camera's 16:9 picture filling the tile with headroom, a screen's
+/// 16:10 fitted whole) and, around a screen, the same frame soft and
+/// dimmed as `ScreenBackdrop` draws it.
+@_spi(Fixtures) public struct FixtureVideo: View {
+ let picture: FixtureBackdrop
+ let screen: Bool
+ public init(picture: FixtureBackdrop, screen: Bool) {
+ self.picture = picture
+ self.screen = screen
+ }
+ public var body: some View {
+ GeometryReader { geometry in
+ let bounds = geometry.size
+ let video = screen ? CGSize(width: 16, height: 10) : CGSize(width: 16, height: 9)
+ let placed = VideoFrame.place(video: video, in: bounds, fit: screen)
+ ZStack(alignment: .topLeading) {
+ if screen {
+ CallsAppearance.stageSurface
+ picture.view
+ .frame(width: bounds.width, height: bounds.height)
+ .scaleEffect(1.15)
+ .blur(radius: 24)
+ .opacity(BackdropSampler.dimmed)
+ }
+ picture.view
+ .frame(width: placed.width, height: placed.height)
+ .offset(x: placed.minX, y: placed.minY)
+ }
+ }
+ .clipped()
+ }
+}
+
/// The warm rim on a tile's name at one level, over each kind of picture
/// it has to read on (0.4.3): the rim mock-up's own dark and bright tiles,
/// for comparing with it, then white and the busy frame. The name is the
diff --git a/native/apple/Sources/CallsKit/CallWake.swift b/native/apple/Sources/CallsKit/CallWake.swift
new file mode 100644
index 0000000..86150d7
--- /dev/null
+++ b/native/apple/Sources/CallsKit/CallWake.swift
@@ -0,0 +1,55 @@
+import Foundation
+
+/// Keeps the Mac's display awake through a call (2026-10-19, for 0.4.10):
+/// a call is often watched rather than typed into, and the display going
+/// to sleep under it reads as the call having ended. Held from the moment
+/// the call is joined until it is left or ends, by a process activity
+/// that disables idle display sleep only. It does not stop a person
+/// locking the Mac, or the lid closing, and it goes with the process: the
+/// system ends an activity whose process quits or crashes. The iPhone
+/// keeps its own screen on with isIdleTimerDisabled.
+@MainActor public final class CallWake {
+ public static let reason = "In a Calls call"
+
+ /// What begins and ends the activity: ProcessInfo's, or a test's.
+ public protocol Activities {
+ func begin(reason: String) -> NSObjectProtocol
+ func end(_ activity: NSObjectProtocol)
+ }
+
+ struct ProcessActivities: Activities {
+ func begin(reason: String) -> NSObjectProtocol {
+ ProcessInfo.processInfo.beginActivity(
+ options: [.idleDisplaySleepDisabled, .userInitiated], reason: reason)
+ }
+ func end(_ activity: NSObjectProtocol) { ProcessInfo.processInfo.endActivity(activity) }
+ }
+
+ private let activities: Activities
+ private var activity: NSObjectProtocol?
+
+ /// Whether the display is being kept awake now.
+ public var holding: Bool { activity != nil }
+
+ public init(activities: Activities? = nil) {
+ self.activities = activities ?? ProcessActivities()
+ }
+
+ /// In a call or not: begins the activity on the way in, ends it on the
+ /// way out, and does nothing when nothing changed, so it can be told
+ /// as often as the state is published.
+ public func update(inCall: Bool) {
+ if inCall, activity == nil {
+ activity = activities.begin(reason: Self.reason)
+ } else if !inCall, let held = activity {
+ activities.end(held)
+ activity = nil
+ }
+ }
+
+ /// Whether a call in `phase` (the room's: "admitted", "ended", …) keeps
+ /// the display awake while `joining`.
+ public static func inCall(joining: Bool, phase: String?) -> Bool {
+ joining && phase != "ended"
+ }
+}
diff --git a/native/apple/Sources/CallsKit/CallsAppearance.swift b/native/apple/Sources/CallsKit/CallsAppearance.swift
index e72f292..cee6e96 100644
--- a/native/apple/Sources/CallsKit/CallsAppearance.swift
+++ b/native/apple/Sources/CallsKit/CallsAppearance.swift
@@ -8,4 +8,7 @@ public enum CallsAppearance {
/// the accent itself is a deep teal that would read as a shadow there.
public static let accentLit = Color(red: 0.18, green: 0.80, blue: 0.86)
public static let background = Color.primary.opacity(0.035)
+ /// The stage under a picture that does not cover it: the web call
+ /// window's #0b0b0c, a shade off black.
+ public static let stageSurface = Color(red: 11 / 255, green: 11 / 255, blue: 12 / 255)
}
diff --git a/native/apple/Sources/CallsKit/CallsModel.swift b/native/apple/Sources/CallsKit/CallsModel.swift
index fbd7441..4aa8f06 100644
--- a/native/apple/Sources/CallsKit/CallsModel.swift
+++ b/native/apple/Sources/CallsKit/CallsModel.swift
@@ -43,7 +43,12 @@ import Foundation
private var authEpoch = UUID()
private var callEpoch = UUID()
@Published public var signedIn = false
- @Published public var joining = false
+ @Published public var joining = false {
+ didSet { updateWake() }
+ }
+ /// The display kept awake through the call, Mac only (CallWake).
+ let wake = CallWake()
+ private var wakeWatch: AnyCancellable?
/// A meeting is starting on the server; there is nothing to join yet.
@Published public var starting = false
/// A link is being opened on the instance this app is signed in to,
@@ -96,6 +101,15 @@ import Foundation
self.remote = remote
self.defaults = defaults
server = CallsServer.load(from: defaults)
+ // A call that ends (the meeting ended, or this person was removed)
+ // lets the display sleep even before it is left.
+ wakeWatch = call.$state.sink { [weak self] state in self?.updateWake(phase: state?.phase) }
+ }
+
+ private func updateWake(phase: String? = nil) {
+ #if os(macOS)
+ wake.update(inCall: CallWake.inCall(joining: joining, phase: phase ?? call.state?.phase))
+ #endif
}
/// The server's host, for the line that names it.
diff --git a/native/apple/Sources/CallsKit/MacFullWindowCall.swift b/native/apple/Sources/CallsKit/MacFullWindowCall.swift
index d859891..d5f38e3 100644
--- a/native/apple/Sources/CallsKit/MacFullWindowCall.swift
+++ b/native/apple/Sources/CallsKit/MacFullWindowCall.swift
@@ -139,27 +139,31 @@
.onPreferenceChange(TileFramesKey.self) { tileFrames = $0 }
}
- /// Speaker view: the stage above, and everyone else in a filmstrip
- /// across the bottom of the window, in this viewer's order. The
- /// filmstrip rises clear of the bar while the controls show, as the
- /// self view does, and settles back to the window's edge when they go,
- /// so with the controls hidden nothing lies over the stage at all. In
- /// a call of two there is no filmstrip: the other person fills the
- /// window and you float in a corner, as since 0.3.4.
+ /// Speaker view: the stage fills the whole window, edge to edge, as
+ /// FaceTime's does, and everyone else is a filmstrip floating over its
+ /// bottom, in this viewer's order (2026-10-05: until then the stage
+ /// stopped above the filmstrip, leaving a black band across the
+ /// window behind it and the bar). The filmstrip rises clear of the bar
+ /// while the controls show, as the self view does, and settles to the
+ /// window's edge when they go. The stage's name sits at the window's
+ /// bottom left, just above the filmstrip (`StageLabel`). In a call of
+ /// two there is no filmstrip: the other person fills the window and
+ /// you float in a corner, as since 0.3.4.
private func speakerStage(in size: CGSize) -> some View {
let strip = selfFloats ? [] : filmstrip
let thumb = Self.filmstripTile(in: size)
let band = strip.isEmpty ? 0 : thumb.height + 2 * Self.filmstripPadding
+ let stageLift = StageLabel.lift(bar: lift, band: band)
return ZStack(alignment: .bottom) {
Group {
if let focused {
- macTile(focused, cornerRadius: 0, compact: true, labelLift: lift, onStage: true)
+ macTile(focused, cornerRadius: 0, compact: true, labelLift: stageLift, onStage: true)
.overlay(alignment: .bottomTrailing) {
if arrangement.pinned == focused {
PinBadge(title: itemTitle(focused)) {
withAnimation(panelAnimation) { arrangement.pin(nil) }
}
- .padding(8).padding(.bottom, lift)
+ .padding(8).padding(.bottom, stageLift)
}
}
} else {
@@ -173,7 +177,7 @@
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
- .padding(.bottom, band)
+ .animation(fade, value: chrome.visible)
if !strip.isEmpty {
filmstripView(strip, tile: thumb, width: size.width)
.padding(.bottom, chrome.visible ? Self.barReserve - 8 : 0)
diff --git a/native/apple/Sources/CallsKit/MacVideoTile.swift b/native/apple/Sources/CallsKit/MacVideoTile.swift
index 5d2bd88..2ba3e22 100644
--- a/native/apple/Sources/CallsKit/MacVideoTile.swift
+++ b/native/apple/Sources/CallsKit/MacVideoTile.swift
@@ -41,8 +41,9 @@
var iconOnly = false
/// The height of the name label and what is over it, lift included,
- /// which the placeholder stays above.
+ /// which the placeholder keeps clear of.
@State private var labelBlock: CGFloat = 0
+ @Environment(\.fixtureStagePicture) private var fixturePicture
var body: some View {
TileAudioReader(audio) { info in tile(info) }
@@ -51,7 +52,12 @@
private func tile(_ info: TileAudioInfo?) -> some View {
ZStack {
backdrop
- if let track, !cameraOff, placeholder == nil {
+ if let fixturePicture, onStage, track == nil, !cameraOff, placeholder == nil {
+ FixtureVideo(picture: fixturePicture, screen: screen)
+ } else if let track, !cameraOff, placeholder == nil {
+ // A screen on the stage is fitted whole; around it, the same
+ // picture soft and dimmed rather than black bars.
+ if screen, onStage { ScreenBackdrop(track: track) }
MacVideoView(track: track, fit: screen).scaleEffect(x: mirrored ? -1 : 1, y: 1)
} else {
TilePlaceholder(
@@ -145,14 +151,10 @@
override func layout() {
super.layout()
- let across = bounds.width / videoSize.width
- let down = bounds.height / videoSize.height
- let scale = fit ? min(across, down) : max(across, down)
- let size = NSSize(width: videoSize.width * scale, height: videoSize.height * scale)
+ // Bottom-left origin: VideoFrame works top down.
+ let placed = VideoFrame.place(video: videoSize, in: bounds.size, fit: fit)
renderer.frame = NSRect(
- x: (bounds.width - size.width) / 2,
- y: (bounds.height - size.height) / 2,
- width: size.width, height: size.height)
+ x: placed.minX, y: bounds.height - placed.maxY, width: placed.width, height: placed.height)
}
}
#endif
diff --git a/native/apple/Sources/CallsKit/MiniWindow.swift b/native/apple/Sources/CallsKit/MiniWindow.swift
index d4af785..ed55282 100644
--- a/native/apple/Sources/CallsKit/MiniWindow.swift
+++ b/native/apple/Sources/CallsKit/MiniWindow.swift
@@ -1,8 +1,18 @@
#if os(macOS)
import AppKit
import Combine
+ import OSLog
import SwiftUI
+ /// The mini window's decisions, for a "sometimes" (2026-10-05, Paul:
+ /// "Sometimes I can't click the mini window"): every press, what was
+ /// under it, what it was taken as, and whether bringing the call forward
+ /// worked. `log show --predicate 'subsystem == "net.amadan.oficina.calls"
+ /// && category == "mini"' --info`.
+ enum MiniLog {
+ static let logger = Logger(subsystem: "net.amadan.oficina.calls", category: "mini")
+ }
+
extension View {
/// Puts the call into a small floating window while the person is in
/// another app, and back into this view when they return. Apply it to
@@ -66,9 +76,26 @@
}
}
}
+ // Another app coming forward can bring its own floating windows
+ // over this one, and a Space change or a full-screen app can leave
+ // it behind: put it back in front, taking clicks, each time.
+ let workspace = NSWorkspace.shared.notificationCenter
+ for name in [
+ NSWorkspace.activeSpaceDidChangeNotification, NSWorkspace.didActivateApplicationNotification,
+ ] {
+ observers.append(
+ workspace.addObserver(forName: name, object: nil, queue: .main) { [weak self] note in
+ MainActor.assumeIsolated { self?.panel?.reassert(because: note.name.rawValue) }
+ })
+ }
}
- deinit { observers.forEach(NotificationCenter.default.removeObserver) }
+ deinit {
+ for observer in observers {
+ NotificationCenter.default.removeObserver(observer)
+ NSWorkspace.shared.notificationCenter.removeObserver(observer)
+ }
+ }
func attach(model: CallsModel, mainWindow: NSWindow?) {
if let mainWindow { self.mainWindow = mainWindow }
@@ -105,11 +132,17 @@
case .none: break
case .show(let hideMain):
guard let model else { return }
+ // Never two: a panel left behind by a show without a restore would
+ // sit over the new one, its clicks going to a window nobody sees.
+ if let stale = panel {
+ MiniLog.logger.info("show: dismissing a panel still up")
+ stale.dismiss()
+ }
let panel = MiniWindowPanel(
call: model.call,
actions: MiniWindowActions(
leave: { [weak model] in model?.leave() },
- open: { NSApp.activate(ignoringOtherApps: true) },
+ open: { [weak self] in self?.bringCallForward() },
moved: { [weak self] option in self?.dropped(option: option) },
resized: { [weak self] in self?.resized() },
collapse: { [weak self] in self?.collapse() },
@@ -117,7 +150,7 @@
tabReleased: { [weak self] in self?.tabReleased() }))
self.panel = panel
layout(panel, on: mainWindow?.screen ?? NSScreen.main)
- panel.orderFrontRegardless()
+ panel.reassert(because: "show")
if hideMain, let mainWindow { Self.conceal(mainWindow, true) }
case .restore(let activate):
panel?.dismiss()
@@ -132,6 +165,34 @@
}
}
+ /// A click on the mini window: the call, forward. Since macOS 14 an
+ /// app's request to activate itself is a request the system may turn
+ /// down, and a click in a non-activating panel does not activate the
+ /// app by itself, so a refusal left the person clicking a window that
+ /// did nothing. Asked for twice, the second time as the running
+ /// application, and if the app is still not active the call window is
+ /// put back and brought in front anyway, so a click always shows the
+ /// call. Every step is logged.
+ func bringCallForward() {
+ MiniLog.logger.info(
+ "open: activating (active=\(NSApp.isActive), frontmost=\(NSWorkspace.shared.frontmostApplication?.bundleIdentifier ?? "none", privacy: .public))"
+ )
+ if #available(macOS 14, *) { NSApp.activate() } else { NSApp.activate(ignoringOtherApps: true) }
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
+ guard let self, self.panel != nil else { return }
+ if NSApp.isActive { return }
+ MiniLog.logger.info("open: not active after 0.25 s; asking again as the running application")
+ NSRunningApplication.current.activate(options: [.activateAllWindows])
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
+ guard let self, self.panel != nil else { return }
+ if NSApp.isActive { return }
+ MiniLog.logger.error("open: activation refused twice; showing the call window in front regardless")
+ self.apply(self.policy.becameActive())
+ self.mainWindow?.orderFrontRegardless()
+ }
+ }
+ }
+
/// Hides the call window by making it transparent and letting clicks
/// through, not by ordering it out. SwiftUI treats an ordered-out
/// `Window` as closed: the view's onDisappear runs, which leaves the
@@ -287,6 +348,33 @@
/// window's controls out of reach of the keyboard altogether.
override var canBecomeKey: Bool { true }
+ /// In front, at its level, visible and taking the mouse: what a click
+ /// on it needs, put back whenever something may have changed it.
+ func reassert(because reason: String) {
+ if ignoresMouseEvents || alphaValue < 1 || level != .floating {
+ MiniLog.logger.info(
+ "reassert (\(reason, privacy: .public)): mouse ignored=\(self.ignoresMouseEvents), alpha=\(self.alphaValue), level=\(self.level.rawValue)"
+ )
+ }
+ ignoresMouseEvents = false
+ alphaValue = 1
+ level = .floating
+ orderFrontRegardless()
+ (contentView as? MiniWindowHostingView)?.syncHover()
+ }
+
+ /// Every press, logged with what it landed on, before AppKit sends it
+ /// there.
+ override func sendEvent(_ event: NSEvent) {
+ if event.type == .leftMouseDown, let content = contentView {
+ let hit = content.hitTest(content.convert(event.locationInWindow, from: nil))
+ MiniLog.logger.info(
+ "mouse down at \(Int(event.locationInWindow.x)),\(Int(event.locationInWindow.y)) in \(Int(self.frame.width))x\(Int(self.frame.height)) (\(self.collapsed ? "tab" : "window", privacy: .public)) on \(hit.map { String(describing: type(of: $0)) } ?? "nothing", privacy: .public); app active=\(NSApp.isActive), key=\(self.isKeyWindow), clicks=\(event.clickCount)"
+ )
+ }
+ super.sendEvent(event)
+ }
+
func expand(to frame: CGRect, animated: Bool) {
content.collapsed = false
expandedFrame = frame
@@ -345,12 +433,26 @@
super.updateTrackingAreas()
if let tracking { removeTrackingArea(tracking) }
let area = NSTrackingArea(
- rect: .zero, options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect],
+ rect: .zero, options: [.mouseEnteredAndExited, .mouseMoved, .activeAlways, .inVisibleRect],
owner: self)
addTrackingArea(area)
tracking = area
}
+ /// A window that appears under the pointer, or moves under it, gets no
+ /// mouseEntered, and its controls would stay hidden until the pointer
+ /// left and came back: hover follows where the pointer is instead.
+ func syncHover() {
+ guard let window else { return }
+ let inside = window.frame.contains(NSEvent.mouseLocation)
+ if hover?.hovering != inside { hover?.hovering = inside }
+ }
+
+ override func mouseMoved(with event: NSEvent) {
+ super.mouseMoved(with: event)
+ if hover?.hovering == false { hover?.hovering = true }
+ }
+
override func mouseEntered(with event: NSEvent) {
super.mouseEntered(with: event)
hover?.hovering = true
@@ -460,12 +562,17 @@
.font(.callout.bold()).foregroundStyle(.white)
.padding(8).callOverlaySurface(Circle())
.accessibilityLabel("Your microphone is muted")
+ // A mark, not a control: a press on it is a press on the
+ // window (MiniWindowGesture), not swallowed by SwiftUI.
+ .allowsHitTesting(false)
}
Spacer()
// Spec §15.24, for the people this window is not showing: the
// one it shows says it on its own tile. Top right, clear of
// the tile's Muted badge.
ConnectingDot(names: offStage.map { call.name(of: $0) })
+ // Its names are in VoiceOver's label; a press goes through.
+ .allowsHitTesting(false)
if showingControls {
Button("Collapse to a tab", glyph: "arrow.down.right.and.arrow.up.left") {
actions.collapse()
@@ -540,6 +647,13 @@
Spacer(minLength: 0)
SpeakingGlyph(speaking: call.levels.anyoneSpeaking, glow: call.levels.othersGlow)
}
+ // Nothing in the tab is a control of its own: every press on it,
+ // on the microphone, the names or the waveform included, is the
+ // surface's, so a click always opens and a drag always moves. Until
+ // 2026-10-05 a press on the names or a mark went to SwiftUI, which
+ // had nothing to do with it, and was lost (CallsInterop
+ // --mini-clicks).
+ .allowsHitTesting(false)
.padding(.horizontal, 7)
.frame(width: MiniWindowTab.size.width, height: MiniWindowTab.size.height)
.background(
@@ -684,18 +798,18 @@
override func mouseDown(with event: NSEvent) {
guard let window else { return }
- let start = NSEvent.mouseLocation
+ let surface: MiniWindowGesture.Surface = mode == .window ? .window : .tab
+ let start = window.convertPoint(toScreen: event.locationInWindow)
let startFrame = window.frame
- let grabbed = handle(at: convert(event.locationInWindow, from: nil))
+ let grabbed = MiniWindowGesture.grab(
+ at: convert(event.locationInWindow, from: nil), in: bounds.size, surface: surface)
let visible = window.screen?.visibleFrame ?? startFrame
var dragging = false
while let next = window.nextEvent(matching: [.leftMouseDragged, .leftMouseUp]) {
- let now = NSEvent.mouseLocation
+ let now = Self.screenLocation(of: next)
let dx = now.x - start.x
let dy = now.y - start.y
- // A few points of slack, so a click with a shaky hand is still
- // a click and not a very short drag.
- if !dragging, hypot(dx, dy) > 4 { dragging = true }
+ if !dragging, MiniWindowGesture.dragged(dx: dx, dy: dy) { dragging = true }
if dragging {
if grabbed.isEmpty {
window.setFrameOrigin(NSPoint(x: startFrame.minX + dx, y: startFrame.minY + dy))
@@ -708,14 +822,25 @@
}
if next.type == .leftMouseUp { break }
}
- if !dragging {
- click(event.clickCount)
- } else if grabbed.isEmpty {
- dropped(NSEvent.modifierFlags.contains(.option))
- } else {
- resized()
+ let outcome = MiniWindowGesture.outcome(
+ grabbed: grabbed, dragged: dragging, clickCount: event.clickCount,
+ option: NSEvent.modifierFlags.contains(.option) || event.modifierFlags.contains(.option))
+ MiniLog.logger.info("\(surface.rawValue, privacy: .public) press: \(String(describing: outcome), privacy: .public)")
+ switch outcome {
+ case .click(let count): click(count)
+ case .move(let option): dropped(option)
+ case .resize: resized()
}
}
+
+ /// Where the pointer is for an event of a drag. The window moves
+ /// under the pointer as it is dragged, so a real event's position in
+ /// the window is stale by the time it is read: the pointer's own
+ /// location is used. An event made with no window (CallsInterop's
+ /// --mini-clicks) carries screen coordinates and is read as it is.
+ static func screenLocation(of event: NSEvent) -> CGPoint {
+ event.windowNumber == 0 && event.window == nil ? event.locationInWindow : NSEvent.mouseLocation
+ }
}
}
diff --git a/native/apple/Sources/CallsKit/MiniWindowProbe.swift b/native/apple/Sources/CallsKit/MiniWindowProbe.swift
new file mode 100644
index 0000000..4ab9c71
--- /dev/null
+++ b/native/apple/Sources/CallsKit/MiniWindowProbe.swift
@@ -0,0 +1,196 @@
+#if os(macOS)
+ import AppKit
+ import SwiftUI
+
+ /// The mini window pressed as a person presses it, for CallsInterop's
+ /// --mini-clicks (2026-10-05, Paul: "Sometimes I can't click the mini
+ /// window"). A real panel, on screen, in an app that is not active (the
+ /// harness never activates), with a call behind it; presses made as
+ /// NSEvents and sent through the window, so AppKit's hit-testing, first
+ /// mouse, the surface's tracking loop and SwiftUI's buttons all take
+ /// part. What each press did is read from the actions it called and
+ /// from the window's frame. This Mac refuses screen capture and event
+ /// posting to this process, so these presses do not pass through the
+ /// window server; everything after it they do.
+ @_spi(Fixtures) @MainActor public enum MiniWindowProbe {
+ public struct Result: Sendable {
+ public let name: String
+ public let passed: Bool
+ public let detail: String
+ }
+
+ public static func run(call: Call) async -> [Result] {
+ var results: [Result] = []
+ for width in [MiniWindowRules.minimumWidth, MiniWindowRules.size.width, 480] {
+ results += await pressWindow(call: call, width: width)
+ }
+ results += await pressTab(call: call)
+ return results
+ }
+
+ static func trace(_ line: String) {
+ FileHandle.standardError.write(Data(("mini-clicks: " + line + "\n").utf8))
+ }
+
+ private final class Recorder {
+ var calls: [String] = []
+ }
+
+ private static func panel(call: Call, recorder: Recorder) -> MiniWindowPanel {
+ MiniWindowPanel(
+ call: call,
+ actions: MiniWindowActions(
+ leave: { recorder.calls.append("leave") },
+ open: { recorder.calls.append("open") },
+ moved: { option in recorder.calls.append(option ? "moved-option" : "moved") },
+ resized: { recorder.calls.append("resized") },
+ collapse: { recorder.calls.append("collapse") },
+ expand: { recorder.calls.append("expand") },
+ tabReleased: { recorder.calls.append("tabReleased") }))
+ }
+
+ private static func pressWindow(call: Call, width: CGFloat) async -> [Result] {
+ let recorder = Recorder()
+ let size = CGSize(width: width, height: width / MiniWindowRules.aspect)
+ let panel = panel(call: call, recorder: recorder)
+ let visible = NSScreen.main?.visibleFrame ?? CGRect(x: 0, y: 0, width: 1440, height: 900)
+ let home = CGRect(x: visible.midX - size.width / 2, y: visible.midY - size.height / 2, width: size.width, height: size.height)
+ panel.expand(to: home, animated: false)
+ panel.reassert(because: "probe")
+ try? await Task.sleep(for: .milliseconds(300))
+ var results: [Result] = []
+ let tag = "\(Int(width))"
+ trace("window \(tag) up at \(panel.frame)")
+ func check(_ name: String, _ expected: String, _ act: () async -> Void, frame: ((CGRect) -> Bool)? = nil) async {
+ panel.expand(to: home, animated: false)
+ recorder.calls = []
+ trace("\(tag) \(name)")
+ await act()
+ try? await Task.sleep(for: .milliseconds(80))
+ let got = recorder.calls.joined(separator: ",")
+ let frameOK = frame?(panel.frame) ?? true
+ results.append(
+ Result(
+ name: "\(tag) \(name)", passed: got == expected && frameOK,
+ detail: "expected \(expected), got [\(got)], frame \(panel.frame)"))
+ }
+ let w = size.width, h = size.height
+ // Hover off: the picture alone.
+ panel.content.hovering = false
+ try? await Task.sleep(for: .milliseconds(100))
+ await check("click in the middle brings the call forward", "open") { await click(panel, at: CGPoint(x: w / 2, y: h / 2)) }
+ await check("click on the name brings the call forward", "open") { await click(panel, at: CGPoint(x: 24, y: 16)) }
+ await check("click near the top left brings the call forward", "open") { await click(panel, at: CGPoint(x: 20, y: h - 20)) }
+ await check("a shaky click is still a click", "open") {
+ await press(panel, from: CGPoint(x: w / 2, y: h / 2), by: CGSize(width: 3, height: -2))
+ }
+ await check("a drag from the middle moves it", "moved", {
+ await press(panel, from: CGPoint(x: w / 2, y: h / 2), by: CGSize(width: 60, height: -40))
+ }, frame: { abs($0.minX - (home.minX + 60)) < 1 && abs($0.minY - (home.minY - 40)) < 1 })
+ await check("a drag from the right edge resizes it", "resized", {
+ await press(panel, from: CGPoint(x: w - 3, y: h / 2), by: CGSize(width: 40, height: 0))
+ }, frame: { $0.width > w + 20 || w >= MiniWindowRules.size(width: 10_000, in: visible).width })
+ await check("a drag from the bottom left corner resizes it", "resized", {
+ await press(panel, from: CGPoint(x: 3, y: 3), by: CGSize(width: -30, height: -20))
+ }, frame: { $0.width > w + 10 || w >= MiniWindowRules.size(width: 10_000, in: visible).width })
+ // Marks over the picture are not controls: muted, and someone
+ // still connecting (the ear), a press on them is the window's.
+ call.showFixtureSelf(muted: true, cameraOff: false, sharingScreen: false)
+ call.showFixtureConnecting(["li"])
+ try? await Task.sleep(for: .milliseconds(200))
+ await check("click on the Muted mark brings the call forward", "open") { await click(panel, at: CGPoint(x: 10 + 16, y: h - 10 - 16)) }
+ await check("click on the connecting ear brings the call forward", "open") { await click(panel, at: CGPoint(x: w - 10 - 16, y: h - 10 - 16)) }
+ await check("a drag from the Muted mark moves it", "moved") {
+ await press(panel, from: CGPoint(x: 10 + 16, y: h - 10 - 16), by: CGSize(width: -50, height: -30))
+ }
+ call.showFixtureSelf(muted: false, cameraOff: false, sharingScreen: false)
+ call.showFixtureConnecting([])
+ // Hover on: the controls (44 pt, 8 apart, 10 in from the top right:
+ // Collapse, Mute, Leave) answer the first click.
+ panel.content.hovering = true
+ try? await Task.sleep(for: .milliseconds(200))
+ let row = h - 10 - 22
+ await check("first click on Leave", "leave") { await click(panel, at: CGPoint(x: w - 10 - 22, y: row), control: true) }
+ await check("first click on Collapse", "collapse") { await click(panel, at: CGPoint(x: w - 10 - 44 - 8 - 44 - 8 - 22, y: row), control: true) }
+ let before = call.muted
+ await click(panel, at: CGPoint(x: w - 10 - 44 - 8 - 22, y: row), control: true)
+ try? await Task.sleep(for: .milliseconds(80))
+ results.append(Result(name: "\(tag) first click on Mute", passed: call.muted != before, detail: "muted \(before) -> \(call.muted)"))
+ if call.muted != before { call.showFixtureSelf(muted: before, cameraOff: false, sharingScreen: false) }
+ await check("click in the middle with the controls up", "open") { await click(panel, at: CGPoint(x: w / 2, y: h / 2)) }
+ panel.dismiss()
+ return results
+ }
+
+ private static func pressTab(call: Call) async -> [Result] {
+ let recorder = Recorder()
+ let panel = panel(call: call, recorder: recorder)
+ let visible = NSScreen.main?.visibleFrame ?? CGRect(x: 0, y: 0, width: 1440, height: 900)
+ let expanded = CGRect(origin: CGPoint(x: visible.maxX - 340, y: visible.minY + 20), size: MiniWindowRules.size)
+ let tab = MiniWindowTab.dock(expanded, in: visible).1
+ var results: [Result] = []
+ func reset() {
+ panel.collapse(to: tab, expanded: expanded)
+ recorder.calls = []
+ }
+ reset()
+ panel.reassert(because: "probe")
+ try? await Task.sleep(for: .milliseconds(300))
+ let middle = CGPoint(x: tab.width / 2, y: tab.height / 2)
+ await click(panel, at: middle)
+ try? await Task.sleep(for: .milliseconds(80))
+ results.append(Result(name: "tab click opens the window", passed: recorder.calls == ["expand"], detail: "\(recorder.calls)"))
+ reset()
+ await click(panel, at: middle, count: 2)
+ try? await Task.sleep(for: .milliseconds(80))
+ results.append(Result(name: "tab double click opens the call", passed: recorder.calls.last == "open", detail: "\(recorder.calls)"))
+ reset()
+ await press(panel, from: CGPoint(x: 3, y: middle.y), by: CGSize(width: -120, height: 30))
+ try? await Task.sleep(for: .milliseconds(80))
+ results.append(Result(name: "tab drag, even from its edge, is a drag", passed: recorder.calls == ["tabReleased"], detail: "\(recorder.calls)"))
+ panel.dismiss()
+ return results
+ }
+
+ private static func click(_ panel: NSWindow, at point: CGPoint, count: Int = 1, control: Bool = false) async {
+ for n in 1...count {
+ await press(panel, from: point, by: .zero, clickCount: n, control: control)
+ }
+ }
+
+ /// A press at `point` in the window, moved by `by` in steps, released:
+ /// the down sent through the window as AppKit would, the drags and
+ /// the up queued for the surface's tracking loop, in screen points.
+ /// A control's up goes to the window, where SwiftUI's button reads it;
+ /// the surface's tracking loop reads screen points from window-less
+ /// events (SurfaceView.screenLocation).
+ private static func press(
+ _ panel: NSWindow, from point: CGPoint, by: CGSize, clickCount: Int = 1, control: Bool = false
+ ) async {
+ let screenStart = panel.convertPoint(toScreen: point)
+ let now = ProcessInfo.processInfo.systemUptime
+ func event(_ type: NSEvent.EventType, at location: CGPoint, window: Bool) -> NSEvent? {
+ NSEvent.mouseEvent(
+ with: type, location: location, modifierFlags: [], timestamp: now,
+ windowNumber: window ? panel.windowNumber : 0, context: nil, eventNumber: 0,
+ clickCount: clickCount, pressure: type == .leftMouseUp ? 0 : 1)
+ }
+ let steps = by == .zero ? 0 : 5
+ for i in stride(from: 1, through: steps, by: 1) {
+ let f = CGFloat(i) / CGFloat(steps)
+ if let drag = event(.leftMouseDragged, at: CGPoint(x: screenStart.x + by.width * f, y: screenStart.y + by.height * f), window: false) {
+ NSApp.postEvent(drag, atStart: false)
+ }
+ }
+ if control, let up = event(.leftMouseUp, at: point, window: true) {
+ NSApp.postEvent(up, atStart: false)
+ } else if let up = event(.leftMouseUp, at: CGPoint(x: screenStart.x + by.width, y: screenStart.y + by.height), window: false) {
+ NSApp.postEvent(up, atStart: false)
+ }
+ if let down = event(.leftMouseDown, at: point, window: true) { panel.sendEvent(down) }
+ // A SwiftUI button acts on the up, which it reads from the queue
+ // itself; let the run loop turn.
+ try? await Task.sleep(for: .milliseconds(60))
+ }
+ }
+#endif
diff --git a/native/apple/Sources/CallsKit/MiniWindowRules.swift b/native/apple/Sources/CallsKit/MiniWindowRules.swift
index 20a9c8f..2e421b3 100644
--- a/native/apple/Sources/CallsKit/MiniWindowRules.swift
+++ b/native/apple/Sources/CallsKit/MiniWindowRules.swift
@@ -507,4 +507,44 @@
return .restore(activate: activate)
}
}
+
+/// What one press on the mini window's surface is (2026-10-05, Paul:
+/// "Sometimes I can't click the mini window to either bring the call to
+/// the front or move it around"): apart from any view, so tests hold it.
+/// A press that ends within `slack` of where it began is a click, which
+/// brings the call forward (the tab: opens the window; twice, the call).
+/// One that travels further is a drag: from an edge or corner of the
+/// expanded window it resizes, from anywhere else it moves. The tab has
+/// no edges to resize by. Controls are SwiftUI buttons over the surface
+/// and never reach it.
+public enum MiniWindowGesture {
+ public enum Surface: String, Sendable { case window, tab }
+ public enum Outcome: Equatable, Sendable {
+ case click(count: Int)
+ case move(option: Bool)
+ case resize(MiniWindowResize.Handle)
+ }
+
+ /// How far a press may wander and still be a click: a shaky hand's.
+ public static let slack: CGFloat = 4
+
+ /// The edges a press at `point` takes hold of: none on the tab, and none
+ /// on a window too small for its grips to leave a middle to move by.
+ public static func grab(at point: CGPoint, in size: CGSize, surface: Surface) -> MiniWindowResize.Handle {
+ guard surface == .window, size.width > 4 * MiniWindowResize.grip,
+ size.height > 4 * MiniWindowResize.grip
+ else { return [] }
+ return MiniWindowResize.handle(at: point, in: size)
+ }
+
+ public static func dragged(dx: CGFloat, dy: CGFloat) -> Bool { hypot(dx, dy) > slack }
+
+ /// The press's outcome once the button is up.
+ public static func outcome(
+ grabbed: MiniWindowResize.Handle, dragged: Bool, clickCount: Int, option: Bool
+ ) -> Outcome {
+ guard dragged else { return .click(count: max(1, clickCount)) }
+ return grabbed.isEmpty ? .move(option: option) : .resize(grabbed)
+ }
+}
#endif
diff --git a/native/apple/Sources/CallsKit/MutedBadge.swift b/native/apple/Sources/CallsKit/MutedBadge.swift
index cddb9ea..47af48a 100644
--- a/native/apple/Sources/CallsKit/MutedBadge.swift
+++ b/native/apple/Sources/CallsKit/MutedBadge.swift
@@ -76,10 +76,15 @@ public struct TileNameLabel: View {
}
/// What a tile shows where its picture is not: a symbol, and the words
-/// for it when there is room (0.4.1). Centred in the space the tile has
-/// above its name label (`reserve`), never under it, and on a tile too
-/// short for the words the symbol stands alone, then smaller. The tile's
-/// accessibility value carries the words either way.
+/// for it when there is room. Centred in the whole tile, as FaceTime has
+/// it, with the name label over the tile's bottom edge (2026-10-05, Paul:
+/// "Vertical centering here too"; until then, from 0.4.1, it was centred
+/// in the space above the label, which left it high). Only if the tile is
+/// so short that the centred placeholder would reach the label is it
+/// moved up, by no more than that (`PlaceholderPlacement`). On a tile too
+/// short for the words between the label and the top, the symbol stands
+/// alone, then smaller. The tile's accessibility value carries the words
+/// either way.
public struct TilePlaceholder: View {
let symbol: String
let words: String?
@@ -101,25 +106,67 @@ public struct TilePlaceholder: View {
}
public var body: some View {
- ViewThatFits(in: .vertical) {
- if let words {
- VStack(spacing: spacing) {
- Image(systemName: symbol).font(iconFont)
- Text(words).font(wordsFont).multilineTextAlignment(.center).lineLimit(2)
- .padding(.horizontal, 12)
+ PlaceholderLayout(reserve: reserve) {
+ ViewThatFits(in: .vertical) {
+ if let words {
+ VStack(spacing: spacing) {
+ Image(systemName: symbol).font(iconFont)
+ Text(words).font(wordsFont).multilineTextAlignment(.center).lineLimit(2)
+ .padding(.horizontal, 12)
+ }
}
+ Image(systemName: symbol).font(iconFont)
+ Image(systemName: symbol).font(.body)
}
- Image(systemName: symbol).font(iconFont)
- Image(systemName: symbol).font(.body)
}
.foregroundStyle(.white.opacity(0.8))
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- // A little air between the placeholder and the label as well.
- .padding(.bottom, reserve > 0 ? reserve + 4 : 0)
.accessibilityHidden(true)
}
}
+/// Where a tile's placeholder goes, apart from any view so tests hold it:
+/// centred in the tile, unless that would bring it within `gap` of the
+/// label block at the bottom, when it rises just clear of it, and never
+/// above the tile's top.
+public enum PlaceholderPlacement {
+ /// Air kept between the placeholder and the top of the label block.
+ public static let gap: CGFloat = 4
+
+ /// The height the placeholder may take and still clear the label: what
+ /// chooses between the words, the symbol, and the smaller symbol.
+ public static func room(tile: CGFloat, reserve: CGFloat) -> CGFloat {
+ max(0, tile - clearance(reserve))
+ }
+
+ /// The placeholder's top, from the tile's top, for content `height` tall.
+ public static func top(tile: CGFloat, content height: CGFloat, reserve: CGFloat) -> CGFloat {
+ let centred = (tile - height) / 2
+ let clear = tile - clearance(reserve) - height
+ return max(0, min(centred, clear))
+ }
+
+ static func clearance(_ reserve: CGFloat) -> CGFloat { reserve > 0 ? reserve + gap : 0 }
+}
+
+/// Fills the tile and puts its one subview where `PlaceholderPlacement`
+/// says, offered the height it may take so `ViewThatFits` chooses what
+/// fits there.
+struct PlaceholderLayout: Layout {
+ let reserve: CGFloat
+ func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
+ proposal.replacingUnspecifiedDimensions()
+ }
+ func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
+ guard let content = subviews.first else { return }
+ let room = PlaceholderPlacement.room(tile: bounds.height, reserve: reserve)
+ let size = content.sizeThatFits(ProposedViewSize(width: bounds.width, height: room))
+ let top = PlaceholderPlacement.top(tile: bounds.height, content: size.height, reserve: reserve)
+ content.place(
+ at: CGPoint(x: bounds.midX, y: bounds.minY + top), anchor: .top,
+ proposal: ProposedViewSize(width: bounds.width, height: size.height))
+ }
+}
+
/// A tile's name label block, measured, so its placeholder can keep
/// clear of it (see `TilePlaceholder`).
extension View {
diff --git a/native/apple/Sources/CallsKit/ScreenBackdrop.swift b/native/apple/Sources/CallsKit/ScreenBackdrop.swift
new file mode 100644
index 0000000..aefb084
--- /dev/null
+++ b/native/apple/Sources/CallsKit/ScreenBackdrop.swift
@@ -0,0 +1,239 @@
+import CoreGraphics
+import Foundation
+import LiveKitWebRTC
+import SwiftUI
+
+/// What surrounds a shared screen on the stage (2026-10-05, Paul: "Should
+/// the video view stay fullscreen even here?"). A screen is fitted whole,
+/// never cropped, so a window of another shape leaves bars beside or
+/// above it. Rather than black, they show the same picture enlarged, soft
+/// and dimmed, as FaceTime does.
+///
+/// Cheap by construction: twice a second, WebRTC scales one frame down to
+/// `width` × `height` (libyuv, from whatever buffer the decoder gave), it
+/// is converted and blurred at that size, and the picture is drawn
+/// enlarged, its smooth upscaling doing the rest of the blur. Nothing runs
+/// at the frame rate and nothing is blurred at the window's size.
+public struct ScreenBackdrop: View {
+ let track: RTCVideoTrack
+ @StateObject private var sampler = BackdropSampler()
+
+ public init(track: RTCVideoTrack) { self.track = track }
+
+ public var body: some View {
+ ZStack {
+ CallsAppearance.stageSurface
+ if let image = sampler.image {
+ Image(decorative: image, scale: 1)
+ .resizable()
+ .interpolation(.high)
+ .aspectRatio(contentMode: .fill)
+ // Past the edges a little: the blur's own edge is darker.
+ .scaleEffect(1.15)
+ .opacity(BackdropSampler.dimmed)
+ .transition(.opacity)
+ }
+ }
+ .clipped()
+ .animation(.easeInOut(duration: 0.4), value: sampler.image != nil)
+ .onAppear { sampler.attach(track) }
+ .onChange(of: ObjectIdentifier(track)) { sampler.attach(track) }
+ .onDisappear { sampler.detach() }
+ .accessibilityHidden(true)
+ }
+}
+
+/// Takes frames from a track at `interval` and keeps the latest as a small
+/// blurred picture.
+@MainActor final class BackdropSampler: ObservableObject {
+ /// The picture's size: 16:9, enough for the colours and their places.
+ nonisolated static let width = 64
+ nonisolated static let height = 36
+ /// How often a frame is taken.
+ nonisolated static let interval: TimeInterval = 0.5
+ /// How much of the picture shows over the dark surface behind it.
+ nonisolated static let dimmed: Double = 0.45
+
+ @Published private(set) var image: CGImage?
+ private var track: RTCVideoTrack?
+ private var renderer: Renderer?
+
+ func attach(_ next: RTCVideoTrack) {
+ guard track !== next else { return }
+ detach()
+ let renderer = Renderer { [weak self] image in
+ Task { @MainActor in self?.image = image }
+ }
+ next.add(renderer)
+ track = next
+ self.renderer = renderer
+ }
+
+ func detach() {
+ if let renderer { track?.remove(renderer) }
+ track = nil
+ renderer = nil
+ image = nil
+ }
+
+ /// WebRTC's side: called on its decoding thread for every frame, it
+ /// keeps one in `interval` and lets the rest go at once.
+ final class Renderer: NSObject, RTCVideoRenderer, @unchecked Sendable {
+ private let lock = NSLock()
+ private var last: TimeInterval = 0
+ private let deliver: (CGImage) -> Void
+ init(deliver: @escaping (CGImage) -> Void) { self.deliver = deliver }
+
+ func setSize(_ size: CGSize) {}
+
+ func renderFrame(_ frame: RTCVideoFrame?) {
+ guard let frame else { return }
+ let now = ProcessInfo.processInfo.systemUptime
+ lock.lock()
+ let due = now - last >= BackdropSampler.interval
+ if due { last = now }
+ lock.unlock()
+ guard due, let image = BackdropSampler.picture(of: frame) else { return }
+ deliver(image)
+ }
+ }
+
+ /// One frame as the backdrop's picture: scaled down by WebRTC, then
+ /// converted and blurred here.
+ nonisolated static func picture(of frame: RTCVideoFrame) -> CGImage? {
+ let source = frame.buffer
+ guard source.width > 0, source.height > 0 else { return nil }
+ let small =
+ source.cropAndScale?(
+ with: 0, offsetY: 0, cropWidth: source.width, cropHeight: source.height,
+ scaleWidth: Int32(width), scaleHeight: Int32(height)) ?? source
+ let planes = small.toI420()
+ var rgba = rgbaFromI420(
+ width: Int(planes.width), height: Int(planes.height),
+ y: planes.dataY, strideY: Int(planes.strideY),
+ u: planes.dataU, strideU: Int(planes.strideU),
+ v: planes.dataV, strideV: Int(planes.strideV),
+ outWidth: width, outHeight: height)
+ boxBlur(&rgba, width: width, height: height, radius: 3, passes: 2)
+ return image(rgba, width: width, height: height)
+ }
+
+ /// I420 to RGBA (BT.601, video range) at `outWidth` × `outHeight`,
+ /// sampling the nearest source pixel when the sizes differ. Integer
+ /// arithmetic (coefficients × 256) over raw buffers.
+ nonisolated static func rgbaFromI420(
+ width: Int, height: Int, y: UnsafePointer<UInt8>, strideY: Int,
+ u: UnsafePointer<UInt8>, strideU: Int, v: UnsafePointer<UInt8>, strideV: Int,
+ outWidth: Int, outHeight: Int
+ ) -> [UInt8] {
+ var out = [UInt8](repeating: 255, count: outWidth * outHeight * 4)
+ guard width > 0, height > 0 else { return out }
+ out.withUnsafeMutableBufferPointer { buffer in
+ let o = buffer.baseAddress!
+ var i = 0
+ for row in 0..<outHeight {
+ let sy = min(height - 1, row * height / outHeight)
+ let yRow = y + sy * strideY
+ let uRow = u + (sy >> 1) * strideU
+ let vRow = v + (sy >> 1) * strideV
+ for col in 0..<outWidth {
+ let sx = min(width - 1, col * width / outWidth)
+ let c = 298 * (Int(yRow[sx]) - 16) + 128
+ let d = Int(uRow[sx >> 1]) - 128
+ let e = Int(vRow[sx >> 1]) - 128
+ o[i] = clamp8((c + 409 * e) >> 8)
+ o[i + 1] = clamp8((c - 100 * d - 208 * e) >> 8)
+ o[i + 2] = clamp8((c + 516 * d) >> 8)
+ i += 4
+ }
+ }
+ }
+ return out
+ }
+
+ @inline(__always) nonisolated private static func clamp8(_ value: Int) -> UInt8 {
+ UInt8(truncatingIfNeeded: value < 0 ? 0 : value > 255 ? 255 : value)
+ }
+
+ /// A separable box blur, `passes` times (two is close to Gaussian),
+ /// with running sums, so each pass is linear in the pixels.
+ nonisolated static func boxBlur(
+ _ pixels: inout [UInt8], width: Int, height: Int, radius: Int, passes: Int
+ ) {
+ guard radius > 0, width > 0, height > 0 else { return }
+ var scratch = pixels
+ pixels.withUnsafeMutableBufferPointer { a in
+ scratch.withUnsafeMutableBufferPointer { b in
+ for _ in 0..<passes {
+ blurLines(a.baseAddress!, b.baseAddress!, count: height, length: width, step: 4, lineStep: width * 4, radius: radius)
+ blurLines(b.baseAddress!, a.baseAddress!, count: width, length: height, step: width * 4, lineStep: 4, radius: radius)
+ }
+ }
+ }
+ }
+
+ nonisolated private static func blurLines(
+ _ source: UnsafeMutablePointer<UInt8>, _ out: UnsafeMutablePointer<UInt8>, count: Int,
+ length: Int, step: Int, lineStep: Int, radius: Int
+ ) {
+ for line in 0..<count {
+ let base = line * lineStep
+ for channel in 0..<3 {
+ var sum = 0
+ var n = 0
+ // The window [at - radius, at + radius], clipped to the line.
+ for k in 0...min(length - 1, radius) {
+ sum += Int(source[base + k * step + channel])
+ n += 1
+ }
+ for at in 0..<length {
+ out[base + at * step + channel] = UInt8(truncatingIfNeeded: sum / n)
+ let leaving = at - radius
+ let entering = at + radius + 1
+ if leaving >= 0 {
+ sum -= Int(source[base + leaving * step + channel])
+ n -= 1
+ }
+ if entering < length {
+ sum += Int(source[base + entering * step + channel])
+ n += 1
+ }
+ }
+ }
+ for at in 0..<length { out[base + at * step + 3] = 255 }
+ }
+ }
+
+ nonisolated static func image(_ rgba: [UInt8], width: Int, height: Int) -> CGImage? {
+ guard let provider = CGDataProvider(data: Data(rgba) as CFData) else { return nil }
+ return CGImage(
+ width: width, height: height, bitsPerComponent: 8, bitsPerPixel: 32, bytesPerRow: width * 4,
+ space: CGColorSpaceCreateDeviceRGB(),
+ bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.noneSkipLast.rawValue),
+ provider: provider, decode: nil, shouldInterpolate: true, intent: .defaultIntent)
+ }
+}
+
+/// Where a picture goes in its tile, top-down: a screen fitted whole and
+/// centred; a camera filling the tile, cropped evenly from the sides but,
+/// from top and bottom, more from the bottom (`headroom`), since a face
+/// is above the middle of the picture and a crop through the forehead
+/// reads worse than one through the chest. The web's stage and tiles do
+/// the same with object-position (calls.css).
+public enum VideoFrame {
+ /// The share of a vertical crop taken from the top: 0.5 would be even.
+ public static let headroom: CGFloat = 0.35
+
+ public static func place(video: CGSize, in bounds: CGSize, fit: Bool) -> CGRect {
+ guard video.width > 0, video.height > 0 else { return CGRect(origin: .zero, size: bounds) }
+ let across = bounds.width / video.width
+ let down = bounds.height / video.height
+ let scale = fit ? min(across, down) : max(across, down)
+ let size = CGSize(width: video.width * scale, height: video.height * scale)
+ let spare = bounds.height - size.height
+ // Spare room (a fitted screen) is shared evenly; a crop (negative)
+ // takes headroom of it from the top.
+ let top = spare >= 0 || fit ? spare / 2 : spare * headroom
+ return CGRect(x: (bounds.width - size.width) / 2, y: top, width: size.width, height: size.height)
+ }
+}
diff --git a/native/apple/Sources/CallsKit/SpeakerView.swift b/native/apple/Sources/CallsKit/SpeakerView.swift
index 8222e73..9f79050 100644
--- a/native/apple/Sources/CallsKit/SpeakerView.swift
+++ b/native/apple/Sources/CallsKit/SpeakerView.swift
@@ -347,3 +347,17 @@ public func stageLabel(_ label: String, onStage: Bool, pinned: Bool) -> String {
guard onStage else { return label }
return StageWords.onStage(label) + (pinned ? ", pinned" : "")
}
+
+/// Where the stage's name sits in Speaker view, now that the stage fills
+/// the window under the filmstrip: at the bottom left, lifted by the
+/// filmstrip's band and, while the controls show, by as much as the
+/// filmstrip rises for the bar, so it rides just above the filmstrip and
+/// is never stranded mid-height. Without a filmstrip it clears the bar
+/// alone, as it always has. The same rule is calls.css's stage caption and
+/// the iPhone's stage label.
+public enum StageLabel {
+ /// `bar` is how far the filmstrip (or a name) rises for the bar now: 0
+ /// while the controls are hidden. `band` is the filmstrip's height with
+ /// its padding, 0 when there is none.
+ public static func lift(bar: CGFloat, band: CGFloat) -> CGFloat { bar + band }
+}
diff --git a/native/apple/Sources/CallsMac/CallsMac.swift b/native/apple/Sources/CallsMac/CallsMac.swift
index 31b4ab4..375caba 100644
--- a/native/apple/Sources/CallsMac/CallsMac.swift
+++ b/native/apple/Sources/CallsMac/CallsMac.swift
@@ -90,6 +90,13 @@ final class CallsAppDelegate: NSObject, NSApplicationDelegate {
}
func applicationDidFinishLaunching(_ notification: Notification) {
+ // On macOS 26 and later a bundled app's Dock icon comes from Assets.car,
+ // which the system masks and renders in the person's icon style. Setting
+ // a picture here would replace that with a flat PNG, so only a run
+ // without the bundle's icon (swift run) or an older macOS sets one.
+ if #available(macOS 26, *), Bundle.main.object(forInfoDictionaryKey: "CFBundleIconName") != nil {
+ return
+ }
if let url = Bundle.main.url(forResource: "MacIcon", withExtension: "png")
?? Bundle.module.url(forResource: "MacIcon", withExtension: "png")
{
diff --git a/native/apple/Sources/CallsMac/Resources/MacIcon.png b/native/apple/Sources/CallsMac/Resources/MacIcon.png
index 1ba703e..e97c4fe 100644
Binary files a/native/apple/Sources/CallsMac/Resources/MacIcon.png and b/native/apple/Sources/CallsMac/Resources/MacIcon.png differ
diff --git a/native/apple/Tests/CallsKitTests/CallWakeTests.swift b/native/apple/Tests/CallsKitTests/CallWakeTests.swift
new file mode 100644
index 0000000..cfc719e
--- /dev/null
+++ b/native/apple/Tests/CallsKitTests/CallWakeTests.swift
@@ -0,0 +1,77 @@
+import Foundation
+import XCTest
+
+@testable import CallsKit
+
+/// The display kept awake through a call (0.4.10): begun once on joining,
+/// ended once on leaving or the call ending, never held twice.
+@MainActor final class CallWakeTests: XCTestCase {
+ final class Recorder: CallWake.Activities {
+ var begun: [String] = []
+ var ended = 0
+ var live = 0
+ func begin(reason: String) -> NSObjectProtocol {
+ begun.append(reason)
+ live += 1
+ return NSObject()
+ }
+ func end(_ activity: NSObjectProtocol) {
+ ended += 1
+ live -= 1
+ }
+ }
+
+ func testHeldFromJoiningUntilLeaving() {
+ let recorder = Recorder()
+ let wake = CallWake(activities: recorder)
+ XCTAssertFalse(wake.holding)
+ wake.update(inCall: true)
+ wake.update(inCall: true)
+ XCTAssertTrue(wake.holding)
+ XCTAssertEqual(recorder.begun, [CallWake.reason], "once, however often it is told")
+ wake.update(inCall: false)
+ wake.update(inCall: false)
+ XCTAssertFalse(wake.holding)
+ XCTAssertEqual(recorder.ended, 1)
+ XCTAssertEqual(recorder.live, 0)
+ }
+
+ func testAnEndedCallLetsTheDisplaySleep() {
+ XCTAssertTrue(CallWake.inCall(joining: true, phase: "admitted"))
+ XCTAssertTrue(CallWake.inCall(joining: true, phase: nil), "joining, before the room has answered")
+ XCTAssertTrue(CallWake.inCall(joining: true, phase: "reconnecting"))
+ XCTAssertFalse(CallWake.inCall(joining: true, phase: "ended"))
+ XCTAssertFalse(CallWake.inCall(joining: false, phase: "admitted"))
+ }
+
+ func testRejoiningHoldsItAgain() {
+ let recorder = Recorder()
+ let wake = CallWake(activities: recorder)
+ for _ in 0..<3 {
+ wake.update(inCall: true)
+ wake.update(inCall: false)
+ }
+ XCTAssertEqual(recorder.begun.count, 3)
+ XCTAssertEqual(recorder.ended, 3)
+ XCTAssertEqual(recorder.live, 0)
+ }
+
+ #if os(macOS)
+ func testTheModelHoldsItWhileJoiningOnly() {
+ let model = CallsModel(store: MemorySessionStore(), remote: .bridge, defaults: UserDefaults(suiteName: "wake-\(UUID())")!)
+ XCTAssertFalse(model.wake.holding)
+ model.joining = true
+ XCTAssertTrue(model.wake.holding)
+ model.joining = false
+ XCTAssertFalse(model.wake.holding)
+ }
+ #endif
+
+ func testTheRealActivityBeginsAndEnds() {
+ let wake = CallWake()
+ wake.update(inCall: true)
+ XCTAssertTrue(wake.holding)
+ wake.update(inCall: false)
+ XCTAssertFalse(wake.holding)
+ }
+}
diff --git a/native/apple/Tests/CallsKitTests/MiniWindowGestureTests.swift b/native/apple/Tests/CallsKitTests/MiniWindowGestureTests.swift
new file mode 100644
index 0000000..3bdff64
--- /dev/null
+++ b/native/apple/Tests/CallsKitTests/MiniWindowGestureTests.swift
@@ -0,0 +1,63 @@
+#if os(macOS)
+ import CoreGraphics
+ import XCTest
+
+ @testable import CallsKit
+
+ /// One press on the mini window (2026-10-05, Paul: "Sometimes I can't
+ /// click the mini window to either bring the call to the front or move
+ /// it around"): click, move or resize, and on which surface.
+ final class MiniWindowGestureTests: XCTestCase {
+ let window = CGSize(width: 200, height: 112.5)
+
+ func testAPressThatStaysPutIsAClick() {
+ XCTAssertFalse(MiniWindowGesture.dragged(dx: 0, dy: 0))
+ XCTAssertFalse(MiniWindowGesture.dragged(dx: 3, dy: -2), "a shaky hand")
+ XCTAssertEqual(
+ MiniWindowGesture.outcome(grabbed: [], dragged: false, clickCount: 1, option: false), .click(count: 1))
+ XCTAssertEqual(
+ MiniWindowGesture.outcome(grabbed: [.right], dragged: false, clickCount: 2, option: false), .click(count: 2),
+ "a click on an edge is still a click: it brings the call forward")
+ }
+
+ func testAPressThatTravelsIsADrag() {
+ XCTAssertTrue(MiniWindowGesture.dragged(dx: 5, dy: 0))
+ XCTAssertEqual(
+ MiniWindowGesture.outcome(grabbed: [], dragged: true, clickCount: 1, option: true), .move(option: true))
+ XCTAssertEqual(
+ MiniWindowGesture.outcome(grabbed: [.left, .bottom], dragged: true, clickCount: 1, option: false),
+ .resize([.left, .bottom]))
+ }
+
+ func testTheMiddleMovesAndTheEdgesResize() {
+ XCTAssertEqual(MiniWindowGesture.grab(at: CGPoint(x: 100, y: 56), in: window, surface: .window), [])
+ XCTAssertEqual(MiniWindowGesture.grab(at: CGPoint(x: 197, y: 56), in: window, surface: .window), [.right])
+ XCTAssertEqual(MiniWindowGesture.grab(at: CGPoint(x: 2, y: 2), in: window, surface: .window), [.left, .bottom])
+ XCTAssertEqual(MiniWindowGesture.grab(at: CGPoint(x: 198, y: 110), in: window, surface: .window), [.right, .top])
+ }
+
+ func testEvenTheSmallestWindowIsMostlyForMoving() {
+ // Grips are 8 pt: at 200 × 112.5 the middle that moves is the rest.
+ var moving = 0
+ var total = 0
+ for x in stride(from: 0.0, through: 200, by: 4) {
+ for y in stride(from: 0.0, through: 112, by: 4) {
+ total += 1
+ if MiniWindowGesture.grab(at: CGPoint(x: x, y: y), in: window, surface: .window).isEmpty { moving += 1 }
+ }
+ }
+ XCTAssertGreaterThan(Double(moving) / Double(total), 0.7, "about 79% by area")
+ }
+
+ func testTheTabNeverResizes() {
+ let tab = MiniWindowTab.size
+ for point in [CGPoint(x: 1, y: 1), CGPoint(x: tab.width - 1, y: tab.height / 2), CGPoint(x: tab.width / 2, y: tab.height / 2)] {
+ XCTAssertEqual(MiniWindowGesture.grab(at: point, in: tab, surface: .tab), [], "\(point)")
+ }
+ }
+
+ func testAWindowTooSmallForGripsHasNone() {
+ XCTAssertEqual(MiniWindowGesture.grab(at: CGPoint(x: 1, y: 1), in: CGSize(width: 30, height: 20), surface: .window), [])
+ }
+ }
+#endif
diff --git a/native/apple/Tests/CallsKitTests/TileLayoutTests.swift b/native/apple/Tests/CallsKitTests/TileLayoutTests.swift
new file mode 100644
index 0000000..2e6ba2a
--- /dev/null
+++ b/native/apple/Tests/CallsKitTests/TileLayoutTests.swift
@@ -0,0 +1,155 @@
+import CoreGraphics
+import Foundation
+import XCTest
+
+@testable import CallsKit
+
+/// 2026-10-05: a tile's placeholder centred in the whole tile, the stage
+/// filling the window under the filmstrip, a camera cropped with headroom,
+/// and the screen's backdrop.
+final class TileLayoutTests: XCTestCase {
+ // MARK: Placeholder
+
+ func testCentredInTheWholeTileWhenThereIsRoom() {
+ // A stage 600 tall, a 34 pt label block: centred, not above the label.
+ let top = PlaceholderPlacement.top(tile: 600, content: 80, reserve: 34)
+ XCTAssertEqual(top, 260)
+ XCTAssertEqual(top + 40, 300, "its middle is the tile's middle")
+ }
+
+ func testRisesOnlyAsFarAsTheLabelNeedsOnAShortTile() {
+ // The mini window at its smallest, 200 × 112.5, label block 44: a
+ // 34 pt symbol centred would end at 73.25, past the label's top less
+ // the gap (112.5 - 48 = 64.5), so it rises by 8.75 and no more.
+ let top = PlaceholderPlacement.top(tile: 112.5, content: 34, reserve: 44)
+ XCTAssertEqual(top + 34, 112.5 - 44 - PlaceholderPlacement.gap, accuracy: 0.001)
+ XCTAssertLessThan(top, (112.5 - 34) / 2)
+ XCTAssertEqual((112.5 - 34) / 2 - top, 8.75, accuracy: 0.001)
+ }
+
+ func testNeverAboveTheTop() {
+ XCTAssertEqual(PlaceholderPlacement.top(tile: 60, content: 50, reserve: 40), 0)
+ }
+
+ func testNoLabelIsPlainCentring() {
+ XCTAssertEqual(PlaceholderPlacement.top(tile: 100, content: 20, reserve: 0), 40)
+ XCTAssertEqual(PlaceholderPlacement.room(tile: 100, reserve: 0), 100)
+ }
+
+ func testRoomIsWhatChoosesTheWords() {
+ // At 200 wide the words and symbol (about 66 pt) do not fit above a
+ // 44 pt label in 112.5: the symbol stands alone.
+ XCTAssertLessThan(PlaceholderPlacement.room(tile: 112.5, reserve: 44), 66)
+ // At 320 (180 tall) they do.
+ XCTAssertGreaterThan(PlaceholderPlacement.room(tile: 180, reserve: 44), 66)
+ }
+
+ func testEveryMiniSizeKeepsTheSymbolClearOfTheLabelAndCentredWhereItCan() {
+ for width in [200.0, 320, 480] {
+ let tile = width * 9 / 16
+ let reserve: CGFloat = 44
+ let content: CGFloat = PlaceholderPlacement.room(tile: tile, reserve: reserve) >= 66 ? 66 : 34
+ let top = PlaceholderPlacement.top(tile: tile, content: content, reserve: reserve)
+ XCTAssertLessThanOrEqual(top + content, tile - reserve - PlaceholderPlacement.gap + 0.001, "\(width)")
+ let centred = (tile - content) / 2
+ if centred + content <= tile - reserve - PlaceholderPlacement.gap {
+ XCTAssertEqual(top, centred, accuracy: 0.001, "\(width): centred when it clears")
+ }
+ }
+ }
+
+ // MARK: The stage
+
+ func testStageLabelRidesAboveTheFilmstrip() {
+ // Controls showing: the filmstrip rises 72 for the bar; band 102.
+ XCTAssertEqual(StageLabel.lift(bar: 72, band: 102), 174)
+ // Hidden: just the band.
+ XCTAssertEqual(StageLabel.lift(bar: 0, band: 102), 102)
+ // A call of two: no filmstrip, the bar alone.
+ XCTAssertEqual(StageLabel.lift(bar: 72, band: 0), 72)
+ }
+
+ func testACameraFillsWithMoreCropFromTheBottom() {
+ // 16:9 into a 4:3 window: fills, crops sides evenly.
+ let wide = VideoFrame.place(video: CGSize(width: 1280, height: 720), in: CGSize(width: 800, height: 600), fit: false)
+ XCTAssertEqual(wide.height, 600, accuracy: 0.001)
+ XCTAssertEqual(wide.minX, -(wide.width - 800) / 2, accuracy: 0.001)
+ XCTAssertEqual(wide.minY, 0, accuracy: 0.001)
+ // 16:9 into a tall window: crops top and bottom, 35% from the top.
+ let tall = VideoFrame.place(video: CGSize(width: 1280, height: 720), in: CGSize(width: 1600, height: 600), fit: false)
+ let overflow = tall.height - 600
+ XCTAssertGreaterThan(overflow, 0)
+ XCTAssertEqual(-tall.minY, overflow * VideoFrame.headroom, accuracy: 0.001)
+ XCTAssertEqual(tall.maxY - 600, overflow * (1 - VideoFrame.headroom), accuracy: 0.001)
+ }
+
+ func testAScreenIsFittedWholeAndCentred() {
+ let r = VideoFrame.place(video: CGSize(width: 1920, height: 1200), in: CGSize(width: 1000, height: 1000), fit: true)
+ XCTAssertEqual(r.width, 1000, accuracy: 0.001)
+ XCTAssertEqual(r.minY, (1000 - r.height) / 2, accuracy: 0.001)
+ XCTAssertGreaterThanOrEqual(r.minY, 0)
+ }
+
+ // MARK: The screen's backdrop
+
+ func testBackdropPictureIsTheFramesColoursBlurred() throws {
+ // A 64 × 36 I420 frame: left half pure red, right half pure blue.
+ let (w, h) = (BackdropSampler.width, BackdropSampler.height)
+ var y = [UInt8](repeating: 0, count: w * h)
+ var u = [UInt8](repeating: 0, count: (w / 2) * (h / 2))
+ var v = [UInt8](repeating: 0, count: (w / 2) * (h / 2))
+ for row in 0..<h {
+ for col in 0..<w { y[row * w + col] = col < w / 2 ? 81 : 41 }
+ }
+ for row in 0..<(h / 2) {
+ for col in 0..<(w / 2) {
+ u[row * (w / 2) + col] = col < w / 4 ? 90 : 240
+ v[row * (w / 2) + col] = col < w / 4 ? 240 : 110
+ }
+ }
+ var rgba = y.withUnsafeBufferPointer { yp in
+ u.withUnsafeBufferPointer { up in
+ v.withUnsafeBufferPointer { vp in
+ BackdropSampler.rgbaFromI420(
+ width: w, height: h, y: yp.baseAddress!, strideY: w, u: up.baseAddress!, strideU: w / 2,
+ v: vp.baseAddress!, strideV: w / 2, outWidth: w, outHeight: h)
+ }
+ }
+ }
+ XCTAssertGreaterThan(rgba[0], 200, "red on the left")
+ XCTAssertGreaterThan(rgba[(w - 1) * 4 + 2], 200, "blue on the right")
+ let edge = (w / 2) * 4
+ let before = rgba[edge]
+ BackdropSampler.boxBlur(&rgba, width: w, height: h, radius: 3, passes: 2)
+ XCTAssertLessThan(rgba[edge], before == 0 ? 255 : 255, "blurred")
+ XCTAssertGreaterThan(rgba[edge + 2], 40, "blue has bled across the edge")
+ XCTAssertGreaterThan(rgba[edge], 40, "and red")
+ XCTAssertNotNil(BackdropSampler.image(rgba, width: w, height: h))
+ _ = try XCTUnwrap(rgba.last)
+ }
+
+ func testBackdropWorkIsCheap() {
+ // What happens twice a second for a screen on the stage, after
+ // WebRTC's own scale to 64 × 36: conversion, blur and the CGImage.
+ let (w, h) = (BackdropSampler.width, BackdropSampler.height)
+ let y = [UInt8](repeating: 120, count: w * h)
+ let c = [UInt8](repeating: 128, count: (w / 2) * (h / 2))
+ let runs = 50
+ let start = Date()
+ for _ in 0..<runs {
+ var rgba = y.withUnsafeBufferPointer { yp in
+ c.withUnsafeBufferPointer { cp in
+ BackdropSampler.rgbaFromI420(
+ width: w, height: h, y: yp.baseAddress!, strideY: w, u: cp.baseAddress!, strideU: w / 2,
+ v: cp.baseAddress!, strideV: w / 2, outWidth: w, outHeight: h)
+ }
+ }
+ BackdropSampler.boxBlur(&rgba, width: w, height: h, radius: 3, passes: 2)
+ _ = BackdropSampler.image(rgba, width: w, height: h)
+ }
+ let each = Date().timeIntervalSince(start) / Double(runs) * 1000
+ print("backdrop: \(String(format: "%.3f", each)) ms a picture, twice a second")
+ // A debug build; release is several times quicker. Well under a frame.
+ XCTAssertLessThan(each, 8)
+ }
+}
diff --git a/native/ios/Sources/CallsPhone.swift b/native/ios/Sources/CallsPhone.swift
index b644fda..9b21a5c 100644
--- a/native/ios/Sources/CallsPhone.swift
+++ b/native/ios/Sources/CallsPhone.swift
@@ -425,15 +425,21 @@ struct PhoneVideoTile: View {
/// A filmstrip thumbnail: the symbol alone for a missing picture.
var iconOnly = false
/// The height of the name label and what is over it, which the
- /// placeholder stays above.
+ /// placeholder keeps clear of.
@State private var labelBlock: CGFloat = 0
+ @Environment(\.fixtureStagePicture) private var fixturePicture
var body: some View {
TileAudioReader(audio) { info in tile(info) }
}
private func tile(_ info: TileAudioInfo?) -> some View {
ZStack {
backdrop
- if let track, !cameraOff, placeholder == nil {
+ if let fixturePicture, onStage, track == nil, !cameraOff, placeholder == nil {
+ FixtureVideo(picture: fixturePicture, screen: screen)
+ } else if let track, !cameraOff, placeholder == nil {
+ // A screen on the stage is fitted whole; around it, the same
+ // picture soft and dimmed rather than black bars.
+ if screen, onStage { ScreenBackdrop(track: track) }
PhoneVideo(track: track, fit: screen).scaleEffect(x: mirrored ? -1 : 1, y: 1)
} else {
TilePlaceholder(
@@ -472,18 +478,18 @@ struct PhoneVideo: UIViewRepresentable {
let track: RTCVideoTrack
var fit = false
func makeCoordinator() -> Coordinator { Coordinator() }
- func makeUIView(context: Context) -> RTCMTLVideoView {
- let view = RTCMTLVideoView(frame: .zero)
- view.videoContentMode = fit ? .scaleAspectFit : .scaleAspectFill
- context.coordinator.attach(track, to: view)
+ func makeUIView(context: Context) -> PlacedVideoView {
+ let view = PlacedVideoView()
+ view.fit = fit
+ context.coordinator.attach(track, to: view.renderer)
return view
}
- func updateUIView(_ view: RTCMTLVideoView, context: Context) {
- view.videoContentMode = fit ? .scaleAspectFit : .scaleAspectFill
- context.coordinator.attach(track, to: view)
+ func updateUIView(_ view: PlacedVideoView, context: Context) {
+ view.fit = fit
+ context.coordinator.attach(track, to: view.renderer)
}
- static func dismantleUIView(_ view: RTCMTLVideoView, coordinator: Coordinator) {
- coordinator.track?.remove(view)
+ static func dismantleUIView(_ view: PlacedVideoView, coordinator: Coordinator) {
+ coordinator.track?.remove(view.renderer)
}
final class Coordinator {
var track: RTCVideoTrack?
@@ -497,6 +503,37 @@ struct PhoneVideo: UIViewRepresentable {
}
}
+/// The renderer at the size and place `VideoFrame` gives it: a camera
+/// filling the tile with more of a crop taken from the bottom than the
+/// top, so heads are not cut; a screen fitted whole. The container clips.
+final class PlacedVideoView: UIView, RTCVideoViewDelegate {
+ let renderer = RTCMTLVideoView(frame: .zero)
+ private var videoSize = CGSize(width: 16, height: 9)
+ var fit = false {
+ didSet { if fit != oldValue { setNeedsLayout() } }
+ }
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+ clipsToBounds = true
+ isUserInteractionEnabled = false
+ renderer.videoContentMode = .scaleToFill
+ renderer.delegate = self
+ addSubview(renderer)
+ }
+ required init?(coder: NSCoder) { nil }
+ nonisolated func videoView(_ videoView: RTCVideoRenderer, didChangeVideoSize size: CGSize) {
+ DispatchQueue.main.async { [weak self] in
+ guard let self, size.width > 0, size.height > 0 else { return }
+ self.videoSize = size
+ self.setNeedsLayout()
+ }
+ }
+ override func layoutSubviews() {
+ super.layoutSubviews()
+ renderer.frame = VideoFrame.place(video: videoSize, in: bounds.size, fit: fit)
+ }
+}
+
/// Speaker or Grid, and what is pinned to Speaker view's stage: the
/// iPhone's layout menu, in the classic call and the controls sheet.
struct LayoutChoices: View {
diff --git a/native/ios/Sources/PhoneCallFixture.swift b/native/ios/Sources/PhoneCallFixture.swift
index 714f657..f3e4284 100644
--- a/native/ios/Sources/PhoneCallFixture.swift
+++ b/native/ios/Sources/PhoneCallFixture.swift
@@ -8,7 +8,9 @@
/// optionally `-CallsFixtureControls shown|hidden` to pin the controls,
/// `-CallsFixtureMore YES` to open the controls menu, `-CallsFixtureDark
/// YES`, `-CallsFixtureClassic YES`, `-CallsFixtureBackdrop black|white|busy`
- /// for a frame in place of the stage, `-CallsFixtureSelf
+ /// for a frame in place of the stage, `-CallsFixtureStagePicture
+ /// black|white|busy` for a frame as the picture on the stage's tile (the
+ /// filmstrip and labels over it), `-CallsFixtureSelf
/// muted|cameraOff|sharing` for your own state, `-CallsFixtureSpeaking
/// silent|low|mid|high` for everyone's speaking glow, and
/// `-CallsFixtureNoGlass YES` for the material older systems draw in
@@ -45,6 +47,9 @@
.environment(
\.fixtureBackdrop,
defaults.string(forKey: "CallsFixtureBackdrop").flatMap(FixtureBackdrop.init(rawValue:)))
+ .environment(
+ \.fixtureStagePicture,
+ defaults.string(forKey: "CallsFixtureStagePicture").flatMap(FixtureBackdrop.init(rawValue:)))
.environment(\.callOverlayGlass, !defaults.bool(forKey: "CallsFixtureNoGlass"))
.preferredColorScheme(defaults.bool(forKey: "CallsFixtureDark") ? .dark : .light)
.onAppear {
diff --git a/native/ios/Sources/PhoneFullScreenCall.swift b/native/ios/Sources/PhoneFullScreenCall.swift
index 194cf26..242703e 100644
--- a/native/ios/Sources/PhoneFullScreenCall.swift
+++ b/native/ios/Sources/PhoneFullScreenCall.swift
@@ -102,11 +102,14 @@ extension PhoneCall {
.onPreferenceChange(TileFramesKey.self) { tileFrames = $0 }
}
- /// Speaker view: the stage, and everyone else in a filmstrip along the
- /// bottom that scrolls sideways, in this viewer's order. It sits just
- /// above the home indicator and rises clear of the bar while the
- /// controls show, as the self view does. In a call of two there is no
- /// filmstrip: the other person fills the screen and you float.
+ /// Speaker view: the stage fills the whole screen, edge to edge, and
+ /// everyone else is a filmstrip floating over its bottom that scrolls
+ /// sideways, in this viewer's order (2026-10-05: until then the stage
+ /// stopped above the filmstrip, a black band behind it). The filmstrip
+ /// sits just above the home indicator and rises clear of the bar while
+ /// the controls show, as the self view does; the stage's name rides just
+ /// above it (StageLabel). In a call of two there is no filmstrip: the
+ /// other person fills the screen and you float.
private func speakerStage(in size: CGSize, safe: EdgeInsets) -> some View {
let strip = selfFloats ? [] : filmstrip
let thumb = Self.filmstripTile(in: size)
@@ -114,7 +117,7 @@ extension PhoneCall {
let raised = chrome.visible ? Self.barHeight : 0
let lift = EdgeInsets(
top: 0, leading: safe.leading,
- bottom: strip.isEmpty ? safe.bottom + raised : raised,
+ bottom: safe.bottom + StageLabel.lift(bar: raised, band: band),
trailing: 0)
return ZStack(alignment: .bottom) {
Group {
@@ -134,7 +137,6 @@ extension PhoneCall {
// bring the controls back.
.accessibilityIdentifier("callStage")
.frame(maxWidth: .infinity, maxHeight: .infinity)
- .padding(.bottom, strip.isEmpty ? 0 : band + safe.bottom)
// The pin, over the stage's top corner below the title; outside
// the stage's own accessibility identifier, so it keeps its own.
if let focused, arrangement.pinned == focused {
diff --git a/native/ios/project.yml b/native/ios/project.yml
index ef3c94e..c4bd67d 100644
--- a/native/ios/project.yml
+++ b/native/ios/project.yml
@@ -10,7 +10,7 @@ settings:
base:
SWIFT_VERSION: '5.0'
CODE_SIGN_STYLE: Automatic
- MARKETING_VERSION: '0.4.9'
+ MARKETING_VERSION: '0.4.10'
CURRENT_PROJECT_VERSION: '1'
# The App Group the app and its broadcast extension share: the socket
# the iPhone's screen crosses lives in its container. One setting
diff --git a/native/scripts/bundle-mac.py b/native/scripts/bundle-mac.py
index f478491..18313a2 100644
--- a/native/scripts/bundle-mac.py
+++ b/native/scripts/bundle-mac.py
@@ -1,4 +1,5 @@
"""Bundle the local development app. Distribution signing is a separate step."""
+import json
from pathlib import Path
import plistlib
import shutil
@@ -29,8 +30,35 @@ shutil.copy2(root / 'PATENTS.webrtc', bundle / 'Resources/PATENTS.webrtc')
# The Mac's noise suppressor model (scripts/build-deepfilter.sh). Without
# it the app falls back to RNNoise.
shutil.copy2(root / 'build/DeepFilterNet3_onnx.tar.gz', bundle / 'Resources/DeepFilterNet3_onnx.tar.gz')
-# Finder reads the ICNS before the app launches; the PNG also supplies the
-# runtime Dock icon. Generate each required representation from the master.
+# macOS 26 and later draw an app's icon from an asset catalog: full-bleed
+# artwork that the system masks, lights and tints itself. An app with only an
+# ICNS is legacy there, its artwork shrunk onto a plain squircle. So compile
+# the square master (the iPhone's, which is opaque and unrounded) as an Icon
+# Composer .icon into Assets.car, named by CFBundleIconName. actool also
+# flattens it into renditions on the 824-point grid for macOS 14 and 15.
+icon_package = root / 'build/AppIcon.icon'
+if icon_package.exists(): shutil.rmtree(icon_package)
+(icon_package / 'Assets').mkdir(parents=True)
+shutil.copy2(root / 'ios/Assets.xcassets/AppIcon.appiconset/AppIcon.png', icon_package / 'Assets/calls.png')
+(icon_package / 'icon.json').write_text(json.dumps({
+ 'fill': 'automatic',
+ 'groups': [{'layers': [{'glass': False, 'image-name': 'calls.png', 'name': 'calls'}],
+ 'shadow': {'kind': 'neutral', 'opacity': 0.5},
+ 'translucency': {'enabled': False, 'value': 0}}],
+ 'supported-platforms': {'squares': ['macOS']},
+}, indent=2))
+catalog = root / 'build/AssetCatalog'
+if catalog.exists(): shutil.rmtree(catalog)
+catalog.mkdir(parents=True)
+subprocess.run(['xcrun', 'actool', str(icon_package), '--compile', str(catalog),
+ '--platform', 'macosx', '--minimum-deployment-target', '14.0',
+ '--app-icon', 'AppIcon', '--output-partial-info-plist', str(catalog / 'partial.plist'),
+ '--errors', '--warnings'], check=True, stdout=subprocess.DEVNULL)
+if not (catalog / 'Assets.car').exists():
+ raise SystemExit('actool wrote no Assets.car: an Xcode that compiles .icon (26 or later) is required')
+shutil.copy2(catalog / 'Assets.car', bundle / 'Resources/Assets.car')
+# The ICNS stays as CFBundleIconFile for anything that reads only that key.
+# The PNG is the Dock icon for a run outside a bundle (CallsMac.swift).
iconset = root / 'build/AppIcon.iconset'
iconset.mkdir(parents=True, exist_ok=True)
for size in (16, 32, 128, 256, 512):
@@ -47,7 +75,7 @@ info = {
'CFBundleName': 'Calls', 'CFBundleDisplayName': 'Calls',
'CFBundleIdentifier': 'net.amadan.oficina.calls',
'CFBundleExecutable': 'CallsMac', 'CFBundlePackageType': 'APPL',
- 'CFBundleIconFile': 'CallsMac.icns',
+ 'CFBundleIconFile': 'CallsMac.icns', 'CFBundleIconName': 'AppIcon',
'CFBundleShortVersionString': '0.1.0', 'CFBundleVersion': '1',
'LSMinimumSystemVersion': '14.0', 'NSHighResolutionCapable': True,
'NSCameraUseContinuityCameraDeviceType': True,
diff --git a/native/scripts/generate-mac-icon.swift b/native/scripts/generate-mac-icon.swift
index ff8c7c4..1e96ac7 100644
--- a/native/scripts/generate-mac-icon.swift
+++ b/native/scripts/generate-mac-icon.swift
@@ -1,11 +1,17 @@
import AppKit
-// macOS displays the supplied silhouette; unlike iOS it does not mask an opaque master.
-// Keep the approved square master intact and derive a padded Mac icon from it.
+// Before macOS 26 the system draws the supplied silhouette as it is; unlike iOS
+// it does not mask an opaque master. Derive the Mac's rounded tile on Apple's
+// 824-on-1024 grid from the full-bleed square master, the iPhone's. Not from
+// Resources/AppIcon.png: that one is already rounded onto the grid, and
+// drawing it into the grid again left the artwork at 664 of 1024, small in the
+// Dock beside every other app. macOS 26 and later use Assets.car instead
+// (bundle-mac.py), where the system applies the mask.
let native = URL(fileURLWithPath: #filePath).standardizedFileURL
.deletingLastPathComponent().deletingLastPathComponent()
let resources = native.appendingPathComponent("apple/Sources/CallsMac/Resources")
-guard let source = NSImage(contentsOf: resources.appendingPathComponent("AppIcon.png")),
+let master = native.appendingPathComponent("ios/Assets.xcassets/AppIcon.appiconset/AppIcon.png")
+guard let source = NSImage(contentsOf: master),
let bitmap = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 1024, pixelsHigh: 1024,
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0),
diff --git a/test/browser/call.mjs b/test/browser/call.mjs
index 7bcf2ec..05a98ac 100644
--- a/test/browser/call.mjs
+++ b/test/browser/call.mjs
@@ -31,6 +31,7 @@
// candidates are the only ones there are, and an obfuscated one connects
// to nobody; and --auto-accept-this-tab-capture answers getDisplayMedia
// with no picker, which the fake-ui flag does not cover (§5.4).
+import { syntheticMedia } from './synthetic-media.mjs';
import { writeSync, mkdirSync, writeFileSync } from 'node:fs';
import { inflateSync } from 'node:zlib';
import { createInterface } from 'node:readline';
@@ -353,9 +354,11 @@ async function newContext(label, { cookie = null, javaScriptEnabled = true, via
// constructed — no page behaviour changes, and nothing reads it
// except diagnose() and the renewal scenario's own ufrag walk.
if (javaScriptEnabled) await ctx.addInitScript(pcTap);
+ if (javaScriptEnabled && process.env.CALLS_SYNTHETIC_MEDIA) await ctx.addInitScript(syntheticMedia);
return ctx;
}
+
function watch(page, label) {
const errs = consoleErrors.get(label);
const all = consoleLines.get(label);
@@ -5040,13 +5043,18 @@ const run = {
await pageA.locator('.calls-tile-share').waitFor({state: 'visible'});
check(await until(async () => !await pageA.locator('#tiles').evaluate(el => el.hasAttribute('data-pip')), 2000),
'screen share never gets a self-preview overlay');
- // With the controls hidden: while they show, the filmstrip rises
- // clear of the bar and over the stage's lower edge, as the Mac's does.
+ // With the controls hidden. Since 2026-10-05 the stage fills the
+ // window and the filmstrip floats over its foot, as FaceTime's does:
+ // you are a filmstrip tile there, never a floating self view, and the
+ // filmstrip is in the bottom of the window, over the screen's soft
+ // surround or its lowest edge (the stage scenario holds the rest).
await pageA.evaluate(() => window.callsChrome.pin(false));
await sleep(400);
const shareBounds = await pageA.locator('.calls-tile-share').boundingBox();
const selfBounds = await pageA.locator('.calls-tile-self').boundingBox();
- check(selfBounds.y >= shareBounds.y + shareBounds.height - 1, 'self preview is outside shared screen content');
+ check(!await pageA.locator('.calls-tile-self').evaluate((f) => f.classList.contains('calls-tile-pip')) &&
+ selfBounds.y + selfBounds.height >= shareBounds.y + shareBounds.height - 24,
+ 'self preview is a filmstrip tile at the foot of the shared screen, not floating over its content');
await pageA.evaluate(() => window.callsChrome.pin(null));
if (out) await pageA.screenshot({path: out + '/share-320.png', fullPage: true});
await pageB.locator('#share').click();
@@ -5169,6 +5177,159 @@ const run = {
await ctxB.close();
},
+ // The stage (2026-10-05). Paul: "Should the video view stay fullscreen
+ // even here? The black line is a bit weird." Speaker view's stage fills
+ // the whole window, the filmstrip over its bottom, with no band behind
+ // it and the bar; its caption sits just above the filmstrip, at the
+ // window's left; a screen on the stage is fitted with its own picture
+ // soft and dimmed around it (the backdrop canvas, painted, and what a
+ // paint costs); Grid keeps its tiles inside the window. And "Vertical
+ // centering here too": every camera-off cover is centred in its whole
+ // tile, or, on a tile too short for that, only as high as clears the
+ // caption. Controls shown and hidden throughout.
+ // CALLS_STAGE_SNAPSHOTS=<dir> keeps pictures and the measurements.
+ async stage() {
+ const out = process.env.CALLS_STAGE_SNAPSHOTS;
+ if (out) mkdirSync(out, { recursive: true });
+ const report = [];
+ const ctxA = await newContext('stage A', { cookie: cookieA });
+ const ctxB = await newContext('stage B', { cookie: cookieB });
+ const pageA = await openRoom(ctxA);
+ const pageB = await openRoom(ctxB);
+ const pair = await assertPair(pageA, pageB, 'stage A', 'stage B');
+ if (!pair) throw new Error('stage call did not connect');
+ const b = pair.b.peer;
+ await pageA.emulateMedia({ reducedMotion: 'reduce' });
+ await pageA.setViewportSize({ width: 1280, height: 800 });
+ await chooseLayout(pageA, 'speaker');
+ const geo = () => pageA.evaluate(() => {
+ const r = (el) => {
+ if (!el || !el.getClientRects().length) return null;
+ const x = el.getBoundingClientRect();
+ return { top: x.top, bottom: x.bottom, left: x.left, right: x.right, width: x.width, height: x.height };
+ };
+ const ink = (fig) => {
+ const off = fig && fig.querySelector('.calls-tile-off');
+ if (!off || off.hidden) return null;
+ // What can be seen: not the words a narrow tile keeps only for
+ // assistive technology (1 px, clipped).
+ const parts = Array.from(off.children).filter((c) => getComputedStyle(c).display !== 'none' && c.getClientRects().length).map(r)
+ .filter((q) => q.width > 1 && q.height > 1);
+ if (!parts.length) return null;
+ return { top: Math.min(...parts.map((q) => q.top)), bottom: Math.max(...parts.map((q) => q.bottom)) };
+ };
+ const stage = document.querySelector('#tiles > [data-stage]');
+ const strip = Array.from(document.querySelectorAll('#tiles > figure:not([data-stage], .calls-tile-pip)')).filter((f) => f.getClientRects().length);
+ const backdrop = stage && stage.querySelector('.calls-tile-backdrop');
+ return {
+ window: r(document.querySelector('.calls-stage')),
+ stage: r(stage), stageKey: stage ? stage.dataset.tile : '',
+ caption: r(stage && stage.querySelector('figcaption')),
+ cover: ink(stage),
+ strip: strip.map((f) => ({ key: f.dataset.tile, tile: r(f), caption: r(f.querySelector('figcaption')), cover: ink(f) })),
+ backdrop: backdrop ? { shown: getComputedStyle(backdrop).display !== 'none', painted: +(backdrop.dataset.painted || 0), costMs: +(backdrop.dataset.costMs || 0) } : null,
+ grid: Array.from(document.querySelectorAll('#tiles[data-layout="grid"] > figure')).map(r),
+ };
+ });
+ // Centred in the tile, or risen only as far as clears its caption.
+ const placed = (tile, cover, caption, what) => {
+ if (!cover || !caption) return check(false, what + ': has a cover and a caption');
+ const mid = (cover.top + cover.bottom) / 2;
+ const centre = (tile.top + tile.bottom) / 2;
+ check(cover.bottom <= caption.top + 0.5, what + ': the cover is clear of the caption (' + Math.round(cover.bottom) + ' vs ' + Math.round(caption.top) + ')');
+ check(Math.abs(mid - centre) <= 1.5 || cover.bottom >= caption.top - 8,
+ what + ': the cover is centred in the whole tile (' + mid.toFixed(1) + ' vs ' + centre.toFixed(1) + '), or only as high as clears the caption');
+ };
+ const both = async (name, fn) => {
+ for (const shown of [true, false]) {
+ await pageA.evaluate((v) => window.callsChrome.pin(v), shown);
+ await sleep(500);
+ const g = await geo();
+ report.push({ name, shown, ...g });
+ await fn(g, name + (shown ? ' (controls shown)' : ' (controls hidden)'), shown);
+ if (out) await pageA.screenshot({ path: out + '/web-' + name + '-' + (shown ? 'controls' : 'hidden') + '.png' });
+ }
+ await pageA.evaluate(() => window.callsChrome.pin(null));
+ };
+ const fills = (g, what, shown) => {
+ check(g.stage && g.window && Math.abs(g.stage.top - g.window.top) <= 1 && Math.abs(g.stage.bottom - g.window.bottom) <= 1 &&
+ Math.abs(g.stage.left - g.window.left) <= 1 && Math.abs(g.stage.right - g.window.right) <= 1,
+ what + ': the stage fills the window, no band (' + JSON.stringify(g.stage) + ' in ' + JSON.stringify(g.window) + ')');
+ // With a filmstrip, the caption rides just above it; without one
+ // (a call of two, you floating), it is at the window's foot, above
+ // the bar while that shows.
+ const top = g.strip.length ? Math.min(...g.strip.map((t) => t.tile.top)) : g.window.bottom - (shown ? 72 : 0);
+ check(g.caption && g.caption.bottom <= top - 4 && g.caption.bottom >= top - 24,
+ what + ': the stage caption sits just above the ' + (g.strip.length ? 'filmstrip' : 'window\'s foot') +
+ ' (' + (g.caption && Math.round(g.caption.bottom)) + ' vs ' + Math.round(top) + ')');
+ check(g.caption && g.caption.left - g.window.left <= 16, what + ': and at the window\'s left');
+ };
+
+ // A pins herself: her camera on the stage, B in the filmstrip.
+ await openTileMenu(pageA, 'self');
+ await pageA.locator('#tile-menu [data-act="pin"]').click();
+ check(await until(async () => (await pageA.locator('#tiles > [data-stage]').getAttribute('data-tile')) === 'self', 3000),
+ 'A pinned to the stage, B in the filmstrip');
+ await both('stage-camera', async (g, what, shown) => {
+ fills(g, what, shown);
+ const fit = await pageA.locator('#tiles > [data-stage] video').evaluate((v) => [getComputedStyle(v).objectFit, getComputedStyle(v).objectPosition]);
+ check(fit[0] === 'cover' && fit[1] === '50% 35%', what + ': the camera fills, cropped with headroom (' + fit.join(' ') + ')');
+ });
+
+ // B's camera off: B's filmstrip tile's cover, centred.
+ await pageB.locator('#camera').click();
+ const tileB = pageA.locator('#tiles > figure[data-tile="' + b + '"]');
+ check(await until(async () => tileB.locator('.calls-tile-off').isVisible(), 10000), 'B shows camera off on A');
+ for (const width of [1280, 760, 390]) {
+ await pageA.setViewportSize({ width, height: width < 500 ? 844 : 800 });
+ await both('filmstrip-camera-off-' + width, async (g, what) => {
+ const t = g.strip.find((x) => x.key === b);
+ placed(t.tile, t.cover, t.caption, what + ' B\'s filmstrip tile');
+ });
+ }
+ await pageA.setViewportSize({ width: 1280, height: 800 });
+
+ // B, camera off, pinned to the stage.
+ await openTileMenu(pageA, b);
+ await pageA.locator('#tile-menu [data-act="pin"]').click();
+ check(await until(async () => (await pageA.locator('#tiles > [data-stage]').getAttribute('data-tile')) === b, 3000), 'B pinned to the stage');
+ await both('stage-camera-off', async (g, what, shown) => {
+ fills(g, what, shown);
+ placed(g.stage, g.cover, g.caption, what + ' the stage');
+ });
+ await pageB.locator('#camera').click();
+
+ // B shares: the screen takes the stage, fitted, its backdrop painted.
+ await openTileMenu(pageA, b);
+ await pageA.locator('#tile-menu [data-act="pin"]').click();
+ await pageB.locator('#share').click();
+ check(await until(async () => (await pageA.locator('#tiles > [data-stage].calls-tile-share').count()) === 1, 15000), 'B\'s screen is on the stage');
+ check(await until(async () => ((await geo()).backdrop || {}).painted > 1, 8000), 'the screen\'s backdrop is painted');
+ await both('stage-screen', async (g, what, shown) => {
+ fills(g, what, shown);
+ const fit = await pageA.locator('#tiles > [data-stage] video').evaluate((v) => getComputedStyle(v).objectFit);
+ check(fit === 'contain', what + ': the screen is fitted whole (' + fit + ')');
+ check(g.backdrop && g.backdrop.shown, what + ': its soft picture shows around it');
+ });
+ const cost = ((await geo()).backdrop || { costMs: NaN }).costMs;
+ report.push({ backdropWorstPaintMs: cost });
+ line('stage: the backdrop\'s slowest paint was ' + cost + ' ms, twice a second');
+ check(cost < 8, 'a backdrop paint is cheap (' + cost + ' ms)');
+
+ // Grid: tiles as before, inside the window.
+ await chooseLayout(pageA, 'grid');
+ await both('grid', async (g, what) => {
+ check(g.grid.length >= 2 && g.grid.every((t) => t.top >= g.window.top - 0.5 && t.bottom <= g.window.bottom + 0.5),
+ what + ': every tile inside the window');
+ });
+ await pageB.locator('#share').click();
+ if (out) writeFileSync(out + '/web-stage-measurements.json', JSON.stringify(report, null, 2));
+ await leaveRoom(pageA);
+ await leaveRoom(pageB);
+ await ctxA.close();
+ await ctxB.close();
+ },
+
// The call window (2026-09-28). Paul asked for the web call to be
// more like the Mac's: the whole call in a rounded window inset from
// the viewport. So: the window is inset with rounded corners on a
diff --git a/test/browser/native-motion.mjs b/test/browser/native-motion.mjs
index f0ab777..ea4f4b2 100644
--- a/test/browser/native-motion.mjs
+++ b/test/browser/native-motion.mjs
@@ -14,6 +14,7 @@
// MOTION_MODES comma-separated modes, default "balanced,detail,motion"
// MOTION_UPLINKS comma-separated caps in bit/s, 0 for none, default "0,1500000"
// MOTION_ASSERT "0" to measure without asserting
+import { syntheticMedia } from './synthetic-media.mjs';
import { chromium } from 'playwright';
import { spawn } from 'node:child_process';
import { createInterface } from 'node:readline';
@@ -31,6 +32,7 @@ const browser = await chromium.launch({ headless: true, args: [
'--mute-audio',
] });
const context = await browser.newContext({ permissions: ['camera', 'microphone'] });
+if (process.env.CALLS_SYNTHETIC_MEDIA) await context.addInitScript(syntheticMedia);
await context.addCookies([{ name: process.env.CALLS_COOKIE_NAME, value: process.env.CALLS_COOKIE_A, url: origin }]);
const page = await context.newPage();
await page.addInitScript(() => {
diff --git a/test/browser/native-screen.mjs b/test/browser/native-screen.mjs
index c4551d3..6e9a618 100644
--- a/test/browser/native-screen.mjs
+++ b/test/browser/native-screen.mjs
@@ -41,6 +41,7 @@
// browser's ladder has to restore it (or say it cannot)
//
// Run by TestNativeMacScreenRecovery (make native-browser).
+import { syntheticMedia } from './synthetic-media.mjs';
import { chromium } from 'playwright';
import { spawn } from 'node:child_process';
import { createInterface } from 'node:readline';
@@ -67,6 +68,7 @@ const browser = await chromium.launch({ headless: true, args: [
// blob, which the page's own policy (default-src 'self') would refuse.
// The gate's, never the page's.
const context = await browser.newContext({ permissions: ['camera', 'microphone'], bypassCSP: true });
+if (process.env.CALLS_SYNTHETIC_MEDIA) await context.addInitScript(syntheticMedia);
await context.addCookies([{ name: process.env.CALLS_COOKIE_NAME, value: process.env.CALLS_COOKIE_A, url: origin }]);
const page = await context.newPage();
await page.addInitScript(() => {
diff --git a/test/browser/native.mjs b/test/browser/native.mjs
index f5a85ca..a10abfd 100644
--- a/test/browser/native.mjs
+++ b/test/browser/native.mjs
@@ -1,3 +1,4 @@
+import { syntheticMedia } from './synthetic-media.mjs';
import { chromium } from 'playwright';
import { spawn } from 'node:child_process';
import { createInterface } from 'node:readline';
@@ -14,6 +15,7 @@ const browser = await chromium.launch({ headless: true, args: [
'--auto-accept-this-tab-capture',
] });
const context = await browser.newContext({ permissions: ['camera', 'microphone'] });
+if (process.env.CALLS_SYNTHETIC_MEDIA) await context.addInitScript(syntheticMedia);
await context.addCookies([{ name: process.env.CALLS_COOKIE_NAME, value: process.env.CALLS_COOKIE_A, url: origin }]);
const page = await context.newPage();
await page.addInitScript(() => {
diff --git a/test/browser/room-wake.test.mjs b/test/browser/room-wake.test.mjs
new file mode 100644
index 0000000..b0bca3d
--- /dev/null
+++ b/test/browser/room-wake.test.mjs
@@ -0,0 +1,152 @@
+// room-wake.test.mjs — the screen kept on through a call (0.4.10), gated
+// without a browser.
+//
+// node --test test/browser/room-wake.test.mjs
+//
+// screenWake is lifted out of the shipped room.js by name, as
+// room-quality.test.mjs lifts its builders, and given a fake navigator
+// and document: a Screen Wake Lock that grants, refuses or is missing,
+// and a tab that is hidden and shown.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+
+const roomSrc = readFileSync(fileURLToPath(new URL('../../internal/calls/static/room.js', import.meta.url)), 'utf8');
+
+function lift(name) {
+ const start = roomSrc.indexOf('function ' + name + '(');
+ assert.notEqual(start, -1, name + ' is not in room.js any more — this test is stale, not passing');
+ let depth = 0;
+ for (let i = roomSrc.indexOf('{', start); i < roomSrc.length; i++) {
+ if (roomSrc[i] === '{') depth += 1;
+ else if (roomSrc[i] === '}') {
+ depth -= 1;
+ if (depth === 0) return roomSrc.slice(start, i + 1);
+ }
+ }
+ assert.fail('unbalanced braces reading ' + name);
+}
+const screenWake = new Function(lift('screenWake') + '\nreturn screenWake;')();
+
+// A browser's wake lock: each grant is a sentinel that the page can
+// release, and that the browser releases itself when the tab is hidden.
+function fakeBrowser({ supported = true, refuse = false } = {}) {
+ const listeners = [];
+ const doc = {
+ visibilityState: 'visible',
+ addEventListener(type, fn) { if (type === 'visibilitychange') listeners.push(fn); },
+ };
+ const live = new Set();
+ const log = [];
+ const sentinel = () => {
+ const handlers = [];
+ const s = {
+ released: false,
+ addEventListener(type, fn) { if (type === 'release') handlers.push(fn); },
+ async release() {
+ if (s.released) return;
+ s.released = true;
+ live.delete(s);
+ log.push('release');
+ handlers.forEach((fn) => fn());
+ },
+ };
+ return s;
+ };
+ const nav = supported ? {
+ wakeLock: {
+ async request(kind) {
+ log.push('request ' + kind);
+ if (refuse) throw Object.assign(new Error('refused'), { name: 'NotAllowedError' });
+ const s = sentinel();
+ live.add(s);
+ return s;
+ },
+ },
+ } : {};
+ const setVisible = async (visible) => {
+ doc.visibilityState = visible ? 'visible' : 'hidden';
+ if (!visible) for (const s of [...live]) await s.release();
+ listeners.forEach((fn) => fn());
+ await new Promise((r) => setTimeout(r, 0));
+ };
+ return { nav, doc, live, log, setVisible };
+}
+
+test('held from the start of the call, released on leaving', async () => {
+ const b = fakeBrowser();
+ const wake = screenWake(b.nav, b.doc);
+ assert.equal(wake.held, false);
+ await wake.hold();
+ assert.equal(wake.held, true);
+ assert.deepEqual(b.log, ['request screen']);
+ await wake.hold();
+ assert.equal(b.live.size, 1, 'never two locks');
+ await wake.release();
+ assert.equal(wake.held, false);
+ assert.equal(b.live.size, 0);
+});
+
+test('asked for again when the tab comes back, while still in the call', async () => {
+ const b = fakeBrowser();
+ const wake = screenWake(b.nav, b.doc);
+ await wake.hold();
+ await b.setVisible(false);
+ assert.equal(wake.held, false, 'the browser let it go with the tab hidden');
+ await b.setVisible(true);
+ assert.equal(wake.held, true);
+ assert.equal(b.log.filter((l) => l === 'request screen').length, 2);
+});
+
+test('not asked for again after leaving', async () => {
+ const b = fakeBrowser();
+ const wake = screenWake(b.nav, b.doc);
+ await wake.hold();
+ await wake.release();
+ await b.setVisible(false);
+ await b.setVisible(true);
+ assert.equal(wake.held, false);
+ assert.equal(b.log.filter((l) => l === 'request screen').length, 1);
+});
+
+test('not asked for while the tab is hidden', async () => {
+ const b = fakeBrowser();
+ b.doc.visibilityState = 'hidden';
+ const wake = screenWake(b.nav, b.doc);
+ await wake.hold();
+ assert.deepEqual(b.log, []);
+ await b.setVisible(true);
+ assert.equal(wake.held, true);
+});
+
+test('a grant that arrives after leaving is let go at once', async () => {
+ const b = fakeBrowser();
+ const wake = screenWake(b.nav, b.doc);
+ const asked = wake.hold();
+ wake.release();
+ await asked;
+ assert.equal(wake.held, false);
+ assert.equal(b.live.size, 0);
+});
+
+test('silent where unsupported or refused', async () => {
+ for (const options of [{ supported: false }, { refuse: true }]) {
+ const b = fakeBrowser(options);
+ const wake = screenWake(b.nav, b.doc);
+ await wake.hold();
+ await b.setVisible(false);
+ await b.setVisible(true);
+ assert.equal(wake.held, false);
+ await wake.release();
+ }
+ const none = screenWake(undefined, { visibilityState: 'visible', addEventListener() {} });
+ await none.hold();
+ await none.release();
+});
+
+test('room.js holds it at the start and lets it go on both ways out', () => {
+ assert.match(roomSrc, /const wake = screenWake\(navigator, document\);/);
+ assert.match(roomSrc, /backdropTimer = setInterval\(paintBackdrops, BACKDROP_EVERY\);\n\s*wake\.hold\(\);/);
+ assert.equal(roomSrc.match(/clearInterval\(backdropTimer\);\n\s*wake\.release\(\);/g).length, 2);
+});
diff --git a/test/browser/synthetic-media.mjs b/test/browser/synthetic-media.mjs
new file mode 100644
index 0000000..8dc6d5a
--- /dev/null
+++ b/test/browser/synthetic-media.mjs
@@ -0,0 +1,67 @@
+// syntheticMedia is an init script, used only when CALLS_SYNTHETIC_MEDIA
+// is set: for a machine where Chromium's fake devices cannot be used.
+// Chromium asks macOS for camera and microphone access even for them, on
+// behalf of whatever launched it, and when that process has never been
+// granted it (TCC "not determined") nobody can answer and getUserMedia
+// never settles — every scenario then stops at "Connecting to the call."
+// (2026-10-05, from an agent's shell). This draws the camera and the
+// screen on canvases instead (a head and shoulders on a gradient, moving;
+// a 16:10 document) and makes the microphone a quiet tone. Scenarios that
+// assert on Chromium's own fake device names do not hold under it: it is
+// a stand-in, said so in the run, never the gate's normal way.
+export const syntheticMedia = () => {
+ const md = navigator.mediaDevices;
+ if (!md) return;
+ const drawn = (w, h, draw) => {
+ const c = document.createElement('canvas');
+ c.width = w;
+ c.height = h;
+ const g = c.getContext('2d');
+ let t = 0;
+ draw(g, w, h, 0);
+ setInterval(() => draw(g, w, h, ++t), 50);
+ return c.captureStream(20).getVideoTracks()[0];
+ };
+ const camera = () => drawn(640, 360, (g, w, h, t) => {
+ const sky = g.createLinearGradient(0, 0, w, h);
+ sky.addColorStop(0, '#6d8fb3');
+ sky.addColorStop(1, '#2f3d2a');
+ g.fillStyle = sky;
+ g.fillRect(0, 0, w, h);
+ g.fillStyle = '#c99a78';
+ g.beginPath(); g.ellipse(w / 2, h * 0.36, w * 0.09, h * 0.2, 0, 0, Math.PI * 2); g.fill();
+ g.fillStyle = '#7a3b2e';
+ g.beginPath(); g.ellipse(w / 2, h * 1.02, w * 0.24, h * 0.42, 0, 0, Math.PI * 2); g.fill();
+ g.fillStyle = '#ffffff';
+ g.fillRect((t * 7) % w, h - 8, 40, 4);
+ });
+ const screen = () => drawn(1280, 800, (g, w, h, t) => {
+ g.fillStyle = '#f4f1ea';
+ g.fillRect(0, 0, w, h);
+ g.fillStyle = '#1f6f8b';
+ g.fillRect(0, 0, w, 48);
+ g.fillStyle = '#c0392b';
+ g.fillRect(w - 160, h - 120, 120, 80);
+ g.fillStyle = '#333';
+ for (let y = 90; y < h - 40; y += 28) g.fillRect(60, y, w - 120 - ((y * 37) % 300), 10);
+ g.fillStyle = '#f39c12';
+ g.fillRect((t * 9) % w, 56, 30, 30);
+ });
+ const tone = () => {
+ const ac = new AudioContext();
+ const o = ac.createOscillator();
+ const gain = ac.createGain();
+ gain.gain.value = 0.02;
+ const out = ac.createMediaStreamDestination();
+ o.connect(gain).connect(out);
+ o.start();
+ return out.stream.getAudioTracks()[0];
+ };
+ md.getUserMedia = async (c = {}) => {
+ const tracks = [];
+ if (c.audio) tracks.push(tone());
+ if (c.video) tracks.push(camera());
+ return new MediaStream(tracks);
+ };
+ md.getDisplayMedia = async () => new MediaStream([screen()]);
+};