dsh-wsl-desktop 0.7.1 → 0.7.2

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
@@ -38,6 +38,34 @@ window.__ModuleLoader__.load({
38
38
  'M5.54492 2.06738', // post-refactor ProjectAddOutlineArtwork
39
39
  ]
40
40
 
41
+ /**
42
+ * The same trigger, named by sprite symbol instead of path data.
43
+ *
44
+ * Desktop 0.2.1-rc3 (ui-primitives `c3eead771d`) moved every product icon
45
+ * into the document icon sprite: the header button now renders
46
+ * `<svg data-icon="dsw-icon-project-add-outline-regular"><use href="#…"/></svg>`
47
+ * with NO `<path>` child anywhere. The geometry scan above therefore matched
48
+ * nothing, the anchor read as absent, and the W button stood down on every
49
+ * 0.2.1-rc3 desktop. The glyph name is the locale-independent identity in
50
+ * that generation, exactly as the path prefix was in the previous one, and a
51
+ * theme may replace the artwork without renaming the symbol — so the name
52
+ * survives the change that broke the geometry.
53
+ */
54
+ const TRIGGER_ICON_GLYPHS = ['project-add-outline']
55
+
56
+ /** Sprite symbol id shape, as `iconSymbolId` composes it: `dsw-icon-<glyph>-<weight>`. */
57
+ const SPRITE_ICON_ID = /^#?dsw-icon-(.+)-(?:regular|medium)$/
58
+
59
+ /**
60
+ * The row glyphs whose artwork is FILL-only, as sprite names.
61
+ *
62
+ * Only the fallback for a sprite symbol that is not in the document yet; the
63
+ * live read (`isSolidIcon`) prefers the symbol's own geometry. Versions
64
+ * qualify the id (`symbolId(glyph, weight)`), so the suffix is not part of a
65
+ * glyph name and the match is on the glyph alone.
66
+ */
67
+ const SOLID_GLYPHS = ['folder-open']
68
+
41
69
  /** Namespace of every SVG node this half creates. */
42
70
  const SVG_NS = 'http://www.w3.org/2000/svg'
43
71
 
@@ -1476,15 +1504,68 @@ window.__ModuleLoader__.load({
1476
1504
  /** Whether the missing-workspace-source warning was already printed. */
1477
1505
  let warnedWorkspaceSource = false
1478
1506
 
1479
- /** Find the shipped "add workspace" trigger by its icon geometry. */
1480
- function findTrigger() {
1507
+ /**
1508
+ * The sprite glyph one `<use>` reference names, or null.
1509
+ *
1510
+ * Accepts both the bare symbol id and the `#`-prefixed href form, and both
1511
+ * `href` and the legacy `xlink:href` spelling, because an icon may render
1512
+ * either. Returning the GLYPH (not the whole id) lets the trigger list stay
1513
+ * a list of glyph names, which is what a desktop generation actually fixes.
1514
+ * @param {Element} node - a `<use>` element.
1515
+ * @returns {string | null} the glyph name, or null when it is not a sprite ref.
1516
+ */
1517
+ function spriteGlyphOf(node) {
1518
+ const ref = node.getAttribute('href')
1519
+ ?? node.getAttributeNS('http://www.w3.org/1999/xlink', 'href')
1520
+ if (ref === null) return null
1521
+ return SPRITE_ICON_ID.exec(ref)?.[1] ?? null
1522
+ }
1523
+
1524
+ /**
1525
+ * The candidate trigger buttons carrying one generation's icon identity.
1526
+ *
1527
+ * Each generation is ONE document-level selector rather than a walk over
1528
+ * every button: `findTrigger` re-runs on every animation frame whenever the
1529
+ * anchor is missing, so a per-button subtree query would turn a single scan
1530
+ * into N of them exactly when the header is already in trouble.
1531
+ * @param {string} selector - the icon nodes this generation renders.
1532
+ * @param {(node: Element) => boolean} identifies - whether one icon is the trigger's.
1533
+ * @returns {HTMLElement[]} the distinct buttons owning a matching icon.
1534
+ */
1535
+ function triggerCandidates(selector, identifies) {
1481
1536
  const matches = []
1482
- for (const path of document.querySelectorAll('svg > path[d]')) {
1483
- const d = path.getAttribute('d')
1484
- if (!TRIGGER_ICON_PATHS.some((geometry) => d.startsWith(geometry))) continue
1485
- const trigger = path.closest('button')
1537
+ for (const icon of document.querySelectorAll(selector)) {
1538
+ if (!identifies(icon)) continue
1539
+ const trigger = icon.closest('button')
1486
1540
  if (trigger !== null && !matches.includes(trigger)) matches.push(trigger)
1487
1541
  }
1542
+ return matches
1543
+ }
1544
+
1545
+ /** Whether one inline path carries the add-workspace geometry. */
1546
+ function isTriggerPath(path) {
1547
+ const d = path.getAttribute('d')
1548
+ return TRIGGER_ICON_PATHS.some((geometry) => d.startsWith(geometry))
1549
+ }
1550
+
1551
+ /**
1552
+ * Whether one icon node is the add-workspace artwork, in either generation.
1553
+ *
1554
+ * A sprite icon is identified by `data-icon` (the id the component was
1555
+ * CONSTRUCTED with, which a theme replacement does not change) and, failing
1556
+ * that, by the `<use>` reference it renders.
1557
+ * @param {Element} icon - an inline path or a sprite `<svg>`/`<use>`.
1558
+ * @returns {boolean} true when it is the trigger's artwork.
1559
+ */
1560
+ function isTriggerIcon(icon) {
1561
+ if (icon.tagName === 'path') return isTriggerPath(icon)
1562
+ const declared = icon.tagName === 'svg' ? icon.getAttribute('data-icon') : null
1563
+ const glyph = declared !== null ? SPRITE_ICON_ID.exec(declared)?.[1] : spriteGlyphOf(icon)
1564
+ return glyph !== undefined && glyph !== null && TRIGGER_ICON_GLYPHS.includes(glyph)
1565
+ }
1566
+
1567
+ /** Pick the trigger out of one generation's candidates, or null. */
1568
+ function pickTrigger(matches) {
1488
1569
  if (matches.length <= 1) return matches[0] ?? null
1489
1570
  // More than one control carries the geometry. The shipped trigger is the
1490
1571
  // one inside a `*_headerActions` cluster — the CSS-module local name is
@@ -1494,6 +1575,24 @@ window.__ModuleLoader__.load({
1494
1575
  return matches.find((trigger) => hasHeaderActionsParent(trigger)) ?? null
1495
1576
  }
1496
1577
 
1578
+ /**
1579
+ * Find the shipped "add workspace" trigger in either desktop generation.
1580
+ *
1581
+ * The sprite generation is scanned FIRST because it is the newer one: on a
1582
+ * desktop that renders it, that single scan is the whole cost. Only a
1583
+ * desktop with no sprite icon anywhere falls through to the geometry scan,
1584
+ * which is what every release before 0.2.1-rc3 needed.
1585
+ * @returns {HTMLElement | null} the anchor, or null when this desktop has none.
1586
+ */
1587
+ function findTrigger() {
1588
+ const sprite = triggerCandidates('svg[data-icon], use', isTriggerIcon)
1589
+ // A non-empty sprite result is this desktop's answer even when it is
1590
+ // ambiguous: `pickTrigger` returns null there to mean "stand down", and
1591
+ // falling through would let an unrelated inline icon pick the anchor.
1592
+ if (sprite.length > 0) return pickTrigger(sprite)
1593
+ return pickTrigger(triggerCandidates('svg > path[d]', isTriggerIcon))
1594
+ }
1595
+
1497
1596
  /**
1498
1597
  * Whether one trigger sits in the shipped trailing action cluster.
1499
1598
  * @param {HTMLElement} trigger - a candidate trigger button.
@@ -1637,7 +1736,7 @@ window.__ModuleLoader__.load({
1637
1736
  // artwork fills its paths, the collapsed one strokes them. A rebuild is
1638
1737
  // owed whenever that flips, which is exactly what expanding a workspace
1639
1738
  // does to this span.
1640
- const solid = shipped !== null && shipped.querySelector('path[stroke]') === null
1739
+ const solid = isSolidIcon(shipped)
1641
1740
  const weight = solid ? 'solid' : 'outline'
1642
1741
  if (mine !== null && mine.dataset.wslCloudWeight !== weight) {
1643
1742
  mine.remove()
@@ -1648,6 +1747,56 @@ window.__ModuleLoader__.load({
1648
1747
  }
1649
1748
  }
1650
1749
 
1750
+ /**
1751
+ * The sprite symbol an icon element references, as a bare symbol id.
1752
+ *
1753
+ * `data-icon` is the id the icon COMPONENT was built with, so it keeps the
1754
+ * shipped symbol even when a theme points the `<use>` href at its own
1755
+ * artwork; the href is the fallback for markup that omits `data-icon`.
1756
+ * @param {Element} icon - one icon `<svg>`.
1757
+ * @returns {string | null} the symbol id without `#`, or null.
1758
+ */
1759
+ function spriteSymbolOf(icon) {
1760
+ const declared = icon.getAttribute('data-icon')
1761
+ if (typeof declared === 'string' && declared !== '') return declared.replace(/^#/, '')
1762
+ for (const use of icon.querySelectorAll('use')) {
1763
+ const ref = use.getAttribute('href')
1764
+ ?? use.getAttributeNS('http://www.w3.org/1999/xlink', 'href')
1765
+ if (typeof ref === 'string' && ref !== '') return ref.replace(/^#/, '')
1766
+ }
1767
+ return null
1768
+ }
1769
+
1770
+ /**
1771
+ * Whether one workspace row icon renders the FILL-only artwork.
1772
+ *
1773
+ * The collapsed row strokes its folder outline and the expanded one fills
1774
+ * it, and the cloud has to match whichever is showing. Up to 0.2.1-rc2 the
1775
+ * artwork was inline, so a stroked `<path>` was the tell. From 0.2.1-rc3 the
1776
+ * icon is a sprite reference with NO path child anywhere: the weight then
1777
+ * belongs to the SYMBOL in the document sprite, not to the `<svg>` naming
1778
+ * it. Reading only the inline shape (as this did) made every sprite row look
1779
+ * fill-only, so a collapsed row got the solid cloud.
1780
+ * @param {Element | null} icon - the shipped folder icon, if the row renders one.
1781
+ * @returns {boolean} true when the icon is the fill-only artwork.
1782
+ */
1783
+ function isSolidIcon(icon) {
1784
+ if (icon === null) return false
1785
+ // Inline generation: the artwork declares its own weight.
1786
+ if (icon.querySelector('path[d]') !== null) return icon.querySelector('path[stroke]') === null
1787
+ const symbolId = spriteSymbolOf(icon)
1788
+ if (symbolId === null) return false
1789
+ const symbol = document.getElementById(symbolId)
1790
+ // Sprite generation, symbol present: judge the artwork the browser paints,
1791
+ // so a theme's own geometry is measured rather than assumed.
1792
+ if (symbol !== null) return symbol.querySelector('path[stroke]') === null
1793
+ // Sprite generation, symbol not in the document (a theme may mount its
1794
+ // own sprite later): fall back to the glyph name, which is the fill-only
1795
+ // identity in the shipped table.
1796
+ const glyph = SPRITE_ICON_ID.exec(`#${symbolId}`)?.[1]
1797
+ return glyph !== undefined && glyph !== null && SOLID_GLYPHS.includes(glyph)
1798
+ }
1799
+
1651
1800
  /**
1652
1801
  * Whether one observed mutation could have changed a workspace row's markup.
1653
1802
  *
package/lib/wsl/paths.js CHANGED
@@ -20,8 +20,19 @@ const UNC_HOSTS = new Set(['wsl.localhost', 'wsl$'])
20
20
  * directory, so `\\wsl.localhost\..\<path>` would hand the share a traversal
21
21
  * spelling. No real distribution name is all dots, and `wsl.exe -d ..` cannot be
22
22
  * a legal invocation, so the lookahead costs nothing legitimate.
23
+ *
24
+ * A LEADING HYPHEN is refused for a different reason: the value becomes the token
25
+ * after `wsl.exe -d`, and an argv element that begins with `-` is not a provably
26
+ * safe option VALUE. Measured on wsl.exe: `-d --cd --cd /x` and `-d -dfoo` both
27
+ * fail with WSL_E_DISTRO_NOT_FOUND rather than re-reading the token as an option,
28
+ * so the practical outcome is a clean refusal and no command — but the invariant
29
+ * `buildWslExecArgv` states (option values validated against our own grammars, so
30
+ * exec-path safety never depends on wsl.exe's undocumented tokenization) was not
31
+ * actually enforced for this value. The sibling grammar LINUX_USER below already
32
+ * forces a safe first character, so `-u` was refused while `-d` was not; this
33
+ * lookahead makes the pair agree. No real distribution name begins with `-`.
23
34
  */
24
- export const DISTRO_NAME = /^(?!\.+$)[A-Za-z0-9._-]+$/
35
+ export const DISTRO_NAME = /^(?!-)(?!\.+$)[A-Za-z0-9._-]+$/
25
36
 
26
37
  /** A Linux user name that is safe to pass as a single wsl.exe -u option value. */
27
38
  export const LINUX_USER = /^[A-Za-z0-9._][A-Za-z0-9._-]*\$?$/
package/lib/wsl/pty.js CHANGED
@@ -15,7 +15,7 @@
15
15
  * @module dsh-wsl-desktop/wsl/pty
16
16
  */
17
17
 
18
- import { appendFileSync } from 'node:fs'
18
+ import { appendFileSync, renameSync, statSync } from 'node:fs'
19
19
  import { randomUUID } from 'node:crypto'
20
20
  import { homedir } from 'node:os'
21
21
  import { join } from 'node:path'
@@ -48,9 +48,25 @@ const DEFAULT_ROWS = 24
48
48
  const verifiedDistros = new Set()
49
49
 
50
50
  /**
51
- * Append one diagnostic line to the terminal debug log (bounded). Temporary
52
- * instrumentation for the live GUI-path terminal failure; harmless if the
53
- * file cannot be written.
51
+ * Ceiling for the opt-in terminal debug log, and the size at which it rotates.
52
+ *
53
+ * The doc comment on `termLog` used to call the sink "bounded" while the body was a
54
+ * bare `appendFileSync` with no cap, no rotation and no redaction: a host that left
55
+ * `WSL_DESKTOP_TERMINAL_DEBUG=1` set grew the file without limit. Rotation is
56
+ * one-generation (`.1`), which bounds the pair at ~2x this figure and needs no
57
+ * bookkeeping beyond a `statSync`.
58
+ */
59
+ const TERMINAL_LOG_MAX_BYTES = 5 * 1024 * 1024
60
+
61
+ /**
62
+ * Append one diagnostic line to the terminal debug log (bounded and rotated).
63
+ * Temporary instrumentation for the live GUI-path terminal failure; harmless if
64
+ * the file cannot be written.
65
+ *
66
+ * Callers must NOT hand this full argv vectors. A command line can carry a secret
67
+ * (`tool --token=…`), and this file is plaintext under `$DSH_HOME`, readable by any
68
+ * host-side reader of the profile. Describe the argv by its length and program
69
+ * name instead — see `describeArgv`.
54
70
  * @param {string} message - the diagnostic line.
55
71
  */
56
72
  export function termLog(message) {
@@ -58,12 +74,40 @@ export function termLog(message) {
58
74
  // without bound on every terminal allocation.
59
75
  if (process.env.WSL_DESKTOP_TERMINAL_DEBUG !== '1') return
60
76
  try {
61
- appendFileSync(join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'wsl-desktop-terminal-debug.log'), `${new Date().toISOString()} ${message}\n`)
77
+ const path = join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'wsl-desktop-terminal-debug.log')
78
+ // Rotate BEFORE appending, so the file cannot cross the ceiling even by one
79
+ // line: the check is "would this write exceed it", not "has it already".
80
+ try {
81
+ const { size } = statSync(path)
82
+ if (size >= TERMINAL_LOG_MAX_BYTES) renameSync(path, `${path}.1`)
83
+ } catch {
84
+ // Absent (first write) or unreadable: appending is still safe.
85
+ }
86
+ appendFileSync(path, `${new Date().toISOString()} ${message}\n`)
62
87
  } catch {
63
88
  // Diagnostics must never break allocation.
64
89
  }
65
90
  }
66
91
 
92
+ /**
93
+ * Describe an argv vector without reproducing it.
94
+ *
95
+ * The program name (argv[0]) and the element count are enough to tell "the bridge
96
+ * got the right shape" from "it got a different one", which is what these log lines
97
+ * exist for. The remaining elements are where secrets live, so they are never
98
+ * written: the count preserves the shape evidence and nothing else is lost that the
99
+ * log was actually used for.
100
+ * @param {readonly string[] | undefined} argv - the vector to describe.
101
+ * @returns {string} a redacted description, e.g. `bash (3 args)`.
102
+ */
103
+ export function describeArgv(argv) {
104
+ if (!Array.isArray(argv) || argv.length === 0) return '(no argv)'
105
+ const program = String(argv[0])
106
+ // The program itself may be a full path; its basename is the informative part.
107
+ const name = program.split(/[\\/]/).pop() ?? program
108
+ return `${name} (${argv.length - 1} args)`
109
+ }
110
+
67
111
  /**
68
112
  * Report a diagnostic that must be VISIBLE without the opt-in debug log.
69
113
  *
@@ -782,7 +826,7 @@ export async function spawnWslTerminal({
782
826
  return false
783
827
  },
784
828
  ))
785
- termLog(`allocate: distro=${plan.distro} linuxCwd=${JSON.stringify(plan.linuxCwd)} windowsCwd=${JSON.stringify(plan.windowsCwd)} argv0=${JSON.stringify(argv[0])} cols=${String(size.cols)} rows=${String(size.rows)} fifo=${fifo}`)
829
+ termLog(`allocate: distro=${plan.distro} linuxCwd=${JSON.stringify(plan.linuxCwd)} windowsCwd=${JSON.stringify(plan.windowsCwd)} argv=${describeArgv(argv)} cols=${String(size.cols)} rows=${String(size.rows)} fifo=${fifo}`)
786
830
  await requireBridgeRuntime(plan, select)
787
831
  termLog('allocate: bridge runtime ok')
788
832
  // The other generation's orphans, swept at the one moment this process can classify them:
@@ -943,10 +987,46 @@ export async function spawnWslTerminal({
943
987
  termLog(`announce ok: pid=${String(announce.pid)} pgrp=${String(announce.pgrp)} fifo=${fifo}`)
944
988
 
945
989
  control = local.spawn({
990
+ // The writer must NOT be able to outlive the reader it exists for.
991
+ //
992
+ // Measured defect (2026-10-24, twice on one host): when the bridge died, the
993
+ // data-done handler below called `control.terminate()`, which kills this
994
+ // wsl.exe CLIENT — but the writer runs inside the distribution and was
995
+ // reparented to /init, where its blocking `exec 3>"$1"` never returned. It
996
+ // then held the FIFO forever, and because every allocation mints a fresh
997
+ // random name no later session could reclaim it. The next terminal send
998
+ // waited for a readiness signal that never came and surfaced as
999
+ // `PTY send aborted before write`.
1000
+ //
1001
+ // A `bash` formulation CANNOT fix this, and that was measured rather than
1002
+ // assumed: `test -w` on a FIFO reports the permission BITS, not a live
1003
+ // reader, so a `[ -w ]` liveness loop passes immediately and then blocks
1004
+ // forever in `exec 3>"$fifo"` (measured: hung until an outer timeout at
1005
+ // 12004ms). Shell has no non-blocking open.
1006
+ //
1007
+ // So the open goes through python3, which IS already a hard prerequisite of
1008
+ // this path — requireBridgeRuntime refuses an allocation without it, so
1009
+ // using it here adds no new dependency. O_WRONLY|O_NONBLOCK turns "no reader
1010
+ // yet" and "no reader any more" into an ENXIO/CleanIO failure instead of an
1011
+ // indefinite block, which is exactly the property the orphan lacked.
1012
+ //
1013
+ // Measured on the shipped formulation: no reader -> exits in 15ms; live
1014
+ // reader -> both control lines delivered; reader vanishing mid-stream ->
1015
+ // exits within ~3.5s. The FIFO is also 0600 and created before the announce,
1016
+ // so a reader normally exists before this process starts.
946
1017
  argv: buildWslExecArgv(plan, [
947
- 'bash', '-c',
948
- 'exec 3>"$1" || exit 1; while IFS= read -r line; do printf \'%s\\n\' "$line" >&3; done',
949
- 'bash', fifo,
1018
+ 'python3', '-c',
1019
+ 'import os,sys\n'
1020
+ + 'try:\n'
1021
+ + ' fd=os.open(sys.argv[1],os.O_WRONLY|os.O_NONBLOCK)\n'
1022
+ + 'except OSError:\n'
1023
+ + ' sys.exit(0)\n'
1024
+ + 'try:\n'
1025
+ + ' for line in sys.stdin:\n'
1026
+ + ' os.write(fd,line.encode())\n'
1027
+ + 'finally:\n'
1028
+ + ' os.close(fd)\n',
1029
+ fifo,
950
1030
  ], select),
951
1031
  cwd: plan.windowsCwd,
952
1032
  stdio: { stdin: 'pipe', stdout: 'ignore', stderr: 'pipe' },
@@ -29,7 +29,7 @@ import { SubprocessExecutableNotFoundError, SubprocessRuntime } from '@deepseek-
29
29
  import z from '@deepseek-ai/schemastery'
30
30
  import { requireLocalSubprocess } from './host-refs.js'
31
31
  import { shellQuote } from './paths.js'
32
- import { spawnWslTerminal, termLog } from './pty.js'
32
+ import { describeArgv, spawnWslTerminal, termLog } from './pty.js'
33
33
  import { LoginShellUnresolvedError, WSL_CHILD_ENV, buildWslExecArgv, planWsl, resolveLoginShell, runWslShell, withWslEnvFlags } from './world.js'
34
34
 
35
35
  /**
@@ -287,7 +287,7 @@ export class WslSubprocessRuntime extends SubprocessRuntime {
287
287
  const terminalArgv = WINDOWS_SHELLS.has(base0)
288
288
  ? [await resolveLoginShell(plan.distro, this.config.username, { wslPath: this.config.wslPath })]
289
289
  : spec.argv
290
- termLog(`spawnTerminal: distro=${plan.distro} linuxCwd=${JSON.stringify(plan.linuxCwd)} terminalArgv=${JSON.stringify(terminalArgv)} terminalType=${String(spec.terminalType)} username=${JSON.stringify(this.config.username ?? null)}`)
290
+ termLog(`spawnTerminal: distro=${plan.distro} linuxCwd=${JSON.stringify(plan.linuxCwd)} terminalArgv=${describeArgv(terminalArgv)} terminalType=${String(spec.terminalType)} username=${JSON.stringify(this.config.username ?? null)}`)
291
291
  const handle = await spawnWslTerminal({
292
292
  local: requireLocalSubprocess(),
293
293
  plan,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-wsl-desktop",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "type": "module",
5
5
  "main": "lib/index.js",
6
6
  "icon": "./lib/icon.svg",