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 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 here, setState below
372
- const grew = (m.devices || []).length > (devices ? devices.length : 0);
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
- infoRow("拍摄照片(phone_take_photo)", "手机立即拍照,照片直接进入模型视野——不放入输入框、不保存到电脑文件夹,与拍照注入模式设置无关。", "w1"),
932
- infoRow("关闭手机摄像头(phone_camera_pause)", "让手机相机提前进入空闲省电状态。", "w2"),
933
- infoRow("恢复手机摄像头(phone_camera_resume)", "立即打开相机并恢复预览拍摄。", "w3"),
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
- await new Promise((resolve$1, reject) => {
1002
- const onError = reject;
1003
- server.once("error", onError);
1004
- server.listen(config$1.server.port, config$1.server.host, () => {
1005
- server.removeListener("error", onError);
1006
- server.on("error", (e) => log$1("warn", `http server error: ${String(e)}`));
1007
- resolve$1();
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.consumeCapture(captureId) : null;
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
- if (uploadId) {
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
- if (uploadId) {
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
- if (ctx.uploadReplays.size > 400) {
1240
- for (const [k, v] of ctx.uploadReplays) if (v.expires <= Date.now()) ctx.uploadReplays.delete(k);
1241
- }
1242
- ctx.uploadReplays.set(uploadId, {
1243
- expires: Date.now() + 10 * 6e4,
1244
- body: replayBody
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 === "failed" ? {
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
- state: msg.state
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(() => finish({
2019
- ok: false,
2020
- reason: `拍照超时(手机未在 ${Math.round(timeoutMs / 1e3)} 秒内上传照片)`
2021
- }), timeoutMs);
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);