Browse Source

广播扩展改走 127.0.0.1 环回 TCP,去掉 App Group 依赖

上一版用 App Group + Unix domain socket 传音频,结果签不出包:
- com.yimai.eaimar 的 profile 不带 App Groups capability
- com.yimai.eaimar.BroadcastExtension 没有任何 profile
而 Xcode 又登录不上账号去申请(-1200)。实测原因是网络层:
developer.apple.com 通(200),appleid.apple.com 与
developerservices2.apple.com 都连不上(000)——后两个正是登录与签发 profile
要走的域名。

改成环回 TCP 之后不需要任何 entitlement:扩展和主 App 在同一台设备上,
127.0.0.1 天然互通,而且不触发 iOS 的「本地网络」隐私弹窗(那个只针对局域网,
环回是明确豁免的)。主 App 的 entitlements 回到原样,用已有的 profile 就能签,
扩展的 profile 由自动签名就地生成。已真机安装成功。

代价:没有 App Group 就没有地方发布「主 App 这次监听在哪个端口」,只能约定
一个固定端口(52781,两边手工同步,注释里写明了)。撞端口时主 App bind 会
失败并打日志,不是静默失败。

顺带补的两个 socket 选项:
- SO_REUSEADDR:上次进程被杀后端口停留在 TIME_WAIT,不开的话几十秒内 bind
  都会 EADDRINUSE,表现是「刚重启 App 就用不了」。
- TCP_NODELAY:音频要低延迟,攒包没有意义。
- 只绑 127.0.0.1 而不是 INADDR_ANY,否则这个端口对局域网可见。

另外记两个这次踩到的构建坑:
- flutter clean 会删掉 ios/Flutter/ephemeral/,其中的 swift_sdk_local 软链由
  Podfile 的补丁在 pod install 时创建、**flutter 不会自己重建**,于是后续
  xcodebuild 直接卡在 "Could not resolve package dependencies"。清理之后要
  补跑一次 pod install。
- 跑过 --no-codesign 之后再跑签名构建,会复用 build/ 里未签名的 framework,
  装机时报 objective_c.framework "invalid signature"。中间做过 no-codesign
  验证的话,出正式包前要先 flutter clean。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main
Rodger-Wang 1 month ago
parent
commit
a9ce8bcdef
  1. 94
      apps/client/ios/BroadcastExtension/BroadcastAudioSocket.swift
  2. 10
      apps/client/ios/BroadcastExtension/BroadcastExtension.entitlements
  3. 3
      apps/client/ios/Runner.xcodeproj/project.pbxproj
  4. 20
      apps/client/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved
  5. 7
      apps/client/ios/Runner/Runner.entitlements
  6. 71
      apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/BroadcastAudioReceiver.swift

94
apps/client/ios/BroadcastExtension/BroadcastAudioSocket.swift

@ -1,46 +1,37 @@
import Foundation
/// 广播扩展 → 主 App 的音频通道(Unix domain socket,客户端一侧)。
/// 广播扩展 → 主 App 的音频通道(**环回 TCP**,客户端一侧)。
///
/// ## 为什么用 socket 而不是共享文件
/// App Group 的共享容器确实两边都能读写,但音频是**连续流**:用文件就要自己做
/// 环形缓冲 + 读写位点同步 + 回绕,而且主 App 只能轮询,白白加一截延迟。
/// Unix domain socket 是内核帮我们做好的有序字节流,两边都在同一台设备上,
/// 没有任何网络开销。这也是各家屏幕共享 SDK(Agora / LiveKit 等)的标准做法。
/// ## 为什么不用 App Group + Unix socket
/// 那是更常规的做法,但它要求在苹果开发者后台登记 App Group、给主 App 开
/// App Groups capability,并让 Xcode 能连上 appleid/developerservices2
/// 重新签发 profile。多一道跨团队的审批,出问题时还完全不在代码里。
/// 环回 TCP 不需要任何 entitlement:扩展和主 App 在同一台设备上,
/// 127.0.0.1 天然互通。
///
/// ## ⚠️ sun_path 只有 104 字节
/// App Group 容器路径形如
/// `/private/var/mobile/Containers/Shared/AppGroup/<UUID>/`(约 70 字符),
/// 加上文件名还剩得下,但**不能再往下建子目录**,否则会静默截断、
/// 连到一个不存在的路径上,表现为「广播开着但主 App 一个字节都收不到」。
/// ⚠️ **127.0.0.1 不会触发 iOS 的「本地网络」隐私弹窗**——那个弹窗只针对
/// 局域网访问,环回是明确豁免的。所以也不需要 NSLocalNetworkUsageDescription。
///
/// ## ⚠️ 端口写死的取舍
/// 没有 App Group 就没有地方发布「主 App 这次监听在哪个端口」,所以只能约定
/// 一个固定端口。代价是理论上会和别的进程撞端口;选了一个冷门高位端口,
/// 手机上几乎不会有别的东西在环回口监听。撞了的表现是主 App bind 失败,
/// 日志里会明说,不是静默失败。
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
}
/// ⚠️ 改这里必须同时改主 App 的 BroadcastAudioReceiver.swift,两边是手工同步的
static let port: UInt16 = 52781
}
/// 扩展侧的发送端。非阻塞连接 + 失败自动重连。
/// 扩展侧的发送端。连接失败会限流重试。
final class BroadcastAudioSender {
private var fd: Int32 = -1
private var lastConnectAttempt: TimeInterval = 0
/// 连不上时的重试间隔。
///
/// ⚠️ 不能每帧都试:音频帧是几十毫秒一个,每帧发起一次 connect
/// 在主 App 没起来时会把扩展的 CPU 打满,而广播扩展的内存/CPU 预算
/// 比普通进程紧得多(内存上限 50MB,超了直接被系统杀掉)。
/// ⚠️ 不能每帧都试:音频帧几十毫秒一个,主 App 没起来时每帧发起一次
/// connect 会把扩展的 CPU 打满,而广播扩展的内存上限只有 **50MB**,
/// 超了系统直接杀进程(表现是广播莫名其妙就停了)。
private let retryInterval: TimeInterval = 1.0
private func ensureConnected() -> Bool {
@ -49,28 +40,17 @@ final class BroadcastAudioSender {
if now - lastConnectAttempt < retryInterval { return false }
lastConnectAttempt = now
guard let path = BroadcastAudio.socketPath else { return false }
let s = socket(AF_UNIX, SOCK_STREAM, 0)
let s = socket(AF_INET, 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
}
}
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_un>.size)) == 0
connect(s, sa, socklen_t(MemoryLayout<sockaddr_in>.size)) == 0
}
}
if !ok {
@ -78,9 +58,12 @@ final class BroadcastAudioSender {
return false
}
// 主 App 挂掉时写入会收到 SIGPIPE,默认行为是**直接杀死本进程**。
// 设成返回 EPIPE 错误,交给下面的 send 自己处理重连。
// 设成返回 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
}
@ -92,25 +75,26 @@ final class BroadcastAudioSender {
/// 发一帧 PCM。帧格式:4 字节小端长度 + 负载。
///
/// 加长度前缀是因为 SOCK_STREAM **不保留消息边界**:接收端读到的可能是
/// 半帧、也可能是两帧粘在一起。不分帧的话喂给 ASR 的就是错位的样本。
/// ⚠️ 长度前缀不能省: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 written: Int = payload.withUnsafeBytes { raw -> Int in
guard let base = raw.baseAddress else { return -1 }
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 -1 }
if n <= 0 { return false }
offset += n
}
return offset
return true
}
if written < 0 { disconnect() }
if !ok { disconnect() }
}
deinit { disconnect() }

10
apps/client/ios/BroadcastExtension/BroadcastExtension.entitlements

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.yimai.eaimar</string>
</array>
</dict>
</plist>

3
apps/client/ios/Runner.xcodeproj/project.pbxproj

@ -561,7 +561,6 @@
baseConfigurationReference = 6C2968D7A4F3A0D478C6EF09 /* Extension.xcconfig */;
buildSettings = {
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = BroadcastExtension/BroadcastExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = B8AJQ7ACU7;
INFOPLIST_FILE = BroadcastExtension/Info.plist;
@ -900,7 +899,6 @@
baseConfigurationReference = 6C2968D7A4F3A0D478C6EF09 /* Extension.xcconfig */;
buildSettings = {
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = BroadcastExtension/BroadcastExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = B8AJQ7ACU7;
INFOPLIST_FILE = BroadcastExtension/Info.plist;
@ -926,7 +924,6 @@
baseConfigurationReference = 6C2968D7A4F3A0D478C6EF09 /* Extension.xcconfig */;
buildSettings = {
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = BroadcastExtension/BroadcastExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = B8AJQ7ACU7;
INFOPLIST_FILE = BroadcastExtension/Info.plist;

20
apps/client/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved

@ -104,8 +104,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleDataTransport.git",
"state" : {
"revision" : "617af071af9aa1d6a091d59a202910ac482128f9",
"version" : "10.1.0"
"revision" : "ba3358d3c3dbae8ef230b58a46b97ad65e84e974",
"version" : "10.1.1"
}
},
{
@ -122,8 +122,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git",
"state" : {
"revision" : "60da361632d0de02786f709bdc0c4df340f7613e",
"version" : "8.1.0"
"revision" : "92c8f6dc3ac375d6febdfcb3db68bc3d10633db3",
"version" : "8.1.3"
}
},
{
@ -203,8 +203,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/promises.git",
"state" : {
"revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac",
"version" : "2.4.0"
"revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837",
"version" : "2.4.1"
}
},
{
@ -230,8 +230,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-http-types",
"state" : {
"revision" : "db774a277f60063a32d854f2980299caf06da041",
"version" : "1.6.0"
"revision" : "6c8ca8297332ea2780c45e1bce9d7fd2a8b51e1d",
"version" : "1.7.0"
}
},
{
@ -248,8 +248,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-openapi-runtime",
"state" : {
"revision" : "3d3a8457661daf7fb260ceeb9f0e24e5204ba5fb",
"version" : "1.12.0"
"revision" : "9e53df53e043b53046a371b31aaf0a1e26eef946",
"version" : "1.12.1"
}
},
{

7
apps/client/ios/Runner/Runner.entitlements

@ -10,13 +10,6 @@
<array>
<string>applinks:yimai.games</string>
</array>
<key>com.apple.security.application-groups</key>
<array>
<!-- 多媒体翻译的录屏广播扩展靠它与主 App 通信(Unix socket 建在这个
容器目录下)。改这里必须同时改 BroadcastExtension.entitlements
和苹果开发者后台注册的 App Group,少一处就是静默收不到音频。 -->
<string>group.com.yimai.eaimar</string>
</array>
<key>com.apple.developer.networking.wifi-info</key>
<true/>
<key>com.apple.external-accessory.wireless-configuration</key>

71
apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/BroadcastAudioReceiver.swift

@ -3,13 +3,18 @@ import Foundation
/// 主 App 侧:接收录屏广播扩展送来的系统音频 PCM。
///
/// 对端是 `ios/BroadcastExtension/BroadcastAudioSocket.swift` 里的
/// `BroadcastAudioSender`。协议:Unix domain socket,帧格式
/// `BroadcastAudioSender`。协议:**127.0.0.1 环回 TCP**,帧格式
/// **4 字节小端长度 + 16kHz/单声道/Int16 交错的 PCM**。
///
/// ## ⚠️ 这里的常量与扩展那份是**手工同步**的
/// 两边分属不同编译单元(扩展在 Runner 工程里,本文件在 azure_speech 这个
/// SwiftPM 包里),没有共享代码的干净办法,所以 appGroupId 与 socketName
/// 是抄了一份。改任何一个都要两边一起改,**改漏了不会编译报错**,
/// ## 为什么是环回 TCP 而不是 App Group + Unix socket
/// 后者更常规,但要在苹果开发者后台登记 App Group、给主 App 开 App Groups
/// capability、并让 Xcode 连得上 appleid/developerservices2 重新签发 profile。
/// 环回不需要任何 entitlement,也不触发 iOS 的「本地网络」隐私弹窗
/// (那个只针对局域网,127.0.0.1 是明确豁免的)。
///
/// ## ⚠️ 端口是两边**手工同步**的
/// 扩展在 Runner 工程里,本文件在 azure_speech 这个 SwiftPM 包里,
/// 没有共享代码的干净办法。改端口要两边一起改,**改漏了不会编译报错**,
/// 只会表现为「广播开着,主 App 一个字节都收不到」。
///
/// ## ⚠️ 主 App 必须活着
@ -20,9 +25,8 @@ import Foundation
public final class BroadcastAudioReceiver {
public static let shared = BroadcastAudioReceiver()
/// 与扩展侧保持一致,见上面的同步警告
private static let appGroupId = "group.com.yimai.eaimar"
private static let socketName = "bcast_audio.sock"
/// 与扩展侧 BroadcastAudio.port 保持一致,见上面的同步警告
private static let port: UInt16 = 52781
private var listenFd: Int32 = -1
private var clientFd: Int32 = -1
@ -34,58 +38,40 @@ public final class BroadcastAudioReceiver {
private init() {}
public var socketPath: String? {
guard let dir = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: Self.appGroupId) else {
return nil
}
return dir.appendingPathComponent(Self.socketName).path
}
@discardableResult
public func start() -> Bool {
if running { return true }
guard let path = socketPath else {
NSLog("[BroadcastAudioReceiver] 拿不到 App Group 容器,检查 entitlements 与开发者后台的 App Group 登记")
return false
}
// ⚠️ 必须先删旧的:bind 到一个已存在的路径会直接 EADDRINUSE。
// 上一次进程被杀时不会自动清理,所以每次都清一遍。
unlink(path)
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
let fd = socket(AF_INET, SOCK_STREAM, 0)
if fd < 0 {
NSLog("[BroadcastAudioReceiver] socket() 失败: %d", errno)
return false
}
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
let pathBytes = Array(path.utf8)
guard pathBytes.count < MemoryLayout.size(ofValue: addr.sun_path) else {
// sun_path 只有 104 字节,App Group 容器路径已经占了 ~70
NSLog("[BroadcastAudioReceiver] socket 路径过长: %d", pathBytes.count)
close(fd)
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
}
}
// ⚠️ SO_REUSEADDR 必须开:上一次进程被杀之后端口会停留在 TIME_WAIT,
// 不开的话几十秒内 bind 都会 EADDRINUSE,表现是「刚重启 App 就用不了」。
var reuse: Int32 = 1
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &reuse, socklen_t(MemoryLayout<Int32>.size))
var addr = sockaddr_in()
addr.sin_family = sa_family_t(AF_INET)
addr.sin_port = Self.port.bigEndian
// 只绑环回:绑 INADDR_ANY 会让这个端口对局域网可见,既没必要也是攻击面
addr.sin_addr.s_addr = inet_addr("127.0.0.1")
let bound = withUnsafePointer(to: &addr) { p -> Bool in
p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
bind(fd, sa, socklen_t(MemoryLayout<sockaddr_un>.size)) == 0
bind(fd, sa, socklen_t(MemoryLayout<sockaddr_in>.size)) == 0
}
}
guard bound, listen(fd, 1) == 0 else {
NSLog("[BroadcastAudioReceiver] bind/listen 失败: %d", errno)
NSLog("[BroadcastAudioReceiver] bind/listen 127.0.0.1:%d 失败: %d(端口被占?)",
Int(Self.port), errno)
close(fd)
return false
}
listenFd = fd
running = true
NSLog("[BroadcastAudioReceiver] 已监听 %@", path)
NSLog("[BroadcastAudioReceiver] 已监听 127.0.0.1:%d", Int(Self.port))
queue.async { [weak self] in self?.acceptLoop() }
return true
}
@ -94,7 +80,6 @@ public final class BroadcastAudioReceiver {
running = false
if clientFd >= 0 { close(clientFd); clientFd = -1 }
if listenFd >= 0 { close(listenFd); listenFd = -1 }
if let path = socketPath { unlink(path) }
NSLog("[BroadcastAudioReceiver] 已停止")
}

Loading…
Cancel
Save