@wenbin_wb/dsh-bridge 1.0.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/LICENSE +21 -0
- package/README.md +88 -0
- package/client/build.mjs +41 -0
- package/client/client.js +484 -0
- package/client/index.js +410 -0
- package/cordis.patch.yml +4 -0
- package/lib/bridge-rpc.js +113 -0
- package/lib/cloudflared-manager.mjs +236 -0
- package/lib/index.js +499 -0
- package/lib/tunnel-client.mjs +291 -0
- package/package.json +73 -0
package/client/index.js
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
// dsh-bridge 客户端插件:设置页「远程访问」面板
|
|
2
|
+
|
|
3
|
+
import { BRIDGE_RPC_CHANNEL, BRIDGE_ENDPOINTS } from '../lib/bridge-rpc.js';
|
|
4
|
+
|
|
5
|
+
const GITHUB_URL = 'https://github.com/wenbin-wb/dsh-bridge';
|
|
6
|
+
const ISSUES_URL = 'https://github.com/wenbin-wb/dsh-bridge/issues/new';
|
|
7
|
+
|
|
8
|
+
const name = 'dsh-bridge';
|
|
9
|
+
const inject = ['slots', 'connection'];
|
|
10
|
+
|
|
11
|
+
// semver 比较:a > b
|
|
12
|
+
function semverGt(a, b) {
|
|
13
|
+
const parse = (v) => {
|
|
14
|
+
const [main = '', pre = ''] = String(v).split('-');
|
|
15
|
+
const [maj = 0, min = 0, pat = 0] = main.split('.').map(Number);
|
|
16
|
+
return { maj, min, pat, pre };
|
|
17
|
+
};
|
|
18
|
+
const av = parse(a), bv = parse(b);
|
|
19
|
+
if (av.maj !== bv.maj) return av.maj > bv.maj;
|
|
20
|
+
if (av.min !== bv.min) return av.min > bv.min;
|
|
21
|
+
if (av.pat !== bv.pat) return av.pat > bv.pat;
|
|
22
|
+
if (!av.pre && bv.pre) return true;
|
|
23
|
+
if (av.pre && !bv.pre) return false;
|
|
24
|
+
return av.pre > bv.pre;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const s = {
|
|
28
|
+
card: { background: 'var(--dsw-alias-bg-layer-1,#fff)', border: '1px solid var(--dsw-alias-border-l2,#e5e7eb)', borderRadius: 12, padding: '16px 20px', marginBottom: 16 },
|
|
29
|
+
block: { borderTop: '1px solid var(--dsw-alias-border-l2,#e5e7eb)', marginTop: 12, paddingTop: 12 },
|
|
30
|
+
muted: { color: 'var(--dsw-alias-label-tertiary,#8b93a1)', fontSize: 12, lineHeight: 1.5 },
|
|
31
|
+
label: { color: 'var(--dsw-alias-label-primary,inherit)', fontSize: 13, fontWeight: 500 },
|
|
32
|
+
code: { fontFamily: 'ui-monospace,Menlo,monospace', fontSize: 12, wordBreak: 'break-all', color: 'var(--dsw-alias-label-primary,inherit)' },
|
|
33
|
+
btnPri: { font: 'inherit', cursor: 'pointer', border: 'none', background: 'var(--dsw-alias-button-primary-fill,var(--dsw-alias-brand-primary,#4f6ef7))', color: '#fff', height: 32, padding: '0 14px', borderRadius: 999, fontSize: 13, fontWeight: 500, display: 'inline-flex', alignItems: 'center', gap: 4 },
|
|
34
|
+
btnGhost: { font: 'inherit', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2,#d1d5db)', background: 'var(--dsw-alias-bg-layer-1,#fff)', color: 'var(--dsw-alias-label-primary,inherit)', height: 32, padding: '0 14px', borderRadius: 999, fontSize: 13, display: 'inline-flex', alignItems: 'center', gap: 4 },
|
|
35
|
+
btnLink: { font: 'inherit', cursor: 'pointer', border: 'none', background: 'none', color: 'var(--dsw-alias-brand-primary,#4f6ef7)', fontSize: 12, padding: 0, display: 'inline-flex', alignItems: 'center', gap: 3, textDecoration: 'none' },
|
|
36
|
+
qr: { width: 200, height: 200, borderRadius: 10, border: '1px solid var(--dsw-alias-border-l2,#e5e7eb)', margin: '8px 0', display: 'block' },
|
|
37
|
+
tag: { display: 'inline-flex', alignItems: 'center', padding: '2px 8px', borderRadius: 999, fontSize: 12, fontWeight: 500 },
|
|
38
|
+
input: { width: '100%', font: 'inherit', fontSize: 13, padding: '7px 10px', 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)', outline: 'none', boxSizing: 'border-box' },
|
|
39
|
+
warn: { background: 'var(--dsw-alias-state-warn-bg,#fffbeb)', border: '1px solid var(--dsw-alias-state-warn-border,#fde68a)', borderRadius: 8, padding: '10px 14px', fontSize: 12, color: 'var(--dsw-alias-state-warn-primary,#92400e)', lineHeight: 1.6 },
|
|
40
|
+
tip: { background: 'var(--dsw-alias-bg-layer-2,#f9fafb)', borderRadius: 8, padding: '10px 14px', fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)', lineHeight: 1.6 },
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// ---- 子组件 ----
|
|
44
|
+
|
|
45
|
+
function StatusTag({ running }) {
|
|
46
|
+
return React.createElement('span', {
|
|
47
|
+
style: {
|
|
48
|
+
...s.tag,
|
|
49
|
+
background: running ? 'var(--dsw-alias-state-success-bg,#ecfdf5)' : 'var(--dsw-alias-bg-layer-2,#f3f4f6)',
|
|
50
|
+
color: running ? 'var(--dsw-alias-state-success-primary,#059669)' : 'var(--dsw-alias-label-secondary,#6b7280)',
|
|
51
|
+
},
|
|
52
|
+
}, running ? '运行中' : '未启动');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function QrBlock({ url, qr, onReset }) {
|
|
56
|
+
const [copied, setCopied] = React.useState(false);
|
|
57
|
+
const [showQr, setShowQr] = React.useState(true);
|
|
58
|
+
|
|
59
|
+
const copy = React.useCallback(() => {
|
|
60
|
+
navigator.clipboard?.writeText(url).then(() => {
|
|
61
|
+
setCopied(true);
|
|
62
|
+
setTimeout(() => setCopied(false), 2000);
|
|
63
|
+
}).catch(() => {});
|
|
64
|
+
}, [url]);
|
|
65
|
+
|
|
66
|
+
const toggleQr = React.useCallback(() => setShowQr(v => !v), []);
|
|
67
|
+
|
|
68
|
+
return React.createElement('div', { style: { marginTop: 10 } },
|
|
69
|
+
React.createElement('div', { style: s.warn },
|
|
70
|
+
'⚠️ 请勿将此链接或二维码分享给他人,任何人扫码后都可直接访问您的 DSH。'
|
|
71
|
+
),
|
|
72
|
+
React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 10 } },
|
|
73
|
+
React.createElement('code', { style: { ...s.code, flex: 1 } }, url),
|
|
74
|
+
React.createElement('button', {
|
|
75
|
+
style: { ...s.btnGhost, height: 26, padding: '0 10px', fontSize: 12, flexShrink: 0 },
|
|
76
|
+
onClick: copy,
|
|
77
|
+
}, copied ? '✓ 已复制' : '复制'),
|
|
78
|
+
React.createElement('button', {
|
|
79
|
+
style: { ...s.btnGhost, height: 26, padding: '0 10px', fontSize: 12, flexShrink: 0 },
|
|
80
|
+
onClick: toggleQr,
|
|
81
|
+
}, showQr ? '隐藏二维码' : '显示二维码'),
|
|
82
|
+
),
|
|
83
|
+
showQr && qr && React.createElement('div', { style: { marginTop: 8 } },
|
|
84
|
+
React.createElement('img', { src: qr, alt: 'QR', style: s.qr }),
|
|
85
|
+
React.createElement('div', { style: { ...s.muted, marginTop: 4 } }, '请在私密环境下使用'),
|
|
86
|
+
),
|
|
87
|
+
onReset && React.createElement('div', { style: { marginTop: 8 } },
|
|
88
|
+
React.createElement('button', {
|
|
89
|
+
style: { ...s.btnGhost, fontSize: 12, height: 28 },
|
|
90
|
+
onClick: onReset,
|
|
91
|
+
title: '关闭隧道并重新开启,可获得新的 URL',
|
|
92
|
+
}, '🔄 重置链接'),
|
|
93
|
+
),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const CustomTunnelGuide = React.memo(function CustomTunnelGuide() {
|
|
98
|
+
const [open, setOpen] = React.useState(false);
|
|
99
|
+
const toggle = React.useCallback(() => setOpen(v => !v), []);
|
|
100
|
+
return React.createElement('div', { style: s.block },
|
|
101
|
+
React.createElement('button', {
|
|
102
|
+
style: { ...s.btnGhost, fontSize: 12, height: 28, marginBottom: open ? 10 : 0 },
|
|
103
|
+
onClick: toggle,
|
|
104
|
+
}, open ? '▲ 收起搭建教程' : '▶ 如何搭建自建隧道服务器?'),
|
|
105
|
+
open && React.createElement('div', { style: s.tip },
|
|
106
|
+
React.createElement('div', { style: { fontWeight: 500, marginBottom: 6, color: 'var(--dsw-alias-label-primary,inherit)' } }, '搭建步骤'),
|
|
107
|
+
React.createElement('ol', { style: { margin: 0, paddingLeft: 18, display: 'flex', flexDirection: 'column', gap: 4 } },
|
|
108
|
+
React.createElement('li', null, '在公网服务器上安装 Node.js 18+'),
|
|
109
|
+
React.createElement('li', null, '部署隧道服务端,推荐使用 frp 或兼容 WebSocket 的反向代理'),
|
|
110
|
+
React.createElement('li', null, '记录服务器的公网域名(如 tunnel.example.com)和访问令牌'),
|
|
111
|
+
React.createElement('li', null, '在下方填写 WebSocket 地址(wss://...)和令牌,保存后点开启'),
|
|
112
|
+
),
|
|
113
|
+
React.createElement('div', { style: { marginTop: 8, paddingTop: 8, borderTop: '1px solid var(--dsw-alias-border-l2,#e5e7eb)' } },
|
|
114
|
+
React.createElement('div', { style: { fontWeight: 500, marginBottom: 4, color: 'var(--dsw-alias-label-primary,inherit)' } }, '地址格式'),
|
|
115
|
+
React.createElement('code', { style: { ...s.code, display: 'block', padding: '6px 8px', background: 'var(--dsw-alias-bg-layer-2,#f3f4f6)', borderRadius: 6 } },
|
|
116
|
+
'wss://tunnel.example.com/connect'
|
|
117
|
+
),
|
|
118
|
+
),
|
|
119
|
+
),
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const CustomTunnelConfigForm = React.memo(function CustomTunnelConfigForm({ serverUrl: initUrl, accessToken: initToken, onSave }) {
|
|
124
|
+
const [serverUrl, setServerUrl] = React.useState(initUrl ?? '');
|
|
125
|
+
const [accessToken, setAccessToken] = React.useState(initToken ?? '');
|
|
126
|
+
const [saving, setSaving] = React.useState(false);
|
|
127
|
+
|
|
128
|
+
const syncedRef = React.useRef(false);
|
|
129
|
+
React.useEffect(() => {
|
|
130
|
+
if (!syncedRef.current && (initUrl || initToken)) {
|
|
131
|
+
setServerUrl(initUrl ?? '');
|
|
132
|
+
setAccessToken(initToken ?? '');
|
|
133
|
+
syncedRef.current = true;
|
|
134
|
+
}
|
|
135
|
+
}, [initUrl, initToken]);
|
|
136
|
+
|
|
137
|
+
const dirty = serverUrl !== (initUrl ?? '') || accessToken !== (initToken ?? '');
|
|
138
|
+
const handleSave = React.useCallback(async () => {
|
|
139
|
+
setSaving(true);
|
|
140
|
+
try { await onSave(serverUrl, accessToken); }
|
|
141
|
+
finally { setSaving(false); }
|
|
142
|
+
}, [onSave, serverUrl, accessToken]);
|
|
143
|
+
const handleUrlChange = React.useCallback((e) => setServerUrl(e.target.value), []);
|
|
144
|
+
const handleTokenChange = React.useCallback((e) => setAccessToken(e.target.value), []);
|
|
145
|
+
|
|
146
|
+
return React.createElement('div', { style: s.block },
|
|
147
|
+
React.createElement('div', { style: { ...s.muted, marginBottom: 8 } }, '服务器配置'),
|
|
148
|
+
React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
|
|
149
|
+
React.createElement('input', {
|
|
150
|
+
style: s.input,
|
|
151
|
+
placeholder: 'WebSocket 地址,例如 wss://tunnel.example.com/connect',
|
|
152
|
+
value: serverUrl,
|
|
153
|
+
onChange: handleUrlChange,
|
|
154
|
+
disabled: saving,
|
|
155
|
+
}),
|
|
156
|
+
React.createElement('input', {
|
|
157
|
+
style: s.input,
|
|
158
|
+
type: 'password',
|
|
159
|
+
placeholder: '访问令牌(Access Token)',
|
|
160
|
+
value: accessToken,
|
|
161
|
+
onChange: handleTokenChange,
|
|
162
|
+
disabled: saving,
|
|
163
|
+
}),
|
|
164
|
+
React.createElement('button', {
|
|
165
|
+
style: { ...s.btnPri, alignSelf: 'flex-start', opacity: (!dirty || saving) ? 0.5 : 1 },
|
|
166
|
+
disabled: !dirty || saving,
|
|
167
|
+
onClick: handleSave,
|
|
168
|
+
}, saving ? '保存中…' : '保存配置'),
|
|
169
|
+
),
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const TunnelCard = React.memo(function TunnelCard({ title, desc, data, onStart, onStop, onReset, children }) {
|
|
174
|
+
const { running, configured, url, qr, state } = data ?? {};
|
|
175
|
+
const phase = state?.phase ?? 'idle';
|
|
176
|
+
|
|
177
|
+
return React.createElement('div', { style: s.card },
|
|
178
|
+
React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
|
|
179
|
+
React.createElement('div', null,
|
|
180
|
+
React.createElement('div', { style: s.label }, title),
|
|
181
|
+
React.createElement('div', { style: { ...s.muted, marginTop: 2 } }, desc),
|
|
182
|
+
),
|
|
183
|
+
React.createElement(StatusTag, { running }),
|
|
184
|
+
),
|
|
185
|
+
children,
|
|
186
|
+
phase !== 'idle' && phase !== 'ready' && React.createElement('div', {
|
|
187
|
+
style: {
|
|
188
|
+
...s.block, fontSize: 12,
|
|
189
|
+
color: phase === 'error' ? 'var(--dsw-alias-state-error-primary,#dc2626)' : 'var(--dsw-alias-label-secondary,#6b7280)',
|
|
190
|
+
},
|
|
191
|
+
}, state?.detail ?? phase),
|
|
192
|
+
url && React.createElement(QrBlock, { url, qr, onReset }),
|
|
193
|
+
(onStart || onStop) && React.createElement('div', {
|
|
194
|
+
style: { ...s.block, display: 'flex', gap: 8, flexWrap: 'wrap' },
|
|
195
|
+
},
|
|
196
|
+
!running && onStart && React.createElement('button', {
|
|
197
|
+
style: { ...s.btnPri, opacity: configured === false ? 0.4 : 1 },
|
|
198
|
+
onClick: onStart,
|
|
199
|
+
disabled: configured === false || phase === 'connecting' || phase === 'downloading',
|
|
200
|
+
title: configured === false ? '请先保存服务器配置' : '',
|
|
201
|
+
}, phase === 'connecting' ? '连接中…' : phase === 'downloading' ? '下载中…' : '开启'),
|
|
202
|
+
running && onStop && React.createElement('button', { style: s.btnGhost, onClick: onStop }, '关闭'),
|
|
203
|
+
),
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// 版本检查 + GitHub/反馈入口
|
|
208
|
+
function VersionBanner({ rpcCall }) {
|
|
209
|
+
const [info, setInfo] = React.useState(null);
|
|
210
|
+
const [loading, setLoading] = React.useState(false);
|
|
211
|
+
|
|
212
|
+
const check = React.useCallback(async () => {
|
|
213
|
+
setLoading(true);
|
|
214
|
+
try {
|
|
215
|
+
const r = await rpcCall(BRIDGE_ENDPOINTS.checkVersion, {});
|
|
216
|
+
if (r?.ok) setInfo(r.value);
|
|
217
|
+
} finally {
|
|
218
|
+
setLoading(false);
|
|
219
|
+
}
|
|
220
|
+
}, [rpcCall]);
|
|
221
|
+
|
|
222
|
+
React.useEffect(() => { check(); }, [check]);
|
|
223
|
+
|
|
224
|
+
// 只有 npm 上的版本严格大于当前版本才算有新版本
|
|
225
|
+
const hasUpdate = info?.latest && info?.current && !info.error && semverGt(info.latest, info.current);
|
|
226
|
+
|
|
227
|
+
// 链接按钮组:GitHub + 反馈 Bug
|
|
228
|
+
const links = React.createElement('div', { style: { display: 'flex', gap: 12, alignItems: 'center' } },
|
|
229
|
+
React.createElement('a', {
|
|
230
|
+
href: GITHUB_URL, target: '_blank', rel: 'noreferrer', style: s.btnLink,
|
|
231
|
+
}, '⭐ GitHub'),
|
|
232
|
+
React.createElement('a', {
|
|
233
|
+
href: ISSUES_URL, target: '_blank', rel: 'noreferrer', style: s.btnLink,
|
|
234
|
+
}, '🐛 反馈 Bug'),
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
// 有新版本:彩色横幅
|
|
238
|
+
if (hasUpdate) {
|
|
239
|
+
return React.createElement('div', { style: { marginBottom: 16 } },
|
|
240
|
+
React.createElement('div', {
|
|
241
|
+
style: {
|
|
242
|
+
display: 'flex', alignItems: 'center', gap: 12,
|
|
243
|
+
background: 'linear-gradient(135deg, var(--dsw-alias-brand-primary,#4f6ef7) 0%, #6366f1 100%)',
|
|
244
|
+
borderRadius: 10, padding: '12px 16px', marginBottom: 8, color: '#fff',
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
React.createElement('span', { style: { fontSize: 20 } }, '🎉'),
|
|
248
|
+
React.createElement('div', { style: { flex: 1 } },
|
|
249
|
+
React.createElement('div', { style: { fontSize: 13, fontWeight: 600 } },
|
|
250
|
+
`发现新版本 v${info.latest}(当前 v${info.current})`
|
|
251
|
+
),
|
|
252
|
+
React.createElement('code', {
|
|
253
|
+
style: { fontSize: 11, opacity: 0.85, fontFamily: 'ui-monospace,Menlo,monospace', wordBreak: 'break-all' },
|
|
254
|
+
}, 'dsh plugin --profile web update @wenbin_wb/dsh-bridge --latest'),
|
|
255
|
+
),
|
|
256
|
+
React.createElement('button', {
|
|
257
|
+
style: {
|
|
258
|
+
font: 'inherit', cursor: 'pointer', border: '1px solid rgba(255,255,255,0.4)',
|
|
259
|
+
background: 'rgba(255,255,255,0.15)', color: '#fff', height: 30, padding: '0 12px',
|
|
260
|
+
borderRadius: 999, fontSize: 12, display: 'inline-flex', alignItems: 'center',
|
|
261
|
+
opacity: loading ? 0.5 : 1, flexShrink: 0,
|
|
262
|
+
},
|
|
263
|
+
onClick: check, disabled: loading,
|
|
264
|
+
}, loading ? '…' : '刷新'),
|
|
265
|
+
),
|
|
266
|
+
links,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// 正常状态:版本信息一行 + 链接
|
|
271
|
+
return React.createElement('div', { style: { marginBottom: 14, display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 } },
|
|
272
|
+
React.createElement('div', { style: { ...s.muted, display: 'flex', alignItems: 'center', gap: 8 } },
|
|
273
|
+
loading && !info
|
|
274
|
+
? React.createElement('span', null, '检查更新中…')
|
|
275
|
+
: info
|
|
276
|
+
? React.createElement('span', null, `v${info.current}${info.latest && !info.error ? ' · 已是最新' : info.error ? ' · 检查失败' : ''}`)
|
|
277
|
+
: null,
|
|
278
|
+
info && React.createElement('button', {
|
|
279
|
+
style: { ...s.btnGhost, height: 22, padding: '0 8px', fontSize: 11, opacity: loading ? 0.5 : 1 },
|
|
280
|
+
onClick: check, disabled: loading,
|
|
281
|
+
}, loading ? '…' : '重新检查'),
|
|
282
|
+
),
|
|
283
|
+
links,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ---- 主面板 ----
|
|
288
|
+
|
|
289
|
+
function BridgePanel({ rpcCall }) {
|
|
290
|
+
const [status, setStatus] = React.useState(null);
|
|
291
|
+
const [err, setErr] = React.useState(null);
|
|
292
|
+
|
|
293
|
+
const load = React.useCallback(async (quiet = false) => {
|
|
294
|
+
try {
|
|
295
|
+
const r = await rpcCall(BRIDGE_ENDPOINTS.getStatus, {});
|
|
296
|
+
if (!r?.ok) throw new Error(r?.error?.message ?? 'RPC failed');
|
|
297
|
+
setStatus(r.value);
|
|
298
|
+
if (!quiet) setErr(null);
|
|
299
|
+
} catch (e) {
|
|
300
|
+
setErr(e.message);
|
|
301
|
+
}
|
|
302
|
+
}, [rpcCall]);
|
|
303
|
+
|
|
304
|
+
React.useEffect(() => {
|
|
305
|
+
load();
|
|
306
|
+
const t = setInterval(() => load(true), 3000);
|
|
307
|
+
return () => clearInterval(t);
|
|
308
|
+
}, [load]);
|
|
309
|
+
|
|
310
|
+
const act = React.useCallback(async (endpoint, payload) => {
|
|
311
|
+
try {
|
|
312
|
+
const r = await rpcCall(endpoint, payload ?? {});
|
|
313
|
+
if (!r?.ok) throw new Error(r?.error?.message ?? 'RPC failed');
|
|
314
|
+
setStatus(r.value);
|
|
315
|
+
setErr(null);
|
|
316
|
+
} catch (e) {
|
|
317
|
+
setErr(e.message);
|
|
318
|
+
}
|
|
319
|
+
}, [rpcCall]);
|
|
320
|
+
|
|
321
|
+
const onStartCloudflared = React.useCallback(() => act(BRIDGE_ENDPOINTS.startCloudflared), [act]);
|
|
322
|
+
const onStopCloudflared = React.useCallback(() => act(BRIDGE_ENDPOINTS.stopCloudflared), [act]);
|
|
323
|
+
const onResetCloudflared = React.useCallback(() =>
|
|
324
|
+
act(BRIDGE_ENDPOINTS.stopCloudflared).then(() => act(BRIDGE_ENDPOINTS.startCloudflared))
|
|
325
|
+
, [act]);
|
|
326
|
+
const onStartCustom = React.useCallback(() => act(BRIDGE_ENDPOINTS.startCustomTunnel), [act]);
|
|
327
|
+
const onStopCustom = React.useCallback(() => act(BRIDGE_ENDPOINTS.stopCustomTunnel), [act]);
|
|
328
|
+
const saveConfig = React.useCallback((serverUrl, accessToken) =>
|
|
329
|
+
act(BRIDGE_ENDPOINTS.saveCustomTunnelConfig, { serverUrl, accessToken })
|
|
330
|
+
, [act]);
|
|
331
|
+
|
|
332
|
+
if (!status && !err) {
|
|
333
|
+
return React.createElement('div', {
|
|
334
|
+
style: { padding: 32, color: 'var(--dsw-alias-label-tertiary,#9ca3af)', fontSize: 13 },
|
|
335
|
+
}, '加载中…');
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const ct = status?.customTunnel;
|
|
339
|
+
|
|
340
|
+
return React.createElement('div', { style: { maxWidth: 560 } },
|
|
341
|
+
err && React.createElement('div', {
|
|
342
|
+
style: { ...s.card, background: 'var(--dsw-alias-state-error-bg,#fef2f2)', color: 'var(--dsw-alias-state-error-primary,#dc2626)', fontSize: 13, marginBottom: 16 },
|
|
343
|
+
}, err),
|
|
344
|
+
|
|
345
|
+
React.createElement(VersionBanner, { rpcCall }),
|
|
346
|
+
|
|
347
|
+
React.createElement(TunnelCard, {
|
|
348
|
+
title: '局域网访问',
|
|
349
|
+
desc: '同一 Wi-Fi 下的设备可直接扫码访问',
|
|
350
|
+
data: { running: status?.proxy?.running, url: status?.lan?.url, qr: status?.lan?.qr },
|
|
351
|
+
}),
|
|
352
|
+
|
|
353
|
+
React.createElement(TunnelCard, {
|
|
354
|
+
title: 'Cloudflare 隧道',
|
|
355
|
+
desc: '一键获取公网地址(重启后 URL 会变化)',
|
|
356
|
+
data: {
|
|
357
|
+
running: status?.cloudflared?.running,
|
|
358
|
+
url: status?.cloudflared?.url,
|
|
359
|
+
qr: status?.cloudflared?.qr,
|
|
360
|
+
state: status?.cloudflared?.state,
|
|
361
|
+
},
|
|
362
|
+
onStart: onStartCloudflared,
|
|
363
|
+
onStop: onStopCloudflared,
|
|
364
|
+
onReset: status?.cloudflared?.running ? onResetCloudflared : null,
|
|
365
|
+
}),
|
|
366
|
+
|
|
367
|
+
React.createElement(TunnelCard, {
|
|
368
|
+
title: '自建隧道',
|
|
369
|
+
desc: '连接自己部署的隧道服务器,获得固定域名',
|
|
370
|
+
data: {
|
|
371
|
+
configured: ct?.configured,
|
|
372
|
+
running: ct?.running,
|
|
373
|
+
url: ct?.url,
|
|
374
|
+
qr: ct?.qr,
|
|
375
|
+
state: ct?.state,
|
|
376
|
+
},
|
|
377
|
+
onStart: onStartCustom,
|
|
378
|
+
onStop: onStopCustom,
|
|
379
|
+
},
|
|
380
|
+
React.createElement(CustomTunnelGuide),
|
|
381
|
+
React.createElement(CustomTunnelConfigForm, {
|
|
382
|
+
serverUrl: ct?.serverUrl ?? '',
|
|
383
|
+
accessToken: ct?.accessToken ?? '',
|
|
384
|
+
onSave: saveConfig,
|
|
385
|
+
}),
|
|
386
|
+
),
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ---- 插件入口 ----
|
|
391
|
+
|
|
392
|
+
function apply(ctx) {
|
|
393
|
+
const rpcCall = (endpoint, payload, signal) =>
|
|
394
|
+
ctx.connection.rpc.call(BRIDGE_RPC_CHANNEL, endpoint, payload, signal);
|
|
395
|
+
|
|
396
|
+
ctx.slots.inject('settings.section', () =>
|
|
397
|
+
ctx.slots.register(
|
|
398
|
+
{
|
|
399
|
+
name: 'settings.section',
|
|
400
|
+
id: 'dsh-bridge',
|
|
401
|
+
order: 10,
|
|
402
|
+
label: () => '远程访问',
|
|
403
|
+
inject: () => ({ rpcCall }),
|
|
404
|
+
},
|
|
405
|
+
BridgePanel,
|
|
406
|
+
),
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export { name, inject, apply };
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// DSH Bridge - RPC Interface
|
|
2
|
+
// Loopback-only RPC methods for browser UI
|
|
3
|
+
|
|
4
|
+
export const BRIDGE_RPC_CHANNEL = '/dsh-bridge';
|
|
5
|
+
|
|
6
|
+
export const BRIDGE_ENDPOINTS = {
|
|
7
|
+
getStatus: 'getStatus',
|
|
8
|
+
startCustomTunnel: 'startCustomTunnel',
|
|
9
|
+
stopCustomTunnel: 'stopCustomTunnel',
|
|
10
|
+
startCloudflared: 'startCloudflared',
|
|
11
|
+
stopCloudflared: 'stopCloudflared',
|
|
12
|
+
resetCloudflared: 'resetCloudflared',
|
|
13
|
+
saveCustomTunnelConfig: 'saveCustomTunnelConfig',
|
|
14
|
+
checkVersion: 'checkVersion',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function ok(value) {
|
|
18
|
+
return { ok: true, value };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function fail(code, message, details = {}) {
|
|
22
|
+
if (code === 'cancelled') {
|
|
23
|
+
return { ok: false, error: { code: 'cancelled', message, details: {} } };
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
ok: false,
|
|
27
|
+
error: {
|
|
28
|
+
code: 'bad-request',
|
|
29
|
+
message,
|
|
30
|
+
details: { issues: [{ message }], ...details },
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function installBridgeRpc(ctx, { service, logger, saveCustomTunnelConfig }) {
|
|
36
|
+
if (!ctx?.connection?.rpc?.handle) {
|
|
37
|
+
logger.warn('dsh-bridge: Connection RPC unavailable — UI will not work');
|
|
38
|
+
return () => {};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return ctx.connection.rpc.handle(
|
|
42
|
+
BRIDGE_RPC_CHANNEL,
|
|
43
|
+
async (endpoint, payload = {}, signal) => {
|
|
44
|
+
if (signal?.aborted) return fail('cancelled', 'Request was cancelled');
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
if (endpoint === BRIDGE_ENDPOINTS.getStatus) {
|
|
48
|
+
const status = await service.getStatus();
|
|
49
|
+
return ok(status);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (endpoint === BRIDGE_ENDPOINTS.saveCustomTunnelConfig) {
|
|
53
|
+
const { serverUrl = '', accessToken = '' } = payload;
|
|
54
|
+
await saveCustomTunnelConfig(serverUrl.trim(), accessToken.trim());
|
|
55
|
+
const status = await service.getStatus();
|
|
56
|
+
return ok(status);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (endpoint === BRIDGE_ENDPOINTS.startCustomTunnel) {
|
|
60
|
+
try {
|
|
61
|
+
await service.startCustomTunnel();
|
|
62
|
+
const status = await service.getStatus();
|
|
63
|
+
return ok(status);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
logger.error('Failed to start custom tunnel: %s', err.message);
|
|
66
|
+
return fail('bad-request', err.message);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (endpoint === BRIDGE_ENDPOINTS.stopCustomTunnel) {
|
|
71
|
+
service.stopCustomTunnel();
|
|
72
|
+
const status = await service.getStatus();
|
|
73
|
+
return ok(status);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (endpoint === BRIDGE_ENDPOINTS.startCloudflared) {
|
|
77
|
+
try {
|
|
78
|
+
await service.startCloudflared();
|
|
79
|
+
const status = await service.getStatus();
|
|
80
|
+
return ok(status);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
logger.error('Failed to start cloudflared: %s', err.message);
|
|
83
|
+
return fail('bad-request', err.message);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (endpoint === BRIDGE_ENDPOINTS.stopCloudflared) {
|
|
88
|
+
service.stopCloudflared();
|
|
89
|
+
const status = await service.getStatus();
|
|
90
|
+
return ok(status);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (endpoint === BRIDGE_ENDPOINTS.resetCloudflared) {
|
|
94
|
+
await service.resetCloudflared();
|
|
95
|
+
const status = await service.getStatus();
|
|
96
|
+
return ok(status);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (endpoint === BRIDGE_ENDPOINTS.checkVersion) {
|
|
100
|
+
const result = await service.checkVersion();
|
|
101
|
+
return ok(result);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return fail('bad-request', `Unknown endpoint: ${endpoint}`);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
logger.error('RPC endpoint %s failed: %s', endpoint, err.message);
|
|
107
|
+
return fail('bad-request', err.message);
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
{ authority: 'loopback' }
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|