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 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. Uses the dsh
124
- // channel confirmed from source: sessions.scope(sessionId) → conversation
125
- // service → createDraftImages(files) → input.for(actx).addImages(ids).
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
- // cordis: accessing a service as a ctx property requires it in
131
- // `inject`; use ctx.get() instead (never throws for absent).
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
- 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?)");
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
- 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})
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
- ws.onclose = () => {
291
- setCamOn(false);
292
- if (!stopped) timer = setTimeout(connect, 2000);
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 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 ? "预览中" : "手机未连接!相同局域网下扫码添加配对设备;若已配对过的手机,请在设置内点击本电脑端,激活为活动设备")),
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: () => void refreshQr() }, "↻ 二维码"),
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-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 }, "切换"),
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,