phone-lens 0.3.6 → 0.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +165 -17
- package/lib/dev.js +247 -33
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +247 -33
- package/lib/index.js.map +1 -1
- package/package.json +74 -74
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { join } from "node:path";
|
|
1
|
+
import { basename, join, resolve, sep } from "node:path";
|
|
2
2
|
import { Service } from "@deepseek-ai/cordis";
|
|
3
3
|
import z from "@deepseek-ai/schemastery";
|
|
4
4
|
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
@@ -371,6 +371,60 @@ var DeviceStore = class {
|
|
|
371
371
|
}
|
|
372
372
|
};
|
|
373
373
|
|
|
374
|
+
//#endregion
|
|
375
|
+
//#region src/store/settings.ts
|
|
376
|
+
const SAVE_MODES = [
|
|
377
|
+
"composer",
|
|
378
|
+
"folder",
|
|
379
|
+
"both"
|
|
380
|
+
];
|
|
381
|
+
/**
|
|
382
|
+
* User-facing capture settings, persisted as JSON under the plugin data dir.
|
|
383
|
+
* Stored host-side on purpose: the folder save happens on this machine, and
|
|
384
|
+
* every loopback view (overlay, /view.html) must see the same mode.
|
|
385
|
+
*/
|
|
386
|
+
var AppSettingsStore = class {
|
|
387
|
+
data;
|
|
388
|
+
constructor(file, defaultSaveDir) {
|
|
389
|
+
this.file = file;
|
|
390
|
+
this.defaultSaveDir = defaultSaveDir;
|
|
391
|
+
this.data = {
|
|
392
|
+
saveMode: "composer",
|
|
393
|
+
saveDir: ""
|
|
394
|
+
};
|
|
395
|
+
this.load();
|
|
396
|
+
}
|
|
397
|
+
load() {
|
|
398
|
+
if (!existsSync(this.file)) return;
|
|
399
|
+
try {
|
|
400
|
+
const raw = JSON.parse(readFileSync(this.file, "utf8"));
|
|
401
|
+
if (SAVE_MODES.includes(raw.saveMode)) this.data.saveMode = raw.saveMode;
|
|
402
|
+
if (typeof raw.saveDir === "string") this.data.saveDir = raw.saveDir;
|
|
403
|
+
} catch {}
|
|
404
|
+
}
|
|
405
|
+
get() {
|
|
406
|
+
return { ...this.data };
|
|
407
|
+
}
|
|
408
|
+
/** Effective absolute folder (resolved default when unset). */
|
|
409
|
+
effectiveSaveDir() {
|
|
410
|
+
return this.data.saveDir.trim() || this.defaultSaveDir;
|
|
411
|
+
}
|
|
412
|
+
set(patch) {
|
|
413
|
+
if (patch.saveMode && SAVE_MODES.includes(patch.saveMode)) this.data.saveMode = patch.saveMode;
|
|
414
|
+
if (typeof patch.saveDir === "string") this.data.saveDir = patch.saveDir.replace(/^["']|["']$/g, "").trim();
|
|
415
|
+
this.persist();
|
|
416
|
+
return this.get();
|
|
417
|
+
}
|
|
418
|
+
persist() {
|
|
419
|
+
try {
|
|
420
|
+
mkdirSync(join(this.file, ".."), { recursive: true });
|
|
421
|
+
const tmp = `${this.file}.tmp`;
|
|
422
|
+
writeFileSync(tmp, JSON.stringify(this.data, null, 2), "utf8");
|
|
423
|
+
renameSync(tmp, this.file);
|
|
424
|
+
} catch {}
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
|
|
374
428
|
//#endregion
|
|
375
429
|
//#region src/types.ts
|
|
376
430
|
const ERROR_CODES = {
|
|
@@ -768,6 +822,8 @@ function queryOf(req) {
|
|
|
768
822
|
|
|
769
823
|
//#endregion
|
|
770
824
|
//#region src/server/http.ts
|
|
825
|
+
/** Process-lifetime marker: surfaces service rebuilds via /status. */
|
|
826
|
+
const SERVER_BOOT_AT = Date.now();
|
|
771
827
|
/** Boot the receiver: HTTP routes + two websocket endpoints. */
|
|
772
828
|
async function startLensServer(deps) {
|
|
773
829
|
const { config, pairing, devices, hub, targets, sink, log } = deps;
|
|
@@ -834,18 +890,19 @@ async function startLensServer(deps) {
|
|
|
834
890
|
done(404, "Not Found");
|
|
835
891
|
});
|
|
836
892
|
const heartbeat = setInterval(() => hub.pingAll(), 2e4);
|
|
837
|
-
await new Promise((resolve, reject) => {
|
|
893
|
+
await new Promise((resolve$1, reject) => {
|
|
838
894
|
server.once("error", reject);
|
|
839
|
-
server.listen(config.server.port, config.server.host, () => resolve());
|
|
895
|
+
server.listen(config.server.port, config.server.host, () => resolve$1());
|
|
840
896
|
});
|
|
841
897
|
log("info", `phone-lens listening on ${config.server.host}:${config.server.port} (paired devices: ${devices.count()})`);
|
|
842
898
|
return {
|
|
843
899
|
port: config.server.port,
|
|
844
900
|
dispose: async () => {
|
|
845
901
|
clearInterval(heartbeat);
|
|
902
|
+
hub.dispose();
|
|
846
903
|
hub.detachAll();
|
|
847
904
|
for (const client of wss.clients) client.terminate();
|
|
848
|
-
await new Promise((resolve) => server.close(() => resolve()));
|
|
905
|
+
await new Promise((resolve$1) => server.close(() => resolve$1()));
|
|
849
906
|
}
|
|
850
907
|
};
|
|
851
908
|
}
|
|
@@ -863,10 +920,10 @@ async function handle(deps, req, res, ctx) {
|
|
|
863
920
|
}
|
|
864
921
|
if (method === "GET" && path === "/info") return sendJson(res, 200, {
|
|
865
922
|
name: "PhoneLens 直连取景",
|
|
866
|
-
version: "0.3.
|
|
923
|
+
version: "0.3.7",
|
|
867
924
|
requiresPairing: true
|
|
868
925
|
}, cors);
|
|
869
|
-
if (!loop && (path === "/" || path === "/view.html" || path === "/qr.json" || path === "/qr.png" || path === "/app-qr.json")) return sendError(res, 403, ERROR_CODES.LOOPBACK_ONLY, "preview surface is loopback-only");
|
|
926
|
+
if (!loop && (path === "/" || path === "/view.html" || path === "/qr.json" || path === "/qr.png" || path === "/app-qr.json" || path === "/app-settings")) return sendError(res, 403, ERROR_CODES.LOOPBACK_ONLY, "preview surface is loopback-only");
|
|
870
927
|
if (method === "GET" && (path === "/" || path === "/view.html")) {
|
|
871
928
|
res.writeHead(200, {
|
|
872
929
|
"content-type": "text/html; charset=utf-8",
|
|
@@ -961,7 +1018,8 @@ async function handle(deps, req, res, ctx) {
|
|
|
961
1018
|
const captureId = q.get("captureId");
|
|
962
1019
|
const note = q.get("note") ?? (captureId ? hub.noteFor(captureId) : void 0);
|
|
963
1020
|
if (captureId) hub.consumeCapture(captureId);
|
|
964
|
-
const
|
|
1021
|
+
const rawName = (q.get("name") ?? `shot_${new Date().toISOString().replace(/[:.]/g, "-")}.${mediaType === "image/png" ? "png" : "jpg"}`).slice(0, 120);
|
|
1022
|
+
const name = sanitizeFileName(rawName);
|
|
965
1023
|
let admitted;
|
|
966
1024
|
try {
|
|
967
1025
|
admitted = await admitImage({
|
|
@@ -975,18 +1033,43 @@ async function handle(deps, req, res, ctx) {
|
|
|
975
1033
|
}
|
|
976
1034
|
log("info", `stored ${name} (${buf.byteLength}B) → ${admitted.storage}:${admitted.ref.attachmentId}`);
|
|
977
1035
|
if (admitted.storage === "file") await pruneUploads(deps.fallbackDir, config.limits.maxStoredUploads).catch((error) => log("warn", `upload pruning failed: ${String(error)}`));
|
|
978
|
-
const
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1036
|
+
const st = deps.appSettings.get();
|
|
1037
|
+
const wantFolder = st.saveMode !== "composer";
|
|
1038
|
+
const wantComposer = st.saveMode !== "folder";
|
|
1039
|
+
let savedDir = null;
|
|
1040
|
+
if (wantFolder) {
|
|
1041
|
+
const dir = deps.appSettings.effectiveSaveDir();
|
|
1042
|
+
try {
|
|
1043
|
+
mkdirSync(dir, { recursive: true });
|
|
1044
|
+
const fileName = nextAvailableName(dir, name);
|
|
1045
|
+
const target = resolveUnder(dir, fileName);
|
|
1046
|
+
if (!target) throw new Error("resolved path escapes the save directory");
|
|
1047
|
+
writeFileSync(target, buf);
|
|
1048
|
+
savedDir = dir;
|
|
1049
|
+
log("info", `folder-mode saved: ${target}`);
|
|
1050
|
+
} catch (error) {
|
|
1051
|
+
log("warn", `folder save failed (${String(error)}) — falling back to composer staging`);
|
|
1052
|
+
}
|
|
985
1053
|
}
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
1054
|
+
if (wantComposer || !savedDir) {
|
|
1055
|
+
const pendingPath = resolveUnder(deps.pendingDir, pendingFileName(admitted.ref.attachmentId));
|
|
1056
|
+
try {
|
|
1057
|
+
if (!pendingPath) throw new Error("resolved path escapes the pending directory");
|
|
1058
|
+
mkdirSync(deps.pendingDir, { recursive: true });
|
|
1059
|
+
writeFileSync(pendingPath, buf);
|
|
1060
|
+
log("info", `staged for composer: ${pendingPath}`);
|
|
1061
|
+
} catch (error) {
|
|
1062
|
+
log("warn", `staging failed: ${String(error)}`);
|
|
1063
|
+
}
|
|
1064
|
+
hub.broadcastToViews({
|
|
1065
|
+
type: "pending_image",
|
|
1066
|
+
attachmentId: admitted.ref.attachmentId,
|
|
1067
|
+
name
|
|
1068
|
+
});
|
|
1069
|
+
} else hub.broadcastToViews({
|
|
1070
|
+
type: "upload_saved",
|
|
1071
|
+
name,
|
|
1072
|
+
dir: savedDir
|
|
990
1073
|
});
|
|
991
1074
|
return sendJson(res, 200, {
|
|
992
1075
|
ok: true,
|
|
@@ -996,12 +1079,13 @@ async function handle(deps, req, res, ctx) {
|
|
|
996
1079
|
bytes: admitted.ref.bytes,
|
|
997
1080
|
storage: admitted.storage,
|
|
998
1081
|
delivered: null,
|
|
999
|
-
deliverReason: "staged-in-composer"
|
|
1082
|
+
deliverReason: savedDir ? wantComposer ? "saved-and-staged" : "saved-to-folder" : "staged-in-composer"
|
|
1000
1083
|
});
|
|
1001
1084
|
}
|
|
1002
1085
|
if (method === "GET" && path.startsWith("/pending/") && loop) {
|
|
1003
1086
|
const id = path.slice(9).split("/")[0] ?? "";
|
|
1004
|
-
const file =
|
|
1087
|
+
const file = resolveUnder(deps.pendingDir, pendingFileName(id));
|
|
1088
|
+
if (!file) return sendError(res, 404, ERROR_CODES.BAD_REQUEST, "pending image not found");
|
|
1005
1089
|
try {
|
|
1006
1090
|
const data = await readFile(file);
|
|
1007
1091
|
res.writeHead(200, {
|
|
@@ -1018,6 +1102,7 @@ async function handle(deps, req, res, ctx) {
|
|
|
1018
1102
|
if (method === "GET" && path === "/status") {
|
|
1019
1103
|
const cameraDeviceId = ctx.getCurrentCamera();
|
|
1020
1104
|
return sendJson(res, 200, {
|
|
1105
|
+
bootAt: SERVER_BOOT_AT,
|
|
1021
1106
|
devices: devices.list().map((d) => ({
|
|
1022
1107
|
id: d.deviceId,
|
|
1023
1108
|
name: d.name,
|
|
@@ -1039,6 +1124,28 @@ async function handle(deps, req, res, ctx) {
|
|
|
1039
1124
|
targets: targets.list(),
|
|
1040
1125
|
default: targets.resolve()?.sessionId ?? null
|
|
1041
1126
|
}, cors);
|
|
1127
|
+
if (method === "GET" && path === "/app-settings") {
|
|
1128
|
+
const st = deps.appSettings.get();
|
|
1129
|
+
return sendJson(res, 200, {
|
|
1130
|
+
saveMode: st.saveMode,
|
|
1131
|
+
saveDir: st.saveDir,
|
|
1132
|
+
effectiveSaveDir: deps.appSettings.effectiveSaveDir()
|
|
1133
|
+
}, cors);
|
|
1134
|
+
}
|
|
1135
|
+
if (method === "POST" && path === "/app-settings") {
|
|
1136
|
+
const body = await readJsonBody(req, 4096);
|
|
1137
|
+
if (!body) return sendError(res, 400, ERROR_CODES.BAD_REQUEST, "invalid JSON body");
|
|
1138
|
+
const patch = {};
|
|
1139
|
+
if (typeof body.saveMode === "string" && SAVE_MODES.includes(body.saveMode)) patch.saveMode = body.saveMode;
|
|
1140
|
+
if (typeof body.saveDir === "string") patch.saveDir = body.saveDir;
|
|
1141
|
+
const st = deps.appSettings.set(patch);
|
|
1142
|
+
log("info", `app settings updated: mode=${st.saveMode} dir=${st.saveDir ? JSON.stringify(st.saveDir) : "(default)"}`);
|
|
1143
|
+
return sendJson(res, 200, {
|
|
1144
|
+
saveMode: st.saveMode,
|
|
1145
|
+
saveDir: st.saveDir,
|
|
1146
|
+
effectiveSaveDir: deps.appSettings.effectiveSaveDir()
|
|
1147
|
+
}, cors);
|
|
1148
|
+
}
|
|
1042
1149
|
if (method === "POST" && path === "/capture" && loop) {
|
|
1043
1150
|
const captureId = randomUUID();
|
|
1044
1151
|
const ok = hub.requestCapture(captureId, queryOf(req).get("note") ?? void 0);
|
|
@@ -1047,6 +1154,37 @@ async function handle(deps, req, res, ctx) {
|
|
|
1047
1154
|
}
|
|
1048
1155
|
return sendError(res, 404, ERROR_CODES.BAD_REQUEST, `no route ${method} ${path}`);
|
|
1049
1156
|
}
|
|
1157
|
+
/** Strip path separators / reserved characters so a phone-supplied upload
|
|
1158
|
+
* name can never escape the target directory or hide as a dotfile. */
|
|
1159
|
+
function sanitizeFileName(input) {
|
|
1160
|
+
const base = basename(input).replace(/[<>:"|?*\u0000-\u001F]/g, "_").replace(/^[\s.]+/, "").trim();
|
|
1161
|
+
return base || "shot.jpg";
|
|
1162
|
+
}
|
|
1163
|
+
/** Pick `name`, then `name (2).ext`, `name (3).ext`, … — folder saves never overwrite. */
|
|
1164
|
+
function nextAvailableName(dir, name) {
|
|
1165
|
+
if (!existsSync(join(dir, name))) return name;
|
|
1166
|
+
const dot = name.lastIndexOf(".");
|
|
1167
|
+
const stem = dot > 0 ? name.slice(0, dot) : name;
|
|
1168
|
+
const ext = dot > 0 ? name.slice(dot) : "";
|
|
1169
|
+
for (let n = 2;; n++) {
|
|
1170
|
+
const candidate = `${stem} (${n})${ext}`;
|
|
1171
|
+
if (!existsSync(join(dir, candidate))) return candidate;
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
/** Pending-staging filename for an attachment id. Ids may be URN-ish
|
|
1175
|
+
* ("file:<digest>" without the dsh attachment service) and Windows forbids
|
|
1176
|
+
* ":" in filenames — flatten to a portable name, write and read alike. */
|
|
1177
|
+
function pendingFileName(attachmentId) {
|
|
1178
|
+
return `${attachmentId.replace(/[^A-Za-z0-9._-]/g, "_")}.jpg`;
|
|
1179
|
+
}
|
|
1180
|
+
/** Resolve `name` under `baseDir` and refuse anything that escapes it
|
|
1181
|
+
* (defense in depth on top of sanitizeFileName — e.g. a crafted name that
|
|
1182
|
+
* survives sanitization but still resolves outside the base). */
|
|
1183
|
+
function resolveUnder(baseDir, name) {
|
|
1184
|
+
const base = resolve(baseDir);
|
|
1185
|
+
const target = resolve(base, name);
|
|
1186
|
+
return target === base || target.startsWith(base + sep) ? target : null;
|
|
1187
|
+
}
|
|
1050
1188
|
function sendJson(res, status, body, extraHeaders) {
|
|
1051
1189
|
res.writeHead(status, {
|
|
1052
1190
|
"content-type": "application/json; charset=utf-8",
|
|
@@ -1072,7 +1210,7 @@ async function readJsonBody(req, limit) {
|
|
|
1072
1210
|
}
|
|
1073
1211
|
}
|
|
1074
1212
|
function readRawBody(req, maxBytes) {
|
|
1075
|
-
return new Promise((resolve, reject) => {
|
|
1213
|
+
return new Promise((resolve$1, reject) => {
|
|
1076
1214
|
const chunks = [];
|
|
1077
1215
|
let size = 0;
|
|
1078
1216
|
let truncated = false;
|
|
@@ -1082,7 +1220,7 @@ function readRawBody(req, maxBytes) {
|
|
|
1082
1220
|
truncated = true;
|
|
1083
1221
|
chunks.length = 0;
|
|
1084
1222
|
req.destroy();
|
|
1085
|
-
resolve({
|
|
1223
|
+
resolve$1({
|
|
1086
1224
|
buf: Buffer.alloc(0),
|
|
1087
1225
|
truncated
|
|
1088
1226
|
});
|
|
@@ -1090,7 +1228,7 @@ function readRawBody(req, maxBytes) {
|
|
|
1090
1228
|
}
|
|
1091
1229
|
chunks.push(chunk);
|
|
1092
1230
|
});
|
|
1093
|
-
req.on("end", () => resolve({
|
|
1231
|
+
req.on("end", () => resolve$1({
|
|
1094
1232
|
buf: Buffer.concat(chunks),
|
|
1095
1233
|
truncated
|
|
1096
1234
|
}));
|
|
@@ -1122,6 +1260,22 @@ async function pruneUploads(dir, max) {
|
|
|
1122
1260
|
//#region src/server/hub.ts
|
|
1123
1261
|
/**
|
|
1124
1262
|
|
|
1263
|
+
* How long the ACTIVE device's stream may go silent before views are told the
|
|
1264
|
+
|
|
1265
|
+
* preview was turned off. The phone app never announces "preview off" — it
|
|
1266
|
+
|
|
1267
|
+
* just stops pushing frames (the WS stays up) — so the host derives the state:
|
|
1268
|
+
|
|
1269
|
+
* a live, open camera uplink with no frames for this long means the user
|
|
1270
|
+
|
|
1271
|
+
* switched preview off on the phone. Device disconnects are signalled
|
|
1272
|
+
|
|
1273
|
+
* separately via the `devices` list.
|
|
1274
|
+
|
|
1275
|
+
*/
|
|
1276
|
+
const PREVIEW_STALL_MS = 3e3;
|
|
1277
|
+
/**
|
|
1278
|
+
|
|
1125
1279
|
* The viewfinder hub: MULTIPLE camera uplinks (one per phone, keyed by
|
|
1126
1280
|
|
|
1127
1281
|
* deviceId), N loopback view downlinks. The "active" device is auto-selected
|
|
@@ -1142,13 +1296,23 @@ var ViewHub = class {
|
|
|
1142
1296
|
/** captureId → { note, requestedAt } until the matching upload lands or timeout. */
|
|
1143
1297
|
pendingCaptures = new Map();
|
|
1144
1298
|
captureTimeoutMs = 6e4;
|
|
1299
|
+
/** Whether the active device is confirmed to be streaming (hello/frame seen). */
|
|
1300
|
+
previewOn = false;
|
|
1301
|
+
stallTimer = null;
|
|
1145
1302
|
constructor(config, log) {
|
|
1146
1303
|
this.config = config;
|
|
1147
1304
|
this.log = log;
|
|
1305
|
+
this.stallTimer = setInterval(() => this.checkPreviewStall(), 1e3);
|
|
1306
|
+
}
|
|
1307
|
+
/** Stop the stall watchdog (server dispose). */
|
|
1308
|
+
dispose() {
|
|
1309
|
+
if (this.stallTimer) clearInterval(this.stallTimer);
|
|
1310
|
+
this.stallTimer = null;
|
|
1148
1311
|
}
|
|
1149
1312
|
attachCamera(deviceId, ws, name) {
|
|
1150
1313
|
const prev = this.cameras.get(deviceId);
|
|
1151
1314
|
if (prev) {
|
|
1315
|
+
this.log("warn", `camera re-attach: kicking previous ws for ${deviceId.slice(0, 8)}`);
|
|
1152
1316
|
try {
|
|
1153
1317
|
prev.ws.close(1e3, "new-instance");
|
|
1154
1318
|
} catch {}
|
|
@@ -1169,7 +1333,8 @@ var ViewHub = class {
|
|
|
1169
1333
|
if (this.activeDeviceId === deviceId) this.sendControl(deviceId, { type: "resume_preview" });
|
|
1170
1334
|
else this.sendControl(deviceId, { type: "pause_preview" });
|
|
1171
1335
|
this.log("info", `camera uplink: ${name} (${deviceId.slice(0, 8)})`);
|
|
1172
|
-
ws.on("close", () => {
|
|
1336
|
+
ws.on("close", (code, reason) => {
|
|
1337
|
+
this.log("warn", `camera ws closed: code=${code} reason=${reason.toString("utf8") || "-"} (${name} ${deviceId.slice(0, 8)})`);
|
|
1173
1338
|
if (this.cameras.get(deviceId)?.ws === ws) this.detachCamera(deviceId);
|
|
1174
1339
|
});
|
|
1175
1340
|
ws.on("message", (data, isBinary) => {
|
|
@@ -1183,6 +1348,7 @@ var ViewHub = class {
|
|
|
1183
1348
|
}
|
|
1184
1349
|
detachCamera(deviceId) {
|
|
1185
1350
|
const cam = this.cameras.get(deviceId);
|
|
1351
|
+
this.log("warn", `detachCamera(${deviceId.slice(0, 8)}) had-camera=${!!cam}`);
|
|
1186
1352
|
if (cam) this.cameras.delete(deviceId);
|
|
1187
1353
|
if (this.activeDeviceId === deviceId) {
|
|
1188
1354
|
const next = [...this.cameras.keys()].at(-1) ?? null;
|
|
@@ -1215,14 +1381,18 @@ var ViewHub = class {
|
|
|
1215
1381
|
fps: msg.fps,
|
|
1216
1382
|
...msg.rotation !== void 0 ? { rotation: msg.rotation } : {}
|
|
1217
1383
|
};
|
|
1218
|
-
if (this.activeDeviceId === deviceId)
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1384
|
+
if (this.activeDeviceId === deviceId) {
|
|
1385
|
+
this.markPreviewActive();
|
|
1386
|
+
this.broadcastToViews({
|
|
1387
|
+
type: "frame_meta",
|
|
1388
|
+
width: msg.width,
|
|
1389
|
+
height: msg.height,
|
|
1390
|
+
...msg.rotation !== void 0 ? { rotation: msg.rotation } : {}
|
|
1391
|
+
});
|
|
1392
|
+
}
|
|
1224
1393
|
break;
|
|
1225
1394
|
case "bye":
|
|
1395
|
+
this.log("warn", `camera sent bye: ${deviceId.slice(0, 8)}`);
|
|
1226
1396
|
this.detachCamera(deviceId);
|
|
1227
1397
|
break;
|
|
1228
1398
|
case "claim_active":
|
|
@@ -1258,12 +1428,50 @@ var ViewHub = class {
|
|
|
1258
1428
|
cam.frameCount = 0;
|
|
1259
1429
|
}
|
|
1260
1430
|
if (this.activeDeviceId === deviceId) {
|
|
1431
|
+
this.markPreviewActive();
|
|
1261
1432
|
for (const view of this.views) if (view.readyState === view.OPEN) view.send(frame, { binary: true });
|
|
1262
1433
|
}
|
|
1263
1434
|
}
|
|
1435
|
+
/** Flip the view-side preview state to on (once) when stream activity returns. */
|
|
1436
|
+
markPreviewActive() {
|
|
1437
|
+
if (this.previewOn) return;
|
|
1438
|
+
this.previewOn = true;
|
|
1439
|
+
this.broadcastToViews({
|
|
1440
|
+
type: "preview_state",
|
|
1441
|
+
on: true
|
|
1442
|
+
});
|
|
1443
|
+
}
|
|
1444
|
+
/**
|
|
1445
|
+
|
|
1446
|
+
* Watchdog: an open, ACTIVE camera uplink that has gone silent means the
|
|
1447
|
+
|
|
1448
|
+
* user turned preview off on the phone (the app keeps the WS while only
|
|
1449
|
+
|
|
1450
|
+
* stopping its frame pump). Tell views so they can clear the stale frame.
|
|
1451
|
+
|
|
1452
|
+
* Disconnected devices are NOT handled here — the `devices` broadcast owns
|
|
1453
|
+
|
|
1454
|
+
* the offline state on the view side.
|
|
1455
|
+
|
|
1456
|
+
*/
|
|
1457
|
+
checkPreviewStall() {
|
|
1458
|
+
if (!this.previewOn) return;
|
|
1459
|
+
const active = this.activeCam();
|
|
1460
|
+
if (!active || active.ws.readyState !== active.ws.OPEN) return;
|
|
1461
|
+
if (Date.now() - active.lastFrameAt <= PREVIEW_STALL_MS) return;
|
|
1462
|
+
this.previewOn = false;
|
|
1463
|
+
this.broadcastToViews({
|
|
1464
|
+
type: "preview_state",
|
|
1465
|
+
on: false
|
|
1466
|
+
});
|
|
1467
|
+
this.log("info", `active preview stalled >${PREVIEW_STALL_MS}ms — notified views (preview off)`);
|
|
1468
|
+
}
|
|
1264
1469
|
attachView(ws, hooks = {}) {
|
|
1265
1470
|
this.views.add(ws);
|
|
1266
|
-
ws.on("close", () =>
|
|
1471
|
+
ws.on("close", (code, reason) => {
|
|
1472
|
+
this.log("warn", `view ws closed: code=${code} reason=${reason.toString("utf8") || "-"} (views left: ${this.views.size - 1})`);
|
|
1473
|
+
this.views.delete(ws);
|
|
1474
|
+
});
|
|
1267
1475
|
ws.on("message", (data, isBinary) => {
|
|
1268
1476
|
if (isBinary) return;
|
|
1269
1477
|
const msg = parseViewClient(data.toString());
|
|
@@ -1291,7 +1499,11 @@ var ViewHub = class {
|
|
|
1291
1499
|
paired: true
|
|
1292
1500
|
}));
|
|
1293
1501
|
this.broadcastDevicesTo(ws);
|
|
1294
|
-
|
|
1502
|
+
ws.send(JSON.stringify({
|
|
1503
|
+
type: "preview_state",
|
|
1504
|
+
on: this.previewOn
|
|
1505
|
+
}));
|
|
1506
|
+
if (this.previewOn && active?.lastFrame && ws.readyState === ws.OPEN) ws.send(active.lastFrame, { binary: true });
|
|
1295
1507
|
}
|
|
1296
1508
|
viewCount() {
|
|
1297
1509
|
return this.views.size;
|
|
@@ -1320,7 +1532,7 @@ var ViewHub = class {
|
|
|
1320
1532
|
}
|
|
1321
1533
|
pushActiveFrameToViews() {
|
|
1322
1534
|
const active = this.activeCam();
|
|
1323
|
-
if (active?.lastFrame) {
|
|
1535
|
+
if (this.previewOn && active?.lastFrame) {
|
|
1324
1536
|
for (const view of this.views) if (view.readyState === view.OPEN) view.send(active.lastFrame, { binary: true });
|
|
1325
1537
|
}
|
|
1326
1538
|
if (active) this.broadcastToViews({
|
|
@@ -1450,6 +1662,7 @@ var PhoneLens = class extends Service {
|
|
|
1450
1662
|
};
|
|
1451
1663
|
const pairing = new PairingStore(config.pairing.codeTtlMs);
|
|
1452
1664
|
const devices = new DeviceStore(dataDir);
|
|
1665
|
+
const appSettings = new AppSettingsStore(join(dataDir, "settings.json"), join(dataDir, "saved"));
|
|
1453
1666
|
const hub = new ViewHub(config, (level, msg) => log(level, msg));
|
|
1454
1667
|
const targets = new TargetTracker(config);
|
|
1455
1668
|
const sink = new HostDeliverySink(config, (level, msg) => log(level, msg));
|
|
@@ -1473,6 +1686,7 @@ var PhoneLens = class extends Service {
|
|
|
1473
1686
|
attachments,
|
|
1474
1687
|
fallbackDir: join(dataDir, "uploads"),
|
|
1475
1688
|
pendingDir: join(dataDir, "pending"),
|
|
1689
|
+
appSettings,
|
|
1476
1690
|
log
|
|
1477
1691
|
}).then(async (h) => {
|
|
1478
1692
|
handle$1 = h;
|