dsh-wsl-desktop 0.7.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +1 -0
- package/README.md +1 -0
- package/lib/client.js +117 -14
- 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/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
|
@@ -119,6 +119,10 @@ window.__ModuleLoader__.load({
|
|
|
119
119
|
healthWarn: { zh: ',{count} 项建议处理', en: ', {count} advisory' },
|
|
120
120
|
healthFail: { zh: ',{count} 项失败', en: ', {count} failed' },
|
|
121
121
|
dialogDescription: { zh: '从 WSL 发行版目录创建工作区。', en: 'Create a workspace from a WSL distribution directory.' },
|
|
122
|
+
filterPlaceholder: { zh: '过滤当前目录…', en: 'Filter this directory…' },
|
|
123
|
+
filterLabel: { zh: '按名称过滤目录', en: 'Filter directories by name' },
|
|
124
|
+
filterEmpty: { zh: '没有匹配的子目录', en: 'No matching subdirectories' },
|
|
125
|
+
retryList: { zh: '重试读取', en: 'Try again' },
|
|
122
126
|
}
|
|
123
127
|
|
|
124
128
|
/**
|
|
@@ -524,7 +528,7 @@ window.__ModuleLoader__.load({
|
|
|
524
528
|
* @param {object|null} bootstrap - the bootstrap result.
|
|
525
529
|
* @returns {HTMLElement|null} the node.
|
|
526
530
|
*/
|
|
527
|
-
function renderFailureNode(failure, bootstrap) {
|
|
531
|
+
function renderFailureNode(failure, bootstrap, state = null) {
|
|
528
532
|
if (failure === null) return null
|
|
529
533
|
const catalog = bootstrap?.ok === true ? bootstrap.value?.catalog : null
|
|
530
534
|
const entry = catalog?.codes?.[failure.code]
|
|
@@ -570,9 +574,64 @@ window.__ModuleLoader__.load({
|
|
|
570
574
|
box.append(link)
|
|
571
575
|
}
|
|
572
576
|
box.append(el('div', { fontSize: '11px', color: T.dim }, failure.code))
|
|
577
|
+
if (state !== null) {
|
|
578
|
+
// A failed browse or commit used to offer no report at all; only the
|
|
579
|
+
// degraded card did. The same report is now one click away on every
|
|
580
|
+
// failure, with the same redaction rules.
|
|
581
|
+
const copyAll = el('button', { ...BUTTON_STYLE, padding: '2px 8px', fontSize: '11px', alignSelf: 'flex-start' },
|
|
582
|
+
t('复制诊断', 'Copy diagnostics'))
|
|
583
|
+
copyAll.type = 'button'
|
|
584
|
+
copyAll.setAttribute('data-wsl-diagnostics', '')
|
|
585
|
+
copyAll.addEventListener('click', () => {
|
|
586
|
+
void navigator.clipboard?.writeText?.(buildDiagnostics(state, failure))
|
|
587
|
+
copyAll.textContent = t('已复制', 'Copied')
|
|
588
|
+
announce(t('已复制', 'Copied'))
|
|
589
|
+
})
|
|
590
|
+
box.append(copyAll)
|
|
591
|
+
}
|
|
573
592
|
return box
|
|
574
593
|
}
|
|
575
594
|
|
|
595
|
+
/**
|
|
596
|
+
* Build the copyable diagnostic report.
|
|
597
|
+
*
|
|
598
|
+
* Deliberately excludes the host message, stderr, resolved paths and the raw
|
|
599
|
+
* failure text: it carries only what the catalog already exposes plus the
|
|
600
|
+
* doctor's path-free facts. A failure is described by its code and params.
|
|
601
|
+
* @param {object} state - the open picker's state.
|
|
602
|
+
* @param {object|null} [failure] - the failure to include, if any.
|
|
603
|
+
* @returns {string} pretty JSON.
|
|
604
|
+
*/
|
|
605
|
+
function buildDiagnostics(state, failure = null) {
|
|
606
|
+
const bootstrap = state.bootstrap
|
|
607
|
+
const value = bootstrap?.ok === true ? bootstrap.value : null
|
|
608
|
+
const health = state.health?.status === 'ready'
|
|
609
|
+
? {
|
|
610
|
+
distro: state.health.distro ?? state.distro,
|
|
611
|
+
user: state.health.user ?? state.user,
|
|
612
|
+
family: state.health.family ?? null,
|
|
613
|
+
runner: state.health.runner ?? null,
|
|
614
|
+
restricted: state.health.restricted ?? null,
|
|
615
|
+
checks: (state.health.checks ?? []).map((entry) => ({
|
|
616
|
+
id: entry.id,
|
|
617
|
+
status: entry.status,
|
|
618
|
+
...(typeof entry.detail === 'string' ? { detail: entry.detail } : {}),
|
|
619
|
+
})),
|
|
620
|
+
}
|
|
621
|
+
: null
|
|
622
|
+
return JSON.stringify({
|
|
623
|
+
schema: 1,
|
|
624
|
+
locale: LOCALE,
|
|
625
|
+
pluginVersion: value?.pluginVersion ?? 'unknown',
|
|
626
|
+
catalog: value === null ? 'unavailable' : 'loaded',
|
|
627
|
+
capabilities: value?.capabilities ?? null,
|
|
628
|
+
features: state.features,
|
|
629
|
+
missing: state.missing,
|
|
630
|
+
health,
|
|
631
|
+
error: failure === null ? null : { code: failure.code, params: failure.params ?? {} },
|
|
632
|
+
}, null, 2)
|
|
633
|
+
}
|
|
634
|
+
|
|
576
635
|
/**
|
|
577
636
|
* The degraded card: what is missing, what to do, and a copyable report.
|
|
578
637
|
* @param {object[]} body - the card's child list.
|
|
@@ -603,11 +662,7 @@ window.__ModuleLoader__.load({
|
|
|
603
662
|
const entry = catalog?.codes?.['host-capability-missing']
|
|
604
663
|
const remedy = entry === undefined ? null : renderRemedy(entry.remedy)
|
|
605
664
|
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)
|
|
665
|
+
const diagnostics = buildDiagnostics(state)
|
|
611
666
|
panel.append(el('pre', {
|
|
612
667
|
margin: '0',
|
|
613
668
|
padding: '8px',
|
|
@@ -720,6 +775,8 @@ window.__ModuleLoader__.load({
|
|
|
720
775
|
home: null,
|
|
721
776
|
path: '/',
|
|
722
777
|
draft: '/',
|
|
778
|
+
/** Case-insensitive substring filter for the current directory. */
|
|
779
|
+
filter: '',
|
|
723
780
|
listing: null,
|
|
724
781
|
/** `{code, params}` or null; the raw host message is never rendered. */
|
|
725
782
|
error: null,
|
|
@@ -933,7 +990,7 @@ window.__ModuleLoader__.load({
|
|
|
933
990
|
}
|
|
934
991
|
if (state.health.status === 'error') {
|
|
935
992
|
strip.setAttribute('data-wsl-health', 'error')
|
|
936
|
-
const failure = renderFailureNode(state.health.failure, state.bootstrap)
|
|
993
|
+
const failure = renderFailureNode(state.health.failure, state.bootstrap, state)
|
|
937
994
|
if (failure !== null) strip.append(failure)
|
|
938
995
|
const retry = el('button', {
|
|
939
996
|
...BUTTON_STYLE,
|
|
@@ -1176,7 +1233,7 @@ window.__ModuleLoader__.load({
|
|
|
1176
1233
|
renderDegraded(body, state, close, retryDegraded)
|
|
1177
1234
|
} else {
|
|
1178
1235
|
if (state.bootstrapError !== null) {
|
|
1179
|
-
const failure = renderFailureNode(state.bootstrapError, state.bootstrap)
|
|
1236
|
+
const failure = renderFailureNode(state.bootstrapError, state.bootstrap, state)
|
|
1180
1237
|
if (failure !== null) body.push(failure)
|
|
1181
1238
|
}
|
|
1182
1239
|
|
|
@@ -1214,7 +1271,7 @@ window.__ModuleLoader__.load({
|
|
|
1214
1271
|
body.push(el('div', { display: 'flex', gap: '8px' }, userField, enter))
|
|
1215
1272
|
|
|
1216
1273
|
if (state.error !== null) {
|
|
1217
|
-
const failure = renderFailureNode(state.error, state.bootstrap)
|
|
1274
|
+
const failure = renderFailureNode(state.error, state.bootstrap, state)
|
|
1218
1275
|
if (failure !== null) body.push(failure)
|
|
1219
1276
|
}
|
|
1220
1277
|
|
|
@@ -1263,15 +1320,45 @@ window.__ModuleLoader__.load({
|
|
|
1263
1320
|
border: `1px solid ${T.border}`,
|
|
1264
1321
|
background: T.layer,
|
|
1265
1322
|
})
|
|
1266
|
-
const
|
|
1323
|
+
const allDirectories = (state.listing?.entries ?? []).filter((entry) => entry.kind === 'directory')
|
|
1324
|
+
// Filtering updates the rendered rows in place instead of repainting the
|
|
1325
|
+
// card: a paint per keystroke replaces the input and steals focus (and
|
|
1326
|
+
// breaks IME composition). The initial paint applies the stored filter.
|
|
1327
|
+
const matchesFilter = (name) => state.filter === ''
|
|
1328
|
+
|| name.toLowerCase().includes(state.filter.toLowerCase())
|
|
1329
|
+
const empty = el('div', { fontSize: '12px', color: T.dim, padding: '6px' }, copy('filterEmpty'))
|
|
1330
|
+
empty.style.display = 'none'
|
|
1331
|
+
const applyFilter = () => {
|
|
1332
|
+
let visible = 0
|
|
1333
|
+
for (const node of list.querySelectorAll('[data-wsl-entry]')) {
|
|
1334
|
+
const shown = matchesFilter(node.getAttribute('data-name') ?? '')
|
|
1335
|
+
node.style.display = shown ? 'block' : 'none'
|
|
1336
|
+
if (shown) visible += 1
|
|
1337
|
+
}
|
|
1338
|
+
empty.style.display = visible === 0 ? 'block' : 'none'
|
|
1339
|
+
if (state.filter !== '') empty.setAttribute('data-wsl-filter-empty', '')
|
|
1340
|
+
else empty.removeAttribute('data-wsl-filter-empty')
|
|
1341
|
+
}
|
|
1342
|
+
if (state.listing !== null) {
|
|
1343
|
+
const filter = el('input', FIELD_STYLE)
|
|
1344
|
+
filter.value = state.filter
|
|
1345
|
+
filter.placeholder = copy('filterPlaceholder')
|
|
1346
|
+
filter.setAttribute('data-wsl-filter', '')
|
|
1347
|
+
filter.setAttribute('aria-label', copy('filterLabel'))
|
|
1348
|
+
filter.addEventListener('input', () => {
|
|
1349
|
+
state.filter = filter.value
|
|
1350
|
+
applyFilter()
|
|
1351
|
+
})
|
|
1352
|
+
body.push(filter)
|
|
1353
|
+
}
|
|
1267
1354
|
if (state.listing === null) {
|
|
1268
1355
|
list.append(el('div', { fontSize: '12px', color: T.dim, padding: '6px' }, copy('readingDir')))
|
|
1269
|
-
} else if (
|
|
1356
|
+
} else if (allDirectories.length === 0) {
|
|
1270
1357
|
list.append(el('div', { fontSize: '12px', color: T.dim, padding: '6px' }, copy('noSubdirs')))
|
|
1271
1358
|
} else {
|
|
1272
|
-
for (const entry of
|
|
1359
|
+
for (const entry of allDirectories) {
|
|
1273
1360
|
const item = el('button', {
|
|
1274
|
-
display: 'block',
|
|
1361
|
+
display: matchesFilter(entry.name) ? 'block' : 'none',
|
|
1275
1362
|
width: '100%',
|
|
1276
1363
|
textAlign: 'left',
|
|
1277
1364
|
border: 'none',
|
|
@@ -1284,11 +1371,16 @@ window.__ModuleLoader__.load({
|
|
|
1284
1371
|
cursor: 'pointer',
|
|
1285
1372
|
}, `📁 ${entry.name}`)
|
|
1286
1373
|
item.type = 'button'
|
|
1374
|
+
item.setAttribute('data-wsl-entry', '')
|
|
1375
|
+
item.setAttribute('data-name', entry.name)
|
|
1287
1376
|
item.addEventListener('mouseenter', () => { item.style.background = T.hover })
|
|
1288
1377
|
item.addEventListener('mouseleave', () => { item.style.background = 'transparent' })
|
|
1289
1378
|
item.addEventListener('click', () => { go(joinLinux(state.path, entry.name)) })
|
|
1290
1379
|
list.append(item)
|
|
1291
1380
|
}
|
|
1381
|
+
list.append(empty)
|
|
1382
|
+
if (state.filter !== '') empty.setAttribute('data-wsl-filter-empty', '')
|
|
1383
|
+
applyFilter()
|
|
1292
1384
|
}
|
|
1293
1385
|
body.push(list)
|
|
1294
1386
|
if (typeof state.listing?.hidden === 'number' && state.listing.hidden > 0) {
|
|
@@ -1308,8 +1400,19 @@ window.__ModuleLoader__.load({
|
|
|
1308
1400
|
}
|
|
1309
1401
|
|
|
1310
1402
|
if (state.error !== null) {
|
|
1311
|
-
const failure = renderFailureNode(state.error, state.bootstrap)
|
|
1403
|
+
const failure = renderFailureNode(state.error, state.bootstrap, state)
|
|
1312
1404
|
if (failure !== null) body.push(failure)
|
|
1405
|
+
// A failed listing left the operator with an error and no way back to
|
|
1406
|
+
// the directory they were reading. Retrying the same load is safe; a
|
|
1407
|
+
// failed commit is deliberately excluded because it may have created
|
|
1408
|
+
// the workspace before the error.
|
|
1409
|
+
if (state.listing === null && !state.working) {
|
|
1410
|
+
const retryList = el('button', BUTTON_STYLE, copy('retryList'))
|
|
1411
|
+
retryList.type = 'button'
|
|
1412
|
+
retryList.setAttribute('data-wsl-list-retry', '')
|
|
1413
|
+
retryList.addEventListener('click', () => { void load() })
|
|
1414
|
+
body.push(el('div', { display: 'flex', justifyContent: 'flex-start' }, retryList))
|
|
1415
|
+
}
|
|
1313
1416
|
}
|
|
1314
1417
|
|
|
1315
1418
|
const cancel = el('button', BUTTON_STYLE, copy('cancel'))
|
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/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
|
*
|