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 分离等) - 验收基线(复用现有测试套件)与建议的工作顺序
This commit is contained in:
commit
85e9a53750
65 files changed
+18603
No files matched your search
+2385
File diff suppressed because it is too large.
Load diff
+235
@@ -0,0 +1,235 @@
|
||||
/*!
|
||||
* coord.js — 坐标系统转换与地理计算(纯 vanilla ES2020,无外部依赖)
|
||||
*
|
||||
* 约定(见 docs/PROTOCOL.md):
|
||||
* - 内部存储、发往后端的一切经纬度 **一律 WGS84**;
|
||||
* - 高德 / 腾讯瓦片为 GCJ-02(火星坐标),ArcGIS / OSM 为 WGS84;
|
||||
* - 显示用点 = WGS84 → 瓦片基准(GCJ-02 源时做一次 wgs84ToGcj02),
|
||||
* 取点(鼠标 → 坐标)= 瓦片基准 → WGS84(gcj02ToWgs84)。
|
||||
*
|
||||
* 导出:window.Coord(转换)、window.Geo(距离/格式化工具)
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
var PI = Math.PI;
|
||||
var A = 6378245.0; // 克拉索夫斯基椭球长半轴
|
||||
var EE = 0.00669342162296594323; // 偏心率平方
|
||||
|
||||
/** 中国大陆粗略范围之外(含港澳台、境外)不做偏移 */
|
||||
function outOfChina(lng, lat) {
|
||||
if (!isFinite(lng) || !isFinite(lat)) return true;
|
||||
if (lng < 72.004 || lng > 137.8347) return true;
|
||||
if (lat < 0.8293 || lat > 55.8271) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function transformLat(x, y) {
|
||||
var ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x));
|
||||
ret += (20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0 / 3.0;
|
||||
ret += (20.0 * Math.sin(y * PI) + 40.0 * Math.sin(y / 3.0 * PI)) * 2.0 / 3.0;
|
||||
ret += (160.0 * Math.sin(y / 12.0 * PI) + 320.0 * Math.sin(y * PI / 30.0)) * 2.0 / 3.0;
|
||||
return ret;
|
||||
}
|
||||
|
||||
function transformLng(x, y) {
|
||||
var ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x));
|
||||
ret += (20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0 / 3.0;
|
||||
ret += (20.0 * Math.sin(x * PI) + 40.0 * Math.sin(x / 3.0 * PI)) * 2.0 / 3.0;
|
||||
ret += (150.0 * Math.sin(x / 12.0 * PI) + 300.0 * Math.sin(x / 30.0 * PI)) * 2.0 / 3.0;
|
||||
return ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* WGS84 → GCJ-02
|
||||
* @returns {[number, number]} [lng, lat]
|
||||
*/
|
||||
function wgs84ToGcj02(lng, lat) {
|
||||
lng = +lng; lat = +lat;
|
||||
if (outOfChina(lng, lat)) return [lng, lat];
|
||||
var dLat = transformLat(lng - 105.0, lat - 35.0);
|
||||
var dLng = transformLng(lng - 105.0, lat - 35.0);
|
||||
var radLat = lat / 180.0 * PI;
|
||||
var magic = Math.sin(radLat);
|
||||
magic = 1 - EE * magic * magic;
|
||||
var sqrtMagic = Math.sqrt(magic);
|
||||
dLat = (dLat * 180.0) / ((A * (1 - EE)) / (magic * sqrtMagic) * PI);
|
||||
dLng = (dLng * 180.0) / (A / sqrtMagic * Math.cos(radLat) * PI);
|
||||
return [lng + dLng, lat + dLat];
|
||||
}
|
||||
|
||||
/**
|
||||
* GCJ-02 → WGS84(迭代逼近,精度 1e-9 度 ≈ 0.1mm,足够)
|
||||
* @returns {[number, number]} [lng, lat]
|
||||
*/
|
||||
function gcj02ToWgs84(lng, lat) {
|
||||
lng = +lng; lat = +lat;
|
||||
if (outOfChina(lng, lat)) return [lng, lat];
|
||||
var wLng = lng, wLat = lat;
|
||||
for (var i = 0; i < 12; i++) {
|
||||
var g = wgs84ToGcj02(wLng, wLat);
|
||||
var dLng = lng - g[0];
|
||||
var dLat = lat - g[1];
|
||||
wLng += dLng;
|
||||
wLat += dLat;
|
||||
if (Math.abs(dLng) < 1e-10 && Math.abs(dLat) < 1e-10) break;
|
||||
}
|
||||
return [wLng, wLat];
|
||||
}
|
||||
|
||||
/** 判断瓦片源使用的坐标基准 */
|
||||
function datumOfSource(key, src) {
|
||||
if (src) {
|
||||
var explicit = src.datum || src.coordsys || src.crs || src.coordinate_system;
|
||||
if (typeof explicit === 'string') {
|
||||
var e = explicit.toLowerCase();
|
||||
if (e.indexOf('gcj') >= 0 || e.indexOf('mars') >= 0 || e.indexOf('amap') >= 0) return 'gcj02';
|
||||
if (e.indexOf('wgs') >= 0 || e.indexOf('4326') >= 0 || e.indexOf('epsg') >= 0) return 'wgs84';
|
||||
}
|
||||
}
|
||||
var probe = ((key || '') + ' ' + ((src && (src.url || src.name)) || '')).toLowerCase();
|
||||
if (/amap|autonavi|gaode|tencent|gtimg|map\.qq|qqmap/.test(probe)) return 'gcj02';
|
||||
return 'wgs84';
|
||||
}
|
||||
|
||||
/** WGS84 → 显示基准(GCJ-02 源需要偏移,其余原样返回) */
|
||||
function toDisplay(lng, lat, datum) {
|
||||
if (datum === 'gcj02') return wgs84ToGcj02(lng, lat);
|
||||
return [+lng, +lat];
|
||||
}
|
||||
|
||||
/** 显示基准 → WGS84 */
|
||||
function fromDisplay(lng, lat, datum) {
|
||||
if (datum === 'gcj02') return gcj02ToWgs84(lng, lat);
|
||||
return [+lng, +lat];
|
||||
}
|
||||
|
||||
/** 保留 6 位小数(契约要求下发坐标 6 位小数) */
|
||||
function round6(v) { return Math.round((+v) * 1e6) / 1e6; }
|
||||
|
||||
global.Coord = {
|
||||
outOfChina: outOfChina,
|
||||
wgs84ToGcj02: wgs84ToGcj02,
|
||||
gcj02ToWgs84: gcj02ToWgs84,
|
||||
datumOfSource: datumOfSource,
|
||||
toDisplay: toDisplay,
|
||||
fromDisplay: fromDisplay,
|
||||
round6: round6,
|
||||
A: A,
|
||||
EE: EE
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 地理计算 / 格式化小工具 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
var EARTH_R = 6371008.8; // 平均地球半径(米)
|
||||
|
||||
function rad(d) { return d * PI / 180; }
|
||||
|
||||
/** 两点球面距离(米),入参 [lng,lat] 或 (lng,lat) */
|
||||
function haversine(a, b) {
|
||||
var lng1, lat1, lng2, lat2;
|
||||
if (Array.isArray(a)) { lng1 = a[0]; lat1 = a[1]; } else { lng1 = a; lat1 = arguments[1]; b = arguments[2]; lng2 = b[0]; lat2 = b[1]; }
|
||||
if (Array.isArray(b)) { lng2 = b[0]; lat2 = b[1]; }
|
||||
var dLat = rad(lat2 - lat1);
|
||||
var dLng = rad(lng2 - lng1);
|
||||
var s = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(rad(lat1)) * Math.cos(rad(lat2)) * Math.sin(dLng / 2) * Math.sin(dLng / 2);
|
||||
return 2 * EARTH_R * Math.asin(Math.min(1, Math.sqrt(s)));
|
||||
}
|
||||
|
||||
/** 折线总长度(米) */
|
||||
function pathLength(path) {
|
||||
var d = 0;
|
||||
if (!path || path.length < 2) return 0;
|
||||
for (var i = 1; i < path.length; i++) d += haversine(path[i - 1], path[i]);
|
||||
return d;
|
||||
}
|
||||
|
||||
/** 距离格式化:<1000 → 米,否则 公里 */
|
||||
function fmtDistance(m) {
|
||||
if (!isFinite(m)) return '--';
|
||||
if (m < 1000) return Math.round(m) + ' 米';
|
||||
if (m < 10000) return (m / 1000).toFixed(2) + ' 公里';
|
||||
return (m / 1000).toFixed(1) + ' 公里';
|
||||
}
|
||||
|
||||
/** 秒 → 中文时长 */
|
||||
function fmtDuration(sec) {
|
||||
if (!isFinite(sec) || sec < 0) return '--';
|
||||
sec = Math.round(sec);
|
||||
var h = Math.floor(sec / 3600);
|
||||
var m = Math.floor((sec % 3600) / 60);
|
||||
var s = sec % 60;
|
||||
var pad = function (n) { return (n < 10 ? '0' : '') + n; };
|
||||
if (h > 0) return h + ':' + pad(m) + ':' + pad(s);
|
||||
return pad(m) + ':' + pad(s);
|
||||
}
|
||||
|
||||
/** 秒 → 「1 小时 2 分」式估算文案 */
|
||||
function fmtDurationCn(sec) {
|
||||
if (!isFinite(sec) || sec < 0) return '--';
|
||||
sec = Math.round(sec);
|
||||
if (sec < 60) return sec + ' 秒';
|
||||
var m = Math.floor(sec / 60);
|
||||
if (m < 60) return m + ' 分 ' + (sec % 60) + ' 秒';
|
||||
var h = Math.floor(m / 60);
|
||||
return h + ' 小时 ' + (m % 60) + ' 分';
|
||||
}
|
||||
|
||||
function fmtSpeed(ms) {
|
||||
if (!isFinite(ms)) return '--';
|
||||
return (ms * 3.6).toFixed(1) + ' km/h';
|
||||
}
|
||||
|
||||
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
|
||||
|
||||
function fmtLngLat(lng, lat, digits) {
|
||||
var d = digits == null ? 6 : digits;
|
||||
if (!isFinite(lng) || !isFinite(lat)) return '--';
|
||||
return (+lng).toFixed(d) + ', ' + (+lat).toFixed(d);
|
||||
}
|
||||
|
||||
/** 倍速滑块(0..1000)↔ 0.1x..50x 指数映射,低速段更细腻 */
|
||||
var SPEED_MIN = 0.1, SPEED_MAX = 50;
|
||||
function sliderToSpeed(t) {
|
||||
var k = SPEED_MAX / SPEED_MIN;
|
||||
return SPEED_MIN * Math.pow(k, clamp(+t, 0, 1));
|
||||
}
|
||||
function speedToSlider(v) {
|
||||
var k = SPEED_MAX / SPEED_MIN;
|
||||
return clamp(Math.log(clamp(+v, SPEED_MIN, SPEED_MAX) / SPEED_MIN) / Math.log(k), 0, 1);
|
||||
}
|
||||
function fmtSpeedScale(v) {
|
||||
var x = +v;
|
||||
if (x >= 10) return x.toFixed(0) + 'x';
|
||||
if (x >= 1) return x.toFixed(1) + 'x';
|
||||
return x.toFixed(2) + 'x';
|
||||
}
|
||||
|
||||
/** 时间戳 → HH:MM:SS */
|
||||
function fmtClock(ts) {
|
||||
var d = ts == null ? new Date() : new Date(ts * 1000);
|
||||
if (isNaN(d.getTime())) d = new Date();
|
||||
var pad = function (n) { return (n < 10 ? '0' : '') + n; };
|
||||
return pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
|
||||
}
|
||||
|
||||
global.Geo = {
|
||||
haversine: haversine,
|
||||
pathLength: pathLength,
|
||||
fmtDistance: fmtDistance,
|
||||
fmtDuration: fmtDuration,
|
||||
fmtDurationCn: fmtDurationCn,
|
||||
fmtSpeed: fmtSpeed,
|
||||
fmtLngLat: fmtLngLat,
|
||||
fmtClock: fmtClock,
|
||||
clamp: clamp,
|
||||
sliderToSpeed: sliderToSpeed,
|
||||
speedToSlider: speedToSlider,
|
||||
fmtSpeedScale: fmtSpeedScale,
|
||||
SPEED_MIN: SPEED_MIN,
|
||||
SPEED_MAX: SPEED_MAX
|
||||
};
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
+1186
File diff suppressed because it is too large.
Load diff
+339
@@ -0,0 +1,339 @@
|
||||
/*!
|
||||
* 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);
|
||||
Reference in new issue
Block a user