dsh-tailscale-remote 0.0.0-stage → 0.2.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 +264 -2
- package/client/client.js +457 -0
- package/cordis.patch.yml +9 -0
- package/index.js +1003 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +61 -4
package/client/client.js
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
// dsh-tailscale-remote 的浏览器半边:在「设置 → 通用」里加一行「手机连接」,
|
|
2
|
+
// 显示手机可用的登录链接,并允许更换密钥。
|
|
3
|
+
//
|
|
4
|
+
// 手写,没有构建步骤:元素一律用 React.createElement 构造,样式全部内联,
|
|
5
|
+
// 颜色走 DSH 主题变量(真名前缀是 --dsw-alias-,深浅色自动跟随)并各带一个
|
|
6
|
+
// 浅色兜底值,变量缺失时界面仍然可读。
|
|
7
|
+
//
|
|
8
|
+
// 数据来自同源的两个 /api 路由(host 侧 index.js 注册在 connection.fetch 上,
|
|
9
|
+
// 因此白拿 DSH 自己的 Host/Origin fence 与 cookie 鉴权):
|
|
10
|
+
// GET /api/dsh-tailscale-remote/state → { ok, phase, error, plainUrl, loginUrl, ... }
|
|
11
|
+
// POST /api/dsh-tailscale-remote/rotate → { ok, loginUrl } | { ok:false, error }
|
|
12
|
+
// 注意失败时状态码不是 200 但响应体仍是 JSON,所以先解析 body 再看 body.ok,
|
|
13
|
+
// 不拿状态码覆盖原文。
|
|
14
|
+
//
|
|
15
|
+
// 这一行**不轮询、不挂后台定时器**:只在挂载时取一次,另有窗口重新获得焦点时
|
|
16
|
+
// 刷新一次。设置页关掉就什么都不剩。
|
|
17
|
+
|
|
18
|
+
window.__ModuleLoader__.load({
|
|
19
|
+
id: 'dsh-tailscale-remote',
|
|
20
|
+
factory: (require) => {
|
|
21
|
+
// loader 只传 require、不传 module,所以每个 bundle 自带这一对,
|
|
22
|
+
// 和宿主自己的 bundle 写法一致。
|
|
23
|
+
const module = { exports: {} }
|
|
24
|
+
const exports = module.exports
|
|
25
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
26
|
+
|
|
27
|
+
const React = require('react')
|
|
28
|
+
const h = React.createElement
|
|
29
|
+
const useState = React.useState
|
|
30
|
+
const useEffect = React.useEffect
|
|
31
|
+
const useCallback = React.useCallback
|
|
32
|
+
const useRef = React.useRef
|
|
33
|
+
|
|
34
|
+
/** 这一行的插槽 id;同时是设置页里这一行的定位键。 */
|
|
35
|
+
const ROW_ID = 'dsh-tailscale-remote'
|
|
36
|
+
|
|
37
|
+
const STATE_PATH = '/api/dsh-tailscale-remote/state'
|
|
38
|
+
const ROTATE_PATH = '/api/dsh-tailscale-remote/rotate'
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 行内字典。
|
|
42
|
+
*
|
|
43
|
+
* 宿主有 locale 服务时优先用它(见 pickLanguage),这里这份是兜底:
|
|
44
|
+
* 拿不到 locale 服务时(浏览器 harness、精简组合)界面仍然是中文/英文,
|
|
45
|
+
* 而不是一堆字面量 key。
|
|
46
|
+
*/
|
|
47
|
+
const ZH = {
|
|
48
|
+
title: '手机连接',
|
|
49
|
+
description: '开 DSH 时把网页发布到你的 Tailscale 网络(tailnet),手机在任何网络下打开下面的链接就能连上;关掉 DSH 会自动撤掉这条路由。',
|
|
50
|
+
loading: '正在读取…',
|
|
51
|
+
urlLabel: '手机登录链接',
|
|
52
|
+
plainLabel: '直连地址',
|
|
53
|
+
copy: '复制',
|
|
54
|
+
copied: '已复制',
|
|
55
|
+
copyFailed: '复制失败,请手动选中输入框里的内容复制',
|
|
56
|
+
rotate: '更换密钥',
|
|
57
|
+
rotateConfirm: '确认更换',
|
|
58
|
+
rotateCancel: '取消',
|
|
59
|
+
rotateWarning: '换完之后旧链接立刻失效,需要把新链接重新存一次书签。已经登录过的手机不受影响。',
|
|
60
|
+
rotateDone: '已更换。请重新保存下面的链接。',
|
|
61
|
+
rotateFailed: '更换失败',
|
|
62
|
+
noLogin: '登录入口已关闭(loginRoute=false),下面的直连地址需要先在本机用一次性令牌登录。',
|
|
63
|
+
keepBookmark: '存一次书签即可,跨重启不变。',
|
|
64
|
+
cookieHint: (days) => `登录状态有效期 ${days} 天。`,
|
|
65
|
+
retry: '重试',
|
|
66
|
+
phaseStarting: '还在启动中(拉起 Tailscale / 建立 Serve 路由)。',
|
|
67
|
+
phaseUnavailable: 'Tailscale 没在运行,远程访问暂时不可用。',
|
|
68
|
+
phaseError: '远程访问没能建起来。',
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const EN = {
|
|
72
|
+
title: 'Phone access',
|
|
73
|
+
description: 'While DSH runs, its Web GUI is published to your Tailscale network (tailnet), so a phone anywhere can open the link below. Quitting DSH removes the route.',
|
|
74
|
+
loading: 'Loading…',
|
|
75
|
+
urlLabel: 'Phone login link',
|
|
76
|
+
plainLabel: 'Direct address',
|
|
77
|
+
copy: 'Copy',
|
|
78
|
+
copied: 'Copied',
|
|
79
|
+
copyFailed: 'Copy failed — select the field and copy manually',
|
|
80
|
+
rotate: 'Change key',
|
|
81
|
+
rotateConfirm: 'Confirm change',
|
|
82
|
+
rotateCancel: 'Cancel',
|
|
83
|
+
rotateWarning: 'The old link stops working immediately; bookmark the new one. Phones already signed in are unaffected.',
|
|
84
|
+
rotateDone: 'Changed. Save the link below again.',
|
|
85
|
+
rotateFailed: 'Change failed',
|
|
86
|
+
noLogin: 'The login entry is off (loginRoute=false); the direct address needs a one-time token login from this machine first.',
|
|
87
|
+
keepBookmark: 'Bookmark it once — it survives restarts.',
|
|
88
|
+
cookieHint: (days) => `Sign-in stays valid for ${days} days.`,
|
|
89
|
+
retry: 'Retry',
|
|
90
|
+
phaseStarting: 'Still starting up (bringing up Tailscale / the Serve route).',
|
|
91
|
+
phaseUnavailable: 'Tailscale is not running, so remote access is unavailable.',
|
|
92
|
+
phaseError: 'Remote access could not be established.',
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 当前该说中文还是英文。
|
|
97
|
+
*
|
|
98
|
+
* 只读 `ctx.get('locale')`(可选服务,**不写进 inject**:写进去会在
|
|
99
|
+
* locale 缺席时把整个包 park 掉)。拿不到就退回浏览器语言。
|
|
100
|
+
* @param ctx - 客户端根上下文。
|
|
101
|
+
* @returns 'zh' 或 'en'。
|
|
102
|
+
*/
|
|
103
|
+
function pickLanguage(ctx) {
|
|
104
|
+
let active
|
|
105
|
+
try {
|
|
106
|
+
active = ctx.get('locale')?.getSnapshot?.().active
|
|
107
|
+
} catch {
|
|
108
|
+
active = undefined
|
|
109
|
+
}
|
|
110
|
+
if (typeof active === 'string' && active !== '') {
|
|
111
|
+
return active.toLowerCase().startsWith('zh') ? 'zh' : 'en'
|
|
112
|
+
}
|
|
113
|
+
const nav = typeof navigator === 'undefined' ? '' : navigator.language
|
|
114
|
+
return String(nav).toLowerCase().startsWith('zh') ? 'zh' : 'en'
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 行内样式。token 名照抄宿主,括号里是浅色兜底。 */
|
|
118
|
+
const styles = {
|
|
119
|
+
row: {
|
|
120
|
+
display: 'flex',
|
|
121
|
+
flexDirection: 'column',
|
|
122
|
+
gap: '10px',
|
|
123
|
+
padding: '16px 0',
|
|
124
|
+
borderBottom: '.5px solid var(--dsw-alias-border-l2,#e5e7eb)',
|
|
125
|
+
},
|
|
126
|
+
head: { display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '24px' },
|
|
127
|
+
title: { fontSize: 14, lineHeight: '20px', color: 'var(--dsw-alias-label-primary,#111827)' },
|
|
128
|
+
description: {
|
|
129
|
+
marginTop: 4,
|
|
130
|
+
fontSize: 12,
|
|
131
|
+
lineHeight: '18px',
|
|
132
|
+
color: 'var(--dsw-alias-label-secondary,#6b7280)',
|
|
133
|
+
},
|
|
134
|
+
actions: { display: 'flex', alignItems: 'center', gap: 8, flex: 'none' },
|
|
135
|
+
field: { display: 'flex', alignItems: 'center', gap: 8 },
|
|
136
|
+
label: {
|
|
137
|
+
fontSize: 12,
|
|
138
|
+
lineHeight: '18px',
|
|
139
|
+
color: 'var(--dsw-alias-label-secondary,#6b7280)',
|
|
140
|
+
flex: 'none',
|
|
141
|
+
},
|
|
142
|
+
input: {
|
|
143
|
+
flex: '1 1 auto',
|
|
144
|
+
minWidth: 0,
|
|
145
|
+
font: 'inherit',
|
|
146
|
+
fontSize: 12,
|
|
147
|
+
lineHeight: '18px',
|
|
148
|
+
padding: '5px 8px',
|
|
149
|
+
borderRadius: 'var(--dsw-radius-sm,8px)',
|
|
150
|
+
border: '1px solid var(--dsw-alias-border-l2,#e5e7eb)',
|
|
151
|
+
background: 'var(--dsw-alias-bg-base,#fff)',
|
|
152
|
+
color: 'var(--dsw-alias-label-primary,#111827)',
|
|
153
|
+
},
|
|
154
|
+
button: {
|
|
155
|
+
font: 'inherit',
|
|
156
|
+
fontSize: 12,
|
|
157
|
+
lineHeight: '18px',
|
|
158
|
+
padding: '5px 10px',
|
|
159
|
+
borderRadius: 'var(--dsw-radius-sm,8px)',
|
|
160
|
+
border: '1px solid var(--dsw-alias-border-l2,#e5e7eb)',
|
|
161
|
+
background: 'transparent',
|
|
162
|
+
color: 'var(--dsw-alias-label-primary,#111827)',
|
|
163
|
+
cursor: 'pointer',
|
|
164
|
+
whiteSpace: 'nowrap',
|
|
165
|
+
flex: 'none',
|
|
166
|
+
},
|
|
167
|
+
buttonPrimary: {
|
|
168
|
+
borderColor: 'transparent',
|
|
169
|
+
background: 'var(--dsw-alias-state-warn-primary,#b45309)',
|
|
170
|
+
color: '#fff',
|
|
171
|
+
},
|
|
172
|
+
hint: { fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-label-secondary,#6b7280)' },
|
|
173
|
+
warn: { fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-state-warn-primary,#b45309)' },
|
|
174
|
+
error: { fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-state-error-primary,#dc2626)' },
|
|
175
|
+
ok: { fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-state-success-primary,#16a34a)' },
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 读一个 JSON 端点。
|
|
180
|
+
*
|
|
181
|
+
* 失败时状态码不是 200,但 body 仍是 JSON 且带着真正的原因,所以这里
|
|
182
|
+
* 先解析、再看 `ok`,不拿状态码覆盖原文(照抄 dsh-mini-remote 的经验)。
|
|
183
|
+
* @param path - 同源路径。
|
|
184
|
+
* @param options - fetch 选项。
|
|
185
|
+
* @returns `{ ok: true, data }` 或 `{ ok: false, error }`。
|
|
186
|
+
*/
|
|
187
|
+
async function readJson(path, options) {
|
|
188
|
+
let response
|
|
189
|
+
try {
|
|
190
|
+
response = await fetch(path, { credentials: 'same-origin', cache: 'no-store', ...options })
|
|
191
|
+
} catch (error) {
|
|
192
|
+
return { ok: false, error: String((error && error.message) || error) }
|
|
193
|
+
}
|
|
194
|
+
let body
|
|
195
|
+
try {
|
|
196
|
+
body = await response.json()
|
|
197
|
+
} catch {
|
|
198
|
+
return { ok: false, error: `HTTP ${response.status}` }
|
|
199
|
+
}
|
|
200
|
+
if (body === null || typeof body !== 'object') return { ok: false, error: `HTTP ${response.status}` }
|
|
201
|
+
if (body.ok !== true) {
|
|
202
|
+
return { ok: false, error: typeof body.error === 'string' ? body.error : `HTTP ${response.status}` }
|
|
203
|
+
}
|
|
204
|
+
return { ok: true, data: body }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 把文案里的阶段/错误翻成人话;认不出来就把原文附在后面。 */
|
|
208
|
+
function describePhase(t, state) {
|
|
209
|
+
if (state === null) return t.loading
|
|
210
|
+
if (state.phase === 'starting') return t.phaseStarting
|
|
211
|
+
if (state.phase === 'unavailable') return t.phaseUnavailable
|
|
212
|
+
if (state.phase === 'error') return t.phaseError
|
|
213
|
+
return undefined
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* 「设置 → 通用」里的这一行。
|
|
218
|
+
*
|
|
219
|
+
* 只在挂载时取一次状态;另有 window focus 时刷新一次(桌面 App 里
|
|
220
|
+
* 用户切走再切回来很常见,这时 Tailscale 可能刚好起来了)。
|
|
221
|
+
* @param props - 注入面给的 `ctx`(见 apply 里的 `inject`)。
|
|
222
|
+
*/
|
|
223
|
+
function RemoteRow(props) {
|
|
224
|
+
const ctx = props.ctx
|
|
225
|
+
// 语言变化时强制重渲染;字典本身是行内常量,不需要订阅字典。
|
|
226
|
+
const [, setTick] = useState(0)
|
|
227
|
+
const [state, setState] = useState(null)
|
|
228
|
+
const [error, setError] = useState(null)
|
|
229
|
+
const [busy, setBusy] = useState(false)
|
|
230
|
+
const [copied, setCopied] = useState(false)
|
|
231
|
+
const [confirming, setConfirming] = useState(false)
|
|
232
|
+
const [notice, setNotice] = useState(null)
|
|
233
|
+
const inputRef = useRef(null)
|
|
234
|
+
|
|
235
|
+
const t = pickLanguage(ctx) === 'zh' ? ZH : EN
|
|
236
|
+
|
|
237
|
+
const load = useCallback(async () => {
|
|
238
|
+
const result = await readJson(STATE_PATH)
|
|
239
|
+
if (result.ok) {
|
|
240
|
+
setState(result.data)
|
|
241
|
+
setError(null)
|
|
242
|
+
} else {
|
|
243
|
+
setError(result.error)
|
|
244
|
+
}
|
|
245
|
+
}, [])
|
|
246
|
+
|
|
247
|
+
useEffect(() => {
|
|
248
|
+
void load()
|
|
249
|
+
}, [load])
|
|
250
|
+
|
|
251
|
+
useEffect(() => {
|
|
252
|
+
const locale = (() => {
|
|
253
|
+
try {
|
|
254
|
+
return ctx.get('locale')
|
|
255
|
+
} catch {
|
|
256
|
+
return undefined
|
|
257
|
+
}
|
|
258
|
+
})()
|
|
259
|
+
if (locale === undefined || typeof locale.subscribe !== 'function') return undefined
|
|
260
|
+
return locale.subscribe(() => setTick((n) => n + 1))
|
|
261
|
+
}, [ctx])
|
|
262
|
+
|
|
263
|
+
useEffect(() => {
|
|
264
|
+
const onFocus = () => {
|
|
265
|
+
void load()
|
|
266
|
+
}
|
|
267
|
+
window.addEventListener('focus', onFocus)
|
|
268
|
+
return () => window.removeEventListener('focus', onFocus)
|
|
269
|
+
}, [load])
|
|
270
|
+
|
|
271
|
+
const url = state === null ? null : state.loginUrl || state.plainUrl
|
|
272
|
+
const usesLoginUrl = state !== null && state.loginUrl !== null && state.loginUrl !== undefined
|
|
273
|
+
|
|
274
|
+
const copy = useCallback(async () => {
|
|
275
|
+
if (url === null || url === undefined) return
|
|
276
|
+
let done = false
|
|
277
|
+
try {
|
|
278
|
+
if (typeof navigator !== 'undefined' && navigator.clipboard !== undefined) {
|
|
279
|
+
await navigator.clipboard.writeText(url)
|
|
280
|
+
done = true
|
|
281
|
+
}
|
|
282
|
+
} catch {
|
|
283
|
+
done = false
|
|
284
|
+
}
|
|
285
|
+
if (!done) {
|
|
286
|
+
// 非安全上下文里 clipboard 不可用(tailnet 的 https 和 localhost 都算安全,
|
|
287
|
+
// 但用户可能从 http 的局域网地址进来)。退回选中输入框内容,让人手动复制。
|
|
288
|
+
try {
|
|
289
|
+
inputRef.current?.select?.()
|
|
290
|
+
done = document.execCommand('copy')
|
|
291
|
+
} catch {
|
|
292
|
+
done = false
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
setNotice(done ? null : t.copyFailed)
|
|
296
|
+
setCopied(done)
|
|
297
|
+
if (done) setTimeout(() => setCopied(false), 1600)
|
|
298
|
+
}, [url, t])
|
|
299
|
+
|
|
300
|
+
const rotate = useCallback(async () => {
|
|
301
|
+
setBusy(true)
|
|
302
|
+
setNotice(null)
|
|
303
|
+
const result = await readJson(ROTATE_PATH, { method: 'POST' })
|
|
304
|
+
setBusy(false)
|
|
305
|
+
setConfirming(false)
|
|
306
|
+
if (!result.ok) {
|
|
307
|
+
setNotice(`${t.rotateFailed}:${result.error}`)
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
setState((previous) => ({ ...(previous ?? {}), ...result.data }))
|
|
311
|
+
setNotice(t.rotateDone)
|
|
312
|
+
}, [t])
|
|
313
|
+
|
|
314
|
+
const canRotate = state !== null && state.canRotate === true && !busy
|
|
315
|
+
|
|
316
|
+
// 首帧:还没拿到状态,也没出错。这时不要摆一个空输入框配一个会误导的标签,
|
|
317
|
+
// 直接说「正在读取」。
|
|
318
|
+
if (state === null && error === null) {
|
|
319
|
+
return h(
|
|
320
|
+
'div',
|
|
321
|
+
{ style: styles.row, 'data-dsh-tailscale-remote-row': '' },
|
|
322
|
+
h('div', { style: styles.title }, t.title),
|
|
323
|
+
h('div', { style: styles.description }, t.description),
|
|
324
|
+
h('div', { style: styles.hint }, t.loading),
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const hint = describePhase(t, state)
|
|
329
|
+
|
|
330
|
+
return h(
|
|
331
|
+
'div',
|
|
332
|
+
{ style: styles.row, 'data-dsh-tailscale-remote-row': '' },
|
|
333
|
+
h(
|
|
334
|
+
'div',
|
|
335
|
+
{ style: styles.head },
|
|
336
|
+
h(
|
|
337
|
+
'div',
|
|
338
|
+
null,
|
|
339
|
+
h('div', { style: styles.title }, t.title),
|
|
340
|
+
h('div', { style: styles.description }, t.description),
|
|
341
|
+
),
|
|
342
|
+
h(
|
|
343
|
+
'div',
|
|
344
|
+
{ style: styles.actions },
|
|
345
|
+
confirming
|
|
346
|
+
? [
|
|
347
|
+
h(
|
|
348
|
+
'button',
|
|
349
|
+
{
|
|
350
|
+
key: 'go',
|
|
351
|
+
type: 'button',
|
|
352
|
+
style: { ...styles.button, ...styles.buttonPrimary },
|
|
353
|
+
disabled: busy,
|
|
354
|
+
onClick: () => void rotate(),
|
|
355
|
+
},
|
|
356
|
+
t.rotateConfirm,
|
|
357
|
+
),
|
|
358
|
+
h(
|
|
359
|
+
'button',
|
|
360
|
+
{ key: 'no', type: 'button', style: styles.button, disabled: busy, onClick: () => setConfirming(false) },
|
|
361
|
+
t.rotateCancel,
|
|
362
|
+
),
|
|
363
|
+
]
|
|
364
|
+
: h(
|
|
365
|
+
'button',
|
|
366
|
+
{
|
|
367
|
+
type: 'button',
|
|
368
|
+
style: styles.button,
|
|
369
|
+
disabled: !canRotate,
|
|
370
|
+
onClick: () => {
|
|
371
|
+
setNotice(null)
|
|
372
|
+
setConfirming(true)
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
t.rotate,
|
|
376
|
+
),
|
|
377
|
+
),
|
|
378
|
+
),
|
|
379
|
+
h(
|
|
380
|
+
'div',
|
|
381
|
+
{ style: styles.field },
|
|
382
|
+
h('span', { style: styles.label }, usesLoginUrl ? t.urlLabel : t.plainLabel),
|
|
383
|
+
h('input', {
|
|
384
|
+
ref: inputRef,
|
|
385
|
+
readOnly: true,
|
|
386
|
+
spellCheck: false,
|
|
387
|
+
value: url ?? '',
|
|
388
|
+
'aria-label': usesLoginUrl ? t.urlLabel : t.plainLabel,
|
|
389
|
+
style: styles.input,
|
|
390
|
+
onFocus: (event) => {
|
|
391
|
+
try {
|
|
392
|
+
event.target.select()
|
|
393
|
+
} catch {
|
|
394
|
+
/* 选中失败无所谓 */
|
|
395
|
+
}
|
|
396
|
+
},
|
|
397
|
+
}),
|
|
398
|
+
h(
|
|
399
|
+
'button',
|
|
400
|
+
{ type: 'button', style: styles.button, disabled: url === null || url === undefined, onClick: () => void copy() },
|
|
401
|
+
copied ? t.copied : t.copy,
|
|
402
|
+
),
|
|
403
|
+
),
|
|
404
|
+
confirming ? h('div', { style: styles.warn }, t.rotateWarning) : null,
|
|
405
|
+
notice === null ? null : h('div', { style: styles.warn }, notice),
|
|
406
|
+
error !== null
|
|
407
|
+
? h(
|
|
408
|
+
'div',
|
|
409
|
+
{ style: styles.error, role: 'alert' },
|
|
410
|
+
`${error} `,
|
|
411
|
+
h(
|
|
412
|
+
'button',
|
|
413
|
+
{ type: 'button', style: styles.button, onClick: () => void load() },
|
|
414
|
+
t.retry,
|
|
415
|
+
),
|
|
416
|
+
)
|
|
417
|
+
: null,
|
|
418
|
+
state === null || state.phase === 'ready'
|
|
419
|
+
? h(
|
|
420
|
+
'div',
|
|
421
|
+
{ style: styles.hint },
|
|
422
|
+
usesLoginUrl ? `${t.keepBookmark}${t.cookieHint(state?.cookieMaxAgeDays ?? 0)}` : t.noLogin,
|
|
423
|
+
)
|
|
424
|
+
: h('div', { style: styles.hint }, hint),
|
|
425
|
+
)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* 注册这一行。
|
|
430
|
+
*
|
|
431
|
+
* `slots.inject` 而不是 `slots.register` 直调:`settings.general.item` 由
|
|
432
|
+
* ui-settings-general 声明,它和本包的激活顺序没有保证。
|
|
433
|
+
* @param ctx - 客户端根上下文。
|
|
434
|
+
*/
|
|
435
|
+
function apply(ctx) {
|
|
436
|
+
ctx.slots.inject('settings.general.item', () =>
|
|
437
|
+
ctx.slots.register(
|
|
438
|
+
{
|
|
439
|
+
name: 'settings.general.item',
|
|
440
|
+
id: ROW_ID,
|
|
441
|
+
// 排在 performance-usage(30) 之后、current-version(100) 之前。
|
|
442
|
+
order: 95,
|
|
443
|
+
// 组件从注入面上拿 ctx(插槽的 standard props 里没有 ctx)。
|
|
444
|
+
inject: () => ({ ctx }),
|
|
445
|
+
},
|
|
446
|
+
RemoteRow,
|
|
447
|
+
),
|
|
448
|
+
)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
exports.name = 'dsh-tailscale-remote'
|
|
452
|
+
// 只要 slots;locale 走 ctx.get 可选读取(写进 inject 会在它缺席时 park 掉整包)。
|
|
453
|
+
exports.inject = ['slots']
|
|
454
|
+
exports.apply = apply
|
|
455
|
+
return module.exports
|
|
456
|
+
},
|
|
457
|
+
})
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Standalone bundle layer: one Host-only row that owns the Tailscale Serve
|
|
2
|
+
# route in front of this Harness's own Web server. It is inert unless its
|
|
3
|
+
# `enabled` config is true, and it never touches the Web bundle's rows.
|
|
4
|
+
- insert:
|
|
5
|
+
- id: dsh-tailscale-remote
|
|
6
|
+
name: dsh-tailscale-remote
|
|
7
|
+
config:
|
|
8
|
+
enabled: true
|
|
9
|
+
httpsPort: 7259
|