oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1import CoreGraphics
2import Foundation
3
4// ---------------------------------------------------------------
5// The iPhone's screen, from the broadcast extension to the call
6// ---------------------------------------------------------------
7//
8// Sharing the whole iPhone screen is a ReplayKit broadcast, and ReplayKit
9// hands the frames to an extension — a separate process, capped at about
10// fifty megabytes, that cannot hold the call's peer connections and could
11// not afford WebRTC if it did. So the extension does the least it can:
12// shrinks each frame and passes it to the app, which is already in the
13// call, over a Unix socket in the App Group container both of them can
14// reach. The app listens for as long as a call is up; the extension
15// connecting *is* the broadcast starting, and either side closing is it
16// ending.
17//
18// This module is everything the two sides share, and nothing else: no
19// WebRTC, no UIKit, no ReplayKit. The extension links this and only this.
20//
21// One frame on the wire is a 32-byte header and then the picture, NV12
22// packed tight: the luma plane, `width` bytes a row, then the interleaved
23// chroma plane at half height, also `width` bytes a row. Little-endian.
24//
25// 0 magic u32 "CSF1"
26// 4 version u16 1
27// 6 orientation u16 CGImagePropertyOrientation, as ReplayKit says it
28// 8 width u32 even, 2...4096
29// 12 height u32 even, 2...4096
30// 16 timestamp u64 nanoseconds, the extension's clock
31// 24 length u32 width * height * 3 / 2
32// 28 flags u32 bit 0: video range (16-235) rather than full
33
34public struct BroadcastFrameHeader: Equatable, Sendable {
35 public static let size = 32
36 static let magic: UInt32 = 0x3146_5343 // "CSF1" read little-endian
37 static let version: UInt16 = 1
38 static let largest = 4096
39
40 public var width: Int
41 public var height: Int
42 public var orientation: UInt16
43 public var timestampNs: UInt64
44 /// Whether the luma runs 16-235 rather than 0-255. ReplayKit has sent
45 /// both over the years, and a picture decoded with the wrong one is
46 /// washed out or crushed.
47 public var videoRange: Bool
48
49 public init(
50 width: Int, height: Int, orientation: UInt16, timestampNs: UInt64, videoRange: Bool = false
51 ) {
52 self.width = width
53 self.height = height
54 self.orientation = orientation
55 self.timestampNs = timestampNs
56 self.videoRange = videoRange
57 }
58
59 public var payloadLength: Int { width * height * 3 / 2 }
60
61 public func write(to bytes: UnsafeMutableRawPointer) {
62 bytes.storeBytes(of: Self.magic.littleEndian, toByteOffset: 0, as: UInt32.self)
63 bytes.storeBytes(of: Self.version.littleEndian, toByteOffset: 4, as: UInt16.self)
64 bytes.storeBytes(of: orientation.littleEndian, toByteOffset: 6, as: UInt16.self)
65 bytes.storeBytes(of: UInt32(width).littleEndian, toByteOffset: 8, as: UInt32.self)
66 bytes.storeBytes(of: UInt32(height).littleEndian, toByteOffset: 12, as: UInt32.self)
67 bytes.storeBytes(of: timestampNs.littleEndian, toByteOffset: 16, as: UInt64.self)
68 bytes.storeBytes(of: UInt32(payloadLength).littleEndian, toByteOffset: 24, as: UInt32.self)
69 bytes.storeBytes(of: UInt32(videoRange ? 1 : 0).littleEndian, toByteOffset: 28, as: UInt32.self)
70 }
71
72 /// Reads a header, or nil for anything that is not one this side can
73 /// use. The socket is in a container only this app's own processes can
74 /// open, so a bad header is a bug or a version skew rather than an
75 /// attack — but it is still refused rather than trusted, because the
76 /// length in it is what the reader allocates.
77 public static func read(_ bytes: UnsafeRawPointer) -> BroadcastFrameHeader? {
78 func u32(_ at: Int) -> UInt32 { UInt32(littleEndian: bytes.loadUnaligned(fromByteOffset: at, as: UInt32.self)) }
79 func u16(_ at: Int) -> UInt16 { UInt16(littleEndian: bytes.loadUnaligned(fromByteOffset: at, as: UInt16.self)) }
80 guard u32(0) == magic, u16(4) == version else { return nil }
81 let width = Int(u32(8))
82 let height = Int(u32(12))
83 guard (2...largest).contains(width), (2...largest).contains(height),
84 width % 2 == 0, height % 2 == 0
85 else { return nil }
86 let header = BroadcastFrameHeader(
87 width: width, height: height, orientation: u16(6),
88 timestampNs: UInt64(littleEndian: bytes.loadUnaligned(fromByteOffset: 16, as: UInt64.self)),
89 videoRange: u32(28) & 1 == 1)
90 guard Int(u32(24)) == header.payloadLength else { return nil }
91 return header
92 }
93}
94
95/// Where the socket lives: one well-known name in the App Group container,
96/// short because a Unix socket's path has to fit in 104 bytes, and the
97/// container's own path on a device already takes about ninety.
98public enum BroadcastPath {
99 public static let name = "calls.sock"
100
101 /// The App Group this build shares with its extension, from the
102 /// `CallsAppGroup` key both Info.plists carry. One build setting feeds
103 /// the key and the entitlement, so a build with a different bundle
104 /// identifier changes the group in one place.
105 public static var appGroup: String? {
106 Bundle.main.object(forInfoDictionaryKey: "CallsAppGroup") as? String
107 }
108
109 public static func socket(appGroup: String) -> String? {
110 FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup)?
111 .appendingPathComponent(name).path
112 }
113}
114
115enum BroadcastSocket {
116 struct Failure: Error, CustomStringConvertible {
117 let description: String
118 }
119
120 /// A Unix-socket address for `path`, or nil if the path does not fit.
121 static func address(_ path: String) -> sockaddr_un? {
122 var address = sockaddr_un()
123 address.sun_family = sa_family_t(AF_UNIX)
124 let bytes = Array(path.utf8)
125 let capacity = MemoryLayout.size(ofValue: address.sun_path)
126 guard bytes.count < capacity else { return nil }
127 withUnsafeMutableBytes(of: &address.sun_path) { raw in
128 raw.copyBytes(from: bytes)
129 raw[bytes.count] = 0
130 }
131 address.sun_len = UInt8(MemoryLayout<sockaddr_un>.size)
132 return address
133 }
134
135 /// A socket that will not raise SIGPIPE when the far side has gone: a
136 /// write to a closed peer answers EPIPE instead, which is the ending
137 /// both sides are listening for, rather than killing the process.
138 static func make() throws -> Int32 {
139 let fd = Darwin.socket(AF_UNIX, SOCK_STREAM, 0)
140 guard fd >= 0 else { throw Failure(description: "socket: \(errno)") }
141 var on: Int32 = 1
142 setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &on, socklen_t(MemoryLayout<Int32>.size))
143 return fd
144 }
145
146 static func listen(path: String) throws -> Int32 {
147 guard var address = address(path) else { throw Failure(description: "socket path too long") }
148 unlink(path)
149 let fd = try make()
150 let bound = withUnsafePointer(to: &address) {
151 $0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
152 bind(fd, $0, socklen_t(MemoryLayout<sockaddr_un>.size))
153 }
154 }
155 guard bound == 0, Darwin.listen(fd, 1) == 0 else {
156 let code = errno
157 Darwin.close(fd)
158 throw Failure(description: "listen: \(code)")
159 }
160 return fd
161 }
162
163 static func connect(path: String) throws -> Int32 {
164 guard var address = address(path) else { throw Failure(description: "socket path too long") }
165 let fd = try make()
166 let connected = withUnsafePointer(to: &address) {
167 $0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
168 Darwin.connect(fd, $0, socklen_t(MemoryLayout<sockaddr_un>.size))
169 }
170 }
171 guard connected == 0 else {
172 let code = errno
173 Darwin.close(fd)
174 throw Failure(description: "connect: \(code)")
175 }
176 return fd
177 }
178
179 static func nonBlocking(_ fd: Int32) {
180 _ = fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK)
181 }
182}
183
184/// A frame size that fits within `longest` on its long side, keeps its
185/// shape, and is even in both directions, which is what 4:2:0 chroma and
186/// the encoders need. The Mac's screen capture and the iPhone's broadcast
187/// both size their frames with it.
188public func fitted(width: CGFloat, height: CGFloat, longest: CGFloat) -> (width: Int, height: Int) {
189 guard width > 0, height > 0 else { return (2, 2) }
190 let scale = min(1, longest / max(width, height))
191 func even(_ value: CGFloat) -> Int { max(2, Int((value * scale).rounded(.down)) & ~1) }
192 return (even(width), even(height))
193}
194