phone-lens 1.0.3 → 1.1.1

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,10 +260,15 @@ 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)
271
+ const [previewOffline, setPreviewOffline] = useState(false); // host flagged the LINK itself dead (wifi drop, no frames AND no keepalive) — not a preview toggle
242
272
  const [settingsOpen, setSettingsOpen] = useState(false); // collapsible settings section under the main card
243
273
  const [appSt, setAppSt] = useState(null); // {saveMode, saveDir, effectiveSaveDir} | null (null = host too old)
244
274
  const [dirDraft, setDirDraft] = useState("");
@@ -251,6 +281,26 @@ window.__ModuleLoader__.load({
251
281
  const baseUrl = `http://127.0.0.1:${port}`;
252
282
  const wsUrl = `ws://127.0.0.1:${port}/ws/view`;
253
283
 
284
+ // plugin-update check, once per panel-open (host caches the Gitee
285
+ // lookup 12h / failures 30min — this fetch never hits the API itself)
286
+ useEffect(() => {
287
+ if (!open) return;
288
+ let alive = true;
289
+ void (async () => {
290
+ try {
291
+ const r = await fetch(`${baseUrl}/update-check`, { cache: "no-store" });
292
+ if (!r.ok) throw new Error(String(r.status));
293
+ const data = await r.json();
294
+ if (alive) setUpd(data && data.current ? data : null);
295
+ } catch {
296
+ if (alive) setUpd(null);
297
+ }
298
+ })();
299
+ return () => {
300
+ alive = false;
301
+ };
302
+ }, [open, port]);
303
+
254
304
  // view websocket: ALWAYS connected (independent of panel open state),
255
305
  // so the connection status and pending-photo events refresh even when
256
306
  // the FAB is collapsed. Frames are only drawn when the canvas exists.
@@ -292,9 +342,13 @@ window.__ModuleLoader__.load({
292
342
  } else if (m.type === "frame_meta") {
293
343
  if (m.rotation !== undefined) rotRef.current = m.rotation;
294
344
  } 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
345
+ // host-derived: the ACTIVE phone's stream stopped — either the
346
+ // user turned preview off in the app (keepalive still alive) or
347
+ // the link itself died (no frames AND no keepalive for 25s →
348
+ // reason:"offline"). Old hosts never send `reason` → legacy
349
+ // behavior is kept bit-for-bit.
297
350
  setPreviewOff(!m.on);
351
+ setPreviewOffline(!m.on && m.reason === "offline");
298
352
  } else if (m.type === "upload_saved") {
299
353
  // folder-only mode: the photo bypassed the composer; local hint
300
354
  setFlashOk(`已保存至 ${m.dir || "指定文件夹"}`);
@@ -313,12 +367,13 @@ window.__ModuleLoader__.load({
313
367
  })
314
368
  .catch((e) => setFlashErr("自动放入失败: " + String(e && e.message || e).slice(0, 90)));
315
369
  } 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
- });
370
+ // side effects OUT of the updater (updaters must stay pure —
371
+ // StrictMode double-invokes them); compare here, setState below
372
+ const grew = (m.devices || []).length > (devices ? devices.length : 0);
373
+ setDevices(m.devices || []);
374
+ // a NEW device came online → the one-time pairing code was
375
+ // consumed; rotate the QR so the next scan sees a fresh code
376
+ if (grew) void refreshQr(true);
322
377
  // an active device present == camera linked; derive both the
323
378
  // canvas visibility and the header/FAB link state from the
324
379
  // device list (host no longer emits `device`)
@@ -337,6 +392,7 @@ window.__ModuleLoader__.load({
337
392
  ws.onclose = () => {
338
393
  setCamOn(false);
339
394
  setPreviewOff(false);
395
+ setPreviewOffline(false);
340
396
  if (!stopped) timer = setTimeout(connect, 2000);
341
397
  };
342
398
  };
@@ -527,11 +583,13 @@ window.__ModuleLoader__.load({
527
583
  { className: "lm-body" },
528
584
  // only show the viewfinder canvas while a camera is actually
529
585
  // streaming; when the phone turned preview off, clear the last
530
- // frame and say so instead of freezing on a stale picture
586
+ // frame and say so instead of freezing on a stale picture.
587
+ // previewOffline = the host proved the LINK died (no frames AND
588
+ // no keepalive 25s): say "connection lost", never "preview off"
531
589
  camOn && !previewOff
532
590
  ? h("canvas", { ref: canvasRef, className: "lm-canvas", width: 360, height: 640 })
533
591
  : camOn && previewOff
534
- ? h("div", { className: "lm-preview-off" }, "您在手机端已关闭预览功能")
592
+ ? h("div", { className: "lm-preview-off" }, previewOffline ? "与手机的连接已中断(设备离线)" : "您在手机端已关闭预览功能")
535
593
  : null,
536
594
  h(
537
595
  "div",
@@ -539,14 +597,31 @@ window.__ModuleLoader__.load({
539
597
  h("span", null, camOn && !previewOff && fps > 0 ? `${fps} fps` : "—"),
540
598
  h(
541
599
  "span",
542
- { className: !camOn ? "warn-big" : previewOff ? "warn" : "" },
600
+ { className: !camOn || previewOffline ? "warn-big" : previewOff ? "warn" : "" },
543
601
  !camOn
544
602
  ? "手机未连接!相同局域网下扫码添加配对设备;若已配对过的手机,请在设置内点击本电脑端,激活为活动设备"
545
- : previewOff
546
- ? "预览已关闭,在手机端重新开启后恢复"
547
- : "预览中",
603
+ : previewOffline
604
+ ? "连接已断开(手机离线),请检查手机网络;网络恢复后将自动重连"
605
+ : previewOff
606
+ ? "预览已关闭,在手机端重新开启后恢复"
607
+ : "预览中",
548
608
  ),
549
609
  ),
610
+ // ── version hints (plain colored lines, never dialogs) ──
611
+ upd && upd.updateAvailable
612
+ ? h("div", { className: "lm-updwarn", key: "upd" }, `⬆ 电脑端有新版本 v${upd.latest} — 请在 设置 → 插件市场 更新`)
613
+ : null,
614
+ (() => {
615
+ // cross-end consistency: the phone reports its App version
616
+ // in hello; a mismatch (both ends known) warms the line
617
+ const app = (devices || []).find((d) => d.active)?.appVersion;
618
+ if (!app) return null;
619
+ const host = upd && upd.current;
620
+ if (host && app !== host) {
621
+ return h("div", { className: "lm-appver mismatch", key: "appver" }, `手机 App v${app} 与电脑端 v${host} 版本不一致,建议两端同时更新`);
622
+ }
623
+ return h("div", { className: "lm-appver", key: "appver" }, `手机 App v${app}`);
624
+ })(),
550
625
  devices && devices.length > 1
551
626
  ? h(
552
627
  "div",
@@ -711,6 +786,223 @@ window.__ModuleLoader__.load({
711
786
  );
712
787
  }
713
788
 
789
+ // ── settings.section — the official settings-page panel ───────────────
790
+ // Privacy-bearing switches live here with an honest description of what
791
+ // the model can do and where photos go. The high-frequency capture-mode
792
+ // settings stay in the overlay ⚙ panel on purpose (switched constantly).
793
+ function currentLensPort() {
794
+ try {
795
+ return window.localStorage.getItem(PORT_KEY) || "8791";
796
+ } catch {
797
+ return "8791";
798
+ }
799
+ }
800
+
801
+ // Custom switch per the DSH settings-row recipe: a real checkbox (native
802
+ // semantics + focus) driving a styled track/thumb — 36×20 track, 14px
803
+ // thumb, primary fill when checked. Sizes/tokens mirror dsh-better-sidebar.
804
+ function LensSwitch({ checked, onChange }) {
805
+ return h(
806
+ "label",
807
+ { className: "lm-set-switch" },
808
+ h("input", {
809
+ type: "checkbox",
810
+ checked: !!checked,
811
+ onChange: (e) => onChange(e.target.checked),
812
+ }),
813
+ h("span", { className: "lm-set-track", "aria-hidden": "true" }, h("span", { className: "lm-set-thumb" })),
814
+ );
815
+ }
816
+
817
+ // One settings row: title/desc on the left, control on the right.
818
+ function LensSetRow({ title, desc, children }) {
819
+ return h(
820
+ "div",
821
+ { className: "lm-set-row" },
822
+ h(
823
+ "span",
824
+ { className: "lm-set-rowtext" },
825
+ h("span", { className: "lm-set-rowtitle" }, title),
826
+ desc ? h("span", { className: "lm-set-rowdesc" }, desc) : null,
827
+ ),
828
+ h("span", { className: "lm-set-control" }, children),
829
+ );
830
+ }
831
+
832
+ function LensSettingsSection() {
833
+ const [st, setSt] = useState(undefined); // undefined=loading, false=unavailable, object=live
834
+ const [flash, setFlash] = useState("");
835
+
836
+ useEffect(() => {
837
+ let alive = true;
838
+ void (async () => {
839
+ try {
840
+ const r = await fetch(`http://127.0.0.1:${currentLensPort()}/app-settings`, { cache: "no-store" });
841
+ if (!r.ok) throw new Error(String(r.status));
842
+ const data = await r.json();
843
+ if (alive) setSt(data);
844
+ } catch {
845
+ if (alive) setSt(false);
846
+ }
847
+ })();
848
+ return () => {
849
+ alive = false;
850
+ };
851
+ }, []);
852
+
853
+ async function patch(p) {
854
+ try {
855
+ const r = await fetch(`http://127.0.0.1:${currentLensPort()}/app-settings`, {
856
+ method: "POST",
857
+ headers: { "content-type": "application/json" },
858
+ body: JSON.stringify(p),
859
+ });
860
+ if (!r.ok) throw new Error("HTTP " + r.status);
861
+ setSt(await r.json());
862
+ setFlash("已保存");
863
+ setTimeout(() => setFlash(""), 1800);
864
+ } catch (e) {
865
+ setFlash("保存失败: " + String((e && e.message) || e).slice(0, 60));
866
+ }
867
+ }
868
+
869
+ const infoRow = (title, desc, key) =>
870
+ h(
871
+ "div",
872
+ { className: "lm-set-row", key },
873
+ h(
874
+ "span",
875
+ { className: "lm-set-rowtext" },
876
+ h("span", { className: "lm-set-rowtitle" }, title),
877
+ h("span", { className: "lm-set-rowdesc" }, desc),
878
+ ),
879
+ );
880
+
881
+ return h(
882
+ "div",
883
+ { className: "lm-setsec" },
884
+ h(
885
+ "div",
886
+ { className: "lm-setsec-head" },
887
+ h("span", { className: "lm-setsec-headtitle" }, "PhoneLens 手机相机"),
888
+ h("span", { className: "lm-setsec-badge" }, "phone-lens"),
889
+ ),
890
+ h("div", { className: "lm-setsec-intro" }, "管理对话中模型对本机手机相机的调用能力与隐私行为。"),
891
+ st === undefined
892
+ ? h("div", { className: "lm-setsec-note" }, "加载中…")
893
+ : st === false
894
+ ? h("div", { className: "lm-setsec-note" }, "设置服务不可用(需电脑端 phone-lens ≥ 1.0.4)")
895
+ : [
896
+ h(
897
+ "div",
898
+ { className: "lm-setsec-card", key: "privacy" },
899
+ h("div", { className: "lm-setsec-cardhead" }, "隐私开关"),
900
+ h(
901
+ "div",
902
+ { className: "lm-setsec-rows" },
903
+ h(LensSetRow, {
904
+ key: "t1",
905
+ title: "启用模型相机工具",
906
+ desc: "关闭后模型调用会被拒绝,手机摄像头完全由你自己控制",
907
+ }, h(LensSwitch, { checked: st.modelToolsEnabled, onChange: (v) => void patch({ modelToolsEnabled: v }) })),
908
+ h(LensSetRow, {
909
+ key: "t2",
910
+ title: "模型免确认调用",
911
+ desc: "开启时模型调用不弹确认框;关闭后每次调用需你在弹窗中确认(若宿主不支持审批弹窗,则视为拒绝)",
912
+ }, h(LensSwitch, { checked: st.modelToolsConfirmFree, onChange: (v) => void patch({ modelToolsConfirmFree: v }) })),
913
+ // pointer to the high-frequency settings, parked INSIDE
914
+ // the toggles card (user-requested placement)
915
+ h(
916
+ "div",
917
+ { className: "lm-set-row", key: "ft" },
918
+ h("span", { className: "lm-set-rowtext" }, h("span", { className: "lm-set-rowdesc" }, "高频的「拍照注入模式 / 保存目录」设置仍在右下角悬浮窗 ⚙ 面板")),
919
+ ),
920
+ ),
921
+ ),
922
+ h(
923
+ "div",
924
+ { className: "lm-setsec-card", key: "what" },
925
+ h("div", { className: "lm-setsec-cardhead" }, "打开上述开关后,对话内模型将被允许:"),
926
+ h(
927
+ "div",
928
+ { className: "lm-setsec-rows" },
929
+ // tool names in the open (user-requested): what the
930
+ // model will actually see in its tool list
931
+ infoRow("拍摄照片(phone_take_photo)", "手机立即拍照,照片直接进入模型视野——不放入输入框、不保存到电脑文件夹,与拍照注入模式设置无关。", "w1"),
932
+ infoRow("关闭手机摄像头(phone_camera_pause)", "让手机相机提前进入空闲省电状态。", "w2"),
933
+ infoRow("恢复手机摄像头(phone_camera_resume)", "立即打开相机并恢复预览拍摄。", "w3"),
934
+ ),
935
+ ),
936
+ flash ? h("div", { className: "lm-setsec-note", key: "f" }, flash) : null,
937
+ ],
938
+ );
939
+ }
940
+
941
+ // ── settings nav icon ─────────────────────────────────────────────────
942
+ // settings.section alone yields the nav row; this paints a camera icon
943
+ // onto it with the same CSS-mask trick dshmarket uses (mark the row
944
+ // whose text matches our label, inject a ::before mask).
945
+ const LENS_NAV_SVG =
946
+ '<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>';
947
+
948
+ function installSettingsNavIcon(ctx, resolveLabel) {
949
+ if (typeof document === "undefined") return;
950
+ const MARKER = "data-phone-lens-nav-icon";
951
+ const SELECTOR = '[role="dialog"] nav button';
952
+ const maskUrl = `data:image/svg+xml,${encodeURIComponent(LENS_NAV_SVG)}`;
953
+ ctx.effect(() => {
954
+ const tag = document.createElement("style");
955
+ tag.dataset.plugin = "phone-lens";
956
+ tag.textContent = [
957
+ `[${MARKER}] > svg { display: none; }`,
958
+ `[${MARKER}]::before {`,
959
+ ` content: '';`,
960
+ ` flex: none;`,
961
+ ` width: 16px;`,
962
+ ` height: 16px;`,
963
+ ` background-color: currentColor;`,
964
+ ` -webkit-mask-image: url("${maskUrl}");`,
965
+ ` mask-image: url("${maskUrl}");`,
966
+ ` -webkit-mask-repeat: no-repeat;`,
967
+ ` mask-repeat: no-repeat;`,
968
+ ` -webkit-mask-position: center;`,
969
+ ` mask-position: center;`,
970
+ ` -webkit-mask-size: 16px 16px;`,
971
+ ` mask-size: 16px 16px;`,
972
+ `}`,
973
+ ].join("\n");
974
+ document.head.appendChild(tag);
975
+ let disposed = false;
976
+ let scheduled = false;
977
+ const sync = () => {
978
+ scheduled = false;
979
+ if (disposed) return;
980
+ // cheap early-out: the settings dialog isn't even mounted — skip
981
+ // the querySelectorAll sweep while chat tokens stream in
982
+ if (!document.querySelector('[role="dialog"] nav')) return;
983
+ const wanted = resolveLabel();
984
+ for (const row of document.querySelectorAll(SELECTOR)) {
985
+ if ((row.textContent || "").trim() === wanted) row.setAttribute(MARKER, "");
986
+ else row.removeAttribute(MARKER);
987
+ }
988
+ };
989
+ const schedule = () => {
990
+ if (scheduled || disposed) return;
991
+ scheduled = true;
992
+ queueMicrotask(sync);
993
+ };
994
+ sync();
995
+ const observer = new MutationObserver(schedule);
996
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true });
997
+ return () => {
998
+ disposed = true;
999
+ observer.disconnect();
1000
+ for (const row of document.querySelectorAll(`[${MARKER}]`)) row.removeAttribute(MARKER);
1001
+ tag.remove();
1002
+ };
1003
+ }, "phone-lens: settings nav icon");
1004
+ }
1005
+
714
1006
  // ── plugin face ───────────────────────────────────────────────────────
715
1007
  const inject = ["slots"];
716
1008
  function apply(ctx) {
@@ -721,6 +1013,15 @@ window.__ModuleLoader__.load({
721
1013
  LensOverlay,
722
1014
  ),
723
1015
  );
1016
+ // official settings page: additive section — the host renders it as a
1017
+ // dedicated entry in the settings dialog's left nav (same as dshmarket)
1018
+ ctx.slots.inject("settings.section", () =>
1019
+ ctx.slots.register(
1020
+ { name: "settings.section", id: "phone-lens", order: 50, label: () => "PhoneLens" },
1021
+ () => h(LensSettingsSection),
1022
+ ),
1023
+ );
1024
+ installSettingsNavIcon(ctx, () => "PhoneLens");
724
1025
  }
725
1026
 
726
1027
  exports.apply = apply;