import Foundation /// 广播扩展 → 主 App 的音频通道(Unix domain socket,客户端一侧)。 /// /// ## 为什么用 socket 而不是共享文件 /// App Group 的共享容器确实两边都能读写,但音频是**连续流**:用文件就要自己做 /// 环形缓冲 + 读写位点同步 + 回绕,而且主 App 只能轮询,白白加一截延迟。 /// Unix domain socket 是内核帮我们做好的有序字节流,两边都在同一台设备上, /// 没有任何网络开销。这也是各家屏幕共享 SDK(Agora / LiveKit 等)的标准做法。 /// /// ## ⚠️ sun_path 只有 104 字节 /// App Group 容器路径形如 /// `/private/var/mobile/Containers/Shared/AppGroup//`(约 70 字符), /// 加上文件名还剩得下,但**不能再往下建子目录**,否则会静默截断、 /// 连到一个不存在的路径上,表现为「广播开着但主 App 一个字节都收不到」。 enum BroadcastAudio { /// ⚠️ 改这里必须同时改三处:主 App 的 Runner.entitlements、 /// 本扩展的 entitlements、以及 Apple 开发者后台注册的 App Group。 /// 少改一处的表现是 `containerURL` 返回 nil,同样是静默无声。 static let appGroupId = "group.com.yimai.eaimar" /// 不带子目录,直接放在容器根下,见上面 sun_path 的说明 static let socketName = "bcast_audio.sock" static var socketPath: String? { guard let dir = FileManager.default .containerURL(forSecurityApplicationGroupIdentifier: appGroupId) else { return nil } return dir.appendingPathComponent(socketName).path } } /// 扩展侧的发送端。非阻塞连接 + 失败自动重连。 final class BroadcastAudioSender { private var fd: Int32 = -1 private var lastConnectAttempt: TimeInterval = 0 /// 连不上时的重试间隔。 /// /// ⚠️ 不能每帧都试:音频帧是几十毫秒一个,每帧发起一次 connect /// 在主 App 没起来时会把扩展的 CPU 打满,而广播扩展的内存/CPU 预算 /// 比普通进程紧得多(内存上限 50MB,超了直接被系统杀掉)。 private let retryInterval: TimeInterval = 1.0 private func ensureConnected() -> Bool { if fd >= 0 { return true } let now = Date().timeIntervalSince1970 if now - lastConnectAttempt < retryInterval { return false } lastConnectAttempt = now guard let path = BroadcastAudio.socketPath else { return false } let s = socket(AF_UNIX, SOCK_STREAM, 0) if s < 0 { return false } var addr = sockaddr_un() addr.sun_family = sa_family_t(AF_UNIX) let pathBytes = Array(path.utf8) // 留一个字节给结尾的 \0 guard pathBytes.count < MemoryLayout.size(ofValue: addr.sun_path) else { close(s) return false } withUnsafeMutablePointer(to: &addr.sun_path) { ptr in ptr.withMemoryRebound(to: CChar.self, capacity: pathBytes.count + 1) { dst in for (i, b) in pathBytes.enumerated() { dst[i] = CChar(bitPattern: b) } dst[pathBytes.count] = 0 } } let ok = withUnsafePointer(to: &addr) { p -> Bool in p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in connect(s, sa, socklen_t(MemoryLayout.size)) == 0 } } if !ok { close(s) return false } // 主 App 挂掉时写入会收到 SIGPIPE,默认行为是**直接杀死本进程**。 // 设成返回 EPIPE 错误,交给下面的 send 自己处理重连。 var nosigpipe: Int32 = 1 setsockopt(s, SOL_SOCKET, SO_NOSIGPIPE, &nosigpipe, socklen_t(MemoryLayout.size)) fd = s return true } private func disconnect() { if fd >= 0 { close(fd) } fd = -1 } /// 发一帧 PCM。帧格式:4 字节小端长度 + 负载。 /// /// 加长度前缀是因为 SOCK_STREAM **不保留消息边界**:接收端读到的可能是 /// 半帧、也可能是两帧粘在一起。不分帧的话喂给 ASR 的就是错位的样本。 func send(_ data: Data) { guard ensureConnected() else { return } var header = UInt32(data.count).littleEndian var payload = Data(bytes: &header, count: 4) payload.append(data) let written: Int = payload.withUnsafeBytes { raw -> Int in guard let base = raw.baseAddress else { return -1 } var offset = 0 while offset < payload.count { let n = write(fd, base.advanced(by: offset), payload.count - offset) if n <= 0 { return -1 } offset += n } return offset } if written < 0 { disconnect() } } deinit { disconnect() } }