@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/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
@@ -0,0 +1,7 @@
1
+ {
2
+ "meta": {
3
+ "title": "WSL run mode",
4
+ "description": "Run chosen tasks in WSL from the Desktop window, with WSL's plugins managed on their own tab.",
5
+ "detail": "README.md"
6
+ }
7
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "meta": {
3
+ "title": "WSL 运行模式",
4
+ "description": "在 Desktop 窗口里把选中的任务放到 WSL 中运行,WSL 里的插件单独管理。",
5
+ "detail": "README.zh.md"
6
+ }
7
+ }