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