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.
 
 
 
 
 
 

137 lines
5.9 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。没连上过就断开不算「主 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<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
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() }
}