phone-lens 1.0.2 → 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
@@ -858,13 +858,11 @@ async function startLensServer(deps) {
858
858
  const { config: config$1, pairing: pairing$1, devices: devices$1, hub: hub$1, targets: targets$1, sink: sink$1, log: log$1 } = deps;
859
859
  const pairLimiter = new RateLimiter(6e4, 10);
860
860
  const uploadReplays = new Map();
861
- let currentCameraDeviceId = null;
862
861
  const injectionBox = { last: null };
863
862
  const server = createServer((req, res) => {
864
863
  handle$1(deps, req, res, {
865
864
  pairLimiter,
866
865
  uploadReplays,
867
- getCurrentCamera: () => currentCameraDeviceId,
868
866
  noteInjection: (receipt, attachmentId) => {
869
867
  injectionBox.last = {
870
868
  at: Date.now(),
@@ -900,7 +898,6 @@ async function startLensServer(deps) {
900
898
  const record = devices$1.authenticate(deviceId, token);
901
899
  if (!record) return done(401, "Unauthorized");
902
900
  wss.handleUpgrade(req, socket, head, (ws) => {
903
- currentCameraDeviceId = record.deviceId;
904
901
  hub$1.attachCamera(record.deviceId, ws, record.name);
905
902
  });
906
903
  return;
@@ -1161,15 +1158,15 @@ async function handle$1(deps, req, res, ctx) {
1161
1158
  }
1162
1159
  }
1163
1160
  if (method === "GET" && path === "/status") {
1164
- const cameraDeviceId = ctx.getCurrentCamera();
1161
+ const online = hub$1.onlineDeviceIds();
1165
1162
  return sendJson(res, 200, {
1166
1163
  bootAt: SERVER_BOOT_AT,
1167
1164
  devices: devices$1.list().map((d) => ({
1168
1165
  id: d.deviceId,
1169
1166
  name: d.name,
1170
1167
  model: d.model,
1171
- online: d.deviceId === cameraDeviceId,
1172
- streaming: d.deviceId === cameraDeviceId,
1168
+ online: online.has(d.deviceId),
1169
+ streaming: online.has(d.deviceId),
1173
1170
  lastSeenAt: d.lastSeenAt
1174
1171
  })),
1175
1172
  camera: hub$1.stats(),
@@ -1216,6 +1213,7 @@ async function handle$1(deps, req, res, ctx) {
1216
1213
  return sendError(res, 404, ERROR_CODES.BAD_REQUEST, `no route ${method} ${path}`);
1217
1214
  }
1218
1215
  /** Strip path separators / reserved characters so a phone-supplied upload
1216
+
1219
1217
  * name can never escape the target directory or hide as a dotfile. */
1220
1218
  function sanitizeFileName(input) {
1221
1219
  const base = basename(input).replace(/[<>:"|?*\u0000-\u001F]/g, "_").replace(/^[\s.]+/, "").trim();
@@ -1233,13 +1231,17 @@ function nextAvailableName(dir, name) {
1233
1231
  }
1234
1232
  }
1235
1233
  /** Pending-staging filename for an attachment id. Ids may be URN-ish
1234
+
1236
1235
  * ("file:<digest>" without the dsh attachment service) and Windows forbids
1236
+
1237
1237
  * ":" in filenames — flatten to a portable name, write and read alike. */
1238
1238
  function pendingFileName(attachmentId) {
1239
1239
  return `${attachmentId.replace(/[^A-Za-z0-9._-]/g, "_")}.jpg`;
1240
1240
  }
1241
1241
  /** Resolve `name` under `baseDir` and refuse anything that escapes it
1242
+
1242
1243
  * (defense in depth on top of sanitizeFileName — e.g. a crafted name that
1244
+
1243
1245
  * survives sanitization but still resolves outside the base). */
1244
1246
  function resolveUnder(baseDir, name) {
1245
1247
  const base = resolve(baseDir);
@@ -1321,6 +1323,18 @@ async function pruneUploads(dir, max) {
1321
1323
  //#region src/server/hub.ts
1322
1324
  /**
1323
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
+
1324
1338
  * How long the ACTIVE device's stream may go silent before views are told the
1325
1339
 
1326
1340
  * preview was turned off. The phone app never announces "preview off" — it
@@ -1353,6 +1367,22 @@ const PREVIEW_STALL_MS = 3e3;
1353
1367
  var ViewHub = class {
1354
1368
  cameras = new Map();
1355
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;
1356
1386
  views = new Set();
1357
1387
  /** captureId → { note, requestedAt } until the matching upload lands or timeout. */
1358
1388
  pendingCaptures = new Map();
@@ -1385,12 +1415,16 @@ var ViewHub = class {
1385
1415
  meta: {},
1386
1416
  lastFrame: null,
1387
1417
  lastFrameAt: 0,
1418
+ lastSeenAt: Date.now(),
1388
1419
  frameCount: 0,
1389
1420
  windowStart: Date.now(),
1390
1421
  measuredFps: 0
1391
1422
  };
1392
1423
  this.cameras.set(deviceId, cam);
1393
- 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);
1394
1428
  if (this.activeDeviceId === deviceId) this.sendControl(deviceId, { type: "resume_preview" });
1395
1429
  else this.sendControl(deviceId, { type: "pause_preview" });
1396
1430
  this.log("info", `camera uplink: ${name} (${deviceId.slice(0, 8)})`);
@@ -1398,7 +1432,13 @@ var ViewHub = class {
1398
1432
  this.log("warn", `camera ws closed: code=${code} reason=${reason.toString("utf8") || "-"} (${name} ${deviceId.slice(0, 8)})`);
1399
1433
  if (this.cameras.get(deviceId)?.ws === ws) this.detachCamera(deviceId);
1400
1434
  });
1435
+ ws.on("pong", () => {
1436
+ const c = this.cameras.get(deviceId);
1437
+ if (c) c.lastSeenAt = Date.now();
1438
+ });
1401
1439
  ws.on("message", (data, isBinary) => {
1440
+ const c = this.cameras.get(deviceId);
1441
+ if (c) c.lastSeenAt = Date.now();
1402
1442
  if (isBinary) {
1403
1443
  this.ingestFrame(deviceId, data);
1404
1444
  return;
@@ -1428,7 +1468,22 @@ var ViewHub = class {
1428
1468
  this.broadcastDevices();
1429
1469
  }
1430
1470
  pingAll() {
1431
- 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;
1432
1487
  }
1433
1488
  onCameraControl(deviceId, msg) {
1434
1489
  if (!msg) return;
@@ -1459,6 +1514,9 @@ var ViewHub = class {
1459
1514
  case "claim_active":
1460
1515
  this.selectDevice(deviceId);
1461
1516
  break;
1517
+ case "ping":
1518
+ this.sendControl(deviceId, { type: "pong" });
1519
+ break;
1462
1520
  case "capture_result":
1463
1521
  if (msg.status !== "taken") {
1464
1522
  this.pendingCaptures.delete(msg.captureId);
@@ -1581,6 +1639,7 @@ var ViewHub = class {
1581
1639
  const prevActive = this.activeDeviceId;
1582
1640
  if (prevActive === deviceId) return;
1583
1641
  this.activeDeviceId = deviceId;
1642
+ this.preferredDeviceId = deviceId;
1584
1643
  if (prevActive) this.sendControl(prevActive, { type: "pause_preview" });
1585
1644
  this.sendControl(deviceId, { type: "resume_preview" });
1586
1645
  this.broadcastDevices();
package/lib/index.js CHANGED
@@ -928,13 +928,11 @@ async function startLensServer(deps) {
928
928
  const { config, pairing, devices, hub, targets, sink, log } = deps;
929
929
  const pairLimiter = new RateLimiter(6e4, 10);
930
930
  const uploadReplays = new Map();
931
- let currentCameraDeviceId = null;
932
931
  const injectionBox = { last: null };
933
932
  const server = createServer((req, res) => {
934
933
  handle(deps, req, res, {
935
934
  pairLimiter,
936
935
  uploadReplays,
937
- getCurrentCamera: () => currentCameraDeviceId,
938
936
  noteInjection: (receipt, attachmentId) => {
939
937
  injectionBox.last = {
940
938
  at: Date.now(),
@@ -970,7 +968,6 @@ async function startLensServer(deps) {
970
968
  const record = devices.authenticate(deviceId, token);
971
969
  if (!record) return done(401, "Unauthorized");
972
970
  wss.handleUpgrade(req, socket, head, (ws) => {
973
- currentCameraDeviceId = record.deviceId;
974
971
  hub.attachCamera(record.deviceId, ws, record.name);
975
972
  });
976
973
  return;
@@ -1231,15 +1228,15 @@ async function handle(deps, req, res, ctx) {
1231
1228
  }
1232
1229
  }
1233
1230
  if (method === "GET" && path === "/status") {
1234
- const cameraDeviceId = ctx.getCurrentCamera();
1231
+ const online = hub.onlineDeviceIds();
1235
1232
  return sendJson(res, 200, {
1236
1233
  bootAt: SERVER_BOOT_AT,
1237
1234
  devices: devices.list().map((d) => ({
1238
1235
  id: d.deviceId,
1239
1236
  name: d.name,
1240
1237
  model: d.model,
1241
- online: d.deviceId === cameraDeviceId,
1242
- streaming: d.deviceId === cameraDeviceId,
1238
+ online: online.has(d.deviceId),
1239
+ streaming: online.has(d.deviceId),
1243
1240
  lastSeenAt: d.lastSeenAt
1244
1241
  })),
1245
1242
  camera: hub.stats(),
@@ -1286,6 +1283,7 @@ async function handle(deps, req, res, ctx) {
1286
1283
  return sendError(res, 404, ERROR_CODES.BAD_REQUEST, `no route ${method} ${path}`);
1287
1284
  }
1288
1285
  /** Strip path separators / reserved characters so a phone-supplied upload
1286
+
1289
1287
  * name can never escape the target directory or hide as a dotfile. */
1290
1288
  function sanitizeFileName(input) {
1291
1289
  const base = basename(input).replace(/[<>:"|?*\u0000-\u001F]/g, "_").replace(/^[\s.]+/, "").trim();
@@ -1303,13 +1301,17 @@ function nextAvailableName(dir, name) {
1303
1301
  }
1304
1302
  }
1305
1303
  /** Pending-staging filename for an attachment id. Ids may be URN-ish
1304
+
1306
1305
  * ("file:<digest>" without the dsh attachment service) and Windows forbids
1306
+
1307
1307
  * ":" in filenames — flatten to a portable name, write and read alike. */
1308
1308
  function pendingFileName(attachmentId) {
1309
1309
  return `${attachmentId.replace(/[^A-Za-z0-9._-]/g, "_")}.jpg`;
1310
1310
  }
1311
1311
  /** Resolve `name` under `baseDir` and refuse anything that escapes it
1312
+
1312
1313
  * (defense in depth on top of sanitizeFileName — e.g. a crafted name that
1314
+
1313
1315
  * survives sanitization but still resolves outside the base). */
1314
1316
  function resolveUnder(baseDir, name) {
1315
1317
  const base = resolve(baseDir);
@@ -1391,6 +1393,18 @@ async function pruneUploads(dir, max) {
1391
1393
  //#region src/server/hub.ts
1392
1394
  /**
1393
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
+
1394
1408
  * How long the ACTIVE device's stream may go silent before views are told the
1395
1409
 
1396
1410
  * preview was turned off. The phone app never announces "preview off" — it
@@ -1423,6 +1437,22 @@ const PREVIEW_STALL_MS = 3e3;
1423
1437
  var ViewHub = class {
1424
1438
  cameras = new Map();
1425
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;
1426
1456
  views = new Set();
1427
1457
  /** captureId → { note, requestedAt } until the matching upload lands or timeout. */
1428
1458
  pendingCaptures = new Map();
@@ -1455,12 +1485,16 @@ var ViewHub = class {
1455
1485
  meta: {},
1456
1486
  lastFrame: null,
1457
1487
  lastFrameAt: 0,
1488
+ lastSeenAt: Date.now(),
1458
1489
  frameCount: 0,
1459
1490
  windowStart: Date.now(),
1460
1491
  measuredFps: 0
1461
1492
  };
1462
1493
  this.cameras.set(deviceId, cam);
1463
- 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);
1464
1498
  if (this.activeDeviceId === deviceId) this.sendControl(deviceId, { type: "resume_preview" });
1465
1499
  else this.sendControl(deviceId, { type: "pause_preview" });
1466
1500
  this.log("info", `camera uplink: ${name} (${deviceId.slice(0, 8)})`);
@@ -1468,7 +1502,13 @@ var ViewHub = class {
1468
1502
  this.log("warn", `camera ws closed: code=${code} reason=${reason.toString("utf8") || "-"} (${name} ${deviceId.slice(0, 8)})`);
1469
1503
  if (this.cameras.get(deviceId)?.ws === ws) this.detachCamera(deviceId);
1470
1504
  });
1505
+ ws.on("pong", () => {
1506
+ const c = this.cameras.get(deviceId);
1507
+ if (c) c.lastSeenAt = Date.now();
1508
+ });
1471
1509
  ws.on("message", (data, isBinary) => {
1510
+ const c = this.cameras.get(deviceId);
1511
+ if (c) c.lastSeenAt = Date.now();
1472
1512
  if (isBinary) {
1473
1513
  this.ingestFrame(deviceId, data);
1474
1514
  return;
@@ -1498,7 +1538,22 @@ var ViewHub = class {
1498
1538
  this.broadcastDevices();
1499
1539
  }
1500
1540
  pingAll() {
1501
- 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;
1502
1557
  }
1503
1558
  onCameraControl(deviceId, msg) {
1504
1559
  if (!msg) return;
@@ -1529,6 +1584,9 @@ var ViewHub = class {
1529
1584
  case "claim_active":
1530
1585
  this.selectDevice(deviceId);
1531
1586
  break;
1587
+ case "ping":
1588
+ this.sendControl(deviceId, { type: "pong" });
1589
+ break;
1532
1590
  case "capture_result":
1533
1591
  if (msg.status !== "taken") {
1534
1592
  this.pendingCaptures.delete(msg.captureId);
@@ -1651,6 +1709,7 @@ var ViewHub = class {
1651
1709
  const prevActive = this.activeDeviceId;
1652
1710
  if (prevActive === deviceId) return;
1653
1711
  this.activeDeviceId = deviceId;
1712
+ this.preferredDeviceId = deviceId;
1654
1713
  if (prevActive) this.sendControl(prevActive, { type: "pause_preview" });
1655
1714
  this.sendControl(deviceId, { type: "resume_preview" });
1656
1715
  this.broadcastDevices();
package/lib/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":["raw: unknown","t: unknown","value: unknown","fallback: number","min: number","max: number","config: LensConfig","log: (level: \"info\" | \"warn\", msg: string) => void","agent: EventedAgent","admitted: AdmittedImage","note: string | undefined","mode: \"followup\" | \"steer\"","content: unknown[]","config: LensConfig","ttlMs: number","submitted: string","now: number","token: string","recordHash: string","presentedToken: string","dataDir: string","record: Omit<DeviceRecord, \"firstPairedAt\" | \"lastSeenAt\">","full: DeviceRecord","deviceId: string","name: string","token: string","SAVE_MODES: readonly SaveMode[]","file: string","defaultSaveDir: string","patch: Partial<AppSettings>","mediaType: string","head: Buffer","input: { data: Uint8Array; mediaType: string; name: string }","attachments: AttachmentStoreLike | undefined","fallbackDir: string","cached: { url: string; at: number } | null","inflight: Promise<string | null> | null","raw: unknown","data: unknown","config: LensConfig","out: LanAddress[]","ip: string","code: string","expiresAt: number","config: LensConfig","originOverride?: string","windowMs: number","max: number","key: string","req: IncomingMessage","ownVersion: string | null","deps: ServerDeps","currentCameraDeviceId: string | null","injectionBox: { last: { at: number; sessionId: string | null; attachmentId: string; ok: boolean } | null }","server: HttpServer","req: IncomingMessage","socket: Duplex","head: Buffer","status: number","message: string","res: ServerResponse","ctx: RouteCtx","savedDir: string | null","patch: { saveMode?: SaveMode; saveDir?: string }","input: string","dir: string","name: string","attachmentId: string","baseDir: string","body: unknown","extraHeaders?: Record<string, string>","code: string","v: unknown","limit: number","maxBytes: number","chunks: Buffer[]","chunk: Buffer","max: number","config: LensConfig","log: (level: \"info\" | \"warn\", msg: string) => void","deviceId: string","ws: WebSocket","name: string","cam: CamState","msg: CameraControl | null","frame: Buffer","hooks: { onRefreshPairing?: () => void; onRenameDevice?: (deviceId: string, name: string) => void }","msg: CameraControl","view: WebSocket","captureId: string","note?: string","msg: ViewServerMessage","text: string","ctx: any","rawConfig: unknown","level: LogLevel","msg: string","agent: EventedAgent","payload: { agent: EventedAgent }","payload: { agent: EventedAgent; status: string }","handle: LensServerHandle | null","error: unknown"],"sources":["../src/config.ts","../src/inject/host-sink.ts","../src/inject/target.ts","../src/store/pairing.ts","../src/store/devices.ts","../src/store/settings.ts","../src/types.ts","../src/inject/admit.ts","../src/server/apk-link.ts","../src/server/qr.ts","../src/server/static-view.ts","../src/server/auth.ts","../src/server/http.ts","../src/server/hub.ts","../src/paths.ts","../src/index.ts"],"sourcesContent":["import type { LensConfig } from \"./types.js\";\r\n\r\n/**\r\n * Offline fallback for the Gitee APK download link. Since v1.0.2 the actual\r\n * link is resolved from Gitee's public latest-release API at runtime (see\r\n * server/apk-link.ts), so this constant needs NO per-release bump anymore —\r\n * it only serves when the API is unreachable, and deliberately points at a\r\n * release known to exist (pointing it at a future tag would 404 until that\r\n * release is actually published).\r\n */\r\nexport const GITEE_APK_DEFAULT = \"https://gitee.com/qianfengbingtang/phone-lens/releases/download/v1.0.0/app-release.apk\";\r\n\r\n/** Coerce an unknown config object (cordis patch row / CLI overrides) into LensConfig. */\r\nexport function normalizeConfig(raw: unknown): LensConfig {\r\n const r = (raw ?? {}) as Record<string, any>;\r\n const server = (r.server ?? {}) as Record<string, any>;\r\n const limits = (r.limits ?? {}) as Record<string, any>;\r\n const pairing = (r.pairing ?? {}) as Record<string, any>;\r\n const preview = (r.preview ?? {}) as Record<string, any>;\r\n const inject = (r.inject ?? {}) as Record<string, any>;\r\n const target = (r.target ?? {}) as Record<string, any>;\r\n const app = (r.app ?? {}) as Record<string, any>;\r\n // App download sources: Gitee first (CN-friendly), GitHub as the fallback\r\n // link. The Gitee URL is resolved from Gitee's latest-release API at\r\n // runtime (server/apk-link.ts, since v1.0.2) — GITEE_APK_DEFAULT above is\r\n // only the offline fallback; GitHub's latest/download link is inherently\r\n // dynamic. Neither needs a bump when a new APK ships.\r\n const GITHUB_APK = \"https://github.com/yxqfg/phone-lens/releases/latest/download/app-release.apk\";\r\n const giteeUrl = typeof app.giteeUrl === \"string\" && app.giteeUrl ? app.giteeUrl : GITEE_APK_DEFAULT;\r\n const allowed = Array.isArray(limits.allowedTypes) ? limits.allowedTypes.filter((t: unknown): t is string => typeof t === \"string\") : undefined;\r\n const mode = inject.mode === \"steer\" ? \"steer\" : \"followup\";\r\n return {\r\n server: {\r\n host: typeof server.host === \"string\" && server.host ? server.host : \"0.0.0.0\",\r\n port: Number.isInteger(server.port) && server.port! > 0 && server.port! < 65536 ? server.port! : 8791,\r\n },\r\n limits: {\r\n maxUploadBytes: positiveInt(limits.maxUploadBytes, 10 * 1024 * 1024),\r\n allowedTypes: allowed && allowed.length > 0 ? allowed : [\"image/jpeg\", \"image/png\"],\r\n previewFrameMaxBytes: positiveInt(limits.previewFrameMaxBytes, 512 * 1024),\r\n // keep the local upload archive bounded: oldest files are pruned past this\r\n maxStoredUploads: positiveInt(limits.maxStoredUploads, 200),\r\n },\r\n pairing: { codeTtlMs: positiveInt(pairing.codeTtlMs, 15 * 60 * 1000) },\r\n preview: {\r\n maxWidth: positiveInt(preview.maxWidth, 854),\r\n maxHeight: positiveInt(preview.maxHeight, 480),\r\n fps: clampInt(preview.fps, 1, 30, 10),\r\n jpegQuality: clampInt(preview.jpegQuality, 20, 95, 70),\r\n },\r\n inject: {\r\n mode,\r\n notePrefix: typeof inject.notePrefix === \"string\" ? inject.notePrefix : \"[手机拍照]\",\r\n },\r\n target: {\r\n mode: target.mode === \"pinned\" ? \"pinned\" : \"latest\",\r\n pinnedSessionId: typeof target.pinnedSessionId === \"string\" && target.pinnedSessionId ? target.pinnedSessionId : null,\r\n },\r\n app: {\r\n giteeUrl,\r\n githubUrl: typeof app.githubUrl === \"string\" && app.githubUrl ? app.githubUrl : GITHUB_APK,\r\n // The QR encodes this URL; defaults to the Gitee release asset.\r\n downloadUrl: typeof app.downloadUrl === \"string\" && app.downloadUrl ? app.downloadUrl : giteeUrl,\r\n },\r\n };\r\n}\r\n\r\nfunction positiveInt(value: unknown, fallback: number): number {\r\n return Number.isInteger(value) && (value as number) > 0 ? (value as number) : fallback;\r\n}\r\n\r\nfunction clampInt(value: unknown, min: number, max: number, fallback: number): number {\r\n if (!Number.isInteger(value)) return fallback;\r\n const n = value as number;\r\n return Math.min(max, Math.max(min, n));\r\n}\r\n","import { createUserMessage } from \"@deepseek-ai/dsh-llm\";\r\nimport type { AdmittedImage, DeliveryReceipt, LensConfig } from \"../types.js\";\r\nimport type { DeliverySink } from \"./deliver.js\";\r\n\r\n/** Minimal structural view of a live dsh agent (docs/dsh-caps.md Q1/Q3). */\r\ninterface LiveAgent {\r\n id: string;\r\n session: { id: string };\r\n followup(message: unknown): void;\r\n steer(message: unknown): void;\r\n ctx?: { sessions?: unknown };\r\n}\r\n\r\nexport interface EventedAgent {\r\n send(message: unknown, target: \"next-turn\" | \"next-step\", wakeup: boolean): void;\r\n followup(message: unknown): void;\r\n steer(message: unknown): void;\r\n session: { id: string };\r\n id?: string;\r\n}\r\n\r\n/**\r\n * Phase 2 delivery seam: turns a stored image into a durable dsh user message\r\n * attached to the current/last-active session, so the model sees it on the\r\n * next turn.\r\n *\r\n * The active session is tracked from live agent events (inbox inserts and\r\n * `running` status flips). With no live session the image stays stored but\r\n * no delivery occurs — a real state, not an error.\r\n */\r\nexport class HostDeliverySink implements DeliverySink {\r\n private lastActiveId: string | null = null;\r\n private agents = new Map<string, EventedAgent>();\r\n\r\n constructor(\r\n private readonly config: LensConfig,\r\n private readonly log: (level: \"info\" | \"warn\", msg: string) => void,\r\n ) {\r\n // subscribe via injected listeners; the caller wires these to the context\r\n // because the sink must stay cordis-agnostic for standalone tests.\r\n }\r\n\r\n /** Track an agent that appeared / got input. Call from ctx event handlers. */\r\n track(agent: EventedAgent): void {\r\n const id = agent.id ?? String(agent.session.id);\r\n this.agents.set(id, agent);\r\n this.lastActiveId = id;\r\n this.log(\"info\", `tracking agent ${id} (active target)`);\r\n }\r\n\r\n /** Forget a disposed agent. */\r\n untrack(agent: EventedAgent): void {\r\n const id = agent.id ?? String(agent.session.id);\r\n this.agents.delete(id);\r\n if (this.lastActiveId === id) this.lastActiveId = this.agents.keys().next().value ?? null;\r\n }\r\n\r\n /** The session an upload targets right now, if any. */\r\n private resolve(): EventedAgent | null {\r\n if (this.config.target.mode === \"pinned\" && this.config.target.pinnedSessionId) {\r\n const pinned = [...this.agents.values()].find((a) => String(a.session.id) === this.config.target.pinnedSessionId);\r\n if (pinned) return pinned;\r\n this.log(\"warn\", `pinned session ${this.config.target.pinnedSessionId} has no live agent`);\r\n }\r\n return (this.lastActiveId ? this.agents.get(this.lastActiveId) : null) ?? null;\r\n }\r\n\r\n async deliver(admitted: AdmittedImage, note: string | undefined, mode: \"followup\" | \"steer\"): Promise<DeliveryReceipt> {\r\n const agent = this.resolve();\r\n if (!agent) {\r\n return { ok: false, sessionId: null, mode: \"none\", reason: \"no active session\" };\r\n }\r\n try {\r\n const content: unknown[] = [\r\n { type: \"text\", text: note ?? this.config.inject.notePrefix },\r\n { type: \"image\", attachment: admitted.ref },\r\n ];\r\n const message = createUserMessage({\r\n content,\r\n source: { kind: \"plugin\", plugin: \"phone-lens\" },\r\n });\r\n this.log(\"info\", `delivering image (${admitted.ref.attachmentId}) to session ${agent.session.id} via ${mode}`);\r\n if (mode === \"steer\") agent.steer(message);\r\n else agent.followup(message);\r\n return { ok: true, sessionId: String(agent.session.id), mode };\r\n } catch (e) {\r\n this.log(\"warn\", `delivery failed: ${String(e)}`);\r\n return { ok: false, sessionId: null, mode: \"none\", reason: String(e) };\r\n }\r\n }\r\n}\r\n","import type { LensConfig } from \"../types.js\";\r\n\r\nexport interface SessionTarget {\r\n sessionId: string;\r\n title: string;\r\n active: boolean;\r\n running: boolean;\r\n}\r\n\r\n/**\r\n * Resolves the session an upload should be injected into.\r\n *\r\n * Phase 1: pure config view, no live agents (the agent-event wiring that\r\n * feeds `latest` lands in Phase 2 — see architecture.md §2 D4).\r\n */\r\nexport class TargetTracker {\r\n constructor(private readonly config: LensConfig) {}\r\n\r\n /** The session an incoming image would target right now, if any. */\r\n resolve(): SessionTarget | null {\r\n if (this.config.target.mode === \"pinned\" && this.config.target.pinnedSessionId) {\r\n return { sessionId: this.config.target.pinnedSessionId, title: \"(pinned)\", active: true, running: false };\r\n }\r\n return null;\r\n }\r\n\r\n /** Choosable targets for GET /targets. */\r\n list(): SessionTarget[] {\r\n const pinned = this.resolve();\r\n return pinned ? [pinned] : [];\r\n }\r\n\r\n mode(): string {\r\n return this.config.target.mode;\r\n }\r\n}\r\n","import { createHash, randomBytes, randomInt, timingSafeEqual } from \"node:crypto\";\r\n\r\n/** One-shot pairing-code lifecycle: create / verify / burn, with TTL. */\r\nexport class PairingStore {\r\n private code: string | null = null;\r\n private createdAt = 0;\r\n private expiresAt = 0;\r\n private used = false;\r\n\r\n constructor(private readonly ttlMs: number) {}\r\n\r\n /** Current live code, or a freshly minted one. */\r\n current(): { code: string; expiresAt: number } {\r\n const now = Date.now();\r\n if (this.code === null || this.used || now >= this.expiresAt) this.mint(now);\r\n return { code: this.code!, expiresAt: this.expiresAt };\r\n }\r\n\r\n /** Force a fresh code (manual refresh). Invalidates the previous one. */\r\n refresh(): { code: string; expiresAt: number } {\r\n this.mint(Date.now());\r\n return { code: this.code!, expiresAt: this.expiresAt };\r\n }\r\n\r\n /**\r\n * Verify a submitted code and burn it on success.\r\n * Constant-time compare; expired codes read as invalid.\r\n */\r\n verify(submitted: string): { ok: true } | { ok: false; reason: \"invalid\" | \"expired\" } {\r\n if (this.code === null || this.used) return { ok: false, reason: \"invalid\" };\r\n const now = Date.now();\r\n if (now >= this.expiresAt) return { ok: false, reason: \"expired\" };\r\n const a = Buffer.from(submitted);\r\n const b = Buffer.from(this.code);\r\n const same = a.length === b.length && timingSafeEqual(a, b);\r\n if (!same) return { ok: false, reason: \"invalid\" };\r\n this.used = true;\r\n return { ok: true };\r\n }\r\n\r\n private mint(now: number) {\r\n // 8 decimal digits, zero-padded (human-typable fallback).\r\n this.code = String(randomInt(0, 100_000_000)).padStart(8, \"0\");\r\n this.createdAt = now;\r\n this.expiresAt = now + this.ttlMs;\r\n this.used = false;\r\n }\r\n}\r\n\r\n/** Mint a per-device token. Only its SHA-256 may be persisted. */\r\nexport function mintDeviceToken(): string {\r\n return randomBytes(32).toString(\"hex\");\r\n}\r\n\r\nexport function hashToken(token: string): string {\r\n return createHash(\"sha256\").update(token).digest(\"hex\");\r\n}\r\n\r\n/** Constant-time token-hash comparison. */\r\nexport function tokenHashMatches(recordHash: string, presentedToken: string): boolean {\r\n const a = Buffer.from(recordHash, \"hex\");\r\n const b = Buffer.from(hashToken(presentedToken), \"hex\");\r\n return a.length === b.length && a.length > 0 && timingSafeEqual(a, b);\r\n}\r\n","import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from \"node:fs\";\r\nimport { join } from \"node:path\";\r\nimport type { DeviceRecord } from \"../types.js\";\r\nimport { tokenHashMatches } from \"./pairing.js\";\r\n\r\n/**\r\n * Paired-device registry, persisted as JSON under the plugin data dir.\r\n * Records carry token HASHES only; the raw token exists solely in the\r\n * pairing response and the phone's secure storage.\r\n */\r\nexport class DeviceStore {\r\n private devices = new Map<string, DeviceRecord>();\r\n private readonly file: string;\r\n private dirty = false;\r\n\r\n constructor(dataDir: string) {\r\n mkdirSync(dataDir, { recursive: true });\r\n this.file = join(dataDir, \"devices.json\");\r\n this.load();\r\n }\r\n\r\n private load() {\r\n if (!existsSync(this.file)) return;\r\n try {\r\n const raw = JSON.parse(readFileSync(this.file, \"utf8\")) as { devices?: DeviceRecord[] };\r\n for (const d of raw.devices ?? []) {\r\n if (typeof d?.deviceId === \"string\" && typeof d?.tokenHash === \"string\") {\r\n this.devices.set(d.deviceId, { ...d, name: d.name ?? \"unknown\", model: d.model ?? \"\", firstPairedAt: d.firstPairedAt ?? Date.now(), lastSeenAt: d.lastSeenAt ?? 0 });\r\n }\r\n }\r\n } catch {\r\n // Corrupt registry: start empty rather than refuse to boot.\r\n }\r\n }\r\n\r\n private persist() {\r\n const tmp = `${this.file}.tmp`;\r\n writeFileSync(tmp, JSON.stringify({ version: 1, devices: [...this.devices.values()] }, null, 2), \"utf8\");\r\n renameSync(tmp, this.file);\r\n }\r\n\r\n /** Register/re-register a device, uniquifying the name against others. */\r\n upsert(record: Omit<DeviceRecord, \"firstPairedAt\" | \"lastSeenAt\">): DeviceRecord {\r\n const prior = this.devices.get(record.deviceId);\r\n const full: DeviceRecord = {\r\n ...record,\r\n name: this.uniquifyName(record.deviceId, record.name),\r\n firstPairedAt: prior?.firstPairedAt ?? Date.now(),\r\n lastSeenAt: Date.now(),\r\n };\r\n this.devices.set(record.deviceId, full);\r\n this.dirty = true;\r\n this.persist();\r\n return full;\r\n }\r\n\r\n /** Make a display name unique among other devices (x, x (2), x (3), …). */\r\n private uniquifyName(deviceId: string, name: string): string {\r\n const others = [...this.devices.values()].map((d) => d.name).filter((n, i, arr) => arr.indexOf(n) === i);\r\n const base = name.trim() || \"Android 设备\";\r\n if (!others.includes(base)) return base;\r\n for (let n = 2; ; n++) {\r\n const candidate = `${base} (${n})`;\r\n if (!others.includes(candidate)) return candidate;\r\n }\r\n }\r\n\r\n /** Rename a device (user-editable in the web UI); returns the updated record. */\r\n rename(deviceId: string, name: string): DeviceRecord | null {\r\n const record = this.devices.get(deviceId);\r\n if (!record) return null;\r\n record.name = name.trim() || record.name;\r\n this.dirty = true;\r\n this.persist();\r\n return record;\r\n }\r\n\r\n /** Authenticate deviceId + token; refreshes lastSeenAt on success. */\r\n authenticate(deviceId: string, token: string): DeviceRecord | null {\r\n const record = this.devices.get(deviceId);\r\n if (!record) return null;\r\n if (!tokenHashMatches(record.tokenHash, token)) return null;\r\n record.lastSeenAt = Date.now();\r\n this.dirty = true;\r\n return record;\r\n }\r\n\r\n remove(deviceId: string): boolean {\r\n const had = this.devices.delete(deviceId);\r\n if (had) this.persist();\r\n return had;\r\n }\r\n\r\n list(): DeviceRecord[] {\r\n return [...this.devices.values()].sort((a, b) => b.lastSeenAt - a.lastSeenAt);\r\n }\r\n\r\n count(): number {\r\n return this.devices.size;\r\n }\r\n}\r\n","import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\n\n/** How an uploaded photo reaches the user (user-selected in the web UI settings). */\nexport type SaveMode = \"composer\" | \"folder\" | \"both\";\n\nexport const SAVE_MODES: readonly SaveMode[] = [\"composer\", \"folder\", \"both\"];\n\nexport interface AppSettings {\n /** composer = stage into the chat box draft; folder = save to saveDir; both = both. */\n saveMode: SaveMode;\n /** Absolute target folder for folder/both modes. Empty = built-in default. */\n saveDir: string;\n}\n\n/**\n * User-facing capture settings, persisted as JSON under the plugin data dir.\n * Stored host-side on purpose: the folder save happens on this machine, and\n * every loopback view (overlay, /view.html) must see the same mode.\n */\nexport class AppSettingsStore {\n private data: AppSettings;\n\n constructor(\n /** e.g. ~/.dsh/phone-lens/settings.json */\n private readonly file: string,\n /** Built-in default folder when saveDir is empty. */\n private readonly defaultSaveDir: string,\n ) {\n this.data = { saveMode: \"composer\", saveDir: \"\" };\n this.load();\n }\n\n private load(): void {\n if (!existsSync(this.file)) return;\n try {\n const raw = JSON.parse(readFileSync(this.file, \"utf8\")) as Partial<AppSettings>;\n if (SAVE_MODES.includes(raw.saveMode as SaveMode)) this.data.saveMode = raw.saveMode as SaveMode;\n if (typeof raw.saveDir === \"string\") this.data.saveDir = raw.saveDir;\n } catch {\n // corrupt settings file: start with defaults rather than refuse to boot\n }\n }\n\n get(): AppSettings {\n return { ...this.data };\n }\n\n /** Effective absolute folder (resolved default when unset). */\n effectiveSaveDir(): string {\n return this.data.saveDir.trim() || this.defaultSaveDir;\n }\n\n set(patch: Partial<AppSettings>): AppSettings {\n if (patch.saveMode && SAVE_MODES.includes(patch.saveMode)) this.data.saveMode = patch.saveMode;\n if (typeof patch.saveDir === \"string\") {\n // strip wrapping quotes (right-click \"copy path\" often adds them)\n this.data.saveDir = patch.saveDir.replace(/^[\"']|[\"']$/g, \"\").trim();\n }\n this.persist();\n return this.get();\n }\n\n private persist(): void {\n try {\n mkdirSync(join(this.file, \"..\"), { recursive: true });\n const tmp = `${this.file}.tmp`;\n writeFileSync(tmp, JSON.stringify(this.data, null, 2), \"utf8\");\n renameSync(tmp, this.file);\n } catch {\n // persistence is best-effort; runtime keeps working with in-memory values\n }\n }\n}\n","/** Shared vocabulary for the phone-lens host half. */\r\n\r\n/** Normalized plugin config (see cordis.patch.yml / config.ts defaults). */\r\nexport interface LensConfig {\r\n server: { host: string; port: number };\r\n limits: {\r\n maxUploadBytes: number;\r\n allowedTypes: string[];\r\n previewFrameMaxBytes: number;\r\n maxStoredUploads: number;\r\n };\r\n pairing: { codeTtlMs: number };\r\n preview: { maxWidth: number; maxHeight: number; fps: number; jpegQuality: number };\r\n inject: { mode: \"followup\" | \"steer\"; notePrefix: string };\r\n target: { mode: \"latest\" | \"pinned\"; pinnedSessionId: string | null };\r\n /** Where the phone App APK can be fetched from (QR + fallback links). */\r\n app: { downloadUrl: string; giteeUrl: string; githubUrl: string };\r\n}\r\n\r\n/** Minimal structural view of a dsh ImageAttachmentRef (docs/dsh-caps.md Q1). */\r\nexport interface ImageAttachmentRef {\r\n attachmentId: string;\r\n mediaType: string;\r\n bytes: number;\r\n width: number;\r\n height: number;\r\n name?: string;\r\n originalDimensions?: { width: number; height: number };\r\n}\r\n\r\n/** One durable image ready for session injection. */\r\nexport interface AdmittedImage {\r\n ref: ImageAttachmentRef;\r\n /** Where the bytes physically landed: \"attachments\" (dsh store) or \"file\" (fallback dir). */\r\n storage: \"attachments\" | \"file\";\r\n /** Fallback file path when storage === \"file\". */\r\n filePath?: string;\r\n}\r\n\r\n/** Result of the deliver step. */\r\nexport interface DeliveryReceipt {\r\n ok: boolean;\r\n sessionId: string | null;\r\n mode: \"followup\" | \"steer\" | \"none\";\r\n reason?: string;\r\n}\r\n\r\n/** Persistent paired-device record. The raw token never touches disk — only its SHA-256. */\r\nexport interface DeviceRecord {\r\n deviceId: string;\r\n name: string;\r\n model: string;\r\n tokenHash: string;\r\n firstPairedAt: number;\r\n lastSeenAt: number;\r\n}\r\n\r\n/** Live pairing-code state. */\r\nexport interface PairingState {\r\n code: string;\r\n createdAt: number;\r\n expiresAt: number;\r\n used: boolean;\r\n}\r\n\r\n/** JSON message on the camera websocket (text frames). */\r\nexport type CameraControl =\r\n | { type: \"hello\"; width: number; height: number; fps: number; rotation?: number }\r\n | { type: \"bye\" }\r\n | { type: \"set_preview\"; maxWidth: number; maxHeight: number; fps: number; jpegQuality: number }\r\n | { type: \"capture\"; captureId: string; note?: string }\r\n | { type: \"capture_result\"; captureId: string; status: \"taken\" | \"declined\" | \"failed\"; detail?: string }\r\n // phone→host: make THIS phone the active preview/shutter device\r\n | { type: \"claim_active\" }\r\n // host→phone: another device owns the PC preview; stop/start streaming\r\n | { type: \"pause_preview\" }\r\n | { type: \"resume_preview\" };\r\n\r\n/** JSON message on the view websocket. */\r\nexport type ViewServerMessage =\r\n | { type: \"meta\"; camera: { connected: boolean; width?: number; height?: number; fps?: number; rotation?: number; name?: string }; preview: { maxWidth: number; maxHeight: number; fps: number; jpegQuality: number }; paired: boolean }\r\n | { type: \"frame_meta\"; width: number; height: number; rotation?: number }\r\n | { type: \"devices\"; devices: { id: string; name: string; active: boolean }[] }\r\n | { type: \"device\"; online: boolean; name?: string }\r\n // host→view: the ACTIVE phone's stream stalled (user turned preview off on\r\n // the phone) or resumed. `on:false` lets the web UI clear the last frame.\r\n | { type: \"preview_state\"; on: boolean }\r\n | { type: \"capture_pending\"; captureId: string; note?: string }\r\n | { type: \"pending_image\"; attachmentId: string; name?: string }\r\n // folder-only save: the photo bypassed the composer; surface a local hint.\r\n | { type: \"upload_saved\"; name?: string; dir: string }\r\n | { type: \"injected\"; captureId?: string; attachmentId: string; sessionId: string | null; ok: boolean; reason?: string; name?: string }\r\n | { type: \"upload\"; attachmentId: string; storage: string; name?: string }\r\n | { type: \"error\"; code: string; message?: string };\r\n\r\nexport type ViewClientMessage =\r\n | { type: \"capture\"; note?: string }\r\n | { type: \"select_device\"; deviceId: string }\r\n | { type: \"rename_device\"; deviceId: string; name: string }\r\n | { type: \"refresh_pairing\" }\r\n | { type: \"ping\" };\r\n\r\n/** Wire error body. */\r\nexport interface ErrorBody {\r\n error: { code: string; message: string };\r\n}\r\n\r\nexport const ERROR_CODES = {\r\n AUTH_REQUIRED: \"AUTH_REQUIRED\",\r\n PAIR_CODE_INVALID: \"PAIR_CODE_INVALID\",\r\n PAIR_CODE_EXPIRED: \"PAIR_CODE_EXPIRED\",\r\n RATE_LIMITED: \"RATE_LIMITED\",\r\n TYPE_NOT_ALLOWED: \"TYPE_NOT_ALLOWED\",\r\n TOO_LARGE: \"TOO_LARGE\",\r\n BAD_MAGIC: \"BAD_MAGIC\",\r\n STORE_FAILED: \"STORE_FAILED\",\r\n NO_TARGET: \"NO_TARGET\",\r\n NO_CAMERA: \"NO_CAMERA\",\r\n CAPTURE_TIMEOUT: \"CAPTURE_TIMEOUT\",\r\n LOOPBACK_ONLY: \"LOOPBACK_ONLY\",\r\n BAD_REQUEST: \"BAD_REQUEST\",\r\n INTERNAL: \"INTERNAL\",\r\n} as const;\r\n","import { createHash } from \"node:crypto\";\r\nimport { mkdirSync, writeFileSync } from \"node:fs\";\r\nimport { join } from \"node:path\";\r\nimport type { AdmittedImage } from \"../types.js\";\r\n\r\n/** Structural type of the dsh attachment store we consume (docs/dsh-caps.md Q1). */\r\nexport interface AttachmentStoreLike {\r\n saveImages(inputs: ReadonlyArray<{ data: Uint8Array; mediaType: string; name?: string }>): Promise<readonly AdmittedImage[\"ref\"][]>;\r\n}\r\n\r\n/** Magic-byte whitelist check — never trust Content-Type alone. */\r\nexport function magicMatches(mediaType: string, head: Buffer): boolean {\r\n if (mediaType === \"image/jpeg\") return head.length >= 3 && head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff;\r\n if (mediaType === \"image/png\") return head.length >= 4 && head[0] === 0x89 && head[1] === 0x50 && head[2] === 0x4e && head[3] === 0x47;\r\n return false;\r\n}\r\n\r\n/**\r\n * Persist one image through the durable attachment seam.\r\n * Falls back to a content-addressed file when the dsh attachments service is\r\n * not mounted (standalone dev / unusual profile), so uploads never vanish.\r\n */\r\nexport async function admitImage(\r\n input: { data: Uint8Array; mediaType: string; name: string },\r\n attachments: AttachmentStoreLike | undefined,\r\n fallbackDir: string,\r\n): Promise<AdmittedImage> {\r\n if (attachments) {\r\n const refs = await attachments.saveImages([{ data: input.data, mediaType: input.mediaType, name: input.name }]);\r\n const ref = refs[0];\r\n if (!ref) throw new Error(\"attachment store returned no reference\");\r\n return { ref, storage: \"attachments\" };\r\n }\r\n const ext = input.mediaType === \"image/png\" ? \"png\" : \"jpg\";\r\n const digest = createHash(\"sha1\").update(input.data).digest(\"hex\").slice(0, 16);\r\n mkdirSync(fallbackDir, { recursive: true });\r\n const filePath = join(fallbackDir, `${digest}-${input.name.replace(/[^\\w.-]+/g, \"_\") || \"image\"}.${ext}`);\r\n writeFileSync(filePath, input.data);\r\n return {\r\n ref: {\r\n attachmentId: `file:${digest}`,\r\n mediaType: input.mediaType,\r\n bytes: input.data.byteLength,\r\n width: 0,\r\n height: 0,\r\n name: input.name,\r\n },\r\n storage: \"file\",\r\n filePath,\r\n };\r\n}\r\n","/**\n * Dynamic APK-link resolution for the download QR.\n *\n * Gitee has no GitHub-style `releases/latest/download/<file>` short link (it\n * 302s to /repository/archive/ and 404s), so a hardcoded asset URL goes stale\n * the moment a new release ships — v1.0.0 shipped with a v0.3.11 link exactly\n * this way. Instead of bumping a constant on every release, ask Gitee's public\n * latest-release API for the current asset URL and cache it; the constant in\n * config.ts is only the offline fallback and needs no per-release bump.\n */\nimport { GITEE_APK_DEFAULT } from \"../config.js\";\nimport type { LensConfig } from \"../types.js\";\n\nconst GITEE_LATEST_API = \"https://gitee.com/api/v5/repos/qianfengbingtang/phone-lens/releases/latest\";\nconst CACHE_TTL_MS = 10 * 60 * 1000;\nconst REQUEST_TIMEOUT_MS = 6000;\n\nlet cached: { url: string; at: number } | null = null;\nlet inflight: Promise<string | null> | null = null;\n\n/** The asset URL comes from an external API response, so re-validate it before\n * it ever reaches a QR code: https only, and only the expected release host —\n * never localhost/loopback/private addresses or any other origin. */\nfunction isTrustedGiteeAsset(raw: unknown): raw is string {\n if (typeof raw !== \"string\" || !raw.startsWith(\"https://\")) return false;\n try {\n const u = new URL(raw);\n return u.protocol === \"https:\" && u.host === \"gitee.com\";\n } catch {\n return false;\n }\n}\n\nasync function fetchLatestGiteeApk(): Promise<string | null> {\n const ctrl = new AbortController();\n const timer = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS);\n try {\n const resp = await fetch(GITEE_LATEST_API, { signal: ctrl.signal, headers: { accept: \"application/json\" } });\n if (!resp.ok) return null;\n const data: unknown = await resp.json();\n const assets = (data as { assets?: unknown } | null)?.assets;\n if (!Array.isArray(assets)) return null;\n for (const asset of assets) {\n const row = asset as { name?: unknown; browser_download_url?: unknown };\n if (row.name === \"app-release.apk\" && isTrustedGiteeAsset(row.browser_download_url)) {\n return row.browser_download_url;\n }\n }\n return null;\n } catch {\n return null; // network error / timeout / malformed body — caller falls back\n } finally {\n clearTimeout(timer);\n }\n}\n\n/** Resolve the latest APK URL (cached 10 min, in-flight deduped).\n * Resolves null when the API is unreachable or carries no trusted asset. */\nexport function resolveLatestGiteeApk(): Promise<string | null> {\n if (cached && Date.now() - cached.at < CACHE_TTL_MS) return Promise.resolve(cached.url);\n if (inflight) return inflight;\n inflight = fetchLatestGiteeApk()\n .then((url) => {\n if (url) cached = { url, at: Date.now() };\n return url;\n })\n .finally(() => {\n inflight = null;\n });\n return inflight;\n}\n\n/**\n * Refresh config.app download links in place, but only while they still carry\n * the built-in defaults — an explicit user config always wins. Mutating the\n * shared config object lets the QR endpoints keep reading the plain fields.\n */\nexport async function refreshAppApkLinks(config: LensConfig): Promise<void> {\n if (config.app.giteeUrl !== GITEE_APK_DEFAULT) return;\n const url = await resolveLatestGiteeApk();\n if (!url) return;\n config.app.giteeUrl = url;\n if (config.app.downloadUrl === GITEE_APK_DEFAULT) config.app.downloadUrl = url;\n}\n","import * as os from \"node:os\";\r\nimport QRCode from \"qrcode\";\r\nimport type { LensConfig } from \"../types.js\";\r\n\r\nexport interface LanAddress {\r\n ip: string;\r\n iface: string;\r\n}\r\n\r\n/** Enumerate LAN IPv4 candidates: private ranges first, virtual/tethering included. */\r\nexport function lanAddresses(): LanAddress[] {\r\n const out: LanAddress[] = [];\r\n for (const [iface, addrs] of Object.entries(os.networkInterfaces())) {\r\n for (const addr of addrs ?? []) {\r\n if (addr.family !== \"IPv4\" || addr.internal) continue;\r\n out.push({ ip: addr.address, iface });\r\n }\r\n }\r\n const rank = (ip: string): number => {\r\n if (ip.startsWith(\"192.168.\")) return 0; // typical Wi-Fi + Android USB tethering\r\n if (ip.startsWith(\"172.\")) return 1; // container/iPhone-USB ranges\r\n if (ip.startsWith(\"10.\")) return 2;\r\n return 3;\r\n };\r\n return out.sort((a, b) => rank(a.ip) - rank(b.ip) || a.ip.localeCompare(b.ip));\r\n}\r\n\r\nexport interface PairingQr {\r\n code: string;\r\n expiresAt: number;\r\n payload: string;\r\n urls: string[];\r\n pngDataUrl: string;\r\n ascii: string;\r\n}\r\n\r\n/** Build the full pairing payload + rendered QR (data URL for browsers, ASCII for terminals). */\r\nexport async function buildPairingQr(code: string, expiresAt: number, config: LensConfig, originOverride?: string): Promise<PairingQr> {\r\n const addrs = lanAddresses();\r\n const host = originOverride ?? addrs[0]?.ip ?? \"127.0.0.1\";\r\n const port = config.server.port;\r\n const payload = `lensmate://pair?v=1&host=${encodeURIComponent(host)}&port=${port}&code=${code}`;\r\n const urls = (addrs.length > 0 ? addrs : [{ ip: \"127.0.0.1\", iface: \"loopback\" }]).map((a) => `http://${a.ip}:${port}`);\r\n const [pngDataUrl, ascii] = await Promise.all([\r\n QRCode.toDataURL(payload, { errorCorrectionLevel: \"M\", margin: 2, width: 320 }),\r\n QRCode.toString(payload, { type: \"terminal\", small: true }),\r\n ]);\r\n return { code, expiresAt, payload, urls, pngDataUrl, ascii };\r\n}\r\n","/**\r\n * The standalone fallback viewfinder page, served at /view.html (loopback only).\r\n * Same protocol as the future Web UI overlay: WS /ws/view frames + capture control.\r\n * Picture-in-Picture turns it into a true always-on-top mini window.\r\n */\r\nexport const VIEW_HTML = `<!doctype html>\r\n<html lang=\"zh-CN\">\r\n<head>\r\n<meta charset=\"utf-8\">\r\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\r\n<title>PhoneLens 直连取景</title>\r\n<style>\r\n :root { color-scheme: dark; }\r\n * { box-sizing: border-box; }\r\n body { margin: 0; font: 13px/1.5 system-ui, \"Segoe UI\", \"Microsoft YaHei\", sans-serif;\r\n background: #101418; color: #dfe7ee; display: flex; flex-direction: column; height: 100vh; }\r\n header { display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: #171d24; border-bottom: 1px solid #232c36; }\r\n header h1 { font-size: 13px; margin: 0; font-weight: 600; }\r\n #dot { width: 9px; height: 9px; border-radius: 50%; background: #e5484d; transition: background .2s; }\r\n #dot.on { background: #46a758; }\r\n #fps { margin-left: auto; color: #7d8da0; font-variant-numeric: tabular-nums; }\r\n main { flex: 1; display: flex; min-height: 0; }\r\n #stage { flex: 1; display: flex; align-items: center; justify-content: center; background: #0b0e11; position: relative; min-width: 0; }\r\n canvas { max-width: 100%; max-height: 100%; object-fit: contain; background: #000; }\r\n #hint { position: absolute; color: #7d8da0; }\r\n aside { width: 240px; border-left: 1px solid #232c36; padding: 12px; overflow: auto; }\r\n aside h2 { font-size: 12px; margin: 0 0 8px; color: #9fb0c3; text-transform: uppercase; letter-spacing: .08em; }\r\n #qrimg { width: 100%; image-rendering: pixelated; background: #fff; padding: 8px; border-radius: 8px; }\r\n .row { display: flex; gap: 6px; align-items: center; margin: 6px 0; }\r\n .row code { background: #1d242d; padding: 2px 8px; border-radius: 6px; letter-spacing: .12em; font-size: 15px; }\r\n .row a { color: #3b7cb5; text-decoration: none; }\r\n .row a:hover { text-decoration: underline; }\r\n button { background: #1f2937; border: 1px solid #2f3b48; color: #dfe7ee; padding: 6px 10px; border-radius: 8px; cursor: pointer; font-size: 12px; }\r\n button:hover { background: #28323e; }\r\n button:disabled { opacity: .45; cursor: default; }\r\n #shoot { padding: 10px; font-size: 14px; background: #2a5d8f; border-color: #3b7cb5; }\r\n #pip { display: none; }\r\n #log { margin-top: 10px; font-size: 12px; color: #8fa1b5; max-height: 160px; overflow: auto; }\r\n #log div { padding: 2px 0; border-bottom: 1px dashed #202832; }\r\n #log .ok { color: #58b368; } #log .err { color: #e5696e; }\r\n</style>\r\n</head>\r\n<body>\r\n<header>\r\n <div id=\"dot\"></div><h1>PhoneLens 直连取景</h1>\r\n <span id=\"dev\"></span>\r\n <span id=\"fps\"></span><span id=\"age\"></span>\r\n</header>\r\n<main>\r\n <div id=\"stage\"><canvas width=\"854\" height=\"480\"></canvas><div id=\"hint\">等待手机连接…</div></div>\r\n <aside>\r\n <h2>配对</h2>\r\n <img id=\"qrimg\" alt=\"配对二维码\">\r\n <div class=\"row\"><code id=\"paircode\"></code><button id=\"refresh\">刷新</button></div>\r\n <div class=\"row\" id=\"urls\"></div>\r\n <div class=\"row\"><a href=\"https://github.com/yxqfg/phone-lens/releases/latest/download/app-release.apk\" target=\"_blank\" rel=\"noopener\">📱 手机还没装 App?点此下载(Android APK)</a></div>\r\n <h2 style=\"margin-top:14px\">快门</h2>\r\n <div class=\"row\">\r\n <button id=\"shoot\" disabled>◉ 拍照并注入</button>\r\n <button id=\"pip\">置顶小窗</button>\r\n </div>\r\n <div id=\"log\"></div>\r\n </aside>\r\n</main>\r\n<video id=\"pipvid\" muted playsinline style=\"display:none\"></video>\r\n<script>\r\n'use strict';\r\nconst $ = (id) => document.getElementById(id);\r\nconst canvas = $('stage').querySelector('canvas');\r\nconst ctx2d = canvas.getContext('2d');\r\nconst logEl = $('log');\r\nlet lastFrameAt = 0, frames = 0, fpsTimer = 0, pendingShot = null;\r\nlet streamRotation = 0;\r\n\r\nfunction log(msg, cls) {\r\n const d = document.createElement('div');\r\n if (cls) d.className = cls;\r\n d.textContent = new Date().toLocaleTimeString() + ' ' + msg;\r\n logEl.prepend(d);\r\n while (logEl.childElementCount > 50) logEl.lastChild.remove();\r\n}\r\n\r\nasync function refreshQr() {\r\n try {\r\n const r = await fetch('/qr.json');\r\n const j = await r.json();\r\n $('qrimg').src = j.pngDataUrl;\r\n $('paircode').textContent = j.code;\r\n $('urls').innerHTML = (j.urls || []).map(u => '<span style=\"color:#7d8da0\">' + u + '</span>').join('<br>');\r\n } catch (e) { log('二维码加载失败: ' + e.message, 'err'); }\r\n}\r\n$('refresh').onclick = () => { wsSend({ type: 'refresh_pairing' }); setTimeout(refreshQr, 150); };\r\n\r\nfunction drawFrame(buf) {\r\n const blob = new Blob([buf], { type: 'image/jpeg' });\r\n const url = URL.createObjectURL(blob);\r\n const img = new Image();\r\n img.onload = () => {\r\n const r = ((streamRotation % 360) + 360) % 360;\r\n const swap = r === 90 || r === 270;\r\n const w = swap ? img.height : img.width;\r\n const h = swap ? img.width : img.height;\r\n if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }\r\n ctx2d.save();\r\n ctx2d.translate(w / 2, h / 2);\r\n ctx2d.rotate(r * Math.PI / 180);\r\n ctx2d.drawImage(img, -img.width / 2, -img.height / 2);\r\n ctx2d.restore();\r\n URL.revokeObjectURL(url);\r\n $('hint').style.display = 'none';\r\n frames++;\r\n const now = performance.now();\r\n if (now - fpsTimer >= 1000) { $('fps').textContent = (frames * 1000 / (now - fpsTimer)).toFixed(1) + ' fps'; frames = 0; fpsTimer = now; }\r\n lastFrameAt = now;\r\n };\r\n img.src = url;\r\n}\r\n\r\nlet ws = null;\r\nfunction connect() {\r\n const proto = location.protocol === 'https:' ? 'wss' : 'ws';\r\n ws = new WebSocket(proto + '://' + location.host + '/ws/view');\r\n ws.binaryType = 'arraybuffer';\r\n ws.onopen = () => { log('已连接'); refreshQr(); };\r\n ws.onclose = () => { $('dot').className = ''; setTimeout(connect, 1500); };\r\n ws.onerror = () => {};\r\n ws.onmessage = (ev) => {\r\n if (ev.data instanceof ArrayBuffer) { drawFrame(ev.data); return; }\r\n const m = JSON.parse(ev.data);\r\n switch (m.type) {\r\n case 'meta':\r\n $('dot').className = m.camera.connected ? 'on' : '';\r\n $('dev').textContent = m.camera.name || (m.camera.connected ? '已连接' : '未连接');\r\n $('shoot').disabled = !m.camera.connected;\r\n if (m.camera.rotation !== undefined) streamRotation = m.camera.rotation;\r\n $('hint').style.display = m.camera.connected ? 'none' : '';\r\n break;\r\n case 'frame_meta':\r\n if (m.rotation !== undefined) streamRotation = m.rotation;\r\n break;\r\n case 'device':\r\n $('dot').className = m.online ? 'on' : '';\r\n $('dev').textContent = m.online ? (m.name || '已连接') : '未连接';\r\n $('shoot').disabled = !m.online;\r\n $('hint').style.display = m.online ? 'none' : '';\r\n $('hint').textContent = '等待手机连接…';\r\n break;\r\n case 'capture_pending':\r\n pendingShot = m.captureId;\r\n log('快门已触发' + (m.note ? '(' + m.note + ')' : '') + '…');\r\n break;\r\n case 'injected':\r\n pendingShot = null;\r\n if (m.ok) log('已注入会话 ' + (m.sessionId || '').slice(0, 8) + (m.name ? ' · ' + m.name : ''), 'ok');\r\n else log('入库成功,注入未完成: ' + (m.reason || ''), 'err');\r\n break;\r\n case 'upload':\r\n log('图片已入库 ' + (m.name || ''), 'ok');\r\n break;\r\n case 'error':\r\n log('错误 ' + m.code + (m.message ? ': ' + m.message : ''), 'err');\r\n break;\r\n }\r\n };\r\n}\r\nfunction wsSend(obj) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); }\r\n\r\n$('shoot').onclick = () => wsSend({ type: 'capture', note: '' });\r\n\r\n// PiP: mirror the canvas into a video stream, then request Picture-in-Picture.\r\n$('pip').onclick = async () => {\r\n try {\r\n const stream = canvas.captureStream(10);\r\n const v = $('pipvid');\r\n v.srcObject = stream;\r\n await v.play();\r\n await v.requestPictureInPicture();\r\n } catch (e) { log('画中画失败: ' + e.message, 'err'); }\r\n};\r\n\r\nsetInterval(() => {\r\n if (document.pictureInPictureElement) return;\r\n if (performance.now() - lastFrameAt > 4000 && lastFrameAt > 0) $('hint').style.display = '';\r\n $('age').textContent = lastFrameAt > 0 ? ' · ' + ((performance.now() - lastFrameAt) / 1000).toFixed(1) + 's前' : '';\r\n}, 1000);\r\nif (document.pictureInPictureEnabled) $('pip').style.display = '';\r\n\r\nconnect();\r\n</script>\r\n</body>\r\n</html>\r\n`;\r\n","import type { IncomingMessage } from \"node:http\";\r\n\r\n/** Sliding-window rate limiter (in-memory; resets with the process). */\r\nexport class RateLimiter {\r\n private hits = new Map<string, number[]>();\r\n\r\n constructor(private readonly windowMs: number, private readonly max: number) {}\r\n\r\n /** Record one hit and report whether the key is still within budget. */\r\n allow(key: string): boolean {\r\n const now = Date.now();\r\n const cutoff = now - this.windowMs;\r\n const list = (this.hits.get(key) ?? []).filter((t) => t > cutoff);\r\n if (list.length >= this.max) {\r\n this.hits.set(key, list);\r\n return false;\r\n }\r\n list.push(now);\r\n this.hits.set(key, list);\r\n return true;\r\n }\r\n}\r\n\r\nconst LOOPBACK = new Set([\"127.0.0.1\", \"::1\", \"::ffff:127.0.0.1\"]);\r\n\r\n/** Whether the connection originates from this machine (loopback). */\r\nexport function isLoopback(req: IncomingMessage): boolean {\r\n const addr = req.socket.remoteAddress ?? \"\";\r\n return LOOPBACK.has(addr);\r\n}\r\n\r\n/**\r\n * CORS for the dsh Web UI page (served on another loopback port, or the\r\n * desktop shell's `dsh-app://` origin) so it can fetch /qr.json and /status\r\n * from this receiver. Only same-machine origins are echoed — a foreign page\r\n * must not read the pairing code, and its cross-origin JSON POST fails the\r\n * preflight we never answer. `dsh-app:` is a privileged scheme registered by\r\n * the desktop shell; only the shell's own renderer can ever present it.\r\n */\r\nexport function corsFor(req: IncomingMessage): Record<string, string> {\r\n const origin = req.headers.origin;\r\n if (typeof origin !== \"string\" || origin === \"\") return {};\r\n try {\r\n const u = new URL(origin);\r\n const host = u.hostname.replace(/^\\[|\\]$/g, \"\");\r\n if ((u.protocol === \"http:\" && LOOPBACK.has(host)) || u.protocol === \"dsh-app:\") {\r\n return {\r\n \"access-control-allow-origin\": origin,\r\n \"access-control-allow-methods\": \"GET, POST, OPTIONS\",\r\n \"access-control-allow-headers\": \"content-type, x-lm-device, x-lm-token\",\r\n \"access-control-max-age\": \"600\",\r\n };\r\n }\r\n } catch {\r\n /* malformed origin: no CORS headers */\r\n }\r\n return {};\r\n}\r\n\r\n/** Extract a stable client key for rate limiting. */\r\nexport function clientKey(req: IncomingMessage): string {\r\n return req.socket.remoteAddress ?? \"unknown\";\r\n}\r\n\r\n/** Read the device auth headers, if present. */\r\nexport function deviceAuth(req: IncomingMessage): { deviceId: string; token: string } | null {\r\n const deviceId = req.headers[\"x-lm-device\"];\r\n const token = req.headers[\"x-lm-token\"];\r\n if (typeof deviceId !== \"string\" || typeof token !== \"string\" || !deviceId || !token) return null;\r\n return { deviceId, token };\r\n}\r\n\r\n/** Query-string parser (URLSearchParams handles plus-encoding for notes). */\r\nexport function queryOf(req: IncomingMessage): URLSearchParams {\r\n const url = new URL(req.url ?? \"/\", \"http://phone-lens.local\");\r\n return url.searchParams;\r\n}\r\n","import { randomUUID } from \"node:crypto\";\nimport type { IncomingMessage, Server as HttpServer, ServerResponse } from \"node:http\";\nimport { createServer } from \"node:http\";\nimport { existsSync, mkdirSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { readFile, readdir, stat, unlink } from \"node:fs/promises\";\nimport { basename, join, resolve, sep } from \"node:path\";\nimport type { Duplex } from \"node:stream\";\nimport { WebSocketServer, type WebSocket } from \"ws\";\nimport QRCode from \"qrcode\";\nimport type { DeliveryReceipt, LensConfig } from \"../types.js\";\nimport { ERROR_CODES } from \"../types.js\";\nimport type { DeliverySink } from \"../inject/deliver.js\";\nimport { admitImage, magicMatches, type AttachmentStoreLike } from \"../inject/admit.js\";\nimport type { TargetTracker } from \"../inject/target.js\";\nimport type { DeviceStore } from \"../store/devices.js\";\nimport { hashToken, mintDeviceToken, type PairingStore } from \"../store/pairing.js\";\nimport { SAVE_MODES, type AppSettingsStore, type SaveMode } from \"../store/settings.js\";\nimport type { ViewHub } from \"./hub.js\";\nimport { refreshAppApkLinks, resolveLatestGiteeApk } from \"./apk-link.js\";\nimport { buildPairingQr } from \"./qr.js\";\nimport { VIEW_HTML } from \"./static-view.js\";\nimport { clientKey, corsFor, deviceAuth, isLoopback, queryOf, RateLimiter } from \"./auth.js\";\n\nexport interface ServerDeps {\n config: LensConfig;\n pairing: PairingStore;\n devices: DeviceStore;\n hub: ViewHub;\n targets: TargetTracker;\n sink: DeliverySink;\n /** Read the attachment store lazily — the service may mount after us. */\n attachments: () => AttachmentStoreLike | undefined;\n /** Fallback dir for uploads when the attachment service is absent. */\n fallbackDir: string;\n /** Browser-fetchable staging dir for images awaiting user send (composer pre-send). */\n pendingDir: string;\n /** User-selected capture mode + folder target (web UI settings). */\n appSettings: AppSettingsStore;\n log: (level: \"info\" | \"warn\" | \"error\", msg: string) => void;\n}\n\nexport interface LensServerHandle {\n port: number;\n dispose: () => Promise<void>;\n}\n\n/** Process-lifetime marker: surfaces service rebuilds via /status. */\nconst SERVER_BOOT_AT = Date.now();\n\n/** Boot the receiver: HTTP routes + two websocket endpoints. */\n// Report our own package version without a hardcoded literal (the old literal\n// went stale across releases). Resolved once from ../package.json — the bundle\n// lives in lib/, so that resolves to the package root in every install form.\nlet ownVersion: string | null = null;\nfunction hostVersion(): string {\n if (ownVersion) return ownVersion;\n try {\n ownVersion = String(JSON.parse(readFileSync(new URL(\"../package.json\", import.meta.url), \"utf8\")).version ?? \"0.0.0\");\n } catch {\n ownVersion = \"0.0.0\";\n }\n return ownVersion;\n}\n\nexport async function startLensServer(deps: ServerDeps): Promise<LensServerHandle> {\n const { config, pairing, devices, hub, targets, sink, log } = deps;\n const pairLimiter = new RateLimiter(60_000, 10);\n // POST /upload idempotency: the phone's upload queue retries with a stable\n // uploadId, and a retry after a lost response must not store/inject twice.\n const uploadReplays = new Map<string, { expires: number; body: unknown }>();\n let currentCameraDeviceId: string | null = null;\n const injectionBox: { last: { at: number; sessionId: string | null; attachmentId: string; ok: boolean } | null } = { last: null };\n\n const server: HttpServer = createServer((req, res) => {\n handle(deps, req, res, {\n pairLimiter,\n uploadReplays,\n getCurrentCamera: () => currentCameraDeviceId,\n noteInjection: (receipt, attachmentId) => {\n injectionBox.last = { at: Date.now(), sessionId: receipt.sessionId, attachmentId, ok: receipt.ok };\n },\n getLastInjection: () => injectionBox.last,\n }).catch((error) => {\n log(\"error\", `request failed: ${String(error)}`);\n if (!res.headersSent) sendJson(res, 500, { error: { code: ERROR_CODES.INTERNAL, message: String(error) } });\n else res.destroy();\n });\n });\n\n const wss = new WebSocketServer({ noServer: true, maxPayload: config.limits.previewFrameMaxBytes * 4 });\n server.on(\"upgrade\", (req: IncomingMessage, socket: Duplex, head: Buffer) => {\n const url = new URL(req.url ?? \"/\", \"http://phone-lens.local\");\n const done = (status: number, message: string) => {\n socket.write(`HTTP/1.1 ${status} ${message}\\r\\nConnection: close\\r\\n\\r\\n`);\n socket.destroy();\n };\n if (url.pathname === \"/ws/camera\") {\n const q = url.searchParams;\n const deviceId = q.get(\"deviceId\") ?? \"\";\n const token = q.get(\"token\") ?? \"\";\n const record = devices.authenticate(deviceId, token);\n if (!record) return done(401, \"Unauthorized\");\n wss.handleUpgrade(req, socket, head, (ws) => {\n currentCameraDeviceId = record.deviceId;\n hub.attachCamera(record.deviceId, ws, record.name);\n });\n return;\n }\n if (url.pathname === \"/ws/view\") {\n if (!isLoopback(req)) return done(403, \"Forbidden\");\n wss.handleUpgrade(req, socket, head, (ws) => hub.attachView(ws as WebSocket, {\n onRefreshPairing: () => pairing.refresh(),\n onRenameDevice: (deviceId, name) => {\n const updated = devices.rename(deviceId, name);\n if (updated) hub.renameDevice(deviceId, updated.name);\n },\n }));\n return;\n }\n done(404, \"Not Found\");\n });\n\n const heartbeat = setInterval(() => hub.pingAll(), 20_000);\n\n await new Promise<void>((resolve, reject) => {\n server.once(\"error\", reject);\n server.listen(config.server.port, config.server.host, () => resolve());\n });\n log(\"info\", `phone-lens listening on ${config.server.host}:${config.server.port} (paired devices: ${devices.count()})`);\n // Warm the APK-link cache so the first rendered download QR already carries\n // the dynamically resolved URL (never blocks startup on the network).\n void resolveLatestGiteeApk().then((url) => {\n if (url) log(\"info\", `APK download link resolved from Gitee API: ${url}`);\n });\n\n return {\n port: config.server.port,\n dispose: async () => {\n clearInterval(heartbeat);\n hub.dispose();\n hub.detachAll();\n for (const client of wss.clients) client.terminate();\n await new Promise<void>((resolve) => server.close(() => resolve()));\n },\n };\n}\n\ntype RouteCtx = {\n pairLimiter: RateLimiter;\n /** uploadId → cached 200 response, so queued-upload retries are idempotent. */\n uploadReplays: Map<string, { expires: number; body: unknown }>;\n getCurrentCamera: () => string | null;\n noteInjection: (receipt: DeliveryReceipt, attachmentId: string) => void;\n getLastInjection: () => { at: number; sessionId: string | null; attachmentId: string; ok: boolean } | null;\n};\n\nasync function handle(deps: ServerDeps, req: IncomingMessage, res: ServerResponse, ctx: RouteCtx): Promise<void> {\n const { config, pairing, devices, hub, targets, sink, log } = deps;\n const url = new URL(req.url ?? \"/\", \"http://phone-lens.local\");\n const path = url.pathname.replace(/\\/+$/, \"\") || \"/\";\n const method = req.method ?? \"GET\";\n const loop = isLoopback(req);\n const cors = corsFor(req);\n\n // Preflight from the Web UI page (another loopback origin).\n if (method === \"OPTIONS\") {\n res.writeHead(204, cors);\n res.end();\n return;\n }\n\n // ── open endpoints ────────────────────────────────────────────────────────\n if (method === \"GET\" && path === \"/info\") {\n return sendJson(res, 200, { name: \"PhoneLens 直连取景\", version: hostVersion(), requiresPairing: true }, cors);\n }\n\n // ── loopback-only endpoints (preview page, QR, view stream) ──────────────\n if (!loop && (path === \"/\" || path === \"/view.html\" || path === \"/qr.json\" || path === \"/qr.png\" || path === \"/app-qr.json\" || path === \"/app-settings\")) {\n return sendError(res, 403, ERROR_CODES.LOOPBACK_ONLY, \"preview surface is loopback-only\");\n }\n if (method === \"GET\" && (path === \"/\" || path === \"/view.html\")) {\n res.writeHead(200, { \"content-type\": \"text/html; charset=utf-8\", \"cache-control\": \"no-store\" });\n res.end(VIEW_HTML);\n return;\n }\n if (method === \"GET\" && path === \"/qr.json\") {\n const { code, expiresAt } = pairing.current();\n const qr = await buildPairingQr(code, expiresAt, config);\n return sendJson(res, 200, { code: qr.code, expiresAt: qr.expiresAt, payload: qr.payload, urls: qr.urls, pngDataUrl: qr.pngDataUrl }, { ...cors, \"cache-control\": \"no-store\" });\n }\n if (method === \"GET\" && path === \"/qr.png\") {\n const { code, expiresAt } = pairing.current();\n const qr = await buildPairingQr(code, expiresAt, config);\n const b64 = qr.pngDataUrl.slice(qr.pngDataUrl.indexOf(\",\") + 1);\n res.writeHead(200, { \"content-type\": \"image/png\", \"cache-control\": \"no-store\" });\n res.end(Buffer.from(b64, \"base64\"));\n return;\n }\n // App-download QR: encodes the APK URL (Gitee by default) so a phone can\n // scan it straight from the Web UI overlay and start the download.\n if (method === \"GET\" && path === \"/app-qr.json\") {\n // Resolve the Gitee link from the latest-release API (no per-release\n // constant bump); mutates config.app only while defaults are in use.\n await refreshAppApkLinks(config);\n const target = config.app.downloadUrl || config.app.giteeUrl;\n const pngDataUrl = await QRCode.toDataURL(target, { errorCorrectionLevel: \"M\", margin: 2, width: 320 });\n return sendJson(res, 200, { url: target, gitee: config.app.giteeUrl, github: config.app.githubUrl, pngDataUrl }, { ...cors, \"cache-control\": \"no-store\" });\n }\n\n // ── pairing ──────────────────────────────────────────────────────────────\n // Rate limit only FAILED attempts (anti-brute-force); a legitimate new\n // device entering a valid code is never throttled, so switching phones\n // \"just works\" instead of hitting \"too many pairing attempts\".\n if (method === \"POST\" && path === \"/pair\") {\n const body = await readJsonBody(req, 4096);\n const code = str(body?.code);\n const device = (body?.device ?? {}) as Record<string, unknown>;\n const deviceId = str(device.id);\n const name = str(device.name) ?? \"unnamed device\";\n const model = str(device.model) ?? \"\";\n if (!code || !deviceId) return sendError(res, 400, ERROR_CODES.BAD_REQUEST, \"code and device.id are required\");\n const verdict = pairing.verify(code);\n if (!verdict.ok) {\n if (!ctx.pairLimiter.allow(clientKey(req))) return sendError(res, 429, ERROR_CODES.RATE_LIMITED, \"too many pairing attempts\");\n return sendError(res, 401, verdict.reason === \"expired\" ? ERROR_CODES.PAIR_CODE_EXPIRED : ERROR_CODES.PAIR_CODE_INVALID, `pairing code ${verdict.reason}`);\n }\n const token = mintDeviceToken();\n devices.upsert({ deviceId, name, model, tokenHash: hashToken(token) });\n log(\"info\", `paired device \"${name}\" (${deviceId.slice(0, 8)})`);\n return sendJson(res, 200, {\n token,\n serverInfo: { preview: config.preview, limits: config.limits },\n });\n }\n\n // ── authenticated endpoints: loopback or paired device ───────────────────\n const auth = loop ? { ok: true } : deviceAuth(req) && devices.authenticate(deviceAuth(req)!.deviceId, deviceAuth(req)!.token) ? { ok: true } : { ok: false };\n if (!auth.ok) return sendError(res, 401, ERROR_CODES.AUTH_REQUIRED, \"pair this device first\");\n\n if (method === \"POST\" && path === \"/upload\") {\n const q = queryOf(req);\n // Idempotent replay BEFORE anything else: a queued-upload retry that\n // already stored once replays the original 200. Key format-bounded:\n // uploadId is attacker-controllable input and doubles as the map key.\n const rawUploadId = q.get(\"uploadId\") ?? \"\";\n const uploadId = /^[\\w-]{8,64}$/.test(rawUploadId) ? rawUploadId : null;\n if (uploadId) {\n const hit = ctx.uploadReplays.get(uploadId);\n if (hit && hit.expires > Date.now()) {\n log(\"info\", `idempotent replay for upload ${uploadId.slice(0, 8)}`);\n return sendJson(res, 200, hit.body);\n }\n ctx.uploadReplays.delete(uploadId);\n }\n // No upload rate limiting by design: the phone's queue uploads serially\n // and the per-photo size cap plus archive pruning bound the damage any\n // authenticated (or loopback) client can do.\n const mediaType = (req.headers[\"content-type\"] ?? \"\").split(\";\")[0]!.trim();\n if (!config.limits.allowedTypes.includes(mediaType)) {\n return sendError(res, 415, ERROR_CODES.TYPE_NOT_ALLOWED, `allowed: ${config.limits.allowedTypes.join(\", \")}`);\n }\n const declared = Number(req.headers[\"content-length\"] ?? \"0\");\n if (declared > config.limits.maxUploadBytes) return sendError(res, 413, ERROR_CODES.TOO_LARGE, `> ${config.limits.maxUploadBytes} bytes`);\n const { buf, truncated } = await readRawBody(req, config.limits.maxUploadBytes);\n if (truncated) return sendError(res, 413, ERROR_CODES.TOO_LARGE, `> ${config.limits.maxUploadBytes} bytes`);\n if (!magicMatches(mediaType, buf)) return sendError(res, 415, ERROR_CODES.BAD_MAGIC, \"bytes do not match the declared type\");\n\n const captureId = q.get(\"captureId\");\n const note = q.get(\"note\") ?? (captureId ? hub.noteFor(captureId) : undefined);\n if (captureId) hub.consumeCapture(captureId);\n const rawName = (q.get(\"name\") ?? `shot_${new Date().toISOString().replace(/[:.]/g, \"-\")}.${mediaType === \"image/png\" ? \"png\" : \"jpg\"}`).slice(0, 120);\n const name = sanitizeFileName(rawName);\n\n let admitted;\n try {\n admitted = await admitImage({ data: buf, mediaType, name }, deps.attachments(), deps.fallbackDir);\n } catch (error) {\n log(\"error\", `admit failed: ${String(error)}`);\n return sendError(res, 500, ERROR_CODES.STORE_FAILED, String(error));\n }\n log(\"info\", `stored ${name} (${buf.byteLength}B) → ${admitted.storage}:${admitted.ref.attachmentId}`);\n\n // keep the local archive bounded (retention: maxStoredUploads oldest-first)\n if (admitted.storage === \"file\") await pruneUploads(deps.fallbackDir, config.limits.maxStoredUploads).catch((error) => log(\"warn\", `upload pruning failed: ${String(error)}`));\n\n // Capture-mode routing (user-selected in the web UI settings). The photo\n // always lands in the attachment store first; the mode decides what\n // happens next:\n // composer → stage for the dsh composer draft (paste pre-send)\n // folder → additionally/only write the file to the user's folder\n // A failed folder write never loses the photo — it falls back to staging.\n const st = deps.appSettings.get();\n const wantFolder = st.saveMode !== \"composer\";\n const wantComposer = st.saveMode !== \"folder\";\n let savedDir: string | null = null;\n if (wantFolder) {\n const dir = deps.appSettings.effectiveSaveDir();\n try {\n mkdirSync(dir, { recursive: true });\n const fileName = nextAvailableName(dir, name);\n const target = resolveUnder(dir, fileName);\n if (!target) throw new Error(\"resolved path escapes the save directory\");\n writeFileSync(target, buf);\n savedDir = dir;\n log(\"info\", `folder-mode saved: ${target}`);\n } catch (error) {\n log(\"warn\", `folder save failed (${String(error)}) — falling back to composer staging`);\n }\n }\n\n // PRE-SEND semantics. The phone's photo is staged for the dsh composer,\n // not injected straight into the model — the browser client fetches this\n // staging copy and drops it into the composer draft; the user types text\n // and hits send. So no agent.followup here.\n if (wantComposer || !savedDir) {\n const pendingPath = resolveUnder(deps.pendingDir, pendingFileName(admitted.ref.attachmentId));\n try {\n if (!pendingPath) throw new Error(\"resolved path escapes the pending directory\");\n mkdirSync(deps.pendingDir, { recursive: true });\n writeFileSync(pendingPath, buf);\n log(\"info\", `staged for composer: ${pendingPath}`);\n } catch (error) {\n log(\"warn\", `staging failed: ${String(error)}`);\n }\n hub.broadcastToViews({ type: \"pending_image\", attachmentId: admitted.ref.attachmentId, name });\n // no direct delivery; the client owns placing it in the composer\n } else {\n // folder-only save: bypass the composer; surface a local UI hint on the\n // view — never a chat message (the tool does not speak for the user).\n hub.broadcastToViews({ type: \"upload_saved\", name, dir: savedDir });\n }\n\n // Idempotency bookkeeping for queued-upload retries. uploadId serves only\n // as the lookup KEY; the cached receipt is entirely server-generated data\n // (sha refs, probed dimensions, literal enums) — must stay byte-identical\n // to the 200 body returned below.\n if (uploadId) {\n const replayBody = {\n ok: true,\n attachmentId: admitted.ref.attachmentId,\n width: admitted.ref.width,\n height: admitted.ref.height,\n bytes: admitted.ref.bytes,\n storage: admitted.storage,\n delivered: null,\n deliverReason: savedDir ? (wantComposer ? \"saved-and-staged\" : \"saved-to-folder\") : \"staged-in-composer\",\n };\n if (ctx.uploadReplays.size > 400) {\n for (const [k, v] of ctx.uploadReplays) if (v.expires <= Date.now()) ctx.uploadReplays.delete(k);\n }\n ctx.uploadReplays.set(uploadId, { expires: Date.now() + 10 * 60_000, body: replayBody });\n }\n\n return sendJson(res, 200, {\n ok: true,\n attachmentId: admitted.ref.attachmentId,\n width: admitted.ref.width,\n height: admitted.ref.height,\n bytes: admitted.ref.bytes,\n storage: admitted.storage,\n delivered: null,\n deliverReason: savedDir ? (wantComposer ? \"saved-and-staged\" : \"saved-to-folder\") : \"staged-in-composer\",\n });\n }\n\n // Browser client fetches the staged photo to place into the composer draft.\n if (method === \"GET\" && path.startsWith(\"/pending/\") && loop) {\n const id = path.slice(\"/pending/\".length).split(\"/\")[0] ?? \"\";\n const file = resolveUnder(deps.pendingDir, pendingFileName(id));\n if (!file) return sendError(res, 404, ERROR_CODES.BAD_REQUEST, \"pending image not found\");\n try {\n const data = await readFile(file);\n res.writeHead(200, { \"content-type\": \"image/jpeg\", \"cache-control\": \"no-store\", ...cors });\n res.end(data);\n return;\n } catch {\n return sendError(res, 404, ERROR_CODES.BAD_REQUEST, \"pending image not found\");\n }\n }\n\n if (method === \"GET\" && path === \"/status\") {\n const cameraDeviceId = ctx.getCurrentCamera();\n return sendJson(res, 200, {\n bootAt: SERVER_BOOT_AT,\n devices: devices.list().map((d) => ({ id: d.deviceId, name: d.name, model: d.model, online: d.deviceId === cameraDeviceId, streaming: d.deviceId === cameraDeviceId, lastSeenAt: d.lastSeenAt })),\n camera: hub.stats(),\n preview: config.preview,\n target: { mode: targets.mode(), sessionId: targets.resolve()?.sessionId ?? null },\n lastInjection: ctx.getLastInjection(),\n }, cors);\n }\n\n if (method === \"GET\" && path === \"/targets\") {\n return sendJson(res, 200, { targets: targets.list(), default: targets.resolve()?.sessionId ?? null }, cors);\n }\n\n // Web UI settings: capture mode (composer / folder / both) + target folder.\n // Loopback-only; the settings live on the host because the folder save runs here.\n if (method === \"GET\" && path === \"/app-settings\") {\n const st = deps.appSettings.get();\n return sendJson(res, 200, { saveMode: st.saveMode, saveDir: st.saveDir, effectiveSaveDir: deps.appSettings.effectiveSaveDir() }, cors);\n }\n if (method === \"POST\" && path === \"/app-settings\") {\n const body = await readJsonBody(req, 4096);\n if (!body) return sendError(res, 400, ERROR_CODES.BAD_REQUEST, \"invalid JSON body\");\n const patch: { saveMode?: SaveMode; saveDir?: string } = {};\n if (typeof body.saveMode === \"string\" && SAVE_MODES.includes(body.saveMode as SaveMode)) patch.saveMode = body.saveMode as SaveMode;\n if (typeof body.saveDir === \"string\") patch.saveDir = body.saveDir;\n const st = deps.appSettings.set(patch);\n log(\"info\", `app settings updated: mode=${st.saveMode} dir=${st.saveDir ? JSON.stringify(st.saveDir) : \"(default)\"}`);\n return sendJson(res, 200, { saveMode: st.saveMode, saveDir: st.saveDir, effectiveSaveDir: deps.appSettings.effectiveSaveDir() }, cors);\n }\n\n // View-side control also arrives over /ws/view; a loopback HTTP trigger is\n // handy for smoke tests.\n if (method === \"POST\" && path === \"/capture\" && loop) {\n const captureId = randomUUID();\n const ok = hub.requestCapture(captureId, queryOf(req).get(\"note\") ?? undefined);\n if (!ok) return sendError(res, 409, ERROR_CODES.NO_CAMERA, \"no camera uplink connected\");\n return sendJson(res, 202, { captureId });\n }\n\n return sendError(res, 404, ERROR_CODES.BAD_REQUEST, `no route ${method} ${path}`);\n}\n\n// ── helpers ─────────────────────────────────────────────────────────────────\n\n/** Strip path separators / reserved characters so a phone-supplied upload\n * name can never escape the target directory or hide as a dotfile. */\nfunction sanitizeFileName(input: string): string {\n const base = basename(input)\n .replace(/[<>:\"|?*\\u0000-\\u001F]/g, \"_\")\n .replace(/^[\\s.]+/, \"\")\n .trim();\n return base || \"shot.jpg\";\n}\n\n/** Pick `name`, then `name (2).ext`, `name (3).ext`, … — folder saves never overwrite. */\nfunction nextAvailableName(dir: string, name: string): string {\n if (!existsSync(join(dir, name))) return name;\n const dot = name.lastIndexOf(\".\");\n const stem = dot > 0 ? name.slice(0, dot) : name;\n const ext = dot > 0 ? name.slice(dot) : \"\";\n for (let n = 2; ; n++) {\n const candidate = `${stem} (${n})${ext}`;\n if (!existsSync(join(dir, candidate))) return candidate;\n }\n}\n\n/** Pending-staging filename for an attachment id. Ids may be URN-ish\n * (\"file:<digest>\" without the dsh attachment service) and Windows forbids\n * \":\" in filenames — flatten to a portable name, write and read alike. */\nfunction pendingFileName(attachmentId: string): string {\n return `${attachmentId.replace(/[^A-Za-z0-9._-]/g, \"_\")}.jpg`;\n}\n\n/** Resolve `name` under `baseDir` and refuse anything that escapes it\n * (defense in depth on top of sanitizeFileName — e.g. a crafted name that\n * survives sanitization but still resolves outside the base). */\nfunction resolveUnder(baseDir: string, name: string): string | null {\n const base = resolve(baseDir);\n const target = resolve(base, name);\n return target === base || target.startsWith(base + sep) ? target : null;\n}\n\nfunction sendJson(res: ServerResponse, status: number, body: unknown, extraHeaders?: Record<string, string>): void {\n res.writeHead(status, { \"content-type\": \"application/json; charset=utf-8\", ...(extraHeaders ?? {}) });\n res.end(JSON.stringify(body));\n}\n\nfunction sendError(res: ServerResponse, status: number, code: string, message: string): void {\n sendJson(res, status, { error: { code, message } });\n}\n\nfunction str(v: unknown): string | undefined {\n return typeof v === \"string\" ? v : undefined;\n}\n\nasync function readJsonBody(req: IncomingMessage, limit: number): Promise<Record<string, unknown> | null> {\n const { buf } = await readRawBody(req, limit);\n try {\n return JSON.parse(buf.toString(\"utf8\")) as Record<string, unknown>;\n } catch {\n return null;\n }\n}\n\nfunction readRawBody(req: IncomingMessage, maxBytes: number): Promise<{ buf: Buffer; truncated: boolean }> {\n return new Promise((resolve, reject) => {\n const chunks: Buffer[] = [];\n let size = 0;\n let truncated = false;\n req.on(\"data\", (chunk: Buffer) => {\n size += chunk.byteLength;\n if (size > maxBytes) {\n truncated = true;\n chunks.length = 0;\n req.destroy();\n resolve({ buf: Buffer.alloc(0), truncated });\n return;\n }\n chunks.push(chunk);\n });\n req.on(\"end\", () => resolve({ buf: Buffer.concat(chunks), truncated }));\n req.on(\"error\", reject);\n });\n}\n\n/** Delete the oldest files beyond `max` in a directory (mtime order). */\nasync function pruneUploads(dir: string, max: number): Promise<void> {\n if (max <= 0) return;\n const names = await readdir(dir);\n if (names.length <= max) return;\n const entries = await Promise.all(\n names.map(async (name) => {\n let mtime = 0;\n try {\n mtime = (await stat(join(dir, name))).mtimeMs;\n } catch {\n mtime = 0;\n }\n return { name, mtime };\n }),\n );\n entries.sort((a, b) => b.mtime - a.mtime);\n for (const entry of entries.slice(max)) {\n await unlink(join(dir, entry.name)).catch(() => {});\n }\n}\n","import { randomUUID } from \"node:crypto\";\r\nimport type { WebSocket } from \"ws\";\r\nimport type { CameraControl, LensConfig, ViewClientMessage, ViewServerMessage } from \"../types.js\";\r\n\r\n/** Per-device camera uplink state; multiple phones coexist without kicking. */\r\ninterface CamState {\r\n ws: WebSocket;\r\n name: string;\r\n meta: { width?: number; height?: number; fps?: number; rotation?: number };\r\n lastFrame: Buffer | null;\r\n lastFrameAt: number;\r\n frameCount: number;\r\n windowStart: number;\r\n measuredFps: number;\r\n}\r\n\r\n/**\r\n * How long the ACTIVE device's stream may go silent before views are told the\r\n * preview was turned off. The phone app never announces \"preview off\" — it\r\n * just stops pushing frames (the WS stays up) — so the host derives the state:\r\n * a live, open camera uplink with no frames for this long means the user\r\n * switched preview off on the phone. Device disconnects are signalled\r\n * separately via the `devices` list.\r\n */\r\nconst PREVIEW_STALL_MS = 3_000;\r\n\r\n/**\r\n * The viewfinder hub: MULTIPLE camera uplinks (one per phone, keyed by\r\n * deviceId), N loopback view downlinks. The \"active\" device is auto-selected\r\n * as the last one to send a frame; the view side can switch it via\r\n * `select_device`. Only the active device's frames are fanned out — a second\r\n * phone connecting no longer kicks the first, so several paired phones coexist\r\n * and the user picks which one to watch / shoot from.\r\n */\r\nexport class ViewHub {\r\n private cameras = new Map<string, CamState>();\r\n private activeDeviceId: string | null = null;\r\n private views = new Set<WebSocket>();\r\n /** captureId → { note, requestedAt } until the matching upload lands or timeout. */\r\n private pendingCaptures = new Map<string, { note?: string; requestedAt: number }>();\r\n private readonly captureTimeoutMs = 60_000;\r\n /** Whether the active device is confirmed to be streaming (hello/frame seen). */\r\n private previewOn = false;\r\n private stallTimer: ReturnType<typeof setInterval> | null = null;\r\n\r\n constructor(\r\n private readonly config: LensConfig,\r\n private readonly log: (level: \"info\" | \"warn\", msg: string) => void,\r\n ) {\r\n this.stallTimer = setInterval(() => this.checkPreviewStall(), 1_000);\r\n }\r\n\r\n /** Stop the stall watchdog (server dispose). */\r\n dispose(): void {\r\n if (this.stallTimer) clearInterval(this.stallTimer);\r\n this.stallTimer = null;\r\n }\r\n\r\n // ── camera side ───────────────────────────────────────────────────────────\r\n\r\n attachCamera(deviceId: string, ws: WebSocket, name: string): void {\r\n const prev = this.cameras.get(deviceId);\r\n if (prev) {\r\n this.log(\"warn\", `camera re-attach: kicking previous ws for ${deviceId.slice(0, 8)}`);\r\n try {\r\n prev.ws.close(1000, \"new-instance\");\r\n } catch {}\r\n this.cameras.delete(deviceId);\r\n }\r\n const cam: CamState = { ws, name, meta: {}, lastFrame: null, lastFrameAt: 0, frameCount: 0, windowStart: Date.now(), measuredFps: 0 };\r\n this.cameras.set(deviceId, cam);\r\n if (this.activeDeviceId === null) this.activeDeviceId = deviceId;\r\n // only the ACTIVE device streams to the PC; others pause immediately\r\n if (this.activeDeviceId === deviceId) this.sendControl(deviceId, { type: \"resume_preview\" });\r\n else this.sendControl(deviceId, { type: \"pause_preview\" });\r\n this.log(\"info\", `camera uplink: ${name} (${deviceId.slice(0, 8)})`);\r\n\r\n ws.on(\"close\", (code, reason) => {\r\n this.log(\"warn\", `camera ws closed: code=${code} reason=${reason.toString(\"utf8\") || \"-\"} (${name} ${deviceId.slice(0, 8)})`);\r\n if (this.cameras.get(deviceId)?.ws === ws) this.detachCamera(deviceId);\r\n });\r\n ws.on(\"message\", (data, isBinary) => {\r\n if (isBinary) {\r\n this.ingestFrame(deviceId, data as Buffer);\r\n return;\r\n }\r\n this.onCameraControl(deviceId, safeJson(data.toString()));\r\n });\r\n // greet the new device to views\r\n this.broadcastDevices();\r\n }\r\n\r\n detachCamera(deviceId: string): void {\r\n const cam = this.cameras.get(deviceId);\r\n this.log(\"warn\", `detachCamera(${deviceId.slice(0, 8)}) had-camera=${!!cam}`);\r\n if (cam) this.cameras.delete(deviceId);\r\n if (this.activeDeviceId === deviceId) {\r\n const next = [...this.cameras.keys()].at(-1) ?? null;\r\n this.activeDeviceId = next;\r\n if (next) this.sendControl(next, { type: \"resume_preview\" });\r\n this.pushActiveFrameToViews();\r\n }\r\n this.broadcastDevices();\r\n }\r\n\r\n detachAll(): void {\r\n for (const cam of this.cameras.values()) {\r\n try {\r\n cam.ws.close(1000, \"server-dispose\");\r\n } catch {}\r\n }\r\n this.cameras.clear();\r\n this.activeDeviceId = null;\r\n this.broadcastDevices();\r\n }\r\n\r\n pingAll(): void {\r\n for (const cam of this.cameras.values()) {\r\n if (cam.ws.readyState === cam.ws.OPEN) cam.ws.ping();\r\n }\r\n }\r\n\r\n private onCameraControl(deviceId: string, msg: CameraControl | null): void {\r\n if (!msg) return;\r\n const cam = this.cameras.get(deviceId);\r\n if (!cam) return;\r\n switch (msg.type) {\r\n case \"hello\":\r\n cam.meta = { width: msg.width, height: msg.height, fps: msg.fps, ...(msg.rotation !== void 0 ? { rotation: msg.rotation } : {}) };\r\n if (this.activeDeviceId === deviceId) {\r\n // hello is the phone's \"stream starting\" announcement (sent on every\r\n // _startStream) — treat it as preview-on for the view side.\r\n this.markPreviewActive();\r\n this.broadcastToViews({ type: \"frame_meta\", width: msg.width, height: msg.height, ...(msg.rotation !== void 0 ? { rotation: msg.rotation } : {}) });\r\n }\r\n break;\r\n case \"bye\":\r\n this.log(\"warn\", `camera sent bye: ${deviceId.slice(0, 8)}`);\r\n this.detachCamera(deviceId);\r\n break;\r\n case \"claim_active\":\r\n // phone asked to become the active device → switch the view to it.\r\n this.selectDevice(deviceId);\r\n break;\r\n case \"capture_result\":\r\n if (msg.status !== \"taken\") {\r\n this.pendingCaptures.delete(msg.captureId);\r\n this.broadcastToViews({ type: \"error\", code: \"CAPTURE_DECLINED\", message: `phone reported ${msg.status}${msg.detail ? `: ${msg.detail}` : \"\"}` });\r\n }\r\n break;\r\n default:\r\n break;\r\n }\r\n }\r\n\r\n private ingestFrame(deviceId: string, frame: Buffer): void {\r\n const cam = this.cameras.get(deviceId);\r\n if (!cam || frame.byteLength === 0) return;\r\n if (frame.byteLength > this.config.limits.previewFrameMaxBytes) {\r\n this.log(\"warn\", `dropping oversized preview frame from ${deviceId.slice(0, 8)} (${frame.byteLength}B > ${this.config.limits.previewFrameMaxBytes}B)`);\r\n return;\r\n }\r\n const now = Date.now();\r\n cam.lastFrame = frame;\r\n cam.lastFrameAt = now;\r\n cam.frameCount++;\r\n if (now - cam.windowStart >= 2000) {\r\n cam.measuredFps = (cam.frameCount * 1000) / (now - cam.windowStart);\r\n cam.windowStart = now;\r\n cam.frameCount = 0;\r\n }\r\n // only the ACTIVE device drives the view; others just keep their own state\r\n if (this.activeDeviceId === deviceId) {\r\n this.markPreviewActive();\r\n for (const view of this.views) {\r\n if (view.readyState === view.OPEN) view.send(frame, { binary: true });\r\n }\r\n }\r\n }\r\n\r\n /** Flip the view-side preview state to on (once) when stream activity returns. */\r\n private markPreviewActive(): void {\r\n if (this.previewOn) return;\r\n this.previewOn = true;\r\n this.broadcastToViews({ type: \"preview_state\", on: true });\r\n }\r\n\r\n /**\r\n * Watchdog: an open, ACTIVE camera uplink that has gone silent means the\r\n * user turned preview off on the phone (the app keeps the WS while only\r\n * stopping its frame pump). Tell views so they can clear the stale frame.\r\n * Disconnected devices are NOT handled here — the `devices` broadcast owns\r\n * the offline state on the view side.\r\n */\r\n private checkPreviewStall(): void {\r\n if (!this.previewOn) return;\r\n const active = this.activeCam();\r\n if (!active || active.ws.readyState !== active.ws.OPEN) return;\r\n if (Date.now() - active.lastFrameAt <= PREVIEW_STALL_MS) return;\r\n this.previewOn = false;\r\n this.broadcastToViews({ type: \"preview_state\", on: false });\r\n this.log(\"info\", `active preview stalled >${PREVIEW_STALL_MS}ms — notified views (preview off)`);\r\n }\r\n\r\n // ── view side ─────────────────────────────────────────────────────────────\r\n\r\n attachView(ws: WebSocket, hooks: { onRefreshPairing?: () => void; onRenameDevice?: (deviceId: string, name: string) => void } = {}): void {\r\n this.views.add(ws);\r\n ws.on(\"close\", (code, reason) => {\r\n this.log(\"warn\", `view ws closed: code=${code} reason=${reason.toString(\"utf8\") || \"-\"} (views left: ${this.views.size - 1})`);\r\n this.views.delete(ws);\r\n });\r\n ws.on(\"message\", (data, isBinary) => {\r\n if (isBinary) return;\r\n const msg = parseViewClient(data.toString());\r\n if (!msg) return;\r\n if (msg.type === \"capture\") {\r\n const captureId = randomUUID();\r\n if (!this.requestCapture(captureId, msg.note)) {\r\n this.broadcastToViews({ type: \"error\", code: \"NO_CAMERA\", message: \"no camera uplink connected\" });\r\n }\r\n } else if (msg.type === \"select_device\") {\r\n this.selectDevice(msg.deviceId);\r\n } else if (msg.type === \"rename_device\") {\r\n hooks.onRenameDevice?.(msg.deviceId, msg.name);\r\n } else if (msg.type === \"refresh_pairing\") {\r\n hooks.onRefreshPairing?.();\r\n }\r\n });\r\n const active = this.activeCam();\r\n ws.send(\r\n JSON.stringify({\r\n type: \"meta\",\r\n camera: active ? { connected: true, name: active.name, ...active.meta } : { connected: false },\r\n preview: this.config.preview,\r\n paired: true,\r\n } satisfies ViewServerMessage),\r\n );\r\n this.broadcastDevicesTo(ws);\r\n // initial preview state so a freshly (re)loaded view renders the correct\r\n // canvas/placeholder instead of guessing\r\n ws.send(JSON.stringify({ type: \"preview_state\", on: this.previewOn } satisfies ViewServerMessage));\r\n // only replay a cached frame while the stream is confirmed fresh; a stale\r\n // frame would flash an old picture before the \"preview off\" state lands\r\n if (this.previewOn && active?.lastFrame && ws.readyState === ws.OPEN) ws.send(active.lastFrame, { binary: true });\r\n }\r\n\r\n viewCount(): number {\r\n return this.views.size;\r\n }\r\n\r\n /** Update one device's display name and re-broadcast the device list. */\r\n renameDevice(deviceId: string, name: string): void {\r\n const cam = this.cameras.get(deviceId);\r\n if (cam) cam.name = name;\r\n this.broadcastDevices();\r\n }\r\n\r\n /** Switch which phone's frames / shutter the view follows. */\r\n selectDevice(deviceId: string): void {\r\n if (!this.cameras.has(deviceId)) return;\r\n const prevActive = this.activeDeviceId;\r\n if (prevActive === deviceId) return;\r\n this.activeDeviceId = deviceId;\r\n if (prevActive) this.sendControl(prevActive, { type: \"pause_preview\" });\r\n this.sendControl(deviceId, { type: \"resume_preview\" });\r\n this.broadcastDevices();\r\n this.pushActiveFrameToViews();\r\n }\r\n\r\n /** Send one control message to a specific phone uplink. */\r\n private sendControl(deviceId: string, msg: CameraControl): void {\r\n const cam = this.cameras.get(deviceId);\r\n if (cam && cam.ws.readyState === cam.ws.OPEN) cam.ws.send(JSON.stringify(msg));\r\n }\r\n\r\n private pushActiveFrameToViews(): void {\r\n const active = this.activeCam();\r\n // replay the cached frame only when the preview is confirmed streaming;\r\n // otherwise views would briefly show an old picture before the stall\r\n // watchdog reports \"preview off\"\r\n if (this.previewOn && active?.lastFrame) {\r\n for (const view of this.views) {\r\n if (view.readyState === view.OPEN) view.send(active.lastFrame, { binary: true });\r\n }\r\n }\r\n // re-announce frame size/rotation for the newly selected device\r\n if (active) {\r\n this.broadcastToViews({ type: \"frame_meta\", width: active.meta.width ?? 0, height: active.meta.height ?? 0, ...(active.meta.rotation !== void 0 ? { rotation: active.meta.rotation } : {}) });\r\n }\r\n }\r\n\r\n private activeCam(): CamState | undefined {\r\n return this.activeDeviceId ? this.cameras.get(this.activeDeviceId) : undefined;\r\n }\r\n\r\n private broadcastDevices(): void {\r\n for (const view of this.views) this.broadcastDevicesTo(view);\r\n }\r\n\r\n private broadcastDevicesTo(view: WebSocket): void {\r\n if (view.readyState !== view.OPEN) return;\r\n view.send(\r\n JSON.stringify({\r\n type: \"devices\",\r\n devices: [...this.cameras.values()].map((c, i) => {\r\n const id = [...this.cameras.keys()][i]!;\r\n return { id, name: c.name, active: id === this.activeDeviceId };\r\n }),\r\n } satisfies ViewServerMessage),\r\n );\r\n }\r\n\r\n // ── capture correlation ───────────────────────────────────────────────────\r\n\r\n /** Ask the ACTIVE phone to shoot. Returns the captureId, or null when none. */\r\n requestCapture(captureId: string, note?: string): string | null {\r\n const active = this.activeCam();\r\n if (!active || active.ws.readyState !== active.ws.OPEN) return null;\r\n this.pendingCaptures.set(captureId, { note, requestedAt: Date.now() });\r\n active.ws.send(JSON.stringify({ type: \"capture\", captureId, ...(note ? { note } : {}) } satisfies CameraControl));\r\n this.broadcastToViews({ type: \"capture_pending\", captureId, ...(note ? { note } : {}) });\r\n this.gcCaptures();\r\n return captureId;\r\n }\r\n\r\n consumeCapture(captureId: string): { note?: string } | null {\r\n const pending = this.pendingCaptures.get(captureId);\r\n if (!pending) return null;\r\n this.pendingCaptures.delete(captureId);\r\n return { note: pending.note };\r\n }\r\n\r\n noteFor(captureId: string): string | undefined {\r\n return this.pendingCaptures.get(captureId)?.note;\r\n }\r\n\r\n private gcCaptures(): void {\r\n const cutoff = Date.now() - this.captureTimeoutMs;\r\n for (const [id, p] of this.pendingCaptures) {\r\n if (p.requestedAt < cutoff) this.pendingCaptures.delete(id);\r\n }\r\n }\r\n\r\n broadcastToViews(msg: ViewServerMessage): void {\r\n const text = JSON.stringify(msg);\r\n for (const view of this.views) {\r\n if (view.readyState === view.OPEN) view.send(text);\r\n }\r\n }\r\n\r\n stats(): { connected: boolean; fps: number; lastFrameAt: number; views: number; devices: number } {\r\n const active = this.activeCam();\r\n return {\r\n connected: this.cameras.size > 0,\r\n fps: active ? Math.round(active.measuredFps * 10) / 10 : 0,\r\n lastFrameAt: active?.lastFrameAt ?? 0,\r\n views: this.views.size,\r\n devices: this.cameras.size,\r\n };\r\n }\r\n}\r\n\r\nfunction safeJson(text: string): CameraControl | null {\r\n try {\r\n return JSON.parse(text) as CameraControl;\r\n } catch {\r\n return null;\r\n }\r\n}\r\n\r\nfunction parseViewClient(text: string): ViewClientMessage | null {\r\n try {\r\n return JSON.parse(text) as ViewClientMessage;\r\n } catch {\r\n return null;\r\n }\r\n}\r\n","import { join } from \"node:path\";\r\nimport { homedir } from \"node:os\";\r\nimport { env } from \"node:process\";\r\n\r\n/** Where pairing records + fallback uploads live (mirrors dsh-home conventions). */\r\nexport function lensDataDir(): string {\r\n const home = env.DSH_HOME ?? join(homedir(), \".dsh\");\r\n return join(home, \"phone-lens\");\r\n}\r\n","import { join } from \"node:path\";\r\nimport { Service } from \"@deepseek-ai/cordis\";\r\nimport z from \"@deepseek-ai/schemastery\";\r\nimport { normalizeConfig } from \"./config.js\";\r\nimport { HostDeliverySink, type EventedAgent } from \"./inject/host-sink.js\";\r\nimport { TargetTracker } from \"./inject/target.js\";\r\nimport { DeviceStore } from \"./store/devices.js\";\r\nimport { PairingStore } from \"./store/pairing.js\";\r\nimport { AppSettingsStore } from \"./store/settings.js\";\r\nimport { startLensServer, type LensServerHandle } from \"./server/http.js\";\r\nimport { ViewHub } from \"./server/hub.js\";\r\nimport { buildPairingQr } from \"./server/qr.js\";\r\nimport type { AttachmentStoreLike } from \"./inject/admit.js\";\r\nimport { lensDataDir } from \"./paths.js\";\r\n\r\ntype LogLevel = \"info\" | \"warn\" | \"error\";\r\n\r\n/**\r\n * phone-lens host plugin.\r\n *\r\n * Boots the receiver inside the dsh process so uploads can flow straight\r\n * into `ctx.attachments` (and, from Phase 2 on, into agent inboxes) with no\r\n * extra IPC. Everything registered here unwinds with the fiber.\r\n */\r\nexport default class PhoneLens extends Service {\r\n static inject: string[] = [];\r\n static Config = z.any();\r\n\r\n constructor(ctx: any, rawConfig: unknown) {\r\n super(ctx, \"phoneLens\");\r\n const config = normalizeConfig(rawConfig);\r\n const dataDir = lensDataDir();\r\n const log = (level: LogLevel, msg: string) => {\r\n const target = (ctx.logger as Record<LogLevel, (m: string) => void> | undefined) ?? console;\r\n target[level]?.(`[phone-lens] ${msg}`);\r\n };\r\n\r\n const pairing = new PairingStore(config.pairing.codeTtlMs);\r\n const devices = new DeviceStore(dataDir);\r\n // user-selected capture mode + folder target (web UI settings panel)\r\n const appSettings = new AppSettingsStore(join(dataDir, \"settings.json\"), join(dataDir, \"saved\"));\r\n const hub = new ViewHub(config, (level, msg) => log(level, msg));\r\n const targets = new TargetTracker(config);\r\n // Phase 2: real delivery into a live dsh session; the LoggingSink remains\r\n // the standalone/dev fallback. Agent events keep the sink's active target.\r\n const sink = new HostDeliverySink(config, (level, msg) => log(level, msg));\r\n const attachments = (): AttachmentStoreLike | undefined => ctx.get?.(\"attachments\") as AttachmentStoreLike | undefined;\r\n\r\n // agent wiring (scope-filtered events fire on the root ctx for global\r\n // listeners): the sink tracks the last-active session for delivery.\r\n const onAgent = (agent: EventedAgent) => sink.track(agent);\r\n const offAgent = (agent: EventedAgent) => sink.untrack(agent);\r\n ctx.on?.(\"agent/created\", (payload: { agent: EventedAgent }) => onAgent(payload.agent));\r\n ctx.on?.(\"agent/inbox/inserted\", (payload: { agent: EventedAgent }) => onAgent(payload.agent));\r\n ctx.on?.(\"agent/status\", (payload: { agent: EventedAgent; status: string }) => {\r\n if (payload.status === \"running\") onAgent(payload.agent);\r\n });\r\n ctx.on?.(\"agent/disposed\", (payload: { agent: EventedAgent }) => offAgent(payload.agent));\r\n\r\n let handle: LensServerHandle | null = null;\r\n startLensServer({\r\n config,\r\n pairing,\r\n devices,\r\n hub,\r\n targets,\r\n sink,\r\n attachments,\r\n fallbackDir: join(dataDir, \"uploads\"),\r\n pendingDir: join(dataDir, \"pending\"),\r\n appSettings,\r\n log,\r\n })\r\n .then(async (h) => {\r\n handle = h;\r\n const { code, expiresAt } = pairing.current();\r\n const qr = await buildPairingQr(code, expiresAt, config);\r\n // ASCII QR goes to the real stdout; prefixing it through the logger\r\n // would mangle the block characters.\r\n process.stdout.write(`\\n[phone-lens] 手机扫码配对(或浏览器打开 http://127.0.0.1:${h.port}/view.html):\\n${qr.ascii}\\n[phone-lens] 备用地址: ${qr.urls.join(\" \")}\\n\\n`);\r\n })\r\n .catch((error: unknown) => {\r\n log(\"error\", `receiver failed to start: ${String(error)} (check port ${config.server.port})`);\r\n });\r\n\r\n ctx.effect(() => () => {\r\n void handle?.dispose();\r\n handle = null;\r\n }, \"phone-lens.server()\");\r\n }\r\n}\r\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAUA,MAAa,oBAAoB;;AAGjC,SAAgB,gBAAgBA,KAA0B;CACxD,MAAM,IAAK,OAAO,CAAE;CACpB,MAAM,SAAU,EAAE,UAAU,CAAE;CAC9B,MAAM,SAAU,EAAE,UAAU,CAAE;CAC9B,MAAM,UAAW,EAAE,WAAW,CAAE;CAChC,MAAM,UAAW,EAAE,WAAW,CAAE;CAChC,MAAM,SAAU,EAAE,UAAU,CAAE;CAC9B,MAAM,SAAU,EAAE,UAAU,CAAE;CAC9B,MAAM,MAAO,EAAE,OAAO,CAAE;CAMxB,MAAM,aAAa;CACnB,MAAM,kBAAkB,IAAI,aAAa,YAAY,IAAI,WAAW,IAAI,WAAW;CACnF,MAAM,UAAU,MAAM,QAAQ,OAAO,aAAa,GAAG,OAAO,aAAa,OAAO,CAACC,aAAmC,MAAM,SAAS;CACnI,MAAM,OAAO,OAAO,SAAS,UAAU,UAAU;AACjD,QAAO;EACL,QAAQ;GACN,aAAa,OAAO,SAAS,YAAY,OAAO,OAAO,OAAO,OAAO;GACrE,MAAM,OAAO,UAAU,OAAO,KAAK,IAAI,OAAO,OAAQ,KAAK,OAAO,OAAQ,QAAQ,OAAO,OAAQ;EAClG;EACD,QAAQ;GACN,gBAAgB,YAAY,OAAO,gBAAgB,KAAK,OAAO,KAAK;GACpE,cAAc,WAAW,QAAQ,SAAS,IAAI,UAAU,CAAC,cAAc,WAAY;GACnF,sBAAsB,YAAY,OAAO,sBAAsB,MAAM,KAAK;GAE1E,kBAAkB,YAAY,OAAO,kBAAkB,IAAI;EAC5D;EACD,SAAS,EAAE,WAAW,YAAY,QAAQ,WAAW,KAAK,KAAK,IAAK,CAAE;EACtE,SAAS;GACP,UAAU,YAAY,QAAQ,UAAU,IAAI;GAC5C,WAAW,YAAY,QAAQ,WAAW,IAAI;GAC9C,KAAK,SAAS,QAAQ,KAAK,GAAG,IAAI,GAAG;GACrC,aAAa,SAAS,QAAQ,aAAa,IAAI,IAAI,GAAG;EACvD;EACD,QAAQ;GACN;GACA,mBAAmB,OAAO,eAAe,WAAW,OAAO,aAAa;EACzE;EACD,QAAQ;GACN,MAAM,OAAO,SAAS,WAAW,WAAW;GAC5C,wBAAwB,OAAO,oBAAoB,YAAY,OAAO,kBAAkB,OAAO,kBAAkB;EAClH;EACD,KAAK;GACH;GACA,kBAAkB,IAAI,cAAc,YAAY,IAAI,YAAY,IAAI,YAAY;GAEhF,oBAAoB,IAAI,gBAAgB,YAAY,IAAI,cAAc,IAAI,cAAc;EACzF;CACF;AACF;AAED,SAAS,YAAYC,OAAgBC,UAA0B;AAC7D,QAAO,OAAO,UAAU,MAAM,IAAK,QAAmB,IAAK,QAAmB;AAC/E;AAED,SAAS,SAASD,OAAgBE,KAAaC,KAAaF,UAA0B;AACpF,MAAK,OAAO,UAAU,MAAM,CAAE,QAAO;CACrC,MAAM,IAAI;AACV,QAAO,KAAK,IAAI,KAAK,KAAK,IAAI,KAAK,EAAE,CAAC;AACvC;;;;;;;;;;;;;;;;;;;;;AC7CD,IAAa,mBAAb,MAAsD;CACpD,eAAsC;CACtC,SAAiB,IAAI;CAErB,YACmBG,QACAC,KACjB;OAFiB,SAAA;OACA,MAAA;CAIlB;;CAGD,MAAMC,OAA2B;EAC/B,MAAM,KAAK,MAAM,MAAM,OAAO,MAAM,QAAQ,GAAG;AAC/C,OAAK,OAAO,IAAI,IAAI,MAAM;AAC1B,OAAK,eAAe;AACpB,OAAK,IAAI,SAAS,iBAAiB,GAAG,kBAAkB;CACzD;;CAGD,QAAQA,OAA2B;EACjC,MAAM,KAAK,MAAM,MAAM,OAAO,MAAM,QAAQ,GAAG;AAC/C,OAAK,OAAO,OAAO,GAAG;AACtB,MAAI,KAAK,iBAAiB,GAAI,MAAK,eAAe,KAAK,OAAO,MAAM,CAAC,MAAM,CAAC,SAAS;CACtF;;CAGD,UAAuC;AACrC,MAAI,KAAK,OAAO,OAAO,SAAS,YAAY,KAAK,OAAO,OAAO,iBAAiB;GAC9E,MAAM,SAAS,CAAC,GAAG,KAAK,OAAO,QAAQ,AAAC,EAAC,KAAK,CAAC,MAAM,OAAO,EAAE,QAAQ,GAAG,KAAK,KAAK,OAAO,OAAO,gBAAgB;AACjH,OAAI,OAAQ,QAAO;AACnB,QAAK,IAAI,SAAS,iBAAiB,KAAK,OAAO,OAAO,gBAAgB,oBAAoB;EAC3F;AACD,UAAQ,KAAK,eAAe,KAAK,OAAO,IAAI,KAAK,aAAa,GAAG,SAAS;CAC3E;CAED,MAAM,QAAQC,UAAyBC,MAA0BC,MAAsD;EACrH,MAAM,QAAQ,KAAK,SAAS;AAC5B,OAAK,MACH,QAAO;GAAE,IAAI;GAAO,WAAW;GAAM,MAAM;GAAQ,QAAQ;EAAqB;AAElF,MAAI;GACF,MAAMC,UAAqB,CACzB;IAAE,MAAM;IAAQ,MAAM,QAAQ,KAAK,OAAO,OAAO;GAAY,GAC7D;IAAE,MAAM;IAAS,YAAY,SAAS;GACvC,CAAA;GACD,MAAM,UAAU,kBAAkB;IAChC;IACA,QAAQ;KAAE,MAAM;KAAU,QAAQ;IAAc;GACjD,EAAC;AACF,QAAK,IAAI,SAAS,oBAAoB,SAAS,IAAI,aAAa,eAAe,MAAM,QAAQ,GAAG,OAAO,KAAK,EAAE;AAC9G,OAAI,SAAS,QAAS,OAAM,MAAM,QAAQ;OACrC,OAAM,SAAS,QAAQ;AAC5B,UAAO;IAAE,IAAI;IAAM,WAAW,OAAO,MAAM,QAAQ,GAAG;IAAE;GAAM;EAC/D,SAAQ,GAAG;AACV,QAAK,IAAI,SAAS,mBAAmB,OAAO,EAAE,CAAC,EAAE;AACjD,UAAO;IAAE,IAAI;IAAO,WAAW;IAAM,MAAM;IAAQ,QAAQ,OAAO,EAAE;GAAE;EACvE;CACF;AACF;;;;;;;;;;;;;;;AC3ED,IAAa,gBAAb,MAA2B;CACzB,YAA6BC,QAAoB;OAApB,SAAA;CAAsB;;CAGnD,UAAgC;AAC9B,MAAI,KAAK,OAAO,OAAO,SAAS,YAAY,KAAK,OAAO,OAAO,gBAC7D,QAAO;GAAE,WAAW,KAAK,OAAO,OAAO;GAAiB,OAAO;GAAY,QAAQ;GAAM,SAAS;EAAO;AAE3G,SAAO;CACR;;CAGD,OAAwB;EACtB,MAAM,SAAS,KAAK,SAAS;AAC7B,SAAO,SAAS,CAAC,MAAO,IAAG,CAAE;CAC9B;CAED,OAAe;AACb,SAAO,KAAK,OAAO,OAAO;CAC3B;AACF;;;;;AChCD,IAAa,eAAb,MAA0B;CACxB,OAA8B;CAC9B,YAAoB;CACpB,YAAoB;CACpB,OAAe;CAEf,YAA6BC,OAAe;OAAf,QAAA;CAAiB;;CAG9C,UAA+C;EAC7C,MAAM,MAAM,KAAK,KAAK;AACtB,MAAI,KAAK,SAAS,QAAQ,KAAK,QAAQ,OAAO,KAAK,UAAW,MAAK,KAAK,IAAI;AAC5E,SAAO;GAAE,MAAM,KAAK;GAAO,WAAW,KAAK;EAAW;CACvD;;CAGD,UAA+C;AAC7C,OAAK,KAAK,KAAK,KAAK,CAAC;AACrB,SAAO;GAAE,MAAM,KAAK;GAAO,WAAW,KAAK;EAAW;CACvD;;;;;;;;CAMD,OAAOC,WAAgF;AACrF,MAAI,KAAK,SAAS,QAAQ,KAAK,KAAM,QAAO;GAAE,IAAI;GAAO,QAAQ;EAAW;EAC5E,MAAM,MAAM,KAAK,KAAK;AACtB,MAAI,OAAO,KAAK,UAAW,QAAO;GAAE,IAAI;GAAO,QAAQ;EAAW;EAClE,MAAM,IAAI,OAAO,KAAK,UAAU;EAChC,MAAM,IAAI,OAAO,KAAK,KAAK,KAAK;EAChC,MAAM,OAAO,EAAE,WAAW,EAAE,UAAU,gBAAgB,GAAG,EAAE;AAC3D,OAAK,KAAM,QAAO;GAAE,IAAI;GAAO,QAAQ;EAAW;AAClD,OAAK,OAAO;AACZ,SAAO,EAAE,IAAI,KAAM;CACpB;CAED,KAAaC,KAAa;AAExB,OAAK,OAAO,OAAO,UAAU,GAAG,IAAY,CAAC,CAAC,SAAS,GAAG,IAAI;AAC9D,OAAK,YAAY;AACjB,OAAK,YAAY,MAAM,KAAK;AAC5B,OAAK,OAAO;CACb;AACF;;AAGD,SAAgB,kBAA0B;AACxC,QAAO,YAAY,GAAG,CAAC,SAAS,MAAM;AACvC;AAED,SAAgB,UAAUC,OAAuB;AAC/C,QAAO,WAAW,SAAS,CAAC,OAAO,MAAM,CAAC,OAAO,MAAM;AACxD;;AAGD,SAAgB,iBAAiBC,YAAoBC,gBAAiC;CACpF,MAAM,IAAI,OAAO,KAAK,YAAY,MAAM;CACxC,MAAM,IAAI,OAAO,KAAK,UAAU,eAAe,EAAE,MAAM;AACvD,QAAO,EAAE,WAAW,EAAE,UAAU,EAAE,SAAS,KAAK,gBAAgB,GAAG,EAAE;AACtE;;;;;;;;;;;;;ACrDD,IAAa,cAAb,MAAyB;CACvB,UAAkB,IAAI;CACtB;CACA,QAAgB;CAEhB,YAAYC,SAAiB;AAC3B,YAAU,SAAS,EAAE,WAAW,KAAM,EAAC;AACvC,OAAK,OAAO,KAAK,SAAS,eAAe;AACzC,OAAK,MAAM;CACZ;CAED,OAAe;AACb,OAAK,WAAW,KAAK,KAAK,CAAE;AAC5B,MAAI;GACF,MAAM,MAAM,KAAK,MAAM,aAAa,KAAK,MAAM,OAAO,CAAC;AACvD,QAAK,MAAM,KAAK,IAAI,WAAW,CAAE,EAC/B,YAAW,GAAG,aAAa,mBAAmB,GAAG,cAAc,SAC7D,MAAK,QAAQ,IAAI,EAAE,UAAU;IAAE,GAAG;IAAG,MAAM,EAAE,QAAQ;IAAW,OAAO,EAAE,SAAS;IAAI,eAAe,EAAE,iBAAiB,KAAK,KAAK;IAAE,YAAY,EAAE,cAAc;GAAG,EAAC;EAGzK,QAAO,CAEP;CACF;CAED,UAAkB;EAChB,MAAM,OAAO,EAAE,KAAK,KAAK;AACzB,gBAAc,KAAK,KAAK,UAAU;GAAE,SAAS;GAAG,SAAS,CAAC,GAAG,KAAK,QAAQ,QAAQ,AAAC;EAAE,GAAE,MAAM,EAAE,EAAE,OAAO;AACxG,aAAW,KAAK,KAAK,KAAK;CAC3B;;CAGD,OAAOC,QAA0E;EAC/E,MAAM,QAAQ,KAAK,QAAQ,IAAI,OAAO,SAAS;EAC/C,MAAMC,OAAqB;GACzB,GAAG;GACH,MAAM,KAAK,aAAa,OAAO,UAAU,OAAO,KAAK;GACrD,eAAe,OAAO,iBAAiB,KAAK,KAAK;GACjD,YAAY,KAAK,KAAK;EACvB;AACD,OAAK,QAAQ,IAAI,OAAO,UAAU,KAAK;AACvC,OAAK,QAAQ;AACb,OAAK,SAAS;AACd,SAAO;CACR;;CAGD,aAAqBC,UAAkBC,MAAsB;EAC3D,MAAM,SAAS,CAAC,GAAG,KAAK,QAAQ,QAAQ,AAAC,EAAC,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,OAAO,CAAC,GAAG,GAAG,QAAQ,IAAI,QAAQ,EAAE,KAAK,EAAE;EACxG,MAAM,OAAO,KAAK,MAAM,IAAI;AAC5B,OAAK,OAAO,SAAS,KAAK,CAAE,QAAO;AACnC,OAAK,IAAI,IAAI,IAAK,KAAK;GACrB,MAAM,aAAa,EAAE,KAAK,IAAI,EAAE;AAChC,QAAK,OAAO,SAAS,UAAU,CAAE,QAAO;EACzC;CACF;;CAGD,OAAOD,UAAkBC,MAAmC;EAC1D,MAAM,SAAS,KAAK,QAAQ,IAAI,SAAS;AACzC,OAAK,OAAQ,QAAO;AACpB,SAAO,OAAO,KAAK,MAAM,IAAI,OAAO;AACpC,OAAK,QAAQ;AACb,OAAK,SAAS;AACd,SAAO;CACR;;CAGD,aAAaD,UAAkBE,OAAoC;EACjE,MAAM,SAAS,KAAK,QAAQ,IAAI,SAAS;AACzC,OAAK,OAAQ,QAAO;AACpB,OAAK,iBAAiB,OAAO,WAAW,MAAM,CAAE,QAAO;AACvD,SAAO,aAAa,KAAK,KAAK;AAC9B,OAAK,QAAQ;AACb,SAAO;CACR;CAED,OAAOF,UAA2B;EAChC,MAAM,MAAM,KAAK,QAAQ,OAAO,SAAS;AACzC,MAAI,IAAK,MAAK,SAAS;AACvB,SAAO;CACR;CAED,OAAuB;AACrB,SAAO,CAAC,GAAG,KAAK,QAAQ,QAAQ,AAAC,EAAC,KAAK,CAAC,GAAG,MAAM,EAAE,aAAa,EAAE,WAAW;CAC9E;CAED,QAAgB;AACd,SAAO,KAAK,QAAQ;CACrB;AACF;;;;AC9FD,MAAaG,aAAkC;CAAC;CAAY;CAAU;AAAO;;;;;;AAc7E,IAAa,mBAAb,MAA8B;CAC5B;CAEA,YAEmBC,MAEAC,gBACjB;OAHiB,OAAA;OAEA,iBAAA;AAEjB,OAAK,OAAO;GAAE,UAAU;GAAY,SAAS;EAAI;AACjD,OAAK,MAAM;CACZ;CAED,OAAqB;AACnB,OAAK,WAAW,KAAK,KAAK,CAAE;AAC5B,MAAI;GACF,MAAM,MAAM,KAAK,MAAM,aAAa,KAAK,MAAM,OAAO,CAAC;AACvD,OAAI,WAAW,SAAS,IAAI,SAAqB,CAAE,MAAK,KAAK,WAAW,IAAI;AAC5E,cAAW,IAAI,YAAY,SAAU,MAAK,KAAK,UAAU,IAAI;EAC9D,QAAO,CAEP;CACF;CAED,MAAmB;AACjB,SAAO,EAAE,GAAG,KAAK,KAAM;CACxB;;CAGD,mBAA2B;AACzB,SAAO,KAAK,KAAK,QAAQ,MAAM,IAAI,KAAK;CACzC;CAED,IAAIC,OAA0C;AAC5C,MAAI,MAAM,YAAY,WAAW,SAAS,MAAM,SAAS,CAAE,MAAK,KAAK,WAAW,MAAM;AACtF,aAAW,MAAM,YAAY,SAE3B,MAAK,KAAK,UAAU,MAAM,QAAQ,QAAQ,gBAAgB,GAAG,CAAC,MAAM;AAEtE,OAAK,SAAS;AACd,SAAO,KAAK,KAAK;CAClB;CAED,UAAwB;AACtB,MAAI;AACF,aAAU,KAAK,KAAK,MAAM,KAAK,EAAE,EAAE,WAAW,KAAM,EAAC;GACrD,MAAM,OAAO,EAAE,KAAK,KAAK;AACzB,iBAAc,KAAK,KAAK,UAAU,KAAK,MAAM,MAAM,EAAE,EAAE,OAAO;AAC9D,cAAW,KAAK,KAAK,KAAK;EAC3B,QAAO,CAEP;CACF;AACF;;;;ACkCD,MAAa,cAAc;CACzB,eAAe;CACf,mBAAmB;CACnB,mBAAmB;CACnB,cAAc;CACd,kBAAkB;CAClB,WAAW;CACX,WAAW;CACX,cAAc;CACd,WAAW;CACX,WAAW;CACX,iBAAiB;CACjB,eAAe;CACf,aAAa;CACb,UAAU;AACX;;;;;AC/GD,SAAgB,aAAaC,WAAmBC,MAAuB;AACrE,KAAI,cAAc,aAAc,QAAO,KAAK,UAAU,KAAK,KAAK,OAAO,OAAQ,KAAK,OAAO,OAAQ,KAAK,OAAO;AAC/G,KAAI,cAAc,YAAa,QAAO,KAAK,UAAU,KAAK,KAAK,OAAO,OAAQ,KAAK,OAAO,MAAQ,KAAK,OAAO,MAAQ,KAAK,OAAO;AAClI,QAAO;AACR;;;;;;;;;;AAOD,eAAsB,WACpBC,OACAC,aACAC,aACwB;AACxB,KAAI,aAAa;EACf,MAAM,OAAO,MAAM,YAAY,WAAW,CAAC;GAAE,MAAM,MAAM;GAAM,WAAW,MAAM;GAAW,MAAM,MAAM;EAAO,CAAA,EAAC;EAC/G,MAAM,MAAM,KAAK;AACjB,OAAK,IAAK,OAAM,IAAI,MAAM;AAC1B,SAAO;GAAE;GAAK,SAAS;EAAe;CACvC;CACD,MAAM,MAAM,MAAM,cAAc,cAAc,QAAQ;CACtD,MAAM,SAAS,WAAW,OAAO,CAAC,OAAO,MAAM,KAAK,CAAC,OAAO,MAAM,CAAC,MAAM,GAAG,GAAG;AAC/E,WAAU,aAAa,EAAE,WAAW,KAAM,EAAC;CAC3C,MAAM,WAAW,KAAK,cAAc,EAAE,OAAO,GAAG,MAAM,KAAK,QAAQ,aAAa,IAAI,IAAI,QAAQ,GAAG,IAAI,EAAE;AACzG,eAAc,UAAU,MAAM,KAAK;AACnC,QAAO;EACL,KAAK;GACH,eAAe,OAAO,OAAO;GAC7B,WAAW,MAAM;GACjB,OAAO,MAAM,KAAK;GAClB,OAAO;GACP,QAAQ;GACR,MAAM,MAAM;EACb;EACD,SAAS;EACT;CACD;AACF;;;;ACrCD,MAAM,mBAAmB;AACzB,MAAM,eAAe,KAAK,KAAK;AAC/B,MAAM,qBAAqB;AAE3B,IAAIC,SAA6C;AACjD,IAAIC,WAA0C;;;;AAK9C,SAAS,oBAAoBC,KAA6B;AACxD,YAAW,QAAQ,aAAa,IAAI,WAAW,WAAW,CAAE,QAAO;AACnE,KAAI;EACF,MAAM,IAAI,IAAI,IAAI;AAClB,SAAO,EAAE,aAAa,YAAY,EAAE,SAAS;CAC9C,QAAO;AACN,SAAO;CACR;AACF;AAED,eAAe,sBAA8C;CAC3D,MAAM,OAAO,IAAI;CACjB,MAAM,QAAQ,WAAW,MAAM,KAAK,OAAO,EAAE,mBAAmB;AAChE,KAAI;EACF,MAAM,OAAO,MAAM,MAAM,kBAAkB;GAAE,QAAQ,KAAK;GAAQ,SAAS,EAAE,QAAQ,mBAAoB;EAAE,EAAC;AAC5G,OAAK,KAAK,GAAI,QAAO;EACrB,MAAMC,OAAgB,MAAM,KAAK,MAAM;EACvC,MAAM,SAAU,MAAsC;AACtD,OAAK,MAAM,QAAQ,OAAO,CAAE,QAAO;AACnC,OAAK,MAAM,SAAS,QAAQ;GAC1B,MAAM,MAAM;AACZ,OAAI,IAAI,SAAS,qBAAqB,oBAAoB,IAAI,qBAAqB,CACjF,QAAO,IAAI;EAEd;AACD,SAAO;CACR,QAAO;AACN,SAAO;CACR,UAAS;AACR,eAAa,MAAM;CACpB;AACF;;;AAID,SAAgB,wBAAgD;AAC9D,KAAI,UAAU,KAAK,KAAK,GAAG,OAAO,KAAK,aAAc,QAAO,QAAQ,QAAQ,OAAO,IAAI;AACvF,KAAI,SAAU,QAAO;AACrB,YAAW,qBAAqB,CAC7B,KAAK,CAAC,QAAQ;AACb,MAAI,IAAK,UAAS;GAAE;GAAK,IAAI,KAAK,KAAK;EAAE;AACzC,SAAO;CACR,EAAC,CACD,QAAQ,MAAM;AACb,aAAW;CACZ,EAAC;AACJ,QAAO;AACR;;;;;;AAOD,eAAsB,mBAAmBC,QAAmC;AAC1E,KAAI,OAAO,IAAI,aAAa,kBAAmB;CAC/C,MAAM,MAAM,MAAM,uBAAuB;AACzC,MAAK,IAAK;AACV,QAAO,IAAI,WAAW;AACtB,KAAI,OAAO,IAAI,gBAAgB,kBAAmB,QAAO,IAAI,cAAc;AAC5E;;;;;ACzED,SAAgB,eAA6B;CAC3C,MAAMC,MAAoB,CAAE;AAC5B,MAAK,MAAM,CAAC,OAAO,MAAM,IAAI,OAAO,QAAQ,GAAG,mBAAmB,CAAC,CACjE,MAAK,MAAM,QAAQ,SAAS,CAAE,GAAE;AAC9B,MAAI,KAAK,WAAW,UAAU,KAAK,SAAU;AAC7C,MAAI,KAAK;GAAE,IAAI,KAAK;GAAS;EAAO,EAAC;CACtC;CAEH,MAAM,OAAO,CAACC,OAAuB;AACnC,MAAI,GAAG,WAAW,WAAW,CAAE,QAAO;AACtC,MAAI,GAAG,WAAW,OAAO,CAAE,QAAO;AAClC,MAAI,GAAG,WAAW,MAAM,CAAE,QAAO;AACjC,SAAO;CACR;AACD,QAAO,IAAI,KAAK,CAAC,GAAG,MAAM,KAAK,EAAE,GAAG,GAAG,KAAK,EAAE,GAAG,IAAI,EAAE,GAAG,cAAc,EAAE,GAAG,CAAC;AAC/E;;AAYD,eAAsB,eAAeC,MAAcC,WAAmBC,QAAoBC,gBAA6C;CACrI,MAAM,QAAQ,cAAc;CAC5B,MAAM,OAAO,kBAAkB,MAAM,IAAI,MAAM;CAC/C,MAAM,OAAO,OAAO,OAAO;CAC3B,MAAM,WAAW,2BAA2B,mBAAmB,KAAK,CAAC,QAAQ,KAAK,QAAQ,KAAK;CAC/F,MAAM,OAAO,CAAC,MAAM,SAAS,IAAI,QAAQ,CAAC;EAAE,IAAI;EAAa,OAAO;CAAa,CAAA,GAAE,IAAI,CAAC,OAAO,SAAS,EAAE,GAAG,GAAG,KAAK,EAAE;CACvH,MAAM,CAAC,YAAY,MAAM,GAAG,MAAM,QAAQ,IAAI,CAC5C,OAAO,UAAU,SAAS;EAAE,sBAAsB;EAAK,QAAQ;EAAG,OAAO;CAAK,EAAC,EAC/E,OAAO,SAAS,SAAS;EAAE,MAAM;EAAY,OAAO;CAAM,EAAC,AAC5D,EAAC;AACF,QAAO;EAAE;EAAM;EAAW;EAAS;EAAM;EAAY;CAAO;AAC7D;;;;;;;;;;;;;AC3CD,MAAa,aAAa;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;ACF1B,IAAa,cAAb,MAAyB;CACvB,OAAe,IAAI;CAEnB,YAA6BC,UAAmCC,KAAa;OAAhD,WAAA;OAAmC,MAAA;CAAe;;CAG/E,MAAMC,KAAsB;EAC1B,MAAM,MAAM,KAAK,KAAK;EACtB,MAAM,SAAS,MAAM,KAAK;EAC1B,MAAM,OAAO,CAAC,KAAK,KAAK,IAAI,IAAI,IAAI,CAAE,GAAE,OAAO,CAAC,MAAM,IAAI,OAAO;AACjE,MAAI,KAAK,UAAU,KAAK,KAAK;AAC3B,QAAK,KAAK,IAAI,KAAK,KAAK;AACxB,UAAO;EACR;AACD,OAAK,KAAK,IAAI;AACd,OAAK,KAAK,IAAI,KAAK,KAAK;AACxB,SAAO;CACR;AACF;AAED,MAAM,WAAW,IAAI,IAAI;CAAC;CAAa;CAAO;AAAmB;;AAGjE,SAAgB,WAAWC,KAA+B;CACxD,MAAM,OAAO,IAAI,OAAO,iBAAiB;AACzC,QAAO,SAAS,IAAI,KAAK;AAC1B;;;;;;;;;;;;;;;;AAUD,SAAgB,QAAQA,KAA8C;CACpE,MAAM,SAAS,IAAI,QAAQ;AAC3B,YAAW,WAAW,YAAY,WAAW,GAAI,QAAO,CAAE;AAC1D,KAAI;EACF,MAAM,IAAI,IAAI,IAAI;EAClB,MAAM,OAAO,EAAE,SAAS,QAAQ,YAAY,GAAG;AAC/C,MAAK,EAAE,aAAa,WAAW,SAAS,IAAI,KAAK,IAAK,EAAE,aAAa,WACnE,QAAO;GACL,+BAA+B;GAC/B,gCAAgC;GAChC,gCAAgC;GAChC,0BAA0B;EAC3B;CAEJ,QAAO,CAEP;AACD,QAAO,CAAE;AACV;;AAGD,SAAgB,UAAUA,KAA8B;AACtD,QAAO,IAAI,OAAO,iBAAiB;AACpC;;AAGD,SAAgB,WAAWA,KAAkE;CAC3F,MAAM,WAAW,IAAI,QAAQ;CAC7B,MAAM,QAAQ,IAAI,QAAQ;AAC1B,YAAW,aAAa,mBAAmB,UAAU,aAAa,aAAa,MAAO,QAAO;AAC7F,QAAO;EAAE;EAAU;CAAO;AAC3B;;AAGD,SAAgB,QAAQA,KAAuC;CAC7D,MAAM,MAAM,IAAI,IAAI,IAAI,OAAO,KAAK;AACpC,QAAO,IAAI;AACZ;;;;;AC7BD,MAAM,iBAAiB,KAAK,KAAK;;AAMjC,IAAIC,aAA4B;AAChC,SAAS,cAAsB;AAC7B,KAAI,WAAY,QAAO;AACvB,KAAI;AACF,eAAa,OAAO,KAAK,MAAM,aAAa,IAAI,IAAI,mBAAmB,OAAO,KAAK,MAAM,OAAO,CAAC,CAAC,WAAW,QAAQ;CACtH,QAAO;AACN,eAAa;CACd;AACD,QAAO;AACR;AAED,eAAsB,gBAAgBC,MAA6C;CACjF,MAAM,EAAE,QAAQ,SAAS,SAAS,KAAK,SAAS,MAAM,KAAK,GAAG;CAC9D,MAAM,cAAc,IAAI,YAAY,KAAQ;CAG5C,MAAM,gBAAgB,IAAI;CAC1B,IAAIC,wBAAuC;CAC3C,MAAMC,eAA6G,EAAE,MAAM,KAAM;CAEjI,MAAMC,SAAqB,aAAa,CAAC,KAAK,QAAQ;AACpD,SAAO,MAAM,KAAK,KAAK;GACrB;GACA;GACA,kBAAkB,MAAM;GACxB,eAAe,CAAC,SAAS,iBAAiB;AACxC,iBAAa,OAAO;KAAE,IAAI,KAAK,KAAK;KAAE,WAAW,QAAQ;KAAW;KAAc,IAAI,QAAQ;IAAI;GACnG;GACD,kBAAkB,MAAM,aAAa;EACtC,EAAC,CAAC,MAAM,CAAC,UAAU;AAClB,OAAI,UAAU,kBAAkB,OAAO,MAAM,CAAC,EAAE;AAChD,QAAK,IAAI,YAAa,UAAS,KAAK,KAAK,EAAE,OAAO;IAAE,MAAM,YAAY;IAAU,SAAS,OAAO,MAAM;GAAE,EAAE,EAAC;OACtG,KAAI,SAAS;EACnB,EAAC;CACH,EAAC;CAEF,MAAM,MAAM,IAAI,gBAAgB;EAAE,UAAU;EAAM,YAAY,OAAO,OAAO,uBAAuB;CAAG;AACtG,QAAO,GAAG,WAAW,CAACC,KAAsBC,QAAgBC,SAAiB;EAC3E,MAAM,MAAM,IAAI,IAAI,IAAI,OAAO,KAAK;EACpC,MAAM,OAAO,CAACC,QAAgBC,YAAoB;AAChD,UAAO,OAAO,WAAW,OAAO,GAAG,QAAQ,+BAA+B;AAC1E,UAAO,SAAS;EACjB;AACD,MAAI,IAAI,aAAa,cAAc;GACjC,MAAM,IAAI,IAAI;GACd,MAAM,WAAW,EAAE,IAAI,WAAW,IAAI;GACtC,MAAM,QAAQ,EAAE,IAAI,QAAQ,IAAI;GAChC,MAAM,SAAS,QAAQ,aAAa,UAAU,MAAM;AACpD,QAAK,OAAQ,QAAO,KAAK,KAAK,eAAe;AAC7C,OAAI,cAAc,KAAK,QAAQ,MAAM,CAAC,OAAO;AAC3C,4BAAwB,OAAO;AAC/B,QAAI,aAAa,OAAO,UAAU,IAAI,OAAO,KAAK;GACnD,EAAC;AACF;EACD;AACD,MAAI,IAAI,aAAa,YAAY;AAC/B,QAAK,WAAW,IAAI,CAAE,QAAO,KAAK,KAAK,YAAY;AACnD,OAAI,cAAc,KAAK,QAAQ,MAAM,CAAC,OAAO,IAAI,WAAW,IAAiB;IAC3E,kBAAkB,MAAM,QAAQ,SAAS;IACzC,gBAAgB,CAAC,UAAU,SAAS;KAClC,MAAM,UAAU,QAAQ,OAAO,UAAU,KAAK;AAC9C,SAAI,QAAS,KAAI,aAAa,UAAU,QAAQ,KAAK;IACtD;GACF,EAAC,CAAC;AACH;EACD;AACD,OAAK,KAAK,YAAY;CACvB,EAAC;CAEF,MAAM,YAAY,YAAY,MAAM,IAAI,SAAS,EAAE,IAAO;AAE1D,OAAM,IAAI,QAAc,CAAC,WAAS,WAAW;AAC3C,SAAO,KAAK,SAAS,OAAO;AAC5B,SAAO,OAAO,OAAO,OAAO,MAAM,OAAO,OAAO,MAAM,MAAM,WAAS,CAAC;CACvE;AACD,KAAI,SAAS,0BAA0B,OAAO,OAAO,KAAK,GAAG,OAAO,OAAO,KAAK,oBAAoB,QAAQ,OAAO,CAAC,GAAG;AAGvH,CAAK,uBAAuB,CAAC,KAAK,CAAC,QAAQ;AACzC,MAAI,IAAK,KAAI,SAAS,6CAA6C,IAAI,EAAE;CAC1E,EAAC;AAEF,QAAO;EACL,MAAM,OAAO,OAAO;EACpB,SAAS,YAAY;AACnB,iBAAc,UAAU;AACxB,OAAI,SAAS;AACb,OAAI,WAAW;AACf,QAAK,MAAM,UAAU,IAAI,QAAS,QAAO,WAAW;AACpD,SAAM,IAAI,QAAc,CAAC,cAAY,OAAO,MAAM,MAAM,WAAS,CAAC;EACnE;CACF;AACF;AAWD,eAAe,OAAOR,MAAkBI,KAAsBK,KAAqBC,KAA8B;CAC/G,MAAM,EAAE,QAAQ,SAAS,SAAS,KAAK,SAAS,MAAM,KAAK,GAAG;CAC9D,MAAM,MAAM,IAAI,IAAI,IAAI,OAAO,KAAK;CACpC,MAAM,OAAO,IAAI,SAAS,QAAQ,QAAQ,GAAG,IAAI;CACjD,MAAM,SAAS,IAAI,UAAU;CAC7B,MAAM,OAAO,WAAW,IAAI;CAC5B,MAAM,OAAO,QAAQ,IAAI;AAGzB,KAAI,WAAW,WAAW;AACxB,MAAI,UAAU,KAAK,KAAK;AACxB,MAAI,KAAK;AACT;CACD;AAGD,KAAI,WAAW,SAAS,SAAS,QAC/B,QAAO,SAAS,KAAK,KAAK;EAAE,MAAM;EAAkB,SAAS,aAAa;EAAE,iBAAiB;CAAM,GAAE,KAAK;AAI5G,MAAK,SAAS,SAAS,OAAO,SAAS,gBAAgB,SAAS,cAAc,SAAS,aAAa,SAAS,kBAAkB,SAAS,iBACtI,QAAO,UAAU,KAAK,KAAK,YAAY,eAAe,mCAAmC;AAE3F,KAAI,WAAW,UAAU,SAAS,OAAO,SAAS,eAAe;AAC/D,MAAI,UAAU,KAAK;GAAE,gBAAgB;GAA4B,iBAAiB;EAAY,EAAC;AAC/F,MAAI,IAAI,UAAU;AAClB;CACD;AACD,KAAI,WAAW,SAAS,SAAS,YAAY;EAC3C,MAAM,EAAE,MAAM,WAAW,GAAG,QAAQ,SAAS;EAC7C,MAAM,KAAK,MAAM,eAAe,MAAM,WAAW,OAAO;AACxD,SAAO,SAAS,KAAK,KAAK;GAAE,MAAM,GAAG;GAAM,WAAW,GAAG;GAAW,SAAS,GAAG;GAAS,MAAM,GAAG;GAAM,YAAY,GAAG;EAAY,GAAE;GAAE,GAAG;GAAM,iBAAiB;EAAY,EAAC;CAC/K;AACD,KAAI,WAAW,SAAS,SAAS,WAAW;EAC1C,MAAM,EAAE,MAAM,WAAW,GAAG,QAAQ,SAAS;EAC7C,MAAM,KAAK,MAAM,eAAe,MAAM,WAAW,OAAO;EACxD,MAAM,MAAM,GAAG,WAAW,MAAM,GAAG,WAAW,QAAQ,IAAI,GAAG,EAAE;AAC/D,MAAI,UAAU,KAAK;GAAE,gBAAgB;GAAa,iBAAiB;EAAY,EAAC;AAChF,MAAI,IAAI,OAAO,KAAK,KAAK,SAAS,CAAC;AACnC;CACD;AAGD,KAAI,WAAW,SAAS,SAAS,gBAAgB;AAG/C,QAAM,mBAAmB,OAAO;EAChC,MAAM,SAAS,OAAO,IAAI,eAAe,OAAO,IAAI;EACpD,MAAM,aAAa,MAAM,OAAO,UAAU,QAAQ;GAAE,sBAAsB;GAAK,QAAQ;GAAG,OAAO;EAAK,EAAC;AACvG,SAAO,SAAS,KAAK,KAAK;GAAE,KAAK;GAAQ,OAAO,OAAO,IAAI;GAAU,QAAQ,OAAO,IAAI;GAAW;EAAY,GAAE;GAAE,GAAG;GAAM,iBAAiB;EAAY,EAAC;CAC3J;AAMD,KAAI,WAAW,UAAU,SAAS,SAAS;EACzC,MAAM,OAAO,MAAM,aAAa,KAAK,KAAK;EAC1C,MAAM,OAAO,IAAI,MAAM,KAAK;EAC5B,MAAM,SAAU,MAAM,UAAU,CAAE;EAClC,MAAM,WAAW,IAAI,OAAO,GAAG;EAC/B,MAAM,OAAO,IAAI,OAAO,KAAK,IAAI;EACjC,MAAM,QAAQ,IAAI,OAAO,MAAM,IAAI;AACnC,OAAK,SAAS,SAAU,QAAO,UAAU,KAAK,KAAK,YAAY,aAAa,kCAAkC;EAC9G,MAAM,UAAU,QAAQ,OAAO,KAAK;AACpC,OAAK,QAAQ,IAAI;AACf,QAAK,IAAI,YAAY,MAAM,UAAU,IAAI,CAAC,CAAE,QAAO,UAAU,KAAK,KAAK,YAAY,cAAc,4BAA4B;AAC7H,UAAO,UAAU,KAAK,KAAK,QAAQ,WAAW,YAAY,YAAY,oBAAoB,YAAY,oBAAoB,eAAe,QAAQ,OAAO,EAAE;EAC3J;EACD,MAAM,QAAQ,iBAAiB;AAC/B,UAAQ,OAAO;GAAE;GAAU;GAAM;GAAO,WAAW,UAAU,MAAM;EAAE,EAAC;AACtE,MAAI,SAAS,iBAAiB,KAAK,KAAK,SAAS,MAAM,GAAG,EAAE,CAAC,GAAG;AAChE,SAAO,SAAS,KAAK,KAAK;GACxB;GACA,YAAY;IAAE,SAAS,OAAO;IAAS,QAAQ,OAAO;GAAQ;EAC/D,EAAC;CACH;CAGD,MAAM,OAAO,OAAO,EAAE,IAAI,KAAM,IAAG,WAAW,IAAI,IAAI,QAAQ,aAAa,WAAW,IAAI,CAAE,UAAU,WAAW,IAAI,CAAE,MAAM,GAAG,EAAE,IAAI,KAAM,IAAG,EAAE,IAAI,MAAO;AAC5J,MAAK,KAAK,GAAI,QAAO,UAAU,KAAK,KAAK,YAAY,eAAe,yBAAyB;AAE7F,KAAI,WAAW,UAAU,SAAS,WAAW;EAC3C,MAAM,IAAI,QAAQ,IAAI;EAItB,MAAM,cAAc,EAAE,IAAI,WAAW,IAAI;EACzC,MAAM,WAAW,gBAAgB,KAAK,YAAY,GAAG,cAAc;AACnE,MAAI,UAAU;GACZ,MAAM,MAAM,IAAI,cAAc,IAAI,SAAS;AAC3C,OAAI,OAAO,IAAI,UAAU,KAAK,KAAK,EAAE;AACnC,QAAI,SAAS,+BAA+B,SAAS,MAAM,GAAG,EAAE,CAAC,EAAE;AACnE,WAAO,SAAS,KAAK,KAAK,IAAI,KAAK;GACpC;AACD,OAAI,cAAc,OAAO,SAAS;EACnC;EAID,MAAM,YAAY,CAAC,IAAI,QAAQ,mBAAmB,IAAI,MAAM,IAAI,CAAC,GAAI,MAAM;AAC3E,OAAK,OAAO,OAAO,aAAa,SAAS,UAAU,CACjD,QAAO,UAAU,KAAK,KAAK,YAAY,mBAAmB,WAAW,OAAO,OAAO,aAAa,KAAK,KAAK,CAAC,EAAE;EAE/G,MAAM,WAAW,OAAO,IAAI,QAAQ,qBAAqB,IAAI;AAC7D,MAAI,WAAW,OAAO,OAAO,eAAgB,QAAO,UAAU,KAAK,KAAK,YAAY,YAAY,IAAI,OAAO,OAAO,eAAe,QAAQ;EACzI,MAAM,EAAE,KAAK,WAAW,GAAG,MAAM,YAAY,KAAK,OAAO,OAAO,eAAe;AAC/E,MAAI,UAAW,QAAO,UAAU,KAAK,KAAK,YAAY,YAAY,IAAI,OAAO,OAAO,eAAe,QAAQ;AAC3G,OAAK,aAAa,WAAW,IAAI,CAAE,QAAO,UAAU,KAAK,KAAK,YAAY,WAAW,uCAAuC;EAE5H,MAAM,YAAY,EAAE,IAAI,YAAY;EACpC,MAAM,OAAO,EAAE,IAAI,OAAO,KAAK,YAAY,IAAI,QAAQ,UAAU;AACjE,MAAI,UAAW,KAAI,eAAe,UAAU;EAC5C,MAAM,UAAU,CAAC,EAAE,IAAI,OAAO,KAAK,OAAO,IAAI,OAAO,aAAa,CAAC,QAAQ,SAAS,IAAI,CAAC,GAAG,cAAc,cAAc,QAAQ,MAAM,GAAG,MAAM,GAAG,IAAI;EACtJ,MAAM,OAAO,iBAAiB,QAAQ;EAEtC,IAAI;AACJ,MAAI;AACF,cAAW,MAAM,WAAW;IAAE,MAAM;IAAK;IAAW;GAAM,GAAE,KAAK,aAAa,EAAE,KAAK,YAAY;EAClG,SAAQ,OAAO;AACd,OAAI,UAAU,gBAAgB,OAAO,MAAM,CAAC,EAAE;AAC9C,UAAO,UAAU,KAAK,KAAK,YAAY,cAAc,OAAO,MAAM,CAAC;EACpE;AACD,MAAI,SAAS,SAAS,KAAK,IAAI,IAAI,WAAW,OAAO,SAAS,QAAQ,GAAG,SAAS,IAAI,aAAa,EAAE;AAGrG,MAAI,SAAS,YAAY,OAAQ,OAAM,aAAa,KAAK,aAAa,OAAO,OAAO,iBAAiB,CAAC,MAAM,CAAC,UAAU,IAAI,SAAS,yBAAyB,OAAO,MAAM,CAAC,EAAE,CAAC;EAQ9K,MAAM,KAAK,KAAK,YAAY,KAAK;EACjC,MAAM,aAAa,GAAG,aAAa;EACnC,MAAM,eAAe,GAAG,aAAa;EACrC,IAAIC,WAA0B;AAC9B,MAAI,YAAY;GACd,MAAM,MAAM,KAAK,YAAY,kBAAkB;AAC/C,OAAI;AACF,cAAU,KAAK,EAAE,WAAW,KAAM,EAAC;IACnC,MAAM,WAAW,kBAAkB,KAAK,KAAK;IAC7C,MAAM,SAAS,aAAa,KAAK,SAAS;AAC1C,SAAK,OAAQ,OAAM,IAAI,MAAM;AAC7B,kBAAc,QAAQ,IAAI;AAC1B,eAAW;AACX,QAAI,SAAS,qBAAqB,OAAO,EAAE;GAC5C,SAAQ,OAAO;AACd,QAAI,SAAS,sBAAsB,OAAO,MAAM,CAAC,sCAAsC;GACxF;EACF;AAMD,MAAI,iBAAiB,UAAU;GAC7B,MAAM,cAAc,aAAa,KAAK,YAAY,gBAAgB,SAAS,IAAI,aAAa,CAAC;AAC7F,OAAI;AACF,SAAK,YAAa,OAAM,IAAI,MAAM;AAClC,cAAU,KAAK,YAAY,EAAE,WAAW,KAAM,EAAC;AAC/C,kBAAc,aAAa,IAAI;AAC/B,QAAI,SAAS,uBAAuB,YAAY,EAAE;GACnD,SAAQ,OAAO;AACd,QAAI,SAAS,kBAAkB,OAAO,MAAM,CAAC,EAAE;GAChD;AACD,OAAI,iBAAiB;IAAE,MAAM;IAAiB,cAAc,SAAS,IAAI;IAAc;GAAM,EAAC;EAE/F,MAGC,KAAI,iBAAiB;GAAE,MAAM;GAAgB;GAAM,KAAK;EAAU,EAAC;AAOrE,MAAI,UAAU;GACZ,MAAM,aAAa;IACjB,IAAI;IACJ,cAAc,SAAS,IAAI;IAC3B,OAAO,SAAS,IAAI;IACpB,QAAQ,SAAS,IAAI;IACrB,OAAO,SAAS,IAAI;IACpB,SAAS,SAAS;IAClB,WAAW;IACX,eAAe,WAAY,eAAe,qBAAqB,oBAAqB;GACrF;AACD,OAAI,IAAI,cAAc,OAAO,KAC3B;SAAK,MAAM,CAAC,GAAG,EAAE,IAAI,IAAI,cAAe,KAAI,EAAE,WAAW,KAAK,KAAK,CAAE,KAAI,cAAc,OAAO,EAAE;GAAA;AAElG,OAAI,cAAc,IAAI,UAAU;IAAE,SAAS,KAAK,KAAK,GAAG,KAAK;IAAQ,MAAM;GAAY,EAAC;EACzF;AAED,SAAO,SAAS,KAAK,KAAK;GACxB,IAAI;GACJ,cAAc,SAAS,IAAI;GAC3B,OAAO,SAAS,IAAI;GACpB,QAAQ,SAAS,IAAI;GACrB,OAAO,SAAS,IAAI;GACpB,SAAS,SAAS;GAClB,WAAW;GACX,eAAe,WAAY,eAAe,qBAAqB,oBAAqB;EACrF,EAAC;CACH;AAGD,KAAI,WAAW,SAAS,KAAK,WAAW,YAAY,IAAI,MAAM;EAC5D,MAAM,KAAK,KAAK,MAAM,EAAmB,CAAC,MAAM,IAAI,CAAC,MAAM;EAC3D,MAAM,OAAO,aAAa,KAAK,YAAY,gBAAgB,GAAG,CAAC;AAC/D,OAAK,KAAM,QAAO,UAAU,KAAK,KAAK,YAAY,aAAa,0BAA0B;AACzF,MAAI;GACF,MAAM,OAAO,MAAM,SAAS,KAAK;AACjC,OAAI,UAAU,KAAK;IAAE,gBAAgB;IAAc,iBAAiB;IAAY,GAAG;GAAM,EAAC;AAC1F,OAAI,IAAI,KAAK;AACb;EACD,QAAO;AACN,UAAO,UAAU,KAAK,KAAK,YAAY,aAAa,0BAA0B;EAC/E;CACF;AAED,KAAI,WAAW,SAAS,SAAS,WAAW;EAC1C,MAAM,iBAAiB,IAAI,kBAAkB;AAC7C,SAAO,SAAS,KAAK,KAAK;GACxB,QAAQ;GACR,SAAS,QAAQ,MAAM,CAAC,IAAI,CAAC,OAAO;IAAE,IAAI,EAAE;IAAU,MAAM,EAAE;IAAM,OAAO,EAAE;IAAO,QAAQ,EAAE,aAAa;IAAgB,WAAW,EAAE,aAAa;IAAgB,YAAY,EAAE;GAAY,GAAE;GACjM,QAAQ,IAAI,OAAO;GACnB,SAAS,OAAO;GAChB,QAAQ;IAAE,MAAM,QAAQ,MAAM;IAAE,WAAW,QAAQ,SAAS,EAAE,aAAa;GAAM;GACjF,eAAe,IAAI,kBAAkB;EACtC,GAAE,KAAK;CACT;AAED,KAAI,WAAW,SAAS,SAAS,WAC/B,QAAO,SAAS,KAAK,KAAK;EAAE,SAAS,QAAQ,MAAM;EAAE,SAAS,QAAQ,SAAS,EAAE,aAAa;CAAM,GAAE,KAAK;AAK7G,KAAI,WAAW,SAAS,SAAS,iBAAiB;EAChD,MAAM,KAAK,KAAK,YAAY,KAAK;AACjC,SAAO,SAAS,KAAK,KAAK;GAAE,UAAU,GAAG;GAAU,SAAS,GAAG;GAAS,kBAAkB,KAAK,YAAY,kBAAkB;EAAE,GAAE,KAAK;CACvI;AACD,KAAI,WAAW,UAAU,SAAS,iBAAiB;EACjD,MAAM,OAAO,MAAM,aAAa,KAAK,KAAK;AAC1C,OAAK,KAAM,QAAO,UAAU,KAAK,KAAK,YAAY,aAAa,oBAAoB;EACnF,MAAMC,QAAmD,CAAE;AAC3D,aAAW,KAAK,aAAa,YAAY,WAAW,SAAS,KAAK,SAAqB,CAAE,OAAM,WAAW,KAAK;AAC/G,aAAW,KAAK,YAAY,SAAU,OAAM,UAAU,KAAK;EAC3D,MAAM,KAAK,KAAK,YAAY,IAAI,MAAM;AACtC,MAAI,SAAS,6BAA6B,GAAG,SAAS,OAAO,GAAG,UAAU,KAAK,UAAU,GAAG,QAAQ,GAAG,YAAY,EAAE;AACrH,SAAO,SAAS,KAAK,KAAK;GAAE,UAAU,GAAG;GAAU,SAAS,GAAG;GAAS,kBAAkB,KAAK,YAAY,kBAAkB;EAAE,GAAE,KAAK;CACvI;AAID,KAAI,WAAW,UAAU,SAAS,cAAc,MAAM;EACpD,MAAM,YAAY,YAAY;EAC9B,MAAM,KAAK,IAAI,eAAe,WAAW,QAAQ,IAAI,CAAC,IAAI,OAAO,WAAc;AAC/E,OAAK,GAAI,QAAO,UAAU,KAAK,KAAK,YAAY,WAAW,6BAA6B;AACxF,SAAO,SAAS,KAAK,KAAK,EAAE,UAAW,EAAC;CACzC;AAED,QAAO,UAAU,KAAK,KAAK,YAAY,cAAc,WAAW,OAAO,GAAG,KAAK,EAAE;AAClF;;;AAMD,SAAS,iBAAiBC,OAAuB;CAC/C,MAAM,OAAO,SAAS,MAAM,CACzB,QAAQ,2BAA2B,IAAI,CACvC,QAAQ,WAAW,GAAG,CACtB,MAAM;AACT,QAAO,QAAQ;AAChB;;AAGD,SAAS,kBAAkBC,KAAaC,MAAsB;AAC5D,MAAK,WAAW,KAAK,KAAK,KAAK,CAAC,CAAE,QAAO;CACzC,MAAM,MAAM,KAAK,YAAY,IAAI;CACjC,MAAM,OAAO,MAAM,IAAI,KAAK,MAAM,GAAG,IAAI,GAAG;CAC5C,MAAM,MAAM,MAAM,IAAI,KAAK,MAAM,IAAI,GAAG;AACxC,MAAK,IAAI,IAAI,IAAK,KAAK;EACrB,MAAM,aAAa,EAAE,KAAK,IAAI,EAAE,GAAG,IAAI;AACvC,OAAK,WAAW,KAAK,KAAK,UAAU,CAAC,CAAE,QAAO;CAC/C;AACF;;;;AAKD,SAAS,gBAAgBC,cAA8B;AACrD,SAAQ,EAAE,aAAa,QAAQ,oBAAoB,IAAI,CAAC;AACzD;;;;AAKD,SAAS,aAAaC,SAAiBF,MAA6B;CAClE,MAAM,OAAO,QAAQ,QAAQ;CAC7B,MAAM,SAAS,QAAQ,MAAM,KAAK;AAClC,QAAO,WAAW,QAAQ,OAAO,WAAW,OAAO,IAAI,GAAG,SAAS;AACpE;AAED,SAAS,SAASN,KAAqBF,QAAgBW,MAAeC,cAA6C;AACjH,KAAI,UAAU,QAAQ;EAAE,gBAAgB;EAAmC,GAAI,gBAAgB,CAAE;CAAG,EAAC;AACrG,KAAI,IAAI,KAAK,UAAU,KAAK,CAAC;AAC9B;AAED,SAAS,UAAUV,KAAqBF,QAAgBa,MAAcZ,SAAuB;AAC3F,UAAS,KAAK,QAAQ,EAAE,OAAO;EAAE;EAAM;CAAS,EAAE,EAAC;AACpD;AAED,SAAS,IAAIa,GAAgC;AAC3C,eAAc,MAAM,WAAW;AAChC;AAED,eAAe,aAAajB,KAAsBkB,OAAwD;CACxG,MAAM,EAAE,KAAK,GAAG,MAAM,YAAY,KAAK,MAAM;AAC7C,KAAI;AACF,SAAO,KAAK,MAAM,IAAI,SAAS,OAAO,CAAC;CACxC,QAAO;AACN,SAAO;CACR;AACF;AAED,SAAS,YAAYlB,KAAsBmB,UAAgE;AACzG,QAAO,IAAI,QAAQ,CAAC,WAAS,WAAW;EACtC,MAAMC,SAAmB,CAAE;EAC3B,IAAI,OAAO;EACX,IAAI,YAAY;AAChB,MAAI,GAAG,QAAQ,CAACC,UAAkB;AAChC,WAAQ,MAAM;AACd,OAAI,OAAO,UAAU;AACnB,gBAAY;AACZ,WAAO,SAAS;AAChB,QAAI,SAAS;AACb,cAAQ;KAAE,KAAK,OAAO,MAAM,EAAE;KAAE;IAAW,EAAC;AAC5C;GACD;AACD,UAAO,KAAK,MAAM;EACnB,EAAC;AACF,MAAI,GAAG,OAAO,MAAM,UAAQ;GAAE,KAAK,OAAO,OAAO,OAAO;GAAE;EAAW,EAAC,CAAC;AACvE,MAAI,GAAG,SAAS,OAAO;CACxB;AACF;;AAGD,eAAe,aAAaX,KAAaY,KAA4B;AACnE,KAAI,OAAO,EAAG;CACd,MAAM,QAAQ,MAAM,QAAQ,IAAI;AAChC,KAAI,MAAM,UAAU,IAAK;CACzB,MAAM,UAAU,MAAM,QAAQ,IAC5B,MAAM,IAAI,OAAO,SAAS;EACxB,IAAI,QAAQ;AACZ,MAAI;AACF,YAAS,MAAM,KAAK,KAAK,KAAK,KAAK,CAAC,EAAE;EACvC,QAAO;AACN,WAAQ;EACT;AACD,SAAO;GAAE;GAAM;EAAO;CACvB,EAAC,CACH;AACD,SAAQ,KAAK,CAAC,GAAG,MAAM,EAAE,QAAQ,EAAE,MAAM;AACzC,MAAK,MAAM,SAAS,QAAQ,MAAM,IAAI,CACpC,OAAM,OAAO,KAAK,KAAK,MAAM,KAAK,CAAC,CAAC,MAAM,MAAM,CAAE,EAAC;AAEtD;;;;;;;;;;;;;;;;;;;ACxfD,MAAM,mBAAmB;;;;;;;;;;;;;;;;AAUzB,IAAa,UAAb,MAAqB;CACnB,UAAkB,IAAI;CACtB,iBAAwC;CACxC,QAAgB,IAAI;;CAEpB,kBAA0B,IAAI;CAC9B,mBAAoC;;CAEpC,YAAoB;CACpB,aAA4D;CAE5D,YACmBC,QACAC,KACjB;OAFiB,SAAA;OACA,MAAA;AAEjB,OAAK,aAAa,YAAY,MAAM,KAAK,mBAAmB,EAAE,IAAM;CACrE;;CAGD,UAAgB;AACd,MAAI,KAAK,WAAY,eAAc,KAAK,WAAW;AACnD,OAAK,aAAa;CACnB;CAID,aAAaC,UAAkBC,IAAeC,MAAoB;EAChE,MAAM,OAAO,KAAK,QAAQ,IAAI,SAAS;AACvC,MAAI,MAAM;AACR,QAAK,IAAI,SAAS,4CAA4C,SAAS,MAAM,GAAG,EAAE,CAAC,EAAE;AACrF,OAAI;AACF,SAAK,GAAG,MAAM,KAAM,eAAe;GACpC,QAAO,CAAE;AACV,QAAK,QAAQ,OAAO,SAAS;EAC9B;EACD,MAAMC,MAAgB;GAAE;GAAI;GAAM,MAAM,CAAE;GAAE,WAAW;GAAM,aAAa;GAAG,YAAY;GAAG,aAAa,KAAK,KAAK;GAAE,aAAa;EAAG;AACrI,OAAK,QAAQ,IAAI,UAAU,IAAI;AAC/B,MAAI,KAAK,mBAAmB,KAAM,MAAK,iBAAiB;AAExD,MAAI,KAAK,mBAAmB,SAAU,MAAK,YAAY,UAAU,EAAE,MAAM,iBAAkB,EAAC;MACvF,MAAK,YAAY,UAAU,EAAE,MAAM,gBAAiB,EAAC;AAC1D,OAAK,IAAI,SAAS,iBAAiB,KAAK,IAAI,SAAS,MAAM,GAAG,EAAE,CAAC,GAAG;AAEpE,KAAG,GAAG,SAAS,CAAC,MAAM,WAAW;AAC/B,QAAK,IAAI,SAAS,yBAAyB,KAAK,UAAU,OAAO,SAAS,OAAO,IAAI,IAAI,IAAI,KAAK,GAAG,SAAS,MAAM,GAAG,EAAE,CAAC,GAAG;AAC7H,OAAI,KAAK,QAAQ,IAAI,SAAS,EAAE,OAAO,GAAI,MAAK,aAAa,SAAS;EACvE,EAAC;AACF,KAAG,GAAG,WAAW,CAAC,MAAM,aAAa;AACnC,OAAI,UAAU;AACZ,SAAK,YAAY,UAAU,KAAe;AAC1C;GACD;AACD,QAAK,gBAAgB,UAAU,SAAS,KAAK,UAAU,CAAC,CAAC;EAC1D,EAAC;AAEF,OAAK,kBAAkB;CACxB;CAED,aAAaH,UAAwB;EACnC,MAAM,MAAM,KAAK,QAAQ,IAAI,SAAS;AACtC,OAAK,IAAI,SAAS,eAAe,SAAS,MAAM,GAAG,EAAE,CAAC,iBAAiB,IAAI,EAAE;AAC7E,MAAI,IAAK,MAAK,QAAQ,OAAO,SAAS;AACtC,MAAI,KAAK,mBAAmB,UAAU;GACpC,MAAM,OAAO,CAAC,GAAG,KAAK,QAAQ,MAAM,AAAC,EAAC,GAAA,GAAM,IAAI;AAChD,QAAK,iBAAiB;AACtB,OAAI,KAAM,MAAK,YAAY,MAAM,EAAE,MAAM,iBAAkB,EAAC;AAC5D,QAAK,wBAAwB;EAC9B;AACD,OAAK,kBAAkB;CACxB;CAED,YAAkB;AAChB,OAAK,MAAM,OAAO,KAAK,QAAQ,QAAQ,CACrC,KAAI;AACF,OAAI,GAAG,MAAM,KAAM,iBAAiB;EACrC,QAAO,CAAE;AAEZ,OAAK,QAAQ,OAAO;AACpB,OAAK,iBAAiB;AACtB,OAAK,kBAAkB;CACxB;CAED,UAAgB;AACd,OAAK,MAAM,OAAO,KAAK,QAAQ,QAAQ,CACrC,KAAI,IAAI,GAAG,eAAe,IAAI,GAAG,KAAM,KAAI,GAAG,MAAM;CAEvD;CAED,gBAAwBA,UAAkBI,KAAiC;AACzE,OAAK,IAAK;EACV,MAAM,MAAM,KAAK,QAAQ,IAAI,SAAS;AACtC,OAAK,IAAK;AACV,UAAQ,IAAI,MAAZ;GACE,KAAK;AACH,QAAI,OAAO;KAAE,OAAO,IAAI;KAAO,QAAQ,IAAI;KAAQ,KAAK,IAAI;KAAK,GAAI,IAAI,kBAAkB,IAAI,EAAE,UAAU,IAAI,SAAU,IAAG,CAAE;IAAG;AACjI,QAAI,KAAK,mBAAmB,UAAU;AAGpC,UAAK,mBAAmB;AACxB,UAAK,iBAAiB;MAAE,MAAM;MAAc,OAAO,IAAI;MAAO,QAAQ,IAAI;MAAQ,GAAI,IAAI,kBAAkB,IAAI,EAAE,UAAU,IAAI,SAAU,IAAG,CAAE;KAAG,EAAC;IACpJ;AACD;GACF,KAAK;AACH,SAAK,IAAI,SAAS,mBAAmB,SAAS,MAAM,GAAG,EAAE,CAAC,EAAE;AAC5D,SAAK,aAAa,SAAS;AAC3B;GACF,KAAK;AAEH,SAAK,aAAa,SAAS;AAC3B;GACF,KAAK;AACH,QAAI,IAAI,WAAW,SAAS;AAC1B,UAAK,gBAAgB,OAAO,IAAI,UAAU;AAC1C,UAAK,iBAAiB;MAAE,MAAM;MAAS,MAAM;MAAoB,UAAU,iBAAiB,IAAI,OAAO,EAAE,IAAI,UAAU,IAAI,IAAI,OAAO,IAAI,GAAG;KAAG,EAAC;IAClJ;AACD;GACF,QACE;EACH;CACF;CAED,YAAoBJ,UAAkBK,OAAqB;EACzD,MAAM,MAAM,KAAK,QAAQ,IAAI,SAAS;AACtC,OAAK,OAAO,MAAM,eAAe,EAAG;AACpC,MAAI,MAAM,aAAa,KAAK,OAAO,OAAO,sBAAsB;AAC9D,QAAK,IAAI,SAAS,wCAAwC,SAAS,MAAM,GAAG,EAAE,CAAC,IAAI,MAAM,WAAW,MAAM,KAAK,OAAO,OAAO,qBAAqB,IAAI;AACtJ;EACD;EACD,MAAM,MAAM,KAAK,KAAK;AACtB,MAAI,YAAY;AAChB,MAAI,cAAc;AAClB,MAAI;AACJ,MAAI,MAAM,IAAI,eAAe,KAAM;AACjC,OAAI,cAAe,IAAI,aAAa,OAAS,MAAM,IAAI;AACvD,OAAI,cAAc;AAClB,OAAI,aAAa;EAClB;AAED,MAAI,KAAK,mBAAmB,UAAU;AACpC,QAAK,mBAAmB;AACxB,QAAK,MAAM,QAAQ,KAAK,MACtB,KAAI,KAAK,eAAe,KAAK,KAAM,MAAK,KAAK,OAAO,EAAE,QAAQ,KAAM,EAAC;EAExE;CACF;;CAGD,oBAAkC;AAChC,MAAI,KAAK,UAAW;AACpB,OAAK,YAAY;AACjB,OAAK,iBAAiB;GAAE,MAAM;GAAiB,IAAI;EAAM,EAAC;CAC3D;;;;;;;;;;;;;;CASD,oBAAkC;AAChC,OAAK,KAAK,UAAW;EACrB,MAAM,SAAS,KAAK,WAAW;AAC/B,OAAK,UAAU,OAAO,GAAG,eAAe,OAAO,GAAG,KAAM;AACxD,MAAI,KAAK,KAAK,GAAG,OAAO,eAAe,iBAAkB;AACzD,OAAK,YAAY;AACjB,OAAK,iBAAiB;GAAE,MAAM;GAAiB,IAAI;EAAO,EAAC;AAC3D,OAAK,IAAI,SAAS,0BAA0B,iBAAiB,mCAAmC;CACjG;CAID,WAAWJ,IAAeK,QAAsG,CAAE,GAAQ;AACxI,OAAK,MAAM,IAAI,GAAG;AAClB,KAAG,GAAG,SAAS,CAAC,MAAM,WAAW;AAC/B,QAAK,IAAI,SAAS,uBAAuB,KAAK,UAAU,OAAO,SAAS,OAAO,IAAI,IAAI,gBAAgB,KAAK,MAAM,OAAO,EAAE,GAAG;AAC9H,QAAK,MAAM,OAAO,GAAG;EACtB,EAAC;AACF,KAAG,GAAG,WAAW,CAAC,MAAM,aAAa;AACnC,OAAI,SAAU;GACd,MAAM,MAAM,gBAAgB,KAAK,UAAU,CAAC;AAC5C,QAAK,IAAK;AACV,OAAI,IAAI,SAAS,WAAW;IAC1B,MAAM,YAAY,YAAY;AAC9B,SAAK,KAAK,eAAe,WAAW,IAAI,KAAK,CAC3C,MAAK,iBAAiB;KAAE,MAAM;KAAS,MAAM;KAAa,SAAS;IAA8B,EAAC;GAErG,WAAU,IAAI,SAAS,gBACtB,MAAK,aAAa,IAAI,SAAS;YACtB,IAAI,SAAS,gBACtB,OAAM,iBAAiB,IAAI,UAAU,IAAI,KAAK;YACrC,IAAI,SAAS,kBACtB,OAAM,oBAAoB;EAE7B,EAAC;EACF,MAAM,SAAS,KAAK,WAAW;AAC/B,KAAG,KACD,KAAK,UAAU;GACb,MAAM;GACN,QAAQ,SAAS;IAAE,WAAW;IAAM,MAAM,OAAO;IAAM,GAAG,OAAO;GAAM,IAAG,EAAE,WAAW,MAAO;GAC9F,SAAS,KAAK,OAAO;GACrB,QAAQ;EACT,EAA6B,CAC/B;AACD,OAAK,mBAAmB,GAAG;AAG3B,KAAG,KAAK,KAAK,UAAU;GAAE,MAAM;GAAiB,IAAI,KAAK;EAAW,EAA6B,CAAC;AAGlG,MAAI,KAAK,aAAa,QAAQ,aAAa,GAAG,eAAe,GAAG,KAAM,IAAG,KAAK,OAAO,WAAW,EAAE,QAAQ,KAAM,EAAC;CAClH;CAED,YAAoB;AAClB,SAAO,KAAK,MAAM;CACnB;;CAGD,aAAaN,UAAkBE,MAAoB;EACjD,MAAM,MAAM,KAAK,QAAQ,IAAI,SAAS;AACtC,MAAI,IAAK,KAAI,OAAO;AACpB,OAAK,kBAAkB;CACxB;;CAGD,aAAaF,UAAwB;AACnC,OAAK,KAAK,QAAQ,IAAI,SAAS,CAAE;EACjC,MAAM,aAAa,KAAK;AACxB,MAAI,eAAe,SAAU;AAC7B,OAAK,iBAAiB;AACtB,MAAI,WAAY,MAAK,YAAY,YAAY,EAAE,MAAM,gBAAiB,EAAC;AACvE,OAAK,YAAY,UAAU,EAAE,MAAM,iBAAkB,EAAC;AACtD,OAAK,kBAAkB;AACvB,OAAK,wBAAwB;CAC9B;;CAGD,YAAoBA,UAAkBO,KAA0B;EAC9D,MAAM,MAAM,KAAK,QAAQ,IAAI,SAAS;AACtC,MAAI,OAAO,IAAI,GAAG,eAAe,IAAI,GAAG,KAAM,KAAI,GAAG,KAAK,KAAK,UAAU,IAAI,CAAC;CAC/E;CAED,yBAAuC;EACrC,MAAM,SAAS,KAAK,WAAW;AAI/B,MAAI,KAAK,aAAa,QAAQ,WAC5B;QAAK,MAAM,QAAQ,KAAK,MACtB,KAAI,KAAK,eAAe,KAAK,KAAM,MAAK,KAAK,OAAO,WAAW,EAAE,QAAQ,KAAM,EAAC;EACjF;AAGH,MAAI,OACF,MAAK,iBAAiB;GAAE,MAAM;GAAc,OAAO,OAAO,KAAK,SAAS;GAAG,QAAQ,OAAO,KAAK,UAAU;GAAG,GAAI,OAAO,KAAK,kBAAkB,IAAI,EAAE,UAAU,OAAO,KAAK,SAAU,IAAG,CAAE;EAAG,EAAC;CAEhM;CAED,YAA0C;AACxC,SAAO,KAAK,iBAAiB,KAAK,QAAQ,IAAI,KAAK,eAAe;CACnE;CAED,mBAAiC;AAC/B,OAAK,MAAM,QAAQ,KAAK,MAAO,MAAK,mBAAmB,KAAK;CAC7D;CAED,mBAA2BC,MAAuB;AAChD,MAAI,KAAK,eAAe,KAAK,KAAM;AACnC,OAAK,KACH,KAAK,UAAU;GACb,MAAM;GACN,SAAS,CAAC,GAAG,KAAK,QAAQ,QAAQ,AAAC,EAAC,IAAI,CAAC,GAAG,MAAM;IAChD,MAAM,KAAK,CAAC,GAAG,KAAK,QAAQ,MAAM,AAAC,EAAC;AACpC,WAAO;KAAE;KAAI,MAAM,EAAE;KAAM,QAAQ,OAAO,KAAK;IAAgB;GAChE,EAAC;EACH,EAA6B,CAC/B;CACF;;CAKD,eAAeC,WAAmBC,MAA8B;EAC9D,MAAM,SAAS,KAAK,WAAW;AAC/B,OAAK,UAAU,OAAO,GAAG,eAAe,OAAO,GAAG,KAAM,QAAO;AAC/D,OAAK,gBAAgB,IAAI,WAAW;GAAE;GAAM,aAAa,KAAK,KAAK;EAAE,EAAC;AACtE,SAAO,GAAG,KAAK,KAAK,UAAU;GAAE,MAAM;GAAW;GAAW,GAAI,OAAO,EAAE,KAAM,IAAG,CAAE;EAAG,EAAyB,CAAC;AACjH,OAAK,iBAAiB;GAAE,MAAM;GAAmB;GAAW,GAAI,OAAO,EAAE,KAAM,IAAG,CAAE;EAAG,EAAC;AACxF,OAAK,YAAY;AACjB,SAAO;CACR;CAED,eAAeD,WAA6C;EAC1D,MAAM,UAAU,KAAK,gBAAgB,IAAI,UAAU;AACnD,OAAK,QAAS,QAAO;AACrB,OAAK,gBAAgB,OAAO,UAAU;AACtC,SAAO,EAAE,MAAM,QAAQ,KAAM;CAC9B;CAED,QAAQA,WAAuC;AAC7C,SAAO,KAAK,gBAAgB,IAAI,UAAU,EAAE;CAC7C;CAED,aAA2B;EACzB,MAAM,SAAS,KAAK,KAAK,GAAG,KAAK;AACjC,OAAK,MAAM,CAAC,IAAI,EAAE,IAAI,KAAK,gBACzB,KAAI,EAAE,cAAc,OAAQ,MAAK,gBAAgB,OAAO,GAAG;CAE9D;CAED,iBAAiBE,KAA8B;EAC7C,MAAM,OAAO,KAAK,UAAU,IAAI;AAChC,OAAK,MAAM,QAAQ,KAAK,MACtB,KAAI,KAAK,eAAe,KAAK,KAAM,MAAK,KAAK,KAAK;CAErD;CAED,QAAkG;EAChG,MAAM,SAAS,KAAK,WAAW;AAC/B,SAAO;GACL,WAAW,KAAK,QAAQ,OAAO;GAC/B,KAAK,SAAS,KAAK,MAAM,OAAO,cAAc,GAAG,GAAG,KAAK;GACzD,aAAa,QAAQ,eAAe;GACpC,OAAO,KAAK,MAAM;GAClB,SAAS,KAAK,QAAQ;EACvB;CACF;AACF;AAED,SAAS,SAASC,MAAoC;AACpD,KAAI;AACF,SAAO,KAAK,MAAM,KAAK;CACxB,QAAO;AACN,SAAO;CACR;AACF;AAED,SAAS,gBAAgBA,MAAwC;AAC/D,KAAI;AACF,SAAO,KAAK,MAAM,KAAK;CACxB,QAAO;AACN,SAAO;CACR;AACF;;;;;ACpXD,SAAgB,cAAsB;CACpC,MAAM,OAAO,IAAI,YAAY,KAAK,SAAS,EAAE,OAAO;AACpD,QAAO,KAAK,MAAM,aAAa;AAChC;;;;;;;;;;;;;;;;;ACgBD,IAAqB,YAArB,cAAuC,QAAQ;CAC7C,OAAO,SAAmB,CAAE;CAC5B,OAAO,SAAS,EAAE,KAAK;CAEvB,YAAYC,KAAUC,WAAoB;AACxC,QAAM,KAAK,YAAY;EACvB,MAAM,SAAS,gBAAgB,UAAU;EACzC,MAAM,UAAU,aAAa;EAC7B,MAAM,MAAM,CAACC,OAAiBC,QAAgB;GAC5C,MAAM,SAAU,IAAI,UAAgE;AACpF,UAAO,UAAU,eAAe,IAAI,EAAE;EACvC;EAED,MAAM,UAAU,IAAI,aAAa,OAAO,QAAQ;EAChD,MAAM,UAAU,IAAI,YAAY;EAEhC,MAAM,cAAc,IAAI,iBAAiB,KAAK,SAAS,gBAAgB,EAAE,KAAK,SAAS,QAAQ;EAC/F,MAAM,MAAM,IAAI,QAAQ,QAAQ,CAAC,OAAO,QAAQ,IAAI,OAAO,IAAI;EAC/D,MAAM,UAAU,IAAI,cAAc;EAGlC,MAAM,OAAO,IAAI,iBAAiB,QAAQ,CAAC,OAAO,QAAQ,IAAI,OAAO,IAAI;EACzE,MAAM,cAAc,MAAuC,IAAI,MAAM,cAAc;EAInF,MAAM,UAAU,CAACC,UAAwB,KAAK,MAAM,MAAM;EAC1D,MAAM,WAAW,CAACA,UAAwB,KAAK,QAAQ,MAAM;AAC7D,MAAI,KAAK,iBAAiB,CAACC,YAAqC,QAAQ,QAAQ,MAAM,CAAC;AACvF,MAAI,KAAK,wBAAwB,CAACA,YAAqC,QAAQ,QAAQ,MAAM,CAAC;AAC9F,MAAI,KAAK,gBAAgB,CAACC,YAAqD;AAC7E,OAAI,QAAQ,WAAW,UAAW,SAAQ,QAAQ,MAAM;EACzD,EAAC;AACF,MAAI,KAAK,kBAAkB,CAACD,YAAqC,SAAS,QAAQ,MAAM,CAAC;EAEzF,IAAIE,WAAkC;AACtC,kBAAgB;GACd;GACA;GACA;GACA;GACA;GACA;GACA;GACA,aAAa,KAAK,SAAS,UAAU;GACrC,YAAY,KAAK,SAAS,UAAU;GACpC;GACA;EACD,EAAC,CACC,KAAK,OAAO,MAAM;AACjB,cAAS;GACT,MAAM,EAAE,MAAM,WAAW,GAAG,QAAQ,SAAS;GAC7C,MAAM,KAAK,MAAM,eAAe,MAAM,WAAW,OAAO;AAGxD,WAAQ,OAAO,OAAO,gDAAgD,EAAE,KAAK,gBAAgB,GAAG,MAAM,uBAAuB,GAAG,KAAK,KAAK,KAAK,CAAC,MAAM;EACvJ,EAAC,CACD,MAAM,CAACC,UAAmB;AACzB,OAAI,UAAU,4BAA4B,OAAO,MAAM,CAAC,eAAe,OAAO,OAAO,KAAK,GAAG;EAC9F,EAAC;AAEJ,MAAI,OAAO,MAAM,MAAM;AACrB,GAAK,UAAQ,SAAS;AACtB,cAAS;EACV,GAAE,sBAAsB;CAC1B;AACF"}
1
+ {"version":3,"file":"index.js","names":["raw: unknown","t: unknown","value: unknown","fallback: number","min: number","max: number","config: LensConfig","log: (level: \"info\" | \"warn\", msg: string) => void","agent: EventedAgent","admitted: AdmittedImage","note: string | undefined","mode: \"followup\" | \"steer\"","content: unknown[]","config: LensConfig","ttlMs: number","submitted: string","now: number","token: string","recordHash: string","presentedToken: string","dataDir: string","record: Omit<DeviceRecord, \"firstPairedAt\" | \"lastSeenAt\">","full: DeviceRecord","deviceId: string","name: string","token: string","SAVE_MODES: readonly SaveMode[]","file: string","defaultSaveDir: string","patch: Partial<AppSettings>","mediaType: string","head: Buffer","input: { data: Uint8Array; mediaType: string; name: string }","attachments: AttachmentStoreLike | undefined","fallbackDir: string","cached: { url: string; at: number } | null","inflight: Promise<string | null> | null","raw: unknown","data: unknown","config: LensConfig","out: LanAddress[]","ip: string","code: string","expiresAt: number","config: LensConfig","originOverride?: string","windowMs: number","max: number","key: string","req: IncomingMessage","ownVersion: string | null","deps: ServerDeps","injectionBox: { last: { at: number; sessionId: string | null; attachmentId: string; ok: boolean } | null }","server: HttpServer","req: IncomingMessage","socket: Duplex","head: Buffer","status: number","message: string","res: ServerResponse","ctx: RouteCtx","savedDir: string | null","patch: { saveMode?: SaveMode; saveDir?: string }","input: string","dir: string","name: string","attachmentId: string","baseDir: string","body: unknown","extraHeaders?: Record<string, string>","code: string","v: unknown","limit: number","maxBytes: number","chunks: Buffer[]","chunk: Buffer","max: number","config: LensConfig","log: (level: \"info\" | \"warn\", msg: string) => void","deviceId: string","ws: WebSocket","name: string","cam: CamState","msg: CameraControl | null","frame: Buffer","hooks: { onRefreshPairing?: () => void; onRenameDevice?: (deviceId: string, name: string) => void }","msg: CameraControl","view: WebSocket","captureId: string","note?: string","msg: ViewServerMessage","text: string","ctx: any","rawConfig: unknown","level: LogLevel","msg: string","agent: EventedAgent","payload: { agent: EventedAgent }","payload: { agent: EventedAgent; status: string }","handle: LensServerHandle | null","error: unknown"],"sources":["../src/config.ts","../src/inject/host-sink.ts","../src/inject/target.ts","../src/store/pairing.ts","../src/store/devices.ts","../src/store/settings.ts","../src/types.ts","../src/inject/admit.ts","../src/server/apk-link.ts","../src/server/qr.ts","../src/server/static-view.ts","../src/server/auth.ts","../src/server/http.ts","../src/server/hub.ts","../src/paths.ts","../src/index.ts"],"sourcesContent":["import type { LensConfig } from \"./types.js\";\r\n\r\n/**\r\n * Offline fallback for the Gitee APK download link. Since v1.0.2 the actual\r\n * link is resolved from Gitee's public latest-release API at runtime (see\r\n * server/apk-link.ts), so this constant needs NO per-release bump anymore —\r\n * it only serves when the API is unreachable, and deliberately points at a\r\n * release known to exist (pointing it at a future tag would 404 until that\r\n * release is actually published).\r\n */\r\nexport const GITEE_APK_DEFAULT = \"https://gitee.com/qianfengbingtang/phone-lens/releases/download/v1.0.0/app-release.apk\";\r\n\r\n/** Coerce an unknown config object (cordis patch row / CLI overrides) into LensConfig. */\r\nexport function normalizeConfig(raw: unknown): LensConfig {\r\n const r = (raw ?? {}) as Record<string, any>;\r\n const server = (r.server ?? {}) as Record<string, any>;\r\n const limits = (r.limits ?? {}) as Record<string, any>;\r\n const pairing = (r.pairing ?? {}) as Record<string, any>;\r\n const preview = (r.preview ?? {}) as Record<string, any>;\r\n const inject = (r.inject ?? {}) as Record<string, any>;\r\n const target = (r.target ?? {}) as Record<string, any>;\r\n const app = (r.app ?? {}) as Record<string, any>;\r\n // App download sources: Gitee first (CN-friendly), GitHub as the fallback\r\n // link. The Gitee URL is resolved from Gitee's latest-release API at\r\n // runtime (server/apk-link.ts, since v1.0.2) — GITEE_APK_DEFAULT above is\r\n // only the offline fallback; GitHub's latest/download link is inherently\r\n // dynamic. Neither needs a bump when a new APK ships.\r\n const GITHUB_APK = \"https://github.com/yxqfg/phone-lens/releases/latest/download/app-release.apk\";\r\n const giteeUrl = typeof app.giteeUrl === \"string\" && app.giteeUrl ? app.giteeUrl : GITEE_APK_DEFAULT;\r\n const allowed = Array.isArray(limits.allowedTypes) ? limits.allowedTypes.filter((t: unknown): t is string => typeof t === \"string\") : undefined;\r\n const mode = inject.mode === \"steer\" ? \"steer\" : \"followup\";\r\n return {\r\n server: {\r\n host: typeof server.host === \"string\" && server.host ? server.host : \"0.0.0.0\",\r\n port: Number.isInteger(server.port) && server.port! > 0 && server.port! < 65536 ? server.port! : 8791,\r\n },\r\n limits: {\r\n maxUploadBytes: positiveInt(limits.maxUploadBytes, 10 * 1024 * 1024),\r\n allowedTypes: allowed && allowed.length > 0 ? allowed : [\"image/jpeg\", \"image/png\"],\r\n previewFrameMaxBytes: positiveInt(limits.previewFrameMaxBytes, 512 * 1024),\r\n // keep the local upload archive bounded: oldest files are pruned past this\r\n maxStoredUploads: positiveInt(limits.maxStoredUploads, 200),\r\n },\r\n pairing: { codeTtlMs: positiveInt(pairing.codeTtlMs, 15 * 60 * 1000) },\r\n preview: {\r\n maxWidth: positiveInt(preview.maxWidth, 854),\r\n maxHeight: positiveInt(preview.maxHeight, 480),\r\n fps: clampInt(preview.fps, 1, 30, 10),\r\n jpegQuality: clampInt(preview.jpegQuality, 20, 95, 70),\r\n },\r\n inject: {\r\n mode,\r\n notePrefix: typeof inject.notePrefix === \"string\" ? inject.notePrefix : \"[手机拍照]\",\r\n },\r\n target: {\r\n mode: target.mode === \"pinned\" ? \"pinned\" : \"latest\",\r\n pinnedSessionId: typeof target.pinnedSessionId === \"string\" && target.pinnedSessionId ? target.pinnedSessionId : null,\r\n },\r\n app: {\r\n giteeUrl,\r\n githubUrl: typeof app.githubUrl === \"string\" && app.githubUrl ? app.githubUrl : GITHUB_APK,\r\n // The QR encodes this URL; defaults to the Gitee release asset.\r\n downloadUrl: typeof app.downloadUrl === \"string\" && app.downloadUrl ? app.downloadUrl : giteeUrl,\r\n },\r\n };\r\n}\r\n\r\nfunction positiveInt(value: unknown, fallback: number): number {\r\n return Number.isInteger(value) && (value as number) > 0 ? (value as number) : fallback;\r\n}\r\n\r\nfunction clampInt(value: unknown, min: number, max: number, fallback: number): number {\r\n if (!Number.isInteger(value)) return fallback;\r\n const n = value as number;\r\n return Math.min(max, Math.max(min, n));\r\n}\r\n","import { createUserMessage } from \"@deepseek-ai/dsh-llm\";\r\nimport type { AdmittedImage, DeliveryReceipt, LensConfig } from \"../types.js\";\r\nimport type { DeliverySink } from \"./deliver.js\";\r\n\r\n/** Minimal structural view of a live dsh agent (docs/dsh-caps.md Q1/Q3). */\r\ninterface LiveAgent {\r\n id: string;\r\n session: { id: string };\r\n followup(message: unknown): void;\r\n steer(message: unknown): void;\r\n ctx?: { sessions?: unknown };\r\n}\r\n\r\nexport interface EventedAgent {\r\n send(message: unknown, target: \"next-turn\" | \"next-step\", wakeup: boolean): void;\r\n followup(message: unknown): void;\r\n steer(message: unknown): void;\r\n session: { id: string };\r\n id?: string;\r\n}\r\n\r\n/**\r\n * Phase 2 delivery seam: turns a stored image into a durable dsh user message\r\n * attached to the current/last-active session, so the model sees it on the\r\n * next turn.\r\n *\r\n * The active session is tracked from live agent events (inbox inserts and\r\n * `running` status flips). With no live session the image stays stored but\r\n * no delivery occurs — a real state, not an error.\r\n */\r\nexport class HostDeliverySink implements DeliverySink {\r\n private lastActiveId: string | null = null;\r\n private agents = new Map<string, EventedAgent>();\r\n\r\n constructor(\r\n private readonly config: LensConfig,\r\n private readonly log: (level: \"info\" | \"warn\", msg: string) => void,\r\n ) {\r\n // subscribe via injected listeners; the caller wires these to the context\r\n // because the sink must stay cordis-agnostic for standalone tests.\r\n }\r\n\r\n /** Track an agent that appeared / got input. Call from ctx event handlers. */\r\n track(agent: EventedAgent): void {\r\n const id = agent.id ?? String(agent.session.id);\r\n this.agents.set(id, agent);\r\n this.lastActiveId = id;\r\n this.log(\"info\", `tracking agent ${id} (active target)`);\r\n }\r\n\r\n /** Forget a disposed agent. */\r\n untrack(agent: EventedAgent): void {\r\n const id = agent.id ?? String(agent.session.id);\r\n this.agents.delete(id);\r\n if (this.lastActiveId === id) this.lastActiveId = this.agents.keys().next().value ?? null;\r\n }\r\n\r\n /** The session an upload targets right now, if any. */\r\n private resolve(): EventedAgent | null {\r\n if (this.config.target.mode === \"pinned\" && this.config.target.pinnedSessionId) {\r\n const pinned = [...this.agents.values()].find((a) => String(a.session.id) === this.config.target.pinnedSessionId);\r\n if (pinned) return pinned;\r\n this.log(\"warn\", `pinned session ${this.config.target.pinnedSessionId} has no live agent`);\r\n }\r\n return (this.lastActiveId ? this.agents.get(this.lastActiveId) : null) ?? null;\r\n }\r\n\r\n async deliver(admitted: AdmittedImage, note: string | undefined, mode: \"followup\" | \"steer\"): Promise<DeliveryReceipt> {\r\n const agent = this.resolve();\r\n if (!agent) {\r\n return { ok: false, sessionId: null, mode: \"none\", reason: \"no active session\" };\r\n }\r\n try {\r\n const content: unknown[] = [\r\n { type: \"text\", text: note ?? this.config.inject.notePrefix },\r\n { type: \"image\", attachment: admitted.ref },\r\n ];\r\n const message = createUserMessage({\r\n content,\r\n source: { kind: \"plugin\", plugin: \"phone-lens\" },\r\n });\r\n this.log(\"info\", `delivering image (${admitted.ref.attachmentId}) to session ${agent.session.id} via ${mode}`);\r\n if (mode === \"steer\") agent.steer(message);\r\n else agent.followup(message);\r\n return { ok: true, sessionId: String(agent.session.id), mode };\r\n } catch (e) {\r\n this.log(\"warn\", `delivery failed: ${String(e)}`);\r\n return { ok: false, sessionId: null, mode: \"none\", reason: String(e) };\r\n }\r\n }\r\n}\r\n","import type { LensConfig } from \"../types.js\";\r\n\r\nexport interface SessionTarget {\r\n sessionId: string;\r\n title: string;\r\n active: boolean;\r\n running: boolean;\r\n}\r\n\r\n/**\r\n * Resolves the session an upload should be injected into.\r\n *\r\n * Phase 1: pure config view, no live agents (the agent-event wiring that\r\n * feeds `latest` lands in Phase 2 — see architecture.md §2 D4).\r\n */\r\nexport class TargetTracker {\r\n constructor(private readonly config: LensConfig) {}\r\n\r\n /** The session an incoming image would target right now, if any. */\r\n resolve(): SessionTarget | null {\r\n if (this.config.target.mode === \"pinned\" && this.config.target.pinnedSessionId) {\r\n return { sessionId: this.config.target.pinnedSessionId, title: \"(pinned)\", active: true, running: false };\r\n }\r\n return null;\r\n }\r\n\r\n /** Choosable targets for GET /targets. */\r\n list(): SessionTarget[] {\r\n const pinned = this.resolve();\r\n return pinned ? [pinned] : [];\r\n }\r\n\r\n mode(): string {\r\n return this.config.target.mode;\r\n }\r\n}\r\n","import { createHash, randomBytes, randomInt, timingSafeEqual } from \"node:crypto\";\r\n\r\n/** One-shot pairing-code lifecycle: create / verify / burn, with TTL. */\r\nexport class PairingStore {\r\n private code: string | null = null;\r\n private createdAt = 0;\r\n private expiresAt = 0;\r\n private used = false;\r\n\r\n constructor(private readonly ttlMs: number) {}\r\n\r\n /** Current live code, or a freshly minted one. */\r\n current(): { code: string; expiresAt: number } {\r\n const now = Date.now();\r\n if (this.code === null || this.used || now >= this.expiresAt) this.mint(now);\r\n return { code: this.code!, expiresAt: this.expiresAt };\r\n }\r\n\r\n /** Force a fresh code (manual refresh). Invalidates the previous one. */\r\n refresh(): { code: string; expiresAt: number } {\r\n this.mint(Date.now());\r\n return { code: this.code!, expiresAt: this.expiresAt };\r\n }\r\n\r\n /**\r\n * Verify a submitted code and burn it on success.\r\n * Constant-time compare; expired codes read as invalid.\r\n */\r\n verify(submitted: string): { ok: true } | { ok: false; reason: \"invalid\" | \"expired\" } {\r\n if (this.code === null || this.used) return { ok: false, reason: \"invalid\" };\r\n const now = Date.now();\r\n if (now >= this.expiresAt) return { ok: false, reason: \"expired\" };\r\n const a = Buffer.from(submitted);\r\n const b = Buffer.from(this.code);\r\n const same = a.length === b.length && timingSafeEqual(a, b);\r\n if (!same) return { ok: false, reason: \"invalid\" };\r\n this.used = true;\r\n return { ok: true };\r\n }\r\n\r\n private mint(now: number) {\r\n // 8 decimal digits, zero-padded (human-typable fallback).\r\n this.code = String(randomInt(0, 100_000_000)).padStart(8, \"0\");\r\n this.createdAt = now;\r\n this.expiresAt = now + this.ttlMs;\r\n this.used = false;\r\n }\r\n}\r\n\r\n/** Mint a per-device token. Only its SHA-256 may be persisted. */\r\nexport function mintDeviceToken(): string {\r\n return randomBytes(32).toString(\"hex\");\r\n}\r\n\r\nexport function hashToken(token: string): string {\r\n return createHash(\"sha256\").update(token).digest(\"hex\");\r\n}\r\n\r\n/** Constant-time token-hash comparison. */\r\nexport function tokenHashMatches(recordHash: string, presentedToken: string): boolean {\r\n const a = Buffer.from(recordHash, \"hex\");\r\n const b = Buffer.from(hashToken(presentedToken), \"hex\");\r\n return a.length === b.length && a.length > 0 && timingSafeEqual(a, b);\r\n}\r\n","import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from \"node:fs\";\r\nimport { join } from \"node:path\";\r\nimport type { DeviceRecord } from \"../types.js\";\r\nimport { tokenHashMatches } from \"./pairing.js\";\r\n\r\n/**\r\n * Paired-device registry, persisted as JSON under the plugin data dir.\r\n * Records carry token HASHES only; the raw token exists solely in the\r\n * pairing response and the phone's secure storage.\r\n */\r\nexport class DeviceStore {\r\n private devices = new Map<string, DeviceRecord>();\r\n private readonly file: string;\r\n private dirty = false;\r\n\r\n constructor(dataDir: string) {\r\n mkdirSync(dataDir, { recursive: true });\r\n this.file = join(dataDir, \"devices.json\");\r\n this.load();\r\n }\r\n\r\n private load() {\r\n if (!existsSync(this.file)) return;\r\n try {\r\n const raw = JSON.parse(readFileSync(this.file, \"utf8\")) as { devices?: DeviceRecord[] };\r\n for (const d of raw.devices ?? []) {\r\n if (typeof d?.deviceId === \"string\" && typeof d?.tokenHash === \"string\") {\r\n this.devices.set(d.deviceId, { ...d, name: d.name ?? \"unknown\", model: d.model ?? \"\", firstPairedAt: d.firstPairedAt ?? Date.now(), lastSeenAt: d.lastSeenAt ?? 0 });\r\n }\r\n }\r\n } catch {\r\n // Corrupt registry: start empty rather than refuse to boot.\r\n }\r\n }\r\n\r\n private persist() {\r\n const tmp = `${this.file}.tmp`;\r\n writeFileSync(tmp, JSON.stringify({ version: 1, devices: [...this.devices.values()] }, null, 2), \"utf8\");\r\n renameSync(tmp, this.file);\r\n }\r\n\r\n /** Register/re-register a device, uniquifying the name against others. */\r\n upsert(record: Omit<DeviceRecord, \"firstPairedAt\" | \"lastSeenAt\">): DeviceRecord {\r\n const prior = this.devices.get(record.deviceId);\r\n const full: DeviceRecord = {\r\n ...record,\r\n name: this.uniquifyName(record.deviceId, record.name),\r\n firstPairedAt: prior?.firstPairedAt ?? Date.now(),\r\n lastSeenAt: Date.now(),\r\n };\r\n this.devices.set(record.deviceId, full);\r\n this.dirty = true;\r\n this.persist();\r\n return full;\r\n }\r\n\r\n /** Make a display name unique among other devices (x, x (2), x (3), …). */\r\n private uniquifyName(deviceId: string, name: string): string {\r\n const others = [...this.devices.values()].map((d) => d.name).filter((n, i, arr) => arr.indexOf(n) === i);\r\n const base = name.trim() || \"Android 设备\";\r\n if (!others.includes(base)) return base;\r\n for (let n = 2; ; n++) {\r\n const candidate = `${base} (${n})`;\r\n if (!others.includes(candidate)) return candidate;\r\n }\r\n }\r\n\r\n /** Rename a device (user-editable in the web UI); returns the updated record. */\r\n rename(deviceId: string, name: string): DeviceRecord | null {\r\n const record = this.devices.get(deviceId);\r\n if (!record) return null;\r\n record.name = name.trim() || record.name;\r\n this.dirty = true;\r\n this.persist();\r\n return record;\r\n }\r\n\r\n /** Authenticate deviceId + token; refreshes lastSeenAt on success. */\r\n authenticate(deviceId: string, token: string): DeviceRecord | null {\r\n const record = this.devices.get(deviceId);\r\n if (!record) return null;\r\n if (!tokenHashMatches(record.tokenHash, token)) return null;\r\n record.lastSeenAt = Date.now();\r\n this.dirty = true;\r\n return record;\r\n }\r\n\r\n remove(deviceId: string): boolean {\r\n const had = this.devices.delete(deviceId);\r\n if (had) this.persist();\r\n return had;\r\n }\r\n\r\n list(): DeviceRecord[] {\r\n return [...this.devices.values()].sort((a, b) => b.lastSeenAt - a.lastSeenAt);\r\n }\r\n\r\n count(): number {\r\n return this.devices.size;\r\n }\r\n}\r\n","import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\n\n/** How an uploaded photo reaches the user (user-selected in the web UI settings). */\nexport type SaveMode = \"composer\" | \"folder\" | \"both\";\n\nexport const SAVE_MODES: readonly SaveMode[] = [\"composer\", \"folder\", \"both\"];\n\nexport interface AppSettings {\n /** composer = stage into the chat box draft; folder = save to saveDir; both = both. */\n saveMode: SaveMode;\n /** Absolute target folder for folder/both modes. Empty = built-in default. */\n saveDir: string;\n}\n\n/**\n * User-facing capture settings, persisted as JSON under the plugin data dir.\n * Stored host-side on purpose: the folder save happens on this machine, and\n * every loopback view (overlay, /view.html) must see the same mode.\n */\nexport class AppSettingsStore {\n private data: AppSettings;\n\n constructor(\n /** e.g. ~/.dsh/phone-lens/settings.json */\n private readonly file: string,\n /** Built-in default folder when saveDir is empty. */\n private readonly defaultSaveDir: string,\n ) {\n this.data = { saveMode: \"composer\", saveDir: \"\" };\n this.load();\n }\n\n private load(): void {\n if (!existsSync(this.file)) return;\n try {\n const raw = JSON.parse(readFileSync(this.file, \"utf8\")) as Partial<AppSettings>;\n if (SAVE_MODES.includes(raw.saveMode as SaveMode)) this.data.saveMode = raw.saveMode as SaveMode;\n if (typeof raw.saveDir === \"string\") this.data.saveDir = raw.saveDir;\n } catch {\n // corrupt settings file: start with defaults rather than refuse to boot\n }\n }\n\n get(): AppSettings {\n return { ...this.data };\n }\n\n /** Effective absolute folder (resolved default when unset). */\n effectiveSaveDir(): string {\n return this.data.saveDir.trim() || this.defaultSaveDir;\n }\n\n set(patch: Partial<AppSettings>): AppSettings {\n if (patch.saveMode && SAVE_MODES.includes(patch.saveMode)) this.data.saveMode = patch.saveMode;\n if (typeof patch.saveDir === \"string\") {\n // strip wrapping quotes (right-click \"copy path\" often adds them)\n this.data.saveDir = patch.saveDir.replace(/^[\"']|[\"']$/g, \"\").trim();\n }\n this.persist();\n return this.get();\n }\n\n private persist(): void {\n try {\n mkdirSync(join(this.file, \"..\"), { recursive: true });\n const tmp = `${this.file}.tmp`;\n writeFileSync(tmp, JSON.stringify(this.data, null, 2), \"utf8\");\n renameSync(tmp, this.file);\n } catch {\n // persistence is best-effort; runtime keeps working with in-memory values\n }\n }\n}\n","/** Shared vocabulary for the phone-lens host half. */\r\n\r\n/** Normalized plugin config (see cordis.patch.yml / config.ts defaults). */\r\nexport interface LensConfig {\r\n server: { host: string; port: number };\r\n limits: {\r\n maxUploadBytes: number;\r\n allowedTypes: string[];\r\n previewFrameMaxBytes: number;\r\n maxStoredUploads: number;\r\n };\r\n pairing: { codeTtlMs: number };\r\n preview: { maxWidth: number; maxHeight: number; fps: number; jpegQuality: number };\r\n inject: { mode: \"followup\" | \"steer\"; notePrefix: string };\r\n target: { mode: \"latest\" | \"pinned\"; pinnedSessionId: string | null };\r\n /** Where the phone App APK can be fetched from (QR + fallback links). */\r\n app: { downloadUrl: string; giteeUrl: string; githubUrl: string };\r\n}\r\n\r\n/** Minimal structural view of a dsh ImageAttachmentRef (docs/dsh-caps.md Q1). */\r\nexport interface ImageAttachmentRef {\r\n attachmentId: string;\r\n mediaType: string;\r\n bytes: number;\r\n width: number;\r\n height: number;\r\n name?: string;\r\n originalDimensions?: { width: number; height: number };\r\n}\r\n\r\n/** One durable image ready for session injection. */\r\nexport interface AdmittedImage {\r\n ref: ImageAttachmentRef;\r\n /** Where the bytes physically landed: \"attachments\" (dsh store) or \"file\" (fallback dir). */\r\n storage: \"attachments\" | \"file\";\r\n /** Fallback file path when storage === \"file\". */\r\n filePath?: string;\r\n}\r\n\r\n/** Result of the deliver step. */\r\nexport interface DeliveryReceipt {\r\n ok: boolean;\r\n sessionId: string | null;\r\n mode: \"followup\" | \"steer\" | \"none\";\r\n reason?: string;\r\n}\r\n\r\n/** Persistent paired-device record. The raw token never touches disk — only its SHA-256. */\r\nexport interface DeviceRecord {\r\n deviceId: string;\r\n name: string;\r\n model: string;\r\n tokenHash: string;\r\n firstPairedAt: number;\r\n lastSeenAt: number;\r\n}\r\n\r\n/** Live pairing-code state. */\r\nexport interface PairingState {\r\n code: string;\r\n createdAt: number;\r\n expiresAt: number;\r\n used: boolean;\r\n}\r\n\r\n/** JSON message on the camera websocket (text frames). */\r\nexport type CameraControl =\r\n | { type: \"hello\"; width: number; height: number; fps: number; rotation?: number }\r\n | { type: \"bye\" }\r\n | { type: \"set_preview\"; maxWidth: number; maxHeight: number; fps: number; jpegQuality: number }\r\n | { type: \"capture\"; captureId: string; note?: string }\r\n | { type: \"capture_result\"; captureId: string; status: \"taken\" | \"declined\" | \"failed\"; detail?: string }\r\n // phone→host: make THIS phone the active preview/shutter device\r\n | { type: \"claim_active\" }\r\n // app-level keepalive (both directions): the phone pings every 10s and the\r\n // host answers pong — a half-open TCP (wifi switch, host reboot) would\r\n // otherwise stay \"connected\" for OS-internal timeouts on both sides\r\n | { type: \"ping\" }\r\n | { type: \"pong\" }\r\n // host→phone: another device owns the PC preview; stop/start streaming\r\n | { type: \"pause_preview\" }\r\n | { type: \"resume_preview\" };\r\n\r\n/** JSON message on the view websocket. */\r\nexport type ViewServerMessage =\r\n | { type: \"meta\"; camera: { connected: boolean; width?: number; height?: number; fps?: number; rotation?: number; name?: string }; preview: { maxWidth: number; maxHeight: number; fps: number; jpegQuality: number }; paired: boolean }\r\n | { type: \"frame_meta\"; width: number; height: number; rotation?: number }\r\n | { type: \"devices\"; devices: { id: string; name: string; active: boolean }[] }\r\n | { type: \"device\"; online: boolean; name?: string }\r\n // host→view: the ACTIVE phone's stream stalled (user turned preview off on\r\n // the phone) or resumed. `on:false` lets the web UI clear the last frame.\r\n | { type: \"preview_state\"; on: boolean }\r\n | { type: \"capture_pending\"; captureId: string; note?: string }\r\n | { type: \"pending_image\"; attachmentId: string; name?: string }\r\n // folder-only save: the photo bypassed the composer; surface a local hint.\r\n | { type: \"upload_saved\"; name?: string; dir: string }\r\n | { type: \"injected\"; captureId?: string; attachmentId: string; sessionId: string | null; ok: boolean; reason?: string; name?: string }\r\n | { type: \"upload\"; attachmentId: string; storage: string; name?: string }\r\n | { type: \"error\"; code: string; message?: string };\r\n\r\nexport type ViewClientMessage =\r\n | { type: \"capture\"; note?: string }\r\n | { type: \"select_device\"; deviceId: string }\r\n | { type: \"rename_device\"; deviceId: string; name: string }\r\n | { type: \"refresh_pairing\" }\r\n | { type: \"ping\" };\r\n\r\n/** Wire error body. */\r\nexport interface ErrorBody {\r\n error: { code: string; message: string };\r\n}\r\n\r\nexport const ERROR_CODES = {\r\n AUTH_REQUIRED: \"AUTH_REQUIRED\",\r\n PAIR_CODE_INVALID: \"PAIR_CODE_INVALID\",\r\n PAIR_CODE_EXPIRED: \"PAIR_CODE_EXPIRED\",\r\n RATE_LIMITED: \"RATE_LIMITED\",\r\n TYPE_NOT_ALLOWED: \"TYPE_NOT_ALLOWED\",\r\n TOO_LARGE: \"TOO_LARGE\",\r\n BAD_MAGIC: \"BAD_MAGIC\",\r\n STORE_FAILED: \"STORE_FAILED\",\r\n NO_TARGET: \"NO_TARGET\",\r\n NO_CAMERA: \"NO_CAMERA\",\r\n CAPTURE_TIMEOUT: \"CAPTURE_TIMEOUT\",\r\n LOOPBACK_ONLY: \"LOOPBACK_ONLY\",\r\n BAD_REQUEST: \"BAD_REQUEST\",\r\n INTERNAL: \"INTERNAL\",\r\n} as const;\r\n","import { createHash } from \"node:crypto\";\r\nimport { mkdirSync, writeFileSync } from \"node:fs\";\r\nimport { join } from \"node:path\";\r\nimport type { AdmittedImage } from \"../types.js\";\r\n\r\n/** Structural type of the dsh attachment store we consume (docs/dsh-caps.md Q1). */\r\nexport interface AttachmentStoreLike {\r\n saveImages(inputs: ReadonlyArray<{ data: Uint8Array; mediaType: string; name?: string }>): Promise<readonly AdmittedImage[\"ref\"][]>;\r\n}\r\n\r\n/** Magic-byte whitelist check — never trust Content-Type alone. */\r\nexport function magicMatches(mediaType: string, head: Buffer): boolean {\r\n if (mediaType === \"image/jpeg\") return head.length >= 3 && head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff;\r\n if (mediaType === \"image/png\") return head.length >= 4 && head[0] === 0x89 && head[1] === 0x50 && head[2] === 0x4e && head[3] === 0x47;\r\n return false;\r\n}\r\n\r\n/**\r\n * Persist one image through the durable attachment seam.\r\n * Falls back to a content-addressed file when the dsh attachments service is\r\n * not mounted (standalone dev / unusual profile), so uploads never vanish.\r\n */\r\nexport async function admitImage(\r\n input: { data: Uint8Array; mediaType: string; name: string },\r\n attachments: AttachmentStoreLike | undefined,\r\n fallbackDir: string,\r\n): Promise<AdmittedImage> {\r\n if (attachments) {\r\n const refs = await attachments.saveImages([{ data: input.data, mediaType: input.mediaType, name: input.name }]);\r\n const ref = refs[0];\r\n if (!ref) throw new Error(\"attachment store returned no reference\");\r\n return { ref, storage: \"attachments\" };\r\n }\r\n const ext = input.mediaType === \"image/png\" ? \"png\" : \"jpg\";\r\n const digest = createHash(\"sha1\").update(input.data).digest(\"hex\").slice(0, 16);\r\n mkdirSync(fallbackDir, { recursive: true });\r\n const filePath = join(fallbackDir, `${digest}-${input.name.replace(/[^\\w.-]+/g, \"_\") || \"image\"}.${ext}`);\r\n writeFileSync(filePath, input.data);\r\n return {\r\n ref: {\r\n attachmentId: `file:${digest}`,\r\n mediaType: input.mediaType,\r\n bytes: input.data.byteLength,\r\n width: 0,\r\n height: 0,\r\n name: input.name,\r\n },\r\n storage: \"file\",\r\n filePath,\r\n };\r\n}\r\n","/**\n * Dynamic APK-link resolution for the download QR.\n *\n * Gitee has no GitHub-style `releases/latest/download/<file>` short link (it\n * 302s to /repository/archive/ and 404s), so a hardcoded asset URL goes stale\n * the moment a new release ships — v1.0.0 shipped with a v0.3.11 link exactly\n * this way. Instead of bumping a constant on every release, ask Gitee's public\n * latest-release API for the current asset URL and cache it; the constant in\n * config.ts is only the offline fallback and needs no per-release bump.\n */\nimport { GITEE_APK_DEFAULT } from \"../config.js\";\nimport type { LensConfig } from \"../types.js\";\n\nconst GITEE_LATEST_API = \"https://gitee.com/api/v5/repos/qianfengbingtang/phone-lens/releases/latest\";\nconst CACHE_TTL_MS = 10 * 60 * 1000;\nconst REQUEST_TIMEOUT_MS = 6000;\n\nlet cached: { url: string; at: number } | null = null;\nlet inflight: Promise<string | null> | null = null;\n\n/** The asset URL comes from an external API response, so re-validate it before\n * it ever reaches a QR code: https only, and only the expected release host —\n * never localhost/loopback/private addresses or any other origin. */\nfunction isTrustedGiteeAsset(raw: unknown): raw is string {\n if (typeof raw !== \"string\" || !raw.startsWith(\"https://\")) return false;\n try {\n const u = new URL(raw);\n return u.protocol === \"https:\" && u.host === \"gitee.com\";\n } catch {\n return false;\n }\n}\n\nasync function fetchLatestGiteeApk(): Promise<string | null> {\n const ctrl = new AbortController();\n const timer = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS);\n try {\n const resp = await fetch(GITEE_LATEST_API, { signal: ctrl.signal, headers: { accept: \"application/json\" } });\n if (!resp.ok) return null;\n const data: unknown = await resp.json();\n const assets = (data as { assets?: unknown } | null)?.assets;\n if (!Array.isArray(assets)) return null;\n for (const asset of assets) {\n const row = asset as { name?: unknown; browser_download_url?: unknown };\n if (row.name === \"app-release.apk\" && isTrustedGiteeAsset(row.browser_download_url)) {\n return row.browser_download_url;\n }\n }\n return null;\n } catch {\n return null; // network error / timeout / malformed body — caller falls back\n } finally {\n clearTimeout(timer);\n }\n}\n\n/** Resolve the latest APK URL (cached 10 min, in-flight deduped).\n * Resolves null when the API is unreachable or carries no trusted asset. */\nexport function resolveLatestGiteeApk(): Promise<string | null> {\n if (cached && Date.now() - cached.at < CACHE_TTL_MS) return Promise.resolve(cached.url);\n if (inflight) return inflight;\n inflight = fetchLatestGiteeApk()\n .then((url) => {\n if (url) cached = { url, at: Date.now() };\n return url;\n })\n .finally(() => {\n inflight = null;\n });\n return inflight;\n}\n\n/**\n * Refresh config.app download links in place, but only while they still carry\n * the built-in defaults — an explicit user config always wins. Mutating the\n * shared config object lets the QR endpoints keep reading the plain fields.\n */\nexport async function refreshAppApkLinks(config: LensConfig): Promise<void> {\n if (config.app.giteeUrl !== GITEE_APK_DEFAULT) return;\n const url = await resolveLatestGiteeApk();\n if (!url) return;\n config.app.giteeUrl = url;\n if (config.app.downloadUrl === GITEE_APK_DEFAULT) config.app.downloadUrl = url;\n}\n","import * as os from \"node:os\";\r\nimport QRCode from \"qrcode\";\r\nimport type { LensConfig } from \"../types.js\";\r\n\r\nexport interface LanAddress {\r\n ip: string;\r\n iface: string;\r\n}\r\n\r\n/** Enumerate LAN IPv4 candidates: private ranges first, virtual/tethering included. */\r\nexport function lanAddresses(): LanAddress[] {\r\n const out: LanAddress[] = [];\r\n for (const [iface, addrs] of Object.entries(os.networkInterfaces())) {\r\n for (const addr of addrs ?? []) {\r\n if (addr.family !== \"IPv4\" || addr.internal) continue;\r\n out.push({ ip: addr.address, iface });\r\n }\r\n }\r\n const rank = (ip: string): number => {\r\n if (ip.startsWith(\"192.168.\")) return 0; // typical Wi-Fi + Android USB tethering\r\n if (ip.startsWith(\"172.\")) return 1; // container/iPhone-USB ranges\r\n if (ip.startsWith(\"10.\")) return 2;\r\n return 3;\r\n };\r\n return out.sort((a, b) => rank(a.ip) - rank(b.ip) || a.ip.localeCompare(b.ip));\r\n}\r\n\r\nexport interface PairingQr {\r\n code: string;\r\n expiresAt: number;\r\n payload: string;\r\n urls: string[];\r\n pngDataUrl: string;\r\n ascii: string;\r\n}\r\n\r\n/** Build the full pairing payload + rendered QR (data URL for browsers, ASCII for terminals). */\r\nexport async function buildPairingQr(code: string, expiresAt: number, config: LensConfig, originOverride?: string): Promise<PairingQr> {\r\n const addrs = lanAddresses();\r\n const host = originOverride ?? addrs[0]?.ip ?? \"127.0.0.1\";\r\n const port = config.server.port;\r\n const payload = `lensmate://pair?v=1&host=${encodeURIComponent(host)}&port=${port}&code=${code}`;\r\n const urls = (addrs.length > 0 ? addrs : [{ ip: \"127.0.0.1\", iface: \"loopback\" }]).map((a) => `http://${a.ip}:${port}`);\r\n const [pngDataUrl, ascii] = await Promise.all([\r\n QRCode.toDataURL(payload, { errorCorrectionLevel: \"M\", margin: 2, width: 320 }),\r\n QRCode.toString(payload, { type: \"terminal\", small: true }),\r\n ]);\r\n return { code, expiresAt, payload, urls, pngDataUrl, ascii };\r\n}\r\n","/**\r\n * The standalone fallback viewfinder page, served at /view.html (loopback only).\r\n * Same protocol as the future Web UI overlay: WS /ws/view frames + capture control.\r\n * Picture-in-Picture turns it into a true always-on-top mini window.\r\n */\r\nexport const VIEW_HTML = `<!doctype html>\r\n<html lang=\"zh-CN\">\r\n<head>\r\n<meta charset=\"utf-8\">\r\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\r\n<title>PhoneLens 直连取景</title>\r\n<style>\r\n :root { color-scheme: dark; }\r\n * { box-sizing: border-box; }\r\n body { margin: 0; font: 13px/1.5 system-ui, \"Segoe UI\", \"Microsoft YaHei\", sans-serif;\r\n background: #101418; color: #dfe7ee; display: flex; flex-direction: column; height: 100vh; }\r\n header { display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: #171d24; border-bottom: 1px solid #232c36; }\r\n header h1 { font-size: 13px; margin: 0; font-weight: 600; }\r\n #dot { width: 9px; height: 9px; border-radius: 50%; background: #e5484d; transition: background .2s; }\r\n #dot.on { background: #46a758; }\r\n #fps { margin-left: auto; color: #7d8da0; font-variant-numeric: tabular-nums; }\r\n main { flex: 1; display: flex; min-height: 0; }\r\n #stage { flex: 1; display: flex; align-items: center; justify-content: center; background: #0b0e11; position: relative; min-width: 0; }\r\n canvas { max-width: 100%; max-height: 100%; object-fit: contain; background: #000; }\r\n #hint { position: absolute; color: #7d8da0; }\r\n aside { width: 240px; border-left: 1px solid #232c36; padding: 12px; overflow: auto; }\r\n aside h2 { font-size: 12px; margin: 0 0 8px; color: #9fb0c3; text-transform: uppercase; letter-spacing: .08em; }\r\n #qrimg { width: 100%; image-rendering: pixelated; background: #fff; padding: 8px; border-radius: 8px; }\r\n .row { display: flex; gap: 6px; align-items: center; margin: 6px 0; }\r\n .row code { background: #1d242d; padding: 2px 8px; border-radius: 6px; letter-spacing: .12em; font-size: 15px; }\r\n .row a { color: #3b7cb5; text-decoration: none; }\r\n .row a:hover { text-decoration: underline; }\r\n button { background: #1f2937; border: 1px solid #2f3b48; color: #dfe7ee; padding: 6px 10px; border-radius: 8px; cursor: pointer; font-size: 12px; }\r\n button:hover { background: #28323e; }\r\n button:disabled { opacity: .45; cursor: default; }\r\n #shoot { padding: 10px; font-size: 14px; background: #2a5d8f; border-color: #3b7cb5; }\r\n #pip { display: none; }\r\n #log { margin-top: 10px; font-size: 12px; color: #8fa1b5; max-height: 160px; overflow: auto; }\r\n #log div { padding: 2px 0; border-bottom: 1px dashed #202832; }\r\n #log .ok { color: #58b368; } #log .err { color: #e5696e; }\r\n</style>\r\n</head>\r\n<body>\r\n<header>\r\n <div id=\"dot\"></div><h1>PhoneLens 直连取景</h1>\r\n <span id=\"dev\"></span>\r\n <span id=\"fps\"></span><span id=\"age\"></span>\r\n</header>\r\n<main>\r\n <div id=\"stage\"><canvas width=\"854\" height=\"480\"></canvas><div id=\"hint\">等待手机连接…</div></div>\r\n <aside>\r\n <h2>配对</h2>\r\n <img id=\"qrimg\" alt=\"配对二维码\">\r\n <div class=\"row\"><code id=\"paircode\"></code><button id=\"refresh\">刷新</button></div>\r\n <div class=\"row\" id=\"urls\"></div>\r\n <div class=\"row\"><a href=\"https://github.com/yxqfg/phone-lens/releases/latest/download/app-release.apk\" target=\"_blank\" rel=\"noopener\">📱 手机还没装 App?点此下载(Android APK)</a></div>\r\n <h2 style=\"margin-top:14px\">快门</h2>\r\n <div class=\"row\">\r\n <button id=\"shoot\" disabled>◉ 拍照并注入</button>\r\n <button id=\"pip\">置顶小窗</button>\r\n </div>\r\n <div id=\"log\"></div>\r\n </aside>\r\n</main>\r\n<video id=\"pipvid\" muted playsinline style=\"display:none\"></video>\r\n<script>\r\n'use strict';\r\nconst $ = (id) => document.getElementById(id);\r\nconst canvas = $('stage').querySelector('canvas');\r\nconst ctx2d = canvas.getContext('2d');\r\nconst logEl = $('log');\r\nlet lastFrameAt = 0, frames = 0, fpsTimer = 0, pendingShot = null;\r\nlet streamRotation = 0;\r\n\r\nfunction log(msg, cls) {\r\n const d = document.createElement('div');\r\n if (cls) d.className = cls;\r\n d.textContent = new Date().toLocaleTimeString() + ' ' + msg;\r\n logEl.prepend(d);\r\n while (logEl.childElementCount > 50) logEl.lastChild.remove();\r\n}\r\n\r\nasync function refreshQr() {\r\n try {\r\n const r = await fetch('/qr.json');\r\n const j = await r.json();\r\n $('qrimg').src = j.pngDataUrl;\r\n $('paircode').textContent = j.code;\r\n $('urls').innerHTML = (j.urls || []).map(u => '<span style=\"color:#7d8da0\">' + u + '</span>').join('<br>');\r\n } catch (e) { log('二维码加载失败: ' + e.message, 'err'); }\r\n}\r\n$('refresh').onclick = () => { wsSend({ type: 'refresh_pairing' }); setTimeout(refreshQr, 150); };\r\n\r\nfunction drawFrame(buf) {\r\n const blob = new Blob([buf], { type: 'image/jpeg' });\r\n const url = URL.createObjectURL(blob);\r\n const img = new Image();\r\n img.onload = () => {\r\n const r = ((streamRotation % 360) + 360) % 360;\r\n const swap = r === 90 || r === 270;\r\n const w = swap ? img.height : img.width;\r\n const h = swap ? img.width : img.height;\r\n if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }\r\n ctx2d.save();\r\n ctx2d.translate(w / 2, h / 2);\r\n ctx2d.rotate(r * Math.PI / 180);\r\n ctx2d.drawImage(img, -img.width / 2, -img.height / 2);\r\n ctx2d.restore();\r\n URL.revokeObjectURL(url);\r\n $('hint').style.display = 'none';\r\n frames++;\r\n const now = performance.now();\r\n if (now - fpsTimer >= 1000) { $('fps').textContent = (frames * 1000 / (now - fpsTimer)).toFixed(1) + ' fps'; frames = 0; fpsTimer = now; }\r\n lastFrameAt = now;\r\n };\r\n img.src = url;\r\n}\r\n\r\nlet ws = null;\r\nfunction connect() {\r\n const proto = location.protocol === 'https:' ? 'wss' : 'ws';\r\n ws = new WebSocket(proto + '://' + location.host + '/ws/view');\r\n ws.binaryType = 'arraybuffer';\r\n ws.onopen = () => { log('已连接'); refreshQr(); };\r\n ws.onclose = () => { $('dot').className = ''; setTimeout(connect, 1500); };\r\n ws.onerror = () => {};\r\n ws.onmessage = (ev) => {\r\n if (ev.data instanceof ArrayBuffer) { drawFrame(ev.data); return; }\r\n const m = JSON.parse(ev.data);\r\n switch (m.type) {\r\n case 'meta':\r\n $('dot').className = m.camera.connected ? 'on' : '';\r\n $('dev').textContent = m.camera.name || (m.camera.connected ? '已连接' : '未连接');\r\n $('shoot').disabled = !m.camera.connected;\r\n if (m.camera.rotation !== undefined) streamRotation = m.camera.rotation;\r\n $('hint').style.display = m.camera.connected ? 'none' : '';\r\n break;\r\n case 'frame_meta':\r\n if (m.rotation !== undefined) streamRotation = m.rotation;\r\n break;\r\n case 'device':\r\n $('dot').className = m.online ? 'on' : '';\r\n $('dev').textContent = m.online ? (m.name || '已连接') : '未连接';\r\n $('shoot').disabled = !m.online;\r\n $('hint').style.display = m.online ? 'none' : '';\r\n $('hint').textContent = '等待手机连接…';\r\n break;\r\n case 'capture_pending':\r\n pendingShot = m.captureId;\r\n log('快门已触发' + (m.note ? '(' + m.note + ')' : '') + '…');\r\n break;\r\n case 'injected':\r\n pendingShot = null;\r\n if (m.ok) log('已注入会话 ' + (m.sessionId || '').slice(0, 8) + (m.name ? ' · ' + m.name : ''), 'ok');\r\n else log('入库成功,注入未完成: ' + (m.reason || ''), 'err');\r\n break;\r\n case 'upload':\r\n log('图片已入库 ' + (m.name || ''), 'ok');\r\n break;\r\n case 'error':\r\n log('错误 ' + m.code + (m.message ? ': ' + m.message : ''), 'err');\r\n break;\r\n }\r\n };\r\n}\r\nfunction wsSend(obj) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); }\r\n\r\n$('shoot').onclick = () => wsSend({ type: 'capture', note: '' });\r\n\r\n// PiP: mirror the canvas into a video stream, then request Picture-in-Picture.\r\n$('pip').onclick = async () => {\r\n try {\r\n const stream = canvas.captureStream(10);\r\n const v = $('pipvid');\r\n v.srcObject = stream;\r\n await v.play();\r\n await v.requestPictureInPicture();\r\n } catch (e) { log('画中画失败: ' + e.message, 'err'); }\r\n};\r\n\r\nsetInterval(() => {\r\n if (document.pictureInPictureElement) return;\r\n if (performance.now() - lastFrameAt > 4000 && lastFrameAt > 0) $('hint').style.display = '';\r\n $('age').textContent = lastFrameAt > 0 ? ' · ' + ((performance.now() - lastFrameAt) / 1000).toFixed(1) + 's前' : '';\r\n}, 1000);\r\nif (document.pictureInPictureEnabled) $('pip').style.display = '';\r\n\r\nconnect();\r\n</script>\r\n</body>\r\n</html>\r\n`;\r\n","import type { IncomingMessage } from \"node:http\";\r\n\r\n/** Sliding-window rate limiter (in-memory; resets with the process). */\r\nexport class RateLimiter {\r\n private hits = new Map<string, number[]>();\r\n\r\n constructor(private readonly windowMs: number, private readonly max: number) {}\r\n\r\n /** Record one hit and report whether the key is still within budget. */\r\n allow(key: string): boolean {\r\n const now = Date.now();\r\n const cutoff = now - this.windowMs;\r\n const list = (this.hits.get(key) ?? []).filter((t) => t > cutoff);\r\n if (list.length >= this.max) {\r\n this.hits.set(key, list);\r\n return false;\r\n }\r\n list.push(now);\r\n this.hits.set(key, list);\r\n return true;\r\n }\r\n}\r\n\r\nconst LOOPBACK = new Set([\"127.0.0.1\", \"::1\", \"::ffff:127.0.0.1\"]);\r\n\r\n/** Whether the connection originates from this machine (loopback). */\r\nexport function isLoopback(req: IncomingMessage): boolean {\r\n const addr = req.socket.remoteAddress ?? \"\";\r\n return LOOPBACK.has(addr);\r\n}\r\n\r\n/**\r\n * CORS for the dsh Web UI page (served on another loopback port, or the\r\n * desktop shell's `dsh-app://` origin) so it can fetch /qr.json and /status\r\n * from this receiver. Only same-machine origins are echoed — a foreign page\r\n * must not read the pairing code, and its cross-origin JSON POST fails the\r\n * preflight we never answer. `dsh-app:` is a privileged scheme registered by\r\n * the desktop shell; only the shell's own renderer can ever present it.\r\n */\r\nexport function corsFor(req: IncomingMessage): Record<string, string> {\r\n const origin = req.headers.origin;\r\n if (typeof origin !== \"string\" || origin === \"\") return {};\r\n try {\r\n const u = new URL(origin);\r\n const host = u.hostname.replace(/^\\[|\\]$/g, \"\");\r\n if ((u.protocol === \"http:\" && LOOPBACK.has(host)) || u.protocol === \"dsh-app:\") {\r\n return {\r\n \"access-control-allow-origin\": origin,\r\n \"access-control-allow-methods\": \"GET, POST, OPTIONS\",\r\n \"access-control-allow-headers\": \"content-type, x-lm-device, x-lm-token\",\r\n \"access-control-max-age\": \"600\",\r\n };\r\n }\r\n } catch {\r\n /* malformed origin: no CORS headers */\r\n }\r\n return {};\r\n}\r\n\r\n/** Extract a stable client key for rate limiting. */\r\nexport function clientKey(req: IncomingMessage): string {\r\n return req.socket.remoteAddress ?? \"unknown\";\r\n}\r\n\r\n/** Read the device auth headers, if present. */\r\nexport function deviceAuth(req: IncomingMessage): { deviceId: string; token: string } | null {\r\n const deviceId = req.headers[\"x-lm-device\"];\r\n const token = req.headers[\"x-lm-token\"];\r\n if (typeof deviceId !== \"string\" || typeof token !== \"string\" || !deviceId || !token) return null;\r\n return { deviceId, token };\r\n}\r\n\r\n/** Query-string parser (URLSearchParams handles plus-encoding for notes). */\r\nexport function queryOf(req: IncomingMessage): URLSearchParams {\r\n const url = new URL(req.url ?? \"/\", \"http://phone-lens.local\");\r\n return url.searchParams;\r\n}\r\n","import { randomUUID } from \"node:crypto\";\r\nimport type { IncomingMessage, Server as HttpServer, ServerResponse } from \"node:http\";\r\nimport { createServer } from \"node:http\";\r\nimport { existsSync, mkdirSync, readFileSync, writeFileSync } from \"node:fs\";\r\nimport { readFile, readdir, stat, unlink } from \"node:fs/promises\";\r\nimport { basename, join, resolve, sep } from \"node:path\";\r\nimport type { Duplex } from \"node:stream\";\r\nimport { WebSocketServer, type WebSocket } from \"ws\";\r\nimport QRCode from \"qrcode\";\r\nimport type { DeliveryReceipt, LensConfig } from \"../types.js\";\r\nimport { ERROR_CODES } from \"../types.js\";\r\nimport type { DeliverySink } from \"../inject/deliver.js\";\r\nimport { admitImage, magicMatches, type AttachmentStoreLike } from \"../inject/admit.js\";\r\nimport type { TargetTracker } from \"../inject/target.js\";\r\nimport type { DeviceStore } from \"../store/devices.js\";\r\nimport { hashToken, mintDeviceToken, type PairingStore } from \"../store/pairing.js\";\r\nimport { SAVE_MODES, type AppSettingsStore, type SaveMode } from \"../store/settings.js\";\r\nimport type { ViewHub } from \"./hub.js\";\r\nimport { refreshAppApkLinks, resolveLatestGiteeApk } from \"./apk-link.js\";\r\nimport { buildPairingQr } from \"./qr.js\";\r\nimport { VIEW_HTML } from \"./static-view.js\";\r\nimport { clientKey, corsFor, deviceAuth, isLoopback, queryOf, RateLimiter } from \"./auth.js\";\r\n\r\nexport interface ServerDeps {\r\n config: LensConfig;\r\n pairing: PairingStore;\r\n devices: DeviceStore;\r\n hub: ViewHub;\r\n targets: TargetTracker;\r\n sink: DeliverySink;\r\n /** Read the attachment store lazily — the service may mount after us. */\r\n attachments: () => AttachmentStoreLike | undefined;\r\n /** Fallback dir for uploads when the attachment service is absent. */\r\n fallbackDir: string;\r\n /** Browser-fetchable staging dir for images awaiting user send (composer pre-send). */\r\n pendingDir: string;\r\n /** User-selected capture mode + folder target (web UI settings). */\r\n appSettings: AppSettingsStore;\r\n log: (level: \"info\" | \"warn\" | \"error\", msg: string) => void;\r\n}\r\n\r\nexport interface LensServerHandle {\r\n port: number;\r\n dispose: () => Promise<void>;\r\n}\r\n\r\n/** Process-lifetime marker: surfaces service rebuilds via /status. */\r\nconst SERVER_BOOT_AT = Date.now();\r\n\r\n/** Boot the receiver: HTTP routes + two websocket endpoints. */\r\n// Report our own package version without a hardcoded literal (the old literal\r\n// went stale across releases). Resolved once from ../package.json — the bundle\r\n// lives in lib/, so that resolves to the package root in every install form.\r\nlet ownVersion: string | null = null;\r\nfunction hostVersion(): string {\r\n if (ownVersion) return ownVersion;\r\n try {\r\n ownVersion = String(JSON.parse(readFileSync(new URL(\"../package.json\", import.meta.url), \"utf8\")).version ?? \"0.0.0\");\r\n } catch {\r\n ownVersion = \"0.0.0\";\r\n }\r\n return ownVersion;\r\n}\r\n\r\nexport async function startLensServer(deps: ServerDeps): Promise<LensServerHandle> {\r\n const { config, pairing, devices, hub, targets, sink, log } = deps;\r\n const pairLimiter = new RateLimiter(60_000, 10);\r\n // POST /upload idempotency: the phone's upload queue retries with a stable\r\n // uploadId, and a retry after a lost response must not store/inject twice.\r\n const uploadReplays = new Map<string, { expires: number; body: unknown }>();\r\n const injectionBox: { last: { at: number; sessionId: string | null; attachmentId: string; ok: boolean } | null } = { last: null };\r\n\r\n const server: HttpServer = createServer((req, res) => {\r\n handle(deps, req, res, {\r\n pairLimiter,\r\n uploadReplays,\r\n noteInjection: (receipt, attachmentId) => {\r\n injectionBox.last = { at: Date.now(), sessionId: receipt.sessionId, attachmentId, ok: receipt.ok };\r\n },\r\n getLastInjection: () => injectionBox.last,\r\n }).catch((error) => {\r\n log(\"error\", `request failed: ${String(error)}`);\r\n if (!res.headersSent) sendJson(res, 500, { error: { code: ERROR_CODES.INTERNAL, message: String(error) } });\r\n else res.destroy();\r\n });\r\n });\r\n\r\n const wss = new WebSocketServer({ noServer: true, maxPayload: config.limits.previewFrameMaxBytes * 4 });\r\n server.on(\"upgrade\", (req: IncomingMessage, socket: Duplex, head: Buffer) => {\r\n const url = new URL(req.url ?? \"/\", \"http://phone-lens.local\");\r\n const done = (status: number, message: string) => {\r\n socket.write(`HTTP/1.1 ${status} ${message}\\r\\nConnection: close\\r\\n\\r\\n`);\r\n socket.destroy();\r\n };\r\n if (url.pathname === \"/ws/camera\") {\r\n const q = url.searchParams;\r\n const deviceId = q.get(\"deviceId\") ?? \"\";\r\n const token = q.get(\"token\") ?? \"\";\r\n const record = devices.authenticate(deviceId, token);\r\n if (!record) return done(401, \"Unauthorized\");\r\n wss.handleUpgrade(req, socket, head, (ws) => {\r\n hub.attachCamera(record.deviceId, ws, record.name);\r\n });\r\n return;\r\n }\r\n if (url.pathname === \"/ws/view\") {\r\n if (!isLoopback(req)) return done(403, \"Forbidden\");\r\n wss.handleUpgrade(req, socket, head, (ws) => hub.attachView(ws as WebSocket, {\r\n onRefreshPairing: () => pairing.refresh(),\r\n onRenameDevice: (deviceId, name) => {\r\n const updated = devices.rename(deviceId, name);\r\n if (updated) hub.renameDevice(deviceId, updated.name);\r\n },\r\n }));\r\n return;\r\n }\r\n done(404, \"Not Found\");\r\n });\r\n\r\n const heartbeat = setInterval(() => hub.pingAll(), 20_000);\r\n\r\n await new Promise<void>((resolve, reject) => {\r\n server.once(\"error\", reject);\r\n server.listen(config.server.port, config.server.host, () => resolve());\r\n });\r\n log(\"info\", `phone-lens listening on ${config.server.host}:${config.server.port} (paired devices: ${devices.count()})`);\r\n // Warm the APK-link cache so the first rendered download QR already carries\r\n // the dynamically resolved URL (never blocks startup on the network).\r\n void resolveLatestGiteeApk().then((url) => {\r\n if (url) log(\"info\", `APK download link resolved from Gitee API: ${url}`);\r\n });\r\n\r\n return {\r\n port: config.server.port,\r\n dispose: async () => {\r\n clearInterval(heartbeat);\r\n hub.dispose();\r\n hub.detachAll();\r\n for (const client of wss.clients) client.terminate();\r\n await new Promise<void>((resolve) => server.close(() => resolve()));\r\n },\r\n };\r\n}\r\n\r\ntype RouteCtx = {\r\n pairLimiter: RateLimiter;\r\n /** uploadId → cached 200 response, so queued-upload retries are idempotent. */\r\n uploadReplays: Map<string, { expires: number; body: unknown }>;\r\n noteInjection: (receipt: DeliveryReceipt, attachmentId: string) => void;\r\n getLastInjection: () => { at: number; sessionId: string | null; attachmentId: string; ok: boolean } | null;\r\n};\r\n\r\nasync function handle(deps: ServerDeps, req: IncomingMessage, res: ServerResponse, ctx: RouteCtx): Promise<void> {\r\n const { config, pairing, devices, hub, targets, sink, log } = deps;\r\n const url = new URL(req.url ?? \"/\", \"http://phone-lens.local\");\r\n const path = url.pathname.replace(/\\/+$/, \"\") || \"/\";\r\n const method = req.method ?? \"GET\";\r\n const loop = isLoopback(req);\r\n const cors = corsFor(req);\r\n\r\n // Preflight from the Web UI page (another loopback origin).\r\n if (method === \"OPTIONS\") {\r\n res.writeHead(204, cors);\r\n res.end();\r\n return;\r\n }\r\n\r\n // ── open endpoints ────────────────────────────────────────────────────────\r\n if (method === \"GET\" && path === \"/info\") {\r\n return sendJson(res, 200, { name: \"PhoneLens 直连取景\", version: hostVersion(), requiresPairing: true }, cors);\r\n }\r\n\r\n // ── loopback-only endpoints (preview page, QR, view stream) ──────────────\r\n if (!loop && (path === \"/\" || path === \"/view.html\" || path === \"/qr.json\" || path === \"/qr.png\" || path === \"/app-qr.json\" || path === \"/app-settings\")) {\r\n return sendError(res, 403, ERROR_CODES.LOOPBACK_ONLY, \"preview surface is loopback-only\");\r\n }\r\n if (method === \"GET\" && (path === \"/\" || path === \"/view.html\")) {\r\n res.writeHead(200, { \"content-type\": \"text/html; charset=utf-8\", \"cache-control\": \"no-store\" });\r\n res.end(VIEW_HTML);\r\n return;\r\n }\r\n if (method === \"GET\" && path === \"/qr.json\") {\r\n const { code, expiresAt } = pairing.current();\r\n const qr = await buildPairingQr(code, expiresAt, config);\r\n return sendJson(res, 200, { code: qr.code, expiresAt: qr.expiresAt, payload: qr.payload, urls: qr.urls, pngDataUrl: qr.pngDataUrl }, { ...cors, \"cache-control\": \"no-store\" });\r\n }\r\n if (method === \"GET\" && path === \"/qr.png\") {\r\n const { code, expiresAt } = pairing.current();\r\n const qr = await buildPairingQr(code, expiresAt, config);\r\n const b64 = qr.pngDataUrl.slice(qr.pngDataUrl.indexOf(\",\") + 1);\r\n res.writeHead(200, { \"content-type\": \"image/png\", \"cache-control\": \"no-store\" });\r\n res.end(Buffer.from(b64, \"base64\"));\r\n return;\r\n }\r\n // App-download QR: encodes the APK URL (Gitee by default) so a phone can\r\n // scan it straight from the Web UI overlay and start the download.\r\n if (method === \"GET\" && path === \"/app-qr.json\") {\r\n // Resolve the Gitee link from the latest-release API (no per-release\r\n // constant bump); mutates config.app only while defaults are in use.\r\n await refreshAppApkLinks(config);\r\n const target = config.app.downloadUrl || config.app.giteeUrl;\r\n const pngDataUrl = await QRCode.toDataURL(target, { errorCorrectionLevel: \"M\", margin: 2, width: 320 });\r\n return sendJson(res, 200, { url: target, gitee: config.app.giteeUrl, github: config.app.githubUrl, pngDataUrl }, { ...cors, \"cache-control\": \"no-store\" });\r\n }\r\n\r\n // ── pairing ──────────────────────────────────────────────────────────────\r\n // Rate limit only FAILED attempts (anti-brute-force); a legitimate new\r\n // device entering a valid code is never throttled, so switching phones\r\n // \"just works\" instead of hitting \"too many pairing attempts\".\r\n if (method === \"POST\" && path === \"/pair\") {\r\n const body = await readJsonBody(req, 4096);\r\n const code = str(body?.code);\r\n const device = (body?.device ?? {}) as Record<string, unknown>;\r\n const deviceId = str(device.id);\r\n const name = str(device.name) ?? \"unnamed device\";\r\n const model = str(device.model) ?? \"\";\r\n if (!code || !deviceId) return sendError(res, 400, ERROR_CODES.BAD_REQUEST, \"code and device.id are required\");\r\n const verdict = pairing.verify(code);\r\n if (!verdict.ok) {\r\n if (!ctx.pairLimiter.allow(clientKey(req))) return sendError(res, 429, ERROR_CODES.RATE_LIMITED, \"too many pairing attempts\");\r\n return sendError(res, 401, verdict.reason === \"expired\" ? ERROR_CODES.PAIR_CODE_EXPIRED : ERROR_CODES.PAIR_CODE_INVALID, `pairing code ${verdict.reason}`);\r\n }\r\n const token = mintDeviceToken();\r\n devices.upsert({ deviceId, name, model, tokenHash: hashToken(token) });\r\n log(\"info\", `paired device \"${name}\" (${deviceId.slice(0, 8)})`);\r\n return sendJson(res, 200, {\r\n token,\r\n serverInfo: { preview: config.preview, limits: config.limits },\r\n });\r\n }\r\n\r\n // ── authenticated endpoints: loopback or paired device ───────────────────\r\n const auth = loop ? { ok: true } : deviceAuth(req) && devices.authenticate(deviceAuth(req)!.deviceId, deviceAuth(req)!.token) ? { ok: true } : { ok: false };\r\n if (!auth.ok) return sendError(res, 401, ERROR_CODES.AUTH_REQUIRED, \"pair this device first\");\r\n\r\n if (method === \"POST\" && path === \"/upload\") {\r\n const q = queryOf(req);\r\n // Idempotent replay BEFORE anything else: a queued-upload retry that\r\n // already stored once replays the original 200. Key format-bounded:\r\n // uploadId is attacker-controllable input and doubles as the map key.\r\n const rawUploadId = q.get(\"uploadId\") ?? \"\";\r\n const uploadId = /^[\\w-]{8,64}$/.test(rawUploadId) ? rawUploadId : null;\r\n if (uploadId) {\r\n const hit = ctx.uploadReplays.get(uploadId);\r\n if (hit && hit.expires > Date.now()) {\r\n log(\"info\", `idempotent replay for upload ${uploadId.slice(0, 8)}`);\r\n return sendJson(res, 200, hit.body);\r\n }\r\n ctx.uploadReplays.delete(uploadId);\r\n }\r\n // No upload rate limiting by design: the phone's queue uploads serially\r\n // and the per-photo size cap plus archive pruning bound the damage any\r\n // authenticated (or loopback) client can do.\r\n const mediaType = (req.headers[\"content-type\"] ?? \"\").split(\";\")[0]!.trim();\r\n if (!config.limits.allowedTypes.includes(mediaType)) {\r\n return sendError(res, 415, ERROR_CODES.TYPE_NOT_ALLOWED, `allowed: ${config.limits.allowedTypes.join(\", \")}`);\r\n }\r\n const declared = Number(req.headers[\"content-length\"] ?? \"0\");\r\n if (declared > config.limits.maxUploadBytes) return sendError(res, 413, ERROR_CODES.TOO_LARGE, `> ${config.limits.maxUploadBytes} bytes`);\r\n const { buf, truncated } = await readRawBody(req, config.limits.maxUploadBytes);\r\n if (truncated) return sendError(res, 413, ERROR_CODES.TOO_LARGE, `> ${config.limits.maxUploadBytes} bytes`);\r\n if (!magicMatches(mediaType, buf)) return sendError(res, 415, ERROR_CODES.BAD_MAGIC, \"bytes do not match the declared type\");\r\n\r\n const captureId = q.get(\"captureId\");\r\n const note = q.get(\"note\") ?? (captureId ? hub.noteFor(captureId) : undefined);\r\n if (captureId) hub.consumeCapture(captureId);\r\n const rawName = (q.get(\"name\") ?? `shot_${new Date().toISOString().replace(/[:.]/g, \"-\")}.${mediaType === \"image/png\" ? \"png\" : \"jpg\"}`).slice(0, 120);\r\n const name = sanitizeFileName(rawName);\r\n\r\n let admitted;\r\n try {\r\n admitted = await admitImage({ data: buf, mediaType, name }, deps.attachments(), deps.fallbackDir);\r\n } catch (error) {\r\n log(\"error\", `admit failed: ${String(error)}`);\r\n return sendError(res, 500, ERROR_CODES.STORE_FAILED, String(error));\r\n }\r\n log(\"info\", `stored ${name} (${buf.byteLength}B) → ${admitted.storage}:${admitted.ref.attachmentId}`);\r\n\r\n // keep the local archive bounded (retention: maxStoredUploads oldest-first)\r\n if (admitted.storage === \"file\") await pruneUploads(deps.fallbackDir, config.limits.maxStoredUploads).catch((error) => log(\"warn\", `upload pruning failed: ${String(error)}`));\r\n\r\n // Capture-mode routing (user-selected in the web UI settings). The photo\r\n // always lands in the attachment store first; the mode decides what\r\n // happens next:\r\n // composer → stage for the dsh composer draft (paste pre-send)\r\n // folder → additionally/only write the file to the user's folder\r\n // A failed folder write never loses the photo — it falls back to staging.\r\n const st = deps.appSettings.get();\r\n const wantFolder = st.saveMode !== \"composer\";\r\n const wantComposer = st.saveMode !== \"folder\";\r\n let savedDir: string | null = null;\r\n if (wantFolder) {\r\n const dir = deps.appSettings.effectiveSaveDir();\r\n try {\r\n mkdirSync(dir, { recursive: true });\r\n const fileName = nextAvailableName(dir, name);\r\n const target = resolveUnder(dir, fileName);\r\n if (!target) throw new Error(\"resolved path escapes the save directory\");\r\n writeFileSync(target, buf);\r\n savedDir = dir;\r\n log(\"info\", `folder-mode saved: ${target}`);\r\n } catch (error) {\r\n log(\"warn\", `folder save failed (${String(error)}) — falling back to composer staging`);\r\n }\r\n }\r\n\r\n // PRE-SEND semantics. The phone's photo is staged for the dsh composer,\r\n // not injected straight into the model — the browser client fetches this\r\n // staging copy and drops it into the composer draft; the user types text\r\n // and hits send. So no agent.followup here.\r\n if (wantComposer || !savedDir) {\r\n const pendingPath = resolveUnder(deps.pendingDir, pendingFileName(admitted.ref.attachmentId));\r\n try {\r\n if (!pendingPath) throw new Error(\"resolved path escapes the pending directory\");\r\n mkdirSync(deps.pendingDir, { recursive: true });\r\n writeFileSync(pendingPath, buf);\r\n log(\"info\", `staged for composer: ${pendingPath}`);\r\n } catch (error) {\r\n log(\"warn\", `staging failed: ${String(error)}`);\r\n }\r\n hub.broadcastToViews({ type: \"pending_image\", attachmentId: admitted.ref.attachmentId, name });\r\n // no direct delivery; the client owns placing it in the composer\r\n } else {\r\n // folder-only save: bypass the composer; surface a local UI hint on the\r\n // view — never a chat message (the tool does not speak for the user).\r\n hub.broadcastToViews({ type: \"upload_saved\", name, dir: savedDir });\r\n }\r\n\r\n // Idempotency bookkeeping for queued-upload retries. uploadId serves only\r\n // as the lookup KEY; the cached receipt is entirely server-generated data\r\n // (sha refs, probed dimensions, literal enums) — must stay byte-identical\r\n // to the 200 body returned below.\r\n if (uploadId) {\r\n const replayBody = {\r\n ok: true,\r\n attachmentId: admitted.ref.attachmentId,\r\n width: admitted.ref.width,\r\n height: admitted.ref.height,\r\n bytes: admitted.ref.bytes,\r\n storage: admitted.storage,\r\n delivered: null,\r\n deliverReason: savedDir ? (wantComposer ? \"saved-and-staged\" : \"saved-to-folder\") : \"staged-in-composer\",\r\n };\r\n if (ctx.uploadReplays.size > 400) {\r\n for (const [k, v] of ctx.uploadReplays) if (v.expires <= Date.now()) ctx.uploadReplays.delete(k);\r\n }\r\n ctx.uploadReplays.set(uploadId, { expires: Date.now() + 10 * 60_000, body: replayBody });\r\n }\r\n\r\n return sendJson(res, 200, {\r\n ok: true,\r\n attachmentId: admitted.ref.attachmentId,\r\n width: admitted.ref.width,\r\n height: admitted.ref.height,\r\n bytes: admitted.ref.bytes,\r\n storage: admitted.storage,\r\n delivered: null,\r\n deliverReason: savedDir ? (wantComposer ? \"saved-and-staged\" : \"saved-to-folder\") : \"staged-in-composer\",\r\n });\r\n }\r\n\r\n // Browser client fetches the staged photo to place into the composer draft.\r\n if (method === \"GET\" && path.startsWith(\"/pending/\") && loop) {\r\n const id = path.slice(\"/pending/\".length).split(\"/\")[0] ?? \"\";\r\n const file = resolveUnder(deps.pendingDir, pendingFileName(id));\r\n if (!file) return sendError(res, 404, ERROR_CODES.BAD_REQUEST, \"pending image not found\");\r\n try {\r\n const data = await readFile(file);\r\n res.writeHead(200, { \"content-type\": \"image/jpeg\", \"cache-control\": \"no-store\", ...cors });\r\n res.end(data);\r\n return;\r\n } catch {\r\n return sendError(res, 404, ERROR_CODES.BAD_REQUEST, \"pending image not found\");\r\n }\r\n }\r\n\r\n if (method === \"GET\" && path === \"/status\") {\r\n // online/streaming reflect the hub's LIVE uplinks — the phone's\r\n // auto-switch and auth probes read this, so a detached device must not\r\n // linger as \"online\" off a stale attach-time marker\r\n const online = hub.onlineDeviceIds();\r\n return sendJson(res, 200, {\r\n bootAt: SERVER_BOOT_AT,\r\n devices: devices.list().map((d) => ({ id: d.deviceId, name: d.name, model: d.model, online: online.has(d.deviceId), streaming: online.has(d.deviceId), lastSeenAt: d.lastSeenAt })),\r\n camera: hub.stats(),\r\n preview: config.preview,\r\n target: { mode: targets.mode(), sessionId: targets.resolve()?.sessionId ?? null },\r\n lastInjection: ctx.getLastInjection(),\r\n }, cors);\r\n }\r\n\r\n if (method === \"GET\" && path === \"/targets\") {\r\n return sendJson(res, 200, { targets: targets.list(), default: targets.resolve()?.sessionId ?? null }, cors);\r\n }\r\n\r\n // Web UI settings: capture mode (composer / folder / both) + target folder.\r\n // Loopback-only; the settings live on the host because the folder save runs here.\r\n if (method === \"GET\" && path === \"/app-settings\") {\r\n const st = deps.appSettings.get();\r\n return sendJson(res, 200, { saveMode: st.saveMode, saveDir: st.saveDir, effectiveSaveDir: deps.appSettings.effectiveSaveDir() }, cors);\r\n }\r\n if (method === \"POST\" && path === \"/app-settings\") {\r\n const body = await readJsonBody(req, 4096);\r\n if (!body) return sendError(res, 400, ERROR_CODES.BAD_REQUEST, \"invalid JSON body\");\r\n const patch: { saveMode?: SaveMode; saveDir?: string } = {};\r\n if (typeof body.saveMode === \"string\" && SAVE_MODES.includes(body.saveMode as SaveMode)) patch.saveMode = body.saveMode as SaveMode;\r\n if (typeof body.saveDir === \"string\") patch.saveDir = body.saveDir;\r\n const st = deps.appSettings.set(patch);\r\n log(\"info\", `app settings updated: mode=${st.saveMode} dir=${st.saveDir ? JSON.stringify(st.saveDir) : \"(default)\"}`);\r\n return sendJson(res, 200, { saveMode: st.saveMode, saveDir: st.saveDir, effectiveSaveDir: deps.appSettings.effectiveSaveDir() }, cors);\r\n }\r\n\r\n // View-side control also arrives over /ws/view; a loopback HTTP trigger is\r\n // handy for smoke tests.\r\n if (method === \"POST\" && path === \"/capture\" && loop) {\r\n const captureId = randomUUID();\r\n const ok = hub.requestCapture(captureId, queryOf(req).get(\"note\") ?? undefined);\r\n if (!ok) return sendError(res, 409, ERROR_CODES.NO_CAMERA, \"no camera uplink connected\");\r\n return sendJson(res, 202, { captureId });\r\n }\r\n\r\n return sendError(res, 404, ERROR_CODES.BAD_REQUEST, `no route ${method} ${path}`);\r\n}\r\n\r\n// ── helpers ─────────────────────────────────────────────────────────────────\r\n\r\n/** Strip path separators / reserved characters so a phone-supplied upload\r\n * name can never escape the target directory or hide as a dotfile. */\r\nfunction sanitizeFileName(input: string): string {\r\n const base = basename(input)\r\n .replace(/[<>:\"|?*\\u0000-\\u001F]/g, \"_\")\r\n .replace(/^[\\s.]+/, \"\")\r\n .trim();\r\n return base || \"shot.jpg\";\r\n}\r\n\r\n/** Pick `name`, then `name (2).ext`, `name (3).ext`, … — folder saves never overwrite. */\r\nfunction nextAvailableName(dir: string, name: string): string {\r\n if (!existsSync(join(dir, name))) return name;\r\n const dot = name.lastIndexOf(\".\");\r\n const stem = dot > 0 ? name.slice(0, dot) : name;\r\n const ext = dot > 0 ? name.slice(dot) : \"\";\r\n for (let n = 2; ; n++) {\r\n const candidate = `${stem} (${n})${ext}`;\r\n if (!existsSync(join(dir, candidate))) return candidate;\r\n }\r\n}\r\n\r\n/** Pending-staging filename for an attachment id. Ids may be URN-ish\r\n * (\"file:<digest>\" without the dsh attachment service) and Windows forbids\r\n * \":\" in filenames — flatten to a portable name, write and read alike. */\r\nfunction pendingFileName(attachmentId: string): string {\r\n return `${attachmentId.replace(/[^A-Za-z0-9._-]/g, \"_\")}.jpg`;\r\n}\r\n\r\n/** Resolve `name` under `baseDir` and refuse anything that escapes it\r\n * (defense in depth on top of sanitizeFileName — e.g. a crafted name that\r\n * survives sanitization but still resolves outside the base). */\r\nfunction resolveUnder(baseDir: string, name: string): string | null {\r\n const base = resolve(baseDir);\r\n const target = resolve(base, name);\r\n return target === base || target.startsWith(base + sep) ? target : null;\r\n}\r\n\r\nfunction sendJson(res: ServerResponse, status: number, body: unknown, extraHeaders?: Record<string, string>): void {\r\n res.writeHead(status, { \"content-type\": \"application/json; charset=utf-8\", ...(extraHeaders ?? {}) });\r\n res.end(JSON.stringify(body));\r\n}\r\n\r\nfunction sendError(res: ServerResponse, status: number, code: string, message: string): void {\r\n sendJson(res, status, { error: { code, message } });\r\n}\r\n\r\nfunction str(v: unknown): string | undefined {\r\n return typeof v === \"string\" ? v : undefined;\r\n}\r\n\r\nasync function readJsonBody(req: IncomingMessage, limit: number): Promise<Record<string, unknown> | null> {\r\n const { buf } = await readRawBody(req, limit);\r\n try {\r\n return JSON.parse(buf.toString(\"utf8\")) as Record<string, unknown>;\r\n } catch {\r\n return null;\r\n }\r\n}\r\n\r\nfunction readRawBody(req: IncomingMessage, maxBytes: number): Promise<{ buf: Buffer; truncated: boolean }> {\r\n return new Promise((resolve, reject) => {\r\n const chunks: Buffer[] = [];\r\n let size = 0;\r\n let truncated = false;\r\n req.on(\"data\", (chunk: Buffer) => {\r\n size += chunk.byteLength;\r\n if (size > maxBytes) {\r\n truncated = true;\r\n chunks.length = 0;\r\n req.destroy();\r\n resolve({ buf: Buffer.alloc(0), truncated });\r\n return;\r\n }\r\n chunks.push(chunk);\r\n });\r\n req.on(\"end\", () => resolve({ buf: Buffer.concat(chunks), truncated }));\r\n req.on(\"error\", reject);\r\n });\r\n}\r\n\r\n/** Delete the oldest files beyond `max` in a directory (mtime order). */\r\nasync function pruneUploads(dir: string, max: number): Promise<void> {\r\n if (max <= 0) return;\r\n const names = await readdir(dir);\r\n if (names.length <= max) return;\r\n const entries = await Promise.all(\r\n names.map(async (name) => {\r\n let mtime = 0;\r\n try {\r\n mtime = (await stat(join(dir, name))).mtimeMs;\r\n } catch {\r\n mtime = 0;\r\n }\r\n return { name, mtime };\r\n }),\r\n );\r\n entries.sort((a, b) => b.mtime - a.mtime);\r\n for (const entry of entries.slice(max)) {\r\n await unlink(join(dir, entry.name)).catch(() => {});\r\n }\r\n}\r\n","import { randomUUID } from \"node:crypto\";\r\nimport type { WebSocket } from \"ws\";\r\nimport type { CameraControl, LensConfig, ViewClientMessage, ViewServerMessage } from \"../types.js\";\r\n\r\n/** Per-device camera uplink state; multiple phones coexist without kicking. */\r\ninterface CamState {\r\n ws: WebSocket;\r\n name: string;\r\n meta: { width?: number; height?: number; fps?: number; rotation?: number };\r\n lastFrame: Buffer | null;\r\n lastFrameAt: number;\r\n /** Any inbound traffic (frame, control, ws-pong) — drives the dead-link watchdog. */\r\n lastSeenAt: number;\r\n frameCount: number;\r\n windowStart: number;\r\n measuredFps: number;\r\n}\r\n\r\n/**\r\n * A camera uplink with NO inbound traffic for this long is presumed half-open\r\n * (phone switched wifi / laptop slept mid-link): the OS keeps the socket\r\n * \"open\" without a FIN, so we evict it ourselves. Generous vs the phone's own\r\n * 30s app-level silence limit so the phone normally tears down first.\r\n */\r\nconst CAM_SILENCE_MS = 60_000;\r\n\r\n/**\r\n * How long the ACTIVE device's stream may go silent before views are told the\r\n * preview was turned off. The phone app never announces \"preview off\" — it\r\n * just stops pushing frames (the WS stays up) — so the host derives the state:\r\n * a live, open camera uplink with no frames for this long means the user\r\n * switched preview off on the phone. Device disconnects are signalled\r\n * separately via the `devices` list.\r\n */\r\nconst PREVIEW_STALL_MS = 3_000;\r\n\r\n/**\r\n * The viewfinder hub: MULTIPLE camera uplinks (one per phone, keyed by\r\n * deviceId), N loopback view downlinks. The \"active\" device is auto-selected\r\n * as the last one to send a frame; the view side can switch it via\r\n * `select_device`. Only the active device's frames are fanned out — a second\r\n * phone connecting no longer kicks the first, so several paired phones coexist\r\n * and the user picks which one to watch / shoot from.\r\n */\r\nexport class ViewHub {\r\n private cameras = new Map<string, CamState>();\r\n private activeDeviceId: string | null = null;\r\n /**\r\n * The device the flow last settled on EXPLICITLY: picked in the view,\r\n * claimed from a phone, or simply the first to come online. When that\r\n * device drops and a fallback takes over, detach does NOT clear this — so\r\n * the preferred phone reconnecting (network blip, host reboot, delayed\r\n * wifi join) takes its hot seat back instead of being parked as \"another\r\n * device is using the preview\". Any explicit switch updates it.\r\n */\r\n private preferredDeviceId: string | null = null;\r\n private views = new Set<WebSocket>();\r\n /** captureId → { note, requestedAt } until the matching upload lands or timeout. */\r\n private pendingCaptures = new Map<string, { note?: string; requestedAt: number }>();\r\n private readonly captureTimeoutMs = 60_000;\r\n /** Whether the active device is confirmed to be streaming (hello/frame seen). */\r\n private previewOn = false;\r\n private stallTimer: ReturnType<typeof setInterval> | null = null;\r\n\r\n constructor(\r\n private readonly config: LensConfig,\r\n private readonly log: (level: \"info\" | \"warn\", msg: string) => void,\r\n ) {\r\n this.stallTimer = setInterval(() => this.checkPreviewStall(), 1_000);\r\n }\r\n\r\n /** Stop the stall watchdog (server dispose). */\r\n dispose(): void {\r\n if (this.stallTimer) clearInterval(this.stallTimer);\r\n this.stallTimer = null;\r\n }\r\n\r\n // ── camera side ───────────────────────────────────────────────────────────\r\n\r\n attachCamera(deviceId: string, ws: WebSocket, name: string): void {\r\n const prev = this.cameras.get(deviceId);\r\n if (prev) {\r\n this.log(\"warn\", `camera re-attach: kicking previous ws for ${deviceId.slice(0, 8)}`);\r\n try {\r\n prev.ws.close(1000, \"new-instance\");\r\n } catch {}\r\n this.cameras.delete(deviceId);\r\n }\r\n const cam: CamState = { ws, name, meta: {}, lastFrame: null, lastFrameAt: 0, lastSeenAt: Date.now(), frameCount: 0, windowStart: Date.now(), measuredFps: 0 };\r\n this.cameras.set(deviceId, cam);\r\n if (this.activeDeviceId === null) {\r\n this.activeDeviceId = deviceId;\r\n this.preferredDeviceId = deviceId;\r\n } else if (this.preferredDeviceId === deviceId && this.activeDeviceId !== deviceId) {\r\n // the preferred phone is back and a fallback currently holds the hot\r\n // seat it only got because the preferred one dropped — hand it back\r\n this.selectDevice(deviceId);\r\n }\r\n // only the ACTIVE device streams to the PC; others pause immediately\r\n if (this.activeDeviceId === deviceId) this.sendControl(deviceId, { type: \"resume_preview\" });\r\n else this.sendControl(deviceId, { type: \"pause_preview\" });\r\n this.log(\"info\", `camera uplink: ${name} (${deviceId.slice(0, 8)})`);\r\n\r\n ws.on(\"close\", (code, reason) => {\r\n this.log(\"warn\", `camera ws closed: code=${code} reason=${reason.toString(\"utf8\") || \"-\"} (${name} ${deviceId.slice(0, 8)})`);\r\n if (this.cameras.get(deviceId)?.ws === ws) this.detachCamera(deviceId);\r\n });\r\n ws.on(\"pong\", () => {\r\n const c = this.cameras.get(deviceId);\r\n if (c) c.lastSeenAt = Date.now();\r\n });\r\n ws.on(\"message\", (data, isBinary) => {\r\n const c = this.cameras.get(deviceId);\r\n if (c) c.lastSeenAt = Date.now();\r\n if (isBinary) {\r\n this.ingestFrame(deviceId, data as Buffer);\r\n return;\r\n }\r\n this.onCameraControl(deviceId, safeJson(data.toString()));\r\n });\r\n // greet the new device to views\r\n this.broadcastDevices();\r\n }\r\n\r\n detachCamera(deviceId: string): void {\r\n const cam = this.cameras.get(deviceId);\r\n this.log(\"warn\", `detachCamera(${deviceId.slice(0, 8)}) had-camera=${!!cam}`);\r\n if (cam) this.cameras.delete(deviceId);\r\n if (this.activeDeviceId === deviceId) {\r\n const next = [...this.cameras.keys()].at(-1) ?? null;\r\n this.activeDeviceId = next;\r\n if (next) this.sendControl(next, { type: \"resume_preview\" });\r\n this.pushActiveFrameToViews();\r\n }\r\n this.broadcastDevices();\r\n }\r\n\r\n detachAll(): void {\r\n for (const cam of this.cameras.values()) {\r\n try {\r\n cam.ws.close(1000, \"server-dispose\");\r\n } catch {}\r\n }\r\n this.cameras.clear();\r\n this.activeDeviceId = null;\r\n this.broadcastDevices();\r\n }\r\n\r\n pingAll(): void {\r\n const now = Date.now();\r\n for (const [deviceId, cam] of this.cameras) {\r\n if (cam.ws.readyState !== cam.ws.OPEN) continue;\r\n if (now - cam.lastSeenAt > CAM_SILENCE_MS) {\r\n // No frames, no controls, no ws-pong: the phone is gone without a\r\n // FIN (wifi hop). Terminate — the close handler detaches and the\r\n // devices list tells the truth again. The phone's own 30s app-level\r\n // watchdog normally tears its side down first.\r\n this.log(\"warn\", `camera uplink silent >${CAM_SILENCE_MS}ms — terminating ${deviceId.slice(0, 8)}`);\r\n cam.ws.terminate();\r\n continue;\r\n }\r\n cam.ws.ping();\r\n }\r\n }\r\n\r\n /** Device ids with a live camera uplink right now (for /status truth). */\r\n onlineDeviceIds(): Set<string> {\r\n const ids = new Set<string>();\r\n for (const [deviceId, cam] of this.cameras) {\r\n if (cam.ws.readyState === cam.ws.OPEN) ids.add(deviceId);\r\n }\r\n return ids;\r\n }\r\n\r\n private onCameraControl(deviceId: string, msg: CameraControl | null): void {\r\n if (!msg) return;\r\n const cam = this.cameras.get(deviceId);\r\n if (!cam) return;\r\n switch (msg.type) {\r\n case \"hello\":\r\n cam.meta = { width: msg.width, height: msg.height, fps: msg.fps, ...(msg.rotation !== void 0 ? { rotation: msg.rotation } : {}) };\r\n if (this.activeDeviceId === deviceId) {\r\n // hello is the phone's \"stream starting\" announcement (sent on every\r\n // _startStream) — treat it as preview-on for the view side.\r\n this.markPreviewActive();\r\n this.broadcastToViews({ type: \"frame_meta\", width: msg.width, height: msg.height, ...(msg.rotation !== void 0 ? { rotation: msg.rotation } : {}) });\r\n }\r\n break;\r\n case \"bye\":\r\n this.log(\"warn\", `camera sent bye: ${deviceId.slice(0, 8)}`);\r\n this.detachCamera(deviceId);\r\n break;\r\n case \"claim_active\":\r\n // phone asked to become the active device → switch the view to it.\r\n this.selectDevice(deviceId);\r\n break;\r\n case \"ping\":\r\n // app-level keepalive echo (see types.ts); lets the phone detect a\r\n // half-open link its OS would keep \"connected\" for minutes\r\n this.sendControl(deviceId, { type: \"pong\" });\r\n break;\r\n case \"capture_result\":\r\n if (msg.status !== \"taken\") {\r\n this.pendingCaptures.delete(msg.captureId);\r\n this.broadcastToViews({ type: \"error\", code: \"CAPTURE_DECLINED\", message: `phone reported ${msg.status}${msg.detail ? `: ${msg.detail}` : \"\"}` });\r\n }\r\n break;\r\n default:\r\n break;\r\n }\r\n }\r\n\r\n private ingestFrame(deviceId: string, frame: Buffer): void {\r\n const cam = this.cameras.get(deviceId);\r\n if (!cam || frame.byteLength === 0) return;\r\n if (frame.byteLength > this.config.limits.previewFrameMaxBytes) {\r\n this.log(\"warn\", `dropping oversized preview frame from ${deviceId.slice(0, 8)} (${frame.byteLength}B > ${this.config.limits.previewFrameMaxBytes}B)`);\r\n return;\r\n }\r\n const now = Date.now();\r\n cam.lastFrame = frame;\r\n cam.lastFrameAt = now;\r\n cam.frameCount++;\r\n if (now - cam.windowStart >= 2000) {\r\n cam.measuredFps = (cam.frameCount * 1000) / (now - cam.windowStart);\r\n cam.windowStart = now;\r\n cam.frameCount = 0;\r\n }\r\n // only the ACTIVE device drives the view; others just keep their own state\r\n if (this.activeDeviceId === deviceId) {\r\n this.markPreviewActive();\r\n for (const view of this.views) {\r\n if (view.readyState === view.OPEN) view.send(frame, { binary: true });\r\n }\r\n }\r\n }\r\n\r\n /** Flip the view-side preview state to on (once) when stream activity returns. */\r\n private markPreviewActive(): void {\r\n if (this.previewOn) return;\r\n this.previewOn = true;\r\n this.broadcastToViews({ type: \"preview_state\", on: true });\r\n }\r\n\r\n /**\r\n * Watchdog: an open, ACTIVE camera uplink that has gone silent means the\r\n * user turned preview off on the phone (the app keeps the WS while only\r\n * stopping its frame pump). Tell views so they can clear the stale frame.\r\n * Disconnected devices are NOT handled here — the `devices` broadcast owns\r\n * the offline state on the view side.\r\n */\r\n private checkPreviewStall(): void {\r\n if (!this.previewOn) return;\r\n const active = this.activeCam();\r\n if (!active || active.ws.readyState !== active.ws.OPEN) return;\r\n if (Date.now() - active.lastFrameAt <= PREVIEW_STALL_MS) return;\r\n this.previewOn = false;\r\n this.broadcastToViews({ type: \"preview_state\", on: false });\r\n this.log(\"info\", `active preview stalled >${PREVIEW_STALL_MS}ms — notified views (preview off)`);\r\n }\r\n\r\n // ── view side ─────────────────────────────────────────────────────────────\r\n\r\n attachView(ws: WebSocket, hooks: { onRefreshPairing?: () => void; onRenameDevice?: (deviceId: string, name: string) => void } = {}): void {\r\n this.views.add(ws);\r\n ws.on(\"close\", (code, reason) => {\r\n this.log(\"warn\", `view ws closed: code=${code} reason=${reason.toString(\"utf8\") || \"-\"} (views left: ${this.views.size - 1})`);\r\n this.views.delete(ws);\r\n });\r\n ws.on(\"message\", (data, isBinary) => {\r\n if (isBinary) return;\r\n const msg = parseViewClient(data.toString());\r\n if (!msg) return;\r\n if (msg.type === \"capture\") {\r\n const captureId = randomUUID();\r\n if (!this.requestCapture(captureId, msg.note)) {\r\n this.broadcastToViews({ type: \"error\", code: \"NO_CAMERA\", message: \"no camera uplink connected\" });\r\n }\r\n } else if (msg.type === \"select_device\") {\r\n this.selectDevice(msg.deviceId);\r\n } else if (msg.type === \"rename_device\") {\r\n hooks.onRenameDevice?.(msg.deviceId, msg.name);\r\n } else if (msg.type === \"refresh_pairing\") {\r\n hooks.onRefreshPairing?.();\r\n }\r\n });\r\n const active = this.activeCam();\r\n ws.send(\r\n JSON.stringify({\r\n type: \"meta\",\r\n camera: active ? { connected: true, name: active.name, ...active.meta } : { connected: false },\r\n preview: this.config.preview,\r\n paired: true,\r\n } satisfies ViewServerMessage),\r\n );\r\n this.broadcastDevicesTo(ws);\r\n // initial preview state so a freshly (re)loaded view renders the correct\r\n // canvas/placeholder instead of guessing\r\n ws.send(JSON.stringify({ type: \"preview_state\", on: this.previewOn } satisfies ViewServerMessage));\r\n // only replay a cached frame while the stream is confirmed fresh; a stale\r\n // frame would flash an old picture before the \"preview off\" state lands\r\n if (this.previewOn && active?.lastFrame && ws.readyState === ws.OPEN) ws.send(active.lastFrame, { binary: true });\r\n }\r\n\r\n viewCount(): number {\r\n return this.views.size;\r\n }\r\n\r\n /** Update one device's display name and re-broadcast the device list. */\r\n renameDevice(deviceId: string, name: string): void {\r\n const cam = this.cameras.get(deviceId);\r\n if (cam) cam.name = name;\r\n this.broadcastDevices();\r\n }\r\n\r\n /** Switch which phone's frames / shutter the view follows. */\r\n selectDevice(deviceId: string): void {\r\n if (!this.cameras.has(deviceId)) return;\r\n const prevActive = this.activeDeviceId;\r\n if (prevActive === deviceId) return;\r\n this.activeDeviceId = deviceId;\r\n this.preferredDeviceId = deviceId;\r\n if (prevActive) this.sendControl(prevActive, { type: \"pause_preview\" });\r\n this.sendControl(deviceId, { type: \"resume_preview\" });\r\n this.broadcastDevices();\r\n this.pushActiveFrameToViews();\r\n }\r\n\r\n /** Send one control message to a specific phone uplink. */\r\n private sendControl(deviceId: string, msg: CameraControl): void {\r\n const cam = this.cameras.get(deviceId);\r\n if (cam && cam.ws.readyState === cam.ws.OPEN) cam.ws.send(JSON.stringify(msg));\r\n }\r\n\r\n private pushActiveFrameToViews(): void {\r\n const active = this.activeCam();\r\n // replay the cached frame only when the preview is confirmed streaming;\r\n // otherwise views would briefly show an old picture before the stall\r\n // watchdog reports \"preview off\"\r\n if (this.previewOn && active?.lastFrame) {\r\n for (const view of this.views) {\r\n if (view.readyState === view.OPEN) view.send(active.lastFrame, { binary: true });\r\n }\r\n }\r\n // re-announce frame size/rotation for the newly selected device\r\n if (active) {\r\n this.broadcastToViews({ type: \"frame_meta\", width: active.meta.width ?? 0, height: active.meta.height ?? 0, ...(active.meta.rotation !== void 0 ? { rotation: active.meta.rotation } : {}) });\r\n }\r\n }\r\n\r\n private activeCam(): CamState | undefined {\r\n return this.activeDeviceId ? this.cameras.get(this.activeDeviceId) : undefined;\r\n }\r\n\r\n private broadcastDevices(): void {\r\n for (const view of this.views) this.broadcastDevicesTo(view);\r\n }\r\n\r\n private broadcastDevicesTo(view: WebSocket): void {\r\n if (view.readyState !== view.OPEN) return;\r\n view.send(\r\n JSON.stringify({\r\n type: \"devices\",\r\n devices: [...this.cameras.values()].map((c, i) => {\r\n const id = [...this.cameras.keys()][i]!;\r\n return { id, name: c.name, active: id === this.activeDeviceId };\r\n }),\r\n } satisfies ViewServerMessage),\r\n );\r\n }\r\n\r\n // ── capture correlation ───────────────────────────────────────────────────\r\n\r\n /** Ask the ACTIVE phone to shoot. Returns the captureId, or null when none. */\r\n requestCapture(captureId: string, note?: string): string | null {\r\n const active = this.activeCam();\r\n if (!active || active.ws.readyState !== active.ws.OPEN) return null;\r\n this.pendingCaptures.set(captureId, { note, requestedAt: Date.now() });\r\n active.ws.send(JSON.stringify({ type: \"capture\", captureId, ...(note ? { note } : {}) } satisfies CameraControl));\r\n this.broadcastToViews({ type: \"capture_pending\", captureId, ...(note ? { note } : {}) });\r\n this.gcCaptures();\r\n return captureId;\r\n }\r\n\r\n consumeCapture(captureId: string): { note?: string } | null {\r\n const pending = this.pendingCaptures.get(captureId);\r\n if (!pending) return null;\r\n this.pendingCaptures.delete(captureId);\r\n return { note: pending.note };\r\n }\r\n\r\n noteFor(captureId: string): string | undefined {\r\n return this.pendingCaptures.get(captureId)?.note;\r\n }\r\n\r\n private gcCaptures(): void {\r\n const cutoff = Date.now() - this.captureTimeoutMs;\r\n for (const [id, p] of this.pendingCaptures) {\r\n if (p.requestedAt < cutoff) this.pendingCaptures.delete(id);\r\n }\r\n }\r\n\r\n broadcastToViews(msg: ViewServerMessage): void {\r\n const text = JSON.stringify(msg);\r\n for (const view of this.views) {\r\n if (view.readyState === view.OPEN) view.send(text);\r\n }\r\n }\r\n\r\n stats(): { connected: boolean; fps: number; lastFrameAt: number; views: number; devices: number } {\r\n const active = this.activeCam();\r\n return {\r\n connected: this.cameras.size > 0,\r\n fps: active ? Math.round(active.measuredFps * 10) / 10 : 0,\r\n lastFrameAt: active?.lastFrameAt ?? 0,\r\n views: this.views.size,\r\n devices: this.cameras.size,\r\n };\r\n }\r\n}\r\n\r\nfunction safeJson(text: string): CameraControl | null {\r\n try {\r\n return JSON.parse(text) as CameraControl;\r\n } catch {\r\n return null;\r\n }\r\n}\r\n\r\nfunction parseViewClient(text: string): ViewClientMessage | null {\r\n try {\r\n return JSON.parse(text) as ViewClientMessage;\r\n } catch {\r\n return null;\r\n }\r\n}\r\n","import { join } from \"node:path\";\r\nimport { homedir } from \"node:os\";\r\nimport { env } from \"node:process\";\r\n\r\n/** Where pairing records + fallback uploads live (mirrors dsh-home conventions). */\r\nexport function lensDataDir(): string {\r\n const home = env.DSH_HOME ?? join(homedir(), \".dsh\");\r\n return join(home, \"phone-lens\");\r\n}\r\n","import { join } from \"node:path\";\r\nimport { Service } from \"@deepseek-ai/cordis\";\r\nimport z from \"@deepseek-ai/schemastery\";\r\nimport { normalizeConfig } from \"./config.js\";\r\nimport { HostDeliverySink, type EventedAgent } from \"./inject/host-sink.js\";\r\nimport { TargetTracker } from \"./inject/target.js\";\r\nimport { DeviceStore } from \"./store/devices.js\";\r\nimport { PairingStore } from \"./store/pairing.js\";\r\nimport { AppSettingsStore } from \"./store/settings.js\";\r\nimport { startLensServer, type LensServerHandle } from \"./server/http.js\";\r\nimport { ViewHub } from \"./server/hub.js\";\r\nimport { buildPairingQr } from \"./server/qr.js\";\r\nimport type { AttachmentStoreLike } from \"./inject/admit.js\";\r\nimport { lensDataDir } from \"./paths.js\";\r\n\r\ntype LogLevel = \"info\" | \"warn\" | \"error\";\r\n\r\n/**\r\n * phone-lens host plugin.\r\n *\r\n * Boots the receiver inside the dsh process so uploads can flow straight\r\n * into `ctx.attachments` (and, from Phase 2 on, into agent inboxes) with no\r\n * extra IPC. Everything registered here unwinds with the fiber.\r\n */\r\nexport default class PhoneLens extends Service {\r\n static inject: string[] = [];\r\n static Config = z.any();\r\n\r\n constructor(ctx: any, rawConfig: unknown) {\r\n super(ctx, \"phoneLens\");\r\n const config = normalizeConfig(rawConfig);\r\n const dataDir = lensDataDir();\r\n const log = (level: LogLevel, msg: string) => {\r\n const target = (ctx.logger as Record<LogLevel, (m: string) => void> | undefined) ?? console;\r\n target[level]?.(`[phone-lens] ${msg}`);\r\n };\r\n\r\n const pairing = new PairingStore(config.pairing.codeTtlMs);\r\n const devices = new DeviceStore(dataDir);\r\n // user-selected capture mode + folder target (web UI settings panel)\r\n const appSettings = new AppSettingsStore(join(dataDir, \"settings.json\"), join(dataDir, \"saved\"));\r\n const hub = new ViewHub(config, (level, msg) => log(level, msg));\r\n const targets = new TargetTracker(config);\r\n // Phase 2: real delivery into a live dsh session; the LoggingSink remains\r\n // the standalone/dev fallback. Agent events keep the sink's active target.\r\n const sink = new HostDeliverySink(config, (level, msg) => log(level, msg));\r\n const attachments = (): AttachmentStoreLike | undefined => ctx.get?.(\"attachments\") as AttachmentStoreLike | undefined;\r\n\r\n // agent wiring (scope-filtered events fire on the root ctx for global\r\n // listeners): the sink tracks the last-active session for delivery.\r\n const onAgent = (agent: EventedAgent) => sink.track(agent);\r\n const offAgent = (agent: EventedAgent) => sink.untrack(agent);\r\n ctx.on?.(\"agent/created\", (payload: { agent: EventedAgent }) => onAgent(payload.agent));\r\n ctx.on?.(\"agent/inbox/inserted\", (payload: { agent: EventedAgent }) => onAgent(payload.agent));\r\n ctx.on?.(\"agent/status\", (payload: { agent: EventedAgent; status: string }) => {\r\n if (payload.status === \"running\") onAgent(payload.agent);\r\n });\r\n ctx.on?.(\"agent/disposed\", (payload: { agent: EventedAgent }) => offAgent(payload.agent));\r\n\r\n let handle: LensServerHandle | null = null;\r\n startLensServer({\r\n config,\r\n pairing,\r\n devices,\r\n hub,\r\n targets,\r\n sink,\r\n attachments,\r\n fallbackDir: join(dataDir, \"uploads\"),\r\n pendingDir: join(dataDir, \"pending\"),\r\n appSettings,\r\n log,\r\n })\r\n .then(async (h) => {\r\n handle = h;\r\n const { code, expiresAt } = pairing.current();\r\n const qr = await buildPairingQr(code, expiresAt, config);\r\n // ASCII QR goes to the real stdout; prefixing it through the logger\r\n // would mangle the block characters.\r\n process.stdout.write(`\\n[phone-lens] 手机扫码配对(或浏览器打开 http://127.0.0.1:${h.port}/view.html):\\n${qr.ascii}\\n[phone-lens] 备用地址: ${qr.urls.join(\" \")}\\n\\n`);\r\n })\r\n .catch((error: unknown) => {\r\n log(\"error\", `receiver failed to start: ${String(error)} (check port ${config.server.port})`);\r\n });\r\n\r\n ctx.effect(() => () => {\r\n void handle?.dispose();\r\n handle = null;\r\n }, \"phone-lens.server()\");\r\n }\r\n}\r\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "phone-lens",
3
3
  "description": "Phone camera → dsh session: pairing, MJPEG viewfinder, upload-to-attachment pipeline",
4
- "version": "1.0.2",
4
+ "version": "1.0.3",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",