@herenfor/dsh-wsl 0.0.0-stage → 0.1.1

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