phone-lens 0.3.6 → 0.3.8
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/dev.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { join } from "node:path";
|
|
1
|
+
import { basename, join, resolve, sep } from "node:path";
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { createHash, randomBytes, randomInt, randomUUID, timingSafeEqual } from "node:crypto";
|
|
4
4
|
import { createServer } from "node:http";
|
|
@@ -301,6 +301,60 @@ var DeviceStore = class {
|
|
|
301
301
|
}
|
|
302
302
|
};
|
|
303
303
|
|
|
304
|
+
//#endregion
|
|
305
|
+
//#region src/store/settings.ts
|
|
306
|
+
const SAVE_MODES = [
|
|
307
|
+
"composer",
|
|
308
|
+
"folder",
|
|
309
|
+
"both"
|
|
310
|
+
];
|
|
311
|
+
/**
|
|
312
|
+
* User-facing capture settings, persisted as JSON under the plugin data dir.
|
|
313
|
+
* Stored host-side on purpose: the folder save happens on this machine, and
|
|
314
|
+
* every loopback view (overlay, /view.html) must see the same mode.
|
|
315
|
+
*/
|
|
316
|
+
var AppSettingsStore = class {
|
|
317
|
+
data;
|
|
318
|
+
constructor(file, defaultSaveDir) {
|
|
319
|
+
this.file = file;
|
|
320
|
+
this.defaultSaveDir = defaultSaveDir;
|
|
321
|
+
this.data = {
|
|
322
|
+
saveMode: "composer",
|
|
323
|
+
saveDir: ""
|
|
324
|
+
};
|
|
325
|
+
this.load();
|
|
326
|
+
}
|
|
327
|
+
load() {
|
|
328
|
+
if (!existsSync(this.file)) return;
|
|
329
|
+
try {
|
|
330
|
+
const raw = JSON.parse(readFileSync(this.file, "utf8"));
|
|
331
|
+
if (SAVE_MODES.includes(raw.saveMode)) this.data.saveMode = raw.saveMode;
|
|
332
|
+
if (typeof raw.saveDir === "string") this.data.saveDir = raw.saveDir;
|
|
333
|
+
} catch {}
|
|
334
|
+
}
|
|
335
|
+
get() {
|
|
336
|
+
return { ...this.data };
|
|
337
|
+
}
|
|
338
|
+
/** Effective absolute folder (resolved default when unset). */
|
|
339
|
+
effectiveSaveDir() {
|
|
340
|
+
return this.data.saveDir.trim() || this.defaultSaveDir;
|
|
341
|
+
}
|
|
342
|
+
set(patch) {
|
|
343
|
+
if (patch.saveMode && SAVE_MODES.includes(patch.saveMode)) this.data.saveMode = patch.saveMode;
|
|
344
|
+
if (typeof patch.saveDir === "string") this.data.saveDir = patch.saveDir.replace(/^["']|["']$/g, "").trim();
|
|
345
|
+
this.persist();
|
|
346
|
+
return this.get();
|
|
347
|
+
}
|
|
348
|
+
persist() {
|
|
349
|
+
try {
|
|
350
|
+
mkdirSync(join(this.file, ".."), { recursive: true });
|
|
351
|
+
const tmp = `${this.file}.tmp`;
|
|
352
|
+
writeFileSync(tmp, JSON.stringify(this.data, null, 2), "utf8");
|
|
353
|
+
renameSync(tmp, this.file);
|
|
354
|
+
} catch {}
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
|
|
304
358
|
//#endregion
|
|
305
359
|
//#region src/types.ts
|
|
306
360
|
const ERROR_CODES = {
|
|
@@ -698,6 +752,8 @@ function queryOf(req) {
|
|
|
698
752
|
|
|
699
753
|
//#endregion
|
|
700
754
|
//#region src/server/http.ts
|
|
755
|
+
/** Process-lifetime marker: surfaces service rebuilds via /status. */
|
|
756
|
+
const SERVER_BOOT_AT = Date.now();
|
|
701
757
|
/** Boot the receiver: HTTP routes + two websocket endpoints. */
|
|
702
758
|
async function startLensServer(deps) {
|
|
703
759
|
const { config: config$1, pairing: pairing$1, devices: devices$1, hub: hub$1, targets: targets$1, sink: sink$1, log: log$1 } = deps;
|
|
@@ -764,18 +820,19 @@ async function startLensServer(deps) {
|
|
|
764
820
|
done(404, "Not Found");
|
|
765
821
|
});
|
|
766
822
|
const heartbeat = setInterval(() => hub$1.pingAll(), 2e4);
|
|
767
|
-
await new Promise((resolve, reject) => {
|
|
823
|
+
await new Promise((resolve$1, reject) => {
|
|
768
824
|
server.once("error", reject);
|
|
769
|
-
server.listen(config$1.server.port, config$1.server.host, () => resolve());
|
|
825
|
+
server.listen(config$1.server.port, config$1.server.host, () => resolve$1());
|
|
770
826
|
});
|
|
771
827
|
log$1("info", `phone-lens listening on ${config$1.server.host}:${config$1.server.port} (paired devices: ${devices$1.count()})`);
|
|
772
828
|
return {
|
|
773
829
|
port: config$1.server.port,
|
|
774
830
|
dispose: async () => {
|
|
775
831
|
clearInterval(heartbeat);
|
|
832
|
+
hub$1.dispose();
|
|
776
833
|
hub$1.detachAll();
|
|
777
834
|
for (const client of wss.clients) client.terminate();
|
|
778
|
-
await new Promise((resolve) => server.close(() => resolve()));
|
|
835
|
+
await new Promise((resolve$1) => server.close(() => resolve$1()));
|
|
779
836
|
}
|
|
780
837
|
};
|
|
781
838
|
}
|
|
@@ -793,10 +850,10 @@ async function handle$1(deps, req, res, ctx) {
|
|
|
793
850
|
}
|
|
794
851
|
if (method === "GET" && path === "/info") return sendJson(res, 200, {
|
|
795
852
|
name: "PhoneLens 直连取景",
|
|
796
|
-
version: "0.3.
|
|
853
|
+
version: "0.3.7",
|
|
797
854
|
requiresPairing: true
|
|
798
855
|
}, cors);
|
|
799
|
-
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");
|
|
856
|
+
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");
|
|
800
857
|
if (method === "GET" && (path === "/" || path === "/view.html")) {
|
|
801
858
|
res.writeHead(200, {
|
|
802
859
|
"content-type": "text/html; charset=utf-8",
|
|
@@ -891,7 +948,8 @@ async function handle$1(deps, req, res, ctx) {
|
|
|
891
948
|
const captureId = q.get("captureId");
|
|
892
949
|
const note = q.get("note") ?? (captureId ? hub$1.noteFor(captureId) : void 0);
|
|
893
950
|
if (captureId) hub$1.consumeCapture(captureId);
|
|
894
|
-
const
|
|
951
|
+
const rawName = (q.get("name") ?? `shot_${new Date().toISOString().replace(/[:.]/g, "-")}.${mediaType === "image/png" ? "png" : "jpg"}`).slice(0, 120);
|
|
952
|
+
const name = sanitizeFileName(rawName);
|
|
895
953
|
let admitted;
|
|
896
954
|
try {
|
|
897
955
|
admitted = await admitImage({
|
|
@@ -905,18 +963,43 @@ async function handle$1(deps, req, res, ctx) {
|
|
|
905
963
|
}
|
|
906
964
|
log$1("info", `stored ${name} (${buf.byteLength}B) → ${admitted.storage}:${admitted.ref.attachmentId}`);
|
|
907
965
|
if (admitted.storage === "file") await pruneUploads(deps.fallbackDir, config$1.limits.maxStoredUploads).catch((error) => log$1("warn", `upload pruning failed: ${String(error)}`));
|
|
908
|
-
const
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
966
|
+
const st = deps.appSettings.get();
|
|
967
|
+
const wantFolder = st.saveMode !== "composer";
|
|
968
|
+
const wantComposer = st.saveMode !== "folder";
|
|
969
|
+
let savedDir = null;
|
|
970
|
+
if (wantFolder) {
|
|
971
|
+
const dir = deps.appSettings.effectiveSaveDir();
|
|
972
|
+
try {
|
|
973
|
+
mkdirSync(dir, { recursive: true });
|
|
974
|
+
const fileName = nextAvailableName(dir, name);
|
|
975
|
+
const target = resolveUnder(dir, fileName);
|
|
976
|
+
if (!target) throw new Error("resolved path escapes the save directory");
|
|
977
|
+
writeFileSync(target, buf);
|
|
978
|
+
savedDir = dir;
|
|
979
|
+
log$1("info", `folder-mode saved: ${target}`);
|
|
980
|
+
} catch (error) {
|
|
981
|
+
log$1("warn", `folder save failed (${String(error)}) — falling back to composer staging`);
|
|
982
|
+
}
|
|
915
983
|
}
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
984
|
+
if (wantComposer || !savedDir) {
|
|
985
|
+
const pendingPath = resolveUnder(deps.pendingDir, pendingFileName(admitted.ref.attachmentId));
|
|
986
|
+
try {
|
|
987
|
+
if (!pendingPath) throw new Error("resolved path escapes the pending directory");
|
|
988
|
+
mkdirSync(deps.pendingDir, { recursive: true });
|
|
989
|
+
writeFileSync(pendingPath, buf);
|
|
990
|
+
log$1("info", `staged for composer: ${pendingPath}`);
|
|
991
|
+
} catch (error) {
|
|
992
|
+
log$1("warn", `staging failed: ${String(error)}`);
|
|
993
|
+
}
|
|
994
|
+
hub$1.broadcastToViews({
|
|
995
|
+
type: "pending_image",
|
|
996
|
+
attachmentId: admitted.ref.attachmentId,
|
|
997
|
+
name
|
|
998
|
+
});
|
|
999
|
+
} else hub$1.broadcastToViews({
|
|
1000
|
+
type: "upload_saved",
|
|
1001
|
+
name,
|
|
1002
|
+
dir: savedDir
|
|
920
1003
|
});
|
|
921
1004
|
return sendJson(res, 200, {
|
|
922
1005
|
ok: true,
|
|
@@ -926,12 +1009,13 @@ async function handle$1(deps, req, res, ctx) {
|
|
|
926
1009
|
bytes: admitted.ref.bytes,
|
|
927
1010
|
storage: admitted.storage,
|
|
928
1011
|
delivered: null,
|
|
929
|
-
deliverReason: "staged-in-composer"
|
|
1012
|
+
deliverReason: savedDir ? wantComposer ? "saved-and-staged" : "saved-to-folder" : "staged-in-composer"
|
|
930
1013
|
});
|
|
931
1014
|
}
|
|
932
1015
|
if (method === "GET" && path.startsWith("/pending/") && loop) {
|
|
933
1016
|
const id = path.slice(9).split("/")[0] ?? "";
|
|
934
|
-
const file =
|
|
1017
|
+
const file = resolveUnder(deps.pendingDir, pendingFileName(id));
|
|
1018
|
+
if (!file) return sendError(res, 404, ERROR_CODES.BAD_REQUEST, "pending image not found");
|
|
935
1019
|
try {
|
|
936
1020
|
const data = await readFile(file);
|
|
937
1021
|
res.writeHead(200, {
|
|
@@ -948,6 +1032,7 @@ async function handle$1(deps, req, res, ctx) {
|
|
|
948
1032
|
if (method === "GET" && path === "/status") {
|
|
949
1033
|
const cameraDeviceId = ctx.getCurrentCamera();
|
|
950
1034
|
return sendJson(res, 200, {
|
|
1035
|
+
bootAt: SERVER_BOOT_AT,
|
|
951
1036
|
devices: devices$1.list().map((d) => ({
|
|
952
1037
|
id: d.deviceId,
|
|
953
1038
|
name: d.name,
|
|
@@ -969,6 +1054,28 @@ async function handle$1(deps, req, res, ctx) {
|
|
|
969
1054
|
targets: targets$1.list(),
|
|
970
1055
|
default: targets$1.resolve()?.sessionId ?? null
|
|
971
1056
|
}, cors);
|
|
1057
|
+
if (method === "GET" && path === "/app-settings") {
|
|
1058
|
+
const st = deps.appSettings.get();
|
|
1059
|
+
return sendJson(res, 200, {
|
|
1060
|
+
saveMode: st.saveMode,
|
|
1061
|
+
saveDir: st.saveDir,
|
|
1062
|
+
effectiveSaveDir: deps.appSettings.effectiveSaveDir()
|
|
1063
|
+
}, cors);
|
|
1064
|
+
}
|
|
1065
|
+
if (method === "POST" && path === "/app-settings") {
|
|
1066
|
+
const body = await readJsonBody(req, 4096);
|
|
1067
|
+
if (!body) return sendError(res, 400, ERROR_CODES.BAD_REQUEST, "invalid JSON body");
|
|
1068
|
+
const patch = {};
|
|
1069
|
+
if (typeof body.saveMode === "string" && SAVE_MODES.includes(body.saveMode)) patch.saveMode = body.saveMode;
|
|
1070
|
+
if (typeof body.saveDir === "string") patch.saveDir = body.saveDir;
|
|
1071
|
+
const st = deps.appSettings.set(patch);
|
|
1072
|
+
log$1("info", `app settings updated: mode=${st.saveMode} dir=${st.saveDir ? JSON.stringify(st.saveDir) : "(default)"}`);
|
|
1073
|
+
return sendJson(res, 200, {
|
|
1074
|
+
saveMode: st.saveMode,
|
|
1075
|
+
saveDir: st.saveDir,
|
|
1076
|
+
effectiveSaveDir: deps.appSettings.effectiveSaveDir()
|
|
1077
|
+
}, cors);
|
|
1078
|
+
}
|
|
972
1079
|
if (method === "POST" && path === "/capture" && loop) {
|
|
973
1080
|
const captureId = randomUUID();
|
|
974
1081
|
const ok = hub$1.requestCapture(captureId, queryOf(req).get("note") ?? void 0);
|
|
@@ -977,6 +1084,37 @@ async function handle$1(deps, req, res, ctx) {
|
|
|
977
1084
|
}
|
|
978
1085
|
return sendError(res, 404, ERROR_CODES.BAD_REQUEST, `no route ${method} ${path}`);
|
|
979
1086
|
}
|
|
1087
|
+
/** Strip path separators / reserved characters so a phone-supplied upload
|
|
1088
|
+
* name can never escape the target directory or hide as a dotfile. */
|
|
1089
|
+
function sanitizeFileName(input) {
|
|
1090
|
+
const base = basename(input).replace(/[<>:"|?*\u0000-\u001F]/g, "_").replace(/^[\s.]+/, "").trim();
|
|
1091
|
+
return base || "shot.jpg";
|
|
1092
|
+
}
|
|
1093
|
+
/** Pick `name`, then `name (2).ext`, `name (3).ext`, … — folder saves never overwrite. */
|
|
1094
|
+
function nextAvailableName(dir, name) {
|
|
1095
|
+
if (!existsSync(join(dir, name))) return name;
|
|
1096
|
+
const dot = name.lastIndexOf(".");
|
|
1097
|
+
const stem = dot > 0 ? name.slice(0, dot) : name;
|
|
1098
|
+
const ext = dot > 0 ? name.slice(dot) : "";
|
|
1099
|
+
for (let n = 2;; n++) {
|
|
1100
|
+
const candidate = `${stem} (${n})${ext}`;
|
|
1101
|
+
if (!existsSync(join(dir, candidate))) return candidate;
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
/** Pending-staging filename for an attachment id. Ids may be URN-ish
|
|
1105
|
+
* ("file:<digest>" without the dsh attachment service) and Windows forbids
|
|
1106
|
+
* ":" in filenames — flatten to a portable name, write and read alike. */
|
|
1107
|
+
function pendingFileName(attachmentId) {
|
|
1108
|
+
return `${attachmentId.replace(/[^A-Za-z0-9._-]/g, "_")}.jpg`;
|
|
1109
|
+
}
|
|
1110
|
+
/** Resolve `name` under `baseDir` and refuse anything that escapes it
|
|
1111
|
+
* (defense in depth on top of sanitizeFileName — e.g. a crafted name that
|
|
1112
|
+
* survives sanitization but still resolves outside the base). */
|
|
1113
|
+
function resolveUnder(baseDir, name) {
|
|
1114
|
+
const base = resolve(baseDir);
|
|
1115
|
+
const target = resolve(base, name);
|
|
1116
|
+
return target === base || target.startsWith(base + sep) ? target : null;
|
|
1117
|
+
}
|
|
980
1118
|
function sendJson(res, status, body, extraHeaders) {
|
|
981
1119
|
res.writeHead(status, {
|
|
982
1120
|
"content-type": "application/json; charset=utf-8",
|
|
@@ -1002,7 +1140,7 @@ async function readJsonBody(req, limit) {
|
|
|
1002
1140
|
}
|
|
1003
1141
|
}
|
|
1004
1142
|
function readRawBody(req, maxBytes) {
|
|
1005
|
-
return new Promise((resolve, reject) => {
|
|
1143
|
+
return new Promise((resolve$1, reject) => {
|
|
1006
1144
|
const chunks = [];
|
|
1007
1145
|
let size = 0;
|
|
1008
1146
|
let truncated = false;
|
|
@@ -1012,7 +1150,7 @@ function readRawBody(req, maxBytes) {
|
|
|
1012
1150
|
truncated = true;
|
|
1013
1151
|
chunks.length = 0;
|
|
1014
1152
|
req.destroy();
|
|
1015
|
-
resolve({
|
|
1153
|
+
resolve$1({
|
|
1016
1154
|
buf: Buffer.alloc(0),
|
|
1017
1155
|
truncated
|
|
1018
1156
|
});
|
|
@@ -1020,7 +1158,7 @@ function readRawBody(req, maxBytes) {
|
|
|
1020
1158
|
}
|
|
1021
1159
|
chunks.push(chunk);
|
|
1022
1160
|
});
|
|
1023
|
-
req.on("end", () => resolve({
|
|
1161
|
+
req.on("end", () => resolve$1({
|
|
1024
1162
|
buf: Buffer.concat(chunks),
|
|
1025
1163
|
truncated
|
|
1026
1164
|
}));
|
|
@@ -1052,6 +1190,22 @@ async function pruneUploads(dir, max) {
|
|
|
1052
1190
|
//#region src/server/hub.ts
|
|
1053
1191
|
/**
|
|
1054
1192
|
|
|
1193
|
+
* How long the ACTIVE device's stream may go silent before views are told the
|
|
1194
|
+
|
|
1195
|
+
* preview was turned off. The phone app never announces "preview off" — it
|
|
1196
|
+
|
|
1197
|
+
* just stops pushing frames (the WS stays up) — so the host derives the state:
|
|
1198
|
+
|
|
1199
|
+
* a live, open camera uplink with no frames for this long means the user
|
|
1200
|
+
|
|
1201
|
+
* switched preview off on the phone. Device disconnects are signalled
|
|
1202
|
+
|
|
1203
|
+
* separately via the `devices` list.
|
|
1204
|
+
|
|
1205
|
+
*/
|
|
1206
|
+
const PREVIEW_STALL_MS = 3e3;
|
|
1207
|
+
/**
|
|
1208
|
+
|
|
1055
1209
|
* The viewfinder hub: MULTIPLE camera uplinks (one per phone, keyed by
|
|
1056
1210
|
|
|
1057
1211
|
* deviceId), N loopback view downlinks. The "active" device is auto-selected
|
|
@@ -1072,13 +1226,23 @@ var ViewHub = class {
|
|
|
1072
1226
|
/** captureId → { note, requestedAt } until the matching upload lands or timeout. */
|
|
1073
1227
|
pendingCaptures = new Map();
|
|
1074
1228
|
captureTimeoutMs = 6e4;
|
|
1229
|
+
/** Whether the active device is confirmed to be streaming (hello/frame seen). */
|
|
1230
|
+
previewOn = false;
|
|
1231
|
+
stallTimer = null;
|
|
1075
1232
|
constructor(config$1, log$1) {
|
|
1076
1233
|
this.config = config$1;
|
|
1077
1234
|
this.log = log$1;
|
|
1235
|
+
this.stallTimer = setInterval(() => this.checkPreviewStall(), 1e3);
|
|
1236
|
+
}
|
|
1237
|
+
/** Stop the stall watchdog (server dispose). */
|
|
1238
|
+
dispose() {
|
|
1239
|
+
if (this.stallTimer) clearInterval(this.stallTimer);
|
|
1240
|
+
this.stallTimer = null;
|
|
1078
1241
|
}
|
|
1079
1242
|
attachCamera(deviceId, ws, name) {
|
|
1080
1243
|
const prev = this.cameras.get(deviceId);
|
|
1081
1244
|
if (prev) {
|
|
1245
|
+
this.log("warn", `camera re-attach: kicking previous ws for ${deviceId.slice(0, 8)}`);
|
|
1082
1246
|
try {
|
|
1083
1247
|
prev.ws.close(1e3, "new-instance");
|
|
1084
1248
|
} catch {}
|
|
@@ -1099,7 +1263,8 @@ var ViewHub = class {
|
|
|
1099
1263
|
if (this.activeDeviceId === deviceId) this.sendControl(deviceId, { type: "resume_preview" });
|
|
1100
1264
|
else this.sendControl(deviceId, { type: "pause_preview" });
|
|
1101
1265
|
this.log("info", `camera uplink: ${name} (${deviceId.slice(0, 8)})`);
|
|
1102
|
-
ws.on("close", () => {
|
|
1266
|
+
ws.on("close", (code, reason) => {
|
|
1267
|
+
this.log("warn", `camera ws closed: code=${code} reason=${reason.toString("utf8") || "-"} (${name} ${deviceId.slice(0, 8)})`);
|
|
1103
1268
|
if (this.cameras.get(deviceId)?.ws === ws) this.detachCamera(deviceId);
|
|
1104
1269
|
});
|
|
1105
1270
|
ws.on("message", (data, isBinary) => {
|
|
@@ -1113,6 +1278,7 @@ var ViewHub = class {
|
|
|
1113
1278
|
}
|
|
1114
1279
|
detachCamera(deviceId) {
|
|
1115
1280
|
const cam = this.cameras.get(deviceId);
|
|
1281
|
+
this.log("warn", `detachCamera(${deviceId.slice(0, 8)}) had-camera=${!!cam}`);
|
|
1116
1282
|
if (cam) this.cameras.delete(deviceId);
|
|
1117
1283
|
if (this.activeDeviceId === deviceId) {
|
|
1118
1284
|
const next = [...this.cameras.keys()].at(-1) ?? null;
|
|
@@ -1145,14 +1311,18 @@ var ViewHub = class {
|
|
|
1145
1311
|
fps: msg.fps,
|
|
1146
1312
|
...msg.rotation !== void 0 ? { rotation: msg.rotation } : {}
|
|
1147
1313
|
};
|
|
1148
|
-
if (this.activeDeviceId === deviceId)
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1314
|
+
if (this.activeDeviceId === deviceId) {
|
|
1315
|
+
this.markPreviewActive();
|
|
1316
|
+
this.broadcastToViews({
|
|
1317
|
+
type: "frame_meta",
|
|
1318
|
+
width: msg.width,
|
|
1319
|
+
height: msg.height,
|
|
1320
|
+
...msg.rotation !== void 0 ? { rotation: msg.rotation } : {}
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1154
1323
|
break;
|
|
1155
1324
|
case "bye":
|
|
1325
|
+
this.log("warn", `camera sent bye: ${deviceId.slice(0, 8)}`);
|
|
1156
1326
|
this.detachCamera(deviceId);
|
|
1157
1327
|
break;
|
|
1158
1328
|
case "claim_active":
|
|
@@ -1188,12 +1358,50 @@ var ViewHub = class {
|
|
|
1188
1358
|
cam.frameCount = 0;
|
|
1189
1359
|
}
|
|
1190
1360
|
if (this.activeDeviceId === deviceId) {
|
|
1361
|
+
this.markPreviewActive();
|
|
1191
1362
|
for (const view of this.views) if (view.readyState === view.OPEN) view.send(frame, { binary: true });
|
|
1192
1363
|
}
|
|
1193
1364
|
}
|
|
1365
|
+
/** Flip the view-side preview state to on (once) when stream activity returns. */
|
|
1366
|
+
markPreviewActive() {
|
|
1367
|
+
if (this.previewOn) return;
|
|
1368
|
+
this.previewOn = true;
|
|
1369
|
+
this.broadcastToViews({
|
|
1370
|
+
type: "preview_state",
|
|
1371
|
+
on: true
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
/**
|
|
1375
|
+
|
|
1376
|
+
* Watchdog: an open, ACTIVE camera uplink that has gone silent means the
|
|
1377
|
+
|
|
1378
|
+
* user turned preview off on the phone (the app keeps the WS while only
|
|
1379
|
+
|
|
1380
|
+
* stopping its frame pump). Tell views so they can clear the stale frame.
|
|
1381
|
+
|
|
1382
|
+
* Disconnected devices are NOT handled here — the `devices` broadcast owns
|
|
1383
|
+
|
|
1384
|
+
* the offline state on the view side.
|
|
1385
|
+
|
|
1386
|
+
*/
|
|
1387
|
+
checkPreviewStall() {
|
|
1388
|
+
if (!this.previewOn) return;
|
|
1389
|
+
const active = this.activeCam();
|
|
1390
|
+
if (!active || active.ws.readyState !== active.ws.OPEN) return;
|
|
1391
|
+
if (Date.now() - active.lastFrameAt <= PREVIEW_STALL_MS) return;
|
|
1392
|
+
this.previewOn = false;
|
|
1393
|
+
this.broadcastToViews({
|
|
1394
|
+
type: "preview_state",
|
|
1395
|
+
on: false
|
|
1396
|
+
});
|
|
1397
|
+
this.log("info", `active preview stalled >${PREVIEW_STALL_MS}ms — notified views (preview off)`);
|
|
1398
|
+
}
|
|
1194
1399
|
attachView(ws, hooks = {}) {
|
|
1195
1400
|
this.views.add(ws);
|
|
1196
|
-
ws.on("close", () =>
|
|
1401
|
+
ws.on("close", (code, reason) => {
|
|
1402
|
+
this.log("warn", `view ws closed: code=${code} reason=${reason.toString("utf8") || "-"} (views left: ${this.views.size - 1})`);
|
|
1403
|
+
this.views.delete(ws);
|
|
1404
|
+
});
|
|
1197
1405
|
ws.on("message", (data, isBinary) => {
|
|
1198
1406
|
if (isBinary) return;
|
|
1199
1407
|
const msg = parseViewClient(data.toString());
|
|
@@ -1221,7 +1429,11 @@ var ViewHub = class {
|
|
|
1221
1429
|
paired: true
|
|
1222
1430
|
}));
|
|
1223
1431
|
this.broadcastDevicesTo(ws);
|
|
1224
|
-
|
|
1432
|
+
ws.send(JSON.stringify({
|
|
1433
|
+
type: "preview_state",
|
|
1434
|
+
on: this.previewOn
|
|
1435
|
+
}));
|
|
1436
|
+
if (this.previewOn && active?.lastFrame && ws.readyState === ws.OPEN) ws.send(active.lastFrame, { binary: true });
|
|
1225
1437
|
}
|
|
1226
1438
|
viewCount() {
|
|
1227
1439
|
return this.views.size;
|
|
@@ -1250,7 +1462,7 @@ var ViewHub = class {
|
|
|
1250
1462
|
}
|
|
1251
1463
|
pushActiveFrameToViews() {
|
|
1252
1464
|
const active = this.activeCam();
|
|
1253
|
-
if (active?.lastFrame) {
|
|
1465
|
+
if (this.previewOn && active?.lastFrame) {
|
|
1254
1466
|
for (const view of this.views) if (view.readyState === view.OPEN) view.send(active.lastFrame, { binary: true });
|
|
1255
1467
|
}
|
|
1256
1468
|
if (active) this.broadcastToViews({
|
|
@@ -1359,6 +1571,7 @@ const dataDir = process.env.LENS_DATA_DIR ?? lensDataDir();
|
|
|
1359
1571
|
const log = (level, msg) => console[level](`[phone-lens:dev] ${msg}`);
|
|
1360
1572
|
const pairing = new PairingStore(config.pairing.codeTtlMs);
|
|
1361
1573
|
const devices = new DeviceStore(dataDir);
|
|
1574
|
+
const appSettings = new AppSettingsStore(join(dataDir, "settings.json"), join(dataDir, "saved"));
|
|
1362
1575
|
const hub = new ViewHub(config, (lvl, msg) => log(lvl, msg));
|
|
1363
1576
|
const targets = new TargetTracker(config);
|
|
1364
1577
|
const sink = new LoggingSink((lvl, msg) => log(lvl, msg));
|
|
@@ -1372,6 +1585,7 @@ const handle = await startLensServer({
|
|
|
1372
1585
|
attachments: () => void 0,
|
|
1373
1586
|
fallbackDir: join(dataDir, "uploads"),
|
|
1374
1587
|
pendingDir: join(dataDir, "pending"),
|
|
1588
|
+
appSettings,
|
|
1375
1589
|
log
|
|
1376
1590
|
});
|
|
1377
1591
|
const qr = await buildPairingQr(pairing.current().code, pairing.current().expiresAt, config);
|
package/lib/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/index.ts"],"sourcesContent":null,"mappings":";;;;;;;;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/index.ts"],"sourcesContent":null,"mappings":";;;;;;;;;;;;;;;;;;cAwBqB,SAAA,SAAkB,OAAA;;EAAlB,OAAA,MAAU,EAEhB,CAFgB,CAAA,GAAA,EAAA,GAAA,CAAA;EAAA,WAAA,CAAA,GAAA,EAAA,GAAA,EAAA,SAAA,EAAA,OAAA"}
|