phone-lens 1.0.3 → 1.1.0

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
@@ -26,8 +26,8 @@ window.__ModuleLoader__.load({
26
26
  { id: "folder", title: "仅保存到文件夹", desc: "不放入输入框,只保存到指定电脑文件夹", flash: "已切换:仅保存到文件夹" },
27
27
  ];
28
28
  // App download fallbacks (used when /app-qr.json is unavailable on an older host).
29
- // Gitee release carrying the newest APK (App unchanged since v0.2.0).
30
- const APP_GITEE = "https://gitee.com/qianfengbingtang/phone-lens/releases/download/v0.2.0/app-release.apk";
29
+ // Gitee release carrying the newest APK.
30
+ const APP_GITEE = "https://gitee.com/qianfengbingtang/phone-lens/releases/download/v1.1.0/app-release.apk";
31
31
  const APP_GITHUB = "https://github.com/yxqfg/phone-lens/releases/latest/download/app-release.apk";
32
32
  const h = react.createElement;
33
33
 
@@ -104,6 +104,31 @@ window.__ModuleLoader__.load({
104
104
  .lm-fab.connecting { color: #ffffff; background: #6cc0ff; animation: lm-blast .5s ease-in-out 3; }
105
105
  .lm-fab.disconnected { background: #23303c; color: #8fa1b5; }
106
106
  .lm-status .warn-big { color: #d9a441; font-weight: 600; font-size: 11px; line-height: 1.4; white-space: normal; }
107
+ .lm-updwarn { font-size: 11px; line-height: 1.5; color: #d9a441; }
108
+ .lm-appver { font-size: 10px; line-height: 1.5; color: #7d8da0; }
109
+ .lm-appver.mismatch { color: #d9a441; }
110
+ .lm-setsec { display: flex; flex-direction: column; gap: 12px; max-width: 760px; }
111
+ .lm-setsec-head { display: flex; align-items: center; gap: 10px; }
112
+ .lm-setsec-headtitle { font-size: 16px; font-weight: 600; color: var(--dsw-alias-label-primary, #e6edf5); }
113
+ .lm-setsec-badge { font-size: 11px; line-height: 18px; padding: 0 10px; border-radius: 999px; border: 1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.14)); color: var(--dsw-alias-label-secondary, #9fb0c0); }
114
+ .lm-setsec-intro { font-size: 12px; line-height: 20px; color: var(--dsw-alias-label-tertiary, #7d8da0); }
115
+ .lm-setsec-card { border: 1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.10)); border-radius: 16px; background: var(--dsw-alias-bg-layer-3, rgba(255,255,255,.03)); overflow: hidden; }
116
+ .lm-setsec-cardhead { padding: 12px 16px 10px; font-size: 14px; font-weight: 600; color: var(--dsw-alias-label-primary, #e6edf5); }
117
+ .lm-setsec-rows { display: flex; flex-direction: column; }
118
+ .lm-set-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 16px; border-top: 1px solid var(--dsw-alias-border-l1, rgba(255,255,255,.06)); }
119
+ .lm-set-rowtext { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
120
+ .lm-set-rowtitle { font-size: 14px; line-height: 22px; color: var(--dsw-alias-label-primary, #e6edf5); }
121
+ .lm-set-rowdesc { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-tertiary, #7d8da0); }
122
+ .lm-set-control { flex: none; display: inline-flex; align-items: center; }
123
+ .lm-set-switch { position: relative; display: inline-flex; flex: none; cursor: pointer; }
124
+ .lm-set-switch input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
125
+ .lm-set-track { display: inline-flex; align-items: center; width: 36px; height: 20px; padding: 2px; box-sizing: border-box; border-radius: 10px; border: 1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.14)); background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,.05)); transition: background .15s ease, border-color .15s ease; }
126
+ .lm-set-thumb { display: block; width: 14px; height: 14px; border-radius: 50%; background: var(--dsw-alias-label-tertiary, #8fa1b5); transition: transform .15s ease, background .15s ease; }
127
+ .lm-set-switch:hover .lm-set-track { border-color: var(--dsw-alias-label-dimmed, #5c6b7a); }
128
+ .lm-set-switch input:checked + .lm-set-track { border-color: var(--dsw-alias-button-primary-fill, #3b82f6); background: var(--dsw-alias-button-primary-fill, #3b82f6); }
129
+ .lm-set-switch input:checked + .lm-set-track .lm-set-thumb { transform: translateX(16px); background: var(--dsw-alias-bg-layer-3, #0f141a); }
130
+ .lm-set-switch input:focus-visible + .lm-set-track { outline: 2px solid var(--dsw-alias-button-primary-fill, #3b82f6); outline-offset: 2px; }
131
+ .lm-setsec-note { font-size: 12px; line-height: 20px; color: var(--dsw-alias-label-tertiary, #7d8da0); }
107
132
  `;
108
133
  // The factory runs at materialization (first import), so the document
109
134
  // head is guaranteed to exist; remove-then-add keeps HMR idempotent.
@@ -235,7 +260,11 @@ window.__ModuleLoader__.load({
235
260
  const [, setTick] = useState(0); // 1s heartbeat: re-renders the QR countdown while pairing is visible
236
261
  const [flash, setFlash] = useState("");
237
262
  const [pending, setPending] = useState([]); // [{id, name}] waiting to be staged
238
- const [devices, setDevices] = useState([]); // [{id,name,active}]
263
+ const [devices, setDevices] = useState([]); // [{id,name,active,appVersion?}]
264
+ // Host plugin-update check (update-check.ts): null = unchecked/failed —
265
+ // the hint line simply doesn't render. Fetched once per panel-open; the
266
+ // host caches the Gitee lookup for 12h so this costs no API call.
267
+ const [upd, setUpd] = useState(null);
239
268
  const [rename, setRename] = useState(null); // {id, name} — self-drawn rename dialog
240
269
  const [appQr, setAppQr] = useState(null); // app-download QR dialog payload (or {loading}/{fallback:true})
241
270
  const [previewOff, setPreviewOff] = useState(false); // host says the phone's stream stopped (preview off on the phone)
@@ -251,6 +280,26 @@ window.__ModuleLoader__.load({
251
280
  const baseUrl = `http://127.0.0.1:${port}`;
252
281
  const wsUrl = `ws://127.0.0.1:${port}/ws/view`;
253
282
 
283
+ // plugin-update check, once per panel-open (host caches the Gitee
284
+ // lookup 12h / failures 30min — this fetch never hits the API itself)
285
+ useEffect(() => {
286
+ if (!open) return;
287
+ let alive = true;
288
+ void (async () => {
289
+ try {
290
+ const r = await fetch(`${baseUrl}/update-check`, { cache: "no-store" });
291
+ if (!r.ok) throw new Error(String(r.status));
292
+ const data = await r.json();
293
+ if (alive) setUpd(data && data.current ? data : null);
294
+ } catch {
295
+ if (alive) setUpd(null);
296
+ }
297
+ })();
298
+ return () => {
299
+ alive = false;
300
+ };
301
+ }, [open, port]);
302
+
254
303
  // view websocket: ALWAYS connected (independent of panel open state),
255
304
  // so the connection status and pending-photo events refresh even when
256
305
  // the FAB is collapsed. Frames are only drawn when the canvas exists.
@@ -313,12 +362,13 @@ window.__ModuleLoader__.load({
313
362
  })
314
363
  .catch((e) => setFlashErr("自动放入失败: " + String(e && e.message || e).slice(0, 90)));
315
364
  } else if (m.type === "devices") {
316
- setDevices((prev) => {
317
- // a NEW device came online → the one-time pairing code was
318
- // consumed; rotate the QR so the next scan sees a fresh code
319
- if ((m.devices || []).length > (prev ? prev.length : 0)) void refreshQr(true);
320
- return m.devices || [];
321
- });
365
+ // side effects OUT of the updater (updaters must stay pure —
366
+ // StrictMode double-invokes them); compare here, setState below
367
+ const grew = (m.devices || []).length > (devices ? devices.length : 0);
368
+ setDevices(m.devices || []);
369
+ // a NEW device came online → the one-time pairing code was
370
+ // consumed; rotate the QR so the next scan sees a fresh code
371
+ if (grew) void refreshQr(true);
322
372
  // an active device present == camera linked; derive both the
323
373
  // canvas visibility and the header/FAB link state from the
324
374
  // device list (host no longer emits `device`)
@@ -547,6 +597,21 @@ window.__ModuleLoader__.load({
547
597
  : "预览中",
548
598
  ),
549
599
  ),
600
+ // ── version hints (plain colored lines, never dialogs) ──
601
+ upd && upd.updateAvailable
602
+ ? h("div", { className: "lm-updwarn", key: "upd" }, `⬆ 电脑端有新版本 v${upd.latest} — 请在 设置 → 插件市场 更新`)
603
+ : null,
604
+ (() => {
605
+ // cross-end consistency: the phone reports its App version
606
+ // in hello; a mismatch (both ends known) warms the line
607
+ const app = (devices || []).find((d) => d.active)?.appVersion;
608
+ if (!app) return null;
609
+ const host = upd && upd.current;
610
+ if (host && app !== host) {
611
+ return h("div", { className: "lm-appver mismatch", key: "appver" }, `手机 App v${app} 与电脑端 v${host} 版本不一致,建议两端同时更新`);
612
+ }
613
+ return h("div", { className: "lm-appver", key: "appver" }, `手机 App v${app}`);
614
+ })(),
550
615
  devices && devices.length > 1
551
616
  ? h(
552
617
  "div",
@@ -711,6 +776,223 @@ window.__ModuleLoader__.load({
711
776
  );
712
777
  }
713
778
 
779
+ // ── settings.section — the official settings-page panel ───────────────
780
+ // Privacy-bearing switches live here with an honest description of what
781
+ // the model can do and where photos go. The high-frequency capture-mode
782
+ // settings stay in the overlay ⚙ panel on purpose (switched constantly).
783
+ function currentLensPort() {
784
+ try {
785
+ return window.localStorage.getItem(PORT_KEY) || "8791";
786
+ } catch {
787
+ return "8791";
788
+ }
789
+ }
790
+
791
+ // Custom switch per the DSH settings-row recipe: a real checkbox (native
792
+ // semantics + focus) driving a styled track/thumb — 36×20 track, 14px
793
+ // thumb, primary fill when checked. Sizes/tokens mirror dsh-better-sidebar.
794
+ function LensSwitch({ checked, onChange }) {
795
+ return h(
796
+ "label",
797
+ { className: "lm-set-switch" },
798
+ h("input", {
799
+ type: "checkbox",
800
+ checked: !!checked,
801
+ onChange: (e) => onChange(e.target.checked),
802
+ }),
803
+ h("span", { className: "lm-set-track", "aria-hidden": "true" }, h("span", { className: "lm-set-thumb" })),
804
+ );
805
+ }
806
+
807
+ // One settings row: title/desc on the left, control on the right.
808
+ function LensSetRow({ title, desc, children }) {
809
+ return h(
810
+ "div",
811
+ { className: "lm-set-row" },
812
+ h(
813
+ "span",
814
+ { className: "lm-set-rowtext" },
815
+ h("span", { className: "lm-set-rowtitle" }, title),
816
+ desc ? h("span", { className: "lm-set-rowdesc" }, desc) : null,
817
+ ),
818
+ h("span", { className: "lm-set-control" }, children),
819
+ );
820
+ }
821
+
822
+ function LensSettingsSection() {
823
+ const [st, setSt] = useState(undefined); // undefined=loading, false=unavailable, object=live
824
+ const [flash, setFlash] = useState("");
825
+
826
+ useEffect(() => {
827
+ let alive = true;
828
+ void (async () => {
829
+ try {
830
+ const r = await fetch(`http://127.0.0.1:${currentLensPort()}/app-settings`, { cache: "no-store" });
831
+ if (!r.ok) throw new Error(String(r.status));
832
+ const data = await r.json();
833
+ if (alive) setSt(data);
834
+ } catch {
835
+ if (alive) setSt(false);
836
+ }
837
+ })();
838
+ return () => {
839
+ alive = false;
840
+ };
841
+ }, []);
842
+
843
+ async function patch(p) {
844
+ try {
845
+ const r = await fetch(`http://127.0.0.1:${currentLensPort()}/app-settings`, {
846
+ method: "POST",
847
+ headers: { "content-type": "application/json" },
848
+ body: JSON.stringify(p),
849
+ });
850
+ if (!r.ok) throw new Error("HTTP " + r.status);
851
+ setSt(await r.json());
852
+ setFlash("已保存");
853
+ setTimeout(() => setFlash(""), 1800);
854
+ } catch (e) {
855
+ setFlash("保存失败: " + String((e && e.message) || e).slice(0, 60));
856
+ }
857
+ }
858
+
859
+ const infoRow = (title, desc, key) =>
860
+ h(
861
+ "div",
862
+ { className: "lm-set-row", key },
863
+ h(
864
+ "span",
865
+ { className: "lm-set-rowtext" },
866
+ h("span", { className: "lm-set-rowtitle" }, title),
867
+ h("span", { className: "lm-set-rowdesc" }, desc),
868
+ ),
869
+ );
870
+
871
+ return h(
872
+ "div",
873
+ { className: "lm-setsec" },
874
+ h(
875
+ "div",
876
+ { className: "lm-setsec-head" },
877
+ h("span", { className: "lm-setsec-headtitle" }, "PhoneLens 手机相机"),
878
+ h("span", { className: "lm-setsec-badge" }, "phone-lens"),
879
+ ),
880
+ h("div", { className: "lm-setsec-intro" }, "管理对话中模型对本机手机相机的调用能力与隐私行为。"),
881
+ st === undefined
882
+ ? h("div", { className: "lm-setsec-note" }, "加载中…")
883
+ : st === false
884
+ ? h("div", { className: "lm-setsec-note" }, "设置服务不可用(需电脑端 phone-lens ≥ 1.0.4)")
885
+ : [
886
+ h(
887
+ "div",
888
+ { className: "lm-setsec-card", key: "privacy" },
889
+ h("div", { className: "lm-setsec-cardhead" }, "隐私开关"),
890
+ h(
891
+ "div",
892
+ { className: "lm-setsec-rows" },
893
+ h(LensSetRow, {
894
+ key: "t1",
895
+ title: "启用模型相机工具",
896
+ desc: "关闭后模型调用会被拒绝,手机摄像头完全由你自己控制",
897
+ }, h(LensSwitch, { checked: st.modelToolsEnabled, onChange: (v) => void patch({ modelToolsEnabled: v }) })),
898
+ h(LensSetRow, {
899
+ key: "t2",
900
+ title: "模型免确认调用",
901
+ desc: "开启时模型调用不弹确认框;关闭后每次调用需你在弹窗中确认(若宿主不支持审批弹窗,则视为拒绝)",
902
+ }, h(LensSwitch, { checked: st.modelToolsConfirmFree, onChange: (v) => void patch({ modelToolsConfirmFree: v }) })),
903
+ // pointer to the high-frequency settings, parked INSIDE
904
+ // the toggles card (user-requested placement)
905
+ h(
906
+ "div",
907
+ { className: "lm-set-row", key: "ft" },
908
+ h("span", { className: "lm-set-rowtext" }, h("span", { className: "lm-set-rowdesc" }, "高频的「拍照注入模式 / 保存目录」设置仍在右下角悬浮窗 ⚙ 面板")),
909
+ ),
910
+ ),
911
+ ),
912
+ h(
913
+ "div",
914
+ { className: "lm-setsec-card", key: "what" },
915
+ h("div", { className: "lm-setsec-cardhead" }, "打开上述开关后,对话内模型将被允许:"),
916
+ h(
917
+ "div",
918
+ { className: "lm-setsec-rows" },
919
+ // tool names in the open (user-requested): what the
920
+ // model will actually see in its tool list
921
+ infoRow("拍摄照片(phone_take_photo)", "手机立即拍照,照片直接进入模型视野——不放入输入框、不保存到电脑文件夹,与拍照注入模式设置无关。", "w1"),
922
+ infoRow("关闭手机摄像头(phone_camera_pause)", "让手机相机提前进入空闲省电状态。", "w2"),
923
+ infoRow("恢复手机摄像头(phone_camera_resume)", "立即打开相机并恢复预览拍摄。", "w3"),
924
+ ),
925
+ ),
926
+ flash ? h("div", { className: "lm-setsec-note", key: "f" }, flash) : null,
927
+ ],
928
+ );
929
+ }
930
+
931
+ // ── settings nav icon ─────────────────────────────────────────────────
932
+ // settings.section alone yields the nav row; this paints a camera icon
933
+ // onto it with the same CSS-mask trick dshmarket uses (mark the row
934
+ // whose text matches our label, inject a ::before mask).
935
+ const LENS_NAV_SVG =
936
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3.5"/></svg>';
937
+
938
+ function installSettingsNavIcon(ctx, resolveLabel) {
939
+ if (typeof document === "undefined") return;
940
+ const MARKER = "data-phone-lens-nav-icon";
941
+ const SELECTOR = '[role="dialog"] nav button';
942
+ const maskUrl = `data:image/svg+xml,${encodeURIComponent(LENS_NAV_SVG)}`;
943
+ ctx.effect(() => {
944
+ const tag = document.createElement("style");
945
+ tag.dataset.plugin = "phone-lens";
946
+ tag.textContent = [
947
+ `[${MARKER}] > svg { display: none; }`,
948
+ `[${MARKER}]::before {`,
949
+ ` content: '';`,
950
+ ` flex: none;`,
951
+ ` width: 16px;`,
952
+ ` height: 16px;`,
953
+ ` background-color: currentColor;`,
954
+ ` -webkit-mask-image: url("${maskUrl}");`,
955
+ ` mask-image: url("${maskUrl}");`,
956
+ ` -webkit-mask-repeat: no-repeat;`,
957
+ ` mask-repeat: no-repeat;`,
958
+ ` -webkit-mask-position: center;`,
959
+ ` mask-position: center;`,
960
+ ` -webkit-mask-size: 16px 16px;`,
961
+ ` mask-size: 16px 16px;`,
962
+ `}`,
963
+ ].join("\n");
964
+ document.head.appendChild(tag);
965
+ let disposed = false;
966
+ let scheduled = false;
967
+ const sync = () => {
968
+ scheduled = false;
969
+ if (disposed) return;
970
+ // cheap early-out: the settings dialog isn't even mounted — skip
971
+ // the querySelectorAll sweep while chat tokens stream in
972
+ if (!document.querySelector('[role="dialog"] nav')) return;
973
+ const wanted = resolveLabel();
974
+ for (const row of document.querySelectorAll(SELECTOR)) {
975
+ if ((row.textContent || "").trim() === wanted) row.setAttribute(MARKER, "");
976
+ else row.removeAttribute(MARKER);
977
+ }
978
+ };
979
+ const schedule = () => {
980
+ if (scheduled || disposed) return;
981
+ scheduled = true;
982
+ queueMicrotask(sync);
983
+ };
984
+ sync();
985
+ const observer = new MutationObserver(schedule);
986
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true });
987
+ return () => {
988
+ disposed = true;
989
+ observer.disconnect();
990
+ for (const row of document.querySelectorAll(`[${MARKER}]`)) row.removeAttribute(MARKER);
991
+ tag.remove();
992
+ };
993
+ }, "phone-lens: settings nav icon");
994
+ }
995
+
714
996
  // ── plugin face ───────────────────────────────────────────────────────
715
997
  const inject = ["slots"];
716
998
  function apply(ctx) {
@@ -721,6 +1003,15 @@ window.__ModuleLoader__.load({
721
1003
  LensOverlay,
722
1004
  ),
723
1005
  );
1006
+ // official settings page: additive section — the host renders it as a
1007
+ // dedicated entry in the settings dialog's left nav (same as dshmarket)
1008
+ ctx.slots.inject("settings.section", () =>
1009
+ ctx.slots.register(
1010
+ { name: "settings.section", id: "phone-lens", order: 50, label: () => "PhoneLens" },
1011
+ () => h(LensSettingsSection),
1012
+ ),
1013
+ );
1014
+ installSettingsNavIcon(ctx, () => "PhoneLens");
724
1015
  }
725
1016
 
726
1017
  exports.apply = apply;