oficina / meet Public

Calls: the screen stays awake through a call (Mac and web)

A call is often watched rather than typed into, and a display going to
sleep under it reads as the call having ended.

Mac: CallWake holds a ProcessInfo activity (.idleDisplaySleepDisabled,
.userInitiated, "In a Calls call") from joining until leaving or the
call ending (CallsModel.joining, and the room's phase "ended"). It does
not stop a person locking the Mac; quitting and crashing end it with the
process. CallWakeTests through a seam (begun once, ended once, rejoin,
the model) and the real activity.

Web: screenWake holds the Screen Wake Lock from the start of the call,
asks again when the tab is visible again (the browser lets it go while
hidden), releases it on leaving and on a call that ends, and says
nothing where it is missing or refused. room-wake.test.mjs lifts it from
room.js with a fake lock and tab; in make browser.

iPhone unchanged: it already sets isIdleTimerDisabled.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev dabcd607ab5020ac8a3264a51ac58cd9b3a5f6dc parent 8c0c6a5
6 files changed, +347 −1
  • Makefile +1 −0
  • internal/calls/static/room.js +47 −0
  • native/apple/Sources/CallsKit/CallWake.swift +55 −0
  • native/apple/Sources/CallsKit/CallsModel.swift +15 −1
  • native/apple/Tests/CallsKitTests/CallWakeTests.swift +77 −0
  • test/browser/room-wake.test.mjs +152 −0
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/internal/calls/static/room.js b/internal/calls/static/room.js
index 4d85cb9..4fd5382 100644
--- a/internal/calls/static/room.js
+++ b/internal/calls/static/room.js
@@ -5786,6 +5786,50 @@
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
@@ -6541,6 +6585,7 @@
clearTimeout(fullTimer);
clearInterval(statsTimer);
clearInterval(backdropTimer);
+ wake.release();
clearInterval(reconnectWatchTimer);
clearInterval(levelTimer);
stopIceTimers();
@@ -7249,6 +7294,7 @@
clearTimeout(fullTimer);
clearInterval(statsTimer);
clearInterval(backdropTimer);
+ wake.release();
clearInterval(reconnectWatchTimer);
clearInterval(levelTimer);
stopIceTimers();
@@ -8093,6 +8139,7 @@
// 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/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/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/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/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);
+});