Files
ios-location-track/web/js/ws.js
T
jbjf6666 85e9a53750 docs: 新增 Windows 移植交接文档
docs/WINDOWS_PORT.md 覆盖:
- 逐文件可移植性审计(核心逻辑层约 90% 可直接复用)
- 核心难点:iOS 17+ 的 RSD 隧道无法在 Windows 复用(macOS 实现依赖 XPC +
  ObjC runtime 私有框架),改用 pymobiledevice3 的 userspace 隧道
- 已实测确认的命令与 API(--userspace 挂在 lockdown start-tunnel / mounter
  auto-mount 上,remote start-tunnel 不支持;类名是 UserspaceRsdTunnel)
- 窗口启动、数据目录、打包与分发(DMG → Inno Setup)、图标(.icns → .ico)
- macOS 侧踩过的坑:镜像挂载用 Python API、必须打包包元数据、不要把 distutils
  放进 excludes、AlreadyMountedError 当成功处理
- 设计约束(DVT 长连接、只能改经纬度、last_point 与 last_reported 分离等)
- 验收基线(复用现有测试套件)与建议的工作顺序
2026-10-16 01:05:09 +08:00

340 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*!
* ws.js — WebSocket 客户端(协议见 docs/PROTOCOL.md §3)
*
* 能力:
* - 连接 ws://127.0.0.1:8787/ws,连接后立即发 hello
* - 自动重连:指数退避 400ms → 10s(带抖动),连接成功后重置
* - 心跳:每 10s 发 ping;连续 3 次没有 pong 主动断开重连
* - 命令请求-应答:request(cmd, fields, timeout) → Promise(ack ok:false 会 reject)
* - 事件订阅:on(type, fn) / on('*', fn) / onState(fn)
*
* 导出:window.WSClient
*/
(function (global) {
'use strict';
var CMD_TIMEOUT = 8000;
var TICK_TIMEOUT = 6000;
var HEARTBEAT_MS = 10000;
var MAX_BACKOFF = 10000;
function nowMs() {
return (global.performance && global.performance.now) ? global.performance.now() : Date.now();
}
function WSClient(opts) {
opts = opts || {};
this.url = opts.url || defaultUrl();
// 部分服务端实现会在 101 响应里回带未请求的 Sec-WebSocket-Protocol,
// 浏览器按 RFC 6455 会直接判定握手失败。这里在首次连接失败后自动带上 v1 再试一次。
this.subprotocols = opts.subprotocols || null;
this.everOpened = false;
this.protocolFallback = false;
this.handlers = Object.create(null); // type -> [fn]
this.stateHandlers = [];
this.state = 'closed'; // closed | connecting | open
this.sock = null;
this.seq = 0;
this.pending = Object.create(null); // id -> {resolve, reject, timer, cmd}
this.attempt = 0;
this.closedByUser = false;
this.reconnectTimer = null;
this.heartbeatTimer = null;
this.lastPongAt = 0;
// 最近一次收到任何入站消息的时间(不只是 pong):用于判断连接是否真的静默
this.lastInboundAt = 0;
this.missedPings = 0;
this.manualClose = false;
}
function defaultUrl() {
var loc = global.location;
var host = (loc && loc.hostname) ? loc.hostname : '127.0.0.1';
var port = (loc && loc.port) ? loc.port : '8787';
var proto = (loc && loc.protocol === 'https:') ? 'wss:' : 'ws:';
return proto + '//' + host + ':' + port + '/ws';
}
WSClient.prototype.on = function (type, fn) {
(this.handlers[type] || (this.handlers[type] = [])).push(fn);
return this;
};
WSClient.prototype.off = function (type, fn) {
var list = this.handlers[type];
if (!list) return this;
var i = list.indexOf(fn);
if (i >= 0) list.splice(i, 1);
return this;
};
WSClient.prototype.onState = function (fn) {
this.stateHandlers.push(fn);
return this;
};
WSClient.prototype._setState = function (s, detail) {
if (this.state === s && !detail) return;
this.state = s;
for (var i = 0; i < this.stateHandlers.length; i++) {
try { this.stateHandlers[i](s, detail); } catch (e) { /* 忽略订阅者异常 */ }
}
};
WSClient.prototype._emit = function (type, payload, raw) {
var list = this.handlers[type];
if (list) {
for (var i = 0; i < list.length; i++) {
try { list[i](payload, raw); } catch (e) {
if (global.console) global.console.error('[ws] 事件处理异常 ' + type, e);
}
}
}
var all = this.handlers['*'];
if (all) {
for (var j = 0; j < all.length; j++) {
try { all[j](payload, raw, type); } catch (e2) { /* 忽略 */ }
}
}
};
WSClient.prototype.connect = function () {
var self = this;
if (this.sock && (this.sock.readyState === 0 || this.sock.readyState === 1)) return this;
this.closedByUser = false;
this.manualClose = false;
this._setState('connecting');
var sock;
try {
sock = this.subprotocols ? new WebSocket(this.url, this.subprotocols) : new WebSocket(this.url);
} catch (e) {
this._scheduleReconnect('WebSocket 构造失败:' + e.message);
return this;
}
this.sock = sock;
sock.onopen = function () {
self.attempt = 0;
self.everOpened = true;
self.lastPongAt = nowMs();
self.lastInboundAt = nowMs();
self.missedPings = 0;
self._setState('open');
self._emit('open', { url: self.url });
// hello 用请求-应答发送:服务端把 welcome 数据并回在 hello 的 ack 里
// (契约里 hello → welcome;两种形态都兼容:ack 命中后归一化成 welcome 事件)
self.request('hello', { client: 'web' }, 8000).then(function (msg) {
self._emit('welcome', msg);
}).catch(function (err) {
self._emit('hello_failed', { error: err && err.message });
});
self._startHeartbeat();
};
sock.onmessage = function (ev) {
var msg;
try {
msg = JSON.parse(ev.data);
} catch (e) {
self._emit('error', { error: '服务端消息不是合法 JSON', raw: String(ev.data).slice(0, 200) });
return;
}
if (!msg || typeof msg !== 'object') return;
self._dispatch(msg);
};
sock.onerror = function () {
self._emit('sockerror', { url: self.url });
};
sock.onclose = function (ev) {
self._stopHeartbeat();
self.sock = null;
if (self.manualClose || self.closedByUser) {
self._setState('closed', ev && ev.reason);
self._emit('close', { code: ev && ev.code, reason: ev && ev.reason, manual: true });
return;
}
self._setState('closed', ev && ev.reason);
self._emit('close', { code: ev && ev.code, reason: ev && ev.reason, manual: false });
self._scheduleReconnect(ev && ev.reason ? ('连接关闭:' + ev.reason) : '连接已关闭');
};
return this;
};
WSClient.prototype._dispatch = function (msg) {
// 任何入站消息都证明服务端还活着。不能只认 pong:
// 服务端执行 device.connect(iOS 17+ 建 RSD 隧道,最长约 1 分钟)时会持续推 log 帧,
// 但由于该命令是同步阻塞的,ping 的 pong 可能迟迟回不来——只认 pong 会误判断线。
this.lastInboundAt = nowMs();
this.missedPings = 0;
if (msg.type === 'pong') {
this.lastPongAt = nowMs();
}
// 请求-应答:只要带 id 且命中 pending,就作为该请求的应答
if (msg.id && this.pending[msg.id]) {
var p = this.pending[msg.id];
delete this.pending[msg.id];
if (p.timer) clearTimeout(p.timer);
if (msg.type === 'ack' && msg.ok === false) {
var err = new Error(msg.error || '服务端拒绝了该命令');
err.code = msg.code || 'ack_failed';
err.cmd = msg.cmd || p.cmd;
p.reject(err);
} else {
p.resolve(msg);
}
// 应答同时也是事件(例如 ack 之外带状态),继续广播
}
this._emit(msg.type || 'unknown', msg, msg);
};
/** 发送原始对象(无应答等待) */
WSClient.prototype.send = function (obj) {
if (!this.sock || this.sock.readyState !== 1) return false;
try {
this.sock.send(JSON.stringify(obj));
return true;
} catch (e) {
this._emit('error', { error: '发送失败:' + e.message });
return false;
}
};
WSClient.prototype.nextId = function (prefix) {
this.seq += 1;
return (prefix || 'c') + this.seq + '-' + Math.floor(Math.random() * 1e6).toString(36);
};
/**
* 命令请求-应答。
* @param {string} cmd 命令 type,例如 'play.start'
* @param {object} [fields] 附加字段
* @param {number} [timeout] 毫秒,默认 8000
* @returns {Promise<object>} 应答消息;ok:false 或超时 → reject(Error)
*/
WSClient.prototype.request = function (cmd, fields, timeout) {
var self = this;
var id = this.nextId();
var payload = Object.assign({ type: cmd, id: id }, fields || {});
return new Promise(function (resolve, reject) {
if (!self.sock || self.sock.readyState !== 1) {
var e = new Error('尚未连接到本地服务,请确认服务已启动');
e.code = 'ws_closed';
reject(e);
return;
}
var timer = setTimeout(function () {
if (self.pending[id]) {
delete self.pending[id];
var te = new Error('命令超时(' + cmd + ')');
te.code = 'timeout';
te.cmd = cmd;
reject(te);
}
}, timeout || CMD_TIMEOUT);
self.pending[id] = { resolve: resolve, reject: reject, timer: timer, cmd: cmd };
var ok = self.send(payload);
if (!ok) {
clearTimeout(timer);
delete self.pending[id];
var se = new Error('发送失败:连接不可用');
se.code = 'ws_send_failed';
reject(se);
}
});
};
WSClient.prototype.isOpen = function () {
return !!this.sock && this.sock.readyState === 1;
};
WSClient.prototype._startHeartbeat = function () {
var self = this;
this._stopHeartbeat();
this.heartbeatTimer = setInterval(function () {
if (!self.isOpen()) return;
var lastSeen = Math.max(self.lastInboundAt || 0, self.lastPongAt || 0);
var idle = nowMs() - lastSeen;
// 只要还在收到任何消息(log/status/location/ack)就说明连接健康,不累计丢包
if (idle > HEARTBEAT_MS * 3) {
self.missedPings += 1;
} else {
self.missedPings = 0;
}
if (self.missedPings >= 3) {
self._emit('error', {
error: '连接静默超过 ' + Math.round(HEARTBEAT_MS * 9 / 1000) + ' 秒,正在重建连接'
});
try { self.sock.close(); } catch (e) { /* 忽略 */ }
return;
}
self.request('ping', {}, TICK_TIMEOUT).catch(function () { /* ping 超时由 missedPings 兜底 */ });
}, HEARTBEAT_MS);
};
WSClient.prototype._stopHeartbeat = function () {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
};
WSClient.prototype._scheduleReconnect = function (reason) {
var self = this;
if (this.closedByUser || this.reconnectTimer) return;
if (!this.everOpened && !this.protocolFallback && !this.subprotocols) {
this.protocolFallback = true;
this.subprotocols = ['v1'];
this.attempt = 0;
this._emit('protocol_fallback', { reason: reason || '' });
}
var delay = Math.min(MAX_BACKOFF, 400 * Math.pow(2, this.attempt)) + Math.floor(Math.random() * 250);
this.attempt += 1;
this._emit('reconnecting', { delay: delay, attempt: this.attempt, reason: reason });
this.reconnectTimer = setTimeout(function () {
self.reconnectTimer = null;
self.connect();
}, delay);
};
WSClient.prototype.close = function () {
this.closedByUser = true;
this.manualClose = true;
this._stopHeartbeat();
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
var ids = Object.keys(this.pending);
for (var i = 0; i < ids.length; i++) {
var p = this.pending[ids[i]];
if (p && p.timer) clearTimeout(p.timer);
if (p) p.reject(new Error('连接已关闭'));
delete this.pending[ids[i]];
}
if (this.sock) {
try { this.sock.close(); } catch (e) { /* 忽略 */ }
}
this.sock = null;
this._setState('closed', 'manual');
};
/** 立即重连(用户点「重试」时用) */
WSClient.prototype.reconnectNow = function () {
this.attempt = 0;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.sock) {
try { this.sock.close(); } catch (e) { /* 忽略 */ }
this.sock = null;
}
this.connect();
};
global.WSClient = WSClient;
})(typeof window !== 'undefined' ? window : globalThis);