phone-lens 1.1.4 → 1.1.6

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/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  版本号三处联动:host `package.json` ↔ App `pubspec.yaml`(三段 = tag,build number 只增不减)↓ Release tag。App 版本号必须与 Gitee release tag 同步 bump,否则手机端会反复弹"发现新版本"。
4
4
 
5
+ ## 1.1.5(2026-11-07)
6
+
7
+ - **新增:裁剪页精细编辑(App 端)**。画笔/马赛克模式下双指捏合缩放 + 拖动平移整幅画面(1x~6x,以双指中心为锚点),放大后可做精细标注;右上角出现「回原比例」小按钮一键复位。缩放只是视图变换,笔画/裁剪框数据全部保持在图像空间,导出结果与屏幕所见严格一致,与裁剪、画笔、马赛克、旋转互不干扰(旋转/翻页自动复位缩放)。
8
+ - **修复:双指缩放残留误触笔画点**。双指落下时自动清扫 600ms 内未延伸的单点笔画(缩放前每根手指的落点都会留下一个小点),有意画的单指点不受影响。
9
+ - **改进:次级工具栏可收起**。画笔/马赛克激活后再次点击主工具栏按钮收起参数面板(工具保持激活),不再遮挡画面编辑区域;切换到其他工具时自动重新展开。
10
+ - **改进:笔画大小改滑条连续调节 + 六色画笔**。三档圆点按钮换成滑条(画笔宽度约图像宽度 0.4%~8%、马赛克块 1%~12% 拖动即见),新增红/蓝/黄/绿/黑/白色板,颜色贯通预览与最终导出(原固定红色)。
11
+ - **改进:上传画质四档 → 五档**:原图(原始尺寸)/ 高(2048px 超精细细节)/ 中(1280px,**默认改为中档**)/ 低(960px,约 130KB)/ 极低(320px 细节不可辨认);已保存的旧档位选择自动按新参数生效,无需重新设置。
12
+ - **修复:有标注内容时旋转直接清空无提示**——现在先弹「清除标注并旋转?」确认框,空画面旋转不弹窗。
13
+ - **修复:Gitee `releases/latest` 端点"最近编辑"陷阱**(双端三处:App `update_check`、host `apk-link`、host `update-check` 改用 releases 列表端点 + 客户端版本比较取最高 tag)。此前在 Gitee 网页上编辑/改名任何老发行版都会让 App 误判"已是最新版本"、二维码指向旧 APK,三处全部中招;现摆脱对该端点语义的依赖。
14
+
5
15
  ## 1.1.4(2026-11-03)
6
16
 
7
17
  - **新增:推流画质四档可调(App 端)**。设置 → 拍摄与上传 → 推流画质:极低(320P 8fps)/ 低(480P 10fps)/ 中(默认,640P 10fps,与升级前取景画质一致,跟随电脑端下发参数)/ 高(720P 10fps)。仅影响电脑端实时取景,不影响拍摄照片画质(照片仍由"上传画质"决定);切换下一帧即生效,无需重启预览或重连;帧协议不变(裸 JPEG 二进制),host 与 Web UI 零改动,新老版本双向兼容,无需版本门槛。推流画质存独立档位键,不被电脑端下发的预览参数在重连时覆盖("中"档则严格采用下发值)。
package/cordis.patch.yml CHANGED
@@ -15,7 +15,6 @@
15
15
  allowedTypes:
16
16
  - 'image/jpeg'
17
17
  - 'image/png'
18
- uploadsPerMinute: 10
19
18
  previewFrameMaxBytes: 524288
20
19
  pairing:
21
20
  codeTtlMs: 900000
@@ -24,9 +23,6 @@
24
23
  maxHeight: 480
25
24
  fps: 10
26
25
  jpegQuality: 70
27
- inject:
28
- mode: 'followup'
29
- notePrefix: '[手机拍照]'
30
26
  target:
31
27
  mode: 'latest'
32
28
  pinnedSessionId: null
package/lib/client.js CHANGED
@@ -26,9 +26,62 @@ window.__ModuleLoader__.load({
26
26
  // FAB_EV CustomEvent so both stay live without a host round-trip.
27
27
  const FAB_KEY = "phoneLens.fab";
28
28
  const FAB_EV = "phone-lens:fab-changed";
29
- // port edits from the settings page push the overlay to re-point its
30
- // URLs without a page reload
29
+ // port updates push the overlay to re-point its URLs without a page
30
+ // reload (fired by discovery when the cached port turns out stale)
31
31
  const PORT_EV = "phone-lens:port-changed";
32
+ // The listen port is HOST-owned (plugin config server.port, default
33
+ // 8791) — the localStorage entry is only a discovery cache, never a
34
+ // source of truth. Discovery probes candidates against GET /info and
35
+ // locks onto whichever port actually answers as this plugin.
36
+ const DEFAULT_PORT = "8791";
37
+ function cachedLensPort() {
38
+ try {
39
+ return window.localStorage.getItem(PORT_KEY) || DEFAULT_PORT;
40
+ } catch {
41
+ return DEFAULT_PORT;
42
+ }
43
+ }
44
+ async function probeLensPort(p) {
45
+ try {
46
+ const ctl = new AbortController();
47
+ const timer = setTimeout(() => ctl.abort(), 2500);
48
+ const r = await fetch(`http://127.0.0.1:${p}/info`, { cache: "no-store", signal: ctl.signal });
49
+ clearTimeout(timer);
50
+ if (!r.ok) return null;
51
+ const d = await r.json().catch(() => null);
52
+ // strict identity: only the real phone-lens /info carries this exact
53
+ // name literal (same string as the host's /info route). Scanning a
54
+ // 10-port range makes loose "any JSON with a name" matching too
55
+ // easy to false-lock onto an unrelated loopback service.
56
+ if (!d || typeof d !== "object" || d.name !== "PhoneLens 直连取景") return null;
57
+ return { port: String(d.serverPort || p), version: d.version || "" };
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+ // Returns the first candidate that answers as phone-lens (cached port
63
+ // first, then the whole 8791~8800 range low→high — the host falls back
64
+ // upward over the same range when 8791 is taken), syncs the cache, and
65
+ // broadcasts PORT_EV on change. If nothing answers, returns the cached
66
+ // value so the UI keeps showing diagnostics against the last-known port
67
+ // instead of guessing.
68
+ async function discoverLensPort() {
69
+ const cached = cachedLensPort();
70
+ const ports = ["8791", "8792", "8793", "8794", "8795", "8796", "8797", "8798", "8799", "8800"];
71
+ const candidates = cached === DEFAULT_PORT ? ports : [cached, ...ports.filter((p) => p !== cached)];
72
+ for (const p of candidates) {
73
+ const hit = await probeLensPort(p);
74
+ if (!hit) continue;
75
+ if (hit.port !== cached) {
76
+ try {
77
+ window.localStorage.setItem(PORT_KEY, hit.port);
78
+ window.dispatchEvent(new CustomEvent(PORT_EV));
79
+ } catch {}
80
+ }
81
+ return hit.port;
82
+ }
83
+ return cached;
84
+ }
32
85
  const FAB_SIZES = [
33
86
  { id: "s", label: "小", px: 38 },
34
87
  { id: "m", label: "标准", px: 46 },
@@ -135,6 +188,8 @@ window.__ModuleLoader__.load({
135
188
  .lm-device { flex: none; padding: 4px 8px; border-radius: 14px; border: 1px solid #2f3b48; background: #1f2937; color: #8fa1b5; font-size: 11px; cursor: pointer; }
136
189
  .lm-device.active { border-color: #46a758; color: #d2f5d8; background: #1f4a2c; }
137
190
  .lm-device:hover { background: #28323e; }
191
+ .lm-unbind { flex: none; width: 15px; height: 15px; padding: 0; border: none; border-radius: 50%; background: none; color: #8fa1b5; font-size: 9px; line-height: 1; cursor: pointer; }
192
+ .lm-unbind:hover { color: #e5696e; background: #2a1f22; }
138
193
  .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; }.lm-rename-title { font-size: 13px; color: #dfe7ee; font-weight: 600; }
139
194
  .lm-rename-input { background: #0d1117; color: #dfe7ee; border: 1px solid #2f3b48; border-radius: 8px; padding: 8px; font-size: 13px; }
140
195
  .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; }
@@ -280,7 +335,14 @@ window.__ModuleLoader__.load({
280
335
  const resp = await fetch(`http://127.0.0.1:${port}/pending/${attachmentId}`).catch((e) => { throw new Error("fetch pending: " + String(e)); });
281
336
  if (!resp.ok) throw new Error("pending fetch HTTP " + resp.status);
282
337
  const buf = await resp.arrayBuffer();
283
- const file = new File([buf], `phone-${attachmentId.slice(0, 8)}.jpg`, { type: "image/jpeg" });
338
+ // PNG/WebP uploads stage with their original bytes (high-quality
339
+ // passes originals through) — sniff the type, don't trust the
340
+ // flattened .jpg staging name
341
+ const u8 = new Uint8Array(buf);
342
+ const isPng = u8.length >= 4 && u8[0] === 0x89 && u8[1] === 0x50 && u8[2] === 0x4e && u8[3] === 0x47;
343
+ const isWebp = !isPng && u8.length >= 12 && String.fromCharCode(u8[0], u8[1], u8[2], u8[3]) === "RIFF" && String.fromCharCode(u8[8], u8[9], u8[10], u8[11]) === "WEBP";
344
+ const mime = isPng ? "image/png" : isWebp ? "image/webp" : "image/jpeg";
345
+ const file = new File([buf], `phone-${attachmentId.slice(0, 8)}.${isPng ? "png" : isWebp ? "webp" : "jpg"}`, { type: mime });
284
346
  // rc.2 path: the composer's own paste handler admits image files
285
347
  // through the official intake (intakeFiles → createDrafts), so we
286
348
  // dispatch a synthetic paste instead of touching package-internal
@@ -329,6 +391,10 @@ window.__ModuleLoader__.load({
329
391
  const [qr, setQr] = useState(null);
330
392
  const [, setTick] = useState(0); // 1s heartbeat: re-renders the QR countdown while pairing is visible
331
393
  const [flash, setFlash] = useState("");
394
+ // last flash's kind: the recovery paths (stream back on / phone back
395
+ // online) clear only ERROR flashes — success messages own their
396
+ // lifetime and get replaced by the next event naturally
397
+ const flashIsErr = useRef(false);
332
398
  const [pending, setPending] = useState([]); // [{id, name}] waiting to be staged
333
399
  const [devices, setDevices] = useState([]); // [{id,name,active,appVersion?}]
334
400
  // Host plugin-update check (update-check.ts): null = unchecked/failed —
@@ -365,6 +431,13 @@ window.__ModuleLoader__.load({
365
431
  const baseUrl = `http://127.0.0.1:${port}`;
366
432
  const wsUrl = `ws://127.0.0.1:${port}/ws/view`;
367
433
 
434
+ // one-shot port discovery on mount: the port state starts from the
435
+ // localStorage cache; discovery re-points it (via PORT_EV) if the
436
+ // cache is stale — the host's actual listen port is the only truth
437
+ useEffect(() => {
438
+ void discoverLensPort();
439
+ }, []);
440
+
368
441
  // plugin-update check, once per panel-open (host caches the Gitee
369
442
  // lookup 12h / failures 30min — this fetch never hits the API itself)
370
443
  useEffect(() => {
@@ -415,32 +488,40 @@ window.__ModuleLoader__.load({
415
488
  } catch {
416
489
  return;
417
490
  }
418
- if (m.type === "meta" || m.type === "device") {
419
- const on = m.type === "meta" ? m.camera.connected : m.online;
420
- setCamOn(Boolean(on));
421
- setLink((prev) => {
422
- if (!on) return "disconnected";
423
- return prev === "disconnected" ? "connecting" : prev;
424
- });
425
- if (m.type === "meta" && m.camera.rotation !== undefined) rotRef.current = m.camera.rotation;
426
- } else if (m.type === "frame_meta") {
491
+ if (m.type === "meta") {
492
+ const on = m.camera.connected;
493
+ setCamOn(Boolean(on));
494
+ setLink((prev) => {
495
+ if (!on) return "disconnected";
496
+ return prev === "disconnected" ? "connecting" : prev;
497
+ });
498
+ // phone (re)connected: same transient-error clearing as
499
+ // preview_state — the complaint is stale the moment it's back
500
+ if (on && flashIsErr.current) {
501
+ flashIsErr.current = false;
502
+ setFlash("");
503
+ }
504
+ if (m.camera.rotation !== undefined) rotRef.current = m.camera.rotation;
505
+ } else if (m.type === "frame_meta") {
427
506
  if (m.rotation !== undefined) rotRef.current = m.rotation;
428
- } else if (m.type === "preview_state") {
429
- // host-derived: the ACTIVE phone's stream stopped — either the
430
- // user turned preview off in the app (keepalive still alive) or
431
- // the link itself died (no frames AND no keepalive for 25s →
432
- // reason:"offline"). Old hosts never send `reason` → legacy
433
- // behavior is kept bit-for-bit.
434
- setPreviewOff(!m.on);
435
- setPreviewOffline(!m.on && m.reason === "offline");
507
+ } else if (m.type === "preview_state") {
508
+ // host-derived: the ACTIVE phone's stream stopped — either the
509
+ // user turned preview off in the app (keepalive still alive) or
510
+ // the link itself died (no frames AND no keepalive for 25s →
511
+ // reason:"offline"). Old hosts never send `reason` → legacy
512
+ // behavior is kept bit-for-bit.
513
+ setPreviewOff(!m.on);
514
+ setPreviewOffline(!m.on && m.reason === "offline");
515
+ // the stream is live again: an error flash (CAPTURE_DECLINED /
516
+ // NO_CAMERA …) described a TRANSIENT condition — clear it now
517
+ // instead of letting the stale complaint sit there forever
518
+ if (m.on && flashIsErr.current) {
519
+ flashIsErr.current = false;
520
+ setFlash("");
521
+ }
436
522
  } else if (m.type === "upload_saved") {
437
523
  // folder-only mode: the photo bypassed the composer; local hint
438
524
  setFlashOk(`已保存至 ${m.dir || "指定文件夹"}`);
439
- } else if (m.type === "injected") {
440
- if (m.ok) setFlashOk(`已注入会话 ${(m.sessionId || "").slice(0, 8)}…`);
441
- else setFlashErr(`已保存到电脑 ✓(未注入:${m.reason || "无活动会话"})`);
442
- } else if (m.type === "upload") {
443
- setFlashOk("图片已入库…");
444
525
  } else if (m.type === "pending_image") {
445
526
  // pre-send: stage this photo into the composer draft
446
527
  setPending((p) => [...p, { id: m.attachmentId, name: m.name || "" }]);
@@ -478,7 +559,9 @@ window.__ModuleLoader__.load({
478
559
  setFlashOk(`设备已配对${m.name ? ": " + m.name : ""},二维码已更换`);
479
560
  void refreshQr(true);
480
561
  } else if (m.type === "error") {
481
- setFlashErr(`${m.code}${m.message ? ":" + m.message : ""}`);
562
+ // human text first (all hub broadcasts carry Chinese messages
563
+ // now); the wire code is only a fallback for unknown/legacy types
564
+ setFlashErr(m.message || m.code || "未知错误");
482
565
  }
483
566
  };
484
567
  ws.onclose = () => {
@@ -566,12 +649,14 @@ window.__ModuleLoader__.load({
566
649
  setAnchor(`${vertical}-${horizontal}`);
567
650
  }, [open, rename, appQr, dirPick, settingsOpen, camOn, previewOff, Boolean(qr), pending.length, fabSt]);
568
651
 
569
- function setFlashOk(t) {
570
- setFlash(t);
571
- }
572
- function setFlashErr(t) {
573
- setFlash(t);
574
- }
652
+ function setFlashOk(t) {
653
+ flashIsErr.current = false;
654
+ setFlash(t);
655
+ }
656
+ function setFlashErr(t) {
657
+ flashIsErr.current = true;
658
+ setFlash(t);
659
+ }
575
660
 
576
661
  async function refreshQr(auto) {
577
662
  try {
@@ -582,9 +667,31 @@ window.__ModuleLoader__.load({
582
667
  if (auto && lastQrCode.current && lastQrCode.current !== data.code) setFlashOk("二维码已更新,请扫描最新码");
583
668
  lastQrCode.current = data.code;
584
669
  setQr(data);
585
- } catch (e) {
586
- setFlashErr(`接收服务不可达(${String(e).slice(0, 60)})`);
670
+ } catch (e) {
671
+ // the cached port may be stale (host re-bound / another
672
+ // instance) — one discovery pass may re-point the URLs via
673
+ // PORT_EV. When the port moved, re-fetch the QR against the
674
+ // new port right here: this closure still holds the old
675
+ // baseUrl, and the qr effect would not retry until the next
676
+ // expiry/drift tick, leaving the panel stuck on "加载中…".
677
+ const found = await discoverLensPort();
678
+ if (found !== port) {
679
+ try {
680
+ const r = await fetch(`http://127.0.0.1:${found}/qr.json`, { cache: "no-store" });
681
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
682
+ const data = await r.json();
683
+ lastQrCode.current = data.code;
684
+ setQr(data);
685
+ setFlashOk(`端口已自动切换为 ${found},二维码已恢复`);
686
+ return;
687
+ } catch {}
587
688
  }
689
+ setFlashErr(
690
+ found !== port
691
+ ? `端口已自动切换为 ${found},二维码恢复失败,请点「↻ 二维码」重试`
692
+ : `接收服务不可达(127.0.0.1:${port})——请确认电脑端 phone-lens 已启动且端口未被占用`,
693
+ );
694
+ }
588
695
  }
589
696
 
590
697
  // Manual refresh must FORCE a new pairing code server-side: /qr.json
@@ -674,7 +781,7 @@ window.__ModuleLoader__.load({
674
781
  function shoot() {
675
782
  const ws = wsRef.current; if (ws && ws.readyState === 1) {
676
783
  ws.send(JSON.stringify({ type: "capture" }));
677
- setFlash("快门已触发,等待手机上传…");
784
+ setFlashOk("快门已触发,等待手机上传…");
678
785
  }
679
786
  }
680
787
 
@@ -692,12 +799,35 @@ window.__ModuleLoader__.load({
692
799
  if (trimmed && ws && ws.readyState === 1) ws.send(JSON.stringify({ type: "rename_device", deviceId: rename.id, name: trimmed }));
693
800
  setRename(null);
694
801
  }
695
- // port now lives in the settings page; edits arrive via PORT_EV so
696
- // the overlay re-points baseUrl/wsUrl without a reload
802
+ // Unbind a paired device (host POST /devices/remove, loopback-only).
803
+ // Confirmed — this erases the pairing entry, the phone must re-scan
804
+ // to come back. Success removes the chip locally; the host also
805
+ // refreshes the views, so the next devices broadcast agrees.
806
+ async function unbindDevice(d) {
807
+ const label = d.name || d.id.slice(0, 8);
808
+ if (!window.confirm(`解绑设备「${label}」?解绑后该手机需重新扫码配对。`)) return;
809
+ try {
810
+ const r = await fetch(`${baseUrl}/devices/remove`, {
811
+ method: "POST",
812
+ headers: { "content-type": "application/json" },
813
+ body: JSON.stringify({ deviceId: d.id }),
814
+ });
815
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
816
+ const data = await r.json().catch(() => null);
817
+ if (!data || data.ok !== true) throw new Error("设备不存在或已被解绑");
818
+ setDevices((prev) => prev.filter((x) => x.id !== d.id));
819
+ if (d.active) setCamOn(false);
820
+ setFlashOk(`设备已解绑: ${label}`);
821
+ } catch (e) {
822
+ setFlashErr("解绑失败: " + String((e && e.message) || e).slice(0, 80));
823
+ }
824
+ }
825
+ // port cache updates (discovery) arrive via PORT_EV so the overlay
826
+ // re-points baseUrl/wsUrl without a reload
697
827
  useEffect(() => {
698
828
  const onPort = () => {
699
829
  try {
700
- setPort(window.localStorage.getItem(PORT_KEY) || "8791");
830
+ setPort(window.localStorage.getItem(PORT_KEY) || DEFAULT_PORT);
701
831
  } catch {}
702
832
  };
703
833
  window.addEventListener(PORT_EV, onPort);
@@ -847,15 +977,24 @@ window.__ModuleLoader__.load({
847
977
  }
848
978
  return h("div", { className: "lm-appver", key: "appver" }, `手机 App v${app}`);
849
979
  })(),
850
- devices && devices.length > 1
980
+ devices && devices.length > 0
851
981
  ? h(
852
982
  "div",
853
983
  { className: "lm-devices" },
854
984
  devices.map((d) =>
855
985
  h(
856
- "button",
857
- { key: d.id, className: "lm-device" + (d.active ? " active" : ""), onClick: () => selectDevice(d.id), onDoubleClick: () => renameDevice(d.id, d.name), title: "单击切换 · 双击重命名" },
858
- (d.active ? "● " : "○ ") + d.name,
986
+ "span",
987
+ { key: d.id, style: { display: "inline-flex", alignItems: "center", gap: "2px" } },
988
+ h(
989
+ "button",
990
+ { className: "lm-device" + (d.active ? " active" : ""), onClick: () => selectDevice(d.id), onDoubleClick: () => renameDevice(d.id, d.name), title: "单击切换 · 双击重命名" },
991
+ (d.active ? "● " : "○ ") + d.name,
992
+ ),
993
+ h(
994
+ "button",
995
+ { className: "lm-unbind", onClick: () => void unbindDevice(d), title: "解绑此设备(解绑后需重新扫码配对)" },
996
+ "✕",
997
+ ),
859
998
  ),
860
999
  ),
861
1000
  )
@@ -910,7 +1049,12 @@ window.__ModuleLoader__.load({
910
1049
  h("a", { className: "lm-dl", href: "#", onClick: (e) => { e.preventDefault(); void openAppQr(); }, title: "弹出二维码,手机扫码下载" }, "手机还没装 App?点此扫码下载"),
911
1050
  )
912
1051
  : null,
913
- h("span", { className: `lm-flash${flash.startsWith("已") ? " ok" : ""}` }, flash),
1052
+ // kind-flag styling, not string sniffing: "照片已注入输入框…"
1053
+ // and friends don't start with "已" — judge by the
1054
+ // flashIsErr ref that setFlashOk/setFlashErr maintain
1055
+ // (kept in lockstep with the flash state, so it's current
1056
+ // at render time)
1057
+ h("span", { className: "lm-flash" + (flash ? (flashIsErr.current ? " err" : " ok") : "") }, flash),
914
1058
  h(
915
1059
  "div",
916
1060
  { className: "lm-row" },
@@ -956,7 +1100,7 @@ window.__ModuleLoader__.load({
956
1100
  ? h("div", { className: "lm-sethint" }, `留空路径即恢复默认目录: ${appSt.effectiveSaveDir}`)
957
1101
  : null,
958
1102
  ]
959
- : h("div", { className: "lm-sethint" }, "设置服务不可用(电脑端版本较旧),请更新后再调整拍照模式与保存目录;端口设置在 设置 → PhoneLens 内。"),
1103
+ : h("div", { className: "lm-sethint" }, "设置服务不可用(电脑端版本较旧),请更新后再调整拍照模式与保存目录;端口查看在 设置 → PhoneLens 内。"),
960
1104
  )
961
1105
  : null,
962
1106
  ),
@@ -1065,9 +1209,9 @@ window.__ModuleLoader__.load({
1065
1209
  // settings stay in the overlay ⚙ panel on purpose (switched constantly).
1066
1210
  function currentLensPort() {
1067
1211
  try {
1068
- return window.localStorage.getItem(PORT_KEY) || "8791";
1212
+ return window.localStorage.getItem(PORT_KEY) || DEFAULT_PORT;
1069
1213
  } catch {
1070
- return "8791";
1214
+ return DEFAULT_PORT;
1071
1215
  }
1072
1216
  }
1073
1217
 
@@ -1103,27 +1247,50 @@ window.__ModuleLoader__.load({
1103
1247
  }
1104
1248
 
1105
1249
  function LensSettingsSection() {
1106
- const [st, setSt] = useState(undefined); // host app-settings: undefined=loading, false=unavailable, object=live
1250
+ // host app-settings: undefined=loading, false=unreachable (nothing
1251
+ // listening on the port), "old"=404 (host < 1.0.4 lacks the
1252
+ // endpoint), object=live
1253
+ const [st, setSt] = useState(undefined);
1107
1254
  const [flash, setFlash] = useState("");
1108
1255
  const [fab, setFab] = useState(loadFabSt); // ball geometry (local, synced with the overlay via FAB_EV)
1109
- const [portDraft, setPortDraft] = useState(currentLensPort);
1256
+ // resolved port: starts from the discovery cache; discovery owns
1257
+ // the truth and re-points this via PORT_EV, then the st fetch
1258
+ // below re-runs for the new port
1259
+ const [port, setPort] = useState(currentLensPort);
1260
+
1261
+ useEffect(() => {
1262
+ const onPort = () => setPort(currentLensPort());
1263
+ window.addEventListener(PORT_EV, onPort);
1264
+ return () => window.removeEventListener(PORT_EV, onPort);
1265
+ }, []);
1266
+ // kick discovery on mount (idempotent with the overlay's own run;
1267
+ // both converge on the port that actually answers /info)
1268
+ useEffect(() => {
1269
+ void discoverLensPort();
1270
+ }, []);
1110
1271
 
1111
1272
  useEffect(() => {
1112
1273
  let alive = true;
1113
1274
  void (async () => {
1114
1275
  try {
1115
- const r = await fetch(`http://127.0.0.1:${currentLensPort()}/app-settings`, { cache: "no-store" });
1116
- if (!r.ok) throw new Error(String(r.status));
1276
+ const r = await fetch(`http://127.0.0.1:${port}/app-settings`, { cache: "no-store" });
1277
+ if (!r.ok) {
1278
+ const err = new Error(String(r.status));
1279
+ err.httpStatus = r.status;
1280
+ throw err;
1281
+ }
1117
1282
  const data = await r.json();
1118
1283
  if (alive) setSt(data);
1119
- } catch {
1120
- if (alive) setSt(false);
1284
+ } catch (e) {
1285
+ // network error = nothing listening on this port;
1286
+ // 404 = endpoint missing → host too old
1287
+ if (alive) setSt(e && e.httpStatus === 404 ? "old" : false);
1121
1288
  }
1122
1289
  })();
1123
1290
  return () => {
1124
1291
  alive = false;
1125
1292
  };
1126
- }, []);
1293
+ }, [port]);
1127
1294
 
1128
1295
  // the overlay drags the ball, this page edits it — stay in sync both ways
1129
1296
  useEffect(() => {
@@ -1134,7 +1301,7 @@ window.__ModuleLoader__.load({
1134
1301
 
1135
1302
  async function patch(p) {
1136
1303
  try {
1137
- const r = await fetch(`http://127.0.0.1:${currentLensPort()}/app-settings`, {
1304
+ const r = await fetch(`http://127.0.0.1:${port}/app-settings`, {
1138
1305
  method: "POST",
1139
1306
  headers: { "content-type": "application/json" },
1140
1307
  body: JSON.stringify(p),
@@ -1154,16 +1321,6 @@ window.__ModuleLoader__.load({
1154
1321
  saveFabSt(next);
1155
1322
  setFab(next || null);
1156
1323
  }
1157
- function savePortDraft() {
1158
- const p = String(portDraft).trim() || "8791";
1159
- setPortDraft(p);
1160
- try {
1161
- window.localStorage.setItem(PORT_KEY, p);
1162
- window.dispatchEvent(new CustomEvent(PORT_EV));
1163
- setFlash("端口已保存");
1164
- setTimeout(() => setFlash(""), 1800);
1165
- } catch {}
1166
- }
1167
1324
 
1168
1325
  const infoRow = (title, desc, key) =>
1169
1326
  h(
@@ -1218,8 +1375,10 @@ window.__ModuleLoader__.load({
1218
1375
  st === undefined
1219
1376
  ? h("div", { className: "lm-setsec-note", style: { padding: "12px 16px" }, key: "loading" }, "加载中…")
1220
1377
  : st === false
1221
- ? h("div", { className: "lm-setsec-note", style: { padding: "12px 16px" }, key: "unavail" }, "设置服务不可用(需电脑端 phone-lens ≥ 1.0.4)")
1222
- : [
1378
+ ? h("div", { className: "lm-setsec-note", style: { padding: "12px 16px" }, key: "unreach" }, `无法连接接收服务(127.0.0.1:${port})——可能服务未启动、端口被占用或插件配置了非默认端口。请确认电脑端 phone-lens 已随宿主启动。`)
1379
+ : st === "old"
1380
+ ? h("div", { className: "lm-setsec-note", style: { padding: "12px 16px" }, key: "unavail" }, "设置服务不可用(需电脑端 phone-lens ≥ 1.0.4)")
1381
+ : [
1223
1382
  h(LensSetRow, {
1224
1383
  key: "t1",
1225
1384
  title: "启用模型相机工具",
@@ -1230,18 +1389,11 @@ window.__ModuleLoader__.load({
1230
1389
  title: "模型免确认调用",
1231
1390
  desc: "开启时模型调用不弹确认框;关闭后每次调用需你在弹窗中确认(若宿主不支持审批弹窗,则视为拒绝)",
1232
1391
  }, h(LensSwitch, { checked: st.modelToolsConfirmFree, onChange: (v) => void patch({ modelToolsConfirmFree: v }) })),
1233
- // tool names in the open (user-requested): what the
1234
- // model will actually see in its tool list
1235
- infoRow("拍摄照片(phone_take_photo)", "手机立即拍照,照片直接进入模型视野——不放入输入框、不保存到电脑文件夹,与拍照注入模式设置无关。", "w1"),
1236
- infoRow("关闭手机摄像头(phone_camera_pause)", "让手机相机提前进入空闲省电状态。", "w2"),
1237
- infoRow("恢复手机摄像头(phone_camera_resume)", "立即打开相机并恢复预览拍摄。", "w3"),
1238
- infoRow("调整/查询变焦(phone_camera_zoom)", "按指定倍数(或步进)拉近/拉远手机镜头;不带参数调用即查询当前倍数与支持范围。超出设备范围自动收拢。", "w4"),
1239
- infoRow("切换/查询镜头(phone_camera_lens)", "切换手机前置/后置摄像头;不带参数调用即查询当前使用的镜头与设备可用镜头。需手机端 ≥ 1.1.3。", "w5"),
1240
- h(
1241
- "div",
1242
- { className: "lm-set-row", key: "ft" },
1243
- h("span", { className: "lm-set-rowtext" }, h("span", { className: "lm-set-rowdesc" }, "高频的「拍照注入模式 / 保存目录(可浏览选择文件夹)」设置仍在悬浮窗 ⚙ 面板")),
1244
- ),
1392
+ h(LensSetRow, {
1393
+ key: "t3",
1394
+ title: "工作 (Beta)",
1395
+ desc: "允许手机 App 镜像并操作本机会话(流式/发送/停止等)。默认关闭;关闭时手机端工作入口不可用",
1396
+ }, h(LensSwitch, { checked: !!st.workBetaEnabled, onChange: (v) => void patch({ workBetaEnabled: v }) })),
1245
1397
  ],
1246
1398
  ),
1247
1399
  ),
@@ -1278,12 +1430,40 @@ window.__ModuleLoader__.load({
1278
1430
  h(
1279
1431
  "div",
1280
1432
  { className: "lm-setsec-rows" },
1281
- h(LensSetRow, { key: "c1", title: "接收端口", desc: "phone-lens 接收服务监听的端口(默认 8791,多实例/端口冲突时调整)" },
1282
- h("input", { value: portDraft, onChange: (e) => setPortDraft(e.target.value), onKeyDown: (e) => e.key === "Enter" && savePortDraft(), inputMode: "numeric", style: { width: 72, background: "var(--dsw-alias-bg-layer-2, rgba(255,255,255,.05))", color: "var(--dsw-alias-label-primary, #e6edf5)", border: "1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.14))", borderRadius: 8, padding: "5px 8px", fontSize: 13 } }),
1283
- h("button", { type: "button", onClick: savePortDraft, style: { marginLeft: 8, padding: "5px 12px", borderRadius: 8, border: "1px solid var(--dsw-alias-button-primary-fill, #3b82f6)", background: "var(--dsw-alias-button-primary-fill, #3b82f6)", color: "#fff", fontSize: 12, cursor: "pointer" } }, "保存"),
1433
+ // read-only: the listen port is host-owned (plugin config
1434
+ // server.port). The browser only discovers it (/info) —
1435
+ // editing it here could never re-bind the host anyway.
1436
+ h(LensSetRow, { key: "c1", title: "接收端口", desc: "由电脑端插件配置决定(默认 8791),只读展示;端口被占用时接收端会自动尝试 8792~8800,此处显示的是实际生效端口。" },
1437
+ h("span", { style: { fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace", fontSize: 13, opacity: 0.9 } }, `127.0.0.1:${port}`),
1284
1438
  ),
1285
1439
  ),
1286
1440
  ),
1441
+ // ── card: command reference (kept last per user-requested layout;
1442
+ // only rendered when the tool list is actually live) ──
1443
+ st && typeof st === "object"
1444
+ ? h(
1445
+ "div",
1446
+ { className: "lm-setsec-card", key: "usage" },
1447
+ h("div", { className: "lm-setsec-cardhead" }, "命令说明"),
1448
+ h(
1449
+ "div",
1450
+ { className: "lm-setsec-rows" },
1451
+ // tool names in the open (user-requested): what the
1452
+ // model will actually see in its tool list
1453
+ infoRow("拍摄照片(phone_take_photo)", "手机立即拍照,照片直接进入模型视野——不放入输入框、不保存到电脑文件夹,与拍照注入模式设置无关。", "w1"),
1454
+ infoRow("关闭手机摄像头(phone_camera_pause)", "让手机相机提前进入空闲省电状态。", "w2"),
1455
+ infoRow("恢复手机摄像头(phone_camera_resume)", "立即打开相机并恢复预览拍摄。", "w3"),
1456
+ infoRow("调整/查询变焦(phone_camera_zoom)", "按指定倍数(或步进)拉近/拉远手机镜头;不带参数调用即查询当前倍数与支持范围。超出设备范围自动收拢。", "w4"),
1457
+ infoRow("切换/查询镜头(phone_camera_lens)", "切换手机前置/后置摄像头;不带参数调用即查询当前使用的镜头与设备可用镜头。需手机端 ≥ 1.1.3。", "w5"),
1458
+ infoRow("开关/查询手电筒(phone_camera_torch)", "打开/关闭手机闪光灯常亮照明,或查询当前状态;需相机处于开启状态。需手机端 ≥ 1.1.6。", "w6"),
1459
+ h(
1460
+ "div",
1461
+ { className: "lm-set-row", key: "ft" },
1462
+ h("span", { className: "lm-set-rowtext" }, h("span", { className: "lm-set-rowdesc" }, "高频的「拍照注入模式 / 保存目录(可浏览选择文件夹)」设置仍在悬浮窗 ⚙ 面板")),
1463
+ ),
1464
+ ),
1465
+ )
1466
+ : null,
1287
1467
  flash ? h("div", { className: "lm-setsec-note", key: "f" }, flash) : null,
1288
1468
  );
1289
1469
  }