You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

101 lines
4.2 KiB

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 没起来时每帧发起一次
/// connect 会把扩展的 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
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<sockaddr_in>.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<Int32>.size))
// 音频要的是低延迟,攒包毫无意义
var nodelay: Int32 = 1
setsockopt(s, IPPROTO_TCP, TCP_NODELAY, &nodelay, socklen_t(MemoryLayout<Int32>.size))
fd = s
return true
}
private func disconnect() {
if fd >= 0 { close(fd) }
fd = -1
}
/// 发一帧 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() }
}