@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/routing.mjs
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
// Per-session routing: each Session runs on this Host or on the WSL Host, and the page lists both.
|
|
2
|
+
//
|
|
3
|
+
// Unary calls arrive through client-connection's `connection/request` waterfall (see index.mjs); streams are opened by
|
|
4
|
+
// the Typert gateway's `openWireStream`, which this module wraps. A call or stream that names WSL identities (ids.mjs)
|
|
5
|
+
// goes to the WSL Host with the prefix removed, and what comes back gets it added. The Workspace list, Session list,
|
|
6
|
+
// Session control and forwarded-event streams are merged from both Hosts (sessions.mjs).
|
|
7
|
+
//
|
|
8
|
+
// Where a new Session runs is decided when the page asks for one (`session/create`): folders that live in WSL always
|
|
9
|
+
// run there; for other folders the run location chosen on the new-task page (`/wsl-bridge/run-target`) decides.
|
|
10
|
+
import { noteLanguage, pick } from './language.mjs'
|
|
11
|
+
import http from 'node:http'
|
|
12
|
+
import { randomUUID } from 'node:crypto'
|
|
13
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
14
|
+
import { dirname, join } from 'node:path'
|
|
15
|
+
import { homedir } from 'node:os'
|
|
16
|
+
import { isWsl, routeOf, toPage, toWsl, translate } from './ids.mjs'
|
|
17
|
+
import { applyWorkspaceFrame, baselineFrames, localizeSessions, mergeWorkspaces, pathMapping, placeSession, prefixEvent, prefixProjections } from './sessions.mjs'
|
|
18
|
+
import { remoteError, upgradeHeaders, WslMux } from './remote-mux.mjs'
|
|
19
|
+
|
|
20
|
+
/** Namespaces that belong to this Host whatever they name (handled elsewhere or never per-session). */
|
|
21
|
+
const LOCAL_ONLY = /^(?:settings|credentials|plugins|pluginManager|pluginInventory|pluginMarket|pluginRegistryProbe|pluginRecommendations|cordis|account|remoteAccess|productAnalytics|llm|directoryPicker)\//
|
|
22
|
+
const MERGED_LISTS = new Set(['session/list', 'session/search'])
|
|
23
|
+
const MERGED_STREAMS = new Set(['workspace/follow', 'session/control', '$events'])
|
|
24
|
+
const RUN_TARGETS_FILE = join(homedir(), '.dsh-wsl', 'run-targets.json')
|
|
25
|
+
|
|
26
|
+
export function installSessionRouting(ctx, { bridge, remoteOrigin, cookie, forwardApi, remoteFetch, readBody, available, whenAvailable, distro, setupState = () => 'connected' }) {
|
|
27
|
+
const logger = ctx.logger
|
|
28
|
+
const paths = () => pathMapping(process.platform, distro())
|
|
29
|
+
|
|
30
|
+
// State shared by every page: the latest Workspace lists seen on either Host, twin folders, event clients.
|
|
31
|
+
let localWorkspaces
|
|
32
|
+
let wslWorkspaces
|
|
33
|
+
let twins = new Map()
|
|
34
|
+
const eventClients = new Map() // this Host's $events client id → the WSL Host's
|
|
35
|
+
// Creator (cordis) in a WSL task asks the page through $events: inspection queries and run approvals carry ids the
|
|
36
|
+
// page answers with; plugin runs it starts are named by pluginRunId. Remembered so the answers go back to WSL.
|
|
37
|
+
const wslCordis = { requests: new Set(), runs: new Set(), manifest: undefined }
|
|
38
|
+
let runTargets = new Map()
|
|
39
|
+
try { runTargets = new Map(Object.entries(JSON.parse(readFileSync(RUN_TARGETS_FILE, 'utf8')))) } catch {}
|
|
40
|
+
const saveRunTargets = () => {
|
|
41
|
+
try { mkdirSync(dirname(RUN_TARGETS_FILE), { recursive: true }); writeFileSync(RUN_TARGETS_FILE, `${JSON.stringify(Object.fromEntries(runTargets), null, 2)}\n`) } catch {}
|
|
42
|
+
}
|
|
43
|
+
const remember = () => {
|
|
44
|
+
const merged = mergeWorkspaces(localWorkspaces, wslWorkspaces, paths())
|
|
45
|
+
twins = merged.twins
|
|
46
|
+
return merged.value
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---- the WSL Host's stream mux, over the bridge
|
|
50
|
+
const mux = new WslMux(async () => {
|
|
51
|
+
const origin = remoteOrigin()
|
|
52
|
+
if (!origin) throw new Error('the WSL DSH Host is not available')
|
|
53
|
+
const url = new URL('/api/remote.mux', origin)
|
|
54
|
+
const { headers, accept } = upgradeHeaders()
|
|
55
|
+
const cookieHeader = await cookie()
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
const req = http.request({ agent: bridge.agent, host: url.hostname, port: url.port, path: url.pathname, headers: { ...headers, host: url.host, origin, cookie: cookieHeader } })
|
|
58
|
+
req.once('upgrade', (res, socket, head) => {
|
|
59
|
+
if (res.headers['sec-websocket-accept'] !== accept) { socket.destroy(); reject(new Error('the WSL Host answered the stream upgrade wrongly')); return }
|
|
60
|
+
resolve({ socket, head })
|
|
61
|
+
})
|
|
62
|
+
req.once('response', (res) => { res.resume(); reject(new Error(`the WSL Host refused the stream connection (${res.statusCode})`)) })
|
|
63
|
+
req.once('error', reject)
|
|
64
|
+
req.end()
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
ctx.effect(() => () => mux.close(), 'wsl-mirror: WSL stream mux')
|
|
68
|
+
let lastToken = bridge.state.dsh.token
|
|
69
|
+
bridge.on('state', (s) => { if (s.dsh.token !== lastToken) { lastToken = s.dsh.token; mux.close() } })
|
|
70
|
+
|
|
71
|
+
// ---- unary calls
|
|
72
|
+
const rpc = async (endpoint, args) => {
|
|
73
|
+
const r = await forwardApi(`/api/${endpoint}`, Buffer.from(JSON.stringify({ type: 'client-request', rpcId: randomUUID(), method: endpoint, payload: { args } })))
|
|
74
|
+
const json = await r.json()
|
|
75
|
+
if (!json?.result) throw new Error(`${endpoint} answered ${r.status}`)
|
|
76
|
+
return json.result
|
|
77
|
+
}
|
|
78
|
+
const reply = (res, rpcId, result) => {
|
|
79
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
80
|
+
res.end(JSON.stringify({ type: 'server-response', rpcId, result }))
|
|
81
|
+
}
|
|
82
|
+
const failure = (code, message) => ({ ok: false, error: { code, message, details: {} } })
|
|
83
|
+
/** Forward one call to the WSL Host, identities rewritten both ways; non-JSON answers are passed through. */
|
|
84
|
+
async function forward(res, pathname, message, payload) {
|
|
85
|
+
const upstream = await forwardApi(pathname, Buffer.from(JSON.stringify({ ...message, payload: translate(payload, false) })))
|
|
86
|
+
const type = upstream.headers.get('content-type') ?? ''
|
|
87
|
+
if (!type.startsWith('application/json')) {
|
|
88
|
+
res.writeHead(upstream.status, { 'content-type': type })
|
|
89
|
+
res.end(Buffer.from(await upstream.arrayBuffer()))
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
const json = await upstream.json()
|
|
93
|
+
if (json?.result?.ok) json.result.value = translate(json.result.value, true)
|
|
94
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
95
|
+
res.end(JSON.stringify(json))
|
|
96
|
+
}
|
|
97
|
+
const replay = (req, body) => { req[Symbol.asyncIterator] = async function* () { yield body }; req.headers['content-length'] = String(body.length) }
|
|
98
|
+
const ownHost = async (req, pathname, body) => {
|
|
99
|
+
const r = await fetch(`${ctx.webServer.getOrigin()}${pathname}`, {
|
|
100
|
+
method: 'POST', body,
|
|
101
|
+
headers: { cookie: req.headers.cookie ?? '', origin: ctx.webServer.getOrigin(), 'content-type': 'application/json', 'x-dsh-wsl-mirror-inner': '1' },
|
|
102
|
+
})
|
|
103
|
+
return r.json()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Returns true when the call was handled here; otherwise the caller passes it on (the body is replayed). */
|
|
107
|
+
const debug = process.env.DSH_WSL_DEBUG ? (...a) => { try { writeFileSync(process.env.DSH_WSL_DEBUG, `${new Date().toISOString()} ${a.join(' ')}\n`, { flag: 'a' }) } catch {} } : () => {}
|
|
108
|
+
async function call(req, res, next, pathname, endpoint) {
|
|
109
|
+
debug('call', endpoint, available())
|
|
110
|
+
if (pathname === '/api/session/uploadFileBinary') return upload(req, res, next)
|
|
111
|
+
if (LOCAL_ONLY.test(endpoint) || !available()) return next()
|
|
112
|
+
const body = await readBody(req)
|
|
113
|
+
let message
|
|
114
|
+
try { message = JSON.parse(body.toString('utf8')) } catch { replay(req, body); return next() }
|
|
115
|
+
const payload = message?.payload
|
|
116
|
+
const args = payload?.args ?? {}
|
|
117
|
+
const local = (changed) => { replay(req, changed === undefined ? body : Buffer.from(JSON.stringify({ ...message, payload: { ...payload, args: changed } }))); return next() }
|
|
118
|
+
|
|
119
|
+
if (MERGED_LISTS.has(endpoint)) {
|
|
120
|
+
const [mine, theirs] = await Promise.all([ownHost(req, pathname, body), rpc(endpoint, translate(args, false)).catch(() => undefined)])
|
|
121
|
+
if (mine?.result?.ok && theirs?.ok) mine.result.value = { ...mine.result.value, items: [...mine.result.value.items, ...localizeSessions(translate(theirs.value, true).items, paths())] }
|
|
122
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
123
|
+
res.end(JSON.stringify(mine))
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
if (endpoint === 'workspace/delete' && !isWsl(args.request?.workspaceId) && twins.has(args.request?.workspaceId)) {
|
|
127
|
+
// Removing a Windows folder also removes the WSL Host's twin; otherwise its tasks would reappear as a row of
|
|
128
|
+
// their own. Like the official delete, this only unregisters the folders: no file or task record is deleted.
|
|
129
|
+
const workspaceId = args.request.workspaceId
|
|
130
|
+
const mine = await ownHost(req, pathname, body)
|
|
131
|
+
if (mine?.result?.ok) {
|
|
132
|
+
const theirs = await rpc(endpoint, translate({ request: { workspaceId: twins.get(workspaceId) } }, false)).catch((error) => ({ ok: false, error }))
|
|
133
|
+
if (!theirs.ok) ctx.logger?.warn?.('wsl-mirror: removing the WSL twin of %s failed: %s', workspaceId, theirs.error?.message)
|
|
134
|
+
twins.delete(workspaceId)
|
|
135
|
+
if (runTargets.delete(workspaceId)) saveRunTargets()
|
|
136
|
+
}
|
|
137
|
+
return reply(res, message.rpcId, mine?.result ?? failure('internal', 'workspace/delete failed'))
|
|
138
|
+
}
|
|
139
|
+
if ((endpoint === 'session/openWorkspacePath' || endpoint === 'session/workspacePathApplications') && typeof args.request?.path === 'string' && args.request.path.startsWith('/')) {
|
|
140
|
+
// A WSL task's file paths are the WSL Host's (/home/..., /mnt/d/...). Opening happens on this desktop, so the
|
|
141
|
+
// path is turned into this Host's form (\\wsl.localhost\<distro>\..., D:\...) and the call stays here.
|
|
142
|
+
const path = paths().toLocal(args.request.path)
|
|
143
|
+
if (path !== args.request.path) return local({ ...args, request: { ...args.request, path } })
|
|
144
|
+
}
|
|
145
|
+
if (endpoint.startsWith('dynamicCordisRunner/')) {
|
|
146
|
+
const method = endpoint.slice('dynamicCordisRunner/'.length)
|
|
147
|
+
if (method === 'syncInspectManifest') {
|
|
148
|
+
// The page's own APIs: both Hosts' Creator may inspect them.
|
|
149
|
+
wslCordis.manifest = args
|
|
150
|
+
rpc(endpoint, args).catch(() => {})
|
|
151
|
+
return local()
|
|
152
|
+
}
|
|
153
|
+
if (method === 'inventory') {
|
|
154
|
+
const [mine, theirs] = await Promise.all([ownHost(req, pathname, body), rpc(endpoint, {}).catch(() => undefined)])
|
|
155
|
+
if (mine?.result?.ok && theirs?.ok) mine.result.value = [...mine.result.value, ...translate(theirs.value, true)]
|
|
156
|
+
return reply(res, message.rpcId, mine?.result ?? failure('internal', 'no inventory'))
|
|
157
|
+
}
|
|
158
|
+
const toWslHost = method === 'resolveRequestRun' ? wslCordis.requests.has(args.requestId)
|
|
159
|
+
: method === 'invoke' ? wslCordis.runs.has(args.pluginRunId)
|
|
160
|
+
: isWsl(args.agentId)
|
|
161
|
+
if (!toWslHost) return local()
|
|
162
|
+
if (method === 'getClientCode' && typeof args.pluginRunId === 'string') wslCordis.runs.add(args.pluginRunId)
|
|
163
|
+
if (method === 'resolveInspectQuery' || method === 'resolveRequestRun') wslCordis.requests.delete(args.requestId)
|
|
164
|
+
const result = await rpc(endpoint, translate(args, false))
|
|
165
|
+
return reply(res, message.rpcId, result.ok ? { ok: true, value: translate(result.value, true) } : result)
|
|
166
|
+
}
|
|
167
|
+
if (endpoint === '$events/result') {
|
|
168
|
+
if (!isWsl(args.eventId)) return local()
|
|
169
|
+
const clientId = eventClients.get(args.clientId)
|
|
170
|
+
if (clientId === undefined) return reply(res, message.rpcId, failure('wsl/events', 'No WSL event stream for this page.'))
|
|
171
|
+
return reply(res, message.rpcId, await rpc(endpoint, { ...args, clientId, eventId: toWsl(args.eventId) }))
|
|
172
|
+
}
|
|
173
|
+
if (endpoint === 'workspace/create' && paths().insideWsl(args.request?.path)) {
|
|
174
|
+
const result = await rpc(endpoint, { request: { ...args.request, path: paths().toWsl(args.request.path) } })
|
|
175
|
+
if (result.ok) result.value = { ...translate(result.value, true), workspace: { ...translate(result.value.workspace, true), path: args.request.path } }
|
|
176
|
+
return reply(res, message.rpcId, result)
|
|
177
|
+
}
|
|
178
|
+
if (endpoint === 'agentPresets/list') {
|
|
179
|
+
const { mine, theirs } = await presets(req)
|
|
180
|
+
if (!mine?.ok) return reply(res, message.rpcId, mine ?? failure('internal', 'no preset list'))
|
|
181
|
+
return reply(res, message.rpcId, { ok: true, value: { ...mine.value, presets: mergePresets(mine.value.presets, theirs) } })
|
|
182
|
+
}
|
|
183
|
+
if (endpoint === 'agentPresets/read') {
|
|
184
|
+
const mine = await ownHost(req, pathname, body)
|
|
185
|
+
if (mine?.result?.ok || mine?.result?.error?.code !== 'agent-preset/not-found') return reply(res, message.rpcId, mine?.result)
|
|
186
|
+
return reply(res, message.rpcId, await rpc(endpoint, args))
|
|
187
|
+
}
|
|
188
|
+
if (endpoint === 'agentPresets/select' && !isWsl(args.agentId)) {
|
|
189
|
+
const refusal = await wslOnlyPreset(req, args.agentPreset)
|
|
190
|
+
if (refusal) return reply(res, message.rpcId, refusal)
|
|
191
|
+
}
|
|
192
|
+
if (endpoint === 'session/create') {
|
|
193
|
+
const request = args.request ?? {}
|
|
194
|
+
const runsHere = !isWsl(request.workspaceId) && !paths().insideWsl(request.cwd) && (runTargets.get(request.workspaceId) ?? 'local') === 'local'
|
|
195
|
+
const refusal = runsHere ? await wslOnlyPreset(req, request.agentPreset) : undefined
|
|
196
|
+
if (refusal) return reply(res, message.rpcId, refusal)
|
|
197
|
+
return create(req, res, next, message, args, local)
|
|
198
|
+
}
|
|
199
|
+
if (endpoint === 'workspace/insertSessionBefore' && isWsl(args.request?.sessionId) && !isWsl(args.request?.workspaceId)) {
|
|
200
|
+
// Reordering a WSL Session inside a Windows folder: the order lives in the folder's twin on the WSL Host.
|
|
201
|
+
const twin = twins.get(args.request.workspaceId)
|
|
202
|
+
if (twin === undefined) return reply(res, message.rpcId, failure('wsl/no-twin', 'This folder has no WSL counterpart.'))
|
|
203
|
+
const { beforeSessionId, ...rest } = args.request
|
|
204
|
+
return forward(res, pathname, message, { ...payload, args: { request: { ...rest, workspaceId: twin, ...(isWsl(beforeSessionId) ? { beforeSessionId } : {}) } } })
|
|
205
|
+
}
|
|
206
|
+
let route
|
|
207
|
+
try { route = routeOf(args, endpoint) } catch (error) { return reply(res, message.rpcId, failure(error.code, error.message)) }
|
|
208
|
+
if (route === 'local') return local()
|
|
209
|
+
return forward(res, pathname, message, payload)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Agent presets (modes): plugins usually live in WSL, so the mode list shows both Hosts'. A mode only the WSL Host has
|
|
213
|
+
// is marked, and choosing it for a task that runs here says to switch the run location.
|
|
214
|
+
const presetCall = (req, endpoint) => ownHost(req, `/api/${endpoint}`, Buffer.from(JSON.stringify({ type: 'client-request', rpcId: randomUUID(), method: endpoint, payload: { args: {} } })))
|
|
215
|
+
async function presets(req) {
|
|
216
|
+
const [mine, theirs] = await Promise.all([presetCall(req, 'agentPresets/list').then((r) => r?.result), rpc('agentPresets/list', {}).catch(() => undefined)])
|
|
217
|
+
return { mine, theirs: theirs?.ok ? theirs.value.presets : [] }
|
|
218
|
+
}
|
|
219
|
+
async function wslOnlyPreset(req, id) {
|
|
220
|
+
if (typeof id !== 'string') return undefined
|
|
221
|
+
const { mine, theirs } = await presets(req)
|
|
222
|
+
if (!mine?.ok || mine.value.presets.some((p) => p.id === id)) return undefined
|
|
223
|
+
const row = theirs.find((p) => p.id === id)
|
|
224
|
+
if (!row) return undefined
|
|
225
|
+
return failure('agent-preset/not-found', pick({ zh: `"${row.name ?? id}" 模式来自 WSL 中的插件,只能用于在 WSL 中运行的任务。请在输入框下方把运行位置切换到 WSL。`, en: `The "${row.name ?? id}" mode comes from a plugin in WSL and works only for tasks that run in WSL. Switch the run location under the input box to WSL.` }))
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function create(req, res, next, message, args, local) {
|
|
229
|
+
const request = args.request ?? {}
|
|
230
|
+
if (request.workspaceId === undefined && paths().insideWsl(request.cwd)) {
|
|
231
|
+
const result = await rpc('session/create', { request: { ...request, cwd: paths().toWsl(request.cwd) } })
|
|
232
|
+
return reply(res, message.rpcId, result.ok ? { ok: true, value: translate(result.value, true) } : result)
|
|
233
|
+
}
|
|
234
|
+
const target = runTargets.get(request.workspaceId) ?? 'local'
|
|
235
|
+
const place = placeSession(request, { target, twin: twins.get(request.workspaceId) })
|
|
236
|
+
if (place.host === 'local') return local({ ...args, request: place.request })
|
|
237
|
+
if (place.needsTwin) {
|
|
238
|
+
const folder = localWorkspaces?.items.find((w) => w.workspaceId === request.workspaceId)
|
|
239
|
+
if (folder === undefined) return reply(res, message.rpcId, failure('wsl/unknown-folder', 'This folder is not known yet; reopen the page and try again.'))
|
|
240
|
+
const created = await rpc('workspace/create', { request: { path: paths().toWsl(folder.path) } })
|
|
241
|
+
if (!created.ok) return reply(res, message.rpcId, created)
|
|
242
|
+
place.request.workspaceId = toPage(created.value.workspace.workspaceId)
|
|
243
|
+
twins.set(request.workspaceId, place.request.workspaceId)
|
|
244
|
+
}
|
|
245
|
+
const result = await rpc('session/create', translate({ request: place.request }, false))
|
|
246
|
+
return reply(res, message.rpcId, result.ok ? { ok: true, value: translate(result.value, true) } : result)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function upload(req, res, next) {
|
|
250
|
+
const url = new URL(req.url, 'http://x')
|
|
251
|
+
const sessionId = url.searchParams.get('sessionId')
|
|
252
|
+
if (!isWsl(sessionId) || !available()) return next()
|
|
253
|
+
url.searchParams.set('sessionId', toWsl(sessionId))
|
|
254
|
+
const upstream = await remoteFetch(`${url.pathname}${url.search}`, {
|
|
255
|
+
method: 'POST', body: req,
|
|
256
|
+
headers: { cookie: await cookie(), origin: remoteOrigin(), 'content-type': req.headers['content-type'] ?? 'application/octet-stream' },
|
|
257
|
+
})
|
|
258
|
+
res.writeHead(upstream.status, { 'content-type': upstream.headers.get('content-type') ?? 'application/json' })
|
|
259
|
+
const text = await upstream.text()
|
|
260
|
+
try { res.end(JSON.stringify(translate(JSON.parse(text), true))) } catch { res.end(text) }
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ---- run location of new Sessions in a folder of this Host: GET ?workspaceId= / POST {workspaceId, target}
|
|
264
|
+
ctx.effect(() => ctx.webServer.register({
|
|
265
|
+
kind: 'exact', path: '/wsl-bridge/run-target',
|
|
266
|
+
async handler(req, res) {
|
|
267
|
+
const admission = ctx.connection.admit(req)
|
|
268
|
+
if ('rejection' in admission) { res.writeHead(admission.rejection); res.end(); return }
|
|
269
|
+
noteLanguage(req)
|
|
270
|
+
let workspaceId = new URL(req.url, 'http://x').searchParams.get('workspaceId')
|
|
271
|
+
let presetAvailable
|
|
272
|
+
if (req.method === 'POST') {
|
|
273
|
+
try {
|
|
274
|
+
const body = JSON.parse((await readBody(req)).toString('utf8'))
|
|
275
|
+
workspaceId = body.workspaceId
|
|
276
|
+
if (typeof workspaceId === 'string' && !isWsl(workspaceId) && (body.target === 'wsl' || body.target === 'local')) {
|
|
277
|
+
if (body.target === 'local') runTargets.delete(workspaceId); else runTargets.set(workspaceId, 'wsl')
|
|
278
|
+
saveRunTargets()
|
|
279
|
+
}
|
|
280
|
+
// Switching a blank task carries its mode over when the chosen side has it (the page asks with agentPreset).
|
|
281
|
+
if (typeof body.agentPreset === 'string') {
|
|
282
|
+
const { mine, theirs } = await presets(req)
|
|
283
|
+
const side = (isWsl(workspaceId) || runTargets.get(workspaceId) === 'wsl') ? theirs : mine?.ok ? mine.value.presets : []
|
|
284
|
+
presetAvailable = side.some((p) => p.id === body.agentPreset)
|
|
285
|
+
}
|
|
286
|
+
} catch {}
|
|
287
|
+
}
|
|
288
|
+
const folder = isWsl(workspaceId) ? wslWorkspaces?.items.find((w) => w.workspaceId === workspaceId) : localWorkspaces?.items.find((w) => w.workspaceId === workspaceId)
|
|
289
|
+
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' })
|
|
290
|
+
res.end(JSON.stringify({
|
|
291
|
+
workspaceId,
|
|
292
|
+
target: isWsl(workspaceId) ? 'wsl' : runTargets.get(workspaceId) ?? 'local',
|
|
293
|
+
insideWsl: isWsl(workspaceId),
|
|
294
|
+
wslAvailable: available(),
|
|
295
|
+
setup: setupState(),
|
|
296
|
+
distro: distro() ?? null,
|
|
297
|
+
wslPath: folder ? (isWsl(workspaceId) ? folder.path : paths().toWsl(folder.path)) : null,
|
|
298
|
+
...(presetAvailable === undefined ? {} : { presetAvailable }),
|
|
299
|
+
}))
|
|
300
|
+
},
|
|
301
|
+
}), 'wsl-mirror: run target route')
|
|
302
|
+
|
|
303
|
+
// ---- WSL directory browsing for the add-folder dialog: GET ?path= lists, POST {path, name} creates a folder.
|
|
304
|
+
// Paths are the WSL Host's (/home/...); the page turns the chosen one into \\wsl.localhost\<distro>\...
|
|
305
|
+
ctx.effect(() => ctx.webServer.register({
|
|
306
|
+
kind: 'exact', path: '/wsl-bridge/dirs',
|
|
307
|
+
async handler(req, res) {
|
|
308
|
+
const admission = ctx.connection.admit(req)
|
|
309
|
+
if ('rejection' in admission) { res.writeHead(admission.rejection); res.end(); return }
|
|
310
|
+
noteLanguage(req)
|
|
311
|
+
const send = (result) => { res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(result)) }
|
|
312
|
+
if (!available()) return send(failure('wsl/unavailable', pick({ zh: 'WSL 端未连接或版本不同', en: 'The WSL side is not connected or runs a different version' })))
|
|
313
|
+
try {
|
|
314
|
+
if (req.method === 'POST') {
|
|
315
|
+
const body = JSON.parse((await readBody(req)).toString('utf8'))
|
|
316
|
+
return send(await rpc('directoryPicker/createDirectory', { path: body.path, name: body.name }))
|
|
317
|
+
}
|
|
318
|
+
const path = new URL(req.url, 'http://x').searchParams.get('path')
|
|
319
|
+
return send(await rpc('directoryPicker/list', path ? { path } : {}))
|
|
320
|
+
} catch (error) { return send(failure('wsl/unavailable', error.message)) }
|
|
321
|
+
},
|
|
322
|
+
}), 'wsl-mirror: WSL directory browsing route')
|
|
323
|
+
|
|
324
|
+
// ---- streams
|
|
325
|
+
const gateway = ctx.typertGateway
|
|
326
|
+
const ownOpen = Object.hasOwn(gateway, 'openWireStream')
|
|
327
|
+
const originalOpen = gateway.openWireStream
|
|
328
|
+
gateway.openWireStream = async function (endpoint, payload, uplink, peer, signal, control, window) {
|
|
329
|
+
const native = () => originalOpen.call(this, endpoint, payload, uplink, peer, signal, control, window)
|
|
330
|
+
// The merged streams take in the WSL Host whenever it comes: one opened before WSL connected (Desktop starting,
|
|
331
|
+
// the person still setting WSL up) keeps trying it, and picks it up once it is there.
|
|
332
|
+
if (MERGED_STREAMS.has(endpoint)) return merged(endpoint, payload, signal, await native())
|
|
333
|
+
if (!available()) return native()
|
|
334
|
+
let route
|
|
335
|
+
try { route = routeOf(payload?.args, endpoint) } catch (error) { throw remoteError({ code: error.code, message: error.message }) }
|
|
336
|
+
if (route === 'local') return native()
|
|
337
|
+
return (async function* () {
|
|
338
|
+
for await (const value of mux.open(endpoint, translate(payload, false), signal, uplink)) yield translate(value, true)
|
|
339
|
+
})()
|
|
340
|
+
}
|
|
341
|
+
ctx.effect(() => () => { if (ownOpen) gateway.openWireStream = originalOpen; else delete gateway.openWireStream }, 'wsl-mirror: restore gateway')
|
|
342
|
+
|
|
343
|
+
/** One merged stream: this Host's (`native`, already open) and the WSL Host's, kept open with retries. */
|
|
344
|
+
async function* merged(endpoint, payload, signal, native) {
|
|
345
|
+
const queue = new Queue()
|
|
346
|
+
const scope = new AbortController()
|
|
347
|
+
const combined = AbortSignal.any([signal, scope.signal])
|
|
348
|
+
void (async () => {
|
|
349
|
+
try { for await (const value of native) queue.push({ source: 'local', value }); queue.push({ source: 'local', end: true }) } catch (error) { queue.push({ source: 'local', error }) }
|
|
350
|
+
})()
|
|
351
|
+
void (async () => {
|
|
352
|
+
// While WSL is not there this waits for it to come (no polling); while the WSL stream is open nothing retries.
|
|
353
|
+
// Only a stream that fails while WSL is there is retried, with backoff.
|
|
354
|
+
for (let attempt = 0; !combined.aborted; attempt++) {
|
|
355
|
+
if (!available()) {
|
|
356
|
+
queue.push({ source: 'wsl', down: true })
|
|
357
|
+
await whenAvailable(combined)
|
|
358
|
+
attempt = 0
|
|
359
|
+
continue
|
|
360
|
+
}
|
|
361
|
+
try {
|
|
362
|
+
for await (const value of mux.open(endpoint, translate(payload, false), combined)) { attempt = 0; queue.push({ source: 'wsl', value: translate(value, true) }) }
|
|
363
|
+
} catch (error) { if (combined.aborted) return; if (attempt === 0) logger.debug('wsl-mirror: %s from WSL: %s', endpoint, error.message) }
|
|
364
|
+
queue.push({ source: 'wsl', down: true })
|
|
365
|
+
await new Promise((r) => { const t = setTimeout(r, Math.min(10_000, 500 * 2 ** Math.min(attempt, 5))); combined.addEventListener('abort', () => { clearTimeout(t); r() }, { once: true }) })
|
|
366
|
+
}
|
|
367
|
+
})()
|
|
368
|
+
try {
|
|
369
|
+
if (endpoint === 'workspace/follow') yield* followWorkspaces(queue, combined)
|
|
370
|
+
else if (endpoint === 'session/control') yield* followControl(queue, combined)
|
|
371
|
+
else yield* followEvents(queue, combined)
|
|
372
|
+
} finally { scope.abort() }
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function* followWorkspaces(queue, signal) {
|
|
376
|
+
// The first frame waits briefly for the WSL Host's folders, so the sidebar does not fill in twice.
|
|
377
|
+
let started = false
|
|
378
|
+
let localReady = false
|
|
379
|
+
let wslReady = false
|
|
380
|
+
let grace
|
|
381
|
+
try {
|
|
382
|
+
for (;;) {
|
|
383
|
+
const item = await queue.next(signal)
|
|
384
|
+
if (item.source === 'local' && item.end) return
|
|
385
|
+
if (item.error) throw item.error
|
|
386
|
+
if (item.down || item.graceOver) wslReady = true // keep the last known WSL folders while it reconnects
|
|
387
|
+
else if (item.source === 'local') { localWorkspaces = applyWorkspaceFrame(localWorkspaces, item.value); localReady = true }
|
|
388
|
+
else { wslWorkspaces = applyWorkspaceFrame(wslWorkspaces, item.value); wslReady = true }
|
|
389
|
+
if (!started) {
|
|
390
|
+
if (!localReady) continue
|
|
391
|
+
if (!wslReady) { grace ??= setTimeout(() => queue.push({ graceOver: true }), 1500); continue }
|
|
392
|
+
} else if (item.down || item.graceOver) continue
|
|
393
|
+
yield { type: started ? 'replace' : 'baseline', value: remember() }
|
|
394
|
+
started = true
|
|
395
|
+
}
|
|
396
|
+
} finally { clearTimeout(grace) }
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function* followControl(queue, signal) {
|
|
400
|
+
// One baseline per stream generation, from both Hosts; the WSL Host gets a short grace period.
|
|
401
|
+
const snapshots = {}
|
|
402
|
+
const waiting = new Set(['local', 'wsl'])
|
|
403
|
+
const buffered = []
|
|
404
|
+
const grace = setTimeout(() => queue.push({ source: 'wsl', down: true }), 3000)
|
|
405
|
+
try {
|
|
406
|
+
for (;;) {
|
|
407
|
+
const item = await queue.next(signal)
|
|
408
|
+
if (item.source === 'local' && item.end) return
|
|
409
|
+
if (item.error) throw item.error
|
|
410
|
+
if (waiting.size > 0) {
|
|
411
|
+
if (item.down) waiting.delete('wsl')
|
|
412
|
+
else if (item.value.type === 'baseline') {
|
|
413
|
+
Object.assign(snapshots, item.source === 'wsl' ? prefixProjections(item.value.value).projections : item.value.value.projections)
|
|
414
|
+
waiting.delete(item.source)
|
|
415
|
+
} else buffered.push(item.value)
|
|
416
|
+
if (waiting.size === 0) { yield { type: 'baseline', value: { projections: snapshots } }; yield* buffered.splice(0) }
|
|
417
|
+
continue
|
|
418
|
+
}
|
|
419
|
+
if (item.down) continue
|
|
420
|
+
if (item.value.type === 'baseline') {
|
|
421
|
+
// The page does not reopen a failed control stream, so a WSL Host that connects late (it starts with the
|
|
422
|
+
// Desktop, after the page) or reconnects is folded in as one projection frame per Session: the page's
|
|
423
|
+
// stores keep the higher seq per key, as with any live frame.
|
|
424
|
+
if (item.source === 'wsl') { yield* baselineFrames(prefixProjections(item.value.value)); continue }
|
|
425
|
+
throw remoteError({ code: 'gateway/carrier', message: 'Session control restarted.' })
|
|
426
|
+
}
|
|
427
|
+
yield item.value
|
|
428
|
+
}
|
|
429
|
+
} finally { clearTimeout(grace) }
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function* followEvents(queue, signal) {
|
|
433
|
+
let localClient
|
|
434
|
+
let wslClient
|
|
435
|
+
const buffered = []
|
|
436
|
+
try {
|
|
437
|
+
for (;;) {
|
|
438
|
+
const item = await queue.next(signal)
|
|
439
|
+
if (item.source === 'local' && item.end) return
|
|
440
|
+
if (item.error) throw item.error
|
|
441
|
+
if (item.down) continue
|
|
442
|
+
const frame = item.value
|
|
443
|
+
if (frame.type === 'ready') {
|
|
444
|
+
if (item.source === 'local') { localClient = frame.clientId; yield frame; yield* buffered.splice(0) } else {
|
|
445
|
+
wslClient = frame.clientId
|
|
446
|
+
// A WSL Host that (re)connected has not seen the page's APIs yet.
|
|
447
|
+
if (wslCordis.manifest !== undefined) rpc('dynamicCordisRunner/syncInspectManifest', wslCordis.manifest).catch(() => {})
|
|
448
|
+
// The page lists Sessions once per connection (session/list); WSL may come after that (Desktop started
|
|
449
|
+
// first, the person still setting WSL up, the WSL Host restarted). Its Sessions are announced to the page
|
|
450
|
+
// as added, once per WSL connection, which the page merges by id.
|
|
451
|
+
void rpc('session/list', { _request: {} }).then((result) => {
|
|
452
|
+
if (!result?.ok || signal.aborted) return
|
|
453
|
+
for (const summary of localizeSessions(translate(result.value, true).items, paths())) queue.push({ source: 'wsl', value: { type: 'emit', event: 'api-session/added', args: [summary] }, listed: true })
|
|
454
|
+
}).catch((error) => logger.debug('wsl-mirror: WSL session list: %s', error.message))
|
|
455
|
+
}
|
|
456
|
+
if (localClient !== undefined && wslClient !== undefined) eventClients.set(localClient, wslClient)
|
|
457
|
+
continue
|
|
458
|
+
}
|
|
459
|
+
if (item.source === 'wsl' && frame.type === 'emit') noteCordis(frame)
|
|
460
|
+
let out = frame
|
|
461
|
+
if (item.source === 'wsl' && !item.listed) {
|
|
462
|
+
out = prefixEvent(frame)
|
|
463
|
+
// A Session the WSL Host adds names its folder as WSL sees it; the page knows it as this side does.
|
|
464
|
+
if (out.event === 'api-session/added' && typeof out.args?.[0]?.cwd === 'string') out = { ...out, args: [localizeSessions([out.args[0]], paths())[0], ...out.args.slice(1)] }
|
|
465
|
+
}
|
|
466
|
+
if (localClient === undefined) buffered.push(out); else yield out
|
|
467
|
+
}
|
|
468
|
+
} finally { if (localClient !== undefined) eventClients.delete(localClient) }
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function noteCordis(frame) {
|
|
472
|
+
const [request] = frame.args ?? []
|
|
473
|
+
const id = request?.requestId
|
|
474
|
+
if (typeof id !== 'string') return
|
|
475
|
+
if (frame.event === 'cordis/inspect-query' || frame.event === 'cordis/request-run') wslCordis.requests.add(id)
|
|
476
|
+
else if (frame.event === 'cordis/inspect-query-resolved' || frame.event === 'cordis/request-run-resolved') wslCordis.requests.delete(id)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Raw ids of the WSL Host's Workspaces, for plugin bodies that name them.
|
|
480
|
+
const wslWorkspaceIds = () => new Set((wslWorkspaces?.items ?? []).map((w) => toWsl(w.workspaceId)))
|
|
481
|
+
return { call, wslWorkspaceIds }
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Both Hosts' modes: this Host's as they are, then the WSL Host's that this one lacks, marked. */
|
|
485
|
+
export function mergePresets(mine, theirs) {
|
|
486
|
+
const have = new Set(mine.map((p) => p.id))
|
|
487
|
+
return [...mine, ...theirs.filter((p) => !have.has(p.id)).map((p) => ({ ...p, isDefault: false, name: `${p.name ?? p.id} · WSL` }))]
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
class Queue {
|
|
491
|
+
values = [];
|
|
492
|
+
push(value) { this.values.push(value); this.wake?.() }
|
|
493
|
+
async next(signal) {
|
|
494
|
+
while (!this.values.length) {
|
|
495
|
+
signal.throwIfAborted()
|
|
496
|
+
await new Promise((resolve) => { this.wake = resolve; this.signal = signal; signal.addEventListener('abort', resolve, { once: true }) })
|
|
497
|
+
signal.removeEventListener('abort', this.wake)
|
|
498
|
+
this.wake = undefined
|
|
499
|
+
}
|
|
500
|
+
return this.values.shift()
|
|
501
|
+
}
|
|
502
|
+
}
|
package/selfupdate.mjs
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Keeps this plugin up to date (docs/deployment.md, decision 8) through DSH's own plugin updates: the plugin manager
|
|
2
|
+
// lists the registry's newer versions (marking those whose engines.dsh rejects this DSH), and updateBundle replaces the
|
|
3
|
+
// installed one, putting the old one back when the new one does not install or start. The WSL half follows by itself:
|
|
4
|
+
// it is copied from this package on the next connect (setup.mjs, STAMP).
|
|
5
|
+
//
|
|
6
|
+
// Only a plugin installed from a registry can update this way; one installed from a folder or a Git URL reports
|
|
7
|
+
// `not-updatable`. The person can switch automatic updates off (config.json `autoUpdate: false`).
|
|
8
|
+
|
|
9
|
+
const FIRST_CHECK_MS = 60_000
|
|
10
|
+
const EVERY_MS = 6 * 60 * 60_000
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The version to update to: the newest that declares a bundle, works with this DSH, is not deprecated, and is a
|
|
14
|
+
* release unless the installed version is itself a prerelease. `list` is the plugin manager's PluginVersionList.
|
|
15
|
+
*/
|
|
16
|
+
export function pickUpdate(list, installed) {
|
|
17
|
+
if (list?.status !== 'listed') return undefined
|
|
18
|
+
const onPrerelease = /-/.test(installed ?? '')
|
|
19
|
+
return list.versions.find((v) => v.bundle && !v.incompatible && !v.deprecated && (onPrerelease || !v.prerelease))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* How the plugin manager can update a listed bundle: DSH 0.2.2-rc.2 lists `update: 'registry' | 'git'`, earlier
|
|
24
|
+
* releases `updatable: boolean` (registry only). Only registry updates are used here.
|
|
25
|
+
*/
|
|
26
|
+
export const updateKind = (row) => row?.update ?? (row?.updatable ? 'registry' : undefined)
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Check now and then; returns {state(), check()}. `state()` is what the setup dialog shows:
|
|
30
|
+
* {name, version, auto, status, latest?, error?, at?} with status one of: waiting, off, checking, not-updatable,
|
|
31
|
+
* up-to-date, updating, updated, restart-required, failed.
|
|
32
|
+
*/
|
|
33
|
+
export function installSelfUpdate(ctx, { name, version, auto, log = () => {} }) {
|
|
34
|
+
let state = { name, version, auto: auto(), status: 'waiting' }
|
|
35
|
+
let manager
|
|
36
|
+
let running
|
|
37
|
+
const set = (patch) => { state = { ...state, ...patch, at: new Date().toISOString() } }
|
|
38
|
+
|
|
39
|
+
async function check() {
|
|
40
|
+
if (running) return running
|
|
41
|
+
running = (async () => {
|
|
42
|
+
set({ auto: auto() })
|
|
43
|
+
if (!state.auto) { set({ status: 'off' }); return }
|
|
44
|
+
if (!manager) { set({ status: 'failed', error: 'the plugin manager is not available' }); return }
|
|
45
|
+
set({ status: 'checking', error: undefined })
|
|
46
|
+
try {
|
|
47
|
+
const row = (await manager.listBundles()).find((b) => b.name === name)
|
|
48
|
+
if (updateKind(row) !== 'registry') { set({ status: 'not-updatable' }); return }
|
|
49
|
+
const list = await manager.listVersions(name)
|
|
50
|
+
if (list.status !== 'listed') { set({ status: 'failed', error: list.problem ?? list.status }); return }
|
|
51
|
+
const next = pickUpdate(list, row.version ?? version)
|
|
52
|
+
if (!next) { set({ status: 'up-to-date', latest: list.versions[0]?.version }); return }
|
|
53
|
+
set({ status: 'updating', latest: next.version })
|
|
54
|
+
log(`updating ${name} ${row.version} → ${next.version}`)
|
|
55
|
+
// With HMR this disposes this plugin before the new version starts; nothing below may run then.
|
|
56
|
+
const result = await manager.updateBundle(name, next.version, { registry: list.registry })
|
|
57
|
+
const application = result.result.application
|
|
58
|
+
if (application === 'applied') set({ status: 'updated' })
|
|
59
|
+
else if (application === 'restart-required') set({ status: 'restart-required' })
|
|
60
|
+
else set({ status: 'failed', error: result.result.error?.message ?? `${result.step}: ${application}` })
|
|
61
|
+
} catch (error) {
|
|
62
|
+
set({ status: 'failed', error: error.message })
|
|
63
|
+
}
|
|
64
|
+
})().finally(() => { running = undefined })
|
|
65
|
+
return running
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
ctx.inject(['pluginManager'], (c) => {
|
|
69
|
+
manager = c.pluginManager
|
|
70
|
+
const first = setTimeout(() => void check(), FIRST_CHECK_MS); first.unref?.()
|
|
71
|
+
const every = setInterval(() => void check(), EVERY_MS); every.unref?.()
|
|
72
|
+
return () => { manager = undefined; clearTimeout(first); clearInterval(every) }
|
|
73
|
+
})
|
|
74
|
+
return {
|
|
75
|
+
// The switch takes effect at once in what the dialog shows; a check in flight finishes.
|
|
76
|
+
state: () => (auto() ? { ...state, auto: true, ...(state.status === 'off' ? { status: 'waiting' } : {}) } : { ...state, auto: false, status: state.status === 'updating' ? 'updating' : 'off' }),
|
|
77
|
+
check,
|
|
78
|
+
}
|
|
79
|
+
}
|