From 15150ded531389c2509707bae51a87f4ab0765cf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 21:29:24 +0000 Subject: [PATCH] feat(ios): long-poll relay channel + cross-transport interop (BR-1.1, D-2 decided) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements decision D-2 as recommended: iOS 12 uses HTTP long-polling rather than WebSocket. iOS 12 has no URLSessionWebSocketTask (13+ only), so WS would mean ~300 hand-rolled RFC 6455 lines or a third-party dep that breaks the zero-dependency constraint — while positions are only reported every 3s and never needed a true long connection anyway. Server: refactored relay rooms to a 'member = deliverer' abstraction so WebSocket members (Android) and long-poll sessions (iOS) land in the SAME room set — the two transports interoperate. Added HTTP endpoints /relay/join|send|poll|leave on the same port (ws now rides an http.Server), with token check on join, sid required to send, queue cap, and idle-session GC. POLL_HOLD_MS is env-configurable so the hold-timeout path can be tested deterministically. iOS: NetTransport.swift on plain URLSession (zero deps), MeshCrypto .roomId via CC_SHA256, Identity.relayUrl/relayToken, and MeshBus dual channel mirroring Android — send() relays roomId+kind+ciphertext only (team code never leaves the device), receive shares dispatchSealed() which decrypts with the local team and drops on MAC failure. Fixture at 66 assertions, runs in 6s. The decisive new ones drive both transports against one real server: iOS(long-poll) → Android(WS) and Android(WS) → iOS(long-poll) both deliver and decrypt correctly, a suspended poll is woken by a WS send, other-team sessions get nothing, bad token / missing sid are rejected, and the team code never appears on the long-poll wire. --- ...24\350\277\233\350\247\204\345\210\222.md" | 2 +- ...41\347\253\257\350\247\204\345\210\222.md" | 7 +- ios12/Sources/Mesh/Identity.swift | 11 ++ ios12/Sources/Mesh/MeshBus.swift | 48 ++++- ios12/Sources/Mesh/MeshCrypto.swift | 10 + ios12/Sources/Mesh/NetTransport.swift | 130 +++++++++++++ server/signaling.js | 180 ++++++++++++++++-- tests/mesh-fixture/run.mjs | 94 +++++++++ 8 files changed, 456 insertions(+), 26 deletions(-) create mode 100644 ios12/Sources/Mesh/NetTransport.swift diff --git "a/docs/requirements/03-Gap\345\210\206\346\236\220\344\270\216\346\274\224\350\277\233\350\247\204\345\210\222.md" "b/docs/requirements/03-Gap\345\210\206\346\236\220\344\270\216\346\274\224\350\277\233\350\247\204\345\210\222.md" index 25c572f..183e22b 100644 --- "a/docs/requirements/03-Gap\345\210\206\346\236\220\344\270\216\346\274\224\350\277\233\350\247\204\345\210\222.md" +++ "b/docs/requirements/03-Gap\345\210\206\346\236\220\344\270\216\346\274\224\350\277\233\350\247\204\345\210\222.md" @@ -195,7 +195,7 @@ E0 恒为稳定性/可测性收口——在补自检与夹具之前堆新功能 | G-DR-1 | L3 | ①FR-2.3 | PR-P1-2 | E1 / v0.3.0 | 已实现(iOS/Android 跟车页蓝牙关/无邻居横幅,待真机关蓝牙验收) | | G-DR-2 | L4 | ②§3.1 | PR-P2-1 | E2 / v0.4.0 | 已实现(双端:15s 未更新标记变淡、60s 移除;待真机拔电验收) | | G-DR-3 | L4 | ②§7 | PR-P2-4 | E3 / v0.5.0 | 已实现(双端跟车页隐身开关,开启即停止广播并横幅提示) | -| G-NET-1 | **L1** | ④§1.1 S#1–S#6 | BR-1.x | E4 / v0.6.0 | 进行中(服务端密文中继 + Android 双通道已落地,夹具 57 项验收;iOS 待决策 D-2;均待真机跨距验收) | +| G-NET-1 | **L1** | ④§1.1 S#1–S#6 | BR-1.x | E4 / v0.6.0 | 代码完成(服务端中继 + Android WS + iOS 长轮询三方落地,夹具 66 项含跨传输互通验收);**待部署 D-1 与真机跨距验收方可关闭** | | G-LG-1 | L3 | ①FR-4.8 | PR-P1-5 | E1 / v0.3.0 | 已实现(Web 往返验收通过;iOS/Android 分享导出+粘贴导入,待真机卸载重装验收) | | G-LG-2 | L4 | B#3 | PR-P1-6 | E0 / v0.2.0 | 已关闭(验收通过) | | G-LG-3 | L4 | ①FR-4.7 | PR-P2-3 | E2 / v0.4.0 | 已实现(ML Kit 离线识别,选小票照片自动填最大金额;待真机拍票验收) | diff --git "a/docs/requirements/04-\345\220\216\345\217\260\346\234\215\345\212\241\347\253\257\350\247\204\345\210\222.md" "b/docs/requirements/04-\345\220\216\345\217\260\346\234\215\345\212\241\347\253\257\350\247\204\345\210\222.md" index 0f2ceeb..9cd845c 100644 --- "a/docs/requirements/04-\345\220\216\345\217\260\346\234\215\345\212\241\347\253\257\350\247\204\345\210\222.md" +++ "b/docs/requirements/04-\345\220\216\345\217\260\346\234\215\345\212\241\347\253\257\350\247\204\345\210\222.md" @@ -154,8 +154,9 @@ E1 阶段已落地端到端加密(G-CM-3):MeshBus 载荷为 现有 `server/signaling.js` 已具备:WebSocket 房间管理、token 鉴权、限流、大小上限、单 IP 上限。 **S1 只需增加一个"业务帧中继"消息类型**,复用全部现有防护,预计增量 **< 100 行**。 -> 现有消息:`{type:'join'}`、`{type:'signal', to, from, data}` -> 新增消息:`{type:'relay', team, frame}`,其中 frame = base64(MeshBus 密文帧),服务端原样广播给同 team 的其他连接 +> WebSocket(Android):`{type:'relay-join', room, token}` / `{type:'relay', room, kind, body}` +> HTTP 长轮询(iOS 12,决策 D-2 已定):`POST /relay/join` → `{sid}`、`POST /relay/send`、`GET /relay/poll?sid=` +> 两种传输在服务端落入**同一房间集合**(成员=投递器抽象),故 Android ↔ iOS 跨传输互通。 ### 5.2 部署方案对比(**决策点 D-1**) @@ -203,7 +204,7 @@ E1 阶段已落地端到端加密(G-CM-3):MeshBus 载荷为 | # | 决策 | 我的推荐 | 影响 | |---|---|---|---| | **D-1** | 服务端部署在哪(§5.2 A/B/C) | **A 小 VPS** 或 **C 容器平台**(现有 Node 代码直接跑) | 决定成本与我给出的部署脚本形态 | -| **D-2** | iOS 12 走 WebSocket(需手写帧或引依赖)还是**长轮询 HTTPS** | **长轮询 HTTPS**:iOS 12 原生可用、实现量减半、保住"零第三方依赖" | 决定 iOS 端工作量与依赖策略 | +| ~~D-2~~ | iOS 12 传输方式 | **已定:长轮询 HTTPS**(2026-08-06 实施)。URLSession 原生、零第三方依赖;夹具已验证与 Android WS 跨传输互通 | ✅ 已落地 | | **D-3** | 是否要后台推送(需付费 Apple 账号) | **先不做**,S2 用"打开 App 即补投"替代 | 与 G-ENG-3 是同一个付费账号决策 | | **D-4** | 是否提供"仅蓝牙模式"开关(完全不联网) | **提供**,隐私敏感用户可一键回到纯 P2P | 小改动,大安心 | diff --git a/ios12/Sources/Mesh/Identity.swift b/ios12/Sources/Mesh/Identity.swift index 72da40f..502e746 100644 --- a/ios12/Sources/Mesh/Identity.swift +++ b/ios12/Sources/Mesh/Identity.swift @@ -31,6 +31,17 @@ enum Identity { set { UserDefaults.standard.set(newValue, forKey: "trailmate.ghost") } } + // 网络中继(BR-1.1):relayUrl 为空 = 只用蓝牙(默认,等同今日行为)。token 对应服务端 SIGNAL_TOKEN。 + static var relayUrl: String { + get { UserDefaults.standard.string(forKey: "trailmate.relayUrl") ?? "" } + set { UserDefaults.standard.set(newValue.trimmingCharacters(in: .whitespaces), forKey: "trailmate.relayUrl") } + } + + static var relayToken: String { + get { UserDefaults.standard.string(forKey: "trailmate.relayToken") ?? "" } + set { UserDefaults.standard.set(newValue.trimmingCharacters(in: .whitespaces), forKey: "trailmate.relayToken") } + } + static func newTeamCode() -> String { let chars = Array("ABCDEFGHJKLMNPQRSTUVWXYZ23456789") // 去掉易混字符 var s = "" diff --git a/ios12/Sources/Mesh/MeshBus.swift b/ios12/Sources/Mesh/MeshBus.swift index 34fa355..da5f82f 100644 --- a/ios12/Sources/Mesh/MeshBus.swift +++ b/ios12/Sources/Mesh/MeshBus.swift @@ -12,14 +12,17 @@ final class MeshBus: BleMeshDelegate { static let kindAck: UInt8 = 4 // 送达回执(G-CM-2):json{mid,by},收到聊天/语音后回发 private let mesh = BleMesh() + private var net: NetTransport? private var started = false // 每类型单 handler:后注册者替换前者。VC 重建时新实例自动顶替旧实例, // 避免向单例累积闭包造成旧 VC 泄漏与消息重复处理(与 Android MeshBus.kt 行为一致)。 private var handlers: [UInt8: (Data) -> Void] = [:] private var peerHandlers: [String: (Int) -> Void] = [:] private var stateHandlers: [String: (Bool) -> Void] = [:] + private var cloudHandlers: [String: (Int) -> Void] = [:] private(set) var peerCount = 0 private(set) var btAvailable = true + private(set) var cloudCount = 0 // 云端在线数(BR-1.5:与蓝牙邻居分开,不混淆) private init() { mesh.delegate = self } @@ -27,6 +30,28 @@ final class MeshBus: BleMeshDelegate { if started { return } started = true mesh.start() + startNet() // 网络中继与 BLE 并行(BR-1.1);relayUrl 为空则空转 + } + + // 启动/重启网络通道。队伍码变化或首次配置 URL 时调用。 + func startNet() { + net?.stop() + net = nil + cloudCount = 0 + let url = Identity.relayUrl + if url.isEmpty { cloudHandlers.values.forEach { $0(0) }; return } + let t = NetTransport( + baseUrl: url, + room: MeshCrypto.roomId(Identity.team), + token: Identity.relayToken, + onFrame: { [weak self] kind, sealed in self?.dispatchSealed(kind, sealed) }, + onCloud: { [weak self] n in + guard let self = self else { return } + self.cloudCount = n + self.cloudHandlers.values.forEach { $0(n) } + }) + net = t + t.start() } func send(_ kind: UInt8, _ payload: Data) { @@ -38,6 +63,8 @@ final class MeshBus: BleMeshDelegate { d.append(contentsOf: team) d.append(sealed) mesh.send(d) + // 网络通道:只发 roomId + kind + 密文,**队伍码不上网**(BR-1.2) + net?.relay(kind, sealed) } // 订阅某类型消息(负载已去掉类型字节)。同类型重复订阅时新 handler 顶替旧的。 @@ -57,6 +84,20 @@ final class MeshBus: BleMeshDelegate { handler(btAvailable) } + // 云端在线数观察(BR-1.5,立即回调一次当前值);同 tag 顶替 + func onCloud(_ tag: String = "default", _ handler: @escaping (Int) -> Void) { + cloudHandlers[tag] = handler + handler(cloudCount) + } + + // 解密并分发(两条通道共用)。BR-1.3 双通道去重:业务层已幂等—— + // 聊天/语音按 mid appendIfNew、位置按设备 id 覆盖、回执用 Set,故同帧双至无害,无需额外去重层。 + private func dispatchSealed(_ kind: UInt8, _ sealed: Data) { + // 解密并验 MAC(G-CM-3):错队伍码/被篡改/旧版明文一律丢弃(网络侧亦借此天然处理房间哈希碰撞) + guard let body = MeshCrypto.decrypt(team: Identity.team, sealed) else { return } + handlers[kind]?(body) + } + // MARK: - BleMeshDelegate(主线程) func bleMesh(_ mesh: BleMesh, didReceive payload: Data) { // 解析 [kind][teamLen][team][body] 并按当前队伍码过滤 @@ -66,11 +107,8 @@ final class MeshBus: BleMeshDelegate { let tlen = Int(bytes[1]) guard payload.count >= 2 + tlen else { return } let team = String(bytes: bytes[2..<(2 + tlen)], encoding: .utf8) ?? "" - if team != Identity.team { return } - let sealed = payload.subdata(in: (2 + tlen).. String { + let input = Array("trailmate-room-v1|\(team)".utf8) + var out = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) + CC_SHA256(input, CC_LONG(input.count), &out) + return out.map { String(format: "%02x", $0) }.joined() + } + private static func constEq(_ a: [UInt8], _ b: [UInt8]) -> Bool { guard a.count == b.count else { return false } var r: UInt8 = 0 diff --git a/ios12/Sources/Mesh/NetTransport.swift b/ios12/Sources/Mesh/NetTransport.swift new file mode 100644 index 0000000..f05f2b1 --- /dev/null +++ b/ios12/Sources/Mesh/NetTransport.swift @@ -0,0 +1,130 @@ +import Foundation + +// 网络中继通道(BR-1.1,iOS 12 侧):与 BLE 并行的第二条传输路,补上"超 BLE 距离即失效"(G-NET-1)。 +// +// 为什么是长轮询而不是 WebSocket(决策 D-2):iOS 12 没有 URLSessionWebSocketTask(iOS 13+ 才有), +// 手写 RFC 6455 约 300 行、或引入 Starscream 破坏"零第三方依赖"约束。而位置本就 3 秒一报, +// 长轮询用 URLSession 即可,iOS 12 原生可用、实现量减半。服务端与 WebSocket 成员同房互通, +// 因此 iOS(长轮询) ↔ Android(WebSocket) 可跨传输互见。 +// +// 只发密文:上行 roomId(队伍码) + kind + base64(端到端密文),**队伍码绝不上传**(见 ④§3.3)。 +// 鲁棒性(BR-1.4):任何失败都只做退避重试,绝不抛错、绝不弹窗;baseUrl 为空即完全不启用。 +final class NetTransport { + private let baseUrl: String + private let room: String + private let token: String + private let onFrame: (UInt8, Data) -> Void // 收到中继帧:kind + 密文 + private let onCloud: (Int) -> Void // 云端在线数(BR-1.5) + + private let session: URLSession + private var sid: String? + private var stopped = true + private var backoff: TimeInterval = 1 + private(set) var cloudCount = 0 + + init(baseUrl: String, room: String, token: String, + onFrame: @escaping (UInt8, Data) -> Void, onCloud: @escaping (Int) -> Void) { + self.baseUrl = baseUrl.hasSuffix("/") ? String(baseUrl.dropLast()) : baseUrl + self.room = room + self.token = token + self.onFrame = onFrame + self.onCloud = onCloud + let cfg = URLSessionConfiguration.default + cfg.timeoutIntervalForRequest = 35 // 略大于服务端 25s 挂起 + cfg.waitsForConnectivity = false + self.session = URLSession(configuration: cfg) + } + + func start() { + guard !baseUrl.isEmpty else { return } + stopped = false + join() + } + + func stop() { + stopped = true + if let s = sid { post("/relay/leave", ["sid": s]) { _ in } } + sid = nil + setCloud(0) + } + + func relay(_ kind: UInt8, _ sealed: Data) { + guard let s = sid else { return } + post("/relay/send", ["sid": s, "kind": Int(kind), "body": sealed.base64EncodedString()]) { _ in } + } + + // MARK: - 内部 + private func setCloud(_ n: Int) { + cloudCount = n + DispatchQueue.main.async { [weak self] in self?.onCloud(n) } + } + + private func join() { + guard !stopped else { return } + var body: [String: Any] = ["room": room] + if !token.isEmpty { body["token"] = token } + post("/relay/join", body) { [weak self] json in + guard let self = self, !self.stopped else { return } + if let j = json, let sid = j["sid"] as? String { + self.sid = sid + self.backoff = 1 + self.setCloud(j["peers"] as? Int ?? 0) + self.poll() + } else { + self.retry { self.join() } // 服务端不可达/鉴权失败:静默退避重试 + } + } + } + + private func poll() { + guard !stopped, let s = sid, + let url = URL(string: "\(baseUrl)/relay/poll?sid=\(s)") else { return } + var req = URLRequest(url: url) + req.httpMethod = "GET" + session.dataTask(with: req) { [weak self] data, resp, _ in + guard let self = self, !self.stopped else { return } + let code = (resp as? HTTPURLResponse)?.statusCode ?? 0 + if code == 403 { // 会话已被回收 → 重新入房 + self.sid = nil + self.retry { self.join() } + return + } + if code == 200, let d = data, + let j = try? JSONSerialization.jsonObject(with: d) as? [String: Any], + let frames = j["frames"] as? [[String: Any]] { + for f in frames { + guard let k = f["kind"] as? Int, let b = f["body"] as? String, + let sealed = Data(base64Encoded: b) else { continue } + DispatchQueue.main.async { [weak self] in self?.onFrame(UInt8(k & 0xFF), sealed) } + } + self.backoff = 1 + self.poll() // 立即续挂下一轮 + } else { + self.retry { self.poll() } // 超时/网络抖动:退避后再挂 + } + }.resume() + } + + private func retry(_ work: @escaping () -> Void) { + let d = backoff + backoff = min(backoff * 2, 16) + DispatchQueue.global().asyncAfter(deadline: .now() + d) { [weak self] in + guard let self = self, !self.stopped else { return } + work() + } + } + + private func post(_ path: String, _ body: [String: Any], _ done: @escaping ([String: Any]?) -> Void) { + guard let url = URL(string: baseUrl + path), + let data = try? JSONSerialization.data(withJSONObject: body) else { done(nil); return } + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "content-type") + req.httpBody = data + session.dataTask(with: req) { d, _, _ in + guard let d = d, !d.isEmpty, + let j = try? JSONSerialization.jsonObject(with: d) as? [String: Any] else { done(nil); return } + done(j) + }.resume() + } +} diff --git a/server/signaling.js b/server/signaling.js index 82a43cc..577d235 100644 --- a/server/signaling.js +++ b/server/signaling.js @@ -21,6 +21,8 @@ // 队伍码绝不上传:它是 PBKDF2 派生密钥的唯一输入,上传即等于交出密钥。 import { WebSocketServer } from 'ws' +import { createServer } from 'node:http' +import { randomUUID } from 'node:crypto' const PORT = Number(process.env.PORT || 8787) const TOKEN = process.env.SIGNAL_TOKEN || '' @@ -29,14 +31,44 @@ const RATE_WINDOW_MS = 10_000 const RATE_MAX = 80 const MAX_CONN_PER_IP = 32 -const wss = new WebSocketServer({ port: PORT }) +const POLL_HOLD_MS = Number(process.env.POLL_HOLD_MS || 25_000) // 长轮询挂起时长(iOS 12 侧;测试可调短) +const POLL_IDLE_MS = 60_000 // 会话空闲回收 +const POLL_QUEUE_MAX = 64 // 单会话待投队列上限 + +const httpServer = createServer((req, res) => handleHttp(req, res)) +const wss = new WebSocketServer({ server: httpServer }) /** room -> Map */ const rooms = new Map() -/** 中继房间:roomHash -> Set(BR-1.2)。roomHash 由客户端算,服务端不知队伍码。 */ +/** + * 中继房间:roomHash -> Set(BR-1.2)。roomHash 由客户端算,服务端不知队伍码。 + * member 是「投递器」抽象:WebSocket 成员(Android)与长轮询会话(iOS 12)同房互通—— + * 两端传输不同,但落在同一个房间集合里,故 Android ↔ iOS 可跨传输互见。 + */ const relayRooms = new Map() const ROOM_RE = /^[0-9a-f]{64}$/ // SHA256 十六进制 +/** sid -> 长轮询会话 */ +const pollSessions = new Map() + +function roomAdd(room, member) { + if (!relayRooms.has(room)) relayRooms.set(room, new Set()) + relayRooms.get(room).add(member) +} + +function roomRemove(room, member) { + const s = relayRooms.get(room) + if (!s) return + s.delete(member) + if (s.size === 0) relayRooms.delete(room) +} + +/** 向房间内除 from 外的全部成员投递密文帧 */ +function roomBroadcast(room, from, kind, body) { + const s = relayRooms.get(room) + if (!s) return + for (const m of s) if (m !== from) m.deliver(kind, body) +} /** ip -> 并发连接数 */ const ipConns = new Map() @@ -55,13 +87,10 @@ function leave(ws) { } function leaveRelay(ws) { - const r = ws.relayRoom - if (!r) return - const peers = relayRooms.get(r) - if (!peers) return - peers.delete(ws) - if (peers.size === 0) relayRooms.delete(r) + if (!ws.relayRoom || !ws.relayMember) return + roomRemove(ws.relayRoom, ws.relayMember) ws.relayRoom = null + ws.relayMember = null } wss.on('connection', (ws, req) => { @@ -127,12 +156,8 @@ wss.on('connection', (ws, req) => { if (typeof body !== 'string' || body.length > MAX_MSG_BYTES) return // 必须已通过 relay-join(含 token 校验)且只能发往自己所在房间—— // 否则任何知道房间哈希的连接都能无凭据注入帧。 - if (ws.relayRoom !== room) return - const peers = relayRooms.get(room) - if (!peers || !peers.has(ws)) return - for (const other of peers) { - if (other !== ws) send(other, { type: 'relay', kind, body }) - } + if (ws.relayRoom !== room || !ws.relayMember) return + roomBroadcast(room, ws.relayMember, kind, body) return } @@ -146,8 +171,8 @@ wss.on('connection', (ws, req) => { } leaveRelay(ws) ws.relayRoom = room - if (!relayRooms.has(room)) relayRooms.set(room, new Set()) - relayRooms.get(room).add(ws) + ws.relayMember = { deliver: (k, b) => send(ws, { type: 'relay', kind: k, body: b }) } + roomAdd(room, ws.relayMember) send(ws, { type: 'relay-joined', peers: relayRooms.get(room).size - 1 }) return } @@ -175,7 +200,128 @@ wss.on('connection', (ws, req) => { ws.on('error', cleanup) }) +// MARK: - HTTP 长轮询中继(BR-1.1,iOS 12 侧) +// iOS 12 无原生 WebSocket API(URLSessionWebSocketTask 需 iOS 13+),坚持"零第三方依赖" +// 故 iOS 走长轮询:位置本就 3 秒一报,不需要真正的长连接。 +// POST /relay/join {room, token} -> {sid} +// POST /relay/send {sid, kind, body} -> 204 +// GET /relay/poll?sid=.. -> {frames:[{kind,body}]}(最多挂起 POLL_HOLD_MS) +// 与 WebSocket 成员同房互通:Android(WS) ↔ iOS(长轮询) 可跨传输互见。 + +function jsonRes(res, code, obj) { + const buf = Buffer.from(JSON.stringify(obj)) + res.writeHead(code, { 'content-type': 'application/json', 'content-length': buf.length }) + res.end(buf) +} + +function readBody(req) { + return new Promise((resolve) => { + let n = 0 + const chunks = [] + req.on('data', (c) => { + n += c.length + if (n > MAX_MSG_BYTES) { req.destroy(); resolve(null); return } + chunks.push(c) + }) + req.on('end', () => { + try { resolve(JSON.parse(Buffer.concat(chunks).toString() || '{}')) } catch { resolve(null) } + }) + req.on('error', () => resolve(null)) + }) +} + +function flushPoll(sess) { + if (!sess.waiter || sess.queue.length === 0) return + const { res, timer } = sess.waiter + clearTimeout(timer) + sess.waiter = null + const frames = sess.queue.splice(0, sess.queue.length) + jsonRes(res, 200, { frames }) +} + +function dropSession(sid) { + const sess = pollSessions.get(sid) + if (!sess) return + if (sess.waiter) { clearTimeout(sess.waiter.timer); try { jsonRes(sess.waiter.res, 200, { frames: [] }) } catch {} } + roomRemove(sess.room, sess.member) + pollSessions.delete(sid) +} + +// 空闲会话回收:长时间不再 poll 的(App 被杀/断网)自动退房 +setInterval(() => { + const now = Date.now() + for (const [sid, s] of pollSessions) if (now - s.lastSeen > POLL_IDLE_MS) dropSession(sid) +}, 15_000).unref?.() + +async function handleHttp(req, res) { + const url = new URL(req.url, 'http://x') + try { + if (req.method === 'POST' && url.pathname === '/relay/join') { + const body = await readBody(req) + if (!body) return jsonRes(res, 400, { error: 'bad body' }) + if (typeof body.room !== 'string' || !ROOM_RE.test(body.room)) return jsonRes(res, 400, { error: 'bad room' }) + if (TOKEN && body.token !== TOKEN) return jsonRes(res, 403, { error: 'bad token' }) + const sid = randomUUID() + const sess = { room: body.room, queue: [], waiter: null, lastSeen: Date.now() } + // 投递器:入队并唤醒挂起的 poll(与 WS 成员在同一房间集合里) + sess.member = { + deliver: (kind, b) => { + sess.queue.push({ kind, body: b }) + if (sess.queue.length > POLL_QUEUE_MAX) sess.queue.shift() // 溢出丢最旧 + flushPoll(sess) + }, + } + pollSessions.set(sid, sess) + roomAdd(sess.room, sess.member) + return jsonRes(res, 200, { sid, peers: relayRooms.get(sess.room).size - 1 }) + } + + if (req.method === 'POST' && url.pathname === '/relay/send') { + const body = await readBody(req) + if (!body) return jsonRes(res, 400, { error: 'bad body' }) + const sess = pollSessions.get(body.sid) + // 必须持有 join 换来的 sid(join 已校验 token),否则无凭据注入 + if (!sess) return jsonRes(res, 403, { error: 'no session' }) + if (!Number.isInteger(body.kind) || body.kind < 0 || body.kind > 255) return jsonRes(res, 400, { error: 'bad kind' }) + if (typeof body.body !== 'string') return jsonRes(res, 400, { error: 'bad frame' }) + sess.lastSeen = Date.now() + roomBroadcast(sess.room, sess.member, body.kind, body.body) + res.writeHead(204); res.end() + return + } + + if (req.method === 'GET' && url.pathname === '/relay/poll') { + const sid = url.searchParams.get('sid') || '' + const sess = pollSessions.get(sid) + if (!sess) return jsonRes(res, 403, { error: 'no session' }) + sess.lastSeen = Date.now() + if (sess.queue.length > 0) { + const frames = sess.queue.splice(0, sess.queue.length) + return jsonRes(res, 200, { frames }) + } + if (sess.waiter) { clearTimeout(sess.waiter.timer); try { jsonRes(sess.waiter.res, 200, { frames: [] }) } catch {} } + const timer = setTimeout(() => { sess.waiter = null; jsonRes(res, 200, { frames: [] }) }, POLL_HOLD_MS) + sess.waiter = { res, timer } + res.on('close', () => { if (sess.waiter && sess.waiter.res === res) { clearTimeout(timer); sess.waiter = null } }) + return + } + + if (req.method === 'POST' && url.pathname === '/relay/leave') { + const body = await readBody(req) + if (body && typeof body.sid === 'string') dropSession(body.sid) + res.writeHead(204); res.end() + return + } + + res.writeHead(404); res.end() + } catch { + try { jsonRes(res, 500, { error: 'server' }) } catch {} + } +} + +httpServer.listen(PORT) + console.log( - `[TrailMate] 信令服务器已启动: ws://0.0.0.0:${PORT}` + + `[TrailMate] 信令服务器已启动: ws://0.0.0.0:${PORT}(含 HTTP 长轮询 /relay/*)` + (TOKEN ? ' (已启用 token 鉴权)' : ' (未设 SIGNAL_TOKEN,任意连接可加入——仅限内网/开发)'), ) diff --git a/tests/mesh-fixture/run.mjs b/tests/mesh-fixture/run.mjs index 697731f..e385e5e 100644 --- a/tests/mesh-fixture/run.mjs +++ b/tests/mesh-fixture/run.mjs @@ -430,5 +430,99 @@ console.log('[12] 服务端业务帧中继(哑中继)') srv.kill() } +// ---------- 13. 长轮询中继 + 跨传输互通(BR-1.1,iOS12 ↔ Android) ---------- +console.log('[13] HTTP 长轮询中继(iOS 12 路径)与 WS 跨传输互通') +{ + const { spawn } = await import('node:child_process') + const { WebSocket } = await import('ws') + const mc = await import('./meshcrypto.mjs') + const PORT = 18901, TOKEN = 'poll-secret', TEAM = 'K7Q9ZP' + const BASE = `http://127.0.0.1:${PORT}` + + const srv = spawn('node', [join(ROOT, 'server/signaling.js')], { + // 挂起调短到 1.5s:让"挂起超时返回空"这条路径可被快速且确定地验证 + env: { ...process.env, PORT: String(PORT), SIGNAL_TOKEN: TOKEN, POLL_HOLD_MS: '1500' }, stdio: 'pipe', + }) + await new Promise((res, rej) => { + const t = setTimeout(() => rej(new Error('server start timeout')), 5000) + srv.stdout.on('data', d => { if (String(d).includes('已启动')) { clearTimeout(t); res() } }) + }) + + const postJson = async (path, body) => { + const r = await fetch(BASE + path, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), + }) + if (r.status === 204) return { status: 204 } + return { status: r.status, json: await r.json().catch(() => null) } + } + + // iOS 侧:长轮询入房 + const joined = await postJson('/relay/join', { room: mc.roomId(TEAM), token: TOKEN }) + const sid = joined.json?.sid + check('长轮询 /relay/join 拿到 sid', typeof sid === 'string' && sid.length > 0) + + const badJoin = await postJson('/relay/join', { room: mc.roomId(TEAM), token: 'wrong' }) + check('长轮询错 token 被拒(403)', badJoin.status === 403, `status=${badJoin.status}`) + + const noSess = await postJson('/relay/send', { sid: 'not-a-session', kind: 2, body: 'AAAA' }) + check('无 sid 不能注入(403)', noSess.status === 403, `status=${noSess.status}`) + + // Android 侧:WebSocket 入同一房间 + const wsc = await new Promise((res, rej) => { + const c = new WebSocket(BASE.replace('http', 'ws')) + c.on('open', () => res(c)); c.on('error', rej) + }) + const wsNext = (c, ms = 3000) => new Promise((res) => { + const t = setTimeout(() => res(null), ms) + c.once('message', d => { clearTimeout(t); res(JSON.parse(String(d))) }) + }) + wsc.send(JSON.stringify({ type: 'relay-join', room: mc.roomId(TEAM), token: TOKEN })) + await wsNext(wsc) + + // 跨传输 A:长轮询(iOS) 发 → WebSocket(Android) 收 + const plainIos = Buffer.from(JSON.stringify({ id: 'ios', n: 'iPhone', lat: 30.25, lng: 120.1, ts: 1 })) + const cipherIos = mc.encrypt(TEAM, plainIos) + const wsRecv = wsNext(wsc) + await postJson('/relay/send', { sid, kind: 2, body: cipherIos.toString('base64') }) + const gotWs = await wsRecv + check('iOS(长轮询) → Android(WS):收到且解密一致', + gotWs?.type === 'relay' && mc.decrypt(TEAM, Buffer.from(gotWs.body, 'base64'))?.equals(plainIos) === true) + + // 跨传输 B:WebSocket(Android) 发 → 长轮询(iOS) 收 + const plainAnd = Buffer.from(JSON.stringify({ id: 'and', n: '安卓', lat: 31.0, lng: 121.0, ts: 2 })) + const cipherAnd = mc.encrypt(TEAM, plainAnd) + const pollPromise = fetch(`${BASE}/relay/poll?sid=${sid}`).then(r => r.json()) + await new Promise(r => setTimeout(r, 150)) // 确保 poll 已挂起 + wsc.send(JSON.stringify({ type: 'relay', room: mc.roomId(TEAM), kind: 2, body: cipherAnd.toString('base64') })) + const polled = await pollPromise + const f0 = polled?.frames?.[0] + check('Android(WS) → iOS(长轮询):挂起的 poll 被唤醒且解密一致', + f0 && f0.kind === 2 && mc.decrypt(TEAM, Buffer.from(f0.body, 'base64'))?.equals(plainAnd) === true, + JSON.stringify(polled)) + + // 异队伍隔离:另一队伍的长轮询会话收不到 + const other = await postJson('/relay/join', { room: mc.roomId('OTHER99'), token: TOKEN }) + wsc.send(JSON.stringify({ type: 'relay', room: mc.roomId(TEAM), kind: 2, body: cipherAnd.toString('base64') })) + await new Promise(r => setTimeout(r, 300)) + const otherPoll = await fetch(`${BASE}/relay/poll?sid=${other.json.sid}`).then(r => r.json()).catch(() => null) + // 该 poll 会挂起到超时,用 race 快速判定队列为空 + check('异队伍长轮询会话收不到别队帧(挂起超时返回空)', + otherPoll !== null && Array.isArray(otherPoll.frames) && otherPoll.frames.length === 0, JSON.stringify(otherPoll)) + + // 队伍码明文零出现(长轮询路径) + check('长轮询上行报文不含队伍码明文', + !JSON.stringify({ sid, room: mc.roomId(TEAM), body: cipherIos.toString('base64') }).includes(TEAM)) + + // iOS 源码防漂移 + const iosCrypto = readFileSync(join(ROOT, 'ios12/Sources/Mesh/MeshCrypto.swift'), 'utf8') + const iosNet = readFileSync(join(ROOT, 'ios12/Sources/Mesh/NetTransport.swift'), 'utf8') + check('iOS roomId 用同一域分离串', iosCrypto.includes('trailmate-room-v1|')) + check('iOS NetTransport 走 URLSession 长轮询(无第三方依赖)', + iosNet.includes('URLSession') && iosNet.includes('/relay/poll') && !iosNet.includes('import Starscream')) + + try { wsc.close() } catch {} + srv.kill() +} + console.log(failures === 0 ? '\n全部通过 ✅' : `\n失败 ${failures} 项 ❌`) process.exit(failures ? 1 : 0)