phone-lens 1.0.1 → 1.0.3

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/dev.js CHANGED
@@ -10,6 +10,22 @@ import { homedir } from "node:os";
10
10
  import { env } from "node:process";
11
11
 
12
12
  //#region src/config.ts
13
+ /**
14
+
15
+ * Offline fallback for the Gitee APK download link. Since v1.0.2 the actual
16
+
17
+ * link is resolved from Gitee's public latest-release API at runtime (see
18
+
19
+ * server/apk-link.ts), so this constant needs NO per-release bump anymore —
20
+
21
+ * it only serves when the API is unreachable, and deliberately points at a
22
+
23
+ * release known to exist (pointing it at a future tag would 404 until that
24
+
25
+ * release is actually published).
26
+
27
+ */
28
+ const GITEE_APK_DEFAULT = "https://gitee.com/qianfengbingtang/phone-lens/releases/download/v1.0.0/app-release.apk";
13
29
  /** Coerce an unknown config object (cordis patch row / CLI overrides) into LensConfig. */
14
30
  function normalizeConfig(raw) {
15
31
  const r = raw ?? {};
@@ -20,9 +36,8 @@ function normalizeConfig(raw) {
20
36
  const inject = r.inject ?? {};
21
37
  const target = r.target ?? {};
22
38
  const app = r.app ?? {};
23
- const GITEE_APK = "https://gitee.com/qianfengbingtang/phone-lens/releases/download/v1.0.0/app-release.apk";
24
39
  const GITHUB_APK = "https://github.com/yxqfg/phone-lens/releases/latest/download/app-release.apk";
25
- const giteeUrl = typeof app.giteeUrl === "string" && app.giteeUrl ? app.giteeUrl : GITEE_APK;
40
+ const giteeUrl = typeof app.giteeUrl === "string" && app.giteeUrl ? app.giteeUrl : GITEE_APK_DEFAULT;
26
41
  const allowed = Array.isArray(limits.allowedTypes) ? limits.allowedTypes.filter((t) => typeof t === "string") : void 0;
27
42
  const mode = inject.mode === "steer" ? "steer" : "followup";
28
43
  return {
@@ -423,6 +438,77 @@ async function admitImage(input, attachments, fallbackDir) {
423
438
  };
424
439
  }
425
440
 
441
+ //#endregion
442
+ //#region src/server/apk-link.ts
443
+ const GITEE_LATEST_API = "https://gitee.com/api/v5/repos/qianfengbingtang/phone-lens/releases/latest";
444
+ const CACHE_TTL_MS = 10 * 60 * 1e3;
445
+ const REQUEST_TIMEOUT_MS = 6e3;
446
+ let cached = null;
447
+ let inflight = null;
448
+ /** The asset URL comes from an external API response, so re-validate it before
449
+ * it ever reaches a QR code: https only, and only the expected release host —
450
+ * never localhost/loopback/private addresses or any other origin. */
451
+ function isTrustedGiteeAsset(raw) {
452
+ if (typeof raw !== "string" || !raw.startsWith("https://")) return false;
453
+ try {
454
+ const u = new URL(raw);
455
+ return u.protocol === "https:" && u.host === "gitee.com";
456
+ } catch {
457
+ return false;
458
+ }
459
+ }
460
+ async function fetchLatestGiteeApk() {
461
+ const ctrl = new AbortController();
462
+ const timer = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS);
463
+ try {
464
+ const resp = await fetch(GITEE_LATEST_API, {
465
+ signal: ctrl.signal,
466
+ headers: { accept: "application/json" }
467
+ });
468
+ if (!resp.ok) return null;
469
+ const data = await resp.json();
470
+ const assets = data?.assets;
471
+ if (!Array.isArray(assets)) return null;
472
+ for (const asset of assets) {
473
+ const row = asset;
474
+ if (row.name === "app-release.apk" && isTrustedGiteeAsset(row.browser_download_url)) return row.browser_download_url;
475
+ }
476
+ return null;
477
+ } catch {
478
+ return null;
479
+ } finally {
480
+ clearTimeout(timer);
481
+ }
482
+ }
483
+ /** Resolve the latest APK URL (cached 10 min, in-flight deduped).
484
+ * Resolves null when the API is unreachable or carries no trusted asset. */
485
+ function resolveLatestGiteeApk() {
486
+ if (cached && Date.now() - cached.at < CACHE_TTL_MS) return Promise.resolve(cached.url);
487
+ if (inflight) return inflight;
488
+ inflight = fetchLatestGiteeApk().then((url) => {
489
+ if (url) cached = {
490
+ url,
491
+ at: Date.now()
492
+ };
493
+ return url;
494
+ }).finally(() => {
495
+ inflight = null;
496
+ });
497
+ return inflight;
498
+ }
499
+ /**
500
+ * Refresh config.app download links in place, but only while they still carry
501
+ * the built-in defaults — an explicit user config always wins. Mutating the
502
+ * shared config object lets the QR endpoints keep reading the plain fields.
503
+ */
504
+ async function refreshAppApkLinks(config$1) {
505
+ if (config$1.app.giteeUrl !== GITEE_APK_DEFAULT) return;
506
+ const url = await resolveLatestGiteeApk();
507
+ if (!url) return;
508
+ config$1.app.giteeUrl = url;
509
+ if (config$1.app.downloadUrl === GITEE_APK_DEFAULT) config$1.app.downloadUrl = url;
510
+ }
511
+
426
512
  //#endregion
427
513
  //#region src/server/qr.ts
428
514
  /** Enumerate LAN IPv4 candidates: private ranges first, virtual/tethering included. */
@@ -758,17 +844,25 @@ function queryOf(req) {
758
844
  /** Process-lifetime marker: surfaces service rebuilds via /status. */
759
845
  const SERVER_BOOT_AT = Date.now();
760
846
  /** Boot the receiver: HTTP routes + two websocket endpoints. */
847
+ let ownVersion = null;
848
+ function hostVersion() {
849
+ if (ownVersion) return ownVersion;
850
+ try {
851
+ ownVersion = String(JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "0.0.0");
852
+ } catch {
853
+ ownVersion = "0.0.0";
854
+ }
855
+ return ownVersion;
856
+ }
761
857
  async function startLensServer(deps) {
762
858
  const { config: config$1, pairing: pairing$1, devices: devices$1, hub: hub$1, targets: targets$1, sink: sink$1, log: log$1 } = deps;
763
859
  const pairLimiter = new RateLimiter(6e4, 10);
764
860
  const uploadReplays = new Map();
765
- let currentCameraDeviceId = null;
766
861
  const injectionBox = { last: null };
767
862
  const server = createServer((req, res) => {
768
863
  handle$1(deps, req, res, {
769
864
  pairLimiter,
770
865
  uploadReplays,
771
- getCurrentCamera: () => currentCameraDeviceId,
772
866
  noteInjection: (receipt, attachmentId) => {
773
867
  injectionBox.last = {
774
868
  at: Date.now(),
@@ -804,7 +898,6 @@ async function startLensServer(deps) {
804
898
  const record = devices$1.authenticate(deviceId, token);
805
899
  if (!record) return done(401, "Unauthorized");
806
900
  wss.handleUpgrade(req, socket, head, (ws) => {
807
- currentCameraDeviceId = record.deviceId;
808
901
  hub$1.attachCamera(record.deviceId, ws, record.name);
809
902
  });
810
903
  return;
@@ -828,6 +921,9 @@ async function startLensServer(deps) {
828
921
  server.listen(config$1.server.port, config$1.server.host, () => resolve$1());
829
922
  });
830
923
  log$1("info", `phone-lens listening on ${config$1.server.host}:${config$1.server.port} (paired devices: ${devices$1.count()})`);
924
+ resolveLatestGiteeApk().then((url) => {
925
+ if (url) log$1("info", `APK download link resolved from Gitee API: ${url}`);
926
+ });
831
927
  return {
832
928
  port: config$1.server.port,
833
929
  dispose: async () => {
@@ -853,7 +949,7 @@ async function handle$1(deps, req, res, ctx) {
853
949
  }
854
950
  if (method === "GET" && path === "/info") return sendJson(res, 200, {
855
951
  name: "PhoneLens 直连取景",
856
- version: "0.3.11",
952
+ version: hostVersion(),
857
953
  requiresPairing: true
858
954
  }, cors);
859
955
  if (!loop && (path === "/" || path === "/view.html" || path === "/qr.json" || path === "/qr.png" || path === "/app-qr.json" || path === "/app-settings")) return sendError(res, 403, ERROR_CODES.LOOPBACK_ONLY, "preview surface is loopback-only");
@@ -891,6 +987,7 @@ async function handle$1(deps, req, res, ctx) {
891
987
  return;
892
988
  }
893
989
  if (method === "GET" && path === "/app-qr.json") {
990
+ await refreshAppApkLinks(config$1);
894
991
  const target = config$1.app.downloadUrl || config$1.app.giteeUrl;
895
992
  const pngDataUrl = await QRCode.toDataURL(target, {
896
993
  errorCorrectionLevel: "M",
@@ -1061,15 +1158,15 @@ async function handle$1(deps, req, res, ctx) {
1061
1158
  }
1062
1159
  }
1063
1160
  if (method === "GET" && path === "/status") {
1064
- const cameraDeviceId = ctx.getCurrentCamera();
1161
+ const online = hub$1.onlineDeviceIds();
1065
1162
  return sendJson(res, 200, {
1066
1163
  bootAt: SERVER_BOOT_AT,
1067
1164
  devices: devices$1.list().map((d) => ({
1068
1165
  id: d.deviceId,
1069
1166
  name: d.name,
1070
1167
  model: d.model,
1071
- online: d.deviceId === cameraDeviceId,
1072
- streaming: d.deviceId === cameraDeviceId,
1168
+ online: online.has(d.deviceId),
1169
+ streaming: online.has(d.deviceId),
1073
1170
  lastSeenAt: d.lastSeenAt
1074
1171
  })),
1075
1172
  camera: hub$1.stats(),
@@ -1116,6 +1213,7 @@ async function handle$1(deps, req, res, ctx) {
1116
1213
  return sendError(res, 404, ERROR_CODES.BAD_REQUEST, `no route ${method} ${path}`);
1117
1214
  }
1118
1215
  /** Strip path separators / reserved characters so a phone-supplied upload
1216
+
1119
1217
  * name can never escape the target directory or hide as a dotfile. */
1120
1218
  function sanitizeFileName(input) {
1121
1219
  const base = basename(input).replace(/[<>:"|?*\u0000-\u001F]/g, "_").replace(/^[\s.]+/, "").trim();
@@ -1133,13 +1231,17 @@ function nextAvailableName(dir, name) {
1133
1231
  }
1134
1232
  }
1135
1233
  /** Pending-staging filename for an attachment id. Ids may be URN-ish
1234
+
1136
1235
  * ("file:<digest>" without the dsh attachment service) and Windows forbids
1236
+
1137
1237
  * ":" in filenames — flatten to a portable name, write and read alike. */
1138
1238
  function pendingFileName(attachmentId) {
1139
1239
  return `${attachmentId.replace(/[^A-Za-z0-9._-]/g, "_")}.jpg`;
1140
1240
  }
1141
1241
  /** Resolve `name` under `baseDir` and refuse anything that escapes it
1242
+
1142
1243
  * (defense in depth on top of sanitizeFileName — e.g. a crafted name that
1244
+
1143
1245
  * survives sanitization but still resolves outside the base). */
1144
1246
  function resolveUnder(baseDir, name) {
1145
1247
  const base = resolve(baseDir);
@@ -1221,6 +1323,18 @@ async function pruneUploads(dir, max) {
1221
1323
  //#region src/server/hub.ts
1222
1324
  /**
1223
1325
 
1326
+ * A camera uplink with NO inbound traffic for this long is presumed half-open
1327
+
1328
+ * (phone switched wifi / laptop slept mid-link): the OS keeps the socket
1329
+
1330
+ * "open" without a FIN, so we evict it ourselves. Generous vs the phone's own
1331
+
1332
+ * 30s app-level silence limit so the phone normally tears down first.
1333
+
1334
+ */
1335
+ const CAM_SILENCE_MS = 6e4;
1336
+ /**
1337
+
1224
1338
  * How long the ACTIVE device's stream may go silent before views are told the
1225
1339
 
1226
1340
  * preview was turned off. The phone app never announces "preview off" — it
@@ -1253,6 +1367,22 @@ const PREVIEW_STALL_MS = 3e3;
1253
1367
  var ViewHub = class {
1254
1368
  cameras = new Map();
1255
1369
  activeDeviceId = null;
1370
+ /**
1371
+
1372
+ * The device the flow last settled on EXPLICITLY: picked in the view,
1373
+
1374
+ * claimed from a phone, or simply the first to come online. When that
1375
+
1376
+ * device drops and a fallback takes over, detach does NOT clear this — so
1377
+
1378
+ * the preferred phone reconnecting (network blip, host reboot, delayed
1379
+
1380
+ * wifi join) takes its hot seat back instead of being parked as "another
1381
+
1382
+ * device is using the preview". Any explicit switch updates it.
1383
+
1384
+ */
1385
+ preferredDeviceId = null;
1256
1386
  views = new Set();
1257
1387
  /** captureId → { note, requestedAt } until the matching upload lands or timeout. */
1258
1388
  pendingCaptures = new Map();
@@ -1285,12 +1415,16 @@ var ViewHub = class {
1285
1415
  meta: {},
1286
1416
  lastFrame: null,
1287
1417
  lastFrameAt: 0,
1418
+ lastSeenAt: Date.now(),
1288
1419
  frameCount: 0,
1289
1420
  windowStart: Date.now(),
1290
1421
  measuredFps: 0
1291
1422
  };
1292
1423
  this.cameras.set(deviceId, cam);
1293
- if (this.activeDeviceId === null) this.activeDeviceId = deviceId;
1424
+ if (this.activeDeviceId === null) {
1425
+ this.activeDeviceId = deviceId;
1426
+ this.preferredDeviceId = deviceId;
1427
+ } else if (this.preferredDeviceId === deviceId && this.activeDeviceId !== deviceId) this.selectDevice(deviceId);
1294
1428
  if (this.activeDeviceId === deviceId) this.sendControl(deviceId, { type: "resume_preview" });
1295
1429
  else this.sendControl(deviceId, { type: "pause_preview" });
1296
1430
  this.log("info", `camera uplink: ${name} (${deviceId.slice(0, 8)})`);
@@ -1298,7 +1432,13 @@ var ViewHub = class {
1298
1432
  this.log("warn", `camera ws closed: code=${code} reason=${reason.toString("utf8") || "-"} (${name} ${deviceId.slice(0, 8)})`);
1299
1433
  if (this.cameras.get(deviceId)?.ws === ws) this.detachCamera(deviceId);
1300
1434
  });
1435
+ ws.on("pong", () => {
1436
+ const c = this.cameras.get(deviceId);
1437
+ if (c) c.lastSeenAt = Date.now();
1438
+ });
1301
1439
  ws.on("message", (data, isBinary) => {
1440
+ const c = this.cameras.get(deviceId);
1441
+ if (c) c.lastSeenAt = Date.now();
1302
1442
  if (isBinary) {
1303
1443
  this.ingestFrame(deviceId, data);
1304
1444
  return;
@@ -1328,7 +1468,22 @@ var ViewHub = class {
1328
1468
  this.broadcastDevices();
1329
1469
  }
1330
1470
  pingAll() {
1331
- for (const cam of this.cameras.values()) if (cam.ws.readyState === cam.ws.OPEN) cam.ws.ping();
1471
+ const now = Date.now();
1472
+ for (const [deviceId, cam] of this.cameras) {
1473
+ if (cam.ws.readyState !== cam.ws.OPEN) continue;
1474
+ if (now - cam.lastSeenAt > CAM_SILENCE_MS) {
1475
+ this.log("warn", `camera uplink silent >${CAM_SILENCE_MS}ms — terminating ${deviceId.slice(0, 8)}`);
1476
+ cam.ws.terminate();
1477
+ continue;
1478
+ }
1479
+ cam.ws.ping();
1480
+ }
1481
+ }
1482
+ /** Device ids with a live camera uplink right now (for /status truth). */
1483
+ onlineDeviceIds() {
1484
+ const ids = new Set();
1485
+ for (const [deviceId, cam] of this.cameras) if (cam.ws.readyState === cam.ws.OPEN) ids.add(deviceId);
1486
+ return ids;
1332
1487
  }
1333
1488
  onCameraControl(deviceId, msg) {
1334
1489
  if (!msg) return;
@@ -1359,6 +1514,9 @@ var ViewHub = class {
1359
1514
  case "claim_active":
1360
1515
  this.selectDevice(deviceId);
1361
1516
  break;
1517
+ case "ping":
1518
+ this.sendControl(deviceId, { type: "pong" });
1519
+ break;
1362
1520
  case "capture_result":
1363
1521
  if (msg.status !== "taken") {
1364
1522
  this.pendingCaptures.delete(msg.captureId);
@@ -1481,6 +1639,7 @@ var ViewHub = class {
1481
1639
  const prevActive = this.activeDeviceId;
1482
1640
  if (prevActive === deviceId) return;
1483
1641
  this.activeDeviceId = deviceId;
1642
+ this.preferredDeviceId = deviceId;
1484
1643
  if (prevActive) this.sendControl(prevActive, { type: "pause_preview" });
1485
1644
  this.sendControl(deviceId, { type: "resume_preview" });
1486
1645
  this.broadcastDevices();
package/lib/index.js CHANGED
@@ -13,6 +13,22 @@ import { homedir } from "node:os";
13
13
  import { env } from "node:process";
14
14
 
15
15
  //#region src/config.ts
16
+ /**
17
+
18
+ * Offline fallback for the Gitee APK download link. Since v1.0.2 the actual
19
+
20
+ * link is resolved from Gitee's public latest-release API at runtime (see
21
+
22
+ * server/apk-link.ts), so this constant needs NO per-release bump anymore —
23
+
24
+ * it only serves when the API is unreachable, and deliberately points at a
25
+
26
+ * release known to exist (pointing it at a future tag would 404 until that
27
+
28
+ * release is actually published).
29
+
30
+ */
31
+ const GITEE_APK_DEFAULT = "https://gitee.com/qianfengbingtang/phone-lens/releases/download/v1.0.0/app-release.apk";
16
32
  /** Coerce an unknown config object (cordis patch row / CLI overrides) into LensConfig. */
17
33
  function normalizeConfig(raw) {
18
34
  const r = raw ?? {};
@@ -23,9 +39,8 @@ function normalizeConfig(raw) {
23
39
  const inject = r.inject ?? {};
24
40
  const target = r.target ?? {};
25
41
  const app = r.app ?? {};
26
- const GITEE_APK = "https://gitee.com/qianfengbingtang/phone-lens/releases/download/v1.0.0/app-release.apk";
27
42
  const GITHUB_APK = "https://github.com/yxqfg/phone-lens/releases/latest/download/app-release.apk";
28
- const giteeUrl = typeof app.giteeUrl === "string" && app.giteeUrl ? app.giteeUrl : GITEE_APK;
43
+ const giteeUrl = typeof app.giteeUrl === "string" && app.giteeUrl ? app.giteeUrl : GITEE_APK_DEFAULT;
29
44
  const allowed = Array.isArray(limits.allowedTypes) ? limits.allowedTypes.filter((t) => typeof t === "string") : void 0;
30
45
  const mode = inject.mode === "steer" ? "steer" : "followup";
31
46
  return {
@@ -493,6 +508,77 @@ async function admitImage(input, attachments, fallbackDir) {
493
508
  };
494
509
  }
495
510
 
511
+ //#endregion
512
+ //#region src/server/apk-link.ts
513
+ const GITEE_LATEST_API = "https://gitee.com/api/v5/repos/qianfengbingtang/phone-lens/releases/latest";
514
+ const CACHE_TTL_MS = 10 * 60 * 1e3;
515
+ const REQUEST_TIMEOUT_MS = 6e3;
516
+ let cached = null;
517
+ let inflight = null;
518
+ /** The asset URL comes from an external API response, so re-validate it before
519
+ * it ever reaches a QR code: https only, and only the expected release host —
520
+ * never localhost/loopback/private addresses or any other origin. */
521
+ function isTrustedGiteeAsset(raw) {
522
+ if (typeof raw !== "string" || !raw.startsWith("https://")) return false;
523
+ try {
524
+ const u = new URL(raw);
525
+ return u.protocol === "https:" && u.host === "gitee.com";
526
+ } catch {
527
+ return false;
528
+ }
529
+ }
530
+ async function fetchLatestGiteeApk() {
531
+ const ctrl = new AbortController();
532
+ const timer = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS);
533
+ try {
534
+ const resp = await fetch(GITEE_LATEST_API, {
535
+ signal: ctrl.signal,
536
+ headers: { accept: "application/json" }
537
+ });
538
+ if (!resp.ok) return null;
539
+ const data = await resp.json();
540
+ const assets = data?.assets;
541
+ if (!Array.isArray(assets)) return null;
542
+ for (const asset of assets) {
543
+ const row = asset;
544
+ if (row.name === "app-release.apk" && isTrustedGiteeAsset(row.browser_download_url)) return row.browser_download_url;
545
+ }
546
+ return null;
547
+ } catch {
548
+ return null;
549
+ } finally {
550
+ clearTimeout(timer);
551
+ }
552
+ }
553
+ /** Resolve the latest APK URL (cached 10 min, in-flight deduped).
554
+ * Resolves null when the API is unreachable or carries no trusted asset. */
555
+ function resolveLatestGiteeApk() {
556
+ if (cached && Date.now() - cached.at < CACHE_TTL_MS) return Promise.resolve(cached.url);
557
+ if (inflight) return inflight;
558
+ inflight = fetchLatestGiteeApk().then((url) => {
559
+ if (url) cached = {
560
+ url,
561
+ at: Date.now()
562
+ };
563
+ return url;
564
+ }).finally(() => {
565
+ inflight = null;
566
+ });
567
+ return inflight;
568
+ }
569
+ /**
570
+ * Refresh config.app download links in place, but only while they still carry
571
+ * the built-in defaults — an explicit user config always wins. Mutating the
572
+ * shared config object lets the QR endpoints keep reading the plain fields.
573
+ */
574
+ async function refreshAppApkLinks(config) {
575
+ if (config.app.giteeUrl !== GITEE_APK_DEFAULT) return;
576
+ const url = await resolveLatestGiteeApk();
577
+ if (!url) return;
578
+ config.app.giteeUrl = url;
579
+ if (config.app.downloadUrl === GITEE_APK_DEFAULT) config.app.downloadUrl = url;
580
+ }
581
+
496
582
  //#endregion
497
583
  //#region src/server/qr.ts
498
584
  /** Enumerate LAN IPv4 candidates: private ranges first, virtual/tethering included. */
@@ -828,17 +914,25 @@ function queryOf(req) {
828
914
  /** Process-lifetime marker: surfaces service rebuilds via /status. */
829
915
  const SERVER_BOOT_AT = Date.now();
830
916
  /** Boot the receiver: HTTP routes + two websocket endpoints. */
917
+ let ownVersion = null;
918
+ function hostVersion() {
919
+ if (ownVersion) return ownVersion;
920
+ try {
921
+ ownVersion = String(JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "0.0.0");
922
+ } catch {
923
+ ownVersion = "0.0.0";
924
+ }
925
+ return ownVersion;
926
+ }
831
927
  async function startLensServer(deps) {
832
928
  const { config, pairing, devices, hub, targets, sink, log } = deps;
833
929
  const pairLimiter = new RateLimiter(6e4, 10);
834
930
  const uploadReplays = new Map();
835
- let currentCameraDeviceId = null;
836
931
  const injectionBox = { last: null };
837
932
  const server = createServer((req, res) => {
838
933
  handle(deps, req, res, {
839
934
  pairLimiter,
840
935
  uploadReplays,
841
- getCurrentCamera: () => currentCameraDeviceId,
842
936
  noteInjection: (receipt, attachmentId) => {
843
937
  injectionBox.last = {
844
938
  at: Date.now(),
@@ -874,7 +968,6 @@ async function startLensServer(deps) {
874
968
  const record = devices.authenticate(deviceId, token);
875
969
  if (!record) return done(401, "Unauthorized");
876
970
  wss.handleUpgrade(req, socket, head, (ws) => {
877
- currentCameraDeviceId = record.deviceId;
878
971
  hub.attachCamera(record.deviceId, ws, record.name);
879
972
  });
880
973
  return;
@@ -898,6 +991,9 @@ async function startLensServer(deps) {
898
991
  server.listen(config.server.port, config.server.host, () => resolve$1());
899
992
  });
900
993
  log("info", `phone-lens listening on ${config.server.host}:${config.server.port} (paired devices: ${devices.count()})`);
994
+ resolveLatestGiteeApk().then((url) => {
995
+ if (url) log("info", `APK download link resolved from Gitee API: ${url}`);
996
+ });
901
997
  return {
902
998
  port: config.server.port,
903
999
  dispose: async () => {
@@ -923,7 +1019,7 @@ async function handle(deps, req, res, ctx) {
923
1019
  }
924
1020
  if (method === "GET" && path === "/info") return sendJson(res, 200, {
925
1021
  name: "PhoneLens 直连取景",
926
- version: "0.3.11",
1022
+ version: hostVersion(),
927
1023
  requiresPairing: true
928
1024
  }, cors);
929
1025
  if (!loop && (path === "/" || path === "/view.html" || path === "/qr.json" || path === "/qr.png" || path === "/app-qr.json" || path === "/app-settings")) return sendError(res, 403, ERROR_CODES.LOOPBACK_ONLY, "preview surface is loopback-only");
@@ -961,6 +1057,7 @@ async function handle(deps, req, res, ctx) {
961
1057
  return;
962
1058
  }
963
1059
  if (method === "GET" && path === "/app-qr.json") {
1060
+ await refreshAppApkLinks(config);
964
1061
  const target = config.app.downloadUrl || config.app.giteeUrl;
965
1062
  const pngDataUrl = await QRCode.toDataURL(target, {
966
1063
  errorCorrectionLevel: "M",
@@ -1131,15 +1228,15 @@ async function handle(deps, req, res, ctx) {
1131
1228
  }
1132
1229
  }
1133
1230
  if (method === "GET" && path === "/status") {
1134
- const cameraDeviceId = ctx.getCurrentCamera();
1231
+ const online = hub.onlineDeviceIds();
1135
1232
  return sendJson(res, 200, {
1136
1233
  bootAt: SERVER_BOOT_AT,
1137
1234
  devices: devices.list().map((d) => ({
1138
1235
  id: d.deviceId,
1139
1236
  name: d.name,
1140
1237
  model: d.model,
1141
- online: d.deviceId === cameraDeviceId,
1142
- streaming: d.deviceId === cameraDeviceId,
1238
+ online: online.has(d.deviceId),
1239
+ streaming: online.has(d.deviceId),
1143
1240
  lastSeenAt: d.lastSeenAt
1144
1241
  })),
1145
1242
  camera: hub.stats(),
@@ -1186,6 +1283,7 @@ async function handle(deps, req, res, ctx) {
1186
1283
  return sendError(res, 404, ERROR_CODES.BAD_REQUEST, `no route ${method} ${path}`);
1187
1284
  }
1188
1285
  /** Strip path separators / reserved characters so a phone-supplied upload
1286
+
1189
1287
  * name can never escape the target directory or hide as a dotfile. */
1190
1288
  function sanitizeFileName(input) {
1191
1289
  const base = basename(input).replace(/[<>:"|?*\u0000-\u001F]/g, "_").replace(/^[\s.]+/, "").trim();
@@ -1203,13 +1301,17 @@ function nextAvailableName(dir, name) {
1203
1301
  }
1204
1302
  }
1205
1303
  /** Pending-staging filename for an attachment id. Ids may be URN-ish
1304
+
1206
1305
  * ("file:<digest>" without the dsh attachment service) and Windows forbids
1306
+
1207
1307
  * ":" in filenames — flatten to a portable name, write and read alike. */
1208
1308
  function pendingFileName(attachmentId) {
1209
1309
  return `${attachmentId.replace(/[^A-Za-z0-9._-]/g, "_")}.jpg`;
1210
1310
  }
1211
1311
  /** Resolve `name` under `baseDir` and refuse anything that escapes it
1312
+
1212
1313
  * (defense in depth on top of sanitizeFileName — e.g. a crafted name that
1314
+
1213
1315
  * survives sanitization but still resolves outside the base). */
1214
1316
  function resolveUnder(baseDir, name) {
1215
1317
  const base = resolve(baseDir);
@@ -1291,6 +1393,18 @@ async function pruneUploads(dir, max) {
1291
1393
  //#region src/server/hub.ts
1292
1394
  /**
1293
1395
 
1396
+ * A camera uplink with NO inbound traffic for this long is presumed half-open
1397
+
1398
+ * (phone switched wifi / laptop slept mid-link): the OS keeps the socket
1399
+
1400
+ * "open" without a FIN, so we evict it ourselves. Generous vs the phone's own
1401
+
1402
+ * 30s app-level silence limit so the phone normally tears down first.
1403
+
1404
+ */
1405
+ const CAM_SILENCE_MS = 6e4;
1406
+ /**
1407
+
1294
1408
  * How long the ACTIVE device's stream may go silent before views are told the
1295
1409
 
1296
1410
  * preview was turned off. The phone app never announces "preview off" — it
@@ -1323,6 +1437,22 @@ const PREVIEW_STALL_MS = 3e3;
1323
1437
  var ViewHub = class {
1324
1438
  cameras = new Map();
1325
1439
  activeDeviceId = null;
1440
+ /**
1441
+
1442
+ * The device the flow last settled on EXPLICITLY: picked in the view,
1443
+
1444
+ * claimed from a phone, or simply the first to come online. When that
1445
+
1446
+ * device drops and a fallback takes over, detach does NOT clear this — so
1447
+
1448
+ * the preferred phone reconnecting (network blip, host reboot, delayed
1449
+
1450
+ * wifi join) takes its hot seat back instead of being parked as "another
1451
+
1452
+ * device is using the preview". Any explicit switch updates it.
1453
+
1454
+ */
1455
+ preferredDeviceId = null;
1326
1456
  views = new Set();
1327
1457
  /** captureId → { note, requestedAt } until the matching upload lands or timeout. */
1328
1458
  pendingCaptures = new Map();
@@ -1355,12 +1485,16 @@ var ViewHub = class {
1355
1485
  meta: {},
1356
1486
  lastFrame: null,
1357
1487
  lastFrameAt: 0,
1488
+ lastSeenAt: Date.now(),
1358
1489
  frameCount: 0,
1359
1490
  windowStart: Date.now(),
1360
1491
  measuredFps: 0
1361
1492
  };
1362
1493
  this.cameras.set(deviceId, cam);
1363
- if (this.activeDeviceId === null) this.activeDeviceId = deviceId;
1494
+ if (this.activeDeviceId === null) {
1495
+ this.activeDeviceId = deviceId;
1496
+ this.preferredDeviceId = deviceId;
1497
+ } else if (this.preferredDeviceId === deviceId && this.activeDeviceId !== deviceId) this.selectDevice(deviceId);
1364
1498
  if (this.activeDeviceId === deviceId) this.sendControl(deviceId, { type: "resume_preview" });
1365
1499
  else this.sendControl(deviceId, { type: "pause_preview" });
1366
1500
  this.log("info", `camera uplink: ${name} (${deviceId.slice(0, 8)})`);
@@ -1368,7 +1502,13 @@ var ViewHub = class {
1368
1502
  this.log("warn", `camera ws closed: code=${code} reason=${reason.toString("utf8") || "-"} (${name} ${deviceId.slice(0, 8)})`);
1369
1503
  if (this.cameras.get(deviceId)?.ws === ws) this.detachCamera(deviceId);
1370
1504
  });
1505
+ ws.on("pong", () => {
1506
+ const c = this.cameras.get(deviceId);
1507
+ if (c) c.lastSeenAt = Date.now();
1508
+ });
1371
1509
  ws.on("message", (data, isBinary) => {
1510
+ const c = this.cameras.get(deviceId);
1511
+ if (c) c.lastSeenAt = Date.now();
1372
1512
  if (isBinary) {
1373
1513
  this.ingestFrame(deviceId, data);
1374
1514
  return;
@@ -1398,7 +1538,22 @@ var ViewHub = class {
1398
1538
  this.broadcastDevices();
1399
1539
  }
1400
1540
  pingAll() {
1401
- for (const cam of this.cameras.values()) if (cam.ws.readyState === cam.ws.OPEN) cam.ws.ping();
1541
+ const now = Date.now();
1542
+ for (const [deviceId, cam] of this.cameras) {
1543
+ if (cam.ws.readyState !== cam.ws.OPEN) continue;
1544
+ if (now - cam.lastSeenAt > CAM_SILENCE_MS) {
1545
+ this.log("warn", `camera uplink silent >${CAM_SILENCE_MS}ms — terminating ${deviceId.slice(0, 8)}`);
1546
+ cam.ws.terminate();
1547
+ continue;
1548
+ }
1549
+ cam.ws.ping();
1550
+ }
1551
+ }
1552
+ /** Device ids with a live camera uplink right now (for /status truth). */
1553
+ onlineDeviceIds() {
1554
+ const ids = new Set();
1555
+ for (const [deviceId, cam] of this.cameras) if (cam.ws.readyState === cam.ws.OPEN) ids.add(deviceId);
1556
+ return ids;
1402
1557
  }
1403
1558
  onCameraControl(deviceId, msg) {
1404
1559
  if (!msg) return;
@@ -1429,6 +1584,9 @@ var ViewHub = class {
1429
1584
  case "claim_active":
1430
1585
  this.selectDevice(deviceId);
1431
1586
  break;
1587
+ case "ping":
1588
+ this.sendControl(deviceId, { type: "pong" });
1589
+ break;
1432
1590
  case "capture_result":
1433
1591
  if (msg.status !== "taken") {
1434
1592
  this.pendingCaptures.delete(msg.captureId);
@@ -1551,6 +1709,7 @@ var ViewHub = class {
1551
1709
  const prevActive = this.activeDeviceId;
1552
1710
  if (prevActive === deviceId) return;
1553
1711
  this.activeDeviceId = deviceId;
1712
+ this.preferredDeviceId = deviceId;
1554
1713
  if (prevActive) this.sendControl(prevActive, { type: "pause_preview" });
1555
1714
  this.sendControl(deviceId, { type: "resume_preview" });
1556
1715
  this.broadcastDevices();