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 ADDED
@@ -0,0 +1,60 @@
1
+ # phone-lens(dsh 插件)
2
+
3
+ `phone-lens` 是一个 **dsh 双面插件**,让手机相机成为 dsh 会话的即时视觉输入:
4
+
5
+ - **host 半边**:在 dsh 进程内起接收服务(Node HTTP + WebSocket,默认 `0.0.0.0:8791`),处理配对、上传、并将图片注入 dsh 会话输入框。
6
+ - **client 半边**:在 dsh Web UI 右下角注册一个悬浮取景小窗(`shell.overlay` 插槽),实时画面 + 快门 + 配对二维码。
7
+
8
+ ## 环境要求
9
+
10
+ - Node.js ≥ 20、pnpm
11
+ - 一个 dsh web 配置(`dsh plugin --profile web`)
12
+
13
+ ## 安装
14
+
15
+ ```powershell
16
+ # 1. 构建
17
+ cd packages\phone-lens
18
+ pnpm install
19
+ pnpm build
20
+
21
+ # 2. 接入 dsh 的 web profile
22
+ dsh plugin --profile web add C:\Users\<you>\phone-lens\packages\phone-lens
23
+ ```
24
+
25
+ 也可以用仓库根的 `scripts\dev-install.ps1` 一键完成构建与接入,然后重启 `dsh web`,
26
+ 终端会打印配对二维码。
27
+
28
+ > 发布到 npm 后,可直接 `dsh plugin --profile web add phone-lens`。
29
+
30
+ ## 配置
31
+
32
+ 默认配置见 `cordis.patch.yml`,可在你的 profile 层覆盖:
33
+
34
+ | 键 | 默认 | 说明 |
35
+ |---|---|---|
36
+ | `server.port` | `8791` | 接收服务端口 |
37
+ | `limits.maxUploadBytes` | `10485760` | 单张图片上限 |
38
+ | `limits.uploadsPerMinute` | `10` | 上传频率限制 |
39
+ | `pairing.codeTtlMs` | `900000` | 配对码有效期(15 分钟) |
40
+ | `preview.fps` | `10` | 预览帧率 |
41
+ | `inject.notePrefix` | `[手机拍照]` | 注入消息文本块前缀 |
42
+ | `target.mode` | `latest` | 目标会话:`latest`(最近活跃)/ `pinned`(固定) |
43
+
44
+ ## 使用
45
+
46
+ 1. 手机 App 扫码配对
47
+ 2. 手机端拍照(可选裁剪)→ 图片进入当前 dsh 会话
48
+ 3. 手机端「启动预览」→ 电脑端悬浮小窗实时取景;多设备时手机端「设为主机」抢占预览
49
+
50
+ 详见仓库根 [README](../../README.md) 与 `docs/`。
51
+
52
+ ## 开发
53
+
54
+ ```bash
55
+ pnpm build # tsdown 构建 lib/index.js(host)与 dev.js
56
+ pnpm dev:standalone # 不进入 dsh,独立起接收服务(上传落盘 ~/.dsh/phone-lens/uploads)
57
+ node --check lib/client.js # 手写 client 的语法校验
58
+ ```
59
+
60
+ 协议与安全设计见 `docs/protocol.md`、`docs/architecture.md`。
@@ -0,0 +1,35 @@
1
+ # phone-lens bundle patch: inserts the host-side plugin row into the profile.
2
+ # Applied via `dsh plugin --profile web add <path-to-packages/phone-lens>`.
3
+ # All config keys mirror src/config.ts defaults; a deployment overrides them
4
+ # in its own profile layer without touching this file.
5
+
6
+ - insert:
7
+ - id: phone-lens
8
+ name: 'phone-lens'
9
+ config:
10
+ server:
11
+ host: '0.0.0.0'
12
+ port: 8791
13
+ limits:
14
+ maxUploadBytes: 10485760
15
+ allowedTypes:
16
+ - 'image/jpeg'
17
+ - 'image/png'
18
+ uploadsPerMinute: 10
19
+ previewFrameMaxBytes: 524288
20
+ pairing:
21
+ codeTtlMs: 900000
22
+ preview:
23
+ maxWidth: 854
24
+ maxHeight: 480
25
+ fps: 10
26
+ jpegQuality: 70
27
+ inject:
28
+ mode: 'followup'
29
+ notePrefix: '[手机拍照]'
30
+ target:
31
+ mode: 'latest'
32
+ pinnedSessionId: null
33
+ app:
34
+ # 手机 App 下载二维码指向的地址;留空走内置默认(Gitee 发行版直链,国内快)
35
+ downloadUrl: null
package/lib/client.js ADDED
@@ -0,0 +1,568 @@
1
+ /**
2
+ * phone-lens browser half — the floating viewfinder window inside the dsh Web UI.
3
+ *
4
+ * Registers into the additive `shell.overlay` slot (list kind, frame-wide
5
+ * floating layer). Speaks the same protocol as /view.html:
6
+ * WS ws://127.0.0.1:<port>/ws/view (frames + events + shutter control)
7
+ * GET http://127.0.0.1:<port>/qr.json (pairing QR; loopback CORS is allowed)
8
+ *
9
+ * No bundler pipeline: this file IS the artifact (lazy-CJS factory registered
10
+ * into window.__ModuleLoader__), react comes from the shell static table.
11
+ */
12
+ window.__ModuleLoader__.load({
13
+ id: "phone-lens",
14
+ factory: (require) => {
15
+ var module = { exports: {} };
16
+ var exports = module.exports;
17
+ const react = require("react");
18
+ const { useState, useEffect, useRef } = react;
19
+
20
+ const NS = "phone-lens";
21
+ const PORT_KEY = "phoneLens.port";
22
+ // App download fallbacks (used when /app-qr.json is unavailable on an older host).
23
+ // Gitee release carrying the newest APK (App unchanged since v0.2.0).
24
+ const APP_GITEE = "https://gitee.com/qianfengbingtang/phone-lens/releases/download/v0.2.0/app-release.apk";
25
+ const APP_GITHUB = "https://github.com/yxqfg/phone-lens/releases/latest/download/app-release.apk";
26
+ const h = react.createElement;
27
+
28
+ // ── styles ────────────────────────────────────────────────────────────
29
+ const styleSheet = document.createElement("style");
30
+ styleSheet.id = `${NS}/overlay.css`;
31
+ styleSheet.textContent = `
32
+ .lm-root { position: fixed; right: 16px; bottom: 16px; z-index: 9999; font-family: system-ui, "Segoe UI", "Microsoft YaHei", sans-serif; }
33
+ .lm-fab { width: 46px; height: 46px; border-radius: 50%; border: 1px solid #3b7cb5; background: linear-gradient(160deg, #2a5d8f, #1d3f61); color: #eaf2fa; font-size: 20px; line-height: 1; cursor: pointer; box-shadow: 0 4px 14px rgba(0,0,0,.45); }
34
+ .lm-fab:hover { filter: brightness(1.15); }
35
+ .lm-fab .dot { position: absolute; top: 4px; right: 4px; width: 8px; height: 8px; border-radius: 50%; background: #e5484d; }
36
+ .lm-fab .dot.on { background: #46a758; }
37
+ .lm-panel { position: absolute; right: 0; bottom: 56px; width: 268px; background: #14181d; border: 1px solid #2a333e; border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,.55); color: #dfe7ee; overflow: hidden; }
38
+ .lm-head { display: flex; align-items: center; gap: 6px; padding: 8px 10px; background: #1a2129; border-bottom: 1px solid #232c36; font-size: 12px; }
39
+ .lm-head b { font-weight: 600; }
40
+ .lm-head .x { margin-left: auto; background: none; border: none; color: #8fa1b5; cursor: pointer; font-size: 14px; }
41
+ .lm-body { padding: 10px; display: flex; flex-direction: column; gap: 8px; }
42
+ .lm-canvas { width: 100%; height: auto; max-height: 300px; background: #000; border-radius: 8px; display: block; object-fit: contain; }
43
+ .lm-status { font-size: 11px; color: #8fa1b5; display: flex; gap: 8px; align-items: center; min-height: 15px; }
44
+ .lm-status .ok { color: #58b368; }
45
+ .lm-status .warn { color: #d9a441; }
46
+ .lm-row { display: flex; gap: 6px; }
47
+ .lm-btn { flex: 1; padding: 7px 8px; border-radius: 8px; border: 1px solid #2f3b48; background: #1f2937; color: #dfe7ee; font-size: 12px; cursor: pointer; }
48
+ .lm-btn:hover { background: #28323e; }
49
+ .lm-btn:disabled { opacity: .45; cursor: default; }
50
+ .lm-btn.primary { background: #2a5d8f; border-color: #3b7cb5; }
51
+ .lm-qr { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 8px; background: #10141a; border: 1px dashed #2a333e; border-radius: 8px; }
52
+ .lm-qr img { width: 148px; height: 148px; image-rendering: pixelated; background: #fff; border-radius: 6px; }
53
+ .lm-qr .code { font-size: 16px; letter-spacing: .18em; }
54
+ .lm-qr .hint { font-size: 10px; color: #7d8da0; text-align: center; line-height: 1.45; }
55
+ .lm-qr a.lm-dl { display: block; margin-top: 4px; font-size: 11px; color: #3b7cb5; text-decoration: none; }
56
+ .lm-qr a.lm-dl:hover { text-decoration: underline; }
57
+ .lm-port { display: flex; gap: 6px; align-items: center; font-size: 11px; color: #7d8da0; }
58
+ .lm-port input { width: 64px; background: #0d1117; color: #dfe7ee; border: 1px solid #2f3b48; border-radius: 6px; padding: 3px 6px; font-size: 12px; }
59
+ .lm-flash { font-size: 11px; color: #8fa1b5; min-height: 14px; }
60
+ .lm-flash.ok { color: #58b368; }
61
+ .lm-flash.err { color: #e5696e; }
62
+ .lm-pending { display: flex; flex-direction: column; gap: 4px; padding: 6px; background: #10141a; border: 1px dashed #2a333e; border-radius: 8px; }
63
+ .lm-pending-label { font-size: 10px; color: #7d8da0; }
64
+ .lm-devices { display: flex; flex-wrap: wrap; gap: 6px; }
65
+ .lm-device { flex: none; padding: 4px 8px; border-radius: 14px; border: 1px solid #2f3b48; background: #1f2937; color: #8fa1b5; font-size: 11px; cursor: pointer; }
66
+ .lm-device.active { border-color: #46a758; color: #d2f5d8; background: #1f4a2c; }
67
+ .lm-device:hover { background: #28323e; }
68
+ .lm-rename { position: absolute; right: 0; bottom: 56px; width: 268px; background: #14181d; border: 1px solid #2a333e; border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,.6); padding: 12px; display: flex; flex-direction: column; gap: 8px; z-index: 5; }
69
+ .lm-rename-title { font-size: 13px; color: #dfe7ee; font-weight: 600; }
70
+ .lm-rename-input { background: #0d1117; color: #dfe7ee; border: 1px solid #2f3b48; border-radius: 8px; padding: 8px; font-size: 13px; }
71
+ .lm-appqr { position: absolute; right: 0; bottom: 56px; width: 268px; background: #14181d; border: 1px solid #2a333e; border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,.6); padding: 12px; display: flex; flex-direction: column; align-items: center; gap: 8px; z-index: 6; }
72
+ .lm-appqr-title { font-size: 13px; color: #dfe7ee; font-weight: 600; }
73
+ .lm-appqr-img { width: 168px; height: 168px; image-rendering: pixelated; background: #fff; border-radius: 8px; }
74
+ .lm-appqr-hint { font-size: 10px; color: #7d8da0; text-align: center; line-height: 1.45; }
75
+ .lm-appqr-links { font-size: 11px; display: flex; gap: 5px; align-items: center; color: #7d8da0; }
76
+ .lm-appqr-links a { color: #3b7cb5; text-decoration: none; }
77
+ .lm-appqr-links a:hover { text-decoration: underline; }
78
+ .fab-cam { display: inline-flex; align-items: center; justify-content: center; }
79
+ .fab-cam svg { display: block; }
80
+ @keyframes lm-blast { 0%,100% { background: #6cc0ff; box-shadow: 0 0 16px rgba(108,192,255,.9); } 50% { background: #2a6db8; box-shadow: 0 0 4px rgba(108,192,255,.2); } }
81
+ .lm-fab.connected { background: #1f2937; color: #46a758; }
82
+ .lm-fab.connecting { color: #ffffff; background: #6cc0ff; animation: lm-blast .5s ease-in-out 3; }
83
+ .lm-fab.disconnected { background: #23303c; color: #8fa1b5; }
84
+ .lm-status .warn-big { color: #d9a441; font-weight: 600; font-size: 11px; line-height: 1.4; white-space: normal; }
85
+ `;
86
+ // The factory runs at materialization (first import), so the document
87
+ // head is guaranteed to exist; remove-then-add keeps HMR idempotent.
88
+ {
89
+ const prior = document.getElementById(styleSheet.id);
90
+ if (prior) prior.remove();
91
+ document.head.appendChild(styleSheet);
92
+ }
93
+
94
+ // ── helpers ───────────────────────────────────────────────────────────
95
+ function drawJpeg(canvas, arrayBuffer, rotation) {
96
+ if (!canvas) return;
97
+ const blob = new Blob([arrayBuffer], { type: "image/jpeg" });
98
+ const url = URL.createObjectURL(blob);
99
+ const img = new Image();
100
+ img.onload = () => {
101
+ const r = ((rotation % 360) + 360) % 360;
102
+ const swap = r === 90 || r === 270;
103
+ const w = swap ? img.height : img.width;
104
+ const h = swap ? img.width : img.height;
105
+ if (canvas.width !== w || canvas.height !== h) {
106
+ canvas.width = w;
107
+ canvas.height = h;
108
+ }
109
+ const ctx = canvas.getContext("2d");
110
+ ctx.save();
111
+ ctx.translate(w / 2, h / 2);
112
+ ctx.rotate((r * Math.PI) / 180);
113
+ ctx.drawImage(img, -img.width / 2, -img.height / 2);
114
+ ctx.restore();
115
+ URL.revokeObjectURL(url);
116
+ };
117
+ img.onerror = () => URL.revokeObjectURL(url);
118
+ img.src = url;
119
+ }
120
+
121
+ // ── composable pre-send (dsh composer draft) ───────────────────────────
122
+ // The phone photo is staged as a composer draft attachment, NOT injected
123
+ // into the model: the user types text next and hits send. Uses the dsh
124
+ // channel confirmed from source: sessions.scope(sessionId) → conversation
125
+ // service → createDraftImages(files) → input.for(actx).addImages(ids).
126
+ let hostCtx = null; // set in apply() so the component can reach the runtime
127
+
128
+ function currentSessionId(ctx) {
129
+ try {
130
+ // cordis: accessing a service as a ctx property requires it in
131
+ // `inject`; use ctx.get() instead (never throws for absent).
132
+ const sessions = ctx.get("sessions");
133
+ if (!sessions) throw new Error("no sessions service");
134
+ const list = sessions.list;
135
+ if (!list) throw new Error("no sessions.list");
136
+ const snap = typeof list.getSnapshot === "function" ? list.getSnapshot() : list.snapshot;
137
+ if (!snap) throw new Error("no session list snapshot");
138
+ return snap.current;
139
+ } catch (e) {
140
+ throw new Error("currentSessionId: " + String(e));
141
+ }
142
+ }
143
+
144
+ async function stageIntoComposer(ctx, port, attachmentId) {
145
+ if (!ctx) throw new Error("no runtime ctx (phone-lens host not wired)");
146
+ const resp = await fetch(`http://127.0.0.1:${port}/pending/${attachmentId}`).catch((e) => { throw new Error("fetch pending: " + String(e)); });
147
+ if (!resp.ok) throw new Error("pending fetch HTTP " + resp.status);
148
+ const buf = await resp.arrayBuffer();
149
+ const file = new File([buf], `phone-${attachmentId.slice(0, 8)}.jpg`, { type: "image/jpeg" });
150
+ const sid = currentSessionId(ctx);
151
+ if (!sid) throw new Error("no current session id");
152
+ // scope-addressed: ctx.get('sessions').scope(sid) → scoped actx
153
+ const sessions = ctx.get("sessions");
154
+ const actx = sessions.scope(sid);
155
+ if (!actx) throw new Error("no session scope for " + sid);
156
+ const conv = actx.get("conversation");
157
+ if (!conv) throw new Error("no conversation service in scope");
158
+ const atts = conv.createDraftImages([file]);
159
+ if (!atts || !atts.length) throw new Error("createDraftImages empty");
160
+ const input = conv.input.for(actx);
161
+ const ok = input.addImages(atts.map((a) => a.id));
162
+ if (ok !== true) throw new Error("input.addImages rejected (busy admission?)");
163
+ return true;
164
+ }
165
+
166
+ // Minimal single-line camera glyph; state expressed by accent + a small
167
+ // overlay mark (slash = disconnected, green dot = connected, pulse =
168
+ // connecting). Keeps the FAB clean instead of an emoji.
169
+ function CameraIcon({ state }) {
170
+ const cls = "fab-cam" + (state === "connected" ? " connected" : state === "connecting" ? " connecting" : " disconnected");
171
+ return h(
172
+ "span",
173
+ { className: cls },
174
+ h(
175
+ "svg",
176
+ { viewBox: "0 0 24 24", width: 24, height: 24, fill: "none", stroke: "currentColor", strokeWidth: 1.7, strokeLinecap: "round", strokeLinejoin: "round" },
177
+ h("path", { d: "M4 8h3l1.7-2.3h6.6L17 8h3a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1Z" }),
178
+ h("circle", { cx: 12, cy: 13.4, r: 3.4 }),
179
+ state === "connected" ? h("circle", { cx: 12, cy: 13.4, r: 1.5, fill: "#46a758", stroke: "none" }) : null,
180
+ state === "disconnected" ? h("path", { d: "M3.5 3.5l17 17", stroke: "#e5484d", strokeWidth: 1.9 }) : null,
181
+ ),
182
+ );
183
+ }
184
+
185
+ function LensOverlay(_props) {
186
+ const [open, setOpen] = useState(false);
187
+ const [port, setPort] = useState(() => {
188
+ try {
189
+ return window.localStorage.getItem(PORT_KEY) || "8791";
190
+ } catch {
191
+ return "8791";
192
+ }
193
+ });
194
+ const [portDraft, setPortDraft] = useState(port);
195
+ const [camOn, setCamOn] = useState(false);
196
+ const [link, setLink] = useState("disconnected"); // disconnected | connecting | connected
197
+ const [fps, setFps] = useState(0);
198
+ const [qr, setQr] = useState(null);
199
+ const [, setTick] = useState(0); // 1s heartbeat: re-renders the QR countdown while pairing is visible
200
+ const [flash, setFlash] = useState("");
201
+ const [pending, setPending] = useState([]); // [{id, name}] waiting to be staged
202
+ const [devices, setDevices] = useState([]); // [{id,name,active}]
203
+ const [rename, setRename] = useState(null); // {id, name} — self-drawn rename dialog
204
+ const [appQr, setAppQr] = useState(null); // app-download QR dialog payload (or {loading}/{fallback:true})
205
+ const canvasRef = useRef(null);
206
+ const lastQrCode = useRef(null); // last pairing code seen — detects auto rotation for the "已更新" flash
207
+ const wsRef = useRef(null);
208
+ const framesRef = useRef(0);
209
+ const rotRef = useRef(0);
210
+
211
+ const baseUrl = `http://127.0.0.1:${port}`;
212
+ const wsUrl = `ws://127.0.0.1:${port}/ws/view`;
213
+
214
+ // view websocket: ALWAYS connected (independent of panel open state),
215
+ // so the connection status and pending-photo events refresh even when
216
+ // the FAB is collapsed. Frames are only drawn when the canvas exists.
217
+ useEffect(() => {
218
+ let stopped = false;
219
+ let timer = null;
220
+ const connect = () => {
221
+ if (stopped) return;
222
+ let ws;
223
+ try {
224
+ ws = new WebSocket(wsUrl);
225
+ } catch {
226
+ timer = setTimeout(connect, 2000);
227
+ return;
228
+ }
229
+ ws.binaryType = "arraybuffer";
230
+ wsRef.current = ws;
231
+ ws.onopen = () => setFlash("");
232
+ ws.onmessage = (ev) => {
233
+ if (ev.data instanceof ArrayBuffer) {
234
+ framesRef.current++;
235
+ drawJpeg(canvasRef.current, ev.data, rotRef.current);
236
+ return;
237
+ }
238
+ let m;
239
+ try {
240
+ m = JSON.parse(ev.data);
241
+ } catch {
242
+ return;
243
+ }
244
+ if (m.type === "meta" || m.type === "device") {
245
+ const on = m.type === "meta" ? m.camera.connected : m.online;
246
+ setCamOn(Boolean(on));
247
+ setLink((prev) => {
248
+ if (!on) return "disconnected";
249
+ return prev === "disconnected" ? "connecting" : prev;
250
+ });
251
+ if (m.type === "meta" && m.camera.rotation !== undefined) rotRef.current = m.camera.rotation;
252
+ } else if (m.type === "frame_meta") {
253
+ if (m.rotation !== undefined) rotRef.current = m.rotation;
254
+ } else if (m.type === "injected") {
255
+ if (m.ok) setFlashOk(`已注入会话 ${(m.sessionId || "").slice(0, 8)}…`);
256
+ else setFlashErr(`已保存到电脑 ✓(未注入:${m.reason || "无活动会话"})`);
257
+ } else if (m.type === "upload") {
258
+ setFlashOk("图片已入库…");
259
+ } else if (m.type === "pending_image") {
260
+ // pre-send: stage this photo into the composer draft
261
+ setPending((p) => [...p, { id: m.attachmentId, name: m.name || "" }]);
262
+ stageIntoComposer(hostCtx, port, m.attachmentId)
263
+ .then(() => {
264
+ setPending((p) => p.filter((x) => x.id !== m.attachmentId));
265
+ setFlashOk("照片已放入对话框输入框,输入文字后发送");
266
+ })
267
+ .catch((e) => setFlashErr("自动放入失败: " + String(e && e.message || e).slice(0, 90)));
268
+ } else if (m.type === "devices") {
269
+ setDevices((prev) => {
270
+ // a NEW device came online → the one-time pairing code was
271
+ // consumed; rotate the QR so the next scan sees a fresh code
272
+ if ((m.devices || []).length > (prev ? prev.length : 0)) void refreshQr(true);
273
+ return m.devices || [];
274
+ });
275
+ // an active device present == camera linked; derive both the
276
+ // canvas visibility and the header/FAB link state from the
277
+ // device list (host no longer emits `device`)
278
+ const active = (m.devices || []).find((d) => d.active);
279
+ setCamOn(Boolean(active));
280
+ setLink((prev) => {
281
+ if (!active) return "disconnected";
282
+ // fresh link plays the triple-flash greeting
283
+ if (prev === "disconnected") return "connecting";
284
+ return prev;
285
+ });
286
+ } else if (m.type === "error") {
287
+ setFlashErr(`${m.code}${m.message ? ":" + m.message : ""}`);
288
+ }
289
+ };
290
+ ws.onclose = () => {
291
+ setCamOn(false);
292
+ if (!stopped) timer = setTimeout(connect, 2000);
293
+ };
294
+ };
295
+ connect();
296
+ return () => {
297
+ stopped = true;
298
+ if (timer) clearTimeout(timer);
299
+ const ws = wsRef.current;
300
+ if (ws) {
301
+ ws.onclose = null;
302
+ try {
303
+ ws.close();
304
+ } catch {}
305
+ }
306
+ wsRef.current = null;
307
+ };
308
+ }, [wsUrl]);
309
+
310
+ // connecting → connected after the 1.5s triple-flash greeting
311
+ useEffect(() => {
312
+ if (link !== "connecting") return;
313
+ const t = setTimeout(() => setLink("connected"), 1500);
314
+ return () => clearTimeout(t);
315
+ }, [link]);
316
+
317
+ // fps meter
318
+ useEffect(() => {
319
+ if (!open) return;
320
+ const iv = setInterval(() => {
321
+ setFps(framesRef.current);
322
+ framesRef.current = 0;
323
+ }, 1000);
324
+ return () => clearInterval(iv);
325
+ }, [open]);
326
+
327
+ function setFlashOk(t) {
328
+ setFlash(t);
329
+ }
330
+ function setFlashErr(t) {
331
+ setFlash(t);
332
+ }
333
+
334
+ async function refreshQr(auto) {
335
+ try {
336
+ const r = await fetch(`${baseUrl}/qr.json`, { cache: "no-store" });
337
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
338
+ const data = await r.json();
339
+ // surfaced only on automatic rotation, so the user sees the code change
340
+ if (auto && lastQrCode.current && lastQrCode.current !== data.code) setFlashOk("二维码已更新,请扫描最新码");
341
+ lastQrCode.current = data.code;
342
+ setQr(data);
343
+ } catch (e) {
344
+ setFlashErr(`接收服务不可达(${String(e).slice(0, 60)})`);
345
+ }
346
+ }
347
+
348
+ // Open the app-download dialog: QR (from the host) + source links.
349
+ // Falls back to the built-in Gitee/GitHub links when the endpoint is
350
+ // missing (older host half running an older release).
351
+ async function openAppQr() {
352
+ setAppQr({ loading: true });
353
+ try {
354
+ const r = await fetch(`${baseUrl}/app-qr.json`, { cache: "no-store" });
355
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
356
+ setAppQr(await r.json());
357
+ } catch (_) {
358
+ setAppQr({ fallback: true, gitee: APP_GITEE, github: APP_GITHUB });
359
+ }
360
+ }
361
+
362
+ // pairing QR lifecycle: fetch on first show, then keep itself alive —
363
+ // refreshes right after expiry (one-time codes burn after 15min),
364
+ // every 30s as a drift/sleep safety net, and beats a 1s heart so the
365
+ // countdown stays honest.
366
+ useEffect(() => {
367
+ if (!open || camOn) return;
368
+ if (!qr) {
369
+ void refreshQr();
370
+ return;
371
+ }
372
+ const remainMs = qr.expiresAt - Date.now();
373
+ const expireTimer = setTimeout(() => void refreshQr(true), Math.max(remainMs, 0) + 800);
374
+ const driftTimer = setInterval(() => void refreshQr(true), 30_000);
375
+ const heartbeat = setInterval(() => setTick((t) => (t + 1) % 1e9), 1000);
376
+ return () => {
377
+ clearTimeout(expireTimer);
378
+ clearInterval(driftTimer);
379
+ clearInterval(heartbeat);
380
+ };
381
+ }, [open, camOn, qr]);
382
+
383
+ function shoot() {
384
+ const ws = wsRef.current; if (ws && ws.readyState === 1) {
385
+ ws.send(JSON.stringify({ type: "capture" }));
386
+ setFlash("快门已触发,等待手机上传…");
387
+ }
388
+ }
389
+
390
+ function selectDevice(id) {
391
+ const ws = wsRef.current;
392
+ if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: "select_device", deviceId: id }));
393
+ }
394
+
395
+ function renameDevice(id, current) {
396
+ setRename({ id, name: current || "" });
397
+ }
398
+ function confirmRename() {
399
+ const ws = wsRef.current;
400
+ const trimmed = String((rename && rename.name) || "").trim();
401
+ if (trimmed && ws && ws.readyState === 1) ws.send(JSON.stringify({ type: "rename_device", deviceId: rename.id, name: trimmed }));
402
+ setRename(null);
403
+ }
404
+
405
+ function savePort() {
406
+ const p = String(portDraft).trim() || "8791";
407
+ setPort(p);
408
+ try {
409
+ window.localStorage.setItem(PORT_KEY, p);
410
+ } catch {}
411
+ setFlash(`端口已切换为 ${p}`);
412
+ }
413
+
414
+ return h(
415
+ "div",
416
+ { className: "lm-root" },
417
+ open
418
+ ? h(
419
+ "div",
420
+ { className: "lm-panel" },
421
+ h(
422
+ "div",
423
+ { className: "lm-head" },
424
+ h("b", null, "PhoneLens 直连取景"),
425
+ h("span", { className: link === "connected" ? "ok" : "warn", style: { fontSize: 11 } }, link === "connected" ? "已连接" : link === "connecting" ? "连接中…" : "未连接"),
426
+ h("button", { className: "x", onClick: () => setOpen(false), title: "收起" }, "✕"),
427
+ ),
428
+ h(
429
+ "div",
430
+ { className: "lm-body" },
431
+ // only show the viewfinder canvas when a camera is actually
432
+ // streaming; a disconnected phone left a big black rectangle
433
+ camOn ? h("canvas", { ref: canvasRef, className: "lm-canvas", width: 360, height: 640 }) : null,
434
+ h("div", { className: "lm-status" }, h("span", null, fps > 0 ? `${fps} fps` : "—"), h("span", { className: camOn ? "" : "warn-big" }, camOn ? "预览中" : "手机未连接!相同局域网下扫码添加配对设备;若已配对过的手机,请在设置内点击本电脑端,激活为活动设备")),
435
+ devices && devices.length > 1
436
+ ? h(
437
+ "div",
438
+ { className: "lm-devices" },
439
+ devices.map((d) =>
440
+ h(
441
+ "button",
442
+ { key: d.id, className: "lm-device" + (d.active ? " active" : ""), onClick: () => selectDevice(d.id), onDoubleClick: () => renameDevice(d.id, d.name), title: "单击切换 · 双击重命名" },
443
+ (d.active ? "● " : "○ ") + d.name,
444
+ ),
445
+ ),
446
+ )
447
+ : null,
448
+ pending && pending.length
449
+ ? h(
450
+ "div",
451
+ { className: "lm-pending" },
452
+ h("span", { className: "lm-pending-label" }, `待发图 ×${pending.length}:`),
453
+ pending.map((p) =>
454
+ h(
455
+ "div",
456
+ { key: p.id, className: "lm-row" },
457
+ h("span", { style: { fontSize: 11, color: "#7d8da0", flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, p.name || p.id.slice(0, 8)),
458
+ h(
459
+ "button",
460
+ { className: "lm-btn", onClick: () => void stageIntoComposer(hostCtx, port, p.id).then(() => { setPending((prev) => prev.filter((x) => x.id !== p.id)); setFlashOk("已放入输入框"); }).catch((e) => setFlashErr("放入失败: " + String(e && e.message || e).slice(0, 90))) },
461
+ "放入输入框",
462
+ ),
463
+ ),
464
+ ),
465
+ )
466
+ : null,
467
+ h(
468
+ "div",
469
+ { className: "lm-row" },
470
+ h("button", { className: "lm-btn primary", disabled: !camOn, onClick: shoot }, "◉ 拍照并放入输入框"),
471
+ h("button", { className: "lm-btn", onClick: () => void refreshQr() }, "↻ 二维码"),
472
+ ),
473
+ !camOn
474
+ ? h(
475
+ "div",
476
+ { className: "lm-qr" },
477
+ qr && qr.pngDataUrl ? h("img", { src: qr.pngDataUrl, alt: "配对二维码" }) : h("div", { className: "hint" }, "二维码加载中…"),
478
+ qr ? h("div", { className: "code" }, qr.code) : null,
479
+ h(
480
+ "div",
481
+ { className: "hint" },
482
+ "手机 App 扫码配对;或在 App 内手动输入",
483
+ h("br", null),
484
+ qr && qr.urls && qr.urls[0] ? `${qr.urls[0].replace("http://", "")}:${qr.code}` : "",
485
+ h("br", null),
486
+ qr
487
+ ? (() => {
488
+ const s = Math.max(0, Math.floor((qr.expiresAt - Date.now()) / 1000));
489
+ return s > 0
490
+ ? `有效期剩余 ${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")} · 到期自动更新`
491
+ : "已到期,正在自动更新…";
492
+ })()
493
+ : "",
494
+ ),
495
+ h("a", { className: "lm-dl", href: "#", onClick: (e) => { e.preventDefault(); void openAppQr(); }, title: "弹出二维码,手机扫码下载" }, "手机还没装 App?点此扫码下载"),
496
+ )
497
+ : null,
498
+ h("span", { className: `lm-flash${flash.startsWith("已") ? " ok" : ""}` }, flash),
499
+ h(
500
+ "div",
501
+ { className: "lm-port" },
502
+ "接收端口",
503
+ h("input", { value: portDraft, onChange: (e) => setPortDraft(e.target.value), onKeyDown: (e) => e.key === "Enter" && savePort(), inputMode: "numeric" }),
504
+ h("button", { className: "lm-btn", style: { flex: "none", padding: "3px 8px" }, onClick: savePort }, "切换"),
505
+ ),
506
+ ),
507
+ )
508
+ : null,
509
+ h(
510
+ "button",
511
+ { className: "lm-fab" + (link === "connected" ? " connected" : link === "connecting" ? " connecting" : " disconnected"), style: { position: "relative" }, onClick: () => setOpen(!open), title: "PhoneLens 直连取景" },
512
+ h(CameraIcon, { state: link }),
513
+ ),
514
+ rename
515
+ ? h(
516
+ "div",
517
+ { className: "lm-rename" },
518
+ h("div", { className: "lm-rename-title" }, "重命名设备"),
519
+ h("input", { className: "lm-rename-input", value: rename.name, onChange: (e) => setRename({ ...rename, name: e.target.value }), onKeyDown: (e) => e.key === "Enter" && confirmRename(), autoFocus: true }),
520
+ h(
521
+ "div",
522
+ { className: "lm-row" },
523
+ h("button", { className: "lm-btn", onClick: () => setRename(null) }, "取消"),
524
+ h("button", { className: "lm-btn primary", onClick: confirmRename }, "确定"),
525
+ ),
526
+ )
527
+ : null,
528
+ appQr
529
+ ? h(
530
+ "div",
531
+ { className: "lm-appqr" },
532
+ h("div", { className: "lm-appqr-title" }, "扫码下载 PhoneLens App"),
533
+ appQr.loading
534
+ ? h("div", { className: "lm-appqr-hint" }, "二维码生成中…")
535
+ : appQr.pngDataUrl
536
+ ? h("img", { className: "lm-appqr-img", src: appQr.pngDataUrl, alt: "App 下载二维码" })
537
+ : h("div", { className: "lm-appqr-hint" }, "二维码暂不可用,请使用下方链接"),
538
+ h("div", { className: "lm-appqr-hint" }, appQr.fallback ? "手机端请通过以下链接下载 APK" : "手机扫码直接下载 Android APK(默认 Gitee 国内源)"),
539
+ h(
540
+ "div",
541
+ { className: "lm-appqr-links" },
542
+ h("a", { href: appQr.gitee || APP_GITEE, target: "_blank", rel: "noopener" }, "Gitee 国内源"),
543
+ h("span", null, "·"),
544
+ h("a", { href: appQr.github || APP_GITHUB, target: "_blank", rel: "noopener" }, "GitHub 国际源"),
545
+ ),
546
+ h("div", { className: "lm-row", style: { width: "100%" } }, h("button", { className: "lm-btn", onClick: () => setAppQr(null) }, "关闭")),
547
+ )
548
+ : null,
549
+ );
550
+ }
551
+
552
+ // ── plugin face ───────────────────────────────────────────────────────
553
+ const inject = ["slots"];
554
+ function apply(ctx) {
555
+ hostCtx = ctx;
556
+ ctx.slots.inject("shell.overlay", () =>
557
+ ctx.slots.register(
558
+ { name: "shell.overlay", id: "phone-lens.overlay", order: 100, label: () => "PhoneLens" },
559
+ LensOverlay,
560
+ ),
561
+ );
562
+ }
563
+
564
+ exports.apply = apply;
565
+ exports.inject = inject;
566
+ return module.exports;
567
+ },
568
+ });