dsh-pocket 2.6.2 → 2.7.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/README.md +1 -1
- package/client/api.js +0 -3
- package/client/client.js +259 -419
- package/client/index.jsx +0 -125
- package/client/mobile/fileCopy.ts +115 -0
- package/client/mobile/mobile-apply.tsx +9 -11
- package/client/mobile/mobile.css.ts +36 -44
- package/client/pocket-locales.js +0 -38
- package/lib/index.js +1 -8
- package/lib/proxy.mjs +2 -2
- package/lib/settings.mjs +0 -75
- package/lib/web-rpc.js +1 -20
- package/package.json +1 -1
- package/client/mobile/MobileComposerFullscreen.tsx +0 -77
package/client/index.jsx
CHANGED
|
@@ -165,75 +165,6 @@ function PocketSettingsTab({ rpcCall, t }) {
|
|
|
165
165
|
|
|
166
166
|
// 安全免责声明(issue #31):每次开启公网都必须先弹框勾选「我已知情」。
|
|
167
167
|
// 服务端同样强制(tunnel.start 需 disclaimer: true),防绕过前端直接调 RPC。
|
|
168
|
-
// 临时 PIN(issue #69):表单、刚生成项、列表
|
|
169
|
-
const [tempPinForm, setTempPinForm] = useState({ kind: 'lan', expiresInSec: 86400, label: '' });
|
|
170
|
-
const [tempPinsList, setTempPinsList] = useState([]);
|
|
171
|
-
const [tempPinLast, setTempPinLast] = useState(null); // { value, kind, expiresAt, label }
|
|
172
|
-
const [tempPinBusy, setTempPinBusy] = useState(false);
|
|
173
|
-
const [tempPinError, setTempPinError] = useState(null);
|
|
174
|
-
|
|
175
|
-
const loadTempPins = async () => {
|
|
176
|
-
try {
|
|
177
|
-
const r = await call(POCKET_ENDPOINTS.tempPinList, {});
|
|
178
|
-
setTempPinsList(Array.isArray(r?.tempPins) ? r.tempPins : []);
|
|
179
|
-
} catch { /* 忽略 */ }
|
|
180
|
-
};
|
|
181
|
-
useEffect(() => { loadTempPins(); }, []);
|
|
182
|
-
|
|
183
|
-
// 每 30 秒刷新一次(让剩余时间实时倒计时显示)
|
|
184
|
-
useEffect(() => {
|
|
185
|
-
if (tempPinsList.length === 0 && !tempPinLast) return;
|
|
186
|
-
const t = setInterval(() => {
|
|
187
|
-
// 触发 setNow 已有的 tick 重新渲染即可;这里只重新拉一次(处理过期自动消失)
|
|
188
|
-
loadTempPins();
|
|
189
|
-
}, 30_000);
|
|
190
|
-
return () => clearInterval(t);
|
|
191
|
-
}, [tempPinsList.length, tempPinLast]);
|
|
192
|
-
|
|
193
|
-
const createTempPinNow = async () => {
|
|
194
|
-
setTempPinBusy(true);
|
|
195
|
-
setTempPinError(null);
|
|
196
|
-
try {
|
|
197
|
-
const r = await call(POCKET_ENDPOINTS.tempPinCreate, { kind: tempPinForm.kind, expiresInSec: tempPinForm.expiresInSec, label: tempPinForm.label });
|
|
198
|
-
setTempPinLast(r);
|
|
199
|
-
setTempPinForm((f) => ({ ...f, label: '' }));
|
|
200
|
-
await loadTempPins();
|
|
201
|
-
} catch (err) {
|
|
202
|
-
setTempPinError(err.message);
|
|
203
|
-
} finally {
|
|
204
|
-
setTempPinBusy(false);
|
|
205
|
-
}
|
|
206
|
-
};
|
|
207
|
-
const revokeTempPinNow = async (p) => {
|
|
208
|
-
try {
|
|
209
|
-
await call(POCKET_ENDPOINTS.tempPinRevoke, { value: p.value, kind: p.kind });
|
|
210
|
-
showToast(t('tempPinRevoked'));
|
|
211
|
-
if (tempPinLast?.value === p.value) setTempPinLast(null);
|
|
212
|
-
await loadTempPins();
|
|
213
|
-
} catch (err) {
|
|
214
|
-
setError(err.message);
|
|
215
|
-
}
|
|
216
|
-
};
|
|
217
|
-
const copyTempPin = async (value) => {
|
|
218
|
-
try {
|
|
219
|
-
if (navigator.clipboard?.writeText) {
|
|
220
|
-
await navigator.clipboard.writeText(value);
|
|
221
|
-
showToast(t('tempPinCopied'));
|
|
222
|
-
}
|
|
223
|
-
} catch { /* 忽略 */ }
|
|
224
|
-
};
|
|
225
|
-
const formatRemaining = (expiresAt) => {
|
|
226
|
-
const ms = expiresAt - Date.now();
|
|
227
|
-
if (ms <= 0) return '0s';
|
|
228
|
-
const s = Math.floor(ms / 1000);
|
|
229
|
-
if (s < 60) return `${s}s`;
|
|
230
|
-
const m = Math.floor(s / 60);
|
|
231
|
-
if (m < 60) return `${m}m`;
|
|
232
|
-
const h = Math.floor(m / 60);
|
|
233
|
-
if (h < 24) return `${h}h`;
|
|
234
|
-
const d = Math.floor(h / 24);
|
|
235
|
-
return `${d}d`;
|
|
236
|
-
};
|
|
237
168
|
|
|
238
169
|
const [disclaimerOpen, setDisclaimerOpen] = useState(false);
|
|
239
170
|
const [disclaimerChecked, setDisclaimerChecked] = useState(false);
|
|
@@ -624,62 +555,6 @@ function PocketSettingsTab({ rpcCall, t }) {
|
|
|
624
555
|
|
|
625
556
|
error ? h('div', { style: { color: 'var(--dsw-alias-state-error-primary,#dc2626)', fontSize: 12, marginTop: 8 } }, `❌ ${errText(error)}`) : null,
|
|
626
557
|
|
|
627
|
-
// 临时 PIN(issue #69):带过期的访问密码,给访客用
|
|
628
|
-
h('div', { style: styles.block },
|
|
629
|
-
h('div', { style: { fontWeight: 600, fontSize: 13, marginBottom: 4 } }, t('tempPinTitle')),
|
|
630
|
-
h('div', { style: { ...styles.muted, marginBottom: 10 } }, t('tempPinSubtitle')),
|
|
631
|
-
// 生成表单:分类 + 时长 + 备注 + 生成
|
|
632
|
-
h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' } },
|
|
633
|
-
h('select', {
|
|
634
|
-
value: tempPinForm.kind,
|
|
635
|
-
onChange: (e) => setTempPinForm((f) => ({ ...f, kind: e.target.value })),
|
|
636
|
-
style: { font: 'inherit', height: 30, padding: '0 8px', borderRadius: 8, border: '1px solid var(--dsw-alias-border-l2,#d1d5db)', background: 'var(--dsw-alias-bg-layer-1,#fff)', color: 'var(--dsw-alias-label-primary,inherit)' },
|
|
637
|
-
},
|
|
638
|
-
h('option', { value: 'public' }, t('tempPinKindPublic')),
|
|
639
|
-
h('option', { value: 'lan' }, t('tempPinKindLan')),
|
|
640
|
-
),
|
|
641
|
-
h('select', {
|
|
642
|
-
value: String(tempPinForm.expiresInSec),
|
|
643
|
-
onChange: (e) => setTempPinForm((f) => ({ ...f, expiresInSec: Number(e.target.value) })),
|
|
644
|
-
style: { font: 'inherit', height: 30, padding: '0 8px', borderRadius: 8, border: '1px solid var(--dsw-alias-border-l2,#d1d5db)', background: 'var(--dsw-alias-bg-layer-1,#fff)', color: 'var(--dsw-alias-label-primary,inherit)' },
|
|
645
|
-
},
|
|
646
|
-
h('option', { value: '3600' }, t('tempPinDuration1h')),
|
|
647
|
-
h('option', { value: '86400' }, t('tempPinDuration24h')),
|
|
648
|
-
h('option', { value: '604800' }, t('tempPinDuration7d')),
|
|
649
|
-
),
|
|
650
|
-
h('input', {
|
|
651
|
-
style: { flex: 1, minWidth: 120, height: 30, padding: '0 10px', borderRadius: 8, border: '1px solid var(--dsw-alias-border-l2,#d1d5db)', fontSize: 13, outline: 'none' },
|
|
652
|
-
placeholder: t('tempPinLabelPh'),
|
|
653
|
-
value: tempPinForm.label,
|
|
654
|
-
onChange: (e) => setTempPinForm((f) => ({ ...f, label: e.target.value })),
|
|
655
|
-
onKeyDown: (e) => { if (e.key === 'Enter') createTempPinNow(); },
|
|
656
|
-
}),
|
|
657
|
-
h('button', { style: { ...styles.btn, height: 30 }, onClick: createTempPinNow, disabled: tempPinBusy }, t('tempPinCreate')),
|
|
658
|
-
),
|
|
659
|
-
// 错误展示
|
|
660
|
-
tempPinError ? h('div', { style: { color: 'var(--dsw-alias-state-error-primary,#dc2626)', fontSize: 12, marginTop: 6 } }, errText(tempPinError)) : null,
|
|
661
|
-
// 刚生成的那一个(高亮,下一秒会消失提示用户立刻复制)
|
|
662
|
-
tempPinLast ? h('div', { style: { marginTop: 10, padding: '8px 10px', borderRadius: 8, background: 'var(--dsw-alias-bg-layer-2,#f3f4f6)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, flexWrap: 'wrap' } },
|
|
663
|
-
h('div', null,
|
|
664
|
-
h('div', { style: { fontSize: 11, color: 'var(--dsw-alias-state-success-primary,#16a34a)', fontWeight: 600, marginBottom: 2 } }, `${t('tempPinCreated')} · ${tempPinLast.kind === 'public' ? t('tempPinKindPublic') : t('tempPinKindLan')} · ${formatRemaining(tempPinLast.expiresAt)}`),
|
|
665
|
-
h('span', { style: { fontFamily: 'ui-monospace,Menlo,monospace', fontSize: 16, letterSpacing: 2, fontWeight: 600 } }, tempPinLast.value),
|
|
666
|
-
),
|
|
667
|
-
h('button', { style: { ...styles.btn, height: 28, padding: '0 12px', fontSize: 12 }, onClick: () => copyTempPin(tempPinLast.value) }, t('tempPinCopy')),
|
|
668
|
-
) : null,
|
|
669
|
-
// 列表
|
|
670
|
-
h('div', { style: { marginTop: 12, borderTop: '1px solid var(--dsw-alias-border-l2,#e5e7eb)', paddingTop: 8 } },
|
|
671
|
-
tempPinsList.length === 0
|
|
672
|
-
? h('div', { style: { ...styles.muted, padding: '6px 0' } }, t('tempPinEmpty'))
|
|
673
|
-
: tempPinsList.map((p) => h('div', { key: p.value, style: { display: 'flex', alignItems: 'center', gap: 8, padding: '6px 0', borderBottom: '1px solid var(--dsw-alias-border-l2,#e5e7eb)' } },
|
|
674
|
-
h('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary,#8b93a1)', minWidth: 50 } }, p.kind === 'public' ? t('tempPinKindPublic') : t('tempPinKindLan')),
|
|
675
|
-
h('span', { style: { fontFamily: 'ui-monospace,Menlo,monospace', fontSize: 13, letterSpacing: 1, flexShrink: 0 } }, p.value),
|
|
676
|
-
h('span', { style: { ...styles.muted, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, p.label || '—'),
|
|
677
|
-
h('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary,#8b93a1)' } }, `${t('tempPinExpiresIn')} ${formatRemaining(p.expiresAt)}`),
|
|
678
|
-
h('button', { style: { ...styles.btn, height: 24, padding: '0 10px', fontSize: 11 }, onClick: () => revokeTempPinNow(p) }, t('tempPinRevoke')),
|
|
679
|
-
)),
|
|
680
|
-
),
|
|
681
|
-
),
|
|
682
|
-
|
|
683
558
|
// 恢复出厂设置:设置出问题时的临时兜底(最底部,避免误触)
|
|
684
559
|
h('div', { style: styles.block },
|
|
685
560
|
h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 } },
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// dsh-web-mobile 移植(MIT,见 LICENSE.dsh-web-mobile):移动端「复制文件内容」按钮。
|
|
2
|
+
//
|
|
3
|
+
// issue #17:用户希望手机上能拿到 DSH 在会话窗口里生成的文件。但当前 dsh-web
|
|
4
|
+
// 的 web UI 没有任何下载能力(全部插件 bundle 里搜不到 download / blob: /
|
|
5
|
+
// saveAs / createObjectURL),桌面端的下载按钮在手机经隧道/局域网访问时也拿
|
|
6
|
+
// 不到文件内容。退而求其次——在「文件内容块」(对话里渲染为 <pre> 代码块)的
|
|
7
|
+
// 右上角注入一个复制按钮,把块内文本(即文件内容)写入剪贴板。
|
|
8
|
+
//
|
|
9
|
+
// 关键:只依赖稳定结构 `<pre>`(fenced code 渲染为 `<pre><code>`),不依赖任何
|
|
10
|
+
// hash 类名。dsh-web 每次构建都会换类名,按类名挂会随版本失效。
|
|
11
|
+
|
|
12
|
+
/** 我们注入的按钮标记(复用 mobile-nav 的 data 属性命名空间,避免与插件自身控件冲突)。 */
|
|
13
|
+
const BUTTON_ATTR = 'data-mobile-nav';
|
|
14
|
+
const BUTTON_VALUE = 'copy-file';
|
|
15
|
+
/** 打在代码块容器上:已注入过按钮就不再重复注入(React 重渲染时自愈)。 */
|
|
16
|
+
const MARKER = 'data-mobile-nav-copy';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 把文本写入剪贴板。优先用 async Clipboard API(隧道/https 下可用);非安全
|
|
20
|
+
* 上下文(局域网 http)下 Clipboard API 会被浏览器拒绝,回退到隐藏 textarea +
|
|
21
|
+
* execCommand('copy')。两者都失败返回 false。
|
|
22
|
+
*/
|
|
23
|
+
export async function copyText(text: string): Promise<boolean> {
|
|
24
|
+
try {
|
|
25
|
+
if (navigator.clipboard && window.isSecureContext) {
|
|
26
|
+
await navigator.clipboard.writeText(text);
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
/* 落到下面的回退路径 */
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const ta = document.createElement('textarea');
|
|
34
|
+
ta.value = text;
|
|
35
|
+
ta.setAttribute('readonly', '');
|
|
36
|
+
ta.style.position = 'fixed';
|
|
37
|
+
ta.style.top = '0';
|
|
38
|
+
ta.style.left = '0';
|
|
39
|
+
ta.style.opacity = '0';
|
|
40
|
+
document.body.appendChild(ta);
|
|
41
|
+
ta.select();
|
|
42
|
+
const ok = document.execCommand('copy');
|
|
43
|
+
ta.remove();
|
|
44
|
+
return ok;
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 造一个复制按钮。点击时复制其所在代码卡里的 <pre> 文本。 */
|
|
51
|
+
function createCopyButton(): HTMLButtonElement {
|
|
52
|
+
const btn = document.createElement('button');
|
|
53
|
+
btn.type = 'button';
|
|
54
|
+
btn.setAttribute(BUTTON_ATTR, BUTTON_VALUE);
|
|
55
|
+
btn.textContent = '复制';
|
|
56
|
+
btn.setAttribute('aria-label', '复制文件内容');
|
|
57
|
+
btn.addEventListener('click', async (event) => {
|
|
58
|
+
event.preventDefault();
|
|
59
|
+
event.stopPropagation();
|
|
60
|
+
const card = btn.parentElement;
|
|
61
|
+
const block = card?.querySelector('pre') ?? null;
|
|
62
|
+
const text = block?.textContent ?? '';
|
|
63
|
+
const ok = await copyText(text);
|
|
64
|
+
const prev = btn.textContent;
|
|
65
|
+
btn.textContent = ok ? '已复制' : '复制失败';
|
|
66
|
+
btn.setAttribute('data-copied', ok ? '1' : '0');
|
|
67
|
+
window.setTimeout(() => {
|
|
68
|
+
btn.textContent = prev;
|
|
69
|
+
btn.removeAttribute('data-copied');
|
|
70
|
+
}, 1500);
|
|
71
|
+
});
|
|
72
|
+
return btn;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 给一个代码块容器注入复制按钮(幂等)。 */
|
|
76
|
+
function injectInto(card: HTMLElement): void {
|
|
77
|
+
if (card.hasAttribute(MARKER)) return;
|
|
78
|
+
card.setAttribute(MARKER, '1');
|
|
79
|
+
if (getComputedStyle(card).position === 'static') card.style.position = 'relative';
|
|
80
|
+
card.appendChild(createCopyButton());
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 收集对话里需要挂复制按钮的代码块容器。候选 = 任何 `<pre>`(fenced code 与
|
|
85
|
+
* 工具结果/错误块都渲染为 <pre>),跳过已标记 / 空块。返回的是其「容器」
|
|
86
|
+
* (pre 的父元素),按钮挂在容器上、定位在块右上角。
|
|
87
|
+
*/
|
|
88
|
+
export function collectCodeBlocks(root: ParentNode): HTMLElement[] {
|
|
89
|
+
const out: HTMLElement[] = [];
|
|
90
|
+
for (const pre of Array.from(root.querySelectorAll('pre'))) {
|
|
91
|
+
const card = pre.parentElement;
|
|
92
|
+
if (card === null) continue;
|
|
93
|
+
if (card.hasAttribute(MARKER)) continue;
|
|
94
|
+
if ((pre.textContent ?? '').trim().length < 1) continue;
|
|
95
|
+
out.push(card);
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 启动注入:MutationObserver 监听全文 DOM,新出现的代码块立即挂按钮;React
|
|
102
|
+
* 重渲染替换了容器时旧按钮随容器消失、新容器会在下次回调里补上(自愈)。
|
|
103
|
+
* @returns 清理函数(断开 observer)。
|
|
104
|
+
*/
|
|
105
|
+
export function startFileCopyInjection(): () => void {
|
|
106
|
+
const scan = (): void => {
|
|
107
|
+
for (const card of collectCodeBlocks(document)) injectInto(card);
|
|
108
|
+
};
|
|
109
|
+
const observer = new MutationObserver(scan);
|
|
110
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
111
|
+
scan();
|
|
112
|
+
return () => {
|
|
113
|
+
observer.disconnect();
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -3,7 +3,7 @@ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
|
|
3
3
|
import { MobileNavToggle } from './MobileNavToggle.tsx'
|
|
4
4
|
import { MobileNavOverlay } from './MobileNavOverlay.tsx'
|
|
5
5
|
import { MobileDrawerFooter } from './MobileDrawerFooter.tsx'
|
|
6
|
-
import {
|
|
6
|
+
import { startFileCopyInjection } from './fileCopy.ts'
|
|
7
7
|
import { MOBILE_CSS } from './mobile.css.ts'
|
|
8
8
|
import { NS, en, zh } from './locales.ts'
|
|
9
9
|
import type { MobileNavKey } from './locales.ts'
|
|
@@ -263,6 +263,14 @@ export function mobileApply(ctx): void {
|
|
|
263
263
|
}
|
|
264
264
|
}, 'dsh-mobile-nav: sheet rise animation replay')
|
|
265
265
|
|
|
266
|
+
// 复制文件内容按钮(issue #17):dsh-web 在手机上没有可用的下载入口,于是在
|
|
267
|
+
// 会话里的代码/文件块右上角注入一个「复制」按钮,把块内文本(文件内容)写
|
|
268
|
+
// 入剪贴板。只挂窄屏——桌面端 DSH 自带复制,不需要。
|
|
269
|
+
ctx.effect(() => {
|
|
270
|
+
if (!narrow.matches) return () => {}
|
|
271
|
+
return startFileCopyInjection()
|
|
272
|
+
}, 'dsh-mobile-nav: file copy button (issue #17)')
|
|
273
|
+
|
|
266
274
|
ctx.slots.inject('conversation.session.header.actions', () => ctx.slots.register({
|
|
267
275
|
name: 'conversation.session.header.actions',
|
|
268
276
|
id: 'mobile-nav-toggle',
|
|
@@ -273,16 +281,6 @@ export function mobileApply(ctx): void {
|
|
|
273
281
|
}),
|
|
274
282
|
}, MobileNavToggle))
|
|
275
283
|
|
|
276
|
-
// 「⛶ 放大输入」按钮(issue #23):注册到 conversation.input.right(发送键旁)
|
|
277
|
-
// ——桌面端由 mobile.css.ts 隐藏(min-width:1024px)。点按切换 body 上的
|
|
278
|
-
// data-dsh-pocket-composer-fullscreen 标记,由 CSS 把 composer 卡片全屏化。
|
|
279
|
-
ctx.slots.inject('conversation.input.right', () => ctx.slots.register({
|
|
280
|
-
name: 'conversation.input.right',
|
|
281
|
-
id: 'mobile-composer-fullscreen',
|
|
282
|
-
order: 100,
|
|
283
|
-
locale: NS,
|
|
284
|
-
}, MobileComposerFullscreen))
|
|
285
|
-
|
|
286
284
|
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
|
|
287
285
|
name: 'shell.overlay',
|
|
288
286
|
id: 'mobile-nav-overlay',
|
|
@@ -872,6 +872,42 @@ export const MOBILE_CSS = `
|
|
|
872
872
|
[data-phase="hero"] [class$="_stack"] {
|
|
873
873
|
gap: 0 !important;
|
|
874
874
|
}
|
|
875
|
+
|
|
876
|
+
/* ---------- 复制文件内容按钮(issue #17) ----------
|
|
877
|
+
挂在代码/文件块容器右上角(position:relative 由 JS 在注入时补上)。
|
|
878
|
+
只屏内可见:桌面端 DSH 自带复制,且本 effect 只在 narrow 下挂载,按钮
|
|
879
|
+
根本不会注入;这里再兜底一层,避免任何遗漏。 */
|
|
880
|
+
[data-mobile-nav="copy-file"] {
|
|
881
|
+
position: absolute !important;
|
|
882
|
+
top: 6px !important;
|
|
883
|
+
right: 6px !important;
|
|
884
|
+
z-index: 6 !important;
|
|
885
|
+
display: inline-flex !important;
|
|
886
|
+
align-items: center !important;
|
|
887
|
+
justify-content: center !important;
|
|
888
|
+
height: 26px !important;
|
|
889
|
+
padding: 0 10px !important;
|
|
890
|
+
border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, .12)) !important;
|
|
891
|
+
border-radius: 8px !important;
|
|
892
|
+
background: var(--dsw-alias-bg-base, #ffffff) !important;
|
|
893
|
+
color: var(--dsw-alias-label-primary, inherit) !important;
|
|
894
|
+
font-family: inherit !important;
|
|
895
|
+
font-size: 12px !important;
|
|
896
|
+
line-height: 1 !important;
|
|
897
|
+
cursor: pointer !important;
|
|
898
|
+
-webkit-tap-highlight-color: transparent !important;
|
|
899
|
+
box-shadow: 0 1px 4px rgba(0, 0, 0, .14) !important;
|
|
900
|
+
}
|
|
901
|
+
[data-mobile-nav="copy-file"]:active {
|
|
902
|
+
background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, .06)) !important;
|
|
903
|
+
}
|
|
904
|
+
[data-mobile-nav="copy-file"][data-copied="1"] {
|
|
905
|
+
color: var(--dsw-alias-state-success-primary, #1a9d54) !important;
|
|
906
|
+
border-color: var(--dsw-alias-state-success-primary, #1a9d54) !important;
|
|
907
|
+
}
|
|
908
|
+
[data-mobile-nav="copy-file"][data-copied="0"] {
|
|
909
|
+
color: var(--dsw-alias-state-danger-primary, #e5484d) !important;
|
|
910
|
+
}
|
|
875
911
|
}
|
|
876
912
|
|
|
877
913
|
/* ---------- desktop: the mobile controls must never appear ---------- */
|
|
@@ -886,50 +922,6 @@ export const MOBILE_CSS = `
|
|
|
886
922
|
[data-mobile-nav="drawer-actions"] {
|
|
887
923
|
display: none !important;
|
|
888
924
|
}
|
|
889
|
-
/* 桌面端永远不需要「⛶ 放大输入」按钮(composer 已经有完整工具行) */
|
|
890
|
-
[data-mobile-nav="composer-fullscreen"] {
|
|
891
|
-
display: none !important;
|
|
892
|
-
}
|
|
893
925
|
}
|
|
894
926
|
|
|
895
|
-
/* ---------- 放大输入(issue #23) ----------
|
|
896
|
-
窄屏默认隐藏插件注册到 conversation.input.* slot 的 UI(仅留官方 resident
|
|
897
|
-
chrome),点「⛶」按钮把 composer 卡片固定到全屏后所有插件 UI 恢复显示——
|
|
898
|
-
新插件零适配。
|
|
899
|
-
实现说明:当前 dsh 0.1.1 没把 input slot 渲染成 [data-slot] 包装(0.1.2+ 才有),
|
|
900
|
-
所以这里用「白名单」反选:mobileApply 注册的 mobile 自身节点加
|
|
901
|
-
data-mobile-nav="composer-fullscreen" 不被隐藏;其它插件 UI 隐藏。 */
|
|
902
|
-
@media (max-width: 1023px) {
|
|
903
|
-
/* 默认(非全屏):隐藏 input slot 容器下的所有直接子元素(包含插件和官方)
|
|
904
|
-
——官方自己如果想在窄屏也露出,注 input slot 时加 data-mobile-nav-keep="1" */
|
|
905
|
-
[data-dsh-pocket-composer-fullscreen]:not([data-dsh-pocket-composer-fullscreen="1"])
|
|
906
|
-
[class$="_card"]:has(textarea) [data-slot^="conversation.input."] > :not([data-mobile-nav-keep]),
|
|
907
|
-
[data-dsh-pocket-composer-fullscreen]:not([data-dsh-pocket-composer-fullscreen="1"])
|
|
908
|
-
[class$="_card"]:has(textarea) [data-slot^="conversation.input."]:empty {
|
|
909
|
-
display: none !important;
|
|
910
|
-
}
|
|
911
|
-
/* 放大按钮:常驻在 conversation.input.right slot 旁,桌面端已被上面规则隐藏 */
|
|
912
|
-
[data-mobile-nav="composer-fullscreen"] {
|
|
913
|
-
display: inline-flex;
|
|
914
|
-
}
|
|
915
|
-
/* 全屏模式:composer 卡片固定到视口,textarea 拉高,工具行可换行 */
|
|
916
|
-
[data-dsh-pocket-composer-fullscreen="1"] [class$="_card"]:has(textarea) {
|
|
917
|
-
position: fixed !important;
|
|
918
|
-
inset: 0 !important;
|
|
919
|
-
z-index: 9999 !important;
|
|
920
|
-
border-radius: 0 !important;
|
|
921
|
-
margin: 0 !important;
|
|
922
|
-
height: 100dvh !important;
|
|
923
|
-
display: flex !important;
|
|
924
|
-
flex-direction: column !important;
|
|
925
|
-
}
|
|
926
|
-
[data-dsh-pocket-composer-fullscreen="1"] [class$="_card"]:has(textarea) textarea {
|
|
927
|
-
flex: 1 1 auto !important;
|
|
928
|
-
min-height: 50dvh !important;
|
|
929
|
-
max-height: none !important;
|
|
930
|
-
}
|
|
931
|
-
[data-dsh-pocket-composer-fullscreen="1"] [class$="_card"]:has(textarea) [data-slot^="conversation.input."] {
|
|
932
|
-
flex-wrap: wrap !important;
|
|
933
|
-
}
|
|
934
|
-
}
|
|
935
927
|
`
|
package/client/pocket-locales.js
CHANGED
|
@@ -97,25 +97,6 @@ export const zh = {
|
|
|
97
97
|
'error': '❌ 开启失败:{detail}(可重试;若是代理/VPN 问题见 README 排障)',
|
|
98
98
|
'unknownError': '未知错误',
|
|
99
99
|
'feedback': '有问题?欢迎到 GitHub Issues 反馈 🙏',
|
|
100
|
-
// 临时 PIN(issue #69)
|
|
101
|
-
'tempPinTitle': '临时访问 PIN(带超时)',
|
|
102
|
-
'tempPinSubtitle': '给访客开一个有寿命的 8 位 PIN,过期自动作废;与主 PIN 共用速率限制。',
|
|
103
|
-
'tempPinKindPublic': '公网',
|
|
104
|
-
'tempPinKindLan': '局域网',
|
|
105
|
-
'tempPinDuration': '有效时长',
|
|
106
|
-
'tempPinDuration1h': '1 小时',
|
|
107
|
-
'tempPinDuration24h': '24 小时',
|
|
108
|
-
'tempPinDuration7d': '7 天',
|
|
109
|
-
'tempPinLabel': '备注(可选,给谁用)',
|
|
110
|
-
'tempPinLabelPh': '朋友小王',
|
|
111
|
-
'tempPinCreate': '生成',
|
|
112
|
-
'tempPinCreated': '已生成(仅显示一次)',
|
|
113
|
-
'tempPinCopied': '已复制',
|
|
114
|
-
'tempPinCopy': '复制',
|
|
115
|
-
'tempPinEmpty': '暂无临时 PIN',
|
|
116
|
-
'tempPinExpiresIn': '过期',
|
|
117
|
-
'tempPinRevoke': '撤销',
|
|
118
|
-
'tempPinRevoked': '已撤销',
|
|
119
100
|
}
|
|
120
101
|
|
|
121
102
|
/** English dictionary, key-identical to the Chinese source of truth. */
|
|
@@ -213,23 +194,4 @@ export const en = {
|
|
|
213
194
|
'error': '❌ Failed to enable: {detail} (you can retry; for proxy/VPN issues see the README)',
|
|
214
195
|
'unknownError': 'unknown error',
|
|
215
196
|
'feedback': '🙏 Questions? Open an issue on GitHub',
|
|
216
|
-
// Temp PIN (issue #69)
|
|
217
|
-
'tempPinTitle': 'Temporary access PINs (auto-expire)',
|
|
218
|
-
'tempPinSubtitle': 'Issue a time-limited 8-char PIN to a guest; expires automatically. Shares the main PIN rate limit.',
|
|
219
|
-
'tempPinKindPublic': 'Public',
|
|
220
|
-
'tempPinKindLan': 'LAN',
|
|
221
|
-
'tempPinDuration': 'Valid for',
|
|
222
|
-
'tempPinDuration1h': '1 hour',
|
|
223
|
-
'tempPinDuration24h': '24 hours',
|
|
224
|
-
'tempPinDuration7d': '7 days',
|
|
225
|
-
'tempPinLabel': 'Note (optional, who is this for)',
|
|
226
|
-
'tempPinLabelPh': 'Friend Alice',
|
|
227
|
-
'tempPinCreate': 'Create',
|
|
228
|
-
'tempPinCreated': 'Created (shown once)',
|
|
229
|
-
'tempPinCopied': 'Copied',
|
|
230
|
-
'tempPinCopy': 'Copy',
|
|
231
|
-
'tempPinEmpty': 'No temporary PINs yet',
|
|
232
|
-
'tempPinExpiresIn': 'expires',
|
|
233
|
-
'tempPinRevoke': 'Revoke',
|
|
234
|
-
'tempPinRevoked': 'Revoked',
|
|
235
197
|
}
|
package/lib/index.js
CHANGED
|
@@ -21,7 +21,7 @@ import { createPocketService } from './service.mjs';
|
|
|
21
21
|
import { installPocketRpc } from './web-rpc.js';
|
|
22
22
|
import { restartHost } from './restart.js';
|
|
23
23
|
import { advancedNoticeScript, DEFAULT_INJECT, classifyHost } from './proxy.mjs';
|
|
24
|
-
import { lanEnabled, setLanEnabled, lanAuthEnabled, setLanAuthEnabled, lanIpOverride, setLanIpOverride, pinCustom, setPinCustom, tunnelMode, setTunnelMode, tunnelToken, setTunnelToken, tunnelHostname, setTunnelHostname, resetSettings, proxyPort,
|
|
24
|
+
import { lanEnabled, setLanEnabled, lanAuthEnabled, setLanAuthEnabled, lanIpOverride, setLanIpOverride, pinCustom, setPinCustom, tunnelMode, setTunnelMode, tunnelToken, setTunnelToken, tunnelHostname, setTunnelHostname, resetSettings, proxyPort, cloudflaredPath } from './settings.mjs';
|
|
25
25
|
|
|
26
26
|
const name = 'dsh-pocket';
|
|
27
27
|
const inject = ['connection', 'webServer'];
|
|
@@ -285,8 +285,6 @@ export function apply(ctx, config = {}, internals = {}) {
|
|
|
285
285
|
sessionKey: randomBytes(16).toString('hex'),
|
|
286
286
|
getToken: (host) => tokenForHost(host),
|
|
287
287
|
isProtected: (host) => (classifyHost(host) === 'public' ? true : lanAuthEnabled()),
|
|
288
|
-
// 临时 PIN(issue #69):与主 PIN 同 host 分类,仅在受保护入口校验
|
|
289
|
-
getAltTokens: (host) => tempPinValuesFor(classifyHost(host) === 'public' ? 'public' : 'lan'),
|
|
290
288
|
},
|
|
291
289
|
// dsh web 浏览器会话启动 token(issue #77):新版 dsh(>= 0.1.2-alpha.1)要求根路径
|
|
292
290
|
// 带一次 `?token=` 换 cookie,否则 /api 与 WebSocket 全 401。token 每次进程启动都变,
|
|
@@ -343,11 +341,6 @@ export function apply(ctx, config = {}, internals = {}) {
|
|
|
343
341
|
if (token !== undefined) setTunnelToken(token);
|
|
344
342
|
return { mode: tunnelMode(), hostname: tunnelHostname(), tokenSet: tunnelToken().length > 0 };
|
|
345
343
|
},
|
|
346
|
-
// 临时 PIN(issue #69):创建/列出/撤销。前端只看到 value 一次(创建时),
|
|
347
|
-
// 列出时也回显 value(用户需要复制给别人),文件 0o600 + 仅本机 loopback RPC 兜底。
|
|
348
|
-
listTempPins: () => readTempPins(),
|
|
349
|
-
createTempPin: ({ kind, expiresInSec, label }) => createTempPin({ kind, expiresInSec, label }),
|
|
350
|
-
revokeTempPin: (value, kind) => revokeTempPin(value, kind),
|
|
351
344
|
runUpdate: internals.runUpdate ?? { currentVersion, perform: performUpdate, loadedVersion: () => loadedVersion },
|
|
352
345
|
restart: internals.restart ?? (() => pocketRestart(service)),
|
|
353
346
|
restartNotice: internals.restartNotice ?? consumeRestartNotice,
|
package/lib/proxy.mjs
CHANGED
|
@@ -439,9 +439,9 @@ function attachWebSocketHeartbeat(socket, { intervalMs = 30_000, missLimit = 2 }
|
|
|
439
439
|
* @param {string} [opts.host] 监听地址(默认 0.0.0.0:LAN 与隧道都能到)
|
|
440
440
|
* @param {{host:string,port:number}} [opts.upstream] 上游 dsh web(默认 127.0.0.1:3080)
|
|
441
441
|
* @param {string} [opts.injectHtml] 注入 HTML 的内容(默认 polyfill + 移动端适配;传 '' 关闭)
|
|
442
|
-
* @param {object} [opts.auth] 可选访问令牌认证(issue #13
|
|
442
|
+
* @param {object} [opts.auth] 可选访问令牌认证(issue #13):{ getToken, getAltTokens?, isProtected, sessionKey }
|
|
443
443
|
* - getToken(host) → 主 PIN(公网/局域网各一个,按 host 分类)
|
|
444
|
-
* - getAltTokens?(host) →
|
|
444
|
+
* - getAltTokens?(host) → 替代令牌列表(可选),校验时与主 PIN 任一命中即放行
|
|
445
445
|
* @param {object|false} [opts.rateLimit] 登录速率限制参数覆盖(issue #40;测试用短窗口)
|
|
446
446
|
* @param {object|false} [opts.heartbeat] WebSocket 心跳注入(PR #41):{ intervalMs, missLimit };false 关闭(默认开:30s/容忍 2 个静默周期)
|
|
447
447
|
* @param {() => boolean} [opts.lanAccessEnabled] 局域网访问是否开启(默认开启)。关闭时拦截经局域网 Host 的请求(公网/loopback 不受影响)。
|
package/lib/settings.mjs
CHANGED
|
@@ -214,81 +214,6 @@ export function setProxyPort(value) {
|
|
|
214
214
|
return proxyPort();
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
-
// ---------- 临时 PIN(issue #69:带超时的临时访问) ----------
|
|
218
|
-
// 让用户能给访客(朋友/同事/临时设备)开一个**有寿命**的 PIN,过期自动作废——
|
|
219
|
-
//
|
|
220
|
-
// 设计要点:
|
|
221
|
-
// - 临时 PIN 与主 PIN **共用速率限制**(按 IP):攻击者拿到临时 PIN 也不能暴力破解别的;
|
|
222
|
-
// - 临时 PIN **绑 host 分类**:公网临时 PIN 只在公网入口校验,局域网临时 PIN 只在局域网入口校验
|
|
223
|
-
// (与主 PIN 的 host 分发一致;防止公网临时 PIN 被局域网内的人滥用,反之亦然);
|
|
224
|
-
// - 过期**懒清理**:访问时过滤掉过期的,避免每次都写磁盘(频繁写影响响应时间);
|
|
225
|
-
// 设置页列表操作或重启 dsh web 之后才落盘(writeSettings 在 setTempPin / revokeTempPin 时调用);
|
|
226
|
-
// - 不复用主 PIN 的「自定义标记」:临时 PIN 是一次性生成,标记无意义;
|
|
227
|
-
// - 存本机 0o600 settings.json(与 Token 一致)。
|
|
228
|
-
//
|
|
229
|
-
// 临时 PIN 默认长度同主 PIN(8 位字母数字),但由系统生成,用户不设置。
|
|
230
|
-
// 一次性签名不重要:临时 PIN 本身足够长(8 位)且绑超时,被截获也只活到过期。
|
|
231
|
-
//
|
|
232
|
-
// 数据结构:settings.tempPins = [{ value, kind: 'public'|'lan', expiresAt: <epoch_ms>, label: <string> }]
|
|
233
|
-
const TEMP_PIN_KINDS = new Set(['public', 'lan']);
|
|
234
|
-
const TEMP_PIN_RE = /^[a-zA-Z0-9]{8}$/;
|
|
235
|
-
/** 最小允许有效期 5 分钟(防误点立即过期让用户迷惑),最大 30 天。 */
|
|
236
|
-
const TEMP_PIN_MIN_SEC = 5 * 60;
|
|
237
|
-
const TEMP_PIN_MAX_SEC = 30 * 24 * 60 * 60;
|
|
238
|
-
/** 临时 PIN 生成(issue #69):与主 PIN 同长度(8 位字母数字),用密码学随机。 */
|
|
239
|
-
function newTempPin() {
|
|
240
|
-
const bytes = randomBytes(8);
|
|
241
|
-
// 把 8 字节转 base32-ish 字母数字串,再截 8 位;碰撞概率忽略(8 字节熵 ≈ 2^64)
|
|
242
|
-
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
|
|
243
|
-
let out = '';
|
|
244
|
-
for (let i = 0; i < 8; i++) out += alphabet[bytes[i] % alphabet.length];
|
|
245
|
-
return out;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/** 读临时 PIN 列表(自动过滤过期项;不写磁盘)。 */
|
|
249
|
-
export function tempPins() {
|
|
250
|
-
const arr = readSettings().tempPins;
|
|
251
|
-
if (!Array.isArray(arr)) return [];
|
|
252
|
-
const now = Date.now();
|
|
253
|
-
return arr
|
|
254
|
-
.filter((p) => p && typeof p === 'object' && typeof p.value === 'string' && typeof p.expiresAt === 'number' && p.expiresAt > now)
|
|
255
|
-
.map((p) => ({ value: p.value, kind: p.kind, expiresAt: p.expiresAt, label: typeof p.label === 'string' ? p.label : '' }));
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/** 创建一个临时 PIN(issue #69)。返回 { value, expiresAt, kind, label }。 */
|
|
259
|
-
export function createTempPin({ kind, expiresInSec, label = '' } = {}) {
|
|
260
|
-
if (!TEMP_PIN_KINDS.has(kind)) throw new Error('临时 PIN 类型必须是 public 或 lan | temp pin kind must be public or lan');
|
|
261
|
-
const sec = Number(expiresInSec);
|
|
262
|
-
if (!Number.isInteger(sec) || sec < TEMP_PIN_MIN_SEC || sec > TEMP_PIN_MAX_SEC) {
|
|
263
|
-
throw new Error(`临时 PIN 有效期需在 ${TEMP_PIN_MIN_SEC}-${TEMP_PIN_MAX_SEC} 秒之间 | expiresInSec out of range`);
|
|
264
|
-
}
|
|
265
|
-
const value = newTempPin();
|
|
266
|
-
const expiresAt = Date.now() + sec * 1000;
|
|
267
|
-
const s = readSettings();
|
|
268
|
-
s.tempPins = Array.isArray(s.tempPins) ? s.tempPins.filter((p) => p && p.expiresAt > Date.now()) : [];
|
|
269
|
-
s.tempPins.push({ value, kind, expiresAt, label: String(label).slice(0, 32) });
|
|
270
|
-
writeSettings(s);
|
|
271
|
-
return { value, kind, expiresAt, label: String(label).slice(0, 32) };
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
/** 撤销一个临时 PIN(按 value 匹配,kind 校验防止误删)。不存在也返回 false。 */
|
|
275
|
-
export function revokeTempPin(value, kind) {
|
|
276
|
-
const v = String(value ?? '');
|
|
277
|
-
if (!TEMP_PIN_RE.test(v)) return false;
|
|
278
|
-
const s = readSettings();
|
|
279
|
-
if (!Array.isArray(s.tempPins)) return false;
|
|
280
|
-
const before = s.tempPins.length;
|
|
281
|
-
s.tempPins = s.tempPins.filter((p) => !(p?.value === v && (kind === undefined || p.kind === kind)));
|
|
282
|
-
if (s.tempPins.length === before) return false;
|
|
283
|
-
writeSettings(s);
|
|
284
|
-
return true;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
/** 给定 kind 返回所有未过期的临时 PIN 集合(value 数组),用于 proxy 校验。 */
|
|
288
|
-
export function tempPinValuesFor(kind) {
|
|
289
|
-
return tempPins().filter((p) => p.kind === kind).map((p) => p.value);
|
|
290
|
-
}
|
|
291
|
-
|
|
292
217
|
// ---------- cloudflared 路径(issue #45:远程 Linux 服务器下载源不可达时手动指定) ----------
|
|
293
218
|
// Linux 服务器在国内/部分企业网下,所有 CDN 源(GitHub / ghproxy / gh.ddlc / gh-proxy)
|
|
294
219
|
// 都连不上时,下载 cloudflared 二进制始终失败。允许用户在 settings.json 里**写死
|
package/lib/web-rpc.js
CHANGED
|
@@ -25,7 +25,7 @@ export function killHint(port) {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/** 注册 /dsh-pocket 逻辑通道(仅本机 loopback 可调)。 */
|
|
28
|
-
export function installPocketRpc(ctx, { service, log = console, desktop = false, runUpdate = null, restart = null, restartNotice = null, getToken = null, getLanToken = null, refreshLanToken = null, getLanAuthEnabled = null, setLanAuthEnabled = null, getLanEnabled = null, setLanEnabled = null, getLanIpOverride = null, setLanIpOverride = null, getPinCustom = null, setCustomPin = null, getTunnelConfig = null, setTunnelConfig = null, resetPocket = null
|
|
28
|
+
export function installPocketRpc(ctx, { service, log = console, desktop = false, runUpdate = null, restart = null, restartNotice = null, getToken = null, getLanToken = null, refreshLanToken = null, getLanAuthEnabled = null, setLanAuthEnabled = null, getLanEnabled = null, setLanEnabled = null, getLanIpOverride = null, setLanIpOverride = null, getPinCustom = null, setCustomPin = null, getTunnelConfig = null, setTunnelConfig = null, resetPocket = null }) {
|
|
29
29
|
if (!ctx?.connection?.rpc?.handle) {
|
|
30
30
|
log.warn?.('dsh-pocket: DSH Host Connection RPC unavailable — settings tab disabled | 无 Connection RPC,设置页不可用');
|
|
31
31
|
return () => {};
|
|
@@ -161,25 +161,6 @@ export function installPocketRpc(ctx, { service, log = console, desktop = false,
|
|
|
161
161
|
const dshPort = service.dshPort ?? 3080;
|
|
162
162
|
return ok({ ...result, hint: `重启后进程在后台运行;如需停止:${killHint(dshPort)}` });
|
|
163
163
|
}
|
|
164
|
-
// 临时 PIN(issue #69):生成 / 列出 / 撤销。临时 PIN 是带过期的主 PIN 同位替代品,
|
|
165
|
-
// 用于把访问权限临时分给别人(朋友/同事/临时设备),过期自动作废。
|
|
166
|
-
if (endpoint === POCKET_ENDPOINTS.tempPinCreate) {
|
|
167
|
-
if (!createTempPin) return fail('bad-request', '临时 PIN 生成不可用 | temp pin create unavailable');
|
|
168
|
-
try {
|
|
169
|
-
const pin = createTempPin({ kind: payload?.kind, expiresInSec: payload?.expiresInSec, label: payload?.label });
|
|
170
|
-
return ok(pin);
|
|
171
|
-
} catch (err) {
|
|
172
|
-
return fail('bad-request', err?.message ?? String(err));
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
if (endpoint === POCKET_ENDPOINTS.tempPinList) {
|
|
176
|
-
return ok({ tempPins: listTempPins?.() ?? [] });
|
|
177
|
-
}
|
|
178
|
-
if (endpoint === POCKET_ENDPOINTS.tempPinRevoke) {
|
|
179
|
-
if (!revokeTempPin) return fail('bad-request', '临时 PIN 撤销不可用 | temp pin revoke unavailable');
|
|
180
|
-
const ok = revokeTempPin(payload?.value, payload?.kind);
|
|
181
|
-
return ok({ revoked: ok });
|
|
182
|
-
}
|
|
183
164
|
return fail('bad-request', `Unknown endpoint: ${endpoint}`);
|
|
184
165
|
} catch (err) {
|
|
185
166
|
log.error?.('dsh-pocket: rpc %s failed | RPC 失败: %s', endpoint, err?.message ?? err);
|
package/package.json
CHANGED