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.
37 files changed,
+1896
−101
Makefile+1 −0callscore/transport.go+11 −3internal/calls/static/calls.css+63 −15internal/calls/static/room.js+89 −0internal/callstest/browser_test.go+1 −0native/README.md+10 −3native/apple/Sources/CallsInterop/Interop.swift+22 −0native/apple/Sources/CallsInterop/StageSnapshots.swift+66 −0native/apple/Sources/CallsKit/CallFixtures.swift+48 −0native/apple/Sources/CallsKit/CallWake.swift+55 −0native/apple/Sources/CallsKit/CallsAppearance.swift+3 −0native/apple/Sources/CallsKit/CallsModel.swift+15 −1native/apple/Sources/CallsKit/MacFullWindowCall.swift+14 −10native/apple/Sources/CallsKit/MacVideoTile.swift+11 −9native/apple/Sources/CallsKit/MiniWindow.swift+141 −16native/apple/Sources/CallsKit/MiniWindowProbe.swift+196 −0native/apple/Sources/CallsKit/MiniWindowRules.swift+40 −0native/apple/Sources/CallsKit/MutedBadge.swift+62 −15native/apple/Sources/CallsKit/ScreenBackdrop.swift+239 −0native/apple/Sources/CallsKit/SpeakerView.swift+14 −0native/apple/Sources/CallsMac/CallsMac.swift+7 −0native/apple/Sources/CallsMac/Resources/MacIcon.pngbinarynative/apple/Tests/CallsKitTests/CallWakeTests.swift+77 −0native/apple/Tests/CallsKitTests/MiniWindowGestureTests.swift+63 −0native/apple/Tests/CallsKitTests/TileLayoutTests.swift+155 −0native/ios/Sources/CallsPhone.swift+48 −11native/ios/Sources/PhoneCallFixture.swift+6 −1native/ios/Sources/PhoneFullScreenCall.swift+9 −7native/ios/project.yml+1 −1native/scripts/bundle-mac.py+31 −3native/scripts/generate-mac-icon.swift+9 −3test/browser/call.mjs+164 −3test/browser/native-motion.mjs+2 −0test/browser/native-screen.mjs+2 −0test/browser/native.mjs+2 −0test/browser/room-wake.test.mjs+152 −0test/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()]); |
| +}; |