From a9ce8bcdef346f314223ebbb228f1be8bdc636d2 Mon Sep 17 00:00:00 2001 From: Rodger-Wang <1367893453@qq.com> Date: Sat, 5 Sep 2026 21:51:17 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B9=BF=E6=92=AD=E6=89=A9=E5=B1=95=E6=94=B9?= =?UTF-8?q?=E8=B5=B0=20127.0.0.1=20=E7=8E=AF=E5=9B=9E=20TCP=EF=BC=8C?= =?UTF-8?q?=E5=8E=BB=E6=8E=89=20App=20Group=20=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一版用 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 --- .../BroadcastAudioSocket.swift | 94 ++++++++----------- .../BroadcastExtension.entitlements | 10 -- .../ios/Runner.xcodeproj/project.pbxproj | 3 - .../xcshareddata/swiftpm/Package.resolved | 20 ++-- apps/client/ios/Runner/Runner.entitlements | 7 -- .../azure_speech/BroadcastAudioReceiver.swift | 71 ++++++-------- 6 files changed, 77 insertions(+), 128 deletions(-) delete mode 100644 apps/client/ios/BroadcastExtension/BroadcastExtension.entitlements diff --git a/apps/client/ios/BroadcastExtension/BroadcastAudioSocket.swift b/apps/client/ios/BroadcastExtension/BroadcastAudioSocket.swift index 29aeb73e..189c2577 100644 --- a/apps/client/ios/BroadcastExtension/BroadcastAudioSocket.swift +++ b/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//`(约 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.size)) == 0 + connect(s, sa, socklen_t(MemoryLayout.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.size)) + // 音频要的是低延迟,攒包毫无意义 + var nodelay: Int32 = 1 + setsockopt(s, IPPROTO_TCP, TCP_NODELAY, &nodelay, socklen_t(MemoryLayout.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() } diff --git a/apps/client/ios/BroadcastExtension/BroadcastExtension.entitlements b/apps/client/ios/BroadcastExtension/BroadcastExtension.entitlements deleted file mode 100644 index 1f0294d4..00000000 --- a/apps/client/ios/BroadcastExtension/BroadcastExtension.entitlements +++ /dev/null @@ -1,10 +0,0 @@ - - - - - com.apple.security.application-groups - - group.com.yimai.eaimar - - - diff --git a/apps/client/ios/Runner.xcodeproj/project.pbxproj b/apps/client/ios/Runner.xcodeproj/project.pbxproj index 00cc98ec..46cca1d3 100644 --- a/apps/client/ios/Runner.xcodeproj/project.pbxproj +++ b/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; diff --git a/apps/client/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apps/client/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved index 27d1df0a..6794fda8 100644 --- a/apps/client/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/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" } }, { diff --git a/apps/client/ios/Runner/Runner.entitlements b/apps/client/ios/Runner/Runner.entitlements index e959d716..c76472d7 100644 --- a/apps/client/ios/Runner/Runner.entitlements +++ b/apps/client/ios/Runner/Runner.entitlements @@ -10,13 +10,6 @@ applinks:yimai.games - com.apple.security.application-groups - - - group.com.yimai.eaimar - com.apple.developer.networking.wifi-info com.apple.external-accessory.wireless-configuration diff --git a/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/BroadcastAudioReceiver.swift b/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/BroadcastAudioReceiver.swift index 4486fb9c..d38ec5e4 100644 --- a/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/BroadcastAudioReceiver.swift +++ b/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.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.size)) == 0 + bind(fd, sa, socklen_t(MemoryLayout.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] 已停止") }