phone-lens 0.3.5 → 0.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +198 -36
- package/lib/dev.js +247 -33
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +248 -34
- package/lib/index.js.map +1 -1
- package/package.json +74 -74
package/lib/client.js
CHANGED
|
@@ -19,6 +19,12 @@ window.__ModuleLoader__.load({
|
|
|
19
19
|
|
|
20
20
|
const NS = "phone-lens";
|
|
21
21
|
const PORT_KEY = "phoneLens.port";
|
|
22
|
+
// Capture modes (user-selected in the settings panel; stored host-side).
|
|
23
|
+
const SAVE_MODES = [
|
|
24
|
+
{ id: "composer", title: "自动注入对话", desc: "拍照后自动放入输入框", flash: "已切换:拍照后自动注入输入框" },
|
|
25
|
+
{ id: "both", title: "注入对话并保存", desc: "放入输入框,同时保存到指定文件夹", flash: "已切换:注入对话并保存到文件夹" },
|
|
26
|
+
{ id: "folder", title: "仅保存到文件夹", desc: "不放入输入框,只保存到指定电脑文件夹", flash: "已切换:仅保存到文件夹" },
|
|
27
|
+
];
|
|
22
28
|
// App download fallbacks (used when /app-qr.json is unavailable on an older host).
|
|
23
29
|
// Gitee release carrying the newest APK (App unchanged since v0.2.0).
|
|
24
30
|
const APP_GITEE = "https://gitee.com/qianfengbingtang/phone-lens/releases/download/v0.2.0/app-release.apk";
|
|
@@ -56,6 +62,22 @@ window.__ModuleLoader__.load({
|
|
|
56
62
|
.lm-qr a.lm-dl:hover { text-decoration: underline; }
|
|
57
63
|
.lm-port { display: flex; gap: 6px; align-items: center; font-size: 11px; color: #7d8da0; }
|
|
58
64
|
.lm-port input { width: 64px; background: #0d1117; color: #dfe7ee; border: 1px solid #2f3b48; border-radius: 6px; padding: 3px 6px; font-size: 12px; }
|
|
65
|
+
.lm-settings { display: flex; flex-direction: column; gap: 8px; border-top: 1px solid #232c36; padding-top: 9px; }
|
|
66
|
+
.lm-sethead { display: flex; align-items: center; gap: 8px; }
|
|
67
|
+
.lm-sethead b { font-size: 12px; color: #dfe7ee; font-weight: 600; }
|
|
68
|
+
.lm-setlabel { font-size: 10px; color: #7d8da0; }
|
|
69
|
+
.lm-mode { display: flex; align-items: center; gap: 8px; padding: 7px 9px; border-radius: 8px; border: 1px solid #2f3b48; background: #1f2937; cursor: pointer; text-align: left; }
|
|
70
|
+
.lm-mode:hover { background: #28323e; }
|
|
71
|
+
.lm-mode.selected { border-color: #3b7cb5; background: #1d3f61; }
|
|
72
|
+
.lm-mode .dot { width: 9px; height: 9px; border-radius: 50%; border: 2px solid #8fa1b5; flex: none; }
|
|
73
|
+
.lm-mode.selected .dot { border-color: #6cc0ff; background: #6cc0ff; }
|
|
74
|
+
.lm-mode .txt { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
|
75
|
+
.lm-mode .t { font-size: 12px; color: #dfe7ee; }
|
|
76
|
+
.lm-mode .d { font-size: 10px; color: #7d8da0; }
|
|
77
|
+
.lm-setrow { display: flex; gap: 6px; }
|
|
78
|
+
.lm-setrow input { flex: 1; min-width: 0; background: #0d1117; color: #dfe7ee; border: 1px solid #2f3b48; border-radius: 6px; padding: 5px 8px; font-size: 11px; }
|
|
79
|
+
.lm-sethint { font-size: 10px; color: #7d8da0; line-height: 1.45; word-break: break-all; }
|
|
80
|
+
.lm-preview-off { height: 220px; display: flex; align-items: center; justify-content: center; background: #10141a; border: 1px dashed #2a333e; border-radius: 8px; color: #d9a441; font-size: 12px; text-align: center; padding: 0 12px; }
|
|
59
81
|
.lm-flash { font-size: 11px; color: #8fa1b5; min-height: 14px; }
|
|
60
82
|
.lm-flash.ok { color: #58b368; }
|
|
61
83
|
.lm-flash.err { color: #e5696e; }
|
|
@@ -120,15 +142,20 @@ window.__ModuleLoader__.load({
|
|
|
120
142
|
|
|
121
143
|
// ── composable pre-send (dsh composer draft) ───────────────────────────
|
|
122
144
|
// The phone photo is staged as a composer draft attachment, NOT injected
|
|
123
|
-
// into the model: the user types text next and hits send.
|
|
124
|
-
//
|
|
125
|
-
//
|
|
145
|
+
// into the model: the user types text next and hits send. On dsh 0.1.7+
|
|
146
|
+
// the composer is a Lexical editor whose intake is package-internal, so
|
|
147
|
+
// we dispatch a synthetic paste event and the editor's own paste handler
|
|
148
|
+
// admits the file (intakeFiles → createDrafts) — the only stable seam.
|
|
126
149
|
let hostCtx = null; // set in apply() so the component can reach the runtime
|
|
127
150
|
|
|
128
151
|
function currentSessionId(ctx) {
|
|
129
152
|
try {
|
|
130
|
-
//
|
|
131
|
-
//
|
|
153
|
+
// rc.2: the current session lives on the uiSession binding source,
|
|
154
|
+
// not on sessions.list.getSnapshot().current (removed in 0.1.7).
|
|
155
|
+
const ui = ctx.get("uiSession");
|
|
156
|
+
const key = ui?.current?.value?.key;
|
|
157
|
+
if (key) return key;
|
|
158
|
+
// pre-0.1.7 fallback.
|
|
132
159
|
const sessions = ctx.get("sessions");
|
|
133
160
|
if (!sessions) throw new Error("no sessions service");
|
|
134
161
|
const list = sessions.list;
|
|
@@ -141,25 +168,34 @@ window.__ModuleLoader__.load({
|
|
|
141
168
|
}
|
|
142
169
|
}
|
|
143
170
|
|
|
171
|
+
// rc.2 composer is a Lexical editor; its root element carries the
|
|
172
|
+
// data-lexical-editor marker. Fall back to any visible contenteditable.
|
|
173
|
+
function findComposerEditor() {
|
|
174
|
+
const lexical = document.querySelector('div[data-lexical-editor="true"][contenteditable="true"]');
|
|
175
|
+
if (lexical) return lexical;
|
|
176
|
+
const candidates = document.querySelectorAll('[contenteditable="true"]');
|
|
177
|
+
for (const el of candidates) {
|
|
178
|
+
if (el.getClientRects().length > 0) return el;
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
144
183
|
async function stageIntoComposer(ctx, port, attachmentId) {
|
|
145
|
-
if (!ctx) throw new Error("no runtime ctx (phone-lens host not wired)");
|
|
146
184
|
const resp = await fetch(`http://127.0.0.1:${port}/pending/${attachmentId}`).catch((e) => { throw new Error("fetch pending: " + String(e)); });
|
|
147
185
|
if (!resp.ok) throw new Error("pending fetch HTTP " + resp.status);
|
|
148
186
|
const buf = await resp.arrayBuffer();
|
|
149
187
|
const file = new File([buf], `phone-${attachmentId.slice(0, 8)}.jpg`, { type: "image/jpeg" });
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
//
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
if (!
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const ok = input.addImages(atts.map((a) => a.id));
|
|
162
|
-
if (ok !== true) throw new Error("input.addImages rejected (busy admission?)");
|
|
188
|
+
// rc.2 path: the composer's own paste handler admits image files
|
|
189
|
+
// through the official intake (intakeFiles → createDrafts), so we
|
|
190
|
+
// dispatch a synthetic paste instead of touching package-internal
|
|
191
|
+
// draft APIs. The composer is bound to the current session already.
|
|
192
|
+
const editor = findComposerEditor();
|
|
193
|
+
if (!editor) throw new Error("no composer input (open a session first)");
|
|
194
|
+
editor.focus({ preventScroll: true });
|
|
195
|
+
const dt = new DataTransfer();
|
|
196
|
+
dt.items.add(file);
|
|
197
|
+
const ev = new ClipboardEvent("paste", { clipboardData: dt, bubbles: true, cancelable: true });
|
|
198
|
+
editor.dispatchEvent(ev);
|
|
163
199
|
return true;
|
|
164
200
|
}
|
|
165
201
|
|
|
@@ -200,8 +236,12 @@ window.__ModuleLoader__.load({
|
|
|
200
236
|
const [flash, setFlash] = useState("");
|
|
201
237
|
const [pending, setPending] = useState([]); // [{id, name}] waiting to be staged
|
|
202
238
|
const [devices, setDevices] = useState([]); // [{id,name,active}]
|
|
203
|
-
|
|
204
|
-
|
|
239
|
+
const [rename, setRename] = useState(null); // {id, name} — self-drawn rename dialog
|
|
240
|
+
const [appQr, setAppQr] = useState(null); // app-download QR dialog payload (or {loading}/{fallback:true})
|
|
241
|
+
const [previewOff, setPreviewOff] = useState(false); // host says the phone's stream stopped (preview off on the phone)
|
|
242
|
+
const [settingsOpen, setSettingsOpen] = useState(false); // collapsible settings section under the main card
|
|
243
|
+
const [appSt, setAppSt] = useState(null); // {saveMode, saveDir, effectiveSaveDir} | null (null = host too old)
|
|
244
|
+
const [dirDraft, setDirDraft] = useState("");
|
|
205
245
|
const canvasRef = useRef(null);
|
|
206
246
|
const lastQrCode = useRef(null); // last pairing code seen — detects auto rotation for the "已更新" flash
|
|
207
247
|
const wsRef = useRef(null);
|
|
@@ -251,6 +291,13 @@ window.__ModuleLoader__.load({
|
|
|
251
291
|
if (m.type === "meta" && m.camera.rotation !== undefined) rotRef.current = m.camera.rotation;
|
|
252
292
|
} else if (m.type === "frame_meta") {
|
|
253
293
|
if (m.rotation !== undefined) rotRef.current = m.rotation;
|
|
294
|
+
} else if (m.type === "preview_state") {
|
|
295
|
+
// host-derived: the ACTIVE phone's stream stopped (user turned
|
|
296
|
+
// preview off in the app) or started again
|
|
297
|
+
setPreviewOff(!m.on);
|
|
298
|
+
} else if (m.type === "upload_saved") {
|
|
299
|
+
// folder-only mode: the photo bypassed the composer; local hint
|
|
300
|
+
setFlashOk(`已保存至 ${m.dir || "指定文件夹"}`);
|
|
254
301
|
} else if (m.type === "injected") {
|
|
255
302
|
if (m.ok) setFlashOk(`已注入会话 ${(m.sessionId || "").slice(0, 8)}…`);
|
|
256
303
|
else setFlashErr(`已保存到电脑 ✓(未注入:${m.reason || "无活动会话"})`);
|
|
@@ -262,7 +309,7 @@ window.__ModuleLoader__.load({
|
|
|
262
309
|
stageIntoComposer(hostCtx, port, m.attachmentId)
|
|
263
310
|
.then(() => {
|
|
264
311
|
setPending((p) => p.filter((x) => x.id !== m.attachmentId));
|
|
265
|
-
setFlashOk("
|
|
312
|
+
setFlashOk("照片已注入输入框,输入文字后发送");
|
|
266
313
|
})
|
|
267
314
|
.catch((e) => setFlashErr("自动放入失败: " + String(e && e.message || e).slice(0, 90)));
|
|
268
315
|
} else if (m.type === "devices") {
|
|
@@ -287,10 +334,11 @@ window.__ModuleLoader__.load({
|
|
|
287
334
|
setFlashErr(`${m.code}${m.message ? ":" + m.message : ""}`);
|
|
288
335
|
}
|
|
289
336
|
};
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
337
|
+
ws.onclose = () => {
|
|
338
|
+
setCamOn(false);
|
|
339
|
+
setPreviewOff(false);
|
|
340
|
+
if (!stopped) timer = setTimeout(connect, 2000);
|
|
341
|
+
};
|
|
294
342
|
};
|
|
295
343
|
connect();
|
|
296
344
|
return () => {
|
|
@@ -345,6 +393,55 @@ window.__ModuleLoader__.load({
|
|
|
345
393
|
}
|
|
346
394
|
}
|
|
347
395
|
|
|
396
|
+
// Manual refresh must FORCE a new pairing code server-side: /qr.json
|
|
397
|
+
// alone keeps returning the still-live code (same QR, same countdown),
|
|
398
|
+
// which read as a dead button. Same recipe as /view.html: ask the host
|
|
399
|
+
// to burn the current code via WS, then fetch the fresh one.
|
|
400
|
+
function manualRefreshQr() {
|
|
401
|
+
const ws = wsRef.current;
|
|
402
|
+
if (ws && ws.readyState === 1) {
|
|
403
|
+
ws.send(JSON.stringify({ type: "refresh_pairing" }));
|
|
404
|
+
setTimeout(() => void refreshQr(true), 150);
|
|
405
|
+
} else {
|
|
406
|
+
void refreshQr(true);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ── settings (capture mode / folder / port) ───────────────────────────
|
|
411
|
+
async function loadAppSt() {
|
|
412
|
+
try {
|
|
413
|
+
const r = await fetch(`${baseUrl}/app-settings`, { cache: "no-store" });
|
|
414
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
415
|
+
const data = await r.json();
|
|
416
|
+
setAppSt(data);
|
|
417
|
+
setDirDraft(data.effectiveSaveDir || data.saveDir || "");
|
|
418
|
+
} catch {
|
|
419
|
+
setAppSt(null); // older host without /app-settings: port-only settings
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async function saveAppSt(patch) {
|
|
424
|
+
try {
|
|
425
|
+
const r = await fetch(`${baseUrl}/app-settings`, {
|
|
426
|
+
method: "POST",
|
|
427
|
+
headers: { "content-type": "application/json" },
|
|
428
|
+
body: JSON.stringify(patch),
|
|
429
|
+
});
|
|
430
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
431
|
+
const data = await r.json();
|
|
432
|
+
setAppSt(data);
|
|
433
|
+
setDirDraft(data.effectiveSaveDir || data.saveDir || "");
|
|
434
|
+
return true;
|
|
435
|
+
} catch (e) {
|
|
436
|
+
setFlashErr("设置保存失败: " + String(e && e.message || e).slice(0, 60));
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function saveDirDraft() {
|
|
442
|
+
void saveAppSt({ saveDir: dirDraft }).then((ok) => ok && setFlashOk("保存目录已更新"));
|
|
443
|
+
}
|
|
444
|
+
|
|
348
445
|
// Open the app-download dialog: QR (from the host) + source links.
|
|
349
446
|
// Falls back to the built-in Gitee/GitHub links when the endpoint is
|
|
350
447
|
// missing (older host half running an older release).
|
|
@@ -428,10 +525,28 @@ window.__ModuleLoader__.load({
|
|
|
428
525
|
h(
|
|
429
526
|
"div",
|
|
430
527
|
{ className: "lm-body" },
|
|
431
|
-
// only show the viewfinder canvas
|
|
432
|
-
// streaming;
|
|
433
|
-
|
|
434
|
-
|
|
528
|
+
// only show the viewfinder canvas while a camera is actually
|
|
529
|
+
// streaming; when the phone turned preview off, clear the last
|
|
530
|
+
// frame and say so instead of freezing on a stale picture
|
|
531
|
+
camOn && !previewOff
|
|
532
|
+
? h("canvas", { ref: canvasRef, className: "lm-canvas", width: 360, height: 640 })
|
|
533
|
+
: camOn && previewOff
|
|
534
|
+
? h("div", { className: "lm-preview-off" }, "您在手机端已关闭预览功能")
|
|
535
|
+
: null,
|
|
536
|
+
h(
|
|
537
|
+
"div",
|
|
538
|
+
{ className: "lm-status" },
|
|
539
|
+
h("span", null, camOn && !previewOff && fps > 0 ? `${fps} fps` : "—"),
|
|
540
|
+
h(
|
|
541
|
+
"span",
|
|
542
|
+
{ className: !camOn ? "warn-big" : previewOff ? "warn" : "" },
|
|
543
|
+
!camOn
|
|
544
|
+
? "手机未连接!相同局域网下扫码添加配对设备;若已配对过的手机,请在设置内点击本电脑端,激活为活动设备"
|
|
545
|
+
: previewOff
|
|
546
|
+
? "预览已关闭,在手机端重新开启后恢复"
|
|
547
|
+
: "预览中",
|
|
548
|
+
),
|
|
549
|
+
),
|
|
435
550
|
devices && devices.length > 1
|
|
436
551
|
? h(
|
|
437
552
|
"div",
|
|
@@ -467,8 +582,8 @@ window.__ModuleLoader__.load({
|
|
|
467
582
|
h(
|
|
468
583
|
"div",
|
|
469
584
|
{ className: "lm-row" },
|
|
470
|
-
h("button", { className: "lm-btn primary", disabled: !camOn, onClick: shoot }, "◉
|
|
471
|
-
h("button", { className: "lm-btn", onClick:
|
|
585
|
+
h("button", { className: "lm-btn primary", disabled: !camOn || previewOff, onClick: shoot }, "◉ 拍照并注入"),
|
|
586
|
+
h("button", { className: "lm-btn", onClick: manualRefreshQr, title: "作废当前配对码并生成新码" }, "↻ 二维码"),
|
|
472
587
|
),
|
|
473
588
|
!camOn
|
|
474
589
|
? h(
|
|
@@ -498,11 +613,58 @@ window.__ModuleLoader__.load({
|
|
|
498
613
|
h("span", { className: `lm-flash${flash.startsWith("已") ? " ok" : ""}` }, flash),
|
|
499
614
|
h(
|
|
500
615
|
"div",
|
|
501
|
-
{ className: "lm-
|
|
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 }, "切换"),
|
|
616
|
+
{ className: "lm-row" },
|
|
617
|
+
h("button", { className: "lm-btn", onClick: () => { const next = !settingsOpen; setSettingsOpen(next); if (next) void loadAppSt(); } }, settingsOpen ? "▲ 收起设置" : "⚙ 设置"),
|
|
505
618
|
),
|
|
619
|
+
settingsOpen
|
|
620
|
+
? h(
|
|
621
|
+
"div",
|
|
622
|
+
{ className: "lm-settings" },
|
|
623
|
+
appSt
|
|
624
|
+
? [
|
|
625
|
+
h("div", { className: "lm-setlabel" }, "拍照模式"),
|
|
626
|
+
...SAVE_MODES.map((m) =>
|
|
627
|
+
h(
|
|
628
|
+
"button",
|
|
629
|
+
{
|
|
630
|
+
key: m.id,
|
|
631
|
+
className: "lm-mode" + (appSt.saveMode === m.id ? " selected" : ""),
|
|
632
|
+
onClick: () => {
|
|
633
|
+
if (appSt.saveMode === m.id) return;
|
|
634
|
+
void saveAppSt({ saveMode: m.id }).then((ok) => ok && setFlashOk(m.flash));
|
|
635
|
+
},
|
|
636
|
+
},
|
|
637
|
+
h("span", { className: "dot" }),
|
|
638
|
+
h(
|
|
639
|
+
"span",
|
|
640
|
+
{ className: "txt" },
|
|
641
|
+
h("span", { className: "t" }, m.title),
|
|
642
|
+
h("span", { className: "d" }, m.desc),
|
|
643
|
+
),
|
|
644
|
+
),
|
|
645
|
+
),
|
|
646
|
+
appSt.saveMode !== "composer"
|
|
647
|
+
? h(
|
|
648
|
+
"div",
|
|
649
|
+
{ className: "lm-setrow" },
|
|
650
|
+
h("input", { value: dirDraft, onChange: (e) => setDirDraft(e.target.value), onKeyDown: (e) => e.key === "Enter" && saveDirDraft(), placeholder: "保存文件夹的绝对路径(手输或粘贴)" }),
|
|
651
|
+
h("button", { className: "lm-btn", style: { flex: "none" }, onClick: saveDirDraft }, "保存"),
|
|
652
|
+
)
|
|
653
|
+
: null,
|
|
654
|
+
appSt.saveMode !== "composer"
|
|
655
|
+
? h("div", { className: "lm-sethint" }, `留空路径即恢复默认目录: ${appSt.effectiveSaveDir}`)
|
|
656
|
+
: null,
|
|
657
|
+
]
|
|
658
|
+
: h("div", { className: "lm-sethint" }, "设置服务不可用(电脑端版本较旧),仅可调整端口。"),
|
|
659
|
+
h(
|
|
660
|
+
"div",
|
|
661
|
+
{ className: "lm-port" },
|
|
662
|
+
"接收端口",
|
|
663
|
+
h("input", { value: portDraft, onChange: (e) => setPortDraft(e.target.value), onKeyDown: (e) => e.key === "Enter" && savePort(), inputMode: "numeric" }),
|
|
664
|
+
h("button", { className: "lm-btn", style: { flex: "none", padding: "3px 8px" }, onClick: savePort }, "切换"),
|
|
665
|
+
),
|
|
666
|
+
)
|
|
667
|
+
: null,
|
|
506
668
|
),
|
|
507
669
|
)
|
|
508
670
|
: null,
|