phone-lens 0.3.5
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/README.md +60 -0
- package/cordis.patch.yml +35 -0
- package/lib/client.js +568 -0
- package/lib/dev.js +1390 -0
- package/lib/index.d.ts +29 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +1494 -0
- package/lib/index.js.map +1 -0
- package/package.json +74 -0
package/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# phone-lens(dsh 插件)
|
|
2
|
+
|
|
3
|
+
`phone-lens` 是一个 **dsh 双面插件**,让手机相机成为 dsh 会话的即时视觉输入:
|
|
4
|
+
|
|
5
|
+
- **host 半边**:在 dsh 进程内起接收服务(Node HTTP + WebSocket,默认 `0.0.0.0:8791`),处理配对、上传、并将图片注入 dsh 会话输入框。
|
|
6
|
+
- **client 半边**:在 dsh Web UI 右下角注册一个悬浮取景小窗(`shell.overlay` 插槽),实时画面 + 快门 + 配对二维码。
|
|
7
|
+
|
|
8
|
+
## 环境要求
|
|
9
|
+
|
|
10
|
+
- Node.js ≥ 20、pnpm
|
|
11
|
+
- 一个 dsh web 配置(`dsh plugin --profile web`)
|
|
12
|
+
|
|
13
|
+
## 安装
|
|
14
|
+
|
|
15
|
+
```powershell
|
|
16
|
+
# 1. 构建
|
|
17
|
+
cd packages\phone-lens
|
|
18
|
+
pnpm install
|
|
19
|
+
pnpm build
|
|
20
|
+
|
|
21
|
+
# 2. 接入 dsh 的 web profile
|
|
22
|
+
dsh plugin --profile web add C:\Users\<you>\phone-lens\packages\phone-lens
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
也可以用仓库根的 `scripts\dev-install.ps1` 一键完成构建与接入,然后重启 `dsh web`,
|
|
26
|
+
终端会打印配对二维码。
|
|
27
|
+
|
|
28
|
+
> 发布到 npm 后,可直接 `dsh plugin --profile web add phone-lens`。
|
|
29
|
+
|
|
30
|
+
## 配置
|
|
31
|
+
|
|
32
|
+
默认配置见 `cordis.patch.yml`,可在你的 profile 层覆盖:
|
|
33
|
+
|
|
34
|
+
| 键 | 默认 | 说明 |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| `server.port` | `8791` | 接收服务端口 |
|
|
37
|
+
| `limits.maxUploadBytes` | `10485760` | 单张图片上限 |
|
|
38
|
+
| `limits.uploadsPerMinute` | `10` | 上传频率限制 |
|
|
39
|
+
| `pairing.codeTtlMs` | `900000` | 配对码有效期(15 分钟) |
|
|
40
|
+
| `preview.fps` | `10` | 预览帧率 |
|
|
41
|
+
| `inject.notePrefix` | `[手机拍照]` | 注入消息文本块前缀 |
|
|
42
|
+
| `target.mode` | `latest` | 目标会话:`latest`(最近活跃)/ `pinned`(固定) |
|
|
43
|
+
|
|
44
|
+
## 使用
|
|
45
|
+
|
|
46
|
+
1. 手机 App 扫码配对
|
|
47
|
+
2. 手机端拍照(可选裁剪)→ 图片进入当前 dsh 会话
|
|
48
|
+
3. 手机端「启动预览」→ 电脑端悬浮小窗实时取景;多设备时手机端「设为主机」抢占预览
|
|
49
|
+
|
|
50
|
+
详见仓库根 [README](../../README.md) 与 `docs/`。
|
|
51
|
+
|
|
52
|
+
## 开发
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pnpm build # tsdown 构建 lib/index.js(host)与 dev.js
|
|
56
|
+
pnpm dev:standalone # 不进入 dsh,独立起接收服务(上传落盘 ~/.dsh/phone-lens/uploads)
|
|
57
|
+
node --check lib/client.js # 手写 client 的语法校验
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
协议与安全设计见 `docs/protocol.md`、`docs/architecture.md`。
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# phone-lens bundle patch: inserts the host-side plugin row into the profile.
|
|
2
|
+
# Applied via `dsh plugin --profile web add <path-to-packages/phone-lens>`.
|
|
3
|
+
# All config keys mirror src/config.ts defaults; a deployment overrides them
|
|
4
|
+
# in its own profile layer without touching this file.
|
|
5
|
+
|
|
6
|
+
- insert:
|
|
7
|
+
- id: phone-lens
|
|
8
|
+
name: 'phone-lens'
|
|
9
|
+
config:
|
|
10
|
+
server:
|
|
11
|
+
host: '0.0.0.0'
|
|
12
|
+
port: 8791
|
|
13
|
+
limits:
|
|
14
|
+
maxUploadBytes: 10485760
|
|
15
|
+
allowedTypes:
|
|
16
|
+
- 'image/jpeg'
|
|
17
|
+
- 'image/png'
|
|
18
|
+
uploadsPerMinute: 10
|
|
19
|
+
previewFrameMaxBytes: 524288
|
|
20
|
+
pairing:
|
|
21
|
+
codeTtlMs: 900000
|
|
22
|
+
preview:
|
|
23
|
+
maxWidth: 854
|
|
24
|
+
maxHeight: 480
|
|
25
|
+
fps: 10
|
|
26
|
+
jpegQuality: 70
|
|
27
|
+
inject:
|
|
28
|
+
mode: 'followup'
|
|
29
|
+
notePrefix: '[手机拍照]'
|
|
30
|
+
target:
|
|
31
|
+
mode: 'latest'
|
|
32
|
+
pinnedSessionId: null
|
|
33
|
+
app:
|
|
34
|
+
# 手机 App 下载二维码指向的地址;留空走内置默认(Gitee 发行版直链,国内快)
|
|
35
|
+
downloadUrl: null
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* phone-lens browser half — the floating viewfinder window inside the dsh Web UI.
|
|
3
|
+
*
|
|
4
|
+
* Registers into the additive `shell.overlay` slot (list kind, frame-wide
|
|
5
|
+
* floating layer). Speaks the same protocol as /view.html:
|
|
6
|
+
* WS ws://127.0.0.1:<port>/ws/view (frames + events + shutter control)
|
|
7
|
+
* GET http://127.0.0.1:<port>/qr.json (pairing QR; loopback CORS is allowed)
|
|
8
|
+
*
|
|
9
|
+
* No bundler pipeline: this file IS the artifact (lazy-CJS factory registered
|
|
10
|
+
* into window.__ModuleLoader__), react comes from the shell static table.
|
|
11
|
+
*/
|
|
12
|
+
window.__ModuleLoader__.load({
|
|
13
|
+
id: "phone-lens",
|
|
14
|
+
factory: (require) => {
|
|
15
|
+
var module = { exports: {} };
|
|
16
|
+
var exports = module.exports;
|
|
17
|
+
const react = require("react");
|
|
18
|
+
const { useState, useEffect, useRef } = react;
|
|
19
|
+
|
|
20
|
+
const NS = "phone-lens";
|
|
21
|
+
const PORT_KEY = "phoneLens.port";
|
|
22
|
+
// App download fallbacks (used when /app-qr.json is unavailable on an older host).
|
|
23
|
+
// Gitee release carrying the newest APK (App unchanged since v0.2.0).
|
|
24
|
+
const APP_GITEE = "https://gitee.com/qianfengbingtang/phone-lens/releases/download/v0.2.0/app-release.apk";
|
|
25
|
+
const APP_GITHUB = "https://github.com/yxqfg/phone-lens/releases/latest/download/app-release.apk";
|
|
26
|
+
const h = react.createElement;
|
|
27
|
+
|
|
28
|
+
// ── styles ────────────────────────────────────────────────────────────
|
|
29
|
+
const styleSheet = document.createElement("style");
|
|
30
|
+
styleSheet.id = `${NS}/overlay.css`;
|
|
31
|
+
styleSheet.textContent = `
|
|
32
|
+
.lm-root { position: fixed; right: 16px; bottom: 16px; z-index: 9999; font-family: system-ui, "Segoe UI", "Microsoft YaHei", sans-serif; }
|
|
33
|
+
.lm-fab { width: 46px; height: 46px; border-radius: 50%; border: 1px solid #3b7cb5; background: linear-gradient(160deg, #2a5d8f, #1d3f61); color: #eaf2fa; font-size: 20px; line-height: 1; cursor: pointer; box-shadow: 0 4px 14px rgba(0,0,0,.45); }
|
|
34
|
+
.lm-fab:hover { filter: brightness(1.15); }
|
|
35
|
+
.lm-fab .dot { position: absolute; top: 4px; right: 4px; width: 8px; height: 8px; border-radius: 50%; background: #e5484d; }
|
|
36
|
+
.lm-fab .dot.on { background: #46a758; }
|
|
37
|
+
.lm-panel { 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,.55); color: #dfe7ee; overflow: hidden; }
|
|
38
|
+
.lm-head { display: flex; align-items: center; gap: 6px; padding: 8px 10px; background: #1a2129; border-bottom: 1px solid #232c36; font-size: 12px; }
|
|
39
|
+
.lm-head b { font-weight: 600; }
|
|
40
|
+
.lm-head .x { margin-left: auto; background: none; border: none; color: #8fa1b5; cursor: pointer; font-size: 14px; }
|
|
41
|
+
.lm-body { padding: 10px; display: flex; flex-direction: column; gap: 8px; }
|
|
42
|
+
.lm-canvas { width: 100%; height: auto; max-height: 300px; background: #000; border-radius: 8px; display: block; object-fit: contain; }
|
|
43
|
+
.lm-status { font-size: 11px; color: #8fa1b5; display: flex; gap: 8px; align-items: center; min-height: 15px; }
|
|
44
|
+
.lm-status .ok { color: #58b368; }
|
|
45
|
+
.lm-status .warn { color: #d9a441; }
|
|
46
|
+
.lm-row { display: flex; gap: 6px; }
|
|
47
|
+
.lm-btn { flex: 1; padding: 7px 8px; border-radius: 8px; border: 1px solid #2f3b48; background: #1f2937; color: #dfe7ee; font-size: 12px; cursor: pointer; }
|
|
48
|
+
.lm-btn:hover { background: #28323e; }
|
|
49
|
+
.lm-btn:disabled { opacity: .45; cursor: default; }
|
|
50
|
+
.lm-btn.primary { background: #2a5d8f; border-color: #3b7cb5; }
|
|
51
|
+
.lm-qr { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 8px; background: #10141a; border: 1px dashed #2a333e; border-radius: 8px; }
|
|
52
|
+
.lm-qr img { width: 148px; height: 148px; image-rendering: pixelated; background: #fff; border-radius: 6px; }
|
|
53
|
+
.lm-qr .code { font-size: 16px; letter-spacing: .18em; }
|
|
54
|
+
.lm-qr .hint { font-size: 10px; color: #7d8da0; text-align: center; line-height: 1.45; }
|
|
55
|
+
.lm-qr a.lm-dl { display: block; margin-top: 4px; font-size: 11px; color: #3b7cb5; text-decoration: none; }
|
|
56
|
+
.lm-qr a.lm-dl:hover { text-decoration: underline; }
|
|
57
|
+
.lm-port { display: flex; gap: 6px; align-items: center; font-size: 11px; color: #7d8da0; }
|
|
58
|
+
.lm-port input { width: 64px; background: #0d1117; color: #dfe7ee; border: 1px solid #2f3b48; border-radius: 6px; padding: 3px 6px; font-size: 12px; }
|
|
59
|
+
.lm-flash { font-size: 11px; color: #8fa1b5; min-height: 14px; }
|
|
60
|
+
.lm-flash.ok { color: #58b368; }
|
|
61
|
+
.lm-flash.err { color: #e5696e; }
|
|
62
|
+
.lm-pending { display: flex; flex-direction: column; gap: 4px; padding: 6px; background: #10141a; border: 1px dashed #2a333e; border-radius: 8px; }
|
|
63
|
+
.lm-pending-label { font-size: 10px; color: #7d8da0; }
|
|
64
|
+
.lm-devices { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
65
|
+
.lm-device { flex: none; padding: 4px 8px; border-radius: 14px; border: 1px solid #2f3b48; background: #1f2937; color: #8fa1b5; font-size: 11px; cursor: pointer; }
|
|
66
|
+
.lm-device.active { border-color: #46a758; color: #d2f5d8; background: #1f4a2c; }
|
|
67
|
+
.lm-device:hover { background: #28323e; }
|
|
68
|
+
.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; }
|
|
69
|
+
.lm-rename-title { font-size: 13px; color: #dfe7ee; font-weight: 600; }
|
|
70
|
+
.lm-rename-input { background: #0d1117; color: #dfe7ee; border: 1px solid #2f3b48; border-radius: 8px; padding: 8px; font-size: 13px; }
|
|
71
|
+
.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; }
|
|
72
|
+
.lm-appqr-title { font-size: 13px; color: #dfe7ee; font-weight: 600; }
|
|
73
|
+
.lm-appqr-img { width: 168px; height: 168px; image-rendering: pixelated; background: #fff; border-radius: 8px; }
|
|
74
|
+
.lm-appqr-hint { font-size: 10px; color: #7d8da0; text-align: center; line-height: 1.45; }
|
|
75
|
+
.lm-appqr-links { font-size: 11px; display: flex; gap: 5px; align-items: center; color: #7d8da0; }
|
|
76
|
+
.lm-appqr-links a { color: #3b7cb5; text-decoration: none; }
|
|
77
|
+
.lm-appqr-links a:hover { text-decoration: underline; }
|
|
78
|
+
.fab-cam { display: inline-flex; align-items: center; justify-content: center; }
|
|
79
|
+
.fab-cam svg { display: block; }
|
|
80
|
+
@keyframes lm-blast { 0%,100% { background: #6cc0ff; box-shadow: 0 0 16px rgba(108,192,255,.9); } 50% { background: #2a6db8; box-shadow: 0 0 4px rgba(108,192,255,.2); } }
|
|
81
|
+
.lm-fab.connected { background: #1f2937; color: #46a758; }
|
|
82
|
+
.lm-fab.connecting { color: #ffffff; background: #6cc0ff; animation: lm-blast .5s ease-in-out 3; }
|
|
83
|
+
.lm-fab.disconnected { background: #23303c; color: #8fa1b5; }
|
|
84
|
+
.lm-status .warn-big { color: #d9a441; font-weight: 600; font-size: 11px; line-height: 1.4; white-space: normal; }
|
|
85
|
+
`;
|
|
86
|
+
// The factory runs at materialization (first import), so the document
|
|
87
|
+
// head is guaranteed to exist; remove-then-add keeps HMR idempotent.
|
|
88
|
+
{
|
|
89
|
+
const prior = document.getElementById(styleSheet.id);
|
|
90
|
+
if (prior) prior.remove();
|
|
91
|
+
document.head.appendChild(styleSheet);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── helpers ───────────────────────────────────────────────────────────
|
|
95
|
+
function drawJpeg(canvas, arrayBuffer, rotation) {
|
|
96
|
+
if (!canvas) return;
|
|
97
|
+
const blob = new Blob([arrayBuffer], { type: "image/jpeg" });
|
|
98
|
+
const url = URL.createObjectURL(blob);
|
|
99
|
+
const img = new Image();
|
|
100
|
+
img.onload = () => {
|
|
101
|
+
const r = ((rotation % 360) + 360) % 360;
|
|
102
|
+
const swap = r === 90 || r === 270;
|
|
103
|
+
const w = swap ? img.height : img.width;
|
|
104
|
+
const h = swap ? img.width : img.height;
|
|
105
|
+
if (canvas.width !== w || canvas.height !== h) {
|
|
106
|
+
canvas.width = w;
|
|
107
|
+
canvas.height = h;
|
|
108
|
+
}
|
|
109
|
+
const ctx = canvas.getContext("2d");
|
|
110
|
+
ctx.save();
|
|
111
|
+
ctx.translate(w / 2, h / 2);
|
|
112
|
+
ctx.rotate((r * Math.PI) / 180);
|
|
113
|
+
ctx.drawImage(img, -img.width / 2, -img.height / 2);
|
|
114
|
+
ctx.restore();
|
|
115
|
+
URL.revokeObjectURL(url);
|
|
116
|
+
};
|
|
117
|
+
img.onerror = () => URL.revokeObjectURL(url);
|
|
118
|
+
img.src = url;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── composable pre-send (dsh composer draft) ───────────────────────────
|
|
122
|
+
// 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).
|
|
126
|
+
let hostCtx = null; // set in apply() so the component can reach the runtime
|
|
127
|
+
|
|
128
|
+
function currentSessionId(ctx) {
|
|
129
|
+
try {
|
|
130
|
+
// cordis: accessing a service as a ctx property requires it in
|
|
131
|
+
// `inject`; use ctx.get() instead (never throws for absent).
|
|
132
|
+
const sessions = ctx.get("sessions");
|
|
133
|
+
if (!sessions) throw new Error("no sessions service");
|
|
134
|
+
const list = sessions.list;
|
|
135
|
+
if (!list) throw new Error("no sessions.list");
|
|
136
|
+
const snap = typeof list.getSnapshot === "function" ? list.getSnapshot() : list.snapshot;
|
|
137
|
+
if (!snap) throw new Error("no session list snapshot");
|
|
138
|
+
return snap.current;
|
|
139
|
+
} catch (e) {
|
|
140
|
+
throw new Error("currentSessionId: " + String(e));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function stageIntoComposer(ctx, port, attachmentId) {
|
|
145
|
+
if (!ctx) throw new Error("no runtime ctx (phone-lens host not wired)");
|
|
146
|
+
const resp = await fetch(`http://127.0.0.1:${port}/pending/${attachmentId}`).catch((e) => { throw new Error("fetch pending: " + String(e)); });
|
|
147
|
+
if (!resp.ok) throw new Error("pending fetch HTTP " + resp.status);
|
|
148
|
+
const buf = await resp.arrayBuffer();
|
|
149
|
+
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?)");
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Minimal single-line camera glyph; state expressed by accent + a small
|
|
167
|
+
// overlay mark (slash = disconnected, green dot = connected, pulse =
|
|
168
|
+
// connecting). Keeps the FAB clean instead of an emoji.
|
|
169
|
+
function CameraIcon({ state }) {
|
|
170
|
+
const cls = "fab-cam" + (state === "connected" ? " connected" : state === "connecting" ? " connecting" : " disconnected");
|
|
171
|
+
return h(
|
|
172
|
+
"span",
|
|
173
|
+
{ className: cls },
|
|
174
|
+
h(
|
|
175
|
+
"svg",
|
|
176
|
+
{ viewBox: "0 0 24 24", width: 24, height: 24, fill: "none", stroke: "currentColor", strokeWidth: 1.7, strokeLinecap: "round", strokeLinejoin: "round" },
|
|
177
|
+
h("path", { d: "M4 8h3l1.7-2.3h6.6L17 8h3a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1Z" }),
|
|
178
|
+
h("circle", { cx: 12, cy: 13.4, r: 3.4 }),
|
|
179
|
+
state === "connected" ? h("circle", { cx: 12, cy: 13.4, r: 1.5, fill: "#46a758", stroke: "none" }) : null,
|
|
180
|
+
state === "disconnected" ? h("path", { d: "M3.5 3.5l17 17", stroke: "#e5484d", strokeWidth: 1.9 }) : null,
|
|
181
|
+
),
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function LensOverlay(_props) {
|
|
186
|
+
const [open, setOpen] = useState(false);
|
|
187
|
+
const [port, setPort] = useState(() => {
|
|
188
|
+
try {
|
|
189
|
+
return window.localStorage.getItem(PORT_KEY) || "8791";
|
|
190
|
+
} catch {
|
|
191
|
+
return "8791";
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
const [portDraft, setPortDraft] = useState(port);
|
|
195
|
+
const [camOn, setCamOn] = useState(false);
|
|
196
|
+
const [link, setLink] = useState("disconnected"); // disconnected | connecting | connected
|
|
197
|
+
const [fps, setFps] = useState(0);
|
|
198
|
+
const [qr, setQr] = useState(null);
|
|
199
|
+
const [, setTick] = useState(0); // 1s heartbeat: re-renders the QR countdown while pairing is visible
|
|
200
|
+
const [flash, setFlash] = useState("");
|
|
201
|
+
const [pending, setPending] = useState([]); // [{id, name}] waiting to be staged
|
|
202
|
+
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})
|
|
205
|
+
const canvasRef = useRef(null);
|
|
206
|
+
const lastQrCode = useRef(null); // last pairing code seen — detects auto rotation for the "已更新" flash
|
|
207
|
+
const wsRef = useRef(null);
|
|
208
|
+
const framesRef = useRef(0);
|
|
209
|
+
const rotRef = useRef(0);
|
|
210
|
+
|
|
211
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
212
|
+
const wsUrl = `ws://127.0.0.1:${port}/ws/view`;
|
|
213
|
+
|
|
214
|
+
// view websocket: ALWAYS connected (independent of panel open state),
|
|
215
|
+
// so the connection status and pending-photo events refresh even when
|
|
216
|
+
// the FAB is collapsed. Frames are only drawn when the canvas exists.
|
|
217
|
+
useEffect(() => {
|
|
218
|
+
let stopped = false;
|
|
219
|
+
let timer = null;
|
|
220
|
+
const connect = () => {
|
|
221
|
+
if (stopped) return;
|
|
222
|
+
let ws;
|
|
223
|
+
try {
|
|
224
|
+
ws = new WebSocket(wsUrl);
|
|
225
|
+
} catch {
|
|
226
|
+
timer = setTimeout(connect, 2000);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
ws.binaryType = "arraybuffer";
|
|
230
|
+
wsRef.current = ws;
|
|
231
|
+
ws.onopen = () => setFlash("");
|
|
232
|
+
ws.onmessage = (ev) => {
|
|
233
|
+
if (ev.data instanceof ArrayBuffer) {
|
|
234
|
+
framesRef.current++;
|
|
235
|
+
drawJpeg(canvasRef.current, ev.data, rotRef.current);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
let m;
|
|
239
|
+
try {
|
|
240
|
+
m = JSON.parse(ev.data);
|
|
241
|
+
} catch {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (m.type === "meta" || m.type === "device") {
|
|
245
|
+
const on = m.type === "meta" ? m.camera.connected : m.online;
|
|
246
|
+
setCamOn(Boolean(on));
|
|
247
|
+
setLink((prev) => {
|
|
248
|
+
if (!on) return "disconnected";
|
|
249
|
+
return prev === "disconnected" ? "connecting" : prev;
|
|
250
|
+
});
|
|
251
|
+
if (m.type === "meta" && m.camera.rotation !== undefined) rotRef.current = m.camera.rotation;
|
|
252
|
+
} else if (m.type === "frame_meta") {
|
|
253
|
+
if (m.rotation !== undefined) rotRef.current = m.rotation;
|
|
254
|
+
} else if (m.type === "injected") {
|
|
255
|
+
if (m.ok) setFlashOk(`已注入会话 ${(m.sessionId || "").slice(0, 8)}…`);
|
|
256
|
+
else setFlashErr(`已保存到电脑 ✓(未注入:${m.reason || "无活动会话"})`);
|
|
257
|
+
} else if (m.type === "upload") {
|
|
258
|
+
setFlashOk("图片已入库…");
|
|
259
|
+
} else if (m.type === "pending_image") {
|
|
260
|
+
// pre-send: stage this photo into the composer draft
|
|
261
|
+
setPending((p) => [...p, { id: m.attachmentId, name: m.name || "" }]);
|
|
262
|
+
stageIntoComposer(hostCtx, port, m.attachmentId)
|
|
263
|
+
.then(() => {
|
|
264
|
+
setPending((p) => p.filter((x) => x.id !== m.attachmentId));
|
|
265
|
+
setFlashOk("照片已放入对话框输入框,输入文字后发送");
|
|
266
|
+
})
|
|
267
|
+
.catch((e) => setFlashErr("自动放入失败: " + String(e && e.message || e).slice(0, 90)));
|
|
268
|
+
} else if (m.type === "devices") {
|
|
269
|
+
setDevices((prev) => {
|
|
270
|
+
// a NEW device came online → the one-time pairing code was
|
|
271
|
+
// consumed; rotate the QR so the next scan sees a fresh code
|
|
272
|
+
if ((m.devices || []).length > (prev ? prev.length : 0)) void refreshQr(true);
|
|
273
|
+
return m.devices || [];
|
|
274
|
+
});
|
|
275
|
+
// an active device present == camera linked; derive both the
|
|
276
|
+
// canvas visibility and the header/FAB link state from the
|
|
277
|
+
// device list (host no longer emits `device`)
|
|
278
|
+
const active = (m.devices || []).find((d) => d.active);
|
|
279
|
+
setCamOn(Boolean(active));
|
|
280
|
+
setLink((prev) => {
|
|
281
|
+
if (!active) return "disconnected";
|
|
282
|
+
// fresh link plays the triple-flash greeting
|
|
283
|
+
if (prev === "disconnected") return "connecting";
|
|
284
|
+
return prev;
|
|
285
|
+
});
|
|
286
|
+
} else if (m.type === "error") {
|
|
287
|
+
setFlashErr(`${m.code}${m.message ? ":" + m.message : ""}`);
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
ws.onclose = () => {
|
|
291
|
+
setCamOn(false);
|
|
292
|
+
if (!stopped) timer = setTimeout(connect, 2000);
|
|
293
|
+
};
|
|
294
|
+
};
|
|
295
|
+
connect();
|
|
296
|
+
return () => {
|
|
297
|
+
stopped = true;
|
|
298
|
+
if (timer) clearTimeout(timer);
|
|
299
|
+
const ws = wsRef.current;
|
|
300
|
+
if (ws) {
|
|
301
|
+
ws.onclose = null;
|
|
302
|
+
try {
|
|
303
|
+
ws.close();
|
|
304
|
+
} catch {}
|
|
305
|
+
}
|
|
306
|
+
wsRef.current = null;
|
|
307
|
+
};
|
|
308
|
+
}, [wsUrl]);
|
|
309
|
+
|
|
310
|
+
// connecting → connected after the 1.5s triple-flash greeting
|
|
311
|
+
useEffect(() => {
|
|
312
|
+
if (link !== "connecting") return;
|
|
313
|
+
const t = setTimeout(() => setLink("connected"), 1500);
|
|
314
|
+
return () => clearTimeout(t);
|
|
315
|
+
}, [link]);
|
|
316
|
+
|
|
317
|
+
// fps meter
|
|
318
|
+
useEffect(() => {
|
|
319
|
+
if (!open) return;
|
|
320
|
+
const iv = setInterval(() => {
|
|
321
|
+
setFps(framesRef.current);
|
|
322
|
+
framesRef.current = 0;
|
|
323
|
+
}, 1000);
|
|
324
|
+
return () => clearInterval(iv);
|
|
325
|
+
}, [open]);
|
|
326
|
+
|
|
327
|
+
function setFlashOk(t) {
|
|
328
|
+
setFlash(t);
|
|
329
|
+
}
|
|
330
|
+
function setFlashErr(t) {
|
|
331
|
+
setFlash(t);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function refreshQr(auto) {
|
|
335
|
+
try {
|
|
336
|
+
const r = await fetch(`${baseUrl}/qr.json`, { cache: "no-store" });
|
|
337
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
338
|
+
const data = await r.json();
|
|
339
|
+
// surfaced only on automatic rotation, so the user sees the code change
|
|
340
|
+
if (auto && lastQrCode.current && lastQrCode.current !== data.code) setFlashOk("二维码已更新,请扫描最新码");
|
|
341
|
+
lastQrCode.current = data.code;
|
|
342
|
+
setQr(data);
|
|
343
|
+
} catch (e) {
|
|
344
|
+
setFlashErr(`接收服务不可达(${String(e).slice(0, 60)})`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Open the app-download dialog: QR (from the host) + source links.
|
|
349
|
+
// Falls back to the built-in Gitee/GitHub links when the endpoint is
|
|
350
|
+
// missing (older host half running an older release).
|
|
351
|
+
async function openAppQr() {
|
|
352
|
+
setAppQr({ loading: true });
|
|
353
|
+
try {
|
|
354
|
+
const r = await fetch(`${baseUrl}/app-qr.json`, { cache: "no-store" });
|
|
355
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
356
|
+
setAppQr(await r.json());
|
|
357
|
+
} catch (_) {
|
|
358
|
+
setAppQr({ fallback: true, gitee: APP_GITEE, github: APP_GITHUB });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// pairing QR lifecycle: fetch on first show, then keep itself alive —
|
|
363
|
+
// refreshes right after expiry (one-time codes burn after 15min),
|
|
364
|
+
// every 30s as a drift/sleep safety net, and beats a 1s heart so the
|
|
365
|
+
// countdown stays honest.
|
|
366
|
+
useEffect(() => {
|
|
367
|
+
if (!open || camOn) return;
|
|
368
|
+
if (!qr) {
|
|
369
|
+
void refreshQr();
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
const remainMs = qr.expiresAt - Date.now();
|
|
373
|
+
const expireTimer = setTimeout(() => void refreshQr(true), Math.max(remainMs, 0) + 800);
|
|
374
|
+
const driftTimer = setInterval(() => void refreshQr(true), 30_000);
|
|
375
|
+
const heartbeat = setInterval(() => setTick((t) => (t + 1) % 1e9), 1000);
|
|
376
|
+
return () => {
|
|
377
|
+
clearTimeout(expireTimer);
|
|
378
|
+
clearInterval(driftTimer);
|
|
379
|
+
clearInterval(heartbeat);
|
|
380
|
+
};
|
|
381
|
+
}, [open, camOn, qr]);
|
|
382
|
+
|
|
383
|
+
function shoot() {
|
|
384
|
+
const ws = wsRef.current; if (ws && ws.readyState === 1) {
|
|
385
|
+
ws.send(JSON.stringify({ type: "capture" }));
|
|
386
|
+
setFlash("快门已触发,等待手机上传…");
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function selectDevice(id) {
|
|
391
|
+
const ws = wsRef.current;
|
|
392
|
+
if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: "select_device", deviceId: id }));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function renameDevice(id, current) {
|
|
396
|
+
setRename({ id, name: current || "" });
|
|
397
|
+
}
|
|
398
|
+
function confirmRename() {
|
|
399
|
+
const ws = wsRef.current;
|
|
400
|
+
const trimmed = String((rename && rename.name) || "").trim();
|
|
401
|
+
if (trimmed && ws && ws.readyState === 1) ws.send(JSON.stringify({ type: "rename_device", deviceId: rename.id, name: trimmed }));
|
|
402
|
+
setRename(null);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function savePort() {
|
|
406
|
+
const p = String(portDraft).trim() || "8791";
|
|
407
|
+
setPort(p);
|
|
408
|
+
try {
|
|
409
|
+
window.localStorage.setItem(PORT_KEY, p);
|
|
410
|
+
} catch {}
|
|
411
|
+
setFlash(`端口已切换为 ${p}`);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return h(
|
|
415
|
+
"div",
|
|
416
|
+
{ className: "lm-root" },
|
|
417
|
+
open
|
|
418
|
+
? h(
|
|
419
|
+
"div",
|
|
420
|
+
{ className: "lm-panel" },
|
|
421
|
+
h(
|
|
422
|
+
"div",
|
|
423
|
+
{ className: "lm-head" },
|
|
424
|
+
h("b", null, "PhoneLens 直连取景"),
|
|
425
|
+
h("span", { className: link === "connected" ? "ok" : "warn", style: { fontSize: 11 } }, link === "connected" ? "已连接" : link === "connecting" ? "连接中…" : "未连接"),
|
|
426
|
+
h("button", { className: "x", onClick: () => setOpen(false), title: "收起" }, "✕"),
|
|
427
|
+
),
|
|
428
|
+
h(
|
|
429
|
+
"div",
|
|
430
|
+
{ 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 ? "预览中" : "手机未连接!相同局域网下扫码添加配对设备;若已配对过的手机,请在设置内点击本电脑端,激活为活动设备")),
|
|
435
|
+
devices && devices.length > 1
|
|
436
|
+
? h(
|
|
437
|
+
"div",
|
|
438
|
+
{ className: "lm-devices" },
|
|
439
|
+
devices.map((d) =>
|
|
440
|
+
h(
|
|
441
|
+
"button",
|
|
442
|
+
{ key: d.id, className: "lm-device" + (d.active ? " active" : ""), onClick: () => selectDevice(d.id), onDoubleClick: () => renameDevice(d.id, d.name), title: "单击切换 · 双击重命名" },
|
|
443
|
+
(d.active ? "● " : "○ ") + d.name,
|
|
444
|
+
),
|
|
445
|
+
),
|
|
446
|
+
)
|
|
447
|
+
: null,
|
|
448
|
+
pending && pending.length
|
|
449
|
+
? h(
|
|
450
|
+
"div",
|
|
451
|
+
{ className: "lm-pending" },
|
|
452
|
+
h("span", { className: "lm-pending-label" }, `待发图 ×${pending.length}:`),
|
|
453
|
+
pending.map((p) =>
|
|
454
|
+
h(
|
|
455
|
+
"div",
|
|
456
|
+
{ key: p.id, className: "lm-row" },
|
|
457
|
+
h("span", { style: { fontSize: 11, color: "#7d8da0", flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, p.name || p.id.slice(0, 8)),
|
|
458
|
+
h(
|
|
459
|
+
"button",
|
|
460
|
+
{ className: "lm-btn", onClick: () => void stageIntoComposer(hostCtx, port, p.id).then(() => { setPending((prev) => prev.filter((x) => x.id !== p.id)); setFlashOk("已放入输入框"); }).catch((e) => setFlashErr("放入失败: " + String(e && e.message || e).slice(0, 90))) },
|
|
461
|
+
"放入输入框",
|
|
462
|
+
),
|
|
463
|
+
),
|
|
464
|
+
),
|
|
465
|
+
)
|
|
466
|
+
: null,
|
|
467
|
+
h(
|
|
468
|
+
"div",
|
|
469
|
+
{ className: "lm-row" },
|
|
470
|
+
h("button", { className: "lm-btn primary", disabled: !camOn, onClick: shoot }, "◉ 拍照并放入输入框"),
|
|
471
|
+
h("button", { className: "lm-btn", onClick: () => void refreshQr() }, "↻ 二维码"),
|
|
472
|
+
),
|
|
473
|
+
!camOn
|
|
474
|
+
? h(
|
|
475
|
+
"div",
|
|
476
|
+
{ className: "lm-qr" },
|
|
477
|
+
qr && qr.pngDataUrl ? h("img", { src: qr.pngDataUrl, alt: "配对二维码" }) : h("div", { className: "hint" }, "二维码加载中…"),
|
|
478
|
+
qr ? h("div", { className: "code" }, qr.code) : null,
|
|
479
|
+
h(
|
|
480
|
+
"div",
|
|
481
|
+
{ className: "hint" },
|
|
482
|
+
"手机 App 扫码配对;或在 App 内手动输入",
|
|
483
|
+
h("br", null),
|
|
484
|
+
qr && qr.urls && qr.urls[0] ? `${qr.urls[0].replace("http://", "")}:${qr.code}` : "",
|
|
485
|
+
h("br", null),
|
|
486
|
+
qr
|
|
487
|
+
? (() => {
|
|
488
|
+
const s = Math.max(0, Math.floor((qr.expiresAt - Date.now()) / 1000));
|
|
489
|
+
return s > 0
|
|
490
|
+
? `有效期剩余 ${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")} · 到期自动更新`
|
|
491
|
+
: "已到期,正在自动更新…";
|
|
492
|
+
})()
|
|
493
|
+
: "",
|
|
494
|
+
),
|
|
495
|
+
h("a", { className: "lm-dl", href: "#", onClick: (e) => { e.preventDefault(); void openAppQr(); }, title: "弹出二维码,手机扫码下载" }, "手机还没装 App?点此扫码下载"),
|
|
496
|
+
)
|
|
497
|
+
: null,
|
|
498
|
+
h("span", { className: `lm-flash${flash.startsWith("已") ? " ok" : ""}` }, flash),
|
|
499
|
+
h(
|
|
500
|
+
"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 }, "切换"),
|
|
505
|
+
),
|
|
506
|
+
),
|
|
507
|
+
)
|
|
508
|
+
: null,
|
|
509
|
+
h(
|
|
510
|
+
"button",
|
|
511
|
+
{ className: "lm-fab" + (link === "connected" ? " connected" : link === "connecting" ? " connecting" : " disconnected"), style: { position: "relative" }, onClick: () => setOpen(!open), title: "PhoneLens 直连取景" },
|
|
512
|
+
h(CameraIcon, { state: link }),
|
|
513
|
+
),
|
|
514
|
+
rename
|
|
515
|
+
? h(
|
|
516
|
+
"div",
|
|
517
|
+
{ className: "lm-rename" },
|
|
518
|
+
h("div", { className: "lm-rename-title" }, "重命名设备"),
|
|
519
|
+
h("input", { className: "lm-rename-input", value: rename.name, onChange: (e) => setRename({ ...rename, name: e.target.value }), onKeyDown: (e) => e.key === "Enter" && confirmRename(), autoFocus: true }),
|
|
520
|
+
h(
|
|
521
|
+
"div",
|
|
522
|
+
{ className: "lm-row" },
|
|
523
|
+
h("button", { className: "lm-btn", onClick: () => setRename(null) }, "取消"),
|
|
524
|
+
h("button", { className: "lm-btn primary", onClick: confirmRename }, "确定"),
|
|
525
|
+
),
|
|
526
|
+
)
|
|
527
|
+
: null,
|
|
528
|
+
appQr
|
|
529
|
+
? h(
|
|
530
|
+
"div",
|
|
531
|
+
{ className: "lm-appqr" },
|
|
532
|
+
h("div", { className: "lm-appqr-title" }, "扫码下载 PhoneLens App"),
|
|
533
|
+
appQr.loading
|
|
534
|
+
? h("div", { className: "lm-appqr-hint" }, "二维码生成中…")
|
|
535
|
+
: appQr.pngDataUrl
|
|
536
|
+
? h("img", { className: "lm-appqr-img", src: appQr.pngDataUrl, alt: "App 下载二维码" })
|
|
537
|
+
: h("div", { className: "lm-appqr-hint" }, "二维码暂不可用,请使用下方链接"),
|
|
538
|
+
h("div", { className: "lm-appqr-hint" }, appQr.fallback ? "手机端请通过以下链接下载 APK" : "手机扫码直接下载 Android APK(默认 Gitee 国内源)"),
|
|
539
|
+
h(
|
|
540
|
+
"div",
|
|
541
|
+
{ className: "lm-appqr-links" },
|
|
542
|
+
h("a", { href: appQr.gitee || APP_GITEE, target: "_blank", rel: "noopener" }, "Gitee 国内源"),
|
|
543
|
+
h("span", null, "·"),
|
|
544
|
+
h("a", { href: appQr.github || APP_GITHUB, target: "_blank", rel: "noopener" }, "GitHub 国际源"),
|
|
545
|
+
),
|
|
546
|
+
h("div", { className: "lm-row", style: { width: "100%" } }, h("button", { className: "lm-btn", onClick: () => setAppQr(null) }, "关闭")),
|
|
547
|
+
)
|
|
548
|
+
: null,
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// ── plugin face ───────────────────────────────────────────────────────
|
|
553
|
+
const inject = ["slots"];
|
|
554
|
+
function apply(ctx) {
|
|
555
|
+
hostCtx = ctx;
|
|
556
|
+
ctx.slots.inject("shell.overlay", () =>
|
|
557
|
+
ctx.slots.register(
|
|
558
|
+
{ name: "shell.overlay", id: "phone-lens.overlay", order: 100, label: () => "PhoneLens" },
|
|
559
|
+
LensOverlay,
|
|
560
|
+
),
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
exports.apply = apply;
|
|
565
|
+
exports.inject = inject;
|
|
566
|
+
return module.exports;
|
|
567
|
+
},
|
|
568
|
+
});
|