dsh-mini-remote 0.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.
@@ -0,0 +1,387 @@
1
+ // dsh-mini-remote 网页客户端:在 DSH 设置页加一个「手机遥控」标签页。
2
+ //
3
+ // 手写,没有构建步骤:元素一律用 React.createElement 构造,样式全部内联。
4
+ // 外层 window.__ModuleLoader__.load({ id, factory }) 的写法照抄 dsh-pocket
5
+ // (见其 client/build.mjs 生成的 client/client.js),只把内容换成这里的。
6
+ //
7
+ // 数据来自同源 GET /mini-remote/pairing(host 侧 lib/index.js 注册的路由):
8
+ // { ok: true, port, token, entries: [{ kind, label, hint, url, qr }] }
9
+ // { ok: false, error: '……' } ← 403(不是本机)/ 503(还没起来)/ 500
10
+ // 注意:失败时 HTTP 状态码不是 200,但响应体仍是 JSON,里面带着真正的原因,
11
+ // 所以这里先解析响应体、再看 body.ok,不拿状态码覆盖原文。
12
+
13
+ window.__ModuleLoader__.load({
14
+ id: "dsh-mini-remote",
15
+ factory: (require) => {
16
+ var module = { exports: {} };
17
+ var exports = module.exports;
18
+ // 客户端模块系统把 react 当模块提供,不是全局,必须 require。
19
+ var React = require("react");
20
+
21
+ var h = React.createElement;
22
+ var useState = React.useState;
23
+ var useEffect = React.useEffect;
24
+
25
+ var PAIRING_PATH = '/mini-remote/pairing';
26
+ var TOKEN_PATH = '/mini-remote/token';
27
+ var TUNNEL_PATH = '/mini-remote/tunnel';
28
+
29
+ // 颜色走 DSH 主题变量(真名前缀是 --dsw-alias-,深浅色自动跟随)。
30
+ // 每个变量都带一个浅色兜底值,变量缺失时界面仍然可读。
31
+ var styles = {
32
+ card: { background: 'var(--dsw-alias-bg-layer-1,#fff)', border: '1px solid var(--dsw-alias-border-l2,#e5e7eb)', borderRadius: 12, padding: '16px 20px', maxWidth: 560 },
33
+ title: { fontWeight: 600, fontSize: 13, marginBottom: 4 },
34
+ muted: { color: 'var(--dsw-alias-label-tertiary,#8b93a1)', fontSize: 12, lineHeight: 1.5 },
35
+ warn: { color: 'var(--dsw-alias-state-warn-primary,#b45309)', fontSize: 12, lineHeight: 1.5, marginTop: 4 },
36
+ loading: { color: 'var(--dsw-alias-label-tertiary,#8b93a1)', fontSize: 13, marginTop: 12 },
37
+ error: { color: 'var(--dsw-alias-state-error-primary,#dc2626)', fontSize: 13, lineHeight: 1.6, marginTop: 12 },
38
+ block: { borderTop: '1px solid var(--dsw-alias-border-l2,#e5e7eb)', marginTop: 16, paddingTop: 16 },
39
+ entryTitle: { fontWeight: 600, fontSize: 13 },
40
+ link: { color: 'var(--dsw-alias-brand-primary,#2563eb)', fontSize: 12, textDecoration: 'underline', display: 'inline-block', marginTop: 4 },
41
+ entryRow: { display: 'flex', alignItems: 'flex-start', gap: 14, marginTop: 10, flexWrap: 'wrap' },
42
+ qr: { width: 180, height: 180, flex: 'none', borderRadius: 10, border: '1px solid var(--dsw-alias-border-l2,#e5e7eb)', background: '#fff' },
43
+ qrFallback: { width: 180, minHeight: 180, flex: 'none', boxSizing: 'border-box', padding: 12, borderRadius: 10, border: '1px dashed var(--dsw-alias-border-l3,#d1d5db)', color: 'var(--dsw-alias-label-tertiary,#8b93a1)', fontSize: 12, lineHeight: 1.5 },
44
+ urlCol: { flex: '1 1 220px', minWidth: 0, display: 'flex', flexDirection: 'column', gap: 8 },
45
+ input: { font: 'inherit', width: '100%', boxSizing: 'border-box', fontFamily: 'ui-monospace,Menlo,monospace', fontSize: 12, padding: '7px 9px', borderRadius: 8, border: '1px solid var(--dsw-alias-border-l2,#e5e7eb)', background: 'var(--dsw-alias-bg-base,#fff)', color: 'var(--dsw-alias-label-primary,inherit)' },
46
+ btn: { font: 'inherit', cursor: 'pointer', alignSelf: 'flex-start', height: 30, padding: '0 14px', borderRadius: 999, fontSize: 12, border: '1px solid var(--dsw-alias-button-ghost-active-border,var(--dsw-alias-border-l2,#d1d5db))', background: 'var(--dsw-alias-bg-layer-1,#fff)', color: 'var(--dsw-alias-label-primary,inherit)' },
47
+ toggleRow: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 10, fontSize: 13, cursor: 'pointer' },
48
+ // cloudflared 的报错是多行原文,换行要留住,否则一长条读不了。
49
+ detail: { color: 'var(--dsw-alias-state-warn-primary,#b45309)', fontSize: 12, lineHeight: 1.6, marginTop: 6, whiteSpace: 'pre-wrap', wordBreak: 'break-word' },
50
+ };
51
+
52
+ function MiniRemoteSettingsTab() {
53
+ var infoState = useState(null);
54
+ var info = infoState[0];
55
+ var setInfo = infoState[1];
56
+ var failState = useState(null);
57
+ var failure = failState[0];
58
+ var setFailure = failState[1];
59
+ var copyState = useState(null);
60
+ var copied = copyState[0];
61
+ var setCopied = copyState[1];
62
+ var busyState = useState(false);
63
+ var busy = busyState[0];
64
+ var setBusy = busyState[1];
65
+ // 密码默认打码。面板虽然只有本机能看,但屏幕边上站着人的时候很多——
66
+ // 一串明文密码摆在设置页上,截图、投屏、路过,都能带走它。
67
+ var showState = useState(false);
68
+ var showPwd = showState[0];
69
+ var setShowPwd = showState[1];
70
+ var draftState = useState('');
71
+ var draft = draftState[0];
72
+ var setDraft = draftState[1];
73
+ var pwdErrState = useState(null);
74
+ var pwdError = pwdErrState[0];
75
+ var setPwdError = pwdErrState[1];
76
+
77
+ // 同源 GET,不带任何 token:这台电脑自己读得到,别人的电脑读不到。
78
+ useEffect(function () {
79
+ var alive = true;
80
+ fetch(PAIRING_PATH)
81
+ .then(function (res) {
82
+ // 403 / 503 / 500 的响应体也是 JSON,里面写着原因,别按状态码丢掉。
83
+ return res.json().then(
84
+ function (body) { return { status: res.status, body: body }; },
85
+ function () { return { status: res.status, body: null }; },
86
+ );
87
+ })
88
+ .then(function (r) {
89
+ if (!alive) return;
90
+ if (r.body && typeof r.body === 'object') setInfo(r.body);
91
+ else setFailure('读不到配对信息:这台电脑上的服务返回了 HTTP ' + r.status + '。');
92
+ })
93
+ .catch(function (err) {
94
+ if (!alive) return;
95
+ setFailure('连不上这台电脑上的遥控服务:' + ((err && err.message) || String(err)));
96
+ });
97
+ return function () { alive = false; };
98
+ }, []);
99
+
100
+ // 「已复制」只停留一会儿,然后自己变回「复制」。
101
+ useEffect(function () {
102
+ if (!copied) return undefined;
103
+ var timer = setTimeout(function () { setCopied(null); }, 1500);
104
+ return function () { clearTimeout(timer); };
105
+ }, [copied]);
106
+
107
+ var copy = function (url) {
108
+ if (!url) return;
109
+ var clip = typeof navigator !== 'undefined' ? navigator.clipboard : null;
110
+ if (!clip || typeof clip.writeText !== 'function') return;
111
+ try {
112
+ var done = clip.writeText(url);
113
+ // 失败就保持原样(不谎报「已复制」)——输入框里的地址仍可手动全选。
114
+ if (done && typeof done.then === 'function') done.then(function () { setCopied(url); }, function () {});
115
+ else setCopied(url);
116
+ } catch (e) { /* 剪贴板不可用:保持原样 */ }
117
+ };
118
+
119
+ // 和服务端那道校验是同一个数。前端先拦一道只是为了让「太短了」当场说出来,
120
+ // 不用等一趟网络;真正的门在服务端,这里改了那边不改也进不去。
121
+ var MIN_PASSWORD_LEN = 12;
122
+
123
+ // 换一个自己设的密码。
124
+ var savePassword = function () {
125
+ var next = (draft || '').trim();
126
+ if (next.length < MIN_PASSWORD_LEN) {
127
+ setPwdError('密码至少要 ' + MIN_PASSWORD_LEN + ' 位,现在这串只有 ' + next.length + ' 位。');
128
+ return;
129
+ }
130
+ setPwdError(null);
131
+ setBusy(true);
132
+ fetch(TOKEN_PATH, {
133
+ method: 'POST',
134
+ headers: { 'content-type': 'application/json' },
135
+ body: JSON.stringify({ token: next }),
136
+ })
137
+ .then(function (res) {
138
+ return res.json().then(
139
+ function (body) { return { status: res.status, body: body }; },
140
+ function () { return { status: res.status, body: null }; },
141
+ );
142
+ })
143
+ .then(function (r) {
144
+ if (r.status === 200 && r.body && r.body.ok !== false) {
145
+ setDraft('');
146
+ setInfo(r.body);
147
+ } else {
148
+ setPwdError((r.body && r.body.error) || ('改密码失败:服务返回了 HTTP ' + r.status + '。'));
149
+ }
150
+ })
151
+ .catch(function (err) {
152
+ setPwdError('改密码失败:' + ((err && err.message) || String(err)));
153
+ })
154
+ .then(function () { setBusy(false); });
155
+ };
156
+
157
+ // 开/关公网访问。这个请求可能要等十几秒(第一次要下载 cloudflared),
158
+ // 所以期间把开关禁掉,免得连点几次起出好几条隧道。
159
+ var toggleTunnel = function (enabled) {
160
+ if (busy) return;
161
+ setBusy(true);
162
+ fetch(TUNNEL_PATH, {
163
+ method: 'POST',
164
+ headers: { 'content-type': 'application/json' },
165
+ body: JSON.stringify({ enabled: enabled }),
166
+ })
167
+ .then(function (res) {
168
+ return res.json().then(
169
+ function (body) { return { status: res.status, body: body }; },
170
+ function () { return { status: res.status, body: null }; },
171
+ );
172
+ })
173
+ .then(function (r) {
174
+ if (r.body && typeof r.body === 'object') { setInfo(r.body); setFailure(null); }
175
+ else setFailure('切换公网访问失败:服务返回了 HTTP ' + r.status + '。');
176
+ })
177
+ .catch(function (err) {
178
+ setFailure('切换公网访问失败:' + ((err && err.message) || String(err)));
179
+ })
180
+ .then(function () { setBusy(false); });
181
+ };
182
+
183
+ return h('div', { style: styles.card },
184
+ h('div', { style: styles.title }, '手机遥控'),
185
+ h('div', { style: styles.muted }, '手机扫码就能用,不用输密码——密码已经编在二维码里了。'),
186
+ h('div', { style: styles.warn }, '这个页面带着密码,别截图发出去。'),
187
+ content(info, failure, copied, copy, busy, toggleTunnel, {
188
+ showPwd: showPwd,
189
+ setShowPwd: setShowPwd,
190
+ draft: draft,
191
+ setDraft: setDraft,
192
+ busy: busy,
193
+ pwdError: pwdError,
194
+ savePassword: savePassword,
195
+ }),
196
+ );
197
+ }
198
+
199
+ function content(info, failure, copied, copy, busy, toggle, pwd) {
200
+ if (failure) return h('div', { style: styles.error }, failure);
201
+ if (!info) return h('div', { style: styles.loading }, '正在读取配对信息…');
202
+
203
+ var blocks = [];
204
+ if (info.ok === false) {
205
+ blocks.push(h('div', { key: 'err', style: styles.error }, info.error || '读取配对信息失败。'));
206
+ } else {
207
+ var entries = Array.isArray(info.entries) ? info.entries : [];
208
+ if (!entries.length) {
209
+ blocks.push(h('div', { key: 'none', style: styles.muted }, '这台电脑暂时没有手机能连上的地址。'));
210
+ } else {
211
+ for (var i = 0; i < entries.length; i += 1) blocks.push(entryBlock(entries[i], i, copied, copy));
212
+ }
213
+ // 密码紧跟在那几条二维码后面:它是这些链接背后的钥匙,放在一起才讲得通。
214
+ if (typeof info.token === 'string' && info.token) blocks.push(passwordBlock(info, copied, copy, pwd));
215
+ }
216
+ // Tailscale 那一行:**没探到的时候也要出现**。原来它是直接不显示的,
217
+ // 用户看到的不是「你没有 Tailscale」,而是「这里什么都没有」——他不知道
218
+ // 自己缺了什么,也就不会去装。而他恰恰是最需要这条路的人。
219
+ if (info.tailscale) blocks.push(tailscaleBlock(info.tailscale));
220
+ // 出错时也要把开关露出来:不然「隧道起不来」会让整个面板变成一条报错,
221
+ // 用户连关掉它的地方都找不到。
222
+ if (info.tunnel) blocks.push(tunnelBlock(info.tunnel, busy, toggle));
223
+ return h('div', null, blocks);
224
+ }
225
+
226
+ /**
227
+ * 没探到 Tailscale 时的那一行:说清楚缺什么、去哪儿拿。
228
+ *
229
+ * 「装了没登录」和「根本没装」分开说——这两种情况的下一步动作完全不同,
230
+ * 合成一句「请安装 Tailscale」会叫一个已经装了的人再去装一遍。
231
+ */
232
+ function tailscaleBlock(ts) {
233
+ var installed = Boolean(ts.installed);
234
+ return h('div', { key: 'tailscale', style: styles.block },
235
+ h('div', { style: styles.entryTitle }, '在外面用(Tailscale)'),
236
+ h('div', { style: styles.muted }, installed
237
+ ? '这台电脑装了 Tailscale,但没登录(或者没开)。登录之后,手机在外面用流量也能连进来。'
238
+ : '这台电脑上没装 Tailscale。装上并登录之后,手机在外面用流量也能连进来,不用连家里 Wi-Fi。'),
239
+ h('a', {
240
+ href: ts.download || 'https://tailscale.com/download',
241
+ target: '_blank',
242
+ rel: 'noreferrer',
243
+ style: styles.link,
244
+ }, installed ? '打开 Tailscale' : '下载 Tailscale'),
245
+ );
246
+ }
247
+
248
+ function tunnelBlock(tunnel, busy, toggle) {
249
+ var enabled = Boolean(tunnel.enabled);
250
+ var working = Boolean(busy || tunnel.starting);
251
+ var status;
252
+ if (working) status = '正在打开…第一次要先下载一个 50MB 左右的组件,可能要等一会儿。';
253
+ else if (tunnel.up) status = '已开启。上面那条「公网」就是出门用的地址。刚打开的话等半分钟再扫——Cloudflare 要先把这个域名公布出去。';
254
+ // 「一直没拿到地址」和「拿到过、后来断了」是两回事:前者多半还在等,后者得重开一次。
255
+ // 合成一句「还没拿到公网地址」的话,第二种情况的用户会一直等下去。
256
+ else if (enabled) status = tunnel.error
257
+ ? '开着,但公网那边连不上。手机现在走这条路会打不开——下面那段就是它说的话。'
258
+ : '已开启,但还没拿到公网地址。';
259
+ else status = '没开。手机不连家里 Wi-Fi 的时候就进不来(除非装了 Tailscale)。';
260
+
261
+ return h('div', { key: 'tunnel', style: styles.block },
262
+ h('div', { style: styles.entryTitle }, '公网访问'),
263
+ h('div', { style: styles.muted }, '不想装 Tailscale 的话就打开这个:走 Cloudflare 的免费隧道,手机在外面用流量也能连进来。'),
264
+ h('label', { style: styles.toggleRow },
265
+ h('input', {
266
+ type: 'checkbox',
267
+ checked: enabled,
268
+ // starting 也要禁掉:那说明**别的地方**(另一个标签页)正在起隧道,
269
+ // 这时候再点一下会起出第二条。
270
+ disabled: Boolean(busy || tunnel.starting),
271
+ onChange: function (e) { toggle(e.target.checked); },
272
+ }),
273
+ h('span', null, enabled ? '已开启' : '开启'),
274
+ ),
275
+ h('div', { style: enabled && tunnel.error ? styles.detail : styles.muted }, status),
276
+ // cloudflared 的原始报错,多行原文照登——排查时这就是全部线索。
277
+ enabled && tunnel.error ? h('div', { style: styles.detail }, tunnel.error) : null,
278
+ enabled ? h('div', { style: styles.warn }, '这个地址每次重启 DSH 都会换一个,换了之后回来重新扫一下码。') : null,
279
+ enabled ? h('div', { style: styles.warn }, '开着的时候这个服务就挂在公网上了。密码仍然是唯一的门,别把地址或二维码发出去。') : null,
280
+ );
281
+ }
282
+
283
+ /**
284
+ * 密码这一块。
285
+ *
286
+ * 以前这里只有一句「手机扫码就能用,不用输密码」——话没错,但用户既看不到密码本身,
287
+ * 也没法换一个自己记得住的。2026-09-22 用户报:「手机遥控页面里似乎根本没有密码,
288
+ * 也无法自定义密码」。配对接口其实一直返回着 token,只是从来没人渲染它。
289
+ */
290
+ function passwordBlock(info, copied, copy, pwd) {
291
+ var token = typeof info.token === 'string' ? info.token : '';
292
+ var shown = pwd.showPwd ? token : token.replace(/./g, '•');
293
+ var done = token !== '' && copied === token;
294
+ var pending = typeof info.pendingToken === 'string' ? info.pendingToken : '';
295
+ return h('div', { key: 'password', style: styles.block },
296
+ h('div', { style: styles.entryTitle }, '密码'),
297
+ h('div', { style: styles.muted }, '手机扫码时已经带着它了,不用手输。它就是这道门的钥匙——别截图发出去。'),
298
+ h('div', { style: { display: 'flex', gap: 8, marginTop: 10, flexWrap: 'wrap' } },
299
+ h('input', {
300
+ readOnly: true,
301
+ value: shown,
302
+ spellCheck: false,
303
+ // 点一下全选:想复制走的时候不用一格一格拖。
304
+ onFocus: function (e) { try { e.target.select(); } catch (err) { /* 忽略 */ } },
305
+ style: Object.assign({}, styles.input, { flex: '1 1 240px', width: 'auto' }),
306
+ }),
307
+ h('button', {
308
+ type: 'button',
309
+ onClick: function () { pwd.setShowPwd(!pwd.showPwd); },
310
+ style: styles.btn,
311
+ }, pwd.showPwd ? '藏起来' : '显示'),
312
+ h('button', { type: 'button', onClick: function () { copy(token); }, style: styles.btn }, done ? '已复制' : '复制'),
313
+ ),
314
+ // 服务端刚存下、但还没重启,所以还没生效的那个。**必须和上面显示的那个分开说**:
315
+ // 上面那串是此刻真正在用的,重启前手机上认的还是它。
316
+ pending ? h('div', { style: styles.warn },
317
+ '新密码已经存好了,但要重启一次 DSH 才会生效。在那之前手机上用的还是上面这个旧的;'
318
+ + '重启之后,手机上要重新扫一次码。') : null,
319
+ h('div', { style: styles.muted, marginTop: 14 }, '换一个自己记得住的(至少 12 位):'),
320
+ h('div', { style: { display: 'flex', gap: 8, marginTop: 8, flexWrap: 'wrap' } },
321
+ h('input', {
322
+ type: 'text',
323
+ value: pwd.draft,
324
+ placeholder: '想一个自己记得住的',
325
+ spellCheck: false,
326
+ onChange: function (e) { pwd.setDraft(e.target.value); },
327
+ style: Object.assign({}, styles.input, { flex: '1 1 240px', width: 'auto' }),
328
+ }),
329
+ h('button', {
330
+ type: 'button',
331
+ disabled: Boolean(pwd.busy),
332
+ onClick: pwd.savePassword,
333
+ style: styles.btn,
334
+ }, pwd.busy ? '保存中…' : '保存'),
335
+ ),
336
+ pwd.pwdError ? h('div', { style: styles.error }, pwd.pwdError) : null,
337
+ );
338
+ }
339
+
340
+ function entryBlock(entry, index, copied, copy) {
341
+ var url = typeof entry.url === 'string' ? entry.url : '';
342
+ var done = url !== '' && copied === url;
343
+ return h('div', { key: entry.kind || String(index), style: styles.block },
344
+ h('div', { style: styles.entryTitle }, entry.label || entry.kind || '手机'),
345
+ entry.hint ? h('div', { style: styles.muted }, entry.hint) : null,
346
+ h('div', { style: styles.entryRow },
347
+ // host 侧二维码画不出来时给的是 null,退化成只显示链接。
348
+ entry.qr
349
+ ? h('img', { src: entry.qr, alt: '手机遥控二维码', style: styles.qr })
350
+ : h('div', { style: styles.qrFallback }, '二维码没画出来,用右边的链接也能进。'),
351
+ h('div', { style: styles.urlCol },
352
+ h('input', {
353
+ readOnly: true,
354
+ value: url,
355
+ spellCheck: false,
356
+ // 点一下就把整条地址选中,方便直接 Ctrl+C。
357
+ onFocus: function (e) { try { e.target.select(); } catch (err) { /* 忽略 */ } },
358
+ style: styles.input,
359
+ }),
360
+ h('button', { type: 'button', onClick: function () { copy(url); }, style: styles.btn }, done ? '已复制' : '复制'),
361
+ ),
362
+ ),
363
+ );
364
+ }
365
+
366
+ function apply(ctx) {
367
+ // settings.section 由设置域插件声明;这里等声明出现再注册。
368
+ ctx.slots.inject('settings.section', function () {
369
+ return ctx.slots.register(
370
+ {
371
+ name: 'settings.section',
372
+ id: 'mini-remote',
373
+ order: 2,
374
+ // 传函数:外壳用 resolveSlotLabel 求值(字符串也支持),函数是更稳的那个。
375
+ label: () => '手机遥控',
376
+ },
377
+ MiniRemoteSettingsTab,
378
+ );
379
+ });
380
+ }
381
+
382
+ exports.name = 'dsh-mini-remote';
383
+ exports.inject = ['slots'];
384
+ exports.apply = apply;
385
+ return module.exports;
386
+ }
387
+ });