dsh-wsl-desktop 0.3.5 → 0.3.6

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/lib/client.js CHANGED
@@ -20,6 +20,9 @@
20
20
  window.__ModuleLoader__.load({
21
21
  id: 'dsh-wsl-desktop',
22
22
  factory() {
23
+ // MUST stay equal to the host half's Config.routePath default (lib/index.js).
24
+ // Nothing derives one from the other: a deployment that mounts the route
25
+ // elsewhere loses the picker silently.
23
26
  const ENDPOINT = '/wsl-desktop/api'
24
27
 
25
28
  /**
@@ -268,12 +271,10 @@ window.__ModuleLoader__.load({
268
271
  * workspace be created and a session opened after the operator cancelled.
269
272
  */
270
273
  function close() {
274
+ // The guard is the ONLY difference from forceClose(); the teardown itself is
275
+ // that function's, so the two cannot drift apart.
271
276
  if (state.closed || state.working) return
272
- state.closed = true
273
- state.token += 1
274
- document.removeEventListener('keydown', onKey)
275
- overlay.remove()
276
- teardown = null
277
+ forceClose()
277
278
  }
278
279
 
279
280
  /** Close the picker regardless of an in-flight commit, once it settles. */
package/lib/index.js CHANGED
@@ -5,9 +5,11 @@
5
5
  *
6
6
  * 1. Answer the browser's WSL questions over the same-origin route — the
7
7
  * browser cannot run `wsl.exe`.
8
- * 2. Materialize the WSL agent presets into the user preset root. A session's
9
- * execution world is chosen by its preset, so this is what lets one Desktop
10
- * instance run Windows workspaces and WSL workspaces side by side.
8
+ * 2. Derive and register the WSL agent presets in the runtime preset registry. A
9
+ * session's execution world is chosen by its preset, so this is what lets one
10
+ * Desktop instance run Windows workspaces and WSL workspaces side by side.
11
+ * (Before 0.1.7 this half MATERIALIZED them into the user preset root; the
12
+ * registry owns them now, and nothing under $DSH_HOME is written.)
11
13
  * @module dsh-wsl-desktop
12
14
  */
13
15
 
@@ -44,6 +46,9 @@ export const Config = z.object({
44
46
  * generation can be mounted beside a running one, which is how the host half
45
47
  * is exercised without restarting the application.
46
48
  */
49
+ // MUST stay equal to the browser half's ENDPOINT (lib/client.js): nothing derives
50
+ // one from the other, and a deployment that mounts the route elsewhere loses the
51
+ // picker silently — the failure this default is the only record of.
47
52
  routePath: z.string().default('/wsl-desktop/api'),
48
53
  /**
49
54
  * Path to `wsl.exe`. Every probe this half runs spawns it — discovery, directory
@@ -78,6 +83,20 @@ const SELF_MODULE = fileURLToPath(import.meta.url)
78
83
  /** Prefix of every preset this plugin owns; its own outputs are never sources. */
79
84
  const GENERATED_PREFIX = 'wsl-'
80
85
 
86
+ /**
87
+ * The generated variant id for a resolved base preset.
88
+ *
89
+ * A preset that is already a variant keeps its id; anything else gets the prefix. The
90
+ * selftest and createWorkspace both derived it this way — the second site's own comment
91
+ * admitted it resolved exactly like the first — so a change to the prefix rule had two
92
+ * homes.
93
+ * @param {{ id: string }} preset - the resolved base preset.
94
+ * @returns {string} the variant id.
95
+ */
96
+ function variantIdFor(preset) {
97
+ return preset.id.startsWith(GENERATED_PREFIX) ? preset.id : `${GENERATED_PREFIX}${preset.id}`
98
+ }
99
+
81
100
  /**
82
101
  * Module specifiers of the WSL providers, as the preset rows must name them.
83
102
  * The 0.1.7 loader imports row names via `new URL(name, baseUrl)` or a bare
@@ -203,6 +222,24 @@ function basePluginsOf(presets, id) {
203
222
  * refuses duplicates).
204
223
  * @returns {Promise<object>} the registration outcome for the route.
205
224
  */
225
+ /**
226
+ * Run a shell spec through whichever seam the host exposes, and always hand back a
227
+ * handle whose `result()` is awaitable.
228
+ *
229
+ * 0.1.7 hosts expose `execute`, which resolves with a live handle; older ones have
230
+ * only `run`, whose settled result carries no `result()` projection. Both selftest
231
+ * steps used to spell that dual dispatch themselves, and the second one called
232
+ * `.result()` on `run`'s settled result — a TypeError on exactly the hosts that
233
+ * branch exists to support. One owner, so the two steps cannot drift again.
234
+ * @param {object} shell - the realm's shell service.
235
+ * @param {object} spec - the resolved spec to run.
236
+ * @returns {Promise<{ result: () => Promise<object> }>} a result()-able handle.
237
+ */
238
+ function runShellSpec(shell, spec) {
239
+ if (typeof shell.execute === 'function') return shell.execute(spec)
240
+ return shell.run(spec).then((settled) => ({ result: async () => settled }))
241
+ }
242
+
206
243
  async function materializePresets(ctx, distro, generation, previous) {
207
244
  const presets = ctx.get('agentPresets')
208
245
  if (presets === undefined) throw new Error('agentPresets 服务不可用')
@@ -213,9 +250,16 @@ async function materializePresets(ctx, distro, generation, previous) {
213
250
  if (hostCompat.registry07 !== true) {
214
251
  throw new Error('需要 DSH Desktop 0.1.7+:当前宿主的 agentPresets 缺少 readDocument(0.1.6 及以下不受支持)。请升级桌面,或使用 0.1.6 兼容的插件版本。')
215
252
  }
216
- if (generation?.disposed) return { status: 'ready', hostCompat, written: [], skipped: [{ id: '-', reason: '插件已卸载,放弃本轮注册' }] }
253
+ /**
254
+ * The answer a round gives when its generation was torn down mid-flight: the preset
255
+ * surface stays READY (the previous generation is still registered), nothing is
256
+ * written, and the skip says why. Spelled once — it stood three times, so the
257
+ * wording and the shape each had three homes.
258
+ */
259
+ const abandonedRound = () => ({ status: 'ready', hostCompat, written: [], skipped: [{ id: '-', reason: '插件已卸载,放弃本轮注册' }] })
260
+ if (generation?.disposed) return abandonedRound()
217
261
  const roster = await presets.list()
218
- if (generation?.disposed) return { status: 'ready', hostCompat, written: [], skipped: [{ id: '-', reason: '插件已卸载,放弃本轮注册' }] }
262
+ if (generation?.disposed) return abandonedRound()
219
263
  const written = []
220
264
  const skipped = []
221
265
 
@@ -223,7 +267,7 @@ async function materializePresets(ctx, distro, generation, previous) {
223
267
  for (const [id, dispose] of previous?.disposers ?? []) {
224
268
  try { await dispose() } catch { /* already gone */ }
225
269
  previous?.disposers.delete(id)
226
- if (generation?.disposed) return { status: 'ready', hostCompat, written: [], skipped: [{ id: '-', reason: '插件已卸载,放弃本轮注册' }] }
270
+ if (generation?.disposed) return abandonedRound()
227
271
  }
228
272
 
229
273
  for (const preset of roster) {
@@ -410,9 +454,7 @@ const METHODS = {
410
454
  const presets = hostCtx?.get('agentPresets')
411
455
  if (presets === undefined) throw new Error('agent presets 尚不可用')
412
456
  const resolved = await presets.resolve(base)
413
- const agentPreset = resolved.id.startsWith(GENERATED_PREFIX)
414
- ? resolved.id
415
- : `${GENERATED_PREFIX}${resolved.id}`
457
+ const agentPreset = variantIdFor(resolved)
416
458
  const roster = await presets.list()
417
459
  const variant = roster.find((preset) => preset.id === agentPreset)
418
460
  if (variant === undefined) throw new Error(`WSL 预设 ${agentPreset} 尚未生成`)
@@ -550,8 +592,9 @@ const METHODS = {
550
592
  // failure would abort the whole selftest.
551
593
  const spec = shell.resolve({ command: 'uname -s; pwd; id -un; echo "$WSL_DISTRO_NAME"', workdir: workspaceCwd })
552
594
  // 0.1.7: execute resolves with a live handle; the settled result is
553
- // its memoized result() projection. Dual-dispatch for older hosts.
554
- const execution = await (typeof shell.execute === 'function' ? shell.execute(spec) : (async () => { const r = await shell.run(spec); return { result: async () => r } })())
595
+ // its memoized result() projection. Dual-dispatch for older hosts — one
596
+ // owner, runShellSpec, shared with the confined step below.
597
+ const execution = await runShellSpec(shell, spec)
555
598
  const result = await execution.result()
556
599
  record('shell.run', {
557
600
  exitCode: result?.exitCode,
@@ -609,8 +652,8 @@ const METHODS = {
609
652
  }
610
653
 
611
654
  if (shell !== undefined) {
612
- const exec = typeof shell.execute === 'function' ? shell.execute.bind(shell) : shell.run.bind(shell)
613
- const execution = await exec(
655
+ const execution = await runShellSpec(
656
+ shell,
614
657
  shell.resolve({ command: 'echo blocked > /dsh-wsl-forbidden.txt', workdir: workspaceCwd }),
615
658
  ).catch((error) => ({ result: async () => ({ exitCode: null, stderr: { text: String(error?.message ?? error) }, sandbox: null }) }))
616
659
  const confined = await execution.result()
@@ -816,9 +859,7 @@ const METHODS = {
816
859
  const presets = hostCtx.get('agentPresets')
817
860
  if (presets !== undefined) {
818
861
  const resolved = await presets.resolve()
819
- const candidate = resolved.id.startsWith(GENERATED_PREFIX)
820
- ? resolved.id
821
- : `${GENERATED_PREFIX}${resolved.id}`
862
+ const candidate = variantIdFor(resolved)
822
863
  const roster = await presets.list()
823
864
  if (roster.some((preset) => preset.id === candidate && preset.broken === undefined)) presetId = candidate
824
865
  }
@@ -961,7 +1002,7 @@ async function isDeveloperCaller(req, config) {
961
1002
  /**
962
1003
  * Register the WSL route and generate the WSL presets.
963
1004
  * @param {import('@deepseek-ai/cordis').Context} ctx - the plugin context.
964
- * @param {{ routePath: string, developerTools: boolean }} config - resolved plugin config.
1005
+ * @param {{ routePath: string, developerTools: boolean, wslPath?: string }} config - resolved plugin config.
965
1006
  */
966
1007
  export function apply(ctx, config) {
967
1008
  hostCtx = ctx
@@ -20,11 +20,6 @@
20
20
  */
21
21
 
22
22
  import { shellQuote } from './paths.js'
23
- import { fileURLToPath } from 'node:url'
24
- import { dirname, join } from 'node:path'
25
-
26
- /** Shipped helper script, installed by the operator (see docs/CONFINEMENT.md). */
27
- export const HELPER_SOURCE = join(dirname(fileURLToPath(import.meta.url)), 'dsh-wsl-confine.sh')
28
23
 
29
24
  /** The direct runner: sudo-wrapped unshare with the fence built in-process. */
30
25
  export const RUNNER_SUDO_UNSHARE = 'sudo-unshare'
@@ -207,9 +202,9 @@ function helperGateScript(helperPath) {
207
202
  'u=$(stat -c %u "$f" 2>/dev/null) || u=',
208
203
  'm=$(stat -c %a "$f" 2>/dev/null) || m=',
209
204
  '[ "$u" = 0 ] || ok=no',
210
- '[ -n "$m" ] || ok=no',
211
205
  // A mode stat cannot report (or one that is not octal) must refuse, not
212
- // error out silently: the arithmetic parse is guarded by the non-empty test.
206
+ // error out silently: the arithmetic parse is guarded by its own non-empty
207
+ // test, which is why no separate [ -n "$m" ] refusal stands above it.
213
208
  '[ -n "$m" ] && (( (8#$m & 022) == 0 )) || ok=no',
214
209
  '[ -w "$f" ] && ok=no',
215
210
  'd=$(dirname "$f")',
@@ -317,7 +312,9 @@ export async function detectRunner({ distro, username, run }) {
317
312
  }
318
313
 
319
314
  /**
320
- * Forget cached probe results. Used by tests and by a settings change.
315
+ * Forget cached probe results. Used by the verification suites — no settings change
316
+ * calls it (the doc claimed one did; the only caller in the tree is
317
+ * scripts/verify-confinement.mjs).
321
318
  */
322
319
  export function resetConfinementCache() {
323
320
  identityCache.clear()
@@ -421,7 +418,7 @@ export async function detectNoNewPrivs({ distro, username, run }) {
421
418
  * unconditionally (dsh-wsl-confine.sh), so on a setpriv without the flag setpriv
422
419
  * rejects the unknown option and exits 1 — the confined command simply does not
423
420
  * run, and that is an ordinary COMMAND failure, not the setup-failure marker
424
- * that reports an unavailable runner. docs/CONFINEMENT.md:33 records the measured exit 1;
421
+ * that reports an unavailable runner. docs/CONFINEMENT.md:31 records the measured exit 1;
425
422
  * this text previously contradicted it by recommending the helper as a remedy.
426
423
  *
427
424
  * The remedy names the PROBEABLE requirement, not a version: the util-linux release
@@ -668,7 +665,10 @@ function postConditions(keep) {
668
665
  * @param {string} options.linuxCwd - absolute Linux working directory.
669
666
  * @param {'read-only' | 'workspace-write'} options.mode - the confined mode.
670
667
  * @param {string | undefined} options.workspaceLinuxRoot - writable root for `workspace-write`.
671
- * @param {{ uid: string, gid: string }} options.identity - identity to drop back to.
668
+ * @param {{ uid: string, gid: string, home: string, name?: string }} options.identity -
669
+ * identity to drop back to; `home` and `name` are read for the environment.
670
+ * @param {boolean | undefined} options.noNewPrivs - whether the runner supports
671
+ * `setpriv --no-new-privs`; adds the flag to the drop-back when true.
672
672
  * @returns {string} the script to run inside the namespace.
673
673
  */
674
674
  export function buildNamespaceScript({ command, linuxCwd, mode, workspaceLinuxRoot, identity, noNewPrivs }) {
@@ -726,7 +726,8 @@ export function buildNamespaceScript({ command, linuxCwd, mode, workspaceLinuxRo
726
726
  * @param {string} options.linuxCwd - absolute Linux working directory.
727
727
  * @param {'read-only' | 'workspace-write'} options.mode - the confined mode.
728
728
  * @param {string | undefined} options.workspaceLinuxRoot - writable root for `workspace-write`.
729
- * @param {{ uid: string, gid: string }} options.identity - identity to drop back to.
729
+ * @param {{ uid: string, gid: string, home: string, name?: string }} options.identity -
730
+ * identity to drop back to; `home` and `name` are read for the environment.
730
731
  * @param {boolean} [options.noNewPrivs] - set NO_NEW_PRIVS on the drop when the
731
732
  * distro's setpriv supports it (blocks setuid elevation inside the fence).
732
733
  * @param {string} [options.runner] - the resolved runner: 'helper' routes the
package/lib/wsl/fs.js CHANGED
@@ -83,11 +83,24 @@ export function toHostPath(value, cwd, fallbackDistro) {
83
83
  * @returns {string} the Linux spelling when one exists, else the input.
84
84
  */
85
85
  export function toLinuxPath(value) {
86
+ return toLinuxPathOrNull(value) ?? value
87
+ }
88
+
89
+ /**
90
+ * The Linux spelling of a host path, or undefined when one does not exist.
91
+ *
92
+ * The ONE place the UNC/drive translation is written: `toLinuxPath` and the fs
93
+ * backend's `processPathFromHostPath` differ only in the sentinel they answer for an
94
+ * unrepresentable path (the input vs undefined), and spelling the translation twice
95
+ * meant a fix could land in one copy only.
96
+ * @param {string} value - a host path on the share or a drive path.
97
+ * @returns {string | undefined} the normalized Linux path, or undefined.
98
+ */
99
+ function toLinuxPathOrNull(value) {
86
100
  const unc = parseWslUnc(value)
87
101
  if (unc !== null) return posix.normalize(unc.linuxPath)
88
102
  const drive = windowsToMntPath(value)
89
- if (drive !== null) return posix.normalize(drive)
90
- return value
103
+ return drive === null ? undefined : posix.normalize(drive)
91
104
  }
92
105
 
93
106
  /**
@@ -288,10 +301,7 @@ export class WslFileSystem extends LocalFileSystem {
288
301
  */
289
302
  processPathFromHostPath(hostPath) {
290
303
  if (typeof hostPath !== 'string' || hostPath.length === 0) return undefined
291
- const unc = parseWslUnc(hostPath)
292
- if (unc !== null) return posix.normalize(unc.linuxPath)
293
- const drive = windowsToMntPath(hostPath)
294
- return drive === null ? undefined : posix.normalize(drive)
304
+ return toLinuxPathOrNull(hostPath)
295
305
  }
296
306
 
297
307
  /**
package/lib/wsl/preset.js CHANGED
@@ -259,18 +259,20 @@ export const WSL_PERSONA_SENTENCE = 'Paths under \\\\wsl.localhost\\<distro> are
259
259
  /**
260
260
  * Append the WSL path-dialect sentence to a persona row OBJECT.
261
261
  * @param {object} row - the persona entry.
262
- * @returns {boolean} whether the row was amended.
262
+ *
263
+ * The row is ALWAYS amended — a string field is extended when one exists and `suffix`
264
+ * is created when none does — so this returns nothing. It used to document and return
265
+ * "whether the row was amended" (always true, and its only caller discards it).
263
266
  */
264
267
  function appendPersonaSuffixObject(row) {
265
268
  const config = row.config ?? (row.config = {})
266
269
  for (const field of ['suffix', 'text', 'prefix']) {
267
270
  if (typeof config[field] === 'string' && config[field].length > 0) {
268
271
  config[field] = `${config[field]} ${WSL_PERSONA_SENTENCE}`
269
- return true
272
+ return
270
273
  }
271
274
  }
272
275
  config.suffix = WSL_PERSONA_SENTENCE
273
- return true
274
276
  }
275
277
 
276
278
  /**
@@ -344,8 +346,11 @@ export function buildVariantPlugins(basePlugins, { subprocessPath, shellPath, fs
344
346
  unclassified.push({ id: row.id ?? null, name: row.name ?? null })
345
347
  } else if (isHostWorldModule(row.name)) {
346
348
  removed.push(row.id ?? row.name)
347
- if (row.name.includes('str-replace-editor')) includeEditor = true
348
- if (row.name.includes('str-replace-editor') && row.config !== undefined) {
349
+ // One evaluation, two uses: the predicate stood on adjacent lines, so a future
350
+ // edit to one of them would silently leave the other behind.
351
+ const isEditor = row.name.includes('str-replace-editor')
352
+ if (isEditor) includeEditor = true
353
+ if (isEditor && row.config !== undefined) {
349
354
  toolConfig.set('str-replace-editor', cloneRow({ config: row.config }).config)
350
355
  }
351
356
  return null
package/lib/wsl/pty.js CHANGED
@@ -318,7 +318,22 @@ export async function spawnWslTerminal({
318
318
  }
319
319
 
320
320
  const stderr = data.stderr
321
- if (stderr === undefined) throw new Error('wsl-pty: 数据进程没有 stderr,无法承载控制应答')
321
+ if (stderr === undefined) {
322
+ // This guard throws BEFORE the allocation try below, so it used to escape with
323
+ // the data process still running — the very leak that try's catch exists to
324
+ // prevent. Same cleanup, minus `control`: it is not spawned yet at this point,
325
+ // and `control` is not even in scope here.
326
+ try {
327
+ data.terminate()
328
+ } catch {
329
+ // Already gone.
330
+ }
331
+ await Promise.allSettled([waitForHandle(data)])
332
+ // The bridge may already have created its FIFO before this check; rm -f is a
333
+ // no-op when it did not.
334
+ await cleanupFifo()
335
+ throw new Error('wsl-pty: 数据进程没有 stderr,无法承载控制应答')
336
+ }
322
337
  stderr.setEncoding('utf8')
323
338
  stderr.on('data', (chunk) => {
324
339
  replyBuffer += chunk
@@ -481,7 +496,7 @@ export async function spawnWslTerminal({
481
496
  control.terminate()
482
497
  data.terminate()
483
498
  failPending('wsl-pty: 会话已终止')
484
- await Promise.allSettled([data.waitForExit?.() ?? waitForHandle(data), waitForHandle(control)])
499
+ await Promise.allSettled([waitForHandle(data), waitForHandle(control)])
485
500
  // AFTER both processes have settled, from the host side: the bridge's
486
501
  // own finally lost this race often enough to leave orphans behind.
487
502
  await cleanupFifo()
package/lib/wsl/shell.js CHANGED
@@ -428,10 +428,13 @@ export class WslShellExecutor extends ShellExecutor {
428
428
  }
429
429
  }
430
430
  const emptyReader = { readFrom: () => ({ text: '', lossy: false, nextOffset: 0 }) }
431
- /** The failure note the READ PATH must serve; filled in when a spawn fails. */
432
- const spawnFailure = {
433
- note: spawnError === undefined ? undefined : `subprocess failed before reporting an outcome: ${String(spawnError)}`,
434
- }
431
+ /**
432
+ * The failure note the READ PATH must serve; assigned by settle() when a spawn
433
+ * fails. The holder starts bare on purpose: servesNote stays false until settle()
434
+ * has written note, so an initializer here could never reach a reader — the
435
+ * ternary that stood here built a string no branch could observe.
436
+ */
437
+ const spawnFailure = { note: undefined }
435
438
  const providerReaders = handle === undefined ? undefined : WslShellExecutor.readers(handle)
436
439
  const stdout = providerReaders?.stdout ?? emptyReader
437
440
  const baseStderr = providerReaders?.stderr ?? emptyReader
@@ -34,7 +34,7 @@ import { LoginShellUnresolvedError, WSL_CHILD_ENV, buildWslExecArgv, planWsl, re
34
34
  /**
35
35
  * The executable lookup's ceiling, on the documented probe policy.
36
36
  *
37
- * Was 15s and the shortest ceiling in the plugin. docs/CONFINEMENT.md:36 names
37
+ * Was 15s and the shortest ceiling in the plugin. docs/CONFINEMENT.md:34 names
38
38
  * `resolveExecutable` in the same sentence as 探针超时 60s + 超时后一次透明重试, and it is an
39
39
  * output-parsed probe like the siblings that sentence lists (the comment on the probe below
40
40
  * says so), so it gets the same 60s as `listLinuxDir`/`checkLinuxPath`/`resolveDistroHome`
@@ -46,6 +46,20 @@ const LOOKUP_TIMEOUT_MS = 60_000
46
46
  /** Windows shells whose names the terminal controller may probe inside the distribution. */
47
47
  const WINDOWS_SHELLS = new Set(['powershell', 'pwsh', 'cmd'])
48
48
 
49
+ /**
50
+ * The basename a Windows-shell test compares: directories stripped, lowercased, a
51
+ * trailing `.exe` removed.
52
+ *
53
+ * ONE owner: the candidate lookup and the terminal-argv translation each spelled this
54
+ * pipeline, so a fix to either (a Windows path separator, a new extension) could land in
55
+ * one copy only. `value ?? ''` because one caller passes an optional argv element.
56
+ * @param {unknown} value - a program spelling.
57
+ * @returns {string} the comparable basename, or '' when there is nothing to compare.
58
+ */
59
+ function shellBaseName(value) {
60
+ return String(value ?? '').split(/[\\/]/).pop()?.toLowerCase().replace(/\.exe$/, '') ?? ''
61
+ }
62
+
49
63
  /** The PTY bridge shipped beside this module. */
50
64
  const BRIDGE_PATH = join(dirname(fileURLToPath(import.meta.url)), 'terminal-bridge.py')
51
65
 
@@ -167,7 +181,7 @@ export class WslSubprocessRuntime extends SubprocessRuntime {
167
181
  // shell (PowerShell on a Windows deployment) through THIS provider; in
168
182
  // the distribution that dialect-translates to the session user's login
169
183
  // shell. Absent any Windows shell, the login shell is the honest answer.
170
- const base = String(command).split(/[\\/]/).pop()?.toLowerCase().replace(/\.exe$/, '') ?? ''
184
+ const base = shellBaseName(command)
171
185
  if (WINDOWS_SHELLS.has(base)) {
172
186
  try {
173
187
  return await resolveLoginShell(plan.distro, this.config.username, { wslPath: this.config.wslPath })
@@ -261,7 +275,7 @@ export class WslSubprocessRuntime extends SubprocessRuntime {
261
275
  // (PowerShell on Windows) through THIS provider; in the distribution
262
276
  // that dialect-translates to the session user's login shell — a lone
263
277
  // '-NoLogo' arg would kill bash, so the whole argv is translated.
264
- const base0 = String(spec.argv?.[0] ?? '').split(/[\\/]/).pop()?.toLowerCase().replace(/\.exe$/, '') ?? ''
278
+ const base0 = shellBaseName(spec.argv?.[0])
265
279
  // Deliberately NOT translated into SubprocessExecutableNotFoundError the
266
280
  // way the candidate lookup above is. There is no candidate list here to
267
281
  // skip down — this spec named exactly ONE program — so the translation
@@ -200,7 +200,12 @@ class Bridge:
200
200
  def activity(self):
201
201
  """Report busy when the foreground group is not the shell itself."""
202
202
  pgrp = self.foreground_pgrp()
203
- state = "unknown" if pgrp is None else ("idle" if pgrp == self.shell_pgrp else "busy")
203
+ if pgrp is None:
204
+ state = "unknown"
205
+ elif pgrp == self.shell_pgrp:
206
+ state = "idle"
207
+ else:
208
+ state = "busy"
204
209
  if state != self.last_state:
205
210
  self.last_state = state
206
211
  self.revision += 1
package/lib/wsl/world.js CHANGED
@@ -225,6 +225,37 @@ export async function listDistros({ wslPath = 'wsl.exe' } = {}) {
225
225
  .filter((line) => line.length > 0)
226
226
  }
227
227
 
228
+ /**
229
+ * The Lxss registry values and the two REG_SZ lines the default-distribution read needs.
230
+ *
231
+ * The patterns live HERE, once, because the async and the sync reader each carried their
232
+ * own copy: a registry-format change had two homes, and only one of them was exercised by
233
+ * whichever caller the operator happened to hit.
234
+ */
235
+ const DEFAULT_DISTRIBUTION_VALUE = 'DefaultDistribution'
236
+ const DISTRIBUTION_NAME_VALUE = 'DistributionName'
237
+ const GUID_PATTERN = /DefaultDistribution\s+REG_SZ\s+(\{[0-9a-fA-F-]+\})/iu
238
+ const NAME_PATTERN = /DistributionName\s+REG_SZ\s+(.+)/iu
239
+
240
+ /**
241
+ * The distribution a `DistributionName` line names.
242
+ * @param {unknown} output - the registry query's stdout.
243
+ * @returns {string | undefined} the name, or undefined when the line names none.
244
+ */
245
+ function distroFromRegistryOutput(output) {
246
+ const distro = NAME_PATTERN.exec(String(output))?.[1]?.trim()
247
+ return distro === undefined || distro === '' ? undefined : distro
248
+ }
249
+
250
+ /**
251
+ * The Lxss subkey a `DefaultDistribution` line names.
252
+ * @param {unknown} output - the registry query's stdout.
253
+ * @returns {string | undefined} the GUID, or undefined when the line names none.
254
+ */
255
+ function guidFromRegistryOutput(output) {
256
+ return GUID_PATTERN.exec(String(output))?.[1]
257
+ }
258
+
228
259
  /**
229
260
  * Read the user's default distribution from the Lxss registry. Non-fatal:
230
261
  * `undefined` when the value is absent or unreadable.
@@ -232,16 +263,15 @@ export async function listDistros({ wslPath = 'wsl.exe' } = {}) {
232
263
  */
233
264
  export async function defaultDistro() {
234
265
  try {
235
- const value = await execFileAsync('reg.exe', ['query', LXSS_KEY, '/v', 'DefaultDistribution'], {
266
+ const value = await execFileAsync('reg.exe', ['query', LXSS_KEY, '/v', DEFAULT_DISTRIBUTION_VALUE], {
236
267
  timeout: DISCOVERY_TIMEOUT_MS,
237
268
  })
238
- const guid = /DefaultDistribution\s+REG_SZ\s+(\{[0-9a-fA-F-]+\})/i.exec(String(value.stdout))?.[1]
269
+ const guid = guidFromRegistryOutput(value.stdout)
239
270
  if (guid === undefined) return undefined
240
- const name = await execFileAsync('reg.exe', ['query', `${LXSS_KEY}\\${guid}`, '/v', 'DistributionName'], {
271
+ const name = await execFileAsync('reg.exe', ['query', `${LXSS_KEY}\\${guid}`, '/v', DISTRIBUTION_NAME_VALUE], {
241
272
  timeout: DISCOVERY_TIMEOUT_MS,
242
273
  })
243
- const distro = /DistributionName\s+REG_SZ\s+(.+)/i.exec(String(name.stdout))?.[1]?.trim()
244
- return distro === undefined || distro === '' ? undefined : distro
274
+ return distroFromRegistryOutput(name.stdout)
245
275
  } catch {
246
276
  // A missing or unreadable registry value leaves the caller its own fallback.
247
277
  return undefined
@@ -261,16 +291,15 @@ export function defaultDistroSync() {
261
291
  if (syncDefaultResolved) return syncDefault
262
292
  syncDefaultResolved = true
263
293
  try {
264
- const value = execFileSync('reg.exe', ['query', LXSS_KEY, '/v', 'DefaultDistribution'], {
294
+ const value = execFileSync('reg.exe', ['query', LXSS_KEY, '/v', DEFAULT_DISTRIBUTION_VALUE], {
265
295
  timeout: DISCOVERY_TIMEOUT_MS,
266
296
  })
267
- const guid = /DefaultDistribution\s+REG_SZ\s+(\{[0-9a-fA-F-]+\})/i.exec(String(value))?.[1]
297
+ const guid = guidFromRegistryOutput(value)
268
298
  if (guid === undefined) return undefined
269
- const name = execFileSync('reg.exe', ['query', `${LXSS_KEY}\\${guid}`, '/v', 'DistributionName'], {
299
+ const name = execFileSync('reg.exe', ['query', `${LXSS_KEY}\\${guid}`, '/v', DISTRIBUTION_NAME_VALUE], {
270
300
  timeout: DISCOVERY_TIMEOUT_MS,
271
301
  })
272
- const distro = /DistributionName\s+REG_SZ\s+(.+)/i.exec(String(name))?.[1]?.trim()
273
- syncDefault = distro === undefined || distro === '' ? undefined : distro
302
+ syncDefault = distroFromRegistryOutput(name)
274
303
  } catch {
275
304
  // An unreadable registry leaves the caller its own fail-loud path.
276
305
  syncDefault = undefined
@@ -353,15 +382,31 @@ function bridgeEnv(extra) {
353
382
  * @returns {{ distro: string, linuxCwd: string, windowsCwd: string }} the execution plan.
354
383
  * @throws Error when the directory belongs to neither world.
355
384
  */
385
+ /**
386
+ * The distribution a plan runs in: the caller's fallback, else the machine default.
387
+ * @param {string} workdir - the directory being planned, named in the failure.
388
+ * @param {string|undefined} fallbackDistro - the configured distro, when there is one.
389
+ * @returns {string} the distribution name.
390
+ * @throws {Error} when neither source names one.
391
+ */
392
+ function wslUserOption(username) {
393
+ return username !== undefined && username !== '' ? ['-u', username] : []
394
+ }
395
+
396
+ function planDistro(workdir, fallbackDistro) {
397
+ const distro = fallbackDistro ?? defaultDistroSync()
398
+ if (distro === undefined) {
399
+ throw new Error(`wsl: 无法确定 "${workdir}" 所属的发行版,请配置 distro`)
400
+ }
401
+ return distro
402
+ }
403
+
356
404
  export function planWsl(workdir, fallbackDistro) {
357
405
  const windowsCwd = process.env.SystemRoot ?? process.cwd()
358
406
  const unc = parseWslUnc(workdir)
359
407
  if (unc !== null) return { distro: unc.distro, linuxCwd: unc.linuxPath, windowsCwd }
360
408
  if (workdir.startsWith('/')) {
361
- const distro = fallbackDistro ?? defaultDistroSync()
362
- if (distro === undefined) {
363
- throw new Error(`wsl: 无法确定 "${workdir}" 所属的发行版,请配置 distro`)
364
- }
409
+ const distro = planDistro(workdir, fallbackDistro)
365
410
  // A drive mount is also addressable from the Windows side; keep that as the
366
411
  // spawn directory so a failure still names a real path.
367
412
  return { distro, linuxCwd: workdir, windowsCwd: mntToWindowsPath(workdir) ?? windowsCwd }
@@ -370,10 +415,7 @@ export function planWsl(workdir, fallbackDistro) {
370
415
  if (mnt === null) {
371
416
  throw new Error(`wsl: 工作目录 "${workdir}" 不在 WSL 执行世界里`)
372
417
  }
373
- const distro = fallbackDistro ?? defaultDistroSync()
374
- if (distro === undefined) {
375
- throw new Error(`wsl: 无法确定 "${workdir}" 所属的发行版,请配置 distro`)
376
- }
418
+ const distro = planDistro(workdir, fallbackDistro)
377
419
  return { distro, linuxCwd: mnt, windowsCwd }
378
420
  }
379
421
 
@@ -399,7 +441,7 @@ export function buildWslExecArgv(plan, argv, options = {}) {
399
441
  return [
400
442
  options.wslPath ?? 'wsl.exe',
401
443
  '-d', plan.distro,
402
- ...(options.username !== undefined && options.username !== '' ? ['-u', options.username] : []),
444
+ ...wslUserOption(options.username),
403
445
  '--cd', plan.linuxCwd,
404
446
  '-e',
405
447
  ...argv,
@@ -486,7 +528,7 @@ export function runWslShell({ distro, linuxCwd, command, username, loginShell =
486
528
  const script = loginShell ? `cd ${shellQuote(linuxCwd)} && ${command}` : command
487
529
  const argv = [
488
530
  '-d', distro,
489
- ...(username !== undefined && username !== '' ? ['-u', username] : []),
531
+ ...wslUserOption(username),
490
532
  '--cd', linuxCwd,
491
533
  '-e', 'bash', loginShell ? '-lc' : '-c', script,
492
534
  ]
@@ -592,12 +634,14 @@ const PROBE_TIMEOUT_MS = 60_000
592
634
  * probe follows: repeat the SAME probe once, immediately, and let a persistent stall
593
635
  * still fail loudly with the second attempt's evidence.
594
636
  *
595
- * Retrying is safe here because all three probes are READS: the listing protocol frames
637
+ * Retrying is safe here because all FOUR probes are READS: the listing protocol frames
596
638
  * its own output and the check prints one of three tokens, so a repeat cannot duplicate
597
- * a side effect. The trigger is deliberately `timedOut` alone and not "any bad answer":
598
- * a non-zero exit from the listing script is the ordinary answer for a directory that is
599
- * not there, and doubling the latency of that case would slow the interactive picker for
600
- * no measured benefit.
639
+ * a side effect; `resolveLoginShell` runs a read-only `getent` and answers the same way.
640
+ * (This comment said "three" while four probes already consumed the runner — scripts/
641
+ * wsl-probe.mjs counts the same four.) The trigger is deliberately `timedOut` alone and
642
+ * not "any bad answer": a non-zero exit from the listing script is the ordinary answer
643
+ * for a directory that is not there, and doubling the latency of that case would slow
644
+ * the interactive picker for no measured benefit.
601
645
  * @param {(request: object) => Promise<object>} run - the probe runner.
602
646
  * @param {object} request - the probe, without its ceiling.
603
647
  * @returns {Promise<object>} the first conclusive attempt, or the second after a timeout.
@@ -612,7 +656,7 @@ async function probeWithRetry(run, request) {
612
656
  * List one directory inside a distribution.
613
657
  * @param {string} distro - distribution name.
614
658
  * @param {string} linuxPath - absolute Linux directory.
615
- * @param {{ wslPath?: string, run?: (options: object) => Promise<object> }} [options] - the configured `wsl.exe` path, and the probe runner (injectable so the retry policy below can be driven without a distribution — the same seam `resolveIdentity`/`detectRunner` expose).
659
+ * @param {{ wslPath?: string, run?: (options: object) => Promise<object> }} [options] - the configured `wsl.exe` path, and the probe runner (injectable so the retry policy above can be driven without a distribution — the same seam `resolveIdentity`/`detectRunner` expose).
616
660
  * @returns {Promise<{ path: string, parent: string | null, entries: Array<{ name: string, kind: 'directory' | 'file' }> }>} the listing.
617
661
  */
618
662
  export async function listLinuxDir(distro, linuxPath, { wslPath, run = runWslShell } = {}) {
@@ -651,7 +695,7 @@ export async function listLinuxDir(distro, linuxPath, { wslPath, run = runWslShe
651
695
  * Check whether one Linux path exists.
652
696
  * @param {string} distro - distribution name.
653
697
  * @param {string} linuxPath - absolute Linux path.
654
- * @param {{ wslPath?: string, run?: (options: object) => Promise<object> }} [options] - the configured `wsl.exe` path, and the probe runner (injectable so the retry policy below can be driven without a distribution).
698
+ * @param {{ wslPath?: string, run?: (options: object) => Promise<object> }} [options] - the configured `wsl.exe` path, and the probe runner (injectable so the retry policy above can be driven without a distribution).
655
699
  * @returns {Promise<{ exists: boolean, isDirectory: boolean }>} existence facts.
656
700
  */
657
701
  export async function checkLinuxPath(distro, linuxPath, { wslPath, run = runWslShell } = {}) {
@@ -707,7 +751,7 @@ export function probeEvidence(entry) {
707
751
  * exactly one path.
708
752
  * @param {string} distro - distribution name.
709
753
  * @param {string} [username] - user to resolve; absent resolves the default user.
710
- * @param {{ wslPath?: string, run?: (options: object) => Promise<object> }} [options] - the configured `wsl.exe` path, and the probe runner (injectable so the retry policy below can be driven without a distribution).
754
+ * @param {{ wslPath?: string, run?: (options: object) => Promise<object> }} [options] - the configured `wsl.exe` path, and the probe runner (injectable so the retry policy above can be driven without a distribution).
711
755
  * @returns {Promise<{ user: string, home: string }>} the user and their home.
712
756
  * @throws Error when the user or a usable home cannot be resolved.
713
757
  */
@@ -842,7 +886,7 @@ export class LoginShellUnresolvedError extends Error {
842
886
  * own evidence instead of answering.
843
887
  * @param {string} distro - distribution name.
844
888
  * @param {string} [username] - Linux user; omitted uses the distribution default.
845
- * @param {{ wslPath?: string, run?: (options: object) => Promise<object> }} [options] - the configured `wsl.exe` path, and the probe runner (injectable so the retry policy below can be driven without a distribution — the same seam the sibling probes expose).
889
+ * @param {{ wslPath?: string, run?: (options: object) => Promise<object> }} [options] - the configured `wsl.exe` path, and the probe runner (injectable so the retry policy above can be driven without a distribution — the same seam the sibling probes expose).
846
890
  * @returns {Promise<string>} the login shell path.
847
891
  * @throws {LoginShellUnresolvedError} when the probe timed out twice, could not
848
892
  * run, or `getent` did not answer. The one value returned without the probe (an
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-wsl-desktop",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "type": "module",
5
5
  "main": "lib/index.js",
6
6
  "icon": "./lib/icon.svg",