| 1 | import CoreGraphics |
| 2 | import 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 | |
| 34 | public 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. |
| 98 | public 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 | |
| 115 | enum 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. |
| 188 | public 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 | |