@herenfor/dsh-wsl 0.0.0-stage → 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.
- package/LICENSE +51 -0
- package/README.md +68 -2
- package/README.zh.md +69 -0
- package/agent/broker.mjs +107 -0
- package/agent/cordis.patch.yml +3 -0
- package/agent/index.mjs +130 -0
- package/agent/package.json +9 -0
- package/client.js +817 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +1 -0
- package/ids.mjs +89 -0
- package/index.mjs +586 -0
- package/language.mjs +27 -0
- package/locale/en.json +7 -0
- package/locale/zh.json +7 -0
- package/merge.mjs +65 -0
- package/package.json +59 -4
- package/paths.mjs +24 -0
- package/plugins.mjs +77 -0
- package/remote-mux.mjs +154 -0
- package/routes.mjs +47 -0
- package/routing.mjs +502 -0
- package/selfupdate.mjs +79 -0
- package/sessions.mjs +101 -0
- package/settings.mjs +53 -0
- package/setup.mjs +286 -0
- package/transport/client.mjs +102 -0
- package/transport/daemon.mjs +268 -0
- package/transport/mux.mjs +118 -0
package/index.mjs
ADDED
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
// Desktop side of the UI mirror experiment. Follows the WSL agent's event stream and makes this Host's client module
|
|
2
|
+
// registry publish the merged graph, so the page here mounts the WSL Host's user plugins; their bundle, chunk and asset
|
|
3
|
+
// requests are forwarded to the WSL Host. It patches two members of the registry instance (`composed` and
|
|
4
|
+
// `fetchBundle`) and restores them on dispose. HTTP routes that only the WSL Host serves (those of plugins installed
|
|
5
|
+
// only in WSL) are registered here too and forwarded, after this Host's own login and origin check.
|
|
6
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
7
|
+
import { spawn } from 'node:child_process'
|
|
8
|
+
import { homedir } from 'node:os'
|
|
9
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
10
|
+
import { dirname, join } from 'node:path'
|
|
11
|
+
import http from 'node:http'
|
|
12
|
+
import { isDeepStrictEqual } from 'node:util'
|
|
13
|
+
import { Readable } from 'node:stream'
|
|
14
|
+
import { startBridge } from './transport/client.mjs'
|
|
15
|
+
import { mergeGraphs, requestedPackages, compatibleReleases } from './merge.mjs'
|
|
16
|
+
import { DROP_RESPONSE_HEADERS, eventStreamRewriter, FORWARD_REQUEST_HEADERS, isJson, routesToForward } from './routes.mjs'
|
|
17
|
+
import { mergeDescribe, settingsMethod, SETTINGS_WRITES, sharedNamespaces, syncOps } from './settings.mjs'
|
|
18
|
+
import { wslBundles, wslBundlePage } from './plugins.mjs'
|
|
19
|
+
import { toWslSpec } from './paths.mjs'
|
|
20
|
+
import { installSessionRouting } from './routing.mjs'
|
|
21
|
+
import { translateLoose } from './ids.mjs'
|
|
22
|
+
import { check as checkWsl, compareVersions, commandsFor, promptFor } from './setup.mjs'
|
|
23
|
+
import { noteLanguage, language, pick } from './language.mjs'
|
|
24
|
+
import { installSelfUpdate } from './selfupdate.mjs'
|
|
25
|
+
|
|
26
|
+
export const name = 'wsl-mirror'
|
|
27
|
+
export const inject = ['clientModules', 'webServer', 'connection', 'settings', 'typertGateway']
|
|
28
|
+
const MODULES_ID = '@deepseek-ai/dsh-client-modules'
|
|
29
|
+
const EVENTS = '/wsl-bridge/events'
|
|
30
|
+
const FORWARDED_HEADERS = ['content-type', 'cache-control', 'etag', 'content-length']
|
|
31
|
+
|
|
32
|
+
// How to reach the WSL side: normally found by setup.mjs (the distribution, its Node and DSH; the bridge files are
|
|
33
|
+
// copied into it). A bridge command given in the entry config or ~/.dsh-wsl/bridge.json overrides that (development):
|
|
34
|
+
// { "command": "wsl.exe", "args": ["-d", "Ubuntu", "--exec", "<node>", "<daemon.mjs>", "--bridge", "--profile",
|
|
35
|
+
// "desktop-wsl"] }. DSH_WSL_COMMAND names another wsl.exe (tests) and ignores bridge.json. The WSL Host's address and
|
|
36
|
+
// launch token arrive over the bridge and stay in memory.
|
|
37
|
+
export const BRIDGE_FILE = join(homedir(), '.dsh-wsl', 'bridge.json')
|
|
38
|
+
export const STATUS_FILE = join(homedir(), '.dsh-wsl', 'mirror-status.json')
|
|
39
|
+
export const CONFIG_FILE = join(homedir(), '.dsh-wsl', 'config.json') // { distro, mode } chosen by the person
|
|
40
|
+
const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url))
|
|
41
|
+
function writeStatus(status) {
|
|
42
|
+
try { mkdirSync(dirname(STATUS_FILE), { recursive: true }); writeFileSync(STATUS_FILE, `${JSON.stringify({ at: new Date().toISOString(), pid: process.pid, ...status }, null, 2)}\n`) } catch {}
|
|
43
|
+
}
|
|
44
|
+
function readBridgeConfig(config) {
|
|
45
|
+
if (config.command) return config
|
|
46
|
+
if (process.env.DSH_WSL_COMMAND) return {}
|
|
47
|
+
try { return JSON.parse(readFileSync(BRIDGE_FILE, 'utf8')) } catch { return {} }
|
|
48
|
+
}
|
|
49
|
+
const readJson = (file) => { try { return JSON.parse(readFileSync(file, 'utf8')) } catch { return {} } }
|
|
50
|
+
|
|
51
|
+
export async function apply(ctx, config = {}) {
|
|
52
|
+
const bridgeConfig = readBridgeConfig(config ?? {})
|
|
53
|
+
// First-run setup (setup.mjs): the state of the WSL side, asked again before every connect and on "re-check".
|
|
54
|
+
let setup = { state: 'checking' }
|
|
55
|
+
let checking
|
|
56
|
+
let job // the install/update the person started: { running, log[], code?, error? }
|
|
57
|
+
async function resolveBridge() {
|
|
58
|
+
if (bridgeConfig.command) { setup = { state: 'ready', dev: true }; return { command: bridgeConfig.command, args: bridgeConfig.args ?? [] } }
|
|
59
|
+
if (job?.running) return null
|
|
60
|
+
checking ??= checkWsl({
|
|
61
|
+
wsl: process.env.DSH_WSL_COMMAND ?? 'wsl.exe',
|
|
62
|
+
desktopVersion: ctx.clientModules.composed?.release?.version,
|
|
63
|
+
chosen: readJson(CONFIG_FILE),
|
|
64
|
+
pluginDir: PLUGIN_DIR,
|
|
65
|
+
}).then((status) => { setup = status; return status }, (error) => { setup = { state: 'error', error: error.message }; return setup }).finally(() => { checking = undefined })
|
|
66
|
+
const status = await checking
|
|
67
|
+
if (status.state !== 'ready') ctx.logger.info('wsl-mirror: WSL side not ready: %s%s', status.state, status.distro ? ` (${status.distro})` : '')
|
|
68
|
+
return status.bridge ?? null
|
|
69
|
+
}
|
|
70
|
+
const bridge = startBridge({ resolve: resolveBridge, caps: process.env.DSH_WSL_NO_BROKER ? [] : ['credentials'], log: (m) => ctx.logger.info('wsl-mirror: %s', m) })
|
|
71
|
+
ctx.effect(() => () => bridge.close(), 'wsl-mirror: bridge')
|
|
72
|
+
|
|
73
|
+
// Credential broker, Desktop half (docs/credentials.md, option 3): the WSL DSH Host asks for a credential when it
|
|
74
|
+
// has none of its own, and this Host answers from its own credential service. Values cross the bridge per request
|
|
75
|
+
// and are kept by neither side. A token refresh on the WSL side is written back as a compare-and-swap.
|
|
76
|
+
let credentials
|
|
77
|
+
ctx.inject(['credentials'], (c) => { credentials = c.credentials; return () => { credentials = undefined } })
|
|
78
|
+
const CREDENTIAL_OPS = {
|
|
79
|
+
resolve: async ({ ref }) => { const r = await credentials.resolve(ref); return r ? { value: r.value, source: `desktop-${r.source}` } : null },
|
|
80
|
+
describe: ({ ref }) => credentials.describe(ref),
|
|
81
|
+
readRecord: async ({ key }) => (await credentials.readRecord(key)) ?? null,
|
|
82
|
+
describeRecord: ({ key }) => credentials.describeRecord(key),
|
|
83
|
+
listRecords: () => credentials.listRecords(),
|
|
84
|
+
swapRecord: async ({ key, expected, next }) => {
|
|
85
|
+
let swapped = false
|
|
86
|
+
const after = await credentials.modifyRecord(key, async (current) => {
|
|
87
|
+
if (!isDeepStrictEqual(current ?? null, expected ?? null)) return undefined
|
|
88
|
+
swapped = true
|
|
89
|
+
return next
|
|
90
|
+
})
|
|
91
|
+
return { swapped, record: after ?? null }
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
bridge.on('stream', (stream, target) => {
|
|
95
|
+
if (target?.kind !== 'credentials') { stream.destroy(new Error('not served')); return }
|
|
96
|
+
const parts = []
|
|
97
|
+
stream.on('data', (c) => parts.push(c))
|
|
98
|
+
stream.once('error', () => {})
|
|
99
|
+
stream.once('end', async () => {
|
|
100
|
+
let out
|
|
101
|
+
try {
|
|
102
|
+
const request = JSON.parse(Buffer.concat(parts).toString('utf8'))
|
|
103
|
+
const op = Object.hasOwn(CREDENTIAL_OPS, request.op) ? CREDENTIAL_OPS[request.op] : undefined
|
|
104
|
+
if (!op) throw new Error(`unknown op ${request.op}`)
|
|
105
|
+
if (!credentials) throw new Error('Desktop has no credential service')
|
|
106
|
+
if ((request.ref !== undefined && typeof request.ref !== 'string') || (request.key !== undefined && typeof request.key !== 'string')) throw new Error('bad request')
|
|
107
|
+
out = { value: await op(request) }
|
|
108
|
+
} catch (error) { out = { error: error.message } }
|
|
109
|
+
stream.end(JSON.stringify(out))
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
let lastToken
|
|
113
|
+
let cookieValue
|
|
114
|
+
bridge.on('state', (s) => { if (s.dsh.token !== lastToken) { lastToken = s.dsh.token; cookieValue = undefined } })
|
|
115
|
+
const remoteOrigin = () => bridge.state.dsh.origin
|
|
116
|
+
|
|
117
|
+
/** fetch-like request to the WSL DSH Host over the bridge. `body` may be a Buffer, a string or a Node stream. */
|
|
118
|
+
function remoteFetch(path, { method = 'GET', headers = {}, body, signal } = {}) {
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
const origin = remoteOrigin()
|
|
121
|
+
if (!origin) return reject(new Error('the WSL DSH Host is not available'))
|
|
122
|
+
const url = new URL(path, origin)
|
|
123
|
+
const req = http.request({ agent: bridge.agent, host: url.hostname, port: url.port, path: url.pathname + url.search, method, headers: { ...headers, host: url.host } }, (res) => {
|
|
124
|
+
const out = new Headers()
|
|
125
|
+
for (const [name, value] of Object.entries(res.headers)) for (const v of [].concat(value)) out.append(name, v)
|
|
126
|
+
const empty = method === 'HEAD' || res.statusCode === 204 || res.statusCode === 304
|
|
127
|
+
if (empty) res.resume()
|
|
128
|
+
resolve(new Response(empty ? null : Readable.toWeb(res), { status: res.statusCode, headers: out }))
|
|
129
|
+
})
|
|
130
|
+
req.once('error', reject)
|
|
131
|
+
signal?.addEventListener('abort', () => req.destroy(new Error('aborted')), { once: true })
|
|
132
|
+
if (body === undefined || body === null) req.end()
|
|
133
|
+
else if (typeof body.pipe === 'function') body.pipe(req)
|
|
134
|
+
else req.end(body)
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
const reg = ctx.clientModules
|
|
138
|
+
const { orderByModuleGraph } = await import(pathToFileURL(join(dirname(reg.clientPath(MODULES_ID)), 'index.js')).href)
|
|
139
|
+
|
|
140
|
+
// Routes this mirror registered on the local web server, as `${kind}:${path}` → disposer.
|
|
141
|
+
const ws = ctx.webServer
|
|
142
|
+
const forwarded = new Map()
|
|
143
|
+
function syncRoutes() {
|
|
144
|
+
const sameVersion = remoteGraph !== undefined && compatibleReleases(raw.release, remoteGraph.graph.release)
|
|
145
|
+
const local = {
|
|
146
|
+
exact: [...ws.exact.keys()].filter((path) => !forwarded.has(`exact:${path}`)),
|
|
147
|
+
prefix: [...ws.prefixes.keys()].filter((path) => !forwarded.has(`prefix:${path}`)),
|
|
148
|
+
}
|
|
149
|
+
const want = new Map((sameVersion ? routesToForward(remoteGraph.routes, local) : []).map((r) => [`${r.kind}:${r.path}`, r]))
|
|
150
|
+
for (const [key, dispose] of forwarded) if (!want.has(key)) { dispose(); forwarded.delete(key) }
|
|
151
|
+
for (const [key, route] of want) {
|
|
152
|
+
if (forwarded.has(key)) continue
|
|
153
|
+
try { forwarded.set(key, ws.register({ kind: route.kind, path: route.path, handler: forwardRequest })) } catch (error) { ctx.logger.warn('wsl-mirror: cannot forward %s: %s', key, error.message) }
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
ctx.effect(() => () => { for (const dispose of forwarded.values()) dispose(); forwarded.clear() }, 'wsl-mirror: forwarded routes')
|
|
157
|
+
|
|
158
|
+
async function forwardRequest(req, res, { origin = false } = {}) {
|
|
159
|
+
const admission = ctx.connection.admit(req)
|
|
160
|
+
if ('rejection' in admission) { res.writeHead(admission.rejection); res.end(); return }
|
|
161
|
+
const abort = new AbortController()
|
|
162
|
+
res.on('close', () => abort.abort())
|
|
163
|
+
try {
|
|
164
|
+
const headers = { cookie: await cookie(), ...(origin ? { origin: remoteOrigin() } : {}) }
|
|
165
|
+
for (const name of FORWARD_REQUEST_HEADERS) if (typeof req.headers[name] === 'string') headers[name] = req.headers[name]
|
|
166
|
+
const hasBody = req.method !== 'GET' && req.method !== 'HEAD'
|
|
167
|
+
// A plugin's own route names Sessions and Workspaces in its own shapes: JSON bodies, event streams and the
|
|
168
|
+
// query are rewritten by value so the page sees `wsl:` ids and the WSL Host its own.
|
|
169
|
+
const url = new URL(req.url, 'http://x')
|
|
170
|
+
for (const [name, value] of [...url.searchParams]) url.searchParams.set(name, translateLoose(value, false))
|
|
171
|
+
let body = hasBody ? req : undefined
|
|
172
|
+
if (hasBody && isJson(req.headers['content-type'])) {
|
|
173
|
+
const raw = await readBody(req)
|
|
174
|
+
try { body = Buffer.from(JSON.stringify(translateLoose(JSON.parse(raw.toString('utf8')), false))) } catch { body = raw }
|
|
175
|
+
delete headers['content-length']
|
|
176
|
+
}
|
|
177
|
+
const upstream = await remoteFetch(`${url.pathname}${url.search}`, { method: req.method, headers, signal: abort.signal, ...(body !== undefined ? { body } : {}) })
|
|
178
|
+
const out = {}
|
|
179
|
+
for (const [name, value] of upstream.headers) if (!DROP_RESPONSE_HEADERS.has(name)) out[name] = value
|
|
180
|
+
const type = upstream.headers.get('content-type') ?? ''
|
|
181
|
+
const ids = routing?.wslWorkspaceIds?.() ?? new Set()
|
|
182
|
+
if (isJson(type) && upstream.body) {
|
|
183
|
+
const text = await upstream.text()
|
|
184
|
+
let rewritten = text
|
|
185
|
+
try { rewritten = JSON.stringify(translateLoose(JSON.parse(text), true, ids)) } catch {}
|
|
186
|
+
delete out['content-length']
|
|
187
|
+
res.writeHead(upstream.status, out)
|
|
188
|
+
res.end(rewritten)
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
res.writeHead(upstream.status, out)
|
|
192
|
+
const sse = type.startsWith('text/event-stream') ? eventStreamRewriter((value) => translateLoose(value, true, ids)) : undefined
|
|
193
|
+
if (upstream.body) for await (const chunk of upstream.body) { if (!res.write(sse ? sse(chunk) : chunk)) await new Promise((r) => res.once('drain', r)) }
|
|
194
|
+
res.end()
|
|
195
|
+
} catch (error) {
|
|
196
|
+
if (abort.signal.aborted) return
|
|
197
|
+
if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' })
|
|
198
|
+
res.end(`wsl-mirror: the WSL Host did not answer (${error.message})\n`)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Whether `path` is an exact Fetch route the WSL Host has for `method` and this Host does not. */
|
|
203
|
+
function wslFetchRoute(path, method) {
|
|
204
|
+
const route = remoteGraph?.routes?.fetch?.find((r) => r.path === path)
|
|
205
|
+
if (!route?.methods.includes(method)) return false
|
|
206
|
+
let mine
|
|
207
|
+
try { mine = ctx.connection.fetchRoutes?.get(path) } catch {}
|
|
208
|
+
return !mine?.methods?.has(method)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Settings of plugins installed only in WSL, through the /api request hook client-connection runs after admission.
|
|
212
|
+
const INNER = 'x-dsh-wsl-mirror-inner'
|
|
213
|
+
let remoteNamespaces = new Set()
|
|
214
|
+
const readBody = async (req) => { const parts = []; for await (const c of req) parts.push(c); return Buffer.concat(parts) }
|
|
215
|
+
const forwardApi = async (path, body) => remoteFetch(path, {
|
|
216
|
+
method: 'POST', body,
|
|
217
|
+
headers: { cookie: await cookie(), origin: remoteOrigin(), 'content-type': 'application/json' },
|
|
218
|
+
})
|
|
219
|
+
const callRemoteApi = async (method, body) => remoteFetch(`/api/settings/${method}`, {
|
|
220
|
+
method: 'POST', body,
|
|
221
|
+
headers: { cookie: await cookie(), origin: remoteOrigin(), 'content-type': 'application/json' },
|
|
222
|
+
})
|
|
223
|
+
const answer = async (res, upstream) => {
|
|
224
|
+
res.writeHead(upstream.status, { 'content-type': upstream.headers.get('content-type') ?? 'application/json' })
|
|
225
|
+
res.end(Buffer.from(await upstream.arrayBuffer()))
|
|
226
|
+
}
|
|
227
|
+
// The WSL tab of the Plugins page (client.js): the WSL Host's bundles, switched, removed and installed there. The
|
|
228
|
+
// official Installed tab stays this Host's own. GET → {connected, distro, bundles}; POST {action, name | spec}.
|
|
229
|
+
const callWsl = async (method, args) => {
|
|
230
|
+
const r = await forwardApi(`/api/pluginManager/${method}`, Buffer.from(JSON.stringify({ type: 'client-request', rpcId: 'mirror', method: `pluginManager/${method}`, payload: { args } })))
|
|
231
|
+
const json = await r.json()
|
|
232
|
+
if (!json?.result?.ok) throw Object.assign(new Error(json?.result?.error?.message ?? `HTTP ${r.status}`), { code: json?.result?.error?.code })
|
|
233
|
+
return json.result.value
|
|
234
|
+
}
|
|
235
|
+
ctx.effect(() => ws.register({
|
|
236
|
+
kind: 'exact', path: '/wsl-bridge/plugins',
|
|
237
|
+
async handler(req, res) {
|
|
238
|
+
const admission = ctx.connection.admit(req)
|
|
239
|
+
if ('rejection' in admission) { res.writeHead(admission.rejection); res.end(); return }
|
|
240
|
+
noteLanguage(req)
|
|
241
|
+
const reply = (value) => { res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(value)) }
|
|
242
|
+
const distro = remoteGraph?.distro ?? setup.distro ?? null
|
|
243
|
+
if (remoteGraph === undefined || !compatibleReleases(raw.release, remoteGraph.graph.release)) return reply({ connected: false, distro, setup: publicSetup().state })
|
|
244
|
+
try {
|
|
245
|
+
if (req.method === 'POST') {
|
|
246
|
+
const body = JSON.parse((await readBody(req)).toString('utf8'))
|
|
247
|
+
const call = {
|
|
248
|
+
enable: () => callWsl('setBundleEnabled', { name: body.name, enabled: true }),
|
|
249
|
+
disable: () => callWsl('setBundleEnabled', { name: body.name, enabled: false }),
|
|
250
|
+
remove: () => callWsl('removeBundle', { name: body.name }),
|
|
251
|
+
inspect: () => callWsl('inspect', { spec: toWslSpec(String(body.spec ?? '')) }),
|
|
252
|
+
install: () => callWsl('installBundle', { spec: toWslSpec(String(body.spec ?? '')), options: { enabled: true } }),
|
|
253
|
+
// One component of a bundle, by its Loader entry id, as the official bundle page switches rows.
|
|
254
|
+
row: () => callWsl('setPluginEnabled', { id: body.entryId, enabled: body.enabled === true }),
|
|
255
|
+
}[body.action]
|
|
256
|
+
if (call === undefined) return reply({ connected: true, distro, error: `unknown action ${body.action}` })
|
|
257
|
+
return reply({ connected: true, distro, result: await call() })
|
|
258
|
+
}
|
|
259
|
+
const query = new URL(req.url, 'http://x').searchParams
|
|
260
|
+
const name = query.get('name')
|
|
261
|
+
if (name !== null) {
|
|
262
|
+
// One bundle's page; its document is read on its own, so a failed read leaves the rest of the page.
|
|
263
|
+
if (query.has('language')) {
|
|
264
|
+
try { return reply({ connected: true, distro, detail: await callWsl('readBundleDetail', { name, language: query.get('language'), options: { npmFileSources: [] } }) }) } catch (error) { return reply({ connected: true, distro, detailError: error.code === 'plugin-manager/detail-unavailable' ? 'unavailable' : 'other', error: error.message }) }
|
|
265
|
+
}
|
|
266
|
+
const [bundles, plugins] = await Promise.all([callWsl('listBundles', {}), callWsl('listPlugins', {})])
|
|
267
|
+
return reply({ connected: true, distro, bundle: wslBundlePage(bundles, plugins, name) ?? null })
|
|
268
|
+
}
|
|
269
|
+
reply({ connected: true, distro, bundles: wslBundles(await callWsl('listBundles', {})) })
|
|
270
|
+
} catch (error) { reply({ connected: true, distro, error: error.message, code: error.code }) }
|
|
271
|
+
},
|
|
272
|
+
}), 'wsl-mirror: WSL plugins route')
|
|
273
|
+
// Images beside a WSL bundle's detail document: the WSL Host's own detail-file route, read with this bridge's cookie.
|
|
274
|
+
ctx.effect(() => ws.register({
|
|
275
|
+
kind: 'exact', path: '/wsl-bridge/plugin-file',
|
|
276
|
+
async handler(req, res) {
|
|
277
|
+
const admission = ctx.connection.admit(req)
|
|
278
|
+
if ('rejection' in admission) { res.writeHead(admission.rejection); res.end(); return }
|
|
279
|
+
noteLanguage(req)
|
|
280
|
+
if (remoteGraph === undefined) { res.writeHead(503); res.end(); return }
|
|
281
|
+
try {
|
|
282
|
+
const upstream = await remoteFetch(`/api/plugin-manager/detail-file${new URL(req.url, 'http://x').search}`, { headers: { cookie: await cookie() } })
|
|
283
|
+
res.writeHead(upstream.status, { 'content-type': upstream.headers.get('content-type') ?? 'application/octet-stream', 'cache-control': 'no-store' })
|
|
284
|
+
res.end(Buffer.from(await upstream.arrayBuffer()))
|
|
285
|
+
} catch (error) { if (!res.headersSent) res.writeHead(502); res.end() }
|
|
286
|
+
},
|
|
287
|
+
}), 'wsl-mirror: WSL plugin detail images')
|
|
288
|
+
const failed = (res, body, error) => {
|
|
289
|
+
let rpcId = null
|
|
290
|
+
try { rpcId = JSON.parse(body.toString('utf8')).rpcId } catch {}
|
|
291
|
+
if (res.headersSent) { res.end(); return }
|
|
292
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
293
|
+
res.end(JSON.stringify({ type: 'server-response', rpcId, result: { ok: false, error: { code: 'internal', message: `The WSL Host did not answer: ${error.message}` } } }))
|
|
294
|
+
}
|
|
295
|
+
const guarded = (handler) => async (req, res, next, ...rest) => {
|
|
296
|
+
let body = Buffer.alloc(0)
|
|
297
|
+
const read = req[Symbol.asyncIterator].bind(req)
|
|
298
|
+
req[Symbol.asyncIterator] = async function* () { for await (const c of read()) { body = Buffer.concat([body, c]); yield c } }
|
|
299
|
+
try { return await handler(req, res, next, ...rest) } catch (error) { ctx.logger.warn('wsl-mirror: %s', error.message); failed(res, body, error) }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Each Session runs on this Host or the WSL Host (routing.mjs).
|
|
303
|
+
const available = () => remoteGraph !== undefined && compatibleReleases(raw.release, remoteGraph.graph.release)
|
|
304
|
+
const routing = installSessionRouting(ctx, {
|
|
305
|
+
bridge, remoteOrigin, cookie, forwardApi, remoteFetch, readBody, available,
|
|
306
|
+
whenAvailable: (signal) => new Promise((resolve) => {
|
|
307
|
+
const done = () => { if (!available() && !signal.aborted) return; availability.removeEventListener('change', done); signal.removeEventListener('abort', done); resolve() }
|
|
308
|
+
availability.addEventListener('change', done); signal.addEventListener('abort', done); done()
|
|
309
|
+
}),
|
|
310
|
+
distro: () => remoteGraph?.distro ?? setup.distro,
|
|
311
|
+
setupState: () => publicSetup().state,
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
// ---- first-run setup: GET answers where the WSL side stands; POST {action:'recheck'} checks again and connects,
|
|
315
|
+
// POST {distro} switches the distribution. The page shows this in the environment menu and the setup dialog.
|
|
316
|
+
const publicSetup = () => {
|
|
317
|
+
const s = setup
|
|
318
|
+
let state = s.state
|
|
319
|
+
const dsh = bridge.state.dsh
|
|
320
|
+
const failed = !bridge.state.connected && bridge.state.lastError
|
|
321
|
+
const wslVersion = remoteGraph?.graph.release?.version ?? null
|
|
322
|
+
const desktopVersion = raw.release?.version ?? null
|
|
323
|
+
if (state === 'ready') {
|
|
324
|
+
if (remoteGraph !== undefined && !compatibleReleases(raw.release, remoteGraph.graph.release)) state = compareVersions(wslVersion, desktopVersion) < 0 ? 'version-mismatch' : 'desktop-outdated'
|
|
325
|
+
else state = remoteGraph !== undefined ? 'connected' : dsh.state === 'stopped' || failed ? 'error' : 'starting'
|
|
326
|
+
}
|
|
327
|
+
if (job?.running) state = 'installing'
|
|
328
|
+
return {
|
|
329
|
+
state, distro: s.distro ?? remoteGraph?.distro ?? null, distros: (s.distros ?? []).map((d) => d.name), dev: !!s.dev,
|
|
330
|
+
mode: s.mode ?? null, own: s.own ? { version: s.own.version, path: s.own.entry } : null,
|
|
331
|
+
// Once connected, the versions that matter are the running ones.
|
|
332
|
+
drift: state === 'connected' ? ((o) => (o === 0 ? null : o < 0 ? 'wsl-older' : 'wsl-newer'))(compareVersions(wslVersion, desktopVersion)) : s.drift ?? null,
|
|
333
|
+
desktopVersion, installed: s.installed ?? wslVersion, wslVersion,
|
|
334
|
+
commands: commandsFor(s, desktopVersion, language()), prompt: promptFor(s, desktopVersion, language()), canInstall: !!s.script && !s.dev, probeError: s.probeError ?? null,
|
|
335
|
+
plugin: selfUpdate.state(),
|
|
336
|
+
job: job ? { running: job.running, log: job.log.slice(-12).join('\n'), code: job.code ?? null, error: job.failure === 'not-published' ? pick({ zh: `npm 上还没有 @deepseek-ai/dsh@${job.version},要等它发布后再装。`, en: `@deepseek-ai/dsh@${job.version} is not on npm yet; install it once it is published.` })
|
|
337
|
+
: job.failure === 'network' ? pick({ zh: '网络连接失败,可以稍后重试。', en: 'The network connection failed; try again later.' })
|
|
338
|
+
: job.failure === 'exit' ? pick({ zh: `安装失败(退出码 ${job.code})`, en: `The install failed (exit code ${job.code})` }) : job.error ?? null } : null,
|
|
339
|
+
error: s.error ?? (dsh.state === 'stopped' ? dsh.exit?.error ?? `DSH exited (${dsh.exit?.code ?? dsh.exit?.signal ?? '?'})` : failed ? bridge.state.lastError : null),
|
|
340
|
+
log: s.home ? `${s.home}/.dsh-wsl/run/desktop-wsl/daemon.log` : null,
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
// This plugin's own updates (selfupdate.mjs).
|
|
344
|
+
const own = readJson(join(PLUGIN_DIR, 'package.json'))
|
|
345
|
+
const selfUpdate = installSelfUpdate(ctx, {
|
|
346
|
+
name: own.name, version: own.version,
|
|
347
|
+
auto: () => readJson(CONFIG_FILE).autoUpdate !== false,
|
|
348
|
+
log: (m) => ctx.logger.info('wsl-mirror: %s', m),
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
// The install/update button: runs setup's script in WSL (it stops the daemon first, then installs or updates DSH).
|
|
352
|
+
// Only on the person's click; the bridge stays down meanwhile and connects again when it is done.
|
|
353
|
+
function startInstall() {
|
|
354
|
+
if (job?.running || !setup.script || setup.dev) return
|
|
355
|
+
const version = raw.release?.version
|
|
356
|
+
job = { running: true, log: [], startedAt: Date.now() }
|
|
357
|
+
const current = job
|
|
358
|
+
const child = spawn(process.env.DSH_WSL_COMMAND ?? 'wsl.exe', ['-d', setup.distro, '--exec', 'bash', '-c', setup.script], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
359
|
+
let partial = ''
|
|
360
|
+
const take = (chunk) => {
|
|
361
|
+
partial += chunk.toString('utf8').replace(/\r/g, '\n')
|
|
362
|
+
const lines = partial.split('\n'); partial = lines.pop()
|
|
363
|
+
for (const line of lines) if (line.trim()) current.log.push(line.slice(0, 300))
|
|
364
|
+
if (current.log.length > 400) current.log.splice(0, current.log.length - 200)
|
|
365
|
+
}
|
|
366
|
+
child.stdout.on('data', take); child.stderr.on('data', take)
|
|
367
|
+
const timer = setTimeout(() => child.kill(), 30 * 60_000); timer.unref?.()
|
|
368
|
+
let finished = false
|
|
369
|
+
const finish = async (code, error) => {
|
|
370
|
+
if (finished) return
|
|
371
|
+
finished = true
|
|
372
|
+
clearTimeout(timer)
|
|
373
|
+
if (partial.trim()) current.log.push(partial)
|
|
374
|
+
const text = current.log.join('\n')
|
|
375
|
+
current.code = code
|
|
376
|
+
if (code !== 0) {
|
|
377
|
+
// Known failures are worded when the page asks, in its language.
|
|
378
|
+
current.version = version
|
|
379
|
+
current.failure = /ETARGET|No matching version|notarget/i.test(text) ? 'not-published' : /ENOTFOUND|ECONNRESET|ETIMEDOUT|EAI_AGAIN|network/i.test(text) ? 'network' : undefined
|
|
380
|
+
current.error = error ?? current.log.filter((l) => /err/i.test(l)).slice(-1)[0]
|
|
381
|
+
if (current.failure === undefined && current.error === undefined) current.failure = 'exit'
|
|
382
|
+
}
|
|
383
|
+
current.running = false
|
|
384
|
+
ctx.logger.info('wsl-mirror: WSL install finished with %s', code)
|
|
385
|
+
await resolveBridge(); bridge.retry()
|
|
386
|
+
}
|
|
387
|
+
child.once('error', (e) => void finish(null, e.message))
|
|
388
|
+
child.once('exit', (code) => void finish(code))
|
|
389
|
+
}
|
|
390
|
+
ctx.effect(() => () => { if (job?.running) job.running = false }, 'wsl-mirror: install job')
|
|
391
|
+
|
|
392
|
+
ctx.effect(() => ws.register({
|
|
393
|
+
kind: 'exact', path: '/wsl-bridge/setup',
|
|
394
|
+
async handler(req, res) {
|
|
395
|
+
const admission = ctx.connection.admit(req)
|
|
396
|
+
if ('rejection' in admission) { res.writeHead(admission.rejection); res.end(); return }
|
|
397
|
+
noteLanguage(req)
|
|
398
|
+
if (req.method === 'POST') {
|
|
399
|
+
let body = {}
|
|
400
|
+
try { body = JSON.parse((await readBody(req)).toString('utf8')) } catch {}
|
|
401
|
+
const remember = (patch) => { try { mkdirSync(dirname(CONFIG_FILE), { recursive: true }); writeFileSync(CONFIG_FILE, `${JSON.stringify({ ...readJson(CONFIG_FILE), ...patch }, null, 2)}\n`) } catch {} }
|
|
402
|
+
if (typeof body.distro === 'string' && body.distro !== readJson(CONFIG_FILE).distro) {
|
|
403
|
+
remember({ distro: body.distro })
|
|
404
|
+
await resolveBridge(); bridge.restart()
|
|
405
|
+
} else if ((body.mode === 'reuse' || body.mode === 'private') && body.mode !== readJson(CONFIG_FILE).mode) {
|
|
406
|
+
// Another DSH and another DSH home: the daemon is replaced on the next connect.
|
|
407
|
+
remember({ mode: body.mode })
|
|
408
|
+
await resolveBridge(); bridge.restart()
|
|
409
|
+
} else if (typeof body.autoUpdate === 'boolean') {
|
|
410
|
+
remember({ autoUpdate: body.autoUpdate })
|
|
411
|
+
if (body.autoUpdate) void selfUpdate.check()
|
|
412
|
+
} else if (body.action === 'check-plugin') {
|
|
413
|
+
await selfUpdate.check()
|
|
414
|
+
} else if (body.action === 'install') {
|
|
415
|
+
startInstall()
|
|
416
|
+
} else if (body.action === 'recheck') {
|
|
417
|
+
if (!bridge.state.connected) { await resolveBridge(); bridge.retry() }
|
|
418
|
+
}
|
|
419
|
+
// Give a fresh connection a moment, so the answer already says whether it came up.
|
|
420
|
+
for (let i = 0; i < 40 && setup.state === 'ready' && publicSetup().state === 'starting'; i++) await new Promise((r) => setTimeout(r, 250))
|
|
421
|
+
}
|
|
422
|
+
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' })
|
|
423
|
+
res.end(JSON.stringify(publicSetup()))
|
|
424
|
+
},
|
|
425
|
+
}), 'wsl-mirror: setup route')
|
|
426
|
+
|
|
427
|
+
// Desktop → WSL settings sync for the namespaces both Hosts have (see settings.mjs): on connecting, after this Host
|
|
428
|
+
// writes settings, and once a minute.
|
|
429
|
+
let lastSync = null
|
|
430
|
+
let syncing = false
|
|
431
|
+
let syncAgain = false
|
|
432
|
+
const callSettings = async (method, args) => {
|
|
433
|
+
const r = await forwardApi(`/api/settings/${method}`, Buffer.from(JSON.stringify({ type: 'client-request', rpcId: 'mirror-sync', method: `settings/${method}`, payload: { args } })))
|
|
434
|
+
const json = await r.json()
|
|
435
|
+
if (!json?.result?.ok) throw new Error(`settings/${method}: ${json?.result?.error?.message ?? r.status}`)
|
|
436
|
+
return json.result.value
|
|
437
|
+
}
|
|
438
|
+
async function syncSettings(reason) {
|
|
439
|
+
if (syncing) { syncAgain = true; return }
|
|
440
|
+
syncing = true
|
|
441
|
+
try {
|
|
442
|
+
do {
|
|
443
|
+
syncAgain = false
|
|
444
|
+
if (remoteGraph === undefined || !compatibleReleases(raw.release, remoteGraph.graph.release)) return
|
|
445
|
+
const mine = ctx.settings.describe({ redactSecrets: true })
|
|
446
|
+
const theirs = await callSettings('describe', {})
|
|
447
|
+
const changed = []
|
|
448
|
+
for (const { ns, desktop, wsl } of sharedNamespaces(mine, theirs.namespaces)) {
|
|
449
|
+
const ops = syncOps(desktop, wsl)
|
|
450
|
+
if (ops.length === 0) continue
|
|
451
|
+
await callSettings('mutate', { ns, ops, expectedRevision: wsl.revision })
|
|
452
|
+
changed.push({ ns, fields: ops.map((o) => `${o.op} ${o.path.join('.')}`) })
|
|
453
|
+
}
|
|
454
|
+
lastSync = { at: new Date().toISOString(), reason, changed }
|
|
455
|
+
if (changed.length) ctx.logger.info('wsl-mirror: settings copied to WSL: %s', changed.map((c) => c.ns).join(', '))
|
|
456
|
+
} while (syncAgain)
|
|
457
|
+
} catch (error) {
|
|
458
|
+
lastSync = { at: new Date().toISOString(), reason, error: error.message }
|
|
459
|
+
ctx.logger.warn('wsl-mirror: settings sync failed: %s', error.message)
|
|
460
|
+
} finally {
|
|
461
|
+
syncing = false
|
|
462
|
+
writeStatus({ local: raw.release, remote: remoteGraph?.graph.release, mirrored: [...state.mirrored.keys()], dropped: [...(state.dropped ?? [])], routes: [...forwarded.keys()], settingsSync: lastSync })
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
ctx.effect(() => { const t = setInterval(() => void syncSettings('periodic'), 60_000); t.unref(); return () => clearInterval(t) }, 'wsl-mirror: periodic settings sync')
|
|
466
|
+
|
|
467
|
+
ctx.on('connection/request', guarded(async (req, res, next) => {
|
|
468
|
+
const pathname = new URL(req.url ?? '/', 'http://x').pathname
|
|
469
|
+
const method = settingsMethod(pathname)
|
|
470
|
+
const sameVersion = remoteGraph !== undefined && compatibleReleases(raw.release, remoteGraph.graph.release)
|
|
471
|
+
if (req.headers[INNER] !== undefined) return next()
|
|
472
|
+
// A Fetch route only a WSL plugin registered (DS Bot's /api/bot and its live feed): pass the request through.
|
|
473
|
+
if (sameVersion && wslFetchRoute(pathname, req.method)) return forwardRequest(req, res, { origin: true })
|
|
474
|
+
const endpoint = /^\/api\/([\w$]+\/[\w$]+)$/.exec(pathname)?.[1]
|
|
475
|
+
if (!sameVersion) return next()
|
|
476
|
+
if (method === undefined) return endpoint ? routing.call(req, res, next, pathname, endpoint) : next()
|
|
477
|
+
const body = await readBody(req)
|
|
478
|
+
if (method === 'describe') {
|
|
479
|
+
const headers = { cookie: req.headers.cookie ?? '', origin: ws.getOrigin(), 'content-type': 'application/json', [INNER]: '1' }
|
|
480
|
+
const [mine, theirs] = await Promise.all([
|
|
481
|
+
fetch(`${ws.getOrigin()}/api/settings/describe`, { method: 'POST', headers, body }).then((r) => r.json()),
|
|
482
|
+
callRemoteApi('describe', body).then((r) => r.json()).catch(() => undefined),
|
|
483
|
+
])
|
|
484
|
+
if (mine?.result?.ok && theirs?.result?.ok) {
|
|
485
|
+
const merged = mergeDescribe(mine.result.value, theirs.result.value)
|
|
486
|
+
remoteNamespaces = merged.remoteNamespaces
|
|
487
|
+
mine.result.value = merged.value
|
|
488
|
+
}
|
|
489
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
490
|
+
res.end(JSON.stringify(mine))
|
|
491
|
+
return
|
|
492
|
+
}
|
|
493
|
+
let ns
|
|
494
|
+
try { ns = JSON.parse(body.toString('utf8'))?.payload?.args?.ns } catch {}
|
|
495
|
+
if (SETTINGS_WRITES.has(method) && remoteNamespaces.has(ns)) return answer(res, await callRemoteApi(method, body))
|
|
496
|
+
req[Symbol.asyncIterator] = async function* () { yield body } // client-connection reads the body with for-await
|
|
497
|
+
const result = await next()
|
|
498
|
+
if (SETTINGS_WRITES.has(method)) void syncSettings(`desktop ${method} ${ns ?? ''}`.trim())
|
|
499
|
+
return result
|
|
500
|
+
}))
|
|
501
|
+
|
|
502
|
+
let raw = reg.composed
|
|
503
|
+
let remoteGraph
|
|
504
|
+
const availability = new EventTarget() // "change" when the WSL Host comes or goes (routing waits on it)
|
|
505
|
+
let state = mergeGraphs(raw, undefined, orderByModuleGraph)
|
|
506
|
+
const recompute = () => {
|
|
507
|
+
state = mergeGraphs(raw, remoteGraph, orderByModuleGraph)
|
|
508
|
+
syncRoutes()
|
|
509
|
+
writeStatus({ local: raw.release, remote: remoteGraph?.graph.release, mirrored: [...state.mirrored.keys()], dropped: [...(state.dropped ?? [])], routes: [...forwarded.keys()], reason: state.reason })
|
|
510
|
+
ctx.logger.info('wsl-mirror: %d mirrored rows%s', state.mirrored.size, state.reason ? ` (${state.reason})` : `: ${[...state.mirrored.keys()].join(', ')}`)
|
|
511
|
+
}
|
|
512
|
+
const originalFetch = reg.fetchBundle
|
|
513
|
+
Object.defineProperty(reg, 'composed', {
|
|
514
|
+
configurable: true, enumerable: true,
|
|
515
|
+
get: () => state.graph,
|
|
516
|
+
set: (value) => { raw = value; recompute() },
|
|
517
|
+
})
|
|
518
|
+
reg.fetchBundle = async function (request) {
|
|
519
|
+
const ids = requestedPackages(request.url)
|
|
520
|
+
if (!ids.every((id) => state.mirrored.has(id))) return originalFetch.call(this, request)
|
|
521
|
+
const { pathname, search } = new URL(request.url, 'http://x')
|
|
522
|
+
const headers = { cookie: await cookie() }
|
|
523
|
+
const etag = request.headers.get('if-none-match'); if (etag) headers['if-none-match'] = etag
|
|
524
|
+
const res = await remoteFetch(`${pathname}${search}`, { method: request.method, headers })
|
|
525
|
+
const out = new Headers()
|
|
526
|
+
for (const h of FORWARDED_HEADERS) { const v = res.headers.get(h); if (v) out.set(h, v) }
|
|
527
|
+
return new Response(res.body, { status: res.status, headers: out })
|
|
528
|
+
}
|
|
529
|
+
ctx.effect(() => () => {
|
|
530
|
+
delete reg.composed
|
|
531
|
+
reg.composed = raw
|
|
532
|
+
delete reg.fetchBundle
|
|
533
|
+
if (reg.fetchBundle !== originalFetch) reg.fetchBundle = originalFetch
|
|
534
|
+
reg.notifyGraphChanged()
|
|
535
|
+
}, 'wsl-mirror: restore registry')
|
|
536
|
+
|
|
537
|
+
// Launch-token exchange: GET /?token= on the WSL Host answers with its signed browser cookie.
|
|
538
|
+
async function cookie() {
|
|
539
|
+
if (cookieValue) return cookieValue
|
|
540
|
+
const token = bridge.state.dsh.token
|
|
541
|
+
if (!token) throw new Error('the WSL DSH Host has not started')
|
|
542
|
+
const res = await remoteFetch(`/?token=${encodeURIComponent(token)}`)
|
|
543
|
+
const set = res.headers.getSetCookie()
|
|
544
|
+
if (set.length === 0) throw new Error(`wsl-mirror: token exchange answered ${res.status} without a cookie`)
|
|
545
|
+
return (cookieValue = set.map((c) => c.split(';')[0]).join('; '))
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
let stopped = false
|
|
549
|
+
const controller = new AbortController()
|
|
550
|
+
ctx.effect(() => () => { stopped = true; controller.abort() }, 'wsl-mirror: stop following')
|
|
551
|
+
;(async () => {
|
|
552
|
+
for (let delay = 500; !stopped; delay = Math.min(delay * 2, 10_000)) {
|
|
553
|
+
try {
|
|
554
|
+
await bridge.ready(24 * 3600_000)
|
|
555
|
+
const res = await remoteFetch(EVENTS, { headers: { cookie: await cookie() }, signal: controller.signal })
|
|
556
|
+
if (!res.ok) throw new Error(`events answered ${res.status}`)
|
|
557
|
+
delay = 500
|
|
558
|
+
let buffer = ''
|
|
559
|
+
for await (const chunk of res.body.pipeThrough(new TextDecoderStream())) {
|
|
560
|
+
buffer += chunk
|
|
561
|
+
let end
|
|
562
|
+
while ((end = buffer.indexOf('\n\n')) >= 0) {
|
|
563
|
+
const event = buffer.slice(0, end); buffer = buffer.slice(end + 2)
|
|
564
|
+
const data = event.split('\n').filter((l) => l.startsWith('data: ')).map((l) => l.slice(6)).join('\n')
|
|
565
|
+
if (!data) continue
|
|
566
|
+
const firstAfterConnect = remoteGraph === undefined
|
|
567
|
+
remoteGraph = JSON.parse(data)
|
|
568
|
+
recompute()
|
|
569
|
+
if (firstAfterConnect) void syncSettings('connected')
|
|
570
|
+
reg.notifyGraphChanged()
|
|
571
|
+
availability.dispatchEvent(new Event('change'))
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
throw new Error('events stream ended')
|
|
575
|
+
} catch (error) {
|
|
576
|
+
if (stopped) return
|
|
577
|
+
// Disconnected: stop mirroring and forwarding until the WSL Host is back, rather than act on a stale graph.
|
|
578
|
+
if (remoteGraph !== undefined) { remoteGraph = undefined; recompute(); reg.notifyGraphChanged(); availability.dispatchEvent(new Event('change')) }
|
|
579
|
+
writeStatus({ local: raw.release, mirrored: [...state.mirrored.keys()], reason: `connection: ${error.message}` })
|
|
580
|
+
ctx.logger.warn('wsl-mirror: %s', error.message)
|
|
581
|
+
cookieValue = undefined
|
|
582
|
+
}
|
|
583
|
+
await new Promise((r) => setTimeout(r, delay))
|
|
584
|
+
}
|
|
585
|
+
})()
|
|
586
|
+
}
|
package/language.mjs
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// The language the page shows, for text the Host writes itself (setup commands, prompts, errors). The page names its
|
|
2
|
+
// active DSH locale id on every request it makes to this plugin's routes; the latest one wins.
|
|
3
|
+
//
|
|
4
|
+
// Text is given as translations by language id ({ zh, en, ... }); adding a language means adding its entry where
|
|
5
|
+
// the text is written. A language without one falls back to its base language, then to English.
|
|
6
|
+
|
|
7
|
+
export const LANGUAGE_HEADER = 'x-dsh-wsl-language'
|
|
8
|
+
let current = 'en'
|
|
9
|
+
|
|
10
|
+
/** Note the locale id a request names. */
|
|
11
|
+
export function noteLanguage(req) {
|
|
12
|
+
const value = req?.headers?.[LANGUAGE_HEADER]
|
|
13
|
+
if (typeof value === 'string' && /^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$/.test(value)) current = value
|
|
14
|
+
return current
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The latest locale id the page named. */
|
|
18
|
+
export const language = () => current
|
|
19
|
+
|
|
20
|
+
/** The translation for a locale id: the id itself, its base language (zh-CN → zh), or English. */
|
|
21
|
+
export function inLanguage(id, texts) {
|
|
22
|
+
const lower = String(id ?? '').toLowerCase()
|
|
23
|
+
return texts[lower] ?? texts[lower.split('-')[0]] ?? texts.en
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The translation for the latest language the page named. */
|
|
27
|
+
export const pick = (texts) => inLanguage(current, texts)
|
package/locale/en.json
ADDED