dsh-wsl-desktop 0.7.0 → 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/README.en.md +1 -0
- package/README.md +1 -0
- package/lib/client.js +273 -21
- package/lib/index.js +8 -2
- package/lib/protocol.js +7 -1
- package/lib/release-check.js +69 -0
- package/lib/wsl/doctor.js +16 -3
- package/lib/wsl/paths.js +12 -1
- package/lib/wsl/pty.js +89 -9
- package/lib/wsl/subprocess.js +2 -2
- package/lib/wsl/world.js +53 -0
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -155,6 +155,7 @@ These are real boundaries, not a disclaimer list. Each one tells you where to re
|
|
|
155
155
|
- [Diagnostics](docs/DIAGNOSTICS.md) — error codes, the health check, degradation and the privacy boundary
|
|
156
156
|
- [Engineering notes](docs/ENGINEERING-NOTES.md) — measured constraints, repository layout, release flow, install shape
|
|
157
157
|
- [Distro support matrix](docs/DISTRO-SUPPORT.md) — which distros were verified, and what each one needs
|
|
158
|
+
- [Compatibility and upgrades](docs/COMPATIBILITY.md) — plugin/desktop version relation, upgrade order, rollback steps
|
|
158
159
|
- [Upstream API proposals](docs/UPSTREAM-PROPOSALS.md) — the two proposals to the harness upstream
|
|
159
160
|
- [Architecture and flow diagrams](docs/diagrams.md) — interactive architecture, sequence and lifecycle diagrams
|
|
160
161
|
|
package/README.md
CHANGED
|
@@ -128,6 +128,7 @@ plugin_manager install_bundle dsh-wsl-desktop@<version>
|
|
|
128
128
|
- [诊断](docs/DIAGNOSTICS.md) — 错误码、环境检查、宿主能力缺失时的降级与隐私边界
|
|
129
129
|
- [工程笔记](docs/ENGINEERING-NOTES.md) — 实测约束、仓库布局、发版流程、安装形态
|
|
130
130
|
- [发行版支持矩阵](docs/DISTRO-SUPPORT.md) — 哪些发行版验证过,每种缺什么
|
|
131
|
+
- [兼容性与升级](docs/COMPATIBILITY.md) — 插件与桌面的版本关系、升级顺序、回退步骤
|
|
131
132
|
- [上游 API 提案](docs/UPSTREAM-PROPOSALS.md) — 对 harness 上游的两项提案
|
|
132
133
|
- [架构与流程图](docs/diagrams.md) — 可交互的架构、时序与生命周期图
|
|
133
134
|
|
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
|
|
|
@@ -119,6 +147,10 @@ window.__ModuleLoader__.load({
|
|
|
119
147
|
healthWarn: { zh: ',{count} 项建议处理', en: ', {count} advisory' },
|
|
120
148
|
healthFail: { zh: ',{count} 项失败', en: ', {count} failed' },
|
|
121
149
|
dialogDescription: { zh: '从 WSL 发行版目录创建工作区。', en: 'Create a workspace from a WSL distribution directory.' },
|
|
150
|
+
filterPlaceholder: { zh: '过滤当前目录…', en: 'Filter this directory…' },
|
|
151
|
+
filterLabel: { zh: '按名称过滤目录', en: 'Filter directories by name' },
|
|
152
|
+
filterEmpty: { zh: '没有匹配的子目录', en: 'No matching subdirectories' },
|
|
153
|
+
retryList: { zh: '重试读取', en: 'Try again' },
|
|
122
154
|
}
|
|
123
155
|
|
|
124
156
|
/**
|
|
@@ -524,7 +556,7 @@ window.__ModuleLoader__.load({
|
|
|
524
556
|
* @param {object|null} bootstrap - the bootstrap result.
|
|
525
557
|
* @returns {HTMLElement|null} the node.
|
|
526
558
|
*/
|
|
527
|
-
function renderFailureNode(failure, bootstrap) {
|
|
559
|
+
function renderFailureNode(failure, bootstrap, state = null) {
|
|
528
560
|
if (failure === null) return null
|
|
529
561
|
const catalog = bootstrap?.ok === true ? bootstrap.value?.catalog : null
|
|
530
562
|
const entry = catalog?.codes?.[failure.code]
|
|
@@ -570,9 +602,64 @@ window.__ModuleLoader__.load({
|
|
|
570
602
|
box.append(link)
|
|
571
603
|
}
|
|
572
604
|
box.append(el('div', { fontSize: '11px', color: T.dim }, failure.code))
|
|
605
|
+
if (state !== null) {
|
|
606
|
+
// A failed browse or commit used to offer no report at all; only the
|
|
607
|
+
// degraded card did. The same report is now one click away on every
|
|
608
|
+
// failure, with the same redaction rules.
|
|
609
|
+
const copyAll = el('button', { ...BUTTON_STYLE, padding: '2px 8px', fontSize: '11px', alignSelf: 'flex-start' },
|
|
610
|
+
t('复制诊断', 'Copy diagnostics'))
|
|
611
|
+
copyAll.type = 'button'
|
|
612
|
+
copyAll.setAttribute('data-wsl-diagnostics', '')
|
|
613
|
+
copyAll.addEventListener('click', () => {
|
|
614
|
+
void navigator.clipboard?.writeText?.(buildDiagnostics(state, failure))
|
|
615
|
+
copyAll.textContent = t('已复制', 'Copied')
|
|
616
|
+
announce(t('已复制', 'Copied'))
|
|
617
|
+
})
|
|
618
|
+
box.append(copyAll)
|
|
619
|
+
}
|
|
573
620
|
return box
|
|
574
621
|
}
|
|
575
622
|
|
|
623
|
+
/**
|
|
624
|
+
* Build the copyable diagnostic report.
|
|
625
|
+
*
|
|
626
|
+
* Deliberately excludes the host message, stderr, resolved paths and the raw
|
|
627
|
+
* failure text: it carries only what the catalog already exposes plus the
|
|
628
|
+
* doctor's path-free facts. A failure is described by its code and params.
|
|
629
|
+
* @param {object} state - the open picker's state.
|
|
630
|
+
* @param {object|null} [failure] - the failure to include, if any.
|
|
631
|
+
* @returns {string} pretty JSON.
|
|
632
|
+
*/
|
|
633
|
+
function buildDiagnostics(state, failure = null) {
|
|
634
|
+
const bootstrap = state.bootstrap
|
|
635
|
+
const value = bootstrap?.ok === true ? bootstrap.value : null
|
|
636
|
+
const health = state.health?.status === 'ready'
|
|
637
|
+
? {
|
|
638
|
+
distro: state.health.distro ?? state.distro,
|
|
639
|
+
user: state.health.user ?? state.user,
|
|
640
|
+
family: state.health.family ?? null,
|
|
641
|
+
runner: state.health.runner ?? null,
|
|
642
|
+
restricted: state.health.restricted ?? null,
|
|
643
|
+
checks: (state.health.checks ?? []).map((entry) => ({
|
|
644
|
+
id: entry.id,
|
|
645
|
+
status: entry.status,
|
|
646
|
+
...(typeof entry.detail === 'string' ? { detail: entry.detail } : {}),
|
|
647
|
+
})),
|
|
648
|
+
}
|
|
649
|
+
: null
|
|
650
|
+
return JSON.stringify({
|
|
651
|
+
schema: 1,
|
|
652
|
+
locale: LOCALE,
|
|
653
|
+
pluginVersion: value?.pluginVersion ?? 'unknown',
|
|
654
|
+
catalog: value === null ? 'unavailable' : 'loaded',
|
|
655
|
+
capabilities: value?.capabilities ?? null,
|
|
656
|
+
features: state.features,
|
|
657
|
+
missing: state.missing,
|
|
658
|
+
health,
|
|
659
|
+
error: failure === null ? null : { code: failure.code, params: failure.params ?? {} },
|
|
660
|
+
}, null, 2)
|
|
661
|
+
}
|
|
662
|
+
|
|
576
663
|
/**
|
|
577
664
|
* The degraded card: what is missing, what to do, and a copyable report.
|
|
578
665
|
* @param {object[]} body - the card's child list.
|
|
@@ -603,11 +690,7 @@ window.__ModuleLoader__.load({
|
|
|
603
690
|
const entry = catalog?.codes?.['host-capability-missing']
|
|
604
691
|
const remedy = entry === undefined ? null : renderRemedy(entry.remedy)
|
|
605
692
|
if (remedy !== null) panel.append(remedy)
|
|
606
|
-
const diagnostics =
|
|
607
|
-
pluginVersion: state.bootstrap?.ok === true ? state.bootstrap.value?.pluginVersion ?? 'unknown' : 'unknown',
|
|
608
|
-
features: state.features,
|
|
609
|
-
missing: state.missing,
|
|
610
|
-
}, null, 2)
|
|
693
|
+
const diagnostics = buildDiagnostics(state)
|
|
611
694
|
panel.append(el('pre', {
|
|
612
695
|
margin: '0',
|
|
613
696
|
padding: '8px',
|
|
@@ -720,6 +803,8 @@ window.__ModuleLoader__.load({
|
|
|
720
803
|
home: null,
|
|
721
804
|
path: '/',
|
|
722
805
|
draft: '/',
|
|
806
|
+
/** Case-insensitive substring filter for the current directory. */
|
|
807
|
+
filter: '',
|
|
723
808
|
listing: null,
|
|
724
809
|
/** `{code, params}` or null; the raw host message is never rendered. */
|
|
725
810
|
error: null,
|
|
@@ -933,7 +1018,7 @@ window.__ModuleLoader__.load({
|
|
|
933
1018
|
}
|
|
934
1019
|
if (state.health.status === 'error') {
|
|
935
1020
|
strip.setAttribute('data-wsl-health', 'error')
|
|
936
|
-
const failure = renderFailureNode(state.health.failure, state.bootstrap)
|
|
1021
|
+
const failure = renderFailureNode(state.health.failure, state.bootstrap, state)
|
|
937
1022
|
if (failure !== null) strip.append(failure)
|
|
938
1023
|
const retry = el('button', {
|
|
939
1024
|
...BUTTON_STYLE,
|
|
@@ -1176,7 +1261,7 @@ window.__ModuleLoader__.load({
|
|
|
1176
1261
|
renderDegraded(body, state, close, retryDegraded)
|
|
1177
1262
|
} else {
|
|
1178
1263
|
if (state.bootstrapError !== null) {
|
|
1179
|
-
const failure = renderFailureNode(state.bootstrapError, state.bootstrap)
|
|
1264
|
+
const failure = renderFailureNode(state.bootstrapError, state.bootstrap, state)
|
|
1180
1265
|
if (failure !== null) body.push(failure)
|
|
1181
1266
|
}
|
|
1182
1267
|
|
|
@@ -1214,7 +1299,7 @@ window.__ModuleLoader__.load({
|
|
|
1214
1299
|
body.push(el('div', { display: 'flex', gap: '8px' }, userField, enter))
|
|
1215
1300
|
|
|
1216
1301
|
if (state.error !== null) {
|
|
1217
|
-
const failure = renderFailureNode(state.error, state.bootstrap)
|
|
1302
|
+
const failure = renderFailureNode(state.error, state.bootstrap, state)
|
|
1218
1303
|
if (failure !== null) body.push(failure)
|
|
1219
1304
|
}
|
|
1220
1305
|
|
|
@@ -1263,15 +1348,45 @@ window.__ModuleLoader__.load({
|
|
|
1263
1348
|
border: `1px solid ${T.border}`,
|
|
1264
1349
|
background: T.layer,
|
|
1265
1350
|
})
|
|
1266
|
-
const
|
|
1351
|
+
const allDirectories = (state.listing?.entries ?? []).filter((entry) => entry.kind === 'directory')
|
|
1352
|
+
// Filtering updates the rendered rows in place instead of repainting the
|
|
1353
|
+
// card: a paint per keystroke replaces the input and steals focus (and
|
|
1354
|
+
// breaks IME composition). The initial paint applies the stored filter.
|
|
1355
|
+
const matchesFilter = (name) => state.filter === ''
|
|
1356
|
+
|| name.toLowerCase().includes(state.filter.toLowerCase())
|
|
1357
|
+
const empty = el('div', { fontSize: '12px', color: T.dim, padding: '6px' }, copy('filterEmpty'))
|
|
1358
|
+
empty.style.display = 'none'
|
|
1359
|
+
const applyFilter = () => {
|
|
1360
|
+
let visible = 0
|
|
1361
|
+
for (const node of list.querySelectorAll('[data-wsl-entry]')) {
|
|
1362
|
+
const shown = matchesFilter(node.getAttribute('data-name') ?? '')
|
|
1363
|
+
node.style.display = shown ? 'block' : 'none'
|
|
1364
|
+
if (shown) visible += 1
|
|
1365
|
+
}
|
|
1366
|
+
empty.style.display = visible === 0 ? 'block' : 'none'
|
|
1367
|
+
if (state.filter !== '') empty.setAttribute('data-wsl-filter-empty', '')
|
|
1368
|
+
else empty.removeAttribute('data-wsl-filter-empty')
|
|
1369
|
+
}
|
|
1370
|
+
if (state.listing !== null) {
|
|
1371
|
+
const filter = el('input', FIELD_STYLE)
|
|
1372
|
+
filter.value = state.filter
|
|
1373
|
+
filter.placeholder = copy('filterPlaceholder')
|
|
1374
|
+
filter.setAttribute('data-wsl-filter', '')
|
|
1375
|
+
filter.setAttribute('aria-label', copy('filterLabel'))
|
|
1376
|
+
filter.addEventListener('input', () => {
|
|
1377
|
+
state.filter = filter.value
|
|
1378
|
+
applyFilter()
|
|
1379
|
+
})
|
|
1380
|
+
body.push(filter)
|
|
1381
|
+
}
|
|
1267
1382
|
if (state.listing === null) {
|
|
1268
1383
|
list.append(el('div', { fontSize: '12px', color: T.dim, padding: '6px' }, copy('readingDir')))
|
|
1269
|
-
} else if (
|
|
1384
|
+
} else if (allDirectories.length === 0) {
|
|
1270
1385
|
list.append(el('div', { fontSize: '12px', color: T.dim, padding: '6px' }, copy('noSubdirs')))
|
|
1271
1386
|
} else {
|
|
1272
|
-
for (const entry of
|
|
1387
|
+
for (const entry of allDirectories) {
|
|
1273
1388
|
const item = el('button', {
|
|
1274
|
-
display: 'block',
|
|
1389
|
+
display: matchesFilter(entry.name) ? 'block' : 'none',
|
|
1275
1390
|
width: '100%',
|
|
1276
1391
|
textAlign: 'left',
|
|
1277
1392
|
border: 'none',
|
|
@@ -1284,11 +1399,16 @@ window.__ModuleLoader__.load({
|
|
|
1284
1399
|
cursor: 'pointer',
|
|
1285
1400
|
}, `📁 ${entry.name}`)
|
|
1286
1401
|
item.type = 'button'
|
|
1402
|
+
item.setAttribute('data-wsl-entry', '')
|
|
1403
|
+
item.setAttribute('data-name', entry.name)
|
|
1287
1404
|
item.addEventListener('mouseenter', () => { item.style.background = T.hover })
|
|
1288
1405
|
item.addEventListener('mouseleave', () => { item.style.background = 'transparent' })
|
|
1289
1406
|
item.addEventListener('click', () => { go(joinLinux(state.path, entry.name)) })
|
|
1290
1407
|
list.append(item)
|
|
1291
1408
|
}
|
|
1409
|
+
list.append(empty)
|
|
1410
|
+
if (state.filter !== '') empty.setAttribute('data-wsl-filter-empty', '')
|
|
1411
|
+
applyFilter()
|
|
1292
1412
|
}
|
|
1293
1413
|
body.push(list)
|
|
1294
1414
|
if (typeof state.listing?.hidden === 'number' && state.listing.hidden > 0) {
|
|
@@ -1308,8 +1428,19 @@ window.__ModuleLoader__.load({
|
|
|
1308
1428
|
}
|
|
1309
1429
|
|
|
1310
1430
|
if (state.error !== null) {
|
|
1311
|
-
const failure = renderFailureNode(state.error, state.bootstrap)
|
|
1431
|
+
const failure = renderFailureNode(state.error, state.bootstrap, state)
|
|
1312
1432
|
if (failure !== null) body.push(failure)
|
|
1433
|
+
// A failed listing left the operator with an error and no way back to
|
|
1434
|
+
// the directory they were reading. Retrying the same load is safe; a
|
|
1435
|
+
// failed commit is deliberately excluded because it may have created
|
|
1436
|
+
// the workspace before the error.
|
|
1437
|
+
if (state.listing === null && !state.working) {
|
|
1438
|
+
const retryList = el('button', BUTTON_STYLE, copy('retryList'))
|
|
1439
|
+
retryList.type = 'button'
|
|
1440
|
+
retryList.setAttribute('data-wsl-list-retry', '')
|
|
1441
|
+
retryList.addEventListener('click', () => { void load() })
|
|
1442
|
+
body.push(el('div', { display: 'flex', justifyContent: 'flex-start' }, retryList))
|
|
1443
|
+
}
|
|
1313
1444
|
}
|
|
1314
1445
|
|
|
1315
1446
|
const cancel = el('button', BUTTON_STYLE, copy('cancel'))
|
|
@@ -1373,15 +1504,68 @@ window.__ModuleLoader__.load({
|
|
|
1373
1504
|
/** Whether the missing-workspace-source warning was already printed. */
|
|
1374
1505
|
let warnedWorkspaceSource = false
|
|
1375
1506
|
|
|
1376
|
-
/**
|
|
1377
|
-
|
|
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) {
|
|
1378
1536
|
const matches = []
|
|
1379
|
-
for (const
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
const trigger = path.closest('button')
|
|
1537
|
+
for (const icon of document.querySelectorAll(selector)) {
|
|
1538
|
+
if (!identifies(icon)) continue
|
|
1539
|
+
const trigger = icon.closest('button')
|
|
1383
1540
|
if (trigger !== null && !matches.includes(trigger)) matches.push(trigger)
|
|
1384
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) {
|
|
1385
1569
|
if (matches.length <= 1) return matches[0] ?? null
|
|
1386
1570
|
// More than one control carries the geometry. The shipped trigger is the
|
|
1387
1571
|
// one inside a `*_headerActions` cluster — the CSS-module local name is
|
|
@@ -1391,6 +1575,24 @@ window.__ModuleLoader__.load({
|
|
|
1391
1575
|
return matches.find((trigger) => hasHeaderActionsParent(trigger)) ?? null
|
|
1392
1576
|
}
|
|
1393
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
|
+
|
|
1394
1596
|
/**
|
|
1395
1597
|
* Whether one trigger sits in the shipped trailing action cluster.
|
|
1396
1598
|
* @param {HTMLElement} trigger - a candidate trigger button.
|
|
@@ -1534,7 +1736,7 @@ window.__ModuleLoader__.load({
|
|
|
1534
1736
|
// artwork fills its paths, the collapsed one strokes them. A rebuild is
|
|
1535
1737
|
// owed whenever that flips, which is exactly what expanding a workspace
|
|
1536
1738
|
// does to this span.
|
|
1537
|
-
const solid = shipped
|
|
1739
|
+
const solid = isSolidIcon(shipped)
|
|
1538
1740
|
const weight = solid ? 'solid' : 'outline'
|
|
1539
1741
|
if (mine !== null && mine.dataset.wslCloudWeight !== weight) {
|
|
1540
1742
|
mine.remove()
|
|
@@ -1545,6 +1747,56 @@ window.__ModuleLoader__.load({
|
|
|
1545
1747
|
}
|
|
1546
1748
|
}
|
|
1547
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
|
+
|
|
1548
1800
|
/**
|
|
1549
1801
|
* Whether one observed mutation could have changed a workspace row's markup.
|
|
1550
1802
|
*
|
package/lib/index.js
CHANGED
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
} from './http-admission.js'
|
|
26
26
|
import { buildVariantPlugins } from './wsl/preset.js'
|
|
27
27
|
import { setLocalSubprocess } from './wsl/host-refs.js'
|
|
28
|
-
import { checkLinuxPath, defaultDistro, defaultWorkspaceUnc, listDistros, listLinuxDir, resolveDistroHome, runWslShell } from './wsl/world.js'
|
|
28
|
+
import { checkLinuxPath, defaultDistro, defaultWorkspaceUnc, listDistros, listLinuxDir, resolveDistroHome, runWslShell, wslMajorVersion } from './wsl/world.js'
|
|
29
29
|
import { joinWslUnc, parseWslUnc, shellQuote, windowsToMntPath } from './wsl/paths.js'
|
|
30
30
|
import { WslRouteError, browserFailure, routeErrorFor } from './route-errors.js'
|
|
31
31
|
import { CHECK_IDS, CHECK_LABELS, CHECK_REMEDIES, DOCS, ERROR_CODES, FEATURE_IDS, FEATURE_LABELS } from './protocol.js'
|
|
@@ -506,7 +506,13 @@ const METHODS = {
|
|
|
506
506
|
health: async ({ distro, username }) => {
|
|
507
507
|
assertDistro(distro)
|
|
508
508
|
assertUser(username)
|
|
509
|
-
|
|
509
|
+
// Host-side: the distribution cannot report its own WSL generation.
|
|
510
|
+
return healthFor({
|
|
511
|
+
distro,
|
|
512
|
+
username,
|
|
513
|
+
wslPath: routeWslPath,
|
|
514
|
+
wslVersionProbe: ({ distro: name }) => wslMajorVersion(name, { wslPath: routeWslPath }),
|
|
515
|
+
})
|
|
510
516
|
},
|
|
511
517
|
|
|
512
518
|
/**
|
package/lib/protocol.js
CHANGED
|
@@ -141,7 +141,7 @@ export const FEATURE_LABELS = deepFreeze({
|
|
|
141
141
|
})
|
|
142
142
|
|
|
143
143
|
/** Health-check ids, in display order. */
|
|
144
|
-
export const CHECK_IDS = deepFreeze(['bash', 'python3', 'util-linux', 'getent', 'runner', 'helper', 'nnp'])
|
|
144
|
+
export const CHECK_IDS = deepFreeze(['bash', 'python3', 'util-linux', 'getent', 'runner', 'helper', 'nnp', 'wsl2'])
|
|
145
145
|
|
|
146
146
|
/** Human-readable check names. */
|
|
147
147
|
export const CHECK_LABELS = deepFreeze({
|
|
@@ -152,6 +152,7 @@ export const CHECK_LABELS = deepFreeze({
|
|
|
152
152
|
runner: { zh: '受限模式 runner', en: 'Confinement runner' },
|
|
153
153
|
helper: { zh: 'dsh-wsl-confine helper', en: 'dsh-wsl-confine helper' },
|
|
154
154
|
nnp: { zh: 'NO_NEW_PRIVS', en: 'NO_NEW_PRIVS' },
|
|
155
|
+
wsl2: { zh: 'WSL2', en: 'WSL2' },
|
|
155
156
|
})
|
|
156
157
|
|
|
157
158
|
/** The fix for one failed check, keyed by check id. */
|
|
@@ -209,6 +210,11 @@ export const CHECK_REMEDIES = deepFreeze({
|
|
|
209
210
|
zh: 'setpriv 不支持 --no-new-privs,受限模式不会运行;升级发行版的 util-linux。',
|
|
210
211
|
en: 'setpriv lacks --no-new-privs, so restricted mode refuses to run; upgrade the distribution util-linux.',
|
|
211
212
|
},
|
|
213
|
+
wsl2: {
|
|
214
|
+
kind: 'text',
|
|
215
|
+
zh: 'WSL1 不支持:在 Windows 侧运行 `wsl.exe --set-version <发行版> 2` 升级到 WSL2 后再试。',
|
|
216
|
+
en: 'WSL1 is unsupported: run `wsl.exe --set-version <distro> 2` on Windows to upgrade it to WSL2 first.',
|
|
217
|
+
},
|
|
212
218
|
})
|
|
213
219
|
|
|
214
220
|
/**
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure publish-readiness rules.
|
|
3
|
+
*
|
|
4
|
+
* The 0.7.0 release learned two preconditions the hard way: an expired npm token
|
|
5
|
+
* (checked by the caller, not here) and pnpm's `minimumReleaseAge` refusing a
|
|
6
|
+
* just-published version during `install_bundle`. These rules are pure so the
|
|
7
|
+
* release runbook and the offline suite can pin them without a registry.
|
|
8
|
+
* @module dsh-wsl-desktop/release-check
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Compare two dotted numeric versions.
|
|
13
|
+
* @param {string} a - the left version.
|
|
14
|
+
* @param {string} b - the right version.
|
|
15
|
+
* @returns {number} -1, 0 or 1.
|
|
16
|
+
*/
|
|
17
|
+
export function compareVersions(a, b) {
|
|
18
|
+
const left = String(a).split('.').map((part) => Number.parseInt(part, 10) || 0)
|
|
19
|
+
const right = String(b).split('.').map((part) => Number.parseInt(part, 10) || 0)
|
|
20
|
+
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
|
|
21
|
+
const difference = (left[index] ?? 0) - (right[index] ?? 0)
|
|
22
|
+
if (difference !== 0) return difference > 0 ? 1 : -1
|
|
23
|
+
}
|
|
24
|
+
return 0
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Decide whether a version can be published.
|
|
29
|
+
* @param {{ target: string, latest: string|null, published?: boolean, localTag?: boolean, remoteTag?: boolean, dirty?: boolean }} input -
|
|
30
|
+
* the target and the facts gathered by the caller.
|
|
31
|
+
* @returns {{ ready: boolean, blockers: string[] }} the verdict.
|
|
32
|
+
*/
|
|
33
|
+
export function classifyPublish({ target, latest, published = false, localTag = false, remoteTag = false, dirty = false }) {
|
|
34
|
+
const blockers = []
|
|
35
|
+
if (latest === null || latest === undefined) {
|
|
36
|
+
blockers.push('the registry latest version is unknown (npm view failed)')
|
|
37
|
+
} else if (compareVersions(target, latest) <= 0) {
|
|
38
|
+
blockers.push(`target ${target} is not newer than the registry latest ${latest}`)
|
|
39
|
+
}
|
|
40
|
+
if (published === true) blockers.push(`target ${target} is already published`)
|
|
41
|
+
if (localTag === true) blockers.push(`a local tag v${target} already exists`)
|
|
42
|
+
if (remoteTag === true) blockers.push(`a remote tag v${target} already exists`)
|
|
43
|
+
if (dirty === true) blockers.push('the working tree carries tracked changes')
|
|
44
|
+
return { ready: blockers.length === 0, blockers }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* When a freshly published version becomes installable under pnpm's minimum age.
|
|
49
|
+
* @param {{ publishedAt: string|null, minimumReleaseAgeMinutes: number, now: Date }} input -
|
|
50
|
+
* the registry publish time, the configured window and the current time.
|
|
51
|
+
* @returns {{ ready: boolean|null, readyAt: Date|null, detail: string }} the verdict.
|
|
52
|
+
*/
|
|
53
|
+
export function installWindow({ publishedAt, minimumReleaseAgeMinutes, now = new Date() }) {
|
|
54
|
+
if (publishedAt === null || publishedAt === undefined || publishedAt === '') {
|
|
55
|
+
return { ready: null, readyAt: null, detail: 'the registry publish time is unreadable; wait one full window before retrying' }
|
|
56
|
+
}
|
|
57
|
+
const published = new Date(publishedAt)
|
|
58
|
+
if (Number.isNaN(published.getTime()) || !Number.isFinite(minimumReleaseAgeMinutes)) {
|
|
59
|
+
return { ready: null, readyAt: null, detail: 'the publish time or the minimum age is unreadable' }
|
|
60
|
+
}
|
|
61
|
+
const readyAt = new Date(published.getTime() + minimumReleaseAgeMinutes * 60_000)
|
|
62
|
+
return {
|
|
63
|
+
ready: now.getTime() >= readyAt.getTime(),
|
|
64
|
+
readyAt,
|
|
65
|
+
detail: now.getTime() >= readyAt.getTime()
|
|
66
|
+
? 'outside the minimumReleaseAge window'
|
|
67
|
+
: `inside the minimumReleaseAge window until ${readyAt.toISOString()}`,
|
|
68
|
+
}
|
|
69
|
+
}
|
package/lib/wsl/doctor.js
CHANGED
|
@@ -192,7 +192,7 @@ async function probeHelperVersion({ distro, username, run, wslPath }) {
|
|
|
192
192
|
* @param {object} options - probe inputs.
|
|
193
193
|
* @returns {Promise<object>} the health answer.
|
|
194
194
|
*/
|
|
195
|
-
async function computeHealth({ distro, username, run, wslPath, now, runnerProbe, nnpProbe, helperProbe }) {
|
|
195
|
+
async function computeHealth({ distro, username, run, wslPath, now, runnerProbe, nnpProbe, helperProbe, wslVersionProbe }) {
|
|
196
196
|
// The cached probes call `run` themselves and do not know about the plugin's
|
|
197
197
|
// configured wsl.exe path, so the doctor binds it once for every probe it hands
|
|
198
198
|
// them. Without this they silently fall back to the bare `wsl.exe`.
|
|
@@ -206,11 +206,16 @@ async function computeHealth({ distro, username, run, wslPath, now, runnerProbe,
|
|
|
206
206
|
run: probeRun,
|
|
207
207
|
cacheKey: typeof wslPath === 'string' ? wslPath : '',
|
|
208
208
|
}
|
|
209
|
-
const [values, runnerOutcome, nnpOutcome] = await Promise.all([
|
|
209
|
+
const [values, runnerOutcome, nnpOutcome, wslVersionOutcome] = await Promise.all([
|
|
210
210
|
runBatch({ distro, username, run, wslPath }),
|
|
211
211
|
bounded(Promise.resolve().then(() => runnerProbe(probeOptions)), PROBE_RACE_MS),
|
|
212
212
|
bounded(Promise.resolve().then(() => nnpProbe(probeOptions)), PROBE_RACE_MS),
|
|
213
|
+
// Host-side, not in-distro: a WSL1 distribution's mount-namespace semantics
|
|
214
|
+
// are not visible from inside it. The route passes a real probe; the suite
|
|
215
|
+
// injects one, so no new spawn leaks into the existing cases.
|
|
216
|
+
bounded(Promise.resolve().then(() => wslVersionProbe({ distro, wslPath })), PROBE_RACE_MS),
|
|
213
217
|
])
|
|
218
|
+
const wslVersion = wslVersionOutcome?.value ?? null
|
|
214
219
|
|
|
215
220
|
const measured = Object.keys(values).length > 0
|
|
216
221
|
const family = distroFamily(values)
|
|
@@ -290,6 +295,13 @@ async function computeHealth({ distro, username, run, wslPath, now, runnerProbe,
|
|
|
290
295
|
} else {
|
|
291
296
|
add('nnp', 'unknown', 'unmeasured')
|
|
292
297
|
}
|
|
298
|
+
if (wslVersion === '1') {
|
|
299
|
+
add('wsl2', 'fail', 'WSL1 distribution; the mount-namespace boundary needs WSL2')
|
|
300
|
+
} else if (wslVersion === '2') {
|
|
301
|
+
add('wsl2', 'pass', 'WSL2')
|
|
302
|
+
} else {
|
|
303
|
+
add('wsl2', 'unknown', 'not measured')
|
|
304
|
+
}
|
|
293
305
|
}
|
|
294
306
|
|
|
295
307
|
const statusOf = (id) => checks.find((entry) => entry.id === id)?.status
|
|
@@ -320,6 +332,7 @@ export async function healthFor({
|
|
|
320
332
|
runnerProbe = detectRunner,
|
|
321
333
|
nnpProbe = detectNoNewPrivs,
|
|
322
334
|
helperProbe = probeHelperVersion,
|
|
335
|
+
wslVersionProbe = async () => null,
|
|
323
336
|
}) {
|
|
324
337
|
const key = `${distro}\0${username ?? ''}\0${wslPath ?? ''}`
|
|
325
338
|
const cached = healthCache.get(key)
|
|
@@ -328,7 +341,7 @@ export async function healthFor({
|
|
|
328
341
|
}
|
|
329
342
|
const inflight = healthInflight.get(key)
|
|
330
343
|
if (inflight !== undefined) return inflight
|
|
331
|
-
const pending = computeHealth({ distro, username, run, wslPath, now, runnerProbe, nnpProbe, helperProbe })
|
|
344
|
+
const pending = computeHealth({ distro, username, run, wslPath, now, runnerProbe, nnpProbe, helperProbe, wslVersionProbe })
|
|
332
345
|
.then((result) => {
|
|
333
346
|
healthCache.set(key, { at: now(), result })
|
|
334
347
|
// The key carries a browser-supplied user; a page could churn spellings to
|
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
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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
|
-
|
|
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)}
|
|
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
|
-
'
|
|
948
|
-
'
|
|
949
|
-
|
|
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' },
|
package/lib/wsl/subprocess.js
CHANGED
|
@@ -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=${
|
|
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/lib/wsl/world.js
CHANGED
|
@@ -231,6 +231,59 @@ export async function listDistros({ wslPath = 'wsl.exe' } = {}) {
|
|
|
231
231
|
.filter((line) => line.length > 0)
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
+
/**
|
|
235
|
+
* The WSL major version of one distribution, from `wsl.exe -l -v`.
|
|
236
|
+
*
|
|
237
|
+
* WSL1 is explicitly unsupported (its syscall translation layer has no mount
|
|
238
|
+
* namespace), but until now nothing in the product asked the host which version a
|
|
239
|
+
* distribution runs. The output is a table:
|
|
240
|
+
*
|
|
241
|
+
* ` NAME STATE VERSION`
|
|
242
|
+
* `* debian Running 2`
|
|
243
|
+
*
|
|
244
|
+
* The active marker is optional; the version is the last column. A table that
|
|
245
|
+
* cannot be parsed answers `null` — an unmeasured fact is not a failure.
|
|
246
|
+
* @param {string} distro - the distribution name.
|
|
247
|
+
* @param {{ wslPath?: string }} [options] - the configured `wsl.exe` path.
|
|
248
|
+
* @returns {Promise<'1'|'2'|null>} the major version, or null when unreadable.
|
|
249
|
+
*/
|
|
250
|
+
export async function wslMajorVersion(distro, { wslPath = 'wsl.exe' } = {}) {
|
|
251
|
+
let stdout
|
|
252
|
+
try {
|
|
253
|
+
({ stdout } = await execFileAsync(wslPath, ['-l', '-v'], {
|
|
254
|
+
encoding: 'buffer',
|
|
255
|
+
timeout: DISCOVERY_TIMEOUT_MS,
|
|
256
|
+
env: { ...process.env, ...WSL_CHILD_ENV },
|
|
257
|
+
}))
|
|
258
|
+
} catch {
|
|
259
|
+
return null
|
|
260
|
+
}
|
|
261
|
+
return parseWslVersionTable(decodeWslOutput(stdout), distro)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Pick one distribution's major version out of a `wsl.exe -l -v` table.
|
|
266
|
+
*
|
|
267
|
+
* Kept pure and exported so the table's spacing variants are testable without a
|
|
268
|
+
* real WSL host. The active marker (`*`) is optional; the last column is VERSION.
|
|
269
|
+
* @param {unknown} text - the decoded table.
|
|
270
|
+
* @param {string} distro - the distribution name.
|
|
271
|
+
* @returns {'1'|'2'|null} the major version, or null when the row is absent.
|
|
272
|
+
*/
|
|
273
|
+
export function parseWslVersionTable(text, distro) {
|
|
274
|
+
const lines = String(text ?? '').split(/\r?\n/)
|
|
275
|
+
for (const raw of lines) {
|
|
276
|
+
const line = raw.replace(/\0/g, '').trim()
|
|
277
|
+
if (line === '') continue
|
|
278
|
+
const cells = line.replace(/^\*\s*/, '').split(/\s{2,}|\t/).filter((cell) => cell !== '')
|
|
279
|
+
if (cells.length < 2) continue
|
|
280
|
+
if (cells[0] !== distro) continue
|
|
281
|
+
const version = cells[cells.length - 1]
|
|
282
|
+
return version === '1' || version === '2' ? version : null
|
|
283
|
+
}
|
|
284
|
+
return null
|
|
285
|
+
}
|
|
286
|
+
|
|
234
287
|
/**
|
|
235
288
|
* The Lxss registry values and the two REG_SZ lines the default-distribution read needs.
|
|
236
289
|
*
|