phone-lens 0.3.5
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/README.md +60 -0
- package/cordis.patch.yml +35 -0
- package/lib/client.js +568 -0
- package/lib/dev.js +1390 -0
- package/lib/index.d.ts +29 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +1494 -0
- package/lib/index.js.map +1 -0
- package/package.json +74 -0
package/lib/dev.js
ADDED
|
@@ -0,0 +1,1390 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { createHash, randomBytes, randomInt, randomUUID, timingSafeEqual } from "node:crypto";
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
import { readFile, readdir, stat, unlink } from "node:fs/promises";
|
|
6
|
+
import { WebSocketServer } from "ws";
|
|
7
|
+
import QRCode from "qrcode";
|
|
8
|
+
import * as os from "node:os";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { env } from "node:process";
|
|
11
|
+
|
|
12
|
+
//#region src/config.ts
|
|
13
|
+
/** Coerce an unknown config object (cordis patch row / CLI overrides) into LensConfig. */
|
|
14
|
+
function normalizeConfig(raw) {
|
|
15
|
+
const r = raw ?? {};
|
|
16
|
+
const server = r.server ?? {};
|
|
17
|
+
const limits = r.limits ?? {};
|
|
18
|
+
const pairing$1 = r.pairing ?? {};
|
|
19
|
+
const preview = r.preview ?? {};
|
|
20
|
+
const inject = r.inject ?? {};
|
|
21
|
+
const target = r.target ?? {};
|
|
22
|
+
const app = r.app ?? {};
|
|
23
|
+
const GITEE_APK = "https://gitee.com/qianfengbingtang/phone-lens/releases/download/v0.2.0/app-release.apk";
|
|
24
|
+
const GITHUB_APK = "https://github.com/yxqfg/phone-lens/releases/latest/download/app-release.apk";
|
|
25
|
+
const giteeUrl = typeof app.giteeUrl === "string" && app.giteeUrl ? app.giteeUrl : GITEE_APK;
|
|
26
|
+
const allowed = Array.isArray(limits.allowedTypes) ? limits.allowedTypes.filter((t) => typeof t === "string") : void 0;
|
|
27
|
+
const mode = inject.mode === "steer" ? "steer" : "followup";
|
|
28
|
+
return {
|
|
29
|
+
server: {
|
|
30
|
+
host: typeof server.host === "string" && server.host ? server.host : "0.0.0.0",
|
|
31
|
+
port: Number.isInteger(server.port) && server.port > 0 && server.port < 65536 ? server.port : 8791
|
|
32
|
+
},
|
|
33
|
+
limits: {
|
|
34
|
+
maxUploadBytes: positiveInt(limits.maxUploadBytes, 10 * 1024 * 1024),
|
|
35
|
+
allowedTypes: allowed && allowed.length > 0 ? allowed : ["image/jpeg", "image/png"],
|
|
36
|
+
uploadsPerMinute: positiveInt(limits.uploadsPerMinute, 10),
|
|
37
|
+
previewFrameMaxBytes: positiveInt(limits.previewFrameMaxBytes, 512 * 1024),
|
|
38
|
+
maxStoredUploads: positiveInt(limits.maxStoredUploads, 200)
|
|
39
|
+
},
|
|
40
|
+
pairing: { codeTtlMs: positiveInt(pairing$1.codeTtlMs, 15 * 60 * 1e3) },
|
|
41
|
+
preview: {
|
|
42
|
+
maxWidth: positiveInt(preview.maxWidth, 854),
|
|
43
|
+
maxHeight: positiveInt(preview.maxHeight, 480),
|
|
44
|
+
fps: clampInt(preview.fps, 1, 30, 10),
|
|
45
|
+
jpegQuality: clampInt(preview.jpegQuality, 20, 95, 70)
|
|
46
|
+
},
|
|
47
|
+
inject: {
|
|
48
|
+
mode,
|
|
49
|
+
notePrefix: typeof inject.notePrefix === "string" ? inject.notePrefix : "[手机拍照]"
|
|
50
|
+
},
|
|
51
|
+
target: {
|
|
52
|
+
mode: target.mode === "pinned" ? "pinned" : "latest",
|
|
53
|
+
pinnedSessionId: typeof target.pinnedSessionId === "string" && target.pinnedSessionId ? target.pinnedSessionId : null
|
|
54
|
+
},
|
|
55
|
+
app: {
|
|
56
|
+
giteeUrl,
|
|
57
|
+
githubUrl: typeof app.githubUrl === "string" && app.githubUrl ? app.githubUrl : GITHUB_APK,
|
|
58
|
+
downloadUrl: typeof app.downloadUrl === "string" && app.downloadUrl ? app.downloadUrl : giteeUrl
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function positiveInt(value, fallback) {
|
|
63
|
+
return Number.isInteger(value) && value > 0 ? value : fallback;
|
|
64
|
+
}
|
|
65
|
+
function clampInt(value, min, max, fallback) {
|
|
66
|
+
if (!Number.isInteger(value)) return fallback;
|
|
67
|
+
const n = value;
|
|
68
|
+
return Math.min(max, Math.max(min, n));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
//#endregion
|
|
72
|
+
//#region src/inject/deliver.ts
|
|
73
|
+
/**
|
|
74
|
+
|
|
75
|
+
* Phase 1 sink: logs the delivery that Phase 2 will perform through
|
|
76
|
+
|
|
77
|
+
* createUserMessage + agent.followup/steer inside the dsh process.
|
|
78
|
+
|
|
79
|
+
*/
|
|
80
|
+
var LoggingSink = class {
|
|
81
|
+
constructor(log$1) {
|
|
82
|
+
this.log = log$1;
|
|
83
|
+
}
|
|
84
|
+
async deliver(admitted, note, mode) {
|
|
85
|
+
this.log("info", `[deliver:${mode}] ${admitted.ref.mediaType} ${admitted.ref.width}x${admitted.ref.height} ${admitted.ref.bytes}B (attachmentId=${admitted.ref.attachmentId}, storage=${admitted.storage}${admitted.filePath ? `, file=${admitted.filePath}` : ""})${note ? ` note="${note}"` : ""} — Phase 2 will inject this into the active session`);
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
sessionId: null,
|
|
89
|
+
mode: "none",
|
|
90
|
+
reason: "delivery activates in Phase 2"
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
//#endregion
|
|
96
|
+
//#region src/inject/target.ts
|
|
97
|
+
/**
|
|
98
|
+
|
|
99
|
+
* Resolves the session an upload should be injected into.
|
|
100
|
+
|
|
101
|
+
*
|
|
102
|
+
|
|
103
|
+
* Phase 1: pure config view, no live agents (the agent-event wiring that
|
|
104
|
+
|
|
105
|
+
* feeds `latest` lands in Phase 2 — see architecture.md §2 D4).
|
|
106
|
+
|
|
107
|
+
*/
|
|
108
|
+
var TargetTracker = class {
|
|
109
|
+
constructor(config$1) {
|
|
110
|
+
this.config = config$1;
|
|
111
|
+
}
|
|
112
|
+
/** The session an incoming image would target right now, if any. */
|
|
113
|
+
resolve() {
|
|
114
|
+
if (this.config.target.mode === "pinned" && this.config.target.pinnedSessionId) return {
|
|
115
|
+
sessionId: this.config.target.pinnedSessionId,
|
|
116
|
+
title: "(pinned)",
|
|
117
|
+
active: true,
|
|
118
|
+
running: false
|
|
119
|
+
};
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
/** Choosable targets for GET /targets. */
|
|
123
|
+
list() {
|
|
124
|
+
const pinned = this.resolve();
|
|
125
|
+
return pinned ? [pinned] : [];
|
|
126
|
+
}
|
|
127
|
+
mode() {
|
|
128
|
+
return this.config.target.mode;
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
//#endregion
|
|
133
|
+
//#region src/store/pairing.ts
|
|
134
|
+
/** One-shot pairing-code lifecycle: create / verify / burn, with TTL. */
|
|
135
|
+
var PairingStore = class {
|
|
136
|
+
code = null;
|
|
137
|
+
createdAt = 0;
|
|
138
|
+
expiresAt = 0;
|
|
139
|
+
used = false;
|
|
140
|
+
constructor(ttlMs) {
|
|
141
|
+
this.ttlMs = ttlMs;
|
|
142
|
+
}
|
|
143
|
+
/** Current live code, or a freshly minted one. */
|
|
144
|
+
current() {
|
|
145
|
+
const now = Date.now();
|
|
146
|
+
if (this.code === null || this.used || now >= this.expiresAt) this.mint(now);
|
|
147
|
+
return {
|
|
148
|
+
code: this.code,
|
|
149
|
+
expiresAt: this.expiresAt
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/** Force a fresh code (manual refresh). Invalidates the previous one. */
|
|
153
|
+
refresh() {
|
|
154
|
+
this.mint(Date.now());
|
|
155
|
+
return {
|
|
156
|
+
code: this.code,
|
|
157
|
+
expiresAt: this.expiresAt
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
|
|
162
|
+
* Verify a submitted code and burn it on success.
|
|
163
|
+
|
|
164
|
+
* Constant-time compare; expired codes read as invalid.
|
|
165
|
+
|
|
166
|
+
*/
|
|
167
|
+
verify(submitted) {
|
|
168
|
+
if (this.code === null || this.used) return {
|
|
169
|
+
ok: false,
|
|
170
|
+
reason: "invalid"
|
|
171
|
+
};
|
|
172
|
+
const now = Date.now();
|
|
173
|
+
if (now >= this.expiresAt) return {
|
|
174
|
+
ok: false,
|
|
175
|
+
reason: "expired"
|
|
176
|
+
};
|
|
177
|
+
const a = Buffer.from(submitted);
|
|
178
|
+
const b = Buffer.from(this.code);
|
|
179
|
+
const same = a.length === b.length && timingSafeEqual(a, b);
|
|
180
|
+
if (!same) return {
|
|
181
|
+
ok: false,
|
|
182
|
+
reason: "invalid"
|
|
183
|
+
};
|
|
184
|
+
this.used = true;
|
|
185
|
+
return { ok: true };
|
|
186
|
+
}
|
|
187
|
+
mint(now) {
|
|
188
|
+
this.code = String(randomInt(0, 1e8)).padStart(8, "0");
|
|
189
|
+
this.createdAt = now;
|
|
190
|
+
this.expiresAt = now + this.ttlMs;
|
|
191
|
+
this.used = false;
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
/** Mint a per-device token. Only its SHA-256 may be persisted. */
|
|
195
|
+
function mintDeviceToken() {
|
|
196
|
+
return randomBytes(32).toString("hex");
|
|
197
|
+
}
|
|
198
|
+
function hashToken(token) {
|
|
199
|
+
return createHash("sha256").update(token).digest("hex");
|
|
200
|
+
}
|
|
201
|
+
/** Constant-time token-hash comparison. */
|
|
202
|
+
function tokenHashMatches(recordHash, presentedToken) {
|
|
203
|
+
const a = Buffer.from(recordHash, "hex");
|
|
204
|
+
const b = Buffer.from(hashToken(presentedToken), "hex");
|
|
205
|
+
return a.length === b.length && a.length > 0 && timingSafeEqual(a, b);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
//#endregion
|
|
209
|
+
//#region src/store/devices.ts
|
|
210
|
+
/**
|
|
211
|
+
|
|
212
|
+
* Paired-device registry, persisted as JSON under the plugin data dir.
|
|
213
|
+
|
|
214
|
+
* Records carry token HASHES only; the raw token exists solely in the
|
|
215
|
+
|
|
216
|
+
* pairing response and the phone's secure storage.
|
|
217
|
+
|
|
218
|
+
*/
|
|
219
|
+
var DeviceStore = class {
|
|
220
|
+
devices = new Map();
|
|
221
|
+
file;
|
|
222
|
+
dirty = false;
|
|
223
|
+
constructor(dataDir$1) {
|
|
224
|
+
mkdirSync(dataDir$1, { recursive: true });
|
|
225
|
+
this.file = join(dataDir$1, "devices.json");
|
|
226
|
+
this.load();
|
|
227
|
+
}
|
|
228
|
+
load() {
|
|
229
|
+
if (!existsSync(this.file)) return;
|
|
230
|
+
try {
|
|
231
|
+
const raw = JSON.parse(readFileSync(this.file, "utf8"));
|
|
232
|
+
for (const d of raw.devices ?? []) if (typeof d?.deviceId === "string" && typeof d?.tokenHash === "string") this.devices.set(d.deviceId, {
|
|
233
|
+
...d,
|
|
234
|
+
name: d.name ?? "unknown",
|
|
235
|
+
model: d.model ?? "",
|
|
236
|
+
firstPairedAt: d.firstPairedAt ?? Date.now(),
|
|
237
|
+
lastSeenAt: d.lastSeenAt ?? 0
|
|
238
|
+
});
|
|
239
|
+
} catch {}
|
|
240
|
+
}
|
|
241
|
+
persist() {
|
|
242
|
+
const tmp = `${this.file}.tmp`;
|
|
243
|
+
writeFileSync(tmp, JSON.stringify({
|
|
244
|
+
version: 1,
|
|
245
|
+
devices: [...this.devices.values()]
|
|
246
|
+
}, null, 2), "utf8");
|
|
247
|
+
renameSync(tmp, this.file);
|
|
248
|
+
}
|
|
249
|
+
/** Register/re-register a device, uniquifying the name against others. */
|
|
250
|
+
upsert(record) {
|
|
251
|
+
const prior = this.devices.get(record.deviceId);
|
|
252
|
+
const full = {
|
|
253
|
+
...record,
|
|
254
|
+
name: this.uniquifyName(record.deviceId, record.name),
|
|
255
|
+
firstPairedAt: prior?.firstPairedAt ?? Date.now(),
|
|
256
|
+
lastSeenAt: Date.now()
|
|
257
|
+
};
|
|
258
|
+
this.devices.set(record.deviceId, full);
|
|
259
|
+
this.dirty = true;
|
|
260
|
+
this.persist();
|
|
261
|
+
return full;
|
|
262
|
+
}
|
|
263
|
+
/** Make a display name unique among other devices (x, x (2), x (3), …). */
|
|
264
|
+
uniquifyName(deviceId, name) {
|
|
265
|
+
const others = [...this.devices.values()].map((d) => d.name).filter((n, i, arr) => arr.indexOf(n) === i);
|
|
266
|
+
const base = name.trim() || "Android 设备";
|
|
267
|
+
if (!others.includes(base)) return base;
|
|
268
|
+
for (let n = 2;; n++) {
|
|
269
|
+
const candidate = `${base} (${n})`;
|
|
270
|
+
if (!others.includes(candidate)) return candidate;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/** Rename a device (user-editable in the web UI); returns the updated record. */
|
|
274
|
+
rename(deviceId, name) {
|
|
275
|
+
const record = this.devices.get(deviceId);
|
|
276
|
+
if (!record) return null;
|
|
277
|
+
record.name = name.trim() || record.name;
|
|
278
|
+
this.dirty = true;
|
|
279
|
+
this.persist();
|
|
280
|
+
return record;
|
|
281
|
+
}
|
|
282
|
+
/** Authenticate deviceId + token; refreshes lastSeenAt on success. */
|
|
283
|
+
authenticate(deviceId, token) {
|
|
284
|
+
const record = this.devices.get(deviceId);
|
|
285
|
+
if (!record) return null;
|
|
286
|
+
if (!tokenHashMatches(record.tokenHash, token)) return null;
|
|
287
|
+
record.lastSeenAt = Date.now();
|
|
288
|
+
this.dirty = true;
|
|
289
|
+
return record;
|
|
290
|
+
}
|
|
291
|
+
remove(deviceId) {
|
|
292
|
+
const had = this.devices.delete(deviceId);
|
|
293
|
+
if (had) this.persist();
|
|
294
|
+
return had;
|
|
295
|
+
}
|
|
296
|
+
list() {
|
|
297
|
+
return [...this.devices.values()].sort((a, b) => b.lastSeenAt - a.lastSeenAt);
|
|
298
|
+
}
|
|
299
|
+
count() {
|
|
300
|
+
return this.devices.size;
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
//#endregion
|
|
305
|
+
//#region src/types.ts
|
|
306
|
+
const ERROR_CODES = {
|
|
307
|
+
AUTH_REQUIRED: "AUTH_REQUIRED",
|
|
308
|
+
PAIR_CODE_INVALID: "PAIR_CODE_INVALID",
|
|
309
|
+
PAIR_CODE_EXPIRED: "PAIR_CODE_EXPIRED",
|
|
310
|
+
RATE_LIMITED: "RATE_LIMITED",
|
|
311
|
+
TYPE_NOT_ALLOWED: "TYPE_NOT_ALLOWED",
|
|
312
|
+
TOO_LARGE: "TOO_LARGE",
|
|
313
|
+
BAD_MAGIC: "BAD_MAGIC",
|
|
314
|
+
STORE_FAILED: "STORE_FAILED",
|
|
315
|
+
NO_TARGET: "NO_TARGET",
|
|
316
|
+
NO_CAMERA: "NO_CAMERA",
|
|
317
|
+
CAPTURE_TIMEOUT: "CAPTURE_TIMEOUT",
|
|
318
|
+
LOOPBACK_ONLY: "LOOPBACK_ONLY",
|
|
319
|
+
BAD_REQUEST: "BAD_REQUEST",
|
|
320
|
+
INTERNAL: "INTERNAL"
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
//#endregion
|
|
324
|
+
//#region src/inject/admit.ts
|
|
325
|
+
/** Magic-byte whitelist check — never trust Content-Type alone. */
|
|
326
|
+
function magicMatches(mediaType, head) {
|
|
327
|
+
if (mediaType === "image/jpeg") return head.length >= 3 && head[0] === 255 && head[1] === 216 && head[2] === 255;
|
|
328
|
+
if (mediaType === "image/png") return head.length >= 4 && head[0] === 137 && head[1] === 80 && head[2] === 78 && head[3] === 71;
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
|
|
333
|
+
* Persist one image through the durable attachment seam.
|
|
334
|
+
|
|
335
|
+
* Falls back to a content-addressed file when the dsh attachments service is
|
|
336
|
+
|
|
337
|
+
* not mounted (standalone dev / unusual profile), so uploads never vanish.
|
|
338
|
+
|
|
339
|
+
*/
|
|
340
|
+
async function admitImage(input, attachments, fallbackDir) {
|
|
341
|
+
if (attachments) {
|
|
342
|
+
const refs = await attachments.saveImages([{
|
|
343
|
+
data: input.data,
|
|
344
|
+
mediaType: input.mediaType,
|
|
345
|
+
name: input.name
|
|
346
|
+
}]);
|
|
347
|
+
const ref = refs[0];
|
|
348
|
+
if (!ref) throw new Error("attachment store returned no reference");
|
|
349
|
+
return {
|
|
350
|
+
ref,
|
|
351
|
+
storage: "attachments"
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
const ext = input.mediaType === "image/png" ? "png" : "jpg";
|
|
355
|
+
const digest = createHash("sha1").update(input.data).digest("hex").slice(0, 16);
|
|
356
|
+
mkdirSync(fallbackDir, { recursive: true });
|
|
357
|
+
const filePath = join(fallbackDir, `${digest}-${input.name.replace(/[^\w.-]+/g, "_") || "image"}.${ext}`);
|
|
358
|
+
writeFileSync(filePath, input.data);
|
|
359
|
+
return {
|
|
360
|
+
ref: {
|
|
361
|
+
attachmentId: `file:${digest}`,
|
|
362
|
+
mediaType: input.mediaType,
|
|
363
|
+
bytes: input.data.byteLength,
|
|
364
|
+
width: 0,
|
|
365
|
+
height: 0,
|
|
366
|
+
name: input.name
|
|
367
|
+
},
|
|
368
|
+
storage: "file",
|
|
369
|
+
filePath
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
//#endregion
|
|
374
|
+
//#region src/server/qr.ts
|
|
375
|
+
/** Enumerate LAN IPv4 candidates: private ranges first, virtual/tethering included. */
|
|
376
|
+
function lanAddresses() {
|
|
377
|
+
const out = [];
|
|
378
|
+
for (const [iface, addrs] of Object.entries(os.networkInterfaces())) for (const addr of addrs ?? []) {
|
|
379
|
+
if (addr.family !== "IPv4" || addr.internal) continue;
|
|
380
|
+
out.push({
|
|
381
|
+
ip: addr.address,
|
|
382
|
+
iface
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
const rank = (ip) => {
|
|
386
|
+
if (ip.startsWith("192.168.")) return 0;
|
|
387
|
+
if (ip.startsWith("172.")) return 1;
|
|
388
|
+
if (ip.startsWith("10.")) return 2;
|
|
389
|
+
return 3;
|
|
390
|
+
};
|
|
391
|
+
return out.sort((a, b) => rank(a.ip) - rank(b.ip) || a.ip.localeCompare(b.ip));
|
|
392
|
+
}
|
|
393
|
+
/** Build the full pairing payload + rendered QR (data URL for browsers, ASCII for terminals). */
|
|
394
|
+
async function buildPairingQr(code, expiresAt, config$1, originOverride) {
|
|
395
|
+
const addrs = lanAddresses();
|
|
396
|
+
const host = originOverride ?? addrs[0]?.ip ?? "127.0.0.1";
|
|
397
|
+
const port = config$1.server.port;
|
|
398
|
+
const payload = `lensmate://pair?v=1&host=${encodeURIComponent(host)}&port=${port}&code=${code}`;
|
|
399
|
+
const urls = (addrs.length > 0 ? addrs : [{
|
|
400
|
+
ip: "127.0.0.1",
|
|
401
|
+
iface: "loopback"
|
|
402
|
+
}]).map((a) => `http://${a.ip}:${port}`);
|
|
403
|
+
const [pngDataUrl, ascii] = await Promise.all([QRCode.toDataURL(payload, {
|
|
404
|
+
errorCorrectionLevel: "M",
|
|
405
|
+
margin: 2,
|
|
406
|
+
width: 320
|
|
407
|
+
}), QRCode.toString(payload, {
|
|
408
|
+
type: "terminal",
|
|
409
|
+
small: true
|
|
410
|
+
})]);
|
|
411
|
+
return {
|
|
412
|
+
code,
|
|
413
|
+
expiresAt,
|
|
414
|
+
payload,
|
|
415
|
+
urls,
|
|
416
|
+
pngDataUrl,
|
|
417
|
+
ascii
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
//#endregion
|
|
422
|
+
//#region src/server/static-view.ts
|
|
423
|
+
/**
|
|
424
|
+
|
|
425
|
+
* The standalone fallback viewfinder page, served at /view.html (loopback only).
|
|
426
|
+
|
|
427
|
+
* Same protocol as the future Web UI overlay: WS /ws/view frames + capture control.
|
|
428
|
+
|
|
429
|
+
* Picture-in-Picture turns it into a true always-on-top mini window.
|
|
430
|
+
|
|
431
|
+
*/
|
|
432
|
+
const VIEW_HTML = `<!doctype html>
|
|
433
|
+
<html lang="zh-CN">
|
|
434
|
+
<head>
|
|
435
|
+
<meta charset="utf-8">
|
|
436
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
437
|
+
<title>PhoneLens 直连取景</title>
|
|
438
|
+
<style>
|
|
439
|
+
:root { color-scheme: dark; }
|
|
440
|
+
* { box-sizing: border-box; }
|
|
441
|
+
body { margin: 0; font: 13px/1.5 system-ui, "Segoe UI", "Microsoft YaHei", sans-serif;
|
|
442
|
+
background: #101418; color: #dfe7ee; display: flex; flex-direction: column; height: 100vh; }
|
|
443
|
+
header { display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: #171d24; border-bottom: 1px solid #232c36; }
|
|
444
|
+
header h1 { font-size: 13px; margin: 0; font-weight: 600; }
|
|
445
|
+
#dot { width: 9px; height: 9px; border-radius: 50%; background: #e5484d; transition: background .2s; }
|
|
446
|
+
#dot.on { background: #46a758; }
|
|
447
|
+
#fps { margin-left: auto; color: #7d8da0; font-variant-numeric: tabular-nums; }
|
|
448
|
+
main { flex: 1; display: flex; min-height: 0; }
|
|
449
|
+
#stage { flex: 1; display: flex; align-items: center; justify-content: center; background: #0b0e11; position: relative; min-width: 0; }
|
|
450
|
+
canvas { max-width: 100%; max-height: 100%; object-fit: contain; background: #000; }
|
|
451
|
+
#hint { position: absolute; color: #7d8da0; }
|
|
452
|
+
aside { width: 240px; border-left: 1px solid #232c36; padding: 12px; overflow: auto; }
|
|
453
|
+
aside h2 { font-size: 12px; margin: 0 0 8px; color: #9fb0c3; text-transform: uppercase; letter-spacing: .08em; }
|
|
454
|
+
#qrimg { width: 100%; image-rendering: pixelated; background: #fff; padding: 8px; border-radius: 8px; }
|
|
455
|
+
.row { display: flex; gap: 6px; align-items: center; margin: 6px 0; }
|
|
456
|
+
.row code { background: #1d242d; padding: 2px 8px; border-radius: 6px; letter-spacing: .12em; font-size: 15px; }
|
|
457
|
+
.row a { color: #3b7cb5; text-decoration: none; }
|
|
458
|
+
.row a:hover { text-decoration: underline; }
|
|
459
|
+
button { background: #1f2937; border: 1px solid #2f3b48; color: #dfe7ee; padding: 6px 10px; border-radius: 8px; cursor: pointer; font-size: 12px; }
|
|
460
|
+
button:hover { background: #28323e; }
|
|
461
|
+
button:disabled { opacity: .45; cursor: default; }
|
|
462
|
+
#shoot { padding: 10px; font-size: 14px; background: #2a5d8f; border-color: #3b7cb5; }
|
|
463
|
+
#pip { display: none; }
|
|
464
|
+
#log { margin-top: 10px; font-size: 12px; color: #8fa1b5; max-height: 160px; overflow: auto; }
|
|
465
|
+
#log div { padding: 2px 0; border-bottom: 1px dashed #202832; }
|
|
466
|
+
#log .ok { color: #58b368; } #log .err { color: #e5696e; }
|
|
467
|
+
</style>
|
|
468
|
+
</head>
|
|
469
|
+
<body>
|
|
470
|
+
<header>
|
|
471
|
+
<div id="dot"></div><h1>PhoneLens 直连取景</h1>
|
|
472
|
+
<span id="dev"></span>
|
|
473
|
+
<span id="fps"></span><span id="age"></span>
|
|
474
|
+
</header>
|
|
475
|
+
<main>
|
|
476
|
+
<div id="stage"><canvas width="854" height="480"></canvas><div id="hint">等待手机连接…</div></div>
|
|
477
|
+
<aside>
|
|
478
|
+
<h2>配对</h2>
|
|
479
|
+
<img id="qrimg" alt="配对二维码">
|
|
480
|
+
<div class="row"><code id="paircode"></code><button id="refresh">刷新</button></div>
|
|
481
|
+
<div class="row" id="urls"></div>
|
|
482
|
+
<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>
|
|
483
|
+
<h2 style="margin-top:14px">快门</h2>
|
|
484
|
+
<div class="row">
|
|
485
|
+
<button id="shoot" disabled>◉ 拍照并注入</button>
|
|
486
|
+
<button id="pip">置顶小窗</button>
|
|
487
|
+
</div>
|
|
488
|
+
<div id="log"></div>
|
|
489
|
+
</aside>
|
|
490
|
+
</main>
|
|
491
|
+
<video id="pipvid" muted playsinline style="display:none"></video>
|
|
492
|
+
<script>
|
|
493
|
+
'use strict';
|
|
494
|
+
const $ = (id) => document.getElementById(id);
|
|
495
|
+
const canvas = $('stage').querySelector('canvas');
|
|
496
|
+
const ctx2d = canvas.getContext('2d');
|
|
497
|
+
const logEl = $('log');
|
|
498
|
+
let lastFrameAt = 0, frames = 0, fpsTimer = 0, pendingShot = null;
|
|
499
|
+
let streamRotation = 0;
|
|
500
|
+
|
|
501
|
+
function log(msg, cls) {
|
|
502
|
+
const d = document.createElement('div');
|
|
503
|
+
if (cls) d.className = cls;
|
|
504
|
+
d.textContent = new Date().toLocaleTimeString() + ' ' + msg;
|
|
505
|
+
logEl.prepend(d);
|
|
506
|
+
while (logEl.childElementCount > 50) logEl.lastChild.remove();
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async function refreshQr() {
|
|
510
|
+
try {
|
|
511
|
+
const r = await fetch('/qr.json');
|
|
512
|
+
const j = await r.json();
|
|
513
|
+
$('qrimg').src = j.pngDataUrl;
|
|
514
|
+
$('paircode').textContent = j.code;
|
|
515
|
+
$('urls').innerHTML = (j.urls || []).map(u => '<span style="color:#7d8da0">' + u + '</span>').join('<br>');
|
|
516
|
+
} catch (e) { log('二维码加载失败: ' + e.message, 'err'); }
|
|
517
|
+
}
|
|
518
|
+
$('refresh').onclick = () => { wsSend({ type: 'refresh_pairing' }); setTimeout(refreshQr, 150); };
|
|
519
|
+
|
|
520
|
+
function drawFrame(buf) {
|
|
521
|
+
const blob = new Blob([buf], { type: 'image/jpeg' });
|
|
522
|
+
const url = URL.createObjectURL(blob);
|
|
523
|
+
const img = new Image();
|
|
524
|
+
img.onload = () => {
|
|
525
|
+
const r = ((streamRotation % 360) + 360) % 360;
|
|
526
|
+
const swap = r === 90 || r === 270;
|
|
527
|
+
const w = swap ? img.height : img.width;
|
|
528
|
+
const h = swap ? img.width : img.height;
|
|
529
|
+
if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }
|
|
530
|
+
ctx2d.save();
|
|
531
|
+
ctx2d.translate(w / 2, h / 2);
|
|
532
|
+
ctx2d.rotate(r * Math.PI / 180);
|
|
533
|
+
ctx2d.drawImage(img, -img.width / 2, -img.height / 2);
|
|
534
|
+
ctx2d.restore();
|
|
535
|
+
URL.revokeObjectURL(url);
|
|
536
|
+
$('hint').style.display = 'none';
|
|
537
|
+
frames++;
|
|
538
|
+
const now = performance.now();
|
|
539
|
+
if (now - fpsTimer >= 1000) { $('fps').textContent = (frames * 1000 / (now - fpsTimer)).toFixed(1) + ' fps'; frames = 0; fpsTimer = now; }
|
|
540
|
+
lastFrameAt = now;
|
|
541
|
+
};
|
|
542
|
+
img.src = url;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
let ws = null;
|
|
546
|
+
function connect() {
|
|
547
|
+
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
548
|
+
ws = new WebSocket(proto + '://' + location.host + '/ws/view');
|
|
549
|
+
ws.binaryType = 'arraybuffer';
|
|
550
|
+
ws.onopen = () => { log('已连接'); refreshQr(); };
|
|
551
|
+
ws.onclose = () => { $('dot').className = ''; setTimeout(connect, 1500); };
|
|
552
|
+
ws.onerror = () => {};
|
|
553
|
+
ws.onmessage = (ev) => {
|
|
554
|
+
if (ev.data instanceof ArrayBuffer) { drawFrame(ev.data); return; }
|
|
555
|
+
const m = JSON.parse(ev.data);
|
|
556
|
+
switch (m.type) {
|
|
557
|
+
case 'meta':
|
|
558
|
+
$('dot').className = m.camera.connected ? 'on' : '';
|
|
559
|
+
$('dev').textContent = m.camera.name || (m.camera.connected ? '已连接' : '未连接');
|
|
560
|
+
$('shoot').disabled = !m.camera.connected;
|
|
561
|
+
if (m.camera.rotation !== undefined) streamRotation = m.camera.rotation;
|
|
562
|
+
$('hint').style.display = m.camera.connected ? 'none' : '';
|
|
563
|
+
break;
|
|
564
|
+
case 'frame_meta':
|
|
565
|
+
if (m.rotation !== undefined) streamRotation = m.rotation;
|
|
566
|
+
break;
|
|
567
|
+
case 'device':
|
|
568
|
+
$('dot').className = m.online ? 'on' : '';
|
|
569
|
+
$('dev').textContent = m.online ? (m.name || '已连接') : '未连接';
|
|
570
|
+
$('shoot').disabled = !m.online;
|
|
571
|
+
$('hint').style.display = m.online ? 'none' : '';
|
|
572
|
+
$('hint').textContent = '等待手机连接…';
|
|
573
|
+
break;
|
|
574
|
+
case 'capture_pending':
|
|
575
|
+
pendingShot = m.captureId;
|
|
576
|
+
log('快门已触发' + (m.note ? '(' + m.note + ')' : '') + '…');
|
|
577
|
+
break;
|
|
578
|
+
case 'injected':
|
|
579
|
+
pendingShot = null;
|
|
580
|
+
if (m.ok) log('已注入会话 ' + (m.sessionId || '').slice(0, 8) + (m.name ? ' · ' + m.name : ''), 'ok');
|
|
581
|
+
else log('入库成功,注入未完成: ' + (m.reason || ''), 'err');
|
|
582
|
+
break;
|
|
583
|
+
case 'upload':
|
|
584
|
+
log('图片已入库 ' + (m.name || ''), 'ok');
|
|
585
|
+
break;
|
|
586
|
+
case 'error':
|
|
587
|
+
log('错误 ' + m.code + (m.message ? ': ' + m.message : ''), 'err');
|
|
588
|
+
break;
|
|
589
|
+
}
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
function wsSend(obj) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); }
|
|
593
|
+
|
|
594
|
+
$('shoot').onclick = () => wsSend({ type: 'capture', note: '' });
|
|
595
|
+
|
|
596
|
+
// PiP: mirror the canvas into a video stream, then request Picture-in-Picture.
|
|
597
|
+
$('pip').onclick = async () => {
|
|
598
|
+
try {
|
|
599
|
+
const stream = canvas.captureStream(10);
|
|
600
|
+
const v = $('pipvid');
|
|
601
|
+
v.srcObject = stream;
|
|
602
|
+
await v.play();
|
|
603
|
+
await v.requestPictureInPicture();
|
|
604
|
+
} catch (e) { log('画中画失败: ' + e.message, 'err'); }
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
setInterval(() => {
|
|
608
|
+
if (document.pictureInPictureElement) return;
|
|
609
|
+
if (performance.now() - lastFrameAt > 4000 && lastFrameAt > 0) $('hint').style.display = '';
|
|
610
|
+
$('age').textContent = lastFrameAt > 0 ? ' · ' + ((performance.now() - lastFrameAt) / 1000).toFixed(1) + 's前' : '';
|
|
611
|
+
}, 1000);
|
|
612
|
+
if (document.pictureInPictureEnabled) $('pip').style.display = '';
|
|
613
|
+
|
|
614
|
+
connect();
|
|
615
|
+
</script>
|
|
616
|
+
</body>
|
|
617
|
+
</html>
|
|
618
|
+
`;
|
|
619
|
+
|
|
620
|
+
//#endregion
|
|
621
|
+
//#region src/server/auth.ts
|
|
622
|
+
/** Sliding-window rate limiter (in-memory; resets with the process). */
|
|
623
|
+
var RateLimiter = class {
|
|
624
|
+
hits = new Map();
|
|
625
|
+
constructor(windowMs, max) {
|
|
626
|
+
this.windowMs = windowMs;
|
|
627
|
+
this.max = max;
|
|
628
|
+
}
|
|
629
|
+
/** Record one hit and report whether the key is still within budget. */
|
|
630
|
+
allow(key) {
|
|
631
|
+
const now = Date.now();
|
|
632
|
+
const cutoff = now - this.windowMs;
|
|
633
|
+
const list = (this.hits.get(key) ?? []).filter((t) => t > cutoff);
|
|
634
|
+
if (list.length >= this.max) {
|
|
635
|
+
this.hits.set(key, list);
|
|
636
|
+
return false;
|
|
637
|
+
}
|
|
638
|
+
list.push(now);
|
|
639
|
+
this.hits.set(key, list);
|
|
640
|
+
return true;
|
|
641
|
+
}
|
|
642
|
+
};
|
|
643
|
+
const LOOPBACK = new Set([
|
|
644
|
+
"127.0.0.1",
|
|
645
|
+
"::1",
|
|
646
|
+
"::ffff:127.0.0.1"
|
|
647
|
+
]);
|
|
648
|
+
/** Whether the connection originates from this machine (loopback). */
|
|
649
|
+
function isLoopback(req) {
|
|
650
|
+
const addr = req.socket.remoteAddress ?? "";
|
|
651
|
+
return LOOPBACK.has(addr);
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
|
|
655
|
+
* CORS for the dsh Web UI page (served on another loopback port) so it can
|
|
656
|
+
|
|
657
|
+
* fetch /qr.json and /status from this receiver. Only same-machine origins
|
|
658
|
+
|
|
659
|
+
* are echoed — a foreign page must not read the pairing code, and its
|
|
660
|
+
|
|
661
|
+
* cross-origin JSON POST fails the preflight we never answer.
|
|
662
|
+
|
|
663
|
+
*/
|
|
664
|
+
function corsFor(req) {
|
|
665
|
+
const origin = req.headers.origin;
|
|
666
|
+
if (typeof origin !== "string" || origin === "") return {};
|
|
667
|
+
try {
|
|
668
|
+
const u = new URL(origin);
|
|
669
|
+
const host = u.hostname.replace(/^\[|\]$/g, "");
|
|
670
|
+
if (u.protocol === "http:" && LOOPBACK.has(host)) return {
|
|
671
|
+
"access-control-allow-origin": origin,
|
|
672
|
+
"access-control-allow-methods": "GET, POST, OPTIONS",
|
|
673
|
+
"access-control-allow-headers": "content-type, x-lm-device, x-lm-token",
|
|
674
|
+
"access-control-max-age": "600"
|
|
675
|
+
};
|
|
676
|
+
} catch {}
|
|
677
|
+
return {};
|
|
678
|
+
}
|
|
679
|
+
/** Extract a stable client key for rate limiting. */
|
|
680
|
+
function clientKey(req) {
|
|
681
|
+
return req.socket.remoteAddress ?? "unknown";
|
|
682
|
+
}
|
|
683
|
+
/** Read the device auth headers, if present. */
|
|
684
|
+
function deviceAuth(req) {
|
|
685
|
+
const deviceId = req.headers["x-lm-device"];
|
|
686
|
+
const token = req.headers["x-lm-token"];
|
|
687
|
+
if (typeof deviceId !== "string" || typeof token !== "string" || !deviceId || !token) return null;
|
|
688
|
+
return {
|
|
689
|
+
deviceId,
|
|
690
|
+
token
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
/** Query-string parser (URLSearchParams handles plus-encoding for notes). */
|
|
694
|
+
function queryOf(req) {
|
|
695
|
+
const url = new URL(req.url ?? "/", "http://phone-lens.local");
|
|
696
|
+
return url.searchParams;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
//#endregion
|
|
700
|
+
//#region src/server/http.ts
|
|
701
|
+
/** Boot the receiver: HTTP routes + two websocket endpoints. */
|
|
702
|
+
async function startLensServer(deps) {
|
|
703
|
+
const { config: config$1, pairing: pairing$1, devices: devices$1, hub: hub$1, targets: targets$1, sink: sink$1, log: log$1 } = deps;
|
|
704
|
+
const pairLimiter = new RateLimiter(6e4, 10);
|
|
705
|
+
const uploadLimiter = new RateLimiter(6e4, config$1.limits.uploadsPerMinute);
|
|
706
|
+
let currentCameraDeviceId = null;
|
|
707
|
+
const injectionBox = { last: null };
|
|
708
|
+
const server = createServer((req, res) => {
|
|
709
|
+
handle$1(deps, req, res, {
|
|
710
|
+
pairLimiter,
|
|
711
|
+
uploadLimiter,
|
|
712
|
+
getCurrentCamera: () => currentCameraDeviceId,
|
|
713
|
+
noteInjection: (receipt, attachmentId) => {
|
|
714
|
+
injectionBox.last = {
|
|
715
|
+
at: Date.now(),
|
|
716
|
+
sessionId: receipt.sessionId,
|
|
717
|
+
attachmentId,
|
|
718
|
+
ok: receipt.ok
|
|
719
|
+
};
|
|
720
|
+
},
|
|
721
|
+
getLastInjection: () => injectionBox.last
|
|
722
|
+
}).catch((error) => {
|
|
723
|
+
log$1("error", `request failed: ${String(error)}`);
|
|
724
|
+
if (!res.headersSent) sendJson(res, 500, { error: {
|
|
725
|
+
code: ERROR_CODES.INTERNAL,
|
|
726
|
+
message: String(error)
|
|
727
|
+
} });
|
|
728
|
+
else res.destroy();
|
|
729
|
+
});
|
|
730
|
+
});
|
|
731
|
+
const wss = new WebSocketServer({
|
|
732
|
+
noServer: true,
|
|
733
|
+
maxPayload: config$1.limits.previewFrameMaxBytes * 4
|
|
734
|
+
});
|
|
735
|
+
server.on("upgrade", (req, socket, head) => {
|
|
736
|
+
const url = new URL(req.url ?? "/", "http://phone-lens.local");
|
|
737
|
+
const done = (status, message) => {
|
|
738
|
+
socket.write(`HTTP/1.1 ${status} ${message}\r\nConnection: close\r\n\r\n`);
|
|
739
|
+
socket.destroy();
|
|
740
|
+
};
|
|
741
|
+
if (url.pathname === "/ws/camera") {
|
|
742
|
+
const q = url.searchParams;
|
|
743
|
+
const deviceId = q.get("deviceId") ?? "";
|
|
744
|
+
const token = q.get("token") ?? "";
|
|
745
|
+
const record = devices$1.authenticate(deviceId, token);
|
|
746
|
+
if (!record) return done(401, "Unauthorized");
|
|
747
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
748
|
+
currentCameraDeviceId = record.deviceId;
|
|
749
|
+
hub$1.attachCamera(record.deviceId, ws, record.name);
|
|
750
|
+
});
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
if (url.pathname === "/ws/view") {
|
|
754
|
+
if (!isLoopback(req)) return done(403, "Forbidden");
|
|
755
|
+
wss.handleUpgrade(req, socket, head, (ws) => hub$1.attachView(ws, {
|
|
756
|
+
onRefreshPairing: () => pairing$1.refresh(),
|
|
757
|
+
onRenameDevice: (deviceId, name) => {
|
|
758
|
+
const updated = devices$1.rename(deviceId, name);
|
|
759
|
+
if (updated) hub$1.renameDevice(deviceId, updated.name);
|
|
760
|
+
}
|
|
761
|
+
}));
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
done(404, "Not Found");
|
|
765
|
+
});
|
|
766
|
+
const heartbeat = setInterval(() => hub$1.pingAll(), 2e4);
|
|
767
|
+
await new Promise((resolve, reject) => {
|
|
768
|
+
server.once("error", reject);
|
|
769
|
+
server.listen(config$1.server.port, config$1.server.host, () => resolve());
|
|
770
|
+
});
|
|
771
|
+
log$1("info", `phone-lens listening on ${config$1.server.host}:${config$1.server.port} (paired devices: ${devices$1.count()})`);
|
|
772
|
+
return {
|
|
773
|
+
port: config$1.server.port,
|
|
774
|
+
dispose: async () => {
|
|
775
|
+
clearInterval(heartbeat);
|
|
776
|
+
hub$1.detachAll();
|
|
777
|
+
for (const client of wss.clients) client.terminate();
|
|
778
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
779
|
+
}
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
async function handle$1(deps, req, res, ctx) {
|
|
783
|
+
const { config: config$1, pairing: pairing$1, devices: devices$1, hub: hub$1, targets: targets$1, sink: sink$1, log: log$1 } = deps;
|
|
784
|
+
const url = new URL(req.url ?? "/", "http://phone-lens.local");
|
|
785
|
+
const path = url.pathname.replace(/\/+$/, "") || "/";
|
|
786
|
+
const method = req.method ?? "GET";
|
|
787
|
+
const loop = isLoopback(req);
|
|
788
|
+
const cors = corsFor(req);
|
|
789
|
+
if (method === "OPTIONS") {
|
|
790
|
+
res.writeHead(204, cors);
|
|
791
|
+
res.end();
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
if (method === "GET" && path === "/info") return sendJson(res, 200, {
|
|
795
|
+
name: "PhoneLens 直连取景",
|
|
796
|
+
version: "0.3.5",
|
|
797
|
+
requiresPairing: true
|
|
798
|
+
}, 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");
|
|
800
|
+
if (method === "GET" && (path === "/" || path === "/view.html")) {
|
|
801
|
+
res.writeHead(200, {
|
|
802
|
+
"content-type": "text/html; charset=utf-8",
|
|
803
|
+
"cache-control": "no-store"
|
|
804
|
+
});
|
|
805
|
+
res.end(VIEW_HTML);
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
if (method === "GET" && path === "/qr.json") {
|
|
809
|
+
const { code, expiresAt } = pairing$1.current();
|
|
810
|
+
const qr$1 = await buildPairingQr(code, expiresAt, config$1);
|
|
811
|
+
return sendJson(res, 200, {
|
|
812
|
+
code: qr$1.code,
|
|
813
|
+
expiresAt: qr$1.expiresAt,
|
|
814
|
+
payload: qr$1.payload,
|
|
815
|
+
urls: qr$1.urls,
|
|
816
|
+
pngDataUrl: qr$1.pngDataUrl
|
|
817
|
+
}, {
|
|
818
|
+
...cors,
|
|
819
|
+
"cache-control": "no-store"
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
if (method === "GET" && path === "/qr.png") {
|
|
823
|
+
const { code, expiresAt } = pairing$1.current();
|
|
824
|
+
const qr$1 = await buildPairingQr(code, expiresAt, config$1);
|
|
825
|
+
const b64 = qr$1.pngDataUrl.slice(qr$1.pngDataUrl.indexOf(",") + 1);
|
|
826
|
+
res.writeHead(200, {
|
|
827
|
+
"content-type": "image/png",
|
|
828
|
+
"cache-control": "no-store"
|
|
829
|
+
});
|
|
830
|
+
res.end(Buffer.from(b64, "base64"));
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
if (method === "GET" && path === "/app-qr.json") {
|
|
834
|
+
const target = config$1.app.downloadUrl || config$1.app.giteeUrl;
|
|
835
|
+
const pngDataUrl = await QRCode.toDataURL(target, {
|
|
836
|
+
errorCorrectionLevel: "M",
|
|
837
|
+
margin: 2,
|
|
838
|
+
width: 320
|
|
839
|
+
});
|
|
840
|
+
return sendJson(res, 200, {
|
|
841
|
+
url: target,
|
|
842
|
+
gitee: config$1.app.giteeUrl,
|
|
843
|
+
github: config$1.app.githubUrl,
|
|
844
|
+
pngDataUrl
|
|
845
|
+
}, {
|
|
846
|
+
...cors,
|
|
847
|
+
"cache-control": "no-store"
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
if (method === "POST" && path === "/pair") {
|
|
851
|
+
const body = await readJsonBody(req, 4096);
|
|
852
|
+
const code = str(body?.code);
|
|
853
|
+
const device = body?.device ?? {};
|
|
854
|
+
const deviceId = str(device.id);
|
|
855
|
+
const name = str(device.name) ?? "unnamed device";
|
|
856
|
+
const model = str(device.model) ?? "";
|
|
857
|
+
if (!code || !deviceId) return sendError(res, 400, ERROR_CODES.BAD_REQUEST, "code and device.id are required");
|
|
858
|
+
const verdict = pairing$1.verify(code);
|
|
859
|
+
if (!verdict.ok) {
|
|
860
|
+
if (!ctx.pairLimiter.allow(clientKey(req))) return sendError(res, 429, ERROR_CODES.RATE_LIMITED, "too many pairing attempts");
|
|
861
|
+
return sendError(res, 401, verdict.reason === "expired" ? ERROR_CODES.PAIR_CODE_EXPIRED : ERROR_CODES.PAIR_CODE_INVALID, `pairing code ${verdict.reason}`);
|
|
862
|
+
}
|
|
863
|
+
const token = mintDeviceToken();
|
|
864
|
+
devices$1.upsert({
|
|
865
|
+
deviceId,
|
|
866
|
+
name,
|
|
867
|
+
model,
|
|
868
|
+
tokenHash: hashToken(token)
|
|
869
|
+
});
|
|
870
|
+
log$1("info", `paired device "${name}" (${deviceId.slice(0, 8)})`);
|
|
871
|
+
return sendJson(res, 200, {
|
|
872
|
+
token,
|
|
873
|
+
serverInfo: {
|
|
874
|
+
preview: config$1.preview,
|
|
875
|
+
limits: config$1.limits
|
|
876
|
+
}
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
const auth = loop ? { ok: true } : deviceAuth(req) && devices$1.authenticate(deviceAuth(req).deviceId, deviceAuth(req).token) ? { ok: true } : { ok: false };
|
|
880
|
+
if (!auth.ok) return sendError(res, 401, ERROR_CODES.AUTH_REQUIRED, "pair this device first");
|
|
881
|
+
if (method === "POST" && path === "/upload") {
|
|
882
|
+
const q = queryOf(req);
|
|
883
|
+
if (!ctx.uploadLimiter.allow(loop ? "loopback" : clientKey(req))) return sendError(res, 429, ERROR_CODES.RATE_LIMITED, "upload rate exceeded");
|
|
884
|
+
const mediaType = (req.headers["content-type"] ?? "").split(";")[0].trim();
|
|
885
|
+
if (!config$1.limits.allowedTypes.includes(mediaType)) return sendError(res, 415, ERROR_CODES.TYPE_NOT_ALLOWED, `allowed: ${config$1.limits.allowedTypes.join(", ")}`);
|
|
886
|
+
const declared = Number(req.headers["content-length"] ?? "0");
|
|
887
|
+
if (declared > config$1.limits.maxUploadBytes) return sendError(res, 413, ERROR_CODES.TOO_LARGE, `> ${config$1.limits.maxUploadBytes} bytes`);
|
|
888
|
+
const { buf, truncated } = await readRawBody(req, config$1.limits.maxUploadBytes);
|
|
889
|
+
if (truncated) return sendError(res, 413, ERROR_CODES.TOO_LARGE, `> ${config$1.limits.maxUploadBytes} bytes`);
|
|
890
|
+
if (!magicMatches(mediaType, buf)) return sendError(res, 415, ERROR_CODES.BAD_MAGIC, "bytes do not match the declared type");
|
|
891
|
+
const captureId = q.get("captureId");
|
|
892
|
+
const note = q.get("note") ?? (captureId ? hub$1.noteFor(captureId) : void 0);
|
|
893
|
+
if (captureId) hub$1.consumeCapture(captureId);
|
|
894
|
+
const name = (q.get("name") ?? `shot_${new Date().toISOString().replace(/[:.]/g, "-")}.${mediaType === "image/png" ? "png" : "jpg"}`).slice(0, 120);
|
|
895
|
+
let admitted;
|
|
896
|
+
try {
|
|
897
|
+
admitted = await admitImage({
|
|
898
|
+
data: buf,
|
|
899
|
+
mediaType,
|
|
900
|
+
name
|
|
901
|
+
}, deps.attachments(), deps.fallbackDir);
|
|
902
|
+
} catch (error) {
|
|
903
|
+
log$1("error", `admit failed: ${String(error)}`);
|
|
904
|
+
return sendError(res, 500, ERROR_CODES.STORE_FAILED, String(error));
|
|
905
|
+
}
|
|
906
|
+
log$1("info", `stored ${name} (${buf.byteLength}B) → ${admitted.storage}:${admitted.ref.attachmentId}`);
|
|
907
|
+
if (admitted.storage === "file") await pruneUploads(deps.fallbackDir, config$1.limits.maxStoredUploads).catch((error) => log$1("warn", `upload pruning failed: ${String(error)}`));
|
|
908
|
+
const pendingPath = join(deps.pendingDir, `${admitted.ref.attachmentId}.jpg`);
|
|
909
|
+
try {
|
|
910
|
+
mkdirSync(deps.pendingDir, { recursive: true });
|
|
911
|
+
writeFileSync(pendingPath, buf);
|
|
912
|
+
log$1("info", `staged for composer: ${pendingPath}`);
|
|
913
|
+
} catch (error) {
|
|
914
|
+
log$1("warn", `staging failed: ${String(error)}`);
|
|
915
|
+
}
|
|
916
|
+
hub$1.broadcastToViews({
|
|
917
|
+
type: "pending_image",
|
|
918
|
+
attachmentId: admitted.ref.attachmentId,
|
|
919
|
+
name
|
|
920
|
+
});
|
|
921
|
+
return sendJson(res, 200, {
|
|
922
|
+
ok: true,
|
|
923
|
+
attachmentId: admitted.ref.attachmentId,
|
|
924
|
+
width: admitted.ref.width,
|
|
925
|
+
height: admitted.ref.height,
|
|
926
|
+
bytes: admitted.ref.bytes,
|
|
927
|
+
storage: admitted.storage,
|
|
928
|
+
delivered: null,
|
|
929
|
+
deliverReason: "staged-in-composer"
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
if (method === "GET" && path.startsWith("/pending/") && loop) {
|
|
933
|
+
const id = path.slice(9).split("/")[0] ?? "";
|
|
934
|
+
const file = join(deps.pendingDir, `${id}.jpg`);
|
|
935
|
+
try {
|
|
936
|
+
const data = await readFile(file);
|
|
937
|
+
res.writeHead(200, {
|
|
938
|
+
"content-type": "image/jpeg",
|
|
939
|
+
"cache-control": "no-store",
|
|
940
|
+
...cors
|
|
941
|
+
});
|
|
942
|
+
res.end(data);
|
|
943
|
+
return;
|
|
944
|
+
} catch {
|
|
945
|
+
return sendError(res, 404, ERROR_CODES.BAD_REQUEST, "pending image not found");
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
if (method === "GET" && path === "/status") {
|
|
949
|
+
const cameraDeviceId = ctx.getCurrentCamera();
|
|
950
|
+
return sendJson(res, 200, {
|
|
951
|
+
devices: devices$1.list().map((d) => ({
|
|
952
|
+
id: d.deviceId,
|
|
953
|
+
name: d.name,
|
|
954
|
+
model: d.model,
|
|
955
|
+
online: d.deviceId === cameraDeviceId,
|
|
956
|
+
streaming: d.deviceId === cameraDeviceId,
|
|
957
|
+
lastSeenAt: d.lastSeenAt
|
|
958
|
+
})),
|
|
959
|
+
camera: hub$1.stats(),
|
|
960
|
+
preview: config$1.preview,
|
|
961
|
+
target: {
|
|
962
|
+
mode: targets$1.mode(),
|
|
963
|
+
sessionId: targets$1.resolve()?.sessionId ?? null
|
|
964
|
+
},
|
|
965
|
+
lastInjection: ctx.getLastInjection()
|
|
966
|
+
}, cors);
|
|
967
|
+
}
|
|
968
|
+
if (method === "GET" && path === "/targets") return sendJson(res, 200, {
|
|
969
|
+
targets: targets$1.list(),
|
|
970
|
+
default: targets$1.resolve()?.sessionId ?? null
|
|
971
|
+
}, cors);
|
|
972
|
+
if (method === "POST" && path === "/capture" && loop) {
|
|
973
|
+
const captureId = randomUUID();
|
|
974
|
+
const ok = hub$1.requestCapture(captureId, queryOf(req).get("note") ?? void 0);
|
|
975
|
+
if (!ok) return sendError(res, 409, ERROR_CODES.NO_CAMERA, "no camera uplink connected");
|
|
976
|
+
return sendJson(res, 202, { captureId });
|
|
977
|
+
}
|
|
978
|
+
return sendError(res, 404, ERROR_CODES.BAD_REQUEST, `no route ${method} ${path}`);
|
|
979
|
+
}
|
|
980
|
+
function sendJson(res, status, body, extraHeaders) {
|
|
981
|
+
res.writeHead(status, {
|
|
982
|
+
"content-type": "application/json; charset=utf-8",
|
|
983
|
+
...extraHeaders ?? {}
|
|
984
|
+
});
|
|
985
|
+
res.end(JSON.stringify(body));
|
|
986
|
+
}
|
|
987
|
+
function sendError(res, status, code, message) {
|
|
988
|
+
sendJson(res, status, { error: {
|
|
989
|
+
code,
|
|
990
|
+
message
|
|
991
|
+
} });
|
|
992
|
+
}
|
|
993
|
+
function str(v) {
|
|
994
|
+
return typeof v === "string" ? v : void 0;
|
|
995
|
+
}
|
|
996
|
+
async function readJsonBody(req, limit) {
|
|
997
|
+
const { buf } = await readRawBody(req, limit);
|
|
998
|
+
try {
|
|
999
|
+
return JSON.parse(buf.toString("utf8"));
|
|
1000
|
+
} catch {
|
|
1001
|
+
return null;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
function readRawBody(req, maxBytes) {
|
|
1005
|
+
return new Promise((resolve, reject) => {
|
|
1006
|
+
const chunks = [];
|
|
1007
|
+
let size = 0;
|
|
1008
|
+
let truncated = false;
|
|
1009
|
+
req.on("data", (chunk) => {
|
|
1010
|
+
size += chunk.byteLength;
|
|
1011
|
+
if (size > maxBytes) {
|
|
1012
|
+
truncated = true;
|
|
1013
|
+
chunks.length = 0;
|
|
1014
|
+
req.destroy();
|
|
1015
|
+
resolve({
|
|
1016
|
+
buf: Buffer.alloc(0),
|
|
1017
|
+
truncated
|
|
1018
|
+
});
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
chunks.push(chunk);
|
|
1022
|
+
});
|
|
1023
|
+
req.on("end", () => resolve({
|
|
1024
|
+
buf: Buffer.concat(chunks),
|
|
1025
|
+
truncated
|
|
1026
|
+
}));
|
|
1027
|
+
req.on("error", reject);
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
/** Delete the oldest files beyond `max` in a directory (mtime order). */
|
|
1031
|
+
async function pruneUploads(dir, max) {
|
|
1032
|
+
if (max <= 0) return;
|
|
1033
|
+
const names = await readdir(dir);
|
|
1034
|
+
if (names.length <= max) return;
|
|
1035
|
+
const entries = await Promise.all(names.map(async (name) => {
|
|
1036
|
+
let mtime = 0;
|
|
1037
|
+
try {
|
|
1038
|
+
mtime = (await stat(join(dir, name))).mtimeMs;
|
|
1039
|
+
} catch {
|
|
1040
|
+
mtime = 0;
|
|
1041
|
+
}
|
|
1042
|
+
return {
|
|
1043
|
+
name,
|
|
1044
|
+
mtime
|
|
1045
|
+
};
|
|
1046
|
+
}));
|
|
1047
|
+
entries.sort((a, b) => b.mtime - a.mtime);
|
|
1048
|
+
for (const entry of entries.slice(max)) await unlink(join(dir, entry.name)).catch(() => {});
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
//#endregion
|
|
1052
|
+
//#region src/server/hub.ts
|
|
1053
|
+
/**
|
|
1054
|
+
|
|
1055
|
+
* The viewfinder hub: MULTIPLE camera uplinks (one per phone, keyed by
|
|
1056
|
+
|
|
1057
|
+
* deviceId), N loopback view downlinks. The "active" device is auto-selected
|
|
1058
|
+
|
|
1059
|
+
* as the last one to send a frame; the view side can switch it via
|
|
1060
|
+
|
|
1061
|
+
* `select_device`. Only the active device's frames are fanned out — a second
|
|
1062
|
+
|
|
1063
|
+
* phone connecting no longer kicks the first, so several paired phones coexist
|
|
1064
|
+
|
|
1065
|
+
* and the user picks which one to watch / shoot from.
|
|
1066
|
+
|
|
1067
|
+
*/
|
|
1068
|
+
var ViewHub = class {
|
|
1069
|
+
cameras = new Map();
|
|
1070
|
+
activeDeviceId = null;
|
|
1071
|
+
views = new Set();
|
|
1072
|
+
/** captureId → { note, requestedAt } until the matching upload lands or timeout. */
|
|
1073
|
+
pendingCaptures = new Map();
|
|
1074
|
+
captureTimeoutMs = 6e4;
|
|
1075
|
+
constructor(config$1, log$1) {
|
|
1076
|
+
this.config = config$1;
|
|
1077
|
+
this.log = log$1;
|
|
1078
|
+
}
|
|
1079
|
+
attachCamera(deviceId, ws, name) {
|
|
1080
|
+
const prev = this.cameras.get(deviceId);
|
|
1081
|
+
if (prev) {
|
|
1082
|
+
try {
|
|
1083
|
+
prev.ws.close(1e3, "new-instance");
|
|
1084
|
+
} catch {}
|
|
1085
|
+
this.cameras.delete(deviceId);
|
|
1086
|
+
}
|
|
1087
|
+
const cam = {
|
|
1088
|
+
ws,
|
|
1089
|
+
name,
|
|
1090
|
+
meta: {},
|
|
1091
|
+
lastFrame: null,
|
|
1092
|
+
lastFrameAt: 0,
|
|
1093
|
+
frameCount: 0,
|
|
1094
|
+
windowStart: Date.now(),
|
|
1095
|
+
measuredFps: 0
|
|
1096
|
+
};
|
|
1097
|
+
this.cameras.set(deviceId, cam);
|
|
1098
|
+
if (this.activeDeviceId === null) this.activeDeviceId = deviceId;
|
|
1099
|
+
if (this.activeDeviceId === deviceId) this.sendControl(deviceId, { type: "resume_preview" });
|
|
1100
|
+
else this.sendControl(deviceId, { type: "pause_preview" });
|
|
1101
|
+
this.log("info", `camera uplink: ${name} (${deviceId.slice(0, 8)})`);
|
|
1102
|
+
ws.on("close", () => {
|
|
1103
|
+
if (this.cameras.get(deviceId)?.ws === ws) this.detachCamera(deviceId);
|
|
1104
|
+
});
|
|
1105
|
+
ws.on("message", (data, isBinary) => {
|
|
1106
|
+
if (isBinary) {
|
|
1107
|
+
this.ingestFrame(deviceId, data);
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
this.onCameraControl(deviceId, safeJson(data.toString()));
|
|
1111
|
+
});
|
|
1112
|
+
this.broadcastDevices();
|
|
1113
|
+
}
|
|
1114
|
+
detachCamera(deviceId) {
|
|
1115
|
+
const cam = this.cameras.get(deviceId);
|
|
1116
|
+
if (cam) this.cameras.delete(deviceId);
|
|
1117
|
+
if (this.activeDeviceId === deviceId) {
|
|
1118
|
+
const next = [...this.cameras.keys()].at(-1) ?? null;
|
|
1119
|
+
this.activeDeviceId = next;
|
|
1120
|
+
if (next) this.sendControl(next, { type: "resume_preview" });
|
|
1121
|
+
this.pushActiveFrameToViews();
|
|
1122
|
+
}
|
|
1123
|
+
this.broadcastDevices();
|
|
1124
|
+
}
|
|
1125
|
+
detachAll() {
|
|
1126
|
+
for (const cam of this.cameras.values()) try {
|
|
1127
|
+
cam.ws.close(1e3, "server-dispose");
|
|
1128
|
+
} catch {}
|
|
1129
|
+
this.cameras.clear();
|
|
1130
|
+
this.activeDeviceId = null;
|
|
1131
|
+
this.broadcastDevices();
|
|
1132
|
+
}
|
|
1133
|
+
pingAll() {
|
|
1134
|
+
for (const cam of this.cameras.values()) if (cam.ws.readyState === cam.ws.OPEN) cam.ws.ping();
|
|
1135
|
+
}
|
|
1136
|
+
onCameraControl(deviceId, msg) {
|
|
1137
|
+
if (!msg) return;
|
|
1138
|
+
const cam = this.cameras.get(deviceId);
|
|
1139
|
+
if (!cam) return;
|
|
1140
|
+
switch (msg.type) {
|
|
1141
|
+
case "hello":
|
|
1142
|
+
cam.meta = {
|
|
1143
|
+
width: msg.width,
|
|
1144
|
+
height: msg.height,
|
|
1145
|
+
fps: msg.fps,
|
|
1146
|
+
...msg.rotation !== void 0 ? { rotation: msg.rotation } : {}
|
|
1147
|
+
};
|
|
1148
|
+
if (this.activeDeviceId === deviceId) this.broadcastToViews({
|
|
1149
|
+
type: "frame_meta",
|
|
1150
|
+
width: msg.width,
|
|
1151
|
+
height: msg.height,
|
|
1152
|
+
...msg.rotation !== void 0 ? { rotation: msg.rotation } : {}
|
|
1153
|
+
});
|
|
1154
|
+
break;
|
|
1155
|
+
case "bye":
|
|
1156
|
+
this.detachCamera(deviceId);
|
|
1157
|
+
break;
|
|
1158
|
+
case "claim_active":
|
|
1159
|
+
this.selectDevice(deviceId);
|
|
1160
|
+
break;
|
|
1161
|
+
case "capture_result":
|
|
1162
|
+
if (msg.status !== "taken") {
|
|
1163
|
+
this.pendingCaptures.delete(msg.captureId);
|
|
1164
|
+
this.broadcastToViews({
|
|
1165
|
+
type: "error",
|
|
1166
|
+
code: "CAPTURE_DECLINED",
|
|
1167
|
+
message: `phone reported ${msg.status}${msg.detail ? `: ${msg.detail}` : ""}`
|
|
1168
|
+
});
|
|
1169
|
+
}
|
|
1170
|
+
break;
|
|
1171
|
+
default: break;
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
ingestFrame(deviceId, frame) {
|
|
1175
|
+
const cam = this.cameras.get(deviceId);
|
|
1176
|
+
if (!cam || frame.byteLength === 0) return;
|
|
1177
|
+
if (frame.byteLength > this.config.limits.previewFrameMaxBytes) {
|
|
1178
|
+
this.log("warn", `dropping oversized preview frame from ${deviceId.slice(0, 8)} (${frame.byteLength}B > ${this.config.limits.previewFrameMaxBytes}B)`);
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
const now = Date.now();
|
|
1182
|
+
cam.lastFrame = frame;
|
|
1183
|
+
cam.lastFrameAt = now;
|
|
1184
|
+
cam.frameCount++;
|
|
1185
|
+
if (now - cam.windowStart >= 2e3) {
|
|
1186
|
+
cam.measuredFps = cam.frameCount * 1e3 / (now - cam.windowStart);
|
|
1187
|
+
cam.windowStart = now;
|
|
1188
|
+
cam.frameCount = 0;
|
|
1189
|
+
}
|
|
1190
|
+
if (this.activeDeviceId === deviceId) {
|
|
1191
|
+
for (const view of this.views) if (view.readyState === view.OPEN) view.send(frame, { binary: true });
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
attachView(ws, hooks = {}) {
|
|
1195
|
+
this.views.add(ws);
|
|
1196
|
+
ws.on("close", () => this.views.delete(ws));
|
|
1197
|
+
ws.on("message", (data, isBinary) => {
|
|
1198
|
+
if (isBinary) return;
|
|
1199
|
+
const msg = parseViewClient(data.toString());
|
|
1200
|
+
if (!msg) return;
|
|
1201
|
+
if (msg.type === "capture") {
|
|
1202
|
+
const captureId = randomUUID();
|
|
1203
|
+
if (!this.requestCapture(captureId, msg.note)) this.broadcastToViews({
|
|
1204
|
+
type: "error",
|
|
1205
|
+
code: "NO_CAMERA",
|
|
1206
|
+
message: "no camera uplink connected"
|
|
1207
|
+
});
|
|
1208
|
+
} else if (msg.type === "select_device") this.selectDevice(msg.deviceId);
|
|
1209
|
+
else if (msg.type === "rename_device") hooks.onRenameDevice?.(msg.deviceId, msg.name);
|
|
1210
|
+
else if (msg.type === "refresh_pairing") hooks.onRefreshPairing?.();
|
|
1211
|
+
});
|
|
1212
|
+
const active = this.activeCam();
|
|
1213
|
+
ws.send(JSON.stringify({
|
|
1214
|
+
type: "meta",
|
|
1215
|
+
camera: active ? {
|
|
1216
|
+
connected: true,
|
|
1217
|
+
name: active.name,
|
|
1218
|
+
...active.meta
|
|
1219
|
+
} : { connected: false },
|
|
1220
|
+
preview: this.config.preview,
|
|
1221
|
+
paired: true
|
|
1222
|
+
}));
|
|
1223
|
+
this.broadcastDevicesTo(ws);
|
|
1224
|
+
if (active?.lastFrame && ws.readyState === ws.OPEN) ws.send(active.lastFrame, { binary: true });
|
|
1225
|
+
}
|
|
1226
|
+
viewCount() {
|
|
1227
|
+
return this.views.size;
|
|
1228
|
+
}
|
|
1229
|
+
/** Update one device's display name and re-broadcast the device list. */
|
|
1230
|
+
renameDevice(deviceId, name) {
|
|
1231
|
+
const cam = this.cameras.get(deviceId);
|
|
1232
|
+
if (cam) cam.name = name;
|
|
1233
|
+
this.broadcastDevices();
|
|
1234
|
+
}
|
|
1235
|
+
/** Switch which phone's frames / shutter the view follows. */
|
|
1236
|
+
selectDevice(deviceId) {
|
|
1237
|
+
if (!this.cameras.has(deviceId)) return;
|
|
1238
|
+
const prevActive = this.activeDeviceId;
|
|
1239
|
+
if (prevActive === deviceId) return;
|
|
1240
|
+
this.activeDeviceId = deviceId;
|
|
1241
|
+
if (prevActive) this.sendControl(prevActive, { type: "pause_preview" });
|
|
1242
|
+
this.sendControl(deviceId, { type: "resume_preview" });
|
|
1243
|
+
this.broadcastDevices();
|
|
1244
|
+
this.pushActiveFrameToViews();
|
|
1245
|
+
}
|
|
1246
|
+
/** Send one control message to a specific phone uplink. */
|
|
1247
|
+
sendControl(deviceId, msg) {
|
|
1248
|
+
const cam = this.cameras.get(deviceId);
|
|
1249
|
+
if (cam && cam.ws.readyState === cam.ws.OPEN) cam.ws.send(JSON.stringify(msg));
|
|
1250
|
+
}
|
|
1251
|
+
pushActiveFrameToViews() {
|
|
1252
|
+
const active = this.activeCam();
|
|
1253
|
+
if (active?.lastFrame) {
|
|
1254
|
+
for (const view of this.views) if (view.readyState === view.OPEN) view.send(active.lastFrame, { binary: true });
|
|
1255
|
+
}
|
|
1256
|
+
if (active) this.broadcastToViews({
|
|
1257
|
+
type: "frame_meta",
|
|
1258
|
+
width: active.meta.width ?? 0,
|
|
1259
|
+
height: active.meta.height ?? 0,
|
|
1260
|
+
...active.meta.rotation !== void 0 ? { rotation: active.meta.rotation } : {}
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
activeCam() {
|
|
1264
|
+
return this.activeDeviceId ? this.cameras.get(this.activeDeviceId) : void 0;
|
|
1265
|
+
}
|
|
1266
|
+
broadcastDevices() {
|
|
1267
|
+
for (const view of this.views) this.broadcastDevicesTo(view);
|
|
1268
|
+
}
|
|
1269
|
+
broadcastDevicesTo(view) {
|
|
1270
|
+
if (view.readyState !== view.OPEN) return;
|
|
1271
|
+
view.send(JSON.stringify({
|
|
1272
|
+
type: "devices",
|
|
1273
|
+
devices: [...this.cameras.values()].map((c, i) => {
|
|
1274
|
+
const id = [...this.cameras.keys()][i];
|
|
1275
|
+
return {
|
|
1276
|
+
id,
|
|
1277
|
+
name: c.name,
|
|
1278
|
+
active: id === this.activeDeviceId
|
|
1279
|
+
};
|
|
1280
|
+
})
|
|
1281
|
+
}));
|
|
1282
|
+
}
|
|
1283
|
+
/** Ask the ACTIVE phone to shoot. Returns the captureId, or null when none. */
|
|
1284
|
+
requestCapture(captureId, note) {
|
|
1285
|
+
const active = this.activeCam();
|
|
1286
|
+
if (!active || active.ws.readyState !== active.ws.OPEN) return null;
|
|
1287
|
+
this.pendingCaptures.set(captureId, {
|
|
1288
|
+
note,
|
|
1289
|
+
requestedAt: Date.now()
|
|
1290
|
+
});
|
|
1291
|
+
active.ws.send(JSON.stringify({
|
|
1292
|
+
type: "capture",
|
|
1293
|
+
captureId,
|
|
1294
|
+
...note ? { note } : {}
|
|
1295
|
+
}));
|
|
1296
|
+
this.broadcastToViews({
|
|
1297
|
+
type: "capture_pending",
|
|
1298
|
+
captureId,
|
|
1299
|
+
...note ? { note } : {}
|
|
1300
|
+
});
|
|
1301
|
+
this.gcCaptures();
|
|
1302
|
+
return captureId;
|
|
1303
|
+
}
|
|
1304
|
+
consumeCapture(captureId) {
|
|
1305
|
+
const pending = this.pendingCaptures.get(captureId);
|
|
1306
|
+
if (!pending) return null;
|
|
1307
|
+
this.pendingCaptures.delete(captureId);
|
|
1308
|
+
return { note: pending.note };
|
|
1309
|
+
}
|
|
1310
|
+
noteFor(captureId) {
|
|
1311
|
+
return this.pendingCaptures.get(captureId)?.note;
|
|
1312
|
+
}
|
|
1313
|
+
gcCaptures() {
|
|
1314
|
+
const cutoff = Date.now() - this.captureTimeoutMs;
|
|
1315
|
+
for (const [id, p] of this.pendingCaptures) if (p.requestedAt < cutoff) this.pendingCaptures.delete(id);
|
|
1316
|
+
}
|
|
1317
|
+
broadcastToViews(msg) {
|
|
1318
|
+
const text = JSON.stringify(msg);
|
|
1319
|
+
for (const view of this.views) if (view.readyState === view.OPEN) view.send(text);
|
|
1320
|
+
}
|
|
1321
|
+
stats() {
|
|
1322
|
+
const active = this.activeCam();
|
|
1323
|
+
return {
|
|
1324
|
+
connected: this.cameras.size > 0,
|
|
1325
|
+
fps: active ? Math.round(active.measuredFps * 10) / 10 : 0,
|
|
1326
|
+
lastFrameAt: active?.lastFrameAt ?? 0,
|
|
1327
|
+
views: this.views.size,
|
|
1328
|
+
devices: this.cameras.size
|
|
1329
|
+
};
|
|
1330
|
+
}
|
|
1331
|
+
};
|
|
1332
|
+
function safeJson(text) {
|
|
1333
|
+
try {
|
|
1334
|
+
return JSON.parse(text);
|
|
1335
|
+
} catch {
|
|
1336
|
+
return null;
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
function parseViewClient(text) {
|
|
1340
|
+
try {
|
|
1341
|
+
return JSON.parse(text);
|
|
1342
|
+
} catch {
|
|
1343
|
+
return null;
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
//#endregion
|
|
1348
|
+
//#region src/paths.ts
|
|
1349
|
+
/** Where pairing records + fallback uploads live (mirrors dsh-home conventions). */
|
|
1350
|
+
function lensDataDir() {
|
|
1351
|
+
const home = env.DSH_HOME ?? join(homedir(), ".dsh");
|
|
1352
|
+
return join(home, "phone-lens");
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
//#endregion
|
|
1356
|
+
//#region src/dev.ts
|
|
1357
|
+
const config = normalizeConfig(process.env.LENS_PORT ? { server: { port: Number(process.env.LENS_PORT) } } : void 0);
|
|
1358
|
+
const dataDir = process.env.LENS_DATA_DIR ?? lensDataDir();
|
|
1359
|
+
const log = (level, msg) => console[level](`[phone-lens:dev] ${msg}`);
|
|
1360
|
+
const pairing = new PairingStore(config.pairing.codeTtlMs);
|
|
1361
|
+
const devices = new DeviceStore(dataDir);
|
|
1362
|
+
const hub = new ViewHub(config, (lvl, msg) => log(lvl, msg));
|
|
1363
|
+
const targets = new TargetTracker(config);
|
|
1364
|
+
const sink = new LoggingSink((lvl, msg) => log(lvl, msg));
|
|
1365
|
+
const handle = await startLensServer({
|
|
1366
|
+
config,
|
|
1367
|
+
pairing,
|
|
1368
|
+
devices,
|
|
1369
|
+
hub,
|
|
1370
|
+
targets,
|
|
1371
|
+
sink,
|
|
1372
|
+
attachments: () => void 0,
|
|
1373
|
+
fallbackDir: join(dataDir, "uploads"),
|
|
1374
|
+
pendingDir: join(dataDir, "pending"),
|
|
1375
|
+
log
|
|
1376
|
+
});
|
|
1377
|
+
const qr = await buildPairingQr(pairing.current().code, pairing.current().expiresAt, config);
|
|
1378
|
+
process.stdout.write(`\n[phone-lens:dev] standalone receiver on :${handle.port}\n${qr.ascii}\n[phone-lens:dev] view page: http://127.0.0.1:${handle.port}/view.html\n\n`);
|
|
1379
|
+
const stats = setInterval(() => {
|
|
1380
|
+
log("info", `camera=${hub.stats().connected ? "on" : "off"} fps=${hub.stats().fps} views=${hub.stats().views} devices=${devices.count()}`);
|
|
1381
|
+
}, 3e4);
|
|
1382
|
+
const shutdown = async () => {
|
|
1383
|
+
clearInterval(stats);
|
|
1384
|
+
await handle.dispose();
|
|
1385
|
+
process.exit(0);
|
|
1386
|
+
};
|
|
1387
|
+
process.on("SIGINT", () => void shutdown());
|
|
1388
|
+
process.on("SIGTERM", () => void shutdown());
|
|
1389
|
+
|
|
1390
|
+
//#endregion
|