import Foundation /// 广播扩展 → 主 App 的音频通道(**环回 TCP**,客户端一侧)。 /// /// ## 为什么不用 App Group + Unix socket /// 那是更常规的做法,但它要求在苹果开发者后台登记 App Group、给主 App 开 /// App Groups capability,并让 Xcode 能连上 appleid/developerservices2 /// 重新签发 profile。多一道跨团队的审批,出问题时还完全不在代码里。 /// 环回 TCP 不需要任何 entitlement:扩展和主 App 在同一台设备上, /// 127.0.0.1 天然互通。 /// /// ⚠️ **127.0.0.1 不会触发 iOS 的「本地网络」隐私弹窗**——那个弹窗只针对 /// 局域网访问,环回是明确豁免的。所以也不需要 NSLocalNetworkUsageDescription。 /// /// ## ⚠️ 端口写死的取舍 /// 没有 App Group 就没有地方发布「主 App 这次监听在哪个端口」,所以只能约定 /// 一个固定端口。代价是理论上会和别的进程撞端口;选了一个冷门高位端口, /// 手机上几乎不会有别的东西在环回口监听。撞了的表现是主 App bind 失败, /// 日志里会明说,不是静默失败。 enum BroadcastAudio { /// ⚠️ 改这里必须同时改主 App 的 BroadcastAudioReceiver.swift,两边是手工同步的 static let port: UInt16 = 52781 } /// 扩展侧的发送端。连接失败会限流重试。 final class BroadcastAudioSender { private var fd: Int32 = -1 private var lastConnectAttempt: TimeInterval = 0 /// 曾经连上过主 App。没连上过就断开不算「主 App 收工了」—— /// 那是用户先开广播、还没点开始翻译的正常情况。 private var everConnected = false /// 从「曾经连上」变成断开的时刻 private var disconnectedAt: TimeInterval? /// 断开多久之后判定主 App 已经收工。 /// /// ⚠️ 不能设太短:主 App 短暂卡顿或切换时可能瞬断,误判会把用户正在用的 /// 广播直接掐掉。也不能太长,否则点了「结束」还要等半天屏幕上那条红条才消失。 private let peerGoneGrace: TimeInterval = 3.0 /// 判定主 App 已收工时回调一次(由 SampleHandler 用来结束广播) var onPeerGone: (() -> Void)? private var peerGoneFired = false /// 供外部定时轮询:即使当前没有音频帧进来,也能发现主 App 已经走了。 func checkPeerAlive() { _ = ensureConnected() } /// 连不上时的重试间隔。 /// /// ⚠️ 不能每帧都试:音频帧几十毫秒一个,主 App 没起来时每帧发起一次 /// connect 会把扩展的 CPU 打满,而广播扩展的内存上限只有 **50MB**, /// 超了系统直接杀进程(表现是广播莫名其妙就停了)。 private let retryInterval: TimeInterval = 1.0 private func ensureConnected() -> Bool { if fd >= 0 { return true } let now = Date().timeIntervalSince1970 // 连过一次又断了、且超过宽限期还连不回来 → 主 App 已经收工(用户点了 // 「结束」,那边把 socket 关了)。广播只能由扩展自己终止,主 App 对 // 系统面板发起的广播没有任何控制权,所以这个判断是唯一的收尾时机。 if everConnected, let since = disconnectedAt, now - since > peerGoneGrace, !peerGoneFired { peerGoneFired = true onPeerGone?() return false } if now - lastConnectAttempt < retryInterval { return false } lastConnectAttempt = now let s = socket(AF_INET, SOCK_STREAM, 0) if s < 0 { return false } var addr = sockaddr_in() addr.sin_family = sa_family_t(AF_INET) addr.sin_port = BroadcastAudio.port.bigEndian addr.sin_addr.s_addr = inet_addr("127.0.0.1") 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)) // 音频要的是低延迟,攒包毫无意义 var nodelay: Int32 = 1 setsockopt(s, IPPROTO_TCP, TCP_NODELAY, &nodelay, socklen_t(MemoryLayout.size)) fd = s everConnected = true disconnectedAt = nil return true } private func disconnect() { if fd >= 0 { close(fd) } fd = -1 if everConnected && disconnectedAt == nil { disconnectedAt = Date().timeIntervalSince1970 } } /// 发一帧 PCM。帧格式:4 字节小端长度 + 负载。 /// /// ⚠️ 长度前缀不能省:TCP 是字节流,**不保留消息边界**,接收端读到的可能 /// 是半帧、也可能是两帧粘在一起。不分帧的话喂给 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 ok: Bool = payload.withUnsafeBytes { raw -> Bool in guard let base = raw.baseAddress else { return false } var offset = 0 while offset < payload.count { let n = write(fd, base.advanced(by: offset), payload.count - offset) if n <= 0 { return false } offset += n } return true } if !ok { disconnect() } } deinit { disconnect() } }