phone-lens 1.1.1 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +20 -5
- package/lib/dev.js +289 -43
- package/lib/index.js +347 -50
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -277,6 +277,11 @@ window.__ModuleLoader__.load({
|
|
|
277
277
|
const wsRef = useRef(null);
|
|
278
278
|
const framesRef = useRef(0);
|
|
279
279
|
const rotRef = useRef(0);
|
|
280
|
+
// last devices-broadcast length — the onmessage closure never re-runs
|
|
281
|
+
// (the WS effect depends only on wsUrl), so comparing against the
|
|
282
|
+
// `devices` STATE there read a stale first-render value and fired the
|
|
283
|
+
// QR-rotation flash on EVERY broadcast instead of only on growth
|
|
284
|
+
const lastDeviceCount = useRef(0);
|
|
280
285
|
|
|
281
286
|
const baseUrl = `http://127.0.0.1:${port}`;
|
|
282
287
|
const wsUrl = `ws://127.0.0.1:${port}/ws/view`;
|
|
@@ -368,8 +373,10 @@ window.__ModuleLoader__.load({
|
|
|
368
373
|
.catch((e) => setFlashErr("自动放入失败: " + String(e && e.message || e).slice(0, 90)));
|
|
369
374
|
} else if (m.type === "devices") {
|
|
370
375
|
// side effects OUT of the updater (updaters must stay pure —
|
|
371
|
-
// StrictMode double-invokes them); compare
|
|
372
|
-
|
|
376
|
+
// StrictMode double-invokes them); compare against the ref,
|
|
377
|
+
// NOT the `devices` state (stale-closure trap), then setState
|
|
378
|
+
const grew = (m.devices || []).length > lastDeviceCount.current;
|
|
379
|
+
lastDeviceCount.current = (m.devices || []).length;
|
|
373
380
|
setDevices(m.devices || []);
|
|
374
381
|
// a NEW device came online → the one-time pairing code was
|
|
375
382
|
// consumed; rotate the QR so the next scan sees a fresh code
|
|
@@ -385,6 +392,12 @@ window.__ModuleLoader__.load({
|
|
|
385
392
|
if (prev === "disconnected") return "connecting";
|
|
386
393
|
return prev;
|
|
387
394
|
});
|
|
395
|
+
} else if (m.type === "paired") {
|
|
396
|
+
// a phone completed /pair — the one-time pairing code burned
|
|
397
|
+
// with it. Rotate the QR right now; the devices-list growth
|
|
398
|
+
// path also rotates, but only once the uplink actually lands.
|
|
399
|
+
setFlashOk(`设备已配对${m.name ? ": " + m.name : ""},二维码已更换`);
|
|
400
|
+
void refreshQr(true);
|
|
388
401
|
} else if (m.type === "error") {
|
|
389
402
|
setFlashErr(`${m.code}${m.message ? ":" + m.message : ""}`);
|
|
390
403
|
}
|
|
@@ -928,9 +941,11 @@ window.__ModuleLoader__.load({
|
|
|
928
941
|
{ className: "lm-setsec-rows" },
|
|
929
942
|
// tool names in the open (user-requested): what the
|
|
930
943
|
// model will actually see in its tool list
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
944
|
+
infoRow("拍摄照片(phone_take_photo)", "手机立即拍照,照片直接进入模型视野——不放入输入框、不保存到电脑文件夹,与拍照注入模式设置无关。", "w1"),
|
|
945
|
+
infoRow("关闭手机摄像头(phone_camera_pause)", "让手机相机提前进入空闲省电状态。", "w2"),
|
|
946
|
+
infoRow("恢复手机摄像头(phone_camera_resume)", "立即打开相机并恢复预览拍摄。", "w3"),
|
|
947
|
+
infoRow("调整变焦(phone_camera_zoom)", "按指定倍数(或步进)拉近/拉远手机镜头,取景画面与照片同步生效,超出设备范围自动收拢。", "w4"),
|
|
948
|
+
infoRow("查询变焦(phone_camera_zoom_info)", "读取手机当前变焦倍数与设备支持的变焦范围。", "w5"),
|
|
934
949
|
),
|
|
935
950
|
),
|
|
936
951
|
flash ? h("div", { className: "lm-setsec-note", key: "f" }, flash) : null,
|
package/lib/dev.js
CHANGED
|
@@ -2,7 +2,7 @@ import { basename, join, resolve, sep } from "node:path";
|
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { createHash, randomBytes, randomInt, randomUUID, timingSafeEqual } from "node:crypto";
|
|
4
4
|
import { createServer } from "node:http";
|
|
5
|
-
import { readFile, readdir, stat, unlink } from "node:fs/promises";
|
|
5
|
+
import { readFile, readdir, rm, stat, unlink } from "node:fs/promises";
|
|
6
6
|
import { WebSocketServer } from "ws";
|
|
7
7
|
import QRCode from "qrcode";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
@@ -760,6 +760,9 @@ function drawFrame(buf) {
|
|
|
760
760
|
if (now - fpsTimer >= 1000) { $('fps').textContent = (frames * 1000 / (now - fpsTimer)).toFixed(1) + ' fps'; frames = 0; fpsTimer = now; }
|
|
761
761
|
lastFrameAt = now;
|
|
762
762
|
};
|
|
763
|
+
// truncated/corrupt JPEG: onload never fires and the blob URL leaks —
|
|
764
|
+
// revoke on error too (same fix the main client already has)
|
|
765
|
+
img.onerror = () => URL.revokeObjectURL(url);
|
|
763
766
|
img.src = url;
|
|
764
767
|
}
|
|
765
768
|
|
|
@@ -792,6 +795,12 @@ function connect() {
|
|
|
792
795
|
$('hint').style.display = m.online ? 'none' : '';
|
|
793
796
|
$('hint').textContent = '等待手机连接…';
|
|
794
797
|
break;
|
|
798
|
+
case 'paired':
|
|
799
|
+
// pairing consumed the one-time code: rotate the QR now, don't wait
|
|
800
|
+
// for the uplink (this page keys off nothing else to trigger it)
|
|
801
|
+
log('设备已配对' + (m.name ? ': ' + m.name : '') + ',二维码已更换', 'ok');
|
|
802
|
+
void refreshQr();
|
|
803
|
+
break;
|
|
795
804
|
case 'capture_pending':
|
|
796
805
|
pendingShot = m.captureId;
|
|
797
806
|
log('快门已触发' + (m.note ? '(' + m.note + ')' : '') + '…');
|
|
@@ -804,6 +813,29 @@ function connect() {
|
|
|
804
813
|
case 'upload':
|
|
805
814
|
log('图片已入库 ' + (m.name || ''), 'ok');
|
|
806
815
|
break;
|
|
816
|
+
// modern-protocol branches (this page previously only knew the retired
|
|
817
|
+
// messages, so photos succeeded silently and preview-off was invisible)
|
|
818
|
+
case 'pending_image':
|
|
819
|
+
if (pendingShot) { pendingShot = null; log('照片已入库,已放入输入框' + (m.name ? ': ' + m.name : ''), 'ok'); }
|
|
820
|
+
else log('照片已入库,已放入输入框' + (m.name ? ': ' + m.name : ''), 'ok');
|
|
821
|
+
break;
|
|
822
|
+
case 'upload_saved':
|
|
823
|
+
if (pendingShot) pendingShot = null;
|
|
824
|
+
log('已保存至 ' + (m.dir || '指定文件夹') + (m.name ? ' · ' + m.name : ''), 'ok');
|
|
825
|
+
break;
|
|
826
|
+
case 'preview_state':
|
|
827
|
+
if (!m.on && m.reason === 'offline') { $('dev').textContent = '连接已断开'; $('hint').textContent = '等待手机连接…'; $('hint').style.display = ''; }
|
|
828
|
+
else if (!m.on) { $('dev').textContent = '预览已关闭'; $('hint').textContent = '手机端已停止预览'; $('hint').style.display = ''; }
|
|
829
|
+
break;
|
|
830
|
+
case 'devices': {
|
|
831
|
+
const active = (m.devices || []).find((d) => d.active);
|
|
832
|
+
$('dot').className = active ? 'on' : '';
|
|
833
|
+
$('dev').textContent = active ? (active.name || '已连接') : '未连接';
|
|
834
|
+
$('shoot').disabled = !active;
|
|
835
|
+
$('hint').style.display = active ? 'none' : '';
|
|
836
|
+
$('hint').textContent = '等待手机连接…';
|
|
837
|
+
break;
|
|
838
|
+
}
|
|
807
839
|
case 'error':
|
|
808
840
|
log('错误 ' + m.code + (m.message ? ': ' + m.message : ''), 'err');
|
|
809
841
|
break;
|
|
@@ -998,15 +1030,22 @@ async function startLensServer(deps) {
|
|
|
998
1030
|
done(404, "Not Found");
|
|
999
1031
|
});
|
|
1000
1032
|
const heartbeat = setInterval(() => hub$1.pingAll(), 2e4);
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
server.
|
|
1006
|
-
|
|
1007
|
-
|
|
1033
|
+
try {
|
|
1034
|
+
await new Promise((resolve$1, reject) => {
|
|
1035
|
+
const onError = reject;
|
|
1036
|
+
server.once("error", onError);
|
|
1037
|
+
server.listen(config$1.server.port, config$1.server.host, () => {
|
|
1038
|
+
server.removeListener("error", onError);
|
|
1039
|
+
server.on("error", (e) => log$1("warn", `http server error: ${String(e)}`));
|
|
1040
|
+
resolve$1();
|
|
1041
|
+
});
|
|
1008
1042
|
});
|
|
1009
|
-
})
|
|
1043
|
+
} catch (error) {
|
|
1044
|
+
clearInterval(heartbeat);
|
|
1045
|
+
for (const client of wss.clients) client.terminate();
|
|
1046
|
+
await new Promise((resolve$1) => wss.close(() => resolve$1()));
|
|
1047
|
+
throw error;
|
|
1048
|
+
}
|
|
1010
1049
|
log$1("info", `phone-lens listening on ${config$1.server.host}:${config$1.server.port} (paired devices: ${devices$1.count()})`);
|
|
1011
1050
|
resolveLatestGiteeApk().then((url) => {
|
|
1012
1051
|
if (url) log$1("info", `APK download link resolved from Gitee API: ${url}`);
|
|
@@ -1022,6 +1061,43 @@ async function startLensServer(deps) {
|
|
|
1022
1061
|
}
|
|
1023
1062
|
};
|
|
1024
1063
|
}
|
|
1064
|
+
/** Composer-staged photos older than this get swept (best effort). */
|
|
1065
|
+
const PENDING_TTL_MS = 24 * 60 * 6e4;
|
|
1066
|
+
/**
|
|
1067
|
+
|
|
1068
|
+
* Cache an idempotent 200 receipt for a phone retry. The 400 cap must be a
|
|
1069
|
+
|
|
1070
|
+
* HARD cap: within the 10min TTL a retry storm can keep the map full of
|
|
1071
|
+
|
|
1072
|
+
* fresh entries, so once expired ones are gone we still evict oldest-first
|
|
1073
|
+
|
|
1074
|
+
* (Map iteration order = insertion order).
|
|
1075
|
+
|
|
1076
|
+
*/
|
|
1077
|
+
function rememberReplay(ctx, uploadId, body) {
|
|
1078
|
+
if (!uploadId) return;
|
|
1079
|
+
if (ctx.uploadReplays.size > 400) {
|
|
1080
|
+
for (const [k, v] of ctx.uploadReplays) if (v.expires <= Date.now()) ctx.uploadReplays.delete(k);
|
|
1081
|
+
while (ctx.uploadReplays.size >= 400) {
|
|
1082
|
+
const oldest = ctx.uploadReplays.keys().next().value;
|
|
1083
|
+
if (oldest === void 0) break;
|
|
1084
|
+
ctx.uploadReplays.delete(oldest);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
ctx.uploadReplays.set(uploadId, {
|
|
1088
|
+
expires: Date.now() + 10 * 6e4,
|
|
1089
|
+
body
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
/** Best-effort TTL sweep for composer-staged photos (see the staging site). */
|
|
1093
|
+
async function prunePending(dir, ttlMs) {
|
|
1094
|
+
const cutoff = Date.now() - ttlMs;
|
|
1095
|
+
for (const entry of await readdir(dir).catch(() => [])) {
|
|
1096
|
+
const p = join(dir, entry);
|
|
1097
|
+
const st = await stat(p).catch(() => null);
|
|
1098
|
+
if (st?.isFile() && st.mtimeMs < cutoff) await rm(p, { force: true }).catch(() => {});
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1025
1101
|
async function handle$1(deps, req, res, ctx) {
|
|
1026
1102
|
const { config: config$1, pairing: pairing$1, devices: devices$1, hub: hub$1, targets: targets$1, sink: sink$1, log: log$1 } = deps;
|
|
1027
1103
|
const url = new URL(req.url ?? "/", "http://phone-lens.local");
|
|
@@ -1116,6 +1192,10 @@ async function handle$1(deps, req, res, ctx) {
|
|
|
1116
1192
|
tokenHash: hashToken(token)
|
|
1117
1193
|
});
|
|
1118
1194
|
log$1("info", `paired device "${name}" (${deviceId.slice(0, 8)})`);
|
|
1195
|
+
hub$1.broadcastToViews({
|
|
1196
|
+
type: "paired",
|
|
1197
|
+
name
|
|
1198
|
+
});
|
|
1119
1199
|
return sendJson(res, 200, {
|
|
1120
1200
|
token,
|
|
1121
1201
|
serverInfo: {
|
|
@@ -1146,7 +1226,8 @@ async function handle$1(deps, req, res, ctx) {
|
|
|
1146
1226
|
if (truncated) return sendError(res, 413, ERROR_CODES.TOO_LARGE, `> ${config$1.limits.maxUploadBytes} bytes`);
|
|
1147
1227
|
if (!magicMatches(mediaType, buf)) return sendError(res, 415, ERROR_CODES.BAD_MAGIC, "bytes do not match the declared type");
|
|
1148
1228
|
const captureId = q.get("captureId");
|
|
1149
|
-
const pending = captureId ? hub$1.
|
|
1229
|
+
const pending = captureId ? hub$1.peekCapture(captureId) : null;
|
|
1230
|
+
const abandoned = captureId ? hub$1.isAbandonedDirect(captureId) : false;
|
|
1150
1231
|
const note = q.get("note") ?? pending?.note;
|
|
1151
1232
|
const rawName = (q.get("name") ?? `shot_${new Date().toISOString().replace(/[:.]/g, "-")}.${mediaType === "image/png" ? "png" : "jpg"}`).slice(0, 120);
|
|
1152
1233
|
const name = sanitizeFileName(rawName);
|
|
@@ -1162,6 +1243,22 @@ async function handle$1(deps, req, res, ctx) {
|
|
|
1162
1243
|
return sendError(res, 500, ERROR_CODES.STORE_FAILED, String(error));
|
|
1163
1244
|
}
|
|
1164
1245
|
log$1("info", `stored ${name} (${buf.byteLength}B) → ${admitted.storage}:${admitted.ref.attachmentId}`);
|
|
1246
|
+
if (pending) hub$1.consumeCapture(captureId);
|
|
1247
|
+
if (abandoned) {
|
|
1248
|
+
log$1("warn", `dropping late direct-capture photo: ${name} (${admitted.ref.attachmentId})`);
|
|
1249
|
+
const droppedBody = {
|
|
1250
|
+
ok: true,
|
|
1251
|
+
attachmentId: admitted.ref.attachmentId,
|
|
1252
|
+
width: admitted.ref.width,
|
|
1253
|
+
height: admitted.ref.height,
|
|
1254
|
+
bytes: admitted.ref.bytes,
|
|
1255
|
+
storage: admitted.storage,
|
|
1256
|
+
delivered: null,
|
|
1257
|
+
deliverReason: "dropped-late-direct"
|
|
1258
|
+
};
|
|
1259
|
+
rememberReplay(ctx, uploadId, droppedBody);
|
|
1260
|
+
return sendJson(res, 200, droppedBody);
|
|
1261
|
+
}
|
|
1165
1262
|
if (admitted.storage === "file") await pruneUploads(deps.fallbackDir, config$1.limits.maxStoredUploads).catch((error) => log$1("warn", `upload pruning failed: ${String(error)}`));
|
|
1166
1263
|
if (pending?.direct && pending.onImage) {
|
|
1167
1264
|
pending.onImage(admitted);
|
|
@@ -1176,15 +1273,7 @@ async function handle$1(deps, req, res, ctx) {
|
|
|
1176
1273
|
delivered: null,
|
|
1177
1274
|
deliverReason: "delivered-to-model"
|
|
1178
1275
|
};
|
|
1179
|
-
|
|
1180
|
-
if (ctx.uploadReplays.size > 400) {
|
|
1181
|
-
for (const [k, v] of ctx.uploadReplays) if (v.expires <= Date.now()) ctx.uploadReplays.delete(k);
|
|
1182
|
-
}
|
|
1183
|
-
ctx.uploadReplays.set(uploadId, {
|
|
1184
|
-
expires: Date.now() + 10 * 6e4,
|
|
1185
|
-
body: directBody
|
|
1186
|
-
});
|
|
1187
|
-
}
|
|
1276
|
+
rememberReplay(ctx, uploadId, directBody);
|
|
1188
1277
|
return sendJson(res, 200, directBody);
|
|
1189
1278
|
}
|
|
1190
1279
|
const st = deps.appSettings.get();
|
|
@@ -1215,6 +1304,7 @@ async function handle$1(deps, req, res, ctx) {
|
|
|
1215
1304
|
} catch (error) {
|
|
1216
1305
|
log$1("warn", `staging failed: ${String(error)}`);
|
|
1217
1306
|
}
|
|
1307
|
+
prunePending(deps.pendingDir, PENDING_TTL_MS).catch(() => {});
|
|
1218
1308
|
hub$1.broadcastToViews({
|
|
1219
1309
|
type: "pending_image",
|
|
1220
1310
|
attachmentId: admitted.ref.attachmentId,
|
|
@@ -1225,7 +1315,7 @@ async function handle$1(deps, req, res, ctx) {
|
|
|
1225
1315
|
name,
|
|
1226
1316
|
dir: savedDir
|
|
1227
1317
|
});
|
|
1228
|
-
|
|
1318
|
+
{
|
|
1229
1319
|
const replayBody = {
|
|
1230
1320
|
ok: true,
|
|
1231
1321
|
attachmentId: admitted.ref.attachmentId,
|
|
@@ -1236,24 +1326,18 @@ async function handle$1(deps, req, res, ctx) {
|
|
|
1236
1326
|
delivered: null,
|
|
1237
1327
|
deliverReason: savedDir ? wantComposer ? "saved-and-staged" : "saved-to-folder" : "staged-in-composer"
|
|
1238
1328
|
};
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1329
|
+
rememberReplay(ctx, uploadId, replayBody);
|
|
1330
|
+
return sendJson(res, 200, {
|
|
1331
|
+
ok: true,
|
|
1332
|
+
attachmentId: admitted.ref.attachmentId,
|
|
1333
|
+
width: admitted.ref.width,
|
|
1334
|
+
height: admitted.ref.height,
|
|
1335
|
+
bytes: admitted.ref.bytes,
|
|
1336
|
+
storage: admitted.storage,
|
|
1337
|
+
delivered: null,
|
|
1338
|
+
deliverReason: savedDir ? wantComposer ? "saved-and-staged" : "saved-to-folder" : "staged-in-composer"
|
|
1245
1339
|
});
|
|
1246
1340
|
}
|
|
1247
|
-
return sendJson(res, 200, {
|
|
1248
|
-
ok: true,
|
|
1249
|
-
attachmentId: admitted.ref.attachmentId,
|
|
1250
|
-
width: admitted.ref.width,
|
|
1251
|
-
height: admitted.ref.height,
|
|
1252
|
-
bytes: admitted.ref.bytes,
|
|
1253
|
-
storage: admitted.storage,
|
|
1254
|
-
delivered: null,
|
|
1255
|
-
deliverReason: savedDir ? wantComposer ? "saved-and-staged" : "saved-to-folder" : "staged-in-composer"
|
|
1256
|
-
});
|
|
1257
1341
|
}
|
|
1258
1342
|
if (method === "GET" && path.startsWith("/pending/") && loop) {
|
|
1259
1343
|
const id = path.slice(9).split("/")[0] ?? "";
|
|
@@ -1455,6 +1539,36 @@ async function pruneUploads(dir, max) {
|
|
|
1455
1539
|
|
|
1456
1540
|
*/
|
|
1457
1541
|
const CAM_SILENCE_MS = 6e4;
|
|
1542
|
+
/** Oldest phone App that understands the camera_zoom control (zoom tools).
|
|
1543
|
+
|
|
1544
|
+
* MUST track the release the zoom tools actually ship in — the app version
|
|
1545
|
+
|
|
1546
|
+
* itself is 1.1.2 (zoom rides a patch release), not 1.2.0. */
|
|
1547
|
+
const ZOOM_MIN_APP = "1.1.2";
|
|
1548
|
+
/** How long a timed-out direct capture stays "abandoned" (late-photo guard). */
|
|
1549
|
+
const ABANDON_DIRECT_TTL_MS = 5 * 6e4;
|
|
1550
|
+
/**
|
|
1551
|
+
|
|
1552
|
+
* Compare "1.2.0"-style dotted versions. Malformed/missing segments parse as
|
|
1553
|
+
|
|
1554
|
+
* 0, so "1.2" >= "1.2.0" and garbage never accidentally passes a floor.
|
|
1555
|
+
|
|
1556
|
+
*/
|
|
1557
|
+
function versionAtLeast(v, need) {
|
|
1558
|
+
if (!v) return false;
|
|
1559
|
+
const seg = (s) => {
|
|
1560
|
+
const m = /^(\d+)/.exec(s.trim());
|
|
1561
|
+
return m ? Number(m[1]) : 0;
|
|
1562
|
+
};
|
|
1563
|
+
const a = v.split(".");
|
|
1564
|
+
const b = need.split(".");
|
|
1565
|
+
for (let i = 0; i < 3; i++) {
|
|
1566
|
+
const x = seg(a[i] ?? "");
|
|
1567
|
+
const y = seg(b[i] ?? "");
|
|
1568
|
+
if (x !== y) return x > y;
|
|
1569
|
+
}
|
|
1570
|
+
return true;
|
|
1571
|
+
}
|
|
1458
1572
|
/**
|
|
1459
1573
|
|
|
1460
1574
|
* How long the ACTIVE device's stream may go silent before the hub starts
|
|
@@ -1537,6 +1651,8 @@ var ViewHub = class {
|
|
|
1537
1651
|
captureTimeoutMs = 6e4;
|
|
1538
1652
|
/** reqId → settle callback for in-flight camera_idle/camera_resume requests. */
|
|
1539
1653
|
cameraStateWaiters = new Map();
|
|
1654
|
+
/** reqId → settle callback for in-flight camera_zoom requests (set/step/query). */
|
|
1655
|
+
zoomWaiters = new Map();
|
|
1540
1656
|
/**
|
|
1541
1657
|
|
|
1542
1658
|
* Derived stream state of the ACTIVE device, read off the traffic mix:
|
|
@@ -1574,6 +1690,13 @@ var ViewHub = class {
|
|
|
1574
1690
|
});
|
|
1575
1691
|
this.cameraStateWaiters.delete(id);
|
|
1576
1692
|
}
|
|
1693
|
+
for (const [id, waiter] of this.zoomWaiters) {
|
|
1694
|
+
waiter.settle({
|
|
1695
|
+
ok: false,
|
|
1696
|
+
reason: "接收端服务已停止"
|
|
1697
|
+
});
|
|
1698
|
+
this.zoomWaiters.delete(id);
|
|
1699
|
+
}
|
|
1577
1700
|
for (const [id, p] of this.pendingCaptures) {
|
|
1578
1701
|
p.onFail?.("接收端服务已停止");
|
|
1579
1702
|
this.pendingCaptures.delete(id);
|
|
@@ -1611,6 +1734,9 @@ var ViewHub = class {
|
|
|
1611
1734
|
this.log("warn", `camera ws closed: code=${code} reason=${reason.toString("utf8") || "-"} (${name} ${deviceId.slice(0, 8)})`);
|
|
1612
1735
|
if (this.cameras.get(deviceId)?.ws === ws) this.detachCamera(deviceId);
|
|
1613
1736
|
});
|
|
1737
|
+
ws.on("error", (err) => {
|
|
1738
|
+
this.log("warn", `camera ws error: ${String(err)} (${name} ${deviceId.slice(0, 8)})`);
|
|
1739
|
+
});
|
|
1614
1740
|
ws.on("pong", () => {
|
|
1615
1741
|
const c = this.cameras.get(deviceId);
|
|
1616
1742
|
if (c?.ws !== ws) return;
|
|
@@ -1639,6 +1765,13 @@ var ViewHub = class {
|
|
|
1639
1765
|
});
|
|
1640
1766
|
this.cameraStateWaiters.delete(id);
|
|
1641
1767
|
}
|
|
1768
|
+
for (const [id, waiter] of this.zoomWaiters) if (waiter.deviceId === deviceId) {
|
|
1769
|
+
waiter.settle({
|
|
1770
|
+
ok: false,
|
|
1771
|
+
reason: "手机已断开连接"
|
|
1772
|
+
});
|
|
1773
|
+
this.zoomWaiters.delete(id);
|
|
1774
|
+
}
|
|
1642
1775
|
if (this.activeDeviceId === deviceId) {
|
|
1643
1776
|
const next = [...this.cameras.keys()].at(-1) ?? null;
|
|
1644
1777
|
this.activeDeviceId = next;
|
|
@@ -1724,12 +1857,30 @@ var ViewHub = class {
|
|
|
1724
1857
|
const waiter = this.cameraStateWaiters.get(msg.reqId);
|
|
1725
1858
|
if (waiter) {
|
|
1726
1859
|
this.cameraStateWaiters.delete(msg.reqId);
|
|
1727
|
-
waiter.settle(msg.state === "
|
|
1860
|
+
waiter.settle(msg.state === "idle" || msg.state === "live" ? {
|
|
1861
|
+
ok: true,
|
|
1862
|
+
state: msg.state
|
|
1863
|
+
} : {
|
|
1728
1864
|
ok: false,
|
|
1729
1865
|
reason: "手机报告执行失败(相机不可用或权限被拒)"
|
|
1730
|
-
}
|
|
1866
|
+
});
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
break;
|
|
1870
|
+
}
|
|
1871
|
+
case "zoom_result": {
|
|
1872
|
+
if (msg.reqId) {
|
|
1873
|
+
const waiter = this.zoomWaiters.get(msg.reqId);
|
|
1874
|
+
if (waiter) {
|
|
1875
|
+
this.zoomWaiters.delete(msg.reqId);
|
|
1876
|
+
waiter.settle(msg.ok && typeof msg.factor === "number" && Number.isFinite(msg.factor) ? {
|
|
1731
1877
|
ok: true,
|
|
1732
|
-
|
|
1878
|
+
factor: msg.factor,
|
|
1879
|
+
...typeof msg.min === "number" ? { min: msg.min } : {},
|
|
1880
|
+
...typeof msg.max === "number" ? { max: msg.max } : {}
|
|
1881
|
+
} : {
|
|
1882
|
+
ok: false,
|
|
1883
|
+
reason: msg.reason ?? "手机报告变焦执行失败"
|
|
1733
1884
|
});
|
|
1734
1885
|
}
|
|
1735
1886
|
}
|
|
@@ -1847,6 +1998,8 @@ var ViewHub = class {
|
|
|
1847
1998
|
|
|
1848
1999
|
*/
|
|
1849
2000
|
checkPreviewStall() {
|
|
2001
|
+
this.gcCaptures();
|
|
2002
|
+
this.gcAbandonedDirect();
|
|
1850
2003
|
const active = this.activeCam();
|
|
1851
2004
|
if (!active || active.ws.readyState !== active.ws.OPEN) return;
|
|
1852
2005
|
const now = Date.now();
|
|
@@ -1860,6 +2013,9 @@ var ViewHub = class {
|
|
|
1860
2013
|
this.log("warn", `view ws closed: code=${code} reason=${reason.toString("utf8") || "-"} (views left: ${this.views.size - 1})`);
|
|
1861
2014
|
this.views.delete(ws);
|
|
1862
2015
|
});
|
|
2016
|
+
ws.on("error", (err) => {
|
|
2017
|
+
this.log("warn", `view ws error: ${String(err)}`);
|
|
2018
|
+
});
|
|
1863
2019
|
ws.on("message", (data, isBinary) => {
|
|
1864
2020
|
if (isBinary) return;
|
|
1865
2021
|
const msg = parseViewClient(data.toString());
|
|
@@ -1984,6 +2140,14 @@ var ViewHub = class {
|
|
|
1984
2140
|
this.pendingCaptures.delete(captureId);
|
|
1985
2141
|
return pending;
|
|
1986
2142
|
}
|
|
2143
|
+
/** Read a pending capture WITHOUT consuming it (admit-first flow: the
|
|
2144
|
+
|
|
2145
|
+
* record must survive an admitImage failure so the phone's retry can still
|
|
2146
|
+
|
|
2147
|
+
* route the photo to the waiting model tool). */
|
|
2148
|
+
peekCapture(captureId) {
|
|
2149
|
+
return this.pendingCaptures.get(captureId) ?? null;
|
|
2150
|
+
}
|
|
1987
2151
|
noteFor(captureId) {
|
|
1988
2152
|
return this.pendingCaptures.get(captureId)?.note;
|
|
1989
2153
|
}
|
|
@@ -1992,8 +2156,31 @@ var ViewHub = class {
|
|
|
1992
2156
|
for (const [id, p] of this.pendingCaptures) if (p.requestedAt < cutoff) {
|
|
1993
2157
|
p.onFail?.("拍摄请求已超时");
|
|
1994
2158
|
this.pendingCaptures.delete(id);
|
|
2159
|
+
if (!p.direct) this.broadcastToViews({
|
|
2160
|
+
type: "error",
|
|
2161
|
+
code: "CAPTURE_TIMEOUT",
|
|
2162
|
+
message: "拍摄请求已超时(手机未上传)"
|
|
2163
|
+
});
|
|
1995
2164
|
}
|
|
1996
2165
|
}
|
|
2166
|
+
/** captureId → abandon time: direct captures that timed out but whose photo
|
|
2167
|
+
|
|
2168
|
+
* may STILL arrive. The upload handler drops those instead of injecting a
|
|
2169
|
+
|
|
2170
|
+
* surprise photo into the user's composer/folder after the tool already
|
|
2171
|
+
|
|
2172
|
+
* reported failure. */
|
|
2173
|
+
abandonedDirect = new Map();
|
|
2174
|
+
gcAbandonedDirect() {
|
|
2175
|
+
const cutoff = Date.now() - ABANDON_DIRECT_TTL_MS;
|
|
2176
|
+
for (const [id, at] of this.abandonedDirect) if (at < cutoff) this.abandonedDirect.delete(id);
|
|
2177
|
+
}
|
|
2178
|
+
/** True when this captureId belongs to a model-direct capture that already
|
|
2179
|
+
|
|
2180
|
+
* timed out (its late photo must not fall into the capture mode). */
|
|
2181
|
+
isAbandonedDirect(captureId) {
|
|
2182
|
+
return this.abandonedDirect.has(captureId);
|
|
2183
|
+
}
|
|
1997
2184
|
/**
|
|
1998
2185
|
|
|
1999
2186
|
* Model tool: ask the ACTIVE phone to shoot and settle with the uploaded
|
|
@@ -2015,10 +2202,13 @@ var ViewHub = class {
|
|
|
2015
2202
|
}
|
|
2016
2203
|
const captureId = randomUUID();
|
|
2017
2204
|
let settled = false;
|
|
2018
|
-
const timer = setTimeout(() =>
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2205
|
+
const timer = setTimeout(() => {
|
|
2206
|
+
this.abandonedDirect.set(captureId, Date.now());
|
|
2207
|
+
finish({
|
|
2208
|
+
ok: false,
|
|
2209
|
+
reason: `拍照超时(手机未在 ${Math.round(timeoutMs / 1e3)} 秒内上传照片)`
|
|
2210
|
+
});
|
|
2211
|
+
}, timeoutMs);
|
|
2022
2212
|
const finish = (r) => {
|
|
2023
2213
|
if (settled) return;
|
|
2024
2214
|
settled = true;
|
|
@@ -2098,6 +2288,62 @@ var ViewHub = class {
|
|
|
2098
2288
|
});
|
|
2099
2289
|
});
|
|
2100
2290
|
}
|
|
2291
|
+
/**
|
|
2292
|
+
|
|
2293
|
+
* Model tool: set / step / query the ACTIVE phone's zoom ratio. Settles with
|
|
2294
|
+
|
|
2295
|
+
* the phone's zoom_result receipt (or a timeout). App builds before 1.2.0
|
|
2296
|
+
|
|
2297
|
+
* have no camera_zoom branch at all — they would silently swallow the
|
|
2298
|
+
|
|
2299
|
+
* command and burn the whole timeout, so fail fast with an upgrade hint
|
|
2300
|
+
|
|
2301
|
+
* instead (undefined appVersion = pre-1.0.4 build, same bucket).
|
|
2302
|
+
|
|
2303
|
+
*/
|
|
2304
|
+
requestZoom(params, timeoutMs) {
|
|
2305
|
+
return new Promise((resolve$1) => {
|
|
2306
|
+
const active = this.activeCam();
|
|
2307
|
+
if (!active || active.ws.readyState !== active.ws.OPEN || this.activeDeviceId === null) {
|
|
2308
|
+
resolve$1({
|
|
2309
|
+
ok: false,
|
|
2310
|
+
reason: "没有已连接的手机"
|
|
2311
|
+
});
|
|
2312
|
+
return;
|
|
2313
|
+
}
|
|
2314
|
+
if (!versionAtLeast(active.meta.appVersion, ZOOM_MIN_APP)) {
|
|
2315
|
+
resolve$1({
|
|
2316
|
+
ok: false,
|
|
2317
|
+
reason: `手机 App 版本过旧(变焦需 ≥ ${ZOOM_MIN_APP}),请先更新手机端`
|
|
2318
|
+
});
|
|
2319
|
+
return;
|
|
2320
|
+
}
|
|
2321
|
+
const reqId = randomUUID();
|
|
2322
|
+
const deviceId = this.activeDeviceId;
|
|
2323
|
+
let settled = false;
|
|
2324
|
+
const timer = setTimeout(() => finish({
|
|
2325
|
+
ok: false,
|
|
2326
|
+
reason: "手机未在时限内回执(可能不在前台或已断连)"
|
|
2327
|
+
}), timeoutMs);
|
|
2328
|
+
const finish = (r) => {
|
|
2329
|
+
if (settled) return;
|
|
2330
|
+
settled = true;
|
|
2331
|
+
clearTimeout(timer);
|
|
2332
|
+
this.zoomWaiters.delete(reqId);
|
|
2333
|
+
resolve$1(r);
|
|
2334
|
+
};
|
|
2335
|
+
this.zoomWaiters.set(reqId, {
|
|
2336
|
+
deviceId,
|
|
2337
|
+
settle: finish
|
|
2338
|
+
});
|
|
2339
|
+
this.sendControl(deviceId, {
|
|
2340
|
+
type: "camera_zoom",
|
|
2341
|
+
...params.factor !== void 0 ? { factor: params.factor } : {},
|
|
2342
|
+
...params.step !== void 0 ? { step: params.step } : {},
|
|
2343
|
+
reqId
|
|
2344
|
+
});
|
|
2345
|
+
});
|
|
2346
|
+
}
|
|
2101
2347
|
broadcastToViews(msg) {
|
|
2102
2348
|
const text = JSON.stringify(msg);
|
|
2103
2349
|
for (const view of this.views) if (view.readyState === view.OPEN) view.send(text);
|