oficina / meet Public

Merge branch 'feat/native-screen-share' into integrate/native-2026-09-23

Shows a shared screen in the native apps, shares one from the Mac through
ScreenCaptureKit, and from the iPhone through the CallsBroadcast extension
and an App Group socket.

Conflicts resolved:
- Package.swift: CallsBroadcastKit joins the LiveKit branch's targets;
  CallsKit depends on CallsBridge, CallsBroadcastKit, RNNoise, DeepFilter
  (Mac only) and LiveKitWebRTC; the tests keep the Fixtures resource.
- Makefile: native-browser keeps the fresh CallsInterop build and the
  DeepFilter model path, with screen share's 5m timeout; native-pair
  also runs CallsInterop, so it gets the same build and model path.
- Interop.swift: snapshot carries both remoteMuted and screenCaptions/
  sharingScreen.
- MacVideoTile / PhoneVideoTile: both muted and screen properties; the
  accessibility label keeps the muted wording, the value keeps "Waiting
  for the screen".
- MacCallView / CallsPhone: the stage's camera case and the Mac's
  thumbnail strip beside a shared screen pass muted:/cameraOff:; screen
  tiles do not.
- test/browser/native.mjs: the mute round trip, then the screen
  scenarios.
- native/ios/README.md: sharing is no longer a follow-up, nor are stored
  credentials.

Screen share's new files imported WebRTC; they now import LiveKitWebRTC
(BroadcastScreenCapture, MacScreenCapture, BroadcastTests).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev bdb2f7a2e9f8fed8f09a3574fd71ed3d794e1ace parent 244e363parent e1ff45e
27 files changed, +2595 −61
  • Makefile +10 −2
  • internal/callstest/native_browser_test.go +216 −1
  • native/DESIGN.md +2 −2
  • native/README.md +49 −2
  • native/apple/Package.swift +7 −2
  • native/apple/Sources/CallsBroadcastKit/BroadcastInbox.swift +215 −0
  • native/apple/Sources/CallsBroadcastKit/BroadcastSender.swift +170 −0
  • native/apple/Sources/CallsBroadcastKit/BroadcastWire.swift +193 −0
  • native/apple/Sources/CallsInterop/Interop.swift +30 −1
  • native/apple/Sources/CallsInterop/SyntheticVideo.swift +60 −0
  • native/apple/Sources/CallsKit/BroadcastScreenCapture.swift +70 −0
  • native/apple/Sources/CallsKit/Call.swift +104 −1
  • native/apple/Sources/CallsKit/Capture.swift +14 −0
  • native/apple/Sources/CallsKit/MacCallView.swift +84 −16
  • native/apple/Sources/CallsKit/MacScreenCapture.swift +231 −0
  • native/apple/Sources/CallsKit/MacVideoTile.swift +28 −9
  • native/apple/Sources/CallsKit/Media.swift +254 −6
  • native/apple/Sources/CallsKit/ScreenShare.swift +120 −0
  • native/apple/Sources/CallsKit/Wire.swift +2 −0
  • native/apple/Tests/CallsKitTests/BroadcastTests.swift +183 −0
  • native/apple/Tests/CallsKitTests/ScreenShareTests.swift +139 −0
  • native/ios/Broadcast/SampleHandler.swift +61 −0
  • native/ios/README.md +59 −5
  • native/ios/Sources/CallsPhone.swift +73 −14
  • native/ios/Sources/PhoneScreenShare.swift +88 −0
  • native/ios/project.yml +39 −0
  • test/browser/native.mjs +94 −0
diff --git a/Makefile b/Makefile
index 7a770bd..14922c6 100644
--- a/Makefile
+++ b/Makefile
@@ -157,7 +157,7 @@ browser:
go test -tags browser -count=1 -timeout 20m -run TestCallInARealBrowser -v ./internal/callstest/
# Native builds isolate gomobile's cgo bridge from the cgo-free server.
-.PHONY: native-mac native-test native-browser
+.PHONY: native-mac native-test native-browser native-pair
native-mac:
./native/scripts/build-mac.sh
@@ -170,7 +170,15 @@ native-browser: native-mac
# The gate runs CallsInterop, which native-mac does not build; without
# this it runs whatever binary a previous build left behind.
swift build --package-path native/apple --product CallsInterop
- CALLS_DEEPFILTER_MODEL=$(CURDIR)/native/build/DeepFilterNet3_onnx.tar.gz CGO_ENABLED=0 go test -tags browser -count=1 -timeout 3m -run 'TestNative(MacCallsBrowser|MacChat|BrowserAuthentication)' -v ./internal/callstest/
+ CALLS_DEEPFILTER_MODEL=$(CURDIR)/native/build/DeepFilterNet3_onnx.tar.gz CGO_ENABLED=0 go test -tags browser -count=1 -timeout 5m -run 'TestNative(MacCallsBrowser|MacChat|BrowserAuthentication)' -v ./internal/callstest/
+
+# Two apps in one call, sharing screens to each other. Separate from
+# native-browser because two unsigned apps on one Mac do not reliably
+# connect, which looks like the macOS firewall: see TestNativeMacScreenShare.
+# It runs CallsInterop too, so it builds it for the same reason.
+native-pair: native-mac
+ swift build --package-path native/apple --product CallsInterop
+ CALLS_DEEPFILTER_MODEL=$(CURDIR)/native/build/DeepFilterNet3_onnx.tar.gz CGO_ENABLED=0 go test -tags browser -count=1 -timeout 3m -run 'TestNativeMacScreenShare' -v ./internal/callstest/
.PHONY: iphone iphone-device iphone-browser
iphone:
diff --git a/internal/callstest/native_browser_test.go b/internal/callstest/native_browser_test.go
index 51d90e9..53da5c7 100644
--- a/internal/callstest/native_browser_test.go
+++ b/internal/callstest/native_browser_test.go
@@ -6,10 +6,12 @@ import (
"bufio"
"context"
"encoding/json"
+ "io"
"net/http"
"os"
"os/exec"
"path/filepath"
+ "sync"
"testing"
"time"
)
@@ -69,7 +71,7 @@ func nativeMacCallsBrowser(t *testing.T, guest, chat bool) {
if _, err := os.Stat(binary); err != nil {
t.Fatal("build native interop executable with make native-mac first")
}
- ctx, cancel := context.WithTimeout(t.Context(), 100*time.Second)
+ ctx, cancel := context.WithTimeout(t.Context(), 160*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "node", filepath.Join(root, "test/browser/native.mjs"), rig.origin, room, binary, mode)
cookies, _ := json.Marshal([]map[string]string{{"name": rig.cookieName, "value": rig.cookieB}})
@@ -95,3 +97,216 @@ func nativeMacCallsBrowser(t *testing.T, guest, chat bool) {
t.Fatal(err)
}
}
+
+// TestNativeMacScreenShare is §5.4 between two apps, with no browser in
+// the call: each side is the shipping Go bridge and WebRTC build driving a
+// synthetic camera and a synthetic screen. The browser gate above holds
+// the app to the browser's rules in both directions; this holds two apps
+// to each other, which is the pairing nothing else would catch drifting —
+// and it shares from both sides at once, which is the negotiation
+// collision the browser's mutualshare scenario was written for.
+//
+// It is `make native-pair`, not part of `make native-browser`, because
+// two apps on one Mac do not reliably connect at all, screens or no
+// screens — measured with main's interop binary as well as this branch's,
+// before anything was shared. About two runs in five fail with signalling
+// complete and correctly ordered, every candidate added on both sides, and
+// no connectivity check ever answered. What the evidence points at is the
+// Mac rather than the code: the macOS application firewall was on, it
+// admits signed software automatically, and the interop binary is
+// unsigned, so inbound UDP reaches it only as a reply to its own outbound
+// on the same address pair — which two such binaries achieve only when
+// their first checks happen to cross in the right order. Chromium is
+// signed, so the browser gates never meet it, and a Developer ID Calls.app
+// would not either. Not proved: allowing the binary through the firewall
+// needs an administrator, and was not done.
+func TestNativeMacScreenShare(t *testing.T) {
+ rig := newBrowserRig(t)
+ room, _ := rig.createMeeting(t)
+ root, err := filepath.Abs("../..")
+ if err != nil {
+ t.Fatal(err)
+ }
+ binary := filepath.Join(root, "native/apple/.build/debug/CallsInterop")
+ if _, err := os.Stat(binary); err != nil {
+ t.Fatal("build native interop executable with make native-mac first")
+ }
+ ctx, cancel := context.WithTimeout(t.Context(), 140*time.Second)
+ defer cancel()
+ // The person behind cookie A is paul@example.com, and the hub names
+ // a person by the local part of their address.
+ const paulsScreen = "paul\u2019s screen"
+
+ // The first app shares before the second has joined, so the `share`
+ // message goes to nobody and is never replayed: the second app has
+ // only the m-line to go on, and has to caption the screen anyway.
+ a := startNative(ctx, t, binary, rig, room, rig.cookieA, "a")
+ defer a.leave(t)
+ waitNative(t, "the first app is in the meeting", func() bool { return a.sample().Phase == "admitted" }, a)
+ a.command(t, "screen-on")
+ waitNative(t, "the first app is sharing to an empty room", func() bool {
+ s := a.sample()
+ return s.SharingScreen != nil && *s.SharingScreen
+ }, a)
+ b := startNative(ctx, t, binary, rig, room, rig.cookieB, "b")
+ defer b.leave(t)
+ waitNative(t, "both apps decode each other's camera", func() bool {
+ return a.stat("framesDecoded") > 15 && b.stat("framesDecoded") > 15
+ }, a, b)
+ waitNative(t, "the second app captions the first app's screen", func() bool {
+ c := b.sample().ScreenCaptions
+ return len(c) == 1 && c[0] == paulsScreen && b.stat("remoteScreens") == 1
+ }, a, b)
+ base, camera := b.stat("screenFramesDecoded"), b.stat("framesDecoded")
+ waitNative(t, "the second app decodes the screen and the camera beside it", func() bool {
+ return b.stat("screenFramesDecoded") > base+15 && b.stat("framesDecoded") > camera+15
+ }, a, b)
+ if b.stat("remoteTracks") != 1 {
+ t.Fatalf("the screen was taken for a second camera: %+v", b.sample())
+ }
+ a.command(t, "screen-off")
+ waitNative(t, "the screen goes when the first app stops", func() bool {
+ return len(b.sample().ScreenCaptions) == 0 && b.stat("remoteScreens") == 0
+ }, a, b)
+ camera = b.stat("framesDecoded")
+ waitNative(t, "the camera survives the screen stopping", func() bool {
+ return b.stat("framesDecoded") > camera+15
+ }, a, b)
+ t.Log("PASS app to app: a screen shared before the other app joined is captioned, decoded and stopped by the button")
+
+ // The other way, ended by the capture itself.
+ b.command(t, "screen-on")
+ waitNative(t, "the first app shows the second app's screen", func() bool {
+ return len(a.sample().ScreenCaptions) == 1 && a.stat("remoteScreens") == 1
+ }, a, b)
+ base = a.stat("screenFramesDecoded")
+ waitNative(t, "the first app decodes it", func() bool { return a.stat("screenFramesDecoded") > base+15 }, a, b)
+ b.command(t, "screen-end")
+ waitNative(t, "a capture ending on its own ends the share", func() bool {
+ s := b.sample()
+ return s.SharingScreen != nil && !*s.SharingScreen && len(a.sample().ScreenCaptions) == 0 && a.stat("remoteScreens") == 0
+ }, a, b)
+ t.Log("PASS app to app the other way, ended by the capture")
+
+ // Both at once: two transceivers added in the same moment, and a
+ // collision for perfect negotiation to settle without either screen
+ // landing on the m-line the other side built for something else.
+ a.command(t, "screen-on")
+ b.command(t, "screen-on")
+ waitNative(t, "each app shows the other's screen", func() bool {
+ return a.stat("remoteScreens") == 1 && b.stat("remoteScreens") == 1
+ }, a, b)
+ baseA, baseB := a.stat("screenFramesDecoded"), b.stat("screenFramesDecoded")
+ camA, camB := a.stat("framesDecoded"), b.stat("framesDecoded")
+ waitNative(t, "both screens and both cameras decode through the collision", func() bool {
+ return a.stat("screenFramesDecoded") > baseA+15 && b.stat("screenFramesDecoded") > baseB+15 &&
+ a.stat("framesDecoded") > camA+15 && b.stat("framesDecoded") > camB+15
+ }, a, b)
+ if a.stat("remoteTracks") != 1 || b.stat("remoteTracks") != 1 {
+ t.Fatalf("a screen was taken for a camera: a=%+v b=%+v", a.sample(), b.sample())
+ }
+ a.command(t, "screen-off")
+ b.command(t, "screen-off")
+ waitNative(t, "both screens go", func() bool {
+ return a.stat("remoteScreens") == 0 && b.stat("remoteScreens") == 0 &&
+ len(a.sample().ScreenCaptions) == 0 && len(b.sample().ScreenCaptions) == 0
+ }, a, b)
+ t.Log("PASS app to app, both sharing at once")
+}
+
+type nativeSample struct {
+ Phase string `json:"phase"`
+ Peer string `json:"peer"`
+ Stats map[string]uint64 `json:"stats"`
+ ScreenCaptions []string `json:"screenCaptions"`
+ SharingScreen *bool `json:"sharingScreen"`
+}
+
+// nativeApp is one CallsInterop process and the last observation it
+// printed. Only numeric samples are kept; nothing else it prints is read.
+type nativeApp struct {
+ label string
+ cmd *exec.Cmd
+ stdin io.WriteCloser
+ mu sync.Mutex
+ last nativeSample
+ done chan struct{}
+}
+
+func startNative(ctx context.Context, t *testing.T, binary string, rig *browserRig, room, cookie, label string) *nativeApp {
+ t.Helper()
+ cookies, _ := json.Marshal([]map[string]string{{"name": rig.cookieName, "value": cookie}})
+ input, _ := json.Marshal(map[string]string{"origin": rig.origin, "room": room, "cookies": string(cookies)})
+ cmd := exec.CommandContext(ctx, binary)
+ stdin, err := cmd.StdinPipe()
+ if err != nil {
+ t.Fatal(err)
+ }
+ out, err := cmd.StdoutPipe()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := cmd.Start(); err != nil {
+ t.Fatal(err)
+ }
+ app := &nativeApp{label: label, cmd: cmd, stdin: stdin, done: make(chan struct{})}
+ go func() {
+ defer close(app.done)
+ scanner := bufio.NewScanner(out)
+ for scanner.Scan() {
+ var s nativeSample
+ if json.Unmarshal(scanner.Bytes(), &s) != nil || s.Stats == nil {
+ continue
+ }
+ app.mu.Lock()
+ app.last = s
+ app.mu.Unlock()
+ }
+ }()
+ if _, err := io.WriteString(stdin, string(input)+"\n"); err != nil {
+ t.Fatal(err)
+ }
+ return app
+}
+
+func (n *nativeApp) sample() nativeSample {
+ n.mu.Lock()
+ defer n.mu.Unlock()
+ return n.last
+}
+
+func (n *nativeApp) stat(name string) uint64 { return n.sample().Stats[name] }
+
+func (n *nativeApp) command(t *testing.T, line string) {
+ t.Helper()
+ if _, err := io.WriteString(n.stdin, line+"\n"); err != nil {
+ t.Fatalf("%s: %s: %v", n.label, line, err)
+ }
+}
+
+func (n *nativeApp) leave(t *testing.T) {
+ _, _ = io.WriteString(n.stdin, "leave\n")
+ select {
+ case <-n.done:
+ case <-time.After(5 * time.Second):
+ _ = n.cmd.Process.Kill()
+ }
+ _ = n.cmd.Wait()
+}
+
+// waitNative polls until ok holds, and on failure says what each app
+// last reported, which is the only account of the failure there is.
+func waitNative(t *testing.T, label string, ok func() bool, apps ...*nativeApp) {
+ t.Helper()
+ until := time.Now().Add(35 * time.Second)
+ for time.Now().Before(until) {
+ if ok() {
+ return
+ }
+ time.Sleep(250 * time.Millisecond)
+ }
+ for _, app := range apps {
+ t.Logf("%s: %+v", app.label, app.sample())
+ }
+ t.Fatal(label)
+}
diff --git a/native/DESIGN.md b/native/DESIGN.md
index 55ffc98..4b3ea5d 100644
--- a/native/DESIGN.md
+++ b/native/DESIGN.md
@@ -61,8 +61,8 @@ retention checks. Window closure never invokes the meeting leave action.
The iOS call stage now defaults to remote focus with a local thumbnail, offers
Focus/Grid and participant selection, and uses the same compact colored icon
control vocabulary. Chat is a native sheet with unread count and a retained
-draft. iOS continues to end calls when backgrounded; native screen sharing and
-background-call continuity remain follow-up media work.
+draft. iOS continues to end calls when backgrounded, except while sharing the screen;
+general background-call continuity remains follow-up media work.
Safari member/guest sign-in and the real iPhone simulator native-media tests
passed after this change. The signed development app was built and installed on
diff --git a/native/README.md b/native/README.md
index a1c83e5..12e44df 100644
--- a/native/README.md
+++ b/native/README.md
@@ -180,6 +180,39 @@ sender's clock — and a stretched voice is what a growl is. On macOS it is
the only instrument there is, since a bad device cannot be ruled out by
switching away from it.
+**Shared screens** from anyone in the call — a browser or the other app —
+take the stage as a tile of their own, captioned "Name’s screen" exactly as
+the browser captions it, while the sharer's camera moves to a thumbnail
+beside yours. Nothing on the wire says which m-line is a screen; the app
+works it out with the browser's own rules (spec §5.4, `classifyTrack` in
+`room.js`, ported in `ScreenShare.swift` with its tests), so the two never
+disagree about whether they are looking at a face or a document. The tile
+goes on whichever of §5.4's two endings arrives first: the `share {on:false}`
+message, or the stopped transceiver taking its receiver away. Each was
+checked alone against a browser when this was written (the other one
+disabled); the native gate checks them together.
+
+**Share your screen** on the Mac opens the system's own picker
+(`SCContentSharingPicker`, macOS 14) for a display or a window. What a person
+picks there is what they have agreed to share, so Calls asks for no Screen
+Recording permission, sends nobody to System Settings, and needs no Info.plist
+key — macOS has no usage string for screen capture — and the same path works in
+the App Sandbox. Calls' own windows are left out of the picker, and from a shared
+display too on macOS 14.4 and later, where the display is rebuilt excluding the
+app so that windows opening later (the floating mini window among them) stay
+out as well. The screen goes to every connection on its own `sendonly`
+transceiver in its own stream, exactly as the browser sends one, announced with
+`share {on:true}`; stopping stops the transceiver and renegotiates, so the far
+side's track ends rather than freezing, and says `share {on:false}`. The menu
+bar's stop control and a shared window closing end it the same way as the
+button. The source is a screencast, so WebRTC gives up frame rate before
+resolution, and frames are capped at 1920 on the long side and 30 a second.
+
+On **iPhone** the whole screen is shared through a ReplayKit broadcast
+extension, which hands its frames to the app over a socket in an App Group
+container. See [ios/README.md](ios/README.md#sharing-the-screen) for how that
+works and what signing it needs.
+
**Knocks** are announced when someone asks to be let in while the app is
not frontmost. The admission queue is already on screen for a member who is
looking at the call; this is for the member who is in another Space,
@@ -261,6 +294,7 @@ make race
CGO_ENABLED=0 make browser
make native-test
make native-browser
+make native-pair
CGO_ENABLED=0 go test -tags browser -run TestNativeBrowserAuthentication ./internal/callstest
```
@@ -289,6 +323,17 @@ it used to run whichever binary an earlier build had left. It never needs produc
camera access. The Go Home test separately exercises a session minted through
Home assertions and explicit identity linking, including resume and meeting end.
+The native/browser gate also shares screens both ways: the browser shares (a
+fake monitor, `--auto-accept-this-tab-capture`) and the app must caption and
+decode it beside the camera, then drop it when it stops; the app shares a
+synthetic screen and the browser must show it under the app's name, then see its
+received track *end* — once by the button and once by the capture ending on its
+own. `make native-pair` puts two apps in one call with no browser: a screen
+shared before the other app joined, both endings, and both apps sharing at once.
+It is separate because two unsigned apps on one Mac do not reliably connect at
+all (see `TestNativeMacScreenShare`), which looks like the machine rather than
+the feature.
+
The native chat gate sends a sequence of browser messages and renders the shared
native chat view to `native/build/chat-incoming.png` for visual inspection of the
latest message. The window stays off-screen. Native chat follows new messages
@@ -296,8 +341,10 @@ and keeps its history visible during signaling reconnects.
There is no claim that a simulator or synthetic test proves physical camera,
microphone permissions, audio-device switching, sleep/wake or Home passkeys.
-Those need a real-device acceptance pass. Native screen sharing, background
-calling and long-call TURN renewal parity are follow-up work.
+Those need a real-device acceptance pass. So does screen sharing: the Mac's
+system picker and the iPhone's broadcast extension cannot be driven by any gate
+here. Background calling (other than while sharing the iPhone's screen) and
+long-call TURN renewal parity are follow-up work.
See [VERIFICATION.md](VERIFICATION.md) for the completed gate record and acceptance limits.
diff --git a/native/apple/Package.swift b/native/apple/Package.swift
index 7f4f9ec..53489c4 100644
--- a/native/apple/Package.swift
+++ b/native/apple/Package.swift
@@ -10,6 +10,9 @@ let package = Package(
platforms: [.macOS(.v14), .iOS(.v17)],
products: [
.library(name: "CallsKit", targets: ["CallsKit"]),
+ // What the iPhone's broadcast extension links, and all it links: no
+ // WebRTC, so the extension stays far inside its memory ceiling.
+ .library(name: "CallsBroadcastKit", targets: ["CallsBroadcastKit"]),
.executable(name: "CallsMac", targets: ["CallsMac"]),
.executable(name: "CallsInterop", targets: ["CallsInterop"]),
],
@@ -24,14 +27,16 @@ let package = Package(
// Built by scripts/build-deepfilter.sh. Linked on Mac only: the iPhone
// runs RNNoise, and should not carry a model runtime it never uses.
.binaryTarget(name: "DeepFilter", path: "../build/DeepFilter.xcframework"),
+ .target(name: "CallsBroadcastKit"),
.target(
name: "CallsKit",
dependencies: [
- "CallsBridge", "RNNoise", .target(name: "DeepFilter", condition: .when(platforms: [.macOS])),
+ "CallsBridge", "CallsBroadcastKit", "RNNoise",
+ .target(name: "DeepFilter", condition: .when(platforms: [.macOS])),
.product(name: "LiveKitWebRTC", package: "webrtc-xcframework"),
]),
.executableTarget(name: "CallsMac", dependencies: [.product(name: "RastrilloMacUpdates", package: "native", condition: .when(platforms: [.macOS])), "CallsKit"], resources: [.copy("Resources/AppIcon.png"), .copy("Resources/MacIcon.png")]),
.executableTarget(name: "CallsInterop", dependencies: ["CallsKit"]),
- .testTarget(name: "CallsKitTests", dependencies: ["CallsKit"], resources: [.copy("Fixtures")]),
+ .testTarget(name: "CallsKitTests", dependencies: ["CallsKit", "CallsBroadcastKit"], resources: [.copy("Fixtures")]),
]
)
diff --git a/native/apple/Sources/CallsBroadcastKit/BroadcastInbox.swift b/native/apple/Sources/CallsBroadcastKit/BroadcastInbox.swift
new file mode 100644
index 0000000..76c4bc9
--- /dev/null
+++ b/native/apple/Sources/CallsBroadcastKit/BroadcastInbox.swift
@@ -0,0 +1,215 @@
+import CoreVideo
+import Foundation
+
+/// The app's half: listens for the extension for as long as a call is up,
+/// and hands each connection to the call.
+///
+/// One broadcast at a time is all iOS allows, so one connection at a time
+/// is all this takes; a second, which would be a bug, is closed at once.
+public final class BroadcastInbox: @unchecked Sendable {
+ private let path: String
+ private let queue = DispatchQueue(label: "calls.broadcast.inbox")
+ private var listener: DispatchSourceRead?
+ private var current: BroadcastConnection?
+
+ /// Called on the main queue for each extension that connects.
+ public var onConnection: ((BroadcastConnection) -> Void)?
+
+ public init(path: String) { self.path = path }
+
+ public func start() throws {
+ let fd = try BroadcastSocket.listen(path: path)
+ BroadcastSocket.nonBlocking(fd)
+ let source = DispatchSource.makeReadSource(fileDescriptor: fd, queue: queue)
+ source.setEventHandler { [weak self] in self?.accept(fd) }
+ source.setCancelHandler { Darwin.close(fd) }
+ listener = source
+ source.resume()
+ }
+
+ /// Stops listening. A connection already handed out is the call's to
+ /// close, not this.
+ public func stop() {
+ queue.sync {
+ listener?.cancel()
+ listener = nil
+ current = nil
+ }
+ unlink(path)
+ }
+
+ private func accept(_ fd: Int32) {
+ let client = Darwin.accept(fd, nil, nil)
+ guard client >= 0 else { return }
+ if let current, !current.isClosed {
+ Darwin.close(client)
+ return
+ }
+ var on: Int32 = 1
+ setsockopt(client, SOL_SOCKET, SO_NOSIGPIPE, &on, socklen_t(MemoryLayout<Int32>.size))
+ let connection = BroadcastConnection(fd: client)
+ current = connection
+ DispatchQueue.main.async { [weak self] in
+ guard let self, let onConnection = self.onConnection else {
+ connection.close()
+ return
+ }
+ onConnection(connection)
+ }
+ }
+}
+
+/// One broadcast: frames in, until either side closes.
+public final class BroadcastConnection: @unchecked Sendable {
+ private let fd: Int32
+ private let queue = DispatchQueue(label: "calls.broadcast.read", qos: .userInteractive)
+ private var source: DispatchSourceRead?
+ private let lock = NSLock()
+ private var closed = false
+ private var header = [UInt8](repeating: 0, count: BroadcastFrameHeader.size)
+ private var headerRead = 0
+ private var frame: BroadcastFrameHeader?
+ private var payload: UnsafeMutableRawPointer?
+ private var payloadCapacity = 0
+ private var payloadRead = 0
+ private var pool: CVPixelBufferPool?
+ private var poolShape: (Int, Int, Bool) = (0, 0, false)
+
+ /// Each complete frame, as an NV12 pixel buffer the receiver may keep,
+ /// with its CGImagePropertyOrientation. Called on the reading queue.
+ public var onFrame: ((CVPixelBuffer, UInt16) -> Void)?
+ /// Called once, on the reading queue, when the extension has gone — the
+ /// broadcast stopped from Control Center or the status bar, or the
+ /// extension ended. Not called for `close()`.
+ public var onClose: (() -> Void)?
+
+ init(fd: Int32) { self.fd = fd }
+
+ deinit { payload?.deallocate() }
+
+ var isClosed: Bool {
+ lock.lock()
+ defer { lock.unlock() }
+ return closed
+ }
+
+ /// Starts reading. Separate from creation so the reader is only live
+ /// once somebody is listening for its frames.
+ public func resume() {
+ BroadcastSocket.nonBlocking(fd)
+ let source = DispatchSource.makeReadSource(fileDescriptor: fd, queue: queue)
+ source.setEventHandler { [weak self] in self?.readable() }
+ source.setCancelHandler { [fd] in Darwin.close(fd) }
+ self.source = source
+ source.resume()
+ }
+
+ /// Closes from this side. The extension's next write fails, and it ends
+ /// the broadcast — which is how "stop sharing" in the app reaches the
+ /// system's recording indicator.
+ public func close() {
+ lock.lock()
+ let first = !closed
+ closed = true
+ lock.unlock()
+ guard first else { return }
+ if let source {
+ source.cancel()
+ } else {
+ Darwin.close(fd)
+ }
+ }
+
+ private func ended() {
+ lock.lock()
+ let first = !closed
+ closed = true
+ lock.unlock()
+ guard first else { return }
+ source?.cancel()
+ onClose?()
+ }
+
+ private func readable() {
+ while !isClosed {
+ if frame == nil {
+ let n = header.withUnsafeMutableBytes {
+ Darwin.read(fd, $0.baseAddress!.advanced(by: headerRead), BroadcastFrameHeader.size - headerRead)
+ }
+ guard let n = settle(n) else { return }
+ headerRead += n
+ guard headerRead == BroadcastFrameHeader.size else { continue }
+ guard let next = header.withUnsafeBytes({ BroadcastFrameHeader.read($0.baseAddress!) }) else {
+ ended()
+ return
+ }
+ frame = next
+ payloadRead = 0
+ if payloadCapacity < next.payloadLength {
+ payload?.deallocate()
+ payload = UnsafeMutableRawPointer.allocate(byteCount: next.payloadLength, alignment: 16)
+ payloadCapacity = next.payloadLength
+ }
+ }
+ guard let frame, let payload else { return }
+ let n = Darwin.read(fd, payload.advanced(by: payloadRead), frame.payloadLength - payloadRead)
+ guard let n = settle(n) else { return }
+ payloadRead += n
+ guard payloadRead == frame.payloadLength else { continue }
+ self.frame = nil
+ headerRead = 0
+ if let pixels = picture(frame, payload) { onFrame?(pixels, frame.orientation) }
+ }
+ }
+
+ /// A read's answer: bytes to count, nil to stop reading for now, and the
+ /// connection ended on end-of-file or a real error.
+ private func settle(_ n: Int) -> Int? {
+ if n > 0 { return n }
+ if n < 0 && (errno == EAGAIN || errno == EINTR) { return nil }
+ ended()
+ return nil
+ }
+
+ /// The packed frame, copied into a pixel buffer from a pool, row by row:
+ /// the pool's rows are padded and the wire's are not.
+ private func picture(_ frame: BroadcastFrameHeader, _ bytes: UnsafeMutableRawPointer)
+ -> CVPixelBuffer?
+ {
+ let shape = (frame.width, frame.height, frame.videoRange)
+ if pool == nil || poolShape != shape {
+ let format =
+ frame.videoRange
+ ? kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange
+ : kCVPixelFormatType_420YpCbCr8BiPlanarFullRange
+ let attributes: [CFString: Any] = [
+ kCVPixelBufferPixelFormatTypeKey: format,
+ kCVPixelBufferWidthKey: frame.width,
+ kCVPixelBufferHeightKey: frame.height,
+ kCVPixelBufferIOSurfacePropertiesKey: [:] as [CFString: Any],
+ ]
+ pool = nil
+ CVPixelBufferPoolCreate(nil, nil, attributes as CFDictionary, &pool)
+ poolShape = shape
+ }
+ guard let pool else { return nil }
+ var made: CVPixelBuffer?
+ CVPixelBufferPoolCreatePixelBuffer(nil, pool, &made)
+ guard let pixels = made else { return nil }
+ CVPixelBufferLockBaseAddress(pixels, [])
+ defer { CVPixelBufferUnlockBaseAddress(pixels, []) }
+ let planes = [
+ (bytes, frame.height),
+ (bytes.advanced(by: frame.width * frame.height), frame.height / 2),
+ ]
+ for (plane, (source, rows)) in planes.enumerated() {
+ guard let target = CVPixelBufferGetBaseAddressOfPlane(pixels, plane) else { return nil }
+ let stride = CVPixelBufferGetBytesPerRowOfPlane(pixels, plane)
+ for row in 0..<rows {
+ target.advanced(by: row * stride).copyMemory(
+ from: source.advanced(by: row * frame.width), byteCount: frame.width)
+ }
+ }
+ return pixels
+ }
+}
diff --git a/native/apple/Sources/CallsBroadcastKit/BroadcastSender.swift b/native/apple/Sources/CallsBroadcastKit/BroadcastSender.swift
new file mode 100644
index 0000000..9019005
--- /dev/null
+++ b/native/apple/Sources/CallsBroadcastKit/BroadcastSender.swift
@@ -0,0 +1,170 @@
+import Accelerate
+import CoreVideo
+import Foundation
+
+/// The extension's half: connects to the app, and sends it each frame it
+/// is given, smaller.
+///
+/// Everything here is shaped by the extension's memory ceiling. One frame
+/// buffer, allocated once and reused; the frame is scaled straight into
+/// it from ReplayKit's own pixel buffer, which is released as soon as
+/// `send` returns; and at most one frame is ever in flight. A frame that
+/// arrives while the last is still being written, or sooner than the rate
+/// allows, is dropped rather than queued — a queue is exactly how an
+/// extension runs out of memory while the app is busy.
+public final class BroadcastSender: @unchecked Sendable {
+ private let path: String
+ private let longest: CGFloat
+ private let interval: UInt64
+ private let queue = DispatchQueue(label: "calls.broadcast.send")
+ private let lock = NSLock()
+ // Guarded by lock.
+ private var fd: Int32 = -1
+ private var busy = false
+ private var last: UInt64 = 0
+ private var finished = false
+ // Only touched on queue, or before connect.
+ private var buffer: UnsafeMutableRawPointer?
+ private var capacity = 0
+
+ /// Called once, on the sending queue, when the app has gone: it closed
+ /// the socket because the person stopped sharing there, or the call
+ /// ended. Not called for `close()`.
+ public var onClosed: (@Sendable () -> Void)?
+
+ /// `longest` is the long side frames are sent at, at most; 1280 keeps a
+ /// phone's text legible on a laptop and the frame near a megabyte.
+ public init(path: String, longest: Int = 1280, framesPerSecond: Int = 15) {
+ self.path = path
+ self.longest = CGFloat(longest)
+ interval = 1_000_000_000 / UInt64(max(1, framesPerSecond))
+ }
+
+ deinit { buffer?.deallocate() }
+
+ /// Connects to the app, trying for up to `timeout`: the app may still be
+ /// settling into the call as the broadcast's countdown ends.
+ public func connect(timeout: TimeInterval) -> Bool {
+ let until = Date().addingTimeInterval(timeout)
+ repeat {
+ if let fd = try? BroadcastSocket.connect(path: path) {
+ lock.lock()
+ self.fd = fd
+ lock.unlock()
+ return true
+ }
+ Thread.sleep(forTimeInterval: 0.1)
+ } while Date() < until
+ return false
+ }
+
+ /// Offers one frame. Returns at once; the write happens on the sending
+ /// queue. `pixels` must be 4:2:0 bi-planar, which is what ReplayKit
+ /// delivers; anything else is dropped.
+ public func send(_ pixels: CVPixelBuffer, orientation: UInt16, timestampNs: UInt64) {
+ let format = CVPixelBufferGetPixelFormatType(pixels)
+ guard format == kCVPixelFormatType_420YpCbCr8BiPlanarFullRange
+ || format == kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange
+ else { return }
+ lock.lock()
+ let now = DispatchTime.now().uptimeNanoseconds
+ guard fd >= 0, !busy, !finished, now >= last + interval else {
+ lock.unlock()
+ return
+ }
+ busy = true
+ last = now
+ lock.unlock()
+ // Scaled here, on ReplayKit's thread, so the pixel buffer is not held
+ // past this call; only the copy crosses to the sending queue.
+ guard let length = pack(pixels, orientation: orientation, timestampNs: timestampNs) else {
+ lock.lock()
+ busy = false
+ lock.unlock()
+ return
+ }
+ queue.async { [self] in
+ let ok = writeAll(length)
+ lock.lock()
+ busy = false
+ let first = !ok && !finished
+ if !ok { finished = true }
+ lock.unlock()
+ if first { onClosed?() }
+ }
+ }
+
+ public func close() {
+ lock.lock()
+ finished = true
+ let fd = self.fd
+ self.fd = -1
+ lock.unlock()
+ if fd >= 0 { Darwin.close(fd) }
+ }
+
+ /// Scales `pixels` into the frame buffer behind a header, and answers the
+ /// number of bytes to write.
+ private func pack(_ pixels: CVPixelBuffer, orientation: UInt16, timestampNs: UInt64) -> Int? {
+ let size = fitted(
+ width: CGFloat(CVPixelBufferGetWidth(pixels)), height: CGFloat(CVPixelBufferGetHeight(pixels)),
+ longest: longest)
+ let header = BroadcastFrameHeader(
+ width: size.width, height: size.height, orientation: orientation, timestampNs: timestampNs,
+ videoRange: CVPixelBufferGetPixelFormatType(pixels)
+ == kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange)
+ let length = BroadcastFrameHeader.size + header.payloadLength
+ if capacity < length {
+ buffer?.deallocate()
+ buffer = UnsafeMutableRawPointer.allocate(byteCount: length, alignment: 16)
+ capacity = length
+ }
+ guard let buffer else { return nil }
+ header.write(to: buffer)
+ CVPixelBufferLockBaseAddress(pixels, .readOnly)
+ defer { CVPixelBufferUnlockBaseAddress(pixels, .readOnly) }
+ guard let luma = CVPixelBufferGetBaseAddressOfPlane(pixels, 0),
+ let chroma = CVPixelBufferGetBaseAddressOfPlane(pixels, 1)
+ else { return nil }
+ var sourceLuma = vImage_Buffer(
+ data: luma, height: vImagePixelCount(CVPixelBufferGetHeightOfPlane(pixels, 0)),
+ width: vImagePixelCount(CVPixelBufferGetWidthOfPlane(pixels, 0)),
+ rowBytes: CVPixelBufferGetBytesPerRowOfPlane(pixels, 0))
+ var sourceChroma = vImage_Buffer(
+ data: chroma, height: vImagePixelCount(CVPixelBufferGetHeightOfPlane(pixels, 1)),
+ width: vImagePixelCount(CVPixelBufferGetWidthOfPlane(pixels, 1)),
+ rowBytes: CVPixelBufferGetBytesPerRowOfPlane(pixels, 1))
+ let picture = buffer.advanced(by: BroadcastFrameHeader.size)
+ var targetLuma = vImage_Buffer(
+ data: picture, height: vImagePixelCount(size.height), width: vImagePixelCount(size.width),
+ rowBytes: size.width)
+ var targetChroma = vImage_Buffer(
+ data: picture.advanced(by: size.width * size.height),
+ height: vImagePixelCount(size.height / 2), width: vImagePixelCount(size.width / 2),
+ rowBytes: size.width)
+ let flags = vImage_Flags(kvImageNoFlags)
+ guard vImageScale_Planar8(&sourceLuma, &targetLuma, nil, flags) == kvImageNoError,
+ vImageScale_CbCr8(&sourceChroma, &targetChroma, nil, flags) == kvImageNoError
+ else { return nil }
+ return length
+ }
+
+ private func writeAll(_ length: Int) -> Bool {
+ lock.lock()
+ let fd = self.fd
+ lock.unlock()
+ guard fd >= 0, let buffer else { return false }
+ var sent = 0
+ while sent < length {
+ let n = Darwin.write(fd, buffer.advanced(by: sent), length - sent)
+ if n > 0 {
+ sent += n
+ } else if n < 0 && errno == EINTR {
+ continue
+ } else {
+ return false
+ }
+ }
+ return true
+ }
+}
diff --git a/native/apple/Sources/CallsBroadcastKit/BroadcastWire.swift b/native/apple/Sources/CallsBroadcastKit/BroadcastWire.swift
new file mode 100644
index 0000000..4a4b837
--- /dev/null
+++ b/native/apple/Sources/CallsBroadcastKit/BroadcastWire.swift
@@ -0,0 +1,193 @@
+import CoreGraphics
+import Foundation
+
+// ---------------------------------------------------------------
+// The iPhone's screen, from the broadcast extension to the call
+// ---------------------------------------------------------------
+//
+// Sharing the whole iPhone screen is a ReplayKit broadcast, and ReplayKit
+// hands the frames to an extension — a separate process, capped at about
+// fifty megabytes, that cannot hold the call's peer connections and could
+// not afford WebRTC if it did. So the extension does the least it can:
+// shrinks each frame and passes it to the app, which is already in the
+// call, over a Unix socket in the App Group container both of them can
+// reach. The app listens for as long as a call is up; the extension
+// connecting *is* the broadcast starting, and either side closing is it
+// ending.
+//
+// This module is everything the two sides share, and nothing else: no
+// WebRTC, no UIKit, no ReplayKit. The extension links this and only this.
+//
+// One frame on the wire is a 32-byte header and then the picture, NV12
+// packed tight: the luma plane, `width` bytes a row, then the interleaved
+// chroma plane at half height, also `width` bytes a row. Little-endian.
+//
+// 0 magic u32 "CSF1"
+// 4 version u16 1
+// 6 orientation u16 CGImagePropertyOrientation, as ReplayKit says it
+// 8 width u32 even, 2...4096
+// 12 height u32 even, 2...4096
+// 16 timestamp u64 nanoseconds, the extension's clock
+// 24 length u32 width * height * 3 / 2
+// 28 flags u32 bit 0: video range (16-235) rather than full
+
+public struct BroadcastFrameHeader: Equatable, Sendable {
+ public static let size = 32
+ static let magic: UInt32 = 0x3146_5343 // "CSF1" read little-endian
+ static let version: UInt16 = 1
+ static let largest = 4096
+
+ public var width: Int
+ public var height: Int
+ public var orientation: UInt16
+ public var timestampNs: UInt64
+ /// Whether the luma runs 16-235 rather than 0-255. ReplayKit has sent
+ /// both over the years, and a picture decoded with the wrong one is
+ /// washed out or crushed.
+ public var videoRange: Bool
+
+ public init(
+ width: Int, height: Int, orientation: UInt16, timestampNs: UInt64, videoRange: Bool = false
+ ) {
+ self.width = width
+ self.height = height
+ self.orientation = orientation
+ self.timestampNs = timestampNs
+ self.videoRange = videoRange
+ }
+
+ public var payloadLength: Int { width * height * 3 / 2 }
+
+ public func write(to bytes: UnsafeMutableRawPointer) {
+ bytes.storeBytes(of: Self.magic.littleEndian, toByteOffset: 0, as: UInt32.self)
+ bytes.storeBytes(of: Self.version.littleEndian, toByteOffset: 4, as: UInt16.self)
+ bytes.storeBytes(of: orientation.littleEndian, toByteOffset: 6, as: UInt16.self)
+ bytes.storeBytes(of: UInt32(width).littleEndian, toByteOffset: 8, as: UInt32.self)
+ bytes.storeBytes(of: UInt32(height).littleEndian, toByteOffset: 12, as: UInt32.self)
+ bytes.storeBytes(of: timestampNs.littleEndian, toByteOffset: 16, as: UInt64.self)
+ bytes.storeBytes(of: UInt32(payloadLength).littleEndian, toByteOffset: 24, as: UInt32.self)
+ bytes.storeBytes(of: UInt32(videoRange ? 1 : 0).littleEndian, toByteOffset: 28, as: UInt32.self)
+ }
+
+ /// Reads a header, or nil for anything that is not one this side can
+ /// use. The socket is in a container only this app's own processes can
+ /// open, so a bad header is a bug or a version skew rather than an
+ /// attack — but it is still refused rather than trusted, because the
+ /// length in it is what the reader allocates.
+ public static func read(_ bytes: UnsafeRawPointer) -> BroadcastFrameHeader? {
+ func u32(_ at: Int) -> UInt32 { UInt32(littleEndian: bytes.loadUnaligned(fromByteOffset: at, as: UInt32.self)) }
+ func u16(_ at: Int) -> UInt16 { UInt16(littleEndian: bytes.loadUnaligned(fromByteOffset: at, as: UInt16.self)) }
+ guard u32(0) == magic, u16(4) == version else { return nil }
+ let width = Int(u32(8))
+ let height = Int(u32(12))
+ guard (2...largest).contains(width), (2...largest).contains(height),
+ width % 2 == 0, height % 2 == 0
+ else { return nil }
+ let header = BroadcastFrameHeader(
+ width: width, height: height, orientation: u16(6),
+ timestampNs: UInt64(littleEndian: bytes.loadUnaligned(fromByteOffset: 16, as: UInt64.self)),
+ videoRange: u32(28) & 1 == 1)
+ guard Int(u32(24)) == header.payloadLength else { return nil }
+ return header
+ }
+}
+
+/// Where the socket lives: one well-known name in the App Group container,
+/// short because a Unix socket's path has to fit in 104 bytes, and the
+/// container's own path on a device already takes about ninety.
+public enum BroadcastPath {
+ public static let name = "calls.sock"
+
+ /// The App Group this build shares with its extension, from the
+ /// `CallsAppGroup` key both Info.plists carry. One build setting feeds
+ /// the key and the entitlement, so a build with a different bundle
+ /// identifier changes the group in one place.
+ public static var appGroup: String? {
+ Bundle.main.object(forInfoDictionaryKey: "CallsAppGroup") as? String
+ }
+
+ public static func socket(appGroup: String) -> String? {
+ FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup)?
+ .appendingPathComponent(name).path
+ }
+}
+
+enum BroadcastSocket {
+ struct Failure: Error, CustomStringConvertible {
+ let description: String
+ }
+
+ /// A Unix-socket address for `path`, or nil if the path does not fit.
+ static func address(_ path: String) -> sockaddr_un? {
+ var address = sockaddr_un()
+ address.sun_family = sa_family_t(AF_UNIX)
+ let bytes = Array(path.utf8)
+ let capacity = MemoryLayout.size(ofValue: address.sun_path)
+ guard bytes.count < capacity else { return nil }
+ withUnsafeMutableBytes(of: &address.sun_path) { raw in
+ raw.copyBytes(from: bytes)
+ raw[bytes.count] = 0
+ }
+ address.sun_len = UInt8(MemoryLayout<sockaddr_un>.size)
+ return address
+ }
+
+ /// A socket that will not raise SIGPIPE when the far side has gone: a
+ /// write to a closed peer answers EPIPE instead, which is the ending
+ /// both sides are listening for, rather than killing the process.
+ static func make() throws -> Int32 {
+ let fd = Darwin.socket(AF_UNIX, SOCK_STREAM, 0)
+ guard fd >= 0 else { throw Failure(description: "socket: \(errno)") }
+ var on: Int32 = 1
+ setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &on, socklen_t(MemoryLayout<Int32>.size))
+ return fd
+ }
+
+ static func listen(path: String) throws -> Int32 {
+ guard var address = address(path) else { throw Failure(description: "socket path too long") }
+ unlink(path)
+ let fd = try make()
+ let bound = withUnsafePointer(to: &address) {
+ $0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
+ bind(fd, $0, socklen_t(MemoryLayout<sockaddr_un>.size))
+ }
+ }
+ guard bound == 0, Darwin.listen(fd, 1) == 0 else {
+ let code = errno
+ Darwin.close(fd)
+ throw Failure(description: "listen: \(code)")
+ }
+ return fd
+ }
+
+ static func connect(path: String) throws -> Int32 {
+ guard var address = address(path) else { throw Failure(description: "socket path too long") }
+ let fd = try make()
+ let connected = withUnsafePointer(to: &address) {
+ $0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
+ Darwin.connect(fd, $0, socklen_t(MemoryLayout<sockaddr_un>.size))
+ }
+ }
+ guard connected == 0 else {
+ let code = errno
+ Darwin.close(fd)
+ throw Failure(description: "connect: \(code)")
+ }
+ return fd
+ }
+
+ static func nonBlocking(_ fd: Int32) {
+ _ = fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK)
+ }
+}
+
+/// A frame size that fits within `longest` on its long side, keeps its
+/// shape, and is even in both directions, which is what 4:2:0 chroma and
+/// the encoders need. The Mac's screen capture and the iPhone's broadcast
+/// both size their frames with it.
+public func fitted(width: CGFloat, height: CGFloat, longest: CGFloat) -> (width: Int, height: Int) {
+ guard width > 0, height > 0 else { return (2, 2) }
+ let scale = min(1, longest / max(width, height))
+ func even(_ value: CGFloat) -> Int { max(2, Int((value * scale).rounded(.down)) & ~1) }
+ return (even(width), even(height))
+}
diff --git a/native/apple/Sources/CallsInterop/Interop.swift b/native/apple/Sources/CallsInterop/Interop.swift
index 8d3cfc0..6e6c702 100644
--- a/native/apple/Sources/CallsInterop/Interop.swift
+++ b/native/apple/Sources/CallsInterop/Interop.swift
@@ -13,6 +13,7 @@ struct Input: Decodable {
let invitation: String?
}
@main struct Interop {
+ @MainActor static var screen: SyntheticScreen?
@MainActor static func main() async {
if CommandLine.arguments.count == 3, CommandLine.arguments[1] == "--lobby-snapshots" {
do { try await LobbySnapshots.save(to: CommandLine.arguments[2]) } catch { exit(1) }
@@ -69,6 +70,27 @@ struct Input: Decodable {
print("{\"camera\":\"\(now)\",\"cameraRefused\":\(note.isEmpty ? "false" : "true")}")
fflush(stdout)
}
+ // §5.4, sending. `screen-end` is the capture ending on its own,
+ // which is the path the system's stop control takes.
+ if command == "screen-on" {
+ let screen = await SyntheticScreen()
+ await MainActor.run { Interop.screen = screen }
+ await call.shareScreen(screen)
+ let on = await call.sharingScreen
+ print("{\"screenSharing\":\(on)}")
+ fflush(stdout)
+ }
+ if command == "screen-off" {
+ await call.stopSharingScreen()
+ print("{\"screenSharing\":false}")
+ fflush(stdout)
+ }
+ if command == "screen-end" {
+ await Interop.screen?.end()
+ let on = await call.sharingScreen
+ print("{\"screenSharing\":\(on)}")
+ fflush(stdout)
+ }
if command == "share-snapshot" {
let saved = await preview?.saveInvitation(call: call) ?? false
print("{\"invitationSnapshotSaved\":\(saved)}")
@@ -76,7 +98,9 @@ struct Input: Decodable {
}
}
}
- for _ in 0..<90 {
+ // Long enough for the member scenario, screens and all, with room to
+ // spare: the gate always says `leave` long before this runs out.
+ for _ in 0..<150 {
try? await Task.sleep(nanoseconds: 1_000_000_000)
let stats = await call.sampleStats()
if let data = try? JSONSerialization.data(withJSONObject: [
@@ -88,6 +112,11 @@ struct Input: Decodable {
"remoteMuted": (call.state?.peers ?? []).filter {
$0.peer != call.state?.peer && call.isMuted($0.peer)
}.count,
+ // §5.4: what the stage would caption each screen, in the order
+ // they started. The words, not a count: the caption is the only
+ // place a screen says whose it is.
+ "screenCaptions": call.screens.map { screenCaption(call.name(of: $0)) },
+ "sharingScreen": call.sharingScreen,
]), let text = String(data: data, encoding: .utf8) {
print(text)
fflush(stdout)
diff --git a/native/apple/Sources/CallsInterop/SyntheticVideo.swift b/native/apple/Sources/CallsInterop/SyntheticVideo.swift
index a28290d..7697662 100644
--- a/native/apple/Sources/CallsInterop/SyntheticVideo.swift
+++ b/native/apple/Sources/CallsInterop/SyntheticVideo.swift
@@ -70,3 +70,63 @@ import LiveKitWebRTC
timeStampNs: Int64(ProcessInfo.processInfo.systemUptime * 1_000_000_000)))
}
}
+
+/// A screen with nothing on it but frames, for the gates: there is no
+/// display to share in CI and no person to answer a picker. What it can
+/// still hold is everything after the picker — the source, the sendonly
+/// transceiver, the `share` message, and both of §5.4's endings, the
+/// button's and the capture's own (`end`, which is the system's stop
+/// control as far as the call can tell).
+@MainActor final class SyntheticScreen: ScreenCapture {
+ private var source: RTCVideoSource?
+ private var capturer: RTCVideoCapturer?
+ private var task: Task<Void, Never>?
+ private var ended: (@MainActor () -> Void)?
+
+ func start(source: RTCVideoSource, ended: @escaping @MainActor () -> Void) async throws {
+ self.source = source
+ self.ended = ended
+ capturer = RTCVideoCapturer(delegate: source)
+ task = Task { @MainActor [weak self] in
+ while !Task.isCancelled {
+ self?.frame()
+ try? await Task.sleep(nanoseconds: 100_000_000)
+ }
+ }
+ }
+ func stop() {
+ task?.cancel()
+ task = nil
+ source = nil
+ capturer = nil
+ ended = nil
+ }
+ /// The capture ending on its own, as a shared window closing would.
+ func end() {
+ let ended = self.ended
+ stop()
+ ended?()
+ }
+ private func frame() {
+ guard let source, let capturer else { return }
+ // A 16:9 screen, wider than either synthetic camera, so the far side
+ // can tell by size alone which one it is decoding.
+ var pixel: CVPixelBuffer?
+ CVPixelBufferCreate(
+ kCFAllocatorDefault, 640, 360, kCVPixelFormatType_32BGRA,
+ [kCVPixelBufferIOSurfacePropertiesKey: [:]] as CFDictionary, &pixel)
+ guard let pixel else { return }
+ CVPixelBufferLockBaseAddress(pixel, [])
+ if let base = CVPixelBufferGetBaseAddress(pixel) {
+ memset(
+ base, Int32(Int(Date().timeIntervalSince1970 * 10) % 100 + 120),
+ CVPixelBufferGetDataSize(pixel))
+ }
+ CVPixelBufferUnlockBaseAddress(pixel, [])
+ source.capturer(
+ capturer,
+ didCapture: RTCVideoFrame(
+ buffer: RTCCVPixelBuffer(pixelBuffer: pixel), rotation: ._0,
+ timeStampNs: Int64(ProcessInfo.processInfo.systemUptime * 1_000_000_000)))
+ }
+}
diff --git a/native/apple/Sources/CallsKit/BroadcastScreenCapture.swift b/native/apple/Sources/CallsKit/BroadcastScreenCapture.swift
new file mode 100644
index 0000000..01ff1a8
--- /dev/null
+++ b/native/apple/Sources/CallsKit/BroadcastScreenCapture.swift
@@ -0,0 +1,70 @@
+import CallsBroadcastKit
+import CoreVideo
+import Foundation
+import LiveKitWebRTC
+
+/// The iPhone's whole screen, arriving from the broadcast extension over
+/// its socket (see CallsBroadcastKit), delivered into the call's screen
+/// source.
+///
+/// There is no picker to wait on by the time one of these exists: the
+/// person chose to broadcast at the system's own sheet, and the extension
+/// connecting is the proof. So `start` returns at once, and `ended` is the
+/// extension going away — the broadcast stopped from Control Center or the
+/// red status bar. `stop` closes the connection, which is how stopping in
+/// the app reaches the system: the extension's next write fails and it
+/// ends the broadcast.
+@MainActor public final class BroadcastScreenCapture: ScreenCapture {
+ private let connection: BroadcastConnection
+ private var stopped = false
+ /// Called after the share has ended on its own, once the call has heard.
+ /// The app uses it to leave a call it is only still in for the screen.
+ public var afterEnded: (@MainActor () -> Void)?
+
+ public init(_ connection: BroadcastConnection) { self.connection = connection }
+
+ public func start(source: RTCVideoSource, ended: @escaping @MainActor () -> Void) async throws {
+ let capturer = RTCVideoCapturer(delegate: source)
+ connection.onFrame = { pixels, orientation in
+ // Stamped on arrival, on this device's clock, rather than with the
+ // extension's: the two are separate processes, and the encoder only
+ // needs the frames in order and spaced as they came.
+ source.capturer(
+ capturer,
+ didCapture: RTCVideoFrame(
+ buffer: RTCCVPixelBuffer(pixelBuffer: pixels), rotation: broadcastRotation(orientation),
+ timeStampNs: Int64(DispatchTime.now().uptimeNanoseconds)))
+ }
+ connection.onClose = { [weak self] in
+ Task { @MainActor [weak self] in
+ guard let self, !self.stopped else { return }
+ self.stopped = true
+ ended()
+ self.afterEnded?()
+ }
+ }
+ connection.resume()
+ }
+
+ public func stop() {
+ stopped = true
+ connection.onClose = nil
+ connection.onFrame = nil
+ connection.close()
+ }
+}
+
+/// ReplayKit's orientation for a frame, as the rotation WebRTC should
+/// apply to show it upright. ReplayKit says which way the *image* is
+/// turned, as a CGImagePropertyOrientation: `left` (8) is a phone held in
+/// landscape with its top to the left, whose frames need turning a quarter
+/// clockwise to stand up; `right` (6) the other way. Anything else — and a
+/// phone upright — is sent as it came.
+func broadcastRotation(_ orientation: UInt16) -> RTCVideoRotation {
+ switch orientation {
+ case 3, 4: return ._180
+ case 5, 8: return ._90
+ case 6, 7: return ._270
+ default: return ._0
+ }
+}
diff --git a/native/apple/Sources/CallsKit/Call.swift b/native/apple/Sources/CallsKit/Call.swift
index a6307b5..ceaf74d 100644
--- a/native/apple/Sources/CallsKit/Call.swift
+++ b/native/apple/Sources/CallsKit/Call.swift
@@ -32,6 +32,17 @@ private final class Events: NSObject, BridgeListenerProtocol {
@Published public private(set) var standing = ""
@Published public private(set) var status = "Ready to join"
@Published public private(set) var remoteTracks: [String: RTCVideoTrack] = [:]
+ /// §5.4: the peers showing a screen here, oldest first, and the screens
+ /// that have arrived. Two things and not one, because the `share`
+ /// message usually beats the media by a renegotiation, and the tile
+ /// carries its caption before there is anything to show in it.
+ @Published public private(set) var screens: [String] = []
+ @Published public private(set) var remoteScreens: [String: RTCVideoTrack] = [:]
+ /// Whether this app is sending a screen, and what the last attempt to
+ /// had to say — which is usually nothing.
+ @Published public private(set) var sharingScreen = false
+ @Published public private(set) var screenNote = ""
+ private var screenPending = false
@Published public private(set) var localTrack: RTCVideoTrack?
@Published public private(set) var muted = false
@Published public private(set) var cameraOff = false
@@ -134,6 +145,10 @@ private final class Events: NSObject, BridgeListenerProtocol {
events = nil
media.close()
remoteTracks = [:]
+ screens = []
+ remoteScreens = [:]
+ sharingScreen = false
+ screenNote = ""
localTrack = nil
ice = nil
state = nil
@@ -335,12 +350,17 @@ private final class Events: NSObject, BridgeListenerProtocol {
][state.reason ?? ""] ?? "The call has ended"
media.close()
remoteTracks = [:]
+ screens = []
+ remoteScreens = [:]
+ sharingScreen = false
localTrack = nil
default: status = "Connecting…"
}
if state.peer.isEmpty {
media.closePeers()
remoteTracks = [:]
+ screens = []
+ remoteScreens = [:]
ice = nil
}
if let ice { media.reconcile(state: state, ice: ice) }
@@ -361,6 +381,10 @@ private final class Events: NSObject, BridgeListenerProtocol {
appendChat("\(message.name ?? "Participant"): \(text)")
receivedChatCount += 1
}
+ if message.type == "share" {
+ media.receiveShare(message)
+ return
+ }
if ice == nil, ["offer", "answer", "candidate"].contains(message.type) {
if pendingMedia.count < 256 { pendingMedia.append(message) }
} else {
@@ -374,12 +398,91 @@ private final class Events: NSObject, BridgeListenerProtocol {
if chat.count > 200 { chat.removeFirst(chat.count - 200) }
}
func setRemote(_ track: RTCVideoTrack, peer: String) { remoteTracks[peer] = track }
- func removeRemote(_ peer: String) { remoteTracks.removeValue(forKey: peer) }
+ func removeRemote(_ peer: String) {
+ remoteTracks.removeValue(forKey: peer)
+ dropScreen(peer)
+ }
+
+ // ---------------------------------------------------------------
+ // Screens (§5.4)
+ // ---------------------------------------------------------------
+
+ /// The name the roster gives a peer, which is the only place a name
+ /// comes from: the hub put it there, and nothing a peer sends can.
+ public func name(of peer: String) -> String {
+ state?.peers.first { $0.peer == peer }?.name ?? "Participant"
+ }
+
+ /// Shares a screen (§5.4): the capture asks the person what to share,
+ /// every connection gets it on a transceiver of its own, and then the
+ /// room is told — the label, not the media, so peers can caption the
+ /// tile before the renegotiation lands.
+ ///
+ /// A refusal at the picker says one sentence, the browser's, and does
+ /// not diagnose which kind of refusal it was.
+ public func shareScreen(_ capture: ScreenCapture) async {
+ // The picker stays open for as long as a person takes to answer it,
+ // and the button is live the whole time. Without this a second press
+ // starts a second capture that nothing would ever stop.
+ guard bridge != nil, !sharingScreen, !screenPending else { return }
+ screenPending = true
+ defer { screenPending = false }
+ let joined = epoch
+ screenNote = ""
+ do {
+ try await media.startScreen(capture) { [weak self] in
+ // The capture ended on its own: the system's stop control, or
+ // the shared window closing. The same ending as the button.
+ guard let self, self.epoch == joined else { return }
+ self.stopSharingScreen()
+ }
+ } catch is CancellationError {
+ return
+ } catch {
+ guard epoch == joined else { return }
+ screenNote = "Screen sharing was not allowed."
+ return
+ }
+ guard epoch == joined, bridge != nil else {
+ media.stopScreen()
+ return
+ }
+ sharingScreen = true
+ send(type: "share", to: "", gen: 0, payload: json(["on": true]) ?? "")
+ }
+
+ /// Stops sharing, from every way out: the button, the system's own stop
+ /// control, and the capture ending under us.
+ public func stopSharingScreen() {
+ guard sharingScreen else { return }
+ sharingScreen = false
+ media.stopScreen()
+ send(type: "share", to: "", gen: 0, payload: json(["on": false]) ?? "")
+ }
+
+ /// A peer has said it is sharing, or its screen has arrived. Either one
+ /// is enough to draw the tile.
+ func announceScreen(_ peer: String) {
+ if !screens.contains(peer) { screens.append(peer) }
+ }
+ func setScreen(_ track: RTCVideoTrack, peer: String) {
+ announceScreen(peer)
+ remoteScreens[peer] = track
+ }
+ /// Both of §5.4's endings meet here — the `share {on:false}` message
+ /// and the receiver going away with its stopped transceiver — and
+ /// whichever comes first wins. The second finds nothing to do.
+ func dropScreen(_ peer: String) {
+ screens.removeAll { $0 == peer }
+ remoteScreens.removeValue(forKey: peer)
+ }
func mediaFailed() { status = "A participant’s media could not connect." }
public func sampleStats() async -> [String: UInt64] {
var values = await media.stats()
values["hasICE"] = ice == nil ? 0 : 1
values["remoteTracks"] = UInt64(remoteTracks.count)
+ values["remoteScreens"] = UInt64(remoteScreens.count)
+ values["screens"] = UInt64(screens.count)
values["invalidEvents"] = invalidEvents
values["rosterPeers"] = UInt64(state?.peers.count ?? 0)
values["noiseFramesProcessed"] = media.audio.denoiser.framesProcessed
diff --git a/native/apple/Sources/CallsKit/Capture.swift b/native/apple/Sources/CallsKit/Capture.swift
index f902446..fa45a8d 100644
--- a/native/apple/Sources/CallsKit/Capture.swift
+++ b/native/apple/Sources/CallsKit/Capture.swift
@@ -37,6 +37,20 @@ extension VideoCapture {
public func use(camera id: String) async throws {}
}
+/// A screen, as §5.4 sends one: frames into a source of its own, which
+/// the call carries on a `sendonly` transceiver beside the camera's.
+///
+/// `start` returns once the person has chosen what to share and frames
+/// are on their way; a refusal at the system's own picker is a throw, and
+/// nothing has been announced. `ended` is for the capture ending on its
+/// own — the system's stop control, a shared window closing, a broadcast
+/// stopped from Control Center — and is called at most once, on the main
+/// actor. `stop` is the call ending it, and calls nothing back.
+@MainActor public protocol ScreenCapture: AnyObject {
+ func start(source: RTCVideoSource, ended: @escaping @MainActor () -> Void) async throws
+ func stop()
+}
+
@MainActor final class CameraCapture: VideoCapture {
private var capturer: RTCCameraVideoCapturer?
private var source: RTCVideoSource?
diff --git a/native/apple/Sources/CallsKit/MacCallView.swift b/native/apple/Sources/CallsKit/MacCallView.swift
index e61f42b..847d92f 100644
--- a/native/apple/Sources/CallsKit/MacCallView.swift
+++ b/native/apple/Sources/CallsKit/MacCallView.swift
@@ -6,7 +6,7 @@
private let leave: () -> Void
@State private var grid: Bool
@State private var showingChat: Bool
- @State private var selectedPeer: String?
+ @State private var chosen: StageItem?
@State private var readChatCount = 0
@State private var showingDevices = false
@StateObject private var chatWindow = MacChatWindow()
@@ -27,17 +27,29 @@
Text(call.title).font(.title2.bold()).lineLimit(1)
Text(call.status).font(.callout).foregroundStyle(.secondary)
.accessibilityAddTraits(.updatesFrequently)
+ // Said in words as well as by the button's state: a person
+ // sharing a screen should never have to wonder whether they
+ // still are.
+ if call.sharingScreen {
+ Label("You are sharing your screen", systemImage: "rectangle.on.rectangle")
+ .font(.callout.bold()).foregroundStyle(.orange)
+ } else if !call.screenNote.isEmpty {
+ Text(call.screenNote).font(.callout).foregroundStyle(.secondary)
+ }
}
Spacer()
- if !grid, peers.count > 1 {
+ if !grid, peers.count > 1 || !call.screens.isEmpty {
Menu {
+ ForEach(call.screens, id: \.self) { peer in
+ Button(screenCaption(call.name(of: peer))) { chosen = .screen(peer) }
+ }
ForEach(peers) { peer in
- Button(peer.name) { selectedPeer = peer.peer }
+ Button(peer.name) { chosen = .camera(peer.peer) }
}
} label: {
Label("Choose participant", systemImage: "person.2")
}
- .help("Choose the participant to show on the main stage")
+ .help("Choose the participant or screen to show on the main stage")
}
Picker("Video layout", selection: $grid) {
Label("Focus", systemImage: "rectangle.inset.bottomtrailing").tag(false)
@@ -98,6 +110,11 @@
.onChange(of: call.receivedChatCount) { _, count in
if showingChat || chatWindow.isOpen { readChatCount = count }
}
+ .onChange(of: call.screens) { before, after in
+ // Somebody starting to share is asking to be looked at, so a new
+ // screen takes the stage from whatever was chosen before it.
+ if after.contains(where: { !before.contains($0) }) { chosen = nil }
+ }
.onChange(of: showingChat) { _, visible in
if visible { readChatCount = call.receivedChatCount }
}
@@ -110,12 +127,25 @@
private var peers: [RosterPeer] {
call.state?.peers.filter { $0.peer != call.state?.peer } ?? []
}
- private var focusedPeer: RosterPeer? { peers.first { $0.peer == selectedPeer } ?? peers.first }
+ private var focused: StageItem? {
+ Stage.focus(chosen: chosen, peers: peers.map(\.peer), screens: call.screens)
+ }
+ private var stageShowsScreen: Bool {
+ if case .screen = focused { return true }
+ return false
+ }
@ViewBuilder private var stage: some View {
if grid {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 240))], spacing: 12) {
+ ForEach(call.screens, id: \.self) { peer in
+ MacVideoTile(
+ track: call.remoteScreens[peer], label: screenCaption(call.name(of: peer)),
+ screen: true
+ )
+ .aspectRatio(16 / 9, contentMode: .fit)
+ }
MacVideoTile(
track: call.localTrack, label: "You", mirrored: true, cameraOff: call.cameraOff
)
@@ -132,11 +162,16 @@
} else {
GeometryReader { geometry in
ZStack(alignment: .bottomTrailing) {
- if let peer = focusedPeer {
+ switch focused {
+ case .screen(let peer):
MacVideoTile(
- track: call.remoteTracks[peer.peer], label: peer.name,
- muted: call.isMuted(peer.peer), cameraOff: call.isCameraOff(peer.peer))
- } else {
+ track: call.remoteScreens[peer], label: screenCaption(call.name(of: peer)),
+ screen: true)
+ case .camera(let peer):
+ MacVideoTile(
+ track: call.remoteTracks[peer], label: call.name(of: peer),
+ muted: call.isMuted(peer), cameraOff: call.isCameraOff(peer))
+ case nil:
ContentUnavailableView(
"Waiting for others", systemImage: "person.2",
description: Text("Your meeting is ready. Others will appear here when they join.")
@@ -144,19 +179,38 @@
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(CallsAppearance.background, in: RoundedRectangle(cornerRadius: 16))
}
- MacVideoTile(
- track: call.localTrack, label: "You", mirrored: true, cameraOff: call.cameraOff
- )
- .frame(
- width: min(180, geometry.size.width * 0.3),
- height: min(135, geometry.size.height * 0.3)
- )
+ // With a screen on the stage every camera would otherwise be
+ // off it, so the people come down to thumbnails beside yours.
+ let others = stageShowsScreen ? peers : []
+ let size = thumbnail(count: others.count + 1, in: geometry.size)
+ VStack(spacing: 10) {
+ ForEach(others) { peer in
+ MacVideoTile(
+ track: call.remoteTracks[peer.peer], label: peer.name,
+ muted: call.isMuted(peer.peer), cameraOff: call.isCameraOff(peer.peer)
+ )
+ .frame(width: size.width, height: size.height)
+ }
+ MacVideoTile(
+ track: call.localTrack, label: "You", mirrored: true, cameraOff: call.cameraOff
+ )
+ .frame(width: size.width, height: size.height)
+ }
.shadow(color: .black.opacity(0.25), radius: 8, y: 3).padding(14)
}
}
}
}
+ /// A thumbnail's size, when `count` of them stack up the stage's
+ /// trailing edge: the size one always had, until there are enough of
+ /// them that it would run off the top.
+ private func thumbnail(count: Int, in stage: CGSize) -> CGSize {
+ let room = (stage.height - 28 - CGFloat(count - 1) * 10) / CGFloat(max(count, 1))
+ let height = max(60, min(135, stage.height * 0.3, room))
+ return CGSize(width: min(180, stage.width * 0.3, height * 4 / 3), height: height)
+ }
+
private var controls: some View {
HStack(spacing: 12) {
Button(
@@ -179,6 +233,20 @@
emphasized: call.cameraOff)
)
.help(call.cameraOff ? "Turn camera on" : "Turn camera off")
+ Button(
+ call.sharingScreen ? "Stop sharing your screen" : "Share your screen",
+ systemImage: call.sharingScreen ? "rectangle.on.rectangle.slash" : "rectangle.on.rectangle"
+ ) {
+ if call.sharingScreen {
+ call.stopSharingScreen()
+ } else {
+ Task { await call.shareScreen(MacScreenCapture()) }
+ }
+ }
+ .buttonStyle(CallActionStyle(color: .orange, emphasized: call.sharingScreen))
+ .help(call.sharingScreen ? "Stop sharing your screen" : "Share a window or your whole screen")
+ .disabled(call.state?.phase != "admitted")
+ .accessibilityIdentifier("shareScreen")
Button("Devices", systemImage: "slider.horizontal.3") { showingDevices.toggle() }
.buttonStyle(CallActionStyle(color: .teal, emphasized: showingDevices))
.help("Choose your camera, and see which microphone and speaker this call is on")
diff --git a/native/apple/Sources/CallsKit/MacScreenCapture.swift b/native/apple/Sources/CallsKit/MacScreenCapture.swift
new file mode 100644
index 0000000..738539f
--- /dev/null
+++ b/native/apple/Sources/CallsKit/MacScreenCapture.swift
@@ -0,0 +1,231 @@
+#if os(macOS)
+ import CallsBroadcastKit
+ import CoreMedia
+ import Foundation
+ import ScreenCaptureKit
+ import LiveKitWebRTC
+
+ /// A display or a window, chosen with the system's own picker and
+ /// delivered into the call's screen source.
+ ///
+ /// The picker is SCContentSharingPicker (macOS 14, the app's floor), and
+ /// that choice does more than save drawing one: content a person picks
+ /// there is content they have consented to share, so the app needs no
+ /// Screen Recording permission of its own and never sends anyone to
+ /// System Settings to grant one. There is nothing in Info.plist for it
+ /// either — macOS has no usage string for screen capture. The same holds
+ /// in the App Sandbox, which is why this is also the path a sandboxed
+ /// build can keep.
+ ///
+ /// The capture ends on its own when the person stops it from the menu
+ /// bar's sharing indicator, or the shared window closes; either one
+ /// reaches `ended`, which the call treats exactly like its own button.
+ @MainActor public final class MacScreenCapture: NSObject, ScreenCapture {
+ private var stream: SCStream?
+ private var frames: ScreenFrames?
+ private var ended: (@MainActor () -> Void)?
+ private var choosing: CheckedContinuation<SCContentFilter, Error>?
+ private let queue = DispatchQueue(label: "calls.screen", qos: .userInteractive)
+
+ public override init() {}
+
+ public func start(source: RTCVideoSource, ended: @escaping @MainActor () -> Void) async throws {
+ let picker = SCContentSharingPicker.shared
+ var configuration = SCContentSharingPickerConfiguration()
+ configuration.allowedPickerModes = [.singleDisplay, .singleWindow]
+ // The call's own window is the one thing nobody in it needs to see:
+ // shared, it is a hall of mirrors of their own faces.
+ if let own = Bundle.main.bundleIdentifier {
+ configuration.excludedBundleIDs = [own]
+ }
+ picker.defaultConfiguration = configuration
+ picker.maximumStreamCount = 1
+ picker.add(self)
+ picker.isActive = true
+ let filter: SCContentFilter
+ do {
+ let picked = try await withCheckedThrowingContinuation { continuation in
+ choosing = continuation
+ picker.present()
+ }
+ filter = await Self.withoutCalls(picked)
+ } catch {
+ release()
+ throw error
+ }
+ let frames = ScreenFrames(source: source)
+ let stream = SCStream(filter: filter, configuration: Self.configuration(for: filter), delegate: self)
+ do {
+ try stream.addStreamOutput(frames, type: .screen, sampleHandlerQueue: queue)
+ try await stream.startCapture()
+ } catch {
+ release()
+ throw error
+ }
+ self.stream = stream
+ self.frames = frames
+ self.ended = ended
+ }
+
+ public func stop() {
+ ended = nil
+ let stream = self.stream
+ self.stream = nil
+ frames = nil
+ release()
+ stream?.stopCapture { _ in }
+ }
+
+ private func release() {
+ let picker = SCContentSharingPicker.shared
+ picker.remove(self)
+ picker.isActive = false
+ choosing?.resume(throwing: CancellationError())
+ choosing = nil
+ }
+
+ /// The capture ending under the call, rather than by it.
+ private func finished() {
+ let ended = self.ended
+ stop()
+ ended?()
+ }
+
+ /// The picked content with Calls taken out of it.
+ ///
+ /// A whole display, as the picker hands it back, includes this app's
+ /// own windows: the call window, and the small floating one it shows
+ /// while it is in the background. Shared, they are a hall of mirrors
+ /// of everyone's faces — and the floating one is always on top of
+ /// whatever the person meant to show. So a display is rebuilt as the
+ /// same display excluding this application, which also covers windows
+ /// that open after sharing starts. A single window needs nothing: this
+ /// app's are left out of the picker, so the window is someone else's.
+ ///
+ /// The display comes from the filter itself on macOS 15.2 and later,
+ /// and before that from matching its frame. The application comes from
+ /// the current process's own shareable content, which is the one query
+ /// that asks for no Screen Recording permission. On a macOS too old
+ /// for that (before 14.4) the display is shared as picked; the
+ /// floating window's own `sharingType` is then the only thing keeping
+ /// it out, and the call window is in the share.
+ static func withoutCalls(_ filter: SCContentFilter) async -> SCContentFilter {
+ guard filter.style == .display, #available(macOS 14.4, *),
+ let content = try? await SCShareableContent.currentProcess,
+ let calls = content.applications.first(where: { $0.processID == getpid() })
+ else { return filter }
+ var display: SCDisplay?
+ if #available(macOS 15.2, *) { display = filter.includedDisplays.first }
+ if display == nil { display = content.displays.first { $0.frame == filter.contentRect } }
+ guard let display else { return filter }
+ return SCContentFilter(display: display, excludingApplications: [calls], exceptingWindows: [])
+ }
+
+ /// Full resolution up to 1920 on the long side, at up to 30 frames a
+ /// second. Larger is sharper than any receiver will be shown it and
+ /// costs the encoder for nothing; the source is a screencast, so
+ /// under pressure WebRTC gives up frames before it gives up pixels.
+ static func configuration(for filter: SCContentFilter) -> SCStreamConfiguration {
+ let configuration = SCStreamConfiguration()
+ let rect = filter.contentRect
+ let scale = CGFloat(filter.pointPixelScale)
+ let size = fitted(width: rect.width * scale, height: rect.height * scale, longest: 1920)
+ configuration.width = size.width
+ configuration.height = size.height
+ configuration.minimumFrameInterval = CMTime(value: 1, timescale: 30)
+ configuration.pixelFormat = kCVPixelFormatType_420YpCbCr8BiPlanarFullRange
+ configuration.showsCursor = true
+ configuration.queueDepth = 5
+ return configuration
+ }
+ }
+
+ extension MacScreenCapture: SCContentSharingPickerObserver {
+ nonisolated public func contentSharingPicker(
+ _ picker: SCContentSharingPicker, didCancelFor stream: SCStream?
+ ) {
+ Task { @MainActor [weak self] in
+ guard let self else { return }
+ if stream == nil || stream === self.stream {
+ if let choosing = self.choosing {
+ self.choosing = nil
+ choosing.resume(throwing: ScreenShareRefused())
+ }
+ }
+ }
+ }
+
+ nonisolated public func contentSharingPicker(
+ _ picker: SCContentSharingPicker, didUpdateWith filter: SCContentFilter, for stream: SCStream?
+ ) {
+ Task { @MainActor [weak self] in
+ guard let self else { return }
+ if let choosing = self.choosing {
+ self.choosing = nil
+ choosing.resume(returning: filter)
+ return
+ }
+ // Chosen again from the menu bar mid-share: the same stream, now
+ // on something else. The call does not need to hear about it.
+ guard let current = self.stream, stream == nil || stream === current else { return }
+ let next = await Self.withoutCalls(filter)
+ guard current === self.stream else { return }
+ current.updateContentFilter(next) { _ in }
+ current.updateConfiguration(Self.configuration(for: next)) { _ in }
+ }
+ }
+
+ nonisolated public func contentSharingPickerStartDidFailWithError(_ error: Error) {
+ Task { @MainActor [weak self] in
+ guard let self, let choosing = self.choosing else { return }
+ self.choosing = nil
+ choosing.resume(throwing: error)
+ }
+ }
+ }
+
+ extension MacScreenCapture: SCStreamDelegate {
+ nonisolated public func stream(_ stream: SCStream, didStopWithError error: Error) {
+ Task { @MainActor [weak self] in
+ guard let self, stream === self.stream else { return }
+ self.finished()
+ }
+ }
+ }
+
+ struct ScreenShareRefused: Error {}
+
+ /// Hands each complete frame to the source. Called on the capture's own
+ /// queue, never the main actor: a screen is thirty frames a second and
+ /// the main actor has an interface to draw.
+ private final class ScreenFrames: NSObject, SCStreamOutput, @unchecked Sendable {
+ private let source: RTCVideoSource
+ private let capturer: RTCVideoCapturer
+
+ init(source: RTCVideoSource) {
+ self.source = source
+ capturer = RTCVideoCapturer(delegate: source)
+ }
+
+ func stream(
+ _ stream: SCStream, didOutputSampleBuffer sample: CMSampleBuffer, of type: SCStreamOutputType
+ ) {
+ guard type == .screen, sample.isValid, let pixels = sample.imageBuffer else { return }
+ // Only complete frames carry pixels. An idle screen sends status
+ // frames and nothing else, and the receiver keeps the last picture,
+ // which is what an idle screen looks like.
+ guard
+ let attachments = CMSampleBufferGetSampleAttachmentsArray(sample, createIfNecessary: false)
+ as? [[SCStreamFrameInfo: Any]],
+ let raw = attachments.first?[.status] as? Int,
+ SCFrameStatus(rawValue: raw) == .complete
+ else { return }
+ let time = CMTimeGetSeconds(sample.presentationTimeStamp)
+ source.capturer(
+ capturer,
+ didCapture: RTCVideoFrame(
+ buffer: RTCCVPixelBuffer(pixelBuffer: pixels), rotation: ._0,
+ timeStampNs: Int64(time * 1_000_000_000)))
+ }
+ }
+#endif
diff --git a/native/apple/Sources/CallsKit/MacVideoTile.swift b/native/apple/Sources/CallsKit/MacVideoTile.swift
index 7475eaa..a0619b6 100644
--- a/native/apple/Sources/CallsKit/MacVideoTile.swift
+++ b/native/apple/Sources/CallsKit/MacVideoTile.swift
@@ -10,17 +10,24 @@
var muted = false
var mirrored = false
var cameraOff = false
+ /// A shared screen (§5.4). Shown whole rather than cropped to fill:
+ /// the edge of a screen is where its menu bar and its scroll bars are,
+ /// and a face survives losing its margins where a document does not.
+ var screen = false
var body: some View {
ZStack {
Color.black
if let track, !cameraOff {
- MacVideoView(track: track).scaleEffect(x: mirrored ? -1 : 1, y: 1)
+ MacVideoView(track: track, fit: screen).scaleEffect(x: mirrored ? -1 : 1, y: 1)
} else {
VStack(spacing: 12) {
- Image(systemName: cameraOff ? "video.slash" : "person.crop.circle")
- .font(.largeTitle)
- Text(cameraOff ? "Camera off" : "Waiting for video").font(.callout)
+ Image(
+ systemName: cameraOff
+ ? "video.slash" : screen ? "rectangle.on.rectangle" : "person.crop.circle"
+ )
+ .font(.largeTitle)
+ Text(waiting).font(.callout)
}.foregroundStyle(.white.opacity(0.8))
}
}
@@ -33,23 +40,33 @@
.clipShape(RoundedRectangle(cornerRadius: 16))
.accessibilityElement(children: .ignore)
.accessibilityLabel(tileAccessibilityLabel(label, muted: muted))
- .accessibilityValue(
- cameraOff ? "Camera off" : track == nil ? "Waiting for video" : "Video on")
+ .accessibilityValue(cameraOff ? "Camera off" : track == nil ? waiting : "Video on")
+ }
+ private var waiting: String {
+ cameraOff ? "Camera off" : screen ? "Waiting for the screen" : "Waiting for video"
}
}
private struct MacVideoView: NSViewRepresentable {
let track: RTCVideoTrack
+ var fit = false
func makeNSView(context: Context) -> FillVideoView { FillVideoView() }
- func updateNSView(_ view: FillVideoView, context: Context) { view.attach(track) }
+ func updateNSView(_ view: FillVideoView, context: Context) {
+ view.fit = fit
+ view.attach(track)
+ }
static func dismantleNSView(_ view: FillVideoView, coordinator: ()) { view.detach() }
}
- /// The renderer keeps its source aspect ratio while the container crops overflow.
+ /// The renderer keeps its source aspect ratio while the container crops
+ /// overflow — or, for a screen, letterboxes it.
private final class FillVideoView: NSView, RTCVideoViewDelegate {
private let renderer = RTCMTLNSVideoView(frame: .zero)
private var track: RTCVideoTrack?
private var videoSize = NSSize(width: 16, height: 9)
+ var fit = false {
+ didSet { if fit != oldValue { needsLayout = true } }
+ }
override init(frame: NSRect) {
super.init(frame: frame)
@@ -81,7 +98,9 @@
override func layout() {
super.layout()
- let scale = max(bounds.width / videoSize.width, bounds.height / videoSize.height)
+ 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)
renderer.frame = NSRect(
x: (bounds.width - size.width) / 2,
diff --git a/native/apple/Sources/CallsKit/Media.swift b/native/apple/Sources/CallsKit/Media.swift
index 6d72d83..9cddb3b 100644
--- a/native/apple/Sources/CallsKit/Media.swift
+++ b/native/apple/Sources/CallsKit/Media.swift
@@ -8,6 +8,14 @@ import LiveKitWebRTC
let factory: RTCPeerConnectionFactory
var localVideo: RTCVideoTrack?
var localAudio: RTCAudioTrack?
+ /// §5.4: the display track while this app is sharing, and nothing
+ /// otherwise. Every connection carries it on a transceiver of its own.
+ private(set) var localScreen: RTCVideoTrack?
+ private var screenCapture: ScreenCapture?
+ // Bumped by everything that ends a share, so a capture whose picker
+ // was still open when the share or the call ended is stopped when it
+ // finally answers, rather than sent.
+ private var screenAttempt = 0
private var capture: VideoCapture?
private var peers: [String: MediaPeer] = [:]
@@ -61,7 +69,44 @@ import LiveKitWebRTC
for (_, p) in peers { p.close() }
peers.removeAll()
}
+
+ /// Starts sending a screen (§5.4). The capture is asked first: a person
+ /// who changes their mind at the system's picker has shared nothing,
+ /// and nothing is added to any connection.
+ ///
+ /// The source is marked as a screencast, which is what tells WebRTC to
+ /// keep resolution and give up frame rate under pressure — text that
+ /// goes soft is unreadable, where text that updates a little late is
+ /// not.
+ func startScreen(_ capture: ScreenCapture, ended: @escaping @MainActor () -> Void) async throws {
+ guard localScreen == nil else { return }
+ screenAttempt += 1
+ let attempt = screenAttempt
+ let source = factory.videoSource(forScreenCast: true)
+ try await capture.start(source: source, ended: ended)
+ guard attempt == screenAttempt, localScreen == nil else {
+ capture.stop()
+ throw CancellationError()
+ }
+ screenCapture = capture
+ localScreen = factory.videoTrack(with: source, trackId: "screen")
+ for peer in peers.values { peer.addScreen() }
+ }
+
+ /// Stops sending the screen: every connection's screen transceiver is
+ /// stopped and renegotiated, so the receivers' tracks *end*, and the
+ /// capture goes last.
+ func stopScreen() {
+ screenAttempt += 1
+ guard let capture = screenCapture else { return }
+ screenCapture = nil
+ localScreen = nil
+ for peer in peers.values { peer.removeScreen() }
+ capture.stop()
+ }
+
func close() {
+ stopScreen()
closePeers()
capture?.stop()
capture = nil
@@ -88,6 +133,14 @@ import LiveKitWebRTC
}
}
func receive(_ message: Message) { if let from = message.from { peers[from]?.receive(message) } }
+ /// §5.4's label. A peer this app has no connection to yet is ignored,
+ /// as the browser ignores it: its screen will be told apart from its
+ /// camera by the m-line when the connection comes, and a caption with
+ /// no connection behind it is a tile nothing will ever fill or clear.
+ func receiveShare(_ message: Message) {
+ guard let from = message.from, let peer = peers[from] else { return }
+ peer.share(on: message.payload?.on == true)
+ }
func updateICE(_ config: ICEConfig) { for p in peers.values { p.updateICE(config) } }
/// The five audio-health fields, gathered alongside the byte counts.
///
@@ -116,6 +169,7 @@ import LiveKitWebRTC
func stats() async -> [String: UInt64] {
var values: [String: UInt64] = [
"framesDecoded": 0, "audioBytesReceived": 0, "videoBytesSent": 0, "audioBytesSent": 0,
+ "screenFramesDecoded": 0, "screenBytesSent": 0,
"peerConnections": UInt64(peers.count),
"concealedSamples": 0, "totalSamplesReceived": 0,
"insertedSamplesForDeceleration": 0, "removedSamplesForAcceleration": 0,
@@ -128,9 +182,15 @@ import LiveKitWebRTC
}
for stat in report.statistics.values {
let kind = (stat.values["kind"] as? String) ?? (stat.values["mediaType"] as? String) ?? ""
+ let mid = stat.values["mid"] as? String ?? ""
if stat.type == "inbound-rtp" {
if kind == "video" {
- values["framesDecoded", default: 0] +=
+ // A peer sharing its screen sends two video streams, and a
+ // count that added them together would let a screen stand in
+ // for a camera that had stopped. Told apart by mid, which is
+ // what the browser's own gate does.
+ let field = peer.isScreen(mid: mid) ? "screenFramesDecoded" : "framesDecoded"
+ values[field, default: 0] +=
(stat.values["framesDecoded"] as? NSNumber)?.uint64Value ?? 0
}
if kind == "audio" {
@@ -150,7 +210,8 @@ import LiveKitWebRTC
}
if stat.type == "outbound-rtp" {
if kind == "video" {
- values["videoBytesSent", default: 0] +=
+ let field = peer.isOwnScreen(mid: mid) ? "screenBytesSent" : "videoBytesSent"
+ values[field, default: 0] +=
(stat.values["bytesSent"] as? NSNumber)?.uint64Value ?? 0
}
if kind == "audio" {
@@ -164,6 +225,19 @@ import LiveKitWebRTC
}
}
+/// A transceiver direction as SDP spells it, which is how the classifier,
+/// and the browser it was ported from, compare them.
+private func sdpDirection(_ direction: RTCRtpTransceiverDirection) -> String {
+ switch direction {
+ case .sendRecv: return "sendrecv"
+ case .sendOnly: return "sendonly"
+ case .recvOnly: return "recvonly"
+ case .inactive: return "inactive"
+ case .stopped: return "stopped"
+ @unknown default: return ""
+ }
+}
+
@MainActor private final class MediaPeer: NSObject, RTCPeerConnectionDelegate {
let id: String
let generation: Int64
@@ -176,6 +250,22 @@ import LiveKitWebRTC
private var ignoreOffer = false
private var candidates: [Candidate] = []
private var lastICE: ICEConfig?
+ // §5.4, receiving. What this peer's camera and microphone came in on,
+ // and whether it has said it is sharing; the receiver its screen
+ // arrived on, so that only *that* receiver going away takes the tile
+ // with it; and the mid, so the statistics can count the two video
+ // streams apart.
+ private var clues = ScreenClues()
+ private var screenReceiver: String?
+ private var screenMid = ""
+ // Whether this connection carries a camera of ours: the classifier's
+ // direction rule turns on it.
+ private var sentVideo = false
+ // §5.4, sending: the transceiver this connection carries our screen on,
+ // while we are sharing. And an offer this connection owes the far side
+ // but could not make yet, because it was in the middle of another.
+ private var screenTx: RTCRtpTransceiver?
+ private var owesOffer = false
init(id: String, generation: Int64, polite: Bool, engine: MediaEngine) {
self.id = id
@@ -200,10 +290,135 @@ import LiveKitWebRTC
constraints: RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil),
delegate: self)
if let audio = engine.localAudio { _ = pc?.add(audio, streamIds: ["calls"]) }
- if let video = engine.localVideo { _ = pc?.add(video, streamIds: ["calls"]) }
+ if let video = engine.localVideo {
+ sentVideo = pc?.add(video, streamIds: ["calls"]) != nil
+ }
+ // A connection built while this app is already sharing gets the
+ // screen with everything else: someone joining mid-share sees it.
+ // Last, as in the browser, so the camera and microphone take the
+ // m-lines the far side built for them and the screen a place of its
+ // own after them.
+ addScreen()
+ }
+
+ /// Gives this connection the screen on a `sendonly` transceiver in a
+ /// stream of its own, which is what keeps it clear of the camera at the
+ /// far end. Adding it asks for negotiation, and perfect negotiation
+ /// carries the offer with no help from here.
+ ///
+ /// Only on a stable connection. A transceiver added mid-negotiation is
+ /// unassociated when the far side's offer arrives, and m-line matching
+ /// will hand it that offer's m-line — so two people sharing at once end
+ /// up each sending a screen in the place the other built for something
+ /// else. The browser's mutualshare gate found that; the deferred ones
+ /// are added when the connection settles.
+ func addScreen() {
+ guard let pc, !closed, screenTx == nil, let track = engine?.localScreen,
+ pc.signalingState == .stable
+ else { return }
+ let setup = RTCRtpTransceiverInit()
+ setup.direction = .sendOnly
+ setup.streamIds = ["screen"]
+ screenTx = pc.addTransceiver(with: track, init: setup)
+ }
+
+ /// Ends this connection's screen m-line. Stopping the transceiver is
+ /// §5.4's own instruction and the only thing that makes the receiver's
+ /// track end rather than freeze on its last frame.
+ ///
+ /// The framework's stop is the immediate one: it stops the transceiver
+ /// and asks for nothing, where the browser's `stop()` also raises
+ /// negotiation-needed. So the offer that tells the far side is asked
+ /// for here, by hand.
+ func removeScreen() {
+ guard let tx = screenTx else { return }
+ screenTx = nil
+ tx.stopInternal()
+ renegotiate()
+ }
+
+ /// An offer this side has to make for a reason negotiation-needed will
+ /// not announce. Made now if the connection is quiet, and otherwise
+ /// owed, and made the next time it settles.
+ private func renegotiate() {
+ guard let pc, !closed else { return }
+ guard pc.signalingState == .stable else {
+ owesOffer = true
+ return
+ }
+ Task { @MainActor [weak self] in
+ guard let self else { return }
+ if self.polite { try? await Task.sleep(nanoseconds: 200_000_000) }
+ self.enqueue { [weak self] in
+ guard let self, let pc = self.pc else { return }
+ if pc.signalingState != .stable {
+ self.owesOffer = true
+ return
+ }
+ try await self.offer()
+ }
+ }
+ }
+
+ /// The connection is back at stable: the one moment a deferred screen
+ /// can be added, and an owed offer made.
+ private func settled() {
+ guard let pc, !closed, pc.signalingState == .stable else { return }
+ addScreen()
+ if owesOffer {
+ owesOffer = false
+ renegotiate()
+ }
+ }
+ func isScreen(mid: String) -> Bool { !mid.isEmpty && mid == screenMid }
+ func isOwnScreen(mid: String) -> Bool { !mid.isEmpty && mid == screenTx?.mid }
+
+ /// The `share` message: on draws the captioned tile ahead of the media,
+ /// off is one of §5.4's two endings.
+ func share(on: Bool) {
+ if on {
+ clues.sharing = true
+ engine?.call?.announceScreen(id)
+ } else {
+ dropScreen()
+ }
+ }
+ private func dropScreen() {
+ clues.sharing = false
+ screenReceiver = nil
+ screenMid = ""
+ engine?.call?.dropScreen(id)
+ }
+
+ /// One track arriving, put in its place. A camera goes on the peer's
+ /// tile exactly as it always did; a screen gets a tile of its own and
+ /// leaves the camera alone.
+ private func arrived(
+ _ track: RTCMediaStreamTrack, receiver: String, info: ArrivingTrack
+ ) {
+ let role = classifyTrack(clues, info, hasVideo: sentVideo)
+ if role == .screen, let video = track as? RTCVideoTrack {
+ clues.sharing = true
+ screenReceiver = receiver
+ if !info.mid.isEmpty { screenMid = info.mid }
+ engine?.call?.setScreen(video, peer: id)
+ return
+ }
+ if role == .camera {
+ if !info.mid.isEmpty { clues.cameraMid = info.mid }
+ if !info.streamId.isEmpty { clues.cameraStream = info.streamId }
+ }
+ // Recorded from the first track of any kind: it is what places a
+ // video track for a peer that has no camera.
+ if clues.peerStream.isEmpty { clues.peerStream = info.streamId }
+ if role == .camera, let video = track as? RTCVideoTrack {
+ engine?.call?.setRemote(video, peer: id)
+ }
}
+
func close() {
closed = true
+ screenTx = nil
work?.cancel()
work = nil
pc?.close()
@@ -354,10 +569,40 @@ import LiveKitWebRTC
if let audio = rtpReceiver.track as? RTCAudioTrack, !playAudio {
audio.source.volume = 0
}
- guard let track = rtpReceiver.track as? RTCVideoTrack else { return }
+ guard let track = rtpReceiver.track else { return }
+ // Read here, on the signalling thread this callback arrives on, rather
+ // than on the main actor: by the time the main actor gets to it the
+ // next description can have moved the transceiver on.
+ let receiver = rtpReceiver.receiverId
+ let transceiver = peerConnection.transceivers.first { $0.receiver.receiverId == receiver }
+ var info = ArrivingTrack(kind: track.kind, streamId: streams.first?.streamId ?? "")
+ if let transceiver {
+ info.mid = transceiver.mid
+ // The negotiated direction when there is one. On the side that
+ // answers an offer there is not yet, and that is exactly the side a
+ // screen arrives at; the direction the transceiver was built with is
+ // recvonly for an m-line the far side added, which is the point.
+ var current = RTCRtpTransceiverDirection.inactive
+ info.direction = sdpDirection(
+ transceiver.currentDirection(&current) ? current : transceiver.direction)
+ }
Task { @MainActor [weak self] in
guard let self, !self.closed else { return }
- self.engine?.call?.setRemote(track, peer: self.id)
+ self.arrived(track, receiver: receiver, info: info)
+ }
+ }
+ /// §5.4's other ending: the sender stopped its screen's transceiver and
+ /// the renegotiation took the receiver away. Only the receiver the
+ /// screen is showing from counts. A screen replaced on its own mid ends
+ /// its predecessor afterwards, and without the check that took the live
+ /// replacement's tile with it (the browser's Codex S4-15).
+ nonisolated func peerConnection(
+ _ peerConnection: RTCPeerConnection, didRemove rtpReceiver: RTCRtpReceiver
+ ) {
+ let receiver = rtpReceiver.receiverId
+ Task { @MainActor [weak self] in
+ guard let self, !self.closed, self.screenReceiver == receiver else { return }
+ self.dropScreen()
}
}
nonisolated func peerConnection(
@@ -367,7 +612,10 @@ import LiveKitWebRTC
}
nonisolated func peerConnection(
_ peerConnection: RTCPeerConnection, didChange stateChanged: RTCSignalingState
- ) {}
+ ) {
+ guard stateChanged == .stable else { return }
+ Task { @MainActor [weak self] in self?.settled() }
+ }
nonisolated func peerConnection(
_ peerConnection: RTCPeerConnection, didAdd stream: RTCMediaStream
) {}
diff --git a/native/apple/Sources/CallsKit/ScreenShare.swift b/native/apple/Sources/CallsKit/ScreenShare.swift
new file mode 100644
index 0000000..c3661f1
--- /dev/null
+++ b/native/apple/Sources/CallsKit/ScreenShare.swift
@@ -0,0 +1,120 @@
+import Foundation
+
+// ---------------------------------------------------------------
+// Screen share, receiving (spec §5.4)
+// ---------------------------------------------------------------
+//
+// The web client is the contract here, and this file is its receiving
+// half ported rule for rule: a sharer adds its display as one `sendonly`
+// transceiver of its own, in a stream of its own, announces it with a
+// `share {on:true}` broadcast, and stops it by stopping the transceiver
+// and broadcasting `share {on:false}`. Nothing on the wire says which
+// m-line is the screen. Each receiver works it out, and a native app and
+// a browser looking at the same sender have to reach the same answer, or
+// one of them captions a face as a screen.
+
+/// What one arriving track is to the person looking at it.
+enum TrackRole: Equatable {
+ case audio, camera, screen
+}
+
+/// What a connection has recorded about the peer on the other end: the
+/// mid and stream its camera came in on, the stream everything else from
+/// it has arrived in, and whether it has said it is sharing. room.js's
+/// `entry` fields of the same names.
+struct ScreenClues: Equatable {
+ var cameraMid = ""
+ var cameraStream = ""
+ var peerStream = ""
+ var sharing = false
+}
+
+/// One arriving track, as the classifier needs it. `direction` is the
+/// transceiver's negotiated direction when there is one and the direction
+/// it was built with when there is not, spelled the way SDP spells it.
+struct ArrivingTrack: Equatable {
+ var kind: String
+ var streamId = ""
+ var mid = ""
+ var direction = ""
+}
+
+/// Tells a peer's screen from its camera. room.js's classifyTrack, in
+/// the same order and for the same reasons, which are set out there at
+/// length; the short form:
+///
+/// 1. A video track arriving `recvonly` on a connection that sends a
+/// camera is on an m-line the far side added, and the only one it adds
+/// is the screen.
+/// 2. The camera's own mid, once one is known.
+/// 3. The camera's stream id, for a sender that reports no mid.
+/// 4. The stream the peer's microphone came in: a sender puts its camera
+/// there and its screen somewhere else.
+///
+/// And when none of those can speak, the `share` message breaks the tie.
+/// It is last on purpose: it says the peer is showing a screen somewhere,
+/// not that this track is it.
+///
+/// `hasVideo` is whether this connection carries a camera of ours. The
+/// apps always add one, so rule 1 is the rule that decides in practice;
+/// the rest are here because a browser on the far side may not have one,
+/// and a port that dropped them would disagree with the browser the day
+/// that mattered.
+func classifyTrack(_ known: ScreenClues, _ track: ArrivingTrack, hasVideo: Bool) -> TrackRole {
+ if track.kind != "video" { return .audio }
+ if track.direction == "recvonly" && hasVideo { return .screen }
+ if !known.cameraMid.isEmpty && !track.mid.isEmpty {
+ return track.mid == known.cameraMid ? .camera : .screen
+ }
+ if !known.cameraStream.isEmpty && !track.streamId.isEmpty {
+ return track.streamId == known.cameraStream ? .camera : .screen
+ }
+ if !known.peerStream.isEmpty && !track.streamId.isEmpty {
+ return track.streamId == known.peerStream ? .camera : .screen
+ }
+ if known.cameraMid.isEmpty && known.cameraStream.isEmpty {
+ return known.sharing ? .screen : .camera
+ }
+ // A camera is known but there is nothing on this track to compare with
+ // it: a second video track is the screen.
+ return .screen
+}
+
+/// The words under a screen. The browser's, character for character —
+/// including the typographic apostrophe — so a person moving between the
+/// app and a browser tab reads the same caption on the same screen.
+public func screenCaption(_ name: String) -> String { name + "’s screen" }
+
+/// One thing the main stage can show: a person's camera or their screen.
+public enum StageItem: Hashable, Sendable {
+ case camera(String)
+ case screen(String)
+
+ public var peer: String {
+ switch self {
+ case .camera(let peer), .screen(let peer): return peer
+ }
+ }
+}
+
+public enum Stage {
+ /// What the main stage shows.
+ ///
+ /// A choice the person made stands for as long as it is still there to
+ /// show. Otherwise a screen beats a camera, and the newest screen beats
+ /// an older one: somebody who starts sharing is asking to be looked at,
+ /// and a call with a screen on it is usually a call about the screen.
+ /// With no screens it is the first camera, which is where the stage
+ /// always was.
+ public static func focus(chosen: StageItem?, peers: [String], screens: [String]) -> StageItem? {
+ if let chosen {
+ switch chosen {
+ case .camera(let peer) where peers.contains(peer): return chosen
+ case .screen(let peer) where screens.contains(peer): return chosen
+ default: break
+ }
+ }
+ if let newest = screens.last { return .screen(newest) }
+ return peers.first.map(StageItem.camera)
+ }
+}
diff --git a/native/apple/Sources/CallsKit/Wire.swift b/native/apple/Sources/CallsKit/Wire.swift
index 4659648..fd9d5a1 100644
--- a/native/apple/Sources/CallsKit/Wire.swift
+++ b/native/apple/Sources/CallsKit/Wire.swift
@@ -45,6 +45,8 @@ struct Payload: Decodable {
let description: Description?
let candidate: Candidate?
let text: String?
+ /// `share`'s one field (§5.4): whether the sender is showing a screen.
+ let on: Bool?
}
struct Description: Codable {
let type: String
diff --git a/native/apple/Tests/CallsKitTests/BroadcastTests.swift b/native/apple/Tests/CallsKitTests/BroadcastTests.swift
new file mode 100644
index 0000000..7b977cc
--- /dev/null
+++ b/native/apple/Tests/CallsKitTests/BroadcastTests.swift
@@ -0,0 +1,183 @@
+import CoreVideo
+import LiveKitWebRTC
+import XCTest
+
+@testable import CallsBroadcastKit
+@testable import CallsKit
+
+/// The iPhone's screen travels from the broadcast extension to the app
+/// over a Unix socket, and nothing about that needs an iPhone: the same
+/// sender and inbox run here, over a real socket, with real pixel
+/// buffers. What cannot run here is ReplayKit itself, the App Group
+/// container and the extension's memory ceiling — those need a device.
+final class BroadcastTests: XCTestCase {
+ private var path = ""
+
+ override func setUp() {
+ // Short, because a socket path has to fit in 104 bytes.
+ path = "/tmp/calls-\(UUID().uuidString.prefix(8)).sock"
+ }
+ override func tearDown() { unlink(path) }
+
+ func testTheHeaderRoundTripsAndRefusesWhatItCannotUse() {
+ let header = BroadcastFrameHeader(
+ width: 640, height: 360, orientation: 8, timestampNs: 123_456_789, videoRange: true)
+ var bytes = [UInt8](repeating: 0, count: BroadcastFrameHeader.size)
+ bytes.withUnsafeMutableBytes { header.write(to: $0.baseAddress!) }
+ XCTAssertEqual(bytes.withUnsafeBytes { BroadcastFrameHeader.read($0.baseAddress!) }, header)
+ // Odd sizes, absurd sizes, a length that disagrees with them, and a
+ // stranger's magic are all refused: the length is what gets allocated.
+ for (offset, value) in [(8, UInt32(641)), (12, UInt32(100_000)), (24, UInt32(12)), (0, UInt32(7))] {
+ var bad = bytes
+ bad.withUnsafeMutableBytes { $0.storeBytes(of: value.littleEndian, toByteOffset: offset, as: UInt32.self) }
+ XCTAssertNil(bad.withUnsafeBytes { BroadcastFrameHeader.read($0.baseAddress!) }, "offset \(offset)")
+ }
+ }
+
+ func testAFrameCrossesTheSocketSmallerAndIntact() throws {
+ let inbox = BroadcastInbox(path: path)
+ let connected = expectation(description: "connected")
+ let arrived = expectation(description: "frame")
+ var received: (CVPixelBuffer, UInt16)?
+ inbox.onConnection = { connection in
+ connection.onFrame = { pixels, orientation in
+ guard received == nil else { return }
+ received = (pixels, orientation)
+ arrived.fulfill()
+ }
+ connection.resume()
+ connected.fulfill()
+ }
+ try inbox.start()
+ defer { inbox.stop() }
+
+ let sender = BroadcastSender(path: path, longest: 640, framesPerSecond: 100)
+ XCTAssertTrue(sender.connect(timeout: 2))
+ defer { sender.close() }
+ wait(for: [connected], timeout: 2)
+ // A 1280x720 frame, luma 100 and chroma (60, 200) throughout, as
+ // ReplayKit's full-range bi-planar buffers are laid out.
+ sender.send(try nv12(width: 1280, height: 720), orientation: 6, timestampNs: 1)
+ wait(for: [arrived], timeout: 2)
+
+ let (pixels, orientation) = try XCTUnwrap(received)
+ XCTAssertEqual(orientation, 6)
+ XCTAssertEqual(CVPixelBufferGetWidth(pixels), 640)
+ XCTAssertEqual(CVPixelBufferGetHeight(pixels), 360)
+ XCTAssertEqual(
+ CVPixelBufferGetPixelFormatType(pixels), kCVPixelFormatType_420YpCbCr8BiPlanarFullRange)
+ CVPixelBufferLockBaseAddress(pixels, .readOnly)
+ defer { CVPixelBufferUnlockBaseAddress(pixels, .readOnly) }
+ let luma = CVPixelBufferGetBaseAddressOfPlane(pixels, 0)!.assumingMemoryBound(to: UInt8.self)
+ let chroma = CVPixelBufferGetBaseAddressOfPlane(pixels, 1)!.assumingMemoryBound(to: UInt8.self)
+ let lumaStride = CVPixelBufferGetBytesPerRowOfPlane(pixels, 0)
+ let chromaStride = CVPixelBufferGetBytesPerRowOfPlane(pixels, 1)
+ XCTAssertEqual(luma[0], 100)
+ XCTAssertEqual(luma[359 * lumaStride + 639], 100)
+ XCTAssertEqual(chroma[0], 60)
+ XCTAssertEqual(chroma[1], 200)
+ XCTAssertEqual(chroma[179 * chromaStride + 638], 60)
+ XCTAssertEqual(chroma[179 * chromaStride + 639], 200)
+ }
+
+ func testTheExtensionGoingAwayEndsTheConnection() throws {
+ let inbox = BroadcastInbox(path: path)
+ let closed = expectation(description: "closed")
+ inbox.onConnection = { connection in
+ connection.onClose = { closed.fulfill() }
+ connection.resume()
+ }
+ try inbox.start()
+ defer { inbox.stop() }
+ let sender = BroadcastSender(path: path)
+ XCTAssertTrue(sender.connect(timeout: 2))
+ sender.close()
+ wait(for: [closed], timeout: 2)
+ }
+
+ func testStoppingInTheAppReachesTheExtension() throws {
+ // The app closing its end is the whole of "stop sharing": the
+ // extension learns it from its next write, and ends the broadcast.
+ let inbox = BroadcastInbox(path: path)
+ var held: BroadcastConnection?
+ let connected = expectation(description: "connected")
+ inbox.onConnection = { connection in
+ held = connection
+ connection.resume()
+ connected.fulfill()
+ }
+ try inbox.start()
+ defer { inbox.stop() }
+ let sender = BroadcastSender(path: path, longest: 64, framesPerSecond: 1000)
+ let gone = expectation(description: "extension told")
+ sender.onClosed = { gone.fulfill() }
+ XCTAssertTrue(sender.connect(timeout: 2))
+ wait(for: [connected], timeout: 2)
+ held?.close()
+ let frame = try nv12(width: 64, height: 64)
+ let until = Date().addingTimeInterval(2)
+ while Date() < until {
+ sender.send(frame, orientation: 1, timestampNs: 0)
+ if XCTWaiter.wait(for: [gone], timeout: 0.05) == .completed { return }
+ }
+ XCTFail("the extension never learned the app had stopped")
+ }
+
+ func testOnlyOneBroadcastAtATime() throws {
+ let inbox = BroadcastInbox(path: path)
+ var connections = 0
+ let first = expectation(description: "first")
+ inbox.onConnection = { connection in
+ connections += 1
+ connection.resume()
+ first.fulfill()
+ }
+ try inbox.start()
+ defer { inbox.stop() }
+ let one = BroadcastSender(path: path)
+ XCTAssertTrue(one.connect(timeout: 2))
+ defer { one.close() }
+ wait(for: [first], timeout: 2)
+ let two = BroadcastSender(path: path)
+ let refused = expectation(description: "second refused")
+ two.onClosed = { refused.fulfill() }
+ XCTAssertTrue(two.connect(timeout: 2))
+ defer { two.close() }
+ let frame = try nv12(width: 64, height: 64)
+ let until = Date().addingTimeInterval(2)
+ while Date() < until {
+ two.send(frame, orientation: 1, timestampNs: 0)
+ if XCTWaiter.wait(for: [refused], timeout: 0.05) == .completed { break }
+ }
+ XCTAssertEqual(connections, 1)
+ }
+
+ func testReplayKitOrientationBecomesAnUprightRotation() {
+ XCTAssertEqual(broadcastRotation(1), ._0)
+ XCTAssertEqual(broadcastRotation(3), ._180)
+ XCTAssertEqual(broadcastRotation(8), ._90)
+ XCTAssertEqual(broadcastRotation(6), ._270)
+ XCTAssertEqual(broadcastRotation(0), ._0)
+ }
+
+ private func nv12(width: Int, height: Int) throws -> CVPixelBuffer {
+ var made: CVPixelBuffer?
+ CVPixelBufferCreate(
+ nil, width, height, kCVPixelFormatType_420YpCbCr8BiPlanarFullRange,
+ [kCVPixelBufferIOSurfacePropertiesKey: [:]] as CFDictionary, &made)
+ let pixels = try XCTUnwrap(made)
+ CVPixelBufferLockBaseAddress(pixels, [])
+ defer { CVPixelBufferUnlockBaseAddress(pixels, []) }
+ let luma = CVPixelBufferGetBaseAddressOfPlane(pixels, 0)!
+ memset(luma, 100, CVPixelBufferGetBytesPerRowOfPlane(pixels, 0) * height)
+ let chroma = CVPixelBufferGetBaseAddressOfPlane(pixels, 1)!.assumingMemoryBound(to: UInt8.self)
+ let stride = CVPixelBufferGetBytesPerRowOfPlane(pixels, 1)
+ for row in 0..<(height / 2) {
+ for pair in 0..<(width / 2) {
+ chroma[row * stride + pair * 2] = 60
+ chroma[row * stride + pair * 2 + 1] = 200
+ }
+ }
+ return pixels
+ }
+}
diff --git a/native/apple/Tests/CallsKitTests/ScreenShareTests.swift b/native/apple/Tests/CallsKitTests/ScreenShareTests.swift
new file mode 100644
index 0000000..78939ec
--- /dev/null
+++ b/native/apple/Tests/CallsKitTests/ScreenShareTests.swift
@@ -0,0 +1,139 @@
+import XCTest
+
+@testable import CallsBroadcastKit
+@testable import CallsKit
+
+/// §5.4's receiving half. These are room-share.test.mjs's classifyTrack
+/// cases, case for case: the browser and the apps look at the same
+/// senders, and the day they disagree about which m-line is a screen, one
+/// of them captions a face as a screen. A case added there belongs here.
+final class ScreenShareTests: XCTestCase {
+ private let nothing = ScreenClues()
+ private let known = ScreenClues(cameraMid: "0", cameraStream: "cam", peerStream: "cam")
+ private func video(_ stream: String = "", mid: String = "", direction: String = "")
+ -> ArrivingTrack
+ {
+ ArrivingTrack(kind: "video", streamId: stream, mid: mid, direction: direction)
+ }
+
+ func testAudioIsNeverAScreen() {
+ let audio = ArrivingTrack(kind: "audio", streamId: "other", mid: "9")
+ XCTAssertEqual(classifyTrack(nothing, audio, hasVideo: true), .audio)
+ XCTAssertEqual(classifyTrack(known, audio, hasVideo: true), .audio)
+ }
+
+ func testTheFirstVideoTrackIsTheCamera() {
+ XCTAssertEqual(classifyTrack(nothing, video("cam", mid: "0"), hasVideo: false), .camera)
+ XCTAssertEqual(classifyTrack(nothing, video(), hasVideo: false), .camera)
+ }
+
+ func testASecondVideoTrackOnAnotherTransceiverIsTheScreen() {
+ XCTAssertEqual(classifyTrack(known, video("screen", mid: "2"), hasVideo: false), .screen)
+ }
+
+ func testTheCameraAgainIsStillTheCamera() {
+ XCTAssertEqual(classifyTrack(known, video("cam", mid: "0"), hasVideo: false), .camera)
+ // The mid decides when there is one, whatever the stream is now called.
+ XCTAssertEqual(classifyTrack(known, video("renamed", mid: "0"), hasVideo: false), .camera)
+ }
+
+ func testWithNoMidTheStreamDecides() {
+ let byStream = ScreenClues(cameraStream: "cam")
+ XCTAssertEqual(classifyTrack(byStream, video("cam"), hasVideo: false), .camera)
+ XCTAssertEqual(classifyTrack(byStream, video("screen"), hasVideo: false), .screen)
+ XCTAssertEqual(classifyTrack(known, video("cam"), hasVideo: false), .camera)
+ XCTAssertEqual(classifyTrack(known, video("screen"), hasVideo: false), .screen)
+ }
+
+ func testASecondVideoTrackWithNothingToCompareIsTheScreen() {
+ XCTAssertEqual(classifyTrack(known, video(), hasVideo: false), .screen)
+ }
+
+ func testRecvonlyIsTheScreenWhenThisSideSendsACamera() {
+ // The rule that decides in practice here: the apps always add a
+ // camera, so a video m-line arriving recvonly is one the far side
+ // added, and a screen is the only one it adds — even as the first
+ // video track that peer ever sends.
+ XCTAssertEqual(
+ classifyTrack(nothing, video("screen", mid: "2", direction: "recvonly"), hasVideo: true),
+ .screen)
+ }
+
+ func testRecvonlySaysNothingWithoutACameraOfOurOwn() {
+ XCTAssertEqual(
+ classifyTrack(nothing, video("cam", mid: "1", direction: "recvonly"), hasVideo: false),
+ .camera)
+ }
+
+ func testASendrecvCameraIsACamera() {
+ XCTAssertEqual(
+ classifyTrack(nothing, video("cam", mid: "1", direction: "sendrecv"), hasVideo: true),
+ .camera)
+ }
+
+ func testAVideoTrackOutsideTheMicrophonesStreamIsTheScreen() {
+ let audioOnly = ScreenClues(peerStream: "theirs")
+ XCTAssertEqual(classifyTrack(audioOnly, video("screen", mid: "2"), hasVideo: false), .screen)
+ XCTAssertEqual(classifyTrack(audioOnly, video("theirs", mid: "1"), hasVideo: false), .camera)
+ }
+
+ func testTheShareMessageBreaksTheTieAndOnlyTheTie() {
+ XCTAssertEqual(classifyTrack(ScreenClues(sharing: true), video(), hasVideo: false), .screen)
+ XCTAssertEqual(classifyTrack(ScreenClues(sharing: false), video(), hasVideo: false), .camera)
+ let sharing = ScreenClues(peerStream: "theirs", sharing: true)
+ XCTAssertEqual(classifyTrack(sharing, video("theirs", mid: "1"), hasVideo: false), .camera)
+ XCTAssertEqual(
+ classifyTrack(ScreenClues(cameraMid: "1", sharing: true), video(mid: "1"), hasVideo: false),
+ .camera)
+ }
+
+ func testTheCaptionIsTheBrowsers() {
+ // Typographic apostrophe, as room.js's shareCaption writes it.
+ XCTAssertEqual(screenCaption("Bea"), "Bea\u{2019}s screen")
+ }
+
+ func testAScreenIsFittedEvenAndNeverEnlarged() {
+ // A 5K display at 2x comes down to 1920 on its long side, keeping its
+ // shape; both sides even, for the encoders' 4:2:0 chroma.
+ let big = fitted(width: 5120, height: 2880, longest: 1920)
+ XCTAssertEqual(big.width, 1920)
+ XCTAssertEqual(big.height, 1080)
+ // A small window is sent at its own size, rounded down to even.
+ let small = fitted(width: 801, height: 601, longest: 1920)
+ XCTAssertEqual(small.width, 800)
+ XCTAssertEqual(small.height, 600)
+ // A portrait phone screen is fitted on its height.
+ let phone = fitted(width: 1179, height: 2556, longest: 1280)
+ XCTAssertEqual(phone.height, 1280)
+ XCTAssertEqual(phone.width % 2, 0)
+ XCTAssertEqual(fitted(width: 0, height: 100, longest: 1920).width, 2)
+ }
+
+ // ---------------------------------------------------------------
+ // The stage
+ // ---------------------------------------------------------------
+
+ func testTheStageIsTheFirstCameraWithNoScreens() {
+ XCTAssertEqual(Stage.focus(chosen: nil, peers: ["a", "b"], screens: []), .camera("a"))
+ XCTAssertNil(Stage.focus(chosen: nil, peers: [], screens: []))
+ }
+
+ func testTheNewestScreenTakesTheStage() {
+ XCTAssertEqual(Stage.focus(chosen: nil, peers: ["a", "b"], screens: ["b"]), .screen("b"))
+ XCTAssertEqual(Stage.focus(chosen: nil, peers: ["a", "b"], screens: ["b", "a"]), .screen("a"))
+ }
+
+ func testAChoiceStandsWhileItIsThere() {
+ XCTAssertEqual(
+ Stage.focus(chosen: .camera("a"), peers: ["a", "b"], screens: ["b"]), .camera("a"))
+ XCTAssertEqual(
+ Stage.focus(chosen: .screen("a"), peers: ["a", "b"], screens: ["a", "b"]), .screen("a"))
+ }
+
+ func testAChoiceThatWentAwayFallsBack() {
+ // A stopped screen, and a person who left: neither leaves the stage
+ // empty while there is something else to show.
+ XCTAssertEqual(Stage.focus(chosen: .screen("a"), peers: ["a", "b"], screens: []), .camera("a"))
+ XCTAssertEqual(Stage.focus(chosen: .camera("c"), peers: ["a"], screens: ["a"]), .screen("a"))
+ }
+}
diff --git a/native/ios/Broadcast/SampleHandler.swift b/native/ios/Broadcast/SampleHandler.swift
new file mode 100644
index 0000000..b20a947
--- /dev/null
+++ b/native/ios/Broadcast/SampleHandler.swift
@@ -0,0 +1,61 @@
+import CallsBroadcastKit
+import CoreMedia
+import ReplayKit
+
+/// The broadcast upload extension: ReplayKit's frames in, the app's socket
+/// out. It links CallsBroadcastKit and nothing else of Calls, and holds
+/// one frame buffer, because it lives under a memory ceiling of about
+/// fifty megabytes and is killed without a word past it.
+///
+/// It never talks to the meeting. The app is already in the call and owns
+/// every peer connection; this only hands it pictures.
+final class SampleHandler: RPBroadcastSampleHandler {
+ private var sender: BroadcastSender?
+
+ override func broadcastStarted(withSetupInfo setupInfo: [String: NSObject]?) {
+ guard let group = BroadcastPath.appGroup, let path = BroadcastPath.socket(appGroup: group)
+ else {
+ finish("Calls could not share your screen on this build.")
+ return
+ }
+ let sender = BroadcastSender(path: path)
+ // Two seconds: the countdown has just ended, and the app, which is in
+ // the call, is listening already. Longer would only delay the answer
+ // to someone who started a broadcast with no call to share it to.
+ guard sender.connect(timeout: 2) else {
+ finish("Join a call in Calls, then share your screen from there.")
+ return
+ }
+ sender.onClosed = { [weak self] in
+ self?.finish("You stopped sharing your screen in Calls.")
+ }
+ self.sender = sender
+ }
+
+ override func processSampleBuffer(
+ _ sampleBuffer: CMSampleBuffer, with sampleBufferType: RPSampleBufferType
+ ) {
+ guard sampleBufferType == .video, let sender, let pixels = sampleBuffer.imageBuffer else {
+ return
+ }
+ let orientation =
+ (CMGetAttachment(sampleBuffer, key: RPVideoSampleOrientationKey as CFString, attachmentModeOut: nil)
+ as? NSNumber)?.uint16Value ?? 1
+ let time = CMTimeGetSeconds(sampleBuffer.presentationTimeStamp)
+ sender.send(pixels, orientation: orientation, timestampNs: UInt64(max(0, time) * 1_000_000_000))
+ }
+
+ override func broadcastFinished() {
+ sender?.close()
+ sender = nil
+ }
+
+ /// Ends the broadcast with a sentence the system shows the person. The
+ /// only way an extension can say anything at all.
+ private func finish(_ sentence: String) {
+ sender?.close()
+ sender = nil
+ finishBroadcastWithError(
+ NSError(domain: "Calls", code: 1, userInfo: [NSLocalizedDescriptionKey: sentence]))
+ }
+}
diff --git a/native/ios/README.md b/native/ios/README.md
index ffd30ca..30df4e8 100644
--- a/native/ios/README.md
+++ b/native/ios/README.md
@@ -49,11 +49,65 @@ available to Xcode, with Developer Mode enabled.
- Camera/microphone permission refusals appear in the app. Retry after changing
permission in Settings. The simulator has no physical camera.
-Calls are foreground-only in this milestone. Switching away ends the call;
-sign-in survives switching to the browser. Screen sharing, camera switching,
-CallKit/background calling, and distribution are follow-up
-work. Physical camera, audible audio, Bluetooth routing, rotation while capturing,
-and interruption recovery need a real-device acceptance pass.
+Calls are foreground-only in this milestone. Switching away ends the call,
+except while you are sharing your screen; sign-in survives switching to the
+browser. Camera switching, CallKit/background calling, and distribution are
+follow-up work. Physical camera, audible audio, Bluetooth routing, rotation while
+capturing, and interruption recovery need a real-device acceptance pass.
+
+## Sharing the screen
+
+**Share your screen** in the call is the system's broadcast button
+(`RPSystemBroadcastPickerView`, offering only Calls' own extension). The whole
+screen is shared, which is the only thing iOS lets any app share. The frames
+reach a **broadcast upload extension** (`Broadcast/SampleHandler.swift`, target
+`CallsBroadcast`), a separate process with a memory ceiling of about fifty
+megabytes. It links only `CallsBroadcastKit` and no WebRTC, which keeps it near
+600 KB. It shrinks each frame to 1280 on the long side, at up to fifteen a second,
+and passes it to the app over a Unix socket in the **App Group** container
+(`calls.sock`). The app is already in the call and sends the frames on the same
+`sendonly` screen transceiver the Mac and the browser use. Only one frame is in
+flight at a time; any frame that arrives while one is being written is dropped,
+never queued.
+
+Stopping works from either end. **Stop sharing** in the app closes the socket;
+the extension's next write fails and it ends the broadcast with "You stopped
+sharing your screen in Calls." Stopping from Control Center or the red status
+bar closes the socket from the other side, and the app stops the share. A
+broadcast started with no call to take it ends with "Join a call in Calls, then
+share your screen from there."
+
+A call that is sharing stays up when you leave the app, because showing other
+apps is the point. That needs the `audio` background mode. Every other call
+still ends when the app goes to the background. If the broadcast is stopped
+from outside while Calls is in the background, the call ends too.
+
+The App Group is one build setting, `CALLS_APP_GROUP` in `project.yml`
+(`group.net.amadan.oficina.calls`). It feeds both targets' entitlements and the
+`CallsAppGroup` key in both Info.plists. The extension's bundle identifier is the
+app's plus `.broadcast`. The app looks for its extension by that rule, so the
+two must stay in step.
+
+**Signing.** The simulator build is unsigned, so it has no App Group and cannot
+broadcast; ReplayKit broadcasts need a device anyway. Before the first signed
+device build, in the Apple Developer portal (Certificates, Identifiers &
+Profiles):
+
+1. Identifiers → App Groups → **+** → `group.net.amadan.oficina.calls`.
+2. Identifiers → `net.amadan.oficina.calls.ios` → enable **App Groups** →
+ choose that group. (Push stays as it is.)
+3. Identifiers → **+** → App IDs → App → `net.amadan.oficina.calls.ios.broadcast`,
+ description "Calls broadcast", with **App Groups** enabled and the same group.
+4. `CALLS_DEVELOPMENT_TEAM=<team> make iphone-device`. Automatic signing makes
+ both development profiles once the identifiers exist, and
+ `-allowProvisioningUpdates` can also create steps 1–3 for you if you would
+ rather let Xcode do it.
+
+A TestFlight or App Store build needs distribution profiles for **both** bundle
+identifiers, and the extension goes up inside the app. If the app ships under
+another identifier (the release branch uses `works.oficina.meet`), change
+`CALLS_APP_GROUP` to match, for example `group.works.oficina.meet`, and register
+`<app id>.broadcast`.
## Verification
diff --git a/native/ios/Sources/CallsPhone.swift b/native/ios/Sources/CallsPhone.swift
index 5d1d88a..136c515 100644
--- a/native/ios/Sources/CallsPhone.swift
+++ b/native/ios/Sources/CallsPhone.swift
@@ -45,7 +45,9 @@ final class CallsPhoneDelegate: NSObject, UIApplicationDelegate {
.onChange(of: scenePhase) { _, phase in
// This milestone is foreground-only. Browser sign-in is kept alive;
// an active call leaves cleanly instead of silently sending audio.
- if phase == .background && model.joining { model.leave() }
+ // The one exception is a call sharing the whole screen, whose
+ // point is the other apps the person is about to go to.
+ if phase == .background && model.joining && !model.call.sharingScreen { model.leave() }
UIApplication.shared.isIdleTimerDisabled = phase == .active && model.joining
}
.onChange(of: model.joining) { _, joining in
@@ -77,11 +79,14 @@ struct PhoneCall: View {
@State private var showingDevices = false
@State private var readChatCount = 0
@State private var grid = false
- @State private var selectedPeer: String?
+ @State private var chosen: StageItem?
@StateObject private var draft = ChatDraft()
+ @StateObject private var screenShare = PhoneScreenShare()
private var peers: [RosterPeer] { call.state?.peers.filter { $0.peer != call.state?.peer } ?? [] }
- private var focused: RosterPeer? { peers.first { $0.peer == selectedPeer } ?? peers.first }
+ private var focused: StageItem? {
+ Stage.focus(chosen: chosen, peers: peers.map(\.peer), screens: call.screens)
+ }
var body: some View {
VStack(spacing: 12) {
@@ -89,6 +94,10 @@ struct PhoneCall: View {
VStack(alignment: .leading, spacing: 4) {
Text(call.title).font(.headline).lineLimit(2)
Text(call.status).font(.caption).foregroundStyle(.secondary)
+ if call.sharingScreen {
+ Label("You are sharing your screen", systemImage: "rectangle.on.rectangle")
+ .font(.caption.bold()).foregroundStyle(.orange)
+ }
}
Spacer()
Menu {
@@ -96,11 +105,17 @@ struct PhoneCall: View {
Label("Focus", systemImage: "rectangle.inset.bottomtrailing").tag(false)
Label("Grid", systemImage: "square.grid.2x2").tag(true)
}
- if peers.count > 1 {
+ if peers.count > 1 || !call.screens.isEmpty {
Section("Focus on") {
+ ForEach(call.screens, id: \.self) { peer in
+ Button(screenCaption(call.name(of: peer))) {
+ chosen = .screen(peer)
+ grid = false
+ }
+ }
ForEach(peers) { peer in
Button(peer.name) {
- selectedPeer = peer.peer
+ chosen = .camera(peer.peer)
grid = false
}
}
@@ -120,6 +135,13 @@ struct PhoneCall: View {
if grid {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 240))], spacing: 12) {
+ ForEach(call.screens, id: \.self) { peer in
+ PhoneVideoTile(
+ track: call.remoteScreens[peer], label: screenCaption(call.name(of: peer)),
+ screen: true
+ )
+ .aspectRatio(4 / 3, contentMode: .fit)
+ }
PhoneVideoTile(
track: call.localTrack, label: "You", mirrored: true, cameraOff: call.cameraOff
)
@@ -136,11 +158,16 @@ struct PhoneCall: View {
} else {
GeometryReader { geometry in
ZStack(alignment: .bottomTrailing) {
- if let peer = focused {
+ switch focused {
+ case .screen(let peer):
PhoneVideoTile(
- track: call.remoteTracks[peer.peer], label: peer.name,
- muted: call.isMuted(peer.peer), cameraOff: call.isCameraOff(peer.peer))
- } else {
+ track: call.remoteScreens[peer], label: screenCaption(call.name(of: peer)),
+ screen: true)
+ case .camera(let peer):
+ PhoneVideoTile(
+ track: call.remoteTracks[peer], label: call.name(of: peer),
+ muted: call.isMuted(peer), cameraOff: call.isCameraOff(peer))
+ case nil:
ContentUnavailableView(
"Waiting for others", systemImage: "person.2",
description: Text("Others will appear here when they join.")
@@ -187,6 +214,17 @@ struct PhoneCall: View {
systemImage: call.cameraOff ? "video.slash.fill" : "video.fill"
) { call.toggleCamera() }
.buttonStyle(CallActionStyle(color: .blue, emphasized: call.cameraOff))
+ if call.sharingScreen {
+ Button("Stop sharing your screen", systemImage: "rectangle.on.rectangle.slash") {
+ call.stopSharingScreen()
+ }
+ .buttonStyle(CallActionStyle(color: .orange, emphasized: true))
+ .accessibilityIdentifier("shareScreen")
+ } else {
+ BroadcastButton().frame(width: 44, height: 44)
+ .disabled(call.state?.phase != "admitted")
+ .accessibilityIdentifier("shareScreen")
+ }
Button("Devices", systemImage: "slider.horizontal.3") { showingDevices = true }
.buttonStyle(CallActionStyle(color: .teal))
.accessibilityIdentifier("devices")
@@ -207,6 +245,15 @@ struct PhoneCall: View {
}.padding(16).background(.bar)
}
.task { await call.watchAudio() }
+ .onAppear { screenShare.listen(for: call, leave: leave) }
+ .onDisappear {
+ screenShare.stop()
+ call.stopSharingScreen()
+ }
+ .onChange(of: call.screens) { before, after in
+ // A new screen takes the stage, as it does on the Mac.
+ if after.contains(where: { !before.contains($0) }) { chosen = nil }
+ }
.sheet(isPresented: $showingDevices) {
NavigationStack {
ScrollView { DevicePanel(call: call).frame(maxWidth: .infinity, alignment: .leading) }
@@ -242,14 +289,21 @@ struct PhoneVideoTile: View {
var muted = false
var mirrored = false
var cameraOff = false
+ /// A shared screen (§5.4), shown whole rather than cropped: on a phone
+ /// more than anywhere, cropping a laptop's screen to a portrait tile
+ /// would leave the middle third of it.
+ var screen = false
var body: some View {
ZStack {
Color.black
if let track, !cameraOff {
- PhoneVideo(track: track).scaleEffect(x: mirrored ? -1 : 1, y: 1)
+ PhoneVideo(track: track, fit: screen).scaleEffect(x: mirrored ? -1 : 1, y: 1)
} else {
- Image(systemName: cameraOff ? "video.slash" : "person.crop.circle")
- .font(.largeTitle).foregroundStyle(.white.opacity(0.8))
+ Image(
+ systemName: cameraOff
+ ? "video.slash" : screen ? "rectangle.on.rectangle" : "person.crop.circle"
+ )
+ .font(.largeTitle).foregroundStyle(.white.opacity(0.8))
}
}
.overlay(alignment: .bottomLeading) {
@@ -261,19 +315,24 @@ struct PhoneVideoTile: View {
.clipShape(RoundedRectangle(cornerRadius: 18))
.accessibilityElement(children: .ignore)
.accessibilityLabel(tileAccessibilityLabel(label, muted: muted))
- .accessibilityValue(cameraOff ? "Camera off" : track == nil ? "Waiting for video" : "Video on")
+ .accessibilityValue(
+ cameraOff
+ ? "Camera off"
+ : track == nil ? (screen ? "Waiting for the screen" : "Waiting for video") : "Video on")
}
}
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 = .scaleAspectFill
+ view.videoContentMode = fit ? .scaleAspectFit : .scaleAspectFill
context.coordinator.attach(track, to: view)
return view
}
func updateUIView(_ view: RTCMTLVideoView, context: Context) {
+ view.videoContentMode = fit ? .scaleAspectFit : .scaleAspectFill
context.coordinator.attach(track, to: view)
}
static func dismantleUIView(_ view: RTCMTLVideoView, coordinator: Coordinator) {
diff --git a/native/ios/Sources/PhoneScreenShare.swift b/native/ios/Sources/PhoneScreenShare.swift
new file mode 100644
index 0000000..30b460d
--- /dev/null
+++ b/native/ios/Sources/PhoneScreenShare.swift
@@ -0,0 +1,88 @@
+import CallsBroadcastKit
+import CallsKit
+import ReplayKit
+import SwiftUI
+
+/// Listens for the broadcast extension for as long as a call is on screen,
+/// and turns each broadcast into the call's screen share.
+///
+/// The order is the reverse of the Mac's. There, the button asks and the
+/// capture answers; here the person starts a broadcast at the system's
+/// sheet, the extension connects, and only then does the call learn there
+/// is a screen to send. A broadcast that arrives when the call cannot take
+/// it — not yet admitted, or already sharing — is closed at once, and the
+/// extension ends it with a sentence of its own.
+@MainActor final class PhoneScreenShare: ObservableObject {
+ private var inbox: BroadcastInbox?
+
+ func listen(for call: Call, leave: @escaping () -> Void) {
+ guard inbox == nil, let group = BroadcastPath.appGroup,
+ let path = BroadcastPath.socket(appGroup: group)
+ else { return }
+ let inbox = BroadcastInbox(path: path)
+ inbox.onConnection = { [weak call] connection in
+ MainActor.assumeIsolated {
+ guard let call, call.state?.phase == "admitted", !call.sharingScreen else {
+ connection.close()
+ return
+ }
+ let capture = BroadcastScreenCapture(connection)
+ // A broadcast stopped from Control Center while the app is in the
+ // background ends the only reason the call was still up there.
+ capture.afterEnded = {
+ if UIApplication.shared.applicationState == .background { leave() }
+ }
+ Task { await call.shareScreen(capture) }
+ }
+ }
+ do {
+ try inbox.start()
+ self.inbox = inbox
+ } catch {
+ // No App Group on this build (the simulator's unsigned one, say):
+ // the button still shows, and the extension says it could not
+ // reach a call. Nothing else in the call depends on this.
+ }
+ }
+
+ func stop() {
+ inbox?.stop()
+ inbox = nil
+ }
+}
+
+/// The system's broadcast button, dressed as the call's other controls.
+///
+/// RPSystemBroadcastPickerView is the only way an app may start a
+/// broadcast, and it only responds to a tap on its own button. So the
+/// picker is the button: its image is replaced with the call's symbol and
+/// tint, and it is sized like a CallActionStyle control beside the others.
+/// `preferredExtension` makes the sheet offer Calls' own extension rather
+/// than every broadcast service on the phone.
+struct BroadcastButton: UIViewRepresentable {
+ static let extensionIdentifier = Bundle.main.bundleIdentifier.map { $0 + ".broadcast" }
+
+ func makeUIView(context: Context) -> RPSystemBroadcastPickerView {
+ let picker = RPSystemBroadcastPickerView(frame: CGRect(x: 0, y: 0, width: 44, height: 44))
+ picker.preferredExtension = Self.extensionIdentifier
+ picker.showsMicrophoneButton = false
+ picker.accessibilityLabel = "Share your screen"
+ style(picker)
+ return picker
+ }
+ func updateUIView(_ picker: RPSystemBroadcastPickerView, context: Context) { style(picker) }
+
+ private func style(_ picker: RPSystemBroadcastPickerView) {
+ guard let button = picker.subviews.compactMap({ $0 as? UIButton }).first else { return }
+ let symbol = UIImage(
+ systemName: "rectangle.on.rectangle",
+ withConfiguration: UIImage.SymbolConfiguration(textStyle: .title3))
+ button.setImage(symbol, for: .normal)
+ button.tintColor = .systemOrange
+ button.backgroundColor = UIColor.systemOrange.withAlphaComponent(0.12)
+ button.layer.cornerRadius = 14
+ button.accessibilityLabel = "Share your screen"
+ button.frame = picker.bounds
+ button.autoresizingMask = [.flexibleWidth, .flexibleHeight]
+ }
+}
diff --git a/native/ios/project.yml b/native/ios/project.yml
index 7b6766a..33be626 100644
--- a/native/ios/project.yml
+++ b/native/ios/project.yml
@@ -12,6 +12,11 @@ settings:
CODE_SIGN_STYLE: Automatic
MARKETING_VERSION: '0.2.0'
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
+ # feeds both entitlements and both Info.plists' CallsAppGroup key, so
+ # a build under another bundle identifier changes it here and only here.
+ CALLS_APP_GROUP: group.net.amadan.oficina.calls
targets:
CallsPhone:
type: application
@@ -29,9 +34,19 @@ targets:
CFBundleURLTypes:
- CFBundleURLName: net.amadan.oficina.calls.join
CFBundleURLSchemes: [oficina-calls]
+ CallsAppGroup: $(CALLS_APP_GROUP)
+ # Audio, so a call sharing the whole screen survives the person
+ # going to the app they meant to show. Only a call that is sharing
+ # stays up in the background; every other call still ends there.
+ UIBackgroundModes: [audio]
+ entitlements:
+ path: ../build/ios-app.entitlements
+ properties:
+ com.apple.security.application-groups: [$(CALLS_APP_GROUP)]
dependencies:
- package: CallsNative
product: CallsKit
+ - target: CallsBroadcast
settings:
base:
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
@@ -43,6 +58,30 @@ targets:
INFOPLIST_KEY_NSMicrophoneUsageDescription: Calls uses your microphone so people in your meeting can hear you.
INFOPLIST_KEY_UILaunchScreen_Generation: YES
INFOPLIST_KEY_UISupportedInterfaceOrientations: UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortraitUpsideDown
+ CallsBroadcast:
+ type: app-extension
+ platform: iOS
+ sources: [Broadcast]
+ info:
+ path: ../build/ios-broadcast-info.plist
+ properties:
+ CFBundleDisplayName: Calls
+ CallsAppGroup: $(CALLS_APP_GROUP)
+ NSExtension:
+ NSExtensionPointIdentifier: com.apple.broadcast-services-upload
+ NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).SampleHandler
+ RPBroadcastProcessMode: RPBroadcastProcessModeSampleBuffer
+ entitlements:
+ path: ../build/ios-broadcast.entitlements
+ properties:
+ com.apple.security.application-groups: [$(CALLS_APP_GROUP)]
+ dependencies:
+ - package: CallsNative
+ product: CallsBroadcastKit
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: net.amadan.oficina.calls.ios.broadcast
+ TARGETED_DEVICE_FAMILY: '1,2'
CallsPhoneTests:
type: bundle.unit-test
platform: iOS
diff --git a/test/browser/native.mjs b/test/browser/native.mjs
index 7227503..5eb05eb 100644
--- a/test/browser/native.mjs
+++ b/test/browser/native.mjs
@@ -9,6 +9,9 @@ const browser = await chromium.launch({ headless: true, args: [
'--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream',
'--autoplay-policy=no-user-gesture-required', '--disable-features=WebRtcHideLocalIpsWithMdns',
'--mute-audio',
+ // §5.4: answers getDisplayMedia with a fake monitor that really
+ // produces frames, as call.mjs does for the browser-only share gate.
+ '--auto-accept-this-tab-capture',
] });
const context = await browser.newContext({ permissions: ['camera', 'microphone'] });
await context.addCookies([{ name: process.env.CALLS_COOKIE_NAME, value: process.env.CALLS_COOKIE_A, url: origin }]);
@@ -161,6 +164,97 @@ try {
await wait(async () => !(await nativeMarked()) && sample?.remoteMuted === 0, 'both unmuted again');
console.log('PASS mute state crosses between native and browser both ways');
}
+ // The screen scenarios run after chat and not before: they take the
+ // better part of a minute, and chat is not replayed to a recipient
+ // whose stream is renewing, which in this mode is every ten seconds.
+ // Screen share does not depend on its message arriving — the m-line
+ // is classified on its own, and a stopped transceiver ends the track —
+ // so it can share the call with renewals where chat cannot.
+ // §5.4, receiving: the browser shares its screen and the app shows it
+ // as a tile of its own, captioned with the sharer's name, while the
+ // browser's camera keeps arriving on the tile it always had. Then the
+ // browser stops, and the tile goes — by the `share {on:false}` or the
+ // stopped transceiver, whichever lands first — with the camera still
+ // arriving afterwards.
+ if (mode === 'member') {
+ const name = await page.evaluate(() => document.getElementById('room')?.dataset.selfName || '');
+ assert.notEqual(name, '', 'the browser has a name for its screen to be captioned with');
+ const caption = name + '\u2019s screen';
+ await page.click('#share');
+ await wait(() => sample?.screenCaptions?.length === 1 && sample.screenCaptions[0] === caption,
+ 'native captions the browser\u2019s screen with its name');
+ await wait(() => sample?.stats.remoteScreens === 1, 'the browser\u2019s screen reaches the app as a track');
+ const screenBase = sample.stats.screenFramesDecoded;
+ await wait(() => sample.stats.screenFramesDecoded > screenBase + 15, 'native decodes the browser\u2019s screen');
+ const cameraBase = sample.stats.framesDecoded;
+ await wait(() => sample.stats.framesDecoded > cameraBase + 15, 'the browser\u2019s camera keeps arriving beside its screen');
+ assert.equal(sample.stats.remoteTracks, 1, 'the screen is not taken for a second camera');
+ console.log('PASS native shows the browser\u2019s screen as its own captioned tile');
+ await page.click('#share');
+ await wait(() => sample?.screenCaptions?.length === 0 && sample.stats.remoteScreens === 0 && sample.stats.screens === 0,
+ 'native removes the browser\u2019s screen when it stops');
+ const afterStop = sample.stats.framesDecoded;
+ await wait(() => sample.stats.framesDecoded > afterStop + 15, 'the browser\u2019s camera survives its screen stopping');
+ console.log('PASS native removes a stopped screen and keeps the camera');
+ }
+ // §5.4, sending: the app shares a (synthetic) screen and the browser
+ // shows it exactly as it shows another browser's — a tile of its own
+ // under shareKey, captioned with the app's name, decoding real frames
+ // while the app's camera keeps arriving. Stopping must *end* the
+ // browser's received track, which only a stopped transceiver and a
+ // renegotiation do; a message alone would leave a frozen frame behind
+ // on a browser that never got it. Then the capture ending on its own,
+ // the path the system's stop control takes, ends the share the same
+ // way.
+ if (mode === 'member') {
+ const nativePeer = sample.peer;
+ const key = nativePeer + '#share';
+ const browserState = () => page.evaluate(() => {
+ const s = window.meet.state();
+ return { shares: s.shares, frames: Object.fromEntries(s.frames) };
+ });
+ const tiles = () => page.evaluate(() => ({
+ camera: [...document.querySelectorAll('#tiles figure.calls-tile:not(.calls-tile-self):not(.calls-tile-share) figcaption span')]
+ .map(e => e.textContent.trim()).filter(Boolean)[0] || '',
+ screens: [...document.querySelectorAll('#tiles figure.calls-tile-share figcaption span')]
+ .map(e => e.textContent.trim()).filter(Boolean),
+ }));
+ for (const ending of ['screen-off', 'screen-end']) {
+ native.stdin.write('screen-on\n');
+ await wait(() => sample?.sharingScreen === true, 'native says it is sharing');
+ await wait(async () => (await browserState()).shares.includes(nativePeer), 'the browser is told whose screen is coming');
+ const { camera } = await tiles();
+ assert.notEqual(camera, '', 'the browser captions the app\u2019s camera');
+ await wait(async () => { const t = await tiles(); return t.screens.length === 1 && t.screens[0] === camera + '\u2019s screen'; },
+ 'the browser captions the app\u2019s screen with its name');
+ const screenBase = (await browserState()).frames[key] || 0;
+ await wait(async () => ((await browserState()).frames[key] || 0) > screenBase + 15, 'the browser decodes the app\u2019s screen');
+ const cameraBase = (await browserState()).frames[nativePeer] || 0;
+ await wait(async () => ((await browserState()).frames[nativePeer] || 0) > cameraBase + 15,
+ 'the app\u2019s camera keeps arriving beside its screen');
+ await wait(() => sample.stats.screenBytesSent > 0, 'the app counts its screen apart from its camera');
+ console.log('PASS the browser shows the app\u2019s screen as its own captioned tile');
+ const armed = await page.evaluate(() => {
+ const v = document.querySelector('#tiles figure.calls-tile-share video');
+ const track = v && v.srcObject ? v.srcObject.getVideoTracks()[0] : null;
+ if (!track) return null;
+ window.__nativeShareEnded = false;
+ track.addEventListener('ended', () => { window.__nativeShareEnded = true; });
+ return track.readyState;
+ });
+ assert.equal(armed, 'live', 'the received screen track is live and watched for its ending');
+ native.stdin.write(ending + '\n');
+ await wait(() => sample?.sharingScreen === false, 'native says it has stopped');
+ await wait(async () => (await browserState()).shares.length === 0 && (await tiles()).screens.length === 0,
+ 'the browser removes the app\u2019s screen');
+ await wait(() => page.evaluate(() => window.__nativeShareEnded === true),
+ 'the browser\u2019s received screen track ended rather than froze');
+ const afterStop = (await browserState()).frames[nativePeer] || 0;
+ await wait(async () => ((await browserState()).frames[nativePeer] || 0) > afterStop + 15,
+ 'the app\u2019s camera survives its screen stopping');
+ console.log('PASS the app\u2019s screen ended in the browser (' + ending + ') and its camera continued');
+ }
+ }
if (mode === 'chat') {
native.stdin.write('share\n');
await wait(() => invitationReady, 'native member retrieves the original invitation');