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>
27 files changed,
+2595
−61
Makefile+10 −2internal/callstest/native_browser_test.go+216 −1native/DESIGN.md+2 −2native/README.md+49 −2native/apple/Package.swift+7 −2native/apple/Sources/CallsBroadcastKit/BroadcastInbox.swift+215 −0native/apple/Sources/CallsBroadcastKit/BroadcastSender.swift+170 −0native/apple/Sources/CallsBroadcastKit/BroadcastWire.swift+193 −0native/apple/Sources/CallsInterop/Interop.swift+30 −1native/apple/Sources/CallsInterop/SyntheticVideo.swift+60 −0native/apple/Sources/CallsKit/BroadcastScreenCapture.swift+70 −0native/apple/Sources/CallsKit/Call.swift+104 −1native/apple/Sources/CallsKit/Capture.swift+14 −0native/apple/Sources/CallsKit/MacCallView.swift+84 −16native/apple/Sources/CallsKit/MacScreenCapture.swift+231 −0native/apple/Sources/CallsKit/MacVideoTile.swift+28 −9native/apple/Sources/CallsKit/Media.swift+254 −6native/apple/Sources/CallsKit/ScreenShare.swift+120 −0native/apple/Sources/CallsKit/Wire.swift+2 −0native/apple/Tests/CallsKitTests/BroadcastTests.swift+183 −0native/apple/Tests/CallsKitTests/ScreenShareTests.swift+139 −0native/ios/Broadcast/SampleHandler.swift+61 −0native/ios/README.md+59 −5native/ios/Sources/CallsPhone.swift+73 −14native/ios/Sources/PhoneScreenShare.swift+88 −0native/ios/project.yml+39 −0test/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(¤t) ? 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'); |