Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/requirements/03-Gap分析与演进规划.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 离线识别,选小票照片自动填最大金额;待真机拍票验收) |
Expand Down
7 changes: 4 additions & 3 deletions docs/requirements/04-后台服务端规划.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**)

Expand Down Expand Up @@ -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 | 小改动,大安心 |

Expand Down
11 changes: 11 additions & 0 deletions ios12/Sources/Mesh/Identity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand Down
48 changes: 43 additions & 5 deletions ios12/Sources/Mesh/MeshBus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,46 @@ 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 }

func start() {
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) {
Expand All @@ -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 顶替旧的。
Expand All @@ -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] 并按当前队伍码过滤
Expand All @@ -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)..<payload.count)
// 解密并验 MAC(G-CM-3):错队伍码/被篡改/旧版明文一律丢弃
guard let body = MeshCrypto.decrypt(team: team, sealed) else { return }
handlers[kind]?(body)
if team != Identity.team { return } // BLE 侧队伍过滤(明文 team)
dispatchSealed(kind, payload.subdata(in: (2 + tlen)..<payload.count))
}

func bleMeshDidUpdatePeers(_ count: Int) {
Expand Down
10 changes: 10 additions & 0 deletions ios12/Sources/Mesh/MeshCrypto.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,16 @@ enum MeshCrypto {
return out
}

// 中继房间哈希(BR-1.2):SHA256("trailmate-room-v1|" + 队伍码)。
// 与加密派生盐 trailmate-mesh-v1 不同做域分离;上网的只有它,队伍码绝不上传。
// 与 Android MeshCrypto.roomId / 夹具 meshcrypto.mjs 一致。
static func roomId(_ team: String) -> 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
Expand Down
130 changes: 130 additions & 0 deletions ios12/Sources/Mesh/NetTransport.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
Loading
Loading