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)